計步器延伸專案

← 回模擬器

六個延伸專案,由淺到深。每個都附完整程式與驗證方式;「✓」是這份程式在整理時實際通過的檢查(編譯或單元測試),真機行為仍要自己燒進去確認。 程式在專案資料夾 firmware/extensions/,這頁是同一份的瀏覽版,可直接下載。

A 雙感測器比對與校正:ADXL345 × MPU6050

進階✓ arduino-cli 編譯通過(310 KB)

兩顆加速度計掛同一條 I2C(0x53/0x68),50 Hz 同步讀、各跑一份相同的計步演算法、按左鍵做六面校正;電腦端腳本算偏移、雜訊、相關係數、步數一致性並畫圖。

步驟

  1. 兩顆模組用雙面膠貼在同一塊小板上,軸向對齊;VCC→3V3、GND→GND、SDA→21、SCL→22;MPU6050 的 AD0 不接(0x68)。
  2. make ext NAME=dual_sensor_compare → make flash-ext NAME=dual_sensor_compare PORT=…。
  3. python3 tools/compare_sensors.py /dev/cu.usbserial-XXXX --secs 60:先靜置 10 秒、走 30 秒、再靜置。
  4. 要校正:在韌體端按左鍵,依序六面朝下各按一次,再收一次資料比對校正前後。

程式

firmware/extensions/dual_sensor_compare/dual_sensor_compare.ino 下載 85 行
// 延伸專案 A:雙感測器比對(ADXL345 @0x53 + MPU6050 @0x68 掛同一條 I2C)
// 50 Hz 同步讀兩顆,序列埠輸出 CSV;兩顆各跑一份同樣的計步演算法;按下鍵(GPIO0)做六面校正。
// 接線:兩顆模組的 VCC→3V3、GND→GND、SDA→GPIO21、SCL→GPIO22;MPU6050 的 AD0 接 GND(或不接)→ 位址 0x68。
// 兩顆模組請用雙面膠貼在同一塊小板上,X/Y/Z 方向一致,才比得出差異。
// 電腦端:python3 tools/compare_sensors.py /dev/cu.usbserial-XXXX
#include <Wire.h>
#include "pedometer.h"      // 與主韌體同一份演算法(Makefile 會複製進來)

static const uint8_t ADXL = 0x53, MPU = 0x68;
static const int PIN_BTN_L = 0, PIN_BTN_R = 35, PIN_SDA = 21, PIN_SCL = 22;

struct Vec { float x, y, z; };
struct Cal { Vec off{0, 0, 0}; Vec gain{1, 1, 1}; };     // 校正:a_cal = (a_raw − off) × gain
static Cal cal1, cal2;
static Pedometer pedo1, pedo2;

static void wr(uint8_t a, uint8_t r, uint8_t v) { Wire.beginTransmission(a); Wire.write(r); Wire.write(v); Wire.endTransmission(); }
static bool rd(uint8_t a, uint8_t r, uint8_t* b, uint8_t n) {
  Wire.beginTransmission(a); Wire.write(r);
  if (Wire.endTransmission(false) != 0) return false;
  return Wire.requestFrom(a, n) == n && [&] { for (uint8_t i = 0; i < n; i++) b[i] = Wire.read(); return true; }();
}
static bool adxl_begin() {
  uint8_t id; if (!rd(ADXL, 0x00, &id, 1) || id != 0xE5) return false;
  wr(ADXL, 0x2C, 0x0A); wr(ADXL, 0x31, 0x08); wr(ADXL, 0x2D, 0x08);  // 100 Hz、全解析度 ±2g、量測
  return true;
}
static bool mpu_begin() {
  uint8_t id; if (!rd(MPU, 0x75, &id, 1) || (id != 0x68 && id != 0x70 && id != 0x71)) return false;  // WHO_AM_I(6050=0x68,6500/9250 相容)
  wr(MPU, 0x6B, 0x00);   // PWR_MGMT_1:喚醒
  wr(MPU, 0x1C, 0x00);   // ACCEL_CONFIG:±2g → 16384 LSB/g
  wr(MPU, 0x1A, 0x03);   // DLPF 約 44 Hz
  return true;
}
static bool adxl_read(Vec& v) { uint8_t b[6]; if (!rd(ADXL, 0x32, b, 6)) return false;
  v.x = (int16_t)(b[0] | b[1] << 8) / 256.0f; v.y = (int16_t)(b[2] | b[3] << 8) / 256.0f; v.z = (int16_t)(b[4] | b[5] << 8) / 256.0f; return true; }
static bool mpu_read(Vec& v) { uint8_t b[6]; if (!rd(MPU, 0x3B, b, 6)) return false;
  v.x = (int16_t)(b[0] << 8 | b[1]) / 16384.0f; v.y = (int16_t)(b[2] << 8 | b[3]) / 16384.0f; v.z = (int16_t)(b[4] << 8 | b[5]) / 16384.0f; return true; }
static Vec apply(const Vec& a, const Cal& c) { return { (a.x - c.off.x) * c.gain.x, (a.y - c.off.y) * c.gain.y, (a.z - c.off.z) * c.gain.z }; }

// 六面校正:依序把板子的 +X、−X、+Y、−Y、+Z、−Z 朝下靜置,每面按一次下鍵(GPIO0),各取 100 筆平均
static void six_face_calibration() {
  const char* faces[] = { "+X 朝下", "-X 朝下", "+Y 朝下", "-Y 朝下", "+Z 朝下", "-Z 朝下" };
  Vec s1[6], s2[6];
  for (int f = 0; f < 6; f++) {
    Serial.printf("# 校正 %d/6:請把板子 %s 靜置,然後按下鍵(GPIO0)\n", f + 1, faces[f]);
    while (digitalRead(PIN_BTN_L) == HIGH) delay(10);
    delay(300);
    Vec a{0, 0, 0}, b{0, 0, 0}; int n = 0;
    for (int i = 0; i < 100; i++) { Vec v1, v2; if (adxl_read(v1) && mpu_read(v2)) { a.x += v1.x; a.y += v1.y; a.z += v1.z; b.x += v2.x; b.y += v2.y; b.z += v2.z; n++; } delay(10); }
    s1[f] = { a.x / n, a.y / n, a.z / n }; s2[f] = { b.x / n, b.y / n, b.z / n };
    while (digitalRead(PIN_BTN_L) == LOW) delay(10);
  }
  auto fit = [](Vec* s, Cal& c) {   // 每軸:最大與最小的平均是偏移,差值的一半是 1 g 的刻度
    float xs[2] = { s[0].x, s[1].x }, ys[2] = { s[2].y, s[3].y }, zs[2] = { s[4].z, s[5].z };
    auto one = [](float p, float m, float& off, float& g) { off = (p + m) / 2; g = 2.0f / fabsf(p - m); };
    one(xs[0], xs[1], c.off.x, c.gain.x); one(ys[0], ys[1], c.off.y, c.gain.y); one(zs[0], zs[1], c.off.z, c.gain.z);
  };
  fit(s1, cal1); fit(s2, cal2);
  Serial.printf("# CAL ADXL345 off=%.4f,%.4f,%.4f gain=%.4f,%.4f,%.4f\n", cal1.off.x, cal1.off.y, cal1.off.z, cal1.gain.x, cal1.gain.y, cal1.gain.z);
  Serial.printf("# CAL MPU6050 off=%.4f,%.4f,%.4f gain=%.4f,%.4f,%.4f\n", cal2.off.x, cal2.off.y, cal2.off.z, cal2.gain.x, cal2.gain.y, cal2.gain.z);
  Serial.println("# 校正完成,繼續輸出 CSV(已套用校正)");
}

void setup() {
  Serial.begin(115200); delay(200);
  pinMode(PIN_BTN_L, INPUT_PULLUP); pinMode(PIN_BTN_R, INPUT);
  Wire.begin(PIN_SDA, PIN_SCL, 400000);
  bool a = adxl_begin(), m = mpu_begin();
  Serial.printf("# ADXL345 %s / MPU6050 %s\n", a ? "OK" : "找不到", m ? "OK" : "找不到");
  Serial.println("# 下鍵(GPIO0):六面校正 上鍵(GPIO35):兩顆步數歸零");
  Serial.println("t_ms,a1x,a1y,a1z,a2x,a2y,a2z,steps1,steps2");
  pedo1.begin(0); pedo2.begin(0);
}

void loop() {
  static uint32_t next = 0; uint32_t now = millis();
  if (digitalRead(PIN_BTN_L) == LOW) { while (digitalRead(PIN_BTN_L) == LOW) delay(10); six_face_calibration(); }
  if (digitalRead(PIN_BTN_R) == LOW) { pedo1.reset(); pedo2.reset(); Serial.println("# 步數歸零"); delay(300); }
  if (now < next) return; next = now + 20;                         // 50 Hz
  Vec r1, r2; if (!adxl_read(r1) || !mpu_read(r2)) return;
  Vec v1 = apply(r1, cal1), v2 = apply(r2, cal2);
  pedo1.feed(v1.x, v1.y, v1.z, now); pedo2.feed(v2.x, v2.y, v2.z, now);
  Serial.printf("%u,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%u,%u\n", now, v1.x, v1.y, v1.z, v2.x, v2.y, v2.z, pedo1.steps(), pedo2.steps());
}
tools/compare_sensors.py 下載 89 行
#!/usr/bin/env python3
"""雙感測器比對:讀 dual_sensor_compare 韌體的序列埠 CSV,產出比對報告與圖。
用法:
  python3 tools/compare_sensors.py /dev/cu.usbserial-XXXX --secs 60 --out build/compare   # 現場收 60 秒
  python3 tools/compare_sensors.py build/compare/raw.csv --out build/compare             # 分析既有檔
建議流程:先靜置 10 秒(量雜訊與偏移)→ 走 30 秒 → 靜置 5 秒。校正請在韌體端按左鍵做六面校正後再收。"""
import sys, time, csv, math, argparse, os, statistics as st

ap = argparse.ArgumentParser(); ap.add_argument('src'); ap.add_argument('--secs', type=float, default=60); ap.add_argument('--out', default='build/compare')
a = ap.parse_args(); os.makedirs(a.out, exist_ok=True)
rows, notes = [], []
if a.src.startswith('/dev/'):
    import serial
    s = serial.Serial(a.src, 115200, timeout=1); t0 = time.time(); raw = open(os.path.join(a.out, 'raw.csv'), 'w')
    print(f'收 {a.secs:.0f} 秒…(Ctrl-C 提早結束)')
    try:
        while time.time() - t0 < a.secs:
            line = s.readline().decode('utf-8', 'replace').strip()
            if not line: continue
            if line.startswith('#'): notes.append(line); print(line); continue
            raw.write(line + '\n'); p = line.split(',')
            if len(p) == 9 and p[0].isdigit(): rows.append([float(x) for x in p])
    except KeyboardInterrupt: pass
    raw.close()
else:
    for line in open(a.src):
        if line.startswith('#') or line.startswith('t_ms'): continue
        p = line.strip().split(',')
        if len(p) == 9: rows.append([float(x) for x in p])
if len(rows) < 100: sys.exit(f'資料太少({len(rows)} 筆)')

t = [r[0] / 1000 for r in rows]
a1 = [(r[1], r[2], r[3]) for r in rows]; a2 = [(r[4], r[5], r[6]) for r in rows]
mag = lambda v: math.sqrt(v[0] ** 2 + v[1] ** 2 + v[2] ** 2)
m1 = [mag(v) for v in a1]; m2 = [mag(v) for v in a2]
# 靜止段:用 |a| 的 1 秒滑動標準差 < 0.02 g 判定
win = 50; still = [False] * len(rows)
for i in range(win, len(rows)):
    if st.pstdev(m1[i - win:i]) < 0.02 and st.pstdev(m2[i - win:i]) < 0.02: still[i] = True
idx_still = [i for i in range(len(rows)) if still[i]]; idx_move = [i for i in range(len(rows)) if not still[i] and i >= win]
def stats(idx, m, a):
    if len(idx) < 20: return None
    return { 'mag_mean': st.mean(m[i] for i in idx), 'mag_std': st.pstdev(m[i] for i in idx),
             'axis_mean': tuple(st.mean(a[i][k] for i in idx) for k in range(3)),
             'axis_std': tuple(st.pstdev(a[i][k] for i in idx) for k in range(3)) }
s1, s2 = stats(idx_still, m1, a1), stats(idx_still, m2, a2)
# 動態段:去平均後的相關係數與增益比
def corr_gain(idx):
    if len(idx) < 50: return None, None
    x = [m1[i] for i in idx]; y = [m2[i] for i in idx]; mx, my = st.mean(x), st.mean(y)
    sxy = sum((xi - mx) * (yi - my) for xi, yi in zip(x, y)); sxx = sum((xi - mx) ** 2 for xi in x); syy = sum((yi - my) ** 2 for yi in y)
    return (sxy / math.sqrt(sxx * syy) if sxx and syy else None), (math.sqrt(syy / sxx) if sxx else None)
corr, gain = corr_gain(idx_move)
steps1, steps2 = int(rows[-1][7]), int(rows[-1][8])
fs = len(rows) / (t[-1] - t[0]) if t[-1] > t[0] else 0

L = []
L.append(f'# 雙感測器比對報告 {time.strftime("%Y-%m-%d %H:%M")}')
L.append(f'樣本 {len(rows)} 筆,{t[-1]-t[0]:.1f} 秒,實際取樣率 {fs:.1f} Hz;靜止段 {len(idx_still)/50:.1f} 秒、運動段 {len(idx_move)/50:.1f} 秒')
for n in notes: L.append(n)
L.append('\n## 靜止時(偏移與雜訊)')
L.append('| 項目 | ADXL345 | MPU6050 | 解讀 |'); L.append('|---|---|---|---|')
if s1 and s2:
    L.append(f'| 合成加速度平均(應為 1.000 g) | {s1["mag_mean"]:.4f} | {s2["mag_mean"]:.4f} | 偏離 1 g 的量=整體刻度/偏移誤差;校正後應 < 0.01 |')
    L.append(f'| 合成加速度雜訊(標準差) | {s1["mag_std"]*1000:.1f} mg | {s2["mag_std"]*1000:.1f} mg | 越小越乾淨;決定門檻能設多低 |')
    L.append(f'| 各軸雜訊 X/Y/Z | {"/".join(f"{v*1000:.1f}" for v in s1["axis_std"])} mg | {"/".join(f"{v*1000:.1f}" for v in s2["axis_std"])} mg | |')
    L.append(f'| 各軸平均 X/Y/Z | {"/".join(f"{v:+.3f}" for v in s1["axis_mean"])} | {"/".join(f"{v:+.3f}" for v in s2["axis_mean"])} | 兩顆若貼齊,數字應接近;差異=安裝角度或偏移 |')
else: L.append('| (靜止段不足 1 秒,無法統計) | | | |')
L.append('\n## 運動時(一致性)')
L.append('| 項目 | 數值 | 解讀 |'); L.append('|---|---|---|')
L.append(f'| 兩顆 \\|a\\| 波形相關係數 | {corr:.3f} | > 0.95 表示兩顆看到同一個動作 |' if corr is not None else '| 相關係數 | (運動段不足) | |')
L.append(f'| 增益比 MPU6050/ADXL345 | {gain:.3f} | 偏離 1 的量=刻度差 |' if gain is not None else '| 增益比 | (運動段不足) | |')
L.append(f'| 步數 ADXL345/MPU6050 | {steps1}/{steps2} | 差異 {abs(steps1-steps2)} 步({abs(steps1-steps2)/max(1,max(steps1,steps2))*100:.1f}%);同一演算法、同一門檻 |')
L.append('\n## 怎麼讀這份報告')
L.append('- 靜止時 |a| 不是 1.000:先做六面校正(韌體按左鍵),校正後偏移應收斂到 0.01 g 以內。')
L.append('- 雜訊:ADXL345(13 bit、3.9 mg/格)通常比 MPU6050(16 bit)粗,但走路訊號 ±0.3 g 遠大於兩者雜訊,對計步影響不大。')
L.append('- 相關係數高但步數不同:差在門檻附近的邊緣峰值;把門檻從 0.12 g 調到 0.15 g 再比一次。')
L.append('- 相關係數低:兩顆沒貼牢、或軸向沒對齊(|a| 不受軸向影響,所以多半是沒貼牢)。')
open(os.path.join(a.out, 'report.md'), 'w').write('\n'.join(L) + '\n'); print('\n'.join(L))
try:
    import matplotlib; matplotlib.use('Agg'); import matplotlib.pyplot as plt
    plt.rcParams['font.family'] = ['PingFang HK', 'Heiti TC']; plt.rcParams['axes.unicode_minus'] = False
    fig, ax = plt.subplots(2, 1, figsize=(11, 6), sharex=True)
    ax[0].plot(t, m1, lw=.8, label='ADXL345'); ax[0].plot(t, m2, lw=.8, label='MPU6050', alpha=.8); ax[0].set_ylabel('|a|(g)'); ax[0].legend(frameon=False)
    ax[0].set_title('兩顆感測器的合成加速度(貼在一起時應幾乎重疊)', loc='left')
    ax[1].step(t, [r[7] for r in rows], where='post', label='ADXL345 步數'); ax[1].step(t, [r[8] for r in rows], where='post', label='MPU6050 步數'); ax[1].legend(frameon=False); ax[1].set_xlabel('秒'); ax[1].set_ylabel('步')
    for x in ax: x.spines[['top', 'right']].set_visible(False)
    plt.tight_layout(); plt.savefig(os.path.join(a.out, 'compare.png'), dpi=160); print('圖:', os.path.join(a.out, 'compare.png'))
except Exception as e: print('(未畫圖:', e, ')')

B 久坐提醒

入門✓ 原生單元測試 9/9

原作說明書寫了但沒做的功能:連續 N 分鐘沒有步數就響鈴,之後每 M 分鐘再提醒;番茄鐘進行中暫停。純 C++,模擬器可直接驗(把速度調到 600× 看 1 小時)。

步驟

  1. 把 sedentary.h 複製到 firmware/app/,照檔頭說明在 app.cpp 加三行。
  2. 在 buzzer.h 加一段提醒旋律。
  3. make sim-build,模擬器速度 600×,走幾步後停 1 小時看會不會響。

程式

firmware/extensions/sedentary/sedentary.h 下載 32 行
// 延伸專案 B:久坐提醒(原作說明書寫了但沒做的功能)
// 純 C++,不碰硬體。用法(在 app.cpp):
//   static Sedentary sed;                         // 全域
//   在 app_loop() 每次取樣後:sed.feed(pedo.steps(), now);
//   if (sed.due(now)) { buzz.play(MELODY_SEDENTARY, 4); hal_log("[久坐] 已 %u 分鐘沒走動", sed.idle_minutes(now)); }
//   番茄鐘進行中不提醒:sed.suspend(pomo.state() == POMO_RUNNING);
// 測試:clang++ -std=c++17 firmware/extensions/sedentary/test_sedentary.cpp -o build/test_sedentary && build/test_sedentary
#pragma once
#include <stdint.h>

class Sedentary {
public:
  // limit_min:連續幾分鐘沒有步數就提醒;repeat_min:提醒後若仍不動,隔幾分鐘再提醒一次
  explicit Sedentary(uint16_t limit_min = 60, uint16_t repeat_min = 15) : limit_(limit_min), repeat_(repeat_min) {}
  void feed(uint32_t steps, uint32_t now_ms) {
    if (!primed_) { last_steps_ = steps; last_move_ = now_ms; primed_ = true; return; }
    if (steps != last_steps_) { last_steps_ = steps; last_move_ = now_ms; reminded_ = 0; }
  }
  void suspend(bool s) { suspended_ = s; }
  uint32_t idle_minutes(uint32_t now_ms) const { return primed_ ? (now_ms - last_move_) / 60000u : 0; }
  // 到了該提醒的時刻回傳 true(每個時刻只回一次)
  bool due(uint32_t now_ms) {
    if (!primed_ || suspended_) return false;
    uint32_t idle = idle_minutes(now_ms);
    uint32_t next = reminded_ == 0 ? limit_ : limit_ + reminded_ * repeat_;
    if (idle >= next) { reminded_++; return true; }
    return false;
  }
  void set_limits(uint16_t limit_min, uint16_t repeat_min) { limit_ = limit_min; repeat_ = repeat_min; }
private:
  uint16_t limit_, repeat_; uint32_t last_steps_ = 0, last_move_ = 0; uint8_t reminded_ = 0; bool primed_ = false, suspended_ = false;
};
firmware/extensions/sedentary/test_sedentary.cpp 下載 17 行
#include "sedentary.h"
#include <cstdio>
int main() {
  int fails = 0; auto check = [&](const char* n, bool ok) { fails += !ok; printf("%s %s\n", ok ? "✓" : "✗", n); };
  Sedentary s(60, 15); uint32_t t = 0; const uint32_t MIN = 60000;
  s.feed(100, t);
  for (int m = 1; m < 60; m++) { t = m * MIN; s.feed(100, t); if (s.due(t)) { check("59 分鐘內不該提醒", false); break; } }
  t = 60 * MIN; s.feed(100, t); check("第 60 分鐘提醒", s.due(t));
  check("同一分鐘不重複", !s.due(t));
  t = 70 * MIN; s.feed(100, t); check("70 分鐘還沒到下一次(75)", !s.due(t));
  t = 75 * MIN; s.feed(100, t); check("75 分鐘第二次提醒", s.due(t));
  t = 76 * MIN; s.feed(105, t); check("走動後歸零", s.idle_minutes(t) == 0 && !s.due(t));
  t = 76 * MIN + 59 * MIN; s.feed(105, t); check("走動後 59 分鐘不提醒", !s.due(t));
  s.suspend(true); t += 10 * MIN; s.feed(105, t); check("番茄鐘進行中不提醒", !s.due(t));
  s.suspend(false); check("解除後補提醒", s.due(t));
  printf(fails ? "\n有 %d 項未通過\n" : "\n全部通過\n", fails); return fails ? 1 : 0;
}

C 步頻穩定度:擋掉「三下搖晃」

挑戰✓ 原生對照測試 6/6(原版 vs 穩定版)

真的走路步伐間隔很規律,搖晃忽快忽慢。多要求最近三個間隔的變異係數 ≤ 25% 才開始計;等距的三下搖晃仍會被計(物理上就是走路步頻),忽快忽慢的會被擋掉。

步驟

  1. 把 app.cpp 的 Pedometer 換成 PedometerStable(介面相同)。
  2. 跑測試比較兩版在六種情境的步數。
  3. 討論:為什麼等距搖晃擋不掉?還有哪些特徵可以用?

程式

firmware/extensions/pedometer_stable/pedometer_stable.h 下載 56 行
// 延伸專案 C:加入「步頻穩定度」判斷的計步器
// 想法:真的走路時,連續步伐的間隔很接近(變異小);拍打、搖晃的間隔忽長忽短。
// 做法:在原本「峰值→間隔→連續確認」之外,再要求最近 N 個間隔的變異係數(標準差/平均)低於 cv_max 才開始計。
// 用法:把 app.cpp 的 Pedometer 換成 PedometerStable(介面相同)。
// 測試:clang++ -std=c++17 -I firmware/app firmware/extensions/pedometer_stable/test_pedometer_stable.cpp firmware/app/pedometer.cpp -o build/test_pedometer_stable && build/test_pedometer_stable
#pragma once
#include <stdint.h>
#include <math.h>

struct StableParams {
  float    k_thresh      = 0.12f;
  uint16_t min_interval  = 250;
  uint16_t max_interval  = 2000;
  uint8_t  confirm_steps = 4;      // 要 4 個峰值才有 3 個間隔可以算變異
  float    cv_max        = 0.25f;  // 間隔變異係數上限:0.25 = 間隔忽快忽慢超過 25% 就不算走路
};

class PedometerStable {
public:
  void begin(uint32_t saved) { steps_ = saved; }
  void feed(float ax, float ay, float az, uint32_t now_ms) {
    float m = sqrtf(ax * ax + ay * ay + az * az);
    if (!primed_) { lp_ = dc_ = m; primed_ = true; return; }
    lp_ += 0.3f * (m - lp_); dc_ += 0.02f * (lp_ - dc_);
    prev2_ = prev_; prev_ = s_; s_ = lp_ - dc_;
    bool peak = prev_ > prev2_ && prev_ >= s_ && prev_ > p.k_thresh;
    if (!peak) { if (pending_ && now_ms - last_ > p.max_interval) pending_ = 0; return; }
    uint32_t gap = now_ms - last_;
    if (last_ && gap < p.min_interval) return;
    if (last_ == 0 || gap > p.max_interval) { pending_ = 0; last_ = now_ms; pending_ = 1; return; }
    last_ = now_ms;
    gaps_[gap_i_++ % 3] = gap;                       // 保留最近 3 個間隔
    if (pending_ < p.confirm_steps) {
      pending_++;
      if (pending_ >= p.confirm_steps) {
        if (cv_ok()) { steps_ += pending_; confirmed_ = true; } else pending_ = 1;   // 節奏亂:重新累積
      }
    } else {
      if (cv_ok()) steps_++; else { pending_ = 1; confirmed_ = false; }
    }
  }
  uint32_t steps() const { return steps_; }
  void reset() { steps_ = 0; pending_ = 0; }
  void set_steps(uint32_t s) { steps_ = s; }
  float signal() const { return s_; }
  StableParams p;
private:
  bool cv_ok() const {
    if (gap_i_ < 3) return false;
    float mean = (gaps_[0] + gaps_[1] + gaps_[2]) / 3.0f, var = 0;
    for (int i = 0; i < 3; i++) var += (gaps_[i] - mean) * (gaps_[i] - mean);
    return sqrtf(var / 3) / mean <= p.cv_max;
  }
  uint32_t steps_ = 0, last_ = 0, gaps_[3] = {0, 0, 0}; uint8_t gap_i_ = 0, pending_ = 0;
  float lp_ = 1, dc_ = 1, s_ = 0, prev_ = 0, prev2_ = 0; bool primed_ = false, confirmed_ = false;
};
firmware/extensions/pedometer_stable/test_pedometer_stable.cpp 下載 29 行
// 與原版 Pedometer 同一套情境對照:目標是「三下 0.7 秒搖晃不計」而走路照常
#include "pedometer_stable.h"
#include "pedometer.h"
#include <cstdio>
#include <cstdlib>
static float frand() { return rand() / (float)RAND_MAX - 0.5f; }
template <class P> static uint32_t run(P& p, bool walking, float bpm, float amp, float secs, int shakes, float gap_s, bool jitter = false) {
  p.begin(0);
  for (int i = 0; i < (int)(secs * 50); i++) {
    uint32_t t = i * 20; float f = 1.0f + frand() * 0.03f;
    if (walking) f += amp * sinf(2 * M_PI * (bpm / 60.0f) * t / 1000.0f);
    for (int k = 0; k < shakes; k++) { float g = jitter ? gap_s * (1 + 0.5f * ((k % 2) ? 1 : -1)) : gap_s; float t0 = 1000 + k * g * 1000; if (t >= t0 && t < t0 + 150) f += 0.7f * sinf(M_PI * (t - t0) / 150); }
    p.feed(0, 0, f, t);
  }
  return p.steps();
}
int main() {
  int fails = 0; srand(1);
  auto check = [&](const char* n, uint32_t a, uint32_t b, uint32_t lo, uint32_t hi) {
    bool ok = b >= lo && b <= hi; fails += !ok; printf("%s %-36s 原版 %3u 穩定版 %3u(預期 %u~%u)\n", ok ? "✓" : "✗", n, a, b, lo, hi); };
  Pedometer o; PedometerStable s;
  check("走路 30 秒 @110", run(o, true, 110, .25f, 30, 0, 0), run(s, true, 110, .25f, 30, 0, 0), 46, 58);
  check("慢走 30 秒 @80", run(o, true, 80, .20f, 30, 0, 0), run(s, true, 80, .20f, 30, 0, 0), 33, 42);
  check("快走 30 秒 @170", run(o, true, 170, .35f, 30, 0, 0), run(s, true, 170, .35f, 30, 0, 0), 76, 88);
  check("單次搖晃", run(o, false, 0, 0, 10, 1, 0), run(s, false, 0, 0, 10, 1, 0), 0, 0);
  check("三下搖晃間隔 0.7 秒(等距,像走路)", run(o, false, 0, 0, 10, 3, .7f), run(s, false, 0, 0, 10, 3, .7f), 0, 3);
  check("五下搖晃間隔忽快忽慢(0.35/1.05 秒)", run(o, false, 0, 0, 12, 5, .7f, true), run(s, false, 0, 0, 12, 5, .7f, true), 0, 0);
  printf(fails ? "\n有 %d 項未通過\n" : "\n全部通過\n", fails); return fails ? 1 : 0;
}

D 省電兩招:閒置關 Wi-Fi、長按深度睡眠

進階✓ arduino-cli 編譯通過

Wi-Fi 是耗電大戶。閒置 5 分鐘自動關、按任意鍵重開;長按左鍵 3 秒進深度睡眠(ESP32 約 10 µA),按右鍵喚醒。這是「軟體關機」,兼顧省電與充電,對照講義 2.1 的開關取捨。

步驟

  1. make ext NAME=power_modes 燒進去,序列埠看訊息。
  2. 有電流表就串在電池線上量三種狀態。
  3. 搬進主韌體:在 hal.h 加 hal_net_set_enabled()、hal_power_off(),app.cpp 記錄最後操作時間。

程式

firmware/extensions/power_modes/power_modes.ino 下載 44 行
// 延伸專案 D:省電兩招——閒置自動關 Wi-Fi、長按下鍵(GPIO0)深度睡眠(軟體關機)
// 獨立示範草稿(不含螢幕與感測器),把觀念弄清楚後再搬進 hal_esp32.cpp/app.cpp。
// 觀察方法:序列埠看訊息;有電流表的話串在電池線上量三種狀態的電流。
//   Wi-Fi 開著:約 80~240 mA  Wi-Fi 關:約 20~40 mA  深度睡眠:ESP32 約 10 µA(板上其他零件另計)
#include <WiFi.h>
#include <esp_sleep.h>
#include <driver/rtc_io.h>

static const int PIN_BTN_L = 0, PIN_BTN_R = 35, PIN_ADC_EN = 14;
static const uint32_t IDLE_WIFI_OFF_MS = 5 * 60 * 1000;    // 閒置 5 分鐘關 Wi-Fi(示範時可改 20000)
static const uint32_t LONG_PRESS_MS    = 3000;             // 長按 3 秒關機
static uint32_t last_activity = 0; static bool wifi_on = false;

static void wifi_start() { WiFi.mode(WIFI_AP); WiFi.softAP("計步器-省電示範", "12345678"); wifi_on = true; Serial.println("[Wi-Fi] 開啟,IP " + WiFi.softAPIP().toString()); }
static void wifi_stop()  { WiFi.softAPdisconnect(true); WiFi.mode(WIFI_OFF); wifi_on = false; Serial.println("[Wi-Fi] 閒置太久,已關閉;按任意鍵重開"); }

static void power_off() {
  Serial.println("[電源] 進入深度睡眠;按上鍵(GPIO35)喚醒"); Serial.flush();
  digitalWrite(PIN_ADC_EN, LOW);                                   // 關掉電池分壓,省一點
  // 真機版還要:tft.sleep()/背光關、ADXL345 寫 POWER_CTL=0 進待機
  esp_sleep_enable_ext0_wakeup(GPIO_NUM_35, 0);                    // GPIO35 是 RTC 腳,拉低喚醒(板上有上拉)
  esp_deep_sleep_start();                                          // 不會回來;喚醒等於重新開機
}

void setup() {
  Serial.begin(115200); delay(200);
  pinMode(PIN_BTN_L, INPUT_PULLUP); pinMode(PIN_BTN_R, INPUT); pinMode(PIN_ADC_EN, OUTPUT); digitalWrite(PIN_ADC_EN, HIGH);
  auto cause = esp_sleep_get_wakeup_cause();
  Serial.println(cause == ESP_SLEEP_WAKEUP_EXT0 ? "[電源] 由按鍵喚醒" : "[電源] 一般開機");
  wifi_start(); last_activity = millis();
  Serial.println("下鍵(GPIO0)長按 3 秒 → 深度睡眠;任何鍵 → 重設閒置計時並重開 Wi-Fi");
}

void loop() {
  uint32_t now = millis();
  bool l = digitalRead(PIN_BTN_L) == LOW, r = digitalRead(PIN_BTN_R) == LOW;
  static uint32_t l_down = 0;
  if (l && !l_down) l_down = now;
  if (!l) l_down = 0;
  if (l_down && now - l_down >= LONG_PRESS_MS) power_off();
  if (l || r) { last_activity = now; if (!wifi_on) wifi_start(); }
  if (wifi_on && now - last_activity >= IDLE_WIFI_OFF_MS) wifi_stop();
  delay(20);
}

E 電腦端後台:裝置上傳、電腦收集(原作說明書的架構)

進階✓ Python 可執行;裝置端 arduino-cli 編譯通過

新版是「裝置當伺服器」,一次只服務幾個人;全班幾十台時要反過來:裝置連家用 Wi-Fi、每分鐘 POST 到電腦,電腦存 CSV 並顯示總表。只用 Python 內建模組。

步驟

  1. 電腦跑 python3 pc_backend.py,記下電腦 IP。
  2. 改 pc_backend.ino 前三行(Wi-Fi、IP),make ext NAME=pc_backend 燒錄。
  3. 開 http://電腦IP:8080/ 看總表;討論兩種架構各適合什麼場景。

程式

firmware/extensions/pc_backend/pc_backend.py 下載 39 行
#!/usr/bin/env python3
"""延伸專案 E:原作說明書想做的「電腦端後台」——用最少的程式收裝置上傳的步數,存成 CSV,並提供簡單網頁。
只用 Python 內建模組,不必安裝任何東西。
  python3 firmware/extensions/pc_backend/pc_backend.py            # 監聽 0.0.0.0:8080
裝置端(見同資料夾 pc_backend.ino)在連上家用 Wi-Fi 後,每分鐘 POST /api/upload,內容:
  {"mac":"2C:BC:...","steps":1234,"day":20732,"pomo":2,"battery_v":3.95}
網頁:http://<電腦IP>:8080/ 看所有裝置的最新數字;CSV 在 pc_backend_log.csv。
教學點:這就是「誰當伺服器」的另一種選擇——裝置當客戶端上傳,電腦當伺服器收集。多台裝置(全班)時這種架構才撐得住。"""
import json, csv, os, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

LOG = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'pc_backend_log.csv')
latest = {}

class H(BaseHTTPRequestHandler):
    def _send(self, code, body, ctype='application/json; charset=utf-8'):
        b = body.encode('utf-8'); self.send_response(code); self.send_header('Content-Type', ctype); self.send_header('Content-Length', str(len(b))); self.end_headers(); self.wfile.write(b)
    def do_POST(self):
        if self.path != '/api/upload': return self._send(404, '{"error":"not found"}')
        try:
            d = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0))))
            mac = str(d.get('mac', self.client_address[0]))
            row = {'time': time.strftime('%Y-%m-%d %H:%M:%S'), 'mac': mac, 'steps': int(d['steps']), 'day': int(d.get('day', -1)), 'pomo': int(d.get('pomo', 0)), 'battery_v': float(d.get('battery_v', 0))}
        except Exception as e: return self._send(400, json.dumps({'error': f'bad json: {e}'}))
        new = not os.path.exists(LOG)
        with open(LOG, 'a', newline='') as f:
            w = csv.DictWriter(f, fieldnames=list(row)); new and w.writeheader(); w.writerow(row)
        latest[mac] = row; print(row); self._send(200, '{"status":"ok"}')
    def do_GET(self):
        if self.path == '/api/latest': return self._send(200, json.dumps(latest, ensure_ascii=False))
        rows = ''.join(f"<tr><td>{r['mac']}</td><td>{r['steps']}</td><td>{r['pomo']}</td><td>{r['battery_v']:.2f}</td><td>{r['time']}</td></tr>" for r in latest.values())
        self._send(200, f"""<!doctype html><meta charset=utf-8><title>計步器後台</title><meta http-equiv=refresh content=5>
<style>body{{font-family:system-ui;padding:20px}}table{{border-collapse:collapse}}td,th{{border:1px solid #ccc;padding:6px 12px}}</style>
<h1>計步器後台</h1><p>每 5 秒更新;原始紀錄在 pc_backend_log.csv</p>
<table><tr><th>裝置</th><th>步數</th><th>番茄鐘</th><th>電池 V</th><th>最後上傳</th></tr>{rows or '<tr><td colspan=5>還沒有裝置上傳</td></tr>'}</table>""", 'text/html; charset=utf-8')
    def log_message(self, *a): pass

if __name__ == '__main__':
    print('後台啟動:http://0.0.0.0:8080/ (裝置要和電腦在同一個 Wi-Fi)'); ThreadingHTTPServer(('0.0.0.0', 8080), H).serve_forever()
firmware/extensions/pc_backend/pc_backend.ino 下載 39 行
// 延伸專案 E(裝置端):連家用 Wi-Fi,每分鐘把步數 POST 到電腦後台(pc_backend.py)
// 獨立示範草稿:步數用假資料遞增;搬進主韌體時把 fake_steps 換成 pedo.steps()、電池換成 hal_battery_volts()。
// 改這三行:
static const char* WIFI_SSID = "你的Wi-Fi名稱";
static const char* WIFI_PASS = "你的Wi-Fi密碼";
static const char* BACKEND   = "http://192.168.1.100:8080/api/upload";   // 跑 pc_backend.py 那台電腦的 IP

#include <WiFi.h>
#include <HTTPClient.h>

static uint32_t fake_steps = 0, last_post = 0;

static bool upload(uint32_t steps, float battery_v) {
  if (WiFi.status() != WL_CONNECTED) return false;
  HTTPClient http; http.begin(BACKEND); http.addHeader("Content-Type", "application/json");
  char body[160];
  snprintf(body, sizeof body, "{\"mac\":\"%s\",\"steps\":%u,\"day\":%d,\"pomo\":%d,\"battery_v\":%.2f}", WiFi.macAddress().c_str(), steps, -1, 0, battery_v);
  int code = http.POST(body); http.end();
  Serial.printf("[上傳] %s → HTTP %d\n", body, code);
  return code == 200;
}

void setup() {
  Serial.begin(115200); delay(200);
  WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.printf("[Wi-Fi] 連線到 %s", WIFI_SSID);
  for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) { delay(250); Serial.print('.'); }
  Serial.println(WiFi.status() == WL_CONNECTED ? "\n[Wi-Fi] 已連線 " + WiFi.localIP().toString() : "\n[Wi-Fi] 連不上,之後每分鐘重試");
}

void loop() {
  uint32_t now = millis();
  fake_steps += 1; delay(500);                                   // 假裝每 0.5 秒走一步
  if (now - last_post >= 60000 || last_post == 0) {
    last_post = now;
    if (WiFi.status() != WL_CONNECTED) WiFi.reconnect();
    upload(fake_steps, 3.9f);
  }
}

F 把 ADXL345 換成 MPU6050(接線不變)

進階✓ `make build-mpu` 編譯通過

主韌體已內建兩種讀法:編譯時加 -DACCEL_MPU6050 就改讀 MPU6050(0x68、16384 LSB/g)。接線完全相同,只有讀暫存器那一小段不同——這就是硬體抽象層的價值。

步驟

  1. make build-mpu 編譯、make flash PORT=… 前把 Makefile 的 build 改成 build-mpu 或直接用 arduino-cli upload build/out_mpu。
  2. 走 100 步比較兩顆的誤差。
  3. 進一步:用 MPU6050 的陀螺儀做「翻轉手腕亮螢幕」。

程式

firmware/hal_esp32/hal_esp32.cpp 下載 675 行
// HAL 的 ESP32 實作:LILYGO T-Display + ADXL345 + 蜂鳴器。
// Arduino ESP32 core 3.3.x、LovyanGFX 1.2.x。
#include <Arduino.h>
#include <Wire.h>
#include <Preferences.h>
#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <Update.h>                        // OTA:從網頁上傳 .bin 寫進另一個 app 分區,重開機切換
#include <LittleFS.h>
#include <nvs.h>
#include <rom/crc.h>                         // 畫面 CRC32:沒變就不送 SPI
#include <ESPmDNS.h>                       // 連上家用 Wi-Fi 時可用 http://pedometer.local/ 找到裝置                      // 走路錄製檔存在 spiffs 分區(LittleFS),重開機不會掉                        // OTA:從網頁上傳 .bin 寫進另一個 app 分區,重開機切換
#include <esp_sleep.h>
#include <driver/gpio.h>
#include <sys/time.h>
#define LGFX_USE_V1
#include <LovyanGFX.hpp>
#include "src/hal/hal.h"
#include "src/app/app.h"

void hal_esp32_poll();   // 由 .ino 每圈呼叫(處理 HTTP)

// ---------- 腳位 ----------
// 按鍵:橫向拿時兩顆鍵在同一側上下排列。GPIO35 在上(BTN_LEFT=上鍵:上一項/加),GPIO0 在下(BTN_RIGHT=下鍵)。
// 2026-10-08 老師回報上下相反,對調。
static const int PIN_BTN_L = 35, PIN_BTN_R = 0, PIN_BUZZER = 25, PIN_VBAT = 34;
static const int PIN_SDA = 21, PIN_SCL = 22;
static const int PIN_TFT_SCLK = 18, PIN_TFT_MOSI = 19, PIN_TFT_CS = 5, PIN_TFT_DC = 16, PIN_TFT_RST = 23, PIN_TFT_BL = 4;
static const uint8_t ADXL_ADDR = 0x53;

// ---------- 螢幕 ----------
class LGFX_TDisplay : public lgfx::LGFX_Device {
  lgfx::Panel_ST7789 panel_; lgfx::Bus_SPI bus_; lgfx::Light_PWM light_;
public:
  LGFX_TDisplay() {
    { auto c = bus_.config();
      c.spi_host = VSPI_HOST; c.spi_mode = 0; c.freq_write = 40000000; c.freq_read = 16000000;
      c.spi_3wire = true; c.use_lock = true; c.dma_channel = SPI_DMA_CH_AUTO;
      c.pin_sclk = PIN_TFT_SCLK; c.pin_mosi = PIN_TFT_MOSI; c.pin_miso = -1; c.pin_dc = PIN_TFT_DC;
      bus_.config(c); panel_.setBus(&bus_); }
    { auto c = panel_.config();
      c.pin_cs = PIN_TFT_CS; c.pin_rst = PIN_TFT_RST; c.pin_busy = -1;
      c.panel_width = 135; c.panel_height = 240; c.offset_x = 52; c.offset_y = 40;
      c.offset_rotation = 0; c.readable = false; c.invert = true; c.rgb_order = false; c.dlen_16bit = false; c.bus_shared = false;
      panel_.config(c); }
    { auto c = light_.config(); c.pin_bl = PIN_TFT_BL; c.invert = false; c.freq = 44100; c.pwm_channel = 7;
      light_.config(c); panel_.setLight(&light_); }
    setPanel(&panel_);
  }
};
static LGFX_TDisplay tft;
static LGFX_Sprite  canvas(&tft);          // 全畫面雙緩衝 240×135×16bit ≈ 64KB

// ---------- 自製點陣字型(tools/gen_font.py 從 app/ 用到的字自動產生;只含用到的字,約數十 KB)----------
#include "font_data.h"
static const BitmapFont* font_of(int f) { return f == FONT_S ? &FONT12 : f == FONT_L ? &FONT24 : &FONT16; }
static const Glyph* find_glyph(const BitmapFont* F, uint32_t cp) {
  int lo = 0, hi = F->n - 1;                                  // glyphs 依 codepoint 排序,二分搜尋
  while (lo <= hi) { int m = (lo + hi) / 2; if (F->glyphs[m].cp == cp) return &F->glyphs[m]; if (F->glyphs[m].cp < cp) lo = m + 1; else hi = m - 1; }
  return cp == '?' ? nullptr : find_glyph(F, '?');            // 沒有的字畫成問號
}
static uint32_t utf8_next(const char** p) {
  const uint8_t* s = (const uint8_t*)*p; uint32_t c = s[0]; int n = 0;
  if (c >= 0xF0) { c &= 7; n = 3; } else if (c >= 0xE0) { c &= 15; n = 2; } else if (c >= 0xC0) { c &= 31; n = 1; }
  for (int i = 1; i <= n && s[i]; i++) c = (c << 6) | (s[i] & 63);
  *p += n + 1; return c;
}
static int text_width(const BitmapFont* F, const char* s) {
  int w = 0; while (*s) { const Glyph* g = find_glyph(F, utf8_next(&s)); if (g) w += g->adv; } return w;
}
static void draw_glyph(const BitmapFont* F, const Glyph* g, int x, int y, uint16_t c) {
  int rowbytes = (g->w + 7) / 8; const uint8_t* b = F->bits + g->off;
  for (int r = 0; r < F->height; r++, b += rowbytes)
    for (int px = 0; px < g->w; px++)
      if (b[px >> 3] & (0x80 >> (px & 7))) canvas.drawPixel(x + px, y + r, c);
}

uint32_t hal_millis() { return millis(); }
void hal_gfx_begin_frame() {}
// 螢幕方向:停在 0°/180° 時直接切 LCD 的硬體方向(setRotation 1/3),不需要額外記憶體;
// 轉動動畫途中才用一張 8 位元色深的暫存畫面(32 KB;16 位元要 64.8 KB,實測最大連續記憶體只有 57 KB,配不到),動畫結束就釋放。
static float rot_deg = 0, rot_zoom = 1; static LGFX_Sprite* rot_frame = nullptr; static uint8_t hw_rot = 1;
static bool psave = true;                                 // 省電總開關
static uint32_t last_crc = 0, pf_push = 0, pf_skip = 0;  // 送畫面/略過次數(perf)
void hal_power_save(bool on) { psave = on; last_crc = 0; }
void hal_gfx_set_rotation(float deg, float zoom) { rot_deg = deg; rot_zoom = zoom; }
void hal_gfx_end_frame() {
  bool still = rot_deg == 0 || rot_deg == 180;
  if (still) {
    if (rot_frame) { rot_frame->deleteSprite(); delete rot_frame; rot_frame = nullptr; }
    uint8_t want = rot_deg == 180 ? 3 : 1;
    if (want != hw_rot) { tft.setRotation(want); hw_rot = want; last_crc = 0; }
    if (psave) {                                             // 項目 3:整張畫面雜湊和上一張一樣就不送(SPI 送一張要 13 ms)
      // 輕量雜湊:每個 32 位元字「左旋 5 位再 XOR」(ROM 的 crc32 要約 12 ms,這個約 1 ms;UI 畫面碰撞機率可忽略)
      const uint32_t* w = (const uint32_t*)canvas.getBuffer(); uint32_t c = 0x9E3779B9u;
      for (int i = 0; i < SCREEN_W * SCREEN_H / 2; i++) c = ((c << 5) | (c >> 27)) ^ w[i];
      if (c == 0) c = 1;
      if (c == last_crc) { pf_skip++; return; }
      last_crc = c;
    }
    pf_push++; canvas.pushSprite(0, 0); return;
  }
  if (hw_rot != 1) { tft.setRotation(1); hw_rot = 1; }
  if (!rot_frame) {
    rot_frame = new LGFX_Sprite(&tft); rot_frame->setColorDepth(8);
    if (!rot_frame->createSprite(SCREEN_W, SCREEN_H)) { delete rot_frame; rot_frame = nullptr; hal_log("[方向] 動畫記憶體不足,直接切換"); canvas.pushSprite(0, 0); return; }
  }
  rot_frame->fillScreen(TFT_BLACK);
  canvas.pushRotateZoom(rot_frame, SCREEN_W / 2.0f, SCREEN_H / 2.0f, rot_deg, rot_zoom, rot_zoom);
  rot_frame->pushSprite(0, 0); pf_push++; last_crc = 0;
}
void hal_gfx_fill(uint16_t c) { canvas.fillScreen(c); }
void hal_gfx_fill_rect(int x, int y, int w, int h, uint16_t c) { canvas.fillRect(x, y, w, h, c); }
void hal_gfx_draw_rect(int x, int y, int w, int h, uint16_t c) { canvas.drawRect(x, y, w, h, c); }
void hal_gfx_text(int x, int y, const char* s, int font, uint16_t c, int align) {
  const BitmapFont* F = font_of(font);
  int w = text_width(F, s);
  if (align == AL_CENTER) x -= w / 2; else if (align == AL_RIGHT) x -= w;
  while (*s) { const Glyph* g = find_glyph(F, utf8_next(&s)); if (!g) continue; draw_glyph(F, g, x, y, c); x += g->adv; }
}
int hal_gfx_text_width(const char* s, int font) { return text_width(font_of(font), s); }
static uint8_t cur_bl = 0;   // 目前背光(perf/plog 記錄螢幕有沒有亮)
static bool scr_power = true;
void hal_gfx_power(bool on) {
  if (on == scr_power) return; scr_power = on;
  if (on) { tft.wakeup(); last_crc = 0; } else { tft.setBrightness(0); cur_bl = 0; tft.sleep(); }
}
void hal_gfx_backlight(uint8_t pct) { cur_bl = pct; tft.setBrightness(pct * 255 / 100); }

// ---------- 按鍵 ----------
bool hal_button_pressed(int id) { return digitalRead(id == BTN_LEFT ? PIN_BTN_L : PIN_BTN_R) == LOW; }

// ---------- 加速度計(自己讀暫存器,不靠外部函式庫)----------
// 預設 ADXL345;編譯時加 -DACCEL_MPU6050(make build-mpu)就改讀 MPU6050,接線完全相同(I2C 21/22)。
#ifdef ACCEL_MPU6050
static const uint8_t MPU_ADDR = 0x68;                       // AD0 接地(或不接)=0x68;接高=0x69
static void mpu_write(uint8_t reg, uint8_t val) { Wire.beginTransmission(MPU_ADDR); Wire.write(reg); Wire.write(val); Wire.endTransmission(); }
bool hal_accel_begin() {
  Wire.begin(PIN_SDA, PIN_SCL, 400000);
  Wire.beginTransmission(MPU_ADDR); Wire.write(0x75);       // WHO_AM_I
  if (Wire.endTransmission(false) != 0 || Wire.requestFrom(MPU_ADDR, (uint8_t)1) != 1) return false;
  uint8_t id = Wire.read();
  if (id != 0x68 && id != 0x70 && id != 0x71) { hal_log("[MPU6050] 裝置代碼錯誤 0x%02X", id); return false; }
  mpu_write(0x6B, 0x00);   // PWR_MGMT_1:喚醒、內部時脈
  mpu_write(0x1C, 0x00);   // ACCEL_CONFIG:±2g → 16384 LSB/g
  mpu_write(0x1A, 0x03);   // CONFIG:數位低通約 44 Hz
  return true;
}
bool hal_accel_read(float* ax, float* ay, float* az) {
  Wire.beginTransmission(MPU_ADDR); Wire.write(0x3B);       // ACCEL_XOUT_H,高位元組在前
  if (Wire.endTransmission(false) != 0 || Wire.requestFrom(MPU_ADDR, (uint8_t)6) != 6) return false;
  int16_t x = Wire.read() << 8, y, z; x |= Wire.read(); y = Wire.read() << 8; y |= Wire.read(); z = Wire.read() << 8; z |= Wire.read();
  *ax = x / 16384.0f; *ay = y / 16384.0f; *az = z / 16384.0f;
  return true;
}
int hal_accel_read_batch(float* xyz, int max, uint8_t* events) { *events = 0; return max >= 1 && hal_accel_read(&xyz[0], &xyz[1], &xyz[2]) ? 1 : 0; }
uint16_t hal_accel_batch_ms() { return 20; }
static uint32_t pf_fifo_reads = 0, pf_fifo_full = 0, pf_taps = 0, pf_fifo_max = 0, pf_fifo_err = 0, pf_fifo_last_n = 0, pf_fifo_last_got = 0, pf_fifo_last_out = 0, pf_fifo_total = 0;
void hal_rec_start(uint16_t) {} int hal_rec_stop(const char*) { return 0; } int hal_rec_count() { return 0; }   // MPU6050 版沒有錄製
#else
// ---------- 走路錄製(診斷用):序列埠 rec <秒> 開始、dump 倒出 CSV。50 Hz × 最多 120 秒,原始 LSB(256 = 1 g)
struct RecSample { int16_t x, y, z; };   // 固定 50 Hz(FIFO),不存時間;120 秒 × 6 bytes = 36 KB(藍牙開著時最大連續記憶體只有 47 KB,原本 48 KB 配不到)
struct RecSampleOld { uint16_t dt; int16_t x, y, z; };   // 舊版 /walk.bin 格式(序列埠 dump 相容用)
static const int REC_MAX = 6000;
static RecSample* rec_buf = nullptr; static int rec_n = 0; static uint32_t rec_start = 0, rec_until = 0, rec_last = 0; static bool rec_on = false;
// 錄音檔格式:標頭 "WALK"+uint16 筆數+uint16 取樣率(50),之後每筆 x,y,z int16(6 bytes;固定 50 Hz 不存時間)
static bool rec_save(const char* path) {
  if (!LittleFS.begin(true)) return false;
  File f = LittleFS.open(path, "w"); if (!f) return false;
  uint16_t hdr[4] = { 0x4157, 0x4B4C, (uint16_t)rec_n, 50 };   // "WALK"
  bool ok = f.write((uint8_t*)hdr, sizeof hdr) == sizeof hdr;
  for (int i = 0; ok && i < rec_n; i++) { int16_t v[3] = { rec_buf[i].x, rec_buf[i].y, rec_buf[i].z }; ok = f.write((uint8_t*)v, 6) == 6; }
  f.close(); return ok;
}
long hal_file_free() { return LittleFS.begin(true) ? (long)LittleFS.totalBytes() - (long)LittleFS.usedBytes() : 0; }
void hal_rec_start(uint16_t max_sec) {
  if (!rec_buf) rec_buf = (RecSample*)malloc(sizeof(RecSample) * REC_MAX);
  if (!rec_buf) { hal_log("[錄製] 記憶體不足(最大連續 %u,需要 %u)", ESP.getMaxAllocHeap(), (unsigned)(sizeof(RecSample) * REC_MAX)); return; }
  rec_n = 0; rec_start = millis(); rec_until = rec_start + (uint32_t)max_sec * 1000; rec_on = true;
}
int hal_rec_stop(const char* path) { if (!rec_on && !rec_n) return 0; rec_on = false; bool ok = rec_save(path); hal_log("[錄製] 停止,%d 筆,%s %s", rec_n, ok ? "已存" : "存檔失敗", path); return rec_n; }
int hal_rec_count() { return rec_n; }
static void rec_push(int16_t x, int16_t y, int16_t z) {
  if (!rec_on) return;
  uint32_t now = millis();
  if (now < rec_start) return;
  if (now >= rec_until || rec_n >= REC_MAX) {
    rec_on = false;
    bool saved = rec_save("/walk.bin");
    hal_tone(1200, 400); Serial.printf("[錄製] 結束,%d 筆,%s\n", rec_n, saved ? "已存進快閃記憶體 /walk.bin" : "存檔失敗");
    return;
  }
  if (rec_n == 0) { hal_tone(2000, 120); Serial.println("[錄製] 開始"); rec_last = now; }
  rec_buf[rec_n++] = { x, y, z }; rec_last = now;   // FIFO 一批讀回很多筆,固定 50 Hz
}
static void adxl_write(uint8_t reg, uint8_t val) { Wire.beginTransmission(ADXL_ADDR); Wire.write(reg); Wire.write(val); Wire.endTransmission(); }
bool hal_accel_begin() {
  Wire.begin(PIN_SDA, PIN_SCL, 400000);
  Wire.beginTransmission(ADXL_ADDR); Wire.write(0x00);                 // DEVID
  if (Wire.endTransmission(false) != 0 || Wire.requestFrom(ADXL_ADDR, (uint8_t)1) != 1) return false;
  uint8_t id = Wire.read();
  if (id != 0xE5) { hal_log("[ADXL345] 裝置代碼錯誤 0x%02X(應為 0xE5)", id); return false; }
  adxl_write(0x2D, 0x00);   // POWER_CTL:先待機再改設定
  adxl_write(0x2C, 0x0A);   // BW_RATE:100 Hz(輕敲偵測要夠快;計步用的 50 Hz 由兩兩平均得到)
  adxl_write(0x31, 0x08);   // DATA_FORMAT:全解析度、±2g → 3.9 mg/LSB
  // 輕敲兩下(只讀 INT_SOURCE,不接中斷腳):門檻 2.5 g、單次 ≤ 20 ms、兩下間隔 80~300 ms
  adxl_write(0x1D, 40);     // THRESH_TAP:62.5 mg/LSB → 2.5 g
  adxl_write(0x21, 32);     // DUR:625 µs/LSB → 20 ms(腳跟著地的衝擊比較長,會被擋掉)
  adxl_write(0x22, 64);     // LATENT:1.25 ms/LSB → 80 ms
  adxl_write(0x23, 240);    // WINDOW:1.25 ms/LSB → 300 ms
  adxl_write(0x2A, 0x07);   // TAP_AXES:X、Y、Z 都偵測
  adxl_write(0x2E, 0x20);   // INT_ENABLE:DOUBLE_TAP(腳沒接,只是讓 INT_SOURCE 會記錄)
  adxl_write(0x38, 0x80 | 31);   // FIFO_CTL:串流模式,存最近 32 筆(100 Hz → 320 ms)
  adxl_write(0x2D, 0x08);   // POWER_CTL:量測模式
  return true;
}
static uint8_t adxl_read8(uint8_t reg) {
  Wire.beginTransmission(ADXL_ADDR); Wire.write(reg);
  if (Wire.endTransmission(false) != 0 || Wire.requestFrom(ADXL_ADDR, (uint8_t)1) != 1) return 0;
  return Wire.read();
}
static uint32_t pf_fifo_reads = 0, pf_fifo_full = 0, pf_taps = 0, pf_fifo_max = 0, pf_fifo_err = 0, pf_fifo_last_n = 0, pf_fifo_last_got = 0, pf_fifo_last_out = 0, pf_fifo_total = 0;
static bool half_have = false; static int16_t half_x, half_y, half_z;   // 兩兩平均:上一批留下的奇數筆
int hal_accel_read_batch(float* xyz, int max, uint8_t* events) {
  *events = 0;
  uint8_t src = adxl_read8(0x30);                     // INT_SOURCE(讀了就清除)
  if (src & 0x20) { *events |= 1; pf_taps++; }
  int n = adxl_read8(0x39) & 0x3F;                    // FIFO_STATUS:目前存了幾筆
  pf_fifo_reads++; pf_fifo_total++; if (n >= 32) pf_fifo_full++; if (n > pf_fifo_max) pf_fifo_max = n;
  int out = 0, got = 0;
  for (int i = 0; i < n; i++) {
    Wire.beginTransmission(ADXL_ADDR); Wire.write(0x32);
    if (Wire.endTransmission(false) != 0 || Wire.requestFrom(ADXL_ADDR, (uint8_t)6) != 6) { pf_fifo_err++; break; }
    got++;
    uint8_t b[6]; for (int k = 0; k < 6; k++) b[k] = Wire.read();
    int16_t x = (int16_t)(b[0] | (b[1] << 8)), y = (int16_t)(b[2] | (b[3] << 8)), z = (int16_t)(b[4] | (b[5] << 8));
    if (!half_have) { half_x = x; half_y = y; half_z = z; half_have = true; continue; }
    half_have = false;
    int16_t mx = (half_x + x) / 2, my = (half_y + y) / 2, mz = (half_z + z) / 2;
    rec_push(mx, my, mz);
    if (out < max) { xyz[out * 3] = mx / 256.0f; xyz[out * 3 + 1] = my / 256.0f; xyz[out * 3 + 2] = mz / 256.0f; out++; }
  }
  pf_fifo_last_n = n; pf_fifo_last_got = got; pf_fifo_last_out = out;
  return out;
}
uint16_t hal_accel_batch_ms() { return 100; }   // FIFO 存 320 ms;每 100 ms 讀一次,留 220 ms 給寫檔等偶發的慢動作
bool hal_accel_read(float* ax, float* ay, float* az) {
  Wire.beginTransmission(ADXL_ADDR); Wire.write(0x32);
  if (Wire.endTransmission(false) != 0 || Wire.requestFrom(ADXL_ADDR, (uint8_t)6) != 6) return false;
  uint8_t b[6]; for (int i = 0; i < 6; i++) b[i] = Wire.read();   // 先依序讀進陣列(| 兩邊的求值順序 C++ 沒有保證)
  int16_t x = (int16_t)(b[0] | (b[1] << 8)), y = (int16_t)(b[2] | (b[3] << 8)), z = (int16_t)(b[4] | (b[5] << 8));
  *ax = x / 256.0f; *ay = y / 256.0f; *az = z / 256.0f;
  return true;
}
#endif

// ---------- 蜂鳴器 ----------
// 用 LEDC 自己發方波:ledcWriteTone 設頻率(10 位元、50% 占空比),再用占空比當音量;到時間由 poll 關掉
static uint8_t  tone_level = 2; static uint32_t tone_end = 0;
static const uint16_t TONE_DUTY[4] = { 0, 12, 90, 512 };
void hal_tone_volume(uint8_t level) { tone_level = level > 3 ? 3 : level; }
void hal_tone(uint16_t hz, uint16_t ms) {
  if (!tone_level || !hz) { hal_tone_stop(); return; }
  ledcWriteTone(PIN_BUZZER, hz); ledcWrite(PIN_BUZZER, TONE_DUTY[tone_level]); tone_end = millis() + ms;
}
void hal_tone_stop() { ledcWrite(PIN_BUZZER, 0); tone_end = 0; }
static void tone_poll() { if (tone_end && (int32_t)(millis() - tone_end) >= 0) hal_tone_stop(); }

// ---------- 藍牙 BLE(NimBLE-Arduino 2.5.1,Apache-2.0)----------
// 2026-10-07:內建 Bluedroid 初始化後只剩 9.7 KB RAM 會當機 → 改用 NimBLE(RAM 少約 100 KB、快閃少一半)。
// 服務 1:標準電池服務 0x180F/電量 0x2A19(手機系統認得)
// 服務 2:自訂計步服務(UUID 前綴 6e3a00xx),步數 uint32(讀+通知)、今天每小時步數 24×uint16、對時(寫入 int32 epoch+int16 時區分鐘)、資訊(UTF-8 文字)
#include <NimBLEDevice.h>
#include <esp_bt.h>
#define PED_SVC   "6e3a0001-5b2f-4c8e-9a51-7d3f2b1c0a00"
#define PED_STEPS "6e3a0002-5b2f-4c8e-9a51-7d3f2b1c0a00"
#define PED_HOURS "6e3a0003-5b2f-4c8e-9a51-7d3f2b1c0a00"
#define PED_TIME  "6e3a0004-5b2f-4c8e-9a51-7d3f2b1c0a00"
#define PED_INFO  "6e3a0005-5b2f-4c8e-9a51-7d3f2b1c0a00"
static bool ble_inited = false, ble_on = false; static volatile bool ble_conn = false;
static NimBLEServer* ble_srv = nullptr; static NimBLECharacteristic *ch_steps, *ch_hours, *ch_info, *ch_batt;
static uint32_t ble_last_steps = 0xFFFFFFFF; static uint8_t ble_last_batt = 255; static uint32_t ble_since = 0;
// 防呆:開藍牙後 15 秒內當機 → 下次開機 hal_ble_crashed() 回 true,app 自動把藍牙關掉,避免一直重開
RTC_NOINIT_ATTR static uint32_t ble_init_mark;
static const uint32_t BLE_MARK = 0xB1E0B1E0;
bool hal_ble_crashed() { bool c = ble_init_mark == BLE_MARK && esp_reset_reason() != ESP_RST_POWERON; ble_init_mark = 0; return c; }
static bool ble_released = false;
void hal_ble_boot(bool wanted) {
  if (wanted) return;
  uint32_t h0 = ESP.getFreeHeap();
  if (esp_bt_controller_mem_release(ESP_BT_MODE_BTDM) == ESP_OK) { ble_released = true; hal_log("[藍牙] 沒開:控制器記憶體還給系統 %u → %u", h0, ESP.getFreeHeap()); }
}
bool hal_ble_needs_reboot() { return ble_released; }
void hal_restart() { Serial.flush(); delay(200); ESP.restart(); }
class SrvCb : public NimBLEServerCallbacks {
  // 連上後繼續廣播,最多同時 2 台(電腦+手機);不然一台連著,其他裝置就掃不到
  void onConnect(NimBLEServer* s, NimBLEConnInfo&) override { ble_conn = true; if (ble_on && s->getConnectedCount() < 2) NimBLEDevice::getAdvertising()->start(); }
  void onDisconnect(NimBLEServer* s, NimBLEConnInfo&, int) override { ble_conn = s->getConnectedCount() > 0; if (ble_on) NimBLEDevice::getAdvertising()->start(); }   // 斷線後繼續廣播
};
class TimeCb : public NimBLECharacteristicCallbacks {
  void onWrite(NimBLECharacteristic* c, NimBLEConnInfo&) override {
    NimBLEAttValue v = c->getValue(); if (v.length() < 4) return;
    int32_t ep; memcpy(&ep, v.data(), 4); int16_t tz = 480; if (v.length() >= 6) memcpy(&tz, v.data() + 4, 2);
    char body[48]; snprintf(body, sizeof body, "epoch=%ld&tz_min=%d", (long)ep, tz);
    HttpReq req = { "POST", "/api/time", body, true }; app_http(req);
  }
};
bool hal_ble_begin(const char* name) {
  if (ble_on) return true;
  if (ble_released) { hal_log("[藍牙] 記憶體已還給系統,要重開機才能開"); return false; }
  uint32_t h0 = ESP.getFreeHeap();
  if (!ble_inited) {
    if (h0 < 60000) { hal_log("[藍牙] 記憶體不足(剩 %u),不開", h0); return false; }
    ble_init_mark = BLE_MARK;
    NimBLEDevice::init(name);
    ble_srv = NimBLEDevice::createServer(); ble_srv->setCallbacks(new SrvCb());
    NimBLEService* bs = ble_srv->createService(NimBLEUUID((uint16_t)0x180F));
    ch_batt = bs->createCharacteristic(NimBLEUUID((uint16_t)0x2A19), NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY);
    bs->start();
    NimBLEService* ps = ble_srv->createService(PED_SVC);
    ch_steps = ps->createCharacteristic(PED_STEPS, NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY);
    ch_hours = ps->createCharacteristic(PED_HOURS, NIMBLE_PROPERTY::READ);
    NimBLECharacteristic* ch_time = ps->createCharacteristic(PED_TIME, NIMBLE_PROPERTY::WRITE); ch_time->setCallbacks(new TimeCb());
    ch_info = ps->createCharacteristic(PED_INFO, NIMBLE_PROPERTY::READ);
    ps->start();
    NimBLEAdvertising* ad = NimBLEDevice::getAdvertising();
    ad->setName(name); ad->addServiceUUID(PED_SVC); ad->enableScanResponse(true);
    ad->setMinInterval(1600); ad->setMaxInterval(1600);   // 0.625 ms × 1600 = 1 秒廣播一次(省電)
    ble_inited = true;
    hal_log("[藍牙] 初始化完成:記憶體 %u → %u(最大連續 %u)", h0, ESP.getFreeHeap(), ESP.getMaxAllocHeap());
  }
  NimBLEDevice::getAdvertising()->start(); ble_on = true; ble_last_steps = 0xFFFFFFFF; ble_last_batt = 255; ble_since = millis();
  hal_log("[藍牙] 開啟,名稱 %s(免費記憶體 %u)", name, ESP.getFreeHeap());
  return true;
}
void hal_ble_stop() {
  if (!ble_on) return; ble_on = false;
  NimBLEDevice::getAdvertising()->stop();
  if (ble_srv) for (auto h : ble_srv->getPeerDevices()) ble_srv->disconnect(h);
  uint32_t h0 = ESP.getFreeHeap();
  NimBLEDevice::deinit(true); ble_inited = false; ble_srv = nullptr; ble_conn = false;   // 卸載堆疊,把記憶體讓給 Wi-Fi
  hal_log("[藍牙] 關閉並卸載:記憶體 %u → %u", h0, ESP.getFreeHeap());
}
bool hal_ble_active() { return ble_on; }
bool hal_ble_connected() { return ble_on && ble_conn; }
void hal_ble_update(uint32_t steps, uint8_t batt, const uint16_t* hours, const char* info) {
  if (!ble_on) return;
  if (ble_init_mark == BLE_MARK && millis() - ble_since > 15000) ble_init_mark = 0;   // 穩定跑了 15 秒,解除防呆記號
  if (steps != ble_last_steps) { ch_steps->setValue((uint8_t*)&steps, 4); if (ble_conn) ch_steps->notify(); ble_last_steps = steps; }
  if (batt != ble_last_batt) { ch_batt->setValue(&batt, 1); if (ble_conn) ch_batt->notify(); ble_last_batt = batt; }
  ch_hours->setValue((uint8_t*)hours, 48);
  ch_info->setValue((uint8_t*)info, strlen(info));
}

// ---------- 儲存 ----------
bool hal_file_save(const char* path, const void* data, size_t len) {
  if (!LittleFS.begin(true)) return false;
  File f = LittleFS.open(path, "w"); if (!f) return false;
  bool ok = f.write((const uint8_t*)data, len) == len; f.close(); return ok;
}
int hal_file_load(const char* path, void* buf, size_t max) {
  if (!LittleFS.begin(true) || !LittleFS.exists(path)) return -1;
  File f = LittleFS.open(path, "r"); if (!f) return -1;
  int n = f.read((uint8_t*)buf, max); f.close(); return n;
}
void hal_file_remove(const char* path) { if (LittleFS.begin(true) && LittleFS.exists(path)) LittleFS.remove(path); }
static Preferences prefs;
bool hal_kv_get(const char* key, void* buf, size_t len) {
  if (!prefs.isKey(key)) return false;
  size_t n = prefs.getBytesLength(key); if (n == 0 || n > len) return false;
  return prefs.getBytes(key, buf, n) == n;
}
void hal_kv_set(const char* key, const void* buf, size_t len) { prefs.putBytes(key, buf, len); }
void hal_kv_remove(const char* key) { prefs.remove(key); }

// ---------- 電池 ----------
// T-Display 的分壓電路由 GPIO14(ADC_EN)控制:要先拉高,GPIO34 讀到的才是電池電壓(官方腳位圖)
static const int PIN_ADC_EN = 14;
// 分壓節點(100k:100k)上還掛著一顆 1 µF 電容,時間常數約 50 ms:量前才拉高 ADC_EN 會讀到還沒充飽的 0 V。
// 改成開機就一直拉高(官方範例做法,多耗約 21 µA),只在深度睡眠前拉低;每次取 4 筆平均。
float hal_battery_volts() {
  uint32_t mv = 0; for (int i = 0; i < 4; i++) mv += analogReadMilliVolts(PIN_VBAT);
  return mv / 4 * 2.0f / 1000.0f;
}

// ---------- 網路 + HTTP ----------
static WebServer server(80);
static DNSServer dns;                       // 強制門戶:所有網域都答成自己的 IP
static String ip_str = "(網路關閉)", mac_str;
static bool sta_ok = false, net_active = false;

static String rebuild_body() {        // WebServer 已把表單拆成 args,拼回 k=v&k=v 給 app 解析
  String b;
  for (int i = 0; i < server.args(); i++) {
    if (server.argName(i) == "plain") continue;
    if (b.length()) b += '&';
    b += server.argName(i); b += '=';
    String v = server.arg(i);
    for (size_t j = 0; j < v.length(); j++) {     // 只轉 & = % + 四個字元,其餘原樣(app 會解 %XX)
      char c = v[j];
      if (c == '&' || c == '=' || c == '%' || c == '+') { char h[4]; snprintf(h, 4, "%%%02X", (uint8_t)c); b += h; } else b += c;
    }
  }
  if (!b.length() && server.hasArg("plain")) b = server.arg("plain");
  return b;
}
static bool is_probe(const String& u) {     // 各家手機/電腦偵測「有沒有網際網路」的網址
  return u == "/hotspot-detect.html" || u == "/library/test/success.html" || u.startsWith("/generate_204") || u == "/gen_204" ||
         u == "/connecttest.txt" || u == "/ncsi.txt" || u == "/redirect" || u == "/canonical.html" || u == "/success.txt" ||
         u.startsWith("/fwlink") || u == "/check_network_status.txt" || u == "/chat";
}
// ---------- OTA(Wi-Fi 更新):GET /update 給表單;POST /update 收 multipart 的 .bin;OPTIONS 給 IDE 頁的 CORS 預檢
static const char UPDATE_FORM[] PROGMEM =
  "<!doctype html><html lang=zh-Hant><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'><title>韌體更新</title>"
  "<body style='font-family:sans-serif;padding:16px;max-width:520px;line-height:1.7'><h2>韌體更新(OTA)</h2>"
  "<p>選 <code>pedometer_lab.ino.bin</code>(IDE 頁「存檔並編譯」產生,或網站 firmware/app.bin),按「更新」。約 30~60 秒,完成後自動重開機;設定與步數都在 NVS,不會消失。</p>"
  "<form method=POST action=/update enctype=multipart/form-data><input type=file name=fw accept=.bin required><br><br><button style='padding:8px 16px'>更新</button></form>"
  "<p style='color:#666'>帳號 admin,密碼同設定頁。</p></body></html>";
static bool ota_routes_done = false, ota_auth_fail = false;
static void cors() { server.sendHeader("Access-Control-Allow-Origin", "*"); server.sendHeader("Access-Control-Allow-Headers", "Authorization, Content-Type"); server.sendHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); }
static void ota_routes() {
  if (ota_routes_done) return; ota_routes_done = true;
  server.on("/update", HTTP_GET, []() { server.send_P(200, "text/html; charset=utf-8", UPDATE_FORM); });
  server.on("/update", HTTP_OPTIONS, []() { cors(); server.send(204, "text/plain", ""); });
  server.on("/update", HTTP_POST, []() {
    cors();
    if (ota_auth_fail) { ota_auth_fail = false; return server.requestAuthentication(BASIC_AUTH, "計步器"); }
    bool ok = !Update.hasError() && Update.isFinished();
    server.send(ok ? 200 : 500, "text/plain; charset=utf-8", ok ? "更新完成,重新開機中…" : String("更新失敗:") + Update.errorString());
    if (ok) { app_flush(); hal_log("[OTA] 重開機"); Serial.flush(); delay(300); ESP.restart(); }
  }, []() {
    HTTPUpload& up = server.upload();
    if (up.status == UPLOAD_FILE_START) {
      if (!server.authenticate("admin", app_web_password())) { ota_auth_fail = true; hal_log("[OTA] 密碼錯誤,拒絕"); return; }
      hal_log("[OTA] 開始接收 %s", up.filename.c_str());
      if (!Update.begin(UPDATE_SIZE_UNKNOWN)) hal_log("[OTA] 無法開始:%s", Update.errorString());
    } else if (ota_auth_fail) {
      return;
    } else if (up.status == UPLOAD_FILE_WRITE) {
      if (Update.write(up.buf, up.currentSize) != up.currentSize) hal_log("[OTA] 寫入錯誤:%s", Update.errorString());
    } else if (up.status == UPLOAD_FILE_END) {
      if (Update.end(true)) hal_log("[OTA] 收完 %u bytes,驗證通過", up.totalSize); else hal_log("[OTA] 失敗:%s", Update.errorString());
    } else if (up.status == UPLOAD_FILE_ABORTED) { Update.abort(); hal_log("[OTA] 中止"); }
  });
}
static void on_request() {
  String uri = server.uri(); String host = server.hostHeader();
  // 不是對我們的 IP 發的請求(例如 captive.apple.com),或是探測網址 → 302 轉到儀表板,手機就會跳出登入頁
  if (is_probe(uri) || (host.length() && host != ip_str && host != "192.168.4.1")) {
    server.sendHeader("Location", String("http://") + ip_str + "/", true); server.send(302, "text/plain", ""); return;
  }
  // 只有「會改資料」的 API 要密碼;儀表板、狀態、時間同步讓連上熱點的人直接用(強制門戶視窗不一定能跳 Basic Auth)
  bool sensitive = (server.method() == HTTP_POST && (uri == "/api/settings" || uri == "/api/reset_steps"));
  if (sensitive && !server.authenticate("admin", app_web_password())) return server.requestAuthentication(BASIC_AUTH, "計步器");
  String body = rebuild_body();
  HttpReq req = { server.method() == HTTP_POST ? "POST" : "GET", uri.c_str(), body.c_str(), true };
  HttpResp r = app_http(req);
  server.sendHeader("Cache-Control", "no-store");
  server.send_P(r.code, r.type, r.body, r.len);
}
void hal_net_begin(const char* ap_ssid, const char* ap_pass, const char* sta_ssid, const char* sta_pass) {
  if (net_active) return;
  setCpuFrequencyMhz(160);                                             // Wi-Fi 要 ≥80 MHz,160 比較穩
  WiFi.persistent(false);
  WiFi.mode(WIFI_AP_STA);
  WiFi.softAP(ap_ssid, ap_pass, 1, 0, 4);
  if (sta_ssid && *sta_ssid) {
    hal_log("[網路] 連線到 %s …", sta_ssid);
    WiFi.begin(sta_ssid, sta_pass);
    for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(200);
    sta_ok = WiFi.status() == WL_CONNECTED;
    if (!sta_ok) hal_log("[網路] 連不上家用網路,改用熱點");
  }
  ip_str = (sta_ok ? WiFi.localIP() : WiFi.softAPIP()).toString();
  if (sta_ok && MDNS.begin("pedometer")) { MDNS.addService("http", "tcp", 80); hal_log("[網路] 家用網路 IP %s(也可用 http://pedometer.local/)", ip_str.c_str()); }
  mac_str = WiFi.macAddress();
  dns.setErrorReplyCode(DNSReplyCode::NoError);
  dns.start(53, "*", WiFi.softAPIP());                                 // 熱點端一律啟動;STA 模式下對熱點客戶端仍有效
  ota_routes();
  server.onNotFound(on_request);
  server.begin(); net_active = true;
  hal_log("[網頁] 伺服器啟動於 port 80");
}
void hal_net_stop() {
  if (!net_active) return;
  MDNS.end(); server.stop(); dns.stop();
  WiFi.softAPdisconnect(true); WiFi.disconnect(true); WiFi.mode(WIFI_OFF);
  sta_ok = false; net_active = false; ip_str = "(網路關閉)";
  setCpuFrequencyMhz(80);                                              // 沒有網路時 80 MHz 就夠,發熱與耗電都降
}
bool hal_net_active() { return net_active; }
const char* hal_net_ip() { return ip_str.c_str(); }
bool hal_net_sta_connected() { return sta_ok; }
const char* hal_net_mac() { return mac_str.c_str(); }
// 序列埠指令(115200):
//   time <epoch秒> [時區分鐘]   用電腦時間同步(和網頁 POST /api/time 同一條路徑)
//   status                      印出 /api/status 的 JSON
// ---------- 效能統計:每秒的迴圈次數、忙碌時間、最長一圈(序列埠 perf)
static uint32_t pf_loops = 0, pf_busy = 0, pf_max = 0, pf_t0 = 0, pf_all = 0, pf_last_all = 0;
static bool pf_ignore = false;   // 這一圈在跑量測/除錯指令(perf、plog、dump…),不算進統計
static uint32_t pf_last_loops = 0, pf_last_busy = 0, pf_last_max = 0;
static uint32_t pf_last_push = 0, pf_last_skip = 0, pf_last_fifo = 0;
static uint32_t pf_sleep_ms = 0, pf_last_sleep = 0, pf_sleep_total = 0, pf_sleep_count = 0;   // light sleep 毫秒數(每秒/累計)
static uint32_t pf_idle_us = 0;   // 這一圈在 hal_idle 休息了多久(從 CPU 活動時間扣掉)
// plog:每分鐘一筆,存 /plog.bin(拔 USB 跑電池也記得到),最多 4000 筆(約 2.7 天)後重頭
struct PlogRec { uint32_t up_s; uint16_t mv; int16_t temp10; uint16_t busy_ms; uint16_t push; uint8_t flags; uint8_t bl; uint16_t loops; uint16_t sleep_ms; uint16_t steps_min; };
static uint32_t plog_next = 60000;
static void plog_tick(uint32_t now) {
  if (now < plog_next) return; plog_next = now + 60000;
  PlogRec r = { now / 1000, (uint16_t)(hal_battery_volts() * 1000), (int16_t)(temperatureRead() * 10), (uint16_t)(psave ? pf_last_all / 1000 : 1000), (uint16_t)pf_last_push,
                (uint8_t)((psave ? 1 : 0) | (net_active ? 2 : 0) | (scr_power ? 4 : 0)), cur_bl, (uint16_t)(pf_last_loops > 65535 ? 65535 : pf_last_loops), (uint16_t)pf_last_sleep, 0 };
  if (!LittleFS.begin(true)) return;
  File f = LittleFS.open("/plog.bin", "a"); if (!f) return;
  if (f.size() % sizeof(PlogRec)) { f.close(); LittleFS.remove("/plog.bin"); f = LittleFS.open("/plog.bin", "a"); }   // 舊格式
  if (f.size() >= sizeof(PlogRec) * 1500) { f.close(); LittleFS.remove("/plog.bin"); f = LittleFS.open("/plog.bin", "a"); }
  f.write((uint8_t*)&r, sizeof r); f.close();
}
void hal_perf_loop(uint32_t us) {
  if (pf_ignore) { pf_ignore = false; }
  else { uint32_t w = us > pf_idle_us ? us - pf_idle_us : 0; pf_loops++; pf_all += w; if (w > 300) pf_busy += w; if (w > pf_max) pf_max = w; }
  pf_idle_us = 0;   // > 300 µs 才算「有在做事」,其餘是空轉
  uint32_t now = millis();
  if (now - pf_t0 >= 1000) { pf_last_sleep = pf_sleep_ms; pf_sleep_ms = 0; pf_last_fifo = pf_fifo_reads; pf_fifo_reads = 0;
                            pf_last_loops = pf_loops; pf_last_busy = pf_busy; pf_last_max = pf_max; pf_last_push = pf_push; pf_last_skip = pf_skip; pf_last_all = pf_all;
                            pf_loops = pf_busy = pf_max = pf_push = pf_skip = pf_all = 0; pf_t0 = now; }
  plog_tick(now);
}
static void perf_report(bool fs = false) {
  size_t fs_total = 0, fs_used = 0; if (fs && LittleFS.begin(true)) { fs_total = LittleFS.totalBytes(); fs_used = LittleFS.usedBytes(); }   // 掃檔案系統要約 200 ms(會讓 FIFO 溢位),只有 perf fs 才做
  nvs_stats_t ns = {}; bool nvs_ok = fs && nvs_get_stats(NULL, &ns) == ESP_OK;
  static uint32_t sk_size = 0, sk_free = 0; if (fs && !sk_size) { sk_size = ESP.getSketchSize(); sk_free = ESP.getFreeSketchSpace(); }   // getSketchSize 會讀整個韌體做驗證(數百 ms),只算一次
  // CPU 活動比例:舊做法(psave 0)主迴圈不停空轉=100%;省電時=每秒所有迴圈的執行時間合計(其餘時間 CPU 在 WAITI 等中斷)
  Serial.printf("PERF {\"psave\":%d,\"cpu_active_pct\":%.1f,\"push_per_s\":%u,\"skip_per_s\":%u,\"backlight\":%u,\"wifi\":%d,", psave, psave ? pf_last_all / 10000.0f : 100.0f, pf_last_push, pf_last_skip, cur_bl, net_active);
  Serial.printf("\"screen_on\":%d,\"sleep_ms_per_s\":%u,\"fifo_reads_per_s\":%u,\"fifo_full_total\":%u,\"fifo_max\":%u,\"taps_total\":%u,\"fifo_err\":%u,\"fifo_last\":[%u,%u,%u],\"sleep_total_ms\":%u,\"sleep_count\":%u,\"fifo_reads_total\":%u,", scr_power, pf_last_sleep, pf_last_fifo, pf_fifo_full, pf_fifo_max, pf_taps, pf_fifo_err, pf_fifo_last_n, pf_fifo_last_got, pf_fifo_last_out, pf_sleep_total, pf_sleep_count, pf_fifo_total); pf_fifo_max = 0;
  Serial.printf("\"cpu_mhz\":%u,\"loops_per_s\":%u,\"busy_ms_per_s\":%.1f,\"max_loop_us\":%u,"
                "\"heap_free\":%u,\"heap_min\":%u,\"heap_max_block\":%u,\"heap_total\":%u,"
                "\"sketch\":%u,\"ota_free\":%u,\"flash\":%u,\"fs_used\":%u,\"fs_total\":%u,"
                "\"nvs_used\":%u,\"nvs_total\":%u,\"chip_temp_c\":%.1f,\"uptime_s\":%lu}\n",
                getCpuFrequencyMhz(), pf_last_loops, pf_last_busy / 1000.0f, pf_last_max,
                ESP.getFreeHeap(), ESP.getMinFreeHeap(), ESP.getMaxAllocHeap(), ESP.getHeapSize(),
                sk_size, sk_free, ESP.getFlashChipSize(), (unsigned)fs_used, (unsigned)fs_total,
                nvs_ok ? (unsigned)ns.used_entries : 0, nvs_ok ? (unsigned)ns.total_entries : 0, temperatureRead(), millis() / 1000);
}
static void serial_commands() {
  static char line[96]; static uint8_t n = 0;
  while (Serial.available()) {
    char c = Serial.read();
    if (c == '\n' || c == '\r') {
      if (n) {
        line[n] = 0; n = 0; pf_ignore = true;
        if (!strncmp(line, "rec ", 4)) {
          int sec = atoi(line + 4); if (sec < 5) sec = 5; if (sec > 120) sec = 120;
          if (!rec_buf) rec_buf = (RecSample*)malloc(sizeof(RecSample) * REC_MAX);
          if (!rec_buf) { Serial.println("[錄製] 記憶體不足"); }
          else { rec_n = 0; rec_start = millis() + 3000; rec_until = rec_start + sec * 1000; rec_on = true;
                 Serial.printf("[錄製] 3 秒後開始,錄 %d 秒(嗶一聲開始、低音結束)\n", sec); }
        } else if (!strcmp(line, "dump") || !strncmp(line, "dump ", 5)) {    // dump=/walk.bin(序列埠 rec 的);dump N=走路測試第 N 段 /wN.bin
          char path[16]; if (line[4]) snprintf(path, sizeof path, "/w%d.bin", atoi(line + 5)); else strcpy(path, "/walk.bin");
          File f; if (LittleFS.begin(true) && LittleFS.exists(path)) f = LittleFS.open(path, "r");
          uint16_t hdr[4] = {0}; bool nf = f && f.read((uint8_t*)hdr, 8) == 8 && hdr[0] == 0x4157 && hdr[1] == 0x4B4C;
          if (f && !nf) f.seek(0);
          int n = !f ? 0 : nf ? hdr[2] : (int)(f.size() / sizeof(RecSampleOld));
          Serial.printf("REC_BEGIN %d %s\n", n, path); uint32_t t = 0;
          for (int i = 0; i < n; i++) {
            int16_t x, y, z;
            if (nf) { int16_t v[3]; if (f.read((uint8_t*)v, 6) != 6) break; x = v[0]; y = v[1]; z = v[2]; t = i * 20; }
            else { RecSampleOld r; if (f.read((uint8_t*)&r, sizeof r) != sizeof r) break; t += r.dt; x = r.x; y = r.y; z = r.z; }
            Serial.printf("%lu,%d,%d,%d\n", (unsigned long)t, x, y, z); if (i % 50 == 0) delay(2);
          }
          if (f) f.close(); Serial.println("REC_END");
        } else if (!strncmp(line, "time ", 5)) {
          unsigned long ep = 0; int tz = 480; sscanf(line + 5, "%lu %d", &ep, &tz);
          char body[48]; snprintf(body, sizeof body, "epoch=%lu&tz_min=%d", ep, tz);
          HttpReq req = { "POST", "/api/time", body, true }; HttpResp r = app_http(req);
          Serial.printf("[序列埠] time → %d %s\n", r.code, r.body);
        } else if (!strcmp(line, "off")) { Serial.println("[序列埠] 關機(深度睡眠),按上鍵喚醒"); app_power_off();
        } else if (!strncmp(line, "get ", 4)) {                             // 讀網頁 API:get /api/minutes?day=… 
          char path[64]; const char* q = strchr(line + 4, '?'); size_t pl = q ? (size_t)(q - (line + 4)) : strlen(line + 4); if (pl > 63) pl = 63;
          memcpy(path, line + 4, pl); path[pl] = 0;
          HttpReq req = { "GET", path, q ? q + 1 : "", true }; HttpResp r = app_http(req); Serial.write((const uint8_t*)r.body, r.len); Serial.println();
        } else if (!strncmp(line, "set ", 4)) {                             // 直接改設定:set scr_off=1、set brightness=30 …
          HttpReq req = { "POST", "/api/settings", line + 4, true }; HttpResp r = app_http(req); Serial.printf("[序列埠] set → %d %s\n", r.code, r.body);
        } else if (!strncmp(line, "sleeptest ", 10)) { app_sleep_test(atoi(line + 10));
        } else if (!strcmp(line, "ls")) {                                   // 列出檔案系統
          if (LittleFS.begin(true)) { File root = LittleFS.open("/"); for (File f = root.openNextFile(); f; f = root.openNextFile()) Serial.printf("FILE %s %u\n", f.name(), (unsigned)f.size()); }
          Serial.println("LS_END");
        } else if (!strcmp(line, "reboot")) { Serial.println("[序列埠] 重新開機"); app_flush(); hal_restart();
        } else if (!strncmp(line, "key ", 4)) {                             // 模擬按鍵:l 左、r 右、s 同按、L 長按
          char k = line[4]; app_key(k == 'l' ? 1 : k == 'r' ? 2 : k == 's' ? 3 : k == 'L' ? 4 : 0);
        } else if (!strcmp(line, "perf")) { perf_report();
        } else if (!strcmp(line, "perf fs")) { perf_report(true);
        } else if (!strncmp(line, "ps ", 3)) {                              // 省電總開關(存進設定):ps 0 全關、ps 1 全開
          char body[16]; snprintf(body, sizeof body, "psave=%d", atoi(line + 3) ? 1 : 0);
          HttpReq req = { "POST", "/api/settings", body, true }; HttpResp r = app_http(req); Serial.printf("[序列埠] ps → %d %s\n", r.code, r.body);
        } else if (!strcmp(line, "plog clear")) { if (LittleFS.begin(true)) LittleFS.remove("/plog.bin"); Serial.println("[plog] 已清除");
        } else if (!strcmp(line, "plog")) {
          File f; if (LittleFS.begin(true)) f = LittleFS.open("/plog.bin", "r");
          int n = f ? f.size() / sizeof(PlogRec) : 0; Serial.printf("PLOG_BEGIN %d\nup_s,mv,temp_c,cpu_active_ms,push,psave,wifi,screen_on,backlight,loops,sleep_ms\n", n); PlogRec r;
          for (int i = 0; i < n && f.read((uint8_t*)&r, sizeof r) == sizeof r; i++) { Serial.printf("%lu,%u,%.1f,%u,%u,%d,%d,%d,%u,%u,%u\n", (unsigned long)r.up_s, r.mv, r.temp10 / 10.0f, r.busy_ms, r.push, r.flags & 1, (r.flags >> 1) & 1, (r.flags >> 2) & 1, r.bl, r.loops, r.sleep_ms); if (i % 40 == 0) delay(2); }
          if (f) f.close(); Serial.println("PLOG_END");
        } else if (!strncmp(line, "rottest", 7)) {                          // 轉動動畫自測:0→180→0,每步印最大連續記憶體與耗時
          for (int k = 0; k <= 12; k++) { float d = k <= 6 ? k * 30.0f : (12 - k) * 30.0f; uint32_t t0 = micros();
            float r = d * 3.14159265f / 180, c = fabsf(cosf(r)), sn = fabsf(sinf(r)), z1 = SCREEN_W / (SCREEN_W * c + SCREEN_H * sn), z2 = SCREEN_H / (SCREEN_W * sn + SCREEN_H * c);
            hal_gfx_set_rotation(d, z1 < z2 ? z1 : z2); hal_gfx_end_frame();
            Serial.printf("ROT %.0f° 推畫面 %lu µs,暫存畫面 %s,最大連續記憶體 %u\n", d, (unsigned long)(micros() - t0), rot_frame ? "有" : "無", ESP.getMaxAllocHeap()); delay(120); }
          hal_gfx_set_rotation(0, 1); hal_gfx_end_frame();
        } else if (!strcmp(line, "settings")) {
          HttpReq req = { "GET", "/api/settings", "", true }; HttpResp r = app_http(req); Serial.println(r.body);
        } else if (!strcmp(line, "status")) {
          HttpReq req = { "GET", "/api/status", "", true }; HttpResp r = app_http(req); Serial.println(r.body);
        } else Serial.println("[序列埠] 指令:time <epoch> [tz_min]、status、settings、get /api/…、set k=v、reboot、sleeptest 秒、perf、key l|r|s|L、ps 0|1、plog、plog clear、rec <秒>、dump、off");
      }
    } else if (n < sizeof line - 1) line[n++] = c;
  }
}
// 主迴圈空檔:省電時休息到下一件事。螢幕關、用電池、沒開無線、沒在響 → light sleep(計時器或按鍵叫醒,FIFO 照樣存資料)
void hal_idle(uint32_t max_ms, bool sleep_ok) {
  if (!psave) return;
  uint32_t t_in = micros();
  if (max_ms < 1) max_ms = 1;
  if (sleep_ok && !tone_end && max_ms >= 10) {
    esp_sleep_enable_timer_wakeup((uint64_t)max_ms * 1000);
    gpio_wakeup_enable((gpio_num_t)PIN_BTN_L, GPIO_INTR_LOW_LEVEL); gpio_wakeup_enable((gpio_num_t)PIN_BTN_R, GPIO_INTR_LOW_LEVEL);
    esp_sleep_enable_gpio_wakeup();
    uint32_t t0 = millis(); Serial.flush(); esp_light_sleep_start(); uint32_t sl = millis() - t0; pf_sleep_ms += sl; pf_sleep_total += sl; pf_sleep_count++;
    gpio_wakeup_disable((gpio_num_t)PIN_BTN_L); gpio_wakeup_disable((gpio_num_t)PIN_BTN_R);
    esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_GPIO); esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_TIMER);
    pf_idle_us += micros() - t_in; return;
  }
  delay(max_ms < 20 ? max_ms : 20);                      // 螢幕開著:最多休息 20 ms,按鍵短按不會漏
  pf_idle_us += micros() - t_in;
}
void hal_esp32_poll() { if (net_active) { dns.processNextRequest(); server.handleClient(); } serial_commands(); tone_poll(); }

// ---------- 電源與 RTC ----------
// 系統時間(settimeofday)存在 RTC 域,深度睡眠期間由 RTC 慢時鐘繼續計,醒來還在;RC 振盪一天約差數分鐘,開網頁會校正。
void hal_rtc_set(uint32_t epoch_s) { struct timeval tv = { (time_t)epoch_s, 0 }; settimeofday(&tv, nullptr); }
uint32_t hal_rtc_get() { struct timeval tv; gettimeofday(&tv, nullptr); return tv.tv_sec > 1600000000 ? (uint32_t)tv.tv_sec : 0; }
bool hal_woke_from_sleep() { return esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_EXT0; }
void hal_power_off() {
  hal_log("[電源] 關機:進入深度睡眠,按上鍵喚醒"); Serial.flush();
  tft.setBrightness(0); tft.sleep();                                  // 螢幕 Sleep In
#ifdef ACCEL_MPU6050
  mpu_write(0x6B, 0x40);                                              // MPU6050 SLEEP
#else
  adxl_write(0x2D, 0x00);                                             // ADXL345 待機(0.1 µA)
#endif
  WiFi.mode(WIFI_OFF);
  digitalWrite(PIN_ADC_EN, LOW); digitalWrite(PIN_TFT_BL, LOW);
  gpio_hold_en((gpio_num_t)PIN_ADC_EN); gpio_hold_en((gpio_num_t)PIN_TFT_BL); gpio_deep_sleep_hold_en();   // 睡眠中維持低電位
  esp_sleep_enable_ext0_wakeup((gpio_num_t)35, 0);                    // 上鍵 GPIO35 是 RTC 腳;不用 GPIO0(開機時按著會進燒錄模式)
  esp_deep_sleep_start();
}

// ---------- 日誌 ----------
void hal_log(const char* fmt, ...) {
  char buf[256]; va_list ap; va_start(ap, fmt); vsnprintf(buf, sizeof buf, fmt, ap); va_end(ap);
  Serial.println(buf);
}

// ---------- 開機初始化(在 app_setup 之前)----------
// Arduino 的 initVariant 弱符號會在 setup() 之前被呼叫,用它把周邊準備好。
void initVariant() {
  setCpuFrequencyMhz(80);                                              // 預設不開網路,80 MHz 省電;開熱點時再升到 160
  Serial.begin(115200); delay(100);
  pinMode(0, INPUT_PULLUP); pinMode(35, INPUT);   // GPIO35 只能輸入、板上有外部上拉
  ledcAttach(PIN_BUZZER, 2000, 10); ledcWrite(PIN_BUZZER, 0);   // 蜂鳴器走 LEDC(音量可調)
  pinMode(PIN_ADC_EN, OUTPUT); digitalWrite(PIN_ADC_EN, HIGH);   // 分壓電路常開
  analogReadResolution(12);
  prefs.begin("pedolab", false);
  gpio_hold_dis((gpio_num_t)PIN_ADC_EN); gpio_hold_dis((gpio_num_t)PIN_TFT_BL);   // 從深度睡眠醒來要解除保持
  tft.init(); tft.setRotation(1); tft.setBrightness(0);
  canvas.setColorDepth(16); canvas.createSprite(SCREEN_W, SCREEN_H);
}

G 遊戲卡帶架構:擲筊、骰子、量角器(已內建在主韌體)

入門~挑戰✓ 主韌體內建;模擬器驗收 5 項通過(啟動卡帶、甩出筊、背景計步不中斷、骰子點數)

主選單多一項「遊戲」,底下每個小遊戲是一個 Cartridge(名稱、提示、begin/update/draw);計步在背景照常跑。已做三個卡帶:擲筊(甩一下→聖/笑/陰筊,各有旋律)、骰子(1~3 顆)、量角器(傾斜角、可歸零)。要加新卡帶只要繼承 Cartridge、在 CARTRIDGES 陣列加一筆——不碰硬體,模擬器就能驗。後面的賽博寵物、擦身而過、傾斜迷宮都可以照這個殼做。

步驟

  1. 開模擬器:同按長按回選單 → 選「遊戲」→ 同按進入 → 選擲筊 → 按「搖晃一下」。
  2. 照 games.cpp 的 Dice 類別寫一個新卡帶(例如猜拳、碼表),加進 CARTRIDGES。
  3. make sim-build 驗過再 make build/make flash。
  4. 進階:寵物卡帶讀 c.steps 當餵食;ESP-NOW「擦身而過」要在 hal.h 加廣播/接收介面,且與 Wi-Fi 同頻道。

程式

firmware/app/games.h 下載 31 行
// 「卡帶」架構:每個小遊戲是一個 Cartridge,掛在主選單「遊戲」底下;計步照常在背景跑。
// 新增卡帶:繼承 Cartridge、在 games.cpp 的 CARTRIDGES 陣列加一筆。不碰硬體,只用 hal.h 的畫圖函式。
#pragma once
#include <stdint.h>
#include "buttons.h"
#include "buzzer.h"

struct GameCtx {
  uint32_t now;            // 毫秒
  float    ax, ay, az;     // g
  float    mag;            // |a|
  float    pitch, roll;    // 度(已平滑)
  BtnEvent ev;             // 這一圈的按鍵事件(EV_BOTH_* 由框架處理,卡帶只會收到左/右)
  uint32_t steps;          // 今日步數(給步數換資源的玩法)
};

class Cartridge {
public:
  virtual ~Cartridge() {}
  virtual const char* name() const = 0;        // 選單名稱
  virtual const char* hint() const = 0;        // 底部操作提示
  virtual void begin(uint32_t now) = 0;
  virtual void update(const GameCtx& c) = 0;   // 每圈(約 50 Hz)
  virtual void draw() = 0;                     // 畫面主體(標題列與底列由框架畫,可畫區 y 22~118)
  virtual const char* status() const { return ""; }   // 給 /api/status 的 game_state
  // 卡帶要響鈴時設定這兩個,框架會取走
  const Note* melody = nullptr; uint8_t melody_n = 0;
};

Cartridge* const* cartridges(int* n);
Cartridge* user_cartridge();                 // 學生自己的卡帶(cart_user.cpp,IDE 頁可改)
firmware/app/games.cpp 下載 105 行
#include "games.h"
#include "../hal/hal.h"
#include <stdio.h>
#include <math.h>

// ---------- 共用 ----------
static uint32_t rng_state = 0x2545F491u;
static void rng_mix(uint32_t v) { rng_state ^= v; rng_state ^= rng_state << 13; rng_state ^= rng_state >> 17; rng_state ^= rng_state << 5; }
static uint32_t rng() { rng_mix(0x9E3779B9u); return rng_state; }

// 甩動偵測:|a| 偏離 1 g 超過門檻算「甩動中」,甩完靜止一段時間才出結果(避免連續出好幾次)
class ShakeDetector {
public:
  // 回傳 true 表示「剛剛甩完、可以出結果」
  bool feed(float mag, uint32_t now) {
    rng_mix((uint32_t)(mag * 100000) ^ now);                     // 用真實雜訊攪亂亂數
    bool strong = fabsf(mag - 1.0f) > 0.6f;
    if (strong) { shaking_ = true; last_strong_ = now; peak_ = fmaxf(peak_, fabsf(mag - 1.0f)); }
    if (shaking_ && now - last_strong_ > 350) { shaking_ = false; float p = peak_; peak_ = 0; return p > 0.6f; }
    return false;
  }
  bool shaking() const { return shaking_; }
private:
  bool shaking_ = false; uint32_t last_strong_ = 0; float peak_ = 0;
};

static const uint16_t C_TEXT = RGB565(240, 244, 250), C_DIM = RGB565(130, 140, 160), C_CYAN = RGB565(0, 220, 240),
                      C_RED = RGB565(220, 60, 60), C_WOOD = RGB565(190, 80, 60), C_WOOD_D = RGB565(120, 45, 35), C_GREEN = RGB565(60, 230, 110),
                      C_ORANGE = RGB565(255, 170, 0), C_WHITE = RGB565(250, 250, 250), C_BG = RGB565(10, 14, 24);

static const Note MEL_SHENG[] = { {1319, 120}, {1568, 120}, {2093, 350} };
static const Note MEL_XIAO[]  = { {1568, 100}, {1319, 100}, {1568, 100}, {1319, 100}, {1568, 250} };
static const Note MEL_YIN[]   = { {523, 300}, {392, 450} };
static const Note MEL_DICE[]  = { {1760, 60}, {2093, 60}, {2637, 120} };

// ---------- 卡帶 1:擲筊 ----------
// 兩塊筊各自隨機「平面朝上」或「凸面朝上」:一平一凸=聖筊(50%),兩平=笑筊(25%),兩凸=陰筊(25%)
class Jiaobei : public Cartridge {
public:
  const char* name() const override { return "擲筊"; }
  const char* hint() const override { return "用力甩一下裝置 同按 返回 長按 選單"; }
  void begin(uint32_t) override { result_ = 0; count_ = 0; }
  void update(const GameCtx& c) override {
    if (det_.feed(c.mag, c.now)) {
      a_ = rng() & 1; b_ = rng() & 1; count_++;
      result_ = (a_ != b_) ? 1 : (a_ ? 2 : 3);          // 1 聖 2 笑(兩平)3 陰(兩凸)
      if (result_ == 1) { melody = MEL_SHENG; melody_n = 3; } else if (result_ == 2) { melody = MEL_XIAO; melody_n = 5; } else { melody = MEL_YIN; melody_n = 2; }
      hal_log("[擲筊] 第 %u 次:%s", count_, status());
    }
  }
  void draw() override {
    if (det_.shaking()) { hal_gfx_text(SCREEN_W / 2, 60, "擲出去…", FONT_L, C_ORANGE, AL_CENTER); return; }
    if (!result_) { hal_gfx_text(SCREEN_W / 2, 40, "心中默念問題", FONT_M, C_TEXT, AL_CENTER); hal_gfx_text(SCREEN_W / 2, 66, "然後用力甩一下", FONT_M, C_DIM, AL_CENTER); return; }
    draw_block(60, 70, a_); draw_block(180, 70, b_);
    static const char* NAMES[] = { "", "聖筊", "笑筊", "陰筊" };
    static const char* MEAN[]  = { "", "神明應允", "神明笑而不答,再問一次", "神明不允,換個問法" };
    hal_gfx_text(SCREEN_W / 2, 24, NAMES[result_], FONT_L, result_ == 1 ? C_GREEN : result_ == 2 ? C_ORANGE : C_RED, AL_CENTER);
    hal_gfx_text(SCREEN_W / 2, 100, MEAN[result_], FONT_S, C_DIM, AL_CENTER);
    char b[16]; snprintf(b, sizeof b, "第 %u 次", count_); hal_gfx_text(SCREEN_W - 6, 24, b, FONT_S, C_DIM, AL_RIGHT);
  }
  const char* status() const override { static const char* S[] = { "尚未擲", "聖筊", "笑筊", "陰筊" }; return S[result_]; }
private:
  // 一塊筊:半月形。平面朝上畫成淺色扁長方形(看到平面);凸面朝上畫成深色圓弧狀(用幾層矩形疊出弧度)
  static void draw_block(int cx, int cy, bool flat_up) {
    if (flat_up) { hal_gfx_fill_rect(cx - 36, cy - 10, 72, 20, C_WOOD); hal_gfx_fill_rect(cx - 32, cy - 7, 64, 3, RGB565(230, 150, 120)); hal_gfx_text(cx, cy + 14, "平", FONT_S, C_DIM, AL_CENTER); }
    else { static const int W[] = { 20, 44, 60, 70, 72 }; for (int i = 0; i < 5; i++) hal_gfx_fill_rect(cx - W[i] / 2, cy - 14 + i * 5, W[i], 5, i < 2 ? C_WOOD : C_WOOD_D); hal_gfx_text(cx, cy + 14, "凸", FONT_S, C_DIM, AL_CENTER); }
  }
  ShakeDetector det_; uint8_t result_ = 0; bool a_ = 0, b_ = 0; uint32_t count_ = 0;
};

// ---------- 卡帶 2:骰子 ----------
class Dice : public Cartridge {
public:
  const char* name() const override { return "骰子"; }
  const char* hint() const override { return "甩一下擲骰 上下 換顆數 同按 返回"; }
  void begin(uint32_t) override { rolled_ = false; }
  void update(const GameCtx& c) override {
    if (c.ev == EV_LEFT && n_ < 3) n_++;
    if (c.ev == EV_RIGHT && n_ > 1) n_--;
    if (det_.feed(c.mag, c.now)) { for (int i = 0; i < 3; i++) v_[i] = 1 + rng() % 6; rolled_ = true; melody = MEL_DICE; melody_n = 3; hal_log("[骰子] %d %d %d", v_[0], v_[1], v_[2]); }
  }
  void draw() override {
    if (det_.shaking()) { hal_gfx_text(SCREEN_W / 2, 60, "搖骰中…", FONT_L, C_ORANGE, AL_CENTER); return; }
    char b[24]; snprintf(b, sizeof b, "%d 顆", n_); hal_gfx_text(SCREEN_W - 6, 24, b, FONT_S, C_DIM, AL_RIGHT);
    if (!rolled_) { hal_gfx_text(SCREEN_W / 2, 52, "甩一下擲骰子", FONT_M, C_TEXT, AL_CENTER); return; }
    int total = 0; int x0 = SCREEN_W / 2 - (n_ * 56 - 8) / 2;
    for (int i = 0; i < n_; i++) { draw_die(x0 + i * 56, 44, v_[i]); total += v_[i]; }
    snprintf(b, sizeof b, "合計 %d", total); hal_gfx_text(SCREEN_W / 2, 100, b, FONT_M, C_CYAN, AL_CENTER);
  }
  const char* status() const override { static char s[24]; if (!rolled_) return "尚未擲"; snprintf(s, sizeof s, "%d %d %d", v_[0], v_[1], n_ > 2 ? v_[2] : 0); return s; }
private:
  static void draw_die(int x, int y, int v) {
    hal_gfx_fill_rect(x, y, 48, 48, C_WHITE); hal_gfx_draw_rect(x, y, 48, 48, C_DIM);
    auto pip = [&](int px, int py) { hal_gfx_fill_rect(x + px - 4, y + py - 4, 8, 8, v == 1 ? C_RED : C_BG); };
    if (v % 2) pip(24, 24);
    if (v > 1) { pip(12, 12); pip(36, 36); }
    if (v > 3) { pip(36, 12); pip(12, 36); }
    if (v == 6) { pip(12, 24); pip(36, 24); }
  }
  ShakeDetector det_; int n_ = 2, v_[3] = { 1, 1, 1 }; bool rolled_ = false;
};

static Jiaobei g_jiaobei; static Dice g_dice;
static Cartridge* const CARTRIDGES[] = { &g_jiaobei, &g_dice, user_cartridge() };   // 量角器 2026-10-08 依老師要求移除
Cartridge* const* cartridges(int* n) { *n = sizeof CARTRIDGES / sizeof CARTRIDGES[0]; return CARTRIDGES; }

© 2026 趙珩宇.生活科技 本頁由 tools/build_extensions_page.py 產生。