437 lines
12 KiB
Go
437 lines
12 KiB
Go
package eebus
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"dario.cat/mergo"
|
|
eebusapi "github.com/enbility/eebus-go/api"
|
|
service "github.com/enbility/eebus-go/service"
|
|
ucapi "github.com/enbility/eebus-go/usecases/api"
|
|
"github.com/enbility/eebus-go/usecases/cem/evcc"
|
|
"github.com/enbility/eebus-go/usecases/cem/evcem"
|
|
"github.com/enbility/eebus-go/usecases/cem/evsecc"
|
|
"github.com/enbility/eebus-go/usecases/cem/evsoc"
|
|
"github.com/enbility/eebus-go/usecases/cem/opev"
|
|
"github.com/enbility/eebus-go/usecases/cem/oscev"
|
|
csplc "github.com/enbility/eebus-go/usecases/cs/lpc"
|
|
cslpp "github.com/enbility/eebus-go/usecases/cs/lpp"
|
|
eglpc "github.com/enbility/eebus-go/usecases/eg/lpc"
|
|
eglpp "github.com/enbility/eebus-go/usecases/eg/lpp"
|
|
"github.com/enbility/eebus-go/usecases/ma/mgcp"
|
|
"github.com/enbility/eebus-go/usecases/ma/mpc"
|
|
shipapi "github.com/enbility/ship-go/api"
|
|
"github.com/enbility/ship-go/mdns"
|
|
shiputil "github.com/enbility/ship-go/util"
|
|
spineapi "github.com/enbility/spine-go/api"
|
|
"github.com/enbility/spine-go/model"
|
|
"github.com/enbility/spine-go/spine"
|
|
"github.com/evcc-io/evcc/util"
|
|
)
|
|
|
|
type Device interface {
|
|
Connect(connected bool)
|
|
UseCaseEvent(device spineapi.DeviceRemoteInterface, entity spineapi.EntityRemoteInterface, event eebusapi.EventType)
|
|
}
|
|
|
|
// Customer Energy Management
|
|
type CustomerEnergyManagement struct {
|
|
EvseCC ucapi.CemEVSECCInterface
|
|
EvCC ucapi.CemEVCCInterface
|
|
EvCem ucapi.CemEVCEMInterface
|
|
EvSoc ucapi.CemEVSOCInterface
|
|
OpEV ucapi.CemOPEVInterface
|
|
OscEV ucapi.CemOSCEVInterface
|
|
}
|
|
|
|
// Controllable System
|
|
type ControllableSystem struct {
|
|
ucapi.CsLPCInterface
|
|
ucapi.CsLPPInterface
|
|
}
|
|
|
|
// Monitoring Appliance
|
|
type MonitoringAppliance struct {
|
|
ucapi.MaMGCPInterface
|
|
ucapi.MaMPCInterface
|
|
}
|
|
|
|
// Energy Guard
|
|
type EnergyGuard struct {
|
|
ucapi.EgLPCInterface
|
|
ucapi.EgLPPInterface
|
|
}
|
|
|
|
type EEBus struct {
|
|
service eebusapi.ServiceInterface
|
|
remoteServices []shipapi.RemoteMdnsService
|
|
|
|
cem CustomerEnergyManagement
|
|
cs ControllableSystem
|
|
ma MonitoringAppliance
|
|
eg EnergyGuard
|
|
|
|
mux sync.Mutex
|
|
log *util.Logger
|
|
|
|
ski string
|
|
|
|
clients map[string][]Device
|
|
}
|
|
|
|
var Instance *EEBus
|
|
|
|
func GetStatus() any {
|
|
return struct {
|
|
Ski string `json:"ski"`
|
|
QR string `json:"qr,omitempty"`
|
|
}{
|
|
Ski: Ski(),
|
|
QR: qrCode(),
|
|
}
|
|
}
|
|
|
|
// qrCode returns the SHIP installation QR code text per EEBus SHIP installation
|
|
// requirements, or empty if unavailable
|
|
func qrCode() string {
|
|
if Instance == nil {
|
|
return ""
|
|
}
|
|
qr, err := Instance.service.QRCodeText()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return qr
|
|
}
|
|
|
|
func NewServer(other Config) (*EEBus, error) {
|
|
cc := Config{
|
|
Port: 4712,
|
|
}
|
|
|
|
if err := mergo.Merge(&cc, other, mergo.WithOverride); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
serial := cc.ShipID
|
|
if serial == "" {
|
|
serial = createShipID()
|
|
}
|
|
|
|
certificate, err := tls.X509KeyPair([]byte(cc.Certificate.Public), []byte(cc.Certificate.Private))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
configuration, err := eebusapi.NewConfiguration(
|
|
BrandName, BrandName, Model, serial,
|
|
[]shipapi.DeviceCategoryType{shipapi.DeviceCategoryTypeEnergyManagementSystem},
|
|
model.DeviceTypeTypeEnergyManagementSystem,
|
|
[]model.EntityTypeType{model.EntityTypeTypeCEM},
|
|
cc.Port, certificate, time.Second*4,
|
|
// no SHIP Pairing (and thus no ring buffer persistence): remote services are
|
|
// trusted by their configured SKI via RegisterRemoteService
|
|
nil, nil,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// use avahi if available, otherwise use go based zeroconf
|
|
configuration.SetMdnsProviderSelection(mdns.MdnsProviderSelectionAll)
|
|
|
|
// for backward compatibility
|
|
configuration.SetAlternateMdnsServiceName(DeviceCode)
|
|
configuration.SetAlternateIdentifier(serial)
|
|
configuration.SetInterfaces(cc.Interfaces)
|
|
|
|
ski, err := SkiFromCert(certificate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c := &EEBus{
|
|
log: util.NewLogger("eebus"),
|
|
ski: ski,
|
|
clients: make(map[string][]Device),
|
|
}
|
|
|
|
c.service = service.NewService(configuration, c)
|
|
c.service.SetLogging(c)
|
|
if err := c.service.Setup(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
localDevice := c.service.LocalDevice()
|
|
|
|
{
|
|
// CEM entity for for connected EVSE and Meters
|
|
localEntity := localDevice.Entity([]model.AddressEntityType{1})
|
|
|
|
// customer energy management to EVSE
|
|
c.cem = CustomerEnergyManagement{
|
|
EvseCC: evsecc.NewEVSECC(localEntity, c.ucCallback),
|
|
EvCC: evcc.NewEVCC(c.service, localEntity, c.ucCallback),
|
|
EvCem: evcem.NewEVCEM(c.service, localEntity, c.ucCallback),
|
|
OpEV: opev.NewOPEV(localEntity, c.ucCallback),
|
|
OscEV: oscev.NewOSCEV(localEntity, c.ucCallback),
|
|
EvSoc: evsoc.NewEVSOC(localEntity, c.ucCallback),
|
|
}
|
|
|
|
// monitoring appliance to meters
|
|
c.ma = MonitoringAppliance{
|
|
MaMGCPInterface: mgcp.NewMGCP(localEntity, c.ucCallback),
|
|
MaMPCInterface: mpc.NewMPC(localEntity, c.ucCallback),
|
|
}
|
|
}
|
|
|
|
{
|
|
// CEM entity for connected SMGW
|
|
// LPC/LPP use a 60s heartbeat timeout, but some EVSE devices have then issues when not set to 4s right now even though they should not connect to this one anyway
|
|
localEntity := spine.NewEntityLocal(localDevice, model.EntityTypeTypeCEM, []model.AddressEntityType{2}, time.Second*4)
|
|
localDevice.AddEntity(localEntity)
|
|
|
|
// controllable system
|
|
c.cs = ControllableSystem{
|
|
CsLPCInterface: csplc.NewLPC(localEntity, c.ucCallback),
|
|
CsLPPInterface: cslpp.NewLPP(localEntity, c.ucCallback),
|
|
}
|
|
}
|
|
|
|
{
|
|
// GridGuard entity for connected Controllable Systems
|
|
// LPC/LPP use a 60s heartbeat timeout, but some EVSE devices have then issues when not set to 4s right now
|
|
localEntity := spine.NewEntityLocal(localDevice, model.EntityTypeTypeGridGuard, []model.AddressEntityType{3}, time.Second*4)
|
|
localDevice.AddEntity(localEntity)
|
|
|
|
// energy guard
|
|
c.eg = EnergyGuard{
|
|
EgLPCInterface: eglpc.NewLPC(localEntity, c.ucCallback),
|
|
EgLPPInterface: eglpp.NewLPP(localEntity, c.ucCallback),
|
|
}
|
|
}
|
|
|
|
// register use cases
|
|
for _, uc := range []eebusapi.UseCaseInterface{
|
|
c.cem.EvseCC, c.cem.EvCC,
|
|
c.cem.EvCem, c.cem.OpEV,
|
|
c.cem.OscEV, c.cem.EvSoc,
|
|
c.cs.CsLPCInterface, c.cs.CsLPPInterface,
|
|
c.ma.MaMGCPInterface, c.ma.MaMPCInterface,
|
|
c.eg.EgLPCInterface, c.eg.EgLPPInterface,
|
|
} {
|
|
c.service.AddUseCase(uc)
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
func (c *EEBus) RegisterDevice(ski, ip string, device Device) error {
|
|
ski = shiputil.NormalizeSKI(ski)
|
|
c.log.TRACE.Printf("registering ski: %s", ski)
|
|
|
|
if ski == c.ski {
|
|
return errors.New("device ski can not be identical to host ski")
|
|
}
|
|
|
|
identity := shipapi.NewServiceIdentity(ski, "", "")
|
|
if len(ip) > 0 {
|
|
identity.IPv4 = ip
|
|
}
|
|
c.service.RegisterRemoteService(identity)
|
|
|
|
c.mux.Lock()
|
|
defer c.mux.Unlock()
|
|
c.clients[ski] = append(c.clients[ski], device)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *EEBus) UnregisterDevice(ski string, device Device) {
|
|
ski = shiputil.NormalizeSKI(ski)
|
|
c.log.TRACE.Printf("unregistering ski: %s", ski)
|
|
|
|
c.mux.Lock()
|
|
if idx := slices.Index(c.clients[ski], device); idx != -1 {
|
|
c.clients[ski] = slices.Delete(c.clients[ski], idx, idx+1)
|
|
|
|
if len(c.clients[ski]) == 0 {
|
|
delete(c.clients, ski)
|
|
|
|
// Tear down the SHIP session after releasing the mutex: ship-go's CloseConnection
|
|
// on a non-Complete state synchronously invokes HandleConnectionClosed,
|
|
// which calls back into evcc's connect(ski, false) — and that needs to
|
|
// acquire c.mux. Holding c.mux across this cross-layer call would
|
|
// deadlock the same goroutine on its own non-reentrant mutex. See #28942.
|
|
defer c.service.UnregisterRemoteService(shipapi.NewServiceIdentity(ski, "", ""))
|
|
}
|
|
}
|
|
c.mux.Unlock()
|
|
}
|
|
|
|
func (c *EEBus) CustomerEnergyManagement() *CustomerEnergyManagement {
|
|
return &c.cem
|
|
}
|
|
|
|
func (c *EEBus) ControllableSystem() *ControllableSystem {
|
|
return &c.cs
|
|
}
|
|
|
|
func (c *EEBus) MonitoringAppliance() *MonitoringAppliance {
|
|
return &c.ma
|
|
}
|
|
|
|
func (c *EEBus) EnergyGuard() *EnergyGuard {
|
|
return &c.eg
|
|
}
|
|
|
|
func (c *EEBus) RemoteServices() []shipapi.RemoteMdnsService {
|
|
c.mux.Lock()
|
|
defer c.mux.Unlock()
|
|
return c.remoteServices
|
|
}
|
|
|
|
func (c *EEBus) Run() {
|
|
c.service.Start()
|
|
}
|
|
|
|
func (c *EEBus) Shutdown() {
|
|
c.service.Shutdown()
|
|
}
|
|
|
|
// Use case callback
|
|
func (c *EEBus) ucCallback(ski string, device spineapi.DeviceRemoteInterface, entity spineapi.EntityRemoteInterface, event eebusapi.EventType) {
|
|
c.mux.Lock()
|
|
defer c.mux.Unlock()
|
|
|
|
c.log.DEBUG.Printf("ski %s event %s", ski, event)
|
|
|
|
if clients, ok := c.clients[ski]; ok {
|
|
for _, client := range clients {
|
|
client.UseCaseEvent(device, entity, event)
|
|
}
|
|
}
|
|
}
|
|
|
|
// EEBUSServiceHandler
|
|
|
|
func (c *EEBus) connect(ski string, connected bool) {
|
|
action := map[bool]string{true: "connected", false: "disconnected"}[connected]
|
|
c.log.DEBUG.Printf("ski %s %s", ski, action)
|
|
|
|
c.mux.Lock()
|
|
defer c.mux.Unlock()
|
|
|
|
if clients, ok := c.clients[ski]; ok {
|
|
for _, client := range clients {
|
|
client.Connect(connected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *EEBus) RemoteServiceConnected(service eebusapi.ServiceInterface, identity shipapi.ServiceIdentity) {
|
|
c.connect(identity.SKI, true)
|
|
}
|
|
|
|
func (c *EEBus) RemoteServiceDisconnected(service eebusapi.ServiceInterface, identity shipapi.ServiceIdentity) {
|
|
c.connect(identity.SKI, false)
|
|
}
|
|
|
|
// report all currently visible EEBUS services
|
|
// this is needed to provide an UI for pairing with other devices
|
|
// if not all incoming pairing requests should be accepted
|
|
func (c *EEBus) VisibleRemoteMdnsServicesUpdated(service eebusapi.ServiceInterface, entries []shipapi.RemoteMdnsService) {
|
|
c.mux.Lock()
|
|
defer c.mux.Unlock()
|
|
c.remoteServices = slices.Clone(entries)
|
|
}
|
|
|
|
// Provides updated service information (ShipID, fingerprint, ...) discovered
|
|
// during the handshake process. This needs to be persisted and passed on for
|
|
// future remote service connections when using `RegisterRemoteService`
|
|
func (c *EEBus) ServiceUpdated(identity shipapi.ServiceIdentity) {}
|
|
|
|
// Provides the current pairing state for the remote service
|
|
// This is called whenever the state changes and can be used to
|
|
// provide user information for the pairing/connection process
|
|
func (c *EEBus) ServicePairingDetailUpdate(identity shipapi.ServiceIdentity, detail *shipapi.ConnectionStateDetail) {
|
|
if detail.State() != shipapi.ConnectionStateReceivedPairingRequest {
|
|
return
|
|
}
|
|
|
|
c.mux.Lock()
|
|
defer c.mux.Unlock()
|
|
|
|
if clients, ok := c.clients[identity.SKI]; !ok || len(clients) == 0 {
|
|
// this is an unknown SKI, so deny pairing
|
|
c.service.CancelPairing(identity)
|
|
}
|
|
}
|
|
|
|
// SHIP Pairing Service events: evcc trusts remote services by configured SKI via
|
|
// RegisterRemoteService and does not use SHIP Pairing; logged for visibility only
|
|
func (c *EEBus) ServiceAutoTrusted(service eebusapi.ServiceInterface, identity shipapi.ServiceIdentity) {
|
|
c.log.INFO.Printf("service trusted: %s", identity.SKI)
|
|
}
|
|
|
|
func (c *EEBus) ServiceAutoTrustFailed(service eebusapi.ServiceInterface, identity shipapi.ServiceIdentity, reason error) {
|
|
c.log.INFO.Printf("service trust failed: %s: %v", identity.SKI, reason)
|
|
}
|
|
|
|
func (c *EEBus) ServiceAutoTrustRemoved(service eebusapi.ServiceInterface, identity shipapi.ServiceIdentity, reason string) {
|
|
c.log.INFO.Printf("service trust removed: %s: %s", identity.SKI, reason)
|
|
}
|
|
|
|
// EEBUS Logging interface
|
|
|
|
func (c *EEBus) Trace(args ...any) {
|
|
c.log.TRACE.Println(args...)
|
|
}
|
|
|
|
func (c *EEBus) Tracef(format string, args ...any) {
|
|
c.log.TRACE.Printf(format, args...)
|
|
}
|
|
|
|
func isRelevant(s string) bool {
|
|
return strings.Contains(s, "connect") || strings.Contains(s, " event ")
|
|
}
|
|
|
|
func (c *EEBus) Debug(args ...any) {
|
|
if s := fmt.Sprint(args...); isRelevant(s) {
|
|
c.log.DEBUG.Print(s)
|
|
}
|
|
}
|
|
|
|
func (c *EEBus) Debugf(format string, args ...any) {
|
|
if s := fmt.Sprintf(format, args...); isRelevant(s) {
|
|
c.log.DEBUG.Print(s)
|
|
}
|
|
}
|
|
|
|
func (c *EEBus) Info(args ...any) {
|
|
c.log.INFO.Println(args...)
|
|
}
|
|
|
|
func (c *EEBus) Infof(format string, args ...any) {
|
|
c.log.INFO.Printf(format, args...)
|
|
}
|
|
|
|
func (c *EEBus) Error(args ...any) {
|
|
if len(args) == 2 {
|
|
// TODO remove when enbility/ship-go is upgraded
|
|
if err := fmt.Sprint(args...); err == "websocket server error:http: Server closed" {
|
|
return
|
|
}
|
|
}
|
|
|
|
c.log.ERROR.Println(args...)
|
|
}
|
|
|
|
func (c *EEBus) Errorf(format string, args ...any) {
|
|
c.log.ERROR.Printf(format, args...)
|
|
}
|