Contents
Contents
System Documentation

GeoNutria

A precision-agriculture platform: soil and leaf telemetry over MQTT, on-premise computer-vision and tabular models for crop diagnosis, satellite field analysis, and an LLM agronomy assistant — served to a React dashboard and a Flutter mobile app from one FastAPI backend.

Domain app.geonutria.ai Backend FastAPI · Python Data MySQL 8 Clients React · Flutter Transport MQTT · REST · WebSocket
Orientation

Overview

GeoNutria is a monorepo containing three deployable units and the infrastructure that joins them. A single FastAPI service owns all business logic, model inference, and device communication; both user-facing clients are thin consumers of its REST API.

Backend

FastAPI on :8009. 12 routers, ~36 Python modules. Loads ML models into memory at startup and runs a persistent MQTT client in a background thread.

Frontend

Create React App dashboard, ~37 source files across 10 views. Built into a static bundle and served by nginx inside its own container.

Mobile

Flutter app, 85 Dart files. Bloc/Cubit state, go_router navigation, English + Arabic with RTL. Reproduces the dashboard minus the admin panel.

Devices

Field nodes publishing soil and leaf sensor readings to an MQTT broker, and — since the My Devices feature — subscribing to control and OTA firmware topics.

What the platform does

  • IoT monitoring — live sensor readings per device, historical charts, weather overlay, and a manual-entry lab for users without hardware.
  • Plant diagnosis — leaf disease classification, palm disease segmentation and classification, soil type classification, and aerial palm-tree counting, all from local model weights.
  • Advisory — crop recommendation, yield prediction, and a conversational agronomy assistant backed by a local Gemma model with hosted-LLM fallback.
  • Satellite analysis — field imagery analysis rendered to plots under static/satellite_plots.
  • Device control — bind a device, publish switch/value commands, read back reported state, and push firmware over the air.
  • Accounts — OTP and Google registration, credit-metered features, team credit pooling, billing receipts, PDF reports, and live support chat.
Infrastructure

Architecture

Everything the user sees arrives through one nginx instance on the host. Everything a device says arrives through a broker the backend subscribes to. These are two separate paths that meet only in the database and in memory.

Clients
React dashboardbrowser
Flutter appAndroid / iOS
↓   HTTPS   app.geonutria.ai
Server · aaPanel host
Host nginx:443 — TLS termination
↓   / → frontend  ·  /api → backend (prefix stripped)
Docker Compose
frontendnginx :3000
backendFastAPI :8009
mosquitto:1883
ollama:11434
↓   host.docker.internal:3306
MySQL · geonutria_dbon the host, not containerised
— telemetry & control path —
Field
Sensor / control nodespublish + subscribe
External MQTT broker:1883 — user “pop”
backendsubscriber + publisher
⇄   bidirectional · the two paths meet only in the database and in memory

Request path

The host nginx terminates TLS for app.geonutria.ai and splits traffic: the site root proxies to the frontend container, and /api proxies to the backend with the prefix stripped. That prefix-stripping is the single most important deployment fact in the system, and it has one consequence worth internalising:

FastAPI mounts static files at /static, but from the public internet those files live at /api/static/.... Any client resolving a relative media path returned by the API — satellite plots, profile pictures, asset photos, firmware binaries — must prefix it with the /api base, not the bare domain. Both clients do this deliberately: the mobile app's Env.staticBaseUrl is set to the /api base for exactly this reason, and the backend's PUBLIC_BASE_URL defaults to https://app.geonutria.ai/api so OTA firmware URLs are reachable by devices.

Two brokers

The compose file configures two distinct MQTT endpoints, and they serve different purposes:

SettingRole
MQTT_BROKER / MQTT_PORTExternal broker the field devices actually use. This is what iot_manager connects to for both the subscriber and the publisher. Credentials are a username hardcoded as pop plus MQTT_PASSWORD.
NEW_MQTT_BROKER_NEW et al.Points at the bundled mosquitto container. Provisioned in compose with its own credentials, configured by mosquitto.conf with allow_anonymous false and a password file.

The live telemetry and control path runs entirely over the external broker. The local mosquitto service is started and available but is not what iot_manager binds to.

Orientation

Repository layout

app.geonutria.ai/ ├── Backend/ # FastAPI service │ ├── main.py # app, middleware, router registration, lifecycle │ ├── config.py # pydantic-settings, reads .env │ ├── database.py # SQLAlchemy engine + SessionLocal │ ├── schemas.py │ ├── routers/ # 12 routers — see API reference │ ├── services/ │ │ ├── iot_manager.py # MQTT subscriber, publisher, stores, cron │ │ ├── model_manager.py # singleton ML model loader │ │ ├── models_def.py # ResNet9 / CustomCNN definitions │ │ ├── access_control.py # credits + feature permission gate │ │ └── weather_service.py# Open-Meteo client │ ├── migrate_*.py # ad-hoc migration scripts │ ├── run_migrations.py │ └── static/ # satellite plots, firmware, uploads ├── Frontend/app/ # Create React App dashboard │ └── src/{views,components,services,utils} ├── mobile/ # Flutter app (own git repo) │ └── lib/{app,core,features} ├── docker-compose.prod.yml # backend, frontend, mosquitto, ollama ├── docker-compose.yml # local dev, hot reload ├── mosquitto.conf ├── deployment_guide.md └── DatabaseBackup.sql # April 2026 dump

The mobile app is a separate git repository. mobile/ has its own .git and pushes to AmirHarounOfficial/geonutria-flutter. The rest of the monorepo — including all of Backend/ — is not under version control, and is deployed by uploading files to the server. Backend changes therefore have no history, no diff, and no rollback.

Backend

Backend service

Application lifecycle

main.py builds the FastAPI app and wires two startup concerns that dominate the service's resource profile:

  1. Model loading. model_manager.load_all_models() pulls every PyTorch, YOLO, joblib and XGBoost artefact into RAM synchronously. The first request is fast; the first boot is not.
  2. IoT thread. start_iot_thread() restores the latest reading per device from the database into the in-memory store, starts the MQTT subscriber on a daemon thread running loop_forever(), starts the publisher client on loop_start(), and schedules a 30-minute APScheduler job that persists the in-memory snapshot back to the database.

Shutdown calls model_manager.unload_all_models(). This is deliberate — without it PyTorch leaves pt_main_thread processes resident.

Middleware

  • SecurityHeadersMiddleware — sets a Content-Security-Policy on every response, permitting self plus Google Fonts for styles and fonts, and data:/blob: for images and media. Note it allows unsafe-eval and unsafe-inline for scripts, which is what the CRA bundle requires.
  • CORS — an explicit origin allowlist covering localhost development ports and a legacy IP. The production domain is absent, which is correct: the browser reaches the API same-origin through nginx, so no preflight occurs. The mobile app is a native client and is not subject to CORS at all.

Static mounts

MountServes
/staticSatellite plots, uploaded media, profile pictures, firmware binaries. Publicly reachable at /api/static/....
/frontendstatic/new_ai_frontend — a standalone bundle for the experimental AI portal.
/new_ai_portalRoute returning that bundle's index.html.

Services

iot_manager

Owns IoTDataStore (latest sensor values per device), ControlStateStore (last raw payload per control state topic), the subscriber IoTService, and the IoTPublisher. Thread-safe via explicit locks.

model_manager

Singleton (__new__-guarded) holding every model handle. Each load is individually try-wrapped, so a missing weight file degrades one feature rather than preventing boot.

access_control

verify_credits_and_permission() — the single gate for metered features. Resolves permission state, deducts credits, falls back to team pool, raises 402/403.

weather_service

Open-Meteo client. Supplies ambient temperature and humidity when sensors report zero, and logs a six-value macro-weather snapshot per device.

Backend

API reference

All paths are relative to https://app.geonutria.ai/api. Unless noted, the caller identifies itself with a user_id query parameter (GET/DELETE) or body field (POST/PUT) — see Auth & permissions.

auth

MethodPathPurpose
POST/registerCreate account, issue OTP
POST/verify-otpConfirm OTP, mark verified
POST/loginbcrypt password check; returns user_id, credits
GET/meCurrent credits, picture, team credits
POST/google-loginExchange Google access token
POST/google-registerFirst-time Google signup
GET/user-hierarchy/{user_id}Farms, crops and devices tree

iot

MethodPathPurpose
GET/iot-statusLive sensor snapshot, MQTT state, AI diagnosis
GET/iot/refresh/{device_id}Force re-read and re-diagnose (metered)
GET/iot-history/{device_id}Historical readings by interval
GET/weather-charts/{device_id}Macro weather series
GET/devicesDevices visible to the user
GET/devices/{device_id}Single device detail
POST/manual-diagnosisDiagnose hand-entered sensor values
GET/ai-credits/refreshRe-read credit balance

my-devices recent

MethodPathPurpose
GET/my-devicesDevices owned by or shared with the user
GET/my-devices/statesLast reported payload per control state topic
POST/my-devices/bindSelf-register a device and its topics
PUT/my-devices/{id}Edit name, location, topics, controls
DELETE/my-devices/{id}Unbind — owner only
POST/my-devices/{id}/publishPublish a command to a registered topic
POST/my-devices/{id}/firmwareUpload firmware and trigger OTA — Admin only

models — inference

MethodPathPurpose
POST/diagnose-leafLeaf disease classification
POST/diagnose-palm-diseasePalm segmentation then disease classification
POST/segment-general-leafGeneral leaf segmentation
POST/count-palm-treesAerial palm counting
POST/classify-soilSoil type from image
POST/recommend-cropsXGBoost crop recommendation
POST/predict-yieldYield estimate

ai & new_ai — assistants

MethodPathPurpose
GET/ai-consultant/optionsAvailable consultation parameters
POST/ai-consultant/startBegin async consultation, returns task_id
GET/ai-consultant/status/{task_id}Poll consultation result
POST/ai-consultant/local-gemma/startSame, routed to the local Ollama Gemma model
POST/v1/chatDirect chat completion
POST/v1/analyze-imageMultimodal image question
POST/v1/openrouter-chatHosted-model fallback path
GET/v1/healthAssistant availability

assets, profile, support, admin

RouterEndpoints
/assets CRUD for /farms, /crops, /trees; plus POST /upload-media and GET /media.
/profile GET /, PUT /update, POST /password, POST /picture; team management via GET /team, POST /team/add, DELETE /team/remove/{id}.
/support WS /ws/{user_id} live chat, GET /{user_id}/history, admin GET /admin/threads and POST /admin/reply, plus GET /guides/{tab_id}.
/admin Analytics, payment approval queue, user listing and profiles, device CRUD, per-user device access grants, feature toggles and credit adjustment. Every handler re-checks role = 'Admin' inline.
root POST /satellite-analysis, POST /generate-report (PDF).
Security model

Authentication & permissions

How a session works today

POST /login looks the user up by email, verifies the password against a bcrypt hash, refuses unverified accounts, and returns a payload containing user_id, credit balances, and a token field. That token is the literal string user_token_ followed by the user's id.

Critical The token is decorative. There is no session verification anywhere in the API. No JWT is issued, signed, or validated — the only Bearer headers in the codebase are outbound calls to Google and OpenRouter. Every endpoint establishes identity by reading a caller-supplied user_id parameter and trusting it.

The practical consequence: anyone who can reach the API can act as any user by changing a number — read their devices, spend their credits, publish to their hardware. Admin-gated routes are no exception, since they check the role of whatever admin_id the caller passes. Ownership checks like _assert_access in the My Devices router are correctly written, but they authorise a claimed identity rather than a proven one.

Fixing this means issuing a signed token at login and deriving user_id from it server-side via a FastAPI dependency, rather than accepting it as a parameter. It is a cross-cutting change touching every router.

Roles

users.role is an enum with three values:

RoleMeaning
AdminBypasses the credit and permission gate entirely. Required for the admin router and for firmware OTA.
Specific AccessStandard metering, intended for accounts with a curated feature set.
Normal UserDefault. Fully metered.

Credits and feature metering

Metered features call verify_credits_and_permission(user_id, feature_name, db, cost) before doing any work. The resolution order is:

  1. If the user is Admin, return immediately — no charge, no permission check.
  2. Join system_features to user_permissions to find this user's access_state and effective cost (custom_credit_cost, else default_credit_cost). With no row, default to credit_based at the caller's suggested cost.
  3. blocked raises 403. unlimited passes free. credit_based continues.
  4. If personal ai_credits cover the cost, deduct and commit.
  5. Otherwise compute the deficit and try to draw it from a team's shared_credits (selected FOR UPDATE), zeroing the personal balance. If no team can cover it, raise 402.

Both clients treat 402 as a paywall signal rather than a generic error. The mobile app's ApiClient installs a Dio interceptor that converts a 402 into an InsufficientCreditsException and fires a PaywallNotifier; the shell listens and raises the top-up popup.

Device access

Device visibility is the union of ownership (devices.user_id) and explicit grants in the user_device_access join table, which an admin manages. The My Devices router enforces this in _assert_access, with an owner_only flag for destructive operations like unbinding.

Data

Database

MySQL 8, schema geonutria_db, running on the host rather than in a container. The backend reaches it through the Docker alias host.docker.internal:3306.

Core tables

TableHoldsNotable columns
usersAccountsrole, ai_credits, is_verified, otp_code, feature_access JSON, permissions JSON, google_sub
farmsLand parcelsuser_id, address, total_area
cropsPlantings per farmfarm_id, crop_category, planted_area
devicesField hardwaremqtt_topics JSON, latitude/longitude, and from the My Devices migration control_topics JSON, ota_topic, firmware_version
iot_readingsPersisted telemetrydevice_id, soil_moisture, soil_temp, soil_ph, nitrogen_n, phosphorus_p, potassium_k, salinity, ec, ambient_temp, ambient_humidity, tds, epsilon
user_device_accessShared device grantsuser_id, device_id
system_featuresMeterable feature cataloguefeature_name, default_credit_cost
user_permissionsPer-user overridesaccess_state (blocked/credit_based/unlimited), custom_credit_cost
billingTop-up receiptsamount, transfer_screenshot_path, status
support_conversations / support_messagesSupport threadsstatus, sender_type
activity_logsFeature usage auditfeature_used, inputs_json, outputs_json

Schema drift

Attention There is no single source of truth for the schema. Migrations are a set of hand-rolled, individually-run scripts, and the checked-in dump predates several of them. Specifically:

  • iot_readings in the dump still has generic sensor_1 … sensor_29 columns. execute_sql.py renames the first ten to their semantic names. The tds and epsilon columns the code writes are not covered by any script in the repo.
  • user_teams (team credit pooling) and device_weather_readings (macro weather log) are read and written by the code but created by nothing in the repository. They exist only on the live database.
  • run_migrations.py adds an ALTER TABLE devices ADD COLUMN mqtt_topic — singular — which migrate_wave2.py later supersedes with the JSON mqtt_topics.

Rebuilding this database from the repository alone is not currently possible. Consolidating the scripts into an ordered, idempotent migration set — or adopting Alembic — would remove a standing recovery risk.

The dump also contains a set of dim_* tables (dim_glaccount, dim_item, dim_site, dim_storeroom, dim_transactiontype, dim_vendor) forming an inventory star schema that no application code references — apparently legacy or from a different project sharing the database.

Migration scripts

ScriptDoes
run_migrations.pyOTP columns, permissions JSON, device coordinates, user_device_access, support tables, billing status
execute_sql.pyRenames sensor_N to semantic names; creates aerial_palms_count
migrate_wave2.pyDevice latitude/longitude as DOUBLE, mqtt_topics JSON
migrate_my_devices.pycontrol_topics JSON, ota_topic, firmware_version
seed_db.py, create_admin.py, insert_device.py, create_ai_tasks.pyFixture and bootstrap helpers
IoT

IoT & MQTT layer

The backend keeps a live picture of every device in memory and only writes to disk twice an hour. Read paths hit RAM; the database is the durable log, not the source of truth for "now".

Inbound telemetry

Each device row carries mqtt_topics, a JSON array of topic roots. On connect the subscriber reads every device's topics from the database and subscribes to {topic}/#, keeping a topic_to_device map. subscribe_topic() lets a newly bound or edited device start being monitored without restarting the service.

Incoming messages are keyed by the last path segment of the topic, mapped to canonical field names:

Topic suffixStored as
Moisture, VWCSoil_Moisture
TemperatureSoil_Temperature
PH, PH_PHSoil_pH
Nitrogen / Phosphorus / PotassiumNitrogen_Level / Phosphorus_Level / Potassium_Level
ECElectrochemical_Signal
Salinity, TDS, EpsilonSalinity, TDS, Epsilon
LF_Temperature, LF_MoistureAmbient_Temperature, Humidity

Anything whose suffix is not in that map is discarded. Payloads are parsed as floats, so a non-numeric message on a sensor topic is logged and dropped.

Persistence and weather fallback

Every 30 minutes the scheduler snapshots the in-memory store and, per device, calls _apply_weather_fallback_and_log: if the device has coordinates it fetches Open-Meteo, always logs a six-value weather row, and substitutes API temperature and humidity wherever the sensor reported exactly zero. The adjusted values are then inserted into iot_readings.

IoTDataStore.get_data() returns None — not a zero-filled dictionary — for an unknown device. This is load-bearing: a zero payload would be read by the diagnosis model as a critically unhealthy plant, so callers must check for None and skip inference.

Outbound control

Control endpoints live in devices.control_topics as a JSON array. Each entry describes one control:

{
  "label":        "Irrigation pump",
  "topic":        "farm101/pump/set",     // device subscribes here
  "state_topic":  "farm101/pump/state",   // device reports here (optional)
  "type":         "switch",               // or "value"
  "on_payload":   "1",
  "off_payload":  "0",
  "min": 0, "max": 100, "unit": "%"
}

POST /my-devices/{id}/publish validates the requested topic against that device's registered control topics plus its OTA topic, and refuses anything else with a 400. Publishing itself goes through the persistent IoTPublisher at QoS 1, so broker credentials never leave the server.

State feedback

A control may declare a state_topic. The backend subscribes to it exactly — no wildcard — and stores raw payloads verbatim in ControlStateStore. This check runs at the very top of the message handler, before the numeric sensor parsing, because feedback payloads like ON are not floats.

Control round trip
  1. User → AppToggles the switch.
  2. AppShows the new position immediately as an optimistic guess.
  3. App → BackendPOST /my-devices/{id}/publish — topic validated against the device's registered controls.
  4. Backend → BrokerPublishes on_payload at QoS 1 via the persistent publisher.
  5. Broker → DeviceCommand delivered on the command topic.
  6. DeviceActuates, then publishes its real state — ideally retained.
  7. Broker → BackendState message stored verbatim in ControlStateStore, before any numeric parsing.
  8. App → BackendGET /my-devices/states — polled every 5s, and 800ms after a publish.
  9. AppAdopts the reported state and drops the optimistic guess.

The app polls /my-devices/states every 5 seconds, but only while at least one control declares a state topic, and re-checks 800 ms after any publish. The switch shows the device's reported position; the user's toggle is an optimistic guess displayed only until the device confirms, and is dropped immediately if the publish fails.

If firmware publishes state with the retain flag, the broker delivers it the moment the backend subscribes, so the correct position appears immediately after a restart. Without retain, the control reads awaiting device until the next publication.

OTA firmware

Admins upload a binary to POST /my-devices/{id}/firmware. It is stored under static/firmware/{device_id}/ with a UUID filename, the version and OTA topic are persisted, and a command is published to the device's OTA topic:

{"url": "https://app.geonutria.ai/api/static/firmware/12/<uuid>.bin", "version": "1.4.2"}

The device is expected to subscribe to that topic, download the URL, and flash. The absolute URL is built from PUBLIC_BASE_URL, which must include the /api prefix or devices will receive an unreachable link.

Operational The backend publishes as MQTT user pop, a username hardcoded in iot_manager. Historically that account only ever subscribed. If the broker ACL does not also grant it publish rights on the control and OTA topics, every control action returns 502 MQTT publish failed.

Backend

Machine-learning models

All inference is local. ModelManager is a singleton loaded once at startup; nothing is fetched at request time.

ModelArtefactUsed by
Plant health (tabular)plant_health_model.pkl — joblibIoT diagnosis, manual diagnosis
Leaf disease (vision)plant-disease-model-complete.pth — ResNet9/diagnose-leaf
Soil classifiercustom_cnn_model.pth + soil_classes.pkl — CustomCNN/classify-soil
Crop recommendationcrop_recommendation_xgb_model.pkl + scaler.pkl + label_encoder.pkl/recommend-crops
Palm leaf segmentationYolo-palm-segm-leaves.pt/diagnose-palm-disease
Palm disease classificationYolo-palm-class-disease.pt/diagnose-palm-disease
General leaf segmentationgeneral-segm-leaves.pt/segment-general-leaf
Aerial palm countingyolov8m_palm_aerial.pt/count-palm-trees

Model definitions for the PyTorch networks (ResNet9, CustomCNN, ImageClassificationBase) live in services/models_def.py — required because the checkpoints are loaded with weights_only=False and need the class definitions present.

Language models

The agronomy assistant runs against a local Ollama container serving a quantised Gemma model. A hosted fallback path exists through OpenRouter/OpenAI, with config.fallback_api_keys assembling an ordered, de-duplicated key list from AI_API_KEYS, OPENROUTER_API_KEY and OPENAI_API_KEY so exhausted keys can rotate.

Web

Web dashboard

Create React App, built to a static bundle at image build time with REACT_APP_API_URL=/api and served by nginx inside the frontend container.

Views

ViewCovers
LoginViewPassword and Google sign-in, OTP verification
IoTViewDevice selector, live sensors, history, weather, manual entry
LeafDoctorViewLeaf and palm disease diagnosis
SatelliteViewField imagery analysis
CropRecommendViewCrop recommendation
YieldPredictionViewYield estimation
AIConsultantViewConversational agronomy assistant
NewAIViewExperimental multimodal assistant
ProfileViewAccount, assets, team, billing
AdminDashboardViewAnalytics, users, devices, payments, feature toggles

Shared UI lives in components/uiGaugeChart, IoTMap, SensorCard, SatelliteImage, MapLocationPicker, MarkdownText, Toast. Cross-cutting widgets include OnboardingWizard, PaywallPopup, PaymentCheckout, SupportChatWidget, ExportReportModal and HelpGuideModal. All HTTP goes through services/apiService.js; services/translationService.js handles localisation.

Mobile

Mobile app

Flutter, Dart SDK ^3.11.5, 85 source files. Full farmer-facing parity with the dashboard excluding the admin panel — the one admin endpoint it uses is payment receipt upload for billing.

Stack

ConcernPackage
Stateflutter_bloc 9.1.1 with equatable
Navigationgo_router 14.6.2
HTTPdio 5.7.0
Credentialsflutter_secure_storage 9.2.4
Mapsflutter_map 7.0.2 + latlong2 — OSM and ArcGIS tiles, no API key
Chartsfl_chart 0.69.2
Realtimeweb_socket_channel 3.0.1 for support chat
Media / filesimage_picker, file_picker 11, cached_network_image, share_plus
Authgoogle_sign_in 6.2.2

Structure

lib/ ├── main.dart # providers, post-first-paint bootstrap ├── app/app_router.dart # / · /splash · /login · /home ├── core/ │ ├── config/ # Env, AppColors, AppTheme │ ├── network/ # ApiClient (Dio), PaywallNotifier │ ├── storage/ # SecureSession — token, user id, role │ ├── localization/ # AppStrings — English + Arabic, RTL │ ├── settings/ # SettingsCubit — theme, locale │ └── widgets/ # AppMap, GlassCard, status views… └── features/ ├── shell/ # HomeShell — bottom nav + drawer ├── auth/ dashboard/ devices/ consultant/ advanced_ai/ ├── leaf_doctor/ crop_advisor/ yield_predict/ satellite/ └── profile/ billing/ report/ support/ ai_models/

Each feature follows the same three-layer split: data/ for models and repositories, bloc/ for Cubits, ui/ for screens and widgets.

Configuration

Env exposes compile-time constants overridable with --dart-define: API_BASE_URL, STATIC_BASE_URL, WS_BASE_URL and GOOGLE_SERVER_CLIENT_ID. All default to the production /api origin. Env.resolveMedia() turns the relative paths the API returns into absolute URLs.

Startup

main.dart renders immediately and runs bootstrap after first paint, with a timeout on each step. This is deliberate: a hanging secure-storage read on some devices would otherwise freeze the launch screen indefinitely. Auth always resolves to a terminal state — login or home.

Navigation

The shell exposes five primary destinations in the bottom bar — Dashboard, My Devices, Leaf Doctor, Satellite, Smart Assistant — with the full feature list in the drawer. Screens are held in an IndexedStack, so state survives tab switches.

My Devices

The device feature is the app's only write path to hardware.

  • Bind — register a device with a name, map location, monitored MQTT topics and an optional OTA topic.
  • Controls — define switch or value endpoints; each publishes its configured payload and reflects reported state.
  • Quick controls — every configured control across all bound devices also renders at the top of the dashboard's Live tab, so acting on hardware never requires navigating into the device screen.
  • Firmware — visible only to admins; everyone else sees a read-only version card.

DevicesCubit is provided at the shell rather than inside the devices screen, so the dashboard and My Devices share one device list and one polling timer. DeviceControlTile is the single shared switch/slider widget used by both surfaces.

Infrastructure

Deployment

The server runs aaPanel with Docker. The project lives at /www/wwwroot/app.geonutria.ai. Host nginx reverse-proxies the domain to the frontend container and /api to the backend.

Compose services (production)

ServiceImage / buildPortsNotes
backend./Backend, target production8009:8009Reads Backend/.env; DB via host.docker.internal
frontend./Frontend/app, target production3000:80Built with REACT_APP_API_URL=/api
mosquittoeclipse-mosquitto:latest1883:1883Mounts mosquitto.conf and passwd
ollamaollama/ollama:latest11434:11434Named volume ollama_data

All four restart always; the backend declares depends_on for ollama and mosquitto. Every service uses extra_hosts: host.docker.internal:host-gateway so containers can reach host MySQL.

Attention Two image tags are :latest (mosquitto, ollama), so a rebuild can silently pull a new major version. Pinning them would make deployments reproducible.

Deploying a backend change

Because the monorepo is not version-controlled, backend deployment is a file upload followed by a rebuild. Preserve the directory structure — routers/ files must land in Backend/routers/.

cd /www/wwwroot/app.geonutria.ai
docker compose -f docker-compose.prod.yml up -d --build --no-deps backend

Then run any pending migration inside the container:

docker compose -f docker-compose.prod.yml exec backend python migrate_my_devices.py

Building the mobile app

cd mobile
flutter build apk --debug

Output lands at build/app/outputs/flutter-apk/app-debug.apk. On the current Windows build machine the Android Studio JBR must be selected as JAVA_HOME, and the Dart analyzer needs a raised heap (DART_VM_OPTIONS=--old_gen_heap_size=1024).

Security

Security findings

These are observations from reading the code, ordered by severity. None are theoretical — each is verifiable in the current source.

Critical Unauthenticated identity

Covered in full under Auth & permissions. The login token is a formatted string, never validated; identity is a caller-supplied parameter on every endpoint. Any client can act as any user, including administrators, by supplying a different id. This undermines every other access control in the system.

Critical Secrets committed in the working tree

docker-compose.prod.yml contains the production MySQL password and the MQTT broker password inline as environment values, rather than being read from an untracked .env. They are visible to anyone with repository or filesystem access, and are shipped in every copy of the project.

Additionally, DatabaseBackup.sql sits at the repository root and contains real user records — names, email addresses, phone numbers, and bcrypt password hashes. It should be removed from the tree and stored as an access-controlled backup artefact; the credentials in it should be treated as exposed and rotated.

Moderate Broad script CSP

The security-headers middleware allows unsafe-eval and unsafe-inline for scripts. This is what the CRA bundle needs to run, but it removes most of the XSS mitigation a CSP is there to provide.

Moderate Error responses leak internals

The login handler catches unexpected exceptions and returns traceback.format_exc() in the 500 response body, exposing file paths and library versions to the caller.

Sound What is done well

  • Passwords are bcrypt-hashed, and login does not distinguish "no such user" from "wrong password".
  • MQTT publishing is server-side only — broker credentials never reach a client — and /publish is restricted to topics the device has actually registered, so it cannot be used as an open relay.
  • Firmware OTA is enforced admin-only in the backend, not merely hidden in the app.
  • Every query uses SQLAlchemy bound parameters; there is no string-interpolated SQL on user input.
  • The broker runs with allow_anonymous false and a password file.
Operations

Runbook

Connect and inspect

ssh root@<server-ip>
cd /www/wwwroot/app.geonutria.ai
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml logs -f --tail=100 backend

Verify the MQTT layer

On boot the backend logs a connection banner and one line per subscribed topic. Look for MQTT Connected, MQTT Publisher connected, and Subscribed to Device N topic: …. A rejection prints the numeric reason code. Unmatched inbound topics log a dropped-message warning, which is the fastest way to spot a device publishing somewhere unexpected.

Common failures

SymptomLikely cause
502 MQTT publish failed on any controlBroker ACL does not grant the pop account publish rights
Control shows awaiting device foreverNo state_topic configured, device not publishing, or publishing without retain after a backend restart
Device selector empty but hardware is livemqtt_topics not set on the device row, so nothing is subscribed
Sensor stuck at zeroPayload not numeric, or topic suffix outside the sensor name map
Firmware link unreachable from the devicePUBLIC_BASE_URL missing the /api prefix
Images 404 in a clientRelative /static path resolved against the bare domain instead of the /api base
Backend slow to accept traffic after deployExpected — all ML weights load synchronously at startup

Recommended next steps

  1. Replace parameter-based identity with a signed token resolved by a FastAPI dependency.
  2. Move the compose secrets into an untracked env file and rotate the exposed credentials; remove the database dump from the tree.
  3. Put the monorepo under version control so backend changes are reviewable and reversible.
  4. Consolidate the migration scripts into an ordered, idempotent set that can rebuild the schema from scratch.
  5. Pin the :latest image tags.