Skip to main content

Controlling the Servo and Motors

Elias Groot

Elias Groot

Software Lead, Course Organizer

To actually spin the motors and servo, you will need to write data to the official ASE actuator service. Based on our trajectory midpoint heuristics, we will write a steering value.

Creating a Write Stream

Similar to reading from the imaging service with a read stream, we want to write to the actuator service with a write stream. To understand to which stream to write, we turn to the actuator service's service.yaml definition and see which inputs it reads from:

actuator/service.yaml
...
inputs:
- service: controller
streams:
- decision
...

From the service "controller" it wants the stream "decision". Let's initialize this stream in our code, right after initializing the imaging read stream.

src/main.go
actuator := service.GetWriteStream("decision")
if actuator == nil {
return fmt.Errorf("Failed to create write stream 'decision'")
}

Notice that here we do not specify to which service we want to write to. We just tell the roverlib that "I want to write some data to a stream named 'decision'". We do not know nor check who will actually read from this stream.

Again similar to our imaging read stream, we have two methods available for writing data:

  • actuator.Write() which will take a SensorOutput message as Go struct, encode it to the binary wire format and send it out
  • actuator.WriteBytes() which will take a raw write buffer from the caller and send it out

Writing To a Write Stream

Because we know that the actuator service expects a ControllerOutput message, using the actuator.Write() message is the obvious choice. Take a look at its definition here to understand which properties can be set for the actuator.

For now, let's only set the servo position, without spinning up the motors. To do so, we construct a ControllerOutput message first.

src/main.go
// Initialize the message that we want to send to the controller
actuatorMsg := pb_outputs.SensorOutput{
Timestamp: uint64(time.Now().UnixMilli()), // milliseconds since epoch
Status: 0, // all is well
SensorId: 1, // this is the first and only sensor we have
SensorOutput: &pb_outputs.SensorOutput_ControllerOutput{
ControllerOutput: &pb_outputs.ControllerOutput{
SteeringAngle: steerPosition,
LeftThrottle: 0,
RightThrottle: 0,
FanSpeed: 0,
FrontLights: false,
},
},
}
pb_outputs library

The pb_outputs library comes installed with roverlib-go, since it depends on the rovercom library. With it, we can instantiate protobuf messages as Go structs. Such libraries are often prefixed with pb_.

To import this Go library, use:

import (
pb_outputs "github.com/VU-ASE/rovercom/packages/go/outputs"
)

The nested pointer might seem complex at first sight. It unfortunately is a result of the Go typing system to allow for (somewhat complicated) union types. Also notice that we manually specify three general fields:

  • timestamp: when was this message generated? (In milliseconds since epoch)
  • status: is this "sensor" still working well? (0 = all is well)
  • sensorId: which sensor in this service did create this message? Must be non-empty. Useful if you have multiple sensors in the same sensor (for example, two cameras in one imaging service).

To send this SensorOutput message, we just use actuator.Write() like so:

src/main.go
// Send the message to the actuator
err = actuator.Write(&actuatorMsg)
if err != nil {
log.Warn().Err(err).Msg("Failed to send message to controller")
}

Convenient right? If we keep writing in a loop, we will continuously update our steering decisions that the actuator service will use to spin the servo and motors. At this point, your code should look like the following:

src/main.go
package main

import (
"fmt"
"os"
"time"

pb_outputs "github.com/VU-ASE/rovercom/packages/go/outputs"
roverlib "github.com/VU-ASE/roverlib-go/src"

"github.com/rs/zerolog/log"
)

// The main user space program
// this program has all you need from roverlib: service identity, reading, writing and configuration
func run(service roverlib.Service, configuration *roverlib.ServiceConfiguration) error {
// "I am the dummy controller, and I want to read from the 'path' stream from the 'imaging' service"
imaging := service.GetReadStream("imaging", "path")
if imaging == nil {
return fmt.Errorf("Failed to get stream 'path' from 'imaging' service")
}

actuator := service.GetWriteStream("decision")
if actuator == nil {
return fmt.Errorf("Failed to create write stream 'decision'")
}

for {
// Read one message from the stream
msg, err := imaging.Read()
if msg == nil || err != nil {
return fmt.Errorf("Failed to read from 'imaging' service")
}

// When did imaging service create this message?
createdAt := msg.Timestamp
// Convert epoch in milliseconds to a human readable format
createdAtHuman := time.Unix(0, int64(createdAt)/int64(time.Millisecond))
log.Info().Time("createdAt", createdAtHuman).Msg("Received message")

// Get the camera data
if msg.GetCameraOutput() == nil {
return fmt.Errorf("Message does not contain camera output. What did imaging do??")
}
cameraOutput := msg.GetCameraOutput()
log.Info().Msgf("imaging service captured a %d by %d image", cameraOutput.Trajectory.Width, cameraOutput.Trajectory.Height)

// This value holds the steering position that we want to pass to the servo (-1 = left, 0 = center, 1 = right)
steerPosition := float32(0)
// Find the desired mid point (x) of the captured image
desiredMidpoint := cameraOutput.Trajectory.Width / 2 // (cameraOutput.Trajectory.Width - 0) / 2
// Compute the actual mid point between the left and right edges of the detected track
if len(cameraOutput.Trajectory.Points) < 2 {
log.Debug().Msgf("Not enough track edge points to compute the mid point")
} else {
// Compute the mid point (assuming [0] is the left edge and [1[] is the right edge)
actualMidpoint := (cameraOutput.Trajectory.Points[1].X-cameraOutput.Trajectory.Points[0].X)/2 + cameraOutput.Trajectory.Points[0].X
// Compute the error
midpointErr := uint32(actualMidpoint) - desiredMidpoint
// Compute the steering position
steerPosition = float32(float64(midpointErr) / float64(desiredMidpoint))
}

// Initialize the message that we want to send to the controller
actuatorMsg := pb_outputs.SensorOutput{
Timestamp: uint64(time.Now().UnixMilli()), // milliseconds since epoch
Status: 0, // all is well
SensorId: 1, // this is the first and only sensor we have
SensorOutput: &pb_outputs.SensorOutput_ControllerOutput{
ControllerOutput: &pb_outputs.ControllerOutput{
SteeringAngle: steerPosition,
LeftThrottle: 0,
RightThrottle: 0,
FanSpeed: 0,
FrontLights: false,
},
},
}

// Send the message to the controller
err = actuator.Write(&actuatorMsg)
if err != nil {
log.Warn().Err(err).Msg("Failed to send message to controller")
}

}
}

// This function gets called when roverd wants to terminate the service
func onTerminate(sig os.Signal) error {
log.Info().Str("signal", sig.String()).Msg("Terminating service")

//
// ...
// Any clean up logic here
// ...
//

return nil
}

// This is just a wrapper to run the user program
// it is not recommended to put any other logic here
func main() {
roverlib.Run(run, onTerminate)
}

This already makes for a fully function (though quite dumb) controller service. Though, just like with declaring our inputs, we need to declare our output stream decision to roverd again through the service.yaml definition.

Declaring a Write Stream

Declaring a write stream through the service.yaml is even easier than declaring a read stream. You just need to specify the name of your stream in the outputs field, like so:

dummy service/service.yaml
...
outputs:
- decision
...

Before you save, there is one more thing that we need to do. If you look at the actuator's service.yaml definition, you will see that it depends on stream decision from a service called controller. roverd will find and match this stream and service based on exact naming. Chances are that your service is currently named differently, so again turn to your service.yaml and make sure to adjust the name like so:

dummy service/service.yaml
name: controller
...

This is all. Save all your changes and use roverctl to sync them if your Rover is powered on.