From fe6aa9663d4cdabdc6e71db6068f8cd9e3739ffe Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 19 May 2026 13:14:48 +0530 Subject: feat: WiFi beacon price discovery via Vendor IE (two-board verified) Price discovery allows TollGate ESP32 boards to advertise their per-step price via WiFi Vendor-Specific Information Elements (OUI 0xC0FFEE) in beacon and probe response frames. Nearby boards passively scan and build a market view of competing TollGates without requiring internet access. Features: - beacon_price.c/h: 26-byte packed Vendor IE payload (price, step, metric, mint_hash, geohash, npub_hash), injected via esp_wifi_set_vendor_ie() - market.c/h: Passive WiFi scan receiver, vendor IE callback parsing, BSSID-correlated market entries, effective price ranking - GET /market API endpoint: JSON market snapshot with discovered entries - AP-only services: beacon + market + API start on WIFI_EVENT_AP_START, independent of STA connectivity - STA reconnect fix: 2s delay between retries creates scan windows; s_sta_connecting guard prevents double-connect - write-config-ap-only-a/b Makefile targets for STA-less testing - market_tick() in main loop, client price comparison logging Hardware verified: both boards discover each other via Vendor IE beacons. Board A sees TollGate-C0E9CA (RSSI=-30), Board B sees TollGate-B96D80 (RSSI=-25). test-market.mjs: 9/9, test-price-discovery.mjs: 7/7 per board. Unit tests: 45 new assertions across test_beacon_price (28) and test_market (17). All 15 test suites pass. ESP-IDF build clean for ESP32-S3. --- tests/integration/test-market.mjs | 60 +++++++++++++ tests/integration/test-price-discovery.mjs | 138 +++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 tests/integration/test-market.mjs create mode 100644 tests/integration/test-price-discovery.mjs (limited to 'tests/integration') diff --git a/tests/integration/test-market.mjs b/tests/integration/test-market.mjs new file mode 100644 index 0000000..20f062f --- /dev/null +++ b/tests/integration/test-market.mjs @@ -0,0 +1,60 @@ +import { execSync } from 'child_process'; + +const API_URL = `http://${process.env.TOLLGATE_IP || '10.192.45.1'}:2121`; + +function run(cmd) { + try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); } + catch (e) { return e.stdout || null; } +} + +function runJson(cmd) { + const out = run(cmd); + try { return out ? JSON.parse(out) : null; } + catch { return null; } +} + +let passed = 0, failed = 0; +function assert(cond, msg) { + if (cond) { console.log(` PASS: ${msg}`); passed++; } + else { console.log(` FAIL: ${msg}`); failed++; } +} + +console.log('=== test-market (GET /market) ===\n'); + +console.log('--- /market endpoint responds ---'); +{ + const data = runJson(`curl -s --connect-timeout 5 ${API_URL}/market`); + assert(data !== null, '/market returns valid JSON'); + assert(typeof data.count === 'number', `count is number (got ${data?.count})`); + assert(Array.isArray(data.entries), 'entries is array'); +} + +console.log('\n--- /market entry structure ---'); +{ + const data = runJson(`curl -s --connect-timeout 5 ${API_URL}/market`); + if (data && data.entries && data.entries.length > 0) { + const e = data.entries[0]; + assert(typeof e.bssid === 'string', `bssid is string (got ${e.bssid})`); + assert(typeof e.ssid === 'string', `ssid is string (got ${e.ssid})`); + assert(typeof e.rssi === 'number', `rssi is number (got ${e.rssi})`); + assert(typeof e.price_per_step === 'number', `price_per_step is number (got ${e.price_per_step})`); + assert(typeof e.step_size === 'number', `step_size is number (got ${e.step_size})`); + assert(typeof e.metric === 'string', `metric is string (got ${e.metric})`); + } else { + console.log(' SKIP: no entries found (scan may not have run yet)'); + } +} + +console.log('\n--- /market with no discovered TollGates ---'); +{ + const data = runJson(`curl -s --connect-timeout 5 ${API_URL}/market`); + if (data && data.count === 0) { + assert(data.entries.length === 0, 'empty entries array when count=0'); + console.log(' INFO: no nearby TollGates discovered yet (expected if only one board)'); + } else if (data && data.count > 0) { + console.log(` INFO: ${data.count} nearby TollGate(s) discovered`); + } +} + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/integration/test-price-discovery.mjs b/tests/integration/test-price-discovery.mjs new file mode 100644 index 0000000..6762130 --- /dev/null +++ b/tests/integration/test-price-discovery.mjs @@ -0,0 +1,138 @@ +import { execSync } from 'child_process'; + +const BOARD_A_IP = process.env.TOLLGATE_IP || '10.185.47.1'; +const BOARD_B_IP = process.env.TOLLGATE_B_IP || process.env.TOLLGATE_IP_B || '10.192.45.1'; +const API_A = `http://${BOARD_A_IP}:2121`; +const API_B = `http://${BOARD_B_IP}:2121`; + +function run(cmd) { + try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); } + catch (e) { return e.stdout || null; } +} + +function runJson(cmd) { + const out = run(cmd); + try { return out ? JSON.parse(out) : null; } + catch { return null; } +} + +let passed = 0, failed = 0; +function assert(cond, msg) { + if (cond) { console.log(` PASS: ${msg}`); passed++; } + else { console.log(` FAIL: ${msg}`); failed++; } +} + +function canReach(url) { + const result = run(`curl -s --connect-timeout 3 --max-time 5 -o /dev/null -w "%{http_code}" ${url}`); + return result && result.trim() !== '000' && result.trim() !== ''; +} + +console.log('=== test-price-discovery (two-board) ===\n'); + +const reachA = canReach(`${API_A}/market`); +const reachB = canReach(`${API_B}/market`); + +console.log(`Reachability: Board A=${reachA ? 'YES' : 'NO'}, Board B=${reachB ? 'YES' : 'NO'}\n`); + +if (!reachA && !reachB) { + console.log('FATAL: Neither board reachable. Check TOLLGATE_IP and TOLLGATE_B_IP'); + process.exit(1); +} + +console.log('--- Board A: market endpoint ---'); +{ + if (reachA) { + const data = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_A}/market`); + assert(data !== null, 'Board A /market returns JSON'); + assert(typeof data?.count === 'number', `Board A count is ${data?.count}`); + if (data && data.entries) { + console.log(` Board A sees ${data.count} nearby TollGate(s):`); + for (const e of data.entries) { + console.log(` ${e.ssid} (BSSID: ${e.bssid}) — ${e.price_per_step} sats/step, RSSI: ${e.rssi}`); + } + } + } else { + console.log(' SKIP: Board A not reachable'); + } +} + +console.log('\n--- Board B: market endpoint ---'); +{ + if (reachB) { + const data = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_B}/market`); + assert(data !== null, 'Board B /market returns JSON'); + assert(typeof data?.count === 'number', `Board B count is ${data?.count}`); + if (data && data.entries) { + console.log(` Board B sees ${data.count} nearby TollGate(s):`); + for (const e of data.entries) { + console.log(` ${e.ssid} (BSSID: ${e.bssid}) — ${e.price_per_step} sats/step, RSSI: ${e.rssi}`); + } + } + } else { + console.log(' SKIP: Board B not reachable'); + } +} + +console.log('\n--- Cross-discovery: Board A sees Board B ---'); +{ + if (reachA) { + const mktA = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_A}/market`); + if (mktA && mktA.count > 0) { + const foundB = mktA.entries.some(e => + e.ssid.startsWith('TollGate-') && e.bssid !== '' && e.price_per_step > 0 + ); + assert(foundB, `Board A discovered another TollGate (count=${mktA.count})`); + } else { + console.log(' INFO: Board A has 0 entries. Scan may need more time.'); + } + } else { + console.log(' SKIP: Board A not reachable'); + } +} + +console.log('\n--- Cross-discovery: Board B sees Board A ---'); +{ + if (reachB) { + const mktB = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_B}/market`); + if (mktB && mktB.count > 0) { + const foundA = mktB.entries.some(e => + e.ssid.startsWith('TollGate-') && e.bssid !== '' && e.price_per_step > 0 + ); + assert(foundA, `Board B discovered another TollGate (count=${mktB.count})`); + } else { + console.log(' INFO: Board B has 0 entries. Scan may need more time.'); + } + } else { + console.log(' SKIP: Board B not reachable'); + } +} + +console.log('\n--- Discovery data integrity ---'); +{ + const boards = []; + if (reachA) { + const mktA = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_A}/market`); + if (mktA?.entries) boards.push({ name: 'A', data: mktA }); + } + if (reachB) { + const mktB = runJson(`curl -s --connect-timeout 5 --max-time 10 ${API_B}/market`); + if (mktB?.entries) boards.push({ name: 'B', data: mktB }); + } + + for (const { name, data } of boards) { + for (const e of data.entries) { + assert(typeof e.price_per_step === 'number' && e.price_per_step > 0, + `Board ${name} entry has valid price (${e.price_per_step})`); + assert(typeof e.step_size === 'number' && e.step_size > 0, + `Board ${name} entry has valid step_size (${e.step_size})`); + assert(typeof e.metric === 'string' && e.metric.length > 0, + `Board ${name} entry has valid metric (${e.metric})`); + assert(typeof e.rssi === 'number', + `Board ${name} entry has valid RSSI (${e.rssi})`); + break; + } + } +} + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); +process.exit(failed > 0 ? 1 : 0); -- cgit v1.2.3