Add Plugchoice/ Volt Time chargers (#20967)

This commit is contained in:
Tygo Egmond 2025-05-01 18:51:13 +02:00 • committed by GitHub
parent ed88746bd3
commit 8ce9ff4e4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 496 additions and 0 deletions

306
charger/plugchoice.go Normal file
View file

@ -0,0 +1,306 @@
package charger
// LICENSE
// Copyright (c) 2025 andig
// 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"
"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]interface{}) (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
}
// If both are provided, Identity takes precedence
if cc.Identity != "" || cc.UUID != "" {
return NewPlugchoice(cc.URI, cc.UUID, cc.Identity, cc.Connector, cc.Token, cc.Cache)
}
return nil, fmt.Errorf("either identity or uuid must be provided")
}
// 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 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 we still don't have a UUID, return an error
if uuid == "" {
return nil, fmt.Errorf("either uuid or identity must be provided")
}
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.ChargePointStatusUnavailable, core.ChargePointStatusFaulted:
return api.StatusE, nil // Using StatusE for error conditions
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("unknown 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) {
// Should be zero if not enabled
if !c.enabled {
return 0, nil
}
res, err := c.powerG.Get()
if err != nil {
return 0, err
}
// Handle the case where power value is "-"
if res.KW == "-" {
return 0, nil
}
kw, err := strconv.ParseFloat(res.KW, 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) {
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
}

36
charger/plugchoice/api.go Normal file
View file

@ -0,0 +1,36 @@
package plugchoice
import (
"errors"
"fmt"
"github.com/evcc-io/evcc/util/request"
)
// FindUUIDByIdentity searches through all available chargers to find the UUID based on the identity
func FindUUIDByIdentity(client *request.Helper, baseURI string, identity string) (string, error) {
baseURI = baseURI + "/api/v3/chargers"
for page := 1; page < 10; page++ {
uri := fmt.Sprintf("%s?page=%d", baseURI, page)
var res ChargerListResponse
if err := client.GetJSON(uri, &res); err != nil {
return "", fmt.Errorf("fetching chargers: %w", err)
}
// Search for the identity in this page
for _, charger := range res.Data {
if charger.Identity == identity {
return charger.UUID, nil
}
}
// If no more data is returned, break the loop
if len(res.Data) == 0 {
break
}
}
return "", errors.New("charger not found")
}

View file

@ -0,0 +1,75 @@
package plugchoice
import "github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
// ChargerData represents a charger from the API
type ChargerData struct {
UUID string `json:"uuid"`
ID int `json:"id"`
Identity string `json:"identity"`
Reference string `json:"reference"`
ConnectionStatus string `json:"connection_status"`
Status core.ChargePointStatus `json:"status"`
Error core.ChargePointErrorCode `json:"error"`
ErrorInfo string `json:"error_info"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Model struct {
Vendor string `json:"vendor"`
Name string `json:"name"`
} `json:"model"`
Connectors []Connector `json:"connectors"`
}
// ChargerListResponse is the response from the /chargers endpoint
type ChargerListResponse struct {
Data []ChargerData `json:"data"`
Links Links `json:"links"`
Meta Meta `json:"meta"`
}
// Links contains pagination links
type Links struct {
First string `json:"first"`
Last string `json:"last"`
Prev string `json:"prev"`
Next string `json:"next"`
}
// Meta contains pagination metadata
type Meta struct {
CurrentPage int `json:"current_page"`
From int `json:"from"`
LastPage int `json:"last_page"`
Path string `json:"path"`
PerPage int `json:"per_page"`
To int `json:"to"`
Total int `json:"total"`
}
// StatusResponse is the connector status response
type StatusResponse struct {
Data ChargerData `json:"data"`
}
// Connector represents a charging connector
type Connector struct {
ID int `json:"id"`
ChargerID int `json:"charger_id"`
ConnectorID int `json:"connector_id"`
Status core.ChargePointStatus `json:"status"`
Error core.ChargePointErrorCode `json:"error"`
ErrorInfo string `json:"error_info"`
MaxAmperage int `json:"max_amperage"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// PowerResponse is the power usage response
type PowerResponse struct {
Timestamp string `json:"timestamp"`
KW string `json:"kW"`
L1 string `json:"L1"`
L2 string `json:"L2"`
L3 string `json:"L3"`
}

View file

@ -0,0 +1,47 @@
template: plugchoice
products:
- brand: Plugchoice
group: generic
requirements:
evcc: ["sponsorship"]
description:
en: |
Chargers connected through Plugchoice can leverage its OCPP proxy functionality to establish a connection to other backoffices while maintaining full control through EVCC. This allows seamless management of Plugchoice-registered chargers directly from EVCC.
For improved meter readings, it is recommended to configure the following settings in the Plugchoice portal under the configuration tab:
- Set `MeterValueSampleInterval` to 10 seconds (or another interval according to your preference).
- Set `MeterValuesSampledData` to `Energy.Active.Import.Register,Current.Offered,Current.Import,Voltage`.
These adjustments enable more frequent and detailed reporting of charging data to EVCC.
de: |
Über Plugchoice angeschlossene Ladegeräte können die OCPP-Proxy-Funktionalität nutzen, um eine Verbindung zu anderen Backoffices herzustellen und gleichzeitig die volle Kontrolle über EVCC zu behalten. Dies ermöglicht eine nahtlose Verwaltung der bei Plugchoice registrierten Ladegeräte direkt vom EVCC aus.
Für eine optimierte Zählerablesung empfehlen wir, die folgenden Einstellungen im Plugchoice-Portal unter `Konfiguration` zu konfigurieren:
– Stellen Sie `MeterValueSampleInterval` auf 10 Sekunden (oder ein anderes Intervall Ihrer Wahl) ein.
– Stellen Sie `MeterValuesSampledData` auf `Energy.Active.Import.Register,Current.Offered,Current.Import,Voltage` ein.
Diese Anpassungen ermöglichen eine häufigere und detailliertere Meldung der Ladedaten an EVCC.
params:
- name: token
required: true
help:
de: API Token
en: API Token
- name: identity
required: true
help:
de: Identity des Ladepunkts (z.B. AA123456)
en: Charger identity (e.g. AA123456)
- name: connector
required: true
default: 1
help:
de: Anschluss-ID (üblicherweise 1)
en: Connector ID (usually 1)
render: |
type: plugchoice
token: {{ .token }}
identity: {{ .identity }}
connector: {{ .connector }}

View file

@ -0,0 +1,32 @@
template: volttime
products:
- brand: Volt Time
description:
generic: Source
- brand: Volt Time
description:
generic: Source 2
- brand: Volt Time
description:
generic: Source 2s
- brand: Volt Time
description:
generic: One
requirements:
evcc: ["sponsorship"]
params:
- name: token
required: true
help:
de: API Token (https://developer.volttime.com/api-reference/authentication#personal-access-tokens)
en: API Token (https://developer.volttime.com/api-reference/authentication#personal-access-tokens)
- name: serial_number
required: true
help:
de: Seriennummer (z. B. 1008621)
en: Serial number (e.g. 1008621)
render: |
type: plugchoice
token: {{ .token }}
identity: VT_{{ .serial_number }}
connector: 1