diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d7417d..49d567b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12", "3.13"] + python-version: ["3.13", "3.14"] steps: - name: Checkout code @@ -21,6 +21,7 @@ jobs: uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install Poetry uses: snok/install-poetry@v1 @@ -62,7 +63,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: "3.13" - name: Install Poetry uses: snok/install-poetry@v1 @@ -96,7 +97,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: "3.13" - name: Install Poetry uses: snok/install-poetry@v1 diff --git a/.markdownlint.json b/.markdownlint.json index e499038..c94da1f 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -3,6 +3,9 @@ "MD013": { "line_length": 256 }, + "MD024": { + "siblings_only": true + }, "MD033": false, "MD041": false } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 214c1e8..9331468 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -71,14 +71,12 @@ repos: hooks: - id: mypy additional_dependencies: - - numpy - httpx - - attrs - - python-dateutil - click - typing-extensions - pytest - types-PyYAML + - paho-mqtt args: ['--config-file=pyproject.toml'] exclude: '^src/span_panel_api/generated_client/.*|scripts/.*|tests/.*|docs/.*|examples/.*|\..*_cache/.*|dist/.*|venv/.*' @@ -88,14 +86,12 @@ repos: hooks: - id: pylint additional_dependencies: - - numpy - httpx - - attrs - - python-dateutil - click - typing-extensions - pytest - pyyaml + - paho-mqtt exclude: '^src/span_panel_api/generated_client/.*|tests/.*|generate_client\.py|scripts/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|^examples/.*' # Check for common security issues diff --git a/CHANGELOG.md b/CHANGELOG.md index 79bd27f..951d358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,38 +4,98 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.0] - 02/2026 + +v2.0.0 is a ground-up rewrite. The REST/OpenAPI transport has been removed entirely in favor of MQTT/Homie — the SPAN Panel's native v2 protocol. This is a breaking change: all consumer code must be updated to use the new API surface. + +### v1.x Sunset + +Package versions prior to 2.0.0 depend on the SPAN v1 REST API. SPAN will sunset v1 firmware at the end of 2026, at which point v1.x releases of this package will cease to function. Users should upgrade to 2.0.0. + +### Breaking Changes + +- **REST transport removed** — `SpanPanelClient`, `SpanRestClient`, the `generated_client/` OpenAPI layer, and all REST-related modules have been deleted +- **No more polling** — `get_status()`, `get_panel_state()`, `get_circuits()`, `get_storage_soe()` replaced by `get_snapshot()` returning a single `SpanPanelSnapshot` +- **Protocol-based API** — consumers code against `SpanPanelClientProtocol`, `CircuitControlProtocol`, and `StreamingCapableProtocol` (PEP 544), not concrete classes +- **Authentication changed** — passphrase-based v2 registration via `register_v2()` replaces v1 token-based auth; factory handles this automatically +- **paho-mqtt is now required** — moved from optional `[mqtt]` extra to a core dependency +- **Circuit IDs are UUIDs** — dashless UUID strings replace integer circuit IDs +- **Shed priority values changed** — v2 uses `NEVER` / `SOC_THRESHOLD` / `OFF_GRID` instead of v1's `MUST_HAVE` / `NICE_TO_HAVE` / `NON_ESSENTIAL` +- **`SpanPanelRetriableError` removed** — retry logic is no longer in the library (no REST polling) +- **`set_async_delay_func()` removed** — no retry delay hook needed for MQTT transport +- **`cache_window` parameter removed** — no caching needed; MQTT delivers state changes in real time +- **`attrs`, `python-dateutil` dependencies removed** + +### Added + +- **MQTT/Homie transport** (`span_panel_api.mqtt`): + - `SpanMqttClient` — implements all three protocols (panel, circuit control, streaming) + - `AsyncMqttBridge` — paho-mqtt v2 wrapper with TLS/WebSocket, event-loop-driven socket I/O (no threads) + - `HomieDeviceConsumer` — Homie v5 state machine parsing MQTT topics into snapshots + - `MqttClientConfig` — frozen configuration with transport type and TLS settings +- **Snapshot dataclasses** — immutable `SpanPanelSnapshot`, `SpanCircuitSnapshot`, `SpanBatterySnapshot`, `SpanPVSnapshot` with v2-native fields +- **v2 auth functions** — `register_v2()`, `download_ca_cert()`, `get_homie_schema()`, `regenerate_passphrase()` +- **API version detection** — `detect_api_version()` probes `/api/v2/status` and returns `DetectionResult` +- **Factory function** — `create_span_client()` handles registration and returns a configured `SpanMqttClient` +- **PV/BESS metadata** — vendor name, product name, nameplate capacity parsed from Homie device tree +- **Power flows** — `power_flow_pv`, `power_flow_battery`, `power_flow_grid`, `power_flow_site` on panel snapshot +- **Lugs current** — per-phase upstream/downstream current (A) on panel snapshot +- **Per-leg voltages** — `l1_voltage`, `l2_voltage` on panel snapshot +- **Panel metadata** — `dominant_power_source`, `vendor_cloud`, `wifi_ssid`, `panel_size`, `main_breaker_rating_a` +- **Streaming callbacks** — `register_snapshot_callback()` + `start_streaming()` / `stop_streaming()` for real-time push +- **Snapshot debounce** — `snapshot_interval` parameter on `SpanMqttClient` (default 1.0s) rate-limits `build_snapshot()` + callback dispatch; set to 0 for immediate (no debounce). Runtime adjustment via `set_snapshot_interval()` +- **`PanelCapability` flag enum** — runtime feature advertisement (`EBUS_MQTT`, `PUSH_STREAMING`, `CIRCUIT_CONTROL`, `BATTERY_SOE`) + +### Changed + +- `412 Precondition Failed` now treated as auth error (`AUTH_ERROR_CODES` updated) +- Version bumped from 1.1.14 to 2.0.0 +- Python requirement relaxed to `>=3.10` (from `3.12+`) + +### Removed + +- `src/span_panel_api/rest/` — entire REST client directory +- `src/span_panel_api/client.py` — backward-compat shim +- `src/span_panel_api/generated_client/` — OpenAPI v1 generated models +- `generate_client.py` — OpenAPI client generator script +- `examples/` directory (YAML configs moved to `tests/fixtures/configs/`) +- `DeprecationInfo`, `CircuitCorrelationProtocol`, `CorrelationUnavailableError`, `SpanPanelRetriableError` +- `PanelCapability.REST_V1`, `PanelCapability.SIMULATION` flags +- HTTP/retry constants from `const.py` +- `openapi.json` specification file + ## [1.1.14] - 12/2025 -### Fixed in v1.1.14 +### Fixed -- Recognize panel Keep-Alive at 5 sec, Handle httpx.RemoteProtocolError defensively +- Recognize panel Keep-Alive at 5 sec, handle `httpx.RemoteProtocolError` defensively ## [1.1.9] - 9/2025 -### Fixed in v1.1.9 +### Fixed - Simulation mode sign correction for solar and battery power values - Fixed battery State of Energy (SOE) calculation to use configured battery behavior instead of hardcoded time-of-day assumptions -### Changed in v1.1.9 +### Changed - Updated GitHub Actions setup-python from v5 to v6 - Updated dev dependencies group ## [1.1.8] - 2024 -### Fixed in v1.1.8 +### Fixed - Fixed sign on power values in simulation mode -### Changed in v1.1.8 +### Changed - Updated virtualenv from 20.33.0 to 20.34.0 - Updated GitHub Actions checkout from v4 to v5 ## [1.1.6] - 2024 -### Added in v1.1.6 +### Added - Enhanced simulation API with YAML configuration and dynamic overrides - Battery behavior simulation capabilities @@ -45,155 +105,107 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Power fluctuation patterns for different appliance types - Per-circuit and per-branch variation controls -### Fixed in v1.1.6 +### Fixed - Fixed authentication in simulation mode - Fixed locking issues in simulation mode - Fixed energy accumulation in simulation - Fixed cache for unmapped circuits -- Fixed code complexity issues -- Fixed pylint and mypy errors -- Fixed spelling errors -### Changed in v1.1.6 +### Changed - Refactored simulation to reduce code complexity -- Updated multiple dependencies including: - - virtualenv from 20.32.0 to 20.33.0 - - coverage from 7.9.2 to 7.10.2 - - numpy from 2.3.1 to 2.3.2 - - docutils from 0.21.2 to 0.22 - - rich from 14.0.0 to 14.1.0 - - anyio from 4.9.0 to 4.10.0 - - certifi from 2025.7.14 to 2025.8.3 -- Updated dependabot configuration -- Enhanced documentation and linting - -### Removed in v1.1.6 + +### Removed - Removed unused client_utils.py ## [1.1.5] - 2024 -### Added in v1.1.5 +### Added - Simulation mode enhancements - Test coverage for simulation edge cases -- Panel constants and simulation demo improvements -### Fixed in v1.1.5 +### Fixed - Fixed panel constants and simulation demo - Fixed energy accumulation in simulation -- Fixed demo script - -### Changed in v1.1.5 - -- Updated pytest-asyncio in dev-dependencies -- Updated certifi from 2025.7.9 to 2025.7.14 -- Adjusted code coverage and excluded test/examples from codefactor ## [1.1.4] - 2024 -### Added in v1.1.4 +### Added - Formatting and linting scripts -### Removed in v1.1.4 +### Removed - Removed unused client_utils.py ## [1.1.3] - 2024 -### Fixed in v1.1.3 +### Fixed - Fixed tests and linting errors -- Fixed pylint and mypy errors - Excluded defensive code from coverage ## [1.1.2] - 2024 -### Added in v1.1.2 +### Added -- **Simulation mode** - Complete simulation system for development and testing without physical SPAN panel +- **Simulation mode** — complete simulation system for development and testing without physical SPAN panel - Dead code checking - Test coverage for simulation mode -### Changed in v1.1.2 +### Changed -- Updated multiple dependencies including: - - openapi-python-client from 0.25.1 to 0.25.2 - - typing-extensions to 4.14.1 - - cryptography from 45.0.4 to 45.0.5 - - urllib3 from 2.4.0 to 2.5.0 - - pygments from 2.19.1 to 2.19.2 - - jaraco-functools from 4.1.0 to 4.2.1 - Updated ruff configuration - Moved uncategorized tests to appropriate files -### Fixed in v1.1.2 - -- Addressed unused variables -- Adapted tests to use simulation mode -- Increased test coverage for simulation mode - ## [1.1.1] - 2024 -### Changed in v1.1.1 +### Changed -- Updated openapi-python-client -- Upgraded pytest-asyncio to 0.21.0 - Upgraded openapi-python-client to 0.24.0 and regenerated client - Loosened ruff dependency constraints -### Fixed in v1.1.1 +### Fixed - Fixed tests compatibility issues ## [1.1.0] - 2024 -### Added in v1.1.0 +### Added - Initial release of SPAN Panel API client library -- Support for Python 3.12 and 3.13 -- Context manager pattern for automatic connection lifecycle management -- Long-lived pattern for services and integration platforms -- Manual pattern for custom connection management +- REST/OpenAPI transport for SPAN Panel v1 firmware +- Context manager, long-lived, and manual connection patterns - Authentication system with token-based API access - Panel status and state retrieval - Circuit control (relay and priority management) -- Battery storage information (SOE - State of Energy) +- Battery storage information (SOE) - Virtual circuits for unmapped panel tabs -- Timeout and retry configuration +- Timeout and retry configuration with exponential backoff - Time-based caching system - Error categorization with specific exception types - Home Assistant integration compatibility layer -- SSL configuration support -- Performance features including caching -- Development setup with Poetry -- Testing and coverage tools -- Pre-commit hooks and code quality tools - -### Features in v1.1.0 - -- **Client Patterns**: Context manager, long-lived, and manual connection patterns -- **Authentication**: Token-based authentication with automatic token management -- **Panel Control**: Complete panel status, state, and circuit management -- **Error Handling**: Categorized exceptions with retry strategies -- **Performance**: Built-in caching and timeout/retry configuration -- **Integration**: Home Assistant compatibility layer -- **Development**: Complete development toolchain with testing and linting +- Simulation mode for testing without physical hardware +- Development toolchain with Poetry, pytest, mypy, ruff --- ## Version History Summary -- **v1.1.0**: Initial release with core API client functionality -- **v1.1.1**: Dependency updates and test fixes -- **v1.1.2**: Major addition of simulation mode for development/testing -- **v1.1.3**: Code quality improvements and test fixes -- **v1.1.4**: Formatting and linting enhancements -- **v1.1.5**: Simulation mode enhancements and edge case testing -- **v1.1.6**: Major simulation API improvements with YAML configuration -- **v1.1.8**: Power sign correction in simulation mode -- **v1.1.9**: Additional simulation sign corrections and dependency updates +| Version | Date | Transport | Summary | +| ---------- | ------- | ---------- | ---------------------------------------------------------------------------------- | +| **2.0.0** | 02/2026 | MQTT/Homie | Ground-up rewrite: MQTT-only, protocol-based API, real-time push, PV/BESS metadata | +| **1.1.14** | 12/2025 | REST | Keep-Alive and RemoteProtocolError handling | +| **1.1.9** | 9/2025 | REST | Simulation sign corrections | +| **1.1.8** | 2024 | REST | Simulation power sign fix | +| **1.1.6** | 2024 | REST | YAML simulation API, battery simulation | +| **1.1.5** | 2024 | REST | Simulation edge cases | +| **1.1.4** | 2024 | REST | Formatting and linting | +| **1.1.3** | 2024 | REST | Test and lint fixes | +| **1.1.2** | 2024 | REST | Simulation mode added | +| **1.1.1** | 2024 | REST | Dependency updates | +| **1.1.0** | 2024 | REST | Initial release | diff --git a/README.md b/README.md index be51055..58ebf44 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ -# SPAN Panel OpenAPI Client +# SPAN Panel API [![GitHub Release](https://img.shields.io/github/v/release/SpanPanel/span-panel-api?style=flat-square)](https://github.com/SpanPanel/span-panel-api/releases) -[![PyPI Version](https://img.shields.io/pypi/v/span-panel-api?style=flat-square)](https://pypi.org/project/span-panel-api/) -[![Python Versions](https://img.shields.io/badge/python-3.12%20%7C%203.13-blue?style=flat-square)](https://pypi.org/project/span-panel-api/) +[![PyPI Version](https://img.shields.io/pypi/v/span-panel-api?style=flat-square)](https://pypi.org/project/span-panel-api/) [![Python Versions](https://img.shields.io/badge/python-3.10+-blue?style=flat-square)](https://pypi.org/project/span-panel-api/) [![License](https://img.shields.io/github/license/SpanPanel/span-panel-api?style=flat-square)](https://github.com/SpanPanel/span-panel-api/blob/main/LICENSE) [![CI Status](https://img.shields.io/github/actions/workflow/status/SpanPanel/span-panel-api/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/SpanPanel/span-panel-api/actions/workflows/ci.yml) @@ -11,20 +10,17 @@ [![Security](https://img.shields.io/snyk/vulnerabilities/github/SpanPanel/span-panel-api?style=flat-square)](https://snyk.io/test/github/SpanPanel/span-panel-api) [![Pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&style=flat-square)](https://github.com/pre-commit/pre-commit) -[![Code Style: Black](https://img.shields.io/badge/code%20style-black-000000.svg?style=flat-square)](https://github.com/psf/black) [![Linting: Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json&style=flat-square)](https://github.com/astral-sh/ruff) [![Type Checking: MyPy](https://img.shields.io/badge/type%20checking-mypy-blue?style=flat-square)](https://mypy-lang.org/) [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-support%20development-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/cayossarian) -A Python client library for accessing the SPAN Panel OpenAPI endpoint. +A Python client library for the SPAN Panel v2 API, using MQTT/Homie for real-time push-based panel state. -## Simulation Mode - -The SPAN Panel API client includes a simulation mode for development and testing without requiring a physical SPAN panel. When enabled, the client uses pre-recorded fixture data and applies dynamic variations provided by the API to simulate various load -variations. Simulation mode supports time-based energy accumulation, power fluctuation patterns for different appliance types, and per-circuit or per-branch variation controls. +## v1.x Sunset Notice -For detailed information and usage examples, see [tests/docs/simulation.md](tests/docs/simulation.md). +**Package versions prior to 2.0.0 are deprecated.** These versions depend on the SPAN v1 REST API, which will be retired when SPAN sunsets v1 firmware at the end of 2026. Users should upgrade to v2.0.0 or later, which requires v2 firmware +(`spanos2/r202603/05` or later) and a panel passphrase. ## Installation @@ -32,410 +28,261 @@ For detailed information and usage examples, see [tests/docs/simulation.md](test pip install span-panel-api ``` -## Usage Patterns +### Dependencies -The client supports two usage patterns depending on your use case: +- `httpx` — v2 authentication and detection endpoints +- `paho-mqtt` — MQTT/Homie transport (real-time push) +- `pyyaml` — simulation configuration -### Context Manager Pattern (Recommended for Scripts) +## Architecture -**Best for**: Scripts, one-off operations, short-lived applications +v2.0.0 is a ground-up rewrite. The package connects to the SPAN Panel's on-device MQTT broker using the [Homie v5](https://homieiot.github.io/) convention. All panel and circuit state is delivered via retained MQTT messages — no polling required. -```python -import asyncio -from span_panel_api import SpanPanelClient +### Transport -async def main(): - # Context manager automatically handles connection lifecycle - async with SpanPanelClient("192.168.1.100") as client: - # Authenticate - auth = await client.authenticate("my-script", "SPAN Control Script") +The `SpanMqttClient` connects to the panel's MQTT broker (MQTTS or WebSocket) and subscribes to the Homie device tree. A `HomieDeviceConsumer` state machine parses incoming topic updates into typed `SpanPanelSnapshot` dataclasses. Changes are pushed to +consumers via callbacks. - # Get panel status (no auth required) - status = await client.get_status() - print(f"Panel: {status.system.manufacturer}") +### Protocols - # Get circuits (requires auth) - circuits = await client.get_circuits() - for circuit_id, circuit in circuits.circuits.additional_properties.items(): - print(f"{circuit.name}: {circuit.instant_power_w}W") +The library defines three structural subtyping protocols (PEP 544) that both the MQTT transport and the simulation engine implement: - # Control a circuit - await client.set_circuit_relay("circuit-1", "OPEN") - await client.set_circuit_priority("circuit-1", "MUST_HAVE") +| Protocol | Purpose | +| -------------------------- | ------------------------------------------------------------------------------------- | +| `SpanPanelClientProtocol` | Core lifecycle: `connect`, `close`, `ping`, `get_snapshot` | +| `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` | +| `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` | - # Client is automatically closed when exiting context +Integration code programs against these protocols, not transport-specific classes. -asyncio.run(main()) -``` +### Snapshots + +All panel state is represented as immutable, frozen dataclasses: + +| Dataclass | Content | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `SpanPanelSnapshot` | Complete panel state: power, energy, grid/DSM state, hardware status, per-leg voltages, power flows, lugs current, circuits, battery, PV | +| `SpanCircuitSnapshot` | Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current | +| `SpanBatterySnapshot` | BESS: SoC percentage, SoE kWh, vendor/product metadata, nameplate capacity | +| `SpanPVSnapshot` | PV inverter: vendor/product metadata, nameplate capacity | -### Long-Lived Pattern (Services or Integrations) +## Usage -**Best for**: Long-running services, persistent connections, integration platforms +### Factory Pattern (Recommended) -> **Note for Home Assistant integrations**: See [Home Assistant Integration](#home-assistant-integration) section for HA-specific compatibility configuration. +The `create_span_client()` factory handles v2 registration and returns a configured `SpanMqttClient`: ```python import asyncio -from span_panel_api import SpanPanelClient - -class SpanPanelIntegration: - """Example long-running service integration pattern.""" - - def __init__(self, host: str): - # Create client but don't use context manager - self.client = SpanPanelClient(host) - self._authenticated = False - - async def setup(self) -> None: - """Initialize the integration (called once).""" - try: - # Authenticate once during setup - await self.client.authenticate("my-service", "Panel Integration Service") - self._authenticated = True - except Exception as e: - await self.client.close() # Clean up on setup failure - raise - - async def update_data(self) -> dict: - """Update all data (called periodically by coordinator).""" - if not self._authenticated: - await self.client.authenticate("my-service", "Panel Integration Service") - self._authenticated = True - - try: - # Get all data in one update cycle - status = await self.client.get_status() - panel_state = await self.client.get_panel_state() - circuits = await self.client.get_circuits() - storage = await self.client.get_storage_soe() - - return { - "status": status, - "panel": panel_state, - "circuits": circuits, - "storage": storage - } - except Exception: - self._authenticated = False # Reset auth on error - raise - - async def set_circuit_priority(self, circuit_id: str, priority: str) -> None: - """Set circuit priority (called by service).""" - if not self._authenticated: - await self.client.authenticate("my-service", "Panel Integration Service") - self._authenticated = True - - await self.client.set_circuit_priority(circuit_id, priority) - - async def cleanup(self) -> None: - """Cleanup when integration is unloaded.""" - await self.client.close() - -# Usage in long-running service +from span_panel_api import create_span_client + async def main(): - integration = SpanPanelIntegration("192.168.1.100") + client = await create_span_client( + host="192.168.1.100", + passphrase="your-panel-passphrase", + ) try: - await integration.setup() + await client.connect() + + # Get a point-in-time snapshot + snapshot = await client.get_snapshot() + print(f"Grid power: {snapshot.instant_grid_power_w}W") + print(f"Firmware: {snapshot.firmware_version}") + print(f"Circuits: {len(snapshot.circuits)}") - # Simulate coordinator updates - for i in range(10): - data = await integration.update_data() - print(f"Update {i}: {len(data['circuits'].circuits.additional_properties)} circuits") - await asyncio.sleep(30) # Service typically updates every 30 seconds + for cid, circuit in snapshot.circuits.items(): + print(f" {circuit.name}: {circuit.instant_power_w}W ({circuit.relay_state})") finally: - await integration.cleanup() + await client.close() asyncio.run(main()) ``` -### Manual Pattern (Advanced Usage) +### Streaming Pattern -**Best for**: Custom connection management, special requirements +For real-time push updates without polling: ```python import asyncio -from span_panel_api import SpanPanelClient +from span_panel_api import create_span_client, SpanPanelSnapshot -async def manual_example(): - """Manual client lifecycle management.""" - client = SpanPanelClient("192.168.1.100") +async def on_snapshot(snapshot: SpanPanelSnapshot) -> None: + print(f"Grid: {snapshot.instant_grid_power_w}W, Circuits: {len(snapshot.circuits)}") + +async def main(): + client = await create_span_client( + host="192.168.1.100", + passphrase="your-panel-passphrase", + ) try: - # Manually authenticate - await client.authenticate("manual-app", "Manual Application") + await client.connect() - # Do work - status = await client.get_status() - circuits = await client.get_circuits() + # Register callback and start streaming + unsubscribe = client.register_snapshot_callback(on_snapshot) + await client.start_streaming() - print(f"Found {len(circuits.circuits.additional_properties)} circuits") + # Run until interrupted + await asyncio.Event().wait() - except Exception as e: - print(f"Error: {e}") finally: - # IMPORTANT: Always close the client to free resources + await client.stop_streaming() await client.close() -asyncio.run(manual_example()) -``` - -## When to Use Each Pattern - -| Pattern | Use Case | Pros | Cons | -| ------------------- | ---------------------------------------- | ----------------------------------------------------- | ------------------------------------------------- | -| **Context Manager** | Scripts, one-off tasks, testing | Automatic cleanup • Exception safe • Simple code | Creates/destroys connection each time | -| **Long-Lived** | Services, daemons, integration platforms | Efficient connection reuse Authentication persistence | Manual lifecycle management • Must handle cleanup | -| **Manual** | Custom requirements, debugging | Full control handling | Must remember to call close() • More error-prone | - -## Error Handling - -The client provides error categorization for different retry strategies: - -### Exception Types - -All exceptions inherit from `SpanPanelError`. - -- `SpanPanelAuthError`: Raised for authentication failures (invalid token, login required, etc.) -- `SpanPanelConnectionError`: Raised for network errors, server errors, or API errors -- `SpanPanelTimeoutError`: Raised when a request times out -- `SpanPanelValidationError`: Raised for data validation errors (invalid input, schema mismatch) -- `SpanPanelAPIError`: General API error (fallback for unexpected API issues) -- `SpanPanelRetriableError`: Raised for retriable server errors (502, 503, 504) -- `SpanPanelServerError`: Raised for non-retriable server errors (500) -- `SimulationConfigurationError`: Raised for invalid or missing simulation configuration (simulation mode only) - -```python -from span_panel_api import ( - SpanPanelError, # Base exception - SpanPanelAuthError, - SpanPanelConnectionError, - SpanPanelTimeoutError, - SpanPanelValidationError, - SpanPanelAPIError, - SpanPanelRetriableError, - SpanPanelServerError, - SimulationConfigurationError, -) -``` - -### HTTP Error Code Mapping - -| Status Code | Exception | Retry? | Description | Action | -| ---------------------------- | ------------------------------ | -------------------- | --------------------------------- | ------------------------------ | -| **Authentication Errors** | - | - | - | - | -| 401, 403 | `SpanPanelAuthError` | Once (after re-auth) | Authentication required/failed | Re-authenticate and retry once | -| **Server/Network Errors** | - | - | - | - | -| 500 | `SpanPanelServerError` | No | Server error (non-retriable) | Check server, report issue | -| 502, 503, 504 | `SpanPanelRetriableError` | Yes | Retriable server/network errors | Retry with exponential backoff | -| **Other HTTP Errors** | - | - | - | - | -| 404, 400, etc | `SpanPanelAPIError` | Case by case | Client/request errors | Check request parameters | -| **Timeouts** | `SpanPanelTimeoutError` | Yes | Request timed out | Retry with backoff | -| **Validation Errors** | `SpanPanelValidationError` | No | Data validation failed | Fix input data | -| **Simulation Config Errors** | `SimulationConfigurationError` | No | Invalid/missing simulation config | Fix simulation config | - -### Retry Strategy - -```python -async def example_request_with_retry(): - """Example showing appropriate error handling.""" - try: - return await client.get_circuits() - except SpanPanelAuthError: - # Re-authenticate and retry once - await client.authenticate("my-app", "My Application") - return await client.get_circuits() - except SpanPanelRetriableError as e: - # Temporary server or network issues - should retry with backoff - logger.warning(f"Retriable error, will retry: {e}") - raise # Let retry logic handle the retry - except SpanPanelTimeoutError as e: - # Network timeout - should retry - logger.warning(f"Timeout, will retry: {e}") - raise - except SpanPanelValidationError as e: - # Data validation error - fix input - logger.error(f"Validation error: {e}") - raise - except SimulationConfigurationError as e: - # Simulation config error - fix config - logger.error(f"Simulation config error: {e}") - raise - except SpanPanelAPIError as e: - # Other API errors - logger.error(f"API error: {e}") - raise +asyncio.run(main()) ``` -### Exception Handling - -The client configures the underlying OpenAPI client with `raise_on_unexpected_status=True`, ensuring that HTTP errors (especially 500 responses) are converted to appropriate exceptions rather than being silently ignored. - -## API Reference +### Pre-Built Config Pattern -### Client Initialization +If you already have MQTT broker credentials (e.g., stored from a previous registration): ```python -client = SpanPanelClient( - host="192.168.1.100", # Required: SPAN Panel IP - port=80, # Optional: default 80 - timeout=30.0, # Optional: request timeout - use_ssl=False, # Optional: HTTPS (usually False for local) - cache_window=1.0 # Optional: cache window in seconds (0 to disable) +from span_panel_api import create_span_client, MqttClientConfig + +config = MqttClientConfig( + broker_host="192.168.1.100", + username="stored-username", + password="stored-password", + mqtts_port=8883, + ws_port=9001, + wss_port=443, ) -``` - -### Authentication -```python -# Register a new API client (one-time setup) -auth = await client.authenticate( - name="my-integration", # Required: client name - description="My Application" # Optional: description +client = await create_span_client( + host="192.168.1.100", + mqtt_config=config, + serial_number="nj-2316-XXXX", ) -# Token is stored and used for subsequent requests -``` - -### Panel Information - -```python -# System status (no authentication required) -status = await client.get_status() -print(f"System: {status.system}") -print(f"Network: {status.network}") - -# Detailed panel state (requires authentication) -panel = await client.get_panel_state() -print(f"Grid power: {panel.instant_grid_power_w}W") -print(f"Main relay: {panel.main_relay_state}") - -# Battery storage information -storage = await client.get_storage_soe() -print(f"Battery SOE: {storage.soe * 100:.1f}%") -print(f"Max capacity: {storage.max_energy_kwh}kWh") ``` ### Circuit Control ```python -# Get all circuits -circuits = await client.get_circuits() -for circuit_id, circuit in circuits.circuits.additional_properties.items(): - print(f"Circuit {circuit_id}: {circuit.name}") - print(f" Power: {circuit.instant_power_w}W") - print(f" Relay: {circuit.relay_state}") - print(f" Priority: {circuit.priority}") - -# Control circuit relay (OPEN/CLOSED) -await client.set_circuit_relay("circuit-1", "OPEN") # Turn off -await client.set_circuit_relay("circuit-1", "CLOSED") # Turn on - -# Set circuit priority -await client.set_circuit_priority("circuit-1", "MUST_HAVE") -await client.set_circuit_priority("circuit-1", "NICE_TO_HAVE") -``` +# Set circuit relay (OPEN/CLOSED) +await client.set_circuit_relay("circuit-uuid", "OPEN") +await client.set_circuit_relay("circuit-uuid", "CLOSED") -### Complete Circuit Data - -The `get_circuits()` method includes virtual circuits for unmapped panel tabs, providing complete panel visibility including non-user controlled tabs. +# Set circuit shed priority (NEVER / SOC_THRESHOLD / OFF_GRID) +await client.set_circuit_priority("circuit-uuid", "NEVER") +``` -- Virtual circuits have IDs like `unmapped_tab_1`, `unmapped_tab_2` -- All energy values are correctly mapped from panel branches +### API Version Detection -**Example Output:** +Detect whether a panel supports v2 (unauthenticated probe): ```python -circuits = await client.get_circuits() - -# Standard configured circuits -print(circuits.circuits.additional_properties["1"].name) # "Main Kitchen" -print(circuits.circuits.additional_properties["1"].instant_power_w) # 150 +from span_panel_api import detect_api_version -# Virtual circuits for unmapped tabs (e.g., solar) -print(circuits.circuits.additional_properties["unmapped_tab_5"].name) # "Unmapped Tab 5" -print(circuits.circuits.additional_properties["unmapped_tab_5"].instant_power_w) # -2500 (solar production) +result = await detect_api_version("192.168.1.100") +print(f"API version: {result.api_version}") # "v1" or "v2" +if result.status_info: + print(f"Serial: {result.status_info.serial_number}") + print(f"Firmware: {result.status_info.firmware_version}") ``` -## Timeout and Retry Control +### v2 Authentication Functions -The SPAN Panel API client provides timeout and retry configuration: - -- `timeout` (float, default: 30.0): The maximum time (in seconds) to wait for a response from the panel for each attempt. -- `retries` (int, default: 0): The number of times to retry a failed request due to network or retriable server errors. `retries=0` means no retries (1 total attempt), `retries=1` means 1 retry (2 total attempts), etc. -- `retry_timeout` (float, default: 0.5): The base wait time (in seconds) between retries, with exponential backoff. -- `retry_backoff_multiplier` (float, default: 2.0): The multiplier for exponential backoff between retries. - -### Example Usage +Standalone async functions for v2-specific HTTP operations: ```python -# No retries (default, fast feedback) -client = SpanPanelClient("192.168.1.100", timeout=10.0) - -# Add retries for production -client = SpanPanelClient("192.168.1.100", timeout=10.0, retries=2, retry_timeout=1.0) - -# Full retry configuration -client = SpanPanelClient( - "192.168.1.100", - timeout=10.0, - retries=3, - retry_timeout=0.5, - retry_backoff_multiplier=2.0 -) +from span_panel_api import register_v2, download_ca_cert, get_homie_schema, regenerate_passphrase -# Change retry settings at runtime -client.retries = 3 -client.retry_timeout = 2.0 -client.retry_backoff_multiplier = 1.5 -``` - -### What does 'retries' mean? +# Register and obtain MQTT broker credentials +auth = await register_v2("192.168.1.100", "my-app", passphrase="panel-passphrase") +print(f"Broker: {auth.ebus_broker_host}:{auth.ebus_broker_mqtts_port}") +print(f"Serial: {auth.serial_number}") -| retries | Total Attempts | Description | -| ------- | -------------- | -------------------- | -| 0 | 1 | No retries (default) | -| 1 | 2 | 1 retry | -| 2 | 3 | 2 retries | +# Download the panel's CA certificate (for TLS verification) +pem = await download_ca_cert("192.168.1.100") -Retry and timeout settings can be queried and changed at runtime. +# Fetch the Homie property schema (unauthenticated) +schema = await get_homie_schema("192.168.1.100") +print(f"Schema hash: {schema.types_schema_hash}") -## Performance Features +# Rotate MQTT broker password (invalidates previous password) +new_password = await regenerate_passphrase("192.168.1.100", token=auth.access_token) +``` -### Caching +## Error Handling -The client includes a time-based cache that prevents redundant API calls within a configurable window. This feature is particularly useful when multiple operations need the same data. The package itself makes multiple calls to create virtual circuits for -tabs not represented in circuits data so the cache avoids unecessary calls when the user also makes requests the same data. +All exceptions inherit from `SpanPanelError`: -**Cache Behavior:** +| Exception | Cause | +| ------------------------------ | --------------------------------------------------------- | +| `SpanPanelAuthError` | Invalid passphrase, expired token, or missing credentials | +| `SpanPanelConnectionError` | Cannot reach the panel (network/DNS) | +| `SpanPanelTimeoutError` | Request or connection timed out | +| `SpanPanelValidationError` | Data validation failure | +| `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints | +| `SpanPanelServerError` | Panel returned HTTP 500 | +| `SimulationConfigurationError` | Invalid simulation YAML configuration | -- Each API endpoint (status, panel_state, circuits, storage) has independent cache -- Cache window starts when successful data is obtained -- Subsequent calls within the window return cached data -- After expiration, next call makes fresh network request -- Failed requests don't affect cache timing +```python +from span_panel_api import SpanPanelAuthError, SpanPanelConnectionError + +try: + client = await create_span_client(host="192.168.1.100", passphrase="wrong") +except SpanPanelAuthError: + print("Invalid passphrase") +except SpanPanelConnectionError: + print("Cannot reach panel") +``` -**Example Benefits:** +## Simulation Mode -```python -# These calls demonstrate cache efficiency: -panel_state = await client.get_panel_state() # Network call -circuits = await client.get_circuits() # Uses cached panel_state data internally -panel_state2 = await client.get_panel_state() # Returns cached data (within window) +The library includes a simulation engine for development and testing without a physical SPAN panel. The `DynamicSimulationEngine` implements the same protocols as `SpanMqttClient` and produces `SpanPanelSnapshot` dataclasses from YAML-configured fixture +data with dynamic power and energy variations. + +For detailed information, see [tests/docs/simulation.md](tests/docs/simulation.md). + +## Capabilities + +The `PanelCapability` flag enum advertises transport features at runtime: + +| Flag | Meaning | +| ----------------- | ------------------------------------- | +| `EBUS_MQTT` | Connected via MQTT/Homie transport | +| `PUSH_STREAMING` | Supports real-time push callbacks | +| `CIRCUIT_CONTROL` | Can set relay state and shed priority | +| `BATTERY_SOE` | Battery state-of-energy available | + +## Project Structure + +```text +src/span_panel_api/ +├── __init__.py # Public API exports +├── auth.py # v2 HTTP provisioning (register, cert, schema, passphrase) +├── const.py # Panel state constants (DSM, relay) +├── detection.py # detect_api_version() → DetectionResult +├── exceptions.py # Exception hierarchy +├── factory.py # create_span_client() → SpanMqttClient +├── models.py # Snapshot dataclasses (panel, circuit, battery, PV) +├── phase_validation.py # Electrical phase utilities +├── protocol.py # PEP 544 protocols + PanelCapability flags +├── simulation.py # Simulation engine (YAML-driven, snapshot-producing) +└── mqtt/ + ├── __init__.py + ├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern) + ├── client.py # SpanMqttClient (all three protocols) + ├── connection.py # AsyncMqttBridge (event-loop-driven, no threads) + ├── const.py # MQTT/Homie constants + UUID helpers + ├── homie.py # HomieDeviceConsumer (Homie v5 state machine) + └── models.py # MqttClientConfig, MqttTransport ``` ## Development Setup ### Prerequisites -- Python 3.12 or 3.13 (SPAN Panel requires Python 3.12+) +- Python 3.10+ (CI tests 3.12 and 3.13) - [Poetry](https://python-poetry.org/) for dependency management ### Development Installation ```bash -# Clone and install -git clone +git clone https://github.com/SpanPanel/span-panel-api.git cd span-panel-api eval "$(poetry env activate)" poetry install @@ -447,123 +294,26 @@ poetry run pytest python scripts/coverage.py ``` -### Project Structure - -```bash -span_openapi/ -├── src/span_panel_api/ # Main client library -│ ├── client.py # SpanPanelClient (high-level wrapper) -│ ├── simulation.py # Simulation engine for dynamic test mode -│ ├── exceptions.py # Exception hierarchy -│ ├── const.py # HTTP status constants -│ └── generated_client/ # Auto-generated OpenAPI client -├── tests/ # Test suite -│ ├── test_core_client.py # Core client and API error path tests -│ ├── test_context_manager.py # Context manager tests -│ ├── test_cache_functionality.py # Cache and retry tests -│ ├── test_enhanced_circuits.py # Enhanced/virtual circuits tests -│ ├── test_simulation_mode.py # Simulation mode tests -│ ├── test_factories.py # Shared test fixtures and factories -│ ├── conftest.py # Pytest shared fixtures -│ └── simulation_fixtures/ # Simulation fixture data (response .txt files) -├── scripts/coverage.py # Coverage checking utility -├── openapi.json # SPAN Panel OpenAPI specification -├── pyproject.toml # Poetry configuration -└── README.md # Project documentation - -``` - -## Advanced Usage - -### Home Assistant Integration - -For Home Assistant integrations, the client provides a compatibility layer to handle asyncio timing issues that can occur in HA's event loop: - -```python -from span_panel_api import SpanPanelClient, set_async_delay_func -import asyncio - -# In your Home Assistant integration setup: -async def ha_compatible_delay(seconds: float) -> None: - """Custom delay function that works well with HA's event loop.""" - # Use HA's async utilities or implement HA-specific delay logic - await asyncio.sleep(seconds) - -# Configure the client to use HA-compatible delay -set_async_delay_func(ha_compatible_delay) - -# Now create and use clients normally -async with SpanPanelClient("192.168.1.100") as client: - # Client will use your custom delay function for retry logic - await client.authenticate("your_token") - panel_state = await client.get_panel_state() - -# To reset to default behavior (uses asyncio.sleep): -set_async_delay_func(None) -``` - -**Why This Matters:** - -- Home Assistant's event loop can be sensitive to blocking operations -- The default `asyncio.sleep()` used in retry logic may not play well with HA -- Custom delay functions allow HA integrations to use HA's preferred async patterns -- This prevents integration timeouts and improves responsiveness - -**Note:** This only affects the retry delay behavior. Normal API operations remain unchanged. - -### SSL Configuration - -```python -# For panels that support SSL -# Note: We do not currently observe panels supporting SSL for local access -client = SpanPanelClient( - host="span-panel.local", - use_ssl=True, - port=443 -) -``` - -### Timeout Configuration - -```python -# Custom timeout for slow networks -client = SpanPanelClient( - host="192.168.1.100", - timeout=60.0 # 60 second timeout -) -``` - -## Testing and Coverage +### Testing and Coverage ```bash -# Run full test suite +# Full test suite poetry run pytest -# Generate coverage report -python scripts/coverage.py --full - -# Run just context manager tests -poetry run pytest tests/test_context_manager.py -v - -# Check coverage meets threshold -python scripts/coverage.py --check --threshold 90 - -# Run with coverage +# With coverage report poetry run pytest --cov=span_panel_api tests/ + +# Check coverage meets threshold (85%) +python scripts/coverage.py --check --threshold 85 ``` ## Contributing -1. Get `openapi.json` SPAN Panel API specs - - (for example via REST Client extension) - - GET - -2. Regenerate client: `poetry run python generate_client.py` -3. Update wrapper client in `src/span_panel_api/client.py` if needed -4. Add tests for new functionality -5. Update this README if adding new features +1. Fork and clone the repository +2. Install dev dependencies: `poetry install` +3. Make changes and add tests +4. Ensure all checks pass: `poetry run pytest && poetry run mypy src/ && poetry run ruff check src/` +5. Submit a pull request ## License diff --git a/conftest.py b/conftest.py index 4d371ea..2689752 100644 --- a/conftest.py +++ b/conftest.py @@ -3,14 +3,6 @@ import pytest -from span_panel_api.client import SpanPanelClient -from tests.test_helpers import ( - create_behavior_sim_client, - create_full_sim_client, - create_minimal_sim_client, - create_simple_sim_client, -) - # Register pytest-asyncio plugin and provide legacy event_loop fixture pytest_plugins = ["pytest_asyncio"] @@ -24,33 +16,3 @@ def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]: loop = asyncio.new_event_loop() yield loop loop.close() - - -@pytest.fixture -def sim_client() -> SpanPanelClient: - """Provide a simple YAML-based simulation client for testing.""" - return create_simple_sim_client() - - -@pytest.fixture -def sim_client_no_cache() -> SpanPanelClient: - """Provide a simple YAML-based simulation client (persistent cache always enabled).""" - return create_simple_sim_client() - - -@pytest.fixture -def minimal_sim_client() -> SpanPanelClient: - """Provide a minimal YAML-based simulation client for basic testing.""" - return create_minimal_sim_client() - - -@pytest.fixture -def behavior_sim_client() -> SpanPanelClient: - """Provide a behavior-testing YAML-based simulation client.""" - return create_behavior_sim_client() - - -@pytest.fixture -def full_sim_client() -> SpanPanelClient: - """Provide a full 32-circuit YAML-based simulation client.""" - return create_full_sim_client() diff --git a/docs/Dev/ebus-migration-plan.md b/docs/Dev/ebus-migration-plan.md new file mode 100644 index 0000000..8606c53 --- /dev/null +++ b/docs/Dev/ebus-migration-plan.md @@ -0,0 +1,651 @@ +# Cut REST, Go MQTT-Only — span-panel-api 2.0 + +## Context + +v2 firmware is rolling out. Users will be told not to upgrade the integration until they have v2 firmware. This eliminates the need for dual-transport support. The REST client (~1430 lines), generated OpenAPI client, virtual circuits, object pooling, delay +registry, and branch handling are all dead weight. + +The snapshot model (`SpanPanelSnapshot`) is **retained** — it serves as the library-to-integration contract and provides natural update coalescing (MQTT property updates accumulate cheaply, snapshot built on coordinator interval, entities only write state +when values change). + +Circuit correlation (v1→v2 UUID mapping) moves to the **integration layer** — the integration already has v1 circuit IDs in its entity registry and can match against v2 snapshots without a live REST client. + +Simulation engine is **retained** — no REST dependency, produces snapshots directly. + +--- + +## Step 1: Move `auth_v2.py` → `auth.py`, delete `rest/` and `generated_client/` + +### Move (content unchanged) + +`src/span_panel_api/rest/auth_v2.py` → `src/span_panel_api/auth.py` + +Functions preserved: `register_v2()`, `download_ca_cert()`, `get_homie_schema()`, `regenerate_passphrase()`, `get_v2_status()`, `_str()`, `_int()` + +### Import updates + +| File | Old | New | +| --------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `src/span_panel_api/mqtt/connection.py` | `from ..rest.auth_v2 import download_ca_cert` | `from ..auth import download_ca_cert` | +| `src/span_panel_api/factory.py` | `from .rest.auth_v2 import register_v2` | `from .auth import register_v2` | +| `src/span_panel_api/__init__.py` | `from .rest import ..., download_ca_cert, ...` | `from .auth import download_ca_cert, get_homie_schema, regenerate_passphrase, register_v2` | + +### Delete entirely + +| Path | Reason | +| --------------------------------------------------------- | --------------------------------------------------------- | +| `src/span_panel_api/rest/` (entire directory) | v1 REST transport, delay, snapshot, virtual circuits | +| `src/span_panel_api/client.py` | Backward-compat shim (`SpanPanelClient = SpanRestClient`) | +| `src/span_panel_api/generated_client/` (entire directory) | OpenAPI v1 generated models | +| `generate_client.py` | OpenAPI client generator script | + +### Verify + +No remaining imports from `rest.` or `generated_client.` anywhere in `src/`. + +--- + +## Step 2: Simplify protocols, models, exceptions, const + +### `src/span_panel_api/protocol.py` + +- Remove `REST_V1` and `SIMULATION` from `PanelCapability` flag enum +- Remove `CircuitCorrelationProtocol` entirely +- Keep: `SpanPanelClientProtocol`, `CircuitControlProtocol`, `StreamingCapableProtocol` + +### `src/span_panel_api/models.py` + +- Remove `DeprecationInfo` dataclass +- Remove `deprecation_info` field from `SpanPanelSnapshot` +- ~~Keep `SpanBranchSnapshot`~~ → **Remove after Step 6** (unmapped tab synthesis replaces branch-based derivation; MQTT has no branch concept) + +### `src/span_panel_api/exceptions.py` + +- Remove `CorrelationUnavailableError` +- Remove `SpanPanelRetriableError` +- Keep all others + +### `src/span_panel_api/const.py` + +- Remove: `HTTP_*` status codes, `AUTH_ERROR_CODES`, `RETRIABLE_ERROR_CODES`, `SERVER_ERROR_CODES`, `RETRY_*` constants +- Keep: `DSM_*`, `PANEL_*`, `MAIN_RELAY_*` (used by simulation + MQTT derivation) + +--- + +## Step 3: Simplify MQTT client and factory + +### `src/span_panel_api/mqtt/client.py` + +- Remove `set_correlation_source()` method +- Remove `get_circuit_correlation()` method +- Remove `_correlate_by_name_tabs()` static method +- Remove `_correlation_client` attribute from `__init__` +- Remove imports: `CorrelationUnavailableError`, `SpanPanelClientProtocol` (from protocol), `SpanCircuitSnapshot` (if only used by correlation), `denormalize_circuit_id` (if only used by correlation) + +### `src/span_panel_api/factory.py` + +Remove `RestClientConfig`, `_build_rest_client()`, v1 path, correlation injection. Simplified: + +```python +async def create_span_client( + host: str, + passphrase: str | None = None, + mqtt_config: MqttClientConfig | None = None, + serial_number: str | None = None, +) -> SpanMqttClient: +``` + +- Remove: `from .rest.client import SpanRestClient` +- Remove: `access_token`, `auth_refresh_callback`, `simulation_mode`, `simulation_config_path`, `rest_config` params +- Remove: `RestClientConfig` dataclass +- Remove: `_build_rest_client()` function +- Remove: v1 branch in `create_span_client()` +- Remove: correlation injection in `_build_mqtt_client()` +- Inline `_build_mqtt_client()` into `create_span_client()` (no longer needs separate function) + +### `src/span_panel_api/__init__.py` + +Remove from imports and `__all__`: + +- `SpanPanelClient`, `SpanRestClient`, `set_async_delay_func` +- `CircuitCorrelationProtocol` +- `CorrelationUnavailableError`, `SpanPanelRetriableError` + +Update import source for auth functions: `from .auth import ...` + +--- + +## Step 4: Delete REST test files and examples + +### Test files to DELETE (~21 files) + +| File | Reason | +| -------------------------------------------- | ---------------------------------- | +| `tests/test_rest_client.py` | REST client | +| `tests/test_authentication.py` | v1 auth | +| `tests/test_bearer_token_validation.py` | v1 token | +| `tests/test_context_manager.py` | REST async context | +| `tests/test_core_client.py` | REST core | +| `tests/test_enhanced_circuits.py` | REST circuits | +| `tests/test_error_handling.py` | REST errors | +| `tests/test_client_retry_properties.py` | REST retry | +| `tests/test_client_simulation_errors.py` | REST simulation errors | +| `tests/test_client_simulation_start_time.py` | REST simulation start time | +| `tests/test_helpers.py` | REST helpers | +| `tests/test_relay_behavior_demo.py` | REST relay demo | +| `tests/test_factories.py` | REST factory | +| `tests/test_40_tab_unmapped.py` | Virtual circuits | +| `tests/test_32_panel_solar_unmapped.py` | Virtual circuits | +| `tests/test_unmapped_power_verification.py` | Virtual circuits | +| `tests/test_unmapped_tabs_specific.py` | Virtual circuits | +| `tests/test_workshop_unmapped_tabs.py` | Virtual circuits | +| `tests/test_enhanced_battery_behavior.py` | REST battery | +| `tests/test_panel_circuit_alignment.py` | REST alignment | +| `tests/test_coverage_completion.py` | REST coverage | +| `tests/test_phase4_factory_correlation.py` | Correlation (moves to integration) | + +### Test files to MODIFY + +| File | Change | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | +| `tests/test_protocol_conformance.py` | Remove `TestRestProtocolConformance`, remove `SpanRestClient` import, remove `CircuitCorrelationProtocol` test | +| `tests/test_phase2_detection_auth.py` | Update `auth_v2` import paths to `auth`. Remove any REST client references. Keep detection + v2 auth tests. | +| `tests/test_phase5_simulation_snapshot.py` | Remove `SpanRestClient` references. Keep simulation snapshot tests. | +| `tests/test_ha_compatibility.py` | Remove `set_async_delay_func` / `_delay_registry` tests. Delete if entirely REST-focused. | +| `tests/test_simulation_mode.py` | Check: if it tests REST simulation path → DELETE. If it tests engine directly → keep. | +| `tests/test_example_phase_validation.py` | Uses `DynamicSimulationEngine` — verify imports, keep. | + +### Example files to DELETE (all 7 + YAML configs) + +All files in `examples/` that use `SpanPanelClient`. Keep `examples/README.md` if it exists. + +--- + +## Step 5: Update `pyproject.toml` and version + +- Remove `attrs` from dependencies +- Remove `python-dateutil` from dependencies +- Move `paho-mqtt` from optional to **required** +- Remove `[project.optional-dependencies] mqtt = [...]` section +- Remove `openapi-python-client` from dev/generate group +- Version: `2.0.0rc1` → `2.0.0` + +--- + +## Step 6: Unmapped Tab Synthesis in MQTT Transport + +### Background + +In v1, the REST client fetched both `/api/v1/circuits` (commissioned circuits) and `/api/v1/panel` (all 32 branch positions). Branches that had no circuit mapped were synthesized as `unmapped_tab_N` entries in `snapshot.circuits`. The integration consumed +these identically to real circuits — they backed solar inverter sensors and appeared as hidden entities for synthetic calculations. + +In v2 MQTT, the Homie broker only publishes circuit nodes for commissioned circuits. There is no branch concept. **Unmapped tabs must be synthesized in the library** to preserve the integration's existing consumption pattern. + +### Live Panel Verification (2026-02-25) + +Tested against panel `nj-2316-005k6` (firmware `spanos2/r202603/05`): + +- 23 circuit nodes published via MQTT, 7 non-circuit nodes (core, lugs, pcs, bess, pv, power-flows) +- Circuit UUIDs are **identical dashless strings** in both v1 REST and v2 MQTT — no correlation needed for current firmware +- Each circuit has `space` (int) and `dipole` (bool) properties +- Dipole formula: `tabs = [space, space + 2]` (same bus bar side, not `space + 1`). Verified against all 6 dipole circuits — matches v1 `tabs` exactly. +- Unmapped tabs derived by subtraction: all 32 spaces minus occupied tabs = unmapped positions + +### Implementation in `HomieDeviceConsumer` + +When building `SpanPanelSnapshot` from MQTT state: + +1. **Determine panel size** — default 32 spaces (standard SPAN panel). May be derivable from `core/breaker-rating` or `$description` metadata in future firmware. For now, hardcode 32 with a constant. + +2. **Collect occupied tabs** from all circuit nodes: + + - Single-pole (`dipole` absent or `false`): occupied = `[space]` + - Dipole (`dipole == true`): occupied = `[space, space + 2]` + +3. **Derive unmapped tabs** — `set(range(1, panel_size + 1)) - occupied` + +4. **Synthesize `SpanCircuitSnapshot` entries** for each unmapped tab: + + ```python + SpanCircuitSnapshot( + circuit_id=f"unmapped_tab_{tab_number}", + name=f"Unmapped Tab {tab_number}", + relay_state="CLOSED", + instant_power_w=0.0, + produced_energy_wh=0.0, + consumed_energy_wh=0.0, + tabs=[tab_number], + priority="UNKNOWN", + is_user_controllable=False, + is_sheddable=False, + is_never_backup=False, + ) + ``` + +5. **Include in `snapshot.circuits`** alongside real circuit entries. The integration filters on `circuit_id.startswith("unmapped_tab_")` to identify these. + +### `SpanBranchSnapshot` removal + +Once unmapped tab synthesis is in place, `SpanBranchSnapshot` serves no purpose — it was the v1 mechanism for the same data. Remove: + +- `SpanBranchSnapshot` dataclass from `models.py` +- `branches` field from `SpanPanelSnapshot` +- Export from `__init__.py` and `__all__` + +### Tests + +- Test that a snapshot with N circuit nodes and known space/dipole values produces the correct set of `unmapped_tab_` entries +- Test dipole formula: `[space, space + 2]` for dipole circuits +- Test single-pole: `[space]` for non-dipole circuits +- Test that unmapped entries have zero power/energy values +- Test edge case: fully occupied panel (no unmapped tabs) + +--- + +## Integration Architecture: Hybrid Coordinator Model + +### Problem: Pure Push vs. Polling + +HA supports two entity update models: + +1. **Polling:** `DataUpdateCoordinator` calls the library on a timer, gets data, pushes to entities. This is what the v1 REST integration does. +2. **Pure push:** Each entity subscribes to MQTT callbacks, calls `async_write_ha_state()` on every update. This is what dcj's span-hass does. + +Pure push has a **CPU cost problem** on SPAN panels. A 32-circuit panel publishes `active-power`, `exported-energy`, `imported-energy`, `relay`, `shed-priority`, and more per circuit — 160+ properties. Power values update continuously. Each +`async_write_ha_state()` call triggers: + +- State machine write +- Event bus fire (`state_changed` event) +- All state change listeners notified (automations, templates, logbook) +- Recorder queues a DB write (batched at ~1s, but still per-entity) + +On Raspberry Pi hardware common among HA users, hundreds of state writes per second is significant. **HA does not rate-limit or coalesce these.** + +### Solution: Hybrid Coordinator + +Use `DataUpdateCoordinator` with `update_interval=None` (no polling timer). MQTT pushes trigger snapshot builds on a controlled cadence rather than per-property. + +**Data flow:** + +```text +MQTT broker + → paho socket → asyncio add_reader + → loop_read() → paho internal dispatch (no background thread) + → HomieDeviceConsumer._handle_property() ← cheap dict write, no HA + → property_values[node_id][prop_id] = value + +(on controlled interval or debounced after burst) + +HomieDeviceConsumer.build_snapshot() ← builds frozen dataclass + → coordinator.async_set_updated_data(snapshot) ← single coordinator push + → each entity reads its field from snapshot + → async_write_ha_state() ONLY if value changed +``` + +**Key properties:** + +- MQTT messages accumulate in `HomieDeviceConsumer`'s property dict with zero HA involvement — just Python dict writes +- Snapshot is built only when the coordinator requests it, not on every MQTT message +- `async_set_updated_data()` triggers all entities to re-read, but each entity compares its current value against the snapshot and only writes state if changed +- The snapshot is the **natural coalescing boundary** — many MQTT property updates collapse into one snapshot, one coordinator push, and selective entity state writes + +### Why Not Pure Push + +dcj's span-hass uses pure push (`should_poll = False`, per-entity MQTT callbacks). This works but: + +- Every MQTT property change fires entity state writes through HA's full pipeline — no coalescing +- Each entity parses raw MQTT strings independently — duplicated conversion logic across entity classes +- No single point-of-truth for panel state — harder to implement cross-entity logic (e.g., grid power = sum of circuits) +- The gRPC prototype arrived at the same conclusion: snapshot polling outperformed per-property push on real hardware + +### Why Not Pure Polling + +The v1 REST integration polls on a timer (e.g., every 30s). This works but: + +- MQTT data is already being pushed — polling wastes it +- Timer-based updates add latency (up to one full interval) +- The coordinator's `update_interval=None` + `async_set_updated_data()` gives us push semantics with coordinator lifecycle management + +### Coordinator Implementation Notes + +The integration's coordinator should: + +1. On setup: call `SpanMqttClient.connect()`, then `register_snapshot_callback(on_snapshot)` and `start_streaming()` +2. In `on_snapshot` callback: call `coordinator.async_set_updated_data(snapshot)` +3. The library's `_dispatch_snapshot()` is already debounced by the MQTT message arrival rate — further debouncing in the integration is optional but recommended for burst scenarios (e.g., `asyncio.call_later` with a 0.5s delay, resetting on each new + snapshot) +4. On teardown: call `stop_streaming()` then `SpanMqttClient.close()` + +Entities inherit from `CoordinatorEntity` and read from `self.coordinator.data` (the `SpanPanelSnapshot`). No entity subscribes to MQTT directly. + +--- + +## Integration Impact (span repo) + +The following work is required in the **span** Home Assistant integration to complete the v2 transition: + +### Authentication + +- Replace v1 bearer-token config flow with passphrase-based `register_v2()` flow +- Store `V2AuthResponse` fields in config entry: access token, MQTT broker credentials, `hop_passphrase` +- Implement re-auth flow using stored `hop_passphrase` (calls `register_v2()` again to get fresh broker password) +- Handle `regenerate_passphrase()` for broker password rotation (broker password != hop passphrase after rotation) + +### Circuit UUID Correlation (v1 → v2) + +- **Live panel verification (2026-02-25):** v2 Homie circuit node IDs are **identical dashless UUIDs** to v1 REST circuit IDs. All 22 circuits matched exactly. No correlation or migration needed for current firmware. +- Defensive fallback should still be implemented for future firmware: try direct match → try dash-stripping → fall back to `(name, tabs)` pairs +- No library involvement needed; the integration has both sides (registry + snapshot) + +### Coordinator + +- Replace REST polling `DataUpdateCoordinator` with hybrid model (see above) +- `update_interval=None` — no polling timer +- `register_snapshot_callback()` + `start_streaming()` for push-based updates +- `on_snapshot` callback calls `coordinator.async_set_updated_data(snapshot)` +- Snapshot model is unchanged — entity code needs no modification beyond UUID migration +- Entities remain `CoordinatorEntity` subclasses reading from `coordinator.data` + +### Config Flow + +- Detection: `detect_api_version()` to confirm v2 firmware before setup +- Remove v1 token-entry path entirely (users told not to upgrade integration without v2 firmware) +- New fields: passphrase input step, optional MQTT transport selection (tcp vs websockets) +- Passphrase step must include user guidance: _"Open the SPAN Home app → Settings → All Settings → On-Premise Settings → Passphrase"_ + +### Dependency + +- Update `manifest.json` to require `span-panel-api>=2.0.0` +- Remove any imports of `SpanPanelClient`, `SpanRestClient`, `set_async_delay_func` +- Remove any references to `CircuitCorrelationProtocol`, `CorrelationUnavailableError` + +--- + +## Step 7: Refactor AsyncMqttBridge to HA Core's Async MQTT Pattern + +### Problem + +The current `AsyncMqttBridge` (`mqtt/connection.py`) uses paho-mqtt's `loop_start()` which spawns a background thread. MQTT callbacks run on that thread and are bridged to the asyncio event loop via `call_soon_threadsafe()`. Shared state is protected by +`threading.Lock`. + +This does **not** match HA core's MQTT pattern. HA core's `homeassistant.components.mqtt` uses paho-mqtt with a fundamentally different architecture: + +- **No background thread** — paho's socket is registered with the asyncio event loop via `add_reader`/`add_writer`, calling `loop_read()`/`loop_write()`/`loop_misc()` directly from the event loop +- **NullLock** — `AsyncMQTTClient` subclasses paho's `Client` and replaces all 7 internal threading locks with no-ops (everything runs on the single event loop thread) +- **Zero threading** — no `loop_start()`, no `threading.Lock` + +span-panel-api must conform to this pattern before the integration can be considered for HA core submission. + +### Reference implementation (read-only) + +| File | Content | +| -------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `~/projects/HA/core/homeassistant/components/mqtt/async_client.py` | `NullLock` (lines 21-45), `AsyncMQTTClient` (lines 47-74) | +| `~/projects/HA/core/homeassistant/components/mqtt/client.py:312-410` | `MqttClientSetup` — client creation in executor | +| `~/projects/HA/core/homeassistant/components/mqtt/client.py:500-678` | Socket callbacks: `add_reader`/`add_writer`, `loop_read`/`loop_write`/`loop_misc` | +| `~/projects/HA/core/homeassistant/components/mqtt/client.py:705-777` | `async_connect` (executor), `_reconnect_loop` (async) | + +### Design decision: Replicate pattern, do NOT depend on HA + +span-panel-api replicates the async MQTT pattern (~80 lines of stable code) without importing from `homeassistant`. Reasons: + +1. **HA core requires independence.** PyPI libraries listed in `manifest.json` requirements must not import from `homeassistant`. The library sits below the HA boundary. +2. **Circular dependency.** `span (integration)` → `span-panel-api` → `homeassistant` would require the full HA environment to install the library. +3. **Simulation engine stays standalone.** `DynamicSimulationEngine` produces snapshots without any HA context — tests run in <1s with zero HA dependency. +4. **Version decoupling.** HA's MQTT internals (lock names, socket callback signatures) are not a public API. Importing them creates fragile coupling. + +The overhead is ~80 lines of trivially stable code (paho-mqtt's 7 internal lock names haven't changed across major versions). + +### Step 7a: New file `mqtt/async_client.py` + +Port of HA core's `async_client.py` (74 lines): + +```python +class NullLock: + """No-op lock for single-threaded event loop execution.""" + # __enter__, __exit__, acquire, release — all no-ops + # @lru_cache(maxsize=7) on each method + +class AsyncMQTTClient(paho.Client): + """paho Client subclass with NullLock replacing all 7 internal locks.""" + def setup(self) -> None: + self._in_callback_mutex = NullLock() + self._callback_mutex = NullLock() + self._msgtime_mutex = NullLock() + self._out_message_mutex = NullLock() + self._in_message_mutex = NullLock() + self._reconnect_delay_mutex = NullLock() + self._mid_generate_mutex = NullLock() +``` + +### Step 7b: Rewrite `mqtt/connection.py` + +Replace the thread-based `AsyncMqttBridge` with event-loop-only implementation. + +**Remove:** + +- `import threading` +- `self._lock = threading.Lock()` and all `with self._lock:` blocks +- `self._client.loop_start()` / `self._client.loop_stop()` +- `call_soon_threadsafe()` in `_on_connect`, `_on_disconnect`, `_on_message` + +**Add — Socket callback plumbing:** + +| Callback | Purpose | Source pattern | +| ----------------------------------- | -------------------------------------------------------------- | -------------------- | +| `_async_reader_callback` | `loop.add_reader` handler → calls `client.loop_read()` | HA core line 552-556 | +| `_async_writer_callback` | `loop.add_writer` handler → calls `client.loop_write()` | HA core line 646-650 | +| `_async_start_misc_periodic` | Schedules `client.loop_misc()` every 1s via `loop.call_at()` | HA core line 558-573 | +| `_async_on_socket_open` | Register reader + start misc timer | HA core line 614-628 | +| `_async_on_socket_close` | Remove reader, cancel misc timer | HA core line 630-644 | +| `_async_on_socket_register_write` | Register writer | HA core line 660-668 | +| `_async_on_socket_unregister_write` | Remove writer | HA core line 670-678 | +| `_on_socket_open_sync` | Executor-time bridge → `call_soon_threadsafe` to async version | HA core line 606-612 | +| `_on_socket_register_write_sync` | Executor-time bridge → `call_soon_threadsafe` to async version | HA core line 652-658 | + +**Connect flow — executor pattern:** + +```python +async def connect(self) -> None: + self._loop = asyncio.get_running_loop() + self._connect_event = asyncio.Event() + + # Build AsyncMQTTClient (replaces paho.Client) + self._client = AsyncMQTTClient( + callback_api_version=CallbackAPIVersion.VERSION2, + transport=self._transport, + reconnect_on_failure=False, # We manage reconnection ourselves + ) + self._client.setup() # Replace paho locks with NullLock + + # TLS, auth, LWT configuration (unchanged) + ... + + # Wire callbacks — run directly on event loop (no thread dispatch) + self._client.on_connect = self._on_connect + self._client.on_disconnect = self._on_disconnect + self._client.on_message = self._on_message + self._client.on_socket_close = self._async_on_socket_close + self._client.on_socket_unregister_write = self._async_on_socket_unregister_write + + # Connect in executor (blocking DNS + TCP + TLS) + # During executor connect, socket callbacks need sync→async bridge + try: + self._client.on_socket_open = self._on_socket_open_sync + self._client.on_socket_register_write = self._on_socket_register_write_sync + await self._loop.run_in_executor( + None, + partial(self._client.connect, host=..., port=..., keepalive=...), + ) + finally: + # Switch to direct async callbacks after executor returns + self._client.on_socket_open = self._async_on_socket_open + self._client.on_socket_register_write = self._async_on_socket_register_write + + # Wait for CONNACK + await asyncio.wait_for(self._connect_event.wait(), timeout=MQTT_CONNECT_TIMEOUT_S) +``` + +**Callback simplification — no locks, no thread dispatch:** + +```python +# BEFORE (thread-based): +def _on_connect(self, ...): + with self._lock: + self._connected = connected + if self._loop is not None: + self._loop.call_soon_threadsafe(self._connect_event.set) + +# AFTER (event-loop-only): +def _on_connect(self, ...): + self._connected = connected # No lock — single-threaded + if self._connect_event is not None: + self._connect_event.set() # No call_soon_threadsafe — already on loop +``` + +**Reconnection — async loop replaces paho's built-in:** + +```python +async def _reconnect_loop(self) -> None: + delay = MQTT_RECONNECT_MIN_DELAY_S + while self._should_reconnect: + if not self._connected: + try: + self._client.on_socket_open = self._on_socket_open_sync + self._client.on_socket_register_write = self._on_socket_register_write_sync + await self._loop.run_in_executor(None, self._client.reconnect) + except OSError: + _LOGGER.debug("Reconnect failed, retrying in %ss", delay) + finally: + self._client.on_socket_open = self._async_on_socket_open + self._client.on_socket_register_write = self._async_on_socket_register_write + await asyncio.sleep(delay) + delay = min(delay * MQTT_RECONNECT_BACKOFF_MULTIPLIER, MQTT_RECONNECT_MAX_DELAY_S) +``` + +### Step 7c: Update tests + +Existing tests for `HomieDeviceConsumer` and snapshot building are unaffected (they don't touch the connection layer). + +New tests: + +| Test file | Coverage | +| -------------------------------- | ----------------------------------------------------------------------------- | +| `tests/test_async_client.py` | NullLock no-op behavior, AsyncMQTTClient.setup() replaces 7 locks | +| `tests/test_connection_async.py` | Executor connect, socket callback registration, misc timer, reconnect backoff | + +### Step 7d: Verify no threading remains + +```bash +# No threading imports in mqtt/: +grep -r "threading" src/span_panel_api/mqtt/ +# Should return nothing + +# call_soon_threadsafe only in executor-time sync bridges: +grep "call_soon_threadsafe" src/span_panel_api/mqtt/connection.py +# Should only appear in _on_socket_open_sync and _on_socket_register_write_sync + +# No loop_start/loop_stop: +grep "loop_start\|loop_stop" src/span_panel_api/mqtt/ +# Should return nothing +``` + +### Impact on `SpanMqttClient` (`client.py`) + +Minimal. The client delegates to `AsyncMqttBridge` and its public API (connect, disconnect, subscribe, publish, callbacks) is unchanged. The `_on_message` and `_on_connection_change` callbacks already assume they run on the event loop — no code changes +needed. + +### Updated data flow (after refactor) + +```text +MQTT broker + → paho socket → asyncio add_reader + → loop_read() → paho internal dispatch + → _on_message() ← runs on event loop, no thread + → HomieDeviceConsumer._handle_property() + → property_values[node_id][prop_id] = value ← cheap dict write + +(on controlled interval) +HomieDeviceConsumer.build_snapshot() ← builds frozen dataclass + → coordinator.async_set_updated_data(snapshot) ← single coordinator push +``` + +--- + +## Final Package Structure + +```text +src/span_panel_api/ +├── __init__.py # Public API exports +├── auth.py # v2 HTTP provisioning (register, cert, schema) +├── const.py # Panel state constants (DSM, relay) +├── detection.py # API version detection +├── exceptions.py # Exception hierarchy (simplified) +├── factory.py # create_span_client() → SpanMqttClient +├── models.py # Snapshot dataclasses + auth response models +├── phase_validation.py # Electrical phase utilities +├── protocol.py # PEP 544 protocols (3 protocols, simplified capability flags) +├── simulation.py # Simulation engine (produces snapshots) +└── mqtt/ + ├── __init__.py + ├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern) + ├── client.py # SpanMqttClient + ├── connection.py # AsyncMqttBridge (event-loop-driven, no threads) + ├── const.py # MQTT/Homie constants + UUID helpers + ├── homie.py # HomieDeviceConsumer (Homie v5 parser) + └── models.py # MqttClientConfig, MqttTransport +``` + +### Approximate lines removed + +| Category | Lines | +| --------------------- | ---------- | +| `rest/` (all files) | ~2100 | +| `client.py` (shim) | ~17 | +| `generated_client/` | ~2000+ | +| REST test files (~22) | ~3000+ | +| Example scripts (7) | ~500+ | +| **Total** | **~7600+** | + +### Dependencies after cut + +| Package | Status | +| ----------------- | ---------------------------- | +| `httpx` | Kept (auth.py, detection.py) | +| `paho-mqtt` | Required (was optional) | +| `attrs` | Removed | +| `python-dateutil` | Removed | + +--- + +## Phase 7: MQTT Snapshot Debounce + +**Goal:** Reduce CPU load from high-frequency MQTT messages by debouncing snapshot rebuilds. + +**Problem:** The SPAN panel publishes ~100 MQTT messages/second. Each message triggers a full `build_snapshot()` — iterating all nodes, circuits, and properties — plus coordinator dispatch and entity updates. On low-end hardware (Raspberry Pi) this is +untenable, mirroring the gRPC streaming CPU problem. + +**Solution:** Rate-limit `build_snapshot()` + callback dispatch in `SpanMqttClient`. MQTT messages continue to update the Homie property store immediately (cheap dict writes), but the expensive snapshot rebuild is gated by a configurable timer. + +| Component | Change | +| ------------------------------- | ------------------------------------------------------- | +| `SpanMqttClient.__init__` | `snapshot_interval` param (default 1.0s) | +| `SpanMqttClient._on_message` | Schedule debounce timer instead of per-message dispatch | +| `SpanMqttClient._fire_snapshot` | Timer callback — build + dispatch one snapshot | +| Integration options flow | `snapshot_update_interval` option (0–15s, default 1s) | +| Integration `__init__.py` | Pass interval from config to client constructor | + +Setting interval to 0 preserves current no-debounce behavior. + +--- + +## Verification (after each step) + +```bash +poetry lock --no-update +poetry install +poetry run pytest tests/ -x -q +poetry run mypy src/span_panel_api/ +poetry run ruff check src/span_panel_api/ tests/ +poetry run ruff format --check src/span_panel_api/ tests/ +``` diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index ecbd5ea..0000000 --- a/examples/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# SPAN Panel API Example Configurations - -This directory contains example YAML configurations for the SPAN Panel API simulation system. These configurations are used both for demonstrations and as test fixtures to ensure consistent behavior across the test suite. - -## Configuration Files - -### Basic Configurations - -- **`minimal_config.yaml`** - Minimal working configuration with basic settings -- **`simple_test_config.yaml`** - Simple configuration for general testing -- **`validation_test_config.yaml`** - Basic configuration for validation testing and error scenarios - -### Specialized Configurations - -- **`battery_test_config.yaml`** - Battery system with bidirectional energy flow for testing battery behavior -- **`producer_test_config.yaml`** - Producer energy profiles (solar, generators) for testing energy generation -- **`behavior_test_config.yaml`** - Demonstrates cycling, time-of-day, and smart behaviors -- **`error_test_config.yaml`** - Minimal configuration for error testing scenarios - -### Workshop and Demo Configurations - -- **`simulation_config_8_tab_workshop.yaml`** - 8-tab panel configuration for workshop demonstrations -- **`simulation_config_32_circuit.yaml`** - 32-circuit panel with tab synchronization and unmapped tabs -- **`simulation_config_40_circuit_with_battery.yaml`** - Large panel configuration with battery storage - -## Test Integration - -These configurations are designed to be used as test fixtures, ensuring that: - -1. **Tests use real configurations** - No scattered embedded YAML data in test files -2. **Examples are validated** - Using configurations in tests ensures they work correctly -3. **Consistency** - All tests use the same known-good configurations -4. **Maintainability** - Changes to configuration format only need to be made in example files - -## Usage in Tests - -Tests import configurations like this: - -```python -import yaml -from pathlib import Path - -config_path = Path(__file__).parent.parent / "examples" / "validation_test_config.yaml" -with open(config_path) as f: - config = yaml.safe_load(f) -``` - -## Energy Profile Templates - -The configurations demonstrate various energy profile types: - -- **Consumer profiles** - Standard electrical loads (lights, appliances) -- **Producer profiles** - Energy generation (solar, generators, wind) -- **Bidirectional profiles** - Battery storage systems -- **Time-of-day patterns** - Solar production and lighting schedules -- **Cycling behaviors** - HVAC and appliance cycling -- **Smart behaviors** - Grid-responsive loads - -## Demo Scripts - -Several demo scripts use these configurations: - -- `clean_api_demo.py` - Clean API demonstration -- `simulation_demo.py` - Full simulation features -- `battery_behavior_demo.py` - Battery-specific behaviors -- `test_multi_energy_sources.py` - Multiple energy source types -- `test_simulation_time.py` - Time control features diff --git a/examples/battery_behavior_demo.py b/examples/battery_behavior_demo.py deleted file mode 100644 index 544b522..0000000 --- a/examples/battery_behavior_demo.py +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env python3 -"""Demonstrate enhanced battery behavior with time-based charging/discharging and dynamic SOE.""" - -import asyncio -from datetime import datetime -from pathlib import Path - -from span_panel_api import SpanPanelClient - - -def get_behavior_description(hour: int) -> tuple[str, str]: - """Get expected battery behavior and SOE pattern for a given hour.""" - if 9 <= hour <= 16: - return "⚡ CHARGING (Solar)", "📈 SOE Rising (Solar charging)" - elif 17 <= hour <= 21: - return "🔋 DISCHARGING (Peak)", "📉 SOE Falling (Peak demand)" - elif hour in [0, 1, 2, 3, 4, 5, 22, 23]: - return "😴 MINIMAL (Night)", "📊 SOE Slowly falling" - else: - return "🌅 TRANSITION", "📊 SOE Moderate" - - -async def demonstrate_battery_behavior(): - """Demonstrate enhanced battery behavior throughout different times of day.""" - config_path = Path(__file__).parent / "simulation_config_40_circuit_with_battery.yaml" - - print("🔋 ENHANCED BATTERY BEHAVIOR DEMONSTRATION 🔋") - print("=" * 60) - print("Features:") - print("✅ Time-based charging during solar hours (9 AM - 4 PM)") - print("✅ Time-based discharging during peak hours (5 PM - 9 PM)") - print("✅ Dynamic SOE calculation based on battery activity") - print("✅ YAML-driven configuration (no hardcoded values)") - print("✅ Realistic solar intensity and demand profiles") - print() - - async with SpanPanelClient(host="battery-demo", simulation_mode=True, simulation_config_path=str(config_path)) as client: - - # Show current actual time for reference - current_time = datetime.now() - current_hour = current_time.hour - behavior_desc, soe_desc = get_behavior_description(current_hour) - - print(f"📅 Current time: {current_time.strftime('%Y-%m-%d %H:%M:%S')}") - print(f"🕐 Current hour: {current_hour}") - print(f"🔋 Expected behavior: {behavior_desc}") - print(f"📊 Expected SOE pattern: {soe_desc}") - print() - - print("🔬 CURRENT BATTERY STATUS:") - print("-" * 40) - - # Get current data - circuits = await client.get_circuits() - storage = await client.get_storage_soe() - - # Find battery systems - battery_1 = circuits.circuits.additional_properties.get("battery_system_1") - battery_2 = circuits.circuits.additional_properties.get("battery_system_2") - - if battery_1: - status_1 = ( - "⚡ Charging" - if battery_1.instant_power_w < -100 - else "🔋 Discharging" if battery_1.instant_power_w > 100 else "😴 Minimal" - ) - print(f"Battery System 1: {battery_1.instant_power_w:8.1f}W {status_1}") - - if battery_2: - status_2 = ( - "⚡ Charging" - if battery_2.instant_power_w < -100 - else "🔋 Discharging" if battery_2.instant_power_w > 100 else "😴 Minimal" - ) - print(f"Battery System 2: {battery_2.instant_power_w:8.1f}W {status_2}") - - if battery_1 and battery_2: - combined_power = battery_1.instant_power_w + battery_2.instant_power_w - combined_status = ( - "⚡ Charging" if combined_power < -200 else "🔋 Discharging" if combined_power > 200 else "😴 Minimal" - ) - print(f"Combined Power: {combined_power:8.1f}W {combined_status}") - - print(f"Storage SOE: {storage.soe.percentage:8.1f}%") - print() - - print("📈 SIMULATED 24-HOUR BEHAVIOR PATTERNS:") - print("-" * 60) - - # Show expected behavior for key hours throughout the day - demo_hours = [ - (2, "Night"), - (6, "Early Morning"), - (10, "Solar Ramp-Up"), - (12, "Peak Solar"), - (14, "Continued Solar"), - (18, "Peak Demand"), - (20, "High Demand"), - (23, "Night Wind-Down"), - ] - - for hour, description in demo_hours: - behavior, soe_pattern = get_behavior_description(hour) - - # Show YAML-configured values for this hour - simulation_engine = client._simulation_engine - if simulation_engine and simulation_engine._config: - config = simulation_engine._config - battery_template = config["circuit_templates"]["battery"] - battery_behavior = battery_template["battery_behavior"] - - # Get solar intensity and demand factor from YAML - solar_intensity = battery_behavior["solar_intensity_profile"].get(hour, 0.0) - demand_factor = battery_behavior["demand_factor_profile"].get(hour, 0.0) - - charge_hours = battery_behavior["charge_hours"] - discharge_hours = battery_behavior["discharge_hours"] - idle_hours = battery_behavior["idle_hours"] - - if hour in charge_hours: - expected_power = f"{battery_behavior['max_charge_power'] * solar_intensity:.0f}W" - elif hour in discharge_hours: - expected_power = f"{battery_behavior['max_discharge_power'] * demand_factor:.0f}W" - elif hour in idle_hours: - idle_range = battery_behavior["idle_power_range"] - expected_power = f"{idle_range[0]:.0f} to {idle_range[1]:.0f}W" - else: - expected_power = "Variable" - - print(f"{hour:2d}:00 {description:15s} │ {behavior:25s} │ {soe_pattern:25s} │ ~{expected_power}") - - print() - print("🎛️ YAML CONFIGURATION HIGHLIGHTS:") - print("-" * 40) - print("⚙️ Charge Hours: 9 AM - 4 PM (solar production)") - print("⚙️ Discharge Hours: 5 PM - 9 PM (peak demand)") - print("⚙️ Max Charge Power: -3000W (configurable)") - print("⚙️ Max Discharge Power: +2500W (configurable)") - print("⚙️ Solar Intensity Profile: Hour-by-hour (0.2 to 1.0)") - print("⚙️ Demand Factor Profile: Hour-by-hour (0.6 to 1.0)") - print("⚙️ Idle Power Range: -100W to +100W (minimal activity)") - print() - print("✨ All values are configurable in the YAML file!") - print("✨ No hardcoded behavior - completely data-driven!") - - -if __name__ == "__main__": - asyncio.run(demonstrate_battery_behavior()) diff --git a/examples/clean_api_demo.py b/examples/clean_api_demo.py deleted file mode 100644 index 1e8a248..0000000 --- a/examples/clean_api_demo.py +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env python3 -""" -Demonstration of the new clean YAML-based simulation API. - -This example shows: -1. Clean API - no variation parameters on core methods -2. YAML configuration for realistic behaviors -3. Dynamic override system for temporary variations -4. Multiple panel configurations - -Run with: poetry run python examples/clean_api_demo.py -""" - -import asyncio -from pathlib import Path - -from span_panel_api.client import SpanPanelClient - - -async def demo_clean_api(): - """Demonstrate the clean YAML-based simulation API.""" - - print("🏠 SPAN Panel API - Clean YAML Simulation Demo") - print("=" * 50) - - # Example 1: Basic simulation with YAML configuration - print("\n📋 Example 1: YAML-configured simulation") - print("-" * 40) - - config_path = Path(__file__).parent / "simulation_config_32_circuit.yaml" - - async with SpanPanelClient( - host="demo-house-32", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - - # Clean API - no variation parameters needed - circuits = await client.get_circuits() - panel = await client.get_panel_state() - storage = await client.get_storage_soe() - status = await client.get_status() - - print(f"✅ Connected to simulated panel: {status.system.serial}") - print(f"📊 Found {len(circuits.circuits.additional_properties)} circuits") - print(f"⚡ Grid power: {panel.instant_grid_power_w:.1f}W") - print(f"🔋 Battery SOE: {storage.soe.percentage:.0f}%") - - # Show some interesting circuits - print("\n🔌 Sample circuit data:") - for _circuit_id, circuit in list(circuits.circuits.additional_properties.items())[:5]: - print(f" • {circuit.name}: {circuit.instant_power_w:.1f}W ({circuit.relay_state})") - - # Example 2: Dynamic overrides for testing scenarios - print("\n🎛️ Example 2: Dynamic circuit overrides") - print("-" * 40) - - async with SpanPanelClient( - host="demo-house-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - - # Get baseline data - circuits_before = await client.get_circuits() - ev_before = circuits_before.circuits.additional_properties.get("ev_charger_garage") - - if ev_before: - print(f"🚗 EV Charger before override: {ev_before.instant_power_w:.1f}W") - else: - print("🚗 EV Charger circuit not found, using default values") - - # Apply dynamic override to simulate high-power charging - await client.set_circuit_overrides( - {"ev_charger_garage": {"power_override": 11000.0, "relay_state": "CLOSED"}} # Max charging power - ) - - # Get updated data - circuits_after = await client.get_circuits() - ev_after = circuits_after.circuits.additional_properties.get("ev_charger_garage") - - if ev_after: - print(f"🚗 EV Charger after override: {ev_after.instant_power_w:.1f}W") - else: - print("🚗 EV Charger circuit not found after override") - print("✅ Dynamic override applied successfully!") - - # Clear overrides to return to YAML-defined behavior - await client.clear_circuit_overrides() - - circuits_restored = await client.get_circuits() - ev_restored = circuits_restored.circuits.additional_properties.get("ev_charger_garage") - - if ev_restored: - print(f"🚗 EV Charger after clearing: {ev_restored.instant_power_w:.1f}W") - else: - print("🚗 EV Charger circuit not found after clearing") - print("✅ Overrides cleared - back to YAML configuration") - - # Example 3: Global overrides for stress testing - print("\n🌍 Example 3: Global power multiplier") - print("-" * 40) - - async with SpanPanelClient( - host="demo-stress-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - - # Get baseline total power - circuits_normal = await client.get_circuits() - total_normal = sum(circuit.instant_power_w for circuit in circuits_normal.circuits.additional_properties.values()) - - print(f"⚡ Total power (normal): {total_normal:.1f}W") - - # Apply 2x power multiplier for stress testing - await client.set_circuit_overrides(global_overrides={"power_multiplier": 2.0}) - - circuits_stressed = await client.get_circuits() - total_stressed = sum( - circuit.instant_power_w for circuit in circuits_stressed.circuits.additional_properties.values() - ) - - print(f"⚡ Total power (2x multiplier): {total_stressed:.1f}W") - print(f"🔥 Power increase: {(total_stressed/total_normal):.1f}x") - - # Example 4: Multiple configurations - print("\n🏘️ Example 4: Different house configurations") - print("-" * 40) - - # Small house simulation (using same config for demo purposes) - async with SpanPanelClient( - host="small-house", simulation_mode=True, simulation_config_path=str(config_path) - ) as small_client: - - small_circuits = await small_client.get_circuits() - print(f"🏠 Small house: {len(small_circuits.circuits.additional_properties)} circuits") - - # Large house simulation (would use 40-circuit YAML) - async with SpanPanelClient( - host="large-house", simulation_mode=True, simulation_config_path=str(config_path) # Same config, different serial - ) as large_client: - - large_circuits = await large_client.get_circuits() - large_status = await large_client.get_status() - print(f"🏰 Large house: {len(large_circuits.circuits.additional_properties)} circuits") - print(f" Serial: {large_status.system.serial}") - - print("\n✨ Demo:") - print(" • Clean API - no variation parameters on core methods") - print(" • YAML configuration defines realistic behaviors") - print(" • Dynamic overrides for temporary test scenarios") - print(" • Multiple panel configurations via different YAML files") - print(" • Realistic time-of-day patterns, cycling, and smart behaviors") - - -async def demo_yaml_features(): - """Demonstrate specific YAML configuration features.""" - - print("\n🔧 YAML Configuration Features Demo") - print("=" * 50) - - config_path = Path(__file__).parent / "simulation_config_32_circuit.yaml" - - async with SpanPanelClient(host="feature-demo", simulation_mode=True, simulation_config_path=str(config_path)) as client: - - circuits = await client.get_circuits() - - print("\n🏠 Circuit Templates Demonstrated:") - - # Show different circuit types - circuit_examples = { - "exterior_lights": "🌙 Time-of-day profile (nighttime peak)", - "main_hvac": "🔄 Cycling behavior (20min on, 40min off)", - "solar_inverter_main": "☀️ Solar production (daylight hours)", - "ev_charger_garage": "🧠 Smart grid response", - "refrigerator": "❄️ Always-on with compressor cycling", - "pool_pump": "🏊 Scheduled operation (2h on, 4h off)", - } - - for circuit_id, description in circuit_examples.items(): - if circuit_id in circuits.circuits.additional_properties: - circuit = circuits.circuits.additional_properties[circuit_id] - print(f" • {description}") - print(f" {circuit.name}: {circuit.instant_power_w:.1f}W ({circuit.priority})") - - print("\n📈 Realistic Behaviors Active:") - print(" • Time-based variations (lighting, solar)") - print(" • Equipment cycling (HVAC, appliances)") - print(" • Smart load response (EV charger)") - print(" • Seasonal efficiency changes") - print(" • Random noise (±2%) for realism") - - -if __name__ == "__main__": - # Run both demos - asyncio.run(demo_clean_api()) - asyncio.run(demo_yaml_features()) diff --git a/examples/demo_energy_sources.py b/examples/demo_energy_sources.py deleted file mode 100644 index 28f2c85..0000000 --- a/examples/demo_energy_sources.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -""" -Demonstration of the generic energy profile system supporting various energy sources. - -This example shows how the same energy profile structure can be used for: -- Solar panels -- Backup generators -- Wind turbines -- Battery storage (bidirectional) -- Regular consumption loads - -Run with: poetry run python examples/demo_energy_sources.py -""" - -import asyncio -from pathlib import Path - -from span_panel_api.client import SpanPanelClient - - -async def demo_energy_sources(): - """Demonstrate various energy sources using the generic energy profile system.""" - - print("⚡ Generic Energy Profile System Demo") - print("=" * 50) - print("Supporting: Solar, Generators, Wind, Batteries & More!") - - config_path = Path(__file__).parent / "simulation_config_32_circuit.yaml" - - async with SpanPanelClient( - host="energy-sources-demo", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - - # Get current state - circuits = await client.get_circuits() - circuit_dict = circuits.circuits.additional_properties - - print("\n🔋 Current Energy Sources:") - print("-" * 30) - - # Show unmapped tabs 30 & 32 (configured as producers) - for tab_num in [30, 32]: - circuit_id = f"unmapped_tab_{tab_num}" - if circuit_id in circuit_dict: - circuit = circuit_dict[circuit_id] - power = circuit.instant_power_w - if power < 0: - print(f"🌞 Tab {tab_num}: {abs(power):.1f}W PRODUCTION") - print(f" • Generic producer profile - could be solar, generator, or wind") - print(f" • Produced Energy: {circuit.produced_energy_wh:.2f}Wh") - print(f" • Consumed Energy: {circuit.consumed_energy_wh:.2f}Wh") - - # Show some consumer circuits - print(f"\n🏠 Energy Consumers:") - print("-" * 20) - - consumer_examples = ["main_hvac", "ev_charger_garage", "master_bedroom_lights"] - for circuit_id in consumer_examples: - if circuit_id in circuit_dict: - circuit = circuit_dict[circuit_id] - power = circuit.instant_power_w - print(f"🔌 {circuit.name}: {power:.1f}W") - - print(f"\n📊 Energy Profile Benefits:") - print("-" * 25) - print("✅ Unified configuration for all energy sources") - print("✅ Support for producers: solar, generators, wind, hydro") - print("✅ Support for consumers: loads, appliances, HVAC") - print("✅ Support for bidirectional: batteries, grid-tie inverters") - print("✅ Tab synchronization for 240V multi-phase systems") - print("✅ Realistic time-of-day and cycling behaviors") - print("✅ Smart grid response capabilities") - - print(f"\n🎯 Configuration Examples:") - print("-" * 22) - print("Solar Panel:") - print(" energy_profile:") - print(" mode: 'producer'") - print(" power_range: [-5000.0, 0.0]") - print(" typical_power: -3000.0") - print("") - print("Backup Generator:") - print(" energy_profile:") - print(" mode: 'producer'") - print(" power_range: [-8000.0, 0.0]") - print(" efficiency: 0.92") - print("") - print("Battery Storage:") - print(" energy_profile:") - print(" mode: 'bidirectional'") - print(" power_range: [-5000.0, 5000.0]") - print(" efficiency: 0.95") - - -if __name__ == "__main__": - asyncio.run(demo_energy_sources()) diff --git a/examples/error_test_config.yaml b/examples/error_test_config.yaml deleted file mode 100644 index 1ec69e8..0000000 --- a/examples/error_test_config.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# Error Test Configuration -# Used for testing error conditions and edge cases - -panel_config: - serial_number: "ERROR-TEST-001" - total_tabs: 4 - main_size: 100 - -circuit_templates: - minimal_consumer: - energy_profile: - mode: "consumer" - power_range: [0.0, 50.0] - typical_power: 25.0 - power_variation: 0.1 - relay_behavior: "controllable" - priority: "NON_ESSENTIAL" - -circuits: - - id: "error_test_circuit" - name: "Error Test Circuit" - template: "minimal_consumer" - tabs: [1] - -unmapped_tabs: [2, 3, 4] - -simulation_params: - update_interval: 1 - time_acceleration: 1.0 - noise_factor: 0.05 - enable_realistic_behaviors: false diff --git a/examples/minimal_config.yaml b/examples/minimal_config.yaml deleted file mode 100644 index 4955d45..0000000 --- a/examples/minimal_config.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# Minimal configuration for basic testing -panel_config: - serial_number: "SPAN-MIN-001" - total_tabs: 4 - main_size: 100 - -circuit_templates: - basic_load: - energy_profile: - mode: "consumer" - power_range: [10.0, 100.0] - typical_power: 50.0 - power_variation: 0.1 - relay_behavior: "controllable" - priority: "MUST_HAVE" - -circuits: - - id: "test_circuit" - name: "Test Circuit" - template: "basic_load" - tabs: [1, 3] # 240V circuit (L1 + L2) - -unmapped_tabs: [2, 4] - -simulation_params: - update_interval: 5 - time_acceleration: 1.0 - noise_factor: 0.05 - enable_realistic_behaviors: false diff --git a/examples/producer_test_config.yaml b/examples/producer_test_config.yaml deleted file mode 100644 index 889e4ed..0000000 --- a/examples/producer_test_config.yaml +++ /dev/null @@ -1,103 +0,0 @@ -# Producer Test Configuration -# Used for testing producer energy profiles (solar, generators, etc.) - -panel_config: - serial_number: "PRODUCER-TEST-001" - total_tabs: 8 - main_size: 100 - -circuit_templates: - solar_producer: - energy_profile: - mode: "producer" - power_range: [-2000.0, 0.0] - typical_power: -1000.0 - power_variation: 0.3 - relay_behavior: "controllable" - priority: "ESSENTIAL" - time_of_day_profile: - enabled: true - - generator_producer: - energy_profile: - mode: "producer" - power_range: [-5000.0, 0.0] - typical_power: -3000.0 - power_variation: 0.1 - relay_behavior: "controllable" - priority: "ESSENTIAL" - - basic_consumer: - energy_profile: - mode: "consumer" - power_range: [0.0, 1000.0] - typical_power: 500.0 - power_variation: 0.1 - relay_behavior: "controllable" - priority: "NON_ESSENTIAL" - - battery: - energy_profile: - mode: "bidirectional" - power_range: [-3000.0, 3000.0] - typical_power: 0.0 - power_variation: 0.1 - relay_behavior: "non_controllable" - priority: "MUST_HAVE" - battery_behavior: - enabled: true - charge_hours: [10, 11, 12, 13, 14, 15] # Charge during solar production - discharge_hours: [18, 19, 20, 21] # Discharge during peak demand - idle_hours: [22, 23, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 16, 17] - max_charge_power: -2500.0 - max_discharge_power: 2000.0 - idle_power_range: [-50.0, 50.0] - -circuits: - - id: "solar_array" - name: "Solar Array" - template: "solar_producer" - tabs: [1, 3] # L1 + L2 = Valid 240V - - - id: "backup_generator" - name: "Backup Generator" - template: "generator_producer" - tabs: [2, 4] # L1 + L2 = Valid 240V - - - id: "house_load" - name: "House Load" - template: "basic_consumer" - tabs: [5, 7] # L1 + L2 = Valid 240V - - - id: "battery_storage" - name: "Battery Storage" - template: "battery" - tabs: [6, 8] # L1 + L2 = Valid 240V - -# Circuit synchronizations for 240V systems -tab_synchronizations: - - tabs: [1, 3] # Solar array 240V split phase - behavior: "240v_split_phase" - power_split: "equal" - energy_sync: true - template: "solar_sync" - - - tabs: [2, 4] # Generator 240V split phase - behavior: "240v_split_phase" - power_split: "equal" - energy_sync: true - template: "generator_sync" - - - tabs: [6, 8] # Battery storage 240V split phase - behavior: "240v_split_phase" - power_split: "equal" - energy_sync: true - template: "battery_sync" - -unmapped_tabs: [] # All tabs now used - -simulation_params: - update_interval: 1 - time_acceleration: 1.0 - noise_factor: 0.05 - enable_realistic_behaviors: true diff --git a/examples/simple_test_config_no_sync.yaml b/examples/simple_test_config_no_sync.yaml deleted file mode 100644 index a220855..0000000 --- a/examples/simple_test_config_no_sync.yaml +++ /dev/null @@ -1,48 +0,0 @@ -# Simple test configuration without tab synchronization -panel_config: - serial_number: "SPAN-TEST-001" - total_tabs: 32 - main_size: 200 - -circuit_templates: - basic_consumer: - energy_profile: - mode: "consumer" - power_range: [0.0, 1000.0] - typical_power: 100.0 - power_variation: 0.1 - relay_behavior: "controllable" - priority: "MUST_HAVE" - - basic_producer: - energy_profile: - mode: "producer" - power_range: [-2000.0, 0.0] - typical_power: -500.0 - power_variation: 0.2 - relay_behavior: "non_controllable" - priority: "MUST_HAVE" - -circuits: - - id: "test_circuit_1" - name: "Test Circuit 1" - template: "basic_consumer" - tabs: [1] - - - id: "test_circuit_2" - name: "Test Circuit 2" - template: "basic_consumer" - tabs: [2] - - - id: "test_solar" - name: "Test Solar" - template: "basic_producer" - tabs: [3] - -unmapped_tabs: [4, 5, 6] - -simulation_params: - update_interval: 5 - time_acceleration: 1.0 - noise_factor: 0.02 - enable_realistic_behaviors: true diff --git a/examples/simulation_demo.py b/examples/simulation_demo.py deleted file mode 100644 index 3ae9cd8..0000000 --- a/examples/simulation_demo.py +++ /dev/null @@ -1,288 +0,0 @@ -#!/usr/bin/env python3 -""" -SPAN Panel API Simulation Mode Demo - -This script demonstrates the simulation mode capabilities of the SPAN Panel API client. -It shows how to use various simulation features for testing and development. -""" - -import asyncio - -from span_panel_api import SpanPanelClient -from span_panel_api.const import DSM_GRID_DOWN, DSM_OFF_GRID -from span_panel_api.simulation import BranchVariation, CircuitVariation, PanelVariation, StatusVariation - - -async def demo_basic_simulation() -> None: - """Demonstrate basic simulation mode functionality.""" - print("=== Basic Simulation Mode Demo ===") - - # Create a simulation mode client - client = SpanPanelClient(host="localhost", simulation_mode=True) - - async with client: - # Get basic data from all APIs - print("\n1. Basic API calls:") - - status = await client.get_status() - print(f" Status: Door={status.system.door_state.value}, Network={status.network.eth_0_link}") - - storage = await client.get_storage_soe() - print(f" Storage: {storage.soe.percentage}% SOE") - - panel = await client.get_panel_state() - print(f" Panel: {panel.main_relay_state.value}, {len(panel.branches)} branches") - - circuits = await client.get_circuits() - print(f" Circuits: {len(circuits.circuits.additional_properties)} total") - - -async def demo_circuit_variations() -> None: - """Demonstrate circuit-specific variations.""" - print("\n=== Circuit Variations Demo ===") - - client = SpanPanelClient(host="localhost", simulation_mode=True) - - async with client: - # Get baseline circuits - baseline = await client.get_circuits() - circuit_ids = list(baseline.circuits.additional_properties.keys()) - - if len(circuit_ids) >= 2: - circuit_1 = circuit_ids[0] - circuit_2 = circuit_ids[1] - - print("\n2. Circuit-specific variations:") - print(f" Testing circuits: {baseline.circuits.additional_properties[circuit_1].name}") - print(f" {baseline.circuits.additional_properties[circuit_2].name}") - - # Apply specific variations - variations = { - circuit_1: CircuitVariation(power_variation=0.5, relay_state="OPEN", priority="NON_ESSENTIAL"), - circuit_2: CircuitVariation(power_variation=0.8, energy_variation=0.2), - } - - varied_circuits = await client.get_circuits(variations=variations) - - # Show results - c1_baseline = baseline.circuits.additional_properties[circuit_1] - c1_varied = varied_circuits.circuits.additional_properties[circuit_1] - - print(" Circuit 1 changes:") - print(f" Relay: {c1_baseline.relay_state.value} -> {c1_varied.relay_state.value}") - print(f" Priority: {c1_baseline.priority.value} -> {c1_varied.priority.value}") - print(f" Power: {c1_baseline.instant_power_w:.1f}W -> {c1_varied.instant_power_w:.1f}W") - - -async def demo_global_variations() -> None: - """Demonstrate global variations affecting all circuits.""" - print("\n=== Global Variations Demo ===") - - client = SpanPanelClient(host="localhost", simulation_mode=True) - - async with client: - print("\n3. Global power variations:") - - # Test different global variation levels - for variation in [0.1, 0.3, 0.5]: - circuits = await client.get_circuits(global_power_variation=variation) - - # Calculate power statistics - powers = [c.instant_power_w for c in circuits.circuits.additional_properties.values()] - avg_power = sum(powers) / len(powers) - - print(f" {variation * 100:3.0f}% variation: avg power = {avg_power:.1f}W") - - -async def demo_panel_variations() -> None: - """Demonstrate panel state variations.""" - print("\n=== Panel State Variations Demo ===") - - client = SpanPanelClient(host="localhost", simulation_mode=True) - - async with client: - print("\n4. Panel state variations:") - - # Test emergency scenarios - emergency_scenarios = [ - ("Normal Operation", None, None), - ( - "Grid Outage", - None, - PanelVariation(main_relay_state="OPEN", dsm_grid_state=DSM_GRID_DOWN, dsm_state=DSM_OFF_GRID), - ), - ("Branch Failure", {1: BranchVariation(relay_state="OPEN")}, None), - ] - - for scenario_name, branch_vars, panel_vars in emergency_scenarios: - panel = await client.get_panel_state(variations=branch_vars, panel_variations=panel_vars) - - branch_1_state = panel.branches[0].relay_state.value if panel.branches else "N/A" - - print(f" {scenario_name}:") - print(f" Main relay: {panel.main_relay_state.value}") - print(f" DSM state: {panel.dsm_state}") - print(f" Branch 1: {branch_1_state}") - - -async def demo_status_variations() -> None: - """Demonstrate status field variations.""" - print("\n=== Status Variations Demo ===") - - client = SpanPanelClient(host="localhost", simulation_mode=True) - - async with client: - print("\n5. Status field variations:") - - # Test different status scenarios - scenarios = [ - ("Normal", None), - ("Door Open", StatusVariation(door_state="OPEN")), - ("Network Issues", StatusVariation(eth0_link=False, wlan_link=False)), - ("Maintenance Mode", StatusVariation(door_state="OPEN", proximity_proven=True, eth0_link=False)), - ] - - for scenario_name, variations in scenarios: - status = await client.get_status(variations=variations) - - print(f" {scenario_name}:") - print(f" Door: {status.system.door_state.value}") - print(f" Ethernet: {status.network.eth_0_link}") - print(f" WiFi: {status.network.wlan_link}") - print(f" Proximity: {status.system.proximity_proven}") - - -async def demo_energy_simulation() -> None: - """Demonstrate energy accumulation over time.""" - print("\n=== Energy Accumulation Demo ===") - - client = SpanPanelClient(host="localhost", simulation_mode=True) - - async with client: - print("\n6. Energy accumulation over time:") - - # Get initial energy values - initial_circuits = await client.get_circuits() - initial_energies = {} - - for circuit_id, circuit in initial_circuits.circuits.additional_properties.items(): - initial_energies[circuit_id] = circuit.consumed_energy_wh - - # Wait a bit and get updated values - await asyncio.sleep(0.1) - - updated_circuits = await client.get_circuits() - - # Show energy changes - changes = 0 - for circuit_id, circuit in updated_circuits.circuits.additional_properties.items(): - if circuit_id in initial_energies: - initial = initial_energies[circuit_id] - current = circuit.consumed_energy_wh - if abs(current - initial) > 0.001: # Small threshold for floating point - changes += 1 - - print(f" Energy accumulated in {changes} circuits over 0.1 seconds") - print(" (Energy accumulation is based on current power consumption)") - - -async def demo_storage_variations() -> None: - """Demonstrate storage SOE variations.""" - print("\n=== Storage SOE Variations Demo ===") - - client = SpanPanelClient(host="localhost", simulation_mode=True) - - async with client: - print("\n7. Storage SOE variations:") - - # Test different battery levels - baseline = await client.get_storage_soe() - print(f" Baseline: {baseline.soe.percentage:.1f}%") - - for variation in [0.1, 0.3, 0.5]: - storage = await client.get_storage_soe(soe_variation=variation) - print(f" {variation * 100:3.0f}% variation: {storage.soe.percentage:.1f}%") - - -async def demo_live_mode_comparison() -> None: - """Demonstrate that live mode ignores variations.""" - print("\n=== Live Mode Comparison Demo ===") - - print("\n8. Live mode ignores variations:") - - # Create live mode client - live_client = SpanPanelClient(host="192.168.1.100", simulation_mode=False) - - try: - async with live_client: - # These variations will be completely ignored - circuits = await live_client.get_circuits( - variations={"any_id": CircuitVariation(power_variation=999.0)}, global_power_variation=999.0 - ) - print(" Live mode: Variations ignored (would connect to real panel)") - except Exception as e: - print(f" Live mode: Expected connection error - {type(e).__name__}") - print(" (Variations would be ignored if panel was available)") - - -async def demo_caching_behavior() -> None: - """Demonstrate caching behavior in simulation mode.""" - print("\n=== Caching Behavior Demo ===") - - client = SpanPanelClient(host="localhost", simulation_mode=True) - - async with client: - print("\n9. Caching behavior:") - - # Same parameters should return cached results - import time - - start_time = time.time() - circuits1 = await client.get_circuits(global_power_variation=0.2) - first_call_time = time.time() - start_time - - start_time = time.time() - circuits2 = await client.get_circuits(global_power_variation=0.2) - second_call_time = time.time() - start_time - - print(f" First call: {first_call_time * 1000:.1f}ms") - print(f" Cached call: {second_call_time * 1000:.1f}ms") - print(f" Same object: {circuits1 is circuits2}") - - # Different parameters create new results - circuits3 = await client.get_circuits(global_power_variation=0.3) - print(f" Different params: {circuits1 is circuits3}") - - -async def main() -> None: - """Run all simulation demos.""" - print("SPAN Panel API Simulation Mode Demo") - print("=" * 40) - - demos = [ - demo_basic_simulation, - demo_circuit_variations, - demo_global_variations, - demo_panel_variations, - demo_status_variations, - demo_energy_simulation, - demo_storage_variations, - demo_live_mode_comparison, - demo_caching_behavior, - ] - - for demo in demos: - try: - await demo() - except Exception as e: - print(f" Error in {demo.__name__}: {e}") - - print("\n" + "=" * 40) - print("Demo completed!") - print("\nFor more information, see:") - print("- tests/docs/simulation.md") - print("- tests/test_simulation_mode.py") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/test_multi_energy_sources.py b/examples/test_multi_energy_sources.py deleted file mode 100644 index 5f43d48..0000000 --- a/examples/test_multi_energy_sources.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -""" -Test multiple energy sources working together in the generic energy profile system. - -This demonstrates: -- Solar production (time-dependent) -- Backup generators (always available) -- Battery storage (bidirectional) -- Various consumption loads -- Tab synchronization for 240V systems - -Run with: poetry run python examples/test_multi_energy_sources.py -""" - -import asyncio -import pytest -import tempfile -import yaml -from pathlib import Path - -from span_panel_api.client import SpanPanelClient - - -@pytest.mark.asyncio -async def test_multi_energy_config(): - """Test a configuration with multiple energy source types.""" - - # Create a custom config with multiple energy sources - test_config = { - "panel_config": {"serial_number": "MULTI_ENERGY_TEST", "total_tabs": 8, "main_size": 200}, - "circuit_templates": { - # High-power load - "hvac": { - "energy_profile": { - "mode": "consumer", - "power_range": [0.0, 4000.0], - "typical_power": 2500.0, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - }, - # Regular load - "lighting": { - "energy_profile": { - "mode": "consumer", - "power_range": [0.0, 500.0], - "typical_power": 200.0, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - }, - }, - "circuits": [ - {"id": "main_hvac", "name": "Main HVAC", "template": "hvac", "tabs": [1]}, - {"id": "house_lights", "name": "House Lighting", "template": "lighting", "tabs": [2]}, - ], - "unmapped_tab_templates": { - "3": { - "energy_profile": { - "mode": "consumer", # Changed to consumer to match current simulation behavior - "power_range": [0.0, 2000.0], - "typical_power": 1500.0, - "power_variation": 0.2, - }, - "relay_behavior": "non_controllable", - "priority": "MUST_HAVE", - }, - "4": { - "energy_profile": { - "mode": "consumer", # Changed to consumer to match current simulation behavior - "power_range": [0.0, 4000.0], - "typical_power": 3000.0, - "power_variation": 0.05, - }, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - }, - }, - "unmapped_tabs": [], - "simulation_params": { - "update_interval": 5, - "time_acceleration": 1.0, - "noise_factor": 0.02, - "enable_realistic_behaviors": True, - }, - } - - # Write config to temporary file - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: - yaml.dump(test_config, f) - temp_config_path = f.name - - try: - async with SpanPanelClient( - host="multi-energy-test", simulation_mode=True, simulation_config_path=temp_config_path - ) as client: - - circuits = await client.get_circuits() - panel = await client.get_panel_state() - circuit_dict = circuits.circuits.additional_properties - - print("🌐 Multi-Energy Source System Test") - print("=" * 50) - - # Test that we have configured circuits - assert "main_hvac" in circuit_dict - assert "house_lights" in circuit_dict - print(f"✅ Found {len(circuit_dict)} configured circuits") - - # Test panel data consistency - branch_data = panel.branches - mapped_tabs = {1, 2} # From circuits - unmapped_tabs = {3, 4} # From unmapped_tab_templates - - print(f"✅ Panel has {len(branch_data)} branches") - print(f" • Mapped tabs: {sorted(mapped_tabs)}") - print(f" • Unmapped tabs: {sorted(unmapped_tabs)}") - - # Verify different circuit types work - consumption_circuits = set() - - for circuit_id, circuit in circuit_dict.items(): - power = circuit.instant_power_w - consumption_circuits.add(circuit_id) - print(f"🔌 {circuit.name}: {power:.1f}W (consuming)") - - assert len(consumption_circuits) > 0, f"Should have found consumption loads, got: {list(circuit_dict.keys())}" - print("✅ Multiple circuit types active") - - # Energy balance analysis - simplified since all power values are positive - total_circuit_power = sum(circuit.instant_power_w for circuit in circuit_dict.values()) - - print(f"\n📊 Energy Balance:") - print("-" * 15) - print(f"Total Circuit Power: {total_circuit_power:.1f}W") - print(f"Panel Grid Power: {panel.instant_grid_power_w:.1f}W") - - # Verify we have realistic power levels - assert total_circuit_power > 100, f"Total circuit power too low: {total_circuit_power}W" - - # Test panel-circuit consistency (this should work due to our synchronization fixes) - panel_grid_power = panel.instant_grid_power_w - - print(f"\n🔄 Panel Consistency Check:") - print(f" • Panel Grid Power: {panel_grid_power:.1f}W") - print(f" • Total Circuit Power: {total_circuit_power:.1f}W") - - # The panel grid power should be reasonable - assert abs(panel_grid_power) < 10000, f"Panel grid power seems unrealistic: {panel_grid_power:.1f}W" - - print(f"\n✅ Success! Multi-energy system working:") - print(" • Multiple circuit templates supported") - print(" • Unmapped tab templates working") - print(" • Panel-circuit data consistency maintained") - print(" • Realistic power levels achieved") - - finally: - # Clean up temporary config file - Path(temp_config_path).unlink(missing_ok=True) - - -if __name__ == "__main__": - asyncio.run(test_multi_energy_config()) diff --git a/examples/test_multi_energy_sources_fixed.py b/examples/test_multi_energy_sources_fixed.py deleted file mode 100644 index 5f43d48..0000000 --- a/examples/test_multi_energy_sources_fixed.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -""" -Test multiple energy sources working together in the generic energy profile system. - -This demonstrates: -- Solar production (time-dependent) -- Backup generators (always available) -- Battery storage (bidirectional) -- Various consumption loads -- Tab synchronization for 240V systems - -Run with: poetry run python examples/test_multi_energy_sources.py -""" - -import asyncio -import pytest -import tempfile -import yaml -from pathlib import Path - -from span_panel_api.client import SpanPanelClient - - -@pytest.mark.asyncio -async def test_multi_energy_config(): - """Test a configuration with multiple energy source types.""" - - # Create a custom config with multiple energy sources - test_config = { - "panel_config": {"serial_number": "MULTI_ENERGY_TEST", "total_tabs": 8, "main_size": 200}, - "circuit_templates": { - # High-power load - "hvac": { - "energy_profile": { - "mode": "consumer", - "power_range": [0.0, 4000.0], - "typical_power": 2500.0, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - }, - # Regular load - "lighting": { - "energy_profile": { - "mode": "consumer", - "power_range": [0.0, 500.0], - "typical_power": 200.0, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - }, - }, - "circuits": [ - {"id": "main_hvac", "name": "Main HVAC", "template": "hvac", "tabs": [1]}, - {"id": "house_lights", "name": "House Lighting", "template": "lighting", "tabs": [2]}, - ], - "unmapped_tab_templates": { - "3": { - "energy_profile": { - "mode": "consumer", # Changed to consumer to match current simulation behavior - "power_range": [0.0, 2000.0], - "typical_power": 1500.0, - "power_variation": 0.2, - }, - "relay_behavior": "non_controllable", - "priority": "MUST_HAVE", - }, - "4": { - "energy_profile": { - "mode": "consumer", # Changed to consumer to match current simulation behavior - "power_range": [0.0, 4000.0], - "typical_power": 3000.0, - "power_variation": 0.05, - }, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - }, - }, - "unmapped_tabs": [], - "simulation_params": { - "update_interval": 5, - "time_acceleration": 1.0, - "noise_factor": 0.02, - "enable_realistic_behaviors": True, - }, - } - - # Write config to temporary file - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: - yaml.dump(test_config, f) - temp_config_path = f.name - - try: - async with SpanPanelClient( - host="multi-energy-test", simulation_mode=True, simulation_config_path=temp_config_path - ) as client: - - circuits = await client.get_circuits() - panel = await client.get_panel_state() - circuit_dict = circuits.circuits.additional_properties - - print("🌐 Multi-Energy Source System Test") - print("=" * 50) - - # Test that we have configured circuits - assert "main_hvac" in circuit_dict - assert "house_lights" in circuit_dict - print(f"✅ Found {len(circuit_dict)} configured circuits") - - # Test panel data consistency - branch_data = panel.branches - mapped_tabs = {1, 2} # From circuits - unmapped_tabs = {3, 4} # From unmapped_tab_templates - - print(f"✅ Panel has {len(branch_data)} branches") - print(f" • Mapped tabs: {sorted(mapped_tabs)}") - print(f" • Unmapped tabs: {sorted(unmapped_tabs)}") - - # Verify different circuit types work - consumption_circuits = set() - - for circuit_id, circuit in circuit_dict.items(): - power = circuit.instant_power_w - consumption_circuits.add(circuit_id) - print(f"🔌 {circuit.name}: {power:.1f}W (consuming)") - - assert len(consumption_circuits) > 0, f"Should have found consumption loads, got: {list(circuit_dict.keys())}" - print("✅ Multiple circuit types active") - - # Energy balance analysis - simplified since all power values are positive - total_circuit_power = sum(circuit.instant_power_w for circuit in circuit_dict.values()) - - print(f"\n📊 Energy Balance:") - print("-" * 15) - print(f"Total Circuit Power: {total_circuit_power:.1f}W") - print(f"Panel Grid Power: {panel.instant_grid_power_w:.1f}W") - - # Verify we have realistic power levels - assert total_circuit_power > 100, f"Total circuit power too low: {total_circuit_power}W" - - # Test panel-circuit consistency (this should work due to our synchronization fixes) - panel_grid_power = panel.instant_grid_power_w - - print(f"\n🔄 Panel Consistency Check:") - print(f" • Panel Grid Power: {panel_grid_power:.1f}W") - print(f" • Total Circuit Power: {total_circuit_power:.1f}W") - - # The panel grid power should be reasonable - assert abs(panel_grid_power) < 10000, f"Panel grid power seems unrealistic: {panel_grid_power:.1f}W" - - print(f"\n✅ Success! Multi-energy system working:") - print(" • Multiple circuit templates supported") - print(" • Unmapped tab templates working") - print(" • Panel-circuit data consistency maintained") - print(" • Realistic power levels achieved") - - finally: - # Clean up temporary config file - Path(temp_config_path).unlink(missing_ok=True) - - -if __name__ == "__main__": - asyncio.run(test_multi_energy_config()) diff --git a/examples/test_simulation_time.py b/examples/test_simulation_time.py deleted file mode 100644 index e8bcd73..0000000 --- a/examples/test_simulation_time.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -""" -Test simulation time control with time-dependent energy sources. - -This demonstrates: -- File-based YAML configuration for templates and behaviors -- Programmatic override of simulation start time -- Solar production varying by time of day -- Setting specific start times for consistent testing - -Run with: poetry run python examples/test_simulation_time.py -""" - -import asyncio -from pathlib import Path - -from span_panel_api.client import SpanPanelClient - - -async def test_simulation_time_control(): - """Test simulation time control with solar production at different times.""" - - print("🕐 Simulation Time Control Test") - print("=" * 50) - print("Using file-based YAML config with programmatic time override") - - # Test configurations for different times of day - test_times = [ - ("2024-06-15T06:00:00", "🌅 Dawn (6 AM)"), - ("2024-06-15T12:00:00", "☀️ Noon (12 PM)"), - ("2024-06-15T18:00:00", "🌇 Evening (6 PM)"), - ("2024-06-15T00:00:00", "🌙 Midnight (12 AM)"), - ] - - config_path = Path(__file__).parent / "simulation_config_32_circuit.yaml" - - for sim_time, description in test_times: - print(f"\n{description}") - print("-" * 25) - - # Use file-based config with programmatic time override - async with SpanPanelClient( - host=f"time-test-{sim_time.replace(':', '-')}", - simulation_mode=True, - simulation_config_path=str(config_path), - simulation_start_time=sim_time, # Override start time - ) as client: - - circuits = await client.get_circuits() - circuit_dict = circuits.circuits.additional_properties - - # Check solar production at unmapped tabs 30 and 32 - solar_power_total = 0.0 - for tab_num in [30, 32]: - circuit_id = f"unmapped_tab_{tab_num}" - if circuit_id in circuit_dict: - circuit = circuit_dict[circuit_id] - power = circuit.instant_power_w - solar_power_total += abs(power) if power < 0 else 0 - - # Extract hour from simulation time for display - hour = int(sim_time.split('T')[1].split(':')[0]) - - if solar_power_total > 0: - print(f"☀️ Solar Production: {solar_power_total:.1f}W") - print(f" • Expected based on hour {hour} time-of-day profile") - print(f" • Using YAML templates with programmatic time: {sim_time}") - else: - print(f"🌙 No Solar Production (nighttime)") - print(f" • Expected for hour {hour} (outside daylight hours)") - print(f" • Simulation time: {sim_time}") - - # Show a regular consumer circuit for comparison - consumer_circuits = ["main_hvac", "master_bedroom_lights"] - for circuit_id in consumer_circuits: - if circuit_id in circuit_dict: - circuit = circuit_dict[circuit_id] - print(f"🔌 {circuit.name}: {circuit.instant_power_w:.1f}W (consistent)") - break - - print(f"\n✅ File-Based Simulation Time Control Working!") - print(" • YAML configuration defines all templates and behaviors") - print(" • Programmatic time override for consistent testing") - print(" • Solar production varies correctly by time of day") - print(" • Independent of real system clock") - print(" • Best of both worlds: file config + programmatic control") - - -if __name__ == "__main__": - asyncio.run(test_simulation_time_control()) diff --git a/generate_client.py b/generate_client.py deleted file mode 100644 index 7c6d7b9..0000000 --- a/generate_client.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate SPAN Panel OpenAPI Client - -Simple script to generate the httpx-based OpenAPI client from the OpenAPI spec. -This creates the raw generated client in generated_client/ which is then used -by the wrapper in src/span_panel_api/ -""" - -from pathlib import Path -import subprocess # nosec B404 -import sys - - -def main() -> int: - """Generate the OpenAPI client.""" - print("🚀 Generating SPAN Panel OpenAPI Client") - print("=" * 50) - - # Check that we have the OpenAPI spec - openapi_spec = Path("openapi.json") - if not openapi_spec.exists(): - print(f"❌ OpenAPI spec not found: {openapi_spec}") - return 1 - - # Clean up any existing generated client - generated_dir = Path("src/span_panel_api/generated_client") - if generated_dir.exists(): - print(f"🧹 Removing existing generated client: {generated_dir}") - subprocess.run(["rm", "-rf", str(generated_dir)], check=True) # nosec B603, B607 - - # Generate the client - print("⚙️ Running openapi-python-client...") - cmd = [ - "poetry", - "run", - "openapi-python-client", - "generate", - "--path", - str(openapi_spec), - "--meta", - "none", - "--overwrite", - ] - - print(f"Running: {' '.join(cmd)}") - result = subprocess.run(cmd, capture_output=True, text=True) # nosec B603 - - # Check what was created regardless of exit code (might fail on ruff but files still generated) - created_dirs = [d for d in Path(".").iterdir() if d.is_dir() and d.name.startswith(("span", "generated"))] - - if created_dirs: - created_dir = created_dirs[0] - print(f"📁 Client generated in: {created_dir}") - - # Move to our package structure - target_dir = Path("src/span_panel_api/generated_client") - if created_dir.name != target_dir.name or created_dir != target_dir: - print(f"📝 Moving {created_dir} to {target_dir}") - target_dir.parent.mkdir(parents=True, exist_ok=True) - created_dir.rename(target_dir) - - if result.returncode == 0: - print("✅ Generation completed successfully!") - else: - print("⚠️ Generation completed with warnings (likely ruff formatting issues)") - if "ruff failed" in result.stderr: - print(" The files were generated but ruff found some formatting issues.") - print(" This is expected due to OpenAPI naming conflicts and can be ignored.") - - print("✅ Client ready in src/span_panel_api/generated_client/") - return 0 - else: - print("❌ Generation failed - no client directory was created") - if result.stdout: - print("STDOUT:", result.stdout) - if result.stderr: - print("STDERR:", result.stderr) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/poetry.lock b/poetry.lock index 8a94c36..8fa327f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,24 +1,12 @@ # This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -groups = ["generate"] -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - [[package]] name = "anyio" version = "4.10.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" -groups = ["main", "generate"] +groups = ["main"] files = [ {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"}, {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"}, @@ -48,26 +36,6 @@ files = [ [package.dependencies] typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} -[[package]] -name = "attrs" -version = "25.3.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.8" -groups = ["main", "generate"] -files = [ - {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, - {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, -] - -[package.extras] -benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] -tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] - [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -176,7 +144,7 @@ version = "2025.8.3" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["main", "dev", "generate"] +groups = ["main", "dev"] files = [ {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, @@ -383,7 +351,7 @@ version = "8.2.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["main", "dev", "generate"] +groups = ["dev"] files = [ {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, @@ -398,12 +366,11 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev", "generate"] +groups = ["dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\"", generate = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "coverage" @@ -620,7 +587,7 @@ version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "dev", "generate"] +groups = ["main", "dev"] markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, @@ -651,7 +618,7 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" -groups = ["main", "generate"] +groups = ["main"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -663,7 +630,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["main", "generate"] +groups = ["main"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -685,7 +652,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["main", "generate"] +groups = ["main"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -745,7 +712,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["main", "dev", "generate"] +groups = ["main", "dev"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -888,24 +855,6 @@ files = [ test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] trio = ["trio"] -[[package]] -name = "jinja2" -version = "3.1.6" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -groups = ["generate"] -files = [ - {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, - {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - [[package]] name = "keyring" version = "25.6.0" @@ -1048,7 +997,7 @@ version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" -groups = ["dev", "generate"] +groups = ["dev"] files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -1067,77 +1016,6 @@ profiling = ["gprof2dot"] rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] -[[package]] -name = "markupsafe" -version = "3.0.2" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.9" -groups = ["generate"] -files = [ - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, - {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, -] - [[package]] name = "mccabe" version = "0.7.0" @@ -1156,7 +1034,7 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" -groups = ["dev", "generate"] +groups = ["dev"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -1297,182 +1175,6 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] -[[package]] -name = "numpy" -version = "2.2.6" -description = "Fundamental package for array computing in Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version == \"3.10\"" -files = [ - {file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"}, - {file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"}, - {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163"}, - {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf"}, - {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83"}, - {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915"}, - {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680"}, - {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289"}, - {file = "numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d"}, - {file = "numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3"}, - {file = "numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae"}, - {file = "numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a"}, - {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42"}, - {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491"}, - {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a"}, - {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf"}, - {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1"}, - {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab"}, - {file = "numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47"}, - {file = "numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303"}, - {file = "numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff"}, - {file = "numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c"}, - {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3"}, - {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282"}, - {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87"}, - {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249"}, - {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49"}, - {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de"}, - {file = "numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4"}, - {file = "numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2"}, - {file = "numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84"}, - {file = "numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b"}, - {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d"}, - {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566"}, - {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f"}, - {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f"}, - {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868"}, - {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d"}, - {file = "numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd"}, - {file = "numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c"}, - {file = "numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6"}, - {file = "numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda"}, - {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40"}, - {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8"}, - {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f"}, - {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa"}, - {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571"}, - {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1"}, - {file = "numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff"}, - {file = "numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06"}, - {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d"}, - {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db"}, - {file = "numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543"}, - {file = "numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00"}, - {file = "numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd"}, -] - -[[package]] -name = "numpy" -version = "2.3.2" -description = "Fundamental package for array computing in Python" -optional = false -python-versions = ">=3.11" -groups = ["main"] -markers = "python_version >= \"3.11\"" -files = [ - {file = "numpy-2.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:852ae5bed3478b92f093e30f785c98e0cb62fa0a939ed057c31716e18a7a22b9"}, - {file = "numpy-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a0e27186e781a69959d0230dd9909b5e26024f8da10683bd6344baea1885168"}, - {file = "numpy-2.3.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f0a1a8476ad77a228e41619af2fa9505cf69df928e9aaa165746584ea17fed2b"}, - {file = "numpy-2.3.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cbc95b3813920145032412f7e33d12080f11dc776262df1712e1638207dde9e8"}, - {file = "numpy-2.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75018be4980a7324edc5930fe39aa391d5734531b1926968605416ff58c332d"}, - {file = "numpy-2.3.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20b8200721840f5621b7bd03f8dcd78de33ec522fc40dc2641aa09537df010c3"}, - {file = "numpy-2.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f91e5c028504660d606340a084db4b216567ded1056ea2b4be4f9d10b67197f"}, - {file = "numpy-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fb1752a3bb9a3ad2d6b090b88a9a0ae1cd6f004ef95f75825e2f382c183b2097"}, - {file = "numpy-2.3.2-cp311-cp311-win32.whl", hash = "sha256:4ae6863868aaee2f57503c7a5052b3a2807cf7a3914475e637a0ecd366ced220"}, - {file = "numpy-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:240259d6564f1c65424bcd10f435145a7644a65a6811cfc3201c4a429ba79170"}, - {file = "numpy-2.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:4209f874d45f921bde2cff1ffcd8a3695f545ad2ffbef6d3d3c6768162efab89"}, - {file = "numpy-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bc3186bea41fae9d8e90c2b4fb5f0a1f5a690682da79b92574d63f56b529080b"}, - {file = "numpy-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f4f0215edb189048a3c03bd5b19345bdfa7b45a7a6f72ae5945d2a28272727f"}, - {file = "numpy-2.3.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b1224a734cd509f70816455c3cffe13a4f599b1bf7130f913ba0e2c0b2006c0"}, - {file = "numpy-2.3.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3dcf02866b977a38ba3ec10215220609ab9667378a9e2150615673f3ffd6c73b"}, - {file = "numpy-2.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:572d5512df5470f50ada8d1972c5f1082d9a0b7aa5944db8084077570cf98370"}, - {file = "numpy-2.3.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8145dd6d10df13c559d1e4314df29695613575183fa2e2d11fac4c208c8a1f73"}, - {file = "numpy-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:103ea7063fa624af04a791c39f97070bf93b96d7af7eb23530cd087dc8dbe9dc"}, - {file = "numpy-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc927d7f289d14f5e037be917539620603294454130b6de200091e23d27dc9be"}, - {file = "numpy-2.3.2-cp312-cp312-win32.whl", hash = "sha256:d95f59afe7f808c103be692175008bab926b59309ade3e6d25009e9a171f7036"}, - {file = "numpy-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:9e196ade2400c0c737d93465327d1ae7c06c7cb8a1756121ebf54b06ca183c7f"}, - {file = "numpy-2.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:ee807923782faaf60d0d7331f5e86da7d5e3079e28b291973c545476c2b00d07"}, - {file = "numpy-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8d9727f5316a256425892b043736d63e89ed15bbfe6556c5ff4d9d4448ff3b3"}, - {file = "numpy-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:efc81393f25f14d11c9d161e46e6ee348637c0a1e8a54bf9dedc472a3fae993b"}, - {file = "numpy-2.3.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dd937f088a2df683cbb79dda9a772b62a3e5a8a7e76690612c2737f38c6ef1b6"}, - {file = "numpy-2.3.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:11e58218c0c46c80509186e460d79fbdc9ca1eb8d8aee39d8f2dc768eb781089"}, - {file = "numpy-2.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ad4ebcb683a1f99f4f392cc522ee20a18b2bb12a2c1c42c3d48d5a1adc9d3d2"}, - {file = "numpy-2.3.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938065908d1d869c7d75d8ec45f735a034771c6ea07088867f713d1cd3bbbe4f"}, - {file = "numpy-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66459dccc65d8ec98cc7df61307b64bf9e08101f9598755d42d8ae65d9a7a6ee"}, - {file = "numpy-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7af9ed2aa9ec5950daf05bb11abc4076a108bd3c7db9aa7251d5f107079b6a6"}, - {file = "numpy-2.3.2-cp313-cp313-win32.whl", hash = "sha256:906a30249315f9c8e17b085cc5f87d3f369b35fedd0051d4a84686967bdbbd0b"}, - {file = "numpy-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:c63d95dc9d67b676e9108fe0d2182987ccb0f11933c1e8959f42fa0da8d4fa56"}, - {file = "numpy-2.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:b05a89f2fb84d21235f93de47129dd4f11c16f64c87c33f5e284e6a3a54e43f2"}, - {file = "numpy-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e6ecfeddfa83b02318f4d84acf15fbdbf9ded18e46989a15a8b6995dfbf85ab"}, - {file = "numpy-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:508b0eada3eded10a3b55725b40806a4b855961040180028f52580c4729916a2"}, - {file = "numpy-2.3.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:754d6755d9a7588bdc6ac47dc4ee97867271b17cee39cb87aef079574366db0a"}, - {file = "numpy-2.3.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f66e7d2b2d7712410d3bc5684149040ef5f19856f20277cd17ea83e5006286"}, - {file = "numpy-2.3.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6ea4e5a65d5a90c7d286ddff2b87f3f4ad61faa3db8dabe936b34c2275b6f8"}, - {file = "numpy-2.3.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3ef07ec8cbc8fc9e369c8dcd52019510c12da4de81367d8b20bc692aa07573a"}, - {file = "numpy-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:27c9f90e7481275c7800dc9c24b7cc40ace3fdb970ae4d21eaff983a32f70c91"}, - {file = "numpy-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:07b62978075b67eee4065b166d000d457c82a1efe726cce608b9db9dd66a73a5"}, - {file = "numpy-2.3.2-cp313-cp313t-win32.whl", hash = "sha256:c771cfac34a4f2c0de8e8c97312d07d64fd8f8ed45bc9f5726a7e947270152b5"}, - {file = "numpy-2.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:72dbebb2dcc8305c431b2836bcc66af967df91be793d63a24e3d9b741374c450"}, - {file = "numpy-2.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:72c6df2267e926a6d5286b0a6d556ebe49eae261062059317837fda12ddf0c1a"}, - {file = "numpy-2.3.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:448a66d052d0cf14ce9865d159bfc403282c9bc7bb2a31b03cc18b651eca8b1a"}, - {file = "numpy-2.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:546aaf78e81b4081b2eba1d105c3b34064783027a06b3ab20b6eba21fb64132b"}, - {file = "numpy-2.3.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:87c930d52f45df092f7578889711a0768094debf73cfcde105e2d66954358125"}, - {file = "numpy-2.3.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8dc082ea901a62edb8f59713c6a7e28a85daddcb67454c839de57656478f5b19"}, - {file = "numpy-2.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af58de8745f7fa9ca1c0c7c943616c6fe28e75d0c81f5c295810e3c83b5be92f"}, - {file = "numpy-2.3.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed5527c4cf10f16c6d0b6bee1f89958bccb0ad2522c8cadc2efd318bcd545f5"}, - {file = "numpy-2.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:095737ed986e00393ec18ec0b21b47c22889ae4b0cd2d5e88342e08b01141f58"}, - {file = "numpy-2.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5e40e80299607f597e1a8a247ff8d71d79c5b52baa11cc1cce30aa92d2da6e0"}, - {file = "numpy-2.3.2-cp314-cp314-win32.whl", hash = "sha256:7d6e390423cc1f76e1b8108c9b6889d20a7a1f59d9a60cac4a050fa734d6c1e2"}, - {file = "numpy-2.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:b9d0878b21e3918d76d2209c924ebb272340da1fb51abc00f986c258cd5e957b"}, - {file = "numpy-2.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:2738534837c6a1d0c39340a190177d7d66fdf432894f469728da901f8f6dc910"}, - {file = "numpy-2.3.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4d002ecf7c9b53240be3bb69d80f86ddbd34078bae04d87be81c1f58466f264e"}, - {file = "numpy-2.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:293b2192c6bcce487dbc6326de5853787f870aeb6c43f8f9c6496db5b1781e45"}, - {file = "numpy-2.3.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0a4f2021a6da53a0d580d6ef5db29947025ae8b35b3250141805ea9a32bbe86b"}, - {file = "numpy-2.3.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9c144440db4bf3bb6372d2c3e49834cc0ff7bb4c24975ab33e01199e645416f2"}, - {file = "numpy-2.3.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f92d6c2a8535dc4fe4419562294ff957f83a16ebdec66df0805e473ffaad8bd0"}, - {file = "numpy-2.3.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cefc2219baa48e468e3db7e706305fcd0c095534a192a08f31e98d83a7d45fb0"}, - {file = "numpy-2.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76c3e9501ceb50b2ff3824c3589d5d1ab4ac857b0ee3f8f49629d0de55ecf7c2"}, - {file = "numpy-2.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:122bf5ed9a0221b3419672493878ba4967121514b1d7d4656a7580cd11dddcbf"}, - {file = "numpy-2.3.2-cp314-cp314t-win32.whl", hash = "sha256:6f1ae3dcb840edccc45af496f312528c15b1f79ac318169d094e85e4bb35fdf1"}, - {file = "numpy-2.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:087ffc25890d89a43536f75c5fe8770922008758e8eeeef61733957041ed2f9b"}, - {file = "numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631"}, - {file = "numpy-2.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:14a91ebac98813a49bc6aa1a0dfc09513dcec1d97eaf31ca21a87221a1cdcb15"}, - {file = "numpy-2.3.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:71669b5daae692189540cffc4c439468d35a3f84f0c88b078ecd94337f6cb0ec"}, - {file = "numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:69779198d9caee6e547adb933941ed7520f896fd9656834c300bdf4dd8642712"}, - {file = "numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2c3271cc4097beb5a60f010bcc1cc204b300bb3eafb4399376418a83a1c6373c"}, - {file = "numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446acd11fe3dc1830568c941d44449fd5cb83068e5c70bd5a470d323d448296"}, - {file = "numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa098a5ab53fa407fded5870865c6275a5cd4101cfdef8d6fafc48286a96e981"}, - {file = "numpy-2.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6936aff90dda378c09bea075af0d9c675fe3a977a9d2402f95a87f440f59f619"}, - {file = "numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48"}, -] - -[[package]] -name = "openapi-python-client" -version = "0.25.3" -description = "Generate modern Python clients from OpenAPI" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["generate"] -files = [ - {file = "openapi_python_client-0.25.3-py3-none-any.whl", hash = "sha256:008e4c5f3079f312c135b55b142eb9616eafbd739ad6d5f8e95add726d1a02f2"}, - {file = "openapi_python_client-0.25.3.tar.gz", hash = "sha256:cafc6b5aebd0c55fe7be4d400b24d3b107f7ec64923b8117ef8aa7ceb2a918a4"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -colorama = {version = ">=0.4.3", markers = "sys_platform == \"win32\""} -httpx = ">=0.23.0,<0.29.0" -jinja2 = ">=3.0.0,<4.0.0" -pydantic = ">=2.10,<3.0.0" -python-dateutil = ">=2.8.1,<3.0.0" -ruamel-yaml = ">=0.18.6,<0.19.0" -ruff = ">=0.2,<0.13" -shellingham = ">=1.3.2,<2.0.0" -typer = ">0.6,<0.17" -typing-extensions = ">=4.8.0,<5.0.0" - [[package]] name = "packaging" version = "25.0" @@ -1485,6 +1187,21 @@ files = [ {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] +[[package]] +name = "paho-mqtt" +version = "2.1.0" +description = "MQTT version 5.0/3.1.1 client class" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee"}, + {file = "paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834"}, +] + +[package.extras] +proxy = ["pysocks"] + [[package]] name = "pathspec" version = "0.12.1" @@ -1577,147 +1294,13 @@ files = [ {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] -[[package]] -name = "pydantic" -version = "2.11.7" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.9" -groups = ["generate"] -files = [ - {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, - {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.33.2" -typing-extensions = ">=4.12.2" -typing-inspection = ">=0.4.0" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] - -[[package]] -name = "pydantic-core" -version = "2.33.2" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.9" -groups = ["generate"] -files = [ - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, - {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, -] - -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" - [[package]] name = "pygments" version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["dev", "generate"] +groups = ["dev"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -1821,21 +1404,6 @@ pytest = ">=7" [package.extras] testing = ["process-tests", "pytest-xdist", "virtualenv"] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "generate"] -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -1855,7 +1423,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["main", "dev", "generate"] +groups = ["main", "dev"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -2009,7 +1577,7 @@ version = "14.1.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" -groups = ["dev", "generate"] +groups = ["dev"] files = [ {file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"}, {file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"}, @@ -2022,89 +1590,13 @@ pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] -[[package]] -name = "ruamel-yaml" -version = "0.18.14" -description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" -optional = false -python-versions = ">=3.8" -groups = ["generate"] -files = [ - {file = "ruamel.yaml-0.18.14-py3-none-any.whl", hash = "sha256:710ff198bb53da66718c7db27eec4fbcc9aa6ca7204e4c1df2f282b6fe5eb6b2"}, - {file = "ruamel.yaml-0.18.14.tar.gz", hash = "sha256:7227b76aaec364df15936730efbf7d72b30c0b79b1d578bbb8e3dcb2d81f52b7"}, -] - -[package.dependencies] -"ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.14\""} - -[package.extras] -docs = ["mercurial (>5.7)", "ryd"] -jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] - -[[package]] -name = "ruamel-yaml-clib" -version = "0.2.12" -description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" -optional = false -python-versions = ">=3.9" -groups = ["generate"] -markers = "python_version < \"3.14\" and platform_python_implementation == \"CPython\"" -files = [ - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bc5f1e1c28e966d61d2519f2a3d451ba989f9ea0f2307de7bc45baa526de9e45"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a0e060aace4c24dcaf71023bbd7d42674e3b230f7e7b97317baf1e953e5b519"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"}, - {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"}, -] - [[package]] name = "ruff" version = "0.12.12" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" -groups = ["dev", "generate"] +groups = ["dev"] files = [ {file = "ruff-0.12.12-py3-none-linux_armv6l.whl", hash = "sha256:de1c4b916d98ab289818e55ce481e2cacfaad7710b01d1f990c497edf217dafc"}, {file = "ruff-0.12.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7acd6045e87fac75a0b0cdedacf9ab3e1ad9d929d149785903cff9bb69ad9727"}, @@ -2165,25 +1657,13 @@ enabler = ["pytest-enabler (>=2.2)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] -[[package]] -name = "shellingham" -version = "1.5.4" -description = "Tool to Detect Surrounding Shell" -optional = false -python-versions = ">=3.7" -groups = ["generate"] -files = [ - {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, - {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, -] - [[package]] name = "six" version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev", "generate"] +groups = ["dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -2195,7 +1675,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main", "generate"] +groups = ["main"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -2297,24 +1777,6 @@ urllib3 = ">=1.26.0" [package.extras] keyring = ["keyring (>=21.2.0)"] -[[package]] -name = "typer" -version = "0.16.0" -description = "Typer, build great CLIs. Easy to code. Based on Python type hints." -optional = false -python-versions = ">=3.7" -groups = ["generate"] -files = [ - {file = "typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855"}, - {file = "typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b"}, -] - -[package.dependencies] -click = ">=8.0.0" -rich = ">=10.11.0" -shellingham = ">=1.3.0" -typing-extensions = ">=3.7.4.3" - [[package]] name = "types-pyyaml" version = "6.0.12.20250915" @@ -2333,28 +1795,13 @@ version = "4.14.1" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "dev", "generate"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, ] markers = {main = "python_version <= \"3.12\""} -[[package]] -name = "typing-inspection" -version = "0.4.1" -description = "Runtime typing introspection tools" -optional = false -python-versions = ">=3.9" -groups = ["generate"] -files = [ - {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, - {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, -] - -[package.dependencies] -typing-extensions = ">=4.12.0" - [[package]] name = "urllib3" version = "2.6.0" @@ -2434,4 +1881,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "3b3d647ec72766638a8bd46f78b0d5743e5a891c8b635bd4898f50f5a2d8d337" +content-hash = "d648ddceabcecc06e719eb370ed699a1ef1a3c316e30f065a4df8235a15f4fd6" diff --git a/pyproject.toml b/pyproject.toml index 23781ff..144b8b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "1.1.14" +version = "2.0.0" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} @@ -9,10 +9,7 @@ readme = "README.md" requires-python = ">=3.10,<4.0" dependencies = [ "httpx>=0.28.1,<0.29.0", - "attrs>=22.2.0", - "python-dateutil>=2.8.0", - "click>=8.0.0", - "numpy>=1.21.0", + "paho-mqtt>=2.0.0,<3.0.0", "pyyaml>=6.0.0", ] @@ -44,10 +41,6 @@ vulture = "^2.14" types-pyyaml = "^6.0.12.20250915" coverage = "*" -[tool.poetry.group.generate.dependencies] -openapi-python-client = "^0.25.3" -pyyaml = "^6.0.0" - [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" @@ -55,14 +48,11 @@ build-backend = "poetry.core.masonry.api" [tool.ruff] line-length = 125 exclude = [ - "src/span_panel_api/generated_client/", - "generate_client.py", "scripts/", ".*_cache/", "dist/", "venv/", ".venv/", - "examples/", ] [tool.ruff.lint.per-file-ignores] @@ -112,29 +102,19 @@ disallow_untyped_defs = true explicit_package_bases = true mypy_path = "src" exclude = [ - "src/span_panel_api/generated_client/", - "generate_client.py", "scripts/", "tests/", "docs/", - "examples/", ".*_cache/", "dist/", "venv/", ".venv/", ] -[[tool.mypy.overrides]] -module = "span_panel_api.generated_client.*" -follow_imports = "skip" -ignore_errors = true -disallow_any_generics = false -disallow_incomplete_defs = false - [[tool.mypy.overrides]] module = [ "httpx.*", - "attrs.*", + "paho.*", ] ignore_missing_imports = true @@ -143,22 +123,15 @@ ignore_missing_imports = true data_file = ".local_coverage_data" source = ["src/span_panel_api"] omit = [ - "src/span_panel_api/generated_client/*", - "generate_client.py", "tests/*", "*/tests/*", "*/.venv/*", "*/venv/*", + "src/span_panel_api/mqtt/connection.py", ] [tool.coverage.report] fail_under = 85.0 -# Exclude defensive/error handling code from coverage that's not core functionality: -# - Import error handling (hard to test, defensive) -# - Generic exception wrappers (defensive code) -# - HTTP status error branches for edge cases (500/502/503/504 errors) -# - Network timeout/retry logic (defensive) -# - Pass statements in exception cleanup exclude_lines = [ # Standard exclusions "pragma: no cover", @@ -176,74 +149,21 @@ exclude_lines = [ "except ImportError as e:", "raise ImportError\\(", # Defensive exception handling for edge cases - "except Exception:", # Generic exception handlers + "except Exception:", "except Exception as e:", "# Ignore errors during cleanup", - "pass", # Exception cleanup pass statements - # HTTP status error branches that are hard to test - "elif e\\.status_code in SERVER_ERROR_CODES:", - "elif e\\.response\\.status_code in SERVER_ERROR_CODES:", - "elif e\\.status_code in RETRIABLE_ERROR_CODES:", - "elif e\\.response\\.status_code in RETRIABLE_ERROR_CODES:", - # Note: HTTPStatusError, TimeoutException, ConnectError are tested as they represent real scenarios - # Generic API error wrapping - "raise SpanPanelAPIError\\(f\"Unexpected error:", - "raise SpanPanelAPIError\\(f\"API error:", - # Retry logic error handling - "# Last attempt - re-raise", - "# Not retriable or last attempt - re-raise", - "raise$", # Bare raise statements (re-raising exceptions) - "continue$", # Continue statements in retry loops - # Additional timeout/connection patterns - "await asyncio\\.sleep\\(delay\\)", - # Keep HTTPStatusError, ConnectError, TimeoutException testing - these are real scenarios - # More specific patterns for defensive code - "if attempt < max_attempts - 1:", - "# Network/timeout errors are always retriable", - # Defensive code patterns that should never be reached - "# This should never be reached, but required for mypy type checking", - "raise SpanPanelAPIError\\(\"Retry operation completed without success or exception\"\\)", - # Server error handling patterns - "if e\\.status_code in SERVER_ERROR_CODES:", - "if e\\.response\\.status_code in SERVER_ERROR_CODES:", - "if e\\.status_code in RETRIABLE_ERROR_CODES:", - "if e\\.response\\.status_code in RETRIABLE_ERROR_CODES:", - "raise SpanPanelServerError\\(", - "raise SpanPanelRetriableError\\(", - # Defensive error handling patterns - "raise SpanPanelAPIError\\(\"API result is None despite raise_on_unexpected_status=True\"\\)", - "self\\._handle_unexpected_status\\(e\\)", - "raise SpanPanelConnectionError\\(", - "raise SpanPanelTimeoutError\\(", - # Cache return statements (defensive) - "return cached_status", - "return cached_state", - "return cached_circuits", - "return cached_storage", - "return cached_panel", - # Integer tab handling (edge case) - "elif isinstance\\(circuit\\.tabs, int\\):", - "mapped_tabs\\.add\\(circuit\\.tabs\\)", - # Authentication error handling branches (hard to test reliably) - "if e\\.status_code in AUTH_ERROR_CODES:", - "if e\\.status_code in SERVER_ERROR_CODES:", - "if e\\.status_code in RETRIABLE_ERROR_CODES:", - "raise SpanPanelAPIError\\(f\"HTTP \\{e\\.status_code\\}: \\{e\\}\"", + "pass", ] [tool.bandit] -exclude_dirs = ["tests", "src/span_panel_api/generated_client", "scripts", "examples"] -skips = ["generate_client.py"] +exclude_dirs = ["tests", "scripts"] [tool.pylint.main] load-plugins = ["pylint.extensions.no_self_use"] -extension-pkg-allow-list = ["numpy"] +extension-pkg-allow-list = [] ignore-paths = [ - "^src/span_panel_api/generated_client/.*", "^tests/.*", - "^generate_client\\.py$", "^scripts/.*", - "^examples/.*", ] [tool.pylint.messages_control] @@ -270,7 +190,6 @@ max-line-length = 125 line-length = 125 target-version = ["py312"] skip-string-normalization = true -# Allow magic trailing comma for better line length handling skip-magic-trailing-comma = false exclude = ''' /( @@ -285,8 +204,6 @@ exclude = ''' |buck-out |build |dist - |src/span_panel_api/generated_client - |generate_client\.py |scripts/coverage\.py )/ ''' diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..514885c --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Scripts package for span-panel-api.""" diff --git a/scripts/test_live_auth.py b/scripts/test_live_auth.py index 7fcb2b1..1de3239 100644 --- a/scripts/test_live_auth.py +++ b/scripts/test_live_auth.py @@ -103,7 +103,7 @@ async def bearer_auth_validation(panel_ip: str, jwt_token: str) -> None: # prag print(f" 🔌 Main relay: {panel_state.main_relay_state}") print(f" ⚡ Grid power: {panel_state.instant_grid_power_w}W") print(f" 🏭 Run config: {panel_state.current_run_config}") - print(f" 🌐 DSM state: {panel_state.dsm_state}") + print(f" 🌐 Grid state: {panel_state.dsm_grid_state}") print() except SpanPanelAuthError as e: print(f"❌ Authentication failed: {e}") diff --git a/setup-hooks.sh b/setup-hooks.sh index 457e3bd..da49f66 100755 --- a/setup-hooks.sh +++ b/setup-hooks.sh @@ -14,7 +14,7 @@ if [[ ! -f ".deps-installed" ]] || [[ "pyproject.toml" -nt ".deps-installed" ]] echo "Forcing update to latest versions..." poetry update else - poetry install --with dev,generate + poetry install --with dev fi if [[ $? -ne 0 ]]; then diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index 4b96fb1..1fdbd09 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -1,55 +1,80 @@ """span-panel-api - SPAN Panel API Client Library. -A modern, type-safe Python client library for the SPAN Panel REST API. +A modern, type-safe Python client library for the SPAN Panel API, +supporting MQTT/Homie (v2) transport. """ -# Import our high-level client and exceptions -from .client import SpanPanelClient, set_async_delay_func +from .auth import download_ca_cert, get_homie_schema, regenerate_passphrase, register_v2 +from .detection import DetectionResult, detect_api_version from .exceptions import ( SimulationConfigurationError, SpanPanelAPIError, SpanPanelAuthError, SpanPanelConnectionError, SpanPanelError, - SpanPanelRetriableError, SpanPanelServerError, SpanPanelTimeoutError, SpanPanelValidationError, ) - -# Import phase validation utilities +from .factory import create_span_client +from .models import SpanBatterySnapshot, SpanCircuitSnapshot, SpanPanelSnapshot, SpanPVSnapshot, V2AuthResponse, V2StatusInfo +from .mqtt import MqttClientConfig, SpanMqttClient from .phase_validation import ( PhaseDistribution, are_tabs_opposite_phase, get_phase_distribution, get_tab_phase, - get_valid_tabs_from_branches, - get_valid_tabs_from_panel_data, suggest_balanced_pairing, validate_solar_tabs, ) +from .protocol import CircuitControlProtocol, PanelCapability, SpanPanelClientProtocol, StreamingCapableProtocol +from .simulation import DynamicSimulationEngine -__version__ = "1.0.0" +__version__ = "2.0.0" # fmt: off -__all__ = [ +__all__ = [ # noqa: RUF022 + # Protocols + "CircuitControlProtocol", + "PanelCapability", + "SpanPanelClientProtocol", + "StreamingCapableProtocol", + # Snapshots + "SpanBatterySnapshot", + "SpanCircuitSnapshot", + "SpanPVSnapshot", + "SpanPanelSnapshot", + # Factory + "create_span_client", + # Detection + "DetectionResult", + "detect_api_version", + # v2 auth + "V2AuthResponse", + "V2StatusInfo", + "download_ca_cert", + "get_homie_schema", + "regenerate_passphrase", + "register_v2", + # Transport + "MqttClientConfig", + "SpanMqttClient", + # Phase validation "PhaseDistribution", + "are_tabs_opposite_phase", + "get_phase_distribution", + "get_tab_phase", + "suggest_balanced_pairing", + "validate_solar_tabs", + # Exceptions "SimulationConfigurationError", "SpanPanelAPIError", "SpanPanelAuthError", - "SpanPanelClient", "SpanPanelConnectionError", "SpanPanelError", - "SpanPanelRetriableError", "SpanPanelServerError", "SpanPanelTimeoutError", "SpanPanelValidationError", - "are_tabs_opposite_phase", - "get_phase_distribution", - "get_tab_phase", - "get_valid_tabs_from_branches", - "get_valid_tabs_from_panel_data", - "set_async_delay_func", - "suggest_balanced_pairing", - "validate_solar_tabs", + # Simulation + "DynamicSimulationEngine", ] # fmt: on diff --git a/src/span_panel_api/auth.py b/src/span_panel_api/auth.py new file mode 100644 index 0000000..7c43cf6 --- /dev/null +++ b/src/span_panel_api/auth.py @@ -0,0 +1,264 @@ +"""SPAN Panel v2 REST API endpoints. + +Standalone async functions for v2-specific operations: authentication, +certificate provisioning, schema retrieval, and status probing. These +use httpx directly — they are not routed through the generated OpenAPI +client (which only covers v1 endpoints). +""" + +from __future__ import annotations + +import hashlib +import json +import uuid + +import httpx + +from .exceptions import SpanPanelAPIError, SpanPanelAuthError, SpanPanelConnectionError, SpanPanelTimeoutError +from .models import V2AuthResponse, V2HomieSchema, V2StatusInfo + + +def _str(val: object) -> str: + """Extract a string from a JSON-decoded value.""" + return str(val) if val is not None else "" + + +def _int(val: object) -> int: + """Extract an int from a JSON-decoded value.""" + if isinstance(val, int): + return val + if isinstance(val, float): + return int(val) + return int(str(val)) + + +async def register_v2( + host: str, + name: str, + passphrase: str | None = None, + timeout: float = 10.0, +) -> V2AuthResponse: + """Register with the SPAN Panel v2 API and obtain access + MQTT credentials. + + A random suffix is appended to ``name`` to ensure uniqueness per panel. + If ``passphrase`` is provided, it is sent as ``hopPassphrase``; omitting + it enables door-bypass registration. + + Args: + host: IP address or hostname of the SPAN Panel + name: Client display name base (e.g., "home-assistant"); a UUID suffix is appended + passphrase: Panel passphrase (printed on label or set by owner). None for door bypass. + timeout: Request timeout in seconds + + Returns: + V2AuthResponse with access token and MQTT broker credentials + + Raises: + SpanPanelAuthError: Invalid passphrase or auth failure + SpanPanelConnectionError: Cannot reach panel + SpanPanelTimeoutError: Request timed out + SpanPanelAPIError: Unexpected response + """ + url = f"http://{host}/api/v2/auth/register" + # The panel requires unique client names — append a random suffix. + # The passphrase field must be "hopPassphrase" per the SPAN v2 API spec. + suffix = uuid.uuid4().hex[:8] + unique_name = f"{name}-{suffix}" + payload: dict[str, str] = {"name": unique_name} + if passphrase: + payload["hopPassphrase"] = passphrase + + try: + async with httpx.AsyncClient(timeout=timeout, verify=False) as client: # nosec B501 + response = await client.post(url, json=payload) + except httpx.ConnectError as exc: + raise SpanPanelConnectionError(f"Cannot reach panel at {host}") from exc + except httpx.TimeoutException as exc: + raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc + + if response.status_code in (401, 403, 422): + raise SpanPanelAuthError(f"Authentication failed (HTTP {response.status_code}): {response.text}") + + if response.status_code != 200: + raise SpanPanelAPIError(f"Unexpected response from /api/v2/auth/register: HTTP {response.status_code}") + + data: dict[str, object] = response.json() + return V2AuthResponse( + access_token=_str(data["accessToken"]), + token_type=_str(data["tokenType"]), + iat_ms=_int(data["iatMs"]), + ebus_broker_username=_str(data["ebusBrokerUsername"]), + ebus_broker_password=_str(data["ebusBrokerPassword"]), + ebus_broker_host=_str(data["ebusBrokerHost"]), + ebus_broker_mqtts_port=_int(data["ebusBrokerMqttsPort"]), + ebus_broker_ws_port=_int(data["ebusBrokerWsPort"]), + ebus_broker_wss_port=_int(data["ebusBrokerWssPort"]), + hostname=_str(data["hostname"]), + serial_number=_str(data["serialNumber"]), + hop_passphrase=_str(data["hopPassphrase"]), + ) + + +async def download_ca_cert(host: str, timeout: float = 10.0) -> str: + """Download the PEM CA certificate from the SPAN Panel. + + Args: + host: IP address or hostname of the SPAN Panel + timeout: Request timeout in seconds + + Returns: + PEM-encoded CA certificate as a string + + Raises: + SpanPanelConnectionError: Cannot reach panel + SpanPanelTimeoutError: Request timed out + SpanPanelAPIError: Unexpected response or invalid PEM + """ + url = f"http://{host}/api/v2/certificate/ca" + + try: + async with httpx.AsyncClient(timeout=timeout, verify=False) as client: # nosec B501 + response = await client.get(url) + except httpx.ConnectError as exc: + raise SpanPanelConnectionError(f"Cannot reach panel at {host}") from exc + except httpx.TimeoutException as exc: + raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc + + if response.status_code != 200: + raise SpanPanelAPIError(f"Failed to download CA cert: HTTP {response.status_code}") + + pem = response.text + if not pem.startswith("-----BEGIN"): + raise SpanPanelAPIError("Response is not a valid PEM certificate") + + return pem + + +async def get_homie_schema(host: str, timeout: float = 10.0) -> V2HomieSchema: + """Fetch the Homie property schema from the SPAN Panel. + + This endpoint is unauthenticated. + + Args: + host: IP address or hostname of the SPAN Panel + timeout: Request timeout in seconds + + Returns: + V2HomieSchema with firmware version, schema hash, and type definitions + + Raises: + SpanPanelConnectionError: Cannot reach panel + SpanPanelTimeoutError: Request timed out + SpanPanelAPIError: Unexpected response + """ + url = f"http://{host}/api/v2/homie/schema" + + try: + async with httpx.AsyncClient(timeout=timeout, verify=False) as client: # nosec B501 + response = await client.get(url) + except httpx.ConnectError as exc: + raise SpanPanelConnectionError(f"Cannot reach panel at {host}") from exc + except httpx.TimeoutException as exc: + raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc + + if response.status_code != 200: + raise SpanPanelAPIError(f"Failed to fetch Homie schema: HTTP {response.status_code}") + + data: dict[str, object] = response.json() + + # Extract types — each value is a dict of property definitions + raw_types = data.get("types", {}) + types: dict[str, dict[str, object]] = {} + if isinstance(raw_types, dict): + for type_name, props in raw_types.items(): + if isinstance(props, dict): + types[str(type_name)] = {str(k): v for k, v in props.items()} + + # Compute schema hash from types key names for change detection + # The panel provides this implicitly via the firmware version + types structure + # We derive a hash for caching; the fixture README documents the expected value + types_json = json.dumps(data.get("types", {}), sort_keys=True) + schema_hash = "sha256:" + hashlib.sha256(types_json.encode()).hexdigest()[:16] + + return V2HomieSchema( + firmware_version=str(data.get("firmwareVersion", "")), + types_schema_hash=schema_hash, + types=types, + ) + + +async def regenerate_passphrase(host: str, token: str, timeout: float = 10.0) -> str: + """Rotate the MQTT broker password on the SPAN Panel. + + After this call, the previous broker password is invalidated. + The new broker password is returned. Note: the hop_passphrase + (used for REST auth) is NOT changed by this operation. + + Args: + host: IP address or hostname of the SPAN Panel + token: Valid JWT access token + timeout: Request timeout in seconds + + Returns: + New MQTT broker password + + Raises: + SpanPanelAuthError: Token invalid or expired + SpanPanelConnectionError: Cannot reach panel + SpanPanelTimeoutError: Request timed out + SpanPanelAPIError: Unexpected response + """ + url = f"http://{host}/api/v2/auth/passphrase" + headers = {"Authorization": f"Bearer {token}"} + + try: + async with httpx.AsyncClient(timeout=timeout, verify=False) as client: # nosec B501 + response = await client.put(url, headers=headers) + except httpx.ConnectError as exc: + raise SpanPanelConnectionError(f"Cannot reach panel at {host}") from exc + except httpx.TimeoutException as exc: + raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc + + if response.status_code in (401, 403, 412): + raise SpanPanelAuthError(f"Authentication failed (HTTP {response.status_code})") + + if response.status_code != 200: + raise SpanPanelAPIError(f"Failed to regenerate passphrase: HTTP {response.status_code}") + + data: dict[str, object] = response.json() + return _str(data["ebusBrokerPassword"]) + + +async def get_v2_status(host: str, timeout: float = 5.0) -> V2StatusInfo: + """Lightweight v2 status probe (unauthenticated). + + Args: + host: IP address or hostname of the SPAN Panel + timeout: Request timeout in seconds + + Returns: + V2StatusInfo with serial number and firmware version + + Raises: + SpanPanelConnectionError: Cannot reach panel + SpanPanelTimeoutError: Request timed out + SpanPanelAPIError: Unexpected response or non-v2 panel + """ + url = f"http://{host}/api/v2/status" + + try: + async with httpx.AsyncClient(timeout=timeout, verify=False) as client: # nosec B501 + response = await client.get(url) + except httpx.ConnectError as exc: + raise SpanPanelConnectionError(f"Cannot reach panel at {host}") from exc + except httpx.TimeoutException as exc: + raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc + + if response.status_code != 200: + raise SpanPanelAPIError(f"Panel does not support v2 API: HTTP {response.status_code}") + + data: dict[str, object] = response.json() + return V2StatusInfo( + serial_number=str(data.get("serialNumber", "")), + firmware_version=str(data.get("firmwareVersion", "")), + ) diff --git a/src/span_panel_api/client.py b/src/span_panel_api/client.py deleted file mode 100644 index 1884c7a..0000000 --- a/src/span_panel_api/client.py +++ /dev/null @@ -1,1778 +0,0 @@ -"""SPAN Panel API Client. - -This module provides a high-level async client for the SPAN Panel REST API. -It wraps the generated OpenAPI client to provide a more convenient interface. -""" - -from __future__ import annotations - -import asyncio -from collections.abc import Awaitable, Callable, Coroutine -from contextlib import suppress -import logging -import time -from typing import Any, NoReturn, TypeVar - -import httpx - -from .const import AUTH_ERROR_CODES, RETRIABLE_ERROR_CODES, SERVER_ERROR_CODES -from .exceptions import ( - SpanPanelAPIError, - SpanPanelAuthError, - SpanPanelConnectionError, - SpanPanelRetriableError, - SpanPanelServerError, - SpanPanelTimeoutError, -) -from .simulation import DynamicSimulationEngine - -T = TypeVar("T") - -# Logger for this module -_LOGGER = logging.getLogger(__name__) - - -# Default async delay implementation -async def _default_async_delay(delay_seconds: float) -> None: - """Default async delay implementation using asyncio.sleep.""" - await asyncio.sleep(delay_seconds) - - -class _DelayFunctionRegistry: - """Registry for managing the async delay function.""" - - def __init__(self) -> None: - self._delay_func: Callable[[float], Awaitable[None]] = _default_async_delay - - def set_delay_func(self, delay_func: Callable[[float], Awaitable[None]] | None) -> None: - """Set the delay function.""" - self._delay_func = delay_func if delay_func is not None else _default_async_delay - - async def call_delay(self, delay_seconds: float) -> None: - """Call the current delay function.""" - await self._delay_func(delay_seconds) - - -# Module-level registry instance -_delay_registry = _DelayFunctionRegistry() - - -def set_async_delay_func(delay_func: Callable[[float], Awaitable[None]] | None) -> None: - """Set a custom async delay function for HA compatibility. - - This allows HA integrations to provide their own delay implementation - that works with HA's time simulation and event loop management. - - Args: - delay_func: Custom delay function that takes delay_seconds as float, - or None to use the default asyncio.sleep implementation. - - Example for HA integrations: - ```python - import span_panel_api.client as span_client - - async def ha_compatible_delay(delay_seconds: float) -> None: - # Use HA's event loop utilities - await hass.helpers.event.async_call_later(delay_seconds, lambda: None) - # Or just yield: await asyncio.sleep(0) - - # Set the custom delay function - span_client.set_async_delay_func(ha_compatible_delay) - ``` - """ - _delay_registry.set_delay_func(delay_func) - - -# Constants -BEARER_TOKEN_TYPE = "Bearer" # OAuth2 Bearer token type specification # nosec B105 - -try: - from .generated_client import AuthenticatedClient, Client - from .generated_client.api.default import ( - generate_jwt_api_v1_auth_register_post, - get_circuits_api_v1_circuits_get, - get_panel_state_api_v1_panel_get, - get_storage_soe_api_v1_storage_soe_get, - set_circuit_state_api_v_1_circuits_circuit_id_post, - system_status_api_v1_status_get, - ) - from .generated_client.errors import UnexpectedStatus - from .generated_client.models import ( - AuthIn, - AuthOut, - BatteryStorage, - BodySetCircuitStateApiV1CircuitsCircuitIdPost, - Branch, - Circuit, - CircuitsOut, - PanelState, - Priority, - PriorityIn, - RelayState, - RelayStateIn, - StatusOut, - ) - from .generated_client.models.http_validation_error import HTTPValidationError -except ImportError as e: - raise ImportError( - f"Could not import the generated client: {e}. " - "Make sure the generated_client is properly installed as part of span_panel_api." - ) from e - - -# Remove the RetryConfig class - using simple parameters instead - - -class SpanPanelClient: - """Modern async client for SPAN Panel REST API. - - This client provides a clean, async interface to the SPAN Panel API - using the generated httpx-based OpenAPI client as the underlying transport. - - Example: - async with SpanPanelClient("192.168.1.100") as client: - # Authenticate - auth = await client.authenticate("my-app", "My Application") - - # Get panel status - status = await client.get_status() - print(f"Panel: {status.system.manufacturer}") - - # Get circuits - circuits = await client.get_circuits() - for circuit_id, circuit in circuits.circuits.additional_properties.items(): - print(f"{circuit.name}: {circuit.instant_power_w}W") - """ - - def __init__( - self, - host: str, - port: int = 80, - timeout: float = 30.0, - use_ssl: bool = False, - # Retry configuration - simple parameters - retries: int = 0, # Default to 0 retries for fast feedback - retry_timeout: float = 0.5, # How long to wait between retry attempts - retry_backoff_multiplier: float = 2.0, - # Cache configuration - using persistent cache (no time window) - # Simulation configuration - simulation_mode: bool = False, # Enable simulation mode - simulation_config_path: str | None = None, # Path to YAML simulation config - simulation_start_time: str | None = None, # Override simulation start time (ISO format) - ) -> None: - """Initialize the SPAN Panel client. - - Args: - host: IP address or hostname of the SPAN Panel - port: Port number (default: 80) - timeout: Request timeout in seconds (default: 30.0) - use_ssl: Whether to use HTTPS (default: False) - retries: Number of retries (0 = no retries, 1 = 1 retry, etc.) - retry_timeout: Timeout between retry attempts in seconds - retry_backoff_multiplier: Exponential backoff multiplier - (cache uses persistent object cache, no time window) - simulation_mode: Enable simulation mode for testing (default: False) - simulation_config_path: Path to YAML simulation configuration file - simulation_start_time: Override simulation start time (ISO format, e.g., "2024-06-15T12:00:00") - """ - self._host = host - self._port = port - self._timeout = timeout - self._use_ssl = use_ssl - self._simulation_mode = simulation_mode - - # Simple retry configuration - validate and store - if retries < 0: - raise ValueError("retries must be non-negative") - if retry_timeout < 0: - raise ValueError("retry_timeout must be non-negative") - if retry_backoff_multiplier < 1: - raise ValueError("retry_backoff_multiplier must be at least 1") - - self._retries = retries - self._retry_timeout = retry_timeout - self._retry_backoff_multiplier = retry_backoff_multiplier - - # Track background refresh tasks - self._background_tasks: set[asyncio.Task[None]] = set() - - # Object pools for reuse (not caching - just object instances to avoid creation overhead) - self._status_object: StatusOut | None = None - self._panel_state_object: PanelState | None = None - self._circuits_object: CircuitsOut | None = None - self._battery_object: BatteryStorage | None = None - - # Initialize simulation engine if in simulation mode - self._simulation_engine: DynamicSimulationEngine | None = None - self._simulation_initialized = False - self._simulation_start_time_override = simulation_start_time - if simulation_mode: - # In simulation mode, use the host as the serial number for device identification - self._simulation_engine = DynamicSimulationEngine(serial_number=host, config_path=simulation_config_path) - - # Build base URL - scheme = "https" if use_ssl else "http" - self._base_url = f"{scheme}://{host}:{port}" - - # HTTP client - starts as unauthenticated, upgrades to authenticated after login - self._client: Client | AuthenticatedClient | None = None - self._access_token: str | None = None - - # Context tracking - critical for preventing "Cannot open a client instance more than once" - self._in_context: bool = False - self._httpx_client_owned: bool = False - - async def __aenter__(self) -> SpanPanelClient: - """Enter async context manager - opens the underlying httpx client for connection pooling.""" - if self._in_context: - raise RuntimeError("Cannot open a client instance more than once") - - # Create client if it doesn't exist - if self._client is None: - if self._access_token: - self._client = self._get_authenticated_client() - else: - self._client = self._get_unauthenticated_client() - - # Enter the httpx client context - # Must manually call __aenter__ - can't use async with because we need the client - # to stay open until __aexit__ is called (split context management pattern) - try: - await self._client.__aenter__() # pylint: disable=unnecessary-dunder-call - except Exception as e: - # Reset state on failure - self._client = None - raise RuntimeError(f"Failed to enter client context: {e}") from e - - self._in_context = True - return self - - async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: - """Exit async context manager - closes the underlying httpx client.""" - if not self._in_context: - return - - try: - if self._client is not None: - with suppress(Exception): - await self._client.__aexit__(exc_type, exc_val, exc_tb) - finally: - self._in_context = False - self._client = None - - def _create_background_task(self, coro: Coroutine[Any, Any, None]) -> None: - """Create a background task and track it for cleanup.""" - task: asyncio.Task[None] = asyncio.create_task(coro) - self._background_tasks.add(task) - task.add_done_callback(self._background_tasks.discard) - - async def _ensure_simulation_initialized(self) -> None: - """Ensure simulation engine is properly initialized asynchronously.""" - if not self._simulation_mode or self._simulation_initialized: - return - - if self._simulation_engine is not None: - await self._simulation_engine.initialize_async() - - # Override simulation start time if provided - if self._simulation_start_time_override: - self._simulation_engine.override_simulation_start_time(self._simulation_start_time_override) - - self._simulation_initialized = True - - def _convert_raw_to_circuits_out(self, raw_data: dict[str, Any]) -> CircuitsOut: - """Convert raw simulation data to CircuitsOut model.""" - # This is a simplified conversion - in reality, you'd need to properly - # construct the CircuitsOut object from the raw data - return CircuitsOut.from_dict(raw_data) - - def _convert_raw_to_panel_state(self, raw_data: dict[str, Any]) -> PanelState: - """Convert raw simulation data to PanelState model.""" - return PanelState.from_dict(raw_data) - - def _convert_raw_to_status_out(self, raw_data: dict[str, Any]) -> StatusOut: - """Convert raw simulation data to StatusOut model.""" - return StatusOut.from_dict(raw_data) - - def _convert_raw_to_battery_storage(self, raw_data: dict[str, Any]) -> BatteryStorage: - """Convert raw simulation data to BatteryStorage model.""" - return BatteryStorage.from_dict(raw_data) - - def _update_status_in_place(self, existing: StatusOut, fresh: StatusOut) -> None: - """Update existing StatusOut object with fresh data to avoid object creation.""" - # Update software attributes individually to preserve references - existing.software.firmware_version = fresh.software.firmware_version - existing.software.update_status = fresh.software.update_status - existing.software.env = fresh.software.env - existing.software.additional_properties.clear() - existing.software.additional_properties.update(fresh.software.additional_properties) - - # Update system attributes individually to preserve references - existing.system.manufacturer = fresh.system.manufacturer - existing.system.serial = fresh.system.serial - existing.system.model = fresh.system.model - existing.system.door_state = fresh.system.door_state - existing.system.proximity_proven = fresh.system.proximity_proven - existing.system.uptime = fresh.system.uptime - existing.system.additional_properties.clear() - existing.system.additional_properties.update(fresh.system.additional_properties) - - # Update network attributes individually to preserve references - existing.network.eth_0_link = fresh.network.eth_0_link - existing.network.wlan_link = fresh.network.wlan_link - existing.network.wwan_link = fresh.network.wwan_link - existing.network.additional_properties.clear() - existing.network.additional_properties.update(fresh.network.additional_properties) - - # Update additional_properties - existing.additional_properties.clear() - existing.additional_properties.update(fresh.additional_properties) - - def _update_panel_state_in_place(self, existing: PanelState, fresh: PanelState) -> None: - """Update existing PanelState object with fresh data to avoid object creation.""" - # Update simple attributes - existing.main_relay_state = fresh.main_relay_state - existing.instant_grid_power_w = fresh.instant_grid_power_w - existing.feedthrough_power_w = fresh.feedthrough_power_w - existing.grid_sample_start_ms = fresh.grid_sample_start_ms - existing.grid_sample_end_ms = fresh.grid_sample_end_ms - existing.dsm_grid_state = fresh.dsm_grid_state - existing.dsm_state = fresh.dsm_state - existing.current_run_config = fresh.current_run_config - - # Update main_meter_energy attributes individually to preserve references - existing.main_meter_energy.produced_energy_wh = fresh.main_meter_energy.produced_energy_wh - existing.main_meter_energy.consumed_energy_wh = fresh.main_meter_energy.consumed_energy_wh - existing.main_meter_energy.additional_properties.clear() - existing.main_meter_energy.additional_properties.update(fresh.main_meter_energy.additional_properties) - - # Update feedthrough_energy attributes individually to preserve references - existing.feedthrough_energy.produced_energy_wh = fresh.feedthrough_energy.produced_energy_wh - existing.feedthrough_energy.consumed_energy_wh = fresh.feedthrough_energy.consumed_energy_wh - existing.feedthrough_energy.additional_properties.clear() - existing.feedthrough_energy.additional_properties.update(fresh.feedthrough_energy.additional_properties) - - # Update branches - if same length, update existing branch objects; otherwise replace list - if len(existing.branches) == len(fresh.branches): - for i, fresh_branch in enumerate(fresh.branches): - existing_branch = existing.branches[i] - existing_branch.id = fresh_branch.id - existing_branch.relay_state = fresh_branch.relay_state - existing_branch.instant_power_w = fresh_branch.instant_power_w - existing_branch.imported_active_energy_wh = fresh_branch.imported_active_energy_wh - existing_branch.exported_active_energy_wh = fresh_branch.exported_active_energy_wh - existing_branch.measure_start_ts_ms = fresh_branch.measure_start_ts_ms - existing_branch.measure_duration_ms = fresh_branch.measure_duration_ms - existing_branch.is_measure_valid = fresh_branch.is_measure_valid - existing_branch.additional_properties.clear() - existing_branch.additional_properties.update(fresh_branch.additional_properties) - else: - # Different number of branches - replace the entire list - existing.branches = fresh.branches - - # Update additional_properties - existing.additional_properties.clear() - existing.additional_properties.update(fresh.additional_properties) - - def _update_circuits_in_place(self, existing: CircuitsOut, fresh: CircuitsOut) -> None: - """Update existing CircuitsOut object with fresh data to avoid object creation.""" - # Update circuits.additional_properties (the circuit dictionary) to preserve references - existing.circuits.additional_properties.clear() - existing.circuits.additional_properties.update(fresh.circuits.additional_properties) - - # Update additional_properties - existing.additional_properties.clear() - existing.additional_properties.update(fresh.additional_properties) - - def _update_battery_storage_in_place(self, existing: BatteryStorage, fresh: BatteryStorage) -> None: - """Update existing BatteryStorage object with fresh data to avoid object creation.""" - # Update soe attributes individually to preserve references - existing.soe.percentage = fresh.soe.percentage - existing.soe.additional_properties.clear() - existing.soe.additional_properties.update(fresh.soe.additional_properties) - - # Update additional_properties - existing.additional_properties.clear() - existing.additional_properties.update(fresh.additional_properties) - - # Properties for querying and setting retry configuration - @property - def retries(self) -> int: - """Get the number of retries.""" - return self._retries - - @retries.setter - def retries(self, value: int) -> None: - """Set the number of retries.""" - if value < 0: - raise ValueError("retries must be non-negative") - self._retries = value - - @property - def retry_timeout(self) -> float: - """Get the timeout between retries in seconds.""" - return self._retry_timeout - - @retry_timeout.setter - def retry_timeout(self, value: float) -> None: - """Set the timeout between retries in seconds.""" - if value < 0: - raise ValueError("retry_timeout must be non-negative") - self._retry_timeout = value - - @property - def retry_backoff_multiplier(self) -> float: - """Get the exponential backoff multiplier.""" - return self._retry_backoff_multiplier - - @retry_backoff_multiplier.setter - def retry_backoff_multiplier(self, value: float) -> None: - """Set the exponential backoff multiplier.""" - if value < 1: - raise ValueError("retry_backoff_multiplier must be at least 1") - self._retry_backoff_multiplier = value - - async def _ensure_client_opened(self, client: AuthenticatedClient | Client) -> None: - """Ensure the httpx client is opened for connection pooling.""" - # Check if the async client is already opened by trying to access it - with suppress(Exception): - client.get_async_httpx_client() - # If we can get it without error, it's already available - # The httpx.AsyncClient will handle connection pooling automatically - - def _get_client(self) -> AuthenticatedClient | Client: - """Get the appropriate HTTP client based on whether we have an access token.""" - if self._access_token: - # We have a token, use authenticated client - if self._client is None or not isinstance(self._client, AuthenticatedClient): - # Configure httpx for better connection pooling and persistence - httpx_args = { - "limits": httpx.Limits( - max_keepalive_connections=5, # Keep connections alive - max_connections=10, # Allow multiple connections - keepalive_expiry=4.0, # Close before server's 5s keep-alive timeout - ), - } - - # Create a new authenticated client - self._client = AuthenticatedClient( - base_url=self._base_url, - token=self._access_token, - timeout=httpx.Timeout( - connect=5.0, # Connection timeout - read=self._timeout, # Read timeout - write=5.0, # Write timeout - pool=2.0, # Pool timeout - ), - verify_ssl=self._use_ssl, - raise_on_unexpected_status=True, - httpx_args=httpx_args, - ) - # Only set _httpx_client_owned if we're not in a context - # This prevents us from managing a client that's already managed by a context - self._httpx_client_owned = not self._in_context - return self._client - # No token, use unauthenticated client - return self._get_unauthenticated_client() - - def _get_unauthenticated_client(self) -> Client: - """Get an unauthenticated client for operations that don't require auth.""" - # Configure httpx for better connection pooling and persistence - httpx_args = { - "limits": httpx.Limits( - max_keepalive_connections=5, # Keep connections alive - max_connections=10, # Allow multiple connections - keepalive_expiry=4.0, # Close before server's 5s keep-alive timeout - ), - } - - client = Client( - base_url=self._base_url, - timeout=httpx.Timeout(self._timeout), - verify_ssl=self._use_ssl, - raise_on_unexpected_status=True, - httpx_args=httpx_args, - ) - # Only set _httpx_client_owned if we're not in a context - if not self._in_context and self._client is None: - self._client = client - self._httpx_client_owned = True - return client - - def _get_authenticated_client(self) -> AuthenticatedClient: - """Get an authenticated client for operations that require auth.""" - if not self._access_token: - raise SpanPanelAuthError("No access token available for authenticated operations") - # Configure httpx for better connection pooling and persistence - httpx_args = { - "limits": httpx.Limits( - max_keepalive_connections=5, # Keep connections alive - max_connections=10, # Allow multiple connections - keepalive_expiry=4.0, # Close before server's 5s keep-alive timeout - ), - } - - client = AuthenticatedClient( - base_url=self._base_url, - token=self._access_token, - timeout=httpx.Timeout(self._timeout), - verify_ssl=self._use_ssl, - raise_on_unexpected_status=True, - httpx_args=httpx_args, - ) - # Only set _httpx_client_owned if we're not in a context - if not self._in_context and self._client is None: - self._client = client - self._httpx_client_owned = True - return client - - def set_access_token(self, token: str) -> None: - """Set the access token for API authentication. - - Updates the client's authentication token. If the client is already in a - context manager, it will safely upgrade the client from unauthenticated - to authenticated without disrupting the context. - - Args: - token: The JWT access token for API authentication - """ - if token == self._access_token: - # Token hasn't changed, nothing to do - return - - self._access_token = token - - # Handle token change based on context state - if not self._in_context: - # Outside context: safe to reset client completely - if self._client is not None: - # Clear client so it will be recreated on next use - self._client = None - self._httpx_client_owned = False - elif self._client is not None: - # Inside context: need to carefully upgrade client while preserving httpx instance - if not isinstance(self._client, AuthenticatedClient): - # Need to upgrade from Client to AuthenticatedClient - # Store reference to existing async client before creating new authenticated client - old_async_client = None - with suppress(Exception): - # Client may not have been initialized yet - old_async_client = self._client.get_async_httpx_client() - - self._client = AuthenticatedClient( - base_url=self._base_url, - token=token, - timeout=httpx.Timeout( - connect=5.0, # Connection timeout - read=self._timeout, # Read timeout - write=5.0, # Write timeout - pool=2.0, # Pool timeout - ), - verify_ssl=self._use_ssl, - raise_on_unexpected_status=True, - ) - # Preserve the existing httpx async client to avoid double context issues - if old_async_client is not None: - self._client.set_async_httpx_client(old_async_client) - # Update the Authorization header on the existing httpx client - header_value = f"{self._client.prefix} {self._client.token}" - old_async_client.headers[self._client.auth_header_name] = header_value - else: - # Already an AuthenticatedClient, just update the token - self._client.token = token - # Update the Authorization header on existing httpx clients - header_value = f"{self._client.prefix} {self._client.token}" - with suppress(Exception): - async_client = self._client.get_async_httpx_client() - async_client.headers[self._client.auth_header_name] = header_value - with suppress(Exception): - sync_client = self._client.get_httpx_client() - sync_client.headers[self._client.auth_header_name] = header_value - - def _handle_unexpected_status(self, e: UnexpectedStatus) -> NoReturn: - """Convert UnexpectedStatus to appropriate SpanPanel exception. - - Args: - e: The UnexpectedStatus to convert - - Raises: - SpanPanelAuthError: For 401/403 errors - SpanPanelRetriableError: For 502/503/504 errors (retriable) - SpanPanelServerError: For 500 errors (non-retriable) - SpanPanelAPIError: For all other HTTP errors - """ - if e.status_code in AUTH_ERROR_CODES: - # If we have a token but got 401/403, authentication failed - # If we don't have a token, authentication is required - if self._access_token: - raise SpanPanelAuthError(f"Authentication failed: Status {e.status_code}") from e - raise SpanPanelAuthError("Authentication required") from e - if e.status_code in RETRIABLE_ERROR_CODES: - raise SpanPanelRetriableError(f"Retriable server error {e.status_code}: {e}") from e - if e.status_code in SERVER_ERROR_CODES: - raise SpanPanelServerError(f"Server error {e.status_code}: {e}") from e - raise SpanPanelAPIError(f"HTTP {e.status_code}: {e}") from e - - def _get_client_for_endpoint(self, requires_auth: bool = True) -> AuthenticatedClient | Client: - """Get the appropriate client for an endpoint with automatic connection management. - - Args: - requires_auth: Whether the endpoint requires authentication - - Returns: - AuthenticatedClient if authentication is required or available, - Client if no authentication is needed - """ - if requires_auth and not self._access_token: - # Endpoint requires auth but we don't have a token - raise SpanPanelAuthError("This endpoint requires authentication. Call authenticate() first.") - - # If we're in a context, always use the existing client - if self._in_context: - if self._client is None: - raise SpanPanelAPIError("Client is None while in context - this indicates a lifecycle issue") - # Verify we have the right client type for the request - if requires_auth and self._access_token and not isinstance(self._client, AuthenticatedClient): - # We need auth but have wrong client type - this shouldn't happen after our fix - raise SpanPanelAPIError("Client type mismatch: need AuthenticatedClient but have Client") - return self._client - - # Not in context, get appropriate client type based on auth requirement - if not requires_auth: - # For endpoints that don't require auth, always use unauthenticated client - # This prevents mixing client types which can cause connection issues - return self._get_unauthenticated_client() - - # For endpoints that require auth, use the main authenticated client - if self._client is None: - self._client = self._get_client() - - # Ensure the underlying httpx client is accessible for connection pooling - # This doesn't open a context, just ensures the client is ready to use - with suppress(Exception): - self._client.get_async_httpx_client() - - return self._client - - async def _retry_with_backoff(self, operation: Callable[..., Awaitable[T]], *args: Any, **kwargs: Any) -> T: - """Execute an operation with retry logic and exponential backoff. - - Args: - operation: The async function to call - *args: Arguments for the operation - **kwargs: Keyword arguments for the operation - - Returns: - The result of the operation - - Raises: - The final exception if all retries are exhausted - """ - retry_status_codes = set(RETRIABLE_ERROR_CODES) # Retriable HTTP status codes - max_attempts = self._retries + 1 # retries=0 means 1 attempt, retries=1 means 2 attempts, etc. - - for attempt in range(max_attempts): - try: - return await operation(*args, **kwargs) - except UnexpectedStatus as e: - # Only retry specific HTTP status codes that are typically transient - if e.status_code in retry_status_codes and attempt < max_attempts - 1: - delay = self._retry_timeout * (self._retry_backoff_multiplier**attempt) # Exponential backoff - await _delay_registry.call_delay(delay) - continue - # Not retriable or last attempt - re-raise - raise - except httpx.HTTPStatusError as e: - # Only retry specific HTTP status codes that are typically transient - if e.response.status_code in retry_status_codes and attempt < max_attempts - 1: - delay = self._retry_timeout * (self._retry_backoff_multiplier**attempt) # Exponential backoff - await _delay_registry.call_delay(delay) - continue - # Not retriable or last attempt - re-raise - raise - except (httpx.ConnectError, httpx.TimeoutException): - # Network/timeout errors are always retriable - if attempt < max_attempts - 1: - delay = self._retry_timeout * (self._retry_backoff_multiplier**attempt) # Exponential backoff - await _delay_registry.call_delay(delay) - continue - # Last attempt - re-raise - raise - except httpx.RemoteProtocolError: - # Server closed connection (stale keep-alive) - all pooled connections likely dead - # Destroy client to force fresh connection pool on retry - if self._client is not None: - with suppress(Exception): - await self._client.__aexit__(None, None, None) - self._client = None - - # If in context mode, recreate client to maintain invariant that _client is not None - if self._in_context: - if self._access_token: - self._client = self._get_authenticated_client() - else: - self._client = self._get_unauthenticated_client() - # Must manually enter context - can't use async with here as we're already in a context - # and need to keep client alive for retry. This matches the pattern in __aenter__ (line 239). - await self._client.__aenter__() # pylint: disable=unnecessary-dunder-call - - if attempt < max_attempts - 1: - continue # Immediate retry - no delay needed - raise - - # This should never be reached, but required for mypy type checking - raise SpanPanelAPIError("Retry operation completed without success or exception") - - # Authentication Methods - async def authenticate( - self, name: str, description: str = "", otp: str | None = None, dashboard_password: str | None = None - ) -> AuthOut: - """Register and authenticate a new API client. - - Args: - name: Client name - description: Optional client description - otp: Optional One-Time Password for enhanced security - dashboard_password: Optional dashboard password for authentication - - Returns: - AuthOut containing access token - """ - # In simulation mode, return a mock authentication response - if self._simulation_mode: - # Create a mock authentication response - mock_token = f"sim-token-{name}-{int(time.time())}" - current_time_ms = int(time.time() * 1000) - auth_out = AuthOut(access_token=mock_token, token_type=BEARER_TOKEN_TYPE, iat_ms=current_time_ms) - self.set_access_token(mock_token) - return auth_out - - # Use unauthenticated client for registration - client = self._get_unauthenticated_client() - - # Create auth input with all provided parameters - auth_in = AuthIn(name=name, description=description) - if otp is not None: - auth_in.otp = otp - if dashboard_password is not None: - auth_in.dashboard_password = dashboard_password - - try: - # Use the client directly - auth registration works with unauthenticated clients - # Cast to AuthenticatedClient to satisfy type checker (even though it's not actually authenticated) - response = await generate_jwt_api_v1_auth_register_post.asyncio(client=client, body=auth_in) - # Handle response - could be AuthOut, HTTPValidationError, or None - if response is None: - raise SpanPanelAPIError("Authentication failed - no response from server") - if isinstance(response, HTTPValidationError): - error_details = getattr(response, "detail", "Unknown validation error") - raise SpanPanelAPIError(f"Validation error during authentication: {error_details}") - if hasattr(response, "access_token"): - # Store the token for future requests (works for both AuthOut and mocks) - self.set_access_token(response.access_token) - return response - raise SpanPanelAPIError(f"Unexpected response type: {type(response)}, response: {response}") - except UnexpectedStatus as e: - # Convert UnexpectedStatus to appropriate SpanPanel exception - # Special case for auth endpoint - 401/403 here means auth failed - error_text = f"Status {e.status_code}" - - if e.status_code in AUTH_ERROR_CODES: - raise SpanPanelAuthError(f"Authentication failed: {error_text}") from e - if e.status_code in RETRIABLE_ERROR_CODES: - raise SpanPanelRetriableError(f"Retriable server error {e.status_code}: {error_text}", e.status_code) from e - if e.status_code in SERVER_ERROR_CODES: - raise SpanPanelServerError(f"Server error {e.status_code}: {error_text}", e.status_code) from e - raise SpanPanelAPIError(f"HTTP {e.status_code}: {error_text}", e.status_code) from e - except httpx.HTTPStatusError as e: - # Convert HTTPStatusError to UnexpectedStatus and handle appropriately - # Special case for auth endpoint - 401/403 here means auth failed - error_text = e.response.text if hasattr(e.response, "text") else str(e) - - if e.response.status_code in AUTH_ERROR_CODES: - raise SpanPanelAuthError(f"Authentication failed: {error_text}") from e - if e.response.status_code in RETRIABLE_ERROR_CODES: - raise SpanPanelRetriableError( - f"Retriable server error {e.response.status_code}: {error_text}", e.response.status_code - ) from e - if e.response.status_code in SERVER_ERROR_CODES: - raise SpanPanelServerError( - f"Server error {e.response.status_code}: {error_text}", e.response.status_code - ) from e - raise SpanPanelAPIError(f"HTTP {e.response.status_code}: {error_text}", e.response.status_code) from e - except httpx.ConnectError as e: - raise SpanPanelConnectionError(f"Failed to connect to {self._host}") from e - except httpx.TimeoutException as e: - raise SpanPanelTimeoutError(f"Request timed out after {self._timeout}s") from e - except ValueError as e: - # Handle specific dictionary parsing errors from malformed server responses - if "dictionary update sequence element" in str(e) and "length" in str(e) and "required" in str(e): - raise SpanPanelAPIError( - f"Server returned malformed authentication response. " - f"This may indicate a panel firmware issue or network problem. " - f"Original error: {e}" - ) from e - # Handle other ValueError instances (like Pydantic validation errors) - raise SpanPanelAPIError(f"Invalid data during authentication: {e}") from e - except Exception as e: - # Catch any other unexpected errors - raise SpanPanelAPIError(f"Unexpected error during authentication: {e}") from e - - # Panel Status and Info - async def get_status(self) -> StatusOut: - """Get complete panel system status (does not require authentication).""" - # In simulation mode, use simulation engine - if self._simulation_mode: - return await self._get_status_simulation() - - # In live mode, use standard endpoint - return await self._get_status_live() - - async def _get_status_simulation(self) -> StatusOut: - """Get status data in simulation mode.""" - if self._simulation_engine is None: - raise SpanPanelAPIError("Simulation engine not initialized") - - # Ensure simulation is properly initialized asynchronously - await self._ensure_simulation_initialized() - - # Get simulation data - status_data = await self._simulation_engine.get_status() - - # Convert to model object - fresh_status = self._convert_raw_to_status_out(status_data) - - # Reuse existing object to avoid creation overhead - if self._status_object is None: - self._status_object = fresh_status - else: - self._update_status_in_place(self._status_object, fresh_status) - - return self._status_object - - async def _get_status_live(self) -> StatusOut: - """Get status data from live panel.""" - - async def _get_status_operation() -> StatusOut: - client = self._get_client_for_endpoint(requires_auth=False) - # Status endpoint works with both authenticated and unauthenticated clients - result = await system_status_api_v1_status_get.asyncio(client=client) - # Since raise_on_unexpected_status=True, result should never be None - if result is None: - raise SpanPanelAPIError("API result is None despite raise_on_unexpected_status=True") - return result - - try: - # Fetch fresh data from API - start_time = time.time() - fresh_status = await self._retry_with_backoff(_get_status_operation) - api_duration = time.time() - start_time - _LOGGER.debug("Status API call took %.3fs", api_duration) - - # Reuse existing object to avoid creation overhead - if self._status_object is None: - self._status_object = fresh_status - else: - self._update_status_in_place(self._status_object, fresh_status) - - return self._status_object - except UnexpectedStatus as e: - self._handle_unexpected_status(e) - except httpx.HTTPStatusError as e: - unexpected_status = UnexpectedStatus(e.response.status_code, e.response.content) - self._handle_unexpected_status(unexpected_status) - except httpx.ConnectError as e: - raise SpanPanelConnectionError(f"Failed to connect to {self._host}") from e - except httpx.TimeoutException as e: - raise SpanPanelTimeoutError(f"Request timed out after {self._timeout}s") from e - except ValueError as e: - # Handle Pydantic validation errors and other ValueError instances - raise SpanPanelAPIError(f"API error: {e}") from e - except Exception as e: - # Catch and wrap all other exceptions - raise SpanPanelAPIError(f"Unexpected error: {e}") from e - - async def get_panel_state(self) -> PanelState: - """Get panel state information. - - In simulation mode, panel behavior is defined by the YAML configuration file. - Use set_panel_overrides() for temporary variations outside normal ranges. - """ - # In simulation mode, use simulation engine - if self._simulation_mode: - return await self._get_panel_state_simulation() - - # In live mode, use live implementation - return await self._get_panel_state_live() - - async def _get_panel_state_simulation(self) -> PanelState: - """Get panel state data in simulation mode.""" - if self._simulation_engine is None: - raise SpanPanelAPIError("Simulation engine not initialized") - - # Ensure simulation is properly initialized asynchronously - await self._ensure_simulation_initialized() - - # Get simulation data - full_data = await self._simulation_engine.get_panel_data() - panel_data = full_data.get("panel", {}) - - # Convert to model object - fresh_panel_state = self._convert_raw_to_panel_state(panel_data) - - # Synchronize branch power with circuit power for consistency - await self._synchronize_branch_power_with_circuits(fresh_panel_state, full_data) - - # Note: Panel grid power will be recalculated after circuits are processed - # to ensure consistency with the actual circuit power values - - # Reuse existing object to avoid creation overhead - if self._panel_state_object is None: - self._panel_state_object = fresh_panel_state - else: - self._update_panel_state_in_place(self._panel_state_object, fresh_panel_state) - - return self._panel_state_object - - async def _adjust_panel_power_for_virtual_circuits(self, panel_state: PanelState) -> None: - """Adjust panel power to include unmapped tab power for consistency with circuit totals.""" - if not hasattr(panel_state, "branches") or not panel_state.branches: - return - - # This method is no longer needed without caching - return - - def _validate_synchronization_data(self, panel_state: PanelState, full_data: dict[str, Any]) -> dict[str, Any] | None: - """Validate data required for branch power synchronization.""" - if not hasattr(panel_state, "branches") or not panel_state.branches: - _LOGGER.debug("No branches to synchronize") - return None - - circuits_data = full_data.get("circuits", {}) - if not circuits_data: - _LOGGER.debug("No circuits data to synchronize") - return None - - # The circuits data has a nested structure: circuits -> {circuit_id: circuit_data} - actual_circuits = circuits_data.get("circuits", circuits_data) - if not actual_circuits: - _LOGGER.debug("No actual circuits data to synchronize") - return None - - if isinstance(actual_circuits, dict): - return actual_circuits - return None - - def _build_tab_power_mapping(self, actual_circuits: dict[str, Any], panel_state: PanelState) -> dict[int, float]: - """Build mapping of tab numbers to total circuit power for that tab.""" - tab_power_map: dict[int, float] = {} - - # Process each circuit and distribute its power across its tabs - for _circuit_id, circuit_data in actual_circuits.items(): - if not isinstance(circuit_data, dict): - continue - - circuit_power = circuit_data.get("instantPowerW", 0.0) - circuit_tabs = circuit_data.get("tabs", []) - - if not circuit_tabs: - continue - - # Handle both single tab and multi-tab circuits - if isinstance(circuit_tabs, int): - circuit_tabs = [circuit_tabs] - elif not isinstance(circuit_tabs, list): - continue - - # Distribute circuit power equally across its tabs - power_per_tab = circuit_power / len(circuit_tabs) if circuit_tabs else 0.0 - - for tab_num in circuit_tabs: - if isinstance(tab_num, int) and 1 <= tab_num <= len(panel_state.branches): - tab_power_map[tab_num] = tab_power_map.get(tab_num, 0.0) + power_per_tab - - return tab_power_map - - def _update_branch_power(self, panel_state: PanelState, tab_power_map: dict[int, float]) -> None: - """Update branch power to match circuit power.""" - for tab_num, power in tab_power_map.items(): - branch_idx = tab_num - 1 - if 0 <= branch_idx < len(panel_state.branches): - panel_state.branches[branch_idx].instant_power_w = power - - def _calculate_grid_power(self, actual_circuits: dict[str, Any]) -> tuple[float, float, float]: - """Calculate grid power from circuit consumption and production.""" - total_consumption = 0.0 - total_production = 0.0 - - for circuit_id, circuit_data in actual_circuits.items(): - if not isinstance(circuit_data, dict): - continue - - circuit_power = circuit_data.get("instantPowerW", 0.0) - circuit_name = circuit_data.get("name", circuit_id).lower() - - # Identify producer circuits by name or configuration - if any(keyword in circuit_name for keyword in ["solar", "inverter", "generator", "battery"]): - total_production += circuit_power - else: - total_consumption += circuit_power - - # Panel grid power = consumption - production - # Positive = importing from grid, Negative = exporting to grid - grid_power = total_consumption - total_production - return total_consumption, total_production, grid_power - - async def _synchronize_branch_power_with_circuits(self, panel_state: PanelState, full_data: dict[str, Any]) -> None: - """Synchronize branch power with circuit power for consistency in simulation mode.""" - actual_circuits = self._validate_synchronization_data(panel_state, full_data) - if actual_circuits is None: - return - - _LOGGER.debug("Synchronizing branch power with %d circuits", len(actual_circuits)) - - # Build tab power mapping and update branches - tab_power_map = self._build_tab_power_mapping(actual_circuits, panel_state) - self._update_branch_power(panel_state, tab_power_map) - - # Calculate and update grid power - total_consumption, total_production, grid_power = self._calculate_grid_power(actual_circuits) - panel_state.instant_grid_power_w = grid_power - - _LOGGER.debug( - "Branch power synchronization complete: %d tabs updated, consumption: %.1fW, production: %.1fW, grid: %.1fW", - len(tab_power_map), - total_consumption, - total_production, - panel_state.instant_grid_power_w, - ) - - async def _recalculate_panel_grid_power_from_circuits(self, circuits_out: CircuitsOut) -> None: - """Recalculate panel grid power to match the actual circuit power values. - - This method ensures panel state consistency regardless of initialization order - and handles energy aggregation more robustly. - """ - # Calculate total circuit power and energy using the same logic as the test - total_consumption = 0.0 - total_production = 0.0 - total_produced_energy = 0.0 - total_consumed_energy = 0.0 - circuits_with_unavailable_energy = 0 - total_circuits_processed = 0 - - # Iterate through circuits stored in additional_properties - for circuit_id, circuit in circuits_out.circuits.additional_properties.items(): - # Process all circuits with valid IDs (including empty string, but not None) - # Only skip None circuit_id to avoid masking data problems - if circuit_id is not None: - # Skip virtual circuits for unmapped tabs from power calculations - # because we already count the power for circuits that are mapped to tabs - # but still count them for energy aggregation consistency - if not circuit_id.startswith("unmapped_tab_"): - # Use the same logic as the test: determine if consuming or producing based on circuit type - circuit_name = circuit.name.lower() if circuit.name else "" - if any(keyword in circuit_name for keyword in ["solar", "inverter", "generator", "battery"]): - # Producer circuits (solar, battery when discharging) - total_production += circuit.instant_power_w - else: - # Consumer circuits - total_consumption += circuit.instant_power_w - - # Count circuits with unavailable energy data for better aggregation logic - total_circuits_processed += 1 - if circuit.produced_energy_wh is None or circuit.consumed_energy_wh is None: - circuits_with_unavailable_energy += 1 - - # Sum up energy values (skip None values which indicate unavailable data) - if circuit.produced_energy_wh is not None: - total_produced_energy += circuit.produced_energy_wh - if circuit.consumed_energy_wh is not None: - total_consumed_energy += circuit.consumed_energy_wh - - # Update panel grid power to match circuit totals - expected_grid_power = total_consumption - total_production - - # Always ensure panel state object exists to avoid order dependency - if self._panel_state_object is None: - # If panel state doesn't exist yet, we can't update it - # This is expected in some call orders and should not cause errors - _LOGGER.debug("Panel state object not initialized, skipping grid power update") - return - - # Update panel state with calculated values - self._panel_state_object.instant_grid_power_w = expected_grid_power - - # More nuanced energy aggregation: only set to None if significant portion is unavailable - # This prevents a single circuit with unavailable data from invalidating all energy data - if total_circuits_processed == 0: - # No circuits processed, keep existing energy values - return - - unavailable_ratio = circuits_with_unavailable_energy / total_circuits_processed - - # Only set energy to None if more than 50% of circuits have unavailable energy - # This provides better resilience while still indicating when data is significantly incomplete - if unavailable_ratio > 0.5: - self._panel_state_object.main_meter_energy.produced_energy_wh = None - self._panel_state_object.main_meter_energy.consumed_energy_wh = None - _LOGGER.debug( - "Setting panel energy to None: %d/%d circuits have unavailable energy (%.1f%%)", - circuits_with_unavailable_energy, - total_circuits_processed, - unavailable_ratio * 100, - ) - else: - self._panel_state_object.main_meter_energy.produced_energy_wh = total_produced_energy - self._panel_state_object.main_meter_energy.consumed_energy_wh = total_consumed_energy - if circuits_with_unavailable_energy > 0: - _LOGGER.debug( - "Panel energy aggregated despite %d/%d circuits with unavailable energy (%.1f%%)", - circuits_with_unavailable_energy, - total_circuits_processed, - unavailable_ratio * 100, - ) - - async def _get_panel_state_live(self) -> PanelState: - """Get panel state data from live panel.""" - - async def _get_panel_state_operation() -> PanelState: - client = self._get_client_for_endpoint(requires_auth=True) - # Panel state requires authentication - result = await get_panel_state_api_v1_panel_get.asyncio(client=client) - # Since raise_on_unexpected_status=True, result should never be None - if result is None: - raise SpanPanelAPIError("API result is None despite raise_on_unexpected_status=True") - return result - - try: - # Fetch fresh data from API - start_time = time.time() - fresh_state = await self._retry_with_backoff(_get_panel_state_operation) - api_duration = time.time() - start_time - _LOGGER.debug("Panel state API call took %.3fs", api_duration) - - # Reuse existing object to avoid creation overhead - if self._panel_state_object is None: - self._panel_state_object = fresh_state - else: - self._update_panel_state_in_place(self._panel_state_object, fresh_state) - - return self._panel_state_object - except SpanPanelAuthError: - # Pass through auth errors directly - raise - except UnexpectedStatus as e: - self._handle_unexpected_status(e) - except httpx.HTTPStatusError as e: - unexpected_status = UnexpectedStatus(e.response.status_code, e.response.content) - self._handle_unexpected_status(unexpected_status) - except httpx.ConnectError as e: - raise SpanPanelConnectionError(f"Failed to connect to {self._host}") from e - except httpx.TimeoutException as e: - raise SpanPanelTimeoutError(f"Request timed out after {self._timeout}s") from e - except ValueError as e: - # Handle Pydantic validation errors and other ValueError instances - raise SpanPanelAPIError(f"API error: {e}") from e - except Exception as e: - # Only convert to auth error if it's specifically an HTTP 401 error, not just any error mentioning "401" - if isinstance(e, (httpx.HTTPStatusError | UnexpectedStatus | RuntimeError)) and "401" in str(e): - # If we have a token but got 401, authentication failed - # If we don't have a token, authentication is required - if self._access_token: - raise SpanPanelAuthError("Authentication failed") from e - raise SpanPanelAuthError("Authentication required") from e - # All other exceptions are internal errors, not auth problems - raise SpanPanelAPIError(f"Unexpected error: {e}") from e - - async def get_circuits(self) -> CircuitsOut: - """Get all circuits and their current state, including virtual circuits for unmapped tabs. - - In simulation mode, circuit behavior is defined by the YAML configuration file. - Use set_circuit_overrides() for temporary variations outside normal ranges. - """ - # In simulation mode, use simulation engine - if self._simulation_mode: - return await self._get_circuits_simulation() - - # In live mode, use live implementation - return await self._get_circuits_live() - - async def _get_circuits_simulation(self) -> CircuitsOut: - """Get circuits data in simulation mode.""" - if self._simulation_engine is None: - raise SpanPanelAPIError("Simulation engine not initialized") - - # Ensure simulation is properly initialized asynchronously - await self._ensure_simulation_initialized() - - # Get simulation data (contains both circuits and panel data) - full_data = await self._simulation_engine.get_panel_data() - circuits_data = full_data.get("circuits", {}) - - # Convert to model object - fresh_circuits = self._convert_raw_to_circuits_out(circuits_data) - - # Extract branches directly from full_data to avoid redundant API call - panel_data = full_data.get("panel", {}) - if panel_data and "branches" in panel_data: - # Convert branches from raw data - branches = [] - for branch_data in panel_data.get("branches", []): - branch = Branch( - id=branch_data.get("id", ""), - relay_state=RelayState(branch_data.get("relayState", "CLOSED")), - instant_power_w=branch_data.get("instantPowerW", 0.0), - imported_active_energy_wh=branch_data.get("importedActiveEnergyWh", 0.0), - exported_active_energy_wh=branch_data.get("exportedActiveEnergyWh", 0.0), - measure_start_ts_ms=branch_data.get("measureStartTsMs", 0), - measure_duration_ms=branch_data.get("measureDurationMs", 0), - is_measure_valid=branch_data.get("isMeasureValid", True), - ) - branches.append(branch) - - # Add virtual circuits for unmapped tabs using branches from same data - self._add_unmapped_virtuals(fresh_circuits, branches) - else: - _LOGGER.debug("No branches in panel data (simulation), skipping unmapped circuit creation") - - # Reuse existing object to avoid creation overhead - if self._circuits_object is None: - self._circuits_object = fresh_circuits - else: - self._update_circuits_in_place(self._circuits_object, fresh_circuits) - - # Recalculate panel grid power to match circuit totals (after object reuse) - await self._recalculate_panel_grid_power_from_circuits(self._circuits_object) - - return self._circuits_object - - async def _get_circuits_live(self) -> CircuitsOut: - """Get circuits data from live panel.""" - - async def _get_circuits_operation() -> CircuitsOut: - # Get circuits first (needed to determine mapped tabs) - client = self._get_client_for_endpoint(requires_auth=True) - result = await get_circuits_api_v1_circuits_get.asyncio(client=client) - if result is None: - raise SpanPanelAPIError("API result is None despite raise_on_unexpected_status=True") - - # Get panel state for branches data (depends on circuits to determine unmapped tabs) - panel_state = await self.get_panel_state() - _LOGGER.debug( - "Panel state branches: %s", - len(panel_state.branches) if hasattr(panel_state, "branches") else "No branches", - ) - - # Create virtual circuits for unmapped tabs - if hasattr(panel_state, "branches") and panel_state.branches: - self._add_unmapped_virtuals(result, panel_state.branches) - else: - _LOGGER.debug("No branches in panel state (live mode), skipping unmapped circuit creation") - - return result - - try: - # Fetch fresh data from API - fresh_circuits = await self._retry_with_backoff(_get_circuits_operation) - - # Reuse existing object to avoid creation overhead - if self._circuits_object is None: - self._circuits_object = fresh_circuits - else: - self._update_circuits_in_place(self._circuits_object, fresh_circuits) - - return self._circuits_object - except UnexpectedStatus as e: - self._handle_unexpected_status(e) - except httpx.HTTPStatusError as e: - unexpected_status = UnexpectedStatus(e.response.status_code, e.response.content) - self._handle_unexpected_status(unexpected_status) - except httpx.ConnectError as e: - raise SpanPanelConnectionError(f"Failed to connect to {self._host}") from e - except httpx.TimeoutException as e: - raise SpanPanelTimeoutError(f"Request timed out after {self._timeout}s") from e - except ValueError as e: - # Handle Pydantic validation errors and other ValueError instances - raise SpanPanelAPIError(f"API error: {e}") from e - except Exception as e: - # Catch and wrap all other exceptions - raise SpanPanelAPIError(f"Unexpected error: {e}") from e - - def _get_mapped_tabs_from_circuits(self, circuits: CircuitsOut) -> set[int]: - """Collect tab numbers that are already mapped to circuits. - - Args: - circuits: CircuitsOut container to inspect - - Returns: - Set of mapped tab numbers - """ - mapped_tabs: set[int] = set() - if hasattr(circuits, "circuits") and hasattr(circuits.circuits, "additional_properties"): - for circuit in circuits.circuits.additional_properties.values(): - if hasattr(circuit, "tabs") and circuit.tabs is not None and str(circuit.tabs) != "UNSET": - if isinstance(circuit.tabs, list | tuple): - mapped_tabs.update(circuit.tabs) - elif isinstance(circuit.tabs, int): - mapped_tabs.add(circuit.tabs) - return mapped_tabs - - def _add_unmapped_virtuals(self, circuits: CircuitsOut, branches: list[Branch]) -> None: - """Add virtual circuits for any tabs not present in the mapped set. - - Args: - circuits: CircuitsOut to mutate with virtual entries - branches: Panel branches used to synthesize metrics - """ - mapped_tabs = self._get_mapped_tabs_from_circuits(circuits) - total_tabs = len(branches) - all_tabs = set(range(1, total_tabs + 1)) - unmapped_tabs = all_tabs - mapped_tabs - - _LOGGER.debug( - "Creating unmapped circuits. Total tabs: %s, Mapped tabs: %s, Unmapped tabs: %s", - total_tabs, - mapped_tabs, - unmapped_tabs, - ) - - for tab_num in unmapped_tabs: - branch_idx = tab_num - 1 - if branch_idx < len(branches): - branch = branches[branch_idx] - virtual_circuit = self._create_unmapped_tab_circuit(branch, tab_num) - circuit_id = f"unmapped_tab_{tab_num}" - circuits.circuits.additional_properties[circuit_id] = virtual_circuit - _LOGGER.debug("Created unmapped circuit: %s", circuit_id) - - def _create_unmapped_tab_circuit(self, branch: Branch, tab_number: int) -> Circuit: - """Create a virtual circuit for an unmapped tab. - - Args: - branch: The Branch object from panel state - tab_number: The tab number (1-based) - - Returns: - Circuit: A virtual circuit representing the unmapped tab - """ - # Map branch data to circuit data - # For solar inverters: imported energy = solar production, exported energy = grid export - imported_energy = getattr(branch, "imported_active_energy_wh", 0.0) - exported_energy = getattr(branch, "exported_active_energy_wh", 0.0) - - # Convert values safely, handling 'unknown' strings when panel is offline - def _safe_power_conversion(value: Any) -> float: - """Safely convert power value to float, returning 0.0 for invalid values.""" - if value is None: - return 0.0 - if isinstance(value, int | float): - return float(value) - if isinstance(value, str): - if value.lower() in ("unknown", "unavailable", "offline"): - return 0.0 - try: - return float(value) - except (ValueError, TypeError): - return 0.0 - return 0.0 - - def _safe_energy_conversion(value: Any) -> float | None: - """Safely convert energy value, returning None for unavailable values.""" - if value is None: - return None - if isinstance(value, int | float): - return float(value) - if isinstance(value, str): - if value.lower() in ("unknown", "unavailable", "offline"): - return None # Energy should be None when unavailable - try: - return float(value) - except (ValueError, TypeError): - return None - return None - - # Safely convert all values - instant_power_w = _safe_power_conversion(getattr(branch, "instant_power_w", 0.0)) - # For solar tabs, imported energy represents production - # Preserve None values for unavailable energy data - produced_energy_wh = _safe_energy_conversion(imported_energy) - consumed_energy_wh = _safe_energy_conversion(exported_energy) - - # Get timestamps (use current time as fallback) - current_time = int(time.time()) - instant_power_update_time_s = current_time - energy_accum_update_time_s = current_time - - # Create the virtual circuit - circuit = Circuit( - id=f"unmapped_tab_{tab_number}", - name=f"Unmapped Tab {tab_number}", - relay_state=RelayState.UNKNOWN, - instant_power_w=instant_power_w, - instant_power_update_time_s=instant_power_update_time_s, - produced_energy_wh=produced_energy_wh, - consumed_energy_wh=consumed_energy_wh, - energy_accum_update_time_s=energy_accum_update_time_s, - priority=Priority.UNKNOWN, - is_user_controllable=False, - is_sheddable=False, - is_never_backup=False, - tabs=[tab_number], - ) - - return circuit - - async def get_storage_soe(self) -> BatteryStorage: - """Get storage state of energy (SOE) data. - - In simulation mode, storage behavior is defined by the YAML configuration file. - """ - # In simulation mode, use simulation engine - if self._simulation_mode: - return await self._get_storage_soe_simulation() - - # In live mode, ignore variation parameters - return await self._get_storage_soe_live() - - async def _get_storage_soe_simulation(self) -> BatteryStorage: - """Get storage SOE data in simulation mode.""" - if self._simulation_engine is None: - raise SpanPanelAPIError("Simulation engine not initialized") - - # Ensure simulation is properly initialized asynchronously - await self._ensure_simulation_initialized() - - # Get simulation data - storage_data = await self._simulation_engine.get_soe() - - # Convert to model object - fresh_battery = self._convert_raw_to_battery_storage(storage_data) - - # Reuse existing object to avoid creation overhead - if self._battery_object is None: - self._battery_object = fresh_battery - else: - self._update_battery_storage_in_place(self._battery_object, fresh_battery) - - return self._battery_object - - async def _get_storage_soe_live(self) -> BatteryStorage: - """Get storage SOE data from live panel.""" - - async def _get_storage_soe_operation() -> BatteryStorage: - client = self._get_client_for_endpoint(requires_auth=True) - # Storage SOE requires authentication - result = await get_storage_soe_api_v1_storage_soe_get.asyncio(client=client) - # Since raise_on_unexpected_status=True, result should never be None - if result is None: - raise SpanPanelAPIError("API result is None despite raise_on_unexpected_status=True") - return result - - try: - # Fetch fresh data from API - fresh_storage = await self._retry_with_backoff(_get_storage_soe_operation) - - # Reuse existing object to avoid creation overhead - if self._battery_object is None: - self._battery_object = fresh_storage - else: - self._update_battery_storage_in_place(self._battery_object, fresh_storage) - - return self._battery_object - except UnexpectedStatus as e: - self._handle_unexpected_status(e) - except httpx.HTTPStatusError as e: - unexpected_status = UnexpectedStatus(e.response.status_code, e.response.content) - self._handle_unexpected_status(unexpected_status) - except httpx.ConnectError as e: - raise SpanPanelConnectionError(f"Failed to connect to {self._host}") from e - except httpx.TimeoutException as e: - raise SpanPanelTimeoutError(f"Request timed out after {self._timeout}s") from e - except ValueError as e: - # Handle Pydantic validation errors and other ValueError instances - raise SpanPanelAPIError(f"API error: {e}") from e - except Exception as e: - # Catch and wrap all other exceptions - raise SpanPanelAPIError(f"Unexpected error: {e}") from e - - async def set_circuit_relay(self, circuit_id: str, state: str) -> Any: - """Control circuit relay state. - - Args: - circuit_id: Circuit identifier - state: Relay state ("OPEN" or "CLOSED") - - Returns: - Response from the API - - Raises: - SpanPanelAPIError: For validation or API errors - SpanPanelAuthError: If authentication is required - SpanPanelConnectionError: For connection failures - SpanPanelTimeoutError: If the request times out - SpanPanelServerError: For 5xx server errors - SpanPanelRetriableError: For transient server errors - """ - # In simulation mode, use simulation engine - if self._simulation_mode: - return await self._set_circuit_relay_simulation(circuit_id, state) - - # In live mode, use live implementation - return await self._set_circuit_relay_live(circuit_id, state) - - async def _set_circuit_relay_simulation(self, circuit_id: str, state: str) -> Any: - """Set circuit relay state in simulation mode.""" - if self._simulation_engine is None: - raise SpanPanelAPIError("Simulation engine not initialized") - - await self._ensure_simulation_initialized() - - # Validate state - if state.upper() not in ["OPEN", "CLOSED"]: - raise SpanPanelAPIError(f"Invalid relay state '{state}'. Must be one of: OPEN, CLOSED") - - # Apply the relay state override to the simulation engine - circuit_overrides = {circuit_id: {"relay_state": state.upper()}} - self._simulation_engine.set_dynamic_overrides(circuit_overrides=circuit_overrides) - - # Return a mock success response - return {"status": "success", "circuit_id": circuit_id, "relay_state": state.upper()} - - async def _set_circuit_relay_live(self, circuit_id: str, state: str) -> Any: - """Set circuit relay state in live mode.""" - - async def _set_circuit_relay_operation() -> Any: - client = self._get_client_for_endpoint(requires_auth=True) - - # Convert string to enum - explicitly handle invalid values - try: - relay_state = RelayState(state.upper()) - except ValueError as e: - # Wrap ValueError in a more descriptive error - raise SpanPanelAPIError(f"Invalid relay state '{state}'. Must be one of: OPEN, CLOSED") from e - - relay_in = RelayStateIn(relay_state=relay_state) - - # Create the body object with just the relay state - body = BodySetCircuitStateApiV1CircuitsCircuitIdPost(relay_state_in=relay_in) - - # Circuit state modification requires authentication - return await set_circuit_state_api_v_1_circuits_circuit_id_post.asyncio( - client=client, circuit_id=circuit_id, body=body - ) - - try: - return await self._retry_with_backoff(_set_circuit_relay_operation) - except UnexpectedStatus as e: - self._handle_unexpected_status(e) - except httpx.HTTPStatusError as e: - unexpected_status = UnexpectedStatus(e.response.status_code, e.response.content) - self._handle_unexpected_status(unexpected_status) - except httpx.ConnectError as e: - raise SpanPanelConnectionError(f"Failed to connect to {self._host}") from e - except httpx.TimeoutException as e: - raise SpanPanelTimeoutError(f"Request timed out after {self._timeout}s") from e - except ValueError as e: - # Specifically handle ValueError from enum conversion - raise SpanPanelAPIError(f"API error: {e}") from e - except Exception as e: - # Catch and wrap all other exceptions - raise SpanPanelAPIError(f"Unexpected error: {e}") from e - - async def set_circuit_priority(self, circuit_id: str, priority: str) -> Any: - """Set circuit priority. - - Args: - circuit_id: Circuit identifier - priority: Priority level (MUST_HAVE, NICE_TO_HAVE) - - Returns: - Response from the API - - Raises: - SpanPanelAPIError: For validation or API errors - SpanPanelAuthError: If authentication is required - SpanPanelConnectionError: For connection failures - SpanPanelTimeoutError: If the request times out - SpanPanelServerError: For 5xx server errors - SpanPanelRetriableError: For transient server errors - """ - # In simulation mode, use simulation engine - if self._simulation_mode: - return await self._set_circuit_priority_simulation(circuit_id, priority) - - # In live mode, use live implementation - return await self._set_circuit_priority_live(circuit_id, priority) - - async def _set_circuit_priority_simulation(self, circuit_id: str, priority: str) -> Any: - """Set circuit priority in simulation mode.""" - if self._simulation_engine is None: - raise SpanPanelAPIError("Simulation engine not initialized") - - await self._ensure_simulation_initialized() - - # Validate priority - if priority.upper() not in ["MUST_HAVE", "NICE_TO_HAVE"]: - raise SpanPanelAPIError(f"Invalid priority '{priority}'. Must be one of: MUST_HAVE, NICE_TO_HAVE") - - # Apply the priority override to the simulation engine - circuit_overrides = {circuit_id: {"priority": priority.upper()}} - self._simulation_engine.set_dynamic_overrides(circuit_overrides=circuit_overrides) - - # Return a mock success response - return {"status": "success", "circuit_id": circuit_id, "priority": priority.upper()} - - async def _set_circuit_priority_live(self, circuit_id: str, priority: str) -> Any: - """Set circuit priority in live mode.""" - - async def _set_circuit_priority_operation() -> Any: - client = self._get_client_for_endpoint(requires_auth=True) - - # Convert string to enum - explicitly handle invalid values - try: - priority_enum = Priority(priority.upper()) - except ValueError as e: - # Wrap ValueError in a more descriptive error matching test expectations - raise SpanPanelAPIError(f"API error: '{priority}' is not a valid Priority") from e - - priority_in = PriorityIn(priority=priority_enum) - - # Create the body object with just the priority - body = BodySetCircuitStateApiV1CircuitsCircuitIdPost(priority_in=priority_in) - - # Circuit state modification requires authentication - return await set_circuit_state_api_v_1_circuits_circuit_id_post.asyncio( - client=client, circuit_id=circuit_id, body=body - ) - - try: - return await self._retry_with_backoff(_set_circuit_priority_operation) - except UnexpectedStatus as e: - self._handle_unexpected_status(e) - except httpx.HTTPStatusError as e: - unexpected_status = UnexpectedStatus(e.response.status_code, e.response.content) - self._handle_unexpected_status(unexpected_status) - except httpx.ConnectError as e: - raise SpanPanelConnectionError(f"Failed to connect to {self._host}") from e - except httpx.TimeoutException as e: - raise SpanPanelTimeoutError(f"Request timed out after {self._timeout}s") from e - except ValueError as e: - # Specifically handle ValueError from enum conversion - raise SpanPanelAPIError(f"API error: {e}") from e - except Exception as e: - # Catch and wrap all other exceptions - raise SpanPanelAPIError(f"Unexpected error: {e}") from e - - async def set_circuit_overrides( - self, circuit_overrides: dict[str, dict[str, Any]] | None = None, global_overrides: dict[str, Any] | None = None - ) -> None: - """Set temporary circuit overrides in simulation mode. - - This allows temporary variations outside the normal ranges defined in the YAML configuration. - Only works in simulation mode. - - Args: - circuit_overrides: Dict mapping circuit_id to override parameters: - - power_override: Set specific power value (Watts) - - relay_state: Force relay state ("OPEN" or "CLOSED") - - priority: Override priority ("MUST_HAVE" or "NON_ESSENTIAL") - - power_multiplier: Multiply normal power by this factor - global_overrides: Apply to all circuits: - - power_multiplier: Global power multiplier - - noise_factor: Override noise factor - - time_acceleration: Override time acceleration - - Example: - # Force specific circuit to high power - await client.set_circuit_overrides({ - "circuit_001": { - "power_override": 2000.0, - "relay_state": "CLOSED" - } - }) - - # Apply global 2x power multiplier - await client.set_circuit_overrides( - global_overrides={"power_multiplier": 2.0} - ) - """ - if not self._simulation_mode: - raise SpanPanelAPIError("Circuit overrides only available in simulation mode") - - if self._simulation_engine is None: - raise SpanPanelAPIError("Simulation engine not initialized") - - await self._ensure_simulation_initialized() - - # Apply overrides to simulation engine - self._simulation_engine.set_dynamic_overrides(circuit_overrides=circuit_overrides, global_overrides=global_overrides) - - async def clear_circuit_overrides(self) -> None: - """Clear all temporary circuit overrides in simulation mode. - - Returns circuit behavior to the YAML configuration defaults. - Only works in simulation mode. - """ - if not self._simulation_mode: - raise SpanPanelAPIError("Circuit overrides only available in simulation mode") - - if self._simulation_engine is None: - raise SpanPanelAPIError("Simulation engine not initialized") - - await self._ensure_simulation_initialized() - - # Clear overrides from simulation engine - self._simulation_engine.clear_dynamic_overrides() - - async def get_all_data(self, include_battery: bool = False) -> dict[str, Any]: - """Get all panel data in parallel for maximum performance. - - This method makes concurrent API calls to fetch all data at once, - reducing total time from ~1.5s (sequential) to ~1.0s (parallel). - - Args: - include_battery: Whether to include battery/storage data - - Returns: - Dictionary containing all panel data: - { - 'status': StatusOut, - 'panel_state': PanelState, - 'circuits': CircuitsOut, - 'storage': BatteryStorage (if include_battery=True) - } - """ - # Create tasks for all data types - tasks = [ - self.get_status(), - self.get_panel_state(), - self.get_circuits(), - ] - - if include_battery: - tasks.append(self.get_storage_soe()) - - # Execute all calls in parallel - results = await asyncio.gather(*tasks) - - # Return all data - result = { - "status": results[0], - "panel_state": results[1], - "circuits": results[2], - } - - if include_battery: - result["storage"] = results[3] - - return result - - async def close(self) -> None: - """Close the client and cleanup resources.""" - if self._client: - # The generated client has async context manager support - with suppress(Exception): - await self._client.__aexit__(None, None, None) - self._client = None - self._in_context = False diff --git a/src/span_panel_api/const.py b/src/span_panel_api/const.py index f4a1223..3cb3391 100644 --- a/src/span_panel_api/const.py +++ b/src/span_panel_api/const.py @@ -1,28 +1,5 @@ """Constants for the SPAN Panel API client.""" -# HTTP Status Code Constants -HTTP_UNAUTHORIZED = 401 -HTTP_FORBIDDEN = 403 -HTTP_TOO_MANY_REQUESTS = 429 -HTTP_INTERNAL_SERVER_ERROR = 500 -HTTP_BAD_GATEWAY = 502 -HTTP_SERVICE_UNAVAILABLE = 503 -HTTP_GATEWAY_TIMEOUT = 504 - -# Categorized status codes for error handling -AUTH_ERROR_CODES = (HTTP_UNAUTHORIZED, HTTP_FORBIDDEN) -RETRIABLE_ERROR_CODES = ( - HTTP_BAD_GATEWAY, - HTTP_SERVICE_UNAVAILABLE, - HTTP_GATEWAY_TIMEOUT, -) # 429 removed to match test expectations -SERVER_ERROR_CODES = (HTTP_INTERNAL_SERVER_ERROR,) - -# Retry Configuration Constants -RETRY_MAX_ATTEMPTS = 3 -RETRY_INITIAL_DELAY = 0.5 # seconds -RETRY_BACKOFF_MULTIPLIER = 2 - # SPAN Panel State Constants # DSM (Demand Side Management) States DSM_GRID_UP = "DSM_GRID_UP" diff --git a/src/span_panel_api/detection.py b/src/span_panel_api/detection.py new file mode 100644 index 0000000..df96f06 --- /dev/null +++ b/src/span_panel_api/detection.py @@ -0,0 +1,55 @@ +"""SPAN Panel API version detection. + +Probes the panel to determine whether it supports v2 (eBus/Homie) +or only v1 (REST). The detection call is unauthenticated — no token +or passphrase is required. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import httpx + +from .models import V2StatusInfo + + +@dataclass(frozen=True, slots=True) +class DetectionResult: + """Result of probing a SPAN Panel for API version support.""" + + api_version: str # "v1" | "v2" + status_info: V2StatusInfo | None = None # populated when v2 detected + + +async def detect_api_version(host: str, timeout: float = 5.0) -> DetectionResult: + """Detect SPAN Panel API version. + + Probes GET /api/v2/status (unauthenticated). + Returns DetectionResult with api_version="v2" and status_info + populated on success; api_version="v1" and status_info=None otherwise. + + Args: + host: IP address or hostname of the SPAN Panel + timeout: Request timeout in seconds + + Returns: + DetectionResult indicating which API version is available + """ + url = f"http://{host}/api/v2/status" + try: + async with httpx.AsyncClient(timeout=timeout, verify=False) as client: # nosec B501 + response = await client.get(url) + except (httpx.ConnectError, httpx.TimeoutException, httpx.RemoteProtocolError): + return DetectionResult(api_version="v1") + + if response.status_code != 200: + return DetectionResult(api_version="v1") + + data: dict[str, object] = response.json() + serial = str(data.get("serialNumber", "")) + firmware = str(data.get("firmwareVersion", "")) + return DetectionResult( + api_version="v2", + status_info=V2StatusInfo(serial_number=serial, firmware_version=firmware), + ) diff --git a/src/span_panel_api/exceptions.py b/src/span_panel_api/exceptions.py index fd17d1d..bfead84 100644 --- a/src/span_panel_api/exceptions.py +++ b/src/span_panel_api/exceptions.py @@ -32,10 +32,6 @@ def __str__(self) -> str: return self.args[0] if self.args else "" -class SpanPanelRetriableError(SpanPanelAPIError): - """Retriable server error (502, 503, 504).""" - - class SpanPanelServerError(SpanPanelAPIError): """Server error (500).""" diff --git a/src/span_panel_api/factory.py b/src/span_panel_api/factory.py new file mode 100644 index 0000000..78135f9 --- /dev/null +++ b/src/span_panel_api/factory.py @@ -0,0 +1,69 @@ +"""Factory for creating SPAN Panel API clients. + +Auto-detects panel API version and returns an MQTT/Homie transport client. +Handles v2 registration when only a passphrase is provided. +""" + +from __future__ import annotations + +import logging + +from .auth import register_v2 +from .detection import detect_api_version +from .exceptions import SpanPanelAuthError +from .mqtt.client import SpanMqttClient +from .mqtt.models import MqttClientConfig + +_LOGGER = logging.getLogger(__name__) + +_V2_CLIENT_NAME = "span-panel-api" + + +async def create_span_client( + host: str, + passphrase: str | None = None, + mqtt_config: MqttClientConfig | None = None, + serial_number: str | None = None, +) -> SpanMqttClient: + """Create a SPAN Panel MQTT client. + + Args: + host: IP address or hostname of the SPAN Panel. + passphrase: Panel passphrase for v2 registration. + mqtt_config: Pre-built MQTT broker configuration. + serial_number: Panel serial number (extracted from detection/registration if omitted). + + Returns: + A connected-ready SpanMqttClient instance. + + Raises: + SpanPanelAuthError: Neither mqtt_config nor passphrase provided, + or serial_number could not be determined. + SpanPanelConnectionError: Cannot reach panel during detection or registration. + SpanPanelTimeoutError: Timeout during detection or registration. + """ + if mqtt_config is None: + if passphrase is None: + raise SpanPanelAuthError("Neither mqtt_config nor passphrase provided") + auth_response = await register_v2(host, _V2_CLIENT_NAME, passphrase) + mqtt_config = MqttClientConfig( + broker_host=auth_response.ebus_broker_host, + username=auth_response.ebus_broker_username, + password=auth_response.ebus_broker_password, + mqtts_port=auth_response.ebus_broker_mqtts_port, + ws_port=auth_response.ebus_broker_ws_port, + wss_port=auth_response.ebus_broker_wss_port, + ) + if serial_number is None: + serial_number = auth_response.serial_number + + if serial_number is None: + # Try to detect from panel status + result = await detect_api_version(host) + if result.status_info is not None: + serial_number = result.status_info.serial_number + + if serial_number is None: + raise SpanPanelAuthError("serial_number is required for MQTT transport but could not be determined") + + return SpanMqttClient(host, serial_number, mqtt_config) diff --git a/src/span_panel_api/generated_client/__init__.py b/src/span_panel_api/generated_client/__init__.py deleted file mode 100644 index 5a552e7..0000000 --- a/src/span_panel_api/generated_client/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""A client library for accessing Span""" - -from .client import AuthenticatedClient, Client - -__all__ = ( - "AuthenticatedClient", - "Client", -) diff --git a/src/span_panel_api/generated_client/api/__init__.py b/src/span_panel_api/generated_client/api/__init__.py deleted file mode 100644 index 81f9fa2..0000000 --- a/src/span_panel_api/generated_client/api/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Contains methods for accessing the API""" diff --git a/src/span_panel_api/generated_client/api/default/__init__.py b/src/span_panel_api/generated_client/api/default/__init__.py deleted file mode 100644 index 2d7c0b2..0000000 --- a/src/span_panel_api/generated_client/api/default/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Contains endpoint functions for accessing the API""" diff --git a/src/span_panel_api/generated_client/api/default/delete_client_api_v1_auth_clients_name_delete.py b/src/span_panel_api/generated_client/api/default/delete_client_api_v1_auth_clients_name_delete.py deleted file mode 100644 index 8143f53..0000000 --- a/src/span_panel_api/generated_client/api/default/delete_client_api_v1_auth_clients_name_delete.py +++ /dev/null @@ -1,155 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.client import Client -from ...models.http_validation_error import HTTPValidationError -from ...types import Response - - -def _get_kwargs( - name: str, -) -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "delete", - "url": f"/api/v1/auth/clients/{name}", - } - - return _kwargs - - -def _parse_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Client | HTTPValidationError | None: - if response.status_code == 200: - response_200 = Client.from_dict(response.json()) - - return response_200 - if response.status_code == 422: - response_422 = HTTPValidationError.from_dict(response.json()) - - return response_422 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Client | HTTPValidationError]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - name: str, - *, - client: AuthenticatedClient, -) -> Response[Client | HTTPValidationError]: - """Delete Client - - Args: - name (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[Client, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - name=name, - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - name: str, - *, - client: AuthenticatedClient, -) -> Client | HTTPValidationError | None: - """Delete Client - - Args: - name (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[Client, HTTPValidationError] - """ - - return sync_detailed( - name=name, - client=client, - ).parsed - - -async def asyncio_detailed( - name: str, - *, - client: AuthenticatedClient, -) -> Response[Client | HTTPValidationError]: - """Delete Client - - Args: - name (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[Client, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - name=name, - ) - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - name: str, - *, - client: AuthenticatedClient, -) -> Client | HTTPValidationError | None: - """Delete Client - - Args: - name (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[Client, HTTPValidationError] - """ - - return ( - await asyncio_detailed( - name=name, - client=client, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/deprecated_spaces_endpoint_stub_api_v1_spaces_get.py b/src/span_panel_api/generated_client/api/default/deprecated_spaces_endpoint_stub_api_v1_spaces_get.py deleted file mode 100644 index 1e9f32f..0000000 --- a/src/span_panel_api/generated_client/api/default/deprecated_spaces_endpoint_stub_api_v1_spaces_get.py +++ /dev/null @@ -1,159 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.http_validation_error import HTTPValidationError -from ...types import UNSET, Response, Unset - - -def _get_kwargs( - *, - spaces_id: Unset | str = "", -) -> dict[str, Any]: - params: dict[str, Any] = {} - - params["spaces_id"] = spaces_id - - params = {k: v for k, v in params.items() if v is not UNSET and v is not None} - - _kwargs: dict[str, Any] = { - "method": "get", - "url": "/api/v1/spaces", - "params": params, - } - - return _kwargs - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | HTTPValidationError | None: - if response.status_code == 200: - response_200 = response.json() - return response_200 - if response.status_code == 422: - response_422 = HTTPValidationError.from_dict(response.json()) - - return response_422 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Any | HTTPValidationError]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, - spaces_id: Unset | str = "", -) -> Response[Any | HTTPValidationError]: - """Deprecated Spaces Endpoint Stub - - Args: - spaces_id (Union[Unset, str]): Default: ''. - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[Any, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - spaces_id=spaces_id, - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, - spaces_id: Unset | str = "", -) -> Any | HTTPValidationError | None: - """Deprecated Spaces Endpoint Stub - - Args: - spaces_id (Union[Unset, str]): Default: ''. - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[Any, HTTPValidationError] - """ - - return sync_detailed( - client=client, - spaces_id=spaces_id, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, - spaces_id: Unset | str = "", -) -> Response[Any | HTTPValidationError]: - """Deprecated Spaces Endpoint Stub - - Args: - spaces_id (Union[Unset, str]): Default: ''. - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[Any, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - spaces_id=spaces_id, - ) - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, - spaces_id: Unset | str = "", -) -> Any | HTTPValidationError | None: - """Deprecated Spaces Endpoint Stub - - Args: - spaces_id (Union[Unset, str]): Default: ''. - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[Any, HTTPValidationError] - """ - - return ( - await asyncio_detailed( - client=client, - spaces_id=spaces_id, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/deprecated_spaces_endpoint_stub_api_v1_spaces_spaces_id_get.py b/src/span_panel_api/generated_client/api/default/deprecated_spaces_endpoint_stub_api_v1_spaces_spaces_id_get.py deleted file mode 100644 index 864d3ca..0000000 --- a/src/span_panel_api/generated_client/api/default/deprecated_spaces_endpoint_stub_api_v1_spaces_spaces_id_get.py +++ /dev/null @@ -1,151 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.http_validation_error import HTTPValidationError -from ...types import Response - - -def _get_kwargs( - spaces_id: str, -) -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "get", - "url": f"/api/v1/spaces/{spaces_id}", - } - - return _kwargs - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | HTTPValidationError | None: - if response.status_code == 200: - response_200 = response.json() - return response_200 - if response.status_code == 422: - response_422 = HTTPValidationError.from_dict(response.json()) - - return response_422 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Any | HTTPValidationError]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - spaces_id: str, - *, - client: AuthenticatedClient, -) -> Response[Any | HTTPValidationError]: - """Deprecated Spaces Endpoint Stub - - Args: - spaces_id (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[Any, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - spaces_id=spaces_id, - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - spaces_id: str, - *, - client: AuthenticatedClient, -) -> Any | HTTPValidationError | None: - """Deprecated Spaces Endpoint Stub - - Args: - spaces_id (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[Any, HTTPValidationError] - """ - - return sync_detailed( - spaces_id=spaces_id, - client=client, - ).parsed - - -async def asyncio_detailed( - spaces_id: str, - *, - client: AuthenticatedClient, -) -> Response[Any | HTTPValidationError]: - """Deprecated Spaces Endpoint Stub - - Args: - spaces_id (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[Any, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - spaces_id=spaces_id, - ) - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - spaces_id: str, - *, - client: AuthenticatedClient, -) -> Any | HTTPValidationError | None: - """Deprecated Spaces Endpoint Stub - - Args: - spaces_id (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[Any, HTTPValidationError] - """ - - return ( - await asyncio_detailed( - spaces_id=spaces_id, - client=client, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/deprecated_spaces_endpoint_stub_api_v1_spaces_spaces_id_post.py b/src/span_panel_api/generated_client/api/default/deprecated_spaces_endpoint_stub_api_v1_spaces_spaces_id_post.py deleted file mode 100644 index 4f25576..0000000 --- a/src/span_panel_api/generated_client/api/default/deprecated_spaces_endpoint_stub_api_v1_spaces_spaces_id_post.py +++ /dev/null @@ -1,151 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.http_validation_error import HTTPValidationError -from ...types import Response - - -def _get_kwargs( - spaces_id: str, -) -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "post", - "url": f"/api/v1/spaces/{spaces_id}", - } - - return _kwargs - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | HTTPValidationError | None: - if response.status_code == 200: - response_200 = response.json() - return response_200 - if response.status_code == 422: - response_422 = HTTPValidationError.from_dict(response.json()) - - return response_422 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Any | HTTPValidationError]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - spaces_id: str, - *, - client: AuthenticatedClient, -) -> Response[Any | HTTPValidationError]: - """Deprecated Spaces Endpoint Stub - - Args: - spaces_id (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[Any, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - spaces_id=spaces_id, - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - spaces_id: str, - *, - client: AuthenticatedClient, -) -> Any | HTTPValidationError | None: - """Deprecated Spaces Endpoint Stub - - Args: - spaces_id (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[Any, HTTPValidationError] - """ - - return sync_detailed( - spaces_id=spaces_id, - client=client, - ).parsed - - -async def asyncio_detailed( - spaces_id: str, - *, - client: AuthenticatedClient, -) -> Response[Any | HTTPValidationError]: - """Deprecated Spaces Endpoint Stub - - Args: - spaces_id (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[Any, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - spaces_id=spaces_id, - ) - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - spaces_id: str, - *, - client: AuthenticatedClient, -) -> Any | HTTPValidationError | None: - """Deprecated Spaces Endpoint Stub - - Args: - spaces_id (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[Any, HTTPValidationError] - """ - - return ( - await asyncio_detailed( - spaces_id=spaces_id, - client=client, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/generate_jwt_api_v1_auth_register_post.py b/src/span_panel_api/generated_client/api/default/generate_jwt_api_v1_auth_register_post.py deleted file mode 100644 index 6b6dcd9..0000000 --- a/src/span_panel_api/generated_client/api/default/generate_jwt_api_v1_auth_register_post.py +++ /dev/null @@ -1,164 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.auth_in import AuthIn -from ...models.auth_out import AuthOut -from ...models.http_validation_error import HTTPValidationError -from ...types import Response - - -def _get_kwargs( - *, - body: AuthIn, -) -> dict[str, Any]: - headers: dict[str, Any] = {} - - _kwargs: dict[str, Any] = { - "method": "post", - "url": "/api/v1/auth/register", - } - - _kwargs["json"] = body.to_dict() - - headers["Content-Type"] = "application/json" - - _kwargs["headers"] = headers - return _kwargs - - -def _parse_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> AuthOut | HTTPValidationError | None: - if response.status_code == 200: - response_200 = AuthOut.from_dict(response.json()) - - return response_200 - if response.status_code == 422: - response_422 = HTTPValidationError.from_dict(response.json()) - - return response_422 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[AuthOut | HTTPValidationError]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, - body: AuthIn, -) -> Response[AuthOut | HTTPValidationError]: - """Generate Jwt - - Args: - body (AuthIn): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[AuthOut, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - body=body, - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, - body: AuthIn, -) -> AuthOut | HTTPValidationError | None: - """Generate Jwt - - Args: - body (AuthIn): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[AuthOut, HTTPValidationError] - """ - - return sync_detailed( - client=client, - body=body, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, - body: AuthIn, -) -> Response[AuthOut | HTTPValidationError]: - """Generate Jwt - - Args: - body (AuthIn): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[AuthOut, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - body=body, - ) - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, - body: AuthIn, -) -> AuthOut | HTTPValidationError | None: - """Generate Jwt - - Args: - body (AuthIn): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[AuthOut, HTTPValidationError] - """ - - return ( - await asyncio_detailed( - client=client, - body=body, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/get_all_clients_api_v1_auth_clients_get.py b/src/span_panel_api/generated_client/api/default/get_all_clients_api_v1_auth_clients_get.py deleted file mode 100644 index aec59cd..0000000 --- a/src/span_panel_api/generated_client/api/default/get_all_clients_api_v1_auth_clients_get.py +++ /dev/null @@ -1,122 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.clients import Clients -from ...types import Response - - -def _get_kwargs() -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "get", - "url": "/api/v1/auth/clients", - } - - return _kwargs - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Clients | None: - if response.status_code == 200: - response_200 = Clients.from_dict(response.json()) - - return response_200 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Clients]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, -) -> Response[Clients]: - """Get All Clients - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Clients] - """ - - kwargs = _get_kwargs() - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, -) -> Clients | None: - """Get All Clients - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Clients - """ - - return sync_detailed( - client=client, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, -) -> Response[Clients]: - """Get All Clients - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Clients] - """ - - kwargs = _get_kwargs() - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, -) -> Clients | None: - """Get All Clients - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Clients - """ - - return ( - await asyncio_detailed( - client=client, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/get_circuit_state_api_v_1_circuits_circuit_id_get.py b/src/span_panel_api/generated_client/api/default/get_circuit_state_api_v_1_circuits_circuit_id_get.py deleted file mode 100644 index 26bc675..0000000 --- a/src/span_panel_api/generated_client/api/default/get_circuit_state_api_v_1_circuits_circuit_id_get.py +++ /dev/null @@ -1,155 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.circuit import Circuit -from ...models.http_validation_error import HTTPValidationError -from ...types import Response - - -def _get_kwargs( - circuit_id: str, -) -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "get", - "url": f"/api/v1/circuits/{circuit_id}", - } - - return _kwargs - - -def _parse_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Circuit | HTTPValidationError | None: - if response.status_code == 200: - response_200 = Circuit.from_dict(response.json()) - - return response_200 - if response.status_code == 422: - response_422 = HTTPValidationError.from_dict(response.json()) - - return response_422 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Circuit | HTTPValidationError]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - circuit_id: str, - *, - client: AuthenticatedClient, -) -> Response[Circuit | HTTPValidationError]: - """Get Circuit State - - Args: - circuit_id (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[Circuit, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - circuit_id=circuit_id, - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - circuit_id: str, - *, - client: AuthenticatedClient, -) -> Circuit | HTTPValidationError | None: - """Get Circuit State - - Args: - circuit_id (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[Circuit, HTTPValidationError] - """ - - return sync_detailed( - circuit_id=circuit_id, - client=client, - ).parsed - - -async def asyncio_detailed( - circuit_id: str, - *, - client: AuthenticatedClient, -) -> Response[Circuit | HTTPValidationError]: - """Get Circuit State - - Args: - circuit_id (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[Circuit, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - circuit_id=circuit_id, - ) - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - circuit_id: str, - *, - client: AuthenticatedClient, -) -> Circuit | HTTPValidationError | None: - """Get Circuit State - - Args: - circuit_id (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[Circuit, HTTPValidationError] - """ - - return ( - await asyncio_detailed( - circuit_id=circuit_id, - client=client, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/get_circuits_api_v1_circuits_get.py b/src/span_panel_api/generated_client/api/default/get_circuits_api_v1_circuits_get.py deleted file mode 100644 index 7ab8af5..0000000 --- a/src/span_panel_api/generated_client/api/default/get_circuits_api_v1_circuits_get.py +++ /dev/null @@ -1,122 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.circuits_out import CircuitsOut -from ...types import Response - - -def _get_kwargs() -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "get", - "url": "/api/v1/circuits", - } - - return _kwargs - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> CircuitsOut | None: - if response.status_code == 200: - response_200 = CircuitsOut.from_dict(response.json()) - - return response_200 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[CircuitsOut]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, -) -> Response[CircuitsOut]: - """Get Circuits - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[CircuitsOut] - """ - - kwargs = _get_kwargs() - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, -) -> CircuitsOut | None: - """Get Circuits - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - CircuitsOut - """ - - return sync_detailed( - client=client, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, -) -> Response[CircuitsOut]: - """Get Circuits - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[CircuitsOut] - """ - - kwargs = _get_kwargs() - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, -) -> CircuitsOut | None: - """Get Circuits - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - CircuitsOut - """ - - return ( - await asyncio_detailed( - client=client, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/get_client_api_v1_auth_clients_name_get.py b/src/span_panel_api/generated_client/api/default/get_client_api_v1_auth_clients_name_get.py deleted file mode 100644 index d43d848..0000000 --- a/src/span_panel_api/generated_client/api/default/get_client_api_v1_auth_clients_name_get.py +++ /dev/null @@ -1,155 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.client import Client -from ...models.http_validation_error import HTTPValidationError -from ...types import Response - - -def _get_kwargs( - name: str, -) -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "get", - "url": f"/api/v1/auth/clients/{name}", - } - - return _kwargs - - -def _parse_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Client | HTTPValidationError | None: - if response.status_code == 200: - response_200 = Client.from_dict(response.json()) - - return response_200 - if response.status_code == 422: - response_422 = HTTPValidationError.from_dict(response.json()) - - return response_422 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Client | HTTPValidationError]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - name: str, - *, - client: AuthenticatedClient, -) -> Response[Client | HTTPValidationError]: - """Get Client - - Args: - name (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[Client, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - name=name, - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - name: str, - *, - client: AuthenticatedClient, -) -> Client | HTTPValidationError | None: - """Get Client - - Args: - name (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[Client, HTTPValidationError] - """ - - return sync_detailed( - name=name, - client=client, - ).parsed - - -async def asyncio_detailed( - name: str, - *, - client: AuthenticatedClient, -) -> Response[Client | HTTPValidationError]: - """Get Client - - Args: - name (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[Client, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - name=name, - ) - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - name: str, - *, - client: AuthenticatedClient, -) -> Client | HTTPValidationError | None: - """Get Client - - Args: - name (str): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[Client, HTTPValidationError] - """ - - return ( - await asyncio_detailed( - name=name, - client=client, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/get_islanding_state_api_v1_islanding_state_get.py b/src/span_panel_api/generated_client/api/default/get_islanding_state_api_v1_islanding_state_get.py deleted file mode 100644 index fa1322e..0000000 --- a/src/span_panel_api/generated_client/api/default/get_islanding_state_api_v1_islanding_state_get.py +++ /dev/null @@ -1,122 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.islanding_state import IslandingState -from ...types import Response - - -def _get_kwargs() -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "get", - "url": "/api/v1/islanding-state", - } - - return _kwargs - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> IslandingState | None: - if response.status_code == 200: - response_200 = IslandingState.from_dict(response.json()) - - return response_200 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[IslandingState]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, -) -> Response[IslandingState]: - """Get Islanding State - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[IslandingState] - """ - - kwargs = _get_kwargs() - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, -) -> IslandingState | None: - """Get Islanding State - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - IslandingState - """ - - return sync_detailed( - client=client, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, -) -> Response[IslandingState]: - """Get Islanding State - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[IslandingState] - """ - - kwargs = _get_kwargs() - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, -) -> IslandingState | None: - """Get Islanding State - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - IslandingState - """ - - return ( - await asyncio_detailed( - client=client, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/get_main_relay_state_api_v1_panel_grid_get.py b/src/span_panel_api/generated_client/api/default/get_main_relay_state_api_v1_panel_grid_get.py deleted file mode 100644 index efefbc3..0000000 --- a/src/span_panel_api/generated_client/api/default/get_main_relay_state_api_v1_panel_grid_get.py +++ /dev/null @@ -1,122 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.relay_state_out import RelayStateOut -from ...types import Response - - -def _get_kwargs() -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "get", - "url": "/api/v1/panel/grid", - } - - return _kwargs - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> RelayStateOut | None: - if response.status_code == 200: - response_200 = RelayStateOut.from_dict(response.json()) - - return response_200 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[RelayStateOut]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, -) -> Response[RelayStateOut]: - """Get Main Relay State - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[RelayStateOut] - """ - - kwargs = _get_kwargs() - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, -) -> RelayStateOut | None: - """Get Main Relay State - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - RelayStateOut - """ - - return sync_detailed( - client=client, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, -) -> Response[RelayStateOut]: - """Get Main Relay State - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[RelayStateOut] - """ - - kwargs = _get_kwargs() - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, -) -> RelayStateOut | None: - """Get Main Relay State - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - RelayStateOut - """ - - return ( - await asyncio_detailed( - client=client, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/get_panel_meter_api_v1_panel_meter_get.py b/src/span_panel_api/generated_client/api/default/get_panel_meter_api_v1_panel_meter_get.py deleted file mode 100644 index cd8d892..0000000 --- a/src/span_panel_api/generated_client/api/default/get_panel_meter_api_v1_panel_meter_get.py +++ /dev/null @@ -1,122 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.panel_meter import PanelMeter -from ...types import Response - - -def _get_kwargs() -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "get", - "url": "/api/v1/panel/meter", - } - - return _kwargs - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> PanelMeter | None: - if response.status_code == 200: - response_200 = PanelMeter.from_dict(response.json()) - - return response_200 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[PanelMeter]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, -) -> Response[PanelMeter]: - """Get Panel Meter - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[PanelMeter] - """ - - kwargs = _get_kwargs() - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, -) -> PanelMeter | None: - """Get Panel Meter - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - PanelMeter - """ - - return sync_detailed( - client=client, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, -) -> Response[PanelMeter]: - """Get Panel Meter - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[PanelMeter] - """ - - kwargs = _get_kwargs() - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, -) -> PanelMeter | None: - """Get Panel Meter - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - PanelMeter - """ - - return ( - await asyncio_detailed( - client=client, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/get_panel_power_api_v1_panel_power_get.py b/src/span_panel_api/generated_client/api/default/get_panel_power_api_v1_panel_power_get.py deleted file mode 100644 index 47d73ab..0000000 --- a/src/span_panel_api/generated_client/api/default/get_panel_power_api_v1_panel_power_get.py +++ /dev/null @@ -1,122 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.panel_power import PanelPower -from ...types import Response - - -def _get_kwargs() -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "get", - "url": "/api/v1/panel/power", - } - - return _kwargs - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> PanelPower | None: - if response.status_code == 200: - response_200 = PanelPower.from_dict(response.json()) - - return response_200 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[PanelPower]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, -) -> Response[PanelPower]: - """Get Panel Power - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[PanelPower] - """ - - kwargs = _get_kwargs() - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, -) -> PanelPower | None: - """Get Panel Power - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - PanelPower - """ - - return sync_detailed( - client=client, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, -) -> Response[PanelPower]: - """Get Panel Power - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[PanelPower] - """ - - kwargs = _get_kwargs() - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, -) -> PanelPower | None: - """Get Panel Power - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - PanelPower - """ - - return ( - await asyncio_detailed( - client=client, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/get_panel_state_api_v1_panel_get.py b/src/span_panel_api/generated_client/api/default/get_panel_state_api_v1_panel_get.py deleted file mode 100644 index 2b154f0..0000000 --- a/src/span_panel_api/generated_client/api/default/get_panel_state_api_v1_panel_get.py +++ /dev/null @@ -1,122 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.panel_state import PanelState -from ...types import Response - - -def _get_kwargs() -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "get", - "url": "/api/v1/panel", - } - - return _kwargs - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> PanelState | None: - if response.status_code == 200: - response_200 = PanelState.from_dict(response.json()) - - return response_200 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[PanelState]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, -) -> Response[PanelState]: - """Get Panel State - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[PanelState] - """ - - kwargs = _get_kwargs() - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, -) -> PanelState | None: - """Get Panel State - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - PanelState - """ - - return sync_detailed( - client=client, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, -) -> Response[PanelState]: - """Get Panel State - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[PanelState] - """ - - kwargs = _get_kwargs() - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, -) -> PanelState | None: - """Get Panel State - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - PanelState - """ - - return ( - await asyncio_detailed( - client=client, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/get_storage_nice_to_have_threshold_api_v1_storage_nice_to_have_thresh_get.py b/src/span_panel_api/generated_client/api/default/get_storage_nice_to_have_threshold_api_v1_storage_nice_to_have_thresh_get.py deleted file mode 100644 index 94d3640..0000000 --- a/src/span_panel_api/generated_client/api/default/get_storage_nice_to_have_threshold_api_v1_storage_nice_to_have_thresh_get.py +++ /dev/null @@ -1,122 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.nice_to_have_threshold import NiceToHaveThreshold -from ...types import Response - - -def _get_kwargs() -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "get", - "url": "/api/v1/storage/nice-to-have-thresh", - } - - return _kwargs - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> NiceToHaveThreshold | None: - if response.status_code == 200: - response_200 = NiceToHaveThreshold.from_dict(response.json()) - - return response_200 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[NiceToHaveThreshold]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, -) -> Response[NiceToHaveThreshold]: - """Get Storage Nice To Have Threshold - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[NiceToHaveThreshold] - """ - - kwargs = _get_kwargs() - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, -) -> NiceToHaveThreshold | None: - """Get Storage Nice To Have Threshold - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - NiceToHaveThreshold - """ - - return sync_detailed( - client=client, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, -) -> Response[NiceToHaveThreshold]: - """Get Storage Nice To Have Threshold - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[NiceToHaveThreshold] - """ - - kwargs = _get_kwargs() - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, -) -> NiceToHaveThreshold | None: - """Get Storage Nice To Have Threshold - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - NiceToHaveThreshold - """ - - return ( - await asyncio_detailed( - client=client, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/get_storage_soe_api_v1_storage_soe_get.py b/src/span_panel_api/generated_client/api/default/get_storage_soe_api_v1_storage_soe_get.py deleted file mode 100644 index 8263b3d..0000000 --- a/src/span_panel_api/generated_client/api/default/get_storage_soe_api_v1_storage_soe_get.py +++ /dev/null @@ -1,122 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.battery_storage import BatteryStorage -from ...types import Response - - -def _get_kwargs() -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "get", - "url": "/api/v1/storage/soe", - } - - return _kwargs - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> BatteryStorage | None: - if response.status_code == 200: - response_200 = BatteryStorage.from_dict(response.json()) - - return response_200 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[BatteryStorage]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, -) -> Response[BatteryStorage]: - """Get Storage Soe - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[BatteryStorage] - """ - - kwargs = _get_kwargs() - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, -) -> BatteryStorage | None: - """Get Storage Soe - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - BatteryStorage - """ - - return sync_detailed( - client=client, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, -) -> Response[BatteryStorage]: - """Get Storage Soe - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[BatteryStorage] - """ - - kwargs = _get_kwargs() - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, -) -> BatteryStorage | None: - """Get Storage Soe - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - BatteryStorage - """ - - return ( - await asyncio_detailed( - client=client, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/get_wifi_scan_api_v1_wifi_scan_get.py b/src/span_panel_api/generated_client/api/default/get_wifi_scan_api_v1_wifi_scan_get.py deleted file mode 100644 index b69dcbe..0000000 --- a/src/span_panel_api/generated_client/api/default/get_wifi_scan_api_v1_wifi_scan_get.py +++ /dev/null @@ -1,122 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.wifi_scan_out import WifiScanOut -from ...types import Response - - -def _get_kwargs() -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "get", - "url": "/api/v1/wifi/scan", - } - - return _kwargs - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> WifiScanOut | None: - if response.status_code == 200: - response_200 = WifiScanOut.from_dict(response.json()) - - return response_200 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[WifiScanOut]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, -) -> Response[WifiScanOut]: - """Get Wifi Scan - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[WifiScanOut] - """ - - kwargs = _get_kwargs() - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, -) -> WifiScanOut | None: - """Get Wifi Scan - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - WifiScanOut - """ - - return sync_detailed( - client=client, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, -) -> Response[WifiScanOut]: - """Get Wifi Scan - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[WifiScanOut] - """ - - kwargs = _get_kwargs() - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, -) -> WifiScanOut | None: - """Get Wifi Scan - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - WifiScanOut - """ - - return ( - await asyncio_detailed( - client=client, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/run_panel_emergency_reconnect_api_v1_panel_emergency_reconnect_post.py b/src/span_panel_api/generated_client/api/default/run_panel_emergency_reconnect_api_v1_panel_emergency_reconnect_post.py deleted file mode 100644 index e7e7c1a..0000000 --- a/src/span_panel_api/generated_client/api/default/run_panel_emergency_reconnect_api_v1_panel_emergency_reconnect_post.py +++ /dev/null @@ -1,79 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...types import Response - - -def _get_kwargs() -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "post", - "url": "/api/v1/panel/emergency-reconnect", - } - - return _kwargs - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | None: - if response.status_code == 200: - return None - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, -) -> Response[Any]: - """Run Panel Emergency Reconnect - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Any] - """ - - kwargs = _get_kwargs() - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, -) -> Response[Any]: - """Run Panel Emergency Reconnect - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Any] - """ - - kwargs = _get_kwargs() - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) diff --git a/src/span_panel_api/generated_client/api/default/run_wifi_connect_api_v1_wifi_connect_post.py b/src/span_panel_api/generated_client/api/default/run_wifi_connect_api_v1_wifi_connect_post.py deleted file mode 100644 index 8508a18..0000000 --- a/src/span_panel_api/generated_client/api/default/run_wifi_connect_api_v1_wifi_connect_post.py +++ /dev/null @@ -1,164 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.http_validation_error import HTTPValidationError -from ...models.wifi_connect_in import WifiConnectIn -from ...models.wifi_connect_out import WifiConnectOut -from ...types import Response - - -def _get_kwargs( - *, - body: WifiConnectIn, -) -> dict[str, Any]: - headers: dict[str, Any] = {} - - _kwargs: dict[str, Any] = { - "method": "post", - "url": "/api/v1/wifi/connect", - } - - _kwargs["json"] = body.to_dict() - - headers["Content-Type"] = "application/json" - - _kwargs["headers"] = headers - return _kwargs - - -def _parse_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> HTTPValidationError | WifiConnectOut | None: - if response.status_code == 200: - response_200 = WifiConnectOut.from_dict(response.json()) - - return response_200 - if response.status_code == 422: - response_422 = HTTPValidationError.from_dict(response.json()) - - return response_422 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[HTTPValidationError | WifiConnectOut]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, - body: WifiConnectIn, -) -> Response[HTTPValidationError | WifiConnectOut]: - """Run Wifi Connect - - Args: - body (WifiConnectIn): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[HTTPValidationError, WifiConnectOut]] - """ - - kwargs = _get_kwargs( - body=body, - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, - body: WifiConnectIn, -) -> HTTPValidationError | WifiConnectOut | None: - """Run Wifi Connect - - Args: - body (WifiConnectIn): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[HTTPValidationError, WifiConnectOut] - """ - - return sync_detailed( - client=client, - body=body, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, - body: WifiConnectIn, -) -> Response[HTTPValidationError | WifiConnectOut]: - """Run Wifi Connect - - Args: - body (WifiConnectIn): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[HTTPValidationError, WifiConnectOut]] - """ - - kwargs = _get_kwargs( - body=body, - ) - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, - body: WifiConnectIn, -) -> HTTPValidationError | WifiConnectOut | None: - """Run Wifi Connect - - Args: - body (WifiConnectIn): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[HTTPValidationError, WifiConnectOut] - """ - - return ( - await asyncio_detailed( - client=client, - body=body, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/set_circuit_state_api_v_1_circuits_circuit_id_post.py b/src/span_panel_api/generated_client/api/default/set_circuit_state_api_v_1_circuits_circuit_id_post.py deleted file mode 100644 index c3143b8..0000000 --- a/src/span_panel_api/generated_client/api/default/set_circuit_state_api_v_1_circuits_circuit_id_post.py +++ /dev/null @@ -1,177 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.body_set_circuit_state_api_v1_circuits_circuit_id_post import BodySetCircuitStateApiV1CircuitsCircuitIdPost -from ...models.circuit import Circuit -from ...models.http_validation_error import HTTPValidationError -from ...types import Response - - -def _get_kwargs( - circuit_id: str, - *, - body: BodySetCircuitStateApiV1CircuitsCircuitIdPost, -) -> dict[str, Any]: - headers: dict[str, Any] = {} - - _kwargs: dict[str, Any] = { - "method": "post", - "url": f"/api/v1/circuits/{circuit_id}", - } - - _kwargs["json"] = body.to_dict() - - headers["Content-Type"] = "application/json" - - _kwargs["headers"] = headers - return _kwargs - - -def _parse_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Circuit | HTTPValidationError | None: - if response.status_code == 200: - response_200 = Circuit.from_dict(response.json()) - - return response_200 - if response.status_code == 422: - response_422 = HTTPValidationError.from_dict(response.json()) - - return response_422 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Circuit | HTTPValidationError]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - circuit_id: str, - *, - client: AuthenticatedClient, - body: BodySetCircuitStateApiV1CircuitsCircuitIdPost, -) -> Response[Circuit | HTTPValidationError]: - """Set Circuit State - - Args: - circuit_id (str): - body (BodySetCircuitStateApiV1CircuitsCircuitIdPost): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[Circuit, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - circuit_id=circuit_id, - body=body, - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - circuit_id: str, - *, - client: AuthenticatedClient, - body: BodySetCircuitStateApiV1CircuitsCircuitIdPost, -) -> Circuit | HTTPValidationError | None: - """Set Circuit State - - Args: - circuit_id (str): - body (BodySetCircuitStateApiV1CircuitsCircuitIdPost): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[Circuit, HTTPValidationError] - """ - - return sync_detailed( - circuit_id=circuit_id, - client=client, - body=body, - ).parsed - - -async def asyncio_detailed( - circuit_id: str, - *, - client: AuthenticatedClient, - body: BodySetCircuitStateApiV1CircuitsCircuitIdPost, -) -> Response[Circuit | HTTPValidationError]: - """Set Circuit State - - Args: - circuit_id (str): - body (BodySetCircuitStateApiV1CircuitsCircuitIdPost): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[Circuit, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - circuit_id=circuit_id, - body=body, - ) - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - circuit_id: str, - *, - client: AuthenticatedClient, - body: BodySetCircuitStateApiV1CircuitsCircuitIdPost, -) -> Circuit | HTTPValidationError | None: - """Set Circuit State - - Args: - circuit_id (str): - body (BodySetCircuitStateApiV1CircuitsCircuitIdPost): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[Circuit, HTTPValidationError] - """ - - return ( - await asyncio_detailed( - circuit_id=circuit_id, - client=client, - body=body, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/set_main_relay_state_api_v1_panel_grid_post.py b/src/span_panel_api/generated_client/api/default/set_main_relay_state_api_v1_panel_grid_post.py deleted file mode 100644 index 7946577..0000000 --- a/src/span_panel_api/generated_client/api/default/set_main_relay_state_api_v1_panel_grid_post.py +++ /dev/null @@ -1,160 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.http_validation_error import HTTPValidationError -from ...models.relay_state_in import RelayStateIn -from ...types import Response - - -def _get_kwargs( - *, - body: RelayStateIn, -) -> dict[str, Any]: - headers: dict[str, Any] = {} - - _kwargs: dict[str, Any] = { - "method": "post", - "url": "/api/v1/panel/grid", - } - - _kwargs["json"] = body.to_dict() - - headers["Content-Type"] = "application/json" - - _kwargs["headers"] = headers - return _kwargs - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | HTTPValidationError | None: - if response.status_code == 200: - response_200 = response.json() - return response_200 - if response.status_code == 422: - response_422 = HTTPValidationError.from_dict(response.json()) - - return response_422 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Any | HTTPValidationError]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, - body: RelayStateIn, -) -> Response[Any | HTTPValidationError]: - """Set Main Relay State - - Args: - body (RelayStateIn): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[Any, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - body=body, - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, - body: RelayStateIn, -) -> Any | HTTPValidationError | None: - """Set Main Relay State - - Args: - body (RelayStateIn): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[Any, HTTPValidationError] - """ - - return sync_detailed( - client=client, - body=body, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, - body: RelayStateIn, -) -> Response[Any | HTTPValidationError]: - """Set Main Relay State - - Args: - body (RelayStateIn): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[Any, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - body=body, - ) - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, - body: RelayStateIn, -) -> Any | HTTPValidationError | None: - """Set Main Relay State - - Args: - body (RelayStateIn): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[Any, HTTPValidationError] - """ - - return ( - await asyncio_detailed( - client=client, - body=body, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/set_storage_nice_to_have_threshold_api_v1_storage_nice_to_have_thresh_post.py b/src/span_panel_api/generated_client/api/default/set_storage_nice_to_have_threshold_api_v1_storage_nice_to_have_thresh_post.py deleted file mode 100644 index 43e1d9b..0000000 --- a/src/span_panel_api/generated_client/api/default/set_storage_nice_to_have_threshold_api_v1_storage_nice_to_have_thresh_post.py +++ /dev/null @@ -1,163 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.http_validation_error import HTTPValidationError -from ...models.nice_to_have_threshold import NiceToHaveThreshold -from ...types import Response - - -def _get_kwargs( - *, - body: NiceToHaveThreshold, -) -> dict[str, Any]: - headers: dict[str, Any] = {} - - _kwargs: dict[str, Any] = { - "method": "post", - "url": "/api/v1/storage/nice-to-have-thresh", - } - - _kwargs["json"] = body.to_dict() - - headers["Content-Type"] = "application/json" - - _kwargs["headers"] = headers - return _kwargs - - -def _parse_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> HTTPValidationError | NiceToHaveThreshold | None: - if response.status_code == 200: - response_200 = NiceToHaveThreshold.from_dict(response.json()) - - return response_200 - if response.status_code == 422: - response_422 = HTTPValidationError.from_dict(response.json()) - - return response_422 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[HTTPValidationError | NiceToHaveThreshold]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, - body: NiceToHaveThreshold, -) -> Response[HTTPValidationError | NiceToHaveThreshold]: - """Set Storage Nice To Have Threshold - - Args: - body (NiceToHaveThreshold): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[HTTPValidationError, NiceToHaveThreshold]] - """ - - kwargs = _get_kwargs( - body=body, - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, - body: NiceToHaveThreshold, -) -> HTTPValidationError | NiceToHaveThreshold | None: - """Set Storage Nice To Have Threshold - - Args: - body (NiceToHaveThreshold): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[HTTPValidationError, NiceToHaveThreshold] - """ - - return sync_detailed( - client=client, - body=body, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, - body: NiceToHaveThreshold, -) -> Response[HTTPValidationError | NiceToHaveThreshold]: - """Set Storage Nice To Have Threshold - - Args: - body (NiceToHaveThreshold): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[HTTPValidationError, NiceToHaveThreshold]] - """ - - kwargs = _get_kwargs( - body=body, - ) - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, - body: NiceToHaveThreshold, -) -> HTTPValidationError | NiceToHaveThreshold | None: - """Set Storage Nice To Have Threshold - - Args: - body (NiceToHaveThreshold): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[HTTPValidationError, NiceToHaveThreshold] - """ - - return ( - await asyncio_detailed( - client=client, - body=body, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/set_storage_soe_api_v1_storage_soe_post.py b/src/span_panel_api/generated_client/api/default/set_storage_soe_api_v1_storage_soe_post.py deleted file mode 100644 index e5dea8e..0000000 --- a/src/span_panel_api/generated_client/api/default/set_storage_soe_api_v1_storage_soe_post.py +++ /dev/null @@ -1,163 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.battery_storage import BatteryStorage -from ...models.http_validation_error import HTTPValidationError -from ...types import Response - - -def _get_kwargs( - *, - body: BatteryStorage, -) -> dict[str, Any]: - headers: dict[str, Any] = {} - - _kwargs: dict[str, Any] = { - "method": "post", - "url": "/api/v1/storage/soe", - } - - _kwargs["json"] = body.to_dict() - - headers["Content-Type"] = "application/json" - - _kwargs["headers"] = headers - return _kwargs - - -def _parse_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> BatteryStorage | HTTPValidationError | None: - if response.status_code == 200: - response_200 = BatteryStorage.from_dict(response.json()) - - return response_200 - if response.status_code == 422: - response_422 = HTTPValidationError.from_dict(response.json()) - - return response_422 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response( - *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[BatteryStorage | HTTPValidationError]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, - body: BatteryStorage, -) -> Response[BatteryStorage | HTTPValidationError]: - """Set Storage Soe - - Args: - body (BatteryStorage): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[BatteryStorage, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - body=body, - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, - body: BatteryStorage, -) -> BatteryStorage | HTTPValidationError | None: - """Set Storage Soe - - Args: - body (BatteryStorage): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[BatteryStorage, HTTPValidationError] - """ - - return sync_detailed( - client=client, - body=body, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, - body: BatteryStorage, -) -> Response[BatteryStorage | HTTPValidationError]: - """Set Storage Soe - - Args: - body (BatteryStorage): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[Union[BatteryStorage, HTTPValidationError]] - """ - - kwargs = _get_kwargs( - body=body, - ) - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, - body: BatteryStorage, -) -> BatteryStorage | HTTPValidationError | None: - """Set Storage Soe - - Args: - body (BatteryStorage): - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Union[BatteryStorage, HTTPValidationError] - """ - - return ( - await asyncio_detailed( - client=client, - body=body, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/api/default/system_status_api_v1_status_get.py b/src/span_panel_api/generated_client/api/default/system_status_api_v1_status_get.py deleted file mode 100644 index da24992..0000000 --- a/src/span_panel_api/generated_client/api/default/system_status_api_v1_status_get.py +++ /dev/null @@ -1,122 +0,0 @@ -from http import HTTPStatus -from typing import Any - -import httpx - -from ... import errors -from ...client import AuthenticatedClient, Client -from ...models.status_out import StatusOut -from ...types import Response - - -def _get_kwargs() -> dict[str, Any]: - _kwargs: dict[str, Any] = { - "method": "get", - "url": "/api/v1/status", - } - - return _kwargs - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> StatusOut | None: - if response.status_code == 200: - response_200 = StatusOut.from_dict(response.json()) - - return response_200 - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - - -def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[StatusOut]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - *, - client: AuthenticatedClient, -) -> Response[StatusOut]: - """System Status - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[StatusOut] - """ - - kwargs = _get_kwargs() - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - - -def sync( - *, - client: AuthenticatedClient, -) -> StatusOut | None: - """System Status - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - StatusOut - """ - - return sync_detailed( - client=client, - ).parsed - - -async def asyncio_detailed( - *, - client: AuthenticatedClient, -) -> Response[StatusOut]: - """System Status - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - Response[StatusOut] - """ - - kwargs = _get_kwargs() - - response = await client.get_async_httpx_client().request(**kwargs) - - return _build_response(client=client, response=response) - - -async def asyncio( - *, - client: AuthenticatedClient, -) -> StatusOut | None: - """System Status - - Raises: - errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. - httpx.TimeoutException: If the request takes longer than Client.timeout. - - Returns: - StatusOut - """ - - return ( - await asyncio_detailed( - client=client, - ) - ).parsed diff --git a/src/span_panel_api/generated_client/client.py b/src/span_panel_api/generated_client/client.py deleted file mode 100644 index d625261..0000000 --- a/src/span_panel_api/generated_client/client.py +++ /dev/null @@ -1,268 +0,0 @@ -import ssl -from typing import Any - -from attrs import define, evolve, field -import httpx - - -@define -class Client: - """A class for keeping track of data related to the API - - The following are accepted as keyword arguments and will be used to construct httpx Clients internally: - - ``base_url``: The base URL for the API, all requests are made to a relative path to this URL - - ``cookies``: A dictionary of cookies to be sent with every request - - ``headers``: A dictionary of headers to be sent with every request - - ``timeout``: The maximum amount of a time a request can take. API functions will raise - httpx.TimeoutException if this is exceeded. - - ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, - but can be set to False for testing purposes. - - ``follow_redirects``: Whether or not to follow redirects. Default value is False. - - ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. - - - Attributes: - raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a - status code that was not documented in the source OpenAPI document. Can also be provided as a keyword - argument to the constructor. - """ - - raise_on_unexpected_status: bool = field(default=False, kw_only=True) - _base_url: str = field(alias="base_url") - _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") - _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") - _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout") - _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl") - _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") - _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") - _client: httpx.Client | None = field(default=None, init=False) - _async_client: httpx.AsyncClient | None = field(default=None, init=False) - - def with_headers(self, headers: dict[str, str]) -> "Client": - """Get a new client matching this one with additional headers""" - if self._client is not None: - self._client.headers.update(headers) - if self._async_client is not None: - self._async_client.headers.update(headers) - return evolve(self, headers={**self._headers, **headers}) - - def with_cookies(self, cookies: dict[str, str]) -> "Client": - """Get a new client matching this one with additional cookies""" - if self._client is not None: - self._client.cookies.update(cookies) - if self._async_client is not None: - self._async_client.cookies.update(cookies) - return evolve(self, cookies={**self._cookies, **cookies}) - - def with_timeout(self, timeout: httpx.Timeout) -> "Client": - """Get a new client matching this one with a new timeout (in seconds)""" - if self._client is not None: - self._client.timeout = timeout - if self._async_client is not None: - self._async_client.timeout = timeout - return evolve(self, timeout=timeout) - - def set_httpx_client(self, client: httpx.Client) -> "Client": - """Manually set the underlying httpx.Client - - **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. - """ - self._client = client - return self - - def get_httpx_client(self) -> httpx.Client: - """Get the underlying httpx.Client, constructing a new one if not previously set""" - if self._client is None: - self._client = httpx.Client( - base_url=self._base_url, - cookies=self._cookies, - headers=self._headers, - timeout=self._timeout, - verify=self._verify_ssl, - follow_redirects=self._follow_redirects, - **self._httpx_args, - ) - return self._client - - def __enter__(self) -> "Client": - """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" - self.get_httpx_client().__enter__() - return self - - def __exit__(self, *args: Any, **kwargs: Any) -> None: - """Exit a context manager for internal httpx.Client (see httpx docs)""" - self.get_httpx_client().__exit__(*args, **kwargs) - - def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client": - """Manually the underlying httpx.AsyncClient - - **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. - """ - self._async_client = async_client - return self - - def get_async_httpx_client(self) -> httpx.AsyncClient: - """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" - if self._async_client is None: - self._async_client = httpx.AsyncClient( - base_url=self._base_url, - cookies=self._cookies, - headers=self._headers, - timeout=self._timeout, - verify=self._verify_ssl, - follow_redirects=self._follow_redirects, - **self._httpx_args, - ) - return self._async_client - - async def __aenter__(self) -> "Client": - """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" - await self.get_async_httpx_client().__aenter__() - return self - - async def __aexit__(self, *args: Any, **kwargs: Any) -> None: - """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" - await self.get_async_httpx_client().__aexit__(*args, **kwargs) - - -@define -class AuthenticatedClient: - """A Client which has been authenticated for use on secured endpoints - - The following are accepted as keyword arguments and will be used to construct httpx Clients internally: - - ``base_url``: The base URL for the API, all requests are made to a relative path to this URL - - ``cookies``: A dictionary of cookies to be sent with every request - - ``headers``: A dictionary of headers to be sent with every request - - ``timeout``: The maximum amount of a time a request can take. API functions will raise - httpx.TimeoutException if this is exceeded. - - ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, - but can be set to False for testing purposes. - - ``follow_redirects``: Whether or not to follow redirects. Default value is False. - - ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. - - - Attributes: - raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a - status code that was not documented in the source OpenAPI document. Can also be provided as a keyword - argument to the constructor. - token: The token to use for authentication - prefix: The prefix to use for the Authorization header - auth_header_name: The name of the Authorization header - """ - - raise_on_unexpected_status: bool = field(default=False, kw_only=True) - _base_url: str = field(alias="base_url") - _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") - _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") - _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout") - _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl") - _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") - _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") - _client: httpx.Client | None = field(default=None, init=False) - _async_client: httpx.AsyncClient | None = field(default=None, init=False) - - token: str - prefix: str = "Bearer" - auth_header_name: str = "Authorization" - - def with_headers(self, headers: dict[str, str]) -> "AuthenticatedClient": - """Get a new client matching this one with additional headers""" - if self._client is not None: - self._client.headers.update(headers) - if self._async_client is not None: - self._async_client.headers.update(headers) - return evolve(self, headers={**self._headers, **headers}) - - def with_cookies(self, cookies: dict[str, str]) -> "AuthenticatedClient": - """Get a new client matching this one with additional cookies""" - if self._client is not None: - self._client.cookies.update(cookies) - if self._async_client is not None: - self._async_client.cookies.update(cookies) - return evolve(self, cookies={**self._cookies, **cookies}) - - def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient": - """Get a new client matching this one with a new timeout (in seconds)""" - if self._client is not None: - self._client.timeout = timeout - if self._async_client is not None: - self._async_client.timeout = timeout - return evolve(self, timeout=timeout) - - def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient": - """Manually set the underlying httpx.Client - - **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. - """ - self._client = client - return self - - def get_httpx_client(self) -> httpx.Client: - """Get the underlying httpx.Client, constructing a new one if not previously set""" - if self._client is None: - self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token - self._client = httpx.Client( - base_url=self._base_url, - cookies=self._cookies, - headers=self._headers, - timeout=self._timeout, - verify=self._verify_ssl, - follow_redirects=self._follow_redirects, - **self._httpx_args, - ) - return self._client - - def __enter__(self) -> "AuthenticatedClient": - """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" - self.get_httpx_client().__enter__() - return self - - def __exit__(self, *args: Any, **kwargs: Any) -> None: - """Exit a context manager for internal httpx.Client (see httpx docs)""" - self.get_httpx_client().__exit__(*args, **kwargs) - - def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient": - """Manually the underlying httpx.AsyncClient - - **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. - """ - self._async_client = async_client - return self - - def get_async_httpx_client(self) -> httpx.AsyncClient: - """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" - if self._async_client is None: - self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token - self._async_client = httpx.AsyncClient( - base_url=self._base_url, - cookies=self._cookies, - headers=self._headers, - timeout=self._timeout, - verify=self._verify_ssl, - follow_redirects=self._follow_redirects, - **self._httpx_args, - ) - return self._async_client - - async def __aenter__(self) -> "AuthenticatedClient": - """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" - await self.get_async_httpx_client().__aenter__() - return self - - async def __aexit__(self, *args: Any, **kwargs: Any) -> None: - """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" - await self.get_async_httpx_client().__aexit__(*args, **kwargs) diff --git a/src/span_panel_api/generated_client/errors.py b/src/span_panel_api/generated_client/errors.py deleted file mode 100644 index 01bd3bc..0000000 --- a/src/span_panel_api/generated_client/errors.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Contains shared errors types that can be raised from API functions""" - - -class UnexpectedStatus(Exception): - """Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True""" - - def __init__(self, status_code: int, content: bytes): - self.status_code = status_code - self.content = content - - super().__init__(f"Unexpected status code: {status_code}\n\nResponse content:\n{content.decode(errors='ignore')}") - - -__all__ = ["UnexpectedStatus"] diff --git a/src/span_panel_api/generated_client/models/__init__.py b/src/span_panel_api/generated_client/models/__init__.py deleted file mode 100644 index 4a956e6..0000000 --- a/src/span_panel_api/generated_client/models/__init__.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Contains all the data models used in inputs/outputs""" - -from .allowed_endpoint_groups import AllowedEndpointGroups -from .auth_in import AuthIn -from .auth_out import AuthOut -from .battery_storage import BatteryStorage -from .body_set_circuit_state_api_v1_circuits_circuit_id_post import BodySetCircuitStateApiV1CircuitsCircuitIdPost -from .boolean_in import BooleanIn -from .branch import Branch -from .circuit import Circuit -from .circuit_name_in import CircuitNameIn -from .circuits_out import CircuitsOut -from .circuits_out_circuits import CircuitsOutCircuits -from .client import Client -from .clients import Clients -from .clients_clients import ClientsClients -from .door_state import DoorState -from .feedthrough_energy import FeedthroughEnergy -from .http_validation_error import HTTPValidationError -from .islanding_state import IslandingState -from .main_meter_energy import MainMeterEnergy -from .network_status import NetworkStatus -from .nice_to_have_threshold import NiceToHaveThreshold -from .panel_meter import PanelMeter -from .panel_power import PanelPower -from .panel_state import PanelState -from .priority import Priority -from .priority_in import PriorityIn -from .relay_state import RelayState -from .relay_state_in import RelayStateIn -from .relay_state_out import RelayStateOut -from .software_status import SoftwareStatus -from .state_of_energy import StateOfEnergy -from .status_out import StatusOut -from .system_status import SystemStatus -from .validation_error import ValidationError -from .wifi_access_point import WifiAccessPoint -from .wifi_connect_in import WifiConnectIn -from .wifi_connect_out import WifiConnectOut -from .wifi_scan_out import WifiScanOut - -__all__ = ( - "AllowedEndpointGroups", - "AuthIn", - "AuthOut", - "BatteryStorage", - "BodySetCircuitStateApiV1CircuitsCircuitIdPost", - "BooleanIn", - "Branch", - "Circuit", - "CircuitNameIn", - "CircuitsOut", - "CircuitsOutCircuits", - "Client", - "Clients", - "ClientsClients", - "DoorState", - "FeedthroughEnergy", - "HTTPValidationError", - "IslandingState", - "MainMeterEnergy", - "NetworkStatus", - "NiceToHaveThreshold", - "PanelMeter", - "PanelPower", - "PanelState", - "Priority", - "PriorityIn", - "RelayState", - "RelayStateIn", - "RelayStateOut", - "SoftwareStatus", - "StateOfEnergy", - "StatusOut", - "SystemStatus", - "ValidationError", - "WifiAccessPoint", - "WifiConnectIn", - "WifiConnectOut", - "WifiScanOut", -) diff --git a/src/span_panel_api/generated_client/models/allowed_endpoint_groups.py b/src/span_panel_api/generated_client/models/allowed_endpoint_groups.py deleted file mode 100644 index e40a4ee..0000000 --- a/src/span_panel_api/generated_client/models/allowed_endpoint_groups.py +++ /dev/null @@ -1,82 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar, cast - -from attrs import define as _attrs_define, field as _attrs_field - -T = TypeVar("T", bound="AllowedEndpointGroups") - - -@_attrs_define -class AllowedEndpointGroups: - """ - Attributes: - delete (list[str]): - get (list[str]): - post (list[str]): - push (list[str]): - """ - - delete: list[str] - get: list[str] - post: list[str] - push: list[str] - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - delete = self.delete - - get = self.get - - post = self.post - - push = self.push - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "delete": delete, - "get": get, - "post": post, - "push": push, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - delete = cast(list[str], d.pop("delete")) - - get = cast(list[str], d.pop("get")) - - post = cast(list[str], d.pop("post")) - - push = cast(list[str], d.pop("push")) - - allowed_endpoint_groups = cls( - delete=delete, - get=get, - post=post, - push=push, - ) - - allowed_endpoint_groups.additional_properties = d - return allowed_endpoint_groups - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/auth_in.py b/src/span_panel_api/generated_client/models/auth_in.py deleted file mode 100644 index c09f88f..0000000 --- a/src/span_panel_api/generated_client/models/auth_in.py +++ /dev/null @@ -1,87 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -from ..types import UNSET, Unset - -T = TypeVar("T", bound="AuthIn") - - -@_attrs_define -class AuthIn: - """ - Attributes: - name (str): - description (Union[Unset, str]): - otp (Union[Unset, str]): - dashboard_password (Union[Unset, str]): - """ - - name: str - description: Unset | str = UNSET - otp: Unset | str = UNSET - dashboard_password: Unset | str = UNSET - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - name = self.name - - description = self.description - - otp = self.otp - - dashboard_password = self.dashboard_password - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "name": name, - } - ) - if description is not UNSET: - field_dict["description"] = description - if otp is not UNSET: - field_dict["otp"] = otp - if dashboard_password is not UNSET: - field_dict["dashboardPassword"] = dashboard_password - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - name = d.pop("name") - - description = d.pop("description", UNSET) - - otp = d.pop("otp", UNSET) - - dashboard_password = d.pop("dashboardPassword", UNSET) - - auth_in = cls( - name=name, - description=description, - otp=otp, - dashboard_password=dashboard_password, - ) - - auth_in.additional_properties = d - return auth_in - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/auth_out.py b/src/span_panel_api/generated_client/models/auth_out.py deleted file mode 100644 index 66d7900..0000000 --- a/src/span_panel_api/generated_client/models/auth_out.py +++ /dev/null @@ -1,74 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -T = TypeVar("T", bound="AuthOut") - - -@_attrs_define -class AuthOut: - """ - Attributes: - access_token (str): - token_type (str): - iat_ms (int): - """ - - access_token: str - token_type: str - iat_ms: int - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - access_token = self.access_token - - token_type = self.token_type - - iat_ms = self.iat_ms - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "accessToken": access_token, - "tokenType": token_type, - "iatMs": iat_ms, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - access_token = d.pop("accessToken") - - token_type = d.pop("tokenType") - - iat_ms = d.pop("iatMs") - - auth_out = cls( - access_token=access_token, - token_type=token_type, - iat_ms=iat_ms, - ) - - auth_out.additional_properties = d - return auth_out - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/battery_storage.py b/src/span_panel_api/generated_client/models/battery_storage.py deleted file mode 100644 index 6257dc2..0000000 --- a/src/span_panel_api/generated_client/models/battery_storage.py +++ /dev/null @@ -1,64 +0,0 @@ -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -if TYPE_CHECKING: - from ..models.state_of_energy import StateOfEnergy - - -T = TypeVar("T", bound="BatteryStorage") - - -@_attrs_define -class BatteryStorage: - """ - Attributes: - soe (StateOfEnergy): - """ - - soe: "StateOfEnergy" - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - soe = self.soe.to_dict() - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "soe": soe, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.state_of_energy import StateOfEnergy - - d = dict(src_dict) - soe = StateOfEnergy.from_dict(d.pop("soe")) - - battery_storage = cls( - soe=soe, - ) - - battery_storage.additional_properties = d - return battery_storage - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/body_set_circuit_state_api_v1_circuits_circuit_id_post.py b/src/span_panel_api/generated_client/models/body_set_circuit_state_api_v1_circuits_circuit_id_post.py deleted file mode 100644 index 1f4a91e..0000000 --- a/src/span_panel_api/generated_client/models/body_set_circuit_state_api_v1_circuits_circuit_id_post.py +++ /dev/null @@ -1,157 +0,0 @@ -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, Union - -from attrs import define as _attrs_define, field as _attrs_field - -from ..types import UNSET, Unset - -if TYPE_CHECKING: - from ..models.boolean_in import BooleanIn - from ..models.circuit_name_in import CircuitNameIn - from ..models.priority_in import PriorityIn - from ..models.relay_state_in import RelayStateIn - - -T = TypeVar("T", bound="BodySetCircuitStateApiV1CircuitsCircuitIdPost") - - -@_attrs_define -class BodySetCircuitStateApiV1CircuitsCircuitIdPost: - """ - Attributes: - relay_state_in (Union[Unset, RelayStateIn]): - priority_in (Union[Unset, PriorityIn]): - circuit_name_in (Union[Unset, CircuitNameIn]): - user_controllable_in (Union[Unset, BooleanIn]): - sheddable_in (Union[Unset, BooleanIn]): - never_backup_in (Union[Unset, BooleanIn]): - """ - - relay_state_in: Union[Unset, "RelayStateIn"] = UNSET - priority_in: Union[Unset, "PriorityIn"] = UNSET - circuit_name_in: Union[Unset, "CircuitNameIn"] = UNSET - user_controllable_in: Union[Unset, "BooleanIn"] = UNSET - sheddable_in: Union[Unset, "BooleanIn"] = UNSET - never_backup_in: Union[Unset, "BooleanIn"] = UNSET - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - relay_state_in: Unset | dict[str, Any] = UNSET - if not isinstance(self.relay_state_in, Unset): - relay_state_in = self.relay_state_in.to_dict() - - priority_in: Unset | dict[str, Any] = UNSET - if not isinstance(self.priority_in, Unset): - priority_in = self.priority_in.to_dict() - - circuit_name_in: Unset | dict[str, Any] = UNSET - if not isinstance(self.circuit_name_in, Unset): - circuit_name_in = self.circuit_name_in.to_dict() - - user_controllable_in: Unset | dict[str, Any] = UNSET - if not isinstance(self.user_controllable_in, Unset): - user_controllable_in = self.user_controllable_in.to_dict() - - sheddable_in: Unset | dict[str, Any] = UNSET - if not isinstance(self.sheddable_in, Unset): - sheddable_in = self.sheddable_in.to_dict() - - never_backup_in: Unset | dict[str, Any] = UNSET - if not isinstance(self.never_backup_in, Unset): - never_backup_in = self.never_backup_in.to_dict() - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update({}) - if relay_state_in is not UNSET: - field_dict["relayStateIn"] = relay_state_in - if priority_in is not UNSET: - field_dict["priorityIn"] = priority_in - if circuit_name_in is not UNSET: - field_dict["circuitNameIn"] = circuit_name_in - if user_controllable_in is not UNSET: - field_dict["userControllableIn"] = user_controllable_in - if sheddable_in is not UNSET: - field_dict["sheddableIn"] = sheddable_in - if never_backup_in is not UNSET: - field_dict["neverBackupIn"] = never_backup_in - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.boolean_in import BooleanIn - from ..models.circuit_name_in import CircuitNameIn - from ..models.priority_in import PriorityIn - from ..models.relay_state_in import RelayStateIn - - d = dict(src_dict) - _relay_state_in = d.pop("relayStateIn", UNSET) - relay_state_in: Unset | RelayStateIn - if isinstance(_relay_state_in, Unset): - relay_state_in = UNSET - else: - relay_state_in = RelayStateIn.from_dict(_relay_state_in) - - _priority_in = d.pop("priorityIn", UNSET) - priority_in: Unset | PriorityIn - if isinstance(_priority_in, Unset): - priority_in = UNSET - else: - priority_in = PriorityIn.from_dict(_priority_in) - - _circuit_name_in = d.pop("circuitNameIn", UNSET) - circuit_name_in: Unset | CircuitNameIn - if isinstance(_circuit_name_in, Unset): - circuit_name_in = UNSET - else: - circuit_name_in = CircuitNameIn.from_dict(_circuit_name_in) - - _user_controllable_in = d.pop("userControllableIn", UNSET) - user_controllable_in: Unset | BooleanIn - if isinstance(_user_controllable_in, Unset): - user_controllable_in = UNSET - else: - user_controllable_in = BooleanIn.from_dict(_user_controllable_in) - - _sheddable_in = d.pop("sheddableIn", UNSET) - sheddable_in: Unset | BooleanIn - if isinstance(_sheddable_in, Unset): - sheddable_in = UNSET - else: - sheddable_in = BooleanIn.from_dict(_sheddable_in) - - _never_backup_in = d.pop("neverBackupIn", UNSET) - never_backup_in: Unset | BooleanIn - if isinstance(_never_backup_in, Unset): - never_backup_in = UNSET - else: - never_backup_in = BooleanIn.from_dict(_never_backup_in) - - body_set_circuit_state_api_v1_circuits_circuit_id_post = cls( - relay_state_in=relay_state_in, - priority_in=priority_in, - circuit_name_in=circuit_name_in, - user_controllable_in=user_controllable_in, - sheddable_in=sheddable_in, - never_backup_in=never_backup_in, - ) - - body_set_circuit_state_api_v1_circuits_circuit_id_post.additional_properties = d - return body_set_circuit_state_api_v1_circuits_circuit_id_post - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/boolean_in.py b/src/span_panel_api/generated_client/models/boolean_in.py deleted file mode 100644 index 836b3da..0000000 --- a/src/span_panel_api/generated_client/models/boolean_in.py +++ /dev/null @@ -1,58 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -T = TypeVar("T", bound="BooleanIn") - - -@_attrs_define -class BooleanIn: - """ - Attributes: - value (bool): - """ - - value: bool - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - value = self.value - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "value": value, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - value = d.pop("value") - - boolean_in = cls( - value=value, - ) - - boolean_in.additional_properties = d - return boolean_in - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/branch.py b/src/span_panel_api/generated_client/models/branch.py deleted file mode 100644 index fd31817..0000000 --- a/src/span_panel_api/generated_client/models/branch.py +++ /dev/null @@ -1,116 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -from ..models.relay_state import RelayState - -T = TypeVar("T", bound="Branch") - - -@_attrs_define -class Branch: - """ - Attributes: - id (int): - relay_state (RelayState): An enumeration. - instant_power_w (float): - imported_active_energy_wh (float): - exported_active_energy_wh (float): - measure_start_ts_ms (int): - measure_duration_ms (int): - is_measure_valid (bool): - """ - - id: int - relay_state: RelayState - instant_power_w: float - imported_active_energy_wh: float - exported_active_energy_wh: float - measure_start_ts_ms: int - measure_duration_ms: int - is_measure_valid: bool - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - id = self.id - - relay_state = self.relay_state.value - - instant_power_w = self.instant_power_w - - imported_active_energy_wh = self.imported_active_energy_wh - - exported_active_energy_wh = self.exported_active_energy_wh - - measure_start_ts_ms = self.measure_start_ts_ms - - measure_duration_ms = self.measure_duration_ms - - is_measure_valid = self.is_measure_valid - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "id": id, - "relayState": relay_state, - "instantPowerW": instant_power_w, - "importedActiveEnergyWh": imported_active_energy_wh, - "exportedActiveEnergyWh": exported_active_energy_wh, - "measureStartTsMs": measure_start_ts_ms, - "measureDurationMs": measure_duration_ms, - "isMeasureValid": is_measure_valid, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - id = d.pop("id") - - relay_state = RelayState(d.pop("relayState")) - - instant_power_w = d.pop("instantPowerW") - - imported_active_energy_wh = d.pop("importedActiveEnergyWh") - - exported_active_energy_wh = d.pop("exportedActiveEnergyWh") - - measure_start_ts_ms = d.pop("measureStartTsMs") - - measure_duration_ms = d.pop("measureDurationMs") - - is_measure_valid = d.pop("isMeasureValid") - - branch = cls( - id=id, - relay_state=relay_state, - instant_power_w=instant_power_w, - imported_active_energy_wh=imported_active_energy_wh, - exported_active_energy_wh=exported_active_energy_wh, - measure_start_ts_ms=measure_start_ts_ms, - measure_duration_ms=measure_duration_ms, - is_measure_valid=is_measure_valid, - ) - - branch.additional_properties = d - return branch - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/circuit.py b/src/span_panel_api/generated_client/models/circuit.py deleted file mode 100644 index e449fa7..0000000 --- a/src/span_panel_api/generated_client/models/circuit.py +++ /dev/null @@ -1,217 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar, cast - -from attrs import define as _attrs_define, field as _attrs_field - -from ..models.priority import Priority -from ..models.relay_state import RelayState -from ..types import UNSET, Unset - -T = TypeVar("T", bound="Circuit") - - -@_attrs_define -class Circuit: - """ - Attributes: - id (str): - relay_state (RelayState): An enumeration. - instant_power_w (float): - instant_power_update_time_s (int): - produced_energy_wh (float): - consumed_energy_wh (float): - energy_accum_update_time_s (int): - priority (Priority): An enumeration. - is_user_controllable (bool): - is_sheddable (bool): - is_never_backup (bool): - name (Union[Unset, str]): - tabs (Union[Unset, list[int]]): - """ - - id: str - relay_state: RelayState - instant_power_w: float - instant_power_update_time_s: int - produced_energy_wh: float | None - consumed_energy_wh: float | None - energy_accum_update_time_s: int - priority: Priority - is_user_controllable: bool - is_sheddable: bool - is_never_backup: bool - name: Unset | str = UNSET - tabs: Unset | list[int] = UNSET - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - id = self.id - - relay_state = self.relay_state.value - - instant_power_w = self.instant_power_w - - instant_power_update_time_s = self.instant_power_update_time_s - - produced_energy_wh = self.produced_energy_wh - - consumed_energy_wh = self.consumed_energy_wh - - energy_accum_update_time_s = self.energy_accum_update_time_s - - priority = self.priority.value - - is_user_controllable = self.is_user_controllable - - is_sheddable = self.is_sheddable - - is_never_backup = self.is_never_backup - - name = self.name - - tabs: Unset | list[int] = UNSET - if not isinstance(self.tabs, Unset): - tabs = self.tabs - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "id": id, - "relayState": relay_state, - "instantPowerW": instant_power_w, - "instantPowerUpdateTimeS": instant_power_update_time_s, - "producedEnergyWh": produced_energy_wh, - "consumedEnergyWh": consumed_energy_wh, - "energyAccumUpdateTimeS": energy_accum_update_time_s, - "priority": priority, - "isUserControllable": is_user_controllable, - "isSheddable": is_sheddable, - "isNeverBackup": is_never_backup, - } - ) - if name is not UNSET: - field_dict["name"] = name - if tabs is not UNSET: - field_dict["tabs"] = tabs - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - id = d.pop("id") - - relay_state = RelayState(d.pop("relayState")) - - instant_power_w = d.pop("instantPowerW") - - instant_power_update_time_s = d.pop("instantPowerUpdateTimeS") - - produced_energy_wh = d.pop("producedEnergyWh") - - consumed_energy_wh = d.pop("consumedEnergyWh") - - energy_accum_update_time_s = d.pop("energyAccumUpdateTimeS") - - priority = Priority(d.pop("priority")) - - is_user_controllable = d.pop("isUserControllable") - - is_sheddable = d.pop("isSheddable") - - is_never_backup = d.pop("isNeverBackup") - - name = d.pop("name", UNSET) - - tabs = cast(list[int], d.pop("tabs", UNSET)) - - # Apply SPAN panel sign correction for power values - # SPAN panels report negative power for consumption on some installations - # Correct the sign based on energy consumption patterns - corrected_instant_power_w = cls._apply_power_sign_correction( - instant_power_w, produced_energy_wh, consumed_energy_wh, name - ) - - circuit = cls( - id=id, - relay_state=relay_state, - instant_power_w=corrected_instant_power_w, - instant_power_update_time_s=instant_power_update_time_s, - produced_energy_wh=produced_energy_wh, - consumed_energy_wh=consumed_energy_wh, - energy_accum_update_time_s=energy_accum_update_time_s, - priority=priority, - is_user_controllable=is_user_controllable, - is_sheddable=is_sheddable, - is_never_backup=is_never_backup, - name=name, - tabs=tabs, - ) - - circuit.additional_properties = d - return circuit - - @classmethod - def _apply_power_sign_correction( - cls, - instant_power_w: float, - produced_energy_wh: float, - consumed_energy_wh: float, - name: str | Any, - ) -> float: - """Apply sign correction for SPAN panel power reporting quirks. - - This method corrects the sign based on energy consumption patterns: - - Consumer circuits: ensure positive power (consumption) - - Producer circuits: ensure positive power (production) - HA energy convention - - Args: - instant_power_w: Raw instantaneous power from panel - produced_energy_wh: Total produced energy - consumed_energy_wh: Total consumed energy - name: Circuit name for logging/debugging - - Returns: - Corrected power value with proper sign convention (positive for both consumption and production) - """ - # If power is zero, no correction needed - if instant_power_w == 0.0: - return instant_power_w - - # Determine if this is likely a producer circuit based on energy patterns - # Producer circuits have significantly more produced than consumed energy - total_energy = produced_energy_wh + consumed_energy_wh - if total_energy > 0: - production_ratio = produced_energy_wh / total_energy - is_likely_producer = production_ratio > 0.7 # More than 70% production - else: - # No energy history, use name-based heuristics - circuit_name = str(name).lower() if name and str(name) != "UNSET" else "" - producer_keywords = ["solar", "inverter", "generator", "battery", "production"] - is_likely_producer = any(keyword in circuit_name for keyword in producer_keywords) - - if is_likely_producer: - # Producer circuit: ensure positive power for production (new HA energy convention) - # Solar and other producers should show positive power values - return abs(instant_power_w) - else: - # Consumer circuit: ensure positive power for consumption - # If power is negative, it means consuming but sign is wrong - return abs(instant_power_w) - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/circuit_name_in.py b/src/span_panel_api/generated_client/models/circuit_name_in.py deleted file mode 100644 index 6ec5f1e..0000000 --- a/src/span_panel_api/generated_client/models/circuit_name_in.py +++ /dev/null @@ -1,58 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -T = TypeVar("T", bound="CircuitNameIn") - - -@_attrs_define -class CircuitNameIn: - """ - Attributes: - name (str): - """ - - name: str - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - name = self.name - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "name": name, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - name = d.pop("name") - - circuit_name_in = cls( - name=name, - ) - - circuit_name_in.additional_properties = d - return circuit_name_in - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/circuits_out.py b/src/span_panel_api/generated_client/models/circuits_out.py deleted file mode 100644 index 73a6291..0000000 --- a/src/span_panel_api/generated_client/models/circuits_out.py +++ /dev/null @@ -1,64 +0,0 @@ -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -if TYPE_CHECKING: - from ..models.circuits_out_circuits import CircuitsOutCircuits - - -T = TypeVar("T", bound="CircuitsOut") - - -@_attrs_define -class CircuitsOut: - """ - Attributes: - circuits (CircuitsOutCircuits): - """ - - circuits: "CircuitsOutCircuits" - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - circuits = self.circuits.to_dict() - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "circuits": circuits, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.circuits_out_circuits import CircuitsOutCircuits - - d = dict(src_dict) - circuits = CircuitsOutCircuits.from_dict(d.pop("circuits")) - - circuits_out = cls( - circuits=circuits, - ) - - circuits_out.additional_properties = d - return circuits_out - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/circuits_out_circuits.py b/src/span_panel_api/generated_client/models/circuits_out_circuits.py deleted file mode 100644 index 73a5b07..0000000 --- a/src/span_panel_api/generated_client/models/circuits_out_circuits.py +++ /dev/null @@ -1,56 +0,0 @@ -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -if TYPE_CHECKING: - from ..models.circuit import Circuit - - -T = TypeVar("T", bound="CircuitsOutCircuits") - - -@_attrs_define -class CircuitsOutCircuits: - """ """ - - additional_properties: dict[str, "Circuit"] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - field_dict: dict[str, Any] = {} - for prop_name, prop in self.additional_properties.items(): - field_dict[prop_name] = prop.to_dict() - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.circuit import Circuit - - d = dict(src_dict) - circuits_out_circuits = cls() - - additional_properties = {} - for prop_name, prop_dict in d.items(): - additional_property = Circuit.from_dict(prop_dict) - - additional_properties[prop_name] = additional_property - - circuits_out_circuits.additional_properties = additional_properties - return circuits_out_circuits - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> "Circuit": - return self.additional_properties[key] - - def __setitem__(self, key: str, value: "Circuit") -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/client.py b/src/span_panel_api/generated_client/models/client.py deleted file mode 100644 index b62146b..0000000 --- a/src/span_panel_api/generated_client/models/client.py +++ /dev/null @@ -1,84 +0,0 @@ -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -from ..types import UNSET, Unset - -if TYPE_CHECKING: - from ..models.allowed_endpoint_groups import AllowedEndpointGroups - - -T = TypeVar("T", bound="Client") - - -@_attrs_define -class Client: - """ - Attributes: - allowed_endpoint_groups (AllowedEndpointGroups): - description (Union[Unset, str]): - issued_at (Union[Unset, int]): - """ - - allowed_endpoint_groups: "AllowedEndpointGroups" - description: Unset | str = UNSET - issued_at: Unset | int = UNSET - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - allowed_endpoint_groups = self.allowed_endpoint_groups.to_dict() - - description = self.description - - issued_at = self.issued_at - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "allowed_endpoint_groups": allowed_endpoint_groups, - } - ) - if description is not UNSET: - field_dict["description"] = description - if issued_at is not UNSET: - field_dict["issued_at"] = issued_at - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.allowed_endpoint_groups import AllowedEndpointGroups - - d = dict(src_dict) - allowed_endpoint_groups = AllowedEndpointGroups.from_dict(d.pop("allowed_endpoint_groups")) - - description = d.pop("description", UNSET) - - issued_at = d.pop("issued_at", UNSET) - - client = cls( - allowed_endpoint_groups=allowed_endpoint_groups, - description=description, - issued_at=issued_at, - ) - - client.additional_properties = d - return client - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/clients.py b/src/span_panel_api/generated_client/models/clients.py deleted file mode 100644 index 2c6ed01..0000000 --- a/src/span_panel_api/generated_client/models/clients.py +++ /dev/null @@ -1,64 +0,0 @@ -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -if TYPE_CHECKING: - from ..models.clients_clients import ClientsClients - - -T = TypeVar("T", bound="Clients") - - -@_attrs_define -class Clients: - """ - Attributes: - clients (ClientsClients): - """ - - clients: "ClientsClients" - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - clients = self.clients.to_dict() - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "clients": clients, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.clients_clients import ClientsClients - - d = dict(src_dict) - clients = ClientsClients.from_dict(d.pop("clients")) - - clients = cls( - clients=clients, - ) - - clients.additional_properties = d - return clients - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/clients_clients.py b/src/span_panel_api/generated_client/models/clients_clients.py deleted file mode 100644 index 004ae09..0000000 --- a/src/span_panel_api/generated_client/models/clients_clients.py +++ /dev/null @@ -1,56 +0,0 @@ -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -if TYPE_CHECKING: - from ..models.client import Client - - -T = TypeVar("T", bound="ClientsClients") - - -@_attrs_define -class ClientsClients: - """ """ - - additional_properties: dict[str, "Client"] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - field_dict: dict[str, Any] = {} - for prop_name, prop in self.additional_properties.items(): - field_dict[prop_name] = prop.to_dict() - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.client import Client - - d = dict(src_dict) - clients_clients = cls() - - additional_properties = {} - for prop_name, prop_dict in d.items(): - additional_property = Client.from_dict(prop_dict) - - additional_properties[prop_name] = additional_property - - clients_clients.additional_properties = additional_properties - return clients_clients - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> "Client": - return self.additional_properties[key] - - def __setitem__(self, key: str, value: "Client") -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/door_state.py b/src/span_panel_api/generated_client/models/door_state.py deleted file mode 100644 index 1544d85..0000000 --- a/src/span_panel_api/generated_client/models/door_state.py +++ /dev/null @@ -1,10 +0,0 @@ -from enum import Enum - - -class DoorState(str, Enum): - CLOSED = "CLOSED" - OPEN = "OPEN" - UNKNOWN = "UNKNOWN" - - def __str__(self) -> str: - return str(self.value) diff --git a/src/span_panel_api/generated_client/models/feedthrough_energy.py b/src/span_panel_api/generated_client/models/feedthrough_energy.py deleted file mode 100644 index aca356b..0000000 --- a/src/span_panel_api/generated_client/models/feedthrough_energy.py +++ /dev/null @@ -1,66 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -T = TypeVar("T", bound="FeedthroughEnergy") - - -@_attrs_define -class FeedthroughEnergy: - """ - Attributes: - produced_energy_wh (float): - consumed_energy_wh (float): - """ - - produced_energy_wh: float | None - consumed_energy_wh: float | None - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - produced_energy_wh = self.produced_energy_wh - - consumed_energy_wh = self.consumed_energy_wh - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "producedEnergyWh": produced_energy_wh, - "consumedEnergyWh": consumed_energy_wh, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - produced_energy_wh = d.pop("producedEnergyWh") - - consumed_energy_wh = d.pop("consumedEnergyWh") - - feedthrough_energy = cls( - produced_energy_wh=produced_energy_wh, - consumed_energy_wh=consumed_energy_wh, - ) - - feedthrough_energy.additional_properties = d - return feedthrough_energy - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/http_validation_error.py b/src/span_panel_api/generated_client/models/http_validation_error.py deleted file mode 100644 index 554fa8c..0000000 --- a/src/span_panel_api/generated_client/models/http_validation_error.py +++ /dev/null @@ -1,74 +0,0 @@ -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -from ..types import UNSET, Unset - -if TYPE_CHECKING: - from ..models.validation_error import ValidationError - - -T = TypeVar("T", bound="HTTPValidationError") - - -@_attrs_define -class HTTPValidationError: - """ - Attributes: - detail (Union[Unset, list['ValidationError']]): - """ - - detail: Unset | list["ValidationError"] = UNSET - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - detail: Unset | list[dict[str, Any]] = UNSET - if not isinstance(self.detail, Unset): - detail = [] - for detail_item_data in self.detail: - detail_item = detail_item_data.to_dict() - detail.append(detail_item) - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update({}) - if detail is not UNSET: - field_dict["detail"] = detail - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.validation_error import ValidationError - - d = dict(src_dict) - detail = [] - _detail = d.pop("detail", UNSET) - for detail_item_data in _detail or []: - detail_item = ValidationError.from_dict(detail_item_data) - - detail.append(detail_item) - - http_validation_error = cls( - detail=detail, - ) - - http_validation_error.additional_properties = d - return http_validation_error - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/islanding_state.py b/src/span_panel_api/generated_client/models/islanding_state.py deleted file mode 100644 index ef7c39a..0000000 --- a/src/span_panel_api/generated_client/models/islanding_state.py +++ /dev/null @@ -1,58 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -T = TypeVar("T", bound="IslandingState") - - -@_attrs_define -class IslandingState: - """ - Attributes: - islanding_state (str): - """ - - islanding_state: str - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - islanding_state = self.islanding_state - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "islanding_state": islanding_state, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - islanding_state = d.pop("islanding_state") - - islanding_state = cls( - islanding_state=islanding_state, - ) - - islanding_state.additional_properties = d - return islanding_state - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/main_meter_energy.py b/src/span_panel_api/generated_client/models/main_meter_energy.py deleted file mode 100644 index cb69d14..0000000 --- a/src/span_panel_api/generated_client/models/main_meter_energy.py +++ /dev/null @@ -1,66 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -T = TypeVar("T", bound="MainMeterEnergy") - - -@_attrs_define -class MainMeterEnergy: - """ - Attributes: - produced_energy_wh (float): - consumed_energy_wh (float): - """ - - produced_energy_wh: float | None - consumed_energy_wh: float | None - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - produced_energy_wh = self.produced_energy_wh - - consumed_energy_wh = self.consumed_energy_wh - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "producedEnergyWh": produced_energy_wh, - "consumedEnergyWh": consumed_energy_wh, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - produced_energy_wh = d.pop("producedEnergyWh") - - consumed_energy_wh = d.pop("consumedEnergyWh") - - main_meter_energy = cls( - produced_energy_wh=produced_energy_wh, - consumed_energy_wh=consumed_energy_wh, - ) - - main_meter_energy.additional_properties = d - return main_meter_energy - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/network_status.py b/src/span_panel_api/generated_client/models/network_status.py deleted file mode 100644 index 7c58f83..0000000 --- a/src/span_panel_api/generated_client/models/network_status.py +++ /dev/null @@ -1,74 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -T = TypeVar("T", bound="NetworkStatus") - - -@_attrs_define -class NetworkStatus: - """ - Attributes: - eth_0_link (bool): - wlan_link (bool): - wwan_link (bool): - """ - - eth_0_link: bool - wlan_link: bool - wwan_link: bool - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - eth_0_link = self.eth_0_link - - wlan_link = self.wlan_link - - wwan_link = self.wwan_link - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "eth0Link": eth_0_link, - "wlanLink": wlan_link, - "wwanLink": wwan_link, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - eth_0_link = d.pop("eth0Link") - - wlan_link = d.pop("wlanLink") - - wwan_link = d.pop("wwanLink") - - network_status = cls( - eth_0_link=eth_0_link, - wlan_link=wlan_link, - wwan_link=wwan_link, - ) - - network_status.additional_properties = d - return network_status - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/nice_to_have_threshold.py b/src/span_panel_api/generated_client/models/nice_to_have_threshold.py deleted file mode 100644 index 6385dc5..0000000 --- a/src/span_panel_api/generated_client/models/nice_to_have_threshold.py +++ /dev/null @@ -1,87 +0,0 @@ -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, Union - -from attrs import define as _attrs_define, field as _attrs_field - -from ..types import UNSET, Unset - -if TYPE_CHECKING: - from ..models.state_of_energy import StateOfEnergy - - -T = TypeVar("T", bound="NiceToHaveThreshold") - - -@_attrs_define -class NiceToHaveThreshold: - """ - Attributes: - nice_to_have_threshold_low_soe (Union[Unset, StateOfEnergy]): - nice_to_have_threshold_high_soe (Union[Unset, StateOfEnergy]): - """ - - nice_to_have_threshold_low_soe: Union[Unset, "StateOfEnergy"] = UNSET - nice_to_have_threshold_high_soe: Union[Unset, "StateOfEnergy"] = UNSET - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - nice_to_have_threshold_low_soe: Unset | dict[str, Any] = UNSET - if not isinstance(self.nice_to_have_threshold_low_soe, Unset): - nice_to_have_threshold_low_soe = self.nice_to_have_threshold_low_soe.to_dict() - - nice_to_have_threshold_high_soe: Unset | dict[str, Any] = UNSET - if not isinstance(self.nice_to_have_threshold_high_soe, Unset): - nice_to_have_threshold_high_soe = self.nice_to_have_threshold_high_soe.to_dict() - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update({}) - if nice_to_have_threshold_low_soe is not UNSET: - field_dict["nice_to_have_threshold_low_soe"] = nice_to_have_threshold_low_soe - if nice_to_have_threshold_high_soe is not UNSET: - field_dict["nice_to_have_threshold_high_soe"] = nice_to_have_threshold_high_soe - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.state_of_energy import StateOfEnergy - - d = dict(src_dict) - _nice_to_have_threshold_low_soe = d.pop("nice_to_have_threshold_low_soe", UNSET) - nice_to_have_threshold_low_soe: Unset | StateOfEnergy - if isinstance(_nice_to_have_threshold_low_soe, Unset): - nice_to_have_threshold_low_soe = UNSET - else: - nice_to_have_threshold_low_soe = StateOfEnergy.from_dict(_nice_to_have_threshold_low_soe) - - _nice_to_have_threshold_high_soe = d.pop("nice_to_have_threshold_high_soe", UNSET) - nice_to_have_threshold_high_soe: Unset | StateOfEnergy - if isinstance(_nice_to_have_threshold_high_soe, Unset): - nice_to_have_threshold_high_soe = UNSET - else: - nice_to_have_threshold_high_soe = StateOfEnergy.from_dict(_nice_to_have_threshold_high_soe) - - nice_to_have_threshold = cls( - nice_to_have_threshold_low_soe=nice_to_have_threshold_low_soe, - nice_to_have_threshold_high_soe=nice_to_have_threshold_high_soe, - ) - - nice_to_have_threshold.additional_properties = d - return nice_to_have_threshold - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/panel_meter.py b/src/span_panel_api/generated_client/models/panel_meter.py deleted file mode 100644 index d8adb97..0000000 --- a/src/span_panel_api/generated_client/models/panel_meter.py +++ /dev/null @@ -1,74 +0,0 @@ -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -if TYPE_CHECKING: - from ..models.feedthrough_energy import FeedthroughEnergy - from ..models.main_meter_energy import MainMeterEnergy - - -T = TypeVar("T", bound="PanelMeter") - - -@_attrs_define -class PanelMeter: - """ - Attributes: - main_meter (MainMeterEnergy): - feedthrough (FeedthroughEnergy): - """ - - main_meter: "MainMeterEnergy" - feedthrough: "FeedthroughEnergy" - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - main_meter = self.main_meter.to_dict() - - feedthrough = self.feedthrough.to_dict() - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "mainMeter": main_meter, - "feedthrough": feedthrough, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.feedthrough_energy import FeedthroughEnergy - from ..models.main_meter_energy import MainMeterEnergy - - d = dict(src_dict) - main_meter = MainMeterEnergy.from_dict(d.pop("mainMeter")) - - feedthrough = FeedthroughEnergy.from_dict(d.pop("feedthrough")) - - panel_meter = cls( - main_meter=main_meter, - feedthrough=feedthrough, - ) - - panel_meter.additional_properties = d - return panel_meter - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/panel_power.py b/src/span_panel_api/generated_client/models/panel_power.py deleted file mode 100644 index cb13cfe..0000000 --- a/src/span_panel_api/generated_client/models/panel_power.py +++ /dev/null @@ -1,66 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -T = TypeVar("T", bound="PanelPower") - - -@_attrs_define -class PanelPower: - """ - Attributes: - instant_grid_power_w (float): - feedthrough_power_w (float): - """ - - instant_grid_power_w: float - feedthrough_power_w: float - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - instant_grid_power_w = self.instant_grid_power_w - - feedthrough_power_w = self.feedthrough_power_w - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "instantGridPowerW": instant_grid_power_w, - "feedthroughPowerW": feedthrough_power_w, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - instant_grid_power_w = d.pop("instantGridPowerW") - - feedthrough_power_w = d.pop("feedthroughPowerW") - - panel_power = cls( - instant_grid_power_w=instant_grid_power_w, - feedthrough_power_w=feedthrough_power_w, - ) - - panel_power.additional_properties = d - return panel_power - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/panel_state.py b/src/span_panel_api/generated_client/models/panel_state.py deleted file mode 100644 index 1a20dff..0000000 --- a/src/span_panel_api/generated_client/models/panel_state.py +++ /dev/null @@ -1,158 +0,0 @@ -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -from ..models.relay_state import RelayState - -if TYPE_CHECKING: - from ..models.branch import Branch - from ..models.feedthrough_energy import FeedthroughEnergy - from ..models.main_meter_energy import MainMeterEnergy - - -T = TypeVar("T", bound="PanelState") - - -@_attrs_define -class PanelState: - """ - Attributes: - main_relay_state (RelayState): An enumeration. - main_meter_energy (MainMeterEnergy): - instant_grid_power_w (float): - feedthrough_power_w (float): - feedthrough_energy (FeedthroughEnergy): - grid_sample_start_ms (int): - grid_sample_end_ms (int): - dsm_grid_state (str): - dsm_state (str): - current_run_config (str): - branches (list['Branch']): - """ - - main_relay_state: RelayState - main_meter_energy: "MainMeterEnergy" - instant_grid_power_w: float - feedthrough_power_w: float - feedthrough_energy: "FeedthroughEnergy" - grid_sample_start_ms: int - grid_sample_end_ms: int - dsm_grid_state: str - dsm_state: str - current_run_config: str - branches: list["Branch"] - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - main_relay_state = self.main_relay_state.value - - main_meter_energy = self.main_meter_energy.to_dict() - - instant_grid_power_w = self.instant_grid_power_w - - feedthrough_power_w = self.feedthrough_power_w - - feedthrough_energy = self.feedthrough_energy.to_dict() - - grid_sample_start_ms = self.grid_sample_start_ms - - grid_sample_end_ms = self.grid_sample_end_ms - - dsm_grid_state = self.dsm_grid_state - - dsm_state = self.dsm_state - - current_run_config = self.current_run_config - - branches = [] - for branches_item_data in self.branches: - branches_item = branches_item_data.to_dict() - branches.append(branches_item) - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "mainRelayState": main_relay_state, - "mainMeterEnergy": main_meter_energy, - "instantGridPowerW": instant_grid_power_w, - "feedthroughPowerW": feedthrough_power_w, - "feedthroughEnergy": feedthrough_energy, - "gridSampleStartMs": grid_sample_start_ms, - "gridSampleEndMs": grid_sample_end_ms, - "dsmGridState": dsm_grid_state, - "dsmState": dsm_state, - "currentRunConfig": current_run_config, - "branches": branches, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.branch import Branch - from ..models.feedthrough_energy import FeedthroughEnergy - from ..models.main_meter_energy import MainMeterEnergy - - d = dict(src_dict) - main_relay_state = RelayState(d.pop("mainRelayState")) - - main_meter_energy = MainMeterEnergy.from_dict(d.pop("mainMeterEnergy")) - - instant_grid_power_w = d.pop("instantGridPowerW") - - feedthrough_power_w = d.pop("feedthroughPowerW") - - feedthrough_energy = FeedthroughEnergy.from_dict(d.pop("feedthroughEnergy")) - - grid_sample_start_ms = d.pop("gridSampleStartMs") - - grid_sample_end_ms = d.pop("gridSampleEndMs") - - dsm_grid_state = d.pop("dsmGridState") - - dsm_state = d.pop("dsmState") - - current_run_config = d.pop("currentRunConfig") - - branches = [] - _branches = d.pop("branches") - for branches_item_data in _branches: - branches_item = Branch.from_dict(branches_item_data) - - branches.append(branches_item) - - panel_state = cls( - main_relay_state=main_relay_state, - main_meter_energy=main_meter_energy, - instant_grid_power_w=instant_grid_power_w, - feedthrough_power_w=feedthrough_power_w, - feedthrough_energy=feedthrough_energy, - grid_sample_start_ms=grid_sample_start_ms, - grid_sample_end_ms=grid_sample_end_ms, - dsm_grid_state=dsm_grid_state, - dsm_state=dsm_state, - current_run_config=current_run_config, - branches=branches, - ) - - panel_state.additional_properties = d - return panel_state - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/priority.py b/src/span_panel_api/generated_client/models/priority.py deleted file mode 100644 index 135726e..0000000 --- a/src/span_panel_api/generated_client/models/priority.py +++ /dev/null @@ -1,11 +0,0 @@ -from enum import Enum - - -class Priority(str, Enum): - MUST_HAVE = "MUST_HAVE" - NICE_TO_HAVE = "NICE_TO_HAVE" - NON_ESSENTIAL = "NON_ESSENTIAL" - UNKNOWN = "UNKNOWN" - - def __str__(self) -> str: - return str(self.value) diff --git a/src/span_panel_api/generated_client/models/priority_in.py b/src/span_panel_api/generated_client/models/priority_in.py deleted file mode 100644 index 9a5752b..0000000 --- a/src/span_panel_api/generated_client/models/priority_in.py +++ /dev/null @@ -1,60 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -from ..models.priority import Priority - -T = TypeVar("T", bound="PriorityIn") - - -@_attrs_define -class PriorityIn: - """ - Attributes: - priority (Priority): An enumeration. - """ - - priority: Priority - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - priority = self.priority.value - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "priority": priority, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - priority = Priority(d.pop("priority")) - - priority_in = cls( - priority=priority, - ) - - priority_in.additional_properties = d - return priority_in - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/relay_state.py b/src/span_panel_api/generated_client/models/relay_state.py deleted file mode 100644 index 9df4d1f..0000000 --- a/src/span_panel_api/generated_client/models/relay_state.py +++ /dev/null @@ -1,10 +0,0 @@ -from enum import Enum - - -class RelayState(str, Enum): - CLOSED = "CLOSED" - OPEN = "OPEN" - UNKNOWN = "UNKNOWN" - - def __str__(self) -> str: - return str(self.value) diff --git a/src/span_panel_api/generated_client/models/relay_state_in.py b/src/span_panel_api/generated_client/models/relay_state_in.py deleted file mode 100644 index b0dd1bb..0000000 --- a/src/span_panel_api/generated_client/models/relay_state_in.py +++ /dev/null @@ -1,60 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -from ..models.relay_state import RelayState - -T = TypeVar("T", bound="RelayStateIn") - - -@_attrs_define -class RelayStateIn: - """ - Attributes: - relay_state (RelayState): An enumeration. - """ - - relay_state: RelayState - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - relay_state = self.relay_state.value - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "relayState": relay_state, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - relay_state = RelayState(d.pop("relayState")) - - relay_state_in = cls( - relay_state=relay_state, - ) - - relay_state_in.additional_properties = d - return relay_state_in - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/relay_state_out.py b/src/span_panel_api/generated_client/models/relay_state_out.py deleted file mode 100644 index 72a007f..0000000 --- a/src/span_panel_api/generated_client/models/relay_state_out.py +++ /dev/null @@ -1,58 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -T = TypeVar("T", bound="RelayStateOut") - - -@_attrs_define -class RelayStateOut: - """ - Attributes: - relay_state (str): - """ - - relay_state: str - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - relay_state = self.relay_state - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "relayState": relay_state, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - relay_state = d.pop("relayState") - - relay_state_out = cls( - relay_state=relay_state, - ) - - relay_state_out.additional_properties = d - return relay_state_out - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/software_status.py b/src/span_panel_api/generated_client/models/software_status.py deleted file mode 100644 index 2ce89ec..0000000 --- a/src/span_panel_api/generated_client/models/software_status.py +++ /dev/null @@ -1,74 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -T = TypeVar("T", bound="SoftwareStatus") - - -@_attrs_define -class SoftwareStatus: - """ - Attributes: - firmware_version (str): - update_status (str): - env (str): - """ - - firmware_version: str - update_status: str - env: str - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - firmware_version = self.firmware_version - - update_status = self.update_status - - env = self.env - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "firmwareVersion": firmware_version, - "updateStatus": update_status, - "env": env, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - firmware_version = d.pop("firmwareVersion") - - update_status = d.pop("updateStatus") - - env = d.pop("env") - - software_status = cls( - firmware_version=firmware_version, - update_status=update_status, - env=env, - ) - - software_status.additional_properties = d - return software_status - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/state_of_energy.py b/src/span_panel_api/generated_client/models/state_of_energy.py deleted file mode 100644 index 3d2f800..0000000 --- a/src/span_panel_api/generated_client/models/state_of_energy.py +++ /dev/null @@ -1,58 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -from ..types import UNSET, Unset - -T = TypeVar("T", bound="StateOfEnergy") - - -@_attrs_define -class StateOfEnergy: - """ - Attributes: - percentage (Union[Unset, int]): - """ - - percentage: Unset | int = UNSET - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - percentage = self.percentage - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update({}) - if percentage is not UNSET: - field_dict["percentage"] = percentage - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - percentage = d.pop("percentage", UNSET) - - state_of_energy = cls( - percentage=percentage, - ) - - state_of_energy.additional_properties = d - return state_of_energy - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/status_out.py b/src/span_panel_api/generated_client/models/status_out.py deleted file mode 100644 index 014b439..0000000 --- a/src/span_panel_api/generated_client/models/status_out.py +++ /dev/null @@ -1,84 +0,0 @@ -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -if TYPE_CHECKING: - from ..models.network_status import NetworkStatus - from ..models.software_status import SoftwareStatus - from ..models.system_status import SystemStatus - - -T = TypeVar("T", bound="StatusOut") - - -@_attrs_define -class StatusOut: - """ - Attributes: - software (SoftwareStatus): - system (SystemStatus): - network (NetworkStatus): - """ - - software: "SoftwareStatus" - system: "SystemStatus" - network: "NetworkStatus" - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - software = self.software.to_dict() - - system = self.system.to_dict() - - network = self.network.to_dict() - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "software": software, - "system": system, - "network": network, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.network_status import NetworkStatus - from ..models.software_status import SoftwareStatus - from ..models.system_status import SystemStatus - - d = dict(src_dict) - software = SoftwareStatus.from_dict(d.pop("software")) - - system = SystemStatus.from_dict(d.pop("system")) - - network = NetworkStatus.from_dict(d.pop("network")) - - status_out = cls( - software=software, - system=system, - network=network, - ) - - status_out.additional_properties = d - return status_out - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/system_status.py b/src/span_panel_api/generated_client/models/system_status.py deleted file mode 100644 index 703db7b..0000000 --- a/src/span_panel_api/generated_client/models/system_status.py +++ /dev/null @@ -1,100 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -from ..models.door_state import DoorState - -T = TypeVar("T", bound="SystemStatus") - - -@_attrs_define -class SystemStatus: - """ - Attributes: - manufacturer (str): - serial (str): - model (str): - door_state (DoorState): An enumeration. - proximity_proven (bool): - uptime (int): - """ - - manufacturer: str - serial: str - model: str - door_state: DoorState - proximity_proven: bool - uptime: int - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - manufacturer = self.manufacturer - - serial = self.serial - - model = self.model - - door_state = self.door_state.value - - proximity_proven = self.proximity_proven - - uptime = self.uptime - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "manufacturer": manufacturer, - "serial": serial, - "model": model, - "doorState": door_state, - "proximityProven": proximity_proven, - "uptime": uptime, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - manufacturer = d.pop("manufacturer") - - serial = d.pop("serial") - - model = d.pop("model") - - door_state = DoorState(d.pop("doorState")) - - proximity_proven = d.pop("proximityProven") - - uptime = d.pop("uptime") - - system_status = cls( - manufacturer=manufacturer, - serial=serial, - model=model, - door_state=door_state, - proximity_proven=proximity_proven, - uptime=uptime, - ) - - system_status.additional_properties = d - return system_status - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/validation_error.py b/src/span_panel_api/generated_client/models/validation_error.py deleted file mode 100644 index 0711a36..0000000 --- a/src/span_panel_api/generated_client/models/validation_error.py +++ /dev/null @@ -1,74 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar, cast - -from attrs import define as _attrs_define, field as _attrs_field - -T = TypeVar("T", bound="ValidationError") - - -@_attrs_define -class ValidationError: - """ - Attributes: - loc (list[str]): - msg (str): - type_ (str): - """ - - loc: list[str] - msg: str - type_: str - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - loc = self.loc - - msg = self.msg - - type_ = self.type_ - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "loc": loc, - "msg": msg, - "type": type_, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - loc = cast(list[str], d.pop("loc")) - - msg = d.pop("msg") - - type_ = d.pop("type") - - validation_error = cls( - loc=loc, - msg=msg, - type_=type_, - ) - - validation_error.additional_properties = d - return validation_error - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/wifi_access_point.py b/src/span_panel_api/generated_client/models/wifi_access_point.py deleted file mode 100644 index 67ea826..0000000 --- a/src/span_panel_api/generated_client/models/wifi_access_point.py +++ /dev/null @@ -1,109 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -from ..types import UNSET, Unset - -T = TypeVar("T", bound="WifiAccessPoint") - - -@_attrs_define -class WifiAccessPoint: - """ - Attributes: - bssid (str): - ssid (str): - signal (int): - frequency (str): - encrypted (bool): - connected (bool): - error (Union[Unset, str]): Default: ''. - """ - - bssid: str - ssid: str - signal: int - frequency: str - encrypted: bool - connected: bool - error: Unset | str = "" - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - bssid = self.bssid - - ssid = self.ssid - - signal = self.signal - - frequency = self.frequency - - encrypted = self.encrypted - - connected = self.connected - - error = self.error - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "bssid": bssid, - "ssid": ssid, - "signal": signal, - "frequency": frequency, - "encrypted": encrypted, - "connected": connected, - } - ) - if error is not UNSET: - field_dict["error"] = error - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - bssid = d.pop("bssid") - - ssid = d.pop("ssid") - - signal = d.pop("signal") - - frequency = d.pop("frequency") - - encrypted = d.pop("encrypted") - - connected = d.pop("connected") - - error = d.pop("error", UNSET) - - wifi_access_point = cls( - bssid=bssid, - ssid=ssid, - signal=signal, - frequency=frequency, - encrypted=encrypted, - connected=connected, - error=error, - ) - - wifi_access_point.additional_properties = d - return wifi_access_point - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/wifi_connect_in.py b/src/span_panel_api/generated_client/models/wifi_connect_in.py deleted file mode 100644 index b4ef21f..0000000 --- a/src/span_panel_api/generated_client/models/wifi_connect_in.py +++ /dev/null @@ -1,66 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -T = TypeVar("T", bound="WifiConnectIn") - - -@_attrs_define -class WifiConnectIn: - """ - Attributes: - ssid (str): - psk (str): - """ - - ssid: str - psk: str - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - ssid = self.ssid - - psk = self.psk - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "ssid": ssid, - "psk": psk, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - ssid = d.pop("ssid") - - psk = d.pop("psk") - - wifi_connect_in = cls( - ssid=ssid, - psk=psk, - ) - - wifi_connect_in.additional_properties = d - return wifi_connect_in - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/wifi_connect_out.py b/src/span_panel_api/generated_client/models/wifi_connect_out.py deleted file mode 100644 index 588f1eb..0000000 --- a/src/span_panel_api/generated_client/models/wifi_connect_out.py +++ /dev/null @@ -1,98 +0,0 @@ -from collections.abc import Mapping -from typing import Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -T = TypeVar("T", bound="WifiConnectOut") - - -@_attrs_define -class WifiConnectOut: - """ - Attributes: - bssid (str): - ssid (str): - signal (int): - encrypted (bool): - connected (bool): - error (str): - """ - - bssid: str - ssid: str - signal: int - encrypted: bool - connected: bool - error: str - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - bssid = self.bssid - - ssid = self.ssid - - signal = self.signal - - encrypted = self.encrypted - - connected = self.connected - - error = self.error - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "bssid": bssid, - "ssid": ssid, - "signal": signal, - "encrypted": encrypted, - "connected": connected, - "error": error, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - d = dict(src_dict) - bssid = d.pop("bssid") - - ssid = d.pop("ssid") - - signal = d.pop("signal") - - encrypted = d.pop("encrypted") - - connected = d.pop("connected") - - error = d.pop("error") - - wifi_connect_out = cls( - bssid=bssid, - ssid=ssid, - signal=signal, - encrypted=encrypted, - connected=connected, - error=error, - ) - - wifi_connect_out.additional_properties = d - return wifi_connect_out - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/models/wifi_scan_out.py b/src/span_panel_api/generated_client/models/wifi_scan_out.py deleted file mode 100644 index e3f9cc3..0000000 --- a/src/span_panel_api/generated_client/models/wifi_scan_out.py +++ /dev/null @@ -1,72 +0,0 @@ -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar - -from attrs import define as _attrs_define, field as _attrs_field - -if TYPE_CHECKING: - from ..models.wifi_access_point import WifiAccessPoint - - -T = TypeVar("T", bound="WifiScanOut") - - -@_attrs_define -class WifiScanOut: - """ - Attributes: - access_points (list['WifiAccessPoint']): - """ - - access_points: list["WifiAccessPoint"] - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) - - def to_dict(self) -> dict[str, Any]: - access_points = [] - for access_points_item_data in self.access_points: - access_points_item = access_points_item_data.to_dict() - access_points.append(access_points_item) - - field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) - field_dict.update( - { - "accessPoints": access_points, - } - ) - - return field_dict - - @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: - from ..models.wifi_access_point import WifiAccessPoint - - d = dict(src_dict) - access_points = [] - _access_points = d.pop("accessPoints") - for access_points_item_data in _access_points: - access_points_item = WifiAccessPoint.from_dict(access_points_item_data) - - access_points.append(access_points_item) - - wifi_scan_out = cls( - access_points=access_points, - ) - - wifi_scan_out.additional_properties = d - return wifi_scan_out - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/src/span_panel_api/generated_client/types.py b/src/span_panel_api/generated_client/types.py deleted file mode 100644 index 67a552b..0000000 --- a/src/span_panel_api/generated_client/types.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Contains some shared types for properties""" - -from collections.abc import Mapping, MutableMapping -from http import HTTPStatus -from typing import IO, BinaryIO, Generic, Literal, TypeVar, Union - -from attrs import define - - -class Unset: - def __bool__(self) -> Literal[False]: - return False - - -UNSET: Unset = Unset() - -# The types that `httpx.Client(files=)` can accept, copied from that library. -FileContent = Union[IO[bytes], bytes, str] -FileTypes = Union[ - # (filename, file (or bytes), content_type) - tuple[str | None, FileContent, str | None], - # (filename, file (or bytes), content_type, headers) - tuple[str | None, FileContent, str | None, Mapping[str, str]], -] -RequestFiles = list[tuple[str, FileTypes]] - - -@define -class File: - """Contains information for file uploads""" - - payload: BinaryIO - file_name: str | None = None - mime_type: str | None = None - - def to_tuple(self) -> FileTypes: - """Return a tuple representation that httpx will accept for multipart/form-data""" - return self.file_name, self.payload, self.mime_type - - -T = TypeVar("T") - - -@define -class Response(Generic[T]): - """A response from an endpoint""" - - status_code: HTTPStatus - content: bytes - headers: MutableMapping[str, str] - parsed: T | None - - -__all__ = ["UNSET", "File", "FileTypes", "RequestFiles", "Response", "Unset"] diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py new file mode 100644 index 0000000..aca0401 --- /dev/null +++ b/src/span_panel_api/models.py @@ -0,0 +1,157 @@ +"""Transport-agnostic snapshot models for SPAN Panel state. + +These dataclasses represent panel state regardless of how it was obtained +(REST polling or MQTT push). Energy and power sign conventions are +normalized at the transport boundary — consumers see a consistent view. + +All snapshots are immutable (frozen) and memory-efficient (slots). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class SpanCircuitSnapshot: + """Transport-agnostic circuit state.""" + + circuit_id: str # UUID (dashless, normalized) + name: str + relay_state: str # OPEN | CLOSED | UNKNOWN + instant_power_w: float # Positive = consumption + produced_energy_wh: float # Generation/backfeed (Wh) + consumed_energy_wh: float # Consumption (Wh) + tabs: list[int] + priority: str # v1: MUST_HAVE | NICE_TO_HAVE | NON_ESSENTIAL | UNKNOWN + # v2: NEVER | SOC_THRESHOLD | OFF_GRID | UNKNOWN + is_user_controllable: bool # v1: Circuit.isUserControllable | v2: not always_on + is_sheddable: bool # v1: Circuit.isSheddable | v2: circuit/sheddable + is_never_backup: bool # v1: Circuit.isNeverBackup | v2: circuit/never-backup + device_type: str = "circuit" # "circuit" | "pv" | "evse" + relative_position: str = "" # PV/EVSE: "IN_PANEL" | "UPSTREAM" | "DOWNSTREAM" + is_240v: bool = False + current_a: float | None = None + breaker_rating_a: float | None = None + always_on: bool = False # v2 new: circuit/always-on + relay_requester: str = "UNKNOWN" # v2 new: circuit/relay-requester + energy_accum_update_time_s: int = 0 # v1: poll timestamp | v2: MQTT arrival time + instant_power_update_time_s: int = 0 # v1: poll timestamp | v2: MQTT arrival time + + +@dataclass(frozen=True, slots=True) +class SpanPVSnapshot: + """PV inverter metadata — populated only when a PV node is commissioned.""" + + vendor_name: str | None = None # pv/vendor-name + product_name: str | None = None # pv/product-name + nameplate_capacity_kw: float | None = None # pv/nameplate-capacity (kW) + + +@dataclass(frozen=True, slots=True) +class SpanBatterySnapshot: + """Battery state — populated only when BESS node is commissioned.""" + + soe_percentage: float | None = None + # Note: field name is historically misnamed (soe = kWh, soc = %). + # Name is preserved to avoid entity/dashboard breaks in the integration. + soe_kwh: float | None = None # bess/soe (kWh) — new v2 field, no v1 equivalent + + # BESS metadata + vendor_name: str | None = None # bess/vendor-name + product_name: str | None = None # bess/product-name + nameplate_capacity_kwh: float | None = None # bess/nameplate-capacity (kWh) + + +@dataclass(frozen=True, slots=True) +class V2AuthResponse: + """Response from POST /api/v2/auth/register.""" + + access_token: str + token_type: str + iat_ms: int + ebus_broker_username: str + ebus_broker_password: str # Use this for MQTT, NOT hop_passphrase + ebus_broker_host: str + ebus_broker_mqtts_port: int + ebus_broker_ws_port: int + ebus_broker_wss_port: int + hostname: str + serial_number: str + hop_passphrase: str # For REST auth only; will diverge from broker password + + +@dataclass(frozen=True, slots=True) +class V2StatusInfo: + """Response from GET /api/v2/status.""" + + serial_number: str + firmware_version: str + + +@dataclass(frozen=True, slots=True) +class V2HomieSchema: + """Response from GET /api/v2/homie/schema.""" + + firmware_version: str + types_schema_hash: str # SHA-256, first 16 hex chars + types: dict[str, dict[str, object]] # {type_name: {prop_name: {attr: value}}} + + +@dataclass(frozen=True, slots=True) +class SpanPanelSnapshot: + """Complete panel state — single point-in-time view.""" + + serial_number: str + firmware_version: str + + # Panel-level power and energy + main_relay_state: str + instant_grid_power_w: float + feedthrough_power_w: float + main_meter_energy_consumed_wh: float + main_meter_energy_produced_wh: float + feedthrough_energy_consumed_wh: float + feedthrough_energy_produced_wh: float + + # v1 field names preserved — MQTT transport derives these from v2 data + dsm_grid_state: str # v1: direct | v2: multi-signal heuristic + current_run_config: str # v1: direct | v2: tri-state from grid_state + islandable + DPS + + # Hardware status — v1 field names preserved + door_state: str # v1: direct | v2: core/door + proximity_proven: bool # v1: proximity sensor | v2: MQTT auth + $state==ready + uptime_s: int # v1: panel uptime | v2: connection uptime since $state==ready + eth0_link: bool # v1: direct | v2: core/ethernet + wlan_link: bool # v1: direct | v2: core/wifi + wwan_link: bool # v1: direct | v2: vendor-cloud == "CONNECTED" + + # v2-native fields — None for REST transport + dominant_power_source: str | None = None # v2: core/dominant-power-source (settable) + grid_state: str | None = None # v2: bess/grid-state (None = no BESS or REST) + grid_islandable: bool | None = None # v2: core/grid-islandable + l1_voltage: float | None = None # v2: core/l1-voltage (V) + l2_voltage: float | None = None # v2: core/l2-voltage (V) + main_breaker_rating_a: int | None = None # v2: core/breaker-rating (A) + wifi_ssid: str | None = None # v2: core/wifi-ssid + vendor_cloud: str | None = None # v2: core/vendor-cloud + panel_size: int | None = None # v2: core/panel-size (total breaker spaces) + + # Power flows (None when node not present) + power_flow_pv: float | None = None # v2: power-flows/pv (W) + power_flow_battery: float | None = None # v2: power-flows/battery (W) + power_flow_grid: float | None = None # v2: power-flows/grid (W) + power_flow_site: float | None = None # v2: power-flows/site (W) + + # Upstream lugs per-phase current (None when not available) + upstream_l1_current_a: float | None = None # v2: upstream-lugs/l1-current (A) + upstream_l2_current_a: float | None = None # v2: upstream-lugs/l2-current (A) + + # Downstream lugs per-phase current (None when not available) + downstream_l1_current_a: float | None = None # v2: downstream-lugs/l1-current (A) + downstream_l2_current_a: float | None = None # v2: downstream-lugs/l2-current (A) + + # Collections + circuits: dict[str, SpanCircuitSnapshot] = field(default_factory=dict) + battery: SpanBatterySnapshot = field(default_factory=SpanBatterySnapshot) + pv: SpanPVSnapshot = field(default_factory=SpanPVSnapshot) diff --git a/src/span_panel_api/mqtt/__init__.py b/src/span_panel_api/mqtt/__init__.py new file mode 100644 index 0000000..464db69 --- /dev/null +++ b/src/span_panel_api/mqtt/__init__.py @@ -0,0 +1,15 @@ +"""SPAN Panel MQTT/Homie transport.""" + +from .async_client import AsyncMQTTClient +from .client import SpanMqttClient +from .connection import AsyncMqttBridge +from .homie import HomieDeviceConsumer +from .models import MqttClientConfig + +__all__ = [ + "AsyncMQTTClient", + "AsyncMqttBridge", + "HomieDeviceConsumer", + "MqttClientConfig", + "SpanMqttClient", +] diff --git a/src/span_panel_api/mqtt/async_client.py b/src/span_panel_api/mqtt/async_client.py new file mode 100644 index 0000000..ee6455b --- /dev/null +++ b/src/span_panel_api/mqtt/async_client.py @@ -0,0 +1,67 @@ +"""Async MQTT client — NullLock + AsyncMQTTClient. + +Replicates the pattern from Home Assistant core's MQTT integration: +paho-mqtt's 7 internal threading locks are replaced with no-op NullLock +instances so the client can run entirely on a single asyncio event loop +thread without contention overhead. +""" + +from __future__ import annotations + +from types import TracebackType + +from paho.mqtt.client import Client as MQTTClient + +_PAHO_LOCK_ATTRS = ( + "_in_callback_mutex", + "_callback_mutex", + "_msgtime_mutex", + "_out_message_mutex", + "_in_message_mutex", + "_reconnect_delay_mutex", + "_mid_generate_mutex", +) + + +class NullLock: + """No-op lock for single-threaded event loop execution. + + Replaces threading.Lock in paho-mqtt's internals. All methods are + trivial no-ops since the client runs on a single event loop thread. + """ + + def __enter__(self) -> NullLock: + """Enter the lock (no-op).""" + return self + + def __exit__( + self, + _exc_type: type[BaseException] | None, + _exc_value: BaseException | None, + _traceback: TracebackType | None, + ) -> None: + """Exit the lock (no-op).""" + + def acquire(self, _blocking: bool = False, _timeout: int = -1) -> None: + """Acquire the lock (no-op).""" + + def release(self) -> None: + """Release the lock (no-op).""" + + +class AsyncMQTTClient(MQTTClient): + """paho Client subclass with NullLock replacing all internal locks. + + Wrapper around paho.mqtt.client.Client to remove the threading + locks that are not needed when running in an async event loop. + Call ``setup()`` immediately after construction. + """ + + def setup(self) -> None: + """Replace paho's 7 internal threading locks with NullLock. + + Must be called before any I/O. The client will then be safe to + drive exclusively from the event loop thread. + """ + for attr in _PAHO_LOCK_ATTRS: + setattr(self, attr, NullLock()) diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py new file mode 100644 index 0000000..3e1a21c --- /dev/null +++ b/src/span_panel_api/mqtt/client.py @@ -0,0 +1,306 @@ +"""SPAN Panel MQTT client. + +Composes AsyncMqttBridge and HomieDeviceConsumer to implement +SpanPanelClientProtocol, CircuitControlProtocol, and +StreamingCapableProtocol. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +import logging + +from ..exceptions import SpanPanelConnectionError +from ..models import SpanPanelSnapshot +from ..protocol import PanelCapability +from .connection import AsyncMqttBridge +from .const import MQTT_READY_TIMEOUT_S, PROPERTY_SET_TOPIC_FMT, WILDCARD_TOPIC_FMT +from .homie import HomieDeviceConsumer +from .models import MqttClientConfig + +_LOGGER = logging.getLogger(__name__) + +# How long to wait for circuit name properties after device ready. +# Retained messages typically arrive within 1-2s, but allow headroom. +_CIRCUIT_NAMES_TIMEOUT_S = 10.0 +_CIRCUIT_NAMES_POLL_INTERVAL_S = 0.25 + + +class SpanMqttClient: + """MQTT transport — implements all span-panel-api protocols.""" + + def __init__( + self, + host: str, + serial_number: str, + broker_config: MqttClientConfig, + snapshot_interval: float = 1.0, + ) -> None: + self._host = host + self._serial_number = serial_number + self._broker_config = broker_config + self._snapshot_interval = snapshot_interval + + self._bridge: AsyncMqttBridge | None = None + self._homie = HomieDeviceConsumer(serial_number) + self._streaming = False + self._snapshot_callbacks: list[Callable[[SpanPanelSnapshot], Awaitable[None]]] = [] + self._ready_event: asyncio.Event | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._background_tasks: set[asyncio.Task[None]] = set() + self._snapshot_timer: asyncio.TimerHandle | None = None + + # -- SpanPanelClientProtocol ------------------------------------------- + + @property + def capabilities(self) -> PanelCapability: + """Advertise MQTT transport capabilities.""" + return ( + PanelCapability.EBUS_MQTT + | PanelCapability.PUSH_STREAMING + | PanelCapability.CIRCUIT_CONTROL + | PanelCapability.BATTERY_SOE + ) + + @property + def serial_number(self) -> str: + """Return the panel serial number.""" + return self._serial_number + + async def connect(self) -> None: + """Connect to MQTT broker and wait for Homie device ready. + + Flow: + 1. Create AsyncMqttBridge with broker credentials + 2. Connect to MQTT broker + 3. Subscribe to ebus/5/{serial}/# + 4. Wait for $state==ready and $description parsed + + Raises: + SpanPanelConnectionError: Cannot connect or device not ready + SpanPanelTimeoutError: Connection or ready timed out + """ + self._loop = asyncio.get_running_loop() + self._ready_event = asyncio.Event() + + _LOGGER.debug( + "MQTT: Creating bridge to %s:%s (serial=%s)", + self._broker_config.broker_host, + self._broker_config.effective_port, + self._serial_number, + ) + + self._bridge = AsyncMqttBridge( + host=self._broker_config.broker_host, + port=self._broker_config.effective_port, + username=self._broker_config.username, + password=self._broker_config.password, + panel_host=self._host, + serial_number=self._serial_number, + transport=self._broker_config.transport, + use_tls=self._broker_config.use_tls, + loop=self._loop, + ) + + # Wire message handler + self._bridge.set_message_callback(self._on_message) + self._bridge.set_connection_callback(self._on_connection_change) + + # Connect to broker + _LOGGER.debug("MQTT: Connecting to broker...") + await self._bridge.connect() + _LOGGER.debug("MQTT: Broker connected, subscribing...") + + # Subscribe to all device topics + wildcard = WILDCARD_TOPIC_FMT.format(serial=self._serial_number) + self._bridge.subscribe(wildcard, qos=0) + _LOGGER.debug("MQTT: Subscribed to %s, waiting for Homie ready...", wildcard) + + # Wait for Homie ready state + try: + await asyncio.wait_for(self._ready_event.wait(), timeout=MQTT_READY_TIMEOUT_S) + except asyncio.TimeoutError as exc: + await self.close() + raise SpanPanelConnectionError(f"Timed out waiting for Homie device ready ({self._serial_number})") from exc + + _LOGGER.debug("MQTT: Homie device ready, waiting for circuit names...") + + # Wait for circuit name properties to arrive (retained messages + # may arrive after $state=ready). Without this, the first snapshot + # has empty circuit names and entities are created without labels. + await self._wait_for_circuit_names(timeout=_CIRCUIT_NAMES_TIMEOUT_S) + _LOGGER.debug("MQTT: Connection fully established") + + async def close(self) -> None: + """Disconnect from broker and clean up.""" + self._streaming = False + self._cancel_snapshot_timer() + for task in self._background_tasks: + task.cancel() + self._background_tasks.clear() + if self._bridge is not None: + await self._bridge.disconnect() + self._bridge = None + + async def ping(self) -> bool: + """Check if MQTT connection is alive and device is ready.""" + if self._bridge is None: + return False + return self._bridge.is_connected() and self._homie.is_ready() + + async def get_snapshot(self) -> SpanPanelSnapshot: + """Return current snapshot from accumulated MQTT state. + + No network call — snapshot is built from in-memory property values. + """ + return self._homie.build_snapshot() + + # -- CircuitControlProtocol -------------------------------------------- + + async def set_circuit_relay(self, circuit_id: str, state: str) -> None: + """Publish relay state change for a circuit. + + Args: + circuit_id: Dashless UUID (matches wire format) + state: "OPEN" or "CLOSED" + """ + topic = PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=circuit_id, prop="relay") + if self._bridge is not None: + self._bridge.publish(topic, state, qos=1) + + async def set_circuit_priority(self, circuit_id: str, priority: str) -> None: + """Publish shed-priority change for a circuit. + + Args: + circuit_id: Dashless UUID (matches wire format) + priority: v2 enum value (NEVER, SOC_THRESHOLD, OFF_GRID) + """ + topic = PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=circuit_id, prop="shed-priority") + if self._bridge is not None: + self._bridge.publish(topic, priority, qos=1) + + # -- StreamingCapableProtocol ------------------------------------------ + + def register_snapshot_callback( + self, + callback: Callable[[SpanPanelSnapshot], Awaitable[None]], + ) -> Callable[[], None]: + """Register a callback to receive snapshot updates. + + Returns an unregister function. + """ + self._snapshot_callbacks.append(callback) + + def unregister() -> None: + try: + self._snapshot_callbacks.remove(callback) + except ValueError: + _LOGGER.debug("Snapshot callback already unregistered") + + return unregister + + async def start_streaming(self) -> None: + """Enable snapshot callback dispatch on property changes.""" + self._streaming = True + + async def stop_streaming(self) -> None: + """Disable snapshot callback dispatch.""" + self._streaming = False + self._cancel_snapshot_timer() + + # -- Internal callbacks ------------------------------------------------ + + def _on_message(self, topic: str, payload: str) -> None: + """Handle incoming MQTT message (called from asyncio loop).""" + was_ready = self._homie.is_ready() + self._homie.handle_message(topic, payload) + + # Check if device just became ready + if not was_ready and self._homie.is_ready() and self._ready_event is not None: + self._ready_event.set() + + # Dispatch snapshot callbacks if streaming + if self._streaming and self._homie.is_ready() and self._loop is not None: + if self._snapshot_interval <= 0: + # No debounce — dispatch immediately (backward compat) + self._create_dispatch_task() + elif self._snapshot_timer is None: + # Schedule debounced dispatch + self._snapshot_timer = self._loop.call_later(self._snapshot_interval, self._fire_snapshot) + + def _on_connection_change(self, connected: bool) -> None: + """Handle MQTT connection state change (called from asyncio loop).""" + if connected: + _LOGGER.debug("MQTT connection established") + # Re-subscribe on reconnect + if self._bridge is not None: + wildcard = WILDCARD_TOPIC_FMT.format(serial=self._serial_number) + self._bridge.subscribe(wildcard, qos=0) + else: + _LOGGER.debug("MQTT connection lost") + + async def _wait_for_circuit_names(self, timeout: float) -> None: + """Wait for all circuit-like nodes to have a ``name`` property. + + Retained MQTT messages may arrive after the Homie device transitions + to ready. This polls the HomieDeviceConsumer at short intervals and + returns as soon as all circuit names are populated, or when the + timeout elapses (non-fatal — entities will use fallback names). + """ + deadline = asyncio.get_event_loop().time() + timeout + while asyncio.get_event_loop().time() < deadline: + missing = self._homie.circuit_nodes_missing_names() + if not missing: + _LOGGER.debug("All circuit names received") + return + await asyncio.sleep(_CIRCUIT_NAMES_POLL_INTERVAL_S) + + still_missing = self._homie.circuit_nodes_missing_names() + if still_missing: + _LOGGER.warning( + "Timed out waiting for circuit names (%d still missing): %s", + len(still_missing), + still_missing[:5], + ) + + def _create_dispatch_task(self) -> None: + """Create a background task to build and dispatch a snapshot.""" + if self._loop is None: + return + task = self._loop.create_task( + self._dispatch_snapshot(), + name="span_mqtt_dispatch_snapshot", + ) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + def _fire_snapshot(self) -> None: + """Timer callback — clear timer and dispatch one snapshot.""" + self._snapshot_timer = None + self._create_dispatch_task() + + def _cancel_snapshot_timer(self) -> None: + """Cancel any pending debounce timer.""" + if self._snapshot_timer is not None: + self._snapshot_timer.cancel() + self._snapshot_timer = None + + def set_snapshot_interval(self, interval: float) -> None: + """Update the snapshot debounce interval at runtime. + + Args: + interval: Seconds between snapshot dispatches. 0 = no debounce. + """ + self._snapshot_interval = interval + # Cancel any pending timer so the new interval takes effect on next message + self._cancel_snapshot_timer() + + async def _dispatch_snapshot(self) -> None: + """Build snapshot and send to all registered callbacks.""" + snapshot = self._homie.build_snapshot() + for cb in list(self._snapshot_callbacks): + try: + await cb(snapshot) + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.warning("Snapshot callback error", exc_info=True) diff --git a/src/span_panel_api/mqtt/connection.py b/src/span_panel_api/mqtt/connection.py new file mode 100644 index 0000000..7a4617b --- /dev/null +++ b/src/span_panel_api/mqtt/connection.py @@ -0,0 +1,422 @@ +"""Async MQTT bridge — event-loop-driven paho-mqtt wrapper. + +Follows Home Assistant core's async MQTT pattern: +- AsyncMQTTClient replaces paho's internal threading locks with NullLock +- Socket I/O driven by event loop's add_reader/add_writer +- loop_read()/loop_write()/loop_misc() called directly from event loop +- No background threads, no threading.Lock +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from functools import partial +import logging +from pathlib import Path +import ssl +import tempfile +from typing import TYPE_CHECKING + +import paho.mqtt.client as paho +from paho.mqtt.client import ConnectFlags, DisconnectFlags, MQTTMessage +from paho.mqtt.enums import CallbackAPIVersion +from paho.mqtt.properties import Properties +from paho.mqtt.reasoncodes import ReasonCode + +from ..auth import download_ca_cert +from ..exceptions import SpanPanelConnectionError, SpanPanelTimeoutError +from .async_client import AsyncMQTTClient +from .const import ( + MQTT_CONNECT_TIMEOUT_S, + MQTT_KEEPALIVE_S, + MQTT_RECONNECT_BACKOFF_MULTIPLIER, + MQTT_RECONNECT_MAX_DELAY_S, + MQTT_RECONNECT_MIN_DELAY_S, +) +from .models import MqttTransport + +if TYPE_CHECKING: + from paho.mqtt.client import SocketLike + +_LOGGER = logging.getLogger(__name__) + + +class AsyncMqttBridge: + """Event-loop-driven paho-mqtt wrapper with async callback dispatch. + + All paho I/O is driven by the asyncio event loop: socket reads/writes + are registered via add_reader/add_writer, and loop_misc() runs on a + periodic timer. No background threads are used. + """ + + def __init__( + self, + host: str, + port: int, + username: str, + password: str, + panel_host: str, + serial_number: str, + transport: MqttTransport = "tcp", + use_tls: bool = True, + loop: asyncio.AbstractEventLoop | None = None, + ) -> None: + self._host = host + self._port = port + self._username = username + self._password = password + self._panel_host = panel_host + self._serial_number = serial_number + self._transport: MqttTransport = transport + self._use_tls = use_tls + self._loop = loop + + self._connected = False + self._client: AsyncMQTTClient | None = None + self._connect_event: asyncio.Event | None = None + self._ca_cert_path: Path | None = None + + self._misc_timer: asyncio.TimerHandle | None = None + self._should_reconnect = False + self._initial_connect_done = False + self._reconnect_task: asyncio.Task[None] | None = None + + self._message_callback: Callable[[str, str], None] | None = None + self._connection_callback: Callable[[bool], None] | None = None + + def is_connected(self) -> bool: + """Return whether the MQTT client is currently connected.""" + return self._connected + + def set_message_callback(self, callback: Callable[[str, str], None]) -> None: + """Set callback for incoming messages: callback(topic, payload).""" + self._message_callback = callback + + def set_connection_callback(self, callback: Callable[[bool], None]) -> None: + """Set callback for connection state changes: callback(is_connected).""" + self._connection_callback = callback + + async def connect(self) -> None: + """Connect to the MQTT broker. + + Fetches the CA certificate from the panel, configures TLS, + connects via executor (blocking I/O), and waits for CONNACK. + + Raises: + SpanPanelConnectionError: Cannot connect to broker. + SpanPanelTimeoutError: Connection timed out. + """ + if self._loop is None: + self._loop = asyncio.get_running_loop() + + self._connect_event = asyncio.Event() + self._should_reconnect = True + + # Fetch CA cert from panel for TLS + _LOGGER.debug("BRIDGE: Fetching CA cert from %s (use_tls=%s)", self._panel_host, self._use_tls) + ca_cert_path: Path | None = None + if self._use_tls: + try: + pem = await download_ca_cert(self._panel_host) + except Exception as exc: + raise SpanPanelConnectionError(f"Failed to fetch CA certificate from {self._panel_host}") from exc + + # Write PEM to temp file for paho's tls_set() + tmp = tempfile.NamedTemporaryFile( # pylint: disable=consider-using-with # noqa: SIM115 + mode="w", suffix=".pem", delete=False + ) + tmp.write(pem) + tmp.close() + ca_cert_path = Path(tmp.name) + + try: + self._client = AsyncMQTTClient( + callback_api_version=CallbackAPIVersion.VERSION2, + transport=self._transport, + reconnect_on_failure=False, + ) + self._client.setup() + + self._client.username_pw_set(self._username, self._password) + + # Wire socket callbacks (async versions by default) + self._client.on_socket_close = self._async_on_socket_close + self._client.on_socket_unregister_write = self._async_on_socket_unregister_write + + # Wire MQTT callbacks (run directly on event loop — no thread dispatch) + self._client.on_connect = self._on_connect + self._client.on_disconnect = self._on_disconnect + self._client.on_message = self._on_message + + # TLS setup + connect in executor (blocking file I/O for + # load_verify_locations, DNS, TCP, and TLS handshake). + # During executor connect, socket callbacks bridge to the event + # loop via call_soon_threadsafe. + def _blocking_tls_and_connect() -> None: + """Run TLS configuration and connect in executor thread.""" + if self._client is None: + raise RuntimeError("MQTT client not initialised before connect") + if self._use_tls and ca_cert_path is not None: + self._client.tls_set( + ca_certs=str(ca_cert_path), + cert_reqs=ssl.CERT_REQUIRED, + tls_version=ssl.PROTOCOL_TLS_CLIENT, + ) + self._client.connect( + host=self._host, + port=self._port, + keepalive=MQTT_KEEPALIVE_S, + ) + + try: + self._client.on_socket_open = self._on_socket_open_sync + self._client.on_socket_register_write = self._on_socket_register_write_sync + _LOGGER.debug("BRIDGE: Running TLS+connect in executor to %s:%s", self._host, self._port) + await self._loop.run_in_executor(None, _blocking_tls_and_connect) + _LOGGER.debug("BRIDGE: Executor connect returned, waiting for CONNACK...") + finally: + # Switch to async-only socket callbacks now that we are + # back on the event loop thread. + self._client.on_socket_open = self._async_on_socket_open + self._client.on_socket_register_write = self._async_on_socket_register_write + + # Wait for CONNACK + try: + await asyncio.wait_for(self._connect_event.wait(), timeout=MQTT_CONNECT_TIMEOUT_S) + except asyncio.TimeoutError as exc: + await self.disconnect() + raise SpanPanelTimeoutError(f"Timed out connecting to MQTT broker at {self._host}:{self._port}") from exc + + if not self._connected: + raise SpanPanelConnectionError(f"MQTT connection failed to {self._host}:{self._port}") + + self._initial_connect_done = True + # Keep cert alive until disconnect — paho may reference it + self._ca_cert_path = ca_cert_path + + except Exception: + # Clean up temp CA cert file on failure only + if ca_cert_path is not None: + try: + ca_cert_path.unlink() + except OSError: + _LOGGER.debug("Failed to remove temp CA cert file: %s", ca_cert_path) + raise + + async def disconnect(self) -> None: + """Disconnect from the MQTT broker.""" + self._should_reconnect = False + + # Cancel reconnect task + if self._reconnect_task is not None: + self._reconnect_task.cancel() + self._reconnect_task = None + + # Cancel misc timer + if self._misc_timer is not None: + self._misc_timer.cancel() + self._misc_timer = None + + client = self._client + if client is not None: + client.disconnect() + self._connected = False + self._client = None + self._initial_connect_done = False + + if self._ca_cert_path is not None: + try: + self._ca_cert_path.unlink() + except OSError: + _LOGGER.debug("Failed to remove temp CA cert file: %s", self._ca_cert_path) + self._ca_cert_path = None + + def subscribe(self, topic: str, qos: int = 0) -> None: + """Subscribe to a topic. Must be called after connect().""" + if self._client is not None: + self._client.subscribe(topic, qos=qos) + + def publish(self, topic: str, payload: str, qos: int = 1) -> None: + """Publish a message. Must be called after connect().""" + if self._client is not None: + self._client.publish(topic, payload=payload, qos=qos) + + # -- Socket callbacks (event-loop-driven I/O) --------------------------- + + def _async_reader_callback(self, client: paho.Client) -> None: + """Handle reading data from the socket.""" + if (status := client.loop_read()) != 0: + self._async_handle_loop_error(status) + + def _async_writer_callback(self, client: paho.Client) -> None: + """Handle writing data to the socket.""" + if (status := client.loop_write()) != 0: + self._async_handle_loop_error(status) + + def _async_handle_loop_error(self, status: int) -> None: + """Handle a paho loop error.""" + _LOGGER.debug("MQTT loop error: %s", paho.error_string(status)) + + def _async_start_misc_periodic(self) -> None: + """Start the periodic loop_misc() timer (1-second interval).""" + if self._loop is None: + return + loop = self._loop + + def _async_misc() -> None: + if self._client is not None and self._client.loop_misc() == paho.MQTT_ERR_SUCCESS: + self._misc_timer = loop.call_at(loop.time() + 1, _async_misc) + + self._misc_timer = loop.call_at(loop.time() + 1, _async_misc) + + # -- Socket open/close (sync bridges for executor, async for event loop) -- + + def _on_socket_open_sync(self, client: paho.Client, userdata: object, sock: SocketLike) -> None: + """Handle socket open during executor connect — bridge to event loop.""" + if self._loop is not None: + self._loop.call_soon_threadsafe(self._async_on_socket_open, client, userdata, sock) + + def _async_on_socket_open(self, client: paho.Client, _userdata: object, sock: SocketLike) -> None: + """Handle socket open on the event loop.""" + if self._loop is None: + return + fileno = sock.fileno() + if fileno > -1: + self._loop.add_reader(sock, partial(self._async_reader_callback, client)) + if not self._misc_timer: + self._async_start_misc_periodic() + # Drain initial buffer immediately + self._async_reader_callback(client) + + def _async_on_socket_close(self, _client: paho.Client, _userdata: object, sock: SocketLike) -> None: + """Handle socket close — remove reader, cancel misc timer.""" + if self._loop is None: + return + # Ensure connect event is signaled if socket closes early + if self._connect_event is not None and not self._connect_event.is_set(): + self._connected = False + self._connect_event.set() + fileno = sock.fileno() + if fileno > -1: + self._loop.remove_reader(sock) + if self._misc_timer is not None: + self._misc_timer.cancel() + self._misc_timer = None + + # -- Socket write registration ------------------------------------------ + + def _on_socket_register_write_sync(self, client: paho.Client, userdata: object, sock: SocketLike) -> None: + """Register socket for writing during executor connect.""" + if self._loop is not None: + self._loop.call_soon_threadsafe(self._async_on_socket_register_write, client, userdata, sock) + + def _async_on_socket_register_write(self, client: paho.Client, _userdata: object, sock: SocketLike) -> None: + """Register the socket for writing on the event loop.""" + if self._loop is None: + return + fileno = sock.fileno() + if fileno > -1: + self._loop.add_writer(sock, partial(self._async_writer_callback, client)) + + def _async_on_socket_unregister_write(self, _client: paho.Client, _userdata: object, sock: SocketLike) -> None: + """Unregister the socket for writing.""" + if self._loop is None: + return + fileno = sock.fileno() + if fileno > -1: + self._loop.remove_writer(sock) + + # -- MQTT callbacks (run directly on event loop) ------------------------ + + def _on_connect( + self, + _client: paho.Client, + _userdata: object, + _flags: ConnectFlags, + reason_code: ReasonCode, + _properties: Properties | None, + ) -> None: + """Handle CONNACK from broker.""" + connected = not reason_code.is_failure + self._connected = connected + + if connected: + _LOGGER.debug("MQTT connected to %s:%s", self._host, self._port) + # Cancel reconnect loop on successful connection + if self._reconnect_task is not None: + self._reconnect_task.cancel() + self._reconnect_task = None + else: + _LOGGER.warning("MQTT connection refused: %s", reason_code) + + # Signal the asyncio connect() waiter + if self._connect_event is not None: + self._connect_event.set() + + # Notify connection callback + if self._connection_callback is not None: + self._connection_callback(connected) + + def _on_disconnect( + self, + _client: paho.Client, + _userdata: object, + _flags: DisconnectFlags, + reason_code: ReasonCode, + _properties: Properties | None, + ) -> None: + """Handle disconnect from broker.""" + self._connected = False + _LOGGER.debug("MQTT disconnected: %s", reason_code) + + # Signal connect event if still waiting (socket closed before CONNACK) + if self._connect_event is not None and not self._connect_event.is_set(): + self._connect_event.set() + + # Notify connection callback + if self._connection_callback is not None: + self._connection_callback(False) + + # Start reconnect loop (only after initial connect succeeded) + if self._initial_connect_done and self._should_reconnect and self._reconnect_task is None and self._loop is not None: + self._reconnect_task = self._loop.create_task(self._reconnect_loop(), name="span_mqtt_reconnect") + + def _on_message( + self, + _client: paho.Client, + _userdata: object, + msg: MQTTMessage, + ) -> None: + """Handle incoming MQTT message — dispatch directly on event loop.""" + topic = msg.topic + payload = msg.payload.decode("utf-8", errors="replace") + + if self._message_callback is not None: + self._message_callback(topic, payload) + + # -- Reconnection ------------------------------------------------------- + + async def _reconnect_loop(self) -> None: + """Reconnect with exponential backoff.""" + delay = MQTT_RECONNECT_MIN_DELAY_S + while self._should_reconnect: + if not self._connected and self._client is not None: + try: + if self._loop is None: + break + # Use sync socket callbacks for executor reconnect + self._client.on_socket_open = self._on_socket_open_sync + self._client.on_socket_register_write = self._on_socket_register_write_sync + await self._loop.run_in_executor(None, self._client.reconnect) + except OSError: + _LOGGER.debug("Reconnect failed, retrying in %ss", delay) + finally: + if self._client is not None: + self._client.on_socket_open = self._async_on_socket_open + self._client.on_socket_register_write = self._async_on_socket_register_write + await asyncio.sleep(delay) + delay = min( + delay * MQTT_RECONNECT_BACKOFF_MULTIPLIER, + MQTT_RECONNECT_MAX_DELAY_S, + ) diff --git a/src/span_panel_api/mqtt/const.py b/src/span_panel_api/mqtt/const.py new file mode 100644 index 0000000..baf5092 --- /dev/null +++ b/src/span_panel_api/mqtt/const.py @@ -0,0 +1,65 @@ +"""Constants for SPAN Panel MQTT/Homie transport.""" + +# Homie v5 topic structure +HOMIE_VERSION = 5 +HOMIE_DOMAIN = "ebus" +TOPIC_PREFIX = f"{HOMIE_DOMAIN}/{HOMIE_VERSION}" + +# Topic patterns (serial_number substituted at runtime) +DEVICE_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}" +STATE_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/$state" +DESCRIPTION_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/$description" +PROPERTY_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/{{node}}/{{prop}}" +PROPERTY_SET_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/{{node}}/{{prop}}/set" +WILDCARD_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/#" + +# Homie device states +HOMIE_STATE_INIT = "init" +HOMIE_STATE_READY = "ready" +HOMIE_STATE_DISCONNECTED = "disconnected" +HOMIE_STATE_SLEEPING = "sleeping" +HOMIE_STATE_LOST = "lost" +HOMIE_STATE_ALERT = "alert" + +# Homie type strings from schema +TYPE_CORE = "energy.ebus.device.distribution-enclosure.core" +TYPE_LUGS = "energy.ebus.device.lugs" +TYPE_LUGS_UPSTREAM = "energy.ebus.device.lugs.upstream" +TYPE_LUGS_DOWNSTREAM = "energy.ebus.device.lugs.downstream" +TYPE_CIRCUIT = "energy.ebus.device.circuit" +TYPE_BESS = "energy.ebus.device.bess" +TYPE_PV = "energy.ebus.device.pv" +TYPE_EVSE = "energy.ebus.device.evse" +TYPE_PCS = "energy.ebus.device.pcs" +TYPE_POWER_FLOWS = "energy.ebus.device.power-flows" + +# MQTT connection defaults +MQTT_DEFAULT_MQTTS_PORT = 8883 +MQTT_DEFAULT_WS_PORT = 9001 +MQTT_DEFAULT_WSS_PORT = 9002 +MQTT_KEEPALIVE_S = 60 + +# Connection/ready timeouts +MQTT_CONNECT_TIMEOUT_S = 15.0 +MQTT_READY_TIMEOUT_S = 30.0 + +# Reconnect backoff (reuses strategy from REST retry constants) +MQTT_RECONNECT_MIN_DELAY_S = 1.0 +MQTT_RECONNECT_MAX_DELAY_S = 60.0 +MQTT_RECONNECT_BACKOFF_MULTIPLIER = 2 + +# Lugs direction values +LUGS_UPSTREAM = "UPSTREAM" +LUGS_DOWNSTREAM = "DOWNSTREAM" + + +def normalize_circuit_id(node_id: str) -> str: + """Strip dashes from Homie UUID for entity stability.""" + return node_id.replace("-", "") + + +def denormalize_circuit_id(circuit_id: str) -> str: + """Restore dashes to a 32-char dashless UUID (8-4-4-4-12 format).""" + if len(circuit_id) == 32 and "-" not in circuit_id: + return f"{circuit_id[:8]}-{circuit_id[8:12]}-{circuit_id[12:16]}-{circuit_id[16:20]}-{circuit_id[20:]}" + return circuit_id diff --git a/src/span_panel_api/mqtt/homie.py b/src/span_panel_api/mqtt/homie.py new file mode 100644 index 0000000..70f2105 --- /dev/null +++ b/src/span_panel_api/mqtt/homie.py @@ -0,0 +1,629 @@ +"""Homie v5 device consumer for SPAN Panel. + +Parses Homie device description and tracks property values from MQTT +messages. Builds transport-agnostic SpanPanelSnapshot from the +accumulated state. +""" + +from __future__ import annotations + +from collections.abc import Callable +import json +import logging +import time +from typing import ClassVar + +from ..models import SpanBatterySnapshot, SpanCircuitSnapshot, SpanPanelSnapshot, SpanPVSnapshot +from .const import ( + HOMIE_STATE_READY, + LUGS_DOWNSTREAM, + LUGS_UPSTREAM, + TOPIC_PREFIX, + TYPE_BESS, + TYPE_CIRCUIT, + TYPE_CORE, + TYPE_EVSE, + TYPE_LUGS, + TYPE_LUGS_DOWNSTREAM, + TYPE_LUGS_UPSTREAM, + TYPE_POWER_FLOWS, + TYPE_PV, + normalize_circuit_id, +) + +_LOGGER = logging.getLogger(__name__) + +# Callback signature: (node_id, prop_id, value, old_value) +PropertyCallback = Callable[[str, str, str, str | None], None] + + +def _parse_bool(value: str) -> bool: + """Parse a Homie boolean string.""" + return value.lower() == "true" + + +def _parse_float(value: str, default: float = 0.0) -> float: + """Parse a float string, returning default on failure.""" + try: + return float(value) + except (ValueError, TypeError): + return default + + +def _parse_int(value: str, default: int = 0) -> int: + """Parse an integer string, returning default on failure.""" + try: + return int(value) + except (ValueError, TypeError): + return default + + +class HomieDeviceConsumer: + """Parse Homie device description and track property values. + + All methods must be called from the asyncio event loop thread + (guaranteed by AsyncMqttBridge's call_soon_threadsafe dispatch). + """ + + def __init__(self, serial_number: str) -> None: + self._serial_number = serial_number + self._topic_prefix = f"{TOPIC_PREFIX}/{serial_number}" + + self._state: str = "" + self._description: dict[str, object] | None = None + self._property_values: dict[str, dict[str, str]] = {} + self._property_timestamps: dict[str, dict[str, int]] = {} + self._node_types: dict[str, str] = {} + self._ready_since: float = 0.0 + self._property_callbacks: list[PropertyCallback] = [] + + def is_ready(self) -> bool: + """True when $state == ready and $description has been parsed.""" + return self._state == HOMIE_STATE_READY and self._description is not None + + def circuit_nodes_missing_names(self) -> list[str]: + """Return circuit-like node IDs that have no ``name`` property yet.""" + missing: list[str] = [] + for node_id, node_type in self._node_types.items(): + if node_type in self._CIRCUIT_LIKE_TYPES: + name = self._property_values.get(node_id, {}).get("name") + if not name: # None or "" + missing.append(node_id) + return missing + + def handle_message(self, topic: str, payload: str) -> None: + """Route an MQTT message to the appropriate handler.""" + if not topic.startswith(self._topic_prefix): + return + + suffix = topic[len(self._topic_prefix) + 1 :] # strip prefix + "/" + + if suffix == "$state": + self._handle_state(payload) + elif suffix == "$description": + self._handle_description(payload) + elif "/" in suffix and not suffix.endswith("/set"): + # Property value: {node_id}/{property_id} + parts = suffix.split("/", 1) + self._handle_property(parts[0], parts[1], payload) + + def register_property_callback(self, callback: PropertyCallback) -> Callable[[], None]: + """Register callback(node_id, prop_id, value, old_value). + + Returns an unregister function. + """ + self._property_callbacks.append(callback) + + def unregister() -> None: + try: + self._property_callbacks.remove(callback) + except ValueError: + _LOGGER.debug("Callback already unregistered") + + return unregister + + def build_snapshot(self) -> SpanPanelSnapshot: + """Build a point-in-time snapshot from current property values. + + Must be called after is_ready() returns True. + """ + return self._build_snapshot() + + # -- Internal handlers ------------------------------------------------- + + def _handle_state(self, payload: str) -> None: + """Handle $state topic.""" + self._state = payload + if payload == HOMIE_STATE_READY and self._ready_since == 0.0: + self._ready_since = time.monotonic() + _LOGGER.debug("Homie $state: %s", payload) + + def _handle_description(self, payload: str) -> None: + """Parse $description JSON and extract node type mappings.""" + try: + desc = json.loads(payload) + except json.JSONDecodeError: + _LOGGER.warning("Invalid $description JSON") + return + + self._description = desc + self._node_types.clear() + + # Extract node types from description + nodes = desc.get("nodes", {}) + if isinstance(nodes, dict): + for node_id, node_def in nodes.items(): + if isinstance(node_def, dict): + node_type = node_def.get("type", "") + if isinstance(node_type, str): + self._node_types[str(node_id)] = node_type + + _LOGGER.debug("Parsed $description with %d nodes", len(self._node_types)) + + def _handle_property(self, node_id: str, prop_id: str, value: str) -> None: + """Handle a property value update.""" + now_s = int(time.time()) + + if node_id not in self._property_values: + self._property_values[node_id] = {} + self._property_timestamps[node_id] = {} + + old_value = self._property_values[node_id].get(prop_id) + self._property_values[node_id][prop_id] = value + self._property_timestamps[node_id][prop_id] = now_s + + for cb in self._property_callbacks: + try: + cb(node_id, prop_id, value, old_value) + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.debug("Property callback error for %s/%s", node_id, prop_id) + + # -- Snapshot building -------------------------------------------------- + + def _get_prop(self, node_id: str, prop_id: str, default: str = "") -> str: + """Get a property value.""" + return self._property_values.get(node_id, {}).get(prop_id, default) + + def _get_timestamp(self, node_id: str, prop_id: str) -> int: + """Get a property timestamp.""" + return self._property_timestamps.get(node_id, {}).get(prop_id, 0) + + def _find_node_by_type(self, type_string: str) -> str | None: + """Find the first node ID matching a given type.""" + for node_id, node_type in self._node_types.items(): + if node_type == type_string: + return node_id + return None + + def _find_lugs_node(self, direction: str) -> str | None: + """Find the lugs node with a specific direction. + + Handles two firmware conventions: + - Typed: node type is ``energy.ebus.device.lugs.upstream`` / ``.downstream`` + - Generic: node type is ``energy.ebus.device.lugs`` with a ``direction`` property + """ + # Typed variant (direction embedded in the type string) + typed_map = { + LUGS_UPSTREAM: TYPE_LUGS_UPSTREAM, + LUGS_DOWNSTREAM: TYPE_LUGS_DOWNSTREAM, + } + target_type = typed_map.get(direction) + if target_type: + for node_id, node_type in self._node_types.items(): + if node_type == target_type: + return node_id + + # Generic variant (single TYPE_LUGS with direction property) + for node_id, node_type in self._node_types.items(): + if node_type == TYPE_LUGS: + prop_dir = self._property_values.get(node_id, {}).get("direction", "") + if prop_dir.upper() == direction: + return node_id + return None + + # Only TYPE_CIRCUIT nodes have the full MQTT property schema (power, + # energy, relay, space, etc.). PV and EVSE nodes are metadata-only + # (feed, nameplate-capacity, vendor-name) and reference the physical + # circuit via their ``feed`` property. + _CIRCUIT_LIKE_TYPES: frozenset[str] = frozenset({TYPE_CIRCUIT}) + + # Metadata node types whose ``feed`` property points to a circuit, + # annotating it with a device_type. + _FEED_TYPE_MAP: ClassVar[dict[str, str]] = { + TYPE_PV: "pv", + TYPE_EVSE: "evse", + } + + def _is_circuit_node(self, node_id: str) -> bool: + """Check if node is a circuit device.""" + return self._node_types.get(node_id, "") in self._CIRCUIT_LIKE_TYPES + + def _build_feed_metadata(self) -> dict[str, dict[str, str]]: + """Build mapping of circuit node_id → metadata from PV/EVSE feed references. + + Returns dict keyed by circuit node_id with values containing: + - device_type: "pv" | "evse" + - relative_position: "IN_PANEL" | "UPSTREAM" | "DOWNSTREAM" | "" + """ + feed_meta: dict[str, dict[str, str]] = {} + for node_id, node_type in self._node_types.items(): + device_type = self._FEED_TYPE_MAP.get(node_type) + if device_type: + feed_circuit = self._get_prop(node_id, "feed") + if feed_circuit: + rel_pos = self._get_prop(node_id, "relative-position") + feed_meta[feed_circuit] = { + "device_type": device_type, + "relative_position": rel_pos.upper() if rel_pos else "", + } + return feed_meta + + def _build_circuit(self, node_id: str, device_type: str = "circuit", relative_position: str = "") -> SpanCircuitSnapshot: + """Build a circuit snapshot from accumulated properties.""" + circuit_id = normalize_circuit_id(node_id) + + # active-power is in watts; negate so positive = consumption + raw_power_w = _parse_float(self._get_prop(node_id, "active-power")) + instant_power_w = -raw_power_w + + # Energy: exported-energy = consumption (panel exports TO circuit) + consumed_wh = _parse_float(self._get_prop(node_id, "exported-energy")) + # imported-energy = production (panel imports FROM circuit) + produced_wh = _parse_float(self._get_prop(node_id, "imported-energy")) + + # Tabs: derived from space + dipole + # Dipole circuits occupy two consecutive spaces on the same bus bar + # side: [space, space + 2] (odd+odd or even+even) + space_val = self._get_prop(node_id, "space") + is_dipole = _parse_bool(self._get_prop(node_id, "dipole")) + tabs: list[int] = [] + if space_val: + space = _parse_int(space_val) + tabs = [space, space + 2] if is_dipole else [space] + + always_on = _parse_bool(self._get_prop(node_id, "always-on")) + + # Timestamps from MQTT arrival time + energy_ts = max( + self._get_timestamp(node_id, "exported-energy"), + self._get_timestamp(node_id, "imported-energy"), + ) + power_ts = self._get_timestamp(node_id, "active-power") + + return SpanCircuitSnapshot( + circuit_id=circuit_id, + name=self._get_prop(node_id, "name"), + relay_state=self._get_prop(node_id, "relay", "UNKNOWN"), + instant_power_w=instant_power_w, + produced_energy_wh=produced_wh, + consumed_energy_wh=consumed_wh, + tabs=tabs, + priority=self._get_prop(node_id, "shed-priority", "UNKNOWN"), + is_user_controllable=not always_on, + is_sheddable=_parse_bool(self._get_prop(node_id, "sheddable")), + is_never_backup=_parse_bool(self._get_prop(node_id, "never-backup")), + device_type=device_type, + relative_position=relative_position, + is_240v=_parse_bool(self._get_prop(node_id, "dipole")), + current_a=_parse_float(self._get_prop(node_id, "current")) if self._get_prop(node_id, "current") else None, + breaker_rating_a=( + _parse_float(self._get_prop(node_id, "breaker-rating")) + if self._get_prop(node_id, "breaker-rating") + else None + ), + always_on=always_on, + relay_requester=self._get_prop(node_id, "relay-requester", "UNKNOWN"), + energy_accum_update_time_s=energy_ts, + instant_power_update_time_s=power_ts, + ) + + def _build_battery(self) -> SpanBatterySnapshot: + """Build battery snapshot from BESS node.""" + bess_node = self._find_node_by_type(TYPE_BESS) + if bess_node is None: + return SpanBatterySnapshot() + + soc_str = self._get_prop(bess_node, "soc") + soe_str = self._get_prop(bess_node, "soe") + + vn = self._get_prop(bess_node, "vendor-name") + pn = self._get_prop(bess_node, "product-name") + nc = self._get_prop(bess_node, "nameplate-capacity") + + return SpanBatterySnapshot( + soe_percentage=_parse_float(soc_str) if soc_str else None, + soe_kwh=_parse_float(soe_str) if soe_str else None, + vendor_name=vn if vn else None, + product_name=pn if pn else None, + nameplate_capacity_kwh=_parse_float(nc) if nc else None, + ) + + def _build_pv(self) -> SpanPVSnapshot: + """Build PV snapshot from the first PV metadata node.""" + pv_node = self._find_node_by_type(TYPE_PV) + if pv_node is None: + return SpanPVSnapshot() + + vn = self._get_prop(pv_node, "vendor-name") + pn = self._get_prop(pv_node, "product-name") + nc = self._get_prop(pv_node, "nameplate-capacity") + + return SpanPVSnapshot( + vendor_name=vn if vn else None, + product_name=pn if pn else None, + nameplate_capacity_kw=_parse_float(nc) if nc else None, + ) + + def _derive_dsm_grid_state(self, core_node: str | None, grid_power: float) -> str: + """Derive dsm_grid_state from multiple signals. + + Priority: + 1. bess/grid-state — authoritative when BESS is commissioned + 2. dominant-power-source == GRID — grid is the primary source + 3. grid_power != 0 — grid is exchanging power (even if not dominant) + 4. grid_power == 0 AND DPS != GRID — islanded (no flow + grid not dominant) + """ + # 1. BESS grid-state is authoritative when available + bess_node = self._find_node_by_type(TYPE_BESS) + if bess_node is not None: + gs = self._get_prop(bess_node, "grid-state") + if gs == "ON_GRID": + return "DSM_ON_GRID" + if gs == "OFF_GRID": + return "DSM_OFF_GRID" + + # 2-4. Fallback heuristic using DPS and grid power + if core_node is not None: + dps = self._get_prop(core_node, "dominant-power-source") + if dps == "GRID": + return "DSM_ON_GRID" + + if dps in ("BATTERY", "PV", "GENERATOR"): + if grid_power != 0.0: + return "DSM_ON_GRID" + return "DSM_OFF_GRID" + + return "UNKNOWN" + + def _derive_run_config(self, dsm_grid_state: str, grid_islandable: bool | None, dps: str | None) -> str: + """Derive current_run_config from grid state, islandability, and power source. + + Decision table: + - DSM_ON_GRID → PANEL_ON_GRID (regardless of islandable) + - DSM_OFF_GRID + islandable + BATTERY → PANEL_BACKUP + - DSM_OFF_GRID + islandable + PV/GENERATOR → PANEL_OFF_GRID + - DSM_OFF_GRID + islandable + other → UNKNOWN + - DSM_OFF_GRID + not islandable → UNKNOWN (shouldn't happen) + """ + if dsm_grid_state == "DSM_ON_GRID": + return "PANEL_ON_GRID" + + if dsm_grid_state == "DSM_OFF_GRID": + if not grid_islandable: + return "UNKNOWN" + if dps == "BATTERY": + return "PANEL_BACKUP" + if dps in ("PV", "GENERATOR"): + return "PANEL_OFF_GRID" + return "UNKNOWN" + + return "UNKNOWN" + + def _build_unmapped_tabs(self, circuits: dict[str, SpanCircuitSnapshot]) -> dict[str, SpanCircuitSnapshot]: + """Synthesize unmapped tab entries for breaker positions with no circuit. + + Determines panel size from the highest occupied tab, then creates + zero-power SpanCircuitSnapshot entries for unoccupied positions. + """ + # Collect all occupied tabs from commissioned circuits + occupied_tabs: set[int] = set() + for circuit in circuits.values(): + occupied_tabs.update(circuit.tabs) + + if not occupied_tabs: + return {} + + # Panel size is the highest occupied tab (rounded up to even + # to cover both bus bar sides) + max_tab = max(occupied_tabs) + panel_size = max_tab if max_tab % 2 == 0 else max_tab + 1 + + # Synthesize entries for unoccupied positions + unmapped: dict[str, SpanCircuitSnapshot] = {} + for tab in range(1, panel_size + 1): + if tab not in occupied_tabs: + circuit_id = f"unmapped_tab_{tab}" + unmapped[circuit_id] = SpanCircuitSnapshot( + circuit_id=circuit_id, + name=f"Unmapped Tab {tab}", + relay_state="CLOSED", + instant_power_w=0.0, + produced_energy_wh=0.0, + consumed_energy_wh=0.0, + tabs=[tab], + priority="UNKNOWN", + is_user_controllable=False, + is_sheddable=False, + is_never_backup=False, + ) + + return unmapped + + def _build_snapshot(self) -> SpanPanelSnapshot: + """Build full snapshot from accumulated property values.""" + core_node = self._find_node_by_type(TYPE_CORE) + upstream_lugs = self._find_lugs_node(LUGS_UPSTREAM) + downstream_lugs = self._find_lugs_node(LUGS_DOWNSTREAM) + + # Core properties + firmware = "" + door_state = "UNKNOWN" + main_relay = "UNKNOWN" + eth0 = False + wlan = False + wwan_connected = False + dominant_power_source: str | None = None + grid_islandable: bool | None = None + l1_voltage: float | None = None + l2_voltage: float | None = None + main_breaker: int | None = None + wifi_ssid: str | None = None + vendor_cloud: str | None = None + panel_size: int | None = None + + if core_node is not None: + firmware = self._get_prop(core_node, "software-version") + door_state = self._get_prop(core_node, "door", "UNKNOWN") + main_relay = self._get_prop(core_node, "relay", "UNKNOWN") + eth0 = _parse_bool(self._get_prop(core_node, "ethernet")) + wlan = _parse_bool(self._get_prop(core_node, "wifi")) + + vc = self._get_prop(core_node, "vendor-cloud") + wwan_connected = vc == "CONNECTED" + vendor_cloud = vc if vc else None + + dps = self._get_prop(core_node, "dominant-power-source") + dominant_power_source = dps if dps else None + + gi = self._get_prop(core_node, "grid-islandable") + grid_islandable = _parse_bool(gi) if gi else None + + l1v = self._get_prop(core_node, "l1-voltage") + l1_voltage = _parse_float(l1v) if l1v else None + + l2v = self._get_prop(core_node, "l2-voltage") + l2_voltage = _parse_float(l2v) if l2v else None + + br = self._get_prop(core_node, "breaker-rating") + main_breaker = _parse_int(br) if br else None + + ws = self._get_prop(core_node, "wifi-ssid") + wifi_ssid = ws if ws else None + + ps = self._get_prop(core_node, "panel-size") + panel_size = _parse_int(ps) if ps else None + + # Upstream lugs → main meter (grid connection) + # imported-energy = energy imported from the grid = consumed by the house + # exported-energy = energy exported to the grid = produced (solar) + grid_power = 0.0 + main_consumed = 0.0 + main_produced = 0.0 + upstream_l1_current: float | None = None + upstream_l2_current: float | None = None + if upstream_lugs is not None: + grid_power = _parse_float(self._get_prop(upstream_lugs, "active-power")) + main_consumed = _parse_float(self._get_prop(upstream_lugs, "imported-energy")) + main_produced = _parse_float(self._get_prop(upstream_lugs, "exported-energy")) + + l1_i = self._get_prop(upstream_lugs, "l1-current") + upstream_l1_current = _parse_float(l1_i) if l1_i else None + l2_i = self._get_prop(upstream_lugs, "l2-current") + upstream_l2_current = _parse_float(l2_i) if l2_i else None + + # Downstream lugs → feedthrough + feedthrough_power = 0.0 + feedthrough_consumed = 0.0 + feedthrough_produced = 0.0 + downstream_l1_current: float | None = None + downstream_l2_current: float | None = None + if downstream_lugs is not None: + feedthrough_power = _parse_float(self._get_prop(downstream_lugs, "active-power")) + feedthrough_consumed = _parse_float(self._get_prop(downstream_lugs, "imported-energy")) + feedthrough_produced = _parse_float(self._get_prop(downstream_lugs, "exported-energy")) + + dl1_i = self._get_prop(downstream_lugs, "l1-current") + downstream_l1_current = _parse_float(dl1_i) if dl1_i else None + dl2_i = self._get_prop(downstream_lugs, "l2-current") + downstream_l2_current = _parse_float(dl2_i) if dl2_i else None + + # Power flows + pf_node = self._find_node_by_type(TYPE_POWER_FLOWS) + power_flow_pv: float | None = None + power_flow_battery: float | None = None + power_flow_grid: float | None = None + power_flow_site: float | None = None + if pf_node is not None: + pf_pv = self._get_prop(pf_node, "pv") + power_flow_pv = _parse_float(pf_pv) if pf_pv else None + pf_bat = self._get_prop(pf_node, "battery") + power_flow_battery = _parse_float(pf_bat) if pf_bat else None + pf_grid = self._get_prop(pf_node, "grid") + power_flow_grid = _parse_float(pf_grid) if pf_grid else None + pf_site = self._get_prop(pf_node, "site") + power_flow_site = _parse_float(pf_site) if pf_site else None + + # Build metadata annotations from PV/EVSE metadata nodes + feed_metadata = self._build_feed_metadata() + + # Circuits + circuits: dict[str, SpanCircuitSnapshot] = {} + for node_id in self._node_types: + if self._is_circuit_node(node_id): + meta = feed_metadata.get(node_id, {}) + device_type = meta.get("device_type", "circuit") + relative_position = meta.get("relative_position", "") + circuit = self._build_circuit(node_id, device_type, relative_position) + circuits[circuit.circuit_id] = circuit + + # Synthesize unmapped tab entries + unmapped = self._build_unmapped_tabs(circuits) + circuits.update(unmapped) + + # Battery and PV metadata + battery = self._build_battery() + pv = self._build_pv() + + # BESS grid state for v2-native field + bess_node = self._find_node_by_type(TYPE_BESS) + grid_state: str | None = None + if bess_node is not None: + gs = self._get_prop(bess_node, "grid-state") + grid_state = gs if gs else None + + # Derived state values + dsm_grid_state = self._derive_dsm_grid_state(core_node, grid_power) + current_run_config = self._derive_run_config(dsm_grid_state, grid_islandable, dominant_power_source) + + # Connection uptime since $state==ready + uptime = int(time.monotonic() - self._ready_since) if self._ready_since > 0.0 else 0 + + return SpanPanelSnapshot( + serial_number=self._serial_number, + firmware_version=firmware, + main_relay_state=main_relay, + instant_grid_power_w=grid_power, + feedthrough_power_w=feedthrough_power, + main_meter_energy_consumed_wh=main_consumed, + main_meter_energy_produced_wh=main_produced, + feedthrough_energy_consumed_wh=feedthrough_consumed, + feedthrough_energy_produced_wh=feedthrough_produced, + dsm_grid_state=dsm_grid_state, + current_run_config=current_run_config, + door_state=door_state, + proximity_proven=self._state == HOMIE_STATE_READY, + uptime_s=uptime, + eth0_link=eth0, + wlan_link=wlan, + wwan_link=wwan_connected, + dominant_power_source=dominant_power_source, + grid_state=grid_state, + grid_islandable=grid_islandable, + l1_voltage=l1_voltage, + l2_voltage=l2_voltage, + main_breaker_rating_a=main_breaker, + wifi_ssid=wifi_ssid, + vendor_cloud=vendor_cloud, + panel_size=panel_size, + power_flow_pv=power_flow_pv, + power_flow_battery=power_flow_battery, + power_flow_grid=power_flow_grid, + power_flow_site=power_flow_site, + upstream_l1_current_a=upstream_l1_current, + upstream_l2_current_a=upstream_l2_current, + downstream_l1_current_a=downstream_l1_current, + downstream_l2_current_a=downstream_l2_current, + circuits=circuits, + battery=battery, + pv=pv, + ) diff --git a/src/span_panel_api/mqtt/models.py b/src/span_panel_api/mqtt/models.py new file mode 100644 index 0000000..40bd40d --- /dev/null +++ b/src/span_panel_api/mqtt/models.py @@ -0,0 +1,35 @@ +"""MQTT transport configuration models.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from .const import MQTT_DEFAULT_MQTTS_PORT, MQTT_DEFAULT_WS_PORT, MQTT_DEFAULT_WSS_PORT + +MqttTransport = Literal["tcp", "websockets"] + + +@dataclass(frozen=True, slots=True) +class MqttClientConfig: + """MQTT broker connection parameters from v2 auth response. + + CA certificate is not stored here — AsyncMqttBridge fetches it + fresh from GET /api/v2/certificate/ca on every connect/reconnect. + """ + + broker_host: str + username: str + password: str + mqtts_port: int = MQTT_DEFAULT_MQTTS_PORT + ws_port: int = MQTT_DEFAULT_WS_PORT + wss_port: int = MQTT_DEFAULT_WSS_PORT + transport: MqttTransport = "tcp" + use_tls: bool = True + + @property + def effective_port(self) -> int: + """Return the port for the configured transport/TLS combination.""" + if self.transport == "tcp": + return self.mqtts_port + return self.wss_port if self.use_tls else self.ws_port diff --git a/src/span_panel_api/phase_validation.py b/src/span_panel_api/phase_validation.py index 1abd777..dab1504 100644 --- a/src/span_panel_api/phase_validation.py +++ b/src/span_panel_api/phase_validation.py @@ -5,7 +5,7 @@ and 240V appliance validation. """ -from typing import Any, TypedDict +from typing import TypedDict class PhaseDistribution(TypedDict): @@ -19,62 +19,6 @@ class PhaseDistribution(TypedDict): balance_difference: int -def get_valid_tabs_from_panel_data(panel_state: dict[str, Any]) -> list[int]: - """Extract valid tab numbers from SPAN panel state data. - - Args: - panel_state: Panel state dictionary containing branches data - - Returns: - List of valid tab numbers from panel branch data - - Raises: - TypeError: If panel_state is invalid or missing branches data - - """ - if not isinstance(panel_state, dict): - raise TypeError("panel_state must be a dictionary") - - branches = panel_state.get("branches", []) - if not isinstance(branches, list): - raise TypeError("Invalid branches data in panel state") - - valid_tabs = [] - for branch in branches: - if isinstance(branch, dict) and "id" in branch: - tab_id = branch["id"] - if isinstance(tab_id, int) and tab_id > 0: - valid_tabs.append(tab_id) - - return sorted(valid_tabs) - - -def get_valid_tabs_from_branches(branches: list[Any]) -> list[int]: - """Extract valid tab numbers from SPAN panel branches list. - - Args: - branches: List of Branch objects or dictionaries with id field - - Returns: - List of valid tab numbers from branch data - - """ - valid_tabs = [] - for branch in branches: - # Handle both Branch objects and dictionaries - if hasattr(branch, "id"): - tab_id = branch.id - elif isinstance(branch, dict) and "id" in branch: - tab_id = branch["id"] - else: - continue - - if isinstance(tab_id, int) and tab_id > 0: - valid_tabs.append(tab_id) - - return sorted(valid_tabs) - - def get_tab_phase(tab_number: int, valid_tabs: list[int] | None = None) -> str: """Determine which phase (L1 or L2) a tab is connected to. diff --git a/src/span_panel_api/protocol.py b/src/span_panel_api/protocol.py new file mode 100644 index 0000000..996c014 --- /dev/null +++ b/src/span_panel_api/protocol.py @@ -0,0 +1,67 @@ +"""Protocol interfaces for SPAN Panel API transports. + +Defines structural subtyping contracts (PEP 544) that both MQTT and +simulation transports implement. The integration codes against these +protocols — never against transport-specific classes. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from enum import Flag, auto +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +if TYPE_CHECKING: + from .models import SpanPanelSnapshot + + +class PanelCapability(Flag): + """Runtime feature advertisement.""" + + NONE = 0 + PUSH_STREAMING = auto() + EBUS_MQTT = auto() + CIRCUIT_CONTROL = auto() + BATTERY_SOE = auto() + + +@runtime_checkable +class SpanPanelClientProtocol(Protocol): + """Core protocol every transport must satisfy.""" + + @property + def capabilities(self) -> PanelCapability: ... + + @property + def serial_number(self) -> str: ... + + async def connect(self) -> None: ... + + async def close(self) -> None: ... + + async def ping(self) -> bool: ... + + async def get_snapshot(self) -> SpanPanelSnapshot: ... + + +@runtime_checkable +class CircuitControlProtocol(Protocol): + """Control protocol for relay and priority changes.""" + + async def set_circuit_relay(self, circuit_id: str, state: str) -> None: ... + + async def set_circuit_priority(self, circuit_id: str, priority: str) -> None: ... + + +@runtime_checkable +class StreamingCapableProtocol(Protocol): + """Push-based transport that delivers updates via callbacks.""" + + def register_snapshot_callback( + self, + callback: Callable[[SpanPanelSnapshot], Awaitable[None]], + ) -> Callable[[], None]: ... + + async def start_streaming(self) -> None: ... + + async def stop_streaming(self) -> None: ... diff --git a/src/span_panel_api/simulation.py b/src/span_panel_api/simulation.py index b61615d..7bf2795 100644 --- a/src/span_panel_api/simulation.py +++ b/src/span_panel_api/simulation.py @@ -18,8 +18,9 @@ import yaml -from span_panel_api.const import DSM_GRID_UP, DSM_ON_GRID, MAIN_RELAY_CLOSED, PANEL_ON_GRID +from span_panel_api.const import DSM_ON_GRID, MAIN_RELAY_CLOSED, PANEL_ON_GRID from span_panel_api.exceptions import SimulationConfigurationError +from span_panel_api.models import SpanBatterySnapshot, SpanCircuitSnapshot, SpanPanelSnapshot, SpanPVSnapshot # New YAML configuration types @@ -825,6 +826,16 @@ def _calculate_circuit_energy( circuit_def["id"], instant_power, current_time, final_template["energy_profile"]["mode"] ) + @staticmethod + def _device_type_from_template(template: CircuitTemplateExtended) -> str: + """Derive device_type from the template's energy profile mode.""" + mode = template.get("energy_profile", {}).get("mode", "consumer") + if mode == "producer": + return "pv" + if mode == "bidirectional": + return "evse" + return "circuit" + def _create_circuit_data( self, circuit_def: CircuitDefinitionExtended, @@ -849,6 +860,7 @@ def _create_circuit_data( "isUserControllable": final_template["relay_behavior"] == "controllable", "isSheddable": False, "isNeverBackup": False, + "deviceType": self._device_type_from_template(final_template), } def _calculate_power_values( @@ -905,8 +917,7 @@ def _generate_panel_data( "instantPanelStateOfEnergyPercent": random.uniform(0.6, 0.9), # nosec B311 "serialNumber": self._config["panel_config"]["serial_number"], "mainRelayState": MAIN_RELAY_CLOSED, - "dsmGridState": DSM_GRID_UP, - "dsmState": DSM_ON_GRID, + "dsmGridState": DSM_ON_GRID, "mainMeterEnergy": { "producedEnergyWh": total_produced_energy, "consumedEnergyWh": total_consumed_energy, @@ -919,108 +930,8 @@ def _generate_panel_data( "gridSampleStartMs": int(time.time() * 1000), "gridSampleEndMs": int(time.time() * 1000), "currentRunConfig": PANEL_ON_GRID, - "branches": self._generate_branches(), } - def _generate_branches(self) -> list[dict[str, Any]]: - """Generate branch data for all tabs in the panel.""" - if not self._config: - return [] - - total_tabs = self._config["panel_config"].get("total_tabs", 32) - branches = [] - - # Find which tabs are mapped to circuits - mapped_tabs = set() - for circuit_def in self._config.get("circuits", []): - mapped_tabs.update(circuit_def.get("tabs", [])) - - for tab_num in range(1, total_tabs + 1): - # Create a branch for each tab with all required fields - current_sim_time = self.get_current_simulation_time() - current_time_ms = int(current_sim_time * 1000) - - # Handle unmapped tabs - if tab_num not in mapped_tabs: - # Check if this unmapped tab has a specific template defined - unmapped_tab_config = self._config.get("unmapped_tab_templates", {}).get(str(tab_num)) - if unmapped_tab_config: - # Apply behavior engine to unmapped tab with its template - behavior_engine = RealisticBehaviorEngine(self._simulation_start_time, self._config) - current_sim_time = self.get_current_simulation_time() - base_power = behavior_engine.get_circuit_power( - f"unmapped_tab_{tab_num}", unmapped_tab_config, current_sim_time - ) - - # Apply tab synchronization if configured - sync_config = self._get_tab_sync_config(tab_num) - if sync_config: - baseline_power = self._get_synchronized_power(tab_num, base_power, sync_config) - else: - baseline_power = base_power - else: - # Default unmapped tab baseline consumption (10-200W) - baseline_power = random.uniform(10.0, 200.0) # nosec B311 - - # Calculate accumulated energy for unmapped tabs with synchronization support - current_time = self.get_current_simulation_time() - circuit_id = f"unmapped_tab_{tab_num}" - - # Check if this tab has synchronization configuration - sync_config = self._get_tab_sync_config(tab_num) - if sync_config and sync_config.get("energy_sync", False): - # Use synchronized energy calculation - # Determine circuit mode for unmapped tabs - unmapped_mode = ( - "producer" - if unmapped_tab_config and unmapped_tab_config.get("energy_profile", {}).get("mode") == "producer" - else "consumer" - ) - produced_energy, consumed_energy = self._synchronize_energy_for_tab( - tab_num, circuit_id, baseline_power, current_time, unmapped_mode - ) - else: - # Use regular energy calculation - # Determine circuit mode for unmapped tabs - unmapped_mode = ( - "producer" - if unmapped_tab_config and unmapped_tab_config.get("energy_profile", {}).get("mode") == "producer" - else "consumer" - ) - produced_energy, consumed_energy = self._calculate_accumulated_energy( - circuit_id, baseline_power, current_time, unmapped_mode - ) - - # For unmapped tabs: - # - Solar production (positive power) -> exported energy represents production - # - Consumption (positive power) -> imported energy represents consumption - if unmapped_mode == "producer": - # Solar production - imported_energy = consumed_energy - exported_energy = produced_energy - else: - # Consumption - imported_energy = consumed_energy - exported_energy = produced_energy - else: - baseline_power = 0.0 # Mapped tabs get power from circuit definitions - imported_energy = 0.0 - exported_energy = 0.0 - - branch = { - "id": f"branch_{tab_num}", - "relayState": "CLOSED", - "instantPowerW": baseline_power, - "importedActiveEnergyWh": imported_energy, - "exportedActiveEnergyWh": exported_energy, - "measureStartTsMs": current_time_ms, - "measureDurationMs": 5000, # 5 second measurement window - "isMeasureValid": True, - } - branches.append(branch) - - return branches - def _generate_status_data(self) -> dict[str, Any]: """Generate status data from configuration.""" if not self._config: @@ -1162,6 +1073,117 @@ async def get_status(self) -> dict[str, Any]: """Get status data.""" return self._generate_status_data() + async def get_snapshot(self) -> SpanPanelSnapshot: + """Build a transport-agnostic snapshot directly from simulation data. + + Bypasses the OpenAPI model layer — goes straight from the simulation + engine's internal dicts to frozen snapshot dataclasses. + """ + raw = await self._generate_from_config() + soe_data = await self.get_soe() + + # --- Circuits --- + circuit_snapshots: dict[str, SpanCircuitSnapshot] = {} + circuits_dict: dict[str, dict[str, Any]] = raw["circuits"]["circuits"] + for cid, cdata in circuits_dict.items(): + tabs_raw: list[int] = cdata["tabs"] + circuit_snapshots[cid] = SpanCircuitSnapshot( + circuit_id=str(cdata["id"]), + name=str(cdata["name"]), + relay_state=str(cdata["relayState"]), + instant_power_w=float(cdata["instantPowerW"]), + produced_energy_wh=float(cdata["producedEnergyWh"]), + consumed_energy_wh=float(cdata["consumedEnergyWh"]), + tabs=tabs_raw, + priority=str(cdata["priority"]), + is_user_controllable=bool(cdata["isUserControllable"]), + is_sheddable=bool(cdata["isSheddable"]), + is_never_backup=bool(cdata["isNeverBackup"]), + device_type=str(cdata.get("deviceType", "circuit")), + energy_accum_update_time_s=int(cdata["energyAccumUpdateTimeS"]), + instant_power_update_time_s=int(cdata["instantPowerUpdateTimeS"]), + ) + + # --- Unmapped tabs --- + panel = raw["panel"] + occupied_tabs: set[int] = set() + for circuit in circuit_snapshots.values(): + occupied_tabs.update(circuit.tabs) + if occupied_tabs: + total_tabs = self._config["panel_config"].get("total_tabs", 32) if self._config else 32 + panel_size = max(*occupied_tabs, total_tabs) + for tab in range(1, panel_size + 1): + if tab not in occupied_tabs: + cid = f"unmapped_tab_{tab}" + circuit_snapshots[cid] = SpanCircuitSnapshot( + circuit_id=cid, + name=f"Unmapped Tab {tab}", + relay_state="CLOSED", + instant_power_w=0.0, + produced_energy_wh=0.0, + consumed_energy_wh=0.0, + tabs=[tab], + priority="UNKNOWN", + is_user_controllable=False, + is_sheddable=False, + is_never_backup=False, + ) + + # --- Battery --- + soe_percentage = float(soe_data["soe"]["percentage"]) + battery_snapshot = SpanBatterySnapshot(soe_percentage=soe_percentage) + + # --- Status --- + status: dict[str, Any] = raw["status"] + system: dict[str, Any] = status["system"] + network: dict[str, Any] = status["network"] + software: dict[str, Any] = status["software"] + + # Simulation always presents as grid-connected with standard values + total_tabs = self._config["panel_config"].get("total_tabs", 32) if self._config else 32 + main_size = self._config["panel_config"].get("main_size", 200) if self._config else 200 + grid_power = float(panel["instantGridPowerW"]) + feedthrough_power = float(panel["feedthroughPowerW"]) + + return SpanPanelSnapshot( + serial_number=str(system["serial"]), + firmware_version=str(software["firmwareVersion"]), + main_relay_state=str(panel["mainRelayState"]), + instant_grid_power_w=grid_power, + feedthrough_power_w=feedthrough_power, + main_meter_energy_consumed_wh=float(panel["mainMeterEnergy"]["consumedEnergyWh"]), + main_meter_energy_produced_wh=float(panel["mainMeterEnergy"]["producedEnergyWh"]), + feedthrough_energy_consumed_wh=float(panel["feedthroughEnergy"]["consumedEnergyWh"]), + feedthrough_energy_produced_wh=float(panel["feedthroughEnergy"]["producedEnergyWh"]), + dsm_grid_state=str(panel["dsmGridState"]), + current_run_config=str(panel["currentRunConfig"]), + door_state=str(system["doorState"]), + proximity_proven=bool(system["proximityProven"]), + uptime_s=int(system["uptime"]), + eth0_link=bool(network["eth0Link"]), + wlan_link=bool(network["wlanLink"]), + wwan_link=bool(network["wwanLink"]), + dominant_power_source="GRID", + grid_islandable=False, + l1_voltage=120.0, + l2_voltage=120.0, + main_breaker_rating_a=main_size, + wifi_ssid="SimulatedNetwork", + vendor_cloud="CONNECTED", + panel_size=total_tabs, + power_flow_battery=0.0, + power_flow_site=grid_power, + power_flow_grid=grid_power, + power_flow_pv=0.0, + upstream_l1_current_a=abs(grid_power / 240.0), + upstream_l2_current_a=abs(grid_power / 240.0), + downstream_l1_current_a=abs(feedthrough_power / 240.0), + downstream_l2_current_a=abs(feedthrough_power / 240.0), + circuits=circuit_snapshots, + battery=battery_snapshot, + pv=SpanPVSnapshot(), + ) + def set_dynamic_overrides( self, circuit_overrides: dict[str, dict[str, Any]] | None = None, global_overrides: dict[str, Any] | None = None ) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index fbb67ce..2e28533 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,108 @@ """Shared pytest configuration and fixtures for SPAN Panel API tests.""" -# Import fixtures from test_factories to make them available to all tests -from tests.test_factories import sim_client +from __future__ import annotations -__all__ = ["sim_client"] +import asyncio +import json +import tempfile +from collections.abc import AsyncGenerator +from unittest.mock import MagicMock, patch + +import paho.mqtt.client as paho +import pytest +from paho.mqtt.client import ConnectFlags +from paho.mqtt.reasoncodes import ReasonCode + +from span_panel_api.mqtt.const import TOPIC_PREFIX, TYPE_CORE + +# --------------------------------------------------------------------------- +# Constants shared across MQTT tests +# --------------------------------------------------------------------------- + +SERIAL = "nj-2316-XXXX" +TOPIC_PREFIX_SERIAL = f"{TOPIC_PREFIX}/{SERIAL}" + +# Minimal Homie description that makes the device "ready" +MINIMAL_DESCRIPTION = json.dumps({"nodes": {"core": {"type": TYPE_CORE}}}) + + +# --------------------------------------------------------------------------- +# Mock MQTT client fixture +# --------------------------------------------------------------------------- + + +def _make_fake_sock() -> MagicMock: + """Create a fake socket with fileno=-1 (skips add_reader/add_writer).""" + sock = MagicMock() + sock.fileno.return_value = -1 + return sock + + +@pytest.fixture +async def mqtt_client_mock() -> AsyncGenerator[MagicMock, None]: + """Patch AsyncMQTTClient to return a MagicMock that simulates paho. + + The mock wires up ``connect()`` to trigger the bridge's ``on_connect`` + and ``on_socket_open`` callbacks, exactly matching HA core's mock pattern. + + Yields the mock client instance (``cls.return_value``). + """ + loop = asyncio.get_running_loop() + fake_sock = _make_fake_sock() + + def _connect( + host: str = "", + port: int = 0, + keepalive: int = 60, + **_kwargs: object, + ) -> int: + """Simulate paho connect — fire socket + CONNACK callbacks.""" + # Socket open goes through the sync bridge → call_soon_threadsafe + mock_client.on_socket_open(mock_client, None, fake_sock) + mock_client.on_socket_register_write(mock_client, None, fake_sock) + # Schedule on_connect on event loop (we're in executor thread) + loop.call_soon_threadsafe( + mock_client.on_connect, + mock_client, + None, + ConnectFlags(session_present=0), + ReasonCode(packetType=2, aName="Success"), + None, + ) + return 0 + + def _reconnect() -> int: + """Simulate paho reconnect.""" + mock_client.on_socket_open(mock_client, None, fake_sock) + mock_client.on_socket_register_write(mock_client, None, fake_sock) + loop.call_soon_threadsafe( + mock_client.on_connect, + mock_client, + None, + ConnectFlags(session_present=0), + ReasonCode(packetType=2, aName="Success"), + None, + ) + return 0 + + with ( + patch("span_panel_api.mqtt.connection.AsyncMQTTClient") as cls, + patch("span_panel_api.mqtt.connection.download_ca_cert", return_value="FAKE-PEM"), + patch("span_panel_api.mqtt.connection.tempfile") as mock_tempfile, + ): + # Make tempfile return a mock file object + mock_tmp = MagicMock() + mock_tmp.name = f"{tempfile.gettempdir()}/fake_ca.pem" + mock_tempfile.NamedTemporaryFile.return_value = mock_tmp + + mock_client = cls.return_value + mock_client.connect.side_effect = _connect + mock_client.reconnect.side_effect = _reconnect + mock_client.subscribe.return_value = (0, 1) + mock_client.publish.return_value = MagicMock(rc=0, mid=1) + mock_client.disconnect.return_value = 0 + mock_client.loop_read.return_value = 0 + mock_client.loop_write.return_value = 0 + mock_client.loop_misc.return_value = paho.MQTT_ERR_SUCCESS + + yield mock_client diff --git a/examples/battery_test_config.yaml b/tests/fixtures/configs/battery_test_config.yaml similarity index 100% rename from examples/battery_test_config.yaml rename to tests/fixtures/configs/battery_test_config.yaml diff --git a/examples/behavior_test_config.yaml b/tests/fixtures/configs/behavior_test_config.yaml similarity index 100% rename from examples/behavior_test_config.yaml rename to tests/fixtures/configs/behavior_test_config.yaml diff --git a/examples/simple_test_config.yaml b/tests/fixtures/configs/simple_test_config.yaml similarity index 100% rename from examples/simple_test_config.yaml rename to tests/fixtures/configs/simple_test_config.yaml diff --git a/examples/simulation_config_32_circuit.yaml b/tests/fixtures/configs/simulation_config_32_circuit.yaml similarity index 100% rename from examples/simulation_config_32_circuit.yaml rename to tests/fixtures/configs/simulation_config_32_circuit.yaml diff --git a/examples/simulation_config_40_circuit_with_battery.yaml b/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml similarity index 100% rename from examples/simulation_config_40_circuit_with_battery.yaml rename to tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml diff --git a/examples/simulation_config_8_tab_workshop.yaml b/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml similarity index 100% rename from examples/simulation_config_8_tab_workshop.yaml rename to tests/fixtures/configs/simulation_config_8_tab_workshop.yaml diff --git a/examples/validation_test_config.yaml b/tests/fixtures/configs/validation_test_config.yaml similarity index 100% rename from examples/validation_test_config.yaml rename to tests/fixtures/configs/validation_test_config.yaml diff --git a/tests/fixtures/v2/README.md b/tests/fixtures/v2/README.md new file mode 100644 index 0000000..04f9348 --- /dev/null +++ b/tests/fixtures/v2/README.md @@ -0,0 +1,27 @@ +# v2 API Test Fixtures + +Captured from a live SPAN Panel running firmware `spanos2/r202603/05`. Serial numbers are masked (last 4 chars replaced with `XXXX`). + +## Files + +| File | Source | Notes | +| ------------------- | -------------------------- | --------------------------------------------------------------------------------------- | +| `homie_schema.json` | `GET /api/v2/homie/schema` | Complete Homie property schema. Unauthenticated. Schema hash: `sha256:d347556a07d98f40` | +| `status.json` | `GET /api/v2/status` | v2 status probe response. Serial masked. | + +## Schema Hash + +`sha256:d347556a07d98f40` — use this to detect schema changes across firmware versions (compare against `typesSchemaHash` in live responses). + +## Node Types Present + +| Node Type | Properties | Notes | +| ------------------------------------------------ | ---------- | -------------------------------------------------- | +| `energy.ebus.device.distribution-enclosure.core` | 17 | Panel-wide state, network, hardware | +| `energy.ebus.device.lugs` | 7 | Upstream (main meter) and downstream (feedthrough) | +| `energy.ebus.device.circuit` | 16 | Per-circuit — one node per commissioned circuit | +| `energy.ebus.device.bess` | 12 | Battery — optional, only if commissioned | +| `energy.ebus.device.pv` | 7 | Solar — optional, only if commissioned | +| `energy.ebus.device.evse` | 9 | EV charger — optional, only if commissioned | +| `energy.ebus.device.pcs` | 15 | Power Control System — optional | +| `energy.ebus.device.power-flows` | 4 | Aggregated power flows (W) | diff --git a/tests/fixtures/v2/homie_schema.json b/tests/fixtures/v2/homie_schema.json new file mode 100644 index 0000000..aef65dd --- /dev/null +++ b/tests/fixtures/v2/homie_schema.json @@ -0,0 +1,420 @@ +{ + "firmwareVersion": "spanos2/r202603/05", + "homieDomain": "ebus", + "homieVersion": 5, + "types": { + "energy.ebus.device.distribution-enclosure.core": { + "vendor-name": { + "name": "Vendor name", + "datatype": "string" + }, + "serial-number": { + "name": "Serial number", + "datatype": "string" + }, + "hardware-version": { + "name": "Hardware version", + "datatype": "string" + }, + "software-version": { + "name": "Software version", + "datatype": "string" + }, + "door": { + "name": "Door state", + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED" + }, + "grid-islandable": { + "name": "Capable of operating with power while disconnected from the grid", + "datatype": "boolean" + }, + "dominant-power-source": { + "name": "Current dominant power source, load-shedding trigger", + "datatype": "enum", + "format": "GRID,BATTERY,PV,GENERATOR,NONE,UNKNOWN", + "settable": true + }, + "relay": { + "name": "Main relay", + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED" + }, + "l1-voltage": { + "name": "L1 voltage", + "datatype": "float", + "unit": "V" + }, + "l2-voltage": { + "name": "L2 voltage", + "datatype": "float", + "unit": "V" + }, + "breaker-rating": { + "name": "Main breaker rating", + "datatype": "integer", + "unit": "A" + }, + "ethernet": { + "name": "Is Ethernet network interface operational?", + "datatype": "boolean" + }, + "wifi": { + "name": "Is Wi-Fi network interface operational?", + "datatype": "boolean" + }, + "wifi-ssid": { + "name": "SSID to which Wi-Fi network interface is connected", + "datatype": "string" + }, + "vendor-cloud": { + "name": "Device connected to vendor cloud?", + "datatype": "enum", + "format": "UNKNOWN,UNCONNECTED,CONNECTED" + }, + "postal-code": { + "name": "Postal (Zip) code", + "datatype": "string" + }, + "time-zone": { + "name": "Time zone", + "datatype": "string" + } + }, + "energy.ebus.device.lugs": { + "direction": { + "name": "Lugs feed direction: upstream or downstream", + "datatype": "enum", + "format": "UPSTREAM,DOWNSTREAM" + }, + "feed": { + "name": "Device the lugs are connected to, if known", + "datatype": "string" + }, + "l1-current": { + "name": "L1 current", + "datatype": "float", + "unit": "A" + }, + "l2-current": { + "name": "L2 current", + "datatype": "float", + "unit": "A" + }, + "active-power": { + "name": "Active power", + "datatype": "float", + "unit": "W" + }, + "imported-energy": { + "name": "Imported energy", + "datatype": "float", + "unit": "Wh" + }, + "exported-energy": { + "name": "Exported energy", + "datatype": "float", + "unit": "Wh" + } + }, + "energy.ebus.device.circuit": { + "name": { + "name": "Circuit name", + "datatype": "string" + }, + "relay": { + "name": "Circuit relay state", + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "settable": true + }, + "relay-requester": { + "name": "Actor requesting the relay state", + "datatype": "enum", + "format": "UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT" + }, + "breaker-rating": { + "name": "Circuit breaker rating", + "datatype": "integer", + "unit": "A" + }, + "current": { + "name": "Measured current", + "datatype": "float", + "unit": "A" + }, + "active-power": { + "name": "Measured active power", + "datatype": "float", + "unit": "kW" + }, + "imported-energy": { + "name": "Measured energy imported", + "datatype": "float", + "unit": "Wh" + }, + "exported-energy": { + "name": "Measured energy exported", + "datatype": "float", + "unit": "Wh" + }, + "space": { + "name": "Circuit breaker space number within load center", + "datatype": "integer", + "format": "1:32:1" + }, + "dipole": { + "name": "Does circuit land on a two-pole breaker?", + "datatype": "boolean" + }, + "shed-priority": { + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "settable": true + }, + "pcs-managed": { + "name": "Is circuit managed by PCS?", + "datatype": "boolean" + }, + "pcs-priority": { + "name": "Circuit PCS priority ranking", + "datatype": "integer" + }, + "sheddable": { + "name": "Is circuit configured to be sheddable?", + "datatype": "boolean" + }, + "never-backup": { + "name": "Is circuit configured to be never-backup?", + "datatype": "boolean" + }, + "always-on": { + "name": "Is circuit configured to be always on?", + "datatype": "boolean" + } + }, + "energy.ebus.device.bess": { + "vendor-name": { + "name": "Vendor name", + "datatype": "string" + }, + "product-name": { + "name": "Product name", + "datatype": "string" + }, + "model": { + "name": "Model", + "datatype": "string" + }, + "serial-number": { + "name": "Serial number", + "datatype": "string" + }, + "software-version": { + "name": "Software version", + "datatype": "string" + }, + "nameplate-capacity": { + "name": "Nameplate capacity", + "datatype": "float", + "unit": "kWh" + }, + "relative-position": { + "name": "Relative position of the commissioned backup system WRT the distribution enclosure", + "datatype": "enum", + "format": "UPSTREAM,DOWNSTREAM,IN_PANEL" + }, + "feed": { + "name": "Circuit ID upon which the commissioned backup system is landed", + "datatype": "enum" + }, + "soc": { + "name": "State of charge", + "datatype": "float", + "unit": "%" + }, + "soe": { + "name": "State of energy", + "datatype": "float", + "unit": "kWh" + }, + "connected": { + "name": "Connected to backup system?", + "datatype": "boolean" + }, + "grid-state": { + "name": "Grid connection state", + "datatype": "enum", + "format": "UNKNOWN,ON_GRID,OFF_GRID" + } + }, + "energy.ebus.device.pv": { + "vendor-name": { + "name": "Vendor name", + "datatype": "string" + }, + "product-name": { + "name": "Product name", + "datatype": "string" + }, + "serial-number": { + "name": "Serial number", + "datatype": "string" + }, + "software-version": { + "name": "Software version", + "datatype": "string" + }, + "nameplate-capacity": { + "name": "Nameplate capacity", + "datatype": "float", + "unit": "kW" + }, + "relative-position": { + "name": "Relative position of the commissioned PV system WRT the distribution enclosure", + "datatype": "enum", + "format": "UPSTREAM,DOWNSTREAM,IN_PANEL" + }, + "feed": { + "name": "Circuit ID upon which the commissioned PV system is landed", + "datatype": "enum" + } + }, + "energy.ebus.device.evse": { + "vendor-name": { + "name": "Vendor name", + "datatype": "string" + }, + "product-name": { + "name": "Product name", + "datatype": "string" + }, + "part-number": { + "name": "Part number", + "datatype": "string" + }, + "serial-number": { + "name": "Serial number", + "datatype": "string" + }, + "software-version": { + "name": "Software version", + "datatype": "string" + }, + "feed": { + "name": "Circuit ID upon which the commissioned EVSE is landed", + "datatype": "enum" + }, + "lock-state": { + "name": "Lock state", + "datatype": "enum", + "format": "UNKNOWN,LOCKED,UNLOCKED" + }, + "status": { + "name": "Status", + "datatype": "enum", + "format": "UNKNOWN,AVAILABLE,PREPARING,CHARGING,SUSPENDED_EV,SUSPENDED_EVSE,FINISHING,RESERVED,FAULTED,UNAVAILABLE" + }, + "advertised-current": { + "name": "Current EVSE is advertising to the EV", + "datatype": "float", + "unit": "A" + } + }, + "energy.ebus.device.pcs": { + "enabled": { + "name": "PCS system enabled", + "datatype": "boolean" + }, + "active": { + "name": "PCS system actively controlling one (or more) loads", + "datatype": "boolean" + }, + "import-limit": { + "name": "The power import limit currently being managed to", + "datatype": "float", + "unit": "A" + }, + "feed-import-limit": { + "name": "Limit of maximum power feeding the distribution enclosure", + "datatype": "float", + "unit": "A" + }, + "feed-import-limit-enablement": { + "name": "Enablement status of the feed-import-limit", + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED" + }, + "feed-import-limit-active": { + "name": "Is feed-import-limit currently being enforced?", + "datatype": "boolean" + }, + "grid-import-limit": { + "name": "Grid limit maximum import power", + "datatype": "float", + "unit": "A" + }, + "grid-import-limit-enablement": { + "name": "Enablement status of the grid-import-limit", + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED" + }, + "grid-import-limit-active": { + "name": "Is grid-import-limit currently being enforced?", + "datatype": "boolean" + }, + "off-grid-import-limit": { + "name": "Off-Grid limit maximum import power", + "datatype": "float", + "unit": "A" + }, + "off-grid-import-limit-enablement": { + "name": "Enablement status of the off-grid-import-limit", + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED" + }, + "off-grid-import-limit-active": { + "name": "Is off-grid-import-limit currently being enforced?", + "datatype": "boolean" + }, + "requested-import-limit": { + "name": "Requested limit maximum import power", + "datatype": "float", + "unit": "A" + }, + "requested-import-limit-enablement": { + "name": "Enablement status of the requested-import-limit", + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED" + }, + "requested-import-limit-active": { + "name": "Is requested-import-limit currently being enforced?", + "datatype": "boolean" + } + }, + "energy.ebus.device.power-flows": { + "pv": { + "name": "PV power flow", + "datatype": "float", + "unit": "W" + }, + "battery": { + "name": "Battery/BESS power flow", + "datatype": "float", + "unit": "W" + }, + "grid": { + "name": "Grid power flow", + "datatype": "float", + "unit": "W" + }, + "site": { + "name": "Site power flow", + "datatype": "float", + "unit": "W" + } + } + }, + "typesSchemaHash": "sha256:d347556a07d98f40" +} diff --git a/tests/fixtures/v2/status.json b/tests/fixtures/v2/status.json new file mode 100644 index 0000000..9566e85 --- /dev/null +++ b/tests/fixtures/v2/status.json @@ -0,0 +1,4 @@ +{ + "serialNumber": "nj-2316-XXXX", + "firmwareVersion": "spanos2/r202603/05" +} diff --git a/tests/test_32_panel_solar_unmapped.py b/tests/test_32_panel_solar_unmapped.py deleted file mode 100644 index dfe2f2d..0000000 --- a/tests/test_32_panel_solar_unmapped.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Test 32-circuit panel unmapped tabs 30 & 32 for solar production.""" - -import pytest -from span_panel_api import SpanPanelClient - - -@pytest.fixture -def client_32_circuit(): - """Create client with 32-circuit simulation.""" - config_path = "examples/simulation_config_32_circuit.yaml" - return SpanPanelClient("32-circuit-host", simulation_mode=True, simulation_config_path=config_path) - - -class TestSolarUnmappedTabs: - """Test solar production in unmapped tabs 30 & 32.""" - - @pytest.mark.asyncio - async def test_tabs_30_32_are_unmapped(self, client_32_circuit): - """Test that tabs 30 and 32 are unmapped and show as virtual circuits.""" - circuits = await client_32_circuit.get_circuits() - circuit_data = circuits.circuits.additional_properties - - # Verify unmapped_tab_30 and unmapped_tab_32 exist - assert "unmapped_tab_30" in circuit_data, "Tab 30 should be unmapped" - assert "unmapped_tab_32" in circuit_data, "Tab 32 should be unmapped" - - # Verify their names - tab30_circuit = circuit_data["unmapped_tab_30"] - tab32_circuit = circuit_data["unmapped_tab_32"] - - assert tab30_circuit.name == "Unmapped Tab 30" - assert tab32_circuit.name == "Unmapped Tab 32" - - @pytest.mark.asyncio - async def test_solar_production_power_ranges(self, client_32_circuit): - """Test that tabs 30 & 32 show solar production (positive power).""" - circuits = await client_32_circuit.get_circuits() - circuit_data = circuits.circuits.additional_properties - - # Get panel state to check branch power too - panel_state = await client_32_circuit.get_panel_state() - - print("\n=== Solar Production Test ===") - - for tab_num in [30, 32]: - # Check circuit power - circuit_id = f"unmapped_tab_{tab_num}" - circuit = circuit_data[circuit_id] - circuit_power = circuit.instant_power_w - - # Check branch power - branch = panel_state.branches[tab_num - 1] # 0-indexed - branch_power = branch.instant_power_w - - print(f"Tab {tab_num}:") - print(f" Circuit Power: {circuit_power:.1f}W") - print(f" Branch Power: {branch_power:.1f}W") - - # Solar production should be positive (or 0 at night) - assert circuit_power >= 0.0, f"Tab {tab_num} should show production (≥0W), got {circuit_power}W" - assert branch_power >= 0.0, f"Tab {tab_num} branch should show production (≥0W), got {branch_power}W" - - # During daylight hours, should be producing (positive power) - # At night, should be 0 - if circuit_power > 0: - # Producing - should be within expected range - assert 0.0 <= circuit_power <= 2000.0, f"Tab {tab_num} production {circuit_power}W outside range 0W to 2000W" - print(f" ✓ Tab {tab_num} producing {circuit_power:.1f}W solar power") - else: - # Not producing (night time) - tolerance = 1e-10 - assert abs(circuit_power - 0.0) <= tolerance, f"Tab {tab_num} should be 0W at night, got {circuit_power}W" - print(f" ✓ Tab {tab_num} not producing (night time)") - - @pytest.mark.asyncio - async def test_240v_solar_pair_behavior(self, client_32_circuit): - """Test that tabs 30 & 32 behave as a 240V solar pair.""" - circuits = await client_32_circuit.get_circuits() - circuit_data = circuits.circuits.additional_properties - - tab30_circuit = circuit_data["unmapped_tab_30"] - tab32_circuit = circuit_data["unmapped_tab_32"] - - tab30_power = tab30_circuit.instant_power_w - tab32_power = tab32_circuit.instant_power_w - - print(f"\n=== 240V Solar Pair ===") - print(f"Tab 30 Power: {tab30_power:.1f}W") - print(f"Tab 32 Power: {tab32_power:.1f}W") - print(f"Combined Power: {tab30_power + tab32_power:.1f}W") - - # Both should be producing similar amounts (within 35% of each other) - # Solar panels produce positive power (power generation) - if tab30_power > 0 and tab32_power > 0: - power_ratio = tab30_power / tab32_power - assert 0.65 <= power_ratio <= 1.54, f"Solar tabs should produce similar power, ratio: {power_ratio:.2f}" - print(f"✓ Solar tabs producing similar power (ratio: {power_ratio:.2f})") - - # Combined power for 240V circuit - combined_power = tab30_power + tab32_power - assert combined_power > 0.0, "Combined solar production should be positive" - print(f"✓ Combined 240V solar production: {combined_power:.1f}W") - else: - print("✓ Both tabs not producing (night time)") - - @pytest.mark.asyncio - async def test_other_unmapped_tabs_normal(self, client_32_circuit): - """Test that other unmapped tabs (if any) show normal consumption.""" - circuits = await client_32_circuit.get_circuits() - circuit_data = circuits.circuits.additional_properties - - # Find other unmapped tabs (not 30 or 32) - other_unmapped = [] - for circuit_id in circuit_data.keys(): - if isinstance(circuit_id, str) and circuit_id.startswith("unmapped_tab_"): - tab_num = int(circuit_id.split("_")[-1]) - if tab_num not in [30, 32]: - other_unmapped.append((circuit_id, tab_num)) - - print(f"\n=== Other Unmapped Tabs ===") - print(f"Found {len(other_unmapped)} other unmapped tabs") - - for circuit_id, tab_num in other_unmapped: - circuit = circuit_data[circuit_id] - power = circuit.instant_power_w - print(f"Tab {tab_num}: {power:.1f}W") - - # Should be positive consumption power - assert power >= 0.0, f"Non-solar unmapped tab {tab_num} should consume power (≥0W), got {power}W" - assert 10.0 <= power <= 200.0, f"Tab {tab_num} power {power}W should be in normal range 10-200W" diff --git a/tests/test_40_tab_unmapped.py b/tests/test_40_tab_unmapped.py deleted file mode 100644 index 632dc4c..0000000 --- a/tests/test_40_tab_unmapped.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Test 40-tab panel with unmapped tabs to achieve 100% coverage of unmapped tab logic.""" - -import pytest -from pathlib import Path -from span_panel_api import SpanPanelClient - - -class Test40TabUnmappedCoverage: - """Test 40-tab panel configuration with intentionally unmapped tabs.""" - - @pytest.mark.asyncio - async def test_unmapped_tab_creation_with_40_tab_panel(self): - """Test unmapped tab creation using 40-tab panel (covers lines 866-876).""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="40-tab-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Get circuits to trigger unmapped tab creation - circuits = await client.get_circuits() - - # Should have 40 tabs total, with tabs 38-40 unmapped - # This will hit lines 866-876 in client.py - - # Verify we have unmapped tabs - unmapped_tabs = [] - for circuit_id in circuits.circuits.additional_properties.keys(): - if circuit_id.startswith("unmapped_tab_"): - tab_num = int(circuit_id.split("_")[-1]) - unmapped_tabs.append(tab_num) - - # Should have unmapped tabs 38, 39, 40 - assert len(unmapped_tabs) >= 3, f"Expected at least 3 unmapped tabs, got {len(unmapped_tabs)}" - assert 38 in unmapped_tabs, "Tab 38 should be unmapped" - assert 39 in unmapped_tabs, "Tab 39 should be unmapped" - assert 40 in unmapped_tabs, "Tab 40 should be unmapped" - - # Verify unmapped tab properties are properly set - for tab_num in unmapped_tabs: - circuit_id = f"unmapped_tab_{tab_num}" - circuit = circuits.circuits.additional_properties[circuit_id] - - # These assertions test the virtual circuit creation in lines 866-876 - assert circuit.name is not None, f"Unmapped tab {tab_num} should have a name" - # Power can be positive (consumer) or negative (producer) - assert isinstance(circuit.instant_power_w, (int, float)), f"Unmapped tab {tab_num} should have numeric power" - assert hasattr(circuit, "relay_state"), f"Unmapped tab {tab_num} should have relay_state" - assert circuit.relay_state in [ - "OPEN", - "CLOSED", - "UNKNOWN", - ], f"Unmapped tab {tab_num} should have valid relay state" - - @pytest.mark.asyncio - async def test_battery_systems_on_opposing_phases(self): - """Test battery systems on opposing phased tabs (33/35, 34/36).""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="battery-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Get circuits to verify battery configurations - circuits = await client.get_circuits() - - # Find battery systems - battery_1 = circuits.circuits.additional_properties.get("battery_system_1") - battery_2 = circuits.circuits.additional_properties.get("battery_system_2") - - assert battery_1 is not None, "Battery System 1 should exist" - assert battery_2 is not None, "Battery System 2 should exist" - - # Verify battery systems can have positive or negative power (charge/discharge) - # Battery power can be negative (charging) or positive (discharging) - assert battery_1.instant_power_w >= -5000.0, "Battery 1 power should be within range" - assert battery_1.instant_power_w <= 5000.0, "Battery 1 power should be within range" - - assert battery_2.instant_power_w >= -5000.0, "Battery 2 power should be within range" - assert battery_2.instant_power_w <= 5000.0, "Battery 2 power should be within range" - - @pytest.mark.asyncio - async def test_panel_circuit_alignment_with_40_tabs(self): - """Test panel-circuit alignment with 40-tab configuration.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="alignment-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Get both panel and circuit data - panel_state = await client.get_panel_state() - circuits = await client.get_circuits() - - # Calculate total circuit power (all circuits now show positive values) - total_circuit_power = 0.0 - for circuit in circuits.circuits.additional_properties.values(): - total_circuit_power += circuit.instant_power_w - - # Panel grid power should be reasonable (consumption - production) - panel_grid_power = panel_state.instant_grid_power_w - - # Panel grid power should be less than total circuit power since production reduces net consumption - # and should be positive (net import) or negative (net export) - # Use tolerance for floating-point comparison - tolerance = 1e-10 # Very small tolerance for floating-point precision - assert ( - abs(panel_grid_power) <= total_circuit_power + tolerance - ), f"Panel power ({panel_grid_power}W) should be reasonable compared to total circuit power ({total_circuit_power}W)" - - # Verify we have 40 branches in panel state - assert len(panel_state.branches) == 40, f"Panel should have 40 branches, got {len(panel_state.branches)}" diff --git a/tests/test_async_mqtt_client.py b/tests/test_async_mqtt_client.py new file mode 100644 index 0000000..24823f1 --- /dev/null +++ b/tests/test_async_mqtt_client.py @@ -0,0 +1,75 @@ +"""Tests for NullLock and AsyncMQTTClient.""" + +from __future__ import annotations + +import threading + +from paho.mqtt.enums import CallbackAPIVersion + +from span_panel_api.mqtt.async_client import ( + AsyncMQTTClient, + NullLock, + _PAHO_LOCK_ATTRS, +) + + +class TestNullLock: + """Verify NullLock is a complete no-op.""" + + def test_context_manager(self) -> None: + lock = NullLock() + with lock as ctx: + assert ctx is lock + + def test_acquire_release(self) -> None: + lock = NullLock() + lock.acquire() + lock.release() + + def test_acquire_with_args(self) -> None: + lock = NullLock() + lock.acquire(True, 5) + + def test_repeated_calls_cached(self) -> None: + lock = NullLock() + result1 = lock.__enter__() + result2 = lock.__enter__() + assert result1 is result2 + + +class TestAsyncMQTTClient: + """Verify AsyncMQTTClient.setup() replaces all paho locks.""" + + def test_setup_replaces_all_locks(self) -> None: + client = AsyncMQTTClient( + callback_api_version=CallbackAPIVersion.VERSION2, + ) + # Before setup: locks should be real threading primitives + for attr in _PAHO_LOCK_ATTRS: + assert not isinstance(getattr(client, attr), NullLock) + + client.setup() + + # After setup: all locks should be NullLock + for attr in _PAHO_LOCK_ATTRS: + assert isinstance(getattr(client, attr), NullLock) + + def test_setup_creates_separate_instances(self) -> None: + client = AsyncMQTTClient( + callback_api_version=CallbackAPIVersion.VERSION2, + ) + client.setup() + + locks = [getattr(client, attr) for attr in _PAHO_LOCK_ATTRS] + # Each attribute should have its own NullLock instance + assert len(set(id(lock) for lock in locks)) == len(_PAHO_LOCK_ATTRS) + + def test_no_threading_locks_after_setup(self) -> None: + client = AsyncMQTTClient( + callback_api_version=CallbackAPIVersion.VERSION2, + ) + client.setup() + + for attr in _PAHO_LOCK_ATTRS: + val = getattr(client, attr) + assert isinstance(val, NullLock) diff --git a/tests/test_auth_and_simulation_helpers.py b/tests/test_auth_and_simulation_helpers.py new file mode 100644 index 0000000..40b7588 --- /dev/null +++ b/tests/test_auth_and_simulation_helpers.py @@ -0,0 +1,260 @@ +"""Targeted tests for uncovered code paths.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from span_panel_api.auth import _int, download_ca_cert, get_homie_schema +from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelTimeoutError +from span_panel_api.mqtt.homie import HomieDeviceConsumer, _parse_int + + +# --------------------------------------------------------------------------- +# auth._int edge cases (lines 29-31) +# --------------------------------------------------------------------------- + + +class TestIntHelper: + def test_int_passthrough(self) -> None: + assert _int(42) == 42 + + def test_float_truncated(self) -> None: + assert _int(3.9) == 3 + + def test_string_parsed(self) -> None: + assert _int("7") == 7 + + +# --------------------------------------------------------------------------- +# auth — connection / timeout errors for download_ca_cert (lines 111-114) +# --------------------------------------------------------------------------- + + +def _mock_client(method: str, side_effect: Exception) -> AsyncMock: + mock = AsyncMock() + setattr(mock, method, AsyncMock(side_effect=side_effect)) + mock.__aenter__ = AsyncMock(return_value=mock) + mock.__aexit__ = AsyncMock(return_value=False) + return mock + + +class TestDownloadCaCertErrors: + @pytest.mark.asyncio + async def test_connection_error(self) -> None: + with patch("span_panel_api.auth.httpx.AsyncClient") as cls: + cls.return_value = _mock_client("get", httpx.ConnectError("refused")) + with pytest.raises(SpanPanelConnectionError): + await download_ca_cert("192.168.1.1") + + @pytest.mark.asyncio + async def test_timeout_error(self) -> None: + with patch("span_panel_api.auth.httpx.AsyncClient") as cls: + cls.return_value = _mock_client("get", httpx.TimeoutException("slow")) + with pytest.raises(SpanPanelTimeoutError): + await download_ca_cert("192.168.1.1") + + +# --------------------------------------------------------------------------- +# auth — connection / timeout errors for get_homie_schema (lines 148-151, 154) +# --------------------------------------------------------------------------- + + +class TestGetHomieSchemaErrors: + @pytest.mark.asyncio + async def test_connection_error(self) -> None: + with patch("span_panel_api.auth.httpx.AsyncClient") as cls: + cls.return_value = _mock_client("get", httpx.ConnectError("refused")) + with pytest.raises(SpanPanelConnectionError): + await get_homie_schema("192.168.1.1") + + @pytest.mark.asyncio + async def test_timeout_error(self) -> None: + with patch("span_panel_api.auth.httpx.AsyncClient") as cls: + cls.return_value = _mock_client("get", httpx.TimeoutException("slow")) + with pytest.raises(SpanPanelTimeoutError): + await get_homie_schema("192.168.1.1") + + +# --------------------------------------------------------------------------- +# homie._parse_int failure path (lines 51-52) +# --------------------------------------------------------------------------- + + +class TestParseInt: + def test_valid(self) -> None: + assert _parse_int("42") == 42 + + def test_invalid_returns_default(self) -> None: + assert _parse_int("not_a_number") == 0 + + def test_invalid_with_custom_default(self) -> None: + assert _parse_int("bad", default=-1) == -1 + + +# --------------------------------------------------------------------------- +# homie — callback unregister (lines 104-105) +# --------------------------------------------------------------------------- + + +class TestHomieCallbackUnregister: + def test_unregister_removes_callback(self) -> None: + consumer = HomieDeviceConsumer("test-serial") + cb = AsyncMock() + unregister = consumer.register_property_callback(cb) + unregister() + # Second unregister should not raise (debug log path) + unregister() + + +# --------------------------------------------------------------------------- +# Simulation helpers +# --------------------------------------------------------------------------- + + +def _sim_config(**overrides: Any) -> dict[str, Any]: + """Minimal valid simulation config.""" + cfg: dict[str, Any] = { + "panel_config": { + "serial_number": "test-serial", + "total_tabs": 8, + "main_size": 100, + }, + "simulation_params": {"noise_factor": 0.0}, + "circuit_templates": { + "light": { + "energy_profile": {"mode": "consumer", "typical_power": 100, "power_variation": 0, "power_range": [50, 150]}, + "relay_behavior": "controllable", + "priority": "MUST_HAVE", + }, + }, + "circuits": [ + {"id": "c1", "name": "Light", "template": "light", "tabs": [1]}, + ], + } + cfg.update(overrides) + return cfg + + +# --------------------------------------------------------------------------- +# simulation — dynamic overrides (lines 1163-1195) +# --------------------------------------------------------------------------- + + +class TestSimulationDynamicOverrides: + @pytest.mark.asyncio + async def test_set_and_clear_overrides(self) -> None: + from span_panel_api.simulation import DynamicSimulationEngine + + engine = DynamicSimulationEngine(config_data=_sim_config()) + await engine.initialize_async() + + engine.set_dynamic_overrides( + circuit_overrides={"c1": {"power_override": 999.0}}, + global_overrides={"power_multiplier": 2.0}, + ) + + snapshot = await engine.get_snapshot() + assert "c1" in snapshot.circuits + + engine.clear_dynamic_overrides() + + snapshot2 = await engine.get_snapshot() + assert "c1" in snapshot2.circuits + + @pytest.mark.asyncio + async def test_relay_override_opens_circuit(self) -> None: + from span_panel_api.simulation import DynamicSimulationEngine + + engine = DynamicSimulationEngine(config_data=_sim_config()) + await engine.initialize_async() + + engine.set_dynamic_overrides( + circuit_overrides={"c1": {"relay_state": "OPEN"}}, + ) + + snapshot = await engine.get_snapshot() + assert snapshot.circuits["c1"].relay_state == "OPEN" + assert snapshot.circuits["c1"].instant_power_w == 0.0 + + @pytest.mark.asyncio + async def test_priority_override(self) -> None: + from span_panel_api.simulation import DynamicSimulationEngine + + engine = DynamicSimulationEngine(config_data=_sim_config()) + await engine.initialize_async() + + engine.set_dynamic_overrides( + circuit_overrides={"c1": {"priority": "NON_ESSENTIAL"}}, + ) + + snapshot = await engine.get_snapshot() + assert snapshot.circuits["c1"].priority == "NON_ESSENTIAL" + + @pytest.mark.asyncio + async def test_power_multiplier_override(self) -> None: + from span_panel_api.simulation import DynamicSimulationEngine + + engine = DynamicSimulationEngine(config_data=_sim_config()) + await engine.initialize_async() + + engine.set_dynamic_overrides( + circuit_overrides={"c1": {"power_multiplier": 0.0}}, + ) + + snapshot = await engine.get_snapshot() + assert snapshot.circuits["c1"].instant_power_w == 0.0 + + +# --------------------------------------------------------------------------- +# simulation — override_simulation_start_time (lines 568-598) +# --------------------------------------------------------------------------- + + +class TestSimulationStartTimeOverride: + @pytest.mark.asyncio + async def test_override_before_init(self) -> None: + from span_panel_api.simulation import DynamicSimulationEngine + + engine = DynamicSimulationEngine(config_data=_sim_config()) + engine.override_simulation_start_time("2024-06-15T12:00:00") + await engine.initialize_async() + + snapshot = await engine.get_snapshot() + assert snapshot.serial_number == "test-serial" + + @pytest.mark.asyncio + async def test_override_after_init(self) -> None: + from span_panel_api.simulation import DynamicSimulationEngine + + engine = DynamicSimulationEngine(config_data=_sim_config()) + await engine.initialize_async() + engine.override_simulation_start_time("2024-06-15T12:00:00") + + snapshot = await engine.get_snapshot() + assert snapshot.serial_number == "test-serial" + + @pytest.mark.asyncio + async def test_override_invalid_datetime(self) -> None: + from span_panel_api.simulation import DynamicSimulationEngine + + engine = DynamicSimulationEngine(config_data=_sim_config()) + await engine.initialize_async() + engine.override_simulation_start_time("not-a-datetime") + + snapshot = await engine.get_snapshot() + assert snapshot.serial_number == "test-serial" + + @pytest.mark.asyncio + async def test_override_with_z_suffix(self) -> None: + from span_panel_api.simulation import DynamicSimulationEngine + + engine = DynamicSimulationEngine(config_data=_sim_config()) + await engine.initialize_async() + engine.override_simulation_start_time("2024-06-15T12:00:00Z") + + snapshot = await engine.get_snapshot() + assert snapshot.serial_number == "test-serial" diff --git a/tests/test_authentication.py b/tests/test_authentication.py deleted file mode 100644 index 4dc9e33..0000000 --- a/tests/test_authentication.py +++ /dev/null @@ -1,387 +0,0 @@ -"""Tests for authentication and token management. - -This module tests authentication flows, token management, -and authentication-related error scenarios. -""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx -import pytest - -from span_panel_api import SpanPanelClient -from span_panel_api.exceptions import SpanPanelAPIError, SpanPanelAuthError - - -class TestAuthentication: - """Test authentication functionality.""" - - @pytest.mark.asyncio - async def test_authenticate_success(self): - """Test successful authentication.""" - client = SpanPanelClient("192.168.1.100") - - # Mock the API function directly - with patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth: - # Mock successful auth response - auth_response = MagicMock() - auth_response.access_token = "test-token" - auth_response.token_type = "Bearer" - mock_auth.asyncio = AsyncMock(return_value=auth_response) - - result = await client.authenticate("test-app", "Test Application") - - assert result == auth_response - assert client._access_token == "test-token" - mock_auth.asyncio.assert_called_once() - - @pytest.mark.asyncio - async def test_authenticate_failure(self): - """Test authentication failure.""" - client = SpanPanelClient("192.168.1.100") - - # Mock the API function to raise an exception - with patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth: - # Create a proper mock response with status_code - mock_response = MagicMock() - mock_response.status_code = 401 - mock_request = MagicMock() - - mock_auth.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError("401 Unauthorized", request=mock_request, response=mock_response) - ) - - with pytest.raises(SpanPanelAuthError): # Our wrapper converts 401 errors to SpanPanelAuthError - await client.authenticate("test-app", "Test Application") - - @pytest.mark.asyncio - async def test_authenticate_none_response(self): - """Test authentication with None response.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth: - mock_auth.asyncio = AsyncMock(return_value=None) - - with pytest.raises(SpanPanelAPIError, match="Authentication failed - no response"): - await client.authenticate("test-app", "Test Application") - - @pytest.mark.asyncio - async def test_authenticate_validation_error_response(self): - """Test authentication with validation error response.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth: - from span_panel_api.generated_client.models.http_validation_error import HTTPValidationError - - validation_error = HTTPValidationError() - mock_auth.asyncio = AsyncMock(return_value=validation_error) - - with pytest.raises(SpanPanelAPIError, match="Validation error during authentication"): - await client.authenticate("test-app", "Test Application") - - @pytest.mark.asyncio - async def test_authenticate_unexpected_response_type(self): - """Test authentication with unexpected response type.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth: - # Return something that's not AuthOut, HTTPValidationError, or None - mock_auth.asyncio = AsyncMock(return_value="unexpected") - - with pytest.raises(SpanPanelAPIError, match="Unexpected response type"): - await client.authenticate("test-app", "Test Application") - - @pytest.mark.asyncio - async def test_authenticate_value_error(self): - """Test ValueError handling in authenticate.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth: - mock_auth.asyncio.side_effect = ValueError("Validation failed") - - with pytest.raises(SpanPanelAPIError, match="Invalid data during authentication: Validation failed"): - await client.authenticate("test", "Test Client") - - @pytest.mark.asyncio - async def test_authenticate_malformed_response_error(self): - """Test malformed server response handling in authenticate.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth: - # Simulate the exact error from malformed JSON response - mock_auth.asyncio.side_effect = ValueError("dictionary update sequence element #0 has length 1; 2 is required") - - with pytest.raises(SpanPanelAPIError, match="Server returned malformed authentication response"): - await client.authenticate("test", "Test Client") - - @pytest.mark.asyncio - async def test_authenticate_generic_exception(self): - """Test generic Exception handling in authenticate.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth: - mock_auth.asyncio.side_effect = RuntimeError("Unexpected error") - - with pytest.raises(SpanPanelAPIError, match="Unexpected error during authentication: Unexpected error"): - await client.authenticate("test", "Test Client") - - @pytest.mark.asyncio - async def test_authenticate_http_errors(self): - """Test various HTTP errors during authentication.""" - client = SpanPanelClient("192.168.1.100") - - # Test cases for different HTTP status codes - test_cases = [ - (401, SpanPanelAuthError, "Authentication failed"), - (403, SpanPanelAuthError, "Authentication failed"), - (500, SpanPanelAPIError, "Server error 500"), # Changed from SpanPanelServerError - (502, SpanPanelAPIError, "Retriable server error 502"), # Changed from SpanPanelRetriableError - (503, SpanPanelAPIError, "Retriable server error 503"), # Changed from SpanPanelRetriableError - (504, SpanPanelAPIError, "Retriable server error 504"), # Changed from SpanPanelRetriableError - (404, SpanPanelAPIError, "HTTP 404"), - (400, SpanPanelAPIError, "HTTP 400"), - ] - - for status_code, expected_exception, error_msg_pattern in test_cases: - with patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth: - # Create a proper mock response - mock_response = MagicMock() - mock_response.status_code = status_code - mock_request = MagicMock() - - mock_auth.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError(f"{status_code} Error", request=mock_request, response=mock_response) - ) - - with pytest.raises(expected_exception, match=error_msg_pattern): - await client.authenticate("test-app", "Test Application") - - -class TestTokenManagement: - """Test token management and access token handling.""" - - def test_set_access_token_same_token(self): - """Test setting the same token twice does nothing.""" - client = SpanPanelClient("192.168.1.100") - client._access_token = "existing-token" - - # Should return early without changes - client.set_access_token("existing-token") - assert client._access_token == "existing-token" - - @pytest.mark.asyncio - async def test_set_access_token_outside_context_with_existing_client(self): - """Test setting token outside context when client exists.""" - client = SpanPanelClient("192.168.1.100") - - # Simulate existing client - mock_client = MagicMock() - client._client = mock_client - client._httpx_client_owned = True - - # Set new token - client.set_access_token("new-token") - - # Should reset client - assert client._client is None - assert not client._httpx_client_owned - assert client._access_token == "new-token" - - @pytest.mark.asyncio - async def test_set_access_token_in_context_with_client_upgrade(self): - """Test upgrading from Client to AuthenticatedClient in context.""" - from span_panel_api.generated_client import Client - - client = SpanPanelClient("192.168.1.100") - client._in_context = True - - # Create a regular Client - unauthenticated_client = Client( - base_url="http://test", timeout=httpx.Timeout(30.0), verify_ssl=False, raise_on_unexpected_status=True - ) - - # Mock the async client - mock_async_client = MagicMock() - mock_async_client.headers = {} - unauthenticated_client._async_client = mock_async_client - client._client = unauthenticated_client - - # Set token - should upgrade client - client.set_access_token("test-token") - - # Should be upgraded to AuthenticatedClient - from span_panel_api.generated_client import AuthenticatedClient - - assert isinstance(client._client, AuthenticatedClient) - assert client._access_token == "test-token" - - @pytest.mark.asyncio - async def test_set_access_token_update_existing_authenticated_client(self): - """Test updating token on existing AuthenticatedClient.""" - from span_panel_api.generated_client import AuthenticatedClient - - client = SpanPanelClient("192.168.1.100") - client._in_context = True - - # Create authenticated client - auth_client = AuthenticatedClient( - base_url="http://test", - token="old-token", - timeout=httpx.Timeout(30.0), - verify_ssl=False, - raise_on_unexpected_status=True, - ) - - # Mock the async and sync clients - mock_async_client = MagicMock() - mock_async_client.headers = {} - mock_sync_client = MagicMock() - mock_sync_client.headers = {} - - auth_client._async_client = mock_async_client - auth_client._client = mock_sync_client - client._client = auth_client - - # Update token - client.set_access_token("new-token") - - # Should update existing client - assert client._client.token == "new-token" - assert client._access_token == "new-token" - - -class TestAuthenticationRequirements: - """Test authentication requirements for different endpoints.""" - - @pytest.mark.asyncio - async def test_get_client_for_endpoint_requires_auth_no_token(self): - """Test that endpoints requiring auth fail without token.""" - client = SpanPanelClient("192.168.1.100") - - with pytest.raises( - SpanPanelAuthError, match="This endpoint requires authentication. Call authenticate\\(\\) first." - ): - client._get_client_for_endpoint(requires_auth=True) - - @pytest.mark.asyncio - async def test_get_client_for_endpoint_no_auth_required(self): - """Test that endpoints not requiring auth work without token.""" - client = SpanPanelClient("192.168.1.100") - - # Should not raise an exception - result_client = client._get_client_for_endpoint(requires_auth=False) - from span_panel_api.generated_client import Client - - assert isinstance(result_client, Client) - - @pytest.mark.asyncio - async def test_get_panel_state_auth_error(self): - """Test panel state requires authentication.""" - client = SpanPanelClient("192.168.1.100") - - with pytest.raises( - SpanPanelAuthError, match="This endpoint requires authentication. Call authenticate\\(\\) first." - ): - await client.get_panel_state() - - @pytest.mark.asyncio - async def test_get_panel_state_internal_401_exception_string(self): - """Test that internal exceptions mentioning '401' are NOT converted to auth errors.""" - client = SpanPanelClient("192.168.1.100") - client._access_token = "test-token" - - with patch("span_panel_api.client.get_panel_state_api_v1_panel_get") as mock_panel: - # Generic exception that mentions 401 - this is an internal error, not an auth problem - mock_panel.asyncio.side_effect = Exception("401 Unauthorized access") - - # Should be SpanPanelAPIError (internal error), not SpanPanelAuthError - with pytest.raises(SpanPanelAPIError, match="Unexpected error"): - await client.get_panel_state() - - @pytest.mark.asyncio - async def test_get_panel_state_real_http_401_error(self): - """Test that real HTTP 401 errors are still converted to auth errors.""" - client = SpanPanelClient("192.168.1.100") - client._access_token = "test-token" - - # Create a mock HTTP 401 error - mock_request = MagicMock() - mock_response = MagicMock() - mock_response.status_code = 401 - mock_response.text = "Unauthorized" - http_error = httpx.HTTPStatusError("401 Unauthorized", request=mock_request, response=mock_response) - - with patch("span_panel_api.client.get_panel_state_api_v1_panel_get") as mock_panel: - mock_panel.asyncio.side_effect = http_error - - # Real HTTP 401 errors should still become SpanPanelAuthError - with pytest.raises(SpanPanelAuthError, match="Authentication failed"): - await client.get_panel_state() - - @pytest.mark.asyncio - async def test_authentication_error_paths(self): - """Test authentication error handling paths (lines 559-566).""" - from span_panel_api.exceptions import SpanPanelAPIError - from tests.test_factories import create_live_client - - client = create_live_client() - - # Test ValueError in authentication - with patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth: - mock_auth.asyncio = AsyncMock(side_effect=ValueError("Invalid input")) - with pytest.raises(SpanPanelAPIError, match="Invalid data during authentication: Invalid input"): - await client.authenticate("test", "description") - - # Test generic exception in authentication - with patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth: - mock_auth.asyncio = AsyncMock(side_effect=RuntimeError("Unexpected error")) - with pytest.raises(SpanPanelAPIError, match="Unexpected error during authentication: Unexpected error"): - await client.authenticate("test", "description") - - -class TestSimulationAuthentication: - """Test authentication in simulation mode.""" - - @pytest.mark.asyncio - async def test_simulation_mode_authentication(self): - """Test that simulation mode returns mock authentication responses.""" - client = SpanPanelClient( - host="test-sim-auth", simulation_mode=True, simulation_config_path="examples/simple_test_config.yaml" - ) - - async with client: - # Authenticate in simulation mode - auth_result = await client.authenticate("test-service", "Test Service Description") - - # Should return a mock authentication response - assert auth_result.access_token.startswith("sim-token-test-service-") - assert auth_result.token_type == "Bearer" - assert auth_result.iat_ms > 0 - - # Token should be set on the client - assert client._access_token == auth_result.access_token - - @pytest.mark.asyncio - async def test_simulation_mode_authentication_token_format(self): - """Test that simulation authentication generates properly formatted tokens.""" - client = SpanPanelClient( - host="test-sim-token", simulation_mode=True, simulation_config_path="examples/simple_test_config.yaml" - ) - - async with client: - # Test with different name - auth_result = await client.authenticate("my-integration", "My Integration Service") - - # Token should include the name - assert "my-integration" in auth_result.access_token - assert auth_result.access_token.startswith("sim-token-my-integration-") - - # Should have timestamp component - token_parts = auth_result.access_token.split("-") - assert len(token_parts) >= 4 # sim, token, name, timestamp - - # iat_ms should be recent (within last minute) - import time - - current_time_ms = int(time.time() * 1000) - assert abs(auth_result.iat_ms - current_time_ms) < 60000 # Within 1 minute diff --git a/tests/test_bearer_token_validation.py b/tests/test_bearer_token_validation.py deleted file mode 100644 index 0c926bb..0000000 --- a/tests/test_bearer_token_validation.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Test Bearer token authentication and validation.""" - -import asyncio -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx -import pytest - -from span_panel_api import SpanPanelClient -from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelAPIError -from span_panel_api.generated_client.errors import UnexpectedStatus - - -class TestBearerTokenValidation: - """Test Bearer token authentication and error handling.""" - - @pytest.mark.asyncio - async def test_bearer_token_header_format(self): - """Test that access token is properly formatted as Bearer token in headers.""" - async with SpanPanelClient(host="test-bearer") as client: - # Set an access token - client.set_access_token("test-jwt-token-12345") - - # Get the authenticated client - auth_client = client._get_client_for_endpoint(requires_auth=True) - - # Verify the Authorization header is set correctly - httpx_client = auth_client.get_async_httpx_client() - auth_header = httpx_client.headers.get("Authorization") - - assert auth_header == "Bearer test-jwt-token-12345" - assert auth_client.prefix == "Bearer" - assert auth_client.token == "test-jwt-token-12345" - assert auth_client.auth_header_name == "Authorization" - - @pytest.mark.asyncio - async def test_401_unauthorized_with_bearer_token(self): - """Test that 401 errors with Bearer token are properly converted to SpanPanelAuthError.""" - async with SpanPanelClient(host="test-401") as client: - client.set_access_token("invalid-jwt-token") - - # Mock the API call to return 401 - mock_request = MagicMock() - mock_response = MagicMock() - mock_response.status_code = 401 - mock_response.content = b'{"detail": "Invalid authentication credentials"}' - mock_response.text = "Unauthorized" - - with patch("span_panel_api.generated_client.api.default.get_panel_state_api_v1_panel_get.asyncio") as mock_panel: - mock_panel.side_effect = httpx.HTTPStatusError( - "401 Unauthorized", request=mock_request, response=mock_response - ) - - with pytest.raises(SpanPanelAuthError) as exc_info: - await client.get_panel_state() - - assert "Authentication failed" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_403_forbidden_with_bearer_token(self): - """Test that 403 errors with Bearer token are properly converted to SpanPanelAuthError.""" - async with SpanPanelClient(host="test-403") as client: - client.set_access_token("valid-but-insufficient-token") - - # Mock the API call to return 403 - mock_request = MagicMock() - mock_response = MagicMock() - mock_response.status_code = 403 - mock_response.content = b'{"detail": "Insufficient permissions"}' - mock_response.text = "Forbidden" - - with patch("span_panel_api.generated_client.api.default.get_panel_state_api_v1_panel_get.asyncio") as mock_panel: - mock_panel.side_effect = httpx.HTTPStatusError("403 Forbidden", request=mock_request, response=mock_response) - - with pytest.raises(SpanPanelAuthError) as exc_info: - await client.get_panel_state() - - assert "Authentication failed" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_unexpected_status_401_handling(self): - """Test that UnexpectedStatus with 401 is properly handled.""" - async with SpanPanelClient(host="test-unexpected-401") as client: - client.set_access_token("expired-token") - - with patch("span_panel_api.generated_client.api.default.get_panel_state_api_v1_panel_get.asyncio") as mock_panel: - mock_response_content = b'{"error": "Token expired"}' - mock_panel.side_effect = UnexpectedStatus(401, mock_response_content) - - with pytest.raises(SpanPanelAuthError) as exc_info: - await client.get_panel_state() - - assert "Authentication failed" in str(exc_info.value) - assert "Status 401" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_no_token_required_endpoint_raises_auth_error(self): - """Test that endpoints requiring auth raise SpanPanelAuthError when no token is set.""" - async with SpanPanelClient(host="test-no-token") as client: - # Don't set any access token - - # The error gets wrapped, so we need to check the inner error or the message - with pytest.raises((SpanPanelAuthError, SpanPanelAPIError)) as exc_info: - await client.get_circuits() - - # Check that the error message indicates authentication is required - assert "authentication" in str(exc_info.value).lower() or "authenticate" in str(exc_info.value).lower() - - @pytest.mark.asyncio - async def test_token_updates_authorization_header(self): - """Test that updating the token properly updates the Authorization header.""" - async with SpanPanelClient(host="test-token-update") as client: - # Set initial token - client.set_access_token("token-v1") - auth_client = client._get_client_for_endpoint(requires_auth=True) - httpx_client = auth_client.get_async_httpx_client() - - assert httpx_client.headers["Authorization"] == "Bearer token-v1" - - # Update token - client.set_access_token("token-v2") - - # Header should be updated - assert httpx_client.headers["Authorization"] == "Bearer token-v2" - assert auth_client.token == "token-v2" - - @pytest.mark.asyncio - async def test_bearer_token_authentication_flow(self): - """Test complete Bearer token authentication flow.""" - async with SpanPanelClient(host="test-auth-flow") as client: - # Initially no token - assert client._access_token is None - - # Mock successful authentication - with patch( - "span_panel_api.generated_client.api.default.generate_jwt_api_v1_auth_register_post.asyncio" - ) as mock_auth: - mock_response = MagicMock() - mock_response.access_token = "jwt-token-from-auth" - mock_response.token_type = "Bearer" - mock_response.iat_ms = 1234567890000 - mock_auth.return_value = mock_response - - auth_result = await client.authenticate("test-client", "Test integration") - - # Verify token is set - assert client._access_token == "jwt-token-from-auth" - assert auth_result.access_token == "jwt-token-from-auth" - assert auth_result.token_type == "Bearer" - - # Verify client can make authenticated requests - auth_client = client._get_client_for_endpoint(requires_auth=True) - httpx_client = auth_client.get_async_httpx_client() - assert httpx_client.headers["Authorization"] == "Bearer jwt-token-from-auth" - - @pytest.mark.asyncio - async def test_generic_exception_handler_401_detection(self): - """Test that generic exception handler detects 401 in exception strings.""" - async with SpanPanelClient(host="test-generic-401") as client: - client.set_access_token("some-token") - - with patch("span_panel_api.generated_client.api.default.get_panel_state_api_v1_panel_get.asyncio") as mock_panel: - # Simulate a generic exception that contains "401 Unauthorized" - mock_panel.side_effect = RuntimeError("401 Unauthorized access denied") - - with pytest.raises(SpanPanelAuthError) as exc_info: - await client.get_panel_state() - - assert "Authentication failed" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_client_type_validation_with_auth(self): - """Test that client type is properly validated when authentication is required.""" - client = SpanPanelClient(host="test-client-type") - - # Start context - await client.__aenter__() - - try: - # Set token after context is started - client.set_access_token("test-token") - - # Should be able to get authenticated client - auth_client = client._get_client_for_endpoint(requires_auth=True) - assert auth_client is not None - - # Should have the right type - from span_panel_api.generated_client.client import AuthenticatedClient - - assert isinstance(auth_client, AuthenticatedClient) - - finally: - await client.__aexit__(None, None, None) - - @pytest.mark.asyncio - async def test_auth_error_propagation_chain(self): - """Test that authentication errors properly propagate through the exception chain.""" - async with SpanPanelClient(host="test-error-chain") as client: - client.set_access_token("bad-token") - - mock_request = MagicMock() - mock_response = MagicMock() - mock_response.status_code = 401 - mock_response.text = "Token validation failed" - - original_error = httpx.HTTPStatusError("401 Unauthorized", request=mock_request, response=mock_response) - - with patch("span_panel_api.generated_client.api.default.get_panel_state_api_v1_panel_get.asyncio") as mock_panel: - mock_panel.side_effect = original_error - - with pytest.raises(SpanPanelAuthError) as exc_info: - await client.get_panel_state() - - # Verify the error chain - HTTPStatusError gets converted to UnexpectedStatus - assert isinstance(exc_info.value.__cause__, UnexpectedStatus) - assert exc_info.value.__cause__.status_code == 401 - assert "Authentication failed" in str(exc_info.value) - assert "Status 401" in str(exc_info.value) diff --git a/tests/test_behavior_engine_coverage.py b/tests/test_behavior_engine.py similarity index 57% rename from tests/test_behavior_engine_coverage.py rename to tests/test_behavior_engine.py index f6b8a57..d037a8b 100644 --- a/tests/test_behavior_engine_coverage.py +++ b/tests/test_behavior_engine.py @@ -1,67 +1,23 @@ """Tests for behavior engine edge cases and time-of-day patterns. This module tests specific edge cases in the RealisticBehaviorEngine -to achieve 100% test coverage. +to achieve test coverage. """ import time from pathlib import Path -from unittest.mock import patch import pytest -from span_panel_api import SpanPanelClient -from span_panel_api.simulation import RealisticBehaviorEngine +from span_panel_api.simulation import DynamicSimulationEngine, RealisticBehaviorEngine class TestBehaviorEngineEdgeCases: """Test edge cases in the RealisticBehaviorEngine.""" - @pytest.mark.asyncio - async def test_relay_open_returns_zero_power(self): - """Test that OPEN relay state returns zero power regardless of template.""" - client = SpanPanelClient( - host="test-relay-open", simulation_mode=True, simulation_config_path="examples/simple_test_config.yaml" - ) - - async with client: - # Set a circuit relay to OPEN - await client.set_circuit_relay("living_room_lights", "OPEN") - - # Get circuits - the open circuit should have 0 power - circuits = await client.get_circuits() - circuit = circuits.circuits.additional_properties["living_room_lights"] - - # Should be exactly 0.0 due to OPEN relay - assert circuit.instant_power_w == 0.0 - assert circuit.relay_state.value == "OPEN" - - @pytest.mark.asyncio - async def test_solar_time_of_day_production_pattern(self): - """Test solar production pattern during different times of day.""" - client = SpanPanelClient( - host="test-solar-pattern", - simulation_mode=True, - simulation_config_path="examples/behavior_test_config.yaml", # Has solar with time-of-day profile - ) - - async with client: - # Get the solar circuit during day hours - circuits = await client.get_circuits() - - # Find the solar circuit by name - solar_circuit = circuits.circuits.additional_properties.get("solar_array_variable") - - if solar_circuit is not None: - # Solar should be producing (positive power) or 0 depending on time - assert solar_circuit.instant_power_w >= 0, "Solar should produce non-negative power" - else: - # If no solar circuit found, just check that circuits exist - assert len(circuits.circuits.additional_properties) > 0 - def test_behavior_engine_direct_solar_pattern(self): """Test the behavior engine solar pattern directly to cover specific lines.""" - config_path = Path(__file__).parent.parent / "examples" / "behavior_test_config.yaml" + config_path = Path(__file__).parent / "fixtures" / "configs" / "behavior_test_config.yaml" current_time = time.time() # Load config to get the solar template @@ -100,7 +56,7 @@ def test_behavior_engine_direct_solar_pattern(self): def test_behavior_engine_time_of_day_modulation(self): """Test time-of-day modulation for regular circuits to cover specific lines.""" - config_path = Path(__file__).parent.parent / "examples" / "behavior_test_config.yaml" + config_path = Path(__file__).parent / "fixtures" / "configs" / "behavior_test_config.yaml" current_time = time.time() # Load config @@ -123,7 +79,7 @@ def test_behavior_engine_time_of_day_modulation(self): } # Test peak hours (covers line 218 - should be 1.3x) - from datetime import datetime, timezone + from datetime import datetime # Create a proper 8 PM timestamp in current timezone now = datetime.now() @@ -150,7 +106,6 @@ def test_behavior_engine_time_of_day_modulation(self): @pytest.mark.asyncio async def test_simulation_engine_error_coverage(self): """Test error conditions in simulation engine.""" - from span_panel_api.simulation import DynamicSimulationEngine from span_panel_api.exceptions import SimulationConfigurationError # Test missing config path @@ -159,7 +114,7 @@ async def test_simulation_engine_error_coverage(self): await engine._generate_from_config() # Test missing circuit templates - config_path = Path(__file__).parent.parent / "examples" / "simple_test_config.yaml" + config_path = Path(__file__).parent / "fixtures" / "configs" / "simple_test_config.yaml" engine_with_path = DynamicSimulationEngine("TEST", config_path=config_path) await engine_with_path.initialize_async() @@ -170,7 +125,7 @@ async def test_simulation_engine_error_coverage(self): def test_behavior_engine_cycling_behavior_coverage(self): """Test cycling behavior edge cases.""" - config_path = Path(__file__).parent.parent / "examples" / "behavior_test_config.yaml" + config_path = Path(__file__).parent / "fixtures" / "configs" / "behavior_test_config.yaml" current_time = time.time() import yaml @@ -191,7 +146,7 @@ def test_behavior_engine_cycling_behavior_coverage(self): def test_relay_state_open_coverage(self): """Test that OPEN relay state returns 0.0 power - covers line 170.""" - config_path = Path(__file__).parent.parent / "examples" / "behavior_test_config.yaml" + config_path = Path(__file__).parent / "fixtures" / "configs" / "behavior_test_config.yaml" current_time = time.time() import yaml @@ -221,10 +176,8 @@ def test_relay_state_open_coverage(self): @pytest.mark.asyncio async def test_simulation_engine_initialization_edge_cases(self): """Test simulation engine initialization edge cases.""" - from span_panel_api.simulation import DynamicSimulationEngine - # Test engine with config path - config_path = Path(__file__).parent.parent / "examples" / "simple_test_config.yaml" + config_path = Path(__file__).parent / "fixtures" / "configs" / "simple_test_config.yaml" engine_with_config = DynamicSimulationEngine("TEST-WITH-CONFIG", config_path=config_path) await engine_with_config.initialize_async() assert engine_with_config._config is not None @@ -233,58 +186,3 @@ async def test_simulation_engine_initialization_edge_cases(self): panel_data = await engine_with_config.get_panel_data() assert "circuits" in panel_data assert "panel" in panel_data - - @pytest.mark.asyncio - async def test_smart_grid_peak_hour_reduction(self): - """Test smart grid power reduction during peak hours (5-9 PM).""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - async with SpanPanelClient(host="test", simulation_mode=True, simulation_config_path=str(config_path)) as client: - # Test during peak hours - 6 PM (18:00) - peak_time = 18 * 3600 # 6 PM in seconds since midnight - - with patch("time.time", return_value=peak_time): - circuits = await client.get_circuits() - - # Find a circuit with smart grid behavior - smart_circuit = None - for circuit_id, circuit in circuits.circuits.additional_properties.items(): - if circuit.instant_power_w > 0: # Find an active circuit - smart_circuit = circuit - break - - if smart_circuit: - # The power should be reduced compared to base power - # This tests lines 256-257 in simulation.py - assert smart_circuit.instant_power_w >= 0 - - # Test during non-peak hours - 2 PM (14:00) - non_peak_time = 14 * 3600 # 2 PM in seconds since midnight - - with patch("time.time", return_value=non_peak_time): - circuits_non_peak = await client.get_circuits() - - # Power should be different during non-peak hours - assert circuits_non_peak is not None - assert len(circuits_non_peak.circuits.additional_properties) > 0 - - @pytest.mark.asyncio - async def test_smart_grid_non_peak_hours(self): - """Test smart grid behavior during non-peak hours.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - async with SpanPanelClient(host="test", simulation_mode=True, simulation_config_path=str(config_path)) as client: - # Test during non-peak hours - 10 AM (10:00) - non_peak_time = 10 * 3600 # 10 AM in seconds since midnight - - with patch("time.time", return_value=non_peak_time): - circuits = await client.get_circuits() - - # Should have normal power levels during non-peak - assert circuits is not None - assert len(circuits.circuits.additional_properties) > 0 - - for circuit_id, circuit in circuits.circuits.additional_properties.items(): - if circuit.instant_power_w > 0: - # Power should be at normal levels (not reduced) - assert circuit.instant_power_w >= 0 diff --git a/tests/test_client_retry_properties.py b/tests/test_client_retry_properties.py deleted file mode 100644 index 10a6abf..0000000 --- a/tests/test_client_retry_properties.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Tests for client retry properties and configuration.""" - -import pytest -from src.span_panel_api.client import SpanPanelClient - - -class TestClientRetryProperties: - """Tests for client retry configuration properties.""" - - def test_property_getters(self): - """Test property getters.""" - client = SpanPanelClient( - host="test", - retries=3, - retry_timeout=1.5, - retry_backoff_multiplier=2.0, - ) - - assert client.retries == 3 - assert client.retry_timeout == 1.5 - assert client.retry_backoff_multiplier == 2.0 - - def test_retry_property_setters_validation(self): - """Test retry property setters validation.""" - client = SpanPanelClient(host="test") - - # Test retries setter validation - with pytest.raises(ValueError, match="retries must be non-negative"): - client.retries = -1 - - # Test retry_timeout setter validation - with pytest.raises(ValueError, match="retry_timeout must be non-negative"): - client.retry_timeout = -1.0 - - # Test retry_backoff_multiplier setter validation - with pytest.raises(ValueError, match="retry_backoff_multiplier must be at least 1"): - client.retry_backoff_multiplier = 0.5 - - # Test valid setters - client.retries = 5 - client.retry_timeout = 2.0 - client.retry_backoff_multiplier = 2.0 - - assert client.retries == 5 - assert client.retry_timeout == 2.0 - assert client.retry_backoff_multiplier == 2.0 - - def test_retry_configuration_validation_at_init(self): - """Test retry configuration validation during initialization.""" - # Test invalid retries - with pytest.raises(ValueError, match="retries must be non-negative"): - SpanPanelClient(host="test", retries=-1) - - # Test invalid retry_timeout - with pytest.raises(ValueError, match="retry_timeout must be non-negative"): - SpanPanelClient(host="test", retry_timeout=-1.0) - - # Test invalid retry_backoff_multiplier - with pytest.raises(ValueError, match="retry_backoff_multiplier must be at least 1"): - SpanPanelClient(host="test", retry_backoff_multiplier=0.5) diff --git a/tests/test_client_simulation_errors.py b/tests/test_client_simulation_errors.py deleted file mode 100644 index c1fb422..0000000 --- a/tests/test_client_simulation_errors.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Tests for client simulation error conditions.""" - -import pytest -from src.span_panel_api.client import SpanPanelClient -from src.span_panel_api.exceptions import SpanPanelAPIError - - -class TestClientSimulationErrors: - """Tests for client simulation error conditions.""" - - async def test_simulation_engine_not_initialized_error(self): - """Test error when simulation engine is not initialized.""" - client = SpanPanelClient(host="test-serial", simulation_mode=True) - - # Manually set simulation engine to None to trigger error - client._simulation_engine = None - - async with client: - with pytest.raises(SpanPanelAPIError, match="Simulation engine not initialized"): - await client._get_status_simulation() - - async def test_storage_simulation_error(self): - """Test get_storage in simulation mode with uninitialized engine.""" - client = SpanPanelClient( - host="test-serial", simulation_mode=True, simulation_config_path="examples/simple_test_config.yaml" - ) - - async with client: - # First ensure initialization happens - await client.get_status() - - # Now set engine to None to trigger error - client._simulation_engine = None - - with pytest.raises(SpanPanelAPIError, match="Simulation engine not initialized"): - await client.get_storage_soe() - - async def test_panel_state_simulation_error(self): - """Test get_panel_state in simulation mode with uninitialized engine.""" - client = SpanPanelClient( - host="test-serial", simulation_mode=True, simulation_config_path="examples/simple_test_config.yaml" - ) - - async with client: - # First ensure initialization happens - await client.get_status() - - # Now set engine to None to trigger error - client._simulation_engine = None - - with pytest.raises(SpanPanelAPIError, match="Simulation engine not initialized"): - await client._get_panel_state_simulation() - - async def test_simulation_methods_coverage(self): - """Test simulation method paths.""" - client = SpanPanelClient( - host="test-serial", simulation_mode=True, simulation_config_path="examples/simple_test_config.yaml" - ) - - async with client: - # Test all simulation methods - status = await client.get_status() - assert status is not None - - soe = await client.get_storage_soe() - assert soe is not None - - circuits = await client.get_circuits() - assert circuits is not None - - panel_state = await client.get_panel_state() - assert panel_state is not None diff --git a/tests/test_client_simulation_start_time.py b/tests/test_client_simulation_start_time.py deleted file mode 100644 index e0fe1d9..0000000 --- a/tests/test_client_simulation_start_time.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Tests for client simulation start time override functionality.""" - -import pytest -from pathlib import Path -from span_panel_api import SpanPanelClient - - -class TestClientSimulationStartTime: - """Test client simulation start time override functionality.""" - - @pytest.mark.asyncio - async def test_simulation_start_time_override_initialization(self): - """Test simulation start time override during client initialization.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - client = SpanPanelClient( - host="localhost", - simulation_mode=True, - simulation_config_path=str(config_path), - simulation_start_time="2024-06-15T12:00:00", - ) - - async with client: - # Verify the override was set during construction - assert client._simulation_start_time_override == "2024-06-15T12:00:00" - - # Trigger initialization by making an API call - status = await client.get_status() - assert status is not None - - # Now verify initialization completed - assert client._simulation_initialized is True - assert client._simulation_engine is not None diff --git a/tests/test_context_manager.py b/tests/test_context_manager.py deleted file mode 100644 index fb13237..0000000 --- a/tests/test_context_manager.py +++ /dev/null @@ -1,388 +0,0 @@ -"""Tests for the context manager fix that resolves the "Cannot open a client instance more than once" error.""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx -import pytest - -from span_panel_api import SpanPanelClient -from span_panel_api.const import RETRY_MAX_ATTEMPTS -from span_panel_api.exceptions import SpanPanelAPIError, SpanPanelTimeoutError -from tests.test_factories import create_sim_client - - -class TestContextManagerFix: - """Test suite for the context manager lifecycle fix.""" - - @pytest.mark.asyncio - async def test_unauthenticated_requests_work_properly(self): - """Test that unauthenticated requests (like get_status) work both inside and outside context managers.""" - client = create_sim_client() # Use simulation mode for testing - - # Test unauthenticated call OUTSIDE context manager - # This should work without any access token set - assert client._access_token is None - status_outside_context = await client.get_status() - assert status_outside_context is not None - assert status_outside_context.system.manufacturer == "Span" - - # Test unauthenticated call INSIDE context manager WITHOUT setting token - async with client: - assert client._in_context is True - assert client._access_token is None # Still no token set - - status_inside_context = await client.get_status() - assert status_inside_context is not None - assert status_inside_context.system.manufacturer == "Span" - - # Test unauthenticated call INSIDE context manager WITH token set - # (status endpoint should still work even if token is set since it doesn't require auth) - # Create a new client for this test since the previous one was closed - client2 = create_sim_client() - async with client2: - client2.set_access_token("test-token") - assert client2._access_token == "test-token" - - status_with_token = await client2.get_status() - assert status_with_token is not None - assert status_with_token.system.manufacturer == "Span" - - @pytest.mark.asyncio - async def test_context_manager_multiple_api_calls(self): - """Test that multiple API calls work within a single context manager without double context errors.""" - client = create_sim_client() - - # Test multiple calls within a single context manager - async with client: - # Verify we're in context - assert client._in_context is True - assert client._client is not None - - # Multiple calls should not cause "Cannot open a client instance more than once" - # These calls are unauthenticated (status endpoint doesn't require auth) - status1 = await client.get_status() - assert status1 is not None - - status2 = await client.get_status() - assert status2 is not None - - # Set access token for authenticated calls (not needed in simulation but tests the flow) - client.set_access_token("test-token") - - circuits = await client.get_circuits() - assert circuits is not None - - panel = await client.get_panel_state() - assert panel is not None - - storage = await client.get_storage_soe() - assert storage is not None - - # Verify we exited context properly - assert client._in_context is False - - @pytest.mark.asyncio - async def test_context_manager_with_authentication_flow(self): - """Test that authentication within a context manager doesn't break the context.""" - client = SpanPanelClient( - "192.168.1.100", - timeout=5.0, - ) - - with ( - patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth, - patch("span_panel_api.client.get_circuits_api_v1_circuits_get") as mock_circuits, - patch("span_panel_api.client.get_panel_state_api_v1_panel_get") as mock_panel_state, - patch("span_panel_api.client.set_circuit_state_api_v_1_circuits_circuit_id_post") as mock_set_circuit, - ): - # Setup mock responses - auth_response = MagicMock(access_token="test-token-12345", token_type="Bearer") - mock_auth.asyncio = AsyncMock(return_value=auth_response) - - # Mock circuits response with proper to_dict method - circuits_response = MagicMock(circuits=MagicMock(additional_properties={})) - circuits_response.to_dict.return_value = {"circuits": {}} - mock_circuits.asyncio = AsyncMock(return_value=circuits_response) - mock_circuits.asyncio_detailed = AsyncMock(return_value=MagicMock(status_code=200, parsed=circuits_response)) - - mock_panel_state.asyncio = AsyncMock(return_value=MagicMock(branches=[])) - mock_set_circuit.asyncio = AsyncMock(return_value=MagicMock(priority="MUST_HAVE")) - - async with client: - # Verify initial state - assert client._in_context is True - assert client._access_token is None - initial_client = client._client - initial_async_client = getattr(initial_client, "_async_client", None) - - # Authenticate - this WILL upgrade the client to AuthenticatedClient within context - auth_result = await client.authenticate("test-app", "Test Application") - assert auth_result.access_token == "test-token-12345" - assert client._access_token == "test-token-12345" - - # Client should be upgraded to AuthenticatedClient but preserve the httpx async client - from span_panel_api.generated_client import AuthenticatedClient - - assert isinstance(client._client, AuthenticatedClient) - # The underlying httpx async client should be preserved to avoid double context issues - assert getattr(client._client, "_async_client", None) is initial_async_client - - # Post-authentication calls should work - circuits = await client.get_circuits() - assert circuits is not None - - set_result = await client.set_circuit_priority("circuit_1", "MUST_HAVE") - assert set_result is not None - - # Verify clean exit - assert client._in_context is False - - @pytest.mark.asyncio - async def test_context_manager_error_handling_preserves_state(self): - """Test that errors within the context don't break the context manager state.""" - client = SpanPanelClient( - "192.168.1.100", - timeout=5.0, - ) - - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - # First call succeeds, next calls fail (with retry attempts) - # The retry logic will attempt up to RETRY_MAX_ATTEMPTS times for TimeoutException - side_effects = [ - MagicMock(system=MagicMock(manufacturer="SPAN")), # First call succeeds - # Generate RETRY_MAX_ATTEMPTS timeout exceptions for the retries - *([httpx.TimeoutException("Request timeout")] * RETRY_MAX_ATTEMPTS), - ] - mock_status.asyncio = AsyncMock(side_effect=side_effects) - - async with client: - # First call should succeed - status1 = await client.get_status() - assert status1 is not None - - # Second call should fail but not break context - with pytest.raises(SpanPanelTimeoutError): - await client.get_status() - - # Client should still be in context and functional - assert client._in_context is True - assert client._client is not None - - # Should exit cleanly even after error - assert client._in_context is False - - @pytest.mark.asyncio - async def test_set_access_token_behavior_in_context(self): - """Test that set_access_token properly upgrades client when in context.""" - client = SpanPanelClient("192.168.1.100") - - # Test outside context first - client.set_access_token("token1") - assert client._access_token == "token1" - assert client._client is None # Should be reset when not in context - - async with client: - initial_client = client._client - initial_async_client = getattr(initial_client, "_async_client", None) - assert initial_client is not None - assert client._in_context is True - - # Setting token within context should upgrade to AuthenticatedClient - # but preserve the underlying httpx async client - client.set_access_token("token2") - assert client._access_token == "token2" - - # Client should be upgraded to AuthenticatedClient - from span_panel_api.generated_client import AuthenticatedClient - - assert isinstance(client._client, AuthenticatedClient) - # But the underlying httpx async client should be preserved - assert getattr(client._client, "_async_client", None) is initial_async_client - - assert client._in_context is False - - @pytest.mark.asyncio - async def test_context_manager_lifecycle_integrity(self): - """Test the complete context manager lifecycle integrity.""" - client = SpanPanelClient("192.168.1.100") - - # Initially not in context - assert client._in_context is False - assert client._client is None - - async with client: - # Should be in context with client - assert client._in_context is True - assert client._client is not None - context_client = client._client - - # Client should be consistent across calls - endpoint_client = client._get_client_for_endpoint(requires_auth=False) - assert endpoint_client is context_client - - # Should cleanly exit context - assert client._in_context is False - # Client should be cleaned up but access token preserved - assert client._client is None - - @pytest.mark.asyncio - async def test_context_manager_exception_during_exit(self): - """Test that exceptions during context exit are handled gracefully.""" - client = SpanPanelClient("192.168.1.100") - - with patch.object(client, "_client") as mock_client: - # Make the __aexit__ raise an exception - mock_client.__aexit__ = AsyncMock(side_effect=Exception("Exit error")) - - async with client: - assert client._in_context is True - - # Should still mark as not in context despite exit error - assert client._in_context is False - - @pytest.mark.asyncio - async def test_client_none_in_context_error(self): - """Test that we get a clear error if client becomes None while in context.""" - client = SpanPanelClient("192.168.1.100") - - async with client: - # Artificially set client to None to simulate the error condition - client._client = None - - # Should raise a clear error - with pytest.raises(SpanPanelAPIError, match="Client is None while in context"): - client._get_client_for_endpoint(requires_auth=False) - - @pytest.mark.asyncio - async def test_multiple_context_managers_not_allowed(self): - """Test that we can't enter the same client context multiple times.""" - client = SpanPanelClient("192.168.1.100") - - async with client: - # Trying to enter context again should fail at the httpx level - with pytest.raises(RuntimeError, match="Cannot open a client instance more than once"): - async with client: - pass - - def test_context_tracking_flag_behavior(self): - """Test the _in_context flag behavior in various scenarios.""" - client = SpanPanelClient("192.168.1.100") - - # Initially false - assert client._in_context is False - - # Should stay false when not in context - client.set_access_token("test-token") - assert client._in_context is False - - # get_client_for_endpoint should work outside context - endpoint_client = client._get_client_for_endpoint(requires_auth=False) - assert endpoint_client is not None - assert client._in_context is False # Still false after getting client - - -class TestContextManagerEdgeCases: - """Test context manager edge cases for better coverage.""" - - @pytest.mark.asyncio - async def test_context_entry_failure_with_client_error(self): - """Test context manager entry failure when client.__aenter__ fails.""" - client = SpanPanelClient("192.168.1.100") - - # Mock the client to fail on __aenter__ - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(side_effect=Exception("Client enter failed")) - - with patch.object(client, "_get_unauthenticated_client", return_value=mock_client): - with pytest.raises(RuntimeError, match="Failed to enter client context"): - async with client: - pass - - # Verify state was reset - assert not client._in_context - assert not client._httpx_client_owned - - @pytest.mark.asyncio - async def test_context_manager_when_client_is_none_error(self): - """Test _get_client_for_endpoint when client is None in context.""" - client = SpanPanelClient("192.168.1.100") - client._in_context = True - client._client = None - client._access_token = "test-token" # Set token so auth check passes - - with pytest.raises(SpanPanelAPIError, match="Client is None while in context"): - client._get_client_for_endpoint() - - @pytest.mark.asyncio - async def test_context_manager_client_type_mismatch(self): - """Test client type mismatch error in context.""" - from span_panel_api.generated_client import Client - - client = SpanPanelClient("192.168.1.100") - client._in_context = True - client._access_token = "test-token" - - # Create a regular Client when we need AuthenticatedClient - unauthenticated_client = Client( - base_url="http://test", timeout=httpx.Timeout(30.0), verify_ssl=False, raise_on_unexpected_status=True - ) - client._client = unauthenticated_client - - with pytest.raises(SpanPanelAPIError, match="Client type mismatch"): - client._get_client_for_endpoint(requires_auth=True) - - @pytest.mark.asyncio - async def test_context_manager_cleanup_with_client(self): - """Test context manager cleanup with existing client.""" - client = SpanPanelClient("192.168.1.100") - - # Mock a client - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=None) - - with patch.object(client, "_get_unauthenticated_client", return_value=mock_client): - async with client: - # Should set the client during context entry - assert client._client is mock_client - assert client._in_context is True - - # Should clean up after context exit - assert client._in_context is False - - @pytest.mark.asyncio - async def test_context_manager_cleanup_with_exception(self): - """Test context manager cleanup when an exception occurs.""" - client = SpanPanelClient("192.168.1.100") - - # Mock a client that doesn't throw during cleanup - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=None) - - with patch.object(client, "_get_client", return_value=mock_client): - with pytest.raises(ValueError, match="Test exception"): - async with client: - assert client._in_context is True - raise ValueError("Test exception") - - # Should still clean up properly even with exception - assert client._in_context is False - - @pytest.mark.asyncio - async def test_context_manager_entry_exit(self): - """Test context manager entry and exit behavior.""" - client = SpanPanelClient("192.168.1.100") - - # Mock client - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=None) - - with patch.object(client, "_get_client", return_value=mock_client): - async with client as context_client: - assert context_client is client - assert client._in_context is True - assert client._httpx_client_owned is True - - assert client._in_context is False diff --git a/tests/test_core_client.py b/tests/test_core_client.py deleted file mode 100644 index 0e069f2..0000000 --- a/tests/test_core_client.py +++ /dev/null @@ -1,492 +0,0 @@ -"""Tests for core SpanPanelClient functionality. - -This module tests client initialization, properties, configuration, -and basic API method success cases. -""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from span_panel_api import SpanPanelClient -from span_panel_api.exceptions import SpanPanelAPIError - - -class TestClientInitialization: - """Test client initialization and configuration.""" - - def test_client_initialization(self): - """Test that the client can be initialized.""" - client = SpanPanelClient("192.168.1.100") - assert client is not None - assert client._host == "192.168.1.100" - assert client._port == 80 - assert client._timeout == 30.0 - assert client._use_ssl is False - assert client._base_url == "http://192.168.1.100:80" - - def test_client_initialization_with_ssl(self): - """Test client initialization with SSL.""" - client = SpanPanelClient("192.168.1.100", port=443, use_ssl=True) - assert client._port == 443 - assert client._use_ssl is True - assert client._base_url == "https://192.168.1.100:443" - - def test_client_with_custom_port(self): - """Test client initialization with custom port.""" - client = SpanPanelClient("192.168.1.100", port=8080) - assert client._port == 8080 - assert client._base_url == "http://192.168.1.100:8080" - - def test_client_with_ssl(self): - """Test client with SSL enabled.""" - client = SpanPanelClient("192.168.1.100", use_ssl=True) - assert client._use_ssl is True - assert client._base_url == "https://192.168.1.100:80" - - def test_imports(self): - """Test that necessary imports work.""" - from span_panel_api import SpanPanelClient - from span_panel_api.exceptions import SpanPanelAuthError - - assert SpanPanelClient is not None - assert SpanPanelAPIError is not None - assert SpanPanelAuthError is not None - - def test_imports_work(self): - """Test that all expected imports are available.""" - from span_panel_api.generated_client import AuthenticatedClient, Client - from span_panel_api.generated_client.api.default import system_status_api_v1_status_get - - assert AuthenticatedClient is not None - assert Client is not None - assert system_status_api_v1_status_get is not None - - -class TestRetryConfiguration: - """Test retry configuration and properties.""" - - def test_default_configuration(self): - """Test default retry configuration.""" - client = SpanPanelClient("192.168.1.100") - assert client.retries == 0 - assert client.retry_timeout == 0.5 - assert client.retry_backoff_multiplier == 2.0 - - def test_timeout_only_configuration(self): - """Test configuration with only timeout.""" - client = SpanPanelClient("192.168.1.100", timeout=10.0) - assert client._timeout == 10.0 - assert client.retries == 0 - - def test_custom_constructor_configuration(self): - """Test custom retry configuration via constructor.""" - client = SpanPanelClient("192.168.1.100", timeout=15.0, retries=3, retry_timeout=1.0, retry_backoff_multiplier=1.5) - assert client._timeout == 15.0 - assert client.retries == 3 - assert client.retry_timeout == 1.0 - assert client.retry_backoff_multiplier == 1.5 - - def test_runtime_configuration_changes(self): - """Test changing retry configuration at runtime.""" - client = SpanPanelClient("192.168.1.100") - - # Change retries - client.retries = 2 - assert client.retries == 2 - - # Change retry timeout - client.retry_timeout = 1.5 - assert client.retry_timeout == 1.5 - - # Change backoff multiplier - client.retry_backoff_multiplier = 3.0 - assert client.retry_backoff_multiplier == 3.0 - - def test_validation_retries(self): - """Test validation of retries parameter.""" - client = SpanPanelClient("192.168.1.100") - with pytest.raises(ValueError, match="retries must be non-negative"): - client.retries = -1 - - def test_validation_retry_timeout(self): - """Test validation of retry_timeout parameter.""" - client = SpanPanelClient("192.168.1.100") - with pytest.raises(ValueError, match="retry_timeout must be non-negative"): - client.retry_timeout = -0.5 - - def test_validation_backoff_multiplier(self): - """Test validation of retry_backoff_multiplier parameter.""" - client = SpanPanelClient("192.168.1.100") - with pytest.raises(ValueError, match="retry_backoff_multiplier must be at least 1"): - client.retry_backoff_multiplier = 0.5 - - def test_constructor_validation(self): - """Test validation during construction.""" - with pytest.raises(ValueError, match="retries must be non-negative"): - SpanPanelClient("192.168.1.100", retries=-1) - - with pytest.raises(ValueError, match="retry_timeout must be non-negative"): - SpanPanelClient("192.168.1.100", retry_timeout=-1.0) - - with pytest.raises(ValueError, match="retry_backoff_multiplier must be at least 1"): - SpanPanelClient("192.168.1.100", retry_backoff_multiplier=0.5) - - -class TestClientInternals: - """Test client internal methods and state management.""" - - def test_set_access_token(self): - """Test setting access token.""" - client = SpanPanelClient("192.168.1.100") - assert client._access_token is None - - client.set_access_token("test-token") - assert client._access_token == "test-token" - - def test_get_client_with_authenticated_client_path(self): - """Test _get_client with authenticated client path.""" - client = SpanPanelClient("192.168.1.100") - client.set_access_token("test-token") - - http_client = client._get_client() - from span_panel_api.generated_client import AuthenticatedClient - - assert isinstance(http_client, AuthenticatedClient) - - def test_get_unauthenticated_client(self): - """Test getting unauthenticated client.""" - client = SpanPanelClient("192.168.1.100") - unauthenticated_client = client._get_unauthenticated_client() - - from span_panel_api.generated_client import Client - - assert isinstance(unauthenticated_client, Client) - - def test_get_unauthenticated_client_when_not_in_context_and_no_existing_client(self): - """Test _get_unauthenticated_client when not in context and no existing client.""" - client = SpanPanelClient("192.168.1.100") - - # Ensure initial state - client._in_context = False - client._client = None - client._httpx_client_owned = False - - # Get unauthenticated client - unauthenticated_client = client._get_unauthenticated_client() - - # Should create and set the client - assert client._client is unauthenticated_client - assert client._httpx_client_owned is True - - def test_get_unauthenticated_client_when_in_context(self): - """Test _get_unauthenticated_client when in context.""" - client = SpanPanelClient("192.168.1.100") - client._in_context = True - - # Get unauthenticated client - client._get_unauthenticated_client() - - # Should not set the client or ownership when in context - assert client._client is None - assert client._httpx_client_owned is False - - def test_get_unauthenticated_client_when_not_in_context_but_has_existing_client(self): - """Test _get_unauthenticated_client when not in context but already has client.""" - client = SpanPanelClient("192.168.1.100") - client._in_context = False - client._client = MagicMock() # Existing client - - # Get unauthenticated client - unauthenticated_client = client._get_unauthenticated_client() - - # Should not change existing client or ownership - assert client._client is not unauthenticated_client - assert client._httpx_client_owned is False - - -class TestAPIMethodsSuccess: - """Test successful API method calls using simulation mode.""" - - @pytest.mark.asyncio - async def test_get_status_success(self, sim_client: SpanPanelClient): - """Test successful status retrieval using simulation mode.""" - result = await sim_client.get_status() - - # Verify we get a proper status response with expected fields - assert result is not None - assert hasattr(result, "system") - assert hasattr(result, "network") - assert hasattr(result.system, "door_state") - - @pytest.mark.asyncio - async def test_get_panel_state_success(self, sim_client: SpanPanelClient): - """Test successful panel state retrieval using simulation mode.""" - result = await sim_client.get_panel_state() - - # Verify we get a proper panel state response with expected fields - assert result is not None - assert hasattr(result, "branches") - assert hasattr(result, "main_relay_state") - assert hasattr(result, "instant_grid_power_w") - - @pytest.mark.asyncio - async def test_get_circuits_success(self, sim_client: SpanPanelClient): - """Test successful circuits retrieval using simulation mode.""" - result = await sim_client.get_circuits() - - # Verify we get a proper circuits response with expected fields - assert result is not None - assert hasattr(result, "circuits") - - # Circuits are stored in additional_properties - circuits = result.circuits.additional_properties - assert len(circuits) > 0 - - # Check first circuit has expected attributes - first_circuit = next(iter(circuits.values())) - assert hasattr(first_circuit, "id") - assert hasattr(first_circuit, "name") - assert hasattr(first_circuit, "instant_power_w") - - @pytest.mark.asyncio - async def test_get_storage_soe_success(self, sim_client: SpanPanelClient): - """Test successful storage SOE retrieval using simulation mode.""" - result = await sim_client.get_storage_soe() - - # Verify we get a proper storage response with expected fields - assert result is not None - assert hasattr(result, "soe") - assert hasattr(result.soe, "percentage") - assert isinstance(result.soe.percentage, (int | float)) - assert 0 <= result.soe.percentage <= 100 - - @pytest.mark.asyncio - async def test_set_circuit_relay_success(self): - """Test successful circuit relay setting (live mode only - no simulation support).""" - client = SpanPanelClient("192.168.1.100") - client.set_access_token("test-token") - - with patch("span_panel_api.client.set_circuit_state_api_v_1_circuits_circuit_id_post") as mock_set_circuit: - # Mock circuit update response - circuit_response = MagicMock() - circuit_response.relay_state = "OPEN" - mock_set_circuit.asyncio = AsyncMock(return_value=circuit_response) - - result = await client.set_circuit_relay("circuit-1", "OPEN") - - assert result == circuit_response - mock_set_circuit.asyncio.assert_called_once() - - @pytest.mark.asyncio - async def test_set_circuit_priority_success(self): - """Test successful circuit priority setting (live mode only - no simulation support).""" - client = SpanPanelClient("192.168.1.100") - client.set_access_token("test-token") - - with patch("span_panel_api.client.set_circuit_state_api_v_1_circuits_circuit_id_post") as mock_set_circuit: - # Mock circuit update response - circuit_response = MagicMock() - circuit_response.priority = "MUST_HAVE" - mock_set_circuit.asyncio = AsyncMock(return_value=circuit_response) - - result = await client.set_circuit_priority("circuit-1", "MUST_HAVE") - - assert result == circuit_response - mock_set_circuit.asyncio.assert_called_once() - - -class TestGeneratedClient: - """Test the generated httpx-based client.""" - - def test_client_import(self): - """Test that the generated client can be imported.""" - from span_panel_api.generated_client import AuthenticatedClient, Client - - # This should not raise any import errors - assert Client is not None - assert AuthenticatedClient is not None - - def test_client_creation(self): - """Test that clients can be created.""" - from span_panel_api.generated_client import AuthenticatedClient, Client - - client = Client(base_url="https://test.example.com") - assert client is not None - - auth_client = AuthenticatedClient(base_url="https://test.example.com", token="test-token") - assert auth_client is not None - - def test_api_functions_import(self): - """Test that API functions can be imported.""" - from span_panel_api.generated_client.api.default import system_status_api_v1_status_get - - # This should not raise import errors - assert system_status_api_v1_status_get is not None - - @pytest.mark.asyncio - async def test_client_context_manager(self): - """Test that the client works as an async context manager.""" - from span_panel_api.generated_client import Client - - async with Client(base_url="https://test.example.com") as client: - # Just test that the context manager works - assert client is not None - - -class TestExceptionInheritance: - """Test exception type hierarchy and constants.""" - - def test_exceptions_inheritance(self): - """Test that exceptions inherit from the correct base classes.""" - from span_panel_api.exceptions import ( - SpanPanelAuthError, - SpanPanelConnectionError, - SpanPanelError, - SpanPanelRetriableError, - SpanPanelServerError, - SpanPanelTimeoutError, - ) - - # All exceptions should inherit from SpanPanelError - assert issubclass(SpanPanelAuthError, SpanPanelError) - assert issubclass(SpanPanelConnectionError, SpanPanelError) - assert issubclass(SpanPanelAPIError, SpanPanelError) - assert issubclass(SpanPanelTimeoutError, SpanPanelError) - - # Server errors should inherit from SpanPanelAPIError - assert issubclass(SpanPanelRetriableError, SpanPanelAPIError) - assert issubclass(SpanPanelServerError, SpanPanelAPIError) - - def test_api_error_with_status_code(self): - """Test SpanPanelAPIError with status code.""" - - error = SpanPanelAPIError("Test error", 404) - assert str(error) == "Test error" - assert error.status_code == 404 - - def test_exception_inheritance(self): - """Test exception inheritance structure.""" - from span_panel_api.exceptions import ( - SpanPanelAuthError, - SpanPanelConnectionError, - SpanPanelError, - SpanPanelRetriableError, - SpanPanelServerError, - SpanPanelTimeoutError, - ) - - # Test that all specific errors inherit from base SpanPanelError - assert issubclass(SpanPanelAuthError, SpanPanelError) - assert issubclass(SpanPanelConnectionError, SpanPanelError) - assert issubclass(SpanPanelAPIError, SpanPanelError) - assert issubclass(SpanPanelTimeoutError, SpanPanelError) - - # Test that server errors inherit from SpanPanelAPIError - assert issubclass(SpanPanelRetriableError, SpanPanelAPIError) - assert issubclass(SpanPanelServerError, SpanPanelAPIError) - - # Test that all inherit from standard Exception - assert issubclass(SpanPanelError, Exception) - - def test_status_code_constants(self): - """Test that status code constants are defined correctly.""" - from span_panel_api.const import AUTH_ERROR_CODES, RETRIABLE_ERROR_CODES, SERVER_ERROR_CODES - - # Auth errors: 401, 403 - assert 401 in AUTH_ERROR_CODES - assert 403 in AUTH_ERROR_CODES - - # Retriable errors: 502, 503, 504 - assert 502 in RETRIABLE_ERROR_CODES - assert 503 in RETRIABLE_ERROR_CODES - assert 504 in RETRIABLE_ERROR_CODES - - # Server errors: 500 - assert 500 in SERVER_ERROR_CODES - - -class TestAPIMethodsErrorPaths: - """Test error paths for API methods (panel state, circuits, storage SOE, relay/priority).""" - - @pytest.mark.asyncio - async def test_panel_state_error_paths(self): - from span_panel_api.exceptions import SpanPanelAPIError, SpanPanelAuthError - from tests.test_factories import create_live_client - - client = create_live_client() - client.set_access_token("test-token") - with patch("span_panel_api.client.get_panel_state_api_v1_panel_get") as mock_panel: - mock_panel.asyncio = AsyncMock(side_effect=ValueError("Validation error")) - with pytest.raises(SpanPanelAPIError, match="API error: Validation error"): - await client.get_panel_state() - with patch("span_panel_api.client.get_panel_state_api_v1_panel_get") as mock_panel: - mock_panel.asyncio = AsyncMock(side_effect=RuntimeError("401 Unauthorized access")) - with pytest.raises(SpanPanelAuthError, match="Authentication failed"): - await client.get_panel_state() - with patch("span_panel_api.client.get_panel_state_api_v1_panel_get") as mock_panel: - mock_panel.asyncio = AsyncMock(side_effect=RuntimeError("Some other error")) - with pytest.raises(SpanPanelAPIError, match="Unexpected error: Some other error"): - await client.get_panel_state() - - @pytest.mark.asyncio - async def test_circuits_error_paths(self): - from span_panel_api.exceptions import SpanPanelAPIError - from tests.test_factories import create_live_client - - client = create_live_client() - client.set_access_token("test-token") - with patch("span_panel_api.client.get_circuits_api_v1_circuits_get") as mock_circuits: - mock_circuits.asyncio = AsyncMock(side_effect=ValueError("Circuit validation error")) - mock_circuits.asyncio_detailed = AsyncMock(side_effect=ValueError("Circuit validation error")) - with pytest.raises(SpanPanelAPIError, match="API error: Circuit validation error"): - await client.get_circuits() - with patch("span_panel_api.client.get_circuits_api_v1_circuits_get") as mock_circuits: - mock_circuits.asyncio = AsyncMock(side_effect=RuntimeError("Circuit error")) - mock_circuits.asyncio_detailed = AsyncMock(side_effect=RuntimeError("Circuit error")) - with pytest.raises(SpanPanelAPIError, match="Unexpected error: Circuit error"): - await client.get_circuits() - - @pytest.mark.asyncio - async def test_storage_soe_error_paths(self): - from span_panel_api.exceptions import SpanPanelAPIError - from tests.test_factories import create_live_client - - client = create_live_client() - client.set_access_token("test-token") - with patch("span_panel_api.client.get_storage_soe_api_v1_storage_soe_get") as mock_storage: - mock_storage.asyncio = AsyncMock(side_effect=ValueError("Storage validation error")) - with pytest.raises(SpanPanelAPIError, match="API error: Storage validation error"): - await client.get_storage_soe() - with patch("span_panel_api.client.get_storage_soe_api_v1_storage_soe_get") as mock_storage: - mock_storage.asyncio = AsyncMock(side_effect=RuntimeError("Storage error")) - with pytest.raises(SpanPanelAPIError, match="Unexpected error: Storage error"): - await client.get_storage_soe() - - @pytest.mark.asyncio - async def test_set_circuit_relay_error_paths(self): - from span_panel_api.exceptions import SpanPanelAPIError - from tests.test_factories import create_live_client - - client = create_live_client() - client.set_access_token("test-token") - with pytest.raises(SpanPanelAPIError, match="Invalid relay state 'INVALID'"): - await client.set_circuit_relay("circuit-1", "INVALID") - with patch("span_panel_api.client.set_circuit_state_api_v_1_circuits_circuit_id_post") as mock_set: - mock_set.asyncio = AsyncMock(side_effect=RuntimeError("Relay error")) - with pytest.raises(SpanPanelAPIError, match="Unexpected error: Relay error"): - await client.set_circuit_relay("circuit-1", "OPEN") - - @pytest.mark.asyncio - async def test_set_circuit_priority_error_paths(self): - from span_panel_api.exceptions import SpanPanelAPIError - from tests.test_factories import create_live_client - - client = create_live_client() - client.set_access_token("test-token") - with pytest.raises(SpanPanelAPIError, match="'INVALID' is not a valid Priority"): - await client.set_circuit_priority("circuit-1", "INVALID") - with patch("span_panel_api.client.set_circuit_state_api_v_1_circuits_circuit_id_post") as mock_set: - mock_set.asyncio = AsyncMock(side_effect=RuntimeError("Priority error")) - with pytest.raises(SpanPanelAPIError, match="Unexpected error: Priority error"): - await client.set_circuit_priority("circuit-1", "MUST_HAVE") diff --git a/tests/test_coverage_completion.py b/tests/test_coverage_completion.py deleted file mode 100644 index 4c04300..0000000 --- a/tests/test_coverage_completion.py +++ /dev/null @@ -1,425 +0,0 @@ -"""Tests to achieve 99%+ coverage by targeting specific missing lines.""" - -import pytest -import time -from pathlib import Path -from unittest.mock import Mock, patch -import yaml - -from span_panel_api import SpanPanelClient -from span_panel_api.simulation import DynamicSimulationEngine -from span_panel_api.phase_validation import get_valid_tabs_from_panel_data - - -class TestPhaseValidationCoverage: - """Test phase validation missing lines.""" - - def test_get_valid_tabs_from_panel_data_missing_branches(self): - """Test get_valid_tabs_from_panel_data with missing branches key.""" - # Panel data without branches key - panel_data = {"panel": {"serial_number": "test123"}} - - tabs = get_valid_tabs_from_panel_data(panel_data) - assert tabs == [] - - -class TestSimulationCoverage: - """Test simulation missing lines.""" - - @pytest.mark.asyncio - async def test_simulation_time_initialization_with_invalid_time(self): - """Test simulation time initialization with invalid time format.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-invalid-time", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Override simulation start time with invalid format - engine = client._simulation_engine - assert engine is not None - - # Test with invalid time format - engine.override_simulation_start_time("invalid-time-format") - - # Should fallback to real time - assert not engine._use_simulation_time - - @pytest.mark.asyncio - async def test_simulation_time_initialization_with_z_suffix(self): - """Test simulation time initialization with Z suffix.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-z-suffix", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - engine = client._simulation_engine - assert engine is not None - - # Test with Z suffix - engine.override_simulation_start_time("2024-06-15T12:00:00Z") - - # Should handle Z suffix correctly - check that it doesn't crash - # The actual behavior depends on the current time, so we just verify it's set - assert hasattr(engine, "_use_simulation_time") - - @pytest.mark.asyncio - async def test_yaml_validation_errors(self): - """Test YAML validation error paths.""" - # Test missing required sections - invalid_config = { - "panel_config": {"serial_number": "test", "total_tabs": 40, "main_size": 200}, - # Missing circuit_templates and circuits - } - - # Test validation during initialization - engine = DynamicSimulationEngine(config_data=invalid_config) - with pytest.raises(ValueError, match="Missing required section: circuit_templates"): - await engine.initialize_async() - - # Test invalid panel_config type - invalid_config = {"panel_config": "not_a_dict", "circuit_templates": {}, "circuits": []} - - engine = DynamicSimulationEngine(config_data=invalid_config) - with pytest.raises(ValueError, match="panel_config must be a dictionary"): - await engine.initialize_async() - - # Test missing panel_config fields - invalid_config = { - "panel_config": {"serial_number": "test"}, # Missing total_tabs and main_size - "circuit_templates": {}, - "circuits": [], - } - - engine = DynamicSimulationEngine(config_data=invalid_config) - with pytest.raises(ValueError, match="Missing required panel_config field: total_tabs"): - await engine.initialize_async() - - # Test invalid circuit_templates type - invalid_config = { - "panel_config": {"serial_number": "test", "total_tabs": 40, "main_size": 200}, - "circuit_templates": "not_a_dict", - "circuits": [], - } - - engine = DynamicSimulationEngine(config_data=invalid_config) - with pytest.raises(ValueError, match="circuit_templates must be a dictionary"): - await engine.initialize_async() - - # Test empty circuit_templates - invalid_config = { - "panel_config": {"serial_number": "test", "total_tabs": 40, "main_size": 200}, - "circuit_templates": {}, - "circuits": [], - } - - engine = DynamicSimulationEngine(config_data=invalid_config) - with pytest.raises(ValueError, match="At least one circuit template must be defined"): - await engine.initialize_async() - - # Test invalid template type - invalid_config = { - "panel_config": {"serial_number": "test", "total_tabs": 40, "main_size": 200}, - "circuit_templates": {"test_template": "not_a_dict"}, - "circuits": [], - } - - engine = DynamicSimulationEngine(config_data=invalid_config) - with pytest.raises(ValueError, match="Circuit template 'test_template' must be a dictionary"): - await engine.initialize_async() - - # Test missing template fields - invalid_config = { - "panel_config": {"serial_number": "test", "total_tabs": 40, "main_size": 200}, - "circuit_templates": {"test_template": {"energy_profile": {}}}, # Missing relay_behavior and priority - "circuits": [], - } - - engine = DynamicSimulationEngine(config_data=invalid_config) - with pytest.raises(ValueError, match="Missing required field 'relay_behavior' in circuit template 'test_template'"): - await engine.initialize_async() - - # Test invalid circuits type - invalid_config = { - "panel_config": {"serial_number": "test", "total_tabs": 40, "main_size": 200}, - "circuit_templates": { - "test_template": { - "energy_profile": { - "mode": "consumer", - "power_range": [0, 100], - "typical_power": 50, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - } - }, - "circuits": "not_a_list", - } - - engine = DynamicSimulationEngine(config_data=invalid_config) - with pytest.raises(ValueError, match="circuits must be a list"): - await engine.initialize_async() - - # Test empty circuits - invalid_config = { - "panel_config": {"serial_number": "test", "total_tabs": 40, "main_size": 200}, - "circuit_templates": { - "test_template": { - "energy_profile": { - "mode": "consumer", - "power_range": [0, 100], - "typical_power": 50, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - } - }, - "circuits": [], - } - - engine = DynamicSimulationEngine(config_data=invalid_config) - with pytest.raises(ValueError, match="At least one circuit must be defined"): - await engine.initialize_async() - - # Test invalid circuit type - invalid_config = { - "panel_config": {"serial_number": "test", "total_tabs": 40, "main_size": 200}, - "circuit_templates": { - "test_template": { - "energy_profile": { - "mode": "consumer", - "power_range": [0, 100], - "typical_power": 50, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - } - }, - "circuits": ["not_a_dict"], - } - - engine = DynamicSimulationEngine(config_data=invalid_config) - with pytest.raises(ValueError, match="Circuit 0 must be a dictionary"): - await engine.initialize_async() - - # Test missing circuit fields - invalid_config = { - "panel_config": {"serial_number": "test", "total_tabs": 40, "main_size": 200}, - "circuit_templates": { - "test_template": { - "energy_profile": { - "mode": "consumer", - "power_range": [0, 100], - "typical_power": 50, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - } - }, - "circuits": [{"id": "test"}], # Missing name, template, tabs - } - - engine = DynamicSimulationEngine(config_data=invalid_config) - with pytest.raises(ValueError, match="Missing required field 'name' in circuit 0"): - await engine.initialize_async() - - # Test unknown template reference - invalid_config = { - "panel_config": {"serial_number": "test", "total_tabs": 40, "main_size": 200}, - "circuit_templates": { - "test_template": { - "energy_profile": { - "mode": "consumer", - "power_range": [0, 100], - "typical_power": 50, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - } - }, - "circuits": [{"id": "test", "name": "Test Circuit", "template": "unknown_template", "tabs": [1]}], - } - - engine = DynamicSimulationEngine(config_data=invalid_config) - with pytest.raises(ValueError, match="Circuit 0 references unknown template 'unknown_template'"): - await engine.initialize_async() - - @pytest.mark.asyncio - async def test_dynamic_overrides_priority(self): - """Test dynamic overrides with priority setting.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-overrides", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - engine = client._simulation_engine - assert engine is not None - - # Set circuit-specific overrides including priority - engine.set_dynamic_overrides( - {"kitchen_lights": {"power_override": 100.0, "relay_state": "CLOSED", "priority": "NON_ESSENTIAL"}} - ) - - # Get circuits to trigger override application - circuits = await client.get_circuits() - assert circuits is not None - - @pytest.mark.asyncio - async def test_global_power_multiplier(self): - """Test global power multiplier override.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-global-multiplier", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - engine = client._simulation_engine - assert engine is not None - - # Set global power multiplier - engine.set_dynamic_overrides(global_overrides={"power_multiplier": 2.0}) - - # Get circuits to trigger override application - circuits = await client.get_circuits() - assert circuits is not None - - @pytest.mark.asyncio - async def test_time_based_soe_edge_hours(self): - """Test time-based SOE for edge hours not in profile.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-soe-edge", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - engine = client._simulation_engine - assert engine is not None - - # Test hour not in profile (should return 50.0 default) - with patch("span_panel_api.simulation.datetime") as mock_datetime: - mock_dt = Mock() - mock_dt.hour = 25 # Invalid hour - mock_datetime.fromtimestamp.return_value = mock_dt - - soe_data = await engine.get_soe() - # The SOE calculation might use different logic, so just verify it's a valid percentage - assert 0.0 <= soe_data["soe"]["percentage"] <= 100.0 - - @pytest.mark.asyncio - async def test_tab_synchronization_primary_secondary(self): - """Test tab synchronization with primary_secondary power split.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-primary-secondary", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - engine = client._simulation_engine - assert engine is not None - - # Test primary_secondary power split - # This tests the missing lines in _get_synchronized_power - sync_config = { - "tabs": [33, 35], - "behavior": "240v_split_phase", - "power_split": "primary_secondary", - "energy_sync": True, - "template": "battery_sync", - } - - # Test first tab (primary) - power = engine._get_synchronized_power(33, 1000.0, sync_config) - assert power == 1000.0 - - # Test second tab (secondary) - should get 0 power - power = engine._get_synchronized_power(35, 1000.0, sync_config) - # The tab needs to be in the sync group for this to work - # Let's test the actual behavior - assert isinstance(power, float) - - @pytest.mark.asyncio - async def test_tab_synchronization_energy_sync_fallback(self): - """Test tab synchronization energy sync fallback paths.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-energy-sync", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - engine = client._simulation_engine - assert engine is not None - - # Test energy sync with no sync group found - # This tests the fallback path in _synchronize_energy_for_tab - produced, consumed = engine._synchronize_energy_for_tab(999, "test_circuit", 100.0, time.time()) - assert isinstance(produced, float) - assert isinstance(consumed, float) - - @pytest.mark.asyncio - async def test_simulation_engine_no_config(self): - """Test simulation engine behavior without config.""" - # Test engine without config - engine = DynamicSimulationEngine() - - # Test serial number property without config - should raise error - with pytest.raises(ValueError, match="No configuration loaded - serial number not available"): - _ = engine.serial_number - - # Test get_status without config - status = await engine.get_status() - assert status is not None - - # Test _generate_branches without config - branches = engine._generate_branches() - assert branches == [] - - @pytest.mark.asyncio - async def test_simulation_time_with_acceleration(self): - """Test simulation time with time acceleration.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-time-acceleration", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - engine = client._simulation_engine - assert engine is not None - - # Enable simulation time with acceleration - engine.override_simulation_start_time("2024-06-15T12:00:00") - - # Test get_current_simulation_time with acceleration - sim_time = engine.get_current_simulation_time() - assert isinstance(sim_time, float) - assert sim_time > 0 - - @pytest.mark.asyncio - async def test_yaml_config_loading_errors(self): - """Test YAML config loading error paths.""" - # Test with non-existent config path - engine = DynamicSimulationEngine(config_path="non_existent_file.yaml") - with pytest.raises(ValueError, match="YAML configuration is required"): - await engine.initialize_async() - - # Test with invalid config data type - engine = DynamicSimulationEngine(config_data="not_a_dict") - with pytest.raises(ValueError, match="YAML configuration must be a dictionary"): - await engine.initialize_async() - - @pytest.mark.asyncio - async def test_simulation_engine_serial_override(self): - """Test simulation engine with serial number override.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-serial-override", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - engine = client._simulation_engine - assert engine is not None - - # Test serial number override - assert engine.serial_number != "CUSTOM-SERIAL-123" # Should be from config - - # Test with custom serial number - custom_engine = DynamicSimulationEngine(serial_number="CUSTOM-SERIAL-123", config_path=config_path) - await custom_engine.initialize_async() - assert custom_engine.serial_number == "CUSTOM-SERIAL-123" diff --git a/tests/test_detection_auth.py b/tests/test_detection_auth.py new file mode 100644 index 0000000..a725986 --- /dev/null +++ b/tests/test_detection_auth.py @@ -0,0 +1,451 @@ +"""Tests for v2 REST Endpoints & Detection.""" + +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from span_panel_api.detection import DetectionResult, detect_api_version +from span_panel_api.exceptions import ( + SpanPanelAPIError, + SpanPanelAuthError, + SpanPanelConnectionError, + SpanPanelTimeoutError, +) +from span_panel_api.models import ( + V2AuthResponse, + V2HomieSchema, + V2StatusInfo, +) +from span_panel_api.auth import ( + download_ca_cert, + get_homie_schema, + get_v2_status, + regenerate_passphrase, + register_v2, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _mock_response(status_code: int = 200, json_data: dict | None = None, text: str = "") -> httpx.Response: + """Build a mock httpx.Response.""" + import json + + if json_data is not None: + content = json.dumps(json_data).encode() + headers = {"content-type": "application/json"} + else: + content = text.encode() + headers = {"content-type": "text/plain"} + + return httpx.Response( + status_code=status_code, + content=content, + headers=headers, + request=httpx.Request("GET", "http://test"), + ) + + +V2_STATUS_JSON = {"serialNumber": "nj-2316-XXXX", "firmwareVersion": "spanos2/r202603/05"} + +V2_AUTH_JSON = { + "accessToken": "jwt-token-here", + "tokenType": "Bearer", + "iatMs": 1700000000000, + "ebusBrokerUsername": "user123", + "ebusBrokerPassword": "pass456", + "ebusBrokerHost": "192.168.65.70", + "ebusBrokerMqttsPort": 8883, + "ebusBrokerWsPort": 9001, + "ebusBrokerWssPort": 9002, + "hostname": "spanpanel", + "serialNumber": "nj-2316-XXXX", + "hopPassphrase": "hop-secret", +} + +PEM_CERT = """-----BEGIN CERTIFICATE----- +MIIBkTCB+wIJALRiMLAh2FfIMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl +c3RjYTAeFw0yNDAyMjQwMDAwMDBaFw0yNTAyMjQwMDAwMDBaMBExDzANBgNVBAMM +BnRlc3RjYTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC7o96VJ4GL0xNPHOVQ+Knx +-----END CERTIFICATE-----""" + + +# =================================================================== +# detect_api_version +# =================================================================== + + +class TestDetectApiVersion: + @pytest.mark.asyncio + async def test_detect_v2_panel(self): + mock_response = _mock_response(200, V2_STATUS_JSON) + with patch("span_panel_api.detection.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await detect_api_version("192.168.65.70") + + assert result.api_version == "v2" + assert result.status_info is not None + assert result.status_info.serial_number == "nj-2316-XXXX" + assert result.status_info.firmware_version == "spanos2/r202603/05" + + @pytest.mark.asyncio + async def test_detect_v1_panel_404(self): + mock_response = _mock_response(404) + with patch("span_panel_api.detection.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await detect_api_version("192.168.1.1") + + assert result.api_version == "v1" + assert result.status_info is None + + @pytest.mark.asyncio + async def test_detect_v1_connection_error(self): + with patch("span_panel_api.detection.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.side_effect = httpx.ConnectError("Connection refused") + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await detect_api_version("192.168.1.1") + + assert result.api_version == "v1" + assert result.status_info is None + + @pytest.mark.asyncio + async def test_detect_v1_timeout(self): + with patch("span_panel_api.detection.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.side_effect = httpx.TimeoutException("Timed out") + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await detect_api_version("192.168.1.1") + + assert result.api_version == "v1" + assert result.status_info is None + + def test_detection_result_frozen(self): + result = DetectionResult(api_version="v2", status_info=V2StatusInfo("serial", "fw")) + with pytest.raises(AttributeError): + result.api_version = "v1" # type: ignore[misc] + + +# =================================================================== +# register_v2 +# =================================================================== + + +class TestRegisterV2: + @pytest.mark.asyncio + async def test_register_success(self): + mock_response = _mock_response(200, V2_AUTH_JSON) + with patch("span_panel_api.auth.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await register_v2("192.168.65.70", "Home Assistant", "my-passphrase") + + assert isinstance(result, V2AuthResponse) + assert result.access_token == "jwt-token-here" + assert result.token_type == "Bearer" + assert result.iat_ms == 1700000000000 + assert result.ebus_broker_username == "user123" + assert result.ebus_broker_password == "pass456" + assert result.ebus_broker_host == "192.168.65.70" + assert result.ebus_broker_mqtts_port == 8883 + assert result.ebus_broker_ws_port == 9001 + assert result.ebus_broker_wss_port == 9002 + assert result.hostname == "spanpanel" + assert result.serial_number == "nj-2316-XXXX" + assert result.hop_passphrase == "hop-secret" + + @pytest.mark.asyncio + async def test_register_invalid_passphrase(self): + mock_response = _mock_response(422, text="Invalid passphrase") + with patch("span_panel_api.auth.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + with pytest.raises(SpanPanelAuthError, match="422"): + await register_v2("192.168.65.70", "HA", "wrong") + + @pytest.mark.asyncio + async def test_register_connection_error(self): + with patch("span_panel_api.auth.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.post.side_effect = httpx.ConnectError("Connection refused") + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + with pytest.raises(SpanPanelConnectionError): + await register_v2("192.168.65.70", "HA", "pass") + + @pytest.mark.asyncio + async def test_register_timeout(self): + with patch("span_panel_api.auth.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.post.side_effect = httpx.TimeoutException("Timed out") + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + with pytest.raises(SpanPanelTimeoutError): + await register_v2("192.168.65.70", "HA", "pass") + + +# =================================================================== +# download_ca_cert +# =================================================================== + + +class TestDownloadCaCert: + @pytest.mark.asyncio + async def test_download_success(self): + mock_response = _mock_response(200, text=PEM_CERT) + with patch("span_panel_api.auth.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await download_ca_cert("192.168.65.70") + + assert result.startswith("-----BEGIN") + + @pytest.mark.asyncio + async def test_download_invalid_pem(self): + mock_response = _mock_response(200, text="not-a-pem") + with patch("span_panel_api.auth.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + with pytest.raises(SpanPanelAPIError, match="not a valid PEM"): + await download_ca_cert("192.168.65.70") + + @pytest.mark.asyncio + async def test_download_http_error(self): + mock_response = _mock_response(500) + with patch("span_panel_api.auth.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + with pytest.raises(SpanPanelAPIError, match="500"): + await download_ca_cert("192.168.65.70") + + +# =================================================================== +# get_homie_schema +# =================================================================== + + +class TestGetHomieSchema: + @pytest.mark.asyncio + async def test_parse_schema(self): + schema_json = { + "firmwareVersion": "spanos2/r202603/05", + "homieDomain": "ebus", + "homieVersion": 5, + "types": { + "energy.ebus.device.distribution-enclosure.core": { + "door": {"name": "Door state", "datatype": "enum", "format": "UNKNOWN,OPEN,CLOSED"}, + } + }, + } + mock_response = _mock_response(200, schema_json) + with patch("span_panel_api.auth.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await get_homie_schema("192.168.65.70") + + assert isinstance(result, V2HomieSchema) + assert result.firmware_version == "spanos2/r202603/05" + assert result.types_schema_hash.startswith("sha256:") + assert len(result.types_schema_hash) == len("sha256:") + 16 + assert "energy.ebus.device.distribution-enclosure.core" in result.types + core_type = result.types["energy.ebus.device.distribution-enclosure.core"] + assert "door" in core_type + + @pytest.mark.asyncio + async def test_schema_frozen(self): + result = V2HomieSchema(firmware_version="fw", types_schema_hash="hash", types={}) + with pytest.raises(AttributeError): + result.firmware_version = "changed" # type: ignore[misc] + + +# =================================================================== +# regenerate_passphrase +# =================================================================== + + +class TestRegeneratePassphrase: + @pytest.mark.asyncio + async def test_regenerate_success(self): + mock_response = _mock_response(200, {"ebusBrokerPassword": "new-password-123"}) + with patch("span_panel_api.auth.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.put.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await regenerate_passphrase("192.168.65.70", "jwt-token") + + assert result == "new-password-123" + + @pytest.mark.asyncio + async def test_regenerate_auth_error(self): + mock_response = _mock_response(401) + with patch("span_panel_api.auth.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.put.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + with pytest.raises(SpanPanelAuthError, match="401"): + await regenerate_passphrase("192.168.65.70", "bad-token") + + @pytest.mark.asyncio + async def test_regenerate_412_precondition_failed(self): + mock_response = _mock_response(412) + with patch("span_panel_api.auth.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.put.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + with pytest.raises(SpanPanelAuthError, match="412"): + await regenerate_passphrase("192.168.65.70", "") + + +# =================================================================== +# get_v2_status +# =================================================================== + + +class TestGetV2Status: + @pytest.mark.asyncio + async def test_get_status_success(self): + mock_response = _mock_response(200, V2_STATUS_JSON) + with patch("span_panel_api.auth.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await get_v2_status("192.168.65.70") + + assert isinstance(result, V2StatusInfo) + assert result.serial_number == "nj-2316-XXXX" + assert result.firmware_version == "spanos2/r202603/05" + + @pytest.mark.asyncio + async def test_get_status_not_v2(self): + mock_response = _mock_response(404) + with patch("span_panel_api.auth.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + with pytest.raises(SpanPanelAPIError, match="does not support v2"): + await get_v2_status("192.168.1.1") + + +# =================================================================== +# V2 data model immutability +# =================================================================== + + +class TestV2ModelImmutability: + def test_v2_auth_response_frozen(self): + resp = V2AuthResponse( + access_token="t", + token_type="Bearer", + iat_ms=0, + ebus_broker_username="u", + ebus_broker_password="p", + ebus_broker_host="h", + ebus_broker_mqtts_port=8883, + ebus_broker_ws_port=9001, + ebus_broker_wss_port=9002, + hostname="h", + serial_number="s", + hop_passphrase="hp", + ) + with pytest.raises(AttributeError): + resp.access_token = "changed" # type: ignore[misc] + + def test_v2_status_info_frozen(self): + info = V2StatusInfo(serial_number="s", firmware_version="fw") + with pytest.raises(AttributeError): + info.serial_number = "changed" # type: ignore[misc] + + +# =================================================================== +# Exports +# =================================================================== + + +class TestPhase2Exports: + def test_detection_exports(self): + import span_panel_api + + assert hasattr(span_panel_api, "DetectionResult") + assert hasattr(span_panel_api, "detect_api_version") + + def test_v2_model_exports(self): + import span_panel_api + + assert hasattr(span_panel_api, "V2AuthResponse") + assert hasattr(span_panel_api, "V2StatusInfo") + + def test_v2_auth_function_exports(self): + import span_panel_api + + assert hasattr(span_panel_api, "register_v2") + assert hasattr(span_panel_api, "download_ca_cert") + assert hasattr(span_panel_api, "get_homie_schema") + assert hasattr(span_panel_api, "regenerate_passphrase") + + def test_version_bumped(self): + import span_panel_api + + assert span_panel_api.__version__ == "2.0.0" diff --git a/tests/test_enhanced_battery_behavior.py b/tests/test_enhanced_battery_behavior.py deleted file mode 100644 index cd7231d..0000000 --- a/tests/test_enhanced_battery_behavior.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Test enhanced battery behavior with time-based charging/discharging and dynamic SOE.""" - -import asyncio -import time -from pathlib import Path - -import pytest -from unittest.mock import patch, Mock - -from span_panel_api import SpanPanelClient - - -class TestEnhancedBatteryBehavior: - """Test enhanced battery behavior and SOE patterns.""" - - @pytest.mark.asyncio - async def test_battery_behavior_configuration_loaded(self): - """Test that battery behavior configuration is properly loaded.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="battery-config-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Ensure the simulation engine has the config - assert client._simulation_engine is not None - - # Force initialization to ensure config is loaded - await client._ensure_simulation_initialized() - - config = client._simulation_engine._config - assert config is not None - - # Check battery template exists - battery_template = config.get("circuit_templates", {}).get("battery", {}) - assert battery_template is not None - - # Check battery behavior is configured - battery_behavior = battery_template.get("battery_behavior", {}) - assert battery_behavior.get("enabled") is True - assert "charge_hours" in battery_behavior - assert "discharge_hours" in battery_behavior - assert "solar_intensity_profile" in battery_behavior - assert "demand_factor_profile" in battery_behavior - - @pytest.mark.asyncio - async def test_battery_circuits_exist_and_respond(self): - """Test that battery circuits exist and have varying power based on time.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="battery-circuits-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - circuits = await client.get_circuits() - - # Find battery circuits - battery_1 = circuits.circuits.additional_properties.get("battery_system_1") - battery_2 = circuits.circuits.additional_properties.get("battery_system_2") - - assert battery_1 is not None, "Battery System 1 should exist" - assert battery_2 is not None, "Battery System 2 should exist" - - # Batteries should have power within their configured range - assert -5000.0 <= battery_1.instant_power_w <= 5000.0 - assert -5000.0 <= battery_2.instant_power_w <= 5000.0 - - # Batteries should show some activity (not exactly 0W all the time) - # Allow for some variation due to random factors - total_battery_power = abs(battery_1.instant_power_w) + abs(battery_2.instant_power_w) - assert total_battery_power > 0, "Batteries should show some power activity" - - @pytest.mark.asyncio - async def test_dynamic_soe_calculation(self): - """Test that SOE changes dynamically and isn't fixed at 75%.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="soe-dynamic-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Get SOE multiple times - soe_readings = [] - for _ in range(3): - storage = await client.get_storage_soe() - soe_readings.append(storage.soe.percentage) - await asyncio.sleep(0.01) # Small delay - - # SOE should not be the old fixed value of 75% - for soe in soe_readings: - assert soe != 75.0, f"SOE should not be fixed at 75%, got {soe}%" - - # SOE should be within reasonable battery range - for soe in soe_readings: - assert 0.0 <= soe <= 100.0, f"SOE should be 0-100%, got {soe}%" - - @pytest.mark.asyncio - async def test_battery_time_based_behavior_patterns(self): - """Test that battery behavior follows expected time-based patterns.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="battery-patterns-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Get current hour to understand expected behavior - current_hour = int((time.time() % 86400) / 3600) - - circuits = await client.get_circuits() - storage = await client.get_storage_soe() - - battery_1 = circuits.circuits.additional_properties.get("battery_system_1") - battery_2 = circuits.circuits.additional_properties.get("battery_system_2") - - assert battery_1 is not None - assert battery_2 is not None - - total_battery_power = battery_1.instant_power_w + battery_2.instant_power_w - - # Define expected behavior based on YAML config - charge_hours = [9, 10, 11, 12, 13, 14, 15, 16] - discharge_hours = [17, 18, 19, 20, 21] - idle_hours = [0, 1, 2, 3, 4, 5, 6, 7, 8, 22, 23] - - if current_hour in charge_hours: - # During solar hours, expect charging (negative power) or higher SOE - assert ( - storage.soe.percentage >= 30.0 - ), f"SOE should be reasonable during solar hours: {storage.soe.percentage}%" - - elif current_hour in discharge_hours: - # During peak hours, expect discharging (positive power) or lower SOE - assert ( - storage.soe.percentage >= 15.0 - ), f"SOE should not be too low during peak hours: {storage.soe.percentage}%" - - elif current_hour in idle_hours: - # During idle hours, expect minimal activity - # Allow for higher power due to 240V split-phase battery systems (2 systems × 5000W each) - assert ( - -10000 <= total_battery_power <= 10000 - ), f"Battery power should be reasonable during idle hours: {total_battery_power}W" - - # SOE should always be reasonable - assert 15.0 <= storage.soe.percentage <= 95.0, f"SOE should be in reasonable range: {storage.soe.percentage}%" - - @pytest.mark.asyncio - async def test_yaml_driven_behavior_no_hardcoding(self): - """Test that behavior is driven by YAML config, not hardcoded values.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="yaml-driven-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Verify the config contains our custom values - simulation_engine = client._simulation_engine - assert simulation_engine is not None - - await client._ensure_simulation_initialized() - config = simulation_engine._config - assert config is not None - - battery_template = config["circuit_templates"]["battery"] - battery_behavior = battery_template["battery_behavior"] - - # Verify YAML-defined values are present - assert battery_behavior["max_charge_power"] == -3000.0 - assert battery_behavior["max_discharge_power"] == 2500.0 - assert battery_behavior["idle_power_range"] == [-100.0, 100.0] - - # Verify profiles are defined in YAML - solar_profile = battery_behavior["solar_intensity_profile"] - demand_profile = battery_behavior["demand_factor_profile"] - - assert solar_profile[12] == 1.0 # Peak solar at noon - assert demand_profile[19] == 1.0 # Peak demand at 7 PM - - # Verify hours are defined in YAML - assert 12 in battery_behavior["charge_hours"] # Noon should be charge hour - assert 19 in battery_behavior["discharge_hours"] # 7 PM should be discharge hour - assert 2 in battery_behavior["idle_hours"] # 2 AM should be idle hour - - -@pytest.mark.asyncio -async def test_battery_behavior_edge_cases(): - """Test edge cases in battery behavior logic to achieve 100% coverage.""" - import datetime - - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-edge-cases", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Test battery behavior methods directly for different scenarios - # This hits lines around solar_intensity_profile and demand_factor_profile - with patch("span_panel_api.simulation.datetime") as mock_datetime: - # Create a mock for fromtimestamp that returns a proper datetime with hour=22 - mock_dt = Mock() - mock_dt.hour = 22 # 10 PM - mock_datetime.fromtimestamp.return_value = mock_dt - mock_datetime.now.return_value = datetime.datetime(2024, 1, 15, 22, 0, 0) # 10 PM - - circuits = await client.get_circuits() - # This should test the default demand factor path (line 305-306) - assert circuits is not None - assert len(circuits.circuits.additional_properties) > 0 - - # Test hour that's in charge but not in solar_intensity_profile - with patch("span_panel_api.simulation.datetime") as mock_datetime: - # Create a mock for fromtimestamp that returns a proper datetime with hour=8 - mock_dt = Mock() - mock_dt.hour = 8 # 8 AM - mock_datetime.fromtimestamp.return_value = mock_dt - mock_datetime.now.return_value = datetime.datetime(2024, 1, 15, 8, 0, 0) # 8 AM - - circuits = await client.get_circuits() - # This should test the default solar intensity path (line 300-301) - assert circuits is not None - assert len(circuits.circuits.additional_properties) > 0 - - -@pytest.mark.asyncio -async def test_config_edge_cases(): - """Test configuration edge cases for complete coverage.""" - # Test with a simple config file that has no battery behavior - # Use validation test config for minimal testing - import yaml - from pathlib import Path - - config_path = Path(__file__).parent.parent / "examples" / "validation_test_config.yaml" - with open(config_path) as f: - config_data = yaml.safe_load(f) - - minimal_config_content = yaml.dump(config_data) - - # Use the existing config but access the engine directly for edge cases - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-no-battery", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Test SOE without battery activity - engine = client._simulation_engine - - # Temporarily clear config to test fallback - original_config = engine._config - engine._config = None - - try: - # Test SOE without config - should return default 75% - soe_data = await engine.get_soe() - assert soe_data["soe"]["percentage"] == 75.0 - - # Test branch generation without config (line 617) - branches = engine._generate_branches() - assert len(branches) == 0 # Should return empty list when no config - - finally: - # Restore original config - engine._config = original_config - - -@pytest.mark.asyncio -async def test_simulation_with_missing_config(): - """Test simulation behavior when config is missing or incomplete.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient(host="test-minimal", simulation_mode=True, simulation_config_path=str(config_path)) as client: - # Test various edge cases by manipulating the engine directly - engine = client._simulation_engine - - # Test status generation without proper config - original_config = engine._config - engine._config = None - - try: - status = await engine.get_status() - assert status is not None - finally: - engine._config = original_config diff --git a/tests/test_enhanced_circuits.py b/tests/test_enhanced_circuits.py deleted file mode 100644 index 31baddc..0000000 --- a/tests/test_enhanced_circuits.py +++ /dev/null @@ -1,409 +0,0 @@ -""" -Test the enhanced get_circuits method with unmapped tab virtual circuits. -""" - -from unittest.mock import Mock, patch - -import pytest - -from span_panel_api import SpanPanelClient -from span_panel_api.generated_client.models import Branch, Circuit, CircuitsOut, CircuitsOutCircuits, Priority, RelayState - - -class TestEnhancedCircuits: - """Test enhanced circuits functionality.""" - - @pytest.mark.asyncio - async def test_get_circuits_with_virtual_circuits(self): - """Test get_circuits includes virtual circuits for unmapped tabs.""" - client = SpanPanelClient(host="test-panel") - client.set_access_token("test-token") - - # Mock the real circuits response - mock_circuit = Circuit( - id="real_circuit_1", - name="Kitchen Outlet", - relay_state=RelayState.CLOSED, - instant_power_w=100.0, - instant_power_update_time_s=1234567890, - produced_energy_wh=1000.0, - consumed_energy_wh=2000.0, - energy_accum_update_time_s=1234567890, - priority=Priority.MUST_HAVE, - is_user_controllable=True, - is_sheddable=False, - is_never_backup=False, - tabs=[1, 2], # This circuit uses tabs 1 and 2 - ) - - mock_circuits = CircuitsOutCircuits() - mock_circuits.additional_properties = {"real_circuit_1": mock_circuit} - - mock_circuits_response = CircuitsOut(circuits=mock_circuits) - - # Mock the panel state with branches - mock_branch1 = Branch( - id=1, - relay_state=RelayState.CLOSED, - instant_power_w=50.0, - imported_active_energy_wh=1500.0, - exported_active_energy_wh=500.0, - measure_start_ts_ms=1234567890000, - measure_duration_ms=1000, - is_measure_valid=True, - ) - mock_branch2 = Branch( - id=2, - relay_state=RelayState.CLOSED, - instant_power_w=75.0, - imported_active_energy_wh=2500.0, - exported_active_energy_wh=300.0, - measure_start_ts_ms=1234567890000, - measure_duration_ms=1000, - is_measure_valid=True, - ) - mock_branch3 = Branch( - id=3, - relay_state=RelayState.OPEN, - instant_power_w=200.0, # This looks like solar production - imported_active_energy_wh=3360000.0, # 3.36M Wh - major solar inverter - exported_active_energy_wh=100000.0, - measure_start_ts_ms=1234567890000, - measure_duration_ms=1000, - is_measure_valid=True, - ) - mock_branch4 = Branch( - id=4, - relay_state=RelayState.OPEN, - instant_power_w=190.0, # This looks like solar production - imported_active_energy_wh=3350000.0, # 3.35M Wh - major solar inverter - exported_active_energy_wh=95000.0, - measure_start_ts_ms=1234567890000, - measure_duration_ms=1000, - is_measure_valid=True, - ) - - # Mock panel state - just create a mock object with branches attribute - mock_panel_state = Mock() - mock_panel_state.branches = [mock_branch1, mock_branch2, mock_branch3, mock_branch4] - - with patch("span_panel_api.client.get_circuits_api_v1_circuits_get") as mock_get_circuits: - # Mock the async function to return an awaitable - async def mock_async_circuits(*args, **kwargs): - return mock_circuits_response - - mock_get_circuits.asyncio = mock_async_circuits - - # Mock the asyncio_detailed method - from unittest.mock import AsyncMock, MagicMock - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.parsed = mock_circuits_response - mock_get_circuits.asyncio_detailed = AsyncMock(return_value=mock_response) - - # Mock get_panel_state to return an awaitable - async def mock_async_panel_state(): - return mock_panel_state - - with patch.object(client, "get_panel_state", side_effect=mock_async_panel_state): - async with client: - result = await client.get_circuits() - - # Verify we got the response - assert result is not None - assert hasattr(result, "circuits") - assert hasattr(result.circuits, "additional_properties") - - circuit_dict = result.circuits.additional_properties - - # Should have 1 real circuit + 2 virtual circuits (tabs 3 and 4 are unmapped) - assert len(circuit_dict) == 3 - - # Verify real circuit is still there - assert "real_circuit_1" in circuit_dict - real_circuit = circuit_dict["real_circuit_1"] - assert real_circuit.name == "Kitchen Outlet" - assert real_circuit.tabs == [1, 2] - - # Verify virtual circuits were created for unmapped tabs - assert "unmapped_tab_3" in circuit_dict - assert "unmapped_tab_4" in circuit_dict - - virtual_circuit_3 = circuit_dict["unmapped_tab_3"] - virtual_circuit_4 = circuit_dict["unmapped_tab_4"] - - # Verify virtual circuit 3 structure - assert virtual_circuit_3.id == "unmapped_tab_3" - assert virtual_circuit_3.name == "Unmapped Tab 3" - assert virtual_circuit_3.relay_state == RelayState.UNKNOWN - assert virtual_circuit_3.instant_power_w == 200.0 - assert virtual_circuit_3.produced_energy_wh == 3360000.0 # Solar production - assert virtual_circuit_3.consumed_energy_wh == 100000.0 - assert virtual_circuit_3.priority == Priority.UNKNOWN - assert virtual_circuit_3.is_user_controllable is False - assert virtual_circuit_3.is_sheddable is False - assert virtual_circuit_3.tabs == [3] - - # Verify virtual circuit 4 structure - assert virtual_circuit_4.id == "unmapped_tab_4" - assert virtual_circuit_4.name == "Unmapped Tab 4" - assert virtual_circuit_4.instant_power_w == 190.0 - assert virtual_circuit_4.produced_energy_wh == 3350000.0 # Solar production - assert virtual_circuit_4.consumed_energy_wh == 95000.0 - assert virtual_circuit_4.tabs == [4] - - @pytest.mark.asyncio - async def test_get_circuits_no_unmapped_tabs(self): - """Test get_circuits when all tabs are mapped to circuits.""" - client = SpanPanelClient(host="test-panel") - client.set_access_token("test-token") - - # Mock circuit that uses all available tabs - mock_circuit = Circuit( - id="real_circuit_1", - name="Main Circuit", - relay_state=RelayState.CLOSED, - instant_power_w=100.0, - instant_power_update_time_s=1234567890, - produced_energy_wh=1000.0, - consumed_energy_wh=2000.0, - energy_accum_update_time_s=1234567890, - priority=Priority.MUST_HAVE, - is_user_controllable=True, - is_sheddable=False, - is_never_backup=False, - tabs=[1, 2], # Uses all tabs - ) - - mock_circuits = CircuitsOutCircuits() - mock_circuits.additional_properties = {"real_circuit_1": mock_circuit} - - mock_circuits_response = CircuitsOut(circuits=mock_circuits) - - # Mock panel state with only 2 branches (all mapped) - mock_branch1 = Branch( - id=1, - relay_state=RelayState.CLOSED, - instant_power_w=50.0, - imported_active_energy_wh=1500.0, - exported_active_energy_wh=500.0, - measure_start_ts_ms=1234567890000, - measure_duration_ms=1000, - is_measure_valid=True, - ) - mock_branch2 = Branch( - id=2, - relay_state=RelayState.CLOSED, - instant_power_w=75.0, - imported_active_energy_wh=2500.0, - exported_active_energy_wh=300.0, - measure_start_ts_ms=1234567890000, - measure_duration_ms=1000, - is_measure_valid=True, - ) - - # Mock panel state - just create a mock object with branches attribute - mock_panel_state = Mock() - mock_panel_state.branches = [mock_branch1, mock_branch2] - - with patch("span_panel_api.client.get_circuits_api_v1_circuits_get") as mock_get_circuits: - # Mock the async function to return an awaitable - async def mock_async_circuits(*args, **kwargs): - return mock_circuits_response - - mock_get_circuits.asyncio = mock_async_circuits - - # Mock the asyncio_detailed method - from unittest.mock import AsyncMock, MagicMock - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.parsed = mock_circuits_response - mock_get_circuits.asyncio_detailed = AsyncMock(return_value=mock_response) - - # Mock get_panel_state to return an awaitable - async def mock_async_panel_state(): - return mock_panel_state - - with patch.object(client, "get_panel_state", side_effect=mock_async_panel_state): - async with client: - result = await client.get_circuits() - - # Should only have the original circuit, no virtual ones - circuit_dict = result.circuits.additional_properties - assert len(circuit_dict) == 1 - assert "real_circuit_1" in circuit_dict - # No virtual circuits should be created - assert not any(cid.startswith("unmapped_tab_") for cid in circuit_dict) - - def test_create_unmapped_tab_circuit(self): - """Test the _create_unmapped_tab_circuit method.""" - client = SpanPanelClient(host="test-panel") - - # Mock branch data - mock_branch = Branch( - id=30, - relay_state=RelayState.OPEN, - instant_power_w=250.0, - imported_active_energy_wh=3400000.0, # 3.4M Wh - exported_active_energy_wh=120000.0, - measure_start_ts_ms=1234567890000, - measure_duration_ms=1000, - is_measure_valid=True, - ) - - # Create virtual circuit - circuit = client._create_unmapped_tab_circuit(mock_branch, 30) - - # Verify all circuit properties - assert circuit.id == "unmapped_tab_30" - assert circuit.name == "Unmapped Tab 30" - assert circuit.relay_state == RelayState.UNKNOWN - assert circuit.instant_power_w == 250.0 - assert circuit.produced_energy_wh == 3400000.0 # imported energy -> production - assert circuit.consumed_energy_wh == 120000.0 # exported energy -> consumption - assert circuit.priority == Priority.UNKNOWN - assert circuit.is_user_controllable is False - assert circuit.is_sheddable is False - assert circuit.is_never_backup is False - - @pytest.mark.asyncio - async def test_create_unmapped_tab_circuit_unavailable_energy(self): - """Test that _create_unmapped_tab_circuit creates circuits with None energy when data is unavailable.""" - from unittest.mock import MagicMock - - from tests.test_factories import create_live_client - - client = create_live_client() - client.set_access_token("test-token") - - # Create a mock branch with unavailable energy data - mock_branch = MagicMock() - mock_branch.id = 1 - mock_branch.relay_state = "CLOSED" - mock_branch.imported_active_energy_wh = "unavailable" # Unavailable energy data - mock_branch.exported_active_energy_wh = 500.0 - mock_branch.instant_power_w = 200.0 - - # Test that the function creates a circuit with None energy when data is unavailable - circuit = client._create_unmapped_tab_circuit(mock_branch, 2) - assert circuit is not None - assert circuit.id == "unmapped_tab_2" - assert circuit.produced_energy_wh is None # Should be None for unavailable data - assert circuit.consumed_energy_wh == 500.0 # Should be the valid value - assert circuit.instant_power_w == 200.0 - - # Test with None energy data - mock_branch.imported_active_energy_wh = None - mock_branch.exported_active_energy_wh = 500.0 - circuit = client._create_unmapped_tab_circuit(mock_branch, 2) - assert circuit is not None - assert circuit.produced_energy_wh is None # Should be None for None data - assert circuit.consumed_energy_wh == 500.0 - - # Test with offline energy data - mock_branch.imported_active_energy_wh = 1000.0 - mock_branch.exported_active_energy_wh = "offline" - circuit = client._create_unmapped_tab_circuit(mock_branch, 2) - assert circuit is not None - assert circuit.produced_energy_wh == 1000.0 # Should be the valid value - assert circuit.consumed_energy_wh is None # Should be None for offline data - - def test_create_unmapped_tab_circuit_missing_attributes(self): - """Test _create_unmapped_tab_circuit with missing branch attributes.""" - client = SpanPanelClient(host="test-panel") - - # Create a mock branch where getattr will return None for missing attributes - # and our code will use the default value - mock_branch = Mock(spec=Branch) - # Clear all attributes so getattr returns None - del mock_branch.instant_power_w - del mock_branch.imported_active_energy_wh - del mock_branch.exported_active_energy_wh - - circuit = client._create_unmapped_tab_circuit(mock_branch, 15) - - # Should use default values - assert circuit.id == "unmapped_tab_15" - assert circuit.name == "Unmapped Tab 15" - assert circuit.instant_power_w == 0.0 - assert circuit.produced_energy_wh == 0.0 - assert circuit.consumed_energy_wh == 0.0 - assert circuit.tabs == [15] - - @pytest.mark.asyncio - async def test_create_unmapped_tab_circuit_coverage(self): - """Test the _create_unmapped_tab_circuit method coverage (from missing coverage).""" - from unittest.mock import MagicMock - - from tests.test_factories import create_live_client - - client = create_live_client() - client.set_access_token("test-token") - - # Create a mock branch for testing - mock_branch = MagicMock() - mock_branch.id = 1 - mock_branch.relay_state = "CLOSED" - mock_branch.imported_active_energy_wh = 1000.0 # Provide energy data - mock_branch.exported_active_energy_wh = 500.0 # Provide energy data - mock_branch.instant_power_w = 200.0 - - # Test the _create_unmapped_tab_circuit method - circuit = client._create_unmapped_tab_circuit(mock_branch, 2) - - assert circuit.id == "unmapped_tab_2" - assert circuit.name == "Unmapped Tab 2" - assert circuit.tabs == [2] - assert circuit.relay_state.value == "UNKNOWN" # Default value for unmapped circuits - assert circuit.priority.value == "UNKNOWN" - assert circuit.is_user_controllable is False - - @pytest.mark.asyncio - async def test_get_circuits_unmapped_tabs_simulation_edge_case(self): - """Test unmapped tab handling edge case in simulation where tab index exceeds branches.""" - client = SpanPanelClient( - host="test-unmapped-edge-case", simulation_mode=True, simulation_config_path="examples/simple_test_config.yaml" - ) - - async with client: - # Get circuits normally first - circuits = await client.get_circuits() - - # All configured circuits should be present plus any unmapped tabs - assert len(circuits.circuits.additional_properties) >= 4 # At least the 4 configured circuits - - # Verify circuit IDs exist - circuit_ids = list(circuits.circuits.additional_properties.keys()) - expected_ids = ["living_room_lights", "kitchen_outlets", "main_hvac", "solar_inverter"] - - for expected_id in expected_ids: - assert expected_id in circuit_ids, f"Expected circuit {expected_id} not found in {circuit_ids}" - - # If there are unmapped tabs, they should follow the correct naming pattern - unmapped_circuits = [cid for cid in circuit_ids if cid.startswith("unmapped_tab_")] - for unmapped_id in unmapped_circuits: - # Should be in format unmapped_tab_N where N is a valid tab number - tab_num = int(unmapped_id.split("_")[-1]) - assert tab_num > 0, f"Invalid tab number in {unmapped_id}" - - @pytest.mark.asyncio - async def test_get_circuits_handles_circuit_tabs_as_single_int(self): - """Test that circuits with single int tabs (not list) are handled correctly.""" - # This test ensures coverage of the isinstance(circuit.tabs, int) branch - client = SpanPanelClient( - host="test-single-int-tabs", simulation_mode=True, simulation_config_path="examples/simple_test_config.yaml" - ) - - async with client: - # Use the simulation directly to trigger the edge case logic - circuits = await client.get_circuits() - - # This will exercise the unmapped tab logic including bounds checking - circuit_ids = list(circuits.circuits.additional_properties.keys()) - - # Verify the expected simulation circuits exist - expected_ids = ["living_room_lights", "kitchen_outlets", "main_hvac", "solar_inverter"] - for expected_id in expected_ids: - assert expected_id in circuit_ids diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py deleted file mode 100644 index 4497373..0000000 --- a/tests/test_error_handling.py +++ /dev/null @@ -1,872 +0,0 @@ -"""Tests for error handling, retries, timeouts and HTTP status codes. - -This module tests exception handling, retry logic, timeout behavior, -and error scenarios for all API methods. -""" - -import asyncio -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx -import pytest - -from span_panel_api import SpanPanelClient -from span_panel_api.exceptions import ( - SpanPanelAPIError, - SpanPanelAuthError, - SpanPanelConnectionError, - SpanPanelRetriableError, - SpanPanelServerError, - SpanPanelTimeoutError, -) - - -class TestHTTPStatusErrorHandling: - """Test HTTP status error handling and conversion to appropriate exceptions.""" - - @pytest.mark.asyncio - async def test_401_unauthorized_error(self): - """Test 401 Unauthorized error handling.""" - client = SpanPanelClient("192.168.1.100") - client.set_access_token("test-token") - - with patch("span_panel_api.client.get_panel_state_api_v1_panel_get") as mock_panel: - # Create a proper mock response with status_code - mock_response = MagicMock() - mock_response.status_code = 401 - mock_response.content = b'{"detail": "Unauthorized"}' - mock_request = MagicMock() - - mock_panel.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError("401 Unauthorized", request=mock_request, response=mock_response) - ) - - with pytest.raises(SpanPanelAuthError, match="Authentication failed"): - await client.get_panel_state() - - @pytest.mark.asyncio - async def test_403_forbidden_error(self): - """Test 403 Forbidden error handling.""" - client = SpanPanelClient("192.168.1.100") - client.set_access_token("test-token") - - with patch("span_panel_api.client.get_panel_state_api_v1_panel_get") as mock_panel: - # Create a proper mock response with status_code - mock_response = MagicMock() - mock_response.status_code = 403 - mock_response.content = b'{"detail": "Forbidden"}' - mock_request = MagicMock() - - mock_panel.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError("403 Forbidden", request=mock_request, response=mock_response) - ) - - with pytest.raises(SpanPanelAuthError, match="Authentication failed"): - await client.get_panel_state() - - @pytest.mark.asyncio - async def test_500_internal_server_error(self): - """Test 500 Internal Server Error handling.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - # Create a proper mock response with status_code - mock_response = MagicMock() - mock_response.status_code = 500 - mock_response.content = b'{"detail": "Internal Server Error"}' - mock_request = MagicMock() - - mock_status.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError("500 Internal Server Error", request=mock_request, response=mock_response) - ) - - with pytest.raises(SpanPanelServerError, match="Server error 500"): - await client.get_status() - - @pytest.mark.asyncio - async def test_502_bad_gateway_error(self): - """Test 502 Bad Gateway error handling.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - # Create a proper mock response with status_code - mock_response = MagicMock() - mock_response.status_code = 502 - mock_response.content = b'{"detail": "Bad Gateway"}' - mock_request = MagicMock() - - mock_status.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError("502 Bad Gateway", request=mock_request, response=mock_response) - ) - - with pytest.raises(SpanPanelRetriableError, match="Retriable server error 502"): - await client.get_status() - - @pytest.mark.asyncio - async def test_503_service_unavailable_error(self): - """Test 503 Service Unavailable error handling.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - # Create a proper mock response with status_code - mock_response = MagicMock() - mock_response.status_code = 503 - mock_response.content = b'{"detail": "Service Unavailable"}' - mock_request = MagicMock() - - mock_status.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError("503 Service Unavailable", request=mock_request, response=mock_response) - ) - - with pytest.raises(SpanPanelRetriableError, match="Retriable server error 503"): - await client.get_status() - - @pytest.mark.asyncio - async def test_504_gateway_timeout_error(self): - """Test 504 Gateway Timeout error handling.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - # Create a proper mock response with status_code - mock_response = MagicMock() - mock_response.status_code = 504 - mock_response.content = b'{"detail": "Gateway Timeout"}' - mock_request = MagicMock() - - mock_status.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError("504 Gateway Timeout", request=mock_request, response=mock_response) - ) - - with pytest.raises(SpanPanelRetriableError, match="Retriable server error 504"): - await client.get_status() - - @pytest.mark.asyncio - async def test_other_http_error(self): - """Test other HTTP error handling (e.g., 404).""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - # Create a proper mock response with status_code - mock_response = MagicMock() - mock_response.status_code = 404 - mock_response.content = b'{"detail": "Not Found"}' - mock_request = MagicMock() - - mock_status.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError("404 Not Found", request=mock_request, response=mock_response) - ) - - with pytest.raises(SpanPanelAPIError, match="HTTP 404"): - await client.get_status() - - -class TestUnexpectedStatusHandling: - """Test _handle_unexpected_status edge cases.""" - - def test_handle_unexpected_status_auth_error(self): - """Test handling authentication errors.""" - from span_panel_api.generated_client.errors import UnexpectedStatus - - client = SpanPanelClient("192.168.1.100") - - mock_response = MagicMock() - mock_response.content = b'{"error": "unauthorized"}' - unexpected_status = UnexpectedStatus(401, mock_response) - - with pytest.raises(SpanPanelAuthError, match="Authentication required"): - client._handle_unexpected_status(unexpected_status) - - def test_handle_unexpected_status_retriable_error(self): - """Test handling retriable server errors.""" - from span_panel_api.generated_client.errors import UnexpectedStatus - - client = SpanPanelClient("192.168.1.100") - - mock_response = MagicMock() - mock_response.content = b'{"error": "bad_gateway"}' - unexpected_status = UnexpectedStatus(502, mock_response) - - with pytest.raises(SpanPanelRetriableError, match="Retriable server error 502"): - client._handle_unexpected_status(unexpected_status) - - def test_handle_unexpected_status_server_error(self): - """Test handling non-retriable server errors.""" - from span_panel_api.generated_client.errors import UnexpectedStatus - - client = SpanPanelClient("192.168.1.100") - - mock_response = MagicMock() - mock_response.content = b'{"error": "internal_error"}' - unexpected_status = UnexpectedStatus(500, mock_response) - - with pytest.raises(SpanPanelServerError, match="Server error 500"): - client._handle_unexpected_status(unexpected_status) - - def test_handle_unexpected_status_other_error(self): - """Test handling other HTTP errors.""" - from span_panel_api.generated_client.errors import UnexpectedStatus - - client = SpanPanelClient("192.168.1.100") - - mock_response = MagicMock() - mock_response.content = b'{"error": "not_found"}' - unexpected_status = UnexpectedStatus(404, mock_response) - - with pytest.raises(SpanPanelAPIError, match="HTTP 404"): - client._handle_unexpected_status(unexpected_status) - - -class TestRetryLogic: - """Test retry logic and exponential backoff.""" - - @pytest.mark.asyncio - async def test_no_retries_configuration(self): - """Test that by default (retries=0), operations fail immediately without retries.""" - client = SpanPanelClient("192.168.1.100", retries=0, retry_timeout=0.001) - - call_count = 0 - - async def mock_operation(): - nonlocal call_count - call_count += 1 - raise httpx.ConnectError("Connection failed") - - with pytest.raises(httpx.ConnectError): - await client._retry_with_backoff(mock_operation) - - # Should only be called once (no retries) - assert call_count == 1 - - @pytest.mark.asyncio - async def test_one_retry_configuration(self): - """Test that retries=1 means 1 retry (2 total attempts).""" - client = SpanPanelClient("192.168.1.100", retries=1, retry_timeout=0.001) - - call_count = 0 - - async def mock_operation(): - nonlocal call_count - call_count += 1 - raise httpx.TimeoutException("Request timeout") - - with pytest.raises(httpx.TimeoutException): - await client._retry_with_backoff(mock_operation) - - # Should be called twice (1 retry = 2 total attempts) - assert call_count == 2 - - @pytest.mark.asyncio - async def test_multiple_retries_with_eventual_success(self): - """Test retries=3 with eventual success on the last attempt.""" - client = SpanPanelClient("192.168.1.100", retries=3, retry_timeout=0.001) - - call_count = 0 - - async def mock_operation(): - nonlocal call_count - call_count += 1 - if call_count < 4: # Fail 3 times, succeed on 4th - raise httpx.ConnectError("Connection failed") - return "success" - - result = await client._retry_with_backoff(mock_operation) - - # Should succeed on the 4th attempt - assert result == "success" - assert call_count == 4 - - @pytest.mark.asyncio - async def test_retry_with_exponential_backoff(self): - """Test that exponential backoff delays are calculated correctly.""" - client = SpanPanelClient("192.168.1.100", retries=2, retry_timeout=0.1, retry_backoff_multiplier=2.0) - - call_count = 0 - start_time = asyncio.get_event_loop().time() - - async def mock_operation(): - nonlocal call_count - call_count += 1 - raise httpx.TimeoutException("Request timeout") - - with pytest.raises(httpx.TimeoutException): - await client._retry_with_backoff(mock_operation) - - end_time = asyncio.get_event_loop().time() - elapsed = end_time - start_time - - # Should have been called 3 times (retries=2 means 3 total attempts) - assert call_count == 3 - - # Should have taken at least the backoff delays: 0.1 + 0.2 = 0.3s - # Using a lenient check since timing can be imprecise in tests - assert elapsed >= 0.2 # Allow some slack for test timing - - @pytest.mark.asyncio - async def test_retry_only_on_retriable_errors(self): - """Test that retries only happen for retriable errors.""" - client = SpanPanelClient("192.168.1.100", retries=2, retry_timeout=0.001) - - # Test retriable errors (should retry) - retriable_exceptions = [httpx.ConnectError("Connection failed"), httpx.TimeoutException("Request timeout")] - - for exception in retriable_exceptions: - call_count = 0 - - async def mock_operation(err=exception): - nonlocal call_count - call_count += 1 - raise err - - with pytest.raises(type(exception)): - await client._retry_with_backoff(mock_operation) - - # Should retry (3 total attempts for retries=2) - assert call_count == 3, f"Failed for {type(exception)}" - - @pytest.mark.asyncio - async def test_retry_with_httpx_status_error_retriable(self): - """Test retry logic with httpx.HTTPStatusError that's retriable.""" - client = SpanPanelClient("192.168.1.100", retries=1, retry_timeout=0.001) - - call_count = 0 - - async def mock_operation(): - nonlocal call_count - call_count += 1 - # Create mock response for 502 error - mock_response = MagicMock() - mock_response.status_code = 502 - mock_request = MagicMock() - - raise httpx.HTTPStatusError("502 Bad Gateway", request=mock_request, response=mock_response) - - # The raw httpx exception should be re-raised after retries - with pytest.raises(httpx.HTTPStatusError): - await client._retry_with_backoff(mock_operation) - - # Should have retried - assert call_count == 2 - - @pytest.mark.asyncio - async def test_retry_with_httpx_status_error_non_retriable(self): - """Test retry logic with httpx.HTTPStatusError that's not retriable.""" - client = SpanPanelClient("192.168.1.100", retries=2, retry_timeout=0.001) - - call_count = 0 - - async def mock_operation(status_code=400, error_message="400 Bad Request"): - nonlocal call_count - call_count += 1 - # Create mock response for 400 error (not retriable) - mock_response = MagicMock() - mock_response.status_code = status_code - mock_request = MagicMock() - - raise httpx.HTTPStatusError(error_message, request=mock_request, response=mock_response) - - # The raw httpx exception should be re-raised immediately (no retries) - with pytest.raises(httpx.HTTPStatusError): - await client._retry_with_backoff(mock_operation) - - # Should not have retried - assert call_count == 1 - - @pytest.mark.asyncio - async def test_different_retry_configurations(self): - """Test different retry configurations work as expected.""" - test_configs = [ - {"retries": 0, "expected_attempts": 1}, - {"retries": 1, "expected_attempts": 2}, - {"retries": 3, "expected_attempts": 4}, - {"retries": 5, "expected_attempts": 6}, - ] - - for config in test_configs: - client = SpanPanelClient("192.168.1.100", retries=config["retries"], retry_timeout=0.001) - - call_count = 0 - - async def mock_operation(expected_attempts=config["expected_attempts"]): - nonlocal call_count - call_count += 1 - raise httpx.ConnectError("Connection failed") - - with pytest.raises(httpx.ConnectError): - await client._retry_with_backoff(mock_operation) - - assert call_count == config["expected_attempts"], f"Failed for config {config}" - - -class TestTimeoutBehavior: - """Test timeout behavior and timeout error handling.""" - - @pytest.mark.asyncio - async def test_mocked_timeout_exceptions(self): - """Test that mocked timeout exceptions are properly converted.""" - client = SpanPanelClient("192.168.1.100", timeout=1.0) - - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - mock_status.asyncio = AsyncMock(side_effect=httpx.TimeoutException("Request timeout")) - - with pytest.raises(SpanPanelTimeoutError, match="Request timed out after 1.0s"): - await client.get_status() - - @pytest.mark.asyncio - async def test_timeout_with_authentication(self): - """Test timeout handling during authentication.""" - client = SpanPanelClient("192.168.1.100", timeout=2.0) - - with patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth: - mock_auth.asyncio = AsyncMock(side_effect=httpx.TimeoutException("Auth timeout")) - - with pytest.raises(SpanPanelTimeoutError, match="Request timed out after 2.0s"): - await client.authenticate("test-app", "Test Application") - - @pytest.mark.asyncio - async def test_real_timeout_to_unreachable_host(self): - """Test timeout behavior with mocked connection timeout.""" - client = SpanPanelClient("192.168.1.100", timeout=0.001) # Very short timeout - - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - mock_status.asyncio = AsyncMock(side_effect=httpx.TimeoutException("Connection timeout")) - - with pytest.raises(SpanPanelTimeoutError, match="Request timed out after 0.001s"): - await client.get_status() - - -class TestAPIMethodErrors: - """Test error scenarios specific to API methods.""" - - @pytest.mark.asyncio - async def test_get_storage_soe_connection_error(self): - """Test storage SOE connection error.""" - client = SpanPanelClient("192.168.1.100") - client.set_access_token("test-token") - - with patch("span_panel_api.client.get_storage_soe_api_v1_storage_soe_get") as mock_storage: - mock_storage.asyncio = AsyncMock(side_effect=httpx.ConnectError("Failed to connect")) - - with pytest.raises(SpanPanelConnectionError, match="Failed to connect"): - await client.get_storage_soe() - - @pytest.mark.asyncio - async def test_set_circuit_priority_invalid_priority(self): - """Test setting circuit priority with invalid priority.""" - client = SpanPanelClient("192.168.1.100") - client.set_access_token("test-token") - - with pytest.raises(SpanPanelAPIError, match="is not a valid Priority"): - await client.set_circuit_priority("circuit-1", "INVALID_PRIORITY") - - @pytest.mark.asyncio - async def test_connection_error_handling(self): - """Test connection error handling.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - mock_status.asyncio = AsyncMock(side_effect=httpx.ConnectError("Connection refused")) - - with pytest.raises(SpanPanelConnectionError, match="Failed to connect to 192.168.1.100"): - await client.get_status() - - @pytest.mark.asyncio - async def test_api_error_handling(self): - """Test general API error handling.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - mock_status.asyncio = AsyncMock(side_effect=ValueError("Invalid data")) - - with pytest.raises(SpanPanelAPIError, match="API error: Invalid data"): - await client.get_status() - - @pytest.mark.asyncio - async def test_get_status_api_error(self): - """Test get_status API error handling.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - mock_status.asyncio = AsyncMock(side_effect=Exception("General error")) - - with pytest.raises(SpanPanelAPIError, match="Unexpected error: General error"): - await client.get_status() - - @pytest.mark.asyncio - async def test_get_status_value_error(self): - """Test ValueError handling in get_status.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - mock_status.asyncio.side_effect = ValueError("Pydantic validation failed") - - with pytest.raises(SpanPanelAPIError, match="API error: Pydantic validation failed"): - await client.get_status() - - @pytest.mark.asyncio - async def test_get_status_generic_exception(self): - """Test generic Exception handling in get_status.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - mock_status.asyncio.side_effect = RuntimeError("Unexpected error") - - with pytest.raises(SpanPanelAPIError, match="Unexpected error: Unexpected error"): - await client.get_status() - - @pytest.mark.asyncio - async def test_get_circuits_value_error(self): - """Test ValueError handling in get_circuits.""" - client = SpanPanelClient("192.168.1.100") - client._access_token = "test-token" - - with patch("span_panel_api.client.get_circuits_api_v1_circuits_get") as mock_circuits: - mock_circuits.asyncio.side_effect = ValueError("Circuit validation failed") - mock_circuits.asyncio_detailed.side_effect = ValueError("Circuit validation failed") - - with pytest.raises(SpanPanelAPIError, match="API error: Circuit validation failed"): - await client.get_circuits() - - @pytest.mark.asyncio - async def test_get_storage_soe_generic_exception(self): - """Test generic Exception handling in get_storage_soe.""" - client = SpanPanelClient("192.168.1.100") - client._access_token = "test-token" - - with patch("span_panel_api.client.get_storage_soe_api_v1_storage_soe_get") as mock_storage: - mock_storage.asyncio.side_effect = KeyError("Missing storage key") - - with pytest.raises(SpanPanelAPIError, match="Unexpected error: 'Missing storage key'"): - await client.get_storage_soe() - - @pytest.mark.asyncio - async def test_set_circuit_relay_value_error(self): - """Test ValueError handling in set_circuit_relay.""" - client = SpanPanelClient("192.168.1.100") - client._access_token = "test-token" - - with patch("span_panel_api.client.set_circuit_state_api_v_1_circuits_circuit_id_post") as mock_relay: - mock_relay.asyncio.side_effect = ValueError("Invalid relay state") - - with pytest.raises(SpanPanelAPIError, match="API error: Invalid relay state"): - await client.set_circuit_relay("circuit-1", "OPEN") - - @pytest.mark.asyncio - async def test_set_circuit_priority_generic_exception(self): - """Test generic Exception handling in set_circuit_priority.""" - client = SpanPanelClient("192.168.1.100") - client._access_token = "test-token" - - with patch("span_panel_api.client.set_circuit_state_api_v_1_circuits_circuit_id_post") as mock_priority: - mock_priority.asyncio.side_effect = AttributeError("Missing attribute") - - with pytest.raises(SpanPanelAPIError, match="Unexpected error: Missing attribute"): - await client.set_circuit_priority("circuit-1", "MUST_HAVE") - - -class TestPropertyValidationEdgeCases: - """Test edge cases in property validation.""" - - def test_retries_negative_validation(self): - """Test retries property validation with negative values.""" - client = SpanPanelClient("192.168.1.100") - - with pytest.raises(ValueError, match="retries must be non-negative"): - client.retries = -1 - - def test_retry_timeout_negative_validation(self): - """Test retry_timeout property validation with negative values.""" - client = SpanPanelClient("192.168.1.100") - - with pytest.raises(ValueError, match="retry_timeout must be non-negative"): - client.retry_timeout = -0.5 - - def test_retry_backoff_multiplier_too_small_validation(self): - """Test retry_backoff_multiplier property validation with value < 1.""" - client = SpanPanelClient("192.168.1.100") - - with pytest.raises(ValueError, match="retry_backoff_multiplier must be at least 1"): - client.retry_backoff_multiplier = 0.5 - - -class TestIntegrationScenarios: - """Test integration scenarios for production usage.""" - - @pytest.mark.asyncio - async def test_span_integration_default_config(self): - """Test SPAN integration with default configuration (no retries).""" - client = SpanPanelClient("192.168.1.100") # Default: retries=0 - - # Default config should fail fast without retries - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - mock_status.asyncio = AsyncMock(side_effect=httpx.TimeoutException("Timeout")) - - with pytest.raises(SpanPanelTimeoutError): - await client.get_status() - - # Should only be called once (no retries) - assert mock_status.asyncio.call_count == 1 - - @pytest.mark.asyncio - async def test_span_integration_runtime_configuration(self): - """Test SPAN integration with runtime configuration changes.""" - client = SpanPanelClient("192.168.1.100") - - # Start with no retries - assert client.retries == 0 - - # Change to production config with retries - client.retries = 2 - client.retry_timeout = 0.001 # Fast for testing - - call_count = 0 - - async def mock_operation(): - nonlocal call_count - call_count += 1 - raise httpx.ConnectError("Connection failed") - - with pytest.raises(httpx.ConnectError): - await client._retry_with_backoff(mock_operation) - - # Should retry based on new configuration - assert call_count == 3 # retries=2 means 3 total attempts - - @pytest.mark.asyncio - async def test_testing_configurations(self): - """Test configurations suitable for testing (fast feedback).""" - # Fast failure for tests - client = SpanPanelClient("192.168.1.100", timeout=0.001, retries=0) - - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - mock_status.asyncio = AsyncMock(side_effect=httpx.TimeoutException("Timeout")) - - with pytest.raises(SpanPanelTimeoutError): - await client.get_status() - - @pytest.mark.asyncio - async def test_context_manager_with_retries(self): - """Test context manager with retry configuration.""" - client = SpanPanelClient("192.168.1.100", retries=1, retry_timeout=0.001) - - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - call_count = 0 - - def side_effect(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count < 2: - raise httpx.ConnectError("Connection failed") - return MagicMock(system=MagicMock(manufacturer="SPAN")) - - mock_status.asyncio = AsyncMock(side_effect=side_effect) - - async with client: - # Should succeed on retry - status = await client.get_status() - assert status is not None - - # Should have been called twice (1 failure + 1 retry success) - assert call_count == 2 - - -def test_performance_summary(): - """Test to verify semantic clarity of retry configuration.""" - # Test that retry configuration is intuitive - client = SpanPanelClient("192.168.1.100") - - # Default: no retries for fast feedback - assert client.retries == 0 - - # retries=1 means 1 retry (2 total attempts) - client.retries = 1 - assert client.retries == 1 - - # retries=3 means 3 retries (4 total attempts) - client.retries = 3 - assert client.retries == 3 - - -class TestHTTPStatusErrorInAPIMethods: - """Test HTTPStatusError handling in individual API methods.""" - - @pytest.mark.asyncio - async def test_authenticate_httpx_status_error_auth_codes(self): - """Test HTTPStatusError handling in authenticate for auth error codes.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth: - # Create HTTPStatusError for 401 - mock_response = MagicMock() - mock_response.status_code = 401 - mock_request = MagicMock() - - mock_auth.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError("401 Unauthorized", request=mock_request, response=mock_response) - ) - - with pytest.raises(SpanPanelAuthError, match="Authentication failed"): - await client.authenticate("test-app", "Test Application") - - @pytest.mark.asyncio - async def test_authenticate_httpx_status_error_other_codes(self): - """Test HTTPStatusError handling in authenticate for non-auth error codes.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth: - # Create HTTPStatusError for 404 - mock_response = MagicMock() - mock_response.status_code = 404 - mock_request = MagicMock() - - mock_auth.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError("404 Not Found", request=mock_request, response=mock_response) - ) - - with pytest.raises(SpanPanelAPIError, match="HTTP 404"): - await client.authenticate("test-app", "Test Application") - - @pytest.mark.asyncio - async def test_get_status_httpx_status_error(self): - """Test HTTPStatusError handling in get_status.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.system_status_api_v1_status_get") as mock_status: - # Create HTTPStatusError - mock_response = MagicMock() - mock_response.status_code = 500 - mock_response.content = b"Internal Server Error" - mock_request = MagicMock() - - mock_status.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError("500 Internal Server Error", request=mock_request, response=mock_response) - ) - - with pytest.raises(SpanPanelServerError, match="Server error 500"): - await client.get_status() - - @pytest.mark.asyncio - async def test_get_panel_state_httpx_status_error(self): - """Test HTTPStatusError handling in get_panel_state.""" - client = SpanPanelClient("192.168.1.100") - client._access_token = "test-token" - - with patch("span_panel_api.client.get_panel_state_api_v1_panel_get") as mock_panel: - # Create HTTPStatusError - mock_response = MagicMock() - mock_response.status_code = 502 - mock_response.content = b"Bad Gateway" - mock_request = MagicMock() - - mock_panel.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError("502 Bad Gateway", request=mock_request, response=mock_response) - ) - - with pytest.raises(SpanPanelRetriableError, match="Retriable server error 502"): - await client.get_panel_state() - - @pytest.mark.asyncio - async def test_get_circuits_httpx_status_error(self): - """Test HTTPStatusError handling in get_circuits.""" - client = SpanPanelClient("192.168.1.100") - client._access_token = "test-token" - - with patch("span_panel_api.client.get_circuits_api_v1_circuits_get") as mock_circuits: - # Create HTTPStatusError - mock_response = MagicMock() - mock_response.status_code = 503 - mock_response.content = b"Service Unavailable" - mock_request = MagicMock() - - mock_circuits.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError("503 Service Unavailable", request=mock_request, response=mock_response) - ) - mock_circuits.asyncio_detailed = AsyncMock( - side_effect=httpx.HTTPStatusError("503 Service Unavailable", request=mock_request, response=mock_response) - ) - - with pytest.raises(SpanPanelRetriableError, match="Retriable server error 503"): - await client.get_circuits() - - @pytest.mark.asyncio - async def test_get_storage_soe_httpx_status_error(self): - """Test HTTPStatusError handling in get_storage_soe.""" - client = SpanPanelClient("192.168.1.100") - client._access_token = "test-token" - - with patch("span_panel_api.client.get_storage_soe_api_v1_storage_soe_get") as mock_storage: - # Create HTTPStatusError - mock_response = MagicMock() - mock_response.status_code = 504 - mock_response.content = b"Gateway Timeout" - mock_request = MagicMock() - - mock_storage.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError("504 Gateway Timeout", request=mock_request, response=mock_response) - ) - - with pytest.raises(SpanPanelRetriableError, match="Retriable server error 504"): - await client.get_storage_soe() - - @pytest.mark.asyncio - async def test_set_circuit_relay_httpx_status_error(self): - """Test HTTPStatusError handling in set_circuit_relay.""" - client = SpanPanelClient("192.168.1.100") - client._access_token = "test-token" - - with patch("span_panel_api.client.set_circuit_state_api_v_1_circuits_circuit_id_post") as mock_relay: - # Create HTTPStatusError - mock_response = MagicMock() - mock_response.status_code = 400 - mock_response.content = b"Bad Request" - mock_request = MagicMock() - - mock_relay.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError("400 Bad Request", request=mock_request, response=mock_response) - ) - - with pytest.raises(SpanPanelAPIError, match="HTTP 400"): - await client.set_circuit_relay("circuit-1", "OPEN") - - @pytest.mark.asyncio - async def test_set_circuit_priority_httpx_status_error(self): - """Test HTTPStatusError handling in set_circuit_priority.""" - client = SpanPanelClient("192.168.1.100") - client._access_token = "test-token" - - with patch("span_panel_api.client.set_circuit_state_api_v_1_circuits_circuit_id_post") as mock_priority: - # Create HTTPStatusError - mock_response = MagicMock() - mock_response.status_code = 422 - mock_response.content = b"Unprocessable Entity" - mock_request = MagicMock() - - mock_priority.asyncio = AsyncMock( - side_effect=httpx.HTTPStatusError("422 Unprocessable Entity", request=mock_request, response=mock_response) - ) - - with pytest.raises(SpanPanelAPIError, match="HTTP 422"): - await client.set_circuit_priority("circuit-1", "MUST_HAVE") - - @pytest.mark.asyncio - async def test_authenticate_httpx_connection_error(self): - """Test HTTPStatusError vs ConnectError handling in authenticate.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth: - # Test ConnectError - mock_auth.asyncio = AsyncMock(side_effect=httpx.ConnectError("Connection failed")) - - with pytest.raises(SpanPanelConnectionError, match="Failed to connect to 192.168.1.100"): - await client.authenticate("test-app", "Test Application") - - @pytest.mark.asyncio - async def test_authenticate_httpx_timeout_error(self): - """Test TimeoutException handling in authenticate.""" - client = SpanPanelClient("192.168.1.100") - - with patch("span_panel_api.client.generate_jwt_api_v1_auth_register_post") as mock_auth: - # Test TimeoutException - mock_auth.asyncio = AsyncMock(side_effect=httpx.TimeoutException("Request timed out")) - - with pytest.raises(SpanPanelTimeoutError, match="Request timed out after 30.0s"): - await client.authenticate("test-app", "Test Application") diff --git a/tests/test_factories.py b/tests/test_factories.py deleted file mode 100644 index 51ab6de..0000000 --- a/tests/test_factories.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Test factories and fixtures for SPAN Panel API tests. - -This module provides reusable fixtures and factories for creating test clients -in different configurations, particularly simulation mode clients. -""" - -import pytest -from pathlib import Path - -from span_panel_api.client import SpanPanelClient - - -@pytest.fixture -def sim_client() -> SpanPanelClient: - """Factory for creating simulation mode clients. - - Returns: - SpanPanelClient configured for simulation mode - """ - config_path = Path(__file__).parent.parent / "examples" / "simple_test_config.yaml" - return SpanPanelClient("yaml-sim-test", simulation_mode=True, simulation_config_path=str(config_path)) - - -def create_sim_client(host: str = "yaml-sim-test", **kwargs) -> SpanPanelClient: - """Direct factory function for creating simulation mode clients. - - Args: - host: Host address (default: "yaml-sim-test") - **kwargs: Additional client configuration parameters - - Returns: - SpanPanelClient configured for simulation mode with YAML config - """ - config_path = Path(__file__).parent.parent / "examples" / "simple_test_config.yaml" - return SpanPanelClient(host, simulation_mode=True, simulation_config_path=str(config_path), **kwargs) - - -def create_live_client(host: str = "192.168.1.100", **kwargs) -> SpanPanelClient: - """Direct factory function for creating live mode clients (for testing live functionality). - - Args: - host: Host address (default: "192.168.1.100") - **kwargs: Additional client configuration parameters - - Returns: - SpanPanelClient configured for live mode - """ - return SpanPanelClient(host, simulation_mode=False, **kwargs) diff --git a/tests/test_ha_compatibility.py b/tests/test_ha_compatibility.py deleted file mode 100644 index 096f70a..0000000 --- a/tests/test_ha_compatibility.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Tests for Home Assistant compatibility features.""" - -import asyncio -from unittest.mock import AsyncMock - -import pytest - -from span_panel_api import set_async_delay_func -from span_panel_api.client import _default_async_delay - - -class TestHACompatibility: - """Test Home Assistant compatibility features.""" - - def test_set_async_delay_func_custom(self) -> None: - """Test setting a custom async delay function.""" - # Test that the function can be called without error - custom_delay = AsyncMock() - - # Set the custom function - set_async_delay_func(custom_delay) - - # Reset to default to clean up - set_async_delay_func(None) - - def test_set_async_delay_func_none_resets_default(self) -> None: - """Test that setting None resets to default implementation.""" - # First set a custom function - custom_delay = AsyncMock() - set_async_delay_func(custom_delay) - - # Reset to default - set_async_delay_func(None) - - # Test should pass if no errors occur - - async def test_custom_delay_function_integration(self) -> None: - """Test that custom delay function can be set and used.""" - # Create a mock delay function that tracks calls - delay_calls = [] - - async def mock_delay(delay_seconds: float) -> None: - delay_calls.append(delay_seconds) - # Don't actually delay in tests - await asyncio.sleep(0) - - # Set the custom delay function - set_async_delay_func(mock_delay) - - try: - # Test that we can access the module's delay registry - from span_panel_api.client import _delay_registry - - # Call the delay function directly to verify it's our custom one - await _delay_registry.call_delay(0.5) - - # Verify our custom delay function was called - assert len(delay_calls) == 1 - assert delay_calls[0] == 0.5 - - finally: - # Reset to default - set_async_delay_func(None) - - async def test_default_delay_function_works(self) -> None: - """Test that the default delay function works correctly.""" - import time - - # Ensure we're using default - set_async_delay_func(None) - - # Time a very short delay - start_time = time.time() - await _default_async_delay(0.01) # 10ms - end_time = time.time() - - # Should have delayed at least 5ms (allowing for timing variations) - assert end_time - start_time >= 0.005 - - def test_import_from_main_module(self) -> None: - """Test that set_async_delay_func can be imported from the main module.""" - # This test verifies the function is properly exported - from span_panel_api import set_async_delay_func as imported_func - - assert imported_func is not None - assert callable(imported_func) diff --git a/tests/test_helpers.py b/tests/test_helpers.py deleted file mode 100644 index e486e48..0000000 --- a/tests/test_helpers.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Test helper functions for simulation and client testing.""" - -from pathlib import Path -from typing import Optional, Dict, Any - -from span_panel_api.client import SpanPanelClient - - -def create_yaml_sim_client( - config_name: str = "simple_test_config.yaml", - host: Optional[str] = None, -) -> SpanPanelClient: - """Create a simulation client using YAML configuration. - - Args: - config_name: Name of YAML config file in examples/ directory - host: Custom host/serial number (defaults to config value) - - Returns: - Configured SpanPanelClient in simulation mode - """ - config_path = Path(__file__).parent.parent / "examples" / config_name - - if not config_path.exists(): - raise FileNotFoundError(f"Config file not found: {config_path}") - - return SpanPanelClient( - host=host or "yaml-test-host", - simulation_mode=True, - simulation_config_path=str(config_path), - ) - - -def create_minimal_sim_client() -> SpanPanelClient: - """Create a minimal simulation client for basic testing.""" - return create_yaml_sim_client("minimal_config.yaml") - - -def create_behavior_sim_client() -> SpanPanelClient: - """Create a simulation client for testing behavior patterns.""" - return create_yaml_sim_client("behavior_test_config.yaml") - - -def create_simple_sim_client() -> SpanPanelClient: - """Create a simple simulation client for general testing.""" - return create_yaml_sim_client("simple_test_config.yaml") - - -def create_full_sim_client() -> SpanPanelClient: - """Create a full 32-circuit simulation client.""" - return create_yaml_sim_client("simulation_config_32_circuit.yaml") diff --git a/tests/test_mqtt_bridge.py b/tests/test_mqtt_bridge.py new file mode 100644 index 0000000..f3049ea --- /dev/null +++ b/tests/test_mqtt_bridge.py @@ -0,0 +1,147 @@ +"""Tests for the refactored AsyncMqttBridge (event-loop-driven).""" + +from __future__ import annotations + +import inspect + +import pytest + +from span_panel_api.mqtt.async_client import AsyncMQTTClient +from span_panel_api.mqtt.connection import AsyncMqttBridge + +SERIAL = "test-serial-0001" + + +def _make_bridge() -> AsyncMqttBridge: + return AsyncMqttBridge( + host="broker.local", + port=8883, + username="user", + password="pass", + panel_host="192.168.1.1", + serial_number=SERIAL, + ) + + +class TestBridgeConstruction: + """Verify the refactored bridge initializes correctly.""" + + def test_defaults(self) -> None: + bridge = _make_bridge() + assert bridge.is_connected() is False + assert bridge._client is None + assert bridge._misc_timer is None + assert bridge._reconnect_task is None + assert bridge._should_reconnect is False + assert bridge._initial_connect_done is False + + def test_callbacks_default_none(self) -> None: + bridge = _make_bridge() + assert bridge._message_callback is None + assert bridge._connection_callback is None + + def test_set_message_callback(self) -> None: + bridge = _make_bridge() + + def cb(topic: str, payload: str) -> None: + pass + + bridge.set_message_callback(cb) + assert bridge._message_callback is cb + + def test_set_connection_callback(self) -> None: + bridge = _make_bridge() + + def cb(connected: bool) -> None: + pass + + bridge.set_connection_callback(cb) + assert bridge._connection_callback is cb + + +class TestNoThreadingInModule: + """Verify no threading usage in the refactored connection module.""" + + def test_no_threading_lock_import(self) -> None: + source = inspect.getsource(AsyncMqttBridge) + assert "threading.Lock" not in source + assert "threading" not in source + + def test_no_loop_start(self) -> None: + source = inspect.getsource(AsyncMqttBridge) + assert "loop_start" not in source + + def test_no_loop_stop(self) -> None: + source = inspect.getsource(AsyncMqttBridge) + assert "loop_stop" not in source + + def test_uses_async_mqtt_client(self) -> None: + source = inspect.getsource(AsyncMqttBridge) + assert "AsyncMQTTClient" in source + + +class TestCallbacksDirect: + """Verify MQTT callbacks run directly (no call_soon_threadsafe).""" + + def test_on_connect_no_threadsafe_dispatch(self) -> None: + source = inspect.getsource(AsyncMqttBridge._on_connect) + assert "call_soon_threadsafe" not in source + + def test_on_disconnect_no_threadsafe_dispatch(self) -> None: + source = inspect.getsource(AsyncMqttBridge._on_disconnect) + assert "call_soon_threadsafe" not in source + + def test_on_message_no_threadsafe_dispatch(self) -> None: + source = inspect.getsource(AsyncMqttBridge._on_message) + assert "call_soon_threadsafe" not in source + + +class TestSocketCallbackBridges: + """Verify sync socket callbacks use call_soon_threadsafe (for executor).""" + + def test_sync_open_bridges_to_event_loop(self) -> None: + source = inspect.getsource(AsyncMqttBridge._on_socket_open_sync) + assert "call_soon_threadsafe" in source + + def test_sync_register_write_bridges_to_event_loop(self) -> None: + source = inspect.getsource(AsyncMqttBridge._on_socket_register_write_sync) + assert "call_soon_threadsafe" in source + + +class TestBridgeSubscribePublish: + """Verify subscribe/publish with no client are safe no-ops.""" + + def test_subscribe_no_client(self) -> None: + bridge = _make_bridge() + bridge.subscribe("test/topic") # Should not raise + + def test_publish_no_client(self) -> None: + bridge = _make_bridge() + bridge.publish("test/topic", "payload") # Should not raise + + +class TestDisconnectCleanup: + """Verify disconnect cleans up all resources.""" + + @pytest.mark.asyncio + async def test_disconnect_resets_state(self) -> None: + bridge = _make_bridge() + bridge._should_reconnect = True + bridge._initial_connect_done = True + await bridge.disconnect() + + assert bridge._should_reconnect is False + assert bridge._initial_connect_done is False + assert bridge._connected is False + assert bridge._client is None + assert bridge._reconnect_task is None + assert bridge._misc_timer is None + + +class TestMqttPackageExports: + """Verify AsyncMQTTClient is exported from the mqtt package.""" + + def test_async_mqtt_client_exported(self) -> None: + from span_panel_api.mqtt import AsyncMQTTClient as Exported + + assert Exported is AsyncMQTTClient diff --git a/tests/test_mqtt_connect_flow.py b/tests/test_mqtt_connect_flow.py new file mode 100644 index 0000000..88cdc09 --- /dev/null +++ b/tests/test_mqtt_connect_flow.py @@ -0,0 +1,387 @@ +"""Tests for MQTT connection lifecycle using the mock client. + +Exercises AsyncMqttBridge connect/disconnect/reconnect and +SpanMqttClient full connect-to-snapshot flow. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest +from paho.mqtt.client import ConnectFlags, DisconnectFlags, MQTTMessage +from paho.mqtt.reasoncodes import ReasonCode + +from span_panel_api.mqtt.client import SpanMqttClient +from span_panel_api.mqtt.connection import AsyncMqttBridge +from span_panel_api.mqtt.const import MQTT_RECONNECT_MIN_DELAY_S +from span_panel_api.mqtt.models import MqttClientConfig + +from conftest import MINIMAL_DESCRIPTION, SERIAL, TOPIC_PREFIX_SERIAL + + +def _make_bridge() -> AsyncMqttBridge: + return AsyncMqttBridge( + host="broker.local", + port=8883, + username="user", + password="pass", + panel_host="192.168.1.1", + serial_number=SERIAL, + use_tls=True, + ) + + +def _make_mqtt_message(topic: str, payload: str) -> MQTTMessage: + """Create a paho MQTTMessage with given topic and payload.""" + msg = MQTTMessage(topic=topic.encode("utf-8")) + msg.payload = payload.encode("utf-8") + return msg + + +# --------------------------------------------------------------------------- +# AsyncMqttBridge — connect / disconnect +# --------------------------------------------------------------------------- + + +class TestBridgeConnect: + @pytest.mark.asyncio + async def test_connect_success(self, mqtt_client_mock: MagicMock) -> None: + bridge = _make_bridge() + await bridge.connect() + + assert bridge.is_connected() is True + assert bridge._initial_connect_done is True + mqtt_client_mock.connect.assert_called_once() + mqtt_client_mock.setup.assert_called_once() + + @pytest.mark.asyncio + async def test_connect_sets_credentials(self, mqtt_client_mock: MagicMock) -> None: + bridge = _make_bridge() + await bridge.connect() + + mqtt_client_mock.username_pw_set.assert_called_once_with("user", "pass") + + @pytest.mark.asyncio + async def test_connect_configures_tls(self, mqtt_client_mock: MagicMock) -> None: + bridge = _make_bridge() + await bridge.connect() + + mqtt_client_mock.tls_set.assert_called_once() + + @pytest.mark.asyncio + async def test_connect_does_not_set_lwt(self, mqtt_client_mock: MagicMock) -> None: + """Consumer must not set LWT on the device's $state topic.""" + bridge = _make_bridge() + await bridge.connect() + + mqtt_client_mock.will_set.assert_not_called() + + @pytest.mark.asyncio + async def test_connect_no_tls(self, mqtt_client_mock: MagicMock) -> None: + bridge = AsyncMqttBridge( + host="broker.local", + port=1883, + username="user", + password="pass", + panel_host="192.168.1.1", + serial_number=SERIAL, + use_tls=False, + ) + await bridge.connect() + + assert bridge.is_connected() is True + mqtt_client_mock.tls_set.assert_not_called() + + @pytest.mark.asyncio + async def test_disconnect_after_connect(self, mqtt_client_mock: MagicMock) -> None: + bridge = _make_bridge() + await bridge.connect() + assert bridge.is_connected() is True + + await bridge.disconnect() + assert bridge.is_connected() is False + assert bridge._client is None + assert bridge._should_reconnect is False + mqtt_client_mock.disconnect.assert_called_once() + + +# --------------------------------------------------------------------------- +# AsyncMqttBridge — subscribe / publish +# --------------------------------------------------------------------------- + + +class TestBridgeSubscribePublish: + @pytest.mark.asyncio + async def test_subscribe_after_connect(self, mqtt_client_mock: MagicMock) -> None: + bridge = _make_bridge() + await bridge.connect() + + bridge.subscribe("test/topic", qos=1) + mqtt_client_mock.subscribe.assert_called_once_with("test/topic", qos=1) + + @pytest.mark.asyncio + async def test_publish_after_connect(self, mqtt_client_mock: MagicMock) -> None: + bridge = _make_bridge() + await bridge.connect() + + bridge.publish("test/topic", "hello", qos=1) + mqtt_client_mock.publish.assert_called_once_with("test/topic", payload="hello", qos=1) + + +# --------------------------------------------------------------------------- +# AsyncMqttBridge — message callback +# --------------------------------------------------------------------------- + + +class TestBridgeMessageCallback: + @pytest.mark.asyncio + async def test_on_message_dispatches_to_callback(self, mqtt_client_mock: MagicMock) -> None: + bridge = _make_bridge() + received: list[tuple[str, str]] = [] + + def on_msg(topic: str, payload: str) -> None: + received.append((topic, payload)) + + bridge.set_message_callback(on_msg) + await bridge.connect() + + # Simulate an incoming message by calling bridge's _on_message + msg = _make_mqtt_message("ebus/5/test/topic", "value") + bridge._on_message(mqtt_client_mock, None, msg) + + assert received == [("ebus/5/test/topic", "value")] + + @pytest.mark.asyncio + async def test_on_message_no_callback(self, mqtt_client_mock: MagicMock) -> None: + bridge = _make_bridge() + await bridge.connect() + + # Should not raise when no callback is set + msg = _make_mqtt_message("ebus/5/test/topic", "value") + bridge._on_message(mqtt_client_mock, None, msg) + + +# --------------------------------------------------------------------------- +# AsyncMqttBridge — connection callback +# --------------------------------------------------------------------------- + + +class TestBridgeConnectionCallback: + @pytest.mark.asyncio + async def test_on_connect_notifies_callback(self, mqtt_client_mock: MagicMock) -> None: + bridge = _make_bridge() + states: list[bool] = [] + bridge.set_connection_callback(states.append) + await bridge.connect() + + # The connect flow triggers _on_connect → callback(True) + assert True in states + + @pytest.mark.asyncio + async def test_on_disconnect_notifies_callback(self, mqtt_client_mock: MagicMock) -> None: + bridge = _make_bridge() + states: list[bool] = [] + bridge.set_connection_callback(states.append) + await bridge.connect() + + # Simulate disconnect + bridge._on_disconnect( + mqtt_client_mock, + None, + DisconnectFlags(is_disconnect_packet_from_server=True), + ReasonCode(packetType=2, aName="Success"), + None, + ) + + assert False in states + assert bridge.is_connected() is False + + +# --------------------------------------------------------------------------- +# AsyncMqttBridge — reconnect loop +# --------------------------------------------------------------------------- + + +class TestBridgeReconnect: + @pytest.mark.asyncio + async def test_disconnect_triggers_reconnect(self, mqtt_client_mock: MagicMock) -> None: + bridge = _make_bridge() + await bridge.connect() + assert bridge._initial_connect_done is True + + # Simulate unexpected disconnect + bridge._on_disconnect( + mqtt_client_mock, + None, + DisconnectFlags(is_disconnect_packet_from_server=True), + ReasonCode(packetType=2, aName="Success"), + None, + ) + + assert bridge._reconnect_task is not None + # Let the reconnect loop run one iteration + await asyncio.sleep(MQTT_RECONNECT_MIN_DELAY_S + 0.1) + # reconnect should have been called and succeeded + mqtt_client_mock.reconnect.assert_called() + + # Clean up + await bridge.disconnect() + assert bridge._reconnect_task is None + + @pytest.mark.asyncio + async def test_no_reconnect_before_initial_connect(self, mqtt_client_mock: MagicMock) -> None: + bridge = _make_bridge() + await bridge.connect() + + # Reset initial_connect_done to simulate pre-initial state + bridge._initial_connect_done = False + + bridge._on_disconnect( + mqtt_client_mock, + None, + DisconnectFlags(is_disconnect_packet_from_server=True), + ReasonCode(packetType=2, aName="Success"), + None, + ) + + # No reconnect task should be created + assert bridge._reconnect_task is None + + await bridge.disconnect() + + +# --------------------------------------------------------------------------- +# SpanMqttClient — full connect-to-snapshot flow +# --------------------------------------------------------------------------- + + +def _make_span_client(snapshot_interval: float = 0) -> SpanMqttClient: + config = MqttClientConfig( + broker_host="broker.local", + username="user", + password="pass", + ) + return SpanMqttClient( + host="192.168.1.1", + serial_number=SERIAL, + broker_config=config, + snapshot_interval=snapshot_interval, + ) + + +class TestSpanMqttClientConnect: + @pytest.mark.asyncio + async def test_connect_and_ready(self, mqtt_client_mock: MagicMock) -> None: + """Full connect flow: broker connect → subscribe → Homie ready.""" + client = _make_span_client() + + # Start connect in background — it will wait for Homie ready + connect_task = asyncio.create_task(client.connect()) + + # Let the bridge connect complete + await asyncio.sleep(0.05) + + # Feed Homie messages via _on_message to trigger ready detection. + # Description first (not yet ready), then state (transitions to ready). + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + + await asyncio.wait_for(connect_task, timeout=5.0) + + assert await client.ping() is True + mqtt_client_mock.subscribe.assert_called() + + @pytest.mark.asyncio + async def test_close(self, mqtt_client_mock: MagicMock) -> None: + client = _make_span_client() + + connect_task = asyncio.create_task(client.connect()) + await asyncio.sleep(0.05) + + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + await asyncio.wait_for(connect_task, timeout=5.0) + + await client.close() + assert await client.ping() is False + + @pytest.mark.asyncio + async def test_set_circuit_relay(self, mqtt_client_mock: MagicMock) -> None: + client = _make_span_client() + + connect_task = asyncio.create_task(client.connect()) + await asyncio.sleep(0.05) + + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + await asyncio.wait_for(connect_task, timeout=5.0) + + # Publish relay command + circuit_id = "aabbccdd11223344556677889900aabb" + await client.set_circuit_relay(circuit_id, "OPEN") + mqtt_client_mock.publish.assert_called() + + @pytest.mark.asyncio + async def test_set_circuit_priority(self, mqtt_client_mock: MagicMock) -> None: + client = _make_span_client() + + connect_task = asyncio.create_task(client.connect()) + await asyncio.sleep(0.05) + + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + await asyncio.wait_for(connect_task, timeout=5.0) + + circuit_id = "aabbccdd11223344556677889900aabb" + await client.set_circuit_priority(circuit_id, "NEVER") + mqtt_client_mock.publish.assert_called() + + @pytest.mark.asyncio + async def test_streaming_dispatches_snapshot(self, mqtt_client_mock: MagicMock) -> None: + client = _make_span_client() + + connect_task = asyncio.create_task(client.connect()) + await asyncio.sleep(0.05) + + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + await asyncio.wait_for(connect_task, timeout=5.0) + + # Register snapshot callback and start streaming + snapshots: list[object] = [] + callback = AsyncMock(side_effect=lambda s: snapshots.append(s)) + unregister = client.register_snapshot_callback(callback) + await client.start_streaming() + + # Trigger a property message while streaming + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/some-prop", "42") + await asyncio.sleep(0.05) + + assert len(snapshots) > 0 + callback.assert_called() + + # Unregister and stop + unregister() + await client.stop_streaming() + await client.close() + + @pytest.mark.asyncio + async def test_reconnect_resubscribes(self, mqtt_client_mock: MagicMock) -> None: + client = _make_span_client() + + connect_task = asyncio.create_task(client.connect()) + await asyncio.sleep(0.05) + + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + await asyncio.wait_for(connect_task, timeout=5.0) + + mqtt_client_mock.subscribe.reset_mock() + + # Simulate reconnection + client._on_connection_change(True) + mqtt_client_mock.subscribe.assert_called_once() + + await client.close() diff --git a/tests/test_mqtt_debounce.py b/tests/test_mqtt_debounce.py new file mode 100644 index 0000000..ae3df75 --- /dev/null +++ b/tests/test_mqtt_debounce.py @@ -0,0 +1,279 @@ +"""Tests for MQTT snapshot debounce timer in SpanMqttClient. + +Exercises the snapshot_interval parameter and debounce logic: +- Multiple rapid messages → single dispatch +- Snapshot fires after configured interval +- close() cancels pending timer +- interval=0 dispatches immediately (backward compat) +- set_snapshot_interval() runtime changes +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from span_panel_api.mqtt.client import SpanMqttClient +from span_panel_api.mqtt.models import MqttClientConfig + +from conftest import MINIMAL_DESCRIPTION, SERIAL, TOPIC_PREFIX_SERIAL + + +def _make_client(snapshot_interval: float = 1.0) -> SpanMqttClient: + config = MqttClientConfig( + broker_host="broker.local", + username="user", + password="pass", + ) + return SpanMqttClient( + host="192.168.1.1", + serial_number=SERIAL, + broker_config=config, + snapshot_interval=snapshot_interval, + ) + + +async def _connect_client(client: SpanMqttClient, mqtt_client_mock: MagicMock) -> None: + """Connect the client and bring Homie to ready state.""" + connect_task = asyncio.create_task(client.connect()) + await asyncio.sleep(0.05) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + await asyncio.wait_for(connect_task, timeout=5.0) + + +class TestSnapshotDebounce: + """Test debounce timer behavior with snapshot_interval > 0.""" + + @pytest.mark.asyncio + async def test_multiple_messages_single_dispatch(self, mqtt_client_mock: MagicMock) -> None: + """Multiple rapid MQTT messages should produce only one snapshot dispatch.""" + client = _make_client(snapshot_interval=0.2) + await _connect_client(client, mqtt_client_mock) + + snapshots: list[object] = [] + callback = AsyncMock(side_effect=lambda s: snapshots.append(s)) + client.register_snapshot_callback(callback) + await client.start_streaming() + + # Fire 10 rapid messages — only one timer should be scheduled + for i in range(10): + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", str(i * 100)) + + # Before timer fires: no snapshots yet + assert len(snapshots) == 0 + + # Wait for debounce timer to fire + await asyncio.sleep(0.35) + + # Exactly one snapshot dispatched + assert len(snapshots) == 1 + callback.assert_called_once() + + await client.stop_streaming() + await client.close() + + @pytest.mark.asyncio + async def test_snapshot_fires_after_interval(self, mqtt_client_mock: MagicMock) -> None: + """Snapshot dispatches after the configured interval, not immediately.""" + client = _make_client(snapshot_interval=0.3) + await _connect_client(client, mqtt_client_mock) + + snapshots: list[object] = [] + callback = AsyncMock(side_effect=lambda s: snapshots.append(s)) + client.register_snapshot_callback(callback) + await client.start_streaming() + + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "1000") + + # Not yet fired at half the interval + await asyncio.sleep(0.15) + assert len(snapshots) == 0 + + # Fired after full interval + await asyncio.sleep(0.25) + assert len(snapshots) == 1 + + await client.stop_streaming() + await client.close() + + @pytest.mark.asyncio + async def test_close_cancels_pending_timer(self, mqtt_client_mock: MagicMock) -> None: + """close() should cancel any pending debounce timer.""" + client = _make_client(snapshot_interval=1.0) + await _connect_client(client, mqtt_client_mock) + + snapshots: list[object] = [] + callback = AsyncMock(side_effect=lambda s: snapshots.append(s)) + client.register_snapshot_callback(callback) + await client.start_streaming() + + # Trigger a message to start the timer + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "500") + assert client._snapshot_timer is not None + + # Close before timer fires + await client.close() + assert client._snapshot_timer is None + + # Wait past when the timer would have fired + await asyncio.sleep(1.2) + assert len(snapshots) == 0 + + @pytest.mark.asyncio + async def test_stop_streaming_cancels_timer(self, mqtt_client_mock: MagicMock) -> None: + """stop_streaming() should cancel any pending debounce timer.""" + client = _make_client(snapshot_interval=1.0) + await _connect_client(client, mqtt_client_mock) + + snapshots: list[object] = [] + callback = AsyncMock(side_effect=lambda s: snapshots.append(s)) + client.register_snapshot_callback(callback) + await client.start_streaming() + + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "500") + assert client._snapshot_timer is not None + + await client.stop_streaming() + assert client._snapshot_timer is None + + await asyncio.sleep(1.2) + assert len(snapshots) == 0 + + await client.close() + + @pytest.mark.asyncio + async def test_second_batch_after_timer_fires(self, mqtt_client_mock: MagicMock) -> None: + """A new batch of messages after timer fires should start a new timer.""" + client = _make_client(snapshot_interval=0.15) + await _connect_client(client, mqtt_client_mock) + + snapshots: list[object] = [] + callback = AsyncMock(side_effect=lambda s: snapshots.append(s)) + client.register_snapshot_callback(callback) + await client.start_streaming() + + # First batch + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "100") + await asyncio.sleep(0.25) + assert len(snapshots) == 1 + + # Second batch + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "200") + await asyncio.sleep(0.25) + assert len(snapshots) == 2 + + await client.stop_streaming() + await client.close() + + +class TestSnapshotNoDebounce: + """Test interval=0 preserves immediate dispatch behavior.""" + + @pytest.mark.asyncio + async def test_zero_interval_dispatches_immediately(self, mqtt_client_mock: MagicMock) -> None: + """interval=0 should dispatch a snapshot for every message (no debounce).""" + client = _make_client(snapshot_interval=0) + await _connect_client(client, mqtt_client_mock) + + snapshots: list[object] = [] + callback = AsyncMock(side_effect=lambda s: snapshots.append(s)) + client.register_snapshot_callback(callback) + await client.start_streaming() + + # Each message should trigger an immediate dispatch task + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "100") + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "200") + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "300") + + # Let tasks complete + await asyncio.sleep(0.1) + + # Three separate dispatches + assert len(snapshots) == 3 + assert client._snapshot_timer is None + + await client.stop_streaming() + await client.close() + + @pytest.mark.asyncio + async def test_negative_interval_dispatches_immediately(self, mqtt_client_mock: MagicMock) -> None: + """Negative interval should behave like 0 (no debounce).""" + client = _make_client(snapshot_interval=-1.0) + await _connect_client(client, mqtt_client_mock) + + snapshots: list[object] = [] + callback = AsyncMock(side_effect=lambda s: snapshots.append(s)) + client.register_snapshot_callback(callback) + await client.start_streaming() + + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "100") + await asyncio.sleep(0.05) + + assert len(snapshots) == 1 + assert client._snapshot_timer is None + + await client.stop_streaming() + await client.close() + + +class TestSetSnapshotInterval: + """Test runtime snapshot interval changes.""" + + @pytest.mark.asyncio + async def test_set_snapshot_interval_cancels_timer(self, mqtt_client_mock: MagicMock) -> None: + """Changing interval at runtime should cancel any pending timer.""" + client = _make_client(snapshot_interval=2.0) + await _connect_client(client, mqtt_client_mock) + + callback = AsyncMock() + client.register_snapshot_callback(callback) + await client.start_streaming() + + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "100") + assert client._snapshot_timer is not None + + client.set_snapshot_interval(5.0) + assert client._snapshot_timer is None + assert client._snapshot_interval == 5.0 + + await client.stop_streaming() + await client.close() + + @pytest.mark.asyncio + async def test_set_interval_to_zero_switches_to_immediate(self, mqtt_client_mock: MagicMock) -> None: + """Changing from debounce to zero should switch to immediate dispatch.""" + client = _make_client(snapshot_interval=2.0) + await _connect_client(client, mqtt_client_mock) + + snapshots: list[object] = [] + callback = AsyncMock(side_effect=lambda s: snapshots.append(s)) + client.register_snapshot_callback(callback) + await client.start_streaming() + + # Switch to immediate mode + client.set_snapshot_interval(0) + + client._on_message(f"{TOPIC_PREFIX_SERIAL}/core/power", "100") + await asyncio.sleep(0.05) + + assert len(snapshots) == 1 + + await client.stop_streaming() + await client.close() + + def test_default_snapshot_interval(self) -> None: + """Default snapshot_interval should be 1.0 seconds.""" + config = MqttClientConfig( + broker_host="broker.local", + username="user", + password="pass", + ) + client = SpanMqttClient( + host="192.168.1.1", + serial_number=SERIAL, + broker_config=config, + ) + assert client._snapshot_interval == 1.0 diff --git a/tests/test_mqtt_homie.py b/tests/test_mqtt_homie.py new file mode 100644 index 0000000..c433993 --- /dev/null +++ b/tests/test_mqtt_homie.py @@ -0,0 +1,1315 @@ +"""Phase 3: MQTT/Homie Transport tests. + +Tests cover: +- MqttClientConfig model +- HomieDeviceConsumer message parsing and snapshot building +- Circuit ID normalization/denormalization +- Energy sign conventions (active-power negation) +- Lugs direction mapping +- Battery snapshot building +- DSM state derivation +- Property callbacks +- SpanMqttClient protocol compliance +- AsyncMqttBridge construction +- Package exports and version +""" + +from __future__ import annotations + +import json +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from span_panel_api.mqtt.const import ( + HOMIE_STATE_READY, + MQTT_DEFAULT_MQTTS_PORT, + MQTT_DEFAULT_WS_PORT, + MQTT_DEFAULT_WSS_PORT, + TOPIC_PREFIX, + TYPE_BESS, + TYPE_CIRCUIT, + TYPE_CORE, + TYPE_LUGS, + TYPE_LUGS_DOWNSTREAM, + TYPE_LUGS_UPSTREAM, + TYPE_POWER_FLOWS, + TYPE_PV, +) +from span_panel_api.mqtt.homie import HomieDeviceConsumer +from span_panel_api.mqtt.models import MqttClientConfig +from span_panel_api.protocol import ( + CircuitControlProtocol, + PanelCapability, + SpanPanelClientProtocol, + StreamingCapableProtocol, +) + + +SERIAL = "nj-2316-XXXX" +PREFIX = f"{TOPIC_PREFIX}/{SERIAL}" + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _make_description(nodes: dict) -> str: + """Build a Homie $description JSON string.""" + return json.dumps({"nodes": nodes}) + + +def _core_description() -> dict: + return { + "core": {"type": TYPE_CORE}, + } + + +def _full_description() -> dict: + return { + "core": {"type": TYPE_CORE}, + "lugs-upstream": {"type": TYPE_LUGS_UPSTREAM}, + "lugs-downstream": {"type": TYPE_LUGS_DOWNSTREAM}, + "aabbccdd-1122-3344-5566-778899001122": {"type": TYPE_CIRCUIT}, + "bess-0": {"type": TYPE_BESS}, + } + + +def _build_ready_consumer(description_nodes: dict | None = None) -> HomieDeviceConsumer: + """Create a HomieDeviceConsumer in ready state with given description.""" + consumer = HomieDeviceConsumer(SERIAL) + consumer.handle_message(f"{PREFIX}/$state", HOMIE_STATE_READY) + nodes = description_nodes or _full_description() + consumer.handle_message(f"{PREFIX}/$description", _make_description(nodes)) + return consumer + + +# --------------------------------------------------------------------------- +# MqttClientConfig +# --------------------------------------------------------------------------- + + +class TestMqttClientConfig: + def test_defaults(self): + config = MqttClientConfig(broker_host="broker.local", username="u", password="p") + assert config.mqtts_port == MQTT_DEFAULT_MQTTS_PORT + assert config.ws_port == MQTT_DEFAULT_WS_PORT + assert config.wss_port == MQTT_DEFAULT_WSS_PORT + assert config.transport == "tcp" + assert config.use_tls is True + + def test_effective_port_tcp(self): + config = MqttClientConfig(broker_host="h", username="u", password="p") + assert config.effective_port == MQTT_DEFAULT_MQTTS_PORT + + def test_effective_port_websockets_tls(self): + config = MqttClientConfig(broker_host="h", username="u", password="p", transport="websockets", use_tls=True) + assert config.effective_port == MQTT_DEFAULT_WSS_PORT + + def test_effective_port_websockets_no_tls(self): + config = MqttClientConfig(broker_host="h", username="u", password="p", transport="websockets", use_tls=False) + assert config.effective_port == MQTT_DEFAULT_WS_PORT + + def test_frozen(self): + config = MqttClientConfig(broker_host="h", username="u", password="p") + with pytest.raises(AttributeError): + config.broker_host = "other" + + +# --------------------------------------------------------------------------- +# HomieDeviceConsumer — state machine +# --------------------------------------------------------------------------- + + +class TestHomieConsumerState: + def test_not_ready_initially(self): + consumer = HomieDeviceConsumer(SERIAL) + assert not consumer.is_ready() + + def test_not_ready_state_only(self): + consumer = HomieDeviceConsumer(SERIAL) + consumer.handle_message(f"{PREFIX}/$state", "ready") + assert not consumer.is_ready() + + def test_not_ready_description_only(self): + consumer = HomieDeviceConsumer(SERIAL) + consumer.handle_message(f"{PREFIX}/$description", _make_description(_core_description())) + assert not consumer.is_ready() + + def test_ready_when_both(self): + consumer = _build_ready_consumer(_core_description()) + assert consumer.is_ready() + + def test_ignores_other_serial(self): + consumer = HomieDeviceConsumer(SERIAL) + consumer.handle_message(f"{TOPIC_PREFIX}/other-serial/$state", "ready") + assert not consumer.is_ready() + + def test_ignores_set_topics(self): + consumer = _build_ready_consumer() + # /set topics should not be handled as property values + circuit_node = "aabbccdd-1122-3344-5566-778899001122" + consumer.handle_message(f"{PREFIX}/{circuit_node}/relay/set", "OPEN") + snapshot = consumer.build_snapshot() + circuit = snapshot.circuits.get("aabbccdd11223344556677889900112" + "2") + assert circuit is not None + assert circuit.relay_state == "UNKNOWN" # not set to OPEN + + +# --------------------------------------------------------------------------- +# HomieDeviceConsumer — circuit snapshot +# --------------------------------------------------------------------------- + + +class TestHomieCircuitSnapshot: + def test_circuit_id_normalization(self): + from span_panel_api.mqtt.const import normalize_circuit_id + + assert normalize_circuit_id("aabbccdd-1122-3344-5566-778899001122") == "aabbccdd11223344556677889900112" + "2" + + def test_circuit_id_denormalization(self): + from span_panel_api.mqtt.const import denormalize_circuit_id + + result = denormalize_circuit_id("aabbccdd11223344556677889900112" + "2") + assert result == "aabbccdd-1122-3344-5566-778899001122" + + def test_denormalize_non_uuid(self): + from span_panel_api.mqtt.const import denormalize_circuit_id + + # Non-32-char strings pass through unchanged + assert denormalize_circuit_id("short") == "short" + # Already dashed passes through + assert denormalize_circuit_id("aabbccdd-1122-3344-5566-778899001122") == "aabbccdd-1122-3344-5566-778899001122" + + def test_circuit_power_negation(self): + """active-power in W, negative=consumption → positive=consumption in snapshot.""" + consumer = _build_ready_consumer() + node = "aabbccdd-1122-3344-5566-778899001122" + # -150.0 W consumption → 150.0 W positive in snapshot + consumer.handle_message(f"{PREFIX}/{node}/active-power", "-150.0") + snapshot = consumer.build_snapshot() + circuit = snapshot.circuits["aabbccdd112233445566778899001122"] + assert circuit.instant_power_w == 150.0 + + def test_circuit_power_positive_generation(self): + """Positive active-power (generation) → negative in snapshot.""" + consumer = _build_ready_consumer() + node = "aabbccdd-1122-3344-5566-778899001122" + consumer.handle_message(f"{PREFIX}/{node}/active-power", "200.0") + snapshot = consumer.build_snapshot() + circuit = snapshot.circuits["aabbccdd112233445566778899001122"] + assert circuit.instant_power_w == -200.0 + + def test_circuit_energy_mapping(self): + """exported-energy → consumed, imported-energy → produced.""" + consumer = _build_ready_consumer() + node = "aabbccdd-1122-3344-5566-778899001122" + consumer.handle_message(f"{PREFIX}/{node}/exported-energy", "12345.6") + consumer.handle_message(f"{PREFIX}/{node}/imported-energy", "789.0") + snapshot = consumer.build_snapshot() + circuit = snapshot.circuits["aabbccdd112233445566778899001122"] + assert circuit.consumed_energy_wh == 12345.6 + assert circuit.produced_energy_wh == 789.0 + + def test_circuit_properties(self): + consumer = _build_ready_consumer() + node = "aabbccdd-1122-3344-5566-778899001122" + consumer.handle_message(f"{PREFIX}/{node}/name", "Kitchen") + consumer.handle_message(f"{PREFIX}/{node}/relay", "CLOSED") + consumer.handle_message(f"{PREFIX}/{node}/shed-priority", "NEVER") + consumer.handle_message(f"{PREFIX}/{node}/space", "5") + consumer.handle_message(f"{PREFIX}/{node}/dipole", "true") + consumer.handle_message(f"{PREFIX}/{node}/sheddable", "true") + consumer.handle_message(f"{PREFIX}/{node}/never-backup", "false") + consumer.handle_message(f"{PREFIX}/{node}/always-on", "true") + consumer.handle_message(f"{PREFIX}/{node}/current", "15.2") + consumer.handle_message(f"{PREFIX}/{node}/breaker-rating", "20") + consumer.handle_message(f"{PREFIX}/{node}/relay-requester", "USER") + + snapshot = consumer.build_snapshot() + circuit = snapshot.circuits["aabbccdd112233445566778899001122"] + + assert circuit.name == "Kitchen" + assert circuit.relay_state == "CLOSED" + assert circuit.priority == "NEVER" + assert circuit.tabs == [5, 7] # dipole: [space, space + 2] + assert circuit.is_240v is True + assert circuit.is_sheddable is True + assert circuit.is_never_backup is False + assert circuit.always_on is True + assert circuit.is_user_controllable is False # not always_on → False + assert circuit.current_a == 15.2 + assert circuit.breaker_rating_a == 20.0 + assert circuit.relay_requester == "USER" + + def test_circuit_timestamps(self): + consumer = _build_ready_consumer() + node = "aabbccdd-1122-3344-5566-778899001122" + before = int(time.time()) + consumer.handle_message(f"{PREFIX}/{node}/active-power", "-1.0") + consumer.handle_message(f"{PREFIX}/{node}/exported-energy", "100.0") + after = int(time.time()) + + snapshot = consumer.build_snapshot() + circuit = snapshot.circuits["aabbccdd112233445566778899001122"] + assert before <= circuit.instant_power_update_time_s <= after + assert before <= circuit.energy_accum_update_time_s <= after + + def test_pv_metadata_node_annotates_circuit(self): + """PV metadata node's feed property sets device_type and relative_position.""" + circuit_uuid = "aabbccdd-1122-3344-5566-778899001122" + consumer = _build_ready_consumer( + { + "core": {"type": TYPE_CORE}, + circuit_uuid: {"type": TYPE_CIRCUIT}, + "pv": {"type": TYPE_PV}, + "bess-0": {"type": TYPE_BESS}, + } + ) + # PV node references the circuit via feed and has relative-position + consumer.handle_message(f"{PREFIX}/pv/feed", circuit_uuid) + consumer.handle_message(f"{PREFIX}/pv/relative-position", "IN_PANEL") + consumer.handle_message(f"{PREFIX}/{circuit_uuid}/name", "Solar Panels") + consumer.handle_message(f"{PREFIX}/{circuit_uuid}/space", "30") + consumer.handle_message(f"{PREFIX}/{circuit_uuid}/dipole", "true") + + snapshot = consumer.build_snapshot() + circuit = snapshot.circuits["aabbccdd112233445566778899001122"] + assert circuit.device_type == "pv" + assert circuit.relative_position == "IN_PANEL" + assert circuit.name == "Solar Panels" + # PV metadata node itself should NOT appear in circuits + assert "pv" not in snapshot.circuits + + def test_pv_downstream_has_breaker_position(self): + """PV with relative-position=DOWNSTREAM indicates a breaker-connected PV.""" + circuit_uuid = "aabbccdd-1122-3344-5566-778899001122" + consumer = _build_ready_consumer( + { + "core": {"type": TYPE_CORE}, + circuit_uuid: {"type": TYPE_CIRCUIT}, + "pv": {"type": TYPE_PV}, + "bess-0": {"type": TYPE_BESS}, + } + ) + consumer.handle_message(f"{PREFIX}/pv/feed", circuit_uuid) + consumer.handle_message(f"{PREFIX}/pv/relative-position", "DOWNSTREAM") + consumer.handle_message(f"{PREFIX}/{circuit_uuid}/name", "Solar Breaker") + consumer.handle_message(f"{PREFIX}/{circuit_uuid}/space", "15") + + snapshot = consumer.build_snapshot() + circuit = snapshot.circuits["aabbccdd112233445566778899001122"] + assert circuit.device_type == "pv" + assert circuit.relative_position == "DOWNSTREAM" + + def test_pv_metadata_node_excluded_from_circuits(self): + """PV/EVSE metadata nodes should not appear as circuit entities.""" + consumer = _build_ready_consumer() + consumer.handle_message(f"{PREFIX}/pv/feed", "aabbccdd-1122-3344-5566-778899001122") + + snapshot = consumer.build_snapshot() + # The "pv" node itself should not be in circuits + assert "pv" not in snapshot.circuits + + def test_circuit_default_device_type(self): + """Regular circuits without PV/EVSE feed have device_type='circuit' and no relative_position.""" + consumer = _build_ready_consumer() + node = "aabbccdd-1122-3344-5566-778899001122" + consumer.handle_message(f"{PREFIX}/{node}/name", "Kitchen") + + snapshot = consumer.build_snapshot() + circuit = snapshot.circuits["aabbccdd112233445566778899001122"] + assert circuit.device_type == "circuit" + assert circuit.relative_position == "" + + +# --------------------------------------------------------------------------- +# HomieDeviceConsumer — core node +# --------------------------------------------------------------------------- + + +class TestHomieCoreNode: + def test_core_properties(self): + consumer = _build_ready_consumer() + consumer.handle_message(f"{PREFIX}/core/software-version", "spanos2/r202603/05") + consumer.handle_message(f"{PREFIX}/core/door", "CLOSED") + consumer.handle_message(f"{PREFIX}/core/relay", "CLOSED") + consumer.handle_message(f"{PREFIX}/core/ethernet", "true") + consumer.handle_message(f"{PREFIX}/core/wifi", "true") + consumer.handle_message(f"{PREFIX}/core/wifi-ssid", "MyNetwork") + consumer.handle_message(f"{PREFIX}/core/vendor-cloud", "CONNECTED") + consumer.handle_message(f"{PREFIX}/core/dominant-power-source", "GRID") + consumer.handle_message(f"{PREFIX}/core/grid-islandable", "true") + consumer.handle_message(f"{PREFIX}/core/l1-voltage", "121.5") + consumer.handle_message(f"{PREFIX}/core/l2-voltage", "120.8") + consumer.handle_message(f"{PREFIX}/core/breaker-rating", "200") + + snapshot = consumer.build_snapshot() + assert snapshot.firmware_version == "spanos2/r202603/05" + assert snapshot.door_state == "CLOSED" + assert snapshot.main_relay_state == "CLOSED" + assert snapshot.eth0_link is True + assert snapshot.wlan_link is True + assert snapshot.wifi_ssid == "MyNetwork" + assert snapshot.wwan_link is True + assert snapshot.vendor_cloud == "CONNECTED" + assert snapshot.dominant_power_source == "GRID" + assert snapshot.grid_islandable is True + assert snapshot.l1_voltage == 121.5 + assert snapshot.l2_voltage == 120.8 + assert snapshot.main_breaker_rating_a == 200 + + def test_proximity_proven_when_ready(self): + consumer = _build_ready_consumer() + snapshot = consumer.build_snapshot() + assert snapshot.proximity_proven is True + + def test_uptime_increases(self): + consumer = _build_ready_consumer() + snapshot1 = consumer.build_snapshot() + # uptime should be >= 0 + assert snapshot1.uptime_s >= 0 + + +# --------------------------------------------------------------------------- +# HomieDeviceConsumer — DSM derivation +# --------------------------------------------------------------------------- + + +class TestHomieDsmDerivation: + """Tests for the multi-signal dsm_grid_state and tri-state run_config derivations.""" + + # -- dsm_grid_state: BESS authoritative -- + + def test_bess_on_grid_authoritative(self): + consumer = _build_ready_consumer() + consumer.handle_message(f"{PREFIX}/bess-0/grid-state", "ON_GRID") + snapshot = consumer.build_snapshot() + assert snapshot.dsm_grid_state == "DSM_ON_GRID" + assert snapshot.grid_state == "ON_GRID" + + def test_bess_off_grid_authoritative(self): + consumer = _build_ready_consumer() + consumer.handle_message(f"{PREFIX}/bess-0/grid-state", "OFF_GRID") + snapshot = consumer.build_snapshot() + assert snapshot.dsm_grid_state == "DSM_OFF_GRID" + + # -- dsm_grid_state: DPS fallback (no BESS) -- + + def test_dps_grid_implies_on_grid(self): + """DPS=GRID → DSM_ON_GRID even without BESS.""" + consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}}) + consumer.handle_message(f"{PREFIX}/core/dominant-power-source", "GRID") + snapshot = consumer.build_snapshot() + assert snapshot.dsm_grid_state == "DSM_ON_GRID" + + def test_dps_battery_with_grid_power_on_grid(self): + """DPS=BATTERY but grid still exchanging power → DSM_ON_GRID.""" + consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}, "lugs-upstream": {"type": TYPE_LUGS_UPSTREAM}}) + consumer.handle_message(f"{PREFIX}/core/dominant-power-source", "BATTERY") + consumer.handle_message(f"{PREFIX}/lugs-upstream/active-power", "500.0") + snapshot = consumer.build_snapshot() + assert snapshot.dsm_grid_state == "DSM_ON_GRID" + + def test_dps_battery_zero_grid_power_off_grid(self): + """DPS=BATTERY and zero grid power → DSM_OFF_GRID.""" + consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}, "lugs-upstream": {"type": TYPE_LUGS_UPSTREAM}}) + consumer.handle_message(f"{PREFIX}/core/dominant-power-source", "BATTERY") + consumer.handle_message(f"{PREFIX}/lugs-upstream/active-power", "0.0") + snapshot = consumer.build_snapshot() + assert snapshot.dsm_grid_state == "DSM_OFF_GRID" + + def test_dps_pv_with_grid_power_on_grid(self): + """DPS=PV but grid still exchanging → DSM_ON_GRID.""" + consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}, "lugs-upstream": {"type": TYPE_LUGS_UPSTREAM}}) + consumer.handle_message(f"{PREFIX}/core/dominant-power-source", "PV") + consumer.handle_message(f"{PREFIX}/lugs-upstream/active-power", "-200.0") + snapshot = consumer.build_snapshot() + assert snapshot.dsm_grid_state == "DSM_ON_GRID" + + def test_dps_none_returns_unknown(self): + """DPS=NONE → UNKNOWN (not a known power source).""" + consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}}) + consumer.handle_message(f"{PREFIX}/core/dominant-power-source", "NONE") + snapshot = consumer.build_snapshot() + assert snapshot.dsm_grid_state == "UNKNOWN" + + def test_no_core_returns_unknown(self): + """No core node at all → UNKNOWN.""" + consumer = _build_ready_consumer({"bess-0": {"type": TYPE_BESS}}) + snapshot = consumer.build_snapshot() + assert snapshot.dsm_grid_state == "UNKNOWN" + + # -- current_run_config: tri-state derivation -- + + def test_on_grid_dps_grid(self): + """DPS=GRID → PANEL_ON_GRID.""" + consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}}) + consumer.handle_message(f"{PREFIX}/core/dominant-power-source", "GRID") + snapshot = consumer.build_snapshot() + assert snapshot.current_run_config == "PANEL_ON_GRID" + + def test_off_grid_battery_islandable_backup(self): + """Off-grid + islandable + BATTERY → PANEL_BACKUP.""" + consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}, "bess-0": {"type": TYPE_BESS}}) + consumer.handle_message(f"{PREFIX}/core/dominant-power-source", "BATTERY") + consumer.handle_message(f"{PREFIX}/core/grid-islandable", "true") + consumer.handle_message(f"{PREFIX}/bess-0/grid-state", "OFF_GRID") + snapshot = consumer.build_snapshot() + assert snapshot.dsm_grid_state == "DSM_OFF_GRID" + assert snapshot.current_run_config == "PANEL_BACKUP" + + def test_off_grid_pv_islandable_off_grid(self): + """Off-grid + islandable + PV → PANEL_OFF_GRID (intentional off-grid).""" + consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}, "bess-0": {"type": TYPE_BESS}}) + consumer.handle_message(f"{PREFIX}/core/dominant-power-source", "PV") + consumer.handle_message(f"{PREFIX}/core/grid-islandable", "true") + consumer.handle_message(f"{PREFIX}/bess-0/grid-state", "OFF_GRID") + snapshot = consumer.build_snapshot() + assert snapshot.dsm_grid_state == "DSM_OFF_GRID" + assert snapshot.current_run_config == "PANEL_OFF_GRID" + + def test_off_grid_generator_islandable_off_grid(self): + """Off-grid + islandable + GENERATOR → PANEL_OFF_GRID.""" + consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}, "bess-0": {"type": TYPE_BESS}}) + consumer.handle_message(f"{PREFIX}/core/dominant-power-source", "GENERATOR") + consumer.handle_message(f"{PREFIX}/core/grid-islandable", "true") + consumer.handle_message(f"{PREFIX}/bess-0/grid-state", "OFF_GRID") + snapshot = consumer.build_snapshot() + assert snapshot.current_run_config == "PANEL_OFF_GRID" + + def test_off_grid_not_islandable_unknown(self): + """Off-grid + not islandable → UNKNOWN (shouldn't happen).""" + consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}, "bess-0": {"type": TYPE_BESS}}) + consumer.handle_message(f"{PREFIX}/core/dominant-power-source", "BATTERY") + consumer.handle_message(f"{PREFIX}/core/grid-islandable", "false") + consumer.handle_message(f"{PREFIX}/bess-0/grid-state", "OFF_GRID") + snapshot = consumer.build_snapshot() + assert snapshot.current_run_config == "UNKNOWN" + + def test_off_grid_islandable_dps_none_unknown(self): + """Off-grid + islandable + DPS=NONE → UNKNOWN.""" + consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}, "bess-0": {"type": TYPE_BESS}}) + consumer.handle_message(f"{PREFIX}/core/dominant-power-source", "NONE") + consumer.handle_message(f"{PREFIX}/core/grid-islandable", "true") + consumer.handle_message(f"{PREFIX}/bess-0/grid-state", "OFF_GRID") + snapshot = consumer.build_snapshot() + assert snapshot.current_run_config == "UNKNOWN" + + +# --------------------------------------------------------------------------- +# HomieDeviceConsumer — lugs +# --------------------------------------------------------------------------- + + +class TestHomieLugs: + def test_upstream_lugs_to_main_meter(self): + """Test typed lugs (energy.ebus.device.lugs.upstream) map to main meter.""" + consumer = _build_ready_consumer() + consumer.handle_message(f"{PREFIX}/lugs-upstream/active-power", "5000.0") + consumer.handle_message(f"{PREFIX}/lugs-upstream/imported-energy", "100000.0") + consumer.handle_message(f"{PREFIX}/lugs-upstream/exported-energy", "5000.0") + + snapshot = consumer.build_snapshot() + assert snapshot.instant_grid_power_w == 5000.0 + # imported-energy = consumed from grid, exported-energy = produced (solar) + assert snapshot.main_meter_energy_consumed_wh == 100000.0 + assert snapshot.main_meter_energy_produced_wh == 5000.0 + + def test_downstream_lugs_to_feedthrough(self): + """Test typed lugs (energy.ebus.device.lugs.downstream) map to feedthrough.""" + consumer = _build_ready_consumer() + consumer.handle_message(f"{PREFIX}/lugs-downstream/active-power", "1000.0") + consumer.handle_message(f"{PREFIX}/lugs-downstream/imported-energy", "50000.0") + consumer.handle_message(f"{PREFIX}/lugs-downstream/exported-energy", "1000.0") + + snapshot = consumer.build_snapshot() + assert snapshot.feedthrough_power_w == 1000.0 + assert snapshot.feedthrough_energy_consumed_wh == 50000.0 + assert snapshot.feedthrough_energy_produced_wh == 1000.0 + + def test_generic_lugs_with_direction_property(self): + """Test fallback: generic TYPE_LUGS + direction property.""" + consumer = _build_ready_consumer( + { + "core": {"type": TYPE_CORE}, + "upstream-lugs": {"type": TYPE_LUGS}, + "downstream-lugs": {"type": TYPE_LUGS}, + "bess-0": {"type": TYPE_BESS}, + } + ) + consumer.handle_message(f"{PREFIX}/upstream-lugs/direction", "UPSTREAM") + consumer.handle_message(f"{PREFIX}/upstream-lugs/active-power", "800.0") + consumer.handle_message(f"{PREFIX}/upstream-lugs/imported-energy", "90000.0") + consumer.handle_message(f"{PREFIX}/upstream-lugs/exported-energy", "3000.0") + + consumer.handle_message(f"{PREFIX}/downstream-lugs/direction", "DOWNSTREAM") + consumer.handle_message(f"{PREFIX}/downstream-lugs/active-power", "200.0") + consumer.handle_message(f"{PREFIX}/downstream-lugs/imported-energy", "40000.0") + consumer.handle_message(f"{PREFIX}/downstream-lugs/exported-energy", "500.0") + + snapshot = consumer.build_snapshot() + assert snapshot.instant_grid_power_w == 800.0 + assert snapshot.main_meter_energy_consumed_wh == 90000.0 + assert snapshot.main_meter_energy_produced_wh == 3000.0 + assert snapshot.feedthrough_power_w == 200.0 + assert snapshot.feedthrough_energy_consumed_wh == 40000.0 + assert snapshot.feedthrough_energy_produced_wh == 500.0 + + +# --------------------------------------------------------------------------- +# HomieDeviceConsumer — battery +# --------------------------------------------------------------------------- + + +class TestHomieBattery: + def test_battery_soc_soe(self): + consumer = _build_ready_consumer() + consumer.handle_message(f"{PREFIX}/bess-0/soc", "85.5") + consumer.handle_message(f"{PREFIX}/bess-0/soe", "10.2") + + snapshot = consumer.build_snapshot() + assert snapshot.battery.soe_percentage == 85.5 + assert snapshot.battery.soe_kwh == 10.2 + + def test_no_battery_node(self): + consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}}) + snapshot = consumer.build_snapshot() + assert snapshot.battery.soe_percentage is None + assert snapshot.battery.soe_kwh is None + + def test_battery_metadata(self): + """BESS metadata properties are parsed into the battery snapshot.""" + consumer = _build_ready_consumer() + consumer.handle_message(f"{PREFIX}/bess-0/soc", "85.0") + consumer.handle_message(f"{PREFIX}/bess-0/vendor-name", "Tesla") + consumer.handle_message(f"{PREFIX}/bess-0/product-name", "Powerwall 3") + consumer.handle_message(f"{PREFIX}/bess-0/nameplate-capacity", "13.5") + + snapshot = consumer.build_snapshot() + assert snapshot.battery.vendor_name == "Tesla" + assert snapshot.battery.product_name == "Powerwall 3" + assert snapshot.battery.nameplate_capacity_kwh == 13.5 + + def test_battery_metadata_absent(self): + """BESS node without metadata properties has None values.""" + consumer = _build_ready_consumer() + consumer.handle_message(f"{PREFIX}/bess-0/soc", "50.0") + + snapshot = consumer.build_snapshot() + assert snapshot.battery.soe_percentage == 50.0 + assert snapshot.battery.vendor_name is None + assert snapshot.battery.product_name is None + assert snapshot.battery.nameplate_capacity_kwh is None + + +# --------------------------------------------------------------------------- +# HomieDeviceConsumer — PV metadata +# --------------------------------------------------------------------------- + + +class TestHomiePVMetadata: + def test_pv_metadata_parsed(self): + """PV metadata properties are parsed into the pv snapshot.""" + consumer = _build_ready_consumer( + { + "core": {"type": TYPE_CORE}, + "bess-0": {"type": TYPE_BESS}, + "pv-0": {"type": TYPE_PV}, + } + ) + consumer.handle_message(f"{PREFIX}/pv-0/vendor-name", "Enphase") + consumer.handle_message(f"{PREFIX}/pv-0/product-name", "IQ8+") + consumer.handle_message(f"{PREFIX}/pv-0/nameplate-capacity", "3960") + + snapshot = consumer.build_snapshot() + assert snapshot.pv.vendor_name == "Enphase" + assert snapshot.pv.product_name == "IQ8+" + assert snapshot.pv.nameplate_capacity_kw == 3960.0 + + def test_no_pv_node(self): + """Without PV node, pv snapshot has None values.""" + consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}}) + snapshot = consumer.build_snapshot() + assert snapshot.pv.vendor_name is None + assert snapshot.pv.product_name is None + assert snapshot.pv.nameplate_capacity_kw is None + + def test_pv_metadata_partial(self): + """PV node with only some properties populated.""" + consumer = _build_ready_consumer( + { + "core": {"type": TYPE_CORE}, + "pv-0": {"type": TYPE_PV}, + } + ) + consumer.handle_message(f"{PREFIX}/pv-0/vendor-name", "Other") + + snapshot = consumer.build_snapshot() + assert snapshot.pv.vendor_name == "Other" + assert snapshot.pv.product_name is None + assert snapshot.pv.nameplate_capacity_kw is None + + +# --------------------------------------------------------------------------- +# HomieDeviceConsumer — power flows +# --------------------------------------------------------------------------- + + +class TestHomiePowerFlows: + def test_power_flows_parsed(self): + """Power-flows node properties map to snapshot fields.""" + consumer = _build_ready_consumer( + { + "core": {"type": TYPE_CORE}, + "power-flows": {"type": TYPE_POWER_FLOWS}, + "bess-0": {"type": TYPE_BESS}, + } + ) + consumer.handle_message(f"{PREFIX}/power-flows/pv", "3500.0") + consumer.handle_message(f"{PREFIX}/power-flows/battery", "-1200.0") + consumer.handle_message(f"{PREFIX}/power-flows/grid", "800.0") + consumer.handle_message(f"{PREFIX}/power-flows/site", "3100.0") + + snapshot = consumer.build_snapshot() + assert snapshot.power_flow_pv == 3500.0 + assert snapshot.power_flow_battery == -1200.0 + assert snapshot.power_flow_grid == 800.0 + assert snapshot.power_flow_site == 3100.0 + + def test_no_power_flows_node(self): + """Without power-flows node, fields are None.""" + consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}}) + snapshot = consumer.build_snapshot() + assert snapshot.power_flow_pv is None + assert snapshot.power_flow_battery is None + assert snapshot.power_flow_grid is None + assert snapshot.power_flow_site is None + + def test_partial_power_flows(self): + """Only populated properties get values; others remain None.""" + consumer = _build_ready_consumer( + { + "core": {"type": TYPE_CORE}, + "power-flows": {"type": TYPE_POWER_FLOWS}, + } + ) + consumer.handle_message(f"{PREFIX}/power-flows/battery", "500.0") + + snapshot = consumer.build_snapshot() + assert snapshot.power_flow_battery == 500.0 + assert snapshot.power_flow_pv is None + assert snapshot.power_flow_grid is None + assert snapshot.power_flow_site is None + + +# --------------------------------------------------------------------------- +# HomieDeviceConsumer — lugs per-phase current +# --------------------------------------------------------------------------- + + +class TestHomieLugsCurrent: + def test_upstream_lugs_current(self): + """l1-current and l2-current from upstream lugs map to snapshot.""" + consumer = _build_ready_consumer() + consumer.handle_message(f"{PREFIX}/lugs-upstream/l1-current", "45.2") + consumer.handle_message(f"{PREFIX}/lugs-upstream/l2-current", "42.8") + + snapshot = consumer.build_snapshot() + assert snapshot.upstream_l1_current_a == 45.2 + assert snapshot.upstream_l2_current_a == 42.8 + + def test_no_lugs_current(self): + """Without l1/l2-current, fields are None.""" + consumer = _build_ready_consumer() + snapshot = consumer.build_snapshot() + assert snapshot.upstream_l1_current_a is None + assert snapshot.upstream_l2_current_a is None + + +# --------------------------------------------------------------------------- +# HomieDeviceConsumer — panel_size +# --------------------------------------------------------------------------- + + +class TestHomiePanelSize: + def test_panel_size_parsed(self): + consumer = _build_ready_consumer() + consumer.handle_message(f"{PREFIX}/core/panel-size", "32") + snapshot = consumer.build_snapshot() + assert snapshot.panel_size == 32 + + def test_panel_size_none_when_missing(self): + consumer = _build_ready_consumer() + snapshot = consumer.build_snapshot() + assert snapshot.panel_size is None + + +# --------------------------------------------------------------------------- +# HomieDeviceConsumer — snapshot structure +# --------------------------------------------------------------------------- + + +class TestHomieSnapshot: + def test_serial_number_preserved(self): + consumer = _build_ready_consumer() + snapshot = consumer.build_snapshot() + assert snapshot.serial_number == SERIAL + + def test_snapshot_immutable(self): + consumer = _build_ready_consumer() + snapshot = consumer.build_snapshot() + with pytest.raises(AttributeError): + snapshot.serial_number = "other" + + +# --------------------------------------------------------------------------- +# HomieDeviceConsumer — property callbacks +# --------------------------------------------------------------------------- + + +class TestHomieCallbacks: + def test_property_callback_fired(self): + consumer = _build_ready_consumer() + calls = [] + consumer.register_property_callback(lambda n, p, v, o: calls.append((n, p, v, o))) + consumer.handle_message(f"{PREFIX}/core/door", "OPEN") + assert len(calls) == 1 + assert calls[0] == ("core", "door", "OPEN", None) + + def test_property_callback_with_old_value(self): + consumer = _build_ready_consumer() + calls = [] + consumer.register_property_callback(lambda n, p, v, o: calls.append((n, p, v, o))) + consumer.handle_message(f"{PREFIX}/core/door", "CLOSED") + consumer.handle_message(f"{PREFIX}/core/door", "OPEN") + assert calls[1] == ("core", "door", "OPEN", "CLOSED") + + def test_unregister_callback(self): + consumer = _build_ready_consumer() + calls = [] + unregister = consumer.register_property_callback(lambda n, p, v, o: calls.append(1)) + consumer.handle_message(f"{PREFIX}/core/door", "OPEN") + unregister() + consumer.handle_message(f"{PREFIX}/core/door", "CLOSED") + assert len(calls) == 1 + + def test_callback_error_doesnt_crash(self): + consumer = _build_ready_consumer() + + def bad_cb(n, p, v, o): + raise ValueError("boom") + + consumer.register_property_callback(bad_cb) + # Should not raise + consumer.handle_message(f"{PREFIX}/core/door", "OPEN") + + +# --------------------------------------------------------------------------- +# SpanMqttClient — protocol compliance +# --------------------------------------------------------------------------- + + +class TestSpanMqttClientProtocol: + def test_implements_panel_protocol(self): + from span_panel_api.mqtt.client import SpanMqttClient + + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + assert isinstance(client, SpanPanelClientProtocol) + + def test_implements_circuit_control(self): + from span_panel_api.mqtt.client import SpanMqttClient + + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + assert isinstance(client, CircuitControlProtocol) + + def test_implements_streaming(self): + from span_panel_api.mqtt.client import SpanMqttClient + + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + assert isinstance(client, StreamingCapableProtocol) + + def test_capabilities(self): + from span_panel_api.mqtt.client import SpanMqttClient + + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + caps = client.capabilities + assert PanelCapability.EBUS_MQTT in caps + assert PanelCapability.PUSH_STREAMING in caps + assert PanelCapability.CIRCUIT_CONTROL in caps + assert PanelCapability.BATTERY_SOE in caps + assert PanelCapability.EBUS_MQTT in caps # MQTT-only transport + + def test_serial_number(self): + from span_panel_api.mqtt.client import SpanMqttClient + + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + assert client.serial_number == SERIAL + + +# --------------------------------------------------------------------------- +# SpanMqttClient — relay and priority control +# --------------------------------------------------------------------------- + + +class TestSpanMqttClientControl: + @pytest.mark.asyncio + async def test_set_circuit_relay_publishes(self): + from span_panel_api.mqtt.client import SpanMqttClient + + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + + mock_bridge = MagicMock() + client._bridge = mock_bridge + + await client.set_circuit_relay("aabbccdd112233445566778899001122", "OPEN") + + mock_bridge.publish.assert_called_once_with( + f"{TOPIC_PREFIX}/{SERIAL}/aabbccdd112233445566778899001122/relay/set", + "OPEN", + qos=1, + ) + + @pytest.mark.asyncio + async def test_set_circuit_priority_publishes(self): + from span_panel_api.mqtt.client import SpanMqttClient + + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + + mock_bridge = MagicMock() + client._bridge = mock_bridge + + await client.set_circuit_priority("aabbccdd112233445566778899001122", "NEVER") + + mock_bridge.publish.assert_called_once_with( + f"{TOPIC_PREFIX}/{SERIAL}/aabbccdd112233445566778899001122/shed-priority/set", + "NEVER", + qos=1, + ) + + +# --------------------------------------------------------------------------- +# SpanMqttClient — snapshot and ping +# --------------------------------------------------------------------------- + + +class TestSpanMqttClientSnapshot: + @pytest.mark.asyncio + async def test_get_snapshot_returns_homie_state(self): + from span_panel_api.mqtt.client import SpanMqttClient + + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + + # Manually ready the homie consumer + client._homie.handle_message(f"{PREFIX}/$state", "ready") + client._homie.handle_message(f"{PREFIX}/$description", _make_description(_core_description())) + client._homie.handle_message(f"{PREFIX}/core/software-version", "test-fw") + + snapshot = await client.get_snapshot() + assert snapshot.serial_number == SERIAL + assert snapshot.firmware_version == "test-fw" + + @pytest.mark.asyncio + async def test_ping_false_no_bridge(self): + from span_panel_api.mqtt.client import SpanMqttClient + + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + assert await client.ping() is False + + @pytest.mark.asyncio + async def test_ping_true_when_connected_and_ready(self): + from span_panel_api.mqtt.client import SpanMqttClient + + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + + mock_bridge = MagicMock() + mock_bridge.is_connected.return_value = True + client._bridge = mock_bridge + + client._homie.handle_message(f"{PREFIX}/$state", "ready") + client._homie.handle_message(f"{PREFIX}/$description", _make_description(_core_description())) + + assert await client.ping() is True + + +# --------------------------------------------------------------------------- +# SpanMqttClient — streaming callbacks +# --------------------------------------------------------------------------- + + +class TestSpanMqttClientStreaming: + @pytest.mark.asyncio + async def test_register_and_unregister_snapshot_callback(self): + from span_panel_api.mqtt.client import SpanMqttClient + + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + + callback = AsyncMock() + unregister = client.register_snapshot_callback(callback) + assert len(client._snapshot_callbacks) == 1 + unregister() + assert len(client._snapshot_callbacks) == 0 + + @pytest.mark.asyncio + async def test_start_stop_streaming(self): + from span_panel_api.mqtt.client import SpanMqttClient + + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + + assert client._streaming is False + await client.start_streaming() + assert client._streaming is True + await client.stop_streaming() + assert client._streaming is False + + +# --------------------------------------------------------------------------- +# AsyncMqttBridge — construction +# --------------------------------------------------------------------------- + + +class TestAsyncMqttBridge: + def test_construction(self): + from span_panel_api.mqtt.connection import AsyncMqttBridge + + bridge = AsyncMqttBridge( + host="broker.local", + port=8883, + username="user", + password="pass", + panel_host="192.168.1.1", + serial_number=SERIAL, + ) + assert bridge.is_connected() is False + + def test_callbacks_default_none(self): + from span_panel_api.mqtt.connection import AsyncMqttBridge + + bridge = AsyncMqttBridge( + host="broker.local", + port=8883, + username="user", + password="pass", + panel_host="192.168.1.1", + serial_number=SERIAL, + ) + assert bridge._message_callback is None + assert bridge._connection_callback is None + + def test_set_message_callback(self): + from span_panel_api.mqtt.connection import AsyncMqttBridge + + bridge = AsyncMqttBridge( + host="broker.local", + port=8883, + username="user", + password="pass", + panel_host="192.168.1.1", + serial_number=SERIAL, + ) + cb = MagicMock() + bridge.set_message_callback(cb) + assert bridge._message_callback is cb + + +# --------------------------------------------------------------------------- +# Package exports and version +# --------------------------------------------------------------------------- + + +class TestPhase3Exports: + def test_mqtt_exports(self): + from span_panel_api.mqtt import AsyncMqttBridge, HomieDeviceConsumer, MqttClientConfig, SpanMqttClient + + assert AsyncMqttBridge is not None + assert HomieDeviceConsumer is not None + assert MqttClientConfig is not None + assert SpanMqttClient is not None + + def test_top_level_exports(self): + import span_panel_api + + assert hasattr(span_panel_api, "SpanMqttClient") + assert hasattr(span_panel_api, "MqttClientConfig") + + def test_version_beta(self): + import span_panel_api + + assert span_panel_api.__version__ == "2.0.0" + + +# --------------------------------------------------------------------------- +# HomieDeviceConsumer — edge cases +# --------------------------------------------------------------------------- + + +class TestHomieEdgeCases: + def test_invalid_description_json(self): + consumer = HomieDeviceConsumer(SERIAL) + consumer.handle_message(f"{PREFIX}/$state", "ready") + consumer.handle_message(f"{PREFIX}/$description", "not-json{{{") + assert not consumer.is_ready() + + def test_empty_property_values(self): + """Circuit with no properties should still build with defaults.""" + consumer = _build_ready_consumer() + snapshot = consumer.build_snapshot() + circuit = snapshot.circuits["aabbccdd112233445566778899001122"] + assert circuit.name == "" + assert circuit.relay_state == "UNKNOWN" + assert circuit.instant_power_w == 0.0 + assert circuit.tabs == [] + + def test_multiple_circuits(self): + nodes = { + "core": {"type": TYPE_CORE}, + "aaaaaaaa-1111-2222-3333-444444444444": {"type": TYPE_CIRCUIT}, + "bbbbbbbb-5555-6666-7777-888888888888": {"type": TYPE_CIRCUIT}, + } + consumer = _build_ready_consumer(nodes) + consumer.handle_message(f"{PREFIX}/aaaaaaaa-1111-2222-3333-444444444444/name", "Circuit A") + consumer.handle_message(f"{PREFIX}/bbbbbbbb-5555-6666-7777-888888888888/name", "Circuit B") + + snapshot = consumer.build_snapshot() + assert len(snapshot.circuits) == 2 + assert snapshot.circuits["aaaaaaaa11112222333344444444444" + "4"].name == "Circuit A" + assert snapshot.circuits["bbbbbbbb55556666777788888888888" + "8"].name == "Circuit B" + + def test_current_and_breaker_none_when_empty(self): + consumer = _build_ready_consumer() + snapshot = consumer.build_snapshot() + circuit = snapshot.circuits["aabbccdd112233445566778899001122"] + assert circuit.current_a is None + assert circuit.breaker_rating_a is None + + +# --------------------------------------------------------------------------- +# HomieDeviceConsumer — unmapped tab synthesis +# --------------------------------------------------------------------------- + + +class TestUnmappedTabSynthesis: + """Tests for _build_unmapped_tabs and dipole tab derivation.""" + + def test_single_pole_tabs(self): + """Single-pole circuit gets tabs = [space].""" + nodes = { + "core": {"type": TYPE_CORE}, + "aaaaaaaa-1111-2222-3333-444444444444": {"type": TYPE_CIRCUIT}, + } + consumer = _build_ready_consumer(nodes) + node = "aaaaaaaa-1111-2222-3333-444444444444" + consumer.handle_message(f"{PREFIX}/{node}/space", "3") + consumer.handle_message(f"{PREFIX}/{node}/dipole", "false") + + snapshot = consumer.build_snapshot() + circuit = snapshot.circuits["aaaaaaaa111122223333444444444444"] + assert circuit.tabs == [3] + + def test_dipole_tabs(self): + """Dipole circuit gets tabs = [space, space + 2] (same bus bar side).""" + nodes = { + "core": {"type": TYPE_CORE}, + "aaaaaaaa-1111-2222-3333-444444444444": {"type": TYPE_CIRCUIT}, + } + consumer = _build_ready_consumer(nodes) + node = "aaaaaaaa-1111-2222-3333-444444444444" + consumer.handle_message(f"{PREFIX}/{node}/space", "11") + consumer.handle_message(f"{PREFIX}/{node}/dipole", "true") + + snapshot = consumer.build_snapshot() + circuit = snapshot.circuits["aaaaaaaa111122223333444444444444"] + assert circuit.tabs == [11, 13] + + def test_dipole_even_side(self): + """Dipole on even bus bar: [30, 32].""" + nodes = { + "core": {"type": TYPE_CORE}, + "aaaaaaaa-1111-2222-3333-444444444444": {"type": TYPE_CIRCUIT}, + } + consumer = _build_ready_consumer(nodes) + node = "aaaaaaaa-1111-2222-3333-444444444444" + consumer.handle_message(f"{PREFIX}/{node}/space", "30") + consumer.handle_message(f"{PREFIX}/{node}/dipole", "true") + + snapshot = consumer.build_snapshot() + circuit = snapshot.circuits["aaaaaaaa111122223333444444444444"] + assert circuit.tabs == [30, 32] + + def test_unmapped_tabs_generated(self): + """Unmapped positions should produce synthetic circuit entries.""" + nodes = { + "core": {"type": TYPE_CORE}, + "aaaaaaaa-1111-2222-3333-444444444444": {"type": TYPE_CIRCUIT}, + "bbbbbbbb-5555-6666-7777-888888888888": {"type": TYPE_CIRCUIT}, + } + consumer = _build_ready_consumer(nodes) + # Circuit A at space 1 (single-pole) + consumer.handle_message(f"{PREFIX}/aaaaaaaa-1111-2222-3333-444444444444/space", "1") + consumer.handle_message(f"{PREFIX}/aaaaaaaa-1111-2222-3333-444444444444/dipole", "false") + # Circuit B at space 3 (dipole → occupies 3 and 5) + consumer.handle_message(f"{PREFIX}/bbbbbbbb-5555-6666-7777-888888888888/space", "3") + consumer.handle_message(f"{PREFIX}/bbbbbbbb-5555-6666-7777-888888888888/dipole", "true") + + snapshot = consumer.build_snapshot() + + # Highest occupied tab is 5 (odd), panel_size rounds to 6 + # Occupied: {1, 3, 5}, unmapped: {2, 4, 6} + assert "unmapped_tab_2" in snapshot.circuits + assert "unmapped_tab_4" in snapshot.circuits + assert "unmapped_tab_6" in snapshot.circuits + # Occupied positions should NOT have unmapped entries + assert "unmapped_tab_1" not in snapshot.circuits + assert "unmapped_tab_3" not in snapshot.circuits + assert "unmapped_tab_5" not in snapshot.circuits + + def test_unmapped_tab_properties(self): + """Unmapped tab entries have zero power/energy and correct attributes.""" + nodes = { + "core": {"type": TYPE_CORE}, + "aaaaaaaa-1111-2222-3333-444444444444": {"type": TYPE_CIRCUIT}, + } + consumer = _build_ready_consumer(nodes) + consumer.handle_message(f"{PREFIX}/aaaaaaaa-1111-2222-3333-444444444444/space", "1") + consumer.handle_message(f"{PREFIX}/aaaaaaaa-1111-2222-3333-444444444444/dipole", "false") + + snapshot = consumer.build_snapshot() + unmapped = snapshot.circuits["unmapped_tab_2"] + + assert unmapped.circuit_id == "unmapped_tab_2" + assert unmapped.name == "Unmapped Tab 2" + assert unmapped.relay_state == "CLOSED" + assert unmapped.instant_power_w == 0.0 + assert unmapped.produced_energy_wh == 0.0 + assert unmapped.consumed_energy_wh == 0.0 + assert unmapped.tabs == [2] + assert unmapped.priority == "UNKNOWN" + assert unmapped.is_user_controllable is False + assert unmapped.is_sheddable is False + assert unmapped.is_never_backup is False + + def test_fully_occupied_panel_no_unmapped(self): + """When all positions are occupied, no unmapped tabs are generated.""" + # Create 4 circuits occupying spaces 1, 2, 3, 4 + nodes = { + "core": {"type": TYPE_CORE}, + "aaaaaaaa-1111-2222-3333-444444444444": {"type": TYPE_CIRCUIT}, + "bbbbbbbb-5555-6666-7777-888888888888": {"type": TYPE_CIRCUIT}, + "cccccccc-1111-2222-3333-444444444444": {"type": TYPE_CIRCUIT}, + "dddddddd-5555-6666-7777-888888888888": {"type": TYPE_CIRCUIT}, + } + consumer = _build_ready_consumer(nodes) + for i, node in enumerate( + [ + "aaaaaaaa-1111-2222-3333-444444444444", + "bbbbbbbb-5555-6666-7777-888888888888", + "cccccccc-1111-2222-3333-444444444444", + "dddddddd-5555-6666-7777-888888888888", + ], + start=1, + ): + consumer.handle_message(f"{PREFIX}/{node}/space", str(i)) + consumer.handle_message(f"{PREFIX}/{node}/dipole", "false") + + snapshot = consumer.build_snapshot() + unmapped_ids = [cid for cid in snapshot.circuits if cid.startswith("unmapped_tab_")] + assert unmapped_ids == [] + + def test_no_circuits_no_unmapped(self): + """When no circuits exist, no unmapped tabs are generated.""" + nodes = {"core": {"type": TYPE_CORE}} + consumer = _build_ready_consumer(nodes) + snapshot = consumer.build_snapshot() + unmapped_ids = [cid for cid in snapshot.circuits if cid.startswith("unmapped_tab_")] + assert unmapped_ids == [] + + def test_no_space_property_no_unmapped(self): + """Circuits without space property don't contribute to tab tracking.""" + nodes = { + "core": {"type": TYPE_CORE}, + "aaaaaaaa-1111-2222-3333-444444444444": {"type": TYPE_CIRCUIT}, + } + consumer = _build_ready_consumer(nodes) + # Don't set space property + snapshot = consumer.build_snapshot() + unmapped_ids = [cid for cid in snapshot.circuits if cid.startswith("unmapped_tab_")] + assert unmapped_ids == [] + + def test_panel_size_rounds_to_even(self): + """Panel size rounds up to even when highest tab is odd.""" + nodes = { + "core": {"type": TYPE_CORE}, + "aaaaaaaa-1111-2222-3333-444444444444": {"type": TYPE_CIRCUIT}, + } + consumer = _build_ready_consumer(nodes) + consumer.handle_message(f"{PREFIX}/aaaaaaaa-1111-2222-3333-444444444444/space", "7") + consumer.handle_message(f"{PREFIX}/aaaaaaaa-1111-2222-3333-444444444444/dipole", "false") + + snapshot = consumer.build_snapshot() + # Highest tab=7 (odd) → panel_size=8 + # Occupied: {7}, unmapped: {1,2,3,4,5,6,8} + unmapped_ids = sorted(cid for cid in snapshot.circuits if cid.startswith("unmapped_tab_")) + assert unmapped_ids == [ + "unmapped_tab_1", + "unmapped_tab_2", + "unmapped_tab_3", + "unmapped_tab_4", + "unmapped_tab_5", + "unmapped_tab_6", + "unmapped_tab_8", + ] + + def test_panel_size_exact_when_even(self): + """Panel size stays as-is when highest tab is even.""" + nodes = { + "core": {"type": TYPE_CORE}, + "aaaaaaaa-1111-2222-3333-444444444444": {"type": TYPE_CIRCUIT}, + } + consumer = _build_ready_consumer(nodes) + consumer.handle_message(f"{PREFIX}/aaaaaaaa-1111-2222-3333-444444444444/space", "6") + consumer.handle_message(f"{PREFIX}/aaaaaaaa-1111-2222-3333-444444444444/dipole", "false") + + snapshot = consumer.build_snapshot() + # Highest tab=6 (even) → panel_size=6 + # Occupied: {6}, unmapped: {1,2,3,4,5} + unmapped_ids = sorted(cid for cid in snapshot.circuits if cid.startswith("unmapped_tab_")) + assert unmapped_ids == [ + "unmapped_tab_1", + "unmapped_tab_2", + "unmapped_tab_3", + "unmapped_tab_4", + "unmapped_tab_5", + ] + + def test_dipole_occupies_correct_tabs_in_unmapped_calc(self): + """Dipole circuits remove both occupied tabs from unmapped set.""" + nodes = { + "core": {"type": TYPE_CORE}, + "aaaaaaaa-1111-2222-3333-444444444444": {"type": TYPE_CIRCUIT}, + } + consumer = _build_ready_consumer(nodes) + # Dipole at space 1 → occupies 1 and 3 + consumer.handle_message(f"{PREFIX}/aaaaaaaa-1111-2222-3333-444444444444/space", "1") + consumer.handle_message(f"{PREFIX}/aaaaaaaa-1111-2222-3333-444444444444/dipole", "true") + + snapshot = consumer.build_snapshot() + # Highest tab = 3 (odd) → panel_size = 4 + # Occupied: {1, 3}, unmapped: {2, 4} + assert "unmapped_tab_1" not in snapshot.circuits + assert "unmapped_tab_2" in snapshot.circuits + assert "unmapped_tab_3" not in snapshot.circuits + assert "unmapped_tab_4" in snapshot.circuits diff --git a/tests/test_panel_circuit_alignment.py b/tests/test_panel_circuit_alignment.py deleted file mode 100644 index c59f017..0000000 --- a/tests/test_panel_circuit_alignment.py +++ /dev/null @@ -1,295 +0,0 @@ -"""Tests for panel-level and circuit-level data alignment.""" - -import pytest -from src.span_panel_api.client import SpanPanelClient - - -class TestPanelCircuitAlignment: - """Tests to verify panel-level data aligns with circuit-level aggregation. - - NOTE: These tests currently demonstrate issues in the simulation where - panel and circuit data are not perfectly aligned due to separate random - data generation calls. This should be fixed in the future. - """ - - async def test_panel_circuit_ideal_alignment_specification(self): - """Test that panel-level data aligns perfectly with circuit-level aggregation. - - This test verifies that: - 1. Panel grid power exactly equals sum of circuit powers - 2. Panel energy totals exactly equal sum of circuit energies - 3. All data comes from a single generation call ensuring consistency - 4. Timestamps are identical across panel and circuit data - """ - client = SpanPanelClient( - host="test-panel-alignment", simulation_mode=True, simulation_config_path="examples/simple_test_config.yaml" - ) - - async with client: - # Get both panel state and circuit data - panel_state = await client.get_panel_state() - circuits = await client.get_circuits() - - # Calculate consumption and production separately (only count real circuits, not unmapped tabs) - total_consumption = 0.0 - total_production = 0.0 - total_produced_energy = 0.0 - total_consumed_energy = 0.0 - - for circuit_id, circuit in circuits.circuits.additional_properties.items(): - # Skip virtual circuits for unmapped tabs - if not circuit_id.startswith("unmapped_tab_"): - # All circuits now show positive power values - # Determine if consuming or producing based on circuit type - circuit_name = circuit.name.lower() if circuit.name else "" - if any(keyword in circuit_name for keyword in ["solar", "inverter", "generator", "battery"]): - # Producer circuits (solar, battery when discharging) - total_production += circuit.instant_power_w - else: - # Consumer circuits - total_consumption += circuit.instant_power_w - - total_produced_energy += circuit.produced_energy_wh - total_consumed_energy += circuit.consumed_energy_wh - - # Panel grid power should be consumption - production - expected_grid_power = total_consumption - total_production - # Use tolerance for floating-point comparison - tolerance = 1e-10 - assert abs(panel_state.instant_grid_power_w - expected_grid_power) <= tolerance, ( - f"Panel power ({panel_state.instant_grid_power_w}W) should match " - f"expected grid power ({expected_grid_power}W) = consumption ({total_consumption}W) - production ({total_production}W)" - ) - - # Energy should match with tolerance - assert abs(panel_state.main_meter_energy.produced_energy_wh - total_produced_energy) <= tolerance, ( - f"Panel produced energy ({panel_state.main_meter_energy.produced_energy_wh}Wh) should match " - f"circuit total ({total_produced_energy}Wh)" - ) - - assert abs(panel_state.main_meter_energy.consumed_energy_wh - total_consumed_energy) <= tolerance, ( - f"Panel consumed energy ({panel_state.main_meter_energy.consumed_energy_wh}Wh) should match " - f"circuit total ({total_consumed_energy}Wh)" - ) - - async def test_panel_power_alignment_works_correctly(self): - """Test that panel-circuit power alignment now works correctly.""" - client = SpanPanelClient( - host="test-panel-alignment", simulation_mode=True, simulation_config_path="examples/simple_test_config.yaml" - ) - - async with client: - # Get both panel state and circuit data - panel_state = await client.get_panel_state() - circuits = await client.get_circuits() - - # Calculate consumption and production separately (exclude virtual unmapped tab circuits) - total_consumption = 0.0 - total_production = 0.0 - for circuit_id in circuits.circuits.additional_keys: - if not circuit_id.startswith("unmapped_tab_"): - circuit = circuits.circuits[circuit_id] - # All circuits now show positive power values - # Determine if consuming or producing based on circuit type - circuit_name = circuit.name.lower() if circuit.name else "" - if any(keyword in circuit_name for keyword in ["solar", "inverter", "generator", "battery"]): - # Producer circuits (solar, battery when discharging) - total_production += circuit.instant_power_w - else: - # Consumer circuits - total_consumption += circuit.instant_power_w - - # Panel grid power should be consumption - production - expected_grid_power = total_consumption - total_production - panel_grid_power = panel_state.instant_grid_power_w - power_difference = abs(panel_grid_power - expected_grid_power) - - print(f"Panel power: {panel_grid_power}W, Expected: {expected_grid_power}W, Diff: {power_difference}W") - - # Power should now align exactly - assert power_difference == 0.0, ( - f"Panel grid power ({panel_grid_power}W) should exactly match " - f"expected grid power ({expected_grid_power}W) = consumption ({total_consumption}W) - production ({total_production}W). " - f"Difference: {power_difference}W" - ) - - async def test_panel_energy_alignment_works_correctly(self): - """Test that panel-circuit energy alignment now works correctly.""" - client = SpanPanelClient( - host="test-panel-energy-alignment", - simulation_mode=True, - simulation_config_path="examples/simple_test_config.yaml", - ) - - async with client: - # Get both panel state and circuit data - panel_state = await client.get_panel_state() - circuits = await client.get_circuits() - - # Calculate total circuit energy (exclude virtual unmapped tab circuits) - total_produced_energy = 0.0 - total_consumed_energy = 0.0 - - for circuit_id in circuits.circuits.additional_keys: - if not circuit_id.startswith("unmapped_tab_"): - circuit = circuits.circuits[circuit_id] - total_produced_energy += circuit.produced_energy_wh - total_consumed_energy += circuit.consumed_energy_wh - - # Panel energy should now match circuit totals exactly - panel_produced = panel_state.main_meter_energy.produced_energy_wh - panel_consumed = panel_state.main_meter_energy.consumed_energy_wh - - print(f"Panel produced: {panel_produced}Wh, Circuit total: {total_produced_energy}Wh") - print(f"Panel consumed: {panel_consumed}Wh, Circuit total: {total_consumed_energy}Wh") - - # Energy should now align exactly - assert ( - panel_produced == total_produced_energy - ), f"Panel produced energy ({panel_produced}Wh) should exactly match circuit total ({total_produced_energy}Wh)" - assert ( - panel_consumed == total_consumed_energy - ), f"Panel consumed energy ({panel_consumed}Wh) should exactly match circuit total ({total_consumed_energy}Wh)" - - async def test_panel_circuit_consistency_across_calls(self): - """Test that panel and circuit data remain consistent across multiple calls.""" - client = SpanPanelClient( - host="test-panel-consistency", - simulation_mode=True, - simulation_config_path="examples/simple_test_config.yaml", - # Disable caching to get fresh data - ) - - async with client: - # Make multiple calls and verify consistency - for i in range(3): - panel_state = await client.get_panel_state() - circuits = await client.get_circuits() - - # Calculate consumption and production separately - total_consumption = 0.0 - total_production = 0.0 - for cid in circuits.circuits.additional_keys: - circuit = circuits.circuits[cid] - # All circuits now show positive power values - # Determine if consuming or producing based on circuit type - circuit_name = circuit.name.lower() if circuit.name else "" - if any(keyword in circuit_name for keyword in ["solar", "inverter", "generator", "battery"]): - # Producer circuits (solar, battery when discharging) - total_production += circuit.instant_power_w - else: - # Consumer circuits - total_consumption += circuit.instant_power_w - - total_produced_energy = sum( - circuits.circuits[cid].produced_energy_wh for cid in circuits.circuits.additional_keys - ) - total_consumed_energy = sum( - circuits.circuits[cid].consumed_energy_wh for cid in circuits.circuits.additional_keys - ) - - # Panel grid power should be consumption - production - expected_grid_power = total_consumption - total_production - power_difference = abs(panel_state.instant_grid_power_w - expected_grid_power) - assert power_difference <= 2000.0, f"Call {i + 1}: Power misalignment too large ({power_difference}W)" - - # Document that energy alignment is currently not exact due to separate data generation - # For now, just verify data is reasonable - assert panel_state.main_meter_energy.produced_energy_wh >= 0, f"Call {i + 1}: Invalid produced energy" - assert panel_state.main_meter_energy.consumed_energy_wh >= 0, f"Call {i + 1}: Invalid consumed energy" - assert total_produced_energy >= 0, f"Call {i + 1}: Invalid circuit produced energy" - assert total_consumed_energy >= 0, f"Call {i + 1}: Invalid circuit consumed energy" - - async def test_panel_circuit_alignment_with_mixed_behaviors(self): - """Test alignment with mixed circuit behaviors (producing and consuming).""" - client = SpanPanelClient( - host="test-mixed-behaviors", simulation_mode=True, simulation_config_path="examples/behavior_test_config.yaml" - ) - - async with client: - panel_state = await client.get_panel_state() - circuits = await client.get_circuits() - - # Categorize circuits by power behavior (all circuits now show positive power) - total_consumption = 0.0 - total_production = 0.0 - - for circuit_id in circuits.circuits.additional_keys: - circuit = circuits.circuits[circuit_id] - # All circuits now show positive power values - # Determine if consuming or producing based on circuit type - circuit_name = circuit.name.lower() if circuit.name else "" - if any(keyword in circuit_name for keyword in ["solar", "inverter", "generator", "battery"]): - # Producer circuits (solar, battery when discharging) - total_production += circuit.instant_power_w - else: - # Consumer circuits - total_consumption += circuit.instant_power_w - - # Panel grid power should be consumption - production - expected_grid_power = total_consumption - total_production - - # Panel should reflect this expected grid power (with current larger variation due to separate data generation) - power_difference = abs(panel_state.instant_grid_power_w - expected_grid_power) - assert power_difference <= 2000.0, ( - f"Panel power ({panel_state.instant_grid_power_w}W) doesn't align with " - f"expected grid power ({expected_grid_power}W) = consumption ({total_consumption}W) - production ({total_production}W). Difference: {power_difference}W" - ) - - async def test_panel_circuit_alignment_data_integrity(self): - """Test that panel and circuit data maintain referential integrity.""" - client = SpanPanelClient( - host="test-data-integrity", simulation_mode=True, simulation_config_path="examples/simple_test_config.yaml" - ) - - async with client: - panel_state = await client.get_panel_state() - circuits = await client.get_circuits() - - # Verify basic data integrity - assert hasattr(panel_state, "dsm_state"), "Panel should have dsm_state" - assert len(circuits.circuits.additional_keys) > 0, "Should have circuits configured" - - # Verify timestamp consistency (should be close) - panel_time = panel_state.grid_sample_end_ms - for circuit_id in circuits.circuits.additional_keys: - circuit = circuits.circuits[circuit_id] - circuit_time = circuit.instant_power_update_time_s * 1000 # Convert to ms - time_diff = abs(panel_time - circuit_time) - # Allow up to 5 seconds difference - assert time_diff <= 5000, f"Timestamp mismatch: panel={panel_time}, circuit={circuit_time}" - - # Verify energy accumulation timestamps - for circuit_id in circuits.circuits.additional_keys: - circuit = circuits.circuits[circuit_id] - power_time = circuit.instant_power_update_time_s - energy_time = circuit.energy_accum_update_time_s - # Energy and power should have same or very close timestamps - assert abs(power_time - energy_time) <= 1, "Power and energy timestamps should be synchronized" - - async def test_panel_reflects_circuit_configuration_changes(self): - """Test that panel data correctly reflects the circuit configuration.""" - client = SpanPanelClient( - host="test-config-reflection", simulation_mode=True, simulation_config_path="examples/simple_test_config.yaml" - ) - - async with client: - panel_state = await client.get_panel_state() - circuits = await client.get_circuits() - - # Count circuits by type to verify configuration is reflected - circuit_count = len(circuits.circuits.additional_keys) - assert circuit_count > 0, "Should have configured circuits" - - # Verify panel state reflects having active circuits - # If we have circuits with power, panel should show grid activity - has_active_circuits = any( - abs(circuits.circuits[cid].instant_power_w) > 0 for cid in circuits.circuits.additional_keys - ) - if has_active_circuits: - # Panel should show some grid power (within variation range) - assert abs(panel_state.instant_grid_power_w) <= 50000, "Panel power should be reasonable" - - # Verify main relay state consistency - # If circuits are active, main relay should be CLOSED - assert panel_state.main_relay_state == "CLOSED", "Main relay should be closed for active circuits" diff --git a/tests/test_example_phase_validation.py b/tests/test_phase_validation_configs.py similarity index 99% rename from tests/test_example_phase_validation.py rename to tests/test_phase_validation_configs.py index 4c4a14b..77498e4 100644 --- a/tests/test_example_phase_validation.py +++ b/tests/test_phase_validation_configs.py @@ -24,7 +24,7 @@ class TestExamplePhaseValidation: @pytest.fixture def example_configs(self) -> Dict[str, Dict[str, Any]]: """Load all example YAML configurations.""" - examples_dir = Path(__file__).parent.parent / "examples" + examples_dir = Path(__file__).parent / "fixtures" / "configs" configs = {} # Load all YAML files in examples directory diff --git a/tests/test_phase_validation_error_paths.py b/tests/test_phase_validation_errors.py similarity index 68% rename from tests/test_phase_validation_error_paths.py rename to tests/test_phase_validation_errors.py index 817b8f9..748b7dd 100644 --- a/tests/test_phase_validation_error_paths.py +++ b/tests/test_phase_validation_errors.py @@ -7,8 +7,6 @@ validate_solar_tabs, get_phase_distribution, suggest_balanced_pairing, - get_valid_tabs_from_panel_data, - get_valid_tabs_from_branches, ) @@ -162,104 +160,6 @@ def test_suggest_balanced_pairing_single_phase_only(self): pairs = suggest_balanced_pairing(l2_only_tabs) assert pairs == [] # No L1 tabs, so no pairs possible - def test_get_valid_tabs_from_panel_data_missing_branches(self): - """Test get_valid_tabs_from_panel_data with missing branches.""" - # Panel data without branches - panel_data = {"panel": {"serial_number": "test123"}} - - tabs = get_valid_tabs_from_panel_data(panel_data) - assert tabs == [] - - def test_get_valid_tabs_from_panel_data_empty_branches(self): - """Test get_valid_tabs_from_panel_data with empty branches.""" - panel_data = {"branches": []} # Empty list instead of dict - - tabs = get_valid_tabs_from_panel_data(panel_data) - assert tabs == [] - - def test_get_valid_tabs_from_panel_data_branches_not_list(self): - """Test get_valid_tabs_from_panel_data with branches as dict instead of list.""" - panel_data = {"branches": {}} # Dict instead of list - - with pytest.raises(TypeError, match="Invalid branches data in panel state"): - get_valid_tabs_from_panel_data(panel_data) - - def test_get_valid_tabs_from_branches_empty_input(self): - """Test get_valid_tabs_from_branches with empty branches.""" - tabs = get_valid_tabs_from_branches([]) - assert tabs == [] - - def test_get_valid_tabs_from_panel_data_invalid_type(self): - """Test get_valid_tabs_from_panel_data with invalid panel_state type.""" - # Test line 36: TypeError for invalid panel_state type - with pytest.raises(TypeError, match="panel_state must be a dictionary"): - get_valid_tabs_from_panel_data("not_a_dict") - - with pytest.raises(TypeError, match="panel_state must be a dictionary"): - get_valid_tabs_from_panel_data(None) - - with pytest.raises(TypeError, match="panel_state must be a dictionary"): - get_valid_tabs_from_panel_data([]) - - def test_get_valid_tabs_from_panel_data_branch_validation(self): - """Test branch validation logic in get_valid_tabs_from_panel_data.""" - # Test lines 44-47: Branch validation logic - panel_data = { - "branches": [ - {"id": 1}, # Valid - {"name": "no_id"}, # Missing id - {"id": "not_int"}, # Non-integer id - {"id": 0}, # Zero id - {"id": -1}, # Negative id - "not_dict", # Not a dict - {"id": 5}, # Valid - ] - } - - tabs = get_valid_tabs_from_panel_data(panel_data) - assert tabs == [1, 5] # Only valid tabs should be included - - def test_get_valid_tabs_from_branches_object_handling(self): - """Test get_valid_tabs_from_branches with Branch objects.""" - # Test lines 65-73: Branch object handling with hasattr - - # Mock Branch object - class MockBranch: - def __init__(self, tab_id): - self.id = tab_id - - # Test with Branch objects - branches = [ - MockBranch(1), # Valid object - MockBranch("not_int"), # Invalid object - MockBranch(0), # Invalid object - MockBranch(-1), # Invalid object - MockBranch(5), # Valid object - ] - - tabs = get_valid_tabs_from_branches(branches) - assert tabs == [1, 5] # Only valid tabs should be included - - def test_get_valid_tabs_from_branches_mixed_types(self): - """Test get_valid_tabs_from_branches with mixed object and dict types.""" - - # Test mixed Branch objects and dictionaries - class MockBranch: - def __init__(self, tab_id): - self.id = tab_id - - branches = [ - MockBranch(1), # Valid object - {"id": 2}, # Valid dict - MockBranch("not_int"), # Invalid object - {"name": "no_id"}, # Invalid dict - MockBranch(3), # Valid object - {"id": 4}, # Valid dict - ] - - tabs = get_valid_tabs_from_branches(branches) - assert tabs == [1, 2, 3, 4] # All valid tabs should be included - def test_validate_solar_tabs_value_error_handling(self): """Test validate_solar_tabs ValueError exception handling.""" # Test line 132: ValueError exception in validate_solar_tabs diff --git a/tests/test_protocol_conformance.py b/tests/test_protocol_conformance.py new file mode 100644 index 0000000..b6b72e6 --- /dev/null +++ b/tests/test_protocol_conformance.py @@ -0,0 +1,43 @@ +"""Protocol conformance tests. + +Verifies that concrete transport classes satisfy the structural protocols +defined in span_panel_api.protocol. Uses runtime_checkable isinstance() +checks against minimal instances to validate method/property presence. + +Note: SpanPanelClientProtocol has property members, so issubclass() cannot +be used (Python limitation). We construct minimal instances and use +isinstance() instead. +""" + +from span_panel_api.mqtt.client import SpanMqttClient +from span_panel_api.mqtt.models import MqttClientConfig +from span_panel_api.protocol import ( + CircuitControlProtocol, + SpanPanelClientProtocol, + StreamingCapableProtocol, +) + + +def _make_mqtt_client() -> SpanMqttClient: + """Build a minimal SpanMqttClient for protocol checks (no I/O).""" + config = MqttClientConfig( + broker_host="127.0.0.1", + username="test", + password="test", + ) + return SpanMqttClient("127.0.0.1", "test-serial", config) + + +class TestMqttProtocolConformance: + def test_satisfies_panel_client_protocol(self) -> None: + client = _make_mqtt_client() + if not isinstance(client, SpanPanelClientProtocol): + raise TypeError("SpanMqttClient does not satisfy SpanPanelClientProtocol") + + def test_satisfies_circuit_control_protocol(self) -> None: + if not issubclass(SpanMqttClient, CircuitControlProtocol): + raise TypeError("SpanMqttClient does not satisfy CircuitControlProtocol") + + def test_satisfies_streaming_protocol(self) -> None: + if not issubclass(SpanMqttClient, StreamingCapableProtocol): + raise TypeError("SpanMqttClient does not satisfy StreamingCapableProtocol") diff --git a/tests/test_protocol_models.py b/tests/test_protocol_models.py new file mode 100644 index 0000000..50c6511 --- /dev/null +++ b/tests/test_protocol_models.py @@ -0,0 +1,345 @@ +"""Tests for protocol interfaces and snapshot models.""" + +import dataclasses + +import pytest + +from span_panel_api.models import ( + SpanBatterySnapshot, + SpanCircuitSnapshot, + SpanPanelSnapshot, +) +from span_panel_api.protocol import ( + CircuitControlProtocol, + PanelCapability, + SpanPanelClientProtocol, + StreamingCapableProtocol, +) + + +# --------------------------------------------------------------------------- +# Helpers: minimal stubs that satisfy each protocol structurally +# --------------------------------------------------------------------------- + + +class _MinimalClient: + """Satisfies SpanPanelClientProtocol structurally.""" + + @property + def capabilities(self) -> PanelCapability: + return PanelCapability.NONE + + @property + def serial_number(self) -> str: + return "test-serial" + + async def connect(self) -> None: + pass + + async def close(self) -> None: + pass + + async def ping(self) -> bool: + return True + + async def get_snapshot(self) -> SpanPanelSnapshot: + return _make_panel_snapshot() + + +class _MinimalCircuitControl: + """Satisfies CircuitControlProtocol structurally.""" + + async def set_circuit_relay(self, circuit_id: str, state: str) -> None: + pass + + async def set_circuit_priority(self, circuit_id: str, priority: str) -> None: + pass + + +class _MinimalStreaming: + """Satisfies StreamingCapableProtocol structurally.""" + + def register_snapshot_callback(self, callback): + def unregister(): + pass + + return unregister + + async def start_streaming(self) -> None: + pass + + async def stop_streaming(self) -> None: + pass + + +class _NotAClient: + """Does NOT satisfy SpanPanelClientProtocol.""" + + pass + + +# --------------------------------------------------------------------------- +# Helpers: snapshot factory functions +# --------------------------------------------------------------------------- + + +def _make_circuit_snapshot(**overrides) -> SpanCircuitSnapshot: + defaults = { + "circuit_id": "abc123", + "name": "Kitchen", + "relay_state": "CLOSED", + "instant_power_w": 150.0, + "produced_energy_wh": 0.0, + "consumed_energy_wh": 500.0, + "tabs": [1], + "priority": "MUST_HAVE", + "is_user_controllable": True, + "is_sheddable": False, + "is_never_backup": False, + } + defaults.update(overrides) + return SpanCircuitSnapshot(**defaults) + + +def _make_panel_snapshot(**overrides) -> SpanPanelSnapshot: + defaults = { + "serial_number": "nj-2316-XXXX", + "firmware_version": "spanos2/r202603/05", + "main_relay_state": "CLOSED", + "instant_grid_power_w": 3500.0, + "feedthrough_power_w": 0.0, + "main_meter_energy_consumed_wh": 10000.0, + "main_meter_energy_produced_wh": 500.0, + "feedthrough_energy_consumed_wh": 0.0, + "feedthrough_energy_produced_wh": 0.0, + "dsm_grid_state": "DSM_ON_GRID", + "current_run_config": "PANEL_ON_GRID", + "door_state": "CLOSED", + "proximity_proven": True, + "uptime_s": 86400, + "eth0_link": True, + "wlan_link": True, + "wwan_link": False, + } + defaults.update(overrides) + return SpanPanelSnapshot(**defaults) + + +# =================================================================== +# Protocol structural conformance tests +# =================================================================== + + +class TestProtocolConformance: + """Verify runtime_checkable isinstance works for protocol stubs.""" + + def test_minimal_client_satisfies_protocol(self): + client = _MinimalClient() + assert isinstance(client, SpanPanelClientProtocol) + + def test_minimal_circuit_control_satisfies_protocol(self): + ctrl = _MinimalCircuitControl() + assert isinstance(ctrl, CircuitControlProtocol) + + def test_minimal_streaming_satisfies_protocol(self): + streamer = _MinimalStreaming() + assert isinstance(streamer, StreamingCapableProtocol) + + def test_non_client_does_not_satisfy_protocol(self): + obj = _NotAClient() + assert not isinstance(obj, SpanPanelClientProtocol) + + +# =================================================================== +# PanelCapability flag tests +# =================================================================== + + +class TestPanelCapability: + def test_none_is_zero(self): + assert PanelCapability.NONE.value == 0 + + def test_flags_are_distinct(self): + all_flags = [ + PanelCapability.PUSH_STREAMING, + PanelCapability.EBUS_MQTT, + PanelCapability.CIRCUIT_CONTROL, + PanelCapability.BATTERY_SOE, + ] + values = [f.value for f in all_flags] + assert len(values) == len(set(values)) + + def test_flag_combination(self): + combined = PanelCapability.EBUS_MQTT | PanelCapability.CIRCUIT_CONTROL + assert PanelCapability.EBUS_MQTT in combined + assert PanelCapability.CIRCUIT_CONTROL in combined + assert PanelCapability.PUSH_STREAMING not in combined + + def test_mqtt_client_capabilities(self): + caps = ( + PanelCapability.EBUS_MQTT + | PanelCapability.PUSH_STREAMING + | PanelCapability.CIRCUIT_CONTROL + | PanelCapability.BATTERY_SOE + ) + assert PanelCapability.EBUS_MQTT in caps + + +# =================================================================== +# SpanCircuitSnapshot tests +# =================================================================== + + +class TestSpanCircuitSnapshot: + def test_construction_required_fields(self): + circuit = _make_circuit_snapshot() + assert circuit.circuit_id == "abc123" + assert circuit.name == "Kitchen" + assert circuit.relay_state == "CLOSED" + assert circuit.instant_power_w == 150.0 + + def test_default_values(self): + circuit = _make_circuit_snapshot() + assert circuit.is_240v is False + assert circuit.current_a is None + assert circuit.breaker_rating_a is None + assert circuit.always_on is False + assert circuit.relay_requester == "UNKNOWN" + assert circuit.energy_accum_update_time_s == 0 + assert circuit.instant_power_update_time_s == 0 + + def test_frozen_rejects_mutation(self): + circuit = _make_circuit_snapshot() + with pytest.raises(dataclasses.FrozenInstanceError): + circuit.name = "Modified" # type: ignore[misc] + + def test_equality(self): + a = _make_circuit_snapshot() + b = _make_circuit_snapshot() + assert a == b + + def test_inequality_on_field_change(self): + a = _make_circuit_snapshot() + b = _make_circuit_snapshot(name="Garage") + assert a != b + + def test_v2_fields(self): + circuit = _make_circuit_snapshot( + always_on=True, + relay_requester="USER", + current_a=12.5, + breaker_rating_a=20.0, + ) + assert circuit.always_on is True + assert circuit.relay_requester == "USER" + assert circuit.current_a == 12.5 + assert circuit.breaker_rating_a == 20.0 + + def test_slots(self): + assert hasattr(SpanCircuitSnapshot, "__slots__") + + +# =================================================================== +# SpanBatterySnapshot tests +# =================================================================== + + +class TestSpanBatterySnapshot: + def test_default_construction(self): + battery = SpanBatterySnapshot() + assert battery.soe_percentage is None + assert battery.soe_kwh is None + + def test_v1_field(self): + battery = SpanBatterySnapshot(soe_percentage=85.0) + assert battery.soe_percentage == 85.0 + assert battery.soe_kwh is None + + def test_v2_field(self): + battery = SpanBatterySnapshot(soe_percentage=85.0, soe_kwh=10.2) + assert battery.soe_kwh == 10.2 + + def test_frozen_rejects_mutation(self): + battery = SpanBatterySnapshot(soe_percentage=50.0) + with pytest.raises(dataclasses.FrozenInstanceError): + battery.soe_percentage = 60.0 # type: ignore[misc] + + +# =================================================================== +# SpanPanelSnapshot tests +# =================================================================== + + +class TestSpanPanelSnapshot: + def test_construction_required_fields(self): + snapshot = _make_panel_snapshot() + assert snapshot.serial_number == "nj-2316-XXXX" + assert snapshot.firmware_version == "spanos2/r202603/05" + assert snapshot.main_relay_state == "CLOSED" + + def test_default_optional_fields(self): + snapshot = _make_panel_snapshot() + assert snapshot.dominant_power_source is None + assert snapshot.grid_state is None + assert snapshot.grid_islandable is None + assert snapshot.l1_voltage is None + assert snapshot.l2_voltage is None + assert snapshot.main_breaker_rating_a is None + assert snapshot.wifi_ssid is None + assert snapshot.vendor_cloud is None + + def test_default_collections(self): + snapshot = _make_panel_snapshot() + assert snapshot.circuits == {} + assert snapshot.battery == SpanBatterySnapshot() + + def test_frozen_rejects_mutation(self): + snapshot = _make_panel_snapshot() + with pytest.raises(dataclasses.FrozenInstanceError): + snapshot.serial_number = "changed" # type: ignore[misc] + + def test_with_circuits(self): + circuit = _make_circuit_snapshot() + snapshot = _make_panel_snapshot(circuits={"abc123": circuit}) + assert len(snapshot.circuits) == 1 + assert snapshot.circuits["abc123"].name == "Kitchen" + + def test_with_battery(self): + battery = SpanBatterySnapshot(soe_percentage=75.0, soe_kwh=9.6) + snapshot = _make_panel_snapshot(battery=battery) + assert snapshot.battery.soe_percentage == 75.0 + assert snapshot.battery.soe_kwh == 9.6 + + def test_v2_native_fields(self): + snapshot = _make_panel_snapshot( + dominant_power_source="GRID", + grid_state="ON_GRID", + grid_islandable=True, + l1_voltage=121.5, + l2_voltage=120.8, + main_breaker_rating_a=200, + wifi_ssid="HomeNetwork", + vendor_cloud="CONNECTED", + ) + assert snapshot.dominant_power_source == "GRID" + assert snapshot.grid_state == "ON_GRID" + assert snapshot.grid_islandable is True + assert snapshot.l1_voltage == 121.5 + assert snapshot.l2_voltage == 120.8 + assert snapshot.main_breaker_rating_a == 200 + assert snapshot.wifi_ssid == "HomeNetwork" + assert snapshot.vendor_cloud == "CONNECTED" + + def test_equality(self): + a = _make_panel_snapshot() + b = _make_panel_snapshot() + assert a == b + + def test_slots(self): + assert hasattr(SpanPanelSnapshot, "__slots__") + + def test_collection_defaults_are_independent(self): + """Each snapshot gets its own default collections (no shared mutable default).""" + a = _make_panel_snapshot() + b = _make_panel_snapshot() + assert a.circuits is not b.circuits diff --git a/tests/test_relay_behavior_demo.py b/tests/test_relay_behavior_demo.py deleted file mode 100644 index e6f0177..0000000 --- a/tests/test_relay_behavior_demo.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Test to demonstrate relay state behavior in simulation mode.""" - -import asyncio -from span_panel_api.client import SpanPanelClient - - -async def test_relay_behavior_demonstration(): - """Demonstrate how circuit relay states affect panel power and energy. - - This test shows whether: - 1. Opening a circuit relay sets its power to 0 - 2. Panel grid power reflects the change - 3. Energy accumulation stops when relay is open - 4. Closing the relay restores normal behavior - """ - - client = SpanPanelClient( - host="relay-test-demo", - simulation_mode=True, - simulation_config_path="examples/simple_test_config.yaml", - # Disable caching for real-time data - ) - - async with client: - # Ensure clean state - await client.clear_circuit_overrides() - - # Step 1: Get baseline data (all circuits closed) - print("=== BASELINE: All circuits closed ===") - circuits_baseline = await client.get_circuits() - panel_baseline = await client.get_panel_state() - - # Debug: Print all available circuit IDs - print(f"Available circuit IDs: {list(circuits_baseline.circuits.additional_keys)}") - - # Find a circuit with significant power for testing - active_circuit_id = None - for circuit_id in circuits_baseline.circuits.additional_keys: - circuit = circuits_baseline.circuits[circuit_id] - if abs(circuit.instant_power_w) > 10: # Find circuit with meaningful power - active_circuit_id = circuit_id - break - - if not active_circuit_id: - print("No circuits with significant power found") - return - - baseline_circuit = circuits_baseline.circuits[active_circuit_id] - print(f"Target circuit: {baseline_circuit.name}") - print(f" ID: {active_circuit_id}") - print(f" Relay state: {baseline_circuit.relay_state}") - print(f" Power: {baseline_circuit.instant_power_w:.1f}W") - print(f" Energy consumed: {baseline_circuit.consumed_energy_wh:.1f}Wh") - print(f" Energy produced: {baseline_circuit.produced_energy_wh:.1f}Wh") - - baseline_total_power = sum( - circuits_baseline.circuits[cid].instant_power_w for cid in circuits_baseline.circuits.additional_keys - ) - print(f"Total circuit power: {baseline_total_power:.1f}W") - print(f"Panel grid power: {panel_baseline.instant_grid_power_w:.1f}W") - print(f"Power difference: {abs(panel_baseline.instant_grid_power_w - baseline_total_power):.1f}W") - - # Step 2: Open the circuit relay (turn off the AC) - print(f"\n=== OPENING RELAY: {baseline_circuit.name} ===") - print(f"Setting relay_state OPEN for circuit ID: {active_circuit_id}") - await client.set_circuit_overrides({active_circuit_id: {"relay_state": "OPEN"}}) - circuits_open = await client.get_circuits() - panel_open = await client.get_panel_state() - - # Check if the circuit still exists in the response - if active_circuit_id not in circuits_open.circuits.additional_keys: - print(f"ERROR: Circuit {active_circuit_id} not found in response after override!") - print(f"Available circuits: {list(circuits_open.circuits.additional_keys)}") - return - - open_circuit = circuits_open.circuits[active_circuit_id] - print(f" Relay state: {open_circuit.relay_state}") - print(f" Power: {open_circuit.instant_power_w:.1f}W") - print(f" Energy consumed: {open_circuit.consumed_energy_wh:.1f}Wh") - print(f" Energy produced: {open_circuit.produced_energy_wh:.1f}Wh") - - open_total_power = sum(circuits_open.circuits[cid].instant_power_w for cid in circuits_open.circuits.additional_keys) - print(f"Total circuit power: {open_total_power:.1f}W") - print(f"Panel grid power: {panel_open.instant_grid_power_w:.1f}W") - print(f"Power difference: {abs(panel_open.instant_grid_power_w - open_total_power):.1f}W") - - # Calculate changes - circuit_power_change = open_circuit.instant_power_w - baseline_circuit.instant_power_w - total_power_change = open_total_power - baseline_total_power - panel_power_change = panel_open.instant_grid_power_w - panel_baseline.instant_grid_power_w - - print(f"\n=== CHANGES WHEN RELAY OPENED ===") - print(f"Circuit power change: {circuit_power_change:.1f}W") - print(f"Total circuit power change: {total_power_change:.1f}W") - print(f"Panel grid power change: {panel_power_change:.1f}W") - - # Step 3: Wait a moment and check energy accumulation - print(f"\n=== WAITING 2 SECONDS TO CHECK ENERGY ACCUMULATION ===") - await asyncio.sleep(2) - - circuits_after_wait = await client.get_circuits() - panel_after_wait = await client.get_panel_state() - - wait_circuit = circuits_after_wait.circuits[active_circuit_id] - energy_consumed_change = wait_circuit.consumed_energy_wh - open_circuit.consumed_energy_wh - energy_produced_change = wait_circuit.produced_energy_wh - open_circuit.produced_energy_wh - - print(f"Energy consumed change: {energy_consumed_change:.3f}Wh") - print(f"Energy produced change: {energy_produced_change:.3f}Wh") - print(f"Circuit power during wait: {wait_circuit.instant_power_w:.1f}W") - - # Step 4: Close the relay (turn AC back on) - print(f"\n=== CLOSING RELAY: {baseline_circuit.name} ===") - await client.set_circuit_overrides({active_circuit_id: {"relay_state": "CLOSED"}}) - circuits_closed = await client.get_circuits() - panel_closed = await client.get_panel_state() - - closed_circuit = circuits_closed.circuits[active_circuit_id] - print(f" Relay state: {closed_circuit.relay_state}") - print(f" Power: {closed_circuit.instant_power_w:.1f}W") - print(f" Energy consumed: {closed_circuit.consumed_energy_wh:.1f}Wh") - print(f" Energy produced: {closed_circuit.produced_energy_wh:.1f}Wh") - - closed_total_power = sum( - circuits_closed.circuits[cid].instant_power_w for cid in circuits_closed.circuits.additional_keys - ) - print(f"Total circuit power: {closed_total_power:.1f}W") - print(f"Panel grid power: {panel_closed.instant_grid_power_w:.1f}W") - print(f"Power difference: {abs(panel_closed.instant_grid_power_w - closed_total_power):.1f}W") - - # Verify behavior - print(f"\n=== BEHAVIOR VERIFICATION ===") - print(f"1. Circuit power when OPEN: {open_circuit.instant_power_w:.1f}W (should be 0)") - print(f"2. Circuit power when CLOSED: {closed_circuit.instant_power_w:.1f}W (should be non-zero)") - print( - f"3. Panel power reflects circuit changes: Panel changed by {panel_power_change:.1f}W when circuit changed by {circuit_power_change:.1f}W" - ) - print( - f"4. Energy accumulation when OPEN: {energy_consumed_change:.3f}Wh consumed, {energy_produced_change:.3f}Wh produced" - ) - - -if __name__ == "__main__": - asyncio.run(test_relay_behavior_demonstration()) diff --git a/tests/test_simulation_engine_coverage.py b/tests/test_simulation_battery.py similarity index 65% rename from tests/test_simulation_engine_coverage.py rename to tests/test_simulation_battery.py index f1e528a..bd83a29 100644 --- a/tests/test_simulation_engine_coverage.py +++ b/tests/test_simulation_battery.py @@ -1,20 +1,19 @@ """Test simulation engine edge cases and missing coverage lines.""" import pytest +import time from pathlib import Path -from unittest.mock import patch, MagicMock -from span_panel_api import SpanPanelClient +from unittest.mock import patch + from span_panel_api.simulation import DynamicSimulationEngine from span_panel_api.exceptions import SimulationConfigurationError -import time def get_base_config(): """Get a base configuration for testing purposes.""" import yaml - from pathlib import Path - config_path = Path(__file__).parent.parent / "examples" / "validation_test_config.yaml" + config_path = Path(__file__).parent / "fixtures" / "configs" / "validation_test_config.yaml" with open(config_path) as f: config = yaml.safe_load(f) @@ -26,9 +25,8 @@ def get_base_config(): def get_battery_config(): """Get a configuration with battery template for testing purposes.""" import yaml - from pathlib import Path - config_path = Path(__file__).parent.parent / "examples" / "battery_test_config.yaml" + config_path = Path(__file__).parent / "fixtures" / "configs" / "battery_test_config.yaml" with open(config_path) as f: config = yaml.safe_load(f) @@ -40,9 +38,8 @@ def get_battery_config(): def get_soe_battery_config(): """Get a configuration for SOE battery testing with bidirectional energy behavior.""" import yaml - from pathlib import Path - config_path = Path(__file__).parent.parent / "examples" / "battery_test_config.yaml" + config_path = Path(__file__).parent / "fixtures" / "configs" / "battery_test_config.yaml" with open(config_path) as f: config = yaml.safe_load(f) @@ -119,91 +116,11 @@ async def test_serial_number_property_without_config(self): assert serial_override == "custom-serial-456" -class TestCircuitOverrideEdgeCases: - """Test circuit override edge cases.""" - - @pytest.mark.asyncio - async def test_circuit_override_power_multiplier(self): - """Test circuit override power multiplier (line 649).""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - async with SpanPanelClient(host="test", simulation_mode=True, simulation_config_path=str(config_path)) as client: - # Get initial circuits to find a valid circuit ID - circuits = await client.get_circuits() - circuit_id = next(iter(circuits.circuits.additional_properties.keys())) - - # Set a power multiplier override for a specific circuit - await client.set_circuit_overrides(circuit_overrides={circuit_id: {"power_multiplier": 2.0}}) - - circuits_modified = await client.get_circuits() - circuit = circuits_modified.circuits.additional_properties.get(circuit_id) - assert circuit is not None - - # Power should be affected by the multiplier - assert circuit.instant_power_w >= 0 - - @pytest.mark.asyncio - async def test_global_power_multiplier_override(self): - """Test global power multiplier override (line 657).""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - async with SpanPanelClient(host="test", simulation_mode=True, simulation_config_path=str(config_path)) as client: - # Get baseline power first - circuits_baseline = await client.get_circuits() - baseline_power = 0 - for circuit in circuits_baseline.circuits.additional_properties.values(): - baseline_power += circuit.instant_power_w - - # Set a global power multiplier - await client.set_circuit_overrides(global_overrides={"power_multiplier": 1.5}) - - circuits_modified = await client.get_circuits() - modified_power = 0 - for circuit in circuits_modified.circuits.additional_properties.values(): - modified_power += circuit.instant_power_w - - # Total power should be affected by the global multiplier - # Allow for some variance due to simulation randomness - assert modified_power > baseline_power * 1.2 # Should be roughly 1.5x - - class TestSimulationEngineInitialization: """Test simulation engine initialization edge cases.""" - @pytest.mark.asyncio - async def test_simulation_engine_with_yaml_config(self): - """Test simulation engine with YAML configuration file.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - async with SpanPanelClient( - host="yaml-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Should initialize successfully with YAML config - assert client._simulation_engine is not None - - # Should be able to get data - circuits = await client.get_circuits() - assert circuits is not None - - panel_state = await client.get_panel_state() - assert panel_state is not None - - @pytest.mark.asyncio - async def test_simulation_engine_basic_initialization(self): - """Test basic simulation engine initialization.""" - async with SpanPanelClient(host="config-test", simulation_mode=True) as client: - # Should initialize successfully with default config - assert client._simulation_engine is not None - - # Should be able to access properties - # Host is used as serial when creating simulation engine - serial = client._simulation_engine.serial_number - assert serial == "config-test" - async def test_battery_power_calculation_edge_cases(self) -> None: """Test battery power calculation for different time periods.""" - from span_panel_api.simulation import DynamicSimulationEngine - config = get_battery_config() engine = DynamicSimulationEngine("battery-test", config_data=config) await engine.initialize_async() @@ -237,8 +154,6 @@ async def test_battery_power_calculation_edge_cases(self) -> None: async def test_serial_number_with_override_no_config(self) -> None: """Test serial number property when override is set but no config is loaded.""" - from span_panel_api.simulation import DynamicSimulationEngine - # Create engine with serial number override but no config engine = DynamicSimulationEngine(serial_number="OVERRIDE-12345") @@ -248,8 +163,6 @@ async def test_serial_number_with_override_no_config(self) -> None: async def test_serial_number_error_when_no_config_or_override(self) -> None: """Test serial number property raises error when no config or override is set.""" - from span_panel_api.simulation import DynamicSimulationEngine - # Create engine without serial number override and no config engine = DynamicSimulationEngine() @@ -259,8 +172,6 @@ async def test_serial_number_error_when_no_config_or_override(self) -> None: async def test_battery_soe_charging_discharging_scenarios(self) -> None: """Test battery SOE calculation for charging and discharging scenarios.""" - from span_panel_api.simulation import DynamicSimulationEngine - # Use battery test config for SOE calculation config = get_soe_battery_config() diff --git a/tests/test_simulation_edge_cases_final.py b/tests/test_simulation_edge_cases_final.py deleted file mode 100644 index 9647425..0000000 --- a/tests/test_simulation_edge_cases_final.py +++ /dev/null @@ -1,235 +0,0 @@ -"""Tests for simulation edge cases and missing coverage lines.""" - -import pytest -from pathlib import Path -from unittest.mock import Mock, patch -from span_panel_api import SpanPanelClient -from span_panel_api.simulation import SimulationConfigurationError -import time - - -class TestSimulationMissingCoverage: - """Test cases to cover missing lines in simulation.py.""" - - @pytest.mark.asyncio - async def test_battery_behavior_disabled(self): - """Test battery behavior when disabled in config (line 269).""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="battery-disabled-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Get the simulation engine and ensure it's initialized - engine = client._simulation_engine - assert engine is not None - await engine.initialize_async() - - # Modify battery template to disable battery behavior - battery_template = engine._config["circuit_templates"]["battery"] - battery_template["battery_behavior"]["enabled"] = False - - # Test that battery behavior returns base power when disabled - behavior_engine = engine._behavior_engine - template = engine._config["circuit_templates"]["battery"] - - # This should hit line 269 (battery behavior disabled check) - power = behavior_engine._apply_battery_behavior(1000.0, template, time.time()) - tolerance = 1e-10 - assert abs(power - 1000.0) <= tolerance # Should return base power unchanged - - @pytest.mark.asyncio - async def test_battery_behavior_not_dict(self): - """Test battery behavior when config is not a dict (line 274).""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="battery-not-dict-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Get the simulation engine and ensure it's initialized - engine = client._simulation_engine - assert engine is not None - await engine.initialize_async() - - # Modify battery template to have non-dict battery_behavior - battery_template = engine._config["circuit_templates"]["battery"] - battery_template["battery_behavior"] = "not_a_dict" - - # Test that battery behavior returns base power when config is not dict - behavior_engine = engine._behavior_engine - - # This should hit line 274 (battery config not dict check) - tolerance = 1e-10 - power = behavior_engine._apply_battery_behavior(1000.0, battery_template, time.time()) - assert abs(power - 1000.0) <= tolerance # Should return base power unchanged - - @pytest.mark.asyncio - async def test_simulation_time_invalid_format(self): - """Test simulation time initialization with invalid datetime format (lines 427, 434-435).""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="invalid-time-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Get the simulation engine - engine = client._simulation_engine - assert engine is not None - - # Test override_simulation_start_time with invalid format - # This should hit lines 434-435 (invalid datetime fallback) - engine.override_simulation_start_time("invalid-datetime-format") - - # Should fall back to real time (use_simulation_time = False) - assert not engine._use_simulation_time - - @pytest.mark.asyncio - async def test_template_reference_validation(self): - """Test circuit template reference validation (line 472).""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="template-validation-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Get the simulation engine and ensure it's initialized - engine = client._simulation_engine - assert engine is not None - await engine.initialize_async() - - # Test validation with unknown template reference - invalid_circuit = {"id": "test_circuit", "name": "Test Circuit", "template": "nonexistent_template", "tabs": [1]} - - # This should hit line 472 (template reference validation) - with pytest.raises(ValueError, match="references unknown template"): - engine._validate_single_circuit(0, invalid_circuit, engine._config["circuit_templates"]) - - @pytest.mark.asyncio - async def test_primary_secondary_power_split(self): - """Test primary/secondary power split logic (lines 1033-1040).""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="power-split-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Get the simulation engine and ensure it's initialized - engine = client._simulation_engine - assert engine is not None - await engine.initialize_async() - - # Create a sync config with primary_secondary split - sync_config = { - "tabs": [33, 35], - "behavior": "240v_split_phase", - "power_split": "primary_secondary", - "energy_sync": True, - "template": "battery_sync", - } - - # Test primary tab gets full power - primary_power = engine._get_synchronized_power(33, 1000.0, sync_config) - assert primary_power == 1000.0 - - # Test secondary tab gets 0 power - secondary_power = engine._get_synchronized_power(35, 1000.0, sync_config) - assert secondary_power == 0.0 - - @pytest.mark.asyncio - async def test_tab_sync_config_not_found(self): - """Test tab sync config not found scenario (line 1006).""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="sync-config-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Get the simulation engine and ensure it's initialized - engine = client._simulation_engine - assert engine is not None - await engine.initialize_async() - - # Test with a tab that has no sync config - # This should hit line 1006 (tab sync config not found) - sync_config = engine._get_tab_sync_config(999) # Non-existent tab - assert sync_config is None - - @pytest.mark.asyncio - async def test_energy_sync_fallback(self): - """Test energy sync fallback when sync config fails (line 1077).""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="energy-sync-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Get the simulation engine - engine = client._simulation_engine - assert engine is not None - - # Test energy synchronization with a tab that has no sync group - # This should hit line 1077 (energy sync fallback) - produced, consumed = engine._synchronize_energy_for_tab(999, "test_circuit", 100.0, time.time()) - assert isinstance(produced, float) - assert isinstance(consumed, float) - - @pytest.mark.asyncio - async def test_no_config_provided_error(self): - """Test error when no config is provided (line 381).""" - # Test initialization without config - from span_panel_api.simulation import DynamicSimulationEngine - - engine = DynamicSimulationEngine() - - # This should hit line 381 (no config provided error) - with pytest.raises(ValueError, match="YAML configuration is required"): - await engine.initialize_async() - - @pytest.mark.asyncio - async def test_simulation_time_init_error(self): - """Test simulation time initialization error (lines 389-390).""" - from span_panel_api.simulation import DynamicSimulationEngine - - # Create engine with invalid config that will cause simulation time init error - invalid_config = { - "panel_config": {"serial_number": "test", "total_tabs": 40, "main_size": 200}, - "circuit_templates": { - "test": { - "energy_profile": { - "mode": "consumer", - "power_range": [0, 100], - "typical_power": 50, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - } - }, - "circuits": [{"id": "test", "name": "Test", "template": "test", "tabs": [1]}], - "unmapped_tabs": [], - "simulation_params": {}, - } - - engine = DynamicSimulationEngine(config_data=invalid_config) - - # This should hit lines 389-390 (simulation time init error) - with pytest.raises(SimulationConfigurationError, match="Simulation configuration is required"): - engine._initialize_simulation_time() - - @pytest.mark.asyncio - async def test_soe_calculation_edge_cases(self): - """Test SOE calculation edge cases (lines 876, 881).""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="soe-edge-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Get the simulation engine - engine = client._simulation_engine - assert engine is not None - - # Test SOE calculation with no battery circuits - # This should hit line 876 (no battery circuits) - with patch.object(engine, "_config") as mock_config: - mock_config.get.return_value = [] # No battery circuits - soe = engine._calculate_dynamic_soe() - assert 15.0 <= soe <= 95.0 - - # Test SOE calculation with battery circuits - # This should hit line 881 (with battery circuits) - soe = engine._calculate_dynamic_soe() - assert 15.0 <= soe <= 95.0 diff --git a/tests/test_simulation_errors.py b/tests/test_simulation_errors.py new file mode 100644 index 0000000..41a5e35 --- /dev/null +++ b/tests/test_simulation_errors.py @@ -0,0 +1,49 @@ +"""Tests for simulation edge cases and missing coverage lines.""" + +import pytest +from pathlib import Path + +from span_panel_api.simulation import DynamicSimulationEngine +from span_panel_api.exceptions import SimulationConfigurationError + + +class TestSimulationMissingCoverage: + """Test cases to cover missing lines in simulation.py.""" + + @pytest.mark.asyncio + async def test_no_config_provided_error(self): + """Test error when no config is provided (line 381).""" + engine = DynamicSimulationEngine() + + # This should hit line 381 (no config provided error) + with pytest.raises(ValueError, match="YAML configuration is required"): + await engine.initialize_async() + + @pytest.mark.asyncio + async def test_simulation_time_init_error(self): + """Test simulation time initialization error (lines 389-390).""" + # Create engine with invalid config that will cause simulation time init error + invalid_config = { + "panel_config": {"serial_number": "test", "total_tabs": 40, "main_size": 200}, + "circuit_templates": { + "test": { + "energy_profile": { + "mode": "consumer", + "power_range": [0, 100], + "typical_power": 50, + "power_variation": 0.1, + }, + "relay_behavior": "controllable", + "priority": "MUST_HAVE", + } + }, + "circuits": [{"id": "test", "name": "Test", "template": "test", "tabs": [1]}], + "unmapped_tabs": [], + "simulation_params": {}, + } + + engine = DynamicSimulationEngine(config_data=invalid_config) + + # This should hit lines 389-390 (simulation time init error) + with pytest.raises(SimulationConfigurationError, match="Simulation configuration is required"): + engine._initialize_simulation_time() diff --git a/tests/test_simulation_final_coverage.py b/tests/test_simulation_final_coverage.py deleted file mode 100644 index c2a72da..0000000 --- a/tests/test_simulation_final_coverage.py +++ /dev/null @@ -1,313 +0,0 @@ -"""Final coverage tests for simulation.py missing lines.""" - -import pytest -import time -from pathlib import Path -from unittest.mock import Mock, patch -import yaml - -from span_panel_api import SpanPanelClient, SimulationConfigurationError -from span_panel_api.simulation import DynamicSimulationEngine - - -class TestSimulationTimeInitialization: - """Test simulation time initialization missing lines.""" - - @pytest.mark.asyncio - async def test_simulation_time_with_start_time_string(self): - """Test simulation time initialization with start_time_str in config.""" - # Create a config with simulation start time - config_data = { - "panel_config": {"serial_number": "TEST-PANEL-001", "total_tabs": 40, "main_size": 200}, - "circuit_templates": { - "test_template": { - "energy_profile": { - "mode": "consumer", - "power_range": [0, 100], - "typical_power": 50, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - } - }, - "circuits": [{"id": "test_circuit", "name": "Test Circuit", "template": "test_template", "tabs": [1]}], - "unmapped_tabs": [], - "simulation_params": {"use_simulation_time": True, "simulation_start_time": "2024-06-15T12:00:00"}, - } - - engine = DynamicSimulationEngine(config_data=config_data) - await engine.initialize_async() - - # Should have simulation time enabled - assert engine._use_simulation_time - - @pytest.mark.asyncio - async def test_override_simulation_time_exception_handling(self): - """Test override_simulation_start_time exception handling.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-override-exception", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - engine = client._simulation_engine - assert engine is not None - - # Test with invalid time format that will cause exception - engine.override_simulation_start_time("invalid-time-format") - - # Should disable simulation time due to exception - assert not engine._use_simulation_time - - -class TestSOECalculationPaths: - """Test SOE calculation missing lines.""" - - @pytest.mark.asyncio - async def test_soe_calculation_with_battery_charging(self): - """Test SOE calculation when battery is charging significantly.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-soe-charging", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - engine = client._simulation_engine - assert engine is not None - - # Mock battery circuits to have high charging power - with patch.object(engine, "_config") as mock_config: - # Create a mock config with battery circuits having high charging power - mock_config.__getitem__.side_effect = lambda key: { - "circuits": [ - {"id": "battery_system_1", "template": "battery", "tabs": [33, 35]}, - {"id": "battery_system_2", "template": "battery", "tabs": [34, 36]}, - ] - }[key] - - # Mock the circuits data to have high charging power - with patch.object(engine, "_base_data") as mock_base_data: - mock_base_data.__getitem__.return_value = { - "circuits": { - "additional_properties": { - "battery_system_1": Mock(instant_power_w=-2000.0), - "battery_system_2": Mock(instant_power_w=-1500.0), - } - } - } - - soe_data = await engine.get_soe() - assert soe_data["soe"]["percentage"] >= 75.0 # Should be higher due to charging - - @pytest.mark.asyncio - async def test_soe_calculation_with_battery_discharging(self): - """Test SOE calculation when battery is discharging significantly.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-soe-discharging", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - engine = client._simulation_engine - assert engine is not None - - # Mock battery circuits to have high discharging power - with patch.object(engine, "_config") as mock_config: - mock_config.__getitem__.side_effect = lambda key: { - "circuits": [ - {"id": "battery_system_1", "template": "battery", "tabs": [33, 35]}, - {"id": "battery_system_2", "template": "battery", "tabs": [34, 36]}, - ] - }[key] - - with patch.object(engine, "_base_data") as mock_base_data: - mock_base_data.__getitem__.return_value = { - "circuits": { - "additional_properties": { - "battery_system_1": Mock(instant_power_w=2000.0), - "battery_system_2": Mock(instant_power_w=1500.0), - } - } - } - - soe_data = await engine.get_soe() - # Just verify we get a valid SOE value, don't make assumptions about the exact value - assert isinstance(soe_data["soe"]["percentage"], float) - assert 0.0 <= soe_data["soe"]["percentage"] <= 100.0 - - -class TestTabSynchronizationEdgeCases: - """Test tab synchronization edge cases.""" - - @pytest.mark.asyncio - async def test_tab_sync_config_without_config(self): - """Test _get_tab_sync_config when no config is loaded.""" - engine = DynamicSimulationEngine() - - # Should raise SimulationConfigurationError when no config - with pytest.raises( - SimulationConfigurationError, match="Simulation configuration is required for tab synchronization" - ): - engine._get_tab_sync_config(1) - - @pytest.mark.asyncio - async def test_energy_synchronization_with_sync_enabled(self): - """Test energy synchronization when energy_sync is enabled.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-energy-sync-enabled", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - engine = client._simulation_engine - assert engine is not None - - # Test energy synchronization for a tab that has sync enabled - # This tests the missing lines in _synchronize_energy_for_tab - produced, consumed = engine._synchronize_energy_for_tab(33, "test_circuit", 100.0, time.time()) - assert isinstance(produced, float) - assert isinstance(consumed, float) - - @pytest.mark.asyncio - async def test_energy_synchronization_without_sync_group(self): - """Test energy synchronization when sync group is not found.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-energy-sync-no-group", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - engine = client._simulation_engine - assert engine is not None - - # Test with a tab that has sync config but no sync group - # This tests the fallback path - with patch.object(engine, "_tab_sync_groups", {}): - produced, consumed = engine._synchronize_energy_for_tab(33, "test_circuit", 100.0, time.time()) - assert isinstance(produced, float) - assert isinstance(consumed, float) - - -class TestSimulationTimeAcceleration: - """Test simulation time with acceleration.""" - - @pytest.mark.asyncio - async def test_simulation_time_with_acceleration_config(self): - """Test simulation time calculation with time acceleration.""" - config_data = { - "panel_config": {"serial_number": "TEST-PANEL-001", "total_tabs": 40, "main_size": 200}, - "circuit_templates": { - "test_template": { - "energy_profile": { - "mode": "consumer", - "power_range": [0, 100], - "typical_power": 50, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - } - }, - "circuits": [{"id": "test_circuit", "name": "Test Circuit", "template": "test_template", "tabs": [1]}], - "unmapped_tabs": [], - "simulation_params": { - "use_simulation_time": True, - "simulation_start_time": "2024-06-15T12:00:00", - "time_acceleration": 2.0, - }, - } - - engine = DynamicSimulationEngine(config_data=config_data) - await engine.initialize_async() - - # Test get_current_simulation_time with acceleration - sim_time = engine.get_current_simulation_time() - assert isinstance(sim_time, float) - assert sim_time > 0 - - -class TestCircuitGenerationWithSync: - """Test circuit generation with tab synchronization.""" - - @pytest.mark.asyncio - async def test_circuit_generation_with_synchronized_tabs(self): - """Test circuit generation when tabs are synchronized.""" - config_data = { - "panel_config": {"serial_number": "TEST-PANEL-001", "total_tabs": 40, "main_size": 200}, - "circuit_templates": { - "test_template": { - "energy_profile": { - "mode": "consumer", - "power_range": [0, 100], - "typical_power": 50, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - } - }, - "circuits": [ - { - "id": "test_circuit", - "name": "Test Circuit", - "template": "test_template", - "tabs": [33, 35], # Multi-tab circuit - } - ], - "unmapped_tabs": [], - "simulation_params": {"noise_factor": 0.02}, - "tab_synchronizations": [ - { - "tabs": [33, 35], - "behavior": "240v_split_phase", - "power_split": "equal", - "energy_sync": True, - "template": "test_sync", - } - ], - } - - engine = DynamicSimulationEngine(config_data=config_data) - await engine.initialize_async() - - # Test circuit generation with synchronized tabs - circuits_data = await engine._generate_from_config() - assert "test_circuit" in circuits_data["circuits"]["circuits"] - - -class TestDynamicOverridesEdgeCases: - """Test dynamic overrides edge cases.""" - - @pytest.mark.asyncio - async def test_dynamic_overrides_with_priority(self): - """Test dynamic overrides with priority setting.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-overrides-priority", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - engine = client._simulation_engine - assert engine is not None - - # Set circuit-specific overrides including priority - engine.set_dynamic_overrides( - {"kitchen_lights": {"power_override": 100.0, "relay_state": "CLOSED", "priority": "NON_ESSENTIAL"}} - ) - - # Get circuits to trigger override application - circuits = await client.get_circuits() - assert circuits is not None - - @pytest.mark.asyncio - async def test_global_power_multiplier_override(self): - """Test global power multiplier override.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-global-multiplier", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - engine = client._simulation_engine - assert engine is not None - - # Set global power multiplier - engine.set_dynamic_overrides(global_overrides={"power_multiplier": 2.0}) - - # Get circuits to trigger override application - circuits = await client.get_circuits() - assert circuits is not None diff --git a/tests/test_simulation_mode.py b/tests/test_simulation_mode.py deleted file mode 100644 index 5c35447..0000000 --- a/tests/test_simulation_mode.py +++ /dev/null @@ -1,1555 +0,0 @@ -"""Tests for SPAN Panel API simulation mode functionality.""" - -import time -import pytest -from pathlib import Path -from typing import Any - -from span_panel_api import SpanPanelClient -from span_panel_api.exceptions import SpanPanelAPIError - - -class TestSimulationModeBasics: - """Test basic simulation mode functionality.""" - - def test_simulation_mode_initialization(self) -> None: - """Test that simulation mode initializes correctly.""" - # Live mode (default) - live_client = SpanPanelClient(host="192.168.1.100") - assert live_client._simulation_mode is False - assert live_client._simulation_engine is None - - # Simulation mode - sim_client = SpanPanelClient(host="localhost", simulation_mode=True) - assert sim_client._simulation_mode is True - assert sim_client._simulation_engine is not None - - def test_simulation_mode_ignores_host_in_simulation(self) -> None: - """Test that host is ignored in simulation mode.""" - # Should work with any host in simulation mode - client = SpanPanelClient(host="invalid-host", simulation_mode=True) - assert client._simulation_mode is True - - -class TestYAMLConfigurationMode: - """Test YAML configuration-based simulation mode.""" - - async def test_yaml_config_simulation(self) -> None: - """Test simulation with YAML configuration.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - # Test with YAML config - async with SpanPanelClient( - host="yaml-test-panel", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Test all API endpoints - circuits = await client.get_circuits() - panel = await client.get_panel_state() - status = await client.get_status() - storage = await client.get_storage_soe() - - # Verify YAML config is used - assert circuits is not None - assert panel is not None - assert status is not None - assert storage is not None - - # Check that host is used as serial number with YAML config - assert status.system.serial == "yaml-test-panel" - - async def test_yaml_realistic_behaviors(self) -> None: - """Test realistic behaviors with YAML configuration.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - async with SpanPanelClient( - host="behavior-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Get circuits to trigger behavior engine - circuits = await client.get_circuits() - assert circuits is not None - - # Check that we have the expected circuits from YAML - circuit_names = [c.name for c in circuits.circuits.additional_properties.values()] - - # Should have circuits from the YAML config - assert len(circuit_names) > 0 - - # Test circuit overrides work with YAML config - await client.set_circuit_overrides({"living_room_lights": {"power_override": 500.0}}) - - overridden_circuits = await client.get_circuits() - await client.clear_circuit_overrides() - - async def test_yaml_circuit_templates(self) -> None: - """Test circuit template system with YAML configuration.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - async with SpanPanelClient( - host="template-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - circuits = await client.get_circuits() - - # Check that different circuit types have different power patterns - circuit_powers = {} - for circuit in circuits.circuits.additional_properties.values(): - circuit_powers[circuit.name] = circuit.instant_power_w - - # Should have varied power levels based on templates - power_values = list(circuit_powers.values()) - assert len(set(power_values)) > 1 # Not all the same power - - async def test_simulation_engine_direct_methods(self) -> None: - """Test simulation engine methods directly for coverage.""" - from span_panel_api.simulation import DynamicSimulationEngine - - # Test with custom config - config_path = Path(__file__).parent.parent / "examples" / "simple_test_config.yaml" - engine = DynamicSimulationEngine("direct-test", config_path=config_path) - - await engine.initialize_async() - - # Test direct method calls using new YAML-only API - panel_data = await engine.get_panel_data() - assert isinstance(panel_data, dict) - assert "circuits" in panel_data - assert "panel" in panel_data - assert "status" in panel_data - assert "soe" in panel_data - - # Test individual methods - status_data = await engine.get_status() - assert isinstance(status_data, dict) - assert "system" in status_data - - soe_data = await engine.get_soe() - assert isinstance(soe_data, dict) - assert "soe" in soe_data - - async def test_behavior_engine_coverage(self) -> None: - """Test behavior engine methods for coverage using YAML config.""" - from span_panel_api.simulation import DynamicSimulationEngine - - # Use behavior test config with realistic patterns - config_path = Path(__file__).parent.parent / "examples" / "behavior_test_config.yaml" - engine = DynamicSimulationEngine("behavior-test", config_path=config_path) - await engine.initialize_async() - - # Test that behavior patterns are applied - panel_data = await engine.get_panel_data() - circuits = panel_data["circuits"]["circuits"] - - # Should have circuits with different behavior types - circuit_names = [circuit["name"] for circuit in circuits.values()] - - # Verify we have different circuit types that test different behaviors - assert any("HVAC" in name for name in circuit_names) # Cycling behavior - assert any("Lights" in name for name in circuit_names) # Time of day behavior - assert any("EV" in name or "Charger" in name for name in circuit_names) # Smart behavior - assert any("Solar" in name for name in circuit_names) # Production behavior - - # Test multiple calls to verify behavior patterns work - for _ in range(3): - panel_data = await engine.get_panel_data() - circuits = panel_data["circuits"]["circuits"] - - # Verify power values are realistic - for circuit_data in circuits.values(): - power = circuit_data["instantPowerW"] - assert isinstance(power, (int, float)) - # Solar should be negative (production), others positive (consumption) - if "solar" in circuit_data["name"].lower(): - assert power >= 0 - else: - assert power >= 0 - - async def test_template_inference_coverage(self) -> None: - """Test YAML template system - no inference needed.""" - from span_panel_api.simulation import DynamicSimulationEngine - - # Test that YAML configuration provides complete templates - config_path = Path(__file__).parent.parent / "examples" / "simple_test_config.yaml" - engine = DynamicSimulationEngine("template-test", config_path=config_path) - await engine.initialize_async() - - # Verify that templates are loaded from YAML - panel_data = await engine.get_panel_data() - circuits = panel_data["circuits"]["circuits"] - - # All circuits should have proper templates applied - for circuit_id, circuit_data in circuits.items(): - assert "instantPowerW" in circuit_data - assert "priority" in circuit_data - assert "isUserControllable" in circuit_data - # Power should be realistic (not zero for non-solar circuits) - if "solar" not in circuit_data["name"].lower(): - assert circuit_data["instantPowerW"] >= 0 - - async def test_edge_cases_and_error_handling(self) -> None: - """Test edge cases and error handling for YAML-only simulation.""" - from span_panel_api.simulation import DynamicSimulationEngine - - # Test with no config should raise error - engine = DynamicSimulationEngine("edge-test") - - with pytest.raises(ValueError, match="YAML configuration is required"): - await engine.initialize_async() - - # Test with valid YAML config should work - config_path = Path(__file__).parent.parent / "examples" / "minimal_config.yaml" - engine_with_config = DynamicSimulationEngine("edge-test-with-config", config_path=config_path) - await engine_with_config.initialize_async() - - panel_data = await engine_with_config.get_panel_data() - assert isinstance(panel_data, dict) - assert "circuits" in panel_data - - -class TestCircuitsSimulation: - """Test circuits API simulation functionality.""" - - @pytest.fixture - def sim_client(self) -> SpanPanelClient: - """Create a simulation mode client.""" - config_path = Path(__file__).parent.parent / "examples" / "simple_test_config.yaml" - return SpanPanelClient(host="circuits-test", simulation_mode=True, simulation_config_path=str(config_path)) - - @pytest.fixture - def live_client(self) -> SpanPanelClient: - """Create a live mode client.""" - return SpanPanelClient(host="192.168.1.100", simulation_mode=False) - - async def test_get_circuits_basic_simulation(self, sim_client: SpanPanelClient) -> None: - """Test basic circuits retrieval in simulation mode.""" - async with sim_client: - circuits = await sim_client.get_circuits() - - # Should return CircuitsOut object - assert circuits is not None - assert hasattr(circuits, "circuits") - assert len(circuits.circuits.additional_properties) > 0 - - async def test_get_circuits_with_global_variations(self, sim_client: SpanPanelClient) -> None: - """Test circuits with global power and energy variations using override API.""" - async with sim_client: - # Get baseline circuits - baseline_circuits = await sim_client.get_circuits() - - # Apply global power multiplier override - await sim_client.set_circuit_overrides(global_overrides={"power_multiplier": 1.2}) - - # Get circuits with override - overridden_circuits = await sim_client.get_circuits() - - assert overridden_circuits is not None - assert len(overridden_circuits.circuits.additional_properties) > 0 - - # Clear overrides - await sim_client.clear_circuit_overrides() - - async def test_get_circuits_with_specific_variations(self, sim_client: SpanPanelClient) -> None: - """Test circuits with specific circuit variations using override API.""" - async with sim_client: - # Get baseline first - baseline = await sim_client.get_circuits() - circuit_ids = list(baseline.circuits.additional_properties.keys()) - - if circuit_ids: - test_circuit_id = circuit_ids[0] - - # Apply specific circuit override - await sim_client.set_circuit_overrides({test_circuit_id: {"power_override": 1000.0}}) - - # Get circuits with override - overridden = await sim_client.get_circuits() - - assert overridden is not None - assert len(overridden.circuits.additional_properties) > 0 - - # Clear overrides - await sim_client.clear_circuit_overrides() - - async def test_get_circuits_mixed_variations(self, sim_client: SpanPanelClient) -> None: - """Test circuits with mixed global and specific variations.""" - async with sim_client: - # Get baseline - baseline = await sim_client.get_circuits() - circuit_ids = list(baseline.circuits.additional_properties.keys()) - - if circuit_ids: - test_circuit_id = circuit_ids[0] - - # Apply both global and specific overrides - await sim_client.set_circuit_overrides( - circuit_overrides={test_circuit_id: {"power_override": 500.0}}, - global_overrides={"power_multiplier": 1.5}, - ) - - overridden = await sim_client.get_circuits() - - assert overridden is not None - assert len(overridden.circuits.additional_properties) > 0 - - # Clear overrides - await sim_client.clear_circuit_overrides() - - async def test_get_circuits_legacy_parameters(self, sim_client: SpanPanelClient) -> None: - """Test that the clean API doesn't accept old variation parameters.""" - async with sim_client: - # This should work (no parameters) - circuits = await sim_client.get_circuits() - assert circuits is not None - - # These should raise TypeError if old parameters are passed - with pytest.raises(TypeError): - await sim_client.get_circuits(global_power_variation=0.1) # type: ignore[call-arg] - - with pytest.raises(TypeError): - await sim_client.get_circuits(variations={}) # type: ignore[call-arg] - - -class TestPanelStateSimulation: - """Test panel state API simulation functionality.""" - - @pytest.fixture - def sim_client(self) -> SpanPanelClient: - """Create a simulation mode client.""" - config_path = Path(__file__).parent.parent / "examples" / "simple_test_config.yaml" - return SpanPanelClient(host="panel-test", simulation_mode=True, simulation_config_path=str(config_path)) - - async def test_get_panel_state_basic(self, sim_client: SpanPanelClient) -> None: - """Test basic panel state retrieval in simulation mode.""" - async with sim_client: - panel = await sim_client.get_panel_state() - - assert panel is not None - assert hasattr(panel, "instant_grid_power_w") - - -class TestStatusSimulation: - """Test status API simulation functionality.""" - - @pytest.fixture - def sim_client(self) -> SpanPanelClient: - """Create a simulation mode client.""" - config_path = Path(__file__).parent.parent / "examples" / "simple_test_config.yaml" - return SpanPanelClient(host="status-test", simulation_mode=True, simulation_config_path=str(config_path)) - - async def test_get_status_basic(self, sim_client: SpanPanelClient) -> None: - """Test basic status retrieval in simulation mode.""" - async with sim_client: - status = await sim_client.get_status() - - assert status is not None - assert hasattr(status, "system") - - -class TestStorageSimulation: - """Test storage SOE API simulation functionality.""" - - @pytest.fixture - def sim_client(self) -> SpanPanelClient: - """Create a simulation mode client.""" - config_path = Path(__file__).parent.parent / "examples" / "simple_test_config.yaml" - return SpanPanelClient(host="storage-test", simulation_mode=True, simulation_config_path=str(config_path)) - - async def test_get_storage_soe_basic(self, sim_client: SpanPanelClient) -> None: - """Test basic storage SOE retrieval in simulation mode.""" - async with sim_client: - soe = await sim_client.get_storage_soe() - - assert soe is not None - assert hasattr(soe, "soe") - - -class TestSimulationRealism: - """Test realistic simulation behaviors.""" - - @pytest.fixture - def sim_client(self) -> SpanPanelClient: - """Create a simulation mode client.""" - config_path = Path(__file__).parent.parent / "examples" / "simple_test_config.yaml" - return SpanPanelClient(host="realism-test", simulation_mode=True, simulation_config_path=str(config_path)) - - async def test_power_variation_by_circuit_type(self, sim_client: SpanPanelClient) -> None: - """Test that different circuit types have appropriate power variations.""" - async with sim_client: - circuits = await sim_client.get_circuits() - - # Collect power values by circuit name patterns - power_values = {} - for circuit in circuits.circuits.additional_properties.values(): - power_values[circuit.name] = circuit.instant_power_w - - # Verify we have some circuits with different power levels - assert len(power_values) > 0 - - async def test_circuit_specific_behavior(self, sim_client: SpanPanelClient) -> None: - """Test circuit-specific behavioral characteristics.""" - async with sim_client: - circuits = await sim_client.get_circuits() - - # Test that circuits have varied power levels - power_levels = [c.instant_power_w for c in circuits.circuits.additional_properties.values()] - - # Should have some variation in power levels (not all the same) - assert len(set(power_levels)) > 1 - - -class TestSimulationErrorHandling: - """Test error handling in simulation mode.""" - - async def test_missing_fixture_data_errors(self) -> None: - """Test error handling when YAML config is missing.""" - from span_panel_api.simulation import DynamicSimulationEngine - - # Create engine without YAML config - engine = DynamicSimulationEngine() - - # Should raise error when trying to initialize without config - with pytest.raises(ValueError, match="YAML configuration is required"): - await engine.initialize_async() - - async def test_simulation_engine_methods(self) -> None: - """Test simulation engine methods work correctly with YAML config.""" - from span_panel_api.simulation import DynamicSimulationEngine - - config_path = Path(__file__).parent.parent / "examples" / "minimal_config.yaml" - engine = DynamicSimulationEngine("test-engine", config_path=config_path) - await engine.initialize_async() - - # Test panel data method (async) - panel_data = await engine.get_panel_data() - assert isinstance(panel_data, dict) - assert "circuits" in panel_data - assert "panel" in panel_data - - # Test status data method (async) - status_data = await engine.get_status() - assert isinstance(status_data, dict) - assert "system" in status_data - - # Test SOE data method (async) - soe_data = await engine.get_soe() - assert isinstance(soe_data, dict) - assert "soe" in soe_data - - -class TestSimulationCaching: - """Test simulation caching functionality.""" - - @pytest.fixture - def sim_client(self) -> SpanPanelClient: - """Create a simulation mode client.""" - config_path = Path(__file__).parent.parent / "examples" / "simple_test_config.yaml" - return SpanPanelClient(host="cache-test-host", simulation_mode=True, simulation_config_path=str(config_path)) - - async def test_simulation_uses_host_as_serial_number(self) -> None: - """Test that simulation mode uses the host parameter as the serial number.""" - custom_serial = "SPAN-TEST-ABC123" - - # Create client with custom serial as host - config_path = Path(__file__).parent.parent / "examples" / "simple_test_config.yaml" - client = SpanPanelClient(host=custom_serial, simulation_mode=True, simulation_config_path=str(config_path)) - - async with client: - status = await client.get_status() - - # Host parameter is still used as serial number override even with YAML config - assert status.system.serial == custom_serial - - async def test_simulation_async_initialization_race_condition(self) -> None: - """Test that concurrent initialization calls handle race conditions properly.""" - from span_panel_api.simulation import DynamicSimulationEngine - import asyncio - - # Create engine with YAML config - config_path = Path(__file__).parent.parent / "examples" / "simple_test_config.yaml" - - # Multiple concurrent initializations - engine1 = DynamicSimulationEngine("test-race-1", str(config_path)) - engine2 = DynamicSimulationEngine("test-race-2", str(config_path)) - - # Initialize both concurrently - await asyncio.gather(engine1.initialize_async(), engine2.initialize_async()) - - # Both should be initialized successfully - panel_data1 = await engine1.get_panel_data() - panel_data2 = await engine2.get_panel_data() - - assert panel_data1 is not None - assert panel_data2 is not None - assert "circuits" in panel_data1 - assert "circuits" in panel_data2 - assert len(panel_data1["circuits"]) > 0 - assert len(panel_data2["circuits"]) > 0 - - async def test_simulation_relay_behavior(self) -> None: - """Test circuit relay state behavior and its effect on power/energy. - - This test verifies that relay state changes work correctly in simulation mode: - 1. Opening a circuit relay sets its power to 0W - 2. Relay state is properly reflected in circuit data - 3. Panel grid power reflects the circuit changes - 4. Closing the relay restores normal behavior - """ - import asyncio - from tests.time_utils import advance_time_async - - config_path = Path(__file__).parent.parent / "examples" / "simple_test_config.yaml" - client = SpanPanelClient( - host="relay-test-demo", - simulation_mode=True, - simulation_config_path=str(config_path), - # Disable caching for real-time data - ) - - async with client: - # Ensure clean state - await client.clear_circuit_overrides() - - # Step 1: Get baseline data (all circuits closed) - circuits_baseline = await client.get_circuits() - panel_baseline = await client.get_panel_state() - - # Find a circuit with significant power for testing - active_circuit_id = None - for circuit_id in circuits_baseline.circuits.additional_keys: - circuit = circuits_baseline.circuits[circuit_id] - if abs(circuit.instant_power_w) > 10: # Find circuit with meaningful power - active_circuit_id = circuit_id - break - - assert active_circuit_id is not None, "Should find at least one circuit with significant power" - - baseline_circuit = circuits_baseline.circuits[active_circuit_id] - baseline_total_power = sum( - circuits_baseline.circuits[cid].instant_power_w for cid in circuits_baseline.circuits.additional_keys - ) - - # Step 2: Open the circuit relay (simulate turning off AC) - await client.set_circuit_overrides({active_circuit_id: {"relay_state": "OPEN"}}) - circuits_open = await client.get_circuits() - panel_open = await client.get_panel_state() - - # Verify circuit still exists in response - assert ( - active_circuit_id in circuits_open.circuits.additional_keys - ), f"Circuit {active_circuit_id} should still exist after override" - - open_circuit = circuits_open.circuits[active_circuit_id] - open_total_power = sum( - circuits_open.circuits[cid].instant_power_w for cid in circuits_open.circuits.additional_keys - ) - - # Calculate changes - circuit_power_change = open_circuit.instant_power_w - baseline_circuit.instant_power_w - total_power_change = open_total_power - baseline_total_power - panel_power_change = panel_open.instant_grid_power_w - panel_baseline.instant_grid_power_w - - # Verify relay override is working correctly - assert ( - open_circuit.relay_state == "OPEN" - ), f"Relay state should be OPEN after override, got {open_circuit.relay_state}" - assert ( - open_circuit.instant_power_w == 0.0 - ), f"Power should be 0W when relay is OPEN, got {open_circuit.instant_power_w}W" - - # Document the working behavior for verification: - print(f"RELAY OVERRIDE SUCCESS - Circuit {baseline_circuit.name}:") - print(f" Relay state: {open_circuit.relay_state} (correct)") - print(f" Power: {open_circuit.instant_power_w:.1f}W (correct)") - print(f" Circuit power change: {circuit_power_change:.1f}W") - print(f" Total power change: {total_power_change:.1f}W") - print(f" Panel power change: {panel_power_change:.1f}W") - - # Step 3: Test energy accumulation behavior - # Yield to event loop to allow any pending operations - await advance_time_async(0) - - circuits_after_wait = await client.get_circuits() - wait_circuit = circuits_after_wait.circuits[active_circuit_id] - energy_consumed_change = wait_circuit.consumed_energy_wh - open_circuit.consumed_energy_wh - - # Verify power stays at 0 while relay is open - assert ( - wait_circuit.instant_power_w == 0.0 - ), f"Power should remain 0W while relay is OPEN, got {wait_circuit.instant_power_w}W" - print( - f" Energy accumulation check: {energy_consumed_change:.3f}Wh change (expected variation due to random generation)" - ) - - # Step 4: Close the relay - await client.set_circuit_overrides({active_circuit_id: {"relay_state": "CLOSED"}}) - circuits_closed = await client.get_circuits() - closed_circuit = circuits_closed.circuits[active_circuit_id] - - # Verify basic functionality still works - assert closed_circuit is not None, "Circuit should exist after closing relay" - - # Clean up - await client.clear_circuit_overrides() - - async def test_simulation_double_check_in_lock(self) -> None: - """Test the double-check pattern inside the async lock.""" - from span_panel_api.simulation import DynamicSimulationEngine - import asyncio - - config_path = Path(__file__).parent.parent / "examples" / "minimal_config.yaml" - engine = DynamicSimulationEngine("TEST-SERIAL", config_path=config_path) - - # First initialization should load data - await engine.initialize_async() - first_data = engine._base_data - - # Second initialization should use existing data - await engine.initialize_async() - second_data = engine._base_data - - # Should be the same object (not re-created) - assert first_data is second_data - - -class TestSimulationErrorConditions: - """Test error conditions for simulation-only methods.""" - - @pytest.mark.asyncio - async def test_circuit_overrides_outside_simulation_mode(self): - """Test that circuit override methods fail outside simulation mode.""" - client = SpanPanelClient("192.168.1.100", simulation_mode=False) - - async with client: - # set_circuit_overrides should fail - with pytest.raises(SpanPanelAPIError, match="Circuit overrides only available in simulation mode"): - await client.set_circuit_overrides({"test": {"power_override": 100.0}}) - - # clear_circuit_overrides should fail - with pytest.raises(SpanPanelAPIError, match="Circuit overrides only available in simulation mode"): - await client.clear_circuit_overrides() - - @pytest.mark.asyncio - async def test_set_circuit_relay_validation_in_simulation(self): - """Test relay state validation in simulation mode.""" - client = SpanPanelClient( - host="test-relay-validation", simulation_mode=True, simulation_config_path="examples/simple_test_config.yaml" - ) - - async with client: - # Test invalid relay state - with pytest.raises(SpanPanelAPIError, match="Invalid relay state 'INVALID'. Must be one of: OPEN, CLOSED"): - await client.set_circuit_relay("living_room_lights", "INVALID") - - # Test valid relay states work (case insensitive) - result_open = await client.set_circuit_relay("living_room_lights", "open") - assert result_open["relay_state"] == "OPEN" - - result_closed = await client.set_circuit_relay("living_room_lights", "CLOSED") - assert result_closed["relay_state"] == "CLOSED" - - -class TestSimulationEngineErrors: - """Test simulation engine error conditions.""" - - @pytest.mark.asyncio - async def test_get_circuits_simulation_engine_not_initialized(self): - """Test that get_circuits raises error when simulation engine not initialized.""" - client = SpanPanelClient(host="simulation", simulation_mode=True) - # Force simulation engine to None to test this specific error path - client._simulation_engine = None - - with pytest.raises(SpanPanelAPIError, match="Simulation engine not initialized"): - await client.get_circuits() - - @pytest.mark.asyncio - async def test_set_relay_state_simulation_engine_not_initialized(self): - """Test that set_circuit_relay raises error when simulation engine not initialized.""" - client = SpanPanelClient(host="simulation", simulation_mode=True) - # Force simulation engine to None to test this specific error path - client._simulation_engine = None - - with pytest.raises(SpanPanelAPIError, match="Simulation engine not initialized"): - await client.set_circuit_relay("circuit1", "OPEN") - - @pytest.mark.asyncio - async def test_set_circuit_overrides_simulation_engine_not_initialized(self): - """Test that set_circuit_overrides raises error when simulation engine not initialized.""" - client = SpanPanelClient(host="simulation", simulation_mode=True) - # Force simulation engine to None to test this specific error path - client._simulation_engine = None - - with pytest.raises(SpanPanelAPIError, match="Simulation engine not initialized"): - await client.set_circuit_overrides(circuit_overrides={"circuit1": {"power_override": 100}}) - - @pytest.mark.asyncio - async def test_clear_circuit_overrides_simulation_engine_not_initialized(self): - """Test that clear_circuit_overrides raises error when simulation engine not initialized.""" - client = SpanPanelClient(host="simulation", simulation_mode=True) - # Force simulation engine to None to test this specific error path - client._simulation_engine = None - - with pytest.raises(SpanPanelAPIError, match="Simulation engine not initialized"): - await client.clear_circuit_overrides() - - -class TestCircuitOverrideEdgeCases: - """Test circuit override edge cases.""" - - @pytest.mark.asyncio - async def test_set_circuit_overrides_not_simulation_mode(self): - """Test that set_circuit_overrides raises error when not in simulation mode.""" - client = SpanPanelClient(host="192.168.1.100", simulation_mode=False) - - with pytest.raises(SpanPanelAPIError, match="Circuit overrides only available in simulation mode"): - await client.set_circuit_overrides(circuit_overrides={"circuit1": {"power_override": 100}}) - - @pytest.mark.asyncio - async def test_clear_circuit_overrides_not_simulation_mode(self): - """Test that clear_circuit_overrides raises error when not in simulation mode.""" - client = SpanPanelClient(host="192.168.1.100", simulation_mode=False) - - with pytest.raises(SpanPanelAPIError, match="Circuit overrides only available in simulation mode"): - await client.clear_circuit_overrides() - - -class TestUnmappedTabEdgeCases: - """Test unmapped tab creation edge cases.""" - - @pytest.mark.asyncio - async def test_unmapped_tab_creation_edge_case(self): - """Test unmapped tab creation with unusual branch configuration.""" - # Use YAML config to have actual circuits and tabs - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - async with SpanPanelClient(host="test", simulation_mode=True, simulation_config_path=str(config_path)) as client: - # Get initial state to ensure we have branches - panel_state = await client.get_panel_state() - - # The simulation should handle unmapped tabs correctly - circuits = await client.get_circuits() - - # Should have circuits and unmapped tabs - assert circuits is not None - assert len(circuits.circuits.additional_properties) > 0 - - # Check that we can handle tabs correctly - for circuit_id, circuit in circuits.circuits.additional_properties.items(): - if circuit_id.startswith("unmapped_tab_"): - # Verify unmapped tab properties - # Solar tabs (30, 32) produce power (negative values), others consume (positive) - tab_num = int(circuit_id.split("_")[-1]) - if tab_num in [30, 32]: - # Solar tabs should produce power (positive values) - assert ( - circuit.instant_power_w >= 0 - ), f"Solar tab {tab_num} should produce power (positive): {circuit.instant_power_w}W" - else: - # Other unmapped tabs should consume power (positive values) - assert ( - circuit.instant_power_w >= 0 - ), f"Unmapped tab {tab_num} should consume power (positive): {circuit.instant_power_w}W" - assert circuit.name is not None - - -class TestUnmappedTabEnergyAccumulation: - """Test energy accumulation for unmapped tabs 30 and 32 in 32-circuit panel.""" - - async def test_energy_accumulation_unmapped_tabs_30_32(self) -> None: - """Test that energy accumulates properly for solar production tabs 30 and 32.""" - import asyncio - from pathlib import Path - - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - async with SpanPanelClient( - host="energy-accumulation-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - print("\n=== Energy Accumulation Test for Unmapped Tabs 30 & 32 ===") - - # Get initial state - circuits_initial = await client.get_circuits() - circuit_data_initial = circuits_initial.circuits.additional_properties - - # Verify tabs 30 and 32 exist and are configured for solar production - assert "unmapped_tab_30" in circuit_data_initial, "Tab 30 should be unmapped" - assert "unmapped_tab_32" in circuit_data_initial, "Tab 32 should be unmapped" - - tab30_initial = circuit_data_initial["unmapped_tab_30"] - tab32_initial = circuit_data_initial["unmapped_tab_32"] - - print( - f"Initial Tab 30: {tab30_initial.instant_power_w:.1f}W, " - f"Produced: {tab30_initial.produced_energy_wh:.2f}Wh, " - f"Consumed: {tab30_initial.consumed_energy_wh:.2f}Wh" - ) - print( - f"Initial Tab 32: {tab32_initial.instant_power_w:.1f}W, " - f"Produced: {tab32_initial.produced_energy_wh:.2f}Wh, " - f"Consumed: {tab32_initial.consumed_energy_wh:.2f}Wh" - ) - - # Wait for energy accumulation (2 seconds should be enough for some accumulation) - print("\n--- Waiting 2 seconds for energy accumulation ---") - await asyncio.sleep(2) - - # Get updated state - circuits_updated = await client.get_circuits() - circuit_data_updated = circuits_updated.circuits.additional_properties - - tab30_updated = circuit_data_updated["unmapped_tab_30"] - tab32_updated = circuit_data_updated["unmapped_tab_32"] - - print( - f"Updated Tab 30: {tab30_updated.instant_power_w:.1f}W, " - f"Produced: {tab30_updated.produced_energy_wh:.2f}Wh, " - f"Consumed: {tab30_updated.consumed_energy_wh:.2f}Wh" - ) - print( - f"Updated Tab 32: {tab32_updated.instant_power_w:.1f}W, " - f"Produced: {tab32_updated.produced_energy_wh:.2f}Wh, " - f"Consumed: {tab32_updated.consumed_energy_wh:.2f}Wh" - ) - - # Calculate energy changes - tab30_produced_change = tab30_updated.produced_energy_wh - tab30_initial.produced_energy_wh - tab30_consumed_change = tab30_updated.consumed_energy_wh - tab30_initial.consumed_energy_wh - tab32_produced_change = tab32_updated.produced_energy_wh - tab32_initial.produced_energy_wh - tab32_consumed_change = tab32_updated.consumed_energy_wh - tab32_initial.consumed_energy_wh - - print(f"\nEnergy Changes:") - print(f"Tab 30 - Produced: +{tab30_produced_change:.3f}Wh, Consumed: +{tab30_consumed_change:.3f}Wh") - print(f"Tab 32 - Produced: +{tab32_produced_change:.3f}Wh, Consumed: +{tab32_consumed_change:.3f}Wh") - - # Test assertions for solar production tabs - # These tabs should accumulate produced energy when generating power (negative power) - if tab30_updated.instant_power_w < 0: # If producing power - assert ( - tab30_produced_change >= 0 - ), f"Tab 30 should accumulate produced energy when generating power, got {tab30_produced_change:.3f}Wh" - assert ( - tab30_consumed_change == 0 - ), f"Tab 30 should not accumulate consumed energy when producing, got {tab30_consumed_change:.3f}Wh" - else: # If not producing (nighttime) - # During non-production periods, no energy should accumulate - print("Tab 30 not producing (likely nighttime) - no energy accumulation expected") - - if tab32_updated.instant_power_w < 0: # If producing power - assert ( - tab32_produced_change >= 0 - ), f"Tab 32 should accumulate produced energy when generating power, got {tab32_produced_change:.3f}Wh" - assert ( - tab32_consumed_change == 0 - ), f"Tab 32 should not accumulate consumed energy when producing, got {tab32_consumed_change:.3f}Wh" - else: # If not producing (nighttime) - # During non-production periods, no energy should accumulate - print("Tab 32 not producing (likely nighttime) - no energy accumulation expected") - - # Energy values should never be negative - assert ( - tab30_updated.produced_energy_wh >= 0 - ), f"Tab 30 produced energy should never be negative: {tab30_updated.produced_energy_wh}" - assert ( - tab30_updated.consumed_energy_wh >= 0 - ), f"Tab 30 consumed energy should never be negative: {tab30_updated.consumed_energy_wh}" - assert ( - tab32_updated.produced_energy_wh >= 0 - ), f"Tab 32 produced energy should never be negative: {tab32_updated.produced_energy_wh}" - assert ( - tab32_updated.consumed_energy_wh >= 0 - ), f"Tab 32 consumed energy should never be negative: {tab32_updated.consumed_energy_wh}" - - # Energy should be monotonically increasing (never decrease) - assert ( - tab30_updated.produced_energy_wh >= tab30_initial.produced_energy_wh - ), "Tab 30 produced energy should only increase" - assert ( - tab30_updated.consumed_energy_wh >= tab30_initial.consumed_energy_wh - ), "Tab 30 consumed energy should only increase" - assert ( - tab32_updated.produced_energy_wh >= tab32_initial.produced_energy_wh - ), "Tab 32 produced energy should only increase" - assert ( - tab32_updated.consumed_energy_wh >= tab32_initial.consumed_energy_wh - ), "Tab 32 consumed energy should only increase" - - print("\n✅ Energy accumulation test passed for unmapped tabs 30 & 32!") - - async def test_energy_accumulation_over_longer_period(self) -> None: - """Test energy accumulation over a longer period to verify proper time-based calculation.""" - import asyncio - from pathlib import Path - - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - async with SpanPanelClient( - host="longer-energy-test", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - print("\n=== Longer Period Energy Accumulation Test ===") - - # Record energy over multiple measurements - measurements = [] - measurement_count = 5 - wait_time = 1.0 # 1 second between measurements - - for i in range(measurement_count): - circuits = await client.get_circuits() - circuit_data = circuits.circuits.additional_properties - - tab30 = circuit_data["unmapped_tab_30"] - tab32 = circuit_data["unmapped_tab_32"] - - measurements.append( - { - "time": i * wait_time, - "tab30_power": tab30.instant_power_w, - "tab30_produced": tab30.produced_energy_wh, - "tab30_consumed": tab30.consumed_energy_wh, - "tab32_power": tab32.instant_power_w, - "tab32_produced": tab32.produced_energy_wh, - "tab32_consumed": tab32.consumed_energy_wh, - } - ) - - print( - f"Measurement {i + 1}: Tab30({tab30.instant_power_w:.1f}W, {tab30.produced_energy_wh:.3f}Wh), " - f"Tab32({tab32.instant_power_w:.1f}W, {tab32.produced_energy_wh:.3f}Wh)" - ) - - if i < measurement_count - 1: # Don't wait after last measurement - await asyncio.sleep(wait_time) - - # Analyze energy accumulation patterns - print(f"\n=== Energy Accumulation Analysis ===") - - # Check that energy values are monotonically increasing - for i in range(1, len(measurements)): - current = measurements[i] - previous = measurements[i - 1] - - # Energy should never decrease - assert ( - current["tab30_produced"] >= previous["tab30_produced"] - ), f"Tab 30 produced energy decreased from {previous['tab30_produced']:.3f} to {current['tab30_produced']:.3f}" - assert ( - current["tab30_consumed"] >= previous["tab30_consumed"] - ), f"Tab 30 consumed energy decreased from {previous['tab30_consumed']:.3f} to {current['tab30_consumed']:.3f}" - assert ( - current["tab32_produced"] >= previous["tab32_produced"] - ), f"Tab 32 produced energy decreased from {previous['tab32_produced']:.3f} to {current['tab32_produced']:.3f}" - assert ( - current["tab32_consumed"] >= previous["tab32_consumed"] - ), f"Tab 32 consumed energy decreased from {previous['tab32_consumed']:.3f} to {current['tab32_consumed']:.3f}" - - # Calculate total energy change - first = measurements[0] - last = measurements[-1] - total_time_hours = (len(measurements) - 1) * wait_time / 3600 # Convert to hours - - tab30_total_produced = last["tab30_produced"] - first["tab30_produced"] - tab32_total_produced = last["tab32_produced"] - first["tab32_produced"] - - print(f"Total time period: {total_time_hours:.6f} hours") - print(f"Tab 30 total produced energy: {tab30_total_produced:.6f}Wh") - print(f"Tab 32 total produced energy: {tab32_total_produced:.6f}Wh") - - # If panels were producing during test, energy accumulation should be reasonable - # Energy (Wh) = Power (W) × Time (h), but energy accumulation can vary due to: - # - Power fluctuations during the test period - # - Simulation update frequency - # - Time-based behavior patterns - if any(m["tab30_power"] < 0 for m in measurements): - # Use average power over the test period for more accurate estimation - avg_power = sum(abs(m["tab30_power"]) for m in measurements) / len(measurements) - expected_range = avg_power * total_time_hours - # Allow 3x tolerance to account for simulation variations and time-based patterns - tolerance_factor = 3.0 - assert ( - tab30_total_produced <= expected_range * tolerance_factor - ), f"Tab 30 energy accumulation seems unreasonably high: {tab30_total_produced:.6f}Wh for {expected_range:.6f}Wh expected (with {tolerance_factor}x tolerance)" - - if any(m["tab32_power"] < 0 for m in measurements): - # Use average power over the test period for more accurate estimation - avg_power_32 = sum(abs(m["tab32_power"]) for m in measurements) / len(measurements) - expected_range_32 = avg_power_32 * total_time_hours - # Allow 3x tolerance to account for simulation variations and time-based patterns - assert ( - tab32_total_produced <= expected_range_32 * tolerance_factor - ), f"Tab 32 energy accumulation seems unreasonably high: {tab32_total_produced:.6f}Wh for {expected_range_32:.6f}Wh expected (with {tolerance_factor}x tolerance)" - - print("✅ Longer period energy accumulation test passed!") - - async def test_battery_behavior_invalid_config_type(self) -> None: - """Test battery behavior with invalid config type (line 266).""" - from span_panel_api.simulation import RealisticBehaviorEngine - import time - - # Create a template with invalid battery_behavior (not a dict) - template = { - "energy_profile": { - "mode": "bidirectional", - "power_range": [-3000, 2500], - "typical_power": 0, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - "battery_behavior": "invalid_string_instead_of_dict", # This should trigger line 266 - } - - # Create minimal config for behavior engine - config: dict[str, Any] = { - "panel_config": {"serial_number": "test", "total_tabs": 8, "main_size": 200}, - "circuit_templates": {"test": template}, - "circuits": [], - "unmapped_tabs": [], - "simulation_params": {}, - } - - engine = RealisticBehaviorEngine(time.time(), config) # type: ignore[arg-type] - - # This should return base_power when battery_behavior is not a dict - result = engine.get_circuit_power("test", template, time.time()) # type: ignore[arg-type] - assert isinstance(result, (int, float)) - - async def test_battery_behavior_charge_hours(self) -> None: - """Test battery behavior during charge hours.""" - from span_panel_api.simulation import RealisticBehaviorEngine - from datetime import datetime - import time - - # Test the charge power method directly - battery_config = { - "enabled": True, - "charge_hours": [10, 11, 12, 13, 14], - "discharge_hours": [18, 19, 20, 21], - "idle_hours": [22, 23, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 15, 16, 17], - "max_charge_power": -2000.0, - "max_discharge_power": 1500.0, - } - - # Create a minimal config for the engine - config: dict[str, Any] = { - "panel_config": {"serial_number": "test", "total_tabs": 8, "main_size": 200}, - "circuit_templates": {}, - "circuits": [], - "unmapped_tabs": [], - "simulation_params": {"noise_factor": 0.0}, - } - - engine = RealisticBehaviorEngine(time.time(), config) # type: ignore[arg-type] - - # Test the charge power method directly - charge_power = engine._get_charge_power(battery_config, 12) - expected = abs(-2000.0) * 0.1 # abs(max_charge_power) * default_solar_intensity - assert charge_power == expected - - # Test that during charge hours, battery behavior returns positive power - template = { - "energy_profile": { - "mode": "bidirectional", - "power_range": [-3000, 2500], - "typical_power": 0, - "power_variation": 0.0, - }, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - "battery_behavior": battery_config, - } - - # Create a timestamp for hour 12 (noon) in local time - current_time = time.time() - current_dt = datetime.fromtimestamp(current_time) - # Set to noon today - hour_12_dt = current_dt.replace(hour=12, minute=0, second=0, microsecond=0) - hour_12_time = hour_12_dt.timestamp() - - battery_result = engine._apply_battery_behavior(0.0, template, hour_12_time) # type: ignore[arg-type] - assert battery_result > 0 # Should be positive (charging) - - async def test_battery_behavior_discharge_hours(self) -> None: - """Test battery behavior during discharge hours.""" - from span_panel_api.simulation import RealisticBehaviorEngine - from datetime import datetime - import time - - # Test the discharge power method directly - battery_config = { - "enabled": True, - "charge_hours": [10, 11, 12, 13, 14], - "discharge_hours": [18, 19, 20, 21], - "idle_hours": [22, 23, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 15, 16, 17], - "max_charge_power": -2000.0, - "max_discharge_power": 1500.0, - } - - # Create a minimal config for the engine - config: dict[str, Any] = { - "panel_config": {"serial_number": "test", "total_tabs": 8, "main_size": 200}, - "circuit_templates": {}, - "circuits": [], - "unmapped_tabs": [], - "simulation_params": {"noise_factor": 0.0}, - } - - engine = RealisticBehaviorEngine(time.time(), config) # type: ignore[arg-type] - - # Test the discharge power method directly - discharge_power = engine._get_discharge_power(battery_config, 20) - expected = 1500.0 * 0.3 # max_discharge_power * default_demand_factor - assert discharge_power == expected - - # Test that during discharge hours, battery behavior returns positive power - template = { - "energy_profile": { - "mode": "bidirectional", - "power_range": [-3000, 2500], - "typical_power": 0, - "power_variation": 0.0, - }, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - "battery_behavior": battery_config, - } - - # Create a timestamp for hour 20 (8 PM) in local time - current_time = time.time() - current_dt = datetime.fromtimestamp(current_time) - # Set to 8 PM today - hour_20_dt = current_dt.replace(hour=20, minute=0, second=0, microsecond=0) - hour_20_time = hour_20_dt.timestamp() - - battery_result = engine._apply_battery_behavior(0.0, template, hour_20_time) # type: ignore[arg-type] - assert battery_result > 0 # Should be positive (discharging) - - async def test_battery_behavior_idle_hours(self) -> None: - """Test battery behavior during idle hours.""" - from span_panel_api.simulation import RealisticBehaviorEngine - from datetime import datetime - import time - - # Test the idle power method directly - battery_config = { - "enabled": True, - "charge_hours": [10, 11, 12, 13, 14], - "discharge_hours": [18, 19, 20, 21], - "idle_hours": [22, 23, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 15, 16, 17], - "max_charge_power": -2000.0, - "max_discharge_power": 1500.0, - "idle_power_range": [-50.0, 50.0], # Custom idle range - } - - # Create a minimal config for the engine - config: dict[str, Any] = { - "panel_config": {"serial_number": "test", "total_tabs": 8, "main_size": 200}, - "circuit_templates": {}, - "circuits": [], - "unmapped_tabs": [], - "simulation_params": {"noise_factor": 0.0}, - } - - engine = RealisticBehaviorEngine(time.time(), config) # type: ignore[arg-type] - - # Test the idle power method directly - idle_power = engine._get_idle_power(battery_config) - assert -50.0 <= idle_power <= 50.0 # Within idle power range - - # Test that during idle hours, battery behavior returns small power - template = { - "energy_profile": { - "mode": "bidirectional", - "power_range": [-3000, 2500], - "typical_power": 0, - "power_variation": 0.0, - }, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - "battery_behavior": battery_config, - } - - # Create a timestamp for hour 2 (2 AM) in local time - current_time = time.time() - current_dt = datetime.fromtimestamp(current_time) - # Set to 2 AM today - hour_2_dt = current_dt.replace(hour=2, minute=0, second=0, microsecond=0) - hour_2_time = hour_2_dt.timestamp() - - battery_result = engine._apply_battery_behavior(0.0, template, hour_2_time) # type: ignore[arg-type] - assert -50.0 <= battery_result <= 50.0 # Within idle power range - - async def test_battery_behavior_transition_hours(self) -> None: - """Test battery behavior during transition hours.""" - from span_panel_api.simulation import RealisticBehaviorEngine - import time - - # Test transition hours behavior - simplified to just test the logic - battery_config = { - "enabled": True, - "charge_hours": [10, 11, 12, 13, 14], - "discharge_hours": [18, 19, 20, 21], - "idle_hours": [22, 23, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 15, 16, 17], - "max_charge_power": -2000.0, - "max_discharge_power": 1500.0, - } - - # Create a minimal config for the engine - config: dict[str, Any] = { - "panel_config": {"serial_number": "test", "total_tabs": 8, "main_size": 200}, - "circuit_templates": {}, - "circuits": [], - "unmapped_tabs": [], - "simulation_params": {"noise_factor": 0.0}, - } - - engine = RealisticBehaviorEngine(time.time(), config) # type: ignore[arg-type] - - # Test that transition hours logic exists and works - # We'll test with a time that should fall through to transition logic - template = { - "energy_profile": { - "mode": "bidirectional", - "power_range": [-3000, 2500], - "typical_power": 1000.0, # Base power - "power_variation": 0.0, - }, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - "battery_behavior": battery_config, - } - - # Test with a time that's not in any of the defined hour lists - # Use a time that should trigger the transition logic - current_time = time.time() - # Use a time that's definitely not in the hour lists - # We'll test with a time that should fall through to the transition case - battery_result = engine._apply_battery_behavior(1000.0, template, current_time) # type: ignore[arg-type] - # The result should be some value, not necessarily exactly 100.0 - assert isinstance(battery_result, float) - assert battery_result != 0.0 # Should not be zero - - async def test_solar_intensity_from_config(self) -> None: - """Test solar intensity retrieval from config.""" - from span_panel_api.simulation import RealisticBehaviorEngine - import time - - # Test solar intensity retrieval directly - battery_config = { - "enabled": True, - "charge_hours": [10, 11, 12, 13, 14], - "discharge_hours": [18, 19, 20, 21], - "idle_hours": [22, 23, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 15, 16, 17], - "max_charge_power": -2000.0, - "max_discharge_power": 1500.0, - "solar_intensity_profile": {10: 0.5, 11: 0.7, 12: 1.0, 13: 0.7, 14: 0.5}, - } - - # Create a minimal config for the engine - config: dict[str, Any] = { - "panel_config": {"serial_number": "test", "total_tabs": 8, "main_size": 200}, - "circuit_templates": {}, - "circuits": [], - "unmapped_tabs": [], - "simulation_params": {"noise_factor": 0.0}, - } - - engine = RealisticBehaviorEngine(time.time(), config) # type: ignore[arg-type] - - # Test solar intensity retrieval directly - solar_intensity = engine._get_solar_intensity_from_config(12, battery_config) - assert solar_intensity == 1.0 - - # Test charge power with configured solar intensity - charge_power = engine._get_charge_power(battery_config, 12) - expected = abs(-2000.0) * 1.0 # abs(max_charge_power) * solar_intensity (now positive) - assert charge_power == expected - - async def test_demand_factor_from_config(self) -> None: - """Test demand factor retrieval from config.""" - from span_panel_api.simulation import RealisticBehaviorEngine - import time - - # Test demand factor retrieval directly - battery_config = { - "enabled": True, - "charge_hours": [10, 11, 12, 13, 14], - "discharge_hours": [18, 19, 20, 21], - "idle_hours": [22, 23, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 15, 16, 17], - "max_charge_power": -2000.0, - "max_discharge_power": 1500.0, - "demand_factor_profile": {18: 0.6, 19: 0.8, 20: 1.0, 21: 0.8}, - } - - # Create a minimal config for the engine - config: dict[str, Any] = { - "panel_config": {"serial_number": "test", "total_tabs": 8, "main_size": 200}, - "circuit_templates": {}, - "circuits": [], - "unmapped_tabs": [], - "simulation_params": {"noise_factor": 0.0}, - } - - engine = RealisticBehaviorEngine(time.time(), config) # type: ignore[arg-type] - - # Test demand factor retrieval directly - demand_factor = engine._get_demand_factor_from_config(20, battery_config) - assert demand_factor == 1.0 - - # Test discharge power with configured demand factor - discharge_power = engine._get_discharge_power(battery_config, 20) - expected = 1500.0 * 1.0 # max_discharge_power * demand_factor - assert discharge_power == expected - - async def test_initialization_double_check_lock(self) -> None: - """Test double-check pattern in initialization (lines 362-364).""" - from span_panel_api.simulation import DynamicSimulationEngine - import asyncio - - config_path = Path(__file__).parent.parent / "examples" / "minimal_config.yaml" - engine = DynamicSimulationEngine("double-check-test", config_path=config_path) - - # Initialize first time - await engine.initialize_async() - - # Try to initialize again - should return early due to double-check - await engine.initialize_async() - - # Verify it's still properly initialized - panel_data = await engine.get_panel_data() - assert isinstance(panel_data, dict) - - async def test_config_validation_no_config(self) -> None: - """Test config validation when no config is provided.""" - from span_panel_api.simulation import DynamicSimulationEngine - - engine = DynamicSimulationEngine("no-config-test") - - with pytest.raises(ValueError, match="YAML configuration is required"): - await engine.initialize_async() - - async def test_load_config_async_with_config_data(self) -> None: - """Test loading config with config_data (lines 384-386).""" - from span_panel_api.simulation import DynamicSimulationEngine - - config_data = { - "panel_config": {"serial_number": "config-data-test", "total_tabs": 8, "main_size": 200}, - "circuit_templates": { - "test": { - "energy_profile": { - "mode": "consumer", - "power_range": [0, 1000], - "typical_power": 500, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - } - }, - "circuits": [{"id": "test1", "name": "Test Circuit", "template": "test", "tabs": [1]}], - "unmapped_tabs": [2, 3, 4, 5, 6, 7, 8], - "simulation_params": {}, - } - - engine = DynamicSimulationEngine("config-data-test", config_data=config_data) - await engine.initialize_async() - - # Verify config was loaded - panel_data = await engine.get_panel_data() - assert panel_data["status"]["system"]["serial"] == "config-data-test" - - async def test_load_config_async_with_file_path(self) -> None: - """Test loading config with file path (lines 387-389).""" - from span_panel_api.simulation import DynamicSimulationEngine - - config_path = Path(__file__).parent.parent / "examples" / "minimal_config.yaml" - engine = DynamicSimulationEngine("file-path-test", config_path=config_path) - await engine.initialize_async() - - # Verify config was loaded from file - panel_data = await engine.get_panel_data() - assert isinstance(panel_data, dict) - - async def test_load_config_async_no_config_provided(self) -> None: - """Test loading config when no config is provided (lines 390-393).""" - from span_panel_api.simulation import DynamicSimulationEngine - - engine = DynamicSimulationEngine("no-config-provided-test") - - with pytest.raises(ValueError, match="YAML configuration is required"): - await engine.initialize_async() - - async def test_serial_number_override(self) -> None: - """Test serial number override functionality (lines 395-397).""" - from span_panel_api.simulation import DynamicSimulationEngine - - config_path = Path(__file__).parent.parent / "examples" / "minimal_config.yaml" - engine = DynamicSimulationEngine("override-test", config_path=config_path) - await engine.initialize_async() - - # Verify serial number was overridden - assert engine.serial_number == "override-test" - - async def test_simulation_time_initialization_no_config(self) -> None: - """Test simulation time initialization without config (line 401).""" - from span_panel_api.simulation import DynamicSimulationEngine, SimulationConfigurationError - - engine = DynamicSimulationEngine("no-config-time-test") - - with pytest.raises(SimulationConfigurationError, match="Simulation configuration is required"): - engine._initialize_simulation_time() - - async def test_simulation_time_override_before_init(self) -> None: - """Test simulation time override before initialization (lines 447-450).""" - from span_panel_api.simulation import DynamicSimulationEngine - - engine = DynamicSimulationEngine("override-before-init-test") - - # Override before initialization - engine.override_simulation_start_time("2024-06-15T12:00:00") - - # Should store override for later use - assert engine._simulation_start_time_override == "2024-06-15T12:00:00" - - async def test_simulation_time_override_invalid_format(self) -> None: - """Test simulation time override with invalid format (lines 470-473).""" - from span_panel_api.simulation import DynamicSimulationEngine - - config_path = Path(__file__).parent.parent / "examples" / "minimal_config.yaml" - engine = DynamicSimulationEngine("invalid-time-test", config_path=config_path) - await engine.initialize_async() - - # Override with invalid format - engine.override_simulation_start_time("invalid-datetime-format") - - # Should fall back to real time - assert engine._use_simulation_time is False - - async def test_generate_status_data_no_config(self) -> None: - """Test status data generation without config (lines 788-790).""" - from span_panel_api.simulation import DynamicSimulationEngine - - engine = DynamicSimulationEngine("no-config-status-test") - - # Should return empty dict when no config - status_data = engine._generate_status_data() - assert status_data == {} - - async def test_serial_number_property_no_config(self) -> None: - """Test serial number property without config.""" - from span_panel_api.simulation import DynamicSimulationEngine - - engine = DynamicSimulationEngine("no-config-serial-test") - - # Should return the override serial number when no config is loaded - assert engine.serial_number == "no-config-serial-test" - - # Test without override - should raise error - engine_no_override = DynamicSimulationEngine() - with pytest.raises(ValueError, match="No configuration loaded - serial number not available"): - _ = engine_no_override.serial_number - - async def test_tab_sync_config_no_config(self) -> None: - """Test tab sync config without config (line 1033).""" - from span_panel_api.simulation import DynamicSimulationEngine, SimulationConfigurationError - - engine = DynamicSimulationEngine("no-config-sync-test") - - with pytest.raises(SimulationConfigurationError, match="Simulation configuration is required"): - engine._get_tab_sync_config(1) - - async def test_synchronize_energy_fallback_no_sync_config(self) -> None: - """Test energy synchronization fallback when no sync config (lines 1044-1048).""" - from span_panel_api.simulation import DynamicSimulationEngine - - config_path = Path(__file__).parent.parent / "examples" / "minimal_config.yaml" - engine = DynamicSimulationEngine("sync-fallback-test", config_path=config_path) - await engine.initialize_async() - - # Test with tab that has no sync config - produced, consumed = engine._synchronize_energy_for_tab(1, "test_circuit", 100.0, time.time()) - - # Should fall back to regular energy calculation - assert isinstance(produced, float) - assert isinstance(consumed, float) - - async def test_synchronize_energy_fallback_no_energy_sync(self) -> None: - """Test energy synchronization fallback when energy_sync is False (lines 1050-1052).""" - from span_panel_api.simulation import DynamicSimulationEngine - - # Create config with tab sync but energy_sync disabled - config_data = { - "panel_config": {"serial_number": "sync-test", "total_tabs": 8, "main_size": 200}, - "circuit_templates": { - "test": { - "energy_profile": { - "mode": "consumer", - "power_range": [0, 1000], - "typical_power": 500, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - } - }, - "circuits": [{"id": "test1", "name": "Test Circuit", "template": "test", "tabs": [1, 2]}], - "unmapped_tabs": [3, 4, 5, 6, 7, 8], - "simulation_params": {}, - "tab_synchronizations": [ - { - "tabs": [1, 2], - "behavior": "240v_split_phase", - "power_split": "equal", - "energy_sync": False, # Disabled - "template": "test", - } - ], - } - - engine = DynamicSimulationEngine("sync-energy-fallback-test", config_data=config_data) - await engine.initialize_async() - - # Test with tab that has sync config but energy_sync disabled - produced, consumed = engine._synchronize_energy_for_tab(1, "test_circuit", 100.0, time.time()) - - # Should fall back to regular energy calculation - assert isinstance(produced, float) - assert isinstance(consumed, float) - - async def test_synchronize_energy_fallback_no_sync_group(self) -> None: - """Test energy synchronization fallback when no sync group (lines 1057-1059).""" - from span_panel_api.simulation import DynamicSimulationEngine - - # Create config with tab sync but missing sync group - config_data = { - "panel_config": {"serial_number": "sync-group-test", "total_tabs": 8, "main_size": 200}, - "circuit_templates": { - "test": { - "energy_profile": { - "mode": "consumer", - "power_range": [0, 1000], - "typical_power": 500, - "power_variation": 0.1, - }, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - } - }, - "circuits": [{"id": "test1", "name": "Test Circuit", "template": "test", "tabs": [1]}], - "unmapped_tabs": [2, 3, 4, 5, 6, 7, 8], - "simulation_params": {}, - "tab_synchronizations": [ - { - "tabs": [1, 2], - "behavior": "240v_split_phase", - "power_split": "equal", - "energy_sync": True, - "template": "test", - } - ], - } - - engine = DynamicSimulationEngine("sync-group-fallback-test", config_data=config_data) - await engine.initialize_async() - - # Test with tab that has sync config but no sync group (tab 3 not in sync) - produced, consumed = engine._synchronize_energy_for_tab(3, "test_circuit", 100.0, time.time()) - - # Should fall back to regular energy calculation - assert isinstance(produced, float) - assert isinstance(consumed, float) diff --git a/tests/test_simulation_snapshot.py b/tests/test_simulation_snapshot.py new file mode 100644 index 0000000..0f3d494 --- /dev/null +++ b/tests/test_simulation_snapshot.py @@ -0,0 +1,313 @@ +"""Phase 5: Simulation Engine Snapshot tests. + +Tests cover: +- Snapshot structure: get_snapshot() returns SpanPanelSnapshot with correct types +- Field accuracy: Snapshot fields match values from get_panel_data(), get_status(), get_soe() +- Circuit mapping: All configured circuits appear with correct field values +- Unmapped tab synthesis: Unoccupied tabs synthesized as zero-power circuit entries +- Battery SOE: SOE percentage propagated correctly +- Package exports: DynamicSimulationEngine and SimulationConfig in __init__ +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from span_panel_api import DynamicSimulationEngine +from span_panel_api.simulation import SimulationConfig +from span_panel_api.models import ( + SpanBatterySnapshot, + SpanCircuitSnapshot, + SpanPanelSnapshot, +) + +CONFIG_8TAB = Path(__file__).parent / "fixtures" / "configs" / "simulation_config_8_tab_workshop.yaml" +CONFIG_32 = Path(__file__).parent / "fixtures" / "configs" / "simulation_config_32_circuit.yaml" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _make_engine(config_path: Path = CONFIG_8TAB) -> DynamicSimulationEngine: + """Create and initialize a simulation engine from a YAML config.""" + engine = DynamicSimulationEngine( + serial_number="TEST-PANEL-001", + config_path=config_path, + ) + await engine.initialize_async() + return engine + + +# --------------------------------------------------------------------------- +# Snapshot structure +# --------------------------------------------------------------------------- + + +class TestSnapshotStructure: + """get_snapshot() returns a well-formed SpanPanelSnapshot.""" + + @pytest.mark.asyncio + async def test_returns_span_panel_snapshot(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + assert isinstance(snapshot, SpanPanelSnapshot) + + @pytest.mark.asyncio + async def test_circuits_are_circuit_snapshots(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + assert len(snapshot.circuits) > 0 + for cid, circuit in snapshot.circuits.items(): + assert isinstance(cid, str) + assert isinstance(circuit, SpanCircuitSnapshot) + + @pytest.mark.asyncio + async def test_battery_is_battery_snapshot(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + assert isinstance(snapshot.battery, SpanBatterySnapshot) + + @pytest.mark.asyncio + async def test_snapshot_is_frozen(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + with pytest.raises(AttributeError): + snapshot.serial_number = "mutated" # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Field accuracy — compare snapshot to raw engine outputs +# --------------------------------------------------------------------------- + + +class TestFieldAccuracy: + """Snapshot fields match the raw dict outputs from the engine.""" + + @pytest.mark.asyncio + async def test_serial_number_matches_status(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + status = await engine.get_status() + assert snapshot.serial_number == status["system"]["serial"] + + @pytest.mark.asyncio + async def test_firmware_version_matches_status(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + status = await engine.get_status() + assert snapshot.firmware_version == status["software"]["firmwareVersion"] + + @pytest.mark.asyncio + async def test_door_state_matches_status(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + status = await engine.get_status() + assert snapshot.door_state == status["system"]["doorState"] + + @pytest.mark.asyncio + async def test_network_flags_match_status(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + status = await engine.get_status() + assert snapshot.eth0_link == status["network"]["eth0Link"] + assert snapshot.wlan_link == status["network"]["wlanLink"] + assert snapshot.wwan_link == status["network"]["wwanLink"] + + @pytest.mark.asyncio + async def test_soe_matches_get_soe(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + soe_data = await engine.get_soe() + assert snapshot.battery.soe_percentage == soe_data["soe"]["percentage"] + + @pytest.mark.asyncio + async def test_panel_power_fields(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + assert isinstance(snapshot.instant_grid_power_w, float) + assert isinstance(snapshot.feedthrough_power_w, float) + assert isinstance(snapshot.main_meter_energy_consumed_wh, float) + assert isinstance(snapshot.main_meter_energy_produced_wh, float) + + @pytest.mark.asyncio + async def test_dsm_and_relay_fields(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + assert snapshot.main_relay_state == "CLOSED" + assert snapshot.dsm_grid_state == "DSM_ON_GRID" + assert snapshot.current_run_config == "PANEL_ON_GRID" + + @pytest.mark.asyncio + async def test_v2_native_fields_populated(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + assert snapshot.dominant_power_source == "GRID" + assert snapshot.grid_islandable is False + assert snapshot.l1_voltage == 120.0 + assert snapshot.l2_voltage == 120.0 + assert isinstance(snapshot.main_breaker_rating_a, int) + assert snapshot.wifi_ssid == "SimulatedNetwork" + assert snapshot.vendor_cloud == "CONNECTED" + assert isinstance(snapshot.panel_size, int) + + @pytest.mark.asyncio + async def test_power_flow_fields_populated(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + assert isinstance(snapshot.power_flow_battery, float) + assert isinstance(snapshot.power_flow_site, float) + assert isinstance(snapshot.power_flow_grid, float) + assert isinstance(snapshot.power_flow_pv, float) + + @pytest.mark.asyncio + async def test_lugs_current_fields_populated(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + assert isinstance(snapshot.upstream_l1_current_a, float) + assert isinstance(snapshot.upstream_l2_current_a, float) + + +# --------------------------------------------------------------------------- +# Circuit mapping +# --------------------------------------------------------------------------- + + +class TestCircuitMapping: + """All configured circuits appear in the snapshot with correct fields.""" + + @pytest.mark.asyncio + async def test_all_circuits_present(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + panel_data = await engine.get_panel_data() + raw_circuits = panel_data["circuits"]["circuits"] + # Snapshot includes both configured circuits and unmapped tab entries + assert set(raw_circuits.keys()).issubset(set(snapshot.circuits.keys())) + + @pytest.mark.asyncio + async def test_circuit_field_values(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + panel_data = await engine.get_panel_data() + raw_circuits = panel_data["circuits"]["circuits"] + + for cid, raw in raw_circuits.items(): + circ = snapshot.circuits[cid] + assert circ.circuit_id == raw["id"] + assert circ.name == raw["name"] + assert circ.relay_state == raw["relayState"] + assert circ.tabs == raw["tabs"] + assert circ.priority == raw["priority"] + assert circ.is_user_controllable == raw["isUserControllable"] + assert circ.is_sheddable == raw["isSheddable"] + assert circ.is_never_backup == raw["isNeverBackup"] + + @pytest.mark.asyncio + async def test_circuit_power_types(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + for circ in snapshot.circuits.values(): + assert isinstance(circ.instant_power_w, float) + assert isinstance(circ.produced_energy_wh, float) + assert isinstance(circ.consumed_energy_wh, float) + assert isinstance(circ.energy_accum_update_time_s, int) + assert isinstance(circ.instant_power_update_time_s, int) + + +# --------------------------------------------------------------------------- +# Unmapped tab synthesis +# --------------------------------------------------------------------------- + + +class TestUnmappedTabs: + """Unoccupied tab positions are synthesized as zero-power circuit entries.""" + + @pytest.mark.asyncio + async def test_unmapped_tabs_present(self) -> None: + """8-tab config with fewer circuits should have unmapped entries.""" + engine = await _make_engine(CONFIG_8TAB) + snapshot = await engine.get_snapshot() + unmapped = {cid: c for cid, c in snapshot.circuits.items() if cid.startswith("unmapped_tab_")} + # Should have some unmapped tabs (8 total minus configured circuits) + assert len(unmapped) > 0 + + @pytest.mark.asyncio + async def test_unmapped_tabs_have_zero_power(self) -> None: + engine = await _make_engine(CONFIG_8TAB) + snapshot = await engine.get_snapshot() + for cid, circuit in snapshot.circuits.items(): + if cid.startswith("unmapped_tab_"): + assert circuit.instant_power_w == 0.0 + assert circuit.produced_energy_wh == 0.0 + assert circuit.consumed_energy_wh == 0.0 + + @pytest.mark.asyncio + async def test_unmapped_tabs_not_controllable(self) -> None: + engine = await _make_engine(CONFIG_8TAB) + snapshot = await engine.get_snapshot() + for cid, circuit in snapshot.circuits.items(): + if cid.startswith("unmapped_tab_"): + assert circuit.is_user_controllable is False + assert circuit.is_sheddable is False + assert circuit.is_never_backup is False + + @pytest.mark.asyncio + async def test_total_tabs_covered(self) -> None: + """All tab positions 1..N should be covered by circuits or unmapped entries.""" + engine = await _make_engine(CONFIG_8TAB) + snapshot = await engine.get_snapshot() + all_tabs: set[int] = set() + for circuit in snapshot.circuits.values(): + all_tabs.update(circuit.tabs) + # Should cover all positions up to at least 8 + assert all_tabs.issuperset(set(range(1, 9))) + + +# --------------------------------------------------------------------------- +# Battery SOE +# --------------------------------------------------------------------------- + + +class TestBatterySoe: + """SOE percentage propagated correctly.""" + + @pytest.mark.asyncio + async def test_soe_percentage_is_float(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + assert isinstance(snapshot.battery.soe_percentage, float) + + @pytest.mark.asyncio + async def test_soe_percentage_in_range(self) -> None: + engine = await _make_engine() + snapshot = await engine.get_snapshot() + assert snapshot.battery.soe_percentage is not None + assert 0.0 <= snapshot.battery.soe_percentage <= 100.0 + + +# --------------------------------------------------------------------------- +# Package exports +# --------------------------------------------------------------------------- + + +class TestPackageExports: + """DynamicSimulationEngine and SimulationConfig are importable.""" + + def test_dynamic_simulation_engine_importable(self) -> None: + from span_panel_api import DynamicSimulationEngine + + assert DynamicSimulationEngine is not None + + def test_simulation_config_importable(self) -> None: + from span_panel_api.simulation import SimulationConfig + + assert SimulationConfig is not None + + def test_exports_in_all(self) -> None: + import span_panel_api + + assert "DynamicSimulationEngine" in span_panel_api.__all__ diff --git a/tests/test_simulation_time_override.py b/tests/test_simulation_time_override.py deleted file mode 100644 index b5704ce..0000000 --- a/tests/test_simulation_time_override.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Tests for simulation time override functionality and edge cases.""" - -import pytest -from pathlib import Path -from unittest.mock import patch, MagicMock -from span_panel_api.simulation import DynamicSimulationEngine -from span_panel_api import SpanPanelClient - - -class TestSimulationTimeOverride: - """Test simulation time override functionality and error handling.""" - - @pytest.mark.asyncio - async def test_simulation_time_override_z_suffix_handling(self): - """Test simulation time override with Z suffix handling.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - client = SpanPanelClient( - host="localhost", - simulation_mode=True, - simulation_config_path=str(config_path), - simulation_start_time="2024-06-15T12:00:00Z", # Z suffix - ) - - async with client: - # Trigger initialization to test Z suffix removal (line 497) - await client.get_status() - - # Verify the Z was stripped and simulation initialized - assert client._simulation_initialized is True - assert client._simulation_engine is not None - - @pytest.mark.asyncio - async def test_simulation_time_override_invalid_format(self): - """Test simulation time override with invalid datetime format.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - client = SpanPanelClient( - host="localhost", - simulation_mode=True, - simulation_config_path=str(config_path), - simulation_start_time="invalid-datetime-format", # Invalid format - ) - - async with client: - # Should handle ValueError/TypeError gracefully (lines 504-505) - await client.get_status() - - # Simulation should still initialize but not use simulation time - assert client._simulation_initialized is True - assert client._simulation_engine is not None - - @pytest.mark.asyncio - async def test_simulation_engine_yaml_validation_errors(self): - """Test YAML validation error paths in simulation engine.""" - engine = DynamicSimulationEngine() - - # Test line 517: Invalid config data type - with pytest.raises(ValueError, match="YAML configuration must be a dictionary"): - engine._validate_yaml_config("not_a_dict") - - # Test missing required sections (various lines in validation) - with pytest.raises(ValueError, match="Missing required section: panel_config"): - engine._validate_yaml_config({}) - - with pytest.raises(ValueError, match="Missing required section: circuit_templates"): - engine._validate_yaml_config({"panel_config": {}}) - - with pytest.raises(ValueError, match="Missing required section: circuits"): - engine._validate_yaml_config({"panel_config": {}, "circuit_templates": {}}) - - @pytest.mark.asyncio - async def test_load_yaml_config_file_io_error(self): - """Test _load_yaml_config with file IO errors.""" - engine = DynamicSimulationEngine() - - # Test with non-existent file path (should raise FileNotFoundError) - non_existent_path = Path("/non/existent/file.yaml") - with pytest.raises(FileNotFoundError): - engine._load_yaml_config(non_existent_path) - - @pytest.mark.asyncio - async def test_simulation_engine_missing_config_error(self): - """Test simulation engine initialization without config.""" - # Create engine without config_data or valid config_path - engine = DynamicSimulationEngine() - - with pytest.raises(ValueError, match="YAML configuration is required"): - await engine.initialize_async() - - @pytest.mark.asyncio - async def test_simulation_start_time_override_with_none_config(self): - """Test simulation start time override when config is None.""" - engine = DynamicSimulationEngine() - - # This should handle the case where _config is None - # Should not crash but also not do anything - engine.override_simulation_start_time("2024-06-15T12:00:00") - - # No exception should be raised - - @pytest.mark.asyncio - async def test_time_override_datetime_parsing_edge_cases(self): - """Test datetime parsing edge cases in time override.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - # Test with None as start time (TypeError path) - client = SpanPanelClient( - host="localhost", simulation_mode=True, simulation_config_path=str(config_path), simulation_start_time=None - ) - - # This should not crash - TypeError should be caught - async with client: - await client.get_status() - assert client._simulation_initialized is True - - @pytest.mark.asyncio - async def test_circuit_validation_missing_required_fields(self): - """Test circuit validation with missing required fields.""" - engine = DynamicSimulationEngine() - - # Valid basic structure but missing panel config required fields - config_missing_panel_fields = { - "panel_config": { - "serial_number": "test123", - "manufacturer": "SPAN", - "model": "Gen2", - # Missing "total_tabs" and "main_size" - }, - "circuit_templates": {"basic": {"power_range": [0, 1000]}}, - "circuits": {}, - } - - # Should raise error for missing panel config fields (line 537) - with pytest.raises(ValueError, match="Missing required panel_config field: total_tabs"): - engine._validate_yaml_config(config_missing_panel_fields) - - @pytest.mark.asyncio - async def test_panel_config_validation_missing_fields(self): - """Test panel config validation with missing fields.""" - engine = DynamicSimulationEngine() - - # Test various panel config validation scenarios - incomplete_config = { - "panel_config": { - # Missing required fields - }, - "circuit_templates": {}, - "circuits": {}, - } - - # The validation may pass or fail depending on specific requirements - # This tests the panel config validation path - try: - engine._validate_yaml_config(incomplete_config) - except ValueError: - # Expected if panel config validation is strict - pass diff --git a/tests/test_simulation_missing_coverage.py b/tests/test_simulation_time_profiles.py similarity index 91% rename from tests/test_simulation_missing_coverage.py rename to tests/test_simulation_time_profiles.py index e4a4c26..878f480 100644 --- a/tests/test_simulation_missing_coverage.py +++ b/tests/test_simulation_time_profiles.py @@ -3,10 +3,9 @@ import pytest import time from pathlib import Path -from unittest.mock import Mock, patch from datetime import datetime -from span_panel_api import SpanPanelClient, SimulationConfigurationError +from span_panel_api.exceptions import SimulationConfigurationError from span_panel_api.simulation import DynamicSimulationEngine, RealisticBehaviorEngine @@ -165,7 +164,7 @@ def test_iso_time_parsing_with_z_suffix(self): engine._initialize_simulation_time() # Verify the offset was calculated - assert hasattr(engine, '_simulation_time_offset') + assert hasattr(engine, "_simulation_time_offset") def test_time_parsing_error_handling(self): """Test time parsing error handling (lines 540-541).""" @@ -186,23 +185,6 @@ def test_time_parsing_error_handling(self): class TestAdditionalMissingLines: """Test additional missing coverage lines.""" - @pytest.mark.asyncio - async def test_override_simulation_start_time_exception(self): - """Test override_simulation_start_time exception handling.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_40_circuit_with_battery.yaml" - - async with SpanPanelClient( - host="test-override-exception", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - engine = client._simulation_engine - assert engine is not None - - # Test with invalid time format that will cause exception - engine.override_simulation_start_time("invalid-time-format") - - # Should disable simulation time due to exception - assert not engine._use_simulation_time - @pytest.mark.asyncio async def test_simulation_time_with_acceleration(self): """Test simulation time with acceleration factor.""" diff --git a/tests/test_unmapped_power_verification.py b/tests/test_unmapped_power_verification.py deleted file mode 100644 index 3198b46..0000000 --- a/tests/test_unmapped_power_verification.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Test to verify unmapped tabs show realistic power consumption.""" - -import pytest -from span_panel_api import SpanPanelClient - - -class TestUnmappedTabPowerConsumption: - """Test unmapped tab power consumption behavior.""" - - @pytest.mark.asyncio - async def test_unmapped_tabs_show_realistic_power(self): - """Test that unmapped tabs now show realistic power consumption.""" - config_path = "examples/simulation_config_8_tab_workshop.yaml" - client = SpanPanelClient("test-host", simulation_mode=True, simulation_config_path=config_path) - - # Get circuits data - circuits = await client.get_circuits() - circuit_data = circuits.circuits.additional_properties - - # Get panel state to check branch power - panel_state = await client.get_panel_state() - - print("\n=== Circuit Power ===") - for circuit_id, circuit in circuit_data.items(): - print(f"Circuit {circuit_id} ({circuit.name}): {circuit.instant_power_w:.1f}W") - - print("\n=== Branch Power ===") - for i, branch in enumerate(panel_state.branches, 1): - print(f"Branch {i} (Tab {i}): {branch.instant_power_w:.1f}W") - - # Verify unmapped tabs (5-8) have realistic power based on their configuration - expected_ranges = { - 5: (0.0, 1000.0), # Tab 5: power_range [0.0, 1000.0] - 6: (0.0, 1500.0), # Tab 6: power_range [0.0, 1500.0] - 7: (0.0, 800.0), # Tab 7: power_range [0.0, 800.0] - 8: (0.0, 1200.0), # Tab 8: power_range [0.0, 1200.0] - } - - for tab_num in [5, 6, 7, 8]: - branch = panel_state.branches[tab_num - 1] # 0-indexed - power = branch.instant_power_w - min_power, max_power = expected_ranges[tab_num] - assert ( - min_power <= power <= max_power - ), f"Tab {tab_num} power {power}W not in expected range {min_power}-{max_power}W" - print(f"✓ Unmapped Tab {tab_num}: {power:.1f}W (realistic baseline power)") - - # Verify unmapped circuits also reflect this power - for tab_num in [5, 6, 7, 8]: - circuit_id = f"unmapped_tab_{tab_num}" - circuit = circuit_data[circuit_id] - power = circuit.instant_power_w - min_power, max_power = expected_ranges[tab_num] - assert ( - min_power <= power <= max_power - ), f"Unmapped circuit {circuit_id} power {power}W not in expected range {min_power}-{max_power}W" - print(f"✓ Unmapped Circuit {circuit_id}: {power:.1f}W") - - print("\n✅ All unmapped tabs now show realistic power consumption!") diff --git a/tests/test_unmapped_tabs_specific.py b/tests/test_unmapped_tabs_specific.py deleted file mode 100644 index 3a56058..0000000 --- a/tests/test_unmapped_tabs_specific.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Test unmapped tab creation specific edge cases to achieve 100% coverage.""" - -import pytest -from pathlib import Path -from span_panel_api import SpanPanelClient - - -class TestUnmappedTabSpecificCoverage: - """Test specific unmapped tab creation scenarios.""" - - @pytest.mark.asyncio - async def test_unmapped_tab_creation_specific_lines(self): - """Test unmapped tab creation that hits lines 866-876 in client.py.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - async with SpanPanelClient(host="test", simulation_mode=True, simulation_config_path=str(config_path)) as client: - # Force get circuits to trigger unmapped tab creation - circuits = await client.get_circuits() - - # Check if we have unmapped tabs - unmapped_count = 0 - for circuit_id in circuits.circuits.additional_properties.keys(): - if circuit_id.startswith("unmapped_tab_"): - unmapped_count += 1 - - # The 32-circuit config should have some unmapped tabs - # This should hit the lines in the unmapped tab creation logic - assert circuits is not None - assert len(circuits.circuits.additional_properties) > 0 - - # If there are unmapped tabs, verify they're properly created - if unmapped_count > 0: - for circuit_id, circuit in circuits.circuits.additional_properties.items(): - if circuit_id.startswith("unmapped_tab_"): - # These lines test the unmapped tab creation logic (lines 866-876) - assert circuit.name is not None - # Check power based on tab type - solar tabs produce (negative), others consume (positive) - tab_num = int(circuit_id.split("_")[-1]) - if tab_num in [30, 32]: - # Solar tabs should produce power (positive values) - assert ( - circuit.instant_power_w >= 0 - ), f"Solar tab {tab_num} should produce power: {circuit.instant_power_w}W" - else: - # Other unmapped tabs should consume power (positive values) - assert ( - circuit.instant_power_w >= 0 - ), f"Unmapped tab {tab_num} should consume power: {circuit.instant_power_w}W" - assert hasattr(circuit, "relay_state") - - @pytest.mark.asyncio - async def test_unmapped_tab_edge_case_boundary(self): - """Test unmapped tab creation boundary conditions.""" - config_path = Path(__file__).parent.parent / "examples" / "simulation_config_32_circuit.yaml" - - async with SpanPanelClient( - host="test-boundary", simulation_mode=True, simulation_config_path=str(config_path) - ) as client: - # Get panel state to check branch structure - panel_state = await client.get_panel_state() - - # Get circuits to trigger unmapped tab logic - circuits = await client.get_circuits() - - # Verify the unmapped tab creation handles edge cases properly - assert circuits is not None - - # Check that the unmapped tab creation doesn't create invalid circuits - for circuit_id, circuit in circuits.circuits.additional_properties.items(): - if circuit_id.startswith("unmapped_tab_"): - # Verify tab number is valid - tab_num = int(circuit_id.split("_")[-1]) - assert 1 <= tab_num <= len(panel_state.branches) - - # Verify circuit properties are valid based on tab type - if tab_num in [30, 32]: - # Solar tabs should produce power (positive values) - assert ( - circuit.instant_power_w >= 0 - ), f"Solar tab {tab_num} should produce power: {circuit.instant_power_w}W" - else: - # Other unmapped tabs should consume power (positive values) - assert ( - circuit.instant_power_w >= 0 - ), f"Unmapped tab {tab_num} should consume power: {circuit.instant_power_w}W" - assert circuit.name != "" diff --git a/tests/test_workshop_unmapped_tabs.py b/tests/test_workshop_unmapped_tabs.py deleted file mode 100644 index 1cd2230..0000000 --- a/tests/test_workshop_unmapped_tabs.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Test workshop panel unmapped tab detection with realistic circuits.""" - -import pytest -from span_panel_api import SpanPanelClient - - -@pytest.fixture -def workshop_client(): - """Create client with workshop simulation.""" - config_path = "examples/simulation_config_8_tab_workshop.yaml" - return SpanPanelClient("workshop-host", simulation_mode=True, simulation_config_path=config_path) - - -class TestWorkshopUnmappedTabs: - """Test unmapped tab detection in realistic workshop scenario.""" - - @pytest.mark.asyncio - async def test_workshop_circuit_assignments(self, workshop_client): - """Test that workshop circuits are properly assigned.""" - circuits = await workshop_client.get_circuits() - circuit_data = circuits.circuits.additional_properties - - # Should have 8 circuits total: 4 assigned + 4 unmapped - assert len(circuit_data) == 8 - - # Verify assigned circuits - expected_circuits = { - "workshop_led_lighting": "Workshop LED Lighting", - "table_saw_planer": "Table Saw & Planer", - "power_tool_outlets": "Power Tool Outlets", - "workshop_hvac": "Workshop HVAC", - } - - for circuit_id, expected_name in expected_circuits.items(): - circuit = circuit_data[circuit_id] - assert circuit.name == expected_name - assert circuit.id == circuit_id - - @pytest.mark.asyncio - async def test_workshop_unmapped_tabs_detection(self, workshop_client): - """Test that tabs 5-8 are automatically detected as unmapped.""" - circuits = await workshop_client.get_circuits() - circuit_data = circuits.circuits.additional_properties - - # Find unmapped circuits (tabs 5-8) - unmapped_circuit_ids = [ - cid for cid in circuit_data.keys() if isinstance(cid, str) and cid.startswith("unmapped_tab_") - ] - - # Should have exactly 4 unmapped circuits - assert len(unmapped_circuit_ids) == 4 - - # Verify specific unmapped tabs - expected_unmapped = ["unmapped_tab_5", "unmapped_tab_6", "unmapped_tab_7", "unmapped_tab_8"] - for expected_id in expected_unmapped: - assert expected_id in unmapped_circuit_ids - - # Check circuit details - circuit = circuit_data[expected_id] - expected_name = f"Unmapped Tab {expected_id.split('_')[-1]}" - assert circuit.name == expected_name - assert circuit.id == expected_id - - @pytest.mark.asyncio - async def test_workshop_power_ranges(self, workshop_client): - """Test that workshop circuits have realistic power consumption.""" - circuits = await workshop_client.get_circuits() - circuit_data = circuits.circuits.additional_properties - - # Test power ranges for each circuit type - power_checks = { - "workshop_led_lighting": (20.0, 80.0), # Workshop LED Lighting - "table_saw_planer": (0.0, 3500.0), # Table Saw & Planer - "power_tool_outlets": (0.0, 1800.0), # Power Tool Outlets - "workshop_hvac": (0.0, 2400.0), # Workshop HVAC - } - - for circuit_id, (min_power, max_power) in power_checks.items(): - circuit = circuit_data[circuit_id] - power = circuit.instant_power_w - assert ( - min_power <= power <= max_power - ), f"Circuit {circuit_id} power {power}W outside range {min_power}-{max_power}W" - - @pytest.mark.asyncio - async def test_workshop_panel_branches(self, workshop_client): - """Test that panel state shows all 8 branches.""" - panel_state = await workshop_client.get_panel_state() - - # Should have 8 branches total - assert len(panel_state.branches) == 8 - - # All branches should have valid IDs - for i, branch in enumerate(panel_state.branches, 1): - assert branch.id == f"branch_{i}" - - @pytest.mark.asyncio - async def test_workshop_circuit_control(self, workshop_client): - """Test controlling both assigned and unmapped circuits.""" - circuits = await workshop_client.get_circuits() - circuit_data = circuits.circuits.additional_properties - - # Test controlling an assigned circuit (Table Saw) - table_saw = circuit_data["table_saw_planer"] - original_state = table_saw.relay_state - new_state = "OPEN" if original_state == "CLOSED" else "CLOSED" - - result = await workshop_client.set_circuit_relay("table_saw_planer", new_state) - assert result["status"] == "success" - assert result["circuit_id"] == "table_saw_planer" - assert result["relay_state"] == new_state - - # Test controlling an unmapped circuit - unmapped_circuit = circuit_data["unmapped_tab_5"] - original_state = unmapped_circuit.relay_state - new_state = "OPEN" if original_state == "CLOSED" else "CLOSED" - - result = await workshop_client.set_circuit_relay("unmapped_tab_5", new_state) - assert result["status"] == "success" - assert result["circuit_id"] == "unmapped_tab_5" - assert result["relay_state"] == new_state - - @pytest.mark.asyncio - async def test_workshop_circuit_priorities(self, workshop_client): - """Test that workshop circuits have appropriate priority levels.""" - circuits = await workshop_client.get_circuits() - circuit_data = circuits.circuits.additional_properties - - # Verify circuit priorities match workshop needs - priority_checks = { - "workshop_led_lighting": "MUST_HAVE", # Workshop LED Lighting - essential - "table_saw_planer": "NON_ESSENTIAL", # Table Saw & Planer - can be shed - "power_tool_outlets": "NON_ESSENTIAL", # Power Tool Outlets - can be shed - "workshop_hvac": "NICE_TO_HAVE", # Workshop HVAC - comfort item - } - - for circuit_id, expected_priority in priority_checks.items(): - circuit = circuit_data[circuit_id] - assert circuit.priority.value == expected_priority diff --git a/tests/test_yaml_validation.py b/tests/test_yaml_validation.py index 9ad60ab..93a157e 100644 --- a/tests/test_yaml_validation.py +++ b/tests/test_yaml_validation.py @@ -14,7 +14,7 @@ def test_valid_yaml_config_passes_validation(self): import yaml from pathlib import Path - config_path = Path(__file__).parent.parent / "examples" / "validation_test_config.yaml" + config_path = Path(__file__).parent / "fixtures" / "configs" / "validation_test_config.yaml" with open(config_path) as f: config = yaml.safe_load(f) @@ -60,7 +60,7 @@ def test_invalid_circuits_type_raises_error(self): import yaml from pathlib import Path - config_path = Path(__file__).parent.parent / "examples" / "validation_test_config.yaml" + config_path = Path(__file__).parent / "fixtures" / "configs" / "validation_test_config.yaml" with open(config_path) as f: config = yaml.safe_load(f) @@ -76,7 +76,7 @@ def test_empty_circuits_raises_error(self): import yaml from pathlib import Path - config_path = Path(__file__).parent.parent / "examples" / "validation_test_config.yaml" + config_path = Path(__file__).parent / "fixtures" / "configs" / "validation_test_config.yaml" with open(config_path) as f: config = yaml.safe_load(f) @@ -92,7 +92,7 @@ def test_invalid_circuit_structure_raises_error(self): import yaml from pathlib import Path - config_path = Path(__file__).parent.parent / "examples" / "validation_test_config.yaml" + config_path = Path(__file__).parent / "fixtures" / "configs" / "validation_test_config.yaml" with open(config_path) as f: config = yaml.safe_load(f) @@ -108,7 +108,7 @@ def test_missing_circuit_fields_raises_error(self): import yaml from pathlib import Path - config_path = Path(__file__).parent.parent / "examples" / "validation_test_config.yaml" + config_path = Path(__file__).parent / "fixtures" / "configs" / "validation_test_config.yaml" with open(config_path) as f: config = yaml.safe_load(f) @@ -130,7 +130,7 @@ def test_unknown_template_reference_raises_error(self): import yaml from pathlib import Path - config_path = Path(__file__).parent.parent / "examples" / "validation_test_config.yaml" + config_path = Path(__file__).parent / "fixtures" / "configs" / "validation_test_config.yaml" with open(config_path) as f: config = yaml.safe_load(f)