/** * test_hcsr04_simulation.mjs * * Full end-to-end diagnostic test for the ESP32 + HC-SR04 ultrasonic sensor. * Mirrors exactly what the frontend does: * 1. POST /api/compile/ → get firmware_b64 * 2. WebSocket /api/simulation/ws/{id} * 3. send start_esp32 with firmware + sensors:[{sensor_type:'hc-sr04',…}] * 4. Watch serial output — should see "Distance: N cm", NOT "Out of range" * 5. Send esp32_sensor_update with several distances and verify each one * is reflected in the next serial output line * * Diagnostics printed: * - Every GPIO change on TRIG (GPIO18) and ECHO (GPIO19) is logged * - System events (boot, crash) are logged * - A separate "TRIG/ECHO timeline" is built to show the full pulse sequence * * Run from the backend/ directory: * node test_hcsr04_simulation.mjs [--timeout=60] [--backend=http://localhost:8001] * * Prerequisites: Backend running on http://localhost:8001 */ // ─── Config ─────────────────────────────────────────────────────────────────── const BACKEND = process.env.BACKEND_URL ?? process.argv.find(a => a.startsWith('--backend='))?.slice(10) ?? 'http://localhost:8001'; const WS_BASE = BACKEND.replace(/^https?:/, m => m === 'https:' ? 'wss:' : 'ws:'); const SESSION = `test-hcsr04-${Date.now()}`; const TIMEOUT_S = parseInt( process.argv.find(a => a.startsWith('--timeout='))?.slice(10) ?? '60' ); // ─── ESP32 HC-SR04 sketch (same as the example in examples.ts) ─────────────── const SKETCH = `// ESP32 — HC-SR04 Ultrasonic Distance Sensor // Wiring: TRIG → D18 | ECHO → D19 | VCC → 3V3 | GND → GND #define TRIG_PIN 18 #define ECHO_PIN 19 void setup() { Serial.begin(115200); pinMode(TRIG_PIN, OUTPUT); pinMode(ECHO_PIN, INPUT); Serial.println("ESP32 HC-SR04 ready"); } long measureCm() { digitalWrite(TRIG_PIN, LOW); delayMicroseconds(2); digitalWrite(TRIG_PIN, HIGH); delayMicroseconds(10); digitalWrite(TRIG_PIN, LOW); long d = pulseIn(ECHO_PIN, HIGH, 30000UL); return (d == 0) ? -1 : (long)(d * 0.0343 / 2.0); } void loop() { long cm = measureCm(); if (cm < 0) Serial.println("Out of range"); else Serial.printf("Distance: %ld cm\\n", cm); delay(500); }`; // ─── Sensor config: TRIG=GPIO18, ECHO=GPIO19 ───────────────────────────────── const HCSR04_SENSOR = { sensor_type: 'hc-sr04', pin: 18, // TRIG pin (listened to for HIGH pulse) echo_pin: 19, // ECHO pin (driven HIGH by backend sync handler) distance: 40.0, // cm — echo_us = 40 * 58 = 2320 µs }; // Distance → expected cm conversion // pulseIn measures HIGH duration in µs; firmware: (µs * 0.0343 / 2) → cm // Our driver drives ECHO HIGH for (distance_cm * 58) µs // Expected: (distance_cm * 58 * 0.0343 / 2) ≈ distance_cm * 0.9947 ≈ distance_cm const distanceToCm = (d) => Math.round(d * 58 * 0.0343 / 2); // Test distances to cycle through, with expected serial output const TEST_DISTANCES = [ { distance: 40, label: 'initial (40 cm)' }, { distance: 100, label: 'far (100 cm)' }, { distance: 10, label: 'close (10 cm)' }, { distance: 200, label: 'very far (200 cm)' }, ]; // ─── Logging helpers ────────────────────────────────────────────────────────── const T0 = Date.now(); const ts = () => `[+${((Date.now() - T0) / 1000).toFixed(3)}s]`; const C = { INFO: '\x1b[36m', WARN: '\x1b[33m', ERROR: '\x1b[31m', OK: '\x1b[32m', SERIAL: '\x1b[32m', GPIO: '\x1b[35m', DIAG: '\x1b[33m', RESET: '\x1b[0m', }; const log = (lvl, ...a) => console.log(`${C[lvl] ?? ''}${ts()} [${lvl}]${C.RESET}`, ...a); const info = (...a) => log('INFO', ...a); const ok = (...a) => log('OK', ...a); const err = (...a) => log('ERROR', ...a); const serial = (...a) => log('SERIAL', ...a); const gpiolog = (...a) => log('GPIO', ...a); const diag = (...a) => log('DIAG', ...a); // ─── Step 1: Compile the sketch ─────────────────────────────────────────────── async function compile() { info('Compiling HC-SR04 sketch via POST /api/compile/ ...'); const res = await fetch(`${BACKEND}/api/compile/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ files: [{ name: 'sketch.ino', content: SKETCH }], board_fqbn: 'esp32:esp32:esp32', }), }); if (!res.ok) { const text = await res.text(); throw new Error(`Compilation HTTP ${res.status}: ${text.slice(0, 300)}`); } const body = await res.json(); if (!body.success) { throw new Error(`Compilation error:\n${(body.error ?? body.stderr ?? 'unknown').slice(0, 500)}`); } const firmware_b64 = body.binary_content ?? body.firmware_b64; if (!firmware_b64) { throw new Error(`No firmware in response. Keys: ${Object.keys(body).join(', ')}`); } ok(`Compiled — ${Math.round(firmware_b64.length * 0.75 / 1024)} KB firmware`); return firmware_b64; } // ─── Step 2: Run simulation via WebSocket ───────────────────────────────────── function runSimulation(firmware_b64) { return new Promise((resolve, reject) => { const wsUrl = `${WS_BASE}/api/simulation/ws/${SESSION}`; info(`Connecting WebSocket → ${wsUrl}`); const ws = new WebSocket(wsUrl); // ── State tracking ────────────────────────────────────────────────── let serialLines = []; // all lines received let outOfRangeCount = 0; // "Out of range" count let distanceReadings = []; // {sent, received, line} let trigHigh = 0; // count of TRIG→HIGH events (gpio_change) let echoHighSys = 0; // count of hcsr04_echo_high system events let echoLowSys = 0; // count of hcsr04_echo_low system events // Current distance under test let distIdx = 0; // index into TEST_DISTANCES let currentDist = TEST_DISTANCES[0].distance; let advanceTimer = null; // single pending advance timeout (cancel on reschedule) let readingsAtCurrent = 0; // readings received at currentDist const timer = setTimeout(() => { info(`Timeout (${TIMEOUT_S}s) — stopping`); ws.close(); resolve({ timedOut: true, serialLines, distanceReadings, outOfRangeCount, trigHigh, echoHigh: echoHighSys, echoLow: echoLowSys }); }, TIMEOUT_S * 1000); // ── Schedule one advance (cancels any pending) ────────────────────── function scheduleAdvance(delayMs) { if (advanceTimer) clearTimeout(advanceTimer); advanceTimer = setTimeout(() => { advanceTimer = null; advanceDistance(); }, delayMs); } // ── Advance to the next test distance ─────────────────────────────── function advanceDistance() { distIdx++; if (distIdx >= TEST_DISTANCES.length) { clearTimeout(timer); ws.close(); resolve({ timedOut: false, serialLines, distanceReadings, outOfRangeCount, trigHigh, echoHigh: echoHighSys, echoLow: echoLowSys }); return; } currentDist = TEST_DISTANCES[distIdx].distance; readingsAtCurrent = 0; info(`→ Sending sensor_update: distance=${currentDist} cm (${TEST_DISTANCES[distIdx].label})`); ws.send(JSON.stringify({ type: 'esp32_sensor_update', data: { pin: 18, distance: currentDist }, })); } ws.addEventListener('open', () => { ok('WebSocket connected'); ws.send(JSON.stringify({ type: 'start_esp32', data: { board: 'esp32', firmware_b64, sensors: [HCSR04_SENSOR], wifi_enabled: false, }, })); info(`Sent start_esp32 with HC-SR04: TRIG=GPIO18 ECHO=GPIO19 distance=${HCSR04_SENSOR.distance} cm`); info(`Expected echo_us=${HCSR04_SENSOR.distance * 58} µs → ~${distanceToCm(HCSR04_SENSOR.distance)} cm`); }); ws.addEventListener('message', ev => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } const { type, data } = msg; // ── Serial output ───────────────────────────────────────────────── if (type === 'serial_output') { const text = data?.data ?? ''; for (const line of text.split(/\r?\n/)) { if (!line.trim()) continue; serialLines.push(line); serial(`UART: ${line}`); if (line.includes('HC-SR04 ready')) { info('Firmware booted — waiting for distance readings...'); return; } if (line.includes('Out of range')) { outOfRangeCount++; diag(`⚠ "Out of range" at distance=${currentDist} cm — ECHO may have missed pulseIn window`); readingsAtCurrent++; // Schedule only on the 2nd reading to avoid re-arming the timer // (readings arrive every 500 ms — >= 2 keeps resetting the 800 ms timer) if (readingsAtCurrent === 2) scheduleAdvance(800); return; } const m = line.match(/Distance:\s*(-?\d+)\s*cm/); if (m) { const cm = parseInt(m[1]); distanceReadings.push({ sent: currentDist, received: cm, line }); info(`Distance reading: sent=${currentDist} cm → received=${cm} cm (expected≈${distanceToCm(currentDist)})`); readingsAtCurrent++; // Schedule only on the 2nd reading to avoid re-arming the timer if (readingsAtCurrent === 2) scheduleAdvance(800); } } return; } // ── GPIO changes on TRIG (18) — firmware-driven OUTPUT changes ─── if (type === 'gpio_change') { const { pin, state } = data ?? {}; if (pin === 18) { const tsMs = Date.now() - T0; gpiolog(`GPIO${pin} (TRIG) → ${state ? 'HIGH' : 'LOW '} @ +${tsMs}ms`); if (state === 1) trigHigh++; } return; } // ── System events (boot, hcsr04_echo_high/low, etc.) ────────────── if (type === 'system') { info(`system: ${JSON.stringify(data)}`); // ECHO is driven externally by backend thread — emits system events if (data?.event === 'hcsr04_echo_high') echoHighSys++; if (data?.event === 'hcsr04_echo_low') echoLowSys++; return; } if (type === 'error') { err(`simulation error: ${JSON.stringify(data)}`); return; } }); ws.addEventListener('close', ev => { clearTimeout(timer); info(`WebSocket closed (code=${ev.code})`); resolve({ timedOut: false, serialLines, distanceReadings, outOfRangeCount, trigHigh, echoHigh, echoLow }); }); ws.addEventListener('error', ev => { clearTimeout(timer); err('WebSocket error:', ev.message ?? ev.type); reject(new Error('WebSocket error')); }); }); } // ─── Main ────────────────────────────────────────────────────────────────────── async function main() { console.log('\n' + '═'.repeat(60)); console.log(' TEST: ESP32 + HC-SR04 Ultrasonic Distance Simulation'); console.log(' Session:', SESSION); console.log(' Backend:', BACKEND); console.log(' Timeout:', TIMEOUT_S, 's'); console.log(' TRIG : GPIO18 ECHO: GPIO19'); console.log(' Distances to test:', TEST_DISTANCES.map(d => d.distance + ' cm').join(', ')); console.log('═'.repeat(60) + '\n'); let firmware_b64; try { firmware_b64 = await compile(); } catch (e) { err('Compilation failed:', e.message); process.exit(1); } console.log('\n' + '─'.repeat(60)); console.log(' Starting simulation...'); console.log('─'.repeat(60) + '\n'); const result = await runSimulation(firmware_b64); // ─── GPIO timeline summary ───────────────────────────────────────────────── console.log('\n' + '─'.repeat(60)); console.log(' TRIG / ECHO event timeline'); console.log('─'.repeat(60)); console.log(` TRIG HIGH events (gpio_change) : ${result.trigHigh}`); console.log(` ECHO HIGH events (system) : ${result.echoHigh}`); console.log(` ECHO LOW events (system) : ${result.echoLow}`); console.log(' Note: ECHO is driven externally (system events, not gpio_change)'); if (result.trigHigh > 0 && result.echoHigh === 0) { console.log('\x1b[31m ✗ TRIG fired but ECHO thread never ran\x1b[0m'); console.log('\x1b[33m → Check _on_pin_change hc-sr04 branch in esp32_worker.py\x1b[0m'); } else if (result.echoHigh > 0 && result.outOfRangeCount > 0) { console.log('\x1b[33m ⚠ ECHO fired but some pulseIn() calls timed out\x1b[0m'); console.log('\x1b[33m → Transient OS scheduling jitter — normal for short echo pulses\x1b[0m'); } else if (result.trigHigh > 0 && result.echoHigh === result.trigHigh) { console.log('\x1b[32m ✓ Every TRIG got an ECHO response\x1b[0m'); } // ─── Distance readings summary ───────────────────────────────────────────── console.log('\n' + '─'.repeat(60)); console.log(' Distance readings'); console.log('─'.repeat(60)); if (result.distanceReadings.length === 0) { console.log(' (none — all readings were "Out of range")'); } else { for (const r of result.distanceReadings) { const expected = distanceToCm(r.sent); const delta = Math.abs(r.received - expected); const ok_str = delta <= 5 ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m'; console.log(` ${ok_str} sent=${r.sent} cm → received=${r.received} cm (expected≈${expected}, delta=${delta})`); } } // ─── Summary ────────────────────────────────────────────────────────────── console.log('\n' + '═'.repeat(60)); console.log(' SUMMARY'); console.log('═'.repeat(60)); console.log(` Serial lines received : ${result.serialLines.length}`); console.log(` "Out of range" count : ${result.outOfRangeCount}`); console.log(` Distance readings : ${result.distanceReadings.length}`); console.log(` Timed out : ${result.timedOut}`); console.log(); console.log(' All serial output:'); for (const l of result.serialLines) console.log(` ${l}`); console.log(); // ─── Pass/Fail ───────────────────────────────────────────────────────────── // Tolerance: ±15 cm — simulation timing is not hardware-accurate const TOLERANCE_CM = 15; const uniqueSent = new Set(result.distanceReadings.map(r => r.sent)).size; const correctCount = result.distanceReadings.filter( r => Math.abs(r.received - distanceToCm(r.sent)) <= TOLERANCE_CM ).length; const missRate = result.distanceReadings.length === 0 ? 1 : result.outOfRangeCount / (result.distanceReadings.length + result.outOfRangeCount); if (result.distanceReadings.length >= 3 && uniqueSent >= 2 && correctCount >= 3 && missRate <= 0.3) { console.log('\x1b[32m ✓ PASS — HC-SR04 sensor simulation is working correctly\x1b[0m'); console.log(`\x1b[32m ${correctCount}/${result.distanceReadings.length} readings within ±${TOLERANCE_CM} cm, ` + `${uniqueSent} distances tested, miss rate ${(missRate * 100).toFixed(0)}%\x1b[0m`); process.exit(0); } else if (result.outOfRangeCount > 0 && result.distanceReadings.length === 0) { console.log('\x1b[31m ✗ FAIL — All readings were "Out of range"\x1b[0m'); if (result.trigHigh === 0) { console.log('\x1b[33m → TRIG never went HIGH: sensor registration failed\x1b[0m'); } else if (result.echoHigh === 0) { console.log('\x1b[33m → TRIG fired but ECHO thread never ran\x1b[0m'); console.log('\x1b[33m Check _on_pin_change hc-sr04 branch in esp32_worker.py\x1b[0m'); } else { console.log('\x1b[33m → ECHO fired but pulseIn() timed out every time\x1b[0m'); } process.exit(1); } else if (result.timedOut) { console.log('\x1b[33m ? TIMEOUT — no serial output received\x1b[0m'); process.exit(1); } else { console.log('\x1b[33m ? PARTIAL — not enough readings or too many misses\x1b[0m'); console.log(`\x1b[33m readings=${result.distanceReadings.length} correct=${correctCount} ` + `uniqueDist=${uniqueSent} missRate=${(missRate * 100).toFixed(0)}%\x1b[0m`); process.exit(1); } } main().catch(e => { err('Unhandled error:', e); process.exit(1); });