Architecture¶
How a reading gets from a hive in a field to a phone.
flowchart TD
SENSOR["Sensor node<br/><small>ESP32 + sensors, solar powered</small>"]
GATEWAY["Gateway node<br/><small>the node with internet access</small>"]
subgraph SERVER["meshbee-server"]
direction TB
MOSQ["mosquitto<br/><small>MQTT broker</small>"]
HANDLER["mqtt_handler<br/><small>decodes and validates readings</small>"]
API["api<br/><small>FastAPI REST, port 8000</small>"]
CADDY["caddy<br/><small>HTTPS for local development</small>"]
CORE["meshbee_core<br/><small>shared library: schemas, services, all the SQL</small>"]
DB[("database<br/><small>PostgreSQL</small>")]
end
APP["Mobile app<br/><small>Expo / React Native</small>"]
SENSOR -- "Meshtastic LoRa mesh" --> GATEWAY
GATEWAY -- "MQTT over the internet" --> MOSQ
MOSQ -- "subscribe beehive/+/data" --> HANDLER
APP -- "HTTPS" --> CADDY
CADDY --> API
APP -- "HTTP" --> API
HANDLER --> CORE
API --> CORE
CORE --> DB
Hive nodes talk to each other over a Meshtastic LoRa mesh — no WiFi needed in the apiary. A reading hops across the mesh until it reaches a node with internet access, which acts as the gateway and forwards it to the MQTT broker over an ordinary internet connection.
caddy terminates HTTPS in front of the API on :8443. It is there for local development and is
optional — in a plain docker-compose up the app talks to api directly on :8000.
Why it's built this way¶
The rationale that drives most of the design: the node fleet is heterogeneous.
A node is a board bolted to a solar panel in a field. There is no reliable way to push an update to it. Some nodes will run last year's firmware for as long as they physically survive. There is no flag day, no coordinated upgrade, no moment where every node runs the same code.
So the system cannot assume anything about what's on the other end of a message. It has to be told, by the message itself. That gives us three intended consequences:
- The MQTT payload carries a version integer (
v), so the server can decide compatibility per message, not per deployment. An old node and a new node could publish to the same broker at the same time and both get handled correctly. - The HTTP API is path-versioned (
/v1/). App releases in the wild have the same problem as nodes: users update when they feel like it. An old app keeps hitting the endpoint it was built against. - A compatibility matrix records which firmware / server / app versions are known to work together, because "it builds" and "it interoperates with what's already deployed" are different questions.
Design intent, not current behaviour
The first two points describe where the interface is going, not where it is. Today the payload
carries no version integer — the handler reads a fixed set of fields — and the API is served
under /api/… with no version segment in the path. The compatibility matrix is real and
maintained. See the contract for what actually ships.
Where things live¶
Only the two ends of the chain live outside meshbee-server: the nodes are
meshbee-firmware on
meshbee-hardware, and the phone is
meshbee-app. Everything in the box above is one
repository. Each piece gets a short page under Components.
Two structural rules explain the shape of the server:
- Two processes, one library.
apiandmqtt_handlerare separate containers with separate lifecycles — restarting the broker doesn't touch the REST API, and the API is not an MQTT client. But they write to the same database, so they must agree on what a valid reading is and how to store one. That agreement ismeshbee_core: imported by both, never deployed on its own. - Three layers, one direction. SQL lives in
meshbee_core/repository/; decisions live inmeshbee_core/services/, which know nothing about HTTP; the entry points validate input, call one service, and translate the outcome into a status code or a log line.
The contract is the interface between the boxes above — it's the part that must not change casually.