Skip to content

About

A Flipper app that lets you send miles tag ii protocol at 38, 40 or 56k

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

12 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MilesTag II — Flipper Zero

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.

Protocol

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.

Line coding

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.

Shot packet — 14 bits

[header][0 ppppppp][tt dddd]
  • 0 marks the packet as a shot
  • ppppppp — player ID, 0–127 (IDs 0–59 have stock display names: 000 Eagle, 001 Joker, …)
  • tt — team: 0 Red, 1 Blue, 2 Yellow, 3 Green
  • dddd — 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.

Message packet — 24 bits

[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.

Using the app

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.

Send Signal

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.

Brute Force

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.

The sweep log

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.

Receiving

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.

Read configuration

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.

Read Signal

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/infrared as a raw .ir file
  • Edit — open the bit editor

A capture with unresolved bits reads 110?0101?1, and OK opens the editor instead of sending.

The bit editor

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 .ir file: 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.

Read Raw

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 0 mark 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, no 0xE8 terminator 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/infrared under 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.

Every read is logged automatically

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.

Saved files

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.

Oscilloscope

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 or digital — this is the setting that matters

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.

Setup

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

Rate, and why gaps in the file are the honest answer

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.

The screen

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.

Rolling, triggering, and the 128-byte trace

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.

The trace 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.

Building

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 clean

ufbt 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.

Releasing

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.

Layout

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:

  • VariableItemList runs its change and enter callbacks while holding its own view-model mutex, and scene_manager_next_scene() runs the outgoing scene's on_exit — which touches that same model. Navigating straight from a callback deadlocks, so row actions are deferred through custom events. (Submenu calls 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 a ByteInput screen instead.

Legal

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.

About

A Flipper app that lets you send miles tag ii protocol at 38, 40 or 56k

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages