← Live Dashboard

The Build Journal

wy2z — the journal

A four-device plant lab keeping a tomato vine and two zinnias alive while I'm out of town.


May 2, 2026 — Hands in the Dirt

This morning I drove out to Cobblestone Farms.

Cobblestone is a small farm in Northwest Arkansas — a few acres, raised beds, a rotating crew of volunteers. I'd been meaning to go for a while. This was something I could just show up to.

I met three fabulous individuals there: Lucy, Leslie, and Rob. Together with three other volunteers, I helped transplant pepper and lettuce saplings into a freshly tilled bed, and I helped install a length of drip irrigation tubing — quarter-inch black poly running along the row, emitters every six inches, a header off a manifold at the end of the bed.

It was just super fun. I loved getting my hands in the dirt and feeling connected again with nature and earth and land and connecting with the things that go in my body and in the bodies of people in my community.


Arkansas is the number one state for food insecurity in America.

Not the country I grew up imagining we lived in. The number one state. I think a lot of it is because these people rely heavily on these large mega corporations to grow the food for them and ship it to these markets where the supply chain costs jack up the prices like crazy. Sometimes eating organic is not a viable option for a lot of people.

There's definitely more to unpack there, and I'm excited to explore how I can use my interests and skills in:

— and try to envision a future where all of these different segments of thought and disciplines are now able to converge to create more interconnected systems that integrate technology and nature to produce good, healthy, affordable outcomes for local communities.

That's not a thesis statement. That's a direction.


At the end of the volunteering session, which was just so fabulous, I was gifted three plants: a Wyches Yellow heirloom indeterminate tomato, and two zinnias.

I drove home with them in the passenger seat.

I'm going home soon — back to Gilroy, California — for a few weeks. Funny enough, Gilroy is the garlic capital of the world, and it was funny to see garlic plants outside of Gilroy at Cobblestone. It kind of reminded me of my roots and the kind of town that I grew up in, which was predominantly agricultural. The Christopher family. Christopher Ranch. The whole Santa Clara Valley, built on the way water and sunlight and human labor turn into food.

I got these three plants and I realized that I really cared about them. I wanted them to survive. I saw them not only as plants but as sustenance for humanity and as a vision of hope for the future and how nature always finds a way to grow and be resourceful and produce fruit, even when all it's provided is simple ingredients like sunlight and water.

I'm gone from May 11 through the second week of June. I needed something to keep them alive while I was away.


I came home and whipped out my notepad like I always do when I need to solve a good hard problem.

I started listing out all the problems that I needed to solve, all of the systems that needed to be interconnected, what computers and chips needed to communicate, how and when, and what are the mission-critical points of failure.

A tomato isn't a hard problem in the way distributed computing is a hard problem. But once you start enumerating — high-PPFD light requirements, water dosing on a schedule, an indeterminate vine that grows six to eight feet, airflow to keep mold off the leaves and shake the flowers enough for self-pollination, a Brita pitcher as the only water source on hand and a quarter-inch vinyl tube I'd already pre-fitted from my fridge — the space gets dense fast.

I needed to solve very granular problems, not just large systems-level problems, but literally physical problems. How am I going to connect my Brita to a quarter-inch vinyl pipe? I used a 1/2" to 1/4" MIP adapter, but even that didn't fit tightly, so I had to wrap the threads in plumber's tape. I had to learn the best techniques to wrap plumber's tape.

My respect for plumbers is incredible. I went to Lowe's, and the different kinds, the sheer number and diversity of piping connections and valves and details that plumbers need to pay attention to is just incredible. There are so many specifications that need to be perfect. A quarter-inch tube isn't actually a quarter-inch — it's nominal, and depending on whether the measurement is the outside diameter or the inside diameter, the right barb or compression fitting is different. MIP versus FIP. NPT versus straight thread. The threads alone aren't the seal — the tape is.

I feel like the tech industry, especially nowadays with AI and whatnot, throws around the term "builders" a lot. I feel like I understand how being a builder now encapsulates so much more than just a software engineer who can iterate quickly and deploy good software products. There are real physical, tangible problems that need to be solved in real life and that are being solved every day by people who are often overlooked and overseen by society, such as plumbers and electricians.


Being a product manager at Walmart in tech led me to be very methodical about how I approached this problem — essentially writing down problem statements and developing feature requirements for this system. I'm glad that product development has trained that initial muscle in me to think across broad systems and about constraints and how I define success and points of failure.

I made three trips to Lowe's within one day, multiple trips to Walmart, and a trip out to a random field in the countryside to grab some dirt — which is free, by the way.

By the end of the night I had a setup. A green frame holding a full-spectrum grow light. Three pots on mesh trays under it: two zinnias in matched gray plastic pots, the tomato repotted into a much larger black nursery pot to give the roots somewhere to go. A vinyl tube running from the Brita on the kitchen counter to the corner of my bedroom, taped at every transition. An SG90 servo on my desk, waiting to be wired to an ESP32 that will press the Brita lever on command. A Cync outdoor smart plug controlling the light on a sunrise/sunset schedule.

And on a shelf next to the rig — the Jetson, the same one I taught to see last month. Repurposed. Still pointed at the world, but at a different world. Closer. Slower. Three plants and a wall outlet.

A computer to be my eyes while I'm away.


What's on the desk now

A grow operation in the corner of my bedroom. Three living things — one tomato vine that will grow taller than the room can comfortably accommodate, two zinnias just starting their first true leaves, and a soil that smells like the field I drove out to.

The system isn't built yet. The Pi orchestrator, the watering loop, the sensors, the dashboard — that's what the next eight days are for. Tonight was about the physical problem. The problem of pots and roots and tubing and thread tape. The problem of how a Brita pitcher in a kitchen connects to a tomato in a bedroom in a way that won't flood the floor while I'm in California.


WhatValue
Plants3 (1 tomato, 2 zinnias)
Trips to Lowe's3 (in one day)
Trips to Walmartmultiple
Random fields visited for free dirt1
Adapters that needed plumber's tape1
New respect for plumbersconsiderable
Days until departure9

May 3, 2026 — Three Plants, One Row (of Data)

I woke up with three plants and a notepad full of ideas.

The plants were where I'd left them last night. Two zinnias on a mesh tray, the tomato repotted in a black nursery pot beside them. The grow light was already on — a Cync schedule I'd configured the night before, sunrise to sunset for zip 72712. The plants didn't know anything about a system. The system didn't exist yet.

This is what I had in my notepad when I started:

Wyches Yellow and 2 Zinnias

Problems/constraints

  • I have a wyches yellow heirloom indeterminate tomato plant and 2 zinnias that I need to keep alive.
  • imma be out of town for over a month, may 11 - second week of June, so I need to figure out a way to sustain these guys over that duration.
  • These plants need high sunlight and high PPFD
  • the indeterminate tomato plant is actually a vine and can grow up to 6-8 ft tall and idk how i can manage that height (auto pruning or tresses wrapping) while im gone, hopefully it doesn't get THAT big (?)
  • I need to water them well and I do not have a dedicated watering contraption. And i think the tomato needs hella water. Dawg this tomato jawn high maintenance af.
  • Im not sure if the tomato plant will blossom in which case it needs to self pollinate by wind shaking or bugs that i need to simulate using dht sensor
  • I need good air circulation and humidity control so that mold and mildew dont grow and kill the plants
  • Need to make sure the plants have enough room and soil to grow over the month
  • Need an LCD display to show date and time photo was taken. Maybe also show temp and humidity.

Proposed system solution

Water: using my fridge brita water dispenser, and using a servo motor hooked up to a esp32 board to control it to push the lever on the brita to dispense water. I've already hooked up a 10ft 1/4in vinyl tube with a 1/2in to 1/4in MIP adapter using plumber tape so I have piping from my brita to the plants.

Main system control will be via raspberry pi 5, which will manage:

  • Sending a signal to a oled .96inch little panel that I have to display the time and date right after I turn the light on in the morning interval and right before I turn the light off in the evening interval so that I can check the images on my ultimate dashboard and make sure that I can confirm the passage of time
  • Asking the Jetson orin nano to snap a picture at dedicated intervals (with the oled panel showing the date and time also), send the image to raspberry pi 5
  • Triggering a DHT humidity and temp sensor at the interval (once in the morning and once at night) to capture air telemetry.
  • Raspberry pi 5 then sends the info package (picture and DHT telemetry) to claude api where VLM and LLM analyzes soil moisture and air moisture and overall plant health and determines what actions should be invoked (for now the only action will be a "water" tool that sends a signal over wifi to the esp32 board to trigger the servo motor to dispense water when necessary.
  • Raspberry pi 5 saves all of this data (picture, telemetry, claude api response, action taken, date, time) to a log in google drive

Dashboard: hosted on vercel, sleek, modern, polished, with data that the raspberry pi 5 provides us. The dashboard will have a "update" button that ensures im looking at the freshest data.

Im thinking It has like:

  • Daily strip of photos as hero view
  • Option to view the Google Drive data but in a visually appealing and easily digestible way

Data:

  • I don't want raspberry pi 5 to store the data locally. I want to store the data on the cloud. So that means that the raspberry pi 5 will have to capture telemetry and photo analysis data and send it over via wifi to maybe a Google Drive folder that will have a spreadsheet (come up with a schema for me).

The plan was opinionated. I needed to validate the spine before getting precious about anything else: could the Jetson take a photo of the plants, could Claude grade them, could the result land somewhere queryable. If those three things worked, the rest was wiring.

I started by forking the work into a new repo at personal_projects/wy2z/, instead of bolting it onto the YOLO project. Different scope, different lifespan, different surface. The forking-now-versus-disentangling-later math was easy. I lifted the autofocus driver and CSI capture pipeline from learn/jetson-yolo-stream/, simplified them down to one job — open the camera, run AF, save a JPEG, exit — and SCP'd them onto the Jetson.

Then I took the first picture.

It was severely out of focus.


The Tenengrad metric the autofocus uses crops to the geometric center of the frame and looks for sharp gradients there. The plants in my first capture were at the bottom of the frame. The center of the frame was a bright bloom on the wall behind them — the grow light reflecting off the white paint. AF dutifully maximized gradient energy in the center crop and converged on a DAC value that made the wall look as sharp as a featureless white blob can look. Which is to say, not sharp at all.

I rebuilt the AF call as a manual sweep instead. Eight DAC positions across the full 0–4095 range, save a JPEG at each, log the Tenengrad score. The result told a clean story:

dac=0     →  243
dac=585   →  218
dac=1170  →  201
dac=1755  →  187
dac=2340  →  179
dac=2925  →  177
dac=3510  →  934   ← sharp
dac=4095  →  205

Five times the score of any neighbor. A real peak. The hill-climb missed it because the coarse sweep stepped past the spike — DAC=3328 and DAC=3584 both sit on the slope, and the algorithm picked the wrong winner from those samples.

I tilted the camera down so the plants pulled into the bottom 40% of the frame. Re-ran the sweep. DAC=3510 still won, but the score had jumped to 1314 — more leaf detail in the central crop, more gradient energy. From soft bloom to readable seedling.


Then I asked Claude what it saw.

I sent the photo to the Anthropic API with a structured prompt — you're an expert plant care advisor; here's a photo of three plants under a grow light; return JSON with per-plant health and a recommended action. The response came back in a few hundred milliseconds.

zinnia_a → green, action: none
zinnia_b → green, action: none
tomato   → yellow, action: water

The yellow on the tomato wasn't surprising. What was surprising was the scene note Claude attached:

"The tomato is in a disproportionately large pot for its current seedling size, which means the bulk of the soil can dry out quickly and unevenly."

That's a real plant-care insight. A bare seedling with a tiny root ball sitting in a giant volume of soil is a classic mismatch — the surface dries fast, but watering enough to wet the surface drowns the root ball. The kind of thing a careful gardener says aloud when looking at a new transplant. Nobody told Claude to say that. It read the photo, pattern-matched against everything it had learned about plants and pots and water, and surfaced the structural concern that mattered.

A computer looked at my plants for the first time and gave me back a diagnosis worth listening to.


The notepad said the data would land in Google Drive. The reality became Supabase Postgres plus Supabase Storage.

The reasoning was practical. Google Drive's auth dance is a friction tax I'd pay every time the Pi rebooted. Querying a Drive spreadsheet from a Vercel dashboard is slower and more brittle than querying Postgres. Row-level security gives me a way to be precise about who can read versus write. And buckets configured public-by-default mean the photo URLs work without auth — paste them into a browser, you see the tomato.

I deployed an observations table with an index on capture time, a plant-photos storage bucket with a public read policy, and a thin Python wrapper that uploads a JPEG, gets back a URL, and writes a row containing the path, the verdict, and the air-temp and humidity readings (the last two as placeholders until the DHT11 is wired tomorrow).

The notepad also said the Pi would control the Cync grow light over Wi-Fi. That bailed too. Cync has no public API, the reverse-engineered Python library I found supports light bulbs but not smart plugs cleanly, and the single-connection-per-account constraint means every Pi-side toggle would kick the Cync app off my phone. The simpler answer was always there: schedule the light in the Cync app on a sunrise/sunset clock, and have the Pi's photo captures land inside the lit window. The grow light is already on. The Pi doesn't need to ask.


By the end of the day there was one row in the observations table.

id:           2f00a643-8002-4fc7-9c68-e2c8f7119175
captured_at:  2026-05-03 15:02:42 UTC
photo_path:   2026/05/03/wy2z_v2_dac3510.jpg
photo_url:    https://efozxnwhmdopkiidgwuw.supabase.co/storage/v1/object/public/plant-photos/...
action_taken: logged_only
verdict:      3 plants analyzed, 609 chars of scene notes

The photo URL is public. Anyone who has it can open it in a browser and see my tomato at 3:02 in the afternoon on a Sunday in May. The grow light overhead. Two zinnias to the left. The Wyches Yellow seedling barely poking its head above the soil in a pot it'll fill out over the next two months — if any of this works.

The watering loop isn't built. The DHT isn't wired. The OLED that's supposed to show the date and time inside the frame is still on the desk. The dashboard exists only as a folder named site/ and a README. There's a lot left.

But this morning there was no system. Tonight there is a system that produced a row.


WhatValue
Sharpest DAC found3510
Tenengrad peak score (post-reframe)1314
Score multiple over nearest neighbor~5x
Plants analyzed by Claude3
Plants flagged for watering1 (tomato)
Cync libraries evaluated1 (pycync v0.5.0)
Cync libraries adopted0
Supabase rows written1
Public photo URLs1
Days until departure8

May 3, 2026 (evening) — Twice a Day, Forever

I came back to the project after dinner. The system had a spine. It needed a loop.

The spine was what I'd built that morning — one photo, one Claude verdict, one Supabase row, all triggered by hand. A demo. The loop is what makes the system survive May 11 without me: the same flow, kicked off by cron, twice a day, every day, until I tell it to stop.

In between the spine and the loop I had to build five things — an ESP32 watering controller, the Pi → Jetson SSH bridge, the DHT11 air sensor, the OLED display, and the orchestrator that strings it all together. I'd budgeted eight days for the whole list. It took one evening.


I started with the ESP32 because watering felt like the highest-risk piece. If the actuator can't actually move water, no amount of camera and Claude work matters.

I had an SG90 servo, an ESP32 DevKit, a breadboard, and a plan: flash MicroPython, write a tiny HTTP server with a POST /water endpoint, mount the servo against the Brita lever, calibrate angle and dwell, done.

Wiring took five minutes. Flashing MicroPython 1.28.0 took three — the firmware came down as a 1.7 MB blob, esptool wrote it to 0x1000, and the chip — an ESP32-D0WD-V3 at MAC 28:05:a5:30:9a:b8 — came up on the REPL announcing itself by version. Writing the firmware took fifteen: machine.PWM(Pin(13), freq=50) for the servo, network.WLAN(STA_IF) for Wi-Fi, a hand-rolled HTTP parser doing exactly one thing, all of it under 200 lines. The board pulled DHCP and got 192.168.0.28 on the home network. curl -X POST returned {"status":"ok","duration_ms":5000} in 5.1 seconds. The signal was real. Now to make the signal do work.

Then I started calibrating angles. REST at 10°. Press at 45°. Not enough travel. 80°. Still not enough. 140°. 160°. A nominal "200°" — pulse-width pushing past spec, knowing the cheap clone under-rotates anyway. The horn rotated. The Brita lever, made for a human thumb, did not move. SG90 stall torque is 1.8 kg-cm. Whatever the spring constant of the Brita's dispense lever actually is, it was bigger.

I sketched a fallback. Instead of pressing the lever, what if the servo pinched the tube downstream? Or pressed a small plug against the open end of the tube? The math on the plug worked out cleanly — head pressure from a full Brita over a 1/4" tube is about 10 grams of force, well inside the SG90's torque budget. But the vinyl tube I'd plumbed in is a stiff grade; I tried pinching it shut by hand and couldn't. Plug-on-tube-end was sketchable but not buildable in the next two minutes from what was on the desk.

So I tabled it. A 5V DC water pump is arriving Tuesday. Same firmware scaffold, same POST /water endpoint, just swap the PWM call for a GPIO line into a transistor that switches the pump's 5V power. The servo work isn't wasted — the Wi-Fi, the HTTP, the parser, the structure of water_pulse(), all of it stays. Only the actuator changes shape.

I wrote the dead-end up in what_id_change_next_time.md and committed everything. There's a moment when you realize the cleanest thing to do with a failed approach is leave a clear record of why it failed — so future-me doesn't sit down on some Tuesday and decide the SG90 must've just been miswired the first time and try the whole thing again.


With watering tabled until the pump arrives, I moved to the sensors.

The Pi → Jetson SSH bridge had to come first. I'd set up Mac → Jetson keys last month, but the Pi couldn't SCP photos off the Jetson yet, which blocked the orchestrator. I generated an ed25519 keypair on the Pi, copied the public key onto the Jetson via the Mac (which already had Jetson auth, so the Jetson's password never had to surface), pre-seeded the Jetson's host key into the Pi's known_hosts so the first connection wouldn't sit on a "trust this host?" prompt, and tested with BatchMode=yes so a silent password fallback couldn't fool me. PI_TO_JETSON_OK came back. Five minutes, no surprises.

Then DHT11. I have never had this much trouble with a $3 sensor.

The wiring was textbook — 3V3 to VCC, DATA to GPIO 4 (Pi header pin 7), GND to GND. The library installed cleanly. Every read returned DHT sensor not found, check wiring. I checked the wiring. I rewired the wiring. I checked the GPIO chip — gpiochip0, 54 lines, GPIO 17 alive — and then checked /boot/firmware/config.txt and saw it. Raspberry Pi OS ships dtoverlay=w1-gpio enabled by default, which claims GPIO 4 for the 1-Wire kernel driver. The kernel had the pin. My Python couldn't have it.

I moved the DATA jumper one slot over to header pin 11 (GPIO 17), reran the test, and got 22.6 °C / 56% humidity. The number for a Sunday evening in my apartment in early May. A real reading from a real piece of glass and stamped sheet metal sitting twelve inches from a tomato.

Lesson cached: before assuming any "default" GPIO is free on a Pi, check the overlays.

The OLED was the first thing all evening that worked first try.

GME12864-11, SSD1306 controller, I2C-1 at 0x3C. Seventy lines of Python using adafruit-circuitpython-ssd1306 for the panel and Pillow for the text rendering. Four rows of 16-pixel DejaVuSans: date and time, temp and humidity, two rows for status. i2cdetect lit up the moment all four wires were seated. The first frame rendered exactly as drawn.

The OLED's job is bigger than a status line: it sits inside the camera frame. When the orchestrator pushes capturing... to the OLED before triggering the Jetson, the photo that comes back has the date, time, temperature, and humidity baked into the image itself, in pixels, where any future viewer — Claude included — can read them without trusting any file metadata. The image carries its own timestamp. Self-attesting metadata, eight dollars of glass.


Stitching it together was the moment I'd been building toward all evening.

pi5/capture.py is the orchestrator. One function, run_capture(mode), that does: read DHT, push to OLED, SSH to Jetson, run the capture script, SCP the JPEG back, upload to Supabase, hand the file to Claude, write the observation row, push the verdict back to the OLED, clean up. Two thin wrappers — morning_capture.py and evening_capture.py — exist only so cron has stable filenames to point at. The mode label lands in the observation row's notes column, so I can filter morning shots from evening ones later.

I ran it once. End-to-end took 28 seconds. Observation 7c27e7f9-ff76-424a-a2b9-e5930a17c82b landed in production.

Claude said zinnia_a was healthy, zinnia_b had some early yellowing worth watching, and the tomato was holding steady but still the big-pot-small-plant problem from this morning. Same plants. Different photo, hours later, slightly different light. The verdict was consistent. The system was reading the world and reporting back, and the report made sense.


The cron lines went in last. Two of them:

30 7  * * * /home/paul/wy2z/.venv/bin/python /home/paul/wy2z/pi5/morning_capture.py >> /home/paul/wy2z/cron.log 2>&1
30 19 * * * /home/paul/wy2z/.venv/bin/python /home/paul/wy2z/pi5/evening_capture.py >> /home/paul/wy2z/cron.log 2>&1

The next firing is 19:30 tonight. Then 07:30 tomorrow. Then 19:30 again. Twice a day, forever, until I run crontab -e and delete two lines.

That's the moment that hits hardest. Not the calibration, not the orchestration, not the API call to Claude — the cron line. Because the cron line is the moment the system stops needing me. It doesn't care if I'm asleep, if I'm in California, if the Mac is closed, if I forget. Twice a day, the Pi is going to wake up, ask the Jetson to take a picture of three plants under a light, ask Claude what it thinks, write the row, update a tiny screen, and go back to sleep.

I haven't left for Gilroy yet. The plants haven't been watered by a machine yet. The dashboard doesn't exist. The pump doesn't ship until Tuesday. But the loop is closed, and the loop is where the survival is going to happen.


WhatValue
Pivots executed1 (servo → pump)
Pivots documented1
MicroPython versions flashed1 (1.28.0)
Servo angles attempted5 (45°, 80°, 140°, 160°, "200°")
Servo torque vs. Brita lever springservo lost
GPIO conflicts learned about1 (w1-gpio overlay on GPIO 4)
Sensor reads on real hardwareDHT 22.6°C / 56%, OLED 0x3C
Files written or refactored7 (main.py, capture.py, dht.py, oled.py, analyze.py refactor, two cron wrappers)
End-to-end orchestrator runtime28 seconds
Cron jobs installed2
Times the system will run itself before I'm back~70
Commits today5
Days until departure8

May 3, 2026 (later) — Walking the Lens Home

After dinner I came back to fire one more test capture before bed — just to confirm the loop survived a real round-trip with fresh eyes. The photo landed in Supabase. I opened it.

It was blurry.

Not scene-blurry. Not motion-blurry. Out-of-focus blurry. The grow-light bulb dominated the frame, the plants were soft green smudges, the OLED on the wall was an unreadable rectangle. The same calibrated DAC value — 3510 — that gave me a sharp image at 3 PM was now producing mush. Claude noticed too: the verdict came back caveated, every plant marked at lower confidence than the morning, the scene notes flagging "image is somewhat blurry and overexposed near the grow light."

Something between 3 PM and 8 PM had moved.


I ran a fresh focus sweep — eight DAC positions, full range, log the Tenengrad score at each. The peak had walked itself across the dial.

dac=2234  →  1241   ← old slope
dac=2606  →  2659   ← new peak, 5x neighbors
dac=2978  →   732
dac=3510  →   358   ← yesterday's calibration
dac=4095  →   175

DAC 2606 was the new sharp. DAC 3510 was now soft. The camera or the scene had shifted by enough that the lens needed to focus several hundred DAC steps closer. I baked the new value into the orchestrator and re-fired the capture script.

Still soft.


This is the part of debugging where the easy story breaks and the hard story begins.

The sweep at DAC 2606 was sharp. The orchestrator at DAC 2606 was soft. Same camera, same lens, same scene, seconds apart, same number written to the same I2C register. Different image.

I ran the orchestrator three more times. Soft, soft, soft — all visibly identical, all 140 KB JPEGs where the sweep's sharp ones were 220 KB. Then I ran the sweep again and pulled the dac_2606.jpg straight off the Jetson. Sharp. Same DAC. Same minute.

The difference was structural. The sweep walks through DAC values one at a time — sets 0, sleeps, captures a frame, sets 372, sleeps, captures a frame, ramps gradually upward to 2606. The orchestrator just jumps directly to the target — one I2C transaction, one settle, one capture. The two paths land the lens at the same nominal coordinate by different routes, and the lens evidently cares which route it took.

Voice coil motors have springs. They have inertia. A big single-step current pulse asks the lens to move 2,606 DAC units of distance in one go and the spring oscillates as it overshoots and pulls back. By the time the camera actually captures a frame, the lens hasn't fully damped — it's still ringing on its mount, smearing edges. Walking the lens up in smaller steps with a deliberate pause and a frame-flush at each one gave the spring time to bleed its energy off between motions. The lens arrived calmly instead of arriving loud.


The fix was a six-line change. Where the orchestrator used to call

focuser.set_position(2606)

it now calls

for stop in (target // 4, target // 2, 3 * target // 4, target):
    focuser.set_position(stop)
    time.sleep(0.3)
    cap.read()

Four small moves, each with a 300 ms pause and a frame-flush. The lens walks home.

The next test photo landed at 220 KB. Sharp leaves, readable book spine on the bookshelf to the right of the tomato pot, plant tag legible inside the soil, OLED text visible in the bottom of the frame. Claude's verdict came back at 0.65–0.75 confidence and finally named the thing it had been struggling to name all afternoon: a slight yellowing on one of zinnia_b's lower leaves. A real observation, made possible only because the photo could finally support it.


There's a physical-systems lesson buried in this one. Software people, including me, default to thinking about coordinates — the lens is at DAC 2606 — as if the coordinate were the thing. But the lens isn't at DAC 2606. The lens is at whatever-actual-mechanical-equilibrium-position-results-from-asking-DAC-2606-to-be-true-while-a-spring-pulls-against-a-coil-and-friction-resists. The number is a request. The spring decides whether to honor it cleanly or noisily.

The number doesn't move things. The number times the path you took to get there moves things.


WhatValue
Calibration recalibratedDAC 3510 → 2606
Tenengrad peak score (post-shift)2659
Capture paths that produced soft images1 (single-jump)
Capture paths that produced sharp images1 (multi-step ramp)
Sharp-photo file size~220 KB
Soft-photo file size~140 KB
Lines changed in capture_one.py6
Days until departure8

May 6, 2026 — The Pump Comes Online

The pump arrived Tuesday. A small 5V DC USB-A mini pump, the kind you'd find inside a desktop fountain. The plan was the one I'd been carrying since the SG90 lost the fight with the Brita lever: feed the pump from a wall adapter, switch it on and off through an NPN BJT, drive the BJT's base from an ESP32 GPIO, keep the existing POST /water HTTP scaffold so the Pi side wouldn't have to know anything had changed.

I drew the circuit on graph paper, laid out the parts on the breadboard, soldered the leads on the pump, sat down to write the firmware. I was going to be done by 8.

I was not done by 8.


The first test fired the GPIO high for one second, the multimeter said the GPIO was at 3.3V, the base of the BJT was at 0.7V, the transistor's base-emitter junction was clearly conducting, and absolutely nothing happened to the pump. No spin. No twitch. No buzz. The pump sat there.

I added a longer hold so I could probe more carefully. The collector dropped to zero volts during the hold, which would be the textbook sign of a transistor saturating cleanly into its load. The pump still didn't move.

Then I did the thing you do when nothing makes sense: I measured every voltage I could think of. Voltage across the pump terminals was 0.7V at idle and 1.3V when the test was running — exactly one diode forward-drop and then a slightly larger one as current rose. Voltage across the BJT collector-to-emitter was 3.7V when the BJT was supposedly "on" — which meant the BJT was eating most of the supply voltage and the pump was being given the leftover crumbs.

I had built two failures stacked on top of each other and they were taking turns explaining the symptoms.


The first failure was the flyback diode. I'd put it across the pump for inductive-spike protection — correctly identifying that the motor coil would generate a back-EMF when current was cut and that the spike would punch through the transistor unless given a safe path home. What I had not done correctly was orient it. The cathode (the banded end) was on the collector side instead of the supply side. Reverse-biased in normal operation is what you want. Forward-biased in normal operation is what I had. The diode wasn't a flyback diode; it was a permanent shunt around the pump, eating most of the current and dropping a single forward voltage across itself, which was exactly what the multimeter was telling me. Flip the diode, the bypass closes.

The second failure was more philosophical and more humbling. I had two power supplies on the breadboard: one for the ESP32 over micro-USB, one for the pump from a 5V/3A wall adapter. They had two separate ground rails. The ESP32's ground and the pump's ground were unconnected.

A BJT is a current-controlled switch. To open the channel you have to push current into the base, and that current has to come out of the emitter and find its way back to the source that pushed it. If the source is the ESP32 GPIO and the emitter is grounded to the pump supply rather than the ESP32 rail, the loop never closes. The base voltage looked correct because the multimeter is a high-impedance witness — it'll measure a potential difference across two unrelated reference points and report a number, but the number doesn't mean anything when there's no actual current flowing. We measured "18 volts" between the pump rails and the ESP32 ground at one point during the debugging, which was nonsense, which was the meter saying there is no shared reference between these two things and you are asking a question that has no answer.

A single jumper wire from the wall adapter's negative output to the ESP32's GND pin made the question answerable.


There's a lesson in that one wire that I've been writing down in my head all evening.

When you write software, "communication" is a word that comes free. Two functions in the same process share the call stack. Two services in the same network share the protocol. The plumbing is invisible until something breaks it.

When you wire physical hardware, communication is literally a closed loop of moving electrons, and the loop has to actually exist in copper. Every wire is part of a path. The path goes out of the supply's positive terminal, through the load, through the switch, back to the supply's negative terminal — and if any segment is missing, the whole thing is dead, silent, no error message, no exception, just a pump that doesn't spin and a meter reading you can't trust.

The phrase "common ground" is so worn out as a metaphor that it took me an hour of debugging to remember it's a literal electrical requirement.


Once the diode was right and the grounds were tied, the test pulse worked on the first try. The pump ran for one second, then ten seconds, then five. The wiring was correct. The schematic was correct. The schematic had been correct for two hours; what had been wrong was the gap between the schematic and the breadboard.

Porting it into main.py took five minutes. The old SG90 code came out, the BJT-pulse code went in, the HTTP server stayed exactly as it was. POST /water now does what its name has always claimed to do.

PUMP_PIN = 4
PUMP_PULSE_MS = 5000

pump = Pin(PUMP_PIN, Pin.OUT, value=0)

def water_pulse():
    pump.value(1)
    time.sleep_ms(PUMP_PULSE_MS)
    pump.value(0)

The Pi-side change was even smaller. pi5/capture.py had been writing action_taken = "logged_only" whenever Claude flagged a plant as needing water — the verdict landed in the database, the OLED summarized it, but nothing physical happened. I added a helper that POSTs to http://wy2z-water.local/water whenever the verdict has any plant flagged for water, recorded the ESP32's JSON ack into the observation row, and let the existing failure-handling pattern catch any unreachable-endpoint case as a soft fail.

Then I ran one capture pass to test it.

2026-05-06 22:53:34 dht11: 20.6C 52%
2026-05-06 22:53:42 photo on pi: 202214 bytes
2026-05-06 22:53:43 uploaded to Supabase
2026-05-06 22:53:59 verdict: tomato → water
2026-05-06 22:54:02 water endpoint ack: {"status":"ok","duration_ms":5000}

The tomato got its first automated drink at 10:54 PM Central, three days into a five-week experiment, on a setup that had only existed as a closed loop for about ninety seconds at that point. Observation 77770a50-9bdd-46e8-b4fc-789f8bef06c6 is the row in the database that records it.


There was a coda that night, and it was almost as instructive as the main act.

Earlier in the evening, before the pump worked, the OLED had briefly shown ERR: capture after the 19:30 cron. The Pi's log showed the failure was at the Jetson SSH step: ssh: connect to host 192.168.0.224 port 22: No route to host. By the time I got to the Pi to investigate, the Jetson was up, reachable, the IP was the same as the SSH config pinned, the host hadn't rebooted in three days. Whatever had broken at 19:30 had healed itself.

I dug through the Jetson's network state. Excellent signal at -38 dBm. No NetworkManager log entries for the failure window. No kernel events. Then I checked one specific thing on a hunch: iw dev wlP1p1s0 get power_save. It came back: Power save: on.

The Jetson's Wi-Fi card is allowed to put itself into a low-power state when idle. From the AP's perspective the Jetson is still associated. From the Jetson's perspective the radio is mostly off. When an inbound packet arrives — like the cron's once-every-twelve-hours SSH attempt — the radio takes hundreds of milliseconds to wake up. ARP queries during that window get nothing back, the kernel returns "No route to host", the SSH client gives up. Minutes later when something else triggers traffic, the radio is awake and everything is fine. The host is almost always reachable; the failure mode is precisely the case the cron job is most exposed to.

This is the mirror image of the common-ground bug. The common-ground bug was the hardware proving that every wire matters even when software thinks they're abstractions. The Wi-Fi-power-save bug was the radio proving that every connection matters even when the kernel thinks they're permanent. In both cases, a layer that I had been mentally treating as continuous turned out to be discrete, with gaps you can fall into if you don't check.

The fix for power_save is a one-line NetworkManager config (wifi.powersave = 2, "force off") plus a service restart. Documented in docs/jetson_wifi_powersave.md. Has to be applied with sudo on the Jetson itself, because the SSH playbook intentionally caps non-interactive sudo at the i2c tools and nothing else.


WhatValue
Pump pulses fired before one worked~12
Distinct hardware bugs found3 (backwards diode, missing common ground, BJT E/C orientation)
Distinct network bugs found1 (Jetson Wi-Fi power_save)
Multimeter probingsuncountable
Voltage across pump when "off"0.7V (diode forward drop, sign of bypass)
Voltage across BJT when "on"3.7V → 0.05V (after fixes)
Lines changed in esp32/main.py~25
Lines added to pi5/capture.py~30
First successful end-to-end watering2026-05-06 22:54 CT
Plants watered by a machine for the first time ever1 (tomato)
Lead fumes inhaled from solderingtoo much
Days until departure5