Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions vehicle/leapmotor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package vehicle

import (
"fmt"
"time"

"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/vehicle/leapmotor"
)

// Leapmotor is an api.Vehicle implementation for Leapmotor cars.
type Leapmotor struct {
*embed
*leapmotor.Provider
}

func init() {
registry.Add("leapmotor", NewLeapmotorFromConfig)
}

// NewLeapmotorFromConfig creates a new Leapmotor vehicle from config.
func NewLeapmotorFromConfig(other map[string]any) (api.Vehicle, error) {
cc := struct {
embed `mapstructure:",squash"`
User, Password, VIN string
AppCert, AppKey string
Cache time.Duration
}{
Cache: interval,
}

if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}

if cc.User == "" || cc.Password == "" {
return nil, api.ErrMissingCredentials
}
if cc.AppCert == "" || cc.AppKey == "" {
return nil, fmt.Errorf("leapmotor: app_cert and app_key are required (extract from Leapmotor APK)")
}

log := util.NewLogger("leapmotor").Redact(cc.User, cc.Password, cc.VIN)

identity, err := leapmotor.NewIdentity(log, cc.AppCert, cc.AppKey, cc.User, cc.Password)
if err != nil {
return nil, err
}
if err := identity.Login(); err != nil {
return nil, err
}

api := leapmotor.NewAPI(log, identity)

vehicles, err := api.Vehicles()
if err != nil {
return nil, fmt.Errorf("leapmotor: get vehicles: %w", err)
}
if len(vehicles) == 0 {
return nil, fmt.Errorf("leapmotor: no vehicles found on account")
}

var matched *leapmotor.Vehicle
for i := range vehicles {
v := &vehicles[i]
if cc.VIN == "" || v.VIN == cc.VIN {
matched = v
break
}
}
if matched == nil {
return nil, fmt.Errorf("leapmotor: VIN %s not found on account", cc.VIN)
}

return &Leapmotor{
embed: &cc.embed,
Provider: leapmotor.NewProvider(api, matched.VIN, matched.CarType, cc.Cache),
}, nil
}
86 changes: 86 additions & 0 deletions vehicle/leapmotor/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package leapmotor

import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)

const (
BaseURL = "https://appgateway.leapmotor-international.de"
appVersion = "1.12.3"
source = "leapmotor"
channel = "1"
deviceType = "1"
p12EncAlg = "1"
policyID = "20260204"
defaultLang = "en"
)

type apiEnvelope[T any] struct {
Code int `json:"code"`
Message string `json:"message"`
Data T `json:"data"`
}

// LoginResponse holds fields returned by the login endpoint.
type LoginResponse struct {
ID json.Number `json:"id"`
UID string `json:"uid"`
Token string `json:"token"`
RefreshToken string `json:"refreshToken"`
SignIkm string `json:"signIkm"`
SignSalt string `json:"signSalt"`
SignInfo string `json:"signInfo"`
Base64Cert string `json:"base64Cert"`
}

// Vehicle is a vehicle entry from the account vehicle list.
type Vehicle struct {
VIN string `json:"vin"`
CarType string `json:"carType"`
}

// StatusData holds the vehicle status fields relevant to EVCC.
type StatusData struct {
Soc *int `json:"soc"`
ChargeState *int `json:"chargeState"`
ChargeRemainTime *int `json:"chargeRemainTime"`
BatteryCurrent *float64 `json:"batteryCurrent"`
BatteryVoltage *float64 `json:"batteryVoltage"`
ExpectedMileage *int `json:"expectedMileage"`
Speed *int `json:"speed"`
TotalMileage *int `json:"totalMileage"`
}

// apiPost sends a POST to fullURL with the given headers and body, returns the response body.
func apiPost(client *http.Client, fullURL string, headers map[string]string, body string) ([]byte, error) {
req, err := http.NewRequest(http.MethodPost, fullURL, strings.NewReader(body))
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}

// parseEnvelope decodes the API envelope, returning Data or an error for non-zero codes.
func parseEnvelope[T any](body []byte) (T, error) {
var res apiEnvelope[T]
var zero T
if err := json.Unmarshal(body, &res); err != nil {
return zero, err
}
if res.Code != 0 {
return zero, fmt.Errorf("api %d: %s", res.Code, res.Message)
}
return res.Data, nil
}
Loading
Loading