Portfolio
Instrument the physical world, move its data somewhere useful, and let software — increasingly, AI — act on it. From a garage bioreactor an AI runs on its own to AR glasses, laser shows, and utility meters read out of thin air — here is what I build after hours.
Flagship · Bioscience + AI
The Autonomous Bioreactor
A garage bioreactor grows bioluminescent marine plankton on a 12-hour light cycle while an AI operates it autonomously — and writes the science blog. It is a real Pyrocystis circadian-rhythm experiment, measuring how the organism’s stimulated glow peaks at “subjective night.” Real science, industrial-grade tooling, and an AI agent given genuine operational autonomy.
- How it worksSensor data — optical density, temperature, stir RPM, LED state, and culture mass from a precision balance — streams over MQTT into an Ignition SCADA historian and a live Perspective dashboard with a vessel mimic and trend charts. Between human check-ins, Claude adjusts the run, performs a nightly autonomous reflection, and authors a first-person blog, “Field Notes from a Bioreactor,” under the pen name Scintilla. Stirring happens only in brief daytime bursts to protect the fragile cells; blue light entrains the culture; dark-phase glow imaging is next.
State · Live — running 24/7
These aren’t screenshots — they’re the vessel’s own instruments, read live off the LAN a few seconds ago.
Live from the bioreactor
Temperature
23.1
°C · in-vessel · safe band 19–22
Experiment · Pyrocystis circadian bioluminescence — day 88.7 of the run.
Pyrocystis fusiformis/lunula bioluminescence circadian rhythm study. Batch mode, daytime-only OD sampling, 12:12 blue-light entrainment.
Live from the garage bioreactor · last relay 1 week ago · raw JSON feed

Hardware
- Pioreactor 20 mL
- Raspberry Pi
- Precision balance
- NoIR camera
Software & AI
- Python
- MQTT (Mosquitto)
- Ignition 8.3
- MariaDB
- Perspective
- Claude
01 · Built on real hardware
AR / XR — XREAL glasses suite
Five apps for XREAL One Pro glasses and the Beam Pro compute puck, on the native XREAL SDK 3.1 — four built and verified running on-device, one still in development. One of them is open source.
Vision HUD · Open source
CyberEye
A cyberpunk HUD that locks onto and labels what you’re looking at. An on-device neural net picks people, pets and household objects out of the glasses’ camera feed, drops glowing target boxes on them, and pops glitchy — deliberately fictional — biometric dossiers over reactive synth audio.
- How it worksYOLOv8n runs through Unity’s Inference Engine on the Beam Pro’s GPU, detecting 38 everyday classes. Detections become world-locked pins: a size prior turns a box’s height into a depth estimate, and every pin is reprojected from the pose its frame was captured at, so labels stay put on the object when you turn your head. A performance guard watches frame time and SoC temperature and throttles the detector before the HUD can stutter.
- The hard partThe XREAL Eye hands over roughly one RGB frame every three to five seconds — a firmware ceiling underneath the C# API, not a bug I could fix. So rather than fake a video feed, CyberEye leans into it: the app is optical see-through, you look through clear glass at the real room, and the sporadic frames drive a deliberate scan-and-lock cadence instead of a smooth tracking loop.
- Fiction, on purposeEvery dossier — name, address, threat score — is randomly generated and labeled as such on a boot disclaimer. Nobody is recognized, no face is matched against anything, and no frame leaves the device.
State · Built and running on-device · AGPL-3.0
Stack
- Unity 6
- Unity Inference Engine
- YOLOv8n
- XREAL SDK 3.1
- URP
- C#
Music
UkuleleAR
An AR ukulele tutor that listens while you play: a tuner with real pitch detection, chord and tab diagrams on a floating panel, a play-along note highway, and a magnified view of your own fretboard for close coaching.
- How it worksThe tuner runs YIN pitch detection on the microphone and reports the note, how many cents sharp or flat you are, and which string it thinks you are turning — in high-g, low-G or baritone tuning. Chord diagrams are drawn procedurally from data rather than shipped as images, so any shape renders with correct finger dots, barres and muted strings. Songs are beat-timed JSON advanced by a DSP-clocked metronome, and everything is driven by gaze-dwell so your hands never leave the instrument.
- The hard partThe obvious design — little dots registered onto the real fretboard — is the wrong one. With see-through optics and roughly a 25° field of view, dots on a moving neck jitter enough to teach badly. A large, stable floating chart teaches better, so on-instrument help instead uses a magnified, stabilized crop of the Eye’s view. The pure algorithms — note maths, FFT, chord matching, fret geometry — carry unit tests.
- Unity 6
- YIN pitch detection
- XREAL SDK 3.1
- Gaze dwell
- C#
Assistant
Hermes
A voice assistant living in the glasses, answering to a self-hosted model on my own LAN. Speak, and the reply comes back on the HUD and through the glasses’ speakers; look at something and ask what it is.
- How it worksSpeech is captured by Android’s recognizer on the Beam Pro, sent to a self-hosted OpenAI-compatible agent over the LAN, and spoken back through Android TTS with the text on a HUD card. A vision intent grabs a frame from the Eye and sends it to the same endpoint as an image. The menu answers to gaze-dwell, or you can point the Beam Pro at a button like a laser pointer and tap. Conversations keep their context across turns.
- The hard partThe XREAL runtime handshake is occasionally flaky — the session comes up without a display and the app is stranded, and once that happens an in-process relaunch is impossible. So Hermes schedules its own cold restart through Android’s AlarmManager and comes back into a good session. That guard proved useful enough that it ended up in three of the five apps.
- Unity 6
- Self-hosted LLM
- JNI speech/TTS
- XREAL SDK 3.1
- C#
Translation
Babel Fish
Live captions and translation floating in the real world. Someone speaks; their words appear in the lenses in your language — and it will speak your reply back in theirs.
- How it worksThe speech pipeline is Kotlin, reused wholesale from the flat Android version as a library: Vosk for fully offline recognition, Deepgram when you want streaming cloud accuracy, ML Kit for on-device translation, plus voice-activity detection. Unity owns only the AR layer — caption panels, gaze targeting, and a menu that holds still instead of chasing your gaze. Two-way interpreter mode marks your own speech, translates it outbound and speaks it aloud, while the other direction captions inbound. Optional assist offers three suggested replies in their language with a gloss in yours.
- The hard partLatency is the experience — a reply that lands after the moment has passed is useless. Measured on-device, a flash-class model came back about 1.75 s from question to HUD where heavier ones took four to five seconds, which settled the default. The JNI surface between Unity and the Kotlin library was pinned by running
javapagainst the built library rather than trusting the design doc, which had quietly drifted out of date.
- Kotlin
- Unity 6
- Vosk
- ML Kit
- Deepgram
- JNI/AAR bridge
Visualizer
LaserShowAR
Wear the glasses and watch your laser show land on the real walls and haze of the venue, driven live by the design software — without firing the actual high-power projectors to check placement.
- How it worksFour steps in a loop: scan the venue with the Eye for planes, a mesh and a persistent anchor; calibrate each projector by having it paint a low-power frame the camera can see, then solving where it sits and how wide it scans; ingest the design software’s NDI feed and project it through that solved model onto the mapped surfaces; then refine placement and paint audience keep-out zones that light the preview red when a beam enters them.
- The hard partFor the preview to be spatially true, the NDI feed has to carry the laser’s own scan field — normalized galvo XY, pixel color as beam color — not a pre-rendered visualizer view; software that can only send the latter falls back to a flat preview panel. The geometry underneath (a homography DLT, a Levenberg–Marquardt pose solve, oriented-box intersection for keep-out zones) runs in an offline harness with no Unity involved, so every safety-relevant change is regression-tested before it reaches hardware.
- What it is notAn aid to judgement, not an MPE calculator — and no substitute for a laser safety officer or a variance.
State · In development
- Unity 6
- KlakNDI
- AR Foundation
- XREAL SDK 3.1
- C#
Under the Hood
What the hardware taught me
Five apps through the same glasses surfaces things no datasheet mentions. Four that changed how these were built:
- BuildsA stock Unity Android build produces an APK that runs perfectly — flat on the phone, nothing in the glasses, reporting
XR loader: none. The SDK’s build step needs its settings object registered or it fails silently, so every app has its own configure-then-build entry point instead of a generic one. - InputAlmost nothing reaches an app as a key event; the system consumes it first. Real input on this hardware is gaze-dwell and pointing the Beam Pro like a laser pointer. Both ended up in every app.
- OpticsLens dimming is global and about three steps — there is no darkening a patch of the world behind a caption. So captions live in a lower third or beside the speaker, never over their face.
- The cameraOne RGB frame every three to five seconds is this unit’s ceiling. Three of the five apps are built around snapshots because of it, which is a better outcome than pretending there is a video feed.
02 · Everything reports in
Connected home & IoT
SDR Hack
Gas Meter via SDR
Reading the utility gas meter out of thin air: an RTL-SDR plus rtlamr decodes the meter’s own wireless AMR broadcast and publishes it over MQTT into Home Assistant. No account, no wiring — just listening.
State · In progress — decoder feed being stabilized
Read the build log — the protocol, the passthrough, and the restart loop →
- RTL-SDR
- rtlamr
- MQTT
- Home Assistant
Water Quality
Aquarium Controller
An ESP32 water-quality monitor — EZO pH, TDS/conductivity, DS18B20 temperature, and a float switch — with a memorable debugging saga: beating heap exhaustion on a no-PSRAM board.
Read the build log — three numbers, a float switch, and the temperature trap →
- ESP32
- ESPHome
- EZO pH
- DS18B20
Off-Grid Energy · Live dashboard
Solar + Battery BMS
An off-grid duck coop that reports its own energy balance: an ESP32-S3 reads a Renogy MPPT controller and a LiFePO4 battery BMS over Modbus-over-BLE — hand-written ESPHome C++ — into Home Assistant. The dashboard below is that coop’s real telemetry, relayed live.
Live from the coop
Battery
89%
state of charge
PV
123.0 W
18.4 V · solar in
Flow
101.1 W
13.5 V · charging
Load
7.0 W
coop draw
Today
156 Wh
169 Wh used
ESP32-S3 → Renogy MPPT + LiFePO4 BMS over Modbus-BLE → Home Assistant · charge mode MPPT · coop 60.4°F · updated 18 seconds ago · raw JSON feed
- ESP32-S3
- ESPHome (C++)
- Renogy MPPT
- LiFePO4 BMS
- Modbus/BLE
- Home Assistant
Acoustic Sensing · MQTT showcase
Backyard BirdNET
The security cameras were already listening. BirdNET-Go on the NAS taps the UniFi Protect cameras’ RTSP audio streams and identifies bird species from song in real time — no new hardware, just the microphones already watching the yard, repurposed as a distributed acoustic sensor grid. Each detection publishes over MQTT for the smart-home stack (Home Assistant auto-discovers the feed), and a small bridge relays it to the panel below. First catch, minutes after the garden feed went live: a Mallard, 79% confidence.
Recently heard in the yard
- Downy WoodpeckerDryobates pubescens · 36 mins ago · 86% · Dirt Slope
- Northern FlickerColaptes auratus · 38 mins ago · 77% · Dirt Slope
- Downy WoodpeckerDryobates pubescens · 2 hours ago · 96% · Dirt Slope
- Brown CreeperCerthia americana · 2 hours ago · 93% · Dirt Slope
- Chestnut-backed ChickadeePoecile rufescens · 2 hours ago · 97% · Dirt Slope
- Western TanagerPiranga ludoviciana · 4 hours ago · 96% · Dirt Slope
Live from the garden cameras · last relay 36 mins ago · raw JSON feed
State · Live — listening on the garden cameras
- BirdNET-Go
- UniFi Protect
- RTSP
- MQTT
- Home Assistant
- Docker
Long-range Radio · Decentralized LoRaWAN
Helium LoRaWAN Gateway
A LoRaWAN gateway on the Helium IoT network (hotspot little-mandarin-ostrich), giving long-range, low-power coverage to field sensors too distant or too battery-constrained for WiFi — paired with a CubeCell GPS node for coverage mapping. Helium is the interesting part for IIoT: instead of buying and backhauling your own gateways, sensors ride a network of independently owned hotspots and pay per packet in blockchain-settled Data Credits. It reframes industrial connectivity as a metered utility — the live numbers below are that model in motion.
My hotspot
Little Mandarin Ostrich — Bellingham, Washington · United States · 8 dBi · 6 m · IOT · on-chain
Lifetime earned · 46,787 IOT · ≈$3.13 — Proof-of-Coverage rewards, climbing with each daily distribution.
Live from the Helium network
IOT token
$0.00006682
spot price
1 HNT buys
39,728
Data Credits = uplinks
Hourly sensor
$0.09
per year, all-in
A Helium Data Credit is pegged at $0.00001 and pays for one sensor uplink (≤24 bytes). Credits are minted by burning HNT at the live oracle price, so the same $0.00001 packet costs the same in dollars no matter what the token does — predictable IIoT economics on a network nobody owns. raw JSON feed
Prices via CoinGecko · updated 14 mins ago
State · Live on Helium IoT
- LoRaWAN
- Helium IoT
- Solana
- Data Credits
- CubeCell
- Semtech
Plus across the home
- PetSafe dog door → HA
- WLED closet lighting
- Solar-aware pre-cooling
- ESPHome fleet
03 · Where the day job shows
Industrial & homelab infrastructure
The same discipline as the client work — kept deliberately client-neutral. No credentials, addresses, or keys, here or anywhere.
Integration
MQTT ↔ OSIsoft PI WebAPI Bridge
A bidirectional bridge between MQTT and the OSIsoft/AVEVA PI System WebAPI that preserves the data-quality attribute — not just the value — across the boundary. A real industrial-integration concern.
- TypeScript/Node
- MQTT
- PI WebAPI
Edge AI
Condition Monitoring
Edge predictive-maintenance: an ESP32 collects vibration, temperature, and humidity, runs a TensorFlow Lite anomaly model on-device, and alerts via Twilio — visualized in Grafana.
- ESP32
- TensorFlow Lite
- MQTT
- Grafana
Ops
Production-grade homelab
Self-hosted Ignition SCADA in Docker on a 43 TB ZFS NAS — the backbone for the bioreactor historian — behind a hardened UniFi network with VLAN-isolated IoT.
- Ignition
- Docker
- ZFS
- UniFi
Open Source · Public
modulaser-wled-bridge
Your LED strips dance in perfect sync with a laser show. A Python bridge that drives WLED devices from a Modulaser laser show in real time — follow its OSC feedback (clips, strobe/chase, blackout, BPM) or sample its NDI video for gradient/dominant/zone color streamed per-LED over DDP. Auto-discovers devices via mDNS, drives multiple controllers at once, syncs BPM, and restores every device to its original state on exit.
State · Public · MIT · pip-installable
Stack
- Python 3.9+
- python-osc
- NDI
- WLED / DDP
- zeroconf/mDNS
Polish
- Test suite
--dry-run--headless- Thread-safe
Open Source · Public
Webcam → ILDA Laser
Point a camera at the world and a laser draws what it sees. Live frames — a webcam, an RTSP camera, a video, or a still — run through an OpenCV pipeline, and the resulting contours become a real laser point stream on an ILDA projector driven by a Helios DAC. The interesting part is not the computer vision; it is that a laser has one beam and no frame buffer. Everything you see is the path two mirrors traced in the last fortieth of a second, which turns “draw this picture” into a routing problem with a hard budget.
- Seeing the linesFour extraction modes, because what a laser should trace depends on what you point it at: Canny edges for detail, adaptive thresholding for clean silhouettes, background subtraction so only what moves gets drawn, and an HSV color key. Contours are simplified with Douglas–Peucker before they leave the vision stage — a raw contour carries one point per boundary pixel, and the whole scene has a budget of about a thousand.
- Drawing themPaths are ordered by a nearest-neighbour walk to cut the blanked travel between shapes, with open paths reversible and closed ones rotated to start at their nearest vertex. Lines are resampled to constant arc length, because brightness is dwell time and unevenly spaced samples make a line that is bright where they bunch and dim where they spread. Corners turning through θ° get
round(θ/30)repeated points so the galvanometers can arrive, and eight blanked points sit on each path’s first vertex while the mirrors settle before the beam comes on. - The budget
min(4096, pps ÷ fps)— at 30,000 points per second and 25 frames per second, the entire scene must fit in 1200 points. The optimizer keeps contours longest-first so a busy frame loses fine detail rather than whichever shape sorted last, and coarsens the sample step rather than dropping a shape that would otherwise be the only thing on screen. - Safety in code, not just in the READMEWritten from the Certified Laser Safety Officer’s chair. Every frame passes a validator that rejects a parked beam or a collapsed frame, the brightness ceiling is applied past the point where any upstream stage could raise it, a watchdog blanks output if the pipeline stalls, and every exit path — including an unhandled exception — blanks and closes the shutter. It is documented just as plainly as a contributing control: software cannot detect a seized galvanometer, and the scan-fail safeguard is hardware.
State · Public · MIT · 95 tests, all hardware-free · write path verified against the DAC
Read the build log — the path optimizer, the budget, and the safety layer →
Stack
- Python 3.10+
- OpenCV
- NumPy
- Helios DAC
- ctypes
- pyusb
- ILDA
Polish
- Test suite
--dry-runsimulator--headless- CI on Linux + Windows
- Safety enforced in code
Aerial CV
Gas Meter Reader
A DJI Mini 4 Pro flies a fixed MSDK waypoint to face the analog gas meter, captures the dial, and an EasyOCR + OpenCV pipeline reads the digits and publishes the value over MQTT into Home Assistant / Ignition SCADA — a camera and a flight path standing in for the meter-reader’s walk.
State · CV pipeline reads captured frames; drone-automation phase in progress
- DJI Mini 4 Pro
- MSDK
- EasyOCR
- OpenCV
- MQTT
- Python
06 · Sensors to statistics
Sport analytics
What happens after the sensor is working: the part where you have to be honest about what the numbers do and do not support.
Sport analytics · Applied statistics
TechDisc Golf Coach
A disc golf disc with an IMU in it records fifty-three fields per throw and offers no coaching. This turns them into a per-session report that says one thing to work on — measured against the thrower’s own history and against real PDGA tournament data. Built for my dad, who is not going to open a terminal.
- Getting the data outTechDisc publishes no read API, and the Firestore collection behind the app enforces App Check — a headless script with correct credentials still gets a 403, because the token that satisfies it is only minted inside the real logged-in web app. So the exporter runs in a browser context and pages through the structured documents from there. Nothing is scraped off the screen, and everything downstream runs offline.
- The pro benchmarkA one-time read of TechDisc’s public event broadcasts — 24,860 throws pulled, 18,527 inside a real event window, across nine events with usable data (USDGC, PDGA Worlds, OTB Open, Champions Cup and the distance competitions). Only percentile tables are kept, never raw pro throws: 8 KB of JSON that ships inside the app, so the comparison works with no account and no internet. Tournament play and max-effort distance competitions are held as two separate benchmarks and never averaged — a developing thrower can sit at p11 against one and p1 against the other, and both facts are worth knowing.
- The modelsSession averages are compared to the thrower’s own baseline in standard deviations rather than raw units, with a minimum sample behind every claim. Percentile placement interpolates between the stored pro percentile points. A standardized multiple regression reports what each release metric is worth in feet, holding the others fixed — the only view that separates speed from spin, which move together. A speed×wobble interaction, tested within power terciles and bootstrapped by session rather than by throw, asks whether the release breaks down under power.
- Arguing with itThe two biggest claims were re-derived and attacked by three independent analyses each — multicollinearity, leverage, alternative binning, robust regression, confounds — and the saved verdicts record the caveats alongside what survived. Negative results are reported as negative. Sixty-one commits, a four-lens review written down before anything was fixed, and 215 tests that encode past review findings as regressions.
State · Running — used every session, ships as a double-click desktop app
Read the build log — the models, the screenshots, and the unit trap →
Stack
- Python
- pandas
- NumPy
- matplotlib
- Playwright
- Firestore
- Tkinter
- PyInstaller
07 · Explorations & builds
More on the bench
COVID-era CV
Face-Mask Detection
A webcam, two Haar cascades and one rule. Find a face, then go looking for a mouth inside it — visible lips mean no mask; a face with no mouth to be found means the mask is on. A white mask can stop the greyscale cascade seeing a face at all, so a second pass pushes the frame through a black-and-white threshold and treats “only the thresholded image found a face” as evidence in its own right.

The technique and the original script are Saurabh Shaligram’s, from his June 2020 write-up. I stood the environment back up, retuned the threshold for my own lighting, reworded the prompts and pointed it at my face — an afternoon of lockdown curiosity, not original research. His script carries no licence, so the code on GitHub is not his: it is a fresh implementation of the same published technique, credited to him, scored against this very clip.
It is also gloriously brittle, and that is the part worth keeping. The mouth cascade sweeps the whole frame rather than the face box and only checks vertical overlap, so anything mouth-shaped beside your head counts as a mouth. Turn your head a few degrees and the frontal-face cascade loses you altogether. The threshold that rescues white masks is one number tuned to one room’s light. Five years on, the same job is a small network running on-device in the AR fleet — the cascades are why I know what that buys.
State · Archived prototype · 2020
View on GitHub → · technique from Saurabh Shaligram’s write-up
- Python
- OpenCV
- Haar cascades
OpenFlexure Microscope
A 3D-printed motorized microscope an AI can drive to auto-scan and count cells — via the OpenFlexure REST API and a custom MCP server; body printed on a Bambu X1C.
State · In build

- OpenFlexure
- Raspberry Pi
- MCP
P25 Radio Monitor
Monitoring P25 trunked-radio systems with triangulation to estimate transmitter location — receive-only, hobbyist RF.
- RTL-SDR
- Python
- Multilateration
Foam Soft-Sensor
Teaching a bioreactor to see its own foam — OpenCV foam detection as a camera-based “soft sensor” that infers a process condition without a dedicated probe.
- Raspberry Pi
- OpenCV
- Python
Immersive Training Lite
A browser-based 3D lab (WebXR) that teaches bioreactor SOPs before you touch the real one — clickable hotspots, guided steps, optional VR.
- Babylon.js
- TypeScript
- WebXR
Raspberry Pi Hexapod
Server/client control for a six-legged Raspberry Pi hexapod robot you drive over the network.
- Raspberry Pi
- Python
Biodata Sonification
Turning living data into sound — sonifying experiment and culture data from the bioreactor so you can hear its rhythms instead of only charting them.
State · Early exploration
Just for Fun
Stugometer 9000
A gloriously over-engineered gag gadget that measures “stugginess.” An ESP32 with a touchscreen, temp/humidity sensor, and NeoPixel ring computes a tongue-in-cheek Stugginess % — calm blue chase, orange warning at 80%, red alarm at 90% — with custom sound effects, a 3D-printed enclosure, and a printed A5 manual.
State · Built & beloved
- ESP32
- Arduino/C++
- NeoPixel
- 3D printing
Ambient Dev-Tool
ClaudeGlow
Mirrors Claude Code usage onto a living-room WLED strip: a gauge of the tokens left in the active session that breathes while Claude works, shades amber then red as the budget drains, goes deep red when exhausted with a blue bar filling toward the reset, and green-wipes when the window resets. An ambient fuel gauge for AI coding.
State · Running
- Python
- WLED
- ccusage
On the roadmap: a touchscreen media-prep QC station (salinity/pH/temp validation before inoculation) and a lightweight electronic batch record in Ignition Perspective — with human-vs-AI actor attribution and human sign-off on AI-prepared batches.
More detail lives in the field notes — or get in touch to talk shop.