A standalone Elixir/OTP application that simulates a configurable fleet of drones emitting realistic telemetry over MQTT. Completely independent from any consuming application. JSON over MQTT is the only contract between the two sides. This is a learning project designed to allow OTP experimentation at configurable scale.
Each drone is a supervised GenServer that ticks once per second, advancing its
own physics (position, altitude, heading, battery) and publishing telemetry. Drones
move through a flight-phase lifecycle — idle → taking_off → cruising → returning → landing → idle — and publish a status message on every transition.
- Elixir ~> 1.16 and Erlang/OTP
- Docker (for the MQTT broker)
Start the broker, then fetch dependencies:
docker compose up -d # EMQX on 1883 (MQTT) and 18083 (dashboard)
mix deps.getRun the simulator:
mix sim.start --drones 25This starts the application, spawns 25 drones, and blocks until Ctrl+C. Omit
--drones to get a single drone.
To watch what the fleet is publishing:
mosquitto_sub -h localhost -t 'drones/#' -t 'simulator/stats' -vThe EMQX dashboard is at http://localhost:18083 (default login admin /
public).
The fleet can be resized, ramped, paused, and inspected while it runs — either
over HTTP or from IEx. Both drive the same DroneSim.SimulatorController.
An HTTP listener starts with the application on port 4001, including under
mix sim.start.
| Method | Path | Body | Action |
|---|---|---|---|
GET |
/drones/status |
— | Current fleet state |
POST |
/drones/count |
{"count": n} |
Set absolute fleet size |
POST |
/drones/ramp |
{"target": n, "seconds": s} |
Ramp gradually to n |
POST |
/drones/pause |
— | Freeze the fleet |
POST |
/drones/resume |
— | Unfreeze the fleet |
POST |
/chaos |
— | Returns 501 — arrives in Phase 4 |
curl -s localhost:4001/drones/status | jq
# {"active":3,"target":3,"paused":false,"uptime_sec":42}
curl -s -X POST localhost:4001/drones/count \
-H 'content-type: application/json' -d '{"count": 50}'
# {"ok":true}
curl -s -X POST localhost:4001/drones/ramp \
-H 'content-type: application/json' -d '{"target": 200, "seconds": 120}'
# {"ok":true}
curl -s -X POST localhost:4001/drones/pause
curl -s -X POST localhost:4001/drones/resumeGET returns the data map; successful POSTs return {"ok": true}. Errors are
JSON too: 400 {"error": ...} for malformed JSON, 415 for a missing or
unsupported content-type, 404 for an unknown route.
iex -S mix boots the application with zero drones and gives direct access to
the same control plane:
# Set an absolute fleet size — starts or stops drones to match.
DroneSim.SimulatorController.set_count(50)
# Ramp gradually instead of all at once (default: 60 seconds).
DroneSim.SimulatorController.ramp_to(200, seconds: 120)
# Freeze and unfreeze the whole fleet. Paused drones stop publishing but stay alive.
DroneSim.SimulatorController.pause_all()
DroneSim.SimulatorController.resume_all()
# Current fleet state.
DroneSim.SimulatorController.status()
#=> %{active: 50, target: 50, paused: false, uptime_sec: 143}Both ramp_to/2 and set_count/1 cancel any ramp already in flight, so calling
them repeatedly — or interrupting a long ramp with an immediate resize — is safe.
Individual drones can be managed directly through DroneSim.DroneSupervisor:
DroneSim.DroneSupervisor.start_drone()
DroneSim.DroneSupervisor.stop_drone("drone_0007")
DroneSim.DroneSupervisor.drone_ids()
DroneSim.DroneSupervisor.drone_count()Drones are supervised :one_for_one, so a crashed drone is restarted
automatically with a fresh starting position.
Note that DroneSupervisor bypasses the controller, so drones started this way
are not reflected in target. Prefer set_count/1 unless you specifically want
that.
| Topic | Published | Payload |
|---|---|---|
drones/{drone_id}/telemetry |
every tick (1/sec per drone) | DroneSim.Schema.Telemetry |
drones/{drone_id}/status |
on flight-phase transition | DroneSim.Schema.Status |
simulator/stats |
every 5 seconds (once, fleet-wide) | DroneSim.Schema.Stats |
Everything publishes at QoS 0. Note that simulator/stats is deliberately not
under $SYS/ — EMQX reserves that prefix for broker-generated topics and denies
client subscriptions to it, while a QoS 0 publish there still returns :ok. Using
$SYS/ would look correct from inside the app while the messages went nowhere.
Sample payloads, captured from the broker:
// drones/drone_0001/telemetry
{"timestamp":"2026-07-21T08:42:18.257192Z","drone_id":"drone_0001","lat":33.77032465574413,
"lon":-118.04569949484569,"altitude_m":100.2,"heading_deg":-1.0376056550834392,"speed_ms":12.0,
"vertical_speed_ms":0.2,"battery_pct":99.995,"flight_phase":"idle","sequence":0,"signal_dbm":-66}
// drones/drone_0001/status
{"reason":"Automatic transition","timestamp":"2026-07-21T08:42:21.266180Z",
"drone_id":"drone_0001","previous_phase":"idle","current_phase":"taking_off"}
// simulator/stats
{"timestamp":"2026-07-21T08:42:22.248236Z","paused":false,"active_drones":2,
"target_drones":2,"uptime_sec":5,"messages_per_sec":null}messages_per_sec is currently always null. It needs a fleet-wide tick counter
that arrives with the :telemetry instrumentation in Phase 5; the field ships in
the payload now so adding it later is not a schema change.
Broker connection is read from the environment at runtime (config/runtime.exs):
| Variable | Default |
|---|---|
MQTT_HOST |
localhost |
MQTT_PORT |
1883 |
HTTP_PORT |
4001 |
MQTT_HOST=broker.internal MQTT_PORT=1883 mix sim.start --drones 100The fleet's operating area is configured in the same file. It defaults to greater Los Angeles — roughly Santa Monica/Burbank in the north-west to Anaheim in the south-east, about 83 x 66 km:
config :drone_sim, :fleet,
lat_min: 33.7,
lat_max: 34.3,
lon_min: -118.5,
lon_max: -117.6,
default_altitude_m: 100.0,
default_speed_ms: 12.0Drones spawn at random positions inside this box and reflect off its edges, so the fleet stays within the operating area rather than diffusing away over time. The box is small enough that drones cross sector boundaries in minutes rather than hours.
lib/drone_sim/
application.ex supervision tree
simulator_controller.ex fleet control plane (count, ramp, pause, stats)
drone_supervisor.ex DynamicSupervisor + registry lookups
drone_worker.ex one GenServer per drone: physics, phases, publishing
mqtt_publisher.ex Tortoise311 connection wrapper
schema/ JSON payload structs and topic names
lib/http/
endpoint.ex Plug.Cowboy listener, supervised
router.ex Plug.Router — the control API above
lib/mix/tasks/sim/
start.ex `mix sim.start`
Drones register in DroneSim.DroneRegistry under their drone_id via a
via-tuple, which is what makes lookup by ID and fleet-wide broadcast possible.
The registry is started before the DynamicSupervisor in the supervision tree
because workers register during init/1 — that ordering is load-bearing.
mix test