<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Arjun Krishna</title>
    <description>The latest articles on DEV Community by Arjun Krishna (@decoded_cipher).</description>
    <link>https://dev.to/decoded_cipher</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2313595%2Fc852a050-b5c6-4f14-9d72-0dd56540e400.jpg</url>
      <title>DEV Community: Arjun Krishna</title>
      <link>https://dev.to/decoded_cipher</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/decoded_cipher"/>
    <language>en</language>
    <item>
      <title>Build an ESP32 energy meter with a live dashboard</title>
      <dc:creator>Arjun Krishna</dc:creator>
      <pubDate>Sat, 18 Jul 2026 09:07:23 +0000</pubDate>
      <link>https://dev.to/decoded_cipher/build-an-esp32-energy-meter-with-a-live-dashboard-1m1g</link>
      <guid>https://dev.to/decoded_cipher/build-an-esp32-energy-meter-with-a-live-dashboard-1m1g</guid>
      <description>&lt;p&gt;Most ESP32 energy-meter tutorials you'll find are built on platforms that have since changed out from under them — deprecated app flows, retired tokens, code that no longer compiles as written. This build has no such dependency. A PZEM-004T does the metering, the ESP32 reports over plain HTTPS/WebSocket, and the dashboard, history, and alerts run in nodrix on &lt;strong&gt;your own Cloudflare account&lt;/strong&gt; — nothing in the path can be discontinued on you.&lt;/p&gt;

&lt;p&gt;What you get: live volts, amps, watts, and a lifetime kWh counter on a dashboard you can open from anywhere, a 24-hour load curve, and a Telegram message when something draws more than it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safety first
&lt;/h2&gt;

&lt;p&gt;This project meters mains electricity. The ESP32 side is all low-voltage, but the PZEM's input terminals connect to live line and neutral:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;De-energize the circuit&lt;/strong&gt; before touching any mains wiring, every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enclose everything.&lt;/strong&gt; No exposed screw terminal when powered — a cheap junction box is fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fuse the voltage tap&lt;/strong&gt; with a small inline fuse (0.5A is plenty; it only feeds the meter).&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;CT clamp is non-invasive&lt;/strong&gt; — it clips around one insulated conductor (line or neutral, never both) and touches no copper.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of that reads as unfamiliar rather than routine, build the firmware against a bench supply and have an electrician land the mains side.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;ESP32&lt;/strong&gt; dev board (any common DevKit variant).&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;PZEM-004T v3.0&lt;/strong&gt; with its split-core CT coil — the version with the Modbus serial interface.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;5V supply&lt;/strong&gt; for the ESP32, an enclosure, and a fused tap off the circuit you're metering.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Arduino IDE&lt;/strong&gt; with the ESP32 board package, the &lt;strong&gt;Nodrix&lt;/strong&gt; library, and the &lt;a href="https://github.com/mandulaj/PZEM-004T-v30" rel="noopener noreferrer"&gt;&lt;strong&gt;PZEM004Tv30&lt;/strong&gt;&lt;/a&gt; library, both from the Library Manager.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;nodrix instance&lt;/strong&gt; with a project and a project token.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why use dedicated metering hardware
&lt;/h2&gt;

&lt;p&gt;The classic DIY route — an SCT-013 current clamp into the ESP32's ADC with EmonLib — measures only current and assumes a mains voltage to estimate watts. Real mains sags and swells a few percent all day, the ESP32's ADC is famously nonlinear, and reactive loads (fridges, motors, anything with a power supply) make apparent and real power diverge.&lt;/p&gt;

&lt;p&gt;The PZEM-004T v3.0 samples voltage and current together in purpose-built metering silicon and hands the ESP32 six finished numbers over serial: volts, amps, watts, kWh, hertz, and power factor. It measures 80–260V, up to 100A with the external CT, and keeps its energy count through power cuts. The ESP32's job collapses to what it's good at: asking for numbers and shipping them to the cloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring
&lt;/h2&gt;

&lt;p&gt;Two sides, kept physically apart. The mains side: line and neutral into the PZEM's voltage terminals (through the fuse), and the CT clipped around the line conductor. The low-voltage side is four wires:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;From&lt;/th&gt;
&lt;th&gt;To&lt;/th&gt;
&lt;th&gt;Wire&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PZEM &lt;span&gt;5V&lt;/span&gt;
&lt;/td&gt;
&lt;td&gt;ESP32 &lt;span&gt;3V3&lt;/span&gt;
&lt;/td&gt;
&lt;td&gt;Power&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PZEM &lt;span&gt;GND&lt;/span&gt;
&lt;/td&gt;
&lt;td&gt;ESP32 &lt;span&gt;GND&lt;/span&gt;
&lt;/td&gt;
&lt;td&gt;Ground&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PZEM &lt;span&gt;TX&lt;/span&gt;
&lt;/td&gt;
&lt;td&gt;ESP32 &lt;span&gt;GPIO16 (RX2)&lt;/span&gt;
&lt;/td&gt;
&lt;td&gt;Serial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PZEM &lt;span&gt;RX&lt;/span&gt;
&lt;/td&gt;
&lt;td&gt;ESP32 &lt;span&gt;GPIO17 (TX2)&lt;/span&gt;
&lt;/td&gt;
&lt;td&gt;Serial&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Power the PZEM's interface side from the ESP32's &lt;strong&gt;3.3V pin&lt;/strong&gt;, not 5V. The comms side is optically isolated from the mains-side metering chip and runs happily at 3.3V — and it keeps the PZEM's TX at levels the ESP32's RX pin (which is not 5V-tolerant) is built for. If your particular module only talks when powered at 5V, keep the 5V supply but put a two-resistor divider on the PZEM-TX line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The firmware
&lt;/h2&gt;

&lt;p&gt;One socket carries everything: readings go up, and the connection stays open for anything you add later (a relay, a counter reset). The &lt;a href="https://github.com/decoded-cipher/nodrix-sdk" rel="noopener noreferrer"&gt;nodrix Arduino library&lt;/a&gt; owns the socket and the reconnects; the sketch is just a read-and-send loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;Nodrix.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;PZEM004Tv30.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;WIFI_SSID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"your-ssid"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;WIFI_PASS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"your-password"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;HOST&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"nodrix.you.workers.dev"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;TOKEN&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"tok_your_project_token"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;PZEM004Tv30&lt;/span&gt; &lt;span class="nf"&gt;pzem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Serial2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Nodrix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WIFI_SSID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WIFI_PASS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TOKEN&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Nodrix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;lastReading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;millis&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lastReading&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;lastReading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;millis&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;voltage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pzem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;voltage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pzem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;power&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pzem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;power&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;energy&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pzem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;energy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isnan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;voltage&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// meter not answering — skip, don't send garbage&lt;/span&gt;

    &lt;span class="n"&gt;Nodrix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"voltage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;voltage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Nodrix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"current"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Nodrix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"power"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;power&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Nodrix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"energy_kwh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;energy&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth understanding rather than copying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;isnan&lt;/code&gt; check matters.&lt;/strong&gt; When the PZEM sees no mains voltage (breaker off, loose tap) it returns NaN for everything. Skipping the send keeps the chart honest — a gap reads as "meter offline", a stream of zeros reads as "house off", and those are different diagnoses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The kWh counter lives in the meter.&lt;/strong&gt; &lt;code&gt;pzem.energy()&lt;/code&gt; is a lifetime total that survives reboots and outages on both sides. Report it as-is; derive per-day or per-billing-period numbers in the cloud from the stored series.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ten seconds is a deliberate rate.&lt;/strong&gt; Fast enough to catch a kettle, slow enough to be free on your own infrastructure — and a rate that monthly-capped hosted tiers can't sustain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin TLS before you ship.&lt;/strong&gt; &lt;code&gt;Nodrix.begin()&lt;/code&gt; connects encrypted but unverified on first run; add &lt;code&gt;Nodrix.setCACert()&lt;/code&gt; for production, covered in &lt;a href="https://nodrix.live/guides/esp32-https-cloud" rel="noopener noreferrer"&gt;Connect an ESP32 over HTTPS&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Build the dashboard
&lt;/h2&gt;

&lt;p&gt;Four widgets, each bound to a variable the firmware is already sending:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Widget&lt;/th&gt;
&lt;th&gt;Bind to&lt;/th&gt;
&lt;th&gt;Shows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gauge&lt;/td&gt;
&lt;td&gt;&lt;code&gt;power&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;live draw in watts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chart&lt;/td&gt;
&lt;td&gt;&lt;code&gt;power&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the 24-hour load curve&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Value&lt;/td&gt;
&lt;td&gt;&lt;code&gt;energy_kwh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;lifetime energy total&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Value&lt;/td&gt;
&lt;td&gt;&lt;code&gt;voltage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;mains health at a glance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The chart is where the project pays for itself. A day of household load has a shape — the overnight baseline, the morning spike, the compressor sawtooth of a fridge. Once you know your baseline, two&lt;br&gt;
things become obvious: what's always on (that's the number that dominates the bill), and anything new that shouldn't be. A baseline that steps up 60W and stays there is how you find the amplifier&lt;br&gt;
that never sleeps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add the alerts
&lt;/h2&gt;

&lt;p&gt;Two automations cover the useful cases; both are edited in the dashboard, not the firmware.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Load spike.&lt;/strong&gt; Trigger on a new &lt;code&gt;power&lt;/code&gt; reading, condition &lt;strong&gt;above 3000&lt;/strong&gt; (tune to your circuit), action: Telegram — "Drawing {{value}}W right now." A washing machine tripping this at 2 p.m. is routine; a space heater tripping it at 3 a.m. is worth a message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Meter offline.&lt;/strong&gt; A schedule trigger every morning with an &lt;code&gt;if-variable&lt;/code&gt; condition on &lt;code&gt;voltage&lt;/code&gt; — if the latest reading is stale or missing, something upstream is off: breaker, fuse, or the meter itself. A monitor that fails silently is worse than none.&lt;/p&gt;

&lt;p&gt;Swap Telegram for Slack, Discord, or SMS without touching the conditions — the alert channel is a detail of the automation, not the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs to run
&lt;/h2&gt;

&lt;p&gt;Four variables at six readings a minute is roughly a million updates a month. On hosted maker platforms that's deep into paid territory — monthly message caps are exactly what continuous monitoring burns through. Here the meter reports to your own Cloudflare account, where that volume sits comfortably inside normal Workers usage; there is no per-device fee and no message quota to manage. The economics are the point: an energy monitor only earns its keep if it runs continuously&lt;br&gt;
for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going further
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Switch loads from the same board.&lt;/strong&gt; A relay on a spare GPIO plus a &lt;code&gt;NODRIX_WRITE&lt;/code&gt; handler turns the meter into a metered smart switch — the pattern is in &lt;a href="https://nodrix.live/guides/esp32-receive-commands" rel="noopener noreferrer"&gt;Receive commands on an ESP32&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meter more circuits.&lt;/strong&gt; Additional PZEM units share one serial bus with distinct Modbus addresses, so one ESP32 can report &lt;code&gt;power_lights&lt;/code&gt;, &lt;code&gt;power_kitchen&lt;/code&gt;, &lt;code&gt;power_ac&lt;/code&gt; — each auto-creates its own variable and chart series.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute the bill.&lt;/strong&gt; Pull the &lt;code&gt;energy_kwh&lt;/code&gt; series from the read API and multiply by your tariff in a spreadsheet or script — the data is yours, behind one token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track power factor.&lt;/strong&gt; The PZEM also reports &lt;code&gt;pf()&lt;/code&gt; and &lt;code&gt;frequency()&lt;/code&gt;; two more &lt;code&gt;Nodrix.send&lt;/code&gt; lines if you want them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing here can be deprecated on you.&lt;/strong&gt; The meter speaks Modbus, the board speaks HTTPS and WebSocket, and the platform is open source (MIT) on your own account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data is queryable.&lt;/strong&gt; Every reading lands in your tenancy and comes back out through the read API — no export button to hunt for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configurable without reflashing.&lt;/strong&gt; Thresholds, alert channels, and message text all live in the automation editor.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why a PZEM-004T instead of an SCT-013 clamp and EmonLib?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The PZEM-004T measures voltage, current, power, energy, frequency, and power factor in dedicated metering hardware and hands the ESP32 finished numbers over serial. The SCT-013 route reads a raw current waveform on the ADC and estimates power by assuming a fixed mains voltage, so it drifts with every sag and it can't see power factor. The clamp still has one advantage — it's fully non-invasive — but for numbers you'd bill against, the PZEM is the right tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work on both 230V and 110V mains?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The PZEM-004T v3.0 measures 80–260V AC at 45–65Hz, which covers 110V/60Hz and 230V/50Hz systems alike. The firmware doesn't change — the meter reports whatever it sees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where is the kWh total stored, and does it reset when the board reboots?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The energy counter accumulates inside the PZEM itself and survives both ESP32 reboots and power cuts. The firmware just reports it. If you want billing-period totals, keep the lifetime counter as-is and compute deltas in the cloud — or call the library's resetEnergy() from a control write when a new period starts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to build this myself?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The low-voltage side — ESP32, serial wiring, USB — is as safe as any breadboard project. The mains side is not: the PZEM's screw terminals connect to live line and neutral. Work with the circuit de-energized, put everything in an enclosure so no live terminal is exposed, fuse the tap, and if you're not comfortable working inside a mains box, stop at a plug-in smart meter or ask an electrician. The CT coil itself clips around one insulated conductor and never touches copper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can the same build switch the load on and off?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes — add a relay on a spare GPIO and a NODRIX_WRITE handler for a switch variable, the same downlink pattern as the toggle in the smart-home guide. Size the relay for the load and keep it on the mains side of the enclosure. The meter and the switch stay independent: you can monitor without switching, or switch without trusting the meter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much data does reporting every 10 seconds generate?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Four variables every 10 seconds is about 34,000 updates a day. That's nothing for a WebSocket on your own Cloudflare account, but it's exactly the kind of rate that burns through hosted-platform free tiers with monthly message caps — one reason energy monitors are usually the first project to outgrow them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nodrix.live/guides/esp32-energy-meter" rel="noopener noreferrer"&gt;nodrix.live&lt;/a&gt;. nodrix is an open-source (MIT) IoT platform you deploy to your own Cloudflare account — &lt;a href="https://github.com/decoded-cipher/nodrix" rel="noopener noreferrer"&gt;source on GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>iot</category>
      <category>esp32</category>
      <category>arduino</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Build an ESP32 air quality monitor with a live CO2 dashboard</title>
      <dc:creator>Arjun Krishna</dc:creator>
      <pubDate>Sat, 18 Jul 2026 08:59:10 +0000</pubDate>
      <link>https://dev.to/decoded_cipher/build-an-esp32-air-quality-monitor-with-a-live-co2-dashboard-53p6</link>
      <guid>https://dev.to/decoded_cipher/build-an-esp32-air-quality-monitor-with-a-live-co2-dashboard-53p6</guid>
      <description>&lt;p&gt;A CO2 monitor is the rare sensor project that changes your behaviour: watch the number climb through a closed meeting room and you will open a window before anyone gets a headache. This build measures&lt;br&gt;
CO2 properly with a Sensirion SCD41, reads temperature and humidity from the same part, and streams all three to a live dashboard with colour-banded thresholds — so a glance tells you whether the air&lt;br&gt;
is fine or stale.&lt;/p&gt;

&lt;p&gt;What it doesn't need is the thing most SCD41 tutorials quietly assume: a Home Assistant server running the ESPHome add-on on your network. The ESP32 here posts straight to your own cloud dashboard over plain HTTPS. Nothing local to keep alive, and the readings open from anywhere.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the SCD41
&lt;/h2&gt;

&lt;p&gt;Air-quality projects live or die on the sensor, and the cheap default is a trap. An MQ-135 costs two dollars and reports something, but not CO2 in real ppm — it's a metal-oxide sensor that drifts, needs constant recalibration, and can't separate CO2 from other gases. The older MH-Z19 is a real NDIR CO2 sensor and a fine choice a few years ago.&lt;/p&gt;

&lt;p&gt;The Sensirion SCD41 is the current answer: a photoacoustic CO2 sensor accurate to about ±(40 ppm + 5% of reading), that also hands you temperature and humidity over the same I2C bus — three variables, one part, no analog guesswork. It's low-power enough for battery builds, and its automatic self-calibration keeps it honest over time. It costs more than an MQ-135, and it's worth every cent the first time the number tells you something true.&lt;/p&gt;
&lt;h2&gt;
  
  
  What you'll need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;ESP32&lt;/strong&gt; dev board (any common DevKit variant).&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Sensirion SCD41&lt;/strong&gt; breakout (SCD40 works too — slightly lower accuracy, same code).&lt;/li&gt;
&lt;li&gt;Four jumper wires; the SCD41 is I2C, so it's just power and two data lines.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Arduino IDE&lt;/strong&gt; with the ESP32 board package, the &lt;strong&gt;Nodrix&lt;/strong&gt; library, and Sensirion's &lt;a href="https://github.com/Sensirion/arduino-i2c-scd4x" rel="noopener noreferrer"&gt;&lt;strong&gt;I2C SCD4x&lt;/strong&gt; library&lt;/a&gt;, both from the Library Manager.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;nodrix instance&lt;/strong&gt; with a project and a project token.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Wiring
&lt;/h2&gt;

&lt;p&gt;Pure I2C — four wires, no analog pins, no level shifting (the SCD41 breakout is 3.3V-friendly):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;From&lt;/th&gt;
&lt;th&gt;To&lt;/th&gt;
&lt;th&gt;Wire&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SCD41 &lt;span&gt;VDD&lt;/span&gt;
&lt;/td&gt;
&lt;td&gt;ESP32 &lt;span&gt;3V3&lt;/span&gt;
&lt;/td&gt;
&lt;td&gt;Power&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SCD41 &lt;span&gt;GND&lt;/span&gt;
&lt;/td&gt;
&lt;td&gt;ESP32 &lt;span&gt;GND&lt;/span&gt;
&lt;/td&gt;
&lt;td&gt;Ground&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SCD41 &lt;span&gt;SDA&lt;/span&gt;
&lt;/td&gt;
&lt;td&gt;ESP32 &lt;span&gt;GPIO21&lt;/span&gt;
&lt;/td&gt;
&lt;td&gt;I2C data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SCD41 &lt;span&gt;SCL&lt;/span&gt;
&lt;/td&gt;
&lt;td&gt;ESP32 &lt;span&gt;GPIO22&lt;/span&gt;
&lt;/td&gt;
&lt;td&gt;I2C clock&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GPIO21/22 are the ESP32's default I2C pins. The SCD41 draws a brief high current during each measurement, so power it from a stable 3.3V rail rather than a long, thin lead.&lt;/p&gt;
&lt;h2&gt;
  
  
  The firmware
&lt;/h2&gt;

&lt;p&gt;The SCD41 measures on its own cadence — one reading every five seconds in periodic mode — so the sketch starts it, then reports whatever it has each cycle. The &lt;a href="https://github.com/decoded-cipher/nodrix-sdk" rel="noopener noreferrer"&gt;nodrix Arduino library&lt;/a&gt; owns the socket and reconnects.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;Nodrix.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;SensirionI2cScd4x.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;Wire.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;WIFI_SSID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"your-ssid"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;WIFI_PASS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"your-password"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;HOST&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"nodrix.you.workers.dev"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;TOKEN&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"tok_your_project_token"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;SensirionI2cScd4x&lt;/span&gt; &lt;span class="n"&gt;scd4x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Wire&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;scd4x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Wire&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x62&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;scd4x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stopPeriodicMeasurement&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// clean state after a reset&lt;/span&gt;
  &lt;span class="n"&gt;scd4x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;startPeriodicMeasurement&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;Nodrix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WIFI_SSID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WIFI_PASS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TOKEN&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Nodrix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;lastReading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;millis&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lastReading&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;lastReading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;millis&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="kt"&gt;uint16_t&lt;/span&gt; &lt;span class="n"&gt;co2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;humidity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scd4x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;readMeasurement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;co2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;humidity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;co2&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;Nodrix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"co2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;co2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="n"&gt;Nodrix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"temperature"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="n"&gt;Nodrix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"humidity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;humidity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth understanding rather than copying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;co2 &amp;gt; 0&lt;/code&gt; guard matters.&lt;/strong&gt; The SCD41 returns 0 ppm when a measurement isn't ready yet; sending it would draw a false floor on the chart. Skipping keeps the history honest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give it warm-up time.&lt;/strong&gt; The first readings after power-up settle as the sensor references itself — expect a couple of minutes before the number is trustworthy, and leave automatic self-calibration on so it stays that way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thirty seconds is plenty.&lt;/strong&gt; CO2 in a room moves over minutes, not seconds. Reporting twice a minute catches every meaningful change and keeps the traffic trivial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin TLS before you ship.&lt;/strong&gt; &lt;code&gt;Nodrix.begin()&lt;/code&gt; connects encrypted but unverified on first run; add &lt;code&gt;Nodrix.setCACert()&lt;/code&gt; for production, covered in [Connect an ESP32 over HTTPS (&lt;a href="https://nodrix.live/guides/esp32-https-cloud" rel="noopener noreferrer"&gt;https://nodrix.live/guides/esp32-https-cloud&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Build the dashboard
&lt;/h2&gt;

&lt;p&gt;Four widgets, each bound to a variable the firmware sends:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Widget&lt;/th&gt;
&lt;th&gt;Bind to&lt;/th&gt;
&lt;th&gt;Shows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gauge&lt;/td&gt;
&lt;td&gt;&lt;code&gt;co2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;live CO2 in ppm, with colour bands&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chart&lt;/td&gt;
&lt;td&gt;&lt;code&gt;co2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the day's air-quality rhythm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Value&lt;/td&gt;
&lt;td&gt;&lt;code&gt;temperature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;room temperature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Value&lt;/td&gt;
&lt;td&gt;&lt;code&gt;humidity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;relative humidity&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Set the CO2 gauge's bands to the levels that mean something: green below 800 ppm, amber 800–1200, red above 1200. Now the dashboard reads at a glance — you don't interpret a number, you see a colour. The chart is the quietly useful part: a room's CO2 has a shape, climbing while it's occupied and closed, dropping when it's ventilated or empty. Once you know your baseline, the anomalies —&lt;br&gt;
the meeting that ran long, the bedroom that never airs out — jump out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add the alert
&lt;/h2&gt;

&lt;p&gt;One automation: trigger on a new &lt;code&gt;co2&lt;/code&gt; reading, condition &lt;strong&gt;above 1200&lt;/strong&gt;, action: Telegram — "CO2 at {{value}} ppm — open a window." Add hysteresis so a room hovering at the line doesn't ping you repeatedly: alarm above 1200, and only rearm once it drops back below 900. Both are edited in the dashboard, not the firmware, and the channel swaps to Discord, Slack, or SMS without touching the condition — the full pattern is in [ESP32 notifications (&lt;a href="https://nodrix.live/guides/esp32-notifications" rel="noopener noreferrer"&gt;https://nodrix.live/guides/esp32-notifications&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Going further
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Make it portable.&lt;/strong&gt; On a battery with deep sleep, it's a monitor you carry room to room — swap the socket for wake-report-sleep over HTTP per &lt;a href="https://nodrix.live/guides/esp32-deep-sleep-battery" rel="noopener noreferrer"&gt;ESP32 battery life&lt;/a&gt;, and budget for the SCD41's warm-up on each wake or use its single-shot low-power mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add particulates.&lt;/strong&gt; A PMS5003 alongside the SCD41 adds PM2.5/PM10 — more variables, more widgets, same reporting loop, for a fuller indoor-air picture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch several rooms.&lt;/strong&gt; One SCD41-on-ESP32 per room, each reporting &lt;code&gt;co2_bedroom&lt;/code&gt;, &lt;code&gt;co2_office&lt;/code&gt;, and so on; a widget per room and one shared automation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automate the fix.&lt;/strong&gt; If a room has a fan or HRV you can switch, add a relay and a &lt;code&gt;NODRIX_WRITE&lt;/code&gt; handler and let high CO2 turn ventilation on — closed-loop, the pattern from the &lt;a href="https://nodrix.live/guides/esp32-smart-home-automation" rel="noopener noreferrer"&gt;smart-home build&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No broker, no local server.&lt;/strong&gt; The board speaks HTTPS; the dashboard is on your Cloudflare account. No Home Assistant, no MQTT, nothing on your LAN to keep alive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A sensor you can trust.&lt;/strong&gt; Real photoacoustic CO2 plus temperature and humidity from one part — no MOX drift, no analog calibration ritual.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scales by repeating.&lt;/strong&gt; One sketch runs a room or a building; add nodes, not complexity.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why the SCD41 instead of an MQ-135 or MH-Z19?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because it measures CO2 honestly. The MQ-135 is a cheap MOX gas sensor that doesn't report CO2 in real ppm — it drifts, needs constant recalibration, and conflates gases. The MH-Z19 is a genuine NDIR CO2 sensor and a reasonable older choice, but the Sensirion SCD41 is a current photoacoustic CO2 sensor that also gives you temperature and humidity from one I2C part, with ±(40 ppm + 5%) accuracy. For a monitor you'll trust enough to act on, the SCD41 is the right sensor in 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need Home Assistant or a local server for this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No — and that's the point. Most ranking SCD41 tutorials assume you already run Home Assistant with the ESPHome add-on, which means a local server humming 24/7 just to see a number. Here the ESP32 posts readings straight to your own cloud dashboard over HTTPS. Nothing local to run, and the dashboard opens from anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What CO2 levels should I actually worry about?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Outdoor air is around 420 ppm. Below 800 ppm indoors is comfortable and well-ventilated; 800-1200 ppm is where drowsiness and reduced concentration start; above 1200 ppm the room needs air, and sustained levels over 1500-2000 ppm are worth fixing. The guide bands the dashboard on those thresholds so a glance tells you whether to open a window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does the SCD41 need a couple of minutes to read correctly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It self-calibrates and the photoacoustic measurement settles after power-up — early readings can be off until it warms in. Give it a few minutes on first boot, and leave its automatic self-calibration enabled so it re-references to fresh air over days. If you run it somewhere that never sees outdoor-level CO2, disable ASC and calibrate manually instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can one board watch several rooms?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not one board, but one dashboard. Put an SCD41 on an ESP32 in each room, have each report co2, temperature, and humidity under its own variable names, and add a widget per room. The automations and alerting are shared — the pattern scales by repeating the node, not by rewiring anything.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nodrix.live/guides/esp32-air-quality-monitor" rel="noopener noreferrer"&gt;nodrix.live&lt;/a&gt;. nodrix is an open-source (MIT) IoT platform you deploy to your own Cloudflare account — &lt;a href="https://github.com/decoded-cipher/nodrix" rel="noopener noreferrer"&gt;source on GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>iot</category>
      <category>esp32</category>
      <category>arduino</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Control your ESP32 with Claude: an MCP server for your own hardware</title>
      <dc:creator>Arjun Krishna</dc:creator>
      <pubDate>Sat, 18 Jul 2026 08:54:37 +0000</pubDate>
      <link>https://dev.to/decoded_cipher/control-your-esp32-with-claude-an-mcp-server-for-your-own-hardware-4ahl</link>
      <guid>https://dev.to/decoded_cipher/control-your-esp32-with-claude-an-mcp-server-for-your-own-hardware-4ahl</guid>
      <description>&lt;p&gt;Ask Claude "is the greenhouse too warm?" and have it actually check — not because you pasted a number into the chat, but because it queried the sensor. Then ask it to turn on the fan, and hear the relay click. That's what an MCP server on your IoT backend makes possible, and nodrix ships one natively.&lt;/p&gt;

&lt;p&gt;This is a different pitch from the AI features bolted onto consumer smart-home apps. There's no vendor cloud in the middle, no pre-baked "skill," and no assistant that only works with devices someone else manufactured. Your ESP32, your firmware, your Cloudflare account — and an AI agent that can read and command all of it through an open protocol, exactly as far as you allow and no further.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MCP actually is, in one paragraph
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; is the open standard AI assistants use to call tools outside their own context. An MCP server publishes a list of tools; the assistant decides when to call them and acts on what comes back. Most MCP servers wrap a SaaS API or a database. When your IoT platform is the&lt;br&gt;
server, the tools are your hardware: list the projects, read a variable's live state, pull a time-series, set a control variable, create an automation. Claude stops being a place you describe your system and becomes something that can inspect and operate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The niche this fills
&lt;/h2&gt;

&lt;p&gt;It's worth being precise, because the smart-home-plus-AI space is not empty. Home Assistant has shipped an [official MCP server (&lt;a href="https://www.home-assistant.io/integrations/mcp_server/" rel="noopener noreferrer"&gt;https://www.home-assistant.io/integrations/mcp_server/&lt;/a&gt;) since early&lt;br&gt;
2025, and there are large, active community MCP projects for it. If your setup is off-the-shelf devices on a local hub, that ecosystem is mature and you should use it.&lt;/p&gt;

&lt;p&gt;What has essentially no coverage is the other half of the maker world: &lt;strong&gt;custom hardware you built, reporting to a cloud you own.&lt;/strong&gt; An ESP32 you flashed doesn't live in Home Assistant's device registry, and it doesn't want a local hub to reach it from anywhere. That project — a board on plain HTTPS, a dashboard on your Cloudflare account, and now an AI agent that can drive it — is the gap. nodrix fills it as a first-party feature rather than a bridge you assemble.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the server exposes
&lt;/h2&gt;

&lt;p&gt;Two tiers of tools, gated separately on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read tools&lt;/strong&gt; (available whenever the server is on):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;list_projects&lt;/code&gt;, &lt;code&gt;list_variables&lt;/code&gt; — discover what exists.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_state&lt;/code&gt; — the current value of a variable, as last reported by the device.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_series&lt;/code&gt; — the time-series history, for "what did CO2 do overnight."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;list_dashboards&lt;/code&gt;, &lt;code&gt;list_widgets&lt;/code&gt;, &lt;code&gt;list_automations&lt;/code&gt;, &lt;code&gt;list_integrations&lt;/code&gt; — the shape of your setup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Management tools&lt;/strong&gt; (behind a second switch):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;set_variable&lt;/code&gt; — write a control variable. This is the one that flips a relay: set the variable your device's &lt;code&gt;NODRIX_WRITE&lt;/code&gt; handler watches, and the board acts.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;create_automation&lt;/code&gt;, &lt;code&gt;update_automation&lt;/code&gt;, &lt;code&gt;run_automation&lt;/code&gt; — build and fire the trigger → condition → action logic.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;create_dashboard&lt;/code&gt;, &lt;code&gt;update_widget&lt;/code&gt;, &lt;code&gt;create_variable&lt;/code&gt;, &lt;code&gt;create_integration&lt;/code&gt;, and the matching &lt;code&gt;update_*&lt;/code&gt; tools — construct the rest of the setup in language.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are deliberately &lt;strong&gt;no delete tools&lt;/strong&gt;. An agent can build, read, and command; it cannot destroy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning it on safely
&lt;/h2&gt;

&lt;p&gt;The safety model is the reason this is a feature rather than a footgun. Everything is off until you decide otherwise:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The server is off by default.&lt;/strong&gt; Until the owner enables it, the MCP endpoint returns 404 — it doesn't exist as far as the internet is concerned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only the owner can enable it.&lt;/strong&gt; It lives in &lt;strong&gt;Settings → More&lt;/strong&gt;, behind the owner role. No member or admin can expose your hardware to an assistant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writes are a separate switch.&lt;/strong&gt; Turn the server on and it comes up &lt;strong&gt;read-only&lt;/strong&gt; — an assistant can see your data but cannot touch a single variable. The management tools stay dark until you flip the write flag too, so "let Claude look at my sensors" and "let Claude control my house" are two distinct, deliberate decisions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The practical result: an LLM can never command your hardware by default. You grant reading, then — if and when you want it — writing, as two separate acts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting Claude
&lt;/h2&gt;

&lt;p&gt;The server speaks standard MCP over two endpoints on your instance: an &lt;strong&gt;OAuth&lt;/strong&gt; endpoint at &lt;code&gt;/v1/mcp/oauth&lt;/code&gt; for interactive clients like the Claude apps, and a &lt;strong&gt;bearer-token&lt;/strong&gt; endpoint at&lt;br&gt;
&lt;code&gt;/v1/mcp&lt;/code&gt; for token-configured clients like Claude Code and IDE plugins.&lt;/p&gt;

&lt;p&gt;For the Claude apps, connect it as a custom connector — the whole path is five steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enable the server in nodrix first.&lt;/strong&gt; In your instance, go to &lt;strong&gt;Settings → More&lt;/strong&gt; and turn the MCP server on; until you do, the endpoint returns 404 and nothing can connect. Turn on the write flag here too if you want Claude to control hardware and not just read it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open Claude's connector settings.&lt;/strong&gt; In the browser, click your profile icon → &lt;strong&gt;Settings&lt;/strong&gt;; on desktop, press &lt;code&gt;⌘⇧,&lt;/code&gt; (macOS) or &lt;code&gt;Ctrl+,&lt;/code&gt;. Then click &lt;strong&gt;Connectors&lt;/strong&gt; in the sidebar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a custom connector.&lt;/strong&gt; Click &lt;strong&gt;Add&lt;/strong&gt; (top-right) → &lt;strong&gt;Add custom connector&lt;/strong&gt;, paste your instance's OAuth URL — &lt;code&gt;https://your-instance.workers.dev/v1/mcp/oauth&lt;/code&gt; — and click &lt;strong&gt;Add&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approve on your own instance.&lt;/strong&gt; Claude redirects you to your nodrix instance to sign in as the owner and approve the connection. That consent screen is where you see exactly what you're granting before anything is shared.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the tools.&lt;/strong&gt; Back in Claude, your instance appears under the &lt;strong&gt;+&lt;/strong&gt; ("Add files, connectors, and more") menu in the message box. From the connector's settings you can enable or disable individual tools — a second place the read-only boundary is yours to draw.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For &lt;strong&gt;Claude Code or an IDE plugin&lt;/strong&gt;, point it at the bearer endpoint &lt;code&gt;/v1/mcp&lt;/code&gt; instead, with an &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt; header carrying a token from your instance. If a menu has moved, Anthropic's &lt;a href="https://support.anthropic.com/en/articles/11175166-getting-started-with-custom-connectors-using-remote-mcp" rel="noopener noreferrer"&gt;custom-connectors guide&lt;/a&gt; has the canonical version of these steps.&lt;/p&gt;

&lt;p&gt;From there it's conversation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"What's the current soil moisture in the greenhouse project?" → &lt;code&gt;get_state&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;"Plot CO2 for the last 24 hours and tell me if it ever crossed 1000 ppm." → &lt;code&gt;get_series&lt;/code&gt;, then analysis.&lt;/li&gt;
&lt;li&gt;"Turn on the exhaust fan." → &lt;code&gt;set_variable&lt;/code&gt;, and the ESP32's handler fires. (Only if you enabled writes.)&lt;/li&gt;
&lt;li&gt;"Make an automation that alerts me on Telegram when the freezer goes above -10." → &lt;code&gt;create_automation&lt;/code&gt;, wired to the integration you already set up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last two are the ones that feel like the future: you described an outcome, and the agent assembled the platform primitives to make it real — no dashboard clicks, no YAML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this beats the bolt-on approach
&lt;/h2&gt;

&lt;p&gt;You could get some of this by giving Claude a generic HTTP tool and your API docs. The native server is better in the ways that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's typed and discoverable.&lt;/strong&gt; The assistant sees named tools with schemas, not a REST surface it has to reverse-engineer, so it calls them correctly the first time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's scoped.&lt;/strong&gt; Read-only really means read-only; the boundary is enforced server-side, not requested politely in a prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's yours.&lt;/strong&gt; The whole path is your instance on your Cloudflare account. No third-party AI-IoT service is brokering access to your devices, and nothing about it can be discontinued on you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it's going
&lt;/h2&gt;

&lt;p&gt;The interesting frontier isn't voice-controlling one bulb — Home Assistant does that well. It's an agent with read access to a fleet of your own sensors and the judgment to reason across them: correlate the energy monitor's spike with the temperature log, notice the greenhouse trend before it becomes a problem, draft the automation and let you approve it. The tools to do that ship in the box&lt;br&gt;
today; the assistant supplies the reasoning. Turn the server on read-only, connect Claude, and ask it what it notices about your data — it's the fastest way to see why this is more than a novelty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Off by default, owner-gated, writes separate.&lt;/strong&gt; Three deliberate gates before an AI touches hardware; no delete tools ever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own endpoint.&lt;/strong&gt; MCP served from your Cloudflare account — no AI-IoT vendor in the path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open protocol.&lt;/strong&gt; Standard MCP; Claude is the reference client, but any MCP-capable assistant connects the same way.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is an MCP server, and why would my IoT platform have one?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;MCP (Model Context Protocol) is the open standard AI assistants like Claude use to call external tools. An MCP server exposes a set of tools an agent can invoke — and when your IoT backend is the server, those tools are your devices: read this sensor, set that variable, create an automation. It's the difference between pasting sensor readings into a chat and letting the assistant query and control the hardware directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to let an AI control my hardware?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;nodrix is built so the answer stays yes. The MCP server is off by default and only the instance owner can enable it. Even then it comes up read-only: the management tools that can write a variable or create an automation are behind a second switch, so an assistant can look at your data without any ability to command hardware until you explicitly turn writes on. There are no delete tools at all. You're granting capabilities deliberately, one gate at a time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How is this different from the Home Assistant MCP server?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Home Assistant's MCP server is built for Home Assistant's world — off-the-shelf smart-home devices on your local network, exposed through its Assist API. nodrix's is built for the other world: your own custom hardware — an ESP32 you flashed — reporting to your own cloud over plain HTTPS. If your project is a store-bought bulb, use Home Assistant. If it's a board you wrote the firmware for, this is the path that doesn't require running a local hub.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which AI assistants can connect?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Anything that speaks MCP. Claude (Desktop, Code, and the web connector) is the reference client, and the server also works with other MCP-capable tools. nodrix exposes both a bearer-token endpoint for programmatic clients and an OAuth endpoint for the ones that authenticate interactively, so you connect whichever way your client expects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Claude need my hardware to be online to answer questions?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For live state, yes — reading a sensor's current value asks your instance, which holds what the device last reported. But the history lives in your instance regardless, so Claude can analyze a week of temperature data whether or not the board is awake right now. A deep-sleeping battery sensor's last reading is always queryable.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nodrix.live/guides/control-esp32-with-claude-mcp" rel="noopener noreferrer"&gt;nodrix.live&lt;/a&gt;. nodrix is an open-source (MIT) IoT platform you deploy to your own Cloudflare account — &lt;a href="https://github.com/decoded-cipher/nodrix" rel="noopener noreferrer"&gt;source on GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>iot</category>
      <category>esp32</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
