feat(vehicle): add support for LeapMotor API - #29666
Conversation
- 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
- 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")
- 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
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
There was a problem hiding this comment.
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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
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.
b0ee9f2 to
761247b
Compare
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.
761247b to
59f7d9a
Compare
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. |
It is. It allows focusing on where we can add value.
Unfortunately, that still comes with the cost of issues and reviews. Integration is always possible using the HA vehicle. |


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