Minecraft GPS Compass
License: See Original Project Board / Sensor Interface Cables Environmental Magnetic Movement 3D Printing LCD / TFT Displays Microcontrollers Adafruit Feather Qwiic STEMMA Raspberry Pi MCU
Courtesy of Adafruit
Guide by Ruiz Brothers
Overview
You can build a Minecraft-inspired compass with GPS.
Inspired by summer camp adventures, this compass saves and loads waypoints so you always know the way back.
The menu lets you pick previously saved locations and points you toward them as you explore outdoors.
This feature-packed compass has plenty of settings.
There are light and dark modes for any lighting, a marine mode, and adjustments for magnetic declination and units of measurement.
Powered by an Adafruit Feather RP2350, this project pairs a mini GPS module with a gyroscope and accelerometer.
The boards work together to sense the Earth's magnetic field and gather your GPS location.
Sensor calibration is handled right on the device, with a step-by-step walk-through that needs no computer.
Powered by CircuitPython, the code lets you rename the preset waypoints, set your local magnetic declination, and switch between miles and kilometers.
You can fine-tune the feel by editing the needle smoothing, the button hold time, or the case-fit offsets for your own enclosure.
Parts
Short Feather Male Headers - 12-pin and 16-pin Male Header Set
Adafruit TFT FeatherWing - 3.5" 480x320 Touchscreen for Feathers
Adafruit MAX17048 LiPoly / LiIon Fuel Gauge and Battery Monitor
Short Socket Headers for Raspberry Pi Pico - 2 x 20 pin Female
Premium Colorful Silicone Extension Jumper Wires - 200mm x 30 pc
M2.5X6mm Screws (×8)
M2X6mm Screws (×3)
M3X6mm Screws (×4)
Circuit Diagram
This provides a visual reference for wiring of the components. This diagram was created using Fritzing software.
Install CircuitPython
CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.
CircuitPython QuickStart
Follow this step-by-step to quickly get CircuitPython running on your board.
Download the latest version of CircuitPython for this board via circuitpython.org
Click the link above to download the latest CircuitPython UF2 file.
Save it wherever is convenient for you.
To enter the bootloader, hold down the BOOT/BOOTSEL button (highlighted in red above), and while continuing to hold it (don't let go!), press and release the reset button (highlighted in red or blue above). Continue to hold the BOOT/BOOTSEL button until the RP2350 drive appears!
If the drive does not appear, release all the buttons, and then repeat the process above.
You can also start with your board unplugged from USB, press and hold the BOOTSEL button (highlighted in red above), continue to hold it while plugging it into USB, and wait for the drive to appear before releasing the button.
A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.
You will see a new disk drive appear called RP2350.
Drag the adafruit-circuitpython-boardname-language-version.uf2 file to RP2350.
The RP2350 drive will disappear and a new disk drive called CIRCUITPY will appear.
That's it, you're done! :)
Safe Mode
You want to edit your code.py or modify the files on your CIRCUITPY drive, but find that you can't. Perhaps your board has gotten into a state where CIRCUITPY is read-only. You may have turned off the CIRCUITPY drive altogether. Whatever the reason, safe mode can help.
Safe mode in CircuitPython does not run any user code on startup, and disables auto-reload. This means a few things. First, safe mode bypasses any code in boot.py (where you can set CIRCUITPY read-only or turn it off completely). Second, it does not run the code in code.py. And finally, it does not automatically soft-reload when data is written to the CIRCUITPY drive.
Therefore, whatever you may have done to put your board in a non-interactive state, safe mode gives you the opportunity to correct it without losing all of the data on the CIRCUITPY drive.
Entering Safe Mode
To enter safe mode when using CircuitPython, plug in your board or hit reset (highlighted in red above). Immediately after the board starts up or resets, it waits 1000ms. On some boards, the onboard status LED (highlighted in green above) will blink yellow during that time. If you press reset during that 1000ms, the board will start up in safe mode. It can be difficult to react to the yellow LED, so you may want to think of it simply as a slow double click of the reset button. (Remember, a fast double click of reset enters the bootloader.)
In Safe Mode
If you successfully enter safe mode on CircuitPython, the LED will intermittently blink yellow three times.
If you connect to the serial console, you'll find the following message.
Auto-reload is off. Running in safe mode! Not running saved code. CircuitPython is in safe mode because you pressed the reset button during boot. Press again to exit safe mode. Press any key to enter the REPL. Use CTRL-D to reload.
You can now edit the contents of the CIRCUITPY drive. Remember, your code will not run until you press the reset button, or unplug and plug in your board, to get out of safe mode.
Flash Resetting UF2
If your board ever gets into a really weird state and CIRCUITPY doesn't show up as a disk drive after installing CircuitPython, try loading this 'nuke' UF2 to RP2350. which will do a 'deep clean' on your Flash Memory. You will lose all the files on the board, but at least you'll be able to revive it! After loading this UF2, follow the steps above to re-install CircuitPython.
Download flash erasing "nuke" UF2 for RP2350
Code
Add the CircuitPython Libraries
This project uses several libraries from the Adafruit CircuitPython Library Bundle. Using the Project Bundle below copies them into your lib folder automatically, but for reference the project imports:
adafruit_hx8357 - the 3.5" TFT display driver
adafruit_tsc2007 - the resistive touch controller
adafruit_lis3mdl - the magnetometer
adafruit_lsm6ds - the gyroscope and accelerometer
adafruit_gps - the PA1010D GPS module
adafruit_max1704x - the optional LiPo battery monitor
adafruit_debouncer - button press timing
adafruit_display_text - on-screen labels
Upload the Code and Files
Click the Download Project Bundle button below to download the code and assets as a zip file. Copy the contents to your CIRCUITPY drive, keeping the file and folder structure the same.
# SPDX-FileCopyrightText: 2026 Pedro Ruiz for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""Minecraft Compass - a GPS waypoint navigator with a pixel-art needle.
A Minecraft-styled prop compass for kids navigating a campsite. The needle
points at the cardinal you face (or toward a saved waypoint), driven by a
tilt-compensated heading from the LSM6DSOX+LIS3MDL 9-DoF and a PA1010D GPS.
This file is the orchestrator: it creates the hardware, hands it to the
sensor and UI modules, runs the calibration hand-off if needed, and then
runs the main loop. The detail lives in three modules:
- mc_config: constants and the editable settings (waypoint names, etc.)
- mc_compass: sensor reads, heading math, and storage
- mc_ui: the display - dial, needle, menus, and touch handling
Hardware (all Adafruit): Feather RP2350 8MB, 3.5" TFT FeatherWing (HX8357
+ TSC2007 touch), LSM6DSOX+LIS3MDL 9-DoF, PA1010D GPS, optional MAX17048
battery gauge, and a momentary button on D5. CircuitPython 10.x.
"""
import gc
import time
import board
import digitalio
import displayio
import fourwire
import supervisor
import adafruit_gps
import adafruit_lis3mdl
import adafruit_tsc2007
from adafruit_debouncer import Button
from adafruit_lsm6ds.lsm6dsox import LSM6DSOX
from adafruit_max1704x import MAX17048
from adafruit_hx8357 import HX8357
import mc_compass
import mc_settings
import mc_ui
import mc_waypoints
from mc_compass import (
declination_index, marine_mode, mode, rotation_flipped,
waypoints,
)
from mc_config import (
BUTTON_PIN, DECLINATION_OPTIONS, HEADING_SMOOTHING, LONG_PRESS_MS,
MAX_WAYPOINTS,
MODE_COMPASS, MODE_WAYPOINT, NEEDLE_DEADBAND_DEG,
)
# --- Display: 3.5" TFT FeatherWing (HX8357) over SPI ---
displayio.release_displays()
spi = board.SPI()
# board.SPI() defaults to a conservative baud rate, which made each
# display.refresh() take ~150 ms. Raising it to 24 MHz roughly halved that
# to ~76 ms. The RP2350 SPI clock is an integer divisor of the system clock,
# so it rounds the request DOWN to the nearest achievable rate: 24 MHz lands
# on a good divisor, while 32 MHz rounds to a SLOWER real rate here, so
# 24 MHz is the sweet spot. (Measured.)
display_bus = fourwire.FourWire(
spi, command=board.D10, chip_select=board.D9, baudrate=24_000_000
)
display = HX8357(display_bus, width=480, height=320)
# Manual refresh: the needle is redrawn by clearing its bitmap then drawing
# the rotated image. With auto-refresh on, the display could refresh in the
# gap between clear and draw, showing a blank "blink". Refreshing manually
# once per loop (after all drawing) makes each frame appear all at once.
display.auto_refresh = False
root = displayio.Group()
display.root_group = root
# --- Sensors on the STEMMA QT I2C chain ---
i2c = board.STEMMA_I2C()
mag = adafruit_lis3mdl.LIS3MDL(i2c)
imu = LSM6DSOX(i2c)
# PA1010D GPS on the same chain (I2C 0x10).
gps = adafruit_gps.GPS_GtopI2C(i2c, debug=False)
# Stream just the sentences we need: GGA (fix + sats) and RMC (position).
gps.send_command(b"PMTK314,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0")
# Update position once per second (plenty for walking navigation).
gps.send_command(b"PMTK220,1000")
def detect_battery_gauge():
"""Return a MAX17048 if one answers at 0x36, else None.
The Feather RP2350 has no onboard gauge, so this is an optional external
breakout that may not be mounted yet. If absent (or if it fails to
initialize), the battery indicator is hidden and everything else runs
normally.
"""
try:
return MAX17048(i2c)
except (ValueError, OSError):
return None
battery = detect_battery_gauge()
# TSC2007 resistive touch (I2C 0x48). invert_x + swap_xy map raw touch to
# the 480x320 landscape orientation (per Adafruit's reference for this wing).
touch = adafruit_tsc2007.TSC2007(i2c, invert_x=True, swap_xy=True)
# Momentary button on D5, active-low with an internal pull-up. Button (from
# adafruit_debouncer) tracks press timing: a tap switches mode or closes a
# menu, a long press (>= LONG_PRESS_MS) saves a waypoint.
button_io = digitalio.DigitalInOut(BUTTON_PIN)
button_io.direction = digitalio.Direction.INPUT
button_io.pull = digitalio.Pull.UP
button = Button(button_io, long_duration_ms=LONG_PRESS_MS)
# --- Wire the modules together ---
# mc_compass takes the sensors and loads saved settings + waypoints; mc_ui
# takes the display objects and builds the scene.
mc_compass.setup(imu, mag, gps, battery, button)
mc_ui.setup(display, root, touch)
# Assemble the finished scene: add the background and every UI group to the
# root in draw order, hide the battery gauge if no fuel gauge is present, then
# apply the saved theme and rotation. The overlay groups live in their own
# modules; everything is appended here so the draw order is explicit.
root.append(mc_ui.bg_rect)
root.append(mc_ui.static_group)
root.append(mc_ui.needle_group)
root.append(mc_ui.header_shadow)
root.append(mc_ui.header_fg)
root.append(mc_ui.heading_num_shadow)
root.append(mc_ui.heading_num_fg)
root.append(mc_ui.battery_group)
root.append(mc_ui.inventory_group)
root.append(mc_ui.gear_group)
root.append(mc_waypoints.list_group)
root.append(mc_waypoints.picker_group)
root.append(mc_settings.settings_group)
root.append(mc_settings.info_group)
mc_ui.battery_group.hidden = not mc_compass.hw["battery_present"]
mc_ui.apply_theme()
mc_ui.apply_rotation()
# --- Phase routing ---
# If there is no calibration yet, hand off to the calibration program rather
# than dead-ending. supervisor runs calibration.py on the reload; when it
# finishes it hands control back here. This is what makes field
# recalibration work with no computer or file renaming.
cal = mc_compass.load_calibration()
if cal is None:
print("No calibration found - launching calibration.py")
supervisor.set_next_code_file("calibration.py")
supervisor.reload()
# reload() is not instant; stop here so nothing runs with cal=None.
while True:
time.sleep(0.1)
heading_offset = cal[6]
print("Minecraft Compass running.")
print(f"Heading offset: {heading_offset:0.2f} deg")
print("Tap: switch mode (or close a menu). Hold: save waypoint.")
def handle_save_waypoint():
"""Open the name picker to capture the current fix, or preview it.
With a fix, the picker assigns a name and the waypoint is saved. Without
a fix the picker still opens so the names can be browsed (a preview of
what locations can be called), but tapping one saves nothing and just
reports there is no fix yet. A full list is the one hard block.
"""
if len(waypoints) >= MAX_WAYPOINTS:
mc_ui.flash_message("List full")
return
if not mc_compass.hw["gps"].has_fix:
# Preview mode: open the picker with no coordinates. Selecting a name
# won't save (handled in handle_picker_touch); it alerts instead.
mc_waypoints.open_name_picker(None, None, keep_active=mode[0] == MODE_WAYPOINT)
return
# Capture the fix now and let the picker assign the name. In Waypoint
# Mode keep the current target active (breadcrumb save); in Compass Mode
# make the new point active.
gps_obj = mc_compass.hw["gps"]
mc_waypoints.open_name_picker(
gps_obj.latitude, gps_obj.longitude,
keep_active=mode[0] == MODE_WAYPOINT,
)
# --- Loop state ---
last_needle_update = 0.0
last_text_update = 0.0
last_gps_update = 0.0
last_battery_update = 0.0
last_footer_text = [""] # last footer string, to skip no-op text updates
smoothed_heading = [None] # low-pass filtered heading (None until first read)
drawn_needle_angle = [None] # last angle actually drawn, for the deadband
touch_was_down = [False] # tracks touch state across polls for tap edges
mc_ui.update_battery() # initial read so the icon is correct on frame 1
mc_ui.apply_mode_header(mode[0])
while True:
now = time.monotonic()
# Set True by anything that changes the screen this iteration; the single
# display.refresh() at the bottom fires only when it's True.
needs_refresh = False
# GPS reads are expensive: the adafruit_gps GtopI2C driver reads one byte
# per I2C transaction, so one NMEA sentence is ~70 locked transactions
# (~300-500 ms). Position only changes at 1 Hz for walking, so read once
# per second - the unavoidable stall then happens 4x less often. (A
# faster fix would need a custom bulk-I2C reader the stock lib lacks.)
if now - last_gps_update >= 1.0:
gps.update()
mc_ui.update_nav_target()
last_gps_update = now
event = mc_compass.poll_button()
# Touch: route to whichever overlay is open (picker, list, settings),
# else to the mode's button (inventory in Waypoint, gear in Compass).
# Acts once per tap on the press edge.
touch_point = mc_ui.read_touch()
if touch_point is not None and not touch_was_down[0]:
touch_was_down[0] = True
if not mc_settings.info_group.hidden:
mc_settings.handle_info_touch(touch_point)
elif not mc_waypoints.picker_group.hidden:
mc_waypoints.handle_picker_touch(touch_point)
elif not mc_waypoints.list_group.hidden:
mc_waypoints.handle_list_touch(touch_point)
elif not mc_settings.settings_group.hidden:
mc_settings.handle_settings_touch(touch_point)
elif mode[0] == MODE_WAYPOINT and mc_ui.touched_inventory_button(touch_point):
mc_waypoints.open_list()
elif mode[0] == MODE_COMPASS and mc_ui.touched_gear(touch_point):
mc_settings.open_settings()
needs_refresh = True # a tap may have changed an overlay/menu
elif touch_point is None:
touch_was_down[0] = False
# While an overlay is open, the physical button closes it (matching the
# tap-outside behavior) rather than switching modes or saving behind it.
# A short press closes the topmost overlay; holds are ignored so a
# hold-to-save/theme can't fire behind a menu.
any_overlay = not (mc_waypoints.list_group.hidden and mc_waypoints.picker_group.hidden
and mc_settings.settings_group.hidden
and mc_settings.info_group.hidden)
if any_overlay:
if event == "short":
if not mc_settings.info_group.hidden:
mc_settings.info_group.hidden = True # info closes to settings
elif not mc_waypoints.picker_group.hidden:
mc_waypoints.pending_save.clear() # cancel pending save
mc_waypoints.picker_group.hidden = True
mc_ui.apply_mode_header(mode[0])
elif not mc_settings.settings_group.hidden:
mc_settings.close_settings()
elif not mc_waypoints.list_group.hidden:
mc_waypoints.close_list()
needs_refresh = True
event = "none" # never fall through to mode-switch/save while open
if event == "short":
mode[0] = MODE_WAYPOINT if mode[0] == MODE_COMPASS else MODE_COMPASS
mc_ui.apply_mode_header(mode[0])
needs_refresh = True
elif event == "save":
# Hold-to-save works in both modes. In Compass Mode the new point
# becomes the active target (you saved it to go to it). In Waypoint
# Mode it's saved as a breadcrumb without redirecting the navigation
# you're already following - select it from the list to switch.
handle_save_waypoint()
mc_ui.apply_mode_header(mode[0])
needs_refresh = True
# Sensor reads (I2C) and heading math are the loop's most expensive work,
# so do them only when we actually update the needle (30 Hz) rather than
# every ~5 ms loop.
if now - last_needle_update >= 0.033: # 30 Hz
mag_cal = mc_compass.read_mag_calibrated(cal)
accel = mc_compass.read_accel()
# corrected_heading is magnetic; add declination (E positive) to get
# true heading so compass and waypoint bearings read true north.
declination = DECLINATION_OPTIONS[declination_index[0]]
raw_heading = (
mc_compass.corrected_heading(mag_cal, accel, heading_offset)
+ declination
) % 360
# Low-pass the heading so magnetometer noise doesn't make the needle
# twitch. Jitter both looks bad and forces a large dirty region (slow
# refresh), so smoothing helps the look and the frame rate.
smoothed_heading[0] = mc_compass.smooth_heading(
raw_heading, smoothed_heading[0], HEADING_SMOOTHING
)
heading_deg = smoothed_heading[0]
# When the case is built button-at-bottom, the display is flipped 180
# and the IMU is physically turned 180 with it, so the same facing
# reads a heading 180 off. Correct it so N still reads N.
if rotation_flipped[0]:
heading_deg = (heading_deg + 180) % 360
if mode[0] == MODE_WAYPOINT:
needle_angle, footer_text = mc_ui.waypoint_needle_and_footer(
heading_deg
)
else:
# Marine mode: needle always points to true north. Intuitive mode
# (default): needle points at the cardinal you face.
if marine_mode[0]:
needle_angle = (0.0 - heading_deg) % 360
else:
needle_angle = heading_deg
cardinal = mc_compass.cardinal_from_heading(heading_deg)
footer_text = f"{heading_deg:3.0f} deg {cardinal}"
# Only redraw the needle when it actually moved beyond a small
# deadband. Holding still then does almost no refreshing (the needle
# bitmap clear+rotozoom and the SPI push are the costly steps), and
# tiny sub-degree wobble is ignored.
if (drawn_needle_angle[0] is None
or abs(mc_compass.angle_diff(needle_angle, drawn_needle_angle[0]))
>= NEEDLE_DEADBAND_DEG):
mc_ui.set_needle_heading(needle_angle)
drawn_needle_angle[0] = needle_angle
needs_refresh = True
last_needle_update = now
if now - last_text_update >= 0.2: # 5 Hz text update
# Only touch the labels when the string actually changed. The
# footer is far from the needle, so a footer change forces a
# refresh spanning both (the ~158 ms spikes); skipping no-op
# updates keeps most frames to the needle-only ~76 ms path.
if footer_text != last_footer_text[0]:
mc_ui.heading_num_fg.text = footer_text
mc_ui.heading_num_shadow.text = footer_text
last_footer_text[0] = footer_text
needs_refresh = True
last_text_update = now
if now - last_battery_update >= 10.0: # battery changes slowly
mc_ui.update_battery()
last_battery_update = now
needs_refresh = True
# Restore the header when a brief flashed message ("List full", "No fix
# yet") has expired. Non-blocking, so the needle never freezes for it.
if mc_ui.tick_message():
needs_refresh = True
# Refresh only when something actually changed (at most the 30 Hz needle
# rate), not every ~5 ms loop. Refreshing every loop was pushing the SPI
# display ~200x/sec for no reason, throttling the whole loop.
if needs_refresh:
display.refresh(minimum_frames_per_second=0)
# Collect garbage at a controlled point each loop so GC pauses stay small
# and regular instead of building up and firing mid-render (which showed
# as the needle going smooth-then-choppy and laggy taps).
gc.collect()
time.sleep(0.005)
After copying, your CIRCUITPY drive should contain these files:
code.py - the main program: hardware setup, the screen, and the main loop
mc_config.py - constants and the editable settings
mc_compass.py - the sensors, heading math, and storage
mc_ui.py - the compass dial, needle, and touch handling
mc_waypoints.py - the waypoint list and name picker screens
mc_settings.py - the settings panel and info guide screens
calibration.py - the guided magnetometer calibration
boot.py - filesystem setup so the compass can save waypoints
needle.bmp - the compass needle artwork
d-map.bmp - the magnetic declination map shown in the info guide
The program is split across several files so each part stays small and easy to read. You don't need to edit any of them to use the compass, but the split makes it simple to find a feature if you want to change one.
Customize It
Everything you are likely to want to change lives in the EDITABLE SETTINGS block at the top of mc_config.py. Open that file and look for these values near the top.
Rename the waypoint presets by editing PICK_NAMES. Keep names to about 10 characters so they fit the header cleanly. There are 10 slots:
PICK_NAMES = (
"Home", "Spawn", "Portal", "Mine", "Castle",
"Nether", "End", "Stronghold", "Outpost", "Camp",
)Set your local magnetic declination. This is also adjustable on the device in Settings, so you can usually leave it at zero:
MAGNETIC_DECLINATION = 0.0
Tune the feel of the needle. Lower smoothing values are steadier but slower to respond; the deadband ignores tiny wobble so the needle holds still when you do:
HEADING_SMOOTHING = 0.25 NEEDLE_DEADBAND_DEG = 1.5
Change how long you hold the button to save a waypoint (in milliseconds):
LONG_PRESS_MS = 1500
If you design your own case, the case-fit offsets line the screen content up with your cutout. These were measured for our enclosure and a different case may need them nudged:
ROTATED_X_SHIFT = 21 ROTATED_TOUCH_Y_FIX = 22 HEADER_RIGHT_X = 254
First Boot and Calibration
The first time the compass runs without a saved calibration, it automatically launches the calibration walk-through. Follow the on-screen steps to rotate the device through each axis. When calibration finishes, it saves the result and hands control back to the main program. No file renaming or computer is needed, so you can recalibrate in the field at any time from the Settings menu.
How the Files Fit Together
The files form a simple chain. mc_config.py holds the settings and depends on nothing else. mc_compass.py reads the sensors and does the math. mc_ui.py builds the screen. mc_waypoints.py and mc_settings.py add the menu screens on top. code.py creates the hardware once and hands it to the other files, then runs the loop. Keeping the hardware setup in one place is what lets the rest of the code stay small and avoid tangled imports.
3D Printing
3MF files for 3D printing are oriented and ready to print on FDM machines using PLA filament. Original design source files may be downloaded using the links below.
The dropdown on the Fusion 360 site allows you to pick your preferred 3D file format like STEP, STL, etc.
Slice with settings for PLA material
The parts were sliced using BambuStudio using the slice settings below.
PLA filament 220c extruder
0.2 layer height
10% gyroid infill
200mm/s print speed
Tree Supports
60 C heated bed
Design Source Files
The project assembly was designed in Fusion 360. Once opened in Fusion 360, It can be exported in different formats like STEP, STL and more.
Electronic components like Adafruit's boards, displays, connectors and more can be downloaded from the Adafruit CAD parts GitHub Repo.
Assembly
Solder Short header
Short headers are soldered to the Feather so it fits into the sockets on the TFT FeatherWing.
See the guide below on tips on how to solder headers.
How To Solder Headers
By Erin St Blaine
Sensor frame
Use M2.5x6mm machine screws to mount the sensor to the printed frame.
Position the STEMMA ports as shown to allow the short STEMMA cables to reach each sensor.
Align sensor frame
Position the notch to the USB port and the standoffs to the screw mounts on the TFT display.
Mount sensor frame
Use M3x6mm machine screws to fasten the frame to the display.
Anti-Glare film
For outdoor use a piece of anti-glare film can be cut to size and laid over the screen.
Connect sensors to TFT
Use the STEMMA port on the TFT to connect the Feather into the rest of the daisy chained sensors.
Battery frame
The optional battery monitor can mount to the screw mounts on the battery frame with M2x6mm screws. The 2500mAh LiPo battery slides into the frame with the JST cable facing outward.
The battery plugs into the battery monitor and a JST-PH 2-pin jumper cable connects from the battery monitor to the JST port on the Feather.
Tactile Push Button
To fit inside the case, you'll need to temporally remove the button cap from the switch stem by gently pulling it off.
Align the STEMMA JST PH port on the tactile button in the same direction of the LiPo battery and use an M2.5x6mm machine screw to fasten to the mount on the battery frame.
Connect STEMMA Battery Monitor
Plug in the last STEMMA board into the chain.
Ground Socket Splitter
Measure and cut a jumper wire with a pin end to a two socket header to create a splitter to easily share the ground connection to the button and the slide switch. Bend the pin to allow it to plug into the socket header on the TFT display.
Slide Switch
Use another set of pin to socket jumper wires to easily plug the slide switch into the EN and GND sockets on the TFT.
STEMMA JST PH Jumper Cable
The STEMMA JST PH port can use a cable with pin ends to connect to the sockets on the TFT. Clip off the center red wire and connect the black wire to the shared GND socket and the white wire to Pin 5 socket on the TFT display.
Socket connections
Bend and connect the jumper pins to the sockets on the TFT.
Connect Tactile Button
Plug in the STEMMA JST PH cable into the port on the tactile button.
Align Battery Frame to Sensor Frame
Make sure to route the slide switch cable to the opposite side of the USB port on the Feather before placing the battery frame over the sensor frame.
The stand-offs on the battery frame align to the screws mounts on the sensor frame.
Tactile Button Slot
The sensor frame features a cavity to allow the push button PCB to seat into.
Attach battery frame to sensor frame
Use M2.5x6mm machine screws to attach together.
Insert assembly into case
Align the Feather USB port to the cutout on the case. Insert the assembly at an angle and place the USB port against the cutout.
Attach Slide Switch to the Lid
The slide switch press fits into the cutout and walls on the lid with the wires coiled up on the opposite side of the USB port.
Fasten Lid to Case
Align the walls on the lid to battery frame to help it hold the assembly against the USB port. Use M2.5x6mm machine screws to attach the lid to the case.
Reattach the Button Cap
Align the button cap the stem on the push button and complete the build!
Use
The compass has two physical inputs: the touchscreen and the side button.
Tap the button - switch between Compass Mode and Waypoint Mode (or close an open menu)
Hold the button - save your current location as a waypoint
Tap the gear (Compass Mode) - open Settings
Tap the menu icon (Waypoint Mode) - open the waypoint list
Tap outside a menu - close it
Boot Up
Power on with the side slide switch. The compass starts in Compass Mode. The first time it runs without a saved calibration, it launches the calibration walk-through automatically. After calibration it returns here on its own.
Compass Mode
The needle points toward the direction you are facing, and the heading is shown below the dial in degrees with its cardinal letter, like 045 deg NE. Hold the device flat for the most accurate reading; the heading is tilt-compensated, so a small tilt is fine. A gear icon sits in the bottom corner to open Settings.
Waypoint Mode
The needle points toward your active saved waypoint instead of a compass direction, and the footer shows the distance to it. The header shows the waypoint's name. A menu icon sits in the bottom corner to open the waypoint list. Before the GPS has a fix, the footer shows that it is still searching for satellites.
Battery
With the optional LiPo monitor installed, a battery gauge appears in the top corner showing the charge level, with a small bolt while charging. If no monitor is installed, the gauge is hidden and everything else works normally.
Alerts
Short messages appear briefly in the header and then clear on their own. No fix yet means a waypoint was selected before the GPS locked on; the choice still applies and navigation begins once a fix is acquired. List full means all waypoint slots are used and you need to delete one before saving another.
The Side Button
A quick tap of the side button switches between Compass Mode and Waypoint Mode. If a menu is open, a tap closes it instead.
Holding the button saves your current GPS location as a waypoint and opens the name picker. In Compass Mode the new waypoint becomes your active target. In Waypoint Mode the new point is saved as a breadcrumb without interrupting the waypoint you are already navigating toward, so you can drop a marker and keep going.
Saving and Naming a Waypoint
Holding the button opens the name picker, a grid of preset names. Tap a name to save the location under it. If there is no GPS fix yet, the picker still opens so you can browse the names, and its title shows Names (no fix); tapping a name in this state saves nothing and reminds you that there is no fix yet.
The Waypoint List
Tap the menu icon in Waypoint Mode to open the list of saved waypoints. Use the arrows to scroll if you have more than fit on screen.
Each row uses an arm-then-confirm tap to prevent accidents. Tap a waypoint's name once to arm it; it highlights gold. Tap it again to make it your active navigation target and close the list.
To delete a waypoint, tap its trash box once to arm it; it turns red. Tap it again to confirm the delete. Tapping elsewhere cancels the armed state.
Settings
Tap the gear icon in Compass Mode to open Settings. Scroll with the arrows. Each option uses an arm-then-confirm tap: tap a row to arm it, then tap again to apply the change. The available settings are:
Theme - switch between dark and light. Light mode is easier to read in bright sunlight.
Marine Mode - the needle points to true north instead of the direction you face.
Units - switch waypoint distances between miles and kilometers.
Declination - adjust for the difference between magnetic north and true north for your area. While this row is armed, the arrows step the value up and down.
Rotation - flip the display 180 degrees, for a case built with the button at the bottom.
Recalibrate - restart the magnetometer calibration walk-through. This row arms red as a confirmation, since it reboots into calibration.
The Info Guide
Tap the question mark in the Settings title bar to open the info guide. It has two pages: a summary of the controls, and a magnetic declination map to help you look up your local value. Tap inside to flip pages, and tap the X or outside to return to Settings.
Magnetic Declination
True north and magnetic north are not the same, and the difference depends on where you are. For the most accurate headings, set your local declination in Settings. The info guide includes a map, or you can look up your value from an online declination calculator. If you leave it at zero the compass still works; headings will simply be off by your local declination amount.
Recalibrating in the Field
Magnetometers can drift, especially near metal or electronics. If the heading seems off, open Settings and choose Recalibrate. The device reboots into the guided walk-through, and when it finishes it returns to the compass. No computer is needed, so you can do this anywhere.

