Support SMA Sunny Home Manager as Energy Management System (#353)
This commit is contained in:
parent
1387ec1a58
commit
7eb85ad8b9
14 changed files with 751 additions and 13 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,3 +8,4 @@ linux-*.Dockerfile
|
|||
!evcc.dist.yaml
|
||||
!modules/**
|
||||
dist
|
||||
*.py
|
||||
|
|
|
|||
11
README.md
11
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
11
cmd/setup.go
11
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
3
go.mod
3
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
|
||||
)
|
||||
|
|
|
|||
6
go.sum
6
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=
|
||||
|
|
|
|||
26
hems/config.go
Normal file
26
hems/config.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
30
hems/semp/helper.go
Normal file
30
hems/semp/helper.go
Normal file
|
|
@ -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
|
||||
}
|
||||
163
hems/semp/messages.go
Normal file
163
hems/semp/messages.go
Normal file
|
|
@ -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
|
||||
}
|
||||
470
hems/semp/semp.go
Normal file
470
hems/semp/semp.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue