Seems to be expensive and hard to obtain? I’m only seeing links to buy in the UK so far. Andy
Easy in Canada. Should be fairly easy in UK and USA too.
I don’t for the rest of the world.
Robotshop.com seems to be the only reseller for now.
Some AOG kit sellers begin to sell it in Europa.
OK. I have added a generic IMU connector and four mounting holes. This will allow any type of SPI or UART IMU to be used. There is just enough space to fit the TM171 on a daughter board and mount it with the holes. ![]()
Andy
Case designed. Not sure how I will mount it into the tractor. There is a metal frame from an old laser system. Any suggestions? I was thinking a RAM mount but it might vibrate too much for the IMU to cope?
Andy
Changed the valve daughterboard so that one of the five possible valves has an integrated relay, controlled by the Teensy. Andy
Added current sensing for the fifth valve and added the analog WAS circuit from the AOG V4.5 Standard board. Andy
Added a connector to the valve daughterboard so an optional 12V to 24V converter can be used.
I think using this, along with the relay, current sensing and WAS circuit, I will hopefully be able to use motor driven autosteer with this board.
Andy
Inspired by the recent thread on using an IMU for a WAS, I decided to switch to a metal housing with o-ring for my IMUs and use a waterproof M12 connector instead of Deutsch. Here is the 3D printed prototype. Andy

I’m trying to build a simple python dozer blade visualization, it use my Emlid rs3 ETC NMEA msg for tilt information.
import pygame
import sys
import math
import serial
import serial.tools.list_ports
import threading
import pynmea2
# --- COLORS & CONFIG ---
FPS = 60
SCREEN_WIDTH, SCREEN_HEIGHT = 1280, 720
COLOR_BG, COLOR_PANEL = (35, 35, 35), (20, 20, 20)
COLOR_GREEN, COLOR_YELLOW, COLOR_ORANGE, COLOR_RED, COLOR_BLUE = (0, 255, 0), (255, 255, 0), (255, 165, 0), (255, 0, 0), (0, 100, 255)
COLOR_TEXT, COLOR_UI = (255, 255, 255), (60, 60, 60)
pygame.init()
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("RS3 Machine Control v19 - 3D TILT")
clock = pygame.time.Clock()
# --- FONTS ---
FONT_XL = pygame.font.SysFont('Arial', 75, bold=True)
FONT_MED = pygame.font.SysFont('Arial', 24, bold=True)
FONT_SMALL = pygame.font.SysFont('Arial', 18)
class GNSSManager:
def __init__(self):
self.elevation = 100.00
self.raw_tilt_value = 0.0 # Field 6: Total Angle
self.raw_tilt_direction = 0.0 # Field 5: Direction of Lean
self.blade_roll = 0.0 # Calculated side-to-side tilt
self.mode = "SIM"
self.ser = None
self.running = False
self.mast_h = 4.0
self.blade_l = 5.0
self.blade_r = 5.0
self.active_input = None
self.input_text = ""
def connect(self, port):
self.stop()
try:
self.ser = serial.Serial(port, 115200, timeout=1)
self.running = True
self.mode = "LIVE"
threading.Thread(target=self.read_serial, daemon=True).start()
except: self.mode = "SIM"
def stop(self):
self.running = False
if self.ser: self.ser.close()
self.mode = "SIM"
def read_serial(self):
while self.running:
try:
line = self.ser.readline().decode('ascii', errors='replace')
if "$GNETC" in line:
parts = line.split(',')
if len(parts) > 6 and parts[2] == '30':
# CAPTURE BOTH TILT COMPONENTS
self.raw_tilt_direction = float(parts[5]) # Where it's leaning
self.raw_tilt_value = float(parts[6]) # How much it's leaning
# CALCULATE ROLL (Side-to-Side Tilt)
# We assume the RS3 is mounted so that 0° or 180° Direction is North/South
# We use Sine to extract the East/West (Roll) component for the blade
dir_rad = math.radians(self.raw_tilt_direction)
self.blade_roll = self.raw_tilt_value * math.cos(dir_rad)
elif "GGA" in line:
msg = pynmea2.parse(line)
self.elevation = msg.altitude * 3.28084
except: pass
gnss = GNSSManager()
def draw_stacked_lights(x, y_center, offset, label=""):
light_h, light_w, gap = 25, 55, 4
thresholds = [0.4, 0.2, 0.05, -0.05, -0.2, -0.4]
colors = [COLOR_RED, COLOR_ORANGE, COLOR_YELLOW, COLOR_GREEN, COLOR_YELLOW, COLOR_ORANGE, COLOR_BLUE]
start_y = y_center - (3 * (light_h + gap)) - (light_h / 2)
for i in range(7):
light_color = (45, 45, 45)
if i == 0 and offset > thresholds[0]: light_color = colors[0]
elif i == 1 and thresholds[0] >= offset > thresholds[1]: light_color = colors[1]
elif i == 2 and thresholds[1] >= offset > thresholds[2]: light_color = colors[2]
elif i == 3 and abs(offset) <= 0.05: light_color = colors[3]
elif i == 4 and thresholds[3] >= offset > thresholds[4]: light_color = colors[4]
elif i == 5 and thresholds[4] >= offset > thresholds[5]: light_color = colors[5]
elif i == 6 and offset <= thresholds[5]: light_color = colors[6]
pygame.draw.rect(screen, light_color, (x, start_y + (i * (light_h + gap)), light_w, light_h), border_radius=2)
screen.blit(FONT_SMALL.render(label, True, (150, 150, 150)), (x + 5, start_y - 25))
screen.blit(FONT_SMALL.render(f"{abs(offset):.2f}", True, COLOR_TEXT), (x + 8, start_y + 185))
def draw_hud_message(offset):
center_x, center_y = 525, 640
if abs(offset) <= 0.05: msg, color, arrow_pts = "ON GRADE", COLOR_GREEN, None
elif offset > 0: msg, color = "CUT", COLOR_RED; arrow_pts = [(center_x - 160, 620), (center_x - 120, 620), (center_x - 140, 660)]
else: msg, color = "FILL", COLOR_BLUE; arrow_pts = [(center_x - 160, 660), (center_x - 120, 660), (center_x - 140, 620)]
txt_surf = FONT_XL.render(f"{msg}: {abs(offset):.2f}", True, color)
screen.blit(txt_surf, (center_x - 80, center_y - 40))
if arrow_pts: pygame.draw.polygon(screen, color, arrow_pts)
def draw_machine_graphic(bx, by, offset, roll):
rad = math.radians(roll)
px_per_ft = 100
off_l = offset + (gnss.blade_l * math.sin(rad))
off_r = offset - (gnss.blade_r * math.sin(rad))
lx, ly = bx - (gnss.blade_l * 40), by - (off_l * px_per_ft)
rx, ry = bx + (gnss.blade_r * 40), by - (off_r * px_per_ft)
slope_pct = ((off_l - off_r) / (gnss.blade_l + gnss.blade_r)) * 100
mx, my = bx, by - (offset * px_per_ft)
mast_top_x = mx + (gnss.mast_h * math.sin(rad) * 10)
mast_top_y = my - (gnss.mast_h * px_per_ft)
pygame.draw.line(screen, (200, 200, 200), (mx, my), (mast_top_x, mast_top_y), 5)
pygame.draw.rect(screen, (255, 255, 255), (mast_top_x - 15, mast_top_y - 20, 30, 20), border_radius=4)
pygame.draw.line(screen, (255, 200, 0), (lx, ly), (rx, ry), 14)
screen.blit(FONT_MED.render(f"SLOPE: {slope_pct:.1f}%", True, COLOR_YELLOW), (bx - 70, by + 45))
draw_stacked_lights(65, 350, off_l, "LEFT")
draw_stacked_lights(910, 350, off_r, "RIGHT")
# --- MAIN LOOP ---
benchmark = 100.00
run = True
ports = [p.device for p in serial.tools.list_ports.comports()]
while run:
screen.fill(COLOR_BG)
for event in pygame.event.get():
if event.type == pygame.QUIT: run = False
if event.type == pygame.MOUSEBUTTONDOWN:
for i, p in enumerate(ports):
if pygame.Rect(180 + (i*110), 10, 100, 30).collidepoint(event.pos): gnss.connect(p)
if pygame.Rect(1050, 50, 200, 60).collidepoint(event.pos): benchmark = gnss.elevation - gnss.mast_h
if pygame.Rect(1050, 200, 200, 35).collidepoint(event.pos): gnss.active_input = 'mast'; gnss.input_text = ""
elif pygame.Rect(1050, 245, 200, 35).collidepoint(event.pos): gnss.active_input = 'left'; gnss.input_text = ""
elif pygame.Rect(1050, 290, 200, 35).collidepoint(event.pos): gnss.active_input = 'right'; gnss.input_text = ""
else: gnss.active_input = None
if event.type == pygame.KEYDOWN and gnss.active_input:
if event.key == pygame.K_RETURN:
try:
v = float(gnss.input_text)
if gnss.active_input == 'mast': gnss.mast_h = v
elif gnss.active_input == 'left': gnss.blade_l = v
elif gnss.active_input == 'right': gnss.blade_r = v
except: pass
gnss.active_input = None
elif event.key == pygame.K_BACKSPACE: gnss.input_text = gnss.input_text[:-1]
else: gnss.input_text += event.unicode
if gnss.mode == "SIM":
keys = pygame.key.get_pressed()
if keys[pygame.K_w]: gnss.elevation += 0.01
if keys[pygame.K_s]: gnss.elevation -= 0.01
gnss.blade_roll = (gnss.blade_roll + 0.5) if keys[pygame.K_a] else (gnss.blade_roll - 0.5) if keys[pygame.K_d] else gnss.blade_roll
pygame.draw.rect(screen, COLOR_PANEL, (50, 80, 950, 500))
pygame.draw.rect(screen, COLOR_UI, (1020, 0, 260, 720))
pygame.draw.line(screen, COLOR_GREEN, (130, 350), (890, 350), 2)
current_off = (gnss.elevation - gnss.mast_h) - benchmark
draw_machine_graphic(500, 350, current_off, gnss.blade_roll)
# UI/Sidebar
status_col = COLOR_GREEN if gnss.mode == "LIVE" else COLOR_YELLOW
pygame.draw.circle(screen, status_col, (30, 25), 8)
screen.blit(FONT_MED.render(f"MODE: {gnss.mode}", True, status_col), (50, 10))
for i, p in enumerate(ports):
pygame.draw.rect(screen, (80, 80, 80), (180 + (i*110), 10, 100, 30), border_radius=5)
screen.blit(FONT_SMALL.render(p, True, COLOR_TEXT), (190 + (i*110), 15))
pygame.draw.rect(screen, (80, 80, 200), (1050, 50, 200, 60), border_radius=8)
screen.blit(FONT_MED.render("BENCHMARK", True, COLOR_TEXT), (1075, 65))
for i, (lab, key, val) in enumerate([("MAST HEIGHT", 'mast', gnss.mast_h), ("BLADE LEFT", 'left', gnss.blade_l), ("BLADE RIGHT", 'right', gnss.blade_r)]):
r = pygame.Rect(1050, 200 + (i*45), 200, 35)
pygame.draw.rect(screen, (100, 100, 255) if gnss.active_input == key else (40, 40, 40), r, border_radius=5)
screen.blit(FONT_SMALL.render(f"{lab}: {gnss.input_text if gnss.active_input == key else f'{val:.2f}'}", True, COLOR_TEXT), (r.x + 10, r.y + 8))
screen.blit(FONT_MED.render(f"GNSS: {gnss.elevation:.2f}'", True, (150, 150, 150)), (60, 610))
screen.blit(FONT_MED.render(f"EDGE: {(gnss.elevation - gnss.mast_h):.2f}'", True, COLOR_TEXT), (60, 640))
draw_hud_message(current_off)
pygame.display.flip()
clock.tick(FPS)
gnss.stop(); pygame.quit()
The case design would take too long to print, so here is the redesigned version - a combo of aluminium extrusions and 3D printed panels.
Total dimensions are 325mm x 147mm x 97 mm.
Andy
Now putting the IMU-GNSS sensor fusing correction values onto the CAN bus. This is purely informational - giving the correction that has already been applied in the Teensy. Values are in mm. The chart shows the corrections for easting and northing changed as I moved the IMU.
Andy












