package ocpp import ( "context" "encoding/json" "fmt" "sync" "time" ) // Control modes. These mirror the cascade field the operator picks in Settings // (see internal/api/integrations_ankersolix.go). const ( ModeOff = "off" // monitoring only; no CSMS (charger keeps its normal backend) ModeOwn = "own" // DriverVault is the charger's Central System ModeProxy = "proxy" // DriverVault forwards to Anker's cloud and taps/injects ) // injectedPrefix namespaces the message ids of CALLs DriverVault injects toward // the charger, so that in proxy mode a reply to one of our calls is never // confused with a reply destined for the upstream CSMS. const injectedPrefix = "dv-" // callTimeout bounds how long a control command waits for the charger to answer. const callTimeout = 30 * time.Second // Status is a point-in-time snapshot of one charger's OCPP state, safe to return // to clients. type Status struct { Serial string `json:"serial"` Mode string `json:"mode"` Connected bool `json:"connected"` Vendor string `json:"vendor,omitempty"` Model string `json:"model,omitempty"` Firmware string `json:"firmware,omitempty"` BootedAt time.Time `json:"bootedAt,omitempty"` LastHeartbeat time.Time `json:"lastHeartbeat,omitempty"` ConnectorStatus string `json:"connectorStatus,omitempty"` // Available, Charging, Faulted, … ErrorCode string `json:"errorCode,omitempty"` MeterWh int64 `json:"meterWh,omitempty"` TransactionID int `json:"transactionId,omitempty"` LastUpdated time.Time `json:"lastUpdated,omitempty"` } // CallError is returned by Session.Call when the charger answers with a CALLERROR. type CallError struct { Code string Description string } func (e *CallError) Error() string { return fmt.Sprintf("ocpp charger rejected call: %s: %s", e.Code, e.Description) } // callHandler answers an inbound CALL from the charger (own mode only). It // returns either a result payload, or a non-empty errCode/errDesc to send a // CALLERROR. type callHandler func(s *Session, action string, payload json.RawMessage) (result any, errCode, errDesc string) // Session is one live charge-point connection. In own mode it also holds the // local dispatch handler; in proxy mode it additionally holds the upstream // connection and pumps frames between the two while tapping the stream. type Session struct { serial string mode string cp *Conn // charge-point (downstream) connection up *Conn // upstream CSMS connection (proxy mode only) handler callHandler onClose func(*Session) logf func(string, ...any) mu sync.Mutex pending map[string]chan Message // our injected call id -> result channel status Status closed bool done chan struct{} nextTxn int // own mode: assigns transaction ids } // newSession builds a session. Call start once it is registered to begin its // read loop(s). In proxy mode up must be non-nil; in own mode handler must be // non-nil. func newSession(serial, mode string, cp, up *Conn, handler callHandler, onClose func(*Session), logf func(string, ...any)) *Session { if logf == nil { logf = func(string, ...any) {} } return &Session{ serial: serial, mode: mode, cp: cp, up: up, handler: handler, onClose: onClose, logf: logf, pending: map[string]chan Message{}, done: make(chan struct{}), status: Status{Serial: serial, Mode: mode, Connected: true}, } } // start launches the read loop(s). Separated from newSession so the CSMS can // register the session before any inbound frame (or an immediate disconnect) can // fire the onClose deregister callback. func (s *Session) start() { go s.cpLoop() if s.mode == ModeProxy && s.up != nil { go s.upLoop() } } // Serial returns the charger serial this session serves. func (s *Session) Serial() string { return s.serial } // Mode returns the control mode (own|proxy). func (s *Session) Mode() string { return s.mode } // Close terminates the session (used when a charger's control token is revoked). func (s *Session) Close() { s.close(nil) } // Snapshot returns the current status. func (s *Session) Snapshot() Status { s.mu.Lock() defer s.mu.Unlock() st := s.status st.Serial = s.serial st.Mode = s.mode return st } // Call injects a CALL toward the charger and waits for its reply. It is how every // control command (commands.go) reaches the charger, in both own and proxy modes. func (s *Session) Call(ctx context.Context, action string, payload any) (json.RawMessage, error) { ctx, cancel := context.WithTimeout(ctx, callTimeout) defer cancel() id := injectedPrefix + newMessageID() frame, err := EncodeCall(id, action, payload) if err != nil { return nil, err } ch := make(chan Message, 1) s.mu.Lock() if s.closed { s.mu.Unlock() return nil, ErrClosed } s.pending[id] = ch s.mu.Unlock() defer func() { s.mu.Lock() delete(s.pending, id) s.mu.Unlock() }() if err := s.cp.WriteMessage(frame); err != nil { return nil, err } select { case <-ctx.Done(): return nil, ctx.Err() case <-s.done: return nil, ErrClosed case m := <-ch: if m.Type == MessageTypeCallError { return nil, &CallError{Code: m.ErrorCode, Description: m.ErrorDescription} } return m.Payload, nil } } // ---- read loops -------------------------------------------------------------- func (s *Session) cpLoop() { defer s.close(nil) for { data, err := s.cp.ReadMessage() if err != nil { s.close(err) return } msg, err := DecodeMessage(data) if err != nil { s.logf("ocpp: bad frame from charger %s: %v", s.serial, err) continue } s.tap(msg) if s.mode == ModeOwn { s.handleLocal(msg) continue } // Proxy: a reply to one of OUR injected calls is consumed locally and not // forwarded upstream; everything else is relayed to the upstream CSMS. if msg.Type != MessageTypeCall && s.isPending(msg.ID) { s.deliver(msg) continue } if s.up == nil || s.up.WriteMessage(data) != nil { s.close(fmt.Errorf("ocpp: upstream write failed for %s", s.serial)) return } } } func (s *Session) upLoop() { for { data, err := s.up.ReadMessage() if err != nil { s.close(err) return } if msg, derr := DecodeMessage(data); derr == nil { s.tap(msg) } if err := s.cp.WriteMessage(data); err != nil { s.close(err) return } } } // handleLocal answers an inbound frame in own mode. func (s *Session) handleLocal(msg Message) { switch msg.Type { case MessageTypeCall: result, code, desc := s.handler(s, msg.Action, msg.Payload) var ( out []byte err error ) if code != "" { out, err = EncodeCallError(msg.ID, code, desc, nil) } else { out, err = EncodeCallResult(msg.ID, result) } if err != nil { s.logf("ocpp: encode response for %s/%s: %v", s.serial, msg.Action, err) return } _ = s.cp.WriteMessage(out) case MessageTypeCallResult, MessageTypeCallError: s.deliver(msg) } } func (s *Session) isPending(id string) bool { s.mu.Lock() _, ok := s.pending[id] s.mu.Unlock() return ok } func (s *Session) deliver(msg Message) { s.mu.Lock() ch := s.pending[msg.ID] delete(s.pending, msg.ID) s.mu.Unlock() if ch != nil { ch <- msg } } // tap updates the status snapshot from charger-originated notifications, so both // own and proxy modes keep a live view without special-casing the dispatch path. func (s *Session) tap(msg Message) { if msg.Type != MessageTypeCall { return } switch msg.Action { case "BootNotification": var p struct { Vendor string `json:"chargePointVendor"` Model string `json:"chargePointModel"` Firmware string `json:"firmwareVersion"` SerialCP string `json:"chargePointSerialNumber"` SerialBox string `json:"chargeBoxSerialNumber"` } _ = json.Unmarshal(msg.Payload, &p) s.mutate(func(st *Status) { st.Vendor, st.Model, st.Firmware = p.Vendor, p.Model, p.Firmware st.BootedAt = time.Now() }) case "Heartbeat": s.mutate(func(st *Status) { st.LastHeartbeat = time.Now() }) case "StatusNotification": var p struct { Status string `json:"status"` ErrorCode string `json:"errorCode"` } _ = json.Unmarshal(msg.Payload, &p) s.mutate(func(st *Status) { st.ConnectorStatus = p.Status if p.ErrorCode != "" && p.ErrorCode != "NoError" { st.ErrorCode = p.ErrorCode } else { st.ErrorCode = "" } }) case "MeterValues": if wh, ok := meterWh(msg.Payload); ok { s.mutate(func(st *Status) { st.MeterWh = wh }) } case "StopTransaction": s.mutate(func(st *Status) { st.TransactionID = 0 }) } } func (s *Session) mutate(f func(*Status)) { s.mu.Lock() f(&s.status) s.status.LastUpdated = time.Now() s.mu.Unlock() } func (s *Session) close(err error) { s.mu.Lock() if s.closed { s.mu.Unlock() return } s.closed = true s.status.Connected = false close(s.done) s.mu.Unlock() _ = s.cp.Close() if s.up != nil { _ = s.up.Close() } if s.onClose != nil { s.onClose(s) } if err != nil && err != ErrClosed { s.logf("ocpp: session %s closed: %v", s.serial, err) } } // meterWh best-effort extracts an Energy.Active.Import.Register reading (in Wh) // from a MeterValues payload. func meterWh(payload json.RawMessage) (int64, bool) { var p struct { MeterValue []struct { SampledValue []struct { Value string `json:"value"` Measurand string `json:"measurand"` Unit string `json:"unit"` } `json:"sampledValue"` } `json:"meterValue"` } if err := json.Unmarshal(payload, &p); err != nil { return 0, false } for _, mv := range p.MeterValue { for _, sv := range mv.SampledValue { // Default measurand per spec is Energy.Active.Import.Register. if sv.Measurand != "" && sv.Measurand != "Energy.Active.Import.Register" { continue } var f float64 if _, err := fmt.Sscanf(sv.Value, "%g", &f); err != nil { continue } if sv.Unit == "kWh" { f *= 1000 } return int64(f), true } } return 0, false }