From 7eb85ad8b9b54e392f3c9bf61554df18c20df642 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 20 Sep 2020 17:03:39 +0200 Subject: [PATCH] Support SMA Sunny Home Manager as Energy Management System (#353) --- .gitignore | 1 + README.md | 11 + cmd/config.go | 1 + cmd/root.go | 6 + cmd/setup.go | 11 + core/loadpoint.go | 2 +- core/site.go | 9 +- go.mod | 3 + go.sum | 6 + hems/config.go | 26 +++ hems/semp/helper.go | 30 +++ hems/semp/messages.go | 163 +++++++++++++++ hems/semp/semp.go | 470 ++++++++++++++++++++++++++++++++++++++++++ server/http.go | 25 ++- 14 files changed, 751 insertions(+), 13 deletions(-) create mode 100644 hems/config.go create mode 100644 hems/semp/helper.go create mode 100644 hems/semp/messages.go create mode 100644 hems/semp/semp.go diff --git a/.gitignore b/.gitignore index d20366944..c20e4edc2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ linux-*.Dockerfile !evcc.dist.yaml !modules/** dist +*.py diff --git a/README.md b/README.md index 319b194f7..d84f2675c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ EVCC is an extensible EV Charge Controller with PV integration implemented in [G - [Charger](#charger) - [Meter](#meter) - [Vehicle](#vehicle) + - [Home Energy Management System](#home-energy-management-system) - [Plugins](#plugins) - [Modbus](#modbus-read-only) - [MQTT](#mqtt-readwrite) @@ -216,6 +217,16 @@ Available vehicle implementations are: Configuration examples are documented at [andig/evcc-config#vehicles](https://github.com/andig/evcc-config#vehicles) +### Home Energy Management System + +EVCC can integrate itself with Home Energy Management Systems. At this time, the SMA Home Manager (SHM) is the only supported system. To enable add + +```yaml +hems: sma +``` + +to the configuration. The EVCC loadpoints can then be added to the SHM configuration. + ## Plugins Plugins are used to integrate various devices and external data sources with EVCC. Plugins can be used in combination with a `default` type meter, charger or vehicle. diff --git a/cmd/config.go b/cmd/config.go index f33f06e93..f4300482c 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -19,6 +19,7 @@ type config struct { Interval time.Duration Mqtt provider.MqttConfig Influx server.InfluxConfig + HEMS string Menu []server.MenuConfig Messaging messagingConfig Meters []qualifiedConfig diff --git a/cmd/root.go b/cmd/root.go index 54df67654..07b2c2b7c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -156,6 +156,12 @@ func run(cmd *cobra.Command, args []string) { socketHub := server.NewSocketHub() httpd := server.NewHTTPd(uri, conf.Menu, site, socketHub, cache) + // start HEMS server + if conf.HEMS != "" { + hems := configureHEMS(conf.HEMS, site, cache, httpd) + go hems.Run() + } + // publish to UI go socketHub.Run(tee.Attach(), cache) diff --git a/cmd/setup.go b/cmd/setup.go index d4e39e66a..9f3fe21c0 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -7,6 +7,7 @@ import ( "time" "github.com/andig/evcc/core" + "github.com/andig/evcc/hems" "github.com/andig/evcc/provider" "github.com/andig/evcc/push" "github.com/andig/evcc/server" @@ -51,6 +52,16 @@ func configureMQTT(conf provider.MqttConfig) { provider.MQTT = provider.NewMqttClient(conf.Broker, conf.User, conf.Password, mqttClientID(), 1) } +// setup HEMS +func configureHEMS(conf string, site *core.Site, cache *util.Cache, httpd *server.HTTPd) hems.HEMS { + hems, err := hems.NewFromConfig(conf, site, cache, httpd) + if err != nil { + log.FATAL.Fatal(err) + } + return hems +} + +// setup messaging func configureMessengers(conf messagingConfig, cache *util.Cache) chan push.Event { notificationChan := make(chan push.Event, 1) notificationHub := push.NewHub(conf.Events, cache) diff --git a/core/loadpoint.go b/core/loadpoint.go index a9015df14..c01337d5d 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -603,7 +603,7 @@ func (lp *LoadPoint) publishSoC() { } lp.publish("socCharge", -1) - lp.publish("chargeEstimate", -1) + lp.publish("chargeEstimate", time.Duration(-1)) } // Update is the main control function. It reevaluates meters and charger state diff --git a/core/site.go b/core/site.go index 7ea5d5a2f..946120ab5 100644 --- a/core/site.go +++ b/core/site.go @@ -146,7 +146,8 @@ func (site *Site) SetTargetSoC(targetSoC int) { } } -func (lp *LoadPoint) hasChargeMeter() bool { +// HasChargeMeter determines if a physical charge meter is attached +func (lp *LoadPoint) HasChargeMeter() bool { _, isWrapped := lp.chargeMeter.(*wrapper.ChargeMeter) return lp.chargeMeter != nil && !isWrapped } @@ -172,7 +173,7 @@ func (site *Site) Configuration() SiteConfiguration { Phases: lp.Phases, MinCurrent: lp.MinCurrent, MaxCurrent: lp.MaxCurrent, - ChargeMeter: lp.hasChargeMeter(), + ChargeMeter: lp.HasChargeMeter(), } if lp.vehicle != nil { @@ -215,8 +216,8 @@ func (site *Site) DumpConfig() { lp.log.INFO.Printf("loadpoint %d config:", i+1) lp.log.INFO.Printf(" vehicle %s", presence[lp.vehicle != nil]) - lp.log.INFO.Printf(" charge %s", presence[lp.hasChargeMeter()]) - if lp.hasChargeMeter() { + lp.log.INFO.Printf(" charge %s", presence[lp.HasChargeMeter()]) + if lp.HasChargeMeter() { lp.log.INFO.Println(" charge meter config:") logMeter(site.log, lp.chargeMeter) } diff --git a/go.mod b/go.mod index 5f655a411..bb696bf41 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,8 @@ require ( github.com/joeshaw/carwings v0.0.0-20191118152321-61b46581307a github.com/jsgoecke/tesla v0.0.0-20200530171421-e02ebd220e5a github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 + github.com/koron/go-ssdp v0.0.0-20191105050749-2e1c40ed0b5d + github.com/kr/pretty v0.2.0 // indirect github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40 github.com/mitchellh/mapstructure v1.3.2 github.com/mjibson/esc v0.2.0 @@ -41,6 +43,7 @@ require ( github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e github.com/volkszaehler/mbmd v0.0.0-20200831092453-b235d6a65b21 golang.org/x/net v0.0.0-20200707034311-ab3426394381 + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/ini.v1 v1.57.0 gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c ) diff --git a/go.sum b/go.sum index e45469b7b..3d1035b75 100644 --- a/go.sum +++ b/go.sum @@ -225,10 +225,14 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3 github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/koron/go-ssdp v0.0.0-20191105050749-2e1c40ed0b5d h1:68u9r4wEvL3gYg2jvAOgROwZ3H+Y3hIDk4tbbmIjcYQ= +github.com/koron/go-ssdp v0.0.0-20191105050749-2e1c40ed0b5d/go.mod h1:5Ky9EC2xfoUKUor0Hjgi2BJhCSXJfMOFlmyYrVKGQMk= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -569,6 +573,8 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= diff --git a/hems/config.go b/hems/config.go new file mode 100644 index 000000000..bf9834f72 --- /dev/null +++ b/hems/config.go @@ -0,0 +1,26 @@ +package hems + +import ( + "errors" + "strings" + + "github.com/andig/evcc/core" + "github.com/andig/evcc/hems/semp" + "github.com/andig/evcc/server" + "github.com/andig/evcc/util" +) + +// HEMS describes the HEMS system interface +type HEMS interface { + Run() +} + +// NewFromConfig creates new HEMS from config +func NewFromConfig(typ string, site *core.Site, cache *util.Cache, httpd *server.HTTPd) (HEMS, error) { + switch strings.ToLower(typ) { + case "sma", "shm", "semp": + return semp.New(site, cache, httpd) + default: + return nil, errors.New("unknown hems: " + typ) + } +} diff --git a/hems/semp/helper.go b/hems/semp/helper.go new file mode 100644 index 000000000..a501ee0a3 --- /dev/null +++ b/hems/semp/helper.go @@ -0,0 +1,30 @@ +package semp + +import "net" + +// LocalIPs returns a slice of local IPv4 addresses +func LocalIPs() []net.IP { + ips := make([]net.IP, 0) + + ifaces, err := net.Interfaces() + if err != nil { + panic(err) + } + + for _, i := range ifaces { + addrs, err := i.Addrs() + if err != nil { + panic(err) + } + + for _, addr := range addrs { + if ip, ok := addr.(*net.IPNet); ok { + if !ip.IP.IsLoopback() && ip.IP.To4() != nil { + ips = append(ips, ip.IP) + } + } + } + } + + return ips +} diff --git a/hems/semp/messages.go b/hems/semp/messages.go new file mode 100644 index 000000000..a04c733e8 --- /dev/null +++ b/hems/semp/messages.go @@ -0,0 +1,163 @@ +package semp + +import "encoding/xml" + +const ( + urnUPNPDevice = "urn:schemas-upnp-org:device-1-0" + urnSEMPService = "urn:schemas-simple-energy-management-protocol:service-1-0" +) + +type DeviceDescription struct { + XMLName xml.Name `xml:"root"` + Xmlns string `xml:"xmlns,attr"` + SpecVersion SpecVersion `xml:"specVersion"` + Device Device `xml:"device"` +} + +type SpecVersion struct { + Major int `xml:"major"` + Minor int `xml:"minor"` +} + +type Device struct { + DeviceType string `xml:"deviceType"` + FriendlyName string `xml:"friendlyName"` + Manufacturer string `xml:"manufacturer"` + ModelName string `xml:"modelName"` + UDN string `xml:"UDN"` + PresentationURL string `xml:"presentationURL"` + SEMPService SEMPService `xml:"semp:X_SEMPSERVICE"` + ServiceList []Service `xml:"serviceList"` // optional +} + +type Service struct { + ServiceType string `xml:"serviceType"` + ServiceID string `xml:"serviceId"` + SCPDURL string `xml:"SCPDURL"` + ControlURL string `xml:"controlURL"` + EventSubURL string `xml:"eventSubURL"` +} + +type SEMPService struct { + Xmlns string `xml:"xmlns:semp,attr"` + Server string `xml:"semp:server"` + BasePath string `xml:"semp:basePath"` + Transport string `xml:"semp:transport"` + ExchangeFormat string `xml:"semp:exchangeFormat"` + WsVersion string `xml:"semp:wsVersion"` +} + +// Device2EM is the device to EM message +type Device2EM struct { + Xmlns string `xml:"xmlns,attr"` + DeviceInfo []DeviceInfo `xml:",omitempty"` + DeviceStatus []DeviceStatus `xml:",omitempty"` + PlanningRequest []PlanningRequest `xml:",omitempty"` +} + +type DeviceInfo struct { + Identification Identification + Characteristics Characteristics + Capabilities Capabilities +} + +type Identification struct { + DeviceID string `xml:"DeviceId"` + DeviceName string + DeviceType string + DeviceSerial string + DeviceVendor string +} + +type Characteristics struct { + MinPowerConsumption int + MaxPowerConsumption int + MinOnTime int `xml:",omitempty"` + MinOffTime int `xml:",omitempty"` +} + +type Capabilities struct { + CurrentPower CurrentPower + Timestamps Timestamps + Interruptions Interruptions + Requests Requests +} + +const ( + MethodMeasurement = "Measurement" + MethodEstimation = "Estimation" +) + +type CurrentPower struct { + Method string +} + +type Timestamps struct { + AbsoluteTimestamps bool +} + +type Interruptions struct { + InterruptionsAllowed bool +} + +type Requests struct { + OptionalEnergy bool +} + +const ( + StatusOn = "On" + StatusOff = "Off" +) + +type DeviceStatus struct { + DeviceID string `xml:"DeviceId"` + EMSignalsAccepted bool + Status string + PowerConsumption PowerConsumption +} + +type PowerConsumption struct { + PowerInfo PowerInfo +} + +type PowerInfo struct { + AveragePower int + Timestamp int + AveragingInterval int +} + +type PlanningRequest struct { + Timeframe Timeframe +} + +type Timeframe struct { + DeviceID string `xml:"DeviceId"` + EarliestStart int + LatestEnd int + MinRunningTime int + MaxRunningTime int `xml:",omitempty"` +} + +// EM2Device is the EM to device message +type EM2Device struct { + Xmlns string `xml:"xmlns,attr"` + DeviceControl []DeviceControl `xml:",omitempty"` + PlanningRequest []PlanningRequest `xml:",omitempty"` +} + +type DeviceControl struct { + DeviceID string `xml:"DeviceId"` + On bool + Timestamp int +} + +// Device2EMMsg is the XML message container +func Device2EMMsg() Device2EM { + msg := Device2EM{ + Xmlns: "http://www.sma.de/communication/schema/SEMP/v1", + DeviceInfo: make([]DeviceInfo, 0), + DeviceStatus: make([]DeviceStatus, 0), + } + + return msg +} diff --git a/hems/semp/semp.go b/hems/semp/semp.go new file mode 100644 index 000000000..117d5540c --- /dev/null +++ b/hems/semp/semp.go @@ -0,0 +1,470 @@ +package semp + +import ( + "encoding/xml" + "errors" + "fmt" + "io/ioutil" + "net" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/andig/evcc/api" + "github.com/andig/evcc/core" + "github.com/andig/evcc/server" + "github.com/andig/evcc/util" + "github.com/google/uuid" + "github.com/gorilla/mux" + "github.com/koron/go-ssdp" +) + +const ( + sempBaseUrlEnv = "SEMP_BASE_URL" + sempGateway = "urn:schemas-simple-energy-management-protocol:device:Gateway:1" + sempLocalDevice = "F-28081973-%s-%.02d" + 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 + cache *util.Cache + closeC chan struct{} + doneC chan struct{} + uid string + hostURI string + port int + site site +} + +// site is the minimal interface for accessing site methods +type site interface { + Configuration() core.SiteConfiguration + LoadPoints() []*core.LoadPoint +} + +// New generates SEMP Gateway listening at /semp endpoint +func New(site site, cache *util.Cache, httpd *server.HTTPd) (*SEMP, error) { + uid, err := uuid.NewUUID() + if err != nil { + return nil, err + } + + s := &SEMP{ + doneC: make(chan struct{}), + log: util.NewLogger("semp"), + cache: cache, + site: site, + uid: uid.String(), + } + + // 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 { + descriptor := s.hostURI + basePath + "/description.xml" + ad, err := ssdp.Advertise(st, usn, descriptor, serverName, maxAge) + if err != nil { + s.log.ERROR.Println(err) + } + return ad +} + +// 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 + ads := []*ssdp.Advertiser{ + s.advertise(ssdp.RootDevice, uid+"::"+ssdp.RootDevice), + s.advertise(uid, uid), + s.advertise(sempGateway, uid+"::"+sempGateway), + } + + 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 := LocalIPs() + if len(ips) > 0 { + ip = ips[0].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{}) { + 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/andig/evcc", + ModelName: serverName, + PresentationURL: s.hostURI, + UDN: uid, + SEMPService: SEMPService{ + 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()...) + 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); pr.Timeframe.DeviceID != "" { + msg.PlanningRequest = append(msg.PlanningRequest, pr) + } + } + } + + s.writeXML(w, msg) +} + +func (s *SEMP) serialNumber() string { + uidParts := strings.SplitN(s.uid, "-", 5) + return uidParts[len(uidParts)-1] +} + +func (s *SEMP) deviceID(id int) string { + return fmt.Sprintf(sempLocalDevice, s.serialNumber(), id) +} + +// cacheGet returns loadpoint value from cache +func (s *SEMP) cacheGet(id int, key string) (res util.Param, err error) { + pid := util.Param{LoadPoint: &id, Key: key} + + res = s.cache.Get(pid.UniqueID()) + if res.Key == "" { + err = errors.New("not found") + } + + return res, err +} + +func (s *SEMP) deviceInfo(id int, lp *core.LoadPoint) DeviceInfo { + method := MethodEstimation + if lp.HasChargeMeter() { + method = MethodMeasurement + } + + res := DeviceInfo{ + Identification: Identification{ + DeviceID: s.deviceID(id), + DeviceName: lp.Name(), + DeviceType: sempCharger, + DeviceSerial: fmt.Sprintf(sempSerialNumber, s.serialNumber(), id), + DeviceVendor: "github.com/andig/evcc", + }, + Capabilities: Capabilities{ + CurrentPower: CurrentPower{ + Method: method, + }, + Interruptions: Interruptions{ + InterruptionsAllowed: true, + }, + Requests: Requests{ + OptionalEnergy: true, + }, + }, + Characteristics: Characteristics{ + MinPowerConsumption: 230 * int(lp.MinCurrent), + MaxPowerConsumption: 230 * int(lp.Phases*lp.MaxCurrent), + }, + } + + 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 *core.LoadPoint) DeviceStatus { + var chargePower float64 + if chargePowerP, err := s.cacheGet(id, "chargePower"); err == nil { + chargePower = chargePowerP.Val.(float64) + } + + status := StatusOff + if statusP, err := s.cacheGet(id, "charging"); err == nil { + if statusP.Val.(bool) { + status = StatusOn + } + } + + res := DeviceStatus{ + DeviceID: s.deviceID(id), + EMSignalsAccepted: true, + PowerConsumption: PowerConsumption{ + PowerInfo: PowerInfo{ + AveragePower: int(chargePower), + AveragingInterval: 60, + }, + }, + Status: status, + } + + 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 *core.LoadPoint) (res PlanningRequest) { + mode := api.ModeOff + if modeP, err := s.cacheGet(id, "mode"); err == nil { + mode = api.ChargeMode(modeP.Val.(string)) + } + + var charging bool + if chargingP, err := s.cacheGet(id, "charging"); err == nil { + charging = chargingP.Val.(bool) + } + + chargeEstimate := time.Duration(-1) + if chargeEstimateP, err := s.cacheGet(id, "chargeEstimate"); err == nil { + chargeEstimate = chargeEstimateP.Val.(time.Duration) + } + + maxDuration := int(chargeEstimate / time.Second) + if chargeEstimate <= 0 { + maxDuration = 10 * 60 // 10min + } + + minDuration := maxDuration + if mode == api.ModePV { + minDuration = 0 + } + + latestEnd := maxDuration + if mode == api.ModePV { + latestEnd = 2 * maxDuration + } + + if charging { + res = PlanningRequest{ + Timeframe: Timeframe{ + DeviceID: s.deviceID(id), + EarliestStart: 0, + LatestEnd: latestEnd, + MinRunningTime: minDuration, + MaxRunningTime: maxDuration, + }, + } + } + + return res +} + +func (s *SEMP) allPlanningRequest() (res []PlanningRequest) { + for id, lp := range s.site.LoadPoints() { + if pr := s.planningRequest(id, lp); pr.Timeframe.DeviceID != "" { + res = append(res, pr) + } + } + + return res +} + +func (s *SEMP) deviceControlHandler(w http.ResponseWriter, r *http.Request) { + var msg EM2Device + + body, err := ioutil.ReadAll(r.Body) + if err == nil { + defer r.Body.Close() + err = xml.Unmarshal(body, &msg) + } + + s.log.TRACE.Printf("recv: %+v", msg) + + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/server/http.go b/server/http.go index cc9f8806d..045475cc3 100644 --- a/server/http.go +++ b/server/http.go @@ -232,8 +232,14 @@ func applyRouteHandler(router *mux.Router, r route, handler http.HandlerFunc) { router.Methods(r.Methods...).Path(r.Pattern).Handler(handler) } +// HTTPd wraps an http.Server and adds the root router +type HTTPd struct { + *http.Server + *mux.Router +} + // NewHTTPd creates HTTP server with configured routes for loadpoint -func NewHTTPd(url string, links []MenuConfig, site site, hub *SocketHub, cache *util.Cache) *http.Server { +func NewHTTPd(url string, links []MenuConfig, site site, hub *SocketHub, cache *util.Cache) *HTTPd { var routes = map[string]route{ "health": {[]string{"GET"}, "/health", HealthHandler()}, "config": {[]string{"GET"}, "/config", ConfigHandler(site)}, @@ -284,13 +290,16 @@ func NewHTTPd(url string, links []MenuConfig, site site, hub *SocketHub, cache * applyRouteHandler(subAPI, routes["settargetsoc"], TargetSoCHandler(lp)) } - srv := &http.Server{ - Addr: url, - Handler: router, - ReadTimeout: 5 * time.Second, - WriteTimeout: 10 * time.Second, - IdleTimeout: 120 * time.Second, - ErrorLog: log.ERROR, + srv := &HTTPd{ + Server: &http.Server{ + Addr: url, + Handler: router, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + ErrorLog: log.ERROR, + }, + Router: router, } srv.SetKeepAlivesEnabled(true)