Lab 04: Companion Computer & Autopilot Integration

1 Introduction and objectives

1.1 From simulation to autonomous control

In Lab 03 you ran the boat's autopilot in simulation and drove it yourself, using the MAVProxy console and a graphical GCS like MissionPlanner or QGroundControl to change modes, send the boat to a point in GUIDED, and build and run missions. The autopilot is very good at the low-level job of driving: holding a heading, going to a waypoint, keeping the boat steady. What it does not do is make decisions. It cannot choose where to go next because of what another boat is doing, or change the plan when conditions change.

That kind of higher-level thinking is the job of a companion computer: a small computer that rides on the boat right next to the autopilot and runs programs that you write. The autopilot keeps doing the steering, while the companion computer watches what is going on and tells the autopilot what to do next. This is the step that turns the boat from something following a fixed plan into something that acts on its own, and it is what this whole lab is about.

We build all of this in simulation first. Everything in this lab runs against SITL, the same lightweight simulator you used in Lab 03. It is easy to run, asks little of your computer, and is a genuinely useful skill to keep long after the school. If you want to go further on your own later, richer simulators such as Gazebo can model a full 3D world with simulated cameras and other sensors, which opens the door to behaviour that reacts to what the boat "sees." That is well beyond this lab, but it is worth knowing it exists as a direction to explore.

1.2 The companion computer setup

Each of our boats carries a few parts that work together:

Splitting the work this way keeps each part simple. The autopilot firmware only ever has to worry about driving the boat, and you never modify it. Your Python on the Pi adds all of the smart behaviour on top, and because it is ordinary Python you can change it and test it quickly. The boats coordinate with each other by passing short messages through a system called MQTT, which we set up later in the lab.

In this lab, all of this runs on your own laptop. The setup above is the real one used out on the water, but you do not need any of that hardware to do the lab. SITL stands in for the autopilot and the boat, and the Python you would normally run on the Raspberry Pi you simply run on your laptop, right beside the simulator. So wherever we say "the companion computer," picture your own machine playing that part. The code you write is the same code that would later run on a real Pi, and that is the whole point: get it working safely in simulation first, then move it across unchanged.

1.3 What you will be able to do

By the end of the lab you will be able to:

  1. Connect a Python program to the autopilot and exchange data with it.
  2. Read live telemetry, the readings the boat reports about itself: position, heading, speed, and status.
  3. Command the boat from code: change its mode and send it to a point.
  4. Upload a mission and follow its progress from Python.
  5. Pass messages between boats, and between a boat and the shore, over MQTT.
  6. Build simple autonomous behaviour, such as one boat following another.
  7. Know what changes when the same code moves from the simulator onto a real boat (a walk-through at the end of the lab; no hardware needed).

1.4 The nine projects

The lab is a series of nine small projects, each adding one new idea on top of the one before. You are not expected to finish all of them during the session; the later ones are there to carry on with in your own time.

1.5 Where this is used

The same ideas turn up directly in real maritime work. What you build here is also the foundation for real sensor integration: on a real boat, cameras or lidar connect to the same companion computer, and what they see takes priority over anything a message says. The messages are not only boat-to-boat either; MQTT can carry information processed ashore, for example live AIS traffic telling a vessel to reroute because a large ship is nearby. Some of the places this pattern shows up:

1.6 How we work

Three habits run through the whole lab:

2 Development environment setup

This section gets your laptop ready to run the lab's Python: the lab's code, a clean Python workspace, the libraries the projects need, the one fix an old library requires, and the settings the code reads. It builds on the WSL2 and Ubuntu setup you already did in Lab 03, and as in Lab 03 every command here runs in the Ubuntu terminal, not in Windows PowerShell or Command Prompt.

Not on Windows + WSL2? That is fine. Everything in this lab runs in any ordinary Linux environment, and macOS works too; the commands are identical, only the ground-station address differs, and each project points that out where it matters. What the lab actually needs is: a working SITL from Lab 03, a recent Python 3 with venv and pip (on Debian-family systems sudo apt install python3-venv python3-pip covers both), and network access to GitHub and the session broker. We test on Ubuntu 24.04 with Python 3.12; on a different distro or Python version the most likely hiccup is pip install failing while building a library. If that happens, the last few lines of the error are your best lead: search the web for them together with your exact setup, the distro, its version, and your Python version, since only you know that combination. The answer is usually a one-line install of a missing system package.

Use a real editor. The comfortable way to work through this lab is to open the whole code folder (you create it in the next step) in a graphical editor, so every project's code, your .env settings file, and everything else is one click away as you read and edit. We recommend VS Code: it works with WSL out of the box; run code . from the Ubuntu terminal inside the code folder, let it install its WSL extension the first time it offers, and from then on you can browse and edit every file directly. Microsoft has a short guide at https://learn.microsoft.com/en-us/windows/wsl/tutorials/wsl-vscode. On Mac or Linux, whatever editor you already use is fine. The commands below use nano, a bare-bones editor that is already there in the terminal; treat it as the fallback, not the recommendation.

2.1 Getting the code and a Python environment

Everything for this lab lives in one GitHub repository: the eight project folders, the list of Python libraries, the broker's certificate, and a settings template. Clone it into a folder named code inside a working directory:

mkdir -p ~/lab04-companion
cd ~/lab04-companion
git clone https://github.com/ITSLab-UAegean/lab04-maritime25.git code
cd code

You now have everything in ~/lab04-companion/code: the project folders 1_proj through 8_proj, a requirements.txt, the certificate ca.crt, and a settings template .env.example.

Next, a virtual environment: a private copy of Python just for this lab. It keeps the lab's libraries separate from everything else on your machine, so versions cannot clash. That matters here because one of our libraries, DroneKit, is fussy about versions. Create it (named ss_venv, for "summer school virtual environment") and activate it. Once it is active, your prompt shows a (ss_venv) prefix:

python3 -m venv ss_venv
source ss_venv/bin/activate

Check that it worked. which pip should point to a path inside ss_venv, and Python should report a 3.x version:

which pip
# .../lab04-companion/code/ss_venv/bin/pip
python --version

When you finish for the day you can leave the environment with deactivate, and step back into it later by running source ss_venv/bin/activate again from the code folder. The environment needs to be active any time you run the lab's code.

2.2 Installing the Python libraries

The clone already includes a requirements.txt that pins the exact library versions the lab needs. With ss_venv active, install them all in one go:

pip install -r requirements.txt

What you just installed:

Check the install worked. We deliberately do not import DroneKit yet, it will not import cleanly until the fix in the next section:

pip list
python -c "import paho.mqtt, dotenv, pymavlink; print('libraries OK')"

If pip install reports an error, update pip itself with pip install --upgrade pip and try once more.

If the install fails while building lxml (an error mentioning libxml2 or libxslt): your Python is probably newer than these pinned versions have ready-made packages for (Python 3.13 or newer; the standard WSL Ubuntu 24.04 setup is not affected). Open requirements.txt, remove the version numbers from the lxml and pymavlink lines so they read just lxml and pymavlink, save, and run the install command again. Leave the other lines as they are.

2.3 The DroneKit compatibility fix

DroneKit was last updated in 2019, and on Python 3.10 and newer it trips over one outdated line. The first time anything imports DroneKit, you get:

AttributeError: module 'collections' has no attribute 'MutableMapping'

The cause is small. Python moved a helper called MutableMapping from collections to collections.abc, and removed the old name for good in version 3.10. DroneKit still uses the old name, so we point it at the new one. It is a single line, and you only do it once per virtual environment.

This is the moment a real editor pays off. If you opened the code folder in VS Code, as recommended at the start of this section, do the fix there instead of in nano: in the file tree, follow ss_venv / lib / python3... / site-packages / dronekit, open __init__.py, press Ctrl-F and search for MutableMapping, make the one-word change shown below, and save. Then skip ahead to the "confirm DroneKit now imports cleanly" check.

From the code folder, step into DroneKit's own folder inside the environment (the python3.* part matches whatever Python version you have):

cd ss_venv/lib/python3.*/site-packages/dronekit/

Open its main file:

nano __init__.py

In nano, press Ctrl-W, type MutableMapping, and press Enter to jump straight to the line. It reads:

class Parameters(collections.MutableMapping, HasObservers):

Change collections.MutableMapping into collections.abc.MutableMapping, so it becomes:

class Parameters(collections.abc.MutableMapping, HasObservers):

Save and exit (Ctrl-O, Enter, Ctrl-X).

Go back to the code folder and confirm DroneKit now imports cleanly:

cd ~/lab04-companion/code
python -c "import dronekit; print('DroneKit OK')"

A few things worth knowing:

Why keep using an unmaintained library? Because for a first contact it is hard to beat. With DroneKit the boat is an object whose readings behave like plain attributes: ask for vehicle.heading or vehicle.groundspeed and you have the number, with no MAVLink details in sight. That friendliness is why the lab uses it, and its age is why the one-line patch above exists; we have checked that this single change is still all it takes, even on the newest Python versions. If you keep building after the lab and outgrow DroneKit, the natural next step is a small wrapper class of your own around pymavlink, the maintained lower-level library DroneKit itself is built on. We kept that out of this lab for simplicity, and Section 4 says a little more about it as a direction to explore.

2.4 Settings: the .env file

Things like the broker address, usernames, and passwords do not belong hard-coded in your scripts. They live in a file named .env, which the python-dotenv library loads when a program starts. The clone gives you a template, .env.example. Copy it to .env, the real file every project reads:

cp .env.example .env

A single .env is shared by all the projects, and most of it is already filled in for you. The early projects use only a few of its values and ignore the rest, which is normal. Your passwords stay safe: the repository already ignores .env in git, so it is never committed.

You do not need to change anything in it yet. Project 1 reads nothing from .env, so you can go straight on to it. The one part you will edit, the broker address and its connection settings, is covered in Project 2, where MQTT first appears.

Opening .env you will also see names you do not recognise yet. That is expected: it is one shared file for the whole lab, so some settings only come into play in the later projects, and a few are there as suggestions you can wire into your own code to replace a hardcoded value (the telemetry update rate in Project 1 is one example). The table below shows which projects actually read what; anything a project does not mention, you can safely ignore for now.

Which projects read what:

Project Uses from .env
1 nothing (its connection is written into the code)
2 to 3 MQTT_BROKER, MQTT_PORT, MQTT_USERNAME, MQTT_PASSWORD
4 to 7 TEAM_NS and FLEET_NS, plus the role-based values (SCOUT_*, VESSEL*_*) including the SITL connection strings

If a file ever misbehaves in WSL. Because you cloned the project with git, all of its files (including the certificate) arrive in the correct Linux format, so you should not run into this. But if you ever copy a file in by hand and something refuses to work, check it with file yourfile. The usual culprits are Windows-style line endings (shown as CRLF), fixed with dos2unix yourfile, or a stray companion file ending in :Zone.Identifier, which is harmless Windows metadata you can simply delete.

3 SITL-based Python development

This is the heart of the lab. It is a series of small Python programs, each one adding a single new idea to the one before, that slowly turn the boat from something you drive by hand into something that runs on its own.

You write and test every one of them against the SITL simulator from Lab 03 first, where a mistake costs nothing.

Each project lives in its own folder inside the code you cloned: 1_proj, 2_proj, and so on. The way you run them is always the same, and it uses two terminals side by side:

Start SITL first, give it a few seconds to get a GPS fix, then run your script in the second terminal.

3.1 Project 1: Basic connection and telemetry

Project 1 in the code repository

This first project does the simplest useful thing: it connects to the boat and prints what the boat reports about itself. That stream of self-reported readings, things like position, heading, and speed, is called telemetry. Reading it reliably is the foundation every later project builds on.

The code. Project 1 is already in the 1_proj folder from the clone, so there is nothing to create. Open 1_proj/leader_telemetry.py and read through it; it is short. The parts that matter:

This attribute style is the whole reason we lean on DroneKit. Underneath, the boat speaks MAVLink, a constant stream of raw messages; on your own you would have to catch each message and pull the fields out by hand. DroneKit does that work for you and presents the boat as a single Python object, so vehicle.heading simply gives you the heading. That is also why it is so easy to learn, even though the library itself is old and no longer maintained.

Run it. Two terminals.

Terminal 1, start SITL exactly as you did in Lab 03 (Rover at Syros, with the boat parameter file). If you need a refresher on the run folder and the full command, see A folder to run from in the Lab 03 notes. As a reminder:

cd ~/maritime26/sitl-test
sim_vehicle.py -v Rover -L Syros \
  --out=udp:127.0.0.1:14550 --out=udp:127.0.0.1:14551 \
  --add-param-file=$HOME/maritime26/custom-parms/boat.parm \
  --console --map

The boat's data now goes out twice: the 14551 copy is the one your Python script reads, and 14550 stays reserved for a graphical ground station (the note below shows how to use it). Wait until the console reports a GPS fix.

Terminal 2, from the code folder with the environment active, run the script:

cd ~/lab04-companion/code
source ss_venv/bin/activate
python 1_proj/leader_telemetry.py

What you will see. The script connects, then prints a block like this every 5 seconds:

Connecting to vehicle on: udp:127.0.0.1:14551
Timestamp: 06/28/2026 - 17:57:51
Heading: 180 degrees
Ground Speed: 0.0 m/s
Latitude: 37.4393255
Longitude: 24.9456159
------------------------------

At this point the boat is sitting still at its start point, so the numbers barely move. That is expected: Project 1 only reads, it does not drive.

Make the numbers move. To see the telemetry actually change, drive the boat while the script keeps running. In the SITL terminal, put it in GUIDED mode and arm it, just as in Lab 03:

mode GUIDED
arm throttle

Then right-click a nearby point on the map and choose Fly to here (set the altitude to 1; a boat ignores altitude, and 1 is the one value every ground station accepts). Now watch Terminal 2: the heading, ground speed, and position update on every line as the boat moves. In GUIDED the boat cruises at about 5 m/s, so you will see the ground speed climb (roughly 0.3, then 3.9, then 5.0 m/s as it gets under way) and the latitude and longitude step along, then settle again once it arrives. Press Ctrl-C in Terminal 2 to stop the script cleanly.

Watching the boat in a ground station while the script runs

A single network port can be read by only one program at a time, and ground stations grab the standard port eagerly; QGroundControl, for example, opens 14550 by itself the moment it starts. That is exactly why these labs reserve 14550 for the ground station and give your script its own 14551: both can run at the same time, with nothing to configure and nothing clashing. On native Linux or macOS, simply start QGroundControl and the boat appears. On Windows with WSL2 the ground station lives on the Windows side of the fence, so point the 14550 output at your Windows address instead of 127.0.0.1 (Lab 03 section 8 shows how to find it) and Mission Planner picks the boat up. (If your WSL2 is switched to mirrored networking, localhost is shared with Windows, so the 127.0.0.1 output already reaches Mission Planner.) The link works in both directions: you can arm, change mode, and send "go to" points from the ground station while the script keeps reading telemetry. To see every port the boat's data is being sent to, type output list in the SITL console.

Try this: change the time.sleep(5) near the bottom of the script to 1, or to 10, and run it again. A shorter interval gives a smoother, faster-updating readout; a longer one is quieter. That number is the update rate, and different jobs want different rates. Right now it is written straight into the code; a tidier habit, which you will meet later, is to keep a value like this in .env and read it from there, so you can change it without touching the script.

If it breaks.

Where this leads. This connection is the base for everything that follows. Behind the scenes DroneKit is doing the talking for you in the autopilot's own language, called MAVLink, which you will meet directly in a later project. In Project 2 you take this same telemetry and send it out over the network with MQTT, so other computers can see what the boat is doing.


3.2 Project 2: Publishing telemetry over MQTT

Project 2 in the code repository

Project 1 kept the telemetry on one computer: the script read the boat's readings and printed them in its own terminal, and that was the end of it. Project 2 takes that same telemetry and sends it out over the network, so any other computer can pick it up. This is the step that lets a shore station watch the boat, and later lets boats react to each other. The tool that carries the messages is MQTT.

What MQTT is. MQTT is a lightweight way to pass short messages between programs. It has three pieces:

Publishers and subscribers never talk to each other directly; the broker sits in the middle and routes messages by topic. The whole arrangement in one picture:

 boat A            boat B
   |                 |
   | publishes       | publishes
   | A/position      | B/position
   v                 v
+-----------------------+
|        broker         |
|   (routes by topic)   |
+-----------------------+
   |                 |
   | A/position      | A/position + B/position
   v                 v
 boat B            shore laptop
 (subscribed       (subscribed to both,
  to boat A)        watching the fleet)

It is worth pausing on how different this is from the usual way programs talk over a network. The usual way is request and response: a client calls a server and waits for the answer, the way your browser fetches a page, and both sides must be reachable at that exact moment for anything to happen. Publish and subscribe cuts that tie. A boat publishes its position and is done, whether one program is listening, or ten, or none at all; a subscriber can join, drop out, and rejoin without the publisher ever noticing; and nobody needs anybody else's network address, only the broker's. For vehicles on mobile links that come and go, this is exactly the right shape: whoever is connected right now gets the messages, and a boat that loses signal behind a headland for a minute simply picks up where it left off.

This is not a classroom-only arrangement, either. The SmartMove lab's real boats run exactly this pattern out on the water: the Raspberry Pi on board publishes telemetry through a 4G dongle to a broker just like ours, and the shore station subscribes. What you are building in this project is that system, with SITL standing in for the hull.

Why MQTT, and a note on reliability. MQTT is small and fast: each message carries very little extra data, which suits the boats' mobile (4G) links and the modest computers on board. It was designed for exactly this kind of network, where the connection can be slow or drop out, and it has become the standard choice for "Internet of Things" devices and for marine telemetry for that reason. It also lets you choose, message by message, how hard the broker should work to deliver it; this setting is called the quality of service, or QoS. A steady stream of telemetry like position can use the lowest, cheapest level: if one update goes missing, the next one arrives a few seconds later, so it does not matter. A command sent to a boat ("go to this point", "stop") would use a higher level, so the broker makes sure it gets through. Our telemetry goes out at that lowest level, which is the sensible default for a steady position stream. Commands are the case that really wants the higher, guaranteed level, and you will see exactly that in the follow-the-leader project (Project 7), where a lost command would matter and so its command topic uses the higher level.

The broker for this lab. We have set up a broker for you, so there is nothing to install. While the summer school is running it stays in plain (unencrypted) mode on the normal MQTT port:

You reach it only through eduroam: connect with the account your home university gives you. If your university does not provide eduroam, tell us at the session; we have temporary eduroam accounts for the school week. Neither the open internet nor the campus guest WiFi can reach the broker (that is deliberate), so from a hotel, from home, or on the guest network it will not answer; the tip below shows how to keep working anywhere. The broker username and password are handed out on the day, so they are not written here.

Point the lab at the broker. Open .env in the code folder and set the MQTT connection to this broker. Make sure these are the active (uncommented) lines, and that the encrypted "secure" block just below them is commented out:

MQTT_BROKER=smartmove-local.syros.aegean.gr
MQTT_PORT=1883
MQTT_USE_TLS=false
MQTT_USERNAME=     # the username given to you in the session
MQTT_PASSWORD=     # the password given to you in the session

The template ships with a placeholder address, so this edit is needed before Project 2 will connect.

Run your own broker (a great next step). You are not tied to our broker at all. Installing your own takes a minute and gives you a private setup that you fully control. Install it once with sudo apt install mosquitto mosquitto-clients, then start it in its own terminal with mosquitto -v. In .env, set MQTT_BROKER=localhost, keep MQTT_PORT=1883, and set MQTT_USE_TLS=false. A broker running like this on your own machine accepts local connections without checking credentials, so the username and password in .env can stay as they are, and everything stays safely on your laptop with nothing exposed to the internet.

Once you leave the campus network, this is how you keep going, because the session broker cannot be reached from the internet. It is also the natural setup for real experimenting. Because the broker is yours, you can organise it however you like: as many topics as you want, and your own logins if you decide you want them. That is exactly what you need for the later multi-vessel projects, where you run several simulated boats at once and have them coordinate over MQTT, all against your own local broker.

The code. Project 2 is in the 2_proj folder. It is Project 1's telemetry loop with MQTT wrapped around it. Open 2_proj/leader_telemetry.py; the new parts are:

The script checks MQTT_USE_TLS. With it off, it prints TLS disabled, using non-secure connection and connects on the plain port, which is the path we use during the school. Turning encryption on is covered at the end of this project, as an optional extra.

Run it. The same two terminals as Project 1, and you can add a third to watch the broker.

Terminal 1, start SITL exactly as before (the full launch command is back in Project 1 if you need it). Terminal 2, from the code folder with the environment active:

cd ~/lab04-companion/code
source ss_venv/bin/activate
python 2_proj/leader_telemetry.py

What you will see. The script connects to the boat, then to the broker, and reports a successful publish on every cycle:

Connecting to vehicle on: udp:127.0.0.1:14551
TLS disabled, using non-secure connection
Connecting to smartmove-local.syros.aegean.gr:1883 ...
Timestamp: 28/06/2026 - 19:37:46, Heading: 180 degrees, Ground Speed: 0.0 m/s, Latitude: 37.4393257, Longitude: 24.9456159
Successfully published to MQTT.

Successfully published to MQTT. means your MQTT client accepted the message and queued it for sending. As in Project 1 the boat is parked, so the numbers barely move until you drive it. Press Ctrl-C to stop; the script closes the vehicle connection and disconnects from the broker cleanly.

Check it really arrived (optional third terminal). "Successfully published" only tells you the message left the script. To prove it reached the broker, subscribe to the same topic from another terminal with the mosquitto_sub command-line tool (install it once with sudo apt install mosquitto-clients if you do not have it) and watch the lines come in:

mosquitto_sub -h smartmove-local.syros.aegean.gr -p 1883 \
  -u <username> -P <password> -t leader/position -v

Use the same username and password as in .env. Every line your script publishes appears here within a second. This is MQTT working as intended: the subscriber is a completely separate program, and it could just as easily be running on another laptop or on the shore station.

Try this: start a second mosquitto_sub on a teammate's laptop, subscribed to the same topic. Both receive every message, with no change to your script. That is exactly how a fleet works: one boat publishes once, and everyone who cares is listening.

Expect company on this topic. In the lab, everyone's script publishes to the same hardcoded leader/position with the same session login, so your subscriber will also show lines from other students' boats, and since every simulated boat starts at the same spot near Syros, those lines look nearly identical to yours. Do not let that confuse you; your own lines are the ones ticking in step with your script's terminal. Sharing one topic like this is exactly the mess Project 4 cleans up, when every running fleet gets a topic prefix of its own.

Make the numbers move. Just like Project 1, drive the boat to see live data. In the SITL terminal put it in GUIDED mode, arm it, and right-click Fly to here on the map. Now the heading, speed, and position change on every line, in both your script's terminal and the subscriber's, at the same time.

If it breaks.

Optional: securing the connection with TLS. On plain port 1883 the messages, and even your password, travel across the network unencrypted. On a closed lab network that is fine, but for anything on the open internet you want TLS, the same encryption your browser uses for https. The code already supports it, and the repository ships the broker's certificate, ca.crt. To switch it on, change these in .env:

MQTT_PORT=8883
MQTT_USE_TLS=true
MQTT_CA_CERT_PATH=ca.crt

The script then connects over an encrypted channel and prints TLS enabled instead. You are not expected to do this during the session; our broker runs in plain mode for the school. It is here for when you connect to a properly secured broker, or move to a real deployment (Section 5). One handy detail: a publisher using TLS on 8883 and a subscriber using plain 1883 still share the same topics, so you can always debug with mosquitto_sub on the plain port even when your script is encrypted.

Where this leads. The boat is now broadcasting its state to anyone who subscribes, which is the foundation for every multi-boat behaviour later in the lab. Two things still want improving, and the next projects handle them: the code mixes the vehicle, the MQTT, and the main loop all together, which Project 3 tidies into clean modules; and the message is one long human-readable string, easy for a person to read but awkward for a program to take apart, which Project 5 replaces with JSON.


3.3 Project 3: Modular architecture

Project 3 in the code repository

Project 2 did its whole job in one file: the vehicle code, the MQTT code, and the main loop all lived together in a single script. That is fine while the script is small, but it gets harder to read and to extend as features pile up. Project 3 does the same job as Project 2, with one small addition, but reorganised into three tidy files, each responsible for one thing. This is the shape every project after this builds on.

What "modular" means. Instead of one long script, the code is split so each part has a single, clear job. This is called separation of concerns: the piece that talks to the boat knows nothing about MQTT, and the piece that talks to MQTT knows nothing about the boat. Each part is wrapped in a class, which is just a named bundle of related data and the functions that work on it. The three files are:

┌──────────────────────────┐
│ main.py                  │
│   main(): read, publish, │
│   wait, repeat           │
└────────────┬─────────────┘
             │  creates and coordinates both
   ┌─────────┴───────────────────────┐
   ▼                                 ▼
┌──────────────────────────┐   ┌──────────────────────────┐
│ VesselController         │   │ MQTTHandler              │
│   vehicle                │   │   broker, port, username │
│   get_telemetry()        │   │   publish()              │
│   close_connection()     │   │   disconnect()           │
└──────────────────────────┘   └──────────────────────────┘
   the boat side                   the MQTT side

Why bother, when the program does almost the same thing? Because this shape pays off the moment things grow. Each class can be reused in another project by importing it, tested on its own, and changed without disturbing the others; a fix to the MQTT code cannot break the vehicle code. The later projects, where several boats take on different roles, would be unworkable as one long script. This is the foundation that keeps them manageable.

The code. Open the three files in 3_proj and read them side by side. Together they come to about the same length as Project 2's single file, just sorted into drawers.

The one new thing. Project 3 is mostly a tidy-up, but it does add one change you can see at the broker: MQTTHandler.publish() appends your username from .env to the end of every message, so each line now finishes with , USER: ... (in the transcripts below it is scout, the instructor's login; your lines show your own login). Note what this does and does not do. The topic is still the shared leader/position, so everyone still receives everyone's lines; and since your whole team shares one login, the field tells a reader which team a line came from, not which teammate. So this is attribution, a tag saying who sent what, not separation. It is a first hint of the bigger problem the multi-boat projects have to solve; the real fix, giving each running fleet its own topics, is Project 4's job.

Run it. The same two terminals as before. Start SITL in Terminal 1 exactly as in Project 1. In Terminal 2, from the code folder with the environment active:

cd ~/lab04-companion/code
source ss_venv/bin/activate
python 3_proj/main.py

Running it as python 3_proj/main.py keeps the command in the same shape as the earlier projects. Python automatically looks for the two helper modules next to main.py, so they are found with no extra setup. (If you prefer, cd 3_proj first and run python main.py; both work.)

What you will see. The same telemetry as Project 2, now with the USER: field on the end of each line:

TLS disabled, using non-secure connection
Connecting to smartmove-local.syros.aegean.gr:1883 ...
Successfully connected to MQTT broker
Connecting to vehicle on: udp:127.0.0.1:14551
Successfully published to MQTT.
Timestamp: 30/06/2026 - 20:41:24, Heading: 180 degrees, Ground Speed: 0.03 m/s, Latitude: 37.4393258, Longitude: 24.9456158, USER: scout

The broker and vehicle connect first, then it publishes on every cycle. As before the boat is parked, so the numbers barely move until you drive it in GUIDED. Press Ctrl-C to stop; both connections close cleanly.

Check it really arrived. Just as in Project 2, prove the messages reach the broker by subscribing from a separate terminal (install the tool once with sudo apt install mosquitto-clients if you have not already):

mosquitto_sub -h smartmove-local.syros.aegean.gr -p 1883 \
  -u <username> -P <password> -t leader/position -v

Use the same username and password as in .env. This time every line ends with the USER: field, proving it is travelling over the network, not just printed locally:

leader/position Timestamp: 30/06/2026 - 20:42:09, Heading: 180 degrees, Ground Speed: 0.03 m/s, Latitude: 37.4393258, Longitude: 24.9456158, USER: scout

Team exercises.

If it breaks.

Where this leads. You now have a clean separation between the boat, the messaging, and the main loop, plus a way to label who sent each message. That is exactly the foundation Project 4 needs: it gives boats different roles (a scout, and other vessels), runs more than one at a time, and starts routing their messages on separate topics so they do not read each other's traffic. The modular shape is what makes that possible without rewriting everything each time.


3.4 Project 4: Multi-vehicle support

Project 4 in the code repository

Projects 1 to 3 always talked to one boat. Project 4 is where the fleet appears: the same program now runs several times side by side, one copy per boat, and each copy is told which boat it is when you start it. The code is built for a small fleet: a scout, the lead boat of the later scenarios, and the vessels vessel1, vessel2, and vessel3. In this project you run just two of them, the scout and vessel1, with the extra vessels joining in the later scenarios. Each copy does exactly what Project 3 did, read telemetry and publish it, so there is very little new code. The two new ideas are how one code base serves a whole fleet, and how the whole summer school can share one broker without getting in each other's way.

The code. 4_proj holds the same three files as Project 3, with one addition in main.py:

Each role maps to one simulator instance:

Role SITL instance System id Its Python port (in .env)
scout 0 1 14551
vessel1 1 2 14561
vessel2 2 3 14571
vessel3 3 4 14581

The instance numbering is the same idea as Lab 03, each extra instance moves the boat's default ports up by ten, and the Python port adds one small step on top: every boat's script reads its instance default plus one (14551, 14561, and so on), keeping the plain 14550 free for the ground station, which all boats feed. Each boat also carries its own system id in every message it sends. Ports and ids solve two different problems, and it is worth keeping them apart in your head: the port keeps the simulators, the scripts, and the ground station from fighting over the same network door on your machine, while the system id is the boat's identity inside the messages themselves, how anything that receives traffic from several boats tells them apart.

Your fleet's namespace. Everyone in the summer school lab runs this project against the same broker, and most of you will be running it at the same time. Out of the box, every copy of the code would publish to the very same topics, so you would be reading other people's telemetry mixed into your own. With telemetry that is merely confusing; in the later projects boats also receive commands over MQTT, and a command on a shared topic would reach every boat listening there at once, moving boats their owners never meant to move. The fix is to give each running fleet, meaning one person's scout and vessels, its own corner of the topic tree. The prefix has two parts, your team id and your surname, because a team id alone is not enough: several members of the same team will be running their own fleets at the same time.

You set this up in two lines, because the topics in .env are written as templates:

TEAM_NS=team1
FLEET_NS=${TEAM_NS}/yoursurname

SCOUT_POSITION_TOPIC=${FLEET_NS}/scout/position
VESSEL1_POSITION_TOPIC=${FLEET_NS}/vessel1/position
SCOUT_MQTT_USERNAME=${TEAM_NS}

The ${...} notation is a small feature of the python-dotenv library: when it loads the file, it replaces ${TEAM_NS} and ${FLEET_NS} with the values you set on those two lines. Set them once and every topic and login below follows along; there is no way to update half your topics and forget the other half.

Set your team id and your name. Open .env and make two edits. First set TEAM_NS to the team id you were given in the session (for example team3); it selects the broker login your team shares from this project on (the password is given in the session). Then, on the FLEET_NS line just below, replace yoursurname with your own surname in lowercase latin letters; that makes the topic prefix yours alone. Topics are case-sensitive, kogias and Kogias would be two different fleets, so stay lowercase. If you later run boats together with other students, everyone joining the shared fleet sets the same FLEET_NS; for now, use your own.

One thing to be clear about: the separation comes from the topics, not from the login. The broker delivers a message only to programs subscribed to its topic, so team3/smith/... traffic never reaches code that subscribed to team3/jones/.... The login says which team you belong to; the topic prefix is what keeps the running fleets apart.

Run it. This project uses more windows than before: one terminal per simulator, one terminal per Python, and Mission Planner as the single map that shows the whole fleet. Two boats are enough to see every new idea, the scout and vessel1; a third and fourth follow the same pattern.

Terminals 1 and 2 are the two simulators, started exactly as in Multi-vehicle SITL in the Lab 03 notes: each boat in its own folder, no map or console windows, boat 2 with its own instance, system id, and start point, and every boat sending its two usual outputs, the ground station's copy and its own Python's copy. As a reminder, here are the commands for the usual Windows and WSL2 setup. Replace YOUR_WINDOWS_IP with your own Windows host address (Lab 03 section 8 shows how to find it; it changes between reboots, so check it fresh today):

# Terminal 1, the scout's boat
cd ~/maritime26/sitl-test
sim_vehicle.py -v Rover -L Syros \
  --add-param-file=$HOME/maritime26/custom-parms/boat.parm \
  --out=udp:YOUR_WINDOWS_IP:14550 --out=udp:127.0.0.1:14551

# Terminal 2, vessel1's boat
cd ~/maritime26/sitl-test2
sim_vehicle.py -v Rover --instance 1 --sysid 2 -L Syros2 \
  --add-param-file=$HOME/maritime26/custom-parms/boat.parm \
  --out=udp:YOUR_WINDOWS_IP:14550 --out=udp:127.0.0.1:14561

Each boat sends its data out twice: the two 127.0.0.1 outputs are what your Python copies will read (notice they match the SCOUT_CONNECTION_STRING and VESSEL1_CONNECTION_STRING values in .env), while both boats' other output feeds the ground station on 14550. Wait until each console reports a GPS fix.

Mission Planner sees the whole fleet on one connection. Connect once, UDP on port 14550, and both boats appear. A port that a program listens on belongs to that one program, but any number of boats can send to it, and Mission Planner sorts the arrivals by system id. The selector at its top right lists them as UDP14550-1-SURFACE BOAT and UDP14550-2-SURFACE BOAT; the number after the port is the system id from the table above.

Ground station on the same machine as the scripts (native Linux, macOS, or mirrored WSL2). On these setups everything shares one network space, but the port convention already keeps the peace: 14550 belongs to the ground station, and every script reads its own boat's private port. The only change to the commands above is where the ground-station copies point: use --out=udp:127.0.0.1:14550 on both boats instead of the Windows address, and Mission Planner or QGroundControl finds the whole fleet on its usual port by itself. Everything else in this project is identical.

Terminals 3 and 4 are the two Pythons, from the code folder with the environment active, one role each:

# Terminal 3
cd ~/lab04-companion/code
source ss_venv/bin/activate
python 4_proj/main.py scout
# Terminal 4, same folder, environment also active
python 4_proj/main.py vessel1

What you will see. Each terminal announces its role, connects to the broker and to its own boat, and publishes on its own topic:

Starting scout vessel...
TLS disabled, using non-secure connection
Connecting to smartmove-local.syros.aegean.gr:1883 ...
Successfully connected to MQTT broker
Connecting to vehicle on: udp:127.0.0.1:14551
Successfully published to MQTT topic: team1/kogias/scout/position
Timestamp: 02/07/2026 - 10:44:15, Heading: 180 degrees, Ground Speed: 0.03 m/s, Latitude: 37.4393258, Longitude: 24.9456158, USER: team1

The vessel1 terminal shows the same shape with its own topic and its own position. At the broker, one subscription to your fleet's whole subtree shows both boats together:

mosquitto_sub -h smartmove-local.syros.aegean.gr -p 1883 \
  -u <team login> -P <team password> -t 'team1/kogias/#' -v
team1/kogias/scout/position Timestamp: 02/07/2026 - 10:44:15, Heading: 180 degrees, Ground Speed: 0.03 m/s, Latitude: 37.4393258, Longitude: 24.9456158, USER: team1
team1/kogias/vessel1/position Timestamp: 02/07/2026 - 10:44:16, Heading: 180 degrees, Ground Speed: 0.03 m/s, Latitude: 37.4391456, Longitude: 24.9453889, USER: team1

Two boats, two topics, one namespace. The positions differ because the boats start about 25 m apart, and while the USER: field shows the shared team login, it is the topic path that says which boat is talking.

The # is a wildcard. In a subscription, # matches everything under the prefix, however deep, so team1/kogias/# means every topic your fleet publishes: the positions now, and the command topics in the later projects. Subscribing to team1/# instead would show every running fleet in your team. Wildcards work only in subscriptions; a publish always goes to one exact topic. The single quotes around it just stop the terminal from treating # as the start of a comment.

Drive a boat and watch the right topic move. In Mission Planner, pick a boat in the top-right selector, put it in GUIDED and arm it (from the Actions tab, or by typing mode GUIDED and arm throttle at that boat's own terminal prompt as in Lab 03), then right-click the map and choose Fly To Here. Only the topic of the boat you drove changes its numbers; the other keeps reporting the same parked position. The selector works because every command Mission Planner sends carries the chosen boat's system id inside it, and each boat acts only on commands addressed to it.

Mission Planner's altitude question. When you click Fly To Here, Mission Planner asks for an altitude. Type a whole number, 1 is fine, and press OK; the frame choice (Absolute or Relative) does not matter. A boat ignores altitude anyway, but Mission Planner does not: give it 0 and it drops your click silently, the dialog closes and nothing is sent, which looks exactly like a boat refusing to move. It also refuses decimal values like 0.1; that number is only for the MAVProxy map's own altitude prompt, where it is the normal choice in these labs.

If it breaks.

Where this leads. A fleet of boats now publishes side by side, each under its own role, inside your fleet's own namespace. The message itself is still one long human-readable string though, easy for a person, awkward for a program. Project 5 swaps it for JSON, the standard machine-readable format, and adds the other half of MQTT: boats that subscribe and react to what they hear.


3.5 Project 5: JSON communication

Project 5 in the code repository

So far the boats talk and nobody listens. Every project up to now ends the same way: a boat publishes its position, and the only listener is you, watching a terminal or mosquitto_sub. Project 5 adds the other half of MQTT. The follower vessels now subscribe to the scout's position topic, so each follower both publishes its own telemetry and receives the scout's, boat to boat, with no person in the middle. And because a program that receives a message needs to pull numbers out of it rather than read it like a person, the message itself changes shape too: the long human-readable string becomes JSON, the standard text format for structured data.

JSON in one paragraph. JSON (JavaScript Object Notation) is a text format that nearly every programming language can read and write. It looks almost exactly like a Python dictionary written out as text: {"heading": 179, "boat": "scout"}. Python turns a dictionary into JSON text with json.dumps() and text back into a dictionary with json.loads(), and values keep their types on the trip, so a number arrives as a number. That last part is what makes Project 7 possible, where a follower does arithmetic on positions it received over the network.

The code. 5_proj keeps the two helper classes and replaces the single main.py with two entry scripts, one per kind of role:

In Project 4 every boat behaved identically, so one script with a role argument served them all. Now the two kinds of boat genuinely behave differently, so each gets its own small entry script, and everything they share still lives in the two helper classes. That is the Project 3 split paying off again.

Three changes inside the helpers make this work:

Receiving without waiting. subscribe() registers a callback, a function the MQTT library calls for you whenever a message arrives on that topic. Remember from Project 2 that loop_start() runs the MQTT side on a background thread; that thread is what receives the scout's message and runs the callback, while the main loop keeps publishing every five seconds, undisturbed. The built-in callback decodes the message with json.loads() and prints just the latitude and longitude from it, and subscribe() also accepts your own function in its place if you want different behaviour, which is exactly what a later project will do.

Run it. Terminals 1 and 2 are the same two simulators as Project 4, started exactly the same way, and Mission Planner connects the same way too if you want the map. Terminals 3 and 4 run the two new scripts. Start the scout first so the follower has something to hear from its first seconds; either order works, the follower simply stays quiet until the scout speaks.

# Terminal 3, the scout
cd ~/lab04-companion/code
source ss_venv/bin/activate
python 5_proj/scout.py
# Terminal 4, same folder, environment also active
python 5_proj/vessel.py vessel1

What you will see. The scout terminal looks like Project 4, except the published line is now JSON:

Starting scout vessel...
TLS disabled, using non-secure connection
Connecting to smartmove-local.syros.aegean.gr:1883 ...
Successfully connected to MQTT broker
Connecting to vehicle on: udp:127.0.0.1:14551
Successfully published to MQTT topic: team1/kogias/scout/position
{"timestamp": "02/07/2026 - 15:01:25", "heading": 179, "ground_speed": 0.03, "latitude": 37.4393256, "longitude": 24.945616, "boat": "scout"}

The follower terminal is the interesting one: between its own publishes, the scout's position keeps arriving:

Starting vessel1 vessel...
TLS disabled, using non-secure connection
Connecting to smartmove-local.syros.aegean.gr:1883 ...
Successfully connected to MQTT broker
Connecting to vehicle on: udp:127.0.0.1:14561
Successfully published to MQTT topic: team1/kogias/vessel1/position
{"timestamp": "02/07/2026 - 15:01:25", "heading": 180, "ground_speed": 0.02, "latitude": 37.4391454, "longitude": 24.9453891, "boat": "vessel1"}
Received message on topic team1/kogias/scout/position:
  Latitude: 37.4393256, Longitude: 24.945616

Notice the asymmetry. The follower prints its own message in full, exactly the JSON that went out, but prints only two fields of what it received. That is the built-in callback picking the fields it cares about out of the parsed JSON, and it is the practical payoff of the format change: a program can now reach into a message and take just the numbers it needs.

At the broker, the same wildcard subscription as Project 4 shows the whole fleet's traffic, now as JSON:

mosquitto_sub -h smartmove-local.syros.aegean.gr -p 1883 \
  -u <team login> -P <team password> -t 'team1/kogias/#' -v
team1/kogias/scout/position {"timestamp": "02/07/2026 - 15:01:25", "heading": 179, "ground_speed": 0.03, "latitude": 37.4393256, "longitude": 24.945616, "boat": "scout"}
team1/kogias/vessel1/position {"timestamp": "02/07/2026 - 15:01:25", "heading": 180, "ground_speed": 0.02, "latitude": 37.4391454, "longitude": 24.9453891, "boat": "vessel1"}

One scout, any number of listeners. The scout never learns who subscribes. Add vessel2 and vessel3 in two more terminals and each receives the same position stream, with no change to the scout at all; the broker makes the copies. This decoupling of sender from receivers is the core idea of publish/subscribe, and it is what will let a whole formation follow one leader in Project 7.

Split the fleet across laptops. The two scripts do not need to share a machine, and trying that needs nothing new: every laptop already has the code, the environment, and a .env from Section 2. Pick who plays which role. The scout's laptop starts its own simulator with Project 4's Terminal 1 command and runs python 5_proj/scout.py; the follower's laptop starts its own simulator with the Terminal 2 command (the --instance 1 one) and runs python 5_proj/vessel.py vessel1. The one thing that must match is the namespace in the two .env files: decide whose fleet you are running, and both laptops set that same FLEET_NS, surname included, so the follower's subscription finds the scout's topic. Watching the Received message lines arrive from a different computer makes it very concrete that this is real networking, not just two windows on one screen.

If it breaks. Everything in Project 4's list still applies: the roles and their .env blocks, one folder and instance per simulator, the team login. New in this project:

Where this leads. Boats now exchange structured data that programs can act on, and that changes what a message can be. Project 6 introduces a second kind of topic: commands. A vessel will subscribe to its own command topic and obey JSON messages such as follow and stop, which means a message will no longer just inform, it will make a boat do something. Project 7 then closes the loop by turning the scout positions the followers are already receiving into movement.

3.6 Project 6: Command processing

Project 6 in the code repository

Until now every message was a report; this project adds messages that are orders. Everything the boats have exchanged so far is telemetry, one boat informing another. Project 6 introduces a second kind of topic, commands: each follower now also subscribes to its own command topic (team1/kogias/vessel1/commands in our example fleet) and reacts to instructions such as follow and stop arriving there. The sender is you: any terminal with mosquitto_pub becomes a small command center. And deliberately, nothing moves yet. The vessel only confirms, loudly, that each command was received and understood. Building and testing the communication channel first and wiring in the behaviour after (that is Project 7) is a strategy worth copying: when something misbehaves later, you already know the channel works, so the bug must be in the behaviour.

The code. scout.py and vessel_controller.py are unchanged from Project 5; the changes live in vessel.py and MQTTHandler:

Agree on the message format. A command here is either a small JSON object with a command field, {"command": "follow"}, or the bare word, follow. The broker does not enforce that; a topic carries whatever bytes someone publishes, so the format is simply a convention between sender and receiver, and this code assumes everyone follows it. It does not defend against creative input; a payload shaped like nothing it expects can confuse it or even crash the listening script. That is a deliberate choice to keep the example short and readable. A production system would validate every incoming message before acting on it.

Run it. Terminals 1 and 2 are the same two simulators as Projects 4 and 5. Terminals 3 and 4 run this project's scripts:

# Terminal 3, the scout
cd ~/lab04-companion/code
source ss_venv/bin/activate
python 6_proj/scout.py
# Terminal 4, same folder, environment also active
python 6_proj/vessel.py vessel1

The scout terminal looks exactly like Project 5. The follower's startup is where to look; it now reports two subscriptions:

Starting vessel1 vessel...
TLS disabled, using non-secure connection
Connecting to smartmove-local.syros.aegean.gr:1883 ...
Successfully connected to MQTT broker
Connecting to vehicle on: udp:127.0.0.1:14561
Subscribed to topic: team1/kogias/scout/position
Subscribed to topic: team1/kogias/vessel1/commands

Send your first command. Open one more terminal; any machine with mosquitto_pub will do, the same laptop or a different one, the broker does not care:

# Terminal 5, the command center
mosquitto_pub -h smartmove-local.syros.aegean.gr -p 1883 \
  -u <team login> -P <team password> \
  -t team1/kogias/vessel1/commands -m '{"command": "follow"}'

In the follower's terminal, in between the usual telemetry lines:

Received message on topic team1/kogias/vessel1/commands:

**************************************************
Command to start following is issued
**************************************************

Try the other paths too. Send -m 'stop', the bare word: the plain-text fallback catches it and the starred block reads "Command to stop following is issued" (this path skips the Received message header line; that line belongs to the JSON branch). Then send something the vessel does not know, say -m 'dance', and it answers "Unknown command received: dance". The scout's terminal shows none of this; the scout subscribes to nothing.

At the broker, the team1/kogias/# wildcard subscription from Project 5 now shows all three kinds of traffic interleaved, two position streams plus your commands:

team1/kogias/scout/position {"timestamp": "02/07/2026 - 15:51:10", "heading": 181, "ground_speed": 0.03, "latitude": 37.4393258, "longitude": 24.9456158, "boat": "scout"}
team1/kogias/vessel1/position {"timestamp": "02/07/2026 - 15:51:11", "heading": 181, "ground_speed": 0.03, "latitude": 37.4391456, "longitude": 24.9453889, "boat": "vessel1"}
team1/kogias/vessel1/commands {"command": "follow"}
team1/kogias/vessel1/commands stop
team1/kogias/vessel1/commands dance

The dress rehearsal for Project 7. Now put the pieces together the way Project 7 will. Get the scout moving, drive it from Mission Planner with a fly-to or a small mission, and while it cruises send vessel1 the follow command again. In the follower's terminal the scout positions keep arriving with visibly changing coordinates, the command is confirmed, and the boat stays exactly where it is. The broker view of a real run tells the story in five lines:

team1/kogias/scout/position {"timestamp": "02/07/2026 - 15:57:13", "heading": 187, "ground_speed": 5.01, "latitude": 37.4376662, "longitude": 24.9446368, "boat": "scout"}
team1/kogias/vessel1/position {"timestamp": "02/07/2026 - 15:57:15", "heading": 179, "ground_speed": 0.03, "latitude": 37.4391454, "longitude": 24.9453892, "boat": "vessel1"}
team1/kogias/vessel1/commands {"command": "follow"}
team1/kogias/scout/position {"timestamp": "02/07/2026 - 15:57:48", "heading": 130, "ground_speed": 4.98, "latitude": 37.436194, "longitude": 24.944657, "boat": "scout"}
team1/kogias/vessel1/position {"timestamp": "02/07/2026 - 15:57:49", "heading": 179, "ground_speed": 0.03, "latitude": 37.4391454, "longitude": 24.9453891, "boat": "vessel1"}

The scout is doing 5 m/s and its heading swings through a turn; vessel1 sits motionless at the same spot. And yet vessel1 now holds everything a follower needs, a live stream of where the leader is and an explicit order to follow. The only missing piece is code connecting the two, and that is exactly what Project 7 adds.

If it breaks. Project 5's list still applies. New in this project:

Where this leads. The channel is proven; the behaviour is next. In Project 7 the follow command stops being a printout: it will arm the boat, switch it to GUIDED, and steer it toward each new scout position, while stop pulls it back to a station-keeping LOITER. The whole convoy behaviour is built out of exactly the two message streams you just watched flow.


3.7 Project 7: Follow the leader

Project 7 in the code repository

Everything so far was preparation for this moment. At the end of Project 6 the follower holds a live stream of scout positions and an explicit order to follow, and acts on neither. Project 7 adds the missing piece, the code that turns those messages into DroneKit calls. On follow the vessel arms itself, switches to GUIDED, and starts chasing the scout's reported positions; on stop it settles into a station-keeping LOITER. One boat leads, the others follow, and nothing connects them but the broker.

The code. scout.py is unchanged; the scout still just publishes. The new behaviour lives in vessel_controller.py, which grows from "connect and read telemetry" into the follower's decision-maker, with vessel.py feeding it:

Why LOITER and not HOLD. ArduPilot's HOLD mode simply stops the thrusters, and a real boat then drifts wherever wind and current push it. LOITER actively holds the GPS position, working the thrusters against whatever moves the boat; you watched it fight the Meltemi in Lab 03's weather section. For a boat, "stop" almost always means LOITER.

Commands run on the message thread, and arming can hang it. The command handler runs inside the MQTT library's background thread, and arm_vehicle() and set_guided_mode() wait in small loops until the autopilot confirms. While they wait, no other message for this vessel is processed; messages queue up behind the command. Normally that is a few seconds and entirely harmless. But if a follow arrives before the simulated boat has its GPS fix, the autopilot rejects the arm, the wait loop never finishes, and the vessel goes deaf: it keeps publishing telemetry, yet never reacts to anything again, and the only fix is restarting the script. So wait for the GPS fix before the first follow. A production system would hand such work to a separate thread and keep the message handler free; we keep the code simple and learn the rule instead.

Run it. Boats first, each with the usual two outputs: one copy for the ground station on 14550, one private copy for the boat's own Python (on the usual Windows and WSL2 setup, replace YOUR_WINDOWS_IP with your own Windows host address; on native Linux, macOS, or mirrored WSL2 that first output becomes 127.0.0.1:14550):

# Terminal 1, the scout's boat
cd ~/maritime26/sitl-test
sim_vehicle.py -v Rover -L Syros \
  --add-param-file=$HOME/maritime26/custom-parms/boat.parm \
  --out=udp:YOUR_WINDOWS_IP:14550 --out=udp:127.0.0.1:14551

# Terminal 2, vessel1's boat
cd ~/maritime26/sitl-test2
sim_vehicle.py -v Rover --instance 1 --sysid 2 -L Syros2 \
  --add-param-file=$HOME/maritime26/custom-parms/boat.parm \
  --out=udp:YOUR_WINDOWS_IP:14550 --out=udp:127.0.0.1:14561

Give both consoles their GPS settle time, half a minute or so, until you see EKF3 IMU0 is using GPS; the note above is why this matters today. Mission Planner watches the whole fleet on 14550 exactly as in Project 4. Terminals 3 and 4 run the scripts:

# Terminal 3, the scout
cd ~/lab04-companion/code
source ss_venv/bin/activate
python 7_proj/scout.py
# Terminal 4, same folder, environment also active
python 7_proj/vessel.py vessel1

The follower's startup now labels each subscription with its QoS:

Starting vessel1 vessel...
Connecting to vehicle on: udp:127.0.0.1:14561
TLS disabled, using non-secure connection
Connecting to smartmove-local.syros.aegean.gr:1883 ...
Successfully connected to MQTT broker
Subscribed to topic: team1/kogias/scout/position (QoS=0)
Subscribed to topic: team1/kogias/vessel1/commands (QoS=1)
Vessel ready. Waiting for commands...

Send the command. One more terminal, the command center, same as Project 6 plus the -q 1:

# Terminal 5, the command center
mosquitto_pub -h smartmove-local.syros.aegean.gr -p 1883 \
  -u <team login> -P <team password> \
  -t team1/kogias/vessel1/commands -m '{"command": "follow"}' -q 1

This time the boat moves. In the follower's terminal (its own telemetry lines trimmed here):

**************************************************
Command to start following is issued
**************************************************

Arming vehicle...
Vehicle armed.
Vehicle is in GUIDED mode. Started following.
Distance to scout: 28.33 meters. Mode: FOLLOWING
Issuing new goto command. Distance to scout: 28.33 meters
Distance to scout: 27.42 meters. Mode: FOLLOWING
Distance to scout: 8.30 meters. Mode: LOITERING
Too close to scout. Loitering to maintain position.
Distance to scout: 2.58 meters. Mode: LOITERING

The boat armed, turned toward the parked scout 28 meters away, closed in, and stopped itself. The last lines deserve some honesty. The ring is checked only when a scout position arrives, once every five seconds, and a boat doing several meters per second covers a lot of water between checks; this one crossed from 27 meters to 8 between two looks, and the LOITER position it captured ended up 2.6 meters from the scout, well inside the 15 the setting asks for. The ring reliably stops the follower from pressing on, which is its job, but treat it as a demonstration of the idea, not a safety system. Project 8 narrows the blind window by publishing positions faster; checking the boat's own GPS continuously instead of waiting for messages is a good exercise beyond that.

Now drive the scout. In Mission Planner select the scout, put it in GUIDED, arm it, and send it a few hundred meters away (Fly To Here, altitude 1). The follower springs back to life (abridged):

Distance to scout: 26.37 meters. Mode: FOLLOWING
Resuming follow mode.
Issuing new goto command. Distance to scout: 26.37 meters
Distance to scout: 42.56 meters. Mode: FOLLOWING
Issuing new goto command. Distance to scout: 42.56 meters
Distance to scout: 63.70 meters. Mode: FOLLOWING
Issuing new goto command. Distance to scout: 63.70 meters
Distance to scout: 90.45 meters. Mode: FOLLOWING
Issuing new goto command. Distance to scout: 90.45 meters
Distance to scout: 87.97 meters. Mode: FOLLOWING
Distance to scout: 67.97 meters. Mode: FOLLOWING
Distance to scout: 44.69 meters. Mode: FOLLOWING
Distance to scout: 20.15 meters. Mode: FOLLOWING
Distance to scout: 3.64 meters. Mode: LOITERING
Too close to scout. Loitering to maintain position.

Two things are visible in the numbers. The gap grows before it shrinks: both boats sail at the same speed (WP_SPEED in boat.parm), and the follower is always steering for a point the scout has already left, so on a long leg it falls back to about 90 meters, then collapses back onto the scout when the scout arrives and stops. And not every report becomes a goto; the Issuing new goto lines appear only when the throttle rules fire, and between them the autopilot is simply left alone to drive.

Stop it and start it again. Send -m 'stop' (plain text still works, as in Project 6): the vessel parks in LOITER right where it is and reports STOPPED. Now drive the scout a few hundred meters away and send follow again. The boat is already armed, so it goes straight to GUIDED and issues exactly one goto for the entire return trip: the scout is parked, so the movement gate blocks every repeat, and the distance line simply counts down, report by report, until the ring closes the show.

Scale the fleet. Everything doubles mechanically. A third simulator and a third script:

# Terminal 6, vessel2's boat
mkdir -p ~/maritime26/sitl-test3
cd ~/maritime26/sitl-test3
sim_vehicle.py -v Rover --instance 2 --sysid 3 -L Syros3 \
  --add-param-file=$HOME/maritime26/custom-parms/boat.parm \
  --out=udp:YOUR_WINDOWS_IP:14550 --out=udp:127.0.0.1:14571
# Terminal 7, from the code folder, environment active
python 7_proj/vessel.py vessel2

The code never changed; only the role argument did, and with it the boat, the ports, the topics, and the command channel. Send follow to both vessels and drive the scout: two boats chase it. Then send stop to vessel1 only, while the scout is still moving: vessel2 keeps chasing and vessel1 parks, each obeying only its own command topic. That is the per-vessel topic design from Project 6 doing exactly what it was built for. vessel3 follows the same recipe if your laptop is comfortable with four simulators; adding it at home is a good exercise.

One more thing to watch on the map at the end of a chase: every follower is aiming at the same point, the scout's reported position, so they finish practically on top of the scout and of each other. The simulator has no collision physics; real hulls very much do. Keep that image in mind, it is the problem the next project exists to solve.

If it breaks. The lists from Projects 4 to 6 still apply (roles and .env blocks, one folder and instance per simulator, the fleet namespace, shell quoting). New in this project:

Where this leads. The convoy works, and the run that proves it also shows its two honest flaws: followers pile onto the scout's exact position, and the keep-out ring only reacts when a message happens to arrive. Project 8 addresses both with surprisingly little new code: each vessel gets its own station in a formation, held relative to the scout's heading, and the telemetry rate becomes a setting instead of a number hardcoded in the loop.

3.8 Project 8: Formation keeping

Project 8 in the code repository

Project 7 ends with a pile-up. Every follower aims at the same point, the scout's reported position, so a successful chase finishes with the whole fleet stacked on top of its leader. Project 8 fixes that with one idea: every vessel gets its own station, a reserved place in a formation around the scout, and aims there instead. As promised, it takes surprisingly little new code; one new method, a table of angles, and two settings.

The code. mqtt_handler.py is untouched, and scout.py and vessel.py change in one small way each; the real work is in vessel_controller.py:

Everything else, the commands and topics, the QoS split, arming and mode switching, the goto throttle, and the movement gate, is Project 7 code, untouched.

Run it. Start the same boats as in Project 7 (the launch commands are there; vessel3's simulator follows the same recipe with instance 3, sysid 4, location Syros4, and Python port 14581), give them their GPS settle time, then run the Project 8 scripts:

# One terminal per script, from the code folder, environment active
python 8_proj/scout.py
python 8_proj/vessel.py vessel1
python 8_proj/vessel.py vessel2
python 8_proj/vessel.py vessel3    # optional, the astern station

Send follow to each vessel from the command center, exactly as in Project 7:

mosquitto_pub -h smartmove-local.syros.aegean.gr -p 1883 \
  -u <team login> -P <team password> \
  -t team1/kogias/vessel1/commands -m '{"command": "follow"}' -q 1

and the same for vessel2 and vessel3 on their own command topics (plain-text follow still works too). Here is vessel2 arriving on station, with the scout still parked (telemetry lines trimmed):

**************************************************
Command to start following is issued
**************************************************

Arming vehicle...
Vehicle armed.
Vehicle is in GUIDED mode. Started following.
Distance to scout: 56.66 meters. Mode: FOLLOWING
Issuing new goto command. Distance to scout: 56.66 meters
Distance to scout: 48.07 meters. Mode: FOLLOWING
Distance to scout: 33.36 meters. Mode: FOLLOWING
Distance to scout: 27.17 meters. Mode: FOLLOWING
Distance to scout: 29.74 meters. Mode: FOLLOWING
Distance to scout: 29.79 meters. Mode: FOLLOWING

One goto, a straight drive, a small overshoot as it brakes, and the distance line settles at the station distance. Read that against Project 7, where the same line counted down to the keep-out ring: in a working formation, "Distance to scout" simply reads FORMATION_DISTANCE.

Vessel1, though, does not make it, and the reason is worth understanding. The simulators spawn our boats in a diagonal line, and that happens to put vessel1 on the exact opposite side of the scout from its assigned station. The code does the only thing it knows, a straight line to the goal, and that line passes right over the scout:

Distance to scout: 28.33 meters. Mode: FOLLOWING
Issuing new goto command. Distance to scout: 28.33 meters
Distance to scout: 16.04 meters. Mode: FOLLOWING
Too close to scout. Loitering to maintain position.
Distance to scout: 1.76 meters. Mode: LOITERING
Distance to scout: 4.75 meters. Mode: LOITERING

The keep-out ring fires, which is its job. But a boat doing five meters per second needs braking distance, so it comes to rest not at 15 meters but at 4.75, well inside its own safety zone. And there it stays: the rule for resuming is "farther than 15 meters", both boats are parked, and a distance between two parked boats never changes. Vessel1 sits next to the scout until the scout moves. (If you carried straight on from Project 7 without restarting the simulators, expect this times three: the boats begin piled on the scout from the last chase, so every follower starts inside the ring and loiters where it is.) Nothing is broken; the vessel aims at a point and guards a distance, exactly as written. It just has no concept of going around anything. Hold that thought for the closing notes below, and meanwhile do what the fleet is waiting for: move the leader.

Drive the scout, as in Project 7 (select it in Mission Planner, GUIDED, arm, fly to a point a few hundred meters away, altitude 1). Vessel1's distance opens past 15 meters and it frees itself (abridged):

Resuming follow mode.
Issuing new goto command. Distance to scout: 16.88 meters
Issuing new goto command. Distance to scout: 36.31 meters
...
Issuing new goto command. Distance to scout: 90.85 meters
Issuing new goto command. Distance to scout: 40.78 meters
Issuing new goto command. Distance to scout: 29.90 meters
Distance to scout: 28.97 meters. Mode: FOLLOWING
Distance to scout: 28.97 meters. Mode: FOLLOWING

The middle of the chase is Project 7's familiar shape, the follower falling back to about 90 meters because both boats sail at the same speed, then collapsing back as the scout arrives and stops. The ending is what changed: the follower now stops at 29 meters, on station, instead of ramming the ring at 3. Watch the same story on the map, where Project 8 really pays off: all three vessels swing into a triangle trailing the scout, and when you send the scout off in a new direction the stations swing with its heading and the formation re-forms behind it. During a sharp turn the followers cut the corner rather than tracing the scout's arc; they chase station points, they do not follow paths. When the scout stops, the formation parks around it, held in place by the movement gate from Project 7.

If it breaks. The Project 7 list still applies. New in this project:

What Project 8 is, and is not. It fixes Project 7's pile-up and looks great doing it, but it is a teaching project, not a fleet product. Three honest gaps remain, and they are good take-home projects rather than failures:

One refinement is specific to the simulator: our spawn points put the fleet in a diagonal line that guarantees vessel1's awkward first transit. A formation-ready set of start locations, custom spawn points placed roughly on station, would make the parked-scout start clean. We left it out to keep Project 8 simple, but writing your own locations.txt entries is a five-minute improvement, and Lab 03 shows how.

4 Possible advanced challenges

Nine projects ago, your first program connected to one simulated boat and printed its position. Now a small fleet shares telemetry through a broker, takes commands, follows a leader, and holds formation behind it. Before the ideas below, it is worth being clear about what you have built, and what it is not.

We stopped at this level on purpose, and partly for the simplest of reasons: nine projects is already a lot for a three-hour lab. Each project is the smallest version of its idea that works. Parts are missing, and the code is plain and readable rather than polished and complete. You met the gaps honestly along the way: the keep-out ring is soft, the followers ignore each other, a station is a point rather than a planned route. On real boats we would be far more conservative and add protection at every level. None of this is an oversight; a lab that tried to be complete would have been one you read, not one you built.

What you have is a starting ground. A working system you understand end to end: a simulator, an autopilot, your own Python, messaging, and a small fleet that does something real. Every piece is small enough to change with confidence, and a crashed simulated boat costs exactly nothing. The rest of this section is a list of directions to take it; the best one is whichever you catch yourself thinking about on the ferry home.

4.1 Project 9: missions from code

In Lab 03 you built missions by hand: place waypoints in Mission Planner, upload them, switch to AUTO, and the boat works through the list. Project 9 is the same journey done from Python, and it is the one project in this lab with no folder in the repo. It is a walk-through of the ideas, not finished code; finding the exact calls is part of the exercise, and DroneKit's documentation pages on missions are good.

The shape of the project:

Two habits from the earlier projects apply double here. Start tiny, two or three waypoints, and grow the mission only after the whole round trip works. And verify each step on its own: after the upload, confirm the mission actually arrived (wp list in MAVProxy, or Mission Planner's plan screen) before you blame the AUTO switch.

Once it works, it connects to everything you already have. A mission arriving as an MQTT command is Project 6's pattern with a bigger payload. A vessel that leaves the formation, runs a survey pattern, and rejoins combines Projects 8 and 9, and that combination is most of what a real survey fleet does all day.

4.2 Ideas to explore

Every idea here can start in SITL exactly as the lab left you, with no new hardware. Closest to home, extending the fleet you have:

Then the unglamorous ideas that real operations are made of:

And further out, bringing in new inputs:

One last direction is about the code itself rather than the boats. DroneKit made this lab friendly (vehicle.location, and you have a position), but it is old; that is why §2's one-line patch exists. If your ambitions outgrow it, the natural next step is a small connection class of your own built on pymavlink, the lower-level library DroneKit itself sits on. Writing one teaches you exactly what DroneKit has been doing on your behalf, and it frees your code from a library that is no longer maintained.

5 From SITL to real-world deployment

Section 1 promised that the code you wrote here moves onto a real boat essentially unchanged, and by now you can see why. Nothing in your Python cares whether "the autopilot" is a simulator on your laptop or a Pixhawk in a hull. What changes is the plumbing: how your program reaches the autopilot, and where your program runs. This closing section maps that road, so a first real deployment holds no surprises.

5.1 A cable instead of a network

On your laptop, every project reached the autopilot over the network, udp:127.0.0.1:14551. On the real boat there is no network between them: the Raspberry Pi connects to the Pixhawk with a USB cable, and the autopilot appears in Linux as a serial device, typically /dev/ttyACM0 (or /dev/ttyUSB0, depending on the hardware). The connection string becomes that device path, plus a baud rate, the speed setting of a serial link. connect() accepts this form just the same, and because every project reads its connection string from .env, the whole transition is a settings edit, not a code change. That is the quiet payoff of a habit the lab enforced from Project 1.

One trap waits here, and it is worth knowing by name. Linux hands out serial device names in detection order, so the same autopilot can be /dev/ttyACM0 today and /dev/ttyACM1 tomorrow, after a reboot or a different USB port, and your connection string quietly points at nothing. The fix is a udev rule (udev is the part of Linux that names devices): one line that matches the board's own identity, its vendor, product, and serial number, and gives it a permanent name such as /dev/pixhawk1 that follows that physical board whatever port it is plugged into. The procedure in one breath: read the identifiers with lsusb and udevadm info, write the rule, replug the cable, and check the name appears.

A well-trodden path. Persistent naming for USB serial devices is a standard Linux recipe; this tutorial walks through it step by step. On a boat it is not optional polish: nobody can SSH into a vessel mid-mission to fix a device name.

5.2 Living on a Raspberry Pi

The Pi is an ordinary Linux computer, so §2 is already the setup guide for it: clone the repo, create the venv, install the requirements, apply the patch. The real differences come from the life the computer leads out there, not from the software:

5.3 Real water changes the rules

Everything in this lab trusted its inputs. Positions arrived over MQTT and were believed, commands were obeyed, and the one safety measure we built was the keep-out ring, and we saw how soft it was. That was the right trade for learning, and it is the wrong trade for open water. So the last lesson of the lab is the deployment mindset:

And that is the whole arc. Lab 03 gave you a simulator and an autopilot. This lab put your own code on top and grew it from one line of telemetry to a coordinating fleet. The road in this section is the one the real SmartMove boats crossed, the same Raspberry Pis, the same broker pattern, the same code shapes, out on actual water. Take the repo home, keep SITL installed, and go break some simulated boats; it is the cheapest sea time you will ever get.


↑ Top