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

Sensor initialisation

The VL53L0X is initialised at module load (before run() is called) with up to 5 attempts and 500 ms between each. If all fail, the device logs the error and calls machine.reset() after a 3-second delay — the next boot will OTA-sync and retry.

If the sensor fails mid-run (20 consecutive bad reads), a reinitialisation is attempted in-loop via VL53L0X(i2c) without rebooting. Sensor status is reported as "offline" until a successful read recovers it.

Configuration

Config is read from /config.json on the device filesystem at boot via platform.get_config(). All keys are optional — defaults are applied in code if a key is absent. Update at runtime via POST /config; hardware-level settings (pin assignments, LED count) require a reboot to take effect.

KeyDefaultDescription
min_dist50 Minimum valid distance in mm. Readings below this are clamped upward. Also the "full" end of the LED scale.
max_dist1500 Maximum valid distance in mm. Readings above this are returned as max_dist — not counted as a sensor failure. Also the "empty" end of the LED scale.
alert_dist200 Distance threshold in mm. Below this, LEDs switch to full alert brightness (255) regardless of the normal brightness setting.
led_count10 Number of NeoPixel LEDs in the strip. Requires reboot to take effect.
brightness128 Normal LED brightness (0–255). Alert brightness is always 255.
location"unknown" Human-readable location label. Written into META and reported to the IoT registry at startup.

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"}'

Sensor Loop

The main run() coroutine executes at approximately 10 Hz (asyncio.sleep_ms(100)). Each iteration reads the sensor, smooths the value, drives the LED bar, and pushes telemetry to the platform.

Reading pipeline

# 1. Raw read
raw = tof.read()

if raw <= 0:           # hardware error → increment failures, return max
    raise Exception("invalid low reading")

if raw > MAX_DIST_MM:  # out-of-range — NOT a failure
    return MAX_DIST_MM

dist = clamp(raw, MIN_DIST_MM, MAX_DIST_MM)

# 2. Smooth (3-sample rolling average)
_history.pop(0)
_history.append(dist)
smoothed = sum(_history) / len(_history)

# 3. LED bar: more LEDs lit = object closer
num_lit = int((1 - (smoothed - MIN_DIST) / (MAX_DIST - MIN_DIST)) * NUM_LEDS)
num_lit = clamp(num_lit, 0, NUM_LEDS)

# 4. Colour: green (far) → red (close)
ratio = (MAX_DIST - smoothed) / (MAX_DIST - MIN_DIST)
brightness = 255 if smoothed <= ALERT_DIST else BRIGHTNESS_NORMAL
color = (int(ratio * brightness), int((1 - ratio) * brightness), 0)

Sensor status states

ok
Last valid read was within 5 seconds and consecutive failure count is zero.
warning
Last read was recent but some consecutive failures have accumulated (count > 0, < 20).
offline
20 consecutive failures. In-loop VL53L0X reinitialisation attempted. Returns max_dist until a valid read recovers it.
unknown
No valid read in the last 5 seconds and failure count is below 20. Typically seen at startup or after a long gap.

Crash recovery

The entire 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 an app crash — only on sensor init failure at startup or on OTA update.

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 file survives reboots and is overwritten on each new crash.

Telemetry

The app pushes these keys via platform.report() on every sensor loop iteration (~100 ms). 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 (unsmoothed) distance reading in mm. Clamped to [min_dist, max_dist]. Returns max_dist when the object is out of range.
sensor_status
str
Current sensor health. One of: ok · warning · offline · unknown. See Sensor Loop section.
sensor_failures
int
Consecutive read failures since last successful read. Resets to 0 on success. Triggers reinit at 20.
uptime_s
int
Seconds since app.run() started. Distinct from the platform's own uptime_ms (which counts from main.py start).
num_leds
int
Reported once at module load. Required by the platform's POST /identify endpoint for the blue-flash identification blink.
np_pin
int
NeoPixel GPIO pin number. Reported once at module load. Also 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 the telemetry snapshot. Does not trigger a new sensor read — data is at most ~100 ms stale at normal loop rate.
{
  "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 via an asyncio task. Useful for watching a device in real time without polling /status manually.
{ "status": "high pulse activated (60s)" }
All app endpoints are also listed in GET /endpoints alongside the platform routes.

Logs

Log files are accessible via the platform's GET /logs and GET /logs/<filename> endpoints. The ?lines=N query param returns the last N lines of any file.

FileWritten byContents
boot_log.txtboot.py Current boot session. WiFi state, OTA decisions, file writes, time sync, device_id.
boot_log_1.txt … _5.txtboot.py Rotated boot logs from the previous five boots.
platform_log.txtplatform.py Registry calls, heartbeat results, route registration. Auto-rotates at 8 KB.
crash.txtapp.py Full Python traceback from the most recent unhandled exception in run(). Overwritten on each crash. Absent if no crash has occurred.

Useful commands

# Tail platform log (last 30 lines)
curl http://<device-ip>/logs/platform_log.txt?lines=30

# Check for a crash
curl http://<device-ip>/logs/crash.txt

# Most recent boot log
curl http://<device-ip>/logs/boot_log.txt