Š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
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