🌐 Level 6 – Wi-Fi Robotics

Project 6.2: "Tilt Control (Accelerometer)"

 

What you’ll learn

  • ✅ Read tilt data: Get X, Y, Z acceleration from the ADXL345 (GY‑291) over I2C.
  • ✅ Map tilt to motion: Turn “device tilt” into clear commands like FWD/BACK/LEFT/RIGHT.
  • ✅ Control by tilt: Use thresholds and dead‑zones to avoid jittery control.
  • ✅ Gesture commands: Detect simple gestures (shake, nod, double tilt) to trigger actions.
  • ✅ Calibration and filtering: Apply offsets and smoothing to make data reliable.

Blocks glossary (used in this project)

  • I2C init (Pins 22/21): Opens the I2C bus to talk to the ADXL345.
  • Register read/write: Configures sensor power and range; reads axis registers.
  • Variables: Store thresholds, offsets, and moving averages.
  • If / else: Converts tilt ranges into commands.
  • Serial println: Prints “AXIS:X,Y,Z”, “CMD:FWD”, “GESTURE:SHAKE”, and “CAL:OK”.

What you need

PartHow many?Pin connection
D1 R321USB cable (30 cm)
GY‑291 ADXL3451I2C: SCL → Pin 22, SDA → Pin 21, VCC, GND
Jumper wires4–6Keep short and secure

Notes

  • Default ADXL345 I2C address is 0x53.
  • Share ground between the R32 and the sensor. Keep wires short to reduce noise.

Before you start

  • USB cable is plugged in
  • Serial monitor is open
  • Test print shows:
print("Ready!")  # Confirm serial is working so you can see messages

Microprojects 1–5

Microproject 6.2.1 – Reading X, Y, Z axes

Goal: Initialize ADXL345 and print signed X/Y/Z acceleration (raw counts) continuously.
Blocks used:

  • I2C init: Pins 22 (SCL), 21 (SDA).
  • Register write: POWER_CTL (0x2D) to start measurement; DATA_FORMAT (0x31) to ±2g.
  • Register read: DATAX0..DATAZ1 (0x32–0x37) for axes.

MicroPython code:

import machine  # Import machine to access I2C and pins
import time  # Import time to add small delays between reads

# ADXL345 constants
ADXL_ADDR = 0x53  # I2C address for ADXL345 (default 0x53)
REG_POWER_CTL = 0x2D  # Register to control measurement mode
REG_DATA_FORMAT = 0x31  # Register to set data range and format
REG_DATAX0 = 0x32  # First axis data register (X low byte)

# I2C setup
i2c = machine.SoftI2C(scl=machine.Pin(22), sda=machine.Pin(21), freq=100000)  # Create I2C bus on pins 22/21

# Sensor init: measurement ON, ±2g range
i2c.writeto_mem(ADXL_ADDR, REG_POWER_CTL, b'\x08')  # Set MEASURE bit (start measurements)
i2c.writeto_mem(ADXL_ADDR, REG_DATA_FORMAT, b'\x00')  # Set ±2g, right-justified, 10-bit default

print("Microproject 6.2.1: ADXL345 ready at 0x53")  # Confirm sensor initialization

def read_xyz():  # Define a helper to read all axes at once
    buf = i2c.readfrom_mem(ADXL_ADDR, REG_DATAX0, 6)  # Read 6 bytes: X0,X1,Y0,Y1,Z0,Z1
    x = int.from_bytes(buf[0:2], 'little', signed=True)  # Convert X bytes to signed int
    y = int.from_bytes(buf[2:4], 'little', signed=True)  # Convert Y bytes to signed int
    z = int.from_bytes(buf[4:6], 'little', signed=True)  # Convert Z bytes to signed int
    return x, y, z  # Return tuple of axes

while True:  # Continuous reading loop
    x, y, z = read_xyz()  # Read axes from sensor
    print("AXIS:", x, y, z)  # Print raw axis values (counts)
    time.sleep(0.1)  # Short delay to keep output readable

Reflection: Raw axis values show how the board is tilted or resting—Z is strong when lying flat.
Challenge:

  • Easy: Change the loop delay to 0.05 s for faster reads.
  • Harder: Convert counts to g by dividing by 256 (approx for ±2g mode) and print “g” values.

Microproject 6.2.2 – Tilt‑to‑motion mapping

Goal: Map tilt to motion commands with dead‑zones to reduce jitter.
Blocks used:

  • Variables: tilt_threshold and dead_zone.
  • If / else: Print CMD:FWD/BACK/LEFT/RIGHT based on X/Y.

MicroPython code:

import machine  # Import machine for I2C access
import time  # Import time for loop delays

# Reuse constants and I2C from previous microproject
ADXL_ADDR = 0x53  # I2C address
REG_POWER_CTL = 0x2D  # Power control register
REG_DATA_FORMAT = 0x31  # Data format register
REG_DATAX0 = 0x32  # Axis data start register

i2c = machine.SoftI2C(scl=machine.Pin(22), sda=machine.Pin(21), freq=100000)  # Create I2C bus
i2c.writeto_mem(ADXL_ADDR, REG_POWER_CTL, b'\x08')  # Enable measurement
i2c.writeto_mem(ADXL_ADDR, REG_DATA_FORMAT, b'\x00')  # Set ±2g range

def read_xyz():  # Helper to read all axes
    buf = i2c.readfrom_mem(ADXL_ADDR, REG_DATAX0, 6)  # Read 6 bytes for X,Y,Z
    x = int.from_bytes(buf[0:2], 'little', signed=True)  # Convert X to signed
    y = int.from_bytes(buf[2:4], 'little', signed=True)  # Convert Y to signed
    z = int.from_bytes(buf[4:6], 'little', signed=True)  # Convert Z to signed
    return x, y, z  # Return values

tilt_threshold = 150  # Minimum absolute tilt count to trigger a command
dead_zone = 80  # Range near zero where we print STOP to avoid jitter
print("Microproject 6.2.2: Thresholds -> tilt:", tilt_threshold, "dead:", dead_zone)  # Show thresholds

while True:  # Continuous mapping loop
    x, y, z = read_xyz()  # Read axes
    print("AXIS:", x, y, z)  # Print axes for visibility
    if abs(x) < dead_zone and abs(y) < dead_zone:  # If inside dead-zone
        print("CMD:STOP")  # Print stop to avoid jitter
    else:  # Outside dead-zone
        if y > tilt_threshold:  # Tilt “forward” (positive Y)
            print("CMD:FWD")  # Forward command
        elif y < -tilt_threshold:  # Tilt “backward” (negative Y)
            print("CMD:BACK")  # Backward command
        elif x > tilt_threshold:  # Tilt “right” (positive X)
            print("CMD:RIGHT")  # Right command
        elif x < -tilt_threshold:  # Tilt “left” (negative X)
            print("CMD:LEFT")  # Left command
        else:  # Between thresholds
            print("CMD:STOP")  # Default stop if no strong tilt
    time.sleep(0.1)  # Short delay for readability

Reflection: Dead‑zones make control smooth—commands change only when tilt is clear.
Challenge:

  • Easy: Print “CMD:DIAG_FWD_LEFT/RIGHT” when both X and Y exceed the threshold.
  • Harder: Create a “speed” level from tilt magnitude and print “SPEED:LOW/MID/HIGH”.

Microproject 6.2.3 – Robot control by tilt (safe command stream)

Goal: Emit a clean stream of commands: only print when the command changes to reduce spam.
Blocks used:

  • Variables: last_cmd remembers the last printed command.
  • If / else: Only print when current != last.

MicroPython code:

import machine  # Import machine for I2C hardware
import time  # Import time for loop pacing

# Reuse constants and I2C
ADXL_ADDR = 0x53  # Sensor address
REG_POWER_CTL = 0x2D  # Power control register
REG_DATA_FORMAT = 0x31  # Data format register
REG_DATAX0 = 0x32  # Axis data register start

i2c = machine.SoftI2C(scl=machine.Pin(22), sda=machine.Pin(21), freq=100000)  # Create I2C bus
i2c.writeto_mem(ADXL_ADDR, REG_POWER_CTL, b'\x08')  # Enable measurement mode
i2c.writeto_mem(ADXL_ADDR, REG_DATA_FORMAT, b'\x00')  # ±2g range

def read_xyz():  # Helper to read axes
    buf = i2c.readfrom_mem(ADXL_ADDR, REG_DATAX0, 6)  # Read 6 bytes
    x = int.from_bytes(buf[0:2], 'little', signed=True)  # X signed
    y = int.from_bytes(buf[2:4], 'little', signed=True)  # Y signed
    z = int.from_bytes(buf[4:6], 'little', signed=True)  # Z signed
    return x, y, z  # Return values

tilt_threshold = 150  # Tilt threshold for commands
dead_zone = 80  # Dead-zone to avoid jitter
last_cmd = "STOP"  # Start with STOP as the last command
print("Microproject 6.2.3: Streaming commands with change-only prints")  # Status message

while True:  # Continuous control loop
    x, y, z = read_xyz()  # Read axes
    cmd = "STOP"  # Default command
    if abs(x) < dead_zone and abs(y) < dead_zone:  # Inside dead-zone
        cmd = "STOP"  # Keep stop
    else:  # Outside dead-zone
        if y > tilt_threshold:  # Forward tilt
            cmd = "FWD"  # Forward command
        elif y < -tilt_threshold:  # Backward tilt
            cmd = "BACK"  # Backward command
        elif x > tilt_threshold:  # Right tilt
            cmd = "RIGHT"  # Right command
        elif x < -tilt_threshold:  # Left tilt
            cmd = "LEFT"  # Left command
        else:  # Weak tilt region
            cmd = "STOP"  # Stop command
    if cmd != last_cmd:  # If command changed
        print("CMD:", cmd)  # Print only the new command
        last_cmd = cmd  # Update last printed command
    time.sleep(0.08)  # Short delay for responsive control

Reflection: Change‑only prints make app parsing and robot reactions more stable.
Challenge:

  • Easy: Add a small “COOLDOWN=200 ms” after any command change.
  • Harder: Add “CMD:HOLD” if the same command stays active for >2 s.

Microproject 6.2.4 – Gestures for commands

Goal: Detect simple gestures (shake, nod, double tilt) and print special commands.
Blocks used:

  • Variables: windows and counters.
  • If / else: Compare axis magnitudes to thresholds for gesture detection.

MicroPython code:

import machine  # Import machine for I2C
import time  # Import time for gesture windows

# Reuse constants and I2C
ADXL_ADDR = 0x53  # Sensor address
REG_POWER_CTL = 0x2D  # Power control register
REG_DATA_FORMAT = 0x31  # Data format register
REG_DATAX0 = 0x32  # Axis data start

i2c = machine.SoftI2C(scl=machine.Pin(22), sda=machine.Pin(21), freq=100000)  # Create I2C bus
i2c.writeto_mem(ADXL_ADDR, REG_POWER_CTL, b'\x08')  # Start measurement
i2c.writeto_mem(ADXL_ADDR, REG_DATA_FORMAT, b'\x00')  # ±2g range

def read_xyz():  # Helper to read axes
    buf = i2c.readfrom_mem(ADXL_ADDR, REG_DATAX0, 6)  # Read 6 bytes
    x = int.from_bytes(buf[0:2], 'little', signed=True)  # X signed
    y = int.from_bytes(buf[2:4], 'little', signed=True)  # Y signed
    z = int.from_bytes(buf[4:6], 'little', signed=True)  # Z signed
    return x, y, z  # Return values

shake_threshold = 400  # Large magnitude change considered a shake
nod_threshold = 250  # Y-axis magnitude for nod gesture
double_window_ms = 600  # Time window to confirm a double tilt
print("Microproject 6.2.4: Gestures -> shake:", shake_threshold, "nod:", nod_threshold)  # Show thresholds

last_event_time = 0  # Store time of the last detected tilt event
last_event = ""  # Store type of last tilt event

while True:  # Continuous gesture detection loop
    x, y, z = read_xyz()  # Read axes from sensor
    mag = abs(x) + abs(y) + abs(z)  # Compute simple magnitude sum
    if mag > shake_threshold:  # If magnitude suggests shaking
        print("GESTURE:SHAKE")  # Print shake gesture
        time.sleep(0.2)  # Debounce after shake
    if abs(y) > nod_threshold:  # If strong tilt on Y
        now = time.ticks_ms()  # Record current time in ms
        if last_event == "NOD" and time.ticks_diff(now, last_event_time) < double_window_ms:  # Check double nod
            print("GESTURE:DOUBLE_NOD")  # Print double nod gesture
            last_event = ""  # Reset last event
        else:  # First nod in sequence
            print("GESTURE:NOD")  # Print nod gesture
            last_event = "NOD"  # Save event type
            last_event_time = now  # Save event time
        time.sleep(0.15)  # Debounce after nod detection
    time.sleep(0.05)  # Small loop delay for responsiveness

Reflection: Gestures add personality—shake and nod let you trigger special actions quickly.
Challenge:

  • Easy: Add “GESTURE:TILT_LEFT” and “GESTURE:TILT_RIGHT” using X thresholds.
  • Harder: Add a “DOUBLE_SHAKE” detector with a 500 ms window.

Microproject 6.2.5 – Data calibration and filtering

Goal: Calibrate offsets and apply a moving average filter for smooth data.
Blocks used:

  • Variables: x_off, y_off, z_off; simple buffers for averaging.
  • Loop: Collect samples and compute offsets.

MicroPython code:

import machine  # Import machine for I2C access
import time  # Import time for sampling and delays

# Reuse constants and I2C
ADXL_ADDR = 0x53  # Sensor address
REG_POWER_CTL = 0x2D  # Power control register
REG_DATA_FORMAT = 0x31  # Data format register
REG_DATAX0 = 0x32  # Axis data start

i2c = machine.SoftI2C(scl=machine.Pin(22), sda=machine.Pin(21), freq=100000)  # Create I2C bus
i2c.writeto_mem(ADXL_ADDR, REG_POWER_CTL, b'\x08')  # Enable measurement
i2c.writeto_mem(ADXL_ADDR, REG_DATA_FORMAT, b'\x00')  # ±2g range

def read_xyz():  # Helper to read raw axes
    buf = i2c.readfrom_mem(ADXL_ADDR, REG_DATAX0, 6)  # Read 6 bytes for X,Y,Z
    x = int.from_bytes(buf[0:2], 'little', signed=True)  # X signed
    y = int.from_bytes(buf[2:4], 'little', signed=True)  # Y signed
    z = int.from_bytes(buf[4:6], 'little', signed=True)  # Z signed
    return x, y, z  # Return raw values

print("Microproject 6.2.5: Hold flat for 1 s to calibrate offsets")  # Ask user to keep board still

# Offset calibration (average of 20 samples)
x_sum = 0  # Start sum for X
y_sum = 0  # Start sum for Y
z_sum = 0  # Start sum for Z
for i in range(20):  # Take 20 samples
    x, y, z = read_xyz()  # Read raw axes
    x_sum += x  # Add X to sum
    y_sum += y  # Add Y to sum
    z_sum += z  # Add Z to sum
    time.sleep(0.05)  # Small delay between samples
x_off = int(x_sum / 20)  # Compute X offset average
y_off = int(y_sum / 20)  # Compute Y offset average
z_off = int(z_sum / 20)  # Compute Z offset average
print("CAL:OFFSETS", x_off, y_off, z_off)  # Print computed offsets

# Moving average buffers (size 5)
buf_x = [0, 0, 0, 0, 0]  # Initialize buffer for X
buf_y = [0, 0, 0, 0, 0]  # Initialize buffer for Y
buf_z = [0, 0, 0, 0, 0]  # Initialize buffer for Z
idx = 0  # Index for circular buffer

while True:  # Continuous filtered output
    x, y, z = read_xyz()  # Read raw axes
    x -= x_off  # Apply X offset
    y -= y_off  # Apply Y offset
    z -= z_off  # Apply Z offset
    buf_x[idx] = x  # Store X in buffer
    buf_y[idx] = y  # Store Y in buffer
    buf_z[idx] = z  # Store Z in buffer
    idx = (idx + 1) % 5  # Move to next index in circular buffer
    x_f = int(sum(buf_x) / 5)  # Compute average X
    y_f = int(sum(buf_y) / 5)  # Compute average Y
    z_f = int(sum(buf_z) / 5)  # Compute average Z
    print("AXIS_FILT:", x_f, y_f, z_f)  # Print filtered axes
    time.sleep(0.08)  # Short delay for readability

Reflection: Offsets and averages turn noisy raw data into smooth, trustworthy values.
Challenge:

  • Easy: Increase buffer size to 7 samples for stronger smoothing.
  • Harder: Add a “CAL:OK” flag only if offsets stay within a safe range (e.g., |offset| < 200).

Main project – Tilt control with accelerometer

Blocks steps (with glossary)

  • I2C + sensor init: Start ADXL345 and set ±2g.
  • Calibration: Compute offsets while flat and apply moving average.
  • Tilt mapping: Use thresholds and dead‑zones to produce commands.
  • Gesture layer: Detect shake and double‑nod.
  • Change‑only prints: Reduce spam by printing only when the command changes.

MicroPython code (mirroring blocks)

# Project 6.2 – Tilt Control (Accelerometer)

import machine  # Import machine for I2C pins
import time  # Import time for delays and windows

# ADXL345 constants and registers
ADXL_ADDR = 0x53  # Sensor I2C address
REG_POWER_CTL = 0x2D  # Power control register
REG_DATA_FORMAT = 0x31  # Data format register
REG_DATAX0 = 0x32  # Axis data start register

# I2C setup on pins 22/21
i2c = machine.SoftI2C(scl=machine.Pin(22), sda=machine.Pin(21), freq=100000)  # Create I2C bus
i2c.writeto_mem(ADXL_ADDR, REG_POWER_CTL, b'\x08')  # Enable measurement mode
i2c.writeto_mem(ADXL_ADDR, REG_DATA_FORMAT, b'\x00')  # Set ±2g range

def read_xyz():  # Read signed X/Y/Z counts
    buf = i2c.readfrom_mem(ADXL_ADDR, REG_DATAX0, 6)  # Read 6 bytes from axis registers
    x = int.from_bytes(buf[0:2], 'little', signed=True)  # Convert X bytes to signed int
    y = int.from_bytes(buf[2:4], 'little', signed=True)  # Convert Y bytes to signed int
    z = int.from_bytes(buf[4:6], 'little', signed=True)  # Convert Z bytes to signed int
    return x, y, z  # Return tuple of axes

print("INIT: ADXL345 ready at 0x53")  # Confirm sensor init

# Calibration: hold flat to compute offsets
print("CAL: Hold flat 1 s")  # Instruct user to hold board flat
x_sum = 0  # Initialize X sum
y_sum = 0  # Initialize Y sum
z_sum = 0  # Initialize Z sum
for i in range(20):  # Take 20 samples for calibration
    x, y, z = read_xyz()  # Read raw axes
    x_sum += x  # Add X to sum
    y_sum += y  # Add Y to sum
    z_sum += z  # Add Z to sum
    time.sleep(0.05)  # Small delay between samples
x_off = int(x_sum / 20)  # Compute X offset
y_off = int(y_sum / 20)  # Compute Y offset
z_off = int(z_sum / 20)  # Compute Z offset
print("CAL:OFFSETS", x_off, y_off, z_off)  # Show offsets

# Filtering buffers
buf_x = [0, 0, 0, 0, 0]  # X moving average buffer
buf_y = [0, 0, 0, 0, 0]  # Y moving average buffer
buf_z = [0, 0, 0, 0, 0]  # Z moving average buffer
idx = 0  # Circular buffer index

# Mapping thresholds and state
tilt_threshold = 150  # Tilt threshold for commands
dead_zone = 80  # Dead-zone to avoid jitter
last_cmd = "STOP"  # Start with STOP as last command
shake_threshold = 400  # Shake magnitude threshold
nod_threshold = 250  # Nod threshold on Y
last_event = ""  # Last gesture event
last_event_time = 0  # Time of last gesture event

print("RUN: Tilt control active")  # Announce program start

while True:  # Main control loop
    x, y, z = read_xyz()  # Read raw axes
    x -= x_off  # Apply X offset
    y -= y_off  # Apply Y offset
    z -= z_off  # Apply Z offset

    buf_x[idx] = x  # Store X in buffer
    buf_y[idx] = y  # Store Y in buffer
    buf_z[idx] = z  # Store Z in buffer
    idx = (idx + 1) % 5  # Advance circular buffer index

    x_f = int(sum(buf_x) / 5)  # Average X
    y_f = int(sum(buf_y) / 5)  # Average Y
    z_f = int(sum(buf_z) / 5)  # Average Z
    print("AXIS_FILT:", x_f, y_f, z_f)  # Print filtered axes

    # Gesture layer: shake and double nod
    mag = abs(x_f) + abs(y_f) + abs(z_f)  # Simple magnitude check
    if mag > shake_threshold:  # If shaking detected
        print("GESTURE:SHAKE")  # Print shake gesture
        time.sleep(0.2)  # Debounce after shake

    if abs(y_f) > nod_threshold:  # If strong Y tilt detected
        now = time.ticks_ms()  # Current ms time
        if last_event == "NOD" and time.ticks_diff(now, last_event_time) < 600:  # Double nod window
            print("GESTURE:DOUBLE_NOD")  # Print double nod
            last_event = ""  # Reset last event
        else:  # First nod
            print("GESTURE:NOD")  # Print nod
            last_event = "NOD"  # Save event type
            last_event_time = now  # Save event time
        time.sleep(0.15)  # Debounce nod detection

    # Tilt mapping with dead-zone
    cmd = "STOP"  # Default command
    if abs(x_f) < dead_zone and abs(y_f) < dead_zone:  # Inside dead-zone
        cmd = "STOP"  # Keep stop
    else:  # Outside dead-zone
        if y_f > tilt_threshold:  # Forward tilt
            cmd = "FWD"  # Forward command
        elif y_f < -tilt_threshold:  # Backward tilt
            cmd = "BACK"  # Backward command
        elif x_f > tilt_threshold:  # Right tilt
            cmd = "RIGHT"  # Right command
        elif x_f < -tilt_threshold:  # Left tilt
            cmd = "LEFT"  # Left command
        else:  # Weak tilt
            cmd = "STOP"  # Stop command

    if cmd != last_cmd:  # If command changed
        print("CMD:", cmd)  # Print new command
        last_cmd = cmd  # Update last command
    time.sleep(0.08)  # Short delay for smooth control

External explanation

  • What it teaches: How to configure and read an accelerometer, smooth the data, and convert tilt and gestures into clean commands.
  • Why it works: Offsets remove bias, moving average reduces noise, dead‑zones prevent jitter, and change‑only prints keep control stable and readable.
  • Key concept: “Sense → calibrate → smooth → decide → command.”

Story time

You tilt the board and the robot responds like it’s listening to your hands. A quick nod sends a special action; a shake cancels tasks. Smooth, simple, and surprisingly human.


Debugging (2)

Debugging 6.2.1 – Tilt not detected

Problem: Axis values print, but no CMD:FWD/BACK/LEFT/RIGHT appear.
Clues: AXIS_FILT changes slightly; commands stay “STOP”.
Broken code:

tilt_threshold = 500  # Threshold too high for small tilts

Fixed code:

tilt_threshold = 150  # Lower threshold to a realistic value
print("DEBUG: tilt_threshold=", tilt_threshold)  # Print current threshold

Why it works: A realistic threshold lets typical tilts cross the decision boundary.
Avoid next time: Tune thresholds while watching AXIS_FILT values.

Debugging 6.2.2 – Erratic movement due to vibrations

Problem: Commands flicker quickly during small shakes or steps.
Clues: AXIS_FILT jumps; CMD alternates fast.
Broken code:

dead_zone = 20  # Dead-zone too small to absorb noise

Fixed code:

dead_zone = 80  # Increase dead-zone to ignore minor changes
print("DEBUG: dead_zone=", dead_zone)  # Print dead-zone setting

Why it works: A wider dead‑zone prevents micro‑tilts from triggering new commands.
Avoid next time: Use filtering and dead‑zones together for stability.


Final checklist

  • ADXL345 initializes and prints AXIS raw/filtered values
  • Calibration offsets computed and applied
  • Dead‑zone + thresholds produce stable CMD outputs
  • Gestures (SHAKE, NOD, DOUBLE_NOD) detected reliably
  • Change‑only command stream keeps serial clean

Extras

  • 🧠 Student tip: Print “SPEED:” from tilt magnitude and use it later to scale robot motion.
  • đŸ§‘â€đŸ« Instructor tip: Have students record AXIS and CMD logs during tuning—data makes good thresholds.
  • 📖 Glossary:
    • Dead‑zone: A range around zero where changes are ignored to avoid jitter.
    • Offset (calibration): A correction added/subtracted to remove bias from readings.
    • Moving average: A simple filter that smooths values by averaging recent samples.
  • 💡 Mini tips:
    • Keep wires short on I2C and check grounds to reduce noise.
    • Calibrate on a stable surface; re‑calibrate if temperature changes.
    • Label your prints consistently so apps can parse them easily.
On this page