282 lines
7.3 KiB
Go
282 lines
7.3 KiB
Go
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 (
|
|
nexblueHost = "https://api.nexblue.com"
|
|
nexblueAPI = nexblueHost + "/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(serial, 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
|
|
3, // Finished
|
|
6, // Delayed
|
|
7: // EV Waiting
|
|
return api.StatusB, nil
|
|
case
|
|
2, // Charging
|
|
5: // Load Balancing
|
|
return api.StatusC, 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 {
|
|
uri := fmt.Sprintf("%s/chargers/%s/cmd/set_current_limit", nexblueAPI, wb.serial)
|
|
req, _ := request.New(http.MethodPost, uri, 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 {
|
|
if phases != 1 && phases != 3 {
|
|
return fmt.Errorf("invalid phases: %d", phases)
|
|
}
|
|
|
|
uri := fmt.Sprintf("%s/chargers/command/%s/switch_phase_mode", nexblueHost, wb.serial)
|
|
req, _ := request.New(http.MethodPost, uri, request.MarshalJSON(struct {
|
|
EnforceSinglePhase bool `json:"enforce_single_phase"`
|
|
}{
|
|
phases == 1,
|
|
}), request.JSONEncoding)
|
|
|
|
_, err := wb.DoBody(req)
|
|
return err
|
|
}
|