diff --git a/CHANGELOG.md b/CHANGELOG.md index 51162925..9c27ff97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## 1.6.0 + +### New Features + +- **Path Tracking Timeout**: Automatically resets the state machine when someone enters halfway and turns back, preventing stuck states. Configurable via `path_tracking_timeout` (default: 3s). +- **Adaptive Thresholds**: Continuously adjusts idle baseline to handle environmental drift (temperature changes, lighting). Uses Exponential Moving Average for smooth updates. Configurable via `adaptive_threshold` block. +- **Timeout Event**: The `entry_exit_event` text sensor now publishes "Timeout" when an incomplete crossing is detected. + +### Improvements + +- Moved path tracking state from static variables to class members for better encapsulation and testability +- Added sanity checks for adaptive threshold updates (rejects readings >20% different from current idle) +- Enhanced logging for debugging path tracking and threshold updates +- Added configuration dump for new features in `dump_config()` + +### Configuration + +New configuration options: + +```yaml +roode: + # Reset state machine if no activity within this period (prevents stuck states) + path_tracking_timeout: 3s + + # Continuously adjust thresholds for environmental drift + adaptive_threshold: + enabled: true + update_interval: 60s # How long zones must be empty before updating + alpha: 0.05 # EMA smoothing factor (0.01-0.5) +``` + ## 1.5.4 - Fix ESPHome 2026.2+ compatibility by migrating ESP8266 configurations to new platform format diff --git a/README.md b/README.md index e3923139..323e2545 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![GitHub release](https://img.shields.io/github/v/tag/Lyr3x/Roode?style=flat-square)](https://GitHub.com/Lyr3x/Roode/releases/) [![Build](https://img.shields.io/github/workflow/status/Lyr3x/Roode/CI?style=flat-square)](https://github.com/Lyr3x/Roode/blob/master/.github/workflows/ci.yml) -[![Maintenance](https://img.shields.io/maintenance/yes/2023?style=flat-square)](https://GitHub.com/Lyr3x/Roode/graphs/commit-activity) +[![Maintenance](https://img.shields.io/maintenance/yes/2026?style=flat-square)](https://GitHub.com/Lyr3x/Roode/graphs/commit-activity) [![Roode community](https://img.shields.io/discord/879407995837087804.svg?label=Discord&logo=Discord&colorB=7289da&style=for-the-badge)](https://discord.gg/hU9SvSXMHs) @@ -16,6 +16,7 @@ People counter working with any smart home system which supports ESPHome/MQTT li - [Platform Setup](#platform-setup) - [Sensors](#sensors) - [Threshold distance](#threshold-distance) + - [Robustness Features](#robustness-features) - [Algorithm](#algorithm) - [FAQ/Troubleshoot](#faqtroubleshoot) @@ -145,6 +146,18 @@ roode: # min: 50mm # max: 234cm + # Path tracking timeout: resets state machine if someone enters halfway and turns back. + # This prevents the algorithm from getting stuck waiting for a crossing to complete. + # Set to 0s to disable timeout (not recommended). + path_tracking_timeout: 3s + + # Adaptive threshold: continuously adjusts idle baseline to handle environmental drift + # (temperature changes, lighting variations, etc.). Uses Exponential Moving Average. + adaptive_threshold: + enabled: true # Enable/disable adaptive updates + update_interval: 60s # How long zones must be empty before updating + alpha: 0.05 # EMA smoothing factor (0.01-0.5, lower = slower adaptation) + # The people counting algorithm works by splitting the sensor's capability reading area into two zones. # This allows for detecting whether a crossing is an entry or exit based on which zones was crossed first. zones: @@ -244,6 +257,50 @@ min_threshold_percentage: 10% = 200 All distances smaller then 200mm and greater then 1760mm will be ignored. ``` +### Robustness Features + +Roode v1.6.0 introduces two features to improve counting reliability: + +#### Path Tracking Timeout + +**Problem:** If someone enters the detection area but turns back without completing a crossing, the state machine could get stuck waiting for the crossing to complete. + +**Solution:** The `path_tracking_timeout` option (default: 3 seconds) automatically resets the state machine if no state change occurs within the timeout period. + +```yaml +roode: + path_tracking_timeout: 3s # Reset if no activity for 3 seconds +``` + +When a timeout occurs: +- The state machine is reset to idle +- A "Timeout" event is published to the `entry_exit_event` sensor +- No count change occurs (the incomplete crossing is ignored) + +You can monitor these events in Home Assistant to detect frequent turn-backs. + +#### Adaptive Thresholds + +**Problem:** Environmental changes (temperature drift, lighting variations) can cause the idle distance to shift over time, degrading detection accuracy. + +**Solution:** The `adaptive_threshold` feature continuously updates the idle baseline using an Exponential Moving Average (EMA) when both zones are empty. + +```yaml +roode: + adaptive_threshold: + enabled: true # Default: enabled + update_interval: 60s # Update after zones empty for 60s + alpha: 0.05 # Smoothing factor (lower = slower adaptation) +``` + +How it works: +1. When both zones are empty for the configured interval, the current distance is sampled +2. The idle baseline is updated: `new_idle = (1 - alpha) × old_idle + alpha × reading` +3. Min/max thresholds are recalculated based on the new idle value +4. A sanity check rejects readings that differ more than 20% from current idle + +The threshold sensors will reflect the updated values in Home Assistant. + ## Algorithm The implemented Algorithm is an improved version of my own implementation which checks the direction of a movement through two defined zones. ST implemented a nice and efficient way to track the path from one to the other direction. I migrated the algorigthm with some changes into the Roode project. @@ -256,6 +313,8 @@ The concept of path tracking is the detecion of a human: That way we can ensure the direction of movement. +The algorithm includes timeout handling: if the sequence doesn't complete within the configured timeout (default 3s), the state machine resets. This handles cases where someone enters the detection area but turns back without completing a crossing. + The sensor creates a 16x16 grid and the final distance is computed by taking the average of the distance of the values of the grid. We are defining two different Region of Interest (ROI) inside this grid. Then the sensor will measure the two distances in the two zones and will detect any presence and tracks the path to receive the direction. @@ -330,6 +389,42 @@ lower right. 3. Light interference (You will see a lot of noise) 4. Bad connections +--- + +**Question:** The counter seems to get stuck or miss counts after some time? + +**Answer:** This could be environmental drift affecting the thresholds. Check if: + +1. **Adaptive thresholds are enabled** (default). If disabled, enable them: + ```yaml + roode: + adaptive_threshold: + enabled: true + ``` +2. **Check the threshold sensors** in Home Assistant - if they're drifting significantly from initial calibration, adaptive thresholds should help. +3. **Recalibrate manually** if the environment has changed dramatically (e.g., new flooring, moved sensor). + +--- + +**Question:** I see many "Timeout" events in the entry_exit_event sensor? + +**Answer:** Timeout events occur when someone enters the detection area but doesn't complete a crossing. This is normal behavior. However, frequent timeouts might indicate: + +1. **Timeout too short**: Increase `path_tracking_timeout` if people walk slowly through the doorway +2. **Threshold issues**: The detection thresholds might be triggering on objects that aren't people (pets, swinging doors) +3. **Sensor placement**: The sensor might be detecting movement outside the intended area + +--- + +**Question:** The count seems to drift over long periods? + +**Answer:** People counting will never be 100% accurate. To minimize drift: + +1. Ensure adaptive thresholds are enabled +2. Adjust the detection thresholds based on your mounting height +3. Use the people_counter number entity to manually correct the count when needed +4. Consider automations that reset the count when the room is known to be empty (e.g., at night) + ## Sponsors Thank you very much for you sponsorship! diff --git a/ci/common.yaml b/ci/common.yaml index 70ece37f..6a6278ae 100644 --- a/ci/common.yaml +++ b/ci/common.yaml @@ -9,6 +9,25 @@ external_components: esphome: name: $devicename +vl53l1x: + calibration: + ranging: auto + +roode: + id: roode_platform + sampling: 2 + roi: { height: 16, width: 6 } + detection_thresholds: + max: 85% + # Test new v1.6.0 features + path_tracking_timeout: 3s + adaptive_threshold: + enabled: true + update_interval: 60s + alpha: 0.05 + zones: + invert: false + button: - platform: restart name: $friendly_name Restart diff --git a/ci/esp32.yaml b/ci/esp32.yaml index bd9ba973..1a1fd5b8 100644 --- a/ci/esp32.yaml +++ b/ci/esp32.yaml @@ -8,10 +8,3 @@ esp32: i2c: sda: 21 scl: 22 - -# VL53L1X sensor configuration is separate from Roode people counting algorithm -vl53l1x: - calibration: - -roode: - id: roode_platform diff --git a/ci/esp8266.yaml b/ci/esp8266.yaml index 65092dd6..53a3b1bc 100644 --- a/ci/esp8266.yaml +++ b/ci/esp8266.yaml @@ -6,10 +6,3 @@ esp8266: i2c: sda: 4 scl: 5 - -# VL53L1X sensor configuration is separate from Roode people counting algorithm -vl53l1x: - calibration: - -roode: - id: roode_platform diff --git a/components/roode/__init__.py b/components/roode/__init__.py index 692eed22..bdb4b099 100644 --- a/components/roode/__init__.py +++ b/components/roode/__init__.py @@ -31,6 +31,13 @@ CONF_SAMPLING = "sampling" CONF_ZONES = "zones" +# New configuration options for high-priority features +CONF_PATH_TRACKING_TIMEOUT = "path_tracking_timeout" +CONF_ADAPTIVE_THRESHOLD = "adaptive_threshold" +CONF_ENABLED = "enabled" +CONF_UPDATE_INTERVAL = "update_interval" +CONF_ALPHA = "alpha" + Orientation = roode_ns.enum("Orientation") ORIENTATION_VALUES = { "parallel": Orientation.Parallel, @@ -66,6 +73,15 @@ } ) +# Adaptive threshold configuration schema +ADAPTIVE_THRESHOLD_SCHEMA = NullableSchema( + { + cv.Optional(CONF_ENABLED, default=True): cv.boolean, + cv.Optional(CONF_UPDATE_INTERVAL, default="60s"): cv.positive_time_period_milliseconds, + cv.Optional(CONF_ALPHA, default=0.05): cv.float_range(min=0.01, max=0.5), + } +) + CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(Roode), @@ -81,6 +97,11 @@ cv.Optional(CONF_EXIT_ZONE, default={}): ZONE_SCHEMA, } ), + # Path tracking timeout: resets state machine if no activity within this period + # Prevents stuck states when someone enters halfway and turns back + cv.Optional(CONF_PATH_TRACKING_TIMEOUT, default="3s"): cv.positive_time_period_milliseconds, + # Adaptive threshold: continuously adjusts idle baseline to handle environmental drift + cv.Optional(CONF_ADAPTIVE_THRESHOLD, default={}): ADAPTIVE_THRESHOLD_SCHEMA, } ).extend(cv.COMPONENT_SCHEMA) @@ -98,6 +119,15 @@ async def to_code(config: Dict): setup_zone(CONF_ENTRY_ZONE, config, roode) setup_zone(CONF_EXIT_ZONE, config, roode) + # Path tracking timeout configuration + cg.add(roode.set_path_tracking_timeout(config[CONF_PATH_TRACKING_TIMEOUT])) + + # Adaptive threshold configuration + adaptive_config = config[CONF_ADAPTIVE_THRESHOLD] + cg.add(roode.set_adaptive_threshold_enabled(adaptive_config[CONF_ENABLED])) + cg.add(roode.set_adaptive_threshold_update_interval(adaptive_config[CONF_UPDATE_INTERVAL])) + cg.add(roode.set_adaptive_threshold_alpha(adaptive_config[CONF_ALPHA])) + def setup_zone(name: str, config: Dict, roode: cg.Pvariable): zone_config = config[CONF_ZONES][name] diff --git a/components/roode/roode.cpp b/components/roode/roode.cpp index f19cff4e..541bfa15 100644 --- a/components/roode/roode.cpp +++ b/components/roode/roode.cpp @@ -5,6 +5,12 @@ namespace roode { void Roode::dump_config() { ESP_LOGCONFIG(TAG, "Roode:"); ESP_LOGCONFIG(TAG, " Sample size: %d", samples); + ESP_LOGCONFIG(TAG, " Path tracking timeout: %lu ms", path_tracking_timeout_ms_); + ESP_LOGCONFIG(TAG, " Adaptive thresholds: %s", adaptive_threshold_enabled_ ? "enabled" : "disabled"); + if (adaptive_threshold_enabled_) { + ESP_LOGCONFIG(TAG, " Update interval: %lu ms", adaptive_threshold_interval_ms_); + ESP_LOGCONFIG(TAG, " Smoothing alpha: %.2f", adaptive_threshold_alpha_); + } LOG_UPDATE_INTERVAL(this); entry->dump_config(); exit->dump_config(); @@ -23,7 +29,19 @@ void Roode::setup() { return; } + // Initialize timestamps for new features + uint32_t now = millis(); + last_state_change_time_ = now; + last_adaptive_update_time_ = now; + zones_empty_since_ = now; + calibrate_zones(); + + if (adaptive_threshold_enabled_) { + ESP_LOGI(SETUP, "Adaptive thresholds enabled (interval: %lu ms, alpha: %.2f)", + adaptive_threshold_interval_ms_, adaptive_threshold_alpha_); + } + ESP_LOGI(SETUP, "Path tracking timeout: %lu ms", path_tracking_timeout_ms_); } void Roode::update() { @@ -36,17 +54,16 @@ void Roode::update() { } void Roode::loop() { - // unsigned long start = micros(); this->current_zone->readDistance(distanceSensor); - // uint16_t samplingDistance = sampling(this->current_zone); path_tracking(this->current_zone); handle_sensor_status(); + + // Adaptive threshold update when both zones are empty + if (adaptive_threshold_enabled_) { + updateAdaptiveThresholds(); + } + this->current_zone = this->current_zone == this->entry ? this->exit : this->entry; - // ESP_LOGI("Experimental", "Entry zone: %d, exit zone: %d", - // entry->getDistance(Roode::distanceSensor, Roode::sensor_status), - // exit->getDistance(Roode::distanceSensor, Roode::sensor_status)); unsigned - // long end = micros(); unsigned long delta = end - start; ESP_LOGI("Roode - // loop", "loop took %lu microseconds", delta); } bool Roode::handle_sensor_status() { @@ -68,15 +85,37 @@ bool Roode::handle_sensor_status() { return check_status; } +void Roode::resetPathTracking() { + path_track_[0] = 0; + path_track_[1] = 0; + path_track_[2] = 0; + path_track_[3] = 0; + path_track_filling_size_ = 1; + left_previous_status_ = NOBODY; + right_previous_status_ = NOBODY; + last_state_change_time_ = millis(); + ESP_LOGD(TAG, "Path tracking state reset (timeout or completion)"); +} + void Roode::path_tracking(Zone *zone) { - static int PathTrack[] = {0, 0, 0, 0}; - static int PathTrackFillingSize = 1; // init this to 1 as we start from state - // where nobody is any of the zones - static int LeftPreviousStatus = NOBODY; - static int RightPreviousStatus = NOBODY; int CurrentZoneStatus = NOBODY; int AllZonesCurrentStatus = 0; int AnEventHasOccured = 0; + uint32_t now = millis(); + + // Timeout check: reset state if no activity for too long + // This prevents stuck states when someone enters halfway and turns back + if (path_track_filling_size_ > 1 && path_tracking_timeout_ms_ > 0) { + if ((now - last_state_change_time_) > path_tracking_timeout_ms_) { + ESP_LOGW(TAG, "Path tracking timeout after %lu ms - resetting state (incomplete crossing)", + now - last_state_change_time_); + if (entry_exit_event_sensor != nullptr) { + entry_exit_event_sensor->publish_state("Timeout"); + } + resetPathTracking(); + return; + } + } // PathTrack algorithm if (zone->getMinDistance() < zone->threshold->max && zone->getMinDistance() > zone->threshold->min) { @@ -89,7 +128,7 @@ void Roode::path_tracking(Zone *zone) { // left zone if (zone == (this->invert_direction_ ? this->exit : this->entry)) { - if (CurrentZoneStatus != LeftPreviousStatus) { + if (CurrentZoneStatus != left_previous_status_) { // event in left zone has occured AnEventHasOccured = 1; @@ -97,49 +136,50 @@ void Roode::path_tracking(Zone *zone) { AllZonesCurrentStatus += 1; } // need to check right zone as well ... - if (RightPreviousStatus == SOMEONE) { + if (right_previous_status_ == SOMEONE) { // event in right zone has occured AllZonesCurrentStatus += 2; } // remember for next time - LeftPreviousStatus = CurrentZoneStatus; + left_previous_status_ = CurrentZoneStatus; } } // right zone else { - if (CurrentZoneStatus != RightPreviousStatus) { + if (CurrentZoneStatus != right_previous_status_) { // event in right zone has occured AnEventHasOccured = 1; if (CurrentZoneStatus == SOMEONE) { AllZonesCurrentStatus += 2; } // need to check left zone as well ... - if (LeftPreviousStatus == SOMEONE) { + if (left_previous_status_ == SOMEONE) { // event in left zone has occured AllZonesCurrentStatus += 1; } // remember for next time - RightPreviousStatus = CurrentZoneStatus; + right_previous_status_ = CurrentZoneStatus; } } // if an event has occured if (AnEventHasOccured) { + last_state_change_time_ = now; // Update timestamp on state change ESP_LOGD(TAG, "Event has occured, AllZonesCurrentStatus: %d", AllZonesCurrentStatus); - if (PathTrackFillingSize < 4) { - PathTrackFillingSize++; + if (path_track_filling_size_ < 4) { + path_track_filling_size_++; } // if nobody anywhere lets check if an exit or entry has happened - if ((LeftPreviousStatus == NOBODY) && (RightPreviousStatus == NOBODY)) { + if ((left_previous_status_ == NOBODY) && (right_previous_status_ == NOBODY)) { ESP_LOGD(TAG, "Nobody anywhere, AllZonesCurrentStatus: %d", AllZonesCurrentStatus); - // check exit or entry only if PathTrackFillingSize is 4 (for example 0 1 + // check exit or entry only if path_track_filling_size_ is 4 (for example 0 1 // 3 2) and last event is 0 (nobobdy anywhere) - if (PathTrackFillingSize == 4) { - // check exit or entry. no need to check PathTrack[0] == 0 , it is + if (path_track_filling_size_ == 4) { + // check exit or entry. no need to check path_track_[0] == 0 , it is // always the case - if ((PathTrack[1] == 1) && (PathTrack[2] == 3) && (PathTrack[3] == 2)) { + if ((path_track_[1] == 1) && (path_track_[2] == 3) && (path_track_[3] == 2)) { // This an exit ESP_LOGI("Roode pathTracking", "Exit detected."); @@ -147,31 +187,35 @@ void Roode::path_tracking(Zone *zone) { if (entry_exit_event_sensor != nullptr) { entry_exit_event_sensor->publish_state("Exit"); } - } else if ((PathTrack[1] == 2) && (PathTrack[2] == 3) && (PathTrack[3] == 1)) { + } else if ((path_track_[1] == 2) && (path_track_[2] == 3) && (path_track_[3] == 1)) { // This an entry ESP_LOGI("Roode pathTracking", "Entry detected."); this->updateCounter(1); if (entry_exit_event_sensor != nullptr) { entry_exit_event_sensor->publish_state("Entry"); } + } else { + // Incomplete or invalid sequence - log for debugging + ESP_LOGD(TAG, "Invalid path sequence: [%d, %d, %d, %d]", + path_track_[0], path_track_[1], path_track_[2], path_track_[3]); } } - PathTrackFillingSize = 1; + resetPathTracking(); } else { - // update PathTrack - // example of PathTrack update + // update path_track_ + // example of path_track_ update // 0 // 0 1 // 0 1 3 // 0 1 3 1 // 0 1 3 3 // 0 1 3 2 ==> if next is 0 : check if exit - PathTrack[PathTrackFillingSize - 1] = AllZonesCurrentStatus; + path_track_[path_track_filling_size_ - 1] = AllZonesCurrentStatus; } } if (presence_sensor != nullptr) { - if (CurrentZoneStatus == NOBODY && LeftPreviousStatus == NOBODY && RightPreviousStatus == NOBODY) { + if (CurrentZoneStatus == NOBODY && left_previous_status_ == NOBODY && right_previous_status_ == NOBODY) { // nobody is in the sensing area presence_sensor->publish_state(false); } @@ -189,6 +233,50 @@ void Roode::updateCounter(int delta) { } void Roode::recalibration() { calibrate_zones(); } +void Roode::updateAdaptiveThresholds() { + uint32_t now = millis(); + bool both_zones_empty = !entry->isOccupied() && !exit->isOccupied(); + + if (both_zones_empty) { + // Track when zones became empty + if (zones_were_occupied_) { + zones_empty_since_ = now; + zones_were_occupied_ = false; + } + + // Only update if zones have been empty for at least the update interval + // This ensures we're capturing true idle readings, not brief gaps during crossings + uint32_t empty_duration = now - zones_empty_since_; + if (empty_duration >= adaptive_threshold_interval_ms_) { + // Check if enough time has passed since last update + if ((now - last_adaptive_update_time_) >= adaptive_threshold_interval_ms_) { + ESP_LOGD(TAG, "Updating adaptive thresholds (zones empty for %lu ms)", empty_duration); + + entry->updateAdaptiveThreshold(adaptive_threshold_alpha_); + exit->updateAdaptiveThreshold(adaptive_threshold_alpha_); + + last_adaptive_update_time_ = now; + + // Publish updated threshold values if sensors are configured + if (max_threshold_entry_sensor != nullptr) { + max_threshold_entry_sensor->publish_state(entry->threshold->max); + } + if (max_threshold_exit_sensor != nullptr) { + max_threshold_exit_sensor->publish_state(exit->threshold->max); + } + if (min_threshold_entry_sensor != nullptr) { + min_threshold_entry_sensor->publish_state(entry->threshold->min); + } + if (min_threshold_exit_sensor != nullptr) { + min_threshold_exit_sensor->publish_state(exit->threshold->min); + } + } + } + } else { + zones_were_occupied_ = true; + } +} + const RangingMode *Roode::determine_raning_mode(uint16_t average_entry_zone_distance, uint16_t average_exit_zone_distance) { uint16_t min = average_entry_zone_distance < average_exit_zone_distance ? average_entry_zone_distance diff --git a/components/roode/roode.h b/components/roode/roode.h index e9deaf1b..c937425d 100644 --- a/components/roode/roode.h +++ b/components/roode/roode.h @@ -18,7 +18,7 @@ namespace esphome { namespace roode { #define NOBODY 0 #define SOMEONE 1 -#define VERSION "1.5.1" +#define VERSION "1.6.0" static const char *const TAG = "Roode"; static const char *const SETUP = "Setup"; static const char *const CALIBRATION = "Sensor Calibration"; @@ -101,6 +101,12 @@ class Roode : public PollingComponent { Zone *entry = new Zone(0); Zone *exit = new Zone(1); + // Configuration setters for new features + void set_path_tracking_timeout(uint32_t timeout_ms) { path_tracking_timeout_ms_ = timeout_ms; } + void set_adaptive_threshold_enabled(bool enabled) { adaptive_threshold_enabled_ = enabled; } + void set_adaptive_threshold_update_interval(uint32_t interval_ms) { adaptive_threshold_interval_ms_ = interval_ms; } + void set_adaptive_threshold_alpha(float alpha) { adaptive_threshold_alpha_ = alpha; } + protected: TofSensor *distanceSensor; Zone *current_zone = entry; @@ -137,6 +143,29 @@ class Roode : public PollingComponent { int medium_distance_threshold = 2000; int medium_long_distance_threshold = 2700; int long_distance_threshold = 3400; + + // Path tracking state (moved from static variables for better encapsulation) + int path_track_[4] = {0, 0, 0, 0}; + int path_track_filling_size_ = 1; + int left_previous_status_ = NOBODY; + int right_previous_status_ = NOBODY; + uint32_t last_state_change_time_ = 0; + + // Timeout configuration: resets path tracking if no state change within timeout + // Prevents stuck states when someone enters halfway and turns back + uint32_t path_tracking_timeout_ms_ = 3000; // Default 3 seconds + + // Adaptive threshold configuration: continuously adjusts idle baseline + // to handle environmental drift (temperature, lighting changes) + bool adaptive_threshold_enabled_ = true; + uint32_t adaptive_threshold_interval_ms_ = 60000; // Update every 60 seconds + float adaptive_threshold_alpha_ = 0.05f; // EMA smoothing factor (0.0-1.0) + uint32_t last_adaptive_update_time_ = 0; + uint32_t zones_empty_since_ = 0; + bool zones_were_occupied_ = false; + + void resetPathTracking(); + void updateAdaptiveThresholds(); }; } // namespace roode diff --git a/components/roode/zone.cpp b/components/roode/zone.cpp index 12524097..c037d557 100644 --- a/components/roode/zone.cpp +++ b/components/roode/zone.cpp @@ -128,5 +128,43 @@ int Zone::getOptimizedValues(int *values, int sum, int size) { uint16_t Zone::getDistance() const { return this->last_distance; } uint16_t Zone::getMinDistance() const { return this->min_distance; } + +bool Zone::isOccupied() const { + return min_distance < threshold->max && min_distance > threshold->min; +} + +void Zone::updateAdaptiveThreshold(float alpha) { + // Only update if we have a valid distance reading above our max threshold + // (meaning the zone is definitely empty - reading is near idle distance) + if (last_distance <= threshold->max || last_distance == 0) { + return; + } + + // Sanity check: don't update if the reading is unreasonably different + // from current idle (more than 20% change would be suspicious) + uint16_t max_reasonable_change = threshold->idle / 5; // 20% + if (abs((int)last_distance - (int)threshold->idle) > max_reasonable_change) { + ESP_LOGD(CALIBRATION, "Skipping adaptive update: reading %d too far from idle %d", + last_distance, threshold->idle); + return; + } + + // Exponential Moving Average update + uint16_t old_idle = threshold->idle; + threshold->idle = (uint16_t)((1.0f - alpha) * threshold->idle + alpha * last_distance); + + // Recalculate dependent thresholds + if (threshold->max_percentage.has_value()) { + threshold->max = (threshold->idle * threshold->max_percentage.value()) / 100; + } + if (threshold->min_percentage.has_value()) { + threshold->min = (threshold->idle * threshold->min_percentage.value()) / 100; + } + + if (old_idle != threshold->idle) { + ESP_LOGD(CALIBRATION, "Adaptive threshold update zone %d: idle %d -> %d, max: %d, min: %d", + id, old_idle, threshold->idle, threshold->max, threshold->min); + } +} } // namespace roode } // namespace esphome \ No newline at end of file diff --git a/components/roode/zone.h b/components/roode/zone.h index bcb20bb1..54e308f9 100644 --- a/components/roode/zone.h +++ b/components/roode/zone.h @@ -43,6 +43,10 @@ class Zone { Threshold *threshold = new Threshold(); void set_max_samples(uint8_t max) { max_samples = max; }; + // Adaptive threshold methods + void updateAdaptiveThreshold(float alpha); + bool isOccupied() const; + protected: int getOptimizedValues(int *values, int sum, int size); VL53L1_Error last_sensor_status = VL53L1_ERROR_NONE; diff --git a/home_assistant.md b/home_assistant.md index 09c4819a..eb39f737 100644 --- a/home_assistant.md +++ b/home_assistant.md @@ -1,25 +1,214 @@ -To make all functions of Roode work with home assistant you need to set up a few entities and automations. -Roode has endpoints to set the count value, reset the counter to 0 and to recalibrate. Unfourtunately its not possible to expose buttons via ESPHome that do just that. - -``` -# This automation script runs when the counter has changed. -# It sets the value slider on the GUI. This slides also had its own automation when the value is changed. -- alias: "Set people32 slider" - trigger: - platform: state - entity_id: sensor.roode32_people_counter_2 - action: - service: input_number.set_value - target: - entity_id: input_number.set_people32 - data: - value: "{{ states('sensor.roode32_people_counter_2') }}" -- alias: "people32 slider moved" - trigger: - platform: state - entity_id: input_number.set_people32 - action: - service: esphome.roode32_set_counter - data: - newCount: "{{ states('input_number.set_people32') | int }}" -``` \ No newline at end of file +# Home Assistant Integration + +Roode integrates seamlessly with Home Assistant via ESPHome. All entities are created automatically, but here are some useful automations and configurations. + +## Available Entities + +After adding Roode to Home Assistant, you'll have access to: + +| Entity Type | Name | Description | +|-------------|------|-------------| +| Number | People Counter | Current count (adjustable) | +| Binary Sensor | Presence | Someone in detection area | +| Text Sensor | Version | Roode firmware version | +| Text Sensor | Entry/Exit Event | Last event: "Entry", "Exit", or "Timeout" | +| Sensor | Distance Entry/Exit | Current distance readings | +| Sensor | Threshold Entry/Exit | Current min/max thresholds | +| Sensor | ROI Height/Width | Current ROI configuration | +| Sensor | Sensor Status | VL53L1X status code | + +## Example Automations + +### Reset Counter at Night + +Reset the people counter when everyone should be out (or in bed): + +```yaml +automation: + - alias: "Reset people counter at midnight" + trigger: + - platform: time + at: "00:00:00" + condition: + - condition: state + entity_id: binary_sensor.roode32_presence + state: "off" + action: + - service: number.set_value + target: + entity_id: number.roode32_people_counter + data: + value: 0 +``` + +### Notify on Timeout Events + +Get notified if there are frequent timeout events (might indicate calibration issues): + +```yaml +automation: + - alias: "Notify on frequent Roode timeouts" + trigger: + - platform: state + entity_id: text_sensor.roode32_last_direction + to: "Timeout" + action: + - service: counter.increment + target: + entity_id: counter.roode_timeout_count + - condition: numeric_state + entity_id: counter.roode_timeout_count + above: 10 + - service: notify.mobile_app + data: + title: "Roode Sensor Alert" + message: "High timeout count - consider recalibrating" +``` + +### Sync Counter with Input Number (Legacy) + +If you prefer using an input_number slider to adjust the count: + +```yaml +input_number: + roode_people_count: + name: "Room Occupancy" + min: 0 + max: 20 + step: 1 + mode: slider + +automation: + - alias: "Sync Roode counter to slider" + trigger: + - platform: state + entity_id: number.roode32_people_counter + action: + - service: input_number.set_value + target: + entity_id: input_number.roode_people_count + data: + value: "{{ states('number.roode32_people_counter') | int }}" + + - alias: "Sync slider to Roode counter" + trigger: + - platform: state + entity_id: input_number.roode_people_count + condition: + - condition: template + value_template: > + {{ states('input_number.roode_people_count') | int != + states('number.roode32_people_counter') | int }} + action: + - service: number.set_value + target: + entity_id: number.roode32_people_counter + data: + value: "{{ states('input_number.roode_people_count') | int }}" +``` + +### Turn Off Lights When Room Empty + +```yaml +automation: + - alias: "Turn off lights when room empty" + trigger: + - platform: numeric_state + entity_id: number.roode32_people_counter + below: 1 + for: + minutes: 5 + action: + - service: light.turn_off + target: + entity_id: light.living_room +``` + +### Monitor Threshold Drift + +Track if adaptive thresholds are drifting significantly: + +```yaml +automation: + - alias: "Alert on significant threshold change" + trigger: + - platform: state + entity_id: sensor.roode32_max_zone_0 + condition: + - condition: template + value_template: > + {{ (trigger.to_state.state | float - trigger.from_state.state | float) | abs > 100 }} + action: + - service: notify.mobile_app + data: + title: "Roode Threshold Alert" + message: > + Entry zone threshold changed from {{ trigger.from_state.state }}mm + to {{ trigger.to_state.state }}mm +``` + +## ESPHome Services + +Roode exposes a recalibration service that can be called from Home Assistant: + +```yaml +# In Developer Tools > Services +service: esphome.roode32_recalibrate +``` + +Or in an automation: + +```yaml +automation: + - alias: "Recalibrate Roode daily" + trigger: + - platform: time + at: "03:00:00" + condition: + - condition: state + entity_id: binary_sensor.roode32_presence + state: "off" + for: + minutes: 10 + action: + - service: esphome.roode32_recalibrate +``` + +## Dashboard Cards + +### Simple Counter Card + +```yaml +type: entities +entities: + - entity: number.roode32_people_counter + name: People in Room + - entity: binary_sensor.roode32_presence + name: Motion Detected + - entity: text_sensor.roode32_last_direction + name: Last Event +``` + +### Detailed Sensor Card + +```yaml +type: entities +title: Roode Sensor Details +entities: + - entity: number.roode32_people_counter + - entity: text_sensor.roode32_last_direction + - type: section + label: Zone 0 (Entry) + - entity: sensor.roode32_distance_zone_0 + - entity: sensor.roode32_max_zone_0 + - entity: sensor.roode32_min_zone_0 + - type: section + label: Zone 1 (Exit) + - entity: sensor.roode32_distance_zone_1 + - entity: sensor.roode32_max_zone_1 + - entity: sensor.roode32_min_zone_1 + - type: section + label: System + - entity: text_sensor.roode32_version + - entity: sensor.roode32_sensor_status +``` diff --git a/peopleCounter32.yaml b/peopleCounter32.yaml index e7e7faa0..851fc82d 100644 --- a/peopleCounter32.yaml +++ b/peopleCounter32.yaml @@ -35,16 +35,6 @@ api: - service: recalibrate then: - lambda: "id(roode_platform)->recalibration();" - - service: set_max_threshold - variables: - newThreshold: int - then: - - lambda: "id(roode_platform)->set_max_threshold_percentage(newThreshold);id(roode_platform)->recalibration();" - - service: set_min_threshold - variables: - newThreshold: int - then: - - lambda: "id(roode_platform)->set_min_threshold_percentage(newThreshold);id(roode_platform)->recalibration();" ota: password: !secret ota_password @@ -84,6 +74,16 @@ roode: detection_thresholds: # defaults for both zones. These also default to 0% & 85% as previously done. # min: 234mm # absolute distance max: 85% # percent based on idle distance + + # Robustness features (v1.6.0+) + # Reset state machine if someone enters but turns back without completing crossing + path_tracking_timeout: 3s + # Continuously adjust thresholds for environmental drift (temperature, lighting) + adaptive_threshold: + enabled: true + update_interval: 60s + alpha: 0.05 + # The people counting algorithm works by splitting the sensor's capability reading area into two zones. # This allows for detecting whether a crossing is an entry or exit based on which zones was crossed first. zones: diff --git a/peopleCounter8266.yaml b/peopleCounter8266.yaml index 9e134ee4..feaa5ef1 100644 --- a/peopleCounter8266.yaml +++ b/peopleCounter8266.yaml @@ -72,6 +72,16 @@ roode: detection_thresholds: # defaults for both zones. These also default to 0% & 85% as previously done. # min: 234mm # absolute distance max: 85% # percent based on idle distance + + # Robustness features (v1.6.0+) + # Reset state machine if someone enters but turns back without completing crossing + path_tracking_timeout: 3s + # Continuously adjust thresholds for environmental drift (temperature, lighting) + adaptive_threshold: + enabled: true + update_interval: 60s + alpha: 0.05 + # The people counting algorithm works by splitting the sensor's capability reading area into two zones. # This allows for detecting whether a crossing is an entry or exit based on which zones was crossed first. zones: