evcc-io/charger/plugchoice.go

300 lines
7.6 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.
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/charger/plugchoice"
"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/lorenzodonini/ocpp-go/ocpp1.6/core"
)
// Plugchoice charger implementation
type Plugchoice struct {
*request.Helper
uri string
uuid string
connector int
enabled bool
current int64
statusG util.Cacheable[plugchoice.StatusResponse]
powerG util.Cacheable[plugchoice.PowerResponse]
}
func init() {
registry.Add("plugchoice", NewPlugchoiceFromConfig)
}
// NewPlugchoiceFromConfig creates a Plugchoice charger from generic config
func NewPlugchoiceFromConfig(other map[string]any) (api.Charger, error) {
cc := struct {
URI string
UUID string // kept for backward compatibility
Identity string
Connector int
Token string
Cache time.Duration
}{
URI: "https://app.plugchoice.com",
Connector: 1,
Cache: time.Second,
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
return NewPlugchoice(cc.URI, cc.UUID, cc.Identity, cc.Connector, cc.Token, cc.Cache)
}
// NewPlugchoice creates a Plugchoice charger
func NewPlugchoice(uri, uuid, identity string, connector int, token string, cache time.Duration) (api.Charger, error) {
log := util.NewLogger("plugchoice")
helper := request.NewHelper(log)
uri = strings.TrimRight(uri, "/")
// Set up authentication if provided
if token != "" {
helper.Client.Transport = &transport.Decorator{
Decorator: transport.DecorateHeaders(map[string]string{
"Authorization": "Bearer " + token,
}),
Base: helper.Client.Transport,
}
}
// If both are provided, Identity takes precedence
if identity == "" && uuid == "" {
return nil, errors.New("either identity or uuid are required")
}
// If identity is provided but no UUID, try to find the UUID
if uuid == "" && identity != "" {
var err error
uuid, err = plugchoice.FindUUIDByIdentity(helper, uri, identity)
if err != nil {
return nil, fmt.Errorf("error finding charger UUID: %w", err)
}
}
if !sponsor.IsAuthorized() {
return nil, api.ErrSponsorRequired
}
c := &Plugchoice{
Helper: helper,
uri: uri,
uuid: uuid,
connector: connector,
current: 6,
}
// setup cached status values
c.statusG = util.ResettableCached(func() (plugchoice.StatusResponse, error) {
var res plugchoice.StatusResponse
uri := fmt.Sprintf("%s/api/v3/chargers/%s", c.uri, c.uuid)
err := c.GetJSON(uri, &res)
return res, err
}, cache)
// setup cached power values
c.powerG = util.ResettableCached(func() (plugchoice.PowerResponse, error) {
var res plugchoice.PowerResponse
uri := fmt.Sprintf("%s/api/v3/chargers/%s/connectors/%d/power-usage", c.uri, c.uuid, c.connector)
err := c.GetJSON(uri, &res)
return res, err
}, cache)
return c, nil
}
// Status implements the api.Charger interface
func (c *Plugchoice) Status() (api.ChargeStatus, error) {
res, err := c.statusG.Get()
if err != nil {
return api.StatusNone, err
}
// Find the connector with the specified connector
for _, connector := range res.Data.Connectors {
if connector.ConnectorID == c.connector {
// Map the status codes as per specifications
switch status := connector.Status; status {
case core.ChargePointStatusAvailable:
return api.StatusA, nil
case core.ChargePointStatusPreparing, core.ChargePointStatusSuspendedEVSE, core.ChargePointStatusSuspendedEV, core.ChargePointStatusFinishing:
return api.StatusB, nil
case core.ChargePointStatusCharging:
return api.StatusC, nil
default:
return api.StatusNone, fmt.Errorf("invalid status: %s", status)
}
}
}
return api.StatusNone, fmt.Errorf("connector with ID %d not found", c.connector)
}
// Enabled implements the api.Charger interface
func (c *Plugchoice) Enabled() (bool, error) {
res, err := c.statusG.Get()
if err != nil {
return false, err
}
// Find the connector with the specified connector
for _, connector := range res.Data.Connectors {
if connector.ConnectorID == c.connector {
// Check status for enabled state
switch status := connector.Status; status {
case core.ChargePointStatusCharging, core.ChargePointStatusSuspendedEV:
return true, nil
case core.ChargePointStatusSuspendedEVSE:
return false, nil
default:
return c.enabled, nil
}
}
}
return false, fmt.Errorf("connector with ID %d not found", c.connector)
}
// Enable implements the api.Charger interface
func (c *Plugchoice) Enable(enable bool) error {
var current int64
if enable {
current = c.current
}
err := c.maxCurrent(current)
if err == nil {
c.enabled = enable
}
return err
}
func (c *Plugchoice) maxCurrent(current int64) error {
type chargeLimit struct {
Connector int `json:"connector_id"`
Limit int64 `json:"limit"`
}
data := chargeLimit{
Connector: c.connector,
Limit: current,
}
uri := fmt.Sprintf("%s/api/v3/chargers/%s/actions/charge-limit", c.uri, c.uuid)
req, _ := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
_, err := c.Do(req)
if err == nil {
c.statusG.Reset()
c.powerG.Reset()
}
return err
}
// MaxCurrent implements the api.Charger interface
func (c *Plugchoice) MaxCurrent(current int64) error {
err := c.maxCurrent(current)
if err == nil {
c.current = current
}
return err
}
var _ api.Meter = (*Plugchoice)(nil)
// CurrentPower implements the api.Meter interface
func (c *Plugchoice) CurrentPower() (float64, error) {
res, err := c.powerG.Get()
if err != nil {
return 0, err
}
// the API may return values with surrounding whitespace (e.g. " 0.0")
kwVal := strings.TrimSpace(res.KW)
// Handle the case where power value is "-"
if kwVal == "-" {
return 0, nil
}
kw, err := strconv.ParseFloat(kwVal, 64)
if err != nil {
return 0, err
}
return kw * 1000, nil // Convert kW to W
}
var _ api.PhaseCurrents = (*Plugchoice)(nil)
// Currents implements the api.PhaseCurrents interface
func (c *Plugchoice) Currents() (float64, float64, float64, error) {
res, err := c.powerG.Get()
if err != nil {
return 0, 0, 0, err
}
// Helper function to parse current values, handling "-" as 0
parsePhaseValue := func(val string, phase string) (float64, error) {
// the API may return values with surrounding whitespace (e.g. " 0.0")
val = strings.TrimSpace(val)
if val == "-" {
return 0, nil
}
res, err := strconv.ParseFloat(val, 64)
if err != nil {
return 0, fmt.Errorf("parsing %s current: %w", phase, err)
}
return res, nil
}
l1, err := parsePhaseValue(res.L1, "L1")
if err != nil {
return 0, 0, 0, err
}
l2, err := parsePhaseValue(res.L2, "L2")
if err != nil {
return 0, 0, 0, err
}
l3, err := parsePhaseValue(res.L3, "L3")
if err != nil {
return 0, 0, 0, err
}
return l1, l2, l3, nil
}