From 41c3bbff25cac5a04c96e4820846e9ca94d4574e Mon Sep 17 00:00:00 2001 From: will-tm <1313292+will-tm@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:05:46 -0400 Subject: [PATCH] [vl53l1x] Add VL53L1X time-of-flight distance sensor (#13605) --- CODEOWNERS | 1 + esphome/components/vl53l1x/LICENSE.txt | 36 ++ esphome/components/vl53l1x/__init__.py | 1 + esphome/components/vl53l1x/sensor.py | 170 +++++++ esphome/components/vl53l1x/vl53l1x.cpp | 436 ++++++++++++++++++ esphome/components/vl53l1x/vl53l1x.h | 65 +++ tests/components/vl53l1x/common.yaml | 17 + tests/components/vl53l1x/test.esp32-idf.yaml | 3 + .../components/vl53l1x/test.esp8266-ard.yaml | 3 + 9 files changed, 732 insertions(+) create mode 100644 esphome/components/vl53l1x/LICENSE.txt create mode 100644 esphome/components/vl53l1x/__init__.py create mode 100644 esphome/components/vl53l1x/sensor.py create mode 100644 esphome/components/vl53l1x/vl53l1x.cpp create mode 100644 esphome/components/vl53l1x/vl53l1x.h create mode 100644 tests/components/vl53l1x/common.yaml create mode 100644 tests/components/vl53l1x/test.esp32-idf.yaml create mode 100644 tests/components/vl53l1x/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 54cfa5766f0..445da1c45e0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -697,6 +697,7 @@ esphome/components/veml3235/* @kbx81 esphome/components/veml7700/* @latonita esphome/components/version/* @esphome/core esphome/components/vevor_dtx/* @Bofr5555 +esphome/components/vl53l1x/* @will-tm esphome/components/voice_assistant/* @jesserockz @kahrendt esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher diff --git a/esphome/components/vl53l1x/LICENSE.txt b/esphome/components/vl53l1x/LICENSE.txt new file mode 100644 index 00000000000..2b4ec6d3b4f --- /dev/null +++ b/esphome/components/vl53l1x/LICENSE.txt @@ -0,0 +1,36 @@ +The register sequence and the configuration, distance mode and timing budget tables in this +component come from the VL53L1X Ultra Lite Driver (STSW-IMG009) published by STMicroelectronics. +Please see the top-level LICENSE.txt for information about ESPHome's license. ST's license for +the Ultra Lite Driver is included below. + +================================================================= + +Copyright (c) 2017, STMicroelectronics - All Rights Reserved + +This file is part of VL53L1 Core and is dual licensed, either 'STMicroelectronics Proprietary +license' or 'BSD 3-clause "New" or "Revised" License', at your option. + +BSD 3-clause "New" or "Revised" License + +Redistribution and use in source and binary forms, with or without modification, are permitted +provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of + conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of + conditions and the following disclaimer in the documentation and/or other materials provided + with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to + endorse or promote products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/esphome/components/vl53l1x/__init__.py b/esphome/components/vl53l1x/__init__.py new file mode 100644 index 00000000000..034d6aa187f --- /dev/null +++ b/esphome/components/vl53l1x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "vl53l1x" diff --git a/esphome/components/vl53l1x/sensor.py b/esphome/components/vl53l1x/sensor.py new file mode 100644 index 00000000000..612ee864081 --- /dev/null +++ b/esphome/components/vl53l1x/sensor.py @@ -0,0 +1,170 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, sensor +from esphome.components.i2c import CONF_I2C_ID +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_ENABLE_PIN, + CONF_OFFSET, + CONF_PLATFORM, + ICON_ARROW_EXPAND_VERTICAL, + STATE_CLASS_MEASUREMENT, + UNIT_METER, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import DOMAIN + +CODEOWNERS = ["@will-tm"] +DEPENDENCIES = ["i2c"] + +vl53l1x_ns = cg.esphome_ns.namespace("vl53l1x") +VL53L1XSensor = vl53l1x_ns.class_( + "VL53L1XSensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice +) + +DistanceMode = vl53l1x_ns.enum("DistanceMode") +DISTANCE_MODE_OPTIONS = DistanceMode.options( + "short", "medium", "long", prefix="DISTANCE_MODE_" +) + +CONF_DISTANCE_MODE = "distance_mode" +CONF_TIMING_BUDGET = "timing_budget" +CONF_SIGNAL_RATE_LIMIT = "signal_rate_limit" +CONF_ROI_WIDTH = "roi_width" +CONF_ROI_HEIGHT = "roi_height" +CONF_ROI_CENTER = "roi_center" + +DEFAULT_ADDRESS = 0x29 +# ROI centre the ULD uses for a ROI wider or taller than 10 SPADs +ROI_CENTER_LARGE = 199 +# Timing budgets in ms supported by the ST Ultra Lite Driver tables, per distance mode +TIMING_BUDGETS = { + "short": (15, 20, 33, 50, 100, 200, 500), + "medium": (20, 33, 50, 100, 200, 500), + "long": (20, 33, 50, 100, 200, 500), +} + + +def validate_address(config: ConfigType) -> ConfigType: + if config[CONF_ADDRESS] != DEFAULT_ADDRESS and CONF_ENABLE_PIN not in config: + raise cv.Invalid( + "Address other than 0x29 requires enable_pin definition to allow sensor " + "re-addressing. If you have multiple VL53L1X sensors on the same I2C bus, " + "each sensor must have an enable_pin defined." + ) + return config + + +def validate_timing_budget(config: ConfigType) -> ConfigType: + mode = config[CONF_DISTANCE_MODE] + budgets = TIMING_BUDGETS[mode] + if config[CONF_TIMING_BUDGET].total_milliseconds not in budgets: + raise cv.Invalid( + f"Timing budget for {mode} distance mode must be one of " + f"{', '.join(f'{b}ms' for b in budgets)}", + path=[CONF_TIMING_BUDGET], + ) + return config + + +def validate_roi(config: ConfigType) -> ConfigType: + if ( + (center := config.get(CONF_ROI_CENTER)) is not None + and center != ROI_CENTER_LARGE + and (config[CONF_ROI_WIDTH] > 10 or config[CONF_ROI_HEIGHT] > 10) + ): + raise cv.Invalid( + f"roi_center must be {ROI_CENTER_LARGE} when roi_width or roi_height " + "is larger than 10", + path=[CONF_ROI_CENTER], + ) + return config + + +def _final_validate(config: ConfigType) -> ConfigType: + # Every sensor boots at 0x29. With several on one bus each needs its own enable_pin so it + # can be held in reset while the others are re-addressed, and none may stay at 0x29, or a + # later sibling boots at the same address and the re-address write reaches both chips. + full_config = fv.full_config.get() + siblings = [ + conf + for conf in full_config.get(sensor.DOMAIN, []) + if conf.get(CONF_PLATFORM) == DOMAIN + and conf[CONF_I2C_ID] == config[CONF_I2C_ID] + ] + if len(siblings) < 2: + return config + if CONF_ENABLE_PIN not in config: + raise cv.Invalid( + "Every VL53L1X sensor on a shared I2C bus needs an enable_pin", + path=[CONF_ENABLE_PIN], + ) + address = config[CONF_ADDRESS] + if address == DEFAULT_ADDRESS: + raise cv.Invalid( + f"VL53L1X sensors sharing an I2C bus must all be re-addressed away from " + f"0x{DEFAULT_ADDRESS:02X}", + path=[CONF_ADDRESS], + ) + if sum(conf[CONF_ADDRESS] == address for conf in siblings) > 1: + raise cv.Invalid( + f"Address 0x{address:02X} is used by more than one VL53L1X sensor on this bus", + path=[CONF_ADDRESS], + ) + return config + + +CONFIG_SCHEMA = cv.All( + sensor.sensor_schema( + VL53L1XSensor, + unit_of_measurement=UNIT_METER, + accuracy_decimals=3, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_ARROW_EXPAND_VERTICAL, + ) + .extend( + { + cv.Optional(CONF_DISTANCE_MODE, default="long"): cv.enum( + DISTANCE_MODE_OPTIONS, lower=True + ), + cv.Optional( + CONF_TIMING_BUDGET, default="50ms" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_ENABLE_PIN): pins.gpio_output_pin_schema, + # Minimum return signal rate in MCPS (mega counts per second) + cv.Optional(CONF_SIGNAL_RATE_LIMIT, default=1.0): cv.float_range( + min=0.0, max=512.0, min_included=False, max_included=False + ), + cv.Optional(CONF_ROI_WIDTH, default=16): cv.int_range(min=4, max=16), + cv.Optional(CONF_ROI_HEIGHT, default=16): cv.int_range(min=4, max=16), + # Defaults to the chip's optical centre, or 199 for a ROI larger than 10x10 + cv.Optional(CONF_ROI_CENTER): cv.int_range(min=1, max=255), + cv.Optional(CONF_OFFSET, default=0): cv.int_range(min=-1024, max=1023), + } + ) + .extend(cv.polling_component_schema("1s")) + .extend(i2c.i2c_device_schema(DEFAULT_ADDRESS)), + validate_address, + validate_timing_budget, + validate_roi, +) + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = await i2c.new_i2c_component(config) + await sensor.register_sensor(var, config) + + cg.add(var.set_distance_mode(config[CONF_DISTANCE_MODE])) + cg.add(var.set_timing_budget(config[CONF_TIMING_BUDGET].total_milliseconds)) + cg.add(var.set_signal_rate_limit(config[CONF_SIGNAL_RATE_LIMIT])) + cg.add(var.set_roi(config[CONF_ROI_WIDTH], config[CONF_ROI_HEIGHT])) + if (roi_center := config.get(CONF_ROI_CENTER)) is not None: + cg.add(var.set_roi_center(roi_center)) + cg.add(var.set_offset(config[CONF_OFFSET])) + + await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) diff --git a/esphome/components/vl53l1x/vl53l1x.cpp b/esphome/components/vl53l1x/vl53l1x.cpp new file mode 100644 index 00000000000..fa3363d1dbd --- /dev/null +++ b/esphome/components/vl53l1x/vl53l1x.cpp @@ -0,0 +1,436 @@ +// VL53L1X time-of-flight distance sensor, ported from ST's VL53L1X Ultra Lite Driver (ULD). + +#include "vl53l1x.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +#include + +namespace esphome::vl53l1x { + +ESPHOME_LOG_TAG(TAG, "vl53l1x"); + +static constexpr uint8_t DEFAULT_ADDRESS = 0x29; +static constexpr uint16_t MODEL_ID = 0xEACC; + +static constexpr uint16_t I2C_DEVICE_ADDRESS = 0x0001; +static constexpr uint16_t VHV_CONFIG_TIMEOUT_MACROP_LOOP_BOUND = 0x0008; +static constexpr uint16_t VHV_CONFIG_INIT = 0x000B; +static constexpr uint16_t ALGO_PART_TO_PART_RANGE_OFFSET_MM = 0x001E; +static constexpr uint16_t MM_CONFIG_INNER_OFFSET_MM = 0x0020; +static constexpr uint16_t MM_CONFIG_OUTER_OFFSET_MM = 0x0022; +static constexpr uint16_t DEFAULT_CONFIG_START = 0x002D; +static constexpr uint16_t GPIO_TIO_HV_STATUS = 0x0031; +static constexpr uint16_t PHASECAL_CONFIG_TIMEOUT_MACROP = 0x004B; +static constexpr uint16_t RANGE_CONFIG_TIMEOUT_MACROP_A_HI = 0x005E; +static constexpr uint16_t RANGE_CONFIG_VCSEL_PERIOD_A = 0x0060; +static constexpr uint16_t RANGE_CONFIG_TIMEOUT_MACROP_B_HI = 0x0061; +static constexpr uint16_t RANGE_CONFIG_VCSEL_PERIOD_B = 0x0063; +static constexpr uint16_t RANGE_CONFIG_MIN_COUNT_RATE_RTN_LIMIT_MCPS = 0x0066; +static constexpr uint16_t RANGE_CONFIG_VALID_PHASE_HIGH = 0x0069; +static constexpr uint16_t SD_CONFIG_WOI_SD0 = 0x0078; +static constexpr uint16_t SD_CONFIG_INITIAL_PHASE_SD0 = 0x007A; +static constexpr uint16_t ROI_CONFIG_USER_ROI_CENTRE_SPAD = 0x007F; +static constexpr uint16_t ROI_CONFIG_USER_ROI_REQUESTED_GLOBAL_XY_SIZE = 0x0080; +static constexpr uint16_t SYSTEM_INTERRUPT_CLEAR = 0x0086; +static constexpr uint16_t SYSTEM_MODE_START = 0x0087; +static constexpr uint16_t RESULT_RANGE_STATUS = 0x0089; +static constexpr uint16_t FIRMWARE_SYSTEM_STATUS = 0x00E5; +static constexpr uint16_t IDENTIFICATION_MODEL_ID = 0x010F; +static constexpr uint16_t ROI_CONFIG_MODE_ROI_CENTRE_SPAD = 0x013E; + +static constexpr uint8_t MODE_START_SINGLE_SHOT = 0x10; +// ROI centre for a ROI wider or taller than 10 SPADs (ULD SetROI) +static constexpr uint8_t ROI_CENTER_LARGE = 199; +// RESULT_RANGE_STATUS through RESULT_FINAL_CROSSTALK_CORRECTED_RANGE_MM_SD0 (0x0096) +static constexpr size_t RESULT_LENGTH = 15; +// Raw range status that the ULD reports as a valid range +static constexpr uint8_t RANGE_STATUS_VALID = 9; + +// ULD VL51L1X_DEFAULT_CONFIGURATION, registers 0x2D to 0x87 +static const uint8_t DEFAULT_CONFIG[] PROGMEM = { + 0x00, // 0x2D: reserved + 0x01, // 0x2E: I2C pads, bit 0 set for pull-up at AVDD + 0x01, // 0x2F: GPIO pads, bit 0 set for pull-up at AVDD + 0x01, // 0x30: GPIO_HV_MUX_CTRL (bit 4 clear: data ready reads high) + 0x02, // 0x31: reserved + 0x00, // 0x32: reserved + 0x02, // 0x33: reserved + 0x08, // 0x34: reserved + 0x00, // 0x35: reserved + 0x08, // 0x36: reserved + 0x10, // 0x37: reserved + 0x01, // 0x38: reserved + 0x01, // 0x39: reserved + 0x00, // 0x3A: reserved + 0x00, // 0x3B: reserved + 0x00, // 0x3C: reserved + 0x00, // 0x3D: reserved + 0xFF, // 0x3E: reserved + 0x00, // 0x3F: reserved + 0x0F, // 0x40: reserved + 0x00, // 0x41: reserved + 0x00, // 0x42: reserved + 0x00, // 0x43: reserved + 0x00, // 0x44: reserved + 0x00, // 0x45: reserved + 0x20, // 0x46: CAL_CONFIG_SPAD_DC_TARGET_COUNT + 0x0B, // 0x47: CAL_CONFIG_VCSEL_START + 0x00, // 0x48: reserved + 0x00, // 0x49: reserved + 0x02, // 0x4A: reserved + 0x14, // 0x4B: PHASECAL_CONFIG_TIMEOUT_MACROP + 0x21, // 0x4C: reserved + 0x00, // 0x4D: PHASECAL_CONFIG_OVERRIDE + 0x00, // 0x4E: reserved + 0x05, // 0x4F: DSS_CONFIG_ROI_MODE_CONTROL + 0x00, // 0x50: SYSTEM_THRESH_RATE_HIGH + 0x00, // 0x51: SYSTEM_THRESH_RATE_HIGH + 0x00, // 0x52: SYSTEM_THRESH_RATE_LOW + 0x00, // 0x53: SYSTEM_THRESH_RATE_LOW + 0xC8, // 0x54: DSS_CONFIG_MANUAL_EFFECTIVE_SPADS_SELECT (200 << 8) + 0x00, // 0x55: DSS_CONFIG_MANUAL_EFFECTIVE_SPADS_SELECT + 0x00, // 0x56: reserved + 0x38, // 0x57: DSS_CONFIG_APERTURE_ATTENUATION + 0xFF, // 0x58: reserved + 0x01, // 0x59: reserved + 0x00, // 0x5A: reserved + 0x08, // 0x5B: reserved + 0x00, // 0x5C: reserved + 0x00, // 0x5D: reserved + 0x01, // 0x5E: RANGE_CONFIG_TIMEOUT_MACROP_A_HI + 0xCC, // 0x5F: RANGE_CONFIG_TIMEOUT_MACROP_A_LO + 0x0F, // 0x60: RANGE_CONFIG_VCSEL_PERIOD_A + 0x01, // 0x61: RANGE_CONFIG_TIMEOUT_MACROP_B_HI + 0xF1, // 0x62: RANGE_CONFIG_TIMEOUT_MACROP_B_LO + 0x0D, // 0x63: RANGE_CONFIG_VCSEL_PERIOD_B + 0x01, // 0x64: RANGE_CONFIG_SIGMA_THRESH + 0x68, // 0x65: RANGE_CONFIG_SIGMA_THRESH (360) + 0x00, // 0x66: RANGE_CONFIG_MIN_COUNT_RATE_RTN_LIMIT_MCPS + 0x80, // 0x67: RANGE_CONFIG_MIN_COUNT_RATE_RTN_LIMIT_MCPS (1.0 MCPS) + 0x08, // 0x68: reserved + 0xB8, // 0x69: RANGE_CONFIG_VALID_PHASE_HIGH + 0x00, // 0x6A: reserved + 0x00, // 0x6B: reserved + 0x00, // 0x6C: SYSTEM_INTERMEASUREMENT_PERIOD + 0x00, // 0x6D: SYSTEM_INTERMEASUREMENT_PERIOD + 0x0F, // 0x6E: SYSTEM_INTERMEASUREMENT_PERIOD + 0x89, // 0x6F: SYSTEM_INTERMEASUREMENT_PERIOD + 0x00, // 0x70: reserved + 0x00, // 0x71: SYSTEM_THRESH_HIGH + 0x00, // 0x72: SYSTEM_THRESH_HIGH + 0x00, // 0x73: SYSTEM_THRESH_LOW + 0x00, // 0x74: SYSTEM_THRESH_LOW + 0x00, // 0x75: reserved + 0x00, // 0x76: reserved + 0x01, // 0x77: reserved + 0x0F, // 0x78: SD_CONFIG_WOI_SD0 + 0x0D, // 0x79: SD_CONFIG_WOI_SD1 + 0x0E, // 0x7A: SD_CONFIG_INITIAL_PHASE_SD0 + 0x0E, // 0x7B: SD_CONFIG_INITIAL_PHASE_SD1 + 0x00, // 0x7C: reserved + 0x00, // 0x7D: reserved + 0x02, // 0x7E: reserved + 0xC7, // 0x7F: ROI_CONFIG_USER_ROI_CENTRE_SPAD + 0xFF, // 0x80: ROI_CONFIG_USER_ROI_REQUESTED_GLOBAL_XY_SIZE + 0x9B, // 0x81: SYSTEM_SEQUENCE_CONFIG + 0x00, // 0x82: reserved + 0x00, // 0x83: reserved + 0x00, // 0x84: reserved + 0x01, // 0x85: reserved + 0x00, // 0x86: SYSTEM_INTERRUPT_CLEAR + 0x00, // 0x87: SYSTEM_MODE_START +}; + +// ULD SetDistanceMode values, indexed by DistanceMode. The ULD only knows short and long; the +// medium row is the standard ranging preset of ST's full VL53L1 API. +struct DistanceModeConfig { + uint8_t phasecal_timeout; + uint8_t vcsel_period_a; + uint8_t vcsel_period_b; + uint8_t valid_phase_high; + uint16_t woi_sd0; + uint16_t initial_phase_sd0; +}; +static const DistanceModeConfig DISTANCE_MODE_CONFIGS[] PROGMEM = { + {0x14, 0x07, 0x05, 0x38, 0x0705, 0x0606}, // short + {0x0B, 0x0B, 0x09, 0x78, 0x0B09, 0x0A0A}, // medium + {0x0A, 0x0F, 0x0D, 0xB8, 0x0F0D, 0x0E0E}, // long +}; + +// ULD SetTimingBudgetInMs values +struct TimingBudgetConfig { + uint16_t budget_ms; + uint16_t macrop_a; + uint16_t macrop_b; +}; +static const TimingBudgetConfig SHORT_TIMING_BUDGETS[] PROGMEM = { + {15, 0x001D, 0x0027}, {20, 0x0051, 0x006E}, {33, 0x00D6, 0x006E}, {50, 0x01AE, 0x01E8}, + {100, 0x02E1, 0x0388}, {200, 0x03E1, 0x0496}, {500, 0x0591, 0x05C1}, +}; +static const TimingBudgetConfig LONG_TIMING_BUDGETS[] PROGMEM = { + {20, 0x001E, 0x0022}, {33, 0x0060, 0x006E}, {50, 0x00AD, 0x00C6}, + {100, 0x01CC, 0x01EA}, {200, 0x02D9, 0x02F8}, {500, 0x048F, 0x04A4}, +}; +// The ULD has no medium table: these are the long values scaled by the VCSEL period ratio +// (16/12 for A, 14/10 for B), since the macro period grows with the period and the timeouts +// count macro periods. The data-ready poll tolerates the small error this leaves. +static const TimingBudgetConfig MEDIUM_TIMING_BUDGETS[] PROGMEM = { + {20, 0x0028, 0x0030}, {33, 0x0080, 0x009A}, {50, 0x00E7, 0x018B}, + {100, 0x0288, 0x02A4}, {200, 0x0390, 0x03AD}, {500, 0x04BE, 0x04E5}, +}; + +// Copies the entry for budget_ms out of flash; false when the mode has no such budget. +static bool find_timing_budget(DistanceMode mode, uint16_t budget_ms, TimingBudgetConfig *out) { + const TimingBudgetConfig *table = LONG_TIMING_BUDGETS; + size_t count = std::size(LONG_TIMING_BUDGETS); + if (mode == DISTANCE_MODE_SHORT) { + table = SHORT_TIMING_BUDGETS; + count = std::size(SHORT_TIMING_BUDGETS); + } else if (mode == DISTANCE_MODE_MEDIUM) { + table = MEDIUM_TIMING_BUDGETS; + count = std::size(MEDIUM_TIMING_BUDGETS); + } + for (size_t i = 0; i < count; i++) { + progmem_memcpy(out, &table[i], sizeof(TimingBudgetConfig)); + if (out->budget_ms == budget_ms) + return true; + } + return false; +} + +VL53L1XSensor *VL53L1XSensor::first_sensor = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +VL53L1XSensor::VL53L1XSensor() { + this->next_ = VL53L1XSensor::first_sensor; + VL53L1XSensor::first_sensor = this; +} + +void VL53L1XSensor::setup() { + // The first sensor to set up holds every sensor with an enable pin in reset, so each one + // comes back at the default address only when its own setup() releases it. + if (VL53L1XSensor::first_sensor != nullptr) { + for (auto *other = VL53L1XSensor::first_sensor; other != nullptr; other = other->next_) { + if (other->enable_pin_ != nullptr) { + other->enable_pin_->setup(); + other->enable_pin_->digital_write(false); + } + } + VL53L1XSensor::first_sensor = nullptr; + } + + const uint8_t final_address = this->address_; + if (this->enable_pin_ != nullptr) { + this->enable_pin_->digital_write(true); + delay(2); + } + this->set_i2c_address(DEFAULT_ADDRESS); + + if (!this->wait_for_boot_()) { + this->fail_at_default_address_(LOG_STR("Boot timeout")); + return; + } + + uint16_t model_id; + if (!this->read_word16_(IDENTIFICATION_MODEL_ID, &model_id)) { + this->fail_at_default_address_(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (model_id != MODEL_ID) { + ESP_LOGE(TAG, "Wrong model ID 0x%04X", model_id); + this->fail_at_default_address_(LOG_STR("Wrong model ID")); + return; + } + + if (final_address != DEFAULT_ADDRESS) { + if (!this->write_byte16_(I2C_DEVICE_ADDRESS, final_address & 0x7F)) { + this->fail_at_default_address_(LOG_STR("Setting address failed")); + return; + } + this->set_i2c_address(final_address); + } + + if (!this->configure_()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // The ULD ranges once during init to calibrate; loop() finishes that from the first result. + if (!this->start_ranging_()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->measurement_start_ = millis(); +} + +// A sensor that failed before it was re-addressed is still answering at 0x29, which would +// collide with the next sibling's bring-up, so hold it in reset. +void VL53L1XSensor::fail_at_default_address_(const LogString *message) { + if (this->enable_pin_ != nullptr) + this->enable_pin_->digital_write(false); + this->mark_failed(message); +} + +bool VL53L1XSensor::wait_for_boot_() { + for (uint8_t i = 0; i < 100; i++) { + uint8_t status; + if (this->read_byte16_(FIRMWARE_SYSTEM_STATUS, &status) && (status & 0x01)) + return true; + delay(1); + } + return false; +} + +bool VL53L1XSensor::configure_() { + TimingBudgetConfig budget; + if (!find_timing_budget(this->distance_mode_, this->budget_ms_, &budget)) { + ESP_LOGE(TAG, "Unsupported timing budget %u ms", this->budget_ms_); + return false; + } + DistanceModeConfig mode; + progmem_memcpy(&mode, &DISTANCE_MODE_CONFIGS[this->distance_mode_], sizeof(mode)); + + uint8_t config[sizeof(DEFAULT_CONFIG)]; + progmem_memcpy(config, DEFAULT_CONFIG, sizeof(config)); + // ULD SetROI: a ROI of at most 10x10 is centred on the part's calibrated optical centre. + uint8_t center = this->roi_center_; + if (center == 0) { + center = ROI_CENTER_LARGE; + if (this->roi_width_ <= 10 && this->roi_height_ <= 10 && + !this->read_byte16_(ROI_CONFIG_MODE_ROI_CENTRE_SPAD, ¢er)) { + return false; + } + } + // The register address auto-increments, so the whole table goes in one write. + return this->write_register16(DEFAULT_CONFIG_START, config, sizeof(config)) == i2c::ERROR_OK && + // SetDistanceMode + this->write_byte16_(PHASECAL_CONFIG_TIMEOUT_MACROP, mode.phasecal_timeout) && + this->write_byte16_(RANGE_CONFIG_VCSEL_PERIOD_A, mode.vcsel_period_a) && + this->write_byte16_(RANGE_CONFIG_VCSEL_PERIOD_B, mode.vcsel_period_b) && + this->write_byte16_(RANGE_CONFIG_VALID_PHASE_HIGH, mode.valid_phase_high) && + this->write_word16_(SD_CONFIG_WOI_SD0, mode.woi_sd0) && + this->write_word16_(SD_CONFIG_INITIAL_PHASE_SD0, mode.initial_phase_sd0) && + // SetTimingBudgetInMs + this->write_word16_(RANGE_CONFIG_TIMEOUT_MACROP_A_HI, budget.macrop_a) && + this->write_word16_(RANGE_CONFIG_TIMEOUT_MACROP_B_HI, budget.macrop_b) && + // SetOffset + this->write_word16_(ALGO_PART_TO_PART_RANGE_OFFSET_MM, static_cast(this->offset_mm_ * 4)) && + this->write_word16_(MM_CONFIG_INNER_OFFSET_MM, 0) && this->write_word16_(MM_CONFIG_OUTER_OFFSET_MM, 0) && + // SetROI and SetROICenter + this->write_byte16_(ROI_CONFIG_USER_ROI_CENTRE_SPAD, center) && + this->write_byte16_(ROI_CONFIG_USER_ROI_REQUESTED_GLOBAL_XY_SIZE, + static_cast(((this->roi_height_ - 1) << 4) | (this->roi_width_ - 1))) && + // SetSignalThreshold + this->write_word16_(RANGE_CONFIG_MIN_COUNT_RATE_RTN_LIMIT_MCPS, this->signal_rate_limit_); +} + +bool VL53L1XSensor::start_ranging_() { return this->write_byte16_(SYSTEM_MODE_START, MODE_START_SINGLE_SHOT); } + +void VL53L1XSensor::update() { + if (this->is_in_loop_state()) { + ESP_LOGD(TAG, "Measurement still running"); + return; + } + if (!this->start_ranging_()) { + ESP_LOGW(TAG, "Starting measurement failed"); + this->status_set_warning(); + this->publish_state(NAN); + return; + } + this->measurement_start_ = App.get_loop_component_start_time(); + this->enable_loop(); +} + +void VL53L1XSensor::loop() { + const uint32_t elapsed = App.get_loop_component_start_time() - this->measurement_start_; + if (elapsed < this->budget_ms_) + return; + + uint8_t gpio_status; + if (!this->read_byte16_(GPIO_TIO_HV_STATUS, &gpio_status) || (gpio_status & 0x01) == 0) { + if (elapsed > static_cast(this->budget_ms_) * 2 + 100) { + ESP_LOGW(TAG, "Measurement timed out"); + this->status_set_warning(); + this->publish_state(NAN); + this->disable_loop(); + } + return; + } + + uint8_t result[RESULT_LENGTH]; + if (this->read_register16(RESULT_RANGE_STATUS, result, sizeof(result)) != i2c::ERROR_OK || + !this->write_byte16_(SYSTEM_INTERRUPT_CLEAR, 0x01)) { + ESP_LOGW(TAG, "Reading result failed"); + this->status_set_warning(); + this->publish_state(NAN); + this->disable_loop(); + return; + } + this->disable_loop(); + + if (this->first_result_) { + // ULD SensorInit: after the first ranging, start VHV calibration from the previous temperature. + if (!this->write_byte16_(VHV_CONFIG_TIMEOUT_MACROP_LOOP_BOUND, 0x09) || !this->write_byte16_(VHV_CONFIG_INIT, 0)) { + this->status_set_warning(); + return; + } + this->first_result_ = false; + return; + } + + this->status_clear_warning(); + const uint8_t range_status = result[0] & 0x1F; + if (range_status != RANGE_STATUS_VALID) { + ESP_LOGD(TAG, "Range status %u", range_status); + this->publish_state(NAN); + return; + } + const uint16_t distance_mm = encode_uint16(result[13], result[14]); + this->publish_state(distance_mm / 1000.0f); +} + +// Indexed by DistanceMode +PROGMEM_STRING_TABLE(DistanceModeStrings, "short", "medium", "long"); + +void VL53L1XSensor::dump_config() { + LOG_SENSOR("", "VL53L1X", this); + LOG_UPDATE_INTERVAL(this); + LOG_I2C_DEVICE(this); + LOG_PIN(" Enable Pin: ", this->enable_pin_); + ESP_LOGCONFIG(TAG, + " Distance Mode: %s\n" + " Timing Budget: %u ms\n" + " ROI: %ux%u\n" + " Offset: %d mm", + LOG_STR_ARG(DistanceModeStrings::get_log_str(this->distance_mode_, DistanceModeStrings::LAST_INDEX)), + this->budget_ms_, this->roi_width_, this->roi_height_, this->offset_mm_); + if (this->roi_center_ != 0) { + ESP_LOGCONFIG(TAG, " ROI Center: %u", this->roi_center_); + } +} + +bool VL53L1XSensor::write_byte16_(uint16_t reg, uint8_t value) { + return this->write_register16(reg, &value, 1) == i2c::ERROR_OK; +} + +bool VL53L1XSensor::write_word16_(uint16_t reg, uint16_t value) { + const uint8_t data[2] = {static_cast(value >> 8), static_cast(value)}; + return this->write_register16(reg, data, 2) == i2c::ERROR_OK; +} + +bool VL53L1XSensor::read_byte16_(uint16_t reg, uint8_t *value) { + return this->read_register16(reg, value, 1) == i2c::ERROR_OK; +} + +bool VL53L1XSensor::read_word16_(uint16_t reg, uint16_t *value) { + uint8_t data[2]; + if (this->read_register16(reg, data, 2) != i2c::ERROR_OK) + return false; + *value = encode_uint16(data[0], data[1]); + return true; +} + +} // namespace esphome::vl53l1x diff --git a/esphome/components/vl53l1x/vl53l1x.h b/esphome/components/vl53l1x/vl53l1x.h new file mode 100644 index 00000000000..8deb1c4274f --- /dev/null +++ b/esphome/components/vl53l1x/vl53l1x.h @@ -0,0 +1,65 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/gpio.h" + +namespace esphome::vl53l1x { + +// Values index the distance mode and timing budget tables in the .cpp. +enum DistanceMode : uint8_t { + DISTANCE_MODE_SHORT = 0, + DISTANCE_MODE_MEDIUM = 1, + DISTANCE_MODE_LONG = 2, +}; + +class VL53L1XSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { + public: + VL53L1XSensor(); + + void setup() override; + void loop() override; + void update() override; + void dump_config() override; + + void set_enable_pin(GPIOPin *pin) { this->enable_pin_ = pin; } + void set_distance_mode(DistanceMode mode) { this->distance_mode_ = mode; } + void set_timing_budget(uint16_t budget_ms) { this->budget_ms_ = budget_ms; } + // Rate in MCPS, stored as the register's 9.7 fixed point value. + void set_signal_rate_limit(float limit_mcps) { this->signal_rate_limit_ = static_cast(limit_mcps * 128); } + void set_offset(int16_t offset_mm) { this->offset_mm_ = offset_mm; } + void set_roi(uint8_t width, uint8_t height) { + this->roi_width_ = width; + this->roi_height_ = height; + } + void set_roi_center(uint8_t center) { this->roi_center_ = center; } + + protected: + void fail_at_default_address_(const LogString *message); + bool wait_for_boot_(); + bool configure_(); + bool start_ranging_(); + + bool write_byte16_(uint16_t reg, uint8_t value); + bool write_word16_(uint16_t reg, uint16_t value); + bool read_byte16_(uint16_t reg, uint8_t *value); + bool read_word16_(uint16_t reg, uint16_t *value); + + // Every instance, so the first setup() can hold all sensors with an enable pin in reset. + static VL53L1XSensor *first_sensor; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + + VL53L1XSensor *next_{nullptr}; + GPIOPin *enable_pin_{nullptr}; + uint32_t measurement_start_{0}; + uint16_t budget_ms_{50}; + uint16_t signal_rate_limit_{128}; + int16_t offset_mm_{0}; + uint8_t roi_width_{16}; + uint8_t roi_height_{16}; + uint8_t roi_center_{0}; // 0 selects the centre the way the ULD does + DistanceMode distance_mode_{DISTANCE_MODE_LONG}; + bool first_result_{true}; +}; + +} // namespace esphome::vl53l1x diff --git a/tests/components/vl53l1x/common.yaml b/tests/components/vl53l1x/common.yaml new file mode 100644 index 00000000000..40a07aaac88 --- /dev/null +++ b/tests/components/vl53l1x/common.yaml @@ -0,0 +1,17 @@ +sensor: + - platform: vl53l1x + i2c_id: i2c_bus + name: VL53L1X Distance + address: 0x31 + enable_pin: GPIO12 + - platform: vl53l1x + i2c_id: i2c_bus + name: VL53L1X Short Range + address: 0x30 + enable_pin: GPIO13 + distance_mode: medium + timing_budget: 33ms + roi_width: 8 + roi_height: 8 + offset: 5 + signal_rate_limit: 2.0 diff --git a/tests/components/vl53l1x/test.esp32-idf.yaml b/tests/components/vl53l1x/test.esp32-idf.yaml new file mode 100644 index 00000000000..f5562bc0ee4 --- /dev/null +++ b/tests/components/vl53l1x/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + vl53l1x: !include common.yaml diff --git a/tests/components/vl53l1x/test.esp8266-ard.yaml b/tests/components/vl53l1x/test.esp8266-ard.yaml new file mode 100644 index 00000000000..faa50e143b7 --- /dev/null +++ b/tests/components/vl53l1x/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + vl53l1x: !include common.yaml