diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index ab4f8cc960..c8f94cb6bb 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -1,17 +1,13 @@
 {
   "name": "ESPHome Dev",
   "image": "ghcr.io/esphome/esphome-lint:dev",
-  "postCreateCommand": [
-    "script/devcontainer-post-create"
-  ],
+  "postCreateCommand": ["script/devcontainer-post-create"],
   "containerEnv": {
-    "DEVCONTAINER": "1"
+    "DEVCONTAINER": "1",
+    "PIP_BREAK_SYSTEM_PACKAGES": "1",
+    "PIP_ROOT_USER_ACTION": "ignore"
   },
-  "runArgs": [
-    "--privileged",
-    "-e",
-    "ESPHOME_DASHBOARD_USE_PING=1"
-  ],
+  "runArgs": ["--privileged", "-e", "ESPHOME_DASHBOARD_USE_PING=1"],
   "appPort": 6052,
   "customizations": {
     "vscode": {
@@ -24,7 +20,7 @@
         // cpp
         "ms-vscode.cpptools",
         // editorconfig
-        "editorconfig.editorconfig",
+        "editorconfig.editorconfig"
       ],
       "settings": {
         "python.languageServer": "Pylance",
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 70da22e57a..8d1daf922f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -221,6 +221,29 @@ jobs:
         id: set-matrix
         run: echo "matrix=$(ls tests/test*.yaml | jq -R -s -c 'split("\n")[:-1]')" >> $GITHUB_OUTPUT
 
+  validate-tests:
+    name: Validate YAML test ${{ matrix.file }}
+    runs-on: ubuntu-latest
+    needs:
+      - common
+      - compile-tests-list
+    strategy:
+      fail-fast: false
+      matrix:
+        file: ${{ fromJson(needs.compile-tests-list.outputs.matrix) }}
+    steps:
+      - name: Check out code from GitHub
+        uses: actions/checkout@v4.1.1
+      - name: Restore Python
+        uses: ./.github/actions/restore-python
+        with:
+          python-version: ${{ env.DEFAULT_PYTHON }}
+          cache-key: ${{ needs.common.outputs.cache-key }}
+      - name: Run esphome config ${{ matrix.file }}
+        run: |
+          . venv/bin/activate
+          esphome config ${{ matrix.file }}
+
   compile-tests:
     name: Run YAML test ${{ matrix.file }}
     runs-on: ubuntu-latest
@@ -234,6 +257,7 @@ jobs:
       - pytest
       - pyupgrade
       - compile-tests-list
+      - validate-tests
     strategy:
       fail-fast: false
       max-parallel: 2
diff --git a/.github/workflows/needs-docs.yml b/.github/workflows/needs-docs.yml
new file mode 100644
index 0000000000..5019d64752
--- /dev/null
+++ b/.github/workflows/needs-docs.yml
@@ -0,0 +1,24 @@
+name: Needs Docs
+
+on:
+  pull_request:
+    types: [labeled, unlabeled]
+
+jobs:
+  check:
+    name: Check
+    runs-on: ubuntu-latest
+    steps:
+      - name: Check for needs-docs label
+        uses: actions/github-script@v6.4.1
+        with:
+          script: |
+            const { data: labels } = await github.rest.issues.listLabelsOnIssue({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              issue_number: context.issue.number
+            });
+            const needsDocs = labels.find(label => label.name === 'needs-docs');
+            if (needsDocs) {
+              core.setFailed('Pull request needs docs');
+            }
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index e76f8b0df2..ad8562640c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -3,7 +3,7 @@
 # See https://pre-commit.com/hooks.html for more hooks
 repos:
   - repo: https://github.com/psf/black-pre-commit-mirror
-    rev: 23.10.0
+    rev: 23.10.1
     hooks:
       - id: black
         args:
@@ -11,7 +11,7 @@ repos:
           - --quiet
         files: ^((esphome|script|tests)/.+)?[^/]+\.py$
   - repo: https://github.com/PyCQA/flake8
-    rev: 6.0.0
+    rev: 6.1.0
     hooks:
       - id: flake8
         additional_dependencies:
diff --git a/CODEOWNERS b/CODEOWNERS
index d48294bad3..6f87017420 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -77,6 +77,7 @@ esphome/components/dashboard_import/* @esphome/core
 esphome/components/debug/* @OttoWinter
 esphome/components/delonghi/* @grob6000
 esphome/components/dfplayer/* @glmnet
+esphome/components/dfrobot_sen0395/* @niklasweber
 esphome/components/dht/* @OttoWinter
 esphome/components/display_menu_base/* @numo68
 esphome/components/dps310/* @kbx81
@@ -151,6 +152,7 @@ esphome/components/key_provider/* @ssieb
 esphome/components/kuntze/* @ssieb
 esphome/components/lcd_menu/* @numo68
 esphome/components/ld2410/* @regevbr @sebcaps
+esphome/components/ld2420/* @descipher
 esphome/components/ledc/* @OttoWinter
 esphome/components/libretiny/* @kuba2k2
 esphome/components/libretiny_pwm/* @kuba2k2
@@ -182,6 +184,7 @@ esphome/components/mcp9808/* @k7hpn
 esphome/components/md5/* @esphome/core
 esphome/components/mdns/* @esphome/core
 esphome/components/media_player/* @jesserockz
+esphome/components/micronova/* @jorre05
 esphome/components/microphone/* @jesserockz
 esphome/components/mics_4514/* @jesserockz
 esphome/components/midea/* @dudanov
@@ -217,7 +220,7 @@ esphome/components/optolink/* @j0ta29
 esphome/components/ota/* @esphome/core
 esphome/components/output/* @esphome/core
 esphome/components/pca6416a/* @Mat931
-esphome/components/pca9554/* @hwstar
+esphome/components/pca9554/* @clydebarrow @hwstar
 esphome/components/pcf85063/* @brogon
 esphome/components/pcf8563/* @KoenBreeman
 esphome/components/pid/* @OttoWinter
diff --git a/docker/Dockerfile b/docker/Dockerfile
index f076173519..72aa9d9a9c 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -43,10 +43,11 @@ RUN \
           zlib1g-dev=1:1.2.13.dfsg-1 \
           libjpeg-dev=1:2.1.5-2 \
           libfreetype-dev=2.12.1+dfsg-5 \
-          libssl-dev=3.0.11-1~deb12u1 \
+          libssl-dev=3.0.11-1~deb12u2 \
           libffi-dev=3.4.4-1 \
           cargo=0.66.0+ds1-1 \
           pkg-config=1.8.1-1; \
+          gcc-arm-linux-gnueabihf=4:12.2.0-3; \
     fi; \
     rm -rf \
         /tmp/* \
@@ -100,6 +101,10 @@ ENV USERNAME="" PASSWORD=""
 # Expose the dashboard to Docker
 EXPOSE 6052
 
+# Run healthcheck (heartbeat)
+HEALTHCHECK --interval=30s --timeout=30s \
+  CMD curl --fail http://localhost:6052/version -A "HealthCheck" || exit 1
+
 COPY docker/docker_entrypoint.sh /entrypoint.sh
 
 # The directory the user should mount their configuration files to
diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py
index 1076ebc707..d6b4416af8 100644
--- a/esphome/components/api/__init__.py
+++ b/esphome/components/api/__init__.py
@@ -18,6 +18,8 @@ from esphome.const import (
     CONF_TRIGGER_ID,
     CONF_EVENT,
     CONF_TAG,
+    CONF_ON_CLIENT_CONNECTED,
+    CONF_ON_CLIENT_DISCONNECTED,
 )
 from esphome.core import coroutine_with_priority
 
@@ -87,6 +89,12 @@ CONFIG_SCHEMA = cv.Schema(
                 cv.Required(CONF_KEY): validate_encryption_key,
             }
         ),
+        cv.Optional(CONF_ON_CLIENT_CONNECTED): automation.validate_automation(
+            single=True
+        ),
+        cv.Optional(CONF_ON_CLIENT_DISCONNECTED): automation.validate_automation(
+            single=True
+        ),
     }
 ).extend(cv.COMPONENT_SCHEMA)
 
@@ -116,6 +124,20 @@ async def to_code(config):
         cg.add(var.register_user_service(trigger))
         await automation.build_automation(trigger, func_args, conf)
 
+    if CONF_ON_CLIENT_CONNECTED in config:
+        await automation.build_automation(
+            var.get_client_connected_trigger(),
+            [(cg.std_string, "client_info"), (cg.std_string, "client_address")],
+            config[CONF_ON_CLIENT_CONNECTED],
+        )
+
+    if CONF_ON_CLIENT_DISCONNECTED in config:
+        await automation.build_automation(
+            var.get_client_disconnected_trigger(),
+            [(cg.std_string, "client_info"), (cg.std_string, "client_address")],
+            config[CONF_ON_CLIENT_DISCONNECTED],
+        )
+
     if encryption_config := config.get(CONF_ENCRYPTION):
         decoded = base64.b64decode(encryption_config[CONF_KEY])
         cg.add(var.set_noise_psk(list(decoded)))
diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto
index ca3071d6d9..2f33750686 100644
--- a/esphome/components/api/api.proto
+++ b/esphome/components/api/api.proto
@@ -217,6 +217,8 @@ message DeviceInfoResponse {
   string friendly_name = 13;
 
   uint32 voice_assistant_version = 14;
+
+  string suggested_area = 16;
 }
 
 message ListEntitiesRequest {
diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp
index bc61271e93..0389df215f 100644
--- a/esphome/components/api/api_connection.cpp
+++ b/esphome/components/api/api_connection.cpp
@@ -32,9 +32,9 @@ APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *pa
   this->proto_write_buffer_.reserve(64);
 
 #if defined(USE_API_PLAINTEXT)
-  helper_ = std::unique_ptr<APIFrameHelper>{new APIPlaintextFrameHelper(std::move(sock))};
+  this->helper_ = std::unique_ptr<APIFrameHelper>{new APIPlaintextFrameHelper(std::move(sock))};
 #elif defined(USE_API_NOISE)
-  helper_ = std::unique_ptr<APIFrameHelper>{new APINoiseFrameHelper(std::move(sock), parent->get_noise_ctx())};
+  this->helper_ = std::unique_ptr<APIFrameHelper>{new APINoiseFrameHelper(std::move(sock), parent->get_noise_ctx())};
 #else
 #error "No frame helper defined"
 #endif
@@ -42,14 +42,16 @@ APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *pa
 void APIConnection::start() {
   this->last_traffic_ = millis();
 
-  APIError err = helper_->init();
+  APIError err = this->helper_->init();
   if (err != APIError::OK) {
     on_fatal_error();
-    ESP_LOGW(TAG, "%s: Helper init failed: %s errno=%d", client_info_.c_str(), api_error_to_str(err), errno);
+    ESP_LOGW(TAG, "%s: Helper init failed: %s errno=%d", this->client_combined_info_.c_str(), api_error_to_str(err),
+             errno);
     return;
   }
-  client_info_ = helper_->getpeername();
-  helper_->set_log_info(client_info_);
+  this->client_info_ = helper_->getpeername();
+  this->client_peername_ = this->client_info_;
+  this->helper_->set_log_info(this->client_info_);
 }
 
 APIConnection::~APIConnection() {
@@ -58,6 +60,11 @@ APIConnection::~APIConnection() {
     bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this);
   }
 #endif
+#ifdef USE_VOICE_ASSISTANT
+  if (voice_assistant::global_voice_assistant->get_api_connection() == this) {
+    voice_assistant::global_voice_assistant->client_subscription(this, false);
+  }
+#endif
 }
 
 void APIConnection::loop() {
@@ -68,7 +75,7 @@ void APIConnection::loop() {
     // when network is disconnected force disconnect immediately
     // don't wait for timeout
     this->on_fatal_error();
-    ESP_LOGW(TAG, "%s: Network unavailable, disconnecting", client_info_.c_str());
+    ESP_LOGW(TAG, "%s: Network unavailable, disconnecting", this->client_combined_info_.c_str());
     return;
   }
   if (this->next_close_) {
@@ -78,24 +85,26 @@ void APIConnection::loop() {
     return;
   }
 
-  APIError err = helper_->loop();
+  APIError err = this->helper_->loop();
   if (err != APIError::OK) {
     on_fatal_error();
-    ESP_LOGW(TAG, "%s: Socket operation failed: %s errno=%d", client_info_.c_str(), api_error_to_str(err), errno);
+    ESP_LOGW(TAG, "%s: Socket operation failed: %s errno=%d", this->client_combined_info_.c_str(),
+             api_error_to_str(err), errno);
     return;
   }
   ReadPacketBuffer buffer;
-  err = helper_->read_packet(&buffer);
+  err = this->helper_->read_packet(&buffer);
   if (err == APIError::WOULD_BLOCK) {
     // pass
   } else if (err != APIError::OK) {
     on_fatal_error();
     if (err == APIError::SOCKET_READ_FAILED && errno == ECONNRESET) {
-      ESP_LOGW(TAG, "%s: Connection reset", client_info_.c_str());
+      ESP_LOGW(TAG, "%s: Connection reset", this->client_combined_info_.c_str());
     } else if (err == APIError::CONNECTION_CLOSED) {
-      ESP_LOGW(TAG, "%s: Connection closed", client_info_.c_str());
+      ESP_LOGW(TAG, "%s: Connection closed", this->client_combined_info_.c_str());
     } else {
-      ESP_LOGW(TAG, "%s: Reading failed: %s errno=%d", client_info_.c_str(), api_error_to_str(err), errno);
+      ESP_LOGW(TAG, "%s: Reading failed: %s errno=%d", this->client_combined_info_.c_str(), api_error_to_str(err),
+               errno);
     }
     return;
   } else {
@@ -115,7 +124,7 @@ void APIConnection::loop() {
     // Disconnect if not responded within 2.5*keepalive
     if (now - this->last_traffic_ > (keepalive * 5) / 2) {
       on_fatal_error();
-      ESP_LOGW(TAG, "%s didn't respond to ping request in time. Disconnecting...", this->client_info_.c_str());
+      ESP_LOGW(TAG, "%s didn't respond to ping request in time. Disconnecting...", this->client_combined_info_.c_str());
     }
   } else if (now - this->last_traffic_ > keepalive) {
     ESP_LOGVV(TAG, "Sending keepalive PING...");
@@ -169,7 +178,7 @@ DisconnectResponse APIConnection::disconnect(const DisconnectRequest &msg) {
   // remote initiated disconnect_client
   // don't close yet, we still need to send the disconnect response
   // close will happen on next loop
-  ESP_LOGD(TAG, "%s requested disconnected", client_info_.c_str());
+  ESP_LOGD(TAG, "%s requested disconnected", this->client_combined_info_.c_str());
   this->next_close_ = true;
   DisconnectResponse resp;
   return resp;
@@ -946,14 +955,17 @@ BluetoothConnectionsFreeResponse APIConnection::subscribe_bluetooth_connections_
 #endif
 
 #ifdef USE_VOICE_ASSISTANT
-bool APIConnection::request_voice_assistant(const VoiceAssistantRequest &msg) {
-  if (!this->voice_assistant_subscription_)
-    return false;
-
-  return this->send_voice_assistant_request(msg);
+void APIConnection::subscribe_voice_assistant(const SubscribeVoiceAssistantRequest &msg) {
+  if (voice_assistant::global_voice_assistant != nullptr) {
+    voice_assistant::global_voice_assistant->client_subscription(this, msg.subscribe);
+  }
 }
 void APIConnection::on_voice_assistant_response(const VoiceAssistantResponse &msg) {
   if (voice_assistant::global_voice_assistant != nullptr) {
+    if (voice_assistant::global_voice_assistant->get_api_connection() != this) {
+      return;
+    }
+
     if (msg.error) {
       voice_assistant::global_voice_assistant->failed_to_start();
       return;
@@ -966,6 +978,10 @@ void APIConnection::on_voice_assistant_response(const VoiceAssistantResponse &ms
 };
 void APIConnection::on_voice_assistant_event_response(const VoiceAssistantEventResponse &msg) {
   if (voice_assistant::global_voice_assistant != nullptr) {
+    if (voice_assistant::global_voice_assistant->get_api_connection() != this) {
+      return;
+    }
+
     voice_assistant::global_voice_assistant->on_event(msg);
   }
 }
@@ -1045,12 +1061,14 @@ bool APIConnection::send_log_message(int level, const char *tag, const char *lin
 }
 
 HelloResponse APIConnection::hello(const HelloRequest &msg) {
-  this->client_info_ = msg.client_info + " (" + this->helper_->getpeername() + ")";
-  this->helper_->set_log_info(client_info_);
+  this->client_info_ = msg.client_info;
+  this->client_peername_ = this->helper_->getpeername();
+  this->client_combined_info_ = this->client_info_ + " (" + this->client_peername_ + ")";
+  this->helper_->set_log_info(this->client_combined_info_);
   this->client_api_version_major_ = msg.api_version_major;
   this->client_api_version_minor_ = msg.api_version_minor;
-  ESP_LOGV(TAG, "Hello from client: '%s' | API Version %" PRIu32 ".%" PRIu32, this->client_info_.c_str(),
-           this->client_api_version_major_, this->client_api_version_minor_);
+  ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu32 ".%" PRIu32, this->client_info_.c_str(),
+           this->client_peername_.c_str(), this->client_api_version_major_, this->client_api_version_minor_);
 
   HelloResponse resp;
   resp.api_version_major = 1;
@@ -1068,9 +1086,9 @@ ConnectResponse APIConnection::connect(const ConnectRequest &msg) {
   // bool invalid_password = 1;
   resp.invalid_password = !correct;
   if (correct) {
-    ESP_LOGD(TAG, "%s: Connected successfully", this->client_info_.c_str());
+    ESP_LOGD(TAG, "%s: Connected successfully", this->client_combined_info_.c_str());
     this->connection_state_ = ConnectionState::AUTHENTICATED;
-
+    this->parent_->get_client_connected_trigger()->trigger(this->client_info_, this->client_peername_);
 #ifdef USE_HOMEASSISTANT_TIME
     if (homeassistant::global_homeassistant_time != nullptr) {
       this->send_time_request();
@@ -1084,6 +1102,7 @@ DeviceInfoResponse APIConnection::device_info(const DeviceInfoRequest &msg) {
   resp.uses_password = this->parent_->uses_password();
   resp.name = App.get_name();
   resp.friendly_name = App.get_friendly_name();
+  resp.suggested_area = App.get_area();
   resp.mac_address = get_mac_address_pretty();
   resp.esphome_version = ESPHOME_VERSION;
   resp.compilation_time = App.get_compilation_time();
@@ -1144,10 +1163,11 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint32_t message_type)
     return false;
   if (!this->helper_->can_write_without_blocking()) {
     delay(0);
-    APIError err = helper_->loop();
+    APIError err = this->helper_->loop();
     if (err != APIError::OK) {
       on_fatal_error();
-      ESP_LOGW(TAG, "%s: Socket operation failed: %s errno=%d", client_info_.c_str(), api_error_to_str(err), errno);
+      ESP_LOGW(TAG, "%s: Socket operation failed: %s errno=%d", this->client_combined_info_.c_str(),
+               api_error_to_str(err), errno);
       return false;
     }
     if (!this->helper_->can_write_without_blocking()) {
@@ -1166,9 +1186,10 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint32_t message_type)
   if (err != APIError::OK) {
     on_fatal_error();
     if (err == APIError::SOCKET_WRITE_FAILED && errno == ECONNRESET) {
-      ESP_LOGW(TAG, "%s: Connection reset", client_info_.c_str());
+      ESP_LOGW(TAG, "%s: Connection reset", this->client_combined_info_.c_str());
     } else {
-      ESP_LOGW(TAG, "%s: Packet write failed %s errno=%d", client_info_.c_str(), api_error_to_str(err), errno);
+      ESP_LOGW(TAG, "%s: Packet write failed %s errno=%d", this->client_combined_info_.c_str(), api_error_to_str(err),
+               errno);
     }
     return false;
   }
@@ -1177,11 +1198,11 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint32_t message_type)
 }
 void APIConnection::on_unauthenticated_access() {
   this->on_fatal_error();
-  ESP_LOGD(TAG, "%s: tried to access without authentication.", this->client_info_.c_str());
+  ESP_LOGD(TAG, "%s: tried to access without authentication.", this->client_combined_info_.c_str());
 }
 void APIConnection::on_no_setup_connection() {
   this->on_fatal_error();
-  ESP_LOGD(TAG, "%s: tried to access without full connection.", this->client_info_.c_str());
+  ESP_LOGD(TAG, "%s: tried to access without full connection.", this->client_combined_info_.c_str());
 }
 void APIConnection::on_fatal_error() {
   this->helper_->close();
diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h
index c17aaab611..09b595bb71 100644
--- a/esphome/components/api/api_connection.h
+++ b/esphome/components/api/api_connection.h
@@ -126,10 +126,7 @@ class APIConnection : public APIServerConnection {
 #endif
 
 #ifdef USE_VOICE_ASSISTANT
-  void subscribe_voice_assistant(const SubscribeVoiceAssistantRequest &msg) override {
-    this->voice_assistant_subscription_ = msg.subscribe;
-  }
-  bool request_voice_assistant(const VoiceAssistantRequest &msg);
+  void subscribe_voice_assistant(const SubscribeVoiceAssistantRequest &msg) override;
   void on_voice_assistant_response(const VoiceAssistantResponse &msg) override;
   void on_voice_assistant_event_response(const VoiceAssistantEventResponse &msg) override;
 #endif
@@ -188,6 +185,8 @@ class APIConnection : public APIServerConnection {
   }
   bool send_buffer(ProtoWriteBuffer buffer, uint32_t message_type) override;
 
+  std::string get_client_combined_info() const { return this->client_combined_info_; }
+
  protected:
   friend APIServer;
 
@@ -207,6 +206,8 @@ class APIConnection : public APIServerConnection {
   std::unique_ptr<APIFrameHelper> helper_;
 
   std::string client_info_;
+  std::string client_peername_;
+  std::string client_combined_info_;
   uint32_t client_api_version_major_{0};
   uint32_t client_api_version_minor_{0};
 #ifdef USE_ESP32_CAMERA
@@ -218,9 +219,6 @@ class APIConnection : public APIServerConnection {
   uint32_t last_traffic_;
   bool sent_ping_{false};
   bool service_call_subscription_{false};
-#ifdef USE_VOICE_ASSISTANT
-  bool voice_assistant_subscription_{false};
-#endif
   bool next_close_ = false;
   APIServer *parent_;
   InitialStateIterator initial_state_iterator_;
diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp
index c070b3c988..1e97a57bb1 100644
--- a/esphome/components/api/api_pb2.cpp
+++ b/esphome/components/api/api_pb2.cpp
@@ -761,6 +761,10 @@ bool DeviceInfoResponse::decode_length(uint32_t field_id, ProtoLengthDelimited v
       this->friendly_name = value.as_string();
       return true;
     }
+    case 16: {
+      this->suggested_area = value.as_string();
+      return true;
+    }
     default:
       return false;
   }
@@ -781,6 +785,7 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const {
   buffer.encode_string(12, this->manufacturer);
   buffer.encode_string(13, this->friendly_name);
   buffer.encode_uint32(14, this->voice_assistant_version);
+  buffer.encode_string(16, this->suggested_area);
 }
 #ifdef HAS_PROTO_MESSAGE_DUMP
 void DeviceInfoResponse::dump_to(std::string &out) const {
@@ -849,6 +854,10 @@ void DeviceInfoResponse::dump_to(std::string &out) const {
   sprintf(buffer, "%" PRIu32, this->voice_assistant_version);
   out.append(buffer);
   out.append("\n");
+
+  out.append("  suggested_area: ");
+  out.append("'").append(this->suggested_area).append("'");
+  out.append("\n");
   out.append("}");
 }
 #endif
diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h
index b935784831..a63e90b7b7 100644
--- a/esphome/components/api/api_pb2.h
+++ b/esphome/components/api/api_pb2.h
@@ -328,6 +328,7 @@ class DeviceInfoResponse : public ProtoMessage {
   std::string manufacturer{};
   std::string friendly_name{};
   uint32_t voice_assistant_version{0};
+  std::string suggested_area{};
   void encode(ProtoWriteBuffer buffer) const override;
 #ifdef HAS_PROTO_MESSAGE_DUMP
   void dump_to(std::string &out) const override;
diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp
index c4edddc92b..0348112fcd 100644
--- a/esphome/components/api/api_server.cpp
+++ b/esphome/components/api/api_server.cpp
@@ -111,6 +111,7 @@ void APIServer::loop() {
                                 [](const std::unique_ptr<APIConnection> &conn) { return !conn->remove_; });
   // print disconnection messages
   for (auto it = new_end; it != this->clients_.end(); ++it) {
+    this->client_disconnected_trigger_->trigger((*it)->client_info_, (*it)->client_peername_);
     ESP_LOGV(TAG, "Removing connection to %s", (*it)->client_info_.c_str());
   }
   // resize vector
@@ -331,30 +332,6 @@ void APIServer::on_shutdown() {
   delay(10);
 }
 
-#ifdef USE_VOICE_ASSISTANT
-bool APIServer::start_voice_assistant(const std::string &conversation_id, uint32_t flags,
-                                      const api::VoiceAssistantAudioSettings &audio_settings) {
-  VoiceAssistantRequest msg;
-  msg.start = true;
-  msg.conversation_id = conversation_id;
-  msg.flags = flags;
-  msg.audio_settings = audio_settings;
-  for (auto &c : this->clients_) {
-    if (c->request_voice_assistant(msg))
-      return true;
-  }
-  return false;
-}
-void APIServer::stop_voice_assistant() {
-  VoiceAssistantRequest msg;
-  msg.start = false;
-  for (auto &c : this->clients_) {
-    if (c->request_voice_assistant(msg))
-      return;
-  }
-}
-#endif
-
 #ifdef USE_ALARM_CONTROL_PANEL
 void APIServer::on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj) {
   if (obj->is_internal())
diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h
index 4d359ebb79..9605a196b3 100644
--- a/esphome/components/api/api_server.h
+++ b/esphome/components/api/api_server.h
@@ -4,6 +4,7 @@
 #include "api_pb2.h"
 #include "api_pb2_service.h"
 #include "esphome/components/socket/socket.h"
+#include "esphome/core/automation.h"
 #include "esphome/core/component.h"
 #include "esphome/core/controller.h"
 #include "esphome/core/defines.h"
@@ -83,12 +84,6 @@ class APIServer : public Component, public Controller {
   void request_time();
 #endif
 
-#ifdef USE_VOICE_ASSISTANT
-  bool start_voice_assistant(const std::string &conversation_id, uint32_t flags,
-                             const api::VoiceAssistantAudioSettings &audio_settings);
-  void stop_voice_assistant();
-#endif
-
 #ifdef USE_ALARM_CONTROL_PANEL
   void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj) override;
 #endif
@@ -106,6 +101,11 @@ class APIServer : public Component, public Controller {
   const std::vector<HomeAssistantStateSubscription> &get_state_subs() const;
   const std::vector<UserServiceDescriptor *> &get_user_services() const { return this->user_services_; }
 
+  Trigger<std::string, std::string> *get_client_connected_trigger() const { return this->client_connected_trigger_; }
+  Trigger<std::string, std::string> *get_client_disconnected_trigger() const {
+    return this->client_disconnected_trigger_;
+  }
+
  protected:
   std::unique_ptr<socket::Socket> socket_ = nullptr;
   uint16_t port_{6053};
@@ -115,6 +115,8 @@ class APIServer : public Component, public Controller {
   std::string password_;
   std::vector<HomeAssistantStateSubscription> state_subs_;
   std::vector<UserServiceDescriptor *> user_services_;
+  Trigger<std::string, std::string> *client_connected_trigger_ = new Trigger<std::string, std::string>();
+  Trigger<std::string, std::string> *client_disconnected_trigger_ = new Trigger<std::string, std::string>();
 
 #ifdef USE_API_NOISE
   std::shared_ptr<APINoiseContext> noise_ctx_ = std::make_shared<APINoiseContext>();
diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp
index cc78528e46..78eee4b226 100644
--- a/esphome/components/captive_portal/captive_portal.cpp
+++ b/esphome/components/captive_portal/captive_portal.cpp
@@ -48,7 +48,7 @@ void CaptivePortal::start() {
   this->dns_server_ = make_unique<DNSServer>();
   this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError);
   network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip();
-  this->dns_server_->start(53, "*", IPAddress(ip));
+  this->dns_server_->start(53, "*", ip);
 #endif
 
   this->base_->get_server()->onNotFound([this](AsyncWebServerRequest *req) {
diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py
new file mode 100644
index 0000000000..e772db5a15
--- /dev/null
+++ b/esphome/components/dfrobot_sen0395/__init__.py
@@ -0,0 +1,208 @@
+import esphome.codegen as cg
+import esphome.config_validation as cv
+from esphome import automation
+from esphome import core
+from esphome.automation import maybe_simple_id
+from esphome.const import CONF_ID
+from esphome.components import uart
+
+CODEOWNERS = ["@niklasweber"]
+DEPENDENCIES = ["uart"]
+MULTI_CONF = True
+
+dfrobot_sen0395_ns = cg.esphome_ns.namespace("dfrobot_sen0395")
+DfrobotSen0395Component = dfrobot_sen0395_ns.class_(
+    "DfrobotSen0395Component", cg.Component
+)
+
+# Actions
+DfrobotSen0395ResetAction = dfrobot_sen0395_ns.class_(
+    "DfrobotSen0395ResetAction", automation.Action
+)
+DfrobotSen0395SettingsAction = dfrobot_sen0395_ns.class_(
+    "DfrobotSen0395SettingsAction", automation.Action
+)
+
+CONF_DFROBOT_SEN0395_ID = "dfrobot_sen0395_id"
+
+CONF_DELAY_AFTER_DETECT = "delay_after_detect"
+CONF_DELAY_AFTER_DISAPPEAR = "delay_after_disappear"
+CONF_DETECTION_SEGMENTS = "detection_segments"
+CONF_OUTPUT_LATENCY = "output_latency"
+CONF_FACTORY_RESET = "factory_reset"
+CONF_SENSITIVITY = "sensitivity"
+
+CONFIG_SCHEMA = cv.All(
+    cv.Schema(
+        {
+            cv.GenerateID(): cv.declare_id(DfrobotSen0395Component),
+        }
+    ).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)
+
+
+@automation.register_action(
+    "dfrobot_sen0395.reset",
+    DfrobotSen0395ResetAction,
+    maybe_simple_id(
+        {
+            cv.GenerateID(): cv.use_id(DfrobotSen0395Component),
+        }
+    ),
+)
+async def dfrobot_sen0395_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
+
+
+def range_segment_list(input):
+    """Validate input is a list of ranges which can be used to configure the dfrobot mmwave radar
+
+    A list of segments should be provided. A minimum of one segment is required and a maximum of
+    four segments is allowed. A segment describes a range of distances. E.g. from 0mm to 1m.
+    The distances need to be defined in an ascending order and they cannot contain / intersect
+    each other.
+    """
+
+    # Flatten input to one dimensional list
+    flat_list = []
+    if isinstance(input, list):
+        for list_item in input:
+            if isinstance(list_item, list):
+                for item in list_item:
+                    flat_list.append(item)
+            else:
+                flat_list.append(list_item)
+    else:
+        flat_list.append(input)
+
+    input = flat_list
+
+    if len(input) < 2:
+        raise cv.Invalid(
+            "At least two values need to be specified (start + stop distances)"
+        )
+    if len(input) % 2 != 0:
+        raise cv.Invalid(
+            "An even number of arguments must be specified (pairs of min + max)"
+        )
+    if len(input) > 8:
+        raise cv.Invalid(
+            "Maximum four segments can be specified (8 values: 4 * min + max)"
+        )
+
+    largest_distance = -1
+    for distance in input:
+        if isinstance(distance, core.Lambda):
+            continue
+        m = cv.distance(distance)
+        if m > 9:
+            raise cv.Invalid("Maximum distance is 9m")
+        if m < 0:
+            raise cv.Invalid("Minimum distance is 0m")
+        if m <= largest_distance:
+            raise cv.Invalid(
+                "Distances must be delared from small to large "
+                "and they cannot contain each other"
+            )
+        largest_distance = m
+        # Replace distance object with meters float
+        input[input.index(distance)] = m
+
+    return input
+
+
+MMWAVE_SETTINGS_SCHEMA = cv.Schema(
+    {
+        cv.GenerateID(): cv.use_id(DfrobotSen0395Component),
+        cv.Optional(CONF_FACTORY_RESET): cv.templatable(cv.boolean),
+        cv.Optional(CONF_DETECTION_SEGMENTS): range_segment_list,
+        cv.Optional(CONF_OUTPUT_LATENCY): {
+            cv.Required(CONF_DELAY_AFTER_DETECT): cv.templatable(
+                cv.All(
+                    cv.positive_time_period,
+                    cv.Range(max=core.TimePeriod(seconds=1638.375)),
+                )
+            ),
+            cv.Required(CONF_DELAY_AFTER_DISAPPEAR): cv.templatable(
+                cv.All(
+                    cv.positive_time_period,
+                    cv.Range(max=core.TimePeriod(seconds=1638.375)),
+                )
+            ),
+        },
+        cv.Optional(CONF_SENSITIVITY): cv.templatable(cv.int_range(min=0, max=9)),
+    }
+).add_extra(
+    cv.has_at_least_one_key(
+        CONF_FACTORY_RESET,
+        CONF_DETECTION_SEGMENTS,
+        CONF_OUTPUT_LATENCY,
+        CONF_SENSITIVITY,
+    )
+)
+
+
+@automation.register_action(
+    "dfrobot_sen0395.settings",
+    DfrobotSen0395SettingsAction,
+    MMWAVE_SETTINGS_SCHEMA,
+)
+async def dfrobot_sen0395_settings_to_code(config, action_id, template_arg, args):
+    var = cg.new_Pvariable(action_id, template_arg)
+    await cg.register_parented(var, config[CONF_ID])
+
+    if factory_reset_config := config.get(CONF_FACTORY_RESET):
+        template_ = await cg.templatable(factory_reset_config, args, int)
+        cg.add(var.set_factory_reset(template_))
+
+    if CONF_DETECTION_SEGMENTS in config:
+        segments = config[CONF_DETECTION_SEGMENTS]
+
+        if len(segments) >= 2:
+            template_ = await cg.templatable(segments[0], args, float)
+            cg.add(var.set_det_min1(template_))
+            template_ = await cg.templatable(segments[1], args, float)
+            cg.add(var.set_det_max1(template_))
+        if len(segments) >= 4:
+            template_ = await cg.templatable(segments[2], args, float)
+            cg.add(var.set_det_min2(template_))
+            template_ = await cg.templatable(segments[3], args, float)
+            cg.add(var.set_det_max2(template_))
+        if len(segments) >= 6:
+            template_ = await cg.templatable(segments[4], args, float)
+            cg.add(var.set_det_min3(template_))
+            template_ = await cg.templatable(segments[5], args, float)
+            cg.add(var.set_det_max3(template_))
+        if len(segments) >= 8:
+            template_ = await cg.templatable(segments[6], args, float)
+            cg.add(var.set_det_min4(template_))
+            template_ = await cg.templatable(segments[7], args, float)
+            cg.add(var.set_det_max4(template_))
+    if CONF_OUTPUT_LATENCY in config:
+        template_ = await cg.templatable(
+            config[CONF_OUTPUT_LATENCY][CONF_DELAY_AFTER_DETECT], args, float
+        )
+        if isinstance(template_, cv.TimePeriod):
+            template_ = template_.total_milliseconds / 1000
+        cg.add(var.set_delay_after_detect(template_))
+
+        template_ = await cg.templatable(
+            config[CONF_OUTPUT_LATENCY][CONF_DELAY_AFTER_DISAPPEAR], args, float
+        )
+        if isinstance(template_, cv.TimePeriod):
+            template_ = template_.total_milliseconds / 1000
+        cg.add(var.set_delay_after_disappear(template_))
+    if CONF_SENSITIVITY in config:
+        template_ = await cg.templatable(config[CONF_SENSITIVITY], args, int)
+        cg.add(var.set_sensitivity(template_))
+
+    return var
diff --git a/esphome/components/dfrobot_sen0395/automation.h b/esphome/components/dfrobot_sen0395/automation.h
new file mode 100644
index 0000000000..1f942c02e4
--- /dev/null
+++ b/esphome/components/dfrobot_sen0395/automation.h
@@ -0,0 +1,89 @@
+#pragma once
+
+#include "esphome/core/automation.h"
+#include "esphome/core/helpers.h"
+
+#include "dfrobot_sen0395.h"
+
+namespace esphome {
+namespace dfrobot_sen0395 {
+
+template<typename... Ts>
+class DfrobotSen0395ResetAction : public Action<Ts...>, public Parented<DfrobotSen0395Component> {
+ public:
+  void play(Ts... x) { this->parent_->enqueue(make_unique<ResetSystemCommand>()); }
+};
+
+template<typename... Ts>
+class DfrobotSen0395SettingsAction : public Action<Ts...>, public Parented<DfrobotSen0395Component> {
+ public:
+  TEMPLATABLE_VALUE(int8_t, factory_reset)
+  TEMPLATABLE_VALUE(int8_t, start_after_power_on)
+  TEMPLATABLE_VALUE(int8_t, turn_on_led)
+  TEMPLATABLE_VALUE(int8_t, presence_via_uart)
+  TEMPLATABLE_VALUE(int8_t, sensitivity)
+  TEMPLATABLE_VALUE(float, delay_after_detect)
+  TEMPLATABLE_VALUE(float, delay_after_disappear)
+  TEMPLATABLE_VALUE(float, det_min1)
+  TEMPLATABLE_VALUE(float, det_max1)
+  TEMPLATABLE_VALUE(float, det_min2)
+  TEMPLATABLE_VALUE(float, det_max2)
+  TEMPLATABLE_VALUE(float, det_min3)
+  TEMPLATABLE_VALUE(float, det_max3)
+  TEMPLATABLE_VALUE(float, det_min4)
+  TEMPLATABLE_VALUE(float, det_max4)
+
+  void play(Ts... x) {
+    this->parent_->enqueue(make_unique<PowerCommand>(0));
+    if (this->factory_reset_.has_value() && this->factory_reset_.value(x...) == true) {
+      this->parent_->enqueue(make_unique<FactoryResetCommand>());
+    }
+    if (this->det_min1_.has_value() && this->det_max1_.has_value()) {
+      if (this->det_min1_.value() >= 0 && this->det_max1_.value() >= 0) {
+        this->parent_->enqueue(make_unique<DetRangeCfgCommand>(
+            this->det_min1_.value_or(-1), this->det_max1_.value_or(-1), this->det_min2_.value_or(-1),
+            this->det_max2_.value_or(-1), this->det_min3_.value_or(-1), this->det_max3_.value_or(-1),
+            this->det_min4_.value_or(-1), this->det_max4_.value_or(-1)));
+      }
+    }
+    if (this->delay_after_detect_.has_value() && this->delay_after_disappear_.has_value()) {
+      float detect = this->delay_after_detect_.value(x...);
+      float disappear = this->delay_after_disappear_.value(x...);
+      if (detect >= 0 && disappear >= 0) {
+        this->parent_->enqueue(make_unique<OutputLatencyCommand>(detect, disappear));
+      }
+    }
+    if (this->start_after_power_on_.has_value()) {
+      int8_t val = this->start_after_power_on_.value(x...);
+      if (val >= 0) {
+        this->parent_->enqueue(make_unique<SensorCfgStartCommand>(val));
+      }
+    }
+    if (this->turn_on_led_.has_value()) {
+      int8_t val = this->turn_on_led_.value(x...);
+      if (val >= 0) {
+        this->parent_->enqueue(make_unique<LedModeCommand>(val));
+      }
+    }
+    if (this->presence_via_uart_.has_value()) {
+      int8_t val = this->presence_via_uart_.value(x...);
+      if (val >= 0) {
+        this->parent_->enqueue(make_unique<UartOutputCommand>(val));
+      }
+    }
+    if (this->sensitivity_.has_value()) {
+      int8_t val = this->sensitivity_.value(x...);
+      if (val >= 0) {
+        if (val > 9) {
+          val = 9;
+        }
+        this->parent_->enqueue(make_unique<SensitivityCommand>(val));
+      }
+    }
+    this->parent_->enqueue(make_unique<SaveCfgCommand>());
+    this->parent_->enqueue(make_unique<PowerCommand>(1));
+  }
+};
+
+}  // namespace dfrobot_sen0395
+}  // namespace esphome
diff --git a/esphome/components/dfrobot_sen0395/binary_sensor.py b/esphome/components/dfrobot_sen0395/binary_sensor.py
new file mode 100644
index 0000000000..2fd0510476
--- /dev/null
+++ b/esphome/components/dfrobot_sen0395/binary_sensor.py
@@ -0,0 +1,22 @@
+import esphome.codegen as cg
+import esphome.config_validation as cv
+from esphome.components import binary_sensor
+from esphome.const import DEVICE_CLASS_MOTION
+from . import CONF_DFROBOT_SEN0395_ID, DfrobotSen0395Component
+
+DEPENDENCIES = ["dfrobot_sen0395"]
+
+CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(
+    device_class=DEVICE_CLASS_MOTION
+).extend(
+    {
+        cv.GenerateID(CONF_DFROBOT_SEN0395_ID): cv.use_id(DfrobotSen0395Component),
+    }
+)
+
+
+async def to_code(config):
+    parent = await cg.get_variable(config[CONF_DFROBOT_SEN0395_ID])
+    binary_sens = await binary_sensor.new_binary_sensor(config)
+
+    cg.add(parent.set_detected_binary_sensor(binary_sens))
diff --git a/esphome/components/dfrobot_sen0395/commands.cpp b/esphome/components/dfrobot_sen0395/commands.cpp
new file mode 100644
index 0000000000..3a89b2b71e
--- /dev/null
+++ b/esphome/components/dfrobot_sen0395/commands.cpp
@@ -0,0 +1,329 @@
+#include "commands.h"
+
+#include "esphome/core/log.h"
+
+#include "dfrobot_sen0395.h"
+
+namespace esphome {
+namespace dfrobot_sen0395 {
+
+static const char *const TAG = "dfrobot_sen0395.commands";
+
+uint8_t Command::execute(DfrobotSen0395Component *parent) {
+  this->parent_ = parent;
+  if (this->cmd_sent_) {
+    if (this->parent_->read_message_()) {
+      std::string message(this->parent_->read_buffer_);
+      if (message.rfind("is not recognized as a CLI command") != std::string::npos) {
+        ESP_LOGD(TAG, "Command not recognized properly by sensor");
+        if (this->retries_left_ > 0) {
+          this->retries_left_ -= 1;
+          this->cmd_sent_ = false;
+          ESP_LOGD(TAG, "Retrying...");
+          return 0;
+        } else {
+          this->parent_->find_prompt_();
+          return 1;  // Command done
+        }
+      }
+      uint8_t rc = on_message(message);
+      if (rc == 2) {
+        if (this->retries_left_ > 0) {
+          this->retries_left_ -= 1;
+          this->cmd_sent_ = false;
+          ESP_LOGD(TAG, "Retrying...");
+          return 0;
+        } else {
+          this->parent_->find_prompt_();
+          return 1;  // Command done
+        }
+      } else if (rc == 0) {
+        return 0;
+      } else {
+        this->parent_->find_prompt_();
+        return 1;
+      }
+    }
+    if (millis() - this->parent_->ts_last_cmd_sent_ > this->timeout_ms_) {
+      ESP_LOGD(TAG, "Command timeout");
+      if (this->retries_left_ > 0) {
+        this->retries_left_ -= 1;
+        this->cmd_sent_ = false;
+        ESP_LOGD(TAG, "Retrying...");
+      } else {
+        return 1;  // Command done
+      }
+    }
+  } else if (this->parent_->send_cmd_(this->cmd_.c_str(), this->cmd_duration_ms_)) {
+    this->cmd_sent_ = true;
+  }
+  return 0;  // Command not done yet
+}
+
+uint8_t ReadStateCommand::execute(DfrobotSen0395Component *parent) {
+  this->parent_ = parent;
+  if (this->parent_->read_message_()) {
+    std::string message(this->parent_->read_buffer_);
+    if (message.rfind("$JYBSS,0, , , *") != std::string::npos) {
+      this->parent_->set_detected_(false);
+      this->parent_->set_active(true);
+      return 1;  // Command done
+    } else if (message.rfind("$JYBSS,1, , , *") != std::string::npos) {
+      this->parent_->set_detected_(true);
+      this->parent_->set_active(true);
+      return 1;  // Command done
+    }
+  }
+  if (millis() - this->parent_->ts_last_cmd_sent_ > this->timeout_ms_) {
+    return 1;  // Command done, timeout
+  }
+  return 0;  // Command not done yet.
+}
+
+uint8_t ReadStateCommand::on_message(std::string &message) { return 1; }
+
+uint8_t PowerCommand::on_message(std::string &message) {
+  if (message == "sensor stopped already") {
+    this->parent_->set_active(false);
+    ESP_LOGI(TAG, "Stopped sensor (already stopped)");
+    return 1;  // Command done
+  } else if (message == "sensor started already") {
+    this->parent_->set_active(true);
+    ESP_LOGI(TAG, "Started sensor (already started)");
+    return 1;  // Command done
+  } else if (message == "new parameter isn't save, can't startSensor") {
+    this->parent_->set_active(false);
+    ESP_LOGE(TAG, "Can't start sensor! (Use SaveCfgCommand to save config first)");
+    return 1;  // Command done
+  } else if (message == "Done") {
+    this->parent_->set_active(this->power_on_);
+    if (this->power_on_) {
+      ESP_LOGI(TAG, "Started sensor");
+    } else {
+      ESP_LOGI(TAG, "Stopped sensor");
+    }
+    return 1;  // Command done
+  }
+  return 0;  // Command not done yet.
+}
+
+DetRangeCfgCommand::DetRangeCfgCommand(float min1, float max1, float min2, float max2, float min3, float max3,
+                                       float min4, float max4) {
+  // TODO: Print warning when values are rounded
+  if (min1 < 0 || max1 < 0) {
+    this->min1_ = min1 = 0;
+    this->max1_ = max1 = 0;
+    this->min2_ = min2 = this->max2_ = max2 = this->min3_ = min3 = this->max3_ = max3 = this->min4_ = min4 =
+        this->max4_ = max4 = -1;
+
+    ESP_LOGW(TAG, "DetRangeCfgCommand invalid input parameters. Using range config 0 0.");
+
+    this->cmd_ = "detRangeCfg -1 0 0";
+  } else if (min2 < 0 || max2 < 0) {
+    this->min1_ = min1 = round(min1 / 0.15) * 0.15;
+    this->max1_ = max1 = round(max1 / 0.15) * 0.15;
+    this->min2_ = min2 = this->max2_ = max2 = this->min3_ = min3 = this->max3_ = max3 = this->min4_ = min4 =
+        this->max4_ = max4 = -1;
+
+    this->cmd_ = str_sprintf("detRangeCfg -1 %.0f %.0f", min1 / 0.15, max1 / 0.15);
+  } else if (min3 < 0 || max3 < 0) {
+    this->min1_ = min1 = round(min1 / 0.15) * 0.15;
+    this->max1_ = max1 = round(max1 / 0.15) * 0.15;
+    this->min2_ = min2 = round(min2 / 0.15) * 0.15;
+    this->max2_ = max2 = round(max2 / 0.15) * 0.15;
+    this->min3_ = min3 = this->max3_ = max3 = this->min4_ = min4 = this->max4_ = max4 = -1;
+
+    this->cmd_ = str_sprintf("detRangeCfg -1 %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, min2 / 0.15, max2 / 0.15);
+  } else if (min4 < 0 || max4 < 0) {
+    this->min1_ = min1 = round(min1 / 0.15) * 0.15;
+    this->max1_ = max1 = round(max1 / 0.15) * 0.15;
+    this->min2_ = min2 = round(min2 / 0.15) * 0.15;
+    this->max2_ = max2 = round(max2 / 0.15) * 0.15;
+    this->min3_ = min3 = round(min3 / 0.15) * 0.15;
+    this->max3_ = max3 = round(max3 / 0.15) * 0.15;
+    this->min4_ = min4 = this->max4_ = max4 = -1;
+
+    this->cmd_ = str_sprintf("detRangeCfg -1 "
+                             "%.0f %.0f %.0f %.0f %.0f %.0f",
+                             min1 / 0.15, max1 / 0.15, min2 / 0.15, max2 / 0.15, min3 / 0.15, max3 / 0.15);
+  } else {
+    this->min1_ = min1 = round(min1 / 0.15) * 0.15;
+    this->max1_ = max1 = round(max1 / 0.15) * 0.15;
+    this->min2_ = min2 = round(min2 / 0.15) * 0.15;
+    this->max2_ = max2 = round(max2 / 0.15) * 0.15;
+    this->min3_ = min3 = round(min3 / 0.15) * 0.15;
+    this->max3_ = max3 = round(max3 / 0.15) * 0.15;
+    this->min4_ = min4 = round(min4 / 0.15) * 0.15;
+    this->max4_ = max4 = round(max4 / 0.15) * 0.15;
+
+    this->cmd_ = str_sprintf("detRangeCfg -1 "
+                             "%.0f %.0f %.0f %.0f %.0f %.0f %.0f %.0f",
+                             min1 / 0.15, max1 / 0.15, min2 / 0.15, max2 / 0.15, min3 / 0.15, max3 / 0.15, min4 / 0.15,
+                             max4 / 0.15);
+  }
+
+  this->min1_ = min1;
+  this->max1_ = max1;
+  this->min2_ = min2;
+  this->max2_ = max2;
+  this->min3_ = min3;
+  this->max3_ = max3;
+  this->min4_ = min4;
+  this->max4_ = max4;
+};
+
+uint8_t DetRangeCfgCommand::on_message(std::string &message) {
+  if (message == "sensor is not stopped") {
+    ESP_LOGE(TAG, "Cannot configure range config. Sensor is not stopped!");
+    return 1;  // Command done
+  } else if (message == "Done") {
+    ESP_LOGI(TAG, "Updated detection area config:");
+    ESP_LOGI(TAG, "Detection area 1 from %.02fm to %.02fm.", this->min1_, this->max1_);
+    if (this->min2_ >= 0 && this->max2_ >= 0) {
+      ESP_LOGI(TAG, "Detection area 2 from %.02fm to %.02fm.", this->min2_, this->max2_);
+    }
+    if (this->min3_ >= 0 && this->max3_ >= 0) {
+      ESP_LOGI(TAG, "Detection area 3 from %.02fm to %.02fm.", this->min3_, this->max3_);
+    }
+    if (this->min4_ >= 0 && this->max4_ >= 0) {
+      ESP_LOGI(TAG, "Detection area 4 from %.02fm to %.02fm.", this->min4_, this->max4_);
+    }
+    ESP_LOGD(TAG, "Used command: %s", this->cmd_.c_str());
+    return 1;  // Command done
+  }
+  return 0;  // Command not done yet.
+}
+
+OutputLatencyCommand::OutputLatencyCommand(float delay_after_detection, float delay_after_disappear) {
+  delay_after_detection = round(delay_after_detection / 0.025) * 0.025;
+  delay_after_disappear = round(delay_after_disappear / 0.025) * 0.025;
+  if (delay_after_detection < 0)
+    delay_after_detection = 0;
+  if (delay_after_detection > 1638.375)
+    delay_after_detection = 1638.375;
+  if (delay_after_disappear < 0)
+    delay_after_disappear = 0;
+  if (delay_after_disappear > 1638.375)
+    delay_after_disappear = 1638.375;
+
+  this->delay_after_detection_ = delay_after_detection;
+  this->delay_after_disappear_ = delay_after_disappear;
+
+  this->cmd_ = str_sprintf("outputLatency -1 %.0f %.0f", delay_after_detection / 0.025, delay_after_disappear / 0.025);
+};
+
+uint8_t OutputLatencyCommand::on_message(std::string &message) {
+  if (message == "sensor is not stopped") {
+    ESP_LOGE(TAG, "Cannot configure output latency. Sensor is not stopped!");
+    return 1;  // Command done
+  } else if (message == "Done") {
+    ESP_LOGI(TAG, "Updated output latency config:");
+    ESP_LOGI(TAG, "Signal that someone was detected is delayed by %.02fs.", this->delay_after_detection_);
+    ESP_LOGI(TAG, "Signal that nobody is detected anymore is delayed by %.02fs.", this->delay_after_disappear_);
+    ESP_LOGD(TAG, "Used command: %s", this->cmd_.c_str());
+    return 1;  // Command done
+  }
+  return 0;  // Command not done yet
+}
+
+uint8_t SensorCfgStartCommand::on_message(std::string &message) {
+  if (message == "sensor is not stopped") {
+    ESP_LOGE(TAG, "Cannot configure sensor startup behavior. Sensor is not stopped!");
+    return 1;  // Command done
+  } else if (message == "Done") {
+    ESP_LOGI(TAG, "Updated sensor startup behavior:");
+    if (startup_mode_) {
+      this->parent_->set_start_after_boot(true);
+      ESP_LOGI(TAG, "Sensor will start automatically after power-on.");
+    } else {
+      this->parent_->set_start_after_boot(false);
+      ESP_LOGI(TAG, "Sensor needs to be started manually after power-on.");
+    }
+    ESP_LOGD(TAG, "Used command: %s", this->cmd_.c_str());
+    return 1;  // Command done
+  }
+  return 0;  // Command not done yet
+}
+
+uint8_t FactoryResetCommand::on_message(std::string &message) {
+  if (message == "sensor is not stopped") {
+    ESP_LOGE(TAG, "Cannot factory reset. Sensor is not stopped!");
+    return 1;  // Command done
+  } else if (message == "Done") {
+    ESP_LOGI(TAG, "Sensor factory reset done.");
+    return 1;  // Command done
+  }
+  return 0;  // Command not done yet
+}
+
+uint8_t ResetSystemCommand::on_message(std::string &message) {
+  if (message == "leapMMW:/>") {
+    ESP_LOGI(TAG, "Restarted sensor.");
+    return 1;  // Command done
+  }
+  return 0;  // Command not done yet
+}
+
+uint8_t SaveCfgCommand::on_message(std::string &message) {
+  if (message == "no parameter has changed") {
+    ESP_LOGI(TAG, "Not saving config (no parameter changed).");
+    return 1;  // Command done
+  } else if (message == "Done") {
+    ESP_LOGI(TAG, "Saved config. Saving a lot may damage the sensor.");
+    return 1;  // Command done
+  }
+  return 0;  // Command not done yet
+}
+
+uint8_t LedModeCommand::on_message(std::string &message) {
+  if (message == "sensor is not stopped") {
+    ESP_LOGE(TAG, "Cannot set led mode. Sensor is not stopped!");
+    return 1;  // Command done
+  } else if (message == "Done") {
+    ESP_LOGI(TAG, "Set led mode done.");
+    if (this->active_) {
+      this->parent_->set_led_active(true);
+      ESP_LOGI(TAG, "Sensor LED will blink.");
+    } else {
+      this->parent_->set_led_active(false);
+      ESP_LOGI(TAG, "Turned off LED.");
+    }
+    ESP_LOGD(TAG, "Used command: %s", this->cmd_.c_str());
+    return 1;  // Command done
+  }
+  return 0;  // Command not done yet
+}
+
+uint8_t UartOutputCommand::on_message(std::string &message) {
+  if (message == "sensor is not stopped") {
+    ESP_LOGE(TAG, "Cannot set uart output mode. Sensor is not stopped!");
+    return 1;  // Command done
+  } else if (message == "Done") {
+    ESP_LOGI(TAG, "Set uart mode done.");
+    if (this->active_) {
+      this->parent_->set_uart_presence_active(true);
+      ESP_LOGI(TAG, "Presence information is sent via UART and GPIO.");
+    } else {
+      this->parent_->set_uart_presence_active(false);
+      ESP_LOGI(TAG, "Presence information is only sent via GPIO.");
+    }
+    ESP_LOGD(TAG, "Used command: %s", this->cmd_.c_str());
+    return 1;  // Command done
+  }
+  return 0;  // Command not done yet
+}
+
+uint8_t SensitivityCommand::on_message(std::string &message) {
+  if (message == "sensor is not stopped") {
+    ESP_LOGE(TAG, "Cannot set sensitivity. Sensor is not stopped!");
+    return 1;  // Command done
+  } else if (message == "Done") {
+    ESP_LOGI(TAG, "Set sensitivity done. Set to value %d.", this->sensitivity_);
+    ESP_LOGD(TAG, "Used command: %s", this->cmd_.c_str());
+    return 1;  // Command done
+  }
+  return 0;  // Command not done yet
+}
+
+}  // namespace dfrobot_sen0395
+}  // namespace esphome
diff --git a/esphome/components/dfrobot_sen0395/commands.h b/esphome/components/dfrobot_sen0395/commands.h
new file mode 100644
index 0000000000..7426d9732a
--- /dev/null
+++ b/esphome/components/dfrobot_sen0395/commands.h
@@ -0,0 +1,156 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+
+#include "esphome/core/helpers.h"
+
+namespace esphome {
+namespace dfrobot_sen0395 {
+
+class DfrobotSen0395Component;
+
+// Use command queue and time stamps to avoid blocking.
+// When component has run time, check if minimum time (1s) between
+// commands has passed. After that run a command from the queue.
+class Command {
+ public:
+  virtual ~Command() = default;
+  virtual uint8_t execute(DfrobotSen0395Component *parent);
+  virtual uint8_t on_message(std::string &message) = 0;
+
+ protected:
+  DfrobotSen0395Component *parent_{nullptr};
+  std::string cmd_;
+  bool cmd_sent_{false};
+  int8_t retries_left_{2};
+  uint32_t cmd_duration_ms_{1000};
+  uint32_t timeout_ms_{1500};
+};
+
+class ReadStateCommand : public Command {
+ public:
+  uint8_t execute(DfrobotSen0395Component *parent) override;
+  uint8_t on_message(std::string &message) override;
+
+ protected:
+  uint32_t timeout_ms_{500};
+};
+
+class PowerCommand : public Command {
+ public:
+  PowerCommand(bool power_on) : power_on_(power_on) {
+    if (power_on) {
+      cmd_ = "sensorStart";
+    } else {
+      cmd_ = "sensorStop";
+    }
+  };
+  uint8_t on_message(std::string &message) override;
+
+ protected:
+  bool power_on_;
+};
+
+class DetRangeCfgCommand : public Command {
+ public:
+  DetRangeCfgCommand(float min1, float max1, float min2, float max2, float min3, float max3, float min4, float max4);
+  uint8_t on_message(std::string &message) override;
+
+ protected:
+  float min1_, max1_, min2_, max2_, min3_, max3_, min4_, max4_;
+  // TODO: Set min max values in component, so they can be published as sensor.
+};
+
+class OutputLatencyCommand : public Command {
+ public:
+  OutputLatencyCommand(float delay_after_detection, float delay_after_disappear);
+  uint8_t on_message(std::string &message) override;
+
+ protected:
+  float delay_after_detection_;
+  float delay_after_disappear_;
+};
+
+class SensorCfgStartCommand : public Command {
+ public:
+  SensorCfgStartCommand(bool startup_mode) : startup_mode_(startup_mode) {
+    char tmp_cmd[20] = {0};
+    sprintf(tmp_cmd, "sensorCfgStart %d", startup_mode);
+    cmd_ = std::string(tmp_cmd);
+  }
+  uint8_t on_message(std::string &message) override;
+
+ protected:
+  bool startup_mode_;
+};
+
+class FactoryResetCommand : public Command {
+ public:
+  FactoryResetCommand() { cmd_ = "factoryReset 0x45670123 0xCDEF89AB 0x956128C6 0xDF54AC89"; };
+  uint8_t on_message(std::string &message) override;
+};
+
+class ResetSystemCommand : public Command {
+ public:
+  ResetSystemCommand() { cmd_ = "resetSystem"; }
+  uint8_t on_message(std::string &message) override;
+};
+
+class SaveCfgCommand : public Command {
+ public:
+  SaveCfgCommand() { cmd_ = "saveCfg 0x45670123 0xCDEF89AB 0x956128C6 0xDF54AC89"; }
+  uint8_t on_message(std::string &message) override;
+
+ protected:
+  uint32_t cmd_duration_ms_{3000};
+  uint32_t timeout_ms_{3500};
+};
+
+class LedModeCommand : public Command {
+ public:
+  LedModeCommand(bool active) : active_(active) {
+    if (active) {
+      cmd_ = "setLedMode 1 0";
+    } else {
+      cmd_ = "setLedMode 1 1";
+    }
+  };
+  uint8_t on_message(std::string &message) override;
+
+ protected:
+  bool active_;
+};
+
+class UartOutputCommand : public Command {
+ public:
+  UartOutputCommand(bool active) : active_(active) {
+    if (active) {
+      cmd_ = "setUartOutput 1 1";
+    } else {
+      cmd_ = "setUartOutput 1 0";
+    }
+  };
+  uint8_t on_message(std::string &message) override;
+
+ protected:
+  bool active_;
+};
+
+class SensitivityCommand : public Command {
+ public:
+  SensitivityCommand(uint8_t sensitivity) : sensitivity_(sensitivity) {
+    if (sensitivity > 9)
+      sensitivity_ = sensitivity = 9;
+    char tmp_cmd[20] = {0};
+    sprintf(tmp_cmd, "setSensitivity %d", sensitivity);
+    cmd_ = std::string(tmp_cmd);
+  };
+  uint8_t on_message(std::string &message) override;
+
+ protected:
+  uint8_t sensitivity_;
+};
+
+}  // namespace dfrobot_sen0395
+}  // namespace esphome
diff --git a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp
new file mode 100644
index 0000000000..f8ef6c7138
--- /dev/null
+++ b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp
@@ -0,0 +1,142 @@
+#include "dfrobot_sen0395.h"
+
+#include "esphome/core/helpers.h"
+#include "esphome/core/log.h"
+
+namespace esphome {
+namespace dfrobot_sen0395 {
+
+static const char *const TAG = "dfrobot_sen0395";
+const char ASCII_CR = 0x0D;
+const char ASCII_LF = 0x0A;
+
+void DfrobotSen0395Component::dump_config() {
+  ESP_LOGCONFIG(TAG, "Dfrobot Mmwave Radar:");
+#ifdef USE_BINARY_SENSOR
+  LOG_BINARY_SENSOR("  ", "Registered", this->detected_binary_sensor_);
+#endif
+#ifdef USE_SWITCH
+  LOG_SWITCH("  ", "Sensor Active Switch", this->sensor_active_switch_);
+  LOG_SWITCH("  ", "Turn on LED Switch", this->turn_on_led_switch_);
+  LOG_SWITCH("  ", "Presence via UART Switch", this->presence_via_uart_switch_);
+  LOG_SWITCH("  ", "Start after Boot Switch", this->start_after_boot_switch_);
+#endif
+}
+
+void DfrobotSen0395Component::loop() {
+  if (cmd_queue_.is_empty()) {
+    // Command queue empty. Read sensor state.
+    cmd_queue_.enqueue(make_unique<ReadStateCommand>());
+  }
+
+  // Commands are non-blocking and need to be called repeatedly.
+  if (cmd_queue_.process(this)) {
+    // Dequeue if command is done
+    cmd_queue_.dequeue();
+  }
+}
+
+int8_t DfrobotSen0395Component::enqueue(std::unique_ptr<Command> cmd) {
+  return cmd_queue_.enqueue(std::move(cmd));  // Transfer ownership using std::move
+}
+
+uint8_t DfrobotSen0395Component::read_message_() {
+  while (this->available()) {
+    uint8_t byte;
+    this->read_byte(&byte);
+
+    if (this->read_pos_ == MMWAVE_READ_BUFFER_LENGTH)
+      this->read_pos_ = 0;
+
+    ESP_LOGVV(TAG, "Buffer pos: %u %d", this->read_pos_, byte);
+
+    if (byte == ASCII_CR)
+      continue;
+    if (byte >= 0x7F)
+      byte = '?';  // needs to be valid utf8 string for log functions.
+    this->read_buffer_[this->read_pos_] = byte;
+
+    if (this->read_pos_ == 9 && byte == '>')
+      this->read_buffer_[++this->read_pos_] = ASCII_LF;
+
+    if (this->read_buffer_[this->read_pos_] == ASCII_LF) {
+      this->read_buffer_[this->read_pos_] = 0;
+      this->read_pos_ = 0;
+      ESP_LOGV(TAG, "Message: %s", this->read_buffer_);
+      return 1;  // Full message in buffer
+    } else {
+      this->read_pos_++;
+    }
+  }
+  return 0;  // No full message yet
+}
+
+uint8_t DfrobotSen0395Component::find_prompt_() {
+  if (this->read_message_()) {
+    std::string message(this->read_buffer_);
+    if (message.rfind("leapMMW:/>") != std::string::npos) {
+      return 1;  // Prompt found
+    }
+  }
+  return 0;  // Not found yet
+}
+
+uint8_t DfrobotSen0395Component::send_cmd_(const char *cmd, uint32_t duration) {
+  // The interval between two commands must be larger than the specified duration (in ms).
+  if (millis() - ts_last_cmd_sent_ > duration) {
+    this->write_str(cmd);
+    ts_last_cmd_sent_ = millis();
+    return 1;  // Command sent
+  }
+  // Could not send command yet as command duration did not fully pass yet.
+  return 0;
+}
+
+void DfrobotSen0395Component::set_detected_(bool detected) {
+  this->detected_ = detected;
+#ifdef USE_BINARY_SENSOR
+  if (this->detected_binary_sensor_ != nullptr)
+    this->detected_binary_sensor_->publish_state(detected);
+#endif
+}
+
+int8_t CircularCommandQueue::enqueue(std::unique_ptr<Command> cmd) {
+  if (this->is_full()) {
+    ESP_LOGE(TAG, "Command queue is full");
+    return -1;
+  } else if (this->is_empty())
+    front_++;
+  rear_ = (rear_ + 1) % COMMAND_QUEUE_SIZE;
+  commands_[rear_] = std::move(cmd);  // Transfer ownership using std::move
+  return 1;
+}
+
+std::unique_ptr<Command> CircularCommandQueue::dequeue() {
+  if (this->is_empty())
+    return nullptr;
+  std::unique_ptr<Command> dequeued_cmd = std::move(commands_[front_]);
+  if (front_ == rear_) {
+    front_ = -1;
+    rear_ = -1;
+  } else
+    front_ = (front_ + 1) % COMMAND_QUEUE_SIZE;
+
+  return dequeued_cmd;
+}
+
+bool CircularCommandQueue::is_empty() { return front_ == -1; }
+
+bool CircularCommandQueue::is_full() { return (rear_ + 1) % COMMAND_QUEUE_SIZE == front_; }
+
+// Run execute method of first in line command.
+// Execute is non-blocking and has to be called until it returns 1.
+uint8_t CircularCommandQueue::process(DfrobotSen0395Component *parent) {
+  if (!is_empty()) {
+    return commands_[front_]->execute(parent);
+  } else {
+    return 1;
+  }
+}
+
+}  // namespace dfrobot_sen0395
+}  // namespace esphome
diff --git a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h
new file mode 100644
index 0000000000..d3b2ecedc3
--- /dev/null
+++ b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h
@@ -0,0 +1,125 @@
+#pragma once
+
+#include "esphome/components/uart/uart.h"
+#include "esphome/core/automation.h"
+#include "esphome/core/component.h"
+
+#ifdef USE_BINARY_SENSOR
+#include "esphome/components/binary_sensor/binary_sensor.h"
+#endif
+#ifdef USE_SWITCH
+#include "esphome/components/switch/switch.h"
+#endif
+
+#include "commands.h"
+
+namespace esphome {
+namespace dfrobot_sen0395 {
+
+const uint8_t MMWAVE_READ_BUFFER_LENGTH = 255;
+
+// forward declaration due to circular dependency
+class DfrobotSen0395Component;
+
+static const uint8_t COMMAND_QUEUE_SIZE = 20;
+
+class CircularCommandQueue {
+ public:
+  int8_t enqueue(std::unique_ptr<Command> cmd);
+  std::unique_ptr<Command> dequeue();
+  bool is_empty();
+  bool is_full();
+  uint8_t process(DfrobotSen0395Component *parent);
+
+ protected:
+  int front_{-1};
+  int rear_{-1};
+  std::unique_ptr<Command> commands_[COMMAND_QUEUE_SIZE];
+};
+
+class DfrobotSen0395Component : public uart::UARTDevice, public Component {
+#ifdef USE_SWITCH
+  SUB_SWITCH(sensor_active)
+  SUB_SWITCH(turn_on_led)
+  SUB_SWITCH(presence_via_uart)
+  SUB_SWITCH(start_after_boot)
+#endif
+
+ public:
+  void dump_config() override;
+  void loop() override;
+  void set_active(bool active) {
+    if (active != active_) {
+#ifdef USE_SWITCH
+      if (this->sensor_active_switch_ != nullptr)
+        this->sensor_active_switch_->publish_state(active);
+#endif
+      active_ = active;
+    }
+  }
+  bool is_active() { return active_; }
+
+  void set_led_active(bool active) {
+    if (led_active_ != active) {
+#ifdef USE_SWITCH
+      if (this->turn_on_led_switch_ != nullptr)
+        this->turn_on_led_switch_->publish_state(active);
+#endif
+      led_active_ = active;
+    }
+  }
+  bool is_led_active() { return led_active_; }
+
+  void set_uart_presence_active(bool active) {
+    uart_presence_active_ = active;
+#ifdef USE_SWITCH
+    if (this->presence_via_uart_switch_ != nullptr)
+      this->presence_via_uart_switch_->publish_state(active);
+#endif
+  }
+  bool is_uart_presence_active() { return uart_presence_active_; }
+
+  void set_start_after_boot(bool start) {
+    start_after_boot_ = start;
+#ifdef USE_SWITCH
+    if (this->start_after_boot_switch_ != nullptr)
+      this->start_after_boot_switch_->publish_state(start);
+#endif
+  }
+  bool does_start_after_boot() { return start_after_boot_; }
+
+#ifdef USE_BINARY_SENSOR
+  void set_detected_binary_sensor(binary_sensor::BinarySensor *detected_binary_sensor) {
+    detected_binary_sensor_ = detected_binary_sensor;
+  }
+#endif
+
+  int8_t enqueue(std::unique_ptr<Command> cmd);
+
+ protected:
+#ifdef USE_BINARY_SENSOR
+  binary_sensor::BinarySensor *detected_binary_sensor_{nullptr};
+#endif
+
+  bool detected_{false};
+  bool active_{false};
+  bool led_active_{false};
+  bool uart_presence_active_{false};
+  bool start_after_boot_{false};
+  char read_buffer_[MMWAVE_READ_BUFFER_LENGTH];
+  size_t read_pos_{0};
+  CircularCommandQueue cmd_queue_;
+  uint32_t ts_last_cmd_sent_{0};
+
+  uint8_t read_message_();
+  uint8_t find_prompt_();
+  uint8_t send_cmd_(const char *cmd, uint32_t duration);
+
+  void set_detected_(bool detected);
+
+  friend class Command;
+  friend class ReadStateCommand;
+};
+
+}  // namespace dfrobot_sen0395
+}  // namespace esphome
diff --git a/esphome/components/dfrobot_sen0395/switch/__init__.py b/esphome/components/dfrobot_sen0395/switch/__init__.py
new file mode 100644
index 0000000000..b1c35d27ac
--- /dev/null
+++ b/esphome/components/dfrobot_sen0395/switch/__init__.py
@@ -0,0 +1,65 @@
+import esphome.codegen as cg
+import esphome.config_validation as cv
+from esphome.components import switch
+from esphome.const import ENTITY_CATEGORY_CONFIG, CONF_TYPE
+
+from .. import CONF_DFROBOT_SEN0395_ID, DfrobotSen0395Component
+
+
+DEPENDENCIES = ["dfrobot_sen0395"]
+
+dfrobot_sen0395_ns = cg.esphome_ns.namespace("dfrobot_sen0395")
+DfrobotSen0395Switch = dfrobot_sen0395_ns.class_(
+    "DfrobotSen0395Switch",
+    switch.Switch,
+    cg.Component,
+    cg.Parented.template(DfrobotSen0395Component),
+)
+
+Sen0395PowerSwitch = dfrobot_sen0395_ns.class_(
+    "Sen0395PowerSwitch", DfrobotSen0395Switch
+)
+Sen0395LedSwitch = dfrobot_sen0395_ns.class_("Sen0395LedSwitch", DfrobotSen0395Switch)
+Sen0395UartPresenceSwitch = dfrobot_sen0395_ns.class_(
+    "Sen0395UartPresenceSwitch", DfrobotSen0395Switch
+)
+Sen0395StartAfterBootSwitch = dfrobot_sen0395_ns.class_(
+    "Sen0395StartAfterBootSwitch", DfrobotSen0395Switch
+)
+
+_SWITCH_SCHEMA = (
+    switch.switch_schema(
+        entity_category=ENTITY_CATEGORY_CONFIG,
+    )
+    .extend(
+        {
+            cv.GenerateID(CONF_DFROBOT_SEN0395_ID): cv.use_id(DfrobotSen0395Component),
+        }
+    )
+    .extend(cv.COMPONENT_SCHEMA)
+)
+
+CONFIG_SCHEMA = cv.typed_schema(
+    {
+        "sensor_active": _SWITCH_SCHEMA.extend(
+            {cv.GenerateID(): cv.declare_id(Sen0395PowerSwitch)}
+        ),
+        "turn_on_led": _SWITCH_SCHEMA.extend(
+            {cv.GenerateID(): cv.declare_id(Sen0395LedSwitch)}
+        ),
+        "presence_via_uart": _SWITCH_SCHEMA.extend(
+            {cv.GenerateID(): cv.declare_id(Sen0395UartPresenceSwitch)}
+        ),
+        "start_after_boot": _SWITCH_SCHEMA.extend(
+            {cv.GenerateID(): cv.declare_id(Sen0395StartAfterBootSwitch)}
+        ),
+    }
+)
+
+
+async def to_code(config):
+    parent = await cg.get_variable(config[CONF_DFROBOT_SEN0395_ID])
+    var = await switch.new_switch(config)
+    await cg.register_component(var, config)
+    await cg.register_parented(var, parent)
+    cg.add(getattr(parent, f"set_{config[CONF_TYPE]}_switch")(var))
diff --git a/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.cpp b/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.cpp
new file mode 100644
index 0000000000..ca72d94531
--- /dev/null
+++ b/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.cpp
@@ -0,0 +1,48 @@
+#include "dfrobot_sen0395_switch.h"
+
+namespace esphome {
+namespace dfrobot_sen0395 {
+
+void Sen0395PowerSwitch::write_state(bool state) { this->parent_->enqueue(make_unique<PowerCommand>(state)); }
+
+void Sen0395LedSwitch::write_state(bool state) {
+  bool was_active = false;
+  if (this->parent_->is_active()) {
+    was_active = true;
+    this->parent_->enqueue(make_unique<PowerCommand>(false));
+  }
+  this->parent_->enqueue(make_unique<LedModeCommand>(state));
+  this->parent_->enqueue(make_unique<SaveCfgCommand>());
+  if (was_active) {
+    this->parent_->enqueue(make_unique<PowerCommand>(true));
+  }
+}
+
+void Sen0395UartPresenceSwitch::write_state(bool state) {
+  bool was_active = false;
+  if (this->parent_->is_active()) {
+    was_active = true;
+    this->parent_->enqueue(make_unique<PowerCommand>(false));
+  }
+  this->parent_->enqueue(make_unique<UartOutputCommand>(state));
+  this->parent_->enqueue(make_unique<SaveCfgCommand>());
+  if (was_active) {
+    this->parent_->enqueue(make_unique<PowerCommand>(true));
+  }
+}
+
+void Sen0395StartAfterBootSwitch::write_state(bool state) {
+  bool was_active = false;
+  if (this->parent_->is_active()) {
+    was_active = true;
+    this->parent_->enqueue(make_unique<PowerCommand>(false));
+  }
+  this->parent_->enqueue(make_unique<SensorCfgStartCommand>(state));
+  this->parent_->enqueue(make_unique<SaveCfgCommand>());
+  if (was_active) {
+    this->parent_->enqueue(make_unique<PowerCommand>(true));
+  }
+}
+
+}  // namespace dfrobot_sen0395
+}  // namespace esphome
diff --git a/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h b/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h
new file mode 100644
index 0000000000..ab32d81dd8
--- /dev/null
+++ b/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h
@@ -0,0 +1,34 @@
+#pragma once
+
+#include "esphome/components/switch/switch.h"
+#include "esphome/core/component.h"
+
+#include "../dfrobot_sen0395.h"
+
+namespace esphome {
+namespace dfrobot_sen0395 {
+
+class DfrobotSen0395Switch : public switch_::Switch, public Component, public Parented<DfrobotSen0395Component> {};
+
+class Sen0395PowerSwitch : public DfrobotSen0395Switch {
+ public:
+  void write_state(bool state) override;
+};
+
+class Sen0395LedSwitch : public DfrobotSen0395Switch {
+ public:
+  void write_state(bool state) override;
+};
+
+class Sen0395UartPresenceSwitch : public DfrobotSen0395Switch {
+ public:
+  void write_state(bool state) override;
+};
+
+class Sen0395StartAfterBootSwitch : public DfrobotSen0395Switch {
+ public:
+  void write_state(bool state) override;
+};
+
+}  // namespace dfrobot_sen0395
+}  // namespace esphome
diff --git a/esphome/components/duty_time/duty_time_sensor.cpp b/esphome/components/duty_time/duty_time_sensor.cpp
index 1101c4d41e..d4369c89c0 100644
--- a/esphome/components/duty_time/duty_time_sensor.cpp
+++ b/esphome/components/duty_time/duty_time_sensor.cpp
@@ -95,7 +95,7 @@ void DutyTimeSensor::publish_and_save_(const uint32_t sec, const uint32_t ms) {
 
 void DutyTimeSensor::dump_config() {
   ESP_LOGCONFIG(TAG, "Duty Time:");
-  ESP_LOGCONFIG(TAG, "  Update Interval: %dms", this->get_update_interval());
+  ESP_LOGCONFIG(TAG, "  Update Interval: %" PRId32 "ms", this->get_update_interval());
   ESP_LOGCONFIG(TAG, "  Restore: %s", ONOFF(this->restore_));
   LOG_SENSOR("  ", "Duty Time Sensor:", this);
   LOG_SENSOR("  ", "Last Duty Time Sensor:", this->last_duty_time_sensor_);
diff --git a/esphome/components/duty_time/duty_time_sensor.h b/esphome/components/duty_time/duty_time_sensor.h
index 1ec2f7b94f..38655f104a 100644
--- a/esphome/components/duty_time/duty_time_sensor.h
+++ b/esphome/components/duty_time/duty_time_sensor.h
@@ -1,5 +1,7 @@
 #pragma once
 
+#include <cinttypes>
+
 #include "esphome/core/automation.h"
 #include "esphome/core/component.h"
 #include "esphome/core/preferences.h"
diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.cpp b/esphome/components/esp32_camera_web_server/camera_web_server.cpp
index 3210989ff5..7ca0c56d23 100644
--- a/esphome/components/esp32_camera_web_server/camera_web_server.cpp
+++ b/esphome/components/esp32_camera_web_server/camera_web_server.cpp
@@ -194,8 +194,8 @@ esp_err_t CameraWebServer::streaming_handler_(struct httpd_req *req) {
       int64_t frame_time = millis() - last_frame;
       last_frame = millis();
 
-      ESP_LOGD(TAG, "MJPG: %uB %ums (%.1ffps)", (uint32_t) image->get_data_length(), (uint32_t) frame_time,
-               1000.0 / (uint32_t) frame_time);
+      ESP_LOGD(TAG, "MJPG: %" PRIu32 "B %" PRIu32 "ms (%.1ffps)", (uint32_t) image->get_data_length(),
+               (uint32_t) frame_time, 1000.0 / (uint32_t) frame_time);
     }
   }
 
@@ -205,7 +205,7 @@ esp_err_t CameraWebServer::streaming_handler_(struct httpd_req *req) {
 
   esp32_camera::global_esp32_camera->stop_stream(esphome::esp32_camera::WEB_REQUESTER);
 
-  ESP_LOGI(TAG, "STREAM: closed. Frames: %u", frames);
+  ESP_LOGI(TAG, "STREAM: closed. Frames: %" PRIu32, frames);
 
   return res;
 }
diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.h b/esphome/components/esp32_camera_web_server/camera_web_server.h
index 509ca81592..f65625554c 100644
--- a/esphome/components/esp32_camera_web_server/camera_web_server.h
+++ b/esphome/components/esp32_camera_web_server/camera_web_server.h
@@ -2,6 +2,7 @@
 
 #ifdef USE_ESP32
 
+#include <cinttypes>
 #include <freertos/FreeRTOS.h>
 #include <freertos/semphr.h>
 
diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py
index 2bd6beeaeb..7e34dff22d 100644
--- a/esphome/components/font/__init__.py
+++ b/esphome/components/font/__init__.py
@@ -137,11 +137,10 @@ def validate_weight_name(value):
 
 
 def download_gfonts(value):
-    wght = value[CONF_WEIGHT]
-    if value[CONF_ITALIC]:
-        wght = f"1,{wght}"
-    name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}"
-    url = f"https://fonts.googleapis.com/css2?family={value[CONF_FAMILY]}:wght@{wght}"
+    name = (
+        f"{value[CONF_FAMILY]}:ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}"
+    )
+    url = f"https://fonts.googleapis.com/css2?family={name}"
 
     path = _compute_gfonts_local_path(value)
     if path.is_file():
diff --git a/esphome/components/ld2420/__init__.py b/esphome/components/ld2420/__init__.py
new file mode 100644
index 0000000000..c701423081
--- /dev/null
+++ b/esphome/components/ld2420/__init__.py
@@ -0,0 +1,39 @@
+import esphome.codegen as cg
+import esphome.config_validation as cv
+from esphome.components import uart
+from esphome.const import CONF_ID
+
+CODEOWNERS = ["@descipher"]
+
+DEPENDENCIES = ["uart"]
+
+MULTI_CONF = True
+
+ld2420_ns = cg.esphome_ns.namespace("ld2420")
+LD2420Component = ld2420_ns.class_("LD2420Component", cg.Component, uart.UARTDevice)
+
+CONF_LD2420_ID = "ld2420_id"
+
+CONFIG_SCHEMA = cv.All(
+    cv.Schema(
+        {
+            cv.GenerateID(): cv.declare_id(LD2420Component),
+        }
+    )
+    .extend(uart.UART_DEVICE_SCHEMA)
+    .extend(cv.COMPONENT_SCHEMA)
+)
+
+FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
+    "ld2420_uart",
+    require_tx=True,
+    require_rx=True,
+    parity="NONE",
+    stop_bits=1,
+)
+
+
+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)
diff --git a/esphome/components/ld2420/binary_sensor/__init__.py b/esphome/components/ld2420/binary_sensor/__init__.py
new file mode 100644
index 0000000000..f94e4d969f
--- /dev/null
+++ b/esphome/components/ld2420/binary_sensor/__init__.py
@@ -0,0 +1,33 @@
+import esphome.codegen as cg
+import esphome.config_validation as cv
+from esphome.components import binary_sensor
+from esphome.const import CONF_ID, DEVICE_CLASS_OCCUPANCY
+from .. import ld2420_ns, LD2420Component, CONF_LD2420_ID
+
+LD2420BinarySensor = ld2420_ns.class_(
+    "LD2420BinarySensor", binary_sensor.BinarySensor, cg.Component
+)
+
+CONF_HAS_TARGET = "has_target"
+
+CONFIG_SCHEMA = cv.All(
+    cv.COMPONENT_SCHEMA.extend(
+        {
+            cv.GenerateID(): cv.declare_id(LD2420BinarySensor),
+            cv.GenerateID(CONF_LD2420_ID): cv.use_id(LD2420Component),
+            cv.Optional(CONF_HAS_TARGET): binary_sensor.binary_sensor_schema(
+                device_class=DEVICE_CLASS_OCCUPANCY
+            ),
+        }
+    ),
+)
+
+
+async def to_code(config):
+    var = cg.new_Pvariable(config[CONF_ID])
+    await cg.register_component(var, config)
+    if CONF_HAS_TARGET in config:
+        sens = await binary_sensor.new_binary_sensor(config[CONF_HAS_TARGET])
+        cg.add(var.set_presence_sensor(sens))
+    ld2420 = await cg.get_variable(config[CONF_LD2420_ID])
+    cg.add(ld2420.register_listener(var))
diff --git a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.cpp b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.cpp
new file mode 100644
index 0000000000..c6ea0a348b
--- /dev/null
+++ b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.cpp
@@ -0,0 +1,16 @@
+#include "ld2420_binary_sensor.h"
+#include "esphome/core/helpers.h"
+#include "esphome/core/log.h"
+
+namespace esphome {
+namespace ld2420 {
+
+static const char *const TAG = "LD2420.binary_sensor";
+
+void LD2420BinarySensor::dump_config() {
+  ESP_LOGCONFIG(TAG, "LD2420 BinarySensor:");
+  LOG_BINARY_SENSOR("  ", "Presence", this->presence_bsensor_);
+}
+
+}  // namespace ld2420
+}  // namespace esphome
diff --git a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h
new file mode 100644
index 0000000000..ee06439090
--- /dev/null
+++ b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#include "../ld2420.h"
+#include "esphome/components/binary_sensor/binary_sensor.h"
+
+namespace esphome {
+namespace ld2420 {
+
+class LD2420BinarySensor : public LD2420Listener, public Component, binary_sensor::BinarySensor {
+ public:
+  void dump_config() override;
+  void set_presence_sensor(binary_sensor::BinarySensor *bsensor) { this->presence_bsensor_ = bsensor; };
+  void on_presence(bool presence) override {
+    if (this->presence_bsensor_ != nullptr) {
+      if (this->presence_bsensor_->state != presence)
+        this->presence_bsensor_->publish_state(presence);
+    }
+  }
+
+ protected:
+  binary_sensor::BinarySensor *presence_bsensor_{nullptr};
+};
+
+}  // namespace ld2420
+}  // namespace esphome
diff --git a/esphome/components/ld2420/button/__init__.py b/esphome/components/ld2420/button/__init__.py
new file mode 100644
index 0000000000..675e041dd4
--- /dev/null
+++ b/esphome/components/ld2420/button/__init__.py
@@ -0,0 +1,69 @@
+import esphome.codegen as cg
+from esphome.components import button
+import esphome.config_validation as cv
+from esphome.const import (
+    DEVICE_CLASS_RESTART,
+    ENTITY_CATEGORY_DIAGNOSTIC,
+    ENTITY_CATEGORY_CONFIG,
+    ICON_RESTART,
+    ICON_RESTART_ALERT,
+    ICON_DATABASE,
+)
+from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns
+
+LD2420ApplyConfigButton = ld2420_ns.class_("LD2420ApplyConfigButton", button.Button)
+LD2420RevertConfigButton = ld2420_ns.class_("LD2420RevertConfigButton", button.Button)
+LD2420RestartModuleButton = ld2420_ns.class_("LD2420RestartModuleButton", button.Button)
+LD2420FactoryResetButton = ld2420_ns.class_("LD2420FactoryResetButton", button.Button)
+
+CONF_APPLY_CONFIG = "apply_config"
+CONF_REVERT_CONFIG = "revert_config"
+CONF_RESTART_MODULE = "restart_module"
+CONF_FACTORY_RESET = "factory_reset"
+
+
+CONFIG_SCHEMA = {
+    cv.GenerateID(CONF_LD2420_ID): cv.use_id(LD2420Component),
+    cv.Required(CONF_APPLY_CONFIG): button.button_schema(
+        LD2420ApplyConfigButton,
+        device_class=DEVICE_CLASS_RESTART,
+        entity_category=ENTITY_CATEGORY_CONFIG,
+        icon=ICON_RESTART_ALERT,
+    ),
+    cv.Optional(CONF_REVERT_CONFIG): button.button_schema(
+        LD2420RevertConfigButton,
+        device_class=DEVICE_CLASS_RESTART,
+        entity_category=ENTITY_CATEGORY_CONFIG,
+        icon=ICON_RESTART,
+    ),
+    cv.Optional(CONF_RESTART_MODULE): button.button_schema(
+        LD2420RestartModuleButton,
+        entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
+        icon=ICON_DATABASE,
+    ),
+    cv.Optional(CONF_FACTORY_RESET): button.button_schema(
+        LD2420FactoryResetButton,
+        entity_category=ENTITY_CATEGORY_CONFIG,
+        icon=ICON_DATABASE,
+    ),
+}
+
+
+async def to_code(config):
+    ld2420_component = await cg.get_variable(config[CONF_LD2420_ID])
+    if apply_config := config.get(CONF_APPLY_CONFIG):
+        b = await button.new_button(apply_config)
+        await cg.register_parented(b, config[CONF_LD2420_ID])
+        cg.add(ld2420_component.set_apply_config_button(b))
+    if revert_config := config.get(CONF_REVERT_CONFIG):
+        b = await button.new_button(revert_config)
+        await cg.register_parented(b, config[CONF_LD2420_ID])
+        cg.add(ld2420_component.set_revert_config_button(b))
+    if restart_config := config.get(CONF_RESTART_MODULE):
+        b = await button.new_button(restart_config)
+        await cg.register_parented(b, config[CONF_LD2420_ID])
+        cg.add(ld2420_component.set_restart_module_button(b))
+    if factory_reset := config.get(CONF_FACTORY_RESET):
+        b = await button.new_button(factory_reset)
+        await cg.register_parented(b, config[CONF_LD2420_ID])
+        cg.add(ld2420_component.set_factory_reset_button(b))
diff --git a/esphome/components/ld2420/button/reconfig_buttons.cpp b/esphome/components/ld2420/button/reconfig_buttons.cpp
new file mode 100644
index 0000000000..3537c1d64a
--- /dev/null
+++ b/esphome/components/ld2420/button/reconfig_buttons.cpp
@@ -0,0 +1,16 @@
+#include "reconfig_buttons.h"
+#include "esphome/core/helpers.h"
+#include "esphome/core/log.h"
+
+static const char *const TAG = "LD2420.button";
+
+namespace esphome {
+namespace ld2420 {
+
+void LD2420ApplyConfigButton::press_action() { this->parent_->apply_config_action(); }
+void LD2420RevertConfigButton::press_action() { this->parent_->revert_config_action(); }
+void LD2420RestartModuleButton::press_action() { this->parent_->restart_module_action(); }
+void LD2420FactoryResetButton::press_action() { this->parent_->factory_reset_action(); }
+
+}  // namespace ld2420
+}  // namespace esphome
diff --git a/esphome/components/ld2420/button/reconfig_buttons.h b/esphome/components/ld2420/button/reconfig_buttons.h
new file mode 100644
index 0000000000..4e9e7a3692
--- /dev/null
+++ b/esphome/components/ld2420/button/reconfig_buttons.h
@@ -0,0 +1,42 @@
+#pragma once
+
+#include "esphome/components/button/button.h"
+#include "../ld2420.h"
+
+namespace esphome {
+namespace ld2420 {
+
+class LD2420ApplyConfigButton : public button::Button, public Parented<LD2420Component> {
+ public:
+  LD2420ApplyConfigButton() = default;
+
+ protected:
+  void press_action() override;
+};
+
+class LD2420RevertConfigButton : public button::Button, public Parented<LD2420Component> {
+ public:
+  LD2420RevertConfigButton() = default;
+
+ protected:
+  void press_action() override;
+};
+
+class LD2420RestartModuleButton : public button::Button, public Parented<LD2420Component> {
+ public:
+  LD2420RestartModuleButton() = default;
+
+ protected:
+  void press_action() override;
+};
+
+class LD2420FactoryResetButton : public button::Button, public Parented<LD2420Component> {
+ public:
+  LD2420FactoryResetButton() = default;
+
+ protected:
+  void press_action() override;
+};
+
+}  // namespace ld2420
+}  // namespace esphome
diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp
new file mode 100644
index 0000000000..6130617457
--- /dev/null
+++ b/esphome/components/ld2420/ld2420.cpp
@@ -0,0 +1,775 @@
+#include "ld2420.h"
+#include "esphome/core/helpers.h"
+
+/*
+Configure commands - little endian
+
+No command can exceed 64 bytes, otherwise they would need be to be split up into multiple sends.
+
+All send command frames will have:
+  Header = FD FC FB FA, Bytes 0 - 3, uint32_t 0xFAFBFCFD
+  Length, bytes 4 - 5, uint16_t 0x0002, must be at least 2 for the command byte if no addon data.
+  Command bytes 6 - 7, uint16_t
+  Footer = 04 03 02 01 - uint32_t 0x01020304, Always last 4 Bytes.
+Receive
+  Error bytes 8-9 uint16_t, 0 = success, all other positive values = error
+
+Enable config mode:
+Send:
+  UART Tx: FD FC FB FA 04 00 FF 00 02 00 04 03 02 01
+  Command = FF 00 - uint16_t 0x00FF
+  Protocol version = 02 00, can be 1 or 2 - uint16_t 0x0002
+Reply:
+  UART Rx: FD FC FB FA 06 00 FF 01 00 00 02 00 04 03 02 01
+
+Disable config mode:
+Send:
+  UART Tx: FD FC FB FA 02 00 FE 00 04 03 02 01
+  Command = FE 00 - uint16_t 0x00FE
+Receive:
+  UART Rx: FD FC FB FA 04 00 FE 01 00 00 04 03 02 01
+
+Configure system parameters:
+
+UART Tx: FD FC FB FA 08 00 12 00 00 00 64 00 00 00 04 03 02 01  Set system parms
+Command = 12 00 - uint16_t 0x0012, Param
+There are three documented parameters for modes:
+  00 64 = Basic status mode
+    This mode outputs text as presence "ON" or  "OFF" and "Range XXXX"
+    where XXXX is a decimal value for distance in cm
+  00 04 = Energy output mode
+    This mode outputs detailed signal energy values for each gate and the target distance.
+    The data format consist of the following.
+    Header HH, Length LL, Persence PP, Distance DD, Range Gate GG, 16 Gate Energies EE, Footer FF
+    HH HH HH HH LL LL PP DD DD GG GG EE EE .. 16x   .. FF FF FF FF
+    F4 F3 F2 F1 00 23 00 00 00 00 01 00 00 .. .. .. .. F8 F7 F6 F5
+  00 00 = debug output mode
+    This mode outputs detailed values consisting of 20 Dopplers, 16 Ranges for a total 20 * 16 * 4 bytes
+    The data format consist of the following.
+    Header HH, Doppler DD, Range RR, Footer FF
+    HH HH HH HH DD DD DD DD .. 20x   .. RR RR RR RR .. 16x   .. FF FF FF FF
+    AA BF 10 14 00 00 00 00 .. .. .. .. 00 00 00 00 .. .. .. .. FD FC FB FA
+
+Configure gate sensitivity parameters:
+UART Tx: FD FC FB FA 0E 00 07 00 10 00 60 EA 00 00 20 00 60 EA 00 00 04 03 02 01
+Command = 12 00 - uint16_t 0x0007
+Gate 0 high thresh = 10 00 uint16_t 0x0010, Threshold value = 60 EA 00 00 uint32_t 0x0000EA60
+Gate 0 low thresh = 20 00 uint16_t 0x0020, Threshold value = 60 EA 00 00 uint32_t 0x0000EA60
+*/
+
+namespace esphome {
+namespace ld2420 {
+
+static const char *const TAG = "ld2420";
+
+float LD2420Component::get_setup_priority() const { return setup_priority::BUS; }
+
+void LD2420Component::dump_config() {
+  ESP_LOGCONFIG(TAG, "LD2420:");
+  ESP_LOGCONFIG(TAG, "  Firmware Version : %7s", this->ld2420_firmware_ver_);
+  ESP_LOGCONFIG(TAG, "LD2420 Number:");
+  LOG_NUMBER(TAG, "  Gate Timeout:", this->gate_timeout_number_);
+  LOG_NUMBER(TAG, "  Gate Max Distance:", this->max_gate_distance_number_);
+  LOG_NUMBER(TAG, "  Gate Min Distance:", this->min_gate_distance_number_);
+  LOG_NUMBER(TAG, "  Gate Select:", this->gate_select_number_);
+  for (uint8_t gate = 0; gate < LD2420_TOTAL_GATES; gate++) {
+    LOG_NUMBER(TAG, "  Gate Move Threshold:", this->gate_move_threshold_numbers_[gate]);
+    LOG_NUMBER(TAG, "  Gate Still Threshold::", this->gate_still_threshold_numbers_[gate]);
+  }
+  LOG_BUTTON(TAG, "  Apply Config:", this->apply_config_button_);
+  LOG_BUTTON(TAG, "  Revert Edits:", this->revert_config_button_);
+  LOG_BUTTON(TAG, "  Factory Reset:", this->factory_reset_button_);
+  LOG_BUTTON(TAG, "  Restart Module:", this->restart_module_button_);
+  ESP_LOGCONFIG(TAG, "LD2420 Select:");
+  LOG_SELECT(TAG, "  Operating Mode", this->operating_selector_);
+  if (this->get_firmware_int_(ld2420_firmware_ver_) < CALIBRATE_VERSION_MIN) {
+    ESP_LOGW(TAG, "LD2420 Firmware Version %s and older are only supported in Simple Mode", ld2420_firmware_ver_);
+  }
+}
+
+uint8_t LD2420Component::calc_checksum(void *data, size_t size) {
+  uint8_t checksum = 0;
+  uint8_t *data_bytes = (uint8_t *) data;
+  for (size_t i = 0; i < size; i++) {
+    checksum ^= data_bytes[i];  // XOR operation
+  }
+  return checksum;
+}
+
+int LD2420Component::get_firmware_int_(const char *version_string) {
+  std::string version_str = version_string;
+  if (version_str[0] == 'v') {
+    version_str = version_str.substr(1);
+  }
+  version_str.erase(remove(version_str.begin(), version_str.end(), '.'), version_str.end());
+  int version_integer = stoi(version_str);
+  return version_integer;
+}
+
+void LD2420Component::setup() {
+  ESP_LOGCONFIG(TAG, "Setting up LD2420...");
+  if (this->set_config_mode(true) == LD2420_ERROR_TIMEOUT) {
+    ESP_LOGE(TAG, "LD2420 module has failed to respond, check baud rate and serial connections.");
+    this->mark_failed();
+    return;
+  }
+  this->get_min_max_distances_timeout_();
+#ifdef USE_NUMBER
+  this->init_gate_config_numbers();
+#endif
+  this->get_firmware_version_();
+  const char *pfw = this->ld2420_firmware_ver_;
+  std::string fw_str(pfw);
+
+  for (auto &listener : listeners_) {
+    listener->on_fw_version(fw_str);
+  }
+
+  for (uint8_t gate = 0; gate < LD2420_TOTAL_GATES; gate++) {
+    delay_microseconds_safe(125);
+    this->get_gate_threshold_(gate);
+  }
+
+  memcpy(&this->new_config, &this->current_config, sizeof(this->current_config));
+  if (get_firmware_int_(ld2420_firmware_ver_) < CALIBRATE_VERSION_MIN) {
+    this->set_operating_mode(OP_SIMPLE_MODE_STRING);
+    this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING);
+    this->set_mode_(CMD_SYSTEM_MODE_SIMPLE);
+    ESP_LOGW(TAG, "LD2420 Frimware Version %s and older are only supported in Simple Mode", ld2420_firmware_ver_);
+  } else {
+    this->set_mode_(CMD_SYSTEM_MODE_ENERGY);
+    this->operating_selector_->publish_state(OP_NORMAL_MODE_STRING);
+  }
+#ifdef USE_NUMBER
+  this->init_gate_config_numbers();
+#endif
+  this->set_system_mode(this->system_mode_);
+  this->set_config_mode(false);
+  ESP_LOGCONFIG(TAG, "LD2420 setup complete.");
+}
+
+void LD2420Component::apply_config_action() {
+  const uint8_t checksum = calc_checksum(&this->new_config, sizeof(this->new_config));
+  if (checksum == calc_checksum(&this->current_config, sizeof(this->current_config))) {
+    ESP_LOGCONFIG(TAG, "No configuration change detected");
+    return;
+  }
+  ESP_LOGCONFIG(TAG, "Reconfiguring LD2420...");
+  if (this->set_config_mode(true) == LD2420_ERROR_TIMEOUT) {
+    ESP_LOGE(TAG, "LD2420 module has failed to respond, check baud rate and serial connections.");
+    this->mark_failed();
+    return;
+  }
+  this->set_min_max_distances_timeout(this->new_config.max_gate, this->new_config.min_gate, this->new_config.timeout);
+  for (uint8_t gate = 0; gate < LD2420_TOTAL_GATES; gate++) {
+    delay_microseconds_safe(125);
+    this->set_gate_threshold(gate);
+  }
+  memcpy(&current_config, &new_config, sizeof(new_config));
+#ifdef USE_NUMBER
+  this->init_gate_config_numbers();
+#endif
+  this->set_system_mode(this->system_mode_);
+  this->set_config_mode(false);  // Disable config mode to save new values in LD2420 nvm
+  this->set_operating_mode(OP_NORMAL_MODE_STRING);
+  ESP_LOGCONFIG(TAG, "LD2420 reconfig complete.");
+}
+
+void LD2420Component::factory_reset_action() {
+  ESP_LOGCONFIG(TAG, "Setiing factory defaults...");
+  if (this->set_config_mode(true) == LD2420_ERROR_TIMEOUT) {
+    ESP_LOGE(TAG, "LD2420 module has failed to respond, check baud rate and serial connections.");
+    this->mark_failed();
+    return;
+  }
+  this->set_min_max_distances_timeout(FACTORY_MAX_GATE, FACTORY_MIN_GATE, FACTORY_TIMEOUT);
+  this->gate_timeout_number_->state = FACTORY_TIMEOUT;
+  this->min_gate_distance_number_->state = FACTORY_MIN_GATE;
+  this->max_gate_distance_number_->state = FACTORY_MAX_GATE;
+  for (uint8_t gate = 0; gate < LD2420_TOTAL_GATES; gate++) {
+    this->new_config.move_thresh[gate] = FACTORY_MOVE_THRESH[gate];
+    this->new_config.still_thresh[gate] = FACTORY_STILL_THRESH[gate];
+    delay_microseconds_safe(125);
+    this->set_gate_threshold(gate);
+  }
+  memcpy(&this->current_config, &this->new_config, sizeof(this->new_config));
+  this->set_system_mode(this->system_mode_);
+  this->set_config_mode(false);
+#ifdef USE_NUMBER
+  this->init_gate_config_numbers();
+  this->refresh_gate_config_numbers();
+#endif
+  ESP_LOGCONFIG(TAG, "LD2420 factory reset complete.");
+}
+
+void LD2420Component::restart_module_action() {
+  ESP_LOGCONFIG(TAG, "Restarting LD2420 module...");
+  this->send_module_restart();
+  delay_microseconds_safe(45000);
+  this->set_config_mode(true);
+  this->set_system_mode(system_mode_);
+  this->set_config_mode(false);
+  ESP_LOGCONFIG(TAG, "LD2420 Restarted.");
+}
+
+void LD2420Component::revert_config_action() {
+  memcpy(&this->new_config, &this->current_config, sizeof(this->current_config));
+#ifdef USE_NUMBER
+  this->init_gate_config_numbers();
+#endif
+  ESP_LOGCONFIG(TAG, "Reverted config number edits.");
+}
+
+void LD2420Component::loop() {
+  // If there is a active send command do not process it here, the send command call will handle it.
+  if (!get_cmd_active_()) {
+    if (!available())
+      return;
+    static uint8_t buffer[2048];
+    static uint8_t rx_data;
+    while (available()) {
+      rx_data = read();
+      this->readline_(rx_data, buffer, sizeof(buffer));
+    }
+  }
+}
+
+void LD2420Component::update_radar_data(uint16_t const *gate_energy, uint8_t sample_number) {
+  for (uint8_t gate = 0; gate < LD2420_TOTAL_GATES; ++gate) {
+    this->radar_data[gate][sample_number] = gate_energy[gate];
+  }
+  this->total_sample_number_counter++;
+}
+
+void LD2420Component::auto_calibrate_sensitivity() {
+  // Calculate average and peak values for each gate
+  const float move_factor = gate_move_sensitivity_factor + 1;
+  const float still_factor = (gate_still_sensitivity_factor / 2) + 1;
+  for (uint8_t gate = 0; gate < LD2420_TOTAL_GATES; ++gate) {
+    uint32_t sum = 0;
+    uint16_t peak = 0;
+
+    for (uint8_t sample_number = 0; sample_number < CALIBRATE_SAMPLES; ++sample_number) {
+      // Calculate average
+      sum += this->radar_data[gate][sample_number];
+
+      // Calculate max value
+      if (this->radar_data[gate][sample_number] > peak) {
+        peak = this->radar_data[gate][sample_number];
+      }
+    }
+
+    // Store average and peak values
+    this->gate_avg[gate] = sum / CALIBRATE_SAMPLES;
+    if (this->gate_peak[gate] < peak)
+      this->gate_peak[gate] = peak;
+
+    uint32_t calculated_value =
+        (static_cast<uint32_t>(this->gate_peak[gate]) + (move_factor * static_cast<uint32_t>(this->gate_peak[gate])));
+    this->new_config.move_thresh[gate] = static_cast<uint16_t>(calculated_value <= 65535 ? calculated_value : 65535);
+    calculated_value =
+        (static_cast<uint32_t>(this->gate_peak[gate]) + (still_factor * static_cast<uint32_t>(this->gate_peak[gate])));
+    this->new_config.still_thresh[gate] = static_cast<uint16_t>(calculated_value <= 65535 ? calculated_value : 65535);
+  }
+}
+
+void LD2420Component::report_gate_data() {
+  for (uint8_t gate = 0; gate < LD2420_TOTAL_GATES; ++gate) {
+    // Output results
+    ESP_LOGI(TAG, "Gate: %2d Avg: %5d Peak: %5d", gate, this->gate_avg[gate], this->gate_peak[gate]);
+  }
+  ESP_LOGI(TAG, "Total samples: %d", this->total_sample_number_counter);
+}
+
+void LD2420Component::set_operating_mode(const std::string &state) {
+  // If unsupported firmware ignore mode select
+  if (get_firmware_int_(ld2420_firmware_ver_) >= CALIBRATE_VERSION_MIN) {
+    this->current_operating_mode = OP_MODE_TO_UINT.at(state);
+    // Entering Auto Calibrate we need to clear the privoiuos data collection
+    this->operating_selector_->publish_state(state);
+    if (current_operating_mode == OP_CALIBRATE_MODE) {
+      this->set_calibration_(true);
+      for (uint8_t gate = 0; gate < LD2420_TOTAL_GATES; gate++) {
+        this->gate_avg[gate] = 0;
+        this->gate_peak[gate] = 0;
+        for (uint8_t i = 0; i < CALIBRATE_SAMPLES; i++) {
+          this->radar_data[gate][i] = 0;
+        }
+        this->total_sample_number_counter = 0;
+      }
+    } else {
+      // Set the current data back so we don't have new data that can be applied in error.
+      if (this->get_calibration_())
+        memcpy(&this->new_config, &this->current_config, sizeof(this->current_config));
+      this->set_calibration_(false);
+    }
+  } else {
+    this->current_operating_mode = OP_SIMPLE_MODE;
+    this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING);
+  }
+}
+
+void LD2420Component::readline_(int rx_data, uint8_t *buffer, int len) {
+  static int pos = 0;
+
+  if (rx_data >= 0) {
+    if (pos < len - 1) {
+      buffer[pos++] = rx_data;
+      buffer[pos] = 0;
+    } else {
+      pos = 0;
+    }
+    if (pos >= 4) {
+      if (memcmp(&buffer[pos - 4], &CMD_FRAME_FOOTER, sizeof(CMD_FRAME_FOOTER)) == 0) {
+        this->set_cmd_active_(false);  // Set command state to inactive after responce.
+        this->handle_ack_data_(buffer, pos);
+        pos = 0;
+      } else if ((buffer[pos - 2] == 0x0D && buffer[pos - 1] == 0x0A) && (get_mode_() == CMD_SYSTEM_MODE_SIMPLE)) {
+        this->handle_simple_mode_(buffer, pos);
+        pos = 0;
+      } else if ((memcmp(&buffer[pos - 4], &ENERGY_FRAME_FOOTER, sizeof(ENERGY_FRAME_FOOTER)) == 0) &&
+                 (get_mode_() == CMD_SYSTEM_MODE_ENERGY)) {
+        this->handle_energy_mode_(buffer, pos);
+        pos = 0;
+      }
+    }
+  }
+}
+
+void LD2420Component::handle_energy_mode_(uint8_t *buffer, int len) {
+  uint8_t index = 6;  // Start at presence byte position
+  uint16_t range;
+  const uint8_t elements = sizeof(this->gate_energy_) / sizeof(this->gate_energy_[0]);
+  this->set_presence_(buffer[index]);
+  index++;
+  memcpy(&range, &buffer[index], sizeof(range));
+  index += sizeof(range);
+  this->set_distance_(range);
+  for (uint8_t i = 0; i < elements; i++) {  // NOLINT
+    memcpy(&this->gate_energy_[i], &buffer[index], sizeof(this->gate_energy_[0]));
+    index += sizeof(this->gate_energy_[0]);
+  }
+
+  if (this->current_operating_mode == OP_CALIBRATE_MODE) {
+    this->update_radar_data(gate_energy_, sample_number_counter);
+    this->sample_number_counter > CALIBRATE_SAMPLES ? this->sample_number_counter = 0 : this->sample_number_counter++;
+  }
+
+  // Resonable refresh rate for home assistant database size health
+  const int32_t current_millis = millis();
+  if (current_millis - this->last_periodic_millis < REFRESH_RATE_MS)
+    return;
+  this->last_periodic_millis = current_millis;
+  for (auto &listener : this->listeners_) {
+    listener->on_distance(get_distance_());
+    listener->on_presence(get_presence_());
+    listener->on_energy(this->gate_energy_, sizeof(this->gate_energy_) / sizeof(this->gate_energy_[0]));
+  }
+
+  if (this->current_operating_mode == OP_CALIBRATE_MODE) {
+    this->auto_calibrate_sensitivity();
+    if (current_millis - this->report_periodic_millis > REFRESH_RATE_MS * CALIBRATE_REPORT_INTERVAL) {
+      this->report_periodic_millis = current_millis;
+      this->report_gate_data();
+    }
+  }
+}
+
+void LD2420Component::handle_simple_mode_(const uint8_t *inbuf, int len) {
+  const uint8_t bufsize = 16;
+  uint8_t index{0};
+  uint8_t pos{0};
+  char *endptr{nullptr};
+  char outbuf[bufsize]{0};
+  while (true) {
+    if (inbuf[pos - 2] == 'O' && inbuf[pos - 1] == 'F' && inbuf[pos] == 'F') {
+      set_presence_(false);
+    } else if (inbuf[pos - 1] == 'O' && inbuf[pos] == 'N') {
+      set_presence_(true);
+    }
+    if (inbuf[pos] >= '0' && inbuf[pos] <= '9') {
+      if (index < bufsize - 1) {
+        outbuf[index++] = inbuf[pos];
+        pos++;
+      }
+    } else {
+      if (pos < len - 1) {
+        pos++;
+      } else {
+        break;
+      }
+    }
+  }
+  outbuf[index] = '\0';
+  if (index > 1)
+    set_distance_(strtol(outbuf, &endptr, 10));
+
+  if (get_mode_() == CMD_SYSTEM_MODE_SIMPLE) {
+    // Resonable refresh rate for home assistant database size health
+    const int32_t current_millis = millis();
+    if (current_millis - this->last_normal_periodic_millis < REFRESH_RATE_MS)
+      return;
+    this->last_normal_periodic_millis = current_millis;
+    for (auto &listener : this->listeners_)
+      listener->on_distance(get_distance_());
+    for (auto &listener : this->listeners_)
+      listener->on_presence(get_presence_());
+  }
+}
+
+void LD2420Component::handle_ack_data_(uint8_t *buffer, int len) {
+  this->cmd_reply_.command = buffer[CMD_FRAME_COMMAND];
+  this->cmd_reply_.length = buffer[CMD_FRAME_DATA_LENGTH];
+  uint8_t reg_element = 0;
+  uint8_t data_element = 0;
+  uint16_t data_pos = 0;
+  if (this->cmd_reply_.length > CMD_MAX_BYTES) {
+    ESP_LOGW(TAG, "LD2420 reply - received command reply frame is corrupt, length exceeds %d bytes.", CMD_MAX_BYTES);
+    return;
+  } else if (this->cmd_reply_.length < 2) {
+    ESP_LOGW(TAG, "LD2420 reply - received command frame is corrupt, length is less than 2 bytes.");
+    return;
+  }
+  memcpy(&this->cmd_reply_.error, &buffer[CMD_ERROR_WORD], sizeof(this->cmd_reply_.error));
+  const char *result = this->cmd_reply_.error ? "failure" : "success";
+  if (this->cmd_reply_.error > 0) {
+    return;
+  };
+  this->cmd_reply_.ack = true;
+  switch ((uint16_t) this->cmd_reply_.command) {
+    case (CMD_ENABLE_CONF):
+      ESP_LOGD(TAG, "LD2420 reply - set config enable: CMD = %2X %s", CMD_ENABLE_CONF, result);
+      break;
+    case (CMD_DISABLE_CONF):
+      ESP_LOGD(TAG, "LD2420 reply - set config disable: CMD = %2X %s", CMD_DISABLE_CONF, result);
+      break;
+    case (CMD_READ_REGISTER):
+      ESP_LOGD(TAG, "LD2420 reply - read register: CMD = %2X %s", CMD_READ_REGISTER, result);
+      // TODO Read/Write register is not implemented yet, this will get flushed out to a proper header file
+      data_pos = 0x0A;
+      for (uint16_t index = 0; index < (CMD_REG_DATA_REPLY_SIZE *  // NOLINT
+                                        ((buffer[CMD_FRAME_DATA_LENGTH] - 4) / CMD_REG_DATA_REPLY_SIZE));
+           index += CMD_REG_DATA_REPLY_SIZE) {
+        memcpy(&this->cmd_reply_.data[reg_element], &buffer[data_pos + index], sizeof(CMD_REG_DATA_REPLY_SIZE));
+        byteswap(this->cmd_reply_.data[reg_element]);
+        reg_element++;
+      }
+      break;
+    case (CMD_WRITE_REGISTER):
+      ESP_LOGD(TAG, "LD2420 reply - write register: CMD = %2X %s", CMD_WRITE_REGISTER, result);
+      break;
+    case (CMD_WRITE_ABD_PARAM):
+      ESP_LOGD(TAG, "LD2420 reply - write gate parameter(s): %2X %s", CMD_WRITE_ABD_PARAM, result);
+      break;
+    case (CMD_READ_ABD_PARAM):
+      ESP_LOGD(TAG, "LD2420 reply - read gate parameter(s): %2X %s", CMD_READ_ABD_PARAM, result);
+      data_pos = CMD_ABD_DATA_REPLY_START;
+      for (uint16_t index = 0; index < (CMD_ABD_DATA_REPLY_SIZE *  // NOLINT
+                                        ((buffer[CMD_FRAME_DATA_LENGTH] - 4) / CMD_ABD_DATA_REPLY_SIZE));
+           index += CMD_ABD_DATA_REPLY_SIZE) {
+        memcpy(&this->cmd_reply_.data[data_element], &buffer[data_pos + index],
+               sizeof(this->cmd_reply_.data[data_element]));
+        byteswap(this->cmd_reply_.data[data_element]);
+        data_element++;
+      }
+      break;
+    case (CMD_WRITE_SYS_PARAM):
+      ESP_LOGD(TAG, "LD2420 reply - set system parameter(s): %2X %s", CMD_WRITE_SYS_PARAM, result);
+      break;
+    case (CMD_READ_VERSION):
+      memcpy(this->ld2420_firmware_ver_, &buffer[12], buffer[10]);
+      ESP_LOGD(TAG, "LD2420 reply - module firmware version: %7s %s", this->ld2420_firmware_ver_, result);
+      break;
+    default:
+      break;
+  }
+}
+
+int LD2420Component::send_cmd_from_array(CmdFrameT frame) {
+  uint8_t error = 0;
+  uint8_t ack_buffer[64];
+  uint8_t cmd_buffer[64];
+  uint16_t loop_count;
+  this->cmd_reply_.ack = false;
+  if (frame.command != CMD_RESTART)
+    this->set_cmd_active_(true);  // Restart does not reply, thus no ack state required.
+  uint8_t retry = 3;
+  while (retry) {
+    // TODO setup a dynamic method e.g. millis time count etc. to tune for non ESP32 240Mhz devices
+    // this is ok for now since the module firmware is changing like the weather atm
+    frame.length = 0;
+    loop_count = 1250;
+    uint16_t frame_data_bytes = frame.data_length + 2;  // Always add two bytes for the cmd size
+
+    memcpy(&cmd_buffer[frame.length], &frame.header, sizeof(frame.header));
+    frame.length += sizeof(frame.header);
+
+    memcpy(&cmd_buffer[frame.length], &frame_data_bytes, sizeof(frame.data_length));
+    frame.length += sizeof(frame.data_length);
+
+    memcpy(&cmd_buffer[frame.length], &frame.command, sizeof(frame.command));
+    frame.length += sizeof(frame.command);
+
+    for (uint16_t index = 0; index < frame.data_length; index++) {
+      memcpy(&cmd_buffer[frame.length], &frame.data[index], sizeof(frame.data[index]));
+      frame.length += sizeof(frame.data[index]);
+    }
+
+    memcpy(cmd_buffer + frame.length, &frame.footer, sizeof(frame.footer));
+    frame.length += sizeof(frame.footer);
+    for (uint16_t index = 0; index < frame.length; index++) {
+      this->write_byte(cmd_buffer[index]);
+    }
+
+    delay_microseconds_safe(500);  // give the module a moment to process it
+    error = 0;
+    if (frame.command == CMD_RESTART) {
+      delay_microseconds_safe(25000);  // Wait for the restart
+      return 0;                        // restart does not reply exit now
+    }
+
+    while (!this->cmd_reply_.ack) {
+      while (available()) {
+        this->readline_(read(), ack_buffer, sizeof(ack_buffer));
+      }
+      delay_microseconds_safe(250);
+      if (loop_count <= 0) {
+        error = LD2420_ERROR_TIMEOUT;
+        retry--;
+        break;
+      }
+      loop_count--;
+    }
+    if (this->cmd_reply_.ack)
+      retry = 0;
+    if (this->cmd_reply_.error > 0)
+      handle_cmd_error(error);
+  }
+  return error;
+}
+
+uint8_t LD2420Component::set_config_mode(bool enable) {
+  CmdFrameT cmd_frame;
+  cmd_frame.data_length = 0;
+  cmd_frame.header = CMD_FRAME_HEADER;
+  cmd_frame.command = enable ? CMD_ENABLE_CONF : CMD_DISABLE_CONF;
+  if (enable) {
+    memcpy(&cmd_frame.data[0], &CMD_PROTOCOL_VER, sizeof(CMD_PROTOCOL_VER));
+    cmd_frame.data_length += sizeof(CMD_PROTOCOL_VER);
+  }
+  cmd_frame.footer = CMD_FRAME_FOOTER;
+  ESP_LOGD(TAG, "Sending set config %s command: %2X", enable ? "enable" : "disable", cmd_frame.command);
+  return this->send_cmd_from_array(cmd_frame);
+}
+
+// Sends a restart and set system running mode to normal
+void LD2420Component::send_module_restart() { this->ld2420_restart(); }
+
+void LD2420Component::ld2420_restart() {
+  CmdFrameT cmd_frame;
+  cmd_frame.data_length = 0;
+  cmd_frame.header = CMD_FRAME_HEADER;
+  cmd_frame.command = CMD_RESTART;
+  cmd_frame.footer = CMD_FRAME_FOOTER;
+  ESP_LOGD(TAG, "Sending restart command: %2X", cmd_frame.command);
+  this->send_cmd_from_array(cmd_frame);
+}
+
+void LD2420Component::get_reg_value_(uint16_t reg) {
+  CmdFrameT cmd_frame;
+  cmd_frame.data_length = 0;
+  cmd_frame.header = CMD_FRAME_HEADER;
+  cmd_frame.command = CMD_READ_REGISTER;
+  cmd_frame.data[1] = reg;
+  cmd_frame.data_length += 2;
+  cmd_frame.footer = CMD_FRAME_FOOTER;
+  ESP_LOGD(TAG, "Sending read register %4X command: %2X", reg, cmd_frame.command);
+  this->send_cmd_from_array(cmd_frame);
+}
+
+void LD2420Component::set_reg_value(uint16_t reg, uint16_t value) {
+  CmdFrameT cmd_frame;
+  cmd_frame.data_length = 0;
+  cmd_frame.header = CMD_FRAME_HEADER;
+  cmd_frame.command = CMD_WRITE_REGISTER;
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &reg, sizeof(CMD_REG_DATA_REPLY_SIZE));
+  cmd_frame.data_length += 2;
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &value, sizeof(CMD_REG_DATA_REPLY_SIZE));
+  cmd_frame.data_length += 2;
+  cmd_frame.footer = CMD_FRAME_FOOTER;
+  ESP_LOGD(TAG, "Sending write register %4X command: %2X data = %4X", reg, cmd_frame.command, value);
+  this->send_cmd_from_array(cmd_frame);
+}
+
+void LD2420Component::handle_cmd_error(uint8_t error) { ESP_LOGI(TAG, "Command failed: %s", ERR_MESSAGE[error]); }
+
+int LD2420Component::get_gate_threshold_(uint8_t gate) {
+  uint8_t error;
+  CmdFrameT cmd_frame;
+  cmd_frame.data_length = 0;
+  cmd_frame.header = CMD_FRAME_HEADER;
+  cmd_frame.command = CMD_READ_ABD_PARAM;
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_GATE_MOVE_THRESH[gate], sizeof(CMD_GATE_MOVE_THRESH[gate]));
+  cmd_frame.data_length += 2;
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_GATE_STILL_THRESH[gate], sizeof(CMD_GATE_STILL_THRESH[gate]));
+  cmd_frame.data_length += 2;
+  cmd_frame.footer = CMD_FRAME_FOOTER;
+  ESP_LOGD(TAG, "Sending read gate %d high/low theshold command: %2X", gate, cmd_frame.command);
+  error = this->send_cmd_from_array(cmd_frame);
+  if (error == 0) {
+    this->current_config.move_thresh[gate] = cmd_reply_.data[0];
+    this->current_config.still_thresh[gate] = cmd_reply_.data[1];
+  }
+  return error;
+}
+
+int LD2420Component::get_min_max_distances_timeout_() {
+  uint8_t error;
+  CmdFrameT cmd_frame;
+  cmd_frame.data_length = 0;
+  cmd_frame.header = CMD_FRAME_HEADER;
+  cmd_frame.command = CMD_READ_ABD_PARAM;
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_MIN_GATE_REG,
+         sizeof(CMD_MIN_GATE_REG));  // Register: global min detect gate number
+  cmd_frame.data_length += sizeof(CMD_MIN_GATE_REG);
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_MAX_GATE_REG,
+         sizeof(CMD_MAX_GATE_REG));  // Register: global max detect gate number
+  cmd_frame.data_length += sizeof(CMD_MAX_GATE_REG);
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_TIMEOUT_REG,
+         sizeof(CMD_TIMEOUT_REG));  // Register: global delay time
+  cmd_frame.data_length += sizeof(CMD_TIMEOUT_REG);
+  cmd_frame.footer = CMD_FRAME_FOOTER;
+  ESP_LOGD(TAG, "Sending read gate min max and timeout command: %2X", cmd_frame.command);
+  error = this->send_cmd_from_array(cmd_frame);
+  if (error == 0) {
+    this->current_config.min_gate = (uint16_t) cmd_reply_.data[0];
+    this->current_config.max_gate = (uint16_t) cmd_reply_.data[1];
+    this->current_config.timeout = (uint16_t) cmd_reply_.data[2];
+  }
+  return error;
+}
+
+void LD2420Component::set_system_mode(uint16_t mode) {
+  CmdFrameT cmd_frame;
+  uint16_t unknown_parm = 0x0000;
+  cmd_frame.data_length = 0;
+  cmd_frame.header = CMD_FRAME_HEADER;
+  cmd_frame.command = CMD_WRITE_SYS_PARAM;
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_SYSTEM_MODE, sizeof(CMD_SYSTEM_MODE));
+  cmd_frame.data_length += sizeof(CMD_SYSTEM_MODE);
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &mode, sizeof(mode));
+  cmd_frame.data_length += sizeof(mode);
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &unknown_parm, sizeof(unknown_parm));
+  cmd_frame.data_length += sizeof(unknown_parm);
+  cmd_frame.footer = CMD_FRAME_FOOTER;
+  ESP_LOGD(TAG, "Sending write system mode command: %2X", cmd_frame.command);
+  if (this->send_cmd_from_array(cmd_frame) == 0)
+    set_mode_(mode);
+}
+
+void LD2420Component::get_firmware_version_() {
+  CmdFrameT cmd_frame;
+  cmd_frame.data_length = 0;
+  cmd_frame.header = CMD_FRAME_HEADER;
+  cmd_frame.command = CMD_READ_VERSION;
+  cmd_frame.footer = CMD_FRAME_FOOTER;
+
+  ESP_LOGD(TAG, "Sending read firmware version command: %2X", cmd_frame.command);
+  this->send_cmd_from_array(cmd_frame);
+}
+
+void LD2420Component::set_min_max_distances_timeout(uint32_t max_gate_distance, uint32_t min_gate_distance,  // NOLINT
+                                                    uint32_t timeout) {
+  // Header H, Length L, Register R, Value V, Footer F
+  //                        |Min Gate         |Max Gate         |Timeout          |
+  // HH HH HH HH LL LL CC CC RR RR VV VV VV VV RR RR VV VV VV VV RR RR VV VV VV VV FF FF FF FF
+  // FD FC FB FA 14 00 07 00 00 00 01 00 00 00 01 00 09 00 00 00 04 00 0A 00 00 00 04 03 02 01 e.g.
+
+  CmdFrameT cmd_frame;
+  cmd_frame.data_length = 0;
+  cmd_frame.header = CMD_FRAME_HEADER;
+  cmd_frame.command = CMD_WRITE_ABD_PARAM;
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_MIN_GATE_REG,
+         sizeof(CMD_MIN_GATE_REG));  // Register: global min detect gate number
+  cmd_frame.data_length += sizeof(CMD_MIN_GATE_REG);
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &min_gate_distance, sizeof(min_gate_distance));
+  cmd_frame.data_length += sizeof(min_gate_distance);
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_MAX_GATE_REG,
+         sizeof(CMD_MAX_GATE_REG));  // Register: global max detect gate number
+  cmd_frame.data_length += sizeof(CMD_MAX_GATE_REG);
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &max_gate_distance, sizeof(max_gate_distance));
+  cmd_frame.data_length += sizeof(max_gate_distance);
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_TIMEOUT_REG,
+         sizeof(CMD_TIMEOUT_REG));  // Register: global delay time
+  cmd_frame.data_length += sizeof(CMD_TIMEOUT_REG);
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &timeout, sizeof(timeout));
+  ;
+  cmd_frame.data_length += sizeof(timeout);
+  cmd_frame.footer = CMD_FRAME_FOOTER;
+
+  ESP_LOGD(TAG, "Sending write gate min max and timeout command: %2X", cmd_frame.command);
+  this->send_cmd_from_array(cmd_frame);
+}
+
+void LD2420Component::set_gate_threshold(uint8_t gate) {
+  // Header H, Length L, Command C, Register R, Value V, Footer F
+  // HH HH HH HH LL LL CC CC RR RR VV VV VV VV RR RR VV VV VV VV FF FF FF FF
+  // FD FC FB FA 14 00 07 00 10 00 00 FF 00 00 00 01 00 0F 00 00 04 03 02 01
+
+  uint16_t move_threshold_gate = CMD_GATE_MOVE_THRESH[gate];
+  uint16_t still_threshold_gate = CMD_GATE_STILL_THRESH[gate];
+  CmdFrameT cmd_frame;
+  cmd_frame.data_length = 0;
+  cmd_frame.header = CMD_FRAME_HEADER;
+  cmd_frame.command = CMD_WRITE_ABD_PARAM;
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &move_threshold_gate, sizeof(move_threshold_gate));
+  cmd_frame.data_length += sizeof(move_threshold_gate);
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &this->new_config.move_thresh[gate],
+         sizeof(this->new_config.move_thresh[gate]));
+  cmd_frame.data_length += sizeof(this->new_config.move_thresh[gate]);
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &still_threshold_gate, sizeof(still_threshold_gate));
+  cmd_frame.data_length += sizeof(still_threshold_gate);
+  memcpy(&cmd_frame.data[cmd_frame.data_length], &this->new_config.still_thresh[gate],
+         sizeof(this->new_config.still_thresh[gate]));
+  cmd_frame.data_length += sizeof(this->new_config.still_thresh[gate]);
+  cmd_frame.footer = CMD_FRAME_FOOTER;
+  ESP_LOGD(TAG, "Sending set gate %4X sensitivity command: %2X", gate, cmd_frame.command);
+  this->send_cmd_from_array(cmd_frame);
+}
+
+#ifdef USE_NUMBER
+void LD2420Component::init_gate_config_numbers() {
+  if (this->gate_timeout_number_ != nullptr)
+    this->gate_timeout_number_->publish_state(static_cast<uint16_t>(this->current_config.timeout));
+  if (this->gate_select_number_ != nullptr)
+    this->gate_select_number_->publish_state(0);
+  if (this->min_gate_distance_number_ != nullptr)
+    this->min_gate_distance_number_->publish_state(static_cast<uint16_t>(this->current_config.min_gate));
+  if (this->max_gate_distance_number_ != nullptr)
+    this->max_gate_distance_number_->publish_state(static_cast<uint16_t>(this->current_config.max_gate));
+  if (this->gate_move_sensitivity_factor_number_ != nullptr)
+    this->gate_move_sensitivity_factor_number_->publish_state(this->gate_move_sensitivity_factor);
+  if (this->gate_still_sensitivity_factor_number_ != nullptr)
+    this->gate_still_sensitivity_factor_number_->publish_state(this->gate_still_sensitivity_factor);
+  for (uint8_t gate = 0; gate < LD2420_TOTAL_GATES; gate++) {
+    if (this->gate_still_threshold_numbers_[gate] != nullptr) {
+      this->gate_still_threshold_numbers_[gate]->publish_state(
+          static_cast<uint16_t>(this->current_config.still_thresh[gate]));
+    }
+    if (this->gate_move_threshold_numbers_[gate] != nullptr) {
+      this->gate_move_threshold_numbers_[gate]->publish_state(
+          static_cast<uint16_t>(this->current_config.move_thresh[gate]));
+    }
+  }
+}
+
+void LD2420Component::refresh_gate_config_numbers() {
+  this->gate_timeout_number_->publish_state(this->new_config.timeout);
+  this->min_gate_distance_number_->publish_state(this->new_config.min_gate);
+  this->max_gate_distance_number_->publish_state(this->new_config.max_gate);
+}
+
+#endif
+
+}  // namespace ld2420
+}  // namespace esphome
diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h
new file mode 100644
index 0000000000..2780503776
--- /dev/null
+++ b/esphome/components/ld2420/ld2420.h
@@ -0,0 +1,272 @@
+#pragma once
+
+#include "esphome/core/component.h"
+#include "esphome/components/uart/uart.h"
+#include "esphome/core/automation.h"
+#include "esphome/core/helpers.h"
+#ifdef USE_TEXT_SENSOR
+#include "esphome/components/text_sensor/text_sensor.h"
+#endif
+#ifdef USE_SELECT
+#include "esphome/components/select/select.h"
+#endif
+#ifdef USE_NUMBER
+#include "esphome/components/number/number.h"
+#endif
+#ifdef USE_BUTTON
+#include "esphome/components/button/button.h"
+#endif
+#include <map>
+#include <functional>
+
+namespace esphome {
+namespace ld2420 {
+
+// Local const's
+static const uint16_t REFRESH_RATE_MS = 1000;
+
+// Command sets
+static const uint8_t CMD_ABD_DATA_REPLY_SIZE = 0x04;
+static const uint8_t CMD_ABD_DATA_REPLY_START = 0x0A;
+static const uint16_t CMD_DISABLE_CONF = 0x00FE;
+static const uint16_t CMD_ENABLE_CONF = 0x00FF;
+static const uint8_t CMD_MAX_BYTES = 0x64;
+static const uint16_t CMD_PARM_HIGH_TRESH = 0x0012;
+static const uint16_t CMD_PARM_LOW_TRESH = 0x0021;
+static const uint16_t CMD_PROTOCOL_VER = 0x0002;
+static const uint16_t CMD_READ_ABD_PARAM = 0x0008;
+static const uint16_t CMD_READ_REG_ADDR = 0x0020;
+static const uint16_t CMD_READ_REGISTER = 0x0002;
+static const uint16_t CMD_READ_SERIAL_NUM = 0x0011;
+static const uint16_t CMD_READ_SYS_PARAM = 0x0013;
+static const uint16_t CMD_READ_VERSION = 0x0000;
+static const uint8_t CMD_REG_DATA_REPLY_SIZE = 0x02;
+static const uint16_t CMD_RESTART = 0x0068;
+static const uint16_t CMD_SYSTEM_MODE = 0x0000;
+static const uint16_t CMD_SYSTEM_MODE_GR = 0x0003;
+static const uint16_t CMD_SYSTEM_MODE_MTT = 0x0001;
+static const uint16_t CMD_SYSTEM_MODE_SIMPLE = 0x0064;
+static const uint16_t CMD_SYSTEM_MODE_DEBUG = 0x0000;
+static const uint16_t CMD_SYSTEM_MODE_ENERGY = 0x0004;
+static const uint16_t CMD_SYSTEM_MODE_VS = 0x0002;
+static const uint16_t CMD_WRITE_ABD_PARAM = 0x0007;
+static const uint16_t CMD_WRITE_REGISTER = 0x0001;
+static const uint16_t CMD_WRITE_SYS_PARAM = 0x0012;
+
+static const uint8_t LD2420_ERROR_NONE = 0x00;
+static const uint8_t LD2420_ERROR_TIMEOUT = 0x02;
+static const uint8_t LD2420_ERROR_UNKNOWN = 0x01;
+static const uint8_t LD2420_TOTAL_GATES = 16;
+static const uint8_t CALIBRATE_SAMPLES = 64;
+
+// Register address values
+static const uint16_t CMD_MIN_GATE_REG = 0x0000;
+static const uint16_t CMD_MAX_GATE_REG = 0x0001;
+static const uint16_t CMD_TIMEOUT_REG = 0x0004;
+static const uint16_t CMD_GATE_MOVE_THRESH[LD2420_TOTAL_GATES] = {0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015,
+                                                                  0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B,
+                                                                  0x001C, 0x001D, 0x001E, 0x001F};
+static const uint16_t CMD_GATE_STILL_THRESH[LD2420_TOTAL_GATES] = {0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025,
+                                                                   0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B,
+                                                                   0x002C, 0x002D, 0x002E, 0x002F};
+static const uint32_t FACTORY_MOVE_THRESH[LD2420_TOTAL_GATES] = {60000, 30000, 400, 250, 250, 250, 250, 250,
+                                                                 250,   250,   250, 250, 250, 250, 250, 250};
+static const uint32_t FACTORY_STILL_THRESH[LD2420_TOTAL_GATES] = {40000, 20000, 200, 200, 200, 200, 200, 150,
+                                                                  150,   100,   100, 100, 100, 100, 100, 100};
+static const uint16_t FACTORY_TIMEOUT = 120;
+static const uint16_t FACTORY_MIN_GATE = 1;
+static const uint16_t FACTORY_MAX_GATE = 12;
+
+// COMMAND_BYTE Header & Footer
+static const uint8_t CMD_FRAME_COMMAND = 6;
+static const uint8_t CMD_FRAME_DATA_LENGTH = 4;
+static const uint32_t CMD_FRAME_FOOTER = 0x01020304;
+static const uint32_t CMD_FRAME_HEADER = 0xFAFBFCFD;
+static const uint32_t DEBUG_FRAME_FOOTER = 0xFAFBFCFD;
+static const uint32_t DEBUG_FRAME_HEADER = 0x1410BFAA;
+static const uint32_t ENERGY_FRAME_FOOTER = 0xF5F6F7F8;
+static const uint32_t ENERGY_FRAME_HEADER = 0xF1F2F3F4;
+static const uint8_t CMD_FRAME_STATUS = 7;
+static const uint8_t CMD_ERROR_WORD = 8;
+static const uint8_t ENERGY_SENSOR_START = 9;
+static const uint8_t CALIBRATE_REPORT_INTERVAL = 4;
+static const int CALIBRATE_VERSION_MIN = 154;
+static const std::string OP_NORMAL_MODE_STRING = "Normal";
+static const std::string OP_SIMPLE_MODE_STRING = "Simple";
+
+enum OpModeStruct : uint8_t { OP_NORMAL_MODE = 1, OP_CALIBRATE_MODE = 2, OP_SIMPLE_MODE = 3 };
+static const std::map<std::string, uint8_t> OP_MODE_TO_UINT{
+    {"Normal", OP_NORMAL_MODE}, {"Calibrate", OP_CALIBRATE_MODE}, {"Simple", OP_SIMPLE_MODE}};
+static constexpr const char *ERR_MESSAGE[] = {"None", "Unknown", "Timeout"};
+
+class LD2420Listener {
+ public:
+  virtual void on_presence(bool presence){};
+  virtual void on_distance(uint16_t distance){};
+  virtual void on_energy(uint16_t *sensor_energy, size_t size){};
+  virtual void on_fw_version(std::string &fw){};
+};
+
+class LD2420Component : public Component, public uart::UARTDevice {
+ public:
+  void setup() override;
+  void dump_config() override;
+  void loop() override;
+#ifdef USE_SELECT
+  void set_operating_mode_select(select::Select *selector) { this->operating_selector_ = selector; };
+#endif
+#ifdef USE_NUMBER
+  void set_gate_timeout_number(number::Number *number) { this->gate_timeout_number_ = number; };
+  void set_gate_select_number(number::Number *number) { this->gate_select_number_ = number; };
+  void set_min_gate_distance_number(number::Number *number) { this->min_gate_distance_number_ = number; };
+  void set_max_gate_distance_number(number::Number *number) { this->max_gate_distance_number_ = number; };
+  void set_gate_move_sensitivity_factor_number(number::Number *number) {
+    this->gate_move_sensitivity_factor_number_ = number;
+  };
+  void set_gate_still_sensitivity_factor_number(number::Number *number) {
+    this->gate_still_sensitivity_factor_number_ = number;
+  };
+  void set_gate_still_threshold_numbers(int gate, number::Number *n) { this->gate_still_threshold_numbers_[gate] = n; };
+  void set_gate_move_threshold_numbers(int gate, number::Number *n) { this->gate_move_threshold_numbers_[gate] = n; };
+  bool is_gate_select() { return gate_select_number_ != nullptr; };
+  uint8_t get_gate_select_value() { return static_cast<uint8_t>(this->gate_select_number_->state); };
+  float get_min_gate_distance_value() { return min_gate_distance_number_->state; };
+  float get_max_gate_distance_value() { return max_gate_distance_number_->state; };
+  void publish_gate_move_threshold(uint8_t gate) {
+    // With gate_select we only use 1 number pointer, thus we hard code [0]
+    this->gate_move_threshold_numbers_[0]->publish_state(this->new_config.move_thresh[gate]);
+  };
+  void publish_gate_still_threshold(uint8_t gate) {
+    this->gate_still_threshold_numbers_[0]->publish_state(this->new_config.still_thresh[gate]);
+  };
+  void init_gate_config_numbers();
+  void refresh_gate_config_numbers();
+#endif
+#ifdef USE_BUTTON
+  void set_apply_config_button(button::Button *button) { this->apply_config_button_ = button; };
+  void set_revert_config_button(button::Button *button) { this->revert_config_button_ = button; };
+  void set_restart_module_button(button::Button *button) { this->restart_module_button_ = button; };
+  void set_factory_reset_button(button::Button *button) { this->factory_reset_button_ = button; };
+#endif
+  void register_listener(LD2420Listener *listener) { this->listeners_.push_back(listener); }
+
+  struct CmdFrameT {
+    uint32_t header{0};
+    uint16_t length{0};
+    uint16_t command{0};
+    uint8_t data[18];
+    uint16_t data_length{0};
+    uint32_t footer{0};
+  };
+
+  struct RegConfigT {
+    uint16_t min_gate{0};
+    uint16_t max_gate{0};
+    uint16_t timeout{0};
+    uint32_t move_thresh[LD2420_TOTAL_GATES];
+    uint32_t still_thresh[LD2420_TOTAL_GATES];
+  };
+
+  void send_module_restart();
+  void restart_module_action();
+  void apply_config_action();
+  void factory_reset_action();
+  void revert_config_action();
+  float get_setup_priority() const override;
+  int send_cmd_from_array(CmdFrameT cmd_frame);
+  void report_gate_data();
+  void handle_cmd_error(uint8_t error);
+  void set_operating_mode(const std::string &state);
+  void auto_calibrate_sensitivity();
+  void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number);
+  uint8_t calc_checksum(void *data, size_t size);
+
+  RegConfigT current_config;
+  RegConfigT new_config;
+  int32_t last_periodic_millis = millis();
+  int32_t report_periodic_millis = millis();
+  int32_t monitor_periodic_millis = millis();
+  int32_t last_normal_periodic_millis = millis();
+  bool output_energy_state{false};
+  uint8_t current_operating_mode{OP_NORMAL_MODE};
+  uint16_t radar_data[LD2420_TOTAL_GATES][CALIBRATE_SAMPLES];
+  uint16_t gate_avg[LD2420_TOTAL_GATES];
+  uint16_t gate_peak[LD2420_TOTAL_GATES];
+  uint8_t sample_number_counter{0};
+  uint16_t total_sample_number_counter{0};
+  float gate_move_sensitivity_factor{0.5};
+  float gate_still_sensitivity_factor{0.5};
+#ifdef USE_SELECT
+  select::Select *operating_selector_{nullptr};
+#endif
+#ifdef USE_BUTTON
+  button::Button *apply_config_button_{nullptr};
+  button::Button *revert_config_button_{nullptr};
+  button::Button *restart_module_button_{nullptr};
+  button::Button *factory_reset_button_{nullptr};
+#endif
+  void set_min_max_distances_timeout(uint32_t max_gate_distance, uint32_t min_gate_distance, uint32_t timeout);
+  void set_gate_threshold(uint8_t gate);
+  void set_reg_value(uint16_t reg, uint16_t value);
+  uint8_t set_config_mode(bool enable);
+  void set_system_mode(uint16_t mode);
+  void ld2420_restart();
+
+ protected:
+  struct CmdReplyT {
+    uint8_t command;
+    uint8_t status;
+    uint32_t data[4];
+    uint8_t length;
+    uint16_t error;
+    volatile bool ack;
+  };
+
+  int get_firmware_int_(const char *version_string);
+  void get_firmware_version_();
+  int get_gate_threshold_(uint8_t gate);
+  void get_reg_value_(uint16_t reg);
+  int get_min_max_distances_timeout_();
+  uint16_t get_mode_() { return this->system_mode_; };
+  void set_mode_(uint16_t mode) { this->system_mode_ = mode; };
+  bool get_presence_() { return this->presence_; };
+  void set_presence_(bool presence) { this->presence_ = presence; };
+  uint16_t get_distance_() { return this->distance_; };
+  void set_distance_(uint16_t distance) { this->distance_ = distance; };
+  bool get_cmd_active_() { return this->cmd_active_; };
+  void set_cmd_active_(bool active) { this->cmd_active_ = active; };
+  void handle_simple_mode_(const uint8_t *inbuf, int len);
+  void handle_energy_mode_(uint8_t *buffer, int len);
+  void handle_ack_data_(uint8_t *buffer, int len);
+  void readline_(int rx_data, uint8_t *buffer, int len);
+  void set_calibration_(bool state) { this->calibration_ = state; };
+  bool get_calibration_() { return this->calibration_; };
+
+#ifdef USE_NUMBER
+  number::Number *gate_timeout_number_{nullptr};
+  number::Number *gate_select_number_{nullptr};
+  number::Number *min_gate_distance_number_{nullptr};
+  number::Number *max_gate_distance_number_{nullptr};
+  number::Number *gate_move_sensitivity_factor_number_{nullptr};
+  number::Number *gate_still_sensitivity_factor_number_{nullptr};
+  std::vector<number::Number *> gate_still_threshold_numbers_ = std::vector<number::Number *>(16);
+  std::vector<number::Number *> gate_move_threshold_numbers_ = std::vector<number::Number *>(16);
+#endif
+
+  uint16_t gate_energy_[LD2420_TOTAL_GATES];
+  CmdReplyT cmd_reply_;
+  uint32_t timeout_;
+  uint32_t max_distance_gate_;
+  uint32_t min_distance_gate_;
+  uint16_t system_mode_{CMD_SYSTEM_MODE_ENERGY};
+  bool cmd_active_{false};
+  char ld2420_firmware_ver_[8];
+  bool presence_{false};
+  bool calibration_{false};
+  uint16_t distance_{0};
+  uint8_t config_checksum_{0};
+  std::vector<LD2420Listener *> listeners_{};
+};
+
+}  // namespace ld2420
+}  // namespace esphome
diff --git a/esphome/components/ld2420/number/__init__.py b/esphome/components/ld2420/number/__init__.py
new file mode 100644
index 0000000000..4ae08356fc
--- /dev/null
+++ b/esphome/components/ld2420/number/__init__.py
@@ -0,0 +1,183 @@
+import esphome.codegen as cg
+from esphome.components import number
+import esphome.config_validation as cv
+from esphome.const import (
+    CONF_ID,
+    DEVICE_CLASS_DISTANCE,
+    UNIT_SECOND,
+    ENTITY_CATEGORY_CONFIG,
+    ICON_MOTION_SENSOR,
+    ICON_TIMELAPSE,
+    ICON_SCALE,
+)
+from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns
+
+LD2420TimeoutNumber = ld2420_ns.class_("LD2420TimeoutNumber", number.Number)
+LD2420MoveSensFactorNumber = ld2420_ns.class_(
+    "LD2420MoveSensFactorNumber", number.Number
+)
+LD2420StillSensFactorNumber = ld2420_ns.class_(
+    "LD2420StillSensFactorNumber", number.Number
+)
+LD2420MinDistanceNumber = ld2420_ns.class_("LD2420MinDistanceNumber", number.Number)
+LD2420MaxDistanceNumber = ld2420_ns.class_("LD2420MaxDistanceNumber", number.Number)
+LD2420GateSelectNumber = ld2420_ns.class_("LD2420GateSelectNumber", number.Number)
+LD2420MoveThresholdNumbers = ld2420_ns.class_(
+    "LD2420MoveThresholdNumbers", number.Number
+)
+LD2420StillThresholdNumbers = ld2420_ns.class_(
+    "LD2420StillThresholdNumbers", number.Number
+)
+CONF_MIN_GATE_DISTANCE = "min_gate_distance"
+CONF_MAX_GATE_DISTANCE = "max_gate_distance"
+CONF_STILL_THRESHOLD = "still_threshold"
+CONF_MOVE_THRESHOLD = "move_threshold"
+CONF_GATE_MOVE_SENSITIVITY = "gate_move_sensitivity"
+CONF_GATE_STILL_SENSITIVITY = "gate_still_sensitivity"
+CONF_GATE_SELECT = "gate_select"
+CONF_PRESENCE_TIMEOUT = "presence_timeout"
+GATE_GROUP = "gate_group"
+TIMEOUT_GROUP = "timeout_group"
+
+
+CONFIG_SCHEMA = cv.Schema(
+    {
+        cv.GenerateID(CONF_LD2420_ID): cv.use_id(LD2420Component),
+        cv.Inclusive(CONF_PRESENCE_TIMEOUT, TIMEOUT_GROUP): number.number_schema(
+            LD2420TimeoutNumber,
+            unit_of_measurement=UNIT_SECOND,
+            entity_category=ENTITY_CATEGORY_CONFIG,
+            icon=ICON_TIMELAPSE,
+        ),
+        cv.Inclusive(CONF_MIN_GATE_DISTANCE, TIMEOUT_GROUP): number.number_schema(
+            LD2420MinDistanceNumber,
+            device_class=DEVICE_CLASS_DISTANCE,
+            entity_category=ENTITY_CATEGORY_CONFIG,
+            icon=ICON_MOTION_SENSOR,
+        ),
+        cv.Inclusive(CONF_MAX_GATE_DISTANCE, TIMEOUT_GROUP): number.number_schema(
+            LD2420MaxDistanceNumber,
+            device_class=DEVICE_CLASS_DISTANCE,
+            entity_category=ENTITY_CATEGORY_CONFIG,
+            icon=ICON_MOTION_SENSOR,
+        ),
+        cv.Inclusive(CONF_GATE_SELECT, GATE_GROUP): number.number_schema(
+            LD2420GateSelectNumber,
+            device_class=DEVICE_CLASS_DISTANCE,
+            entity_category=ENTITY_CATEGORY_CONFIG,
+            icon=ICON_MOTION_SENSOR,
+        ),
+        cv.Inclusive(CONF_STILL_THRESHOLD, GATE_GROUP): number.number_schema(
+            LD2420StillThresholdNumbers,
+            entity_category=ENTITY_CATEGORY_CONFIG,
+            icon=ICON_MOTION_SENSOR,
+        ),
+        cv.Inclusive(CONF_MOVE_THRESHOLD, GATE_GROUP): number.number_schema(
+            LD2420MoveThresholdNumbers,
+            entity_category=ENTITY_CATEGORY_CONFIG,
+            icon=ICON_MOTION_SENSOR,
+        ),
+        cv.Optional(CONF_GATE_MOVE_SENSITIVITY): number.number_schema(
+            LD2420MoveSensFactorNumber,
+            device_class=DEVICE_CLASS_DISTANCE,
+            entity_category=ENTITY_CATEGORY_CONFIG,
+            icon=ICON_SCALE,
+        ),
+        cv.Optional(CONF_GATE_STILL_SENSITIVITY): number.number_schema(
+            LD2420StillSensFactorNumber,
+            device_class=DEVICE_CLASS_DISTANCE,
+            entity_category=ENTITY_CATEGORY_CONFIG,
+            icon=ICON_SCALE,
+        ),
+    }
+)
+CONFIG_SCHEMA = CONFIG_SCHEMA.extend(
+    {
+        cv.Optional(f"gate_{x}"): (
+            {
+                cv.Required(CONF_MOVE_THRESHOLD): number.number_schema(
+                    LD2420MoveThresholdNumbers,
+                    entity_category=ENTITY_CATEGORY_CONFIG,
+                    icon=ICON_MOTION_SENSOR,
+                ),
+                cv.Required(CONF_STILL_THRESHOLD): number.number_schema(
+                    LD2420StillThresholdNumbers,
+                    entity_category=ENTITY_CATEGORY_CONFIG,
+                    icon=ICON_MOTION_SENSOR,
+                ),
+            }
+        )
+        for x in range(16)
+    }
+)
+
+
+async def to_code(config):
+    LD2420_component = await cg.get_variable(config[CONF_LD2420_ID])
+    if gate_timeout_config := config.get(CONF_PRESENCE_TIMEOUT):
+        n = await number.new_number(
+            gate_timeout_config, min_value=0, max_value=255, step=5
+        )
+        await cg.register_parented(n, config[CONF_LD2420_ID])
+        cg.add(LD2420_component.set_gate_timeout_number(n))
+    if min_distance_gate_config := config.get(CONF_MIN_GATE_DISTANCE):
+        n = await number.new_number(
+            min_distance_gate_config, min_value=0, max_value=15, step=1
+        )
+        await cg.register_parented(n, config[CONF_LD2420_ID])
+        cg.add(LD2420_component.set_min_gate_distance_number(n))
+    if max_distance_gate_config := config.get(CONF_MAX_GATE_DISTANCE):
+        n = await number.new_number(
+            max_distance_gate_config, min_value=1, max_value=15, step=1
+        )
+        await cg.register_parented(n, config[CONF_LD2420_ID])
+        cg.add(LD2420_component.set_max_gate_distance_number(n))
+    if gate_move_sensitivity_config := config.get(CONF_GATE_MOVE_SENSITIVITY):
+        n = await number.new_number(
+            gate_move_sensitivity_config, min_value=0.05, max_value=1, step=0.025
+        )
+        await cg.register_parented(n, config[CONF_LD2420_ID])
+        cg.add(LD2420_component.set_gate_move_sensitivity_factor_number(n))
+    if gate_still_sensitivity_config := config.get(CONF_GATE_STILL_SENSITIVITY):
+        n = await number.new_number(
+            gate_still_sensitivity_config, min_value=0.05, max_value=1, step=0.025
+        )
+        await cg.register_parented(n, config[CONF_LD2420_ID])
+        cg.add(LD2420_component.set_gate_still_sensitivity_factor_number(n))
+    if config.get(CONF_GATE_SELECT):
+        if gate_number := config.get(CONF_GATE_SELECT):
+            n = await number.new_number(gate_number, min_value=0, max_value=15, step=1)
+            await cg.register_parented(n, config[CONF_LD2420_ID])
+            cg.add(LD2420_component.set_gate_select_number(n))
+        if gate_still_threshold := config.get(CONF_STILL_THRESHOLD):
+            n = cg.new_Pvariable(gate_still_threshold[CONF_ID])
+            await number.register_number(
+                n, gate_still_threshold, min_value=0, max_value=65535, step=25
+            )
+            await cg.register_parented(n, config[CONF_LD2420_ID])
+            cg.add(LD2420_component.set_gate_still_threshold_numbers(0, n))
+        if gate_move_threshold := config.get(CONF_MOVE_THRESHOLD):
+            n = cg.new_Pvariable(gate_move_threshold[CONF_ID])
+            await number.register_number(
+                n, gate_move_threshold, min_value=0, max_value=65535, step=25
+            )
+            await cg.register_parented(n, config[CONF_LD2420_ID])
+            cg.add(LD2420_component.set_gate_move_threshold_numbers(0, n))
+    else:
+        for x in range(16):
+            if gate_conf := config.get(f"gate_{x}"):
+                move_config = gate_conf[CONF_MOVE_THRESHOLD]
+                n = cg.new_Pvariable(move_config[CONF_ID], x)
+                await number.register_number(
+                    n, move_config, min_value=0, max_value=65535, step=25
+                )
+                await cg.register_parented(n, config[CONF_LD2420_ID])
+                cg.add(LD2420_component.set_gate_move_threshold_numbers(x, n))
+
+                still_config = gate_conf[CONF_STILL_THRESHOLD]
+                n = cg.new_Pvariable(still_config[CONF_ID], x)
+                await number.register_number(
+                    n, still_config, min_value=0, max_value=65535, step=25
+                )
+                await cg.register_parented(n, config[CONF_LD2420_ID])
+                cg.add(LD2420_component.set_gate_still_threshold_numbers(x, n))
diff --git a/esphome/components/ld2420/number/gate_config_number.cpp b/esphome/components/ld2420/number/gate_config_number.cpp
new file mode 100644
index 0000000000..e5eaafb46d
--- /dev/null
+++ b/esphome/components/ld2420/number/gate_config_number.cpp
@@ -0,0 +1,73 @@
+#include "gate_config_number.h"
+#include "esphome/core/helpers.h"
+#include "esphome/core/log.h"
+
+static const char *const TAG = "LD2420.number";
+
+namespace esphome {
+namespace ld2420 {
+
+void LD2420TimeoutNumber::control(float timeout) {
+  this->publish_state(timeout);
+  this->parent_->new_config.timeout = timeout;
+}
+
+void LD2420MinDistanceNumber::control(float min_gate) {
+  if ((uint16_t) min_gate > this->parent_->new_config.max_gate) {
+    min_gate = this->parent_->get_min_gate_distance_value();
+  } else {
+    this->parent_->new_config.min_gate = (uint16_t) min_gate;
+  }
+  this->publish_state(min_gate);
+}
+
+void LD2420MaxDistanceNumber::control(float max_gate) {
+  if ((uint16_t) max_gate < this->parent_->new_config.min_gate) {
+    max_gate = this->parent_->get_max_gate_distance_value();
+  } else {
+    this->parent_->new_config.max_gate = (uint16_t) max_gate;
+  }
+  this->publish_state(max_gate);
+}
+
+void LD2420GateSelectNumber::control(float gate_select) {
+  const uint8_t gate = (uint8_t) gate_select;
+  this->publish_state(gate_select);
+  this->parent_->publish_gate_move_threshold(gate);
+  this->parent_->publish_gate_still_threshold(gate);
+}
+
+void LD2420MoveSensFactorNumber::control(float move_factor) {
+  this->publish_state(move_factor);
+  this->parent_->gate_move_sensitivity_factor = move_factor;
+}
+
+void LD2420StillSensFactorNumber::control(float still_factor) {
+  this->publish_state(still_factor);
+  this->parent_->gate_still_sensitivity_factor = still_factor;
+}
+
+LD2420MoveThresholdNumbers::LD2420MoveThresholdNumbers(uint8_t gate) : gate_(gate) {}
+
+void LD2420MoveThresholdNumbers::control(float move_threshold) {
+  this->publish_state(move_threshold);
+  if (!this->parent_->is_gate_select()) {
+    this->parent_->new_config.move_thresh[this->gate_] = move_threshold;
+  } else {
+    this->parent_->new_config.move_thresh[this->parent_->get_gate_select_value()] = move_threshold;
+  }
+}
+
+LD2420StillThresholdNumbers::LD2420StillThresholdNumbers(uint8_t gate) : gate_(gate) {}
+
+void LD2420StillThresholdNumbers::control(float still_threshold) {
+  this->publish_state(still_threshold);
+  if (!this->parent_->is_gate_select()) {
+    this->parent_->new_config.still_thresh[this->gate_] = still_threshold;
+  } else {
+    this->parent_->new_config.still_thresh[this->parent_->get_gate_select_value()] = still_threshold;
+  }
+}
+
+}  // namespace ld2420
+}  // namespace esphome
diff --git a/esphome/components/ld2420/number/gate_config_number.h b/esphome/components/ld2420/number/gate_config_number.h
new file mode 100644
index 0000000000..459a8026e3
--- /dev/null
+++ b/esphome/components/ld2420/number/gate_config_number.h
@@ -0,0 +1,78 @@
+#pragma once
+
+#include "esphome/components/number/number.h"
+#include "../ld2420.h"
+
+namespace esphome {
+namespace ld2420 {
+
+class LD2420TimeoutNumber : public number::Number, public Parented<LD2420Component> {
+ public:
+  LD2420TimeoutNumber() = default;
+
+ protected:
+  void control(float timeout) override;
+};
+
+class LD2420MinDistanceNumber : public number::Number, public Parented<LD2420Component> {
+ public:
+  LD2420MinDistanceNumber() = default;
+
+ protected:
+  void control(float min_gate) override;
+};
+
+class LD2420MaxDistanceNumber : public number::Number, public Parented<LD2420Component> {
+ public:
+  LD2420MaxDistanceNumber() = default;
+
+ protected:
+  void control(float max_gate) override;
+};
+
+class LD2420GateSelectNumber : public number::Number, public Parented<LD2420Component> {
+ public:
+  LD2420GateSelectNumber() = default;
+
+ protected:
+  void control(float gate_select) override;
+};
+
+class LD2420MoveSensFactorNumber : public number::Number, public Parented<LD2420Component> {
+ public:
+  LD2420MoveSensFactorNumber() = default;
+
+ protected:
+  void control(float move_factor) override;
+};
+
+class LD2420StillSensFactorNumber : public number::Number, public Parented<LD2420Component> {
+ public:
+  LD2420StillSensFactorNumber() = default;
+
+ protected:
+  void control(float still_factor) override;
+};
+
+class LD2420StillThresholdNumbers : public number::Number, public Parented<LD2420Component> {
+ public:
+  LD2420StillThresholdNumbers() = default;
+  LD2420StillThresholdNumbers(uint8_t gate);
+
+ protected:
+  uint8_t gate_;
+  void control(float still_threshold) override;
+};
+
+class LD2420MoveThresholdNumbers : public number::Number, public Parented<LD2420Component> {
+ public:
+  LD2420MoveThresholdNumbers() = default;
+  LD2420MoveThresholdNumbers(uint8_t gate);
+
+ protected:
+  uint8_t gate_;
+  void control(float move_threshold) override;
+};
+
+}  // namespace ld2420
+}  // namespace esphome
diff --git a/esphome/components/ld2420/select/__init__.py b/esphome/components/ld2420/select/__init__.py
new file mode 100644
index 0000000000..554bd4147d
--- /dev/null
+++ b/esphome/components/ld2420/select/__init__.py
@@ -0,0 +1,33 @@
+import esphome.codegen as cg
+from esphome.components import select
+import esphome.config_validation as cv
+from esphome.const import ENTITY_CATEGORY_CONFIG
+from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns
+
+CONF_OPERATING_MODE = "operating_mode"
+CONF_SELECTS = [
+    "Normal",
+    "Calibrate",
+    "Simple",
+]
+
+LD2420Select = ld2420_ns.class_("LD2420Select", cg.Component)
+
+CONFIG_SCHEMA = {
+    cv.GenerateID(CONF_LD2420_ID): cv.use_id(LD2420Component),
+    cv.Required(CONF_OPERATING_MODE): select.select_schema(
+        LD2420Select,
+        entity_category=ENTITY_CATEGORY_CONFIG,
+    ),
+}
+
+
+async def to_code(config):
+    LD2420_component = await cg.get_variable(config[CONF_LD2420_ID])
+    if operating_mode_config := config.get(CONF_OPERATING_MODE):
+        sel = await select.new_select(
+            operating_mode_config,
+            options=[CONF_SELECTS],
+        )
+        await cg.register_parented(sel, config[CONF_LD2420_ID])
+        cg.add(LD2420_component.set_operating_mode_select(sel))
diff --git a/esphome/components/ld2420/select/operating_mode_select.cpp b/esphome/components/ld2420/select/operating_mode_select.cpp
new file mode 100644
index 0000000000..1c59f443a5
--- /dev/null
+++ b/esphome/components/ld2420/select/operating_mode_select.cpp
@@ -0,0 +1,16 @@
+#include "operating_mode_select.h"
+#include "esphome/core/helpers.h"
+#include "esphome/core/log.h"
+
+namespace esphome {
+namespace ld2420 {
+
+static const char *const TAG = "LD2420.select";
+
+void LD2420Select::control(const std::string &value) {
+  this->publish_state(value);
+  this->parent_->set_operating_mode(value);
+}
+
+}  // namespace ld2420
+}  // namespace esphome
diff --git a/esphome/components/ld2420/select/operating_mode_select.h b/esphome/components/ld2420/select/operating_mode_select.h
new file mode 100644
index 0000000000..317b2af8c0
--- /dev/null
+++ b/esphome/components/ld2420/select/operating_mode_select.h
@@ -0,0 +1,18 @@
+#pragma once
+
+#include "../ld2420.h"
+#include "esphome/components/select/select.h"
+
+namespace esphome {
+namespace ld2420 {
+
+class LD2420Select : public Component, public select::Select, public Parented<LD2420Component> {
+ public:
+  LD2420Select() = default;
+
+ protected:
+  void control(const std::string &value) override;
+};
+
+}  // namespace ld2420
+}  // namespace esphome
diff --git a/esphome/components/ld2420/sensor/__init__.py b/esphome/components/ld2420/sensor/__init__.py
new file mode 100644
index 0000000000..6a67d1fc41
--- /dev/null
+++ b/esphome/components/ld2420/sensor/__init__.py
@@ -0,0 +1,35 @@
+import esphome.codegen as cg
+import esphome.config_validation as cv
+from esphome.components import sensor
+from esphome.const import CONF_ID, DEVICE_CLASS_DISTANCE, UNIT_CENTIMETER
+from .. import ld2420_ns, LD2420Component, CONF_LD2420_ID
+
+LD2420Sensor = ld2420_ns.class_("LD2420Sensor", sensor.Sensor, cg.Component)
+
+CONF_MOVING_DISTANCE = "moving_distance"
+CONF_GATE_ENERGY = "gate_energy"
+
+CONFIG_SCHEMA = cv.All(
+    cv.COMPONENT_SCHEMA.extend(
+        {
+            cv.GenerateID(): cv.declare_id(LD2420Sensor),
+            cv.GenerateID(CONF_LD2420_ID): cv.use_id(LD2420Component),
+            cv.Optional(CONF_MOVING_DISTANCE): sensor.sensor_schema(
+                device_class=DEVICE_CLASS_DISTANCE, unit_of_measurement=UNIT_CENTIMETER
+            ),
+        }
+    ),
+)
+
+
+async def to_code(config):
+    var = cg.new_Pvariable(config[CONF_ID])
+    await cg.register_component(var, config)
+    if CONF_MOVING_DISTANCE in config:
+        sens = await sensor.new_sensor(config[CONF_MOVING_DISTANCE])
+        cg.add(var.set_distance_sensor(sens))
+    if CONF_GATE_ENERGY in config:
+        sens = await sensor.new_sensor(config[CONF_GATE_ENERGY])
+        cg.add(var.set_energy_sensor(sens))
+    ld2420 = await cg.get_variable(config[CONF_LD2420_ID])
+    cg.add(ld2420.register_listener(var))
diff --git a/esphome/components/ld2420/sensor/ld2420_sensor.cpp b/esphome/components/ld2420/sensor/ld2420_sensor.cpp
new file mode 100644
index 0000000000..97f0c594b7
--- /dev/null
+++ b/esphome/components/ld2420/sensor/ld2420_sensor.cpp
@@ -0,0 +1,16 @@
+#include "ld2420_sensor.h"
+#include "esphome/core/helpers.h"
+#include "esphome/core/log.h"
+
+namespace esphome {
+namespace ld2420 {
+
+static const char *const TAG = "LD2420.sensor";
+
+void LD2420Sensor::dump_config() {
+  ESP_LOGCONFIG(TAG, "LD2420 Sensor:");
+  LOG_SENSOR("  ", "Distance", this->distance_sensor_);
+}
+
+}  // namespace ld2420
+}  // namespace esphome
diff --git a/esphome/components/ld2420/sensor/ld2420_sensor.h b/esphome/components/ld2420/sensor/ld2420_sensor.h
new file mode 100644
index 0000000000..4eebefe0e3
--- /dev/null
+++ b/esphome/components/ld2420/sensor/ld2420_sensor.h
@@ -0,0 +1,34 @@
+#pragma once
+
+#include "../ld2420.h"
+#include "esphome/components/sensor/sensor.h"
+
+namespace esphome {
+namespace ld2420 {
+
+class LD2420Sensor : public LD2420Listener, public Component, sensor::Sensor {
+ public:
+  void dump_config() override;
+  void set_distance_sensor(sensor::Sensor *sensor) { this->distance_sensor_ = sensor; }
+  void on_distance(uint16_t distance) override {
+    if (this->distance_sensor_ != nullptr) {
+      if (this->distance_sensor_->get_state() != distance) {
+        this->distance_sensor_->publish_state(distance);
+      }
+    }
+  }
+  void on_energy(uint16_t *gate_energy, size_t size) override {
+    for (size_t active = 0; active < size; active++) {
+      if (this->energy_sensors_[active] != nullptr) {
+        this->energy_sensors_[active]->publish_state(gate_energy[active]);
+      }
+    }
+  }
+
+ protected:
+  sensor::Sensor *distance_sensor_{nullptr};
+  std::vector<sensor::Sensor *> energy_sensors_ = std::vector<sensor::Sensor *>(LD2420_TOTAL_GATES);
+};
+
+}  // namespace ld2420
+}  // namespace esphome
diff --git a/esphome/components/ld2420/text_sensor/__init__.py b/esphome/components/ld2420/text_sensor/__init__.py
new file mode 100644
index 0000000000..b6d8c7c0e4
--- /dev/null
+++ b/esphome/components/ld2420/text_sensor/__init__.py
@@ -0,0 +1,38 @@
+import esphome.codegen as cg
+from esphome.components import text_sensor
+import esphome.config_validation as cv
+from esphome.const import (
+    CONF_ID,
+    ENTITY_CATEGORY_DIAGNOSTIC,
+    ICON_CHIP,
+)
+
+from .. import ld2420_ns, LD2420Component, CONF_LD2420_ID
+
+LD2420TextSensor = ld2420_ns.class_(
+    "LD2420TextSensor", text_sensor.TextSensor, cg.Component
+)
+
+CONF_FW_VERSION = "fw_version"
+
+CONFIG_SCHEMA = cv.All(
+    cv.COMPONENT_SCHEMA.extend(
+        {
+            cv.GenerateID(): cv.declare_id(LD2420TextSensor),
+            cv.GenerateID(CONF_LD2420_ID): cv.use_id(LD2420Component),
+            cv.Optional(CONF_FW_VERSION): text_sensor.text_sensor_schema(
+                entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP
+            ),
+        }
+    ),
+)
+
+
+async def to_code(config):
+    var = cg.new_Pvariable(config[CONF_ID])
+    await cg.register_component(var, config)
+    if CONF_FW_VERSION in config:
+        sens = await text_sensor.new_text_sensor(config[CONF_FW_VERSION])
+        cg.add(var.set_fw_version_text_sensor(sens))
+    ld2420 = await cg.get_variable(config[CONF_LD2420_ID])
+    cg.add(ld2420.register_listener(var))
diff --git a/esphome/components/ld2420/text_sensor/text_sensor.cpp b/esphome/components/ld2420/text_sensor/text_sensor.cpp
new file mode 100644
index 0000000000..1dcdcf7d60
--- /dev/null
+++ b/esphome/components/ld2420/text_sensor/text_sensor.cpp
@@ -0,0 +1,16 @@
+#include "text_sensor.h"
+#include "esphome/core/helpers.h"
+#include "esphome/core/log.h"
+
+namespace esphome {
+namespace ld2420 {
+
+static const char *const TAG = "LD2420.text_sensor";
+
+void LD2420TextSensor::dump_config() {
+  ESP_LOGCONFIG(TAG, "LD2420 TextSensor:");
+  LOG_TEXT_SENSOR("  ", "Firmware", this->fw_version_text_sensor_);
+}
+
+}  // namespace ld2420
+}  // namespace esphome
diff --git a/esphome/components/ld2420/text_sensor/text_sensor.h b/esphome/components/ld2420/text_sensor/text_sensor.h
new file mode 100644
index 0000000000..073ddd5d0f
--- /dev/null
+++ b/esphome/components/ld2420/text_sensor/text_sensor.h
@@ -0,0 +1,24 @@
+#pragma once
+
+#include "../ld2420.h"
+#include "esphome/components/text_sensor/text_sensor.h"
+
+namespace esphome {
+namespace ld2420 {
+
+class LD2420TextSensor : public LD2420Listener, public Component, text_sensor::TextSensor {
+ public:
+  void dump_config() override;
+  void set_fw_version_text_sensor(text_sensor::TextSensor *tsensor) { this->fw_version_text_sensor_ = tsensor; };
+  void on_fw_version(std::string &fw) override {
+    if (this->fw_version_text_sensor_ != nullptr) {
+      this->fw_version_text_sensor_->publish_state(fw);
+    }
+  }
+
+ protected:
+  text_sensor::TextSensor *fw_version_text_sensor_{nullptr};
+};
+
+}  // namespace ld2420
+}  // namespace esphome
diff --git a/esphome/components/mcp4728/__init__.py b/esphome/components/mcp4728/__init__.py
index d130ceb738..a0702c415c 100644
--- a/esphome/components/mcp4728/__init__.py
+++ b/esphome/components/mcp4728/__init__.py
@@ -10,6 +10,7 @@ CONF_STORE_IN_EEPROM = "store_in_eeprom"
 
 mcp4728_ns = cg.esphome_ns.namespace("mcp4728")
 MCP4728Component = mcp4728_ns.class_("MCP4728Component", cg.Component, i2c.I2CDevice)
+CONF_MCP4728_ID = "mcp4728_id"
 
 CONFIG_SCHEMA = (
     cv.Schema(
diff --git a/esphome/components/mcp4728/mcp4728_output.cpp b/esphome/components/mcp4728/mcp4728.cpp
similarity index 90%
rename from esphome/components/mcp4728/mcp4728_output.cpp
rename to esphome/components/mcp4728/mcp4728.cpp
index d011967624..1a8568a21c 100644
--- a/esphome/components/mcp4728/mcp4728_output.cpp
+++ b/esphome/components/mcp4728/mcp4728.cpp
@@ -1,4 +1,4 @@
-#include "mcp4728_output.h"
+#include "mcp4728.h"
 
 #include "esphome/core/helpers.h"
 #include "esphome/core/log.h"
@@ -110,12 +110,5 @@ void MCP4728Component::select_gain_(MCP4728ChannelIdx channel, MCP4728Gain gain)
   this->update_ = true;
 }
 
-void MCP4728Channel::write_state(float state) {
-  const uint16_t max_duty = 4095;
-  const float duty_rounded = roundf(state * max_duty);
-  auto duty = static_cast<uint16_t>(duty_rounded);
-  this->parent_->set_channel_value_(this->channel_, duty);
-}
-
 }  // namespace mcp4728
 }  // namespace esphome
diff --git a/esphome/components/mcp4728/mcp4728_output.h b/esphome/components/mcp4728/mcp4728.h
similarity index 69%
rename from esphome/components/mcp4728/mcp4728_output.h
rename to esphome/components/mcp4728/mcp4728.h
index 55bcfdccb6..f2262f4a35 100644
--- a/esphome/components/mcp4728/mcp4728_output.h
+++ b/esphome/components/mcp4728/mcp4728.h
@@ -1,7 +1,6 @@
 #pragma once
 
 #include "esphome/core/component.h"
-#include "esphome/components/output/float_output.h"
 #include "esphome/components/i2c/i2c.h"
 
 namespace esphome {
@@ -64,28 +63,5 @@ class MCP4728Component : public Component, public i2c::I2CDevice {
   bool update_ = false;
 };
 
-class MCP4728Channel : public output::FloatOutput {
- public:
-  MCP4728Channel(MCP4728Component *parent, MCP4728ChannelIdx channel, MCP4728Vref vref, MCP4728Gain gain,
-                 MCP4728PwrDown pwrdown)
-      : parent_(parent), channel_(channel), vref_(vref), gain_(gain), pwrdown_(pwrdown) {
-    // update VREF
-    parent->select_vref_(channel, vref_);
-    // update PD
-    parent->select_power_down_(channel, pwrdown_);
-    // update GAIN
-    parent->select_gain_(channel, gain_);
-  }
-
- protected:
-  void write_state(float state) override;
-
-  MCP4728Component *parent_;
-  MCP4728ChannelIdx channel_;
-  MCP4728Vref vref_;
-  MCP4728Gain gain_;
-  MCP4728PwrDown pwrdown_;
-};
-
 }  // namespace mcp4728
 }  // namespace esphome
diff --git a/esphome/components/mcp4728/output.py b/esphome/components/mcp4728/output/__init__.py
similarity index 96%
rename from esphome/components/mcp4728/output.py
rename to esphome/components/mcp4728/output/__init__.py
index e0913ab98a..20b196ca2c 100644
--- a/esphome/components/mcp4728/output.py
+++ b/esphome/components/mcp4728/output/__init__.py
@@ -2,12 +2,11 @@ import esphome.codegen as cg
 import esphome.config_validation as cv
 from esphome.components import output
 from esphome.const import CONF_CHANNEL, CONF_ID, CONF_GAIN
-from . import MCP4728Component, mcp4728_ns
+from .. import MCP4728Component, CONF_MCP4728_ID, mcp4728_ns
 
 DEPENDENCIES = ["mcp4728"]
 
 MCP4728Channel = mcp4728_ns.class_("MCP4728Channel", output.FloatOutput)
-CONF_MCP4728_ID = "mcp4728_id"
 CONF_VREF = "vref"
 CONF_POWER_DOWN = "power_down"
 
diff --git a/esphome/components/mcp4728/output/mcp4728_output.cpp b/esphome/components/mcp4728/output/mcp4728_output.cpp
new file mode 100644
index 0000000000..b587e8801b
--- /dev/null
+++ b/esphome/components/mcp4728/output/mcp4728_output.cpp
@@ -0,0 +1,17 @@
+#include "mcp4728_output.h"
+
+#include "esphome/core/helpers.h"
+#include "esphome/core/log.h"
+
+namespace esphome {
+namespace mcp4728 {
+
+void MCP4728Channel::write_state(float state) {
+  const uint16_t max_duty = 4095;
+  const float duty_rounded = roundf(state * max_duty);
+  auto duty = static_cast<uint16_t>(duty_rounded);
+  this->parent_->set_channel_value_(this->channel_, duty);
+}
+
+}  // namespace mcp4728
+}  // namespace esphome
diff --git a/esphome/components/mcp4728/output/mcp4728_output.h b/esphome/components/mcp4728/output/mcp4728_output.h
new file mode 100644
index 0000000000..453d632f4c
--- /dev/null
+++ b/esphome/components/mcp4728/output/mcp4728_output.h
@@ -0,0 +1,32 @@
+#pragma once
+
+#include "../mcp4728.h"
+#include "esphome/core/component.h"
+#include "esphome/components/output/float_output.h"
+#include "esphome/components/i2c/i2c.h"
+
+namespace esphome {
+namespace mcp4728 {
+
+class MCP4728Channel : public output::FloatOutput {
+ public:
+  MCP4728Channel(MCP4728Component *parent, MCP4728ChannelIdx channel, MCP4728Vref vref, MCP4728Gain gain,
+                 MCP4728PwrDown pwrdown)
+      : parent_(parent), channel_(channel) {
+    // update VREF
+    parent->select_vref_(channel, vref);
+    // update PD
+    parent->select_power_down_(channel, pwrdown);
+    // update GAIN
+    parent->select_gain_(channel, gain);
+  }
+
+ protected:
+  void write_state(float state) override;
+
+  MCP4728Component *parent_;
+  MCP4728ChannelIdx channel_;
+};
+
+}  // namespace mcp4728
+}  // namespace esphome
diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py
index fbe1e1a719..9efefd58cc 100644
--- a/esphome/components/mdns/__init__.py
+++ b/esphome/components/mdns/__init__.py
@@ -88,7 +88,7 @@ async def to_code(config):
         add_idf_component(
             name="mdns",
             repo="https://github.com/espressif/esp-protocols.git",
-            ref="mdns-v1.2.0",
+            ref="mdns-v1.2.2",
             path="components/mdns",
         )
 
diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py
new file mode 100644
index 0000000000..bd253f8ebd
--- /dev/null
+++ b/esphome/components/micronova/__init__.py
@@ -0,0 +1,69 @@
+import esphome.codegen as cg
+import esphome.config_validation as cv
+from esphome import pins
+from esphome.components import uart
+from esphome.const import (
+    CONF_ID,
+)
+
+CODEOWNERS = ["@jorre05"]
+
+DEPENDENCIES = ["uart"]
+
+CONF_MICRONOVA_ID = "micronova_id"
+CONF_ENABLE_RX_PIN = "enable_rx_pin"
+CONF_MEMORY_LOCATION = "memory_location"
+CONF_MEMORY_ADDRESS = "memory_address"
+
+micronova_ns = cg.esphome_ns.namespace("micronova")
+
+MicroNovaFunctions = micronova_ns.enum("MicroNovaFunctions", is_class=True)
+MICRONOVA_FUNCTIONS_ENUM = {
+    "STOVE_FUNCTION_SWITCH": MicroNovaFunctions.STOVE_FUNCTION_SWITCH,
+    "STOVE_FUNCTION_ROOM_TEMPERATURE": MicroNovaFunctions.STOVE_FUNCTION_ROOM_TEMPERATURE,
+    "STOVE_FUNCTION_THERMOSTAT_TEMPERATURE": MicroNovaFunctions.STOVE_FUNCTION_THERMOSTAT_TEMPERATURE,
+    "STOVE_FUNCTION_FUMES_TEMPERATURE": MicroNovaFunctions.STOVE_FUNCTION_FUMES_TEMPERATURE,
+    "STOVE_FUNCTION_STOVE_POWER": MicroNovaFunctions.STOVE_FUNCTION_STOVE_POWER,
+    "STOVE_FUNCTION_FAN_SPEED": MicroNovaFunctions.STOVE_FUNCTION_FAN_SPEED,
+    "STOVE_FUNCTION_STOVE_STATE": MicroNovaFunctions.STOVE_FUNCTION_STOVE_STATE,
+    "STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR": MicroNovaFunctions.STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR,
+    "STOVE_FUNCTION_WATER_TEMPERATURE": MicroNovaFunctions.STOVE_FUNCTION_WATER_TEMPERATURE,
+    "STOVE_FUNCTION_WATER_PRESSURE": MicroNovaFunctions.STOVE_FUNCTION_WATER_PRESSURE,
+    "STOVE_FUNCTION_POWER_LEVEL": MicroNovaFunctions.STOVE_FUNCTION_POWER_LEVEL,
+    "STOVE_FUNCTION_CUSTOM": MicroNovaFunctions.STOVE_FUNCTION_CUSTOM,
+}
+
+MicroNova = micronova_ns.class_("MicroNova", cg.PollingComponent, uart.UARTDevice)
+
+CONFIG_SCHEMA = (
+    cv.Schema(
+        {
+            cv.GenerateID(): cv.declare_id(MicroNova),
+            cv.Required(CONF_ENABLE_RX_PIN): pins.gpio_output_pin_schema,
+        }
+    )
+    .extend(uart.UART_DEVICE_SCHEMA)
+    .extend(cv.polling_component_schema("60s"))
+)
+
+
+def MICRONOVA_LISTENER_SCHEMA(default_memory_location, default_memory_address):
+    return cv.Schema(
+        {
+            cv.GenerateID(CONF_MICRONOVA_ID): cv.use_id(MicroNova),
+            cv.Optional(
+                CONF_MEMORY_LOCATION, default=default_memory_location
+            ): cv.hex_int_range(),
+            cv.Optional(
+                CONF_MEMORY_ADDRESS, default=default_memory_address
+            ): cv.hex_int_range(),
+        }
+    )
+
+
+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)
+    enable_rx_pin = await cg.gpio_pin_expression(config[CONF_ENABLE_RX_PIN])
+    cg.add(var.set_enable_rx_pin(enable_rx_pin))
diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py
new file mode 100644
index 0000000000..442f69c08b
--- /dev/null
+++ b/esphome/components/micronova/button/__init__.py
@@ -0,0 +1,44 @@
+import esphome.codegen as cg
+import esphome.config_validation as cv
+from esphome.components import button
+
+from .. import (
+    MicroNova,
+    MicroNovaFunctions,
+    CONF_MICRONOVA_ID,
+    CONF_MEMORY_LOCATION,
+    CONF_MEMORY_ADDRESS,
+    MICRONOVA_LISTENER_SCHEMA,
+    micronova_ns,
+)
+
+MicroNovaButton = micronova_ns.class_("MicroNovaButton", button.Button, cg.Component)
+
+CONF_CUSTOM_BUTTON = "custom_button"
+CONF_MEMORY_DATA = "memory_data"
+
+CONFIG_SCHEMA = cv.Schema(
+    {
+        cv.GenerateID(CONF_MICRONOVA_ID): cv.use_id(MicroNova),
+        cv.Optional(CONF_CUSTOM_BUTTON): button.button_schema(
+            MicroNovaButton,
+        )
+        .extend(
+            MICRONOVA_LISTENER_SCHEMA(
+                default_memory_location=0xA0, default_memory_address=0x7D
+            )
+        )
+        .extend({cv.Required(CONF_MEMORY_DATA): cv.hex_int_range()}),
+    }
+)
+
+
+async def to_code(config):
+    mv = await cg.get_variable(config[CONF_MICRONOVA_ID])
+
+    if custom_button_config := config.get(CONF_CUSTOM_BUTTON):
+        bt = await button.new_button(custom_button_config, mv)
+        cg.add(bt.set_memory_location(custom_button_config.get(CONF_MEMORY_LOCATION)))
+        cg.add(bt.set_memory_address(custom_button_config.get(CONF_MEMORY_ADDRESS)))
+        cg.add(bt.set_memory_data(custom_button_config[CONF_MEMORY_DATA]))
+        cg.add(bt.set_function(MicroNovaFunctions.STOVE_FUNCTION_CUSTOM))
diff --git a/esphome/components/micronova/button/micronova_button.cpp b/esphome/components/micronova/button/micronova_button.cpp
new file mode 100644
index 0000000000..c1903fd878
--- /dev/null
+++ b/esphome/components/micronova/button/micronova_button.cpp
@@ -0,0 +1,18 @@
+#include "micronova_button.h"
+
+namespace esphome {
+namespace micronova {
+
+void MicroNovaButton::press_action() {
+  switch (this->get_function()) {
+    case MicroNovaFunctions::STOVE_FUNCTION_CUSTOM:
+      this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_);
+      break;
+    default:
+      break;
+  }
+  this->micronova_->update();
+}
+
+}  // namespace micronova
+}  // namespace esphome
diff --git a/esphome/components/micronova/button/micronova_button.h b/esphome/components/micronova/button/micronova_button.h
new file mode 100644
index 0000000000..77649051d6
--- /dev/null
+++ b/esphome/components/micronova/button/micronova_button.h
@@ -0,0 +1,23 @@
+#pragma once
+
+#include "esphome/components/micronova/micronova.h"
+#include "esphome/core/component.h"
+#include "esphome/components/button/button.h"
+
+namespace esphome {
+namespace micronova {
+
+class MicroNovaButton : public Component, public button::Button, public MicroNovaButtonListener {
+ public:
+  MicroNovaButton(MicroNova *m) : MicroNovaButtonListener(m) {}
+  void dump_config() override { LOG_BUTTON("", "Micronova button", this); }
+
+  void set_memory_data(uint8_t f) { this->memory_data_ = f; }
+  uint8_t get_memory_data() { return this->memory_data_; }
+
+ protected:
+  void press_action() override;
+};
+
+}  // namespace micronova
+}  // namespace esphome
diff --git a/esphome/components/micronova/micronova.cpp b/esphome/components/micronova/micronova.cpp
new file mode 100644
index 0000000000..b96798ed12
--- /dev/null
+++ b/esphome/components/micronova/micronova.cpp
@@ -0,0 +1,148 @@
+#include "micronova.h"
+#include "esphome/core/log.h"
+
+namespace esphome {
+namespace micronova {
+
+void MicroNova::setup() {
+  if (this->enable_rx_pin_ != nullptr) {
+    this->enable_rx_pin_->setup();
+    this->enable_rx_pin_->pin_mode(gpio::FLAG_OUTPUT);
+    this->enable_rx_pin_->digital_write(false);
+  }
+  this->current_transmission_.request_transmission_time = millis();
+  this->current_transmission_.memory_location = 0;
+  this->current_transmission_.memory_address = 0;
+  this->current_transmission_.reply_pending = false;
+  this->current_transmission_.initiating_listener = nullptr;
+}
+
+void MicroNova::dump_config() {
+  ESP_LOGCONFIG(TAG, "MicroNova:");
+  if (this->enable_rx_pin_ != nullptr) {
+    LOG_PIN("  Enable RX Pin: ", this->enable_rx_pin_);
+  }
+
+  for (auto &mv_sensor : this->micronova_listeners_) {
+    mv_sensor->dump_config();
+    ESP_LOGCONFIG(TAG, "    sensor location:%02X, address:%02X", mv_sensor->get_memory_location(),
+                  mv_sensor->get_memory_address());
+  }
+}
+
+void MicroNova::update() {
+  ESP_LOGD(TAG, "Schedule sensor update");
+  for (auto &mv_listener : this->micronova_listeners_) {
+    mv_listener->set_needs_update(true);
+  }
+}
+
+void MicroNova::loop() {
+  // Only read one sensor that needs update per loop
+  // If STOVE_REPLY_DELAY time has passed since last loop()
+  // check for a reply from the stove
+  if ((this->current_transmission_.reply_pending) &&
+      (millis() - this->current_transmission_.request_transmission_time > STOVE_REPLY_DELAY)) {
+    int stove_reply_value = this->read_stove_reply();
+    if (this->current_transmission_.initiating_listener != nullptr) {
+      this->current_transmission_.initiating_listener->process_value_from_stove(stove_reply_value);
+      this->current_transmission_.initiating_listener = nullptr;
+    }
+    this->current_transmission_.reply_pending = false;
+    return;
+  } else if (!this->current_transmission_.reply_pending) {
+    for (auto &mv_listener : this->micronova_listeners_) {
+      if (mv_listener->get_needs_update()) {
+        mv_listener->set_needs_update(false);
+        this->current_transmission_.initiating_listener = mv_listener;
+        mv_listener->request_value_from_stove();
+        return;
+      }
+    }
+  }
+}
+
+void MicroNova::request_address(uint8_t location, uint8_t address, MicroNovaSensorListener *listener) {
+  uint8_t write_data[2] = {0, 0};
+  uint8_t trash_rx;
+
+  if (this->reply_pending_mutex_.try_lock()) {
+    // clear rx buffer.
+    // Stove hickups may cause late replies in the rx
+    while (this->available()) {
+      this->read_byte(&trash_rx);
+      ESP_LOGW(TAG, "Reading excess byte 0x%02X", trash_rx);
+    }
+
+    write_data[0] = location;
+    write_data[1] = address;
+    ESP_LOGV(TAG, "Request from stove [%02X,%02X]", write_data[0], write_data[1]);
+
+    this->enable_rx_pin_->digital_write(true);
+    this->write_array(write_data, 2);
+    this->flush();
+    this->enable_rx_pin_->digital_write(false);
+
+    this->current_transmission_.request_transmission_time = millis();
+    this->current_transmission_.memory_location = location;
+    this->current_transmission_.memory_address = address;
+    this->current_transmission_.reply_pending = true;
+    this->current_transmission_.initiating_listener = listener;
+  } else {
+    ESP_LOGE(TAG, "Reply is pending, skipping read request");
+  }
+}
+
+int MicroNova::read_stove_reply() {
+  uint8_t reply_data[2] = {0, 0};
+  uint8_t checksum = 0;
+
+  // assert enable_rx_pin is false
+  this->read_array(reply_data, 2);
+
+  this->reply_pending_mutex_.unlock();
+  ESP_LOGV(TAG, "Reply from stove [%02X,%02X]", reply_data[0], reply_data[1]);
+
+  checksum = ((uint16_t) this->current_transmission_.memory_location +
+              (uint16_t) this->current_transmission_.memory_address + (uint16_t) reply_data[1]) &
+             0xFF;
+  if (reply_data[0] != checksum) {
+    ESP_LOGE(TAG, "Checksum missmatch! From [0x%02X:0x%02X] received [0x%02X,0x%02X]. Expected 0x%02X, got 0x%02X",
+             this->current_transmission_.memory_location, this->current_transmission_.memory_address, reply_data[0],
+             reply_data[1], checksum, reply_data[0]);
+    return -1;
+  }
+  return ((int) reply_data[1]);
+}
+
+void MicroNova::write_address(uint8_t location, uint8_t address, uint8_t data) {
+  uint8_t write_data[4] = {0, 0, 0, 0};
+  uint16_t checksum = 0;
+
+  if (this->reply_pending_mutex_.try_lock()) {
+    write_data[0] = location;
+    write_data[1] = address;
+    write_data[2] = data;
+
+    checksum = ((uint16_t) write_data[0] + (uint16_t) write_data[1] + (uint16_t) write_data[2]) & 0xFF;
+    write_data[3] = checksum;
+
+    ESP_LOGV(TAG, "Write 4 bytes [%02X,%02X,%02X,%02X]", write_data[0], write_data[1], write_data[2], write_data[3]);
+
+    this->enable_rx_pin_->digital_write(true);
+    this->write_array(write_data, 4);
+    this->flush();
+    this->enable_rx_pin_->digital_write(false);
+
+    this->current_transmission_.request_transmission_time = millis();
+    this->current_transmission_.memory_location = location;
+    this->current_transmission_.memory_address = address;
+    this->current_transmission_.reply_pending = true;
+    this->current_transmission_.initiating_listener = nullptr;
+  } else {
+    ESP_LOGE(TAG, "Reply is pending, skipping write");
+  }
+}
+
+}  // namespace micronova
+}  // namespace esphome
diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h
new file mode 100644
index 0000000000..aebef277e5
--- /dev/null
+++ b/esphome/components/micronova/micronova.h
@@ -0,0 +1,164 @@
+#pragma once
+
+#include "esphome/core/component.h"
+#include "esphome/components/uart/uart.h"
+#include "esphome/core/log.h"
+#include "esphome/core/defines.h"
+#include "esphome/core/helpers.h"
+
+#include <vector>
+
+namespace esphome {
+namespace micronova {
+
+static const char *const TAG = "micronova";
+static const int STOVE_REPLY_DELAY = 60;
+
+static const std::string STOVE_STATES[11] = {"Off",
+                                             "Start",
+                                             "Pellets loading",
+                                             "Ignition",
+                                             "Working",
+                                             "Brazier Cleaning",
+                                             "Final Cleaning",
+                                             "Standby",
+                                             "No pellets alarm",
+                                             "No ignition alarm",
+                                             "Undefined alarm"};
+
+enum class MicroNovaFunctions {
+  STOVE_FUNCTION_VOID = 0,
+  STOVE_FUNCTION_SWITCH = 1,
+  STOVE_FUNCTION_ROOM_TEMPERATURE = 2,
+  STOVE_FUNCTION_THERMOSTAT_TEMPERATURE = 3,
+  STOVE_FUNCTION_FUMES_TEMPERATURE = 4,
+  STOVE_FUNCTION_STOVE_POWER = 5,
+  STOVE_FUNCTION_FAN_SPEED = 6,
+  STOVE_FUNCTION_STOVE_STATE = 7,
+  STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR = 8,
+  STOVE_FUNCTION_WATER_TEMPERATURE = 9,
+  STOVE_FUNCTION_WATER_PRESSURE = 10,
+  STOVE_FUNCTION_POWER_LEVEL = 11,
+  STOVE_FUNCTION_CUSTOM = 12
+};
+
+class MicroNova;
+
+//////////////////////////////////////////////////////////////////////
+// Interface classes.
+class MicroNovaBaseListener {
+ public:
+  MicroNovaBaseListener() {}
+  MicroNovaBaseListener(MicroNova *m) { this->micronova_ = m; }
+  virtual void dump_config();
+
+  void set_micronova_object(MicroNova *m) { this->micronova_ = m; }
+
+  void set_function(MicroNovaFunctions f) { this->function_ = f; }
+  MicroNovaFunctions get_function() { return this->function_; }
+
+  void set_memory_location(uint8_t l) { this->memory_location_ = l; }
+  uint8_t get_memory_location() { return this->memory_location_; }
+
+  void set_memory_address(uint8_t a) { this->memory_address_ = a; }
+  uint8_t get_memory_address() { return this->memory_address_; }
+
+ protected:
+  MicroNova *micronova_{nullptr};
+  MicroNovaFunctions function_ = MicroNovaFunctions::STOVE_FUNCTION_VOID;
+  uint8_t memory_location_ = 0;
+  uint8_t memory_address_ = 0;
+};
+
+class MicroNovaSensorListener : public MicroNovaBaseListener {
+ public:
+  MicroNovaSensorListener() {}
+  MicroNovaSensorListener(MicroNova *m) : MicroNovaBaseListener(m) {}
+  virtual void request_value_from_stove() = 0;
+  virtual void process_value_from_stove(int value_from_stove) = 0;
+
+  void set_needs_update(bool u) { this->needs_update_ = u; }
+  bool get_needs_update() { return this->needs_update_; }
+
+ protected:
+  bool needs_update_ = false;
+};
+
+class MicroNovaNumberListener : public MicroNovaBaseListener {
+ public:
+  MicroNovaNumberListener(MicroNova *m) : MicroNovaBaseListener(m) {}
+  virtual void request_value_from_stove() = 0;
+  virtual void process_value_from_stove(int value_from_stove) = 0;
+
+  void set_needs_update(bool u) { this->needs_update_ = u; }
+  bool get_needs_update() { return this->needs_update_; }
+
+ protected:
+  bool needs_update_ = false;
+};
+
+class MicroNovaSwitchListener : public MicroNovaBaseListener {
+ public:
+  MicroNovaSwitchListener(MicroNova *m) : MicroNovaBaseListener(m) {}
+  virtual void set_stove_state(bool v) = 0;
+  virtual bool get_stove_state() = 0;
+
+ protected:
+  uint8_t memory_data_on_ = 0;
+  uint8_t memory_data_off_ = 0;
+};
+
+class MicroNovaButtonListener : public MicroNovaBaseListener {
+ public:
+  MicroNovaButtonListener(MicroNova *m) : MicroNovaBaseListener(m) {}
+
+ protected:
+  uint8_t memory_data_ = 0;
+};
+
+/////////////////////////////////////////////////////////////////////
+// Main component class
+class MicroNova : public PollingComponent, public uart::UARTDevice {
+ public:
+  MicroNova() {}
+
+  void setup() override;
+  void loop() override;
+  void update() override;
+  void dump_config() override;
+  void register_micronova_listener(MicroNovaSensorListener *l) { this->micronova_listeners_.push_back(l); }
+
+  void request_address(uint8_t location, uint8_t address, MicroNovaSensorListener *listener);
+  void write_address(uint8_t location, uint8_t address, uint8_t data);
+  int read_stove_reply();
+
+  void set_enable_rx_pin(GPIOPin *enable_rx_pin) { this->enable_rx_pin_ = enable_rx_pin; }
+
+  void set_current_stove_state(uint8_t s) { this->current_stove_state_ = s; }
+  uint8_t get_current_stove_state() { return this->current_stove_state_; }
+
+  void set_stove(MicroNovaSwitchListener *s) { this->stove_switch_ = s; }
+  MicroNovaSwitchListener *get_stove_switch() { return this->stove_switch_; }
+
+ protected:
+  uint8_t current_stove_state_ = 0;
+
+  GPIOPin *enable_rx_pin_{nullptr};
+
+  struct MicroNovaSerialTransmission {
+    uint32_t request_transmission_time;
+    uint8_t memory_location;
+    uint8_t memory_address;
+    bool reply_pending;
+    MicroNovaSensorListener *initiating_listener;
+  };
+
+  Mutex reply_pending_mutex_;
+  MicroNovaSerialTransmission current_transmission_;
+
+  std::vector<MicroNovaSensorListener *> micronova_listeners_{};
+  MicroNovaSwitchListener *stove_switch_{nullptr};
+};
+
+}  // namespace micronova
+}  // namespace esphome
diff --git a/esphome/components/micronova/number/__init__.py b/esphome/components/micronova/number/__init__.py
new file mode 100644
index 0000000000..7124bf50d0
--- /dev/null
+++ b/esphome/components/micronova/number/__init__.py
@@ -0,0 +1,110 @@
+import esphome.codegen as cg
+import esphome.config_validation as cv
+from esphome.components import number
+from esphome.const import (
+    DEVICE_CLASS_TEMPERATURE,
+    UNIT_CELSIUS,
+    CONF_STEP,
+)
+
+from .. import (
+    MicroNova,
+    MicroNovaFunctions,
+    CONF_MICRONOVA_ID,
+    CONF_MEMORY_LOCATION,
+    CONF_MEMORY_ADDRESS,
+    MICRONOVA_LISTENER_SCHEMA,
+    micronova_ns,
+)
+
+ICON_FLASH = "mdi:flash"
+
+CONF_THERMOSTAT_TEMPERATURE = "thermostat_temperature"
+CONF_POWER_LEVEL = "power_level"
+CONF_MEMORY_WRITE_LOCATION = "memory_write_location"
+
+MicroNovaNumber = micronova_ns.class_("MicroNovaNumber", number.Number, cg.Component)
+
+CONFIG_SCHEMA = cv.Schema(
+    {
+        cv.GenerateID(CONF_MICRONOVA_ID): cv.use_id(MicroNova),
+        cv.Optional(CONF_THERMOSTAT_TEMPERATURE): number.number_schema(
+            MicroNovaNumber,
+            unit_of_measurement=UNIT_CELSIUS,
+            device_class=DEVICE_CLASS_TEMPERATURE,
+        )
+        .extend(
+            MICRONOVA_LISTENER_SCHEMA(
+                default_memory_location=0x20, default_memory_address=0x7D
+            )
+        )
+        .extend(
+            {
+                cv.Optional(
+                    CONF_MEMORY_WRITE_LOCATION, default=0xA0
+                ): cv.hex_int_range(),
+                cv.Optional(CONF_STEP, default=1.0): cv.float_range(min=0.1, max=10.0),
+            }
+        ),
+        cv.Optional(CONF_POWER_LEVEL): number.number_schema(
+            MicroNovaNumber,
+            icon=ICON_FLASH,
+        )
+        .extend(
+            MICRONOVA_LISTENER_SCHEMA(
+                default_memory_location=0x20, default_memory_address=0x7F
+            )
+        )
+        .extend(
+            {cv.Optional(CONF_MEMORY_WRITE_LOCATION, default=0xA0): cv.hex_int_range()}
+        ),
+    }
+)
+
+
+async def to_code(config):
+    mv = await cg.get_variable(config[CONF_MICRONOVA_ID])
+
+    if thermostat_temperature_config := config.get(CONF_THERMOSTAT_TEMPERATURE):
+        numb = await number.new_number(
+            thermostat_temperature_config,
+            min_value=0,
+            max_value=40,
+            step=thermostat_temperature_config.get(CONF_STEP),
+        )
+        cg.add(numb.set_micronova_object(mv))
+        cg.add(mv.register_micronova_listener(numb))
+        cg.add(
+            numb.set_memory_location(
+                thermostat_temperature_config[CONF_MEMORY_LOCATION]
+            )
+        )
+        cg.add(
+            numb.set_memory_address(thermostat_temperature_config[CONF_MEMORY_ADDRESS])
+        )
+        cg.add(
+            numb.set_memory_write_location(
+                thermostat_temperature_config.get(CONF_MEMORY_WRITE_LOCATION)
+            )
+        )
+        cg.add(
+            numb.set_function(MicroNovaFunctions.STOVE_FUNCTION_THERMOSTAT_TEMPERATURE)
+        )
+
+    if power_level_config := config.get(CONF_POWER_LEVEL):
+        numb = await number.new_number(
+            power_level_config,
+            min_value=1,
+            max_value=5,
+            step=1,
+        )
+        cg.add(numb.set_micronova_object(mv))
+        cg.add(mv.register_micronova_listener(numb))
+        cg.add(numb.set_memory_location(power_level_config[CONF_MEMORY_LOCATION]))
+        cg.add(numb.set_memory_address(power_level_config[CONF_MEMORY_ADDRESS]))
+        cg.add(
+            numb.set_memory_write_location(
+                power_level_config.get(CONF_MEMORY_WRITE_LOCATION)
+            )
+        )
+        cg.add(numb.set_function(MicroNovaFunctions.STOVE_FUNCTION_POWER_LEVEL))
diff --git a/esphome/components/micronova/number/micronova_number.cpp b/esphome/components/micronova/number/micronova_number.cpp
new file mode 100644
index 0000000000..244eb7ee9f
--- /dev/null
+++ b/esphome/components/micronova/number/micronova_number.cpp
@@ -0,0 +1,45 @@
+#include "micronova_number.h"
+
+namespace esphome {
+namespace micronova {
+
+void MicroNovaNumber::process_value_from_stove(int value_from_stove) {
+  float new_sensor_value = 0;
+
+  if (value_from_stove == -1) {
+    this->publish_state(NAN);
+    return;
+  }
+
+  switch (this->get_function()) {
+    case MicroNovaFunctions::STOVE_FUNCTION_THERMOSTAT_TEMPERATURE:
+      new_sensor_value = ((float) value_from_stove) * this->traits.get_step();
+      break;
+    case MicroNovaFunctions::STOVE_FUNCTION_POWER_LEVEL:
+      new_sensor_value = (float) value_from_stove;
+      break;
+    default:
+      break;
+  }
+  this->publish_state(new_sensor_value);
+}
+
+void MicroNovaNumber::control(float value) {
+  uint8_t new_number = 0;
+
+  switch (this->get_function()) {
+    case MicroNovaFunctions::STOVE_FUNCTION_THERMOSTAT_TEMPERATURE:
+      new_number = (uint8_t) (value / this->traits.get_step());
+      break;
+    case MicroNovaFunctions::STOVE_FUNCTION_POWER_LEVEL:
+      new_number = (uint8_t) value;
+      break;
+    default:
+      break;
+  }
+  this->micronova_->write_address(this->memory_write_location_, this->memory_address_, new_number);
+  this->micronova_->update();
+}
+
+}  // namespace micronova
+}  // namespace esphome
diff --git a/esphome/components/micronova/number/micronova_number.h b/esphome/components/micronova/number/micronova_number.h
new file mode 100644
index 0000000000..49c6358255
--- /dev/null
+++ b/esphome/components/micronova/number/micronova_number.h
@@ -0,0 +1,28 @@
+#pragma once
+
+#include "esphome/components/micronova/micronova.h"
+#include "esphome/components/number/number.h"
+
+namespace esphome {
+namespace micronova {
+
+class MicroNovaNumber : public number::Number, public MicroNovaSensorListener {
+ public:
+  MicroNovaNumber() {}
+  MicroNovaNumber(MicroNova *m) : MicroNovaSensorListener(m) {}
+  void dump_config() override { LOG_NUMBER("", "Micronova number", this); }
+  void control(float value) override;
+  void request_value_from_stove() override {
+    this->micronova_->request_address(this->memory_location_, this->memory_address_, this);
+  }
+  void process_value_from_stove(int value_from_stove) override;
+
+  void set_memory_write_location(uint8_t l) { this->memory_write_location_ = l; }
+  uint8_t get_memory_write_location() { return this->memory_write_location_; }
+
+ protected:
+  uint8_t memory_write_location_ = 0;
+};
+
+}  // namespace micronova
+}  // namespace esphome
diff --git a/esphome/components/micronova/sensor/__init__.py b/esphome/components/micronova/sensor/__init__.py
new file mode 100644
index 0000000000..32e42f3888
--- /dev/null
+++ b/esphome/components/micronova/sensor/__init__.py
@@ -0,0 +1,172 @@
+import esphome.codegen as cg
+import esphome.config_validation as cv
+from esphome.components import sensor
+from esphome.const import (
+    DEVICE_CLASS_TEMPERATURE,
+    DEVICE_CLASS_PRESSURE,
+    STATE_CLASS_MEASUREMENT,
+    UNIT_CELSIUS,
+    UNIT_REVOLUTIONS_PER_MINUTE,
+)
+
+from .. import (
+    MicroNova,
+    MicroNovaFunctions,
+    CONF_MICRONOVA_ID,
+    CONF_MEMORY_LOCATION,
+    CONF_MEMORY_ADDRESS,
+    MICRONOVA_LISTENER_SCHEMA,
+    micronova_ns,
+)
+
+UNIT_BAR = "bar"
+
+MicroNovaSensor = micronova_ns.class_("MicroNovaSensor", sensor.Sensor, cg.Component)
+
+CONF_ROOM_TEMPERATURE = "room_temperature"
+CONF_FUMES_TEMPERATURE = "fumes_temperature"
+CONF_STOVE_POWER = "stove_power"
+CONF_FAN_SPEED = "fan_speed"
+CONF_WATER_TEMPERATURE = "water_temperature"
+CONF_WATER_PRESSURE = "water_pressure"
+CONF_MEMORY_ADDRESS_SENSOR = "memory_address_sensor"
+CONF_FAN_RPM_OFFSET = "fan_rpm_offset"
+
+CONFIG_SCHEMA = cv.Schema(
+    {
+        cv.GenerateID(CONF_MICRONOVA_ID): cv.use_id(MicroNova),
+        cv.Optional(CONF_ROOM_TEMPERATURE): sensor.sensor_schema(
+            MicroNovaSensor,
+            unit_of_measurement=UNIT_CELSIUS,
+            device_class=DEVICE_CLASS_TEMPERATURE,
+            state_class=STATE_CLASS_MEASUREMENT,
+            accuracy_decimals=1,
+        ).extend(
+            MICRONOVA_LISTENER_SCHEMA(
+                default_memory_location=0x00, default_memory_address=0x01
+            )
+        ),
+        cv.Optional(CONF_FUMES_TEMPERATURE): sensor.sensor_schema(
+            MicroNovaSensor,
+            unit_of_measurement=UNIT_CELSIUS,
+            device_class=DEVICE_CLASS_TEMPERATURE,
+            state_class=STATE_CLASS_MEASUREMENT,
+            accuracy_decimals=1,
+        ).extend(
+            MICRONOVA_LISTENER_SCHEMA(
+                default_memory_location=0x00, default_memory_address=0x5A
+            )
+        ),
+        cv.Optional(CONF_STOVE_POWER): sensor.sensor_schema(
+            MicroNovaSensor,
+            state_class=STATE_CLASS_MEASUREMENT,
+            accuracy_decimals=0,
+        ).extend(
+            MICRONOVA_LISTENER_SCHEMA(
+                default_memory_location=0x00, default_memory_address=0x34
+            )
+        ),
+        cv.Optional(CONF_FAN_SPEED): sensor.sensor_schema(
+            MicroNovaSensor,
+            state_class=STATE_CLASS_MEASUREMENT,
+            unit_of_measurement=UNIT_REVOLUTIONS_PER_MINUTE,
+        )
+        .extend(
+            MICRONOVA_LISTENER_SCHEMA(
+                default_memory_location=0x00, default_memory_address=0x37
+            )
+        )
+        .extend(
+            {cv.Optional(CONF_FAN_RPM_OFFSET, default=0): cv.int_range(min=0, max=255)}
+        ),
+        cv.Optional(CONF_WATER_TEMPERATURE): sensor.sensor_schema(
+            MicroNovaSensor,
+            unit_of_measurement=UNIT_CELSIUS,
+            device_class=DEVICE_CLASS_TEMPERATURE,
+            state_class=STATE_CLASS_MEASUREMENT,
+            accuracy_decimals=1,
+        ).extend(
+            MICRONOVA_LISTENER_SCHEMA(
+                default_memory_location=0x00, default_memory_address=0x3B
+            )
+        ),
+        cv.Optional(CONF_WATER_PRESSURE): sensor.sensor_schema(
+            MicroNovaSensor,
+            unit_of_measurement=UNIT_BAR,
+            device_class=DEVICE_CLASS_PRESSURE,
+            state_class=STATE_CLASS_MEASUREMENT,
+            accuracy_decimals=1,
+        ).extend(
+            MICRONOVA_LISTENER_SCHEMA(
+                default_memory_location=0x00, default_memory_address=0x3C
+            )
+        ),
+        cv.Optional(CONF_MEMORY_ADDRESS_SENSOR): sensor.sensor_schema(
+            MicroNovaSensor,
+        ).extend(
+            MICRONOVA_LISTENER_SCHEMA(
+                default_memory_location=0x00, default_memory_address=0x00
+            )
+        ),
+    }
+)
+
+
+async def to_code(config):
+    mv = await cg.get_variable(config[CONF_MICRONOVA_ID])
+
+    if room_temperature_config := config.get(CONF_ROOM_TEMPERATURE):
+        sens = await sensor.new_sensor(room_temperature_config, mv)
+        cg.add(mv.register_micronova_listener(sens))
+        cg.add(sens.set_memory_location(room_temperature_config[CONF_MEMORY_LOCATION]))
+        cg.add(sens.set_memory_address(room_temperature_config[CONF_MEMORY_ADDRESS]))
+        cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_ROOM_TEMPERATURE))
+
+    if fumes_temperature_config := config.get(CONF_FUMES_TEMPERATURE):
+        sens = await sensor.new_sensor(fumes_temperature_config, mv)
+        cg.add(mv.register_micronova_listener(sens))
+        cg.add(sens.set_memory_location(fumes_temperature_config[CONF_MEMORY_LOCATION]))
+        cg.add(sens.set_memory_address(fumes_temperature_config[CONF_MEMORY_ADDRESS]))
+        cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_FUMES_TEMPERATURE))
+
+    if stove_power_config := config.get(CONF_STOVE_POWER):
+        sens = await sensor.new_sensor(stove_power_config, mv)
+        cg.add(mv.register_micronova_listener(sens))
+        cg.add(sens.set_memory_location(stove_power_config[CONF_MEMORY_LOCATION]))
+        cg.add(sens.set_memory_address(stove_power_config[CONF_MEMORY_ADDRESS]))
+        cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_STOVE_POWER))
+
+    if fan_speed_config := config.get(CONF_FAN_SPEED):
+        sens = await sensor.new_sensor(fan_speed_config, mv)
+        cg.add(mv.register_micronova_listener(sens))
+        cg.add(sens.set_memory_location(fan_speed_config[CONF_MEMORY_LOCATION]))
+        cg.add(sens.set_memory_address(fan_speed_config[CONF_MEMORY_ADDRESS]))
+        cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_FAN_SPEED))
+        cg.add(sens.set_fan_speed_offset(fan_speed_config[CONF_FAN_RPM_OFFSET]))
+
+    if memory_address_sensor_config := config.get(CONF_MEMORY_ADDRESS_SENSOR):
+        sens = await sensor.new_sensor(memory_address_sensor_config, mv)
+        cg.add(mv.register_micronova_listener(sens))
+        cg.add(
+            sens.set_memory_location(memory_address_sensor_config[CONF_MEMORY_LOCATION])
+        )
+        cg.add(
+            sens.set_memory_address(memory_address_sensor_config[CONF_MEMORY_ADDRESS])
+        )
+        cg.add(
+            sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR)
+        )
+
+    if water_temperature_config := config.get(CONF_WATER_TEMPERATURE):
+        sens = await sensor.new_sensor(water_temperature_config, mv)
+        cg.add(mv.register_micronova_listener(sens))
+        cg.add(sens.set_memory_location(water_temperature_config[CONF_MEMORY_LOCATION]))
+        cg.add(sens.set_memory_address(water_temperature_config[CONF_MEMORY_ADDRESS]))
+        cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_WATER_TEMPERATURE))
+
+    if water_pressure_config := config.get(CONF_WATER_PRESSURE):
+        sens = await sensor.new_sensor(water_pressure_config, mv)
+        cg.add(mv.register_micronova_listener(sens))
+        cg.add(sens.set_memory_location(water_pressure_config[CONF_MEMORY_LOCATION]))
+        cg.add(sens.set_memory_address(water_pressure_config[CONF_MEMORY_ADDRESS]))
+        cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_WATER_PRESSURE))
diff --git a/esphome/components/micronova/sensor/micronova_sensor.cpp b/esphome/components/micronova/sensor/micronova_sensor.cpp
new file mode 100644
index 0000000000..3f0c0feaf8
--- /dev/null
+++ b/esphome/components/micronova/sensor/micronova_sensor.cpp
@@ -0,0 +1,35 @@
+#include "micronova_sensor.h"
+
+namespace esphome {
+namespace micronova {
+
+void MicroNovaSensor::process_value_from_stove(int value_from_stove) {
+  if (value_from_stove == -1) {
+    this->publish_state(NAN);
+    return;
+  }
+
+  float new_sensor_value = (float) value_from_stove;
+  switch (this->get_function()) {
+    case MicroNovaFunctions::STOVE_FUNCTION_ROOM_TEMPERATURE:
+      new_sensor_value = new_sensor_value / 2;
+      break;
+    case MicroNovaFunctions::STOVE_FUNCTION_THERMOSTAT_TEMPERATURE:
+      break;
+    case MicroNovaFunctions::STOVE_FUNCTION_FAN_SPEED:
+      new_sensor_value = new_sensor_value == 0 ? 0 : (new_sensor_value * 10) + this->fan_speed_offset_;
+      break;
+    case MicroNovaFunctions::STOVE_FUNCTION_WATER_TEMPERATURE:
+      new_sensor_value = new_sensor_value / 2;
+      break;
+    case MicroNovaFunctions::STOVE_FUNCTION_WATER_PRESSURE:
+      new_sensor_value = new_sensor_value / 10;
+      break;
+    default:
+      break;
+  }
+  this->publish_state(new_sensor_value);
+}
+
+}  // namespace micronova
+}  // namespace esphome
diff --git a/esphome/components/micronova/sensor/micronova_sensor.h b/esphome/components/micronova/sensor/micronova_sensor.h
new file mode 100644
index 0000000000..9d5ae96b87
--- /dev/null
+++ b/esphome/components/micronova/sensor/micronova_sensor.h
@@ -0,0 +1,27 @@
+#pragma once
+
+#include "esphome/components/micronova/micronova.h"
+#include "esphome/components/sensor/sensor.h"
+
+namespace esphome {
+namespace micronova {
+
+class MicroNovaSensor : public sensor::Sensor, public MicroNovaSensorListener {
+ public:
+  MicroNovaSensor(MicroNova *m) : MicroNovaSensorListener(m) {}
+  void dump_config() override { LOG_SENSOR("", "Micronova sensor", this); }
+
+  void request_value_from_stove() override {
+    this->micronova_->request_address(this->memory_location_, this->memory_address_, this);
+  }
+  void process_value_from_stove(int value_from_stove) override;
+
+  void set_fan_speed_offset(uint8_t f) { this->fan_speed_offset_ = f; }
+  uint8_t get_set_fan_speed_offset() { return this->fan_speed_offset_; }
+
+ protected:
+  int fan_speed_offset_ = 0;
+};
+
+}  // namespace micronova
+}  // namespace esphome
diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py
new file mode 100644
index 0000000000..9846d46cc6
--- /dev/null
+++ b/esphome/components/micronova/switch/__init__.py
@@ -0,0 +1,56 @@
+import esphome.codegen as cg
+import esphome.config_validation as cv
+from esphome.components import switch
+from esphome.const import (
+    ICON_POWER,
+)
+
+from .. import (
+    MicroNova,
+    MicroNovaFunctions,
+    CONF_MICRONOVA_ID,
+    CONF_MEMORY_LOCATION,
+    CONF_MEMORY_ADDRESS,
+    MICRONOVA_LISTENER_SCHEMA,
+    micronova_ns,
+)
+
+CONF_STOVE = "stove"
+CONF_MEMORY_DATA_ON = "memory_data_on"
+CONF_MEMORY_DATA_OFF = "memory_data_off"
+
+MicroNovaSwitch = micronova_ns.class_("MicroNovaSwitch", switch.Switch, cg.Component)
+
+CONFIG_SCHEMA = cv.Schema(
+    {
+        cv.GenerateID(CONF_MICRONOVA_ID): cv.use_id(MicroNova),
+        cv.Optional(CONF_STOVE): switch.switch_schema(
+            MicroNovaSwitch,
+            icon=ICON_POWER,
+        )
+        .extend(
+            MICRONOVA_LISTENER_SCHEMA(
+                default_memory_location=0x80, default_memory_address=0x21
+            )
+        )
+        .extend(
+            {
+                cv.Optional(CONF_MEMORY_DATA_OFF, default=0x06): cv.hex_int_range(),
+                cv.Optional(CONF_MEMORY_DATA_ON, default=0x01): cv.hex_int_range(),
+            }
+        ),
+    }
+)
+
+
+async def to_code(config):
+    mv = await cg.get_variable(config[CONF_MICRONOVA_ID])
+
+    if stove_config := config.get(CONF_STOVE):
+        sw = await switch.new_switch(stove_config, mv)
+        cg.add(mv.set_stove(sw))
+        cg.add(sw.set_memory_location(stove_config[CONF_MEMORY_LOCATION]))
+        cg.add(sw.set_memory_address(stove_config[CONF_MEMORY_ADDRESS]))
+        cg.add(sw.set_memory_data_on(stove_config[CONF_MEMORY_DATA_ON]))
+        cg.add(sw.set_memory_data_off(stove_config[CONF_MEMORY_DATA_OFF]))
+        cg.add(sw.set_function(MicroNovaFunctions.STOVE_FUNCTION_SWITCH))
diff --git a/esphome/components/micronova/switch/micronova_switch.cpp b/esphome/components/micronova/switch/micronova_switch.cpp
new file mode 100644
index 0000000000..dcc96102db
--- /dev/null
+++ b/esphome/components/micronova/switch/micronova_switch.cpp
@@ -0,0 +1,33 @@
+#include "micronova_switch.h"
+
+namespace esphome {
+namespace micronova {
+
+void MicroNovaSwitch::write_state(bool state) {
+  switch (this->get_function()) {
+    case MicroNovaFunctions::STOVE_FUNCTION_SWITCH:
+      if (state) {
+        // Only send power-on when current state is Off
+        if (this->micronova_->get_current_stove_state() == 0) {
+          this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_on_);
+          this->publish_state(true);
+        } else
+          ESP_LOGW(TAG, "Unable to turn stove on, invalid state: %d", micronova_->get_current_stove_state());
+      } else {
+        // don't send power-off when status is Off or Final cleaning
+        if (this->micronova_->get_current_stove_state() != 0 && micronova_->get_current_stove_state() != 6) {
+          this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_off_);
+          this->publish_state(false);
+        } else
+          ESP_LOGW(TAG, "Unable to turn stove off, invalid state: %d", micronova_->get_current_stove_state());
+      }
+      this->micronova_->update();
+      break;
+
+    default:
+      break;
+  }
+}
+
+}  // namespace micronova
+}  // namespace esphome
diff --git a/esphome/components/micronova/switch/micronova_switch.h b/esphome/components/micronova/switch/micronova_switch.h
new file mode 100644
index 0000000000..b0ca33b497
--- /dev/null
+++ b/esphome/components/micronova/switch/micronova_switch.h
@@ -0,0 +1,29 @@
+#pragma once
+
+#include "esphome/components/micronova/micronova.h"
+#include "esphome/core/component.h"
+#include "esphome/components/switch/switch.h"
+
+namespace esphome {
+namespace micronova {
+
+class MicroNovaSwitch : public Component, public switch_::Switch, public MicroNovaSwitchListener {
+ public:
+  MicroNovaSwitch(MicroNova *m) : MicroNovaSwitchListener(m) {}
+  void dump_config() override { LOG_SWITCH("", "Micronova switch", this); }
+
+  void set_stove_state(bool v) override { this->publish_state(v); }
+  bool get_stove_state() override { return this->state; }
+
+  void set_memory_data_on(uint8_t f) { this->memory_data_on_ = f; }
+  uint8_t get_memory_data_on() { return this->memory_data_on_; }
+
+  void set_memory_data_off(uint8_t f) { this->memory_data_off_ = f; }
+  uint8_t get_memory_data_off() { return this->memory_data_off_; }
+
+ protected:
+  void write_state(bool state) override;
+};
+
+}  // namespace micronova
+}  // namespace esphome
diff --git a/esphome/components/micronova/text_sensor/__init__.py b/esphome/components/micronova/text_sensor/__init__.py
new file mode 100644
index 0000000000..dc27c4f32c
--- /dev/null
+++ b/esphome/components/micronova/text_sensor/__init__.py
@@ -0,0 +1,43 @@
+import esphome.codegen as cg
+import esphome.config_validation as cv
+from esphome.components import text_sensor
+
+from .. import (
+    MicroNova,
+    MicroNovaFunctions,
+    CONF_MICRONOVA_ID,
+    CONF_MEMORY_LOCATION,
+    CONF_MEMORY_ADDRESS,
+    MICRONOVA_LISTENER_SCHEMA,
+    micronova_ns,
+)
+
+CONF_STOVE_STATE = "stove_state"
+
+MicroNovaTextSensor = micronova_ns.class_(
+    "MicroNovaTextSensor", text_sensor.TextSensor, cg.Component
+)
+
+CONFIG_SCHEMA = cv.Schema(
+    {
+        cv.GenerateID(CONF_MICRONOVA_ID): cv.use_id(MicroNova),
+        cv.Optional(CONF_STOVE_STATE): text_sensor.text_sensor_schema(
+            MicroNovaTextSensor
+        ).extend(
+            MICRONOVA_LISTENER_SCHEMA(
+                default_memory_location=0x00, default_memory_address=0x21
+            )
+        ),
+    }
+)
+
+
+async def to_code(config):
+    mv = await cg.get_variable(config[CONF_MICRONOVA_ID])
+
+    if stove_state_config := config.get(CONF_STOVE_STATE):
+        sens = await text_sensor.new_text_sensor(stove_state_config, mv)
+        cg.add(mv.register_micronova_listener(sens))
+        cg.add(sens.set_memory_location(stove_state_config[CONF_MEMORY_LOCATION]))
+        cg.add(sens.set_memory_address(stove_state_config[CONF_MEMORY_ADDRESS]))
+        cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_STOVE_STATE))
diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp
new file mode 100644
index 0000000000..03b192ffd1
--- /dev/null
+++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp
@@ -0,0 +1,31 @@
+#include "micronova_text_sensor.h"
+
+namespace esphome {
+namespace micronova {
+
+void MicroNovaTextSensor::process_value_from_stove(int value_from_stove) {
+  if (value_from_stove == -1) {
+    this->publish_state("unknown");
+    return;
+  }
+
+  switch (this->get_function()) {
+    case MicroNovaFunctions::STOVE_FUNCTION_STOVE_STATE:
+      this->micronova_->set_current_stove_state(value_from_stove);
+      this->publish_state(STOVE_STATES[value_from_stove]);
+      // set the stove switch to on for any value but 0
+      if (value_from_stove != 0 && this->micronova_->get_stove_switch() != nullptr &&
+          !this->micronova_->get_stove_switch()->get_stove_state()) {
+        this->micronova_->get_stove_switch()->set_stove_state(true);
+      } else if (value_from_stove == 0 && this->micronova_->get_stove_switch() != nullptr &&
+                 this->micronova_->get_stove_switch()->get_stove_state()) {
+        this->micronova_->get_stove_switch()->set_stove_state(false);
+      }
+      break;
+    default:
+      break;
+  }
+}
+
+}  // namespace micronova
+}  // namespace esphome
diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.h b/esphome/components/micronova/text_sensor/micronova_text_sensor.h
new file mode 100644
index 0000000000..b4e5de9bb3
--- /dev/null
+++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.h
@@ -0,0 +1,20 @@
+#pragma once
+
+#include "esphome/components/micronova/micronova.h"
+#include "esphome/components/text_sensor/text_sensor.h"
+
+namespace esphome {
+namespace micronova {
+
+class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaSensorListener {
+ public:
+  MicroNovaTextSensor(MicroNova *m) : MicroNovaSensorListener(m) {}
+  void dump_config() override { LOG_TEXT_SENSOR("", "Micronova text sensor", this); }
+  void request_value_from_stove() override {
+    this->micronova_->request_address(this->memory_location_, this->memory_address_, this);
+  }
+  void process_value_from_stove(int value_from_stove) override;
+};
+
+}  // namespace micronova
+}  // namespace esphome
diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp
index 02d77a6b33..e543ceb864 100644
--- a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp
+++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp
@@ -69,7 +69,7 @@ bool MopekaProCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device)
   if ((this->distance_ != nullptr) || (this->level_ != nullptr)) {
     uint32_t distance_value = this->parse_distance_(manu_data.data);
     SensorReadQuality quality_value = this->parse_read_quality_(manu_data.data);
-    ESP_LOGD(TAG, "Distance Sensor: Quality (0x%X) Distance (%dmm)", quality_value, distance_value);
+    ESP_LOGD(TAG, "Distance Sensor: Quality (0x%X) Distance (%" PRId32 "mm)", quality_value, distance_value);
     if (quality_value < QUALITY_HIGH) {
       ESP_LOGW(TAG, "Poor read quality.");
     }
diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.h b/esphome/components/mopeka_pro_check/mopeka_pro_check.h
index 8b126a204c..b5dff153e7 100644
--- a/esphome/components/mopeka_pro_check/mopeka_pro_check.h
+++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.h
@@ -1,11 +1,12 @@
 #pragma once
 
+#include <cinttypes>
+#include <vector>
+
 #include "esphome/core/component.h"
 #include "esphome/components/sensor/sensor.h"
 #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
 
-#include <vector>
-
 #ifdef USE_ESP32
 
 namespace esphome {
diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.cpp b/esphome/components/mopeka_std_check/mopeka_std_check.cpp
index 9dd1718cb2..6685a23c41 100644
--- a/esphome/components/mopeka_std_check/mopeka_std_check.cpp
+++ b/esphome/components/mopeka_std_check/mopeka_std_check.cpp
@@ -16,8 +16,8 @@ static const uint16_t MANUFACTURER_ID = 0x000D;
 void MopekaStdCheck::dump_config() {
   ESP_LOGCONFIG(TAG, "Mopeka Std Check");
   ESP_LOGCONFIG(TAG, "  Propane Butane mix: %.0f%%", this->propane_butane_mix_ * 100);
-  ESP_LOGCONFIG(TAG, "  Tank distance empty: %imm", this->empty_mm_);
-  ESP_LOGCONFIG(TAG, "  Tank distance full: %imm", this->full_mm_);
+  ESP_LOGCONFIG(TAG, "  Tank distance empty: %" PRIi32 "mm", this->empty_mm_);
+  ESP_LOGCONFIG(TAG, "  Tank distance full: %" PRIi32 "mm", this->full_mm_);
   LOG_SENSOR("  ", "Level", this->level_);
   LOG_SENSOR("  ", "Temperature", this->temperature_);
   LOG_SENSOR("  ", "Battery Level", this->battery_level_);
diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.h b/esphome/components/mopeka_std_check/mopeka_std_check.h
index ee588c8e5f..2a1d9d2dfc 100644
--- a/esphome/components/mopeka_std_check/mopeka_std_check.h
+++ b/esphome/components/mopeka_std_check/mopeka_std_check.h
@@ -1,5 +1,6 @@
 #pragma once
 
+#include <cinttypes>
 #include <vector>
 
 #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
diff --git a/esphome/components/mqtt/mqtt_backend_esp8266.h b/esphome/components/mqtt/mqtt_backend_esp8266.h
index 981d27693f..06d4993bdf 100644
--- a/esphome/components/mqtt/mqtt_backend_esp8266.h
+++ b/esphome/components/mqtt/mqtt_backend_esp8266.h
@@ -19,7 +19,7 @@ class MQTTBackendESP8266 final : public MQTTBackend {
   void set_will(const char *topic, uint8_t qos, bool retain, const char *payload) final {
     mqtt_client_.setWill(topic, qos, retain, payload);
   }
-  void set_server(network::IPAddress ip, uint16_t port) final { mqtt_client_.setServer(IPAddress(ip), port); }
+  void set_server(network::IPAddress ip, uint16_t port) final { mqtt_client_.setServer(ip, port); }
   void set_server(const char *host, uint16_t port) final { mqtt_client_.setServer(host, port); }
 #if ASYNC_TCP_SSL_ENABLED
   void set_secure(bool secure) { mqtt_client.setSecure(secure); }
diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp
index 571f5c8317..95dc082e84 100644
--- a/esphome/components/mqtt/mqtt_component.cpp
+++ b/esphome/components/mqtt/mqtt_component.cpp
@@ -136,6 +136,7 @@ bool MQTTComponent::send_discovery_() {
         if (node_friendly_name.empty()) {
           node_friendly_name = node_name;
         }
+        const std::string &node_area = App.get_area();
 
         JsonObject device_info = root.createNestedObject(MQTT_DEVICE);
         device_info[MQTT_DEVICE_IDENTIFIERS] = get_mac_address();
@@ -143,6 +144,7 @@ bool MQTTComponent::send_discovery_() {
         device_info[MQTT_DEVICE_SW_VERSION] = "esphome v" ESPHOME_VERSION " " + App.get_compilation_time();
         device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD;
         device_info[MQTT_DEVICE_MANUFACTURER] = "espressif";
+        device_info[MQTT_DEVICE_SUGGESTED_AREA] = node_area;
       },
       0, discovery_info.retain);
 }
diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h
index 7bf09078be..709524c9d1 100644
--- a/esphome/components/network/ip_address.h
+++ b/esphome/components/network/ip_address.h
@@ -3,7 +3,11 @@
 #include <string>
 #include <cstdio>
 #include <array>
+#include "esphome/core/macros.h"
+
+#if defined(USE_ESP_IDF) || defined(USE_LIBRETINY) || USE_ARDUINO_VERSION_CODE > VERSION_CODE(3, 0, 0)
 #include <lwip/ip_addr.h>
+#endif
 
 #if USE_ARDUINO
 #include <Arduino.h>
diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp
index 6133ad1d7e..78c07be890 100644
--- a/esphome/components/nextion/nextion.cpp
+++ b/esphome/components/nextion/nextion.cpp
@@ -116,6 +116,7 @@ void Nextion::reset_(bool reset_nextion) {
     this->read_byte(&d);
   };
   this->nextion_queue_.clear();
+  this->waveform_queue_.clear();
 }
 
 void Nextion::dump_config() {
@@ -364,37 +365,21 @@ void Nextion::process_nextion_commands_() {
         ESP_LOGW(TAG, "Nextion reported baud rate invalid!");
         break;
       case 0x12:  // invalid Waveform ID or Channel # was used
+        if (this->waveform_queue_.empty()) {
+          ESP_LOGW(TAG,
+                   "Nextion reported invalid Waveform ID or Channel # was used but no waveform sensor in queue found!");
+        } else {
+          auto &nb = this->waveform_queue_.front();
+          NextionComponentBase *component = nb->component;
 
-        if (!this->nextion_queue_.empty()) {
-          int index = 0;
-          int found = -1;
-          for (auto &nb : this->nextion_queue_) {
-            NextionComponentBase *component = nb->component;
+          ESP_LOGW(TAG, "Nextion reported invalid Waveform ID %d or Channel # %d was used!",
+                   component->get_component_id(), component->get_wave_channel_id());
 
-            if (component->get_queue_type() == NextionQueueType::WAVEFORM_SENSOR) {
-              ESP_LOGW(TAG, "Nextion reported invalid Waveform ID %d or Channel # %d was used!",
-                       component->get_component_id(), component->get_wave_channel_id());
+          ESP_LOGN(TAG, "Removing waveform from queue with component id %d and waveform id %d",
+                   component->get_component_id(), component->get_wave_channel_id());
 
-              ESP_LOGN(TAG, "Removing waveform from queue with component id %d and waveform id %d",
-                       component->get_component_id(), component->get_wave_channel_id());
-
-              found = index;
-
-              delete component;  // NOLINT(cppcoreguidelines-owning-memory)
-              delete nb;         // NOLINT(cppcoreguidelines-owning-memory)
-
-              break;
-            }
-            ++index;
-          }
-
-          if (found != -1) {
-            this->nextion_queue_.erase(this->nextion_queue_.begin() + found);
-          } else {
-            ESP_LOGW(
-                TAG,
-                "Nextion reported invalid Waveform ID or Channel # was used but no waveform sensor in queue found!");
-          }
+          delete nb;  // NOLINT(cppcoreguidelines-owning-memory)
+          this->waveform_queue_.pop_front();
         }
         break;
       case 0x1A:  // variable name invalid
@@ -697,44 +682,29 @@ void Nextion::process_nextion_commands_() {
       }
       case 0xFD: {  // data transparent transmit finished
         ESP_LOGVV(TAG, "Nextion reported data transmit finished!");
+        this->check_pending_waveform_();
         break;
       }
       case 0xFE: {  // data transparent transmit ready
         ESP_LOGVV(TAG, "Nextion reported ready for transmit!");
-
-        int index = 0;
-        int found = -1;
-        for (auto &nb : this->nextion_queue_) {
-          auto *component = nb->component;
-          if (component->get_queue_type() == NextionQueueType::WAVEFORM_SENSOR) {
-            size_t buffer_to_send = component->get_wave_buffer().size() < 255 ? component->get_wave_buffer().size()
-                                                                              : 255;  // ADDT command can only send 255
-
-            this->write_array(component->get_wave_buffer().data(), static_cast<int>(buffer_to_send));
-
-            ESP_LOGN(TAG, "Nextion sending waveform data for component id %d and waveform id %d, size %zu",
-                     component->get_component_id(), component->get_wave_channel_id(), buffer_to_send);
-
-            if (component->get_wave_buffer().size() <= 255) {
-              component->get_wave_buffer().clear();
-            } else {
-              component->get_wave_buffer().erase(component->get_wave_buffer().begin(),
-                                                 component->get_wave_buffer().begin() + buffer_to_send);
-            }
-            found = index;
-            delete component;  // NOLINT(cppcoreguidelines-owning-memory)
-            delete nb;         // NOLINT(cppcoreguidelines-owning-memory)
-            break;
-          }
-          ++index;
-        }
-
-        if (found == -1) {
+        if (this->waveform_queue_.empty()) {
           ESP_LOGE(TAG, "No waveforms in queue to send data!");
           break;
-        } else {
-          this->nextion_queue_.erase(this->nextion_queue_.begin() + found);
         }
+
+        auto &nb = this->waveform_queue_.front();
+        auto *component = nb->component;
+        size_t buffer_to_send = component->get_wave_buffer_size() < 255 ? component->get_wave_buffer_size()
+                                                                        : 255;  // ADDT command can only send 255
+
+        this->write_array(component->get_wave_buffer().data(), static_cast<int>(buffer_to_send));
+
+        ESP_LOGN(TAG, "Nextion sending waveform data for component id %d and waveform id %d, size %zu",
+                 component->get_component_id(), component->get_wave_channel_id(), buffer_to_send);
+
+        component->clear_wave_buffer(buffer_to_send);
+        delete nb;  // NOLINT(cppcoreguidelines-owning-memory)
+        this->waveform_queue_.pop_front();
         break;
       }
       default:
@@ -1093,17 +1063,28 @@ void Nextion::add_addt_command_to_queue(NextionComponentBase *component) {
   // NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
   nextion::NextionQueue *nextion_queue = new nextion::NextionQueue;
 
-  // NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
-  nextion_queue->component = new nextion::NextionComponentBase;
+  nextion_queue->component = component;
   nextion_queue->queue_time = millis();
 
+  this->waveform_queue_.push_back(nextion_queue);
+  if (this->waveform_queue_.size() == 1)
+    this->check_pending_waveform_();
+}
+
+void Nextion::check_pending_waveform_() {
+  if (this->waveform_queue_.empty())
+    return;
+
+  auto *nb = this->waveform_queue_.front();
+  auto *component = nb->component;
   size_t buffer_to_send = component->get_wave_buffer_size() < 255 ? component->get_wave_buffer_size()
                                                                   : 255;  // ADDT command can only send 255
 
   std::string command = "addt " + to_string(component->get_component_id()) + "," +
                         to_string(component->get_wave_channel_id()) + "," + to_string(buffer_to_send);
-  if (this->send_command_(command)) {
-    this->nextion_queue_.push_back(nextion_queue);
+  if (!this->send_command_(command)) {
+    delete nb;  // NOLINT(cppcoreguidelines-owning-memory)
+    this->waveform_queue_.pop_front();
   }
 }
 
diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h
index 7518d7f4cb..92ff3fe235 100644
--- a/esphome/components/nextion/nextion.h
+++ b/esphome/components/nextion/nextion.h
@@ -740,6 +740,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe
 
  protected:
   std::deque<NextionQueue *> nextion_queue_;
+  std::deque<NextionQueue *> waveform_queue_;
   uint16_t recv_ret_string_(std::string &response, uint32_t timeout, bool recv_flag);
   void all_components_send_state_(bool force_update = false);
   uint64_t comok_sent_ = 0;
@@ -780,6 +781,8 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe
                                                  const std::string &variable_name_to_send,
                                                  const std::string &state_value, bool is_sleep_safe = false);
 
+  void check_pending_waveform_();
+
 #ifdef USE_NEXTION_TFT_UPLOAD
 #ifdef USE_ESP8266
   WiFiClient *wifi_client_{nullptr};
diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h
index e0ef8f93bc..42e1b00998 100644
--- a/esphome/components/nextion/nextion_component_base.h
+++ b/esphome/components/nextion/nextion_component_base.h
@@ -69,6 +69,13 @@ class NextionComponentBase {
 
   std::vector<uint8_t> get_wave_buffer() { return this->wave_buffer_; }
   size_t get_wave_buffer_size() { return this->wave_buffer_.size(); }
+  void clear_wave_buffer(size_t buffer_sent) {
+    if (this->wave_buffer_.size() <= buffer_sent) {
+      this->wave_buffer_.clear();
+    } else {
+      this->wave_buffer_.erase(this->wave_buffer_.begin(), this->wave_buffer_.begin() + buffer_sent);
+    }
+  }
 
   std::string get_variable_name() { return this->variable_name_; }
   std::string get_variable_name_to_send() { return this->variable_name_to_send_; }
diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py
index 56a59c54d1..2b064a90cf 100644
--- a/esphome/components/packages/__init__.py
+++ b/esphome/components/packages/__init__.py
@@ -135,7 +135,7 @@ def _process_base_package(config: dict) -> dict:
                 packages[file] = new_yaml
             except EsphomeError as e:
                 raise cv.Invalid(
-                    f"{file} is not a valid YAML file. Please check the file contents."
+                    f"{file} is not a valid YAML file. Please check the file contents.\n{e}"
                 ) from e
         return packages
 
diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py
index 574d8dce91..93be148169 100644
--- a/esphome/components/pca6416a/__init__.py
+++ b/esphome/components/pca6416a/__init__.py
@@ -64,7 +64,7 @@ PCA6416A_PIN_SCHEMA = cv.All(
 )
 
 
-@pins.PIN_SCHEMA_REGISTRY.register("pca6416a", PCA6416A_PIN_SCHEMA)
+@pins.PIN_SCHEMA_REGISTRY.register(CONF_PCA6416A, PCA6416A_PIN_SCHEMA)
 async def pca6416a_pin_to_code(config):
     var = cg.new_Pvariable(config[CONF_ID])
     parent = await cg.get_variable(config[CONF_PCA6416A])
diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py
index 76d6ddaf32..fd52fafc5d 100644
--- a/esphome/components/pca9554/__init__.py
+++ b/esphome/components/pca9554/__init__.py
@@ -11,9 +11,10 @@ from esphome.const import (
     CONF_OUTPUT,
 )
 
-CODEOWNERS = ["@hwstar"]
+CODEOWNERS = ["@hwstar", "@clydebarrow"]
 DEPENDENCIES = ["i2c"]
 MULTI_CONF = True
+CONF_PIN_COUNT = "pin_count"
 pca9554_ns = cg.esphome_ns.namespace("pca9554")
 
 PCA9554Component = pca9554_ns.class_("PCA9554Component", cg.Component, i2c.I2CDevice)
@@ -23,7 +24,12 @@ PCA9554GPIOPin = pca9554_ns.class_(
 
 CONF_PCA9554 = "pca9554"
 CONFIG_SCHEMA = (
-    cv.Schema({cv.Required(CONF_ID): cv.declare_id(PCA9554Component)})
+    cv.Schema(
+        {
+            cv.Required(CONF_ID): cv.declare_id(PCA9554Component),
+            cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16),
+        }
+    )
     .extend(cv.COMPONENT_SCHEMA)
     .extend(
         i2c.i2c_device_schema(0x20)
@@ -33,6 +39,7 @@ CONFIG_SCHEMA = (
 
 async def to_code(config):
     var = cg.new_Pvariable(config[CONF_ID])
+    cg.add(var.set_pin_count(config[CONF_PIN_COUNT]))
     await cg.register_component(var, config)
     await i2c.register_i2c_device(var, config)
 
@@ -49,7 +56,7 @@ PCA9554_PIN_SCHEMA = cv.All(
     {
         cv.GenerateID(): cv.declare_id(PCA9554GPIOPin),
         cv.Required(CONF_PCA9554): cv.use_id(PCA9554Component),
-        cv.Required(CONF_NUMBER): cv.int_range(min=0, max=8),
+        cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15),
         cv.Optional(CONF_MODE, default={}): cv.All(
             {
                 cv.Optional(CONF_INPUT, default=False): cv.boolean,
@@ -58,11 +65,19 @@ PCA9554_PIN_SCHEMA = cv.All(
             validate_mode,
         ),
         cv.Optional(CONF_INVERTED, default=False): cv.boolean,
-    }
+    },
 )
 
 
-@pins.PIN_SCHEMA_REGISTRY.register("pca9554", PCA9554_PIN_SCHEMA)
+def pca9554_pin_final_validate(pin_config, parent_config):
+    count = parent_config[CONF_PIN_COUNT]
+    if pin_config[CONF_NUMBER] >= count:
+        raise cv.Invalid(f"Pin number must be in range 0-{count - 1}")
+
+
+@pins.PIN_SCHEMA_REGISTRY.register(
+    CONF_PCA9554, PCA9554_PIN_SCHEMA, pca9554_pin_final_validate
+)
 async def pca9554_pin_to_code(config):
     var = cg.new_Pvariable(config[CONF_ID])
     parent = await cg.get_variable(config[CONF_PCA9554])
diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp
index 74c64dffaa..c5a4bcfb09 100644
--- a/esphome/components/pca9554/pca9554.cpp
+++ b/esphome/components/pca9554/pca9554.cpp
@@ -4,6 +4,7 @@
 namespace esphome {
 namespace pca9554 {
 
+// for 16 bit expanders, these addresses will be doubled.
 const uint8_t INPUT_REG = 0;
 const uint8_t OUTPUT_REG = 1;
 const uint8_t INVERT_REG = 2;
@@ -13,9 +14,10 @@ static const char *const TAG = "pca9554";
 
 void PCA9554Component::setup() {
   ESP_LOGCONFIG(TAG, "Setting up PCA9554/PCA9554A...");
+  this->reg_width_ = (this->pin_count_ + 7) / 8;
   // Test to see if device exists
   if (!this->read_inputs_()) {
-    ESP_LOGE(TAG, "PCA9554 not available under 0x%02X", this->address_);
+    ESP_LOGE(TAG, "PCA95xx not detected at 0x%02X", this->address_);
     this->mark_failed();
     return;
   }
@@ -44,6 +46,7 @@ void PCA9554Component::loop() {
 
 void PCA9554Component::dump_config() {
   ESP_LOGCONFIG(TAG, "PCA9554:");
+  ESP_LOGCONFIG(TAG, "  I/O Pins: %d", this->pin_count_);
   LOG_I2C_DEVICE(this)
   if (this->is_failed()) {
     ESP_LOGE(TAG, "Communication with PCA9554 failed!");
@@ -85,25 +88,33 @@ void PCA9554Component::pin_mode(uint8_t pin, gpio::Flags flags) {
 }
 
 bool PCA9554Component::read_inputs_() {
-  uint8_t inputs;
+  uint8_t inputs[2];
 
   if (this->is_failed()) {
     ESP_LOGD(TAG, "Device marked failed");
     return false;
   }
 
-  if ((this->last_error_ = this->read_register(INPUT_REG, &inputs, 1, true)) != esphome::i2c::ERROR_OK) {
+  if ((this->last_error_ = this->read_register(INPUT_REG * this->reg_width_, inputs, this->reg_width_, true)) !=
+      esphome::i2c::ERROR_OK) {
     this->status_set_warning();
     ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) this->last_error_);
     return false;
   }
   this->status_clear_warning();
-  this->input_mask_ = inputs;
+  this->input_mask_ = inputs[0];
+  if (this->reg_width_ == 2) {
+    this->input_mask_ |= inputs[1] << 8;
+  }
   return true;
 }
 
-bool PCA9554Component::write_register_(uint8_t reg, uint8_t value) {
-  if ((this->last_error_ = this->write_register(reg, &value, 1, true)) != esphome::i2c::ERROR_OK) {
+bool PCA9554Component::write_register_(uint8_t reg, uint16_t value) {
+  uint8_t outputs[2];
+  outputs[0] = (uint8_t) value;
+  outputs[1] = (uint8_t) (value >> 8);
+  if ((this->last_error_ = this->write_register(reg * this->reg_width_, outputs, this->reg_width_, true)) !=
+      esphome::i2c::ERROR_OK) {
     this->status_set_warning();
     ESP_LOGE(TAG, "write_register_(): I2C I/O error: %d", (int) this->last_error_);
     return false;
diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h
index c2aa5c30ed..c548bec619 100644
--- a/esphome/components/pca9554/pca9554.h
+++ b/esphome/components/pca9554/pca9554.h
@@ -28,19 +28,25 @@ class PCA9554Component : public Component, public i2c::I2CDevice {
 
   void dump_config() override;
 
+  void set_pin_count(size_t pin_count) { this->pin_count_ = pin_count; }
+
  protected:
   bool read_inputs_();
 
-  bool write_register_(uint8_t reg, uint8_t value);
+  bool write_register_(uint8_t reg, uint16_t value);
 
+  /// number of bits the expander has
+  size_t pin_count_{8};
+  /// width of registers
+  size_t reg_width_{1};
   /// Mask for the pin config - 1 means OUTPUT, 0 means INPUT
-  uint8_t config_mask_{0x00};
+  uint16_t config_mask_{0x00};
   /// The mask to write as output state - 1 means HIGH, 0 means LOW
-  uint8_t output_mask_{0x00};
+  uint16_t output_mask_{0x00};
   /// The state of the actual input pin states - 1 means HIGH, 0 means LOW
-  uint8_t input_mask_{0x00};
+  uint16_t input_mask_{0x00};
   /// Flags to check if read previously during this loop
-  uint8_t was_previously_read_ = {0x00};
+  uint16_t was_previously_read_ = {0x00};
   /// Storage for last I2C error seen
   esphome::i2c::ErrorCode last_error_;
 };
diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py
index a5f963707f..d44ac28364 100644
--- a/esphome/components/pcf8574/__init__.py
+++ b/esphome/components/pcf8574/__init__.py
@@ -65,7 +65,7 @@ PCF8574_PIN_SCHEMA = cv.All(
 )
 
 
-@pins.PIN_SCHEMA_REGISTRY.register("pcf8574", PCF8574_PIN_SCHEMA)
+@pins.PIN_SCHEMA_REGISTRY.register(CONF_PCF8574, PCF8574_PIN_SCHEMA)
 async def pcf8574_pin_to_code(config):
     var = cg.new_Pvariable(config[CONF_ID])
     parent = await cg.get_variable(config[CONF_PCF8574])
diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py
index e9453896ac..6163129529 100644
--- a/esphome/components/rtttl/__init__.py
+++ b/esphome/components/rtttl/__init__.py
@@ -4,7 +4,15 @@ import esphome.config_validation as cv
 import esphome.final_validate as fv
 from esphome import automation
 from esphome.components.output import FloatOutput
-from esphome.const import CONF_ID, CONF_OUTPUT, CONF_PLATFORM, CONF_TRIGGER_ID
+from esphome.components.speaker import Speaker
+
+from esphome.const import (
+    CONF_ID,
+    CONF_OUTPUT,
+    CONF_PLATFORM,
+    CONF_TRIGGER_ID,
+    CONF_SPEAKER,
+)
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -24,17 +32,23 @@ IsPlayingCondition = rtttl_ns.class_("IsPlayingCondition", automation.Condition)
 
 MULTI_CONF = True
 
-CONFIG_SCHEMA = cv.Schema(
-    {
-        cv.GenerateID(CONF_ID): cv.declare_id(Rtttl),
-        cv.Required(CONF_OUTPUT): cv.use_id(FloatOutput),
-        cv.Optional(CONF_ON_FINISHED_PLAYBACK): automation.validate_automation(
-            {
-                cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FinishedPlaybackTrigger),
-            }
-        ),
-    }
-).extend(cv.COMPONENT_SCHEMA)
+CONFIG_SCHEMA = cv.All(
+    cv.Schema(
+        {
+            cv.GenerateID(CONF_ID): cv.declare_id(Rtttl),
+            cv.Optional(CONF_OUTPUT): cv.use_id(FloatOutput),
+            cv.Optional(CONF_SPEAKER): cv.use_id(Speaker),
+            cv.Optional(CONF_ON_FINISHED_PLAYBACK): automation.validate_automation(
+                {
+                    cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
+                        FinishedPlaybackTrigger
+                    ),
+                }
+            ),
+        }
+    ).extend(cv.COMPONENT_SCHEMA),
+    cv.has_exactly_one_key(CONF_OUTPUT, CONF_SPEAKER),
+)
 
 
 def validate_parent_output_config(value):
@@ -63,9 +77,9 @@ def validate_parent_output_config(value):
 
 FINAL_VALIDATE_SCHEMA = cv.Schema(
     {
-        cv.Required(CONF_OUTPUT): fv.id_declaration_match_schema(
+        cv.Optional(CONF_OUTPUT): fv.id_declaration_match_schema(
             validate_parent_output_config
-        )
+        ),
     },
     extra=cv.ALLOW_EXTRA,
 )
@@ -75,8 +89,14 @@ async def to_code(config):
     var = cg.new_Pvariable(config[CONF_ID])
     await cg.register_component(var, config)
 
-    out = await cg.get_variable(config[CONF_OUTPUT])
-    cg.add(var.set_output(out))
+    if CONF_OUTPUT in config:
+        out = await cg.get_variable(config[CONF_OUTPUT])
+        cg.add(var.set_output(out))
+        cg.add_define("USE_OUTPUT")
+
+    if CONF_SPEAKER in config:
+        out = await cg.get_variable(config[CONF_SPEAKER])
+        cg.add(var.set_speaker(out))
 
     for conf in config.get(CONF_ON_FINISHED_PLAYBACK, []):
         trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp
index 6274e69ba3..199a373785 100644
--- a/esphome/components/rtttl/rtttl.cpp
+++ b/esphome/components/rtttl/rtttl.cpp
@@ -1,4 +1,5 @@
 #include "rtttl.h"
+#include <cmath>
 #include "esphome/core/hal.h"
 #include "esphome/core/log.h"
 
@@ -15,104 +16,185 @@ static const uint16_t NOTES[] = {0,    262,  277,  294,  311,  330,  349,  370,
                                  1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661, 1760, 1865, 1976, 2093, 2217,
                                  2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951};
 
+static const uint16_t I2S_SPEED = 1600;
+
+#undef HALF_PI
+static const double HALF_PI = 1.5707963267948966192313216916398;
+
+inline double deg2rad(double degrees) {
+  static const double PI_ON_180 = 4.0 * atan(1.0) / 180.0;
+  return degrees * PI_ON_180;
+}
+
 void Rtttl::dump_config() { ESP_LOGCONFIG(TAG, "Rtttl"); }
 
 void Rtttl::play(std::string rtttl) {
-  rtttl_ = std::move(rtttl);
+  this->rtttl_ = std::move(rtttl);
+
+  this->default_duration_ = 4;
+  this->default_octave_ = 6;
+  this->note_duration_ = 0;
 
-  default_duration_ = 4;
-  default_octave_ = 6;
   int bpm = 63;
   uint8_t num;
 
   // Get name
-  position_ = rtttl_.find(':');
+  this->position_ = rtttl_.find(':');
 
   // it's somewhat documented to be up to 10 characters but let's be a bit flexible here
-  if (position_ == std::string::npos || position_ > 15) {
+  if (this->position_ == std::string::npos || this->position_ > 15) {
     ESP_LOGE(TAG, "Missing ':' when looking for name.");
     return;
   }
 
-  auto name = this->rtttl_.substr(0, position_);
+  auto name = this->rtttl_.substr(0, this->position_);
   ESP_LOGD(TAG, "Playing song %s", name.c_str());
 
   // get default duration
-  position_ = this->rtttl_.find("d=", position_);
-  if (position_ == std::string::npos) {
+  this->position_ = this->rtttl_.find("d=", this->position_);
+  if (this->position_ == std::string::npos) {
     ESP_LOGE(TAG, "Missing 'd='");
     return;
   }
-  position_ += 2;
+  this->position_ += 2;
   num = this->get_integer_();
   if (num > 0)
-    default_duration_ = num;
+    this->default_duration_ = num;
 
   // get default octave
-  position_ = rtttl_.find("o=", position_);
-  if (position_ == std::string::npos) {
+  this->position_ = this->rtttl_.find("o=", this->position_);
+  if (this->position_ == std::string::npos) {
     ESP_LOGE(TAG, "Missing 'o=");
     return;
   }
-  position_ += 2;
+  this->position_ += 2;
   num = get_integer_();
   if (num >= 3 && num <= 7)
-    default_octave_ = num;
+    this->default_octave_ = num;
 
   // get BPM
-  position_ = rtttl_.find("b=", position_);
-  if (position_ == std::string::npos) {
+  this->position_ = this->rtttl_.find("b=", this->position_);
+  if (this->position_ == std::string::npos) {
     ESP_LOGE(TAG, "Missing b=");
     return;
   }
-  position_ += 2;
+  this->position_ += 2;
   num = get_integer_();
   if (num != 0)
     bpm = num;
 
-  position_ = rtttl_.find(':', position_);
-  if (position_ == std::string::npos) {
+  this->position_ = this->rtttl_.find(':', this->position_);
+  if (this->position_ == std::string::npos) {
     ESP_LOGE(TAG, "Missing second ':'");
     return;
   }
-  position_++;
+  this->position_++;
 
   // BPM usually expresses the number of quarter notes per minute
-  wholenote_ = 60 * 1000L * 4 / bpm;  // this is the time for whole note (in milliseconds)
+  this->wholenote_ = 60 * 1000L * 4 / bpm;  // this is the time for whole note (in milliseconds)
 
-  output_freq_ = 0;
-  last_note_ = millis();
-  note_duration_ = 1;
+  this->output_freq_ = 0;
+  this->last_note_ = millis();
+  this->note_duration_ = 1;
+
+#ifdef USE_SPEAKER
+  this->samples_sent_ = 0;
+  this->samples_count_ = 0;
+#endif
+}
+
+void Rtttl::stop() {
+  this->note_duration_ = 0;
+#ifdef USE_OUTPUT
+  if (this->output_ != nullptr) {
+    this->output_->set_level(0.0);
+  }
+#endif
+#ifdef USE_SPEAKER
+  if (this->speaker_ != nullptr) {
+    if (this->speaker_->is_running()) {
+      this->speaker_->stop();
+    }
+  }
+#endif
 }
 
 void Rtttl::loop() {
-  if (note_duration_ == 0 || millis() - last_note_ < note_duration_)
+  if (this->note_duration_ == 0)
     return;
 
-  if (!rtttl_[position_]) {
-    output_->set_level(0.0);
+#ifdef USE_SPEAKER
+  if (this->speaker_ != nullptr) {
+    if (this->samples_sent_ != this->samples_count_) {
+      SpeakerSample sample[SAMPLE_BUFFER_SIZE + 1];
+      int x = 0;
+      double rem = 0.0;
+
+      while (true) {
+        // Try and send out the remainder of the existing note, one per loop()
+
+        if (this->samples_per_wave_ != 0 && this->samples_sent_ >= this->samples_gap_) {  // Play note//
+          rem = ((this->samples_sent_ << 10) % this->samples_per_wave_) * (360.0 / this->samples_per_wave_);
+
+          int16_t val = 8192 * sin(deg2rad(rem));
+
+          sample[x].left = val;
+          sample[x].right = val;
+
+        } else {
+          sample[x].left = 0;
+          sample[x].right = 0;
+        }
+
+        if (x >= SAMPLE_BUFFER_SIZE || this->samples_sent_ >= this->samples_count_) {
+          break;
+        }
+        this->samples_sent_++;
+        x++;
+      }
+      if (x > 0) {
+        int send = this->speaker_->play((uint8_t *) (&sample), x * 4);
+        if (send != x * 4) {
+          this->samples_sent_ -= (x - (send / 4));
+        }
+        return;
+      }
+    }
+  }
+#endif
+#ifdef USE_OUTPUT
+  if (this->output_ != nullptr && millis() - this->last_note_ < this->note_duration_)
+    return;
+#endif
+  if (!this->rtttl_[position_]) {
+    this->note_duration_ = 0;
+#ifdef USE_OUTPUT
+    if (this->output_ != nullptr) {
+      this->output_->set_level(0.0);
+    }
+#endif
     ESP_LOGD(TAG, "Playback finished");
     this->on_finished_playback_callback_.call();
-    note_duration_ = 0;
     return;
   }
 
   // align to note: most rtttl's out there does not add and space after the ',' separator but just in case...
-  while (rtttl_[position_] == ',' || rtttl_[position_] == ' ')
-    position_++;
+  while (this->rtttl_[this->position_] == ',' || this->rtttl_[this->position_] == ' ')
+    this->position_++;
 
   // first, get note duration, if available
   uint8_t num = this->get_integer_();
 
   if (num) {
-    note_duration_ = wholenote_ / num;
+    this->note_duration_ = this->wholenote_ / num;
   } else {
-    note_duration_ = wholenote_ / default_duration_;  // we will need to check if we are a dotted note after
+    this->note_duration_ =
+        this->wholenote_ / this->default_duration_;  // we will need to check if we are a dotted note after
   }
 
   uint8_t note;
 
-  switch (rtttl_[position_]) {
+  switch (this->rtttl_[this->position_]) {
     case 'c':
       note = 1;
       break;
@@ -138,51 +220,81 @@ void Rtttl::loop() {
     default:
       note = 0;
   }
-  position_++;
+  this->position_++;
 
   // now, get optional '#' sharp
-  if (rtttl_[position_] == '#') {
+  if (this->rtttl_[this->position_] == '#') {
     note++;
-    position_++;
+    this->position_++;
   }
 
   // now, get optional '.' dotted note
-  if (rtttl_[position_] == '.') {
-    note_duration_ += note_duration_ / 2;
-    position_++;
+  if (this->rtttl_[this->position_] == '.') {
+    this->note_duration_ += this->note_duration_ / 2;
+    this->position_++;
   }
 
   // now, get scale
   uint8_t scale = get_integer_();
   if (scale == 0)
-    scale = default_octave_;
+    scale = this->default_octave_;
+  bool need_note_gap = false;
 
   // Now play the note
   if (note) {
     auto note_index = (scale - 4) * 12 + note;
     if (note_index < 0 || note_index >= (int) sizeof(NOTES)) {
       ESP_LOGE(TAG, "Note out of valid range");
+      this->note_duration_ = 0;
       return;
     }
     auto freq = NOTES[note_index];
+    need_note_gap = freq == this->output_freq_;
 
-    if (freq == output_freq_) {
-      // Add small silence gap between same note
-      output_->set_level(0.0);
-      delay(DOUBLE_NOTE_GAP_MS);
-      note_duration_ -= DOUBLE_NOTE_GAP_MS;
-    }
-    output_freq_ = freq;
+    // Add small silence gap between same note
+    this->output_freq_ = freq;
 
-    ESP_LOGVV(TAG, "playing note: %d for %dms", note, note_duration_);
-    output_->update_frequency(freq);
-    output_->set_level(0.5);
+    ESP_LOGVV(TAG, "playing note: %d for %dms", note, this->note_duration_);
   } else {
-    ESP_LOGVV(TAG, "waiting: %dms", note_duration_);
-    output_->set_level(0.0);
+    ESP_LOGVV(TAG, "waiting: %dms", this->note_duration_);
+    this->output_freq_ = 0;
   }
 
-  last_note_ = millis();
+#ifdef USE_OUTPUT
+  if (this->output_ != nullptr) {
+    if (need_note_gap) {
+      this->output_->set_level(0.0);
+      delay(DOUBLE_NOTE_GAP_MS);
+      this->note_duration_ -= DOUBLE_NOTE_GAP_MS;
+    }
+    if (this->output_freq_ != 0) {
+      this->output_->update_frequency(this->output_freq_);
+      this->output_->set_level(0.5);
+    } else {
+      this->output_->set_level(0.0);
+    }
+  }
+#endif
+#ifdef USE_SPEAKER
+  if (this->speaker_ != nullptr) {
+    this->samples_sent_ = 0;
+    this->samples_count_ = (this->sample_rate_ * this->note_duration_) / I2S_SPEED;
+    // Convert from frequency in Hz to high and low samples in fixed point
+    if (this->output_freq_ != 0) {
+      this->samples_per_wave_ = (this->sample_rate_ << 10) / this->output_freq_;
+    } else {
+      this->samples_per_wave_ = 0;
+    }
+    if (need_note_gap) {
+      this->samples_gap_ = (this->sample_rate_ * DOUBLE_NOTE_GAP_MS) / I2S_SPEED;
+    } else {
+      this->samples_gap_ = 0;
+    }
+  }
+#endif
+
+  this->last_note_ = millis();
 }
+
 }  // namespace rtttl
 }  // namespace esphome
diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h
index ec6fe7f98f..e09b0265be 100644
--- a/esphome/components/rtttl/rtttl.h
+++ b/esphome/components/rtttl/rtttl.h
@@ -1,23 +1,41 @@
 #pragma once
 
-#include "esphome/core/component.h"
 #include "esphome/core/automation.h"
+#include "esphome/core/component.h"
+
+#ifdef USE_OUTPUT
 #include "esphome/components/output/float_output.h"
+#endif
+
+#ifdef USE_SPEAKER
+#include "esphome/components/speaker/speaker.h"
+#endif
 
 namespace esphome {
 namespace rtttl {
 
+#ifdef USE_SPEAKER
+static const size_t SAMPLE_BUFFER_SIZE = 256;
+
+struct SpeakerSample {
+  int16_t left{0};
+  int16_t right{0};
+};
+#endif
+
 class Rtttl : public Component {
  public:
-  void set_output(output::FloatOutput *output) { output_ = output; }
+#ifdef USE_OUTPUT
+  void set_output(output::FloatOutput *output) { this->output_ = output; }
+#endif
+#ifdef USE_SPEAKER
+  void set_speaker(speaker::Speaker *speaker) { this->speaker_ = speaker; }
+#endif
   void play(std::string rtttl);
-  void stop() {
-    note_duration_ = 0;
-    output_->set_level(0.0);
-  }
+  void stop();
   void dump_config() override;
 
-  bool is_playing() { return note_duration_ != 0; }
+  bool is_playing() { return this->note_duration_ != 0; }
   void loop() override;
 
   void add_on_finished_playback_callback(std::function<void()> callback) {
@@ -27,14 +45,14 @@ class Rtttl : public Component {
  protected:
   inline uint8_t get_integer_() {
     uint8_t ret = 0;
-    while (isdigit(rtttl_[position_])) {
-      ret = (ret * 10) + (rtttl_[position_++] - '0');
+    while (isdigit(this->rtttl_[this->position_])) {
+      ret = (ret * 10) + (this->rtttl_[this->position_++] - '0');
     }
     return ret;
   }
 
-  std::string rtttl_;
-  size_t position_;
+  std::string rtttl_{""};
+  size_t position_{0};
   uint16_t wholenote_;
   uint16_t default_duration_;
   uint16_t default_octave_;
@@ -42,7 +60,22 @@ class Rtttl : public Component {
   uint16_t note_duration_;
 
   uint32_t output_freq_;
+
+#ifdef USE_OUTPUT
   output::FloatOutput *output_;
+#endif
+
+  void play_output_();
+
+#ifdef USE_SPEAKER
+  speaker::Speaker *speaker_;
+  void play_speaker_();
+  int sample_rate_{16000};
+  int samples_per_wave_{0};
+  int samples_sent_{0};
+  int samples_count_{0};
+  int samples_gap_{0};
+#endif
 
   CallbackManager<void()> on_finished_playback_callback_;
 };
diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp
index d1cb8d1c4b..b5bef4930c 100644
--- a/esphome/components/sensor/filter.cpp
+++ b/esphome/components/sensor/filter.cpp
@@ -1,8 +1,8 @@
 #include "filter.h"
+#include <cmath>
 #include "esphome/core/hal.h"
 #include "esphome/core/log.h"
 #include "sensor.h"
-#include <cmath>
 
 namespace esphome {
 namespace sensor {
@@ -376,9 +376,7 @@ void OrFilter::initialize(Sensor *parent, Filter *next) {
 // TimeoutFilter
 optional<float> TimeoutFilter::new_value(float value) {
   this->set_timeout("timeout", this->time_period_, [this]() { this->output(this->value_); });
-  this->output(value);
-
-  return {};
+  return value;
 }
 
 TimeoutFilter::TimeoutFilter(uint32_t time_period, float new_value) : time_period_(time_period), value_(new_value) {}
diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp
index a48372aab7..561d41e225 100644
--- a/esphome/components/sgp4x/sgp4x.cpp
+++ b/esphome/components/sgp4x/sgp4x.cpp
@@ -70,15 +70,15 @@ void SGP4xComponent::setup() {
     if (this->pref_.load(&this->voc_baselines_storage_)) {
       this->voc_state0_ = this->voc_baselines_storage_.state0;
       this->voc_state1_ = this->voc_baselines_storage_.state1;
-      ESP_LOGI(TAG, "Loaded VOC baseline state0: 0x%04X, state1: 0x%04X", this->voc_baselines_storage_.state0,
-               voc_baselines_storage_.state1);
+      ESP_LOGI(TAG, "Loaded VOC baseline state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32,
+               this->voc_baselines_storage_.state0, voc_baselines_storage_.state1);
     }
 
     // Initialize storage timestamp
     this->seconds_since_last_store_ = 0;
 
     if (this->voc_baselines_storage_.state0 > 0 && this->voc_baselines_storage_.state1 > 0) {
-      ESP_LOGI(TAG, "Setting VOC baseline from save state0: 0x%04X, state1: 0x%04X",
+      ESP_LOGI(TAG, "Setting VOC baseline from save state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32,
                this->voc_baselines_storage_.state0, voc_baselines_storage_.state1);
       voc_algorithm_.set_states(this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1);
     }
@@ -178,8 +178,8 @@ bool SGP4xComponent::measure_gas_indices_(int32_t &voc, int32_t &nox) {
       this->voc_baselines_storage_.state1 = this->voc_state1_;
 
       if (this->pref_.save(&this->voc_baselines_storage_)) {
-        ESP_LOGI(TAG, "Stored VOC baseline state0: 0x%04X ,state1: 0x%04X", this->voc_baselines_storage_.state0,
-                 voc_baselines_storage_.state1);
+        ESP_LOGI(TAG, "Stored VOC baseline state0: 0x%04" PRIX32 " ,state1: 0x%04" PRIX32,
+                 this->voc_baselines_storage_.state0, voc_baselines_storage_.state1);
       } else {
         ESP_LOGW(TAG, "Could not store VOC baselines");
       }
@@ -273,7 +273,7 @@ void SGP4xComponent::update_gas_indices() {
   }
   if (this->samples_read_ < this->samples_to_stabilize_) {
     this->samples_read_++;
-    ESP_LOGD(TAG, "Sensor has not collected enough samples yet. (%d/%d) VOC index is: %u", this->samples_read_,
+    ESP_LOGD(TAG, "Sensor has not collected enough samples yet. (%d/%d) VOC index is: %" PRIu32, this->samples_read_,
              this->samples_to_stabilize_, this->voc_index_);
     return;
   }
diff --git a/esphome/components/sgp4x/sgp4x.h b/esphome/components/sgp4x/sgp4x.h
index 3a8d8200a7..aa5ae4b9d2 100644
--- a/esphome/components/sgp4x/sgp4x.h
+++ b/esphome/components/sgp4x/sgp4x.h
@@ -1,5 +1,8 @@
 #pragma once
 
+#include <cinttypes>
+#include <cmath>
+
 #include "esphome/core/component.h"
 #include "esphome/components/sensor/sensor.h"
 #include "esphome/components/sensirion_common/i2c_sensirion.h"
@@ -8,8 +11,6 @@
 #include <VOCGasIndexAlgorithm.h>
 #include <NOxGasIndexAlgorithm.h>
 
-#include <cmath>
-
 namespace esphome {
 namespace sgp4x {
 
diff --git a/esphome/components/sn74hc165/__init__.py b/esphome/components/sn74hc165/__init__.py
index 85d0220a88..0f2abd3678 100644
--- a/esphome/components/sn74hc165/__init__.py
+++ b/esphome/components/sn74hc165/__init__.py
@@ -77,7 +77,15 @@ SN74HC165_PIN_SCHEMA = cv.All(
 )
 
 
-@pins.PIN_SCHEMA_REGISTRY.register(CONF_SN74HC165, SN74HC165_PIN_SCHEMA)
+def sn74hc165_pin_final_validate(pin_config, parent_config):
+    max_pins = parent_config[CONF_SR_COUNT] * 8
+    if pin_config[CONF_NUMBER] >= max_pins:
+        raise cv.Invalid(f"Pin number must be less than {max_pins}")
+
+
+@pins.PIN_SCHEMA_REGISTRY.register(
+    CONF_SN74HC165, SN74HC165_PIN_SCHEMA, sn74hc165_pin_final_validate
+)
 async def sn74hc165_pin_to_code(config):
     var = cg.new_Pvariable(config[CONF_ID])
     await cg.register_parented(var, config[CONF_SN74HC165])
diff --git a/esphome/components/sn74hc595/__init__.py b/esphome/components/sn74hc595/__init__.py
index 92b6d8d0e5..e98da72304 100644
--- a/esphome/components/sn74hc595/__init__.py
+++ b/esphome/components/sn74hc595/__init__.py
@@ -75,7 +75,15 @@ SN74HC595_PIN_SCHEMA = cv.All(
 )
 
 
-@pins.PIN_SCHEMA_REGISTRY.register(CONF_SN74HC595, SN74HC595_PIN_SCHEMA)
+def sn74hc595_pin_final_validate(pin_config, parent_config):
+    max_pins = parent_config[CONF_SR_COUNT] * 8
+    if pin_config[CONF_NUMBER] >= max_pins:
+        raise cv.Invalid(f"Pin number must be less than {max_pins}")
+
+
+@pins.PIN_SCHEMA_REGISTRY.register(
+    CONF_SN74HC595, SN74HC595_PIN_SCHEMA, sn74hc595_pin_final_validate
+)
 async def sn74hc595_pin_to_code(config):
     var = cg.new_Pvariable(config[CONF_ID])
     await cg.register_parented(var, config[CONF_SN74HC595])
diff --git a/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp b/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp
index 602595e89d..fbe511811f 100644
--- a/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp
+++ b/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp
@@ -1,5 +1,5 @@
-#include "esphome/core/log.h"
 #include "tuya_text_sensor.h"
+#include "esphome/core/log.h"
 
 namespace esphome {
 namespace tuya {
@@ -19,6 +19,12 @@ void TuyaTextSensor::setup() {
         this->publish_state(data);
         break;
       }
+      case TuyaDatapointType::ENUM: {
+        std::string data = to_string(datapoint.value_enum);
+        ESP_LOGD(TAG, "MCU reported text sensor %u is: %s", datapoint.id, data.c_str());
+        this->publish_state(data);
+        break;
+      }
       default:
         ESP_LOGW(TAG, "Unsupported data type for tuya text sensor %u: %#02hhX", datapoint.id, (uint8_t) datapoint.type);
         break;
diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp
index daf5080e7a..da03e3faad 100644
--- a/esphome/components/tuya/tuya.cpp
+++ b/esphome/components/tuya/tuya.cpp
@@ -61,7 +61,7 @@ void Tuya::dump_config() {
     } else if (info.type == TuyaDatapointType::ENUM) {
       ESP_LOGCONFIG(TAG, "  Datapoint %u: enum (value: %d)", info.id, info.value_enum);
     } else if (info.type == TuyaDatapointType::BITMASK) {
-      ESP_LOGCONFIG(TAG, "  Datapoint %u: bitmask (value: %x)", info.id, info.value_bitmask);
+      ESP_LOGCONFIG(TAG, "  Datapoint %u: bitmask (value: %" PRIx32 ")", info.id, info.value_bitmask);
     } else {
       ESP_LOGCONFIG(TAG, "  Datapoint %u: unknown", info.id);
     }
@@ -342,7 +342,7 @@ void Tuya::handle_datapoints_(const uint8_t *buffer, size_t len) {
             ESP_LOGW(TAG, "Datapoint %u has bad bitmask len %zu", datapoint.id, data_size);
             return;
         }
-        ESP_LOGD(TAG, "Datapoint %u update to %#08X", datapoint.id, datapoint.value_bitmask);
+        ESP_LOGD(TAG, "Datapoint %u update to %#08" PRIX32, datapoint.id, datapoint.value_bitmask);
         break;
       default:
         ESP_LOGW(TAG, "Datapoint %u has unknown type %#02hhX", datapoint.id, static_cast<uint8_t>(datapoint.type));
@@ -594,7 +594,7 @@ optional<TuyaDatapoint> Tuya::get_datapoint_(uint8_t datapoint_id) {
 
 void Tuya::set_numeric_datapoint_value_(uint8_t datapoint_id, TuyaDatapointType datapoint_type, const uint32_t value,
                                         uint8_t length, bool forced) {
-  ESP_LOGD(TAG, "Setting datapoint %u to %u", datapoint_id, value);
+  ESP_LOGD(TAG, "Setting datapoint %u to %" PRIu32, datapoint_id, value);
   optional<TuyaDatapoint> datapoint = this->get_datapoint_(datapoint_id);
   if (!datapoint.has_value()) {
     ESP_LOGW(TAG, "Setting unknown datapoint %u", datapoint_id);
diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h
index 26f6f65912..27a97c3dc9 100644
--- a/esphome/components/tuya/tuya.h
+++ b/esphome/components/tuya/tuya.h
@@ -1,5 +1,8 @@
 #pragma once
 
+#include <cinttypes>
+#include <vector>
+
 #include "esphome/core/component.h"
 #include "esphome/core/defines.h"
 #include "esphome/core/helpers.h"
@@ -10,8 +13,6 @@
 #include "esphome/core/time.h"
 #endif
 
-#include <vector>
-
 namespace esphome {
 namespace tuya {
 
diff --git a/esphome/components/vbus/sensor/__init__.py b/esphome/components/vbus/sensor/__init__.py
index 2ad9da424e..2b89da6d32 100644
--- a/esphome/components/vbus/sensor/__init__.py
+++ b/esphome/components/vbus/sensor/__init__.py
@@ -22,6 +22,7 @@ from esphome.const import (
     ICON_THERMOMETER,
     ICON_TIMER,
     STATE_CLASS_MEASUREMENT,
+    STATE_CLASS_TOTAL_INCREASING,
     UNIT_CELSIUS,
     UNIT_HOUR,
     UNIT_MINUTE,
@@ -128,7 +129,7 @@ CONFIG_SCHEMA = cv.typed_schema(
                     icon=ICON_RADIATOR,
                     accuracy_decimals=0,
                     device_class=DEVICE_CLASS_ENERGY,
-                    state_class=STATE_CLASS_MEASUREMENT,
+                    state_class=STATE_CLASS_TOTAL_INCREASING,
                 ),
                 cv.Optional(CONF_TIME): sensor.sensor_schema(
                     unit_of_measurement=UNIT_MINUTE,
@@ -209,7 +210,7 @@ CONFIG_SCHEMA = cv.typed_schema(
                     icon=ICON_RADIATOR,
                     accuracy_decimals=0,
                     device_class=DEVICE_CLASS_ENERGY,
-                    state_class=STATE_CLASS_MEASUREMENT,
+                    state_class=STATE_CLASS_TOTAL_INCREASING,
                 ),
                 cv.Optional(CONF_TIME): sensor.sensor_schema(
                     unit_of_measurement=UNIT_MINUTE,
@@ -290,7 +291,7 @@ CONFIG_SCHEMA = cv.typed_schema(
                     icon=ICON_RADIATOR,
                     accuracy_decimals=0,
                     device_class=DEVICE_CLASS_ENERGY,
-                    state_class=STATE_CLASS_MEASUREMENT,
+                    state_class=STATE_CLASS_TOTAL_INCREASING,
                 ),
                 cv.Optional(CONF_TIME): sensor.sensor_schema(
                     unit_of_measurement=UNIT_MINUTE,
@@ -353,7 +354,7 @@ CONFIG_SCHEMA = cv.typed_schema(
                     icon=ICON_RADIATOR,
                     accuracy_decimals=0,
                     device_class=DEVICE_CLASS_ENERGY,
-                    state_class=STATE_CLASS_MEASUREMENT,
+                    state_class=STATE_CLASS_TOTAL_INCREASING,
                 ),
                 cv.Optional(CONF_VERSION): sensor.sensor_schema(
                     accuracy_decimals=2,
@@ -433,7 +434,7 @@ CONFIG_SCHEMA = cv.typed_schema(
                     icon=ICON_RADIATOR,
                     accuracy_decimals=0,
                     device_class=DEVICE_CLASS_ENERGY,
-                    state_class=STATE_CLASS_MEASUREMENT,
+                    state_class=STATE_CLASS_TOTAL_INCREASING,
                 ),
                 cv.Optional(CONF_TIME): sensor.sensor_schema(
                     unit_of_measurement=UNIT_MINUTE,
diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py
index 14176ad7cf..3270b9f370 100644
--- a/esphome/components/voice_assistant/__init__.py
+++ b/esphome/components/voice_assistant/__init__.py
@@ -6,6 +6,8 @@ from esphome.const import (
     CONF_MICROPHONE,
     CONF_SPEAKER,
     CONF_MEDIA_PLAYER,
+    CONF_ON_CLIENT_CONNECTED,
+    CONF_ON_CLIENT_DISCONNECTED,
 )
 from esphome import automation
 from esphome.automation import register_action, register_condition
@@ -80,6 +82,12 @@ CONFIG_SCHEMA = cv.All(
             cv.Optional(CONF_ON_TTS_END): automation.validate_automation(single=True),
             cv.Optional(CONF_ON_END): automation.validate_automation(single=True),
             cv.Optional(CONF_ON_ERROR): automation.validate_automation(single=True),
+            cv.Optional(CONF_ON_CLIENT_CONNECTED): automation.validate_automation(
+                single=True
+            ),
+            cv.Optional(CONF_ON_CLIENT_DISCONNECTED): automation.validate_automation(
+                single=True
+            ),
         }
     ).extend(cv.COMPONENT_SCHEMA),
 )
@@ -155,6 +163,20 @@ async def to_code(config):
             config[CONF_ON_ERROR],
         )
 
+    if CONF_ON_CLIENT_CONNECTED in config:
+        await automation.build_automation(
+            var.get_client_connected_trigger(),
+            [],
+            config[CONF_ON_CLIENT_CONNECTED],
+        )
+
+    if CONF_ON_CLIENT_DISCONNECTED in config:
+        await automation.build_automation(
+            var.get_client_disconnected_trigger(),
+            [],
+            config[CONF_ON_CLIENT_DISCONNECTED],
+        )
+
     cg.add_define("USE_VOICE_ASSISTANT")
 
 
diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp
index df7853156d..d15d702d4b 100644
--- a/esphome/components/voice_assistant/voice_assistant.cpp
+++ b/esphome/components/voice_assistant/voice_assistant.cpp
@@ -127,8 +127,8 @@ int VoiceAssistant::read_microphone_() {
 }
 
 void VoiceAssistant::loop() {
-  if (this->state_ != State::IDLE && this->state_ != State::STOP_MICROPHONE &&
-      this->state_ != State::STOPPING_MICROPHONE && !api::global_api_server->is_connected()) {
+  if (this->api_client_ == nullptr && this->state_ != State::IDLE && this->state_ != State::STOP_MICROPHONE &&
+      this->state_ != State::STOPPING_MICROPHONE) {
     if (this->mic_->is_running() || this->state_ == State::STARTING_MICROPHONE) {
       this->set_state_(State::STOP_MICROPHONE, State::IDLE);
     } else {
@@ -213,7 +213,14 @@ void VoiceAssistant::loop() {
       audio_settings.noise_suppression_level = this->noise_suppression_level_;
       audio_settings.auto_gain = this->auto_gain_;
       audio_settings.volume_multiplier = this->volume_multiplier_;
-      if (!api::global_api_server->start_voice_assistant(this->conversation_id_, flags, audio_settings)) {
+
+      api::VoiceAssistantRequest msg;
+      msg.start = true;
+      msg.conversation_id = this->conversation_id_;
+      msg.flags = flags;
+      msg.audio_settings = audio_settings;
+
+      if (this->api_client_ == nullptr || !this->api_client_->send_voice_assistant_request(msg)) {
         ESP_LOGW(TAG, "Could not request start.");
         this->error_trigger_->trigger("not-connected", "Could not request start.");
         this->continuous_ = false;
@@ -326,6 +333,28 @@ void VoiceAssistant::loop() {
   }
 }
 
+void VoiceAssistant::client_subscription(api::APIConnection *client, bool subscribe) {
+  if (!subscribe) {
+    if (this->api_client_ == nullptr || client != this->api_client_) {
+      ESP_LOGE(TAG, "Client attempting to unsubscribe that is not the current API Client");
+      return;
+    }
+    this->api_client_ = nullptr;
+    this->client_disconnected_trigger_->trigger();
+    return;
+  }
+
+  if (this->api_client_ != nullptr) {
+    ESP_LOGE(TAG, "Multiple API Clients attempting to connect to Voice Assistant");
+    ESP_LOGE(TAG, "Current client: %s", this->api_client_->get_client_combined_info().c_str());
+    ESP_LOGE(TAG, "New client: %s", client->get_client_combined_info().c_str());
+    return;
+  }
+
+  this->api_client_ = client;
+  this->client_connected_trigger_->trigger();
+}
+
 static const LogString *voice_assistant_state_to_string(State state) {
   switch (state) {
     case State::IDLE:
@@ -408,7 +437,7 @@ void VoiceAssistant::start_streaming(struct sockaddr_storage *addr, uint16_t por
 }
 
 void VoiceAssistant::request_start(bool continuous, bool silence_detection) {
-  if (!api::global_api_server->is_connected()) {
+  if (this->api_client_ == nullptr) {
     ESP_LOGE(TAG, "No API client connected");
     this->set_state_(State::IDLE, State::IDLE);
     this->continuous_ = false;
@@ -459,9 +488,14 @@ void VoiceAssistant::request_stop() {
 }
 
 void VoiceAssistant::signal_stop_() {
-  ESP_LOGD(TAG, "Signaling stop...");
-  api::global_api_server->stop_voice_assistant();
   memset(&this->dest_addr_, 0, sizeof(this->dest_addr_));
+  if (this->api_client_ == nullptr) {
+    return;
+  }
+  ESP_LOGD(TAG, "Signaling stop...");
+  api::VoiceAssistantRequest msg;
+  msg.start = false;
+  this->api_client_->send_voice_assistant_request(msg);
 }
 
 void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) {
diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h
index cd448293db..a265522bca 100644
--- a/esphome/components/voice_assistant/voice_assistant.h
+++ b/esphome/components/voice_assistant/voice_assistant.h
@@ -8,8 +8,8 @@
 #include "esphome/core/component.h"
 #include "esphome/core/helpers.h"
 
+#include "esphome/components/api/api_connection.h"
 #include "esphome/components/api/api_pb2.h"
-#include "esphome/components/api/api_server.h"
 #include "esphome/components/microphone/microphone.h"
 #ifdef USE_SPEAKER
 #include "esphome/components/speaker/speaker.h"
@@ -109,6 +109,12 @@ class VoiceAssistant : public Component {
   Trigger<> *get_end_trigger() const { return this->end_trigger_; }
   Trigger<std::string, std::string> *get_error_trigger() const { return this->error_trigger_; }
 
+  Trigger<> *get_client_connected_trigger() const { return this->client_connected_trigger_; }
+  Trigger<> *get_client_disconnected_trigger() const { return this->client_disconnected_trigger_; }
+
+  void client_subscription(api::APIConnection *client, bool subscribe);
+  api::APIConnection *get_api_connection() const { return this->api_client_; }
+
  protected:
   int read_microphone_();
   void set_state_(State state);
@@ -127,6 +133,11 @@ class VoiceAssistant : public Component {
   Trigger<> *end_trigger_ = new Trigger<>();
   Trigger<std::string, std::string> *error_trigger_ = new Trigger<std::string, std::string>();
 
+  Trigger<> *client_connected_trigger_ = new Trigger<>();
+  Trigger<> *client_disconnected_trigger_ = new Trigger<>();
+
+  api::APIConnection *api_client_{nullptr};
+
   microphone::Microphone *mic_{nullptr};
 #ifdef USE_SPEAKER
   speaker::Speaker *speaker_{nullptr};
diff --git a/esphome/components/xpt2046/xpt2046.cpp b/esphome/components/xpt2046/xpt2046.cpp
index 6c7c55a995..078a1b01e9 100644
--- a/esphome/components/xpt2046/xpt2046.cpp
+++ b/esphome/components/xpt2046/xpt2046.cpp
@@ -3,6 +3,7 @@
 #include "esphome/core/helpers.h"
 
 #include <algorithm>
+#include <cinttypes>
 
 namespace esphome {
 namespace xpt2046 {
@@ -151,7 +152,7 @@ void XPT2046Component::dump_config() {
   ESP_LOGCONFIG(TAG, "  Invert Y: %s", YESNO(this->invert_y_));
 
   ESP_LOGCONFIG(TAG, "  threshold: %d", this->threshold_);
-  ESP_LOGCONFIG(TAG, "  Report interval: %u", this->report_millis_);
+  ESP_LOGCONFIG(TAG, "  Report interval: %" PRIu32, this->report_millis_);
 
   LOG_UPDATE_INTERVAL(this);
 }
diff --git a/esphome/config.py b/esphome/config.py
index b04de020e0..a980358186 100644
--- a/esphome/config.py
+++ b/esphome/config.py
@@ -10,7 +10,7 @@ from contextlib import contextmanager
 
 import voluptuous as vol
 
-from esphome import core, yaml_util, loader
+from esphome import core, yaml_util, loader, pins
 import esphome.core.config as core_config
 from esphome.const import (
     CONF_ESPHOME,
@@ -645,14 +645,40 @@ class FinalValidateValidationStep(ConfigValidationStep):
             # If result already has errors, skip this step
             return
 
-        if self.comp.final_validate_schema is None:
-            return
-
         token = fv.full_config.set(result)
 
         conf = result.get_nested_item(self.path)
         with result.catch_error(self.path):
-            self.comp.final_validate_schema(conf)
+            if self.comp.final_validate_schema is not None:
+                self.comp.final_validate_schema(conf)
+
+            fconf = fv.full_config.get()
+
+            def _check_pins(c):
+                for value in c.values():
+                    if not isinstance(value, dict):
+                        continue
+                    for key, (
+                        _,
+                        _,
+                        pin_final_validate,
+                    ) in pins.PIN_SCHEMA_REGISTRY.items():
+                        if (
+                            key != CORE.target_platform
+                            and key in value
+                            and pin_final_validate is not None
+                        ):
+                            pin_final_validate(fconf, value)
+
+            # Check for pin configs and a final_validate schema in the pin registry
+            confs = conf
+            if not isinstance(
+                confs, list
+            ):  # Handle components like SPI that have a list instead of MULTI_CONF
+                confs = [conf]
+            for c in confs:
+                if c:  # Some component have None or empty schemas
+                    _check_pins(c)
 
         fv.full_config.reset(token)
 
diff --git a/esphome/const.py b/esphome/const.py
index 47eedc24b7..9457958863 100644
--- a/esphome/const.py
+++ b/esphome/const.py
@@ -53,6 +53,7 @@ CONF_AND = "and"
 CONF_AP = "ap"
 CONF_APPARENT_POWER = "apparent_power"
 CONF_ARDUINO_VERSION = "arduino_version"
+CONF_AREA = "area"
 CONF_ARGS = "args"
 CONF_ASSUMED_STATE = "assumed_state"
 CONF_AT = "at"
@@ -484,6 +485,8 @@ CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE = "on_ble_manufacturer_data_advertise"
 CONF_ON_BLE_SERVICE_DATA_ADVERTISE = "on_ble_service_data_advertise"
 CONF_ON_BOOT = "on_boot"
 CONF_ON_CLICK = "on_click"
+CONF_ON_CLIENT_CONNECTED = "on_client_connected"
+CONF_ON_CLIENT_DISCONNECTED = "on_client_disconnected"
 CONF_ON_CONNECT = "on_connect"
 CONF_ON_CONTROL = "on_control"
 CONF_ON_DISCONNECT = "on_disconnect"
diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py
index 52c58cb54a..0b597e0c9e 100644
--- a/esphome/core/__init__.py
+++ b/esphome/core/__init__.py
@@ -464,6 +464,8 @@ class EsphomeCore:
         self.name: Optional[str] = None
         # The friendly name of the node
         self.friendly_name: Optional[str] = None
+        # The area / zone of the node
+        self.area: Optional[str] = None
         # Additional data components can store temporary data in
         # The first key to this dict should always be the integration name
         self.data = {}
@@ -504,6 +506,7 @@ class EsphomeCore:
         self.dashboard = False
         self.name = None
         self.friendly_name = None
+        self.area = None
         self.data = {}
         self.config_path = None
         self.build_path = None
diff --git a/esphome/core/application.h b/esphome/core/application.h
index f2dbaa4db5..059e393912 100644
--- a/esphome/core/application.h
+++ b/esphome/core/application.h
@@ -59,8 +59,8 @@ namespace esphome {
 
 class Application {
  public:
-  void pre_setup(const std::string &name, const std::string &friendly_name, const char *comment,
-                 const char *compilation_time, bool name_add_mac_suffix) {
+  void pre_setup(const std::string &name, const std::string &friendly_name, const std::string &area,
+                 const char *comment, const char *compilation_time, bool name_add_mac_suffix) {
     arch_init();
     this->name_add_mac_suffix_ = name_add_mac_suffix;
     if (name_add_mac_suffix) {
@@ -74,6 +74,7 @@ class Application {
       this->name_ = name;
       this->friendly_name_ = friendly_name;
     }
+    this->area_ = area;
     this->comment_ = comment;
     this->compilation_time_ = compilation_time;
   }
@@ -160,6 +161,10 @@ class Application {
 
   /// Get the friendly name of this Application set by pre_setup().
   const std::string &get_friendly_name() const { return this->friendly_name_; }
+
+  /// Get the area of this Application set by pre_setup().
+  const std::string &get_area() const { return this->area_; }
+
   /// Get the comment of this Application set by pre_setup().
   std::string get_comment() const { return this->comment_; }
 
@@ -395,6 +400,7 @@ class Application {
 
   std::string name_;
   std::string friendly_name_;
+  std::string area_;
   const char *comment_{nullptr};
   const char *compilation_time_{nullptr};
   bool name_add_mac_suffix_;
diff --git a/esphome/core/config.py b/esphome/core/config.py
index 1625644092..e4a1fdcafa 100644
--- a/esphome/core/config.py
+++ b/esphome/core/config.py
@@ -8,6 +8,7 @@ import esphome.config_validation as cv
 from esphome import automation
 from esphome.const import (
     CONF_ARDUINO_VERSION,
+    CONF_AREA,
     CONF_BOARD,
     CONF_BOARD_FLASH_MODE,
     CONF_BUILD_PATH,
@@ -126,6 +127,7 @@ CONFIG_SCHEMA = cv.All(
         {
             cv.Required(CONF_NAME): cv.valid_name,
             cv.Optional(CONF_FRIENDLY_NAME, ""): cv.string,
+            cv.Optional(CONF_AREA, ""): cv.string,
             cv.Optional(CONF_COMMENT): cv.string,
             cv.Required(CONF_BUILD_PATH): cv.string,
             cv.Optional(CONF_PLATFORMIO_OPTIONS, default={}): cv.Schema(
@@ -350,6 +352,7 @@ async def to_code(config):
         cg.App.pre_setup(
             config[CONF_NAME],
             config[CONF_FRIENDLY_NAME],
+            config[CONF_AREA],
             config.get(CONF_COMMENT, ""),
             cg.RawExpression('__DATE__ ", " __TIME__'),
             config[CONF_NAME_ADD_MAC_SUFFIX],
diff --git a/esphome/core/defines.h b/esphome/core/defines.h
index 598b08063b..d4187d4c08 100644
--- a/esphome/core/defines.h
+++ b/esphome/core/defines.h
@@ -78,6 +78,7 @@
 #define USE_VOICE_ASSISTANT
 #define USE_MICROPHONE
 #define USE_SPEAKER
+#define USE_SPI
 
 #ifdef USE_ARDUINO
 #define USE_ARDUINO_VERSION_CODE VERSION_CODE(2, 0, 5)
@@ -97,9 +98,7 @@
 #define USE_HTTP_REQUEST_ESP8266_HTTPS
 #define USE_SOCKET_IMPL_LWIP_TCP
 
-#ifdef USE_LIBRETINY
-#define USE_SOCKET_IMPL_LWIP_SOCKETS
-#endif
+#define USE_SPI
 
 // Dummy firmware payload for shelly_dimmer
 #define USE_SHD_FIRMWARE_MAJOR_VERSION 56
@@ -112,6 +111,11 @@
 #ifdef USE_RP2040
 #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0)
 #define USE_SOCKET_IMPL_LWIP_TCP
+#define USE_SPI
+#endif
+
+#ifdef USE_LIBRETINY
+#define USE_SOCKET_IMPL_LWIP_SOCKETS
 #endif
 
 #ifdef USE_HOST
diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py
index cc53f491f5..4b3716e223 100644
--- a/esphome/cpp_helpers.py
+++ b/esphome/cpp_helpers.py
@@ -35,7 +35,7 @@ async def gpio_pin_expression(conf):
         return None
     from esphome import pins
 
-    for key, (func, _) in pins.PIN_SCHEMA_REGISTRY.items():
+    for key, (func, _, _) in pins.PIN_SCHEMA_REGISTRY.items():
         if key in conf:
             return await coroutine(func)(conf)
     return await coroutine(pins.PIN_SCHEMA_REGISTRY[CORE.target_platform][0])(conf)
diff --git a/esphome/pins.py b/esphome/pins.py
index cec715b922..0035bea4f0 100644
--- a/esphome/pins.py
+++ b/esphome/pins.py
@@ -11,10 +11,10 @@ from esphome.const import (
     CONF_PULLUP,
     CONF_IGNORE_STRAPPING_WARNING,
 )
-from esphome.util import SimpleRegistry
+from esphome.util import PinRegistry
 from esphome.core import CORE
 
-PIN_SCHEMA_REGISTRY = SimpleRegistry()
+PIN_SCHEMA_REGISTRY = PinRegistry()
 
 
 def _set_mode(value, default_mode):
diff --git a/esphome/util.py b/esphome/util.py
index 480618aca0..d9c8502e0e 100644
--- a/esphome/util.py
+++ b/esphome/util.py
@@ -57,6 +57,32 @@ class SimpleRegistry(dict):
         return decorator
 
 
+def _final_validate(parent_id_key, fun):
+    def validator(fconf, pin_config):
+        import esphome.config_validation as cv
+
+        parent_path = fconf.get_path_for_id(pin_config[parent_id_key])[:-1]
+        parent_config = fconf.get_config_for_path(parent_path)
+
+        pin_path = fconf.get_path_for_id(pin_config[const.CONF_ID])[:-1]
+        with cv.prepend_path([cv.ROOT_CONFIG_PATH] + pin_path):
+            fun(pin_config, parent_config)
+
+    return validator
+
+
+class PinRegistry(dict):
+    def register(self, name, schema, final_validate=None):
+        if final_validate is not None:
+            final_validate = _final_validate(name, final_validate)
+
+        def decorator(fun):
+            self[name] = (fun, schema, final_validate)
+            return fun
+
+        return decorator
+
+
 def safe_print(message="", end="\n"):
     from esphome.core import CORE
 
@@ -196,7 +222,7 @@ def run_external_command(
     try:
         sys.argv = list(cmd)
         sys.exit = mock_exit
-        return func() or 0
+        retval = func() or 0
     except KeyboardInterrupt:  # pylint: disable=try-except-raise
         raise
     except SystemExit as err:
@@ -212,9 +238,10 @@ def run_external_command(
         sys.stdout = orig_stdout
         sys.stderr = orig_stderr
 
-        if capture_stdout:
-            # pylint: disable=lost-exception
-            return cap_stdout.getvalue()
+    if capture_stdout:
+        return cap_stdout.getvalue()
+
+    return retval
 
 
 def run_external_process(*cmd, **kwargs):
diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py
index 8a03c431a7..3d3fa8c5b4 100644
--- a/esphome/yaml_util.py
+++ b/esphome/yaml_util.py
@@ -18,6 +18,7 @@ from esphome.core import (
     MACAddress,
     TimePeriod,
     DocumentRange,
+    CORE,
 )
 from esphome.helpers import add_class_to_obj
 from esphome.util import OrderedDict, filter_yaml_files
@@ -240,7 +241,18 @@ class ESPHomeLoader(yaml.SafeLoader):
 
     @_add_data_ref
     def construct_secret(self, node):
-        secrets = _load_yaml_internal(self._rel_path(SECRET_YAML))
+        try:
+            secrets = _load_yaml_internal(self._rel_path(SECRET_YAML))
+        except EsphomeError as e:
+            if self.name == CORE.config_path:
+                raise e
+            try:
+                main_config_dir = os.path.dirname(CORE.config_path)
+                main_secret_yml = os.path.join(main_config_dir, SECRET_YAML)
+                secrets = _load_yaml_internal(main_secret_yml)
+            except EsphomeError as er:
+                raise EsphomeError(f"{e}\n{er}") from er
+
         if node.value not in secrets:
             raise yaml.MarkedYAMLError(
                 f"Secret '{node.value}' not defined", node.start_mark
diff --git a/requirements.txt b/requirements.txt
index 630802ee74..b9b6708ff2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,14 +3,14 @@ PyYAML==6.0.1
 paho-mqtt==1.6.1
 colorama==0.4.6
 tornado==6.3.3
-tzlocal==5.1    # from time
+tzlocal==5.2    # from time
 tzdata>=2021.1  # from time
 pyserial==3.5
 platformio==6.1.11  # When updating platformio, also update Dockerfile
 esptool==4.6.2
 click==8.1.7
 esphome-dashboard==20230904.0
-aioesphomeapi==18.0.12
+aioesphomeapi==18.2.1
 zeroconf==0.119.0
 
 # esp-idf requires this, but doesn't bundle it by default
diff --git a/requirements_test.txt b/requirements_test.txt
index f8c66b5ea4..fade3cda3e 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -1,11 +1,11 @@
 pylint==2.17.6
 flake8==6.1.0  # also change in .pre-commit-config.yaml when updating
-black==23.10.0  # also change in .pre-commit-config.yaml when updating
+black==23.10.1  # also change in .pre-commit-config.yaml when updating
 pyupgrade==3.15.0  # also change in .pre-commit-config.yaml when updating
 pre-commit
 
 # Unit tests
-pytest==7.4.2
+pytest==7.4.3
 pytest-cov==4.1.0
 pytest-mock==3.12.0
 pytest-asyncio==0.21.1
diff --git a/tests/dummy_main.cpp b/tests/dummy_main.cpp
index 236b9f5fc2..da5c6d10d0 100644
--- a/tests/dummy_main.cpp
+++ b/tests/dummy_main.cpp
@@ -12,7 +12,7 @@
 using namespace esphome;
 
 void setup() {
-  App.pre_setup("livingroom", "LivingRoom", "comment", __DATE__ ", " __TIME__, false);
+  App.pre_setup("livingroom", "LivingRoom", "LivingRoomArea", "comment", __DATE__ ", " __TIME__, false);
   auto *log = new logger::Logger(115200, 512);  // NOLINT
   log->pre_setup();
   log->set_uart_selection(logger::UART_SELECTION_UART0);
diff --git a/tests/test1.yaml b/tests/test1.yaml
index 7ee1baaba5..f894f3fbe1 100644
--- a/tests/test1.yaml
+++ b/tests/test1.yaml
@@ -220,6 +220,16 @@ uart:
     baud_rate: 256000
     parity: NONE
     stop_bits: 1
+  - id: dfrobot_mmwave_uart
+    tx_pin: 14
+    rx_pin: 27
+    baud_rate: 115200
+  - id: ld2420_uart
+    tx_pin: 17
+    rx_pin: 16
+    baud_rate: 115200
+    parity: NONE
+    stop_bits: 1
   - id: gcja5_uart
     rx_pin: GPIO10
     parity: EVEN
@@ -340,6 +350,14 @@ optolink:
   device_info: Device Info
   state: Component state
 
+micronova:
+  enable_rx_pin: 4
+  uart_id: uart_0
+
+dfrobot_sen0395:
+  - id: mmwave
+    uart_id: dfrobot_mmwave_uart
+
 sensor:
   - platform: pmwcs3
     i2c_id: i2c_bus
@@ -1441,6 +1459,9 @@ sensor:
       still_energy:
         name: g8 still energy
 
+  - platform: ld2420
+    moving_distance:
+      name: "Moving distance (cm)"
   - platform: sen21231
     name: "Person Sensor"
     i2c_id: i2c_bus
@@ -1516,6 +1537,24 @@ sensor:
     field_strength_z:
       name: "Magnet Z"
       id: magnet_z
+  - platform: micronova
+    room_temperature:
+      name: Room Temperature
+    fumes_temperature:
+      name: Fumes Temperature
+    water_temperature:
+      name: Water temperature
+    water_pressure:
+      name: Water pressure
+    stove_power:
+      name: Stove Power
+    fan_speed:
+      fan_rpm_offset: 240
+      name: Fan RPM
+    memory_address_sensor:
+      memory_location: 0x20
+      memory_address: 0x7d
+      name: Adres sensor
 
 esp32_touch:
   setup_mode: false
@@ -1834,6 +1873,9 @@ binary_sensor:
   - platform: qwiic_pir
     i2c_id: i2c_bus
     name: "Qwiic PIR Motion Sensor"
+  - platform: dfrobot_sen0395
+    id: mmwave_detected_uart
+    dfrobot_sen0395_id: mmwave
 
 pca9685:
   frequency: 500
@@ -2707,6 +2749,9 @@ switch:
       name: "control ld2410 engineering mode"
     bluetooth:
       name: "control ld2410 bluetooth"
+  - platform: micronova
+    stove:
+      name: Stove on/off
 
 fan:
   - platform: binary
@@ -3193,6 +3238,7 @@ pcf8574:
 
 pca9554:
   - id: pca9554_hub
+    pin_count: 8
     address: 0x3F
     i2c_id: i2c_bus
 
@@ -3347,7 +3393,12 @@ rtttl:
 canbus:
   - platform: mcp2515
     id: mcp2515_can
-    cs_pin: GPIO17
+    cs_pin:
+      pca9554: pca9554_hub
+      number: 7
+      mode:
+        output: true
+      inverted: true
     can_id: 4
     bit_rate: 50kbps
     on_frame:
@@ -3501,6 +3552,12 @@ number:
         name: g8 move threshold
       still_threshold:
         name: g8 still threshold
+  - platform: micronova
+    thermostat_temperature:
+      name: Micronova Thermostaat
+      step: 1
+    power_level:
+      name: Micronova Power level
 
 select:
   - platform: template
@@ -3593,6 +3650,25 @@ button:
     name: Midea Power Inverse
     on_press:
       midea_ac.power_toggle:
+  - platform: template
+    name: Update Mmwave Sensor Settings
+    on_press:
+      - dfrobot_sen0395.settings:
+          id: mmwave
+          factory_reset: true
+          detection_segments:
+            - [0cm, 5m]
+            - 600cm
+            - !lambda |-
+              return 7;
+          output_latency:
+            delay_after_detect: 0s
+            delay_after_disappear: 0s
+          sensitivity: 6
+  - platform: template
+    name: Reset Mmwave Sensor
+    on_press:
+      - dfrobot_sen0395.reset:
   - platform: template
     name: Poller component suspend test
     on_press:
@@ -3620,11 +3696,21 @@ button:
     uart_id: uart_0
     name: UART button
     data: "Pressed\r\n"
+  - platform: micronova
+    custom_button:
+      name: Custom Micronova Button
+      memory_location: 0xA0
+      memory_address: 0x7D
+      memory_data: 0x0F
 
 ld2410:
   id: my_ld2410
   uart_id: ld2410_uart
 
+ld2420:
+  id: my_ld2420
+  uart_id: ld2420_uart
+
 lcd_menu:
   display_id: my_lcd_gpio
   mark_back: 0x5e
@@ -3724,3 +3810,4 @@ alarm_control_panel:
       then:
         - lambda: !lambda |-
             ESP_LOGD("TEST", "State change %s", alarm_control_panel_state_to_string(id(alarmcontrolpanel1)->get_state()));
+