Skip to content

Teleop Starter Project

Here, you will complete a Vue component by:

  • Creating page elements.
  • Formatting page elements.
  • Importing other components (ArmControls and Rover3D).
  • Sending/receiving messages to/from the backend.

The tasks you complete in this project will be similar to tasks that you see in the future. Don’t be afraid to ask questions if you can’t understand something, or are just curious. At a quick glance, the teleop system may seem simple, but there are a lot of moving parts.


First, go to Teleop Quickstart and make sure your environment is set up. Critically, make sure you’ve run ./build.sh and have all necessary dependencies.

Open up a terminal, and type mrover. Then:

Checkout/go to the branch that has the starter project code:

git checkout teleop-starter-2026

Copy it into a new branch:

git checkout -b <your-initials>/starter-project-2026
example: git checkout -b km/starter-project-2026

Now you (hopefully) have the starter code ready to be worked on. But how do you run and debug it?

Go to the url http://localhost:8080/starter in your browser. You should see an error page. This is because it is trying to access a server on and IP address that doesn’t have any - localhost a.k.a. 127.0.0.1 a.k.a. your computer. To create the server it needs, go to your terminal, and run

ros2 launch mrover basestation.launch.py mode:=dev

This launches both the frontend and the backend. Now once you go back to your browser, you should see a webpage with a header and some text with “Hello World!” in it after reloading. If not, make sure you did everything in Teleop Quickstart, or ask for help.


Now, open up StarterProject.vue in a code editor. It’s easiest to just run this in a new terminal:

mrover
code .

Then press ctrl-p and type the file’s name to search for it. It should look something like this:

<template>
<div class="view-wrapper">
<h1>Hello world!</h1>
<!-- TODO -->
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted } from 'vue'
...

Let’s add a button to it. Delete the <h1> and replace the // TODO add button with this code:

<button class="btn btn-primary">
Hello button!
</button>

Now, you have a button that does absolutely nothing! Note that if you go back to the browser, you don’t have to reload to see your changes. This is due to hot swapping.


We would probably like our buttons to not do nothing, so let’s fix that by adding some functionality.

Scroll down in the file until you find the spamTestMessages function. It should look like this:

const spamTestMessages = () => {
// Send a message every 1000 milliseconds
const interval = setInterval(() => {
sendMessage('starter', {
type: 'debug',
timestamp: new Date().toISOString(),
})
}, 1000)
// Stop sending messages after 5000 miliseconds
setTimeout(() => clearInterval(interval), 5000)
}

What better for adding functionality than a function? We will make it so that this code runs whenever the button is pressed. Go back to the button, and add the event @onclick="spamTestMessages()" to it as such. Don’t forget to change the text to something descriptive:

<button class="btn btn-primary" @click="spamTestMessages()">
Spam test messages
</button>

Now click it and… still nothing? Look at the box that says “starter” in the top right. When you press the button, a green light will flicker. Look at your terminal, and you will see something like this multiple times:

[gui_backend.sh-1] [WARN] [1753735204.830010889] [gui_backend_node]: debug message received by consumer, 2025-07-28T20:40:04.827Z

Let’s go through the code to see what exactly is going on.

const interval = setInterval(() => {
...
}, 1000)

setInterval (as the comment suggests), runs the function inside of it every 1000 milliseconds (every second). An id for it is stored in the interval variable.

setTimeout(() => clearInterval(interval), 5000)

setTimeout runs the function inside of it after 5000 milliseconds (5 seconds). That function (clearInterval()) stops the previous interval.

sendMessage('starter', {
type: 'debug',
timestamp: new Date().toISOString(),
})

This is the main attraction of this function. It sends a “debug” message to the “starter” websocket containing the current time.


WebSocket is a networking protocol, like HTTP. It lets computers talk to each other via WebSockets. It sends messages quickly - great for real-time updates. In our codebase, when a ROS topic is published, it gets sent to the backend, and then forwarded to the frontend via a WebSocket (if one has been set up). It also works in reverse; a frontend element can send messages to the backend and then to the rover.

To use a WebSocket in a component, we first import necessary dependencies and define the necessary functions:

import { useWebsocketStore } from '@/stores/websocket'
...
const { setupWebSocket, closeWebSocket, sendMessage } = useWebsocketStore()

Then, we set up needed WebSockets.

onMounted(() => {
setupWebSocket('starter')
// TODO add necessary sockets
})

Messages can now be sent through this WebSocket (WebSocket with id “starter”). When the Vue component is unmounted, we also have to close the WebSocket.

The box in the upper left is a status indicator. A green light (on the left) indicates a transmit, while a red light indicates a receive. If the whole box is yellow, this indicates a disconnect. Go back into your terminal and press ctrl-c. That kills the backend, causing all sockets to disconnect. The frontend will stick around until the page unloads. Restart the basestation by running the launch command (the command will probably come back if you just press up in your terminal).

sendMessage('starter', {
type: 'debug',
timestamp: new Date().toISOString(),
})

Change the new Date().toISOString() to some other value. Press the button and see what happens in the terminal.

Next, undo changing timestamp, and try this challenge: Get the following to display in your terminal:

[gui_backend.sh-1] [WARN] testing message 'Phil' received at <current time>

Hint: look in starter_ws.py. Where is a message’s type used? What data does the WebSocket expect when it receives a particular message?

Finally, try receiving a message. The starter websocket publishes a String message to “foo” every 2 seconds. Get it to display that message in the browser’s console.

Hint:

// TODO add onMessage
onMessage<?>('starter', '?', msg => {
console.log(?)
})

If you have pried into some of the other views/pages, you’ve seen they have many sections with complex parts, and that some of those sections are reused on different pages. These sections are called components. The view itself is also a component. The Starter page is sparse, so let’s try to add some to it.

It would be nice to test the rover’s arm on the page, so we should add the necessary components for that. First, we have to import them inside of the <script> tag. Replace the // TODO import components:

import ArmControls from '../components/ArmControls.vue'
import Rover3D from '../components/Rover3D.vue'

Add necessary WebSocket management:

onMounted(() => {
setupWebSocket('starter')
setupWebSocket('arm') // <-- Add this
})
...
onUnmounted(() => {
closeWebSocket('starter')
closeWebSocket('arm') // <-- this too
})

Now that they have been imported, we can use them in the <template>.

Replace the <!--TODO add components--> with this code:

<ArmControls/>
<Rover3D/>

Now the page looks… kinda weird actually. We should format it.


Tailwind is a CSS framework that we use for styling. It provides classes to modify styles of elements. Add some to the new components.

<ArmControls class="island py-1" />
<Rover3D class="island m-0 p-0" style="max-height: 700px;" />
  • island adds a white, rounded background to a component.
  • p (and by extension, p-y) changes padding.
  • m is for changing margins.
  • style is not part of Tailwind; it manually changes the elements CSS styling. max-height: 700px forces to Rover3D component to stay under 700 pixels of height

The page looks a little better now, but it still has an odd layout. Group the button and ArmControls together using a <div>

<div>
<button class="btn btn-primary" @click="spamTestMessages()">
Spam test messages
</button>
<ArmControls class="island py-1" />
</div>

Even better, but there is a little awkwardness. Make the <div> into a flexbox (with additional styling) by adding class="flex flex-col gap-2 mb-2 p-1" to it. Your final <template> code should look like this:

<template>
<div class="view-wrapper">
<div class="flex flex-col gap-2 mb-2 p-1">
<button class="btn btn-primary" @click="spamTestMessages()">
Spam test messages
</button>
<ArmControls class="island py-1" />
</div>
<Rover3D class="island m-0 p-0" style="max-height: 700px;" />
</div>
</template>

Lookin’ good! Graphic design is your passion as it is mine, I’m assuming.


Rover3D is a display of the rover in its current state. It also displays a costmap, a grid of how “expensive” it would be for the rover to navigate a certain section of terrain.

Arm Controls allows the operator to move the robot arm with a controller, and displays the controller’s state. Here, however, the code has been modified so that keyboard input also moves the arm.

Here is part of the code for keyboard input (not all of it):

interval = window.setInterval(() => {
const axes: number[] = [0, 0, 0, 0]
const buttons: boolean[] = []
axes[0] = (keysPressed.d ? 1 : 0) - (keysPressed.a ? 1 : 0)
axes[1] = (keysPressed.s ? 1 : 0) - (keysPressed.w ? 1 : 0)
sendMessage('arm', {
type: 'ra_controller',
axes: axes,
buttons: buttons
})
}, 1000 / UPDATE_HZ)

It takes the keyboard input and “translates” it into a controller input. Here is part of the code for direct controller input:

const { connected, axes, buttons, vibrationActuator } = useGamepadPolling({
controllerIdFilter: 'Microsoft',
topic: 'arm',
messageType: 'ra_controller',
})

However, pressing anything won’t move the arm currently. This is for two reasons:

  • The arm mode has to be set.
  • The backend needs a rover with an arm to move.

Head inside ArmControls.vue. In VSCode, you can control click its name from its html tag or import statement. Then, go to this div:

<div class="flex w-full" role="group" aria-label="Arm mode selection" data-testid="pw-arm-mode-buttons">
<!-- TODO add buttons-->

And add these buttons where the TODO is:

<button
type="button"
class="btn btn-sm flex-1"
:class="mode === 'disabled' ? 'btn-danger' : 'btn-outline-danger'"
data-testid="pw-arm-mode-disabled"
@click="newRAMode('disabled')"
>
Disabled
</button>
<button
type="button"
class="btn btn-sm flex-1"
:class="mode === 'throttle' ? 'btn-success' : 'btn-outline-success'"
data-testid="pw-arm-mode-throttle"
@click="newRAMode('throttle')"
>
Throttle
</button>

Click throttle. The hard part is now done. Next, we need to get the rover, or at least a virtual one.


Open a new terminal, but keep the old one running the basestation. Run and then run these commands. If you get stuck in the simulator, press esc:

mrover
ros2 launch mrover simulator.launch.py

The simulator and RViz will open in new windows. RViz is a useful tool that allows you to see what the rover sees, and what topics are being broadcast. The simulator provides a digital version of the rover that communicates with the basestation in a similar way to if it was real.

The simulator is the one with the MRover logo as its symbol. You can move the camera with WASD, space, ctrl, and the mouse. Esc toggles the mouse from being locked to unlocked and back again.

Everything is currently frozen. Press p to enable physics, and uncheck publish ik. You can move the rover with i, j, l, and ”,” while the mouse is locked. Go back to the Starter view in your browser. Make sure throttle mode is selected, and use WASD to control the arm. It will move in both the browser and the simulator.


One last thing - Open up yet another terminal, but leave the other two running. Run these commands:

mrover
ros2 topic list

It will display every topic that currently exists. Let’s try to see how our arm controls are being sent. Because we’re using throttle mode, they will be sent through /arm_thr_cmd. Echo that topic.

ros2 topic echo /arm_thr_cmd

Whenever you move the arm, a new message will appear. Press ctrl-c to stop the echoing.


The starter project is pretty much done now, you can stop the simulator and basestation. Let’s add it to the codebase for safekeeping. Commit your changes by running this in a terminal:

# "mrover" doesn't need to be run if from one of the previous terminals
mrover
# Mark all files in "." (a.k.a. this folder) for commit
git add .
# Go through the output this prints. It should say that every file you personally changed is marked for commit, likely in green.
# It's a good idea to check this before committing any changes
git status
# Commits locally with message "Starter project completed"
# (-m is required to put a message, and you should put a message with every commit)
git commit -m "Starter project completed"

This commits the changes locally. It only exists on your computer. To send your changes to the shared codebase, type this:

git push

That… probably gave an error. Run what it tells you to, which is probably this:

# --set-upstream can be replaced with -u
git push --set-upstream origin <your-initials>/teleop-starter-2026

This tells git to create a remote branch (shared) to match with your local branch (on your computer), and to put the changes there. Now that the remote branch exists, you can simply type git push whenever you make a new commit.

If this were a normal feature, this would be when you make a pull request, but this feature isn’t going into the main branch. If it was pulled, you should delete the feature branch afterwards. If you want to delete your starter branch remotely, run this.

git push -d origin <your-initials>/teleop-starter-2026

To delete it locally (although I advise you keep the branch for your future reference), run this:

git branch -d <your-initials>/teleop-starter-2026

Again, if this was a real feature, you absolutely should delete the remote and local branch after the pull request gets approved.

There you have it! Your first teleop project done. It was a lot to take in, so don’t sweat if you don’t get it all right away. With practice, it will come to you. Here are some things you can do to learn more, and help you in the future:

  • Read the docs. You were probably already doing that, but, if you weren’t, go ahead and do that. Try to read all about ROS2, all about teleop, the general resources, and some of each of the other teams.
  • Skim the codebase. Look at files at multiple parts of the codebase, and try to figure out what they do. Modify them, remove them, add them, and see what happens. You can reset a branch back to its remote version with git reset --hard origin/<branch-name>. I recommend looking in the views, the components, the _ws.py files, the .msgs, the shell scripts (.sh files), and whatever seems to interest you.
  • Customize your environment. Change the colors on your terminal. Learn keyboard shortcuts for VSCode (did you know ctrl-alt-”-” will move the cursor to its previous position on Ubuntu, even between files). Try out Vim. Install some extensions. Put up a fancy wallpaper. Making navigating your computer easy will pay off in the long run.
  • Talk with other members. MRover is a team, and we work best when there’s good communication. Try to familiarize yourself with your teammates and some members of other teams too. Heck, try out another team if they look fun, I ain’t stopping you. If you have any questions at all, don’t be afraid to ask me or someone else.
  • Ctrl-f, ctrl-shift-f, ctrl-p, and ctrl-click are your best friends. I think I learned the most about MRover by looking at files related to what I was working on. Learn what these do, and try them out.

Now, go eat lunch or something. You’ve probably been here a while.