DEV Community

Cover image for I built a drone Ground Control Station in Electron, using LiDAR for autonomous navigation
Lluis Estape
Lluis Estape

Posted on

I built a drone Ground Control Station in Electron, using LiDAR for autonomous navigation

A build log on the ground station for an autonomous warehouse inventory drone, ROS2 on the aircraft, Electron on the laptop, and nothing in between but a WebSocket.

The problem

The drone is a hexacopter that flies through a warehouse on its own, builds a 3D map as it goes, reads the barcodes off the shelves, and hands you a geolocated inventory. It runs ArduCopter on a Pixhawk for flight and ROS2 Jazzy on a Raspberry Pi 5 for everything else: a Unitree 4D LiDAR feeding Point-LIO SLAM, two Pi cameras, and barcode detection.

That is the aircraft. The other half of the problem is the laptop.

Whoever is standing in the warehouse holding that laptop needs to see the telemetry, arm the drone, watch the map build, and read the barcode log. And critically: they should not need ROS installed to do it. A ground station that requires a full ROS2 desktop install on every machine that wants to look at the drone is a ground station that only I can run.

So the constraint I set was: the GCS is an ordinary desktop app. Download an installer, double-click, connect. No ROS on the client, ever.

The GCS dashboard: arm/disarm and flight-mode commands, live camera, hexacopter thrust ring, and the nav map with the flown path in blue against the planned waypoints

The architecture, and the one decision that shapes it

┌─ Raspberry Pi 5 (ROS2 Jazzy) ──────────┐      ┌─ laptop (no ROS) ──────┐
│  MAVROS ──► /mavros/*                  │      │                        │
│  Point-LIO ──► /scan, /map, pose       │ WS   │  Electron              │
│  camera_publisher.py ──► CompressedImage├─────►│   └ renderer           │
│  barcode_detector.py ──► /barcode/*    │ 9090 │      roslib.js         │
│  brain_node.py ──► /brain/planned_path │      │      canvas 2D         │
│      └─► /mavros/setpoint_position/local      └────────────────────────┘
│  gcs_control.py ──► /gcs/cmd, /gcs/status
│  rosbridge_server ─────────────────────┘
└────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Or, as the team drew it, the ROS2 node graph on the aircraft side, with the LiDAR feeding SLAM, SLAM feeding both the brain and MAVROS, and MAVROS driving the Pixhawk:

ROS2 node graph: LiDAR Publisher → SLAM → MAVROS → PixHawk, with the Brain Node between SLAM and MAVROS, and an Image/Position Publisher going out to an external server

rosbridge is the whole trick. It exposes every ROS2 topic as JSON over a WebSocket on port 9090. Once that is running, a browser is a first-class ROS client, roslibjs subscribes to /mavros/battery exactly the way a Python node would, and the message arrives as a plain JavaScript object.

Which means the renderer needs no Node APIs at all. The Electron window runs with:

webPreferences: {
  preload: path.join(__dirname, 'preload.js'),
  contextIsolation: true,
  nodeIntegration: false,
}
Enter fullscreen mode Exit fullscreen mode

and the preload file is empty. Nothing is bridged from main to renderer, because nothing needs to be. My preload.js is three lines of comment explaining why it is otherwise blank.

One packaging gotcha worth writing down: roslib is a dependency in package.json, but the renderer loads a bundled copy from renderer/lib/roslib.js, not from node_modules. After electron-builder packages the app, node_modules is inside an asar archive that a plain <script src> in the renderer cannot reach. Copying the library into the renderer folder is unglamorous and it is what makes the built installer work.

No framework, no bundler

The whole UI is one HTML file with inline CSS, one 1,700-line app.js, and the Canvas 2D API. No React, no build step, no transpile. npm start runs the actual shipping code.

I want to defend that, because it is not laziness. Almost every widget here is a drawing: an artificial horizon, a gauge needle, a thrust ring, a point cloud, an occupancy grid. React's value is reconciling a DOM tree against state, but there is no DOM tree to reconcile when the answer is "repaint this canvas at 60 fps from the latest telemetry." A framework would sit between the ROS callback and ctx.fillRect and do nothing but add a frame of latency and a build directory.

So the pattern is deliberately dumb:

const S = { }                       // one shared state object

sub('/mavros/battery', 'sensor_msgs/BatteryState', m => {
  S.batPct = m.percentage * 100     // callbacks only ever write to S
  S.batV   = m.voltage
})

function frame () {                 // rAF loop only ever reads from S
  drawHorizon(); drawGauges(); drawNavMap()
  requestAnimationFrame(frame)
}
Enter fullscreen mode Exit fullscreen mode

Sixteen subscriptions write into S. One requestAnimationFrame loop reads it. Nothing else in the app coordinates anything. Rendering is decoupled from message arrival for free, a topic publishing at 200 Hz and one publishing at 1 Hz both just leave their latest value in S, and the frame loop picks up whatever is there.

Telemetry: 16 topics, one table

The MAVROS side is where a GCS earns its name. The full subscription set:

Topic Type Feeds
/mavros/vfr_hud mavros_msgs/VFR_HUD speed, climb rate, heading
/mavros/altitude mavros_msgs/Altitude altitude bar
/mavros/battery sensor_msgs/BatteryState battery % and voltage
/mavros/imu/data sensor_msgs/Imu attitude → artificial horizon
/mavros/local_position/velocity_body geometry_msgs/TwistStamped body-frame velocity
/mavros/global_position/global sensor_msgs/NavSatFix GPS position + trail
/mavros/gpsstatus mavros_msgs/GPSRAW fix type, satellite count
/mavros/state mavros_msgs/State armed state, flight mode
/mavros/rc/out mavros_msgs/RCOut motor PWM, channels 1–6
/scan, /map, /slam_toolbox/pose LiDAR + SLAM the SLAM tab
/barcode/detection, /detection/volume std_msgs/String inventory log
/camera/{forward,down}/image_raw/compressed CompressedImage the video panels

The IMU one is the only piece of real maths on the client. MAVROS publishes attitude as a quaternion; the horizon needs Euler angles:

const yaw = Math.atan2(2*(q.w*q.z + q.x*q.y), 1 - 2*(q.y*q.y + q.z*q.z))
Enter fullscreen mode Exit fullscreen mode

The navigation tab: artificial horizon, compass, and the full flight-data readout with roll/pitch/yaw, body velocities and GPS fix state

And the motor ring is a small lesson in reading someone else's convention. /mavros/rc/out gives you PWM in microseconds, 1000–2000, per channel. Turning that into a percentage is trivial; the part that took reading the docs is that motor numbering is not spatial. M1 through M6 on a hexacopter sit at specific angular positions with specific rotation directions, and if you lay them out in the order the array arrives, you get a diagram that looks plausible and is wrong. The positions in drawHexDiagram() match the PX4/QGroundControl actuator layout, so a pilot who has used QGC reads it correctly on the first glance.

Two ways to get video, because one was not enough

Camera streaming is where I ended up with a genuinely dual path, and both paths earn their keep.

Path one, MJPEG through a plain <img>. A Flask server on the Pi serves multipart/x-mixed-replace on port 8080 and the dashboard points an <img> at it. Chromium's own decoder does all the work; the JS cost is zero. On connect the app derives the URL from the rosbridge host automatically:

connectMJPEG(`http://${host}:8080/cam1`)   // falls back to a test pattern, retries every 3 s
Enter fullscreen mode Exit fullscreen mode

When the stream is not up, the canvas underneath draws SMPTE colour bars and "NO SIGNAL". It is a small thing that makes the app feel finished rather than broken.

Path two, sensor_msgs/CompressedImage over rosbridge. rosbridge base64-encodes the JPEG bytes into the JSON message. Decoding that in the renderer:

const raw = atob(m.data)
const buf = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i)
createImageBitmap(new Blob([buf], { type: 'image/jpeg' })).then(bitmap => {
  ctx.drawImage(bitmap, 0, 0, W, H)
})
Enter fullscreen mode Exit fullscreen mode

createImageBitmap is the important call; it decodes off the main thread and hands back something drawImage takes directly, so a 30 fps stream does not stall the frame loop.

This path is slower than the <img> one, and I keep it because it can do things the fast path cannot: it carries the annotated feed from barcode_detector.py with detection ROIs already drawn on it, and because the frames land as pixels in my canvas I can rotate and channel-swap them. Both of which I needed. The forward camera is mounted upside down (rotation: 180) and one of the Pi cameras publishes with red and blue swapped, which is a one-line fix in the draw call and a much bigger one on the Pi.

A scan is worthless without a position

The image-processing tab: the annotated feed with a decoded barcode boxed in green and the SLAM pose stamped into the frame, the inventory table below it, and the database insert log beside it

That screenshot is the whole product in one frame, and it is worth reading carefully. The green box is barcode_detector.py's ROI. P003GUA is the decoded code. The overlay in the top-left is the SLAM pose at the moment of the scan (x:1600.77 y:-2482.32 z:0.00) burned into the frame by the detector, not added by the GCS. The table underneath pairs each code with that position, and the log on the right shows the row landing in the database.

That is what "geolocated inventory" actually means in practice: a barcode is worthless unless you know where it was, and the only thing that knows where the drone was is SLAM. Tagging the detection at the moment of detection (on the Pi, in the same process that read the code) is what keeps the two from drifting apart. Export to CSV/Excel is one button.

800,000 points per scan, drawn with fillRect

The SLAM tab is the one that surprises people. Point-LIO publishes roughly 800,000 points per scan from the Unitree LiDAR, and the viewer renders them coloured by height.

The SLAM tab: Point-LIO cloud coloured by height, with pose, frame names and start/pause/save-map controls

Two things make this survivable on a laptop:

Draw only when visible. drawSLAM() is called from the frame loop only when S.activeView === 'slam'. On the dashboard tab, the SLAM subscriptions still run and still update S, but nothing paints. That one guard is the difference between a smooth app and a hot one.

The occupancy grid is cells, not pixels. nav_msgs/OccupancyGrid arrives as a flat array with a resolution in metres per cell, so each cell is one fillRect at the grid's own scale, not a per-pixel ImageData write. The LiDAR overlay draws on top of it in the SLAM pose frame at 60 px/m.

Colouring by height is what turns the cloud into a room. Shelving units come out as vertical bands, the floor is a plane, and you can see the aisle. It is the same data either way; the ramp is what makes it legible.

The brain node: what actually flies the mission

Go back to the dashboard screenshot for a second. There is an orange dashed line running through it, labelled origin and wp-1, and a badge in the corner reading WP 1/5: ORIGIN. Neither of those is telemetry. They come from a node I have not introduced yet, and it is the one that makes the word "autonomous" mean anything.

brain_node.py is the mission planner. It is about 175 lines and it does four things:

1. Waypoints live in SQLite, not in the code. Two tables (missions and waypoints) in ~/brain_data.db, with an active flag picking which mission runs. A brand-new database seeds itself with a 5 m square at 5 m altitude so the node has something to do on first launch, and you edit the DB to match the real environment.

Putting the route in a database rather than a constant is the difference between "re-flash the Pi to change the route" and "run one UPDATE". For a warehouse, where the route is the shelf layout, that matters.

2. Position comes from SLAM, not GPS. This is the design decision the whole node hangs on:

self.create_subscription(Odometry, '/Odometry', self._on_odom, 10)
Enter fullscreen mode Exit fullscreen mode

/Odometry is Point-LIO's output. Look at any screenshot in this post and the top-right corner says NO GPS: because there is no GPS indoors. A warehouse is exactly the environment where the usual position source does not exist, so the LiDAR SLAM odometry is the position source. The node does subscribe to /mavros/global_position/global, but only to keep the fix around; the control loop never reads it.

3. A 10 Hz loop that is deliberately boring.

def _tick(self):
    wp = self.waypoints[self.wp_idx]
    if dist2d(self.slam_x, self.slam_y, wp['x'], wp['y']) < ARRIVE_DIST:
        self.wp_idx += 1                       # arrived, advance
        return
    sp = PoseStamped()                          # otherwise, keep asking
    sp.pose.position.x = float(wp['x'])
    sp.pose.position.y = float(wp['y'])
    sp.pose.position.z = float(wp['z'])
    self._pub_sp.publish(sp)                    # → /mavros/setpoint_position/local
Enter fullscreen mode Exit fullscreen mode

That is the entire autonomy loop: am I close enough to the current waypoint? If yes, target the next one. If no, publish this one as a setpoint, again.

The restraint is the point. The brain does not compute trajectories, ramp velocities or tune anything; it publishes a position setpoint and lets ArduCopter's own controller work out how to get there. Trying to out-fly the flight controller from a Python node at 10 Hz over ROS is how you get a drone that fights itself. The division of labour is: ArduCopter flies, the brain decides where.

4. It publishes its own plan for the GCS to draw.

self._pub_path.publish(String(data=json.dumps({
    'mission': self.mission_name, 'wp_index': self.wp_idx,
    'done': self.done, 'waypoints': self.waypoints,
    'slam_pos': {...},
})))
Enter fullscreen mode Exit fullscreen mode

One JSON string on /brain/planned_path at 1 Hz. The renderer subscribes to it, draws the waypoints as the orange dashed route, and turns wp_index into that WP 1/5 badge, green and reading "Mission complete" when done flips.

That last part is why the GCS map has two lines. The blue one is where the drone actually went, from SLAM. The orange dashed one is where the brain intends to go. Drawing both, from two independent sources, means the gap between them is visible in real time, and that gap is the only honest measure of whether the autonomy is working.

One honest gap. ARRIVE_DIST is 1.5 m: the conservative value that flew, and considerably looser than the waypoint radius the simulations below suggest is achievable. Tightening it, and pushing a denser waypoint list into the missions table to match, is the obvious next step and it has not been flown yet.

Before any of it flew: Monte Carlo

You cannot iterate on a warehouse mission by flying it. Battery is ten minutes, the room has to be free, and a bad waypoint list is a drone in a shelf.

So the route was validated in simulation first: a physics-based flight simulator plus a Monte Carlo runner that flies the same mission over and over with noise injected into it. Two separate noise sources, because they are physically different things, LiDAR noise corrupts what the drone perceives, motor noise corrupts what it achieves.

The simulated stack mirrors the real one node for node, with the aircraft replaced by an integrator:

Simulation architecture: LiDAR Node publishes /drone/pose, Brain Node reads mission.json and publishes the next destination, MAVROS Node computes a proportional velocity on /drone/cmd_vel, and the Pixhawk/Drone node applies it and adds noise, closing the loop back to /drone/pose

Two things worth flagging about that diagram, because they are differences rather than details. It is ROS 2 Humble, while the aircraft runs Jazzy, the simulation predates the flight stack. And in simulation the controller is ours: a proportional law v = K_p × (target − position) publishing velocities on /drone/cmd_vel. On the real drone, that job belongs to ArduCopter and the brain only publishes a position setpoint. The simulation had to model a controller precisely because it did not have one.

It also explains a bit of archaeology in the GCS: the first version of the renderer subscribed to /drone/pose as a Float32MultiArray and to /drone/cmd_vel: the simulation's topics, not MAVROS's. The ground station was built against the simulated drone weeks before there was a real one to point it at.

Monte Carlo runs over the warehouse aisle with three shelving units, in 3D

30 runs of the single-shelf route, showing the trajectory bundle against the ideal path and its milestones

The ideal route is the dashed line, the milestones are the red dots, and the blue bundle is every simulated run. Where the bundle stays tight, the route survives realistic sensor noise; where it fans out, it does not. That is a question worth answering with numpy in an afternoon rather than with a drone and a shelf.

The feature I did not plan: finding the drone

Here is the thing nobody warns you about. In a lab, the Pi is at 192.168.1.42 and you type it once. In a warehouse (on a hotspot, on a different subnet, on someone else's phone tethering) the Pi is at some address you do not know, and the person holding the laptop is not going to SSH in and run ip addr.

So the connection popover has a Scan button that finds it. In a browser sandbox, with no Node APIs.

First, discover the local subnet. There is no API for "what is my IP" in a renderer, but WebRTC leaks it in ICE candidates:

function getLocalIP () {
  return new Promise(resolve => {
    const pc = new RTCPeerConnection({ iceServers: [] })
    pc.createDataChannel('')
    pc.createOffer().then(o => pc.setLocalDescription(o))
    pc.onicecandidate = ({ candidate }) => {
      if (!candidate) return
      const m = /([0-9]{1,3}(?:\.[0-9]{1,3}){3})/.exec(candidate.candidate)
      if (m && !m[1].startsWith('127.')) { pc.close(); resolve(m[1]) }
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

(This is the same behaviour browsers spent years trying to suppress for fingerprinting reasons. Inside a desktop app talking to hardware on the same LAN, it is exactly the right tool.)

Then probe the /24 for anything with port 9090 open, in batches, with a short timeout:

const BATCH = 40   // parallel probes
const TOUT  = 400  // ms per probe

for (let start = 1; start <= 254; start += BATCH) {
  const tasks = []
  for (let i = start; i <= Math.min(start + BATCH - 1, 254); i++) {
    tasks.push(probeWS(`ws://${subnet}.${i}:${port}`, TOUT).then(ok => {
      if (ok) onFound(`${subnet}.${i}`)
    }))
  }
  await Promise.all(tasks)
}
Enter fullscreen mode Exit fullscreen mode

A failed WebSocket connection is a fast, cheap probe, you never complete a handshake, you just see whether the socket errors before the timeout. 254 addresses in seven batches of 40 at 400 ms each is about three seconds, and the found hosts show up as clickable rows. The URL then lives in localStorage, so it is remembered next launch.

Three seconds of clever hack, and the app stopped needing me standing next to it.

Controlling the Pi from the dashboard

The last piece closes the loop. Early on, every demo started with me SSHing into the Pi to launch SLAM, then the camera, then MAVROS, in the right order, in separate terminals. That is fine for a developer and impossible for a demo.

So gcs_control.py runs permanently under systemd next to rosbridge, and it is a process supervisor driven by a ROS topic:

# Subscribes:  /gcs/cmd     {"action":"start"|"stop","service":"slam"|"camera"|"mavros"|"brain"}
# Publishes:   /gcs/status  {"slam":"running","camera":"stopped",...}  @ 1 Hz
Enter fullscreen mode Exit fullscreen mode

Each service is a command line it knows how to spawn, with stdout going to ~/gcs_logs/<service>.log. The dashboard gets a SERVICES panel with a green dot and a STOP button per service, and the operator never opens a terminal.

rosbridge under systemd on the Pi, with gcs_control starting SLAM, both cameras, MJPEG, MAVROS, the mission brain and the barcode detector

One deployment note that cost me an evening: ros-jazzy-rosbridge-suite is not available via apt on the Pi. I built rosbridge from source at Release 2.3.0 into ~/rosbridge_ws. If you are on Jazzy and apt install comes up empty, that is the reason and building it is the fix.

What I would tell you if you are about to build one

  • rosbridge turns "needs ROS" into "needs a browser." For a client that only reads telemetry and calls a few services, this is a much better deal than a native ROS client, and it is the single decision that made the rest of the app simple.
  • A framework is not free, and canvas widgets do not need one. If most of your UI is drawn rather than laid out, the reconciliation you are paying for has nothing to reconcile.
  • Guard the expensive draw on visibility. The 800k-point cloud is only unaffordable when you paint it on a tab nobody is looking at.
  • Discovery is a feature, not a nicety. The subnet scanner took an afternoon and removed the last reason anyone needed me in the room.
  • Assume the built app is a different app. nodeIntegration: false plus an asar archive breaks assumptions that hold perfectly in npm start. Package early, package often.
  • Simulate the mission before you fly it. A Monte Carlo runner and two separate noise sources cost an afternoon; the alternative is finding out with a drone and a shelf.
  • Let the flight controller fly. The autonomy node publishes a position setpoint at 10 Hz and gets out of the way. ArduCopter has spent a decade learning to hold a position; a Python node over ROS will not beat it.
  • Draw the plan and the reality as two separate lines. They come from independent sources (the brain's waypoints and SLAM odometry), so the gap between them is the one number that tells you whether any of this is working.

This is WP2 of a 14-student project for the industry client EDISA: the ground station, the mission planner and the drone-control stack. The airframe, the SLAM tuning and the barcode/volumetry pipeline are other people's work packages, and this post only covers mine.


I'm an audio DSP and software engineering student at UPC. Most of what I build is audio plugins: this was a very enjoyable detour. More at github.com/lluisestape-upc.


Top comments (0)