Overhead bench photo of the assembled OX rig: small solar panel, breadboard with the 100k/100k voltage divider populated, ESP32-C3 carrier with sensors mounted, LiPo pouch and USB-C cable.

Spike: OX Solar Power, Battery, and Voltage Detection

May 6, 2026 • By Nicholas Romero

This is the engineering journal for the OX outdoor power spike: can the existing carrier board — plus a small solar panel and a 250 mAh LiPo — sustain itself in the field, and can firmware tell us enough about the battery to make outdoor operation trustworthy? The short version: yes on the firmware side, no on the carrier side. A red power LED hard-wired to the LiPo rail eats the entire daily budget on its own. Everything below is how we got there.

What we set out to validate

Four hypotheses, with the verdicts the bench produced:

#HypothesisVerdict
H1The carrier's bottom-side B+ pad can be tapped through a 100 kΩ / 100 kΩ divider into GPIO4 to read battery voltage in firmware.✅ Works. multiply: 2.0 recovers true Vbatt; the divider's ~20 µA load doesn't disturb the charge path.
H2Without an INA219-class current sensor, dV/dt across deep-sleep wakes is enough to classify charge / discharge / float and to estimate net current.✅ Works in the linear region of the LiPo curve (3.5–4.0 V). Falsely flat above ~4.0 V (charger CC→CV transition); inflated below 3.5 V.
H3The 5-minute-wake sleep cycle keeps the platform inside the existing OX power budget (~31 mAh/day).❌ The carrier's red power LED draws ~1.3 mA continuously off the LiPo rail and burns the entire budget on its own. The architecture works; this carrier doesn't, until the LED is desoldered.
H4dV/dt baseline can be persisted across deep-sleep wakes in firmware-only, without an external RTC or SD card.✅ NVS-backed globals carry the previous voltage and SNTP epoch across wakes, so the rate sensor reports a real value from the second wake onward.

Bench rig

A simple bench mock between the panel, the carrier, and a multimeter:

  • ESP32-C3 Supermini on the existing expansion board
  • 250 mAh 1S LiPo into the carrier's JST connector
  • ~0.5 W solar panel into the carrier's 5V input
  • A small breadboard between everything for the divider plus a probe-friendly battery-rail tap
  • USB-C for bring-up, flashing, and fallback power

The breadboard hosts two physically independent subcircuits sharing only the common GND net: the solar feed on the top rails, and the divider on the bottom rails plus the R1 / R2 branch.

                          ┌──── GPIO4   (carrier read pin)

  Battery+ ──[R1 100k]────┴────[R2 100k]──── GND

Four wires run between the breadboard and the carrier:

Carrier pointSideWhat lands here
5Vtoptop + rail col 28
B+ padbottombot + rail col 12 ([BP])
B- padbottombot - rail col 30 ([GB])
GPIO4topjumper from [I12] (R1/R2 midpoint)

The LiPo plugs into the carrier's JST directly; USB-C plugs into the ESP32-C3 directly. Neither passes through the breadboard. The B+ tap is sense-only (~20 µA), in parallel with the JST connector.

A1 bottom-left, J1 top-left, J30 top-right
 
              01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
TOP - rail    P-  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o [GB]
TOP + rail    P+  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  5V o  o
 
j             o  o  o  o  o  o  o[J8]══R2══[J12]o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o
i             o  o  o  o  o  o  o │  o  o  o[I12]──→ jumper to carrier GPIO4
h             o  o  o  o  o  o  o │  o  o  o │   o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o
g             o  o  o  o  o  o  o │  o  o  o │   o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o
f             o  o  o  o  o  o  o │  o  o  o[F12]o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o
              ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ ─ ─ ─ ─ ║R1─ ─center gap─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
e             o  o  o  o  o  o  o │  o  o  o[E12]o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o
d             o  o  o  o  o  o  o │  o  o  o │   o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o
c             o  o  o  o  o  o  o │  o  o  o │   o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o
b             o  o  o  o  o  o  o │  o  o  o │   o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o
a             o  o  o  o  o  o  o │  o  o  o[A12]o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o  o
                                  │          │
BOT - rail    o  o  o  o  o  o  o[GN]─o─o─o─o│o─o─o─o─o─o─o─o─o─o─o─o─o─o─o─o─o─o─o─o[GB]──→ to carrier `B-` pad (bottom-side)
BOT + rail    o  o  o  o  o  o  o    o  o  o[BP]─o─o─o─o─o─o─o─o─o─o─o─o─o─o─o─o─o─o ────→ to carrier `B+` pad (bottom-side)
 
legend:
- `P+` = solar panel positive lead, soldered to panel and landed at top `+` rail col 1
- `P-` = solar panel negative lead, soldered to panel and landed at top `-` rail col 1
- `5V` = jumper from top `+` rail col 28 to carrier top-side `5V` pin
- `[J8]` to `[J12]` = `R2` (100kΩ) horizontal in row j, between columns 8 and 12
- `[F12]` to `[E12]` = `R1` (100kΩ) vertical across the center gap at column 12
- `[A12]` to `[BP]` = jumper from row a col 12 down to bot `+` rail col 12, passing over bot `-` rail col 12 without connecting (carries the bottom-side `B+` sense voltage to R1's lower lead)
- `[J8]` to `[GN]` = jumper from row j col 8 down to bot `-` rail col 8 (R2 return into the GND rail)
- `[I12]` to carrier `GPIO4` = jumper off-board from row i col 12, taps the R1/R2 midpoint (`[I12]` shares the upper col 12 tie strip with `[F12]` and `[J12]`, so it is electrically the divider midpoint without crowding R2's lead at `[J12]`)
- `[BP]` = bot `+` rail col 12; the bot `+` rail is wired off-board to the carrier's **bottom-side `B+` pad** (battery+ sense, in parallel with the JST connector)
- `[GN]` = bot `-` rail col 8, where R2's return lands; the bot `-` rail is wired off-board to the carrier's **bottom-side `B-` pad** (= system GND)
- `[GB]` to `[GB]` = long on-breadboard jumper from top `-` rail col 30 down to bot `-` rail col 30; ties the two `-` rails so solar return current exits via the bot `-` ↔ `B-` wire (no separate top-side `GND` wire is needed)
- `│` in upper col 8 (j-f) and upper col 12 (j-f), and lower col 12 (a-e) = internal column ties (not wires you place)

Safety: verify B+ polarity first

The carrier's bottom-side B+ / B- silkscreen is reversed on some board revisions. Power the carrier over USB, probe the labeled pads with a multimeter, confirm the real positive pad, and only then connect the LiPo or land the divider tap. Trusting the silkscreen blindly destroys the charging IC on first contact.

Firmware bring-up

The firmware lives at hardware/firmware/ox-node.yaml. The relevant ADC block:

sensor:
  - platform: adc
    pin: GPIO4
    id: battery_voltage
    update_interval: 30s
    attenuation: 12db
    accuracy_decimals: 3
    filters:
      - multiply: 2.0   # 100k / 100k divider — see breadboard layout above
prometheus:

12db attenuation gives a useful range up to ~3.3 V at the pin, which after the 2x divider covers a full 1S LiPo. Flash from the firmware devshell, then hit the metrics endpoint:

nix develop hardware/firmware --command \
  esphome run hardware/firmware/ox-node.yaml --device /dev/ttyACM0
 
curl -s http://ox-node.local/metrics | grep battery_voltage
# esphome_sensor_value{id="battery_voltage",name="Battery voltage"} 3.853

ESPHome's web UI shows the same data alongside the derived sensors:

Live Telemetry
ESPHome web UI for OX Node showing battery voltage 4.166 V, voltage rate -0.330 V/h, state 'discharging', net current -105.9 mA, time to empty 3.0 h, solar input current -100.9 mA, solar input power -421.3 mW.
OX Node web UI captured during a USB-disconnect discharge run. Every derived sensor populated: rate negative, state classified as discharging, time-to-empty around 3 h. The negative `Solar input current` is an artifact of the placeholder `system_load_ma = 5 mA` — a reminder the inference is only as good as that calibration.

Sanity checks against a multimeter at the carrier's B+ / B- pads:

  • Off by 2xmultiply: 2.0 filter missing or only one resistor populated. Re-check R1 (e12f12) and R2 (j8j12).
  • Reads ~0 V[I12] → GPIO4 jumper loose, or [A12][BP] jumper not reaching R1's lower lead.
  • Pinned ~3.3 V → divider midpoint exceeding ADC range; a 1S LiPo can't physically do this, so it's a wiring inversion or stuck pin.
  • Stable but offset → ESP32-C3 ADC factory calibration is imperfect; a calibrate_linear filter cleans up small offsets once meter and divider agree on shape.

What we found

Voltage measurement and dV/dt across deep sleep

The divider lands inside the spec'd 3.20 V cutoff to 4.20 V full range with credible accuracy. Cross-sleep dV/dt computation works once two timing knobs are right: update_interval: 30s on the rate template sensor (so it fires before the 60 s wake ends) and preferences: flash_write_interval: 5s (so the NVS baseline lands in flash before deep sleep cuts power). With those, the rate sensor reports a real value from the second wake onward; the previous-sample voltage and SNTP epoch persist in NVS-backed globals (wear math: ~1400 years at 5-min cadence — not the bottleneck).

State classifies cleanly as charging / discharging / float / idle, plus inferred Battery net current (mA) and Solar input current (mA = net + assumed system load). The solar inference is honest only in the linear region of the LiPo curve and only as good as the calibration of system_load_ma — currently a 5 mA placeholder that should be replaced with the magnitude of net current observed during a USB-disconnect, no-solar discharge run.

The red LED breaks the budget

The bigger finding: the carrier has a red power LED wired directly to the LiPo rail through a current-limiting resistor. No GPIO route. No software off-switch. With USB unplugged and the firmware in deep sleep, the LED still draws ~1.3 mA continuously:

1.3 mA × 24 h = 31.2 mAh/day

That is essentially the entire OX daily budget burned on a status indicator. A fully-charged 250 mAh cell holds about 8 days of pure-darkness reserve when only the LED is alive — and the planned 72-hour cloudy reserve is gone before the µC has done any work.

It is a hardware mod, not a firmware fix. Locate the red LED's series resistor (small SMD adjacent to the LED) and lift one pad — or desolder the LED itself. After the mod, sleep current returns to the spec'd ~5 µA range and the sizing study reads true.

Sizing reality check

SourceDaily draw
µC + radio at 5-min wake cadence (modeled)~31.4 mAh/day
Carrier red power LED (measured)~31.2 mAh/day
Total without mod~62.6 mAh/day
Total with mod~31.4 mAh/day

The architecture is sound. The carrier as-shipped isn't.

What's next

In order of value:

  • Lift the carrier's red-LED resistor. Precondition for any outdoor deployment.
  • Calibrate system_load_ma from a USB-disconnect, no-solar discharge run.
  • Indoor lamp / window-sun test: verify the rate flips positive during meaningful charging and that Solar input current lands in the right ballpark.
  • Add a temporary inline current meter to spot-check the inferred figures against a real one.
  • Repeat with the full sensor stack attached so the power model reflects real wake-load behaviour.

Beyond this spike, the unanswered question is measurement confidence: we can infer charging today but cannot measure solar current directly on stock hardware. That is acceptable for a first outdoor prototype but is the boundary to keep watching as OX moves from spike to product — likely via an INA219 or a charge controller with native current telemetry.


All hardware docs and firmware are open source — see the GitHub repo.