[tfluna] New component for the TF-Luna Low Cost LiDAR Distance Sensor (#9199)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick+github@koston.org>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Craig Andrews
2026-09-25 23:55:14 +01:00
committed by GitHub
co-authored by Copilot J. Nick Koston pre-commit-ci-lite[bot] Jonathan Swoboda J. Nick Koston
parent 64388318e8
commit d5d0901e2c
15 changed files with 496 additions and 0 deletions
+1
View File
@@ -562,6 +562,7 @@ esphome/components/template/datetime/* @rfdarter
esphome/components/template/event/* @nohat
esphome/components/template/fan/* @ssieb
esphome/components/text/* @mauritskorse
esphome/components/tfluna/* @candrews
esphome/components/thermopro_ble/* @sittner
esphome/components/thermostat/* @kbx81
esphome/components/time/* @esphome/core
+34
View File
@@ -0,0 +1,34 @@
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
CODEOWNERS = ["@candrews"]
DEPENDENCIES = ["i2c"]
MULTI_CONF = True
tfluna_ns = cg.esphome_ns.namespace("tfluna")
TFLunaComponent = tfluna_ns.class_("TFLuna", cg.PollingComponent, i2c.I2CDevice)
CONF_TFLUNA_ID = "tfluna_id"
CONF_TIMESTAMP = "timestamp"
FACTORY_DEFAULT_ADDRESS = 0x10
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(TFLunaComponent),
}
)
.extend(cv.polling_component_schema("1s"))
.extend(i2c.i2c_device_schema(FACTORY_DEFAULT_ADDRESS))
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
@@ -0,0 +1,72 @@
import esphome.codegen as cg
from esphome.components import button
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_FACTORY_RESET,
CONF_RESTART,
DEVICE_CLASS_RESTART,
ENTITY_CATEGORY_CONFIG,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_RESTART,
ICON_RESTART_ALERT,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
from .. import CONF_TFLUNA_ID, FACTORY_DEFAULT_ADDRESS, TFLunaComponent, tfluna_ns
DEPENDENCIES = ["tfluna"]
ResetButton = tfluna_ns.class_("ResetButton", button.Button)
RestartButton = tfluna_ns.class_("RestartButton", button.Button)
CONFIG_SCHEMA = {
cv.GenerateID(CONF_TFLUNA_ID): cv.use_id(TFLunaComponent),
cv.Optional(CONF_FACTORY_RESET): button.button_schema(
ResetButton,
device_class=DEVICE_CLASS_RESTART,
entity_category=ENTITY_CATEGORY_CONFIG,
icon=ICON_RESTART_ALERT,
),
cv.Optional(CONF_RESTART): button.button_schema(
RestartButton,
device_class=DEVICE_CLASS_RESTART,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
icon=ICON_RESTART,
),
}
def _validate_factory_default_address(config: ConfigType) -> ConfigType:
if config.get(CONF_ADDRESS) != FACTORY_DEFAULT_ADDRESS:
raise cv.Invalid(
f"'{CONF_FACTORY_RESET}' requires the TF-Luna to use its factory default "
f"I2C address 0x{FACTORY_DEFAULT_ADDRESS:02X}, as a factory reset "
"restores that address",
path=[CONF_ADDRESS],
)
return config
def _final_validate(config: ConfigType) -> ConfigType:
if CONF_FACTORY_RESET in config:
fv.id_declaration_match_schema(_validate_factory_default_address)(
config[CONF_TFLUNA_ID]
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID])
if factory_reset_config := config.get(CONF_FACTORY_RESET):
b = await button.new_button(factory_reset_config)
await cg.register_parented(b, config[CONF_TFLUNA_ID])
cg.add(tfluna_component.set_reset_button(b))
if restart_config := config.get(CONF_RESTART):
b = await button.new_button(restart_config)
await cg.register_parented(b, config[CONF_TFLUNA_ID])
cg.add(tfluna_component.set_restart_button(b))
@@ -0,0 +1,7 @@
#include "reset_button.h"
namespace esphome::tfluna {
void ResetButton::press_action() { this->parent_->factory_reset(); }
} // namespace esphome::tfluna
@@ -0,0 +1,13 @@
#pragma once
#include "esphome/components/button/button.h"
#include "../tfluna.h"
namespace esphome::tfluna {
class ResetButton : public button::Button, public Parented<TFLuna> {
protected:
void press_action() override;
};
} // namespace esphome::tfluna
@@ -0,0 +1,7 @@
#include "restart_button.h"
namespace esphome::tfluna {
void RestartButton::press_action() { this->parent_->restart(); }
} // namespace esphome::tfluna
@@ -0,0 +1,13 @@
#pragma once
#include "esphome/components/button/button.h"
#include "../tfluna.h"
namespace esphome::tfluna {
class RestartButton : public button::Button, public Parented<TFLuna> {
protected:
void press_action() override;
};
} // namespace esphome::tfluna
+75
View File
@@ -0,0 +1,75 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_DISTANCE,
CONF_SIGNAL_STRENGTH,
CONF_TEMPERATURE,
DEVICE_CLASS_DISTANCE,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_SIGNAL_STRENGTH,
DEVICE_CLASS_TEMPERATURE,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_ARROW_EXPAND_VERTICAL,
ICON_SIGNAL,
ICON_THERMOMETER,
ICON_TIMER,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_CENTIMETER,
UNIT_MILLISECOND,
)
from esphome.types import ConfigType
from . import CONF_TFLUNA_ID, CONF_TIMESTAMP, TFLunaComponent
DEPENDENCIES = ["tfluna"]
CONFIG_SCHEMA = {
cv.GenerateID(CONF_TFLUNA_ID): cv.use_id(TFLunaComponent),
cv.Optional(CONF_DISTANCE): sensor.sensor_schema(
unit_of_measurement=UNIT_CENTIMETER,
icon=ICON_ARROW_EXPAND_VERTICAL,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DISTANCE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
icon=ICON_THERMOMETER,
accuracy_decimals=2,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema(
icon=ICON_SIGNAL,
accuracy_decimals=0,
device_class=DEVICE_CLASS_SIGNAL_STRENGTH,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_TIMESTAMP): sensor.sensor_schema(
icon=ICON_TIMER,
accuracy_decimals=0,
unit_of_measurement=UNIT_MILLISECOND,
device_class=DEVICE_CLASS_DURATION,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
async def to_code(config: ConfigType) -> None:
tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID])
if distance_config := config.get(CONF_DISTANCE):
sens = await sensor.new_sensor(distance_config)
cg.add(tfluna_component.set_distance_sensor(sens))
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(tfluna_component.set_temperature_sensor(sens))
if signal_strength_config := config.get(CONF_SIGNAL_STRENGTH):
sens = await sensor.new_sensor(signal_strength_config)
cg.add(tfluna_component.set_signal_strength_sensor(sens))
if timestamp_config := config.get(CONF_TIMESTAMP):
sens = await sensor.new_sensor(timestamp_config)
cg.add(tfluna_component.set_timestamp_sensor(sens))
+23
View File
@@ -0,0 +1,23 @@
import esphome.codegen as cg
from esphome.components import text_sensor
import esphome.config_validation as cv
from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP
from esphome.types import ConfigType
from . import CONF_TFLUNA_ID, TFLunaComponent
DEPENDENCIES = ["tfluna"]
CONFIG_SCHEMA = {
cv.GenerateID(CONF_TFLUNA_ID): cv.use_id(TFLunaComponent),
cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema(
entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP
),
}
async def to_code(config: ConfigType) -> None:
tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID])
if version_config := config.get(CONF_VERSION):
sens = await text_sensor.new_text_sensor(version_config)
cg.add(tfluna_component.set_version_text_sensor(sens))
+158
View File
@@ -0,0 +1,158 @@
#include "tfluna.h"
#include <cstddef>
#include "esphome/core/log.h"
#include "esphome/core/helpers.h"
namespace esphome::tfluna {
// see https://files.waveshare.com/upload/a/ac/SJ-PM-TF-Luna_A05_Product_Manual.pdf
static constexpr uint8_t SAVE_REGISTER = 0x20;
static constexpr uint8_t VERSION_REVISION_REGISTER = 0x0A;
static constexpr uint8_t DISTANCE_LOW_REGISTER = 0x00;
static constexpr uint8_t RESTORE_FACTORY_DEFAULTS_REGISTER = 0x29;
static constexpr uint8_t SHUTDOWN_REBOOT_REGISTER = 0x21;
static constexpr uint8_t MODE_REGISTER = 0x23;
static constexpr uint8_t MODE_TRIGGER = 0x01;
static constexpr uint8_t TRIGGER_ONESHOT_REGISTER = 0x24;
static constexpr uint8_t MAX_READ_ATTEMPTS = 5;
static constexpr uint8_t READ_RETRY_MS = 5;
static const char *const TAG = "tfluna";
static const char *const READ_RETRY_TIMEOUT = "read_retry";
void TFLuna::dump_config() {
ESP_LOGCONFIG(TAG, "TF-Luna (i2c):");
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
ESP_LOGCONFIG(TAG, " Firmware: %s", this->version_);
#ifdef USE_SENSOR
LOG_SENSOR(" ", "Distance:", this->distance_sensor_);
LOG_SENSOR(" ", "Temperature:", this->temperature_sensor_);
LOG_SENSOR(" ", "Signal Strength:", this->signal_strength_sensor_);
LOG_SENSOR(" ", "Timestamp:", this->timestamp_sensor_);
#endif
#ifdef USE_TEXT_SENSOR
LOG_TEXT_SENSOR(" ", "Version:", this->version_text_sensor_);
#endif
#ifdef USE_BUTTON
LOG_BUTTON(" ", "Factory Reset:", this->reset_button_);
LOG_BUTTON(" ", "Restart:", this->restart_button_);
#endif
}
void TFLuna::setup() {
uint8_t mode;
if (!this->read_byte(MODE_REGISTER, &mode)) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
this->mark_failed();
return;
}
if (mode != MODE_TRIGGER) {
if (!this->write_byte(MODE_REGISTER, MODE_TRIGGER)) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
this->mark_failed();
return;
}
if (!this->write_byte(SAVE_REGISTER, 1)) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
this->mark_failed();
return;
}
}
uint8_t raw_version[3];
if (!this->read_bytes(VERSION_REVISION_REGISTER, raw_version, sizeof(raw_version))) {
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
return;
} else {
snprintf(this->version_, sizeof(this->version_), "%d.%d.%d", raw_version[2], raw_version[1], raw_version[0]);
#ifdef USE_TEXT_SENSOR
if (this->version_text_sensor_ != nullptr) {
this->version_text_sensor_->publish_state(version_);
}
#endif
}
}
bool TFLuna::read_data_() {
uint8_t buf[8];
if (!this->read_bytes(DISTANCE_LOW_REGISTER, buf, sizeof(buf))) {
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
return false;
}
// Layout:
// buf[0..1] = distance (LE), buf[2..3] = signal (LE),
// buf[4..5] = temperature (LE), buf[6..7] = timestamp (LE)
uint16_t distance = encode_uint16(buf[1], buf[0]);
uint16_t signal_strength = encode_uint16(buf[3], buf[2]);
int16_t temperature_raw = encode_uint16(buf[5], buf[4]);
uint16_t timestamp = encode_uint16(buf[7], buf[6]);
if (timestamp == this->previous_timestamp_) {
return false;
}
#ifdef USE_SENSOR
if (this->timestamp_sensor_ != nullptr) {
this->timestamp_sensor_->publish_state(timestamp);
}
if (this->distance_sensor_ != nullptr) {
// distance is unreliable when strength is below 100 or saturated at 0xFFFF
this->distance_sensor_->publish_state(signal_strength < 100 || signal_strength == 0xFFFF ? NAN : distance);
}
if (this->temperature_sensor_ != nullptr) {
this->temperature_sensor_->publish_state(temperature_raw / (float) 100);
}
if (this->signal_strength_sensor_ != nullptr) {
this->signal_strength_sensor_->publish_state(signal_strength);
}
#endif
this->status_clear_warning();
this->previous_timestamp_ = timestamp;
return true;
}
void TFLuna::read_data_timeout_() {
if (this->read_data_()) {
this->attempt_ = 0;
} else {
if (this->attempt_ < MAX_READ_ATTEMPTS) {
this->attempt_++;
this->set_timeout(READ_RETRY_TIMEOUT, READ_RETRY_MS, [this]() { this->read_data_timeout_(); });
} else {
this->status_set_warning("Hung device, restarting...");
this->restart();
}
}
}
void TFLuna::update() {
this->cancel_timeout(READ_RETRY_TIMEOUT);
this->attempt_ = 0;
if (!this->write_byte(TRIGGER_ONESHOT_REGISTER, 0x01)) {
this->status_set_warning(ESP_LOG_MSG_COMM_FAIL);
return;
}
this->read_data_timeout_();
}
void TFLuna::factory_reset() {
if (!this->write_byte(RESTORE_FACTORY_DEFAULTS_REGISTER, 1)) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
return;
}
this->status_set_warning("Factory reset issued; waiting for device to become ready");
this->set_timeout("_setup", 100, [this]() { this->setup(); });
}
void TFLuna::restart() {
if (!this->write_byte(SHUTDOWN_REBOOT_REGISTER, 0x02)) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
return;
}
}
} // namespace esphome::tfluna
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include "esphome/core/defines.h"
#include "esphome/core/component.h"
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
#ifdef USE_TEXT_SENSOR
#include "esphome/components/text_sensor/text_sensor.h"
#endif
#ifdef USE_BUTTON
#include "esphome/components/button/button.h"
#endif
#include "esphome/components/i2c/i2c.h"
namespace esphome::tfluna {
class TFLuna : public i2c::I2CDevice, public PollingComponent {
#ifdef USE_SENSOR
SUB_SENSOR(distance)
SUB_SENSOR(temperature)
SUB_SENSOR(signal_strength)
SUB_SENSOR(timestamp)
#endif
#ifdef USE_TEXT_SENSOR
SUB_TEXT_SENSOR(version)
#endif
#ifdef USE_BUTTON
SUB_BUTTON(reset)
SUB_BUTTON(restart)
#endif
public:
void setup() override;
float get_setup_priority() const override { return setup_priority::LATE; }
void dump_config() override;
void update() override;
void factory_reset();
void restart();
protected:
char version_[12] = "unknown";
uint16_t previous_timestamp_ = 0;
[[nodiscard]] bool read_data_();
void read_data_timeout_();
uint8_t attempt_ = 0;
};
} // namespace esphome::tfluna
+24
View File
@@ -0,0 +1,24 @@
tfluna:
sensor:
- platform: tfluna
distance:
name: "Distance"
signal_strength:
name: TF-Luna Signal Strength
temperature:
name: TF-Luna Temperature
timestamp:
name: TF-Luna Timestamp
text_sensor:
- platform: tfluna
version:
name: "TF-Luna firmware version"
button:
- platform: tfluna
factory_reset:
name: "factory reset"
restart:
name: "restart"
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
<<: !include common.yaml