package semp import ( "encoding/xml" "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 ( sempController = "Sunny Home Manager" 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 core.SiteAPI } // New generates SEMP Gateway listening at /semp endpoint func New(site core.SiteAPI, 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 := util.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{}) { 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/andig/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()...) 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() 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) } func (s *SEMP) deviceInfo(id int, lp core.LoadPointAPI) 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{ CurrentPowerMethod: method, InterruptionsAllowed: true, OptionalEnergy: true, }, Characteristics: Characteristics{ MinPowerConsumption: int(lp.GetMinPower()), MaxPowerConsumption: int(lp.GetMaxPower()), }, } 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.LoadPointAPI) DeviceStatus { var chargePower float64 if chargePowerP, err := s.cache.GetChecked(id, "chargePower"); err == nil { chargePower = chargePowerP.Val.(float64) } isPV := false if modeP, err := s.cache.GetChecked(id, "mode"); err == nil { if mode, ok := modeP.Val.(api.ChargeMode); ok && mode == api.ModePV { isPV = true } } status := StatusOff if statusP, err := s.cache.GetChecked(id, "charging"); err == nil { if statusP.Val.(bool) { status = StatusOn } } res := DeviceStatus{ DeviceID: s.deviceID(id), EMSignalsAccepted: isPV, 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.LoadPointAPI) (res PlanningRequest) { mode := api.ModeOff if modeP, err := s.cache.GetChecked(id, "mode"); err == nil { mode = api.ChargeMode(modeP.Val.(string)) } var charging bool if chargingP, err := s.cache.GetChecked(id, "charging"); err == nil { charging = chargingP.Val.(bool) } chargeEstimate := time.Duration(-1) if chargeEstimateP, err := s.cache.GetChecked(id, "chargeEstimate"); err == nil { chargeEstimate = chargeEstimateP.Val.(time.Duration) } latestEnd := int(chargeEstimate / time.Second) if mode == api.ModePV || latestEnd <= 0 { latestEnd = 24 * 3600 } var maxEnergy int if chargeRemainingEnergyP, err := s.cache.GetChecked(id, "chargeRemainingEnergy"); err == nil { maxEnergy = int(chargeRemainingEnergyP.Val.(float64)) } // add 1kWh in case we're charging but battery claims full if maxEnergy == 0 { maxEnergy = 1e3 // 1kWh } minEnergy := maxEnergy if mode == api.ModePV { minEnergy = 0 } if charging { res = PlanningRequest{ Timeframe: []Timeframe{{ DeviceID: s.deviceID(id), EarliestStart: 0, LatestEnd: latestEnd, MinEnergy: &minEnergy, MaxEnergy: &maxEnergy, }}, } } 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 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 } 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.ModePV { w.WriteHeader(http.StatusBadRequest) return } demand := core.RemoteSoftDisable if dev.On { demand = core.RemoteEnable } lp.RemoteControl(sempController, demand) } } w.WriteHeader(http.StatusOK) }