mirror of
https://github.com/VioletTides/KnockThru.git
synced 2026-10-17 16:02:34 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a90745e25 | ||
|
|
ff3f150e00 | ||
|
|
c6a7f8c2cc | ||
|
|
ab331f0a85 | ||
|
|
9a0d044d3c | ||
|
|
e01afc9833 | ||
|
|
fae097477b | ||
|
|
de976ad256 | ||
|
|
f880edc51b | ||
|
|
abe798d798 | ||
|
|
5ddc7c4787 | ||
|
|
5d0ac36d02 | ||
|
|
a8a1e50f00 | ||
|
|
0e1fd2afe1 | ||
|
|
fce4b03b29 | ||
|
|
ec26507e7c | ||
|
|
8fdae6111f | ||
|
|
cef1a82ac7 | ||
|
|
4489a6334f | ||
|
|
1467a4af6f | ||
|
|
d5ca7dbe42 | ||
|
|
126d97ee33 | ||
|
|
67d6605385 | ||
|
|
a3b12616d2 | ||
|
|
ba6d5cea29 | ||
|
|
5be8ee2baf | ||
|
|
0862852cbd | ||
|
|
361c270089 | ||
|
|
56833d5671 |
No files matched your search
@@ -3,3 +3,4 @@
|
||||
/.zig-cache/
|
||||
*.log
|
||||
/compile_commands.json
|
||||
/CLAUDE.md
|
||||
@@ -1,37 +0,0 @@
|
||||
# KnockThru
|
||||
|
||||
A double knock toggles Steam Frame passthrough (Room View). A triple knock switches Room View between translucent and opaque. The app runs on the Frame as a SteamVR overlay app with no visible surface.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/knock_detector.*`: knock detection. Pure logic with no OpenVR types, so the host tests can replay recordings.
|
||||
- `src/room_view.*`: clicks the Room View button through Steam's CEF debug port (8080 on the Frame).
|
||||
- `src/style.*`: switches the SteamVR setting `camera.roomViewStyle` (3 is translucent, 4 is opaque).
|
||||
- `src/config.*`: loads `assets/config.json`, then `~/.config/knockthru/config.json`, then `calibration.json`.
|
||||
- `src/update.*`: the GitHub release check, the install that checks the ZIP's SHA-256, and the file swap. `--update` and the Settings header use it.
|
||||
- `src/report.*`, `src/report_server.*`: the bug report. The report card's QR code opens a page the headset serves on the local Wi-Fi.
|
||||
- `src/main.cpp`: the CLI commands and the resident app (`--run`).
|
||||
- `scripts/install.sh`: the installer for people without a PC. Each release attaches it, and the README runs it through `curl | sh`.
|
||||
- `tests/data/*.csv.gz`: pose recordings from the Frame. `docs/knock-signal.md` explains each one and how the detector was tuned.
|
||||
|
||||
## Commands
|
||||
|
||||
- Host build and tests: `cmake -S . -B build && cmake --build build && ctest --test-dir build`
|
||||
- Frame build: `scripts/build-arm64.sh` (Zig 0.16.0), output in `build-arm64/bin/`
|
||||
- Release ZIP and FrameDrop manifest: `node scripts/package.mjs --build-dir build-arm64 --version X.Y.Z`
|
||||
- README images: `python3 scripts/readme_media.py` (needs Pillow, numpy, ffmpeg, Chromium, and the host build). The controller and headset sprites in `images/cad/` come from `scripts/cad_sprites.py <CAD folder>`, which needs cadquery and Valve's STEP files.
|
||||
- Tuning sweep: `KNOCKTHRU_TEST_SET="confirmed_peak=30" build/bin/detector_test`. Add `KNOCKTHRU_TEST_VERBOSE=1` to print every knock and event.
|
||||
- Replay a recording from a bug report: `build/bin/detector_test knockthru-knocks.csv.gz`. Pass the reporter's calibrated peaks through `KNOCKTHRU_TEST_SET`.
|
||||
|
||||
## Testing on the Frame
|
||||
|
||||
- `ssh frame` reaches the headset. The dev install lives in `~/knockthru-dev/`.
|
||||
- To deploy, run `knockthru-ctl --uninstall` first, because a running binary cannot be overwritten. Then copy `knockthru` and `knockthru-ctl`, and run `./knockthru` to install and start.
|
||||
- The app log is `~/.config/knockthru/application.log`.
|
||||
|
||||
## Rules
|
||||
|
||||
- `knockthru-ctl` must stay a byte-identical copy of `knockthru`. SteamVR identifies apps by executable path, so management commands re-exec through `knockthru-ctl` to keep the resident app's identity.
|
||||
- Poses are valid only in the standing tracking universe, not the raw one.
|
||||
- Any detector change must keep `ctest` passing: no actions in `play` and `noise`, and no wrong actions in `mixed`.
|
||||
- A release goes out when a `v*` tag is pushed. Tag only with Robin's approval.
|
||||
+5
-2
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(KnockThru VERSION 1.0.0 LANGUAGES CXX)
|
||||
project(KnockThru VERSION 1.0.2 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
@@ -24,10 +24,13 @@ target_include_directories(knockthru_ui SYSTEM PRIVATE third_party/stb third_par
|
||||
target_link_libraries(knockthru_ui PUBLIC knockthru_core)
|
||||
target_compile_options(knockthru_ui PRIVATE -Wall -Wextra -Wpedantic)
|
||||
|
||||
add_executable(knockthru src/main.cpp src/room_view.cpp src/style.cpp src/sound.cpp src/overlay_texture.cpp)
|
||||
add_executable(knockthru src/main.cpp src/app.cpp src/steamvr.cpp src/poses.cpp src/diagnostics.cpp src/install.cpp src/room_view.cpp src/style.cpp src/sound.cpp src/overlay_texture.cpp)
|
||||
target_include_directories(knockthru SYSTEM PRIVATE third_party/vulkan/include)
|
||||
target_link_libraries(knockthru PRIVATE knockthru_core knockthru_ui openvr_loader Threads::Threads ${CMAKE_DL_LIBS})
|
||||
target_compile_definitions(knockthru PRIVATE KNOCKTHRU_VERSION="${PROJECT_VERSION}")
|
||||
# The host build is never shipped, so it always compiles the test hook and CI catches breaks in it.
|
||||
# Releases come from scripts/build-arm64.sh, which leaves the hook out unless KNOCKTHRU_TEST_HOOK=1.
|
||||
target_compile_definitions(knockthru PRIVATE KNOCKTHRU_TEST_HOOK)
|
||||
target_compile_options(knockthru PRIVATE -Wall -Wextra -Wpedantic)
|
||||
add_custom_command(TARGET knockthru POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/assets" "$<TARGET_FILE_DIR:knockthru>"
|
||||
|
||||
@@ -52,6 +52,8 @@ Any of these three knocks works. Use the same kind for every knock in a set.
|
||||
|
||||

|
||||
|
||||
A surface knock also works on your leg. Keep the controller where the headset cameras can see it, because knocks out of view get missed more often.
|
||||
|
||||
Pause for a moment, then knock at a steady pace. After your last knock, KnockThru waits a moment in case another one follows.
|
||||
|
||||

|
||||
@@ -191,7 +193,7 @@ The log is `~/.config/knockthru/application.log`.
|
||||
Copy the Linux ARM64 ZIP from the releases page to the Frame, then run:
|
||||
|
||||
```sh
|
||||
unzip -oq knockthru-1.0.0-linux-arm64.zip -d ~/KnockThru
|
||||
unzip -oq knockthru-1.0.2-linux-arm64.zip -d ~/KnockThru
|
||||
~/KnockThru/knockthru --install
|
||||
```
|
||||
|
||||
@@ -212,7 +214,7 @@ Steam Frame build (needs Zig 0.16.0) and release ZIP (needs Node.js):
|
||||
|
||||
```sh
|
||||
scripts/build-arm64.sh
|
||||
node scripts/package.mjs --build-dir build-arm64 --version 1.0.0
|
||||
node scripts/package.mjs --build-dir build-arm64 --version 1.0.2
|
||||
```
|
||||
|
||||
README images (needs Pillow, numpy, ffmpeg, and Chromium, after the host build):
|
||||
@@ -233,8 +235,6 @@ To replay the knock recording from a bug report, unzip the report and run this:
|
||||
build/bin/detector_test knockthru-knocks.csv.gz
|
||||
```
|
||||
|
||||
`docs/knock-signal.md` explains how knock detection works and how it was tuned.
|
||||
|
||||
</details>
|
||||
|
||||
## Credits
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
# Calibration redo plan
|
||||
|
||||
Branch `calibration-redo`, worktree `../KnockThru-calibration`. The UI comes first. The logic changes come second and stay small: sensitivity and threshold handling.
|
||||
|
||||
## What exists now
|
||||
|
||||
- `src/calibrator.*` collects 10 knocks of one kind within 90 s with lowered thresholds. It saves `0.6 * weakest peak` to `calibration.json`, clamped to 50..150 m/s² for surface and 20..150 for together and headset.
|
||||
- `src/config.cpp` `apply_calibration` maps the saved values onto the detector. Together and headset share `confirmed_peak`, so the softer of the two wins.
|
||||
- `src/panel.cpp` `calibrate_screen` has two states. One is a list of three kinds with a Start button each. The other is a live screen with a "3 of 10" counter, ten dots, a seconds countdown, and Cancel. A "Saved" banner follows.
|
||||
- Calibration counts single knocks, not doubles or triples. `Calibrator::add` takes every knock from `take_knocks()`, which logs any knock more than `merge_s` (80 ms) after the last one. The current screen says "10 times, at least 1 second apart" but never says "one knock at a time", so it reads as if it wants doubles.
|
||||
- The panel is static. `Panel::render` takes no time input. `DashboardPanel::draw` in `src/main.cpp` redraws at most every 50 ms when dirty and every 500 ms otherwise.
|
||||
- `tests/panel_preview.cpp` writes PNGs of sample states. Nothing previews motion.
|
||||
|
||||
## Phase 1: UI
|
||||
|
||||
### Prototype on the readme branch
|
||||
|
||||
Branch `readme-revamp` (worktree `../KnockThru-readme`, local only, still in progress) already scripts the knock animations in Python for the README GIFs. The panel animations port from it, so the motion matches the README.
|
||||
|
||||
- `scripts/readme_media.py` has the easing curves (`ease_in`, `ease_out`, `ease_in_out`, `ease_out_back`) and a `keyframes` interpolator.
|
||||
- The same file has the knock motion: `knock_motion` (pull back, contact, rebound, settle), `impact` and `burst` (accent lines flying out from the contact), `buzz` (a shake per haptic buzz), and `knock_pair` (two controllers swinging from the wrist). One timeline drives them: first knock at 0.5 s, 0.3 s between knocks, 0.4 s to the action.
|
||||
- `knock_kinds()` in that file draws one card per knock kind, which is the layout the "Pick a kind" step needs.
|
||||
- The README loops play a double, then a triple. Calibration wants one knock, then a rest. `knock_motion` already takes the knock count, so the calibration loop calls it with one knock and a longer pause. Only the "Try it" step shows a double.
|
||||
- `scripts/cad_sprites.py` renders the controller and headset sprites in `images/cad/` from Valve's CAD files, in the logo style.
|
||||
- The CAD-derived drawings use CC BY-NC-SA 4.0, like the logo. `THIRD_PARTY_NOTICES.md` on that branch says so. Any sprite shipped in the app needs the same notice.
|
||||
|
||||
`Canvas` cannot load PNGs or draw rotated images. It can fill polygons, and `logo_shapes.hpp` already bakes the logo into polygons. Tracing the sprites into a polygon header the same way lets the panel scale and rotate them by transforming points. Loading PNGs would instead need a vendored image decoder and a rotated blit.
|
||||
|
||||
### Animation groundwork
|
||||
|
||||
`calibration-redo` never merges `readme-revamp` and never copies its files. The README work stays unpublished until it is ready on its own. The readme worktree is a read-only reference.
|
||||
|
||||
Release order: `calibration-redo` merges and ships first, with the traced sprites in the panel. `readme-revamp` follows and adds README sections for calibration. `panel_preview` frames from this branch can feed those sections.
|
||||
|
||||
1. Port the easing curves, `keyframes`, and the knock motion to C++ by hand, beside the canvas code. `scripts/readme_media.py` in the readme worktree stays the reference for timing.
|
||||
2. Write a tracer that reads the sprite PNGs from a path given on the command line, such as `../KnockThru-readme/images/cad/`. It writes a polygon header, as `logo_shapes.hpp` does for the logo. Only the header lands on `calibration-redo`.
|
||||
3. Add the new header to the "Logo" section of `THIRD_PARTY_NOTICES.md`, which already covers CAD-derived drawings under CC BY-NC-SA 4.0.
|
||||
|
||||
Then the render loop:
|
||||
|
||||
1. Pass the current time into `Panel::render`.
|
||||
2. Add an "animating" flag that the panel reports back. While it is set, `DashboardPanel::draw` redraws at the 50 ms rate even without input.
|
||||
3. Log how long one `render` plus texture upload takes on the Frame. The redraw rate depends on that number, which nobody has measured yet.
|
||||
4. Extend `panel_preview` to write a frame sequence for each animated state, so motion can be reviewed on the host.
|
||||
|
||||
### Wizard steps
|
||||
|
||||
1. **Pick a kind:** three cards, each with a short looping animation of that knock, ported from `knock_kinds()`.
|
||||
2. **Show how:** one screen per kind with the single-knock animation large. The instruction says "one knock, then wait" in plain words. Ask for a normal knock, not the softest one.
|
||||
3. **Record:** a pacing cue (a ring that fills over about a second) shows when the next knock can come. Each counted knock pops a dot and pulses a ring. A live strength bar shows each knock's peak against the current threshold. A knock of the wrong kind gets a hint ("That looked like a surface knock") instead of silence.
|
||||
4. **Review:** plot the recorded peaks with the threshold line. The sensitivity slider sits under the plot, and the line moves as the slider moves.
|
||||
5. **Try it:** this step is required before saving. The user does one double knock at the chosen threshold. The panel shows whether it would have toggled passthrough, without toggling it. A pass unlocks Save. A miss offers to raise sensitivity or redo the recording.
|
||||
|
||||
### Sensitivity slider
|
||||
|
||||
- The slider is continuous, with notches at minimum, middle, and maximum.
|
||||
- Releasing near a notch snaps the knob onto it, with an eased slide.
|
||||
- Dragging works through the overlay's mouse events. `DashboardPanel` currently turns only clicks into panel commands, so it needs press, move, and release.
|
||||
|
||||
Cancel and Back stay on every step. Leaving the panel mid-recording keeps the current behavior: the recording continues until done or timed out.
|
||||
|
||||
### UI acceptance
|
||||
|
||||
- Every step renders in `panel_preview`, including at least one mid-animation frame per step.
|
||||
- On the Frame, a full calibration of each kind works from the panel alone.
|
||||
|
||||
### Checkpoint, 2026-10-15
|
||||
|
||||
Phase 1 runs on the Frame, and Robin checked it in the headset. Testing it turned up problems beyond the UI, fixed on this branch:
|
||||
|
||||
- Pose sampling runs on its own thread, so panel drawing cannot stretch the gaps between samples.
|
||||
- Calibration counts only deliberate knocks, as `docs/knock-signal.md` describes under "Calibration knocks".
|
||||
- The record bars show approach speed, because the top tenth of spikes ran 2.8 to 8.3 times the bottom tenth in the knock recordings.
|
||||
- Panel frames take about 1.5 ms on the host, and screen fades about 3.7 ms.
|
||||
|
||||
The review chart, the threshold, and the slider still use the spike size. The app logs every knock heard while calibrating, for tuning phase 2.
|
||||
|
||||
## Phase 2: logic
|
||||
|
||||
1. Replace "60 percent of the weakest knock" with a sensitivity value from 0 to 1. It scales a low percentile of the recorded peaks, not the single minimum. A rebound lands 83 to 99 ms after a knock (`docs/knock-signal.md`), past the 80 ms `merge_s`, so it can count as a weak extra knock and drag the minimum down. The test data sets the fractions at 0, 0.5, and 1.
|
||||
2. Store the recorded peaks and the sensitivity in `calibration.json`, so the slider can move the threshold later without a new recording.
|
||||
3. Give together and headset knocks separate thresholds. Today `judge` in `src/knock_detector.cpp` checks both against `confirmed_peak`, and `apply_calibration` keeps the softer of the two. Add a headset threshold to `DetectorConfig`, keep `confirmed_peak` for together knocks, and keep the existing `config.json` keys working.
|
||||
4. Let the user change sensitivity later without recording again.
|
||||
5. Lower the knock count if the robust statistic allows it. The test data decides the number.
|
||||
|
||||
Later: a "Reset to defaults" action that deletes the saved thresholds.
|
||||
|
||||
The existing floors stay: surface never below 50 m/s², together and headset never below 20. `docs/knock-signal.md` explains why.
|
||||
|
||||
### Phase 2 status, 2026-10-15
|
||||
|
||||
Robin picked "drag the line" (option 1 of two mockups) for the sensitivity step, and asked for whatever testing showed best against movement. Done on this branch:
|
||||
|
||||
- The chart, the line, and the slider use approach speed. Each kind saves a spike threshold and an approach threshold, plus the slider position and the recorded knocks.
|
||||
- Together and headset knocks have separate thresholds.
|
||||
- `docs/knock-signal.md` ("Calibrated thresholds") has the replay results at each notch.
|
||||
|
||||
Later the same day, testing showed the slider earned little: its middle and most sensitive ends gave nearly the same replay results, and its least sensitive end only lost real knocks. Robin chose to drop it. The walkthrough is four steps (pick, learn, record, try), and the approach threshold is fixed at 55 percent of the second slowest knock.
|
||||
|
||||
Not done: lowering the knock count.
|
||||
|
||||
### Logic acceptance
|
||||
|
||||
- `ctest` passes: no actions in `play` and `noise`, and no wrong actions in `mixed`.
|
||||
- A host test calibrates each kind on its recording, applies the saved thresholds, and replays every recording.
|
||||
- `mixed` still sorts together and headset knocks correctly with the split thresholds.
|
||||
- `--calibrate` on the command line keeps working with the new logic.
|
||||
|
||||
## Decisions
|
||||
|
||||
- No sensitivity slider. The approach threshold is 55 percent of the second slowest recorded knock (see "Phase 2 status").
|
||||
- The "Try it" step is required before saving.
|
||||
- Together and headset knocks get separate thresholds.
|
||||
- Recording opens with a 3 s get-ready ring, and each knock must wait for the 1 s pacing ring.
|
||||
@@ -1,129 +0,0 @@
|
||||
# Knock signal findings
|
||||
|
||||
Source: five 45 second recordings in `tests/data/`, made with `knockthru --record` on a Steam Frame on 2026-10-14. In the three knock files, the wearer knocked in pairs for the full 45 seconds. Exact counts are unknown, and the estimate is about 20 pairs per file.
|
||||
|
||||
## What the Frame provides
|
||||
|
||||
- `GetDeviceToAbsoluteTrackingPose` returns valid poses only in `TrackingUniverseStanding`. `TrackingUniverseRawAndUncalibrated` returns `bPoseIsValid = false` for every device.
|
||||
- Headset velocity updates at 1000 Hz. Each controller updates at about 233 Hz (one sample every 4.3 ms).
|
||||
- The Frame has no `/dev/hidraw*` devices, so raw controller accelerometer data is not reachable that way.
|
||||
- Acceleration below is the velocity difference between consecutive changed samples, divided by their time gap.
|
||||
|
||||
## What separates a knock from motion
|
||||
|
||||
Spike size alone does not work. Normal play reached 697 m/s², above every recorded knock.
|
||||
|
||||
| Feature | Knocks | Play and waving |
|
||||
|---|---|---|
|
||||
| Speed 30 ms after the spike, median | 0.1 to 0.2 m/s | 1.6 to 1.7 m/s |
|
||||
| Speed 30 ms before the spike, median | 0.5 to 1.1 m/s | 2.3 to 2.5 m/s |
|
||||
| Spike width at 233 Hz | 1 to 3 samples | 1 to 2 samples |
|
||||
|
||||
The controller nearly stops after a knock. During motion it keeps moving. Spike width does not separate them at this sample rate.
|
||||
|
||||
Kinds:
|
||||
|
||||
- `headset`: the headset spikes above 25 m/s² within 30 ms of a controller spike. Headset knocks reach 40 to 215 m/s². Head shaking reaches 22 m/s², and controller knocks move the headset less than 10 m/s².
|
||||
- `together`: the other controller spikes above 20 m/s² within 40 ms, with the controllers less than 15 cm apart. The second controller often spikes much weaker than the first.
|
||||
- `surface`: anything else. Both knocks of a double must land within 3 cm of each other, because the table does not move.
|
||||
|
||||
## Prototype rule and results
|
||||
|
||||
A controller spike counts as a knock when all of these hold:
|
||||
|
||||
- Peak above 60 m/s².
|
||||
- Speed 30 ms after the peak below 0.4 m/s.
|
||||
- Speed 30 ms before the peak below 1.8 m/s.
|
||||
|
||||
Spikes less than 80 ms apart merge into one knock, which absorbs bounces. A sequence is knocks of the same kind and the same device, 120 to 450 ms apart. A sequence of 2 is a double, 3 is a triple, and 4 or more is ignored (steady clapping).
|
||||
|
||||
Prototype results:
|
||||
|
||||
| Recording | Doubles found | Wrong kind | False doubles |
|
||||
|---|---|---|---|
|
||||
| `together` | 20 | 0 | |
|
||||
| `surface` | 18 | 1 (as `together`) | |
|
||||
| `headset` | 18 | 2 (as `surface`) | |
|
||||
| `play` | | | 0 |
|
||||
| `noise` | | | 0 |
|
||||
|
||||
A double with the wrong kind still toggles passthrough, unless that kind is turned off in `knock_types`.
|
||||
|
||||
The C++ detector (`src/knock_detector.cpp`) adds two rules. Without them, one false double appeared at 18.9 s in `noise`, from two weak claps (peaks 63 and 60 m/s²) 500 ms after a 3 second clapping run.
|
||||
|
||||
- Every knock within 450 ms of the previous one joins the same burst. A burst that mixes kinds, sources, or surface spots is rejected as a whole. It does not restart.
|
||||
- A burst must start at least 0.8 s after the previous burst ended.
|
||||
|
||||
## Live session tuning
|
||||
|
||||
Two 60 second live sessions with `knockthru --detect` drove these changes. The first one was not saved. It led to two changes:
|
||||
|
||||
- The 1 s cooldown after an action is gone. It swallowed the first knock of the next sequence. The 0.8 s stillness rule already keeps sequences apart.
|
||||
- Surface knocks less than 150 ms apart count as one knock. A table bounce landed 120 ms after a real knock, while real knocks were about 215 ms apart. Together and headset knocks use a shorter gap (see "Fast doubles" below).
|
||||
|
||||
The second session is `tests/data/mixed.csv.gz`, with a known routine: 4 each of together, surface, and headset doubles, then 4 of each as triples. The build at that point got 15 of 24 right and read 4 triples as doubles. These changes fixed that:
|
||||
|
||||
- A together or headset knock needs only 40 m/s², because the other controller or the headset confirms it. Weak third knocks peaked at 42 to 48 m/s².
|
||||
- A surface knock may still move at 0.55 m/s 30 ms later, because hard table knocks rebound.
|
||||
- Knocks in a sequence must be at most 350 ms apart (was 450). Real gaps were 170 to 290 ms. The wider window let two weak spikes 357 ms apart in `play` form a false double.
|
||||
|
||||
Result: 22 of 24 right, 0 wrong. The 2 misses are surface doubles where hard knocks bounced several times.
|
||||
|
||||
## Fast doubles
|
||||
|
||||
`tests/data/fast.csv.gz` is 30 s of fast together doubles. Its knock pairs were 89 to 168 ms apart, so the 150 ms gap dropped half of them. Rebounds in the other recordings land 83 to 99 ms after a real knock. Peak ratio (0.15 to 0.99 for real pairs, 0.39 for a rebound) and controller separation (1.3 to 3.6 cm for real pairs, 1.4 cm for a rebound) do not tell them apart.
|
||||
|
||||
- Together and headset knocks need only 105 ms between them (`fast_gap_s`). Surface knocks keep 150 ms.
|
||||
- At the default thresholds, `fast` gives 13 doubles (was 7), and every other recording keeps its result.
|
||||
- With together or headset thresholds under 20 m/s², soft rebounds pass the 105 ms gap and turn doubles into triples. Calibration never sets them below 20.
|
||||
|
||||
## Calibration knocks
|
||||
|
||||
Calibration lowers the thresholds (controller 30 m/s², confirmed 12, partner 8) to catch soft knocks. At those thresholds, `play` and `noise` give a knock candidate about once a second: 84 and 82 in 45 s each. As surface or together knocks, most of them would count.
|
||||
|
||||
Two more things showed up in a surface calibration on the Frame. Starting a hard swing from rest spiked 0.09 to 0.14 s before the knock (37 m/s², approach 0.08 to 0.29 m/s), and that spike took the count from the real knock (146 and 461 m/s²). A slow hand movement counted too (approach 0.20 m/s).
|
||||
|
||||
Knocks less than 0.25 s apart form a burst, and the burst stands for its knock with the fastest approach. Approach speed is the controller's fastest speed in the 100 ms before the spike. A burst counts when:
|
||||
|
||||
- No knock came in the 0.6 s before it. Waving makes clusters of spikes, and the wearer waits between calibration knocks.
|
||||
- Its knock approached at 0.25 m/s or faster.
|
||||
- For surface knocks, the controller averaged under 0.6 m/s from 0.6 to 0.15 s before the first knock in the burst. Real surface knocks averaged 0.02 to 0.51 m/s there, and false ones in `play` and `noise` mostly 0.64 to 2.8 m/s. Together and headset knocks skip this rule. Their false knocks never got past the first two rules, and headset knocks start with the arm rising at up to 1.3 m/s.
|
||||
|
||||
With these rules, calibration counts 1 knock in the 90 s of `play` and `noise`, and 10 of 10 in `together`, `surface`, and `headset`. That one knock spiked under the surface floor of 50 m/s². Calibration now turns such knocks away as too light, since they could never count, which leaves 0 in `play` and `noise`. In the logged Frame session, the hard knocks count instead of the swing starts, and the slow hand movement does not count.
|
||||
|
||||
Approach speed also shows how hard a knock was more steadily than the spike. The spike divides a velocity change by the gap between pose updates, which varies from 2 to 7 ms. In the knock recordings, the top tenth of spikes ran 2.8 to 8.3 times the bottom tenth, and approach speeds ran 1.7 to 2.5 times.
|
||||
|
||||
## Calibrated thresholds
|
||||
|
||||
Each calibrated knock kind gets two thresholds. Together and headset knocks have their own spike thresholds (`confirmed_peak` and `headset_knock_peak`), so calibrating one does not change the other.
|
||||
|
||||
- The spike threshold is 60 percent of the weakest recorded spike, at least 50 m/s² for surface knocks, 30 for together knocks, and 20 for headset knocks. A calibration file saved under a lower floor loads with the current one. "Strap fitting" below explains the together floor.
|
||||
- The approach threshold (`min_approach`) is 55 percent of the second slowest recorded approach. An earlier build had a sensitivity slider from 30 to 80 percent. In the replays, anything stricter than 55 percent only lost real doubles. 30 percent matched 55 percent on surface and headset knocks and did a little better on together knocks (22 against 20 right in `mixed`), but it leaves less margin against movement that the recordings lack. The walkthrough uses 55 percent and has no slider.
|
||||
|
||||
A sequence needs its fastest knock to reach the approach threshold. Two other ways of applying it failed on the recordings:
|
||||
|
||||
- Dropping slow knocks before the sequence logic let two leftover spikes form false doubles in `play` (together at approach 0.43 m/s) and `noise` (at 0.64 m/s). A slow knock still has to join its sequence, so clapping runs and busy bursts stay rejected.
|
||||
- Requiring every knock in a sequence to be fast lost most together doubles (2 to 11 of 20), because the later knocks of a quick double start from the rebound.
|
||||
|
||||
The test calibrates each kind on its own recording, applies it, and replays every recording:
|
||||
|
||||
| Kind | Spike | Approach | Doubles | `mixed` right |
|
||||
|---|---|---|---|---|
|
||||
| together | 31 | 0.24 | 19 | 21 |
|
||||
| surface | 56 | 0.56 | 18 | 22 |
|
||||
| headset | 20 | 0.20 | 22 | 22 |
|
||||
|
||||
Every row has no actions in `play`, `noise`, and `straps`, and no wrong actions in `mixed`. With default settings, the recordings give 20 together, 18 surface, and 22 headset doubles, and 22 right in `mixed`.
|
||||
|
||||
## Strap fitting
|
||||
|
||||
`tests/data/straps.csv.gz` is 21 s from a 40 minute `knockthru --detect` session in Into the Radius 2 on 2026-10-16. In it, the wearer fits the strap on each controller, and the controllers bump each other 19 times in 5 s. The rest of the session had 18.7 minutes of play with no actions.
|
||||
|
||||
- The bumps count as together knocks at 20 to 45 m/s², with approach speeds of 0.16 to 0.64 m/s.
|
||||
- The wearer's together calibration had set the spike threshold to 20, from calibration knocks as soft as 20 m/s². At that threshold, the first two bumps (25 and 28 m/s², 287 ms apart) turned passthrough on.
|
||||
- The next bump came 594 ms after the second, too late to make the burst too long.
|
||||
- Approach speed and distance from the headset do not separate the bumps from real knocks. Real together knocks in `fast` sit 0.22 to 0.45 m from the headset, and the bumps sat 0.21 to 0.41 m away.
|
||||
|
||||
The together floor is 30 m/s². At 30, `straps` gives no action with that calibration, and the calibrated replay above keeps 19 together doubles. The default `confirmed_peak` of 40 already gives no action in `straps`. Headset knocks keep the floor of 20.
|
||||
|
||||
Background: [ST design tip DT0101](https://www.st.com/resource/en/design_tip/dt0101-setting-up-singletap-and-doubletap-recognition-with-sts-mems-accelerometers-stmicroelectronics.pdf) and [NXP AN3919](https://www.nxp.com/docs/en/application-note/AN3919.pdf) describe threshold, pulse width, quiet time, and latency windows for accelerometer double-tap detection.
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prints the update rate and the largest acceleration spikes in a knockthru --record CSV."""
|
||||
import csv
|
||||
import math
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
def main(path: str, top: int = 20) -> None:
|
||||
rows = defaultdict(list)
|
||||
with open(path, newline="") as handle:
|
||||
for row in csv.DictReader(handle):
|
||||
if row["valid"] == "1":
|
||||
rows[row["device"]].append(row)
|
||||
|
||||
for device, samples in rows.items():
|
||||
fresh, spikes = [], []
|
||||
last = None
|
||||
for row in samples:
|
||||
velocity = tuple(float(row[k]) for k in ("vx", "vy", "vz"))
|
||||
if last is None or velocity != last[1]:
|
||||
t = float(row["t_s"])
|
||||
if last is not None and t > last[0]:
|
||||
dv = math.dist(velocity, last[1])
|
||||
spikes.append((dv / (t - last[0]), t))
|
||||
fresh.append(t)
|
||||
last = (t, velocity)
|
||||
if len(fresh) < 2:
|
||||
print(f"{device}: not enough valid samples")
|
||||
continue
|
||||
gaps = sorted(b - a for a, b in zip(fresh, fresh[1:]))
|
||||
span = fresh[-1] - fresh[0]
|
||||
print(f"{device}: {len(fresh)} fresh samples, {len(fresh) / span:.0f} Hz, "
|
||||
f"median gap {gaps[len(gaps) // 2] * 1000:.2f} ms")
|
||||
print(" top spikes (m/s^2 at time s):")
|
||||
for accel, t in sorted(spikes, reverse=True)[:top]:
|
||||
print(f" {accel:9.1f} {t:9.3f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit("Usage: analyze.py <recording.csv> [top]")
|
||||
main(sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 20)
|
||||
@@ -5,13 +5,20 @@ set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
build="${1:-$root/build-arm64}"
|
||||
version="${KNOCKTHRU_VERSION:-1.0.0}"
|
||||
version="${KNOCKTHRU_VERSION:-1.0.2}"
|
||||
# KNOCKTHRU_TEST_HOOK=1 builds in the test hook from src/app.cpp. Releases never set it.
|
||||
test_hook="${KNOCKTHRU_TEST_HOOK:-}"
|
||||
mkdir -p "$build/bin"
|
||||
export ZIG_GLOBAL_CACHE_DIR="$build/zig-global-cache"
|
||||
export ZIG_LOCAL_CACHE_DIR="$build/zig-local-cache"
|
||||
|
||||
sources=(
|
||||
src/main.cpp
|
||||
src/app.cpp
|
||||
src/steamvr.cpp
|
||||
src/poses.cpp
|
||||
src/diagnostics.cpp
|
||||
src/install.cpp
|
||||
src/knock_detector.cpp
|
||||
src/room_view.cpp
|
||||
src/style.cpp
|
||||
@@ -41,6 +48,7 @@ cd "$root"
|
||||
zig c++ -target aarch64-linux-gnu.2.31 -std=c++17 -O2 -s -fvisibility=hidden -Wno-deprecated-declarations \
|
||||
-DLINUX -DPOSIX -DLINUXARM64 -DVR_API_PUBLIC -DVRCORE_NO_PLATFORM -DOPENVR_BUILD_STATIC \
|
||||
-DKNOCKTHRU_VERSION="\"$version\"" \
|
||||
${test_hook:+-DKNOCKTHRU_TEST_HOOK} \
|
||||
"${includes[@]/#/-I}" "${sources[@]}" -ldl -lpthread -o "$build/bin/knockthru"
|
||||
|
||||
cp "$build/bin/knockthru" "$build/bin/knockthru-ctl"
|
||||
|
||||
+1108
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
namespace knockthru {
|
||||
|
||||
// Runs the resident app until SteamVR quits or a signal arrives. SteamVR starts it with --run.
|
||||
int run_app();
|
||||
|
||||
} // namespace knockthru
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "config.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace knockthru {
|
||||
@@ -102,4 +103,10 @@ KindCalibration Calibrator::save() const {
|
||||
return *calibration[kind_];
|
||||
}
|
||||
|
||||
std::string thresholds_text(const KindCalibration& calibration) {
|
||||
char line[128];
|
||||
std::snprintf(line, sizeof line, "spike %.0f m/s^2, approach %.2f m/s", calibration.peak, calibration.approach);
|
||||
return line;
|
||||
}
|
||||
|
||||
} // namespace knockthru
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <array>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace knockthru {
|
||||
@@ -111,4 +112,7 @@ private:
|
||||
std::vector<double> peaks_, speeds_;
|
||||
};
|
||||
|
||||
// The spike and approach thresholds as one line, for the log and the terminal.
|
||||
std::string thresholds_text(const KindCalibration& calibration);
|
||||
|
||||
} // namespace knockthru
|
||||
+24
-5
@@ -138,7 +138,25 @@ void Canvas::rounded_rect(Rect rect, float radius, Color color) {
|
||||
if (c >= 1) break;
|
||||
blend(right, y, color, c);
|
||||
}
|
||||
for (int x = left; x <= right; ++x) blend(x, y, color, 1);
|
||||
fill_span(y, left, right + 1, color);
|
||||
}
|
||||
}
|
||||
|
||||
void Canvas::fill_span(int y, int from, int to, Color color) {
|
||||
if (y < 0 || y >= height_) return;
|
||||
from = std::max(from, 0);
|
||||
to = std::min(to, width_);
|
||||
if (color.a != 255) {
|
||||
for (int x = from; x < to; ++x) blend(x, y, color, 1);
|
||||
return;
|
||||
}
|
||||
// An opaque color replaces what is under it.
|
||||
auto* p = &pixels_[(static_cast<std::size_t>(y) * width_ + from) * 4];
|
||||
for (int x = from; x < to; ++x, p += 4) {
|
||||
p[0] = color.r;
|
||||
p[1] = color.g;
|
||||
p[2] = color.b;
|
||||
p[3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,8 +231,9 @@ void Canvas::arc(float cx, float cy, float inner, float outer, float from, float
|
||||
}
|
||||
}
|
||||
|
||||
float Canvas::measure(Font& font, const std::string& utf8, float size) {
|
||||
const auto text = decode_utf8(utf8);
|
||||
float Canvas::measure(Font& font, const std::string& utf8, float size) { return measure(font, decode_utf8(utf8), size); }
|
||||
|
||||
float Canvas::measure(Font& font, const std::u32string& text, float size) {
|
||||
float width = 0;
|
||||
for (std::size_t i = 0; i < text.size(); ++i) {
|
||||
width += font.glyph(text[i], size).advance;
|
||||
@@ -269,12 +288,12 @@ std::vector<std::string> Canvas::wrap(Font& font, const std::string& utf8, float
|
||||
}
|
||||
|
||||
void Canvas::text(Font& font, const std::string& utf8, float x, float y, float size, Color color, Align align) {
|
||||
const float width = measure(font, utf8, size);
|
||||
const auto text = decode_utf8(utf8);
|
||||
const float width = measure(font, text, size);
|
||||
if (align == Align::Center) x -= width / 2;
|
||||
if (align == Align::Right) x -= width;
|
||||
// Centers the cap height on y. Cap height is about 0.7 of the pixel size.
|
||||
const float baseline = y + size * 0.36f;
|
||||
const auto text = decode_utf8(utf8);
|
||||
float pen = x;
|
||||
for (std::size_t i = 0; i < text.size(); ++i) {
|
||||
const auto& glyph = font.glyph(text[i], size);
|
||||
|
||||
@@ -84,6 +84,9 @@ private:
|
||||
std::vector<std::uint8_t> pixels_;
|
||||
|
||||
void blend(int x, int y, Color color, float coverage);
|
||||
// Blends `color` at full coverage over pixels `from` to `to` (exclusive) of row `y`.
|
||||
void fill_span(int y, int from, int to, Color color);
|
||||
float measure(Font& font, const std::u32string& text, float size);
|
||||
};
|
||||
|
||||
} // namespace knockthru
|
||||
+7
-16
@@ -43,6 +43,8 @@ void merge(AppConfig& config, const json& file, const fs::path& path) {
|
||||
const auto fail = [&](const std::string& what) {
|
||||
throw std::runtime_error(path.string() + ": " + what);
|
||||
};
|
||||
const std::map<std::string, bool*> flags{{"paused", &config.paused}, {"classic_bar_icon", &config.classic_bar_icon},
|
||||
{"sounds", &config.sounds}, {"tour_done", &config.tour_done}, {"check_updates", &config.check_updates}};
|
||||
for (const auto& [key, value] : file.items()) {
|
||||
if (key == "knock_types") {
|
||||
if (!value.is_array()) fail("knock_types must be a list");
|
||||
@@ -59,21 +61,9 @@ void merge(AppConfig& config, const json& file, const fs::path& path) {
|
||||
if (!app.is_string()) fail("blocked_apps must be a list of app keys");
|
||||
config.blocked_apps.insert(app.get<std::string>());
|
||||
}
|
||||
} else if (key == "paused") {
|
||||
if (!value.is_boolean()) fail("paused must be true or false");
|
||||
config.paused = value.get<bool>();
|
||||
} else if (key == "classic_bar_icon") {
|
||||
if (!value.is_boolean()) fail("classic_bar_icon must be true or false");
|
||||
config.classic_bar_icon = value.get<bool>();
|
||||
} else if (key == "sounds") {
|
||||
if (!value.is_boolean()) fail("sounds must be true or false");
|
||||
config.sounds = value.get<bool>();
|
||||
} else if (key == "tour_done") {
|
||||
if (!value.is_boolean()) fail("tour_done must be true or false");
|
||||
config.tour_done = value.get<bool>();
|
||||
} else if (key == "check_updates") {
|
||||
if (!value.is_boolean()) fail("check_updates must be true or false");
|
||||
config.check_updates = value.get<bool>();
|
||||
} else if (const auto flag = flags.find(key); flag != flags.end()) {
|
||||
if (!value.is_boolean()) fail(key + " must be true or false");
|
||||
*flag->second = value.get<bool>();
|
||||
} else if (key == "debug_port") {
|
||||
if (!value.is_number_integer()) fail("debug_port must be a whole number");
|
||||
config.debug_port = value.get<int>();
|
||||
@@ -83,7 +73,8 @@ void merge(AppConfig& config, const json& file, const fs::path& path) {
|
||||
{"controller_peak", &d.controller_peak}, {"confirmed_peak", &d.confirmed_peak},
|
||||
{"partner_peak", &d.partner_peak}, {"headset_peak", &d.headset_peak}, {"headset_knock_peak", &d.headset_knock_peak},
|
||||
{"max_speed_after", &d.max_speed_after}, {"surface_speed_after", &d.surface_speed_after},
|
||||
{"max_speed_before", &d.max_speed_before}, {"together_distance", &d.together_distance},
|
||||
{"max_speed_before", &d.max_speed_before}, {"surface_speed_before", &d.surface_speed_before},
|
||||
{"together_distance", &d.together_distance},
|
||||
{"same_spot", &d.same_spot}, {"merge_s", &d.merge_s}, {"min_gap_s", &d.min_gap_s}, {"fast_gap_s", &d.fast_gap_s},
|
||||
{"max_gap_s", &d.max_gap_s}, {"quiet_before_s", &d.quiet_before_s}};
|
||||
if (!value.is_object()) fail("detector must be an object");
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
#include "diagnostics.hpp"
|
||||
|
||||
#include "config.hpp"
|
||||
#include "steamvr.hpp"
|
||||
#include "style.hpp"
|
||||
#include "update.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <deque>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
#include <sys/utsname.h>
|
||||
|
||||
namespace knockthru {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
fs::path home_directory() {
|
||||
const auto home = std::getenv("HOME");
|
||||
return home && *home ? home : "/tmp";
|
||||
}
|
||||
|
||||
// The last `count` lines of a text file, or a note if it is missing.
|
||||
std::string tail(const fs::path& path, std::size_t count) {
|
||||
std::ifstream stream(path);
|
||||
if (!stream) return "(missing)\n";
|
||||
std::deque<std::string> lines;
|
||||
for (std::string line; std::getline(stream, line);) {
|
||||
lines.push_back(std::move(line));
|
||||
if (lines.size() > count) lines.pop_front();
|
||||
}
|
||||
std::string text;
|
||||
for (const auto& line : lines) text += line + "\n";
|
||||
return text.empty() ? "(empty)\n" : text;
|
||||
}
|
||||
|
||||
std::string os_name() {
|
||||
std::ifstream stream("/etc/os-release");
|
||||
std::string name = "unknown";
|
||||
for (std::string line; std::getline(stream, line);)
|
||||
if (line.rfind("PRETTY_NAME=", 0) == 0) name = line.substr(12);
|
||||
name.erase(std::remove(name.begin(), name.end(), '"'), name.end());
|
||||
utsname system{};
|
||||
if (::uname(&system) == 0) name += std::string(", kernel ") + system.release;
|
||||
return name;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
fs::path report_path() { return home_directory() / "knockthru-report.txt"; }
|
||||
|
||||
fs::path recording_path() { return home_directory() / "knockthru-knocks.csv.gz"; }
|
||||
|
||||
void write_recording(const KnockRecording& recording) {
|
||||
const auto csv = user_directory() / "knocks.csv";
|
||||
{
|
||||
std::ofstream out(csv, std::ios::trunc);
|
||||
out << kCsvHeader;
|
||||
for (const auto& s : recording.samples) write_csv_row(out, s.t - recording.start, s.device, s.position, s.velocity);
|
||||
if (!out) throw std::runtime_error("Cannot write " + csv.string());
|
||||
}
|
||||
if (run_program({"gzip", "-f", csv.string()}) != 0) throw std::runtime_error("gzip could not compress the recording");
|
||||
fs::copy_file(csv.string() + ".gz", recording_path(), fs::copy_options::overwrite_existing);
|
||||
fs::remove(csv.string() + ".gz");
|
||||
std::ofstream summary(user_directory() / "knocks.txt", std::ios::trunc);
|
||||
summary << "Recorded " << recording.samples.size() << " samples over " << KnockRecording::kSeconds << " s. Heard "
|
||||
<< recording.heard.size() << " knocks and actions:\n";
|
||||
for (const auto& line : recording.heard) summary << line << "\n";
|
||||
}
|
||||
|
||||
std::string report_text(bool vr, const std::string& room_view) {
|
||||
std::ostringstream out;
|
||||
const auto now = std::time(nullptr);
|
||||
char stamp[32];
|
||||
std::strftime(stamp, sizeof stamp, "%F %T", std::localtime(&now));
|
||||
out << "KnockThru bug report, " << stamp << "\n\n";
|
||||
out << "KnockThru: " << kVersion << "\n";
|
||||
out << "Program: " << executable().string() << "\n";
|
||||
out << "System: " << os_name() << "\n";
|
||||
if (vr) {
|
||||
auto* apps = vr::VRApplications();
|
||||
out << "SteamVR: " << steamvr_version() << "\n";
|
||||
const auto remembered = load_state();
|
||||
if (!remembered.steamvr_since.empty())
|
||||
out << "SteamVR seen since: " << remembered.steamvr_since
|
||||
<< (remembered.steamvr_previous.empty() ? "" : ", before that " + remembered.steamvr_previous) << "\n";
|
||||
out << "Registered with SteamVR: " << (apps->IsApplicationInstalled(kAppKey) ? "yes" : "no") << "\n";
|
||||
out << "Starts with SteamVR: " << (apps->GetApplicationAutoLaunch(kAppKey) ? "yes" : "no") << "\n";
|
||||
const auto pid = apps->GetApplicationProcessId(kAppKey);
|
||||
out << "Running: " << (pid ? "yes, pid " + std::to_string(pid) : std::string("no")) << "\n";
|
||||
try {
|
||||
out << "Style: " << to_string(read_style()) << "\n";
|
||||
} catch (const std::exception& error) {
|
||||
out << "Style: " << error.what() << "\n";
|
||||
}
|
||||
const auto key = scene_app_key();
|
||||
out << "Current app: " << (key.empty() ? "none" : key) << "\n";
|
||||
} else {
|
||||
out << "SteamVR: not running\n";
|
||||
}
|
||||
out << "Room View: " << room_view << "\n";
|
||||
const auto user = user_directory();
|
||||
// The summary of the last knock recording, if it is under an hour old.
|
||||
std::error_code age_error;
|
||||
const auto knocks = user / "knocks.txt";
|
||||
if (fs::exists(knocks, age_error) &&
|
||||
fs::file_time_type::clock::now() - fs::last_write_time(knocks, age_error) < std::chrono::hours(1))
|
||||
out << "\n== Knock recording, knockthru-knocks.csv.gz ==\n" << tail(knocks, 200);
|
||||
for (const auto* name : {"config.json", "calibration.json"})
|
||||
out << "\n== " << (user / name).string() << " ==\n" << tail(user / name, 400);
|
||||
out << "\n== " << (user / "application.log").string() << ", last 300 lines ==\n" << tail(user / "application.log", 300);
|
||||
const auto home = std::getenv("HOME");
|
||||
if (home && *home) {
|
||||
const auto steam_log = fs::path(home) / ".local/share/Steam/logs/vrclient_knockthru.txt";
|
||||
out << "\n== " << steam_log.string() << ", last 100 lines ==\n" << tail(steam_log, 100);
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
fs::path write_report(bool vr, const std::string& room_view) {
|
||||
const auto path = report_path();
|
||||
std::ofstream file(path, std::ios::trunc);
|
||||
if (!(file << report_text(vr, room_view))) throw std::runtime_error("Cannot write " + path.string());
|
||||
return path;
|
||||
}
|
||||
|
||||
} // namespace knockthru
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include "poses.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace knockthru {
|
||||
|
||||
// The bug report files go in the home folder, outside ~/.config, so a person can find them to attach.
|
||||
std::filesystem::path report_path();
|
||||
std::filesystem::path recording_path();
|
||||
|
||||
// 20 s of headset and controller motion, in the tests/data CSV format, for a knock bug report.
|
||||
struct KnockRecording {
|
||||
static constexpr double kSeconds = 20;
|
||||
double start = 0;
|
||||
std::vector<PoseThread::Sample> samples;
|
||||
std::vector<std::string> heard; // one line per knock and action, for the report
|
||||
};
|
||||
|
||||
// Writes the recording as ~/knockthru-knocks.csv.gz and its summary as knocks.txt.
|
||||
// The angular velocity columns are zero, because the detector does not read them.
|
||||
void write_recording(const KnockRecording& recording);
|
||||
|
||||
// A bug report for a GitHub issue: versions, SteamVR state, settings, and the
|
||||
// end of the logs. `vr` is false when SteamVR is not running, and `room_view`
|
||||
// describes what the caller read from Room View.
|
||||
std::string report_text(bool vr, const std::string& room_view);
|
||||
// Writes report_text() to report_path() and returns that path.
|
||||
std::filesystem::path write_report(bool vr, const std::string& room_view);
|
||||
|
||||
} // namespace knockthru
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <openvr.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace knockthru {
|
||||
|
||||
// Short buzzes on both controllers. Uses SteamVR input actions when the action
|
||||
// manifest is loaded, and the older direct pulse call otherwise.
|
||||
class Haptics {
|
||||
public:
|
||||
void use_actions(vr::VRActionHandle_t left, vr::VRActionHandle_t right) { left_ = left; right_ = right; }
|
||||
|
||||
// Queues `count` pulses, 150 ms apart.
|
||||
void buzz(int count, double now) {
|
||||
for (int i = 0; i < count; ++i) pending_.push_back(now + 0.15 * i);
|
||||
}
|
||||
|
||||
void update(double now) {
|
||||
for (auto it = pending_.begin(); it != pending_.end();) {
|
||||
if (*it > now) { ++it; continue; }
|
||||
pulse();
|
||||
it = pending_.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
vr::VRActionHandle_t left_ = vr::k_ulInvalidActionHandle;
|
||||
vr::VRActionHandle_t right_ = vr::k_ulInvalidActionHandle;
|
||||
std::vector<double> pending_;
|
||||
|
||||
void pulse() {
|
||||
if (left_ != vr::k_ulInvalidActionHandle) {
|
||||
for (const auto action : {left_, right_})
|
||||
vr::VRInput()->TriggerHapticVibrationAction(action, 0, 0.07f, 160, 1.0f, vr::k_ulInvalidInputValueHandle);
|
||||
return;
|
||||
}
|
||||
auto* system = vr::VRSystem();
|
||||
for (int i = 0; i < 14; ++i) { // 14 x 5 ms
|
||||
for (const auto role : {vr::TrackedControllerRole_LeftHand, vr::TrackedControllerRole_RightHand})
|
||||
system->TriggerHapticPulse(system->GetTrackedDeviceIndexForControllerRole(role), 0, 3999);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace knockthru
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
#include "install.hpp"
|
||||
|
||||
#include "canvas.hpp"
|
||||
#include "config.hpp"
|
||||
#include "diagnostics.hpp"
|
||||
#include "panel.hpp"
|
||||
#include "steamvr.hpp"
|
||||
#include "update.hpp"
|
||||
|
||||
#include <json.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <iterator>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
namespace knockthru {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
fs::path app_binary() { return install_directory() / "knockthru"; }
|
||||
|
||||
fs::path manifest_path() { return user_directory() / "app.vrmanifest"; }
|
||||
|
||||
fs::path icon_path() { return user_directory() / "icon.png"; }
|
||||
|
||||
void register_app() {
|
||||
const auto binary = app_binary().generic_u8string();
|
||||
Canvas icon(256, 256);
|
||||
Panel::draw_icon(icon);
|
||||
fs::create_directories(icon_path().parent_path());
|
||||
if (!icon.write_png(icon_path())) throw std::runtime_error("Cannot write " + icon_path().string());
|
||||
json app = {
|
||||
{"app_key", kAppKey}, {"launch_type", "binary"}, {"is_dashboard_overlay", true},
|
||||
{"binary_path_linux", binary},
|
||||
// The Frame's native ARM runtime reads this key, not binary_path_linux (from KominoVR).
|
||||
{"binary_path_linux_arm", binary},
|
||||
{"arguments", "--run"},
|
||||
{"image_path", icon_path().generic_u8string()},
|
||||
{"action_manifest_path", (install_directory() / "actions.json").generic_u8string()},
|
||||
{"strings", {{"en_us", {{"name", "KnockThru"}, {"description", "Double knock to toggle passthrough"}}}}}};
|
||||
fs::create_directories(manifest_path().parent_path());
|
||||
std::ofstream(manifest_path(), std::ios::trunc) << json{{"source", "builtin"}, {"applications", json::array({app})}}.dump(2);
|
||||
check_app(vr::VRApplications()->AddApplicationManifest(manifest_path().u8string().c_str(), false), "Register with SteamVR");
|
||||
}
|
||||
|
||||
// Stops every running resident KnockThru: a process of this user whose program
|
||||
// is named knockthru and whose arguments include --run. After an update renames
|
||||
// a new file over the old one, the running process's exe link reads
|
||||
// "knockthru (deleted)". It reads /proc instead of asking SteamVR, which files
|
||||
// knockthru-ctl under KnockThru's app key when STEAMVR_APPKEY leaks into it.
|
||||
void stop_running_app() {
|
||||
std::vector<pid_t> stopped;
|
||||
std::error_code error;
|
||||
for (const auto& entry : fs::directory_iterator("/proc", error)) {
|
||||
const auto name = entry.path().filename().string();
|
||||
if (name.find_first_not_of("0123456789") != std::string::npos) continue;
|
||||
const auto pid = static_cast<pid_t>(std::stol(name));
|
||||
if (pid == getpid()) continue;
|
||||
std::error_code ignored;
|
||||
const auto exe = fs::read_symlink(entry.path() / "exe", ignored);
|
||||
if (ignored || (exe.filename() != "knockthru" && exe.filename() != "knockthru (deleted)")) continue;
|
||||
std::ifstream file(entry.path() / "cmdline", std::ios::binary);
|
||||
std::string args((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
|
||||
if (args.find(std::string("--run") + '\0') == std::string::npos) continue;
|
||||
if (::kill(pid, SIGTERM) == 0) stopped.push_back(pid);
|
||||
}
|
||||
for (const auto pid : stopped)
|
||||
for (int i = 0; i < 50 && fs::exists(fs::path("/proc") / std::to_string(pid)); ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
|
||||
// SteamVR's own per-app file, which holds the autostart flag.
|
||||
fs::path steamvr_app_config() {
|
||||
return user_directory().parent_path() / "openvr" / "config" / "vrappconfig" /
|
||||
(std::string(kAppKey) + ".vrappconfig");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int install() {
|
||||
stop_running_app(); // restart so an update takes effect
|
||||
VrSession session(vr::VRApplication_Utility);
|
||||
register_app();
|
||||
check_app(vr::VRApplications()->SetApplicationAutoLaunch(kAppKey, true), "Turn on SteamVR autostart");
|
||||
// SteamVR clears the old app's PID a few ms after the process exits. A launch
|
||||
// before that fails with ApplicationAlreadyRunning.
|
||||
for (int i = 0; i < 30 && vr::VRApplications()->GetApplicationProcessId(kAppKey) != 0; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
check_app(vr::VRApplications()->LaunchDashboardOverlay(kAppKey), "Start KnockThru");
|
||||
std::cout << "KnockThru is running and starts with SteamVR. Double knock to toggle passthrough.\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
void unregister() {
|
||||
if (vr::VRApplications()->IsApplicationInstalled(kAppKey)) {
|
||||
vr::VRApplications()->SetApplicationAutoLaunch(kAppKey, false);
|
||||
check_app(vr::VRApplications()->RemoveApplicationManifest(manifest_path().u8string().c_str()), "Unregister");
|
||||
}
|
||||
std::error_code error;
|
||||
fs::remove(manifest_path(), error);
|
||||
fs::remove(icon_path(), error);
|
||||
}
|
||||
|
||||
int uninstall(bool purge) {
|
||||
VrSession session(vr::VRApplication_Utility);
|
||||
stop_running_app();
|
||||
unregister();
|
||||
if (!purge) {
|
||||
std::cout << "KnockThru stopped and removed from SteamVR. Your settings stay in "
|
||||
<< user_directory().string() << ". Add --purge to delete them.\n";
|
||||
return 0;
|
||||
}
|
||||
fs::remove_all(user_directory());
|
||||
fs::remove(steamvr_app_config());
|
||||
fs::remove(report_path());
|
||||
fs::remove(recording_path());
|
||||
std::cout << "KnockThru stopped and removed from SteamVR. Deleted " << user_directory().string()
|
||||
<< ", " << steamvr_app_config().string() << ", and the report files in your home folder.\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
int update() {
|
||||
const auto work = user_directory() / "update";
|
||||
const auto release = fetch_latest_release(work);
|
||||
if (compare_versions(release.version, kVersion) <= 0) {
|
||||
std::cout << "KnockThru " << kVersion << " is up to date. The latest release is " << release.version << ".\n";
|
||||
return 0;
|
||||
}
|
||||
std::cout << "Installing KnockThru " << release.version << " from " << release.url << "\n";
|
||||
install_release(release, install_directory(), work);
|
||||
// The new files are in place. install() restarts the resident app from them.
|
||||
return install();
|
||||
}
|
||||
|
||||
} // namespace knockthru
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
namespace knockthru {
|
||||
|
||||
// Registers with SteamVR, turns on autostart, and starts the resident app. A
|
||||
// running resident app stops first, so an update takes effect.
|
||||
int install();
|
||||
|
||||
// Stops the resident app and removes it from SteamVR. With `purge`, also deletes
|
||||
// ~/.config/knockthru, SteamVR's file for the app, and the report files in the home
|
||||
// folder. The install folder stays, because FrameDrop or the person who unpacked it owns it.
|
||||
int uninstall(bool purge);
|
||||
|
||||
// Installs the latest release over this one, then restarts KnockThru.
|
||||
int update();
|
||||
|
||||
// Removes KnockThru's manifest and autostart from SteamVR. Needs a SteamVR session.
|
||||
void unregister();
|
||||
|
||||
} // namespace knockthru
|
||||
+25
-16
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <iterator>
|
||||
|
||||
namespace knockthru {
|
||||
namespace {
|
||||
@@ -46,35 +47,41 @@ void KnockDetector::push(Device device, double t, const Vec3& position, const Ve
|
||||
while (!history.empty() && history.front().t < t - kKeep) history.pop_front();
|
||||
}
|
||||
|
||||
// History is sorted by time, so each lookup starts with a binary search.
|
||||
KnockDetector::History::const_iterator KnockDetector::first_from(const History& history, double t) {
|
||||
return std::lower_bound(history.begin(), history.end(), t, [](const Sample& s, double t) { return s.t < t; });
|
||||
}
|
||||
|
||||
const KnockDetector::Sample* KnockDetector::nearest(const History& history, double t) {
|
||||
const Sample* best = nullptr;
|
||||
for (const auto& sample : history)
|
||||
if (!best || std::abs(sample.t - t) < std::abs(best->t - t)) best = &sample;
|
||||
return best;
|
||||
if (history.empty()) return nullptr;
|
||||
const auto after = first_from(history, t);
|
||||
if (after == history.begin()) return &*after;
|
||||
const auto before = std::prev(after);
|
||||
// The earlier sample wins a tie.
|
||||
if (after == history.end() || std::abs(before->t - t) <= std::abs(after->t - t)) return &*before;
|
||||
return &*after;
|
||||
}
|
||||
|
||||
double KnockDetector::mean_speed(const History& history, double from, double to) {
|
||||
double sum = 0;
|
||||
int count = 0;
|
||||
for (const auto& sample : history)
|
||||
if (sample.t >= from && sample.t <= to) {
|
||||
sum += norm(sample.velocity);
|
||||
++count;
|
||||
}
|
||||
for (auto it = first_from(history, from); it != history.end() && it->t <= to; ++it) {
|
||||
sum += norm(it->velocity);
|
||||
++count;
|
||||
}
|
||||
return count ? sum / count : 0;
|
||||
}
|
||||
|
||||
double KnockDetector::max_speed(const History& history, double from, double to) {
|
||||
double fastest = 0;
|
||||
for (const auto& sample : history)
|
||||
if (sample.t >= from && sample.t <= to) fastest = std::max(fastest, norm(sample.velocity));
|
||||
for (auto it = first_from(history, from); it != history.end() && it->t <= to; ++it)
|
||||
fastest = std::max(fastest, norm(it->velocity));
|
||||
return fastest;
|
||||
}
|
||||
|
||||
double KnockDetector::peak_in(const History& history, double from, double to) {
|
||||
double peak = 0;
|
||||
for (const auto& sample : history)
|
||||
if (sample.t >= from && sample.t <= to) peak = std::max(peak, sample.accel);
|
||||
for (auto it = first_from(history, from); it != history.end() && it->t <= to; ++it) peak = std::max(peak, it->accel);
|
||||
return peak;
|
||||
}
|
||||
|
||||
@@ -109,7 +116,7 @@ std::optional<Knock> KnockDetector::judge(Device device, std::size_t i) const {
|
||||
if (sample.accel < threshold) return std::nullopt;
|
||||
const auto* before = nearest(history, sample.t - 0.03);
|
||||
const auto* after = nearest(history, sample.t + 0.03);
|
||||
if (!before || !after || norm(before->velocity) >= config_.max_speed_before ||
|
||||
if (!before || !after || norm(before->velocity) >= (surface ? config_.surface_speed_before : config_.max_speed_before) ||
|
||||
norm(after->velocity) >= (surface ? config_.surface_speed_after : config_.max_speed_after))
|
||||
return std::nullopt;
|
||||
return knock;
|
||||
@@ -164,8 +171,10 @@ std::optional<KnockEvent> KnockDetector::poll(double now) {
|
||||
for (const auto device : {Device::Left, Device::Right}) {
|
||||
const auto& history = history_[index(device)];
|
||||
auto& judged = judged_until_[index(device)];
|
||||
for (std::size_t i = 0; i < history.size(); ++i) {
|
||||
if (history[i].t <= judged || history[i].t > horizon) continue;
|
||||
// Samples up to `judged` were judged by an earlier call.
|
||||
auto i = static_cast<std::size_t>(std::upper_bound(history.begin(), history.end(), judged,
|
||||
[](double t, const Sample& s) { return t < s.t; }) - history.begin());
|
||||
for (; i < history.size() && history[i].t <= horizon; ++i) {
|
||||
if (auto knock = judge(device, i)) found.push_back(*knock);
|
||||
judged = history[i].t;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ enum class KnockKind { Together, Surface, Headset };
|
||||
const char* to_string(KnockKind kind);
|
||||
std::optional<KnockKind> parse_knock_kind(const std::string& name);
|
||||
|
||||
// Thresholds come from the recordings described in docs/knock-signal.md.
|
||||
// Thresholds come from the Steam Frame recordings in tests/data/.
|
||||
struct DetectorConfig {
|
||||
double controller_peak = 60; // m/s^2, spike on the knocking controller
|
||||
double confirmed_peak = 40; // m/s^2, controller spike for a together knock, which the other controller confirms
|
||||
@@ -25,8 +25,9 @@ struct DetectorConfig {
|
||||
double max_speed_after = 0.4; // m/s, 30 ms after the spike
|
||||
double surface_speed_after = 0.55; // m/s, table knocks rebound
|
||||
double max_speed_before = 1.8; // m/s, 30 ms before the spike
|
||||
double surface_speed_before = 2.2; // m/s, knocks on a leg come in faster
|
||||
double together_distance = 0.15; // m, controller separation for a together knock
|
||||
double same_spot = 0.03; // m, surface knocks in one sequence must land this close
|
||||
double same_spot = 0.06; // m, surface knocks in one sequence must land this close. Leg knocks spread more than desk knocks.
|
||||
double merge_s = 0.08; // spikes closer than this are one knock (bounces)
|
||||
double min_gap_s = 0.15; // closer surface knocks are table bounces
|
||||
double fast_gap_s = 0.105; // closer together or headset knocks are rebounds
|
||||
@@ -95,6 +96,7 @@ private:
|
||||
std::optional<Knock> judge(Device device, std::size_t index) const;
|
||||
void add(const Knock& knock);
|
||||
std::optional<KnockEvent> finish_sequence();
|
||||
static History::const_iterator first_from(const History& history, double t);
|
||||
static const Sample* nearest(const History& history, double t);
|
||||
static double peak_in(const History& history, double from, double to);
|
||||
static double mean_speed(const History& history, double from, double to);
|
||||
|
||||
+35
-1378
File diff suppressed because it is too large.
Load diff
@@ -211,7 +211,6 @@ struct OverlayTexture::Impl {
|
||||
}
|
||||
|
||||
VkCommandPoolCreateInfo pool_info{};
|
||||
|
||||
pool_info.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
|
||||
pool_info.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
|
||||
pool_info.queueFamilyIndex = family;
|
||||
@@ -290,7 +289,6 @@ void OverlayTexture::show(vr::VROverlayHandle_t overlay, const std::uint8_t* rgb
|
||||
check(v.EndCommandBuffer(v.commands), "end");
|
||||
|
||||
VkSubmitInfo submit{};
|
||||
|
||||
submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
|
||||
submit.commandBufferCount = 1;
|
||||
submit.pCommandBuffers = &v.commands;
|
||||
|
||||
+37
-55
@@ -92,6 +92,28 @@ Panel::Panel(const std::filesystem::path& fonts)
|
||||
background_.rounded_rect({0, 0, kWidth, kHeight}, 28, kBackground);
|
||||
}
|
||||
|
||||
// Drawing the icon from its outlines is slow, so each size and style is drawn once and kept.
|
||||
const Canvas& Panel::icon(int size, IconStyle style) {
|
||||
const auto key = std::make_pair(size, style);
|
||||
auto found = icons_.find(key);
|
||||
if (found == icons_.end()) {
|
||||
found = icons_.emplace(key, Canvas(size, size)).first;
|
||||
draw_icon(found->second, style);
|
||||
}
|
||||
return found->second;
|
||||
}
|
||||
|
||||
// Draws `text` wrapped to `width` in at most `max_lines` lines, `leading` pixels apart.
|
||||
// Returns the y after the last line.
|
||||
float Panel::paragraph(Font& font, const std::string& text, float x, float y, float size, float leading, float width,
|
||||
int max_lines, Color color) {
|
||||
for (const auto& line : canvas_.wrap(font, text, size, width, max_lines)) {
|
||||
canvas_.text(font, line, x, y, size, color);
|
||||
y += leading;
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
bool Panel::hovered(Rect rect) const { return hover_ && rect.contains(hover_->first, hover_->second); }
|
||||
|
||||
void Panel::switch_control(float right, float cy, bool on, bool enabled, float scale) {
|
||||
@@ -119,17 +141,12 @@ void Panel::button(Rect rect, const std::string& label, std::optional<PanelClick
|
||||
}
|
||||
|
||||
void Panel::header(const std::string& title) {
|
||||
Canvas icon(56, 56);
|
||||
draw_icon(icon);
|
||||
canvas_.draw(icon, static_cast<int>(kMargin), 40);
|
||||
canvas_.draw(icon(56, IconStyle::Tile), static_cast<int>(kMargin), 40);
|
||||
canvas_.text(bold_, title, kMargin + 76, 68, 38, kText);
|
||||
}
|
||||
|
||||
void Panel::message(const PanelState& state, float y) {
|
||||
for (const auto& line : canvas_.wrap(regular_, state.message, 26, kWidth - 2 * kMargin, 2)) {
|
||||
canvas_.text(regular_, line, kMargin, y, 26, state.message_is_error ? kError : kDim);
|
||||
y += 36;
|
||||
}
|
||||
paragraph(regular_, state.message, kMargin, y, 26, 36, kWidth - 2 * kMargin, 2, state.message_is_error ? kError : kDim);
|
||||
}
|
||||
|
||||
void Panel::passthrough_tile(Rect rect, const PanelState& state) {
|
||||
@@ -142,11 +159,7 @@ void Panel::passthrough_tile(Rect rect, const PanelState& state) {
|
||||
const std::string value = state.busy ? "Switching" : broken ? "Unavailable" : !state.room_view ? "Checking" : on ? "On" : "Off";
|
||||
canvas_.text(bold_, value, x, rect.y + 160, broken ? 64 : 96, kText);
|
||||
if (broken) {
|
||||
float y = rect.y + rect.h - 92;
|
||||
for (const auto& line : canvas_.wrap(regular_, state.room_view_error, 26, w, 2)) {
|
||||
canvas_.text(regular_, line, x, y, 26, kText);
|
||||
y += 36;
|
||||
}
|
||||
paragraph(regular_, state.room_view_error, x, rect.y + rect.h - 92, 26, 36, w, 2, kText);
|
||||
}
|
||||
targets_.push_back({rect, PanelClick{PanelCommand::RoomView}, std::nullopt});
|
||||
}
|
||||
@@ -347,10 +360,7 @@ void Panel::learn_screen(const PanelState& state) {
|
||||
std::string("Hold still while the ring fills."),
|
||||
std::string("Do it 10 times. One buzz means it counted."),
|
||||
std::string("Knock as hard as you would to switch passthrough.")}) {
|
||||
for (const auto& part : canvas_.wrap(regular_, line, 28, w, 2)) {
|
||||
canvas_.text(regular_, part, x, y, 28, kDim);
|
||||
y += 40;
|
||||
}
|
||||
y = paragraph(regular_, line, x, y, 28, 40, w, 2, kDim);
|
||||
y += 14;
|
||||
}
|
||||
button({kWidth - kMargin - 240, 616, 240, 72}, "Start", PanelClick{PanelCommand::Calibrate, learn_kind_}, std::nullopt,
|
||||
@@ -461,11 +471,8 @@ void Panel::try_screen(const PanelState& state, const CalibrationView& view) {
|
||||
const float shake = anim::buzz(since, 0, 2) * 2;
|
||||
canvas_.text(bold_, "Not quite", status.x + 40 + shake, status.y + 70, 34, kError);
|
||||
float y = status.y + 124;
|
||||
for (const auto& line : canvas_.wrap(regular_, "Heard " + view.trial_heard + ". Try again, or record your knocks again.",
|
||||
26, status.w - 80, 3)) {
|
||||
canvas_.text(regular_, line, status.x + 40, y, 26, kDim);
|
||||
y += 36;
|
||||
}
|
||||
paragraph(regular_, "Heard " + view.trial_heard + ". Try again, or record your knocks again.", status.x + 40, y,
|
||||
26, 36, status.w - 80, 3, kDim);
|
||||
}
|
||||
|
||||
button({kMargin, 656, 260, 72}, "Record again", PanelClick{PanelCommand::Calibrate, view.kind}, std::nullopt);
|
||||
@@ -487,9 +494,7 @@ void Panel::settings_screen(const PanelState& state) {
|
||||
"Click on a triple knock and on buttons", true, true);
|
||||
switch_row({kMargin, top + 2 * row, w, row}, PanelClick{PanelCommand::BarIcon}, state.classic_bar_icon,
|
||||
"Classic bar icon", "Show the logo colors in the dashboard bar", true, true);
|
||||
Canvas icon(64, 64);
|
||||
draw_icon(icon, state.classic_bar_icon ? IconStyle::BarColor : IconStyle::BarWhite);
|
||||
canvas_.draw(icon, static_cast<int>(kMargin + w - 24 - 80 - 40 - 64), static_cast<int>(top + 2 * row + (row - 64) / 2));
|
||||
canvas_.draw(icon(64, state.classic_bar_icon ? IconStyle::BarColor : IconStyle::BarWhite), static_cast<int>(kMargin + w - 24 - 80 - 40 - 64), static_cast<int>(top + 2 * row + (row - 64) / 2));
|
||||
button_row({kMargin, top + 3 * row, w, row}, "Guided tour", "Learn the knocks again", "Replay",
|
||||
PanelClick{PanelCommand::StartTour}, std::nullopt, false);
|
||||
button_row({kMargin, top + 4 * row, w, row}, "Something wrong?", "Tell the developer on GitHub", "Report",
|
||||
@@ -594,10 +599,7 @@ void Panel::report_card(const PanelState& state) {
|
||||
}
|
||||
const std::string hint = state.report_full ? "Hold the phone up to a lens. Use the Frame's Wi-Fi."
|
||||
: "The Frame is offline. Save the report below to attach it.";
|
||||
for (const auto& line : canvas_.wrap(regular_, hint, 26, text_w, 3)) {
|
||||
canvas_.text(regular_, line, x, y, 26, kDim);
|
||||
y += 36;
|
||||
}
|
||||
paragraph(regular_, hint, x, y, 26, 36, text_w, 3, kDim);
|
||||
|
||||
// Knock problems need the motion itself, so the report can carry a recording.
|
||||
const Rect record{x, card.y + 420, 240, 64};
|
||||
@@ -610,11 +612,7 @@ void Panel::report_card(const PanelState& state) {
|
||||
else if (state.recorded) record_note = state.report_full ? "Recording added. Your phone gets it in one report file."
|
||||
: "Recording saved as knockthru-knocks.csv.gz.";
|
||||
const float record_x = record.x + record.w + 24;
|
||||
float ry = record.y + record.h / 2 - 16;
|
||||
for (const auto& line : canvas_.wrap(regular_, record_note, 24, text_w - record.w - 24, 2)) {
|
||||
canvas_.text(regular_, line, record_x, ry, 24, kDim);
|
||||
ry += 32;
|
||||
}
|
||||
paragraph(regular_, record_note, record_x, record.y + record.h / 2 - 16, 24, 32, text_w - record.w - 24, 2, kDim);
|
||||
|
||||
// The code on white with a 4-module quiet zone, in whole pixels per module.
|
||||
const Rect white{card.x + card.w - 48 - box, card.y + 48, box, box};
|
||||
@@ -666,11 +664,7 @@ void Panel::confirm_card(const std::string& title, const std::string& text, cons
|
||||
const Rect card{220, 190, kWidth - 440, 390};
|
||||
canvas_.rounded_rect(card, kRadius, kSurface);
|
||||
canvas_.text(bold_, title, card.x + 48, card.y + 72, 38, kText);
|
||||
float y = card.y + 140;
|
||||
for (const auto& line : canvas_.wrap(regular_, text, 28, card.w - 96, 3)) {
|
||||
canvas_.text(regular_, line, card.x + 48, y, 28, kText);
|
||||
y += 40;
|
||||
}
|
||||
paragraph(regular_, text, card.x + 48, card.y + 140, 28, 40, card.w - 96, 3, kText);
|
||||
const float by = card.y + card.h - 48 - 72;
|
||||
button({card.x + 48, by, 240, 72}, cancel, std::nullopt, cancel_to);
|
||||
button({card.x + card.w - 48 - 240, by, 240, 72}, confirm, confirm_click, Screen::Main, true, true, danger);
|
||||
@@ -742,11 +736,8 @@ void Panel::tour_knock_step(const PanelState& state, const TourView& tour) {
|
||||
|
||||
const float x = box.x + box.w + 48, w = kWidth - kMargin - x;
|
||||
float y = 190;
|
||||
for (const auto& line : canvas_.wrap(bold_,
|
||||
triple ? "Knock three times to switch between translucent and opaque" : "Knock twice to switch passthrough", 36, w, 2)) {
|
||||
canvas_.text(bold_, line, x, y, 36, kText);
|
||||
y += 48;
|
||||
}
|
||||
const auto title = triple ? "Knock three times to switch between translucent and opaque" : "Knock twice to switch passthrough";
|
||||
y = paragraph(bold_, title, x, y, 36, 48, w, 2, kText);
|
||||
y += 4;
|
||||
// Lists only the kinds left on, since the others do nothing.
|
||||
static constexpr const char* kWays[3] = {"controllers together", "a controller on a table", "a controller on the headset"};
|
||||
@@ -757,10 +748,7 @@ void Panel::tour_knock_step(const PanelState& state, const TourView& tour) {
|
||||
for (std::size_t i = 0; i < ways.size(); ++i)
|
||||
list += (i == 0 ? "" : i + 1 == ways.size() ? (ways.size() == 2 ? " or " : ", or ") : ", ") + ways[i];
|
||||
const std::string how = ways.size() > 1 ? "Any of these work: " + list + "." : "Use " + list + ".";
|
||||
for (const auto& line : canvas_.wrap(regular_, how, 28, w, 3)) {
|
||||
canvas_.text(regular_, line, x, y, 28, kDim);
|
||||
y += 40;
|
||||
}
|
||||
paragraph(regular_, how, x, y, 28, 40, w, 3, kDim);
|
||||
|
||||
const Rect status{x, 408, w, 220};
|
||||
const double heard_t = triple ? tour.triple_t : tour.double_t;
|
||||
@@ -768,11 +756,7 @@ void Panel::tour_knock_step(const PanelState& state, const TourView& tour) {
|
||||
if (!triple && !state.room_view_error.empty()) {
|
||||
canvas_.rounded_rect(status, kRadius, kSurface);
|
||||
canvas_.text(bold_, "Passthrough unavailable", status.x + 40, status.y + 64, 34, kError);
|
||||
float line_y = status.y + 118;
|
||||
for (const auto& line : canvas_.wrap(regular_, state.room_view_error, 26, status.w - 80, 2)) {
|
||||
canvas_.text(regular_, line, status.x + 40, line_y, 26, kDim);
|
||||
line_y += 36;
|
||||
}
|
||||
paragraph(regular_, state.room_view_error, status.x + 40, status.y + 118, 26, 36, status.w - 80, 2, kDim);
|
||||
} else if (heard_t < 0) {
|
||||
canvas_.rounded_rect(status, kRadius, kSurface);
|
||||
const float pulse = 0.5f + 0.5f * std::sin(static_cast<float>(now_) * 2 * anim::kPi / 1.4f);
|
||||
@@ -805,9 +789,7 @@ void Panel::tour_screen(const PanelState& state, const TourView& tour) {
|
||||
const float cx = kWidth / 2.0f;
|
||||
switch (tour.step) {
|
||||
case Step::Welcome: {
|
||||
Canvas icon(152, 152);
|
||||
draw_icon(icon);
|
||||
canvas_.draw(icon, (kWidth - 152) / 2, 150);
|
||||
canvas_.draw(icon(152, IconStyle::Tile), (kWidth - 152) / 2, 150);
|
||||
canvas_.text(bold_, "Welcome to KnockThru", cx, 370, 48, kText, Align::Center);
|
||||
canvas_.text(regular_, "Knock to switch passthrough, without the side button.", cx, 440, 30, kDim, Align::Center);
|
||||
canvas_.text(regular_, "This tour takes a few minutes.", cx, 484, 30, kDim, Align::Center);
|
||||
|
||||
+5
-1
@@ -4,6 +4,7 @@
|
||||
#include "knock_detector.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
@@ -60,7 +61,6 @@ struct UpdateNotice {
|
||||
struct PanelState {
|
||||
std::optional<bool> room_view; // unknown until the first debug port read
|
||||
std::string room_view_error; // why Room View cannot be read or changed
|
||||
bool dashboard_changed = false; // the Room View button is missing, likely after a Steam update
|
||||
std::optional<bool> opaque; // Room View style, unknown without SteamVR
|
||||
bool busy = false; // a Room View change is in progress
|
||||
bool paused = false;
|
||||
@@ -168,8 +168,12 @@ private:
|
||||
std::optional<View> view_;
|
||||
std::optional<TourView::Step> tour_step_; // the tour step on screen, so each step rises in
|
||||
double view_t_ = 0;
|
||||
std::map<std::pair<int, IconStyle>, Canvas> icons_; // drawn icons by size and style
|
||||
|
||||
const Canvas& icon(int size, IconStyle style);
|
||||
bool hovered(Rect rect) const;
|
||||
float paragraph(Font& font, const std::string& text, float x, float y, float size, float leading, float width,
|
||||
int max_lines, Color color);
|
||||
void header(const std::string& title);
|
||||
void main_screen(const PanelState& state);
|
||||
void pick_screen(const PanelState& state);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#include "poses.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
|
||||
namespace knockthru {
|
||||
|
||||
Vec3 position_of(const vr::TrackedDevicePose_t& pose) {
|
||||
const auto& m = pose.mDeviceToAbsoluteTracking.m;
|
||||
return {m[0][3], m[1][3], m[2][3]};
|
||||
}
|
||||
|
||||
Vec3 velocity_of(const vr::TrackedDevicePose_t& pose) {
|
||||
const float* v = pose.vVelocity.v;
|
||||
return {v[0], v[1], v[2]};
|
||||
}
|
||||
|
||||
void write_csv_row(std::ostream& out, double t, Device device, const Vec3& p, const Vec3& v, const float* angular) {
|
||||
char line[256];
|
||||
const int n = std::snprintf(line, sizeof line, "%.6f,%s,1,%.5f,%.5f,%.5f,%.5f,%.5f,%.5f,", t,
|
||||
kDeviceNames[static_cast<int>(device)], p[0], p[1], p[2], v[0], v[1], v[2]);
|
||||
if (angular) std::snprintf(line + n, sizeof line - n, "%.5f,%.5f,%.5f\n", angular[0], angular[1], angular[2]);
|
||||
else std::snprintf(line + n, sizeof line - n, "0,0,0\n");
|
||||
out << line;
|
||||
}
|
||||
|
||||
} // namespace knockthru
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
#pragma once
|
||||
|
||||
#include "knock_detector.hpp"
|
||||
|
||||
#include <openvr.h>
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <ostream>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace knockthru {
|
||||
|
||||
constexpr std::array<const char*, 3> kDeviceNames{"hmd", "left", "right"}; // indexed by Device
|
||||
|
||||
Vec3 position_of(const vr::TrackedDevicePose_t& pose);
|
||||
Vec3 velocity_of(const vr::TrackedDevicePose_t& pose);
|
||||
|
||||
constexpr const char* kCsvHeader = "t_s,device,valid,px,py,pz,vx,vy,vz,wx,wy,wz\n";
|
||||
|
||||
// One row of the tests/data CSV format. Without `angular`, its columns are zero.
|
||||
void write_csv_row(std::ostream& out, double t, Device device, const Vec3& p, const Vec3& v,
|
||||
const float* angular = nullptr);
|
||||
|
||||
// Reads headset and controller poses once per call. SteamVR poses are only
|
||||
// valid in the standing universe on the Frame.
|
||||
class PoseSampler {
|
||||
public:
|
||||
using Callback = std::function<void(Device, const vr::TrackedDevicePose_t&)>;
|
||||
|
||||
void sample(const Callback& callback) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now >= next_refresh_) {
|
||||
auto* system = vr::VRSystem();
|
||||
indices_[0] = vr::k_unTrackedDeviceIndex_Hmd;
|
||||
indices_[1] = system->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_LeftHand);
|
||||
indices_[2] = system->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_RightHand);
|
||||
next_refresh_ = now + std::chrono::seconds(1);
|
||||
}
|
||||
// Fetches only up to the highest device index in use, not all 64.
|
||||
std::uint32_t count = 0;
|
||||
for (const auto index : indices_)
|
||||
if (index < vr::k_unMaxTrackedDeviceCount) count = std::max(count, index + 1);
|
||||
vr::VRSystem()->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, poses_.data(), count);
|
||||
for (std::size_t i = 0; i < 3; ++i) {
|
||||
if (indices_[i] >= vr::k_unMaxTrackedDeviceCount) continue;
|
||||
const auto& pose = poses_[indices_[i]];
|
||||
if (pose.bPoseIsValid) callback(static_cast<Device>(i), pose);
|
||||
}
|
||||
}
|
||||
|
||||
static void feed(KnockDetector& detector, double t, Device device, const vr::TrackedDevicePose_t& pose) {
|
||||
detector.push(device, t, position_of(pose), velocity_of(pose));
|
||||
}
|
||||
|
||||
static void write_csv(std::ostream& out, double t, Device device, const vr::TrackedDevicePose_t& pose) {
|
||||
write_csv_row(out, t, device, position_of(pose), velocity_of(pose), pose.vAngularVelocity.v);
|
||||
}
|
||||
|
||||
private:
|
||||
std::array<vr::TrackedDeviceIndex_t, 3> indices_{};
|
||||
std::array<vr::TrackedDevicePose_t, vr::k_unMaxTrackedDeviceCount> poses_{};
|
||||
std::chrono::steady_clock::time_point next_refresh_{};
|
||||
};
|
||||
|
||||
class Clock {
|
||||
public:
|
||||
double now() const { return std::chrono::duration<double>(std::chrono::steady_clock::now() - start_).count(); }
|
||||
// Sleeps until the next tick, `step` after the last one.
|
||||
void tick(std::chrono::milliseconds step = std::chrono::milliseconds(1)) {
|
||||
next_ += step;
|
||||
std::this_thread::sleep_until(next_);
|
||||
}
|
||||
|
||||
private:
|
||||
std::chrono::steady_clock::time_point start_ = std::chrono::steady_clock::now();
|
||||
std::chrono::steady_clock::time_point next_ = start_;
|
||||
};
|
||||
|
||||
// Samples poses every millisecond on its own thread. Knock strength is the
|
||||
// velocity change between samples over the time between them, so a main loop
|
||||
// busy drawing the panel would stretch those gaps and garble the strength.
|
||||
class PoseThread {
|
||||
public:
|
||||
struct Sample {
|
||||
Device device;
|
||||
double t;
|
||||
Vec3 position, velocity;
|
||||
};
|
||||
|
||||
explicit PoseThread(const Clock& clock) : thread_([this, &clock] { loop(clock); }) {}
|
||||
~PoseThread() {
|
||||
stop_ = true;
|
||||
thread_.join();
|
||||
}
|
||||
PoseThread(const PoseThread&) = delete;
|
||||
|
||||
// Returns the samples since the last call, oldest first.
|
||||
std::vector<Sample> take() {
|
||||
std::vector<Sample> samples;
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
samples.swap(samples_);
|
||||
return samples;
|
||||
}
|
||||
|
||||
private:
|
||||
void loop(const Clock& clock) {
|
||||
PoseSampler sampler;
|
||||
Clock ticker;
|
||||
while (!stop_) {
|
||||
const double t = clock.now();
|
||||
sampler.sample([&](Device device, const vr::TrackedDevicePose_t& pose) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
samples_.push_back({device, t, position_of(pose), velocity_of(pose)});
|
||||
});
|
||||
ticker.tick();
|
||||
}
|
||||
}
|
||||
|
||||
std::mutex mutex_;
|
||||
std::vector<Sample> samples_;
|
||||
std::atomic<bool> stop_{false};
|
||||
std::thread thread_; // last, so it starts after the members it uses
|
||||
};
|
||||
|
||||
} // namespace knockthru
|
||||
@@ -0,0 +1,76 @@
|
||||
#include "steamvr.hpp"
|
||||
|
||||
#include "config.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
|
||||
namespace knockthru {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
volatile std::sig_atomic_t stopping = 0;
|
||||
|
||||
namespace {
|
||||
|
||||
std::ofstream log_file;
|
||||
|
||||
} // namespace
|
||||
|
||||
void log(const std::string& message) {
|
||||
const auto now = std::time(nullptr);
|
||||
char stamp[32];
|
||||
std::strftime(stamp, sizeof stamp, "%F %T", std::localtime(&now));
|
||||
std::cout << message << std::endl;
|
||||
if (log_file.is_open()) log_file << stamp << " " << message << std::endl;
|
||||
}
|
||||
|
||||
void open_log() {
|
||||
const auto path = user_directory() / "application.log";
|
||||
fs::create_directories(path.parent_path());
|
||||
std::error_code error;
|
||||
if (fs::exists(path) && fs::file_size(path, error) > 1024 * 1024) fs::rename(path, path.string() + ".old", error);
|
||||
log_file.open(path, std::ios::app);
|
||||
}
|
||||
|
||||
// Resolved once, because /proc/self/exe stops resolving after the install
|
||||
// directory is deleted.
|
||||
fs::path executable() {
|
||||
static const fs::path path = fs::canonical("/proc/self/exe");
|
||||
return path;
|
||||
}
|
||||
|
||||
fs::path install_directory() { return executable().parent_path(); }
|
||||
|
||||
void check_app(vr::EVRApplicationError error, const char* what) {
|
||||
if (error != vr::VRApplicationError_None)
|
||||
throw std::runtime_error(std::string(what) + ": " + vr::VRApplications()->GetApplicationsErrorNameFromEnum(error));
|
||||
}
|
||||
|
||||
std::string scene_app_key() {
|
||||
const auto pid = vr::VRCompositor() ? vr::VRCompositor()->GetCurrentSceneFocusProcess() : 0;
|
||||
if (!pid) return "";
|
||||
std::array<char, vr::k_unMaxApplicationKeyLength> key{};
|
||||
if (vr::VRApplications()->GetApplicationKeyByProcessId(pid, key.data(), static_cast<uint32_t>(key.size())) !=
|
||||
vr::VRApplicationError_None) return "";
|
||||
return key.data();
|
||||
}
|
||||
|
||||
std::string app_name(const std::string& key) {
|
||||
if (key.empty()) return "";
|
||||
std::array<char, 256> name{};
|
||||
vr::EVRApplicationError error = vr::VRApplicationError_None;
|
||||
vr::VRApplications()->GetApplicationPropertyString(key.c_str(), vr::VRApplicationProperty_Name_String,
|
||||
name.data(), static_cast<uint32_t>(name.size()), &error);
|
||||
return error == vr::VRApplicationError_None ? name.data() : "";
|
||||
}
|
||||
|
||||
std::string steamvr_version() {
|
||||
const char* version = vr::VRSystem() ? vr::VRSystem()->GetRuntimeVersion() : nullptr;
|
||||
return version && *version ? version : "unknown";
|
||||
}
|
||||
|
||||
} // namespace knockthru
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <openvr.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <filesystem>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace knockthru {
|
||||
|
||||
inline constexpr const char* kAppKey = "local.knockthru";
|
||||
inline constexpr const char* kPanelKey = "local.knockthru.panel";
|
||||
inline constexpr const char* kVersion = KNOCKTHRU_VERSION;
|
||||
|
||||
// Set by SIGINT, SIGTERM, and SteamVR's quit event. Long waits check it.
|
||||
extern volatile std::sig_atomic_t stopping;
|
||||
|
||||
// Prints a line, and appends it with a timestamp to application.log once open_log() ran.
|
||||
void log(const std::string& message);
|
||||
void open_log();
|
||||
|
||||
// This program's file, and the folder it runs from.
|
||||
std::filesystem::path executable();
|
||||
std::filesystem::path install_directory();
|
||||
|
||||
class VrSession {
|
||||
public:
|
||||
// With `wait`, retries every 5 s until SteamVR is up or the process stops.
|
||||
explicit VrSession(vr::EVRApplicationType type, bool wait = false) {
|
||||
for (unsigned attempt = 0; !stopping; ++attempt) {
|
||||
vr::EVRInitError error = vr::VRInitError_None;
|
||||
vr::VR_Init(&error, type);
|
||||
if (error == vr::VRInitError_None) return;
|
||||
const std::string reason = vr::VR_GetVRInitErrorAsEnglishDescription(error);
|
||||
if (!wait) throw std::runtime_error("SteamVR unavailable: " + reason);
|
||||
if (attempt % 12 == 0) log("Waiting for SteamVR: " + reason);
|
||||
for (int i = 0; i < 50 && !stopping; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
throw std::runtime_error("Stopped while waiting for SteamVR");
|
||||
}
|
||||
~VrSession() { vr::VR_Shutdown(); }
|
||||
VrSession(const VrSession&) = delete;
|
||||
};
|
||||
|
||||
// Throws with SteamVR's name for `error`, unless it is VRApplicationError_None.
|
||||
void check_app(vr::EVRApplicationError error, const char* what);
|
||||
|
||||
// The app key of the app in front, or "" for none.
|
||||
std::string scene_app_key();
|
||||
// The display name of the app with `key`, or "".
|
||||
std::string app_name(const std::string& key);
|
||||
std::string steamvr_version();
|
||||
|
||||
} // namespace knockthru
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
// The pass and fail report the test programs share.
|
||||
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
inline int failures = 0;
|
||||
|
||||
inline void check(bool ok, const std::string& what) {
|
||||
std::cout << (ok ? " ok " : " FAIL ") << what << "\n";
|
||||
if (!ok) ++failures;
|
||||
}
|
||||
|
||||
// Prints the summary. Returns the exit code for main().
|
||||
inline int finish() {
|
||||
std::cout << (failures ? std::to_string(failures) + " failed\n" : "all passed\n");
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
+18
-14
@@ -1,4 +1,5 @@
|
||||
// Config tests: the tour flag and factory reset, in a scratch user directory.
|
||||
#include "check.hpp"
|
||||
#include "config.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
@@ -12,17 +13,6 @@
|
||||
using namespace knockthru;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void check(bool ok, const std::string& what) {
|
||||
std::cout << (ok ? " ok " : " FAIL ") << what << "\n";
|
||||
if (!ok) ++failures;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
const auto root = fs::temp_directory_path() / ("knockthru-config-test-" + std::to_string(getpid()));
|
||||
fs::remove_all(root);
|
||||
@@ -55,7 +45,22 @@ int main() {
|
||||
check(fs::exists(user / "application.log"), "reset keeps application.log");
|
||||
const auto after = load_config(install);
|
||||
check(!after.tour_done && !after.paused, "settings are back to defaults after reset");
|
||||
check(after.check_updates, "the weekly update check is on by default");
|
||||
check(after.check_updates, "the daily update check is on by default");
|
||||
|
||||
std::ofstream(user / "config.json") << R"({"paused": true, "classic_bar_icon": true, "sounds": false,
|
||||
"tour_done": true, "check_updates": false})";
|
||||
const auto flags = load_config(install);
|
||||
check(flags.paused && flags.classic_bar_icon && !flags.sounds && flags.tour_done && !flags.check_updates,
|
||||
"every true or false setting loads from config.json");
|
||||
std::ofstream(user / "config.json") << R"({"sounds": "yes"})";
|
||||
try {
|
||||
load_config(install);
|
||||
check(false, "a setting that is not true or false is rejected");
|
||||
} catch (const std::exception& error) {
|
||||
check(std::string(error.what()).find("sounds must be true or false") != std::string::npos,
|
||||
"a setting that is not true or false is rejected");
|
||||
}
|
||||
fs::remove(user / "config.json");
|
||||
|
||||
{
|
||||
AppState state = load_state();
|
||||
@@ -78,6 +83,5 @@ int main() {
|
||||
}
|
||||
|
||||
fs::remove_all(root);
|
||||
std::cout << (failures ? std::to_string(failures) + " failed\n" : "all passed\n");
|
||||
return failures ? 1 : 0;
|
||||
return finish();
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
+15
-11
@@ -1,5 +1,6 @@
|
||||
// Detector tests: synthetic sequences, then replays of the Steam Frame
|
||||
// recordings in tests/data/ (see docs/knock-signal.md).
|
||||
// recordings in tests/data/.
|
||||
#include "check.hpp"
|
||||
#include "calibrator.hpp"
|
||||
#include "config.hpp"
|
||||
#include "knock_detector.hpp"
|
||||
@@ -17,13 +18,6 @@ using namespace knockthru;
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void check(bool ok, const std::string& what) {
|
||||
std::cout << (ok ? " ok " : " FAIL ") << what << "\n";
|
||||
if (!ok) ++failures;
|
||||
}
|
||||
|
||||
std::vector<KnockEvent> drain(KnockDetector& detector, double now, std::vector<KnockEvent> events = {}) {
|
||||
if (auto event = detector.poll(now)) events.push_back(*event);
|
||||
return events;
|
||||
@@ -116,7 +110,8 @@ DetectorConfig test_config() {
|
||||
{"min_approach_together", &config.min_approach[0]}, {"min_approach_surface", &config.min_approach[1]},
|
||||
{"min_approach_headset", &config.min_approach[2]},
|
||||
{"max_speed_after", &config.max_speed_after}, {"surface_speed_after", &config.surface_speed_after},
|
||||
{"max_speed_before", &config.max_speed_before}, {"same_spot", &config.same_spot},
|
||||
{"max_speed_before", &config.max_speed_before}, {"surface_speed_before", &config.surface_speed_before},
|
||||
{"same_spot", &config.same_spot},
|
||||
{"min_gap_s", &config.min_gap_s}, {"fast_gap_s", &config.fast_gap_s}, {"max_gap_s", &config.max_gap_s},
|
||||
{"quiet_before_s", &config.quiet_before_s}};
|
||||
std::stringstream list(set);
|
||||
@@ -231,6 +226,16 @@ void replay_tests() {
|
||||
check(surface["surfacex2"] >= 16, "surface: at least 16 surface doubles");
|
||||
auto headset = replay("headset");
|
||||
check(headset["headsetx2"] >= 15, "headset: at least 15 headset doubles");
|
||||
// Doubles and triples knocked on a leg with each hand, headset worn. At
|
||||
// surface_speed_before 1.8 most right-hand knocks were lost, giving 9 actions.
|
||||
auto leg = replay("leg");
|
||||
check(leg["surfacex2"] + leg["surfacex3"] >= 14 && total(leg) == leg["surfacex2"] + leg["surfacex3"],
|
||||
"leg: at least 14 surface actions, nothing else");
|
||||
// About 40 doubles knocked on a leg, a few seconds apart, mostly with the
|
||||
// right hand. At same_spot 0.03, 20 counted.
|
||||
auto leg_doubles = replay("leg-doubles");
|
||||
check(leg_doubles["surfacex2"] >= 23 && total(leg_doubles) == leg_doubles["surfacex2"],
|
||||
"leg-doubles: at least 23 surface doubles, nothing else");
|
||||
check(total(replay("play")) == 0, "play: no actions");
|
||||
check(total(replay("noise")) == 0, "noise: no actions");
|
||||
// The controllers bumping while the wearer fit the strap on each one, in Into the Radius 2.
|
||||
@@ -344,6 +349,5 @@ int main(int argc, char** argv) {
|
||||
replay_tests();
|
||||
calibration_tests();
|
||||
calibrated_replay_tests();
|
||||
std::cout << (failures ? std::to_string(failures) + " failed\n" : "all passed\n");
|
||||
return failures ? 1 : 0;
|
||||
return finish();
|
||||
}
|
||||
@@ -434,7 +434,6 @@ int main(int argc, char** argv) {
|
||||
broken.room_view_error = "Turn on CEF Remote Debugging in the Frame's Developer settings.";
|
||||
broken.opaque = false;
|
||||
panel.render(broken).write_png(out / "panel-broken.png");
|
||||
broken.dashboard_changed = true;
|
||||
broken.room_view_error = "A Steam update moved the Room View button. Update KnockThru in Settings.";
|
||||
panel.render(broken).write_png(out / "panel-dashboard-changed.png");
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Update tests: version order, manifest parsing, SHA-256, the file swap, and the issue link,
|
||||
// in a scratch directory. The install test reads a local ZIP through curl's file:// support.
|
||||
#include "check.hpp"
|
||||
#include "report.hpp"
|
||||
#include "report_server.hpp"
|
||||
#include "update.hpp"
|
||||
@@ -28,13 +29,6 @@ namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void check(bool ok, const std::string& what) {
|
||||
std::cout << (ok ? " ok " : " FAIL ") << what << "\n";
|
||||
if (!ok) ++failures;
|
||||
}
|
||||
|
||||
void write(const fs::path& path, const std::string& text) {
|
||||
fs::create_directories(path.parent_path());
|
||||
std::ofstream(path, std::ios::trunc | std::ios::binary) << text;
|
||||
@@ -309,6 +303,5 @@ int main() {
|
||||
}
|
||||
|
||||
fs::remove_all(root);
|
||||
if (failures) std::cout << failures << " update checks failed\n";
|
||||
return failures ? 1 : 0;
|
||||
return finish();
|
||||
}
|
||||
Reference in new issue
Block a user