Building a real-time drone ground control station in React
DEV Community Grade 10

Building a real-time drone ground control station in React

I had a quadcopter publishing telemetry over MAVLink and a stubborn preference for not leaving the browser. QGroundControl and Mission Planner are both excellent, but they are native apps, and the thing I actually wanted was a status page the rest of the team could open in a tab. Attitude, position, battery, and a running log of whatever the flight controller was complaining about. The catch is that a drone publishes attitude at 30 to 50 Hz, and the first React version of this that anyone writes tends to fall over the moment real data arrives. You wire each reading to a piece of state, the state updates a few dozen times a second across five or six components, and React spends its whole frame budget reconciling instead of drawing. The display either lags behind the aircraft or, worse, different instruments end up showing different instants of the same moment. So I built it with Altara , a set of telemetry components I maintain that keep the high-frequency path off React's render cycle. This post builds the ground control station in two passes. First the entire dashboard in mock mode, with no drone and no rosbridge involved, so you can get the layout and styling right against animated data. Then we point the exact same components at a real MAVROS stack. What we're building Four panels, the same four every ground station has: A primary flight display (PFD), the artificial horizon with airspeed and altitude tapes, a heading scale, and a vertical speed indicator. A moving map showing the aircraft's position and the track it has flown. A battery gauge. And an event log for the messages coming off the flight controller. We will lay them out in a grid, get all four animating, and only then think about where the numbers come from. Setup The PFD lives in the aerospace package and the map, gauge, and log live in core, so: npm install @altara/core @altara/aerospace The map renders with Leaflet, which Altara treats as an optional peer dependency and loads on mount. If you skip it the map will throw a setup hint at you, so install it now even though we are still in mock mode: npm install leaflet@^1.9.4 react-leaflet@^4.2.1 The smallest thing that moves Before the full grid, here is the PFD and the map side by side, animating themselves with no data plumbing at all: import { PrimaryFlightDisplay } from ' @altara/aerospace ' ; import { LiveMap } from ' @altara/core ' ; export function FlightDeck () { return ( < div style = { { display : ' flex ' , gap : 16 } } > < PrimaryFlightDisplay mockMode size = "md" showFlightDirector /> < LiveMap mockMode /> </ div > ); } That is the whole thing. The mockMode flag on each component switches on a built-in generator, so there is no provider to set up and nothing to feed them. What you see is worth describing, because it is more deliberate than random jitter. The PFD runs a gentle cruise profile. The horizon banks left and right through about 18 degrees on a slow cycle, the nose pitches up and down a few degrees out of phase with the bank so it reads as a coordinated turn rather than a metronome, the heading scale sweeps slowly around east, and the airspeed and altitude tapes drift around 120 knots and 4500 feet. The vertical speed needle leads the altitude changes correctly, because it is driven by the rate of the same wave. With showFlightDirector on, the magenta guidance bars lag the aircraft slightly, so they look like something the pilot is chasing instead of sitting dead center. It reads as an aircraft loafing along in cruise, which is exactly the point. You want to develop against motion that looks plausible. The map, meanwhile, flies a slow circle around a fixed point and turns its nose to follow the track, leaving a trail behind it. The full dashboard, still faked Now the real layout. Four panels in a grid, every one of them in mock mode except the log, which works a little differently: import { LiveMap , Gauge , EventLog , type EventLogEntry } from ' @altara/core ' ; import { PrimaryFlightDisplay } from ' @altara/aerospace ' ; const demoEvents : EventLogEntry [] = [ { timestamp : Date . now () - 9000 , severity : ' info ' , message : ' EKF using GPS ' }, { timestamp : Date . now () - 6000 , severity : ' info ' , message : ' Armed ' }, { timestamp : Date . now () - 3000 , severity : ' warn ' , message : ' Wind 11 m/s, approaching limit ' }, { timestamp : Date . now () - 1000 , severity : ' info ' , message : ' Waypoint 3 reached ' }, ]; export function GroundControlStation () { return ( < div style = { { display : ' grid ' , gridTemplateColumns : ' 1fr 1fr ' , gap : 16 , padding : 16 , alignItems : ' start ' , } } > < PrimaryFlightDisplay mockMode size = "md" showFlightDirector /> < LiveMap mockMode /> < Gauge mockMode mockProfile = "ramp" min = { 0 } max = { 100 } label = "Battery" unit = "%" thresholds = { [ { value : 0 , color : ' var(--vt-color-danger) ' }, { value : 20 , color : ' var(--vt-color-warn) ' }, { value : 40 , color : ' var(--vt-color-active) ' }, ] } /> < Eve

I had a quadcopter publishing telemetry over MAVLink and a stubborn preference for not leaving the browser. QGroundControl and Mission Planner are both excellent, but they are native apps, and the thing I actually wanted was a status page the rest of the team could open in a tab. Attitude, position, battery, and a running log of whatever the flight controller was complaining about. The catch is that a drone publishes attitude at 30 to 50 Hz, and the first React version of this that anyone writes tends to fall over the moment real data arrives. You wire each reading to a piece of state, the state updates a few dozen times a second across five or six components, and React spends its whole frame budget reconciling instead of drawing. The display either lags behind the aircraft or, worse, different instruments end up showing different instants of the same moment. So I built it with Altara, a set of telemetry components I maintain that keep the high-frequency path off React's render cycle. This post builds the ground control station in two passes. First the entire dashboard in mock mode, with no drone and no rosbridge involved, so you can get the layout and styling right against animated data. Then we point the exact same components at a real MAVROS stack. What we're building Four panels, the same four every ground station has: A primary flight display (PFD), the artificial horizon with airspeed and altitude tapes, a heading scale, and a vertical speed indicator. A moving map showing the aircraft's position and the track it has flown. A battery gauge. And an event log for the messages coming off the flight controller. We will lay them out in a grid, get all four animating, and only then think about where the numbers come from. Setup The PFD lives in the aerospace package and the map, gauge, and log live in core, so: npm install @altara/core @altara/aerospace The map renders with Leaflet, which Altara treats as an optional peer dependency and loads on mount. If you skip it the map will throw a setup hint at you, so install it now even though we are still in mock mode: npm install leaflet@^1.9.4 react-leaflet@^4.2.1 The smallest thing that moves Before the full grid, here is the PFD and the map side by side, animating themselves with no data plumbing at all: import { PrimaryFlightDisplay } from '@altara/aerospace'; import { LiveMap } from '@altara/core'; export function FlightDeck() { return ( ); } That is the whole thing. The mockMode flag on each component switches on a built-in generator, so there is no provider to set up and nothing to feed them. What you see is worth describing, because it is more deliberate than random jitter. The PFD runs a gentle cruise profile. The horizon banks left and right through about 18 degrees on a slow cycle, the nose pitches up and down a few degrees out of phase with the bank so it reads as a coordinated turn rather than a metronome, the heading scale sweeps slowly around east, and the airspeed and altitude tapes drift around 120 knots and 4500 feet. The vertical speed needle leads the altitude changes correctly, because it is driven by the rate of the same wave. With showFlightDirector on, the magenta guidance bars lag the aircraft slightly, so they look like something the pilot is chasing instead of sitting dead center. It reads as an aircraft loafing along in cruise, which is exactly the point. You want to develop against motion that looks plausible. The map, meanwhile, flies a slow circle around a fixed point and turns its nose to follow the track, leaving a trail behind it. The full dashboard, still faked Now the real layout. Four panels in a grid, every one of them in mock mode except the log, which works a little differently: import { LiveMap, Gauge, EventLog, type EventLogEntry } from '@altara/core'; import { PrimaryFlightDisplay } from '@altara/aerospace'; const demoEvents: EventLogEntry[] = [ { timestamp: Date.now() - 9000, severity: 'info', message: 'EKF using GPS' }, { timestamp: Date.now() - 6000, severity: 'info', message: 'Armed' }, { timestamp: Date.now() - 3000, severity: 'warn', message: 'Wind 11 m/s, approaching limit' }, { timestamp: Date.now() - 1000, severity: 'info', message: 'Waypoint 3 reached' }, ]; export function GroundControlStation() { return ( ); } Two things in there are worth pulling out. The gauge has a mockProfile . The default mock animation sweeps the needle up and down with a sine wave, which is fine for a generic gauge but looks absurd on a battery, because the charge appears to refill itself every few seconds. The ramp profile drains it from full to empty and resets, so a screenshot of your dashboard does not show a battery doing something physically impossible. Small detail, but it is the difference between a demo that looks considered and one that looks like a component sampler. The event log is the odd one out. It has no mock mode, and that is on purpose. Every other component here carries a single number per channel, and the log carries text and a severity, which is a different kind of thing entirely. So instead of a mockMode flag, you hand it an array of entries and own that array yourself. Here it is a static list. Later, when we have real data, we will append to it from the flight controller's log stream. The shape is just a timestamp, a message, and one of info , warn , or error . One layout thing that will save you a confused minute. The PFD has a fixed pixel footprint rather than a fluid one, around 680px wide at size="lg" and narrower at md . In a two-up grid that means the grid has to be wide enough to hold it, or the instrument overflows its column. I dropped to size="md" above so the dashboard sits comfortably at normal widths. If you want the larger PFD, give the grid the room for it rather than expecting the instrument to shrink. Everything above runs without a drone, without rosbridge, and without a simulator. You can build the entire dashboard, get the grid proportions right, tune the thresholds, and hand it to a designer, all before any real telemetry exists. That is the reason to start in mock mode rather than treating it as an afterthought. The hard part of a ground station is rarely the data. It is the layout and the rendering, and you can finish both of those against fake motion. Next we throw the mock flag away and point these same four components at a live MAVROS stack over rosbridge, which is where the interesting wiring lives: turning an IMU quaternion into something the horizon can draw, getting five channels onto one display, and feeding text logs into a component that only speaks numbers. Wiring it to a real drone The plan from here is the same four components, none of them changed, fed by a live MAVROS stack instead of the mock generator. The bridge between the two worlds is a small idea. Every Altara component that takes live data takes a dataSource , an object you subscribe to for a stream of timestamped numbers. rosbridge, meanwhile, hands you ROS topics over a WebSocket. So the wiring is mostly about turning topics into data sources, with a couple of places where the translation is more than a rename. You will need a rosbridge server in front of your ROS graph (the standard rosbridge_suite ), and for a MAVLink vehicle, MAVROS bridging the flight controller into ROS. Once that is up, everything below talks to it at ws://localhost:9090 . These four are the imports for the whole section: import { useEffect, useMemo, useState } from 'react'; import { EventLog, Gauge, LiveMap, type EventLogEntry } from '@altara/core'; import { PrimaryFlightDisplay } from '@altara/aerospace'; import { createBatteryStateAdapter, createImuAdapter, createRosbridgeAdapter, mergeChannels, } from '@altara/ros'; The PFD: five numbers, two topics, one instrument This is the one with the actual problem in it. A primary flight display shows five live values: roll, pitch, heading, airspeed, and altitude. They do not come from one place. Roll and pitch come from the IMU, and heading, airspeed, and altitude come from the flight controller's HUD topic. The display, though, wants a single data source. export function LivePrimaryFlightDisplay({ url }: { url: string }) { const source = useMemo(() => { const imu = createImuAdapter({ url, topic: '/mavros/imu/data' }); const hud = createRosbridgeAdapter({ url, topic: '/mavros/vfr_hud', messageType: 'mavros_msgs/VFR_HUD', channels: { heading: (m) => m.heading, airspeed: (m) => m.airspeed * 1.94384, // m/s -> kt altitude: (m) => m.altitude * 3.28084, // m -> ft }, }); return mergeChannels({ roll: imu.roll, pitch: imu.pitch, heading: hud.heading, airspeed: hud.airspeed, altitude: hud.altitude, }); }, [url]); useEffect(() => () => source.destroy(), [source]); return ; } Three things are happening there. createImuAdapter opens one socket to the IMU topic and gives you back roll, pitch, and yaw as separate channels. That matters more than it looks, because sensor_msgs/Imu reports orientation as a quaternion and the horizon needs degrees. The adapter does that conversion for you, and if you ever want it on its own, quaternionToEuler is exported too. There is one detail in that conversion worth knowing about, which I will come back to. The HUD adapter uses the generic createRosbridgeAdapter with a channels map, which pulls several numbers off a single message. VFR_HUD publishes heading already in degrees, but airspeed in meters per second and altitude in meters, so the conversions to knots and feet are mine to do, right there in the extractor. The tapes display whatever number you give them. Hand them meters and they will read 4500 meters without complaint. Then mergeChannels unions those named sources into one channel-tagged stream, keyed by the names I passed. That key is what the PFD routes on internally, so the labels are load bearing. roll has to be roll . The whole display ends up running on two sockets, one to the IMU and one to the HUD, which is about as lean as five live values across two topics g

Comments

No comments yet. Start the discussion.

34.7 ms