Send, receive and replay MilesTag II laser tag infrared signals.
Transmit shots with a chosen player ID, team colour and damage; health and ammo grants; the full admin command set; game-box pickups; and arbitrary raw messages. When you don't know what a tagger expects, a brute-force mode sweeps every combination for you.
Receive a packet and read back what it does — "player 007 Blaze, blue team,
20 damage". Marks the receiver could not resolve are shown as ? rather than
guessed at; you can edit each one by hand, or let Try All send every combination
of them until the target reacts. Read Raw takes a much wider window, hunts
through it for the best-fitting MilesTag alignment wherever it happens to be,
and keeps the untouched capture available to send back verbatim. Every read is
written to the card automatically, whether you remember to save it or not.
Look at a GPIO pin directly with the built-in oscilloscope when a signal isn't arriving at all and you need to know whether the wire is the problem — either as an analog voltage sampled with the ADC, or as digital edges timestamped to the microsecond. Every sample is stamped where it was taken and streamed to a size-capped CSV; the 256-byte on-screen trace is a summary of it, never the record.
Transmitting needs no extra hardware. Receiving works off the Flipper's own IR receiver, but that is a 38 kHz part: for reliable 56 kHz reception — the MilesTag II default — wire up an external demodulator, as described under Receiving below.
Implemented from The MilesTag 2 protocol (Christopher Malton, April 2011) and
cross-checked against the ncmreynolds/milesTag
Arduino library, a copy of which is vendored in milesTagArduino/
for reference. That copy is not compiled — see the explicit sources= list in
application.fam, because the default source glob is recursive
and would otherwise try to build the Arduino .cpp.
Every symbol is a burst of carrier followed by a fixed gap:
| Symbol | Carrier on | Gap |
|---|---|---|
| Header | 2400 µs | 600 µs |
1 |
1200 µs | 600 µs |
0 |
600 µs | 600 µs |
Carrier is 38, 40 or 56 kHz (56 kHz is the MilesTag II default). Bits go out MSB first.
[header][0 ppppppp][tt dddd]
0marks the packet as a shotppppppp— player ID, 0–127 (IDs 0–59 have stock display names: 000 Eagle, 001 Joker, …)tt— team: 0 Red, 1 Blue, 2 Yellow, 3 Greendddd— damage code, not the damage number:
| Code | HP | Code | HP | Code | HP | Code | HP |
|---|---|---|---|---|---|---|---|
| 0 | 1 | 4 | 7 | 8 | 20 | 12 | 40 |
| 1 | 2 | 5 | 10 | 9 | 25 | 13 | 50 |
| 2 | 4 | 6 | 15 | 10 | 30 | 14 | 75 |
| 3 | 5 | 7 | 17 | 11 | 35 | 15 | 100 |
Only the top six bits of the second byte are transmitted, which is why the packet is 14 bits rather than 16.
[header][1 mmmmmmm][dddddddd][0xE8]
| ID | Message | Data byte |
|---|---|---|
0x80 |
Add Health | 1–100 |
0x81 |
Add Rounds | 1–100 |
0x83 |
Command | command byte, see below |
0x87 |
System Data | cloning / scoring payloads (not sent by this app) |
0x8A |
Clips pickup | ammo box ID 0–15 |
0x8B |
Health pickup | medic box ID 0–15 |
0x8C |
Flag pickup | flag ID 0–15 |
Commands carried by 0x83:
| Byte | Command | Byte | Command |
|---|---|---|---|
0x00 |
Admin Kill | 0x0B |
Explode Player |
0x01 |
Pause/Unpause | 0x0C |
New Game (ready) |
0x02 |
Start Game | 0x0D |
Full Health |
0x03 |
Restore Defaults | 0x0F |
Full Armour |
0x04 |
Respawn | 0x14 |
Clear Scores |
0x05 |
Immediate New Game | 0x15 |
Test Sensors |
0x06 |
Full Ammo | 0x16 |
Stun Player |
0x07 |
End Game | 0x17 |
Disarm Player |
0x08 |
Reset Clock | ||
0x0A |
Initialize Player |
0x09, 0x0E and 0x10–0x13 are reserved by the spec; they are listed in the
app so every command's position still matches its data byte.
Apps → Infrared → MilesTag II:
MilesTag II
├── Send Signal build a packet and transmit it
├── Brute Force sweep the fields you don't know
├── Read Signal receive, decode, edit, replay
├── Read Raw receive anything, decode what you can, replay all of it
├── Oscilloscope sample a GPIO pin, analog or digital, straight to CSV
└── About / Protocol
The two transmit-only modes are covered below; the two read modes need a word about hardware first, so they have their own Receiving section, and the oscilloscope has its own after that.
One screen holds everything, and the rows below Signal change to match it — you only ever see fields that the selected packet actually carries:
| Row | Values |
|---|---|
| Frequency | 38 / 40 / 56 kHz |
| Signal | Shot, Add Health, Add Rounds, Command, Clips/Health/Flag Pickup, Custom Msg |
| Shot | Player ID, Team, Damage |
| Add Health / Add Rounds | Amount 1–100 |
| Command | Command (OK opens the full named list) |
| Pickups | Box / Flag ID 0–15 |
| Custom Msg | Message ID 0x80–0xFF, Data byte (OK opens a hex editor) |
| Duty cycle | 33 / 40 / 50 % |
| Repeat | 1–10 copies per trigger pull |
| Repeat gap | 10–250 ms |
| Transmit | opens the fire screen |
Player ID and Command rows can be scrolled with left/right, or press OK to drill into a full list — players are shown with their MilesTag names ("007 Blaze").
On the fire screen you get the encoded bytes, the exact bit string going out and the carrier in use. OK fires; hold OK to keep firing.
For when you don't know the player ID, team colour or carrier a target expects.
Every field is independently either Fixed or Sweep all, so you can:
| Player | Team | Result |
|---|---|---|
| Sweep all | Fixed → Red | all 128 player IDs, red team only — 128 packets |
| Fixed → 007 Blaze | Sweep all | player 7 against each of the four colours — 4 packets |
| Sweep all | Sweep all | every player in every colour — 512 packets |
| Fixed | Fixed | the single configured packet |
Choosing Fixed reveals a row underneath for the value to hold it at. Damage works the same way, and frequency is either one carrier or All (×3). The screen shows the resulting packet count and a time estimate before you commit.
Sweep dimensions are only offered for fields the packet actually has: player, team and damage for shots, the command byte for commands.
Controls on the run screen:
- OK — Start, then Pause while running, then Resume. Pausing keeps your place, so resuming carries on at the next untried combination rather than starting over.
- Left — Reset back to the first combination (only when paused or finished).
- Back — pause and leave. The radio is never left running.
Editing the sweep settings and returning to the run screen discards a paused position, since it no longer belongs to the sweep you're now describing.
With Log to file on (the default), every combination that goes on the air is
appended to /ext/apps_data/miles_tag_ii/sweep_log.csv, in the order it was
tried — so when a tagger finally reacts you can look up what was being sent.
# MilesTag II TX - sweep started 2026-08-01 14:32:05
# Player+Team @ all freqs, 1536 combinations
# index,freq_hz,type,player_id,player_name,team_id,team_name,damage_hp,command_hex,command_name
1,38000,Shot,0,Eagle,0,Red,1,,
2,38000,Shot,0,Eagle,1,Blue,1,,
...
# paused after 137 combinations
Lines are written after transmission, so the file only ever claims what actually went out. Writes are buffered and flushed in batches — the SD card is much slower than the radio, and a zero-delay sweep would otherwise be paced by the filesystem. Pausing flushes and closes the file, so it's complete and readable on the card while the sweep sits paused; resuming appends a new session header.
Runs accumulate in the one file. Delete it from the Flipper's file browser (or
over qFlipper) when you want a clean slate.
The Flipper's built-in IR receiver is a 38 kHz demodulator. It usually hears 38 and 40 kHz taggers fine, and often hears 56 kHz ones too — but "often" is not "reliably", and 56 kHz is what MilesTag II actually specifies. For 56 kHz, use an external module:
TSOP38456 Flipper GPIO header
VCC ───────────► pin 9 (3V3)
GND ───────────► pin 8/11/18 (GND)
OUT ───────────► pin 2 (PA7), or any other RX GPIO
The module demodulates the carrier itself and hands the Flipper a plain digital envelope, so the app never has to sample the carrier — it only measures how long the module held its output low. Available pins are PA7, PA6, PA4, PB2, PB3, PC0, PC1, PC3.
PA7 is also the external transmit pin. If you set TX Output to External, put the receiver on a different pin.
Both read screens open the same setup list:
| Row | Values | Notes |
|---|---|---|
| RX Source | Internal / GPIO | GPIO reveals the pin row below it |
| RX GPIO | PA7 … PC3 | only the pins usable as digital inputs |
| Frequency | 38 / 40 / 56 kHz | used to validate, decode, retransmit and save |
| Duty cycle | 33 / 40 / 50 % | for retransmission and the saved .ir |
| TX Output | Internal / External | which emitter replays the capture |
| Send Count | 1–10 or Blast | Blast repeats until you press Back |
| Shot Delay | 0.05–2.00 s | between individual transmissions, default 0.25 s |
| Try-All Delay | 0.50–10.00 s | between brute-force candidates, default 3.00 s |
| Go | starts listening |
Settings persist between runs.
Waits for a packet, decodes it, and shows what it means:
Shot 14 timings
00000111 010000
{plr:007 Blaze, team:Blue, dmg:20}
Save Send Edit
- Send — retransmit it, using the configured count, delay and output
- Save — write the capture to
/ext/infraredas a raw.irfile - Edit — open the bit editor
A capture with unresolved bits reads 110?0101?1, and OK opens the editor
instead of sending.
Bit editor Shot
00000111 01?000
‾
{plr:007 Blaze, team:Blue, dmg:?}
- Left / Right — move between bits
- OK — cycle the selected bit
1 → 0 → ? → 1 - Down — open the action menu
The reading underneath is recomputed on every keypress, so you can watch a field
resolve as you set its bits — and see it fall back to ??? if the pattern stops
being a MilesTag layout at all.
The actions are:
- Go — transmit. Refused while any
?remains, naming the positions that still need resolving; the app never picks a value on your behalf. - Send Raw — transmit the capture these bits were read out of, untouched. Ignores every edit on the screen, and the bit count, and which window the scan settled on. This is the escape hatch for when the reading is wrong and the capture is not.
- Try All — send every combination of the unresolved bits, waiting the Try-All delay between each so you can watch the target. Capped at 16 unknown bits (65,536 attempts, already over two days at three seconds each).
- Save — write a
.irfile: the regenerated packet if every bit is resolved, otherwise the original capture.
During a Try All run the screen counts attempts and shows the candidate on the air. Press OK the moment something reacts — the sweep stops immediately and offers to save the packet that worked. Back stops it without saving.
Same setup, two differences: it listens for a much wider window, and it never requires what arrives to be MilesTag.
Read Signal closes its capture at the first real silence — 8 ms — and decodes the result as a packet starting at its very first timing. Read Raw keeps listening for 120 ms, so a whole trigger burst, its repeats and whatever sat between them all land in one buffer. Then it goes looking:
The rolling window. A MilesTag packet is a header, then up to 24 symbols. Nothing guarantees the one you want starts at timing zero of a wide capture — the receiver may have opened mid-packet, or on noise, or on the tail of something else entirely. So rather than testing one alignment, Read Raw walks every alignment in the window, decodes what it finds at each, scores how close that reading is to the shape MilesTag actually uses, and keeps the best.
The scoring is what makes this work. A 600 µs gap and a 600 µs
0mark are the same length, so a window read one timing out of step still parses — as an unbroken run of zeros. It loses, because it has no header, no0xE8terminator and no recognised bit count, and the correctly aligned window has all three.
What you get is the bits, in binary, ready to edit:
Raw 312 timings
100000110000001011101000
{msg:Command, cmd:Start Game} @84H
Save Send Raw Edit
The @84H is where the window was found: alignment 84 of the capture, opening
on a header (H is absent when the window started straight onto a symbol,
which is what a capture that began mid-packet looks like).
- Send Raw — put the whole capture back on the air, exactly as received. Not the window, not the bits, not a regenerated packet — all 312 timings. This is the default action here on purpose: it is the one thing that does not depend on the scan having guessed right.
- Edit — open the bit editor on the window's bits, where Go sends the
edited packet, Try All sweeps the
?bits, and Send Raw is available again. - Save — write the capture to
/ext/infraredunder a generated name.
A capture the scan can make nothing of still reaches this screen — with no bits, and Send Raw and Save as the only things that make sense. Read Raw exists for signals the decoder cannot read, so refusing the ones it reads least would defeat the point.
Once you send, the replay screen offers Send Again and Save. Send Again disappears whenever pressing it would do nothing — during a transmission, and for the whole of a Blast, which is already sending continuously.
Saving is a deliberate act, and a signal you only get to hear once is exactly the kind of thing you realise you wanted about a second after you have walked away from it. So the moment a capture lands — before you are asked anything, and before any check can decide it is unusable — it is written to a fixed name:
| Screen | File |
|---|---|
| Read Signal | /ext/infrared/last_read.ir |
| Read Raw | /ext/infrared/last_read_raw.ir |
Each new read replaces the previous one. These are a safety net, not a pile: "the last thing I heard" is the only thing they promise, and the numbered files below are still how you keep something. Read Raw's copy is the whole window, not just the part the scan decoded.
If the card refuses the write, the read still succeeds — the signal is in memory and on screen — and the screen says so on its status line.
Captures you choose to keep are written to /ext/infrared, alongside everything
else the stock Infrared app can replay:
Filetype: IR signals file
Version: 1
#
name: MilesTag_56k_00000111010000
type: raw
frequency: 56000
duty_cycle: 0.500000
data: 2400 600 600 600 600 600 ...
The name carries the bit string when it is known, so a saved shot is
identifiable at a glance; captures that could not be decoded get
MilesTag_RAW_56k instead. Existing files are never overwritten — a numeric
suffix is added if needed.
Saving always writes the raw timings rather than a parsed packet. Raw is the
format that can hold everything — an undecodable capture as easily as a clean
one — and it stays replayable by any app that reads .ir.
Watches one ext-header pin and records what it is doing. Useful here for the obvious thing: checking that the TSOP you just wired up is producing anything at all, before wondering why Read Signal hears nothing.
The file is the product. The screen is feedback. Everything below is built around getting correct, densely sampled, honestly timestamped data onto the card; the trace on the 128x64 display is whatever falls out of that for free.
| Analog | Digital | |
|---|---|---|
| How | ADC samples the pin's voltage | rise/fall interrupt timestamps transitions |
| Records | time_us,value — value 0.0000–1.0000 |
time_us,level — level 0 or 1 |
| Resolution | the sample period (~10–100 µs) | the interrupt latency (~1 µs) |
| Use it for | anything whose shape matters — a floating pin, a sensor, a divider, an unknown | a TSOP's demodulated envelope, where exact edge timing is the whole content |
Picking wrong is not a small error. Run a genuinely analog signal through the
digital path and the pin's Schmitt trigger manufactures transitions out of the
waveform crossing its threshold. The recording comes out perfectly
self-consistent, alternating 0,1,0,1 forever — because that is the only thing
an edge list can do — and describes the digitiser rather than the signal.
Analog needs a pin wired to an ADC channel. PA7, PA6, PA4, PC0, PC1 and PC3
have one; PB2 and PB3 do not — the setup screen marks a pin it cannot sample
with a !, and the trace screen says NO ADC rather than drawing a flat line
and letting you believe it. The mapping is read out of the firmware's own pin
table, not hardcoded here.
A recorded 1.0000 means 2500 mV or above. The ADC's reference cannot go higher
than that, so a 3V3 logic high clips — the shape is right, the top is flat.
There is no scale on this chip that avoids it.
| Row | Values | Notes |
|---|---|---|
| Input | Analog / Digital | decides what everything below means |
| GPIO Pin | PA7 … PC3 | ! marks a pin with no ADC channel |
| Rate | Max … 1k | analog only; samples per second |
| Pull | Up / None | digital only; Up idles an unconnected pin high |
| Time/div | 10 µs … 50 ms | display only — never touches the file |
| Record | On / Off | stream the trace to the card |
| Max Size | 64 KB … 16 MB | recording stops here; only shown when Record is on |
| Start | takes the pin and starts acquiring |
The ADC HAL is blunt about its limits: "No DMA or interrupt API available at this point." Every sample costs a blocking CPU read, so there is no way to sample continuously without pinning the core. The scope samples in ~4 ms bursts and comes up for air between them to write to the card.
That leaves gaps — and the gaps are the point:
Every sample and every edge carries its own timestamp, taken from the cycle counter at the moment it was acquired. Nothing in the file is interpolated, averaged, or assumed. A gap between two true timestamps is visible and harmless. Evenly spaced rows that quietly skipped time would be a lie you could not detect afterwards.
The ceiling is not the ADC, it is the card. A row is ~18 bytes, so 10 kSa/s is already ~180 KB/s of writing. Past what the card sustains, acquisition spends its time writing instead of sampling — so the screen shows the achieved rate, not the requested one. When that number falls away from the Rate setting, come back down a notch. 10 kSa/s is the default because it is dense and the card keeps up.
While the analog scope is running it does not sleep, so the app is busier than usual and the rest of the UI is less snappy. That is the cost of a sample rate with no DMA behind it.
Scope PA7~ 100us/div TRIG
▌▌ ▌ ▌▌▌ ▌▌ ▌▌▌▌▌
─────── ────
1.4s 9.8k/s 0.62
OK hold <> zoom ^ rec v clear
The ~ after the pin means analog. Each column is one peak-detect bar, from the
lowest to the highest value seen during it — the same thing a real scope shows.
A digital capture lands on the rails and reads as a logic trace; an analog one
uses the full height. The right-hand number is the live level in the same units
the file records, or REC/FULL/NO ADC when there is something to say about
the file.
The corner says which of three things the trace is: nothing for a live roll,
TRIG for a held triggered window, HOLD for a display you froze yourself.
- OK — hold. Freezes the display and nothing else: acquisition continues and the file keeps being written. Back is what stops collecting.
- Left / Right — faster / slower time base. A display setting; it does not restart the run and does not reach the file.
- Up — start or stop recording. Starting opens a fresh file.
- Down — clear the trace and start over.
The screen redraws ten times a second and holds 128 columns. At 1 ms per column that is 128 ms of signal — more than one redraw, so a rolling trace shows every microsecond that passed. At 10 µs per column it is 1.28 ms, and a display showing 1.28 ms once every 100 ms is showing 1.3% of the time line while looking exactly like a display that is not discarding anything.
So below the crossover the screen triggers instead of rolling: it waits for an event — an edge in digital, a jump of a quarter of full scale in analog — collects one screenful around it with 16 columns of lead-in, and holds that until a newer one completes. This only ever affects the display. The file has everything either way.
Memory is 256 bytes: a minimum and a maximum per column. A column that scrolls off the left edge is gone from RAM; the record is in the file.
/ext/apps_data/miles_tag_ii/last_scope.csv, overwritten at the start of every
run. Analog:
# MilesTag II scope, pin PC1, analog
# value is a fraction of full scale; 1.0000 = 2500 mV or above
# every row is timestamped where it was sampled - gaps are real
time_us,value
1204,0.5123
1304,0.5119
1404,0.0031
Digital:
# MilesTag II scope, pin PA7, digital
# one row per transition; level is the pin after the edge
time_us,level
1204,0
1802,1
Times are microseconds since the run started. Values are formatted from integers
— the fraction is computed in ten-thousandths and printed as two integer fields,
so no float ever reaches the formatter and the last digit is the ADC's, not the
printer's. Zooming writes a # timebase now Nus/column line into the stream, so
the header can never describe a scale the rest of the file was not written at.
The size cap is not optional. Recording stops at the limit, the screen
changes REC to FULL, and the file is closed cleanly — what is on disk is a
complete prefix of the run rather than a truncation. Acquisition keeps going;
only the writing stops.
make setup # install ufbt and pull the Flipper SDK
make build # build the .fap
make install SD=E: # copy it to an SD card mounted at E:
make launch # build and run on a USB-connected Flipper
make test # host-side protocol and decoder checks
make cleanufbt builds into ~/.ufbt/build/miles_tag_ii.fap, a per-user cache shared by
every ufbt app — make install and make launch know where to find it.
make test needs no hardware. It runs two host-side suites, each of which
re-derives the behaviour independently and then checks that the C agrees:
- tests/test_protocol.py — the packet formats, from the specification, against the tables and layouts in miles_tag_protocol.c.
- tests/test_decoder.py — a reference decoder mirroring miles_tag_decoder.c, fed perfect signals, stretched and squashed ones, ambiguous marks, truncated packets and trailing noise. It also pins down the Try All candidate ordering, so a successful brute-force attempt stays reproducible.
Neither can catch a hardware problem — for that, test each receiver against each carrier on a real tagger.
Push a branch named rel-* (e.g. rel-1.1.0) and
.github/workflows/release.yml builds the .fap
and publishes a GitHub Release named after the branch, with
<appid>-<branch>.fap attached. Pushing again to the same branch replaces the
asset rather than creating a second release. workflow_dispatch re-releases a
branch without needing an empty commit.
application.fam is the single source of truth for the app's identity: the
Makefile reads appid, name and fap_version out of it, and CI asks the
Makefile via make -s print-appid / print-name / print-version / print-fap.
Neither the Makefile nor the workflow spells the app's name anywhere, so both
can be copied to another Flipper app unchanged — only application.fam needs
editing.
| File | Contents |
|---|---|
| miles_tag_protocol.c | packet encoding, lookup tables, formatters |
| miles_tag_decoder.c | timings → bits, the rolling window scan, unknown-bit detection, plain-English readings |
| miles_tag_rx.c | receiver: internal IR HAL and external GPIO capture |
| miles_tag_scope.c | oscilloscope: ADC sampling and GPIO edge capture, peak-detect columns, streamed trace file |
| miles_tag_tx.c | IR worker thread: bursts, sweeps, replays, Try All |
| miles_tag_raw.c | raw .ir serialisation and filename generation |
| miles_tag_log.c | buffered CSV log of every combination swept |
| miles_tag_ii.c | app setup, views, scene wiring |
| miles_tag_settings.c | settings saved to app data between runs |
| scenes/ | one file per screen |
| views/ | the custom fire, sweep, read, bit-editor, replay and scope screens |
Reception, decoding and transmission are three independent layers, and the receiver never drives the transmitter directly:
internal IR / external GPIO
│
▼
raw timing capture ──────────────┬─────────► last_read[_raw].ir (automatic)
│ │
│ └─────────► save .ir (on request)
│ │
├────────────────────────┘
│ "Send Raw" hands the capture straight across, unread
▼
MilesTag decoder / rolling scan
│
▼
bits (0/1/?)
│
▼
bit editor
│
▼
MilesTagTxSignal ─────────► existing TX worker
That is what lets one capture be understood, modified and replayed exactly,
and it is why MilesTagTxSignal is the only type the receive side hands over:
whether the timings were regenerated from edited bits or captured verbatim, the
transmitter treats them identically, so there is one piece of code driving the
IR hardware rather than two.
Interrupt handlers only ever write into the capture buffer. Completion is
handed to the RX worker thread, which is what raises the GUI event — a Furi
message queue cannot be posted to with an infinite timeout from an ISR, so
calling view_dispatcher_send_custom_event() there would silently drop the
capture. The oscilloscope follows the same rule for the same reason: its
interrupt pushes a timestamped edge into a ring buffer and does nothing else,
and a worker thread is what touches the filesystem.
MilesTagTxSignal holds a full capture's worth of timings — a little over four
kilobytes — because "Send Raw" means the whole window goes back out. That is
why it is never a local: every one of them lives inside a struct that was itself
heap-allocated, and the two scenes that need a scratch one malloc it.
Two Flipper GUI details are worth knowing if you extend this:
VariableItemListruns its change and enter callbacks while holding its own view-model mutex, andscene_manager_next_scene()runs the outgoing scene'son_exit— which touches that same model. Navigating straight from a callback deadlocks, so row actions are deferred through custom events. (Submenucalls its callbacks outside the lock, so those scenes navigate directly.)- A variable item's value count is a
uint8_t, so a full 0–255 byte cannot be a row; the custom data byte uses aByteInputscreen instead.
For use on your own equipment and in games you're part of. Transmitting admin commands (Admin Kill, End Game, Restore Defaults) at someone else's laser tag event is a good way to get thrown out of it.