Co-authored-by: Michael Geers <michael@geers.tv> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: andig <184815+andig@users.noreply.github.com>
450 lines
11 KiB
Go
450 lines
11 KiB
Go
package shm
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/evcc-io/evcc/api"
|
|
"github.com/evcc-io/evcc/core/loadpoint"
|
|
"github.com/evcc-io/evcc/core/site"
|
|
"github.com/evcc-io/evcc/util"
|
|
"github.com/evcc-io/evcc/util/machine"
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/mux"
|
|
"github.com/koron/go-ssdp"
|
|
)
|
|
|
|
const (
|
|
sempGateway = "urn:schemas-simple-energy-management-protocol:device:Gateway:1"
|
|
sempDeviceId = "F-%s-%.12x-00" // 6 bytes
|
|
sempSerialNumber = "%s-%d"
|
|
sempCharger = "EVCharger"
|
|
basePath = "/semp"
|
|
maxAge = 1800
|
|
)
|
|
|
|
var serverName = "evcc"
|
|
|
|
// SEMP is the SMA SEMP server
|
|
type SEMP struct {
|
|
log *util.Logger
|
|
vid string
|
|
did []byte
|
|
uid string
|
|
uri string
|
|
site site.API
|
|
}
|
|
|
|
type Config struct {
|
|
AllowControl_ bool `json:"allowControl,omitempty"` // deprecated
|
|
VendorId string `json:"vendorId"`
|
|
DeviceId string `json:"deviceId"`
|
|
DeviceSerial string `json:"deviceSerial"`
|
|
}
|
|
|
|
// NewFromConfig creates a new SEMP instance from configuration and starts it
|
|
func NewFromConfig(cfg Config, hostUri string, site site.API, addr string, router *mux.Router) error {
|
|
vendorId := cfg.VendorId
|
|
if vendorId == "" {
|
|
vendorId = "28081973"
|
|
} else if len(vendorId) != 8 {
|
|
return fmt.Errorf("invalid vendor id: %v. Must be 8 characters HEX string", vendorId)
|
|
}
|
|
|
|
uid, err := uuid.NewUUID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Only if DeviceSerial is explicitly configured: validate it and patch the
|
|
// UUID node (last 6 bytes) to ensure the UDN and DeviceSerial are stable across restarts.
|
|
// Ideally we'd have used the same machine-id approach as UniqueDeviceID does, but that
|
|
// would break existing installations that relied on the node ID of the UUID which was set to the MAC address
|
|
// of the host. See https://github.com/evcc-io/evcc/issues/28126 for context.
|
|
if cfg.DeviceSerial != "" {
|
|
b, err := hex.DecodeString(cfg.DeviceSerial)
|
|
if err != nil {
|
|
return fmt.Errorf("device serial: %w", err)
|
|
}
|
|
if len(b) != 6 {
|
|
return fmt.Errorf("invalid device serial: %v. Must be 12 characters HEX string", cfg.DeviceSerial)
|
|
}
|
|
|
|
// replaces the node (last 6 bytes) of a UUID with the given bytes.
|
|
copy(uid[10:], b)
|
|
}
|
|
|
|
var did []byte
|
|
if cfg.DeviceId == "" {
|
|
if did, err = UniqueDeviceID(); err != nil {
|
|
return fmt.Errorf("creating device id: %w", err)
|
|
}
|
|
} else {
|
|
if did, err = hex.DecodeString(cfg.DeviceId); err != nil {
|
|
return fmt.Errorf("device id: %w", err)
|
|
}
|
|
}
|
|
|
|
if len(did) != 6 {
|
|
return fmt.Errorf("invalid device id: %v. Must be 12 characters HEX string", cfg.DeviceId)
|
|
}
|
|
|
|
s := &SEMP{
|
|
log: util.NewLogger("semp"),
|
|
site: site,
|
|
uid: uid.String(),
|
|
vid: vendorId,
|
|
did: did,
|
|
uri: hostUri,
|
|
}
|
|
|
|
s.handlers(router)
|
|
|
|
go s.run()
|
|
return nil
|
|
}
|
|
|
|
func (s *SEMP) advertise(st, usn string) (*ssdp.Advertiser, error) {
|
|
descriptor := s.uri + basePath + "/description.xml"
|
|
return ssdp.Advertise(st, usn, descriptor, serverName, maxAge)
|
|
}
|
|
|
|
// run executes the SEMP runtime
|
|
func (s *SEMP) run() {
|
|
uid := "uuid:" + s.uid
|
|
|
|
var ads []*ssdp.Advertiser
|
|
for _, ad := range []struct{ st, usn string }{
|
|
{ssdp.RootDevice, uid + "::" + ssdp.RootDevice},
|
|
{sempGateway, uid + "::" + sempGateway},
|
|
{uid, uid},
|
|
} {
|
|
ad, err := s.advertise(ad.st, ad.usn)
|
|
if err != nil {
|
|
s.log.ERROR.Printf("advertise: %v", err)
|
|
continue
|
|
}
|
|
ads = append(ads, ad)
|
|
}
|
|
|
|
for range time.Tick(maxAge * time.Second / 2) {
|
|
for _, ad := range ads {
|
|
if err := ad.Alive(); err != nil {
|
|
s.log.ERROR.Println(err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *SEMP) handlers(router *mux.Router) {
|
|
sempRouter := router.PathPrefix(basePath).Subrouter()
|
|
getRouter := sempRouter.Methods(http.MethodGet).Subrouter()
|
|
|
|
// get description / root / info / status
|
|
getRouter.HandleFunc("/description.xml", s.gatewayDescription)
|
|
getRouter.HandleFunc("/", s.deviceRootHandler)
|
|
getRouter.HandleFunc("/DeviceInfo", s.deviceInfoQuery)
|
|
getRouter.HandleFunc("/DeviceStatus", s.deviceStatusQuery)
|
|
getRouter.HandleFunc("/PlanningRequest", s.devicePlanningQuery)
|
|
|
|
// post control messages
|
|
postRouter := sempRouter.Methods(http.MethodPost).Subrouter()
|
|
postRouter.HandleFunc("/", s.deviceControlHandler)
|
|
}
|
|
|
|
func (s *SEMP) writeXML(w http.ResponseWriter, msg any) {
|
|
s.log.TRACE.Printf("send: %+v", msg)
|
|
|
|
b, err := xml.MarshalIndent(msg, "", " ")
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
_, _ = w.Write([]byte(xml.Header))
|
|
_, _ = w.Write(b)
|
|
}
|
|
|
|
func (s *SEMP) gatewayDescription(w http.ResponseWriter, r *http.Request) {
|
|
uid := "uuid:" + s.uid
|
|
|
|
msg := DeviceDescription{
|
|
Xmlns: urnUPNPDevice,
|
|
SpecVersion: SpecVersion{Major: 1},
|
|
Device: Device{
|
|
DeviceType: sempGateway,
|
|
FriendlyName: "evcc",
|
|
Manufacturer: "evcc.io",
|
|
ModelName: serverName,
|
|
PresentationURL: s.uri,
|
|
UDN: uid,
|
|
ServiceDefinition: ServiceDefinition{
|
|
Xmlns: urnSEMPService,
|
|
Server: s.uri,
|
|
BasePath: basePath,
|
|
Transport: "HTTP/Pull",
|
|
ExchangeFormat: "XML",
|
|
WsVersion: "1.1.0",
|
|
},
|
|
},
|
|
}
|
|
|
|
s.writeXML(w, msg)
|
|
}
|
|
|
|
func (s *SEMP) deviceRootHandler(w http.ResponseWriter, r *http.Request) {
|
|
msg := Device2EMMsg()
|
|
msg.DeviceInfo = append(msg.DeviceInfo, s.allDeviceInfo()...)
|
|
msg.DeviceStatus = append(msg.DeviceStatus, s.allDeviceStatus()...)
|
|
msg.PlanningRequest = append(msg.PlanningRequest, s.allPlanningRequest()...)
|
|
s.writeXML(w, msg)
|
|
}
|
|
|
|
// deviceInfoQuery answers /semp/DeviceInfo
|
|
func (s *SEMP) deviceInfoQuery(w http.ResponseWriter, r *http.Request) {
|
|
msg := Device2EMMsg()
|
|
|
|
did := r.URL.Query().Get("DeviceId")
|
|
if did == "" {
|
|
msg.DeviceInfo = append(msg.DeviceInfo, s.allDeviceInfo()...)
|
|
} else {
|
|
for id, lp := range s.site.ActiveLoadpoints() {
|
|
if did != s.deviceID(id) {
|
|
continue
|
|
}
|
|
|
|
msg.DeviceInfo = append(msg.DeviceInfo, s.deviceInfo(id, lp))
|
|
}
|
|
|
|
if len(msg.DeviceInfo) == 0 {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
s.writeXML(w, msg)
|
|
}
|
|
|
|
// deviceStatusQuery answers /semp/DeviceStatus
|
|
func (s *SEMP) deviceStatusQuery(w http.ResponseWriter, r *http.Request) {
|
|
msg := Device2EMMsg()
|
|
|
|
did := r.URL.Query().Get("DeviceId")
|
|
if did == "" {
|
|
msg.DeviceStatus = append(msg.DeviceStatus, s.allDeviceStatus()...)
|
|
} else {
|
|
for id, lp := range s.site.ActiveLoadpoints() {
|
|
if did != s.deviceID(id) {
|
|
continue
|
|
}
|
|
|
|
msg.DeviceStatus = append(msg.DeviceStatus, s.deviceStatus(id, lp))
|
|
}
|
|
|
|
if len(msg.DeviceStatus) == 0 {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
s.writeXML(w, msg)
|
|
}
|
|
|
|
// devicePlanningQuery answers /semp/PlanningRequest
|
|
func (s *SEMP) devicePlanningQuery(w http.ResponseWriter, r *http.Request) {
|
|
msg := Device2EMMsg()
|
|
|
|
did := r.URL.Query().Get("DeviceId")
|
|
if did == "" {
|
|
msg.PlanningRequest = append(msg.PlanningRequest, s.allPlanningRequest()...)
|
|
} else {
|
|
for id, lp := range s.site.ActiveLoadpoints() {
|
|
if did != s.deviceID(id) {
|
|
continue
|
|
}
|
|
|
|
if pr := s.planningRequest(id, lp); len(pr.Timeframe) > 0 {
|
|
msg.PlanningRequest = append(msg.PlanningRequest, pr)
|
|
}
|
|
}
|
|
}
|
|
|
|
s.writeXML(w, msg)
|
|
}
|
|
|
|
func (s *SEMP) serialNumber(id int) string {
|
|
uidParts := strings.SplitN(s.uid, "-", 5)
|
|
ser := uidParts[len(uidParts)-1]
|
|
|
|
return fmt.Sprintf(sempSerialNumber, ser, id)
|
|
}
|
|
|
|
// UniqueDeviceID creates a 6-bytes base device id from machine id
|
|
func UniqueDeviceID() ([]byte, error) {
|
|
bytes := 6
|
|
|
|
mid := machine.ProtectedID("evcc-semp")
|
|
|
|
b, err := hex.DecodeString(mid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i, v := range b {
|
|
b[i%bytes] += v
|
|
}
|
|
|
|
return b[:bytes], nil
|
|
}
|
|
|
|
// deviceID combines base device id with device number
|
|
func (s *SEMP) deviceID(id int) string {
|
|
// numerically add device number
|
|
did := append([]byte{0, 0}, s.did...)
|
|
return fmt.Sprintf(sempDeviceId, s.vid, ^uint64(0xffff<<48)&(binary.BigEndian.Uint64(did)+uint64(id)))
|
|
}
|
|
|
|
func (s *SEMP) deviceInfo(id int, lp loadpoint.API) DeviceInfo {
|
|
method := MethodEstimation
|
|
if lp.HasChargeMeter() {
|
|
method = MethodMeasurement
|
|
}
|
|
|
|
res := DeviceInfo{
|
|
Identification: Identification{
|
|
DeviceID: s.deviceID(id),
|
|
DeviceName: lp.GetTitle(),
|
|
DeviceType: sempCharger,
|
|
DeviceSerial: s.serialNumber(id),
|
|
DeviceVendor: "github.com/evcc-io/evcc",
|
|
},
|
|
Capabilities: Capabilities{
|
|
CurrentPowerMethod: method,
|
|
InterruptionsAllowed: true,
|
|
OptionalEnergy: true,
|
|
},
|
|
Characteristics: Characteristics{
|
|
MinPowerConsumption: int(lp.EffectiveMinPower()),
|
|
MaxPowerConsumption: int(lp.EffectiveMaxPower()),
|
|
},
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
func (s *SEMP) allDeviceInfo() (res []DeviceInfo) {
|
|
for id, lp := range s.site.ActiveLoadpoints() {
|
|
res = append(res, s.deviceInfo(id, lp))
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
func (s *SEMP) deviceStatus(id int, lp loadpoint.API) DeviceStatus {
|
|
chargePower := lp.GetChargePower()
|
|
|
|
status := lp.GetStatus()
|
|
|
|
deviceStatus := StatusOff
|
|
if status == api.StatusC {
|
|
deviceStatus = StatusOn
|
|
}
|
|
|
|
res := DeviceStatus{
|
|
DeviceID: s.deviceID(id),
|
|
EMSignalsAccepted: false,
|
|
PowerInfo: PowerInfo{
|
|
AveragePower: int(chargePower),
|
|
AveragingInterval: 60,
|
|
},
|
|
Status: deviceStatus,
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
func (s *SEMP) allDeviceStatus() (res []DeviceStatus) {
|
|
for id, lp := range s.site.ActiveLoadpoints() {
|
|
res = append(res, s.deviceStatus(id, lp))
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
func (s *SEMP) planningRequest(id int, lp loadpoint.API) (res PlanningRequest) {
|
|
mode := lp.GetMode()
|
|
charging := lp.GetStatus() == api.StatusC
|
|
connected := charging || lp.GetStatus() == api.StatusB
|
|
|
|
// remaining max demand duration in seconds
|
|
chargeRemainingDuration := lp.GetRemainingDuration()
|
|
latestEnd := int(chargeRemainingDuration / time.Second)
|
|
if mode == api.ModeMinPV || mode == api.ModePV || latestEnd <= 0 {
|
|
latestEnd = 24 * 3600
|
|
}
|
|
|
|
// remaining max energy demand in Wh
|
|
chargeRemainingEnergy := lp.GetRemainingEnergy() * 1e3
|
|
maxEnergy := int(chargeRemainingEnergy)
|
|
|
|
// add 1kWh in case we're charging but battery claims full
|
|
if charging && maxEnergy == 0 {
|
|
maxEnergy = 1e3 // 1kWh
|
|
}
|
|
|
|
minEnergy := maxEnergy
|
|
if mode == api.ModePV {
|
|
minEnergy = 0
|
|
}
|
|
|
|
maxPowerConsumption := int(lp.EffectiveMaxPower())
|
|
minPowerConsumption := int(lp.EffectiveMinPower())
|
|
if mode == api.ModeNow {
|
|
minPowerConsumption = maxPowerConsumption
|
|
}
|
|
|
|
if mode != api.ModeOff && connected && maxEnergy > 0 {
|
|
res = PlanningRequest{
|
|
Timeframe: []Timeframe{{
|
|
DeviceID: s.deviceID(id),
|
|
EarliestStart: 0,
|
|
LatestEnd: latestEnd,
|
|
MinEnergy: &minEnergy,
|
|
MaxEnergy: &maxEnergy,
|
|
MaxPowerConsumption: &maxPowerConsumption,
|
|
MinPowerConsumption: &minPowerConsumption,
|
|
}},
|
|
}
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
func (s *SEMP) allPlanningRequest() (res []PlanningRequest) {
|
|
for id, lp := range s.site.ActiveLoadpoints() {
|
|
if pr := s.planningRequest(id, lp); len(pr.Timeframe) > 0 {
|
|
res = append(res, pr)
|
|
}
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
func (s *SEMP) deviceControlHandler(w http.ResponseWriter, r *http.Request) {
|
|
var msg EM2Device
|
|
|
|
if err := xml.NewDecoder(r.Body).Decode(&msg); err == nil {
|
|
s.log.TRACE.Printf("recv: %+v", msg)
|
|
} else {
|
|
s.log.ERROR.Printf("recv: %+v", msg)
|
|
}
|
|
|
|
// ignore control requests
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}
|