The Easee integration communicates with the Easee cloud over two distinct channels:
- REST API (
https://api.easee.com/api) β synchronous control commands and configuration. - 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 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.
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.
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.
NewEasee performs these steps in order:
- Charger Discovery β If no serial provided, queries
GET /api/chargersand expects exactly one charger. - Site and Circuit Discovery β
GET /api/chargers/{chargerID}/site. Searches for a single-charger circuit for circuit-level phase control. - SignalR Connection β Creates client with
WithMaxElapsedTime(0)to retry forever (default 15-min cap would silently stop updates). - Subscription β On every
ClientConnected, sendsSubscribeWithCurrentState(chargerID, true)to replay full current state before switching to push-on-change. - Startup Gate β Blocks until
CHARGER_OP_MODEis received (one-shotsync.OnceFunc). - Optional State Wait β Waits up to 3s for
SESSION_ENERGY,LIFETIME_ENERGY,TOTAL_POWER. WARN if missing but initialization succeeds.
- Commands are fire-and-forget at HTTP level. HTTP response only confirms cloud received the request. Success/failure arrives via SignalR
CommandResponse. - State is event-driven, not pollable. No REST endpoint streams charger state.
- Ticks correlation only works with a live connection. If SignalR drops mid-command, the waiter times out.
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
obsCvia non-blocking select.
Async acknowledgement for REST commands. Contains Ticks (correlation key), WasAccepted, ResultCode, and ID (ObservationID).
Routes through three maps in order:
pendingTicks[res.Ticks]β primary correlation for async (HTTP 202) commandspendingByID[ObservationID(res.ID)]β fallback when Ticks mismatchexpectedOrphans[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).
Logged at TRACE, not processed further.
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)
| HTTP Status | Meaning | Behavior |
|---|---|---|
200 |
Synchronous / already applied | Returns immediately |
202 |
Asynchronous, Ticks provided | Waits for matching CommandResponse |
| other | Error | Returns error |
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.
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 cmdMuBefore 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.
| 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 |
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.
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
| opMode | evcc Status |
|---|---|
| 1 (Disconnected) | A |
| 2, 4, 6, 7, 8 | B |
| 3 (Charging) | C |
| 0, 5 and others | error |
- If
chargerEnabled == false: POST settings{ enabled: true }and wait. - If
opMode == Disconnected: return (no cable). - If
opMode == AwaitingAuthentication && authorize: action =start_charging. - Otherwise: action =
resume_charging. - POST
/commands/{action}and wait. - Wait for
opModeto reach enabled state. - Wait for
dynamicChargerCurrentto reach32(Easee sets this on resume). - Call
MaxCurrent(c.current)to restore previous setpoint.
- If disconnected or (awaiting auth && !authorize): return.
- POST
/commands/pause_chargingand wait. - Wait for
opModeto reach disabled state. - Wait for
dynamicChargerCurrentto reach0.
Both waitForChargerEnabledState and waitForDynamicChargerCurrent use:
- Short-circuit check: if already in target state, return immediately.
- Open a timer.
- Loop on
obsCchannel. - 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 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).
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.
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.
| 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.
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.
- SESSION_ENERGY zero-value protection β defensive measure based on field observations; root cause unverified.
- LIFETIME_ENERGY β inaccurate by design, API pushes updates ~hourly.
- 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. - 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.
- 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.
| 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) |
| Endpoint | https://streams.easee.com/hubs/chargers |
|---|---|
| Client -> Server | SubscribeWithCurrentState(chargerID, true) |
| Server -> Client | ProductUpdate, ChargerUpdate, SubscribeToMyProduct, CommandResponse |
| 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.