Add domain knowledge docs for AI agents (#29228)
This commit is contained in:
parent
cd176a4399
commit
c968d68947
6 changed files with 736 additions and 0 deletions
24
AGENTS.md
24
AGENTS.md
|
|
@ -24,6 +24,30 @@ This file provides guidance to AI coding agents when working with code in this r
|
||||||
- `evcc --template-type [type] --template [file]` - test device templates
|
- `evcc --template-type [type] --template [file]` - test device templates
|
||||||
- `make docs` - generate template documentation
|
- `make docs` - generate template documentation
|
||||||
|
|
||||||
|
## Domain Knowledge
|
||||||
|
|
||||||
|
Deep documentation on specific subsystems is available in `docs/agents/`. Load what you need based on the task:
|
||||||
|
|
||||||
|
| File | When to load |
|
||||||
|
|------|-------------|
|
||||||
|
| [Core Domain](docs/agents/core-domain.md) | Control loop, loadpoint logic, PV surplus, charge modes, tariffs, interfaces |
|
||||||
|
| [Hardware Integrations](docs/agents/hardware-integrations.md) | Charger/meter/vehicle implementations, adding new devices |
|
||||||
|
| [Easee Architecture](docs/agents/easee-architecture.md) | Easee charger (REST+SignalR, async correlation, concurrency) |
|
||||||
|
| [Plugin System](docs/agents/plugin-system.md) | Plugin layer (HTTP, MQTT, Modbus, SunSpec, JS) |
|
||||||
|
| [Web UI & API](docs/agents/web-ui-api.md) | REST API, WebSocket, Vue frontend, authentication |
|
||||||
|
|
||||||
|
### Loading guide by task type
|
||||||
|
|
||||||
|
- **Charger implementation** — hardware-integrations + core-domain
|
||||||
|
- **Easee charger work** — easee-architecture + core-domain
|
||||||
|
- **Meter implementation** — hardware-integrations + plugin-system
|
||||||
|
- **Vehicle implementation** — hardware-integrations
|
||||||
|
- **UI/frontend work** — web-ui-api
|
||||||
|
- **API endpoint work** — web-ui-api + core-domain
|
||||||
|
- **Config/template work** — plugin-system
|
||||||
|
- **Control loop / charging logic** — core-domain
|
||||||
|
- **Bug in any area** — core-domain + relevant topic file(s)
|
||||||
|
|
||||||
## Architecture Guidelines
|
## Architecture Guidelines
|
||||||
|
|
||||||
### Core Components
|
### Core Components
|
||||||
|
|
|
||||||
147
docs/agents/core-domain.md
Normal file
147
docs/agents/core-domain.md
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
# Core Domain: Site, Loadpoint, and the Control Loop
|
||||||
|
|
||||||
|
## Object Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
Site (orchestrator — core/site.go)
|
||||||
|
├── Meters: Grid, PV[], Battery[], Auxiliary[], External[]
|
||||||
|
├── Tariffs: Grid, FeedIn, CO2, Solar
|
||||||
|
├── Coordinator (vehicle <-> loadpoint assignment)
|
||||||
|
├── Prioritizer (power allocation fairness)
|
||||||
|
└── Loadpoints[] (core/loadpoint.go)
|
||||||
|
├── Charger (api.Charger — hardware controller)
|
||||||
|
├── Vehicle (api.Vehicle — EV battery state via cloud API)
|
||||||
|
├── ChargeMeter (api.Meter — AC power at charger)
|
||||||
|
└── Circuit (optional — electrical domain limits)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Interfaces (api/api.go)
|
||||||
|
|
||||||
|
### Meter
|
||||||
|
- `Meter` — `CurrentPower() (float64, error)` — watts
|
||||||
|
- `MeterEnergy` — `TotalEnergy() (float64, error)` — kWh
|
||||||
|
- `PhaseCurrents` / `PhaseVoltages` / `PhasePowers` — per-phase readings
|
||||||
|
|
||||||
|
### Battery
|
||||||
|
- `Battery` — `Soc() (float64, error)` — 0-100%
|
||||||
|
- `BatteryCapacity` — kWh
|
||||||
|
- `BatteryController` — set charge/discharge/hold mode
|
||||||
|
|
||||||
|
### Charger
|
||||||
|
- `Charger` — `Status()`, `Enabled()`, `Enable(bool)`, `MaxCurrent(int64)`
|
||||||
|
- `ChargerEx` — milliamp-precision current via `MaxCurrentMillis(float64)`
|
||||||
|
- `PhaseSwitcher` — `Phases1p3p(int) error`
|
||||||
|
- `ChargeRater` — `ChargedEnergy() (float64, error)`
|
||||||
|
- `ChargeTimer` — `ChargeDuration() (time.Duration, error)`
|
||||||
|
|
||||||
|
### Vehicle
|
||||||
|
- `Vehicle` — `Soc()`, `Capacity()`, `Identifiers()`, `Phases()`, `OnIdentified()`
|
||||||
|
- `VehicleRange`, `VehicleOdometer`, `VehicleClimater`, `VehicleFinishTimer`, `VehiclePosition`
|
||||||
|
- `ChargeController` — remote start/stop on vehicle
|
||||||
|
- `CurrentLimiter` — `GetMinMaxCurrent()` for vehicle-side current limits
|
||||||
|
- `CurrentController` — some vehicles (Tesla, Fiat) also implement `MaxCurrent()` to set charge current from the vehicle side
|
||||||
|
|
||||||
|
## Charge Modes
|
||||||
|
|
||||||
|
| Mode | Behavior |
|
||||||
|
|------|----------|
|
||||||
|
| `OFF` | Disabled (unless welcome charge) |
|
||||||
|
| `NOW` | Max current immediately |
|
||||||
|
| `MINPV` | Min current when PV surplus; fast if cheap tariff |
|
||||||
|
| `PV` | Ramp current proportional to available solar |
|
||||||
|
|
||||||
|
## Charge States (IEC 61851)
|
||||||
|
|
||||||
|
- `A` — not connected
|
||||||
|
- `B` — connected, not charging
|
||||||
|
- `C` — connected, charging
|
||||||
|
|
||||||
|
## The Control Loop (Site.update — runs every N seconds)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Update all meters (grid, PV, battery, aux)
|
||||||
|
2. For each loadpoint: UpdateChargePowerAndCurrents()
|
||||||
|
3. Calculate site power balance:
|
||||||
|
sitePower = gridPower + batteryPower + excessDCPower
|
||||||
|
+ residualPower - auxPower - flexiblePower
|
||||||
|
4. Apply battery priority rules (prioritySoc, bufferSoc)
|
||||||
|
5. Get tariff rates
|
||||||
|
6. For EACH loadpoint: Update(sitePower, ...)
|
||||||
|
├── Read charger status
|
||||||
|
├── Detect/identify vehicle
|
||||||
|
├── Check plan requirements (minSOC, target time)
|
||||||
|
├── Check limits (limitSOC, limitEnergy)
|
||||||
|
├── MODE switch -> calculate target current
|
||||||
|
├── Cap at maxCurrent, respect circuit limits
|
||||||
|
├── Send MaxCurrent() to charger
|
||||||
|
└── Record metrics
|
||||||
|
7. Push updates to WebSocket + metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
The loop is stateless per cycle: always re-reads actual state, calculates
|
||||||
|
optimal current, sends single command. Resilient to restarts and missed updates.
|
||||||
|
|
||||||
|
## PV Surplus Charging (pvMaxCurrent in core/loadpoint.go)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Read effective min/max current limits
|
||||||
|
2. Reduce sitePower by battery boost power
|
||||||
|
3. Consider phase switching (1p <-> 3p) if supported
|
||||||
|
4. deltaCurrent = powerToCurrent(-sitePower, activePhases)
|
||||||
|
targetCurrent = effectiveCurrent + deltaCurrent
|
||||||
|
5. Below minCurrent -> start disable timer (default 3 min)
|
||||||
|
6. Surplus returns -> start enable timer (default 1 min)
|
||||||
|
7. Cap at maxCurrent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Battery Priority Rules
|
||||||
|
|
||||||
|
| Setting | Effect |
|
||||||
|
|---------|--------|
|
||||||
|
| `prioritySoc` | Below this: battery charges first, EV gets 0 |
|
||||||
|
| `bufferSoc` | Above this: EV can draw from battery reserves |
|
||||||
|
| `bufferStartSoc` | Above this: EV charging can begin even if importing |
|
||||||
|
|
||||||
|
## Effective Price Calculation
|
||||||
|
|
||||||
|
```
|
||||||
|
greenShare = (max(pvPower,0) + max(batteryPower,0)) / totalChargePower
|
||||||
|
effectivePrice = gridPrice * (1 - greenShare) + feedInPrice * greenShare
|
||||||
|
```
|
||||||
|
|
||||||
|
## Concurrency Model
|
||||||
|
|
||||||
|
- **Site** owns `RWMutex` for its state (meters, battery, tariffs)
|
||||||
|
- **Loadpoint** owns `RWMutex` for its state (charger, vehicle, current)
|
||||||
|
- **Coordinator** owns `RWMutex` for vehicle <-> loadpoint tracking
|
||||||
|
- No global locks — ordering prevents deadlocks
|
||||||
|
|
||||||
|
### Channels
|
||||||
|
|
||||||
|
| Channel | Scope | Buffer | Purpose |
|
||||||
|
|---------|-------|--------|---------|
|
||||||
|
| `valueChan` | Site | Unbounded (`chanx.NewUnboundedChan`) | State changes -> DB + UI (ordering) |
|
||||||
|
| `lpUpdateChan` | Site | 1 | Early loadpoint update requests |
|
||||||
|
| `pushChan` | Loadpoint | Buffered | User notifications |
|
||||||
|
|
||||||
|
## Tariff Integration
|
||||||
|
|
||||||
|
Types: `TariffUsageGrid`, `TariffUsageFeedIn`, `TariffUsageCo2`, `TariffUsagePlanner`, `TariffUsageSolar`
|
||||||
|
|
||||||
|
### Smart Features
|
||||||
|
- **Cheap-tariff override** — rate below threshold -> fast charge
|
||||||
|
- **Smart feed-in** — feed-in rate above threshold -> prioritize export
|
||||||
|
- **Planner** (`core/planner/planner.go`) — finds cheapest time slots for target SOC/energy by deadline
|
||||||
|
- `optimalPlan()` — cheapest non-contiguous slots
|
||||||
|
- `continuousPlan()` — cheapest continuous window (fallback)
|
||||||
|
|
||||||
|
## Key File Locations
|
||||||
|
|
||||||
|
- `api/api.go` — all core interfaces
|
||||||
|
- `core/site.go` — Site orchestrator + control loop
|
||||||
|
- `core/loadpoint.go` — Loadpoint state machine (pvMaxCurrent, mode switch)
|
||||||
|
- `core/site_battery.go` — battery priority logic
|
||||||
|
- `core/site_tariffs.go` — tariff integration
|
||||||
|
- `core/planner/planner.go` — charge time optimization
|
||||||
|
- `core/prioritizer/prioritizer.go` — power allocation across loadpoints
|
||||||
|
- `core/circuit/circuit.go` — electrical domain limits
|
||||||
269
docs/agents/easee-architecture.md
Normal file
269
docs/agents/easee-architecture.md
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
# 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.
|
||||||
148
docs/agents/hardware-integrations.md
Normal file
148
docs/agents/hardware-integrations.md
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
# Hardware Integrations: Chargers, Meters, Vehicles
|
||||||
|
|
||||||
|
## Integration Pattern
|
||||||
|
|
||||||
|
All device types use a registry-based factory pattern:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Self-registration in init()
|
||||||
|
func init() {
|
||||||
|
registry.AddCtx("typename", NewFromConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFromConfig(ctx context.Context, other map[string]interface{}) (api.Charger, error) {
|
||||||
|
// Parse config, create client, return implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional interfaces are added via the decorator pattern:
|
||||||
|
```go
|
||||||
|
//go:generate decorate -f decorateXxx -b *Xxx -t "api.PhaseSwitcher,Phases1p3p,func(int) error"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Charger Implementations
|
||||||
|
|
||||||
|
### By Protocol
|
||||||
|
|
||||||
|
| Protocol | Examples |
|
||||||
|
|----------|---------|
|
||||||
|
| HTTP/REST | Easee (REST+SignalR), Wallbox, go-e, OpenWB, Shelly |
|
||||||
|
| Modbus RTU/TCP | KEBA, Wallbe, CFOS, Bender, Delta, Mennekes |
|
||||||
|
| OCPP 1.6 | Generic charge point server |
|
||||||
|
| EEBus/ISO 15118 | EEBus SPINE protocol |
|
||||||
|
| UDP/Custom | KEBA UDP, OpenEVSE, Wattpilot, NRGKick |
|
||||||
|
| MQTT | OpenWB, Tasmota, Shelly |
|
||||||
|
| Smart Socket | Shelly, Tapo, TP-Link, FritzDECT |
|
||||||
|
|
||||||
|
### Required Charger Interface
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Charger interface {
|
||||||
|
ChargeState
|
||||||
|
Enabled() (bool, error)
|
||||||
|
Enable(enable bool) error
|
||||||
|
CurrentController
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `ChargeState` provides `Status() (ChargeStatus, error)` (A/B/C) and
|
||||||
|
`CurrentController` provides `MaxCurrent(current int64) error`.
|
||||||
|
|
||||||
|
### Optional Charger Interfaces
|
||||||
|
|
||||||
|
- `ChargerEx` — `MaxCurrentMillis(float64)` for milliamp precision
|
||||||
|
- `PhaseSwitcher` — `Phases1p3p(int)` to switch 1p/3p
|
||||||
|
- `Meter` / `MeterEnergy` — built-in power/energy measurement
|
||||||
|
- `PhaseCurrents` / `PhaseVoltages` — per-phase readings
|
||||||
|
- `ChargeRater` — `ChargedEnergy()` for session energy
|
||||||
|
- `ChargeTimer` — `ChargeDuration()` for session time
|
||||||
|
- `Identifier` — `Identify()` for RFID/vehicle identification
|
||||||
|
|
||||||
|
### Key Implementations
|
||||||
|
|
||||||
|
- **Easee** — REST + async SignalR; see `docs/agents/easee-architecture.md` for full detail
|
||||||
|
- **OCPP** (`charger/ocpp.go`) — Full 1.6 with charge point management
|
||||||
|
- **go-e** — Dual API (v1 local HTTP, v2 cloud); phase switching on v2
|
||||||
|
- **EEBus** — Complex SPINE protocol with USE cases (CEM, EV, EVCC)
|
||||||
|
- **Generic configurable** (`charger/charger.go`) — plugin-driven via YAML template
|
||||||
|
|
||||||
|
### Adding a New Charger
|
||||||
|
|
||||||
|
1. Create `charger/xxx.go` (or YAML template in `templates/definition/charger/`)
|
||||||
|
2. Implement `NewXxxFromConfig()` returning `api.Charger`
|
||||||
|
3. Required: `Status()`, `Enabled()`, `Enable()`, `MaxCurrent()`
|
||||||
|
4. Register: `registry.AddCtx("xxx", factory)` in `init()`
|
||||||
|
5. Optional: add decorator for PhaseSwitcher, Meter, etc.
|
||||||
|
6. Add template: `templates/definition/charger/xxx.yaml` for UI metadata
|
||||||
|
|
||||||
|
## Meter Implementations
|
||||||
|
|
||||||
|
### By Category
|
||||||
|
|
||||||
|
| Category | Examples |
|
||||||
|
|----------|---------|
|
||||||
|
| Modbus/SunSpec | SDM630, SMA, Fronius, Victron |
|
||||||
|
| HTTP/REST | Homewizard, Shelly Gen3, E3DC |
|
||||||
|
| Smart Home | HomeAssistant entities, Homematic |
|
||||||
|
| Battery/Storage | Tesla Powerwall, LG ESS, Zendure |
|
||||||
|
|
||||||
|
### Required Meter Interface
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Meter interface {
|
||||||
|
CurrentPower() (float64, error) // watts
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional: `MeterEnergy`, `PhaseCurrents/Voltages/Powers`, `Battery`, `BatteryCapacity`
|
||||||
|
|
||||||
|
### Key Implementations
|
||||||
|
- **mbmd** (`meter/mbmd.go`) — RS485 device library with auto-detection
|
||||||
|
- **SunSpec** (`plugin/sunspec.go`) — Modbus model-based point queries
|
||||||
|
- **Generic** — plugin-driven (HTTP, Modbus, MQTT sources)
|
||||||
|
|
||||||
|
## Vehicle Integrations
|
||||||
|
|
||||||
|
| Manufacturer | API Type |
|
||||||
|
|-------------|----------|
|
||||||
|
| Tesla | Fleet API + vehicle-command proxy |
|
||||||
|
| VW Group | WeConnect (VW, Audi, Skoda, Seat, Cupra) |
|
||||||
|
| Hyundai/Kia | BlueLink (regional variants) |
|
||||||
|
| BMW/Mini | ConnectedDrive v2 |
|
||||||
|
| Mercedes | Official API |
|
||||||
|
| Renault/Nissan | Renault API + Carwings |
|
||||||
|
| Ford | FordConnect (US/EU) |
|
||||||
|
| Porsche | Porsche Connect |
|
||||||
|
| PSA Group | Peugeot, Citroen, DS, Opel |
|
||||||
|
| Generic | OVMS, Tronity |
|
||||||
|
|
||||||
|
### Required Vehicle Interface
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Vehicle interface {
|
||||||
|
Battery // Soc() (float64, error)
|
||||||
|
BatteryCapacity // Capacity() float64
|
||||||
|
IconDescriber // Icon() string
|
||||||
|
FeatureDescriber // Features() []Feature
|
||||||
|
PhaseDescriber // Phases() int
|
||||||
|
TitleDescriber // GetTitle() string
|
||||||
|
SetTitle(string)
|
||||||
|
Identifiers() []string
|
||||||
|
OnIdentified() ActionConfig
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional: `SocLimiter`, `ChargeState`, `VehicleRange`, `VehicleOdometer`, `VehicleClimater`, `VehicleFinishTimer`, `VehiclePosition`, `CurrentLimiter`, `CurrentController`, `ChargeController`, `Resurrector`
|
||||||
|
|
||||||
|
### Polling Strategy
|
||||||
|
|
||||||
|
Configurable: always / while charging / while connected. Interval-based caching
|
||||||
|
to avoid excessive cloud API calls. OAuth2 token handling built into each provider.
|
||||||
|
|
||||||
|
## Auto-Detection (`cmd/detect/`)
|
||||||
|
|
||||||
|
Task-based parallel IP scanning:
|
||||||
|
`ping` -> `tcp_http` -> `tcp_modbus` -> `sunspec` -> device-specific probes
|
||||||
|
|
||||||
|
Detects: OpenWB, SMA, KEBA, E3DC, Sonnen, Tesla Powerwall, Wallbe, Fronius,
|
||||||
|
Tasmota, Shelly, Phoenix, and many more.
|
||||||
61
docs/agents/plugin-system.md
Normal file
61
docs/agents/plugin-system.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Plugin System
|
||||||
|
|
||||||
|
The plugin system (`plugin/`) provides protocol-level abstraction for device
|
||||||
|
communication. Plugins implement typed getter/setter interfaces and are composed
|
||||||
|
into charger, meter, or vehicle implementations via configuration.
|
||||||
|
|
||||||
|
## Plugin Types
|
||||||
|
|
||||||
|
| Plugin | Protocol | Key Config |
|
||||||
|
|--------|----------|------------|
|
||||||
|
| `http` | HTTP/REST | `uri`, `method`, `headers`, `auth`, `cache`, `timeout` |
|
||||||
|
| `mqtt` | MQTT | `topic`, `retained`, `payload` template, `timeout` |
|
||||||
|
| `modbus` | Modbus TCP/RTU | `uri`, `register`, `scale`, `baudrate`, `rtu` |
|
||||||
|
| `sunspec` | SunSpec/Modbus | Model-based point queries via device tree |
|
||||||
|
| `js` | JavaScript/WASM | Inline script evaluation |
|
||||||
|
| `go` | Go runtime | Dynamic Go code |
|
||||||
|
| `gpio` | Linux GPIO | Digital I/O for relays |
|
||||||
|
|
||||||
|
## Getter/Setter Interfaces
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StringGetter func() (string, error)
|
||||||
|
type FloatGetter func() (float64, error)
|
||||||
|
type IntGetter func() (int64, error)
|
||||||
|
type BoolGetter func() (bool, error)
|
||||||
|
// + corresponding Setter types
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pipeline Transforms
|
||||||
|
|
||||||
|
Plugins support chained transforms: `scale`, `offset`, `lookup`, `regex`.
|
||||||
|
|
||||||
|
## Template-Based Device Configuration
|
||||||
|
|
||||||
|
Devices can be defined entirely via YAML templates using plugins:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# templates/definition/charger/example.yaml
|
||||||
|
status:
|
||||||
|
source: http
|
||||||
|
uri: http://{{ .host }}/status
|
||||||
|
enable:
|
||||||
|
source: http
|
||||||
|
uri: http://{{ .host }}/enable
|
||||||
|
method: POST
|
||||||
|
maxcurrent:
|
||||||
|
source: http
|
||||||
|
uri: http://{{ .host }}/current/{{ .maxcurrent }}
|
||||||
|
```
|
||||||
|
|
||||||
|
The generic configurable charger (`charger/charger.go`) wires these plugin
|
||||||
|
configs into the `api.Charger` interface at runtime.
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
- `plugin/config.go` — plugin registry and config types
|
||||||
|
- `plugin/http.go` — HTTP plugin
|
||||||
|
- `plugin/mqtt.go` — MQTT plugin
|
||||||
|
- `plugin/modbus.go` — Modbus plugin
|
||||||
|
- `plugin/sunspec.go` — SunSpec plugin
|
||||||
|
- `charger/charger.go` — generic configurable charger using plugins
|
||||||
87
docs/agents/web-ui-api.md
Normal file
87
docs/agents/web-ui-api.md
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Web UI & REST API
|
||||||
|
|
||||||
|
## Server Architecture
|
||||||
|
|
||||||
|
- **Router:** gorilla/mux, strict slash
|
||||||
|
- **Middleware:** GZIP, CORS (`*`), ETag caching, request logging, JSON headers, JWT auth
|
||||||
|
- **Timeouts:** Read 5s, Write 10s, Idle 120s
|
||||||
|
- **Static assets:** embedded in binary (`fs.FS`)
|
||||||
|
- **Default port:** 7070
|
||||||
|
|
||||||
|
## REST API (base `/api/`)
|
||||||
|
|
||||||
|
### Site-level
|
||||||
|
- `POST /buffersoc/{value}`, `/prioritysoc/{value}`, `/residualpower/{value}` etc.
|
||||||
|
- `GET /tariff/{tariff}` — tariff rates
|
||||||
|
- `GET /sessions` — charging history
|
||||||
|
- `GET /state` — complete system state (supports jq filtering)
|
||||||
|
|
||||||
|
### Per-loadpoint (`/loadpoints/{id}/...`)
|
||||||
|
- `POST mode/{value}` — off/now/minpv/pv
|
||||||
|
- `POST limitsoc/{value}`, `limitenergy/{value}` — charge limits
|
||||||
|
- `POST mincurrent/{value}`, `maxcurrent/{value}` — current limits
|
||||||
|
- `POST phases/{value}` — phase config
|
||||||
|
- `POST priority/{value}`, `batteryboost/{value}`
|
||||||
|
- `POST plan/energy/{value}/{time}` — schedule plan
|
||||||
|
- `POST vehicle/{name}` — select vehicle
|
||||||
|
- `POST smartcostlimit/{value}` — smart cost threshold
|
||||||
|
|
||||||
|
### Configuration (`/config/...`, auth required)
|
||||||
|
- CRUD for devices (chargers, meters, vehicles, tariffs)
|
||||||
|
- Template browsing and testing
|
||||||
|
- Site, loadpoint, circuit, HEMS, messaging config
|
||||||
|
- `GET /config/evcc.yaml` — YAML export
|
||||||
|
|
||||||
|
### System (`/system/...`, auth required)
|
||||||
|
- Log viewing, cache clear, DB backup/restore/reset, shutdown
|
||||||
|
|
||||||
|
### Handler Pattern
|
||||||
|
Generic `handler[T]` with type conversion, setter, getter.
|
||||||
|
Specialized: `floatHandler`, `intHandler`, `boolHandler`, `durationHandler`.
|
||||||
|
|
||||||
|
## WebSocket (`/ws`)
|
||||||
|
|
||||||
|
- `coder/websocket` (RFC 6455)
|
||||||
|
- Pub/sub via `SocketHub`
|
||||||
|
- Buffered channels (1024 per subscriber)
|
||||||
|
- Welcome message with full state snapshot
|
||||||
|
- Incremental updates as JSON key-value pairs with dot-notation keys:
|
||||||
|
```json
|
||||||
|
{"loadpoints.1.mode": "solar", "site.gridPower": 1234}
|
||||||
|
```
|
||||||
|
- Write timeout: 10s, compression (disabled for Safari)
|
||||||
|
|
||||||
|
## State Flow
|
||||||
|
|
||||||
|
1. WS connects -> receives welcome with full state
|
||||||
|
2. App emits `util.Param` on changes
|
||||||
|
3. Hub broadcasts to subscribers
|
||||||
|
4. Frontend `store.update(msg)` merges via dot-notation
|
||||||
|
5. Components reactively re-render
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
- JWT, 90-day lifetime
|
||||||
|
- HttpOnly cookie (`auth`) with `SameSite=Strict`
|
||||||
|
- Also accepts `Authorization: Bearer <token>` header
|
||||||
|
- Modes: Disabled, Locked (demo), Configured (password)
|
||||||
|
- Protects `/api/config` and `/api/system`
|
||||||
|
|
||||||
|
## MQTT Integration
|
||||||
|
|
||||||
|
- Publishes state changes to configurable broker
|
||||||
|
- Subscribes to control topics
|
||||||
|
- Retained messages for state persistence
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
- `server/http.go` — router setup
|
||||||
|
- `server/http_auth.go` — authentication
|
||||||
|
- `server/http_site_handler.go` — state + request handlers
|
||||||
|
- `server/http_config_*.go` — config endpoints
|
||||||
|
- `server/http_loadpoint_handler.go` — per-loadpoint endpoints
|
||||||
|
- `server/socket.go` — WebSocket pub/sub
|
||||||
|
- `assets/js/app.ts` — Vue app entry
|
||||||
|
- `assets/js/store.ts` — reactive state store
|
||||||
|
- `assets/js/api.ts` — Axios clients
|
||||||
|
- `assets/js/router.ts` — route definitions
|
||||||
Loading…
Add table
Add a link
Reference in a new issue