Overview
The meter is a single STM32F103 running bare-metal C. It owns the whole measurement chain: drive the two ADCs that read mains voltage and load current, turn those streams into calibrated RMS and power values, accumulate energy, and serve a host controller over UART. It also switches the load through a relay.
The firmware is deliberately layered so nothing time-critical is done in software that the hardware can do on its own:
- Acquisition. ADC1 and ADC2 convert in dual regular simultaneous mode. A DMA channel streams the paired results into a circular buffer and interrupts on half and full transfer. The CPU never copies a sample.
- Measurement. Each finished half-buffer is reduced to voltage, current, active and apparent power, power factor, and energy in a fixed number of integer operations.
- Communication. A DMA-driven UART receives variable-length frames using idle-line detection and transmits responses without blocking.
mains ─► sensor front-end ─► ADC1 + ADC2 (simultaneous)
│
DMA1 Ch1 (circular, 32-bit)
│ half / transfer IRQ
▼
adc_process_samples()
│
Measurements_t
│
ESP32 ◄── USART1 packet ◄── uart command loopMeasurement Front-End
Voltage and current are sensed with two off-the-shelf modules, each built around a small transformer and an operational amplifier. As shipped they target 5 V logic: the op-amp output is biased at half the supply (2.5 V) and the gain is set so a full-scale primary signal swings within roughly 0–5 V.
Voltage — ZMPT101C
A voltage transformer scales the mains down to a low-voltage replica. The op-amp buffers and level-shifts it so the output is a small AC signal centred on a DC bias rather than a bipolar signal the ADC cannot read.
Current — ZMCT103C
A current transformer senses the load current. A burden resistor converts the secondary current to a voltage, and an op-amp provides the same biased, AC-coupled output referenced to the mid-rail.
Adapting the modules to a 3.3 V ADC
The STM32's ADC must stay between 0 V and 3.3 V and reads around a 1.65 V mid-rail, so the 5 V modules are modified:
- Re-bias to 1.65 V. The bias divider that sets the op-amp's DC operating point is moved to half of the ADC reference, so each channel idles at about 2047 of 4095 counts instead of sitting near mid-supply of a 5 V scale.
- Reduce the gain. The input/feedback resistance is increased to lower the op-amp gain, so a full-scale primary signal still fits under 3.3 V with headroom instead of clipping against the ADC's top rail.
The result is a symmetric signal around the mid-rail that uses the ADC's range without hitting either limit.
Signal Chain
┌── ZMPT101C ──► biased AC ──► PA0 (ADC1_IN0)
230 V AC ───┤
└── CT ──► ZMCT103C ──► burden + bias ──► PA1 (ADC2_IN1)ADC1 and ADC2 convert their channels at the same instant, so the voltage and current words in a given buffer slot belong together. That pairing is what makes the real-power calculation valid.
Hardware
Microcontroller
| Item | Detail |
|---|---|
| MCU | STM32F103C8T6 — Cortex-M3, 72 MHz, 64 KB flash, 20 KB SRAM |
| Also builds for | STM32F103CBT6 (128 KB flash), same STM32F103xB headers |
| Clock source | 8 MHz HSE crystal, PLL ×9 |
| Linker script | ldscripts/stm32f103c8.ld |
| Startup | startup/startup_stm32f103xb.s |
Pin Map
| Pin | Function | Notes |
|---|---|---|
| PA0 | ADC1_IN0 | AC voltage, ZMPT101C |
| PA1 | ADC2_IN1 | AC current, ZMCT103C |
| PA9 | USART1_TX | Alternate function push-pull → host RX |
| PA10 | USART1_RX | Floating input ← host TX |
| PC13 | GPIO output | Status LED, driven when frames are dropped |
| load relay | GPIO output | Switches the live line to the load |
Clocking and Timing
SystemClock_Config_72MHz() brings the part up from the external crystal:
| Domain | Value |
|---|---|
| HSE | 8 MHz |
| PLL | HSE × 9 |
| SYSCLK / AHB | 72 MHz |
| APB2 (PCLK2) | 72 MHz |
| APB1 (PCLK1) | 36 MHz |
| Flash latency | 2 wait states, prefetch on |
The ADC prescaler is set to PCLK2 / 6, giving a 12 MHz ADC clock. With a sample time of 71.5 cycles and a 12.5-cycle conversion, a conversion takes 84 ADC cycles:
sample rate = 12,000,000 / 84 = 142,857 samples/second
one 50 Hz cycle = 142,857 × 0.02 = 2,857 samples → ADC_BUF_LEN = 2,858 (even)The buffer holds one full mains cycle, and ADC_HALF_LEN (1,429 samples, about 10 ms) is the unit the processor works on.
Acquisition Pipeline
/* Dual regular simultaneous mode: ADC1 is master, ADC2 the slave */
ADC1->CR1 |= (0x6U << 16);
/* One channel each, no scan */
ADC1->SQR3 = 0; /* ADC1_IN0 — voltage */
ADC2->SQR3 = 1; /* ADC2_IN1 — current */
/* DMA: 32-bit transfers, circular, half + transfer + error interrupts */
DMA1_Channel1->CPAR = (uint32_t)&ADC1->DR;
DMA1_Channel1->CMAR = (uint32_t)adc_buf;
DMA1_Channel1->CNDTR = ADC_BUF_LEN;- Data packing. In dual mode the master's data register carries both results: bits
[15:0]are the ADC1 sample (voltage) and bits[31:16]are the ADC2 sample (current). The DMA moves the whole 32-bit word, so each buffer entry is a voltage/current pair. - Ping-pong processing. The half-transfer interrupt processes the first half of the buffer while the DMA fills the second, and the transfer-complete interrupt does the reverse. The processor always reads a finished half while the hardware writes the other.
- Priority. The DMA channel interrupts the CPU at priority 3 — the highest in the system — so a completed half-buffer is never delayed behind UART work.
void DMA1_Channel1_IRQHandler(void)
{
if (DMA1->ISR & DMA_ISR_HTIF1) {
DMA1->IFCR = DMA_IFCR_CHTIF1;
adc_process_samples(&adc_buf[0], ADC_HALF_LEN);
}
if (DMA1->ISR & DMA_ISR_TCIF1) {
DMA1->IFCR = DMA_IFCR_CTCIF1;
adc_process_samples(&adc_buf[ADC_HALF_LEN], ADC_HALF_LEN);
}
}Measurement Maths
adc_process_samples() runs on each half-buffer and updates a shared Measurements_t. Every reduction runs over the same block of samples and uses 64-bit accumulators so the squares and products cannot overflow 32 bits.
typedef struct {
double v_rms;
double i_rms;
double active_power;
double apparent_power;
double accumulated_energy;
double power_factor;
} Measurements_t;The three passes over the block are:
- Offset removal. Average the raw counts of each channel over the block. That running average is the DC bias (nominally 1.65 V) plus any slow drift, and it is subtracted from every sample before the squares are taken.
- Accumulate. For each sample, add
v²,i², and the instantaneous productv·i. - Reduce.
v_rms = sqrt(mean(v²)) · V_CAL
i_rms = sqrt(mean(i²)) · I_CAL
active_power = mean(v·i) · V_CAL · I_CAL
apparent_power = v_rms · i_rms
power_factor = active_power / apparent_power (0 when apparent is 0)
energy += active_power · (len / 142857) [watt-seconds]Averaging over a whole number of half-cycles is what makes sqrt(mean(v²)) the true RMS of a sine and mean(v·i) the real power rather than a pair of unrelated averages.
V_CAL and I_CAL (200.0 and 125.0) fold together the transformer ratio, the burden resistor, and the reduced op-amp gain to map raw RMS counts to volts and amps. They are first-pass figures and are being refined against a reference meter. rms.c keeps an earlier floating-point compute_rms(buf, len, vref, offset) helper for reference; the live path is the integer reduction above.
UART Command Interface
The host link is USART1 at 115200 8N1. At 72 MHz the baud divisor is SystemCoreClock / baudrate = 625 (0x271), which is also the correct 16× oversampling encoding for the 39.0625 divider.
Transmit
uart_send_dma() moves a buffer out through DMA1 Channel 4. A tx_busy flag allows only one transfer at a time. The flag is cleared by the USART transfer-complete interrupt — after the last byte has actually left the shift register — not by the DMA's own completion, which only means the last byte was loaded.
Receive
Receive uses DMA1 Channel 5 into one of two 256-byte buffers. There is no fixed frame length: when the line goes idle the USART1 interrupt reads the number of bytes the DMA has written from CNDTR, publishes that buffer to the application, then restarts the DMA on the other buffer. If the application has not yet consumed the previous frame, the new one is discarded and counted.
typedef struct {
const uint8_t *data; /* valid until uart_rx_release() */
size_t len;
} uart_frame_t;Error accounting
The UART layer counts overrun, framing, and noise errors per byte, plus dropped frames and DMA transfer errors, and exposes them through uart_get_stats():
typedef struct {
uint32_t frames_dropped;
uint32_t overrun_errors;
uint32_t framing_errors;
uint32_t noise_errors;
uint32_t dma_errors;
} uart_stats_t;main() reads these every loop and lights PC13 while any frame has been dropped, so a marginal link is visible on the board rather than silent.
Interrupt priorities
| Interrupt | Priority | Purpose |
|---|---|---|
DMA1_Channel1 | 3 | ADC half/transfer complete |
USART1 | 5 | Idle line, transfer complete, errors |
DMA1_Channel5 | 6 | RX DMA transfer error |
DMA1_Channel4 | 7 | TX DMA transfer complete/error |
Commands and packet
main() polls uart_rx_get_frame(). A single 'p' byte requests the latest reading; when the transmitter is free the meter sends a response. The response payload is built by prepare_energy_packet():
typedef struct __attribute__((__packed__)) {
uint8_t start_byte; /* 0xAA */
uint16_t voltage; /* centivolts */
uint16_t current; /* centiamps */
uint16_t power; /* active, watts */
uint32_t energy; /* watt-seconds */
uint8_t checksum; /* byte-wise sum of the fields above */
} energy_packet_t;The packet is 12 bytes and is little-endian on the Cortex-M. In the current build the request is answered with a placeholder string while the packet send is switched over; the packet itself and its checksum are implemented.
Relay and Load Control
A relay sits between the mains and the load. It is driven from a GPIO and controlled by the host: the ESP32 can open or close the load on command over the same UART, so the grow controller can disconnect a fixture on a threshold or schedule without a second board. The relay command shares the single-byte command channel used for readings.
Firmware Structure
| File | Responsibility |
|---|---|
src/main.c | Bring-up, command loop, packet assembly |
src/adc_dual_dma.c/.h | Dual ADC + DMA setup, IRQ, sample processing, Measurements_t |
src/uart_dma.c/.h | DMA UART, framed receive, statistics |
src/system_clock_cfg.c/.h | 72 MHz clock tree from HSE |
src/blink.c/.h | PC13 status LED |
src/rms.c/.h | Floating-point RMS reference helper |
Build and Flash
The project uses CMake with the arm-none-eabi toolchain and CMSIS headers from STM32CubeF1 (stm32f103xb.h). Compiler flags are -mcpu=cortex-m3 -mthumb -msoft-float -O2 -ffunction-sections -fdata-sections -g -Wall, linked with --gc-sections.
rm -rf build
cmake -S . -B build
cmake --build buildFlashing over the serial bootloader and debugging over SWD:
stm32flash -w build/acMeter.hex -v -g 0x0 /dev/ttyUSB0The VS Code cortex-debug configuration attaches OpenOCD over SWD (interface/stlink.cfg, target/stm32f1x.cfg) for step-through debugging.
Memory Footprint
| Section | Size | Notes |
|---|---|---|
.text | 2,396 B | Code and constants |
.data | 12 B | Initialised data |
.bss | 13,548 B | ADC buffer (2,858 × 4 B) dominates; RX double buffer (2 × 256 B) |
| Total | ~15.6 KB | Fits comfortably in 64 KB flash / 20 KB SRAM |
Design Notes
- Hardware does the heavy lifting. ADC conversions and both DMA streams run without CPU involvement; a half-buffer interrupt is the only thing that pulls the core away from its idle loop.
- Bounded work per interrupt. A half-buffer is a fixed 1,429 samples, so the processing time is constant and comfortably inside the 10 ms window before the next one.
- Idle-line framing. Variable-length commands are delimited by the UART hardware rather than a timer or a length prefix, which keeps short single-byte commands cheap.
- Truly complete transmits. Tying
tx_busyto the USART transfer-complete flag avoids the classic bug where the DMA reports done while the final byte is still shifting out. - Failures are visible. Every error class has a counter, and dropped frames drive an LED.
Future Improvements
- Calibrate
V_CAL/I_CALagainst a reference meter and store per-unit constants in flash - Wire
prepare_energy_packet()onto the'p'response path in place of the placeholder - Extend the command set: relay control, zeroing of the energy accumulator, statistics readout
- Add phase detection so power factor and reactive power are explicit
- Track the actual mains frequency per cycle instead of assuming 50 Hz
- Package readings for the grow controller's telemetry path (UART to ESP32, then MQTT)