mirror of
https://github.com/VioletTides/KnockThru.git
synced 2026-10-17 17:01:42 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3f52b84fe | ||
|
|
9f1e6d3c57 | ||
|
|
5dfc55e770 | ||
|
|
e33931a206 | ||
|
|
391b6d41d9 | ||
|
|
ffc5aa022a | ||
|
|
7898749942 | ||
|
|
1a7485c8fa | ||
|
|
f55869acf2 | ||
|
|
086ca05af0 | ||
|
|
f977454de6 | ||
|
|
b1b217bcbc | ||
|
|
616d1d4308 | ||
|
|
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
@@ -0,0 +1,4 @@
|
||||
contact_links:
|
||||
- name: Questions and ideas to talk over
|
||||
url: https://github.com/VioletTides/KnockThru/discussions
|
||||
about: Ask how something works, or talk an idea over before filing it.
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Idea
|
||||
description: Suggest a feature or a change to KnockThru
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for the idea! A sentence or two per box is plenty.
|
||||
If you'd rather talk it over first, start a [discussion](https://github.com/VioletTides/KnockThru/discussions) instead.
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: What is the idea about?
|
||||
options:
|
||||
- Something new a knock can do
|
||||
- How knocks are detected or calibrated
|
||||
- Settings or the panel in the headset
|
||||
- Installing or updating
|
||||
- Something else
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: What would you like KnockThru to do?
|
||||
placeholder: Show and hide the performance overlay with a triple knock.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: When would you use it?
|
||||
description: What you were doing when you wanted it.
|
||||
placeholder: I check my frame rate in some games, and I don't want to open a menu each time.
|
||||
- type: dropdown
|
||||
id: knock-kinds
|
||||
attributes:
|
||||
label: Which knocks do you use?
|
||||
description: Pick any that apply. This helps if the idea is about knocks.
|
||||
multiple: true
|
||||
options:
|
||||
- Controllers together
|
||||
- Controller on a surface (or your leg)
|
||||
- Knock on the headset
|
||||
@@ -3,3 +3,4 @@
|
||||
/.zig-cache/
|
||||
*.log
|
||||
/compile_commands.json
|
||||
/CLAUDE.md
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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 itself as a SteamVR overlay app.
|
||||
|
||||
Read `docs/README.md` before you change knock detection, calibration, or how the app switches passthrough. It links one page for each.
|
||||
|
||||
## 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: `node scripts/package.mjs --build-dir build-arm64 --version X.Y.Z`
|
||||
- Detector 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`
|
||||
|
||||
CI runs the host build, `ctest`, and the Frame build on every pull request.
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep `src/knock_detector.*` free of OpenVR types. The host tests replay the recordings in `tests/data/` through it.
|
||||
- After any detector change, `ctest` must pass: no actions in `play`, `noise`, or `straps`, and no wrong actions in `mixed`. If you change a threshold, update the docs page that explains it.
|
||||
- Read poses in `vr::TrackingUniverseStanding`. Poses in the raw universe are wrong on the Frame.
|
||||
- `knockthru` and `knockthru-ctl` ship as byte-identical copies. SteamVR identifies apps by executable path, so management commands re-exec through `knockthru-ctl` to keep the resident app's identity.
|
||||
- Call every gesture a "knock" in code, docs, and UI text.
|
||||
@@ -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.
|
||||
+12
-3
@@ -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)
|
||||
@@ -14,7 +14,7 @@ endif()
|
||||
include(cmake/OpenVRLoader.cmake)
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
add_library(knockthru_core STATIC src/knock_detector.cpp src/config.cpp src/calibrator.cpp src/update.cpp src/report.cpp src/report_server.cpp)
|
||||
add_library(knockthru_core STATIC src/knock_detector.cpp src/config.cpp src/calibrator.cpp src/update.cpp src/report.cpp src/report_server.cpp src/room_view.cpp)
|
||||
target_include_directories(knockthru_core PUBLIC src third_party/json)
|
||||
target_compile_options(knockthru_core PRIVATE -Wall -Wextra -Wpedantic)
|
||||
|
||||
@@ -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/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>"
|
||||
@@ -51,6 +54,12 @@ target_link_libraries(update_test PRIVATE knockthru_core)
|
||||
target_compile_options(update_test PRIVATE -Wall -Wextra -Wpedantic)
|
||||
add_test(NAME update COMMAND update_test)
|
||||
|
||||
add_executable(room_view_test tests/room_view_test.cpp)
|
||||
target_link_libraries(room_view_test PRIVATE knockthru_core Threads::Threads)
|
||||
target_compile_options(room_view_test PRIVATE -Wall -Wextra -Wpedantic)
|
||||
add_test(NAME room_view COMMAND room_view_test)
|
||||
set_tests_properties(room_view PROPERTIES SKIP_RETURN_CODE 77)
|
||||
|
||||
# Renders the dashboard panel in sample states to PNG files, for review.
|
||||
add_executable(panel_preview tests/panel_preview.cpp)
|
||||
target_link_libraries(panel_preview PRIVATE knockthru_ui)
|
||||
|
||||
@@ -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,7 +235,7 @@ 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.
|
||||
[`docs/`](docs/README.md) explains how knock detection and calibration work, how they were tuned, and how the app fits together.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# KnockThru docs
|
||||
|
||||
These pages explain how KnockThru works on the inside. To install and use it, read the [main README](../README.md) instead.
|
||||
|
||||
- [How knock detection works](knock-detection.md): how KnockThru tells a knock apart from swinging your arms, and where each rule came from.
|
||||
- [How calibration works](calibration.md): what the calibration walkthrough measures, and how it turns your knocks into thresholds.
|
||||
- [The test recordings](test-recordings.md): the motion recordings from a Steam Frame that every change gets tested against.
|
||||
- [How the app fits together](how-the-app-works.md): what runs on the headset, how it switches passthrough, and where your settings live.
|
||||
|
||||
Every number in these pages comes from the code or from the test recordings. If you change a threshold, update the page that explains it.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,82 @@
|
||||
# How calibration works
|
||||
|
||||
Everyone knocks a little differently. Some people knock hard, some barely touch. The default thresholds catch most knocks, but soft ones can slip under them. Calibration measures 10 of your knocks and sets the thresholds for that kind of knock to fit you.
|
||||
|
||||
The code is in `src/calibrator.cpp`. The saved result goes to `~/.config/knockthru/calibration.json`.
|
||||
|
||||
## The walkthrough
|
||||
|
||||

|
||||
|
||||
1. **Pick** the kind of knock to calibrate: together, surface, or headset.
|
||||
2. **Learn** how to knock it, then press Start.
|
||||
3. **Record** 10 single knocks within 90 s. A ring fills before each one. One buzz means the knock counted.
|
||||
4. **Try** the new thresholds with a double knock. Save unlocks only if the try works. The try never switches passthrough.
|
||||
|
||||
The first 3 s after Start don't count, because the hand coming down from pressing Start once counted as a knock. After each counted knock, the next one has to wait 1 s, which is how long the ring takes to fill.
|
||||
|
||||
You can also calibrate from a terminal on the Frame with `knockthru --calibrate together` (or `surface`, or `headset`).
|
||||
|
||||
## Catching soft knocks without catching everything else
|
||||
|
||||
To hear soft knocks, calibration lowers the spike thresholds while it records. Surface knocks need 30 m/s² instead of 60, and together and headset knocks need 12 instead of 40.
|
||||
|
||||
At those thresholds, almost anything looks like a knock. The `play` and `noise` recordings gave 84 and 82 knock candidates in 45 s each, about one a second. Two more problems showed up during a real surface calibration on the Frame:
|
||||
|
||||
- Starting a hard swing from rest spiked at 37 m/s², 0.09 to 0.14 s before the real knock. That early spike took the count instead of the knock, which spiked at 146 and 461.
|
||||
- A slow hand movement counted as a knock.
|
||||
|
||||
So calibration is pickier about which knocks count. Knocks less than 0.25 s apart form a burst, and the burst is judged by its knock with the fastest approach. A burst counts when:
|
||||
|
||||
1. Nothing else happened in the 0.6 s before it. Waving makes clusters of spikes, and people wait between calibration knocks.
|
||||
2. Its knock approached at 0.25 m/s or faster. Approach speed is the controller's fastest speed in the 100 ms before the spike.
|
||||
3. For surface knocks, the controller was nearly still beforehand. It must have averaged under 0.6 m/s from 0.6 to 0.15 s before the knock. 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.
|
||||
4. Its spike reaches the floor for its kind (see below). A knock under the floor could never trigger anything, so the walkthrough says "too light" instead of counting it.
|
||||
|
||||
Together and headset knocks skip the stillness check. Their false knocks never got past the first two rules, and a headset knock starts with your arm rising.
|
||||
|
||||
With these rules, calibrating on the 90 s of `play` and `noise` counts no knocks at all. Calibrating on the `together`, `surface`, and `headset` recordings counts 10 of 10. In the Frame session above, the hard knocks count and the swing starts and slow movement don't.
|
||||
|
||||
## Why approach speed matters
|
||||
|
||||
The spike is a noisy measure of how hard you knocked. It divides a velocity change by the time between two controller updates, and that time varies from 2 to 7 ms. In the knock recordings, the hardest tenth of spikes ran 2.8 to 8.3 times the softest tenth.
|
||||
|
||||
Approach speed is steadier. The fastest tenth of approaches ran only 1.7 to 2.5 times the slowest tenth. Slow movement also falls well under it. That's why calibration sets an approach threshold as well as a spike threshold.
|
||||
|
||||
## Turning 10 knocks into thresholds
|
||||
|
||||
Each kind of knock gets two thresholds:
|
||||
|
||||
- **Spike threshold:** 60 percent of your weakest recorded spike. It never goes below the floor for that kind, and never above 150 m/s².
|
||||
- **Approach threshold:** 55 percent of your second slowest approach. The second slowest, not the slowest, so one odd knock can't drag it down.
|
||||
|
||||
The floors come from replaying the recordings:
|
||||
|
||||
| Kind | Floor | What goes wrong below it |
|
||||
| --- | --- | --- |
|
||||
| Surface | 50 m/s² | Replays split real surface doubles apart |
|
||||
| Together | 30 m/s² | The strap fitting bumps in the `straps` recording form a double |
|
||||
| Headset | 20 m/s² | Soft rebounds slip past the 105 ms gap and turn doubles into triples |
|
||||
|
||||
A calibration file saved under an older, lower floor loads with the current one.
|
||||
|
||||
To use the approach threshold, a set of knocks needs at least one knock that reaches it. Two other ways failed on the recordings:
|
||||
|
||||
- Dropping slow knocks before grouping them let leftover spikes form false doubles in `play` and `noise`. Slow knocks still join their set, so clapping runs stay rejected.
|
||||
- Making every knock in a set reach the threshold lost most together doubles, because the second knock of a quick double starts from the rebound and comes in slow.
|
||||
|
||||
### The slider that got cut
|
||||
|
||||
An earlier version had a sensitivity slider from 30 to 80 percent, in place of the fixed 55. In the replays, anything stricter than 55 percent only lost real doubles. 30 percent matched 55 on surface and headset knocks and did a little better on together knocks, but it leaves less room for movement the recordings don't have. The slider didn't earn its place, so the walkthrough uses 55 percent.
|
||||
|
||||
## How well it works
|
||||
|
||||
The tests calibrate each kind on its own recording, apply the result, and replay every recording. These are the results today:
|
||||
|
||||
| Kind | Spike threshold | Approach threshold | Doubles found | `mixed` right, of 24 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Together | 31.2 m/s² | 0.24 m/s | 19 | 22 |
|
||||
| Surface | 55.6 m/s² | 0.68 m/s | 18 | 23 |
|
||||
| Headset | 20 m/s² | 0.20 m/s | 22 | 23 |
|
||||
|
||||
Every row gives no actions in `play` and `noise`, and no wrong actions in `mixed`. With the default thresholds, the same recordings give 20 together, 18 surface, and 22 headset doubles, and 23 of 24 right in `mixed`.
|
||||
@@ -0,0 +1,59 @@
|
||||
# How the app fits together
|
||||
|
||||
KnockThru is one program that runs on the Steam Frame itself, not on a PC. SteamVR starts it as an overlay app, the same kind of app as a desktop or chat overlay. It shows up as a tab on the SteamVR dashboard.
|
||||
|
||||
The code is C++. Each part of the app lives in its own file pair under `src/`.
|
||||
|
||||
## From a knock to passthrough
|
||||
|
||||
1. **Reading motion.** A thread reads the headset and controller poses from SteamVR about once a millisecond (`src/poses.cpp`). Poses are only valid in SteamVR's standing tracking space on the Frame, so KnockThru reads them there.
|
||||
2. **Finding knocks.** The main loop feeds the poses to the knock detector (`src/knock_detector.cpp`). [How knock detection works](knock-detection.md) explains it.
|
||||
3. **Acting.** A double knock turns passthrough on or off. A triple knock switches its style. Both controllers buzz once for a double and twice for a triple. Three buzzes mean something failed.
|
||||
|
||||
Knocks do nothing while KnockThru is paused, or in a game where you turned knocks off.
|
||||
|
||||
## Switching passthrough
|
||||
|
||||
KnockThru turns passthrough (Room View) on and off by pressing the Room View button on the SteamVR dashboard bar, just as you would.
|
||||
|
||||
The Steam client draws the dashboard bar as a web page, and it has a browser debug port (CEF Remote Debugging, port 8080 on the Frame). KnockThru connects to that port, finds the Room View button on the bar page, and clicks it (`src/room_view.cpp`). Then it reads the button's state back to check that passthrough really changed. This is why CEF Remote Debugging must be on in the Frame's Developer settings. The approach comes from [toorux/steam-frame-passthrough-toggle](https://github.com/toorux/steam-frame-passthrough-toggle).
|
||||
|
||||
The style is simpler. Translucent and opaque are the SteamVR setting `camera.roomViewStyle`, 3 for translucent and 4 for opaque (`src/style.cpp`). KnockThru writes the setting and checks that SteamVR kept it. The values come from [KominoVR/frame-passthrough-shortcuts](https://github.com/KominoVR/frame-passthrough-shortcuts).
|
||||
|
||||
If a Steam update moves the Room View button, KnockThru can't find it any more. The dashboard tab then says "A Steam update moved the Room View button. Update KnockThru in Settings."
|
||||
|
||||
## The dashboard tab
|
||||
|
||||
KnockThru draws its own dashboard tab (`src/panel.cpp`). It draws text and shapes into an image in memory (`src/canvas.cpp`) and hands that image to SteamVR as a texture (`src/overlay_texture.cpp`). The tab holds the switches, Settings, the calibration walkthrough, and the first-run tour.
|
||||
|
||||
## Two copies of one program
|
||||
|
||||
The install folder has `knockthru` and `knockthru-ctl`, and they're the same file. SteamVR tells apps apart by the path of their program. The resident app that SteamVR starts is `knockthru`. Commands like `--uninstall` run through `knockthru-ctl`, so SteamVR never mistakes a command for a second copy of the app.
|
||||
|
||||
## Settings and files
|
||||
|
||||
| File | What it holds |
|
||||
| --- | --- |
|
||||
| `<install folder>/config.json` | The defaults that ship with each release |
|
||||
| `~/.config/knockthru/config.json` | Your settings. The dashboard tab writes here too |
|
||||
| `~/.config/knockthru/calibration.json` | Your calibrated thresholds, one entry per kind of knock |
|
||||
| `~/.config/knockthru/state.json` | What KnockThru remembers between runs, like the last update check |
|
||||
| `~/.config/knockthru/application.log` | The log |
|
||||
|
||||
KnockThru checks for changed settings every 2 s and reloads them. Your files live outside the install folder, so updates never touch them. The [main README](../README.md#for-tinkerers) lists every setting.
|
||||
|
||||
## Updates
|
||||
|
||||
Once a day, KnockThru asks GitHub for the latest release's manifest (`src/update.cpp`). The manifest lists the release ZIP and its SHA-256 checksum. To install an update, KnockThru:
|
||||
|
||||
1. Downloads the ZIP over HTTPS.
|
||||
2. Checks the ZIP's SHA-256 against the manifest, and stops if they differ.
|
||||
3. Unpacks it and checks that `knockthru-ctl` is an identical copy of `knockthru`.
|
||||
4. Copies each file in beside the old one under a temporary name, then renames it into place. A failed copy leaves the old version working.
|
||||
5. Restarts into the new version.
|
||||
|
||||
## Bug reports
|
||||
|
||||
**Report** in Settings writes a report with versions, SteamVR state, settings, and the end of the log (`src/report.cpp`). To get the report to your phone, it shows a QR code. The code links to a small web page the headset serves on your Wi-Fi (`src/report_server.cpp`). The link carries a random token, and the server stops after 15 minutes. On your phone, the page copies the report and opens a prefilled GitHub issue.
|
||||
|
||||
If knocks are the problem, **Record knocks** adds 20 s of motion to the report. [The test recordings](test-recordings.md) shows how to replay it.
|
||||
@@ -0,0 +1,102 @@
|
||||
# How knock detection works
|
||||
|
||||
KnockThru watches how your controllers and headset move, and looks for the short, sharp stop that a knock makes. This page explains what it looks for and why. The code is in `src/knock_detector.cpp`, and the thresholds are in `DetectorConfig` in `src/knock_detector.hpp`.
|
||||
|
||||
## What KnockThru can see
|
||||
|
||||
KnockThru can't read the controllers' accelerometers. The Frame has no `/dev/hidraw` devices that would expose them. What SteamVR does give every app is each device's position and velocity.
|
||||
|
||||
KnockThru reads those about once a millisecond. The headset reports a new velocity 1000 times a second, and each controller about 230 times a second.
|
||||
|
||||
A knock shows up as a sudden jump in velocity. KnockThru divides the change between two updates by the time between them. That gives an acceleration in m/s², and these pages call it the **spike**.
|
||||
|
||||
## Why a big spike isn't enough
|
||||
|
||||
The obvious rule is "a hard spike is a knock". It doesn't work. In the `play` recording, ordinary movement spiked at 696 m/s². The hardest knock in any of the knock recordings spiked at 552.
|
||||
|
||||
What separates a knock from a swing is what happens right after. When you knock, the controller stops dead. When you swing, it keeps going. In the first recordings, the controller moved at a median of 0.1 to 0.2 m/s 30 ms after a knock, and at 1.6 to 1.7 m/s 30 ms after a swing spike.
|
||||
|
||||
So a spike counts as a knock only when all of these hold:
|
||||
|
||||
1. It's the biggest spike within 3 updates either side.
|
||||
2. It's stronger than the threshold for its kind of knock (see below).
|
||||
3. 30 ms later, the controller moves slower than 0.4 m/s. Surface knocks get 0.55 m/s, because a hard knock on a table bounces back.
|
||||
4. 30 ms earlier, the controller moved slower than 1.8 m/s. Surface knocks get 2.2 m/s, because knocks on a leg come in fast.
|
||||
|
||||
KnockThru judges each spike about 50 ms after it happens, so the "30 ms later" part has already arrived.
|
||||
|
||||
## Three kinds of knock
|
||||
|
||||

|
||||
|
||||
KnockThru works out the kind by checking what the other devices did at the same moment:
|
||||
|
||||
- **Headset:** the headset spiked above 25 m/s² within 30 ms of the controller.
|
||||
- **Together:** the other controller spiked above 20 m/s² within 40 ms, and the two controllers were less than 15 cm apart.
|
||||
- **Surface:** anything else, like a desk, a table, or your leg.
|
||||
|
||||
A together or headset knock has a second device backing it up. So the controller's spike only needs to reach 40 m/s². A surface knock has no witness, so it needs 60 m/s².
|
||||
|
||||
Head shaking moved the headset at up to 22 m/s², under the 25 needed for a headset knock. A knock on the headset moved it at 40 to 215 m/s².
|
||||
|
||||
## From knocks to a double or a triple
|
||||
|
||||
One knock does nothing. KnockThru groups knocks into a set, and acts on sets of 2 or 3.
|
||||
|
||||
- **Bounces merge.** Spikes less than 80 ms apart count as one knock.
|
||||
- **Knocks in a set need a small gap.** A surface knock less than 150 ms after the last one counts as a bounce and gets ignored, because a table bounce can land 120 ms after a real knock. Together and headset knocks only need 105 ms, so fast doubles still count.
|
||||
- **A set ends after 350 ms of quiet.** Then 2 knocks are a double, which turns passthrough on or off. 3 knocks are a triple, which switches the style. 4 or more do nothing, so steady clapping never counts.
|
||||
- **A set must match.** Every knock in it must be the same kind, from the same controller. Surface knocks must also land within 6 cm of each other.
|
||||
- **A set must start from stillness.** It has to begin at least 0.8 s after the last set ended.
|
||||
|
||||
The last two rules are about clapping runs. Without them, two weak claps right after a clapping run looked like a double. With them, a set that mixes anything is thrown out whole, and the next set has to wait.
|
||||
|
||||
The 350 ms wait plus the 50 ms judging delay is why passthrough switches about 0.4 s after your last knock.
|
||||
|
||||
## How the rules were found
|
||||
|
||||
After the first prototype, each new rule came from a recording that the older rules got wrong.
|
||||
|
||||
### The first prototype
|
||||
|
||||
The first five recordings were 45 s each, made on 2026-10-14: knocks of each kind, normal play, and clapping and waving (`noise`). A Python prototype with the spike, the stop, and the 80 ms merge found 18 to 20 doubles in each knock file and none in `play` or `noise`.
|
||||
|
||||
The C++ version found one false double at 18.9 s in `noise`. Two weak claps, 500 ms after a 3 s clapping run, formed a pair. The "a set must match" and "start from stillness" rules fixed it.
|
||||
|
||||
### Live sessions
|
||||
|
||||
Running the detector live with `knockthru --detect` turned up three problems:
|
||||
|
||||
- A 1 s cooldown after each action swallowed the first knock of the next set. It's gone. The 0.8 s stillness rule already keeps sets apart.
|
||||
- Weak third knocks peaked at 42 to 48 m/s², under the 60 that every knock needed then. Together and headset knocks now need only 40.
|
||||
- Real gaps between knocks ran 170 to 290 ms. A 450 ms window let two weak spikes 357 ms apart in `play` form a false double. The window is now 350 ms.
|
||||
|
||||
### Fast doubles
|
||||
|
||||
The `fast` recording holds quick together doubles, 89 to 168 ms apart. The 150 ms gap dropped half of them. Rebounds landed 83 to 99 ms after a real knock, so together and headset knocks got a 105 ms gap instead.
|
||||
|
||||
### Leg knocks
|
||||
|
||||
Knocks on a leg come in faster than knocks on a desk. Most right-hand leg knocks moved faster than 1.8 m/s just before the spike, so the detector took them for arm swings. Surface knocks got their own limit of 2.2 m/s. Together and headset knocks kept 1.8 m/s, because at 2.2 a together calibration counted a knock in the `noise` recording.
|
||||
|
||||
Two knocks on a leg also land further apart than two on a desk. In a run of about 40 leg doubles, 7 pairs landed 3.5 to 8.3 cm apart, and the old 3 cm limit dropped them. At 6 cm, 25 doubles count instead of 20.
|
||||
|
||||
### Strap fitting
|
||||
|
||||
Fitting the controller straps in Into the Radius 2 bumped the controllers together 19 times in 5 s. The default together threshold of 40 m/s² ignores them. A calibration that set it to 20 did not, and the first two bumps turned passthrough on. Calibration now never sets the together threshold below 30. [How calibration works](calibration.md) has the details.
|
||||
|
||||
## Changing the thresholds
|
||||
|
||||
Every threshold on this page can be overridden in `~/.config/knockthru/config.json`, under `detector`. The names match `DetectorConfig`. For example, this makes surface knocks need a harder spike:
|
||||
|
||||
```json
|
||||
{
|
||||
"detector": { "controller_peak": 80 }
|
||||
}
|
||||
```
|
||||
|
||||
Before you change a default in the code, run the tests. [The test recordings](test-recordings.md) explains what they check and how to try a value against every recording.
|
||||
|
||||
## Background
|
||||
|
||||
Accelerometer chips with built-in tap detection use the same ideas: a threshold, a short pulse, a quiet time, and a window for the second tap. [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 them.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,92 @@
|
||||
# The test recordings
|
||||
|
||||
`tests/data/` holds 10 recordings of real motion from a Steam Frame. Each one is every headset and controller pose SteamVR reported, about once a millisecond. The detector tests replay them on every change, so a change that misses real knocks, or finds knocks that aren't there, fails the build.
|
||||
|
||||
## What's in each recording
|
||||
|
||||
Knocks on controllers, desks, and the headset:
|
||||
|
||||
| Recording | Length | What happens |
|
||||
| --- | --- | --- |
|
||||
| `together` | 45 s | Controllers knocked together in pairs, about 20 pairs |
|
||||
| `surface` | 45 s | One controller knocked on a table in pairs, about 20 pairs |
|
||||
| `headset` | 45 s | A controller knocked on the headset in pairs, about 20 pairs |
|
||||
| `fast` | 30 s | Quick together doubles, 89 to 168 ms between knocks |
|
||||
|
||||
Knocks on a leg:
|
||||
|
||||
| Recording | Length | What happens |
|
||||
| --- | --- | --- |
|
||||
| `leg` | 70 s | Doubles and triples knocked on a leg with each hand, headset worn |
|
||||
| `leg-doubles` | 106 s | About 40 doubles on a leg, a few seconds apart, mostly with the right hand |
|
||||
|
||||
Everyday movement, where nothing should happen:
|
||||
|
||||
| Recording | Length | What happens |
|
||||
| --- | --- | --- |
|
||||
| `play` | 45 s | Normal play |
|
||||
| `noise` | 45 s | Clapping runs and waving, made to fool the detector |
|
||||
| `straps` | 21 s | Fitting the controller straps in Into the Radius 2, with 19 bumps in 5 s |
|
||||
|
||||
A known routine:
|
||||
|
||||
| Recording | Length | What happens |
|
||||
| --- | --- | --- |
|
||||
| `mixed` | 60 s | 4 doubles of each kind, then 4 triples of each kind, 24 sets in all |
|
||||
|
||||
The `straps` clip comes from a 40 minute session. The other 18.7 minutes of play in it gave no actions.
|
||||
|
||||
## What the tests check
|
||||
|
||||
`tests/detector_test.cpp` replays each recording at the default thresholds. Exact knock counts are unknown for most recordings, so each test sets a floor a little under what the detector finds:
|
||||
|
||||
| Recording | The test needs | Found today |
|
||||
| --- | --- | --- |
|
||||
| `together`, `surface`, `headset` | At least 18, 16, and 15 doubles of that kind | 20, 18, and 22 |
|
||||
| `fast` | At least 12 together doubles, nothing else | 13 |
|
||||
| `leg`, `leg-doubles` | At least 14 and 23 surface actions, nothing else | 18 and 25 |
|
||||
| `play`, `noise`, `straps` | No actions | None |
|
||||
| `mixed` | At least 20 of 24 right, none wrong | 23 right, 0 wrong |
|
||||
|
||||
A wrong kind still switches passthrough, so the knock files allow a few. `surface` gives 1 together double, and `headset` gives 2 surface doubles.
|
||||
|
||||
The same file also tests calibration on the recordings. [How calibration works](calibration.md) has those results.
|
||||
|
||||
## Running the tests
|
||||
|
||||
On any Linux machine with CMake and a C++17 compiler:
|
||||
|
||||
```sh
|
||||
cmake -S . -B build
|
||||
cmake --build build
|
||||
ctest --test-dir build
|
||||
```
|
||||
|
||||
To try a threshold against every recording without changing code, set it through `KNOCKTHRU_TEST_SET`. Add `KNOCKTHRU_TEST_VERBOSE=1` to print every knock and action:
|
||||
|
||||
```sh
|
||||
KNOCKTHRU_TEST_SET="confirmed_peak=30" build/bin/detector_test
|
||||
```
|
||||
|
||||
## Replaying a bug report
|
||||
|
||||
When knocks go wrong, the bug report flow has a **Record knocks** button. It records 20 s of motion while the person knocks the way that fails, and adds it to `knockthru-report.zip` as `knockthru-knocks.csv.gz`. Unzip the report and replay the recording like this:
|
||||
|
||||
```sh
|
||||
build/bin/detector_test knockthru-knocks.csv.gz
|
||||
```
|
||||
|
||||
The report includes the reporter's `calibration.json`. To replay with their thresholds, pass them through `KNOCKTHRU_TEST_SET`. Surface knocks use `controller_peak` and `min_approach_surface`, together knocks `confirmed_peak` and `min_approach_together`, and headset knocks `headset_knock_peak` and `min_approach_headset`.
|
||||
|
||||
## Making a new recording
|
||||
|
||||
On the Frame, in Konsole or over SSH:
|
||||
|
||||
```sh
|
||||
<install folder>/knockthru --record my-knocks.csv --seconds 45
|
||||
gzip my-knocks.csv
|
||||
```
|
||||
|
||||
`knockthru --detect` prints knocks live without acting on them. Add `--save FILE` to keep the poses from that session too.
|
||||
|
||||
Each row of a recording is one device at one moment: the time in seconds, the device (`hmd`, `left`, or `right`), whether the pose was valid, then position, velocity, and angular velocity, each as x, y, and z.
|
||||
@@ -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,8 @@ 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/.
|
||||
// docs/knock-detection.md explains each one.
|
||||
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 +26,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 +97,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;
|
||||
|
||||
+39
-57
@@ -41,7 +41,7 @@ constexpr float kRadius = 16;
|
||||
constexpr float kMargin = 40;
|
||||
|
||||
constexpr const char* kKindTitles[3] = {"Controllers together", "Controller on a surface", "Knock on the headset"};
|
||||
constexpr const char* kKindOnce[3] = {"Knock the controllers together.", "Knock one controller on a table or wall.",
|
||||
constexpr const char* kKindOnce[3] = {"Knock the controllers together.", "Knock one controller on a surface, like your leg.",
|
||||
"Knock on the side of the headset with a controller."};
|
||||
constexpr const char* kKindNames[3] = {"a together knock", "a surface knock", "a headset knock"};
|
||||
|
||||
@@ -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,14 +736,11 @@ 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"};
|
||||
static constexpr const char* kWays[3] = {"controllers together", "a controller on a surface like your leg", "a controller on the headset"};
|
||||
std::vector<std::string> ways;
|
||||
for (int k = 0; k < 3; ++k)
|
||||
if (state.enabled[k]) ways.push_back(kWays[k]);
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
// Room View tests: RoomView talks to a fake Steam debug port, which runs each
|
||||
// script in Node against a fake dashboard bar. Skips with exit code 77 when
|
||||
// node is not on PATH.
|
||||
#include "check.hpp"
|
||||
#include "room_view.hpp"
|
||||
|
||||
#include <json.hpp>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
using namespace knockthru;
|
||||
using json = nlohmann::json;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// The fake bar. Buttons find their React action through __reactFiber, as on the Frame.
|
||||
// The state file carries whether Room View is on between scripts.
|
||||
constexpr const char* kPrelude = R"JS(
|
||||
const fs = require('fs');
|
||||
const statePath = process.argv[2];
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
const button = (action, onClick) => {
|
||||
const element = {click: onClick};
|
||||
element['__reactFiber$test'] = {memoizedProps: {}, return: {memoizedProps: {action}, return: null}};
|
||||
return element;
|
||||
};
|
||||
const buttons = [button({invocation: 2, icon: {enum: 3}, icon_active: {enum: 1}, active: true}, () => {})];
|
||||
if (state.present)
|
||||
buttons.push(button({invocation: 2, icon: {enum: 2}, icon_active: {enum: 1}, active: state.on},
|
||||
() => { state.on = !state.on; }));
|
||||
globalThis.document = {querySelectorAll: (selector) => {
|
||||
if (state.broken) throw new Error('querySelectorAll is gone');
|
||||
return selector === '[role="button"]' ? buttons : [];
|
||||
}};
|
||||
let value;
|
||||
try { value = (EXPRESSION); } finally { fs.writeFileSync(statePath, JSON.stringify(state)); }
|
||||
process.stdout.write(JSON.stringify(value));
|
||||
)JS";
|
||||
|
||||
std::string read(const fs::path& path) {
|
||||
std::ifstream stream(path, std::ios::binary);
|
||||
return {std::istreambuf_iterator<char>(stream), std::istreambuf_iterator<char>()};
|
||||
}
|
||||
|
||||
// A Steam client debug port on 127.0.0.1 with two targets, the bar and its popup.
|
||||
class FakeDebugPort {
|
||||
public:
|
||||
explicit FakeDebugPort(fs::path directory) : directory_(std::move(directory)) {
|
||||
set_state(false, true, false);
|
||||
listener_ = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
sockaddr_in address{};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
::bind(listener_, reinterpret_cast<sockaddr*>(&address), sizeof address);
|
||||
::listen(listener_, 4);
|
||||
socklen_t size = sizeof address;
|
||||
::getsockname(listener_, reinterpret_cast<sockaddr*>(&address), &size);
|
||||
port_ = ntohs(address.sin_port);
|
||||
timeval timeout{0, 100000}; // accept wakes up to see stop_
|
||||
::setsockopt(listener_, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof timeout);
|
||||
thread_ = std::thread([this] { loop(); });
|
||||
}
|
||||
~FakeDebugPort() {
|
||||
stop_ = true;
|
||||
thread_.join();
|
||||
::close(listener_);
|
||||
}
|
||||
|
||||
int port() const { return port_; }
|
||||
bool bar = true; // whether the target list has the bar page
|
||||
|
||||
void set_state(bool on, bool present, bool broken) {
|
||||
std::ofstream(directory_ / "state.json", std::ios::trunc)
|
||||
<< json{{"on", on}, {"present", present}, {"broken", broken}}.dump();
|
||||
}
|
||||
bool on() const { return json::parse(read(directory_ / "state.json")).value("on", false); }
|
||||
|
||||
private:
|
||||
void loop() {
|
||||
while (!stop_) {
|
||||
const int client = ::accept(listener_, nullptr, nullptr);
|
||||
if (client < 0) continue;
|
||||
answer(client);
|
||||
::close(client);
|
||||
}
|
||||
}
|
||||
|
||||
static std::string receive(int client, std::size_t count) {
|
||||
std::string data(count, '\0');
|
||||
for (std::size_t got = 0; got < count;) {
|
||||
const auto n = ::recv(client, data.data() + got, count - got, 0);
|
||||
if (n <= 0) return {};
|
||||
got += static_cast<std::size_t>(n);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
static void send_all(int client, const std::string& data) { ::send(client, data.data(), data.size(), MSG_NOSIGNAL); }
|
||||
|
||||
// An unmasked text frame, as the Steam client sends.
|
||||
static std::string frame(const std::string& text) {
|
||||
std::string out(1, static_cast<char>(0x81));
|
||||
if (text.size() < 126) {
|
||||
out += static_cast<char>(text.size());
|
||||
} else {
|
||||
out += static_cast<char>(126);
|
||||
out += static_cast<char>(text.size() >> 8);
|
||||
out += static_cast<char>(text.size() & 0xFF);
|
||||
}
|
||||
return out + text;
|
||||
}
|
||||
|
||||
// Reads one masked client frame.
|
||||
static std::string read_frame(int client) {
|
||||
const auto head = receive(client, 2);
|
||||
if (head.size() != 2) return {};
|
||||
std::size_t length = static_cast<unsigned char>(head[1]) & 0x7F;
|
||||
if (length == 126) {
|
||||
const auto bytes = receive(client, 2);
|
||||
length = (static_cast<unsigned char>(bytes[0]) << 8) | static_cast<unsigned char>(bytes[1]);
|
||||
}
|
||||
const auto mask = receive(client, 4);
|
||||
auto payload = receive(client, length);
|
||||
for (std::size_t i = 0; i < payload.size(); ++i) payload[i] = static_cast<char>(payload[i] ^ mask[i % 4]);
|
||||
return payload;
|
||||
}
|
||||
|
||||
void answer(int client) {
|
||||
std::string headers;
|
||||
while (headers.find("\r\n\r\n") == std::string::npos) {
|
||||
const auto byte = receive(client, 1);
|
||||
if (byte.empty()) return;
|
||||
headers += byte;
|
||||
}
|
||||
const auto ws = "ws://127.0.0.1:" + std::to_string(port_) + "/devtools/page/";
|
||||
if (headers.rfind("GET /json ", 0) == 0) {
|
||||
json targets = json::array();
|
||||
targets.push_back({{"url", "https://steamloopback.host/?vrOverlayKey=valve.steam.gamepadui.barpopup"},
|
||||
{"webSocketDebuggerUrl", ws + "POPUP"}});
|
||||
if (bar)
|
||||
targets.push_back({{"url", "https://steamloopback.host/?vrOverlayKey=valve.steam.gamepadui.bar"},
|
||||
{"webSocketDebuggerUrl", ws + "BAR"}});
|
||||
const auto body = targets.dump();
|
||||
// The connection stays open, as Steam's does, so the client must read by length.
|
||||
send_all(client, "HTTP/1.1 200 OK\r\nContent-Length: " + std::to_string(body.size()) + "\r\n\r\n" + body);
|
||||
return;
|
||||
}
|
||||
if (headers.rfind("GET /devtools/page/BAR ", 0) != 0) {
|
||||
send_all(client, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
send_all(client, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n");
|
||||
const auto request = json::parse(read_frame(client), nullptr, false);
|
||||
if (request.is_discarded()) return;
|
||||
// An event comes first, longer than 125 bytes, which the client must skip.
|
||||
send_all(client, frame(json{{"method", "Runtime.consoleAPICalled"}, {"params", {{"pad", std::string(200, 'x')}}}}.dump()));
|
||||
send_all(client, frame(evaluate(request["id"], request["params"]["expression"])));
|
||||
}
|
||||
|
||||
// Runs `expression` in Node and returns the reply Runtime.evaluate gives.
|
||||
std::string evaluate(const json& id, const std::string& expression) {
|
||||
std::string script = kPrelude;
|
||||
script.replace(script.find("EXPRESSION"), 10, expression);
|
||||
std::ofstream(directory_ / "script.js", std::ios::trunc) << script;
|
||||
const auto command = "node '" + (directory_ / "script.js").string() + "' '" +
|
||||
(directory_ / "state.json").string() + "' 2>/dev/null";
|
||||
std::string output;
|
||||
int status = -1;
|
||||
if (FILE* pipe = ::popen(command.c_str(), "r")) {
|
||||
char buffer[256];
|
||||
for (std::size_t n; (n = std::fread(buffer, 1, sizeof buffer, pipe)) > 0;) output.append(buffer, n);
|
||||
status = ::pclose(pipe);
|
||||
}
|
||||
json result;
|
||||
if (status == 0) result = {{"type", "string"}, {"value", json::parse(output)}};
|
||||
else result = {{"type", "object"}, {"subtype", "error"}, {"description", "Error: script threw"}};
|
||||
return json{{"id", id}, {"result", {{"result", result}}}}.dump();
|
||||
}
|
||||
|
||||
fs::path directory_;
|
||||
int listener_ = -1;
|
||||
int port_ = 0;
|
||||
std::atomic<bool> stop_{false};
|
||||
std::thread thread_;
|
||||
};
|
||||
|
||||
// The message RoomView threw, and whether it was DashboardChanged.
|
||||
template <typename F> std::pair<std::string, bool> error_of(F f) {
|
||||
try {
|
||||
f();
|
||||
} catch (const DashboardChanged& error) {
|
||||
return {error.what(), true};
|
||||
} catch (const std::exception& error) {
|
||||
return {error.what(), false};
|
||||
}
|
||||
return {"", false};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
if (std::system("node --version >/dev/null 2>&1") != 0) {
|
||||
std::cout << "node is not on PATH, so the Room View tests are skipped\n";
|
||||
return 77;
|
||||
}
|
||||
const auto root = fs::temp_directory_path() / ("knockthru-room-view-test-" + std::to_string(getpid()));
|
||||
fs::remove_all(root);
|
||||
fs::create_directories(root);
|
||||
{
|
||||
FakeDebugPort steam(root);
|
||||
const RoomView room_view(steam.port());
|
||||
|
||||
check(room_view.active() == false, "active reads off from the bar, skipping the popup page");
|
||||
steam.set_state(true, true, false);
|
||||
check(room_view.active() == true, "active reads on");
|
||||
|
||||
steam.set_state(false, true, false);
|
||||
check(room_view.toggle() == true && steam.on(), "toggle clicks the Room View button and returns on");
|
||||
check(room_view.toggle() == false && !steam.on(), "toggle again returns off");
|
||||
|
||||
steam.set_state(false, false, false);
|
||||
const auto [missing, missing_changed] = error_of([&] { room_view.active(); });
|
||||
check(missing_changed && missing.find("not found") != std::string::npos,
|
||||
"a bar without the button throws DashboardChanged: " + missing);
|
||||
const auto [click, click_changed] = error_of([&] { room_view.toggle(); });
|
||||
check(click_changed, "toggle without the button throws DashboardChanged");
|
||||
|
||||
steam.set_state(false, true, true);
|
||||
const auto [broken, broken_changed] = error_of([&] { room_view.active(); });
|
||||
check(broken_changed && broken.find("Dashboard script failed") != std::string::npos,
|
||||
"a script error throws DashboardChanged: " + broken);
|
||||
|
||||
steam.set_state(false, true, false);
|
||||
steam.bar = false;
|
||||
const auto [no_bar, no_bar_changed] = error_of([&] { room_view.active(); });
|
||||
check(!no_bar_changed && no_bar.find("dashboard bar not found") != std::string::npos,
|
||||
"a target list without the bar says SteamVR may not be running: " + no_bar);
|
||||
}
|
||||
// The fake port is closed now.
|
||||
{
|
||||
int probe = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
sockaddr_in address{};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
::bind(probe, reinterpret_cast<sockaddr*>(&address), sizeof address);
|
||||
socklen_t size = sizeof address;
|
||||
::getsockname(probe, reinterpret_cast<sockaddr*>(&address), &size);
|
||||
const int closed_port = ntohs(address.sin_port);
|
||||
::close(probe); // nothing listens on this port
|
||||
const auto [closed, closed_changed] = error_of([&] { RoomView(closed_port).active(); });
|
||||
check(!closed_changed && closed.find("CEF Remote Debugging") != std::string::npos,
|
||||
"a closed port says to turn on CEF Remote Debugging: " + closed);
|
||||
}
|
||||
fs::remove_all(root);
|
||||
return finish();
|
||||
}
|
||||
@@ -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