Skip to content

Add LF PAC/Stanley (125kHz) Support#362

Merged
GameTec-live merged 12 commits intoRfidResearchGroup:mainfrom
kevihiiin:pac-emulation
Apr 6, 2026
Merged

Add LF PAC/Stanley (125kHz) Support#362
GameTec-live merged 12 commits intoRfidResearchGroup:mainfrom
kevihiiin:pac-emulation

Conversation

@kevihiiin
Copy link
Copy Markdown
Contributor

This PR adds (full?) PAC/Stanley reading, emulation and T55XX writing support.

NB: This has only been tested against newer versions with 125kHz fobs. Emulation on the CU does work with our building door reader.

Implementation details

The PAC/Stanley protocl uses ASK and a NRZ type encoding, making the decoding and reading a bit more difficult and error prone. The reader implementation now has a longer calibration phase (allowing for up to 3 attempts within the permitted 500ms timeout).

Verification/Testing

I tested the reader, emlator and tag writer against the Proxmark 3, a physical door access control reader and a T55XX tag.

Reader
The CU reading functionality has been tested against:

  • Multiple T55XX tags programmed to mimic a PAC/Stanley fob
  • Proxmark 3 in sim mode

Emulating
The CU PAC emulation has been tested against:

  • Building door access controller
  • Proxmark 3 with lf search

Writing of T55XX tag
Writing of the tag has been tested against:

  • A T55XX tag, read back using the CU and the PM3

Test it yourself?

Emulation

hw slot type -s 3 -t PAC
lf pac econfig -s 3 --id CARD0123

# Now on PM3
lf search
-> You should see the emulated tag:)

I'm new to the nRF ecosystem and RFID. Please give me feedback or comments if you have any!

@github-actions
Copy link
Copy Markdown

You are welcome to add an entry to the CHANGELOG.md as well

@github-actions
Copy link
Copy Markdown

github-actions bot commented Feb 17, 2026

Built artifacts for commit eddbb31

Firmware

Client

@danieltwagner
Copy link
Copy Markdown
Contributor

I just tried this with a PAC/Stanley fob and it worked fine, both reading and emulating.

@chrisfu
Copy link
Copy Markdown

chrisfu commented Mar 17, 2026

Great work! Looking forward to this feature making its way into the next release 👍

@LupusE
Copy link
Copy Markdown
Contributor

LupusE commented Mar 19, 2026

I've tried the PR.

ChameleonUltra:

[USB] chameleon --> hw slot type --type PAC
 - Set slot 7 tag type success.
[USB] chameleon --> lf pac econfig --slot 7 --id 1414141414141414
 - Set PAC/Stanley tag id success.
[USB] chameleon --> hw slot store
 - Store slots config and data from device memory to flash success.

Seems fine.

PM3:

[usb] pm3 --> lf search
[...]
[+] PAC/Stanley - Card: , Raw: FF2049906C5314C5314C5314C5314C03

[+] Valid PAC/Stanley ID found!

I expected to see the string 1414141414141414. This is maybe a missing interpretation on the PM3 side, but could you explain how we come from 1414141414141414 to FF2049906C5314C5314C5314C5314C03 to verify the result?

Implements NRZ/Direct modulation decoder for PAC/Stanley 125kHz cards
using SAADC ADC sampling with spike-aware threshold calibration.
The LC antenna produces brief high-amplitude transients at NRZ transitions
which are clipped before the moving-average filter to isolate the actual
data levels.
Implements NRZ/Direct modulation at RF/32 for PAC/Stanley tag emulation.
The modulator encodes 8-byte ASCII card IDs into 128-bit NRZ frames
(0xFF sync + 12 UART frames) and generates PWM waveforms using constant
output levels (compare=counter_top for HIGH, compare=0 for LOW).

Firmware: modulator in pac.c, load/save/factory callbacks in lf_tag_em,
tag_emulation registration, SET/GET_EMU_ID commands (5006/5007).
CLI: pac_set/get_emu_id methods, 'lf pac econfig' command, hw slot list
display for PAC tags.
… integer overflows

Replace the 32-sample moving average + hysteresis demodulation with
Proxmark3-inspired per-sample thresholding and dead zone. This
eliminates ~16 samples of group delay per edge, reducing timing
jitter from ~11 samples to ~2-3 samples.

The new approach:
- Prescan: track raw_min, compute spike_cap (unchanged)
- Warmup: track min/max of clipped samples directly (not averaged)
- Detection: per-sample dead zone classification — sample >= high
  threshold → 1, sample <= low threshold → 0, between → keep
  previous state. Thresholds set at 75% fuzz of signal range.

Removes the avg_buf[32] circular buffer, avg_sum, avg_idx, and
sum-unit threshold/hysteresis state. Struct is 72 bytes smaller.

Widen integer types to prevent overflow UB:
- sample_count: uint16_t -> uint32_t (overflows at 524ms)
- interval, nbits: uint16_t -> uint32_t (matching sample_count width)
Add pac_t55xx_writer() for encoding PAC card data into T55XX blocks,
along with the T5577_PAC_CONFIG (NRZ/Direct, RF/32, password-protected,
4 data blocks). Wire DATA_CMD_PAC_WRITE_TO_T55XX (3011) through the
command processor, dispatch table, and Python client.
Three fixes that together bring rapid-fire read reliability from ~20%
to 100%:

- Add MIN_SPIKE_CAP floor (8000) to prevent spike_cap from clipping
  NRZ high when prescan correctly captures NRZ low. Without this,
  spike_cap = raw_min*3 ≈ 2820 collapses the signal range.

- Reorder carrier-before-SAADC in pac_read(): start the 125kHz field
  and wait 10ms before enabling ADC sampling, so prescan calibration
  sees real NRZ signal levels rather than T55XX power-on-reset noise.

- Add auto-recalibration: if no valid frame is found after 20480
  Phase 3 samples (~164ms, ~5 frame periods), reset the decoder to
  Phase 1 and re-calibrate from fresh samples. This gives ~3
  calibration attempts per 500ms scan window instead of just one.

Tested with Proxmark3 sim (15 consecutive rapid-fire reads, 100%) and
T55XX tag (write-read roundtrip + 15x rapid-fire, 100%).
…dle unknown tag types gracefully

- Remove lf pac debug command (development-only)
- Accept both 16-hex and 8-ASCII card ID formats with 7-bit validation
- Add T55xx write command under lf pac write
- Handle unknown TagSpecificType values in slot list without crashing
- Auto-initialize slot data when setting tag type
- Simplify pac_write_to_t55xx by removing unused key parameters
Split the single --id argument into --cn (8 ASCII chars) and --raw
(32 hex char T55XX bitstream, directly compatible with PM3 raw output).
Add Python-side PAC bitstream encoder/decoder for raw format support.
Output now shows CN and Raw labels matching PM3's format.

Add NRF_LOG module registration to pac.c for debug logging,
consistent with other protocol implementations.

Reassign PAC command IDs (3014/3015) to avoid collision with ioProx
(3010/3011) after rebase onto upstream/main.
@kevihiiin
Copy link
Copy Markdown
Contributor Author

Updated CLI & Rebased onto current main

Old confusing behaviour:

PM3:

[usb] pm3 --> lf search
[...]
[+] PAC/Stanley - Card: , Raw: FF2049906C5314C5314C5314C5314C03

[+] Valid PAC/Stanley ID found!

I expected to see the string 1414141414141414. This is maybe a missing interpretation on the PM3 side, but could you explain how we come from 1414141414141414 to FF2049906C5314C5314C5314C5314C03 to verify the result?

Thanks for catching that @LupusE! The ID string 1414141414141414 was interpreted as raw ASCII codes, encoding for 8 ASCII characters (the real ID). Given ASCII code 14 is a control character it couldn't be printed by PM3 (hence the blank card id in the output).

Why did RAW and the ID differ? The raw output of PM3 contains the 8-byte card ID wrapped in a PAC frame:

  • 8-bit sync marker (0xFF)
  • 12 × 10-bit UART frames containing: STX (0x02), '2', '0', 8 card ID bytes, XOR checksum
  • Each UART frame = start bit + 7 data bits LSB-first + odd parity + stop bit
    So 1414141414141414 (8 ASCII control characters) -> encoded -> FF2049906C5314C5314C5314C5314C03

I agree that the CLI options are confusing and too much magic is happening in the background. I aligned it with what PM3 does and changed it to --cn if you want to enter the card number as ASCII characters and --raw if you want to have full control and enter whatever you'd like.

New Commands

Setting the card number

lf pac econfig -s3 --cn DEADBEEF

Read this back on pm3

[usb] pm3 --> lf search
[...]
[+] PAC/Stanley - Card: DEADBEEF, Raw: FF2049906C475150711C875154531403

[+] Valid PAC/Stanley ID found!

And now with the --raw option

pac econfig -s 3 --raw FF2049906D8541C9511C1B06C5926493

Reads this back on pm3

[usb] pm3 --> lf search
[...]
[+] PAC/Stanley - Card: CARD0042, Raw: FF2049906D8541C9511C1B06C5926493

[+] Valid PAC/Stanley ID found!

Please do give the new build a try, hopefully it works on your CU and PM3 aswell:)

@LupusE
Copy link
Copy Markdown
Contributor

LupusE commented Mar 24, 2026

Looks fine for me, tested only virtual on the PM3, no physical access

[USB] chameleon --> lf pac econfig -s 7 --cn 12345678

leads to

[+] PAC/Stanley - Card: 12345678, Raw: FF2049906D192659B1655B36DD90E421

[+] Valid PAC/Stanley ID found!

Thanks for the technical explanation!
The first throw should be enough for a blind Copy/Emulate, but I would say now it is much more real life usable. Very nice!

Please don't force-push in future, if not absolutely necessary. We have some changes and it is hard to keep manual track of all little changes for every force-push.
Of course one of the reasons is, because the PRs are too long in the line. But we all do this in our free time. Please don't make it harder as it need to be.

Copy link
Copy Markdown
Contributor

@LupusE LupusE left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fine for me, the virtual test against PM3 works!

@kevihiiin
Copy link
Copy Markdown
Contributor Author

Thank you for reviewing it! I'll avoid force-pushing and rebasing in the future, as tracking changes during merge conflicts was annoying. I'll merge main into my branch in the future instead :)

Forgot to update the changelog, will do that and then the PR should be ready

@kevihiiin
Copy link
Copy Markdown
Contributor Author

kevihiiin commented Apr 2, 2026

Another CU we had on hand struggled to emulate PAC due to the NRZ encoding (used by PAC/Stanley) and probably different hardware tolerances.

@danieltwagner found a fix by using the external oscillator:
NRZ encodes data as constant levels -- a run of same-polarity bits has no mid-bit transitions for the reader to resync on, so clock drift accumulates across the entire run and can slip a bit boundary (no issue for Manchester). Switching from HFINT (±1.5%) to HFXO (±40 ppm) eliminates this drift; the SoftDevice reference-counts the request so BLE coexists and non-NRZ protocols are unaffected.

@GameTec-live GameTec-live added the testme! This PR is ready but just missing tests. Join in and help testing! label Apr 4, 2026
Comment thread CHANGELOG.md Outdated
@LupusE
Copy link
Copy Markdown
Contributor

LupusE commented Apr 6, 2026

Hi @kevihiiin, as I see there are 'conflicts', but I think this only happens because of the CHANGELOG.md ...
I don't have permission to resolve the conflicts, could you merge the latest changes into your repo and update the PR?
After that we're fine to merge your PR soon.

@kevihiiin
Copy link
Copy Markdown
Contributor Author

Hi @kevihiiin, as I see there are 'conflicts', but I think this only happens because of the CHANGELOG.md ... I don't have permission to resolve the conflicts, could you merge the latest changes into your repo and update the PR? After that we're fine to merge your PR soon.

Done:) Thank you for reviewing the PR

@GameTec-live GameTec-live merged commit 92505b0 into RfidResearchGroup:main Apr 6, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

testme! This PR is ready but just missing tests. Join in and help testing!

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants