Invigil finds hidden phones, Bluetooth earpieces and smartwatches in an exam hall by listening to the 2.4 GHz radio band, without transmitting anything.
Every signal leaves a trace.
One sensor unit sits at the edge of a 6-seat mini exam hall. It is a Raspberry Pi Pico 2 W with an nRF24L01+PA+LNA radio, and it listens in two ways:
- BLE scanner. The Pico's own radio hears Bluetooth Low Energy advertisements and reads the signal strength (RSSI) and the manufacturer ID. The address is hashed on the unit.
- Channel scanner. The nRF24L01 sweeps 126 channels of the 2.4 GHz band and counts where it hears a carrier. Bluetooth audio shows up as scattered hits across the band because it hops between channels. Wi-Fi shows up as fixed blocks.
The unit sends its readings over Wi-Fi with MQTT to a laptop. On the laptop, a model answers two questions about each device:
- Device type: phone, earpiece, smartwatch, or allowed (a proctor's device).
- Distance band from the unit: near (under 1 m), mid (1 to 2 m), or far (2 to 3.5 m).
flowchart LR
D["Phone, earpiece or smartwatch"] -->|2.4 GHz signals| U
subgraph U["Unit N1: Pico 2 W + nRF24L01+PA+LNA"]
B["BLE scanner: hashed address, RSSI, manufacturer ID"]
S["Channel scanner: hits on 126 channels"]
end
U -->|Wi-Fi, MQTT| M["Mosquitto broker on the laptop"]
M --> L["Logger: checks each message, writes CSV"]
L --> F["Features per time window"]
F --> T["Model 1: device type"]
F --> R["Model 2: distance band"]
T --> A["Dashboard: alerts and hall map"]
R --> A
The message formats are in the MQTT contract.
Invigil is a Grade 12 school capstone and a work in progress. The hardware is not built yet, so nothing here has been tested against real devices, and no accuracy or range figure exists yet.
What works today, on a laptop with no hardware:
- Logger (
server/logger.py): subscribes to the unit's topics, checks every message against the contract, and writes valid readings to CSV. - Fake publisher (
server/fake_publisher.py): stands in for the unit and publishes readings in the right format. The numbers are placeholders, not physics. - ML pipeline (
ml/): turns logged sessions into features, trains both models, and tests them on held-out sessions. It has only been run on synthetic data, which proves the code runs and nothing more. - Dashboard (
dashboard/): the web app, with a Demo source that needs no broker and a Live source that reads Mosquitto over WebSockets. Alerts and sessions only exist in Demo so far, and no test results exist at all. See Dashboard. - Tests for the contract, the logger, the fake publisher, the ML pipeline and the dashboard's data layer.
Written but not tested on hardware:
- Firmware (
firmware/): the unit code for the Pico 2 W. It builds in CI and its hardware-free parts pass host tests, but it has never run on a real board. See Unit firmware.
Not started: the digital twin.
| Part | Quantity | Used for | Price (EGP) |
|---|---|---|---|
| Raspberry Pi Pico 2 W (RP2350 + CYW43439) | 1 | Main board, BLE scanner, Wi-Fi | 1,050 (Dev Boards Market) |
| nRF24L01+PA+LNA module with antenna | 1 | 2.4 GHz channel scanner, receive only | 220 (Makers Electronics) |
| nRF24L01 adapter board with 3.3 V regulator (AMS1117-3.3) | 1 | Powers the radio from the Pico's 5 V | 30 (Makers Electronics) |
| 100 uF 25 V electrolytic capacitor | 1 | Steadies the radio's supply | 1 |
| Breadboard (830 points) and 20 jumper wires | 1 set | Wiring | 50 |
| 1x40 male pin header | 2 | Soldered to the Pico so it plugs into the breadboard | 10 |
| Micro USB cable | 1 | Power and flashing | Owned |
| USB power bank | 1 | Power in the hall | Owned |
| Total | About 1,361 |
Prices are store listings from October 2026, before delivery. A laptop on the same Wi-Fi network runs the broker, the logger and the models.
Check each pin against the Pico 2 W pinout before soldering. Physical pins are counted from pin 1 at the USB end, down the left side (1 to 20), then up the right side (21 to 40).
Bare module, wired straight to the Pico:
| nRF24L01+PA+LNA pin | Pico 2 W pin | Physical pin |
|---|---|---|
| VCC | 3V3(OUT). Never 5 V | 36 |
| GND | GND | 23 |
| SCK | GP18 (SPI0) | 24 |
| MOSI | GP19 (SPI0) | 25 |
| MISO | GP16 (SPI0) | 21 |
| CSN | GP17 | 22 |
| CE | GP20 | 26 |
| IRQ | Not connected |
Put a 100 uF 25 V capacitor across the radio's VCC and GND, close to the module.
With the adapter board. The radio plugs into the adapter. The adapter has its own 3.3 V regulator, so its VCC goes to VBUS, the 5 V from USB. The signal pins are the same as for the bare module.
| Adapter pin | Pico 2 W pin | Physical pin |
|---|---|---|
| VCC | VBUS (5 V from USB) | 40 |
| GND | GND | 23 |
| SCK | GP18 (SPI0) | 24 |
| MOSI | GP19 (SPI0) | 25 |
| MISO | GP16 (SPI0) | 21 |
| CSN | GP17 | 22 |
| CE | GP20 | 26 |
| IRQ | Not connected |
VBUS only has power when the Pico is powered through its USB port, which is the case with the laptop or the power bank.
You can run the whole laptop side with fake data and no hardware. These commands are for Windows PowerShell, run from the repo root.
-
Install Python 3.11 or newer and Mosquitto. The Windows installer adds a Mosquitto service that runs a broker on
localhost:1883. -
Create a virtual environment and install the dependencies:
python -m venv .venv .venv\Scripts\Activate.ps1 pip install -r requirements.txt
-
Start the logger in one terminal:
python -m server.logger --session fake-test
-
Start the fake unit in a second terminal (activate the virtual environment there too):
python -m server.fake_publisherThe logger prints a summary every 10 seconds and writes CSV files to
data/raw/<date>-fake-test/. Press Ctrl+C in each terminal to stop. -
Run the tests:
python -m pytest -
Check the ML pipeline on synthetic sessions:
python -m ml.synth python -m ml.train --data data/synthetic
The score it prints only proves the code runs. Never report it.
Linux and macOS: use python3 -m venv .venv and source .venv/bin/activate. Install Mosquitto with your package manager (sudo apt install mosquitto or brew install mosquitto) and start it with mosquitto -v if it is not already running. Every python -m ... command is the same.
More detail:
- Setup and operation: config file, command options, broker setup for the real unit, CSV formats, what the logger rejects.
- Address hashing: the hashing rule and the test vector the firmware must reproduce.
- Data collection protocol: how to record and label sessions so the accuracy number is honest.
- Firmware plan: modules and what must be measured on the real unit.
The web app in dashboard/ shows the live hall map and alerts, the recorded sessions, the test results, the unit's health and the settings. It is built with Vite, React, TypeScript, Tailwind CSS and shadcn/ui, and themed from docs/brand.
Install Node.js 24 and pnpm, then run it from the dashboard/ folder:
cd dashboard
pnpm install
pnpm devOpen http://localhost:5173. It starts in Demo, so it needs no broker and no hardware.
The dashboard reads everything through one data interface with two sources. Pick one in Settings.
- Demo (the default) makes mock readings in the browser. They follow the MQTT contract and use the same placeholder numbers as the fake publisher: the right shape, not the right physics. A "Demo data" badge stays in the top bar while Demo is active.
- Live connects to Mosquitto with mqtt.js over WebSockets and subscribes to
invigil/node/+/+.
Detections (device type and distance band) are not published on MQTT yet. The topic is only a proposal at the end of the MQTT contract. So in Live the hall map and the alerts list stay empty and say why, and Sessions only fills in Demo until the server has an API for it.
Demo never shows a result. On the Results page every requirement is pending in both sources, and the confusion matrices and the RSSI chart in Demo are marked as sample data.
A browser cannot open a plain MQTT socket, so server/mosquitto.conf has a second listener: WebSockets on port 9001. The unit and the Python tools keep using port 1883, and the login in the config applies to both listeners. The Windows Mosquitto service has no WebSockets listener, so use the project config:
-
Start the broker as in Using the real unit: stop the Windows service, create
server/mosquitto.passwdif you have not, then run:mosquitto -c server/mosquitto.conf -v
The log should show a listen socket on port 1883 and another on port 9001.
-
Put the same username and password in
server/config.toml, then start the fake unit in a second terminal:python -m server.fake_publisher -
Start the dashboard in a third terminal with
pnpm dev, open Settings, choose Live, set the broker URL tows://localhost:9001, enter the username and password, and save. The password is kept for that browser tab only.
The top bar changes to "Unit N1 Online". The Unit page shows a status message every 10 s and the channel scan. The Live page counts the devices heard and charts their RSSI, and the hashes match the ground truth the fake publisher prints.
Every live message is checked the same way server/contract.py checks it: a 12-character hashed address, ts in milliseconds, 126 hit counts, and so on. A message that fails is dropped, counted, and listed with its reason on the Unit page. It never reaches a chart.
To read the dashboard from a phone on the same network, run pnpm dev --host and use the laptop's IP address in both the page URL and the broker URL.
Setting VITE_DEMO_ONLY=true at build time makes a demo-only dashboard for the public site. It always uses the Demo source, hides the Demo and Live switch and the broker fields in Settings, keeps the "Demo data" badge, and adds a notice at the top that links to invigil.xyz and this repo. Without the variable, nothing changes. Only the exact value true turns it on.
To try it locally:
$env:VITE_DEMO_ONLY = "true"
pnpm devVercel project settings:
| Setting | Value |
|---|---|
| Root Directory | dashboard |
| Framework Preset | Vite |
| Build Command | pnpm build |
| Output Directory | dist |
| Environment Variable | VITE_DEMO_ONLY = true |
| Ignored Build Step | git diff HEAD^ HEAD --quiet -- . |
The ignored build step runs inside the root directory, so a commit that does not touch dashboard/ skips the deploy.
CI runs the same four commands in dashboard/:
pnpm lint
pnpm typecheck
pnpm test
pnpm buildThe hall size and the seat positions on the map are placeholders in dashboard/src/data/hall.ts. Measure the real hall and replace them.
| Folder | What goes there |
|---|---|
firmware/ |
Pico 2 W unit code (Pico SDK, C, CMake). Not started |
server/ |
MQTT logger, fake data publisher, later the dashboard API |
ml/ |
features.py, label.py, train.py, synth.py |
data/raw/ |
Recorded sessions. Gitignored, never committed |
data/samples/ |
Small anonymized samples that are safe to commit |
dashboard/ |
Web app: live hall map, alerts, sessions, results, unit health |
simulation/ |
Digital twin built from real calibration data. Not started |
docs/ |
Contract, protocols, brand assets, and results/ for every calibration or test run |
The prototype is tested against these six requirements. None has been measured yet.
| # | Requirement | Target |
|---|---|---|
| 1 | Response time | At most 5 s from when a device starts transmitting |
| 2 | Device type accuracy | At least 85% on held-out sessions |
| 3 | Distance band accuracy | At least 80% on held-out sessions |
| 4 | Alert latency | Unit to dashboard within 2 s |
| 5 | False alarms | At most 1 per exam hour |
| 6 | Detection range | At least 3 m, covering a 6-seat mini hall |
Phase 1, one unit:
- MQTT contract, logger and fake publisher
- ML pipeline with held-out session testing
- Firmware: BLE scanner, channel scanner, Wi-Fi and MQTT link
- Calibration: signal strength at each distance, sweep timing, thresholds
- Dataset: about 72 labeled sessions plus a sealed test set
- Dashboard on demo data and on live readings from the broker
- Detections on MQTT, so the live hall map and alerts fill from the real models
- Digital twin built from the calibration data
Phase 2, later:
- More units, for seat-level location.
- A cellular detector, for phones with Bluetooth off.
- Receive only. The unit listens. It never transmits to jam, block or interfere with any signal, and this project will not accept code that does.
- Addresses are hashed. Every Bluetooth address is salted and hashed (SHA-256, first 12 hex characters) before it touches disk or reaches the dashboard. Raw MAC addresses, audio and packet contents are never stored. See address hashing.
- Raw captures stay on the laptop.
data/raw/is gitignored. Only anonymized samples are shared. - Get permission. Use Invigil only with the permission of whoever runs the exam, and tell the people in the hall. Do not use it to watch people anywhere else.
- Follow the law. Radio monitoring and data protection rules differ between countries. Check the radio and privacy law where you are before you switch it on.
- It is a detector, not a verdict. A detection means a radio signal was heard. It does not prove anyone cheated. A person must check before anyone is accused.
To report a privacy or security problem, see SECURITY.md.
Bug reports, ideas and pull requests are welcome. Read CONTRIBUTING.md and the Code of Conduct first. To cite the project, use CITATION.cff.
Team 12323, Alexandria STEM School, Egypt. Grade 12 capstone, 2026-2027.
- Ahmed Khalifa
- Ali Hamdeen
- Mohanad Tarek
To be added: teachers, mentors and everyone who lent a device for testing.
- Code: MIT.
- Documentation and images in
docs/: Creative Commons Attribution 4.0 International (CC BY 4.0). - The Invigil name and logo: covered by neither license. You may show them when referring to Invigil. Please do not use them for your own product. See the brand notes.