Skip to content
Closed
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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ require (
github.com/denisbrodbeck/machineid v1.0.1
github.com/dylanmei/iso8601 v0.1.0
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/emmansun/gmsm v0.42.0
github.com/enbility/eebus-go v0.7.0
github.com/enbility/ship-go v0.6.0
github.com/enbility/spine-go v0.7.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWU
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/emmansun/gmsm v0.42.0 h1:4lKCm4uay1ZjzJmdOzxCZHa4WvIbz64Z3NEJn9itMHg=
github.com/emmansun/gmsm v0.42.0/go.mod h1:FD1EQk4XcSMkahZFzNwFoI/uXzAlODB9JVsJ9G5N7Do=
github.com/enbility/eebus-go v0.7.0 h1:Uh3i+HMmTYecWA+BBlYYhNFuNtqzWWQarbv4z9n/aQI=
github.com/enbility/eebus-go v0.7.0/go.mod h1:ftoVhXGC00IEcfN4RZSb1PbBIglE9i3JYqwrjhXnYSA=
github.com/enbility/go-avahi v0.0.0-20240909195612-d5de6b280d7a h1:foChWb8lhzqa6lWDRs6COYMdp649YlUirFP8GqoT0JQ=
Expand Down
14 changes: 14 additions & 0 deletions templates/definition/vehicle/leapmotor.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
template: leapmotor
products:
- brand: Leapmotor
requirements:
description:
en: |
App certificates are automatically downloaded from the community repository on first use.
de: |
App-Zertifikate werden beim ersten Start automatisch aus dem Community-Repository heruntergeladen.
params:
- preset: vehicle-base
render: |
type: leapmotor
{{ include "vehicle-base" . }}
189 changes: 189 additions & 0 deletions vehicle/leapmotor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package vehicle

import (
"fmt"
"strings"
"time"

"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/vehicle/leapmotor"
"golang.org/x/sync/errgroup"
)

const (
leapmotorCertURL = "https://raw.githubusercontent.com/markoceri/leapmotor-certs/main/app.crt"
leapmotorKeyURL = "https://raw.githubusercontent.com/markoceri/leapmotor-certs/main/app.key"
leapmotorDBKeyCert = "leapmotor.app.cert"
leapmotorDBKeyKey = "leapmotor.app.key"
)

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

// LeapmotorWithControl extends Leapmotor with vehicle-side charge control (requires vehicle PIN).
type LeapmotorWithControl struct {
*Leapmotor
api *leapmotor.API
vin string
}

var _ api.ChargeController = (*LeapmotorWithControl)(nil)

func (l *LeapmotorWithControl) ChargeEnable(enable bool) error {
return l.api.ChargeToggle(l.vin, enable)
}

// SinglePhaseLeapmotor wraps Leapmotor to report 1-phase charging (e.g. T03).
type SinglePhaseLeapmotor struct {
*Leapmotor
}

// SinglePhaseLeapmotorWithControl wraps LeapmotorWithControl for single-phase vehicles.
type SinglePhaseLeapmotorWithControl struct {
*LeapmotorWithControl
}

func (o SinglePhaseLeapmotor) Phases() int {
return 1
}

func (o SinglePhaseLeapmotorWithControl) Phases() int {
return 1
}

func init() {
registry.Add("leapmotor", NewLeapmotorFromConfig)
Comment thread
syphernl marked this conversation as resolved.
}

func getAppCertFromDB() (certPEM, keyPEM []byte, err error) {
cert, err1 := settings.String(leapmotorDBKeyCert)
key, err2 := settings.String(leapmotorDBKeyKey)
if err1 != nil || err2 != nil {
return nil, nil, fmt.Errorf("not cached")
}
return []byte(cert), []byte(key), nil
}

func updateAppCertInDB(log *util.Logger) (certPEM, keyPEM []byte, err error) {
client := request.NewHelper(log)
eg := new(errgroup.Group)
eg.Go(func() error {
var e error
certPEM, e = client.GetBody(leapmotorCertURL)
return e
})
eg.Go(func() error {
var e error
keyPEM, e = client.GetBody(leapmotorKeyURL)
return e
})
if err = eg.Wait(); err != nil {
return nil, nil, err
}
settings.SetString(leapmotorDBKeyCert, string(certPEM))
settings.SetString(leapmotorDBKeyKey, string(keyPEM))
return certPEM, keyPEM, nil
}

// 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
Pin 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
}

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

certPEM, keyPEM, err := getAppCertFromDB()
if err != nil {
if certPEM, keyPEM, err = updateAppCertInDB(log); err != nil {
return nil, fmt.Errorf("leapmotor: fetch app certs: %w", err)
}
}

newIdentity := func(certPEM, keyPEM []byte) (*leapmotor.Identity, error) {
return leapmotor.NewIdentity(log, certPEM, keyPEM, cc.User, cc.Password, cc.Pin)
}

identity, err := newIdentity(certPEM, keyPEM)
if err != nil {
return nil, err
}

if err := identity.TryRestore(); err != nil {
if loginErr := identity.Login(); loginErr != nil {
if strings.Contains(loginErr.Error(), "tls") {
// App cert may be stale — refresh and retry once
if certPEM, keyPEM, err = updateAppCertInDB(log); err != nil {
return nil, fmt.Errorf("leapmotor: refresh app certs: %w", err)
}
if identity, err = newIdentity(certPEM, keyPEM); err != nil {
return nil, err
}
if err = identity.Login(); err != nil {
return nil, err
}
} else {
return nil, loginErr
}
}
}

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

lm := &Leapmotor{
embed: &cc.embed,
Provider: leapmotor.NewProvider(api, matched.VIN, matched.CarType, cc.Cache),
}

if cc.Pin != "" {
lmCtl := &LeapmotorWithControl{Leapmotor: lm, api: api, vin: matched.VIN}
if matched.CarType == "T03" {
return SinglePhaseLeapmotorWithControl{lmCtl}, nil
}
return lmCtl, nil
}

if matched.CarType == "T03" {
return SinglePhaseLeapmotor{lm}, nil
}
return lm, nil
}
Loading
Loading