Skip to content

Latest commit

Β 

History

History
269 lines (189 loc) Β· 12.5 KB

File metadata and controls

269 lines (189 loc) Β· 12.5 KB

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:

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.