Chapter 224: Occupancy Detection and Analytics

Chapter Objectives

By the end of this chapter, you will be able to:

  • Differentiate between motion detection (PIR, microwave) and true presence detection (AI).
  • Interface a Passive Infrared (PIR) sensor with an ESP32 using GPIO interrupts.
  • Implement robust software logic to handle sensor state changes and avoid “flapping.”
  • Develop a system to collect and analyze basic occupancy metrics, such as duration and percentage.
  • Publish real-time occupancy status and analytics data via MQTT.
  • Understand the high-level concepts of AI-based person detection on capable ESP32 variants.
  • Choose the appropriate sensor technology and ESP32 variant for different occupancy sensing applications.

Introduction

In building automation, knowing whether a space is occupied is one of the most powerful pieces of information you can have. It is the fundamental trigger for countless smart systems. Lights turn on when you enter a room and off when you leave. HVAC systems switch from an energy-saving setback mode to a comfortable temperature. Security systems can identify unauthorized presence.

But modern systems go beyond simple on/off control. By collecting and analyzing occupancy data over time—a practice known as space utilization analytics—building managers can gain deep insights. Which conference rooms are overused? Which office spaces are perpetually empty? This data drives decisions about resource allocation, cleaning schedules, and even future building design.

In this chapter, we will explore the technologies behind occupancy sensing, from the ubiquitous PIR sensor to advanced AI-powered vision systems. We will build a practical, network-connected sensor and write the logic to transform raw detection events into meaningful, actionable analytics.

Theory

1. Motion vs. Presence Detection

It’s crucial to distinguish between two concepts:

  • Motion Detection: Senses movement. If a person is in a room but sitting perfectly still, a motion detector may decide the room is empty.
  • Presence Detection: Senses the actual presence of a person, regardless of their movement.

The technologies we discuss fall into these two categories.

2. Passive Infrared (PIR) Sensors

PIR sensors are the workhorse of motion detection. They are inexpensive, reliable, and consume very little power. The “passive” in their name means they don’t emit any energy; instead, they are sensitive to the infrared radiation (i.e., body heat) that all warm-blooded creatures emit.

A PIR sensor uses a special lens (a Fresnel lens) to divide its field of view into multiple detection zones. When a warm body moves from one zone to another, it causes a rapid change in the infrared energy reaching the sensor element. This change triggers the sensor’s output pin to go HIGH.

  • Pros: Low cost, very low power consumption, simple digital output.
  • Cons: Only detects motion, not presence. Can be falsely triggered by other heat sources (HVAC vents, direct sunlight). Requires a clear line of sight.

The most common model is the HC-SR501, which features adjustable sensitivity and on-time delay.

3. Microwave (Radar) Sensors

Microwave sensors are another form of motion detector, but they operate on a different principle: the Doppler effect. They actively emit a continuous, low-power microwave signal and monitor the signal that reflects back. If an object in the room is moving, it will change the frequency of the reflected wave, and the sensor will detect this shift.

  • Pros: More sensitive than PIRs. Can detect very fine movements. Can “see” through thin, non-metallic walls and objects.
  • Cons: Can be too sensitive, detecting movement outside the target area (e.g., in a hallway). Higher power consumption than PIRs. More complex signal processing may be required.

4. AI-Based Presence Detection

This is the leap from motion to true presence detection. By using a camera and a specialized, lightweight machine learning model, an ESP32 can perform person detection. It analyzes the video feed frame by frame and identifies whether a human form is present.

This method is powerful because it can detect a person even if they are sitting still, reading a book. It is the most reliable way to confirm occupancy. Espressif provides frameworks like ESP-WHO (Whole Human-in-the-loop Orchestrator) and ESP-DL (Deep Learning library) with pre-trained models for this purpose.

  • Pros: True presence detection. High accuracy. Can be extended to count people or identify faces.
  • Cons: Significantly higher cost and complexity (camera + capable SoC). Higher power consumption. Privacy concerns must be addressed. Requires a powerful ESP32 variant like the ESP32-S3.
Technology Principle Pros Cons Best For
PIR Sensor Passive Infrared (Body Heat) Low Cost
Very Low Power
Simple to Use
Detects Motion, Not Presence
Line-of-sight required
Can be falsely triggered
General-purpose motion detection for lighting and alerts in common areas.
Microwave (Radar) Active (Doppler Effect) Very Sensitive
Detects through objects
Good for fine motion
Higher Power Use
Can be too sensitive
More complex
Detecting subtle movements in a defined space where PIR might fail (e.g., a person at a desk).
AI Person Detection Vision (Machine Learning) True Presence Detection
High Accuracy
Can count people
High Cost & Complexity
High Power Consumption
Privacy Concerns
High-value applications requiring definite occupancy confirmation, like conference room analytics or security.

5. Occupancy Analytics

The raw output of a sensor is an event: “motion detected.” The real value comes from processing these events over time. A simple but effective analytic is occupancy duration.

Our system will manage two states: VACANT and OCCUPIED. When the sensor first detects motion, we transition to OCCUPIED and record the start time. When a certain period passes with no new motion events, we transition back to VACANT. From this, we can calculate:

  • Total Occupancy Time: The sum of all OCCUPIED durations.
  • Occupancy Percentage: The percentage of a given time window (e.g., an hour or a day) that the space was occupied.

This data, when sent to a central server or dashboard, provides a clear picture of how a space is being used.

Practical Example: PIR Sensor with Occupancy Analytics

We will build a system using the common and accessible PIR sensor.

Hardware Required

  • An ESP32 development board.
  • An HC-SR501 PIR motion sensor.
  • An LED (optional, for visual feedback).
  • A 220Ω resistor (for the LED).
  • Breadboard and jumper wires.

Wiring

Tip: The HC-SR501 PIR sensor can operate from 5V to 20V. While powering it from the ESP32’s 5V pin is possible for simple tests, for a reliable installation, it’s better to power it from a separate, stable 5V source, ensuring grounds are connected. Its output is 3.3V, which is safe for ESP32 GPIOs.

  1. PIR Sensor:
    • VCC -> 5V (or VIN on most ESP32 boards).
    • GND -> GND on ESP32.
    • OUT -> GPIO27 on ESP32.
  2. Status LED:
    • GPIO14 -> Resistor -> LED Anode -> LED Cathode -> GND.

Project Configuration

  1. Create a new ESP-IDF project.
  2. Update main/CMakeLists.txt to require the necessary components:
    REQUIRES
    idf::nvs_flash
    idf::esp_wifi
    idf::esp_event
    idf::esp_netif
    idf::esp_log
    idf::mqtt
  3. Run idf.py menuconfig and configure your Wi-Fi and MQTT broker details.

Code Implementation

Our code will use a GPIO interrupt to react instantly to the PIR signal. A FreeRTOS task will manage the system state and calculate analytics, debouncing the raw sensor output into a stable OCCUPIED/VACANT state.

stateDiagram-v2
    direction LR
    
    [*] --> VACANT: System Start
    
    VACANT --> OCCUPIED: PIR Interrupt
    note left of OCCUPIED
      <b>On Transition:</b>
      1. Set State = OCCUPIED
      2. Record `occupied_start_time`
      3. Turn LED ON
      4. Publish "OCCUPIED" via MQTT
    end note
    
    OCCUPIED --> OCCUPIED: PIR Interrupt
    note right of OCCUPIED
      <b>On Event:</b>
      1. Update `last_motion_time`
      2. State remains OCCUPIED
    end note
    
    OCCUPIED --> VACANT: `(now - last_motion_time) > TIMEOUT`
    note left of VACANT
      <b>On Transition:</b>
      1. Set State = VACANT
      2. Turn LED OFF
      3. Publish "VACANT" via MQTT
      4. Add duration to analytics
    end note

C
/* main/main.c */
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_log.h"
#include "esp_wifi.h"
#include "nvs_flash.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "mqtt_client.h"
#include "driver/gpio.h"
#include "esp_timer.h"

static const char *TAG = "OCCUPANCY_SENSOR";

// --- Hardware & System Configuration ---
#define PIR_SENSOR_GPIO      GPIO_NUM_27
#define STATUS_LED_GPIO      GPIO_NUM_14
#define OCCUPANCY_TIMEOUT_S  60 // If no motion for 60s, mark as vacant

// --- MQTT Topics ---
#define MQTT_STATE_TOPIC           "office/room101/occupancy/state"  // Payload: "OCCUPIED" or "VACANT"
#define MQTT_ANALYTICS_TOPIC       "office/room101/occupancy/analytics" // Payload: JSON with metrics

// --- Global State ---
typedef enum {
    STATE_VACANT,
    STATE_OCCUPIED
} occupancy_state_t;

static volatile occupancy_state_t current_state = STATE_VACANT;
static volatile int64_t last_motion_time_us = 0;
static esp_mqtt_client_handle_t mqtt_client;
static QueueHandle_t pir_evt_queue = NULL;

// --- ISR and Tasks ---
static void IRAM_ATTR gpio_isr_handler(void* arg) {
    uint32_t gpio_num = (uint32_t) arg;
    xQueueSendFromISR(pir_evt_queue, &gpio_num, NULL);
}

void occupancy_manager_task(void *pvParameters) {
    int64_t occupied_start_time_us = 0;
    int64_t total_occupied_duration_us = 0;
    int64_t last_analytics_publish_time_us = 0;
    char json_payload[128];

    while (1) {
        // Check for state changes based on timeout
        if (current_state == STATE_OCCUPIED) {
            int64_t time_since_last_motion_s = (esp_timer_get_time() - last_motion_time_us) / 1000000;
            if (time_since_last_motion_s > OCCUPANCY_TIMEOUT_S) {
                ESP_LOGI(TAG, "Timeout reached. Room is now VACANT.");
                current_state = STATE_VACANT;
                gpio_set_level(STATUS_LED_GPIO, 0);
                esp_mqtt_client_publish(mqtt_client, MQTT_STATE_TOPIC, "VACANT", 0, 1, 1);
                
                // Finalize occupancy duration for this event
                total_occupied_duration_us += (last_motion_time_us - occupied_start_time_us);
                occupied_start_time_us = 0;
            }
        }

        // Publish analytics periodically (e.g., every 5 minutes)
        int64_t current_time_us = esp_timer_get_time();
        if ((current_time_us - last_analytics_publish_time_us) / 1000000 > 300) {
            last_analytics_publish_time_us = current_time_us;

            int64_t current_session_duration_us = 0;
            if (current_state == STATE_OCCUPIED) {
                 current_session_duration_us = current_time_us - occupied_start_time_us;
            }

            float occupancy_percentage = (float)(total_occupied_duration_us + current_session_duration_us) / (float)current_time_us * 100.0f;

            snprintf(json_payload, sizeof(json_payload), 
                     "{\"total_occupied_sec\": %lld, \"uptime_sec\": %lld, \"occupancy_pct\": %.2f}",
                     (total_occupied_duration_us + current_session_duration_us) / 1000000,
                     current_time_us / 1000000,
                     occupancy_percentage);

            esp_mqtt_client_publish(mqtt_client, MQTT_ANALYTICS_TOPIC, json_payload, 0, 1, 0);
            ESP_LOGI(TAG, "Published analytics: %s", json_payload);
        }

        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

void pir_event_handler_task(void* arg) {
    uint32_t io_num;
    for(;;) {
        if(xQueueReceive(pir_evt_queue, &io_num, portMAX_DELAY)) {
            // A motion event has occurred (rising edge from PIR)
            ESP_LOGI(TAG, "Motion detected on GPIO %ld", io_num);
            last_motion_time_us = esp_timer_get_time();

            if (current_state == STATE_VACANT) {
                ESP_LOGI(TAG, "State changed to OCCUPIED.");
                current_state = STATE_OCCUPIED;
                gpio_set_level(STATUS_LED_GPIO, 1);
                esp_mqtt_client_publish(mqtt_client, MQTT_STATE_TOPIC, "OCCUPIED", 0, 1, 1);
            }
        }
    }
}

void app_main(void) {
    // Standard init functions (NVS, WiFi) here...
    // ...

    // --- Configure GPIO ---
    gpio_config_t io_conf = {};
    // PIR Sensor Input
    io_conf.intr_type = GPIO_INTR_POSEDGE; // Trigger on rising edge
    io_conf.pin_bit_mask = (1ULL << PIR_SENSOR_GPIO);
    io_conf.mode = GPIO_MODE_INPUT;
    io_conf.pull_up_en = 0;
    io_conf.pull_down_en = 1; // Pull down to ensure it's low when PIR is inactive
    gpio_config(&io_conf);
    
    // Status LED Output
    io_conf.intr_type = GPIO_INTR_DISABLE;
    io_conf.pin_bit_mask = (1ULL << STATUS_LED_GPIO);
    io_conf.mode = GPIO_MODE_OUTPUT;
    gpio_config(&io_conf);
    gpio_set_level(STATUS_LED_GPIO, 0);

    // Create a queue to handle gpio events from isr
    pir_evt_queue = xQueueCreate(10, sizeof(uint32_t));

    // Install ISR service and add handler for PIR pin
    gpio_install_isr_service(0);
    gpio_isr_handler_add(PIR_SENSOR_GPIO, gpio_isr_handler, (void*) PIR_SENSOR_GPIO);

    // Start tasks
    xTaskCreate(pir_event_handler_task, "pir_event_handler_task", 2048, NULL, 10, NULL);
    xTaskCreate(occupancy_manager_task, "occupancy_manager_task", 4096, NULL, 5, NULL);

    // ... WiFi and MQTT start functions here
}

// NOTE: Full WiFi and MQTT init code is omitted for brevity. 
// Please refer to previous chapters for the standard implementation.

Build, Flash, and Observe

  1. After wiring, flash the code and open the monitor.
  2. Connect an MQTT client and subscribe to office/room101/occupancy/# to see all messages.
  3. When you wave your hand in front of the sensor, you should see:
    • “Motion detected” in the logs.
    • The status LED turn on.
    • An MQTT message on the .../state topic with payload OCCUPIED.
  4. Stand still. After 60 seconds (OCCUPANCY_TIMEOUT_S), you should see:
    • “Timeout reached. Room is now VACANT” in the logs.
    • The status LED turn off.
    • An MQTT message with payload VACANT.
  5. Every 5 minutes, an analytics JSON message will be published to the .../analytics topic.

Variant Notes

  • PIR and Microwave Sensors: These sensors use standard GPIOs for their output. This functionality is available on all ESP32 variants, including the ESP32, S2, S3, C3, C6, and H2. The core logic of using a GPIO interrupt and a software state machine remains identical. For an ESP32-H2, the MQTT communication would be replaced by a Zigbee or Thread equivalent to report the state to a gateway.
  • AI-Based Person Detection: This is highly variant-dependent.
    • ESP32-S3: This is the ideal choice. Its powerful dual-core CPU, vector instruction extensions for accelerating neural networks, and large PSRAM capacity make it perfect for running camera streams and AI models.
    • ESP32 (Original): Capable, as proven by the popular ESP32-CAM module. However, inference is slower without the S3’s hardware acceleration.
    • ESP32-S2: Has a single core and no ML acceleration, making it less suitable for complex, real-time vision tasks.
    • ESP32-C3 / C6 / H2: These RISC-V based chips are designed for connectivity and low-power applications. They generally lack the processing power and memory interfaces (like DVP camera interface on some) for AI vision tasks.

Common Mistakes & Troubleshooting Tips

Mistake / Issue Symptom(s) Troubleshooting / Solution
PIR sensor triggers constantly or not at all. The status LED is always on, or never turns on despite movement. Check Hardware and Sensor Settings:
1. Wiring: Verify VCC, GND, and OUT pins are correctly connected.
2. Sensor Potentiometers: The HC-SR501 has two adjustments. Turn the “Time Delay” (Tx) potentiometer to its minimum setting. Turn the “Sensitivity” (Sx) to its middle setting and adjust from there.
3. Placement: Avoid pointing the sensor at HVAC vents, windows with direct sun, or other sources of rapid temperature change.
System crashes with a Guru Meditation Error. The device reboots when motion is detected. The log mentions an error related to an ISR. Keep ISR Code Minimal:
An Interrupt Service Routine (ISR) must be very fast. It cannot perform complex operations. – Correct: The ISR’s only job should be to send a simple message to a FreeRTOS queue, as shown in the example (xQueueSendFromISR). – Incorrect: Do not call ESP_LOGI, printf, or any blocking function from inside the ISR.
State “flaps” between OCCUPIED and VACANT. The MQTT state topic shows rapid, repeated changes between “OCCUPIED” and “VACANT”. Increase Software Timeout:
The OCCUPANCY_TIMEOUT_S value is too low for the use case. Increase it to a more reasonable value (e.g., 180 for 3 minutes) to prevent the room from being marked vacant during brief periods of no movement.
AI person detection example fails to build or run. When trying to use an example from ESP-WHO, the build fails with errors about missing components, or it runs out of memory on the device. Use a Suitable ESP32 Variant:
AI vision tasks have specific hardware requirements. – Required: Use an ESP32-S3 board with a camera and, ideally, PSRAM. – Unsuitable: ESP32-C3, C6, and H2 variants lack the processing power and memory interfaces for these demanding applications.

Exercises

  1. Link to Lighting Control: Combine this chapter’s project with a relay-controlled light from a previous chapter. When the room state becomes OCCUPIED, turn the light on. When it becomes VACANT, turn it off.
  2. Differentiated Timeouts: Make the system smarter. Implement a shorter timeout during business hours (e.g., 5 minutes) and a longer timeout at night (e.g., 30 minutes). This requires integrating an SNTP client (Chapter 98) to get the current time.
  3. Advanced Analytics: Modify the occupancy_manager_task to track more metrics. Count the number of OCCUPANCY events. Calculate the average duration of an occupancy event. Publish these as part of the analytics JSON payload.
  4. Explore AI Presence Detection (Advanced): If you have an ESP32-S3 with a camera (like an ESP32-S3-EYE or ESP-S3-BOX-3), clone the ESP-WHO repository from Espressif. Find the person detection example, flash it to your board, and observe how it can detect you even when you’re sitting still—a feat the PIR sensor cannot accomplish.

Summary

  • Occupancy sensing is a key enabler for energy efficiency and building intelligence.
  • PIR sensors detect motion by sensing changes in infrared heat, while microwave sensors use the Doppler effect. Both are effective for detecting movement but not static presence.
  • AI-powered person detection using a camera provides true presence detection and is best suited for powerful variants like the ESP32-S3.
  • Using GPIO interrupts and a FreeRTOS queue is the correct, robust way to handle real-time sensor inputs without blocking other tasks or causing crashes.
  • Transforming raw sensor data into analytics (like occupancy percentage) unlocks a much deeper understanding of how a space is used.
  • Choosing the right sensing technology is a trade-off between cost, power, complexity, and the fundamental need for motion vs. presence detection.

Further Reading

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top