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.
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.
| Stage | Mean current | What changed |
|---|---|---|
| SEN66 calibrating, no useful PM | ~99 mA | The board was effectively running like a bench demo. |
| SEN66 rail latched on after reset | ~89 mA | Firmware changed, but the PMIC rail remembered the old state. Rude, but educational. |
| BUCK1 forced off at boot | ~9.7 mA | Explicitly turned off the SEN66 rail during PMIC init. |
| PM enabled, SEN66 off | ~3.3 mA | Let 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 mA | LED, 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:
| Block | Why it mattered |
|---|---|
| nRF54L15 | Main MCU, BLE controller, Zephyr/NCS runtime. |
| nPM1300 | PMIC, battery charger, regulators, fuel-gauge current reporting. |
| SEN66 | Air-quality sensor with fan/laser/current spikes; dominant load. |
| E-ink display | Low average when asleep, easy to falsely blame because “display” sounds guilty. |
| BLE | Connectivity path to gateway and app tooling. |
| SX1262/LoRa | Planned long-range transport; potential silent standby load. |
| Gateway | ESP32-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:
| Configuration | Mean current | Practical read |
|---|---|---|
| SEN66 calibrating, no PM | ~99 mA | Battery life measured in days. Excellent only if the product is a trade-show demo. |
| ”No calibration”, but BUCK1 latched on | ~89 mA | The firmware changed; the PMIC rail did not. |
| BUCK1 forced off at boot, no PM | ~9.7 mA | The first real low-power-ish baseline. About 10x better than the start. |
| BUCK1 off, PM enabled | ~3.3 mA | Kernel sleep and runtime PM removed another ~6.4 mA. |
| Useful sleep-window config after cadence cleanup | ~1.09 mA | The 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:
| Suspect | Result |
|---|---|
| E-ink held continuously active | Not the main load when initialized correctly. Disabling the driver incorrectly made things worse because the panel pins floated. |
| BLE advertising | Much smaller than feared at slow advertising cadence. |
| Sensor polling | Real, but not the giant mA-class leak. |
| Debug probe | Important 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:
- The nPM1300 fuel-gauge current value is read over I2C, which wakes the MCU.
- A connected SWD/J-Link debug probe can keep debug domains alive and alter sleep behavior.
- 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.
| Change | Why it mattered |
|---|---|
| Force SEN66 BUCK1 off at boot | Prevented a retained PMIC rail state from keeping the SEN66 powered after reset. |
| Fix the PM build/flash path | Let the MCU actually sleep instead of burning through the battery at a no-PM floor. |
| Slow the e-ink forced refresh cadence | Deep-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 work | Fewer PMIC/I2C/floating-point fuel-gauge events reduced wake energy. |
| Reprobe SEN66 after powering its rail | Fixed 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:
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 shape | Use |
|---|---|
list_devices | Confirm the JS220 is visible. |
target_power_status | Check whether the target current path is on and autoranged. |
cycle_target_power | Disconnect/reconnect DUT current path for repeatable boot captures. |
measure_energy | Capture current, voltage, power, charge, and energy over a time window. |
read_gpi | Read 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.
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. 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. 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. 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. 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. 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.
| Capture | Result |
|---|---|
| Post-boot SEN66-off nominal window | ~1.26 mA |
| 930 s duty-period validation | 5.559 mA |
| Corrected 929.6 s duty-period validation | 5.542 mA |
| 2000 s normal capture, steady 900 s projection | 5.765 mA |
| Detached MCP duty projection | 5.595 mA |
The SEN66 event itself was repeatable:
| Metric | Measured 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 result | What it changed |
|---|---|
| 15 minute SEN66 schedule measured at ~5.6-5.8 mA | The remaining battery problem was not random leakage; it was the sensing policy. |
| Each SEN66 event cost ~1.09-1.13 mAh above idle | The 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 model | Runtime 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 mA | There 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 run | BLE was measurable, but not worth gutting the product over. |
| LED/display policy showed visible overheads while display writes were small and bounded | The 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:
| Suspect | Result |
|---|---|
| E-ink display software rendering | Not the material idle cost. Skipping hardware display writes landed near the stripped floor. |
| BLE advertising/controller overhead | Measured around +10.5 uA in a 600 s stripped A/B run. Real, but not the problem. |
| Fuel-gauge work | Cadence mattered for the optimized sleep window, but it could not erase the SEN66 duty-cycle cost. |
| SHT45/DPS368 reads | Bounded small compared with SEN66. |
| Debug probe | Detached 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:
| Model | Current | Practical meaning |
|---|---|---|
| Optimized sleep window | ~1.09 mA | The platform waste is low enough to meet the nominal one-year budget between SEN66 events. |
| Measured 15 minute SEN66 schedule | ~5.6-5.8 mA | The 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:
- The agent had steering that required evidence, limitations, and saved artifacts.
- Firmware changes were built and flashed rather than merely proposed.
- The JS220 controlled the DUT current path and measured charge/energy.
- Scripts wrote machine-readable summaries and raw-ish CSV traces.
- 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.