diff --git a/.ai/instructions.md b/.ai/instructions.md index 8d81c6cf0f..681829bae6 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -172,8 +172,7 @@ This document provides essential context for AI models interacting with this pro * **C++ Class Pattern:** ```cpp - namespace esphome { - namespace my_component { + namespace esphome::my_component { class MyComponent : public Component { public: @@ -189,8 +188,7 @@ This document provides essential context for AI models interacting with this pro int param_{0}; }; - } // namespace my_component - } // namespace esphome + } // namespace esphome::my_component ``` * **Common Component Examples:** diff --git a/CODEOWNERS b/CODEOWNERS index 7e785db451..393774372f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -206,6 +206,7 @@ esphome/components/hdc2010/* @optimusprimespace @ssieb esphome/components/he60r/* @clydebarrow esphome/components/heatpumpir/* @rob-deutsch esphome/components/hitachi_ac424/* @sourabhjaiswal +esphome/components/hlk_fm22x/* @OnFreund esphome/components/hm3301/* @freekode esphome/components/hmac_md5/* @dwmw2 esphome/components/homeassistant/* @esphome/core @OttoWinter diff --git a/esphome/components/ble_client/automation.h b/esphome/components/ble_client/automation.h index 9c5646b3d1..bbc2dd05e0 100644 --- a/esphome/components/ble_client/automation.h +++ b/esphome/components/ble_client/automation.h @@ -122,16 +122,19 @@ template class BLEClientWriteAction : public Action, publ void play_complex(const Ts &...x) override { this->num_running_++; this->var_ = std::make_tuple(x...); - std::vector value; + + bool result; if (this->len_ >= 0) { - // Static mode: copy from flash to vector - value.assign(this->value_.data, this->value_.data + this->len_); + // Static mode: write directly from flash pointer + result = this->write(this->value_.data, this->len_); } else { - // Template mode: call function - value = this->value_.func(x...); + // Template mode: call function and write the vector + std::vector value = this->value_.func(x...); + result = this->write(value); } + // on write failure, continue the automation chain rather than stopping so that e.g. disconnect can work. - if (!write(value)) + if (!result) this->play_next_(x...); } @@ -144,15 +147,15 @@ template class BLEClientWriteAction : public Action, publ * errors. */ // initiate the write. Return true if all went well, will be followed by a WRITE_CHAR event. - bool write(const std::vector &value) { + bool write(const uint8_t *data, size_t len) { if (this->node_state != espbt::ClientState::ESTABLISHED) { esph_log_w(Automation::TAG, "Cannot write to BLE characteristic - not connected"); return false; } - esph_log_vv(Automation::TAG, "Will write %d bytes: %s", value.size(), format_hex_pretty(value).c_str()); - esp_err_t err = esp_ble_gattc_write_char(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), - this->char_handle_, value.size(), const_cast(value.data()), - this->write_type_, ESP_GATT_AUTH_REQ_NONE); + esph_log_vv(Automation::TAG, "Will write %d bytes: %s", len, format_hex_pretty(data, len).c_str()); + esp_err_t err = + esp_ble_gattc_write_char(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->char_handle_, len, + const_cast(data), this->write_type_, ESP_GATT_AUTH_REQ_NONE); if (err != ESP_OK) { esph_log_e(Automation::TAG, "Error writing to characteristic: %s!", esp_err_to_name(err)); return false; @@ -160,6 +163,8 @@ template class BLEClientWriteAction : public Action, publ return true; } + bool write(const std::vector &value) { return this->write(value.data(), value.size()); } + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override { switch (event) { diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py new file mode 100644 index 0000000000..efd64b6513 --- /dev/null +++ b/esphome/components/hlk_fm22x/__init__.py @@ -0,0 +1,247 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_DIRECTION, + CONF_ID, + CONF_NAME, + CONF_ON_ENROLLMENT_DONE, + CONF_ON_ENROLLMENT_FAILED, + CONF_TRIGGER_ID, +) + +CODEOWNERS = ["@OnFreund"] +DEPENDENCIES = ["uart"] +AUTO_LOAD = ["binary_sensor", "sensor", "text_sensor"] +MULTI_CONF = True + +CONF_HLK_FM22X_ID = "hlk_fm22x_id" +CONF_FACE_ID = "face_id" +CONF_ON_FACE_SCAN_MATCHED = "on_face_scan_matched" +CONF_ON_FACE_SCAN_UNMATCHED = "on_face_scan_unmatched" +CONF_ON_FACE_SCAN_INVALID = "on_face_scan_invalid" +CONF_ON_FACE_INFO = "on_face_info" + +hlk_fm22x_ns = cg.esphome_ns.namespace("hlk_fm22x") +HlkFm22xComponent = hlk_fm22x_ns.class_( + "HlkFm22xComponent", cg.PollingComponent, uart.UARTDevice +) + +FaceScanMatchedTrigger = hlk_fm22x_ns.class_( + "FaceScanMatchedTrigger", automation.Trigger.template(cg.int16, cg.std_string) +) + +FaceScanUnmatchedTrigger = hlk_fm22x_ns.class_( + "FaceScanUnmatchedTrigger", automation.Trigger.template() +) + +FaceScanInvalidTrigger = hlk_fm22x_ns.class_( + "FaceScanInvalidTrigger", automation.Trigger.template(cg.uint8) +) + +FaceInfoTrigger = hlk_fm22x_ns.class_( + "FaceInfoTrigger", + automation.Trigger.template( + cg.int16, cg.int16, cg.int16, cg.int16, cg.int16, cg.int16, cg.int16, cg.int16 + ), +) + +EnrollmentDoneTrigger = hlk_fm22x_ns.class_( + "EnrollmentDoneTrigger", automation.Trigger.template(cg.int16, cg.uint8) +) + +EnrollmentFailedTrigger = hlk_fm22x_ns.class_( + "EnrollmentFailedTrigger", automation.Trigger.template(cg.uint8) +) + +EnrollmentAction = hlk_fm22x_ns.class_("EnrollmentAction", automation.Action) +DeleteAction = hlk_fm22x_ns.class_("DeleteAction", automation.Action) +DeleteAllAction = hlk_fm22x_ns.class_("DeleteAllAction", automation.Action) +ScanAction = hlk_fm22x_ns.class_("ScanAction", automation.Action) +ResetAction = hlk_fm22x_ns.class_("ResetAction", automation.Action) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(HlkFm22xComponent), + cv.Optional(CONF_ON_FACE_SCAN_MATCHED): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + FaceScanMatchedTrigger + ), + } + ), + cv.Optional(CONF_ON_FACE_SCAN_UNMATCHED): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + FaceScanUnmatchedTrigger + ), + } + ), + cv.Optional(CONF_ON_FACE_SCAN_INVALID): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + FaceScanInvalidTrigger + ), + } + ), + cv.Optional(CONF_ON_FACE_INFO): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FaceInfoTrigger), + } + ), + cv.Optional(CONF_ON_ENROLLMENT_DONE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + EnrollmentDoneTrigger + ), + } + ), + cv.Optional(CONF_ON_ENROLLMENT_FAILED): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + EnrollmentFailedTrigger + ), + } + ), + } + ) + .extend(cv.polling_component_schema("50ms")) + .extend(uart.UART_DEVICE_SCHEMA), +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + for conf in config.get(CONF_ON_FACE_SCAN_MATCHED, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation( + trigger, [(cg.int16, "face_id"), (cg.std_string, "name")], conf + ) + + for conf in config.get(CONF_ON_FACE_SCAN_UNMATCHED, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) + + for conf in config.get(CONF_ON_FACE_SCAN_INVALID, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [(cg.uint8, "error")], conf) + + for conf in config.get(CONF_ON_FACE_INFO, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation( + trigger, + [ + (cg.int16, "status"), + (cg.int16, "left"), + (cg.int16, "top"), + (cg.int16, "right"), + (cg.int16, "bottom"), + (cg.int16, "yaw"), + (cg.int16, "pitch"), + (cg.int16, "roll"), + ], + conf, + ) + + for conf in config.get(CONF_ON_ENROLLMENT_DONE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation( + trigger, [(cg.int16, "face_id"), (cg.uint8, "direction")], conf + ) + + for conf in config.get(CONF_ON_ENROLLMENT_FAILED, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [(cg.uint8, "error")], conf) + + +@automation.register_action( + "hlk_fm22x.enroll", + EnrollmentAction, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(HlkFm22xComponent), + cv.Required(CONF_NAME): cv.templatable(cv.string), + cv.Required(CONF_DIRECTION): cv.templatable(cv.uint8_t), + }, + key=CONF_NAME, + ), +) +async def hlk_fm22x_enroll_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + + template_ = await cg.templatable(config[CONF_NAME], args, cg.std_string) + cg.add(var.set_name(template_)) + template_ = await cg.templatable(config[CONF_DIRECTION], args, cg.uint8) + cg.add(var.set_direction(template_)) + return var + + +@automation.register_action( + "hlk_fm22x.delete", + DeleteAction, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(HlkFm22xComponent), + cv.Required(CONF_FACE_ID): cv.templatable(cv.uint16_t), + }, + key=CONF_FACE_ID, + ), +) +async def hlk_fm22x_delete_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + + template_ = await cg.templatable(config[CONF_FACE_ID], args, cg.int16) + cg.add(var.set_face_id(template_)) + return var + + +@automation.register_action( + "hlk_fm22x.delete_all", + DeleteAllAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(HlkFm22xComponent), + } + ), +) +async def hlk_fm22x_delete_all_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +@automation.register_action( + "hlk_fm22x.scan", + ScanAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(HlkFm22xComponent), + } + ), +) +async def hlk_fm22x_scan_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +@automation.register_action( + "hlk_fm22x.reset", + ResetAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(HlkFm22xComponent), + } + ), +) +async def hlk_fm22x_reset_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/esphome/components/hlk_fm22x/binary_sensor.py b/esphome/components/hlk_fm22x/binary_sensor.py new file mode 100644 index 0000000000..3620f33ac0 --- /dev/null +++ b/esphome/components/hlk_fm22x/binary_sensor.py @@ -0,0 +1,21 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ICON, ICON_KEY_PLUS + +from . import CONF_HLK_FM22X_ID, HlkFm22xComponent + +DEPENDENCIES = ["hlk_fm22x"] + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( + { + cv.GenerateID(CONF_HLK_FM22X_ID): cv.use_id(HlkFm22xComponent), + cv.Optional(CONF_ICON, default=ICON_KEY_PLUS): cv.icon, + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_HLK_FM22X_ID]) + var = await binary_sensor.new_binary_sensor(config) + cg.add(hub.set_enrolling_binary_sensor(var)) diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp new file mode 100644 index 0000000000..ab15a2340d --- /dev/null +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -0,0 +1,325 @@ +#include "hlk_fm22x.h" +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include +#include + +namespace esphome::hlk_fm22x { + +static const char *const TAG = "hlk_fm22x"; + +void HlkFm22xComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up HLK-FM22X..."); + this->set_enrolling_(false); + while (this->available()) { + this->read(); + } + this->defer([this]() { this->send_command_(HlkFm22xCommand::GET_STATUS); }); +} + +void HlkFm22xComponent::update() { + if (this->active_command_ != HlkFm22xCommand::NONE) { + if (this->wait_cycles_ > 600) { + ESP_LOGE(TAG, "Command 0x%.2X timed out", this->active_command_); + if (HlkFm22xCommand::RESET == this->active_command_) { + this->mark_failed(); + } else { + this->reset(); + } + } + } + this->recv_command_(); +} + +void HlkFm22xComponent::enroll_face(const std::string &name, HlkFm22xFaceDirection direction) { + if (name.length() > 31) { + ESP_LOGE(TAG, "enroll_face(): name too long '%s'", name.c_str()); + return; + } + ESP_LOGI(TAG, "Starting enrollment for %s", name.c_str()); + std::array data{}; + data[0] = 0; // admin + std::copy(name.begin(), name.end(), data.begin() + 1); + // Remaining bytes are already zero-initialized + data[33] = (uint8_t) direction; + data[34] = 10; // timeout + this->send_command_(HlkFm22xCommand::ENROLL, data.data(), data.size()); + this->set_enrolling_(true); +} + +void HlkFm22xComponent::scan_face() { + ESP_LOGI(TAG, "Verify face"); + static const uint8_t DATA[] = {0, 0}; + this->send_command_(HlkFm22xCommand::VERIFY, DATA, sizeof(DATA)); +} + +void HlkFm22xComponent::delete_face(int16_t face_id) { + ESP_LOGI(TAG, "Deleting face in slot %d", face_id); + const uint8_t data[] = {(uint8_t) (face_id >> 8), (uint8_t) (face_id & 0xFF)}; + this->send_command_(HlkFm22xCommand::DELETE_FACE, data, sizeof(data)); +} + +void HlkFm22xComponent::delete_all_faces() { + ESP_LOGI(TAG, "Deleting all stored faces"); + this->send_command_(HlkFm22xCommand::DELETE_ALL_FACES); +} + +void HlkFm22xComponent::get_face_count_() { + ESP_LOGD(TAG, "Getting face count"); + this->send_command_(HlkFm22xCommand::GET_ALL_FACE_IDS); +} + +void HlkFm22xComponent::reset() { + ESP_LOGI(TAG, "Resetting module"); + this->active_command_ = HlkFm22xCommand::NONE; + this->wait_cycles_ = 0; + this->set_enrolling_(false); + this->send_command_(HlkFm22xCommand::RESET); +} + +void HlkFm22xComponent::send_command_(HlkFm22xCommand command, const uint8_t *data, size_t size) { + ESP_LOGV(TAG, "Send command: 0x%.2X", command); + if (this->active_command_ != HlkFm22xCommand::NONE) { + ESP_LOGW(TAG, "Command 0x%.2X already active", this->active_command_); + return; + } + this->wait_cycles_ = 0; + this->active_command_ = command; + while (this->available()) + this->read(); + this->write((uint8_t) (START_CODE >> 8)); + this->write((uint8_t) (START_CODE & 0xFF)); + this->write((uint8_t) command); + uint16_t data_size = size; + this->write((uint8_t) (data_size >> 8)); + this->write((uint8_t) (data_size & 0xFF)); + + uint8_t checksum = 0; + checksum ^= (uint8_t) command; + checksum ^= (data_size >> 8); + checksum ^= (data_size & 0xFF); + for (size_t i = 0; i < size; i++) { + this->write(data[i]); + checksum ^= data[i]; + } + + this->write(checksum); + this->active_command_ = command; + this->wait_cycles_ = 0; +} + +void HlkFm22xComponent::recv_command_() { + uint8_t byte, checksum = 0; + uint16_t length = 0; + + if (this->available() < 7) { + ++this->wait_cycles_; + return; + } + this->wait_cycles_ = 0; + + if ((this->read() != (uint8_t) (START_CODE >> 8)) || (this->read() != (uint8_t) (START_CODE & 0xFF))) { + ESP_LOGE(TAG, "Invalid start code"); + return; + } + + byte = this->read(); + checksum ^= byte; + HlkFm22xResponseType response_type = (HlkFm22xResponseType) byte; + + byte = this->read(); + checksum ^= byte; + length = byte << 8; + byte = this->read(); + checksum ^= byte; + length |= byte; + + std::vector data; + data.reserve(length); + for (uint16_t idx = 0; idx < length; ++idx) { + byte = this->read(); + checksum ^= byte; + data.push_back(byte); + } + + ESP_LOGV(TAG, "Recv type: 0x%.2X, data: %s", response_type, format_hex_pretty(data).c_str()); + + byte = this->read(); + if (byte != checksum) { + ESP_LOGE(TAG, "Invalid checksum for data. Calculated: 0x%.2X, Received: 0x%.2X", checksum, byte); + return; + } + switch (response_type) { + case HlkFm22xResponseType::NOTE: + this->handle_note_(data); + break; + case HlkFm22xResponseType::REPLY: + this->handle_reply_(data); + break; + default: + ESP_LOGW(TAG, "Unexpected response type: 0x%.2X", response_type); + break; + } +} + +void HlkFm22xComponent::handle_note_(const std::vector &data) { + switch (data[0]) { + case HlkFm22xNoteType::FACE_STATE: + if (data.size() < 17) { + ESP_LOGE(TAG, "Invalid face note data size: %u", data.size()); + break; + } + { + int16_t info[8]; + uint8_t offset = 1; + for (int16_t &i : info) { + i = ((int16_t) data[offset + 1] << 8) | data[offset]; + offset += 2; + } + ESP_LOGV(TAG, "Face state: status: %d, left: %d, top: %d, right: %d, bottom: %d, yaw: %d, pitch: %d, roll: %d", + info[0], info[1], info[2], info[3], info[4], info[5], info[6], info[7]); + this->face_info_callback_.call(info[0], info[1], info[2], info[3], info[4], info[5], info[6], info[7]); + } + break; + case HlkFm22xNoteType::READY: + ESP_LOGE(TAG, "Command 0x%.2X timed out", this->active_command_); + switch (this->active_command_) { + case HlkFm22xCommand::ENROLL: + this->set_enrolling_(false); + this->enrollment_failed_callback_.call(HlkFm22xResult::FAILED4_TIMEOUT); + break; + case HlkFm22xCommand::VERIFY: + this->face_scan_invalid_callback_.call(HlkFm22xResult::FAILED4_TIMEOUT); + break; + default: + break; + } + this->active_command_ = HlkFm22xCommand::NONE; + this->wait_cycles_ = 0; + break; + default: + ESP_LOGW(TAG, "Unhandled note: 0x%.2X", data[0]); + break; + } +} + +void HlkFm22xComponent::handle_reply_(const std::vector &data) { + auto expected = this->active_command_; + this->active_command_ = HlkFm22xCommand::NONE; + if (data[0] != (uint8_t) expected) { + ESP_LOGE(TAG, "Unexpected response command. Expected: 0x%.2X, Received: 0x%.2X", expected, data[0]); + return; + } + + if (data[1] != HlkFm22xResult::SUCCESS) { + ESP_LOGE(TAG, "Command <0x%.2X> failed. Error: 0x%.2X", data[0], data[1]); + switch (expected) { + case HlkFm22xCommand::ENROLL: + this->set_enrolling_(false); + this->enrollment_failed_callback_.call(data[1]); + break; + case HlkFm22xCommand::VERIFY: + if (data[1] == HlkFm22xResult::REJECTED) { + this->face_scan_unmatched_callback_.call(); + } else { + this->face_scan_invalid_callback_.call(data[1]); + } + break; + default: + break; + } + return; + } + switch (expected) { + case HlkFm22xCommand::VERIFY: { + int16_t face_id = ((int16_t) data[2] << 8) | data[3]; + std::string name(data.begin() + 4, data.begin() + 36); + ESP_LOGD(TAG, "Face verified. ID: %d, name: %s", face_id, name.c_str()); + if (this->last_face_id_sensor_ != nullptr) { + this->last_face_id_sensor_->publish_state(face_id); + } + if (this->last_face_name_text_sensor_ != nullptr) { + this->last_face_name_text_sensor_->publish_state(name); + } + this->face_scan_matched_callback_.call(face_id, name); + break; + } + case HlkFm22xCommand::ENROLL: { + int16_t face_id = ((int16_t) data[2] << 8) | data[3]; + HlkFm22xFaceDirection direction = (HlkFm22xFaceDirection) data[4]; + ESP_LOGI(TAG, "Face enrolled. ID: %d, Direction: 0x%.2X", face_id, direction); + this->enrollment_done_callback_.call(face_id, (uint8_t) direction); + this->set_enrolling_(false); + this->defer([this]() { this->get_face_count_(); }); + break; + } + case HlkFm22xCommand::GET_STATUS: + if (this->status_sensor_ != nullptr) { + this->status_sensor_->publish_state(data[2]); + } + this->defer([this]() { this->send_command_(HlkFm22xCommand::GET_VERSION); }); + break; + case HlkFm22xCommand::GET_VERSION: + if (this->version_text_sensor_ != nullptr) { + std::string version(data.begin() + 2, data.end()); + this->version_text_sensor_->publish_state(version); + } + this->defer([this]() { this->get_face_count_(); }); + break; + case HlkFm22xCommand::GET_ALL_FACE_IDS: + if (this->face_count_sensor_ != nullptr) { + this->face_count_sensor_->publish_state(data[2]); + } + break; + case HlkFm22xCommand::DELETE_FACE: + ESP_LOGI(TAG, "Deleted face"); + break; + case HlkFm22xCommand::DELETE_ALL_FACES: + ESP_LOGI(TAG, "Deleted all faces"); + break; + case HlkFm22xCommand::RESET: + ESP_LOGI(TAG, "Module reset"); + this->defer([this]() { this->send_command_(HlkFm22xCommand::GET_STATUS); }); + break; + default: + ESP_LOGW(TAG, "Unhandled command: 0x%.2X", this->active_command_); + break; + } +} + +void HlkFm22xComponent::set_enrolling_(bool enrolling) { + if (this->enrolling_binary_sensor_ != nullptr) { + this->enrolling_binary_sensor_->publish_state(enrolling); + } +} + +void HlkFm22xComponent::dump_config() { + ESP_LOGCONFIG(TAG, "HLK_FM22X:"); + LOG_UPDATE_INTERVAL(this); + if (this->version_text_sensor_) { + LOG_TEXT_SENSOR(" ", "Version", this->version_text_sensor_); + ESP_LOGCONFIG(TAG, " Current Value: %s", this->version_text_sensor_->get_state().c_str()); + } + if (this->enrolling_binary_sensor_) { + LOG_BINARY_SENSOR(" ", "Enrolling", this->enrolling_binary_sensor_); + ESP_LOGCONFIG(TAG, " Current Value: %s", this->enrolling_binary_sensor_->state ? "ON" : "OFF"); + } + if (this->face_count_sensor_) { + LOG_SENSOR(" ", "Face Count", this->face_count_sensor_); + ESP_LOGCONFIG(TAG, " Current Value: %u", (uint16_t) this->face_count_sensor_->get_state()); + } + if (this->status_sensor_) { + LOG_SENSOR(" ", "Status", this->status_sensor_); + ESP_LOGCONFIG(TAG, " Current Value: %u", (uint8_t) this->status_sensor_->get_state()); + } + if (this->last_face_id_sensor_) { + LOG_SENSOR(" ", "Last Face ID", this->last_face_id_sensor_); + ESP_LOGCONFIG(TAG, " Current Value: %u", (int16_t) this->last_face_id_sensor_->get_state()); + } + if (this->last_face_name_text_sensor_) { + LOG_TEXT_SENSOR(" ", "Last Face Name", this->last_face_name_text_sensor_); + ESP_LOGCONFIG(TAG, " Current Value: %s", this->last_face_name_text_sensor_->get_state().c_str()); + } +} + +} // namespace esphome::hlk_fm22x diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.h b/esphome/components/hlk_fm22x/hlk_fm22x.h new file mode 100644 index 0000000000..5ecc715ea1 --- /dev/null +++ b/esphome/components/hlk_fm22x/hlk_fm22x.h @@ -0,0 +1,224 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/components/uart/uart.h" + +#include +#include + +namespace esphome::hlk_fm22x { + +static const uint16_t START_CODE = 0xEFAA; +enum HlkFm22xCommand { + NONE = 0x00, + RESET = 0x10, + GET_STATUS = 0x11, + VERIFY = 0x12, + ENROLL = 0x13, + DELETE_FACE = 0x20, + DELETE_ALL_FACES = 0x21, + GET_ALL_FACE_IDS = 0x24, + GET_VERSION = 0x30, + GET_SERIAL_NUMBER = 0x93, +}; + +enum HlkFm22xResponseType { + REPLY = 0x00, + NOTE = 0x01, + IMAGE = 0x02, +}; + +enum HlkFm22xNoteType { + READY = 0x00, + FACE_STATE = 0x01, +}; + +enum HlkFm22xResult { + SUCCESS = 0x00, + REJECTED = 0x01, + ABORTED = 0x02, + FAILED4_CAMERA = 0x04, + FAILED4_UNKNOWNREASON = 0x05, + FAILED4_INVALIDPARAM = 0x06, + FAILED4_NOMEMORY = 0x07, + FAILED4_UNKNOWNUSER = 0x08, + FAILED4_MAXUSER = 0x09, + FAILED4_FACEENROLLED = 0x0A, + FAILED4_LIVENESSCHECK = 0x0C, + FAILED4_TIMEOUT = 0x0D, + FAILED4_AUTHORIZATION = 0x0E, + FAILED4_READ_FILE = 0x13, + FAILED4_WRITE_FILE = 0x14, + FAILED4_NO_ENCRYPT = 0x15, + FAILED4_NO_RGBIMAGE = 0x17, + FAILED4_JPGPHOTO_LARGE = 0x18, + FAILED4_JPGPHOTO_SMALL = 0x19, +}; + +enum HlkFm22xFaceDirection { + FACE_DIRECTION_UNDEFINED = 0x00, + FACE_DIRECTION_MIDDLE = 0x01, + FACE_DIRECTION_RIGHT = 0x02, + FACE_DIRECTION_LEFT = 0x04, + FACE_DIRECTION_DOWN = 0x08, + FACE_DIRECTION_UP = 0x10, +}; + +class HlkFm22xComponent : public PollingComponent, public uart::UARTDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + void set_face_count_sensor(sensor::Sensor *face_count_sensor) { this->face_count_sensor_ = face_count_sensor; } + void set_status_sensor(sensor::Sensor *status_sensor) { this->status_sensor_ = status_sensor; } + void set_last_face_id_sensor(sensor::Sensor *last_face_id_sensor) { + this->last_face_id_sensor_ = last_face_id_sensor; + } + void set_last_face_name_text_sensor(text_sensor::TextSensor *last_face_name_text_sensor) { + this->last_face_name_text_sensor_ = last_face_name_text_sensor; + } + void set_enrolling_binary_sensor(binary_sensor::BinarySensor *enrolling_binary_sensor) { + this->enrolling_binary_sensor_ = enrolling_binary_sensor; + } + void set_version_text_sensor(text_sensor::TextSensor *version_text_sensor) { + this->version_text_sensor_ = version_text_sensor; + } + void add_on_face_scan_matched_callback(std::function callback) { + this->face_scan_matched_callback_.add(std::move(callback)); + } + void add_on_face_scan_unmatched_callback(std::function callback) { + this->face_scan_unmatched_callback_.add(std::move(callback)); + } + void add_on_face_scan_invalid_callback(std::function callback) { + this->face_scan_invalid_callback_.add(std::move(callback)); + } + void add_on_face_info_callback( + std::function callback) { + this->face_info_callback_.add(std::move(callback)); + } + void add_on_enrollment_done_callback(std::function callback) { + this->enrollment_done_callback_.add(std::move(callback)); + } + void add_on_enrollment_failed_callback(std::function callback) { + this->enrollment_failed_callback_.add(std::move(callback)); + } + + void enroll_face(const std::string &name, HlkFm22xFaceDirection direction); + void scan_face(); + void delete_face(int16_t face_id); + void delete_all_faces(); + void reset(); + + protected: + void get_face_count_(); + void send_command_(HlkFm22xCommand command, const uint8_t *data = nullptr, size_t size = 0); + void recv_command_(); + void handle_note_(const std::vector &data); + void handle_reply_(const std::vector &data); + void set_enrolling_(bool enrolling); + + HlkFm22xCommand active_command_ = HlkFm22xCommand::NONE; + uint16_t wait_cycles_ = 0; + sensor::Sensor *face_count_sensor_{nullptr}; + sensor::Sensor *status_sensor_{nullptr}; + sensor::Sensor *last_face_id_sensor_{nullptr}; + binary_sensor::BinarySensor *enrolling_binary_sensor_{nullptr}; + text_sensor::TextSensor *last_face_name_text_sensor_{nullptr}; + text_sensor::TextSensor *version_text_sensor_{nullptr}; + CallbackManager face_scan_invalid_callback_; + CallbackManager face_scan_matched_callback_; + CallbackManager face_scan_unmatched_callback_; + CallbackManager face_info_callback_; + CallbackManager enrollment_done_callback_; + CallbackManager enrollment_failed_callback_; +}; + +class FaceScanMatchedTrigger : public Trigger { + public: + explicit FaceScanMatchedTrigger(HlkFm22xComponent *parent) { + parent->add_on_face_scan_matched_callback( + [this](int16_t face_id, const std::string &name) { this->trigger(face_id, name); }); + } +}; + +class FaceScanUnmatchedTrigger : public Trigger<> { + public: + explicit FaceScanUnmatchedTrigger(HlkFm22xComponent *parent) { + parent->add_on_face_scan_unmatched_callback([this]() { this->trigger(); }); + } +}; + +class FaceScanInvalidTrigger : public Trigger { + public: + explicit FaceScanInvalidTrigger(HlkFm22xComponent *parent) { + parent->add_on_face_scan_invalid_callback([this](uint8_t error) { this->trigger(error); }); + } +}; + +class FaceInfoTrigger : public Trigger { + public: + explicit FaceInfoTrigger(HlkFm22xComponent *parent) { + parent->add_on_face_info_callback( + [this](int16_t status, int16_t left, int16_t top, int16_t right, int16_t bottom, int16_t yaw, int16_t pitch, + int16_t roll) { this->trigger(status, left, top, right, bottom, yaw, pitch, roll); }); + } +}; + +class EnrollmentDoneTrigger : public Trigger { + public: + explicit EnrollmentDoneTrigger(HlkFm22xComponent *parent) { + parent->add_on_enrollment_done_callback( + [this](int16_t face_id, uint8_t direction) { this->trigger(face_id, direction); }); + } +}; + +class EnrollmentFailedTrigger : public Trigger { + public: + explicit EnrollmentFailedTrigger(HlkFm22xComponent *parent) { + parent->add_on_enrollment_failed_callback([this](uint8_t error) { this->trigger(error); }); + } +}; + +template class EnrollmentAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(std::string, name) + TEMPLATABLE_VALUE(uint8_t, direction) + + void play(Ts... x) override { + auto name = this->name_.value(x...); + auto direction = (HlkFm22xFaceDirection) this->direction_.value(x...); + this->parent_->enroll_face(name, direction); + } +}; + +template class DeleteAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(int16_t, face_id) + + void play(Ts... x) override { + auto face_id = this->face_id_.value(x...); + this->parent_->delete_face(face_id); + } +}; + +template class DeleteAllAction : public Action, public Parented { + public: + void play(Ts... x) override { this->parent_->delete_all_faces(); } +}; + +template class ScanAction : public Action, public Parented { + public: + void play(Ts... x) override { this->parent_->scan_face(); } +}; + +template class ResetAction : public Action, public Parented { + public: + void play(Ts... x) override { this->parent_->reset(); } +}; + +} // namespace esphome::hlk_fm22x diff --git a/esphome/components/hlk_fm22x/sensor.py b/esphome/components/hlk_fm22x/sensor.py new file mode 100644 index 0000000000..e14b45599f --- /dev/null +++ b/esphome/components/hlk_fm22x/sensor.py @@ -0,0 +1,47 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import CONF_STATUS, ENTITY_CATEGORY_DIAGNOSTIC, ICON_ACCOUNT + +from . import CONF_HLK_FM22X_ID, HlkFm22xComponent + +DEPENDENCIES = ["hlk_fm22x"] + +CONF_FACE_COUNT = "face_count" +CONF_LAST_FACE_ID = "last_face_id" +ICON_FACE = "mdi:face-recognition" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_HLK_FM22X_ID): cv.use_id(HlkFm22xComponent), + cv.Optional(CONF_FACE_COUNT): sensor.sensor_schema( + icon=ICON_FACE, + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_STATUS): sensor.sensor_schema( + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_LAST_FACE_ID): sensor.sensor_schema( + icon=ICON_ACCOUNT, + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_HLK_FM22X_ID]) + + for key in [ + CONF_FACE_COUNT, + CONF_STATUS, + CONF_LAST_FACE_ID, + ]: + if key not in config: + continue + conf = config[key] + sens = await sensor.new_sensor(conf) + cg.add(getattr(hub, f"set_{key}_sensor")(sens)) diff --git a/esphome/components/hlk_fm22x/text_sensor.py b/esphome/components/hlk_fm22x/text_sensor.py new file mode 100644 index 0000000000..06da61c8b3 --- /dev/null +++ b/esphome/components/hlk_fm22x/text_sensor.py @@ -0,0 +1,42 @@ +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_ACCOUNT, + ICON_RESTART, +) + +from . import CONF_HLK_FM22X_ID, HlkFm22xComponent + +DEPENDENCIES = ["hlk_fm22x"] + +CONF_LAST_FACE_NAME = "last_face_name" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_HLK_FM22X_ID): cv.use_id(HlkFm22xComponent), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + icon=ICON_RESTART, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_LAST_FACE_NAME): text_sensor.text_sensor_schema( + icon=ICON_ACCOUNT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_HLK_FM22X_ID]) + for key in [ + CONF_VERSION, + CONF_LAST_FACE_NAME, + ]: + if key not in config: + continue + conf = config[key] + sens = await text_sensor.new_text_sensor(conf) + cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) diff --git a/requirements_test.txt b/requirements_test.txt index 81cb711eec..35010ad52f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,14 +1,14 @@ pylint==4.0.2 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.14.4 # also change in .pre-commit-config.yaml when updating -pyupgrade==3.21.0 # also change in .pre-commit-config.yaml when updating +pyupgrade==3.21.1 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==8.4.2 +pytest==9.0.0 pytest-cov==7.0.0 pytest-mock==3.15.1 -pytest-asyncio==1.2.0 +pytest-asyncio==1.3.0 pytest-xdist==3.8.0 asyncmock==0.4.2 hypothesis==6.92.1 diff --git a/tests/components/hlk_fm22x/test.esp32-idf.yaml b/tests/components/hlk_fm22x/test.esp32-idf.yaml new file mode 100644 index 0000000000..5e7cbde664 --- /dev/null +++ b/tests/components/hlk_fm22x/test.esp32-idf.yaml @@ -0,0 +1,47 @@ +esphome: + on_boot: + then: + - hlk_fm22x.enroll: + name: "Test" + direction: 1 + - hlk_fm22x.delete_all: + +uart: + - id: uart_hlk_fm22x + tx_pin: 17 + rx_pin: 16 + baud_rate: 115200 + +hlk_fm22x: + on_face_scan_matched: + - logger.log: test_hlk_22x_face_scan_matched + on_face_scan_unmatched: + - logger.log: test_hlk_22x_face_scan_unmatched + on_face_scan_invalid: + - logger.log: test_hlk_22x_face_scan_invalid + on_face_info: + - logger.log: test_hlk_22x_face_info + on_enrollment_done: + - logger.log: test_hlk_22x_enrollment_done + on_enrollment_failed: + - logger.log: test_hlk_22x_enrollment_failed + +sensor: + - platform: hlk_fm22x + face_count: + name: "Face Count" + last_face_id: + name: "Last Face ID" + status: + name: "Face Status" + +binary_sensor: + - platform: hlk_fm22x + name: "Face Enrolling" + +text_sensor: + - platform: hlk_fm22x + version: + name: "HLK Version" + last_face_name: + name: "Last Face Name" diff --git a/tests/components/hlk_fm22x/test.esp8266-ard.yaml b/tests/components/hlk_fm22x/test.esp8266-ard.yaml new file mode 100644 index 0000000000..680047834c --- /dev/null +++ b/tests/components/hlk_fm22x/test.esp8266-ard.yaml @@ -0,0 +1,47 @@ +esphome: + on_boot: + then: + - hlk_fm22x.enroll: + name: "Test" + direction: 1 + - hlk_fm22x.delete_all: + +uart: + - id: uart_hlk_fm22x + tx_pin: 4 + rx_pin: 5 + baud_rate: 115200 + +hlk_fm22x: + on_face_scan_matched: + - logger.log: test_hlk_22x_face_scan_matched + on_face_scan_unmatched: + - logger.log: test_hlk_22x_face_scan_unmatched + on_face_scan_invalid: + - logger.log: test_hlk_22x_face_scan_invalid + on_face_info: + - logger.log: test_hlk_22x_face_info + on_enrollment_done: + - logger.log: test_hlk_22x_enrollment_done + on_enrollment_failed: + - logger.log: test_hlk_22x_enrollment_failed + +sensor: + - platform: hlk_fm22x + face_count: + name: "Face Count" + last_face_id: + name: "Last Face ID" + status: + name: "Face Status" + +binary_sensor: + - platform: hlk_fm22x + name: "Face Enrolling" + +text_sensor: + - platform: hlk_fm22x + version: + name: "HLK Version" + last_face_name: + name: "Last Face Name" diff --git a/tests/components/hlk_fm22x/test.rp2040-ard.yaml b/tests/components/hlk_fm22x/test.rp2040-ard.yaml new file mode 100644 index 0000000000..680047834c --- /dev/null +++ b/tests/components/hlk_fm22x/test.rp2040-ard.yaml @@ -0,0 +1,47 @@ +esphome: + on_boot: + then: + - hlk_fm22x.enroll: + name: "Test" + direction: 1 + - hlk_fm22x.delete_all: + +uart: + - id: uart_hlk_fm22x + tx_pin: 4 + rx_pin: 5 + baud_rate: 115200 + +hlk_fm22x: + on_face_scan_matched: + - logger.log: test_hlk_22x_face_scan_matched + on_face_scan_unmatched: + - logger.log: test_hlk_22x_face_scan_unmatched + on_face_scan_invalid: + - logger.log: test_hlk_22x_face_scan_invalid + on_face_info: + - logger.log: test_hlk_22x_face_info + on_enrollment_done: + - logger.log: test_hlk_22x_enrollment_done + on_enrollment_failed: + - logger.log: test_hlk_22x_enrollment_failed + +sensor: + - platform: hlk_fm22x + face_count: + name: "Face Count" + last_face_id: + name: "Last Face ID" + status: + name: "Face Status" + +binary_sensor: + - platform: hlk_fm22x + name: "Face Enrolling" + +text_sensor: + - platform: hlk_fm22x + version: + name: "HLK Version" + last_face_name: + name: "Last Face Name" diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 378a226dc2..e52cb24831 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -671,44 +671,50 @@ class TestEsphomeCore: os.environ.pop("ESPHOME_DATA_DIR", None) assert target.data_dir == Path(expected_default) - def test_platformio_cache_dir_with_env_var(self): - """Test platformio_cache_dir when PLATFORMIO_CACHE_DIR env var is set.""" - target = core.EsphomeCore() - test_cache_dir = "/custom/cache/dir" + def test_web_port__none(self, target): + """Test web_port returns None when web_server is not configured.""" + target.config = {} + assert target.web_port is None - with patch.dict(os.environ, {"PLATFORMIO_CACHE_DIR": test_cache_dir}): - assert target.platformio_cache_dir == test_cache_dir + def test_web_port__explicit_web_server_default_port(self, target): + """Test web_port returns 80 when web_server is explicitly configured without port.""" + target.config = {const.CONF_WEB_SERVER: {}} + assert target.web_port == 80 - def test_platformio_cache_dir_without_env_var(self): - """Test platformio_cache_dir defaults to ~/.platformio/.cache.""" - target = core.EsphomeCore() + def test_web_port__explicit_web_server_custom_port(self, target): + """Test web_port returns custom port when web_server is configured with port.""" + target.config = {const.CONF_WEB_SERVER: {const.CONF_PORT: 8080}} + assert target.web_port == 8080 - with patch.dict(os.environ, {}, clear=True): - # Ensure env var is not set - os.environ.pop("PLATFORMIO_CACHE_DIR", None) - expected = os.path.expanduser("~/.platformio/.cache") - assert target.platformio_cache_dir == expected + def test_web_port__ota_web_server_platform_only(self, target): + """ + Test web_port returns None when ota.web_server platform is explicitly configured. - def test_platformio_cache_dir_empty_env_var(self): - """Test platformio_cache_dir with empty env var falls back to default.""" - target = core.EsphomeCore() + This is a critical test for Dashboard Issue #766: + https://github.com/esphome/dashboard/issues/766 - with patch.dict(os.environ, {"PLATFORMIO_CACHE_DIR": ""}): - expected = os.path.expanduser("~/.platformio/.cache") - assert target.platformio_cache_dir == expected + When ota: platform: web_server is explicitly configured (or auto-loaded by captive_portal): + - "web_server" appears in loaded_integrations (platform name added to integrations) + - "ota/web_server" appears in loaded_platforms + - But CONF_WEB_SERVER is NOT in config (only the platform is loaded, not the component) + - web_port MUST return None (no web UI available) + - Dashboard should NOT show VISIT button - def test_platformio_cache_dir_whitespace_env_var(self): - """Test platformio_cache_dir with whitespace-only env var falls back to default.""" - target = core.EsphomeCore() - - with patch.dict(os.environ, {"PLATFORMIO_CACHE_DIR": " "}): - expected = os.path.expanduser("~/.platformio/.cache") - assert target.platformio_cache_dir == expected - - def test_platformio_cache_dir_docker_addon_path(self): - """Test platformio_cache_dir in Docker/HA addon environment.""" - target = core.EsphomeCore() - addon_cache = "/data/cache/platformio" - - with patch.dict(os.environ, {"PLATFORMIO_CACHE_DIR": addon_cache}): - assert target.platformio_cache_dir == addon_cache + This test ensures web_port only checks CONF_WEB_SERVER in config, not loaded_integrations. + """ + # Simulate config with ota.web_server platform but no web_server component + # This happens when: + # 1. User explicitly configures: ota: - platform: web_server + # 2. OR captive_portal auto-loads ota.web_server + target.config = { + const.CONF_OTA: [ + { + "platform": "web_server", + # OTA web_server platform config would be here + } + ], + # Note: CONF_WEB_SERVER is NOT in config - only the OTA platform + } + # Even though "web_server" is in loaded_integrations due to the platform, + # web_port must return None because the full web_server component is not configured + assert target.web_port is None