六個延伸專案,由淺到深。每個都附完整程式與驗證方式;「✓」是這份程式在整理時實際通過的檢查(編譯或單元測試),真機行為仍要自己燒進去確認。
程式在專案資料夾 firmware/extensions/,這頁是同一份的瀏覽版,可直接下載。
兩顆加速度計掛同一條 I2C(0x53/0x68),50 Hz 同步讀、各跑一份相同的計步演算法、按左鍵做六面校正;電腦端腳本算偏移、雜訊、相關係數、步數一致性並畫圖。
make ext NAME=dual_sensor_compare → make flash-ext NAME=dual_sensor_compare PORT=…。python3 tools/compare_sensors.py /dev/cu.usbserial-XXXX --secs 60:先靜置 10 秒、走 30 秒、再靜置。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, ')')
原作說明書寫了但沒做的功能:連續 N 分鐘沒有步數就響鈴,之後每 M 分鐘再提醒;番茄鐘進行中暫停。純 C++,模擬器可直接驗(把速度調到 600× 看 1 小時)。
sedentary.h 複製到 firmware/app/,照檔頭說明在 app.cpp 加三行。buzzer.h 加一段提醒旋律。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;
}
真的走路步伐間隔很規律,搖晃忽快忽慢。多要求最近三個間隔的變異係數 ≤ 25% 才開始計;等距的三下搖晃仍會被計(物理上就是走路步頻),忽快忽慢的會被擋掉。
app.cpp 的 Pedometer 換成 PedometerStable(介面相同)。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;
}
Wi-Fi 是耗電大戶。閒置 5 分鐘自動關、按任意鍵重開;長按左鍵 3 秒進深度睡眠(ESP32 約 10 µA),按右鍵喚醒。這是「軟體關機」,兼顧省電與充電,對照講義 2.1 的開關取捨。
make ext NAME=power_modes 燒進去,序列埠看訊息。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);
}
新版是「裝置當伺服器」,一次只服務幾個人;全班幾十台時要反過來:裝置連家用 Wi-Fi、每分鐘 POST 到電腦,電腦存 CSV 並顯示總表。只用 Python 內建模組。
python3 pc_backend.py,記下電腦 IP。pc_backend.ino 前三行(Wi-Fi、IP),make ext NAME=pc_backend 燒錄。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);
}
}
主韌體已內建兩種讀法:編譯時加 -DACCEL_MPU6050 就改讀 MPU6050(0x68、16384 LSB/g)。接線完全相同,只有讀暫存器那一小段不同——這就是硬體抽象層的價值。
make build-mpu 編譯、make flash PORT=… 前把 Makefile 的 build 改成 build-mpu 或直接用 arduino-cli upload build/out_mpu。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);
}
主選單多一項「遊戲」,底下每個小遊戲是一個 Cartridge(名稱、提示、begin/update/draw);計步在背景照常跑。已做三個卡帶:擲筊(甩一下→聖/笑/陰筊,各有旋律)、骰子(1~3 顆)、量角器(傾斜角、可歸零)。要加新卡帶只要繼承 Cartridge、在 CARTRIDGES 陣列加一筆——不碰硬體,模擬器就能驗。後面的賽博寵物、擦身而過、傾斜迷宮都可以照這個殼做。
games.cpp 的 Dice 類別寫一個新卡帶(例如猜拳、碼表),加進 CARTRIDGES。make sim-build 驗過再 make build/make flash。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 產生。