From gap encoding primitives → PWM pin release bug → antenna ringing compensation → first working read.
A complete firmware debugging journey on the nRF52840.
The ChameleonUltra already supports reading EM410x tags — those broadcast continuously and you just listen. EM4x05 tags are different. They are reader-talk-first (RTF): they sit completely silent until a reader sends a specific gap-encoded command over the 125kHz carrier field. No valid command, no response at all.
The goal of PR #386 was to add lf em 4x05 read — a command that sends a READ request to an EM4305 tag and decodes the config word and UID from the 45-bit Manchester-encoded response. The EM4305 is common in parking fobs, some access control cards, and animal microchips.
I built lf_gap.c as a shared gap encoding library, added lf_em4x05_data.c with a Manchester decoder and 45-bit response parser, wired up the Python CLI commands, and submitted the PR. Clean timeout with no tag present. But when a community member tested with a real EM4305 tag, they got LF tag not found every time.
The EM4305 had Reader Talk First disabled — it was in EM410x broadcast mode, ignoring all gap commands. PM3 confirmed: R.T.F. Reader talk first disabled. After enabling RTF by writing a new config word the tag was ready. Still no tag found on the Chameleon.
Comparing our gap constants against PM3's armsrc/lfops.c: our GAP_BIT1_TC was 56 Tc = 448µs. PM3's proven value for EM4305 is 32 Tc = 256µs. Fixed. Still no tag found.
The timeslot was set to 2000µs but the full command takes 5552µs. It expired mid-transmission. The tag received a truncated command and ignored it. Increased to 6000µs. Still no tag found.
Used PM3 lf sniff while the Chameleon ran lf em 4x05 read. All 1500 samples were 0x80–0x82 — constant unmodulated carrier. The gaps were never being sent.
Digging into lf_125khz_radio.c revealed the cause. The LF carrier is driven by PWM on LF_ANT_DRIVER, configured as NRFX_PWM_PIN_INVERTED. When stop_lf_125khz_radio() calls nrfx_pwm_stop(), the PWM peripheral releases the pin back to GPIO — but leaves it in whatever state the last PWM cycle ended. With an inverted configuration, the pin stays high. The antenna driver stays enabled. The field never cuts.
nrfx_pwm_stop() releases LF_ANT_DRIVER to an indeterminate GPIO state. The field never drops, the tag sees a continuous carrier with no gaps, and can never decode a command. Every read attempt silently transmitted nothing.
The fix: after stopping the PWM, explicitly drive LF_ANT_DRIVER low via GPIO to guarantee the field cuts, then restart the PWM to restore the carrier.
/* Stop PWM, then explicitly drive pin low — field is guaranteed off */
static inline void field_off(void) {
nrfx_pwm_stop(&m_pwm, true);
nrf_gpio_cfg_output(LF_ANT_DRIVER);
nrf_gpio_pin_clear(LF_ANT_DRIVER); /* field OFF */
}
static inline void field_on(void) {
nrf_gpio_pin_set(LF_ANT_DRIVER);
start_lf_125khz_radio(); /* restart PWM on pin */
}
Even with the GPIO fix, a second physical problem remained. A 125kHz antenna coil rings for roughly 200µs after the field drops. Our write gap targeted 128µs. Since 128µs < 200µs of ringing, the tag would never see the field reach zero. From the tag's perspective, the carrier never stopped.
If your write gap delay is shorter than the antenna ringing time, the tag never detects the gap. The EM4305 needs the field to drop below its detection threshold to register a bit boundary. Ringing compensation is not optional.
| Parameter | Target | Old value | New value |
|---|---|---|---|
| Start gap | 440µs | 440µs | 440µs |
| Settle time | ≥ 104µs (13 cycles) | 10µs | 104µs |
| Bit '1' field-on | 256µs (32 Tc) | 448µs (56 Tc) | 256µs |
| Bit '0' field-on | 184µs (23 Tc) | 192µs (24 Tc) | 184µs |
| Write gap | 128µs (16 Tc) | 80µs | 250µs (+ringing comp.) |
There was also a structural problem. The original code sent a gap first, then the field-on period. The EM4305 protocol is the opposite: field on for the bit duration, then the write gap as a delimiter. The gap marks the end of a bit. Switching to field-on → gap correctly aligns with the tag's internal state machine and clock recovery logic.
/* Field ON for bit duration, then write gap.
* 250µs = 128µs target + ~122µs ringing compensation. */
static void send_em4305_bit(bool bit) {
if (bit) {
bsp_delay_us(256); /* bit 1: 32 Tc = 256µs */
} else {
bsp_delay_us(184); /* bit 0: 23 Tc = 184µs */
}
stop_lf_125khz_radio();
bsp_delay_us(250);
start_lf_125khz_radio();
}
static void em4x05_send_timeslot_cb(void) {
stop_lf_125khz_radio();
bsp_delay_us(440); /* start gap */
start_lf_125khz_radio();
bsp_delay_us(104); /* settle: 13 carrier cycles */
uint16_t cmd = em4x05_build_cmd(g_send_opcode, g_send_addr);
for (int i = 8; i >= 0; i--) {
send_em4305_bit((cmd >> i) & 1);
}
/* Field stays ON — tag responds ~3 Tc after last write gap */
g_timeslot_done = true;
}
The receive loop runs in the main thread after the timeslot completes. The timeslot disables GPIOTE interrupts, so receiving during the callback would silently drop all tag response edges. Send in timeslot, receive after.
The command structure is now spec-compliant, the PWM pin release bug is fixed, and the write gap is compensated for antenna ringing. The next step is a PM3 sniff to confirm gaps are visible in the field, then real-hardware testing with the community member's EM4305.
If you have an EM4305, EM4205, or EM4x69 tag with RTF enabled, flash the build and run lf em 4x05 read. A logic analyser on the LF MOD test pad on the ChameleonUltra PCB would also be extremely useful right now.