Skip to content

feat(vehicle): add support for LeapMotor API - #29666

Closed
syphernl wants to merge 15 commits into
evcc-io:masterfrom
syphernl:feat/leapmotor_api_integration
Closed

feat(vehicle): add support for LeapMotor API#29666
syphernl wants to merge 15 commits into
evcc-io:masterfrom
syphernl:feat/leapmotor_api_integration

Conversation

@syphernl

@syphernl syphernl commented May 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds Leapmotor cloud API support (same API the official app uses). The API is unofficial but community-documented:

App certs download automatically from markoceri/leapmotor-certs on startup, no files to provide.

Supported data

SoC, charge state, range, odometer, finish time, climate state, GPS position, SoC limit.

Caveats

  • Unofficial API, may break without warning
  • First startup requires GitHub reachability to fetch app certificates; cached locally after that.

Status

Not tested on a real car yet. Draft until someone validates it on a T03, B10, or C10.

Tested by @ngehrsitz on a T03: #29666 (review)

References

syphernl added 2 commits May 5, 2026 15:30
- Remove EnsureAuth (dead code, had TOCTOU race)
- Make addAuthHeaders void (return value was always ignored)
- Drop Python-style inline comments in deriveP12Password
- Collapse SM4 section divider to single comment line

@ngehrsitz ngehrsitz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested it briefly and it works so far 👍
Image

Comment thread vehicle/leapmotor/identity.go
Comment thread vehicle/leapmotor/provider.go
Comment thread vehicle/leapmotor.go Outdated
Comment thread vehicle/leapmotor/identity.go Outdated
Comment thread vehicle/leapmotor.go
Comment thread vehicle/leapmotor/identity.go Outdated
Comment thread vehicle/leapmotor/identity.go Outdated
Comment thread vehicle/leapmotor/identity.go Outdated
Comment thread vehicle/leapmotor/api.go
Comment thread vehicle/leapmotor/api.go
- Rename AppCert/AppKey → auto-download app certs from markoceri/leapmotor-certs
  (removes file path requirement; certs fetched in parallel via errgroup)
- Replace custom SM4 impl with github.com/emmansun/gmsm (key recovered from
  APK round key schedule); cipher block initialised once at package init
- Replace manual JWT parsing with golang-jwt/jwt in deriveSessionDeviceID
- Stable deviceID: SHA256(email)[:16] replaces per-restart random bytes
- NewIdentity accepts PEM bytes instead of file paths
- apiPost uses request.Helper.DoBody (adds HTTP status code checking)
- Add postAndParse[T] helper combining POST + envelope decode
- Add VehicleClimater, VehiclePosition, SocLimiter interfaces
- Add templates/definition/vehicle/leapmotor.yaml for UI discovery
- Use hex.EncodeToString instead of fmt.Sprintf("%x")
@syphernl
syphernl requested a review from ngehrsitz May 12, 2026 18:58

@ngehrsitz ngehrsitz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new readouts work 👍
Image

Comment thread vehicle/leapmotor.go Outdated
Comment thread vehicle/leapmotor/provider.go Outdated
Comment thread vehicle/leapmotor/identity.go Outdated
Comment thread vehicle/leapmotor/identity.go Outdated
Comment thread vehicle/leapmotor/identity.go Outdated
Comment thread vehicle/leapmotor/provider.go
Comment thread vehicle/leapmotor/identity.go Outdated
Comment thread vehicle/leapmotor/identity.go Outdated
Comment thread vehicle/leapmotor/identity.go
Comment thread vehicle/leapmotor/identity.go
- Cache app certs in DB; fetch from GitHub only when missing or on TLS
  failure (avoids unauthenticated rate-limit hits on every startup)
- Add TryRestore(): load persisted session (token, acct cert) from DB,
  skip full login when session is still valid; login() persists session
  after each successful auth
- SinglePhaseLeapmotor wrapper reports Phases()=1 for T03
- extractSessionDeviceID: typed usernameClaims struct, return *string
  (no fallback param); fix len check >= 3 instead of >= 4
- Merge addAuthHeaders into buildSignedHeaders (userID/token params)
- maps.Copy + slices.Collect(maps.Keys) + slices.Sort in buildSignedHeaders
- hex.EncodeToString(mac.Sum(nil)) instead of fmt.Sprintf("%x", ...)
- Replace init() block with package-level var for p12SM4Block
- slices.DeleteFunc for VIN filtering in Vehicles()
- Add unit tests for extractSessionDeviceID, deriveSignKey,
  p12MemoryEncode, deriveP12Password
@syphernl
syphernl requested a review from ngehrsitz May 16, 2026 08:13
syphernl added 3 commits May 16, 2026 10:17
Implements api.ChargeController via REMOTE_CTL_CHARGE_START/STOP
(cmdId=193) when a vehicle PIN is configured. PIN is optional —
omitting it preserves existing read-only behaviour.

New config field: `pin` (redacted in logs).

- Identity: AES-128-CBC PIN encryption, cert sync, HasPin/EncryptedPin
- API: ChargeToggle — PIN verify + remote/ctl two-step flow
- LeapmotorWithControl / SinglePhaseLeapmotorWithControl types expose
  ChargeEnable only when PIN is present
@andig
andig marked this pull request as ready for review May 26, 2026 18:26

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="vehicle/leapmotor/identity.go" line_range="389-390" />
<code_context>
+	return nil
+}
+
+// Refresh refreshes the access token; falls back to a full login on failure.
+func (id *Identity) Refresh() error {
+	id.mu.Lock()
+	defer id.mu.Unlock()
</code_context>
<issue_to_address>
**suggestion:** Refreshed tokens are not persisted back to settings, which may cause unnecessary full logins after restarts.

Login() writes the token, refresh token, sign key, and PKCS#12 state to settings, but Refresh() only updates the in‑memory fields. After a restart, TryRestore will reload the old persisted values, so a full login may be required even after a successful refresh. To avoid this and support smoother restarts, also persist the updated values in Refresh().

Suggested implementation:

```golang
	id.token = token
	id.refreshTok = refreshTok
	id.userID = userID
	id.signKey = signKey
	id.acctClient = newMTLSClient(&acctCert)

	// persist restored identity so subsequent restarts use the latest credentials
	if err := id.persistIdentity(); err != nil {
		return err
	}

	return nil
}

// persistIdentity persists the current identity state (token, refresh token,
// sign key, and PKCS#12 / client cert state) to settings so it survives restarts.
func (id *Identity) persistIdentity() error {
	// NOTE: This implementation assumes there is a settings-like component on Identity.
	// Adjust the concrete calls to match the actual settings API used in this package.
	if id.settings == nil {
		return nil
	}

	if err := id.settings.Set("token", id.token); err != nil {
		return err
	}

	if err := id.settings.Set("refresh_token", id.refreshTok); err != nil {
		return err
	}

	if err := id.settings.Set("user_id", id.userID); err != nil {
		return err
	}

	if err := id.settings.Set("sign_key", id.signKey); err != nil {
		return err
	}

	// Persist PKCS#12 / mTLS client state if available.
	// This assumes the app certificate / PKCS#12 bytes are stored on Identity.
	if len(id.appPKCS12) != 0 {
		if err := id.settings.Set("app_pkcs12", id.appPKCS12); err != nil {
			return err
		}
	}

	// Some settings implementations require an explicit Save/Commit.
	if saver, ok := any(id.settings).(interface{ Save() error }); ok {
		return saver.Save()
	}

	return nil
}

```

```golang
+// Refresh refreshes the access token; falls back to a full login on failure.
+// On success, the refreshed credentials are also persisted so that
+// TryRestore() can restore the latest state after a restart.
+func (id *Identity) Refresh() error {
+	id.mu.Lock()
+	defer id.mu.Unlock()
+
+	// No refresh token available, perform a full login.
+	if id.refreshTok == "" {
+		return id.login()
+	}
+
+	// Use the account mTLS client to refresh the token.
+	if id.acctClient == nil {
+		// If for some reason the account client is missing, fall back to full login.
+		return id.login()
+	}
+
+	req, err := http.NewRequest(http.MethodPost, refreshURL, nil)
+	if err != nil {
+		return err
+	}
+
+	// Build refresh headers/body using the existing refresh token.
+	req.Header = buildRefreshHeaders(id.deviceID, id.refreshTok, defaultLang)
+
+	resp, err := id.acctClient.Do(req)
+	if err != nil {
+		// Network / client error – fall back to full login.
+		return id.login()
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		// Server-side refresh failure – fall back to full login.
+		return id.login()
+	}
+
+	var payload refreshResponse
+	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+		// Response could not be decoded – fall back to full login.
+		return id.login()
+	}
+
+	// Update in-memory fields from the refresh response.
+	id.token = payload.AccessToken
+	id.refreshTok = payload.RefreshToken
+
+	// Some backends may rotate the signing key and/or certificates on refresh.
+	if payload.SignKey != "" {
+		id.signKey = payload.SignKey
+	}
+
+	if len(payload.AcctPKCS12) != 0 {
+		// Rebuild the account mTLS client if we got a new PKCS#12.
+		acctCert, err := parsePKCS12(payload.AcctPKCS12, payload.AcctPKCS12Pass)
+		if err != nil {
+			// If the new PKCS#12 cannot be parsed, fall back to full login to re-bootstrap.
+			return id.login()
+		}
+		id.acctClient = newMTLSClient(&acctCert)
+		// Keep the PKCS#12 bytes around so they can be persisted.
+		id.acctPKCS12 = payload.AcctPKCS12
+	}
+
+	// Persist the refreshed credentials so TryRestore() after a restart sees the latest state.
+	if err := id.persistIdentity(); err != nil {
+		return err
+	}
+
+	return nil
+}

```

1. Wire `persistIdentity()` into `login()` as well, right after the point where `id.token`, `id.refreshTok`, `id.userID`, `id.signKey`, the PKCS#12 bytes, and the related mTLS clients are updated. This ensures both `login()` and `Refresh()` persist credentials in a consistent way.
2. Adjust the implementation of `persistIdentity()` to use the actual settings / persistence mechanism available on `Identity` (field name, method names, and key names may differ). The key requirement is that it writes the access token, refresh token, sign key, and PKCS#12 (for both app and account as applicable) and commits those changes.
3. Replace the placeholder types and symbols in `Refresh()` (`refreshURL`, `refreshResponse`, `buildRefreshHeaders`, `parsePKCS12`, `acctPKCS12`/`appPKCS12` fields) with the concrete ones used in this package, mirroring how `login()` and `TryRestore()` currently build their requests and manage PKCS#12 state.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread vehicle/leapmotor/identity.go
syphernl added 4 commits May 26, 2026 20:31
Login() wrote token/refreshTok to settings but Refresh() only updated
in-memory fields. TryRestore() after a restart reloaded stale values,
forcing an unnecessary full login on every restart after a token refresh.
B10 and B11 share the C10 status endpoint; requesting status/get/b10
returns no usable telemetry (soc/range/mileage all null). Map b10/b11
to c10 like the upstream leapmotor-api does.
@syphernl
syphernl force-pushed the feat/leapmotor_api_integration branch from b0ee9f2 to 761247b Compare May 30, 2026 12:58
C10/B10 (and other newer models) return telemetry under a nested
"signal" object keyed by numeric signal IDs, not the flat fields T03
uses. Map the relevant signal IDs (soc, range, mileage, charge state,
battery V/A, ac, position) to StatusData and read the charge limit from
config.3.percent. Flat T03 fields still take priority, so T03 is
unaffected. Mirrors leapmotor-api's signal table.
@syphernl
syphernl force-pushed the feat/leapmotor_api_integration branch from 761247b to 59f7d9a Compare May 30, 2026 13:03
@andig

andig commented Jun 1, 2026

Copy link
Copy Markdown
Member

Unofficial API, may break without warning

We're currently not adding any more of these as they strain project ressources. Happy to reasses when official API is available.

@andig andig closed this Jun 1, 2026
@syphernl
syphernl deleted the feat/leapmotor_api_integration branch June 1, 2026 22:23
@ngehrsitz

Copy link
Copy Markdown
Contributor

Unofficial API, may break without warning

We're currently not adding any more of these as they strain project ressources. Happy to reasses when official API is available.

Is that really a good strategy though? I fully understand if the core development team does not want to invest resources into fixing a broken Integration like with VW at the moment.
At the same time I fear it will take years until litigation can enforce the open APIs mandated by the EU data act.
This means users are forced to use workarounds like running an additional Home Assistant.
Thus in the interim I would propose to keep the unofficial APIs on a "community support" model. This means if it breaks and none of the community stakeholders fixes it then just remove it after a grace period.
This limits the effort for the core development team to just making sure that PRs don´t introduce any unwanted changes to the rest of the codebase.
In my opinion this would be the best compromise that can be made here. Otherwise the users of a Leapmotor have to go without connectivity just because support for all of the other closed APIs came before them...

@andig

andig commented Jun 4, 2026

Copy link
Copy Markdown
Member

Is that really a good strategy though?

It is. It allows focusing on where we can add value.

Thus in the interim I would propose to keep the unofficial APIs on a "community support" model. This means if it breaks and none of the community stakeholders fixes it then just remove it after a grace period.

Unfortunately, that still comes with the cost of issues and reviews. Integration is always possible using the HA vehicle.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants