Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

DroneSim

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.

Requirements

  • Elixir ~> 1.16 and Erlang/OTP
  • Docker (for the MQTT broker)

Getting started

Start the broker, then fetch dependencies:

docker compose up -d      # EMQX on 1883 (MQTT) and 18083 (dashboard)
mix deps.get

Run the simulator:

mix sim.start --drones 25

This 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' -v

The EMQX dashboard is at http://localhost:18083 (default login admin / public).

Controlling a running fleet

The fleet can be resized, ramped, paused, and inspected while it runs — either over HTTP or from IEx. Both drive the same DroneSim.SimulatorController.

HTTP API

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/resume

GET 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

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.

MQTT topics

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.

Configuration

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 100

The 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.0

Drones 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.

Project layout

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.

Tests

mix test

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages