diff --git a/charger/plugchoice.go b/charger/plugchoice.go new file mode 100644 index 000000000..a83d679d3 --- /dev/null +++ b/charger/plugchoice.go @@ -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 +} diff --git a/charger/plugchoice/api.go b/charger/plugchoice/api.go new file mode 100644 index 000000000..5f1271563 --- /dev/null +++ b/charger/plugchoice/api.go @@ -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") +} diff --git a/charger/plugchoice/types.go b/charger/plugchoice/types.go new file mode 100644 index 000000000..e675fdcbf --- /dev/null +++ b/charger/plugchoice/types.go @@ -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"` +} diff --git a/templates/definition/charger/plugchoice.yaml b/templates/definition/charger/plugchoice.yaml new file mode 100644 index 000000000..72ab726d2 --- /dev/null +++ b/templates/definition/charger/plugchoice.yaml @@ -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 }} diff --git a/templates/definition/charger/volttime.yaml b/templates/definition/charger/volttime.yaml new file mode 100644 index 000000000..0cd19d694 --- /dev/null +++ b/templates/definition/charger/volttime.yaml @@ -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