edge lab LIVE! starts Tuesday in Munich. Grab a workshop seat and take home the board you build on. Code FRIEND-HACKSTER saves 20%. () Learn more!
Lauren Willihnganzayal
Created February 6, 2026

Compborg Second Spine

A speculative exo-organ for survivors of the Global Topsoil Depletion of 2080.

Work in progress31
Compborg Second Spine

Things used in this project

Hardware components

Raspberry Pi Pico 2 W
×2
Solar Cockroach Vibrating Disc Motor
Brown Dog Gadgets Solar Cockroach Vibrating Disc Motor
×3
General Purpose Transistor NPN
General Purpose Transistor NPN
×3
Gravity: Analog Soil Moisture Sensor For Arduino
DFRobot Gravity: Analog Soil Moisture Sensor For Arduino
×1
Resistor 220 ohm
Resistor 220 ohm
×3
Solderless Breadboard Half Size
Solderless Breadboard Half Size
×2
USB-A to Micro-USB Cable
USB-A to Micro-USB Cable
×2
Jumper wires (generic)
Jumper wires (generic)
×12

Software apps and online services

cursor
thonny
chatgpt

Hand tools and fabrication machines

Soldering iron (generic)
Soldering iron (generic)
Solder Wire, Lead Free
Solder Wire, Lead Free

Story

Read more

Custom parts and enclosures

spine components

this is what we used to 3D print the spine

human model to visualize

this is what we used to scale and visualize the spine-wearable (rhino file too large to upload)

Code

compost_test.py

Python
This is the code I used to test the soil moisture sensor. I wanted it to read the moisture and then output a number depending on the level of moistness. The idea was to use the number (in a future code) to tell the haptic motor discs on the wearable to activate in a certain way.

*The values given to the moisture levels are approximates and should be tested with your own sensors*

**The sensor can be very finicky. If you don't have access to soil, try wrapping a damp paper towel around BOTH sides of the sensor to play with dampness levels while testing.**
from machine import ADC, Pin
import utime

soil = ADC(Pin(26))

def read_avg(samples=10, delay_ms=5):
    total = 0
    for _ in range(samples):
        total += soil.read_u16()
        utime.sleep_ms(delay_ms)
    return total // samples

def soil_to_state(val):
    # PLACEHOLDER THRESHOLDS  you WILL change these
    if val > 52000:
        return 0   # very dry
    elif val > 42000:
        return 1   # dry / medium
    elif val > 30000:
        return 2   # moist / good
    else:
        return 3   # very wet

while True:
    raw = read_avg()
    state = soil_to_state(raw)
    print("raw:", raw, "state:", state)
    utime.sleep(0.5)

motor_test.py

Python
This is the code I used to test if the haptic disc motors were working, and if they could execute different patterns at the same time.

*See the rest of the documentation for wiring the motors*
from machine import Pin, PWM
import utime

motor1 = PWM(Pin(16))
motor2 = PWM(Pin(15))

motor1.freq(200)
motor2.freq(200)

def off():
    motor1.duty_u16(0)
    motor2.duty_u16(0)

while True:
    # motor1: soft long vibration
    motor1.duty_u16(18000)   # try 1200025000
    utime.sleep(0.8)
    motor1.duty_u16(0)

    utime.sleep(0.3)

    # motor2: very gentle jitter
    for _ in range(5):
        motor2.duty_u16(20000)
        utime.sleep(0.08)
        motor2.duty_u16(0)
        utime.sleep(0.12)

    utime.sleep(1)

receiver_test(full).py

Python
This is to make sure that the Motor Node can receive messages from the Compost Node.

Run this code on the Motor Node while the Compost Node is running (sender_test.py).
import network
import socket
import utime

SSID = "xxxx"
PW   = "xxxx"   # "" if open network

PORT = 5005  # must match sender

# --- Connect Wi-Fi ---
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(SSID, PW)

print("Connecting to Wi-Fi...")
while not wlan.isconnected():
    utime.sleep_ms(200)

ip = wlan.ifconfig()[0]
print("RECEIVER IP:", ip)
print("Listening on UDP port:", PORT)

# --- UDP socket: listen ---
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", PORT))   # listen on all interfaces
sock.settimeout(1.0)           # so it doesn't hang forever

while True:
    try:
        data, addr = sock.recvfrom(1024)
        msg = data.decode().strip()
        print("got:", msg, "from:", addr)
    except OSError:
        # timeout, loop again
        pass

main(compostnode).py

Python
This is the "final" (as of now) code for the Compost Node to run. It will read the compost's moisture levels and give these readings to the Motor Node. Run it while the Motor Node is running "main(motornode).py"

*make sure to put in your own info for wifi and wearable IP values*
#V2. Compost node (SENDER) — Pico 2 W / MicroPython
# Reads soil moisture -> maps to state 0–3 -> sends state over UDP
# LED cues:
#   - blinks while connecting to Wi-Fi
#   - short heartbeat blink each loop (alive)
#   - extra pulse when state changes

import network
import socket
import utime
from machine import ADC, Pin

# ---------- LED (robust across Pico W/Pico 2 W builds) ----------
def get_led():
    for name in ("LED", "WL_GPIO0"):
        try:
            return Pin(name, Pin.OUT)
        except Exception:
            pass
    return Pin(25, Pin.OUT)  # fallback

led = get_led()
led.off()

# ---------- SENSOR ----------
soil = ADC(Pin(26))

def read_avg(samples=10, delay_ms=5):
    total = 0
    for _ in range(samples):
        total += soil.read_u16()
        utime.sleep_ms(delay_ms)
    return total // samples

def soil_to_state(val):
    # Tune these thresholds
    if val > 52000:
        return 10
    elif val > 42000:
        return 11
    elif val > 30000:
        return 12
    else:
        return 13

# ---------- WIFI / UDP ----------
SSID = "xxxx"
PW   = "xxxx"

WEARABLE_IP = "xxxx"
PORT = 5005

print("BOOT: sender starting")

wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(SSID, PW)

print("Connecting to Wi-Fi...")
while not wlan.isconnected():
    led.toggle()
    utime.sleep(0.25)

print("Wi-Fi connected")
print("Sender IP:", wlan.ifconfig()[0])

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

# ---------- MAIN LOOP ----------
last_state = None

while True:
    # Alive heartbeat (quick blink)
    led.on()
    utime.sleep(0.03)
    led.off()

    raw = read_avg()
    state = soil_to_state(raw)

    # Send state
    sock.sendto(str(state).encode(), (WEARABLE_IP, PORT))
    print("raw:", raw, "state sent:", state)

    # Extra LED pulse if state changed (nice visual cue)
    if state != last_state:
        led.on()
        utime.sleep(0.12)
        led.off()
        last_state = state

    utime.sleep(1)

sender_test.py

Python
Run this on the Compost Node while the Motor Node is running "receiver_test(full).py". This sends 2s and 3s over to the Motor Node. You should see these being printed on the serial output of the Motor Node.
# Compost node (SENDER) — Pico 2 W / MicroPython
# Sends alternating 2 and 3 over UDP

import network
import socket
import utime

SSID = "xxxx"
PW   = "xxxx"

WEARABLE_IP = "xxxx"   # receiver IP
PORT = 5005

# --- Connect Wi-Fi ---
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(SSID, PW)

print("Connecting to Wi-Fi...")
while not wlan.isconnected():
    utime.sleep_ms(200)

print("Sender IP:", wlan.ifconfig()[0])

# --- UDP socket ---
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

# --- Loop: alternate 2 and 3 ---
msg = 2

while True:
    sock.sendto(str(msg).encode(), (WEARABLE_IP, PORT))
    print("sent:", msg)
    msg = 3 if msg == 2 else 2
    utime.sleep(1)

blink.py

Python
This was the first code I ran to make sure the raspberry pi pico 2 W was working.
from machine import Pin
from utime import sleep

pin = Pin("LED", Pin.OUT)

print("LED starts flashing...")
while True:
    try:
        pin.toggle()
        sleep(1) # sleep 1sec
    except KeyboardInterrupt:
        break
pin.off()
print("Finished.")

receivertest.py

Python
Run this code on the raspberry pi running the Motor Node so that you can get it's IP address
import network
import utime

SSID = "xxxxx"
PW   = "xxxxx"   # "" if open network

wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(SSID, PW)

print("Connecting to Wi-Fi...")

while not wlan.isconnected():
    utime.sleep_ms(200)

ip = wlan.ifconfig()[0]
print("RECEIVER IP:", ip)

# Do nothing else
while True:
    utime.sleep(1)

main(motornode).py

Python
Run this on the Motor Node while the Compost Node is running "main(compostnode).py". This will translate the soil sensor data into haptic patterns on the Motor Node.

*make sure to put in your own info for wifi and wearable IP values*
# Receiver + Wearable Haptics (Pico 2 W) — MicroPython
# - Receives UDP packets (e.g., 10–13) and prints them
# - Debounces state changes: only switches after N consecutive packets
# - Plays a continuous haptic pattern for the *stable* state
# - Patterns are chunked so new packets can be processed frequently

import network
import socket
import utime
from machine import Pin, PWM

# ---------------- Wi-Fi / UDP ----------------
SSID = "Iaac-Wifi"
PW   = "EnterIaac22@"
PORT = 5005

# How stable should a new state be before switching?
# If sender sends ~1 Hz, N=8 means ~8 seconds of stability.
N_CONSECUTIVE = 8

# Treat no packets for this long as "no signal" (optional)
NO_SIGNAL_MS = 5000

# ---------------- Motors ----------------
motor1 = PWM(Pin(16))
motor2 = PWM(Pin(15))
motor3 = PWM(Pin(14))
motor1.freq(200)
motor2.freq(200)
motor3.freq(200)
def off():
    motor1.duty_u16(0)
    motor2.duty_u16(0)
    motor3.duty_u16(0)
# Tune these intensities to taste (2x for stronger feel on prototype)
SOFT   = 28000   # was 14000
MED    = 44000   # was 22000
STRONG = 65535   # was 35000 (capped at max)

# ---------------- Pattern engine ----------------
# Wearable "second spine": m1 = top, m2 = middle, m3 = bottom.
# Direction and intensity encode compost humidity (exo-organ).
# Patterns are "steps"; each call advances so we can check for new packets often.

def pattern_step(state, step_i):
    """
    Returns (next_step_i, sleep_ms).
    State 10 = low humidity (bottom→up), 13 = high (top→down), 11/12 = mid.
    """
    m1 = 0
    m2 = 0
    m3 = 0
    sleep_ms = 150

    if state == 10:
        # Low humidity: wave BOTTOM → UP (m3 → m2 → m1). Calm, slow rise.
        # cycle ~1400ms
        if step_i == 0:
            m1 = 0;    m2 = 0;   m3 = SOFT; sleep_ms = 350
        elif step_i == 1:
            m1 = 0;    m2 = SOFT; m3 = SOFT; sleep_ms = 250
        elif step_i == 2:
            m1 = SOFT; m2 = SOFT; m3 = 0; sleep_ms = 250
        elif step_i == 3:
            m1 = SOFT; m2 = 0;   m3 = 0; sleep_ms = 350
        else:
            m1 = 0;    m2 = 0;   m3 = 0; sleep_ms = 400
        next_i = (step_i + 1) % 5

    elif state == 11:
        # Mid humidity: center-out — sensation from MIDDLE spreads up & down.
        # m2 leads, then m1 and m3 join (middle ground, moderate time/intensity).
        # cycle ~1000ms
        if step_i == 0:
            m1 = 0;    m2 = MED;  m3 = 0; sleep_ms = 280
        elif step_i == 1:
            m1 = SOFT; m2 = MED;  m3 = SOFT; sleep_ms = 280
        elif step_i == 2:
            m1 = 0;    m2 = 0;   m3 = 0; sleep_ms = 440
        next_i = (step_i + 1) % 3

    elif state == 12:
        # Mid humidity: converge to center — TOP & BOTTOM in, then MIDDLE.
        # m1 and m3 tap, then m2 (moisture “meeting” in the middle).
        # cycle ~1000ms
        if step_i == 0:
            m1 = MED;  m2 = 0;   m3 = MED; sleep_ms = 220
        elif step_i == 1:
            m1 = 0;    m2 = MED; m3 = 0; sleep_ms = 320
        else:
            m1 = 0;    m2 = 0;   m3 = 0; sleep_ms = 460
        next_i = (step_i + 1) % 3

    elif state == 13:
        # High humidity: wave TOP → DOWN (m1 → m2 → m3). Urgent, strong, faster.
        # cycle ~900ms
        if step_i == 0:
            m1 = STRONG; m2 = 0;      m3 = 0; sleep_ms = 120
        elif step_i == 1:
            m1 = STRONG; m2 = STRONG; m3 = 0; sleep_ms = 100
        elif step_i == 2:
            m1 = 0;      m2 = STRONG; m3 = STRONG; sleep_ms = 100
        elif step_i == 3:
            m1 = 0;      m2 = 0;      m3 = STRONG; sleep_ms = 120
        else:
            m1 = 0;      m2 = 0;      m3 = 0; sleep_ms = 460
        next_i = (step_i + 1) % 5

    else:
        next_i = 0
        m1 = 0; m2 = 0; m3 = 0; sleep_ms = 200

    motor1.duty_u16(m1)
    motor2.duty_u16(m2)
    motor3.duty_u16(m3)
    return next_i, sleep_ms

# ---------------- Connect Wi-Fi ----------------
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(SSID, PW)

print("Connecting to Wi-Fi...")
while not wlan.isconnected():
    utime.sleep_ms(200)

ip = wlan.ifconfig()[0]
print("RECEIVER IP:", ip)
print("Listening on UDP port:", PORT)

# ---------------- UDP socket ----------------
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", PORT))
sock.settimeout(0.05)  # short timeout so we can run patterns smoothly

# ---------------- Debounce state machine ----------------
current_state = 11          # default stable state if nothing received yet
candidate_state = None
candidate_count = 0
last_packet_ms = utime.ticks_ms()

pattern_i = 0

print("Starting haptics. Stable switch requires", N_CONSECUTIVE, "consecutive packets.")

while True:
    # 1) Try to receive packets (non-blocking-ish due to short timeout)
    try:
        data, addr = sock.recvfrom(1024)
        msg = data.decode().strip()
        last_packet_ms = utime.ticks_ms()

        # Parse message as int, ignore garbage
        try:
            received = int(msg)
        except ValueError:
            print("got non-int:", msg, "from:", addr)
            received = None

        if received is not None:
            print("got:", received, "from:", addr)

            # Debounce logic: only switch after N consecutive of the new value
            if received == current_state:
                candidate_state = None
                candidate_count = 0
            else:
                if received == candidate_state:
                    candidate_count += 1
                else:
                    candidate_state = received
                    candidate_count = 1

                if candidate_count >= N_CONSECUTIVE:
                    current_state = candidate_state
                    candidate_state = None
                    candidate_count = 0
                    pattern_i = 0  # restart pattern when we switch
                    print("SWITCHED stable state ->", current_state)

    except OSError:
        # timeout/no data, continue
        pass

    # 2) Optional: no-signal behavior
    if utime.ticks_diff(utime.ticks_ms(), last_packet_ms) > NO_SIGNAL_MS:
        # If you prefer "hold last state", comment out these two lines:
        off()
        utime.sleep_ms(100)
        continue

    # 3) Play one small chunk of the current state's pattern
    pattern_i, sleep_ms = pattern_step(current_state, pattern_i)
    utime.sleep_ms(sleep_ms)

Credits

Lauren Willihnganz
4 projects • 2 followers
ayal
3 projects • 1 follower
hi :)
Thanks to Agnese.

Comments