Skip to content

Structural refactoring & security hardening#19

Merged
markoceri merged 50 commits into
mainfrom
structural-refactoring
Jun 13, 2026
Merged

Structural refactoring & security hardening#19
markoceri merged 50 commits into
mainfrom
structural-refactoring

Conversation

@markoceri

Copy link
Copy Markdown
Owner

LeapConnect started as a weekend project to keep an eye on my Leapmotor T03, but it quickly grew well beyond that — picking up a lot of features along the way and support for other vehicles too. Given the direction it's heading and the ideas I have in store for it, I felt it was time to reorganize the codebase and give it a more professional shape — not too professional, mind you; it's still a weekend project. 😄

Summary

Reworks the backend into a hexagonal/DDD architecture, cleans up the public API naming, and hardens secret/session handling. Behaviour is preserved except for the explicitly breaking API/env renames listed below (the bundled Vue frontend is migrated in lockstep).

Highlights

Architecture (no behaviour change)

  • Monolithic main.py split into the leapconnect/ package: domain / application / infrastructure / api layers behind a container.py composition root, with layering enforced by an architecture test suite.
  • The 1.9k-line SQLite adapter and the notification dispatcher are each split into per-context / per-policy modules.
  • API routers use FastAPI Depends instead of the container singleton.
  • New entry points: ASGI leapconnect.api.app:app, CLI python -m leapconnect.

API cleanup (breaking)

  • Endpoints renamed in place (no /api/v2): session-style auth & cloud connection, /api/charging/*, /api/telegram/*, REST-style tracking.
  • The ~50 per-command routes collapse into one generic POST /api/vehicles/{vin}/commands/{command} backed by a shared command registry; GET /api/vehicles/{vin}/commands advertises availability.
  • Trip payload field typos fixed (eneryConsumeenergyConsumed, …).
  • OpenAPI tags per bounded context.

Security

  • Secrets encrypted at rest (Fernet) — cloud/MQTT passwords, ABRP token, vehicle PIN, Telegram token; key auto-generated 0600 beside the DB.
  • Dashboard sessions persisted (hashed) so restarts no longer log users out.
  • WebSocket auth is cookie-only; login attempts are rate-limited; vehicle PIN is write-only.

Config (breaking)

  • Environment parsed via pydantic-settings; DB-path env var renamed HISTORY_DB_PATHDB_PATH (no fallback).

markoceri added 30 commits June 12, 2026 01:11
Add the new top-level package and config.py centralising paths
(DATA_DIR, certs, vehicle images, frontend dist), app version and
database path resolution incl. legacy history.db migration.
Move VehicleSnapshot/VehicleEvent dataclasses and the TransitionDetector
(state-change rules + dedup) into leapconnect.domain.telemetry.
Pure stdlib, no framework imports.
Typed UserPreferences, SchedulerSettings, LiveRefreshSettings,
MqttSettings and AbrpSettings models in leapconnect.domain.settings.
ChargingPriceTier/ChargingTimeBand/ChargingSessionCost models plus the
pure cost engine (flat + time-of-use band splitting) extracted from
main.py into leapconnect.domain.charging.
Channel/preference/geofence/Telegram models, geofence geometry
(haversine, ray-casting point-in-polygon, centroid) and the notification
event catalog + message templates, extracted from main.py and
services/notification_dispatcher.py.
Plan/record/alert/repo/pack models, the due/overdue alert engine, pack
normalization & model-compat matching, and the vehicle model resolver,
extracted from services/maintenance_service.py and maintenance_resolver.
SessionStore (token create/validate/invalidate with 7-day expiry)
extracted from the inline session dict in main.py.
~500 lines of pure trip logic extracted from main.py: moving-segment
extraction and merging, trip row building, regen energy, similarity
scoring (route/time/distance), compare metrics and local charge-session
detection.
models.py, services/transition_detector.py and
services/maintenance_resolver.py now re-export from
leapconnect.domain.*; old import paths keep working until the next
major release.
Persistence port regenerated as six per-context interfaces
(Telemetry/Settings/Account/Notification/Charging/Maintenance
Repository) combined into VehicleHistoryRepository, plus the
BaseNotifier/Notification port moved from services/notifiers.
SQLAlchemyVehicleHistoryRepository implements the new combined port;
alembic script_location now resolved from config.ROOT_DIR and
migrations/env.py imports Base from the new path. persistence/* are
deprecation shims.
services/mqtt_ha.py becomes a shim for
leapconnect.infrastructure.mqtt.home_assistant.
services/abrp.py becomes a shim for
leapconnect.infrastructure.abrp.service.
Bot polling handler, notifier adapter and TelegramConfig move to
leapconnect.infrastructure.telegram; the bot now imports the event
catalog from the domain instead of the dispatcher. services/telegram_*
and services/notifiers/* are shims.
GitHub repo discovery / pack fetching moves to
leapconnect.infrastructure.community (imports normalize_pack from the
domain). services/maintenance_community.py is a shim.
The in-memory log buffer + WebSocket broadcast handler moves out of
main.py into leapconnect.infrastructure.logbuffer.
services/vehicle_cache.py becomes a shim.
Imports TransitionDetector from the domain and the repository port from
application.ports. services/scheduler.py becomes a shim.
The dispatcher now imports the event catalog, message templates and
geofence geometry from the domain instead of defining them inline
(~480 lines slimmer). services/notification_dispatcher.py is a shim
re-exporting the historical names.
- maintenance.py: plan auto-generation from factory items
- commands.py: command->rights mapping, ability checks, execution maps
  for REST/Telegram/MQTT (extracted from main.py)
- settings_store.py: typed preferences/MQTT/ABRP persistence and
  session-cost calculation on top of the key/value settings table

services/maintenance_service.py becomes a shim.
schemas.py (1,274 lines) split into leapconnect/api/schemas/{vehicle,
common,identity,connection,telemetry,system,charging,notifications,
maintenance}.py with a re-exporting package init; schemas.py is now a
deprecation shim. All 95 classes preserved.
Replaces main.py module globals: owns cloud client/connection state,
service construction and wiring (startup/shutdown from the old
lifespan), live-refresh loop, WebSocket broadcast, session store,
vehicle image caching and the MQTT/Telegram command callbacks.
Repository access with 503 guards, session cookie constants, public
paths and date-range parsing extracted from main.py.
Setup/certificates/local-user/auth endpoints and Leapmotor cloud
connection endpoints (login/reconnect/disconnect/PIN/status), extracted
from main.py. URL paths unchanged.
Vehicle list/status/pictures/full-data, live WebSocket + live-refresh
settings, and local history (snapshots/daily/events) endpoints,
extracted from main.py. URL paths unchanged.
All ~70 remote-command endpoints (locks, climate, charging, schedules,
FOTA, media, seats...) extracted from main.py. URL paths unchanged.
Trip analytics endpoints (now delegating to domain.trips.analysis) and
charging endpoints (cloud history, consumption, tiers, time bands,
session costs) extracted from main.py. URL paths unchanged.
Maintenance (model/plan/records/overview/library/community repos+packs),
notifications (channels/events/geofences/tracking/Telegram users/log WS)
and system (preferences/scheduler/db-size/MQTT/ABRP/messages/log levels)
endpoints extracted from main.py. URL paths unchanged.
leapconnect/api/app.py builds the app (CORS, session middleware,
LeapmotorApiError handler, SPA mount, routers, lifespan delegating to
the container). main.py shrinks from 6,226 to ~55 lines and keeps only
the uvicorn target and the --reset-password CLI.

Route parity verified programmatically: all 177 original routes
(2 WebSockets included) preserved with identical paths and methods.

Tests: conftest now patches AppContainer.auto_connect; unit tests
repointed at the domain modules.
New test module asserting domain purity (stdlib + domain-only imports,
TYPE_CHECKING excluded), that the application layer never imports the
API layer, and that every legacy shim keeps re-exporting its
historical names.
markoceri added 20 commits June 12, 2026 01:16
Layer diagram, bounded contexts, dependency rules, entry points,
legacy shim inventory and testing notes for the hexagonal layout.
VehicleHistoryRepository was a misnomer: it is the combined persistence
port for every context, not a repository of vehicle history. Renames:

- port: VehicleHistoryRepository -> AppRepository
- adapter: SQLAlchemyVehicleHistoryRepository -> SqlAlchemyRepository
- container attribute: history_repo -> repo

Deprecated aliases kept for one release (old class names + a
container.history_repo property); persistence/* shims still export the
historical names.
GET/PUT /api/vehicle-pin no longer echo the saved PIN back to the
client; responses carry only the has_pin flag. The Settings UI shows a
masked placeholder when a PIN is saved instead of pre-filling the input
with the plaintext value.
main.py, models.py, schemas.py, services/* and persistence/* were
re-export shims kept during the hexagonal refactoring; all code now
imports from leapconnect.* directly.

The ASGI app moves to leapconnect.api.app:app (Dockerfile CMD, serve
script, docs) and the --reset-password CLI moves to
python -m leapconnect (leapconnect/__main__.py).
VehicleHistoryRepository, SQLAlchemyVehicleHistoryRepository and
container.history_repo had no remaining importers after the shim
removal.
The 1.9k-line SqlAlchemyRepository monolith becomes:

- tables.py — ORM rows + Alembic Base (migrations/env.py repointed)
- migration.py — startup Alembic upgrade + self-healing ALTER fallbacks
- base.py — shared async session factory
- one repository class per bounded context (telemetry, settings,
  account, notifications, charging, maintenance), each implementing its
  segregated port
- sqlite_adapter.py — thin facade composing them into AppRepository and
  owning the engine lifecycle

init_db/close move from TelemetryRepository to AppRepository: storage
lifecycle is adapter-wide, not telemetry-specific. Method bodies are
unchanged (AST-verified, 67/67 identical).
The monolithic notification_dispatcher.py becomes the
application/notifications/ package:

- dispatcher.py — orchestrator: channels, preferences, cooldowns, mute,
  dispatch pipeline, notification composition
- policies.py — stateful custom-event detectors (movement alert,
  unlocked timeout, SOC thresholds + 0%-glitch filter, charge
  interrupted, range low, tire pressure, geofence watcher) behind a
  shared StatusReading/ChannelView contract
- tracking.py — LocationTracker (periodic Telegram sendLocation loops)
- telegram_admin.py — user approve/decline notifications

The dispatcher's public API is unchanged; the repo parameter is now
typed as the AppRepository port instead of the infrastructure adapter.
28 new unit tests cover the detection policies.
Routers no longer import the container singleton. api/deps.py now
provides chainable dependency providers — get_container, get_repo,
repo_required(detail), get_client, get_vehicle — with Annotated aliases
(ContainerDep, RepoDep, ClientDep, VehicleDep), so tests can swap the
whole composition root with app.dependency_overrides[get_container].

Non-endpoint helpers receive the container (or repo) explicitly from
the endpoints. URL paths and behavior are unchanged: route parity
re-verified against the previous revision (186/186), endpoint-specific
503 messages preserved via the repo_required factory, and the
400-before-404 precedence in the maintenance model override kept by
looking the vehicle up in the handler body. WebSocket endpoints keep
closing with 4401 when unauthorized. Adds an override regression test.
- asyncutils.spawn() keeps strong references to fire-and-forget tasks
  (snapshot save, MQTT publish, notification send, MQTT command and
  settings handlers) so the event loop cannot GC them mid-flight
- migrate deprecated datetime.utcnow()/utcfromtimestamp() to tz-aware
  equivalents; values that flow into or compare against the DB keep
  naive-UTC semantics (now(UTC).replace(tzinfo=None))
- drop 12 per-endpoint try/except LeapmotorApiError -> 502 duplicates;
  the global handler in api/app.py returns the same payload and logs
- HomeAssistantMqttService exposes mqtt_interval_seconds as a property;
  the container no longer pokes the private attribute
leapconnect/config.py now declares a typed AppSettings class
(pydantic-settings): DATA_DIR, HISTORY_DB_PATH, HOST, PORT, and the new
CORS_ORIGINS variable (comma-separated or JSON list) replacing the
hardcoded Vue dev-server origins. database_path() keeps re-reading the
environment per call so per-test temporary databases keep working;
python -m leapconnect honours HOST/PORT.
domain/identity/throttle.py adds a pure LoginThrottle policy: after 5
consecutive failures per client IP the login is locked for 30 seconds,
doubling with each further failure up to 15 minutes; a successful login
clears the counter. /api/auth/login returns 429 with a Retry-After
header while locked. The throttle lives on the container and is reset
per test in conftest (the container is a process-wide singleton).

Also fixes a stale ARCHITECTURE.md note about the dropped repository
alias.
Apply the naming cleanup directly to the existing paths (no /api/v2
namespace, no aliases); the bundled Vue frontend is migrated in the
same commit:

- local auth is session-style: POST/DELETE /api/auth/session
- cloud connection: POST/PUT/DELETE /api/cloud/session (login/
  reconnect/disconnect), GET /api/cloud/status
- vehicle PIN: set-pin + vehicle-pin merged into GET/PUT /api/cloud/pin;
  the unused POST /api/logout endpoint and the now-orphan
  container.logout() are deleted
- charging: /api/charging/tiers, /api/charging/time-bands; the
  misleading charge-stats/cloud|year (they serve local history) become
  /api/vehicles/{vin}/charging/stats/daily|yearly
- telegram administration: /api/telegram/users*, /api/telegram/link-token
- tracking is a REST resource: GET/POST/DELETE /api/vehicles/{vin}/tracking
- every router now carries an OpenAPI tag, so /docs groups endpoints by
  bounded context

Route diff verified against the previous revision: 23 paths removed,
21 added, nothing else touched (180 routes total). Installed PWA
clients with a stale cached shell recover at the next service-worker
auto-update.
…point

POST /api/vehicles/{vin}/commands/{command} (kebab-case names, optional
JSON body validated per command) replaces the ~50 per-command routes
and their three inconsistent naming styles. GET .../commands lists
every command with an available flag (vehicle rights/abilities) so the
UI can also surface unsupported commands on request.

One registry in application/commands.py (CommandSpec: invoke + right +
param model) is now the single source of truth shared by REST, the
Telegram bot and the MQTT bridge — it replaces COMMAND_RIGHTS,
command_map and MQTT_COMMAND_METHODS, which had drifted apart. The
legacy ac_on name stays as an alias for Telegram/MQTT. Vehicle rights
are NOT enforced on REST yet (pass-through, matching the old routes);
the registry carries the right per command so flipping to 403 is a
follow-up.

Cloud schedules become proper REST resources: PUT (was POST) on
charge-schedule, ac-schedule and fota/schedule.

Frontend migrated in the same commit (execControl funnel + modals).
Route diff vs previous revision: 54 removed, 5 added (180 -> 131).
66 new tests cover registry integrity (every command resolves to a
real client method), rights checks, executors and the endpoint.
The cloud-compat typos in the local trip payloads are gone:
eneryConsume -> energyConsumed, recoveryEnery -> energyRecovered,
accumulated_enery_consume -> accumulated_energy_consumed,
total_enery/totalenery -> total_energy, total_milage/totalmileage ->
total_mileage, totalrecoveryenery -> total_energy_recovered,
maxspeed -> max_speed, ustime -> total_hours.

Applies to /api/vehicles/{vin}/trips, /trips/totals and the similarity
metrics; TripsTab and the tests are migrated in the same commit.
travelMile is kept on purpose: it mirrors the cloud app's field name
(value in meters), renaming it is outside typo scope.
Sessions now survive restarts: the SessionStore keys tokens by SHA-256
hash and a new local_sessions table persists (hash, expiry) rows —
write-through on login/logout, restored into memory at startup,
expired rows purged on load. The DB never sees a raw token, and logout
keeps revoking server-side (the reason a session table was chosen over
itsdangerous signed cookies). The table is brand-new, so create_all
covers it without an Alembic migration.

WebSocket endpoints (/ws/vehicle/{vin}, /ws/logs) accept only the
session cookie now; the ?token= query fallback leaked tokens into
access logs and the frontend never used it.
Leapmotor account password + p12 password, MQTT broker password, ABRP
token, vehicle operation PIN and the Telegram bot token are now stored
encrypted (Fernet/AES) instead of plaintext in SQLite.

- infrastructure/secrets.py: SecretCipher + load_or_create_cipher; the
  key is generated 0600 beside the DB (= DATA_DIR in the Docker image).
- Encrypted values carry an enc: prefix, so legacy plaintext rows are
  read as-is and transparently re-encrypted on the next write — lazy
  migration, no separate step, no downtime.
- The cipher is threaded through SqlRepositoryBase; account/settings/
  notifications repos encrypt their own sensitive fields (settings via
  a SECRET_SETTING_KEYS allowlist, channels via the bot_token field).

The local dashboard login password is deliberately NOT encrypted: it is
verify-only and stays a one-way PBKDF2 hash, which is stronger than
reversible encryption for an auth secret.

Adds cryptography as a direct dependency; 22 new tests cover the cipher
(round-trip, legacy plaintext, wrong-key, key file perms) and that all
three secret locations are ciphertext on disk yet clear through the API.
The old name predates the history.db → leapconnect.db rename and the
database now holds far more than telemetry history (accounts, sessions,
maintenance, channels…), so HISTORY_DB_PATH was misleading. The
pydantic-settings field history_db_path becomes db_path, which maps to
DB_PATH automatically; sibling vars (DATA_DIR, HOST, PORT, CORS_ORIGINS)
are likewise unprefixed.

Breaking, no fallback: deployments setting HISTORY_DB_PATH must switch
to DB_PATH or the app starts on the default database path. Dockerfile,
docker-compose, README and the conftest test fixture are updated in
lockstep.
@markoceri markoceri self-assigned this Jun 13, 2026
@markoceri markoceri added the enhancement New feature or request label Jun 13, 2026
@markoceri markoceri merged commit f878332 into main Jun 13, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant