Sensors and Actuators
How devices sense and act on the physical world: analog and digital sensors, analog-to-digital conversion, resolution, noise and calibration, and actuators such as relays, motors and LEDs.
Sensing and acting
An IoT device is only useful because it touches the physical world. A sensor turns a physical quantity — temperature, light, current, pressure, movement — into an electrical signal; an actuator turns an electrical signal back into something physical: a switched heater, a turning motor, a lit LED. The OT topic introduces both in an industrial setting; this page looks at what happens on the small board between them.
Sensors come in two kinds:
- Analog sensors produce a voltage (or a current, or a resistance) that varies with what they measure: a thermistor, a light-dependent resistor, a potentiometer, a current clamp. The microcontroller must measure that voltage, which is what the rest of this page is mostly about.
- Digital sensors contain their own converter and send finished numbers over a bus such as I2C, SPI or a serial line: most modern temperature, humidity, pressure and motion sensors (see Bus Protocols: UART, I2C and SPI). They are easier to use and often more accurate, but you depend on their datasheet for what the numbers mean.
A smart electricity meter is itself a sensor, and an important one: it measures energy, and every invoice Kestrel Energy sends depends on its accuracy. Lotte Kortenhoeve, the customer whose home runs through this topic, reads her meter through its P1 port — a digital interface, so no analog conversion is needed (see Smart Meters and the P1 Port). For the cabinet that will hold her home battery she adds an analog temperature sensor, and that is where the questions on this page begin.
Analog-to-digital conversion
An analog-to-digital converter (ADC) is a circuit that measures a voltage and expresses it as a whole number. Most microcontrollers have one built in: the ESP32 has a 12-bit ADC with up to 18 input channels, the Raspberry Pi Pico has a 12-bit ADC, and the Arduino Uno R4 can read up to 14 bits. The Raspberry Pi single-board computer has none, which is why analog sensors on a Pi need an external ADC chip.
Resolution
The resolution of an ADC is the number of bits in its result, and therefore the number of distinct steps it can tell apart: an N-bit ADC has 2N steps across its input range. The size of one step is the full-scale range divided by the number of steps; it is called one LSB, after the least significant bit that changes when the input rises by one step.
The example computes the step size for common resolutions at a 3.3 V full-scale range, and converts a raw reading back to a voltage:
# ADC resolution: the size of one step (LSB) at a 3.3 V full-scale range.
FULL_SCALE_V = 3.3
for bits in (10, 12, 14, 16):
steps = 2 ** bits
lsb_mv = FULL_SCALE_V / steps * 1000
print(f"{bits:2}-bit: {steps:6,} steps, 1 LSB = {lsb_mv:.3f} mV, "
f"half a step = {lsb_mv / 2:.3f} mV")
# A raw reading of 2048 from a 12-bit ADC:
raw = 2048
volts = raw * FULL_SCALE_V / 2 ** 12
print(f"raw {raw} of 12 bits = {volts:.3f} V")
# Resolution is not accuracy: the original ESP32's datasheet allows an
# integral nonlinearity of up to 12 LSB in either direction.
print(f"ESP32 INL +/-12 LSB = +/-{12 * FULL_SCALE_V / 4096 * 1000:.1f} mV")
10-bit: 1,024 steps, 1 LSB = 3.223 mV, half a step = 1.611 mV 12-bit: 4,096 steps, 1 LSB = 0.806 mV, half a step = 0.403 mV 14-bit: 16,384 steps, 1 LSB = 0.201 mV, half a step = 0.101 mV 16-bit: 65,536 steps, 1 LSB = 0.050 mV, half a step = 0.025 mV raw 2048 of 12 bits = 1.650 V ESP32 INL +/-12 LSB = +/-9.7 mV
Quantization error
Every value between two steps is reported as one of them, so some information is always lost. The difference between the real input and the value the code stands for is the quantization error. It is at most half a step for a converter that rounds to the nearest step, and up to one full step for one that truncates, as in the figure. A 12-bit ADC at 3.3 V therefore cannot tell 1.6500 V from 1.6503 V, no matter how good the rest of the circuit is. Adding bits makes the steps smaller; it does not make the converter more accurate, as the next sections show.
Converting in firmware
Firmware on small microcontrollers often avoids floating-point arithmetic, which is slow or missing on chips without a floating-point unit. The usual trick is to work in millivolts with integers, and to multiply before dividing:
#include <stdint.h>
#include <stdio.h>
/* Firmware style: convert a 12-bit ADC reading to millivolts with
integers only (no floating point), for a 3300 mV full-scale range. */
static uint32_t adc_to_mv(uint16_t raw)
{
return (uint32_t)raw * 3300u / 4096u; /* multiply first, then divide */
}
int main(void)
{
const uint16_t readings[] = {0, 1, 5, 2048, 3103, 4095};
for (size_t i = 0; i < sizeof readings / sizeof readings[0]; i++) {
printf("raw %4u -> %4u mV\n", (unsigned)readings[i],
(unsigned)adc_to_mv(readings[i]));
}
/* The wrong order: 3300 / 4096 is 0 in integer arithmetic. */
printf("divide first: raw 2048 -> %u mV\n", (unsigned)(2048u * (3300u / 4096u)));
return 0;
}
raw 0 -> 0 mV raw 1 -> 0 mV raw 5 -> 4 mV raw 2048 -> 1650 mV raw 3103 -> 2499 mV raw 4095 -> 3299 mV divide first: raw 2048 -> 0 mV
Three things to notice. Integer division truncates, so 3103 gives
2499 mV where the exact value is 2499.98 mV. The highest code, 4095,
means 3299 mV, not 3300: the top step starts one LSB below full
scale. And dividing first turns the scale factor into zero — a bug
that compiles without a warning. The cast to
uint32_t matters too: 4095 × 3300 is 13,513,500, far
more than fits in 16 bits, and on chips where an int is
only 16 bits wide, such as the AVR in an Arduino Uno R3, the
multiplication would overflow without it.
Resolution is not accuracy
The number of bits says how finely an ADC divides its range, not how close the result is to the truth. Offsets, gain errors, nonlinearity, noise and an imprecise reference voltage all come on top. The original ESP32 is a well-known example:
- Its datasheet allows an integral nonlinearity of up to ±12 LSB: a "12-bit" reading can be off by about ±9.7 mV, the size of twelve steps (last line of the example above).
- Accuracy gets worse near the ends of the range, especially above about 2,450 mV in the setting for the widest input range.
- The chip has two ADCs, and ADC2 cannot be used while Wi-Fi is running. Connect analog sensors to ADC1 pins (GPIO 32 to 39 on the original ESP32).
MicroPython hides some of this, and adds a trap of its own. Its
read_u16() always returns a number from 0 to 65535,
whatever the hardware: on an ESP32 the 12-bit result is scaled up,
so the extra bits carry no extra information.
read_uv() returns microvolts, using the chip's factory
calibration where available:
from machine import ADC, Pin
adc = ADC(Pin(34)) # GPIO 34 is on ADC1, which works while Wi-Fi is on
raw = adc.read_u16() # 0-65535, scaled up from the 12-bit converter
microvolts = adc.read_uv() # calibrated value in microvolts
When accuracy really matters — measuring a battery's voltage to decide when to stop discharging it, for example — use a dedicated external ADC with a precise voltage reference, or a digital sensor that does the conversion itself.
Sampling
An ADC measures at moments, not continuously. The sampling rate is how many measurements per second a system takes, in hertz or samples per second. It depends on what you measure: a room temperature changes over minutes, so one sample every 10 seconds is plenty; the current in a mains cable changes direction 100 times per second, so a power meter samples thousands of times per second. A higher rate costs energy, processing and storage, which is why battery devices sample as rarely as they can.
Nyquist and aliasing
Sampling too slowly does not just lose detail; it produces wrong data that looks right. The Nyquist rate is twice the highest frequency present in a signal: only when you sample faster than that can the samples represent the signal faithfully. Sample more slowly and a fast signal shows up as a slower one that is not really there, an effect called aliasing. The example samples two sine waves ten times per second — one at 1 Hz and one at 11 Hz:
import math
SAMPLE_RATE = 10 # samples per second
def sample(freq_hz, n):
"""Value of a sine wave of freq_hz at sample number n."""
value = math.sin(2 * math.pi * freq_hz * n / SAMPLE_RATE)
return round(value, 3) + 0.0 # + 0.0 turns -0.0 into 0.0
print(" n 11 Hz signal 1 Hz signal")
for n in range(6):
print(f"{n:2} {sample(11, n):+.3f} {sample(1, n):+.3f}")
n 11 Hz signal 1 Hz signal 0 +0.000 +0.000 1 +0.588 +0.588 2 +0.951 +0.951 3 +0.951 +0.951 4 +0.588 +0.588 5 +0.000 +0.000
The samples are identical: at 10 samples per second, an 11 Hz signal is indistinguishable from a 1 Hz one. No software can undo this afterward. The cure is in hardware and design: sample fast enough, and filter out frequencies above half the sampling rate before the ADC (an anti-aliasing filter, often just a resistor and a capacitor).
Noise and filtering
Every analog reading wobbles. Electrical noise from a Wi-Fi radio, a switching power supply or long sensor wires adds a few steps of jitter, and the occasional spike when something switches nearby. Two simple filters cover most needs: a moving average over the last few readings smooths jitter, and a moving median removes single spikes. The example shows the difference on Lotte's battery-cabinet temperature, with one bad reading of 35.0 °C among values around 21.5 °C:
from statistics import mean, median
# Battery-cabinet temperature (degrees C), one reading per second.
# The 35.0 is a single bad reading caused by electrical noise.
readings = [21.4, 21.5, 21.4, 35.0, 21.6, 21.5, 21.7, 21.6, 21.6, 21.7]
WINDOW = 5
print("raw mean5 median5")
for i in range(WINDOW - 1, len(readings)):
window = readings[i - WINDOW + 1:i + 1]
print(f"{readings[i]:4.1f} {mean(window):5.2f} {median(window):5.1f}")
raw mean5 median5 21.6 24.18 21.5 21.5 24.20 21.5 21.7 24.24 21.6 21.6 24.28 21.6 21.6 21.60 21.6 21.7 21.62 21.6
The spike pulls the average up by almost 3 °C for five seconds — long enough to switch on a cooling fan for nothing — while the median ignores it completely. The price of any filter is delay: a real change shows up only after a few readings. Choose the window to match how fast the quantity can really change. The statistics behind both are on Statistics for IT, and catching impossible values (a jump of 13.5 °C in one second) with range and rate-of-change checks is shown on Asset Inventory and Monitoring.
Calibration
Even a noise-free sensor is rarely exact: it may read a little high (an offset) and its scale may be a little steep or flat (a gain error). Sensor calibration is the process of comparing a sensor's readings with known reference values and deriving a correction that is applied to every later reading. The simplest form is a two-point calibration: measure at two known points, fit a straight line through them, and use that line to correct everything in between.
# Two-point calibration of a temperature sensor (degrees C).
# Reference points: melting ice (0.0) and a calibrated bath (60.0).
# The sensor read 0.8 and 59.0 at those points.
true_low, read_low = 0.0, 0.8
true_high, read_high = 60.0, 59.0
gain = (true_high - true_low) / (read_high - read_low)
offset = true_low - gain * read_low
print(f"gain {gain:.4f}, offset {offset:+.3f}")
def calibrate(raw):
return gain * raw + offset
for raw in (0.8, 21.3, 59.0):
print(f"sensor {raw:5.1f} -> corrected {calibrate(raw):5.2f}")
gain 1.0309, offset -0.825 sensor 0.8 -> corrected 0.00 sensor 21.3 -> corrected 21.13 sensor 59.0 -> corrected 60.00
The correction is only as good as the reference points: choose them to span the range you care about, and remember that it assumes the sensor is linear in between. Store the two numbers per device, not in the firmware for all devices, because every sensor has its own errors. Sensors also drift with age and temperature, so devices whose readings matter — a smart meter above all — are calibrated and certified at the factory and checked against legal accuracy classes, not by their owners.
Actuators
A microcontroller pin is a signal, not a power source: it can deliver only a small current at 3.3 V (check the datasheet), enough for an LED and not much else. Anything larger needs a component that uses that small signal to switch a bigger current:
- LEDs can be driven directly through a resistor; their brightness is controlled with pulse-width modulation (see GPIO, PWM and Interrupts).
- Transistors, usually a MOSFET, switch DC loads such as a 12 V fan, an LED strip or a small pump. They switch silently, fast enough for PWM, and without wear.
- Motors need a motor driver that handles their current and direction; a hobby servo takes a pulse whose width sets its angle. Coils in motors and relays produce a voltage spike when switched off, so drivers include a protection diode.
- Relays switch circuits that must stay electrically separate from the microcontroller.
Relays
A relay is an electrically operated switch: a small current through a coil moves a set of contacts that open or close a completely separate circuit. Because the two circuits are only coupled magnetically, a relay keeps a high-power or high-voltage circuit isolated from the electronics that control it. Relays click, wear out after a number of switching cycles, and need a transistor to drive their coil. A solid-state relay does the same job with semiconductors: silent and without moving parts, but it gets warm and can fail in the closed position.
Lotte's battery cabinet uses one for a low-voltage job: when the filtered, calibrated temperature rises above 30 °C, her ESP32 switches a 12 V cabinet fan through a relay module, and Home Assistant logs it. The battery itself is controlled through its own interface, not by switching its power.
Common mistakes
- Sampling as fast as possible. It wastes energy and storage; sample just fast enough for the quantity, and above its Nyquist rate.
- Averaging away spikes. A mean spreads a spike over the whole window; use a median, or reject impossible values first.
- One calibration for all devices. Offsets differ per sensor; store calibration values per device.
- Driving loads straight from a pin. A motor or a relay coil on a GPIO pin can damage the microcontroller; use a transistor or a driver.