# ESP 32 Pulse Generator Component
RMT-backed pulse train generator for ESP32. Configurable pulse width, period and
count, with push-pull or open-drain output for buses that already have an
external pull-up.
- Timing produced entirely by the RMT peripheral — no RTOS jitter
- Finite trains of N pulses, or endless trains looped in hardware
- Open-drain mode emulates an open-collector sensor output
- Phases longer than one RMT symbol field are split automatically
- Completion callback from ISR context
- Configurable defaults through `menuconfig`
Requires ESP-IDF 5.3 or newer.
---
## Install
Into an existing project:
```bash
idf.py add-dependency "leoribg/pulse_generator^1.0.1"
idf.py reconfigure
```
Or declare it in `main/idf_component.yml`:
```yaml
dependencies:
leoribg/pulse_generator: "^1.0.1"
```
Start from the bundled example instead:
```bash
idf.py create-project-from-example "leoribg/pulse_generator^1.0.1:basic"
```
Nothing else is needed. The manager downloads the component into
`managed_components/` and the build system picks it up automatically.
---
## Quick start
```c
#include "pulse_generator.h"
pulse_gen_config_t config =
PULSE_GEN_CONFIG_DEFAULT(25, PULSE_GEN_DRIVE_OPEN_DRAIN, 1);
pulse_gen_handle_t gen;
ESP_ERROR_CHECK(pulse_gen_new(&config, &gen));
const pulse_gen_train_t train = {
.width_us = 500,
.period_us = 1000,
.count = 100,
};
ESP_ERROR_CHECK(pulse_gen_start(gen, &train));
ESP_ERROR_CHECK(pulse_gen_wait_done(gen, 1000));
ESP_ERROR_CHECK(pulse_gen_del(gen));
```
Endless train, stopped explicitly:
```c
const pulse_gen_train_t endless = {
.width_us = 500,
.period_us = 1000,
.count = PULSE_GEN_COUNT_INFINITE,
};
pulse_gen_start(gen, &endless);
vTaskDelay(pdMS_TO_TICKS(5000));
pulse_gen_stop(gen);
```
---
## Picking the output mode
| Wiring | `drive_mode` | `idle_level` |
| --- | --- | --- |
| GPIO straight onto a line pulled up to 3.3 V or less | `OPEN_DRAIN` | `1` |
| External NPN / N-channel MOSFET, pull-up at 5–24 V | `PUSH_PULL` | `0` |
| Dedicated line, no pull-up, receiver idles low | `PUSH_PULL` | `0` |
- **ESP32 pads are not 5 V tolerant.** In open-drain mode the pad goes
high-impedance and then sees the full bus voltage, which destroys it above
~3.6 V. Use an external transistor or opto-coupler for anything higher.
- An external NPN or N-channel MOSFET **inverts** the signal: a high on the GPIO
pulls the line low. That is why the second row idles at 0.
- The rising edge in open-drain mode is passive, set by the pull-up and the line
capacitance (`t ≈ 2.2 · R · C`). It bounds the usable frequency and can
stretch the perceived pulse width.
---
## API
| Function | Purpose |
| --- | --- |
| `pulse_gen_new()` | Allocate an RMT channel and encoder |
| `pulse_gen_start()` | Start a finite or endless train |
| `pulse_gen_wait_done()` | Block until a finite train completes |
| `pulse_gen_stop()` | Abort the current train, return the line to idle |
| `pulse_gen_is_busy()` | Query whether a train is in flight |
| `pulse_gen_register_done_callback()` | ISR callback on completion |
| `pulse_gen_del()` | Release everything |
Full Doxygen comments live in `include/pulse_generator.h`.
---
## Configuration
`menuconfig` → `Component config` → `Pulse Generator`
| Symbol | Default | Effect |
| --- | --- | --- |
| `PULSE_GEN_DEFAULT_RESOLUTION_HZ` | `1000000` | RMT tick rate when `resolution_hz` is 0 |
| `PULSE_GEN_DEFAULT_MEM_BLOCK_SYMBOLS` | `64` | Symbols per block; must match the SoC granularity |
| `PULSE_GEN_DEFAULT_QUEUE_DEPTH` | `4` | RMT transaction queue depth |
| `PULSE_GEN_ENABLE_DMA` | `n` | DMA backend, where the target supports it |
| `PULSE_GEN_ISR_IRAM_SAFE` | `n` | Encoder and ISR in IRAM; needs an IRAM callback |
| `PULSE_GEN_ENABLE_ARG_CHECK` | `y` | Runtime argument validation |
| `PULSE_GEN_ENABLE_DEBUG_LOG` | `n` | DEBUG logging plus a symbol dump per start |
- These are **defaults only**. Any field filled in explicitly in
`pulse_gen_config_t` wins over the Kconfig value.
- `PULSE_GEN_CONFIG_DEFAULT()` reads all of them, so it is the simplest way to
honour the menuconfig settings.
- Pin them per project in `sdkconfig.defaults` rather than editing `sdkconfig`
by hand.
---
## Limits and gotchas
- One RMT symbol field holds **32767 ticks**. Longer phases are split across
several symbols automatically.
- At the default 1 MHz, a phase spans 2 µs to ~32.7 ms per slice. For
second-long periods, drop `resolution_hz` to 100 kHz so each pulse costs 10x
fewer symbols.
- **Endless trains must fit in one RMT memory block**, because the hardware
replays the block without re-entering the encoder. Exceeding it returns
`ESP_ERR_INVALID_SIZE`; lower `resolution_hz` or raise `mem_block_symbols`.
- Only **one train at a time** per generator. Starting a second returns
`ESP_ERR_INVALID_STATE` — the symbol buffer is shared and the RMT driver holds
a pointer to it for the whole transaction.
- `pulse_gen_wait_done()` returns `ESP_ERR_INVALID_STATE` for endless trains.
- `mem_block_symbols` must be a multiple of `SOC_RMT_MEM_WORDS_PER_CHANNEL`
(64 on ESP32 and ESP32-S2, 48 elsewhere), or channel allocation fails.
- Avoid strapping pins (0, 2, 12, 15) and the boot UART pins (1, 3): they toggle
during reset, before your idle level is ever applied.
---
## Supported targets
ESP32 · ESP32-S2 · ESP32-S3 · ESP32-C3 · ESP32-C6 · ESP32-H2
---
## License
MIT. See `LICENSE`.
idf.py add-dependency "leoribg/pulse_generator^1.0.1"