Structural refactoring & security hardening#19
Merged
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
main.pysplit into theleapconnect/package:domain/application/infrastructure/apilayers behind acontainer.pycomposition root, with layering enforced by an architecture test suite.Dependsinstead of thecontainersingleton.leapconnect.api.app:app, CLIpython -m leapconnect.API cleanup (breaking)
/api/v2): session-style auth & cloud connection,/api/charging/*,/api/telegram/*, REST-style tracking.POST /api/vehicles/{vin}/commands/{command}backed by a shared command registry;GET /api/vehicles/{vin}/commandsadvertises availability.eneryConsume→energyConsumed, …).Security
0600beside the DB.Config (breaking)
HISTORY_DB_PATH→DB_PATH(no fallback).