Skip to content

Repository files navigation

in development... MouseLatencyTester

Last commit GitHub stars Open issues

Latest release Downloads Tests CodeQL License

Python 3.10 or newer Windows 10 and 11 Eight UI languages Firmware 1.5 Hardware assembled and verified


Measure how long your mouse actually takes to click — to the microsecond.

A Teensy 2.0 watches a probe touch the switch, the dashboard watches Windows report the click,
and the difference is your real input latency. Archive it, chart it, compare it.


Download for Windows

No hardware yet? The app ships a built-in simulator — Settings → Demo mode.



Gallery

Live test tab, dark theme

Live test — mean, median, P5/P95/P99, IQR, MAD and jitter, with outliers ringed on the chart.


Compare tab with box plot
Compare — box plot with baseline deltas in ms and %
Compare tab with ECDF chart
ECDF — where the slow clicks actually live
Test mode, armed
Test mode — 🟩 armed, press now
Test mode, waiting
Test mode — 🟥 wait / release
More screenshots — light theme, sessions, devices, settings, raw samples, histogram
Live test tab, light theme
Live test — light theme
Sessions archive
Sessions — searchable archive
Raw sample comparison
Compare — raw samples
Histogram comparison
Compare — histogram
Device profiles
Devices — one profile per mouse
Settings with one-click update
Settings — theme, language, one-click update
Test mode, target reached
Test mode — 🟦 target reached

Screenshots are taken in demo mode, which is why they carry the simulated-data banner.


What this actually measures

A metal probe is clamped so that it touches a strip of copper tape on the mouse button at the exact instant the switch actuates. That contact closure is t₀, taken inside an interrupt on the Teensy. The mouse then sends its HID report, Windows delivers the click, and the dashboard answers the Teensy — t₁. The calibrated serial round-trip is subtracted from t₁ − t₀.

The result covers switch debounce, the mouse's internal processing, the wireless or USB link, the polling interval and the OS input stack. It does not cover display or render latency.

Important

The method is completely non-invasive. The mouse is never opened, nothing is soldered to its PCB, and no transistor is wired across its microswitch. The only mouse-side modification is a strip of removable conductive copper tape on the outside of the left button. Peel it off and the mouse is exactly as it was.

Where the project is

The KY-018 build is finished and it works. Every part of it is built, wired and verified on the bench, and every release ships against it. Nothing in the table below is waiting on hardware.

A second front-end is being built — a BPV10 photodiode with a transimpedance amplifier and a comparator, replacing the photoresistor. It is designed, not assembled: no firmware talks to it yet and no measurement in this repository comes from it. It matters because it moves t₁ from a software threshold on a sampled ADC value to a hardware interrupt, which takes the sensor's own response out of the measurement — on a 360 Hz OLED the KY-018 is now the largest unknown left in the chain. Design and BOM: docs/optical_front_end.md.

An enclosure is the other open item: a 3D-printed box for the whole thing, with a mount that holds the probe steady. That one changes no measurement.

Feature maturity

Feature Status
1 Probe method — the measurement above Working and verified on real hardware. Every number the app produces comes from this path
2 OLED SH1106 ✅ Working. Frozen during test mode so I²C traffic cannot disturb the timing
3 KY-018 light sensor ✅ Working, but telemetry only. Recorded with each run; it plays no part in any measurement
4 BTN1 / BTN2 buttons Working. 10/10 acceptance test passed on hardware. BTN1 enters/leaves test mode, BTN2 clears the live run outside test mode. Presses are still counted and logged
5 Probe-to-Photon mode Working, firmware v1.5. Separate mode, own calibration, own metric. Verified on hardware: 40 clicks, ~16 ms median against ~3 ms on the same mouse in Probe-to-PC. Accuracy is limited by the KY-018 — read this before quoting a number
6 Frame cap on the optical target ✅ Working. 30 / 60 / 120 / 240 / 360 / 500 / 1000 fps, or follow the monitor. Recorded with the run
7 Photodiode front-end (BPV10 + OPA380 + TLV3501) 🚧 Designed, not built. Would make t₁ a hardware interrupt instead of a sampled threshold — design
8 In-game measurement 📋 Planned, and it needs row 7 first. Visible but disabled in the mode picker

Rows 1–6 are built and verified. There is no component in this project that is wired but unused, and none that is listed but unwired.


Install

Windows installer  recommended

Grab LatencyTester-<version>-Setup.exe from the latest release.

It installs the dashboard, the firmware sketches and the full documentation into a single folder, so you can flash the Teensy without cloning anything.

  • Per-user install by default — no admin rights needed
  • Your archive lives in Documents\LatencyTester\ and is never touched by the uninstaller
  • Built-in one-click updater: Settings → Check for updates

The installer is not code-signed, so SmartScreen warns on first run. Verify SHA256SUMS.txt from the release.

From source

git clone https://github.com/PrimeBuild-pc/MouseLatencyTester.git
cd MouseLatencyTester
pip install -r requirements.txt
python run_dashboard.py

No hardware?

Settings → Demo mode → Start demo device.

A simulator that speaks the real serial protocol drives the whole interface, the charts and the archive. Runs saved that way are flagged [DEMO] and never mixed with real measurements.

Languages

🇬🇧 English  ·  🇮🇹 Italiano  ·  🇩🇪 Deutsch  ·  🇪🇸 Español  ·  🇫🇷 Français  ·  🇯🇵 日本語  ·  🇷🇺 Русский  ·  🇨🇳 中文

Switch under Settings → Language; the choice persists. Adding another is a single JSON file and no Python at all — see CONTRIBUTING.md.

Documentation

Installation  ·  Flashing the Teensy  ·  Bring-up & electrical notes  ·  First measurement  ·  Troubleshooting  ·  Serial protocol


The dashboard

📊 Live test

Mean, median, min, max, std dev, P5/P95/P99, IQR, MAD and jitter (P95−P5). Live chart with outliers ringed, BTN1/BTN2 counters, and a Measure button for the real polling rate.

🗂 Sessions

Searchable archive. Rename, edit metadata, duplicate as a template for the next configuration, export to CSV.

📈 Compare

Raw samples, box plot, ECDF or histogram. Selectable baseline with Δ median / mean / P95 / P99 in ms and %. Export to PNG, SVG, PDF.

🖱 Devices

One profile per mouse: manufacturer, model, serial, switch type, usual firmware, notes. Detects the connected mouse and reconnects it to its saved profile automatically.

⚙️ Settings

Eight languages, light / dark / system theme, archive backup, demo mode and one-click update. Preferences persist.

🎯 Test mode

Full screen, colour-coded: 🟩 press · 🟥 wait · 🟦 done. The OLED is frozen so the display never disturbs the timing.

Important

Raw data is never discarded. Outliers are detected with the Tukey fence and marked, not removed. They stay in the database, in the CSV export and in every statistic. Deciding what an outlier means is the operator's job, not the software's.

Knowing your mouse

NameDevices → Detect connected mouse reads the USB descriptor and fills in the name, manufacturer and serial. That name is often the internal or dongle name rather than the marketing one (an ATK F1 reports as Compx Wireless mouse 8k dongle-L), so rename it to whatever you like — the profile is linked to the device by its VID:PID hardware ID, not by its name. Once linked, plugging that mouse in selects its profile automatically.

Polling rateLive test → Measure counts the mouse's actual Raw Input reports for two seconds while you move it, and fills the field with the nearest standard rate while reporting what was really counted. It measures what the mouse achieves, which is not always what it is configured for. If you do not move enough, it says so rather than guessing.

DPI — not detectable, and not faked. No Windows API exposes it: DPI lives inside the mouse and vendor software reads it over undocumented, per-manufacturer HID reports. Type it in from your mouse's own software.

A typical session

Razer Viper → 1000 Hz → 50 samples → Save → New run
Razer Viper → 2000 Hz → 50 samples → Save → New run
Razer Viper → 4000 Hz → 50 samples → Save → New run
Razer Viper → 8000 Hz → 50 samples → Save
→ Compare, baseline = 1000 Hz

All without restarting the program.


Hardware

Everything below describes the build that is physically assembled and verified, so it can be reproduced without any prior context.

BOM / parts list
Qty Component Function Notes
1 Teensy 2.0 (ATmega32U4, 16 MHz) Takes t₀ in an ISR, computes latency, talks to the PC The whole measurement lives here
1 OLED 1.30", 128×64, SH1106, I²C, 4-pin Local status display Address 0x3C. One display — see note below
1 KY-018 photoresistor module LIGHT telemetry recorded with each run Analog output. Not part of the measurement
2 Momentary push buttons, 4-pin BTN1 / BTN2 Used as plain switches to GND. No external resistors — INPUT_PULLUP is used. Wire diagonally — see below
1 Breadboard (400 or 830 points) Power rails and module mounting
1 Conductive copper tape, 5 mm Contact pad on the outside of the mouse button Removable. Aluminium foil + tape works too
1 Metal probe / multimeter tip Closes the circuit at the instant of the click A stiff bare wire also works
1 Helping hands / third hand Holds the probe steady Not optional in practice — probe alignment is the biggest error source
Jumper wires M-M and M-F Wiring Both types needed
Solid-core wire Short, tidy breadboard runs
Soldering iron + solder Permanent joints on the probe lead and the tape lead Never used on the mouse itself
Insulating tape Strain relief and shorts prevention
1 Micro USB cable Power and serial A data cable, not charge-only

[!NOTE] An earlier version of this project's parts list mentioned two OLED displays as a future upgrade. The validated build uses exactly one. A second display is not required and is not supported.

[!IMPORTANT] The 4-pin buttons are just switches. A tactile 4-pin button has two internal pairs: the two legs on the same side are permanently joined to each other. Take one leg from one side and one from the opposite side — the diagonal — so that pressing actually closes the circuit.

Wire that diagonal between the Teensy pin and GND, nothing else. Straddle the breadboard's centre groove so the two pairs land on different rows. Check with a continuity tester: open at rest, closed when pressed. If it beeps at rest you took two legs of the same pair, and the pin sits permanently at GND — the firmware reads it as "already pressed" at boot and no press event will ever appear.

Measured on USB power: ≈ 4.8 V between VCC and GND at the breadboard rails.

This is the complete parts list. Everything in it is in the built tester, and nothing else is needed.

Wiring — pin by pin
Component Component pin Teensy pad Arduino pin Wire colour Function
Breadboard rail + VCC 🔴 Red Power to OLED and KY-018 (~4.8 V on USB)
Breadboard rail GND ⚫ Black Common ground for everything
Probe tip D2 7 ⚪ White Probe input, INPUT_PULLUP, trigger on FALLING
Copper tape on left mouse button GND ⚫ Black Return path — closing to GND is the t₀ event
OLED SH1106 GND GND ⚫ Black Ground
OLED SH1106 VCC / VDD VCC 🔴 Red Power
OLED SH1106 SCL / SCK D0 5 🟡 Yellow I²C clock
OLED SH1106 SDA D1 6 🟢 Green I²C data
KY-018 S F0 A0 🔵 Blue Analog light level, 0–1023. LIGHT telemetry and the Probe-to-Photon t₁
KY-018 + VCC 🔴 Red Power
KY-018 GND ⚫ Black Ground
BTN1 one corner B0 0 🟠 Orange Button input, INPUT_PULLUP
BTN1 opposite corner GND ⚫ Black Pressed = LOW. Diagonal, never the same side
BTN2 one corner B1 1 🟣 Purple Button input, INPUT_PULLUP
BTN2 opposite corner GND ⚫ Black Pressed = LOW. Diagonal, never the same side
Built-in LED D6 11 Lit while a measurement is pending

[!WARNING] KY-018 pin order: always follow the S / + / markings printed on the module. Do not infer the order from the physical pin positions — it varies between manufacturers.

[!TIP] A0 on a Teensy 2.0 resolves to pad F0 (digital 21) in Teensyduino. That is why the sketch's analogRead(A0) reads the pad labelled F0.

Wire colour convention
Colour Use
⚫ Black GND
🔴 Red VCC / ≈ 5 V
🟡 Yellow OLED SCL
🟢 Green OLED SDA
🔵 Blue KY-018 analog signal (LIGHT telemetry and the Probe-to-Photon t₁)
⚪ White Probe
🟠 Orange BTN1
🟣 Purple BTN2

These colours are a project convention for readability, not an electrical requirement. Any colour works electrically; keeping to the table makes the photos, the diagrams and the physical build agree with each other.

Diagram
                    TEENSY 2.0  (ATmega32U4, 16 MHz)
                  +--------------------------------+
   metal probe ---|  D2   (7)   probe in    [WHT]  |
        :         |                                |
        :         |  D0   (5)   I2C SCL     [YEL] -+--------> OLED SCL
        :         |  D1   (6)   I2C SDA     [GRN] -+--------> OLED SDA
        :         |                                |
        :         |  F0   (A0)  analog in   [BLU] <+--------- KY-018  S
        :         |             (t1 in Probe-to-Photon)             
        :         |                                |
        :         |  B0   (0)   BTN1 in     [ORG] <+--------- BTN1 --+
        :         |  B1   (1)   BTN2 in     [PUR] <+--------- BTN2 --+
        :         |                                | (4-pin buttons: |
        :         |                                |  wire DIAGONAL  |
        :         |                                |  corners only)  |
        :         |                                |                 |
        :         |  D6   (11)  built-in LED       |                 |
        :         |                                |                 |
        :         |  VCC                    [RED] -+---+--> OLED VCC |
        :         |  GND                    [BLK] -+-+ +--> KY-018 + |
        :         +--------------------------------+ |               |
        :                                            |               |
        v                                            +--> OLED GND   |
  +------------------------+                         +--> KY-018 -   |
  |  copper tape on the    |                         +---------------+
  |  LEFT mouse button     |---------[BLK]-----------+  (BTN1/BTN2 other leg)
  +------------------------+
                                    GND rail

  The probe touches the tape at the exact instant the switch actuates -> t0.

  Colour codes: BLK=GND  RED=VCC  YEL=SCL  GRN=SDA
                BLU=KY-018 signal  WHT=probe  ORG=BTN1  PUR=BTN2

  Nothing is wired to the mouse other than removable copper tape on the outside
  of the left button.
graph LR
    subgraph Teensy["Teensy 2.0 · ATmega32U4"]
        VCC["VCC"]
        GND["GND"]
        D2["D2 · pin 7"]
        D0["D0 · pin 5"]
        D1["D1 · pin 6"]
        F0["F0 · A0"]
        B0["B0 · pin 0"]
        B1["B1 · pin 1"]
        LED["D6 · pin 11 · LED"]
    end

    PROBE["Metal probe"] -->|white| D2
    TAPE["Copper tape<br/>on left mouse button"] -->|black| GND

    D0 -->|yellow · SCL| OLED["OLED SH1106<br/>128x64 I2C 0x3C"]
    D1 -->|green · SDA| OLED
    VCC -->|red| OLED
    GND -->|black| OLED

    KY["KY-018 LDR module"] -->|blue · S| F0
    VCC -->|red · +| KY
    GND -->|black · −| KY

    BTN1["BTN1<br/>4-pin, diagonal"] -->|orange| B0
    BTN1 -->|black| GND
    BTN2["BTN2<br/>4-pin, diagonal"] -->|purple| B1
    BTN2 -->|black| GND

Loading

This is the whole tester. There is no unwired component and nothing left to connect.

Bring-up — test in this order

Each step must pass before moving to the next.

# Step Expected result
1 Teensy alone. Flash, open Serial Monitor at 115200 LATENCY_TESTER v1.5 OLED+LDR+BTN+PHOTON then READY. The Teensy does not reset when the monitor opens, so send V if the banner has already scrolled past
2 Probe. Touch the probe to the copper tape TRIG appears, pin-11 LED lights. Without the dashboard you then get TIMEOUT:… — that is correct
3 OLED I²C scan Exactly one device at 0x3C
4 OLED display testfirmware/displayTester/ Text on the panel; the main sketch prints OLED_OK:0x3C
5 KY-018. Send L, or read LIGHT in the Live tab Covered ≈ 0–20, evening room ≈ 150–250, phone torch ≈ 1000. If it does not move, the module is miswired
6 All three together Probe still triggers; display refreshes ~2×/s. Occasional SKIP:OLED_REFRESH outside test mode is normal and correct
7 Calibration CALIB_OK:<offset>,…. Lands near 250 µs — see the known quirk
8 Test mode Overlay appears, TESTMODE:ON, OLED freezes, green ⇄ red tracks ARMED/REARM
9 BTN1 / BTN2 10 presses → exactly 10 events, no phantoms, no doubles, no repeat while held. Then BTN1 enters/leaves test mode and BTN2 clears the run outside it
10 Probe-to-Photon Aim the KY-018 at the target, calibrate black then white, and check the separation is accepted. Then 10 presses → 10 OPT: samples
11 Done That is the whole bring-up. Nothing else needs wiring

Full detail with expected values: docs/wiring.md.

Electrical notes
  • Disconnect USB before soldering. No exceptions.
  • Never connect VCC directly to the probe signal. D2 is an input with an internal pull-up; it expects to be shorted to GND and nothing else.
  • All modules share a common GND. Teensy, OLED, KY-018 and both buttons on the same rail.
  • Follow the printed S / + / labels on the KY-018. Do not deduce from pin position.
  • No external resistors on the buttons. INPUT_PULLUP provides them; pull-downs break the logic.
  • Never modify the mouse. No opening it, no soldering to its PCB, no transistor across its microswitch. Removable copper tape on the outside of the button is the only mouse-side change.
  • Measured supply on USB: ≈ 4.8 V. Both the OLED and the KY-018 are fine at that level.

Probe-to-Photon

A second, separate measurement mode, firmware v1.5. Probe-to-PC is untouched by it: the probe ISR, the TRIG/H handshake, the serial calibration, the filters, the re-arm rules and the OLED freeze are byte-identical to v1.4 and v1.3.

What each mode measures

Probe-to-PC Probe-to-Photon
t₀ Probe contact on D2 Probe contact on D2the same ISR
t₁ The dashboard answers H over serial The KY-018 on A0 crosses the calibrated threshold
Covers Mouse + link + polling + OS input stack The above plus the app, the compositor, the GPU queue and the panel
Serial round-trip subtracted Yes, calibration_us No — nothing is transmitted between t₀ and t₁, so there is nothing to subtract
Typical figure single-digit ms tens of ms
Status ✅ verified 🧪 works, accuracy bounded by the sensor

How a run works

You do not calibrate by hand. The calibration happens inside the full-screen overlay, on the same target the measurement uses — a baseline sampled anywhere else describes a different patch of screen, with a different backlight, and is worthless.

  1. Live test → Measurement mode → Probe-to-Photon. The optical panel appears.
  2. Fix the KY-018 against the middle of the screen — elastic bands, tape, a clamp — facing it, a couple of centimetres away. The target is a 620 × 440 block dead centre, so it does not need precise aim; it does need the sensor not to drift afterwards.
  3. Press Calibrate on the full-screen target. A centred full-screen target appears, paints itself black, waits 400 ms for the photoresistor to settle, samples A0 — that is dark — then does the same in white for bright, shows the result and closes itself. Do not move the sensor while it runs; it takes about a second and a half. Entering test mode repeats it automatically, so the baselines always come from the run that is about to happen.
  4. The threshold is derived automatically: the midpoint between the two. Direction is derived too, so a module whose ADC value falls as light rises works with no setting.
  5. If the two baselines are less than 60 counts apart it stops at CALIBRATION FAILED, reports the numbers it actually measured, and stays on screen with a live LIGHT reading updating several times a second. Move the sensor until that number swings properly between the black and white phases, press Enter to retry, Esc to give up. It never arms on a bad calibration: a threshold inside the noise would make every sample a coin toss.
  6. Otherwise the overlay hands over to the normal green/red/blue cycle with the black target in place. The instant Windows reports the click the target turns white; the Teensy has been sampling A0 since the probe contact and stops at the first reading past the threshold.
  7. Each sample arrives as OPT:<ms>,raw:<adc>,…. raw is the ADC value that tripped the threshold, kept per sample for debugging.

Pick a frame cap before you start. The optical panel offers 30 / 60 / 120 / 240 / 360 / 500 / 1000 fps and Monitor, which follows the detected refresh rate. The target then changes only on a frame boundary, exactly as a game presents, so the wait for the next frame lands inside the measurement instead of being assumed away. The cap in force is printed on the full-screen overlay and stored with the run: the same mouse at 30 fps and at 360 fps produces two different, both correct, results.

There is deliberately one calibration button and no way to sample a baseline anywhere except a full-screen centred target: two readings from different parts of the screen describe different patches of backlight, and a threshold built from them is worthless. To check the sensor is alive without calibrating anything, watch the LIGHT field in the controls — it updates every second.

Guards, all of them deliberate: the target is a plain label with no bindings, so aiming the sensor can never produce a click; a press with no calibration is refused with OPT_ERR:NO_CAL; a target that is already white at t₀ is refused with OPT_ERR:NOT_DARK instead of returning a near-zero time; and no transition inside 400 ms ends in OPT_TIMEOUT rather than a hung firmware.

Read this before quoting a Probe-to-Photon number

Warning

The KY-018 is a photoresistor, not an instrument. A CdS cell's own response time is in the milliseconds — the same order of magnitude as the thing being measured — and it drifts with temperature and ambient light. Its contribution is inside every figure this mode reports and cannot be calibrated out.

Use Probe-to-Photon to compare one setup against itself: same room, same panel, same sensor position, one variable changed. Do not publish the absolute value as a click-to-photon benchmark, and do not compare it against someone else's. Precision work needs a fast photodiode with a transimpedance amplifier.

Two smaller contributors, named so they are not mistaken for sensor error:

  • The dashboard's own repaint is inside the measurement. The target is flipped by a 1 ms Tk poll, because touching a widget from the input-listener thread is not safe. That is honest — click-to-photon measures everything up to the photons, the application included — but it is a floor the method cannot go below.
  • The ADC runs faster in this mode. The prescaler goes from /128 to /16 while the optical mode is active, so a reading costs ~13 µs instead of ~112 µs. It trades a little absolute accuracy for finer timing, which is the right way round for a threshold crossing.

Both modes are archived side by side and can be compared, but a comparison that mixes them says so in the Compare tab: they do not measure the same interval, so a delta between them is not a like-for-like figure.

A measured example, same mouse, same session: ≈ 3 ms in Probe-to-PC, ≈ 16 ms in Probe-to-Photon over 40 clicks. The ~13 ms difference is the application's repaint, the compositor, the GPU queue, the panel — and the KY-018's own response, which cannot be separated out from it. Read the difference as "this whole chain, including my sensor", never as "my monitor adds 13 ms".


Firmware

Sketch Notes
latency_tester/ v1.0 — probe only
latency_tester_oled_ldr/ v1.1 — adds OLED + KY-018
latency_tester_oled_ldr_v1_2/ v1.2 — adds SKIP:OLED_REFRESH
latency_tester_oled_ldr_v1_3/ v1.3 — debounce/re-arm, test mode, OLED freeze. Measurement baseline
latency_tester_oled_ldr_v1_4/ v1.4 — v1.3 plus BTN1/BTN2 events. Measurement path byte-identical to v1.3
latency_tester_photon_v1_5/ v1.5 — current. v1.4 plus Probe-to-Photon. The Probe-to-PC path is byte-identical to v1.4
displayTester/ Standalone OLED check

Older sketches still work; unknown tokens are logged, never dropped. Compatibility table in docs/serial_protocol.md.


Repository layout

latency_tester/          the dashboard, as a package
  protocol.py            wire format, both modes, optical threshold maths
  constants.py           choice lists and the two measurement-mode ids
  stats.py               percentiles, MAD, outliers, deltas  (pure functions)
  database.py            SQLite archive + schema migrations
  serial_service.py      reader thread, TRIG/click association
  devices.py             mouse identification + report-rate measurement
  updater.py             checksum-verified one-click update
  demo.py                simulated Teensy, both measurement modes
  settings.py            persisted preferences
  i18n.py                translation loader
  locales/               one JSON file per language
  theme.py               light / dark / system palettes
  widgets.py             tooltips, metric cards, live chart
  export.py              CSV
  app.py                 shell, state, event pump
  views/                 live · sessions · compare · devices · preferences · testmode
firmware/                Arduino sketches, v1.0 → v1.5
packaging/               PyInstaller spec, Inno Setup script, build script
tests/                   pytest suite
docs/                    protocol, install, bring-up, first test, troubleshooting,
                         and the photodiode front-end design (not built yet)
legacy/                  the previous single-file dashboards, still runnable

The measurement and serial logic never imports tkinter, which is what lets it be tested headlessly.

Development

pip install -r requirements-dev.txt
python -m pytest tests -q

293 tests, 88% coverage of the non-GUI code — the GUI is deliberately excluded rather than padded with tests that assert nothing. CI enforces the coverage floor, so the badge cannot drift down silently.

Covered: protocol parsing including the button tokens, the firmware button-debounce rules, statistics and percentiles, the database and its migrations, run save/load/duplicate, CSV export, settings, every locale, the updater's checksum verification and host allow-listing, mouse naming rules and the polling-rate formula, the TRIG↔click association, and the demo device.

tests/test_button_debounce.py mirrors serviceButtons() / flushButtonEvents() from the v1.4 sketch line for line, so the bench acceptance rules are checked in CI. It is a mirror, not an import: if the .ino changes, change the test too.

tests/test_button_actions.py is the opposite: it calls the dashboard's real button dispatcher on a stub, so the refusal rules (disconnected, calibrating, BTN2 during test mode, the confirmation preference) are checked without needing a window.

tests/test_photon.py covers Probe-to-Photon end to end short of the hardware: the OPT: wire format and that it can never decode as a LAT: one, the threshold maths including a sensor whose value falls with light, the refusal when the two baselines are too close, and the schema-3 migration of an existing archive.

Building the installer

powershell -ExecutionPolicy Bypass -File packaging\build_installer.ps1

Requires Inno Setup 6. The script runs the tests first and refuses to build if anything fails, then emits the installer and its SHA-256 into build\installer\.


Roadmap

The tester itself is done. What is left, in the order it makes sense to do it:

1. A 3D-printed enclosure

A box for the Teensy, the OLED, the KY-018 and the two buttons, with a mount that holds the probe steady against the mouse button — probe alignment is the largest error source in the whole method, and a printed jig fixes it better than a helping-hands clamp does. The STL will live in this repository when it exists. It changes no measurement.

2. A frame-rate cap on the optical target — done

The target now changes only on a frame boundary, so the wait for the next frame is measured instead of assumed — on average half a frame. Choose it per test in the optical panel: 30 / 60 / 120 / 240 / 360 / 500 / 1000 fps, or Monitor, which follows the detected refresh rate. The figure actually used is written into the run, because an archived optical number without its cap cannot be interpreted.

Its limits, stated up front:

Cap Frame time Mean added wait
60 fps 16.7 ms ≈ 8.3 ms
120 fps 8.3 ms ≈ 4.2 ms
240 fps 4.2 ms ≈ 2.1 ms
360 fps 2.8 ms ≈ 1.4 ms

The coarse steps are measurable; the fine ones are not. 240 → 360 is a 0.7 ms difference in the mean, which is below the KY-018's own noise, and a Tk window throttled with after() has no vsync and inherits the Windows timer granularity, so it reproduces a cadence rather than a real present path. Expect it to be honest at 30/60/120 and increasingly notional above that — until the photodiode front-end lands, at which point the sensor stops being the thing that blurs it.

One timer drives both jobs, which is also how a game loop works: input is sampled at the start of a frame and the result is presented at the end. The schedule aims at an absolute deadline rather than adding a rounded interval each tick, so 360 fps averages 2.78 ms instead of drifting 8% on the 3 ms rounding.

3. Not planned: choosing a flip model

A dropdown for Independent Flip / DirectFlip / composed flip would be a lie. The flip model is a property of a DXGI swap chain plus DWM's own decision about whether the window qualifies; a Tk window is GDI-backed and composed, and nothing in this application can select otherwise. Doing it properly means a separate native tool with a real D3D swap chain in borderless-fullscreen or exclusive mode — a different program, not a setting.

The same question is better answered by the next item, which uses whatever flip model the game already uses.

4. In-game measurement

Calibrate the sensor on a repeatable in-game event — a muzzle flash — then measure click to flash during normal play. This is the most useful of the four, and it wants the photodiode front-end first: a rising edge is exactly what a comparator is for, and doing it by polling an ADC through a photoresistor would be fighting the hardware. It is already in the mode picker, greyed out, so the plan is visible rather than a surprise.

What makes it harder than the desktop target:

  • The scene moves. An absolute threshold taken from one dark sample false-triggers on explosions, HUD flashes and ordinary brightness changes. It needs a rising edge with a minimum slope and a minimum excursion above a rolling baseline, not a fixed level.
  • Calibration becomes a ritual: hold still without firing to capture the baseline, then fire a few times to capture the excursion, and derive the trigger from the two.
  • The number includes the game's weapon animation. If a title draws the flash two frames after the input is consumed, those two frames are in the measurement and they are game design, not latency. Figures are comparable between runs of the same weapon in the same title, and nowhere else.

t₀ does not change: it stays the probe contact in the same ISR, as it has since v1.0.

5. The photodiode front-end — in progress

A 360 Hz OLED settles in microseconds. The KY-018 does not: it is a photoresistor with a millisecond response, and it is now the largest unknown in the whole chain, larger than a frame at any refresh rate worth testing.

The replacement is designed: a BPV10 into an OPA380 transimpedance amplifier, then a TLV3501 comparator whose output goes to D3 / digital 8 — an interrupt-capable pin. t₁ stops being a software threshold on a sampled ADC value and becomes a hardware interrupt, timestamped in an ISR exactly the way t₀ already is. A0 keeps the analogue output for calibration and telemetry, and is never read inside the timing window.

Designed, not built. Full circuit, pinouts, decoupling and BOM: docs/optical_front_end.md. The KY-018 build stays the shipped, verified tester until this one is assembled and measured against it.

Contributing

Pull requests are welcome. CONTRIBUTING.md covers setup, testing rules and how to add a language — the last of which needs no Python and is the easiest place to start.

One rule matters above the others: changes to the measurement pipeline need before/after numbers, not opinions. Everything else is fair game.

Security issues go through private reporting, never a public issue — see SECURITY.md.

Support the project

If MouseLatencyTester is useful to you, you can support its development here:

PayPal

Privacy

No telemetry, no analytics, no background network activity. Every measurement stays in a local SQLite file under your Documents folder. The only network request the app ever makes is the update check, and only when you press the button.

Licence

MIT © 2026 PrimeBuild.

Built around a Teensy 2.0 (PJRC), with pyserial, pynput and matplotlib.

About

🖱️ Microsecond-accurate mouse click latency testing with a Teensy 2.0 probe and Windows dashboard; benchmarks, archives, comparisons, 8 languages, and demo mode.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages