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.
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.
By the end of the lab you will be able to:
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.
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:
Three habits run through the whole lab:
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.
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 codeYou 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/activateCheck 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 --versionWhen 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.
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.txtWhat you just installed:
.env file, so things like the broker address and passwords
stay out of your code.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.
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__.pyIn 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:
ss_venv, apply it again.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.
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 .envA 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.
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:
ss_venv environment activated.Start SITL first, give it a few seconds to get a GPS fix, then run your script in the second terminal.
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:
Connecting:
vehicle = connect('udp:127.0.0.1:14551', wait_ready=True)udp:127.0.0.1:14551 is the local address SITL sends your
script's copy of the boat's data to. It is one above the familiar 14550
on purpose: in these labs, plain 14550 always belongs to the
graphical ground station (Mission Planner or QGroundControl),
and each boat's Python reads its own private copy one port up, so the
two never fight over the same door. wait_ready=True tells
DroneKit to wait until the boat has sent its full set of parameters
before going on, so you never act on half-loaded data.
Reading telemetry: once connected, DroneKit
hands you the readings as plain attributes on the vehicle
object: vehicle.heading, vehicle.groundspeed,
and vehicle.location.global_frame.lat / .lon.
The script prints these in a loop, once every 5 seconds.
Clean exit: the
try / except KeyboardInterrupt / finally block means
pressing Ctrl-C stops the loop, and the finally always runs
vehicle.close(), so the connection is never left
hanging.
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 --mapThe 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.pyWhat 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.
udp:127.0.0.1:14551. Check that the SITL terminal is up and
that your launch command includes the
--out=udp:127.0.0.1:14551 part.ModuleNotFoundError or another import
error. The virtual environment is not active. Run
source ss_venv/bin/activate (your prompt should show
(ss_venv)), and make sure you applied the DroneKit fix from
Section 2.Warning, time moved backwards. Restarting timer. That is
the system clock being nudged back into sync, which is common under
WSL2, for example after the laptop sleeps. MAVProxy resets its own timer
and carries on; the boat is unaffected.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.
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:
leader/position reads as "the position
messages of leader". Later projects lean on this to give
every fleet, and every boat in it, a tidy branch of its own.leader/position", and from then on it
receives every message published to that topic.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:
smartmove-local.syros.aegean.gr1883You 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:
load_dotenv(...) loads your .env, and the
broker address, port, username, and password are read from it with
os.getenv(...). Keeping them in .env instead
of in the code is why the same script works for everyone without editing
it.client.loop_start(). That last call runs the MQTT
side on a background thread, so publishing never holds up the telemetry
loop. The mqtt.CallbackAPIVersion.VERSION2 in the
constructor is a required flag that tells the library which style of
callback functions your code is written for; version 2 is the current
style, and every project here uses it.client.publish('leader/position', payload) hands it to the
broker on the topic leader/position, once every 5 seconds.
publish() returns a small receipt object, and the script
checks its result code: success means the client accepted the message
and queued it for sending, while the usual failure code means the broker
connection is down.finally block now also
stops the MQTT loop and disconnects the client, alongside closing the
vehicle.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.pyWhat 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 -vUse 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.
.env, that your
username and password are correct, and that you are connected through
eduroam; the broker cannot be reached from the internet or from the
campus guest WiFi. The vehicle side is independent, so Project 1's
checks still apply if the boat itself will not connect.leader/position), with working credentials. A firewall
between you and the broker can also block it.ModuleNotFoundError: paho. The virtual
environment is not active, or the libraries are not installed. Run
source ss_venv/bin/activate and, if needed,
pip install -r requirements.txt.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.
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:
vessel_controller.py holds the
VesselController class: everything to do with the boat
(connecting, reading telemetry, closing the link).mqtt_handler.py holds the
MQTTHandler class: everything to do with MQTT (connecting
to the broker, publishing, disconnecting).main.py holds only the application
flow: it creates one of each, then runs the same read-then-publish loop
as before.┌──────────────────────────┐
│ 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.
VesselController takes a connection string (default
udp:127.0.0.1:14551) and connects in __init__,
exactly as in Project 1. get_telemetry() returns the same
readable line of timestamp, heading, speed, and position, and
close_connection() closes the link.MQTTHandler reads the broker settings from
.env and connects in __init__, just like
Project 2. publish(payload) sends the line on the topic
leader/position, and disconnect() stops the
client cleanly.main() creates a VesselController and an
MQTTHandler, then loops: get telemetry, publish it, wait 5
seconds. The same try / except KeyboardInterrupt / finally
as before means Ctrl-C still shuts both down tidily.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.pyRunning 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 -vUse 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.
ModuleNotFoundError: No module named 'mqtt_handler'
(or vessel_controller). You are running
main.py from the wrong place, so Python cannot find the two
helper files next to it. Run it as python 3_proj/main.py
from the code folder, or cd 3_proj first and
run python main.py; the three files must be found
together..env, your credentials, and that you
are on eduroam.ModuleNotFoundError: paho or
dronekit. The virtual environment is not active.
Run source ss_venv/bin/activate, and make sure the DroneKit
fix from Section 2 is in place.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.
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:
main.py uses
argparse, Python's standard tool for reading command-line
arguments. You start the script as python main.py scout or
python main.py vessel1; the word after the script name is
the role, and it must be one of scout,
vessel1, vessel2, vessel3..env: VesselController reads
SCOUT_CONNECTION_STRING (or
VESSEL1_CONNECTION_STRING, and so on) to know which boat to
connect to, and MQTTHandler reads the matching
_MQTT_USERNAME, _MQTT_PASSWORD, and
_POSITION_TOPIC. So .env works like a fleet
roster: one block of settings per role, and the role name picks the
block. The code never changes; only the argument does.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:14561Each 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 vessel1What 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/#' -vteam1/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.
Missing connection string for role: ... or
Missing required MQTT configuration. The role's
block is missing from .env, or the role word on the command
line is mistyped. Check both, and that your .env was copied
from the current .env.example.--instance 1, or it was started in the same folder
as boat 1. Each boat needs its own folder and its own instance
number.output at that boat's prompt and check the list, exactly as
in Lab 03 section 8.--out ports and the connection strings do not line up; each
script must read the port its own boat sends to (see the table
above).TEAM_NS does not match a real team login, or the password
differs from the one given in the session.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.
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:
scout.py runs the scout. It takes no
argument, there is only one scout, and it only publishes, exactly as in
Project 4.vessel.py runs a follower, with the
role argument as before: vessel1, vessel2, or
vessel3. It publishes its own telemetry too, and in
addition subscribes to the scout's position topic.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:
get_telemetry() in VesselController now
returns the telemetry as a Python dictionary instead of
building a formatted string.publish() in MQTTHandler takes that
dictionary, adds a "boat" field carrying the role name in
lower case, turns the whole thing into JSON text with
json.dumps(), and publishes that. The "boat"
field is there so a program reading the payload knows which boat is
talking; remember the login is the whole team's, so it cannot tell boats
apart.subscribe() method in MQTTHandler is
the receiving side. vessel.py calls it as
mqtt_handler.subscribe('SCOUT_POSITION_TOPIC'); notice you
hand it the name of a .env setting, not a topic,
and it looks the topic up the same way the handler looks up all its
other settings. Thanks to the namespace templates, that resolves to your
fleet's own topic, team1/kogias/scout/position in our
example: your own scout and nobody else's.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 vessel1What 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/#' -vteam1/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:
Received message .... The scout is not running, or
the two scripts are not under the same fleet prefix. If they run on
different laptops, both .env files must set the same
FLEET_NS and point at the same broker.Received invalid JSON message.
Something is publishing plain text on the scout's topic, most often a
Project 4 script still running from earlier. Stop the old script; the
callback survives the bad message either way.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.
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:
vessel.py subscribes to a list of two
topics instead of one: the scout's position topic as before, plus its
own command topic (the .env name
VESSEL1_COMMANDS, resolving to
team1/kogias/vessel1/commands). Each follower listens on
its own command topic; an order to vessel1 is not an order to the
fleet.subscribe() in MQTTHandler accepts that
list, and in this version always routes messages to the handler's
built-in on_message (the optional pass-your-own-function
parameter from Project 5 is gone here; it returns in Project 7).on_message grows from "print the position" into a small
dispatcher. It first tries to parse the message as
JSON: a parsed message carrying latitude and longitude is a position and
prints as before, while a message on a topic ending in
commands goes to handle_command(). If the JSON
parse fails and the message came from a command topic, the raw text
itself is treated as the command, so the bare word follow
works too.handle_command() knows exactly two commands,
follow and stop, and prints a hard-to-miss
confirmation framed in asterisks; anything else is reported as an
unknown command.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 vessel1The 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:
team1/kogias/vessel1/commands, its own topic under your
fleet's prefix; check the -t argument character by
character, and make sure the prefix matches the FLEET_NS
the scripts run under.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.
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:
vessel.py hands
its own on_message function to subscribe()
(the pass-your-own-function parameter from Project 5, back as promised).
A scout position no longer just prints: when the vessel is in following
mode it goes straight into follow_scout(), the method at
the heart of this project. Notice the shape this gives the program: the
chase runs message by message, one follow_scout() call per
scout report, inside the MQTT thread; the script's main loop never
steers, it just keeps publishing the vessel's own telemetry.handle_command() and
on_message now live in vessel.py rather than
in MQTTHandler: acting on a message needs the vehicle, and
the MQTT class should not know about boats. On follow the
vessel arms itself (arm_vehicle()), switches to GUIDED
(set_guided_mode()), and raises the controller's
following flag; note that follow issues no
goto itself, it only opens the gate for the position messages. On
stop, stop_following() drops the flag,
switches to LOITER, and also wipes the goto history (last target, times,
speed readings), so a later follow starts fresh instead of
acting on stale state. The starred confirmation blocks are still there;
now you get movement along with them.calculate_distance() implements the
haversine formula, the standard way to turn two
latitude/longitude points into meters; it is the formula you will find
whenever you search for GPS distance, and it works at any scale, from
our harbour to an ocean crossing.SAFE_FOLLOW_DISTANCE (a .env setting, 15
meters), follow_scout() switches the vessel to LOITER
instead of pressing on; when the gap opens again, it returns to GUIDED
and resumes. Crude, but it keeps a follower from ramming its
leader.simple_goto(). First,
scout_has_moved() treats the scout as moving only if it
shifted more than 4 meters or averaged above 0.5 m/s over its last three
reports; a parked boat's GPS jitter stays below both, so a parked scout
triggers nothing. Second, even for a moving scout, a new goto goes out
only if the last one is older than 15 seconds or the scout has pulled 5
meters further ahead than it was. Each goto interrupts the autopilot's
current path, so the code lets the autopilot drive and steps in only
when the target has genuinely changed.report_status() prints STOPPED, FOLLOWING or LOITERING,
plus the current distance, so the terminal tells you at a glance what
the boat thinks it is doing.subscribe()
now assigns QoS by topic kind: command topics get QoS 1, positions stay
on QoS 0. This is the difference the Project 2 aside promised would
matter here: a lost position is replaced by a fresh one seconds later,
but a lost stop is a boat that keeps going. We also add
-q 1 when publishing commands below; the guarantee applies
leg by leg, sender to broker and broker to subscriber, so both legs
should ask for 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:14561Give 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 vessel1The 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 1This 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 vessel2The 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:
follow arrived before the simulated GPS was ready. Restart
the vessel script, wait for the fix this time, then send
follow again.follow,
and the boat barely moves. Not a bug: the boats started closer
together than the keep-out ring. Drive the scout away and the follower
resumes by itself.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.
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:
vessel_controller.py gives each role an angle:
vessel1 sits at -135 degrees (the port quarter, behind the scout and to
its left), vessel2 at +135 (the starboard quarter), vessel3 at 180
(directly astern). The angles are measured relative to the
scout's heading, not relative to north; that is what makes the
formation live, because the stations swing with the scout and stay
behind it whichever way it turns. How far out?
FORMATION_DISTANCE in .env, 30 meters.simple_goto(). The new
offset_position() method does it with a flat-earth
shortcut: one degree of latitude is about 111,320 meters everywhere, and
one degree of longitude is worth that times the cosine of your latitude.
Over a few tens of meters the error is centimeters; you need the
spherical math for kilometers, not for a formation.vessel.py simply starts passing it through to
follow_scout(), which now computes the station and aims
there instead of at the scout. Nothing changes on the wire.time.sleep(5) in the scout's and the vessels' main loops
becomes TELEMETRY_INTERVAL from .env, shipped
at 1 second. Followers now hear about the scout's movement within a
second instead of five, and the keep-out ring's blind window shrinks
from roughly 25 meters of travel to roughly 5. Every console gets
noticeably chattier; that is this setting, and it is yours to tune.SAFE_FOLLOW_DISTANCE
goes up to 15 meters. With stations 30 meters out, the ring should not
fire at all during normal formation keeping; it stays as a guard for the
one case the geometry cannot exclude, the scout turning and driving at a
follower. Keep it smaller than FORMATION_DISTANCE,
otherwise every vessel stops short of its own station.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 stationSend 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 1and 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:
SAFE_FOLLOW_DISTANCE is not smaller than
FORMATION_DISTANCE in your .env; put the ring
back below the station distance.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.
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.
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:
vehicle.commands: clear whatever mission the
autopilot holds, add one Command object per waypoint (the
command type to look up is MAV_CMD_NAV_WAYPOINT), then
upload. This replaces the clicking you did in Mission Planner.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.
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:
formation command through Project 6's command path: single
file to pass through a channel, a triangle in open water, wider spacing
in rough weather, all on request from shore.Then the unglamorous ideas that real operations are made of:
And further out, bringing in new inputs:
mosquitto_pub.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.
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.
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.
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:
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.