@@ -160,6 +160,10 @@ static volatile Source g_source = SRC_DEMO;
160160// starts moving. 0 disables.
161161#define BOOT_HOME_HOLD_MS 3000
162162
163+ // Firmware soft limit for streamed RAW motion: post-cue output is clamped to
164+ // +-this many percent of travel no matter what the host sends.
165+ #define RAW_LIMIT_PCT 50 .0f
166+
163167// ── BLE Accel Input ──────────────────────────────────────────────────
164168// Raw sensor data from phone: [accel_x, accel_y, accel_z, gyro_x, gyro_y, gyro_z]
165169// Accel in m/s² (Android TYPE_ACCELEROMETER, includes gravity)
@@ -206,9 +210,19 @@ static float smoothedPosition[6] = {0}; // current smoothed out
206210static bool smoothingInitialized = false ;
207211
208212// ── IK Angle Limits ──────────────────────────────────────────────────
209- // Max servo arm deflection in radians (±45° is typical hobby servo range)
213+ // HARD STOP: max servo arm deflection from home. The mechanical linkage
214+ // allows ±60°; the enforced rail is ±45° (stricter = safer). This clamp is
215+ // the LAST line of defense before pulse output and must never be widened
216+ // past the mechanical limit. See also ANGLE_SLEW_MAX_RAD_S below — the two
217+ // together prevent the 2026-07-22 incident (out-of-workspace poses snapping
218+ // arms to opposite sides at full speed).
210219#define SERVO_MAX_ANGLE_RAD (IK_PI / 4 .0f )
211220
221+ // Final-stage angle rate limit (rad/s). Even a valid IK step can't slew an
222+ // arm faster than this — prevents rail-to-rail slams and elbow-flip snaps.
223+ // 300°/s is well under the servo's no-load speed but fast enough for cues.
224+ #define ANGLE_SLEW_MAX_RAD_S 5 .236f /* 300 deg/s */
225+
212226// ── Servo PWM ────────────────────────────────────────────────────────
213227static const int servoPins[6 ] = {
214228 SERVO_PIN_0 , SERVO_PIN_1 , SERVO_PIN_2 ,
@@ -386,15 +400,33 @@ static void driveServos(float position[6], float dt) {
386400 float angles[6 ];
387401 calculateAllServoAngles (limited, &stewartConfig, angles);
388402
389- // Validate IK output — clamp NaN and out-of-range angles
403+ // Validate IK output.
404+ // NaN/inf (pose outside workspace) → HOLD the last commanded angle.
405+ // Snapping to 0 here (old behavior) flung arms to opposite sides while
406+ // neighbors sat at the rail — the 2026-07-22 grinding incident.
407+ // Then clamp to the hard stop, then rate-limit the angle step so no
408+ // arm can slam rail-to-rail regardless of what the cue engine asks.
409+ static float lastCmdAngle[6 ] = {0 };
410+ static bool lastCmdInit = false ;
411+ if (!lastCmdInit) {
412+ for (int i = 0 ; i < 6 ; i++)
413+ lastCmdAngle[i] = (isnan (angles[i]) || isinf (angles[i])) ? 0 .0f : angles[i];
414+ lastCmdInit = true ;
415+ }
416+ const float maxAngleStep = ANGLE_SLEW_MAX_RAD_S * dt;
390417 for (int i = 0 ; i < 6 ; i++) {
391- if (isnan (angles[i]) || isinf (angles[i])) {
392- angles[i] = 0 .0f ; // safe fallback
393- } else if (angles[i] > SERVO_MAX_ANGLE_RAD ) {
394- angles[i] = SERVO_MAX_ANGLE_RAD ;
395- } else if (angles[i] < -SERVO_MAX_ANGLE_RAD ) {
396- angles[i] = -SERVO_MAX_ANGLE_RAD ;
397- }
418+ float a = angles[i];
419+ if (isnan (a) || isinf (a)) a = lastCmdAngle[i]; // hold, don't snap
420+ // Soft limit: damped approach into the rail (linear to 80%, then
421+ // tanh compression toward SERVO_MAX_ANGLE_RAD — never slams it).
422+ a = mcaSoftLimit (a, SERVO_MAX_ANGLE_RAD , 0 .8f );
423+ if (a > SERVO_MAX_ANGLE_RAD ) a = SERVO_MAX_ANGLE_RAD ; // hard-stop backstop
424+ if (a < -SERVO_MAX_ANGLE_RAD ) a = -SERVO_MAX_ANGLE_RAD ;
425+ float delta = a - lastCmdAngle[i]; // final rate limit
426+ if (delta > maxAngleStep) delta = maxAngleStep;
427+ if (delta < -maxAngleStep) delta = -maxAngleStep;
428+ lastCmdAngle[i] += delta;
429+ angles[i] = lastCmdAngle[i];
398430 }
399431
400432 memcpy ((void *)lastServoAngles, angles, sizeof (lastServoAngles));
@@ -678,7 +710,15 @@ static void CueTask(void* pv) {
678710 // output — decoupled from input. Bug found on the first live stream.
679711 float home = (float )((int )maxRawInput / 2 );
680712 float counts[6 ];
681- for (int i = 0 ; i < 6 ; i++) counts[i] = home * (1 .0f + o[i] * 0 .01f );
713+ for (int i = 0 ; i < 6 ; i++) {
714+ // Firmware soft limit: clamp post-cue output to +-RAW_LIMIT_PCT
715+ // of travel regardless of what the host streams (hardware
716+ // safety net; requested 2026-07-21 during digital-servo bring-up).
717+ float oc = o[i];
718+ if (oc > RAW_LIMIT_PCT ) oc = RAW_LIMIT_PCT ;
719+ if (oc < -RAW_LIMIT_PCT ) oc = -RAW_LIMIT_PCT ;
720+ counts[i] = home * (1 .0f + oc * 0 .01f );
721+ }
682722 mapRawToPosition (counts, &axisScales, maxRawInput, pos);
683723 } else if (fmt == TGT_PHYS ) {
684724 for (int i = 0 ; i < 6 ; i++) pos[i] = ch[i];
@@ -1286,8 +1326,13 @@ extern "C" void app_main(void) {
12861326 serial_printf (" MCA: Loaded from NVS (preset=%s, intensity=%.2f)\r\n " ,
12871327 mcaPresetName (mcaConfig.preset ), mcaGetIntensity (&mcaConfig));
12881328 } else {
1329+ // SAFETY: no valid saved config — never boot at full intensity. The
1330+ // boot source may be DEMO (unattended playback), so the fallback must
1331+ // be gentle. 25% until someone tunes and saves.
12891332 setMotionCueingPreset (&mcaConfig, MCA_MODERATE );
1290- serial_printf (" MCA: Default preset=%s\r\n " , mcaPresetName (mcaConfig.preset ));
1333+ mcaSetIntensity (&mcaConfig, 0 .25f );
1334+ serial_printf (" MCA: Default preset=%s (SAFE intensity=0.25 — no saved config)\r\n " ,
1335+ mcaPresetName (mcaConfig.preset ));
12911336 }
12921337 // Sync biquads + LEDC carrier to the servo-rate profile (FIX TRAP A).
12931338 applyServoRate (servoRateHz);
0 commit comments