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:

PathMain languageBest for
Arduino core for ESP32C++Fast prototypes, broad beginner library support
ESP-IDFC 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";
TypeUseCaution
booltrue/false statePrefer for flags
intnormal whole numbersSize varies by platform
uint8_t, uint32_tprecise unsigned sizesExcellent for bytes/timers
floatdecimal sensor valuesAvoid for money; compare carefully
char[] / StringtextUnderstand 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 areaMeaningPractice
StackFunction-local valuesAvoid huge local arrays/deep recursion
HeapDynamically allocated memoryAvoid allocation churn in loop()
FlashProgram/constantsPut fixed strings/constants here when appropriate
NVSPersistent ESP32 key/value storageStore 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:

  1. Reduce to a minimal sketch that reads one pin or blinks one LED.
  2. Verify power, ground, physical wiring, and pin mapping.
  3. Watch serial output from boot.
  4. Add one subsystem back at a time.
  5. Test failure paths: unplug sensor, drop Wi-Fi, reboot mid-operation.
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.