Skip to content

feat(hid): variable-speed tuning from the Contour shuttle ring. Principle XI. - #5933

Open
sq9fk wants to merge 4 commits into
aethersdr:mainfrom
sq9fk:feat/5928-shuttle-ring
Open

sq9fk wants to merge 4 commits into
aethersdr:mainfrom
sq9fk:feat/5928-shuttle-ring

Conversation

@sq9fk

@sq9fk sq9fk commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #5928.

Stacked on #5931 → #5932. Until those merge, the diff here also shows their commits. Only 4be1554c is new. I'll rebase onto main as each one lands. #5932's per-report event queue is what lets the ring position ride along without touching the one-event contract.

This implements the design from #5928 with all five corrections from the triage review:

Review point Done
1. Rate table in Hz/s ShuttleRateIntegrator integrates {0, 20, 100, 500, 2k, 8k, 30k, 100k} Hz/s and converts to whole steps of the current step size at tick time, carrying the remainder. Full deflection is ~100 kHz/s at a 10 Hz step and at a 1 kHz step alike (tested).
2. Tune Lock Checked in the tick. notifyTuneBlockedByLock() fires once per deflection, so direct frequency entry is not cancelled every 40 ms.
3. Manager-side stop-on-disconnect HidEncoderManager tracks m_lastShuttle. close() emits shuttleChanged(0) before m_parser.reset() and independently of the m_deviceName guard. MainWindow also stops the timer on connectionChanged(false).
4. Single invert The position uses the existing HidEncoderInvertDir. There is no second flag.
5. Restricted actions Tune Slice / RIT / XIT / None only. RIT and XIT are capped at 1 kHz/s (10 Hz steps within ±9999).

The 60 ms sign-reversal filter is left out, as suggested. The accumulator is reset at centre and on sign change.

Two changes driven by hardware testing

A temporary debug log on a real ShuttleXpress, not included in this PR, showed the software response is symmetric (first step ~0.51 s after reaching ±1 in both directions). This unit's +1 zone is several times wider than −1, though (5.7 s vs 0.7 s at +1/−1 before +2/−2 registered, and 0.88 s vs 0.19 s in an earlier raw capture). At 20 Hz/s, that made the first detent feel dead. It would get far worse with larger steps: at a 1 kHz step, position 1 moved one step every 50 s. So:

  • Immediate first step: a new deflection (from centre, or a reversal) makes one step once it has been held for 60 ms, then the rate takes over. The 60 ms is longer than the measured 30–35 ms spring overshoot through the opposite side on release, so letting go never produces a backward step. A unit test covers this: a 35 ms overshoot produces no step.
  • Step-based floor: the rate is max(table, (|pos| + 1) steps/s). At a 10 Hz step nothing changes. At a 1 kHz step, positions 1–4 now move and are distinct (2, 3, 4, 5 kHz/s).

Other details

  • The parser exposes hasShuttle() / shuttlePosition() as state, set at the top of parse() before any return, so it survives a same-report button edge (tested).
  • ShuttleMapping is a single nested JSON key (Principle V). rc28MappingField and shuttleMappingField now share one file-local helper, with no behaviour change for RC-28.
  • UI: a new "Shuttle Ring" group in Radio Setup → Serial with Action and Speed (Slow ×0.5 / Normal / Fast ×2). Settings are read once per deflection, not per tick.
  • The tick uses real elapsed time (QElapsedTimer, capped at 200 ms), because Qt's coarse timer on Windows fires every 33–52 ms rather than every 40 ms.
  • Help page: the ring and its settings are documented instead of "not currently part of the tuning path".
  • Auto-snap on release is not included, to avoid restarting the RC-28's shared m_hidSnapTimer. It can be a follow-up if wanted.

Constitution principle honored

  • Principle XI: the behaviour is demonstrated by hid_device_parser_test (ring decode plus integrator cases) and on hardware.
  • Principle V: one ShuttleMapping key.

Test plan

  • Local build passes (Windows 11, MSVC 19.44, Qt 6.8.3, Ninja); ctest -R hid_device_parser_test passes.
  • Behavior verified on real hardware: ShuttleXpress on a FLEX-6500 (fw 4.2.20), 10 Hz step.
    • Right and left respond the same.
    • Tuning starts immediately.
    • No backward step on release, across 40 logged ring position changes.
    • With Tune Lock on, the frequency does not move.
    • Unplugging USB with the ring deflected stops tuning.
  • 1 kHz step behaviour is covered by unit tests. The hardware log only exercised the 10 Hz step.
  • ShuttlePro v2: unit test only, no hardware.
  • Existing tests pass (CI)

Checklist

  • Commits are signed
  • No new flat-key AppSettings calls (one nested ShuttleMapping key)
  • Clean-room: the device behaviour comes from my own HID captures
  • All meter UI uses MeterSmoother (no meter UI touched)
  • Documentation updated (resources/help/configuring-aethersdr-controls.md)
  • Security-sensitive changes: n/a (position clamped to ±7; tick dt capped)

🤖 Generated with Claude Code

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

No code blockers. Reviewed 4be1554cb841f3c00ab813fd8978fe4077c1f267. This is the stack tip: #5927's button decode, the simultaneous-edge drain, and #5928's shuttle ring. A clean merge recommendation waits on CI for this SHA (approved out of action_required, now running). This comment is not an approving review.

Issue fit

#5928 asks for the spring ring as a rate input. The triage on that issue asked for five adjustments, and this PR follows them:

  • Rate table in Hz/s ({0, 20, 100, 500, 2k, 8k, 30k, 100k}), not steps/s, so full deflection is about 100 kHz/s at any step size. A (|pos|+1) steps/s floor keeps a 1 kHz step moving.
  • Tune Lock is checked in the tick and notifyTuneBlockedByLock() runs once per deflection, so direct entry is not cancelled on every tick.
  • close() emits shuttleChanged(0) before the parser is destroyed, and not behind the device-name guard. connectionChanged(false) also stops the timer.
  • Direction uses the existing HidEncoderInvertDir flag. There is no second invert in ShuttleMapping.
  • The action list is Tune Slice, RIT, XIT, or None. RIT/XIT are capped at 1 kHz/s. Power and volume are not offered.

Settings are one ShuttleMapping JSON key (Principle V), through the same helper as RC28Mapping. The ring path calls applyFlexControlWheelAction(), so it does not add a command-plane string. The new Radio Setup group sits inside the existing HAVE_HIDAPI block.

The 60 ms first-step delay is the one place this disagrees with the triage suggestion to leave that filter out. The suggestion assumed a pure integrator, where a 35 ms overshoot is a fraction of a step. This PR also applies one step when a new deflection has been held, so the delay has to be longer than the measured 30–35 ms snap-back or letting go steps backwards. That is the right call, and the test covers it. Auto-snap on release is omitted so the shared RC-28 snap timer is not restarted. That matches the triage note.

Scope

File Change
src/core/HidDeviceParser.{h,cpp} #5931 button bytes, #5932 queue, shuttle position set at the top of parse() and clamped to ±7
src/core/HidEncoderManager.{h,cpp} Drain pending events; shuttleChanged; ShuttleMapping; return to 0 on close()
src/core/ShuttleRateIntegrator.h Header-only Hz/s integrator
src/gui/MainWindow.h, MainWindow_Controllers.cpp 40 ms timer, elapsed dt capped at 200 ms, lock-once, stop on disconnect
src/gui/RadioSetupDialog.cpp Shuttle Ring group (Action, Speed), inside HAVE_HIDAPI
resources/help/configuring-aethersdr-controls.md Ring documented
CMakeLists.txt Header listed with the other HID sources
tests/hid_device_parser_test.cpp, tests/tests.cmake Parser, queue, ring decode, integrator

Client HID only. Every radio family that already accepts the FlexControl wheel actions gets the same frequency / RIT / XIT setpoints. Nothing here keys transmit. The action list excludes RF power and volume.

Verification

Compiled tests/hid_device_parser_test.cpp + src/core/HidDeviceParser.cpp with -std=c++20 -DHAVE_HIDAPI from this SHA and ran it: all checks passed (buttons, queue, ring decode, integrator).

Three mutations, each rebuilt, each a behavioral failure, each restored, final run passed:

  • Xpress mask forced back to buf[3]: the Xpress button checks failed.
  • Button loop stopped after the first edge: the simultaneous-button checks failed.
  • kFirstStepDelaySec set to 0: the 60 ms first-step check and the 35 ms overshoot check failed.

The test does not open a socket. The app was not built locally. GUI compile is what the CI workflow on this SHA is for. The per-PR ctest gate will not run this new target.

Nit, not a change request: the help line says the ring "moves one step straight away". The first step is applied once the deflection has been held 60 ms, on the second 40 ms tick when the timer is on time.

Landing

This branch merges clean onto current main (06b6ac95). Squashing #5931 first and then this PR does not. Once this SHA's checks are green I will mark the draft ready and squash this PR, with Fixes #5927 and Closes #5928 in the squash body, and close #5931 and #5932 as included.

Non-blocking follow-up, not a merge condition: HidEncoderManager's emit-on-change / emit-0-on-close path and the MainWindow timer (lock-once, settings read once per deflection, stop on disconnect) are not in hid_device_parser_test. A socket-free test can drive ShuttleRateIntegrator (already done) but not the Qt timer without a GUI harness. No firmware peer is useful here.

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

Request changes. Reviewed 4be1554cb841f3c00ab813fd8978fe4077c1f267. The ring behavior described in the earlier comment still looks right, and hid_device_parser_test passed here, including the three mutations. Static checks on this SHA failed, so this is not mergeable.

Run: https://github.com/aethersdr/AetherSDR/actions/runs/35912059810

Two failures, both from this diff against its base (9f81dc00), not from main moving:

  1. tools/gen_touchpoint_manifest.py --check — core/ShuttleRateIntegrator.h has no semantic tag. MainWindow.h includes it, so it is a gui→engine touchpoint. Tag it ui-support in docs/architecture/aetherd-touchpoint-tags.json (same class as HidEncoderManager.h: client HID plumbing, not radio state) and regenerate with python tools/gen_touchpoint_manifest.py. The check stopped on the missing tag before it could report a stale manifest; the new row has to be generated too.

  2. Hardcoded-colour ratchet — setStyleSheet call sites 1043 > 1040 (+3). Unique colours did not rise. The three new calls are the Shuttle Ring group, its note, and the combo inside addCombo. Copying setStyleSheet(kGroupStyle) from the older groups on this page is what the ratchet counts. ThemeManager::applyStyleSheet is the call that does not add a site (see the comment on makeValueLabel in this file).

#5931 and #5932 are not part of this failure. Their static checks passed. This PR stays draft until the two checks are fixed.

Comment thread src/gui/MainWindow.h
#endif
#ifdef HAVE_HIDAPI
#include "core/HidEncoderManager.h"
#include "core/ShuttleRateIntegrator.h"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This include makes core/ShuttleRateIntegrator.h a gui→engine touchpoint, and Static checks failed on it: aetherd touchpoints require a valid semantic tag: core/ShuttleRateIntegrator.h (run 35912059810).

Add a ui-support entry next to core/HidEncoderManager.h in docs/architecture/aetherd-touchpoint-tags.json — this header is client-side rate math, not radio state — then run python tools/gen_touchpoint_manifest.py and commit the regenerated docs/architecture/aetherd-touchpoints.md. The checker returns on the missing tag before it reports a stale manifest, so both writes are required.

Comment thread src/gui/RadioSetupDialog.cpp Outdated
// ── Contour shuttle ring (#5928) ─────────────────────────────────────────
{
auto* group = new QGroupBox("Shuttle Ring (ShuttleXpress / ShuttlePro)");
group->setStyleSheet(kGroupStyle);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Static checks: the colour ratchet counted setStyleSheet 1043 > 1040 (+3) against this PR's base. These are the three new sites: this kGroupStyle call, note->setStyleSheet(kLabelStyle) just below, and combo->setStyleSheet(...) in addCombo. No new colour literal (unique colours stayed 606).

The older groups on this page already call setStyleSheet, but a new call still raises the count. Use ThemeManager::instance().applyStyleSheet(...) for these three widgets, the same way makeValueLabel does at the top of this file. That styles the widget without adding a counted setStyleSheet site.

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Thanks for this — the shuttle ring as a rate input rather than a step input is the right model, and the 30–35 ms snap-back measurement behind kFirstStepDelaySec is exactly the kind of evidence that makes a feel-sensitive change reviewable. Nothing is wrong with the feature. Two static gates tripped, both mechanical, and I reproduced both locally on 4be1554c so the fixes below are verified rather than guessed.

Static checks failed at two steps. The three build jobs (build, check-windows, check-macos) were still running when I looked, so they are not part of this. Neither failure is CI infrastructure.


1. aetherd touchpoint manifest

::error::aetherd touchpoints require a valid semantic tag: core/ShuttleRateIntegrator.h

src/gui/MainWindow.h now includes core/ShuttleRateIntegrator.h, which makes it a new gui→engine touchpoint. tools/gen_touchpoint_manifest.py --check refuses any touchpoint header without a semantic tag in the sidecar, so a new engine header reached from src/gui/ always needs one row of JSON. There is a second, quieter staleness too: RadioSetupDialog.cpp's new #include "core/HidEncoderManager.h" takes that header's includer count from 2 to 3, so the generated table is out of date even once the tag exists.

Fix, in order:

  1. Add one entry to docs/architecture/aetherd-touchpoint-tags.json (insert it in place — the file is sorted, so please don't let an editor reformat the whole thing):
  "core/ShuttleRateIntegrator.h": {
    "tag": "ui-support",
    "note": "pure shuttle-ring rate arithmetic for the desktop HID surface, no radio state",
    "split": "",
    "confidence": "high"
  },

ui-support is the right tag by precedent: core/HidEncoderManager.h is already ui-support ("desktop input device plumbing, not radio state"), as are core/FlexControlManager.h and core/ShortcutManager.h. Your header is Qt-free arithmetic over a USB input surface, so it is the same class.

  1. Regenerate rather than hand-edit the table:
python tools/gen_touchpoint_manifest.py

With those two steps the check goes green here; the manifest diff is exactly three lines — totals 230 → 231, the HidEncoderManager.h includer count 2 → 3, and the new ShuttleRateIntegrator.h row.

Generator and tag legend: tools/gen_touchpoint_manifest.py, docs/architecture/aetherd-touchpoints.md.

(Worth a thought, not a request: the integrator is pure <algorithm>/<cmath> with no Qt and no radio state. If it lived in src/gui/ it would not be a touchpoint at all, and tests/tests.cmake would only need its include path changed. Tagging it is entirely acceptable — ui-support exists for headers in exactly this position — so take whichever you prefer.)

2. Hardcoded-colour ratchet

  OK   unique_colours       606  (base 606, +0)
  OK   total_references    2617  (base 2617, +0)
  OVER setstylesheet       1043  (base 1040, +3)

You added no new colours — the counter that moved is setStyleSheet() call sites, and the three are the new Shuttle Ring group in src/gui/RadioSetupDialog.cpp:

  • group->setStyleSheet(kGroupStyle)
  • note->setStyleSheet(kLabelStyle)
  • combo->setStyleSheet(QString(kEditStyle).replace("QLineEdit", "QComboBox"))

I want to be straight about this one: you copied the idiom from the group immediately above yours (StreamDeck+ Encoder Push Actions uses those same three lines, in that order), so this is the gate penalising consistency with its neighbours, not a mistake on your part. The ratchet is a strict delta against the PR's merge base and counts sites, so migrating a colour to a token will not clear it — tools/audit_colours.py says as much in its own failure text.

The cheapest honest fix is to add a small file-local helper next to kGroupStyle and convert one or two existing groups onto it, so the net count falls:

// One place that applies the settings-panel styling, so a new group adds no
// setStyleSheet() call site (colour ratchet, #4569).
static void styleSettingsGroup(QGroupBox* group)
{
    group->setStyleSheet(kGroupStyle);
    for (auto* lbl : group->findChildren<QLabel*>())
        if (lbl->styleSheet().isEmpty()) lbl->setStyleSheet(kLabelStyle);
    for (auto* combo : group->findChildren<QComboBox*>())
        if (combo->styleSheet().isEmpty())
            combo->setStyleSheet(QString(kEditStyle).replace("QLineEdit", "QComboBox"));
}

Call it once after the group is populated (styleSettingsGroup(group); before vbox->addWidget(group)), drop your three lines, and convert the adjacent StreamDeck+ group (RadioSetupDialog.cpp:7804 onward, three sites of its own) the same way. That is 3 sites in the helper, 6 removed, so the ratchet reads negative and you leave the file slightly tidier than you found it. The styleSheet().isEmpty() guard is the existing pattern at RadioSetupDialog.cpp:2020 and :2474, so widgets you style explicitly are left alone.

Reproduce either gate locally without waiting on CI:

python tools/gen_touchpoint_manifest.py --check
git worktree add /tmp/colour-base "$(git merge-base HEAD origin/main)"
python tools/audit_colours.py --src src --compare-src /tmp/colour-base/src --summary-only --strict

No reviewer has flagged anything on the code itself, and nothing in either failure touches the decode, the integrator, or the help-page text. Ping me once it's pushed and I'll look again.


🤖 aethersdr-agent · cost: $4.2691 · model: claude-opus-5

sq9fk and others added 3 commits October 7, 2026 09:50
…Principle XI.

The ShuttleXpress/ShuttlePro v2 parsers returned the first changed button
bit but stored the whole new mask, so a second button changing in the same
report was dropped. That includes releases, which could leave a button
logically held. A jog change arriving with a button change was also
deferred to the next report.

- HidDeviceParser gains nextPending() (default: none). HidEncoderManager
  drains it after every parse(), so parse() keeps its single-return
  contract and the other parsers are untouched.
- The two Contour parsers share ContourShuttleParser, which queues every
  button edge in button order and then the jog delta. The subclasses only
  supply the button mask and count.
- hid_device_parser_test: simultaneous press/release, byte-3/4 boundary
  (Xpress 4+5, Pro 8+9), release+press in one report, button+jog in one
  report, and a short report clearing the queue. On the aethersdr#5927 parser 6 of
  these fail; all 13 checks pass here.

Verified on a ShuttleXpress + FLEX-6500: two buttons pressed together
both fire, and releasing them together leaves nothing held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iple XI.

The spring-loaded outer ring on the ShuttleXpress / ShuttlePro v2 was
never decoded (byte 0, signed -7..+7). It only reports when it moves,
never while held, so it is handled as a rate input.

- Parser: HidDeviceParser gains hasShuttle()/shuttlePosition(). The
  Contour decoder sets the position at the top of every parse(), so it is
  never lost to the one-event-per-report contract.
- HidEncoderManager emits shuttleChanged(position) on change, with the
  existing HidEncoderInvertDir applied (no second invert). close() emits
  shuttleChanged(0) before destroying the parser, independent of the
  device-name guard. MainWindow also stops on connectionChanged(false).
- ShuttleRateIntegrator (header-only, no Qt) integrates Hz/s into whole
  steps of the current step size and carries the remainder. The table is
  {0, 20, 100, 500, 2k, 8k, 30k, 100k} Hz/s, so the top speed does not
  depend on the step size. It is floored at (|pos| + 1) steps/s so large
  steps still move on the first detents. A new deflection makes one step
  once held for 60 ms, which is longer than the measured 30-35 ms
  snap-back overshoot on release. The remainder is dropped at centre and
  on reversal.
- MainWindow runs a 40 ms timer while the ring is deflected and feeds
  applyFlexControlWheelAction(). Tune Lock is checked in the tick and
  notified once per deflection, so direct frequency entry is not
  cancelled every tick.
- Actions are limited to Tune Slice / RIT / XIT / None. RIT and XIT are
  capped at 1 kHz/s. Settings live in one "ShuttleMapping" JSON key
  (Principle V), sharing a helper with RC28Mapping. There is a new
  "Shuttle Ring" group in Radio Setup > Serial.
- Help page: the ring is documented instead of listed as unused.
- Tests: ring position decode (captured sweep, clamp, alongside a button
  edge, Pro v2) and the integrator (Hz/s at 10 Hz and 1 kHz steps, creep
  remainder, step floor, first step and overshoot, reversal, cap).

Hardware (ShuttleXpress + FLEX-6500): symmetric response both ways, no
backward step on release across 40 logged ring position changes, and the lock
and unplug-while-deflected cases stop tuning.

Closes aethersdr#5928

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…comments. Principle VIII.

- Tag core/ShuttleRateIntegrator.h as ui-support (same class as
  HidEncoderManager.h) and regenerate aetherd-touchpoints.md: the new row,
  HidEncoderManager.h includers 2 -> 3, totals +1.
- Shuttle Ring group styles its group, note and combos through
  ThemeManager::applyStyleSheet, so the setStyleSheet ratchet stays at +0.
- Help: the first step lands after about 60 ms, not "straight away".
- Comments condensed to the AGENTS.md comment policy (aethersdr#6072); the
  hardware measurements behind kFirstStepDelaySec live in aethersdr#5933.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sq9fk
sq9fk force-pushed the feat/5928-shuttle-ring branch from 4be1554 to 7e9110f Compare October 7, 2026 08:46
@sq9fk

sq9fk commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (25de97c3) and addressed the static-check failures. #5931 landed as fedc434b, so its commit is gone from the stack:

In 7e9110ff:

  1. Touchpoint manifest: tagged core/ShuttleRateIntegrator.h as ui-support next to HidEncoderManager.h and regenerated aetherd-touchpoints.md. The diff is the expected three lines: totals, HidEncoderManager.h includers 2 → 3, and the new row.
  2. Colour ratchet: the group, note and combo now go through ThemeManager::applyStyleSheet. audit_colours.py --strict against the merge base: setstylesheet +0, unique colours +0.
  3. Help nit: the first step is now described as landing after about 60 ms.
  4. Comments: trimmed to the ≤5-line policy (docs: cut review latency — comment policy, AGENTS.md split, condensed comments, Constitution 2.1.0, body-only reviews. Principle VIII. #6072). The measurements behind kFirstStepDelaySec stay in the PR body.

Checked locally before pushing:

  • All 12 strict checks from static-checks.yml pass, including the touchpoint manifest, test registration and the CI test gate.
  • Full Windows build against the pinned Qt 6.12.0 (MSVC 19.44, Ninja).
  • ctest -R hid_device_parser_test passes.

Both #5932 and #5933 report mergeable again. Ready for CI whenever you are.

main replaced the local kEditStyle with the shared token template
(RadioSetupDialogCommon.h); the rebased group still named the old
constant, which only compiles out when HAVE_HIDAPI is off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sq9fk

sq9fk commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

The build jobs on 7e9110ff failed on all three platforms: kEditStyle is undeclared in the Shuttle Ring group. main had moved those combos to the shared kEditStyleTemplate (RadioSetupDialogCommon.h), and my rebase kept the old name. Fixed in 123e6b18, which uses the same call as the neighbouring encoder group.

I need to correct my previous comment: the "full Windows build" I reported had hidapi missing from the build tree after the rebase, so HAVE_HIDAPI was off and none of the HID code compiled. I've now rebuilt with hidapi found — USB HID encoder support enabled (Qt 6.12.0, MSVC 19.44):

  • RadioSetupDialog.cpp, MainWindow_Controllers.cpp, HidEncoderManager.cpp and HidDeviceParser.cpp compile.
  • AetherSDR.exe links.
  • hid_device_parser_test passes.
  • The static checks still pass, with the colour ratchet at +0.

Sorry for the extra CI round.

@sq9fk
sq9fk marked this pull request as ready for review October 7, 2026 11:05
@sq9fk
sq9fk requested review from a team as code owners October 7, 2026 11:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ShuttleXpress / ShuttlePro: support the shuttle ring for variable-speed tuning

2 participants