diff --git a/CODEOWNERS b/CODEOWNERS index 491371f9f4..cb1be26a61 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -628,6 +628,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 86e2b771ab..c5a4288c07 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 524dc55a13..9f755a6eea 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, ) @@ -73,9 +74,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: @@ -86,9 +87,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 @@ -132,6 +133,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] @@ -222,13 +229,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. @@ -243,20 +243,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..54c04473e3 --- /dev/null +++ b/esphome/components/zephyr_pwm/output.py @@ -0,0 +1,177 @@ +from dataclasses import dataclass, field + +from esphome import 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_ALLOW_OTHER_USES, + 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"] +DOMAIN = "zephyr_pwm" + +zephyr_pwm_ns = cg.esphome_ns.namespace("zephyr_pwm") +ZephyrPWMChannel = zephyr_pwm_ns.class_( + "ZephyrPWMChannel", output.FloatOutput, cg.Component +) +validate_frequency = cv.All(cv.frequency, cv.float_range(min=3.815, max=1e7)) + + +def _pin_schema(value): + value = pins.internal_gpio_output_pin_schema(value) + if value.get(CONF_ALLOW_OTHER_USES, False): + raise cv.Invalid("allow_other_uses is not supported for zephyr_pwm pins") + return value + + +CONFIG_SCHEMA = cv.All( + output.FLOAT_OUTPUT_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.declare_id(ZephyrPWMChannel), + cv.Required(CONF_PIN): _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 + + +@dataclass +class PWMBlock: + id: int + period_ns: int + pins: list[int] + + +@dataclass +class ZephyrPWMData: + pwm_blocks: list[PWMBlock] = field(default_factory=list) + + +def _get_data() -> ZephyrPWMData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = ZephyrPWMData() + return CORE.data[DOMAIN] + + +def _allocate_blocks() -> None: + full_config = fv.full_config.get() + zephyr_pwm_conf = [ + cfg + for cfg in full_config.get(CONF_OUTPUT, []) + if cfg.get(CONF_PLATFORM) == DOMAIN + ] + + pwm_blocks: list[PWMBlock] = [] + for cfg in zephyr_pwm_conf: + pin_number = cfg[CONF_PIN][CONF_NUMBER] + period_ns = int(1e9 / cfg[CONF_FREQUENCY]) + pwm_block = next( + ( + block + for block in pwm_blocks + if block.period_ns == period_ns + and len(block.pins) < PWM_CHANNELS_PER_BLOCK + ), + None, + ) + if pwm_block is None: + if len(pwm_blocks) >= PWM_BLOCK_COUNT: + raise cv.Invalid( + f"Only {PWM_BLOCK_COUNT} PWM blocks with a distinct frequency and {PWM_CHANNELS_PER_BLOCK} channels each are supported by nrf52" + ) + pwm_block = PWMBlock(id=len(pwm_blocks), period_ns=period_ns, pins=[]) + pwm_blocks.append(pwm_block) + pwm_block.pins.append(pin_number) + + _get_data().pwm_blocks = pwm_blocks + + +def _final_validate(config: ConfigType) -> ConfigType: + _allocate_blocks() + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +def _overlay_pwm(): + pwm_blocks: list[PWMBlock] = _get_data().pwm_blocks + + assert CORE.is_nrf52 + + 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 in pwm_blocks + ) + + pinctls = [] + for block in pwm_blocks: + psels = ", ".join( + f"" + for channel_id, pin in enumerate(block.pins) + ) + pinctls.append(f""" + pwm{block.id}_default_custom: pwm{block.id}_default_custom {{ + group1 {{ + psels = {psels}; + }}; + }}; + pwm{block.id}_sleep_custom: pwm{block.id}_sleep_custom {{ + group1 {{ + psels = {psels}; + low-power-enable; + }}; + }};""") + + overlay_parts.append(f""" + &pinctrl {{ + {"\n".join(pinctls)} + }};""") + return "\n".join(overlay_parts) + + +async def to_code(config): + zephyr_add_prj_conf("PWM", True) + pin = config[CONF_PIN] + pwm_blocks: list[PWMBlock] = _get_data().pwm_blocks + pwm_block = next( + (block for block in pwm_blocks if pin[CONF_NUMBER] in block.pins), None + ) + channel_id = pwm_block.pins.index(pin[CONF_NUMBER]) + + zephyr_add_overlay_builder(_overlay_pwm) + + pin_inverted = pin.get(CONF_INVERTED, False) + var = cg.new_Pvariable( + config[CONF_ID], + cg.RawExpression(f"DEVICE_DT_GET_OR_NULL(DT_NODELABEL(pwm{pwm_block.id}))"), + channel_id, + pin_inverted, + pwm_block.period_ns, + ) + await cg.register_component(var, config) + await output.register_output(var, config) diff --git a/esphome/components/zephyr_pwm/zephyr_pwm.cpp b/esphome/components/zephyr_pwm/zephyr_pwm.cpp new file mode 100644 index 0000000000..aa1393388a --- /dev/null +++ b/esphome/components/zephyr_pwm/zephyr_pwm.cpp @@ -0,0 +1,39 @@ +#ifdef USE_ZEPHYR + +#include "zephyr_pwm.h" + +#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) { + uint32_t pulse_width_ns = state * this->period_ns_; + pwm_flags_t flags = this->pin_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..cfec0049a5 --- /dev/null +++ b/esphome/components/zephyr_pwm/zephyr_pwm.h @@ -0,0 +1,31 @@ +#pragma once + +#ifdef USE_ZEPHYR +#include "esphome/core/defines.h" +#include "esphome/components/output/float_output.h" + +#include + +namespace esphome::zephyr_pwm { + +class ZephyrPWMChannel : public output::FloatOutput, public Component { + public: + explicit ZephyrPWMChannel(const struct device *device, uint8_t channel, bool pin_inverted, uint32_t period_ns) + : device_(device), channel_(channel), pin_inverted_(pin_inverted), period_ns_(period_ns) {} + + 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 pin_inverted_; + uint32_t period_ns_; +}; +} // 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 9b738ebc81..9091b429f6 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: "", }