Škoda: add MyŠkoda public api vehicle (#33234)

This commit is contained in:
andig 2026-08-27 20:55:34 +02:00 • committed by GitHub
parent f40d67ea72
commit fb120252a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 520 additions and 0 deletions

View 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
View 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
View 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
View 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)
}

View 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
View 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
}