<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: arsenapple</title>
    <description>The latest articles on DEV Community by arsenapple (@arsenapple).</description>
    <link>https://dev.to/arsenapple</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4033857%2Fcbd8c4e1-bbd6-4a34-bcd2-2375e27e2351.png</url>
      <title>DEV Community: arsenapple</title>
      <link>https://dev.to/arsenapple</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arsenapple"/>
    <language>en</language>
    <item>
      <title>Porting Logitech Unifying to nRF Connect SDK — why esb_write_payload gets no ACK, and 4 other walls</title>
      <dc:creator>arsenapple</dc:creator>
      <pubDate>Fri, 17 Jul 2026 12:32:22 +0000</pubDate>
      <link>https://dev.to/arsenapple/porting-logitech-unifying-to-nrf-connect-sdk-why-esbwritepayload-gets-no-ack-and-4-other-walls-1mag</link>
      <guid>https://dev.to/arsenapple/porting-logitech-unifying-to-nrf-connect-sdk-why-esbwritepayload-gets-no-ack-and-4-other-walls-1mag</guid>
      <description>&lt;p&gt;TL;DR for the person searching at 3 a.m.&lt;br&gt;
If you are sending a raw Logitech Unifying (or LOGITacker-derived) frame from the NCS/Zephyr standard ESB library (subsys/esb) and the receiver never ACKs — ESB_EVENT_TX_SUCCESS never fires, only ESB_EVENT_TX_FAILED — even though the bytes on air are provably correct and the receiver responds fine to LOGITacker sending the identical payload:&lt;/p&gt;

&lt;p&gt;The likely cause is the least-significant bit of the S1 field in the DPL (Dynamic Payload Length) PDU header. NCS standard ESB computes it as pdu-&amp;gt;type.dpl_pdu.ack = !noack, which puts a 1 on an ACK-requesting transmit. LOGITacker's Unifying-compatible path puts a 0 there. The two stacks encode "I want an ACK" with opposite bit values. Match LOGITacker's raw S1 value and the receiver starts ACKing immediately.&lt;/p&gt;

&lt;p&gt;Jump to section 3 for the full trace.&lt;/p&gt;

&lt;p&gt;What this project was&lt;br&gt;
I built firmware that makes a Raytac MDBT50Q-CX (nRF52840, USB-C dongle) pair with an off-the-shelf Logitech Unifying receiver as a new Unifying device, so that keyboard/mouse input captured on a Mac can be transmitted to a Windows PC as encrypted Unifying frames — with only the single Unifying receiver on the Windows side (no admin rights, no extra software installed on Windows).&lt;/p&gt;

&lt;p&gt;The protocol implementation is derived from LOGITacker (a Unifying/BLE security-research firmware built on the old nRF5 SDK) and ported onto the modern NCS standard ESB library (subsys/esb).&lt;/p&gt;

&lt;p&gt;This writeup is for anyone working with the same stack (nRF52840 + NCS + subsys/esb + Logitech Unifying protocol), or debugging a similar class of bug: a bit-level discrepancy between a documented standard stack and a reverse-engineered protocol implementation.&lt;/p&gt;

&lt;p&gt;Keywords: nRF52840, Raytac MDBT50Q-CX, nRF Connect SDK, NCS, Zephyr, subsys/esb, Enhanced ShockBurst, ESB, Logitech Unifying, LOGITacker, nrf_esb_illegalmod, DPL Dynamic Payload Length, S1 field, ACK payload, pairing protocol reverse engineering, esb_write_payload noack.&lt;/p&gt;

&lt;p&gt;Environment:&lt;/p&gt;

&lt;p&gt;Board: raytac_mdbt50q_cx_40_dongle/nrf52840 (present in NCS's Zephyr boards)&lt;br&gt;
SDK: nRF Connect SDK v3.4.0&lt;br&gt;
Radio: NCS standard ESB library, extended from nrf/samples/esb/esb_ptx&lt;br&gt;
Peer: an off-the-shelf Logitech Unifying receiver (real hardware), plus a second nRF52840 dongle flashed with LOGITacker used as a sniffer / forced-pairing observation rig&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Prebuilt LOGITacker firmware won't boot on the Raytac dongle
Symptom
Flashing the official prebuilt LOGITacker hex (pca10059 and mdk_dongle variants) to the Raytac MDBT50Q-CX over USB SDFU (via nRF Connect Programmer) succeeds, but the device never enumerates over USB and the LED never lights. The DFU bootloader itself is healthy — flashing works and re-entering DFU mode works.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Isolation&lt;br&gt;
Rule out the hardware. Zephyr officially supports the same board (raytac_mdbt50q_cx_40_dongle). Building and flashing three Zephyr (main) samples — Blinky, CDC ACM, HID keyboard — all worked (Blinky: LED0 = P0.8 blinking at 1 Hz; CDC ACM: /dev/cu.usbmodem* enumerated; HID keyboard: IOHIDDevice enumerated). So the hardware and bootloader are fine.&lt;br&gt;
Instrument main(). Add a diagnostic that blinks LED0 three times at the very top of LOGITacker's main() before normal init. The three blinks never appeared, and there was no USB enumeration. Since the blink code is polarity-independent and trivially early, this means the code hangs before reaching main().&lt;br&gt;
The UICR hypothesis (and why it was wrong). SystemInit() in modules/nrfx/mdk/system_nrf52840.c has a path that, when CONFIG_GPIO_AS_PINRESET is defined, writes PSELRESET into UICR and resets. I suspected an earlier Zephyr flash had modified UICR and the firmware was stuck in a reset loop. A control experiment killed this hypothesis.&lt;br&gt;
Control experiment (decisive). I flashed the nRF5 SDK's own minimal Blinky sample (examples/peripheral/blinky/pca10059/mbr, built without CONFIG_GPIO_AS_PINRESET). It also failed to boot (LED0 never lit → main unreached). A minimal, LOGITacker-unrelated sample from the SDK itself doesn't start either, so the UICR/PINRESET theory has no legs.&lt;br&gt;
Root cause (unconfirmed, shelved)&lt;br&gt;
The real issue reduces to a broader incompatibility that is not LOGITacker-specific: generic apps built with the old nRF5 SDK (assuming ~2018-era toolchain/startup) don't start in this dongle's Open bootloader environment. I never pinned down exactly where it hangs (startup asm / SystemInit / linker VMA placement). Because Zephyr (and NCS) boot the same board fine, I decided I could route around the mystery instead of solving it, and abandoned the nRF5 SDK port entirely, switching to porting the Unifying transmit logic onto NCS (Zephyr).&lt;/p&gt;

&lt;p&gt;Lesson&lt;br&gt;
When "a specific project won't run," first check whether a minimal sample on the same foundation (SDK, toolchain, bootloader assumptions) reproduces the failure. If it does, suspect the foundation before you debug the individual project — it's usually faster.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Don't let the name nrf_esb_illegalmod scare you into over-scoping
LOGITacker's ESB implementation file is named nrf_esb_illegalmod.c. The name suggests Unifying communication might require undocumented, non-standard register manipulation, which would make the port a huge job.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What it actually is&lt;br&gt;
A comment in nrf_esb_illegalmod.c states that the "illegal" part refers only to RX-side passive (pseudo-promiscuous) sniffing configuration using undocumented registers. The transmit (PTX) path — the part I actually needed — is completely standard ESB: NRF_ESB_PROTOCOL_ESB_DPL / NRF_ESB_BITRATE_2MBPS / NRF_ESB_CRC_16BIT / NRF_ESB_MODE_PTX / 32-byte payload. The 100+ RADIO-register hacks exist only for sniffing.&lt;/p&gt;

&lt;p&gt;Result&lt;br&gt;
The sniffing register hacks don't need to be ported. The PTX path maps 1:1 onto NCS's standard subsys/esb library (esb_init() / esb_set_base_address_0/1() / esb_set_prefixes() / esb_set_rf_channel() / esb_write_payload()). The only genuinely Unifying-specific logic that must be reimplemented is: the channel-hopping table, AES-ECB frame-key derivation + XOR, the HID++ frame construction + checksum, and the known pairing-mode fixed address. Scope shrank dramatically.&lt;/p&gt;

&lt;p&gt;Lesson&lt;br&gt;
In reverse-engineering projects, file/function names often reflect history (the research target was sniffing) rather than intent. Don't estimate scope from names — read only the path you actually use.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The main event: a DPL S1 least-significant-bit mismatch between NCS ESB and Unifying/LOGITacker
Symptom
Sending the Unifying Phase-1 pairing request from NCS subsys/esb, transmission continues but the receiver's ACK is never detected (ESB_EVENT_TX_SUCCESS never fires; only ESB_EVENT_TX_FAILED repeats).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Isolation — narrow to the one remaining variable&lt;br&gt;
I verified packet content, transmission, and the receiver independently, to corner the problem onto the only thing left: my own receive/ACK-detection path.&lt;/p&gt;

&lt;p&gt;Packet content. Compared the TX payload (e1 5f 01 de ad be ef 82 08 13 37 04 00 01 05 00 00 00 00 00 00 a9) byte-for-byte against the reference implementation's expected Phase-1 request → exact match. Marker / flags / phase / pseudo device address / keep-alive / WPID / protocol / type / caps / checksum all correct.&lt;br&gt;
Is it actually on air? Flashed a second nRF52840 with LOGITacker and ran pair sniff run (passive sniffing) → detected the identical byte sequence. Transmission is fine.&lt;br&gt;
Is the receiver healthy? Unplugged the device under test (stopping its transmission) and used LOGITacker's pair device run (forced pairing, a complete implementation) to send the exact same bytes to the same real receiver → the receiver responded with RSP1 (E1 1F 01  ...), completed Phase 2 / Phase 3, and reached Device pairing succeeded. The receiver is fine.&lt;br&gt;
Content, transmission, and receiver are all correct → the fault is exclusively in my firmware's ACK/RX-detection path. Since LOGITacker uses low-level RADIO register access (nrf_esb_illegalmod) while NCS uses the abstracted subsys/esb, the TX→RX switch timing was the next suspect.&lt;/p&gt;

&lt;p&gt;Tracing the ACK-receive flow on hardware&lt;br&gt;
Compared the ACK-wait sequence in NCS's esb.c and LOGITacker's nrf_esb_illegalmod.c. Both use the base sequence END → DISABLE → DISABLED_RXEN short → RX READY, but NCS routes TX start and the ACK timer through an EGU/PPI/MPSL-FEM compatibility layer.&lt;br&gt;
Added a PPI + TIMER1 diagnostic on the NCS side and observed RADIO-&amp;gt;STATE at TX END / TX DISABLED / RX READY / timeout on real hardware (3,824 samples). TX DISABLED state was always RXRU (1); post-RX-READY state was always RX (3). So a missing RXEN or a stalled RX ramp-up is not the cause.&lt;br&gt;
Changed ACK-wait timeout 160 µs → 500 µs, TX power 0 dBm → +8 dBm, and 2 Mbps preamble 16-bit → 8-bit, testing each on hardware. EVENTS_ADDRESS still never asserted in any case — none of these was the root cause (preamble reverted to standard 16-bit).&lt;br&gt;
Root cause&lt;br&gt;
The decisive difference is the least-significant bit of the S1 field in the DPL PDU header.&lt;/p&gt;

&lt;p&gt;Implementation  S1 LSB on an ACK-requesting transmit&lt;br&gt;
LOGITacker (nrf_esb_illegalmod, Unifying-compatible raw value)  0&lt;br&gt;
NCS standard ESB (subsys/esb, computed as pdu-&amp;gt;type.dpl_pdu.ack = !noack)   1&lt;br&gt;
With identical ESB settings (protocol/mode/bitrate/CRC), the S1 LSB corresponding to "request an ACK" (noack=false) was inverted between the two stacks. Temporarily changing NCS's pdu-&amp;gt;type.dpl_pdu.ack from !noack to noack (matching LOGITacker's raw S1) produced, for the first time, 187 consecutive ADDRESS / END / CRC radio events, and RSP1 began parsing correctly.&lt;/p&gt;

&lt;p&gt;The S1 bit signals whether an ACK is requested in both Enhanced ShockBurst and Unifying — but the polarity of that bit (is 1 "ACK requested" or is 0?) was interpreted differently by NCS standard ESB and the Unifying (LOGITacker-derived) side. This mismatch never surfaces at the API level (the noack argument to esb_write_payload()); it is only visible in the raw bit representation of the generated physical frame.&lt;/p&gt;

&lt;p&gt;After the fix&lt;br&gt;
Once RSP1 reception was stable, I ported the existing reference implementation's Phase 2 / Phase 3 / final confirmation / key-derivation logic unchanged, and confirmed Unifying pairing succeeded on hardware. After pairing, the RF address, WPID, and derived AES device key are persisted to NVS and restored on boot to re-establish the link.&lt;/p&gt;

&lt;p&gt;Lesson&lt;br&gt;
When porting a proprietary protocol (Unifying) on top of a standard stack (NCS ESB), verify not that your code is semantically equivalent at the API level, but that the raw bytes handed to the radio actually match. The most reliable method was to send an identical payload from both stacks and compare the byte sequences on air (or in RAM). This is a class of bug you cannot reach by reading docs or API signatures — trust the raw output, not the intended meaning.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Test-oracle traps
When radio + crypto + firmware + app all interact, mis-defining "did that succeed?" (the test oracle) makes you doubt a correct implementation endlessly. The traps I actually hit:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;4-1. An RF ACK does not prove "a character appeared on screen"&lt;br&gt;
Link-layer ACKs for encrypted key reports came back fine while Windows showed nothing. An ACK guarantees the packet arrived over the air; it does not guarantee the receiver's HID interpretation or OS acceptance. Sending hello one character per second displayed correctly, but a 20 ms-interval burst stopped at hel. The cause was not crypto or pairing but the time spent hopping to find the channel the receiver is currently listening on (see 4-3-adjacent tuning below).&lt;/p&gt;

&lt;p&gt;4-2. Logi Options+ saying "device not found" does not mean pairing failed&lt;br&gt;
The macOS pairing helper (Logi Options+) sometimes ended its add-device flow with "device not found," yet the firmware logs showed all phases completing and encrypted traffic working. The Options+ UI is only a coarse indicator — it keeps searching while it detects pairing-class traffic and ends when it detects nothing — and cannot be used to judge success/failure of a custom device. Use the firmware serial log (phase completion, key derivation) and actual encrypted traffic as ground truth. Also note Options+ exposed no operation to remove an existing device (don't hand out UI steps that assume one exists).&lt;/p&gt;

&lt;p&gt;4-3. Clearing persistent data: delete vs clear&lt;br&gt;
After changing the pairing-record storage format, boot failed to load (Link storage load failed: -5) and never reached radio pairing. A single-record nvs_delete() left a mix of old/new-format remnants and did not fix it; a full nvs_clear() did. Even then, the same firmware sometimes still received no ACK/RSP1 — resolved by physically unplugging/replugging the receiver and restarting the Options+ search from scratch. With byte-identical firmware and payload, the receiver's transient internal state (e.g., a previous search session not internally closed) can matter.&lt;/p&gt;

&lt;p&gt;4-4. Don't re-present a previously-solved path as a "new root cause"&lt;br&gt;
I nearly attributed the current failure to a plausible-looking code diff ("we discard the ACK payload on the initial request"), but history showed the known-good implementation did the same thing. For recurring hardware failures, before proposing a new hypothesis, A/B test the known-good binary against the current implementation under identical persistent state and identical physical topology — it avoids mistaking an unrelated diff for a new cause.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Data-driven tuning: reordering channel search cut latency by 3×+
Unifying's communication channel table has 25 entries; the transmitter doesn't know which channel the receiver is currently listening on, so it hops through candidates until an ACK returns. Initially I searched in ascending order, but aggregating real logs showed the receiver alternating between two specific channels (44 and 35).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Keeping all channels but placing those two at the front of the search order, measured on a 36-character input test:&lt;/p&gt;

&lt;p&gt;Metric  Before (ascending)  After (44/35 first)&lt;br&gt;
Total time (36 chars)   2,861 ms    856 ms&lt;br&gt;
Avg attempts/char   15.44   1.50&lt;br&gt;
Max attempts/char   38  2&lt;br&gt;
Success – 72/72 reports&lt;br&gt;
Reordering the search from the measured frequency distribution — not a guess — improved responsiveness by more than 3× while keeping the inter-character delay at 0 ms.&lt;/p&gt;

&lt;p&gt;Summary table&lt;/p&gt;

&lt;h1&gt;
  
  
  Symptom Wrong / over-scoped hypothesis  Actual cause    Fix
&lt;/h1&gt;

&lt;p&gt;1   Prebuilt LOGITacker won't boot  Bad hardware; LOGITacker-specific bug   nRF5-SDK generic apps don't start in this bootloader env (root cause unconfirmed, shelved)  Switch the whole port to NCS (Zephyr)&lt;br&gt;
2   The name illegalmod All Unifying comms need special register hacks  Only the sniffing code does; TX is standard ESB Port the PTX path onto standard subsys/esb&lt;br&gt;
3   No ACK from NCS standard ESB    Channel / timing / TX-power problem DPL S1 LSB polarity inverted between NCS and Unifying   Force the S1-bit computation to Unifying's convention&lt;br&gt;
4   Mis-judging "success"   Over-trusting RF ACK / Options+ UI / NVS ops    Each signal guarantees only a narrow thing  Use firmware logs and A/B tests as ground truth&lt;br&gt;
5   Channel search too slow Adding delay will stabilize it  Search order didn't match real receiver behavior    Prioritize channels by measured frequency&lt;br&gt;
References&lt;br&gt;
LOGITacker (RoganDawes) — Unifying/BLE security-research firmware; the source of the protocol implementation referenced here.&lt;br&gt;
nRF Connect SDK subsys/esb (Zephyr's standard Enhanced ShockBurst library).&lt;br&gt;
DEF CON 24 "Unifying" / MouseJack materials (independent description of the 10-byte Unifying mouse frame layout).&lt;/p&gt;

</description>
      <category>embedded</category>
      <category>c</category>
      <category>reverseengineering</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
