Skip to content

Commit 706661f

Browse files
authored
Merge pull request #101 from SpanPanel/grpc_addition
Add Gen3 gRPC transport layer
2 parents dd14aae + 9b979b5 commit 706661f

20 files changed

Lines changed: 3138 additions & 637 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
44

55
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).
66

7+
## [1.1.15] - 2/19/2026
8+
9+
### Added
10+
11+
- **Gen3 gRPC transport** (`grpc/` subpackage): `SpanGrpcClient` connects to Gen3 panels (MAIN40 / MLO48) on port 50065 via manual protobuf encoding. Supports push-streaming via `Subscribe` RPC with registered callbacks. No authentication required. Thanks
12+
to @Griswoldlabs for the Gen3 implementation (PR #169 in `SpanPanel/span`).
13+
- **Protocol abstraction**: `SpanPanelClientProtocol` and capability-mixin protocols (`AuthCapableProtocol`, `CircuitControlProtocol`, `StreamingCapableProtocol`, etc.) provide static type-safe dispatch across transports.
14+
- **`PanelCapability` flags**: Runtime advertisement of transport features. Gen2 advertises `GEN2_FULL`; Gen3 advertises `GEN3_INITIAL` (`PUSH_STREAMING` only).
15+
- **Unified snapshot model**: `SpanPanelSnapshot` and `SpanCircuitSnapshot` are returned by `get_snapshot()` on both transports. Gen2- and Gen3-only fields are `None` where not applicable.
16+
- **`create_span_client()` factory** (`factory.py`): Creates the appropriate client by generation or auto-detects by probing Gen2 HTTP then Gen3 gRPC.
17+
- **Circuit IID mapping fix**: `_parse_instances()` now collects trait-16 and trait-26 IIDs independently, deduplicates and sorts both lists, and pairs them by position. A `_metric_iid_to_circuit` reverse map enables O(1) streaming lookup. Replaces the
18+
hardcoded `METRIC_IID_OFFSET` assumption that failed on MLO48 panels.
19+
- **gRPC exception classes**: `SpanPanelGrpcError`, `SpanPanelGrpcConnectionError`.
20+
- **`grpcio` optional dependency**: Install with `span-panel-api[grpc]` for Gen3 support.
21+
722
## [1.1.14] - 12/2025
823

924
### Fixed in v1.1.14

README.md

Lines changed: 48 additions & 513 deletions
Large diffs are not rendered by default.

docs/Dev/grpc-transport-design.md

Lines changed: 375 additions & 0 deletions
Large diffs are not rendered by default.

docs/development.md

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# Development Guide
2+
3+
## Prerequisites
4+
5+
- Python 3.12 or 3.13
6+
- [Poetry](https://python-poetry.org/) for dependency management
7+
8+
## Setup
9+
10+
```bash
11+
git clone <repository-url>
12+
cd span-panel-api
13+
14+
# Activate the Poetry-managed environment
15+
eval "$(poetry env activate)"
16+
17+
# Install all dependencies including dev extras
18+
poetry install
19+
20+
# Install pre-commit hooks
21+
poetry run pre-commit install
22+
```
23+
24+
## Running Tests
25+
26+
```bash
27+
# Full test suite
28+
poetry run pytest
29+
30+
# With verbose output
31+
poetry run pytest -v
32+
33+
# Specific test file
34+
poetry run pytest tests/test_core_client.py -v
35+
36+
# With coverage
37+
poetry run pytest --cov=span_panel_api tests/
38+
39+
# Generate HTML coverage report
40+
python scripts/coverage.py --full
41+
42+
# Check coverage meets the threshold
43+
python scripts/coverage.py --check --threshold 90
44+
```
45+
46+
## Code Quality
47+
48+
```bash
49+
# Run all pre-commit hooks on all files (lint, format, type-check, security)
50+
poetry run pre-commit run --all-files
51+
52+
# Lint only
53+
poetry run ruff check src/span_panel_api/
54+
55+
# Format code
56+
poetry run ruff format src/span_panel_api/
57+
58+
# Type checking
59+
poetry run mypy src/span_panel_api/
60+
61+
# Security audit
62+
poetry run bandit -c pyproject.toml -r src/span_panel_api/
63+
```
64+
65+
## Project Structure
66+
67+
```text
68+
span-panel-api/
69+
├── src/span_panel_api/ # Main library
70+
│ ├── __init__.py # Public API surface
71+
│ ├── client.py # SpanPanelClient — Gen2 REST client
72+
│ ├── factory.py # create_span_client — auto-detect factory
73+
│ ├── protocol.py # Protocol definitions for type-safe dispatch
74+
│ ├── models.py # Transport-agnostic data models
75+
│ ├── simulation.py # Simulation engine (Gen2 only)
76+
│ ├── exceptions.py # Exception hierarchy
77+
│ ├── const.py # HTTP status constants
78+
│ ├── phase_validation.py # Solar / phase utilities
79+
│ ├── generated_client/ # Auto-generated OpenAPI client (do not edit)
80+
│ └── grpc/ # Gen3 gRPC client
81+
│ ├── client.py # SpanGrpcClient
82+
│ ├── models.py # Low-level gRPC data models
83+
│ └── const.py # gRPC constants (port, trait IDs, etc.)
84+
├── tests/ # Test suite
85+
│ ├── test_core_client.py
86+
│ ├── test_context_manager.py
87+
│ ├── test_cache_functionality.py
88+
│ ├── test_enhanced_circuits.py
89+
│ ├── test_simulation_mode.py
90+
│ ├── test_factories.py
91+
│ ├── conftest.py
92+
│ └── simulation_fixtures/ # Pre-recorded API response fixtures
93+
├── examples/ # Example scripts and simulation configs
94+
├── scripts/ # Developer utility scripts
95+
├── docs/ # This documentation
96+
├── openapi.json # SPAN Panel OpenAPI specification (Gen2)
97+
└── pyproject.toml # Poetry / project configuration
98+
```
99+
100+
## Updating the Gen2 OpenAPI Client
101+
102+
The `generated_client/` directory is auto-generated from `openapi.json`. Do not edit it manually.
103+
104+
1. Obtain a fresh `openapi.json` from a live panel:
105+
106+
```text
107+
GET http://<panel-ip>/api/v1/openapi.json
108+
```
109+
110+
2. Replace `openapi.json` in the repo root.
111+
112+
3. Regenerate:
113+
114+
```bash
115+
poetry run python generate_client.py
116+
```
117+
118+
4. Update `src/span_panel_api/client.py` if the API surface changed.
119+
120+
5. Add or update tests for any changed behaviour.
121+
122+
## Gen3 gRPC Development
123+
124+
The Gen3 client uses manual protobuf encoding/decoding to avoid generated stubs, keeping the dependency surface to the single optional `grpcio` package.
125+
126+
Key files:
127+
128+
- `grpc/client.py``SpanGrpcClient` implementation, protobuf helpers, metric decoders
129+
- `grpc/models.py``CircuitInfo`, `CircuitMetrics`, `PanelData`
130+
- `grpc/const.py` — port number, trait IDs, product identifiers
131+
132+
The gRPC client connects to `TraitHandlerService` at port 50065 and uses three RPC methods:
133+
134+
| RPC | Purpose |
135+
| -------------- | -------------------------------- |
136+
| `GetInstances` | Discover circuit trait instances |
137+
| `GetRevision` | Fetch circuit names by trait IID |
138+
| `Subscribe` | Stream real-time power metrics |
139+
140+
## Adding a New Feature
141+
142+
1. If adding a new API capability, update `PanelCapability` in `models.py`.
143+
2. If adding a new method to both transports, add it to the appropriate `Protocol` in `protocol.py`.
144+
3. Add type hints and docstrings to all new public functions and classes.
145+
4. Write tests covering the new code (target > 80% coverage for new code).
146+
5. Update the relevant `docs/` page.
147+
148+
## Release Process
149+
150+
Versioning follows [Semantic Versioning](https://semver.org/).
151+
152+
1. Update `__version__` in `src/span_panel_api/__init__.py`.
153+
2. Update `CHANGELOG.md`.
154+
3. Run the full test suite and pre-commit hooks.
155+
4. Tag and push — CI will publish to PyPI automatically.

docs/error-handling.md

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# Error Handling and Retry
2+
3+
## Exception Hierarchy
4+
5+
All exceptions inherit from `SpanPanelError`.
6+
7+
```text
8+
SpanPanelError
9+
├── SpanPanelAuthError — authentication failures (401, 403)
10+
├── SpanPanelConnectionError — network errors or unreachable panel
11+
├── SpanPanelTimeoutError — request timeout
12+
├── SpanPanelValidationError — invalid input or schema mismatch
13+
├── SpanPanelAPIError — general API error (catch-all for HTTP errors)
14+
├── SpanPanelRetriableError — transient server errors (502, 503, 504)
15+
├── SpanPanelServerError — non-retriable server error (500)
16+
├── SpanPanelGrpcError — base for Gen3 gRPC errors
17+
│ └── SpanPanelGrpcConnectionError — Gen3 connection failure
18+
└── SimulationConfigurationError — invalid simulation config (simulation mode only)
19+
```
20+
21+
### Import
22+
23+
```python
24+
from span_panel_api import (
25+
SpanPanelError,
26+
SpanPanelAuthError,
27+
SpanPanelConnectionError,
28+
SpanPanelTimeoutError,
29+
SpanPanelValidationError,
30+
SpanPanelAPIError,
31+
SpanPanelRetriableError,
32+
SpanPanelServerError,
33+
SpanPanelGrpcError,
34+
SpanPanelGrpcConnectionError,
35+
SimulationConfigurationError,
36+
)
37+
```
38+
39+
## HTTP Error → Exception Mapping (Gen2)
40+
41+
| HTTP Status | Exception | Retriable | Action |
42+
| ------------------ | ------------------------------ | -------------------- | ------------------------------ |
43+
| 401, 403 | `SpanPanelAuthError` | Once (after re-auth) | Re-authenticate then retry |
44+
| 500 | `SpanPanelServerError` | No | Check server; report issue |
45+
| 502, 503, 504 | `SpanPanelRetriableError` | Yes | Retry with exponential backoff |
46+
| 404, 400, etc. | `SpanPanelAPIError` | Case-by-case | Check request parameters |
47+
| Timeout | `SpanPanelTimeoutError` | Yes | Retry with backoff |
48+
| Validation failure | `SpanPanelValidationError` | No | Fix input data |
49+
| Simulation config | `SimulationConfigurationError` | No | Fix simulation config file |
50+
51+
The underlying HTTP client is configured with `raise_on_unexpected_status=True`, so unexpected status codes are never silently ignored.
52+
53+
## Handling Errors in Practice
54+
55+
```python
56+
from span_panel_api import (
57+
SpanPanelAuthError,
58+
SpanPanelRetriableError,
59+
SpanPanelTimeoutError,
60+
SpanPanelValidationError,
61+
SpanPanelAPIError,
62+
)
63+
64+
async def fetch_circuits(client):
65+
try:
66+
return await client.get_circuits()
67+
except SpanPanelAuthError:
68+
# Token expired or not yet authenticated — re-auth and retry once
69+
await client.authenticate("my-app", "My Application")
70+
return await client.get_circuits()
71+
except SpanPanelRetriableError as exc:
72+
# Temporary server overload — let retry logic or coordinator handle this
73+
logger.warning("Transient server error, will retry: %s", exc)
74+
raise
75+
except SpanPanelTimeoutError as exc:
76+
# Network too slow — retry after backoff
77+
logger.warning("Request timed out: %s", exc)
78+
raise
79+
except SpanPanelValidationError as exc:
80+
# Unexpected response structure — not retriable
81+
logger.error("Validation error: %s", exc)
82+
raise
83+
except SpanPanelAPIError as exc:
84+
# Any other API error
85+
logger.error("API error: %s", exc)
86+
raise
87+
```
88+
89+
## Retry Configuration (Gen2)
90+
91+
Configure retries on the client to handle transient network issues automatically:
92+
93+
```python
94+
from span_panel_api import SpanPanelClient
95+
96+
client = SpanPanelClient(
97+
"192.168.1.100",
98+
timeout=10.0,
99+
retries=3, # 3 retries → up to 4 total attempts
100+
retry_timeout=0.5, # initial delay before first retry
101+
retry_backoff_multiplier=2.0, # delays: 0.5s, 1.0s, 2.0s
102+
)
103+
```
104+
105+
Only `SpanPanelRetriableError` and `SpanPanelTimeoutError` trigger automatic retries. `SpanPanelAuthError` and `SpanPanelValidationError` are not retried automatically.
106+
107+
### Retry Attempt Count
108+
109+
| `retries` | Total attempts |
110+
| ----------- | -------------- |
111+
| 0 (default) | 1 |
112+
| 1 | 2 |
113+
| 2 | 3 |
114+
| 3 | 4 |
115+
116+
Settings can be changed at runtime:
117+
118+
```python
119+
client.retries = 2
120+
client.retry_timeout = 1.0
121+
client.retry_backoff_multiplier = 1.5
122+
```
123+
124+
## Gen3 gRPC Errors
125+
126+
Gen3 errors use a separate, simpler hierarchy since gRPC does not use HTTP status codes:
127+
128+
```python
129+
from span_panel_api import SpanPanelGrpcError, SpanPanelGrpcConnectionError
130+
131+
try:
132+
await client.connect()
133+
snapshot = await client.get_snapshot()
134+
except SpanPanelGrpcConnectionError as exc:
135+
# Panel unreachable or gRPC channel failed
136+
logger.error("Gen3 connection failed: %s", exc)
137+
except SpanPanelGrpcError as exc:
138+
# Other gRPC-level errors
139+
logger.error("Gen3 gRPC error: %s", exc)
140+
```
141+
142+
Gen3 does not have built-in retry logic — reconnect handling should be implemented at the integration layer (e.g., the Home Assistant coordinator).

0 commit comments

Comments
 (0)