Add Nexblue Edge 2 charger (#27918)
This commit is contained in:
parent
3428ddf2c2
commit
867001a4c8
3 changed files with 334 additions and 0 deletions
279
charger/nexblue.go
Normal file
279
charger/nexblue.go
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
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.
|
||||
|
||||
// https://prod-management.nexblue.com/swagger/dist/index.html
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/oauth"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/evcc-io/evcc/util/sponsor"
|
||||
"github.com/samber/lo"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const nexblueAPI = "https://api.nexblue.com/third_party/openapi"
|
||||
|
||||
// Nexblue charger implementation
|
||||
type Nexblue struct {
|
||||
*request.Helper
|
||||
serial string
|
||||
enabled bool
|
||||
statusG util.Cacheable[nexblueStatus]
|
||||
}
|
||||
|
||||
type nexblueStatus struct {
|
||||
ChargingState int `json:"charging_state"`
|
||||
Power float64 `json:"power"` // kW
|
||||
Energy float64 `json:"energy"` // kWh (session)
|
||||
LifetimeEnergy float64 `json:"lifetime_energy"` // kWh (total)
|
||||
CurrentLimit int `json:"current_limit"` // A
|
||||
VoltageList []float64 `json:"voltage_list"` // V per phase
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.AddCtx("nexblue", NewNexblueFromConfig)
|
||||
}
|
||||
|
||||
// NewNexblueFromConfig creates a Nexblue charger from generic config
|
||||
func NewNexblueFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
|
||||
cc := struct {
|
||||
User string
|
||||
Password string
|
||||
Serial string
|
||||
Cache time.Duration
|
||||
}{
|
||||
Cache: time.Second,
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cc.User == "" || cc.Password == "" {
|
||||
return nil, api.ErrMissingCredentials
|
||||
}
|
||||
|
||||
return NewNexblue(ctx, cc.User, cc.Password, cc.Serial, cc.Cache)
|
||||
}
|
||||
|
||||
// NewNexblue creates Nexblue charger
|
||||
func NewNexblue(ctx context.Context, user, password, serial string, cache time.Duration) (api.Charger, error) {
|
||||
log := util.NewLogger("nexblue").Redact(user, password)
|
||||
|
||||
if !sponsor.IsAuthorized() {
|
||||
return nil, api.ErrSponsorRequired
|
||||
}
|
||||
|
||||
wb := &Nexblue{
|
||||
Helper: request.NewHelper(log),
|
||||
}
|
||||
|
||||
// authHelper uses a separate client injected via context to avoid circular
|
||||
// dependency when oauth2.Transport later calls Token() on token refresh.
|
||||
authHelper := request.NewHelper(log)
|
||||
login := func(_ *oauth2.Token) (*oauth2.Token, error) {
|
||||
req, _ := request.New(http.MethodPost, nexblueAPI+"/account/login", request.MarshalJSON(struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
AccountType int `json:"account_type"`
|
||||
}{
|
||||
user, password, 0,
|
||||
}), request.JSONEncoding)
|
||||
|
||||
var res struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := authHelper.DoJSON(req, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &oauth2.Token{
|
||||
AccessToken: res.AccessToken,
|
||||
Expiry: time.Now().Add(time.Duration(res.ExpiresIn) * time.Second),
|
||||
}, nil
|
||||
}
|
||||
|
||||
tok, err := login(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Inject authHelper's client as base transport; oauth2.NewClient wraps it with oauth2.Transport.
|
||||
authCtx := context.WithValue(ctx, oauth2.HTTPClient, authHelper.Client)
|
||||
wb.Client = oauth2.NewClient(authCtx, oauth.RefreshTokenSource(tok, login))
|
||||
|
||||
wb.serial, err = ensureCharger("", func() ([]string, error) {
|
||||
return wb.chargerSerials()
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wb.statusG = util.ResettableCached(func() (nexblueStatus, error) {
|
||||
var res nexblueStatus
|
||||
return res, wb.GetJSON(fmt.Sprintf("%s/chargers/%s/cmd/status", nexblueAPI, wb.serial), &res)
|
||||
}, cache)
|
||||
|
||||
return wb, nil
|
||||
}
|
||||
|
||||
func (wb *Nexblue) chargerSerials() ([]string, error) {
|
||||
type charger = struct {
|
||||
SerialNumber string `json:"serial_number"`
|
||||
}
|
||||
var res struct {
|
||||
Data []charger
|
||||
}
|
||||
|
||||
err := wb.GetJSON(nexblueAPI+"/chargers", &res)
|
||||
|
||||
return lo.Map(res.Data, func(c charger, _ int) string {
|
||||
return c.SerialNumber
|
||||
}), err
|
||||
}
|
||||
|
||||
// Status implements the api.Charger interface
|
||||
func (wb *Nexblue) Status() (api.ChargeStatus, error) {
|
||||
res, err := wb.statusG.Get()
|
||||
if err != nil {
|
||||
return api.StatusNone, err
|
||||
}
|
||||
|
||||
switch res.ChargingState {
|
||||
case 0: // Idle
|
||||
return api.StatusA, nil
|
||||
case 1: // Connected
|
||||
return api.StatusB, nil
|
||||
case 2: // Charging
|
||||
return api.StatusC, nil
|
||||
case 3: // Finished
|
||||
return api.StatusB, nil
|
||||
case 4: // Error
|
||||
return api.StatusE, nil
|
||||
case 5: // Load Balancing
|
||||
return api.StatusC, nil
|
||||
case 6: // Delayed
|
||||
return api.StatusB, nil
|
||||
case 7: // EV Waiting
|
||||
return api.StatusB, nil
|
||||
default:
|
||||
return api.StatusNone, fmt.Errorf("invalid status: %d", res.ChargingState)
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled implements the api.Charger interface
|
||||
func (wb *Nexblue) Enabled() (bool, error) {
|
||||
return verifyEnabled(wb, wb.enabled)
|
||||
}
|
||||
|
||||
// Enable implements the api.Charger interface
|
||||
func (wb *Nexblue) Enable(enable bool) error {
|
||||
cmd := "stop_charging"
|
||||
if enable {
|
||||
cmd = "start_charging"
|
||||
}
|
||||
|
||||
req, err := request.New(http.MethodPost, fmt.Sprintf("%s/chargers/%s/cmd/%s", nexblueAPI, wb.serial, cmd), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var res struct {
|
||||
Result int `json:"result"`
|
||||
}
|
||||
if err := wb.DoJSON(req, &res); err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Result != 0 {
|
||||
return fmt.Errorf("command %s failed: %d", cmd, res.Result)
|
||||
}
|
||||
|
||||
wb.enabled = enable
|
||||
wb.statusG.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
// MaxCurrent implements the api.Charger interface
|
||||
func (wb *Nexblue) MaxCurrent(current int64) error {
|
||||
req, _ := request.New(http.MethodPost,
|
||||
fmt.Sprintf("%s/chargers/%s/cmd/set_current_limit", nexblueAPI, wb.serial), request.MarshalJSON(struct {
|
||||
CurrentLimit int64 `json:"current_limit"`
|
||||
}{
|
||||
current,
|
||||
}), request.JSONEncoding)
|
||||
|
||||
var res struct {
|
||||
Result int `json:"result"`
|
||||
}
|
||||
if err := wb.DoJSON(req, &res); err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Result != 0 {
|
||||
return fmt.Errorf("set current limit failed: %d", res.Result)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ api.Meter = (*Nexblue)(nil)
|
||||
|
||||
// CurrentPower implements the api.Meter interface
|
||||
func (wb *Nexblue) CurrentPower() (float64, error) {
|
||||
res, err := wb.statusG.Get()
|
||||
return res.Power * 1e3, err
|
||||
}
|
||||
|
||||
var _ api.ChargeRater = (*Nexblue)(nil)
|
||||
|
||||
// ChargedEnergy implements the api.ChargeRater interface
|
||||
func (wb *Nexblue) ChargedEnergy() (float64, error) {
|
||||
res, err := wb.statusG.Get()
|
||||
return res.Energy, err
|
||||
}
|
||||
|
||||
var _ api.MeterEnergy = (*Nexblue)(nil)
|
||||
|
||||
// TotalEnergy implements the api.MeterEnergy interface
|
||||
func (wb *Nexblue) TotalEnergy() (float64, error) {
|
||||
res, err := wb.statusG.Get()
|
||||
return res.LifetimeEnergy, err
|
||||
}
|
||||
|
||||
var _ api.PhaseSwitcher = (*Nexblue)(nil)
|
||||
|
||||
// Phases1p3p implements the api.PhaseSwitcher interface
|
||||
func (wb *Nexblue) Phases1p3p(phases int) error {
|
||||
req, _ := request.New(http.MethodPost,
|
||||
fmt.Sprintf("%s/v1/charger/%s/setting", nexblueAPI, wb.serial), request.MarshalJSON(struct {
|
||||
PhaseMode int `json:"phase_mode"`
|
||||
}{
|
||||
phases,
|
||||
}), request.JSONEncoding)
|
||||
|
||||
_, err := wb.DoBody(req)
|
||||
return err
|
||||
}
|
||||
35
charger/nexblue_decorators.go
Normal file
35
charger/nexblue_decorators.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package charger
|
||||
|
||||
// Code generated by github.com/evcc-io/evcc/cmd/tools/decorate.go. DO NOT EDIT.
|
||||
|
||||
import (
|
||||
"github.com/evcc-io/evcc/api"
|
||||
)
|
||||
|
||||
func decorateNexblue(base *Nexblue, phaseSwitcher func(int) error) api.Charger {
|
||||
switch {
|
||||
case phaseSwitcher == nil:
|
||||
return base
|
||||
|
||||
case phaseSwitcher != nil:
|
||||
return &struct {
|
||||
*Nexblue
|
||||
api.PhaseSwitcher
|
||||
}{
|
||||
Nexblue: base,
|
||||
PhaseSwitcher: &decorateNexbluePhaseSwitcherImpl{
|
||||
phaseSwitcher: phaseSwitcher,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type decorateNexbluePhaseSwitcherImpl struct {
|
||||
phaseSwitcher func(int) error
|
||||
}
|
||||
|
||||
func (impl *decorateNexbluePhaseSwitcherImpl) Phases1p3p(p0 int) error {
|
||||
return impl.phaseSwitcher(p0)
|
||||
}
|
||||
20
templates/definition/charger/nexblue.yaml
Normal file
20
templates/definition/charger/nexblue.yaml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
template: nexblue
|
||||
products:
|
||||
- brand: Nexblue
|
||||
description:
|
||||
generic: Edge 2
|
||||
capabilities: ["1p3p"]
|
||||
requirements:
|
||||
evcc: ["sponsorship"]
|
||||
params:
|
||||
- name: user
|
||||
- name: password
|
||||
- name: serial
|
||||
help:
|
||||
de: Seriennummer der Wallbox (wird automatisch erkannt, wenn nur eine Wallbox im Account vorhanden ist)
|
||||
en: Charger serial number (auto-detected if only one charger in account)
|
||||
render: |
|
||||
type: nexblue
|
||||
user: {{ .user }}
|
||||
password: {{ .password }}
|
||||
serial: {{ .serial }}
|
||||
Loading…
Add table
Add a link
Reference in a new issue