From 2ad8d7d1c5b497c991db17df3434ffdd2f1fe5af Mon Sep 17 00:00:00 2001
From: Tomasz Duda <tomaszduda23@gmail.com>
Date: Sat, 6 Jul 2024 18:33:15 +0200
Subject: [PATCH] nrf52 core based on zephyr

---
 esphome/__main__.py                           |  83 ++++++-
 esphome/components/nrf52/__init__.py          | 107 +++++++++
 esphome/components/nrf52/boards_zephyr.py     |   6 +
 esphome/components/nrf52/const.py             |   1 +
 esphome/components/nrf52/gpio.py              |  78 ++++++
 esphome/components/nrf52/power.cpp            |  41 ++++
 esphome/components/zephyr/__init__.py         | 222 ++++++++++++++++++
 esphome/components/zephyr/const.py            |  12 +
 esphome/components/zephyr/core.cpp            |  53 +++++
 esphome/components/zephyr/gpio.cpp            | 120 ++++++++++
 esphome/components/zephyr/gpio.h              |  37 +++
 esphome/components/zephyr/pre_build.py.script |   4 +
 esphome/components/zephyr/preferences.cpp     | 155 ++++++++++++
 esphome/components/zephyr/preferences.h       |  13 +
 esphome/config_validation.py                  |   7 +
 esphome/const.py                              |   2 +
 esphome/core/__init__.py                      |   9 +
 esphome/core/config.py                        |   4 +
 esphome/core/helpers.cpp                      |  22 ++
 esphome/core/helpers.h                        |   8 +-
 esphome/writer.py                             |   4 +
 esphome/zephyr_tools.py                       | 173 ++++++++++++++
 requirements.txt                              |   4 +
 script/ci-custom.py                           |   1 +
 .../components/gpio/test.nrf52-adafruit.yaml  |  14 ++
 tests/components/gpio/test.nrf52-mcumgr.yaml  |  14 ++
 .../build_components_base.nrf52-adafruit.yaml |  15 ++
 .../build_components_base.nrf52-mcumgr.yaml   |  15 ++
 28 files changed, 1215 insertions(+), 9 deletions(-)
 create mode 100644 esphome/components/nrf52/__init__.py
 create mode 100644 esphome/components/nrf52/boards_zephyr.py
 create mode 100644 esphome/components/nrf52/const.py
 create mode 100644 esphome/components/nrf52/gpio.py
 create mode 100644 esphome/components/nrf52/power.cpp
 create mode 100644 esphome/components/zephyr/__init__.py
 create mode 100644 esphome/components/zephyr/const.py
 create mode 100644 esphome/components/zephyr/core.cpp
 create mode 100644 esphome/components/zephyr/gpio.cpp
 create mode 100644 esphome/components/zephyr/gpio.h
 create mode 100644 esphome/components/zephyr/pre_build.py.script
 create mode 100644 esphome/components/zephyr/preferences.cpp
 create mode 100644 esphome/components/zephyr/preferences.h
 create mode 100644 esphome/zephyr_tools.py
 create mode 100644 tests/components/gpio/test.nrf52-adafruit.yaml
 create mode 100644 tests/components/gpio/test.nrf52-mcumgr.yaml
 create mode 100644 tests/test_build_components/build_components_base.nrf52-adafruit.yaml
 create mode 100644 tests/test_build_components/build_components_base.nrf52-mcumgr.yaml

diff --git a/esphome/__main__.py b/esphome/__main__.py
index 5ff1a28ec7..11905a987a 100644
--- a/esphome/__main__.py
+++ b/esphome/__main__.py
@@ -6,6 +6,7 @@ import os
 import re
 import sys
 import time
+import asyncio
 from datetime import datetime
 
 import argcomplete
@@ -36,6 +37,7 @@ from esphome.const import (
     PLATFORM_RP2040,
     PLATFORM_RTL87XX,
     SECRETS_FILES,
+    PLATFORM_NRF52,
 )
 from esphome.core import CORE, EsphomeError, coroutine
 from esphome.helpers import indent, is_ip_address
@@ -47,6 +49,13 @@ from esphome.util import (
     get_serial_ports,
 )
 from esphome.log import color, setup_log, Fore
+from .zephyr_tools import (
+    logger_scan,
+    logger_connect,
+    smpmgr_scan,
+    smpmgr_upload,
+    is_mac_address,
+)
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -86,19 +95,59 @@ def choose_prompt(options, purpose: str = None):
 def choose_upload_log_host(
     default, check_default, show_ota, show_mqtt, show_api, purpose: str = None
 ):
+    try:
+        mcuboot = CORE.config["nrf52"]["bootloader"] == "mcuboot"
+    except KeyError:
+        mcuboot = False
+    try:
+        ble_logger = CORE.config["zephyr_ble_nus"]["log"]
+    except KeyError:
+        ble_logger = False
+    ota = "ota" in CORE.config
     options = []
+    prefix = ""
+    if mcuboot and show_ota and ota:
+        prefix = "mcumgr "
     for port in get_serial_ports():
-        options.append((f"{port.path} ({port.description})", port.path))
+        options.append(
+            (f"{prefix}{port.path} ({port.description})", f"{prefix}{port.path}")
+        )
     if default == "SERIAL":
         return choose_prompt(options, purpose=purpose)
-    if (show_ota and "ota" in CORE.config) or (show_api and "api" in CORE.config):
-        options.append((f"Over The Air ({CORE.address})", CORE.address))
-        if default == "OTA":
-            return CORE.address
+    if default == "PYOCD":
+        if not mcuboot:
+            raise EsphomeError("PYOCD for adafruit is not implemented")
+        options = [("pyocd", "PYOCD")]
+        return choose_prompt(options, purpose=purpose)
+    if not mcuboot:
+        if (show_ota and ota) or (show_api and "api" in CORE.config):
+            options.append((f"Over The Air ({CORE.address})", CORE.address))
+            if default == "OTA":
+                return CORE.address
+    elif show_ota and ota:
+        if default:
+            options.append((f"OTA over Bluetooth LE ({default})", f"mcumgr {default}"))
+            return choose_prompt(options, purpose=purpose)
+        ble_devices = asyncio.run(smpmgr_scan(CORE.config["esphome"]["name"]))
+        if len(ble_devices) == 0:
+            _LOGGER.warning("No OTA over Bluetooth LE service found!")
+        for device in ble_devices:
+            options.append(
+                (
+                    f"OTA over Bluetooth LE({device.address}) {device.name}",
+                    f"mcumgr {device.address}",
+                )
+            )
     if show_mqtt and CONF_MQTT in CORE.config:
         options.append((f"MQTT ({CORE.config['mqtt'][CONF_BROKER]})", "MQTT"))
         if default == "OTA":
             return "MQTT"
+    if "logging" == purpose and ble_logger and default is None:
+        ble_device = asyncio.run(logger_scan(CORE.config["esphome"]["name"]))
+        if ble_device:
+            options.append((f"Bluetooth LE logger ({ble_device})", ble_device.address))
+        else:
+            _LOGGER.warning("No logger over Bluetooth LE service found!")
     if default is not None:
         return default
     if check_default is not None and check_default in [opt[1] for opt in options]:
@@ -111,6 +160,8 @@ def get_port_type(port):
         return "SERIAL"
     if port == "MQTT":
         return "MQTT"
+    if is_mac_address(port):
+        return "BLE"
     return "NETWORK"
 
 
@@ -289,10 +340,11 @@ def upload_using_esptool(config, port, file):
     return run_esptool(115200)
 
 
-def upload_using_platformio(config, port):
+def upload_using_platformio(config, port, upload_args=None):
     from esphome import platformio_api
 
-    upload_args = ["-t", "upload", "-t", "nobuild"]
+    if upload_args is None:
+        upload_args = ["-t", "upload", "-t", "nobuild"]
     if port is not None:
         upload_args += ["--upload-port", port]
     return platformio_api.run_platformio_cli_run(config, CORE.verbose, *upload_args)
@@ -329,7 +381,19 @@ def upload_program(config, args, host):
         if CORE.target_platform in (PLATFORM_BK72XX, PLATFORM_RTL87XX):
             return upload_using_platformio(config, host)
 
-        return 1  # Unknown target platform
+        if CORE.target_platform in (PLATFORM_NRF52):
+            return upload_using_platformio(config, host, ["-t", "upload"])
+
+        raise EsphomeError(f"Unknown target platform: {CORE.target_platform}")
+
+    if host == "PYOCD":
+        print(CORE)
+        return upload_using_platformio(config, host, ["-t", "flash_pyocd"])
+    if host.startswith("mcumgr"):
+        firmware = os.path.abspath(
+            CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
+        )
+        return asyncio.run(smpmgr_upload(config, host.split(" ")[1], firmware))
 
     ota_conf = {}
     for ota_item in config.get(CONF_OTA, []):
@@ -389,6 +453,9 @@ def show_logs(config, args, port):
             config, args.topic, args.username, args.password, args.client_id
         )
 
+    if get_port_type(port) == "BLE":
+        return asyncio.run(logger_connect(port))
+
     raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)")
 
 
diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py
new file mode 100644
index 0000000000..6a8e7bc6ed
--- /dev/null
+++ b/esphome/components/nrf52/__init__.py
@@ -0,0 +1,107 @@
+import esphome.codegen as cg
+import esphome.config_validation as cv
+from esphome.const import (
+    CONF_BOARD,
+    KEY_CORE,
+    KEY_TARGET_FRAMEWORK,
+    KEY_TARGET_PLATFORM,
+    PLATFORM_NRF52,
+    CONF_TYPE,
+    CONF_FRAMEWORK,
+    CONF_PLATFORM_VERSION,
+)
+from esphome.core import CORE, coroutine_with_priority
+
+from esphome.components.zephyr import (
+    zephyr_set_core_data,
+    zephyr_to_code,
+)
+from esphome.components.zephyr.const import (
+    KEY_ZEPHYR,
+    KEY_BOOTLOADER,
+    BOOTLOADER_MCUBOOT,
+)
+from .boards_zephyr import BOARDS_ZEPHYR
+from .const import (
+    BOOTLOADER_ADAFRUIT,
+)
+
+# force import gpio to register pin schema
+from .gpio import nrf52_pin_to_code  # noqa
+
+AUTO_LOAD = ["zephyr"]
+
+
+def set_core_data(config):
+    zephyr_set_core_data(config)
+    CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_NRF52
+    CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR
+    return config
+
+
+BOOTLOADERS = [
+    BOOTLOADER_ADAFRUIT,
+    BOOTLOADER_MCUBOOT,
+]
+
+
+def _detect_bootloader(value):
+    value = value.copy()
+    bootloader = None
+
+    if (
+        value[CONF_BOARD] in BOARDS_ZEPHYR
+        and KEY_BOOTLOADER in BOARDS_ZEPHYR[value[CONF_BOARD]]
+    ):
+        bootloader = BOARDS_ZEPHYR[value[CONF_BOARD]][KEY_BOOTLOADER]
+
+    if KEY_BOOTLOADER not in value:
+        if bootloader is None:
+            bootloader = BOOTLOADER_MCUBOOT
+        value[KEY_BOOTLOADER] = bootloader
+    else:
+        if bootloader is not None and bootloader != value[KEY_BOOTLOADER]:
+            raise cv.Invalid(
+                f"{value[CONF_FRAMEWORK][CONF_TYPE]} does not support '{bootloader}' bootloader for {value[CONF_BOARD]}"
+            )
+    return value
+
+
+CONFIG_SCHEMA = cv.All(
+    cv.Schema(
+        {
+            cv.Required(CONF_BOARD): cv.string_strict,
+            cv.Optional(KEY_BOOTLOADER): cv.one_of(*BOOTLOADERS, lower=True),
+        }
+    ),
+    _detect_bootloader,
+    set_core_data,
+)
+
+
+@coroutine_with_priority(1000)
+async def to_code(config):
+    cg.add_platformio_option("board", config[CONF_BOARD])
+    cg.add_build_flag("-DUSE_NRF52")
+    cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
+    cg.add_define("ESPHOME_VARIANT", "NRF52")
+    conf = {CONF_PLATFORM_VERSION: "platformio/nordicnrf52@10.3.0"}
+    cg.add_platformio_option(CONF_FRAMEWORK, CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK])
+    cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
+    cg.add_platformio_option(
+        "platform_packages",
+        [
+            "platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf",
+            "platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng",
+        ],
+    )
+
+    if config[KEY_BOOTLOADER] == BOOTLOADER_ADAFRUIT:
+        # make sure that firmware.zip is created
+        # for Adafruit_nRF52_Bootloader
+        cg.add_platformio_option("board_upload.protocol", "nrfutil")
+        cg.add_platformio_option("board_upload.use_1200bps_touch", "true")
+        cg.add_platformio_option("board_upload.require_upload_port", "true")
+        cg.add_platformio_option("board_upload.wait_for_upload_port", "true")
+    #
+    zephyr_to_code(conf)
diff --git a/esphome/components/nrf52/boards_zephyr.py b/esphome/components/nrf52/boards_zephyr.py
new file mode 100644
index 0000000000..0d9e5453c4
--- /dev/null
+++ b/esphome/components/nrf52/boards_zephyr.py
@@ -0,0 +1,6 @@
+from esphome.components.zephyr.const import KEY_BOOTLOADER
+from .const import BOOTLOADER_ADAFRUIT
+
+BOARDS_ZEPHYR = {
+    "adafruit_itsybitsy_nrf52840": {KEY_BOOTLOADER: BOOTLOADER_ADAFRUIT},
+}
diff --git a/esphome/components/nrf52/const.py b/esphome/components/nrf52/const.py
new file mode 100644
index 0000000000..0497c12196
--- /dev/null
+++ b/esphome/components/nrf52/const.py
@@ -0,0 +1 @@
+BOOTLOADER_ADAFRUIT = "adafruit"
diff --git a/esphome/components/nrf52/gpio.py b/esphome/components/nrf52/gpio.py
new file mode 100644
index 0000000000..5b31d63b57
--- /dev/null
+++ b/esphome/components/nrf52/gpio.py
@@ -0,0 +1,78 @@
+from esphome import pins
+
+import esphome.codegen as cg
+import esphome.config_validation as cv
+from esphome.const import (
+    CONF_ID,
+    CONF_MODE,
+    CONF_INVERTED,
+    CONF_NUMBER,
+    CONF_ANALOG,
+)
+from esphome.components.zephyr.const import (
+    zephyr_ns,
+)
+
+GPIOPin = zephyr_ns.class_("ZephyrGPIOPin", cg.InternalGPIOPin)
+
+
+def _translate_pin(value):
+    if isinstance(value, dict) or value is None:
+        raise cv.Invalid(
+            "This variable only supports pin numbers, not full pin schemas "
+            "(with inverted and mode)."
+        )
+    if isinstance(value, int):
+        return value
+    try:
+        return int(value)
+    except ValueError:
+        pass
+    # e.g. P0.27
+    if len(value) >= len("P0.0") and value[0] == "P" and value[2] == ".":
+        return cv.int_(value[len("P")].strip()) * 32 + cv.int_(
+            value[len("P0.") :].strip()
+        )
+    raise cv.Invalid(f"Invalid pin: {value}")
+
+
+ADC_INPUTS = [
+    "AIN0",
+    "AIN1",
+    "AIN2",
+    "AIN3",
+    "AIN4",
+    "AIN5",
+    "AIN6",
+    "AIN7",
+    "VDD",
+    "VDDHDIV5",
+]
+
+
+def validate_gpio_pin(value):
+    if value in ADC_INPUTS:
+        return value
+    value = _translate_pin(value)
+    if value < 0 or value > (32 + 16):
+        raise cv.Invalid(f"NRF52: Invalid pin number: {value}")
+    return value
+
+
+NRF52_PIN_SCHEMA = cv.All(
+    pins.gpio_base_schema(
+        GPIOPin,
+        validate_gpio_pin,
+        modes=pins.GPIO_STANDARD_MODES + (CONF_ANALOG,),
+    ),
+)
+
+
+@pins.PIN_SCHEMA_REGISTRY.register("nrf52", NRF52_PIN_SCHEMA)
+async def nrf52_pin_to_code(config):
+    var = cg.new_Pvariable(config[CONF_ID])
+    num = config[CONF_NUMBER]
+    cg.add(var.set_pin(num))
+    cg.add(var.set_inverted(config[CONF_INVERTED]))
+    cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE])))
+    return var
diff --git a/esphome/components/nrf52/power.cpp b/esphome/components/nrf52/power.cpp
new file mode 100644
index 0000000000..88e5228aa4
--- /dev/null
+++ b/esphome/components/nrf52/power.cpp
@@ -0,0 +1,41 @@
+#ifdef USE_NRF52
+#include <zephyr/init.h>
+#include <hal/nrf_power.h>
+
+namespace esphome {
+namespace nrf52 {
+
+static int board_esphome_init(void) {
+  /* if the board is powered from USB
+   * (high voltage mode), GPIO output voltage is set to 1.8 volts by
+   * default and that is not enough to turn the green and blue LEDs on.
+   * Increase GPIO voltage to 3.3 volts.
+   */
+  if ((nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_HIGH) &&
+      ((NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) == (UICR_REGOUT0_VOUT_DEFAULT << UICR_REGOUT0_VOUT_Pos))) {
+    NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen << NVMC_CONFIG_WEN_Pos;
+    while (NRF_NVMC->READY == NVMC_READY_READY_Busy) {
+      ;
+    }
+
+    NRF_UICR->REGOUT0 =
+        (NRF_UICR->REGOUT0 & ~((uint32_t) UICR_REGOUT0_VOUT_Msk)) | (UICR_REGOUT0_VOUT_3V0 << UICR_REGOUT0_VOUT_Pos);
+
+    NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren << NVMC_CONFIG_WEN_Pos;
+    while (NRF_NVMC->READY == NVMC_READY_READY_Busy) {
+      ;
+    }
+    /* a reset is required for changes to take effect */
+    NVIC_SystemReset();
+  }
+
+  return 0;
+}
+}  // namespace nrf52
+}  // namespace esphome
+
+static int board_esphome_init(void) { return esphome::nrf52::board_esphome_init(); }
+
+SYS_INIT(board_esphome_init, PRE_KERNEL_1, CONFIG_KERNEL_INIT_PRIORITY_DEFAULT);
+
+#endif
diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py
new file mode 100644
index 0000000000..076bed0fd1
--- /dev/null
+++ b/esphome/components/zephyr/__init__.py
@@ -0,0 +1,222 @@
+import os
+from typing import Union
+import esphome.codegen as cg
+from esphome.core import CORE
+from esphome.helpers import (
+    write_file_if_changed,
+    copy_file_if_changed,
+)
+from esphome.const import (
+    CONF_BOARD,
+    KEY_NAME,
+)
+from .const import (
+    KEY_ZEPHYR,
+    KEY_PRJ_CONF,
+    KEY_OVERLAY,
+    zephyr_ns,
+    BOOTLOADER_MCUBOOT,
+    KEY_EXTRA_BUILD_FILES,
+    KEY_PATH,
+    KEY_BOOTLOADER,
+)
+
+
+AUTO_LOAD = ["preferences"]
+KEY_BOARD = "board"
+
+KEY_USER = "user"
+
+
+def zephyr_set_core_data(config):
+    CORE.data[KEY_ZEPHYR] = {}
+    CORE.data[KEY_ZEPHYR][KEY_BOARD] = config[CONF_BOARD]
+    CORE.data[KEY_ZEPHYR][KEY_PRJ_CONF] = {}
+    CORE.data[KEY_ZEPHYR][KEY_OVERLAY] = ""
+    CORE.data[KEY_ZEPHYR][KEY_USER] = {}
+    CORE.data[KEY_ZEPHYR][KEY_BOOTLOADER] = config[KEY_BOOTLOADER]
+    CORE.data[KEY_ZEPHYR][KEY_EXTRA_BUILD_FILES] = {}
+    return config
+
+
+PrjConfValueType = Union[bool, str, int]
+
+
+def zephyr_add_prj_conf(name: str, value: PrjConfValueType, required: bool = True):
+    """Set an zephyr prj conf value."""
+    if not name.startswith("CONFIG_"):
+        name = "CONFIG_" + name
+    if name in CORE.data[KEY_ZEPHYR][KEY_PRJ_CONF]:
+        old_value = CORE.data[KEY_ZEPHYR][KEY_PRJ_CONF][name]
+        if old_value[0] != value and old_value[1]:
+            raise ValueError(
+                f"{name} already set with value '{old_value[0]}', cannot set again to '{value}'"
+            )
+        if required:
+            CORE.data[KEY_ZEPHYR][KEY_PRJ_CONF][name] = (value, required)
+    else:
+        CORE.data[KEY_ZEPHYR][KEY_PRJ_CONF][name] = (value, required)
+
+
+def zephyr_add_user(key, value):
+    if key not in CORE.data[KEY_ZEPHYR][KEY_USER]:
+        CORE.data[KEY_ZEPHYR][KEY_USER][key] = []
+    CORE.data[KEY_ZEPHYR][KEY_USER][key] += [value]
+
+
+def zephyr_add_overlay(content):
+    CORE.data[KEY_ZEPHYR][KEY_OVERLAY] += content
+
+
+def add_extra_build_file(filename: str, path: str) -> bool:
+    """Add an extra build file to the project."""
+    if filename not in CORE.data[KEY_ZEPHYR][KEY_EXTRA_BUILD_FILES]:
+        CORE.data[KEY_ZEPHYR][KEY_EXTRA_BUILD_FILES][filename] = {
+            KEY_NAME: filename,
+            KEY_PATH: path,
+        }
+        return True
+    return False
+
+
+def add_extra_script(stage: str, filename: str, path: str):
+    """Add an extra script to the project."""
+    key = f"{stage}:{filename}"
+    if add_extra_build_file(filename, path):
+        cg.add_platformio_option("extra_scripts", [key])
+
+
+def zephyr_to_code(conf):
+    cg.add(zephyr_ns.setup_preferences())
+    cg.add_build_flag("-DUSE_ZEPHYR")
+    # build is done by west so bypass board checking in platformio
+    cg.add_platformio_option("boards_dir", CORE.relative_build_path("boards"))
+
+    # c++ support
+    zephyr_add_prj_conf("NEWLIB_LIBC", True)
+    zephyr_add_prj_conf("CONFIG_FPU", True)
+    zephyr_add_prj_conf("NEWLIB_LIBC_FLOAT_PRINTF", True)
+    zephyr_add_prj_conf("CPLUSPLUS", True)
+    zephyr_add_prj_conf("LIB_CPLUSPLUS", True)
+    # preferences
+    zephyr_add_prj_conf("SETTINGS", True)
+    zephyr_add_prj_conf("NVS", True)
+    zephyr_add_prj_conf("FLASH_MAP", True)
+    zephyr_add_prj_conf("CONFIG_FLASH", True)
+    # watchdog
+    zephyr_add_prj_conf("WATCHDOG", True)
+    zephyr_add_prj_conf("WDT_DISABLE_AT_BOOT", False)
+    # disable console
+    zephyr_add_prj_conf("UART_CONSOLE", False)
+    zephyr_add_prj_conf("CONSOLE", False, False)
+    # TODO move to nrf52
+    # use NFC pins as GPIO
+    zephyr_add_prj_conf("NFCT_PINS_AS_GPIOS", True)
+
+    add_extra_script(
+        "pre",
+        "pre_build.py",
+        os.path.join(os.path.dirname(__file__), "pre_build.py.script"),
+    )
+
+
+def _format_prj_conf_val(value: PrjConfValueType) -> str:
+    if isinstance(value, bool):
+        return "y" if value else "n"
+    if isinstance(value, int):
+        return str(value)
+    if isinstance(value, str):
+        return f'"{value}"'
+    raise ValueError
+
+
+def zephyr_add_cdc_acm(config, id):
+    zephyr_add_prj_conf("USB_DEVICE_STACK", True)
+    zephyr_add_prj_conf("USB_CDC_ACM", True)
+    # prevent device to go to susspend, without this communication stop working in python
+    # there should be a way to solve it
+    zephyr_add_prj_conf("USB_DEVICE_REMOTE_WAKEUP", False)
+    # prevent logging when buffer is full
+    zephyr_add_prj_conf("USB_CDC_ACM_LOG_LEVEL_WRN", True)
+    zephyr_add_overlay(
+        f"""
+&zephyr_udc0 {{
+    cdc_acm_uart{id}: cdc_acm_uart{id} {{
+        compatible = "zephyr,cdc-acm-uart";
+    }};
+}};
+"""
+    )
+
+
+# Called by writer.py
+def copy_files():
+    want_opts = CORE.data[KEY_ZEPHYR][KEY_PRJ_CONF]
+
+    prj_conf = (
+        "\n".join(
+            f"{name}={_format_prj_conf_val(value[0])}"
+            for name, value in sorted(want_opts.items())
+        )
+        + "\n"
+    )
+
+    write_file_if_changed(CORE.relative_build_path("zephyr/prj.conf"), prj_conf)
+
+    if CORE.data[KEY_ZEPHYR][KEY_USER]:
+        zephyr_add_overlay(
+            f"""
+/ {{
+    zephyr,user {{
+        {[f"{key} = {', '.join(value)};" for key, value in CORE.data[KEY_ZEPHYR][KEY_USER].items()][0]}
+}};
+}};"""
+        )
+
+    write_file_if_changed(
+        CORE.relative_build_path("zephyr/app.overlay"),
+        CORE.data[KEY_ZEPHYR][KEY_OVERLAY],
+    )
+
+    #     write_file_if_changed(
+    #         CORE.relative_build_path("zephyr/child_image/mcuboot.conf"),
+    #         """
+    # CONFIG_MCUBOOT_SERIAL=y
+    # CONFIG_BOOT_SERIAL_PIN_RESET=y
+    # CONFIG_UART_CONSOLE=n
+    # CONFIG_BOOT_SERIAL_ENTRANCE_GPIO=n
+    # CONFIG_BOOT_SERIAL_CDC_ACM=y
+    # CONFIG_UART_NRFX=n
+    # CONFIG_LOG=n
+    # CONFIG_ASSERT_VERBOSE=n
+    # CONFIG_BOOT_BANNER=n
+    # CONFIG_PRINTK=n
+    # CONFIG_CBPRINTF_LIBC_SUBSTS=n
+    # """,
+    #     )
+
+    if CORE.data[KEY_ZEPHYR][KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT:
+        fake_board_manifest = """
+{
+"frameworks": [
+    "zephyr"
+],
+"name": "esphome nrf52",
+"upload": {
+    "maximum_ram_size": 248832,
+    "maximum_size": 815104
+},
+"url": "https://esphome.io/",
+"vendor": "esphome"
+}
+"""
+        write_file_if_changed(
+            CORE.relative_build_path(f"boards/{CORE.data[KEY_ZEPHYR][KEY_BOARD]}.json"),
+            fake_board_manifest,
+        )
+
+    for _, file in CORE.data[KEY_ZEPHYR][KEY_EXTRA_BUILD_FILES].items():
+        copy_file_if_changed(
+            file[KEY_PATH],
+            CORE.relative_build_path(file[KEY_NAME]),
+        )
diff --git a/esphome/components/zephyr/const.py b/esphome/components/zephyr/const.py
new file mode 100644
index 0000000000..1f19d3d8a1
--- /dev/null
+++ b/esphome/components/zephyr/const.py
@@ -0,0 +1,12 @@
+import esphome.codegen as cg
+
+KEY_ZEPHYR = "zephyr"
+KEY_PRJ_CONF = "prj_conf"
+KEY_OVERLAY = "overlay"
+KEY_BOOTLOADER = "bootloader"
+KEY_EXTRA_BUILD_FILES = "extra_build_files"
+KEY_PATH = "path"
+
+BOOTLOADER_MCUBOOT = "mcuboot"
+
+zephyr_ns = cg.esphome_ns.namespace("zephyr")
diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp
new file mode 100644
index 0000000000..3ab29e5adc
--- /dev/null
+++ b/esphome/components/zephyr/core.cpp
@@ -0,0 +1,53 @@
+#ifdef USE_ZEPHYR
+
+#include <zephyr/kernel.h>
+#include <zephyr/drivers/watchdog.h>
+#include <zephyr/sys/reboot.h>
+
+namespace esphome {
+
+static int wdt_channel_id = -EINVAL;
+const device *wdt = nullptr;
+
+void yield() { ::k_yield(); }
+uint32_t millis() { return k_ticks_to_ms_floor32(k_uptime_ticks()); }
+void delay(uint32_t ms) { ::k_msleep(ms); }
+uint32_t micros() { return k_ticks_to_us_floor32(k_uptime_ticks()); }
+
+void arch_init() {
+  wdt = DEVICE_DT_GET(DT_ALIAS(watchdog0));
+
+  if (device_is_ready(wdt)) {
+    static wdt_timeout_cfg wdt_config{};
+    wdt_config.flags = WDT_FLAG_RESET_SOC;
+    wdt_config.window.max = 2000;
+    wdt_channel_id = wdt_install_timeout(wdt, &wdt_config);
+    if (wdt_channel_id >= 0) {
+      wdt_setup(wdt, WDT_OPT_PAUSE_HALTED_BY_DBG | WDT_OPT_PAUSE_IN_SLEEP);
+    }
+  }
+}
+
+void arch_feed_wdt() {
+  if (wdt_channel_id >= 0) {
+    wdt_feed(wdt, wdt_channel_id);
+  }
+}
+
+void arch_restart() { sys_reboot(SYS_REBOOT_COLD); }
+
+}  // namespace esphome
+
+void setup();
+void loop();
+
+int main() {
+  setup();
+  while (1) {
+    loop();
+    esphome::yield();
+  }
+  return 0;
+}
+
+#endif
diff --git a/esphome/components/zephyr/gpio.cpp b/esphome/components/zephyr/gpio.cpp
new file mode 100644
index 0000000000..ea33d96236
--- /dev/null
+++ b/esphome/components/zephyr/gpio.cpp
@@ -0,0 +1,120 @@
+#ifdef USE_ZEPHYR
+#include "gpio.h"
+#include "esphome/core/log.h"
+#include <zephyr/drivers/gpio.h>
+
+namespace esphome {
+namespace zephyr {
+
+static const char *const TAG = "zephyr";
+
+static int flags_to_mode(gpio::Flags flags, bool inverted, bool value) {
+  int ret = 0;
+  if (flags & gpio::FLAG_INPUT) {
+    ret |= GPIO_INPUT;
+  }
+  if (flags & gpio::FLAG_OUTPUT) {
+    ret |= GPIO_OUTPUT;
+    if (value != inverted) {
+      ret |= GPIO_OUTPUT_INIT_HIGH;
+    } else {
+      ret |= GPIO_OUTPUT_INIT_LOW;
+    }
+  }
+  if (flags & gpio::FLAG_PULLUP) {
+    ret |= GPIO_PULL_UP;
+  }
+  if (flags & gpio::FLAG_PULLDOWN) {
+    ret |= GPIO_PULL_DOWN;
+  }
+  if (flags & gpio::FLAG_OPEN_DRAIN) {
+    ret |= GPIO_OPEN_DRAIN;
+  }
+  return ret;
+}
+
+struct ISRPinArg {
+  uint8_t pin;
+  bool inverted;
+};
+
+ISRInternalGPIOPin ZephyrGPIOPin::to_isr() const {
+  auto *arg = new ISRPinArg{};
+  arg->pin = pin_;
+  arg->inverted = inverted_;
+  return ISRInternalGPIOPin((void *) arg);
+}
+
+void ZephyrGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const {
+  // TODO
+}
+
+void ZephyrGPIOPin::setup() {
+  const struct device *gpio = nullptr;
+  if (pin_ < 32) {
+#define GPIO0 DT_NODELABEL(gpio0)
+#if DT_NODE_HAS_STATUS(GPIO0, okay)
+    gpio = DEVICE_DT_GET(GPIO0);
+#else
+#error "gpio0 is disabled"
+#endif
+  } else {
+#define GPIO1 DT_NODELABEL(gpio1)
+#if DT_NODE_HAS_STATUS(GPIO1, okay)
+    gpio = DEVICE_DT_GET(GPIO1);
+#else
+#error "gpio1 is disabled"
+#endif
+  }
+  if (device_is_ready(gpio)) {
+    gpio_ = gpio;
+  } else {
+    ESP_LOGE(TAG, "gpio %u is not ready.", pin_);
+    return;
+  }
+  pin_mode(flags_);
+}
+
+void ZephyrGPIOPin::pin_mode(gpio::Flags flags) {
+  if (nullptr == gpio_) {
+    return;
+  }
+  gpio_pin_configure(gpio_, pin_ % 32, flags_to_mode(flags, inverted_, value_));
+}
+
+std::string ZephyrGPIOPin::dump_summary() const {
+  char buffer[32];
+  snprintf(buffer, sizeof(buffer), "GPIO%u", pin_);
+  return buffer;
+}
+
+bool ZephyrGPIOPin::digital_read() {
+  if (nullptr == gpio_) {
+    return false;
+  }
+  return bool(gpio_pin_get(gpio_, pin_ % 32) != inverted_);
+}
+
+void ZephyrGPIOPin::digital_write(bool value) {
+  // make sure that value is not ignored since it can be inverted e.g. on switch side
+  // that way init state should be correct
+  value_ = value;
+  if (nullptr == gpio_) {
+    return;
+  }
+  gpio_pin_set(gpio_, pin_ % 32, value != inverted_ ? 1 : 0);
+}
+void ZephyrGPIOPin::detach_interrupt() const {
+  // TODO
+}
+
+}  // namespace zephyr
+
+bool IRAM_ATTR ISRInternalGPIOPin::digital_read() {
+  // TODO
+  return false;
+}
+
+}  // namespace esphome
+
+#endif
diff --git a/esphome/components/zephyr/gpio.h b/esphome/components/zephyr/gpio.h
new file mode 100644
index 0000000000..7af424f360
--- /dev/null
+++ b/esphome/components/zephyr/gpio.h
@@ -0,0 +1,37 @@
+#pragma once
+
+#ifdef USE_ZEPHYR
+#include "esphome/core/hal.h"
+struct device;
+namespace esphome {
+namespace zephyr {
+
+class ZephyrGPIOPin : public InternalGPIOPin {
+ public:
+  void set_pin(uint8_t pin) { pin_ = pin; }
+  void set_inverted(bool inverted) { inverted_ = inverted; }
+  void set_flags(gpio::Flags flags) { flags_ = flags; }
+
+  void setup() override;
+  void pin_mode(gpio::Flags flags) override;
+  bool digital_read() override;
+  void digital_write(bool value) override;
+  std::string dump_summary() const override;
+  void detach_interrupt() const override;
+  ISRInternalGPIOPin to_isr() const override;
+  uint8_t get_pin() const override { return pin_; }
+  bool is_inverted() const override { return inverted_; }
+
+ protected:
+  void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override;
+  uint8_t pin_;
+  bool inverted_;
+  gpio::Flags flags_;
+  const device *gpio_ = nullptr;
+  bool value_ = false;
+};
+
+}  // namespace zephyr
+}  // namespace esphome
+
+#endif  // USE_ZEPHYR
diff --git a/esphome/components/zephyr/pre_build.py.script b/esphome/components/zephyr/pre_build.py.script
new file mode 100644
index 0000000000..3731fccf53
--- /dev/null
+++ b/esphome/components/zephyr/pre_build.py.script
@@ -0,0 +1,4 @@
+Import("env")
+
+board_config = env.BoardConfig()
+board_config.update("frameworks", ["arduino", "zephyr"])
diff --git a/esphome/components/zephyr/preferences.cpp b/esphome/components/zephyr/preferences.cpp
new file mode 100644
index 0000000000..b8c4be467c
--- /dev/null
+++ b/esphome/components/zephyr/preferences.cpp
@@ -0,0 +1,155 @@
+#ifdef USE_ZEPHYR
+
+#include "esphome/core/preferences.h"
+#include "esphome/core/log.h"
+#include <zephyr/settings/settings.h>
+
+namespace esphome {
+namespace zephyr {
+
+static const char *const TAG = "zephyr.preferences";
+
+#define ESPHOME_SETTINGS_KEY "esphome"
+
+class ZephyrPreferenceBackend : public ESPPreferenceBackend {
+ public:
+  ZephyrPreferenceBackend(uint32_t type) { this->type_ = type; }
+  ZephyrPreferenceBackend(uint32_t type, std::vector<uint8_t> &&data) : data(std::move(data)) { this->type_ = type; }
+
+  bool save(const uint8_t *data, size_t len) override {
+    this->data.resize(len);
+    std::memcpy(this->data.data(), data, len);
+    ESP_LOGVV(TAG, "save key: %u, len: %d", type_, len);
+    return true;
+  }
+
+  bool load(uint8_t *data, size_t len) override {
+    if (len != this->data.size()) {
+      ESP_LOGE(TAG, "size of setting key %s changed, from: %u, to: %u", get_key().c_str(), this->data.size(), len);
+      return false;
+    }
+    std::memcpy(data, this->data.data(), len);
+    ESP_LOGVV(TAG, "load key: %u, len: %d", type_, len);
+    return true;
+  }
+
+  const uint32_t get_type() const { return type_; }
+  const std::string get_key() const { return str_sprintf(ESPHOME_SETTINGS_KEY "/%" PRIx32, type_); }
+
+  std::vector<uint8_t> data;
+
+ protected:
+  uint32_t type_ = 0;
+};
+
+class ZephyrPreferences : public ESPPreferences {
+ public:
+  void open() {
+    int err = settings_subsys_init();
+    if (err) {
+      ESP_LOGE(TAG, "Failed to initialize settings subsystem, err: %d", err);
+      return;
+    }
+
+    static struct settings_handler settings_cb = {
+        .name = ESPHOME_SETTINGS_KEY,
+        .h_set = load_setting_,
+        .h_export = export_settings_,
+    };
+
+    err = settings_register(&settings_cb);
+    if (err) {
+      ESP_LOGE(TAG, "setting_register failed, err, %d", err);
+      return;
+    }
+
+    err = settings_load_subtree(ESPHOME_SETTINGS_KEY);
+    if (err) {
+      ESP_LOGE(TAG, "Cannot load settings, err: %d", err);
+      return;
+    }
+    ESP_LOGD(TAG, "Loaded %u settings.", backends_.size());
+  }
+
+  ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) override {
+    return make_preference(length, type);
+  }
+
+  ESPPreferenceObject make_preference(size_t length, uint32_t type) override {
+    for (auto backend : backends_) {
+      if (backend->get_type() == type) {
+        return ESPPreferenceObject(backend);
+      }
+    }
+    printf("type %u size %u\n", type, backends_.size());
+    auto *pref = new ZephyrPreferenceBackend(type);
+    ESP_LOGD(TAG, "Add new setting %s.", pref->get_key().c_str());
+    backends_.push_back(pref);
+    return ESPPreferenceObject(pref);
+  }
+
+  bool sync() override {
+    ESP_LOGD(TAG, "Save settings");
+    int err = settings_save();
+    if (err) {
+      ESP_LOGE(TAG, "Cannot save settings, err: %d", err);
+      return false;
+    }
+    return true;
+  }
+
+  bool reset() override {
+    ESP_LOGD(TAG, "Reset settings");
+    for (auto backend : backends_) {
+      // save empty delete data
+      backend->data.clear();
+    }
+    sync();
+    return true;
+  }
+
+ protected:
+  std::vector<ZephyrPreferenceBackend *> backends_;
+
+  static int load_setting_(const char *name, size_t len, settings_read_cb read_cb, void *cb_arg) {
+    auto type = parse_hex<uint32_t>(name);
+    if (!type.has_value()) {
+      std::string full_name(ESPHOME_SETTINGS_KEY);
+      full_name += "/";
+      full_name += name;
+      // Delete unusable keys. Otherwise it will stay in flash forever.
+      settings_delete(full_name.c_str());
+      return 1;
+    }
+    std::vector<uint8_t> data(len);
+    int err = read_cb(cb_arg, data.data(), len);
+
+    ESP_LOGD(TAG, "load setting, name: %s(%u), len %u, err %u", name, *type, len, err);
+    auto *pref = new ZephyrPreferenceBackend(*type, std::move(data));
+    static_cast<ZephyrPreferences *>(global_preferences)->backends_.push_back(pref);
+    return 0;
+  }
+
+  static int export_settings_(int (*cb)(const char *name, const void *value, size_t val_len)) {
+    for (auto backend : static_cast<ZephyrPreferences *>(global_preferences)->backends_) {
+      auto name = backend->get_key();
+      int err = cb(name.c_str(), backend->data.data(), backend->data.size());
+      ESP_LOGD(TAG, "save in flash, name %s, len %u, err %d", name.c_str(), backend->data.size(), err);
+    }
+    return 0;
+  }
+};
+
+void setup_preferences() {
+  auto *prefs = new ZephyrPreferences();
+  global_preferences = prefs;
+  prefs->open();
+}
+
+}  // namespace zephyr
+
+ESPPreferences *global_preferences;  // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
+
+}  // namespace esphome
+
+#endif
diff --git a/esphome/components/zephyr/preferences.h b/esphome/components/zephyr/preferences.h
new file mode 100644
index 0000000000..6a37e41b46
--- /dev/null
+++ b/esphome/components/zephyr/preferences.h
@@ -0,0 +1,13 @@
+#pragma once
+
+#ifdef USE_ZEPHYR
+
+namespace esphome {
+namespace zephyr {
+
+void setup_preferences();
+
+}  // namespace zephyr
+}  // namespace esphome
+
+#endif
diff --git a/esphome/config_validation.py b/esphome/config_validation.py
index 7259e3c062..b3a9a3a078 100644
--- a/esphome/config_validation.py
+++ b/esphome/config_validation.py
@@ -62,6 +62,7 @@ from esphome.const import (
     TYPE_LOCAL,
     VALID_SUBSTITUTIONS_CHARACTERS,
     __version__ as ESPHOME_VERSION,
+    PLATFORM_NRF52,
 )
 from esphome.core import (
     CORE,
@@ -604,8 +605,10 @@ def only_with_framework(frameworks):
 only_on_esp32 = only_on(PLATFORM_ESP32)
 only_on_esp8266 = only_on(PLATFORM_ESP8266)
 only_on_rp2040 = only_on(PLATFORM_RP2040)
+only_on_nrf52 = only_on(PLATFORM_NRF52)
 only_with_arduino = only_with_framework("arduino")
 only_with_esp_idf = only_with_framework("esp-idf")
+only_with_zephyr = only_with_framework("zephyr")
 
 
 # Adapted from:
@@ -1648,6 +1651,7 @@ class SplitDefault(Optional):
         bk72xx=vol.UNDEFINED,
         rtl87xx=vol.UNDEFINED,
         host=vol.UNDEFINED,
+        nrf52=vol.UNDEFINED,
     ):
         super().__init__(key)
         self._esp8266_default = vol.default_factory(esp8266)
@@ -1679,6 +1683,7 @@ class SplitDefault(Optional):
         self._bk72xx_default = vol.default_factory(bk72xx)
         self._rtl87xx_default = vol.default_factory(rtl87xx)
         self._host_default = vol.default_factory(host)
+        self._nrf52_default = vol.default_factory(nrf52)
 
     @property
     def default(self):
@@ -1721,6 +1726,8 @@ class SplitDefault(Optional):
             return self._rtl87xx_default
         if CORE.is_host:
             return self._host_default
+        if CORE.is_nrf52:
+            return self._nrf52_default
         raise NotImplementedError
 
     @default.setter
diff --git a/esphome/const.py b/esphome/const.py
index 543b1d00cc..1ac9c88eef 100644
--- a/esphome/const.py
+++ b/esphome/const.py
@@ -14,6 +14,7 @@ PLATFORM_HOST = "host"
 PLATFORM_BK72XX = "bk72xx"
 PLATFORM_RTL87XX = "rtl87xx"
 PLATFORM_LIBRETINY_OLDSTYLE = "libretiny"
+PLATFORM_NRF52 = "nrf52"
 
 TARGET_PLATFORMS = [
     PLATFORM_ESP32,
@@ -23,6 +24,7 @@ TARGET_PLATFORMS = [
     PLATFORM_BK72XX,
     PLATFORM_RTL87XX,
     PLATFORM_LIBRETINY_OLDSTYLE,
+    PLATFORM_NRF52,
 ]
 
 SOURCE_FILE_EXTENSIONS = {".cpp", ".hpp", ".h", ".c", ".tcc", ".ino"}
diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py
index f25891965a..3a70fd2c9a 100644
--- a/esphome/core/__init__.py
+++ b/esphome/core/__init__.py
@@ -21,6 +21,7 @@ from esphome.const import (
     PLATFORM_RTL87XX,
     PLATFORM_RP2040,
     PLATFORM_HOST,
+    PLATFORM_NRF52,
 )
 from esphome.coroutine import FakeAwaitable as _FakeAwaitable
 from esphome.coroutine import FakeEventLoop as _FakeEventLoop
@@ -661,6 +662,10 @@ class EsphomeCore:
     def is_host(self):
         return self.target_platform == PLATFORM_HOST
 
+    @property
+    def is_nrf52(self):
+        return self.target_platform == PLATFORM_NRF52
+
     @property
     def target_framework(self):
         return self.data[KEY_CORE][KEY_TARGET_FRAMEWORK]
@@ -673,6 +678,10 @@ class EsphomeCore:
     def using_esp_idf(self):
         return self.target_framework == "esp-idf"
 
+    @property
+    def using_zephyr(self):
+        return self.target_framework == "zephyr"
+
     def add_job(self, func, *args, **kwargs):
         self.event_loop.add_job(func, *args, **kwargs)
 
diff --git a/esphome/core/config.py b/esphome/core/config.py
index 80b731b905..e92855abbb 100644
--- a/esphome/core/config.py
+++ b/esphome/core/config.py
@@ -40,6 +40,7 @@ from esphome.const import (
 )
 from esphome.core import CORE, coroutine_with_priority
 from esphome.helpers import copy_file_if_changed, get_str_env, walk_files
+from esphome.components.zephyr import zephyr_add_prj_conf
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -361,6 +362,9 @@ async def to_code(config):
         )
     )
 
+    if CORE.using_zephyr:
+        zephyr_add_prj_conf("BT_DEVICE_NAME", config[CONF_NAME])
+
     CORE.add_job(_add_automations, config)
 
     cg.add_build_flag("-fno-exceptions")
diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp
index 7f040f855f..d47cbdc86f 100644
--- a/esphome/core/helpers.cpp
+++ b/esphome/core/helpers.cpp
@@ -10,6 +10,7 @@
 #include <cstdarg>
 #include <cstdio>
 #include <cstring>
+#include <strings.h>
 
 #ifdef USE_HOST
 #ifndef _WIN32
@@ -55,6 +56,10 @@
 #include <WiFi.h>  // for macAddress()
 #endif
 
+#ifdef USE_ZEPHYR
+#include <zephyr/random/rand32.h>
+#endif
+
 namespace esphome {
 
 static const char *const TAG = "helpers";
@@ -209,6 +214,8 @@ uint32_t random_uint32() {
   std::mt19937 rng(dev());
   std::uniform_int_distribution<uint32_t> dist(0, std::numeric_limits<uint32_t>::max());
   return dist(rng);
+#elif defined(USE_ZEPHYR)
+  return rand();
 #else
 #error "No random source available for this configuration."
 #endif
@@ -246,6 +253,9 @@ bool random_bytes(uint8_t *data, size_t len) {
   }
   fclose(fp);
   return true;
+#elif defined(USE_ZEPHYR)
+  sys_rand_get(data, len);
+  return true;
 #else
 #error "No random source available for this configuration."
 #endif
@@ -624,6 +634,11 @@ Mutex::Mutex() {}
 void Mutex::lock() {}
 bool Mutex::try_lock() { return true; }
 void Mutex::unlock() {}
+#elif defined(USE_ZEPHYR)
+Mutex::Mutex() { k_mutex_init(&handle_); }
+void Mutex::lock() { k_mutex_lock(&this->handle_, K_FOREVER); }
+bool Mutex::try_lock() { return k_mutex_lock(&this->handle_, K_NO_WAIT) == 0; }
+void Mutex::unlock() { k_mutex_unlock(&this->handle_); }
 #elif defined(USE_ESP32) || defined(USE_LIBRETINY)
 Mutex::Mutex() { handle_ = xSemaphoreCreateMutex(); }
 void Mutex::lock() { xSemaphoreTake(this->handle_, portMAX_DELAY); }
@@ -681,6 +696,13 @@ void get_mac_address_raw(uint8_t *mac) {  // NOLINT(readability-non-const-parame
   WiFi.macAddress(mac);
 #elif defined(USE_LIBRETINY)
   WiFi.macAddress(mac);
+#elif defined(USE_NRF52)
+  mac[0] = ((NRF_FICR->DEVICEADDR[1] & 0xFFFF) >> 8) | 0xC0;
+  mac[1] = NRF_FICR->DEVICEADDR[1] & 0xFFFF;
+  mac[2] = NRF_FICR->DEVICEADDR[0] >> 24;
+  mac[3] = NRF_FICR->DEVICEADDR[0] >> 16;
+  mac[4] = NRF_FICR->DEVICEADDR[0] >> 8;
+  mac[5] = NRF_FICR->DEVICEADDR[0];
 #else
 // this should be an error, but that messes with CI checks. #error No mac address method defined
 #endif
diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h
index b4ad22b083..cc8e2c6549 100644
--- a/esphome/core/helpers.h
+++ b/esphome/core/helpers.h
@@ -7,6 +7,8 @@
 #include <string>
 #include <type_traits>
 #include <vector>
+#include <limits>
+#include <array>
 
 #include "esphome/core/optional.h"
 
@@ -20,6 +22,8 @@
 #elif defined(USE_LIBRETINY)
 #include <FreeRTOS.h>
 #include <semphr.h>
+#elif defined(USE_ZEPHYR)
+#include <zephyr/kernel.h>
 #endif
 
 #define HOT __attribute__((hot))
@@ -552,7 +556,9 @@ class Mutex {
   Mutex &operator=(const Mutex &) = delete;
 
  private:
-#if defined(USE_ESP32) || defined(USE_LIBRETINY)
+#if defined(USE_ZEPHYR)
+  k_mutex handle_;
+#elif defined(USE_ESP32) || defined(USE_LIBRETINY)
   SemaphoreHandle_t handle_;
 #endif
 };
diff --git a/esphome/writer.py b/esphome/writer.py
index 3ad0e60d31..d5915cf812 100644
--- a/esphome/writer.py
+++ b/esphome/writer.py
@@ -310,6 +310,10 @@ def copy_src_tree():
                 CORE.relative_src_path("esphome.h"),
                 ESPHOME_H_FORMAT.format(include_s + '\n#include "pio_includes.h"'),
             )
+    elif CORE.using_zephyr:
+        from esphome.components.zephyr import copy_files
+
+        copy_files()
 
 
 def generate_defines_h():
diff --git a/esphome/zephyr_tools.py b/esphome/zephyr_tools.py
new file mode 100644
index 0000000000..d32418e403
--- /dev/null
+++ b/esphome/zephyr_tools.py
@@ -0,0 +1,173 @@
+import time
+import asyncio
+import logging
+import re
+from typing import Final
+from rich.pretty import pprint
+from bleak import BleakScanner, BleakClient
+from bleak.exc import BleakDeviceNotFoundError, BleakDBusError
+from smpclient.transport.ble import SMPBLETransport
+from smpclient.transport import SMPTransportDisconnected
+from smpclient.transport.serial import SMPSerialTransport
+from smpclient import SMPClient
+from smpclient.mcuboot import IMAGE_TLV, ImageInfo, TLVNotFound, MCUBootImageError
+from smpclient.requests.image_management import ImageStatesRead, ImageStatesWrite
+from smpclient.requests.os_management import ResetWrite
+from smpclient.generics import error, success
+from smp.exceptions import SMPBadStartDelimiter
+from esphome.espota2 import ProgressBar
+
+SMP_SERVICE_UUID = "8D53DC1D-1DB7-4CD3-868B-8A527460AA84"
+NUS_SERVICE_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"
+NUS_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"
+MAC_ADDRESS_PATTERN: Final = re.compile(
+    r"([0-9A-F]{2}[:]){5}[0-9A-F]{2}$", flags=re.IGNORECASE
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def is_mac_address(value):
+    return MAC_ADDRESS_PATTERN.match(value)
+
+
+async def logger_scan(name):
+    _LOGGER.info("Scanning bluetooth for %s...", name)
+    device = await BleakScanner.find_device_by_name(name)
+    return device
+
+
+async def logger_connect(host):
+    disconnected_event = asyncio.Event()
+
+    def handle_disconnect(client):
+        disconnected_event.set()
+
+    def handle_rx(_, data: bytearray):
+        print(data.decode("utf-8"), end="")
+
+    _LOGGER.info("Connecting %s...", host)
+    async with BleakClient(host, disconnected_callback=handle_disconnect) as client:
+        _LOGGER.info("Connected %s...", host)
+        try:
+            await client.start_notify(NUS_TX_CHAR_UUID, handle_rx)
+        except BleakDBusError as e:
+            _LOGGER.error("Bluetooth LE logger: %s", e)
+            disconnected_event.set()
+        await disconnected_event.wait()
+
+
+async def smpmgr_scan(name):
+    _LOGGER.info("Scanning bluetooth for %s...", name)
+    devices = []
+    for device in await BleakScanner.discover(service_uuids=[SMP_SERVICE_UUID]):
+        if device.name == name:
+            devices += [device]
+    return devices
+
+
+def get_image_tlv_sha256(file):
+    _LOGGER.info("Checking image: %s", str(file))
+    try:
+        image_info = ImageInfo.load_file(str(file))
+        pprint(image_info.header)
+        _LOGGER.debug(str(image_info))
+    except MCUBootImageError as e:
+        _LOGGER.error("Inspection of FW image failed: %s", e)
+        return None
+
+    try:
+        image_tlv_sha256 = image_info.get_tlv(IMAGE_TLV.SHA256)
+        _LOGGER.debug("IMAGE_TLV_SHA256: %s", image_tlv_sha256)
+    except TLVNotFound:
+        _LOGGER.error("Could not find IMAGE_TLV_SHA256 in image.")
+        return None
+    return image_tlv_sha256.value
+
+
+async def smpmgr_upload(config, host, firmware):
+    try:
+        return await smpmgr_upload_(config, host, firmware)
+    except SMPTransportDisconnected:
+        _LOGGER.error("%s was disconnected.", host)
+    return 1
+
+
+async def smpmgr_upload_(config, host, firmware):
+    image_tlv_sha256 = get_image_tlv_sha256(firmware)
+    if image_tlv_sha256 is None:
+        return 1
+
+    if is_mac_address(host):
+        smp_client = SMPClient(SMPBLETransport(), host)
+    else:
+        smp_client = SMPClient(SMPSerialTransport(), host)
+
+    _LOGGER.info("Connecting %s...", host)
+    try:
+        await smp_client.connect()
+    except BleakDeviceNotFoundError:
+        _LOGGER.error("Device %s not found", host)
+        return 1
+
+    _LOGGER.info("Connected %s...", host)
+
+    try:
+        image_state = await smp_client.request(ImageStatesRead(), 2.5)
+    except SMPBadStartDelimiter as e:
+        _LOGGER.error("mcumgr is not supported by device (%s)", e)
+        return 1
+
+    already_uploaded = False
+
+    if error(image_state):
+        _LOGGER.error(image_state)
+        return 1
+    if success(image_state):
+        if len(image_state.images) == 0:
+            _LOGGER.warning("No images on device!")
+        for image in image_state.images:
+            pprint(image)
+            if image.active and not image.confirmed:
+                _LOGGER.error("No free slot")
+                return 1
+            if image.hash == image_tlv_sha256:
+                if already_uploaded:
+                    _LOGGER.error("Both slots have the same image")
+                    return 1
+                if image.confirmed:
+                    _LOGGER.error("Image already confirmted")
+                    return 1
+                _LOGGER.warning("The same image already uploaded")
+                already_uploaded = True
+
+    if not already_uploaded:
+        with open(firmware, "rb") as file:
+            image = file.read()
+            file.close()
+            upload_size = len(image)
+            progress = ProgressBar()
+            progress.update(0)
+            try:
+                async for offset in smp_client.upload(image):
+                    progress.update(offset / upload_size)
+            finally:
+                progress.done()
+
+    _LOGGER.info("Mark image for testing")
+    r = await smp_client.request(ImageStatesWrite(hash=image_tlv_sha256), 1.0)
+
+    if error(r):
+        _LOGGER.error(r)
+        return 1
+
+    # give a chance to execute completion callback
+    time.sleep(1)
+    _LOGGER.info("Reset")
+    r = await smp_client.request(ResetWrite(), 1.0)
+
+    if error(r):
+        _LOGGER.error(r)
+        return 1
+
+    return 0
diff --git a/requirements.txt b/requirements.txt
index 0cbe5e7265..b860fc3104 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -27,3 +27,7 @@ pyparsing >= 3.0
 
 # For autocompletion
 argcomplete>=2.0.0
+
+# for mcumgr
+rich==13.7.0
+smpclient==3.2.0
diff --git a/script/ci-custom.py b/script/ci-custom.py
index 9a97d3e4a8..59f4b66425 100755
--- a/script/ci-custom.py
+++ b/script/ci-custom.py
@@ -540,6 +540,7 @@ def lint_relative_py_import(fname):
         "esphome/components/rp2040/core.cpp",
         "esphome/components/libretiny/core.cpp",
         "esphome/components/host/core.cpp",
+        "esphome/components/zephyr/core.cpp",
     ],
 )
 def lint_namespace(fname, content):
diff --git a/tests/components/gpio/test.nrf52-adafruit.yaml b/tests/components/gpio/test.nrf52-adafruit.yaml
new file mode 100644
index 0000000000..3ca285117d
--- /dev/null
+++ b/tests/components/gpio/test.nrf52-adafruit.yaml
@@ -0,0 +1,14 @@
+binary_sensor:
+  - platform: gpio
+    pin: 2
+    id: gpio_binary_sensor
+
+output:
+  - platform: gpio
+    pin: 3
+    id: gpio_output
+
+switch:
+  - platform: gpio
+    pin: 4
+    id: gpio_switch
diff --git a/tests/components/gpio/test.nrf52-mcumgr.yaml b/tests/components/gpio/test.nrf52-mcumgr.yaml
new file mode 100644
index 0000000000..3ca285117d
--- /dev/null
+++ b/tests/components/gpio/test.nrf52-mcumgr.yaml
@@ -0,0 +1,14 @@
+binary_sensor:
+  - platform: gpio
+    pin: 2
+    id: gpio_binary_sensor
+
+output:
+  - platform: gpio
+    pin: 3
+    id: gpio_output
+
+switch:
+  - platform: gpio
+    pin: 4
+    id: gpio_switch
diff --git a/tests/test_build_components/build_components_base.nrf52-adafruit.yaml b/tests/test_build_components/build_components_base.nrf52-adafruit.yaml
new file mode 100644
index 0000000000..c9733c9fbf
--- /dev/null
+++ b/tests/test_build_components/build_components_base.nrf52-adafruit.yaml
@@ -0,0 +1,15 @@
+esphome:
+  name: componenttestnrf52
+  friendly_name: $component_name
+
+nrf52:
+  board: adafruit_itsybitsy_nrf52840
+
+logger:
+  level: VERY_VERBOSE
+
+packages:
+  component_under_test: !include
+    file: $component_test_file
+    vars:
+      component_test_file: $component_test_file
diff --git a/tests/test_build_components/build_components_base.nrf52-mcumgr.yaml b/tests/test_build_components/build_components_base.nrf52-mcumgr.yaml
new file mode 100644
index 0000000000..04211ffdfe
--- /dev/null
+++ b/tests/test_build_components/build_components_base.nrf52-mcumgr.yaml
@@ -0,0 +1,15 @@
+esphome:
+  name: componenttestnrf52
+  friendly_name: $component_name
+
+nrf52:
+  board: adafruit_feather_nrf52840
+
+logger:
+  level: VERY_VERBOSE
+
+packages:
+  component_under_test: !include
+    file: $component_test_file
+    vars:
+      component_test_file: $component_test_file