evcc-io/hems/semp/semp.go

541 lines
12 KiB
Go

package semp
import (
"encoding/binary"
"encoding/hex"
"encoding/xml"
"fmt"
"net"
"net/http"
"os"
"strconv"
"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/server"
"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 (
sempController = "Sunny Home Manager"
sempBaseURLEnv = "SEMP_BASE_URL"
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 Server " + server.Version
// SEMP is the SMA SEMP server
type SEMP struct {
log *util.Logger
closeC chan struct{}
doneC chan struct{}
controllable bool
vid string
did []byte
uid string
hostURI string
port int
site site.API
}
// New generates SEMP Gateway listening at /semp endpoint
func New(conf map[string]interface{}, site site.API, httpd *server.HTTPd) (*SEMP, error) {
cc := struct {
VendorID string
DeviceID string
AllowControl bool
}{
VendorID: "28081973",
}
if err := util.DecodeOther(conf, &cc); err != nil {
return nil, err
}
uid, err := uuid.NewUUID()
if err != nil {
return nil, err
}
if len(cc.VendorID) != 8 {
return nil, fmt.Errorf("invalid vendor id: %v", cc.VendorID)
}
var did []byte
if cc.DeviceID == "" {
if did, err = UniqueDeviceID(); err != nil {
return nil, fmt.Errorf("creating device id: %w", err)
}
} else {
if did, err = hex.DecodeString(cc.DeviceID); err != nil {
return nil, fmt.Errorf("device id: %w", err)
}
}
if len(did) != 6 {
return nil, fmt.Errorf("invalid device id: %v", cc.DeviceID)
}
s := &SEMP{
doneC: make(chan struct{}),
log: util.NewLogger("semp"),
site: site,
uid: uid.String(),
vid: cc.VendorID,
did: did,
controllable: cc.AllowControl,
}
// find external port
_, port, err := net.SplitHostPort(httpd.Addr)
if err == nil {
s.port, err = strconv.Atoi(port)
}
s.hostURI = s.callbackURI()
s.handlers(httpd.Router())
return s, err
}
func (s *SEMP) advertise(st, usn string) (*ssdp.Advertiser, error) {
descriptor := s.hostURI + basePath + "/description.xml"
return ssdp.Advertise(st, usn, descriptor, serverName, maxAge)
}
// Run executes the SEMP runtime
func (s *SEMP) Run() {
if s.closeC != nil {
panic("already running")
}
s.closeC = make(chan struct{})
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)
}
ticker := time.NewTicker(maxAge * time.Second / 2)
ANNOUNCE:
for {
select {
case <-ticker.C:
for _, ad := range ads {
if err := ad.Alive(); err != nil {
s.log.ERROR.Println(err)
}
}
case <-s.closeC:
break ANNOUNCE
}
}
for _, ad := range ads {
if err := ad.Bye(); err != nil {
s.log.ERROR.Println(err)
}
}
close(s.doneC)
}
// Stop stops the SEMP runtime
func (s *SEMP) Stop() {
if s.closeC == nil {
panic("not running")
}
close(s.closeC)
}
// Done returns the done channel. The channel is closed after byebye has been sent.
func (s *SEMP) Done() chan struct{} {
return s.doneC
}
func (s *SEMP) callbackURI() string {
if uri := os.Getenv(sempBaseURLEnv); uri != "" {
return strings.TrimSuffix(uri, "/")
}
ip := "localhost"
ips := util.LocalIPs()
if len(ips) > 0 {
ip = ips[0].IP.String()
} else {
s.log.ERROR.Printf("couldn't determine ip address- specify %s to override", sempBaseURLEnv)
}
uri := fmt.Sprintf("http://%s:%d", ip, s.port)
s.log.WARN.Printf("%s unspecified, using %s instead", sempBaseURLEnv, uri)
return uri
}
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 interface{}) {
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: "github.com/evcc-io/evcc",
ModelName: serverName,
PresentationURL: s.hostURI,
UDN: uid,
ServiceDefinition: ServiceDefinition{
Xmlns: urnSEMPService,
Server: s.hostURI,
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.Loadpoints() {
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.Loadpoints() {
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.Loadpoints() {
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, err := machine.ProtectedID("evcc-semp")
if err != nil {
return nil, err
}
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.Title(),
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.Loadpoints() {
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()
mode := lp.GetMode()
isPV := mode == api.ModeMinPV || mode == api.ModePV
deviceStatus := StatusOff
if status == api.StatusC {
deviceStatus = StatusOn
}
connected := status == api.StatusB || status == api.StatusC
res := DeviceStatus{
DeviceID: s.deviceID(id),
EMSignalsAccepted: s.controllable && isPV && connected,
PowerInfo: PowerInfo{
AveragePower: int(chargePower),
AveragingInterval: 60,
},
Status: deviceStatus,
}
return res
}
func (s *SEMP) allDeviceStatus() (res []DeviceStatus) {
for id, lp := range s.site.Loadpoints() {
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()
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.Loadpoints() {
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
err := xml.NewDecoder(r.Body).Decode(&msg)
s.log.TRACE.Printf("recv: %+v", msg)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
for _, dev := range msg.DeviceControl {
did := dev.DeviceID
for id, lp := range s.site.Loadpoints() {
if did != s.deviceID(id) {
continue
}
if mode := lp.GetMode(); mode != api.ModeMinPV && mode != api.ModePV {
w.WriteHeader(http.StatusBadRequest)
return
}
// ignore requests if not controllable
if !s.controllable {
w.WriteHeader(http.StatusBadRequest)
return
}
demand := loadpoint.RemoteSoftDisable
if dev.On {
demand = loadpoint.RemoteEnable
}
lp.RemoteControl(sempController, demand)
}
}
w.WriteHeader(http.StatusOK)
}