EnviroIQ

How EnviroIQ Stopped Wasting Power

The real story of taking EnviroIQ from almost 100 mA of bench current to a roughly 1.09 mA optimized sleep window, then writing a JouleScope JS220 MCP server so Codex could expose the remaining SEN66 duty-cycle problem.

Optimized sleep window 1.09 mA
EnviroIQ boot current trace
120 s boot capture, simplified from the JS220 runs: low-milliamp idle, then the SEN66 warmup politely occupies the budget.

EnviroIQ is my air-quality hardware project built around a SimpleAir RevE sensor node: a Nordic nRF54L15 running Zephyr through Nordic’s nRF Connect SDK, a Nordic nPM1300 PMIC and fuel gauge, a 3.52 inch Waveshare e-paper display, Sensirion SHT45 temperature/humidity, Infineon DPS368 pressure, BLE, planned LoRa via Semtech SX1262, and a Sensirion SEN66 particulate matter sensor that is very good at measuring air and very comfortable taking current from the battery while doing it.

The original power goal sounded simple enough to be dangerous:

Run for more than one year from a 10 Ah LiPo.

That gives a nominal current budget of about 1.14 mA. With realistic derating, aging, cold, regulator loss, and the general personality of batteries, the useful target is closer to 0.57-1.14 mA average. The board did not begin there. It began in the land of “why is this drawing almost a hundred milliamps?” which is a useful place to visit once, briefly, and then leave with evidence.

This is the story of cutting the waste first, then writing the bench tooling that forced the remaining problem to identify itself.

The work started in Claude Code. The technology was, and still is, genuinely strong. It also became unusable for this bench workflow after repeated false positives treated legitimate local firmware and hardware automation as unapproved vulnerability testing. My experience with Anthropic support made that limitation feel permanent: I had been a Max 20x subscriber from very early on, my application was rejected immediately, and I never received meaningful responses to support requests. That combination is how I ended up moving the active workflow to Codex.

The Win, With Caveats

The successful part of this project is real: the EnviroIQ platform’s optimized between-measurement current went from the “something is very wrong” class to the “this can plausibly live on a battery” class.

StageMean currentWhat changed
SEN66 calibrating, no useful PM~99 mAThe board was effectively running like a bench demo.
SEN66 rail latched on after reset~89 mAFirmware changed, but the PMIC rail remembered the old state. Rude, but educational.
BUCK1 forced off at boot~9.7 mAExplicitly turned off the SEN66 rail during PMIC init.
PM enabled, SEN66 off~3.3 mALet the MCU sleep between work instead of idling like it was waiting for a meeting to end.
Useful product sleep window after cadence cleanup~1.09 mALED, e-ink, sensors, BLE, fuel gauge, and SEN66 duty manager were all present, with slower periodic refresh/tick cadence.

That is roughly a 91x reduction from the ugly early bench number to the optimized sleep-window number. If you use the cleaner “SEN66 rail actually off but no PM yet” baseline, it is still an 89% reduction.

The caveat matters: the sleep window is not the same thing as the whole product duty cycle. Later JS220 captures showed that a 15 minute SEN66 policy still averages 5.6-5.8 mA because each air-quality measurement costs about 1.1 mAh. The good news is that this is no longer vague. Moving from a 15 minute to a 1 hour SEN66 interval saves roughly 3.3 mA in the modeled field case and more than doubles the forecast from roughly 54 days to roughly 119 days. So the platform waste was dramatically reduced, and the remaining battery-life decision moved up a layer: how often should EnviroIQ run the SEN66, and what air-quality fidelity is worth that current?

That is the kind of success I trust. Not “we fixed everything.” More like: “we removed the nonsense, and now the hard problem is finally standing in the center of the room holding a name tag.”

The System

The firmware lives in simpleair-ncs, the Nordic nRF Connect SDK version of the SimpleAir firmware. The board target is simpleair_reve/nrf54l15/cpuapp. The public-facing product name is EnviroIQ, but the firmware and docs still use the SimpleAir naming because hardware projects enjoy having at least three names for every object.

The important power actors were:

BlockWhy it mattered
nRF54L15Main MCU, BLE controller, Zephyr/NCS runtime.
nPM1300PMIC, battery charger, regulators, fuel-gauge current reporting.
SEN66Air-quality sensor with fan/laser/current spikes; dominant load.
E-ink displayLow average when asleep, easy to falsely blame because “display” sounds guilty.
BLEConnectivity path to gateway and app tooling.
SX1262/LoRaPlanned long-range transport; potential silent standby load.
GatewayESP32-C6 bridge for BLE/LoRa, Home Assistant integration, and OTA workflows.

The gateway side matters because EnviroIQ is not just a sensor blinking alone in a corner. It is part of a sensor/gateway/Home Assistant chain. The power loop eventually sat next to gateway and OTA work, and the broader bench automation direction included agent control over both sensor and gateway firmware. That broader context comes from bench/operator notes; the durable power evidence in this post comes from the sensor-side JS220 captures and the JouleScope MCP tooling.

The First Wrong Model

The first research journal started with a reasonable suspicion list:

  • Debug builds had power management disabled.
  • The SEN66 rail might not be off when we thought it was.
  • The e-ink display might be held powered.
  • The debugger might keep the MCU from sleeping.
  • The nPM1300 current readings might be polluted by the act of reading them.

All of those were plausible. Some were true. Some were false in useful ways.

The early data was dramatic, and this is where the success story really starts:

ConfigurationMean currentPractical read
SEN66 calibrating, no PM~99 mABattery life measured in days. Excellent only if the product is a trade-show demo.
”No calibration”, but BUCK1 latched on~89 mAThe firmware changed; the PMIC rail did not.
BUCK1 forced off at boot, no PM~9.7 mAThe first real low-power-ish baseline. About 10x better than the start.
BUCK1 off, PM enabled~3.3 mAKernel sleep and runtime PM removed another ~6.4 mA.
Useful sleep-window config after cadence cleanup~1.09 mAThe board finally crossed the nominal one-year sleep-window budget.

The key lesson was not subtle: rail state survives assumptions. The nPM1300 BUCK1 regulator feeding the SEN66 could stay enabled across MCU reset. A firmware image with the duty manager disabled did not automatically mean the sensor rail was off. The board was not being mysterious. It was doing exactly what the power hardware had been told to do earlier.

The fix was to explicitly force BUCK1/SEN66 power off during PMIC initialization. After that, the measurements started making sense. This was the first major win: not a clever algorithm, not a heroic rewrite, just refusing to let a retained regulator state quietly burn the battery.

The second major win was enabling the PM path correctly and fixing the flashing workflow that had made PM look broken. The firmware had appeared to fault when CONFIG_PM was enabled, but the real problem was a flash/programming issue. Once that was corrected, the same board dropped from roughly 9.7 mA to roughly 3.3 mA.

Then the work got more subtle. Several suspects did not pay out:

SuspectResult
E-ink held continuously activeNot the main load when initialized correctly. Disabling the driver incorrectly made things worse because the panel pins floated.
BLE advertisingMuch smaller than feared at slow advertising cadence.
Sensor pollingReal, but not the giant mA-class leak.
Debug probeImportant for methodology, but not the explanation for the whole floor in the validated runs.

The important lesson was that power optimization is a detective story where most suspects are innocent, and a few are guilty in ways that are annoying but useful.

The Measurement Problem

Power optimization fails quickly when the measurement setup changes the thing being measured.

There were three problems:

  1. The nPM1300 fuel-gauge current value is read over I2C, which wakes the MCU.
  2. A connected SWD/J-Link debug probe can keep debug domains alive and alter sleep behavior.
  3. The early on-board current readings were useful for relative movement, but not trustworthy as absolute truth below the low-milliamp range.

The first problem turned out to be manageable. Sparse readings were enough to show relative direction. The second problem required discipline: build, flash, detach the probe when needed, power-cycle through the measurement instrument, capture, then reattach only after the measurement window. The third problem forced a tooling upgrade: a high-rate external source-meter showed the on-board current register was under-reading by about 41% in one low-load configuration.

That became the working protocol. It is boring. Boring is good. Boring is how you keep a battery-life claim from being a horoscope.

The source-meter work also produced one of the most useful non-firmware artifacts: a small persistent control service. One-shot scripts were not enough because the instrument dropped DUT power when the host connection closed. Keeping the connection open made repeated captures reliable. That pattern later carried directly into the JS220 MCP work: the agent should not merely ask for measurements; it needs a measurement service that preserves bench state across calls.

The Best Firmware Wins

The biggest changes were not exotic. They were the kind of changes that sound obvious only after a current trace makes them unavoidable.

ChangeWhy it mattered
Force SEN66 BUCK1 off at bootPrevented a retained PMIC rail state from keeping the SEN66 powered after reset.
Fix the PM build/flash pathLet the MCU actually sleep instead of burning through the battery at a no-PM floor.
Slow the e-ink forced refresh cadenceDeep-sleep wake required a full reset/init/LUT load, so refreshing every 10 s was expensive. Moving toward 120 s preserved usefulness and saved hundreds of microamps.
Slow fuel-gauge workFewer PMIC/I2C/floating-point fuel-gauge events reduced wake energy.
Reprobe SEN66 after powering its railFixed a real ordering bug where the driver probed while BUCK1 was off and then cached “not present.”

The e-ink result is my favorite because it is exactly the kind of bug that hides behind good intentions. The driver was correctly putting the panel to deep sleep after refresh. That is good. But the app was also forcing a periodic refresh every 10 seconds even when nothing meaningful had changed. Waking from deep sleep required roughly 700 ms of reset, init, and LUT loading. The display was not wasting energy by existing; the policy was waking it up to ask if it was still a display.

Changing the forced refresh interval from 10 s to 30 s, then to 120 s, plus slowing the fuel-gauge cadence, took the useful sleep-window current from around 1.49 mA to 1.09 mA. A repeat run measured 1.0885 mA, essentially identical to the first 1.0888 mA result. That is the point where the budget hit became credible instead of lucky.

Adding The JS220

The JouleScope JS220 changed the work from “read logs and hope” to “measure charge and energy over a real interval.” The important part is that we did not leave it as a manual bench instrument. We wrote joulescope-mcp, a public Model Context Protocol server that gives an agent direct access to JS220 measurements and target-power control.

The power audit used a JS220 with firmware 1.3.0, FPGA 1.3.3, and the Python JouleScope driver behind that MCP server. The primary tool was measure_energy: give it a duration and interval, get back total charge, total energy, average current, average power, voltage, and per-bin samples. Another tool could cycle target power through the JS220 current path, which meant Codex could start each run from a known electrical state instead of asking me to poke the bench every few minutes like a very expensive metronome.

That gave the agent a clean loop:

Bench workflow

Agent measurement loop

Only count progress after the firmware builds, flashes, power-cycles, measures, saves artifacts, and beats the previous run.

Build firmware variant
Flash with verification
Cycle target power through JS220
Capture current, power, charge, and energy
Save JSON and CSV artifacts
Compare against the prior run
Change one thing
Repeat

The measurement helpers saved compact summaries and CSV traces in the private firmware repository. When MCP transport limits got in the way, the same JS220 service backend was called directly from Python. Later, the MCP timeout was raised and the hosted tool path successfully completed near-5-minute measurements, including a scheduled SEN66 event capture through the MCP path.

That matters because “agent optimizes firmware” is otherwise too easy to fake. The useful version is narrower and more demanding: the agent can only claim progress when the instrument says the charge changed.

The MCP Server

The MCP server is github.com/juanqui/joulescope-mcp. We wrote it so Codex could operate the JS220 autonomously instead of treating power measurement as a manual side channel.

Tool shapeUse
list_devicesConfirm the JS220 is visible.
target_power_statusCheck whether the target current path is on and autoranged.
cycle_target_powerDisconnect/reconnect DUT current path for repeatable boot captures.
measure_energyCapture current, voltage, power, charge, and energy over a time window.
read_gpiRead JS220 GPI pins for future marker-synchronized captures.

This is the point where the setup stopped being “an AI looks at firmware” and became a bench automation loop. Codex could inspect the code, modify firmware, build the board, use the JS220, and then decide whether the change helped. In later bench workflows, this direction extended toward coordinating gateway and sensor firmware together, and using visual confirmation of the e-ink display. For this post, I am keeping the hard claims tied to the JS220 artifacts because those are the receipts.

Prompts That Shaped The Loop

These samples are cleaned from the real Claude Code and Codex histories. I removed local paths, repeated context dumps, typos, secrets, and tool-specific noise, but kept the actual intent and constraints. The raw history is useful to me; it is not suitable for publication.

Claude Code, cleaned

Start the power loop

Turn a one-year battery target into a measured firmware research loop.

Build and test lower-power firmware support for the RevE sensor. The goal is more than one year of battery life from the installed LiPo.

First understand the hardware and every powered component. Review the device docs, backfill missing power-management sections from datasheets, cite primary sources, and then run an iterative measurement loop.

Triple-check assumptions and keep a dated journal.
Claude Code, cleaned

Continue an empirical iteration

Force each loop to pick up from the journal and preserve closed evidence.

Continue the power-optimization research loop.

Read the journal, find the next pending iteration, execute it under the research-loop rules, load the relevant hardware/tooling skills, capture verbatim measurements, cite primary sources, append the result, and do not rewrite closed iterations.
Claude Code, cleaned

Fix the power instrument workflow

Move from one-shot CLI captures to a persistent instrument service.

The power profiler is wired to the battery input, which is the true current path we care about.

The CLI command appears to work, but the device loses power when the tool disconnects. Build a small server that keeps the instrument connection open, exposes status and capture commands, and test it until the power source is reliable enough for autonomous profiling.
Codex, cleaned

Build the JS220 MCP server

Expose charge, energy, current, voltage, and power control as agent-callable tools.

Create a robust MCP server for the JouleScope JS220.

Expose agent-friendly tools for measuring total charge and energy over a requested duration, with configurable accumulation intervals. Include current, voltage, power, charge, energy, target-power control, and enough interval samples for an agent to compare firmware variants honestly.
Codex, cleaned

Run the Codex power audit

Make the agent power-cycle the board, capture boot and idle behavior, and trace the SEN66 load.

Use the JouleScope JS220 MCP to measure exact power consumption of the hardware.

Power-cycle the device through the JS220 current path, capture the full boot cycle multiple times, study nominal fully booted current, trace every sensor and operation, especially the SEN66, and write a power-consumption audit report.
Codex, cleaned

Make the bench visible

Use the camera and real e-ink panel as part of the acceptance test.

Use the camera pointed at the e-ink display to verify the UI, not just the logs.

The display should make it obvious when an OTA is in progress. Redesign the screen until it is legible, polished, and validated visually on the real panel.

What The JS220 Data Added

The earlier loop proved we could remove platform waste. The JS220 loop answered the next question: after the board is no longer doing silly things, what still owns the battery?

The answer was blunt: the current 15 minute SEN66 schedule is dominated by the SEN66 event.

CaptureResult
Post-boot SEN66-off nominal window~1.26 mA
930 s duty-period validation5.559 mA
Corrected 929.6 s duty-period validation5.542 mA
2000 s normal capture, steady 900 s projection5.765 mA
Detached MCP duty projection5.595 mA

The SEN66 event itself was repeatable:

MetricMeasured band
Event duration~45.6-47.0 s
Average event current~86-90 mA
Peak bin current~129-136 mA
Incremental charge above idle~1.09-1.13 mAh

That is the whole plot. The board can sit around one-ish milliamp between events, but every 15 minutes it runs a high-current SEN66 measurement long enough to dominate the average.

The win from the JS220 was that this stopped being a hunch. The agent could run long enough captures to catch complete scheduled events, split idle from event charge, and turn the product question into a current budget:

JS220 resultWhat it changed
15 minute SEN66 schedule measured at ~5.6-5.8 mAThe remaining battery problem was not random leakage; it was the sensing policy.
Each SEN66 event cost ~1.09-1.13 mAh above idleThe firmware team could model interval changes directly instead of arguing from datasheet typicals.
Moving from 15 minutes to 1 hour saves roughly 3.3 mA in the field modelRuntime moves from roughly 54 days to roughly 119 days under the same 80% usable-capacity, 3%/month self-discharge planning case.
Normal non-SEN baseline measured ~1.26 mA, with a stripped floor around ~0.86 mAThere is still a sub-milliamp cleanup target, but it is not the first-order problem while SEN66 runs every 15 minutes.
BLE measured about +10.5 uA in the clean 600 s A/B runBLE was measurable, but not worth gutting the product over.
LED/display policy showed visible overheads while display writes were small and boundedThe remaining cleanup work moved toward sparse LED behavior, panel parking, and marked captures rather than blind driver removal.

The JS220 loop also killed several tempting villains:

SuspectResult
E-ink display software renderingNot the material idle cost. Skipping hardware display writes landed near the stripped floor.
BLE advertising/controller overheadMeasured around +10.5 uA in a 600 s stripped A/B run. Real, but not the problem.
Fuel-gauge workCadence mattered for the optimized sleep window, but it could not erase the SEN66 duty-cycle cost.
SHT45/DPS368 readsBounded small compared with SEN66.
Debug probeDetached checks matched the same current class for the current bench image.

This is why the post title is not “I turned off BLE and saved the planet.” BLE was not the main load. BLE was a paperclip on the floor.

Battery Forecast

The 10 Ah pack sounds enormous until the duty cycle gets a vote.

There are two honest battery-life stories:

ModelCurrentPractical meaning
Optimized sleep window~1.09 mAThe platform waste is low enough to meet the nominal one-year budget between SEN66 events.
Measured 15 minute SEN66 schedule~5.6-5.8 mAThe air-quality policy dominates the deployed average.

Using the measured 15 minute schedule, the realistic forecast landed around 54-61 days for the current firmware under 80-90% usable capacity and 3%/month self-discharge planning assumptions. More conservative aged-pack and event-margin cases were closer to 45-50 days.

That is not a one-year sensor at the current 15 minute SEN66 cadence. It is also not a failure. It means the waste was removed successfully, and the remaining gap is not mysterious leakage. It is the product asking for a 45 second, roughly 90 mA measurement every 15 minutes. The sensor is doing what it was told. The battery is simply filing a formal complaint.

The path to one year is now concrete enough to be uncomfortable: the SEN66 policy has to change, or the product has to accept a shorter runtime. Firmware idle cleanup helped a lot, but it cannot erase a 45 second, roughly 90 mA sensor event every 15 minutes. The optimization target moved from “find random leakage” to “choose the sensing schedule, sensor mode, or air-quality requirements honestly.”

The Part I Liked Most

The most useful artifact was not a single current number. It was the workflow:

  1. The agent had steering that required evidence, limitations, and saved artifacts.
  2. Firmware changes were built and flashed rather than merely proposed.
  3. The JS220 controlled the DUT current path and measured charge/energy.
  4. Scripts wrote machine-readable summaries and raw-ish CSV traces.
  5. Review gates checked whether the evidence set was complete.

That made Codex less like a chatbot and more like a patient lab assistant with a compiler, an ammeter, and no instinct for lunch breaks.

The loop still had limits. The JS220 GPI marker capture was prepared but not physically completed in the current bench setup, so exact hardware-edge synchronization is still missing. The audit records that as an explicit limitation instead of pretending the retained RAM trace and current bins are the same thing. They are not.

What Comes Next

The next useful experiments are product-level, not polish-level:

  • Test longer SEN66 intervals and quantify how air-quality usefulness degrades.
  • Evaluate event-triggered or adaptive sampling instead of a fixed 15 minute cadence.
  • Add a real JS220 GPI marker capture when the bench wiring is available.
  • Decide which raw measurement artifacts should be published with future posts.
  • Keep the agent loop, but make it argue from charge and energy, not vibes.

The board is not done. The process, however, is much better now. It can tell us when the battery story is real, when it is wishful thinking, and when the sensor is simply doing exactly what we asked, at 90 mA, for 46 seconds, again.

Evidence Status

The JouleScope MCP server is public at github.com/juanqui/joulescope-mcp. The power data behind this draft is still in a private firmware repository and local JSON/CSV artifacts. That is fine for drafting, but it is not good enough for public evidence.

Before this post is promoted from private draft to public article, the supporting package should be published as a sanitized artifact bundle with:

  • JS220 summary JSON for the boot, nominal, 930 s duty-period, 2000 s multi-duty, BLE A/B, and detached-probe runs.
  • CSV samples for at least the representative boot, nominal, and duty-period captures.
  • Sanitized high-rate source-meter summaries for the 1.09 mA optimization loop.
  • The scripts used to normalize captures and compute the forecast.
  • A short manifest explaining firmware revision, instrument version, target-power wiring, probe state, and known limitations.
  • Redactions for local absolute paths, serials if desired, account details, private network addresses, and any credentials from agent history.

Until that bundle exists, the honest reader-facing citation is: measurements are internally reproducible and instrument-backed, but the raw evidence is not yet public.