The important distinction: Arduino is C++
People often say “Arduino C”, but the familiar Arduino sketch language is C++ with an Arduino framework. ESP32 development has two common paths:
| Path | Main language | Best for |
|---|---|---|
| Arduino core for ESP32 | C++ | Fast prototypes, broad beginner library support |
| ESP-IDF | C and C++ | Production ESP32 features, FreeRTOS, lower-level control |
C is a smaller, procedural language. C++ adds classes, stronger abstraction tools, and the libraries used by Arduino. You do not need to master every C++ feature to build excellent devices; learn the safe embedded subset first.
The build-to-hardware loop
Source (.ino/.cpp/.c) → compile → link libraries → flash firmware → device boots → serial logs prove behaviour
A compiler catches type and syntax errors before the device runs. A flasher writes the resulting firmware to the ESP32. A serial monitor is your first debugging console.
Minimal Arduino sketch
constexpr int LED_PIN = 2;
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
}
void loop() {
digitalWrite(LED_PIN, HIGH);
delay(500);
digitalWrite(LED_PIN, LOW);
delay(500);
}
setup() runs once after boot. loop() runs forever. constexpr means the pin number is fixed at compile time and makes intent clearer than a magic 2 scattered through the code.
Variables, types, and constants
bool doorOpen = false;
int retryCount = 0;
uint32_t lastReportMs = 0;
float temperatureC = 21.5f;
const char* wifiName = "not-a-secret-here";
| Type | Use | Caution |
|---|---|---|
bool | true/false state | Prefer for flags |
int | normal whole numbers | Size varies by platform |
uint8_t, uint32_t | precise unsigned sizes | Excellent for bytes/timers |
float | decimal sensor values | Avoid for money; compare carefully |
char[] / String | text | Understand allocation/memory trade-offs |
For durations from millis(), use uint32_t / unsigned long; wraparound-safe subtraction is an established pattern.
Functions make hardware code survivable
bool readDoorOpen() {
return digitalRead(DOOR_REED_PIN) == LOW;
}
void reportDoorState(bool open) {
Serial.printf("Door is %s\n", open ? "open" : "closed");
}
Name functions after what they do, not how they happen to be implemented. Keep I/O, decision logic, and output/reporting separate so you can test and change them independently.
Avoid delay() in real device logic
delay() blocks the entire loop. While it waits, your device cannot read buttons, respond to MQTT, or enforce a safety condition. Use millis() for non-blocking timing:
constexpr uint32_t REPORT_EVERY_MS = 60'000;
uint32_t lastReportMs = 0;
void loop() {
const uint32_t now = millis();
if (now - lastReportMs >= REPORT_EVERY_MS) {
lastReportMs = now;
publishStatus();
}
handleInputs();
handleNetwork();
}
This continues to work when the timer counter wraps around.
State machines: the key to reliable automations
A state machine stores the current stage and permits only sensible transitions.
enum class DoorState { Closed, Opening, Open, Closing, Fault };
DoorState state = DoorState::Closed;
For a coop door, define transitions explicitly:
Closed → Opening when scheduled open command arrives
Opening → Open when full-open reed switch confirms
Opening → Fault if timeout expires
Fault → Closed only after a deliberate recovery action
This is safer than a pile of unrelated booleans such as isMoving, openRequested, closed, and hasError that can contradict each other.
Reading hardware safely
pinMode(DOOR_REED_PIN, INPUT_PULLUP);
const bool rawOpen = digitalRead(DOOR_REED_PIN) == LOW;
INPUT_PULLUP prevents an unconnected input from floating randomly. Many reed switches are wired active-low; document this clearly.
Debounce switches
Mechanical switches can flicker rapidly. Treat a state as valid only after it has remained stable briefly:
bool stableState = false;
bool lastRawState = false;
uint32_t lastChangeMs = 0;
constexpr uint32_t DEBOUNCE_MS = 50;
void updateSwitch() {
bool raw = digitalRead(SWITCH_PIN) == LOW;
if (raw != lastRawState) {
lastRawState = raw;
lastChangeMs = millis();
}
if (millis() - lastChangeMs >= DEBOUNCE_MS) stableState = raw;
}
C++ classes: use them for cohesive hardware components
class ReedSwitch {
public:
explicit ReedSwitch(uint8_t pin) : pin_(pin) {}
void begin() const { pinMode(pin_, INPUT_PULLUP); }
bool active() const { return digitalRead(pin_) == LOW; }
private:
uint8_t pin_;
};
A class is useful when data and the operations on that data belong together. Do not create classes merely because C++ can; small functions and structs are often clearer.
ESP32-specific realities
- The ESP32 runs FreeRTOS underneath ESP-IDF; tasks can be useful, but shared state introduces race conditions. Start single-loop/simple.
- Wi-Fi and network calls fail. Design reconnect and backoff behaviour, not one optimistic
connect(). - Brownouts and resets happen. Store only necessary persistent settings; make boot safe and idempotent.
- Pins have restrictions. Check your specific board pinout before assigning bootstrapping, input-only, or flash-related pins.
- Log the reset reason and firmware version at boot.
Memory: the embedded difference
Desktop programs can use gigabytes; microcontrollers cannot. Be intentional:
| Memory area | Meaning | Practice |
|---|---|---|
| Stack | Function-local values | Avoid huge local arrays/deep recursion |
| Heap | Dynamically allocated memory | Avoid allocation churn in loop() |
| Flash | Program/constants | Put fixed strings/constants here when appropriate |
| NVS | Persistent ESP32 key/value storage | Store configuration, not high-frequency logs |
Prefer fixed-size buffers and snprintf for performance-critical code. Arduino String is convenient for small projects but repeated concatenation on long-running constrained devices can fragment memory; use it deliberately, not fearfully.
Serial debugging checklist
Serial.begin(115200);
Serial.printf("Boot; firmware=%s; free heap=%u\n", VERSION, ESP.getFreeHeap());
Log state transitions, pin reads, timeouts, Wi-Fi status, and exact command IDs. Do not log passwords, MQTT credentials, or long secrets.
When a board behaves oddly:
- Reduce to a minimal sketch that reads one pin or blinks one LED.
- Verify power, ground, physical wiring, and pin mapping.
- Watch serial output from boot.
- Add one subsystem back at a time.
- Test failure paths: unplug sensor, drop Wi-Fi, reboot mid-operation.
Recommended project structure
coop-controller/
src/
main.cpp
door_controller.cpp
door_controller.h
config.h.example
test/
platformio.ini
README.md
.gitignore
PlatformIO is worth using when projects become more than one sketch: repeatable dependencies, per-board configurations, serial monitor, and test/build commands.
Non-negotiable safety practices
- Default outputs to a safe state during boot and failures.
- Add physical end-stops or independent sensing for moving hardware.
- Set timeouts for any motor movement.
- Make remote commands authenticated; never expose device control unauthenticated to the internet.
- Keep a manual override for anything that affects animals, heating, access, or mains power.
- Version firmware and record release notes.