From c968d68947e71f6b2368ca75afbe4972e97ade09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20He=C3=9F?= Date: Sun, 19 Apr 2026 14:26:07 +0200 Subject: [PATCH] Add domain knowledge docs for AI agents (#29228) --- AGENTS.md | 24 +++ docs/agents/core-domain.md | 147 +++++++++++++++ docs/agents/easee-architecture.md | 269 +++++++++++++++++++++++++++ docs/agents/hardware-integrations.md | 148 +++++++++++++++ docs/agents/plugin-system.md | 61 ++++++ docs/agents/web-ui-api.md | 87 +++++++++ 6 files changed, 736 insertions(+) create mode 100644 docs/agents/core-domain.md create mode 100644 docs/agents/easee-architecture.md create mode 100644 docs/agents/hardware-integrations.md create mode 100644 docs/agents/plugin-system.md create mode 100644 docs/agents/web-ui-api.md diff --git a/AGENTS.md b/AGENTS.md index 75476d4a0..f7a467b3a 100644 --- a/AGENTS.md +++ b/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 - `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 ### Core Components diff --git a/docs/agents/core-domain.md b/docs/agents/core-domain.md new file mode 100644 index 000000000..42ba9ac3f --- /dev/null +++ b/docs/agents/core-domain.md @@ -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 diff --git a/docs/agents/easee-architecture.md b/docs/agents/easee-architecture.md new file mode 100644 index 000000000..1890e5e24 --- /dev/null +++ b/docs/agents/easee-architecture.md @@ -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. diff --git a/docs/agents/hardware-integrations.md b/docs/agents/hardware-integrations.md new file mode 100644 index 000000000..2fddf3fd5 --- /dev/null +++ b/docs/agents/hardware-integrations.md @@ -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. diff --git a/docs/agents/plugin-system.md b/docs/agents/plugin-system.md new file mode 100644 index 000000000..c37a76922 --- /dev/null +++ b/docs/agents/plugin-system.md @@ -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 diff --git a/docs/agents/web-ui-api.md b/docs/agents/web-ui-api.md new file mode 100644 index 000000000..fed6d2331 --- /dev/null +++ b/docs/agents/web-ui-api.md @@ -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 ` 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