diff --git a/README.md b/README.md index 936c67d6c..c9ddc16fc 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ EVCC is an extensible EV Charge Controller with PV integration implemented in [G - simple and clean user interface - multiple [chargers](#charger): - Wallbe, Phoenix (includes ESL Walli), go-eCharger, NRGkick (direct Bluetooth or via Connect device), SimpleEVSE, EVSEWifi, KEBA/BMW, openWB, Mobile Charger Connect and any other charger using scripting - - Smart-Home outlets: FritzDECT, Tasmota, TP-Link + - Smart-Home outlets: FritzDECT, Shelly, Tasmota, TP-Link - multiple [meters](#meter): ModBus (Eastron SDM, MPM3PM, SBC ALE3 and many more), Discovergy (using HTTP plugin), SMA Sunny Home Manager and Energy Meter, KOSTAL Smart Energy Meter (KSEM, EMxx), any Sunspec-compatible inverter or home battery devices (Fronius, SMA, SolarEdge, KOSTAL, STECA, E3DC, ...), Tesla PowerWall - wide support of vendor-specific [vehicles](#vehicle) interfaces (remote charge, battery and preconditioning status): Audi, BMW, Ford, Hyundai, Kia, Nissan, Niu, Porsche, Renault, Seat, Skoda, Tesla, Volkswagen, Volvo and any other connected vehicle using scripting - [plugins](#plugins) for integrating with hardware devices and home automation: Modbus (meters and grid inverters), HTTP, MQTT, Javascript, WebSockets and shell scripts @@ -188,6 +188,7 @@ Available charger implementations are: Smart-Home outlet charger implementations: - `fritzdect`: Fritz!DECT 200/210 outlets +- `shelly`: Shelly outlets - `tasmota`: Tasmota outlets - `tplink`: TP-Link HSXXX series outlets diff --git a/detect/definitions.go b/detect/definitions.go index 4d8bd161e..8bae831ae 100644 --- a/detect/definitions.go +++ b/detect/definitions.go @@ -38,6 +38,7 @@ const ( taskMeter = "meter" taskFronius = "fronius" taskTasmota = "tasmota" + taskShelly = "shelly" // taskTPLink = "tplink" ) @@ -252,7 +253,7 @@ func init() { Type: tasks.Http, Depends: TaskHttp, Config: map[string]interface{}{ - "path": "//cm?cmnd=Module", + "path": "/cm?cmnd=Module", "jq": ".Module", }, }) @@ -277,4 +278,14 @@ func init() { "jq": ".version", }, }) + + taskList.Add(tasks.Task{ + ID: taskShelly, + Type: tasks.Http, + Depends: TaskHttp, + Config: map[string]interface{}{ + "path": "/shelly", + "jq": ".type", + }, + }) } diff --git a/internal/charger/shelly.go b/internal/charger/shelly.go new file mode 100644 index 000000000..d601e71c9 --- /dev/null +++ b/internal/charger/shelly.go @@ -0,0 +1,129 @@ +package charger + +import ( + "errors" + "fmt" + "strings" + + "github.com/andig/evcc/api" + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/request" +) + +// Shelly api homepage +// https://shelly-api-docs.shelly.cloud/#common-http-api + +// shellyRelayResponse provides the evcc shelly charger enabled information +type shellyRelayResponse struct { + Ison bool `json:"ison,omitempty"` +} + +// shellyStatusResponse provides the evcc shelly charger current power information +type shellyStatusResponse struct { + Meters []struct { + Power float64 `json:"power,omitempty"` + } `json:"meters,omitempty"` +} + +// Shelly charger implementation +type Shelly struct { + *request.Helper + uri string + channel int + standbypower float64 +} + +func init() { + registry.Add("shelly", NewShellyFromConfig) +} + +// NewShellyFromConfig creates a Shelly charger from generic config +func NewShellyFromConfig(other map[string]interface{}) (api.Charger, error) { + cc := struct { + URI string + Channel int + StandbyPower float64 + }{} + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if cc.URI == "" { + return nil, errors.New("missing uri") + } + + return NewShelly(cc.URI, cc.Channel, cc.StandbyPower) +} + +// NewShelly creates Shelly charger +func NewShelly(uri string, channel int, standbypower float64) (*Shelly, error) { + c := &Shelly{ + Helper: request.NewHelper(util.NewLogger("shelly")), + uri: strings.TrimRight(uri, "/"), + channel: channel, + standbypower: standbypower, + } + + return c, nil +} + +// Enabled implements the api.Charger interface +func (c *Shelly) Enabled() (bool, error) { + var resp shellyRelayResponse + err := c.GetJSON(fmt.Sprintf("%s/relay/%d", c.uri, c.channel), &resp) + + return resp.Ison, err +} + +// Enable implements the api.Charger interface +func (c *Shelly) Enable(enable bool) error { + var resp shellyRelayResponse + onoff := map[bool]string{true: "on", false: "off"} + err := c.GetJSON(fmt.Sprintf("%s/relay/%d?turn=%s", c.uri, c.channel, onoff[enable]), &resp) + + switch { + case err != nil: + return err + case enable && !resp.Ison: + return errors.New("switchOn failed") + case !enable && resp.Ison: + return errors.New("switchOff failed") + default: + return nil + } +} + +// MaxCurrent implements the api.Charger interface +func (c *Shelly) MaxCurrent(current int64) error { + return nil +} + +// Status implements the api.Charger interface +func (c *Shelly) Status() (api.ChargeStatus, error) { + power, err := c.CurrentPower() + if power > 0 { + return api.StatusC, err + } + return api.StatusB, err +} + +var _ api.Meter = (*Shelly)(nil) + +// CurrentPower implements the api.Meter interface +func (c *Shelly) CurrentPower() (float64, error) { + var resp shellyStatusResponse + err := c.GetJSON(fmt.Sprintf("%s/%s", c.uri, "status"), &resp) + + if c.channel >= len(resp.Meters) { + return 0, errors.New("invalid channel, power meter missing") + } + power := resp.Meters[c.channel].Power + + // ignore standby power + if power < c.standbypower { + power = 0 + } + + return power, err +}