﻿#include <Arduino.h>
#include <SimpleFOC.h>
#include <SPI.h>

/* =========================================================
 * Makerbase MKS ESP32 FOC V2.0 —— **单轴**云台（AS5048A SPI 版）
 *
 * 由 SimpleFOC_ESP32FOC（双轴 AS5600 版）改造而来：
 *   1) 砍掉第二轴（M1/命令 A 的电机与驱动）—— 本机是单轴设备。
 *   2) 只换传感器层：MagneticSensorI2C(AS5600_I2C) → MagneticSensorSPI(AS5048_SPI)。
 *      原因：本机云台电机的编码器是 **AS5048A**（只有 SPI 和 PWM，**没有 I²C**）
 *      ⇒ 原版两次 I2C 扫描必然为空，电机 "sensor NOT found, skip init"。
 *
 * ★ 接线（与 v08 单轴版完全一致，全部取板载编码器座现成脚，免焊）
 *   CLK =GPIO18（座0 SCL_0）    MISO=GPIO19（座0 SDA_0）
 *   MOSI=GPIO23（座1 SDA_1）    CS  =GPIO5 （座1 SCL_1）
 *   3.3V / GND 从任一座取（本板无 5V 针脚；AS5048A 走 3V 模式需 pin11-pin12 短接）。
 *
 * ★ 限压（安全红线）：云台电机相阻 15.2Ω ⇒ 工作限压 **2.0V**；
 *   对齐期临时给 4.0V（SimpleFOC 会把 voltage_sensor_align 钳到 ≤ voltage_limit，
 *   钳到 2.0 会力矩不足 ⇒ initFOC 失败），对齐结束立刻收回 2.0V。
 *   原版写死 12.0V，对本机电机偏猛，不可沿用。
 *
 * 串口命令（115200，SimpleFOC Commander）：
 *   B = 本机唯一电机（沿用原版"上面电机 = B"的编号）
 *   A = 同一电机的**别名**（原版 A 指下面电机；单轴设备上两个前缀都指向这台，
 *       避免上位机发 A 时无响应）
 *   示例: BE1  BC1  B2   /   AE1  AC1  A2
 *
 * ★ 在线扫参（不用重编重烧，直接在同一个串口控制台敲；前缀 B，A 是别名）：
 *   BAI<v>  位置环 I  ← ★ 锁不住/差一点点到位，先调这个
 *   BAP<v>  位置环 P          BVP<v> / BVI<v>   速度环 P / I
 *   BLV<v>  位置模式最大角速度(=P_angle.limit) ★ 治超调的主旋钮，实测最优 1.5
 *   BLU<v>  电压限幅          BMG<n>  取一次状态(n: 0=目标 1=Vq 5=速度 6=角度 7=全部)
 *   BAF<v>  LPF_angle.Tf      BVF<v>  LPF_velocity.Tf
 *   回读：把值去掉即可，例如 BAI → "PID angle| I: 20.000"
 * ★ 判断"是没力还是环路没要力"：BMD1 打开监视（target/Vq/vel/angle 四列），BMD0 关。
 *   停住不动时 Vq 只有 0.1~0.4V ⇒ 环路没要力（缺 I）；
 *   Vq 顶到 ~2.0V 轴还不动 ⇒ 力矩不足（齿槽井/静摩擦）。
 *
 * ★★ 2026-09-22 起：AS5048A 读帧改为**带校验**（见下面 AS5048AGuarded）。
 *   病象「停住后细微抖动、频率不高」的真凶是编码器**单帧坏数据**
 *   （实测约 2 次/秒，幅度 1°~220°）被位置环当成真位置
 *   ⇒ 立刻输出一记力矩让轴抽一下。
 *   SimpleFOC 的 MagneticSensorSPI 丢掉了 AS5048A 的 bit15(偶校验)/bit14(EF)。
 *   串口敲 X 可看丢帧计数与主循环频率。
 * ========================================================= */

// ---------- 电机引脚 ----------
#define M0_IN1 32
#define M0_IN2 33
#define M0_IN3 25
#define EN_PIN 12

// ---------- 编码器 SPI 引脚 ----------
#define SPI_CLK  18
#define SPI_MISO 19
#define SPI_MOSI 23
#define PIN_CS    5

// ---------- 电源 / 限压 / 速度 ----------
#define SUPPLY_VOLTAGE      12.0f
#define VOLTAGE_LIMIT_RUN    2.0f   // 工作限压（云台电机安全值）
#define VOLTAGE_LIMIT_ALIGN  4.0f   // 仅对齐期，initFOC 需要足够力矩
#define MAX_TARGET_VELOCITY  8.0f   // 速度模式目标上限；同时是 P_angle.limit 的抗饱和边界
#define PWM_FREQUENCY        20000

// ---------- 开机默认模式 ----------
// 本机是单轴定位设备 ⇒ 默认位置(角度)模式。要回纯速度模式改 MotionControlType::velocity。
#define DEFAULT_MODE         MotionControlType::angle

// ---------- 位置环 / 速度环 参数（★ 有实测依据，别凭直觉改） ----------
/*  位置模式在 SimpleFOC 里是一条级联环：
 *      位置误差 --P_angle--> 速度指令 --PID_velocity--> voltage.q
 *
 *  第三方原值 PID_velocity.P = 0.05 ⇒ 速度环**几乎只有积分**：
 *  巡航时积分攒到 ~1V；外环要等到离目标 23° 才撤速度指令，
 *  而内环光靠积分需要约 0.5s 才把这个电压放掉 —— 这 0.5s 电机仍在
 *  以 8rad/s(≈458°/s) 往前冲 ⇒ **冲过头、来回摆几次、锁不住**。就是本次的病根。
 *
 *  本型号实测锚点（GM5208-12 + AS5048A，12N/14P，限压 2.0V）：
 *      PID_vel.P=0.5 / I=1.5        ⇒ 无超时、残差 ≤0.3°、5° 阶跃超调 ~22%
 *      P_angle.I=0.5 ⇒ 个别目标残差 0.70°（停在齿槽井里）；=20 ⇒ 三个目标全 ≤0.3°
 *   12N/14P ⇒ 齿槽井周期 = 360/lcm(12,14) = **4.29°**，
 *   ⇒ 小于一个井宽的位置误差会被井壁"卡住"，**位置环必须有 I** 才顶得出去。
 *      （这正是"锁不住/差一点点"的唯一有效旋钮。）
 *
 *  ★★ LPF_velocity.Tf 必须留在 0.10s（2026-09-22 实测，别改回去！）
 *  早期把"加大 LPF_velocity.Tf"列为反例，那是在治**小角度静差**的语境下：
 *  Tf 越大相位滞后越大 ⇒ 小误差下静态刚度略降。但本机的主诉是
 *  「停住后细微抖动」，它的机理完全不同：
 *     主循环 6.7kHz ⇒ 速度 = 约 150µs 内的位置差 / 150µs，
 *     而编码器只有 14 位 ⇒ **1 LSB 的位置变化 = ±147°/s 的原始速度**！
 *  Tf=0.03 时速度估计仍会被量化噪声推到 ~86°/s，经速度环 P=0.5/I=1.5
 *  放大成一记真力矩脉冲(Vq 达 0.72V)，把转子弹出去 ~1.3°，再花 10s 爬回。
 *  同一位置 45s 实测：
 *     Tf=0.03  |vel|max 85.97°/s  >20°/s 4 点  Vqpp 0.7222V  角度峰峰 1.51°
 *     Tf=0.10  |vel|max  1.56°/s  >20°/s 0 点  Vqpp 0.0421V  角度峰峰 0.15°  ← 选它
 *     Tf=0.20  |vel|max  0.50°/s  >20°/s 0 点  Vqpp 0.0347V  角度峰峰 0.18°
 *  100° 阶跃回归：超调 +3.93°(原 +4.52°)、到位 2.9s、残差 -0.025°、翻转 1(原 2)
 *  ⇒ 不但没变差，反而略好。0.20 再往上收益递减（且滞后更大），故取 0.10。
 *  仍然成立的反例：**只单调抬速度环 P**。
 */
#define P_ANGLE_P            20.0f
#define P_ANGLE_I            20.0f   // ★★ 消静差、顶出齿槽井
#define PID_VEL_P            0.5f    // ★ 原第三方 0.05，差 10 倍 ⇒ 内环刹不住
#define PID_VEL_I            1.5f
#define PID_VEL_RAMP         100.0f
#define LPF_VEL_TF           0.10f   // ★★ 压速度估计的量化尖峰，见上方实测（别改小）

/* ★★ 位置模式的最大角速度（= P_angle.limit，位置环积分的抗饱和边界）
 *
 * 这是"冲过头"的**主旋钮**。下面是本机实测（100° 阶跃，位置模式，每档都复位重跑）：
 *
 *   P_angle.limit   峰值超调    末态残差    末段抖动    翻转   峰值速度
 *   8.0 (原值)      +15.4/+15.7  +0.08/+0.19  0.36      9/7    6.4 rad/s
 *   1.5             + 5.1/+ 5.0  -0.07/-0.14  1.03/0.70  3     1.85 rad/s   ← 选它
 *   2.0             + 5.3        +0.08        2.11              3.26
 *   3.0             + 7.5        +0.41        2.15              3.60
 *   5.0             +14.7        -0.27        3.08       8      4.24
 *   （另外试过 P_angle.P 20→8：超调反而涨到 +11.6°、残差 -3.0° ⇒ **P 别动，保持 20**）
 *   （也试过 P_angle.I 20→5 保持全速：超调降到 +6.0° 但残差 +1.07°、抖动 3.3°、7s 没到位 ⇒ 不如限速）
 *
 * ⇒ 结论：内环在加速段会顶到 ~1.8V(≈电压限幅 2.0V)，**加速段是硬件饱和的**，
 *   光调增益救不了超调；把"要它跑多快"限住（1.5 rad/s ≈ 86°/s）才治本。
 *   代价：100° 行程 0.35s → 约 1.1s。要更快就把这个值往上调一档，超调同步变大。
 */
#define POS_SLEW_LIMIT       1.5f

// ---------- 传感器：AS5048A（SPI，14 位）—— ★ 带读帧校验 ----------
/* ★★ 为什么不直接用 SimpleFOC 的 MagneticSensorSPI
 *
 * AS5048A 返回的 16bit 帧结构：bit15=偶校验  bit14=错误标志EF  bit13..0=14bit角度。
 * 而 MagneticSensorSPI::read() 最后只有一句 `& 0x3FFF`，
 * **校验位和 EF 全被丢掉** ⇒ 一帧坏数据会被当成"真实位置"。
 *
 * 实测（345Hz 抓波形，_fast_trace.py / _fast_trace.csv，2026-09-22）：
 *   使能保持时单帧角度跳变 ≈ **2.0 次/秒**，幅度见过 ~1° 也见过 100°/162°/219°；
 *   电机失能（PWM 停）时只有 0.12 次/秒  ⇒ 与 PWM 开关耦合进 SPI 的干扰相关；
 *   跳变值出现过 0.0000（EF 帧的典型返回值）。
 * 位置环把一个假位置当成真值 ⇒ 立刻输出一记力矩 ⇒ 轴"抽一下"，
 * 现象正是「停住后细微抖动、频率不高」。
 *
 * 本类自己发命令帧、自己读回，四道防线（越靠前越"治本"）：
 *   ① **真·模 2π 归约**（v20 修的核心）：把原始角度对 2π 取整倍归约后再与
 *      上一连续值相减，永远取"最近的等价角"。老写法只做"一次 ±2π 修正"
 *      （if(d>π)d-=2π; if(d<-π)d+=2π），只在 |a-last|<3π 时才对；而 last 是
 *      **连续域**值（可达 ±200rad）、坏帧给的 a 却恒在 [0,2π) ⇒ 差值能到 200rad，
 *      一次修正不够 ⇒ u 落到**错误的圈数**，3 个这样的值进窗口就能把中值/ last
 *      整圈拽走 ⇒ shaft_angle 跳 360° 的整倍。改成真归约后坏帧误差被限在 ≤π，
 *      **再也动不了圈数**。
 *   ② **帧级步长合理性**（readValid，越界就重读）：轴在一个周期内物理上走不了
 *      AS5048_STEP_EPS（2°）⇒ 超限即假读数，立即重读（最多 3 次）。
 *      ★★ 绝不"沿用上一个输出"来冻结：v18 试过，实测是**严重回归** ——
 *         100s 保温里冒出 7 次 ±36~54° 暴走，Vq 打到 ±2V 限幅、|vel| 峰值 516°/s，
 *         而 v16/v17 同口径是 0 次。冻结输出让位置反馈停在旧值、位置环却继续加力
 *         ⇒ 正反馈自激。重读 3 次仍不合理才兜底采用，宁可接受一帧稍差的真值。
 *   ③ **死总线帧显式剔除**：MISO 恒低/恒高时整帧读回 0x0000 / 0xFFFF，
 *      而它们的**偶校验恰好都能通过**（1 的个数分别是 0 / 16，都是偶数）
 *      ⇒ 解码成 0° / 359.98° 的"合法"假角度，只查校验根本拦不住。显式剔除。
 *   ④ **5 点中值滤波**：连串坏帧(≤2 帧)无论跳多大都被中值直接挤掉
 *      （正常运动只是滞后 2 个控制周期 ≈0.30ms，可忽略）。兜底用。
 *   另有**现场记录**（只计数不干预）：中值输出与上一输出差 > 2.9° 时记 n_burst，
 *   并把现场(原始帧/解码值/展开值/中值/上一输出)存进 8 条环形缓冲，
 *   串口敲 X 打印 ⇒ 不必再靠推理猜坏帧长什么样。
 * ★ 初始化/对齐期 guard=false：对齐时轴会一次扫过约 51°，中值窗口会被灌坏。
 * ★★ 实测（2026-09-22，X 诊断 + 原始帧抓取；一次会话 1,730,271 帧）：
 *   - 本机 AS5048A 的 bit14(EF) **恒为 1**（100% 的帧）⇒ EF 不能当判据；
 *   - bit15 确是偶校验位，但**几乎从不失败**（219s 内只命中 2 次）⇒ 校验也抓不到坏帧；
 *   - 坏帧率 ≈ **0.25%**（6700Hz 下 ≈16 帧/秒）；
 *   - 好帧的低 14 位角度完全正确（实测 2963 ⇒ 65.10°，真值 65.11°）。
 *   ⇒ 唯一有效且无损的手段就是**中值滤波**（阈值兜底会漏掉 <2.9° 的坏帧）。
 * ★★ 为什么从 3 点改成 5 点：坏帧会**连续成串**。3 点中值只能吃掉 1 帧，
 *   遇到"2 连坏帧"时中值仍取到坏值 ⇒ 仍往外送 1 个假位置，
 *   实测抓到过一次 **53.9°** 的读数尖峰。5 点中值可吃掉 2 连坏帧，
 *   代价只是多滞后 1 个周期（0.15ms）。
 * 串口敲 X 看计数：SA=好帧 JU=被中值挤掉的帧 BS=连串穿透 PA=校验失败
 *                  DB=死总线帧  GL=步长越界重读  loop=主循环频率  另有连串穿透现场 EV
 */
#define AS5048_CPR       16384       // 2^14
#define AS5048_MED_N     5           // 中值窗口点数（★ 5 才吃得住 2 连坏帧）
#define AS5048_MED_EPS  0.005f      // 判定"这帧被挤掉"的阈值 ≈0.29°（计数用）
#define AS5048_BURST_EPS 0.05f      // 连串穿透的日志阈值 ≈2.9°（只记录，不拦截）
#define AS5048_STEP_EPS  0.035f     // 帧级步长合理上限 ≈2°（越界就重读，不冻结）
class AS5048AGuarded : public Sensor {
public:
  bool     guard  = false;           // 对齐完成后由 setup() 打开
  uint32_t n_ok = 0, n_jump = 0, n_par = 0, n_ef = 0, n_burst = 0, n_dead = 0, n_glitch = 0;
  // 连串穿透现场（X 命令打印）：触发瞬间的原始帧与各级中间量
  struct Ev { uint16_t rx; float a, u, m, last; uint8_t good; };
  Ev       ev[8] = {};
  uint8_t  ev_i  = 0;
  // 诊断（X 命令打印）
  uint16_t dbg_first[4] = {0, 0, 0, 0};
  uint16_t dbg_ring[16] = {0};
  uint16_t dbg_par = 0;
  uint8_t  dbg_i = 0;
  uint32_t n_frame = 0;
  bool diagPrimed() const { return primed; }

  explicit AS5048AGuarded(int cs_pin = PIN_CS) : cs(cs_pin) {}

  void init(SPIClass* sp = &SPI) {
    spi = sp;
    set = SPISettings(1000000, MSBFIRST, SPI_MODE1);   // AMS 上限 10MHz，1MHz 最稳
    pinMode(cs, OUTPUT);
    digitalWrite(cs, HIGH);
    spi->begin(SPI_CLK, SPI_MISO, SPI_MOSI, cs);
    Sensor::init();     // 基类把 full_rotations / angle_prev 对齐到当前位置
  }

protected:
  float getSensorAngle() override {
    uint16_t rx = 0;
    bool good = readValid(rx);
    float raw = (float)(rx & 0x3FFF) * (_2PI / (float)AS5048_CPR);
    if (!guard) { primed = false; return raw; }   // 初始化/对齐期不设防

    float a = good ? raw : (primed ? last : raw);          // 偶校验坏 => 沿用上次好值
    if (!primed) {
      primed = true; last = a;
      for (uint8_t i = 0; i < AS5048_MED_N; i++) h[i] = a;
      hi = 0;
      return a;
    }
    /* ★★ 真·模 2π 归约（v20 核心修复，见文件头 ①）
     * 老写法只做"一次 ±2π 修正"，|a-last| 超过 3π 时就会算错圈数。
     * 实测现场（X 的 EV 行）：真值 5.3452rad，读到 rx=4004(角度=4) ⇒
     * u=6.2847、last=12.5652 —— 圈数已被拽走。 */
    float d = a - last;
    d -= _2PI * roundf(d / _2PI);      // 归约到 (-π, π]，与 |d| 多大无关
    float u = last + d;

    h[hi % AS5048_MED_N] = u;
    hi++;
    if (hi < AS5048_MED_N) { last = u; return u; }         // 先把窗口填满

    // ★ 5 点中值：连串坏帧(≤2 帧)被挤掉；正常运动只滞后 2 周期(≈0.30ms)
    //   返回的永远是"窗口内真实采到过的值"，不做插值 ⇒ 不会引入虚假位置
    float m = median5(h[0], h[1], h[2], h[3], h[4]);
    if (fabsf(m - u) > AS5048_MED_EPS) n_jump++;           // 被挤掉的帧（计数）
    if (fabsf(m - last) > AS5048_BURST_EPS) {              // 连串穿透：只记录，不拦截
      n_burst++;
      Ev& e = ev[ev_i & 7]; ev_i++;                        // 记现场，供 X 打印
      e.rx = rx; e.a = a; e.u = u; e.m = m; e.last = last; e.good = good ? 1u : 0u;
    }
    last = m;
    n_ok++;
    return m;
  }

private:
  uint16_t frame() {
    uint16_t cmd = 0x3FFF | (1u << 14);                 // bit14=1 表示读
    if (__builtin_parity(cmd) & 0x1) cmd |= (1u << 15); // 补成偶校验
    spi->beginTransaction(set);
    digitalWrite(cs, LOW);
    spi->transfer16(cmd);
    digitalWrite(cs, HIGH);
    delayMicroseconds(50);                              // AMS 要求 CS 上升后 >=350ns
    digitalWrite(cs, LOW);
    uint16_t rx = spi->transfer16(0x0000);
    digitalWrite(cs, HIGH);
    spi->endTransaction();
    if (n_frame < 4) dbg_first[n_frame] = rx;
    n_frame++;
    dbg_ring[dbg_i & 15] = rx;
    dbg_i++;
    return rx;
  }

  /* 5 点取中（插入排序；5 个数最多 10 次比较） */
  static float median5(float a, float b, float c, float d, float e) {
    float v[5] = {a, b, c, d, e};
    for (int i = 1; i < 5; i++) {
      float k = v[i];
      int j = i - 1;
      while (j >= 0 && v[j] > k) { v[j + 1] = v[j]; j--; }
      v[j + 1] = k;
    }
    return v[2];
  }

  static bool parityOk(uint16_t rx) {
    return (__builtin_parity(rx) & 0x1) == 0;   // 偶校验：整帧 1 的个数须为偶数
  }

  /* ★★ 死总线帧：MISO 恒低/恒高时整帧读回 0x0000 / 0xFFFF。
   *    它们的偶校验**恰好都能通过**（1 的个数 0 / 16 都是偶数），
   *    只查校验根本拦不住 ⇒ 解码出 0° / 359.98° 的假角度，
   *    再被"跨 0/2π 展开"放大成 50° 级的记号翻转。必须显式剔除（v19 核心修复）。 */
  static bool deadBus(uint16_t rx) { return rx == 0x0000 || rx == 0xFFFF; }

  /* ★★ 帧的"步长合理性"：把原始角度按 2π 归约到 (-π, π] 与上一连续值比较。
   *    轴在一个控制周期内物理上走不了 AS5048_STEP_EPS（2°）：主循环 6.6kHz 下
   *    位置模式 0.013°/周期、速度模式上限 0.070°/周期，主循环即便卡 20ms 也才 1.7°
   *    ⇒ 超限就是假读数，**立即重读**（绝不冻结输出，理由见文件头 ②）。
   *    只在 guard 打开（对齐结束）后判：对齐期轴会大范围扫动。 */
  bool stepPlausible(uint16_t rx) {
    if (!primed || !guard) return true;
    float a = (float)(rx & 0x3FFF) * (_2PI / (float)AS5048_CPR);
    float d = a - last;
    d -= _2PI * roundf(d / _2PI);
    return fabsf(d) <= AS5048_STEP_EPS;
  }

  bool readValid(uint16_t& rx) {
    for (int k = 0; k < 3; k++) {                // 最多读 3 次，挑一帧"干净"的
      uint16_t r = frame();
      if (deadBus(r)) { n_dead++; dbg_par = r; continue; }  // 死总线：丢弃重读
      if (!parityOk(r)) {                                   // 校验不过：丢弃重读
        n_par++; dbg_par = r;
        if ((r >> 14) & 0x1) n_ef++;                        // EF 只统计，不判决
        continue;
      }
      rx = r;
      if (stepPlausible(r)) return true;
      n_glitch++;                                           // 越界：重读
    }
    return false;     // 三次都没拿到"干净"帧 ⇒ 调用方沿用 last，只冻结 1 帧
  }

  int         cs;
  SPIClass*   spi = nullptr;
  SPISettings set;
  bool        primed = false;
  float       last   = 0.0f;
  float       h[AS5048_MED_N] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f};  // 中值窗口（连续域）
  uint8_t     hi     = 0;
};

AS5048AGuarded sensor(PIN_CS);

// ---------- 电机 / 驱动 ----------
BLDCMotor motor = BLDCMotor(7);
BLDCDriver3PWM driver = BLDCDriver3PWM(M0_IN1, M0_IN2, M0_IN3, EN_PIN);

// ---------- Commander ----------
Commander command = Commander(Serial);

bool  g_foc_ok  = false;
float lastTgt   = 0.0f;

// 清速度环积分：SimpleFOC 2.2.x 的 PIDController 没有 reset()，用同样参数重建对象等价清零。
// 治「卡死后换向/停车半天才动、不松」。
void resetVelocityPID(BLDCMotor& m) {
  m.PID_velocity = PIDController(m.PID_velocity.P, m.PID_velocity.I, m.PID_velocity.D,
                                 m.PID_velocity.output_ramp, m.PID_velocity.limit);
}

void onMotor(char* cmd) {
  command.motor(&motor, cmd);
  float t = motor.target;
  if (fabsf(t - lastTgt) > 0.001f) resetVelocityPID(motor);
  lastTgt = t;
}

/* 串口敲 X：打印编码器丢帧计数（JU=被中值挤掉的坏帧）+ 主循环频率。 */
uint32_t g_loop_cnt = 0;
void onStats(char*) {
  static uint32_t last_cnt = 0, last_us = 0;
  uint32_t now = micros();
  uint32_t dc = g_loop_cnt - last_cnt, dt = now - last_us;
  float hz = dt ? (dc * 1e6f / (float)dt) : 0.0f;
  last_cnt = g_loop_cnt;
  last_us  = now;
  Serial.printf("SA%lu JU%lu BS%lu PA%lu EF%lu DB%lu GL%lu guard=%d loop=%.0fHz ang=%.4f\n",
                (unsigned long)sensor.n_ok,    (unsigned long)sensor.n_jump,
                (unsigned long)sensor.n_burst, (unsigned long)sensor.n_par,
                (unsigned long)sensor.n_ef,    (unsigned long)sensor.n_dead,
                (unsigned long)sensor.n_glitch,
                (int)sensor.guard, hz, motor.shaft_angle);
  Serial.printf("  FR %04X %04X %04X %04X lastPAR %04X primed %d\n",
                sensor.dbg_first[0], sensor.dbg_first[1],
                sensor.dbg_first[2], sensor.dbg_first[3],
                sensor.dbg_par, (int)sensor.diagPrimed());
  for (int k = 0; k < 16; k += 4)
    Serial.printf("  RG %04X %04X %04X %04X\n",
                  sensor.dbg_ring[k], sensor.dbg_ring[k + 1],
                  sensor.dbg_ring[k + 2], sensor.dbg_ring[k + 3]);
  /* 连串穿透现场：rx=原始帧 a=解码值 u=展开值 m=中值 last=上一输出 good=校验是否通过 */
  for (int k = 0; k < 8; k++) {
    const AS5048AGuarded::Ev& e = sensor.ev[k];
    if (!e.rx && !e.good) continue;
    Serial.printf("  EV rx=%04X good=%u | a=%.4f u=%.4f m=%.4f last=%.4f (rad)\n",
                  e.rx, (unsigned)e.good, e.a, e.u, e.m, e.last);
  }
}

/* 判断编码器是否真的有响应。
 * AS5048A 在 SPI 无响应时 MISO 恒定（读回全 0 或全 1）⇒ 角度锁死不变。
 * 连读 12 帧；若出现任何变化（真实噪声会让最低位抖动）即判为有响应。 */
bool sensorResponsive(Sensor& s) {
  float a0 = s.getAngle();
  for (int i = 0; i < 12; i++) {
    s.update();
    if (fabsf(s.getAngle() - a0) > 1e-5f) return true;
  }
  return false;
}

void setup() {
  Serial.begin(115200);
  delay(1000);

  // 关闭底层驱动报错刷屏
  esp_log_level_set("*", ESP_LOG_NONE);

  /* ---- 编码器 ---- */
  SPI.begin(SPI_CLK, SPI_MISO, SPI_MOSI, PIN_CS);
  sensor.init(&SPI);
  delay(50);

  bool ok = sensorResponsive(sensor);
  if (ok) {
    Serial.printf("SPI encoder: READY (CS%d) angle=%.4frad\n", PIN_CS, sensor.getAngle());
  } else {
    Serial.printf("SPI encoder: NO REPLY (CS%d) —— 查 CLK18/MISO19/MOSI23/CS5 与 3.3V/GND\n", PIN_CS);
  }

  /* ---- 驱动初始化前：三相 PWM 脚先设上拉输入，防上下桥直通 ---- */
  pinMode(32, INPUT_PULLUP); pinMode(33, INPUT_PULLUP); pinMode(25, INPUT_PULLUP);

  /* ---- 驱动 ---- */
  driver.enable_active_high = true;
  driver.voltage_power_supply = SUPPLY_VOLTAGE;
  driver.pwm_frequency = PWM_FREQUENCY;
  driver.voltage_limit = VOLTAGE_LIMIT_ALIGN;
  driver.init();

  motor.linkSensor(&sensor);
  motor.linkDriver(&driver);

  motor.foc_modulation = FOCModulationType::SinePWM;
  motor.controller = DEFAULT_MODE;

  // 速度环（位置模式的内环：必须够硬，否则外环撤指令后刹不住 ⇒ 冲过头）
  motor.PID_velocity.P = PID_VEL_P;
  motor.PID_velocity.I = PID_VEL_I;
  motor.PID_velocity.D = 0.0f;
  motor.PID_velocity.output_ramp = PID_VEL_RAMP;
  motor.LPF_velocity.Tf = LPF_VEL_TF;

  // 位置环（★ SimpleFOC 默认是 P=20 / **I=0**，I 不设就没有消除静差的能力）
  motor.P_angle.P = P_ANGLE_P;
  motor.P_angle.I = P_ANGLE_I;
  motor.P_angle.D = 0.0f;
  motor.P_angle.output_ramp = 0.0f;
  motor.LPF_angle.Tf = 0.0f;

  motor.voltage_limit  = VOLTAGE_LIMIT_ALIGN;   // 对齐期临时抬到 4.0
  motor.velocity_limit = MAX_TARGET_VELOCITY;

  // 强制每次上电重校（与 STM32 版一致）
  motor.voltage_sensor_align = VOLTAGE_LIMIT_ALIGN;
  motor.sensor_direction     = NOT_SET;
  motor.zero_electric_angle  = NOT_SET;

  Serial.println("Motor init...");

  if (ok) {
    motor.init();
    motor.voltage_sensor_align = VOLTAGE_LIMIT_ALIGN;  // init() 会钳到 voltage_limit，这里复原
    Serial.print("FOC init... ");
    g_foc_ok = motor.initFOC();
    Serial.println(g_foc_ok ? "OK" : "FAILED");
    motor.disable();
    sensor.guard = g_foc_ok;   // ★ 对齐结束才开读帧校验（对齐期轴会大范围扫动）
    Serial.printf("[SENSOR] median-%d + parity + deadbus + step-reject %s | eps %.3frad (%.2fdeg) step %.3frad (%.2fdeg)\n",
                  AS5048_MED_N, sensor.guard ? "ON" : "off",
                  AS5048_MED_EPS, AS5048_MED_EPS * 57.29578f,
                  AS5048_STEP_EPS, AS5048_STEP_EPS * 57.29578f);
  } else {
    Serial.println("encoder NO REPLY, skip init（电机不会动，但设备保持在线）");
  }

  /* ★ 对齐结束 ⇒ 把限压收回安全值。原版这里是 12V，对云台电机偏猛。 */
  motor.voltage_limit      = VOLTAGE_LIMIT_RUN;
  motor.PID_velocity.limit = VOLTAGE_LIMIT_RUN;  // ★ 速度环输出限幅才是真正限电压的那条
  Serial.printf("[CLAMP] align done -> voltage_limit=%.1fV\n", VOLTAGE_LIMIT_RUN);

  /* ★ motor.init() 内部会写 P_angle.limit = velocity_limit，这里覆盖成实测最优值：
     它就是位置环积分的**抗饱和边界**（外环速度指令最多到这么大，不会无限涨）。
     ★ 注意 velocity_limit 只被角度模式/开环到用，速度模式的目标**不受它限制**
       ⇒ 改这里不会影响速度模式的手感。 */
  motor.P_angle.limit = POS_SLEW_LIMIT;

  /* ★ 上电目标 = 当前位置：否则每次 BE1 使能时电机会"猛地跳回编码器零点"。 */
  if (g_foc_ok) motor.target = motor.shaftAngle();

  motor.monitor_downsample = 0;    // 监视默认关；用 BMD1 开、BMD0 关

  command.add('B', onMotor, (char*)"motor_main");
  command.add('A', onMotor, (char*)"motor_alias");
  command.add('X', onStats, (char*)"sensor_stats");

  Serial.println("========================================");
  Serial.printf("Ready. single-axis enc=AS5048A(SPI) cmd=A|B  mode=%s\n",
                (motor.controller == MotionControlType::angle) ? "POSITION(rad)" : "velocity(rad/s)");
  Serial.printf("  P_angle P=%.1f I=%.1f limit=%.1f | PID_vel P=%.2f I=%.1f limit=%.1fV BVF=%.2fs\n",
                motor.P_angle.P, motor.P_angle.I, motor.P_angle.limit,
                motor.PID_velocity.P, motor.PID_velocity.I, motor.PID_velocity.limit,
                motor.LPF_velocity.Tf);
  Serial.println("  POS unit = rad :  100deg = 1.745rad    (deg / 57.2958)");
  Serial.println("  ex: BE1  B1.745     /     AE1  A1.745");
  Serial.println("  tune live: BAI20 BAP20 BVP0.5 BVI1.5 BLV1.5 BLU2.0 BVF0.10 | watch: BMD1");
  Serial.printf("  fw v20: AS5048A median-%d + parity + deadbus + step-reject | vel LPF %.2fs | stats: X\n",
                AS5048_MED_N, LPF_VEL_TF);
  Serial.println("  measured @100deg: overshoot +3.93deg, residual -0.025deg, 1 flip");
  Serial.println("========================================");
}

void loop() {
  if (g_foc_ok) {
    motor.loopFOC();
    motor.move();
    motor.monitor();      // 默认惰性（monitor_downsample=0）；BMD1 打开
  } else {
    motor.voltage.q = 0;
    motor.voltage.d = 0;
    driver.setPwm(0, 0, 0);
  }
  command.run();
  g_loop_cnt++;
}
