A lightweight RFID interface test harness for Ultra Tracker, with the Zebra FXR90 as the first simulated reader implementation.
The project is intentionally not a complete Zebra FXR90 implementation. It reproduces the externally visible behavior that Ultra Tracker consumes so the RFID interface can be tested with normal race traffic, high-volume traffic, bursts, unknown tags, reconnects, and controlled failure scenarios.
The architecture is intended to make additional reader simulators possible later:
Ultra Tracker
│
│ RFID interface
▼
RFID Interface Test Harness
│
├── Zebra FXR90 simulator ← current implementation
├── Future reader simulator
└── Future reader simulator
The current implementation simulates the subset of the Zebra FXR90 interface used by Ultra Tracker:
- HTTPS REST API on port 443
- WebSocket Secure (WSS) on port 443
- REST login with HTTP Basic authentication
- Bearer-authenticated
/cloud/*endpoints - Unauthenticated
/wsWebSocket
HTTPS REST: https://<host>:443/cloud/...
WSS: wss://<host>:443/ws
The FXR90-specific REST paths, WebSocket payload format, and FX90_* environment variables are retained because they are part of the current Ultra Tracker integration contract.
The harness includes a browser-based control panel for repeatable integration and stress testing:
https://<host>:443/test/
It provides:
- Live scan status
- Live counters for total tags, legitimate runner tags, noise tags, remaining runners, custom noise remaining, and WebSocket clients
- Race configuration for runner count, bib range, tag order, noise percentage, and one-read-per-tag behavior
- Custom noise tags supplied as a JSON array
- Burst configuration for burst size, duration, time between bursts, and artificial tag delay
- Failure injection for WebSocket disconnects after a tag count or elapsed time
- Scenario selection and time acceleration for long-duration race simulations
- Start, stop, save, and reset controls
- Diagnostic JSON containing the complete current test state
Settings are runtime-only and can only be changed while the simulated reader is stopped.
Scenario simulation is designed for longer, repeatable race-day traffic patterns that are difficult to reproduce with simple burst settings. Scenarios are versioned JSON definitions stored under src/fx90_simulator/scenarios/ and are loaded by name through the test API and control panel.
Built-in scenarios currently include:
| Scenario | Purpose | Duration | Runners | Burst max | Distribution |
|---|---|---|---|---|---|
| Start Line | Heavy early race-start traffic with a long tail | 1 hour | 500 | 20 | Truncated normal |
| Finish Line | Intermittent finish traffic with a long tail | 17 hours | 500 | 5 | Log-normal |
| Short Burst | Fast development/integration scenario | 60 seconds | 50 | 20 | Truncated normal |
The scenario configuration is the source of truth for the traffic schedule. Each scenario has a deterministic random_seed, so the same configuration produces the same arrival schedule. Runner tags remain unique when report_each_tag_once is enabled.
Scenario noise is additive: configured noise events are inserted in addition to the scheduled runner reads rather than replacing them. This means a 500-runner scenario still delivers all 500 legitimate runner tags even when noise is enabled.
The control panel provides four time-scale choices:
1x normal simulated time
10x ten times faster
60x sixty times faster
600x six hundred times faster
For example, the one-hour Start Line scenario completes in about one minute at 60×, while the 17-hour Finish Line scenario completes in about 1.7 minutes at 600×.
Within a scenario burst, the scheduled arrival spacing is also scaled by time_scale. The configured max_duration_seconds therefore represents actual simulated spacing between events, not just a grouping hint.
A scenario definition has this general shape:
{
"name": "Start Line",
"version": 1,
"type": "start-line",
"duration_seconds": 3600,
"runner_count": 500,
"bib_start": 1,
"bib_end": 500,
"distribution": {
"type": "truncated-normal",
"center_seconds": 480,
"spread_seconds": 720
},
"burst": {
"max_size": 20,
"max_duration_seconds": 0.8
},
"noise": {
"percent": 5
},
"report_each_tag_once": true,
"random_seed": 12345,
"time_scale": 1
}Supported arrival distributions are truncated-normal and log-normal. Burst settings limit how many scheduled arrivals may be grouped together and the maximum simulated duration of that burst. Scenario validation also ensures bib numbers fit the four-digit decimal RFID representation and that the configured runner count fits the bib range.
To inspect the available scenarios through the API:
GET /test/scenarios
To select a built-in scenario:
PUT /test/scenario
with a body such as:
{"name":"Start Line"}A selected scenario is started with:
POST /test/scenario/start
API clients must select a scenario before calling POST /test/scenario/start. If no scenario is selected, the start request returns 400 no scenario selected. The browser control panel performs the selection automatically when you choose a scenario.
Resetting the test clears the selected scenario:
POST /test/reset
Custom noise tags are treated as a one-shot queue. When a noise event occurs, the next supplied custom tag is sent in list order. Once the list is exhausted, the generated noise pool is used.
This is useful for parser and error-handling tests because custom values are not required to look like valid RFID tags. For example:
[
"00000000000000000000015A",
"00000000000000000000015B",
"00000000000000000000015C"
]The Custom noise remaining counter shows how many supplied values are still waiting to be consumed. Because noise selection is probabilistic, the queue is not a fixed number of events; it drains only when noise events are selected.
- Open
/test/in a browser. - For a normal race simulation, select a built-in scenario and choose a time scale.
- For short/manual tests, configure the race, burst, noise, and failure-injection settings instead.
- Click Save Settings when using manual settings.
- Connect Ultra Tracker to the harness.
- Click Start Scan or Start Scenario.
- Watch the live counters and WebSocket client count.
- Use Stop Scan or Reset before changing the configuration.
For example, setting disconnect_after_tags to 190 deliberately closes the WebSocket after approximately 190 delivered events, allowing Ultra Tracker reconnect and recovery behavior to be tested against a repeatable failure.
The test control API is also available directly:
GET /test/status
GET /test/scenarios
PUT /test/config
PUT /test/scenario
POST /test/start
POST /test/scenario/start
POST /test/stop
POST /test/reset
GET /test/
Install dependencies and run the complete suite:
pytestUseful subsets:
pytest -m unit
pytest -m integration
pytest -m websocket
pytest -m stressThe stress marker is reserved for tests that intentionally generate large or high-volume traffic so those tests do not have to run on every small code change.
Scenario tests cover built-in scenario loading, deterministic scheduling, duration bounds, burst limits, bib-range validation, deterministic tag generation, and additive scenario noise behavior.
This section documents the current reader-specific compatibility contract. It is deliberately kept separate from the generic test-harness purpose so additional reader implementations can be added without changing the overall project description.
GET /cloud/localRestLogin uses HTTP Basic authentication and returns:
{"code":0,"message":"<bearer-token>"}All other supported REST endpoints require Authorization: Bearer <token>.
Supported endpoints:
GET /cloud/localRestLogin
GET /cloud/status
GET /cloud/mode
PUT /cloud/mode
PUT /cloud/start
PUT /cloud/stop
PUT /cloud/start accepts the request body used by Ultra Tracker ({"doNotPersistState":true}), starts tag generation, and returns HTTP 204. If already active it returns HTTP 422 with start currently ongoing.
PUT /cloud/stop stops tag generation and returns HTTP 204.
GET /cloud/status reports simulated reader state, including radioActivity and antenna state. GET /cloud/mode returns the simulated reader mode. PUT /cloud/mode accepts the request and returns HTTP 204.
Ultra Tracker connects without a Bearer token:
wss://<host>:443/ws
Tag events use the observed FXR90 format:
{"data":{"eventNum":1,"format":"epc","idHex":"000000000000000000001"},"timestamp":"2026-09-06T16:00:00.000-0600","type":"CUSTOM"}The idHex format is compatible with Ultra Tracker's current parser: 20 leading zeroes followed by the decimal runner/tag number.
The default configuration is designed around a typical ultra race with roughly 300–400 runners:
- 400 legitimate runner tags by default
- Legitimate runner tags are generated from the configured bib range and reported once per reader start
- Runner reads can be sent in sequential or randomized order
- Runner reads are grouped into bursts of up to 20 events
- A burst targets approximately one second or less
- 5% unknown/noise tags by default
- Generated noise uses bib numbers outside the configured runner range
- Tag generation remains stopped until
/cloud/startis called unlessFX90_AUTO_START=true - Periodic heartbeat logs report scan state, counts, remaining runners, custom noise remaining, and connected WebSocket clients
The harness also supports deliberate WebSocket disconnects and artificial delivery delay so reconnect, health-check, timeout, and throughput behavior can be tested deliberately rather than relying only on failures from a physical reader.
fx90-simulator/
├── src/fx90_simulator/
│ ├── api/ # REST, WebSocket, and test-control interfaces
│ ├── simulator/ # Simulated reader, scenarios, test controls, and tag generation
│ ├── scenarios/ # Versioned built-in scenario JSON definitions
│ ├── config.py # Environment-based configuration
│ └── main.py # Application entry point
├── tests/ # Automated unit and integration tests
├── scripts/ # Developer utilities
├── certs/ # Local TLS certificates (not committed)
├── data/ # Runtime and captured data
├── systemd/ # Native Linux service definition
├── .devcontainer/ # VS Code Dev Container configuration
├── Dockerfile
├── compose.yaml
├── requirements.txt
└── README.md
Configuration is supplied through environment variables. Docker Compose loads credentials from a local .secrets file.
Create the local secrets file from the committed template:
cp secrets_example .secretsImportant settings include:
| Variable | Default | Purpose |
|---|---|---|
FX90_HOST |
0.0.0.0 |
Listen address |
FX90_HTTPS_PORT |
443 |
REST and WSS port |
FX90_BIB_START |
1 |
First possible legitimate bib number |
FX90_BIB_END |
429 |
Last possible legitimate bib number |
FX90_RUNNER_COUNT |
400 |
Number of legitimate runner tags per reader start |
FX90_TAG_ORDER |
random |
Legitimate tag order: random or sequential |
FX90_NOISE_PERCENT |
5 |
Percentage of generated events using noise tags |
FX90_MAX_BURST_SIZE |
20 |
Maximum events in one burst |
FX90_MAX_BURST_SECONDS |
0.8 |
Target maximum burst duration |
FX90_BETWEEN_BURSTS_MIN |
2 |
Minimum seconds between bursts |
FX90_BETWEEN_BURSTS_MAX |
8 |
Maximum seconds between bursts |
FX90_REPORT_EACH_TAG_ONCE |
true |
Report each legitimate runner once per start |
FX90_NOISE_POOL_SIZE |
50 |
Number of reusable generated noise tags |
FX90_AUTO_START |
false |
Start automatically |
FX90_HEARTBEAT_SECONDS |
5 |
Diagnostic heartbeat interval |
FX90_CORS_ORIGINS |
(unset) | Comma-separated allowed browser origins |
Legitimate RFID values are derived directly from the bib number. For example:
Bib 1 -> 000000000000000000001
Bib 10 -> 000000000000000000010
Bib 190 -> 000000000000000000190
Bib 429 -> 000000000000000000429
No runner/tag data file is required.
The harness uses TLS because Ultra Tracker connects to the simulated FXR90 over HTTPS and WSS. Generate local certificates with:
./scripts/generate-certs.shThe script detects the machine's hostname and primary IP and includes them in the server certificate's Subject Alternative Names.
If Ultra Tracker's sslCert field is being used for certificate pinning, obtain the server certificate serial with:
openssl x509 -in certs/server.crt -noout -serialEnter the hexadecimal value after serial= as the certificate pin.
Keep ca.key and server.key private. Do not commit private keys or generated certificates.
git clone https://github.com/JordenLuke/fx90-simulator.git
cd fx90-simulator
git checkout develop
cp secrets_example .secrets
./scripts/generate-certs.sh
docker compose up -d --buildThe container publishes HTTPS/WSS on port 443.
Check the service:
docker compose ps
docker compose logs -fStop it with:
docker compose downNative deployment is useful on a Raspberry Pi or another Linux host where Docker is not desired. The examples install the harness under /opt/fx90-simulator.
sudo apt update
sudo apt install -y git python3 python3-venv openssl
sudo mkdir -p /opt
sudo git clone https://github.com/JordenLuke/fx90-simulator.git /opt/fx90-simulator
sudo chown -R "$USER":"$USER" /opt/fx90-simulator
cd /opt/fx90-simulator
git checkout develop
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
./scripts/generate-certs.shFor normal operation, use the repository's systemd service rather than running the application as root.
The repository includes a VS Code Dev Container configuration. Open the repository in VS Code and select Reopen in Container. The harness is available on port 443.
scripts/capture.py is a utility for the current Zebra FXR90-compatible WebSocket interface. It records raw tag data for analysis or replay work and can reconnect after reader-side WebSocket/TCP resets.
python3 scripts/capture.py
python3 scripts/capture.py --cafile certs/ca.crtCaptured data is stored under data/.
This harness is intended for controlled test networks. The default credentials are development defaults and should be changed before exposing the harness to an untrusted network.
MIT ©2026