Škoda: add MyŠkoda public api vehicle (#33234)
This commit is contained in:
parent
f40d67ea72
commit
fb120252a0
6 changed files with 520 additions and 0 deletions
40
templates/definition/vehicle/myskoda.yaml
Normal file
40
templates/definition/vehicle/myskoda.yaml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
template: myskoda
|
||||||
|
products:
|
||||||
|
- brand: Škoda
|
||||||
|
description:
|
||||||
|
generic: MyŠkoda (Public API)
|
||||||
|
requirements:
|
||||||
|
description:
|
||||||
|
de: API-Schlüssel unter [go.skoda.eu/api-keys](https://go.skoda.eu/api-keys) anfordern.
|
||||||
|
en: Request an API key at [go.skoda.eu/api-keys](https://go.skoda.eu/api-keys).
|
||||||
|
params:
|
||||||
|
- preset: vehicle-common
|
||||||
|
- preset: vehicle-online
|
||||||
|
- name: apikey
|
||||||
|
required: true
|
||||||
|
help:
|
||||||
|
de: API-Schlüssel unter [go.skoda.eu/api-keys](https://go.skoda.eu/api-keys) anfordern.
|
||||||
|
en: Request an API key at [go.skoda.eu/api-keys](https://go.skoda.eu/api-keys).
|
||||||
|
- name: vin
|
||||||
|
required: true
|
||||||
|
- name: sandbox
|
||||||
|
type: bool
|
||||||
|
advanced: true
|
||||||
|
description:
|
||||||
|
de: Sandbox-Umgebung
|
||||||
|
en: Sandbox environment
|
||||||
|
- name: cache
|
||||||
|
default: 15m
|
||||||
|
advanced: true
|
||||||
|
- name: timeout
|
||||||
|
render: |
|
||||||
|
type: myskoda
|
||||||
|
vin: {{ .vin }}
|
||||||
|
apikey: {{ .apikey }}
|
||||||
|
{{- if eq .sandbox "true" }}
|
||||||
|
sandbox: true
|
||||||
|
{{- end }}
|
||||||
|
{{ include "common" . }}
|
||||||
|
cache: {{ .cache }}
|
||||||
|
timeout: {{ .timeout }}
|
||||||
|
{{ include "features" . }}
|
||||||
73
vehicle/myskoda.go
Normal file
73
vehicle/myskoda.go
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
package vehicle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/evcc-io/evcc/api"
|
||||||
|
"github.com/evcc-io/evcc/util"
|
||||||
|
"github.com/evcc-io/evcc/util/request"
|
||||||
|
"github.com/evcc-io/evcc/vehicle/myskoda"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MySkoda is an api.Vehicle implementation for the MyŠkoda public api
|
||||||
|
type MySkoda struct {
|
||||||
|
*embed
|
||||||
|
*myskoda.Provider
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registry.Add("myskoda", NewMySkodaFromConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMySkodaFromConfig creates a new vehicle
|
||||||
|
func NewMySkodaFromConfig(other map[string]any) (api.Vehicle, error) {
|
||||||
|
cc := struct {
|
||||||
|
embed `mapstructure:",squash"`
|
||||||
|
VIN string
|
||||||
|
ApiKey string
|
||||||
|
Sandbox bool
|
||||||
|
Cache time.Duration
|
||||||
|
Timeout time.Duration
|
||||||
|
}{
|
||||||
|
Cache: interval,
|
||||||
|
Timeout: request.Timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := util.DecodeOther(other, &cc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cc.ApiKey == "" {
|
||||||
|
return nil, api.ErrMissingCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
if cc.VIN == "" {
|
||||||
|
return nil, errors.New("missing vin")
|
||||||
|
}
|
||||||
|
|
||||||
|
log := util.NewLogger("myskoda").Redact(cc.ApiKey, cc.VIN)
|
||||||
|
|
||||||
|
uri := myskoda.BaseURI
|
||||||
|
if cc.Sandbox {
|
||||||
|
uri = myskoda.SandboxURI
|
||||||
|
}
|
||||||
|
|
||||||
|
apiC := myskoda.NewAPI(log, uri, cc.ApiKey)
|
||||||
|
apiC.Client.Timeout = cc.Timeout
|
||||||
|
|
||||||
|
// validate api key and vin, use vehicle name as title
|
||||||
|
res, err := apiC.Vehicle(cc.VIN, "info")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
v := &MySkoda{
|
||||||
|
embed: &cc.embed,
|
||||||
|
}
|
||||||
|
v.fromVehicle(res.Vehicle.Name, 0)
|
||||||
|
|
||||||
|
v.Provider = myskoda.NewProvider(apiC, cc.VIN, cc.Cache)
|
||||||
|
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
70
vehicle/myskoda/api.go
Normal file
70
vehicle/myskoda/api.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package myskoda
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/evcc-io/evcc/util"
|
||||||
|
"github.com/evcc-io/evcc/util/request"
|
||||||
|
"github.com/evcc-io/evcc/util/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// https://public.api.connect.skoda-auto.cz/docs/myskoda-public-api.yaml
|
||||||
|
|
||||||
|
const (
|
||||||
|
BaseURI = "https://public.api.connect.skoda-auto.cz/api/v1"
|
||||||
|
SandboxURI = "https://public.test-api.connect.skoda-auto.cz/api/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionStart = "start"
|
||||||
|
ActionStop = "stop"
|
||||||
|
)
|
||||||
|
|
||||||
|
// API is the MyŠkoda public api client
|
||||||
|
type API struct {
|
||||||
|
*request.Helper
|
||||||
|
uri string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAPI creates a new api client
|
||||||
|
func NewAPI(log *util.Logger, uri, apiKey string) *API {
|
||||||
|
v := &API{
|
||||||
|
Helper: request.NewHelper(log),
|
||||||
|
uri: uri,
|
||||||
|
}
|
||||||
|
|
||||||
|
v.Client.Transport = &transport.Decorator{
|
||||||
|
Decorator: transport.DecorateHeaders(map[string]string{
|
||||||
|
"X-API-Key": apiKey,
|
||||||
|
}),
|
||||||
|
Base: v.Client.Transport,
|
||||||
|
}
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vehicle returns the vehicle state, limited to the given parts
|
||||||
|
func (v *API) Vehicle(vin string, include ...string) (VehicleResponse, error) {
|
||||||
|
var res VehicleResponse
|
||||||
|
|
||||||
|
uri := fmt.Sprintf("%s/vehicles/%s", v.uri, vin)
|
||||||
|
if len(include) > 0 {
|
||||||
|
uri += "?include=" + strings.Join(include, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := v.GetJSON(uri, &res)
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChargeAction starts or stops charging
|
||||||
|
func (v *API) ChargeAction(vin, action string) error {
|
||||||
|
uri := fmt.Sprintf("%s/vehicles/%s/charging/%s", v.uri, vin, action)
|
||||||
|
|
||||||
|
req, err := request.New(http.MethodPost, uri, nil, request.JSONEncoding)
|
||||||
|
if err == nil {
|
||||||
|
_, err = v.DoBody(req) // returns 202 with empty body
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
166
vehicle/myskoda/provider.go
Normal file
166
vehicle/myskoda/provider.go
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
package myskoda
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/evcc-io/evcc/api"
|
||||||
|
"github.com/evcc-io/evcc/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provider implements the vehicle api
|
||||||
|
type Provider struct {
|
||||||
|
dataG func() (VehicleResponse, error)
|
||||||
|
action func(action string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProvider creates a vehicle api provider
|
||||||
|
func NewProvider(api *API, vin string, cache time.Duration) *Provider {
|
||||||
|
return &Provider{
|
||||||
|
dataG: util.Cached(func() (VehicleResponse, error) {
|
||||||
|
return api.Vehicle(vin, "charging", "odometer", "airConditioning")
|
||||||
|
}, cache),
|
||||||
|
action: func(action string) error {
|
||||||
|
return api.ChargeAction(vin, action)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// partError returns the error reported for the given response part
|
||||||
|
func partError(res VehicleResponse, part string) error {
|
||||||
|
for _, e := range res.Errors {
|
||||||
|
if strings.HasPrefix(e.Type, part) {
|
||||||
|
// the api explains a permanent condition, don't log it on every update
|
||||||
|
return fmt.Errorf("%s: %s: %w", e.Type, e.Description, api.ErrNotAvailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return api.ErrNotAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// charging returns the charging part of the vehicle data
|
||||||
|
func (v *Provider) charging() (*Charging, error) {
|
||||||
|
res, err := v.dataG()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.Vehicle.Charging == nil || res.Vehicle.Charging.Status == nil {
|
||||||
|
return nil, partError(res, "CHARGING")
|
||||||
|
}
|
||||||
|
return res.Vehicle.Charging, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ api.Battery = (*Provider)(nil)
|
||||||
|
|
||||||
|
// Soc implements the api.Battery interface
|
||||||
|
func (v *Provider) Soc() (float64, error) {
|
||||||
|
res, err := v.charging()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return float64(res.Status.Battery.StateOfChargeInPercent), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ api.ChargeState = (*Provider)(nil)
|
||||||
|
|
||||||
|
// Status implements the api.ChargeState interface
|
||||||
|
func (v *Provider) Status() (api.ChargeStatus, error) {
|
||||||
|
res, err := v.charging()
|
||||||
|
if err != nil {
|
||||||
|
return api.StatusNone, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch s := res.Status.State; s {
|
||||||
|
case "CONNECT_CABLE":
|
||||||
|
return api.StatusA, nil
|
||||||
|
case "READY_FOR_CHARGING", "CHARGING_INTERRUPTED", "DISCHARGING":
|
||||||
|
return api.StatusB, nil
|
||||||
|
// conserving is conservation charging
|
||||||
|
case "CHARGING", "CONSERVING":
|
||||||
|
return api.StatusC, nil
|
||||||
|
default:
|
||||||
|
return api.StatusNone, fmt.Errorf("invalid status: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ api.VehicleRange = (*Provider)(nil)
|
||||||
|
|
||||||
|
// Range implements the api.VehicleRange interface
|
||||||
|
func (v *Provider) Range() (int64, error) {
|
||||||
|
res, err := v.charging()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.Status.Battery.RemainingCruisingRangeInMeters / 1e3, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ api.VehicleFinishTimer = (*Provider)(nil)
|
||||||
|
|
||||||
|
// FinishTime implements the api.VehicleFinishTimer interface
|
||||||
|
func (v *Provider) FinishTime() (time.Time, error) {
|
||||||
|
res, err := v.charging()
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
if !res.Status.FullyChargedAt.IsZero() {
|
||||||
|
return res.Status.FullyChargedAt, nil
|
||||||
|
}
|
||||||
|
if res.Status.RemainingTimeToFullyChargedInMinutes > 0 {
|
||||||
|
return time.Now().Add(time.Duration(res.Status.RemainingTimeToFullyChargedInMinutes) * time.Minute), nil
|
||||||
|
}
|
||||||
|
return time.Time{}, api.ErrNotAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ api.SocLimiter = (*Provider)(nil)
|
||||||
|
|
||||||
|
// GetLimitSoc implements the api.SocLimiter interface
|
||||||
|
func (v *Provider) GetLimitSoc() (int64, error) {
|
||||||
|
res, err := v.charging()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if res.Settings == nil || res.Settings.TargetStateOfChargeInPercent == nil {
|
||||||
|
return 0, api.ErrNotAvailable
|
||||||
|
}
|
||||||
|
return int64(*res.Settings.TargetStateOfChargeInPercent), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ api.VehicleOdometer = (*Provider)(nil)
|
||||||
|
|
||||||
|
// Odometer implements the api.VehicleOdometer interface
|
||||||
|
func (v *Provider) Odometer() (float64, error) {
|
||||||
|
res, err := v.dataG()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if res.Vehicle.Odometer == nil {
|
||||||
|
return 0, partError(res, "ODOMETER")
|
||||||
|
}
|
||||||
|
return float64(res.Vehicle.Odometer.MileageInKm), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ api.VehicleClimater = (*Provider)(nil)
|
||||||
|
|
||||||
|
// Climater implements the api.VehicleClimater interface
|
||||||
|
func (v *Provider) Climater() (bool, error) {
|
||||||
|
res, err := v.dataG()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if res.Vehicle.AirConditioning == nil {
|
||||||
|
return false, partError(res, "AIR_CONDITIONING")
|
||||||
|
}
|
||||||
|
return slices.Contains([]string{"COOLING", "HEATING", "HEATING_AUXILIARY", "VENTILATION"}, res.Vehicle.AirConditioning.State), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ api.ChargeController = (*Provider)(nil)
|
||||||
|
|
||||||
|
// ChargeEnable implements the api.ChargeController interface
|
||||||
|
func (v *Provider) ChargeEnable(enable bool) error {
|
||||||
|
action := ActionStop
|
||||||
|
if enable {
|
||||||
|
action = ActionStart
|
||||||
|
}
|
||||||
|
return v.action(action)
|
||||||
|
}
|
||||||
112
vehicle/myskoda/provider_test.go
Normal file
112
vehicle/myskoda/provider_test.go
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package myskoda
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/evcc-io/evcc/api"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVehicleResponse(t *testing.T) {
|
||||||
|
sample := `{
|
||||||
|
"vehicle": {
|
||||||
|
"vin": "TMBJB9NY5RF999999",
|
||||||
|
"name": "My Enyaq",
|
||||||
|
"odometer": { "mileageInKm": 12753 },
|
||||||
|
"airConditioning": { "state": "HEATING" },
|
||||||
|
"charging": {
|
||||||
|
"isVehicleInSavedLocation": true,
|
||||||
|
"status": {
|
||||||
|
"chargePowerInKw": 20.16,
|
||||||
|
"remainingTimeToFullyChargedInMinutes": 15,
|
||||||
|
"state": "CHARGING",
|
||||||
|
"chargeType": "AC",
|
||||||
|
"battery": {
|
||||||
|
"remainingCruisingRangeInMeters": 249000,
|
||||||
|
"stateOfChargeInPercent": 71
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": { "targetStateOfChargeInPercent": 80 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": []
|
||||||
|
}`
|
||||||
|
|
||||||
|
var res VehicleResponse
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(sample), &res))
|
||||||
|
|
||||||
|
v := &Provider{dataG: func() (VehicleResponse, error) { return res, nil }}
|
||||||
|
|
||||||
|
soc, err := v.Soc()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 71.0, soc)
|
||||||
|
|
||||||
|
status, err := v.Status()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, api.StatusC, status)
|
||||||
|
|
||||||
|
rng, err := v.Range()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(249), rng)
|
||||||
|
|
||||||
|
odo, err := v.Odometer()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 12753.0, odo)
|
||||||
|
|
||||||
|
limit, err := v.GetLimitSoc()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(80), limit)
|
||||||
|
|
||||||
|
climate, err := v.Climater()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, climate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatus(t *testing.T) {
|
||||||
|
statusG := func(state string) *Provider {
|
||||||
|
res := VehicleResponse{Vehicle: Vehicle{Charging: &Charging{Status: &ChargingStatus{State: state}}}}
|
||||||
|
return &Provider{dataG: func() (VehicleResponse, error) { return res, nil }}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
state string
|
||||||
|
status api.ChargeStatus
|
||||||
|
}{
|
||||||
|
{"CONNECT_CABLE", api.StatusA},
|
||||||
|
{"READY_FOR_CHARGING", api.StatusB},
|
||||||
|
{"CHARGING_INTERRUPTED", api.StatusB},
|
||||||
|
{"DISCHARGING", api.StatusB},
|
||||||
|
{"CHARGING", api.StatusC},
|
||||||
|
{"CONSERVING", api.StatusC},
|
||||||
|
} {
|
||||||
|
status, err := statusG(tc.state).Status()
|
||||||
|
require.NoError(t, err, tc.state)
|
||||||
|
assert.Equal(t, tc.status, status, tc.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// an unknown state must not read as plugged in, that would assign the vehicle
|
||||||
|
_, err := statusG("SOMETHING_NEW").Status()
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVehicleResponsePartErrors(t *testing.T) {
|
||||||
|
sample := `{
|
||||||
|
"vehicle": { "vin": "TMBJB9NY5RF999999" },
|
||||||
|
"errors": [{ "type": "CHARGING_UNAVAILABLE", "description": "Charging status could not be retrieved." }]
|
||||||
|
}`
|
||||||
|
|
||||||
|
var res VehicleResponse
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(sample), &res))
|
||||||
|
|
||||||
|
v := &Provider{dataG: func() (VehicleResponse, error) { return res, nil }}
|
||||||
|
|
||||||
|
_, err := v.Soc()
|
||||||
|
assert.ErrorContains(t, err, "CHARGING_UNAVAILABLE")
|
||||||
|
// the description must not be logged on every update
|
||||||
|
assert.ErrorIs(t, err, api.ErrNotAvailable)
|
||||||
|
|
||||||
|
_, err = v.Odometer()
|
||||||
|
assert.ErrorIs(t, err, api.ErrNotAvailable)
|
||||||
|
}
|
||||||
59
vehicle/myskoda/types.go
Normal file
59
vehicle/myskoda/types.go
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
package myskoda
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// VehicleResponse is the /api/v1/vehicles/{vin} response
|
||||||
|
type VehicleResponse struct {
|
||||||
|
Vehicle Vehicle
|
||||||
|
Errors []Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error describes a part of the vehicle data that could not be retrieved
|
||||||
|
type Error struct {
|
||||||
|
Type string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vehicle is the vehicle and its current state
|
||||||
|
type Vehicle struct {
|
||||||
|
VIN string
|
||||||
|
Name string
|
||||||
|
LicensePlate string
|
||||||
|
Odometer *Odometer
|
||||||
|
AirConditioning *AirConditioning
|
||||||
|
Charging *Charging
|
||||||
|
}
|
||||||
|
|
||||||
|
type Odometer struct {
|
||||||
|
MileageInKm int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type AirConditioning struct {
|
||||||
|
State string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Charging struct {
|
||||||
|
Status *ChargingStatus
|
||||||
|
Settings *ChargingSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChargingStatus struct {
|
||||||
|
ChargingRateInKilometersPerHour float64
|
||||||
|
ChargePowerInKw float64
|
||||||
|
RemainingTimeToFullyChargedInMinutes int64
|
||||||
|
FullyChargedAt time.Time
|
||||||
|
State string
|
||||||
|
ChargeType string
|
||||||
|
Battery BatteryStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatteryStatus struct {
|
||||||
|
RemainingCruisingRangeInMeters int64
|
||||||
|
StateOfChargeInPercent int
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChargingSettings struct {
|
||||||
|
TargetStateOfChargeInPercent *int
|
||||||
|
MaxChargeCurrentAc string
|
||||||
|
MaxChargeCurrentAcAmpere int
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue