Both the Quackraft and Mallard Module run event-driven software on PIC32MX170F256B microcontrollers, using the ME218 Events and Services Framework. All logic is written in C and organized into flat state machines and services that communicate by posting events to each other's queues. Communication between the two systems happens over XBee radio modules in API mode using UART 9600 baud 8N1 at 5Hz.
The boat runs four services. QRCommSM handles XBee reception, transmission, and parsing; BoatControlSM manages the boat's state machine (Pairing, Driving); MotorService translates joystick values into ESC PWM signals.
On receiving a valid XBee packet, the boat parses the message, checks the source address against its stored paired controller address, and executes the appropriate command. In Pairing state, the boat listens for incoming pairing messages (status byte 0x02), extracts the source address, stores it as the paired controller, resets fuel to maximum, and responds with a 0xFF charge byte to acknowledge the pairing handshake. In Driving state, joystick values map directly to left and right motor PWM. The gate servo opens or closes based on the digital input bit. The fuel counter tracks remaining fuel and stops the motors when empty. If no message is received from the controller for 4 seconds, the boat unpairs and returns to the Pairing state.
ON INIT:
initialize Timer3 at 50Hz for servo PWM
configure OC3 → RPA3 (pairing indicator servo)
configure OC4 → RPA2 (gate servo)
set pairing servo to UNPAIRED position (1.8ms pulse)
set gate servo to CLOSED position (1.25ms pulse)
enter IDLE, immediately transition to PAIRING
PAIRING:
ON ES_PAIRED:
set pairing servo to PAIRED position (1.25ms pulse)
reset fuel level to max (200)
go to DRIVING
DRIVING:
ON ES_DRIVE_CMD:
IF fuel == 0:
post ES_STOP_MOTORS to MotorService
ELSE:
drain fuel:
IF moving (RX_Joy1 or RX_Joy2 != 0x7F): drain 1 fuel unit
IF gating (RX_Digi bit 1 set): drain 1 additional fuel unit
map RX_Joy1 → left motor param (0x7F is neutral, with deadband)
map RX_Joy2 → right motor param
post ES_LEFT_UPDATE and ES_RIGHT_UPDATE to MotorService
IF RX_Digi bit 0 set:
set gate servo OC4RS = OPEN position (2ms pulse)
ELSE:
set gate servo OC4RS = CLOSED position (1.25ms pulse)
ON ES_REFUEL_CMD:
add 6 to fuel level, max at 200
post ES_STOP_MOTORS (no driving while charging)
ON ES_UNPAIRED:
post ES_STOP_MOTORS to MotorService
reset fuel to max
set pairing servo to UNPAIRED position
go to PAIRING
ON INIT:
initialize UART2 at 9600 baud
configure RPB10 = U2TX, RPB11 = U2RX
enable UART2 RX interrupt at priority 7
ON ES_UART_BYTE_RECV:
pass received byte to ParseRecvByte
ParseRecvByte (byte-by-byte state machine):
WAIT_DELIM: wait for 0x7E, reset checksum accumulator
LEN_HIGH: advance
LEN_LOW: advance
API_ID:
accumulate into checksum
IF byte == 0x81: advance (valid RX packet frame)
ELSE: reset to WAIT_DELIM (unexpected frame type)
SRC_HIGH: accumulate, save as RX_SrcHigh
SRC_LOW: accumulate, save as RX_SrcLow
RSSI: accumulate
OPTIONS: accumulate
STATUS: accumulate, save as RX_Status
JOY1: accumulate, save as RX_Joy1
JOY2: accumulate, save as RX_Joy2
DIGI: accumulate, save as RX_Digi
CHECKSUM:
IF (running sum + byte) & 0xFF == 0xFF: call ProcessMMMessage
ELSE: discard, print "bad checksum"
reset to WAIT_DELIM
ProcessMMMessage:
IF RX_Status == 0x02 (pairing):
IF not currently paired:
store RX_SrcHigh/Low as paired MM address
set fuel level to max (200)
send pair response back to controller (charge = 0xFF)
post ES_PAIRED to BoatControlSM
ELSE IF message from our paired MM:
send pair response again (re-acknowledge)
ELSE:
return without resetting timer (ignore other MM addresses)
ELSE IF paired AND message from our MM:
IF RX_Status == 0x01 (charging):
post ES_REFUEL_CMD to BoatControlSM
ELSE (RX_Status == 0x00, driving):
post ES_DRIVE_CMD to BoatControlSM
send 10-byte response with current fuel level
ELSE:
return without resetting timer
reset 4-second unpair watchdog timer
ON UNPAIR (4s) TIMER fire:
clear paired MM address (high and low bytes = 0x00)
set AM_I_PAIRED = 0
post ES_UNPAIRED to BoatControlSM
SendResponseMsg(charge):
build 10-byte XBee TX frame:
delimiter = 0x7E
length = 0x0006
API ID = 0x01
frame ID = 0x00
destination = paired MM address
options = 0x01
data = charge byte
checksum = 0xFF - (sum of bytes 4–9)
transmit byte by byte over UART2
UART2 RX ISR:
clear interrupt flag
read byte from hardware buffer
post ES_UART_BYTE_RECV with byte to QRCommSM
ON INIT:
configure Timer2 at 50Hz (PBCLK 20MHz / prescaler 8 / PR2 = 49999)
configure OC1 → RPA0 (right motor ESC signal)
configure OC2 → RPA1 (left motor ESC signal)
set both channels to NEUTRAL_TICKS (3750 = 1.5ms pulse)
arm ESCs at neutral
ON ES_RIGHT_UPDATE (param = 0–255):
fraction = (param / 255.0) - 0.5 , range -0.5 to +0.5
ticks = 3750 + (fraction × 2500) , range 1250 to 6250
set OC1RS = ticks (right motor ESC)
ON ES_LEFT_UPDATE (param = 0–255):
same calculation
set OC2RS = ticks (left motor ESC)
ON ES_STOP_MOTORS:
set OC1RS = OC2RS = 3750 (neutral)
The controller runs four services. ControllerSM manages the controller's state machine (Idle, Pairing, Driving, Refueling); CommSM handles all XBee transmission, reception, and parsing; LEDService runs the RGB status LED; EventCheckers polls all physical user inputs.
The boat driver's inputs map directly to the controller state machine. The knob selects a target boat address. Pressing the pair button triggers pairing transmissions at 5Hz until the boat responds with an acknowledgement. Pressing the refuel button transitions between Driving and Refueling. In Driving, joystick values are sampled and packed into XBee messages every 200ms. In Refueling, the accelerometer detects shaking and fuel level is sent to the boat with charging status messages. An RGB LED reflects system state and pairing status, blinking blue during pairing (at intervals based on selected QR), solid blue when paired, and green when refueling.
ON INIT:
enter IDLE state
IDLE:
ON mode switch toggled to DRIVE (default):
IF already paired:
set status = DRIVE
start fuel servo timer (500ms)
tell CommSM to start drive transmissions
go to DRIVING
ELSE:
ignore (must pair first)
ON mode switch toggled to PAIR:
set status = PAIR
go to PAIRING
PAIRING:
ON pair button pressed:
IF not in silent period (unpairing time):
read knob: set destination boat address
tell CommSM to start sending pair messages
start LED blink timer (blinks based on boat destination)
ON pair ACK received (from CommSM):
stop LED blink timers
set LED solid blue
mark as paired
tell CommSM to stop pair transmissions
go to IDLE (which goes straight to DRIVING)
DRIVING:
ON refuel button pressed:
set status = REFUEL
set LED green
go to REFUELING
ON device knob moved (to pair):
set status = PAIR
mark as unpaired
start 4-second silent period (no transmissions, to unpair)
tell CommSM to stop drive transmissions
go to PAIRING
ON gate switch toggled:
update CURR_GATE global (open or closed)
ON fuel servo timer fires:
update fuel servo position based on last received fuel level
restart timer
REFUELING:
ON refuel button released:
set status = DRIVE
turn off LED
go to DRIVING
ON device knob moved (to pair):
set status = PAIR
mark as unpaired
tell CommSM to stop drive transmissions
start silent period
go to PAIRING
ON accelerometer event (ES_ACCEL_HI):
increment local fuel counter (up to MAX)
ON fuel servo timer fires:
map fuel counter (0–100) to servo position (0–4)
update fuel servo
restart timer
ON INIT:
initialize UART2 at 9600 baud
configure RPB10 = U2TX, RPB11 = U2RX
enable UART2 RX interrupt at priority 7
ON ES_START_PAIR_TX:
start pair TX timer (200ms)
ON ES_STOP_PAIR_TX:
stop pair TX timer
ON ES_START_DRIVE_TX:
start drive TX timer (200ms)
start drive watchdog timer for unpairing (4000ms)
ON ES_STOP_DRIVE_TX:
stop drive TX timer
stop drive watchdog timer
ON ES_ENTER_PAIRING_MODE (param = boat zone 1–5):
IF zone == 3: set destination address low byte = 0x87
ELSE: set destination address low byte = 0x80 | zone number
ON PAIR TX TIMER fires:
build and send 13-byte pair XBee frame:
delimiter = 0x7E, length = 0x0009
API ID = 0x01, frame ID = 0x00
destination = current DestAddrHigh / DestAddrLow
options = 0x01
status byte = 0x02 (pairing)
Joy1 = SOURCE_ADDR_HIGH (0x21) [MM address embedded]
Joy2 = SOURCE_ADDR_LOW (0x83)
Digi = 0x00
checksum = 0xFF - (sum of bytes 4–12)
restart timer (200ms)
ON DRIVE TX TIMER fires:
query ControllerSM for current state and current status byte
IF state == MM_DRIVING:
read both joysticks via ADC (4-channel MultiRead)
map each raw 10-bit ADC value (8-bit by right-shifting 2 bits)
apply deadband: IF mapped value in range 0x70–0x8F, snap to 0x7F
read CURR_GATE, pack into Digi bit 0
ELSE (refueling state):
Joy1 = Joy2 = 0x7F (neutral, no movement)
read CURR_GATE, pack into Digi bit 0
build and send 13-byte drive XBee frame with above values
compute checksum over bytes 4–12
restart timer (200ms)
ON DRIVE WATCHDOG TIMER fires:
no valid response from boat in 4 seconds
stop drive TX timer
read knob ADC value
compute zone (1–5) from raw value: zone = (knobValue × 5 / 1023) + 1
clamp zone to max 5
post ES_ENTER_PAIRING_MODE with zone to ControllerSM and LEDService
ON ES_UART_BYTE_RECV:
pass received byte to ParseRecvByte
ParseRecvByte (byte-by-byte state machine):
WAIT_DELIM:
IF byte == 0x7E: reset checksum accumulator, advance
ELSE: stay, keep waiting
LENGTH_HIGH: advance
LENGTH_LOW: advance
API_ID: accumulate into checksum, advance
FRAME_ID: accumulate, advance
DEST_HIGH: accumulate, advance
DEST_LOW: accumulate, advance
OPTIONS: accumulate, advance
CHARGE: accumulate, save as RxCharge, advance
CHECKSUM:
IF (running sum + this byte) & 0xFF == 0xFF:
call ProcessQRMessage(RxCharge)
ELSE:
discard, print "bad checksum"
reset to WAIT_DELIM
ProcessQRMessage(charge):
reset drive watchdog timer (4000ms), keeps connection alive
IF charge == 0xFF:
pairing acknowledgement received
post ES_PAIR_ACK to ControllerSM and LEDService
ELSE:
map raw charge (0–200) to percentage: fuel% = charge × 100 / 200
store as CurrentFuel global
post ES_FUEL_UPDATE with percentage to LEDService
UART2 RX ISR:
clear interrupt flag
read byte from U2RXREG
post ES_UART_BYTE_RECV with byte to CommSM
ON INIT:
configure RB15 (blue), RB14 (green), RB9 (red) as digital outputs
all LEDs off
ON ES_PAIR_BTN:
start fast blue blink (very short on/off period)
blink count = unlimited
ON ES_PAIR_ACK:
stop blink timer
set LED solid blue
ON ES_ENTER_PAIRING_MODE (knob changed to zone N):
start blue blink with:
blink count target = N (number of blinks = team number)
short pause between blinks within a group
long pause between groups of blinks
repeats: blink N times, long pause, blink N times, ...
ON ES_REFUEL_BTN:
IF transitioning into refueling (button pressed):
start slow green blink (1 second on, 1 second off)
IF transitioning out of refueling (button released):
switch to blue solid (back to drive mode)
ON ES_FUEL_UPDATE (param = fuel percentage 0–100):
update blink timing:
blink period = 1000ms - (fuel% × 950ms / 200)
high fuel = slow blink, low fuel = fast blink
ON LED BLINK TIMER fires:
IF LED currently on:
turn LED off
increment blink count
IF blink count >= target: next off-time = long pause
ELSE: next off-time = short pause
restart timer with off-time
IF LED currently off:
turn LED on
IF previous off-time was long pause: reset blink count
restart timer with on-time
Check4Keystroke:
IF new character available on UART1 (debug terminal):
post ES_NEW_KEY with character to all services
return true
Check4PairButton (RA2, active low, debounced):
read current pin state
IF state changed: reset debounce counter
ELSE: increment counter
IF counter >= threshold (stable for ~20 checks):
IF new stable state differs from last accepted state:
IF pin went LOW (button pressed):
post ES_PAIR_BTN to all services
return true
Check4RefuelButton (RA4, debounced):
read current pin state
IF state changed: reset debounce counter
ELSE: increment counter
IF counter >= threshold (stable for ~20 checks):
IF new stable state differs from last accepted state:
post ES_REFUEL_BTN with param = current pin state (0 or 1)
[posts on both press AND release (so state machine can track state)]
Check4GateSwitch (RA0, debounced):
read current pin state
IF state changed: reset debounce counter
ELSE: increment counter
IF counter >= threshold (stable for ~20 checks):
IF new stable state differs from last accepted state:
post ES_GATE_SW with param = current pin state
[posts on both transitions]
Check4Accel (ADC channel 0, RB2):
read accelerometer ADC value
IF value > THRESHOLD (300) AND was previously below:
post ES_ACCEL_HI with raw ADC value
mark as high
return true
IF value < THRESHOLD AND was previously above:
post ES_ACCEL_LO with raw ADC value
mark as low
return true
CheckPairingKnob (ADC channel 1):
read knob ADC value
IF outside active range (175–900): ignore
compute zone (1–5) from position within active range
apply hysteresis: only accept zone change if knob moved
well past the boundary (20 ADC counts hysteresis)
IF zone changed:
post ES_ENTER_PAIRING_MODE with zone number as param
special case: zone 5 sets param = 6
return true