diff --git a/templates/definition/vehicle/myskoda.yaml b/templates/definition/vehicle/myskoda.yaml new file mode 100644 index 000000000..485773249 --- /dev/null +++ b/templates/definition/vehicle/myskoda.yaml @@ -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" . }} diff --git a/vehicle/myskoda.go b/vehicle/myskoda.go new file mode 100644 index 000000000..a75feb990 --- /dev/null +++ b/vehicle/myskoda.go @@ -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 +} diff --git a/vehicle/myskoda/api.go b/vehicle/myskoda/api.go new file mode 100644 index 000000000..47807423e --- /dev/null +++ b/vehicle/myskoda/api.go @@ -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 +} diff --git a/vehicle/myskoda/provider.go b/vehicle/myskoda/provider.go new file mode 100644 index 000000000..f811ba847 --- /dev/null +++ b/vehicle/myskoda/provider.go @@ -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) +} diff --git a/vehicle/myskoda/provider_test.go b/vehicle/myskoda/provider_test.go new file mode 100644 index 000000000..208d1ff9f --- /dev/null +++ b/vehicle/myskoda/provider_test.go @@ -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) +} diff --git a/vehicle/myskoda/types.go b/vehicle/myskoda/types.go new file mode 100644 index 000000000..d6641729d --- /dev/null +++ b/vehicle/myskoda/types.go @@ -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 +}