Add basic OCPP support (#395)

This commit is contained in:
andig 2020-11-21 16:49:59 +01:00 • committed by GitHub
parent 9bd8754b85
commit 29ea3c6549
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 618 additions and 130 deletions

View file

@ -265,8 +265,25 @@ loadpoints:
</div>
<div class="row" v-if="!multi">
<div class="col-12 col-md-4 d-none d-md-flex mt-3 mt-md-5 align-items-end">
<span class="h1 align-bottom">{{state.title||"Ladepunkt"}}</span>
<div class="col-12 col-md-4 d-none d-md-flex mt-3 mt-md-5 ">
<span class="h1 ">{{state.title||"Ladepunkt"}}</span>
</div>
<div class="col-12 col-md-8 mt-3 mt-md-5 align-items-end"
v-if="state.remoteDisabled">
<h1 class="d-md-inline d-none">
<span class="badge badge-warning font-base" v-if="state.remoteDisabled == 'soft'">
Adaptives PV-Laden deaktiviert durch {{state.remoteDisabledSource}}
</span>
<span class="badge badge-danger font-base" v-if="state.remoteDisabled == 'hard'">
Deaktiviert durch {{state.remoteDisabledSource}}
</span>
</h1>
<span class="d-md-none badge badge-warning font-base" v-if="state.remoteDisabled == 'soft'">
Adaptives PV-Laden deaktiviert durch {{state.remoteDisabledSource}}
</span>
<span class="d-md-none badge badge-danger font-base" v-if="state.remoteDisabled == 'hard'">
Deaktiviert durch {{state.remoteDisabledSource}}
</span>
</div>
</div>

View file

@ -20,7 +20,7 @@ type config struct {
Interval time.Duration
Mqtt provider.MqttConfig
Influx server.InfluxConfig
HEMS string
HEMS typedConfig
Messaging messagingConfig
Meters []qualifiedConfig
Chargers []qualifiedConfig

View file

@ -163,7 +163,7 @@ func run(cmd *cobra.Command, args []string) {
httpd := server.NewHTTPd(uri, site, socketHub, cache)
// start HEMS server
if conf.HEMS != "" {
if conf.HEMS.Type != "" {
hems := configureHEMS(conf.HEMS, site, cache, httpd)
go hems.Run()
}

View file

@ -52,8 +52,8 @@ func configureMQTT(conf provider.MqttConfig) {
}
// 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)
func configureHEMS(conf typedConfig, site *core.Site, cache *util.Cache, httpd *server.HTTPd) hems.HEMS {
hems, err := hems.NewFromConfig(conf.Type, conf.Other, site, cache, httpd)
if err != nil {
log.FATAL.Fatalf("failed configuring hems: %v", err)
}

View file

@ -86,6 +86,7 @@ type LoadPoint struct {
// cached state
status api.ChargeStatus // Charger status
remoteDemand RemoteDemand // External status demand
charging bool // Charging cycle
chargePower float64 // Charging power
connectedTime time.Time // Time when vehicle was connected
@ -412,6 +413,14 @@ func (lp *LoadPoint) climateActive() bool {
return false
}
// remoteControlled returns true if remote control status is active
func (lp *LoadPoint) remoteControlled(demand RemoteDemand) bool {
lp.Lock()
defer lp.Unlock()
return lp.remoteDemand == demand
}
// setActiveVehicle assigns currently active vehicle and configures soc estimator
func (lp *LoadPoint) setActiveVehicle(vehicle api.Vehicle) {
if lp.vehicle != nil {
@ -703,7 +712,7 @@ func (lp *LoadPoint) publishSoC() {
// Update is the main control function. It reevaluates meters and charger state
func (lp *LoadPoint) Update(sitePower float64) {
mode := lp.GetMode()
lp.publish("mode", string(mode))
lp.publish("mode", mode)
// read and publish meters first
lp.updateChargeMeter()
@ -741,6 +750,9 @@ func (lp *LoadPoint) Update(sitePower float64) {
// check if car connected and ready for charging
var err error
// track if remote disabled is actually active
remoteDisabled := RemoteEnable
// execute loading strategy
switch {
case !lp.connected():
@ -755,6 +767,11 @@ func (lp *LoadPoint) Update(sitePower float64) {
}
err = lp.handler.Ramp(targetCurrent, true)
// OCPP
case lp.remoteControlled(RemoteHardDisable):
remoteDisabled = RemoteHardDisable
fallthrough
case mode == api.ModeOff:
err = lp.handler.Ramp(0, true)
@ -775,9 +792,19 @@ func (lp *LoadPoint) Update(sitePower float64) {
required = true
}
// Sunny Home Manager
if mode == api.ModePV && lp.remoteControlled(RemoteSoftDisable) {
remoteDisabled = RemoteSoftDisable
targetCurrent = 0
required = true
}
err = lp.handler.Ramp(targetCurrent, required)
}
// effective disabled status
lp.publish("remoteDisabled", remoteDisabled)
if err != nil {
lp.log.ERROR.Println(err)
}

View file

@ -21,6 +21,7 @@ type LoadPointSettingsAPI interface {
SetTargetSoC(int) error
GetMinSoC() int
SetMinSoC(int) error
RemoteControl(string, RemoteDemand)
}
// LoadPointEnergyAPI is the external loadpoint API
@ -109,6 +110,24 @@ func (lp *LoadPoint) SetMinSoC(soc int) error {
return nil
}
// RemoteControl sets remote status demand
func (lp *LoadPoint) RemoteControl(source string, demand RemoteDemand) {
lp.Lock()
defer lp.Unlock()
lp.log.INFO.Println("remote demand:", demand)
// apply immediately
if lp.remoteDemand != demand {
lp.remoteDemand = demand
lp.publish("remoteDisabled", demand)
lp.publish("remoteDisabledSource", source)
lp.requestUpdate()
}
}
// HasChargeMeter determines if a physical charge meter is attached
func (lp *LoadPoint) HasChargeMeter() bool {
_, isWrapped := lp.chargeMeter.(*wrapper.ChargeMeter)

11
core/remote.go Normal file
View file

@ -0,0 +1,11 @@
package core
// RemoteDemand defines external status demand
type RemoteDemand string
// remote status demand definition
const (
RemoteEnable RemoteDemand = ""
RemoteHardDisable RemoteDemand = "hard"
RemoteSoftDisable RemoteDemand = "soft"
)

View file

@ -62,3 +62,11 @@ func (site *Site) SetMinSoC(soc int) error {
}
return nil
}
// RemoteControl sets remote status demand
func (site *Site) RemoteControl(source string, demand RemoteDemand) {
site.log.INFO.Println("remote demand:", demand)
for _, lp := range site.loadpoints {
lp.RemoteControl(source, demand)
}
}

5
go.mod
View file

@ -9,6 +9,7 @@ require (
github.com/avast/retry-go v2.6.0+incompatible
github.com/benbjohnson/clock v1.0.3
github.com/containrrr/shoutrrr v0.0.0-20200721140131-bafc331a1968
github.com/denisbrodbeck/machineid v1.0.1
github.com/eclipse/paho.mqtt.golang v1.2.0
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
github.com/godbus/dbus/v5 v5.0.3
@ -29,6 +30,7 @@ require (
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/lorenzodonini/ocpp-go v0.12.0
github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40
github.com/mitchellh/mapstructure v1.3.2
github.com/mjibson/esc v0.2.0
@ -44,7 +46,8 @@ require (
github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c
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
)
replace github.com/lorenzodonini/ocpp-go => github.com/andig/ocpp-go v0.12.1-0.20201110113243-43b1af9c1480

20
go.sum
View file

@ -25,6 +25,10 @@ github.com/andig/evcc-config v0.0.0-20201030211256-8664d1cdae95 h1:GsXyGiwc3gf1X
github.com/andig/evcc-config v0.0.0-20201030211256-8664d1cdae95/go.mod h1:N0hIjIy+5E2AR1fF7Tg2IzBlblBrnFvCCaDGAaHzbWk=
github.com/andig/gosunspec v0.0.0-20200429133549-3cf6a82fed9c h1:AMtX56iHlNYVxMID7fe9efuVtaxgtdjyMeolg7q87IE=
github.com/andig/gosunspec v0.0.0-20200429133549-3cf6a82fed9c/go.mod h1:YkshK8WMzYn1iXAZzHUO75gIqhMSan2ctgBVtBkRIyA=
github.com/andig/ocpp-go v0.12.1-0.20201110090118-4fdb491db96c h1:2pVN47ILzwG8KkDMw+inI2VVLi+q2o4j1DEOhHIvGtA=
github.com/andig/ocpp-go v0.12.1-0.20201110090118-4fdb491db96c/go.mod h1:mN2Tv8bNqQlFMECj3IqcjZL7fOi3PmNnyVuEjICdxgU=
github.com/andig/ocpp-go v0.12.1-0.20201110113243-43b1af9c1480 h1:5yKucDmOcJ6vRNeAn7PCRqYwU3/UrPY4dhwBUoNR+ug=
github.com/andig/ocpp-go v0.12.1-0.20201110113243-43b1af9c1480/go.mod h1:mN2Tv8bNqQlFMECj3IqcjZL7fOi3PmNnyVuEjICdxgU=
github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
@ -60,6 +64,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/deepmap/oapi-codegen v1.3.6/go.mod h1:aBozjEveG+33xPiP55Iw/XbVkhtZHEGLq3nxlX0+hfU=
github.com/deepmap/oapi-codegen v1.3.9 h1:8e3ZfNFzwMDJ5kojta1GGS2+RjzWIThwDWgwe9GqQrE=
github.com/deepmap/oapi-codegen v1.3.9/go.mod h1:suMvK7+rKlx3+tpa8ByptmvoXbAV70wERKTOGH3hLp0=
github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/eclipse/paho.mqtt.golang v1.2.0 h1:1F8mhG9+aO5/xpdtFkW4SxOJB67ukuDC3t2y2qayIX0=
@ -85,6 +91,10 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc=
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM=
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU=
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
@ -133,10 +143,12 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORR
github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gregdel/pushover v0.0.0-20200416074932-c8ad547caed4 h1:QZVozMeLCqyMOOhA+OuqQdNXkyu4uUQEu4+mPBB5pPQ=
@ -228,6 +240,8 @@ 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=
github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8=
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc h1:RKf14vYWi2ttpEmkA4aQ3j4u9dStX2t4M8UM6qqNsG8=
github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc/go.mod h1:kopuH9ugFRkIXf3YoqHKyrJ9YfUFsckUU9S7B+XP+is=
github.com/lestrrat-go/strftime v1.0.1 h1:o7qz5pmLzPDLyGW4lG6JvTKPUfTFXwe+vOamIYWtnVU=
@ -317,6 +331,7 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
@ -349,6 +364,7 @@ github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5q
github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk=
github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@ -550,6 +566,10 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
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=
gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v9 v9.30.0 h1:Wk0Z37oBmKj9/n+tPyBHZmeL19LaCoK3Qq48VwYENss=
gopkg.in/go-playground/validator.v9 v9.30.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.57.0 h1:9unxIsFcTt4I55uWluz+UmL95q4kdJ0buvQ1ZIqVQww=

View file

@ -5,6 +5,7 @@ import (
"strings"
"github.com/andig/evcc/core"
"github.com/andig/evcc/hems/ocpp"
"github.com/andig/evcc/hems/semp"
"github.com/andig/evcc/server"
"github.com/andig/evcc/util"
@ -16,10 +17,12 @@ type HEMS interface {
}
// NewFromConfig creates new HEMS from config
func NewFromConfig(typ string, site *core.Site, cache *util.Cache, httpd *server.HTTPd) (HEMS, error) {
func NewFromConfig(typ string, other map[string]interface{}, 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)
case "ocpp":
return ocpp.New(other, site, cache)
default:
return nil, errors.New("unknown hems: " + typ)
}

135
hems/ocpp/configmap.go Normal file
View file

@ -0,0 +1,135 @@
package ocpp
import (
"strconv"
"github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
"github.com/lorenzodonini/ocpp-go/ocpp1.6/types"
)
// modified from https://github.com/lorenzodonini/ocpp-go/tree/master/example/1.6/cp
// configuration options
const (
AuthorizeRemoteTxRequests string = "AuthorizeRemoteTxRequests"
ClockAlignedDataInterval string = "ClockAlignedDataInterval"
ConnectionTimeOut string = "ConnectionTimeOut"
ConnectorPhaseRotation string = "ConnectorPhaseRotation"
GetConfigurationMaxKeys string = "GetConfigurationMaxKeys"
HeartbeatInterval string = "HeartbeatInterval"
LocalAuthorizeOffline string = "LocalAuthorizeOffline"
LocalPreAuthorize string = "LocalPreAuthorize"
MeterValuesAlignedData string = "MeterValuesAlignedData"
MeterValuesSampledData string = "MeterValuesSampledData"
MeterValueSampleInterval string = "MeterValueSampleInterval"
NumberOfConnectors string = "NumberOfConnectors"
ResetRetries string = "ResetRetries"
StopTransactionOnEVSideDisconnect string = "StopTransactionOnEVSideDisconnect"
StopTransactionOnInvalidID string = "StopTransactionOnInvalidId"
StopTxnAlignedData string = "StopTxnAlignedData"
StopTxnSampledData string = "StopTxnSampledData"
SupportedFeatureProfiles string = "SupportedFeatureProfiles"
TransactionMessageAttempts string = "TransactionMessageAttempts"
TransactionMessageRetryInterval string = "TransactionMessageRetryInterval"
UnlockConnectorOnEVSideDisconnect string = "UnlockConnectorOnEVSideDisconnect"
WebSocketPingInterval string = "WebSocketPingInterval"
LocalAuthListEnabled string = "LocalAuthListEnabled"
LocalAuthListMaxLength string = "LocalAuthListMaxLength"
SendLocalListMaxLength string = "SendLocalListMaxLength"
ChargeProfileMaxStackLevel string = "ChargeProfileMaxStackLevel"
ChargingScheduleAllowedChargingRateUnit string = "ChargingScheduleAllowedChargingRateUnit"
ChargingScheduleMaxPeriods string = "ChargingScheduleMaxPeriods"
MaxChargingProfilesInstalled string = "MaxChargingProfilesInstalled"
)
// ConfigMap defines the active configuration settings
type ConfigMap map[string]core.ConfigurationKey
// func (c ConfigMap) updateInt(key string, i int64) {
// configKey, ok := c[key]
// if ok {
// configKey.Value = strconv.FormatInt(i, 10)
// }
// c[key] = configKey
// }
// func (c ConfigMap) updateBool(key string, b bool) {
// configKey, ok := c[key]
// if ok {
// configKey.Value = strconv.FormatBool(b)
// }
// c[key] = configKey
// }
// func (c ConfigMap) getInt(key string) (int, bool) {
// configKey, ok := c[key]
// if !ok {
// return 0, ok
// }
// result, err := strconv.ParseInt(configKey.Value, 10, 32)
// if err != nil {
// return 0, false
// }
// return int(result), true
// }
// func (c ConfigMap) getBool(key string) (bool, bool) {
// configKey, ok := c[key]
// if !ok {
// return false, ok
// }
// result, err := strconv.ParseBool(configKey.Value)
// if err != nil {
// return false, false
// }
// return result, true
// }
func (c ConfigMap) set(key string, readonly bool, value string) {
c[key] = core.ConfigurationKey{
Key: key,
Readonly: readonly,
Value: value,
}
}
func getDefaultConfig() ConfigMap {
intBase := 10
var cfg ConfigMap = make(map[string]core.ConfigurationKey)
// readonly
cfg.set(SupportedFeatureProfiles, true, core.ProfileName)
cfg.set(AuthorizeRemoteTxRequests, true, strconv.FormatBool(false))
cfg.set(GetConfigurationMaxKeys, true, strconv.FormatInt(50, intBase))
cfg.set(NumberOfConnectors, true, strconv.FormatInt(1, intBase))
cfg.set(LocalAuthListMaxLength, true, strconv.FormatInt(100, intBase))
cfg.set(SendLocalListMaxLength, true, strconv.FormatInt(20, intBase))
cfg.set(ChargeProfileMaxStackLevel, true, strconv.FormatInt(10, intBase))
cfg.set(ChargingScheduleAllowedChargingRateUnit, true, "Power")
cfg.set(ChargingScheduleMaxPeriods, true, strconv.FormatInt(5, intBase))
cfg.set(MaxChargingProfilesInstalled, true, strconv.FormatInt(10, intBase))
// read/write
cfg.set(ClockAlignedDataInterval, false, strconv.FormatInt(0, intBase))
cfg.set(ConnectionTimeOut, false, strconv.FormatInt(60, intBase))
cfg.set(ConnectorPhaseRotation, false, "Unknown")
cfg.set(HeartbeatInterval, false, strconv.FormatInt(86400, intBase))
cfg.set(LocalAuthorizeOffline, false, strconv.FormatBool(true))
cfg.set(LocalAuthListEnabled, false, strconv.FormatBool(true))
cfg.set(LocalPreAuthorize, false, strconv.FormatBool(false))
cfg.set(MeterValuesAlignedData, false, string(types.MeasurandEnergyActiveExportRegister))
cfg.set(MeterValuesSampledData, false, string(types.MeasurandEnergyActiveExportRegister))
cfg.set(MeterValueSampleInterval, false, strconv.FormatInt(5, intBase))
cfg.set(ResetRetries, false, strconv.FormatInt(10, intBase))
cfg.set(StopTransactionOnEVSideDisconnect, false, strconv.FormatBool(true))
cfg.set(StopTransactionOnInvalidID, false, strconv.FormatBool(true))
cfg.set(StopTxnAlignedData, false, strconv.FormatBool(true))
cfg.set(StopTxnSampledData, false, string(types.MeasurandEnergyActiveExportRegister))
cfg.set(TransactionMessageAttempts, false, strconv.FormatInt(5, intBase))
cfg.set(TransactionMessageRetryInterval, false, strconv.FormatInt(60, intBase))
cfg.set(UnlockConnectorOnEVSideDisconnect, false, strconv.FormatBool(true))
cfg.set(WebSocketPingInterval, false, strconv.FormatInt(54, intBase))
return cfg
}

View file

@ -0,0 +1,40 @@
package ocpp
import "github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
// OnGetConfiguration handles the CS message
func (s *OCPP) OnGetConfiguration(request *core.GetConfigurationRequest) (confirmation *core.GetConfigurationConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
var resultKeys []core.ConfigurationKey
var unknownKeys []string
for _, key := range request.Key {
configKey, ok := s.configuration[key]
if !ok {
unknownKeys = append(unknownKeys, configKey.Value)
} else {
resultKeys = append(resultKeys, configKey)
}
}
// return config for all keys
if len(request.Key) == 0 {
for _, v := range s.configuration {
resultKeys = append(resultKeys, v)
}
}
s.log.TRACE.Printf("%s: configuration for requested keys: %v", request.GetFeatureName(), request.Key)
conf := core.NewGetConfigurationConfirmation(resultKeys)
conf.UnknownKey = unknownKeys
return conf, nil
}
// OnChangeConfiguration handles the CS message
func (s *OCPP) OnChangeConfiguration(request *core.ChangeConfigurationRequest) (confirmation *core.ChangeConfigurationConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return core.NewChangeConfigurationConfirmation(core.ConfigurationStatusAccepted), nil
}

23
hems/ocpp/core_other.go Normal file
View file

@ -0,0 +1,23 @@
package ocpp
import (
"github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
)
// OnClearCache handles the CS message
func (s *OCPP) OnClearCache(request *core.ClearCacheRequest) (confirmation *core.ClearCacheConfirmation, err error) {
s.log.TRACE.Printf("recv: %s", request.GetFeatureName())
return core.NewClearCacheConfirmation(core.ClearCacheStatusAccepted), nil
}
// OnDataTransfer handles the CS message
func (s *OCPP) OnDataTransfer(request *core.DataTransferRequest) (confirmation *core.DataTransferConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return core.NewDataTransferConfirmation(core.DataTransferStatusAccepted), nil
}
// OnReset handles the CS message
func (s *OCPP) OnReset(request *core.ResetRequest) (confirmation *core.ResetConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return core.NewResetConfirmation(core.ResetStatusAccepted), nil
}

View file

@ -0,0 +1,30 @@
package ocpp
import (
"github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
"github.com/lorenzodonini/ocpp-go/ocpp1.6/types"
)
// OnChangeAvailability handles the CS message
func (s *OCPP) OnChangeAvailability(request *core.ChangeAvailabilityRequest) (confirmation *core.ChangeAvailabilityConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return core.NewChangeAvailabilityConfirmation(core.AvailabilityStatusRejected), nil
}
// OnUnlockConnector handles the CS message
func (s *OCPP) OnUnlockConnector(request *core.UnlockConnectorRequest) (confirmation *core.UnlockConnectorConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return core.NewUnlockConnectorConfirmation(core.UnlockStatusUnlocked), nil
}
// OnRemoteStartTransaction handles the CS message
func (s *OCPP) OnRemoteStartTransaction(request *core.RemoteStartTransactionRequest) (confirmation *core.RemoteStartTransactionConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return core.NewRemoteStartTransactionConfirmation(types.RemoteStartStopStatusRejected), nil
}
// OnRemoteStopTransaction handles the CS message
func (s *OCPP) OnRemoteStopTransaction(request *core.RemoteStopTransactionRequest) (confirmation *core.RemoteStopTransactionConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return core.NewRemoteStopTransactionConfirmation(types.RemoteStartStopStatusRejected), nil
}

106
hems/ocpp/ocpp.go Normal file
View file

@ -0,0 +1,106 @@
package ocpp
import (
"fmt"
"math/rand"
"strings"
"time"
"github.com/andig/evcc/core"
"github.com/andig/evcc/util"
"github.com/denisbrodbeck/machineid"
ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6"
ocppcore "github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
"github.com/lorenzodonini/ocpp-go/ws"
)
// OCPP is an OCPP client
type OCPP struct {
log *util.Logger
cache *util.Cache
site site
cp ocpp16.ChargePoint
configuration ConfigMap
}
// site is the minimal interface for accessing site methods
type site interface {
LoadPoints() []core.LoadPointAPI
}
const retryTimeout = 5 * time.Second
// New generates OCPP chargepoint client
func New(conf map[string]interface{}, site site, cache *util.Cache) (*OCPP, error) {
cc := struct {
URI string
StationID string
}{}
if err := util.DecodeOther(conf, &cc); err != nil {
return nil, err
}
log := util.NewLogger("ocpp")
if cc.StationID == "" {
id, err := machineid.ID()
if err == nil {
cc.StationID = fmt.Sprintf("evcc-%s", strings.ToLower(id))
} else {
cc.StationID = fmt.Sprintf("evcc-%d", rand.Int31())
}
log.DEBUG.Println("station id:", cc.StationID)
}
ws := ws.NewClient()
cp := ocpp16.NewChargePoint(cc.StationID, nil, ws)
s := &OCPP{
log: log,
cache: cache,
site: site,
cp: cp,
configuration: getDefaultConfig(),
}
err := cp.Start(cc.URI)
if err == nil {
cp.SetCoreHandler(s)
go s.errorHandler(ws.Errors())
go s.errorHandler(cp.Errors())
}
return s, err
}
// errorHandler logs error channel
func (s *OCPP) errorHandler(errC <-chan error) {
for err := range errC {
s.log.ERROR.Println(err)
}
}
// Run executes the OCPP chargepoint client
func (s *OCPP) Run() {
for {
for id := range s.site.LoadPoints() {
connector := id + 1
status := ocppcore.ChargePointStatusAvailable
if statusP, err := s.cache.GetChecked(id, "charging"); err == nil {
if statusP.Val.(bool) {
status = ocppcore.ChargePointStatusCharging
}
}
s.log.TRACE.Printf("send: lp-%d status: %+v", connector, status)
if _, err := s.cp.StatusNotification(connector, ocppcore.NoError, status); err != nil {
s.log.ERROR.Printf("lp-%d: %v", connector, err)
}
}
time.Sleep(retryTimeout)
}
}

View file

@ -7,6 +7,7 @@ const (
urnSEMPService = "urn:schemas-simple-energy-management-protocol:service-1-0"
)
// DeviceDescription message definition
type DeviceDescription struct {
XMLName xml.Name `xml:"root"`
Xmlns string `xml:"xmlns,attr"`
@ -14,22 +15,25 @@ type DeviceDescription struct {
Device Device `xml:"device"`
}
// SpecVersion message definition
type SpecVersion struct {
Major int `xml:"major"`
Minor int `xml:"minor"`
}
// Device message definition
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
DeviceType string `xml:"deviceType"`
FriendlyName string `xml:"friendlyName"`
Manufacturer string `xml:"manufacturer"`
ModelName string `xml:"modelName"`
UDN string `xml:"UDN"`
PresentationURL string `xml:"presentationURL"`
ServiceDefinition ServiceDefinition `xml:"semp:X_SEMPSERVICE"`
ServiceList []Service `xml:"serviceList"` // optional
}
// Service message definition
type Service struct {
ServiceType string `xml:"serviceType"`
ServiceID string `xml:"serviceId"`
@ -38,7 +42,8 @@ type Service struct {
EventSubURL string `xml:"eventSubURL"`
}
type SEMPService struct {
// ServiceDefinition message definition
type ServiceDefinition struct {
Xmlns string `xml:"xmlns:semp,attr"`
Server string `xml:"semp:server"`
BasePath string `xml:"semp:basePath"`
@ -55,12 +60,14 @@ type Device2EM struct {
PlanningRequest []PlanningRequest `xml:",omitempty"`
}
// DeviceInfo message definition
type DeviceInfo struct {
Identification Identification
Characteristics Characteristics
Capabilities Capabilities
}
// Identification message definition
type Identification struct {
DeviceID string `xml:"DeviceId"`
DeviceName string
@ -69,6 +76,7 @@ type Identification struct {
DeviceVendor string
}
// Characteristics message definition
type Characteristics struct {
MinPowerConsumption int
MaxPowerConsumption int
@ -76,11 +84,13 @@ type Characteristics struct {
MinOffTime int `xml:",omitempty"`
}
// method definitions
const (
MethodMeasurement = "Measurement"
MethodEstimation = "Estimation"
)
// Capabilities message definition
type Capabilities struct {
CurrentPowerMethod string `xml:"CurrentPower>Method"`
AbsoluteTimestamps bool `xml:"Timestamps>AbsoluteTimestamps"`
@ -88,11 +98,13 @@ type Capabilities struct {
OptionalEnergy bool `xml:"Requests>OptionalEnergy"`
}
// status definitions
const (
StatusOn = "On"
StatusOff = "Off"
)
// DeviceStatus message definition
type DeviceStatus struct {
DeviceID string `xml:"DeviceId"`
EMSignalsAccepted bool
@ -100,16 +112,19 @@ type DeviceStatus struct {
PowerInfo PowerInfo `xml:"PowerConsumption>PowerInfo"`
}
// PowerInfo message definition
type PowerInfo struct {
AveragePower int
Timestamp int
AveragingInterval int
}
// PlanningRequest message definition
type PlanningRequest struct {
Timeframe []Timeframe
}
// Timeframe message definition
type Timeframe struct {
DeviceID string `xml:"DeviceId"`
EarliestStart int
@ -126,6 +141,7 @@ type EM2Device struct {
DeviceControl []DeviceControl `xml:",omitempty"`
}
// DeviceControl message definition
type DeviceControl struct {
DeviceID string `xml:"DeviceId"`
On bool

View file

@ -2,7 +2,6 @@ package semp
import (
"encoding/xml"
"errors"
"fmt"
"io/ioutil"
"net"
@ -22,7 +21,8 @@ import (
)
const (
sempBaseUrlEnv = "SEMP_BASE_URL"
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"
@ -44,16 +44,11 @@ type SEMP struct {
uid string
hostURI string
port int
site site
}
// site is the minimal interface for accessing site methods
type site interface {
LoadPoints() []core.LoadPointAPI
site core.SiteAPI
}
// New generates SEMP Gateway listening at /semp endpoint
func New(site site, cache *util.Cache, httpd *server.HTTPd) (*SEMP, error) {
func New(site core.SiteAPI, cache *util.Cache, httpd *server.HTTPd) (*SEMP, error) {
uid, err := uuid.NewUUID()
if err != nil {
return nil, err
@ -142,7 +137,7 @@ func (s *SEMP) Done() chan struct{} {
}
func (s *SEMP) callbackURI() string {
if uri := os.Getenv(sempBaseUrlEnv); uri != "" {
if uri := os.Getenv(sempBaseURLEnv); uri != "" {
return strings.TrimSuffix(uri, "/")
}
@ -151,11 +146,11 @@ func (s *SEMP) callbackURI() string {
if len(ips) > 0 {
ip = ips[0].String()
} else {
s.log.ERROR.Printf("couldn't determine ip address- specify %s to override", sempBaseUrlEnv)
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)
s.log.WARN.Printf("%s unspecified, using %s instead", sempBaseURLEnv, uri)
return uri
}
@ -177,6 +172,8 @@ func (s *SEMP) handlers(router *mux.Router) {
}
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)
@ -201,7 +198,7 @@ func (s *SEMP) gatewayDescription(w http.ResponseWriter, r *http.Request) {
ModelName: serverName,
PresentationURL: s.hostURI,
UDN: uid,
SEMPService: SEMPService{
ServiceDefinition: ServiceDefinition{
Xmlns: urnSEMPService,
Server: s.hostURI,
BasePath: basePath,
@ -303,18 +300,6 @@ 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.LoadPointAPI) DeviceInfo {
method := MethodEstimation
if lp.HasChargeMeter() {
@ -353,12 +338,19 @@ func (s *SEMP) allDeviceInfo() (res []DeviceInfo) {
func (s *SEMP) deviceStatus(id int, lp core.LoadPointAPI) DeviceStatus {
var chargePower float64
if chargePowerP, err := s.cacheGet(id, "chargePower"); err == nil {
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.cacheGet(id, "charging"); err == nil {
if statusP, err := s.cache.GetChecked(id, "charging"); err == nil {
if statusP.Val.(bool) {
status = StatusOn
}
@ -366,7 +358,7 @@ func (s *SEMP) deviceStatus(id int, lp core.LoadPointAPI) DeviceStatus {
res := DeviceStatus{
DeviceID: s.deviceID(id),
EMSignalsAccepted: true,
EMSignalsAccepted: isPV,
PowerInfo: PowerInfo{
AveragePower: int(chargePower),
AveragingInterval: 60,
@ -387,17 +379,17 @@ func (s *SEMP) allDeviceStatus() (res []DeviceStatus) {
func (s *SEMP) planningRequest(id int, lp core.LoadPointAPI) (res PlanningRequest) {
mode := api.ModeOff
if modeP, err := s.cacheGet(id, "mode"); err == nil {
if modeP, err := s.cache.GetChecked(id, "mode"); err == nil {
mode = api.ChargeMode(modeP.Val.(string))
}
var charging bool
if chargingP, err := s.cacheGet(id, "charging"); err == nil {
if chargingP, err := s.cache.GetChecked(id, "charging"); err == nil {
charging = chargingP.Val.(bool)
}
chargeEstimate := time.Duration(-1)
if chargeEstimateP, err := s.cacheGet(id, "chargeEstimate"); err == nil {
if chargeEstimateP, err := s.cache.GetChecked(id, "chargeEstimate"); err == nil {
chargeEstimate = chargeEstimateP.Val.(time.Duration)
}
@ -407,7 +399,7 @@ func (s *SEMP) planningRequest(id int, lp core.LoadPointAPI) (res PlanningReques
}
var maxEnergy int
if chargeRemainingEnergyP, err := s.cacheGet(id, "chargeRemainingEnergy"); err == nil {
if chargeRemainingEnergyP, err := s.cache.GetChecked(id, "chargeRemainingEnergy"); err == nil {
maxEnergy = int(chargeRemainingEnergyP.Val.(float64))
}
@ -423,7 +415,7 @@ func (s *SEMP) planningRequest(id int, lp core.LoadPointAPI) (res PlanningReques
if charging {
res = PlanningRequest{
Timeframe: []Timeframe{Timeframe{
Timeframe: []Timeframe{{
DeviceID: s.deviceID(id),
EarliestStart: 0,
LatestEnd: latestEnd,
@ -462,5 +454,27 @@ func (s *SEMP) deviceControlHandler(w http.ResponseWriter, r *http.Request) {
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)
}

View file

@ -24277,87 +24277,90 @@ Pp3/GoRPOTyAIZY5uQvssmEOLnEm3H4CAAD//9PqMyRSAQAA
"/dist/index.html": {
name: "index.html",
local: "dist/index.html",
size: 19473,
size: 20313,
modtime: 1566640112,
compressed: `
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
ec/TuUBqLzCC73f0RMpz0+P2Tbk+r5ne7eZg18FhJznJydblN+7b7SiPJvaQ0XkrVn8q76FOuzvtzsvt
Oy+P/eN//3tnz76Nyd7nepLGRCUb7wndi5l3HypN35kX907VP52T6l7QfVXTcQ/pD+Fpw2T7X7msc6Hq
kXu/0r7huG7/Db6m8a47Dtt8dY+3bvprveSW8709l563UJjseLery4HvYivQvEw+3XiLd4etrHktWg17
zOXeBrP3kscu5t4lRCHvx5ejv8wAqtvb7AGJeUP5EC60FOk9CEopP8ulRK5vb799SKLI9RcQRa57EvVH
W7g9D0la3rn5luQRJJOtzt7D4c2/Kk9overjkNf1YBfP3VrbUFbZxELGpu1Xm/oprFEi/eO//qd152Ar
6b1p+yvJyCeqf/9N35++M5KRiOqb29tL28reT17bNHZfrep8n2QhhEZZ1+mFgWiRufcr7HuyWVicOma6
+K+4srctqd08F96Upf2pnG3t/r6v2zi/UuH8fMu8+c2+h/O0J85IpCnVI/t+svlUIqu+2Nc2oeor178J
sGEqlh1Z/YzSVk6fRCK7eQWTw8nhBs7Wn0Jd6wJEyaj5E1E4niCS8WL4H/a3G2otj+yPQgUj+0t6/xcA
AP//9GuoBVlPAAA=
`,
},

View file

@ -1,6 +1,7 @@
package util
import (
"errors"
"sync"
)
@ -91,3 +92,15 @@ func (c *Cache) Get(key string) Param {
return Param{}
}
// GetChecked returns checked value from cache
func (c *Cache) GetChecked(id int, key string) (res Param, err error) {
pid := Param{LoadPoint: &id, Key: key}
res = c.Get(pid.UniqueID())
if res.Key == "" {
err = errors.New("not found")
}
return res, err
}