From 4bf8ac52d26c61b088653504e4bc0d6e74064b1f Mon Sep 17 00:00:00 2001 From: Jose Cruz Toledo <1273555+jctoledo@users.noreply.github.com> Date: Thu, 1 Jan 2026 08:14:44 -0800 Subject: [PATCH 1/7] various updates to autotune feature --- sensors/blackbox/src/websocket_server.rs | 1058 +++++++++++++++++++++- 1 file changed, 1047 insertions(+), 11 deletions(-) diff --git a/sensors/blackbox/src/websocket_server.rs b/sensors/blackbox/src/websocket_server.rs index 1e94344..e3e1c08 100644 --- a/sensors/blackbox/src/websocket_server.rs +++ b/sensors/blackbox/src/websocket_server.rs @@ -267,7 +267,7 @@ body{font-family:-apple-system,system-ui,sans-serif;background:#0a0a0f;color:#f0 .cfg-btn.cfg-save{background:linear-gradient(135deg,#1e3a5f,#1a2d4a);color:#60a5fa} -
DIAG00:00
--
+
⚙ TuneDIAG00:00
--
Mode
IDLE
@@ -332,17 +332,51 @@ const $=id=>document.getElementById(id); const cv=$('gfc'),ctx=cv.getContext('2d'); const CX=70,CY=70,R=55,SCL=R/2; -// Preset definitions based on real-world G-forces (tested values): -// City: gentle-normal inputs (0.10-0.20g accel, 0.15-0.30g brake, 0.10-0.25g lateral) -// Highway: mostly cruising, higher speed threshold to filter parking maneuvers -// Canyon: spirited driving (0.20-0.40g range) -// Track: racing (0.35-0.80g+ range) -const PRESETS={ -track:{acc:0.35,acc_exit:0.17,brake:0.55,brake_exit:0.27,lat:0.50,lat_exit:0.25,yaw:0.15,min_speed:4.0,desc:'Racing/track days'}, +// Scaling factors: multiply city (baseline) thresholds by these for other modes +// Based on physics: track pushes vehicle harder, highway is gentle lane changes +const PROFILE_SCALES={ +track:{acc:2.5,brake:2.0,lat:3.0,yaw:2.5,min_speed:5.0,desc:'Racing/track days'}, +canyon:{acc:1.5,brake:1.5,lat:1.8,yaw:1.6,min_speed:3.0,desc:'Spirited mountain roads'}, +city:{acc:1.0,brake:1.0,lat:1.0,yaw:1.0,min_speed:2.0,desc:'Daily street driving'}, +highway:{acc:0.8,brake:0.7,lat:0.6,yaw:0.6,min_speed:12.0,desc:'Highway cruising'} +}; + +// Default presets (used when no calibration exists) +const DEFAULT_PRESETS={ +track:{acc:0.35,acc_exit:0.17,brake:0.55,brake_exit:0.27,lat:0.50,lat_exit:0.25,yaw:0.15,min_speed:5.0,desc:'Racing/track days'}, canyon:{acc:0.22,acc_exit:0.11,brake:0.35,brake_exit:0.17,lat:0.28,lat_exit:0.14,yaw:0.10,min_speed:3.0,desc:'Spirited mountain roads'}, city:{acc:0.10,acc_exit:0.05,brake:0.18,brake_exit:0.09,lat:0.12,lat_exit:0.06,yaw:0.05,min_speed:2.0,desc:'Daily street driving'}, -highway:{acc:0.12,acc_exit:0.06,brake:0.22,brake_exit:0.11,lat:0.14,lat_exit:0.07,yaw:0.04,min_speed:5.0,desc:'Highway cruising'} +highway:{acc:0.12,acc_exit:0.06,brake:0.22,brake_exit:0.11,lat:0.14,lat_exit:0.07,yaw:0.04,min_speed:12.0,desc:'Highway cruising'} +}; + +// Generate all profiles from city baseline thresholds +function generateAllProfiles(cityBase){ +const profiles={}; +for(const[mode,scale]of Object.entries(PROFILE_SCALES)){ +profiles[mode]={ +acc:Math.max(0.05,cityBase.acc*scale.acc), +acc_exit:Math.max(0.02,cityBase.acc_exit*scale.acc), +brake:Math.max(0.08,cityBase.brake*scale.brake), +brake_exit:Math.max(0.04,cityBase.brake_exit*scale.brake), +lat:Math.max(0.05,cityBase.lat*scale.lat), +lat_exit:Math.max(0.02,cityBase.lat_exit*scale.lat), +yaw:Math.max(0.02,cityBase.yaw*scale.yaw), +min_speed:scale.min_speed, +desc:scale.desc }; +} +return profiles; +} + +// Get presets - prefer calibrated profiles, fall back to defaults +function getPresets(){ +const calib=localStorage.getItem('bb_profiles'); +if(calib){ +try{return JSON.parse(calib).profiles}catch(e){} +} +return DEFAULT_PRESETS; +} +const PRESETS=getPresets(); function fmtTime(ms){const s=Math.floor(ms/1000),m=Math.floor(s/60);return String(m).padStart(2,'0')+':'+String(s%60).padStart(2,'0')} @@ -501,6 +535,18 @@ $('s-yaw').value=p.yaw;$('v-yaw').textContent=p.yaw.toFixed(3); $('s-minspd').value=p.min_speed;$('v-minspd').textContent=p.min_speed.toFixed(1); } +// Get most recent autotune profile from localStorage +function getCalibrationData(){ +try{ +const data=JSON.parse(localStorage.getItem('bb_profiles')||'null'); +return data; +}catch(e){return null} +} +function isCalibrated(){ +const data=getCalibrationData(); +return data&&data.profiles; +} + // Select preset and apply function selectPreset(name){ currentPreset=name; @@ -518,6 +564,14 @@ const p=PRESETS[name]; updateSummary(p); applyPresetToSliders(p); sendSettings(p); +}else if(isCustom){ +// For custom, try to load from autotune profile +const at=getAutotuneProfile(); +if(at){ +const p={acc:at.acc,acc_exit:at.acc_exit,brake:at.brake,brake_exit:at.brake_exit,lat:at.lat,lat_exit:at.lat_exit,yaw:at.yaw,min_speed:at.min_speed}; +applyPresetToSliders(p); +sendSettings(p); +} } } @@ -574,6 +628,21 @@ if(Math.abs(s.acc-p.acc)<0.01&&Math.abs(s.lat-p.lat)<0.01&&Math.abs(s.min_speed- matched=name;break; } } +// Update preset buttons to show calibration status +const calib=getCalibrationData(); +document.querySelectorAll('.preset-btn:not(.custom)').forEach(btn=>{ +const name=btn.dataset.preset; +if(calib&&calib.profiles){ +// Show calibrated indicator +btn.innerHTML=name.charAt(0).toUpperCase()+name.slice(1)+' '; +btn.title='Calibrated '+new Date(calib.date).toLocaleDateString(); +} +}); +// Update custom button +const customBtn=document.querySelector('.preset-btn.custom'); +if(customBtn){ +customBtn.textContent='Custom'; +} currentPreset=matched; // Update UI document.querySelectorAll('.preset-btn').forEach(b=>{ @@ -716,6 +785,952 @@ update(); "#; +/// Auto-tune page for calibration assistance +/// Uses EVENT DETECTION with full physics validation including: +/// - GPS heading vs gyro yaw rate +/// - Centripetal acceleration check (a_lat = v × ω) +/// - Impulse validation (ΔV = G × t) +/// - Event duration tracking +/// - Report export and localStorage save +const AUTOTUNE_HTML: &str = r#" + +Blackbox AutoTune + + + +
+ +
+
Select Calibration Scenario
+
+
+
🎯
+
Guided
+
Best accuracy. Perform specific maneuvers when prompted.
+
~3 min
+
+
+
🏙️
+
City Loop
+
Drive around the block 5-10 times. Stop signs, turns, traffic.
+
~5 min
+
+
+
🛣️
+
Highway
+
Highway driving with lane changes and on/off ramps.
+
~5 min
+
+
+
🅿️
+
Parking Lot
+
Empty lot. Hard stops, tight turns, figure-8s for max G.
+
~2 min
+
+
+
+
📋 Guided Calibration Instructions
+
+
    +
  1. Find a safe, quiet road or empty parking lot
  2. +
  3. Press Start - events are auto-detected as you drive
  4. +
  5. Accelerate firmly 5+ times (from stops or slow speed)
  6. +
  7. Brake normally 5+ times (to stops or slow down)
  8. +
  9. Turn at intersections or make 10+ turns in a lot
  10. +
  11. Drive as you normally would - don't overdo it!
  12. +
+
+
+
+ +
+
Event Detection0:00
+
+
Ready
+GPS: -- +
+
+
0
peak --
Accels
+
0
peak --
Brakes
+
0
peak --
Turns
+
+
+
0.00
Lon G
+
0.00
Lat G
+
0.00
v×ω G
+
0
km/h
+
+
+
Drive to collect events
+
+ + + + + +
+ + +
+ + +
+ +"#; + impl TelemetryServer { /// Create and start the telemetry server with HTTP polling /// @@ -730,8 +1745,8 @@ impl TelemetryServer { let server_config = Configuration { http_port: port, - max_uri_handlers: 9, /* Dashboard, telemetry, status, calibrate, settings GET, - * settings SET, diagnostics page, diagnostics API */ + max_uri_handlers: 10, /* Dashboard, autotune, telemetry, status, calibrate, + * settings GET, settings SET, diagnostics page, diagnostics API */ max_open_sockets: 8, // HTTP only - no long-lived WebSocket connections stack_size: 10240, ..Default::default() @@ -764,6 +1779,27 @@ impl TelemetryServer { }, )?; + // Serve the auto-tune calibration page + server.fn_handler( + "/autotune", + esp_idf_svc::http::Method::Get, + |req| -> Result<(), esp_idf_svc::io::EspIOError> { + let html_bytes = AUTOTUNE_HTML.as_bytes(); + let content_length = html_bytes.len().to_string(); + let mut response = req.into_response( + 200, + None, + &[ + ("Content-Type", "text/html; charset=utf-8"), + ("Content-Length", &content_length), + ("Connection", "close"), + ], + )?; + response.write_all(html_bytes)?; + Ok(()) + }, + )?; + // WebSocket removed - using HTTP polling for reliability and to eliminate // thread blocking From 0ad23f70c40d77ad95da31deacd082646784f0f2 Mon Sep 17 00:00:00 2001 From: Jose Cruz Toledo <1273555+jctoledo@users.noreply.github.com> Date: Thu, 1 Jan 2026 08:25:06 -0800 Subject: [PATCH 2/7] adds docs, improves ux --- CLAUDE.md | 115 +++++++++++++++++++++++ README.md | 63 +++++++++++++ docs/index.html | 19 ++++ sensors/blackbox/src/websocket_server.rs | 106 ++++----------------- 4 files changed, 216 insertions(+), 87 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ba94932..8562901 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -645,6 +645,121 @@ Displayed as updates per minute (rolling average), not cumulative count. Typical - ZUPT: 0-60/min (depends on stops) - Free heap: ~60KB (stable during operation) +### Autotune System + +**Overview:** +Autotune learns vehicle-specific mode detection thresholds from a calibration drive. Instead of generic presets, thresholds are derived from actual sensor data captured during normal driving. + +**Why Autotune?** +- Every vehicle has different suspension, tire grip, weight distribution +- Generic thresholds may trigger too early (sensitive car) or too late (stiff car) +- Calibration captures your specific vehicle's g-force characteristics + +**Data Flow:** +``` +Telemetry → Event Detection → Categorization → Median Calculation → Scaling → All Profiles + │ │ │ │ │ │ + └─►ax,ay,wz └─►EMA filter └─►ACCEL/BRAKE/ └─►P50 values └─►×scale └─►localStorage + α=0.35 CORNER bins (robust) factors bb_profiles +``` + +**Event Detection Algorithm:** +```javascript +// Detect events from telemetry stream +const EMA_ALPHA = 0.35; // Smoothing factor +const EVENT_MIN_DURATION = 200; // ms - reject transients +const EVENT_TIMEOUT = 300; // ms - gap to end event + +// Smooth raw sensor data +ax_ema = ax_ema * (1 - EMA_ALPHA) + raw_ax * EMA_ALPHA; + +// Detect event start/end based on magnitude threshold +// Record peak values during event +// Categorize: positive ax → ACCEL, negative ax → BRAKE, high ay+wz → CORNER +``` + +**Threshold Calculation:** +```javascript +// For each event category (ACCEL, BRAKE, CORNER): +const sortedPeaks = events.map(e => e.peak).sort((a,b) => a - b); +const P50 = sortedPeaks[Math.floor(sortedPeaks.length * 0.5)]; // Median + +// Entry threshold: 70% of median (triggers before typical peak) +const entry = P50 * 0.7; + +// Exit threshold: 50% of entry (hysteresis prevents oscillation) +const exit = entry * 0.5; +``` + +**Profile Scaling System:** +Single city calibration generates all 4 profiles using physics-based multipliers: + +```javascript +const PROFILE_SCALES = { + track: { acc: 2.5, brake: 2.0, lat: 3.0, yaw: 2.5, min_speed: 5.0 }, + canyon: { acc: 1.5, brake: 1.5, lat: 1.8, yaw: 1.6, min_speed: 3.0 }, + city: { acc: 1.0, brake: 1.0, lat: 1.0, yaw: 1.0, min_speed: 2.0 }, + highway: { acc: 0.8, brake: 0.7, lat: 0.6, yaw: 0.6, min_speed: 12.0 } +}; + +// Scale from city baseline +track.acc_entry = city.acc_entry * 2.5; // Track expects 2.5× higher g-forces +highway.lat_entry = city.lat_entry * 0.6; // Highway lane changes are gentler +``` + +**localStorage Data Structure:** +```javascript +// Stored as 'bb_profiles' in localStorage +{ + "track": { + "acc": 0.25, // Entry threshold (g, converted to m/s²) + "acc_exit": 0.125, + "brake": 0.36, + "brake_exit": 0.18, + "lat": 0.36, + "lat_exit": 0.18, + "yaw": 0.125, + "min_speed": 5.0, + "desc": "Racing/track days" + }, + "canyon": { ... }, + "city": { ... }, + "highway": { ... }, + "calibrated_at": "2024-01-15T10:30:00.000Z", + "vehicle_id": "optional-user-label" +} +``` + +**Physics Validation Metrics:** +Autotune validates calibration quality using physics cross-checks: +- GPS↔Sensor speed correlation (should be >0.8) +- Accel events should show speed increasing +- Brake events should show speed decreasing +- Lateral events should correlate with heading change +- Centripetal validation: ay ≈ v²/r (from wz) + +**Confidence Levels:** +Based on sample count per event category: +- HIGH: n ≥ 15 events (median very stable) +- MEDIUM: n = 8-14 events (usable, some variance) +- LOW: n = 3-7 events (may need more driving) +- INSUFFICIENT: n < 3 (cannot compute reliable threshold) + +**Export Format:** +JSON export includes: +- All 4 scaled profiles with thresholds +- Raw event data (peaks, durations, timestamps) +- Scaling factors used +- Validation metrics +- Confidence per category +- Timestamp and optional vehicle ID + +**Integration with Dashboard:** +- Preset buttons show green calibration dot when `bb_profiles` exists +- Clicking preset loads calibrated values (or defaults if uncalibrated) +- Export button downloads comprehensive JSON for analysis +- Progress bar shows real-time event capture during calibration + ### udp_stream.rs - UDP Client (Station Mode) **Features:** diff --git a/README.md b/README.md index 62d68f2..fb7b00c 100644 --- a/README.md +++ b/README.md @@ -711,6 +711,69 @@ You can trigger recalibration from the dashboard: --- +## Autotune (Threshold Calibration) + +The **Autotune** feature learns your vehicle's characteristics from a single city drive and automatically generates optimized thresholds for all 4 driving profiles. + +### Why Autotune? + +Default presets use generic thresholds, but every vehicle is different: +- A sports car can pull 1.0g lateral; a minivan might max at 0.5g +- Your driving style affects typical G-forces +- One calibration drive personalizes ALL presets to YOUR vehicle + +### How to Use Autotune + +1. **Open the dashboard** at `http://192.168.71.1` +2. **Tap "Autotune"** in the menu +3. **Wait for GPS lock** (required before starting) +4. **Select a scenario** (Guided, Parking Lot, or Free Drive) +5. **Tap "Start Calibration"** and drive normally for 10-20 minutes +6. **Collect events**: accelerations, brakes, and turns +7. **Review results** - the system generates all 4 profiles +8. **Tap "Apply"** to save and activate + +### What Gets Generated + +From your city driving baseline, Autotune generates 4 vehicle-specific profiles: + +| Profile | Scaling | Use Case | +|---------|---------|----------| +| **Track** | 2.0-3.0× baseline | Racing, track days | +| **Canyon** | 1.5-1.8× baseline | Spirited mountain roads | +| **City** | 1.0× (your baseline) | Daily driving | +| **Highway** | 0.6-0.8× baseline | Highway cruising | + +### Confidence Levels + +The more events you collect, the more accurate the thresholds: + +| Events | Confidence | Recommendation | +|--------|------------|----------------| +| 3-7 each | Low | Keep driving | +| 8-14 each | Medium | Good for most uses | +| 15+ each | High | Excellent accuracy | + +### Export Data + +Autotune exports comprehensive JSON for analysis: +- All 4 generated profiles +- Raw event data (peaks, durations, speed changes) +- Physics validation metrics +- Scaling factors used + +This data helps evaluate calibration quality and can be used for post-analysis. + +### Tips for Best Results + +- **Drive normally** - don't exaggerate maneuvers +- **Include variety** - different turn directions, brake intensities +- **Flat ground preferred** - inclines can affect readings +- **More driving = better** - the progress bar is just the minimum +- **GPS lock required** - ensures accurate speed validation + +--- + ## Mobile Dashboard The firmware includes a built-in web dashboard that runs directly on the ESP32. No external server needed - just connect your phone and view live telemetry. diff --git a/docs/index.html b/docs/index.html index 9270172..ca2f0ba 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1063,6 +1063,21 @@

ZUPT

Zero-velocity updates prevent drift. Bias continuously estimated when stationary.

+
+
+ + + + + + + + +
+

Autotune

+

Learn vehicle-specific thresholds from a calibration drive. One city drive generates all 4 profiles.

+
+
@@ -1127,6 +1142,10 @@

Dashboard Features

Driving presets: Track, Canyon, City, Highway, Custom +
  • + + Autotune: calibrate thresholds to your vehicle +
  • Record & export to CSV diff --git a/sensors/blackbox/src/websocket_server.rs b/sensors/blackbox/src/websocket_server.rs index e3e1c08..dbf2e6e 100644 --- a/sensors/blackbox/src/websocket_server.rs +++ b/sensors/blackbox/src/websocket_server.rs @@ -804,14 +804,6 @@ body{font-family:-apple-system,system-ui,sans-serif;background:#0a0a0f;color:#f0 .main{flex:1;padding:12px;display:flex;flex-direction:column;gap:10px;overflow-y:auto} .card{background:#111;border-radius:10px;padding:14px;border:1px solid #1a1a24} .card-title{font-size:11px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-bottom:10px;display:flex;justify-content:space-between;align-items:center} -.scenario-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px} -.scenario{background:#0a0a0f;border:2px solid #1a1a24;border-radius:10px;padding:12px;cursor:pointer;transition:all .2s} -.scenario.active{border-color:#3b82f6;background:linear-gradient(135deg,#0f1a2e,#0a0a0f)} -.scenario:active{transform:scale(0.98)} -.scenario-icon{font-size:24px;margin-bottom:6px} -.scenario-name{font-size:12px;font-weight:600;color:#f0f0f0} -.scenario-desc{font-size:9px;color:#555;margin-top:4px;line-height:1.3} -.scenario-time{font-size:8px;color:#3b82f6;margin-top:6px} .instructions{background:#0f1a2e;border:1px solid #1e3a5f;border-radius:8px;padding:12px;margin-top:10px} .instructions-title{font-size:10px;color:#60a5fa;font-weight:600;margin-bottom:8px} .instructions-text{font-size:11px;color:#888;line-height:1.5} @@ -883,45 +875,27 @@ body{font-family:-apple-system,system-ui,sans-serif;background:#0a0a0f;color:#f0
    -
    -
    Select Calibration Scenario
    -
    -
    -
    🎯
    -
    Guided
    -
    Best accuracy. Perform specific maneuvers when prompted.
    -
    ~3 min
    -
    -
    -
    🏙️
    -
    City Loop
    -
    Drive around the block 5-10 times. Stop signs, turns, traffic.
    -
    ~5 min
    -
    -
    -
    🛣️
    -
    Highway
    -
    Highway driving with lane changes and on/off ramps.
    -
    ~5 min
    -
    -
    -
    🅿️
    -
    Parking Lot
    -
    Empty lot. Hard stops, tight turns, figure-8s for max G.
    -
    ~2 min
    -
    +
    +
    Calibration Drive
    +
    +
    🚗
    +
    Drive normally for 10-20 minutes
    +
    One drive generates all 4 profiles (Track, Canyon, City, Highway)
    -
    -
    📋 Guided Calibration Instructions
    -
    +
    +
    📋 How It Works
    +
      -
    1. Find a safe, quiet road or empty parking lot
    2. -
    3. Press Start - events are auto-detected as you drive
    4. -
    5. Accelerate firmly 5+ times (from stops or slow speed)
    6. -
    7. Brake normally 5+ times (to stops or slow down)
    8. -
    9. Turn at intersections or make 10+ turns in a lot
    10. -
    11. Drive as you normally would - don't overdo it!
    12. +
    13. Press Start Calibration below
    14. +
    15. Drive around your neighborhood normally
    16. +
    17. Include stops, turns, and normal acceleration
    18. +
    19. The system auto-detects driving events
    20. +
    21. When progress bar fills, you have enough data
    22. +
    23. Press Apply to save all 4 profiles
    +
    +Tip: More driving = better accuracy. 15+ events per category is ideal. +
    @@ -998,7 +972,7 @@ const MIN_SPEED_KMH=7.2; // Must match mode.rs min_speed (2.0 m/s = 7.2 km/h) const EMA_ALPHA=0.35; // Must match mode.rs EMA alpha for accurate simulation // State -let scenario='guided'; +let scenario='neighborhood'; // Single calibration mode - drive normally let recording=false; let startTime=0; let lastSeq=0; @@ -1033,45 +1007,6 @@ let gpsAccelCorr=[]; // correlation between GPS accel and velocity accel let centCorr=[]; // lat_g vs v*omega correlation let headingVsGyro=[]; // GPS heading rate vs gyro wz -// Scenario instructions -const INSTRUCTIONS={ -guided:`
      -
    1. Find a safe road or empty parking lot
    2. -
    3. Calibrate on level ground - inclines affect accuracy!
    4. -
    5. Press Start - events auto-detect as you drive
    6. -
    7. Accelerate firmly 5+ times from slow/stop
    8. -
    9. Brake normally 5+ times to slow/stop
    10. -
    11. Turn 10+ times at normal driving speed
    12. -
    13. Drive as you normally would!
    14. -
    `, -city:`
      -
    1. Drive around the block or a few city blocks
    2. -
    3. Include: stop signs, traffic lights, turns
    4. -
    5. 5-10 loops captures enough events
    6. -
    7. Normal city driving - no need to rush
    8. -
    `, -highway:`
      -
    1. Merge onto highway, cruise at speed
    2. -
    3. Make 3-5 lane changes
    4. -
    5. Use on/off ramps for accel/brake
    6. -
    7. One exit-and-re-enter cycle works
    8. -
    `, -parking:`
      -
    1. Empty flat parking lot is ideal
    2. -
    3. Start/stop on level ground (not inclines!)
    4. -
    5. 5 firm accelerations from stop
    6. -
    7. 5 firm (safe) braking stops
    8. -
    9. Figure-8s or circles for turn data
    10. -
    11. Cleanest calibration data!
    12. -
    ` -}; - -function selectScenario(s){ - scenario=s; - document.querySelectorAll('.scenario').forEach(el=>el.classList.toggle('active',el.dataset.scenario===s)); - $('instructions-text').innerHTML=INSTRUCTIONS[s]; -} - function parsePacket(buf){ const d=new DataView(buf); return{ @@ -1705,11 +1640,9 @@ function toggleRecording(){ btn.textContent='Stop Calibration'; btn.className='btn btn-start recording'; - $('scenario-card').style.opacity='0.5'; }else{ btn.textContent='Start Calibration'; btn.className='btn btn-start'; - $('scenario-card').style.opacity='1'; computeSuggestions(); $('report').textContent=generateReport(); $('report-card').style.display='block'; @@ -1723,7 +1656,6 @@ setInterval(()=>{ } },1000); -document.querySelectorAll('.scenario').forEach(el=>el.onclick=()=>selectScenario(el.dataset.scenario)); $('btn-start').onclick=toggleRecording; $('btn-apply').onclick=applySettings; $('btn-export').onclick=exportReport; From 884f3f872781a18f77c8fb7cd775e0090178c873 Mon Sep 17 00:00:00 2001 From: Jose Cruz Toledo <1273555+jctoledo@users.noreply.github.com> Date: Sat, 10 Jan 2026 17:36:28 -0800 Subject: [PATCH 3/7] major improvements to autotune --- CLAUDE.md | 181 ++++---- README.md | 108 +++-- sensors/blackbox/src/main.rs | 23 +- sensors/blackbox/src/settings.rs | 100 +++++ sensors/blackbox/src/websocket_server.rs | 549 ++++++----------------- 5 files changed, 442 insertions(+), 519 deletions(-) create mode 100644 sensors/blackbox/src/settings.rs diff --git a/CLAUDE.md b/CLAUDE.md index 8562901..05894de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -645,117 +645,128 @@ Displayed as updates per minute (rolling average), not cumulative count. Typical - ZUPT: 0-60/min (depends on stops) - Free heap: ~60KB (stable during operation) +### settings.rs - NVS Settings Persistence + +**Purpose:** +Persists mode detection thresholds to ESP32's Non-Volatile Storage (NVS) so they survive power cycles. + +**Functions:** +- `load_settings(nvs_partition)`: Loads `ModeConfig` from NVS, returns `None` if not found +- `save_settings(nvs_partition, config)`: Saves `ModeConfig` to NVS, returns success/failure + +**NVS Schema:** +- **Namespace:** `bb_cfg` +- **Keys (max 15 chars for NVS):** + - `acc` - acceleration entry threshold (f32) + - `acc_x` - acceleration exit threshold (f32) + - `brk` - brake entry threshold (f32) + - `brk_x` - brake exit threshold (f32) + - `lat` - lateral entry threshold (f32) + - `lat_x` - lateral exit threshold (f32) + - `yaw` - yaw rate threshold (f32) + - `spd` - minimum speed (f32) + +**Storage Format:** +Values stored as raw 4-byte little-endian floats (`f32::to_le_bytes()`). + +**Usage in main.rs:** +```rust +// On boot: load saved settings +let saved_config = nvs.as_ref().and_then(|p| settings::load_settings(p.clone())); +if let Some(cfg) = saved_config { + estimator.mode_classifier.update_config(cfg); +} + +// On settings change: save to NVS +if let Some(ref nvs) = nvs_for_settings { + settings::save_settings(nvs.clone(), &new_config); +} +``` + +**Note:** `alpha` (EMA filter) is NOT persisted - always uses mode.rs default (0.35). + ### Autotune System **Overview:** -Autotune learns vehicle-specific mode detection thresholds from a calibration drive. Instead of generic presets, thresholds are derived from actual sensor data captured during normal driving. +Autotune learns mode detection thresholds by driving in the selected mode. Each profile (City, Canyon, Track, Highway) is calibrated independently - no scaling between profiles. -**Why Autotune?** -- Every vehicle has different suspension, tire grip, weight distribution -- Generic thresholds may trigger too early (sensitive car) or too late (stiff car) -- Calibration captures your specific vehicle's g-force characteristics +**Key Design:** +- User selects which mode to calibrate (Track/Canyon/City/Highway) +- Drives in that style for 10-20 minutes +- Thresholds computed directly from that driving style +- No arbitrary scaling factors between profiles +- Each mode calibrated by actually driving in that mode **Data Flow:** ``` -Telemetry → Event Detection → Categorization → Median Calculation → Scaling → All Profiles - │ │ │ │ │ │ - └─►ax,ay,wz └─►EMA filter └─►ACCEL/BRAKE/ └─►P50 values └─►×scale └─►localStorage - α=0.35 CORNER bins (robust) factors bb_profiles +Select Mode → Drive → Event Detection → IQR Filter → Median → NVS + localStorage + │ │ │ │ │ │ + └─►Track └─►ax,ay,wz └─►ACCEL/BRAKE/ └─►Outlier └─►P50×0.7 └─►bb_cfg + CORNER bins rejection (active) ``` **Event Detection Algorithm:** ```javascript -// Detect events from telemetry stream -const EMA_ALPHA = 0.35; // Smoothing factor -const EVENT_MIN_DURATION = 200; // ms - reject transients -const EVENT_TIMEOUT = 300; // ms - gap to end event - -// Smooth raw sensor data -ax_ema = ax_ema * (1 - EMA_ALPHA) + raw_ax * EMA_ALPHA; - -// Detect event start/end based on magnitude threshold -// Record peak values during event -// Categorize: positive ax → ACCEL, negative ax → BRAKE, high ay+wz → CORNER +// Parameters tuned for 25Hz polling to match 200Hz mode.rs dynamics +const EMA_ALPHA = 0.85; // Adjusted for sample rate mismatch +const NOISE_FLOOR = 0.08; // 0.08g - reject road bumps +const EVENT_MIN_DURATION = 200; // ms - reject transients +const EVENT_COOLDOWN = 400; // ms - gap between events +const MIN_SPEED_KMH = 7.2; // 2.0 m/s + +// EMA filter matching mode.rs dynamics +emaLonSim = (1 - EMA_ALPHA) * emaLonSim + EMA_ALPHA * lonG; + +// Event detection with noise floor +if (emaLonSim > NOISE_FLOOR && speed > MIN_SPEED_KMH) { + // Start/continue ACCEL event, track peak +} +// BRAKE: emaLonSim < -NOISE_FLOOR +// TURN: |emaLatSim| > NOISE_FLOOR && |emaYawSim| > 0.02 && sign consistent ``` **Threshold Calculation:** ```javascript -// For each event category (ACCEL, BRAKE, CORNER): -const sortedPeaks = events.map(e => e.peak).sort((a,b) => a - b); -const P50 = sortedPeaks[Math.floor(sortedPeaks.length * 0.5)]; // Median - -// Entry threshold: 70% of median (triggers before typical peak) -const entry = P50 * 0.7; +// IQR outlier rejection +function filterOutliers(arr) { + const q1 = percentile(arr, 25), q3 = percentile(arr, 75); + const iqr = q3 - q1; + return arr.filter(v => v >= q1 - 1.5*iqr && v <= q3 + 1.5*iqr); +} -// Exit threshold: 50% of entry (hysteresis prevents oscillation) -const exit = entry * 0.5; +// Compute thresholds from collected events +const peaks = filterOutliers(events.map(e => e.peak)); +const P50 = median(peaks); +const entry = P50 * 0.7; // 70% of median +const exit = entry * 0.5; // 50% of entry (hysteresis) ``` -**Profile Scaling System:** -Single city calibration generates all 4 profiles using physics-based multipliers: - +**NVS + localStorage Storage:** ```javascript -const PROFILE_SCALES = { - track: { acc: 2.5, brake: 2.0, lat: 3.0, yaw: 2.5, min_speed: 5.0 }, - canyon: { acc: 1.5, brake: 1.5, lat: 1.8, yaw: 1.6, min_speed: 3.0 }, - city: { acc: 1.0, brake: 1.0, lat: 1.0, yaw: 1.0, min_speed: 2.0 }, - highway: { acc: 0.8, brake: 0.7, lat: 0.6, yaw: 0.6, min_speed: 12.0 } -}; +// On Apply: +// 1. Send to ESP32 → saves to NVS (bb_cfg namespace) +await fetch('/api/settings/set?acc=...&brake=...'); -// Scale from city baseline -track.acc_entry = city.acc_entry * 2.5; // Track expects 2.5× higher g-forces -highway.lat_entry = city.lat_entry * 0.6; // Highway lane changes are gentler -``` - -**localStorage Data Structure:** -```javascript -// Stored as 'bb_profiles' in localStorage -{ - "track": { - "acc": 0.25, // Entry threshold (g, converted to m/s²) - "acc_exit": 0.125, - "brake": 0.36, - "brake_exit": 0.18, - "lat": 0.36, - "lat_exit": 0.18, - "yaw": 0.125, - "min_speed": 5.0, - "desc": "Racing/track days" - }, - "canyon": { ... }, - "city": { ... }, - "highway": { ... }, - "calibrated_at": "2024-01-15T10:30:00.000Z", - "vehicle_id": "optional-user-label" -} +// 2. Save to browser localStorage for selected mode +let profiles = JSON.parse(localStorage.getItem('bb_profiles') || '{}'); +profiles[selectedMode] = {...calibratedProfile, calibrated: new Date().toISOString()}; +profiles.lastMode = selectedMode; +localStorage.setItem('bb_profiles', JSON.stringify(profiles)); ``` -**Physics Validation Metrics:** -Autotune validates calibration quality using physics cross-checks: -- GPS↔Sensor speed correlation (should be >0.8) -- Accel events should show speed increasing -- Brake events should show speed decreasing -- Lateral events should correlate with heading change -- Centripetal validation: ay ≈ v²/r (from wz) +**Boot Behavior:** +- ESP32 reads thresholds from NVS on boot +- If NVS has valid data → applies to mode classifier +- If NVS empty → uses City defaults (mode.rs `ModeConfig::default()`) **Confidence Levels:** -Based on sample count per event category: -- HIGH: n ≥ 15 events (median very stable) -- MEDIUM: n = 8-14 events (usable, some variance) -- LOW: n = 3-7 events (may need more driving) -- INSUFFICIENT: n < 3 (cannot compute reliable threshold) - -**Export Format:** -JSON export includes: -- All 4 scaled profiles with thresholds -- Raw event data (peaks, durations, timestamps) -- Scaling factors used -- Validation metrics -- Confidence per category -- Timestamp and optional vehicle ID +- HIGH: n ≥ 15 events per category +- MEDIUM: n = 8-14 events +- LOW: n = 3-7 events +- INSUFFICIENT: n < 3 **Integration with Dashboard:** -- Preset buttons show green calibration dot when `bb_profiles` exists +- User calibrates each mode by selecting it and driving in that style - Clicking preset loads calibrated values (or defaults if uncalibrated) - Export button downloads comprehensive JSON for analysis - Progress bar shows real-time event capture during calibration diff --git a/README.md b/README.md index fb7b00c..dc6ec5c 100644 --- a/README.md +++ b/README.md @@ -713,64 +713,108 @@ You can trigger recalibration from the dashboard: ## Autotune (Threshold Calibration) -The **Autotune** feature learns your vehicle's characteristics from a single city drive and automatically generates optimized thresholds for all 4 driving profiles. +The **Autotune** feature learns your vehicle's characteristics by driving in the mode you want to calibrate. Each profile (City, Canyon, Track, Highway) is calibrated independently - no arbitrary scaling. Calibrated settings persist across power cycles via NVS. ### Why Autotune? -Default presets use generic thresholds, but every vehicle is different: +Default presets use generic thresholds, but every vehicle and driver is different: - A sports car can pull 1.0g lateral; a minivan might max at 0.5g -- Your driving style affects typical G-forces -- One calibration drive personalizes ALL presets to YOUR vehicle +- Track driving produces different g-forces than city driving +- Calibrate each mode by actually driving in that style ### How to Use Autotune 1. **Open the dashboard** at `http://192.168.71.1` 2. **Tap "Autotune"** in the menu -3. **Wait for GPS lock** (required before starting) -4. **Select a scenario** (Guided, Parking Lot, or Free Drive) -5. **Tap "Start Calibration"** and drive normally for 10-20 minutes +3. **Select the mode to calibrate** (City, Canyon, Track, or Highway) +4. **Wait for GPS lock** (green GPS status required) +5. **Tap "Start Calibration"** and drive in that style for 10-20 minutes 6. **Collect events**: accelerations, brakes, and turns -7. **Review results** - the system generates all 4 profiles -8. **Tap "Apply"** to save and activate +7. **Review thresholds** - computed from YOUR driving +8. **Tap "Apply"** to save to ESP32 (NVS) and browser -### What Gets Generated +### Mode-Specific Calibration -From your city driving baseline, Autotune generates 4 vehicle-specific profiles: +Unlike previous versions that scaled from a single baseline, each mode is now calibrated independently: -| Profile | Scaling | Use Case | -|---------|---------|----------| -| **Track** | 2.0-3.0× baseline | Racing, track days | -| **Canyon** | 1.5-1.8× baseline | Spirited mountain roads | -| **City** | 1.0× (your baseline) | Daily driving | -| **Highway** | 0.6-0.8× baseline | Highway cruising | +| Mode | Calibration Drive Style | +|------|------------------------| +| **City** | Normal commute - stop lights, lane changes, parking | +| **Canyon** | Spirited mountain roads - tighter turns, harder braking | +| **Track** | Racing/track day - aggressive accel, hard braking, high-g turns | +| **Highway** | Highway cruising - gentle lane changes, gradual accel/brake | -### Confidence Levels +**Calibrate each mode separately** by driving in that style. This eliminates guesswork from scaling factors. + +### Algorithm Details + +Autotune uses physics-informed signal processing matching `mode.rs`: + +**Event Detection:** +- **EMA filtering** at α=0.85 (tuned for 25Hz polling to match 200Hz firmware) +- **Noise floor** of 0.08g rejects road bumps and vibration +- **Duration filter** of 200ms ensures intentional inputs +- **Cooldown** of 400ms between events prevents double-counting +- **Sign consistency** for turns: lateral G and yaw rate must agree + +**Threshold Calculation:** +1. Collect peak G-forces from each event type (accel, brake, turn) +2. Apply **IQR outlier rejection** (1.5× interquartile range) +3. Compute **median (P50)** of filtered peaks +4. Entry threshold = **70% of median** +5. Exit threshold = **50% of entry** (hysteresis) + +**min_speed** is fixed at 2.0 m/s (7.2 km/h) - physics doesn't change with driving style. + +### NVS Persistence + +Calibrated settings persist in ESP32's Non-Volatile Storage: -The more events you collect, the more accurate the thresholds: +**On Apply:** +- Thresholds sent to ESP32 via `/api/settings/set` +- ESP32 saves to NVS namespace `bb_cfg` +- Browser saves profile to `localStorage` for dashboard + +**On Boot:** +- ESP32 reads thresholds from NVS +- If valid, applies to mode classifier automatically +- If NVS is empty/invalid, uses **City defaults** (acc=0.10g, brake=0.18g, lat=0.12g) +- No manual configuration needed after calibration + +**Storage Keys:** `acc`, `acc_x`, `brk`, `brk_x`, `lat`, `lat_x`, `yaw`, `spd` + +### Confidence Levels | Events | Confidence | Recommendation | |--------|------------|----------------| -| 3-7 each | Low | Keep driving | +| 3-7 each | Low | Keep driving - thresholds may be noisy | | 8-14 each | Medium | Good for most uses | -| 15+ each | High | Excellent accuracy | +| 15+ each | High | Robust outlier rejection, accurate medians | ### Export Data -Autotune exports comprehensive JSON for analysis: -- All 4 generated profiles -- Raw event data (peaks, durations, speed changes) -- Physics validation metrics -- Scaling factors used - -This data helps evaluate calibration quality and can be used for post-analysis. +Exports JSON with mode-specific calibration data: +```json +{ + "version": 2, + "mode": "track", + "exportDate": "2024-01-15T10:30:00Z", + "calibrated": { "acc": 0.35, "brake": 0.50, ... }, + "events": { + "accel": [{"peak": 0.45, "dur": 450, "dv": 18.3}, ...], + "brake": [...], + "turn": [...] + } +} +``` ### Tips for Best Results -- **Drive normally** - don't exaggerate maneuvers +- **Drive in the style you're calibrating** - Track mode = aggressive, City = normal +- **Calibrate each mode separately** - switch modes and recalibrate - **Include variety** - different turn directions, brake intensities -- **Flat ground preferred** - inclines can affect readings -- **More driving = better** - the progress bar is just the minimum -- **GPS lock required** - ensures accurate speed validation +- **More driving = better** - 15+ events each gives high confidence +- **GPS lock required** - ensures accurate speed filtering --- diff --git a/sensors/blackbox/src/main.rs b/sensors/blackbox/src/main.rs index 7af904f..800516a 100644 --- a/sensors/blackbox/src/main.rs +++ b/sensors/blackbox/src/main.rs @@ -6,6 +6,7 @@ mod imu; mod mode; mod mqtt; mod rgb_led; +mod settings; mod system; mod udp_stream; mod websocket_server; @@ -69,6 +70,13 @@ fn main() { let sysloop = EspSystemEventLoop::take().unwrap(); let nvs = EspDefaultNvsPartition::take().ok(); + // Load saved mode settings from NVS (before WiFi uses nvs) + let saved_mode_config = nvs + .as_ref() + .and_then(|p| settings::load_settings(p.clone())); + // Keep a clone for later saves + let nvs_for_settings = nvs.as_ref().cloned(); + // Create LED for status indication let led = RgbLed::new(peripherals.rmt.channel0, peripherals.pins.gpio8) .expect("Failed to initialize RGB LED"); @@ -440,6 +448,13 @@ fn main() { // Create state estimator and telemetry publisher let mut estimator = StateEstimator::new(); + + // Apply saved mode settings if available + if let Some(cfg) = saved_mode_config { + info!("Applying saved mode config from NVS"); + estimator.mode_classifier.update_config(cfg); + } + let mut publisher = TelemetryPublisher::new(udp_stream, mqtt_opt, telemetry_state.clone()); // Main loop timing @@ -489,9 +504,15 @@ fn main() { lat_thr: s.lat_thr, lat_exit: s.lat_exit, yaw_thr: s.yaw_thr, - alpha: 0.25, // Keep default smoothing + alpha: 0.35, // EMA smoothing factor }; estimator.mode_classifier.update_config(new_config); + + // Persist to NVS + if let Some(ref nvs) = nvs_for_settings { + settings::save_settings(nvs.clone(), &new_config); + } + info!( ">>> Mode config updated: acc={:.2}g/{:.2}g, brake={:.2}g/{:.2}g, lat={:.2}g/{:.2}g, yaw={:.3}rad/s, min_spd={:.1}m/s", s.acc_thr, s.acc_exit, s.brake_thr, s.brake_exit, s.lat_thr, s.lat_exit, s.yaw_thr, s.min_speed diff --git a/sensors/blackbox/src/settings.rs b/sensors/blackbox/src/settings.rs new file mode 100644 index 0000000..9057b1a --- /dev/null +++ b/sensors/blackbox/src/settings.rs @@ -0,0 +1,100 @@ +//! NVS-backed settings persistence for mode detection thresholds + +use esp_idf_svc::nvs::{EspNvs, EspNvsPartition, NvsDefault}; +use log::{info, warn}; + +use crate::mode::ModeConfig; + +const NAMESPACE: &str = "bb_cfg"; + +// Keys (max 15 chars for NVS) +const KEY_ACC: &str = "acc"; +const KEY_ACC_EXIT: &str = "acc_x"; +const KEY_BRAKE: &str = "brk"; +const KEY_BRAKE_EXIT: &str = "brk_x"; +const KEY_LAT: &str = "lat"; +const KEY_LAT_EXIT: &str = "lat_x"; +const KEY_YAW: &str = "yaw"; +const KEY_MIN_SPEED: &str = "spd"; + +/// Load settings from NVS, returns None if not found or error +pub fn load_settings(nvs_partition: EspNvsPartition) -> Option { + let nvs = match EspNvs::new(nvs_partition, NAMESPACE, true) { + Ok(nvs) => nvs, + Err(e) => { + warn!("Failed to open NVS namespace: {:?}", e); + return None; + } + }; + + // Try to read all values - if any fail, return None (use defaults) + let acc = read_f32(&nvs, KEY_ACC)?; + let acc_exit = read_f32(&nvs, KEY_ACC_EXIT)?; + let brake = read_f32(&nvs, KEY_BRAKE)?; + let brake_exit = read_f32(&nvs, KEY_BRAKE_EXIT)?; + let lat = read_f32(&nvs, KEY_LAT)?; + let lat_exit = read_f32(&nvs, KEY_LAT_EXIT)?; + let yaw = read_f32(&nvs, KEY_YAW)?; + let min_speed = read_f32(&nvs, KEY_MIN_SPEED)?; + + info!( + "Loaded settings from NVS: acc={:.2}, brake={:.2}, lat={:.2}, yaw={:.3}", + acc, brake, lat, yaw + ); + + Some(ModeConfig { + acc_thr: acc, + acc_exit, + brake_thr: brake, + brake_exit, + lat_thr: lat, + lat_exit, + yaw_thr: yaw, + min_speed, + alpha: 0.35, // EMA alpha is not persisted - always use default + }) +} + +/// Save settings to NVS +pub fn save_settings(nvs_partition: EspNvsPartition, config: &ModeConfig) -> bool { + let mut nvs = match EspNvs::new(nvs_partition, NAMESPACE, true) { + Ok(nvs) => nvs, + Err(e) => { + warn!("Failed to open NVS namespace for write: {:?}", e); + return false; + } + }; + + let success = write_f32(&mut nvs, KEY_ACC, config.acc_thr) + && write_f32(&mut nvs, KEY_ACC_EXIT, config.acc_exit) + && write_f32(&mut nvs, KEY_BRAKE, config.brake_thr) + && write_f32(&mut nvs, KEY_BRAKE_EXIT, config.brake_exit) + && write_f32(&mut nvs, KEY_LAT, config.lat_thr) + && write_f32(&mut nvs, KEY_LAT_EXIT, config.lat_exit) + && write_f32(&mut nvs, KEY_YAW, config.yaw_thr) + && write_f32(&mut nvs, KEY_MIN_SPEED, config.min_speed); + + if success { + info!( + "Saved settings to NVS: acc={:.2}, brake={:.2}, lat={:.2}, yaw={:.3}", + config.acc_thr, config.brake_thr, config.lat_thr, config.yaw_thr + ); + } else { + warn!("Failed to save some settings to NVS"); + } + + success +} + +fn read_f32(nvs: &EspNvs, key: &str) -> Option { + // NVS stores as blob - read 4 bytes + let mut buf = [0u8; 4]; + match nvs.get_raw(key, &mut buf) { + Ok(Some(_)) => Some(f32::from_le_bytes(buf)), + _ => None, + } +} + +fn write_f32(nvs: &mut EspNvs, key: &str, val: f32) -> bool { + nvs.set_raw(key, &val.to_le_bytes()).is_ok() +} diff --git a/sensors/blackbox/src/websocket_server.rs b/sensors/blackbox/src/websocket_server.rs index dbf2e6e..fd6a99b 100644 --- a/sensors/blackbox/src/websocket_server.rs +++ b/sensors/blackbox/src/websocket_server.rs @@ -334,19 +334,22 @@ const CX=70,CY=70,R=55,SCL=R/2; // Scaling factors: multiply city (baseline) thresholds by these for other modes // Based on physics: track pushes vehicle harder, highway is gentle lane changes +// min_speed is constant (2.0 m/s) - physics of cornering doesn't change with driving style +const MIN_SPEED_MS=2.0; const PROFILE_SCALES={ -track:{acc:2.5,brake:2.0,lat:3.0,yaw:2.5,min_speed:5.0,desc:'Racing/track days'}, -canyon:{acc:1.5,brake:1.5,lat:1.8,yaw:1.6,min_speed:3.0,desc:'Spirited mountain roads'}, -city:{acc:1.0,brake:1.0,lat:1.0,yaw:1.0,min_speed:2.0,desc:'Daily street driving'}, -highway:{acc:0.8,brake:0.7,lat:0.6,yaw:0.6,min_speed:12.0,desc:'Highway cruising'} +track:{acc:2.5,brake:2.0,lat:3.0,yaw:2.5,desc:'Racing/track days'}, +canyon:{acc:1.5,brake:1.5,lat:1.8,yaw:1.6,desc:'Spirited mountain roads'}, +city:{acc:1.0,brake:1.0,lat:1.0,yaw:1.0,desc:'Daily street driving'}, +highway:{acc:0.8,brake:0.7,lat:0.6,yaw:0.6,desc:'Highway cruising'} }; // Default presets (used when no calibration exists) +// min_speed constant at 2.0 m/s - threshold scaling handles sensitivity const DEFAULT_PRESETS={ -track:{acc:0.35,acc_exit:0.17,brake:0.55,brake_exit:0.27,lat:0.50,lat_exit:0.25,yaw:0.15,min_speed:5.0,desc:'Racing/track days'}, -canyon:{acc:0.22,acc_exit:0.11,brake:0.35,brake_exit:0.17,lat:0.28,lat_exit:0.14,yaw:0.10,min_speed:3.0,desc:'Spirited mountain roads'}, +track:{acc:0.35,acc_exit:0.17,brake:0.55,brake_exit:0.27,lat:0.50,lat_exit:0.25,yaw:0.15,min_speed:2.0,desc:'Racing/track days'}, +canyon:{acc:0.22,acc_exit:0.11,brake:0.35,brake_exit:0.17,lat:0.28,lat_exit:0.14,yaw:0.10,min_speed:2.0,desc:'Spirited mountain roads'}, city:{acc:0.10,acc_exit:0.05,brake:0.18,brake_exit:0.09,lat:0.12,lat_exit:0.06,yaw:0.05,min_speed:2.0,desc:'Daily street driving'}, -highway:{acc:0.12,acc_exit:0.06,brake:0.22,brake_exit:0.11,lat:0.14,lat_exit:0.07,yaw:0.04,min_speed:12.0,desc:'Highway cruising'} +highway:{acc:0.12,acc_exit:0.06,brake:0.22,brake_exit:0.11,lat:0.14,lat_exit:0.07,yaw:0.04,min_speed:2.0,desc:'Highway cruising'} }; // Generate all profiles from city baseline thresholds @@ -361,7 +364,7 @@ brake_exit:Math.max(0.04,cityBase.brake_exit*scale.brake), lat:Math.max(0.05,cityBase.lat*scale.lat), lat_exit:Math.max(0.02,cityBase.lat_exit*scale.lat), yaw:Math.max(0.02,cityBase.yaw*scale.yaw), -min_speed:scale.min_speed, +min_speed:MIN_SPEED_MS, desc:scale.desc }; } @@ -855,10 +858,6 @@ body{font-family:-apple-system,system-ui,sans-serif;background:#0a0a0f;color:#f0 .btn:active,.btn-sm:active{opacity:.8} .btn:disabled{opacity:.5;cursor:not-allowed} .report{margin-top:10px;padding:10px;background:#0a0a0f;border-radius:6px;font-size:9px;color:#888;max-height:200px;overflow-y:auto;white-space:pre-wrap;font-family:monospace;line-height:1.4} -.validation{margin-top:10px} -.val-section{font-size:9px;color:#555;text-transform:uppercase;margin:8px 0 4px;letter-spacing:1px} -.val-row{display:flex;justify-content:space-between;align-items:center;padding:6px 8px;background:#0a0a0f;border-radius:4px;margin-bottom:4px;font-size:11px} -.val-check{color:#22c55e}.val-warn{color:#f59e0b}.val-fail{color:#ef4444} .baseline-row{display:flex;justify-content:space-between;background:#0a0a0f;border-radius:6px;padding:8px 12px;margin-top:8px;font-size:11px} .baseline-row span:nth-child(odd){color:#666} .baseline-row span:nth-child(even){color:#22c55e;font-weight:600} @@ -866,35 +865,36 @@ body{font-family:-apple-system,system-ui,sans-serif;background:#0a0a0f;color:#f0 .profile-table th{background:#1a1a24;color:#888;padding:6px 4px;text-align:center;font-weight:500} .profile-table td{padding:6px 4px;text-align:center;border-bottom:1px solid #1a1a24} .profile-table tr:hover{background:#0f0f14} -.profile-table .mode-name{text-align:left;color:#60a5fa;font-weight:600} -.profile-table .mode-city{background:#0a1a0a} -.profile-table .mode-city .mode-name{color:#22c55e} -.profile-note{font-size:9px;color:#555;text-align:center;margin-top:8px} +.mode-select{display:flex;gap:8px;justify-content:center;flex-wrap:wrap} +.mode-btn{padding:10px 14px;border:1px solid #333;border-radius:8px;background:#1a1a2e;color:#888;font-size:12px;cursor:pointer;transition:all 0.2s} +.mode-btn:hover{border-color:#60a5fa;color:#60a5fa} +.mode-btn.selected{border-color:#22c55e;background:#0a1a0a;color:#22c55e;font-weight:600}
    -
    Calibration Drive
    -
    -
    🚗
    -
    Drive normally for 10-20 minutes
    -
    One drive generates all 4 profiles (Track, Canyon, City, Highway)
    +
    Select Mode to Calibrate
    +
    + + + +
    +
    Daily street driving - calibrate during normal commute
    📋 How It Works
      +
    1. Select the mode you want to calibrate above
    2. Press Start Calibration below
    3. -
    4. Drive around your neighborhood normally
    5. -
    6. Include stops, turns, and normal acceleration
    7. -
    8. The system auto-detects driving events
    9. -
    10. When progress bar fills, you have enough data
    11. -
    12. Press Apply to save all 4 profiles
    13. +
    14. Drive in that style for 10-20 minutes
    15. +
    16. The system detects your accel/brake/turn events
    17. +
    18. Press Apply to save thresholds for this mode
    -Tip: More driving = better accuracy. 15+ events per category is ideal. +Tip: Calibrate each mode separately by driving in that style. City = commute, Track = aggressive, Highway = gentle cruising.
    @@ -922,30 +922,9 @@ body{font-family:-apple-system,system-ui,sans-serif;background:#0a0a0f;color:#f0
    - -
    @@ -963,26 +942,32 @@ body{font-family:-apple-system,system-ui,sans-serif;background:#0a0a0f;color:#f0 const M={0:'IDLE',1:'ACCEL',2:'BRAKE',4:'CORNER',5:'A+C',6:'B+C'}; const $=id=>document.getElementById(id); const G=9.80665; +const DEG=180/Math.PI; // rad to deg conversion // Noise floor and event parameters -const NOISE_FLOOR=0.04; // 0.04g - slightly higher for robustness +const NOISE_FLOOR=0.08; // 0.08g - reject road bumps, only detect intentional inputs const EVENT_MIN_DURATION=200; // ms - must sustain for 200ms const EVENT_COOLDOWN=400; // ms - between events const MIN_SPEED_KMH=7.2; // Must match mode.rs min_speed (2.0 m/s = 7.2 km/h) -const EMA_ALPHA=0.35; // Must match mode.rs EMA alpha for accurate simulation +// EMA alpha adjusted for 25Hz polling to match 200Hz mode.rs dynamics +// At 200Hz with α=0.35, reaches 97% in 40ms. At 25Hz, need α=0.85 for similar response. +const EMA_ALPHA=0.85; + +// Mode selection +let selectedMode='city'; +const MODE_DESC={ + city:'Daily street driving - calibrate during normal commute', + canyon:'Spirited mountain roads - calibrate on twisty roads', + track:'Racing/track days - calibrate during aggressive driving', + highway:'Highway cruising - calibrate during gentle lane changes' +}; +const MODE_LABELS={city:'🏙️ City',canyon:'🏔️ Canyon',track:'🏁 Track',highway:'🛣️ Highway'}; // State -let scenario='neighborhood'; // Single calibration mode - drive normally let recording=false; let startTime=0; let lastSeq=0; -let hasGpsLock=false; // Track GPS status for calibration requirement - -// Telemetry state -let prevVx=0,prevVy=0,prevTs=0; -let prevLat=0,prevLon=0,prevGpsTs=0; -let prevGpsSpd=0,prevGpsSpdTs=0; -let prevGpsHeading=null; +let hasGpsLock=false; // EMA for display let emaLonG=0,emaLatG=0; @@ -990,22 +975,16 @@ let emaLonG=0,emaLatG=0; // EMA state matching mode.rs (for accurate threshold simulation) let emaLonSim=0,emaLatSim=0,emaYawSim=0; -// Event detection -let accelEv={on:false,t0:0,peak:0,spd0:0,integral:0}; -let brakeEv={on:false,t0:0,peak:0,spd0:0,integral:0}; -let turnEv={on:false,t0:0,peakLat:0,peakYaw:0,centSum:0,latSum:0,n:0}; +// Event detection state +let accelEv={on:false,t0:0,peak:0,spd0:0}; +let brakeEv={on:false,t0:0,peak:0,spd0:0}; +let turnEv={on:false,t0:0,peakLat:0,peakYaw:0}; let lastEvEnd=0; -// Collected events with full data -let accelEvents=[]; // {peak, duration, speedChange, impulse, expectedDV} -let brakeEvents=[]; -let turnEvents=[]; // {peakLat, peakYaw, avgCent, avgLat} - -// Validation data -let gpsSpeedErrors=[]; -let gpsAccelCorr=[]; // correlation between GPS accel and velocity accel -let centCorr=[]; // lat_g vs v*omega correlation -let headingVsGyro=[]; // GPS heading rate vs gyro wz +// Collected events +let accelEvents=[]; // {peak, dur, dv} +let brakeEvents=[]; // {peak, dur, dv} +let turnEvents=[]; // {peakLat, peakYaw, dur} function parsePacket(buf){ const d=new DataView(buf); @@ -1025,95 +1004,29 @@ function parsePacket(buf){ }; } -// Velocity-derived acceleration (for physics validation - what vehicle actually experienced) -function computeAccelVelocity(vx,vy,ts){ - if(prevTs===0){prevVx=vx;prevVy=vy;prevTs=ts;return{lonG:0,latG:0,dt:0}} - const dt=(ts-prevTs)/1000; - if(dt<=0||dt>0.25){prevVx=vx;prevVy=vy;prevTs=ts;return{lonG:0,latG:0,dt:0}} - const dvx=(vx-prevVx)/dt,dvy=(vy-prevVy)/dt; - const spd=Math.sqrt(vx*vx+vy*vy); - let lonG=0,latG=0; - if(spd>0.5){ - lonG=(dvx*vx+dvy*vy)/(spd*G); - latG=(-dvx*vy+dvy*vx)/(spd*G); - } - prevVx=vx;prevVy=vy;prevTs=ts; - return{lonG,latG,dt}; -} - // IMU-based acceleration matching mode.rs (earth_to_car transformation) -// This is what mode classifier actually sees - use for threshold calibration function earthToCar(ax,ay,yaw){ - // Matches mode.rs earth_to_car: rotate earth-frame accel by -yaw to get vehicle frame const c=Math.cos(yaw),s=Math.sin(yaw); const lonG=(ax*c+ay*s)/G; // Forward in vehicle frame const latG=(-ax*s+ay*c)/G; // Left in vehicle frame return{lonG,latG}; } -// GPS-derived values with heading -function computeGps(lat,lon,ts,spd,gpsOk){ - if(!gpsOk||prevLat===0){prevLat=lat;prevLon=lon;prevGpsTs=ts;prevGpsSpd=spd;prevGpsSpdTs=ts;return null} - const dt=(ts-prevGpsTs)/1000; - if(dt<0.15||dt>2){prevLat=lat;prevLon=lon;prevGpsTs=ts;return null} - - // Distance via haversine - const R=6371000; - const dLat=(lat-prevLat)*Math.PI/180,dLon=(lon-prevLon)*Math.PI/180; - const a=Math.sin(dLat/2)**2+Math.cos(prevLat*Math.PI/180)*Math.cos(lat*Math.PI/180)*Math.sin(dLon/2)**2; - const dist=R*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a)); - const gpsSpd=(dist/dt)*3.6; - - // GPS heading from position change - let heading=null,headingRate=null; - if(dist>0.5){// Only if moved >0.5m - const y=Math.sin(dLon)*Math.cos(lat*Math.PI/180); - const x=Math.cos(prevLat*Math.PI/180)*Math.sin(lat*Math.PI/180)-Math.sin(prevLat*Math.PI/180)*Math.cos(lat*Math.PI/180)*Math.cos(dLon); - heading=Math.atan2(y,x); - if(prevGpsHeading!==null){ - let dh=heading-prevGpsHeading; - if(dh>Math.PI)dh-=2*Math.PI; - if(dh<-Math.PI)dh+=2*Math.PI; - headingRate=dh/dt; - } - prevGpsHeading=heading; - } - - // GPS-based acceleration - let gpsAcc=null; - const dtSpd=(ts-prevGpsSpdTs)/1000; - if(dtSpd>0.1&&dtSpd<2){ - gpsAcc=((spd-prevGpsSpd)/3.6)/dtSpd/G; // km/h → m/s → g - } - prevGpsSpd=spd;prevGpsSpdTs=ts; - - prevLat=lat;prevLon=lon;prevGpsTs=ts; - return{gpsSpd,heading,headingRate,gpsAcc}; -} - function process(p){ - // Velocity-derived accel (for physics validation) - const accVel=computeAccelVelocity(p.vx,p.vy,p.ts); - - // IMU-based accel matching mode.rs (for threshold calibration) const accImu=earthToCar(p.ax,p.ay,p.yaw); - - const gps=computeGps(p.lat,p.lon,p.ts,p.speed,p.gpsOk); const now=Date.now(); const spdMs=p.speed/3.6; - // Update EMA simulation matching mode.rs (alpha=0.35) + // Update EMA simulation matching mode.rs emaLonSim=(1-EMA_ALPHA)*emaLonSim+EMA_ALPHA*accImu.lonG; emaLatSim=(1-EMA_ALPHA)*emaLatSim+EMA_ALPHA*accImu.latG; emaYawSim=(1-EMA_ALPHA)*emaYawSim+EMA_ALPHA*p.wz; - // Centripetal: theoretical lateral acceleration = v × ω + // Centripetal: v × ω (for display only) const centG=(spdMs*Math.abs(p.wz))/G; - // Raw IMU in g's (earth frame, gravity-compensated) - for desk testing + // Raw IMU in g's for stationary display const rawLonG=p.ax/G, rawLatG=p.ay/G; - - // Use IMU-based when moving, raw when stationary (for display) const isMoving=spdMs>0.5; const displayLonG=isMoving?accImu.lonG:rawLonG; const displayLatG=isMoving?accImu.latG:rawLatG; @@ -1140,88 +1053,55 @@ function process(p){ if(!recording)return; - // === VALIDATION DATA COLLECTION (use velocity-derived for physics accuracy) === - - // GPS speed error - if(gps&&p.speed>5){ - gpsSpeedErrors.push(Math.abs(gps.gpsSpd-p.speed)); - } - - // GPS acceleration correlation (velocity-derived vs GPS-derived) - if(gps&&gps.gpsAcc!==null&&Math.abs(accVel.lonG)>0.02){ - gpsAccelCorr.push({gps:gps.gpsAcc,vel:accVel.lonG}); - } - - // Centripetal correlation (velocity-derived lat vs v×ω) - if(p.speed>10&&Math.abs(accVel.latG)>0.02){ - centCorr.push({measured:Math.abs(accVel.latG),theory:centG}); - } - - // Heading vs gyro - if(gps&&gps.headingRate!==null&&p.speed>10){ - headingVsGyro.push({gps:gps.headingRate,gyro:p.wz}); - } - - // === EVENT DETECTION (use EMA-simulated values matching mode.rs) === - // This captures what the mode classifier actually sees after filtering + // === EVENT DETECTION === + // Detect accel/brake/turn events using EMA-filtered values matching mode.rs - // ACCELERATION - use EMA values, speed threshold matches mode.rs + // ACCELERATION if(emaLonSim>NOISE_FLOOR&&p.speed>MIN_SPEED_KMH){ if(!accelEv.on&&now-lastEvEnd>EVENT_COOLDOWN){ - accelEv={on:true,t0:now,peak:emaLonSim,spd0:p.speed,integral:emaLonSim*accVel.dt}; + accelEv={on:true,t0:now,peak:emaLonSim,spd0:p.speed}; }else if(accelEv.on){ accelEv.peak=Math.max(accelEv.peak,emaLonSim); - accelEv.integral+=emaLonSim*accVel.dt; } }else if(accelEv.on){ const dur=now-accelEv.t0; if(dur>EVENT_MIN_DURATION){ - const dv=p.speed-accelEv.spd0; - const expectedDV=accelEv.integral*G*3.6; // g→km/h - accelEvents.push({peak:accelEv.peak,dur,dv,expectedDV,impulse:accelEv.integral}); + accelEvents.push({peak:accelEv.peak,dur,dv:p.speed-accelEv.spd0}); updateEvents(); } accelEv.on=false;lastEvEnd=now; } - // BRAKING - use EMA values + // BRAKING if(emaLonSim<-NOISE_FLOOR&&p.speed>MIN_SPEED_KMH){ if(!brakeEv.on&&now-lastEvEnd>EVENT_COOLDOWN){ - brakeEv={on:true,t0:now,peak:Math.abs(emaLonSim),spd0:p.speed,integral:Math.abs(emaLonSim)*accVel.dt}; + brakeEv={on:true,t0:now,peak:Math.abs(emaLonSim),spd0:p.speed}; }else if(brakeEv.on){ brakeEv.peak=Math.max(brakeEv.peak,Math.abs(emaLonSim)); - brakeEv.integral+=Math.abs(emaLonSim)*accVel.dt; } }else if(brakeEv.on){ const dur=now-brakeEv.t0; if(dur>EVENT_MIN_DURATION){ - const dv=p.speed-brakeEv.spd0; - const expectedDV=-brakeEv.integral*G*3.6; - brakeEvents.push({peak:brakeEv.peak,dur,dv,expectedDV,impulse:brakeEv.integral}); + brakeEvents.push({peak:brakeEv.peak,dur,dv:p.speed-brakeEv.spd0}); updateEvents(); } brakeEv.on=false;lastEvEnd=now; } // TURN - use EMA values with sign consistency check (matching mode.rs) - // mode.rs requires: lat_ema * yaw_ema > 0 (same sign = consistent turn) const latEmaAbs=Math.abs(emaLatSim),yawEmaAbs=Math.abs(emaYawSim); - const signConsistent=(emaLatSim*emaYawSim)>0; // Both same sign = real turn + const signConsistent=(emaLatSim*emaYawSim)>0; if(latEmaAbs>NOISE_FLOOR&&yawEmaAbs>0.02&&p.speed>MIN_SPEED_KMH&&signConsistent){ if(!turnEv.on&&now-lastEvEnd>EVENT_COOLDOWN){ - turnEv={on:true,t0:now,peakLat:latEmaAbs,peakYaw:yawEmaAbs,centSum:centG,latSum:latEmaAbs,n:1}; + turnEv={on:true,t0:now,peakLat:latEmaAbs,peakYaw:yawEmaAbs}; }else if(turnEv.on){ turnEv.peakLat=Math.max(turnEv.peakLat,latEmaAbs); turnEv.peakYaw=Math.max(turnEv.peakYaw,yawEmaAbs); - turnEv.centSum+=centG;turnEv.latSum+=latEmaAbs;turnEv.n++; } }else if(turnEv.on){ const dur=now-turnEv.t0; if(dur>EVENT_MIN_DURATION){ - turnEvents.push({ - peakLat:turnEv.peakLat,peakYaw:turnEv.peakYaw, - avgCent:turnEv.centSum/turnEv.n,avgLat:turnEv.latSum/turnEv.n,dur - }); + turnEvents.push({peakLat:turnEv.peakLat,peakYaw:turnEv.peakYaw,dur}); updateEvents(); } turnEv.on=false;lastEvEnd=now; @@ -1274,6 +1154,16 @@ function percentile(arr,p){ function median(arr){return percentile(arr,50)} +// IQR outlier rejection - removes values outside 1.5×IQR from Q1/Q3 +function filterOutliers(arr){ + if(arr.length<4)return arr; // Need enough data for IQR + const q1=percentile(arr,25),q3=percentile(arr,75); + const iqr=q3-q1; + if(iqr<0.01)return arr; // All values similar, keep all + const lo=q1-1.5*iqr,hi=q3+1.5*iqr; + return arr.filter(v=>v>=lo&&v<=hi); +} + function confBadge(n,min,good,high){ if(n>=high)return'n='+n+''; if(n>=good)return'n='+n+''; @@ -1281,129 +1171,49 @@ function confBadge(n,min,good,high){ return''; } -// Global storage for generated profiles -let generatedProfiles=null; -let cityBaseline=null; +// Calibrated thresholds for selected mode +let calibratedProfile=null; function computeSuggestions(){ $('results-card').style.display='block'; - $('val-card').style.display='block'; + $('result-mode').textContent=MODE_LABELS[selectedMode]; - // Compute city baseline from collected events + // Compute thresholds from collected events // Use MEDIAN (P50) × 0.7 for entry threshold - cityBaseline={acc:0.10,acc_exit:0.05,brake:0.18,brake_exit:0.09,lat:0.12,lat_exit:0.06,yaw:0.05,min_speed:2.0}; + calibratedProfile={acc:0.10,acc_exit:0.05,brake:0.18,brake_exit:0.09,lat:0.12,lat_exit:0.06,yaw:0.05,min_speed:2.0}; if(accelEvents.length>=3){ - const peaks=accelEvents.map(e=>e.peak); + const peaks=filterOutliers(accelEvents.map(e=>e.peak)); const p50=median(peaks); - cityBaseline.acc=Math.max(0.05,p50*0.7); - cityBaseline.acc_exit=Math.max(0.02,cityBaseline.acc*0.5); + calibratedProfile.acc=Math.max(0.05,p50*0.7); + calibratedProfile.acc_exit=Math.max(0.02,calibratedProfile.acc*0.5); } if(brakeEvents.length>=3){ - const peaks=brakeEvents.map(e=>e.peak); + const peaks=filterOutliers(brakeEvents.map(e=>e.peak)); const p50=median(peaks); - cityBaseline.brake=Math.max(0.08,p50*0.7); - cityBaseline.brake_exit=Math.max(0.04,cityBaseline.brake*0.5); + calibratedProfile.brake=Math.max(0.08,p50*0.7); + calibratedProfile.brake_exit=Math.max(0.04,calibratedProfile.brake*0.5); } if(turnEvents.length>=5){ - const latPeaks=turnEvents.map(e=>e.peakLat); + const latPeaks=filterOutliers(turnEvents.map(e=>e.peakLat)); const latP50=median(latPeaks); - cityBaseline.lat=Math.max(0.05,latP50*0.7); - cityBaseline.lat_exit=Math.max(0.02,cityBaseline.lat*0.5); + calibratedProfile.lat=Math.max(0.05,latP50*0.7); + calibratedProfile.lat_exit=Math.max(0.02,calibratedProfile.lat*0.5); - const yawPeaks=turnEvents.map(e=>e.peakYaw); + const yawPeaks=filterOutliers(turnEvents.map(e=>e.peakYaw)); const yawP50=median(yawPeaks); - cityBaseline.yaw=Math.max(0.02,yawP50*0.7); - } - - // Display baseline values - $('base-acc').textContent=cityBaseline.acc.toFixed(2)+'g'+confBadge(accelEvents.length,3,8,15); - $('base-brake').textContent=cityBaseline.brake.toFixed(2)+'g'+confBadge(brakeEvents.length,3,8,15); - $('base-lat').textContent=cityBaseline.lat.toFixed(2)+'g'+confBadge(turnEvents.length,5,12,20); - $('base-yaw').textContent=(cityBaseline.yaw*57.3).toFixed(1)+'°/s'; - - // Generate all 4 profiles from city baseline - generatedProfiles=generateAllProfiles(cityBaseline); - - // Build profile table - const tbody=$('profile-tbody'); - tbody.innerHTML=''; - const modeOrder=['track','canyon','city','highway']; - const modeLabels={track:'🏁 Track',canyon:'🏔️ Canyon',city:'🏙️ City',highway:'🛣️ Highway'}; - for(const mode of modeOrder){ - const p=generatedProfiles[mode]; - const tr=document.createElement('tr'); - if(mode==='city')tr.className='mode-city'; - tr.innerHTML=''+modeLabels[mode]+''+ - ''+p.acc.toFixed(2)+''+ - ''+p.brake.toFixed(2)+''+ - ''+p.lat.toFixed(2)+''+ - ''+(p.yaw*57.3).toFixed(1)+'°'+ - ''+p.min_speed.toFixed(0)+''; - tbody.appendChild(tr); - } - - // === VALIDATION === - - // GPS Speed - if(gpsSpeedErrors.length>=20){ - const m=gpsSpeedErrors.reduce((a,b)=>a+b,0)/gpsSpeedErrors.length; - $('val-spd').className=m<3?'val-check':m<8?'val-warn':'val-fail'; - $('val-spd').textContent=m<3?'✓ ±'+m.toFixed(1)+'km/h':m<8?'⚠ ±'+m.toFixed(1):'✗ '+m.toFixed(1); - } - - // GPS Accel correlation - if(gpsAccelCorr.length>=10){ - const corr=gpsAccelCorr.map(c=>Math.abs(c.gps-c.vel)); - const m=corr.reduce((a,b)=>a+b,0)/corr.length; - $('val-gpsacc').className=m<0.05?'val-check':m<0.15?'val-warn':'val-fail'; - $('val-gpsacc').textContent=m<0.05?'✓ ±'+m.toFixed(2)+'g':m<0.15?'⚠ ±'+m.toFixed(2)+'g':'✗ ±'+m.toFixed(2)+'g'; - } - - // Accel→speed - if(accelEvents.length>=3){ - const pos=accelEvents.filter(e=>e.dv>0).length; - const r=pos/accelEvents.length; - $('val-acc').className=r>0.7?'val-check':r>0.4?'val-warn':'val-fail'; - $('val-acc').textContent=(r*100).toFixed(0)+'% increased'; - } - - // Brake→speed - if(brakeEvents.length>=3){ - const neg=brakeEvents.filter(e=>e.dv<0).length; - const r=neg/brakeEvents.length; - $('val-brake').className=r>0.7?'val-check':r>0.4?'val-warn':'val-fail'; - $('val-brake').textContent=(r*100).toFixed(0)+'% decreased'; - } - - // Impulse accuracy: compare actual ΔV to G×t predicted - const allEvs=[...accelEvents,...brakeEvents]; - if(allEvs.length>=3){ - const errs=allEvs.map(e=>Math.abs(e.dv-e.expectedDV)); - const m=errs.reduce((a,b)=>a+b,0)/errs.length; - $('val-impulse').className=m<3?'val-check':m<8?'val-warn':'val-fail'; - $('val-impulse').textContent=m<3?'✓ ±'+m.toFixed(1)+'km/h':'⚠ ±'+m.toFixed(1)+'km/h'; + calibratedProfile.yaw=Math.max(0.02,yawP50*0.7); } - // Centripetal correlation - if(centCorr.length>=20){ - const errs=centCorr.map(c=>Math.abs(c.measured-c.theory)); - const m=errs.reduce((a,b)=>a+b,0)/errs.length; - $('val-cent').className=m<0.05?'val-check':m<0.15?'val-warn':'val-fail'; - $('val-cent').textContent=m<0.05?'✓ ±'+m.toFixed(2)+'g':m<0.15?'⚠ ±'+m.toFixed(2)+'g':'✗ Off'; - } - - // GPS heading vs gyro - if(headingVsGyro.length>=10){ - const errs=headingVsGyro.map(h=>Math.abs(h.gps-h.gyro)); - const m=errs.reduce((a,b)=>a+b,0)/errs.length; - $('val-heading').className=m<0.05?'val-check':m<0.15?'val-warn':'val-fail'; - $('val-heading').textContent=m<0.05?'✓ ±'+(m*57.3).toFixed(1)+'°/s':'⚠ ±'+(m*57.3).toFixed(1)+'°/s'; - } + // Display calibrated values (use innerHTML for badge HTML) + $('base-acc').innerHTML=calibratedProfile.acc.toFixed(2)+'g'+confBadge(accelEvents.length,3,8,15); + $('base-brake').innerHTML=calibratedProfile.brake.toFixed(2)+'g'+confBadge(brakeEvents.length,3,8,15); + $('base-lat').innerHTML=calibratedProfile.lat.toFixed(2)+'g'+confBadge(turnEvents.length,5,12,20); + $('base-yaw').innerHTML=(calibratedProfile.yaw*DEG).toFixed(1)+'°/s'; - // Enable apply + // Enable apply when we have enough data if(accelEvents.length>=3&&brakeEvents.length>=3&&turnEvents.length>=5){ $('btn-apply').disabled=false; $('btn-apply').className='btn btn-apply ready'; @@ -1417,7 +1227,6 @@ function generateReport(){ L.push('===================================='); L.push('Date: '+new Date().toISOString()); L.push('Duration: '+Math.floor(dur/60)+'m '+dur%60+'s'); - L.push('Scenario: '+scenario); L.push(''); // Confidence assessment @@ -1432,89 +1241,33 @@ function generateReport(){ // Event statistics if(accelEvents.length){ const p=accelEvents.map(e=>e.peak); - const d=accelEvents.map(e=>e.dur); - const dv=accelEvents.map(e=>e.dv); L.push('ACCELERATION EVENTS:'); L.push(' G-force: min='+Math.min(...p).toFixed(3)+' med='+median(p).toFixed(3)+' max='+Math.max(...p).toFixed(3)); - L.push(' Duration(ms): min='+Math.min(...d)+' med='+Math.floor(median(d))+' max='+Math.max(...d)); - L.push(' ΔSpeed(km/h): min='+Math.min(...dv).toFixed(1)+' med='+median(dv).toFixed(1)+' max='+Math.max(...dv).toFixed(1)); L.push(''); } if(brakeEvents.length){ const p=brakeEvents.map(e=>e.peak); - const d=brakeEvents.map(e=>e.dur); - const dv=brakeEvents.map(e=>e.dv); L.push('BRAKING EVENTS:'); L.push(' G-force: min='+Math.min(...p).toFixed(3)+' med='+median(p).toFixed(3)+' max='+Math.max(...p).toFixed(3)); - L.push(' Duration(ms): min='+Math.min(...d)+' med='+Math.floor(median(d))+' max='+Math.max(...d)); - L.push(' ΔSpeed(km/h): min='+Math.min(...dv).toFixed(1)+' med='+median(dv).toFixed(1)+' max='+Math.max(...dv).toFixed(1)); L.push(''); } if(turnEvents.length){ const lat=turnEvents.map(e=>e.peakLat); - const yaw=turnEvents.map(e=>e.peakYaw*57.3); - const d=turnEvents.map(e=>e.dur); + const yaw=turnEvents.map(e=>e.peakYaw*DEG); L.push('TURN EVENTS:'); L.push(' Lateral G: min='+Math.min(...lat).toFixed(3)+' med='+median(lat).toFixed(3)+' max='+Math.max(...lat).toFixed(3)); L.push(' Yaw(°/s): min='+Math.min(...yaw).toFixed(1)+' med='+median(yaw).toFixed(1)+' max='+Math.max(...yaw).toFixed(1)); - L.push(' Duration(ms): min='+Math.min(...d)+' med='+Math.floor(median(d))+' max='+Math.max(...d)); - L.push(''); - } - - // Physics validation - L.push('PHYSICS VALIDATION:'); - if(gpsSpeedErrors.length){ - const m=gpsSpeedErrors.reduce((a,b)=>a+b,0)/gpsSpeedErrors.length; - const status=m<3?'✓':m<8?'⚠':'✗'; - L.push(' GPS↔Sensor Speed: '+status+' ±'+m.toFixed(1)+' km/h (n='+gpsSpeedErrors.length+')'); - } - if(accelEvents.length){ - const pos=accelEvents.filter(e=>e.dv>0).length; - const r=pos/accelEvents.length; - L.push(' Accel→Speed↑: '+(r>0.7?'✓':r>0.4?'⚠':'✗')+' '+(r*100).toFixed(0)+'%'); - } - if(brakeEvents.length){ - const neg=brakeEvents.filter(e=>e.dv<0).length; - const r=neg/brakeEvents.length; - L.push(' Brake→Speed↓: '+(r>0.7?'✓':r>0.4?'⚠':'✗')+' '+(r*100).toFixed(0)+'%'); - } - if(centCorr.length){ - const errs=centCorr.map(c=>Math.abs(c.measured-c.theory)); - const m=errs.reduce((a,b)=>a+b,0)/errs.length; - L.push(' Centripetal(v×ω): '+(m<0.05?'✓':m<0.15?'⚠':'✗')+' ±'+m.toFixed(3)+'g (n='+centCorr.length+')'); - } - if(headingVsGyro.length){ - const errs=headingVsGyro.map(h=>Math.abs(h.gps-h.gyro)); - const m=errs.reduce((a,b)=>a+b,0)/errs.length; - L.push(' Heading↔Gyro: '+(m<0.05?'✓':'⚠')+' ±'+(m*57.3).toFixed(1)+'°/s (n='+headingVsGyro.length+')'); - } - L.push(''); - - // Learned baseline - if(cityBaseline){ - L.push('LEARNED BASELINE (City):'); - L.push(' Accel: '+cityBaseline.acc.toFixed(3)+'g (exit: '+cityBaseline.acc_exit.toFixed(3)+'g)'); - L.push(' Brake: '+cityBaseline.brake.toFixed(3)+'g (exit: '+cityBaseline.brake_exit.toFixed(3)+'g)'); - L.push(' Lateral: '+cityBaseline.lat.toFixed(3)+'g (exit: '+cityBaseline.lat_exit.toFixed(3)+'g)'); - L.push(' Yaw: '+(cityBaseline.yaw*57.3).toFixed(1)+'°/s ('+cityBaseline.yaw.toFixed(4)+' rad/s)'); L.push(''); } - // All generated profiles - if(generatedProfiles){ - L.push('GENERATED PROFILES (scaled from baseline):'); - L.push(' Mode Accel Brake Lat Yaw MinSpd'); - L.push(' ─────────────────────────────────────────────────'); - for(const[mode,p]of Object.entries(generatedProfiles)){ - const name=(mode+' ').slice(0,8); - L.push(' '+name+' '+p.acc.toFixed(2)+'g '+p.brake.toFixed(2)+'g '+p.lat.toFixed(2)+'g '+(p.yaw*57.3).toFixed(1)+'°/s '+p.min_speed.toFixed(0)+'m/s'); - } - L.push(''); - L.push('SCALING FACTORS USED:'); - L.push(' Track: acc×2.5 brake×2.0 lat×3.0 yaw×2.5'); - L.push(' Canyon: acc×1.5 brake×1.5 lat×1.8 yaw×1.6'); - L.push(' City: acc×1.0 brake×1.0 lat×1.0 yaw×1.0 (baseline)'); - L.push(' Highway: acc×0.8 brake×0.7 lat×0.6 yaw×0.6'); + // Calibrated thresholds for selected mode + if(calibratedProfile){ + L.push('CALIBRATED THRESHOLDS ('+MODE_LABELS[selectedMode]+'):'); + L.push(' Accel: '+calibratedProfile.acc.toFixed(3)+'g (exit: '+calibratedProfile.acc_exit.toFixed(3)+'g)'); + L.push(' Brake: '+calibratedProfile.brake.toFixed(3)+'g (exit: '+calibratedProfile.brake_exit.toFixed(3)+'g)'); + L.push(' Lateral: '+calibratedProfile.lat.toFixed(3)+'g (exit: '+calibratedProfile.lat_exit.toFixed(3)+'g)'); + L.push(' Yaw: '+(calibratedProfile.yaw*DEG).toFixed(1)+'°/s ('+calibratedProfile.yaw.toFixed(4)+' rad/s)'); + L.push(' Min Speed: '+calibratedProfile.min_speed.toFixed(1)+' m/s'); } return L.join('\\n'); @@ -1537,70 +1290,48 @@ async function poll(){ } async function applySettings(){ - if(!generatedProfiles||!cityBaseline){ + if(!calibratedProfile){ alert('Collect more data first'); return; } - // Apply city profile to ESP32 (the baseline) - const p=generatedProfiles.city; + const p=calibratedProfile; try{ + // Apply to ESP32 (saves to NVS automatically) await fetch('/api/settings/set?acc='+p.acc+'&acc_exit='+p.acc_exit+'&brake='+(0-p.brake)+'&brake_exit='+(0-p.brake_exit)+'&lat='+p.lat+'&lat_exit='+p.lat_exit+'&yaw='+p.yaw+'&min_speed='+p.min_speed); - // Save ALL profiles to localStorage for dashboard integration - const calibData={ - version:1, - date:Date.now(), - dateStr:new Date().toISOString(), - scenario, - duration:startTime?Math.floor((Date.now()-startTime)/1000):0, - events:{ - accel:accelEvents.length, - brake:brakeEvents.length, - turn:turnEvents.length - }, - baseline:cityBaseline, - profiles:generatedProfiles, - scales:PROFILE_SCALES - }; - localStorage.setItem('bb_profiles',JSON.stringify(calibData)); + // Save to localStorage for this mode + let profiles=JSON.parse(localStorage.getItem('bb_profiles')||'{}'); + profiles[selectedMode]={...p,calibrated:new Date().toISOString()}; + profiles.lastMode=selectedMode; + localStorage.setItem('bb_profiles',JSON.stringify(profiles)); const rep=generateReport(); $('report').textContent=rep; $('report-card').style.display='block'; - if(confirm('All 4 profiles saved!\\n\\nCity profile applied to device.\\nAll profiles saved to browser.\\n\\nGo to Dashboard to switch profiles?')){ + if(confirm(MODE_LABELS[selectedMode]+' profile saved!\\n\\nThresholds applied to device and saved to NVS.\\nProfile saved to browser for future sessions.\\n\\nGo to Dashboard?')){ window.location.href='/'; } }catch(e){alert('Failed: '+e)} } function exportReport(){ - // Export comprehensive JSON with all data for analysis + const minEvents=Math.min(accelEvents.length,brakeEvents.length,turnEvents.length/2); const exportData={ - version:1, + version:2, exportDate:new Date().toISOString(), - scenario, + mode:selectedMode, duration:startTime?Math.floor((Date.now()-startTime)/1000):0, quality:{ - confidence:Math.min(accelEvents.length,brakeEvents.length,turnEvents.length/2)>=15?'HIGH': - Math.min(accelEvents.length,brakeEvents.length,turnEvents.length/2)>=8?'MEDIUM':'LOW', + confidence:minEvents>=15?'HIGH':minEvents>=8?'MEDIUM':'LOW', eventCounts:{accel:accelEvents.length,brake:brakeEvents.length,turn:turnEvents.length} }, - baseline:cityBaseline, - profiles:generatedProfiles, - scales:PROFILE_SCALES, + calibrated:calibratedProfile, events:{ - accel:accelEvents.map(e=>({peak:+e.peak.toFixed(4),dur:e.dur,dv:+e.dv.toFixed(2),expectedDV:+e.expectedDV.toFixed(2)})), - brake:brakeEvents.map(e=>({peak:+e.peak.toFixed(4),dur:e.dur,dv:+e.dv.toFixed(2),expectedDV:+e.expectedDV.toFixed(2)})), - turn:turnEvents.map(e=>({peakLat:+e.peakLat.toFixed(4),peakYaw:+e.peakYaw.toFixed(4),avgCent:+e.avgCent.toFixed(4),dur:e.dur})) - }, - validation:{ - gpsSpeedError:gpsSpeedErrors.length?+(gpsSpeedErrors.reduce((a,b)=>a+b,0)/gpsSpeedErrors.length).toFixed(2):null, - accelSpeedIncrease:accelEvents.length?+(accelEvents.filter(e=>e.dv>0).length/accelEvents.length).toFixed(3):null, - brakeSpeedDecrease:brakeEvents.length?+(brakeEvents.filter(e=>e.dv<0).length/brakeEvents.length).toFixed(3):null, - centripetalError:centCorr.length?+(centCorr.map(c=>Math.abs(c.measured-c.theory)).reduce((a,b)=>a+b,0)/centCorr.length).toFixed(4):null, - headingGyroError:headingVsGyro.length?+(headingVsGyro.map(h=>Math.abs(h.gps-h.gyro)).reduce((a,b)=>a+b,0)/headingVsGyro.length).toFixed(4):null + accel:accelEvents.map(e=>({peak:+e.peak.toFixed(4),dur:e.dur,dv:+e.dv.toFixed(2)})), + brake:brakeEvents.map(e=>({peak:+e.peak.toFixed(4),dur:e.dur,dv:+e.dv.toFixed(2)})), + turn:turnEvents.map(e=>({peakLat:+e.peakLat.toFixed(4),peakYaw:+e.peakYaw.toFixed(4),dur:e.dur})) }, textReport:generateReport() }; @@ -1608,14 +1339,15 @@ function exportReport(){ const blob=new Blob([JSON.stringify(exportData,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); - a.href=url;a.download='blackbox-calibration-'+Date.now()+'.json'; + a.href=url;a.download='blackbox-'+selectedMode+'-calibration-'+Date.now()+'.json'; a.click(); + setTimeout(()=>URL.revokeObjectURL(url),100); } function toggleRecording(){ const btn=$('btn-start'); - // If trying to START, check for GPS lock first + // Check for GPS lock before starting if(!recording&&!hasGpsLock){ alert('GPS lock required!\\n\\nWait for GPS status to show coordinates (green) before starting calibration.\\n\\nThis ensures accurate speed and position data.'); return; @@ -1624,15 +1356,13 @@ function toggleRecording(){ recording=!recording; if(recording){ accelEvents=[];brakeEvents=[];turnEvents=[]; - gpsSpeedErrors=[];gpsAccelCorr=[];centCorr=[];headingVsGyro=[]; accelEv.on=false;brakeEv.on=false;turnEv.on=false; - prevTs=0;prevGpsTs=0;prevGpsHeading=null;lastEvEnd=0; - emaLonSim=0;emaLatSim=0;emaYawSim=0; // Reset EMA simulation + lastEvEnd=0; + emaLonSim=0;emaLatSim=0;emaYawSim=0; startTime=Date.now(); updateEvents(); $('results-card').style.display='none'; - $('val-card').style.display='none'; $('report-card').style.display='none'; $('btn-apply').disabled=true; $('btn-apply').className='btn btn-apply'; @@ -1660,6 +1390,22 @@ $('btn-start').onclick=toggleRecording; $('btn-apply').onclick=applySettings; $('btn-export').onclick=exportReport; +// Mode selection handlers +document.querySelectorAll('.mode-btn').forEach(btn=>{ + btn.onclick=function(){ + if(recording){alert('Stop calibration first');return} + document.querySelectorAll('.mode-btn').forEach(b=>b.classList.remove('selected')); + this.classList.add('selected'); + selectedMode=this.dataset.mode; + $('mode-desc').textContent=MODE_DESC[selectedMode]; + // Reset results when mode changes + $('results-card').style.display='none'; + $('btn-apply').disabled=true; + $('btn-apply').className='btn btn-apply'; + calibratedProfile=null; + }; +}); + poll(); "#; @@ -1678,7 +1424,8 @@ impl TelemetryServer { let server_config = Configuration { http_port: port, max_uri_handlers: 10, /* Dashboard, autotune, telemetry, status, calibrate, - * settings GET, settings SET, diagnostics page, diagnostics API */ + * settings GET, settings SET, diagnostics page, diagnostics + * API */ max_open_sockets: 8, // HTTP only - no long-lived WebSocket connections stack_size: 10240, ..Default::default() From 6ad84450fd71bbfdd5f640c142d900fb2b982aee Mon Sep 17 00:00:00 2001 From: Jose Cruz Toledo <1273555+jctoledo@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:14:27 -0800 Subject: [PATCH 4/7] autotune: per-mode NVS storage and UI cleanup - Add /autotune page with real-time event detection UI - Independent NVS storage per driving mode (Track/Canyon/City/Highway/Custom) - Active mode persists across power cycles - Session persistence during calibration (1-hour expiry) - Remove dead code: PROFILE_SCALES, generateAllProfiles(), MIN_SPEED_MS - Sync defaults between settings.rs and dashboard JS - Add From trait impls to reduce type conversion boilerplate - Fix docs/index.html outdated autotune description --- CLAUDE.md | 7 + docs/index.html | 2 +- docs/power-management-issue.md | 136 ++++++++ sensors/blackbox/src/main.rs | 78 ++++- sensors/blackbox/src/settings.rs | 279 +++++++++++---- sensors/blackbox/src/websocket_server.rs | 424 +++++++++++++++++++---- 6 files changed, 791 insertions(+), 135 deletions(-) create mode 100644 docs/power-management-issue.md diff --git a/CLAUDE.md b/CLAUDE.md index 05894de..0555441 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,13 @@ You are a seasoned embedded Rust engineer with deep expertise in: - Avoid over-engineering: solve the problem at hand, not hypothetical future problems - Keep abstractions minimal until they prove necessary +**SOLID Principles:** +- **Single Responsibility**: Each module/struct should have one reason to change. Keep sensors, protocols, and business logic separate. +- **Open/Closed**: Design for extension without modification. Use traits to allow new sensor types or protocols without changing core code. +- **Liskov Substitution**: Implementations of traits must be substitutable. If `GpsSensor` trait exists, any implementation should work interchangeably. +- **Interface Segregation**: Prefer small, focused traits over large ones. Don't force implementations to depend on methods they don't use. +- **Dependency Inversion**: High-level modules should depend on abstractions (traits), not concrete implementations. Pass dependencies via constructors or function parameters. + **Testing Philosophy:** - Write meaningful tests that catch real bugs, not boilerplate - **Every test MUST have assertions** (`assert!`, `assert_eq!`, etc.) - a test without assertions proves nothing diff --git a/docs/index.html b/docs/index.html index ca2f0ba..25d30a2 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1075,7 +1075,7 @@

    ZUPT

    Autotune

    -

    Learn vehicle-specific thresholds from a calibration drive. One city drive generates all 4 profiles.

    +

    Learn vehicle-specific thresholds by driving in each mode. Calibrate Track, City, Canyon, and Highway independently.

    diff --git a/docs/power-management-issue.md b/docs/power-management-issue.md new file mode 100644 index 0000000..df86502 --- /dev/null +++ b/docs/power-management-issue.md @@ -0,0 +1,136 @@ +# Power management: automatic sleep for battery conservation + +## Background + +Currently, Blackbox is powered by an external USB powerbank. The long-term goal is to create a **self-contained unit with an integrated battery** - mount it in your car, forget about it, and it just works. + +The problem: if you finish a track session and forget to turn it off, your battery dies. Since there's no connection to the vehicle's ignition, Blackbox needs to be smart about managing its own power. + +## The Goal + +Blackbox should automatically sleep when the car hasn't moved for a while, and wake up when driving resumes - no user intervention required. + +## Measured Power Consumption + +Real measurements from the current hardware: + +| State | Current | Notes | +|-------|---------|-------| +| Full system active | 160-185mA | WiFi AP + telemetry streaming | +| Without IMU | 150-175mA | GPS adds ~35-45mA | +| Without GPS | 125-140mA | IMU adds ~10-15mA | +| ESP32 only | 100-130mA | WiFi dominates power draw | + +**Key insight:** The ESP32 with WiFi uses ~70% of total power. Turning off sensors saves little. The only real win is putting the ESP32 into deep sleep (~10-50µA) - a 3000x reduction. + +### Battery Life Estimates (2000mAh LiPo) + +| State | Current | Runtime | +|-------|---------|---------| +| Active | ~170mA | ~12 hours | +| Full shutdown | ~0mA | Forever (until next power-on) | +| Deep sleep (future) | ~20-30µA | ~1 year standby | + +## Proposed Implementation + +### Phase 1: Timeout-Based Shutdown (No Additional Hardware) + +Simple approach for the first PCB revision: + +1. Detect stationary state using existing sensors (low acceleration, low gyro, low GPS speed) +2. After **20 minutes** of no movement → **full shutdown** +3. User manually powers on when needed (button or power cycle) + +``` +ACTIVE ──[no movement 20min]──► SHUTDOWN (full power off) + │ + [manual button press or power cycle] + │ + ▼ + ACTIVE +``` + +**Pros:** No hardware changes, simple to implement +**Cons:** Requires manual wake-up + +### Phase 2: Wake-on-Motion with LIS3DH (Future PCB) + +Add a dedicated ultra-low-power accelerometer (~$1.50) that stays on and wakes the system: + +1. Same stationary detection as Phase 1 +2. After 20 minutes → **deep sleep** (ESP32 sleeping, LIS3DH watching) +3. LIS3DH detects motion → **automatic wake** + +``` +ACTIVE ──[no movement 20min]──► DEEP SLEEP (~20-30µA) + ▲ │ + │ [LIS3DH motion interrupt] + │ │ + └────────────────────────────────┘ +``` + +**Why LIS3DH?** +- Ultra-low power: 2-6µA (can watch for motion indefinitely) +- Configurable threshold: detect car door, engine start, driving +- Interrupt pin: wakes ESP32 instantly +- The WT901 IMU draws ~10-15mA and can't wake the ESP32 from sleep + +## Open Questions + +### Calibration on Wake + +The IMU calibration currently runs at boot and requires the vehicle to be **completely stationary** for ~10 seconds. What happens if the system wakes while already driving? + +Options to consider: +- **Store calibration in flash (NVS)** - reload last known biases on wake +- **Skip calibration, accept degraded accuracy** - until next stationary period +- **Require stationary start** - don't transition to active until stopped (bad UX) + +### EKF Convergence + +After wake, the EKF state is reset. How quickly will it converge to accurate estimates? +- Position: Should lock on quickly once GPS has a fix (~2-30s depending on warm/cold start) +- Velocity: GPS provides this directly +- Yaw/heading: May drift until GPS velocity gives direction of travel + +Is this acceptable, or do we need to persist EKF state across sleep cycles? + +### GPS Warm Start + +The GPS backup battery keeps ephemeris data alive during sleep. Warm start is ~2 seconds vs cold start ~24 seconds. Need to verify the SparkFun NEO-M9N backup battery stays charged during deep sleep. + +### False Wakes (Phase 2) + +In a busy parking lot, nearby cars or pedestrians might trigger the LIS3DH. Mitigations: +- Higher threshold (0.2g instead of 0.1g) +- Require sustained motion (multiple triggers within 1 second) +- Quick "is this real movement?" check after wake - go back to sleep if not + +### 20-Minute Timeout + +Is 20 minutes the right value? Considerations: +- Too short: annoying at gas stations, red lights in traffic +- Too long: wasted battery in parking lots +- Should this be user-configurable via the dashboard? + +## Hardware Requirements (Phase 2) + +| Component | Purpose | Cost | +|-----------|---------|------| +| LIS3DH accelerometer | Wake-on-motion detection | ~$1.50 | +| Load switches (x2) | Cut power to IMU and GPS | ~$1.60 | +| Passives | Decoupling, pull-ups | ~$0.20 | +| **Total** | | **~$3.30** | + +## Summary + +| Phase | Hardware | Sleep Mode | Wake Method | Complexity | +|-------|----------|------------|-------------|------------| +| 1 | None | Full shutdown | Manual | Low | +| 2 | LIS3DH + load switches | Deep sleep (~25µA) | Automatic (motion) | Medium | + +Phase 1 is good enough for initial testing and proves the detection logic. Phase 2 delivers the "mount and forget" experience. + +--- + +*Measurements taken 2025-01-11 with bench power supply. System: ESP32-C3 DevKitC-02 + WT901 IMU + SparkFun NEO-M9N GPS.* diff --git a/sensors/blackbox/src/main.rs b/sensors/blackbox/src/main.rs index 800516a..9cc3bc4 100644 --- a/sensors/blackbox/src/main.rs +++ b/sensors/blackbox/src/main.rs @@ -70,11 +70,7 @@ fn main() { let sysloop = EspSystemEventLoop::take().unwrap(); let nvs = EspDefaultNvsPartition::take().ok(); - // Load saved mode settings from NVS (before WiFi uses nvs) - let saved_mode_config = nvs - .as_ref() - .and_then(|p| settings::load_settings(p.clone())); - // Keep a clone for later saves + // Keep a clone of NVS partition for settings operations let nvs_for_settings = nvs.as_ref().cloned(); // Create LED for status indication @@ -449,10 +445,27 @@ fn main() { // Create state estimator and telemetry publisher let mut estimator = StateEstimator::new(); - // Apply saved mode settings if available - if let Some(cfg) = saved_mode_config { - info!("Applying saved mode config from NVS"); - estimator.mode_classifier.update_config(cfg); + // Load active mode from NVS and apply its settings + let active_mode = nvs_for_settings + .as_ref() + .map(|p| settings::load_active_mode(p.clone())) + .unwrap_or(settings::DrivingMode::City); + + let mode_config = nvs_for_settings + .as_ref() + .map(|p| settings::load_mode_settings(p.clone(), active_mode)) + .unwrap_or_else(|| active_mode.default_config()); + + info!("Active mode: {} - applying settings", active_mode.name()); + estimator.mode_classifier.update_config(mode_config); + + // Update telemetry state with current mode and settings + if let Some(ref state) = telemetry_state { + state.set_current_mode(active_mode.into()); + state.set_settings(mode_config.into()); + // Clear any pending flags since we just loaded, not user-changed + let _ = state.take_settings_change(); + let _ = state.take_mode_change(); } let mut publisher = TelemetryPublisher::new(udp_stream, mqtt_opt, telemetry_state.clone()); @@ -508,9 +521,10 @@ fn main() { }; estimator.mode_classifier.update_config(new_config); - // Persist to NVS + // Persist to NVS for current mode if let Some(ref nvs) = nvs_for_settings { - settings::save_settings(nvs.clone(), &new_config); + let driving_mode: settings::DrivingMode = state.get_current_mode().into(); + settings::save_mode_settings(nvs.clone(), driving_mode, &new_config); } info!( @@ -527,6 +541,48 @@ fn main() { } status_mgr.led_mut().set_low().unwrap(); } + + // Check for mode change request from web UI + if let Some(new_mode) = state.take_mode_change() { + let driving_mode: settings::DrivingMode = new_mode.into(); + + // Load settings for the new mode from NVS + let new_config = if let Some(ref nvs) = nvs_for_settings { + settings::load_mode_settings(nvs.clone(), driving_mode) + } else { + driving_mode.default_config() + }; + + // Apply to mode classifier + estimator.mode_classifier.update_config(new_config); + + // Save the new active mode to NVS + if let Some(ref nvs) = nvs_for_settings { + settings::save_active_mode(nvs.clone(), driving_mode); + } + + // Update telemetry state + state.set_current_mode(new_mode); + state.set_settings(new_config.into()); + + info!( + ">>> Mode changed to {}: acc={:.2}g, brake={:.2}g, lat={:.2}g, yaw={:.3}rad/s", + driving_mode.name(), + new_config.acc_thr, + new_config.brake_thr, + new_config.lat_thr, + new_config.yaw_thr + ); + + // Visual confirmation: 3 cyan-white alternating flashes + for _ in 0..3 { + status_mgr.led_mut().cyan().unwrap(); + FreeRtos::delay_ms(80); + status_mgr.led_mut().white().unwrap(); + FreeRtos::delay_ms(80); + } + status_mgr.led_mut().set_low().unwrap(); + } } // Periodic diagnostics (serial log every 5s) diff --git a/sensors/blackbox/src/settings.rs b/sensors/blackbox/src/settings.rs index 9057b1a..abe3346 100644 --- a/sensors/blackbox/src/settings.rs +++ b/sensors/blackbox/src/settings.rs @@ -1,4 +1,5 @@ //! NVS-backed settings persistence for mode detection thresholds +//! Supports multiple driving profiles (track, canyon, city, highway, custom) use esp_idf_svc::nvs::{EspNvs, EspNvsPartition, NvsDefault}; use log::{info, warn}; @@ -7,94 +8,254 @@ use crate::mode::ModeConfig; const NAMESPACE: &str = "bb_cfg"; -// Keys (max 15 chars for NVS) -const KEY_ACC: &str = "acc"; -const KEY_ACC_EXIT: &str = "acc_x"; -const KEY_BRAKE: &str = "brk"; -const KEY_BRAKE_EXIT: &str = "brk_x"; -const KEY_LAT: &str = "lat"; -const KEY_LAT_EXIT: &str = "lat_x"; -const KEY_YAW: &str = "yaw"; -const KEY_MIN_SPEED: &str = "spd"; - -/// Load settings from NVS, returns None if not found or error -pub fn load_settings(nvs_partition: EspNvsPartition) -> Option { +// Key for active mode (max 15 chars for NVS keys) +const KEY_MODE: &str = "mode"; + +/// Driving mode profiles +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DrivingMode { + Track, + Canyon, + City, + Highway, + Custom, +} + +impl DrivingMode { + /// Get the NVS key prefix for this mode (single char to save space) + fn prefix(&self) -> &'static str { + match self { + DrivingMode::Track => "t", + DrivingMode::Canyon => "n", // 'n' for caNyon (c is taken by city) + DrivingMode::City => "c", + DrivingMode::Highway => "h", + DrivingMode::Custom => "x", + } + } + + /// Get mode name for logging/display + pub fn name(&self) -> &'static str { + match self { + DrivingMode::Track => "track", + DrivingMode::Canyon => "canyon", + DrivingMode::City => "city", + DrivingMode::Highway => "highway", + DrivingMode::Custom => "custom", + } + } + + /// Parse mode from string + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "track" => Some(DrivingMode::Track), + "canyon" => Some(DrivingMode::Canyon), + "city" => Some(DrivingMode::City), + "highway" => Some(DrivingMode::Highway), + "custom" => Some(DrivingMode::Custom), + _ => None, + } + } + + /// Get default config for this mode + pub fn default_config(&self) -> ModeConfig { + match self { + DrivingMode::Track => ModeConfig { + acc_thr: 0.30, + acc_exit: 0.15, + brake_thr: -0.50, + brake_exit: -0.25, + lat_thr: 0.50, + lat_exit: 0.25, + yaw_thr: 0.15, + min_speed: 3.0, + alpha: 0.35, + }, + DrivingMode::Canyon => ModeConfig { + acc_thr: 0.20, + acc_exit: 0.10, + brake_thr: -0.35, + brake_exit: -0.17, + lat_thr: 0.30, + lat_exit: 0.15, + yaw_thr: 0.10, + min_speed: 2.5, + alpha: 0.35, + }, + DrivingMode::City => ModeConfig { + acc_thr: 0.10, + acc_exit: 0.05, + brake_thr: -0.18, + brake_exit: -0.09, + lat_thr: 0.12, + lat_exit: 0.06, + yaw_thr: 0.05, + min_speed: 2.0, + alpha: 0.35, + }, + DrivingMode::Highway => ModeConfig { + acc_thr: 0.08, + acc_exit: 0.04, + brake_thr: -0.15, + brake_exit: -0.07, + lat_thr: 0.10, + lat_exit: 0.05, + yaw_thr: 0.04, + min_speed: 4.0, + alpha: 0.35, + }, + DrivingMode::Custom => ModeConfig { + // Custom defaults to City + acc_thr: 0.10, + acc_exit: 0.05, + brake_thr: -0.18, + brake_exit: -0.09, + lat_thr: 0.12, + lat_exit: 0.06, + yaw_thr: 0.05, + min_speed: 2.0, + alpha: 0.35, + }, + } + } +} + +/// Load the active mode from NVS, defaults to City +pub fn load_active_mode(nvs_partition: EspNvsPartition) -> DrivingMode { let nvs = match EspNvs::new(nvs_partition, NAMESPACE, true) { + Ok(nvs) => nvs, + Err(_) => return DrivingMode::City, + }; + + let mut buf = [0u8; 16]; + match nvs.get_raw(KEY_MODE, &mut buf) { + Ok(Some(data)) => { + // Find null terminator or use full length + let len = data.iter().position(|&b| b == 0).unwrap_or(data.len()); + let s = core::str::from_utf8(&data[..len]).unwrap_or("city"); + DrivingMode::from_str(s).unwrap_or(DrivingMode::City) + } + _ => DrivingMode::City, + } +} + +/// Save the active mode to NVS +pub fn save_active_mode(nvs_partition: EspNvsPartition, mode: DrivingMode) -> bool { + let mut nvs = match EspNvs::new(nvs_partition, NAMESPACE, true) { Ok(nvs) => nvs, Err(e) => { - warn!("Failed to open NVS namespace: {:?}", e); - return None; + warn!("Failed to open NVS for mode save: {:?}", e); + return false; } }; - // Try to read all values - if any fail, return None (use defaults) - let acc = read_f32(&nvs, KEY_ACC)?; - let acc_exit = read_f32(&nvs, KEY_ACC_EXIT)?; - let brake = read_f32(&nvs, KEY_BRAKE)?; - let brake_exit = read_f32(&nvs, KEY_BRAKE_EXIT)?; - let lat = read_f32(&nvs, KEY_LAT)?; - let lat_exit = read_f32(&nvs, KEY_LAT_EXIT)?; - let yaw = read_f32(&nvs, KEY_YAW)?; - let min_speed = read_f32(&nvs, KEY_MIN_SPEED)?; - - info!( - "Loaded settings from NVS: acc={:.2}, brake={:.2}, lat={:.2}, yaw={:.3}", - acc, brake, lat, yaw - ); - - Some(ModeConfig { - acc_thr: acc, - acc_exit, - brake_thr: brake, - brake_exit, - lat_thr: lat, - lat_exit, - yaw_thr: yaw, - min_speed, - alpha: 0.35, // EMA alpha is not persisted - always use default - }) + nvs.set_raw(KEY_MODE, mode.name().as_bytes()).is_ok() +} + +/// Load settings for a specific mode from NVS +/// Returns the mode's default config if not found in NVS +pub fn load_mode_settings( + nvs_partition: EspNvsPartition, + mode: DrivingMode, +) -> ModeConfig { + let nvs = match EspNvs::new(nvs_partition, NAMESPACE, true) { + Ok(nvs) => nvs, + Err(_) => return mode.default_config(), + }; + + let prefix = mode.prefix(); + + // Try to load all values - if any missing, use mode defaults + let acc = read_f32_prefixed(&nvs, prefix, "acc"); + let acc_exit = read_f32_prefixed(&nvs, prefix, "acc_x"); + let brake = read_f32_prefixed(&nvs, prefix, "brk"); + let brake_exit = read_f32_prefixed(&nvs, prefix, "brk_x"); + let lat = read_f32_prefixed(&nvs, prefix, "lat"); + let lat_exit = read_f32_prefixed(&nvs, prefix, "lat_x"); + let yaw = read_f32_prefixed(&nvs, prefix, "yaw"); + let min_speed = read_f32_prefixed(&nvs, prefix, "spd"); + + // If all values present, use them; otherwise use defaults + if let (Some(a), Some(ae), Some(b), Some(be), Some(l), Some(le), Some(y), Some(s)) = ( + acc, acc_exit, brake, brake_exit, lat, lat_exit, yaw, min_speed, + ) { + info!( + "Loaded {} profile from NVS: acc={:.2}, brake={:.2}, lat={:.2}, yaw={:.3}", + mode.name(), + a, + b, + l, + y + ); + ModeConfig { + acc_thr: a, + acc_exit: ae, + brake_thr: b, + brake_exit: be, + lat_thr: l, + lat_exit: le, + yaw_thr: y, + min_speed: s, + alpha: 0.35, + } + } else { + info!("Using default {} profile (not found in NVS)", mode.name()); + mode.default_config() + } } -/// Save settings to NVS -pub fn save_settings(nvs_partition: EspNvsPartition, config: &ModeConfig) -> bool { +/// Save settings for a specific mode to NVS +pub fn save_mode_settings( + nvs_partition: EspNvsPartition, + mode: DrivingMode, + config: &ModeConfig, +) -> bool { let mut nvs = match EspNvs::new(nvs_partition, NAMESPACE, true) { Ok(nvs) => nvs, Err(e) => { - warn!("Failed to open NVS namespace for write: {:?}", e); + warn!("Failed to open NVS for settings save: {:?}", e); return false; } }; - let success = write_f32(&mut nvs, KEY_ACC, config.acc_thr) - && write_f32(&mut nvs, KEY_ACC_EXIT, config.acc_exit) - && write_f32(&mut nvs, KEY_BRAKE, config.brake_thr) - && write_f32(&mut nvs, KEY_BRAKE_EXIT, config.brake_exit) - && write_f32(&mut nvs, KEY_LAT, config.lat_thr) - && write_f32(&mut nvs, KEY_LAT_EXIT, config.lat_exit) - && write_f32(&mut nvs, KEY_YAW, config.yaw_thr) - && write_f32(&mut nvs, KEY_MIN_SPEED, config.min_speed); + let prefix = mode.prefix(); + + let success = write_f32_prefixed(&mut nvs, prefix, "acc", config.acc_thr) + && write_f32_prefixed(&mut nvs, prefix, "acc_x", config.acc_exit) + && write_f32_prefixed(&mut nvs, prefix, "brk", config.brake_thr) + && write_f32_prefixed(&mut nvs, prefix, "brk_x", config.brake_exit) + && write_f32_prefixed(&mut nvs, prefix, "lat", config.lat_thr) + && write_f32_prefixed(&mut nvs, prefix, "lat_x", config.lat_exit) + && write_f32_prefixed(&mut nvs, prefix, "yaw", config.yaw_thr) + && write_f32_prefixed(&mut nvs, prefix, "spd", config.min_speed); if success { info!( - "Saved settings to NVS: acc={:.2}, brake={:.2}, lat={:.2}, yaw={:.3}", - config.acc_thr, config.brake_thr, config.lat_thr, config.yaw_thr + "Saved {} profile to NVS: acc={:.2}, brake={:.2}, lat={:.2}, yaw={:.3}", + mode.name(), + config.acc_thr, + config.brake_thr, + config.lat_thr, + config.yaw_thr ); } else { - warn!("Failed to save some settings to NVS"); + warn!("Failed to save {} profile to NVS", mode.name()); } success } -fn read_f32(nvs: &EspNvs, key: &str) -> Option { - // NVS stores as blob - read 4 bytes +// === Helper functions === + +fn read_f32_prefixed(nvs: &EspNvs, prefix: &str, key: &str) -> Option { let mut buf = [0u8; 4]; - match nvs.get_raw(key, &mut buf) { + let full_key = format!("{}_{}", prefix, key); + match nvs.get_raw(&full_key, &mut buf) { Ok(Some(_)) => Some(f32::from_le_bytes(buf)), _ => None, } } -fn write_f32(nvs: &mut EspNvs, key: &str, val: f32) -> bool { - nvs.set_raw(key, &val.to_le_bytes()).is_ok() +fn write_f32_prefixed(nvs: &mut EspNvs, prefix: &str, key: &str, val: f32) -> bool { + let full_key = format!("{}_{}", prefix, key); + nvs.set_raw(&full_key, &val.to_le_bytes()).is_ok() } diff --git a/sensors/blackbox/src/websocket_server.rs b/sensors/blackbox/src/websocket_server.rs index fd6a99b..876fd22 100644 --- a/sensors/blackbox/src/websocket_server.rs +++ b/sensors/blackbox/src/websocket_server.rs @@ -1,5 +1,5 @@ use std::sync::{ - atomic::{AtomicBool, AtomicU32, Ordering}, + atomic::{AtomicBool, AtomicU32, AtomicU8, Ordering}, Arc, Mutex, }; @@ -10,6 +10,8 @@ use esp_idf_svc::io::Write; use log::info; use crate::diagnostics::DiagnosticsState; +use crate::mode::ModeConfig; +use crate::settings::DrivingMode; /// Mode detection settings (matching mode.rs ModeConfig) #[derive(Clone, Copy)] @@ -24,6 +26,63 @@ pub struct ModeSettings { pub min_speed: f32, // Minimum speed for mode detection (m/s) } +/// Current driving mode (for API display) +#[derive(Clone, Copy, PartialEq)] +pub enum CurrentMode { + Track, + Canyon, + City, + Highway, + Custom, +} + +impl CurrentMode { + pub fn as_str(&self) -> &'static str { + match self { + CurrentMode::Track => "track", + CurrentMode::Canyon => "canyon", + CurrentMode::City => "city", + CurrentMode::Highway => "highway", + CurrentMode::Custom => "custom", + } + } + + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "track" => Some(CurrentMode::Track), + "canyon" => Some(CurrentMode::Canyon), + "city" => Some(CurrentMode::City), + "highway" => Some(CurrentMode::Highway), + "custom" => Some(CurrentMode::Custom), + _ => None, + } + } +} + +impl From for CurrentMode { + fn from(mode: DrivingMode) -> Self { + match mode { + DrivingMode::Track => CurrentMode::Track, + DrivingMode::Canyon => CurrentMode::Canyon, + DrivingMode::City => CurrentMode::City, + DrivingMode::Highway => CurrentMode::Highway, + DrivingMode::Custom => CurrentMode::Custom, + } + } +} + +impl From for DrivingMode { + fn from(mode: CurrentMode) -> Self { + match mode { + CurrentMode::Track => DrivingMode::Track, + CurrentMode::Canyon => DrivingMode::Canyon, + CurrentMode::City => DrivingMode::City, + CurrentMode::Highway => DrivingMode::Highway, + CurrentMode::Custom => DrivingMode::Custom, + } + } +} + impl Default for ModeSettings { fn default() -> Self { // Matches city preset - sensitive defaults for street driving @@ -40,6 +99,21 @@ impl Default for ModeSettings { } } +impl From for ModeSettings { + fn from(cfg: ModeConfig) -> Self { + Self { + acc_thr: cfg.acc_thr, + acc_exit: cfg.acc_exit, + brake_thr: cfg.brake_thr, + brake_exit: cfg.brake_exit, + lat_thr: cfg.lat_thr, + lat_exit: cfg.lat_exit, + yaw_thr: cfg.yaw_thr, + min_speed: cfg.min_speed, + } + } +} + /// Shared state for telemetry server pub struct TelemetryServerState { /// Latest telemetry packet (raw bytes) @@ -52,6 +126,10 @@ pub struct TelemetryServerState { mode_settings: Mutex, /// Settings changed flag settings_changed: AtomicBool, + /// Current driving mode (0=track, 1=canyon, 2=city, 3=highway, 4=custom) + current_mode: AtomicU8, + /// Mode change requested (new mode value, 255 = no change) + mode_change_requested: AtomicU8, /// Optional diagnostics state (shared with main loop) diagnostics_state: Option>, } @@ -64,6 +142,8 @@ impl TelemetryServerState { calibration_requested: AtomicBool::new(false), mode_settings: Mutex::new(ModeSettings::default()), settings_changed: AtomicBool::new(false), + current_mode: AtomicU8::new(2), // Default to City + mode_change_requested: AtomicU8::new(255), // No change diagnostics_state: None, } } @@ -76,10 +156,61 @@ impl TelemetryServerState { calibration_requested: AtomicBool::new(false), mode_settings: Mutex::new(ModeSettings::default()), settings_changed: AtomicBool::new(false), + current_mode: AtomicU8::new(2), // Default to City + mode_change_requested: AtomicU8::new(255), // No change diagnostics_state: Some(diagnostics), } } + /// Get current mode + pub fn get_current_mode(&self) -> CurrentMode { + match self.current_mode.load(Ordering::SeqCst) { + 0 => CurrentMode::Track, + 1 => CurrentMode::Canyon, + 2 => CurrentMode::City, + 3 => CurrentMode::Highway, + 4 => CurrentMode::Custom, + _ => CurrentMode::City, + } + } + + /// Set current mode (called when mode is loaded from NVS) + pub fn set_current_mode(&self, mode: CurrentMode) { + let val = match mode { + CurrentMode::Track => 0, + CurrentMode::Canyon => 1, + CurrentMode::City => 2, + CurrentMode::Highway => 3, + CurrentMode::Custom => 4, + }; + self.current_mode.store(val, Ordering::SeqCst); + } + + /// Request a mode change (called from API) + pub fn request_mode_change(&self, mode: CurrentMode) { + let val = match mode { + CurrentMode::Track => 0, + CurrentMode::Canyon => 1, + CurrentMode::City => 2, + CurrentMode::Highway => 3, + CurrentMode::Custom => 4, + }; + self.mode_change_requested.store(val, Ordering::SeqCst); + } + + /// Take mode change request (called from main loop) + pub fn take_mode_change(&self) -> Option { + let val = self.mode_change_requested.swap(255, Ordering::SeqCst); + match val { + 0 => Some(CurrentMode::Track), + 1 => Some(CurrentMode::Canyon), + 2 => Some(CurrentMode::City), + 3 => Some(CurrentMode::Highway), + 4 => Some(CurrentMode::Custom), + _ => None, + } + } + /// Get diagnostics state reference pub fn diagnostics(&self) -> Option<&Arc> { self.diagnostics_state.as_ref() @@ -332,46 +463,16 @@ const $=id=>document.getElementById(id); const cv=$('gfc'),ctx=cv.getContext('2d'); const CX=70,CY=70,R=55,SCL=R/2; -// Scaling factors: multiply city (baseline) thresholds by these for other modes -// Based on physics: track pushes vehicle harder, highway is gentle lane changes -// min_speed is constant (2.0 m/s) - physics of cornering doesn't change with driving style -const MIN_SPEED_MS=2.0; -const PROFILE_SCALES={ -track:{acc:2.5,brake:2.0,lat:3.0,yaw:2.5,desc:'Racing/track days'}, -canyon:{acc:1.5,brake:1.5,lat:1.8,yaw:1.6,desc:'Spirited mountain roads'}, -city:{acc:1.0,brake:1.0,lat:1.0,yaw:1.0,desc:'Daily street driving'}, -highway:{acc:0.8,brake:0.7,lat:0.6,yaw:0.6,desc:'Highway cruising'} -}; - -// Default presets (used when no calibration exists) -// min_speed constant at 2.0 m/s - threshold scaling handles sensitivity +// Default presets (must match settings.rs DrivingMode::default_config()) +// Each mode has independent thresholds - calibrate via Autotune page const DEFAULT_PRESETS={ -track:{acc:0.35,acc_exit:0.17,brake:0.55,brake_exit:0.27,lat:0.50,lat_exit:0.25,yaw:0.15,min_speed:2.0,desc:'Racing/track days'}, -canyon:{acc:0.22,acc_exit:0.11,brake:0.35,brake_exit:0.17,lat:0.28,lat_exit:0.14,yaw:0.10,min_speed:2.0,desc:'Spirited mountain roads'}, +track:{acc:0.30,acc_exit:0.15,brake:0.50,brake_exit:0.25,lat:0.50,lat_exit:0.25,yaw:0.15,min_speed:3.0,desc:'Racing/track days'}, +canyon:{acc:0.20,acc_exit:0.10,brake:0.35,brake_exit:0.17,lat:0.30,lat_exit:0.15,yaw:0.10,min_speed:2.5,desc:'Spirited mountain roads'}, city:{acc:0.10,acc_exit:0.05,brake:0.18,brake_exit:0.09,lat:0.12,lat_exit:0.06,yaw:0.05,min_speed:2.0,desc:'Daily street driving'}, -highway:{acc:0.12,acc_exit:0.06,brake:0.22,brake_exit:0.11,lat:0.14,lat_exit:0.07,yaw:0.04,min_speed:2.0,desc:'Highway cruising'} +highway:{acc:0.08,acc_exit:0.04,brake:0.15,brake_exit:0.07,lat:0.10,lat_exit:0.05,yaw:0.04,min_speed:4.0,desc:'Highway cruising'} }; -// Generate all profiles from city baseline thresholds -function generateAllProfiles(cityBase){ -const profiles={}; -for(const[mode,scale]of Object.entries(PROFILE_SCALES)){ -profiles[mode]={ -acc:Math.max(0.05,cityBase.acc*scale.acc), -acc_exit:Math.max(0.02,cityBase.acc_exit*scale.acc), -brake:Math.max(0.08,cityBase.brake*scale.brake), -brake_exit:Math.max(0.04,cityBase.brake_exit*scale.brake), -lat:Math.max(0.05,cityBase.lat*scale.lat), -lat_exit:Math.max(0.02,cityBase.lat_exit*scale.lat), -yaw:Math.max(0.02,cityBase.yaw*scale.yaw), -min_speed:MIN_SPEED_MS, -desc:scale.desc -}; -} -return profiles; -} - -// Get presets - prefer calibrated profiles, fall back to defaults +// Get presets - prefer calibrated profiles from localStorage, fall back to defaults function getPresets(){ const calib=localStorage.getItem('bb_profiles'); if(calib){ @@ -538,7 +639,8 @@ $('s-yaw').value=p.yaw;$('v-yaw').textContent=p.yaw.toFixed(3); $('s-minspd').value=p.min_speed;$('v-minspd').textContent=p.min_speed.toFixed(1); } -// Get most recent autotune profile from localStorage +// Get calibration data from localStorage +// Schema: { profiles: { track: {...}, city: {...}, ... }, lastMode: 'track', date: '...' } function getCalibrationData(){ try{ const data=JSON.parse(localStorage.getItem('bb_profiles')||'null'); @@ -547,7 +649,17 @@ return data; } function isCalibrated(){ const data=getCalibrationData(); -return data&&data.profiles; +return data&&data.profiles&&Object.keys(data.profiles).length>0; +} +// Get autotune profile for current preset (used by Custom mode) +function getAutotuneProfile(){ +const data=getCalibrationData(); +if(data&&data.profiles){ +// Return last calibrated mode's profile, or first available +const mode=data.lastMode||Object.keys(data.profiles)[0]; +return data.profiles[mode]||null; +} +return null; } // Select preset and apply @@ -883,6 +995,7 @@ body{font-family:-apple-system,system-ui,sans-serif;background:#0a0a0f;color:#f0
    Daily street driving - calibrate during normal commute
    +
    📋 How It Works
    @@ -985,6 +1098,56 @@ let lastEvEnd=0; let accelEvents=[]; // {peak, dur, dv} let brakeEvents=[]; // {peak, dur, dv} let turnEvents=[]; // {peakLat, peakYaw, dur} +let calibratedProfile=null; + +// Session persistence - survives page navigation within same browser tab +const SESSION_KEY='bb_autotune_session'; +function saveSession(){ + if(!recording&&accelEvents.length===0)return; // Don't save empty sessions + const session={ + recording,startTime,selectedMode, + accelEvents,brakeEvents,turnEvents, + calibratedProfile, + savedAt:Date.now() + }; + sessionStorage.setItem(SESSION_KEY,JSON.stringify(session)); +} +function clearSession(){ + sessionStorage.removeItem(SESSION_KEY); +} +function restoreSession(){ + try{ + const data=sessionStorage.getItem(SESSION_KEY); + if(!data)return false; + const s=JSON.parse(data); + // Expire sessions older than 1 hour + if(Date.now()-s.savedAt>3600000){clearSession();return false} + // Restore state + recording=s.recording; + startTime=s.startTime; + selectedMode=s.selectedMode; + accelEvents=s.accelEvents||[]; + brakeEvents=s.brakeEvents||[]; + turnEvents=s.turnEvents||[]; + calibratedProfile=s.calibratedProfile; + // Update UI + document.querySelectorAll('.mode-btn').forEach(b=>{ + b.classList.toggle('selected',b.dataset.mode===selectedMode); + }); + $('mode-desc').textContent=MODE_DESC[selectedMode]; + updateEvents(); + if(recording){ + $('btn-start').textContent='Stop Calibration'; + $('btn-start').className='btn btn-start recording'; + } + if(calibratedProfile){ + $('results-card').style.display='block'; + $('btn-apply').disabled=false; + $('btn-apply').className='btn btn-apply ready'; + } + return true; + }catch(e){return false} +} function parsePacket(buf){ const d=new DataView(buf); @@ -1144,6 +1307,7 @@ function updateEvents(){ $('ev-accel').className='event'+(accelEvents.length>=5?' complete':accelEvents.length>=2?' needs-more':''); $('ev-brake').className='event'+(brakeEvents.length>=5?' complete':brakeEvents.length>=2?' needs-more':''); $('ev-turn').className='event'+(turnEvents.length>=10?' complete':turnEvents.length>=4?' needs-more':''); + saveSession(); // Persist state for page navigation } function percentile(arr,p){ @@ -1171,9 +1335,6 @@ function confBadge(n,min,good,high){ return''; } -// Calibrated thresholds for selected mode -let calibratedProfile=null; - function computeSuggestions(){ $('results-card').style.display='block'; $('result-mode').textContent=MODE_LABELS[selectedMode]; @@ -1301,10 +1462,15 @@ async function applySettings(){ await fetch('/api/settings/set?acc='+p.acc+'&acc_exit='+p.acc_exit+'&brake='+(0-p.brake)+'&brake_exit='+(0-p.brake_exit)+'&lat='+p.lat+'&lat_exit='+p.lat_exit+'&yaw='+p.yaw+'&min_speed='+p.min_speed); // Save to localStorage for this mode - let profiles=JSON.parse(localStorage.getItem('bb_profiles')||'{}'); - profiles[selectedMode]={...p,calibrated:new Date().toISOString()}; - profiles.lastMode=selectedMode; - localStorage.setItem('bb_profiles',JSON.stringify(profiles)); + // Schema: { profiles: { track: {...}, city: {...}, ... }, lastMode: 'track', date: '...' } + let stored=JSON.parse(localStorage.getItem('bb_profiles')||'{}'); + if(!stored.profiles)stored.profiles={}; + stored.profiles[selectedMode]={...p,calibrated:new Date().toISOString()}; + stored.lastMode=selectedMode; + stored.date=new Date().toISOString(); + localStorage.setItem('bb_profiles',JSON.stringify(stored)); + + clearSession(); // Clear calibration session after successful save const rep=generateReport(); $('report').textContent=rep; @@ -1360,6 +1526,7 @@ function toggleRecording(){ lastEvEnd=0; emaLonSim=0;emaLatSim=0;emaYawSim=0; startTime=Date.now(); + calibratedProfile=null; // Clear previous calibration updateEvents(); $('results-card').style.display='none'; @@ -1370,12 +1537,14 @@ function toggleRecording(){ btn.textContent='Stop Calibration'; btn.className='btn btn-start recording'; + saveSession(); // Persist recording state }else{ btn.textContent='Start Calibration'; btn.className='btn btn-start'; computeSuggestions(); $('report').textContent=generateReport(); $('report-card').style.display='block'; + saveSession(); // Persist completed calibration } } @@ -1403,9 +1572,28 @@ document.querySelectorAll('.mode-btn').forEach(btn=>{ $('btn-apply').disabled=true; $('btn-apply').className='btn btn-apply'; calibratedProfile=null; + saveSession(); // Persist mode change }; }); +// Load current NVS settings for comparison +async function loadCurrentSettings(){ + try{ + const r=await fetch('/api/settings'); + const s=await r.json(); + if(s.acc!==undefined){ + const el=$('current-settings'); + if(el){ + el.innerHTML='Current NVS: acc:'+s.acc.toFixed(2)+'g brake:'+Math.abs(s.brake).toFixed(2)+'g lat:'+s.lat.toFixed(2)+'g yaw:'+s.yaw.toFixed(2)+' min_spd:'+s.min_speed.toFixed(1)+'m/s'; + el.style.display='block'; + } + } + }catch(e){} +} + +// Initialize: restore session if exists, load current settings +restoreSession(); +loadCurrentSettings(); poll(); "#; @@ -1562,9 +1750,10 @@ impl TelemetryServer { let state_get = state.clone(); server.fn_handler("/api/settings", esp_idf_svc::http::Method::Get, move |req| -> Result<(), esp_idf_svc::io::EspIOError> { let s = state_get.get_settings(); + let mode = state_get.get_current_mode(); let json = format!( - r#"{{"acc":{:.2},"acc_exit":{:.2},"brake":{:.2},"brake_exit":{:.2},"lat":{:.2},"lat_exit":{:.2},"yaw":{:.3},"min_speed":{:.1}}}"#, - s.acc_thr, s.acc_exit, s.brake_thr, s.brake_exit, s.lat_thr, s.lat_exit, s.yaw_thr, s.min_speed + r#"{{"mode":"{}","acc":{:.2},"acc_exit":{:.2},"brake":{:.2},"brake_exit":{:.2},"lat":{:.2},"lat_exit":{:.2},"yaw":{:.3},"min_speed":{:.1}}}"#, + mode.as_str(), s.acc_thr, s.acc_exit, s.brake_thr, s.brake_exit, s.lat_thr, s.lat_exit, s.yaw_thr, s.min_speed ); let content_length = json.len().to_string(); let mut response = req.into_response( @@ -1586,36 +1775,54 @@ impl TelemetryServer { server.fn_handler("/api/settings/set", esp_idf_svc::http::Method::Get, move |req| -> Result<(), esp_idf_svc::io::EspIOError> { let uri = req.uri(); let mut settings = state_set.get_settings(); + let mut mode_change: Option = None; // Parse query parameters if let Some(query) = uri.split('?').nth(1) { for param in query.split('&') { let mut parts = param.split('='); if let (Some(key), Some(val)) = (parts.next(), parts.next()) { - if let Ok(v) = val.parse::() { - match key { - "acc" => settings.acc_thr = v, - "acc_exit" => settings.acc_exit = v, - "brake" => settings.brake_thr = v, - "brake_exit" => settings.brake_exit = v, - "lat" => settings.lat_thr = v, - "lat_exit" => settings.lat_exit = v, - "yaw" => settings.yaw_thr = v, - "min_speed" => settings.min_speed = v, - _ => {} + match key { + "mode" => { + if let Some(m) = CurrentMode::from_str(val) { + mode_change = Some(m); + } + } + _ => { + if let Ok(v) = val.parse::() { + match key { + "acc" => settings.acc_thr = v, + "acc_exit" => settings.acc_exit = v, + "brake" => settings.brake_thr = v, + "brake_exit" => settings.brake_exit = v, + "lat" => settings.lat_thr = v, + "lat_exit" => settings.lat_exit = v, + "yaw" => settings.yaw_thr = v, + "min_speed" => settings.min_speed = v, + _ => {} + } + } } } } } } - state_set.set_settings(settings); - info!("Settings updated: acc={:.2}, acc_exit={:.2}, brake={:.2}, brake_exit={:.2}, lat={:.2}, lat_exit={:.2}, yaw={:.3}, min_speed={:.1}", - settings.acc_thr, settings.acc_exit, settings.brake_thr, settings.brake_exit, settings.lat_thr, settings.lat_exit, settings.yaw_thr, settings.min_speed); + // Handle mode change (main loop will load settings for new mode from NVS) + if let Some(new_mode) = mode_change { + state_set.request_mode_change(new_mode); + info!("Mode change requested: {}", new_mode.as_str()); + } else { + // Regular settings update for current mode + state_set.set_settings(settings); + info!("Settings updated: acc={:.2}, brake={:.2}, lat={:.2}, yaw={:.3}", + settings.acc_thr, settings.brake_thr, settings.lat_thr, settings.yaw_thr); + } + let current_mode = state_set.get_current_mode(); let json = format!( - r#"{{"status":"ok","acc":{:.2},"acc_exit":{:.2},"brake":{:.2},"brake_exit":{:.2},"lat":{:.2},"lat_exit":{:.2},"yaw":{:.3},"min_speed":{:.1}}}"#, - settings.acc_thr, settings.acc_exit, settings.brake_thr, settings.brake_exit, settings.lat_thr, settings.lat_exit, settings.yaw_thr, settings.min_speed + r#"{{"status":"ok","mode":"{}","acc":{:.2},"acc_exit":{:.2},"brake":{:.2},"brake_exit":{:.2},"lat":{:.2},"lat_exit":{:.2},"yaw":{:.3},"min_speed":{:.1}}}"#, + current_mode.as_str(), settings.acc_thr, settings.acc_exit, settings.brake_thr, settings.brake_exit, settings.lat_thr, settings.lat_exit, settings.yaw_thr, settings.min_speed ); let content_length = json.len().to_string(); let mut response = req.into_response( @@ -1716,3 +1923,92 @@ impl TelemetryServer { self.state.clone() } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Validates localStorage schema consistency between dashboard and autotune pages. + /// + /// Both pages MUST use the same schema for bb_profiles: + /// ```json + /// { + /// "profiles": { "track": {...}, "city": {...}, ... }, + /// "lastMode": "track", + /// "date": "2025-01-11T..." + /// } + /// ``` + /// + /// This test catches the bug where autotune saved flat profiles but + /// dashboard expected a `.profiles` wrapper, breaking the main page. + #[test] + fn test_localstorage_schema_consistency() { + // Dashboard page must read from .profiles property + assert!( + DASHBOARD_HTML.contains(".profiles"), + "Dashboard must access .profiles property from localStorage" + ); + assert!( + DASHBOARD_HTML.contains("getCalibrationData()"), + "Dashboard must use getCalibrationData() helper" + ); + assert!( + DASHBOARD_HTML.contains("getAutotuneProfile()"), + "Dashboard must define getAutotuneProfile() function" + ); + + // Autotune page must save with .profiles wrapper + assert!( + AUTOTUNE_HTML.contains("stored.profiles[selectedMode]"), + "Autotune must save to stored.profiles[mode], not stored[mode]" + ); + assert!( + AUTOTUNE_HTML.contains("if(!stored.profiles)stored.profiles={}"), + "Autotune must initialize .profiles object if missing" + ); + + // Both pages must use the same localStorage key + let dashboard_key_count = DASHBOARD_HTML.matches("bb_profiles").count(); + let autotune_key_count = AUTOTUNE_HTML.matches("bb_profiles").count(); + assert!( + dashboard_key_count > 0, + "Dashboard must use 'bb_profiles' localStorage key" + ); + assert!( + autotune_key_count > 0, + "Autotune must use 'bb_profiles' localStorage key" + ); + + // Schema documentation must be present in both pages + assert!( + DASHBOARD_HTML.contains("Schema:"), + "Dashboard must document localStorage schema in comments" + ); + assert!( + AUTOTUNE_HTML.contains("Schema:"), + "Autotune must document localStorage schema in comments" + ); + } + + /// Validates that required JavaScript functions are defined in dashboard + #[test] + fn test_required_js_functions_defined() { + // Dashboard must define these functions + assert!( + DASHBOARD_HTML.contains("function getCalibrationData()"), + "Dashboard must define getCalibrationData()" + ); + assert!( + DASHBOARD_HTML.contains("function getAutotuneProfile()"), + "Dashboard must define getAutotuneProfile()" + ); + assert!( + DASHBOARD_HTML.contains("function isCalibrated()"), + "Dashboard must define isCalibrated()" + ); + assert!( + DASHBOARD_HTML.contains("function getPresets()"), + "Dashboard must define getPresets()" + ); + } +} From 43557833e7e5ec3f5c86c1401edf9d7b10831877 Mon Sep 17 00:00:00 2001 From: Jose Cruz Toledo <1273555+jctoledo@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:21:29 -0800 Subject: [PATCH 5/7] cleanup --- docs/power-management-issue.md | 136 --------------------------------- 1 file changed, 136 deletions(-) delete mode 100644 docs/power-management-issue.md diff --git a/docs/power-management-issue.md b/docs/power-management-issue.md deleted file mode 100644 index df86502..0000000 --- a/docs/power-management-issue.md +++ /dev/null @@ -1,136 +0,0 @@ -# Power management: automatic sleep for battery conservation - -## Background - -Currently, Blackbox is powered by an external USB powerbank. The long-term goal is to create a **self-contained unit with an integrated battery** - mount it in your car, forget about it, and it just works. - -The problem: if you finish a track session and forget to turn it off, your battery dies. Since there's no connection to the vehicle's ignition, Blackbox needs to be smart about managing its own power. - -## The Goal - -Blackbox should automatically sleep when the car hasn't moved for a while, and wake up when driving resumes - no user intervention required. - -## Measured Power Consumption - -Real measurements from the current hardware: - -| State | Current | Notes | -|-------|---------|-------| -| Full system active | 160-185mA | WiFi AP + telemetry streaming | -| Without IMU | 150-175mA | GPS adds ~35-45mA | -| Without GPS | 125-140mA | IMU adds ~10-15mA | -| ESP32 only | 100-130mA | WiFi dominates power draw | - -**Key insight:** The ESP32 with WiFi uses ~70% of total power. Turning off sensors saves little. The only real win is putting the ESP32 into deep sleep (~10-50µA) - a 3000x reduction. - -### Battery Life Estimates (2000mAh LiPo) - -| State | Current | Runtime | -|-------|---------|---------| -| Active | ~170mA | ~12 hours | -| Full shutdown | ~0mA | Forever (until next power-on) | -| Deep sleep (future) | ~20-30µA | ~1 year standby | - -## Proposed Implementation - -### Phase 1: Timeout-Based Shutdown (No Additional Hardware) - -Simple approach for the first PCB revision: - -1. Detect stationary state using existing sensors (low acceleration, low gyro, low GPS speed) -2. After **20 minutes** of no movement → **full shutdown** -3. User manually powers on when needed (button or power cycle) - -``` -ACTIVE ──[no movement 20min]──► SHUTDOWN (full power off) - │ - [manual button press or power cycle] - │ - ▼ - ACTIVE -``` - -**Pros:** No hardware changes, simple to implement -**Cons:** Requires manual wake-up - -### Phase 2: Wake-on-Motion with LIS3DH (Future PCB) - -Add a dedicated ultra-low-power accelerometer (~$1.50) that stays on and wakes the system: - -1. Same stationary detection as Phase 1 -2. After 20 minutes → **deep sleep** (ESP32 sleeping, LIS3DH watching) -3. LIS3DH detects motion → **automatic wake** - -``` -ACTIVE ──[no movement 20min]──► DEEP SLEEP (~20-30µA) - ▲ │ - │ [LIS3DH motion interrupt] - │ │ - └────────────────────────────────┘ -``` - -**Why LIS3DH?** -- Ultra-low power: 2-6µA (can watch for motion indefinitely) -- Configurable threshold: detect car door, engine start, driving -- Interrupt pin: wakes ESP32 instantly -- The WT901 IMU draws ~10-15mA and can't wake the ESP32 from sleep - -## Open Questions - -### Calibration on Wake - -The IMU calibration currently runs at boot and requires the vehicle to be **completely stationary** for ~10 seconds. What happens if the system wakes while already driving? - -Options to consider: -- **Store calibration in flash (NVS)** - reload last known biases on wake -- **Skip calibration, accept degraded accuracy** - until next stationary period -- **Require stationary start** - don't transition to active until stopped (bad UX) - -### EKF Convergence - -After wake, the EKF state is reset. How quickly will it converge to accurate estimates? -- Position: Should lock on quickly once GPS has a fix (~2-30s depending on warm/cold start) -- Velocity: GPS provides this directly -- Yaw/heading: May drift until GPS velocity gives direction of travel - -Is this acceptable, or do we need to persist EKF state across sleep cycles? - -### GPS Warm Start - -The GPS backup battery keeps ephemeris data alive during sleep. Warm start is ~2 seconds vs cold start ~24 seconds. Need to verify the SparkFun NEO-M9N backup battery stays charged during deep sleep. - -### False Wakes (Phase 2) - -In a busy parking lot, nearby cars or pedestrians might trigger the LIS3DH. Mitigations: -- Higher threshold (0.2g instead of 0.1g) -- Require sustained motion (multiple triggers within 1 second) -- Quick "is this real movement?" check after wake - go back to sleep if not - -### 20-Minute Timeout - -Is 20 minutes the right value? Considerations: -- Too short: annoying at gas stations, red lights in traffic -- Too long: wasted battery in parking lots -- Should this be user-configurable via the dashboard? - -## Hardware Requirements (Phase 2) - -| Component | Purpose | Cost | -|-----------|---------|------| -| LIS3DH accelerometer | Wake-on-motion detection | ~$1.50 | -| Load switches (x2) | Cut power to IMU and GPS | ~$1.60 | -| Passives | Decoupling, pull-ups | ~$0.20 | -| **Total** | | **~$3.30** | - -## Summary - -| Phase | Hardware | Sleep Mode | Wake Method | Complexity | -|-------|----------|------------|-------------|------------| -| 1 | None | Full shutdown | Manual | Low | -| 2 | LIS3DH + load switches | Deep sleep (~25µA) | Automatic (motion) | Medium | - -Phase 1 is good enough for initial testing and proves the detection logic. Phase 2 delivers the "mount and forget" experience. - ---- - -*Measurements taken 2025-01-11 with bench power supply. System: ESP32-C3 DevKitC-02 + WT901 IMU + SparkFun NEO-M9N GPS.* From 7fec88b7e614cb0448d089f11e910ef471a6c697 Mon Sep 17 00:00:00 2001 From: Jose Cruz Toledo <1273555+jctoledo@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:18:50 -0800 Subject: [PATCH 6/7] docs: add phone app comparison to clarify accuracy difference --- docs/index.html | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/index.html b/docs/index.html index 25d30a2..f9cc4e5 100644 --- a/docs/index.html +++ b/docs/index.html @@ -986,6 +986,16 @@

    Commercial Systems

    Examples: AiM Solo 2 DL (~$1,200), Racelogic VBOX Sport (~$1,800), Motec systems ($3,000+)

    + +
    +

    What about phone apps?

    +

    + Apps like Harry's LapTimer use your phone's 1Hz GPS—one position per second. At 100 km/h, that's 28 meters between updates. Racing lines are interpolated guesses. Lap times are ±0.3s at best. No sensor fusion, just raw GPS dots. +

    +

    + Blackbox with 25Hz GPS captures position every 1-2 meters. The EKF fuses 200Hz IMU between GPS updates for continuous tracking—the same approach in commercial systems. Phone apps are fine for casual use. Dedicated hardware is for actual accuracy. +

    +
    From 6bc975bc69e0ea4bc04514bad9d2d432047bc98f Mon Sep 17 00:00:00 2001 From: Jose Cruz Toledo <1273555+jctoledo@users.noreply.github.com> Date: Fri, 16 Jan 2026 19:51:44 -0800 Subject: [PATCH 7/7] fix: send mode change before settings to ensure correct NVS slot Dashboard and autotune were sending settings without specifying the target mode, causing settings to be saved to the currently active mode instead of the user's selected mode. - applySettings() now sends mode change first, waits 150ms, then settings - sendSettings() accepts optional mode parameter for mode-aware saves - selectPreset() and saveCfg() pass current preset mode to sendSettings() --- sensors/blackbox/src/websocket_server.rs | 30 ++++++++++++++---------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/sensors/blackbox/src/websocket_server.rs b/sensors/blackbox/src/websocket_server.rs index 876fd22..cfab7d5 100644 --- a/sensors/blackbox/src/websocket_server.rs +++ b/sensors/blackbox/src/websocket_server.rs @@ -9,9 +9,7 @@ use esp_idf_svc::http::server::{Configuration, EspHttpServer}; use esp_idf_svc::io::Write; use log::info; -use crate::diagnostics::DiagnosticsState; -use crate::mode::ModeConfig; -use crate::settings::DrivingMode; +use crate::{diagnostics::DiagnosticsState, mode::ModeConfig, settings::DrivingMode}; /// Mode detection settings (matching mode.rs ModeConfig) #[derive(Clone, Copy)] @@ -673,19 +671,19 @@ b.classList.toggle('active',b.dataset.preset===name); const isCustom=name==='custom'; $('preset-summary').classList.toggle('hidden',isCustom); $('cfg-sliders').classList.toggle('show',isCustom); -// If not custom, apply preset and send to ESP32 +// If not custom, apply preset and send to ESP32 (includes mode switch) if(!isCustom&&PRESETS[name]){ const p=PRESETS[name]; updateSummary(p); applyPresetToSliders(p); -sendSettings(p); +sendSettings(p,name); }else if(isCustom){ // For custom, try to load from autotune profile const at=getAutotuneProfile(); if(at){ const p={acc:at.acc,acc_exit:at.acc_exit,brake:at.brake,brake_exit:at.brake_exit,lat:at.lat,lat_exit:at.lat_exit,yaw:at.yaw,min_speed:at.min_speed}; applyPresetToSliders(p); -sendSettings(p); +sendSettings(p,'custom'); } } } @@ -700,10 +698,13 @@ applyPresetToSliders(PRESETS.city); } } -// Send settings to ESP32 -async function sendSettings(p){ +// Send settings to ESP32 (includes mode change for correct NVS slot) +async function sendSettings(p,mode){ const brake=-Math.abs(p.brake),brake_exit=-Math.abs(p.brake_exit); try{ +// First switch to target mode if specified +if(mode){await fetch('/api/settings/set?mode='+mode);await new Promise(r=>setTimeout(r,150))} +// Then send settings const url='/api/settings/set?acc='+p.acc+'&acc_exit='+p.acc_exit+'&brake='+brake+'&brake_exit='+brake_exit+'&lat='+p.lat+'&lat_exit='+p.lat_exit+'&yaw='+p.yaw+'&min_speed='+p.min_speed; await fetch(url); }catch(e){console.log('Settings send failed:',e)} @@ -715,10 +716,10 @@ var acc=parseFloat($('s-acc').value),accexit=parseFloat($('s-accexit').value),br if(accexit>=acc){alert('Accel Exit must be < Accel Entry');return} if(brakeexit>=brake){alert('Brake Exit must be < Brake Entry');return} if(latexit>=lat){alert('Lateral Exit must be < Lateral Entry');return} -// Send +// Send (with current preset mode for NVS storage) const p={acc,acc_exit:accexit,brake,brake_exit:brakeexit,lat,lat_exit:latexit,yaw,min_speed:minspd}; try{ -await sendSettings(p); +await sendSettings(p,currentPreset); var btn=document.querySelector('.cfg-save'); btn.textContent='Applied';btn.style.background='#10b981'; setTimeout(()=>{btn.textContent='Apply';btn.style.background=''},1500); @@ -1458,7 +1459,11 @@ async function applySettings(){ const p=calibratedProfile; try{ - // Apply to ESP32 (saves to NVS automatically) + // First switch ESP32 to the target mode + await fetch('/api/settings/set?mode='+selectedMode); + // Wait for mode change to propagate to main loop + await new Promise(r=>setTimeout(r,150)); + // Now send settings (will be saved to the correct mode's NVS slot) await fetch('/api/settings/set?acc='+p.acc+'&acc_exit='+p.acc_exit+'&brake='+(0-p.brake)+'&brake_exit='+(0-p.brake_exit)+'&lat='+p.lat+'&lat_exit='+p.lat_exit+'&yaw='+p.yaw+'&min_speed='+p.min_speed); // Save to localStorage for this mode @@ -1928,7 +1933,8 @@ impl TelemetryServer { mod tests { use super::*; - /// Validates localStorage schema consistency between dashboard and autotune pages. + /// Validates localStorage schema consistency between dashboard and autotune + /// pages. /// /// Both pages MUST use the same schema for bb_profiles: /// ```json