rforssen.net / iot / app

distance-meter

app.py — VL53L0X ToF sensor + NeoPixel strip
deviceRaspberry Pi Pico W
mac28:cd:c1:0d:5f:6b
app_typedistance-meter
firmware2.0.0

Hardware

MCU
Raspberry Pi Pico W
MicroPython · asyncio
Distance sensor
VL53L0X
I2C(0) · SDA=GP4 · SCL=GP5
LED strip
NeoPixel × 10
Pin(0) · WS2812B
Libraries
vl53l0x.py
microdot.py · iot_registry.py

Pin assignment

# I2C bus 0
SDA = GP4
SCL = GP5

# NeoPixel
NP_PIN = 0   # GP0

Configuration

Config is read from /config.json on the device filesystem at boot via platform.get_config(). Update at runtime via POST /config — device must restart to apply changes to hardware-level settings.

Key Default Description
min_dist 50 Minimum valid distance in mm. Readings below this are clamped. Also the "full" end of the LED scale.
max_dist 1500 Maximum valid distance in mm. Readings above this are treated as out-of-range (not a sensor failure). Also the "empty" end of the LED scale.
alert_dist 200 Distance threshold in mm below which LEDs switch to alert brightness (255 vs normal 128).
led_count 10 Number of NeoPixel LEDs in the strip.
brightness 128 Normal LED brightness (0–255). Alert brightness is always 255.
location "unknown" Human-readable location string. Reported in META to the IoT registry.

Example config.json

{
  "min_dist":   50,
  "max_dist":   1200,
  "alert_dist": 150,
  "led_count":  10,
  "brightness": 100,
  "location":   "garage"
}

Update at runtime

curl -X POST http://<device-ip>/config \
  -H "Content-Type: application/json" \
  -d '{"alert_dist": 150, "location": "garage"}'

Telemetry

The app pushes these keys via platform.report() on every sensor loop iteration (~100ms). They appear merged into GET /status alongside platform fields, and are included in each heartbeat to the API.

Key
Type
Description
distance_mm
int
Last raw distance reading in mm. Clamped to [min_dist, max_dist]. Returns max_dist when out-of-range.
sensor_status
str
Current sensor health. One of: ok · warning · offline · unknown. See Sensor Logic section.
sensor_failures
int
Consecutive read failures since last successful reading. Resets to 0 on success. Triggers reinit at 20.
uptime_s
int
Seconds since main.py started (not since boot).
num_leds
int
Reported once at startup. Required by the platform's POST /identify endpoint.
np_pin
int
NeoPixel GPIO pin number. Reported once at startup. Required by POST /identify.

App Endpoints

These are the device-specific endpoints registered by app.py via platform.register_routes(). Platform endpoints (/status, /logs, /restart, etc.) are documented in the platform reference.

GET /distance Current distance reading
Returns the most recent distance value from telemetry. Lightweight — does not trigger a new sensor read.
{
  "distance_mm": 342,
  "distance_cm": 34.2
}
GET /highPulse Activate high heartbeat rate for 60 seconds
Switches heartbeat interval from 5 minutes to 10 seconds for 60 seconds, then reverts automatically. Useful when monitoring a device in real time.
{ "status": "high pulse activated (60s)" }
All app endpoints are also listed in GET /endpoints alongside platform routes.

Sensor Logic

Reading pipeline

The sensor loop runs every 100ms via asyncio.sleep_ms(100). Each iteration: read raw value → clamp → update 3-sample rolling average → drive LEDs → push telemetry.

# Reading pipeline
raw = tof.read()

if raw <= 0:          # hardware error → increment failures
    raise Exception

if raw > MAX_DIST_MM:  # out of range → NOT a failure, return max
    return MAX_DIST_MM

dist = clamp(raw, MIN_DIST_MM, MAX_DIST_MM)
# rolling average over 3 samples → smoothed
# num_lit LEDs proportional to (1 - normalised distance)

Sensor status states

ok
Last successful read was within 5 seconds and failure count is zero.
warning
Last read was recent but some failures have occurred (failures > 0 but < 20).
offline
20 consecutive failures. Platform attempts automatic reinitialisation of VL53L0X via I2C. If reinit fails, status stays offline.
unknown
No successful read in the last 5 seconds and failure count is below 20. Typically seen at startup.

LED colour mapping

LED colour is a green→red gradient based on normalised distance. Number of lit LEDs scales inversely with distance — more LEDs lit = object closer.

# Colour: green (far) → red (close)
ratio = (MAX_DIST - dist) / (MAX_DIST - MIN_DIST)
r = int(ratio * brightness)
g = int((1 - ratio) * brightness)
color = (r, g, 0)

# Alert mode: dist ≤ alert_dist → brightness = 255
brightness = 255 if dist <= ALERT_DIST else BRIGHTNESS_NORMAL

Crash recovery

The main run() loop is wrapped in a try/except. On any unhandled exception, the traceback is written to /crash.txt and the loop sleeps 1 second before continuing. The device does not reboot automatically on app crash — only on sensor init failure at startup.

except Exception as e:
    with open("/crash.txt", "w") as f:
        sys.print_exception(e, f)
    platform.log("run() crashed — see crash.txt")
    await asyncio.sleep_ms(1000)
Check GET /logs/crash.txt if the device is online but not reporting distance. The crash log survives reboots.

Logs

Log files are accessible via the platform's GET /logs and GET /logs/<filename> endpoints.

File Written by Contents
boot_log.txt boot.py Current boot log. WiFi, OTA decisions, file writes.
boot_log_1.txt … _5.txt boot.py Rotated boot logs from previous boots. Up to 5 kept.
platform_log.txt platform.py Registry calls, heartbeat errors, route registration. Rotates at 8KB.
main_log_1.txt app.py (legacy) Heartbeat lines from previous firmware. May be absent after full OTA.
crash.txt app.py Full Python traceback from last unhandled exception in run(). Overwritten on each crash.

Tail the platform log

curl http://<device-ip>/logs/platform_log.txt?lines=30

Check for crashes

curl http://<device-ip>/logs/crash.txt