269 lines
12 KiB
Markdown
269 lines
12 KiB
Markdown
# Easee Charger Architecture
|
|
|
|
The Easee integration communicates with the Easee cloud over two distinct channels:
|
|
|
|
1. **REST API** (`https://api.easee.com/api`) — synchronous control commands and configuration.
|
|
2. **SignalR WebSocket** (`https://streams.easee.com/hubs/chargers`) — asynchronous real-time state updates and command confirmations.
|
|
|
|
Commands are sent via REST; their acknowledgement and state changes are delivered asynchronously via SignalR.
|
|
|
|
## Authentication
|
|
|
|
### Flow
|
|
|
|
Authentication uses username/password credentials against:
|
|
```
|
|
POST https://api.easee.com/api/accounts/login
|
|
```
|
|
|
|
Returns a `Token` struct with `accessToken` (short-lived JWT), `refreshToken`, `expiresIn`, and `tokenType`.
|
|
|
|
### Token Lifecycle
|
|
|
|
Wrapped in `oauth2.TokenSource` via `oauth.RefreshTokenSource`. Near expiry, automatically calls:
|
|
```
|
|
POST https://api.easee.com/api/accounts/refresh_token
|
|
```
|
|
Falls back to full re-login if refresh fails.
|
|
|
|
### Token Caching
|
|
|
|
`TokenSource` is shared per user via `cache.New[oauth2.TokenSource]()`. Multiple `Easee` instances with the same user email share a single token source, preventing redundant re-authentication.
|
|
|
|
## Initialization Sequence
|
|
|
|
`NewEasee` performs these steps in order:
|
|
|
|
1. **Charger Discovery** — If no serial provided, queries `GET /api/chargers` and expects exactly one charger.
|
|
2. **Site and Circuit Discovery** — `GET /api/chargers/{chargerID}/site`. Searches for a single-charger circuit for circuit-level phase control.
|
|
3. **SignalR Connection** — Creates client with `WithMaxElapsedTime(0)` to retry forever (default 15-min cap would silently stop updates).
|
|
4. **Subscription** — On every `ClientConnected`, sends `SubscribeWithCurrentState(chargerID, true)` to replay full current state before switching to push-on-change.
|
|
5. **Startup Gate** — Blocks until `CHARGER_OP_MODE` is received (one-shot `sync.OnceFunc`).
|
|
6. **Optional State Wait** — Waits up to 3s for `SESSION_ENERGY`, `LIFETIME_ENERGY`, `TOTAL_POWER`. WARN if missing but initialization succeeds.
|
|
|
|
## SignalR Back-Channel
|
|
|
|
### Why SignalR is Required
|
|
|
|
1. **Commands are fire-and-forget at HTTP level.** HTTP response only confirms cloud received the request. Success/failure arrives via SignalR `CommandResponse`.
|
|
2. **State is event-driven, not pollable.** No REST endpoint streams charger state.
|
|
3. **Ticks correlation only works with a live connection.** If SignalR drops mid-command, the waiter times out.
|
|
|
|
### Server -> Client Methods
|
|
|
|
#### `ProductUpdate(json.RawMessage)`
|
|
Primary state channel. Carries a single `Observation` with `ID` (ObservationID), `Value`, `DataType`, and `Timestamp`.
|
|
|
|
- **Timestamp deduplication**: older timestamps for the same ID are silently dropped.
|
|
- **Non-blocking fan-out**: observation sent on `obsC` via non-blocking select.
|
|
|
|
#### `CommandResponse(json.RawMessage)`
|
|
Async acknowledgement for REST commands. Contains `Ticks` (correlation key), `WasAccepted`, `ResultCode`, and `ID` (ObservationID).
|
|
|
|
Routes through three maps in order:
|
|
1. `pendingTicks[res.Ticks]` — primary correlation for async (HTTP 202) commands
|
|
2. `pendingByID[ObservationID(res.ID)]` — fallback when Ticks mismatch
|
|
3. `expectedOrphans[ObservationID(res.ID)]` — counter for sync (HTTP 200) endpoints that still produce a CommandResponse
|
|
|
|
Unmatched responses are logged as WARN (rogue response from external system).
|
|
|
|
#### `ChargerUpdate` / `SubscribeToMyProduct`
|
|
Logged at TRACE, not processed further.
|
|
|
|
## Command Flow and Async Correlation
|
|
|
|
### REST Command Endpoints
|
|
|
|
```
|
|
POST /api/chargers/{chargerID}/commands/{action} (start/stop/pause/resume)
|
|
POST /api/chargers/{chargerID}/settings (enable, DCC, PhaseMode, SmartCharging)
|
|
POST /api/sites/{siteID}/circuits/{circuitID}/settings (dynamic circuit currents)
|
|
```
|
|
|
|
### Response Handling
|
|
|
|
| HTTP Status | Meaning | Behavior |
|
|
|-------------|---------|----------|
|
|
| `200` | Synchronous / already applied | Returns immediately |
|
|
| `202` | Asynchronous, Ticks provided | Waits for matching CommandResponse |
|
|
| other | Error | Returns error |
|
|
|
|
### Ticks Correlation
|
|
|
|
On 202, the body contains `RestCommandResponse` with a `Ticks` field (.NET DateTime.Ticks). If `Ticks == 0`, the command was a no-op.
|
|
|
|
Each in-flight command creates a **buffered channel** (capacity 1), registered in both `pendingTicks` and `pendingByID`, cleaned up via `defer`.
|
|
|
|
### The Sync/Async Mismatch
|
|
|
|
Some endpoints return HTTP `200` but still fire a `CommandResponse` via SignalR. The observed case is circuit settings (`POST /api/sites/{siteID}/circuits/{circuitID}/settings`) which returns `200` but generates `CommandResponse` with `ID=22` (`CIRCUIT_MAX_CURRENT_P1`).
|
|
|
|
Handled via the **expected-orphan counter**:
|
|
```go
|
|
expectedOrphans map[easee.ObservationID]int // protected by cmdMu
|
|
```
|
|
|
|
Before a POST to a known 200-returning endpoint, increment the counter. When `CommandResponse` arrives with no pending match, decrement and silently consume. Counter at 0 means genuinely rogue.
|
|
|
|
## State Management
|
|
|
|
### Internal State Fields (all protected by `sync.RWMutex`)
|
|
|
|
| Field | Observation | Notes |
|
|
|-------|------------|-------|
|
|
| `opMode` | `CHARGER_OP_MODE` (109) | Central state machine |
|
|
| `chargerEnabled` | `IS_ENABLED` (31) | Hardware enable state |
|
|
| `smartCharging` | `SMART_CHARGING` (102) | LED color mode |
|
|
| `currentPower` | `TOTAL_POWER` (120) | Watts (API sends kW, multiplied by 1000) |
|
|
| `sessionEnergy` | `SESSION_ENERGY` (121) | kWh, special zero-handling |
|
|
| `totalEnergy` | `LIFETIME_ENERGY` (124) | kWh, updated ~hourly |
|
|
| `currentL1/L2/L3` | `IN_CURRENT_T3/T4/T5` (183/184/185) | Phase currents in A |
|
|
| `phaseMode` | `PHASE_MODE` (38) | 1=single, 2=auto, 3=locked 3-phase |
|
|
| `dynamicCircuitCurrent[3]` | `DYNAMIC_CIRCUIT_CURRENT_P1/P2/P3` (111/112/113) | Per-phase circuit limit |
|
|
| `maxChargerCurrent` | `MAX_CHARGER_CURRENT` (47) | Hardware max (non-volatile) |
|
|
| `dynamicChargerCurrent` | `DYNAMIC_CHARGER_CURRENT` (48) | Volatile current limit |
|
|
| `reasonForNoCurrent` | `REASON_FOR_NO_CURRENT` (96) | Debug enum |
|
|
| `pilotMode` | `PILOT_MODE` (100) | CP signal state A-F |
|
|
| `rfid` | `USER_IDTOKEN` (128) | Last scanned RFID token |
|
|
|
|
### Session Energy Zero-value Protection
|
|
|
|
`sessionEnergy` is never set to `0` from a `ProductUpdate` — the API sends spurious zeros erratically. Session reset is driven by op-mode transition: when `CHARGER_OP_MODE` transitions from disconnected to awaiting-start, `sessionEnergy` resets to `0` with a fresh timestamp.
|
|
|
|
## Charger Operation Modes
|
|
|
|
```
|
|
0 = Offline — no cloud connection
|
|
1 = Disconnected — no car plugged in
|
|
2 = AwaitingStart — car plugged, waiting for authorization/start
|
|
3 = Charging — actively charging
|
|
4 = Completed — car full or finished, cable still plugged
|
|
5 = Error — fault condition
|
|
6 = ReadyToCharge — ready, current available
|
|
7 = AwaitingAuthentication — RFID auth required
|
|
8 = Deauthenticating — finishing authentication teardown
|
|
```
|
|
|
|
### Mapping to evcc Status
|
|
|
|
| opMode | evcc Status |
|
|
|--------|------------|
|
|
| 1 (Disconnected) | A |
|
|
| 2, 4, 6, 7, 8 | B |
|
|
| 3 (Charging) | C |
|
|
| 0, 5 and others | error |
|
|
|
|
## Enable/Disable Flow
|
|
|
|
### Enable = true
|
|
|
|
1. If `chargerEnabled == false`: POST settings `{ enabled: true }` and wait.
|
|
2. If `opMode == Disconnected`: return (no cable).
|
|
3. If `opMode == AwaitingAuthentication && authorize`: action = `start_charging`.
|
|
4. Otherwise: action = `resume_charging`.
|
|
5. POST `/commands/{action}` and wait.
|
|
6. Wait for `opMode` to reach enabled state.
|
|
7. Wait for `dynamicChargerCurrent` to reach `32` (Easee sets this on resume).
|
|
8. Call `MaxCurrent(c.current)` to restore previous setpoint.
|
|
|
|
### Enable = false
|
|
|
|
1. If disconnected or (awaiting auth && !authorize): return.
|
|
2. POST `/commands/pause_charging` and wait.
|
|
3. Wait for `opMode` to reach disabled state.
|
|
4. Wait for `dynamicChargerCurrent` to reach `0`.
|
|
|
|
### State Waiting Pattern
|
|
|
|
Both `waitForChargerEnabledState` and `waitForDynamicChargerCurrent` use:
|
|
1. Short-circuit check: if already in target state, return immediately.
|
|
2. Open a timer.
|
|
3. Loop on `obsC` channel.
|
|
4. On timer expiry: **one final check** before returning `api.ErrTimeout`.
|
|
|
|
The final check handles the race where the state update arrived between the last channel read and the timer fire.
|
|
|
|
## Phase Control
|
|
|
|
### Circuit-Level (preferred, when circuit is known)
|
|
|
|
Phase switching by zeroing dynamic circuit current on unused phases:
|
|
```
|
|
POST /api/sites/{siteID}/circuits/{circuitID}/settings
|
|
```
|
|
|
|
For 1-phase: set P2=0, P3=0. For 3-phase: restore all three.
|
|
|
|
This POST returns HTTP `200` but still fires a `CommandResponse` with `ID=22` (expected orphan).
|
|
|
|
### Charger-Level (fallback)
|
|
|
|
Uses `PhaseMode` setting: `1` for single-phase, `2` (auto) for 3-phase.
|
|
After changing PhaseMode, `Enable(false)` is called — the loadpoint then re-enables, because PhaseMode changes only take effect after a charging cycle restart.
|
|
|
|
## Authorization Mode (`authorize`)
|
|
|
|
When `authorize: true`, evcc sends `start_charging` to authorize sessions when the charger enters `ModeAwaitingAuthentication`. This enables fully unattended operation but is incompatible with RFID-based vehicle identification.
|
|
|
|
When `authorize: false`, evcc does nothing in mode 7 — the charger waits for external authorization (RFID card or app).
|
|
|
|
Setting `authorize: true` also prevents the charger from auto-starting at 32A on plug-in, giving evcc full control from the first amp.
|
|
|
|
## Concurrency Model
|
|
|
|
### Mutexes
|
|
|
|
| Mutex | Type | Protects |
|
|
|-------|------|----------|
|
|
| `c.mux` | `sync.RWMutex` | All charger state fields |
|
|
| `dispatcher.mu` | `sync.Mutex` | `pendingTicks`, `pendingByID`, `expectedOrphans` maps (inside `CommandDispatcher`) |
|
|
|
|
Command dispatch was extracted into `charger/easee/dispatcher.go` (`CommandDispatcher` struct). The two mutexes are intentionally separate to prevent the SignalR receive loop from blocking on command dispatch operations.
|
|
|
|
### Observation Channel
|
|
|
|
`obsC chan Observation` is unbuffered. `ProductUpdate` sends via non-blocking select — if no waiter is listening, the notification is dropped. The authoritative state is always in the struct fields; the channel is only a notification mechanism.
|
|
|
|
**Design constraint**: any waiter on `obsC` must include a final state check after timer expiry before returning `api.ErrTimeout`.
|
|
|
|
## Known Design Concerns
|
|
|
|
1. **SESSION_ENERGY zero-value protection** — defensive measure based on field observations; root cause unverified.
|
|
2. **LIFETIME_ENERGY** — inaccurate by design, API pushes updates ~hourly.
|
|
3. **current vs dynamicChargerCurrent drift** — evcc's desired setpoint and charger's confirmed value can drift around pause/resume cycles. Resynced via `MaxCurrent(c.current)` after resume.
|
|
4. **Multi-charger circuits** — only circuit-level phase control when charger is alone on its circuit. Multi-charger circuits fall back to less precise charger-level control.
|
|
5. **Stale CommandResponses after reconnect** — if SignalR drops mid-command, the response may arrive after reconnect with no pending entry, triggering a false-positive rogue WARN. Acceptable trade-off.
|
|
|
|
## API Endpoints Summary
|
|
|
|
| Method | Endpoint | Used For |
|
|
|--------|----------|----------|
|
|
| `POST` | `/accounts/login` | Initial authentication |
|
|
| `POST` | `/accounts/refresh_token` | Token refresh |
|
|
| `GET` | `/chargers` | Auto-discover charger ID |
|
|
| `GET` | `/chargers/{id}/site` | Discover site and circuit |
|
|
| `POST` | `/chargers/{id}/settings` | Enable/disable, DCC, PhaseMode, SmartCharging |
|
|
| `POST` | `/chargers/{id}/commands/{action}` | start/stop/pause/resume charging |
|
|
| `GET` | `/sites/{siteId}/circuits/{circuitId}/settings` | Read max circuit currents |
|
|
| `POST` | `/sites/{siteId}/circuits/{circuitId}/settings` | Set dynamic circuit currents (phase switching) |
|
|
|
|
### SignalR Hub
|
|
|
|
| Endpoint | `https://streams.easee.com/hubs/chargers` |
|
|
|----------|------------------------------------------|
|
|
| Client -> Server | `SubscribeWithCurrentState(chargerID, true)` |
|
|
| Server -> Client | `ProductUpdate`, `ChargerUpdate`, `SubscribeToMyProduct`, `CommandResponse` |
|
|
|
|
## Configuration
|
|
|
|
| Parameter | Required | Default | Notes |
|
|
|-----------|----------|---------|-------|
|
|
| `user` | yes | | Easee account email |
|
|
| `password` | yes | | Easee account password |
|
|
| `charger` | no | | Charger serial; auto-detected if exactly one on account |
|
|
| `timeout` | no | `20s` | HTTP timeout for all API calls and command waits |
|
|
| `authorize` | no | `false` | If true, evcc sends `start_charging` to authorize sessions |
|
|
|
|
Supported products: Easee Home, Easee Charge, Easee Charge Lite, Easee Charge Core.
|
|
Declared capabilities: `1p3p` (phase switching), `rfid` (RFID identification).
|
|
Requires evcc sponsorship.
|