Close

Infrared Communication - Sending a 1-second tweet

A project log for MalO SAO

SCP-1471

parallel-logicParallel Logic 35 minutes ago0 Comments

I've mentioned previously that part of my motivation for this project was the rock paper scissors concept presented at Supercon 2025 and before that at Burning Man, but wanted to expand on that to encourage longer interactions.  I was also inspired by the RF badge at Supercon 2025 that served as an interesting platform to host a bot in.  So I wanted to make a SAO that could exchange messages, and short messages between users naturally gravitates towards the concept of sharing "tweets".

Having interacted with the device in the video above, it was a little troublesome to make electrical contact, plus it complicated mass production, so I sought to make a contact-less solution.  However, I've shied away from true RF like WiFi and BLE in part due to the extra resource requirements (either requiring an auxiliary chip or delving in with an ExpressIf solution), and in part because of the hacking-concentric nature of DEFCON and my own unfamiliarity with securing/hardening that kind of solution.  So that left with Infrared as being a more traditional solution better suited to line-of-sight and short distances.


Each vendor of a TV or other device responding to IR has a slightly different implementation, but categorically you're talking about a ~940 nm wavelength LED emitting light.  To avoid interference from static sources like light bulbs or otherwise, the protocol actually defines the active state as transmitting pulses of IR light at ~38 kHz, and the inactive state as transmitting no light at all.  The traditional protocols also tend to define the data in terms of the length of the signals transmitted - so 600 us or 1200 us of pulses or inactive can encode values of 1 or 0.  Some protocols (ex. NEC) repeat the signal inverted (active is inactive, inactive is active)as a form of error correction.

To send a 256-character tweet using this protocol at ~1ms off and ~1 ms on per bit, plus doubling the message length for error correction would take ~8 seconds, which feels on the long side if there are potentially other users in the vicinity also using this half-duplex channel (a receiver can only listen to one source at a time - multiple independent transmitters would stomp atop one another, making the channel unusable).  So I was looking for something faster.

Commercial receivers actually do this 38 kHz pulses = active, no pulse = inactive, decoding for me (I do not get 38 kHz as an input), so it's straight-forward to see the chip claims the minimum detectable signal as 16 cycles of 38 kHz IR light.  From there I thought about various protocols that could work over this, with the aim of increasing the data rate, and better yet if they were dynamic where the user could select the rate their device transmitted at and the receiver would dynamically tune to that, on the premise the transmitting user could effectively choose their signal integrity that way with a trade-off against message transmit time.

I thought about UART (could even route this directly into one of the RP2350 hard-coded IPs so I wouldn't have to do any decoding), but that normally requires some apriori agreement on roughly (+/-10%) what the data rate will be.  I ultimately settled on the WS2812 protocol (more often used for driving long strings of RGB LEDs) - that is: each data bit requires three bits: a 1, a data bit, and a 0.  In this way, the receiver can look for the rising edges to directly extract the data rate of the transmitter, and the duty cycle (66% vs 33%) determines if the bit was a 1 or 0.  Simple.  In theory.

In my experience, the receiver architecture tends to be more complicated than the transmitter, mostly because you're trying to do more (like sync with clocks, balance between idle and active decode modes, etc), whereas the transmitter tends to be more open-loop: fire and forget.  So I implemented the receiver first.  I've tried to re-use ideas throughout this design to save development time and give a more standard platform for anyone who wants to build on top of this code base later.  One aspect I re-used was the run-length encoding logic analyzer.  I used this PIO state machine to decode the capacitive touch buttons I will need to elaborate on in a different article - but I used a different state machine loaded with the same shared PIO code here.  This gave me 40 ns timing precision on the rising and falling edge of the received IR signal, a small fraction of the several-cycles of 38 kHz that would constitute each 1/0.

I implemented the IR receiver in two decoding layers: the first captures the waveform as soon as the channel does active and reports that in microsecond of active/inactive.  This directly supports generic IR waveform decoding for traditional remote controls and the like.  I then built an additional decoder atop that for the WS2812 format message decoding.

The first decoding layer I could test relatively easily by connecting an oscilloscope probe to the IR receiver pin, and then actuating a TV remote to produce a signal - then making sure the RP2350 was accurately decoding the waveform with the same timing I saw directly on the scope.  The second layer would require building the transmitter for a closed-loop test...

There's a few ways I could have gone about a transmitter.  The most straight-forward may have been to configure a PWM channel with 38kHz and either 50% duty cycle (active) or 0% (inactive), and then rely on an interrupt to fire every cycle - counting the number of pulses until the target has been reached, before configuring the PWM channel for the next data bit to send.  This has a lot of overhead to be constantly jumping in/out of an IRQ routine, and I generally distrust interrupts (as challenging to debug) and try to avoid them when I can.  Another approach would have been to setup a PIO state machine to take in 1/0 data bit and a number of cycles to produce, and then running that routine.  This would have less overhead for the processor since I could just configure the DMA once and let it run to completion.

However, in the spirit of re-use, I wanted a solution that could also apply to the buzzer - I wanted the buzzer to play a similar pre-scripted routine of tones.  However, for the buzzer, I need to modify the period to get different tones, and that's more complicated to achieve with PIO, so I opted to rely on the PWM peripheral directly for both IR and buzzer.

I implemented this with a DMA control architecture, which along with the assembly code running the PIO programs, I feel is the top two most complicated and fulfilling portions of this project (and deserves its own article).  The DMA controller is the flux capacitor that makes time travel possible: it's like the DNA (deoxyribonucleic acid) that codes for making genes.  It requires 2 DMA channels (of the 16 available in the RP2350): one "control channel" that does nothing but load instructions into the other "data channel".  The data channels instructions involve fetching the signal period, loading it into the PWM configuration, doing the same for duty cycle, then waiting for the PWM channel to cycle the target number of times, then the control channel configures the data channel to configure the control channel to start over from the beginning of the data channel instruction set.  This would normally be an infinite loop: every time a "tone" plays, the system would advance to the next address indefinitely until a system fault.  However, DMAs behave differently when configured to perform 0 transactions: if it's part of a chain-to series (which the DMA controller architecture is), then when the control channel tells the data channel to do something 0 times then link back to the control channel, the data channel doesn't do the chain-to linkback, in effect self-terminating.  So long as the last command in the list of tones is set to play 0 cycles, the DMA chain will terminate when done.

I did end up using a PIO state machine in the above - I need a way to increment address pointers of where the DMA is in the list of tones to play.  Normally this data is readily accessible by querying the read or write address of the DMA itself of where it will read/write to next.  However, with the DMA controller architecture, it's not possible for a data channel to query the state of its read/write address registers from the previous instruction set (since that data has since been overwritten.  The solution I've employed elsewhere could apply where additional aux DMAs are configured by the data channel to load data out of the data channel registers, but this would require additional DMA resources that are in higher demand with this architecture than the PIOs.  This worked out rather well, and I'd like to use it elsewhere.  In the I2C communication with the IMU, I needed to query the number of samples available and then turn around and fetch that many samples - I used the sniff functionality to just add/double/quadruple some numbers for that purpose and in retrospect this is too much of a waste because there is only one sniff channel, and it's better left available for computing check sums.

Results: Testing the IR TxD and RxD was a mitigated disaster.  I can accurately decode about 64 bytes, but consistently fail/corrupt after that.  I tried a lot of permutations, but ultimately traced the issue to the receiver itself becoming deaf (becoming insensitive insensitive to 38 kHz pulses) the longer a message goes on for (so it might initially output a waveform that cover the full 16 pulses, but later only cover the central 12, then central 10, then becoming entirely deaf/non-responsive).  This "Automatic Gain Correction" appears to be built into the device so that it tunes out general environmental noise.  It makes sense if you're trying to avoid false-positives, like not having the TV turn on every day when the sun comes in the room, but does hamper the ability to send larger volume of data.  The result is I need to send data extremely efficiently, and I've really only got a finite amount of time/data I can send (I'd prefer all data be sent as one burst rather than relying on pauses between messages to clear the channel just to allow the peripheral hardware to reset - makes software processing/handling easier and more straight forward that way).


Since the standard protocols use timing of the pulses to encode 0 vs 1, I took inspiration from that and did this: I send a fixed set of contiguous 38 kHz pulses, I then wait a variable amount of time (which is the data being sent), and then repeat with the next set of pulses.  Because I'm cycle-accurate with my encoding and decoding, and the devices are at room temperature where thermal drift of the clocks should be minuscule (parts per million typically), then I'm able to count the number of 38 kHz periods between bursts and interpret that as data.  There's some DC offsets in there to keep the channel below the 40% utilization recommend by various vendors, but each pulse is followed by a silence that codes for 8 bits of data.

With all that I can get the data rate up to 256 bytes in a burst (and each burst lasts close to 1 second), but have a ~15% error rate, typically when a pulse starts early or late which affects neighboring bytes in the message (I found measuring the same-to-same period was more reliable than the duration of the silence itself).  So I compromised with ~128 byte message, and then put in an error correcting code for the other ~128 bytes which will correct for 64 bytes of error anywhere in the ~256 byte message.

It does have a finite range of a meter or two, which is sufficient for the social circles application I had in mind, but won't work for across conference room halls - the precise timing gets lost with weaker signals.

That said, there may be a way to boost this range further.  I designed much of this layout before the DEFCON34 interface spec was released.  The SAO spec allows devices to draw up to 250 mA and I planned the scope of this project around that.  However, this year's badge appears to be driven directly off 2x AA batteries, meaning the SAOs actually only get ~100 mA to share.  Meeting the current draw numbers has been a large hurdle; one of the ways I met that was tuning down the IR LED count from 3 down to 1, allowing me to cut power there from 90 mA down to 30 mA when transmitting.  However, I already paid for the IR LEDs in advance, and if I paid for them, you're dang well going to use them, so all three are populated on the board, but require soldering a jumper closed to boost the signal.  I suspect soldering this closed will boost the range these devices will operate over, but have not experimentally validated how far that is, as I have run out of time on the IR content and am focusing on the games/screen functionality.

There's some other minor details, like human-readable characters only really need 7 bits (6 bits if you skipped most punctuation), so I actually only cover the lower 7 bits of the ascii table per character.  But that allows me to pack an unused byte, 14 bytes for a username (16 characters) and 112 bytes (128 bytes message) = 127 data bytes, and 126 bytes error-correcting code (two bytes are needed for every correction, so an even number makes sense, and 128 breaks the default library when it tries to double the value and can't express 256 in a single byte).

Main thing I'm pushing for now is to show this all working end-to-end.  So device sends a message, and the message appears as a ticker symbol scrolling across the status bar on the other device.  The vision is that devices will passively transmit messages once a minute or so, or the user can opt to manually send a specific message on command from a list.  The sendable list is configurable as a csv file when the device is connected to a computer. Received messages are also logged to a csv file.


Discussions