Add Shelly Top AC charger (#26520)
This commit is contained in:
parent
4bead80886
commit
bfb0caec6b
3 changed files with 291 additions and 0 deletions
229
charger/shelly-topac.go
Normal file
229
charger/shelly-topac.go
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
package charger
|
||||
|
||||
// LICENSE
|
||||
|
||||
// Copyright (c) evcc.io (andig, naltatis, premultiply)
|
||||
|
||||
// This module is NOT covered by the MIT license. All rights reserved.
|
||||
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/charger/shelly"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/evcc-io/evcc/util/sponsor"
|
||||
"github.com/evcc-io/evcc/util/transport"
|
||||
"github.com/jpfielding/go-http-digest/pkg/digest"
|
||||
)
|
||||
|
||||
// ShellyTopAC charger implementation for Shelly Top AC Portable EV Charger
|
||||
// API Reference: https://shelly-api-docs.shelly.cloud/gen2/Devices/ShellyX/XT1/TopACPortableEVCharger/
|
||||
type ShellyTopAC struct {
|
||||
*request.Helper
|
||||
uri string
|
||||
current int64
|
||||
phaseG util.Cacheable[shelly.Measurements]
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.Add("shelly-topac", NewShellyTopACFromConfig)
|
||||
}
|
||||
|
||||
// NewShellyTopACFromConfig creates a Shelly Top AC charger from generic config
|
||||
func NewShellyTopACFromConfig(other map[string]any) (api.Charger, error) {
|
||||
cc := struct {
|
||||
URI string
|
||||
User string
|
||||
Password string
|
||||
}{}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewShellyTopAC(cc.URI, cc.User, cc.Password)
|
||||
}
|
||||
|
||||
// NewShellyTopAC creates Shelly Top AC charger
|
||||
func NewShellyTopAC(uri, user, password string) (api.Charger, error) {
|
||||
if !sponsor.IsAuthorized() {
|
||||
return nil, api.ErrSponsorRequired
|
||||
}
|
||||
|
||||
// normalize URI
|
||||
for _, suffix := range []string{"/", "/rpc", "/shelly"} {
|
||||
uri = strings.TrimSuffix(uri, suffix)
|
||||
}
|
||||
uri = util.DefaultScheme(uri, "http")
|
||||
|
||||
log := util.NewLogger("topac")
|
||||
helper := request.NewHelper(log)
|
||||
helper.Transport = request.NewTripper(log, transport.Insecure())
|
||||
|
||||
c := &ShellyTopAC{
|
||||
Helper: helper,
|
||||
uri: fmt.Sprintf("%s/rpc", uri),
|
||||
current: 6, // default minimum current
|
||||
}
|
||||
|
||||
// Setup digest authentication for Shelly Gen2
|
||||
if user != "" {
|
||||
c.Client.Transport = digest.NewTransport(user, password, c.Client.Transport)
|
||||
}
|
||||
|
||||
// Setup cached status getters
|
||||
c.phaseG = util.ResettableCached(c.getPhaseInfo, time.Second)
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// execRpc executes a Shelly Gen2 RPC call
|
||||
func (c *ShellyTopAC) execRpc(method, owner, role string, value, res any) error {
|
||||
data := shelly.RpcRequest{
|
||||
Id: 0,
|
||||
Src: "evcc",
|
||||
Method: method,
|
||||
Params: shelly.RpcRequestParams{
|
||||
Owner: owner,
|
||||
Role: role,
|
||||
Value: value,
|
||||
},
|
||||
}
|
||||
|
||||
req, err := request.New(http.MethodPost, c.uri, request.MarshalJSON(data), request.JSONEncoding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.DoJSON(req, res)
|
||||
}
|
||||
|
||||
// setCurrentLimit sets the charging current limit
|
||||
func (c *ShellyTopAC) setCurrentLimit(current float64) error {
|
||||
var res any
|
||||
return c.execRpc("Number.Set", "service:0", "current_limit", current, &res)
|
||||
}
|
||||
|
||||
// getPhaseInfo retrieves phase information
|
||||
func (c *ShellyTopAC) getPhaseInfo() (shelly.Measurements, error) {
|
||||
var res shelly.RpcResponse[shelly.Measurements]
|
||||
err := c.execRpc("Object.GetStatus", "service:0", "phase_info", nil, &res)
|
||||
|
||||
return res.Result.Value, err
|
||||
}
|
||||
|
||||
// Status implements the api.Charger interface
|
||||
func (c *ShellyTopAC) Status() (api.ChargeStatus, error) {
|
||||
var res shelly.RpcResponse[string]
|
||||
if err := c.execRpc("Enum.GetStatus", "service:0", "work_state", nil, &res); err != nil {
|
||||
return api.StatusNone, err
|
||||
}
|
||||
|
||||
// Possible states: charger_free, charger_wait, charger_pause, charger_charging, charger_complete, charger_error
|
||||
switch res.Result.Value {
|
||||
case "charger_free":
|
||||
return api.StatusA, nil
|
||||
case "charger_wait", "charger_pause", "charger_complete":
|
||||
return api.StatusB, nil
|
||||
case "charger_charging":
|
||||
return api.StatusC, nil
|
||||
default:
|
||||
return api.StatusNone, fmt.Errorf("unknown work state: %s", res.Result.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled implements the api.Charger interface
|
||||
func (c *ShellyTopAC) Enabled() (bool, error) {
|
||||
var res shelly.RpcResponse[float64]
|
||||
err := c.execRpc("Number.GetStatus", "service:0", "current_limit", nil, &res)
|
||||
|
||||
return res.Result.Value > 0, err
|
||||
}
|
||||
|
||||
// Enable implements the api.Charger interface
|
||||
func (c *ShellyTopAC) Enable(enable bool) error {
|
||||
var current int64
|
||||
if enable {
|
||||
current = c.current
|
||||
}
|
||||
|
||||
return c.setCurrentLimit(float64(current))
|
||||
}
|
||||
|
||||
// MaxCurrent implements the api.Charger interface
|
||||
func (c *ShellyTopAC) MaxCurrent(current int64) error {
|
||||
if current < 6 {
|
||||
return fmt.Errorf("invalid current %d", current)
|
||||
}
|
||||
|
||||
err := c.setCurrentLimit(float64(current))
|
||||
if err == nil {
|
||||
c.current = current
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
var _ api.Meter = (*ShellyTopAC)(nil)
|
||||
|
||||
// CurrentPower implements the api.Meter interface
|
||||
func (c *ShellyTopAC) CurrentPower() (float64, error) {
|
||||
phase, err := c.phaseG.Get()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return phase.TotalPower * 1e3, nil
|
||||
}
|
||||
|
||||
var _ api.MeterEnergy = (*ShellyTopAC)(nil)
|
||||
|
||||
// TotalEnergy implements the api.MeterEnergy interface
|
||||
func (c *ShellyTopAC) TotalEnergy() (float64, error) {
|
||||
res, err := c.phaseG.Get()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return res.TotalActEnergy, nil
|
||||
}
|
||||
|
||||
var _ api.PhaseCurrents = (*ShellyTopAC)(nil)
|
||||
|
||||
// Currents implements the api.PhaseCurrents interface
|
||||
func (c *ShellyTopAC) Currents() (float64, float64, float64, error) {
|
||||
res, err := c.phaseG.Get()
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
|
||||
return res.PhaseA.Current, res.PhaseB.Current, res.PhaseC.Current, nil
|
||||
}
|
||||
|
||||
var _ api.PhaseVoltages = (*ShellyTopAC)(nil)
|
||||
|
||||
// Voltages implements the api.PhaseVoltages interface
|
||||
func (c *ShellyTopAC) Voltages() (float64, float64, float64, error) {
|
||||
res, err := c.phaseG.Get()
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
|
||||
return res.PhaseA.Voltage, res.PhaseB.Voltage, res.PhaseC.Voltage, nil
|
||||
}
|
||||
44
charger/shelly/types.go
Normal file
44
charger/shelly/types.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package shelly
|
||||
|
||||
// RpcRequest represents a Shelly Gen2 RPC request
|
||||
type RpcRequest struct {
|
||||
Id int `json:"id"`
|
||||
Src string `json:"src"`
|
||||
Method string `json:"method"`
|
||||
Params RpcRequestParams `json:"params"`
|
||||
}
|
||||
|
||||
type RpcRequestParams struct {
|
||||
Owner string `json:"owner"`
|
||||
Role string `json:"role"`
|
||||
Value any `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// RpcResponse represents an RPC response
|
||||
type RpcResponse[T any] struct {
|
||||
Id int `json:"id"`
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
Result struct {
|
||||
Value T `json:"value"`
|
||||
Source string `json:"source"`
|
||||
LastUpdateTs int64 `json:"last_update_ts"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
// PhaseMeasurements contains voltage, current and power data for a single phase
|
||||
type PhaseMeasurements struct {
|
||||
Voltage float64 `json:"voltage"`
|
||||
Current float64 `json:"current"`
|
||||
Power float64 `json:"power"`
|
||||
}
|
||||
|
||||
// Measurements contains aggregated phase information
|
||||
type Measurements struct {
|
||||
TotalCurrent float64 `json:"total_current"`
|
||||
TotalPower float64 `json:"total_power"`
|
||||
TotalActEnergy float64 `json:"total_act_energy"`
|
||||
PhaseA PhaseMeasurements `json:"phase_a"`
|
||||
PhaseB PhaseMeasurements `json:"phase_b"`
|
||||
PhaseC PhaseMeasurements `json:"phase_c"`
|
||||
}
|
||||
18
templates/definition/charger/shelly-topac.yaml
Normal file
18
templates/definition/charger/shelly-topac.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
template: shelly-topac
|
||||
products:
|
||||
- brand: Shelly
|
||||
description:
|
||||
generic: Top AC Portable EV Charger
|
||||
requirements:
|
||||
evcc: ["sponsorship"]
|
||||
params:
|
||||
- name: host
|
||||
required: true
|
||||
- name: user
|
||||
- name: password
|
||||
mask: true
|
||||
render: |
|
||||
type: shelly-topac
|
||||
uri: http://{{ .host }}
|
||||
user: {{ .user }}
|
||||
password: {{ .password }}
|
||||
Loading…
Add table
Add a link
Reference in a new issue