diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 11875a1..ff788fd 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -53,7 +53,7 @@ body: attributes: label: Firmware version description: Shown in the connection bar. - placeholder: "v1.4" + placeholder: "v1.5" - type: input id: os diff --git a/CHANGELOG.md b/CHANGELOG.md index 65a5177..6461715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,118 @@ All notable changes to this project are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/); versioning follows [Semantic Versioning](https://semver.org/). +## [1.2.0] — 2026-08-21 + +### Added — a second way to measure + +- **Probe-to-Photon.** A separate measurement mode, firmware **v1.5**. `t₀` is + the same probe contact on `D2`, taken in the same ISR; `t₁` is the first + KY-018 reading past a calibrated threshold. The dashboard's test mode shows a + black target area that turns white the instant Windows reports the click, and + the Teensy times the gap. Nothing is transmitted between t₀ and t₁, so the + serial round-trip offset is **not** subtracted — there is no round-trip. +- **Optical calibration, automatic and in the right place.** Entering test mode + in this mode calibrates first, inside the full-screen overlay, on the same + target the measurement uses: black, settle, sample, white, settle, sample. + A baseline taken anywhere else on the screen describes a different patch of + backlight and is worthless, so it is not left to the operator to get right. + The threshold is the midpoint and the direction is derived, so a module whose + ADC value falls as light rises works with no setting. Baselines closer than + 60 ADC counts stop the run before it arms — a threshold inside the noise would + make every sample a coin toss. Each reading is taken 400 ms after the target + changes colour, because a photoresistor does not settle instantly. The side + panel keeps manual black/white buttons as a bench check on the sensor. +- **The two modes never mix.** Different wire token (`OPT:` vs `LAT:`), + different firmware statistics, `runs.mode` in the archive, and a warning in + the Compare tab when a comparison spans both. They do not measure the same + interval and the interface never pretends otherwise. +- **Schema v3.** `runs.mode`, `runs.optical_dark`, `runs.optical_bright`, + `runs.optical_threshold` and `samples.raw_optical`, added in place. Existing + runs read as `probe_to_pc`, which is what they are: it was the only mode that + existed when they were written. +- **The demo device speaks both modes**, so the whole optical flow — calibration, + target, samples, archive — can be exercised with no hardware attached. + +### Honest about the sensor + +- The KY-018 is a photoresistor. Its own response time is in the milliseconds, + the same order as the quantity being measured, and it drifts with ambient + light and temperature. Probe-to-Photon is documented as a **relative** + indicator — one setup against itself — and explicitly **not** as an absolute + click-to-photon benchmark. The dashboard says so in the optical panel, not + only in the README. +- The dashboard's own repaint is inside the measured interval, driven by a 1 ms + poll because touching a Tk widget from the input-listener thread is not safe. + Documented rather than hidden: click-to-photon includes the application. +- The ADC prescaler goes from /128 to /16 while the optical mode is active + (~13 µs per reading instead of ~112 µs), trading a little absolute accuracy + for finer timing — the right way round for a threshold crossing. + +### Added — buttons that do something + +- **BTN1 / BTN2 now do something.** The 10-press hardware acceptance test + passed, so the buttons are bound: `BTN1` enters and leaves test mode, `BTN2` + clears the live run while test mode is **not** running. Entirely + dashboard-side — the firmware is unchanged and still only *reports* the press, + so the dashboard stays the authority on session state and refuses a press that + would disturb a run (disconnected, calibrating, or `BTN2` during test mode). + +### Fixed + +- **The live tab's right-hand column scrolls.** With the optical panel showing it + was taller than a laptop screen, which put *Enter test mode* below the bottom + edge of the window with no way to reach it. +- **The optical target is large and in the same place in both windows.** It was + a 260 px square that the calibration window and the test overlay positioned + differently; a sensor held on the glass with elastic bands does not hit that, + and both baselines then came back identical. It is now a 620 × 440 block dead + centre, drawn by one function that both windows call. A failed calibration + stays on screen with a live LIGHT reading, several times a second, so the + sensor can be aimed by watching the number rather than by guessing; Enter + retries, Esc gives up. +- **The optical calibration happens where the measurement happens.** It used to + sample a small swatch in the side panel, then measure against a full-screen + target somewhere else entirely — different patch of backlight, so the + threshold described the wrong thing. There is now one button, it opens a + centred full-screen target, and entering test mode repeats the calibration on + that same target. The per-baseline buttons are gone: they only offered a way + to get it wrong. +- **A failed calibration reports the numbers it measured**, not just the limit + it missed — "dark 214, bright 226, 12 counts apart, at least 60 needed" tells + you whether the setup is nearly right or nowhere near. +- **"Access denied" on the COM port at startup.** Two causes, both addressed: + the serial reader thread is now joined when the port is closed, so Windows has + really released the handle before the process exits; and the automatic + connection at startup retries quietly instead of greeting the user with a + modal error. A connection the *user* asks for still reports its errors. + +### Preserved + +- The Probe-to-PC path in the v1.5 sketch is **byte-identical** to v1.4 and + v1.3: `probeISR()`, the t₀/t₁ block, the `TRIG`/`H` handshake, + `runCalibration()`, `serviceProbeRearm()`, every filter and the OLED freeze. + The diff from v1.4 removes six lines: three version strings, one comment, the + `if (probeFlag)` that became `} else if (probeFlag)`, and the OLED refresh + guard that now also checks the optical mode. Everything else is an addition. +- The mouse remains untouched: no opening it, no soldering to its PCB, no + transistor across its microswitch. Removable copper tape on the outside of the + button is still the only mouse-side modification, in this mode as in the other. + +### Settled + +- **The hardware is finished.** Every component is built, wired and verified; + nothing is waiting on hardware. What is left is a 3D-printed enclosure, which + changes no measurement. +- **The 2N2222A and the 220 Ω resistors are not needed.** They were on an early + parts list and never found a role — not in the probe method, which switches + nothing, and not in Probe-to-Photon, which reads a sensor that was already + wired. Documented as "do not buy" rather than left dangling as "reserved". +- **Probe-to-Photon verified on hardware:** 40 clicks, ~16 ms median against + ~3 ms for the same mouse in Probe-to-PC. The ~13 ms difference is the display + pipeline *and* the KY-018's own response together, and is documented as such. + +--- + ## [1.1.0] — 2026-08-19 ### Added — knowing which mouse is plugged in @@ -152,11 +264,11 @@ exactly as in the last known-good internal build. ### Not implemented -- Physical button *behaviour* (BTN1 = test mode, BTN2 = reset the live run) — - planned, gated on the 10-press hardware acceptance test. -- 2N2222A transistor and its 220 Ω resistor — physically present, deliberately - **not connected**, no firmware, no protocol, purpose not specified. -- Any photoresistor-based measurement mode — does not exist. +- 2N2222A transistor and the 220 Ω resistors — **not used at all**, settled: the + finished tester has no role for them. +- **Probe-to-Photon mode** — specified, not implemented. Design recorded in the + README; no firmware, no protocol token, no UI. It will be a separate mode from + Probe-to-PC and will not alter the existing timing path. --- diff --git a/README.md b/README.md index 372efba..d42cc6f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Python 3.10 or newer Windows 10 and 11 Eight UI languages -Firmware 1.4 +Firmware 1.5 Hardware assembled and verified
@@ -135,18 +135,35 @@ Teensy — **t₁**. The calibrated serial round-trip is subtracted from `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. -### Feature maturity — read this first +> [!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. + +### The hardware is finished + +Every part of the tester is built, wired and verified on the bench. Nothing in the list below is +waiting on hardware. What is left is an **enclosure**: a 3D-printed box to hold the Teensy, the +OLED, the KY-018 and the two buttons, so the thing stops being a breadboard. That is cosmetic and +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** | 🧪 **Wired, firmware v1.4, test phase.** Each press emits exactly one `BTN1:PRESS` / `BTN2:PRESS`, counted and logged. **No action bound yet** | -| 5 | 2N2222A transistor + 220 Ω | ⛔ **Not connected, not supported.** Purpose not specified; nothing here anticipates it | -| 6 | Photoresistor / click-to-photon mode | ⛔ Does not exist and is not being faked | +| 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](#probe-to-photon) before quoting a number | +| 6 | 2N2222A transistor + 4 × 220 Ω | ❌ **Not needed. Do not buy them.** They turned out to have no role in the finished tester | -Rows 5–6 are not implemented and no wiring for them is documented, on purpose. +**On the transistor and the resistors:** an early parts list included a 2N2222A and four 220 Ω +resistors. The finished tester does not use them, and no future mode needs them either — the +non-invasive probe method never switches anything, and Probe-to-Photon reads the KY-018 that was +already wired. They are listed here only so anyone who bought the kit knows to leave them in the +drawer. Nothing in this repository references them electrically. --- @@ -320,25 +337,39 @@ reproduced without any prior context. | 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** | `BTN1` / `BTN2` events | No external resistors — `INPUT_PULLUP` is used | +| 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 mouse button | Aluminium foil + tape works too | +| 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 and tape | | +| — | **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 | -| 1 | 2N2222A NPN transistor | — | ⛔ **Present but NOT connected and NOT supported by the firmware** | -| 1 | 220 Ω resistor | — | ⛔ **Present but NOT connected** | +| — | ~~2N2222A NPN transistor~~ | — | ❌ **Not needed.** Was on an early list; the finished tester has no use for it | +| — | ~~220 Ω resistors~~ | — | ❌ **Not needed.** Same | > [!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. +Everything in the list above is in the built tester. The two struck-through rows are there so a kit +buyer knows they are not missing a step. +
@@ -355,13 +386,13 @@ Measured on USB power: **≈ 4.8 V** between `VCC` and `GND` at the breadboard r | 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 | +| 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 | leg A | `B0` | **0** | 🟠 Orange | Button input, `INPUT_PULLUP` | -| BTN1 | leg B | `GND` | — | ⚫ Black | Pressed = LOW | -| BTN2 | leg A | `B1` | **1** | 🟣 Purple | Button input, `INPUT_PULLUP` | -| BTN2 | leg B | `GND` | — | ⚫ Black | Pressed = LOW | +| 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] @@ -384,7 +415,7 @@ Measured on USB power: **≈ 4.8 V** between `VCC` and `GND` at the breadboard r | 🔴 Red | VCC / ≈ 5 V | | 🟡 Yellow | OLED `SCL` | | 🟢 Green | OLED `SDA` | -| 🔵 Blue | KY-018 analog signal | +| 🔵 Blue | KY-018 analog signal (`LIGHT` telemetry and the Probe-to-Photon `t₁`) | | ⚪ White | Probe | | 🟠 Orange | BTN1 | | 🟣 Purple | BTN2 | @@ -408,9 +439,13 @@ build agree with each other. : | 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 | | : | | | @@ -430,7 +465,9 @@ build agree with each other. Colour codes: BLK=GND RED=VCC YEL=SCL GRN=SDA BLU=KY-018 signal WHT=probe ORG=BTN1 PUR=BTN2 - NOT CONNECTED: 2N2222A transistor, 220 ohm resistor. + Nothing is wired to the mouse other than removable copper tape on the outside + of the left button. The 2N2222A and the 220 ohm resistors are NOT part of + this tester and are not needed at all. ``` ```mermaid @@ -459,18 +496,14 @@ graph LR VCC -->|red · +| KY GND -->|black · −| KY - BTN1["BTN1"] -->|orange| B0 + BTN1["BTN1
4-pin, diagonal"] -->|orange| B0 BTN1 -->|black| GND - BTN2["BTN2"] -->|purple| B1 + BTN2["BTN2
4-pin, diagonal"] -->|purple| B1 BTN2 -->|black| GND - TRANSISTOR["2N2222A + 220 Ω
NOT CONNECTED"]:::todo - - classDef todo fill:#2a2a2a,stroke:#b45309,stroke-dasharray: 5 4,color:#fbbf24 ``` -The 2N2222A and its resistor appear only to record that they exist and are **deliberately -unconnected**. No connection for them is invented here. +This is the whole tester. There is no unwired component and nothing left to connect.
@@ -482,16 +515,17 @@ Each step must pass before moving to the next. | # | Step | Expected result | |:--:|---|---| -| 1 | **Teensy alone.** Flash, open Serial Monitor at 115200 | `LATENCY_TESTER v1.4 OLED+LDR+BTN` then `READY` | +| 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 test** — `firmware/displayTester/` | Text on the panel; the main sketch prints `OLED_OK:0x3C` | -| 5 | **KY-018.** Send `L` | Covered ≈ **0–20**, evening room ≈ **150–250**, phone torch ≈ **1000** | +| 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:,…`. Lands near 250 µs — see the [known quirk](docs/serial_protocol.md#6-calibration-sequence) | | 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 | -| 10 | Transistor / light mode | ⛔ Not defined. Do not wire the 2N2222A | +| 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 — in particular the 2N2222A and the 220 Ω resistors are not part of this tester | Full detail with expected values: **[docs/wiring.md](docs/wiring.md)**. @@ -507,14 +541,106 @@ Full detail with expected values: **[docs/wiring.md](docs/wiring.md)**. - **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. -- **Do not assume the 2N2222A pinout.** EBC and ECB both exist depending on package and - manufacturer — and it is not to be wired yet regardless. +- **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. +- **The 2N2222A and the 220 Ω resistors are not used at all.** Leave them out; there is no wiring + for them anywhere in this project. - 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 `D2` — **the 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:,raw:,…`. `raw` is the ADC value that tripped the threshold, + kept per sample for debugging. + +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 | @@ -523,7 +649,8 @@ Full detail with expected values: **[docs/wiring.md](docs/wiring.md)**. | `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 — current. v1.3 plus BTN1/BTN2 test events. Measurement path byte-identical to v1.3** | +| `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 @@ -535,13 +662,14 @@ Older sketches still work; unknown tokens are logged, never dropped. Compatibili ``` latency_tester/ the dashboard, as a package - protocol.py wire format: tokens, parsing (no GUI, no serial) + 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 + demo.py simulated Teensy, both measurement modes settings.py persisted preferences i18n.py translation loader locales/ one JSON file per language @@ -550,7 +678,7 @@ latency_tester/ the dashboard, as a package export.py CSV app.py shell, state, event pump views/ live · sessions · compare · devices · preferences · testmode -firmware/ Arduino sketches, v1.0 → v1.4 +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 @@ -566,7 +694,7 @@ pip install -r requirements-dev.txt python -m pytest tests -q ``` -**246 tests**, **87% coverage of the non-GUI code** — the GUI is deliberately excluded rather than +**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. @@ -579,6 +707,15 @@ rules and the polling-rate formula, the TRIG↔click association, and the demo d 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 @@ -591,6 +728,17 @@ refuses to build if anything fails, then emits the installer and its SHA-256 int --- +## Roadmap + +The tester itself is done. What is left: + +- **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. +- Nothing else is planned. The measurement path is verified and deliberately frozen; changes to it + need before/after numbers. + ## Contributing Pull requests are welcome. [CONTRIBUTING.md](CONTRIBUTING.md) covers setup, testing rules and how to diff --git a/docs/flashing_teensy.md b/docs/flashing_teensy.md index 677980e..1a110e4 100644 --- a/docs/flashing_teensy.md +++ b/docs/flashing_teensy.md @@ -11,7 +11,7 @@ ## Steps -1. Open `firmware/latency_tester_oled_ldr_v1_4/latency_tester_oled_ldr_v1_4.ino`. +1. Open `firmware/latency_tester_photon_v1_5/latency_tester_photon_v1_5.ino`. 2. *Tools → Board →* **Teensy 2.0**. 3. *Tools → USB Type →* **Serial** (or *Serial + Keyboard + Mouse + Joystick* — both work; only the serial endpoint is used). @@ -24,11 +24,18 @@ Open the Arduino Serial Monitor at `115200` baud. You should see: ``` -LATENCY_TESTER v1.4 OLED+LDR+BTN +LATENCY_TESTER v1.5 OLED+LDR+BTN+PHOTON READY OLED_OK:0x3C ``` +> [!IMPORTANT] +> **A Teensy does not reset when the Serial Monitor opens.** The banner is +> printed once at power-on, so an empty monitor is normal and proves nothing. +> Type `V` and press Enter — the reply is what tells you the flash worked. If +> nothing comes back at all, something else is holding the COM port (usually the +> dashboard, or another Serial Monitor). + Press BTN1 and BTN2 once each; you should see exactly one line per press: ``` @@ -53,7 +60,8 @@ can own the COM port. | `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, no duplicate triggers, OLED frozen during test mode. **Measurement baseline** | -| **`latency_tester_oled_ldr_v1_4/`** | **v1.4 — current. v1.3 plus the BTN1/BTN2 test events. The measurement path is byte-identical to v1.3** | +| `latency_tester_oled_ldr_v1_4/` | v1.4 — v1.3 plus the BTN1/BTN2 events. The measurement path is 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 are kept for reference and still work with this dashboard; see diff --git a/docs/serial_protocol.md b/docs/serial_protocol.md index c8ecf88..5741ec1 100644 --- a/docs/serial_protocol.md +++ b/docs/serial_protocol.md @@ -36,12 +36,13 @@ The firmware does **not** transmit a numeric protocol version. It prints a banner instead, and that banner is the compatibility signal: ``` -LATENCY_TESTER v1.4 OLED+LDR+BTN +LATENCY_TESTER v1.5 OLED+LDR+BTN+PHOTON ``` The dashboard parses the `vX.Y` from any line starting with `LATENCY_TESTER` -and shows it in the connection bar. `PROTOCOL_VERSION = 1` in `protocol.py` is +and shows it in the connection bar. `PROTOCOL_VERSION = 2` in `protocol.py` is the revision of *this document*; it is bumped only if the wire format changes. +Version 1 was v1.0-v1.4; version 2 adds the Probe-to-Photon tokens. **Backwards compatibility rule:** older firmware simply never sends the newer tokens. `v1.0` has no `LIGHT:`, `v1.2` has no `ARMED`/`REARM`/`TESTMODE:*`. @@ -50,15 +51,16 @@ firmware change is required to use this dashboard. ### Token availability by firmware version -| Token / command | v1.0 | v1.1 | v1.2 | v1.3 | v1.4 | -|---|:--:|:--:|:--:|:--:|:--:| -| `TRIG`, `H`, `X`, `LAT:`, `STATS:`, `CALIB_OK:`, `RESET`, `READY` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `C` `R` `S` `V` | ✅ | ✅ | ✅ | ✅ | ✅ | -| `LIGHT:` / `L` | — | ✅ | ✅ | ✅ | ✅ | -| `SKIP:OLED_REFRESH` | — | — | ✅ | ✅ | ✅ | -| `ARMED`, `REARM`, `ABORT:NO_CLICK`, `DROP:OUT_OF_RANGE:` | — | — | — | ✅ | ✅ | -| `TESTMODE:ON` / `TESTMODE:OFF` / `T` `E` | — | — | — | ✅ | ✅ | -| `BTN1:PRESS` / `BTN2:PRESS` | — | — | — | — | ✅ | +| Token / command | v1.0 | v1.1 | v1.2 | v1.3 | v1.4 | v1.5 | +|---|:--:|:--:|:--:|:--:|:--:|:--:| +| `TRIG`, `H`, `X`, `LAT:`, `STATS:`, `CALIB_OK:`, `RESET`, `READY` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `C` `R` `S` `V` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `LIGHT:` / `L` | — | ✅ | ✅ | ✅ | ✅ | ✅ | +| `SKIP:OLED_REFRESH` | — | — | ✅ | ✅ | ✅ | ✅ | +| `ARMED`, `REARM`, `ABORT:NO_CLICK`, `DROP:OUT_OF_RANGE:` | — | — | — | ✅ | ✅ | ✅ | +| `TESTMODE:ON` / `TESTMODE:OFF` / `T` `E` | — | — | — | ✅ | ✅ | ✅ | +| `BTN1:PRESS` / `BTN2:PRESS` | — | — | — | — | ✅ | ✅ | +| `OPT:`, `OPT_CAL:`, `OPT_ERR:`, `OPT_TIMEOUT`, `PHOTON:*` / `O` `N` `D` `W` `K` | — | — | — | — | — | ✅ | --- @@ -77,6 +79,22 @@ measurement or a calibration (`measurementActive == false`). | `E` | test mode off | Resumes the OLED, replies `TESTMODE:OFF` | | `L` | light | Replies `LIGHT:<0..1023>` | +Probe-to-Photon, firmware **v1.5+**. Older firmware ignores all five through its +`default:` branch, so a newer dashboard on older firmware degrades to +Probe-to-PC instead of misbehaving. + +| Byte | Name | Effect | +|---|---|---| +| `O` | photon on | Selects the optical path, raises the ADC clock, clears the optical counters. Replies `PHOTON:ON` then `OPT_CAL:…` | +| `N` | photon off | Back to Probe-to-PC, restores the ADC clock. Replies `PHOTON:OFF` | +| `D` | calibrate dark | Samples `A0` as the black baseline, replies `OPT_CAL:…` | +| `W` | calibrate bright | Samples `A0` as the white baseline, replies `OPT_CAL:…` | +| `K` | report calibration | Replies `OPT_CAL:…` without changing anything | + +Selecting the mode is always allowed; **measuring** without a usable calibration +is what gets refused. The dashboard sends `T` first and `O` second, so the OLED +is already frozen before the optical path is armed. + Two bytes are **replies inside an exchange**, not standalone commands: | Byte | Meaning | @@ -112,8 +130,8 @@ Two bytes are **replies inside an exchange**, not standalone commands: | Line | Meaning | |---|---| -| `LATENCY_TESTER v1.4 OLED+LDR+BTN` | Boot banner | -| `LATENCY_TESTER v1.4 - Probe + OLED + KY-018 + Buttons` | Reply to `V` | +| `LATENCY_TESTER v1.5 OLED+LDR+BTN+PHOTON` | Boot banner | +| `LATENCY_TESTER v1.5 - Probe + OLED + KY-018 + Buttons + Photon` | Reply to `V` | | `READY` | Idle | | `RESET` | Counters cleared | | `OLED_OK:0x3C` / `OLED_FAIL` | Display init result | @@ -133,6 +151,28 @@ Parsed by the dashboard with `^BTN(\d+):([A-Z_]+)$`, so a future `BTN1:RELEASE` or `BTN2:LONG_PRESS` needs no dashboard change. See [§10](#10-buttons--firmware-v14). +### Probe-to-Photon (firmware v1.5+) + +| Line | Meaning | +|---|---| +| `PHOTON:ON` / `PHOTON:OFF` | Optical path selected / released | +| `OPT_CAL:dark:,bright:,threshold:,rising:<0\|1>` | The stored optical calibration. `rising` is `0` when the module's ADC value *falls* as light rises | +| `OPT:,raw:,min:,max:,avg:,n:` | Valid optical sample. `raw` is the reading that crossed the threshold | +| `OPT_TIMEOUT` | No transition within 400 ms of t₀ | +| `OPT_ERR:NO_CAL` | A press arrived with no usable calibration | +| `OPT_ERR:NOT_DARK` | The sensor was already past the threshold at t₀ | +| `OPT_ERR:SEPARATION` | `D`/`W` produced baselines less than 60 counts apart | +| `OPT_DROP:OUT_OF_RANGE:` | Sample outside `[0.5 ms, 350 ms]`, discarded by the firmware | + +`OPT:` is a **different token from `LAT:` on purpose.** The two modes do not +measure the same interval, and a parser that treated them alike would let an +optical sample into a Probe-to-PC mean. The optical statistics are kept in +separate firmware variables for the same reason. + +**Nothing is transmitted between t₀ and t₁ in this mode**, so `calibOffset` — the +serial round-trip — is **not** subtracted from an optical latency. There is no +round-trip to subtract. + ### Calibration | Line | Meaning | @@ -294,22 +334,24 @@ block, the two button functions, `setup()`, the two version strings, and two added lines in `loop()`. `probeISR()`, the measurement block, `runCalibration()` and `serviceProbeRearm()` are untouched. -### Current semantics: none +### Firmware semantics: still none -In v1.4 the buttons **change no firmware state**. They emit an event; the -dashboard counts it and logs it. This is deliberate — the hardware is being -verified first. +The buttons **change no firmware state**, in v1.4 and by design. They emit an +event; every decision happens on the PC. -**Acceptance test:** 10 slow presses of a button must produce exactly 10 -events, with no phantom events, no doubles, no repeat while held, and no effect -on a normal mouse measurement. +**Acceptance test** (passed on hardware): 10 slow presses of a button produce +exactly 10 events, with no phantom events, no doubles, no repeat while held, +and no effect on a normal mouse measurement. -### Planned semantics, after the hardware passes +### Dashboard semantics -| Button | Intended action | +| Button | Action | |---|---| -| `BTN1` | **Request** enter/exit test mode | -| `BTN2` | Reset the live run, only while **not** in test mode | +| `BTN1` | Enter / leave test mode | +| `BTN2` | Clear the live run, only while **not** in test mode | + +A press is **refused** — logged, never obeyed — while disconnected, while +calibrating, and for `BTN2` while test mode is running. The firmware reports; the **dashboard decides**. It stays the authority on session state, so a button press during a measurement or during test mode is @@ -321,8 +363,60 @@ is planned. ## 11. Not implemented -* **2N2222A transistor and its 220 Ω resistor** — physically present, **not - connected**, no protocol, no firmware, no measurement mode. Their purpose has - not been specified and nothing in this codebase anticipates them. -* **Any photoresistor-based measurement mode** (click-to-photon or otherwise). - The KY-018 provides the `LIGHT` telemetry value and nothing else. +* **2N2222A transistor and the 220 Ω resistors** — **not used at all.** They + were on an early parts list; the finished tester has no role for them, and no + protocol, firmware or measurement mode references them. +* **Probe-to-Photon mode** — specified, **not implemented**. No command byte, no + result token and no calibration exchange exist for it yet. Today the KY-018 + provides the `LIGHT` telemetry value and nothing else. + + The design is fixed (see + [README → Planned — Probe-to-Photon mode](../README.md#planned--probe-to-photon-mode)): + `t₀` is the same probe contact on `D2` as the Probe-to-PC path, `t₁` is the + first `A0` sample past a calibrated threshold, and latency is `t₁ − t₀`. It + will be a **separate mode** with its own commands and its own stored metric — + results are never mixed with Probe-to-PC — and it may not alter `probeISR()` + or the existing measurement block. The KY-018 is a prototype-grade LDR whose + response time is itself in the milliseconds, so this mode is a relative + indicator rather than a precision click-to-photon benchmark. + +## 12. Probe-to-Photon sequence + +``` +Teensy PC (dashboard) + | | + |<--------------- "T" then "O" ------------| OLED frozen, then optical armed + |---------- "PHOTON:ON", "OPT_CAL:..." --->| + | | + |-- probe falls LOW -> ISR: t0 = micros() | + | | + | NOTHING IS TRANSMITTED HERE. | + | analogRead(A0) in a tight loop. | + | |-- Windows reports the click + | |-- target area repainted WHITE + | first sample past the threshold | + | -> t1 = micros() TIMING STOPS | + | | + |------------- "OPT:,raw:,..." -->| + |------------------ "REARM" -------------->|-- target area repainted BLACK + |------------------ "ARMED" -------------->| +``` + +The PC is never asked for anything during the measurement: it flips its own +target because Windows told it about the click, and the Teensy finds out by +looking at the light. That is why no round-trip offset is subtracted here. + +**The dashboard's repaint is inside the measured interval**, and so is the 1 ms +poll that triggers it — touching a Tk widget from the input-listener thread is +not safe, so the flip cannot be done in the callback. This is not hidden: a +click-to-photon measurement legitimately includes the application, and this one +is honest about where its floor comes from. + +--- + +## 13. Never in scope: modifying the mouse + +The mouse is never opened, nothing is soldered to its PCB, and no transistor is +wired across its microswitch. `t₀` comes from a probe touching **removable** +conductive copper tape on the outside of the left button. Any future mode, +Probe-to-Photon included, keeps that constraint. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f85af5c..f666c3b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -7,6 +7,19 @@ Something else owns the COM port. Close the Arduino Serial Monitor, any older `latency_companion.py` or dashboard window, and any terminal with the port open. Then press *Refresh* and *Connect*. +The automatic connection at startup no longer raises this as a dialog: it +retries quietly four times before giving up, because Windows can still be +holding the port for a moment after a previous run. If a **manual** *Connect* +also fails, the port really is taken by another program. + +**The Arduino Serial Monitor shows nothing at all** +Two different things, and both look identical: +- **The dashboard is connected.** One program at a time owns a COM port. The + monitor then stays silent for ever without an error. Close one or the other. +- **You missed the banner.** A Teensy does not reset when the monitor opens, so + the boot banner is long gone. Type `V` and press Enter; the version reply is + the proof that the link works. The baud rate is irrelevant on a Teensy. + **The Teensy does not appear in the port list** Check Device Manager → *Ports (COM & LPT)*. If it is missing, try another USB cable (many are charge-only), another port, and install the Teensyduino driver. @@ -58,6 +71,60 @@ Should be impossible with firmware v1.3 (80 ms re-arm) plus the dashboard's one-click-one-sample rule. If it happens, you are probably running older firmware — check the version in the connection bar. +## Buttons + +**Pressing BTN1 or BTN2 does nothing** +Almost always the wiring of the 4-pin button. Its legs are joined in **two +pairs**, one pair per side; you must use two **diagonally opposite** corners. If +you picked two legs of the same pair the pin is permanently at GND, the firmware +records it as "already pressed" at boot, and no event ever appears. + +Test it with a continuity tester: **open at rest, closed when pressed.** If it +beeps at rest, rotate the button 90°. + +If the button tests good and still nothing happens, take it out of the circuit +and bridge the Teensy's own `GND` pad to the `B0` pad with a jumper for an +instant. `BTN1:PRESS` appearing means the pin and firmware are fine and the +fault is in the breadboard wiring; nothing appearing means you are not touching +the pad you think you are. + +**BTN2 asks before clearing the run** +By design, and it can be switched off: *Settings → Ask before BTN2 clears the +live run*. It is on by default because BTN2 sits on the same desk as the mouse +being measured. + +**BTN2 does nothing during a test** +Also by design. The firmware reports the press; the dashboard refuses it so a +stray knock cannot destroy a running test. The refusal is written to the log. + +## Probe-to-Photon + +**The test will not start / "dark and bright are too close"** +The two baselines are less than 60 ADC counts apart, which would put the +threshold inside the sensor's own noise. Shade the KY-018 from the room light, +raise the screen brightness, or move the sensor closer to the target square. + +**`OPT_ERR:NOT_DARK`** +The sensor was already above the threshold when the probe fired. Either it is +picking up room light rather than the target, or the target had not gone back to +black yet — give the previous cycle time to re-arm before pressing again. + +**`OPT_TIMEOUT`** +No light change within 400 ms of the probe contact. The sensor is not pointed at +the target area, or the click never reached Windows at all — check the Live tab +log for the click. + +**The optical numbers are much higher than the Probe-to-PC ones** +Expected. Probe-to-Photon includes the application, the compositor, the GPU +queue, the panel and the photoresistor's own response. Tens of milliseconds is +normal. See [README → Probe-to-Photon](../README.md#probe-to-photon) for why the +absolute figure is not a benchmark. + +**The optical numbers move between sessions** +A photoresistor drifts with ambient light and temperature. Re-run the black and +white calibration whenever the room light, the screen brightness or the sensor +position changes — and compare only runs taken under the same conditions. + ## Interface **Text is unreadable after switching theme** diff --git a/docs/wiring.md b/docs/wiring.md index 649908a..77f40e3 100644 --- a/docs/wiring.md +++ b/docs/wiring.md @@ -13,16 +13,28 @@ Do not skip ahead. Each step assumes the previous one passed. ### 1. Teensy alone -Flash `firmware/latency_tester_oled_ldr_v1_4/`, open the Arduino Serial Monitor +Flash `firmware/latency_tester_photon_v1_5/`, open the Arduino Serial Monitor at `115200`. **Expected:** ``` -LATENCY_TESTER v1.4 OLED+LDR+BTN +LATENCY_TESTER v1.5 OLED+LDR+BTN+PHOTON READY ``` The board appears in Device Manager under *Ports (COM & LPT)*. +> [!IMPORTANT] +> **Opening the Serial Monitor does not reset a Teensy** — unlike an Uno. The +> banner is printed once at power-on, so by the time you open the monitor it has +> already gone. An empty monitor proves nothing. Type `V` and press Enter: the +> firmware answers with its version string, and that is how you know both the +> link and the flash are good. The baud rate is irrelevant on a Teensy: the USB +> is native and `115200` is a formality. +> +> **Only one program can hold the COM port.** If the dashboard is connected the +> Serial Monitor stays silent for ever, without any error. Close one before +> opening the other. + **If not:** try another USB cable (many are charge-only), another port, and install the Teensyduino driver. @@ -133,10 +145,27 @@ Wire `B0`(digital 0)→BTN1→GND (orange) and `B1`(digital 1)→BTN2→GND (pur **No external resistors** — `INPUT_PULLUP` supplies them. Not pressed = HIGH, pressed = LOW. -Firmware v1.4 is a **test phase**: the buttons emit an event and nothing else. -They are polled in `loop()` with a non-blocking 30 ms debounce, never on an -interrupt, and the event is queued and only transmitted while no measurement is -pending — so nothing is ever printed between t₀ and t₁. +> [!IMPORTANT] +> **A 4-pin tactile button is two pairs, not four contacts.** The two legs on +> the *same side* are joined together permanently. Use **diagonally opposite** +> corners — one for the Teensy pin, one for GND — and straddle the breadboard's +> centre groove so the pairs land on different rows. +> +> Check with a continuity tester: **open at rest, closed when pressed.** If it +> beeps at rest you have picked two legs of the same pair; the pin then sits +> permanently at GND, the firmware records it as "already pressed" at boot, and +> **no event will ever appear**. That is the single most common cause of +> "the buttons do nothing". +> +> If nothing happens, take the button out of the circuit and bridge the Teensy's +> own `GND` pad to the `B0` pad with a jumper for an instant. If `BTN1:PRESS` +> appears, the pin and the firmware are fine and the fault is in the breadboard +> wiring. + +The buttons are polled in `loop()` with a non-blocking 30 ms debounce, never on +an interrupt, and the event is queued and only transmitted while no measurement +is pending — so nothing is ever printed between t₀ and t₁. The firmware itself +changes no state: it reports, and the **dashboard decides**. With the dashboard open, press **BTN1 ten times, slowly**: @@ -168,12 +197,12 @@ the timing. Run it, compare against an earlier run in the **Compare** tab with the old run as baseline, and confirm Δ median and Δ P95 are within your usual run-to-run noise. -Only once every box is ticked do the buttons get real behaviour: +Once every box is ticked the buttons have real behaviour: -| Button | Planned action | +| Button | Action | |---|---| -| `BTN1` | **Request** enter/exit test mode | -| `BTN2` | Reset the live run, only while **not** in test mode | +| `BTN1` | Enter / leave test mode | +| `BTN2` | Clear the live run, only while **not** in test mode | The firmware reports the event; the **dashboard decides**. During a measurement or during test mode, BTN2 is refused rather than allowed to destroy a run. No @@ -181,14 +210,55 @@ long presses, double clicks or combinations. --- -### 10. Transistor / light-sensing mode +### 10. Probe-to-Photon + +**No new wiring.** `t₀` is still the probe on `D2` and the light transition is +read from the KY-018 already on `A0`. What changes is where you point the sensor. + +1. *Live test → Measurement mode →* **Probe-to-Photon**. The optical panel + appears. +2. Aim the KY-018 at the **centre of the screen** — that is where the + full-screen target appears. Two or three centimetres away, facing it square + on, taped or clamped so it cannot drift. Shade it from the room lamp if you + can. +3. Press **Calibra sul bersaglio a schermo intero**. A centred full-screen + target appears and takes both baselines by itself: black, 400 ms settle, + sample; white, 400 ms settle, sample. **Do not move the sensor.** It shows + the result and closes. About a second and a half. +4. If the two baselines are less than **60 counts** apart it stops at + `CALIBRAZIONE FALLITA`, reports the numbers it measured, and stays on screen + with a live `LUCE` reading. Move the sensor until that number swings between + the black and the white phase, then press Enter to retry or Esc to give up. + **If the reading barely moves, the sensor is not on the target** — that is + what this readout is for. +5. **Enter test mode.** The calibration is repeated automatically, on the same + target, so it can never be stale. Then the usual green/red/blue cycle. +6. Press ten times and expect ten `OPT:` lines, in the tens of milliseconds. + +> [!TIP] +> **Shade the sensor.** A KY-018 sees the whole room, not just the screen. A +> centimetre of black tape rolled into a tube around it is the single biggest +> improvement you can make to the separation — often an order of magnitude. +> Aim for 200 counts or more between dark and bright; 60 is the bare minimum the +> firmware will accept. + +Expected failures, and what they mean: + +| Token | Meaning | +|---|---| +| `OPT_ERR:NO_CAL` | Test started without a usable calibration | +| `OPT_ERR:NOT_DARK` | The target was already bright at `t₀` — the sensor is seeing room light or the previous flash | +| `OPT_ERR:SEPARATION` | The two baselines are too close together | +| `OPT_TIMEOUT` | No transition within 400 ms — the sensor is not looking at the target | -⛔ **Not defined. Do not wire the 2N2222A.** +The **2N2222A and the 220 Ω resistors are not used at all.** They were on an +early parts list and the finished tester has no role for them, in this mode or +any other. Leave them out. -The transistor and its 220 Ω resistor are physically present but deliberately -unconnected. No firmware supports them, no protocol tokens exist, and no -measurement mode has been specified. This step stays blocked until a separate -specification for the click-to-photon / light-sensing mode exists. +Read [README → Probe-to-Photon](../README.md#probe-to-photon) before quoting any +number from this mode: the KY-018 is a photoresistor whose own response time is +in the milliseconds, so this is a relative indicator, not a precision +click-to-photon benchmark. --- @@ -203,9 +273,12 @@ specification for the click-to-photon / light-sensing mode exists. order from pin position — it differs between manufacturers. * **No external resistors on the buttons.** `INPUT_PULLUP` provides them; adding pull-downs breaks the logic. -* **Do not assume the 2N2222A pinout.** Both EBC and ECB orderings exist - depending on package and manufacturer. Identify the exact part before wiring - it — and it is not to be wired yet regardless. +* **Never modify the mouse.** Do not open it, do not solder to its PCB, do not + wire a transistor across its microswitch. The only mouse-side modification in + this project is **removable** conductive copper tape on the outside of the left + button. +* **The 2N2222A and the 220 Ω resistors are not used at all.** There is no + wiring for them anywhere in this project. Leave them out. * Measured supply on USB: **≈ 4.8 V** between `VCC` and `GND`. Both the OLED and the KY-018 are fine at that level. diff --git a/firmware/latency_tester_oled_ldr_v1_4/latency_tester_oled_ldr_v1_4.ino b/firmware/latency_tester_oled_ldr_v1_4/latency_tester_oled_ldr_v1_4.ino index 77c8002..ba4c629 100644 --- a/firmware/latency_tester_oled_ldr_v1_4/latency_tester_oled_ldr_v1_4.ino +++ b/firmware/latency_tester_oled_ldr_v1_4/latency_tester_oled_ldr_v1_4.ino @@ -4,7 +4,10 @@ * * Wiring: * D2 / digital 7 -> Probe (metal tip) [white] - * GND -> Conductive tape on mouse button [black] + * GND -> Removable conductive copper tape on the OUTSIDE of the + * mouse's left button [black]. The mouse is never opened, + * never soldered to, and no transistor is wired across its + * microswitch. That is the only mouse-side modification. * D0 / digital 5 -> OLED SCL/SCK [yellow] * D1 / digital 6 -> OLED SDA [green] * F0 / A0 -> KY-018 S (analog signal) [blue] diff --git a/firmware/latency_tester_photon_v1_5/latency_tester_photon_v1_5.ino b/firmware/latency_tester_photon_v1_5/latency_tester_photon_v1_5.ino new file mode 100644 index 0000000..8cde6f1 --- /dev/null +++ b/firmware/latency_tester_photon_v1_5/latency_tester_photon_v1_5.ino @@ -0,0 +1,869 @@ +/* + * Latency Tester v1.5 - Probe + OLED + KY-018 + Buttons + Probe-to-Photon + * Teensy 2.0 (ATmega32U4) + * + * v1.5 = v1.4 plus a SECOND, SEPARATE measurement mode. The Probe-to-PC path + * (probeISR, t0/t1, the TRIG/'H' handshake, runCalibration, serviceProbeRearm, + * the OLED freeze and every filter) is byte-identical to v1.4 and v1.3. The + * optical path is reached only while opticalMode is on, and opticalMode is off + * unless the dashboard asks for it. + * + * Wiring: + * D2 / digital 7 -> Probe (metal tip) [white] + * GND -> Removable conductive copper tape on the OUTSIDE of the + * mouse's left button [black]. The mouse is never opened, + * never soldered to, and no transistor is wired across its + * microswitch. That is the only mouse-side modification. + * D0 / digital 5 -> OLED SCL/SCK [yellow] + * D1 / digital 6 -> OLED SDA [green] + * F0 / A0 -> KY-018 S (analog signal) [blue] + * B0 / digital 0 -> BTN1 -> GND, INPUT_PULLUP [orange] + * B1 / digital 1 -> BTN2 -> GND, INPUT_PULLUP [purple] + * VCC / GND -> OLED + KY-018 power rails [red / black] + * Pin 11 -> Teensy built-in LED + * + * Buttons (v1.4, TEST PHASE ONLY): + * Polled in loop(), never on an interrupt. Non-blocking millis() debounce. + * One physical press emits exactly one "BTN1:PRESS" / "BTN2:PRESS". + * Events are queued and flushed only while no measurement is pending, so + * nothing is ever printed between t0 and t1. The buttons currently have NO + * effect on the firmware state -- the dashboard only logs them. + * + * Timing: + * The probe remains the t0 source. + * OLED/LDR work is never done after a valid probe trigger and before t1. + * If a probe happens during an OLED I2C refresh, that sample is deliberately + * discarded (SKIP:OLED_REFRESH) rather than reporting an artificially high + * latency. + * + * Companion protocol remains compatible with latency_companion.py: + * Teensy: "TRIG" -> PC detects click -> PC: 'H' -> Teensy computes latency. + * + * Probe-to-Photon (mode 2, entirely separate): + * t0 = the same probe contact on D2, taken in the same ISR. + * After t0 the only thing this firmware does is read A0 in a tight loop. + * t1 = the first reading past the calibrated threshold. latency = t1 - t0. + * No serial traffic happens between t0 and t1, so nothing is subtracted: + * calibOffset belongs to the serial round-trip and is NOT applied here. + * The PC flips its own on-screen target from black to white when Windows + * reports the click, so the light transition the sensor sees is produced by + * the machine under test, not by this firmware. + * + * The KY-018 is a photoresistor: its own response time is in the + * milliseconds and is therefore a real part of every number this mode + * reports. Useful for comparing a setup against itself; not a precision + * click-to-photon instrument. + */ + +#include +#include +#include +#include + +// ---- Pin definitions ---- +#define PROBE_PIN 7 // Teensy 2.0 pad D2, interrupt-capable +#define LED_PIN 11 // Teensy 2.0 built-in LED +#define LIGHT_PIN A0 // Teensy 2.0 pad F0 +#define BTN1_PIN 0 // Teensy 2.0 pad B0, to GND +#define BTN2_PIN 1 // Teensy 2.0 pad B1, to GND + +// ---- OLED ---- +#define OLED_ADDR 0x3C +#define OLED_RESET -1 +Adafruit_SH1106G display(128, 64, &Wire, OLED_RESET); +bool oledOK = false; + +// OLED updates are intentionally infrequent: SH1106 sends the whole buffer. +#define DISPLAY_REFRESH_MS 500 +#define REARM_STABLE_MS 80 +unsigned long lastDisplayRefresh = 0; + +volatile bool displayBusy = false; +volatile bool probeDuringDisplay = false; +volatile bool probeArmed = true; +unsigned long probeHighSince = 0; +bool testMode = false; + +// ---- Buttons ---- +// Deliberately polled, not interrupt-driven: a button must never be able to +// preempt the probe ISR or add work to the measurement window. +#define BUTTON_COUNT 2 +#define BUTTON_DEBOUNCE_MS 30 + +const uint8_t buttonPins[BUTTON_COUNT] = { BTN1_PIN, BTN2_PIN }; +uint8_t buttonReading[BUTTON_COUNT] = { HIGH, HIGH }; // last raw sample +uint8_t buttonStable[BUTTON_COUNT] = { HIGH, HIGH }; // debounced level +unsigned long buttonChangedAt[BUTTON_COUNT] = { 0, 0 }; + +// One bit per button. Set by serviceButtons(), drained by flushButtonEvents() +// only when no measurement is pending. +uint8_t pendingButtonPress = 0; + +// ---- Measurement filter ---- +#define LATENCY_MIN_US 2000 +#define LATENCY_MAX_US 100000 + +// ---- Calibration ---- +#define CALIB_SAMPLES 50 + +// ---- Probe-to-Photon ---- +// Baselines are sampled with the same averaging as the LIGHT telemetry, so a +// calibration and a live reading are directly comparable. +#define OPT_MIN_SEPARATION 60 // ADC counts; mirrors protocol.py +#define OPT_THRESHOLD_NUM 1 // threshold = dark + span * 1/2 +#define OPT_THRESHOLD_DEN 2 +#define OPT_HYSTERESIS 8 // counts inside the dark side, anti-noise +#define OPT_TIMEOUT_US 400000UL +#define OPT_LATENCY_MIN_US 500UL +#define OPT_LATENCY_MAX_US 350000UL + +bool opticalMode = false; +bool opticalCalibrated = false; +int opticalDark = 0; +int opticalBright = 0; +int opticalThreshold = 0; +bool opticalRising = true; // does the ADC value climb as light rises? +int opticalLastRaw = 0; + +// Optical statistics are kept apart from the Probe-to-PC ones on purpose: the +// two modes measure different things and must never share a mean. +unsigned long optLast = 0; +unsigned long optMin = 0; +unsigned long optMax = 0; +float optAvg = 0; +unsigned int optCount = 0; +float optSum = 0; + +// ---- Globals ---- +volatile unsigned long probeTime = 0; +volatile bool probeFlag = false; +bool measurementActive = false; + +unsigned long calibOffset = 0; +unsigned long lastLatency = 0; +unsigned long minLatency = 0; +unsigned long maxLatency = 0; +float avgLatency = 0; +unsigned int sampleCount = 0; +float sumLatency = 0; +int lastLight = 0; + +bool showStats = false; +char stateText[14] = "READY"; + +// ---- Status ---- +void setState(const char *text) { + strncpy(stateText, text, sizeof(stateText) - 1); + stateText[sizeof(stateText) - 1] = '\0'; +} + +// ---- Light sensor ---- +int readLight() { + // Fast average to tame ADC noise. Only called outside timing-critical window. + long total = 0; + const int samples = 16; + for (int i = 0; i < samples; i++) { + total += analogRead(LIGHT_PIN); + } + return (int)(total / samples); +} + +// ---- OLED helpers ---- +void beginDisplayUpdate() { + displayBusy = true; +} + +void endDisplayUpdate() { + displayBusy = false; + lastDisplayRefresh = millis(); +} + +void drawReadyScreen() { + if (!oledOK) return; + + lastLight = readLight(); + + display.clearDisplay(); + display.setTextColor(SH110X_WHITE); + display.setTextSize(1); + display.setCursor(0, 0); + display.println("LATENCY TESTER"); + display.drawLine(0, 9, 127, 9, SH110X_WHITE); + + display.setCursor(0, 14); + display.print("STATE: "); + display.println(stateText); + + display.setCursor(0, 27); + display.print("LIGHT: "); + display.println(lastLight); + + display.setCursor(0, 40); + display.print("CAL: "); + display.print(calibOffset); + display.println(" us"); + + display.setCursor(0, 53); + display.println("Probe on D2"); + + beginDisplayUpdate(); + display.display(); + endDisplayUpdate(); +} + +void drawStatsScreen() { + if (!oledOK) return; + + lastLight = readLight(); + + display.clearDisplay(); + display.setTextColor(SH110X_WHITE); + display.setTextSize(1); + + display.setCursor(0, 0); + display.print("LAST "); + display.print(lastLatency / 1000.0, 3); + display.println(" ms"); + + display.setCursor(0, 11); + display.print("AVG "); + display.print(avgLatency / 1000.0, 3); + display.println(" ms"); + + display.setCursor(0, 22); + display.print("MIN "); + display.print(minLatency / 1000.0, 3); + display.println(" ms"); + + display.setCursor(0, 33); + display.print("MAX "); + display.print(maxLatency / 1000.0, 3); + display.println(" ms"); + + display.setCursor(0, 44); + display.print("N "); + display.print(sampleCount); + display.print(" L "); + display.println(lastLight); + + int bar = map(lastLight, 0, 1023, 0, 126); + bar = constrain(bar, 0, 126); + display.drawRect(0, 55, 128, 9, SH110X_WHITE); + display.fillRect(1, 56, bar, 7, SH110X_WHITE); + + beginDisplayUpdate(); + display.display(); + endDisplayUpdate(); +} + +void drawCurrentScreen() { + if (showStats && sampleCount > 0) { + drawStatsScreen(); + } else { + drawReadyScreen(); + } +} + +void drawTestModeScreen() { + if (!oledOK) return; + + lastLight = readLight(); + display.clearDisplay(); + display.setTextColor(SH110X_WHITE); + display.setTextSize(1); + display.setCursor(0, 0); + display.println("LATENCY TESTER"); + display.drawLine(0, 9, 127, 9, SH110X_WHITE); + display.setTextSize(2); + display.setCursor(10, 18); + display.println("TEST MODE"); + display.setTextSize(1); + display.setCursor(12, 43); + display.println("OLED PAUSED"); + display.setCursor(12, 54); + display.print("LIGHT "); + display.println(lastLight); + + beginDisplayUpdate(); + display.display(); + endDisplayUpdate(); +} + +// ---- Buttons ---- +// Non-blocking debounce. Cheap enough (two digitalRead + compares) to run on +// every loop iteration, and loop() does not run at all between t0 and t1. +void serviceButtons() { + unsigned long now = millis(); + + for (uint8_t i = 0; i < BUTTON_COUNT; i++) { + uint8_t reading = digitalRead(buttonPins[i]); + + if (reading != buttonReading[i]) { + buttonReading[i] = reading; + buttonChangedAt[i] = now; + continue; + } + + if (reading != buttonStable[i] && + (now - buttonChangedAt[i]) >= BUTTON_DEBOUNCE_MS) { + buttonStable[i] = reading; + // INPUT_PULLUP: LOW is pressed. Only the press edge is reported; a held + // button never repeats. + if (reading == LOW) { + pendingButtonPress |= (uint8_t)(1 << i); + } + } + } +} + +// Serial output is done here and nowhere else, so a button can never insert a +// transmission into the timing window. +void flushButtonEvents() { + if (pendingButtonPress == 0) return; + if (measurementActive || probeFlag) return; + + if (pendingButtonPress & 0x01) Serial.println("BTN1:PRESS"); + if (pendingButtonPress & 0x02) Serial.println("BTN2:PRESS"); + pendingButtonPress = 0; +} + +// ---- Probe-to-Photon ---- +// The ADC prescaler is raised only while this mode is active. At the stock +// /128 an analogRead costs ~112 us, which would quantise the result far more +// coarsely than the sensor itself; /16 brings that to ~13 us at the cost of a +// little absolute accuracy, which does not matter for a threshold crossing. +void opticalFastADC(bool fast) { + ADCSRA = (ADCSRA & ~0x07) | (fast ? 0x04 : 0x07); +} + +// True once the two baselines are far enough apart to tell black from white. +bool opticalComputeThreshold() { + int span = opticalBright - opticalDark; + if (span > -OPT_MIN_SEPARATION && span < OPT_MIN_SEPARATION) { + opticalCalibrated = false; + return false; + } + opticalRising = (span > 0); + opticalThreshold = opticalDark + (span * OPT_THRESHOLD_NUM) / OPT_THRESHOLD_DEN; + opticalCalibrated = true; + return true; +} + +void sendOpticalCal() { + Serial.print("OPT_CAL:dark:"); + Serial.print(opticalDark); + Serial.print(",bright:"); + Serial.print(opticalBright); + Serial.print(",threshold:"); + Serial.print(opticalThreshold); + Serial.print(",rising:"); + Serial.println(opticalRising ? 1 : 0); +} + +// Has the target crossed into the bright half? One comparison, no branches +// worth mentioning: this runs inside the t0->t1 window. +inline bool opticalIsBright(int value) { + return opticalRising ? (value >= opticalThreshold) : (value <= opticalThreshold); +} + +// Is the sensor sitting in the dark half, with margin? Checked before arming +// so a target left white cannot fire instantly. +bool opticalIsDark(int value) { + return opticalRising ? (value <= opticalThreshold - OPT_HYSTERESIS) + : (value >= opticalThreshold + OPT_HYSTERESIS); +} + +bool updateOpticalStats(unsigned long latencyUs) { + if (latencyUs < OPT_LATENCY_MIN_US || latencyUs > OPT_LATENCY_MAX_US) return false; + + optLast = latencyUs; + if (optCount == 0) { + optMin = latencyUs; + optMax = latencyUs; + } + if (latencyUs < optMin) optMin = latencyUs; + if (latencyUs > optMax) optMax = latencyUs; + + optSum += latencyUs; + optCount++; + optAvg = optSum / optCount; + return true; +} + +void sendOpticalResult(unsigned long latencyUs) { + Serial.print("OPT:"); + Serial.print(latencyUs / 1000.0, 3); + Serial.print(",raw:"); + Serial.print(opticalLastRaw); + Serial.print(",min:"); + Serial.print(optMin / 1000.0, 3); + Serial.print(",max:"); + Serial.print(optMax / 1000.0, 3); + Serial.print(",avg:"); + Serial.print(optAvg / 1000.0, 3); + Serial.print(",n:"); + Serial.println(optCount); +} + +void resetOpticalStats() { + optLast = 0; + optMin = 0; + optMax = 0; + optAvg = 0; + optCount = 0; + optSum = 0; +} + +// One optical measurement. Nothing is transmitted between t0 and t1 -- the PC +// needs no cue, it flips its own target when Windows reports the click -- and +// no OLED or button work can run, because loop() is not running. +void runOpticalMeasurement() { + measurementActive = true; + + noInterrupts(); + probeFlag = false; + unsigned long t0 = probeTime; + interrupts(); + + if (!opticalCalibrated) { + digitalWrite(LED_PIN, LOW); + Serial.println("OPT_ERR:NO_CAL"); + measurementActive = false; + Serial.println("REARM"); + return; + } + + // The first sample doubles as the "is the target actually black?" check, so + // it costs nothing extra. A target left white would otherwise fire instantly. + int value = analogRead(LIGHT_PIN); + if (opticalIsBright(value)) { + opticalLastRaw = value; + digitalWrite(LED_PIN, LOW); + Serial.println("OPT_ERR:NOT_DARK"); + measurementActive = false; + Serial.println("REARM"); + return; + } + + unsigned long t1 = 0; + bool crossed = false; + while ((micros() - t0) < OPT_TIMEOUT_US) { + value = analogRead(LIGHT_PIN); + if (opticalIsBright(value)) { + // TIMING STOPS HERE. + t1 = micros(); + crossed = true; + break; + } + } + + digitalWrite(LED_PIN, LOW); + opticalLastRaw = value; + + if (!crossed) { + Serial.println("OPT_TIMEOUT"); + setState("OPT T/O"); + } else { + // calibOffset is the serial round-trip and has no business here: this + // measurement never went near the serial port. + unsigned long latency = t1 - t0; + if (updateOpticalStats(latency)) { + sendOpticalResult(latency); + setState("PHOTON"); + } else { + Serial.print("OPT_DROP:OUT_OF_RANGE:"); + Serial.println(latency / 1000.0, 3); + setState("OPT BAD"); + } + } + + measurementActive = false; + Serial.println("REARM"); +} + +// ---- Interrupt Service Routine ---- +void probeISR() { + // One physical press must create at most one measurement. The input is + // re-armed only after it has returned HIGH continuously for REARM_STABLE_MS. + if (!probeArmed) return; + probeArmed = false; + + // Outside TEST MODE an OLED transfer can overlap a physical press. Reject + // that press instead of contaminating t0. In TEST MODE OLED refresh is + // paused, so this path should not occur. + if (displayBusy) { + probeDuringDisplay = true; + return; + } + + probeTime = micros(); + probeFlag = true; + digitalWrite(LED_PIN, HIGH); +} + +// ---- Probe re-arm / debounce ---- +void serviceProbeRearm() { + if (probeArmed || measurementActive || probeFlag) return; + + if (digitalRead(PROBE_PIN) == HIGH) { + if (probeHighSince == 0) { + probeHighSince = millis(); + } else if (millis() - probeHighSince >= REARM_STABLE_MS) { + probeArmed = true; + probeHighSince = 0; + Serial.println("ARMED"); + if (testMode) setState("ARMED"); + } + } else { + probeHighSince = 0; + } +} + +// ---- Serial helpers ---- +char waitForChar(unsigned long timeoutMs = 3000) { + unsigned long start = millis(); + while (!Serial.available()) { + if (millis() - start > timeoutMs) return 0; + } + return Serial.read(); +} + +void sendResult(unsigned long latencyUs) { + float latencyMs = latencyUs / 1000.0; + Serial.print("LAT:"); + Serial.print(latencyMs, 3); + Serial.print(",min:"); + Serial.print(minLatency / 1000.0, 3); + Serial.print(",max:"); + Serial.print(maxLatency / 1000.0, 3); + Serial.print(",avg:"); + Serial.print(avgLatency / 1000.0, 3); + Serial.print(",n:"); + Serial.println(sampleCount); +} + +// ---- Update statistics ---- +bool updateStats(unsigned long latencyUs) { + if (latencyUs < LATENCY_MIN_US || latencyUs > LATENCY_MAX_US) return false; + + lastLatency = latencyUs; + + if (sampleCount == 0) { + minLatency = latencyUs; + maxLatency = latencyUs; + } + + if (latencyUs < minLatency) minLatency = latencyUs; + if (latencyUs > maxLatency) maxLatency = latencyUs; + + sumLatency += latencyUs; + sampleCount++; + avgLatency = sumLatency / sampleCount; + return true; +} + +// ---- Calibrate serial round-trip ---- +void runCalibration() { + measurementActive = true; + showStats = false; + setState("CALIB..."); + drawReadyScreen(); + + Serial.println("CALIBRATING..."); + Serial.println("Please run the companion script."); + Serial.println("Waiting for PC to sync..."); + + while (waitForChar() != 'R') { + Serial.println("AWAIT"); + delay(250); + } + Serial.println("PC_SYNCED"); + delay(100); + + unsigned long total = 0; + unsigned long minTrip = 0xFFFFFFFF; + unsigned long maxTrip = 0; + int valid = 0; + + for (int i = 0; i < CALIB_SAMPLES; i++) { + unsigned long t0 = micros(); + Serial.println("P"); + char c = waitForChar(); + unsigned long t1 = micros(); + + if (c == 'P') { + unsigned long roundTrip = t1 - t0; + if (roundTrip < 50000) { + total += roundTrip; + if (roundTrip < minTrip) minTrip = roundTrip; + if (roundTrip > maxTrip) maxTrip = roundTrip; + valid++; + } + } + delayMicroseconds(20000); + } + + if (valid > 0) { + calibOffset = (total / valid) / 2 + 250; + Serial.print("CALIB_OK:"); + Serial.print(calibOffset); + Serial.print(",samples:"); + Serial.print(valid); + Serial.print(",min_us:"); + Serial.print(minTrip); + Serial.print(",max_us:"); + Serial.print(maxTrip); + Serial.print(",avg_rt:"); + Serial.println(total / valid); + setState("READY"); + } else { + calibOffset = 500; + Serial.println("CALIB_FAIL:DEFAULT"); + setState("CAL FAIL"); + } + + measurementActive = false; + drawReadyScreen(); +} + +// ---- Reset statistics ---- +void resetStats() { + resetOpticalStats(); + lastLatency = 0; + minLatency = 0; + maxLatency = 0; + avgLatency = 0; + sampleCount = 0; + sumLatency = 0; + showStats = false; + setState("READY"); + Serial.println("RESET"); + drawReadyScreen(); +} + +// ---- Setup ---- +void setup() { + pinMode(PROBE_PIN, INPUT_PULLUP); + pinMode(LED_PIN, OUTPUT); + digitalWrite(LED_PIN, LOW); + + pinMode(BTN1_PIN, INPUT_PULLUP); + pinMode(BTN2_PIN, INPUT_PULLUP); + // Seed the debounce state from the real pin level so a button already held + // at power-on does not produce a phantom press. + for (uint8_t i = 0; i < BUTTON_COUNT; i++) { + buttonReading[i] = digitalRead(buttonPins[i]); + buttonStable[i] = buttonReading[i]; + buttonChangedAt[i] = millis(); + } + + Serial.begin(115200); + delay(500); + + // OLED / I2C on Teensy 2.0: D0=SCL, D1=SDA + Wire.begin(); + Wire.setClock(400000); + oledOK = display.begin(OLED_ADDR, true); + + Serial.println("LATENCY_TESTER v1.5 OLED+LDR+BTN+PHOTON"); + Serial.println("READY"); + if (oledOK) { + Serial.println("OLED_OK:0x3C"); + drawReadyScreen(); + } else { + Serial.println("OLED_FAIL"); + } + + attachInterrupt(digitalPinToInterrupt(PROBE_PIN), probeISR, FALLING); +} + +// ---- Main loop ---- +void loop() { + // A click that happened during a display transfer is deliberately rejected. + if (probeDuringDisplay) { + noInterrupts(); + probeDuringDisplay = false; + interrupts(); + Serial.println("SKIP:OLED_REFRESH"); + Serial.println("REARM"); + } + + // Serial commands from companion / future GUI + if (Serial.available() && !measurementActive) { + char cmd = Serial.read(); + + switch (cmd) { + case 'C': + runCalibration(); + break; + + case 'R': + resetStats(); + break; + + case 'S': + if (sampleCount > 0) { + Serial.print("STATS:"); + Serial.print("samples:"); + Serial.print(sampleCount); + Serial.print(",min:"); + Serial.print(minLatency / 1000.0, 3); + Serial.print(",max:"); + Serial.print(maxLatency / 1000.0, 3); + Serial.print(",avg:"); + Serial.print(avgLatency / 1000.0, 3); + Serial.print(",last:"); + Serial.print(lastLatency / 1000.0, 3); + Serial.print(",calib:"); + Serial.println(calibOffset); + showStats = true; + drawStatsScreen(); + } else { + Serial.println("STATS:NO_DATA"); + showStats = false; + setState("NO DATA"); + drawReadyScreen(); + } + break; + + case 'V': + Serial.println("LATENCY_TESTER v1.5 - Probe + OLED + KY-018 + Buttons + Photon"); + break; + + case 'T': + // Desktop TEST MODE: freeze OLED traffic so every armed instant is safe. + testMode = true; + setState("TEST MODE"); + drawTestModeScreen(); + Serial.println("TESTMODE:ON"); + Serial.println(probeArmed ? "ARMED" : "REARM"); + break; + + case 'E': + testMode = false; + setState("READY"); + Serial.println("TESTMODE:OFF"); + drawCurrentScreen(); + break; + + case 'L': + // Manual light read, useful for the future desktop dashboard. + lastLight = readLight(); + Serial.print("LIGHT:"); + Serial.println(lastLight); + break; + + // ---- Probe-to-Photon ------------------------------------------- + case 'O': + // Selecting the mode is always allowed so the dashboard can show the + // sensor state; measuring without a calibration is what gets refused. + opticalMode = true; + opticalFastADC(true); + resetOpticalStats(); + Serial.println("PHOTON:ON"); + sendOpticalCal(); + setState("PHOTON"); + break; + + case 'N': + opticalMode = false; + opticalFastADC(false); + Serial.println("PHOTON:OFF"); + setState("READY"); + drawCurrentScreen(); + break; + + case 'D': + opticalDark = readLight(); + if (!opticalComputeThreshold()) Serial.println("OPT_ERR:SEPARATION"); + sendOpticalCal(); + break; + + case 'W': + opticalBright = readLight(); + if (!opticalComputeThreshold()) Serial.println("OPT_ERR:SEPARATION"); + sendOpticalCal(); + break; + + case 'K': + sendOpticalCal(); + break; + + default: + break; + } + } + + // Probe contact detected. Probe-to-Photon is checked first and returns + // early, so the Probe-to-PC block below stays exactly as it was in v1.3/v1.4. + if (probeFlag && opticalMode) { + runOpticalMeasurement(); + } else if (probeFlag) { + measurementActive = true; + + noInterrupts(); + probeFlag = false; + unsigned long t0 = probeTime; + interrupts(); + + Serial.println("TRIG"); + + // Wait for companion to report the OS mouse click. + char response = waitForChar(); + + if (response == 'H') { + // TIMING STOPS HERE. No LDR/OLED work before this timestamp. + unsigned long t1 = micros(); + unsigned long rawLatency = t1 - t0; + + unsigned long trueLatency; + if (rawLatency > calibOffset) { + trueLatency = rawLatency - calibOffset; + } else { + trueLatency = rawLatency; + } + + bool valid = updateStats(trueLatency); + + if (valid) { + sendResult(trueLatency); + showStats = true; + setState(testMode ? "REARM" : "READY"); + if (!testMode) drawStatsScreen(); + } else { + Serial.print("DROP:OUT_OF_RANGE:"); + Serial.println(trueLatency / 1000.0, 3); + setState("BAD SAMPLE"); + if (!testMode) drawReadyScreen(); + } + } else if (response == 'X') { + Serial.println("ABORT:NO_CLICK"); + setState(testMode ? "REARM" : "READY"); + if (!testMode) drawCurrentScreen(); + } else { + Serial.println("TIMEOUT:No PC response. Is companion script running?"); + setState("TIMEOUT"); + if (!testMode) drawReadyScreen(); + } + + digitalWrite(LED_PIN, LOW); + measurementActive = false; + Serial.println("REARM"); + } + + serviceProbeRearm(); + + // Buttons are sampled here, after any pending measurement has completed. + // TEST PHASE: they only emit an event; they change no firmware state. + serviceButtons(); + flushButtonEvents(); + + // Live LDR/OLED refresh only while idle. No display traffic is started once + // a valid probe event is pending or a measurement/calibration is active. + if (oledOK && !testMode && !opticalMode && !measurementActive && !probeFlag && + (millis() - lastDisplayRefresh >= DISPLAY_REFRESH_MS)) { + drawCurrentScreen(); + } + + delay(1); +} diff --git a/latency_tester/__init__.py b/latency_tester/__init__.py index fb75849..364a2d3 100644 --- a/latency_tester/__init__.py +++ b/latency_tester/__init__.py @@ -1,10 +1,14 @@ """Latency Tester — desktop benchmark manager for the Teensy 2.0 probe tester.""" -__version__ = "1.1.0" +__version__ = "1.2.0" # Firmware versions this release speaks. v1.3 is the measurement baseline; -# v1.4 adds the BTN1/BTN2 test events and changes nothing else. -SUPPORTED_FIRMWARE = ("v1.3", "v1.4") -RECOMMENDED_FIRMWARE = "v1.4" +# v1.4 adds the BTN1/BTN2 test events; v1.5 adds Probe-to-Photon and leaves the +# Probe-to-PC measurement path byte-identical to both. +SUPPORTED_FIRMWARE = ("v1.3", "v1.4", "v1.5") +RECOMMENDED_FIRMWARE = "v1.5" +#: Probe-to-Photon needs firmware that has the optical path. +PHOTON_FIRMWARE = "v1.5" -__all__ = ["__version__", "SUPPORTED_FIRMWARE", "RECOMMENDED_FIRMWARE"] +__all__ = ["__version__", "SUPPORTED_FIRMWARE", "RECOMMENDED_FIRMWARE", + "PHOTON_FIRMWARE"] diff --git a/latency_tester/app.py b/latency_tester/app.py index 582ac69..313266d 100644 --- a/latency_tester/app.py +++ b/latency_tester/app.py @@ -13,8 +13,8 @@ from datetime import datetime from tkinter import filedialog, messagebox, ttk -from . import (SUPPORTED_FIRMWARE, __version__, export, protocol, - serial_service, theme) +from . import SUPPORTED_FIRMWARE, __version__, export, protocol, serial_service, theme +from .constants import MEASUREMENT_MODES, MODE_PHOTON, MODE_PROBE_PC from .database import LatencyDB from .demo import PORT_NAME as DEMO_PORT from .demo import DemoDevice @@ -26,6 +26,17 @@ PUMP_INTERVAL_MS = 50 LIGHT_POLL_MS = 1000 +#: Faster reading while the optical calibration target is showing. +LIGHT_AIM_POLL_MS = 150 +#: How long to let the LDR settle after the target changes colour, before a +#: baseline is sampled. A photoresistor is slow; this is not a guess to tune +#: away, it is the sensor's own response time. +OPTICAL_SETTLE_MS = 400 +#: The optical calibration, in order: (target is bright, command to send). +PHOTON_CALIBRATION_STEPS = ( + (False, protocol.CMD_CAL_DARK), + (True, protocol.CMD_CAL_BRIGHT), +) RECONNECT_INTERVAL_MS = 3000 class LatencyTesterApp: @@ -49,6 +60,7 @@ def __init__(self, root: tk.Tk): self.test_mode = False self.test_complete = False self.overlay = None + self.cal_window = None self._reconnect_port: str | None = None # Current, unsaved run. @@ -113,6 +125,21 @@ def _build_vars(self) -> None: self.language_var = tk.StringVar(value=tr.language) self.theme_var = tk.StringVar(value=str(s.get("theme"))) self.auto_connect_var = tk.BooleanVar(value=bool(s.get("auto_connect"))) + self.confirm_btn2_var = tk.BooleanVar(value=bool(s.get("confirm_btn2_reset"))) + + # Probe-to-Photon. The mode is a plain string so it can go straight + # into the archive; MEASUREMENT_MODES is the only accepted vocabulary. + mode = s.get("measurement_mode") + self.measure_mode_var = tk.StringVar( + value=mode if mode in MEASUREMENT_MODES else MODE_PROBE_PC) + self.optical_dark: int | None = None + self.optical_bright: int | None = None + self.optical_threshold: int | None = None + self.optical_rising = True + self.opt_dark_var = tk.StringVar(value="—") + self.opt_bright_var = tk.StringVar(value="—") + self.opt_threshold_var = tk.StringVar(value="—") + self.opt_status_var = tk.StringVar(value=tr("photon.status_uncalibrated")) self.device_name_to_id: dict[str, int] = {} @@ -211,7 +238,21 @@ def refresh_ports(self, auto_connect: bool = False) -> None: self.port_var.set(labels[0]) if auto_connect and detected and not self.serial.connected: - self.root.after(250, self.connect) + self.root.after(250, lambda: self._auto_connect(attempts=4)) + + def _auto_connect(self, attempts: int) -> None: + """Connect at startup without ever blocking the user with a dialog. + + Windows can still be holding the COM port for a moment after a previous + run of the dashboard, so the automatic attempt is retried quietly + instead of greeting the user with an access error. + """ + if not self.running or self.serial.connected: + return + if self.connect(silent=True): + return + if attempts > 1: + self.root.after(700, lambda: self._auto_connect(attempts - 1)) def _selected_port(self) -> str: value = self.port_var.get().strip() @@ -220,13 +261,19 @@ def _selected_port(self) -> str: def toggle_connection(self) -> None: self.disconnect() if self.serial.connected else self.connect() - def connect(self) -> None: + def connect(self, silent: bool = False) -> bool: + """Open the selected port. Returns whether the dashboard is connected. + + ``silent`` suppresses the error dialogs: an automatic attempt reports + by returning False, only a user-initiated one interrupts with a modal. + """ if self.serial.connected: - return + return True device = self._selected_port() if not device: - messagebox.showwarning(tr("conn.title"), tr("conn.no_port")) - return + if not silent: + messagebox.showwarning(tr("conn.title"), tr("conn.no_port")) + return False if device == DEMO_PORT: self.serial.attach(DemoDevice(), DEMO_PORT, is_demo=True) @@ -235,9 +282,10 @@ def connect(self) -> None: try: transport = serial_service.open_serial(device) except Exception as exc: - messagebox.showerror(tr("conn.open_failed_title"), - tr("conn.open_failed", error=exc)) - return + if not silent: + messagebox.showerror(tr("conn.open_failed_title"), + tr("conn.open_failed", error=exc)) + return False self.serial.attach(transport, device) self.log(tr("log.connected", port=device)) self._reconnect_port = device @@ -245,8 +293,10 @@ def connect(self) -> None: self._update_connection_labels() for delay, command in ((400, protocol.CMD_VERSION), (650, protocol.CMD_STATS), - (900, protocol.CMD_LIGHT)): + (900, protocol.CMD_LIGHT), + (1150, protocol.CMD_CAL_REPORT)): self.root.after(delay, lambda c=command: self.serial.send(c)) + return True def disconnect(self, keep_retrying: bool = False) -> None: was_demo = self.serial.is_demo @@ -327,6 +377,131 @@ def reset_stats(self) -> None: if self.serial.connected and not self.test_mode: self.send(protocol.CMD_RESET) + # =========================================================== photon mode == + @property + def photon_mode(self) -> bool: + return self.measure_mode_var.get() == MODE_PHOTON + + def set_measurement_mode(self, mode: str) -> None: + """Switch between Probe-to-PC and Probe-to-Photon. + + The two are separate measurements, so changing mode starts a new run + rather than appending optical samples to serial ones. + """ + previous = self.measure_mode_var.get() + if mode not in MEASUREMENT_MODES or mode == previous: + return + if self.test_mode: + self.measure_mode_var.set(previous) # put the radio back + messagebox.showinfo(tr("photon.title"), tr("dlg.exit_test_first")) + return + unsaved = bool(self.current_samples) and not self.session_saved + if unsaved and not messagebox.askyesno(tr("photon.title"), + tr("dlg.discard_confirm")): + self.measure_mode_var.set(previous) + return + self.measure_mode_var.set(mode) + self.settings.set("measurement_mode", mode) + self.clear_samples() + self.run_started_at = None + self.run_ended_at = None + self.log(tr("log.mode", mode=tr(f"mode.{mode}"))) + if self.serial.connected and mode == MODE_PHOTON: + self.send(protocol.CMD_CAL_REPORT) + self.live_view.refresh_mode() + + def calibrate_optical(self) -> None: + """Take both optical baselines, full-screen, on the centred target. + + One button, because there is only one correct way to do it: the two + readings must come from the same patch of screen the measurement uses. + """ + if (not self.serial.connected or self.test_mode + or self.cal_window is not None): + return + from .views.testmode import OpticalCalibrationWindow + self.cal_window = OpticalCalibrationWindow(self.root, self) + if not self.photon_mode: + self.send(protocol.CMD_PHOTON_ON) + self.root.after(300, lambda: self._photon_calibration_step(0)) + + def retry_optical_calibration(self) -> None: + """Re-run the sequence without closing the window, so the live reading + stays on screen while the sensor is being aimed.""" + if self.cal_window is None: + return + self._photon_calibration_step(0) + + def cancel_optical_calibration(self) -> None: + """Close the calibration window, whether it finished or was escaped.""" + if self.cal_window is None: + return + self.cal_window.close() + self.cal_window = None + self.live_view.refresh_mode() + + def _calibration_surface(self): + """Whatever is showing the target right now: the test overlay during a + run, the standalone window otherwise.""" + return self.overlay if self.test_mode else self.cal_window + + def optical_ready(self) -> tuple[bool, str]: + """Is the optical calibration good enough to start a test? + + Returns ``(ok, reason)``; the reason is already translated and is what + the interface shows when it refuses to start. + """ + if self.optical_dark is None or self.optical_bright is None: + return False, tr("photon.status_uncalibrated") + if protocol.optical_threshold(self.optical_dark, self.optical_bright) is None: + # Say how far off it is: "too close" leaves you guessing whether the + # setup is nearly right or nowhere near. + return False, tr("photon.status_separation", + dark=self.optical_dark, + bright=self.optical_bright, + gap=abs(self.optical_bright - self.optical_dark), + minimum=protocol.OPTICAL_MIN_SEPARATION) + return True, tr("photon.status_ready") + + def _apply_optical_cal(self, payload: dict) -> None: + self.optical_dark = payload["dark"] + self.optical_bright = payload["bright"] + self.optical_threshold = payload["threshold"] + self.optical_rising = payload["rising"] + self.opt_dark_var.set(str(payload["dark"])) + self.opt_bright_var.set(str(payload["bright"])) + self.opt_threshold_var.set(str(payload["threshold"])) + self.opt_status_var.set(self.optical_ready()[1]) + + # ============================================================== buttons == + def _button_action(self, index: int) -> None: + """Act on a physical button press. The firmware only ever *reports*. + + The dashboard stays the authority on session state, so a press that + would disturb a run is refused instead of obeyed. The firmware already + withholds button events between t0 and t1, so nothing can arrive here + mid-measurement. + """ + if not self.serial.connected or self.serial.calibrating: + self.log(tr("log.button_refused")) + return + + if index == 1: + self.exit_test_mode() if self.test_mode else self.enter_test_mode() + elif index == 2: + # Never inside test mode: a stray press must not wipe a live run. + if self.test_mode: + self.log(tr("log.button_refused")) + return + needs_confirmation = bool(self.confirm_btn2_var.get() + and self.current_samples) + if needs_confirmation and not messagebox.askyesno( + tr("dlg.new_run"), tr("dlg.btn2_confirm")): + self.log(tr("log.button_refused")) + return + # Same entry point the GUI Reset button and the keyboard use. + self.reset_stats() + # ============================================================= test mode == def _accept_trig(self) -> bool: """Called from the serial thread: refuse new samples past the target.""" @@ -354,14 +529,52 @@ def enter_test_mode(self) -> None: self.overlay = TestModeOverlay(self.root, self, target) # Same ordering as the verified v2 flow: clear the firmware counters - # first, then freeze the OLED. + # first, then freeze the OLED. In Probe-to-Photon the optical path is + # selected last, once the display is already frozen. if self.auto_reset_var.get(): self.send(protocol.CMD_RESET) - self.root.after( - 180, - lambda: self.send(protocol.CMD_TEST_MODE_ON) if self.serial.connected else None) + self.root.after(180, self._start_firmware_test_mode) else: - self.send(protocol.CMD_TEST_MODE_ON) + self._start_firmware_test_mode() + + def _start_firmware_test_mode(self) -> None: + if not self.serial.connected: + return + self.send(protocol.CMD_TEST_MODE_ON) + if self.photon_mode: + self.root.after(80, self._arm_photon) + + def _arm_photon(self) -> None: + if not self.serial.connected or not self.test_mode: + return + self.send(protocol.CMD_PHOTON_ON) + # Calibrate on the real target, in the real overlay. A baseline taken + # from the side panel describes a different patch of screen -- different + # backlight, and in test mode that spot is not even the target any more. + self.root.after(240, lambda: self._photon_calibration_step(0)) + + def _photon_calibration_step(self, index: int) -> None: + """Walk the dark/bright baselines, then hand the target surface back.""" + surface = self._calibration_surface() + if surface is None or not self.serial.connected: + return + if index >= len(PHOTON_CALIBRATION_STEPS): + ok, reason = self.optical_ready() + surface.finish_calibration(ok, reason) + self.log(reason) + return + bright, command = PHOTON_CALIBRATION_STEPS[index] + surface.show_calibrating(bright) + # Paint first, sample only once the photoresistor has settled. + self.root.after(OPTICAL_SETTLE_MS, + lambda: self._photon_calibration_sample(command, index)) + + def _photon_calibration_sample(self, command: str, index: int) -> None: + if self._calibration_surface() is None or not self.serial.connected: + return + self.send(command) + # Let the OPT_CAL: reply arrive and be parsed before moving on. + self.root.after(260, lambda: self._photon_calibration_step(index + 1)) def exit_test_mode(self) -> None: was_active = self.test_mode @@ -370,6 +583,10 @@ def exit_test_mode(self) -> None: self.run_ended_at = datetime.now().isoformat(timespec="seconds") if was_active and self.serial.connected: + # Leave the optical path before the OLED comes back, so a stray + # probe contact can never be measured with a half-torn-down state. + if self.photon_mode: + self.send(protocol.CMD_PHOTON_OFF) self.send(protocol.CMD_TEST_MODE_OFF) if self.overlay is not None: @@ -422,6 +639,10 @@ def current_metadata(self) -> dict: "calibration_us": self.calibration_us(), "teensy_firmware": self.serial.firmware_version or "", "is_demo": 1 if self.is_demo else 0, + "mode": self.measure_mode_var.get(), + "optical_dark": self.optical_dark if self.photon_mode else None, + "optical_bright": self.optical_bright if self.photon_mode else None, + "optical_threshold": self.optical_threshold if self.photon_mode else None, "light_start": self.light_start, "light_end": self.light_end if self.light_end is not None else self.current_light, "started_at": self.run_started_at or datetime.now().isoformat(timespec="seconds"), @@ -498,6 +719,8 @@ def remember_last_run_settings(self) -> None: last_dpi=self.dpi_var.get().strip(), target_samples=self.target_samples(), auto_reset_on_test=bool(self.auto_reset_var.get()), + measurement_mode=self.measure_mode_var.get(), + confirm_btn2_reset=bool(self.confirm_btn2_var.get()), last_device_id=self.device_name_to_id.get(self.device_var.get().strip()), ) self.settings.save() @@ -568,6 +791,26 @@ def refresh_sessions(self) -> None: def _latencies(self) -> list[float]: return [s["latency_ms"] for s in self.current_samples] + def _record_sample(self, payload: dict, raw_optical: int | None = None) -> None: + """Store one finished measurement. Shared by both modes on purpose: + the archive, the metrics and the test-mode target must behave + identically whichever path produced the number.""" + sample = { + "latency_ms": payload["latency_ms"], + "timestamp": datetime.now().isoformat(timespec="milliseconds"), + } + if raw_optical is not None: + sample["raw_optical"] = raw_optical + self.current_samples.append(sample) + self.session_saved = False + self._update_metrics() + self.state_var.set("PHOTON" if self.photon_mode else "READY") + + if self.test_mode and self.test_sample_count() >= self.target_samples(): + self.test_complete = True + if self.overlay: + self.overlay.set_mode("complete") + def clear_samples(self) -> None: self.current_samples.clear() self.session_saved = False @@ -615,24 +858,43 @@ def _handle(self, event: protocol.Event) -> None: kind, payload = event.kind, event.payload if kind == "latency": - self.current_samples.append({ - "latency_ms": payload["latency_ms"], - "timestamp": datetime.now().isoformat(timespec="milliseconds"), - }) - self.session_saved = False - self._update_metrics() - self.state_var.set("READY") + # Probe-to-PC only. An optical result arrives as "optical" and is + # never mistaken for one of these. + self._record_sample(payload) self.log(tr("log.latency", value=f"{payload['latency_ms']:.3f}")) - - if self.test_mode: - if self.test_sample_count() >= self.target_samples(): - self.test_complete = True - if self.overlay: - self.overlay.set_mode("complete") - else: + if not self.test_mode: self.root.after( 150, lambda: self.send(protocol.CMD_LIGHT) if self.serial.connected else None) + elif kind == "optical": + self._record_sample(payload, raw_optical=payload["raw_optical"]) + self.log(tr("log.optical", value=f"{payload['latency_ms']:.3f}", + raw=payload["raw_optical"])) + + elif kind == "optical_cal": + self._apply_optical_cal(payload) + self.log(tr("log.optical_cal", dark=payload["dark"], + bright=payload["bright"], threshold=payload["threshold"])) + + elif kind == "optical_timeout": + self.state_var.set("OPT T/O") + self.log(tr("log.optical_timeout")) + + elif kind == "optical_error": + self.state_var.set("OPT ERR") + self.log(tr("log.optical_error", reason=payload)) + + elif kind == "optical_drop": + self.log(tr("log.optical_drop", value=fmt_ms(payload))) + + elif kind == "photon_on": + self.state_var.set("PHOTON") + self.log(tr("log.photon_on")) + + elif kind == "photon_off": + self.state_var.set("READY") + self.log(tr("log.photon_off")) + elif kind == "stats": self.cal_var.set(f"{payload['calib']} µs") @@ -699,13 +961,13 @@ def _handle(self, event: protocol.Event) -> None: self.log(tr("log.drop", value=fmt_ms(payload))) elif kind == "button": - # TEST PHASE: logged and counted only. No action is bound to the - # buttons until the hardware has been verified. index = payload["index"] if index in self.button_counts: self.button_counts[index] += 1 self.button_vars[index].set(str(self.button_counts[index])) self.log(payload["token"]) + if payload["action"] == "PRESS": + self._button_action(index) elif kind == "banner": self.firmware_var.set(payload) @@ -734,7 +996,12 @@ def _poll_light(self) -> None: and not self.serial.calibrating): self.send(protocol.CMD_LIGHT) if self.running: - self._timers["light"] = self.root.after(LIGHT_POLL_MS, self._poll_light) + # While the optical target is on screen the reading is an aiming + # instrument, and once a second is too slow to see the black and + # white phases at all. Safe to speed up: no measurement is running. + interval = (LIGHT_AIM_POLL_MS if self.cal_window is not None + else LIGHT_POLL_MS) + self._timers["light"] = self.root.after(interval, self._poll_light) # ============================================================== shutdown == def close(self) -> None: @@ -750,6 +1017,7 @@ def close(self) -> None: self._timers.clear() if self.test_mode: self.exit_test_mode() + self.cancel_optical_calibration() try: self.settings.set("window_geometry", self.root.winfo_geometry()) diff --git a/latency_tester/constants.py b/latency_tester/constants.py index ebea23e..743f04f 100644 --- a/latency_tester/constants.py +++ b/latency_tester/constants.py @@ -1,4 +1,16 @@ -"""Shared UI choice lists.""" +"""Shared UI choice lists and the measurement-mode identifiers.""" POLLING_RATES = ("125", "250", "500", "1000", "2000", "4000", "8000") CONNECTION_MODES = ("Wireless", "Wired", "Bluetooth", "Altro") + +#: Stored verbatim in ``runs.mode``. Never translated, never renamed: archived +#: runs are matched against these strings. +MODE_PROBE_PC = "probe_to_pc" +MODE_PHOTON = "probe_to_photon" +MEASUREMENT_MODES = (MODE_PROBE_PC, MODE_PHOTON) + +#: Label key per mode, for the interface. +MODE_LABELS = { + MODE_PROBE_PC: "mode.probe_to_pc", + MODE_PHOTON: "mode.probe_to_photon", +} diff --git a/latency_tester/database.py b/latency_tester/database.py index e1f78d4..19302ed 100644 --- a/latency_tester/database.py +++ b/latency_tester/database.py @@ -13,7 +13,9 @@ from pathlib import Path from typing import Any, Sequence -SCHEMA_VERSION = 2 +from .constants import MODE_PROBE_PC + +SCHEMA_VERSION = 3 # The v0 schema exactly as dashboard v2 created it. Applied with IF NOT EXISTS # so an existing archive is left untouched and a fresh one starts from the same @@ -76,6 +78,17 @@ [ "ALTER TABLE devices ADD COLUMN hardware_id TEXT NOT NULL DEFAULT ''", ], + # -> 3 : Probe-to-Photon. Every run is labelled with the mode that produced + # it, so the two can be archived side by side and never averaged + # together by accident. Existing rows are Probe-to-PC by definition: + # it was the only mode that existed when they were written. + [ + "ALTER TABLE runs ADD COLUMN mode TEXT NOT NULL DEFAULT 'probe_to_pc'", + "ALTER TABLE runs ADD COLUMN optical_dark INTEGER", + "ALTER TABLE runs ADD COLUMN optical_bright INTEGER", + "ALTER TABLE runs ADD COLUMN optical_threshold INTEGER", + "ALTER TABLE samples ADD COLUMN raw_optical INTEGER", + ], ] DEVICE_FIELDS = ("name", "manufacturer", "model", "notes", @@ -85,7 +98,8 @@ RUN_FIELDS = ("name", "polling_rate_hz", "connection_mode", "mouse_firmware", "notes", "target_samples", "calibration_us", "light_start", "light_end", "dpi", "debounce_setting", "teensy_firmware", - "is_demo") + "is_demo", "mode", "optical_dark", "optical_bright", + "optical_threshold") def _now() -> str: @@ -222,13 +236,15 @@ def rename_run(self, run_id: int, name: str) -> None: def replace_samples(self, run_id: int, samples: Sequence[dict]) -> None: self.conn.execute("DELETE FROM samples WHERE run_id=?", (int(run_id),)) self.conn.executemany( - "INSERT INTO samples(run_id, seq, latency_ms, timestamp) VALUES(?,?,?,?)", + "INSERT INTO samples(run_id, seq, latency_ms, timestamp, raw_optical) " + "VALUES(?,?,?,?,?)", [ ( int(run_id), i + 1, float(s["latency_ms"]), s.get("timestamp") or datetime.now().isoformat(timespec="milliseconds"), + _as_optional_int(s.get("raw_optical")), ) for i, s in enumerate(samples) ], @@ -286,6 +302,16 @@ def count_demo_runs(self) -> int: "SELECT COUNT(*) FROM runs WHERE is_demo=1").fetchone()[0]) +def _as_optional_int(value: Any) -> int | None: + """Raw ADC reading, or ``None`` for a Probe-to-PC sample that has none.""" + if value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + def _clean(fields: dict, keys: Sequence[str]) -> dict: out = {} for key in keys: @@ -296,15 +322,32 @@ def _clean(fields: dict, keys: Sequence[str]) -> dict: def _run_value(metadata: dict, key: str) -> Any: value = metadata.get(key) + if key == "mode": + # A run with no mode predates Probe-to-Photon, so it is Probe-to-PC. + return str(value).strip() if value else MODE_PROBE_PC if key in ("target_samples", "calibration_us", "is_demo"): return int(value or 0) - if key in ("polling_rate_hz", "light_start", "light_end", "dpi"): + if key in ("polling_rate_hz", "light_start", "light_end", "dpi", + "optical_dark", "optical_bright", "optical_threshold"): return int(value) if value not in (None, "") else None if value is None: return "" return value.strip() if isinstance(value, str) else value +def run_mode(run: sqlite3.Row | dict) -> str: + """The measurement mode of a stored run, defaulting to Probe-to-PC. + + Tolerates a row that predates the column: an archive opened read-only by an + older build, or a hand-made dict in a test. + """ + try: + value = run["mode"] + except (IndexError, KeyError, TypeError): + return MODE_PROBE_PC + return str(value).strip() or MODE_PROBE_PC + + def run_as_metadata(run: sqlite3.Row) -> dict: """Row -> metadata dict, ready for :meth:`LatencyDB.update_run` or to seed a duplicated run.""" diff --git a/latency_tester/demo.py b/latency_tester/demo.py index f2f4b2d..bd59d65 100644 --- a/latency_tester/demo.py +++ b/latency_tester/demo.py @@ -5,7 +5,7 @@ It deliberately does **not** emit ``TRIG``: the click handshake needs a real probe and a real OS click, so the demo produces finished ``LAT:`` lines -instead. The measurement path is therefore never exercised -- and never +instead -- or ``OPT:`` lines once Probe-to-Photon is selected. The measurement path is therefore never exercised -- and never faked -- by demo mode. Runs saved while the demo device is attached are stored with ``is_demo = 1``. """ @@ -32,6 +32,13 @@ _INTERVAL_S = 0.45 # simulated time between presses _LIGHT_BASE = 620 +# Probe-to-Photon is slower and wider than Probe-to-PC: it carries the +# compositor, the panel and the LDR's own response on top of the input stack. +_OPT_BASE_MS = 24.0 +_OPT_SIGMA_MS = 3.2 +_OPT_DARK = 96 +_OPT_BRIGHT = 872 + class DemoDevice: """A :class:`~latency_tester.serial_service.Transport` backed by a thread.""" @@ -44,6 +51,14 @@ def __init__(self, seed: int | None = None): self._test_mode = False self._calibrating = False + self._optical = False + self._opt_dark = _OPT_DARK + self._opt_bright = _OPT_BRIGHT + self._opt_count = 0 + self._opt_last = 0.0 + self._opt_min = 0.0 + self._opt_max = 0.0 + self._opt_sum = 0.0 self._sample_count = 0 self._last = 0.0 self._min = 0.0 @@ -97,6 +112,7 @@ def _command(self, char: str) -> None: with self._lock: self._sample_count = 0 self._last = self._min = self._max = self._sum = 0.0 + self._reset_optical() self._emit(protocol.TOK_RESET) elif char == protocol.CMD_LIGHT: self._emit(f"{protocol.PREFIX_LIGHT}{self._light()}") @@ -112,6 +128,48 @@ def _command(self, char: str) -> None: elif char == protocol.CMD_CALIBRATE: self._calibrating = True self._emit("CALIBRATING...") + elif char == protocol.CMD_PHOTON_ON: + self._optical = True + self._reset_optical() + self._emit(protocol.TOK_PHOTON_ON) + self._emit_optical_cal() + elif char == protocol.CMD_PHOTON_OFF: + self._optical = False + self._emit(protocol.TOK_PHOTON_OFF) + elif char == protocol.CMD_CAL_DARK: + self._opt_dark = _OPT_DARK + self._random.randint(-6, 6) + self._emit_optical_cal() + elif char == protocol.CMD_CAL_BRIGHT: + self._opt_bright = _OPT_BRIGHT + self._random.randint(-10, 10) + self._emit_optical_cal() + elif char == protocol.CMD_CAL_REPORT: + self._emit_optical_cal() + + def _reset_optical(self) -> None: + self._opt_count = 0 + self._opt_last = self._opt_min = self._opt_max = self._opt_sum = 0.0 + + def _emit_optical_cal(self) -> None: + threshold = (self._opt_dark + self._opt_bright) // 2 + rising = 1 if self._opt_bright > self._opt_dark else 0 + self._emit(f"OPT_CAL:dark:{self._opt_dark},bright:{self._opt_bright}," + f"threshold:{threshold},rising:{rising}") + + def _emit_optical_sample(self) -> None: + latency = max(4.0, self._random.gauss(_OPT_BASE_MS, _OPT_SIGMA_MS)) + with self._lock: + self._opt_last = latency + if self._opt_count == 0: + self._opt_min = self._opt_max = latency + self._opt_min = min(self._opt_min, latency) + self._opt_max = max(self._opt_max, latency) + self._opt_sum += latency + self._opt_count += 1 + count, low, high = self._opt_count, self._opt_min, self._opt_max + avg = self._opt_sum / count + raw = self._opt_bright + self._random.randint(-14, 14) + self._emit(f"OPT:{latency:.3f},raw:{raw},min:{low:.3f},max:{high:.3f}," + f"avg:{avg:.3f},n:{count}") def _light(self) -> int: return max(0, min(1023, _LIGHT_BASE + self._random.randint(-12, 12))) @@ -138,6 +196,13 @@ def _run(self) -> None: while not self._closed.wait(_INTERVAL_S): if not self._test_mode: continue + if self._optical: + self._emit_optical_sample() + self._emit(protocol.TOK_REARM) + time.sleep(0.12) + if self._test_mode: + self._emit(protocol.TOK_ARMED) + continue latency = self._next_latency() with self._lock: self._last = latency diff --git a/latency_tester/locales/de.json b/latency_tester/locales/de.json index e4bbccd..16b1114 100644 --- a/latency_tester/locales/de.json +++ b/latency_tester/locales/de.json @@ -20,6 +20,7 @@ "compare.hint_empty": "Die ausgewählten Sitzungen enthalten keine Messwerte.", "compare.hint_one": "Eine Sitzung ausgewählt.", "compare.hint_select": "Wähle mindestens eine Sitzung zum Vergleichen aus.", + "compare.mixed_modes": "Hier stehen Läufe aus verschiedenen Messmodi nebeneinander. Sie messen nicht dasselbe Intervall, die Deltas sind daher nicht direkt vergleichbar.", "compare.no_matplotlib": "Matplotlib ist nicht installiert.\nAusführen: pip install matplotlib", "compare.plot": "Zeichnen", "compare.select": "Sitzungen auswählen (Strg/Umschalt für mehrere Zeilen)", @@ -69,6 +70,7 @@ "devices.switch": "Schaltertyp", "dlg.all_files": "Alle Dateien", "dlg.already_saved": "Diese Version des Durchlaufs wurde bereits gespeichert.", + "dlg.btn2_confirm": "BTN2: aktuellen Lauf zurücksetzen?", "dlg.close": "Schließen", "dlg.close_confirm": "Der aktuelle Durchlauf enthält ungespeicherte Messwerte.\nTrotzdem schließen?", "dlg.csv": "CSV", @@ -132,6 +134,7 @@ "live.stdev": "STD.ABW.", "live.target": "Ziel", "log.abort": "Druck ignoriert: kein neuer Klick vom Betriebssystem", + "log.button_refused": "Tastendruck abgelehnt: derzeit nicht zulässig", "log.calib_failed": "Kalibrierung fehlgeschlagen, Standard-Offset angewendet", "log.calibrated": "Kalibrierung OK: {calib} µs ({samples} Messwerte, mittlere Umlaufzeit {avg} µs)", "log.calibrating": "Kalibrierung wird gestartet…", @@ -142,16 +145,45 @@ "log.disconnected": "Getrennt", "log.drop": "Messwert außerhalb des Firmware-Bereichs: {value} ms", "log.latency": "LAT {value} ms", + "log.mode": "Messmodus: {mode}", + "log.optical": "OPT {value} ms (raw {raw})", + "log.optical_cal": "Optische Kalibrierung: dunkel {dark}, hell {bright}, Schwelle {threshold}", + "log.optical_drop": "Optische Probe außerhalb des Firmware-Bereichs: {value} ms", + "log.optical_error": "Optischer Modus abgelehnt: {reason}", + "log.optical_timeout": "Kein Lichtwechsel erkannt: Timeout", + "log.photon_off": "Probe-to-Photon aus", + "log.photon_on": "Probe-to-Photon aktiv", "log.reset": "Statistik gelöscht", "log.saved": "Sitzung im Archiv gespeichert (ID {id})", "log.skip": "Messwert verworfen: Klick während der OLED-Aktualisierung", "log.testmode_off": "Testmodus beendet: OLED wieder aktiv", "log.testmode_on": "Testmodus: OLED eingefroren", "log.trig_unmatched": "TRIG ohne passenden neuen Klick vom Betriebssystem", + "mode.probe_to_pc": "Probe-to-PC", + "mode.probe_to_photon": "Probe-to-Photon", + "mode.short_photon": "Photon", + "mode.title": "Messmodus", + "photon.bright": "Hell", + "photon.calibrate": "Auf dem Vollbild-Ziel kalibrieren", + "photon.calibrating": "Sensor wird auf dieses Ziel kalibriert — den KY-018 nicht bewegen.", + "photon.calibrating_done": "KALIBRIERUNG OK", + "photon.calibrating_failed": "KALIBRIERUNG FEHLGESCHLAGEN", + "photon.calibrating_state": "KALIBRIERUNG", + "photon.dark": "Dunkel", + "photon.ldr_warning": "Der KY-018 ist ein Fotowiderstand: seine eigene Reaktionszeit liegt im Millisekundenbereich und steckt in jeder Zahl unten. Gut, um einen Aufbau mit sich selbst zu vergleichen, nicht als absoluter Click-to-Photon-Benchmark.", + "photon.retry_hint": "Den Sensor bewegen und dabei den LICHT-Wert unten beobachten, dann Enter für einen neuen Versuch, oder Esc zum Abbrechen.", + "photon.status_ready": "Kalibrierung OK, der Test kann starten.", + "photon.status_separation": "Dunkel {dark} und hell {bright} liegen nur {gap} Zähler auseinander; mindestens {minimum} sind nötig. Den KY-018 vom Raumlicht abschatten, die Bildschirmhelligkeit erhöhen, oder den Sensor näher an den Bildschirm bringen.", + "photon.status_uncalibrated": "Nicht kalibriert: zuerst Schwarz und Weiß abtasten.", + "photon.step_aim": "Den KY-018 gegen die Bildschirmmitte befestigen — Gummibänder oder Klebeband — zum Bildschirm gerichtet, zwei bis drei Zentimeter Abstand. Dann kalibrieren: in der Mitte erscheint ein großes Ziel, auf dem beide Basiswerte genommen werden. Den LICHT-Wert auf diesem Bild beobachten: ändert er sich zwischen Schwarz und Weiß kaum, sitzt der Sensor nicht auf dem Ziel.", + "photon.target": "ZIEL", + "photon.threshold": "Schwelle", + "photon.title": "Probe-to-Photon", "sessions.col_cal": "Kal. µs", "sessions.col_date": "Datum", "sessions.col_device": "Gerät", "sessions.col_dpi": "DPI", + "sessions.col_measure": "Modus", "sessions.col_mode": "Modus", "sessions.col_n": "N", "sessions.col_poll": "Hz", @@ -178,6 +210,7 @@ "settings.auto_connect": "Beim Start automatisch verbinden", "settings.backup": "Archiv sichern", "settings.backup_done": "Sicherung geschrieben nach {path}", + "settings.confirm_btn2": "Vor dem Zurücksetzen per BTN2 nachfragen", "settings.demo": "Demo-Modus", "settings.demo_hint": "Das Demo-Gerät erzeugt simulierte Messwerte, sodass Oberfläche, Diagramme und Archiv ohne Hardware genutzt werden können. Im Demo-Modus gespeicherte Durchläufe werden gekennzeichnet und niemals mit echten Messungen vermischt.", "settings.demo_start": "Demo-Gerät starten", @@ -207,15 +240,20 @@ "test.wait_state": "WARTEN", "tip.auto_reset": "Setzt die Firmware-Zähler beim Start des Testmodus zurück.", "tip.baseline": "Der Durchlauf, gegen den jede Abweichung gemessen wird.", - "tip.buttons": "Testphase Firmware v1.4: BTN1/BTN2-Betätigungen werden nur gezählt und protokolliert. Es ist noch keine Aktion damit verknüpft.", + "tip.buttons": "BTN1/BTN2, Firmware v1.4: BTN1 betritt oder verlässt den Testmodus, BTN2 setzt den Live-Lauf außerhalb des Testmodus zurück. Drücke werden hier ebenfalls gezählt.", "tip.calibrate": "Misst die serielle Umlaufzeit und zieht sie von jedem Messwert ab. Einmal pro Sitzung ausführen.", + "tip.calibrate_optical": "Öffnet ein zentriertes Vollbild-Ziel und tastet darauf Schwarz und dann Weiß ab. Den KY-018 vorher in der Bildschirmmitte befestigen und danach nicht mehr bewegen.", + "tip.confirm_btn2": "Standardmäßig an: BTN2 ist eine echte Taste, und ein Stoß gegen den Tisch soll keinen Lauf kosten.", "tip.demo": "Simulierte Daten. Werden nie mit echten Durchläufen vermischt.", "tip.detect": "Liest die USB-Produkt- und Herstellerangaben der angeschlossenen Maus.", "tip.dpi": "DPI lässt sich nicht aus der Maus auslesen: keine Windows-API stellt den Wert bereit. Trage ihn aus der Software deiner Maus von Hand ein.", "tip.light": "KY-018-Telemetrie, wird mit dem Durchlauf gespeichert. Sie ist nicht Teil der Messung.", "tip.measure_rate": "Zählt zwei Sekunden lang die tatsächlichen Meldungen der Maus. Bewege sie dabei ununterbrochen. Gemessen wird, was die Maus wirklich erreicht — das ist nicht immer der eingestellte Wert.", "tip.polling": "Wird nur im Archiv vermerkt; es ändert nicht, wie der Teensy misst.", + "tip.probe_to_pc": "Die verifizierte Messung: vom Sondenkontakt bis der Klick Windows erreicht.", + "tip.probe_to_photon": "Vom Sondenkontakt bis der Bildschirm tatsächlich weiß wird. Schließt das Display ein; nutzt den KY-018.", "tip.target": "Der Testmodus wechselt zu Blau, sobald so viele Messwerte erfasst wurden.", + "tip.threshold": "Mittelwert zwischen beiden Basiswerten. Die Firmware stoppt die Uhr beim ersten Überschreiten.", "update.available": "Version {version} ist verfügbar ({size} MB).", "update.check": "Nach Aktualisierungen suchen", "update.checking": "Suche läuft…", diff --git a/latency_tester/locales/en.json b/latency_tester/locales/en.json index 8a8b635..08baf63 100644 --- a/latency_tester/locales/en.json +++ b/latency_tester/locales/en.json @@ -20,6 +20,7 @@ "compare.hint_empty": "The selected sessions contain no samples.", "compare.hint_one": "One session selected.", "compare.hint_select": "Select at least one session to compare.", + "compare.mixed_modes": "Runs from different measurement modes are shown together. They do not measure the same interval, so the deltas are not like-for-like.", "compare.no_matplotlib": "Matplotlib is not installed.\nRun: pip install matplotlib", "compare.plot": "Plot", "compare.select": "Select sessions (Ctrl/Shift for multiple rows)", @@ -69,6 +70,7 @@ "devices.switch": "Switch type", "dlg.all_files": "All files", "dlg.already_saved": "This version of the run has already been saved.", + "dlg.btn2_confirm": "BTN2: clear the current run?", "dlg.close": "Close", "dlg.close_confirm": "The current run has unsaved samples.\nClose anyway?", "dlg.csv": "CSV", @@ -132,6 +134,7 @@ "live.stdev": "STD DEV", "live.target": "Target", "log.abort": "Press ignored: no new OS click", + "log.button_refused": "Button press refused: not allowed right now", "log.calib_failed": "Calibration failed, default offset applied", "log.calibrated": "Calibration OK: {calib} µs ({samples} samples, mean RT {avg} µs)", "log.calibrating": "Starting calibration…", @@ -142,16 +145,45 @@ "log.disconnected": "Disconnected", "log.drop": "Sample out of firmware range: {value} ms", "log.latency": "LAT {value} ms", + "log.mode": "Measurement mode: {mode}", + "log.optical": "OPT {value} ms (raw {raw})", + "log.optical_cal": "Optical calibration: dark {dark}, bright {bright}, threshold {threshold}", + "log.optical_drop": "Optical sample out of firmware range: {value} ms", + "log.optical_error": "Optical mode refused: {reason}", + "log.optical_timeout": "No light transition seen: timeout", + "log.photon_off": "Probe-to-Photon off", + "log.photon_on": "Probe-to-Photon active", "log.reset": "Statistics cleared", "log.saved": "Session stored in the archive (ID {id})", "log.skip": "Sample discarded: click during OLED refresh", "log.testmode_off": "Test mode ended: OLED resumed", "log.testmode_on": "Test mode: OLED frozen", "log.trig_unmatched": "TRIG without a matching new OS click", + "mode.probe_to_pc": "Probe-to-PC", + "mode.probe_to_photon": "Probe-to-Photon", + "mode.short_photon": "photon", + "mode.title": "Measurement mode", + "photon.bright": "Bright", + "photon.calibrate": "Calibrate on the full-screen target", + "photon.calibrating": "Calibrating the sensor on this target — do not move the KY-018.", + "photon.calibrating_done": "CALIBRATION OK", + "photon.calibrating_failed": "CALIBRATION FAILED", + "photon.calibrating_state": "CALIBRATING", + "photon.dark": "Dark", + "photon.ldr_warning": "The KY-018 is a photoresistor: its own response is in the milliseconds and is part of every number below. Useful to compare a setup against itself, not as an absolute click-to-photon benchmark.", + "photon.retry_hint": "Move the sensor while watching the LIGHT number below, then press Enter to try again, or Esc to give up.", + "photon.status_ready": "Calibration OK, the test can start.", + "photon.status_separation": "Dark {dark} and bright {bright} are only {gap} counts apart; at least {minimum} are needed. Shade the KY-018 from the room light, raise the screen brightness, or move the sensor closer to the screen.", + "photon.status_uncalibrated": "Not calibrated: sample black and white first.", + "photon.step_aim": "Fix the KY-018 against the middle of the screen — elastic bands or tape — facing it, a couple of centimetres away. Then calibrate: a large target appears in the centre and both baselines are taken on it. Watch the LIGHT number on that screen: if it barely changes between black and white, the sensor is not on the target.", + "photon.target": "TARGET", + "photon.threshold": "Threshold", + "photon.title": "Probe-to-Photon", "sessions.col_cal": "Cal µs", "sessions.col_date": "Date", "sessions.col_device": "Device", "sessions.col_dpi": "DPI", + "sessions.col_measure": "Mode", "sessions.col_mode": "Mode", "sessions.col_n": "N", "sessions.col_poll": "Hz", @@ -178,6 +210,7 @@ "settings.auto_connect": "Connect automatically on start", "settings.backup": "Back up the archive", "settings.backup_done": "Backup written to {path}", + "settings.confirm_btn2": "Ask before BTN2 clears the live run", "settings.demo": "Demo mode", "settings.demo_hint": "The demo device generates simulated samples so the interface, charts and archive can be used without any hardware. Runs saved in demo mode are flagged and are never mixed with real measurements.", "settings.demo_start": "Start demo device", @@ -207,15 +240,20 @@ "test.wait_state": "WAIT", "tip.auto_reset": "Clears the firmware counters when entering test mode.", "tip.baseline": "The run every delta is measured against.", - "tip.buttons": "Firmware v1.4 test phase: BTN1/BTN2 presses are counted and logged only. No action is bound to them yet.", + "tip.buttons": "BTN1/BTN2, firmware v1.4: BTN1 enters or leaves test mode, BTN2 clears the live run outside test mode. Presses are counted here as well.", "tip.calibrate": "Measures the serial round-trip and subtracts it from every sample. Run it once per session.", + "tip.calibrate_optical": "Opens a full-screen centred target and samples black then white on it. Tape the KY-018 to the middle of the screen first and do not move it afterwards.", + "tip.confirm_btn2": "On by default: BTN2 is a physical button and a knock on the desk should not cost you a run.", "tip.demo": "Simulated data. Never mixed with real runs.", "tip.detect": "Reads the USB product and manufacturer strings from the connected mouse.", "tip.dpi": "DPI cannot be read from a mouse: no Windows API exposes it. Enter it by hand from your mouse's own software.", "tip.light": "KY-018 telemetry, stored with the run. It is not part of the measurement.", "tip.measure_rate": "Counts the mouse's actual report rate for two seconds. Move the mouse continuously while it runs. This measures what the mouse really achieves, which is not always the rate it is configured for.", "tip.polling": "Reported in the archive only; it does not change how the Teensy measures.", + "tip.probe_to_pc": "The verified measurement: probe contact to the click reaching Windows.", + "tip.probe_to_photon": "Probe contact to the screen actually turning white. Includes the display; uses the KY-018.", "tip.target": "Test mode switches to blue when this many samples are collected.", + "tip.threshold": "Midpoint between the two baselines. The firmware stops the clock the first time the sensor crosses it.", "update.available": "Version {version} is available ({size} MB).", "update.check": "Check for updates", "update.checking": "Checking…", diff --git a/latency_tester/locales/es.json b/latency_tester/locales/es.json index e29854e..5a47ff7 100644 --- a/latency_tester/locales/es.json +++ b/latency_tester/locales/es.json @@ -20,6 +20,7 @@ "compare.hint_empty": "Las sesiones seleccionadas no contienen muestras.", "compare.hint_one": "Una sesión seleccionada.", "compare.hint_select": "Selecciona al menos una sesión para comparar.", + "compare.mixed_modes": "Se muestran juntas ejecuciones de modos distintos. No miden el mismo intervalo, así que las diferencias no son comparables.", "compare.no_matplotlib": "Matplotlib no está instalado.\nEjecuta: pip install matplotlib", "compare.plot": "Trazar", "compare.select": "Selecciona sesiones (Ctrl/Mayús para varias filas)", @@ -69,6 +70,7 @@ "devices.switch": "Tipo de switch", "dlg.all_files": "Todos los archivos", "dlg.already_saved": "Esta versión de la serie ya se ha guardado.", + "dlg.btn2_confirm": "BTN2: ¿borrar la ejecución actual?", "dlg.close": "Cerrar", "dlg.close_confirm": "La serie actual tiene muestras sin guardar.\n¿Cerrar de todos modos?", "dlg.csv": "CSV", @@ -132,6 +134,7 @@ "live.stdev": "DESV. TÍP.", "live.target": "Objetivo", "log.abort": "Pulsación ignorada: no hay ningún clic nuevo del sistema", + "log.button_refused": "Pulsación rechazada: no permitida en este momento", "log.calib_failed": "Fallo en la calibración, se ha aplicado el desfase predeterminado", "log.calibrated": "Calibración correcta: {calib} µs ({samples} muestras, ida y vuelta media {avg} µs)", "log.calibrating": "Iniciando la calibración…", @@ -142,16 +145,45 @@ "log.disconnected": "Desconectado", "log.drop": "Muestra fuera del rango del firmware: {value} ms", "log.latency": "LAT {value} ms", + "log.mode": "Modo de medición: {mode}", + "log.optical": "OPT {value} ms (raw {raw})", + "log.optical_cal": "Calibración óptica: oscuro {dark}, claro {bright}, umbral {threshold}", + "log.optical_drop": "Muestra óptica fuera del rango del firmware: {value} ms", + "log.optical_error": "Modo óptico rechazado: {reason}", + "log.optical_timeout": "No se detectó ninguna transición de luz: tiempo agotado", + "log.photon_off": "Probe-to-Photon desactivado", + "log.photon_on": "Probe-to-Photon activo", "log.reset": "Estadísticas borradas", "log.saved": "Sesión guardada en el archivo (ID {id})", "log.skip": "Muestra descartada: clic durante el refresco de la OLED", "log.testmode_off": "Modo de prueba finalizado: OLED reanudada", "log.testmode_on": "Modo de prueba: OLED congelada", "log.trig_unmatched": "TRIG sin un clic nuevo del sistema que le corresponda", + "mode.probe_to_pc": "Probe-to-PC", + "mode.probe_to_photon": "Probe-to-Photon", + "mode.short_photon": "fotón", + "mode.title": "Modo de medición", + "photon.bright": "Claro", + "photon.calibrate": "Calibrar en el objetivo a pantalla completa", + "photon.calibrating": "Calibrando el sensor sobre este objetivo: no muevas el KY-018.", + "photon.calibrating_done": "CALIBRACIÓN CORRECTA", + "photon.calibrating_failed": "CALIBRACIÓN FALLIDA", + "photon.calibrating_state": "CALIBRANDO", + "photon.dark": "Oscuro", + "photon.ldr_warning": "El KY-018 es una fotorresistencia: su propia respuesta es de milisegundos y forma parte de cada número de abajo. Sirve para comparar un montaje consigo mismo, no como referencia absoluta de click-to-photon.", + "photon.retry_hint": "Mueve el sensor mirando el número LUZ de abajo, luego pulsa Entrar para reintentar, o Esc para salir.", + "photon.status_ready": "Calibración correcta, la prueba puede empezar.", + "photon.status_separation": "Oscuro {dark} y claro {bright} solo se separan {gap} cuentas; hacen falta al menos {minimum}. Protege el KY-018 de la luz de la habitación, sube el brillo de la pantalla, o acerca el sensor a la pantalla.", + "photon.status_uncalibrated": "Sin calibrar: muestrea primero el negro y luego el blanco.", + "photon.step_aim": "Fija el KY-018 contra el centro de la pantalla — gomas elásticas o cinta — mirando hacia ella, a dos o tres centímetros. Luego calibra: en el centro aparece un objetivo grande y ambas bases se toman sobre él. Observa el número LUZ en esa pantalla: si apenas cambia entre negro y blanco, el sensor no está sobre el objetivo.", + "photon.target": "OBJETIVO", + "photon.threshold": "Umbral", + "photon.title": "Probe-to-Photon", "sessions.col_cal": "Cal µs", "sessions.col_date": "Fecha", "sessions.col_device": "Dispositivo", "sessions.col_dpi": "PPP", + "sessions.col_measure": "Modo", "sessions.col_mode": "Modo", "sessions.col_n": "N", "sessions.col_poll": "Hz", @@ -178,6 +210,7 @@ "settings.auto_connect": "Conectar automáticamente al iniciar", "settings.backup": "Hacer copia del archivo", "settings.backup_done": "Copia de seguridad escrita en {path}", + "settings.confirm_btn2": "Confirmar antes de que BTN2 borre la ejecución", "settings.demo": "Modo demo", "settings.demo_hint": "El dispositivo demo genera muestras simuladas para poder usar la interfaz, los gráficos y el archivo sin ningún hardware. Las series guardadas en modo demo quedan marcadas y nunca se mezclan con mediciones reales.", "settings.demo_start": "Iniciar el dispositivo demo", @@ -207,15 +240,20 @@ "test.wait_state": "ESPERA", "tip.auto_reset": "Pone a cero los contadores del firmware al entrar en el modo de prueba.", "tip.baseline": "La serie con la que se compara cada diferencia.", - "tip.buttons": "Fase de prueba del firmware v1.4: las pulsaciones de BTN1/BTN2 solo se cuentan y se registran. Todavía no tienen ninguna acción asociada.", + "tip.buttons": "BTN1/BTN2, firmware v1.4: BTN1 entra o sale del modo de prueba, BTN2 reinicia la ejecución en vivo fuera del modo de prueba. Las pulsaciones también se cuentan aquí.", "tip.calibrate": "Mide la ida y vuelta del puerto serie y la resta de cada muestra. Ejecútala una vez por sesión.", + "tip.calibrate_optical": "Abre un objetivo centrado a pantalla completa y muestrea negro y luego blanco sobre él. Fija primero el KY-018 en el centro de la pantalla y luego no lo muevas.", + "tip.confirm_btn2": "Activado por defecto: BTN2 es un botón físico y un golpe en la mesa no debería costarte una ejecución.", "tip.demo": "Datos simulados. Nunca se mezclan con series reales.", "tip.detect": "Lee las cadenas USB de producto y fabricante del ratón conectado.", "tip.dpi": "Los PPP no se pueden leer del ratón: ninguna API de Windows los expone. Introdúcelos a mano desde el software de tu ratón.", "tip.light": "Telemetría del KY-018, guardada con la serie. No forma parte de la medición.", "tip.measure_rate": "Cuenta durante dos segundos los informes reales del ratón. Muévelo sin parar. Mide lo que el ratón consigue de verdad, que no siempre coincide con el valor configurado.", "tip.polling": "Solo se registra en el archivo; no cambia la forma en que mide la Teensy.", + "tip.probe_to_pc": "La medición verificada: del contacto de la sonda al clic que llega a Windows.", + "tip.probe_to_photon": "Del contacto de la sonda a que la pantalla se vuelva blanca de verdad. Incluye el monitor; usa el KY-018.", "tip.target": "El modo de prueba pasa a azul cuando se alcanza este número de muestras.", + "tip.threshold": "Punto medio entre las dos bases. El firmware para el cronómetro la primera vez que el sensor lo cruza.", "update.available": "La versión {version} está disponible ({size} MB).", "update.check": "Buscar actualizaciones", "update.checking": "Comprobando…", diff --git a/latency_tester/locales/fr.json b/latency_tester/locales/fr.json index 45188da..6fb83f1 100644 --- a/latency_tester/locales/fr.json +++ b/latency_tester/locales/fr.json @@ -20,6 +20,7 @@ "compare.hint_empty": "Les sessions sélectionnées ne contiennent aucun échantillon.", "compare.hint_one": "Une seule session sélectionnée.", "compare.hint_select": "Sélectionne au moins une session à comparer.", + "compare.mixed_modes": "Des sessions de modes différents sont affichées ensemble. Elles ne mesurent pas le même intervalle : les écarts ne sont pas comparables.", "compare.no_matplotlib": "Matplotlib n'est pas installé.\nExécute : pip install matplotlib", "compare.plot": "Tracer", "compare.select": "Sélectionne des sessions (Ctrl/Maj pour plusieurs lignes)", @@ -69,6 +70,7 @@ "devices.switch": "Type de switch", "dlg.all_files": "Tous les fichiers", "dlg.already_saved": "Cette version de la série a déjà été enregistrée.", + "dlg.btn2_confirm": "BTN2 : effacer la session en cours ?", "dlg.close": "Fermer", "dlg.close_confirm": "La série en cours contient des échantillons non enregistrés.\nFermer quand même ?", "dlg.csv": "CSV", @@ -132,6 +134,7 @@ "live.stdev": "ÉCART-TYPE", "live.target": "Objectif", "log.abort": "Appui ignoré : aucun nouveau clic système", + "log.button_refused": "Appui refusé : non autorisé pour le moment", "log.calib_failed": "Échec de la calibration, décalage par défaut appliqué", "log.calibrated": "Calibration OK : {calib} µs ({samples} échantillons, aller-retour moyen {avg} µs)", "log.calibrating": "Démarrage de la calibration…", @@ -142,16 +145,45 @@ "log.disconnected": "Déconnecté", "log.drop": "Échantillon hors plage du firmware : {value} ms", "log.latency": "LAT {value} ms", + "log.mode": "Mode de mesure : {mode}", + "log.optical": "OPT {value} ms (raw {raw})", + "log.optical_cal": "Calibration optique : sombre {dark}, clair {bright}, seuil {threshold}", + "log.optical_drop": "Échantillon optique hors plage du firmware : {value} ms", + "log.optical_error": "Mode optique refusé : {reason}", + "log.optical_timeout": "Aucune transition lumineuse détectée : délai dépassé", + "log.photon_off": "Probe-to-Photon désactivé", + "log.photon_on": "Probe-to-Photon actif", "log.reset": "Statistiques effacées", "log.saved": "Session enregistrée dans l'archive (ID {id})", "log.skip": "Échantillon écarté : clic pendant le rafraîchissement de l'OLED", "log.testmode_off": "Fin du mode test : OLED réactivé", "log.testmode_on": "Mode test : OLED figé", "log.trig_unmatched": "TRIG sans nouveau clic système correspondant", + "mode.probe_to_pc": "Probe-to-PC", + "mode.probe_to_photon": "Probe-to-Photon", + "mode.short_photon": "photon", + "mode.title": "Mode de mesure", + "photon.bright": "Clair", + "photon.calibrate": "Calibrer sur la cible plein écran", + "photon.calibrating": "Calibration du capteur sur cette cible — ne bougez pas le KY-018.", + "photon.calibrating_done": "CALIBRATION OK", + "photon.calibrating_failed": "CALIBRATION ÉCHOUÉE", + "photon.calibrating_state": "CALIBRATION", + "photon.dark": "Sombre", + "photon.ldr_warning": "Le KY-018 est une photorésistance : son propre temps de réponse est de l'ordre de la milliseconde et entre dans chaque chiffre ci-dessous. Utile pour comparer un montage à lui-même, pas comme référence absolue de click-to-photon.", + "photon.retry_hint": "Déplacez le capteur en regardant le nombre LUMIÈRE ci-dessous, puis Entrée pour réessayer, ou Échap pour abandonner.", + "photon.status_ready": "Calibration correcte, le test peut démarrer.", + "photon.status_separation": "Sombre {dark} et clair {bright} ne sont séparés que de {gap} points ; il en faut au moins {minimum}. Protégez le KY-018 de la lumière de la pièce, montez la luminosité de l'écran, ou rapprochez le capteur de l'écran.", + "photon.status_uncalibrated": "Non calibré : échantillonnez d'abord le noir puis le blanc.", + "photon.step_aim": "Fixez le KY-018 contre le milieu de l'écran — élastiques ou ruban — face à lui, à deux ou trois centimètres. Puis calibrez : une grande cible apparaît au centre et les deux bases y sont prises. Regardez le nombre LUMIÈRE sur cet écran : s'il change à peine entre le noir et le blanc, le capteur n'est pas sur la cible.", + "photon.target": "CIBLE", + "photon.threshold": "Seuil", + "photon.title": "Probe-to-Photon", "sessions.col_cal": "Cal µs", "sessions.col_date": "Date", "sessions.col_device": "Appareil", "sessions.col_dpi": "PPP", + "sessions.col_measure": "Mode", "sessions.col_mode": "Mode", "sessions.col_n": "N", "sessions.col_poll": "Hz", @@ -178,6 +210,7 @@ "settings.auto_connect": "Se connecter automatiquement au démarrage", "settings.backup": "Sauvegarder l'archive", "settings.backup_done": "Sauvegarde écrite dans {path}", + "settings.confirm_btn2": "Demander avant que BTN2 efface la session", "settings.demo": "Mode démo", "settings.demo_hint": "L'appareil démo génère des échantillons simulés afin d'utiliser l'interface, les graphiques et l'archive sans aucun matériel. Les séries enregistrées en mode démo sont signalées et ne sont jamais mélangées aux vraies mesures.", "settings.demo_start": "Démarrer l'appareil démo", @@ -207,15 +240,20 @@ "test.wait_state": "ATTENDS", "tip.auto_reset": "Remet à zéro les compteurs du firmware à l'entrée en mode test.", "tip.baseline": "La série par rapport à laquelle chaque écart est calculé.", - "tip.buttons": "Phase de test du firmware v1.4 : les appuis sur BTN1/BTN2 sont seulement comptés et journalisés. Aucune action ne leur est encore associée.", + "tip.buttons": "BTN1/BTN2, firmware v1.4 : BTN1 entre ou sort du mode test, BTN2 réinitialise la session en direct hors du mode test. Les appuis sont aussi comptés ici.", "tip.calibrate": "Mesure l'aller-retour série et le soustrait de chaque échantillon. À lancer une fois par session.", + "tip.calibrate_optical": "Ouvre une cible centrée en plein écran et y échantillonne le noir puis le blanc. Fixez d'abord le KY-018 au centre de l'écran, puis ne le bougez plus.", + "tip.confirm_btn2": "Activé par défaut : BTN2 est un bouton physique, et un coup sur le bureau ne doit pas coûter une session.", "tip.demo": "Données simulées. Jamais mélangées aux séries réelles.", "tip.detect": "Lit les chaînes USB de produit et de fabricant de la souris connectée.", "tip.dpi": "Les PPP ne peuvent pas être lus depuis la souris : aucune API Windows ne les expose. Saisis-les à la main depuis le logiciel de ta souris.", "tip.light": "Télémétrie KY-018, enregistrée avec la série. Elle ne fait pas partie de la mesure.", "tip.measure_rate": "Compte pendant deux secondes les rapports réels de la souris. Bouge-la sans t'arrêter. Cela mesure ce que la souris atteint réellement, qui n'est pas toujours la valeur configurée.", "tip.polling": "Consigné uniquement dans l'archive ; cela ne change pas la façon dont la Teensy mesure.", + "tip.probe_to_pc": "La mesure vérifiée : du contact de la sonde au clic qui parvient à Windows.", + "tip.probe_to_photon": "Du contact de la sonde à l'écran qui devient réellement blanc. Inclut l'affichage ; utilise le KY-018.", "tip.target": "Le mode test passe au bleu une fois ce nombre d'échantillons atteint.", + "tip.threshold": "Milieu entre les deux bases. Le firmware arrête le chronomètre au premier franchissement.", "update.available": "La version {version} est disponible ({size} Mo).", "update.check": "Rechercher des mises à jour", "update.checking": "Vérification en cours…", diff --git a/latency_tester/locales/it.json b/latency_tester/locales/it.json index cba33e6..2a7a773 100644 --- a/latency_tester/locales/it.json +++ b/latency_tester/locales/it.json @@ -20,6 +20,7 @@ "compare.hint_empty": "Le sessioni selezionate non contengono campioni.", "compare.hint_one": "Una sola sessione selezionata.", "compare.hint_select": "Seleziona almeno una sessione da confrontare.", + "compare.mixed_modes": "Stai confrontando run di modalità diverse. Non misurano lo stesso intervallo, quindi i delta non sono confrontabili alla pari.", "compare.no_matplotlib": "Matplotlib non è installato.\nEsegui: pip install matplotlib", "compare.plot": "Traccia", "compare.select": "Seleziona le sessioni (Ctrl/Shift per più righe)", @@ -69,6 +70,7 @@ "devices.switch": "Tipo di switch", "dlg.all_files": "Tutti i file", "dlg.already_saved": "Questa versione della run è già stata salvata.", + "dlg.btn2_confirm": "BTN2: azzerare la run corrente?", "dlg.close": "Chiudi", "dlg.close_confirm": "La run corrente contiene campioni non salvati.\nChiudere comunque?", "dlg.csv": "CSV", @@ -132,6 +134,7 @@ "live.stdev": "DEV. STD", "live.target": "Target", "log.abort": "Pressione ignorata: nessun nuovo click OS", + "log.button_refused": "Pressione rifiutata: non consentita in questo momento", "log.calib_failed": "Calibrazione fallita, applicato offset di default", "log.calibrated": "Calibrazione OK: {calib} µs ({samples} campioni, RT medio {avg} µs)", "log.calibrating": "Avvio calibrazione…", @@ -142,16 +145,45 @@ "log.disconnected": "Disconnesso", "log.drop": "Campione fuori range firmware: {value} ms", "log.latency": "LAT {value} ms", + "log.mode": "Modalità di misura: {mode}", + "log.optical": "OPT {value} ms (raw {raw})", + "log.optical_cal": "Calibrazione ottica: nero {dark}, bianco {bright}, soglia {threshold}", + "log.optical_drop": "Campione ottico fuori dal range del firmware: {value} ms", + "log.optical_error": "Modalità ottica rifiutata: {reason}", + "log.optical_timeout": "Nessuna transizione di luce rilevata: timeout", + "log.photon_off": "Probe-to-Photon disattivato", + "log.photon_on": "Probe-to-Photon attivo", "log.reset": "Statistiche azzerate", "log.saved": "Sessione salvata nell'archivio (ID {id})", "log.skip": "Campione scartato: click durante il refresh OLED", "log.testmode_off": "Test mode terminato: OLED riattivato", "log.testmode_on": "Test mode: OLED congelato", "log.trig_unmatched": "TRIG senza un nuovo click OS associabile", + "mode.probe_to_pc": "Probe-to-PC", + "mode.probe_to_photon": "Probe-to-Photon", + "mode.short_photon": "photon", + "mode.title": "Modalità di misura", + "photon.bright": "Bianco", + "photon.calibrate": "Calibra sul bersaglio a schermo intero", + "photon.calibrating": "Calibrazione del sensore su questo bersaglio — non muovere il KY-018.", + "photon.calibrating_done": "CALIBRAZIONE OK", + "photon.calibrating_failed": "CALIBRAZIONE FALLITA", + "photon.calibrating_state": "CALIBRAZIONE", + "photon.dark": "Nero", + "photon.ldr_warning": "Il KY-018 è una fotoresistenza: la sua risposta è nell'ordine dei millisecondi ed entra in ogni numero qui sotto. Utile per confrontare un setup con se stesso, non come benchmark click-to-photon assoluto.", + "photon.retry_hint": "Sposta il sensore guardando il numero LUCE qui sotto, poi premi Invio per riprovare, o Esc per uscire.", + "photon.status_ready": "Calibrazione OK, il test può partire.", + "photon.status_separation": "Nero {dark} e bianco {bright} distano solo {gap} conteggi, ne servono almeno {minimum}. Schema il KY-018 dalla luce della stanza, alza la luminosità dello schermo, o avvicina il sensore allo schermo.", + "photon.status_uncalibrated": "Non calibrato: campiona prima il nero e poi il bianco.", + "photon.step_aim": "Fissa il KY-018 contro il centro dello schermo — elastici o nastro — rivolto verso di esso, a due o tre centimetri. Poi calibra: al centro appare un bersaglio grande e le due basi vengono prese su quello. Guarda il numero LUCE su quella schermata: se cambia appena fra nero e bianco, il sensore non è sul bersaglio.", + "photon.target": "BERSAGLIO", + "photon.threshold": "Soglia", + "photon.title": "Probe-to-Photon", "sessions.col_cal": "Cal µs", "sessions.col_date": "Data", "sessions.col_device": "Dispositivo", "sessions.col_dpi": "DPI", + "sessions.col_measure": "Modalità", "sessions.col_mode": "Modo", "sessions.col_n": "N", "sessions.col_poll": "Hz", @@ -178,6 +210,7 @@ "settings.auto_connect": "Connetti automaticamente all'avvio", "settings.backup": "Backup dell'archivio", "settings.backup_done": "Backup salvato in {path}", + "settings.confirm_btn2": "Conferma reset run con BTN2", "settings.demo": "Modalità demo", "settings.demo_hint": "Il dispositivo demo genera campioni simulati per usare interfaccia, grafici e archivio senza hardware. Le run salvate in demo sono contrassegnate e non si mescolano mai alle misure reali.", "settings.demo_start": "Avvia dispositivo demo", @@ -207,15 +240,20 @@ "test.wait_state": "ATTENDI", "tip.auto_reset": "Azzera i contatori del firmware entrando in test mode.", "tip.baseline": "La run rispetto a cui vengono calcolati tutti i delta.", - "tip.buttons": "Fase di test firmware v1.4: le pressioni di BTN1/BTN2 vengono solo contate e registrate. Nessuna azione è ancora associata.", + "tip.buttons": "BTN1/BTN2, firmware v1.4: BTN1 entra o esce dal test mode, BTN2 azzera la run live fuori dal test mode. Le pressioni vengono comunque contate qui.", "tip.calibrate": "Misura il round-trip seriale e lo sottrae da ogni campione. Eseguila una volta per sessione.", + "tip.calibrate_optical": "Apre un bersaglio centrato a schermo intero e campiona nero e poi bianco su quello. Fissa prima il KY-018 al centro dello schermo e poi non spostarlo.", + "tip.confirm_btn2": "Attivo di default: BTN2 è un pulsante fisico e una botta sulla scrivania non deve costarti una run.", "tip.demo": "Dati simulati. Mai mescolati alle run reali.", "tip.detect": "Legge le stringhe USB di prodotto e produttore dal mouse collegato.", "tip.dpi": "I DPI non sono leggibili dal mouse: nessuna API di Windows li espone. Inseriscili a mano dal software del tuo mouse.", "tip.light": "Telemetria KY-018, salvata con la run. Non fa parte della misura.", "tip.measure_rate": "Conta per due secondi i report effettivi del mouse. Muovi il mouse senza fermarti. Misura ciò che il mouse ottiene davvero, che non sempre coincide con il valore impostato.", "tip.polling": "Registrato solo nell'archivio: non cambia il modo in cui la Teensy misura.", + "tip.probe_to_pc": "La misura verificata: dal contatto della sonda al click che arriva a Windows.", + "tip.probe_to_photon": "Dal contatto della sonda allo schermo che diventa davvero bianco. Include il display; usa il KY-018.", "tip.target": "Il test mode diventa blu quando raggiunge questo numero di campioni.", + "tip.threshold": "Punto medio fra le due basi. Il firmware ferma il cronometro alla prima volta che il sensore la supera.", "update.available": "È disponibile la versione {version} ({size} MB).", "update.check": "Cerca aggiornamenti", "update.checking": "Controllo in corso…", diff --git a/latency_tester/locales/ja.json b/latency_tester/locales/ja.json index b5dd858..00ee78a 100644 --- a/latency_tester/locales/ja.json +++ b/latency_tester/locales/ja.json @@ -20,6 +20,7 @@ "compare.hint_empty": "選択したセッションにサンプルがありません。", "compare.hint_one": "セッションを 1 件選択中です。", "compare.hint_select": "比較するセッションを 1 件以上選択してください。", + "compare.mixed_modes": "測定モードの異なるランを並べています。測っている区間が違うため、差分をそのまま比較することはできません。", "compare.no_matplotlib": "Matplotlib がインストールされていません。\n実行してください: pip install matplotlib", "compare.plot": "描画", "compare.select": "セッションを選択 (Ctrl/Shift で複数選択)", @@ -69,6 +70,7 @@ "devices.switch": "スイッチの種類", "dlg.all_files": "すべてのファイル", "dlg.already_saved": "この計測は既に保存済みです。", + "dlg.btn2_confirm": "BTN2: 現在のランを消去しますか?", "dlg.close": "閉じる", "dlg.close_confirm": "現在の計測に未保存のサンプルがあります。\nそれでも閉じますか?", "dlg.csv": "CSV", @@ -132,6 +134,7 @@ "live.stdev": "標準偏差", "live.target": "目標", "log.abort": "押下を無視しました: 新しい OS クリックがありません", + "log.button_refused": "ボタン操作を拒否しました: 現在は実行できません", "log.calib_failed": "キャリブレーションに失敗したため、既定のオフセットを適用しました", "log.calibrated": "キャリブレーション成功: {calib} µs ({samples} サンプル、平均往復 {avg} µs)", "log.calibrating": "キャリブレーションを開始しています…", @@ -142,16 +145,45 @@ "log.disconnected": "切断しました", "log.drop": "ファームウェアの範囲外のサンプル: {value} ms", "log.latency": "LAT {value} ms", + "log.mode": "測定モード: {mode}", + "log.optical": "OPT {value} ms (raw {raw})", + "log.optical_cal": "光学較正: 暗 {dark}、明 {bright}、しきい値 {threshold}", + "log.optical_drop": "光学サンプルがファームウェアの範囲外です: {value} ms", + "log.optical_error": "光学モードを拒否しました: {reason}", + "log.optical_timeout": "光の変化を検出できませんでした: タイムアウト", + "log.photon_off": "Probe-to-Photon 無効", + "log.photon_on": "Probe-to-Photon 有効", "log.reset": "統計をクリアしました", "log.saved": "セッションをアーカイブに保存しました (ID {id})", "log.skip": "サンプルを破棄しました: OLED 更新中のクリックです", "log.testmode_off": "テストモード終了: OLED を再開しました", "log.testmode_on": "テストモード: OLED を停止しました", "log.trig_unmatched": "TRIG に対応する新しい OS クリックがありません", + "mode.probe_to_pc": "Probe-to-PC", + "mode.probe_to_photon": "Probe-to-Photon", + "mode.short_photon": "photon", + "mode.title": "測定モード", + "photon.bright": "明", + "photon.calibrate": "全画面ターゲットで較正", + "photon.calibrating": "このターゲットでセンサーを較正中 — KY-018 を動かさないでください。", + "photon.calibrating_done": "較正 OK", + "photon.calibrating_failed": "較正に失敗しました", + "photon.calibrating_state": "較正中", + "photon.dark": "暗", + "photon.ldr_warning": "KY-018 は CdS セルです。応答自体がミリ秒単位で、下の数値すべてに含まれます。同じ環境同士の比較には使えますが、絶対的な click-to-photon ベンチマークにはなりません。", + "photon.retry_hint": "下の 明るさ の数値を見ながらセンサーを動かし、Enter で再試行、Esc で中止します。", + "photon.status_ready": "較正 OK。テストを開始できます。", + "photon.status_separation": "暗 {dark} と明 {bright} の差が {gap} カウントしかありません(最低 {minimum} 必要)。KY-018 を室内光から遮光し、画面輝度を上げ、またはセンサーを画面に近づけてください。", + "photon.status_uncalibrated": "未較正です。先に黒、次に白をサンプリングしてください。", + "photon.step_aim": "KY-018 を画面中央に固定します(輪ゴムまたはテープ)。画面に向けて 2〜3 cm。その後に較正すると、中央に大きなターゲットが表示され、その上で 2 つの基準値を取得します。その画面の 明るさ の数値を見てください。黒と白でほとんど変わらなければ、センサーはターゲット上にありません。", + "photon.target": "ターゲット", + "photon.threshold": "しきい値", + "photon.title": "Probe-to-Photon", "sessions.col_cal": "補正 µs", "sessions.col_date": "日時", "sessions.col_device": "デバイス", "sessions.col_dpi": "DPI", + "sessions.col_measure": "モード", "sessions.col_mode": "方式", "sessions.col_n": "件数", "sessions.col_poll": "Hz", @@ -178,6 +210,7 @@ "settings.auto_connect": "起動時に自動接続する", "settings.backup": "アーカイブをバックアップ", "settings.backup_done": "バックアップを {path} に書き込みました", + "settings.confirm_btn2": "BTN2 でランを消す前に確認する", "settings.demo": "デモモード", "settings.demo_hint": "デモデバイスはシミュレートされたサンプルを生成するため、ハードウェアなしで画面・グラフ・アーカイブを試せます。デモモードで保存した計測には印が付き、実測データと混ざることはありません。", "settings.demo_start": "デモデバイスを開始", @@ -207,15 +240,20 @@ "test.wait_state": "待機", "tip.auto_reset": "テストモード開始時にファームウェアのカウンタをゼロに戻します。", "tip.baseline": "すべての差分を計算する基準となる計測です。", - "tip.buttons": "ファームウェア v1.4 のテスト段階: BTN1/BTN2 の押下はカウントと記録のみを行い、まだ動作は割り当てられていません。", + "tip.buttons": "BTN1/BTN2、ファームウェア v1.4: BTN1 はテストモードの開始/終了、BTN2 はテストモード外でライブ計測をリセットします。押下回数はここでも数えます。", "tip.calibrate": "シリアルの往復時間を測定し、各サンプルから差し引きます。セッションごとに 1 回実行してください。", + "tip.calibrate_optical": "中央に全画面ターゲットを表示し、その上で黒→白を順にサンプリングします。先に KY-018 を画面中央に固定し、その後は動かさないでください。", + "tip.confirm_btn2": "既定で有効。BTN2 は物理ボタンなので、机への衝撃でランを失わないようにします。", "tip.demo": "シミュレートされたデータです。実際の計測と混ざることはありません。", "tip.detect": "接続中のマウスから USB の製品名とメーカー名を読み取ります。", "tip.dpi": "DPI はマウスから読み取れません。Windows にそれを公開する API がないためです。マウス付属のソフトウェアを見て手入力してください。", "tip.light": "KY-018 のテレメトリで、計測と一緒に保存されます。測定そのものには含まれません。", "tip.measure_rate": "2 秒間、マウスの実際のレポート数を数えます。その間はマウスを動かし続けてください。設定値ではなく、マウスが実際に達成しているレートを測ります。", "tip.polling": "アーカイブに記録されるだけで、Teensy の測定方法は変わりません。", + "tip.probe_to_pc": "検証済みの測定。プローブ接触から Windows がクリックを受け取るまで。", + "tip.probe_to_photon": "プローブ接触から画面が実際に白くなるまで。ディスプレイを含み、KY-018 を使います。", "tip.target": "この件数のサンプルを取得すると、テストモードが青に変わります。", + "tip.threshold": "2 つの基準値の中点。センサーが最初にここを超えた時点で計測を止めます。", "update.available": "バージョン {version} が利用できます ({size} MB)。", "update.check": "アップデートを確認", "update.checking": "確認中…", diff --git a/latency_tester/locales/ru.json b/latency_tester/locales/ru.json index e5da479..e2683e5 100644 --- a/latency_tester/locales/ru.json +++ b/latency_tester/locales/ru.json @@ -20,6 +20,7 @@ "compare.hint_empty": "В выбранных сессиях нет замеров.", "compare.hint_one": "Выбрана одна сессия.", "compare.hint_select": "Выберите хотя бы одну сессию для сравнения.", + "compare.mixed_modes": "Вместе показаны прогоны из разных режимов измерения. Они измеряют разные интервалы, поэтому разницы несопоставимы.", "compare.no_matplotlib": "Matplotlib не установлен.\nВыполните: pip install matplotlib", "compare.plot": "Построить", "compare.select": "Выберите сессии (Ctrl/Shift — несколько строк)", @@ -69,6 +70,7 @@ "devices.switch": "Тип переключателя", "dlg.all_files": "Все файлы", "dlg.already_saved": "Эта версия прогона уже сохранена.", + "dlg.btn2_confirm": "BTN2: сбросить текущий прогон?", "dlg.close": "Закрыть", "dlg.close_confirm": "В текущем прогоне есть несохранённые замеры.\nВсё равно закрыть?", "dlg.csv": "CSV", @@ -132,6 +134,7 @@ "live.stdev": "СТД. ОТКЛ.", "live.target": "Цель", "log.abort": "Нажатие проигнорировано: нет нового клика от ОС", + "log.button_refused": "Нажатие отклонено: сейчас недоступно", "log.calib_failed": "Калибровка не удалась, применено смещение по умолчанию", "log.calibrated": "Калибровка успешна: {calib} мкс ({samples} замеров, средний цикл {avg} мкс)", "log.calibrating": "Запуск калибровки…", @@ -142,16 +145,45 @@ "log.disconnected": "Отключено", "log.drop": "Замер вне диапазона прошивки: {value} мс", "log.latency": "LAT {value} мс", + "log.mode": "Режим измерения: {mode}", + "log.optical": "OPT {value} ms (raw {raw})", + "log.optical_cal": "Оптическая калибровка: тёмный {dark}, светлый {bright}, порог {threshold}", + "log.optical_drop": "Оптический отсчёт вне диапазона прошивки: {value} ms", + "log.optical_error": "Оптический режим отклонён: {reason}", + "log.optical_timeout": "Смена освещённости не обнаружена: тайм-аут", + "log.photon_off": "Probe-to-Photon выключен", + "log.photon_on": "Probe-to-Photon включён", "log.reset": "Статистика очищена", "log.saved": "Сессия сохранена в архиве (ID {id})", "log.skip": "Замер отброшен: клик во время обновления OLED", "log.testmode_off": "Тестовый режим завершён: OLED возобновлён", "log.testmode_on": "Тестовый режим: OLED заморожен", "log.trig_unmatched": "TRIG без соответствующего нового клика от ОС", + "mode.probe_to_pc": "Probe-to-PC", + "mode.probe_to_photon": "Probe-to-Photon", + "mode.short_photon": "photon", + "mode.title": "Режим измерения", + "photon.bright": "Светлый", + "photon.calibrate": "Калибровать на полноэкранной мишени", + "photon.calibrating": "Калибровка датчика по этой мишени — не двигайте KY-018.", + "photon.calibrating_done": "КАЛИБРОВКА ОК", + "photon.calibrating_failed": "КАЛИБРОВКА НЕ УДАЛАСЬ", + "photon.calibrating_state": "КАЛИБРОВКА", + "photon.dark": "Тёмный", + "photon.ldr_warning": "KY-018 — фоторезистор: его собственное время отклика измеряется миллисекундами и входит в каждое число ниже. Годится для сравнения установки с самой собой, но не как абсолютный тест click-to-photon.", + "photon.retry_hint": "Двигайте датчик, следя за числом СВЕТ ниже, затем нажмите Enter для повторной попытки или Esc для выхода.", + "photon.status_ready": "Калибровка в порядке, тест можно начинать.", + "photon.status_separation": "Тёмный {dark} и светлый {bright} различаются всего на {gap} отсчётов, нужно минимум {minimum}. Затените KY-018 от комнатного света, поднимите яркость экрана или придвиньте датчик к экрану.", + "photon.status_uncalibrated": "Не откалибровано: сначала снимите чёрный, затем белый.", + "photon.step_aim": "Закрепите KY-018 напротив центра экрана — резинками или скотчем — лицом к нему, в двух-трёх сантиметрах. Затем калибруйте: в центре появится большая мишень, и оба базовых значения снимутся на ней. Следите за числом СВЕТ на том экране: если между чёрным и белым оно почти не меняется, датчик не на мишени.", + "photon.target": "МИШЕНЬ", + "photon.threshold": "Порог", + "photon.title": "Probe-to-Photon", "sessions.col_cal": "Кал. мкс", "sessions.col_date": "Дата", "sessions.col_device": "Устройство", "sessions.col_dpi": "DPI", + "sessions.col_measure": "Режим", "sessions.col_mode": "Режим", "sessions.col_n": "N", "sessions.col_poll": "Гц", @@ -178,6 +210,7 @@ "settings.auto_connect": "Подключаться автоматически при запуске", "settings.backup": "Создать резервную копию архива", "settings.backup_done": "Резервная копия записана в {path}", + "settings.confirm_btn2": "Спрашивать перед сбросом прогона по BTN2", "settings.demo": "Демонстрационный режим", "settings.demo_hint": "Демоустройство генерирует смоделированные замеры, чтобы интерфейс, графики и архив можно было использовать без оборудования. Прогоны, сохранённые в деморежиме, помечаются и никогда не смешиваются с реальными измерениями.", "settings.demo_start": "Запустить демоустройство", @@ -207,15 +240,20 @@ "test.wait_state": "ЖДИТЕ", "tip.auto_reset": "Обнуляет счётчики прошивки при входе в тестовый режим.", "tip.baseline": "Прогон, относительно которого считаются все отличия.", - "tip.buttons": "Тестовая фаза прошивки v1.4: нажатия BTN1/BTN2 только подсчитываются и записываются в журнал. Действия к ним пока не привязаны.", + "tip.buttons": "BTN1/BTN2, прошивка v1.4: BTN1 включает или выключает тестовый режим, BTN2 сбрасывает текущий прогон вне тестового режима. Нажатия здесь также считаются.", "tip.calibrate": "Измеряет время цикла последовательного порта и вычитает его из каждого замера. Запускайте один раз за сессию.", + "tip.calibrate_optical": "Открывает полноэкранную мишень по центру и снимает на ней сначала чёрный, затем белый. Сначала закрепите KY-018 в центре экрана и после этого не двигайте его.", + "tip.confirm_btn2": "Включено по умолчанию: BTN2 — физическая кнопка, и удар по столу не должен стоить прогона.", "tip.demo": "Смоделированные данные. Никогда не смешиваются с реальными прогонами.", "tip.detect": "Читает строки производителя и модели из USB-дескриптора подключённой мыши.", "tip.dpi": "DPI нельзя прочитать из мыши: ни один API Windows его не предоставляет. Введите значение вручную из фирменной программы мыши.", "tip.light": "Телеметрия KY-018, сохраняется вместе с прогоном. В измерении не участвует.", "tip.measure_rate": "В течение двух секунд считает фактические отчёты мыши. Двигайте мышью непрерывно. Измеряется то, чего мышь действительно достигает, а не то, что выставлено в настройках.", "tip.polling": "Записывается только в архив; на способ измерения Teensy не влияет.", + "tip.probe_to_pc": "Проверенное измерение: от контакта щупа до клика, дошедшего до Windows.", + "tip.probe_to_photon": "От контакта щупа до момента, когда экран действительно становится белым. Включает дисплей, использует KY-018.", "tip.target": "Тестовый режим станет синим, когда наберётся столько замеров.", + "tip.threshold": "Середина между двумя базовыми значениями. Прошивка останавливает отсчёт при первом её пересечении.", "update.available": "Доступна версия {version} ({size} МБ).", "update.check": "Проверить обновления", "update.checking": "Проверка…", diff --git a/latency_tester/locales/zh.json b/latency_tester/locales/zh.json index 051bdaf..14bf251 100644 --- a/latency_tester/locales/zh.json +++ b/latency_tester/locales/zh.json @@ -20,6 +20,7 @@ "compare.hint_empty": "所选会话不含任何样本。", "compare.hint_one": "已选中一个会话。", "compare.hint_select": "请至少选择一个会话进行对比。", + "compare.mixed_modes": "这里同时显示了不同测量模式的记录。它们测的不是同一段区间,因此差值无法直接比较。", "compare.no_matplotlib": "未安装 Matplotlib。\n请运行:pip install matplotlib", "compare.plot": "绘制", "compare.select": "选择会话(按 Ctrl/Shift 可多选)", @@ -69,6 +70,7 @@ "devices.switch": "微动类型", "dlg.all_files": "所有文件", "dlg.already_saved": "该版本的测试已保存过。", + "dlg.btn2_confirm": "BTN2:清空当前测量?", "dlg.close": "关闭", "dlg.close_confirm": "当前测试有未保存的样本。\n仍要关闭吗?", "dlg.csv": "CSV", @@ -132,6 +134,7 @@ "live.stdev": "标准差", "live.target": "目标", "log.abort": "已忽略此次按下:没有新的系统点击", + "log.button_refused": "按键已拒绝:当前不允许该操作", "log.calib_failed": "校准失败,已应用默认偏移量", "log.calibrated": "校准成功:{calib} µs({samples} 个样本,平均往返 {avg} µs)", "log.calibrating": "正在开始校准…", @@ -142,16 +145,45 @@ "log.disconnected": "已断开连接", "log.drop": "样本超出固件范围:{value} 毫秒", "log.latency": "LAT {value} 毫秒", + "log.mode": "测量模式:{mode}", + "log.optical": "OPT {value} ms (raw {raw})", + "log.optical_cal": "光学校准:暗 {dark},亮 {bright},阈值 {threshold}", + "log.optical_drop": "光学采样超出固件范围:{value} ms", + "log.optical_error": "光学模式被拒绝:{reason}", + "log.optical_timeout": "未检测到亮度变化:超时", + "log.photon_off": "Probe-to-Photon 已关闭", + "log.photon_on": "Probe-to-Photon 已启用", "log.reset": "统计数据已清空", "log.saved": "会话已存入档案库(ID {id})", "log.skip": "样本已丢弃:点击发生在 OLED 刷新期间", "log.testmode_off": "测试模式结束:OLED 已恢复", "log.testmode_on": "测试模式:OLED 已冻结", "log.trig_unmatched": "收到 TRIG,但没有匹配的新系统点击", + "mode.probe_to_pc": "Probe-to-PC", + "mode.probe_to_photon": "Probe-to-Photon", + "mode.short_photon": "photon", + "mode.title": "测量模式", + "photon.bright": "亮", + "photon.calibrate": "在全屏目标上校准", + "photon.calibrating": "正在于该目标上校准传感器 — 请勿移动 KY-018。", + "photon.calibrating_done": "校准正常", + "photon.calibrating_failed": "校准失败", + "photon.calibrating_state": "校准中", + "photon.dark": "暗", + "photon.ldr_warning": "KY-018 是光敏电阻:它自身的响应时间就在毫秒量级,并包含在下方每一个数值中。适合把同一套设备与自己比较,不能作为绝对的 click-to-photon 基准。", + "photon.retry_hint": "一边观察下方的 亮度 数值一边移动传感器,然后按 Enter 重试,或按 Esc 退出。", + "photon.status_ready": "校准正常,可以开始测试。", + "photon.status_separation": "暗 {dark} 与亮 {bright} 仅相差 {gap} 个计数,至少需要 {minimum} 个。请遮蔽 KY-018 避开室内光、调高屏幕亮度,或让传感器更靠近屏幕。", + "photon.status_uncalibrated": "尚未校准:请先采样黑色,再采样白色。", + "photon.step_aim": "把 KY-018 固定在屏幕正中央(橡皮筋或胶带),正对屏幕,相距两三厘米。然后校准:中央会出现一个大目标,两个基准值都在它上面采集。注意那个画面上的 亮度 数值:如果黑白之间几乎不变,说明传感器没有对准目标。", + "photon.target": "目标区", + "photon.threshold": "阈值", + "photon.title": "Probe-to-Photon", "sessions.col_cal": "校准 µs", "sessions.col_date": "日期", "sessions.col_device": "设备", "sessions.col_dpi": "DPI", + "sessions.col_measure": "模式", "sessions.col_mode": "方式", "sessions.col_n": "样本数", "sessions.col_poll": "Hz", @@ -178,6 +210,7 @@ "settings.auto_connect": "启动时自动连接", "settings.backup": "备份档案库", "settings.backup_done": "备份已写入 {path}", + "settings.confirm_btn2": "BTN2 清空当前测量前先确认", "settings.demo": "演示模式", "settings.demo_hint": "演示设备会生成模拟样本,让你无需任何硬件即可使用界面、图表和档案库。演示模式下保存的测试会被标记,绝不会与真实测量数据混在一起。", "settings.demo_start": "启动演示设备", @@ -207,15 +240,20 @@ "test.wait_state": "等待", "tip.auto_reset": "进入测试模式时清零固件计数器。", "tip.baseline": "所有差值都以这次测试为基准计算。", - "tip.buttons": "固件 v1.4 测试阶段:BTN1/BTN2 的按下仅作计数和记录,尚未绑定任何操作。", + "tip.buttons": "BTN1/BTN2,固件 v1.4:BTN1 进入或退出测试模式,BTN2 在测试模式之外清空当前实时测量。按下次数同样会在此计数。", "tip.calibrate": "测量串行往返时间并从每个样本中减去。每个会话运行一次即可。", + "tip.calibrate_optical": "打开居中的全屏目标,并在其上先采样黑色再采样白色。请先把 KY-018 固定在屏幕中央,之后不要移动。", + "tip.confirm_btn2": "默认开启:BTN2 是实体按键,桌子被碰一下不应该让你损失一次测量。", "tip.demo": "模拟数据,绝不会与真实测试混合。", "tip.detect": "读取已连接鼠标的 USB 产品和厂商字符串。", "tip.dpi": "DPI 无法从鼠标读取:Windows 没有任何 API 提供该值。请从鼠标自带的软件中查看并手动填写。", "tip.light": "KY-018 遥测数据,与测试一同保存。它不属于测量的一部分。", "tip.measure_rate": "用两秒钟统计鼠标的实际回报次数。测量时请持续移动鼠标。它测的是鼠标真正达到的回报率,未必等于设定值。", "tip.polling": "仅记录在档案库中,不会改变 Teensy 的测量方式。", + "tip.probe_to_pc": "已验证的测量:从探针接触到点击抵达 Windows。", + "tip.probe_to_photon": "从探针接触到屏幕真正变白。包含显示器,使用 KY-018。", "tip.target": "采集到这么多样本后,测试模式会变为蓝色。", + "tip.threshold": "两个基准值的中点。传感器首次越过该值时固件停止计时。", "update.available": "有新版本 {version} 可用({size} MB)。", "update.check": "检查更新", "update.checking": "正在检查…", diff --git a/latency_tester/protocol.py b/latency_tester/protocol.py index 7c50ab1..48157fb 100644 --- a/latency_tester/protocol.py +++ b/latency_tester/protocol.py @@ -16,8 +16,8 @@ from typing import Any, NamedTuple # Revision of the protocol as implemented here. Bump only when the wire format -# changes. 1 == the format spoken by firmware v1.0 through v1.3. -PROTOCOL_VERSION = 1 +# changes. 1 == firmware v1.0-v1.4; 2 adds the Probe-to-Photon tokens (v1.5). +PROTOCOL_VERSION = 2 BAUD_RATE = 115200 @@ -36,6 +36,14 @@ CMD_TEST_MODE_OFF = "E" CMD_LIGHT = "L" +# Probe-to-Photon (firmware v1.5+). A dashboard talking to older firmware never +# sends these; the firmware's `default:` branch ignores anything it does not know. +CMD_PHOTON_ON = "O" # select the optical measurement path +CMD_PHOTON_OFF = "N" # back to Probe-to-PC +CMD_CAL_DARK = "D" # sample the target while it is black +CMD_CAL_BRIGHT = "W" # sample the target while it is white +CMD_CAL_REPORT = "K" # report the stored optical calibration + # Handshake replies sent inside a measurement / calibration exchange. REPLY_CLICK_SEEN = "H" # OS click matched the TRIG -> firmware stops the clock REPLY_NO_CLICK = "X" # no matching click -> firmware aborts the sample @@ -65,6 +73,19 @@ PREFIX_CALIBRATING = "CALIBRATING" PREFIX_BUTTON = "BTN" +# ---- Probe-to-Photon ------------------------------------------------------- +TOK_PHOTON_ON = "PHOTON:ON" +TOK_PHOTON_OFF = "PHOTON:OFF" +TOK_OPT_TIMEOUT = "OPT_TIMEOUT" +PREFIX_OPT_ERR = "OPT_ERR:" +PREFIX_OPT_DROP = "OPT_DROP:OUT_OF_RANGE:" + +#: Smallest dark/bright gap, in ADC counts, that still makes a usable threshold. +#: Mirrors ``OPT_MIN_SEPARATION`` in the v1.5 sketch -- change both together. +OPTICAL_MIN_SEPARATION = 60 +#: Where the threshold sits between the two baselines. Mirrors the firmware. +OPTICAL_THRESHOLD_FRACTION = 0.5 + LAT_RE = re.compile( r"^LAT:(?P[\d.]+),min:(?P[\d.]+),max:(?P[\d.]+)," r"avg:(?P[\d.]+),n:(?P\d+)$" @@ -81,6 +102,31 @@ # Firmware v1.4+. Parsed generically so a future BTNn:RELEASE needs no change # on the dashboard side. BUTTON_RE = re.compile(r"^BTN(?P\d+):(?P[A-Z_]+)$") +# Firmware v1.5+. Deliberately a different token from ``LAT:``: an optical +# sample must never be mistaken for a Probe-to-PC one, in the log or anywhere +# else. +OPT_RE = re.compile( + r"^OPT:(?P[\d.]+),raw:(?P\d+),min:(?P[\d.]+)," + r"max:(?P[\d.]+),avg:(?P[\d.]+),n:(?P\d+)$" +) +OPT_CAL_RE = re.compile( + r"^OPT_CAL:dark:(?P-?\d+),bright:(?P-?\d+)," + r"threshold:(?P-?\d+),rising:(?P[01])$" +) + + +def optical_threshold(dark: int, bright: int) -> tuple[int, bool] | None: + """``(threshold, rising)`` for a dark/bright pair, or ``None`` if too close. + + Mirrors the firmware so the dashboard can refuse an unusable calibration + before a single sample is taken. ``rising`` is ``False`` when the module's + ADC value *falls* as light rises -- some KY-018 boards are wired that way, + and hard-coding one direction would silently break on those. + """ + span = int(bright) - int(dark) + if abs(span) < OPTICAL_MIN_SEPARATION: + return None + return round(dark + span * OPTICAL_THRESHOLD_FRACTION), span > 0 class Event(NamedTuple): @@ -137,6 +183,26 @@ def parse_line(line: str) -> Event: if m: return Event("cal_ok", {k: int(v) for k, v in m.groupdict().items()}) + m = OPT_RE.match(line) + if m: + return Event("optical", { + "latency_ms": float(m.group("last")), + "raw_optical": int(m.group("raw")), + "firmware_min": float(m.group("min")), + "firmware_max": float(m.group("max")), + "firmware_avg": float(m.group("avg")), + "firmware_n": int(m.group("n")), + }) + + m = OPT_CAL_RE.match(line) + if m: + return Event("optical_cal", { + "dark": int(m.group("dark")), + "bright": int(m.group("bright")), + "threshold": int(m.group("threshold")), + "rising": m.group("rising") == "1", + }) + m = BUTTON_RE.match(line) if m: return Event("button", { @@ -152,6 +218,13 @@ def parse_line(line: str) -> Event: return Event("light", int(line.split(":", 1)[1])) except ValueError: return Event("log", line) + if line.startswith(PREFIX_OPT_DROP): + try: + return Event("optical_drop", float(line.rsplit(":", 1)[1])) + except ValueError: + return Event("optical_drop", None) + if line.startswith(PREFIX_OPT_ERR): + return Event("optical_error", line.split(":", 1)[1]) if line.startswith(PREFIX_DROP): try: return Event("drop", float(line.rsplit(":", 1)[1])) @@ -177,6 +250,9 @@ def parse_line(line: str) -> Event: TOK_ABORT_NO_CLICK: "abort", TOK_STATS_NO_DATA: "stats_no_data", TOK_OLED_FAIL: "oled_fail", + TOK_PHOTON_ON: "photon_on", + TOK_PHOTON_OFF: "photon_off", + TOK_OPT_TIMEOUT: "optical_timeout", } if line in simple: return Event(simple[line]) diff --git a/latency_tester/serial_service.py b/latency_tester/serial_service.py index b9854eb..e12197d 100644 --- a/latency_tester/serial_service.py +++ b/latency_tester/serial_service.py @@ -60,6 +60,16 @@ def record(self, timestamp_ns: int) -> None: with self._lock: self._last_click_ns = timestamp_ns + @property + def last_click_ns(self) -> int: + """Timestamp of the newest OS click, consumed or not. + + Probe-to-Photon needs this: there is no TRIG handshake in that mode, so + the target area watches this value to know when to turn white. + """ + with self._lock: + return self._last_click_ns + def try_consume(self, trig_ns: int) -> bool: """Consume a click matching a TRIG seen at ``trig_ns``. Pure enough to unit-test: no sleeping, no I/O.""" @@ -122,11 +132,17 @@ def close(self) -> None: self._running = False self.calibrating = False transport, self._transport = self._transport, None + thread, self._thread = self._thread, None if transport is not None: try: transport.close() except Exception: # a vanished COM port raises all sorts of things log.debug("error while closing %s", self.port_name, exc_info=True) + # The reader can still be inside a blocking read on that same handle. + # Windows frees the COM port only once the thread is really gone, so + # without this join the next process to start can be denied access. + if thread is not None and thread is not threading.current_thread(): + thread.join(timeout=1.0) self.port_name = "" self.is_demo = False diff --git a/latency_tester/settings.py b/latency_tester/settings.py index 9079226..c670623 100644 --- a/latency_tester/settings.py +++ b/latency_tester/settings.py @@ -29,6 +29,8 @@ "target_samples": 50, "auto_connect": True, "auto_reset_on_test": True, + "measurement_mode": "probe_to_pc", # remembered between sessions + "confirm_btn2_reset": True, # ask before BTN2 wipes a live run "window_geometry": "", "data_dir": "", # empty -> default_data_dir() } diff --git a/latency_tester/views/compare.py b/latency_tester/views/compare.py index f342920..acbdf08 100644 --- a/latency_tester/views/compare.py +++ b/latency_tester/views/compare.py @@ -7,6 +7,8 @@ from tkinter import filedialog, messagebox, ttk from typing import TYPE_CHECKING +from ..constants import MODE_PHOTON +from ..database import run_mode from ..i18n import translator as tr from ..stats import compute_stats, delta, fmt_delta, fmt_ms from ..theme import series_style, style_axes, style_figure @@ -202,8 +204,15 @@ def _label_for(self, run_id: int) -> str: bits = [run["device_name"], run["name"]] if run["polling_rate_hz"]: bits.append(f"{run['polling_rate_hz']} Hz") + # The mode is part of the identity of a run, not a detail: two runs from + # different modes measure different things and must never look alike. + if run_mode(run) == MODE_PHOTON: + bits.append(tr("mode.short_photon")) return " — ".join(bits) + def _mixed_modes(self, series: list[tuple]) -> bool: + return len({run_mode(entry[0]) for entry in series}) > 1 + def _collect(self) -> list[tuple]: series = [] for iid in self.tree.selection(): @@ -234,7 +243,12 @@ def plot(self) -> None: return self._draw(series) - if len(series) >= 2: + if self._mixed_modes(series): + # Allowed -- sometimes it is exactly what you want to look at -- but + # never without saying so: the two modes do not measure the same + # interval, so a delta between them is not a like-for-like figure. + self.status_var.set(tr("compare.mixed_modes")) + elif len(series) >= 2: self.status_var.set(f"{tr('compare.baseline')}: {baseline[1]}") else: self.status_var.set(tr("compare.hint_one")) diff --git a/latency_tester/views/live.py b/latency_tester/views/live.py index 2fe1878..7c6ea83 100644 --- a/latency_tester/views/live.py +++ b/latency_tester/views/live.py @@ -11,12 +11,13 @@ from typing import TYPE_CHECKING from .. import devices as hardware -from ..constants import CONNECTION_MODES, POLLING_RATES +from ..constants import (CONNECTION_MODES, MODE_PHOTON, MODE_PROBE_PC, + POLLING_RATES) from ..i18n import translator as tr from ..protocol import CMD_LIGHT, CMD_STATS from ..theme import FONT_FAMILY, MONO_FAMILY from ..widgets import (SampleChart, attach_tooltip, form_row, info_row, - metric_card) + metric_card, scrollable_column) if TYPE_CHECKING: from ..app import LatencyTesterApp @@ -47,20 +48,25 @@ def __init__(self, parent: tk.Misc, app: "LatencyTesterApp"): self.rowconfigure(0, weight=1) left = ttk.Frame(self) - right = ttk.Frame(self) left.grid(row=0, column=0, sticky="nsew", padx=(0, 8)) - right.grid(row=0, column=1, sticky="nsew") left.rowconfigure(1, weight=1) left.columnconfigure(0, weight=1) - right.rowconfigure(3, weight=1) + + # The right column is taller than a laptop screen once the optical + # panel is showing, so it scrolls. Before this, "Enter test mode" could + # sit below the bottom of the window with no way to reach it. + column, right = scrollable_column(self, palette) + column.grid(row=0, column=1, sticky="nsew") right.columnconfigure(0, weight=1) self._build_metrics(left, palette) self._build_chart(left, palette) + self._build_mode(right, palette) self._build_config(right, palette) self._build_controls(right, palette) self._build_run_actions(right, palette) self._build_log(right) + self.refresh_mode() # --------------------------------------------------------------- panels -- def _build_metrics(self, parent: ttk.Frame, palette) -> None: @@ -96,9 +102,64 @@ def _build_chart(self, parent: ttk.Frame, palette) -> None: self.chart = SampleChart(box, palette, empty_text=tr("live.no_samples")) self.chart.pack(fill="both", expand=True) + def _build_mode(self, parent: ttk.Frame, palette) -> None: + """Measurement-mode selector plus the optical calibration it needs.""" + box = ttk.LabelFrame(parent, text=f" {tr('mode.title')} ", padding=12) + box.grid(row=0, column=0, sticky="ew") + box.columnconfigure(0, weight=1) + + picker = ttk.Frame(box) + picker.grid(row=0, column=0, sticky="ew") + for column, mode in enumerate((MODE_PROBE_PC, MODE_PHOTON)): + radio = ttk.Radiobutton( + picker, text=tr(f"mode.{mode}"), value=mode, + variable=self.app.measure_mode_var, + command=lambda m=mode: self.app.set_measurement_mode(m)) + radio.grid(row=0, column=column, sticky="w", padx=(0, 18)) + attach_tooltip(radio, tr(f"tip.{mode}"), palette) + + # Everything below is Probe-to-Photon only and is hidden otherwise. + self.photon_box = ttk.Frame(box) + self.photon_box.grid(row=1, column=0, sticky="ew", pady=(12, 0)) + self.photon_box.columnconfigure(1, weight=1) + + ttk.Label(self.photon_box, text=tr("photon.step_aim"), wraplength=340, + justify="left", style="Muted.TLabel").grid( + row=0, column=0, columnspan=2, sticky="ew", pady=(0, 8)) + + self.calibrate_btn = ttk.Button( + self.photon_box, text=tr("photon.calibrate"), + style="Accent.TButton", command=self.app.calibrate_optical) + self.calibrate_btn.grid(row=2, column=0, columnspan=2, sticky="ew", + ipady=4) + attach_tooltip(self.calibrate_btn, tr("tip.calibrate_optical"), palette) + + values = ttk.Frame(self.photon_box) + values.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(10, 0)) + values.columnconfigure(1, weight=1) + info_row(values, 0, tr("photon.dark"), self.app.opt_dark_var) + info_row(values, 1, tr("photon.bright"), self.app.opt_bright_var) + info_row(values, 2, tr("photon.threshold"), self.app.opt_threshold_var, + tr("tip.threshold"), palette) + + ttk.Label(self.photon_box, textvariable=self.app.opt_status_var, + wraplength=340, justify="left", style="Warn.TLabel").grid( + row=4, column=0, columnspan=2, sticky="ew", pady=(8, 0)) + ttk.Label(self.photon_box, text=tr("photon.ldr_warning"), wraplength=340, + justify="left", style="Muted.TLabel").grid( + row=5, column=0, columnspan=2, sticky="ew", pady=(8, 0)) + + def refresh_mode(self) -> None: + """Show the optical panel only in Probe-to-Photon.""" + if self.app.photon_mode: + self.photon_box.grid() + self.app.opt_status_var.set(self.app.optical_ready()[1]) + else: + self.photon_box.grid_remove() + def _build_config(self, parent: ttk.Frame, palette) -> None: box = ttk.LabelFrame(parent, text=f" {tr('live.config')} ", padding=12) - box.grid(row=0, column=0, sticky="ew") + box.grid(row=1, column=0, sticky="ew", pady=(10, 0)) box.columnconfigure(1, weight=1) self.device_combo = ttk.Combobox(box, textvariable=self.app.device_var, @@ -155,7 +216,7 @@ def _build_config(self, parent: ttk.Frame, palette) -> None: def _build_controls(self, parent: ttk.Frame, palette) -> None: box = ttk.LabelFrame(parent, text=f" {tr('live.controls')} ", padding=12) - box.grid(row=1, column=0, sticky="ew", pady=(10, 0)) + box.grid(row=2, column=0, sticky="ew", pady=(10, 0)) info = ttk.Frame(box) info.pack(fill="x", pady=(0, 10)) @@ -201,7 +262,7 @@ def _build_controls(self, parent: ttk.Frame, palette) -> None: def _build_run_actions(self, parent: ttk.Frame, palette) -> None: box = ttk.LabelFrame(parent, text=f" {tr('live.current_run')} ", padding=12) - box.grid(row=2, column=0, sticky="ew", pady=(10, 0)) + box.grid(row=3, column=0, sticky="ew", pady=(10, 0)) row = ttk.Frame(box) row.pack(fill="x") row.columnconfigure(0, weight=1) @@ -216,7 +277,7 @@ def _build_run_actions(self, parent: ttk.Frame, palette) -> None: def _build_log(self, parent: ttk.Frame) -> None: box = ttk.LabelFrame(parent, text=f" {tr('live.events')} ", padding=8) - box.grid(row=3, column=0, sticky="nsew", pady=(10, 0)) + box.grid(row=4, column=0, sticky="nsew", pady=(10, 0)) palette = self.app.palette holder = ttk.Frame(box) holder.pack(fill="both", expand=True) @@ -286,7 +347,7 @@ def set_samples(self, values) -> None: def set_connected(self, connected: bool) -> None: state = "normal" if connected else "disabled" for button in (self.cal_btn, self.reset_btn, self.stats_btn, - self.light_btn, self.test_btn): + self.light_btn, self.test_btn, self.calibrate_btn): button.configure(state=state) def get_notes(self) -> str: diff --git a/latency_tester/views/preferences.py b/latency_tester/views/preferences.py index 94e12fd..97e765e 100644 --- a/latency_tester/views/preferences.py +++ b/latency_tester/views/preferences.py @@ -78,9 +78,15 @@ def _build_appearance(self) -> None: command=self._change_auto_connect) check.grid(row=2, column=0, columnspan=2, sticky="w", pady=(10, 0)) + btn2 = ttk.Checkbutton(box, text=tr("settings.confirm_btn2"), + variable=self.app.confirm_btn2_var, + command=self._change_confirm_btn2) + btn2.grid(row=3, column=0, columnspan=2, sticky="w", pady=(6, 0)) + attach_tooltip(btn2, tr("tip.confirm_btn2"), self.app.palette) + ttk.Label(box, text=tr("settings.restart_hint"), style="Muted.TLabel", wraplength=420, justify="left").grid( - row=3, column=0, columnspan=2, sticky="w", pady=(10, 0)) + row=4, column=0, columnspan=2, sticky="w", pady=(10, 0)) def _change_language(self, _event=None) -> None: chosen = self.language_display.get() @@ -103,6 +109,11 @@ def _change_auto_connect(self) -> None: self.app.settings.set("auto_connect", bool(self.app.auto_connect_var.get())) self.app.settings.save() + def _change_confirm_btn2(self) -> None: + self.app.settings.set("confirm_btn2_reset", + bool(self.app.confirm_btn2_var.get())) + self.app.settings.save() + # ------------------------------------------------------------- updates -- def _build_updates(self) -> None: box = ttk.LabelFrame(self, text=f" {tr('update.title')} ", padding=14) diff --git a/latency_tester/views/sessions.py b/latency_tester/views/sessions.py index 47f61a7..b0fc6fa 100644 --- a/latency_tester/views/sessions.py +++ b/latency_tester/views/sessions.py @@ -7,8 +7,8 @@ from typing import TYPE_CHECKING from .. import export -from ..constants import CONNECTION_MODES, POLLING_RATES -from ..database import run_as_metadata +from ..constants import CONNECTION_MODES, MODE_LABELS, MODE_PHOTON, POLLING_RATES +from ..database import run_as_metadata, run_mode from ..i18n import translator as tr from ..stats import compute_stats, fmt_ms from ..widgets import form_row, tree_with_scrollbar @@ -20,6 +20,7 @@ ("date", "sessions.col_date", 140), ("device", "sessions.col_device", 150), ("run", "sessions.col_run", 180), + ("measure", "sessions.col_measure", 110), ("poll", "sessions.col_poll", 60), ("mode", "sessions.col_mode", 90), ("dpi", "sessions.col_dpi", 60), @@ -110,6 +111,7 @@ def reload(self) -> None: run["started_at"].replace("T", " ")[:16], run["device_name"], ("[DEMO] " if run["is_demo"] else "") + run["name"], + tr(MODE_LABELS.get(run_mode(run), "mode.probe_to_pc")), run["polling_rate_hz"] or "", run["connection_mode"] or "", run["dpi"] or "", @@ -137,9 +139,15 @@ def _show_details(self) -> None: return run = self.app.db.get_run(selected[0]) stats = compute_stats(self.app.db.get_latencies(selected[0])) + mode = run_mode(run) lines = [ f"{run['device_name']} — {run['name']}" + (" [DEMO]" if run["is_demo"] else ""), + f"{tr('mode.title')}: {tr(MODE_LABELS.get(mode, 'mode.probe_to_pc'))}" + + (f" {tr('photon.dark')}={run['optical_dark']}" + f" {tr('photon.bright')}={run['optical_bright']}" + f" {tr('photon.threshold')}={run['optical_threshold']}" + if mode == MODE_PHOTON else ""), f"{tr('live.polling')}: {run['polling_rate_hz'] or '—'} " f"{tr('live.connection_mode')}: {run['connection_mode'] or '—'} " f"{tr('live.dpi')}: {run['dpi'] or '—'} " diff --git a/latency_tester/views/testmode.py b/latency_tester/views/testmode.py index 9c42926..66bf66f 100644 --- a/latency_tester/views/testmode.py +++ b/latency_tester/views/testmode.py @@ -3,6 +3,17 @@ Colour is the whole message: GREEN = armed, RED = wait/release, BLUE = target reached. The overlay only reads state the app already computed; it never issues serial commands and never blocks, so it cannot influence timing. + +In Probe-to-Photon it also carries the optical target: a black rectangle that +turns white the moment Windows reports the click. The KY-018 is aimed at that +rectangle, and the Teensy times from the probe contact to the light change. +The rectangle is a bare Label with no bindings, so it can never be clicked. + +The repaint is driven by a poll rather than by the input callback: touching a +Tk widget from the ``pynput`` thread is not safe. That poll -- and the paint +itself -- happen on the machine under test and are therefore *inside* the +measured interval. That is not a defect of the method: click-to-photon +measures everything up to the photons, the application included. """ from __future__ import annotations @@ -21,12 +32,146 @@ log = logging.getLogger(__name__) +#: How often the optical target looks for a new OS click. 1 ms is the finest +#: Tk offers; whatever is left is part of what click-to-photon measures. +TARGET_POLL_MS = 1 +#: The optical target, in pixels, and where it sits on the screen. Deliberately +#: large: the sensor is held on the glass with elastic bands, not aligned on an +#: optical bench, and a target you have to hit precisely is a target you miss. +#: The calibration window and the test overlay use the *same* numbers, because a +#: baseline is only worth anything if it comes from the patch of screen the +#: measurement will use. +TARGET_W_PX = 620 +TARGET_H_PX = 440 +TARGET_RELY = 0.5 +#: Where the text sits in the test overlay when the target owns the middle. +PHOTON_TEXT_RELY = 0.17 + + +def place_target(parent: tk.Misc) -> tk.Label: + """The black/white target, centred, identical in both windows.""" + holder = tk.Frame(parent, width=TARGET_W_PX, height=TARGET_H_PX, + background="#000000") + holder.place(relx=0.5, rely=TARGET_RELY, anchor="center") + holder.pack_propagate(False) + target = tk.Label(holder, background="#000000", foreground="#3a3a3a", + text=tr("photon.target"), font=(FONT_FAMILY, 12, "bold")) + target.pack(fill="both", expand=True) + return target + + +class OpticalCalibrationWindow: + """Full-screen black window with a centred target, for calibrating alone. + + Same geometry as the test-mode target, on purpose: the whole point is that + the baselines come from the patch of screen the measurement will use. Tape + the KY-018 to the middle of the screen once and it stays valid for both. + + Exposes the same three methods the test overlay does — ``show_calibrating``, + ``finish_calibration`` and ``close`` — so the app drives either with one + piece of code. + """ + + def __init__(self, root: tk.Misc, app: LatencyTesterApp): + self.app = app + self._target_bright = False + + window = tk.Toplevel(root) + self.window = window + window.title(f"{tr('app.title')} — {tr('photon.calibrate')}") + window.attributes("-fullscreen", True) + window.attributes("-topmost", True) + window.configure(background="#0b0b0b", cursor="none") + window.bind("", lambda _e: self.app.cancel_optical_calibration()) + window.bind("", lambda _e: self.app.retry_optical_calibration()) + window.bind("", lambda _e: self.app.retry_optical_calibration()) + window.protocol("WM_DELETE_WINDOW", self.app.cancel_optical_calibration) + + top = tk.Frame(window, background="#0b0b0b") + top.place(relx=0.5, rely=0.12, anchor="center") + + self.state = tk.Label(top, text=tr("photon.calibrating_state"), + font=(FONT_FAMILY, 22, "bold"), + background="#0b0b0b", foreground="#8a8a8a") + self.state.pack() + self.hint = tk.Label(top, text=tr("photon.calibrating"), + font=(FONT_FAMILY, 13), background="#0b0b0b", + foreground="#8a8a8a", wraplength=900, + justify="center") + self.hint.pack(pady=(10, 0)) + + self.target_area = place_target(window) + + # Live reading, so aiming is not guesswork: if this number barely moves + # between the black and the white phase, the sensor is not on the target. + bottom = tk.Frame(window, background="#0b0b0b") + bottom.place(relx=0.5, rely=0.9, anchor="center") + tk.Label(bottom, text=tr("live.light"), font=(FONT_FAMILY, 12), + background="#0b0b0b", foreground="#6a6a6a").pack(side="left") + tk.Label(bottom, textvariable=app.light_var, + font=(FONT_FAMILY, 20, "bold"), background="#0b0b0b", + foreground="#c8c8c8").pack(side="left", padx=(12, 0)) + + window.focus_force() + + def show_calibrating(self, bright: bool) -> None: + self._paint(bright) + try: + self.state.configure(text=tr("photon.calibrating_state")) + self.hint.configure(text=tr("photon.calibrating")) + except tk.TclError: + log.debug("calibration window is gone") + + def finish_calibration(self, ok: bool, reason: str) -> None: + self._paint(False) + try: + self.state.configure( + text=tr("photon.calibrating_done") if ok + else tr("photon.calibrating_failed"), + foreground="#4ade80" if ok else "#f87171") + self.hint.configure( + text=reason if ok + else reason + "\n\n" + tr("photon.retry_hint")) + except tk.TclError: + log.debug("calibration window is gone") + if ok: + # A success is a step, not a place to sit: show it, then get out. + self.window.after(1800, self.app.cancel_optical_calibration) + # A failure stays put on purpose. The live reading below is the only + # tool for aiming the sensor, and it is useless once the window closes. + + def _paint(self, bright: bool) -> None: + self._target_bright = bright + try: + self.target_area.configure( + background="#ffffff" if bright else "#000000", + foreground="#b0b0b0" if bright else "#4d4d4d") + self.target_area.update_idletasks() + except tk.TclError: + log.debug("calibration target is gone") + + def close(self) -> None: + try: + self.window.destroy() + except tk.TclError: + log.debug("calibration window was already destroyed") + class TestModeOverlay: - def __init__(self, root: tk.Misc, app: "LatencyTesterApp", target: int): + def __init__(self, root: tk.Misc, app: LatencyTesterApp, target: int): self.app = app self.target = target self.mode = "wait" + self.photon = app.photon_mode + # In Probe-to-Photon the overlay opens in a calibration phase: the two + # baselines have to be sampled from the *same* screen area the test + # will use, or the threshold describes a different patch of screen. + self.calibrating = app.photon_mode + self.target_area: tk.Label | None = None + self._target_bright = False + self._last_click_ns = app.serial.clicks.last_click_ns + self._poll_id: str | None = None + self._alive = True window = tk.Toplevel(root) self.window = window @@ -79,8 +224,81 @@ def __init__(self, root: tk.Misc, app: "LatencyTesterApp", target: int): self._label(inner, tr("test.exit"), 12, "normal", pady=(10, 0)) + if self.photon: + # The target owns the middle of the screen, so the numbers move up. + inner.place_configure(rely=PHOTON_TEXT_RELY) + self._build_target() + self.set_mode("wait") window.focus_force() + if self.photon: + self._poll_click() + + # ------------------------------------------------------------- optical -- + + def _build_target(self) -> None: + """The optical target. No bindings, ever: aiming must not click. + + Placed on the window rather than inside the text block, at exactly the + position and size the calibration window uses. + """ + self.target_area = place_target(self.frame) + + def show_calibrating(self, bright: bool) -> None: + """Paint the target for one calibration baseline.""" + self.calibrating = True + self._set_target_bright(bright) + try: + self.hint.configure(text=tr("photon.calibrating")) + self.state.configure(text=tr("photon.calibrating_state")) + except tk.TclError: + log.debug("overlay is gone during calibration") + + def finish_calibration(self, ok: bool, reason: str) -> None: + """Hand control back to the normal arm/wait cycle, or refuse to start.""" + self._set_target_bright(False) + if ok: + self.calibrating = False + self.mode = "" # force the next set_mode() to repaint + self.set_mode("wait") + return + # Still blocked: the firmware would answer OPT_ERR:NO_CAL anyway, but + # the person in front of a full-screen window deserves to be told why. + try: + self.state.configure(text=tr("photon.calibrating_failed")) + self.hint.configure(text=reason) + except tk.TclError: + log.debug("overlay is gone after a failed calibration") + + def _set_target_bright(self, bright: bool) -> None: + if self.target_area is None or bright == self._target_bright: + return + self._target_bright = bright + try: + self.target_area.configure( + background="#ffffff" if bright else "#000000", + foreground="#b0b0b0" if bright else "#4d4d4d") + # Paint now: everything after this point is the display's own + # latency, which is exactly what this mode is trying to measure. + self.target_area.update_idletasks() + except tk.TclError: + log.debug("optical target is gone") + + def _poll_click(self) -> None: + """Turn the target white as soon as Windows reports a click.""" + if not self._alive: + return + latest = self.app.serial.clicks.last_click_ns + if latest != self._last_click_ns: + self._last_click_ns = latest + # A click during calibration must not disturb the baseline being + # sampled; the calibration owns the target until it is done. + if not self.calibrating: + self._set_target_bright(True) + try: + self._poll_id = self.window.after(TARGET_POLL_MS, self._poll_click) + except tk.TclError: + self._poll_id = None def _label(self, parent: tk.Misc, text: str, size: int, weight: str, pady=0, side: str | None = None) -> tk.Label: @@ -105,6 +323,18 @@ def set_mode(self, mode: str) -> None: background = palette.test_wait state, hint = tr("test.wait_state"), tr("test.wait_hint") + # A fresh arm/re-arm means the next press is still to come, so the + # target goes back to black and the sensor sees a real transition. + # Done before the short-circuit below: a target left white would make + # the firmware answer OPT_ERR:NOT_DARK on the very next press. + if mode in ("armed", "wait") and not self.calibrating: + self._set_target_bright(False) + + # While calibrating, the arm/re-arm text would overwrite the progress + # message; the calibration hands control back when it is finished. + if self.calibrating: + return + if mode == self.mode and self.state.cget("text") == state: return self.mode = mode @@ -116,6 +346,10 @@ def set_mode(self, mode: str) -> None: row.configure(background=background) for label in self.labels: label.configure(background=background, foreground="#ffffff") + if self.target_area is not None: + # Deliberately excluded from the recolour above: black and + # white are the measurement, not decoration. + self._set_target_bright(self._target_bright) self.state.configure(text=state) self.hint.configure(text=hint) except tk.TclError: @@ -135,6 +369,13 @@ def refresh(self) -> None: log.debug("overlay refresh skipped: the window is already gone") def close(self) -> None: + self._alive = False + if self._poll_id is not None: + try: + self.window.after_cancel(self._poll_id) + except tk.TclError: + log.debug("target poll was already cancelled") + self._poll_id = None try: self.window.destroy() except tk.TclError: diff --git a/latency_tester/widgets.py b/latency_tester/widgets.py index 61c036b..9ecc006 100644 --- a/latency_tester/widgets.py +++ b/latency_tester/widgets.py @@ -205,3 +205,51 @@ def y_for(value: float) -> float: anchor="nw", fill=p.fg_muted, font=(FONT_FAMILY, 8)) self.create_text(width - pad, height - pad + 12, text=f"n={len(self._values)}", anchor="ne", fill=p.fg_muted, font=(FONT_FAMILY, 8)) + + +def scrollable_column(parent: tk.Misc, palette: Palette) -> tuple[ttk.Frame, ttk.Frame]: + """A vertically scrolling container, and the frame to put content in. + + Returns ``(outer, inner)``: grid or pack ``outer`` where the column goes, + build into ``inner``. Needed because the right-hand column of the live tab + is taller than a laptop screen once the optical panel is showing, and a + control the user cannot reach might as well not exist. + """ + outer = ttk.Frame(parent) + outer.rowconfigure(0, weight=1) + outer.columnconfigure(0, weight=1) + + canvas = tk.Canvas(outer, highlightthickness=0, borderwidth=0, + background=palette.bg) + canvas.grid(row=0, column=0, sticky="nsew") + bar = ttk.Scrollbar(outer, orient="vertical", command=canvas.yview) + # Always shown. Hiding it when the content fits looks tidier and is a trap: + # gridding the bar shrinks the canvas, which fires , which + # re-decides whether the bar is needed -- a geometry feedback loop that + # pegs the event loop for ever. + bar.grid(row=0, column=1, sticky="ns") + canvas.configure(yscrollcommand=bar.set) + + inner = ttk.Frame(canvas) + window = canvas.create_window((0, 0), window=inner, anchor="nw") + + def _resize(_event=None) -> None: + canvas.configure(scrollregion=canvas.bbox("all")) + # Match the inner frame to the visible width so nothing is clipped + # horizontally; only vertical scrolling is wanted here. + canvas.itemconfigure(window, width=canvas.winfo_width()) + + inner.bind("", _resize) + canvas.bind("", _resize) + + def _wheel(event) -> None: + if inner.winfo_reqheight() <= canvas.winfo_height(): + return + canvas.yview_scroll(-1 * (event.delta // 120), "units") + + # Bound to the canvas subtree rather than globally, so the chart and the + # log keep their own wheel behaviour. + canvas.bind_all("", lambda e: _wheel(e) + if str(e.widget).startswith(str(canvas)) else None, + add="+") + return outer, inner diff --git a/packaging/after_install.txt b/packaging/after_install.txt index 2134a38..36c63e8 100644 --- a/packaging/after_install.txt +++ b/packaging/after_install.txt @@ -2,7 +2,7 @@ Latency Tester is installed. WHAT WAS INSTALLED LatencyTester.exe the dashboard - firmware\ Arduino sketches, v1.0 to v1.4 (flash v1.4) + firmware\ Arduino sketches, v1.0 to v1.5 (flash v1.5) docs\ protocol reference, wiring, bring-up, troubleshooting FIRST RUN, WITHOUT HARDWARE diff --git a/packaging/installer.iss b/packaging/installer.iss index 07acabf..9517f92 100644 --- a/packaging/installer.iss +++ b/packaging/installer.iss @@ -9,7 +9,7 @@ #define AppName "Latency Tester" #ifndef AppVersion - #define AppVersion "1.1.0" + #define AppVersion "1.2.0" #endif #define AppPublisher "PrimeBuild" #define AppURL "https://github.com/PrimeBuild-pc/MouseLatencyTester" diff --git a/pyproject.toml b/pyproject.toml index 8e1c13b..d345983 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "latency-tester" -version = "1.1.0" +version = "1.2.0" description = "Microsecond mouse click latency benchmarking with a Teensy 2.0 probe" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_button_actions.py b/tests/test_button_actions.py new file mode 100644 index 0000000..7f52805 --- /dev/null +++ b/tests/test_button_actions.py @@ -0,0 +1,149 @@ +"""BTN1/BTN2 semantics: the firmware reports, the dashboard decides. + +``_button_action`` is called directly on a stub instead of a real window: the +guards *are* the function, and they must hold without a Tk root. +""" + +import pytest + +pytest.importorskip("tkinter") + +from latency_tester import app as app_module # noqa: E402 +from latency_tester.app import LatencyTesterApp # noqa: E402 + + +class _Serial: + def __init__(self, connected=True, calibrating=False): + self.connected = connected + self.calibrating = calibrating + + +class _Flag: + """Stand-in for a ``tk.BooleanVar``.""" + + def __init__(self, value): + self._value = value + + def get(self): + return self._value + + +class _App: + """Just enough dashboard for the button rules.""" + + press = LatencyTesterApp._button_action + + def __init__(self, connected=True, calibrating=False, test_mode=False, + confirm_btn2=False, samples=0): + self.serial = _Serial(connected, calibrating) + self.test_mode = test_mode + self.confirm_btn2_var = _Flag(confirm_btn2) + self.current_samples = [{"latency_ms": 5.0}] * samples + self.calls: list[str] = [] + self.logs: list[str] = [] + + def enter_test_mode(self): + self.calls.append("enter") + + def exit_test_mode(self): + self.calls.append("exit") + + def reset_stats(self): + self.calls.append("reset") + + def log(self, message): + self.logs.append(message) + + +def test_btn1_enters_test_mode_when_idle(): + app = _App() + app.press(1) + assert app.calls == ["enter"] + + +def test_btn1_leaves_test_mode_when_it_is_running(): + app = _App(test_mode=True) + app.press(1) + assert app.calls == ["exit"] + + +def test_btn2_clears_the_live_run_outside_test_mode(): + app = _App() + app.press(2) + assert app.calls == ["reset"] + + +def test_btn2_is_refused_inside_test_mode(): + """The whole point: a stray press must never wipe a running test.""" + app = _App(test_mode=True) + app.press(2) + assert app.calls == [] + assert len(app.logs) == 1 + + +@pytest.mark.parametrize("index", [1, 2]) +def test_nothing_acts_while_calibrating(index): + app = _App(calibrating=True) + app.press(index) + assert app.calls == [] + assert len(app.logs) == 1 + + +@pytest.mark.parametrize("index", [1, 2]) +def test_nothing_acts_while_disconnected(index): + app = _App(connected=False) + app.press(index) + assert app.calls == [] + + +def test_an_unknown_button_does_nothing(): + app = _App() + app.press(3) + assert app.calls == [] + + +@pytest.fixture +def answer(monkeypatch): + """Replace the confirmation dialog with a scripted answer.""" + box = {"asked": 0, "reply": True} + + def fake_askyesno(*_args, **_kwargs): + box["asked"] += 1 + return box["reply"] + + monkeypatch.setattr(app_module.messagebox, "askyesno", fake_askyesno) + return box + + +def test_btn2_asks_first_when_the_confirmation_is_on(answer): + """Default-on, because BTN2 sits on the same desk as the mouse being + tested and a knock must not cost a run.""" + answer["reply"] = True + app = _App(confirm_btn2=True, samples=4) + app.press(2) + assert answer["asked"] == 1 + assert app.calls == ["reset"] + + +def test_a_refused_confirmation_keeps_the_run(answer): + answer["reply"] = False + app = _App(confirm_btn2=True, samples=4) + app.press(2) + assert answer["asked"] == 1 + assert app.calls == [] + assert len(app.logs) == 1 + + +def test_btn2_with_the_confirmation_on_but_nothing_to_lose(answer): + """No samples yet means nothing to lose: reset without asking.""" + app = _App(confirm_btn2=True, samples=0) + app.press(2) + assert answer["asked"] == 0 + assert app.calls == ["reset"] + + +def test_the_confirmation_can_be_switched_off(answer): + app = _App(confirm_btn2=False, samples=4) + app.press(2) + assert answer["asked"] == 0 + assert app.calls == ["reset"] diff --git a/tests/test_database.py b/tests/test_database.py index 12fe4cb..3072470 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -170,6 +170,8 @@ def test_save_and_reload_a_run_with_all_metadata(db, device): "target_samples": 50, "calibration_us": 267, "light_start": 640, "light_end": 651, "dpi": 1600, "debounce_setting": "0 ms", "teensy_firmware": "v1.3", "is_demo": 0, + "mode": "probe_to_pc", "optical_dark": None, "optical_bright": None, + "optical_threshold": None, "started_at": "2026-02-01T10:00:00", "ended_at": "2026-02-01T10:04:00", } values = [5.1, 5.4, 6.0, 5.2] diff --git a/tests/test_demo.py b/tests/test_demo.py index b73b673..02196f2 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -83,3 +83,90 @@ def test_demo_reports_light_and_is_flagged_as_demo(): finally: service.close() assert lights and 0 <= lights[0] <= 1023 + + +def test_demo_reports_an_optical_calibration_on_request(): + service = SerialService() + service.attach(DemoDevice(seed=7), "DEMO", is_demo=True) + try: + service.send(protocol.CMD_CAL_DARK) + service.send(protocol.CMD_CAL_BRIGHT) + service.send(protocol.CMD_CAL_REPORT) + cals = [e for e in collect(service, 0.9) if e.kind == "optical_cal"] + assert cals + payload = cals[-1] + # The demo must produce a calibration the dashboard would accept, or the + # optical UI cannot be exercised without hardware. + assert protocol.optical_threshold(payload.payload["dark"], + payload.payload["bright"]) is not None + assert payload.payload["rising"] is True + finally: + service.close() + + +def test_demo_switches_to_optical_samples(): + """Probe-to-Photon must be reachable without hardware, and must produce + ``optical`` events rather than ``latency`` ones.""" + service = SerialService() + service.attach(DemoDevice(seed=7), "DEMO", is_demo=True) + try: + service.send(protocol.CMD_PHOTON_ON) + service.send(protocol.CMD_TEST_MODE_ON) + events = collect(service, 1.8) + kinds = [e.kind for e in events] + assert "photon_on" in kinds + optical = [e for e in events if e.kind == "optical"] + assert optical + assert "latency" not in kinds + # Every optical sample carries the raw reading that produced it. + assert all(e.payload["raw_optical"] > 0 for e in optical) + finally: + service.close() + + +def test_demo_optical_latencies_are_slower_than_serial_ones(): + """Click-to-photon includes the display, so it cannot be faster.""" + service = SerialService() + service.attach(DemoDevice(seed=11), "DEMO", is_demo=True) + try: + service.send(protocol.CMD_PHOTON_ON) + service.send(protocol.CMD_TEST_MODE_ON) + values = [e.payload["latency_ms"] + for e in collect(service, 1.8) if e.kind == "optical"] + assert values + assert all(v > 8.0 for v in values) + finally: + service.close() + + +def test_demo_leaves_optical_mode_on_request(): + service = SerialService() + service.attach(DemoDevice(seed=7), "DEMO", is_demo=True) + try: + service.send(protocol.CMD_PHOTON_ON) + collect(service, 0.3) + service.send(protocol.CMD_PHOTON_OFF) + service.send(protocol.CMD_TEST_MODE_ON) + events = collect(service, 1.6) + assert "photon_off" in [e.kind for e in events] + assert [e for e in events if e.kind == "latency"] + assert not [e for e in events if e.kind == "optical"] + finally: + service.close() + + +def test_demo_reset_clears_the_optical_counter(): + service = SerialService() + service.attach(DemoDevice(seed=7), "DEMO", is_demo=True) + try: + service.send(protocol.CMD_PHOTON_ON) + service.send(protocol.CMD_TEST_MODE_ON) + first = [e for e in collect(service, 1.6) if e.kind == "optical"] + assert first + service.send(protocol.CMD_RESET) + after = [e for e in collect(service, 1.2) if e.kind == "optical"] + assert after + # Numbering restarts, so a reset really is a new run. + assert after[0].payload["firmware_n"] < first[-1].payload["firmware_n"] + finally: + service.close() diff --git a/tests/test_photon.py b/tests/test_photon.py new file mode 100644 index 0000000..22ff964 --- /dev/null +++ b/tests/test_photon.py @@ -0,0 +1,291 @@ +"""Probe-to-Photon: wire format, threshold maths, and how a run is archived. + +The optical mode is deliberately separate from Probe-to-PC everywhere, and the +tests here are what keeps it that way: an ``OPT:`` line must never decode as a +``LAT:`` one, and a stored run must always say which mode produced it. +""" + +import sqlite3 + +import pytest + +from latency_tester import protocol +from latency_tester.constants import MODE_PHOTON, MODE_PROBE_PC +from latency_tester.database import SCHEMA_VERSION, LatencyDB, run_mode + + +# ------------------------------------------------------------------ parsing -- +def test_an_optical_result_decodes_with_its_raw_reading(): + event = protocol.parse_line( + "OPT:23.417,raw:851,min:19.200,max:31.008,avg:24.100,n:7") + assert event.kind == "optical" + assert event.payload["latency_ms"] == pytest.approx(23.417) + assert event.payload["raw_optical"] == 851 + assert event.payload["firmware_n"] == 7 + + +def test_an_optical_result_is_never_mistaken_for_a_serial_one(): + """The whole point of a separate token.""" + optical = protocol.parse_line( + "OPT:23.417,raw:851,min:19.200,max:31.008,avg:24.100,n:7") + serial = protocol.parse_line( + "LAT:6.400,min:5.000,max:9.000,avg:6.500,n:3") + assert optical.kind == "optical" + assert serial.kind == "latency" + assert optical.kind != serial.kind + + +def test_the_optical_calibration_decodes_including_its_direction(): + event = protocol.parse_line( + "OPT_CAL:dark:118,bright:874,threshold:496,rising:1") + assert event.kind == "optical_cal" + assert event.payload == {"dark": 118, "bright": 874, + "threshold": 496, "rising": True} + + +def test_a_falling_sensor_is_reported_as_such(): + event = protocol.parse_line( + "OPT_CAL:dark:900,bright:120,threshold:510,rising:0") + assert event.payload["rising"] is False + + +@pytest.mark.parametrize("line, kind", [ + ("OPT_TIMEOUT", "optical_timeout"), + ("OPT_ERR:NO_CAL", "optical_error"), + ("OPT_ERR:NOT_DARK", "optical_error"), + ("OPT_ERR:SEPARATION", "optical_error"), + ("OPT_DROP:OUT_OF_RANGE:412.500", "optical_drop"), + ("PHOTON:ON", "photon_on"), + ("PHOTON:OFF", "photon_off"), +]) +def test_the_optical_control_tokens_decode(line, kind): + assert protocol.parse_line(line).kind == kind + + +def test_a_timeout_carries_no_sample(): + """A timeout must not reach the archive as a measurement.""" + assert protocol.parse_line("OPT_TIMEOUT").payload is None + + +def test_the_error_reason_survives(): + assert protocol.parse_line("OPT_ERR:NOT_DARK").payload == "NOT_DARK" + + +@pytest.mark.parametrize("line", [ + "OPT:23.417,raw:851,min:19.200,max:31.008,avg:24.100", # no n + "OPT:23.417,min:19.200,max:31.008,avg:24.100,n:7", # no raw + "OPT_CAL:dark:118,bright:874,threshold:496", # no direction + "OPT_CAL:dark:118,bright:874,threshold:496,rising:2", # not a flag +]) +def test_a_malformed_optical_line_is_logged_not_guessed(line): + assert protocol.parse_line(line).kind == "log" + + +# ---------------------------------------------------------------- threshold -- +def test_the_threshold_sits_between_the_two_baselines(): + threshold, rising = protocol.optical_threshold(100, 900) + assert threshold == 500 + assert rising is True + + +def test_a_sensor_that_falls_with_light_is_handled(): + threshold, rising = protocol.optical_threshold(900, 100) + assert threshold == 500 + assert rising is False + + +def test_baselines_that_are_too_close_have_no_usable_threshold(): + """Blocking the test is the point: a threshold inside the noise would turn + every sample into a coin toss.""" + assert protocol.optical_threshold(500, 520) is None + assert protocol.optical_threshold(520, 500) is None + + +def test_exactly_the_minimum_separation_is_accepted(): + gap = protocol.OPTICAL_MIN_SEPARATION + assert protocol.optical_threshold(100, 100 + gap) is not None + assert protocol.optical_threshold(100, 100 + gap - 1) is None + + +def test_the_threshold_matches_what_the_firmware_reports(): + """The dashboard pre-validates a calibration, so both must agree.""" + line = "OPT_CAL:dark:118,bright:874,threshold:496,rising:1" + payload = protocol.parse_line(line).payload + computed, rising = protocol.optical_threshold(payload["dark"], + payload["bright"]) + assert computed == payload["threshold"] + assert rising is payload["rising"] + + +# ----------------------------------------------------------------- archive -- +@pytest.fixture +def db(tmp_path): + database = LatencyDB(tmp_path / "photon.db") + yield database + database.close() + + +@pytest.fixture +def device(db): + return db.create_device(name="ATK F1") + + +def test_a_photon_run_keeps_its_mode_and_its_calibration(db, device): + run_id = db.save_run( + { + "device_id": device, "name": "photon 1000 Hz", + "polling_rate_hz": 1000, "connection_mode": "Wired", + "calibration_us": 267, "notes": "curtains shut", + "mode": MODE_PHOTON, "optical_dark": 118, + "optical_bright": 874, "optical_threshold": 496, + }, + [ + {"latency_ms": 23.4, "raw_optical": 851}, + {"latency_ms": 24.9, "raw_optical": 860}, + ], + ) + run = db.get_run(run_id) + assert run_mode(run) == MODE_PHOTON + assert (run["optical_dark"], run["optical_bright"], + run["optical_threshold"]) == (118, 874, 496) + assert run["calibration_us"] == 267 + assert run["polling_rate_hz"] == 1000 + assert run["connection_mode"] == "Wired" + assert run["notes"] == "curtains shut" + assert [s["raw_optical"] for s in db.get_samples(run_id)] == [851, 860] + assert db.get_latencies(run_id) == pytest.approx([23.4, 24.9]) + + +def test_a_probe_to_pc_run_still_stores_nothing_optical(db, device): + run_id = db.save_run({"device_id": device, "name": "serial"}, + [{"latency_ms": 6.4}]) + run = db.get_run(run_id) + assert run_mode(run) == MODE_PROBE_PC + assert run["optical_dark"] is None + assert run["optical_threshold"] is None + assert db.get_samples(run_id)[0]["raw_optical"] is None + + +def test_both_modes_live_in_the_same_archive(db, device): + serial_id = db.save_run({"device_id": device, "name": "serial"}, + [{"latency_ms": 6.4}]) + photon_id = db.save_run( + {"device_id": device, "name": "photon", "mode": MODE_PHOTON, + "optical_dark": 100, "optical_bright": 900, "optical_threshold": 500}, + [{"latency_ms": 23.4, "raw_optical": 880}]) + + modes = {run["id"]: run_mode(run) for run in db.list_runs()} + assert modes == {serial_id: MODE_PROBE_PC, photon_id: MODE_PHOTON} + + +def test_an_unknown_mode_string_is_stored_verbatim(db, device): + """No silent rewriting: an archive written by a newer build must survive a + round trip through an older one.""" + run_id = db.save_run( + {"device_id": device, "name": "future", "mode": "probe_to_something"}, + [{"latency_ms": 1.0}]) + assert db.get_run(run_id)["mode"] == "probe_to_something" + + +def test_run_mode_defaults_when_the_column_is_missing(): + assert run_mode({}) == MODE_PROBE_PC + assert run_mode({"mode": ""}) == MODE_PROBE_PC + assert run_mode({"mode": MODE_PHOTON}) == MODE_PHOTON + + +# --------------------------------------------------------------- migration -- +# The schema exactly as it shipped at user_version 2, before Probe-to-Photon. +# Written out rather than produced by dropping columns, so the test does not +# depend on the SQLite build supporting ALTER TABLE ... DROP COLUMN. +V2_SCHEMA = """ +CREATE TABLE devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + manufacturer TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + serial_number TEXT NOT NULL DEFAULT '', + switch_type TEXT NOT NULL DEFAULT '', + default_firmware TEXT NOT NULL DEFAULT '', + hardware_id TEXT NOT NULL DEFAULT '' +); +CREATE TABLE runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id INTEGER NOT NULL, + name TEXT NOT NULL, + polling_rate_hz INTEGER, + connection_mode TEXT NOT NULL DEFAULT '', + mouse_firmware TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + target_samples INTEGER NOT NULL DEFAULT 0, + calibration_us INTEGER NOT NULL DEFAULT 0, + light_start INTEGER, + light_end INTEGER, + started_at TEXT NOT NULL, + ended_at TEXT NOT NULL, + dpi INTEGER, + debounce_setting TEXT NOT NULL DEFAULT '', + teensy_firmware TEXT NOT NULL DEFAULT '', + is_demo INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(device_id) REFERENCES devices(id) ON DELETE CASCADE +); +CREATE TABLE samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL, + seq INTEGER NOT NULL, + latency_ms REAL NOT NULL, + timestamp TEXT NOT NULL, + FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE CASCADE +); +""" + + + +def test_a_v2_archive_is_upgraded_without_losing_runs(tmp_path): + """The user's archive is never recreated: the optical columns are added to + the rows that are already there, and every old run reads as Probe-to-PC.""" + path = tmp_path / "v2.db" + legacy = sqlite3.connect(path) + legacy.executescript(V2_SCHEMA) + legacy.execute( + "INSERT INTO devices(name, manufacturer, model, notes, created_at, " + "serial_number, switch_type, default_firmware, hardware_id) " + "VALUES('Old Mouse','Acme','M1','keep me','2025-01-01T00:00:00'," + "'','','','373B:11D9')") + legacy.execute( + "INSERT INTO runs(device_id, name, polling_rate_hz, connection_mode, " + "mouse_firmware, notes, target_samples, calibration_us, light_start, " + "light_end, started_at, ended_at, dpi, debounce_setting, " + "teensy_firmware, is_demo) " + "VALUES(1,'1000 Hz',1000,'Wired','fw1','n',50,267,600,610," + "'2025-01-02T10:00:00','2025-01-02T10:05:00',1600,'0 ms','v1.4',0)") + legacy.executemany( + "INSERT INTO samples(run_id, seq, latency_ms, timestamp) VALUES(1,?,?,?)", + [(i + 1, 5.0 + i / 10, "2025-01-02T10:00:00") for i in range(20)]) + legacy.execute("PRAGMA user_version = 2") + legacy.commit() + legacy.close() + + run_id = 1 + upgraded = LatencyDB(path) + try: + assert upgraded.schema_version == SCHEMA_VERSION + run = upgraded.get_run(run_id) + assert run["name"] == "1000 Hz" + assert run["calibration_us"] == 267 + assert len(upgraded.get_latencies(run_id)) == 20 + assert run_mode(run) == MODE_PROBE_PC + assert run["optical_threshold"] is None + assert upgraded.get_samples(run_id)[0]["raw_optical"] is None + finally: + upgraded.close() + + +def test_migrating_twice_changes_nothing(tmp_path): + database = LatencyDB(tmp_path / "twice.db") + try: + assert database.migrate() == SCHEMA_VERSION + assert database.migrate() == SCHEMA_VERSION + finally: + database.close()