From cf2f49cc32363edad36909e2a6900d9a3fc2b4a0 Mon Sep 17 00:00:00 2001 From: aidnem <99768676+aidnem@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:38:07 -0500 Subject: [PATCH 01/30] (#80) Add coordination test mode --- .../coordination/CoordinationTestMode.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/main/java/frc/robot/coordination/CoordinationTestMode.java diff --git a/src/main/java/frc/robot/coordination/CoordinationTestMode.java b/src/main/java/frc/robot/coordination/CoordinationTestMode.java new file mode 100644 index 00000000..8d3bf361 --- /dev/null +++ b/src/main/java/frc/robot/coordination/CoordinationTestMode.java @@ -0,0 +1,23 @@ +package frc.robot.coordination; + +import frc.robot.util.TestModeDescription; + +public enum CoordinationTestMode implements TestModeDescription { + ShotTuning("Shot tuning"), + None; // No test mode selected + + private final String description; + + CoordinationTestMode(String description) { + this.description = description; + } + + CoordinationTestMode() { + this.description = name(); + } + + @Override + public String getDescription() { + return this.description; + } +} From 73fb62a5cae40842ac80b2430ac9e574eb92b151 Mon Sep 17 00:00:00 2001 From: aidnem <99768676+aidnem@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:48:29 -0500 Subject: [PATCH 02/30] (#80) Implement shot tuning mode --- .../java/frc/robot/CoordinationLayer.java | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/main/java/frc/robot/CoordinationLayer.java b/src/main/java/frc/robot/CoordinationLayer.java index c378ee7d..f6d8c276 100644 --- a/src/main/java/frc/robot/CoordinationLayer.java +++ b/src/main/java/frc/robot/CoordinationLayer.java @@ -8,6 +8,7 @@ import coppercore.controls.state_machine.State; import coppercore.controls.state_machine.StateMachine; +import coppercore.parameter_tools.LoggedTunableNumber; import coppercore.vision.VisionLocalizer; import coppercore.wpilib_interface.controllers.Controller.Button; import coppercore.wpilib_interface.controllers.Controllers; @@ -23,6 +24,7 @@ import edu.wpi.first.math.geometry.Translation3d; import edu.wpi.first.math.kinematics.ChassisSpeeds; import edu.wpi.first.math.numbers.N3; +import edu.wpi.first.math.util.Units; import edu.wpi.first.wpilibj.Alert; import edu.wpi.first.wpilibj.Alert.AlertType; import edu.wpi.first.wpilibj.DriverStation; @@ -36,6 +38,7 @@ import frc.robot.constants.AllianceBasedFieldConstants; import frc.robot.constants.FieldConstants; import frc.robot.constants.JsonConstants; +import frc.robot.coordination.CoordinationTestMode; import frc.robot.coordination.MatchState; import frc.robot.subsystems.HomingSwitch; import frc.robot.subsystems.drive.Drive; @@ -49,9 +52,12 @@ import frc.robot.util.AllianceUtil; import frc.robot.util.OptionalUtil; import frc.robot.util.StateMachineDump; +import frc.robot.util.TestModeManager; import frc.robot.util.geometry.EnhancedLine2d; import java.util.Optional; import java.util.function.BooleanSupplier; + +import org.junit.Test; import org.littletonrobotics.junction.AutoLogOutput; import org.littletonrobotics.junction.Logger; @@ -145,6 +151,11 @@ public enum ShotMode { @AutoLogOutput(key = "CoordinationLayer/shotMode") private ShotMode shotMode = ShotMode.Hub; + // Tunable numbers for shot tuning + private final LoggedTunableNumber hoodTuningAngleDegrees = new LoggedTunableNumber("CoordinationLayer/ShotTuning/hoodAngleDegrees", JsonConstants.hoodConstants.minHoodAngle.in(Degrees)); + private final LoggedTunableNumber shooterTuningRPM = new LoggedTunableNumber("CoordinationLayer/ShotTuning/shooterRPM", 0.0); + private final TestModeManager testModeManager = new TestModeManager<>("CoordinationLayer", CoordinationTestMode.class); + // Logging private final Alert autonomyOverriddenAlert = new Alert( @@ -671,11 +682,17 @@ public void coordinateRobotActions() { autonomyOverriddenAlert.set(effectiveAutonomyLevel != autonomyLevel); // Aim for a shot based on the current autonomy level - boolean isShotReal = - switch (effectiveAutonomyLevel) { + boolean isShotReal; + if (testModeManager.isInTestMode()) { + aimForTestModeShot(); + isShotReal = true; + } else { + isShotReal = switch (effectiveAutonomyLevel) { case Smart -> drive.map(this::runShotCalculatorWithDrive).orElse(false); case Manual -> aimForManualShot(); }; + } + Logger.recordOutput("CoordinationLayer/isShotReal", isShotReal); boolean shouldStowHoodBasedOnMovement = @@ -772,6 +789,20 @@ private boolean aimForManualShot() { return true; } + private void aimForTestModeShot() { + double hoodAngleRadians = Units.degreesToRadians(hoodTuningAngleDegrees.getAsDouble()); + double shooterRPM = shooterTuningRPM.getAsDouble(); + + hood.ifPresent( + hood -> { + hood.targetAngleRadians(hoodAngleRadians); + }); + + shooter.ifPresent(shooter -> shooter.setTargetVelocityRPM(shooterRPM)); + + // Use turret closed loop mode for turret angle + } + private final EnhancedLine2d leftBlueTrench = new EnhancedLine2d( FieldConstants.LeftTrench.openingTopLeft(), FieldConstants.LeftTrench.openingTopRight()); From 494d842c919a1a4d963fdf5fea93d022ea1407b6 Mon Sep 17 00:00:00 2001 From: aidnem <99768676+aidnem@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:24:04 -0500 Subject: [PATCH 03/30] (#80) More shooter and hood tuning --- .../deploy/constants/comp/FeatureFlags.json | 2 +- .../constants/comp/HopperConstants.json | 2 +- .../constants/comp/IndexerConstants.json | 2 +- .../constants/comp/ShooterConstants.json | 12 ++++----- .../java/frc/robot/CoordinationLayer.java | 26 ++++++++++++------- .../frc/robot/constants/HoodConstants.java | 4 ++- .../frc/robot/constants/ShooterConstants.java | 4 ++- state_machine_graphs/hood.dot | 6 ++--- 8 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/main/deploy/constants/comp/FeatureFlags.json b/src/main/deploy/constants/comp/FeatureFlags.json index 8433f5b7..f46a344d 100644 --- a/src/main/deploy/constants/comp/FeatureFlags.json +++ b/src/main/deploy/constants/comp/FeatureFlags.json @@ -1,5 +1,5 @@ { - "runDrive": true, + "runDrive": false, "useMAPoseEstimator": true, "runVision": false, "runHopper": true, diff --git a/src/main/deploy/constants/comp/HopperConstants.json b/src/main/deploy/constants/comp/HopperConstants.json index 24f7c28e..0c818493 100644 --- a/src/main/deploy/constants/comp/HopperConstants.json +++ b/src/main/deploy/constants/comp/HopperConstants.json @@ -9,7 +9,7 @@ "unit": "Radian per Second" }, "indexingVelocity": { - "value": 2000, + "value": 4000, "unit": "RPM" }, "hopperGains": { diff --git a/src/main/deploy/constants/comp/IndexerConstants.json b/src/main/deploy/constants/comp/IndexerConstants.json index 1b279d15..aa416d87 100644 --- a/src/main/deploy/constants/comp/IndexerConstants.json +++ b/src/main/deploy/constants/comp/IndexerConstants.json @@ -2,7 +2,7 @@ "indexerReduction": 1.0, "indexerMaximumRelativeVelocityError": 0.01, "indexingVelocity": { - "value": 2000, + "value": 4000, "unit": "RPM" }, "indexerGains": { diff --git a/src/main/deploy/constants/comp/ShooterConstants.json b/src/main/deploy/constants/comp/ShooterConstants.json index af1a07b7..9124cbde 100644 --- a/src/main/deploy/constants/comp/ShooterConstants.json +++ b/src/main/deploy/constants/comp/ShooterConstants.json @@ -10,11 +10,11 @@ 7.5 ], "defaultViMetersPerSecond": 9.0, - "shooterKP": 14.0, + "shooterKP": 15.0, "shooterKI": 0.0, "shooterKD": 0.0, "shooterKS": 0.0, - "shooterKV": 0.46, + "shooterKV": 0.55, "shooterKA": 0.0, "shooterReduction": 0.625, "characterizationRampRate": { @@ -26,7 +26,7 @@ "unit": "Amp" }, "shooterStatorCurrentLimit": { - "value": 80.0, + "value": 150.0, "unit": "Amp" }, "velocityFilterTime": { @@ -46,12 +46,12 @@ "unit": "Hertz" }, "outputUpdateFrequency": { - "value": 1000.0, + "value": 500.0, "unit": "Hertz" } }, "shooterClosedLoopFrequency": { - "value": 1000.0, + "value": 500.0, "unit": "Hertz" }, "shooterMaxVelocity": { @@ -59,7 +59,7 @@ "unit": "Rotation per Minute" }, "shooterVelocitySetpointEpsilon": { - "value": 50.0, + "value": 70.0, "unit": "RPM" }, "shooterMaxAcceleration": { diff --git a/src/main/java/frc/robot/CoordinationLayer.java b/src/main/java/frc/robot/CoordinationLayer.java index f6d8c276..9c3bb714 100644 --- a/src/main/java/frc/robot/CoordinationLayer.java +++ b/src/main/java/frc/robot/CoordinationLayer.java @@ -56,9 +56,8 @@ import frc.robot.util.geometry.EnhancedLine2d; import java.util.Optional; import java.util.function.BooleanSupplier; - -import org.junit.Test; import org.littletonrobotics.junction.AutoLogOutput; +import org.littletonrobotics.junction.AutoLogOutputManager; import org.littletonrobotics.junction.Logger; /** @@ -152,9 +151,14 @@ public enum ShotMode { private ShotMode shotMode = ShotMode.Hub; // Tunable numbers for shot tuning - private final LoggedTunableNumber hoodTuningAngleDegrees = new LoggedTunableNumber("CoordinationLayer/ShotTuning/hoodAngleDegrees", JsonConstants.hoodConstants.minHoodAngle.in(Degrees)); - private final LoggedTunableNumber shooterTuningRPM = new LoggedTunableNumber("CoordinationLayer/ShotTuning/shooterRPM", 0.0); - private final TestModeManager testModeManager = new TestModeManager<>("CoordinationLayer", CoordinationTestMode.class); + private final LoggedTunableNumber hoodTuningAngleDegrees = + new LoggedTunableNumber( + "CoordinationLayer/ShotTuning/hoodAngleDegrees", + JsonConstants.hoodConstants.minHoodAngle.in(Degrees)); + private final LoggedTunableNumber shooterTuningRPM = + new LoggedTunableNumber("CoordinationLayer/ShotTuning/shooterRPM", 0.0); + private final TestModeManager testModeManager = + new TestModeManager<>("CoordinationLayer", CoordinationTestMode.class); // Logging private final Alert autonomyOverriddenAlert = @@ -294,6 +298,8 @@ protected void periodic( extensionStateMachine.setState(noExtensionState); StateMachineDump.write("coordination", extensionStateMachine); + + AutoLogOutputManager.addObject(this); } // Controller bindings @@ -687,10 +693,11 @@ public void coordinateRobotActions() { aimForTestModeShot(); isShotReal = true; } else { - isShotReal = switch (effectiveAutonomyLevel) { - case Smart -> drive.map(this::runShotCalculatorWithDrive).orElse(false); - case Manual -> aimForManualShot(); - }; + isShotReal = + switch (effectiveAutonomyLevel) { + case Smart -> drive.map(this::runShotCalculatorWithDrive).orElse(false); + case Manual -> aimForManualShot(); + }; } Logger.recordOutput("CoordinationLayer/isShotReal", isShotReal); @@ -722,6 +729,7 @@ public void coordinateRobotActions() { // When the turret isn't enabled, assume that it's been locked into the correct // location for a manual mode shot if we ever have to run "no turret" && turret.map(TurretSubsystem::isAimedCorrectly).orElse(true)); + Logger.recordOutput("CoordinationLayer/canShoot", canShoot); if (canShoot) { hopper.ifPresent( diff --git a/src/main/java/frc/robot/constants/HoodConstants.java b/src/main/java/frc/robot/constants/HoodConstants.java index 045fec7e..3b85cfb0 100644 --- a/src/main/java/frc/robot/constants/HoodConstants.java +++ b/src/main/java/frc/robot/constants/HoodConstants.java @@ -19,6 +19,7 @@ import com.ctre.phoenix6.configs.TalonFXConfiguration; import com.ctre.phoenix6.signals.FeedbackSensorSourceValue; import com.ctre.phoenix6.signals.InvertedValue; +import com.ctre.phoenix6.signals.StaticFeedforwardSignValue; import coppercore.wpilib_interface.subsystems.configs.CANDeviceID; import coppercore.wpilib_interface.subsystems.configs.MechanismConfig; import coppercore.wpilib_interface.subsystems.configs.MechanismConfig.GravityFeedforwardType; @@ -123,7 +124,8 @@ public TalonFXConfiguration buildTalonFXConfigs() { .withKS(hoodKS) .withKG(hoodKG) .withKV(hoodKV) - .withKA(hoodKA)) + .withKA(hoodKA) + .withStaticFeedforwardSign(StaticFeedforwardSignValue.UseClosedLoopSign)) .withCurrentLimits( new CurrentLimitsConfigs() .withSupplyCurrentLimit(hoodSupplyCurrentLimit) diff --git a/src/main/java/frc/robot/constants/ShooterConstants.java b/src/main/java/frc/robot/constants/ShooterConstants.java index be673a4b..a1a621aa 100644 --- a/src/main/java/frc/robot/constants/ShooterConstants.java +++ b/src/main/java/frc/robot/constants/ShooterConstants.java @@ -14,6 +14,7 @@ import com.ctre.phoenix6.configs.MotorOutputConfigs; import com.ctre.phoenix6.configs.Slot0Configs; import com.ctre.phoenix6.configs.TalonFXConfiguration; +import com.ctre.phoenix6.configs.TorqueCurrentConfigs; import com.ctre.phoenix6.signals.FeedbackSensorSourceValue; import com.ctre.phoenix6.signals.InvertedValue; import com.ctre.phoenix6.signals.NeutralModeValue; @@ -163,7 +164,8 @@ public TalonFXConfiguration buildTalonFXConfigs() { new FeedbackConfigs() .withFeedbackSensorSource(FeedbackSensorSourceValue.RotorSensor) .withSensorToMechanismRatio(shooterReduction) - .withVelocityFilterTimeConstant(velocityFilterTime)); + .withVelocityFilterTimeConstant(velocityFilterTime)) + .withTorqueCurrent(new TorqueCurrentConfigs().withPeakReverseTorqueCurrent(Amps.zero())); } public MechanismConfig buildMechanismConfig() { diff --git a/state_machine_graphs/hood.dot b/state_machine_graphs/hood.dot index 15e62073..4926e41d 100644 --- a/state_machine_graphs/hood.dot +++ b/state_machine_graphs/hood.dot @@ -18,7 +18,7 @@ digraph { HomingWaitForMovementState ; HomingWaitForStoppingState ; IdleState ; - TargetPitchState ; + TargetExitPitchState ; TestModeState ; HomingWaitForButtonState ; @@ -32,10 +32,10 @@ digraph { HomingWaitForStoppingState -> IdleState [label="Is not moving"]; IdleState -> TestModeState [label="Is hood test mode"]; - IdleState -> TargetPitchState [label="Action == TargetPitch"]; + IdleState -> TargetExitPitchState [label="Action == TargetPitch"]; IdleState -> TargetAngleState [label="Action == TargetAngle"]; - TargetPitchState -> IdleState [label="Action != TargetPitch"]; + TargetExitPitchState -> IdleState [label="Action != TargetPitch"]; TestModeState -> IdleState [label="Isn't hood test mode"]; From 16e9bb10ee9f300259c7887233041693d5e610f3 Mon Sep 17 00:00:00 2001 From: Godmar Back Date: Sat, 28 Feb 2026 08:02:08 -0500 Subject: [PATCH 04/30] (#80) Add shot map tuning tab with video recording, NT4 telemetry, and flight time analysis Adds a complete shot tuning workflow: record shots via webcam with configurable frame rate (30/60/120 fps), connect to robot NT4 telemetry for live distance/RPM/hood angle, replay videos with frame-by-frame stepping (keyboard shortcuts: j/k, h/l, arrows, space), mark shooter-exit and target-hit timestamps to compute flight time, and export data points to the existing Shot Maps interpolation tables. Uses a raw NT4 WebSocket client (bypassing ntcore-ts-client which crashes on WPILib struct types) to read pose, actual shooter lead/follower velocities, and hood angle. Co-Authored-By: Claude Opus 4.6 --- settings_gui/nt4-beta-test.mjs | 63 ++++ settings_gui/nt4-debug.mjs | 86 +++++ settings_gui/package-lock.json | 78 +++++ settings_gui/package.json | 1 + .../components/shot-tuning/AttemptsList.tsx | 74 +++++ .../shot-tuning/NTConnectionStatus.tsx | 88 +++++ .../shot-tuning/RecordingControls.tsx | 266 ++++++++++++++++ .../shot-tuning/TelemetryDisplay.tsx | 35 ++ .../shot-tuning/VideoReplayPlayer.tsx | 301 ++++++++++++++++++ settings_gui/src/endpoints/registry.ts | 2 + settings_gui/src/pages/ShotMapTuning.tsx | 181 +++++++++++ settings_gui/src/services/nt4.ts | 189 +++++++++++ .../src/services/shotTuningStorage.ts | 37 +++ settings_gui/src/types/ShotTuning.ts | 16 + settings_gui/vite.config.ts | 114 ++++++- .../java/frc/robot/CoordinationLayer.java | 22 +- state_machine_graphs/hood.dot | 6 +- 17 files changed, 1546 insertions(+), 13 deletions(-) create mode 100644 settings_gui/nt4-beta-test.mjs create mode 100644 settings_gui/nt4-debug.mjs create mode 100644 settings_gui/src/components/shot-tuning/AttemptsList.tsx create mode 100644 settings_gui/src/components/shot-tuning/NTConnectionStatus.tsx create mode 100644 settings_gui/src/components/shot-tuning/RecordingControls.tsx create mode 100644 settings_gui/src/components/shot-tuning/TelemetryDisplay.tsx create mode 100644 settings_gui/src/components/shot-tuning/VideoReplayPlayer.tsx create mode 100644 settings_gui/src/pages/ShotMapTuning.tsx create mode 100644 settings_gui/src/services/nt4.ts create mode 100644 settings_gui/src/services/shotTuningStorage.ts create mode 100644 settings_gui/src/types/ShotTuning.ts diff --git a/settings_gui/nt4-beta-test.mjs b/settings_gui/nt4-beta-test.mjs new file mode 100644 index 00000000..414487d1 --- /dev/null +++ b/settings_gui/nt4-beta-test.mjs @@ -0,0 +1,63 @@ +// Test raw NT4 WebSocket approach (same logic as nt4.ts but for Node) +import { WebSocket } from 'ws'; +import { decodeMulti } from '@msgpack/msgpack'; + +const address = process.argv[2] || 'localhost'; +const url = `ws://${address}:5810/nt/test-client`; +console.log(`Connecting to ${url} ...`); + +const TOPICS = [ + '/AdvantageKit/RealOutputs/Odometry/Robot', + '/AdvantageKit/Shooter/LeadMotorInputs/VelocityRadiansPerSecond', + '/AdvantageKit/Shooter/FollowerMotorInputs/VelocityRadiansPerSecond', + '/AdvantageKit/RealOutputs/Hood/exitAngleRadians', +]; + +const ws = new WebSocket(url, ['networktables.first.wpi.edu']); +const topicNames = new Map(); +let updateCount = 0; + +ws.on('open', () => { + console.log('Connected!'); + ws.send(JSON.stringify([{ + method: 'subscribe', + params: { topics: TOPICS, subuid: 1, options: { periodic: 0.1 } }, + }])); +}); + +ws.on('message', (data, isBinary) => { + if (!isBinary) { + const msgs = JSON.parse(data.toString()); + for (const msg of msgs) { + if (msg.method === 'announce') { + topicNames.set(msg.params.id, msg.params.name); + console.log(` Announced: ${msg.params.name} (type=${msg.params.type})`); + } + } + } else { + const buf = Buffer.isBuffer(data) ? data : Buffer.concat(data); + for (const decoded of decodeMulti(buf)) { + if (Array.isArray(decoded) && decoded.length >= 4) { + const [topicId, , , value] = decoded; + const name = topicNames.get(topicId) || `id=${topicId}`; + if (name.includes('Odometry')) { + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const x = dv.getFloat64(0, true); + const y = dv.getFloat64(8, true); + if (updateCount++ < 10) console.log(` Pose: x=${x.toFixed(4)}, y=${y.toFixed(4)}`); + } else { + if (updateCount++ < 10) console.log(` ${name}: ${value}`); + } + } + } + } +}); + +ws.on('error', (err) => console.error('Error:', err.message)); + +setTimeout(() => { + console.log(`\n${updateCount} value updates received. Done.`); + ws.close(); + process.exit(0); +}, 5000); diff --git a/settings_gui/nt4-debug.mjs b/settings_gui/nt4-debug.mjs new file mode 100644 index 00000000..f2749ea4 --- /dev/null +++ b/settings_gui/nt4-debug.mjs @@ -0,0 +1,86 @@ +// Raw NT4 WebSocket diagnostic - bypasses ntcore-ts-client entirely +// Usage: node nt4-debug.mjs [address] +import { WebSocket } from 'ws'; +import { encode, decode } from '@msgpack/msgpack'; + +const address = process.argv[2] || 'localhost'; +const url = `ws://${address}:5810/nt/nt4-debug`; +console.log(`Connecting to ${url} ...`); + +const ws = new WebSocket(url, ['networktables.first.wpi.edu']); + +const topics = new Map(); // id -> { name, type } + +ws.on('open', () => { + console.log('Connected!\n'); + // Subscribe to everything with prefix "/" + const subMsg = [ + { method: 'subscribe', params: { topics: ['/'], subuid: 1, options: { prefix: true, all: true, periodic: 0.1 } } } + ]; + ws.send(JSON.stringify(subMsg)); +}); + +ws.on('message', (data, isBinary) => { + if (!isBinary) { + // Text frame = JSON array of announce/unannounce messages + const msgs = JSON.parse(data.toString()); + for (const msg of msgs) { + if (msg.method === 'announce') { + const { name, id, type } = msg.params; + topics.set(id, { name, type }); + console.log(` ANNOUNCE id=${id} type=${type.padEnd(20)} ${name}`); + } + } + } else { + // Binary frame = msgpack array of [topicId, timestampUs, typeNum, value] + const buf = Buffer.isBuffer(data) ? data : Buffer.concat(data); + try { + const decoded = decode(buf); + if (Array.isArray(decoded)) { + const [topicId, tsUs, typeNum, value] = decoded; + const topic = topics.get(topicId); + if (topic) { + let preview; + if (value instanceof Uint8Array || value instanceof ArrayBuffer) { + const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : value; + if (topic.type.startsWith('struct:') && bytes.length >= 16) { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const doubles = []; + for (let i = 0; i + 8 <= bytes.length; i += 8) { + doubles.push(dv.getFloat64(i, true).toFixed(4)); + } + preview = `[${doubles.join(', ')}] (${bytes.length} bytes as float64 LE)`; + } else { + preview = `<${bytes.length} bytes>`; + } + } else { + preview = JSON.stringify(value)?.substring(0, 80); + } + console.log(` VALUE ${topic.name} = ${preview}`); + } + } + } catch (e) { + // skip unparseable + } + } +}); + +ws.on('error', (err) => console.error('WS error:', err.message)); +ws.on('close', () => { console.log('Disconnected'); process.exit(0); }); + +setTimeout(() => { + console.log(`\n--- ${topics.size} topics found ---`); + const sorted = [...topics.values()].sort((a, b) => a.name.localeCompare(b.name)); + console.log('\nAll topics:'); + for (const t of sorted) { + console.log(` ${t.type.padEnd(22)} ${t.name}`); + } + const relevant = sorted.filter(t => /odometry|shooter|hood/i.test(t.name)); + if (relevant.length) { + console.log('\nRelevant (odometry/shooter/hood):'); + for (const t of relevant) { + console.log(` ${t.type.padEnd(22)} ${t.name}`); + } + } + ws.close(); +}, 5000); diff --git a/settings_gui/package-lock.json b/settings_gui/package-lock.json index addf4cae..7f0430ab 100644 --- a/settings_gui/package-lock.json +++ b/settings_gui/package-lock.json @@ -12,6 +12,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.7", "@mui/material": "^7.3.7", + "ntcore-ts-client": "^3.2.0-beta.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.0" @@ -1169,6 +1170,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", + "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, "node_modules/@mui/core-downloads-tracker": { "version": "7.3.7", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.7.tgz", @@ -3070,6 +3080,15 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3229,6 +3248,15 @@ "node": "*" } }, + "node_modules/mock-socket": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz", + "integrity": "sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3268,6 +3296,29 @@ "dev": true, "license": "MIT" }, + "node_modules/ntcore-ts-client": { + "version": "3.2.0-beta.0", + "resolved": "https://registry.npmjs.org/ntcore-ts-client/-/ntcore-ts-client-3.2.0-beta.0.tgz", + "integrity": "sha512-3Su8/Orwsh3o7w6+eI3oYSLZio93ep+kmJJ7nDvbCUezERNOaBPundT2g++cNNV/hcS+unen1o6JwUFpIx3fgg==", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^3.1.1", + "isomorphic-ws": "^5.0.0", + "mock-socket": "9.3.1", + "tslib": "2.8.1", + "ws": "^8.18.1", + "zod": "^3.24.3" + } + }, + "node_modules/ntcore-ts-client/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3780,6 +3831,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3980,6 +4037,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/settings_gui/package.json b/settings_gui/package.json index 5faabbf5..6ce6c0dc 100644 --- a/settings_gui/package.json +++ b/settings_gui/package.json @@ -16,6 +16,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.7", "@mui/material": "^7.3.7", + "ntcore-ts-client": "^3.2.0-beta.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.0" diff --git a/settings_gui/src/components/shot-tuning/AttemptsList.tsx b/settings_gui/src/components/shot-tuning/AttemptsList.tsx new file mode 100644 index 00000000..45aada3e --- /dev/null +++ b/settings_gui/src/components/shot-tuning/AttemptsList.tsx @@ -0,0 +1,74 @@ +import { + Box, + Typography, + List, + ListItemButton, + ListItemText, + ListItemSecondaryAction, + IconButton, + Chip, +} from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import type { TuningAttempt } from '../../types/ShotTuning'; + +interface AttemptsListProps { + attempts: TuningAttempt[]; + selectedId: string | null; + onSelect: (id: string) => void; + onDelete: (id: string) => void; +} + +export function AttemptsList({ attempts, selectedId, onSelect, onDelete }: AttemptsListProps) { + const sorted = [...attempts].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + + return ( + + + Attempts ({attempts.length}) + + + {attempts.length === 0 ? ( + + No attempts recorded yet. Use the recording controls to capture a shot. + + ) : ( + + {sorted.map((attempt) => ( + onSelect(attempt.id)} + sx={{ borderRadius: 1 }} + > + + + {attempt.distanceMeters.toFixed(2)}m + + {attempt.flightTimeSec !== null && ( + + )} + {attempt.exportedToShotMap && ( + + )} + + } + secondary={new Date(attempt.createdAt).toLocaleString()} + /> + + { e.stopPropagation(); onDelete(attempt.id); }} + > + + + + + ))} + + )} + + ); +} diff --git a/settings_gui/src/components/shot-tuning/NTConnectionStatus.tsx b/settings_gui/src/components/shot-tuning/NTConnectionStatus.tsx new file mode 100644 index 00000000..b0eb3809 --- /dev/null +++ b/settings_gui/src/components/shot-tuning/NTConnectionStatus.tsx @@ -0,0 +1,88 @@ +import { useState, useEffect, useRef } from 'react'; +import { + Box, + Button, + TextField, + Typography, + Chip, + ToggleButtonGroup, + ToggleButton, +} from '@mui/material'; +import type { NT4Service } from '../../services/nt4'; + +interface NTConnectionStatusProps { + onConnect: (address: string) => Promise; + onDisconnect: () => void; +} + +const PRESETS = [ + { label: 'Sim (localhost)', value: 'localhost' }, + { label: 'Robot (10.4.1.2)', value: '10.4.1.2' }, +]; + +export function NTConnectionStatus({ onConnect, onDisconnect }: NTConnectionStatusProps) { + const [address, setAddress] = useState('localhost'); + const [connected, setConnected] = useState(false); + const [active, setActive] = useState(false); + const cleanupRef = useRef<(() => void) | null>(null); + + useEffect(() => { + return () => { + cleanupRef.current?.(); + }; + }, []); + + const handleConnect = async () => { + setActive(true); + const svc = await onConnect(address); + cleanupRef.current = svc.addConnectionListener((conn) => setConnected(conn)); + }; + + const handleDisconnect = () => { + cleanupRef.current?.(); + cleanupRef.current = null; + onDisconnect(); + setConnected(false); + setActive(false); + }; + + return ( + + + NT4 Connection + + + + { if (v) setAddress(v); }} + size="small" + sx={{ mb: 1 }} + > + {PRESETS.map((p) => ( + {p.label} + ))} + + + + setAddress(e.target.value)} + sx={{ flex: 1 }} + /> + {!active ? ( + + ) : ( + + )} + + + ); +} diff --git a/settings_gui/src/components/shot-tuning/RecordingControls.tsx b/settings_gui/src/components/shot-tuning/RecordingControls.tsx new file mode 100644 index 00000000..08e79d55 --- /dev/null +++ b/settings_gui/src/components/shot-tuning/RecordingControls.tsx @@ -0,0 +1,266 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { Box, Button, Typography, Alert, Select, MenuItem, InputLabel, FormControl, ToggleButtonGroup, ToggleButton, Tooltip } from '@mui/material'; +import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'; +import StopIcon from '@mui/icons-material/Stop'; +import DeleteIcon from '@mui/icons-material/Delete'; +import SaveIcon from '@mui/icons-material/Save'; + +const FPS_OPTIONS = [30, 60, 120] as const; + +interface RecordingControlsProps { + onStore: (blob: Blob) => Promise; + onFpsChange?: (fps: number) => void; +} + +function pickCodec(): string { + for (const codec of ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']) { + if (MediaRecorder.isTypeSupported(codec)) return codec; + } + return 'video/webm'; +} + +export function RecordingControls({ onStore, onFpsChange }: RecordingControlsProps) { + const videoRef = useRef(null); + const recorderRef = useRef(null); + const streamRef = useRef(null); + const chunksRef = useRef([]); + + const [cameras, setCameras] = useState([]); + const [selectedCameraId, setSelectedCameraId] = useState(''); + const [selectedFps, setSelectedFps] = useState(30); + const [actualFps, setActualFps] = useState(null); + const [cameraReady, setCameraReady] = useState(false); + const [cameraError, setCameraError] = useState(null); + const [recording, setRecording] = useState(false); + const [pendingBlob, setPendingBlob] = useState(null); + const [saving, setSaving] = useState(false); + + // Enumerate cameras after initial permission grant + const enumerateCameras = useCallback(async () => { + const devices = await navigator.mediaDevices.enumerateDevices(); + const videoDevices = devices.filter((d) => d.kind === 'videoinput'); + setCameras(videoDevices); + return videoDevices; + }, []); + + // Open a specific camera by deviceId (or default if empty) + const openCamera = useCallback(async (deviceId?: string, fps?: number) => { + // Stop existing stream + streamRef.current?.getTracks().forEach((t) => t.stop()); + setCameraReady(false); + setCameraError(null); + setActualFps(null); + + const targetFps = fps ?? selectedFps; + try { + const videoConstraints: MediaTrackConstraints = { + frameRate: { ideal: targetFps }, + }; + if (deviceId) videoConstraints.deviceId = { exact: deviceId }; + const stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints }); + streamRef.current = stream; + if (videoRef.current) { + videoRef.current.srcObject = stream; + } + setCameraReady(true); + + // Report actual frame rate + const activeTrack = stream.getVideoTracks()[0]; + const settings = activeTrack?.getSettings(); + const deliveredFps = settings?.frameRate ?? null; + setActualFps(deliveredFps); + if (deliveredFps) onFpsChange?.(deliveredFps); + + // After permission is granted, enumerate to get labels + const videoDevices = await enumerateCameras(); + // If no deviceId was specified, figure out which one we got + if (!deviceId && videoDevices.length > 0) { + const activeDeviceId = settings?.deviceId; + if (activeDeviceId) setSelectedCameraId(activeDeviceId); + } + } catch (err) { + setCameraError(err instanceof Error ? err.message : String(err)); + } + }, [enumerateCameras, selectedFps, onFpsChange]); + + // Acquire default camera on mount + useEffect(() => { + openCamera(); + return () => { + streamRef.current?.getTracks().forEach((t) => t.stop()); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleCameraChange = useCallback((deviceId: string) => { + setSelectedCameraId(deviceId); + openCamera(deviceId); + }, [openCamera]); + + const handleFpsChange = useCallback((fps: number) => { + setSelectedFps(fps); + openCamera(selectedCameraId || undefined, fps); + }, [openCamera, selectedCameraId]); + + const handleStart = useCallback(() => { + if (!streamRef.current) return; + chunksRef.current = []; + const mimeType = pickCodec(); + const recorder = new MediaRecorder(streamRef.current, { mimeType }); + recorder.ondataavailable = (e) => { + if (e.data.size > 0) chunksRef.current.push(e.data); + }; + recorder.onstop = () => { + const blob = new Blob(chunksRef.current, { type: mimeType }); + setPendingBlob(blob); + }; + recorder.start(); + recorderRef.current = recorder; + setRecording(true); + setPendingBlob(null); + }, []); + + const handleStop = useCallback(() => { + recorderRef.current?.stop(); + recorderRef.current = null; + setRecording(false); + }, []); + + const handleDiscard = useCallback(() => { + setPendingBlob(null); + }, []); + + const handleStore = useCallback(async () => { + if (!pendingBlob) return; + setSaving(true); + try { + await onStore(pendingBlob); + setPendingBlob(null); + } finally { + setSaving(false); + } + }, [pendingBlob, onStore]); + + return ( + + + Recording + + + {cameras.length > 1 && ( + + Camera + + + )} + + + FPS: + { if (v !== null) handleFpsChange(v); }} + disabled={recording} + > + {FPS_OPTIONS.map((fps) => ( + {fps} + ))} + + {actualFps !== null && actualFps !== selectedFps && ( + + + actual: {Math.round(actualFps)} fps + + + )} + {actualFps !== null && actualFps === selectedFps && ( + + {Math.round(actualFps)} fps + + )} + + + {cameraError && ( + {cameraError} + )} + + + + + + {!recording && !pendingBlob && ( + + )} + + {recording && ( + + )} + + {pendingBlob && ( + <> + + + + )} + + + ); +} diff --git a/settings_gui/src/components/shot-tuning/TelemetryDisplay.tsx b/settings_gui/src/components/shot-tuning/TelemetryDisplay.tsx new file mode 100644 index 00000000..9ae153cd --- /dev/null +++ b/settings_gui/src/components/shot-tuning/TelemetryDisplay.tsx @@ -0,0 +1,35 @@ +import { Box, Typography } from '@mui/material'; +import type { Telemetry } from '../../services/nt4'; + +interface TelemetryDisplayProps { + telemetry: Telemetry; +} + +function fmt(value: number, decimals = 3): string { + return value.toFixed(decimals); +} + +export function TelemetryDisplay({ telemetry }: TelemetryDisplayProps) { + return ( + + + Live Telemetry + + + Distance to Hub: + {fmt(telemetry.distanceMeters)} m + + Shooter Velocity: + {fmt(telemetry.shooterRPMRadPerSec * 60 / (2 * Math.PI), 1)} RPM + + Hood Angle: + {fmt(telemetry.hoodAngleRadians * 180 / Math.PI, 2)} deg + + Pose (x, y): + + ({fmt(telemetry.robotPoseX)}, {fmt(telemetry.robotPoseY)}) + + + + ); +} diff --git a/settings_gui/src/components/shot-tuning/VideoReplayPlayer.tsx b/settings_gui/src/components/shot-tuning/VideoReplayPlayer.tsx new file mode 100644 index 00000000..f7506cc8 --- /dev/null +++ b/settings_gui/src/components/shot-tuning/VideoReplayPlayer.tsx @@ -0,0 +1,301 @@ +import { useRef, useState, useEffect, useCallback } from 'react'; +import { + Box, + Button, + Typography, + IconButton, + Divider, + Slider, + Tooltip, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import PauseIcon from '@mui/icons-material/Pause'; +import type { TuningAttempt } from '../../types/ShotTuning'; +import type { ShotMaps, ShotMapDataPoint } from '../../types/ShotMaps'; +import { clipUrl } from '../../services/shotTuningStorage'; +import { useConnection } from '../../contexts/ConnectionContext'; +import { loadLocal, saveLocal } from '../../services/api'; + +interface VideoReplayPlayerProps { + attempt: TuningAttempt; + onUpdate: (attempt: TuningAttempt) => Promise; + attempts: TuningAttempt[]; + onAttemptsChange: (attempts: TuningAttempt[]) => Promise; + fps?: number; +} + +// Conversion helpers +const RAD_PER_SEC_TO_RPM = 60 / (2 * Math.PI); +const RAD_TO_DEG = 180 / Math.PI; + +export function VideoReplayPlayer({ attempt, onUpdate, attempts, onAttemptsChange, fps = 30 }: VideoReplayPlayerProps) { + const FRAME_STEP = 1 / fps; + const videoRef = useRef(null); + const containerRef = useRef(null); + const [playing, setPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const { environment } = useConnection(); + + // Webm files often report wrong duration until fully buffered. + // Seek to a huge time to force the browser to find the real end, + // then read the corrected duration and seek back to 0. + const fixWebmDuration = useCallback(() => { + const video = videoRef.current; + if (!video) return; + if (video.duration && isFinite(video.duration) && video.duration > 0.5) { + setDuration(video.duration); + return; + } + const onSeeked = () => { + video.removeEventListener('seeked', onSeeked); + if (isFinite(video.duration)) { + setDuration(video.duration); + } + video.currentTime = 0; + }; + video.addEventListener('seeked', onSeeked); + video.currentTime = 1e10; + }, []); + + useEffect(() => { + setPlaying(false); + setCurrentTime(0); + setDuration(0); + }, [attempt.id]); + + const handleTimeUpdate = useCallback(() => { + if (videoRef.current) setCurrentTime(videoRef.current.currentTime); + }, []); + + const handleLoadedMetadata = useCallback(() => { + fixWebmDuration(); + }, [fixWebmDuration]); + + const handleDurationChange = useCallback(() => { + const video = videoRef.current; + if (video && isFinite(video.duration) && video.duration > 0) { + setDuration(video.duration); + } + }, []); + + const togglePlay = useCallback(() => { + if (!videoRef.current) return; + if (playing) { + videoRef.current.pause(); + } else { + videoRef.current.play(); + } + setPlaying(!playing); + }, [playing]); + + const stepFrame = useCallback((delta: number) => { + const video = videoRef.current; + if (!video) return; + video.pause(); + setPlaying(false); + const target = Math.max(0, Math.min(duration || Infinity, video.currentTime + delta)); + video.currentTime = target; + }, [duration]); + + const seekTo = useCallback((time: number) => { + const video = videoRef.current; + if (!video) return; + video.pause(); + setPlaying(false); + video.currentTime = time; + }, []); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement).tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + + switch (e.key) { + case 'ArrowLeft': + case 'j': + e.preventDefault(); + stepFrame(-FRAME_STEP); + break; + case 'ArrowRight': + case 'k': + e.preventDefault(); + stepFrame(FRAME_STEP); + break; + case 'h': + e.preventDefault(); + stepFrame(-FRAME_STEP * 10); + break; + case 'l': + e.preventDefault(); + stepFrame(FRAME_STEP * 10); + break; + case ' ': + e.preventDefault(); + togglePlay(); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [stepFrame, togglePlay]); + + const markLeavesShooter = async () => { + const updated = { ...attempt, leavesShooterTimeSec: currentTime }; + if (updated.hitTargetTimeSec !== null) { + updated.flightTimeSec = updated.hitTargetTimeSec - currentTime; + } + await onUpdate(updated); + }; + + const markHitTarget = async () => { + const updated = { ...attempt, hitTargetTimeSec: currentTime }; + if (updated.leavesShooterTimeSec !== null) { + updated.flightTimeSec = currentTime - updated.leavesShooterTimeSec; + } + await onUpdate(updated); + }; + + const handleExport = async (target: 'hub' | 'pass') => { + if (attempt.flightTimeSec === null) return; + const shotMaps = await loadLocal(environment, 'ShotMaps.json'); + const newPoint: ShotMapDataPoint = { + distance: { value: attempt.distanceMeters, unit: 'Meter' }, + shooterRPM: attempt.shooterRPMRadPerSec * RAD_PER_SEC_TO_RPM, + hoodAngle: { value: attempt.hoodAngleRadians * RAD_TO_DEG, unit: 'Degree' }, + flightTime: { value: attempt.flightTimeSec, unit: 'Second' }, + }; + if (target === 'hub') { + shotMaps.hubDataPoints.push(newPoint); + } else { + shotMaps.passDataPoints.push(newPoint); + } + await saveLocal(environment, 'ShotMaps.json', shotMaps); + + const updated = { ...attempt, exportedToShotMap: target }; + const next = attempts.map((a) => a.id === updated.id ? updated : a); + await onAttemptsChange(next); + }; + + return ( + + + Video Replay + + + + ); +} diff --git a/settings_gui/src/endpoints/registry.ts b/settings_gui/src/endpoints/registry.ts index 98fb7d6b..87418e7a 100644 --- a/settings_gui/src/endpoints/registry.ts +++ b/settings_gui/src/endpoints/registry.ts @@ -1,6 +1,7 @@ import type { ComponentType } from 'react'; import { ShotMapsEditor } from '../pages/ShotMapsEditor'; import { VisionEditor } from '../pages/VisionEditor'; +import { ShotMapTuning } from '../pages/ShotMapTuning'; export interface EndpointEntry { name: string; @@ -11,4 +12,5 @@ export interface EndpointEntry { export const endpoints: EndpointEntry[] = [ { name: 'shotmaps', label: 'Shot Maps', component: ShotMapsEditor }, { name: 'vision', label: 'Vision', component: VisionEditor }, + { name: 'shot-tuning', label: 'Shot Map Tuning', component: ShotMapTuning }, ]; diff --git a/settings_gui/src/pages/ShotMapTuning.tsx b/settings_gui/src/pages/ShotMapTuning.tsx new file mode 100644 index 00000000..3adf510d --- /dev/null +++ b/settings_gui/src/pages/ShotMapTuning.tsx @@ -0,0 +1,181 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { + Box, + Typography, + Alert, + Snackbar, + Paper, + Grid, +} from '@mui/material'; +import type { TuningAttempt } from '../types/ShotTuning'; +import { loadAttempts, saveAttempts, uploadClip, deleteClip } from '../services/shotTuningStorage'; +import { NTConnectionStatus } from '../components/shot-tuning/NTConnectionStatus'; +import { TelemetryDisplay } from '../components/shot-tuning/TelemetryDisplay'; +import { RecordingControls } from '../components/shot-tuning/RecordingControls'; +import { AttemptsList } from '../components/shot-tuning/AttemptsList'; +import { VideoReplayPlayer } from '../components/shot-tuning/VideoReplayPlayer'; +import type { NT4Service, Telemetry } from '../services/nt4'; +import { createNT4Service } from '../services/nt4'; + +export function ShotMapTuning() { + const [attempts, setAttempts] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [snack, setSnack] = useState<{ message: string; severity: 'success' | 'error' } | null>(null); + const [error, setError] = useState(null); + const [telemetry, setTelemetry] = useState({ + distanceMeters: 0, + shooterRPMRadPerSec: 0, + hoodAngleRadians: 0, + robotPoseX: 0, + robotPoseY: 0, + }); + const [recordingFps, setRecordingFps] = useState(30); + const nt4Ref = useRef(null); + const telemetryRef = useRef(telemetry); + telemetryRef.current = telemetry; + + const fetchAttempts = useCallback(async () => { + try { + const data = await loadAttempts(); + setAttempts(data); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load attempts'); + } + }, []); + + useEffect(() => { fetchAttempts(); }, [fetchAttempts]); + + const handleConnect = useCallback(async (address: string) => { + if (nt4Ref.current) { + nt4Ref.current.disconnect(); + } + const svc = await createNT4Service(address, setTelemetry); + nt4Ref.current = svc; + return svc; + }, []); + + const handleDisconnect = useCallback(() => { + if (nt4Ref.current) { + nt4Ref.current.disconnect(); + nt4Ref.current = null; + } + }, []); + + useEffect(() => { + return () => { + if (nt4Ref.current) nt4Ref.current.disconnect(); + }; + }, []); + + const handleStore = useCallback(async (blob: Blob) => { + const id = crypto.randomUUID(); + const snap = telemetryRef.current; + const attempt: TuningAttempt = { + id, + createdAt: new Date().toISOString(), + distanceMeters: snap.distanceMeters, + shooterRPMRadPerSec: snap.shooterRPMRadPerSec, + hoodAngleRadians: snap.hoodAngleRadians, + robotPoseX: snap.robotPoseX, + robotPoseY: snap.robotPoseY, + leavesShooterTimeSec: null, + hitTargetTimeSec: null, + flightTimeSec: null, + exportedToShotMap: null, + fps: recordingFps, + }; + try { + await uploadClip(id, blob); + const next = [...attempts, attempt]; + await saveAttempts(next); + setAttempts(next); + setSelectedId(id); + setSnack({ message: 'Attempt stored', severity: 'success' }); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to store attempt'); + } + }, [attempts]); + + const handleDelete = useCallback(async (id: string) => { + try { + await deleteClip(id); + const next = attempts.filter((a) => a.id !== id); + await saveAttempts(next); + setAttempts(next); + if (selectedId === id) setSelectedId(null); + setSnack({ message: 'Attempt deleted', severity: 'success' }); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to delete attempt'); + } + }, [attempts, selectedId]); + + const handleUpdateAttempt = useCallback(async (updated: TuningAttempt) => { + const next = attempts.map((a) => a.id === updated.id ? updated : a); + try { + await saveAttempts(next); + setAttempts(next); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to update attempt'); + } + }, [attempts]); + + const selectedAttempt = attempts.find((a) => a.id === selectedId) ?? null; + + return ( + + Shot Map Tuning + + {error && setError(null)}>{error}} + + + {/* Left column: NT4 + Recording */} + + + + + + + + + + + + + + + {/* Right column: Replay + Attempts */} + + {selectedAttempt && ( + + { + await saveAttempts(next); + setAttempts(next); + }} + fps={selectedAttempt.fps ?? 30} + /> + + )} + + + + + + + + setSnack(null)}> + setSnack(null)}> + {snack?.message} + + + + ); +} diff --git a/settings_gui/src/services/nt4.ts b/settings_gui/src/services/nt4.ts new file mode 100644 index 00000000..a8dfdce4 --- /dev/null +++ b/settings_gui/src/services/nt4.ts @@ -0,0 +1,189 @@ +import { HUB_CENTER } from '../types/ShotTuning'; + +export interface Telemetry { + distanceMeters: number; + shooterRPMRadPerSec: number; + hoodAngleRadians: number; + robotPoseX: number; + robotPoseY: number; +} + +export interface NT4Service { + disconnect(): void; + addConnectionListener(cb: (connected: boolean) => void): () => void; + isConnected(): boolean; +} + +// AdvantageKit publishes Logger.recordOutput / @AutoLogOutput under this prefix. +const AK_PREFIX = '/AdvantageKit/RealOutputs'; + +// Actual topics discovered via nt4-debug.mjs against the robot sim: +const TOPIC_POSE = `${AK_PREFIX}/Odometry/Robot`; // struct:Pose2d +// processInputs topics live under /AdvantageKit// (no RealOutputs). +const TOPIC_SHOOTER_LEAD = '/AdvantageKit/Shooter/LeadMotorInputs/VelocityRadiansPerSecond'; // double +const TOPIC_SHOOTER_FOLLOWER = '/AdvantageKit/Shooter/FollowerMotorInputs/VelocityRadiansPerSecond'; // double +const TOPIC_HOOD_ANGLE = `${AK_PREFIX}/Hood/exitAngleRadians`; // double + +// Minimal raw NT4.1 WebSocket client. +// ntcore-ts-client cannot handle WPILib struct types (crashes on Zod validation +// in v3.1.x, and on ArrayBuffer data validation in v3.2.0-beta.0). + +export async function createNT4Service( + address: string, + onTelemetry: (update: Telemetry) => void, +): Promise { + const { decodeMulti } = await import('@msgpack/msgpack'); + + let poseX = 0; + let poseY = 0; + let shooterLeadVel = 0; + let shooterFollowerVel = 0; + let hoodAngle = 0; + + function emitUpdate() { + const dx = poseX - HUB_CENTER.x; + const dy = poseY - HUB_CENTER.y; + onTelemetry({ + distanceMeters: Math.sqrt(dx * dx + dy * dy), + shooterRPMRadPerSec: (shooterLeadVel + shooterFollowerVel) / 2, + hoodAngleRadians: hoodAngle, + robotPoseX: poseX, + robotPoseY: poseY, + }); + } + + // Map server-assigned topic id -> handler + const topicHandlers = new Map void>(); + + // Topic name -> handler, wired up when server announces each topic + const wantedTopics = new Map void>(); + + wantedTopics.set(TOPIC_POSE, (value) => { + // struct:Pose2d = 3x float64 LE (x, y, rotation) = 24 bytes + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value as ArrayBuffer); + if (bytes.byteLength >= 16) { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + poseX = dv.getFloat64(0, true); + poseY = dv.getFloat64(8, true); + emitUpdate(); + } + }); + + wantedTopics.set(TOPIC_SHOOTER_LEAD, (value) => { + if (typeof value === 'number') { + shooterLeadVel = value; + emitUpdate(); + } + }); + + wantedTopics.set(TOPIC_SHOOTER_FOLLOWER, (value) => { + if (typeof value === 'number') { + shooterFollowerVel = value; + emitUpdate(); + } + }); + + wantedTopics.set(TOPIC_HOOD_ANGLE, (value) => { + if (typeof value === 'number') { + hoodAngle = value; + emitUpdate(); + } + }); + + const connectionListeners = new Set<(connected: boolean) => void>(); + let connected = false; + let ws: WebSocket | null = null; + let disposed = false; + let reconnectTimer: ReturnType | null = null; + + function setConnected(c: boolean) { + if (connected === c) return; + connected = c; + for (const cb of connectionListeners) cb(c); + } + + function connect() { + if (disposed) return; + + const url = `ws://${address}:5810/nt/settings-gui`; + ws = new WebSocket(url, ['networktables.first.wpi.edu']); + ws.binaryType = 'arraybuffer'; + + ws.onopen = () => { + setConnected(true); + // Subscribe to our specific topics + ws!.send(JSON.stringify([{ + method: 'subscribe', + params: { + topics: [...wantedTopics.keys()], + subuid: 1, + options: { periodic: 0.1 }, + }, + }])); + }; + + ws.onmessage = (event) => { + if (typeof event.data === 'string') { + // Text frame: JSON array of announce/unannounce messages + try { + const msgs = JSON.parse(event.data) as Array<{ method: string; params: Record }>; + for (const msg of msgs) { + if (msg.method === 'announce') { + const name = msg.params.name as string; + const id = msg.params.id as number; + const handler = wantedTopics.get(name); + if (handler) topicHandlers.set(id, handler); + } else if (msg.method === 'unannounce') { + topicHandlers.delete(msg.params.id as number); + } + } + } catch { /* ignore */ } + } else { + // Binary frame: one or more msgpack arrays [topicId, tsUs, typeNum, value] + try { + const buf = new Uint8Array(event.data as ArrayBuffer); + for (const decoded of decodeMulti(buf)) { + const arr = decoded as unknown[]; + if (Array.isArray(arr) && arr.length >= 4) { + const handler = topicHandlers.get(arr[0] as number); + if (handler) handler(arr[3]); + } + } + } catch { /* ignore */ } + } + }; + + ws.onclose = () => { + setConnected(false); + topicHandlers.clear(); + if (!disposed) { + reconnectTimer = setTimeout(connect, 1000); + } + }; + + ws.onerror = () => { /* onclose fires after */ }; + } + + connect(); + + return { + disconnect() { + disposed = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + if (ws) { + ws.onclose = null; + ws.close(); + ws = null; + } + setConnected(false); + }, + addConnectionListener(cb: (connected: boolean) => void) { + connectionListeners.add(cb); + cb(connected); + return () => { connectionListeners.delete(cb); }; + }, + isConnected() { + return connected; + }, + }; +} diff --git a/settings_gui/src/services/shotTuningStorage.ts b/settings_gui/src/services/shotTuningStorage.ts new file mode 100644 index 00000000..f96109c4 --- /dev/null +++ b/settings_gui/src/services/shotTuningStorage.ts @@ -0,0 +1,37 @@ +import type { TuningAttempt } from '../types/ShotTuning'; + +export async function loadAttempts(): Promise { + const res = await fetch('/shot-tuning/attempts'); + if (!res.ok) { + if (res.status === 404) return []; + throw new Error(`Load attempts failed: ${res.status}`); + } + return res.json() as Promise; +} + +export async function saveAttempts(attempts: TuningAttempt[]): Promise { + const res = await fetch('/shot-tuning/attempts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(attempts), + }); + if (!res.ok) throw new Error(`Save attempts failed: ${res.status}`); +} + +export async function uploadClip(id: string, blob: Blob): Promise { + const res = await fetch(`/shot-tuning/clips/${id}`, { + method: 'POST', + headers: { 'Content-Type': 'video/webm' }, + body: blob, + }); + if (!res.ok) throw new Error(`Upload clip failed: ${res.status}`); +} + +export function clipUrl(id: string): string { + return `/shot-tuning/clips/${id}`; +} + +export async function deleteClip(id: string): Promise { + const res = await fetch(`/shot-tuning/clips/${id}`, { method: 'DELETE' }); + if (!res.ok && res.status !== 404) throw new Error(`Delete clip failed: ${res.status}`); +} diff --git a/settings_gui/src/types/ShotTuning.ts b/settings_gui/src/types/ShotTuning.ts new file mode 100644 index 00000000..6d718595 --- /dev/null +++ b/settings_gui/src/types/ShotTuning.ts @@ -0,0 +1,16 @@ +export interface TuningAttempt { + id: string; + createdAt: string; + distanceMeters: number; + shooterRPMRadPerSec: number; + hoodAngleRadians: number; + robotPoseX: number; + robotPoseY: number; + leavesShooterTimeSec: number | null; + hitTargetTimeSec: number | null; + flightTimeSec: number | null; + exportedToShotMap: 'hub' | 'pass' | null; + fps: number; +} + +export const HUB_CENTER = { x: 4.02844, y: 4.00050 } as const; diff --git a/settings_gui/vite.config.ts b/settings_gui/vite.config.ts index 24b49a2b..417e5555 100644 --- a/settings_gui/vite.config.ts +++ b/settings_gui/vite.config.ts @@ -79,9 +79,121 @@ function localFilesPlugin(): Plugin { }; } +function shotTuningPlugin(): Plugin { + const dataDir = (...parts: string[]) => + path.resolve(__dirname, 'shot-tuning-data', ...parts); + + return { + name: 'shot-tuning', + configureServer(server) { + // GET /shot-tuning/attempts + server.middlewares.use((req, res, next) => { + if (req.url !== '/shot-tuning/attempts' || req.method !== 'GET') return next(); + try { + const filePath = dataDir('attempts.json'); + if (!fs.existsSync(filePath)) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end('[]'); + return; + } + const content = fs.readFileSync(filePath, 'utf-8'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(content); + } catch (e) { + res.statusCode = 500; + res.end(JSON.stringify({ error: String(e) })); + } + }); + + // POST /shot-tuning/attempts + server.middlewares.use((req, res, next) => { + if (req.url !== '/shot-tuning/attempts' || req.method !== 'POST') return next(); + let body = ''; + req.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + req.on('end', () => { + try { + const json = JSON.parse(body); + const dest = dataDir('attempts.json'); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, JSON.stringify(json, null, 2) + '\n'); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + } catch (e) { + res.statusCode = 500; + res.end(JSON.stringify({ error: String(e) })); + } + }); + }); + + // POST /shot-tuning/clips/:id (save binary webm) + server.middlewares.use((req, res, next) => { + const match = req.url?.match(/^\/shot-tuning\/clips\/([a-f0-9-]+)$/); + if (!match || req.method !== 'POST') return next(); + const id = match[1]; + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + try { + const dir = dataDir('clips'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `${id}.webm`), Buffer.concat(chunks)); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + } catch (e) { + res.statusCode = 500; + res.end(JSON.stringify({ error: String(e) })); + } + }); + }); + + // GET /shot-tuning/clips/:id (serve webm) + server.middlewares.use((req, res, next) => { + const match = req.url?.match(/^\/shot-tuning\/clips\/([a-f0-9-]+)$/); + if (!match || req.method !== 'GET') return next(); + const id = match[1]; + const filePath = dataDir('clips', `${id}.webm`); + if (!fs.existsSync(filePath)) { + res.statusCode = 404; + res.end('Not found'); + return; + } + const stat = fs.statSync(filePath); + res.statusCode = 200; + res.setHeader('Content-Type', 'video/webm'); + res.setHeader('Content-Length', stat.size); + fs.createReadStream(filePath).pipe(res); + }); + + // DELETE /shot-tuning/clips/:id + server.middlewares.use((req, res, next) => { + const match = req.url?.match(/^\/shot-tuning\/clips\/([a-f0-9-]+)$/); + if (!match || req.method !== 'DELETE') return next(); + const id = match[1]; + const filePath = dataDir('clips', `${id}.webm`); + try { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + } catch (e) { + res.statusCode = 500; + res.end(JSON.stringify({ error: String(e) })); + } + }); + }, + }; +} + // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), localFilesPlugin()], + plugins: [react(), localFilesPlugin(), shotTuningPlugin()], + resolve: { + dedupe: ['react', 'react-dom'], + }, server: { proxy: { '/api': { diff --git a/src/main/java/frc/robot/CoordinationLayer.java b/src/main/java/frc/robot/CoordinationLayer.java index f6d8c276..37f0596f 100644 --- a/src/main/java/frc/robot/CoordinationLayer.java +++ b/src/main/java/frc/robot/CoordinationLayer.java @@ -56,8 +56,6 @@ import frc.robot.util.geometry.EnhancedLine2d; import java.util.Optional; import java.util.function.BooleanSupplier; - -import org.junit.Test; import org.littletonrobotics.junction.AutoLogOutput; import org.littletonrobotics.junction.Logger; @@ -152,9 +150,14 @@ public enum ShotMode { private ShotMode shotMode = ShotMode.Hub; // Tunable numbers for shot tuning - private final LoggedTunableNumber hoodTuningAngleDegrees = new LoggedTunableNumber("CoordinationLayer/ShotTuning/hoodAngleDegrees", JsonConstants.hoodConstants.minHoodAngle.in(Degrees)); - private final LoggedTunableNumber shooterTuningRPM = new LoggedTunableNumber("CoordinationLayer/ShotTuning/shooterRPM", 0.0); - private final TestModeManager testModeManager = new TestModeManager<>("CoordinationLayer", CoordinationTestMode.class); + private final LoggedTunableNumber hoodTuningAngleDegrees = + new LoggedTunableNumber( + "CoordinationLayer/ShotTuning/hoodAngleDegrees", + JsonConstants.hoodConstants.minHoodAngle.in(Degrees)); + private final LoggedTunableNumber shooterTuningRPM = + new LoggedTunableNumber("CoordinationLayer/ShotTuning/shooterRPM", 0.0); + private final TestModeManager testModeManager = + new TestModeManager<>("CoordinationLayer", CoordinationTestMode.class); // Logging private final Alert autonomyOverriddenAlert = @@ -687,10 +690,11 @@ public void coordinateRobotActions() { aimForTestModeShot(); isShotReal = true; } else { - isShotReal = switch (effectiveAutonomyLevel) { - case Smart -> drive.map(this::runShotCalculatorWithDrive).orElse(false); - case Manual -> aimForManualShot(); - }; + isShotReal = + switch (effectiveAutonomyLevel) { + case Smart -> drive.map(this::runShotCalculatorWithDrive).orElse(false); + case Manual -> aimForManualShot(); + }; } Logger.recordOutput("CoordinationLayer/isShotReal", isShotReal); diff --git a/state_machine_graphs/hood.dot b/state_machine_graphs/hood.dot index 15e62073..4926e41d 100644 --- a/state_machine_graphs/hood.dot +++ b/state_machine_graphs/hood.dot @@ -18,7 +18,7 @@ digraph { HomingWaitForMovementState ; HomingWaitForStoppingState ; IdleState ; - TargetPitchState ; + TargetExitPitchState ; TestModeState ; HomingWaitForButtonState ; @@ -32,10 +32,10 @@ digraph { HomingWaitForStoppingState -> IdleState [label="Is not moving"]; IdleState -> TestModeState [label="Is hood test mode"]; - IdleState -> TargetPitchState [label="Action == TargetPitch"]; + IdleState -> TargetExitPitchState [label="Action == TargetPitch"]; IdleState -> TargetAngleState [label="Action == TargetAngle"]; - TargetPitchState -> IdleState [label="Action != TargetPitch"]; + TargetExitPitchState -> IdleState [label="Action != TargetPitch"]; TestModeState -> IdleState [label="Isn't hood test mode"]; From 4ceaea2b9badd242de6dac20eee2dfc4fba74e43 Mon Sep 17 00:00:00 2001 From: Godmar Back Date: Sat, 28 Feb 2026 08:03:41 -0500 Subject: [PATCH 05/30] (#80) Add shot tuning implementation plan Co-Authored-By: Claude Opus 4.6 --- settings_gui/shot-tuning-plan.md | 140 +++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 settings_gui/shot-tuning-plan.md diff --git a/settings_gui/shot-tuning-plan.md b/settings_gui/shot-tuning-plan.md new file mode 100644 index 00000000..7c996680 --- /dev/null +++ b/settings_gui/shot-tuning-plan.md @@ -0,0 +1,140 @@ +# Shot Map Tuning Tab - Implementation Plan + +## Context + +The FRC robot (Team 401) has a shooting mechanism for launching balls into a "hub" target. To tune shot parameters, we need an in-shop workflow that captures video of each shot alongside telemetry (distance, RPM, hood angle), then lets the user analyze the video frame-by-frame to determine flight time. This data feeds into the existing Shot Maps interpolation tables. + +The settings_gui app (React 19 / MUI 7 / Vite 7 / TypeScript) currently has Shot Maps and Vision tabs. This plan adds a "Shot Map Tuning" tab with two new capabilities: **NT4 real-time telemetry** and **browser-based video recording/replay**. + +--- + +## New Files + +``` +settings_gui/ + src/ + types/ShotTuning.ts # Data model + services/nt4.ts # NT4 WebSocket client wrapper + services/shotTuningStorage.ts # Persistence API (fetch to Vite middleware) + pages/ShotMapTuning.tsx # Main page component + components/shot-tuning/ + NTConnectionStatus.tsx # Connection target selector + status + TelemetryDisplay.tsx # Live distance/RPM/hood angle readout + RecordingControls.tsx # Start/Stop/Discard/Store + live camera + AttemptsList.tsx # List stored attempts, select, delete + VideoReplayPlayer.tsx # Playback, frame-step, mark timestamps + shot-tuning-data/ # Created at runtime + attempts.json # Attempt metadata + clips/.webm # Video files +``` + +## Modified Files + +- **`package.json`** -- add `ntcore-ts-client` dependency +- **`vite.config.ts`** -- add `shotTuningPlugin()` middleware for JSON + binary persistence +- **`src/endpoints/registry.ts`** -- register new tab + +--- + +## Data Model (`src/types/ShotTuning.ts`) + +```typescript +export interface TuningAttempt { + id: string; // UUID, doubles as .webm filename + createdAt: string; // ISO 8601 + distanceMeters: number; // 2D distance to hub at capture time + shooterRPMRadPerSec: number; // Avg of lead + follower velocity + hoodAngleRadians: number; // Hood position + robotPoseX: number; // For reference + robotPoseY: number; + leavesShooterTimeSec: number | null; + hitTargetTimeSec: number | null; + flightTimeSec: number | null; // Computed difference + exportedToShotMap: 'hub' | 'pass' | null; +} +export const HUB_CENTER = { x: 4.02844, y: 4.00050 } as const; +``` + +## NT4 Service (`src/services/nt4.ts`) + +- Uses `ntcore-ts-client` (NT4 WebSocket protocol, port 5810) +- Targets: `localhost` (simulation) or `10.4.1.2` (robot) +- Subscriptions: + - **Odometry/Robot** -- Pose2d struct (24 bytes: 3x float64 LE) -> parse x, y -> compute `sqrt((x-hubX)^2 + (y-hubY)^2)` + - **Shooter/LeadMotorInputs/velocityRadiansPerSecond** + **Shooter/FollowerMotorInputs/velocityRadiansPerSecond** -- average both doubles + - **Hood/inputs/positionRadians** -- single double +- Fallback if struct parsing fails: add `Logger.recordOutput` for x/y individually in robot code + +## Persistence (`vite.config.ts` + `shotTuningStorage.ts`) + +New Vite middleware routes (following existing `localFilesPlugin` pattern): +- `GET /shot-tuning/attempts` -- read `attempts.json` +- `POST /shot-tuning/attempts` -- write `attempts.json` +- `POST /shot-tuning/clips/:id` -- save binary .webm +- `GET /shot-tuning/clips/:id` -- serve .webm for playback +- `DELETE /shot-tuning/clips/:id` -- remove clip file + +## Video Recording + +- `navigator.mediaDevices.getUserMedia({ video: true })` for webcam +- `MediaRecorder` with `video/webm` (detect best codec: vp9 > vp8) +- On **Start**: snapshot telemetry, begin recording, show live feed +- On **Stop**: assemble blob, show Discard/Store buttons +- **Store**: upload clip, create attempt record, save metadata + +## Video Replay + +- `