Make PWM frequency and resolution configurable per endpoint #65

Merged
rob merged 5 commits from feat/per-endpoint-pwm into main 2026-09-22 06:48:50 +00:00
Collaborator

PwmConfig hardcoded 490 Hz and the SoC's max LEDC resolution for every LevelEndpoint. The bathroom extractor fan needs its own settings, so frequencyHz and resolutionBits are now fields on PwmConfig (defaulting to the old values), and LevelEndpoint's single-pin constructor is gone — the bathroom node, its only caller, now builds a full PwmConfig like the lighting node does. The extractor runs at 25 kHz/8-bit, above the audible range.

Tested with pio test -e native (new cases in test_pwm_config cover the default and an explicit override) and pio run -e node_bathroom -e node_lighting.

Closes CCS-LLA-25.

`PwmConfig` hardcoded 490 Hz and the SoC's max LEDC resolution for every `LevelEndpoint`. The bathroom extractor fan needs its own settings, so `frequencyHz` and `resolutionBits` are now fields on `PwmConfig` (defaulting to the old values), and `LevelEndpoint`'s single-pin constructor is gone — the bathroom node, its only caller, now builds a full `PwmConfig` like the lighting node does. The extractor runs at 25 kHz/8-bit, above the audible range. Tested with `pio test -e native` (new cases in `test_pwm_config` cover the default and an explicit override) and `pio run -e node_bathroom -e node_lighting`. Closes CCS-LLA-25.
Make PWM frequency and resolution configurable per endpoint
All checks were successful
Build and test / build (pull_request) Successful in 57s
Build and test / release (pull_request) Has been skipped
bf095ef701
Claude left a comment

Nothing blocking. One correctness gap worth a follow-up, rest is nits.

  1. The removed static_assert had a job the new code doesn't replace: nothing stops resolutionBits exceeding the SoC's LEDC timer width. defaultPwmResolutionBits is safe by construction, but an explicit value — the bathroom's 8-bit config today, anyone's tomorrow — isn't checked against SOC_LEDC_TIMER_BIT_WIDE_NUM anywhere. Both current configs are within range, so nothing is actually broken, but the failure mode the old comment called out ("asking LEDC for more bits than the timer has fails at runtime and leaves the circuit dark") is back, silently, for any future per-endpoint override. Worth a runtime check in begin() (#if defined(ARDUINO)) that clamps or asserts resolutionBits <= SOC_LEDC_TIMER_BIT_WIDE_NUM before calling ledcSetup.

  2. Related: in applyOutput(), pwmMax is uint32_t but duty is uint16_t. If resolutionBits ever exceeds 16, static_cast<uint16_t>(ease(...) * pwmMax + 0.5f) casts a float outside uint16_t range — undefined behaviour, not just truncation. Not reachable with today's configs (max 15 bits), but it's the same gap as (1): nothing stops a future config from hitting it, and this path is Arduino-only so UBSan in the native suite never exercises it.

Everything else checks out: duty/pwmMax arithmetic is correct for 8-bit and 15-bit resolutions as used today, defaultPwmResolutionBits stays host-buildable, PwmConfig's trailing new fields don't disturb the existing positional aggregate inits in node_lighting, the removed single-pin constructor has no other callers, and the spec doesn't mention PWM frequency/resolution so no update is needed there.

Nothing blocking. One correctness gap worth a follow-up, rest is nits. 1. The removed `static_assert` had a job the new code doesn't replace: nothing stops `resolutionBits` exceeding the SoC's LEDC timer width. `defaultPwmResolutionBits` is safe by construction, but an explicit value — the bathroom's 8-bit config today, anyone's tomorrow — isn't checked against `SOC_LEDC_TIMER_BIT_WIDE_NUM` anywhere. Both current configs are within range, so nothing is actually broken, but the failure mode the old comment called out ("asking LEDC for more bits than the timer has fails at runtime and leaves the circuit dark") is back, silently, for any future per-endpoint override. Worth a runtime check in `begin()` (`#if defined(ARDUINO)`) that clamps or asserts `resolutionBits <= SOC_LEDC_TIMER_BIT_WIDE_NUM` before calling `ledcSetup`. 2. Related: in `applyOutput()`, `pwmMax` is `uint32_t` but `duty` is `uint16_t`. If `resolutionBits` ever exceeds 16, `static_cast<uint16_t>(ease(...) * pwmMax + 0.5f)` casts a float outside `uint16_t` range — undefined behaviour, not just truncation. Not reachable with today's configs (max 15 bits), but it's the same gap as (1): nothing stops a future config from hitting it, and this path is Arduino-only so UBSan in the native suite never exercises it. Everything else checks out: duty/pwmMax arithmetic is correct for 8-bit and 15-bit resolutions as used today, `defaultPwmResolutionBits` stays host-buildable, `PwmConfig`'s trailing new fields don't disturb the existing positional aggregate inits in `node_lighting`, the removed single-pin constructor has no other callers, and the spec doesn't mention PWM frequency/resolution so no update is needed there.
Guard against an over-wide PWM resolution at runtime
All checks were successful
Build and test / build (pull_request) Successful in 57s
Build and test / release (pull_request) Has been skipped
da4355b02f
Claude left a comment
No description provided.
Both round-1 findings are closed by da4355b. 1. `begin()` now clamps `config_.resolutionBits` to `SOC_LEDC_TIMER_BIT_WIDE_NUM` before `ledcSetup`, under `#if defined(ARDUINO)`. Checked against the installed core's `soc_caps.h`: `SOC_LEDC_TIMER_BIT_WIDE_NUM` is 20 on ESP32, 14 on ESP32-C3, both well inside `uint8_t`/`int` promotion, so the comparison and assignment are sound on both SoCs this project targets. 2. `duty` and `pwmMax` are now both `uint32_t`, and `ledcWrite(uint8_t, uint32_t)` in `esp32-hal-ledc.h` takes that directly — confirmed against the installed core. `pwmMax = (1u << resolutionBits) - 1` tops out at `(1u << 20) - 1`, well inside float-to-uint32_t safe range, so the cast in `applyOutput()` is no longer UB for any resolution the SoC can report post-clamp. `begin()` clamps before its own trailing `applyOutput()` call, and `resolutionBits` has no other reader, so nothing observes the pre-clamp value. Verified: `pio test -e native` (437 cases), `pio run -e node_bathroom -e node_lighting`, `clang-format --dry-run --Werror` on the touched files. All clean. No new issues found. Nit, non-blocking: an over-wide `resolutionBits` is now silently clamped rather than surfaced anywhere (no log, no build failure). Consistent with the project's "hardware filtering is an optimisation, never correctness" / silent-ignore stance elsewhere, so not asking for a change — just flagging in case a future misconfigured node running at the wrong resolution is worth being noisier about.
Cut narrating and placeholder-rationale comments
All checks were successful
Build and test / build (pull_request) Successful in 58s
Build and test / release (pull_request) Has been skipped
807dc159fc
@ -39,1 +39,4 @@
constexpr uint8_t extractorPins[] = {D1};
ctrl::PwmConfig extractorConfig = {extractorPins, 0, false, ctrl::Ease::Linear, ctrl::Ease::Linear, 0, 0, 25000, 8};
Owner

Can the ease function and ease durations be left out from this initialiser? They should be the same as the default values in the declaration

Can the ease function and ease durations be left out from this initialiser? They should be the same as the default values in the declaration
@ -9,1 +14,4 @@
// Capped at 15 even on the classic ESP32's 20-bit LEDC, to match the previous generation of this system.
#if defined(ARDUINO)
constexpr uint8_t defaultPwmResolutionBits = SOC_LEDC_TIMER_BIT_WIDE_NUM < 15 ? SOC_LEDC_TIMER_BIT_WIDE_NUM : 15;
Owner

Create a ticket to remove this cap. Don't want to test it now, but this is a legacy hang over

Create a ticket to remove this cap. Don't want to test it now, but this is a legacy hang over
Use a designated initialiser for the extractor's PwmConfig
All checks were successful
Build and test / build (pull_request) Successful in 56s
Build and test / release (pull_request) Has been skipped
30832a9806
Author
Collaborator

Done — extractorConfig now uses a designated initialiser with only pins, frequencyHz and resolutionBits; fades and ease fall through to the defaults.

Done — extractorConfig now uses a designated initialiser with only `pins`, `frequencyHz` and `resolutionBits`; fades and ease fall through to the defaults.
Author
Collaborator

Ticket raised for removing the 15-bit cap: CCS-NR-29 in Node Runtime.

Ticket raised for removing the 15-bit cap: CCS-NR-29 in Node Runtime.
@ -51,2 +60,4 @@
uint16_t fadeInMs = 500;
uint16_t fadeOutMs = 1000;
uint32_t frequencyHz = 490;
Owner

Is there a way to set this to the default for this SOC? Then this 490 value should be set in the lighting node, as this is a values specific to that

Is there a way to set this to the default for this SOC? Then this 490 value should be set in the lighting node, as this is a values specific to that
Move the 490 Hz PWM frequency into the lighting node
All checks were successful
Build and test / build (pull_request) Successful in 54s
Build and test / release (pull_request) Has been skipped
38e91dd11e
Author
Collaborator

There's no SoC default for frequency — ESP-IDF's LEDC has none, and the Arduino core's own analogWrite uses 1 kHz — so the runtime default is now 1 kHz, and 490 Hz lives in the lighting node as pwmFrequencyHz.

There's no SoC default for frequency — ESP-IDF's LEDC has none, and the Arduino core's own analogWrite uses 1 kHz — so the runtime default is now 1 kHz, and 490 Hz lives in the lighting node as `pwmFrequencyHz`.
rob merged commit a11d1ac9db into main 2026-09-22 06:48:50 +00:00
rob deleted branch feat/per-endpoint-pwm 2026-09-22 06:48:50 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/CampervanControlSystems!65
No description provided.