summaryrefslogtreecommitdiff
path: root/src/hal/ledc.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 12:40:53 -0300
committerGabriel Schneider <[email protected]>2026-08-25 12:46:51 -0300
commitf5f8068fac59b4f16046c2022c2fc7c7e447ef4c (patch)
tree2731a3ed4e51cae09e184e25778eded5fc37d1f5 /src/hal/ledc.zig
downloadesp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.tar.gz
esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.zip
zig-p4: pure-Zig ESP32-P4 toolchain
build.zig generates the linker script and drives Zig's own LLD; tools/image.zig turns the ELF into a flashable image and tools/{rom,serial}.zig speak the mask ROM loader over the UART. No CMake, ninja, idf.py, esptool, or external linker. src/soc.zig is a comptime register model over ESP-IDF's own *_reg.h headers; src/hal/ adds peripheral sequences; src/io/ implements std.Io for the chip; src/oracle/ diffs this HAL against ESP-IDF's on the die.
Diffstat (limited to 'src/hal/ledc.zig')
-rw-r--r--src/hal/ledc.zig587
1 files changed, 587 insertions, 0 deletions
diff --git a/src/hal/ledc.zig b/src/hal/ledc.zig
new file mode 100644
index 0000000..62eacd8
--- /dev/null
+++ b/src/hal/ledc.zig
@@ -0,0 +1,587 @@
+//! LEDC: the LED PWM controller. Four timers, eight channels, and the first **shadow-register**
+//! peripheral in this HAL.
+//!
+//! Three things make LEDC different from everything else here, and all three are load-bearing.
+//!
+//! **1. Configuration is staged, then committed.** `LEDC_PARA_UP_CHn` (channel) and
+//! `LEDC_TIMERn_PARA_UP` (timer) are write-to-trigger bits: writing 1 copies the staged fields into
+//! the shadow registers the counter and comparators actually use, and the hardware clears the bit
+//! again by itself (`ledc_reg.h:42-47`, `:951-958`). Values written without a commit are visible in
+//! the register file and have no effect on the output. So every mutator here stages, and every
+//! commit is its own store - `commitChannel` / `commitTimer` - exactly as ESP-IDF's
+//! `ledc_ll_ls_channel_update` (ledc_ll.h:435-438) and `ledc_ll_ls_timer_update` (ledc_ll.h:286-290)
+//! do it.
+//!
+//! The commit store is a read-modify-write, and that is deliberate rather than sloppy: the commit
+//! bit shares its word with the staged fields it commits. `LEDC_PARA_UP_CH0` is bit 4 of
+//! `LEDC_CH0_CONF0_REG`, whose other fields are `TIMER_SEL`, `SIG_OUT_EN`, `IDLE_LV` and `OVF_*`, so
+//! a bare `writeRaw(1 << 4)` would erase the very configuration it was meant to commit. Compare
+//! `systimer.zig`'s `op.write(.{update.is(1)})`, which is a single whole-word store because
+//! `SYSTIMER_UNIT0_OP_REG` contains nothing else. The read-modify-write is safe here for the reason
+//! `mmio.zig` gives: `PARA_UP` is `WT`, it reads back 0, so the read half of the read-modify-write
+//! can never re-trigger an earlier commit. That is the difference between a self-clearing bit and a
+//! write-1-to-clear bit, and it is why LEDC does not need the interrupt-status treatment.
+//!
+//! **2. The divider is fixed point, Q10.8.** `LEDC_CLK_DIV_TIMERn` is an 18-bit field at [22:5]
+//! (`ledc_reg.h:921-928`) holding a divider with 8 fractional bits (`LEDC_LL_FRACTIONAL_BITS`,
+//! ledc_ll.h:30): bits [17:8] are the integer part, bits [7:0] the fraction, so the value 0x4E2
+//! means 1250/256 = 4.8828. The output frequency is
+//!
+//! f_pwm = f_src * 256 / (div * 2^duty_res)
+//!
+//! and `divisor()` below is ESP-IDF's arithmetic for the inverse, transcribed operation for
+//! operation from `esp_driver_ledc/src/ledc.c:468-497` - including the two places where it is
+//! surprising. See its comment.
+//!
+//! **3. On the P4 the clock mux left the peripheral.** `LEDC_CONF_REG.LEDC_APB_CLK_SEL` still exists
+//! in the register map and still documents an encoding (0: APB, 1: RC_FAST, 2: XTAL), and ESP-IDF's
+//! P4 LL never touches it: the real mux is `HP_SYS_CLKRST.PERI_CLK_CTRL22.REG_LEDC_CLK_SRC_SEL`,
+//! with a *different* encoding (0: XTAL, 1: RC_FAST, 2: PLL_DIV) - ledc_ll.h:223-242. Writing the
+//! in-block register would silently do nothing, and reading it back to check would silently agree.
+//! `ClockSource` below is the HP_SYS_CLKRST encoding.
+//!
+//! Gamma fade *ramps* are out of scope, but one gamma register is not optional: the P4 moved
+//! `DUTY_NUM`/`DUTY_CYCLE`/`DUTY_SCALE`/`DUTY_INC` out of `LEDC_CHn_CONF1_REG` - which on this die
+//! holds only `DUTY_START` - and into gamma RAM. A constant duty is therefore a degenerate one-step
+//! fade, and `setDuty` writes that single entry, which is what ESP-IDF's `ledc_duty_config` does for
+//! every plain duty change (ledc.c:263-280).
+
+const std = @import("std");
+const regs = @import("regs");
+const mmio = @import("mmio");
+const clkrst = @import("clkrst.zig");
+const gpio = @import("gpio.zig");
+
+const Reg = mmio.Reg;
+const Field = mmio.Field;
+
+/// Eight channels, four timers (`soc_caps.h:385-386`).
+pub const channel_count = 8;
+pub const timer_count = 4;
+
+/// The counter is 20 bits, so the duty resolution is at most 20 (`soc_caps.h:387`). The register
+/// field is five bits wide and will happily accept 21-31; the hardware will not.
+pub const max_duty_resolution = 20;
+
+/// Fractional bits in `LEDC_CLK_DIV_TIMERn` - `LEDC_LL_FRACTIONAL_BITS`, ledc_ll.h:30.
+pub const fractional_bits = 8;
+
+/// The divider must be at least 1.0 and must fit the field: ESP-IDF's `LEDC_IS_DIV_INVALID`
+/// (ledc.c:114) rejects anything `<= LEDC_LL_FRACTIONAL_MAX` or `> LEDC_TIMER_DIV_NUM_MAX`.
+pub const divisor_min: u32 = 1 << fractional_bits;
+pub const divisor_max: u32 = 0x3ffff;
+
+pub const Error = error{
+ /// The requested frequency cannot be reached from this source at this resolution: the divider
+ /// would be below 1.0 (frequency too high) or wider than 18 bits (frequency too low).
+ DividerOutOfRange,
+ DutyResolutionOutOfRange,
+};
+
+// ------------------------------------------------------------------------------------- registers
+
+// Five registers per channel, stride 0x14; two per timer, stride 0x08. Both strides come from a
+// second instance's macro rather than being assumed - see mmio.RegArray.
+const ch_conf0 = mmio.RegArray(regs.LEDC_CH0_CONF0_REG, regs.LEDC_CH1_CONF0_REG, channel_count);
+const ch_hpoint = mmio.RegArray(regs.LEDC_CH0_HPOINT_REG, regs.LEDC_CH1_HPOINT_REG, channel_count);
+const ch_duty = mmio.RegArray(regs.LEDC_CH0_DUTY_REG, regs.LEDC_CH1_DUTY_REG, channel_count);
+const ch_conf1 = mmio.RegArray(regs.LEDC_CH0_CONF1_REG, regs.LEDC_CH1_CONF1_REG, channel_count);
+const ch_duty_r = mmio.RegArray(regs.LEDC_CH0_DUTY_R_REG, regs.LEDC_CH1_DUTY_R_REG, channel_count);
+const ch_gamma_conf = mmio.RegArray(regs.LEDC_CH0_GAMMA_CONF_REG, regs.LEDC_CH1_GAMMA_CONF_REG, channel_count);
+// Gamma RAM: 16 entries per channel, so the per-channel stride is 0x40 and entry 0 is the base.
+const ch_gamma_range0 = mmio.RegArray(regs.LEDC_CH0_GAMMA_RANGE0_REG, regs.LEDC_CH1_GAMMA_RANGE0_REG, channel_count);
+const tim_conf = mmio.RegArray(regs.LEDC_TIMER0_CONF_REG, regs.LEDC_TIMER1_CONF_REG, timer_count);
+const tim_value = mmio.RegArray(regs.LEDC_TIMER0_VALUE_REG, regs.LEDC_TIMER1_VALUE_REG, timer_count);
+
+// Field geometry is taken from instance 0 and reused for every instance, which is only sound if the
+// instances agree; the comptime block below checks the ends of both ranges against instance 0. That
+// is not paranoia about the silicon, it is paranoia about the macro names: `LEDC_CLK_DIV_TIMER0` and
+// `LEDC_TIMER0_DUTY_RES` put the instance number in different places, and picking up
+// `LEDC_TIMER1_DUTY_RES_S` while meaning timer 0's shift is a one-character mistake.
+const timer_sel = Field.of(regs.LEDC_TIMER_SEL_CH0_S, regs.LEDC_TIMER_SEL_CH0_V);
+const sig_out_en = Field.of(regs.LEDC_SIG_OUT_EN_CH0_S, regs.LEDC_SIG_OUT_EN_CH0_V);
+const idle_lv = Field.of(regs.LEDC_IDLE_LV_CH0_S, regs.LEDC_IDLE_LV_CH0_V);
+const ch_para_up = Field.of(regs.LEDC_PARA_UP_CH0_S, regs.LEDC_PARA_UP_CH0_V);
+const hpoint = Field.of(regs.LEDC_HPOINT_CH0_S, regs.LEDC_HPOINT_CH0_V);
+const duty = Field.of(regs.LEDC_DUTY_CH0_S, regs.LEDC_DUTY_CH0_V);
+const duty_r = Field.of(regs.LEDC_DUTY_CH0_R_S, regs.LEDC_DUTY_CH0_R_V);
+const duty_start = Field.of(regs.LEDC_DUTY_START_CH0_S, regs.LEDC_DUTY_START_CH0_V);
+const gamma_entry_num = Field.of(regs.LEDC_CH0_GAMMA_ENTRY_NUM_S, regs.LEDC_CH0_GAMMA_ENTRY_NUM_V);
+const gamma_duty_inc = Field.of(regs.LEDC_CH0_GAMMA_RANGE0_DUTY_INC_S, regs.LEDC_CH0_GAMMA_RANGE0_DUTY_INC_V);
+const gamma_duty_cycle = Field.of(regs.LEDC_CH0_GAMMA_RANGE0_DUTY_CYCLE_S, regs.LEDC_CH0_GAMMA_RANGE0_DUTY_CYCLE_V);
+const gamma_scale = Field.of(regs.LEDC_CH0_GAMMA_RANGE0_SCALE_S, regs.LEDC_CH0_GAMMA_RANGE0_SCALE_V);
+const gamma_duty_num = Field.of(regs.LEDC_CH0_GAMMA_RANGE0_DUTY_NUM_S, regs.LEDC_CH0_GAMMA_RANGE0_DUTY_NUM_V);
+
+const duty_res = Field.of(regs.LEDC_TIMER0_DUTY_RES_S, regs.LEDC_TIMER0_DUTY_RES_V);
+const clk_div = Field.of(regs.LEDC_CLK_DIV_TIMER0_S, regs.LEDC_CLK_DIV_TIMER0_V);
+const tim_pause = Field.of(regs.LEDC_TIMER0_PAUSE_S, regs.LEDC_TIMER0_PAUSE_V);
+const tim_rst = Field.of(regs.LEDC_TIMER0_RST_S, regs.LEDC_TIMER0_RST_V);
+const tim_para_up = Field.of(regs.LEDC_TIMER0_PARA_UP_S, regs.LEDC_TIMER0_PARA_UP_V);
+
+comptime {
+ const same = struct {
+ fn check(comptime what: []const u8, comptime a: Field, comptime b: Field) void {
+ if (a.shift != b.shift or a.width != b.width) @compileError(
+ "the per-instance " ++ what ++ " macros disagree on bit position or width; " ++
+ "this file must index the field per instance instead of reusing instance 0's",
+ );
+ }
+ }.check;
+ // Channels: 1 and 7, the two ends of the range beyond instance 0.
+ same("LEDC_TIMER_SEL_CHn", timer_sel, Field.of(regs.LEDC_TIMER_SEL_CH1_S, regs.LEDC_TIMER_SEL_CH1_V));
+ same("LEDC_TIMER_SEL_CHn", timer_sel, Field.of(regs.LEDC_TIMER_SEL_CH7_S, regs.LEDC_TIMER_SEL_CH7_V));
+ same("LEDC_SIG_OUT_EN_CHn", sig_out_en, Field.of(regs.LEDC_SIG_OUT_EN_CH7_S, regs.LEDC_SIG_OUT_EN_CH7_V));
+ same("LEDC_IDLE_LV_CHn", idle_lv, Field.of(regs.LEDC_IDLE_LV_CH7_S, regs.LEDC_IDLE_LV_CH7_V));
+ same("LEDC_PARA_UP_CHn", ch_para_up, Field.of(regs.LEDC_PARA_UP_CH7_S, regs.LEDC_PARA_UP_CH7_V));
+ same("LEDC_HPOINT_CHn", hpoint, Field.of(regs.LEDC_HPOINT_CH7_S, regs.LEDC_HPOINT_CH7_V));
+ same("LEDC_DUTY_CHn", duty, Field.of(regs.LEDC_DUTY_CH7_S, regs.LEDC_DUTY_CH7_V));
+ same("LEDC_DUTY_START_CHn", duty_start, Field.of(regs.LEDC_DUTY_START_CH7_S, regs.LEDC_DUTY_START_CH7_V));
+ same("LEDC_CHn_GAMMA_ENTRY_NUM", gamma_entry_num, Field.of(regs.LEDC_CH7_GAMMA_ENTRY_NUM_S, regs.LEDC_CH7_GAMMA_ENTRY_NUM_V));
+ same("LEDC_CHn_GAMMA_RANGE0_SCALE", gamma_scale, Field.of(regs.LEDC_CH7_GAMMA_RANGE0_SCALE_S, regs.LEDC_CH7_GAMMA_RANGE0_SCALE_V));
+ // Timers: 1 and 3.
+ same("LEDC_TIMERn_DUTY_RES", duty_res, Field.of(regs.LEDC_TIMER1_DUTY_RES_S, regs.LEDC_TIMER1_DUTY_RES_V));
+ same("LEDC_TIMERn_DUTY_RES", duty_res, Field.of(regs.LEDC_TIMER3_DUTY_RES_S, regs.LEDC_TIMER3_DUTY_RES_V));
+ same("LEDC_CLK_DIV_TIMERn", clk_div, Field.of(regs.LEDC_CLK_DIV_TIMER3_S, regs.LEDC_CLK_DIV_TIMER3_V));
+ same("LEDC_TIMERn_PAUSE", tim_pause, Field.of(regs.LEDC_TIMER3_PAUSE_S, regs.LEDC_TIMER3_PAUSE_V));
+ same("LEDC_TIMERn_RST", tim_rst, Field.of(regs.LEDC_TIMER3_RST_S, regs.LEDC_TIMER3_RST_V));
+ same("LEDC_TIMERn_PARA_UP", tim_para_up, Field.of(regs.LEDC_TIMER3_PARA_UP_S, regs.LEDC_TIMER3_PARA_UP_V));
+
+ // `LEDC_TIMER_DIV_NUM_MAX` (ledc.c:110) is a literal in the driver; it should be the field's
+ // own mask, and if a future die widens the field this is where the two part company.
+ if (divisor_max != clk_div.max()) @compileError(
+ "divisor_max no longer matches LEDC_CLK_DIV_TIMERn's width",
+ );
+ // The eight output signals must be consecutive for `signalIndex` to be arithmetic.
+ if (regs.LEDC_LS_SIG_OUT_PAD_OUT7_IDX - regs.LEDC_LS_SIG_OUT_PAD_OUT0_IDX != channel_count - 1)
+ @compileError("the LEDC output signal indices are not consecutive; signalIndex must be a table");
+}
+
+// ------------------------------------------------------------------------------ clocks and reset
+
+/// LEDC's function clock, in HP_SYS_CLKRST rather than in the peripheral (ledc_ll.h:179, :241).
+/// Shared with RMT's fields, hence the interrupt-masked read-modify-write.
+const peri_clk_ctrl22 = Reg.at(regs.HP_SYS_CLKRST_PERI_CLK_CTRL22_REG);
+const clk_src_sel = Field.of(regs.HP_SYS_CLKRST_REG_LEDC_CLK_SRC_SEL_S, regs.HP_SYS_CLKRST_REG_LEDC_CLK_SRC_SEL_V);
+const func_clk_en = Field.of(regs.HP_SYS_CLKRST_REG_LEDC_CLK_EN_S, regs.HP_SYS_CLKRST_REG_LEDC_CLK_EN_V);
+
+/// The four timers' shared source. Encoding from `ledc_ll_set_slow_clk_sel` (ledc_ll.h:223-242) -
+/// *not* the encoding `LEDC_CONF_REG.APB_CLK_SEL` documents, which is a different register on a
+/// different block and is dead on this die.
+pub const ClockSource = enum(u2) {
+ /// 40 MHz on this board (`clk_tree_defs.h:145`).
+ xtal = 0,
+ /// The internal RC oscillator: approximately 17.5 MHz (`clk_tree_defs.h:58`) and not trimmed.
+ /// ESP-IDF calibrates it against XTAL before using it for a divider; there is no calibration
+ /// here, so a frequency computed from `rc_fast_hz_approx` is approximate too.
+ rc_fast = 1,
+ /// PLL_F80M, 80 MHz (`clk_tree_defs.h:168`). Called `LEDC_SLOW_CLK_PLL_DIV` by ESP-IDF.
+ pll_div = 2,
+
+ /// The source frequency to feed `divisor`, or null for RC_FAST, whose real rate has to be
+ /// measured rather than assumed.
+ pub fn hz(self: ClockSource) ?u32 {
+ return switch (self) {
+ .xtal => xtal_hz,
+ .pll_div => pll_div_hz,
+ .rc_fast => null,
+ };
+ }
+};
+
+pub const xtal_hz: u32 = 40_000_000;
+pub const pll_div_hz: u32 = 80_000_000;
+pub const rc_fast_hz_approx: u32 = 17_500_000;
+
+/// Select the timers' source clock. A read-modify-write of a register that also holds RMT's clock
+/// fields, so it runs with interrupts masked, like everything else that touches HP_SYS_CLKRST.
+pub fn setClockSource(src: ClockSource) void {
+ const guard = clkrst.maskInterrupts();
+ defer guard.release();
+ peri_clk_ctrl22.modify(.{clk_src_sel.is(@intFromEnum(src))});
+}
+
+pub fn getClockSource() ClockSource {
+ return @enumFromInt(peri_clk_ctrl22.get(clk_src_sel));
+}
+
+/// LEDC's core ("function") clock gate. Distinct from the APB gate in `clkrst`: the APB clock makes
+/// the registers addressable, this one makes the counters run - and ESP-IDF notes that some LEDC
+/// registers and the gamma RAM need it just to be read or written (ledc.c:433-436).
+pub fn setFunctionClockEnabled(on: bool) void {
+ const guard = clkrst.maskInterrupts();
+ defer guard.release();
+ peri_clk_ctrl22.modify(.{func_clk_en.is(@intFromBool(on))});
+}
+
+/// Bring the peripheral up, in the only order that works: bus clock, reset, function clock, source.
+///
+/// The bus clock first because LEDC is one of the blocks whose APB gate is *off* at power-on
+/// (`hp_sys_clkrst_reg.h:835`, REG_LEDC_APB_CLK_EN default 0), so every register read before this
+/// returns the last value the bus latched. The function clock before any configuration because the
+/// gamma RAM needs it. ESP-IDF deasserts the reset rather than pulsing it (ledc.c:430-431), because
+/// its driver may be attaching to a running LEDC; this pulses, which is the stronger guarantee for a
+/// fresh boot and is measurably safe on this board - pulsing REG_RST_EN_LEDC for 1 ms left the
+/// console untouched and returned LEDC_CH0_CONF0 to 0.
+pub fn init(src: ClockSource) void {
+ clkrst.setClockEnabled(.ledc, true);
+ clkrst.resetPeripheral(.ledc);
+ setFunctionClockEnabled(true);
+ setClockSource(src);
+}
+
+// -------------------------------------------------------------------------------- divider maths
+
+/// ESP-IDF's `ledc_calculate_divisor`, transcribed from `esp_driver_ledc/src/ledc.c:468-497`:
+///
+/// return (((uint64_t) src_clk_freq << LEDC_LL_FRACTIONAL_BITS) + freq_hz * precision / 2)
+/// / (freq_hz * precision);
+///
+/// Result is Q10.8 - see the file comment - and `divisorValid` says whether it fits the field.
+///
+/// Two properties of that C expression are not obvious and are reproduced deliberately, because a
+/// HAL that computed a *better* divider than IDF's would disagree with it on real inputs and there
+/// would be no way to tell which of the two was wrong:
+///
+/// 1. `freq_hz * precision` is `int * uint32_t`, so it is computed in **32 bits and wraps**, and
+/// the wrap is not always harmlessly out of range. Ask for 4097 Hz at 20-bit resolution from the
+/// 40 MHz XTAL: the true product is 2^32 + 2^20, the C code divides by 2^20 instead, and the
+/// answer is 9766 - a *valid* divider, which programs 1.0 Hz. IDF accepts it, because the value
+/// passes its own range check. `%*` here is that wrap, on purpose: reproducing it is what makes
+/// the on-die comparison meaningful, and the numbers above are how a caller can recognise it.
+/// 2. The quotient is `uint64_t` but the return type is `uint32_t`, so it is **truncated**. From a
+/// 40 MHz source at 1 Hz and 1-bit resolution the quotient is 5.12e9 and IDF returns 825032704.
+/// `@truncate` is that truncation.
+///
+/// The one place this cannot follow IDF is `freq_hz * precision == 0`, reachable at exactly 4096 Hz
+/// with 20-bit resolution (2^32, wrapping to zero), where the C code divides by zero. Returning 0 is
+/// a deliberate substitution: it is not a valid divider, so `divisorValid` rejects it and the caller
+/// gets an error instead of undefined behaviour.
+pub fn divisor(src_hz: u32, freq_hz: u32, resolution: u5) u32 {
+ const precision: u32 = @as(u32, 1) << resolution;
+ const den: u32 = freq_hz *% precision;
+ if (den == 0) return 0;
+ const num: u64 = (@as(u64, src_hz) << fractional_bits) + den / 2;
+ return @truncate(num / den);
+}
+
+/// `LEDC_IS_DIV_INVALID`, inverted (ledc.c:114). A divider below 1.0 means the requested frequency
+/// is faster than the source can produce at that resolution.
+pub fn divisorValid(div: u32) bool {
+ return div >= divisor_min and div <= divisor_max;
+}
+
+/// The frequency a given divider and resolution actually produce: `f_src * 256 / (div * 2^res)`,
+/// rounded, and 0 for a divider of 0.
+///
+/// This is `ledc_get_freq`'s arithmetic (ledc.c:1175) with one deliberate difference: the
+/// denominator is computed in 64 bits, so it does not wrap. Nothing compares this against IDF - it
+/// is a convenience for callers checking what they got - and a wrapped denominator here would be a
+/// bug rather than a compatibility requirement.
+pub fn frequencyOf(src_hz: u32, div: u32, resolution: u5) u32 {
+ if (div == 0) return 0;
+ const den: u64 = @as(u64, div) * (@as(u64, 1) << resolution);
+ const num: u64 = (@as(u64, src_hz) << fractional_bits) + den / 2;
+ return @truncate(num / den);
+}
+
+// --------------------------------------------------------------------------------------- timers
+
+/// Stage the divider. `ledc_ll_set_clock_divider`, ledc_ll.h:345-348.
+pub fn setClockDivider(timer: u32, div: u32) void {
+ std.debug.assert(timer < timer_count);
+ tim_conf.at(timer).modify(.{clk_div.is(div)});
+}
+
+pub fn getClockDivider(timer: u32) u32 {
+ std.debug.assert(timer < timer_count);
+ return tim_conf.at(timer).get(clk_div);
+}
+
+/// Stage the duty resolution, in bits. `ledc_ll_set_duty_resolution`, ledc_ll.h:391-394.
+pub fn setDutyResolution(timer: u32, bits: u5) void {
+ std.debug.assert(timer < timer_count);
+ std.debug.assert(bits <= max_duty_resolution);
+ tim_conf.at(timer).modify(.{duty_res.is(bits)});
+}
+
+pub fn getDutyResolution(timer: u32) u5 {
+ std.debug.assert(timer < timer_count);
+ return @intCast(tim_conf.at(timer).get(duty_res));
+}
+
+/// Commit the staged divider and resolution. One store, and the bit clears itself.
+///
+/// ESP-IDF does not wait for it: "we don't wait for the bit gets cleared since it can take quite
+/// long depends on the pwm frequency" (ledc_ll.h:289). Neither does this - a poll here would block
+/// for a whole PWM period, and there is nothing useful to do with the answer.
+pub fn commitTimer(timer: u32) void {
+ std.debug.assert(timer < timer_count);
+ tim_conf.at(timer).modify(.{tim_para_up.is(1)});
+}
+
+/// Reset the timer's counter: assert, deassert (`ledc_ll_timer_rst`, ledc_ll.h:301-305).
+///
+/// Note the reset value of `LEDC_TIMERn_RST` is **1** (ledc_reg.h:936-943), which is one of the
+/// 46.7% of fields whose reset value is not zero, and the reason `configureTimer` finishes by
+/// clearing it: a freshly reset LEDC block holds all four counters at zero and they stay there until
+/// something writes that bit back down.
+pub fn resetTimer(timer: u32) void {
+ std.debug.assert(timer < timer_count);
+ const r = tim_conf.at(timer);
+ r.modify(.{tim_rst.is(1)});
+ r.modify(.{tim_rst.is(0)});
+}
+
+/// Freeze the counter where it is (`ledc_ll_timer_pause`, ledc_ll.h:316-319).
+pub fn pauseTimer(timer: u32) void {
+ std.debug.assert(timer < timer_count);
+ tim_conf.at(timer).modify(.{tim_pause.is(1)});
+}
+
+pub fn resumeTimer(timer: u32) void {
+ std.debug.assert(timer < timer_count);
+ tim_conf.at(timer).modify(.{tim_pause.is(0)});
+}
+
+/// The counter's current value, 20 bits. Reading it is a plain load - no latch handshake, unlike
+/// systimer.
+pub fn timerCount(timer: u32) u32 {
+ std.debug.assert(timer < timer_count);
+ return tim_value.at(timer).raw();
+}
+
+/// Everything a timer needs to produce `freq_hz` at `resolution` bits, in ESP-IDF's order:
+/// divider, resolution, commit, then out of pause and out of reset (`ledc_set_timer_params`,
+/// ledc.c:244-261, followed by ledc.c:816-818).
+///
+/// Returns `DividerOutOfRange` rather than programming a divider the hardware cannot hold. The
+/// caller passes the source frequency because this HAL has no clock tree: `ClockSource.hz()` gives
+/// it for XTAL and PLL_DIV, and RC_FAST has to be measured.
+pub fn configureTimer(timer: u32, opts: struct {
+ src_hz: u32,
+ freq_hz: u32,
+ resolution: u5,
+}) Error!void {
+ std.debug.assert(timer < timer_count);
+ if (opts.resolution == 0 or opts.resolution > max_duty_resolution) return Error.DutyResolutionOutOfRange;
+ const div = divisor(opts.src_hz, opts.freq_hz, opts.resolution);
+ if (!divisorValid(div)) return Error.DividerOutOfRange;
+
+ setClockDivider(timer, div);
+ setDutyResolution(timer, opts.resolution);
+ commitTimer(timer);
+ resumeTimer(timer);
+ resetTimer(timer);
+}
+
+// ------------------------------------------------------------------------------------- channels
+
+/// Which timer drives this channel. Staged; needs `commitChannel`.
+/// `ledc_ll_bind_channel_timer`, ledc_ll.h:697-700.
+pub fn bindTimer(channel: u32, timer: u32) void {
+ std.debug.assert(channel < channel_count and timer < timer_count);
+ ch_conf0.at(channel).modify(.{timer_sel.is(timer)});
+}
+
+pub fn boundTimer(channel: u32) u32 {
+ std.debug.assert(channel < channel_count);
+ return ch_conf0.at(channel).get(timer_sel);
+}
+
+/// Where in the period the output goes high, in counter ticks. Staged.
+/// `ledc_ll_set_hpoint`, ledc_ll.h:450-453.
+pub fn setHpoint(channel: u32, value: u32) void {
+ std.debug.assert(channel < channel_count and value <= hpoint.max());
+ ch_hpoint.at(channel).modify(.{hpoint.is(value)});
+}
+
+/// Stage a duty value, in counter ticks out of `2^resolution`.
+///
+/// Two things happen here that the name does not suggest, and both are ESP-IDF's
+/// (`ledc_ll_set_duty_int_part` ledc_ll.h:480-483, `ledc_duty_config` ledc.c:263-280):
+///
+/// * The register holds duty in **Q21.4** - four fractional bits, used by fades - so the integer
+/// duty is shifted left by 4. `getDuty` shifts back.
+/// * The P4 has no plain-duty path. `DUTY_NUM`/`DUTY_CYCLE`/`DUTY_SCALE`/`DUTY_INC` moved out of
+/// `CHn_CONF1` into gamma RAM, so a constant duty is a one-step fade of scale 0: entry 0 gets
+/// (increase, one cycle, scale 0, one step) and the range count is set to 1. Without that entry
+/// the staged duty is committed and the output does not move.
+///
+/// Staged; needs `commitChannel` (or `start`, which commits).
+pub fn setDuty(channel: u32, value: u32) void {
+ std.debug.assert(channel < channel_count);
+ std.debug.assert(value <= duty.max() >> 4);
+ ch_duty.at(channel).modify(.{duty.is(value << 4)});
+ stageNoFade(channel);
+}
+
+/// The duty the hardware is currently using, from the read-only shadow (`ledc_ll_get_duty`,
+/// ledc_ll.h:495-498). This is the one register that shows whether a commit actually happened - and
+/// it only updates when the timer next overflows, so it is not a synchronous read-back.
+pub fn currentDuty(channel: u32) u32 {
+ std.debug.assert(channel < channel_count);
+ return ch_duty_r.at(channel).get(duty_r) >> 4;
+}
+
+/// Gamma RAM entry 0 as "no fade": one step, one cycle, scale 0, increasing. Exactly the parameters
+/// `ledc_set_duty` passes down (ledc.c:1109-1117) for a constant duty.
+fn stageNoFade(channel: u32) void {
+ // The whole word is being established, and every field in it is being named, so this is one of
+ // the few places `write` is right rather than `modify`.
+ ch_gamma_range0.at(channel).write(.{
+ gamma_duty_inc.is(1),
+ gamma_duty_cycle.is(1),
+ gamma_scale.is(0),
+ gamma_duty_num.is(1),
+ });
+ ch_gamma_conf.at(channel).modify(.{gamma_entry_num.is(1)});
+}
+
+/// The output driver. Staged; needs `commitChannel`.
+/// `ledc_ll_set_sig_out_en`, ledc_ll.h:592-596.
+pub fn setOutputEnabled(channel: u32, on: bool) void {
+ std.debug.assert(channel < channel_count);
+ ch_conf0.at(channel).modify(.{sig_out_en.is(@intFromBool(on))});
+}
+
+/// The level the pad holds while the channel is disabled - and only while it is disabled
+/// (`ledc_reg.h:34-37`: "Valid only when LEDC_SIG_OUT_EN_CHn is 0"). Staged.
+/// `ledc_ll_set_idle_level`, ledc_ll.h:622-626.
+pub fn setIdleLevel(channel: u32, level: u1) void {
+ std.debug.assert(channel < channel_count);
+ ch_conf0.at(channel).modify(.{idle_lv.is(level)});
+}
+
+/// Hand the staged duty to the fade engine. `ledc_ll_set_duty_start`, ledc_ll.h:607-610.
+///
+/// `DUTY_START` lives in `CHn_CONF1`, alone, and is annotated `R/W/SC` - the hardware clears it when
+/// the (here one-step) fade finishes. A read-modify-write is still the right store: the bit is the
+/// only field in the word, but bits 30:0 are reserved and writing them back as read is what IDF's
+/// bitfield assignment does.
+pub fn startFade(channel: u32) void {
+ std.debug.assert(channel < channel_count);
+ ch_conf1.at(channel).modify(.{duty_start.is(1)});
+}
+
+/// Commit the channel's staged fields: `TIMER_SEL`, `SIG_OUT_EN`, `IDLE_LV`, `HPOINT`,
+/// `DUTY_START`, `OVF_CNT_EN` and the duty (`ledc_reg.h:42-47`).
+///
+/// One deliberate store, never folded into the store that staged the values, matching
+/// `ledc_ll_ls_channel_update` (ledc_ll.h:435-438). It is a read-modify-write because the commit bit
+/// shares its word with the staged fields - see the file comment - and that is safe only because the
+/// bit reads back as 0.
+pub fn commitChannel(channel: u32) void {
+ std.debug.assert(channel < channel_count);
+ ch_conf0.at(channel).modify(.{ch_para_up.is(1)});
+}
+
+/// Start driving: output on, duty handed over, committed. `_ledc_update_duty`, ledc.c:1021-1026.
+pub fn start(channel: u32) void {
+ setOutputEnabled(channel, true);
+ startFade(channel);
+ commitChannel(channel);
+}
+
+/// Stop driving and hold the pad at `idle_level`. `ledc_stop`, ledc.c:1039-1050.
+///
+/// The order is IDF's and it matters: the idle level is staged *before* the output is disabled, so
+/// the two reach the hardware in the same commit and the pad never spends a period at the old idle
+/// level.
+pub fn stop(channel: u32, idle_level: u1) void {
+ setIdleLevel(channel, idle_level);
+ setOutputEnabled(channel, false);
+ commitChannel(channel);
+}
+
+/// A whole channel in one commit: timer, duty, hpoint, idle level, output enable.
+///
+/// This is the one operation here that is not a transcription of an ESP-IDF function - IDF's
+/// `ledc_channel_config` also allocates a driver object, reserves the pin and installs a fade
+/// service - but it is the same register sequence: stage everything, then commit once. One commit
+/// rather than five is the point: the channel changes all at once, at a period boundary, instead of
+/// drifting through four intermediate configurations.
+pub fn configureChannel(channel: u32, opts: struct {
+ timer: u32,
+ duty: u32,
+ hpoint: u32 = 0,
+ idle_level: u1 = 0,
+ output_enabled: bool = true,
+}) void {
+ bindTimer(channel, opts.timer);
+ setHpoint(channel, opts.hpoint);
+ setDuty(channel, opts.duty);
+ setIdleLevel(channel, opts.idle_level);
+ setOutputEnabled(channel, opts.output_enabled);
+ startFade(channel);
+ commitChannel(channel);
+}
+
+// ------------------------------------------------------------------------------------ pin output
+
+/// The GPIO matrix signal index for a channel's output. `ledc_periph_signal[0].sig_out0_idx` is
+/// `LEDC_LS_SIG_OUT_PAD_OUT0_IDX` (esp_hal_ledc/esp32p4/ledc_periph.c:14-18) and the driver adds the
+/// channel number to it (ledc.c:831); the eight indices are consecutive from 126, asserted above.
+pub fn signalIndex(channel: u32) u32 {
+ std.debug.assert(channel < channel_count);
+ return @as(u32, @intCast(regs.LEDC_LS_SIG_OUT_PAD_OUT0_IDX)) + channel;
+}
+
+/// Route a channel's output to a pad through the GPIO matrix. No LEDC register is involved: the
+/// peripheral has no pad of its own, and this is the whole of `ledc_set_pin`'s hardware effect
+/// (ledc.c:823-836, whose `gpio_matrix_output` is func_sel + matrix source + output-enable control,
+/// gpio_hal.c:60-69).
+pub fn attachPin(channel: u32, pin: u8) void {
+ gpio.matrixOut(pin, signalIndex(channel));
+}
+
+// ----------------------------------------------------------------------------------------- tests
+
+test "the divider is Q10.8: integer part in [17:8], fraction in [7:0]" {
+ // 40 MHz XTAL, 1 kHz, 13-bit resolution. 40e6*256/(1000*8192) = 1250 = 0x4E2, i.e. 4 + 226/256
+ // = 4.8828. Checked against ESP-IDF's own expression compiled on the host over a 1,680-point
+ // sweep of (source, frequency, resolution).
+ try std.testing.expectEqual(@as(u32, 1250), divisor(40_000_000, 1_000, 13));
+ try std.testing.expectEqual(@as(u32, 1250 >> 8), 4);
+ try std.testing.expectEqual(@as(u32, 1250 & 0xff), 226);
+ // And back again, to within the rounding the format allows.
+ try std.testing.expectEqual(@as(u32, 1_000), frequencyOf(40_000_000, 1250, 13));
+}
+
+test "divider values for the frequencies the differential harness uses" {
+ try std.testing.expectEqual(@as(u32, 2000), divisor(40_000_000, 5_000, 10));
+ try std.testing.expectEqual(@as(u32, 500), divisor(40_000_000, 20_000, 10));
+ try std.testing.expectEqual(@as(u32, 2083), divisor(40_000_000, 300, 14));
+ // 80 MHz PLL_F80M, same request: exactly twice the divider.
+ try std.testing.expectEqual(@as(u32, 4000), divisor(80_000_000, 5_000, 10));
+}
+
+test "the arithmetic reproduces IDF's overflow and truncation rather than fixing them" {
+ // 32-bit wrap of freq*precision: the true product at 1 MHz / 13 bits is 8_192_000_000, and the
+ // C expression divides by 3_897_032_704 instead, giving 3 where the unwrapped arithmetic would
+ // give 1. Neither is a usable divider - both are below 1.0, so `divisorValid` rejects them the
+ // way `LEDC_IS_DIV_INVALID` does - but the *value* has to be IDF's, or a caller comparing the
+ // two implementations sees a difference that is really just two different roundings.
+ try std.testing.expectEqual(@as(u32, 1_000_000 *% (@as(u32, 1) << 13)), 3_897_032_704);
+ try std.testing.expectEqual(@as(u32, 3), divisor(40_000_000, 1_000_000, 13));
+ try std.testing.expect(!divisorValid(divisor(40_000_000, 1_000_000, 13)));
+ // u64 quotient truncated to u32, exactly as the C return type does.
+ try std.testing.expectEqual(@as(u32, 825_032_704), divisor(40_000_000, 1, 1));
+ // The one input where IDF divides by zero: 4096 * 2^20 == 2^32.
+ try std.testing.expectEqual(@as(u32, 0), divisor(40_000_000, 4096, 20));
+ try std.testing.expect(!divisorValid(divisor(40_000_000, 4096, 20)));
+ // The wrap that is *not* self-limiting: 4097 Hz at 20 bits gives a divider IDF's own range check
+ // accepts, and it programs 1.0 Hz. Reproduced rather than corrected, because the point of the
+ // differential test is to be wrong in the same way IDF is or not at all.
+ try std.testing.expectEqual(@as(u32, 9766), divisor(40_000_000, 4097, 20));
+ try std.testing.expect(divisorValid(9766));
+ try std.testing.expectEqual(@as(u32, 1), frequencyOf(40_000_000, 9766, 20));
+}
+
+test "validity is the field's range, not the whole u32" {
+ try std.testing.expect(!divisorValid(0xff)); // below 1.0
+ try std.testing.expect(divisorValid(0x100)); // exactly 1.0
+ try std.testing.expect(divisorValid(0x3ffff));
+ try std.testing.expect(!divisorValid(0x40000));
+ // 40 MHz cannot make 5 kHz at 13 bits: that needs a divider of 0.98.
+ try std.testing.expect(!divisorValid(divisor(40_000_000, 5_000, 13)));
+}