From 1310fe685a31ce653aa82d7179993154408999c3 Mon Sep 17 00:00:00 2001 From: Christoph Walcher Date: Sun, 17 May 2026 18:42:51 +0200 Subject: [PATCH] feat: add support for nrf52 pwm add tests update codeowners [pre-commit.ci lite] apply automatic fixes fix preproc if refactor to zephyr_pwm validate pwm channel limit Post copilot review support inverted & check min frequency typing for builder Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> protect parent Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> check max frequency support multiple blocks validate max pwm components fix test config refactor psels remove key_user --- CODEOWNERS | 1 + esphome/components/adc/sensor.py | 22 ++- esphome/components/zephyr/__init__.py | 37 ++-- esphome/components/zephyr/const.py | 2 +- esphome/components/zephyr_pwm/__init__.py | 1 + esphome/components/zephyr_pwm/output.py | 163 ++++++++++++++++++ esphome/components/zephyr_pwm/zephyr_pwm.cpp | 46 +++++ esphome/components/zephyr_pwm/zephyr_pwm.h | 65 +++++++ .../components/light/test.nrf52-adafruit.yaml | 34 ++-- tests/components/light/test.nrf52-mcumgr.yaml | 34 ++-- tests/components/zephyr_pwm/common.yaml | 9 + .../zephyr_pwm/test.nrf52-adafruit.yaml | 1 + tests/unit_tests/test_nrf52_upload.py | 4 +- 13 files changed, 356 insertions(+), 63 deletions(-) create mode 100644 esphome/components/zephyr_pwm/__init__.py create mode 100644 esphome/components/zephyr_pwm/output.py create mode 100644 esphome/components/zephyr_pwm/zephyr_pwm.cpp create mode 100644 esphome/components/zephyr_pwm/zephyr_pwm.h create mode 100644 tests/components/zephyr_pwm/common.yaml create mode 100644 tests/components/zephyr_pwm/test.nrf52-adafruit.yaml diff --git a/CODEOWNERS b/CODEOWNERS index b222c44214..07f36609d4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -619,6 +619,7 @@ esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68 esphome/components/xxtea/* @clydebarrow esphome/components/zephyr/* @tomaszduda23 esphome/components/zephyr_mcumgr/ota/* @tomaszduda23 +esphome/components/zephyr_pwm/* @wiomoc esphome/components/zhlt01/* @cfeenstra1024 esphome/components/zigbee/* @luar123 @tomaszduda23 esphome/components/zio_ultrasonic/* @kahrendt diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 09e09f0dc1..27442d1837 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -10,8 +10,8 @@ from esphome.components.esp32 import ( from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC from esphome.components.zephyr import ( zephyr_add_overlay, + zephyr_add_overlay_builder, zephyr_add_prj_conf, - zephyr_add_user, ) from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -113,6 +113,18 @@ CONFIG_SCHEMA = cv.All( CONF_ADC_CHANNEL_ID = "adc_channel_id" +def _overlay_io_channels(): + channel_count = CORE.data[CONF_ADC_CHANNEL_ID] + entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count)) + return f""" + / {{ + zephyr,user {{ + io-channels = {entries}; + }}; + }}; + """ + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -173,9 +185,8 @@ async def to_code(config): if isinstance(pin_number, int): GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()} pin_number = GPIO_TO_AIN[pin_number] - zephyr_add_user("io-channels", f"<&adc {channel_id}>") - zephyr_add_overlay( - f""" + zephyr_add_overlay_builder(_overlay_io_channels) + zephyr_add_overlay(f""" &adc {{ #address-cells = <1>; #size-cells = <0>; @@ -190,8 +201,7 @@ async def to_code(config): zephyr,oversampling = <8>; }}; }}; - """ - ) + """) FILTER_SOURCE_FILES = filter_source_files_from_platform( diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index d6c45a744c..9c0d10ba58 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from pathlib import Path import textwrap from typing import TypedDict @@ -16,10 +17,10 @@ from .const import ( KEY_EXTRA_BUILD_FILES, KEY_KCONFIG, KEY_OVERLAY, + KEY_OVERLAY_BUILDER, KEY_PM_STATIC, KEY_PRJ_CONF, KEY_SYSBUILD, - KEY_USER, KEY_ZEPHYR, zephyr_ns, ) @@ -54,9 +55,9 @@ class ZephyrData(TypedDict): overlay: dict[str, str] extra_build_files: dict[str, Path] pm_static: list[Section] - user: dict[str, list[str]] kconfig: str sysbuild: bool + overlay_builder: list[Callable[[], str]] def zephyr_set_core_data(config: ConfigType) -> None: @@ -67,9 +68,9 @@ def zephyr_set_core_data(config: ConfigType) -> None: overlay={ "": "", }, # set empty to make sure that overlay is cleared after config change + overlay_builder=[], extra_build_files={}, pm_static=[], - user={}, kconfig="", # When OTA is disabled, the image is built without a bootloader even if the # config says `bootloader: mcuboot`, so the image can be smaller. This was @@ -113,6 +114,12 @@ def zephyr_add_overlay(content: str, image: str = "") -> None: data[KEY_OVERLAY][image] += textwrap.dedent(content) +def zephyr_add_overlay_builder(func: Callable[[], str]) -> None: + data = zephyr_data() + if func not in data[KEY_OVERLAY_BUILDER]: + data[KEY_OVERLAY_BUILDER].append(func) + + def add_extra_build_file(filename: str, path: Path) -> bool: """Add an extra build file to the project.""" extra_build_files = zephyr_data()[KEY_EXTRA_BUILD_FILES] @@ -203,13 +210,6 @@ def zephyr_add_pm_static(sections: list[Section]) -> None: zephyr_data()[KEY_PM_STATIC].extend(sections) -def zephyr_add_user(key, value): - user = zephyr_data()[KEY_USER] - if key not in user: - user[key] = [] - user[key] += [value] - - def _write_file_if_changed_or_remove_when_empty(path: Path, content: str) -> bool: """Write content to path, or remove a stale file when content is empty. @@ -224,20 +224,9 @@ def _write_file_if_changed_or_remove_when_empty(path: Path, content: str) -> boo def copy_files() -> None: - user = zephyr_data()[KEY_USER] - if user: - entries = " ".join( - f"{key} = {', '.join(value)};" for key, value in user.items() - ) - zephyr_add_overlay( - f""" - / {{ - zephyr,user {{ - {entries} - }}; - }}; - """ - ) + for builder_func in zephyr_data()[KEY_OVERLAY_BUILDER]: + overlay_contents = builder_func() + zephyr_add_overlay(overlay_contents) changed = False diff --git a/esphome/components/zephyr/const.py b/esphome/components/zephyr/const.py index 497e5f3ce5..0bb8d33a1f 100644 --- a/esphome/components/zephyr/const.py +++ b/esphome/components/zephyr/const.py @@ -7,12 +7,12 @@ BOOTLOADER_MCUBOOT = "mcuboot" KEY_BOOTLOADER: Final = "bootloader" KEY_EXTRA_BUILD_FILES: Final = "extra_build_files" KEY_OVERLAY: Final = "overlay" +KEY_OVERLAY_BUILDER: Final = "overlay_builder" KEY_PM_STATIC: Final = "pm_static" KEY_KCONFIG: Final = "kconfig" KEY_PRJ_CONF: Final = "prj_conf" KEY_ZEPHYR = "zephyr" KEY_BOARD: Final = "board" -KEY_USER: Final = "user" KEY_SYSBUILD: Final = "sysbuild" zephyr_ns = cg.esphome_ns.namespace("zephyr") diff --git a/esphome/components/zephyr_pwm/__init__.py b/esphome/components/zephyr_pwm/__init__.py new file mode 100644 index 0000000000..4bcce84845 --- /dev/null +++ b/esphome/components/zephyr_pwm/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@wiomoc"] diff --git a/esphome/components/zephyr_pwm/output.py b/esphome/components/zephyr_pwm/output.py new file mode 100644 index 0000000000..daa9bf6a0f --- /dev/null +++ b/esphome/components/zephyr_pwm/output.py @@ -0,0 +1,163 @@ +from esphome import automation, pins +import esphome.codegen as cg +from esphome.components import output +from esphome.components.zephyr import zephyr_add_overlay_builder, zephyr_add_prj_conf +import esphome.config_validation as cv +from esphome.const import ( + CONF_FREQUENCY, + CONF_ID, + CONF_INVERTED, + CONF_NUMBER, + CONF_OUTPUT, + CONF_PIN, + CONF_PLATFORM, +) +from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +DEPENDENCIES = ["zephyr"] + +zephyr_pwm_ns = cg.esphome_ns.namespace("zephyr_pwm") +ZephyrPWMChannel = zephyr_pwm_ns.class_( + "ZephyrPWMChannel", output.FloatOutput, cg.Component +) +SetFrequencyAction = zephyr_pwm_ns.class_("SetFrequencyAction", automation.Action) +validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0, max=1e7)) + + +CONFIG_SCHEMA = cv.All( + output.FLOAT_OUTPUT_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.declare_id(ZephyrPWMChannel), + cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, + cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency, + } + ).extend(cv.COMPONENT_SCHEMA), + cv.only_on_nrf52, +) + +PWM_BLOCK_COUNT = 4 +PWM_CHANNELS_PER_BLOCK = 4 + + +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + zephyr_pwm_conf = [ + cfg + for cfg in full_config.get(CONF_OUTPUT, []) + if cfg.get(CONF_PLATFORM) == "zephyr_pwm" + ] + if zephyr_pwm_conf and len(zephyr_pwm_conf) > ( + PWM_BLOCK_COUNT * PWM_CHANNELS_PER_BLOCK + ): + raise cv.Invalid( + f"Only {PWM_BLOCK_COUNT * PWM_CHANNELS_PER_BLOCK} PWM outputs are supported by nrf52" + ) + + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + +CONF_ZEPHYR_PWM_PINS = "zephyr_pwm_pins" + + +def _overlay_pwm(): + pwm_pins = CORE.data[CONF_ZEPHYR_PWM_PINS] + + assert CORE.is_nrf52 + pwm_pins_by_block = [ + pwm_pins[i : i + PWM_CHANNELS_PER_BLOCK] + for i in range(0, len(pwm_pins), PWM_CHANNELS_PER_BLOCK) + ] + + overlay_parts = [] + + overlay_parts.extend( + f""" + &pwm{block_id} {{ + status = "okay"; + pinctrl-0 = <&pwm{block_id}_default_custom>; + pinctrl-1 = <&pwm{block_id}_sleep_custom>; + pinctrl-names = "default", "sleep"; + }};""" + for block_id in range(len(pwm_pins_by_block)) + ) + + psels_by_block = [ + ", ".join( + f"" + for channel_id, pin in enumerate(block_pwm_pins) + ) + for block_pwm_pins in pwm_pins_by_block + ] + pinctls = "\n".join( + f""" + pwm{block_id}_default_custom: pwm{block_id}_default_custom {{ + group1 {{ + psels = {block_psels}; + }}; + }}; + pwm{block_id}_sleep_custom: pwm{block_id}_sleep_custom {{ + group1 {{ + psels = {block_psels}; + low-power-enable; + }}; + }};""" + for block_id, block_psels in enumerate(psels_by_block) + ) + + overlay_parts.append(f""" + &pinctrl {{ + {pinctls} + }};""") + return "\n".join(overlay_parts) + + +async def to_code(config): + assert CORE.is_nrf52 + zephyr_add_prj_conf("PWM", True) + pin = config[CONF_PIN] + + CORE.data.setdefault(CONF_ZEPHYR_PWM_PINS, []) + pwm_pins = CORE.data[CONF_ZEPHYR_PWM_PINS] + pwm_id = len(pwm_pins) + assert pwm_id < (PWM_BLOCK_COUNT * PWM_CHANNELS_PER_BLOCK), ( + f"Only {PWM_BLOCK_COUNT * PWM_CHANNELS_PER_BLOCK} PWM outputs are supported by nrf52" + ) + + block_id = pwm_id // PWM_CHANNELS_PER_BLOCK + channel_id = pwm_id % PWM_CHANNELS_PER_BLOCK + pwm_pins.append(pin[CONF_NUMBER]) + zephyr_add_overlay_builder(_overlay_pwm) + + inverted = pin.get(CONF_INVERTED, False) + var = cg.new_Pvariable( + config[CONF_ID], + cg.RawExpression(f"DEVICE_DT_GET_OR_NULL(DT_NODELABEL(pwm{block_id}))"), + channel_id, + inverted, + ) + cg.add(var.set_frequency(config[CONF_FREQUENCY])) + await cg.register_component(var, config) + await output.register_output(var, config) + + +@automation.register_action( + "output.zephyr_pwm.set_frequency", + SetFrequencyAction, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(ZephyrPWMChannel), + cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency), + } + ), + synchronous=True, +) +async def zephyr_pwm_set_frequency_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) + cg.add(var.set_frequency(template_)) + return var diff --git a/esphome/components/zephyr_pwm/zephyr_pwm.cpp b/esphome/components/zephyr_pwm/zephyr_pwm.cpp new file mode 100644 index 0000000000..9ea6a3a5dc --- /dev/null +++ b/esphome/components/zephyr_pwm/zephyr_pwm.cpp @@ -0,0 +1,46 @@ +#ifdef USE_ZEPHYR + +#include "zephyr_pwm.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/macros.h" + +#include +#include + +namespace esphome::zephyr_pwm { + +static const char *const TAG = "zephyr_pwm"; + +void ZephyrPWMChannel::setup() { + if (!device_is_ready(this->device_)) { + ESP_LOGE(TAG, "pwm is not ready."); + this->mark_failed(); + return; + } +} + +void ZephyrPWMChannel::dump_config() { + ESP_LOGCONFIG(TAG, + "Zephyr PWM:\n" + " Channel: %u\n" + " Period: %u ns", + this->channel_, this->period_ns_); + LOG_FLOAT_OUTPUT(this); +} +void HOT ZephyrPWMChannel::write_state(float state) { + this->last_output_ = state; + + uint32_t pulse_width_ns = state * this->period_ns_; + pwm_flags_t flags = this->inverted_ ? PWM_POLARITY_INVERTED : PWM_POLARITY_NORMAL; + int err = pwm_set(this->device_, this->channel_, this->period_ns_, pulse_width_ns, flags); + if (err != 0) { + ESP_LOGE(TAG, "Failed to set PWM output: channel=%u, period=%u ns, pulse_width=%u ns, error=%d", this->channel_, + this->period_ns_, pulse_width_ns, err); + } +} + +} // namespace esphome::zephyr_pwm + +#endif // USE_ZEPHYR diff --git a/esphome/components/zephyr_pwm/zephyr_pwm.h b/esphome/components/zephyr_pwm/zephyr_pwm.h new file mode 100644 index 0000000000..4667483685 --- /dev/null +++ b/esphome/components/zephyr_pwm/zephyr_pwm.h @@ -0,0 +1,65 @@ +#pragma once + +#ifdef USE_ZEPHYR + +#include "esphome/components/output/float_output.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +#include + +namespace esphome::zephyr_pwm { + +class ZephyrPWMChannel : public output::FloatOutput, public Component { + public: + explicit ZephyrPWMChannel(const struct device *device, uint8_t channel, bool inverted) + : device_(device), channel_(channel), inverted_(inverted) {} + + bool set_frequency(float frequency) { + if (frequency < 1 || frequency > 1e7) { + return false; + } + this->period_ns_ = 1e9f / frequency; + return true; + } + /// Dynamically update frequency + void update_frequency(float frequency) override { + if (!this->set_frequency(frequency)) { + return; + } + this->write_state(this->last_output_); + } + + void setup() override; + void dump_config() override; + /// HARDWARE setup_priority + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + protected: + void write_state(float state) override; + + const struct device *device_; + uint8_t channel_; + bool inverted_; + uint32_t period_ns_{1000 * 1000}; // default to 1kHz + float last_output_{0.0}; +}; + +template class SetFrequencyAction : public Action { + public: + SetFrequencyAction(ZephyrPWMChannel *parent) : parent_(parent) {} + TEMPLATABLE_VALUE(float, frequency); + + void play(const Ts &...x) { + float freq = this->frequency_.value(x...); + this->parent_->update_frequency(freq); + } + + protected: + ZephyrPWMChannel *parent_; +}; + +} // namespace esphome::zephyr_pwm + +#endif // USE_ZEPHYR diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml index 60521b8088..08f5f39810 100644 --- a/tests/components/light/test.nrf52-adafruit.yaml +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -1,19 +1,23 @@ -esphome: - on_boot: - then: - - light.toggle: test_binary_light - output: - platform: gpio id: light_test_binary - pin: 0 + pin: 12 + - platform: zephyr_pwm + id: test_ledc_1 + pin: 13 + - platform: zephyr_pwm + id: test_ledc_2 + pin: + number: 14 + inverted: true + - platform: zephyr_pwm + id: test_ledc_3 + pin: 15 + - platform: zephyr_pwm + id: test_ledc_4 + pin: 16 + - platform: zephyr_pwm + id: test_ledc_5 + pin: 17 -light: - - platform: binary - id: test_binary_light - name: Binary Light - output: light_test_binary - effects: - - strobe: - on_state: - - logger.log: Binary light state changed +<<: !include common.yaml diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml index 60521b8088..08f5f39810 100644 --- a/tests/components/light/test.nrf52-mcumgr.yaml +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -1,19 +1,23 @@ -esphome: - on_boot: - then: - - light.toggle: test_binary_light - output: - platform: gpio id: light_test_binary - pin: 0 + pin: 12 + - platform: zephyr_pwm + id: test_ledc_1 + pin: 13 + - platform: zephyr_pwm + id: test_ledc_2 + pin: + number: 14 + inverted: true + - platform: zephyr_pwm + id: test_ledc_3 + pin: 15 + - platform: zephyr_pwm + id: test_ledc_4 + pin: 16 + - platform: zephyr_pwm + id: test_ledc_5 + pin: 17 -light: - - platform: binary - id: test_binary_light - name: Binary Light - output: light_test_binary - effects: - - strobe: - on_state: - - logger.log: Binary light state changed +<<: !include common.yaml diff --git a/tests/components/zephyr_pwm/common.yaml b/tests/components/zephyr_pwm/common.yaml new file mode 100644 index 0000000000..248499951e --- /dev/null +++ b/tests/components/zephyr_pwm/common.yaml @@ -0,0 +1,9 @@ +output: + - platform: zephyr_pwm + id: pwm_output_1 + pin: P0.02 + - platform: zephyr_pwm + id: pwm_output_2 + pin: + number: 10 + inverted: true diff --git a/tests/components/zephyr_pwm/test.nrf52-adafruit.yaml b/tests/components/zephyr_pwm/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/zephyr_pwm/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/unit_tests/test_nrf52_upload.py b/tests/unit_tests/test_nrf52_upload.py index a60e23a337..4945e4c705 100644 --- a/tests/unit_tests/test_nrf52_upload.py +++ b/tests/unit_tests/test_nrf52_upload.py @@ -13,9 +13,9 @@ from esphome.components.zephyr.const import ( KEY_EXTRA_BUILD_FILES, KEY_KCONFIG, KEY_OVERLAY, + KEY_OVERLAY_BUILDER, KEY_PM_STATIC, KEY_PRJ_CONF, - KEY_USER, KEY_ZEPHYR, ) import esphome.config_validation as cv @@ -53,9 +53,9 @@ def _setup_nrf52_core( KEY_BOOTLOADER: bootloader, KEY_PRJ_CONF: {}, KEY_OVERLAY: {"": ""}, + KEY_OVERLAY_BUILDER: [], KEY_EXTRA_BUILD_FILES: {}, KEY_PM_STATIC: [], - KEY_USER: {}, KEY_KCONFIG: "", }