diff --git a/esphome/components/openthread_info/openthread_info_sensor.cpp b/esphome/components/openthread_info/openthread_info_sensor.cpp new file mode 100644 index 0000000000..9beb52d64b --- /dev/null +++ b/esphome/components/openthread_info/openthread_info_sensor.cpp @@ -0,0 +1,24 @@ +#include "openthread_info_sensor.h" +#if defined(USE_OPENTHREAD) && defined(USE_SENSOR) +#include "esphome/core/log.h" + +namespace esphome::openthread_info { + +static const char *const TAG = "openthread_info"; + +void ParentAverageRssiOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Average RSSI", this); } +void ParentLastRssiOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Last RSSI", this); } +void ParentLinkQualityInOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Link Quality In", this); } +void ParentLinkQualityOutOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Link Quality Out", this); } +void TxTotalOpenThreadInfo::dump_config() { LOG_SENSOR("", "TX Total", this); } +void TxRetriesOpenThreadInfo::dump_config() { LOG_SENSOR("", "TX Retries", this); } +void TxErrCcaOpenThreadInfo::dump_config() { LOG_SENSOR("", "TX CCA Errors", this); } +void TxErrAbortOpenThreadInfo::dump_config() { LOG_SENSOR("", "TX Abort Errors", this); } +void RxTotalOpenThreadInfo::dump_config() { LOG_SENSOR("", "RX Total", this); } +void RxErrFcsOpenThreadInfo::dump_config() { LOG_SENSOR("", "RX FCS Errors", this); } +void AttachAttemptsOpenThreadInfo::dump_config() { LOG_SENSOR("", "Attach Attempts", this); } +void ParentChangesOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Changes", this); } +void PartitionIdChangesOpenThreadInfo::dump_config() { LOG_SENSOR("", "Partition ID Changes", this); } + +} // namespace esphome::openthread_info +#endif diff --git a/esphome/components/openthread_info/openthread_info_sensor.h b/esphome/components/openthread_info/openthread_info_sensor.h new file mode 100644 index 0000000000..dcc90da0c0 --- /dev/null +++ b/esphome/components/openthread_info/openthread_info_sensor.h @@ -0,0 +1,131 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_OPENTHREAD) && defined(USE_SENSOR) + +#include "openthread_info_text_sensor.h" +#include "esphome/components/sensor/sensor.h" + +#include +#include + +namespace esphome::openthread_info { + +// Parent RSSI (average) in dBm. Only valid when device is a child; skips publish otherwise. +class ParentAverageRssiOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + int8_t rssi; + if (otThreadGetParentAverageRssi(instance, &rssi) != OT_ERROR_NONE) { + return; + } + this->publish_state(rssi); + } + void dump_config() override; +}; + +// Parent RSSI (last received frame) in dBm. Only valid when device is a child. +class ParentLastRssiOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + int8_t rssi; + if (otThreadGetParentLastRssi(instance, &rssi) != OT_ERROR_NONE) { + return; + } + this->publish_state(rssi); + } + void dump_config() override; +}; + +// Incoming link quality from parent (0-3). Only valid when device is a child. +class ParentLinkQualityInOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + otRouterInfo parent_info; + if (otThreadGetParentInfo(instance, &parent_info) != OT_ERROR_NONE) { + return; + } + this->publish_state(parent_info.mLinkQualityIn); + } + void dump_config() override; +}; + +// Outgoing link quality to parent (0-3). Only valid when device is a child. +class ParentLinkQualityOutOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + otRouterInfo parent_info; + if (otThreadGetParentInfo(instance, &parent_info) != OT_ERROR_NONE) { + return; + } + this->publish_state(parent_info.mLinkQualityOut); + } + void dump_config() override; +}; + +// --- MAC counters (otLinkGetCounters) — cumulative since boot --- + +class TxTotalOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mTxTotal); } + void dump_config() override; +}; + +class TxRetriesOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mTxRetry); } + void dump_config() override; +}; + +class TxErrCcaOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mTxErrCca); } + void dump_config() override; +}; + +class TxErrAbortOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mTxErrAbort); } + void dump_config() override; +}; + +class RxTotalOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mRxTotal); } + void dump_config() override; +}; + +class RxErrFcsOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { this->publish_state(otLinkGetCounters(instance)->mRxErrFcs); } + void dump_config() override; +}; + +// --- MLE stability counters (otThreadGetMleCounters) — cumulative since boot --- + +class AttachAttemptsOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + this->publish_state(otThreadGetMleCounters(instance)->mAttachAttempts); + } + void dump_config() override; +}; + +class ParentChangesOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + this->publish_state(otThreadGetMleCounters(instance)->mParentChanges); + } + void dump_config() override; +}; + +class PartitionIdChangesOpenThreadInfo final : public OpenThreadInstancePollingComponent, public sensor::Sensor { + public: + void update_instance(otInstance *instance) override { + this->publish_state(otThreadGetMleCounters(instance)->mPartitionIdChanges); + } + void dump_config() override; +}; + +} // namespace esphome::openthread_info +#endif diff --git a/esphome/components/openthread_info/sensor.py b/esphome/components/openthread_info/sensor.py new file mode 100644 index 0000000000..4d5b3d54f4 --- /dev/null +++ b/esphome/components/openthread_info/sensor.py @@ -0,0 +1,188 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_SIGNAL_STRENGTH, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_DECIBEL_MILLIWATT, + UNIT_EMPTY, +) + +CONF_PARENT_AVERAGE_RSSI = "parent_average_rssi" +CONF_PARENT_LAST_RSSI = "parent_last_rssi" +CONF_PARENT_LINK_QUALITY_IN = "parent_link_quality_in" +CONF_PARENT_LINK_QUALITY_OUT = "parent_link_quality_out" +CONF_TX_TOTAL = "tx_total" +CONF_TX_RETRIES = "tx_retries" +CONF_TX_ERR_CCA = "tx_err_cca" +CONF_TX_ERR_ABORT = "tx_err_abort" +CONF_RX_TOTAL = "rx_total" +CONF_RX_ERR_FCS = "rx_err_fcs" +CONF_ATTACH_ATTEMPTS = "attach_attempts" +CONF_PARENT_CHANGES = "parent_changes" +CONF_PARTITION_ID_CHANGES = "partition_id_changes" + +DEPENDENCIES = ["openthread"] + +openthread_info_ns = cg.esphome_ns.namespace("openthread_info") +ParentAverageRssiOpenThreadInfo = openthread_info_ns.class_( + "ParentAverageRssiOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +ParentLastRssiOpenThreadInfo = openthread_info_ns.class_( + "ParentLastRssiOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +ParentLinkQualityInOpenThreadInfo = openthread_info_ns.class_( + "ParentLinkQualityInOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +ParentLinkQualityOutOpenThreadInfo = openthread_info_ns.class_( + "ParentLinkQualityOutOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +TxTotalOpenThreadInfo = openthread_info_ns.class_( + "TxTotalOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +TxRetriesOpenThreadInfo = openthread_info_ns.class_( + "TxRetriesOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +TxErrCcaOpenThreadInfo = openthread_info_ns.class_( + "TxErrCcaOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +TxErrAbortOpenThreadInfo = openthread_info_ns.class_( + "TxErrAbortOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +RxTotalOpenThreadInfo = openthread_info_ns.class_( + "RxTotalOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +RxErrFcsOpenThreadInfo = openthread_info_ns.class_( + "RxErrFcsOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +AttachAttemptsOpenThreadInfo = openthread_info_ns.class_( + "AttachAttemptsOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +ParentChangesOpenThreadInfo = openthread_info_ns.class_( + "ParentChangesOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) +PartitionIdChangesOpenThreadInfo = openthread_info_ns.class_( + "PartitionIdChangesOpenThreadInfo", sensor.Sensor, cg.PollingComponent +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.Optional(CONF_PARENT_AVERAGE_RSSI): sensor.sensor_schema( + ParentAverageRssiOpenThreadInfo, + unit_of_measurement=UNIT_DECIBEL_MILLIWATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("5s")), + cv.Optional(CONF_PARENT_LAST_RSSI): sensor.sensor_schema( + ParentLastRssiOpenThreadInfo, + unit_of_measurement=UNIT_DECIBEL_MILLIWATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("5s")), + cv.Optional(CONF_PARENT_LINK_QUALITY_IN): sensor.sensor_schema( + ParentLinkQualityInOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("5s")), + cv.Optional(CONF_PARENT_LINK_QUALITY_OUT): sensor.sensor_schema( + ParentLinkQualityOutOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("5s")), + cv.Optional(CONF_TX_TOTAL): sensor.sensor_schema( + TxTotalOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_TX_RETRIES): sensor.sensor_schema( + TxRetriesOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_TX_ERR_CCA): sensor.sensor_schema( + TxErrCcaOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_TX_ERR_ABORT): sensor.sensor_schema( + TxErrAbortOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_RX_TOTAL): sensor.sensor_schema( + RxTotalOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_RX_ERR_FCS): sensor.sensor_schema( + RxErrFcsOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_ATTACH_ATTEMPTS): sensor.sensor_schema( + AttachAttemptsOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_PARENT_CHANGES): sensor.sensor_schema( + ParentChangesOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + cv.Optional(CONF_PARTITION_ID_CHANGES): sensor.sensor_schema( + PartitionIdChangesOpenThreadInfo, + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.polling_component_schema("30s")), + } +) + + +async def setup_conf(config: dict, key: str): + if conf := config.get(key): + var = await sensor.new_sensor(conf) + await cg.register_component(var, conf) + + +async def to_code(config): + await setup_conf(config, CONF_PARENT_AVERAGE_RSSI) + await setup_conf(config, CONF_PARENT_LAST_RSSI) + await setup_conf(config, CONF_PARENT_LINK_QUALITY_IN) + await setup_conf(config, CONF_PARENT_LINK_QUALITY_OUT) + await setup_conf(config, CONF_TX_TOTAL) + await setup_conf(config, CONF_TX_RETRIES) + await setup_conf(config, CONF_TX_ERR_CCA) + await setup_conf(config, CONF_TX_ERR_ABORT) + await setup_conf(config, CONF_RX_TOTAL) + await setup_conf(config, CONF_RX_ERR_FCS) + await setup_conf(config, CONF_ATTACH_ATTEMPTS) + await setup_conf(config, CONF_PARENT_CHANGES) + await setup_conf(config, CONF_PARTITION_ID_CHANGES) diff --git a/tests/components/openthread_info/test.esp32-c6-idf.yaml b/tests/components/openthread_info/test.esp32-c6-idf.yaml index ded0f17611..8c55546ce6 100644 --- a/tests/components/openthread_info/test.esp32-c6-idf.yaml +++ b/tests/components/openthread_info/test.esp32-c6-idf.yaml @@ -28,3 +28,32 @@ text_sensor: name: "PAN ID" ext_pan_id: name: "Extended PAN ID" + +sensor: + - platform: openthread_info + parent_average_rssi: + name: "Parent Average RSSI" + parent_last_rssi: + name: "Parent Last RSSI" + parent_link_quality_in: + name: "Parent Link Quality In" + parent_link_quality_out: + name: "Parent Link Quality Out" + tx_total: + name: "TX Total" + tx_retries: + name: "TX Retries" + tx_err_cca: + name: "TX CCA Errors" + tx_err_abort: + name: "TX Abort Errors" + rx_total: + name: "RX Total" + rx_err_fcs: + name: "RX FCS Errors" + attach_attempts: + name: "Attach Attempts" + parent_changes: + name: "Parent Changes" + partition_id_changes: + name: "Partition ID Changes"