diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 21e4a33..79f84c7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -9,10 +9,11 @@ Browser-based Progressive Web App for wardriving with MeshCore mesh network devi **Tech Stack**: Vanilla JavaScript (ES6 modules), Web Bluetooth API, Geolocation API, Tailwind CSS v4 **Critical Files**: -- `content/wardrive.js` (4500+ lines) - Main application logic +- `content/wardrive.js` (5200+ lines) - Main application logic +- `content/device-models.json` - Device model database for auto-power selection - `content/mc/` - MeshCore BLE protocol library (Connection classes, Packet parsing, Buffer utilities) - `index.html` - Single-page UI with embedded Leaflet map -- `docs/` - Comprehensive workflow documentation (CONNECTION_WORKFLOW.md, PING_WORKFLOW.md, etc.) +- `docs/` - Comprehensive workflow documentation (CONNECTION_WORKFLOW.md, PING_WORKFLOW.md, DEVICE_MODEL_MAPPING.md, etc.) ## Architecture & Data Flow @@ -22,9 +23,19 @@ Three-layer connection system: - **Protocol Layer**: `Connection.js` (2200+ lines) implements MeshCore companion protocol - packet framing, encryption, channel management, device queries - **App Layer**: `wardrive.js` orchestrates connect/disconnect workflows with 10-step sequences (see `docs/CONNECTION_WORKFLOW.md`) -**Connect Sequence**: BLE GATT → Protocol Handshake → Device Info → Time Sync → Capacity Check (API slot acquisition) → Channel Setup → GPS Init → Connected +**Connect Sequence**: BLE GATT → Protocol Handshake → Device Info → Device Model Auto-Power → Time Sync → Capacity Check (API slot acquisition) → Channel Setup → GPS Init → Connected -### 2. Ping Lifecycle & API Queue System +### 2. Device Model Auto-Power Selection (NEW) +Automatic power configuration based on detected hardware: +- **Database**: `device-models.json` contains 32+ MeshCore device variants with recommended power levels +- **Detection**: `deviceQuery()` returns manufacturer string (e.g., "Ikoka Stick-E22-30dBm (Xiao_nrf52)nightly-e31c46f") +- **Parsing**: `parseDeviceModel()` strips build suffix ("nightly-COMMIT") for database matching +- **Lookup**: `findDeviceConfig()` searches database for exact/partial match +- **Auto-Set**: `autoSetPowerLevel()` configures radio power after successful deviceQuery() +- **Critical Safety**: PA amplifier models (33dBm, 30dBm) require specific input power to avoid hardware damage +- See `docs/DEVICE_MODEL_MAPPING.md` for complete architecture + +### 3. Ping Lifecycle & API Queue System Two independent data flows merge into a unified API batch queue: **TX Flow** (Transmit): @@ -51,7 +62,7 @@ Two independent data flows merge into a unified API batch queue: - **Ottawa Geofence**: 150km radius from Parliament Hill (45.4215, -75.6972) - hard boundary - **Min Distance Filter**: 25m between pings (prevents spam, separate from 25m RX batch trigger) -### 4. State Management +### 5. State Management Global `state` object tracks: - `connection`: Active BLE connection instance - `wardrivingChannel`: Channel object for ping sends @@ -59,6 +70,10 @@ Global `state` object tracks: - `lastPingLat/Lon`: For distance validation - `cooldownEndTime`: 7-second cooldown after each ping - `sessionId`: UUID for correlating TX/RX events per wardrive session +- `deviceModel`: Full manufacturer string from deviceQuery() +- `autoPowerSet`: Boolean tracking if power was automatically configured + +**Device Model Database**: `DEVICE_MODELS` global array loaded from JSON on page load **RX Batch Buffer**: `Map` keyed by repeater node ID → `{rxEvents: [], bufferedSince, lastFlushed, flushTimerId}` @@ -105,9 +120,14 @@ Used by `startAutoCountdown()`, `startRxListeningCountdown()`, cooldown logic. ### 2. Channel Hash & Decryption **Pre-computed at startup**: ```javascript -WARDRIVING_CHANNEL_KEY = await deriveChannelKey("#wardriving"); // PBKDF2 SHA-256 +WARDRIVING_CHANNEL_KEY = await deriveChannelKey("#wardriving"); // SHA-256 for hashtag channels WARDRIVING_CHANNEL_HASH = await computeChannelHash(key); // PSK channel identifier ``` + +**Channel Key Types**: +- **Hashtag channels** (`#wardriving`, `#testing`, `#ottawa`): Keys derived via SHA-256 of channel name +- **Public channel** (`Public`, no hashtag): Uses fixed key `8b3387e9c5cdea6ac9e5edbaa115cd72` (default MeshCore channel) + Used for: - Repeater echo detection (match `channelHash` in received packets) - Message decryption (AES-ECB via aes-js library) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 58db8ef..a959cf3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -49,7 +49,7 @@ jobs: cp -r main-content/* _site/ 2>/dev/null || true # MAIN: inject release version into root site - sed -i 's|]*>[^<]*|'"${RELEASE_VERSION}"'|' _site/index.html + sed -i 's|]*>[^<]*|'"${RELEASE_VERSION}"'|' _site/index.html sed -i 's|const APP_VERSION = "UNKNOWN";|const APP_VERSION = "'"${RELEASE_VERSION}"'";|' _site/content/wardrive.js # DEV: copy dev site under /dev @@ -59,7 +59,7 @@ jobs: # DEV: inject DEV-EPOCH DEV_EPOCH=$(date -u +%s) - sed -i 's|]*>[^<]*|DEV-'"${DEV_EPOCH}"'|' _site/dev/index.html + sed -i 's|]*>[^<]*|DEV-'"${DEV_EPOCH}"'|' _site/dev/index.html sed -i 's|const APP_VERSION = "UNKNOWN";|const APP_VERSION = "DEV-'"${DEV_EPOCH}"'";|' _site/dev/content/wardrive.js find _site -name ".git" -exec rm -rf {} + 2>/dev/null || true diff --git a/README.md b/README.md index 40be602..1f6a6f8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MeshCore GOME WarDriver -[![Version](https://img.shields.io/badge/version-1.7.0-blue.svg)](https://github.com/MrAlders0n/MeshCore-GOME-WarDriver/releases/tag/v1.7.0) +[![Version](https://img.shields.io/badge/version-1.8.0-blue.svg)](https://github.com/MrAlders0n/MeshCore-GOME-WarDriver/releases/tag/v1.8.0) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Platform](https://img.shields.io/badge/platform-Android%20%7C%20iOS-orange.svg)](#platform-support) diff --git a/content/device-models.json b/content/device-models.json new file mode 100644 index 0000000..8945fab --- /dev/null +++ b/content/device-models.json @@ -0,0 +1,277 @@ +{ + "version": "1.0.0", + "generated": "2026-01-04", + "source": "MeshCore firmware repository - github.com/meshcore-dev/MeshCore", + "devices": [ + { + "manufacturer": "Ikoka Stick-E22-22dBm (Xiao_nrf52)", + "shortName": "Ikoka Stick", + "power": 0.3, + "platform": "nrf52", + "txPower": 22, + "notes": "EBYTE E22-900M22S, no PA amplifier" + }, + { + "manufacturer": "Ikoka Stick-E22-30dBm (Xiao_nrf52)", + "shortName": "Ikoka Stick", + "power": 1.0, + "platform": "nrf52", + "txPower": 20, + "notes": "EBYTE E22-900M30S, 1W PA: 20dBm input → 30dBm output" + }, + { + "manufacturer": "Ikoka Stick-E22-33dBm (Xiao_nrf52)", + "shortName": "Ikoka Stick", + "power": 2.0, + "platform": "nrf52", + "txPower": 9, + "notes": "EBYTE E22-900M33S, 2W PA: 9dBm input → 33dBm output (avoid hardware damage)" + }, + { + "manufacturer": "Ikoka Nano-E22-22dBm (Xiao_nrf52)", + "shortName": "Ikoka Nano", + "power": 0.3, + "platform": "nrf52", + "txPower": 22, + "notes": "EBYTE E22-900M22S, no PA amplifier" + }, + { + "manufacturer": "Ikoka Nano-E22-30dBm (Xiao_nrf52)", + "shortName": "Ikoka Nano", + "power": 1.0, + "platform": "nrf52", + "txPower": 20, + "notes": "EBYTE E22-900M30S, 1W PA: 20dBm input → 30dBm output" + }, + { + "manufacturer": "Ikoka Nano-E22-33dBm (Xiao_nrf52)", + "shortName": "Ikoka Nano", + "power": 2.0, + "platform": "nrf52", + "txPower": 9, + "notes": "EBYTE E22-900M33S, 2W PA: 9dBm input → 33dBm output (avoid hardware damage)" + }, + { + "manufacturer": "Ikoka Handheld E22 30dBm (Xiao_nrf52)", + "shortName": "Ikoka Handheld", + "power": 1.0, + "platform": "nrf52", + "txPower": 20, + "notes": "EBYTE E22-900M30S, 1W PA: 20dBm input → 30dBm output" + }, + { + "manufacturer": "Elecrow ThinkNode-M1", + "shortName": "Elecrow ThinkNode M1", + "power": 0.3, + "platform": "nrf52", + "txPower": 22, + "notes": "Elecrow ThinkNode M1" + }, + { + "manufacturer": "Heltec V2", + "shortName": "Heltec V2", + "power": 0.3, + "platform": "esp32", + "txPower": 20, + "notes": "Heltec WiFi LoRa 32 V2" + }, + { + "manufacturer": "Heltec V3", + "shortName": "Heltec V3", + "power": 0.3, + "platform": "esp32-s3", + "txPower": 22, + "notes": "Heltec WiFi LoRa 32 V3" + }, + { + "manufacturer": "Heltec V4", + "shortName": "Heltec V4", + "power": 0.6, + "platform": "esp32-s3", + "txPower": 10, + "notes": "Firmware power 10dBm, PA amplifier to 22dBm actual output" + }, + { + "manufacturer": "Heltec E213", + "shortName": "Heltec E213", + "power": 0.3, + "platform": "esp32-s3", + "txPower": 22, + "notes": "Heltec E-Ink display variant" + }, + { + "manufacturer": "Heltec E290", + "shortName": "Heltec E290", + "power": 0.3, + "platform": "esp32-s3", + "txPower": 22, + "notes": "Heltec E-Ink display variant" + }, + { + "manufacturer": "Heltec T190", + "shortName": "Heltec T190", + "power": 0.3, + "platform": "esp32-s3", + "txPower": 22, + "notes": "Heltec T190 tracker variant" + }, + { + "manufacturer": "Heltec T114", + "shortName": "Heltec T114", + "power": 0.3, + "platform": "nrf52", + "txPower": 22, + "notes": "Heltec T114 tracker variant" + }, + { + "manufacturer": "Heltec Tracker V2", + "shortName": "Heltec Tracker V2", + "power": 0.3, + "platform": "esp32-s3", + "txPower": 10, + "notes": "Firmware power 10dBm, PA amplifier to 22dBm actual output" + }, + { + "manufacturer": "Heltec Mesh Solar", + "shortName": "Heltec Mesh Solar", + "power": 0.3, + "platform": "nrf52", + "txPower": 22, + "notes": "Solar-powered variant" + }, + { + "manufacturer": "Heltec MeshPocket", + "shortName": "Heltec MeshPocket", + "power": 0.3, + "platform": "nrf52", + "txPower": 22, + "notes": "Pocket-sized handheld" + }, + { + "manufacturer": "Heltec CT62", + "shortName": "Heltec CT62", + "power": 0.3, + "platform": "esp32-c3", + "txPower": 22, + "notes": "ESP32-C3 based variant" + }, + { + "manufacturer": "RAK 4631", + "shortName": "RAK 4631", + "power": 0.3, + "platform": "nrf52", + "txPower": 22, + "notes": "RAKwireless WisBlock Core" + }, + { + "manufacturer": "RAK 3x72", + "shortName": "RAK 3x72", + "power": 0.3, + "platform": "stm32", + "txPower": 22, + "notes": "RAKwireless STM32WLE5 module" + }, + { + "manufacturer": "LilyGo T-Echo", + "shortName": "LilyGo T-Echo", + "power": 0.3, + "platform": "nrf52", + "txPower": 22, + "notes": "E-Ink display variant (both T-Echo and T-Echo Lite)" + }, + { + "manufacturer": "LilyGo T-Deck", + "shortName": "LilyGo T-Deck", + "power": 0.3, + "platform": "esp32-s3", + "txPower": 22, + "notes": "Full keyboard handheld" + }, + { + "manufacturer": "LilyGo T-Beam", + "shortName": "LilyGo T-Beam", + "power": 0.3, + "platform": "esp32", + "txPower": 22, + "notes": "GPS tracker variant" + }, + { + "manufacturer": "LILYGO T-LoRa V2.1-1.6", + "shortName": "LILYGO T-LoRa V2.1", + "power": 0.3, + "platform": "esp32", + "txPower": 20, + "notes": "OLED display variant" + }, + { + "manufacturer": "Seeed Wio E5 Dev Board", + "shortName": "Wio E5 Dev Board", + "power": 0.3, + "platform": "stm32", + "txPower": 22, + "notes": "STM32WLE5 integrated LoRa" + }, + { + "manufacturer": "Xiao C3", + "shortName": "Xiao C3", + "power": 0.3, + "platform": "esp32-c3", + "txPower": 22, + "notes": "Seeed Xiao ESP32-C3" + }, + { + "manufacturer": "Xiao C6", + "shortName": "Xiao C6", + "power": 0.3, + "platform": "esp32-c6", + "txPower": 22, + "notes": "Seeed Xiao ESP32-C6" + }, + { + "manufacturer": "Xiao S3 WIO", + "shortName": "Xiao S3 WIO", + "power": 0.3, + "platform": "esp32-s3", + "txPower": 22, + "notes": "Seeed Xiao ESP32-S3 with WIO expansion" + }, + { + "manufacturer": "Tiny Relay", + "shortName": "Tiny Relay", + "power": 0.3, + "platform": "stm32", + "txPower": 22, + "notes": "STM32-based relay control board" + }, + { + "manufacturer": "Minewsemi", + "shortName": "Minewsemi", + "power": 0.3, + "platform": "nrf52", + "txPower": 22, + "notes": "Minewsemi ME25LS01 tracker" + }, + { + "manufacturer": "Station G2", + "shortName": "Station G2", + "power": 1.0, + "platform": "esp32-s3", + "txPower": 19, + "notes": "Custom power 19dBm (RX boosted gain disabled for RF performance)" + } + ], + "powerMapping": { + "0.3": "24 dBm and below - Standard devices without PA", + "0.6": "28 dBm - Heltek v4", + "1.0": "30 dBm - 1W PA modules (E22-900M30S): 20dBm input → 30dBm output", + "2.0": "33 dBm - 2W PA modules (E22-900M33S): 9dBm input → 33dBm output" + }, + "notes": [ + "Build suffix (e.g., 'nightly-e31c46f') is appended at compile time and should be stripped for matching", + "Power values are wardrive.js radio power settings (0.3, 0.6, 1.0, 2.0)", + "txPower values are firmware LORA_TX_POWER settings in dBm", + "PA (Power Amplifier) modules boost input power to higher output power", + "Some devices may report different strings based on firmware version", + "For unknown devices, default to power 0.3 (safest conservative setting)" + ] +} diff --git a/content/mc/connection/connection.js b/content/mc/connection/connection.js index 90132cc..8b5dc6a 100644 --- a/content/mc/connection/connection.js +++ b/content/mc/connection/connection.js @@ -260,6 +260,24 @@ class Connection extends EventEmitter { await this.sendToRadioFrame(data.toBytes()); } + /** + * Send companion GetStats command + * @param {number} statsType - One of Constants.StatsTypes (Core=0, Radio=1, Packets=2) + * @returns {Promise} + */ + async sendCommandGetStats(statsType) { + const data = new BufferWriter(); + data.writeByte(Constants.CommandCodes.GetStats); + data.writeByte(statsType); + // Debug: indicate stats request being sent + try { + console.debug(`[DEBUG] [BLE] sendCommandGetStats: requesting statsType=${statsType}`); + } catch (e) { + // ignore + } + await this.sendToRadioFrame(data.toBytes()); + } + async sendCommandGetChannel(channelIdx) { const data = new BufferWriter(); data.writeByte(Constants.CommandCodes.GetChannel); @@ -348,6 +366,8 @@ class Connection extends EventEmitter { this.onBatteryVoltageResponse(bufferReader); } else if(responseCode === Constants.ResponseCodes.DeviceInfo){ this.onDeviceInfoResponse(bufferReader); + } else if(responseCode === Constants.ResponseCodes.Stats){ + this.onStatsResponse(bufferReader); } else if(responseCode === Constants.ResponseCodes.PrivateKey){ this.onPrivateKeyResponse(bufferReader); } else if(responseCode === Constants.ResponseCodes.Disabled){ @@ -560,6 +580,41 @@ class Connection extends EventEmitter { }); } + /** + * Handle incoming Stats response frames and emit a parsed object + * Radio stats payload format (firmware): + * Emits: Constants.ResponseCodes.Stats with an object { statsType, noiseFloor, lastRssi, lastSnr, txAirSecs, rxAirSecs } + * @param {BufferReader} bufferReader + */ + onStatsResponse(bufferReader) { + // stats response format: ... payload depends on type + const statsType = bufferReader.readByte(); + if (statsType === Constants.StatsTypes.Radio) { + const noiseFloor = bufferReader.readInt16LE(); + const lastRssi = bufferReader.readInt8(); + const lastSnr = bufferReader.readInt8() / 4; // scaled by 4 in firmware + const txAirSecs = bufferReader.readUInt32LE(); + const rxAirSecs = bufferReader.readUInt32LE(); + // Debug: parsed radio stats + try { console.debug(`[DEBUG] [BLE] onStatsResponse: radio stats parsed noise=${noiseFloor} rssi=${lastRssi} snr=${lastSnr}`); } catch(e){} + this.emit(Constants.ResponseCodes.Stats, { + statsType: statsType, + noiseFloor: noiseFloor, + lastRssi: lastRssi, + lastSnr: lastSnr, + txAirSecs: txAirSecs, + rxAirSecs: rxAirSecs + }); + } else { + // Unknown stats type - forward raw remaining bytes + try { console.debug(`[DEBUG] [BLE] onStatsResponse: unknown statsType=${statsType}`); } catch(e){} + this.emit(Constants.ResponseCodes.Stats, { + statsType: statsType, + raw: bufferReader.readRemainingBytes() + }); + } + } + onPrivateKeyResponse(bufferReader) { this.emit(Constants.ResponseCodes.PrivateKey, { privateKey: bufferReader.readBytes(64), @@ -634,6 +689,42 @@ class Connection extends EventEmitter { }); } + /** + * Request radio stats (stats type = Radio) and return parsed object via Promise + * @param {number|null} timeoutMillis - optional timeout in ms + * @returns {Promise} Resolves with stats object or rejects on timeout/error + */ + getRadioStats(timeoutMillis = null) { + return new Promise(async (resolve, reject) => { + + // listen for stats response + this.once(Constants.ResponseCodes.Stats, (stats) => { + // Only resolve radio stats if type matches, otherwise pass through + if (stats && stats.statsType === Constants.StatsTypes.Radio) { + resolve(stats); + } else { + // Not radio stats - resolve with whatever arrived + resolve(stats); + } + }); + + if (timeoutMillis != null) { + setTimeout(() => { + try { console.error('[DEBUG] [BLE] getRadioStats timeout'); } catch(e){} + reject(new Error('getRadioStats timeout')) + }, timeoutMillis); + } + + try { + await this.sendCommandGetStats(Constants.StatsTypes.Radio); + } catch (e) { + try { console.error(`[DEBUG] [BLE] sendCommandGetStats failed: ${e && e.message ? e.message : e}`); } catch(err){} + reject(e); + } + + }); + } + onContactMsgRecvResponse(bufferReader) { this.emit(Constants.ResponseCodes.ContactMsgRecv, { pubKeyPrefix: bufferReader.readBytes(6), diff --git a/content/mc/constants.js b/content/mc/constants.js index 2eb2706..945135a 100644 --- a/content/mc/constants.js +++ b/content/mc/constants.js @@ -50,6 +50,9 @@ class Constants { // todo set device pin command SetOtherParams: 38, SendTelemetryReq: 39, + + // Request various statistics from companion (CMD_GET_STATS) + GetStats: 56, // 0x38 SendBinaryReq: 50, } @@ -74,6 +77,8 @@ class Constants { ChannelInfo: 18, SignStart: 19, Signature: 20, + // Stats response (RESP_CODE_STATS = 0x18 = 24) + Stats: 24, } static PushCodes = { @@ -126,6 +131,13 @@ class Constants { GetNeighbours: 0x06, // #define REQ_TYPE_GET_NEIGHBOURS 0x06 } + // Types of statistics that can be requested with GetStats + static StatsTypes = { + Core: 0, + Radio: 1, + Packets: 2, + } + } export default Constants; diff --git a/content/style.css b/content/style.css index d0209fd..4dda1f8 100644 --- a/content/style.css +++ b/content/style.css @@ -1,6 +1,7 @@ +/* MeshMapper Wardrive Custom Styles */ + html, -body, -#map { +body { height: 100%; margin: 0; } @@ -11,174 +12,6 @@ body, pointer-events: none; } -.interactive { - cursor: pointer; -} - -/* For DIV markers */ -.highlighted { - border-color: #ffc671 !important; - background-color: #e37304 !important; - color: white !important; -} - -/* For SVG paths */ -.highlighted-path { - stroke: #ffc671 !important; - fill: #e37304 !important; - fill-opacity: 0.7 !important; -} - -.dimmed-path { - opacity: 0.5 !important; - fill-opacity: 0.25 !important; -} - -#map { - position: absolute; - inset: 0; -} - -.leaflet-interactive.marker-shadow { - filter: url('#marker-shadow') -} - -.repeater-dot { - width: 16px; - height: 16px; - border-radius: 50%; - border: 1px solid #0a66c2; - background: rgba(10, 102, 194, 0.9); - display: flex; - justify-content: center; - align-items: center; - font: 700 9px system-ui, sans-serif; - color: #ffffff; - box-shadow: 0px 2px 4px rgba(0,0,0,0.3); -} - -.repeater-dot.stale { - background: rgba(75, 101, 126, 0.9); - border: 1px solid #777777; - color: #c3c3c3; -} - -.repeater-dot.dead { - background: rgba(38, 38, 39, 0.9); - border: 1px solid #333333; - color: #929292; -} - -.leaflet-popup-content { - font: 13px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; -} - -.attribution-tweak .leaflet-control-attribution { - font-size: 11px; -} - -.mesh-control { - background: #f0f0f0; - padding: 6px 10px; - border-radius: 4px; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); - font-size: 14px; - line-height: 1.4; - width: 12em; -} - -.mesh-control-row { - align-items: center; -} - -.mesh-control-row.mesh-control-title { - font-weight: 700; -} - -.mesh-control-row + .mesh-control-row { - margin-top: 8px; -} - -.mesh-control-row label { - display: flex; - gap: 8px; - font-weight: 500; -} - -.mesh-control input[type="checkbox"] { - appearance: none; /* Remove native checkbox */ - -webkit-appearance: none; /* Safari */ - width: 16px; - height: 16px; - border-radius: 3px; - border: 1px solid #ccc; - background: #f5f5f5; - cursor: pointer; - position: relative; - flex: 0 0 auto; -} - -.mesh-control input[type="checkbox"]:checked::after { - content: "✓"; - position: absolute; - top: -2px; - left: 2px; - font-size: 16px; - color: #0a66c2; - pointer-events: none; -} - -.mesh-control input[type="checkbox"]:hover { - background: #e8e8e8; -} - -.mesh-control input, -.mesh-control select { - border-radius: 3px; - border: 1px solid #ccc; - background: #f5f5f5; - flex: 1 1 0%; - min-width: 0px; -} - -.mesh-control-row button { - padding: 2px 8px; - border-radius: 3px; - border: 1px solid #ccc; - background: #f5f5f5; - cursor: pointer; -} - -.mesh-control-row button:hover { - background: #e8e8e8; -} - -.mesh-control .top-rpt-row { - width: 100%; - font-size: x-small; - display: flex; - flex-wrap: nowrap; - align-items: center; - column-gap: 0.5em; -} - -.mesh-control .top-rpt-row:nth-child(odd) { - background-color: #e5e5e5; -} - -.mesh-control .top-rpt-row div:first-child { - flex: 1 1 auto; - white-space: nowrap; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.mesh-control .top-rpt-row div:last-child { - flex: 0 0 auto; - white-space: nowrap; -} - /* TX Log - Static Expandable Section */ #txLogBottomSheet.open { display: block !important; diff --git a/content/tailwind.css b/content/tailwind.css index 3e887f1..80d12c3 100644 --- a/content/tailwind.css +++ b/content/tailwind.css @@ -304,9 +304,21 @@ .h-6 { height: calc(var(--spacing) * 6); } + .h-7 { + height: calc(var(--spacing) * 7); + } .h-8 { height: calc(var(--spacing) * 8); } + .h-10 { + height: calc(var(--spacing) * 10); + } + .h-12 { + height: calc(var(--spacing) * 12); + } + .h-14 { + height: calc(var(--spacing) * 14); + } .max-h-48 { max-height: calc(var(--spacing) * 48); } @@ -325,9 +337,21 @@ .w-6 { width: calc(var(--spacing) * 6); } + .w-7 { + width: calc(var(--spacing) * 7); + } .w-8 { width: calc(var(--spacing) * 8); } + .w-10 { + width: calc(var(--spacing) * 10); + } + .w-12 { + width: calc(var(--spacing) * 12); + } + .w-14 { + width: calc(var(--spacing) * 14); + } .w-full { width: 100%; } @@ -494,6 +518,9 @@ .bg-sky-600 { background-color: var(--color-sky-600); } + .bg-slate-800 { + background-color: var(--color-slate-800); + } .bg-slate-800\/80 { background-color: color-mix(in srgb, oklch(27.9% 0.041 260.031) 80%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -590,6 +617,10 @@ font-size: var(--text-xs); line-height: var(--tw-leading, var(--text-xs--line-height)); } + .text-\[10px\] { + font-size: 10px; + line-height: 1; + } .leading-5 { --tw-leading: calc(var(--spacing) * 5); line-height: calc(var(--spacing) * 5); diff --git a/content/wardrive-badge.png b/content/wardrive-badge.png new file mode 100644 index 0000000..fde87fb Binary files /dev/null and b/content/wardrive-badge.png differ diff --git a/content/wardrive.js b/content/wardrive.js index 21527d3..512514d 100644 --- a/content/wardrive.js +++ b/content/wardrive.js @@ -51,7 +51,6 @@ function debugError(message, ...args) { // ---- Config ---- const CHANNEL_NAME = "#wardriving"; // change to "#wardrive" if needed -const DEFAULT_INTERVAL_S = 30; // fallback if selector unavailable const PING_PREFIX = "@[MapperBot]"; const GPS_FRESHNESS_BUFFER_MS = 5000; // Buffer time for GPS freshness checks const GPS_ACCURACY_THRESHOLD_M = 100; // Maximum acceptable GPS accuracy in meters @@ -68,9 +67,16 @@ const ADVERT_HEADER = 0x11; // Header byte for ADVERT packets // RX Packet Filter Configuration const MAX_RX_PATH_LENGTH = 9; // Maximum path length for RX packets (drop if exceeded to filter corrupted packets) -const RX_ALLOWED_CHANNELS = ['#wardriving', '#public', '#testing', '#ottawa']; // Allowed channels for RX wardriving +const RX_ALLOWED_CHANNELS = ['#wardriving', 'Public', '#testing', '#ottawa']; // Allowed channels for RX wardriving (Public uses fixed key, hashtag channels use SHA-256 derivation) const RX_PRINTABLE_THRESHOLD = 0.80; // Minimum printable character ratio for GRP_TXT (80%) +// Fixed key for Public channel (default MeshCore channel without hashtag) +// This is a well-known key used by all MeshCore devices for the default Public channel +const PUBLIC_CHANNEL_FIXED_KEY = new Uint8Array([ + 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, + 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72 +]); + // Pre-computed channel hash and key for the wardriving channel // These will be computed once at startup and used for message correlation and decryption let WARDRIVING_CHANNEL_HASH = null; @@ -79,6 +85,10 @@ let WARDRIVING_CHANNEL_KEY = null; // Pre-computed channel hashes and keys for all allowed RX channels const RX_CHANNEL_MAP = new Map(); // Map +// ---- Device Models Database ---- +// Loaded from device-models.json at startup +let DEVICE_MODELS = []; + // Initialize channel hashes and keys at startup (async function initializeChannelHash() { try { @@ -91,7 +101,7 @@ const RX_CHANNEL_MAP = new Map(); // Map // Initialize all allowed RX channels debugLog(`[INIT] Pre-computing hashes/keys for ${RX_ALLOWED_CHANNELS.length} allowed RX channels...`); for (const channelName of RX_ALLOWED_CHANNELS) { - const key = await deriveChannelKey(channelName); + const key = await getChannelKey(channelName); const hash = await computeChannelHash(key); RX_CHANNEL_MAP.set(hash, { name: channelName, key: key }); debugLog(`[INIT] ${channelName} -> hash=0x${hash.toString(16).padStart(2, '0')}`); @@ -104,30 +114,31 @@ const RX_CHANNEL_MAP = new Map(); // Map } })(); -// Ottawa Geofence Configuration -const OTTAWA_CENTER_LAT = 45.4215; // Parliament Hill latitude -const OTTAWA_CENTER_LON = -75.6972; // Parliament Hill longitude -const OTTAWA_GEOFENCE_RADIUS_M = 150000; // 150 km in meters +// Geo-Auth Zone Configuration +const ZONE_CHECK_DISTANCE_M = 100; // Recheck zone status every 100 meters // Distance-Based Ping Filtering const MIN_PING_DISTANCE_M = 25; // Minimum distance (25m) between pings // Passive RX Log Batch Configuration -const RX_BATCH_DISTANCE_M = 25; // Distance trigger for flushing batch (separate from MIN_PING_DISTANCE_M for independent tuning) -const RX_BATCH_TIMEOUT_MS = 30000; // Max hold time per repeater (30 sec) -const RX_BATCH_MIN_WAIT_MS = 2000; // Min wait to collect burst RX events +const RX_BATCH_DISTANCE_M = 50; // Distance trigger for flushing batch (50m) +const RX_BATCH_TIMEOUT_MS = 30000; // Max hold time per repeater (30 sec) - triggers flush if no movement -// API Batch Queue Configuration +// Wardrive Batch Queue Configuration const API_BATCH_MAX_SIZE = 50; // Maximum messages per batch POST const API_BATCH_FLUSH_INTERVAL_MS = 30000; // Flush every 30 seconds const API_TX_FLUSH_DELAY_MS = 3000; // Flush 3 seconds after TX ping +// Heartbeat Configuration +const HEARTBEAT_BUFFER_MS = 5 * 60 * 1000; // Schedule heartbeat 5 minutes before session expiry +const WARDRIVE_RETRY_DELAY_MS = 2000; // Delay before retry on network failure (2 seconds) + // MeshMapper API Configuration -const MESHMAPPER_API_URL = "https://yow.meshmapper.net/wardriving-api.php"; -const MESHMAPPER_CAPACITY_CHECK_URL = "https://yow.meshmapper.net/capacitycheck.php"; +const WARDRIVE_ENDPOINT = "https://meshmapper.net/wardrive-api.php/wardrive"; // New wardrive data + heartbeat endpoint +const GEO_AUTH_STATUS_URL = "https://meshmapper.net/wardrive-api.php/status"; // Geo-auth zone status endpoint +const GEO_AUTH_URL = "https://meshmapper.net/wardrive-api.php/auth"; // Geo-auth connect/disconnect endpoint const MESHMAPPER_API_KEY = "59C7754DABDF5C11CA5F5D8368F89"; const MESHMAPPER_DEFAULT_WHO = "GOME-WarDriver"; // Default identifier -const MESHMAPPER_RX_LOG_API_URL = "https://yow.meshmapper.net/wardriving-api.php"; // Static for now; will be made dynamic later. const WARDIVE_IATA_CODE = "YOW"; @@ -138,11 +149,28 @@ const WARDIVE_IATA_CODE = "YOW"; // For DEV builds: Contains "DEV-" format (e.g., "DEV-1734652800") const APP_VERSION = "UNKNOWN"; // Placeholder - replaced during build -// ---- Capacity Check Reason Messages ---- +// ---- Auth Reason Messages ---- // Maps API reason codes to user-facing error messages const REASON_MESSAGES = { + // Auth/connect errors outofdate: "App out of date, please update", - // Future reasons can be added here + unknown_device: "Unknown device - advertise on mesh first", + outside_zone: "Outside zone", + zone_disabled: "Zone is disabled", + zone_full: "Zone at capacity", + bad_key: "Invalid API key", + gps_stale: "GPS data too old - try again", + gps_inaccurate: "GPS accuracy too low - try again", + // Session errors (wardrive API) + bad_session: "Invalid session", + session_expired: "Session expired", + session_invalid: "Session invalid", + session_revoked: "Session revoked", + // Authorization errors (wardrive API) + invalid_key: "Invalid API key", + unauthorized: "Unauthorized", + // Rate limiting (wardrive API) + rate_limited: "Rate limited - slow down", }; // ---- UI helpers ---- @@ -155,27 +183,34 @@ const STATUS_COLORS = { info: "text-sky-300" }; -// ---- DOM refs (from index.html; unchanged except the two new selectors) ---- +// ---- DOM refs (from index.html) ---- const $ = (id) => document.getElementById(id); const statusEl = $("status"); -const deviceInfoEl = $("deviceInfo"); const channelInfoEl = $("channelInfo"); const connectBtn = $("connectBtn"); const txPingBtn = $("txPingBtn"); const txRxAutoBtn = $("txRxAutoBtn"); const rxAutoBtn = $("rxAutoBtn"); -const lastPingEl = $("lastPing"); const gpsInfoEl = document.getElementById("gpsInfo"); const gpsAccEl = document.getElementById("gpsAcc"); const distanceInfoEl = document.getElementById("distanceInfo"); // Distance from last ping const txPingsEl = document.getElementById("txPings"); // TX log container -const coverageFrameEl = document.getElementById("coverageFrame"); +// Double-buffered iframes for seamless map updates +let coverageFrameA = document.getElementById("coverageFrameA"); +let coverageFrameB = document.getElementById("coverageFrameB"); +let activeFrame = coverageFrameA; // Track which frame is currently visible + +// Track last connection status to avoid logging spam (declared here to avoid TDZ with setConnStatus call below) +let lastConnStatusText = null; + setConnectButton(false); setConnStatus("Disconnected", STATUS_COLORS.error); -// NEW: selectors -const intervalSelect = $("intervalSelect"); // 15 / 30 / 60 seconds -const powerSelect = $("powerSelect"); // "", "0.3w", "0.6w", "1.0w" +// Power, Device Model, and Zone selectors +const deviceModelEl = $("deviceModel"); +// Zone status removed from connection bar - only shown in settings panel (locationDisplay) +const locationDisplay = $("locationDisplay"); // Location (zone code) in settings +const slotsDisplay = $("slotsDisplay"); // Slot availability in settings // TX Log selectors const txLogSummaryBar = $("txLogSummaryBar"); @@ -256,15 +291,28 @@ const state = { skipReason: null, // Reason for skipping a ping - internal value only (e.g., "gps too old") pausedAutoTimerRemainingMs: null, // Remaining time when auto ping timer was paused by manual ping lastSuccessfulPingLocation: null, // { lat, lon } of the last successful ping (Mesh + API) - capturedPingCoords: null, // { lat, lon, accuracy } captured at ping time, used for API post after 7s delay - devicePublicKey: null, // Hex string of device's public key (used for capacity check) - wardriveSessionId: null, // Session ID from capacity check API (used for all MeshMapper API posts) + capturedPingCoords: null, // { lat, lon, accuracy, noisefloor, timestamp } captured at ping time, used for API post after RX window + devicePublicKey: null, // Hex string of device's public key (used for auth) + deviceModel: null, // Manufacturer/model string exposed by companion + autoPowerSet: false, // Whether power was automatically set based on device model + lastNoiseFloor: null, // Most recent noise floor read from companion (dBm) or 'ERR' + noiseFloorUpdateTimer: null, // Timer for periodic noise floor updates (5s interval) + deviceName: null, + wardriveSessionId: null, // Session ID from /auth API (used for all MeshMapper API posts) debugMode: false, // Whether debug mode is enabled by MeshMapper API + txAllowed: false, // Whether TX wardriving is permitted (from /auth response) + rxAllowed: false, // Whether RX wardriving is permitted (from /auth response) + sessionExpiresAt: null, // Unix timestamp when session expires (for heartbeat scheduling) + heartbeatTimerId: null, // Timer ID for heartbeat scheduling tempTxRepeaterData: null, // Temporary storage for TX repeater debug data - disconnectReason: null, // Tracks the reason for disconnection (e.g., "app_down", "capacity_full", "public_key_error", "channel_setup_error", "ble_disconnect_error", "session_id_error", "normal", or API reason codes like "outofdate") + disconnectReason: null, // Tracks the reason for disconnection (e.g., "app_down", "unknown_device", "outside_zone", "zone_disabled", "channel_setup_error", "ble_disconnect_error", "session_id_error", "normal", or API reason codes like "outofdate") channelSetupErrorMessage: null, // Error message from channel setup failure bleDisconnectErrorMessage: null, // Error message from BLE disconnect failure pendingApiPosts: [], // Array of pending background API post promises + currentZone: null, // Current zone object from preflight check: { name, code, enabled, at_capacity, slots_available, slots_max } + lastZoneCheckCoords: null, // { lat, lon } of last zone status check (for 100m movement trigger) + zoneCheckInProgress: false, // Prevents duplicate concurrent zone checks + slotRefreshTimerId: null, // Timer for periodic slot capacity refresh (30s disconnected, 60s connected) txTracking: { isListening: false, // Whether we're currently listening for TX echoes sentTimestamp: null, // Timestamp when the ping was sent @@ -283,8 +331,8 @@ const state = { rxBatchBuffer: new Map() // Map }; -// API Batch Queue State -const apiQueue = { +// Wardrive Batch Queue State +const wardriveQueue = { messages: [], // Array of pending payloads flushTimerId: null, // Timer ID for periodic flush (30s) txFlushTimerId: null, // Timer ID for TX-triggered flush (3s) @@ -298,7 +346,8 @@ const statusMessageState = { pendingMessage: null, // Pending message to display after minimum visibility pendingTimer: null, // Timer for pending message currentText: '', // Current status text - currentColor: '' // Current status color + currentColor: '', // Current status color + outsideZoneError: null // Persistent "outside zone" error message (blocks other messages until cleared) }; /** @@ -441,12 +490,6 @@ const autoCountdownTimer = createCountdownTimer( return { message: "Sending auto ping", color: STATUS_COLORS.info }; } // If there's a skip reason, show it with the countdown in warning color - if (state.skipReason === "outside geofence") { - return { - message: `Ping skipped, outside of geofenced region, waiting for next ping (${remainingSec}s)`, - color: STATUS_COLORS.warning - }; - } if (state.skipReason === "too close") { return { message: `Ping skipped, too close to last ping, waiting for next ping (${remainingSec}s)`, @@ -504,7 +547,15 @@ function pauseAutoCountdown() { state.pausedAutoTimerRemainingMs = null; } } - // Stop the auto ping timer (but keep autoTimerId so we know auto mode is active) + + // CRITICAL: Clear the actual ping timer to prevent it from firing during manual ping + if (state.autoTimerId) { + debugLog(`[TIMER] Clearing ping timer (id=${state.autoTimerId}) during pause`); + clearTimeout(state.autoTimerId); + state.autoTimerId = null; + } + + // Stop the UI countdown display autoCountdownTimer.stop(); state.nextAutoPingTime = null; } @@ -514,8 +565,32 @@ function resumeAutoCountdown() { if (state.pausedAutoTimerRemainingMs !== null) { // Validate paused time is still reasonable before resuming if (state.pausedAutoTimerRemainingMs > MIN_PAUSE_THRESHOLD_MS && state.pausedAutoTimerRemainingMs < MAX_REASONABLE_TIMER_MS) { - debugLog(`[TIMER] Resuming auto countdown with ${state.pausedAutoTimerRemainingMs}ms remaining`); - startAutoCountdown(state.pausedAutoTimerRemainingMs); + const remainingMs = state.pausedAutoTimerRemainingMs; + debugLog(`[TIMER] Resuming auto countdown with ${remainingMs}ms remaining`); + + // Start the UI countdown display + startAutoCountdown(remainingMs); + + // CRITICAL: Also schedule the actual ping timer with the remaining time + state.autoTimerId = setTimeout(() => { + debugLog(`[TX/RX AUTO] Resumed auto ping timer fired (id=${state.autoTimerId})`); + + // Double-check guards before sending ping + if (!state.txRxAutoRunning) { + debugLog("[TX/RX AUTO] Auto mode no longer running, ignoring timer"); + return; + } + if (state.pingInProgress) { + debugLog("[TX/RX AUTO] Ping already in progress, ignoring timer"); + return; + } + + state.skipReason = null; + debugLog("[TX/RX AUTO] Sending auto ping (resumed)"); + sendPing(false).catch((e) => debugError("[TX/RX AUTO] Resumed auto ping error:", e?.message || String(e))); + }, remainingMs); + debugLog(`[TIMER] Resumed ping timer scheduled (id=${state.autoTimerId})`); + state.pausedAutoTimerRemainingMs = null; return true; } else { @@ -587,16 +662,18 @@ function startCooldown() { function updateControlsForCooldown() { const connected = !!state.connection; const inCooldown = isInCooldown(); - debugLog(`[UI] updateControlsForCooldown: connected=${connected}, inCooldown=${inCooldown}, pingInProgress=${state.pingInProgress}, txRxAutoRunning=${state.txRxAutoRunning}, rxAutoRunning=${state.rxAutoRunning}`); + const powerSelected = getCurrentPowerSetting() !== ""; + debugLog(`[UI] updateControlsForCooldown: connected=${connected}, inCooldown=${inCooldown}, pingInProgress=${state.pingInProgress}, txRxAutoRunning=${state.txRxAutoRunning}, rxAutoRunning=${state.rxAutoRunning}, powerSelected=${powerSelected}, txAllowed=${state.txAllowed}, rxAllowed=${state.rxAllowed}`); - // TX Ping button - disabled during cooldown or ping in progress - txPingBtn.disabled = !connected || inCooldown || state.pingInProgress; + // TX Ping button - requires TX permission, disabled during cooldown, ping in progress, OR when no power selected + txPingBtn.disabled = !connected || !state.txAllowed || inCooldown || state.pingInProgress || !powerSelected; - // TX/RX Auto button - disabled during cooldown, ping in progress, OR when RX Auto running - txRxAutoBtn.disabled = !connected || inCooldown || state.pingInProgress || state.rxAutoRunning; + // TX/RX Auto button - requires TX permission, disabled during cooldown, ping in progress, when RX Auto running, OR when no power selected + txRxAutoBtn.disabled = !connected || !state.txAllowed || inCooldown || state.pingInProgress || state.rxAutoRunning || !powerSelected; - // RX Auto button - permanently disabled (backend API not ready) - rxAutoBtn.disabled = true; + // RX Auto button - enabled when connected with RX permission (including RX-only mode) + // Disabled during TX/RX Auto mode (can't run both), and requires power selected + rxAutoBtn.disabled = !connected || !state.rxAllowed || state.txRxAutoRunning || !powerSelected; } /** @@ -631,7 +708,10 @@ function cleanupAllTimers() { } // Clean up API queue timers - stopFlushTimers(); + stopWardriveTimers(); + + // Cancel heartbeat timer + cancelHeartbeat(); // Clean up state timer references state.autoCountdownTimer = null; @@ -655,15 +735,22 @@ function cleanupAllTimers() { state.debugMode = false; state.tempTxRepeaterData = null; - // Clear RX batch buffer (no timeouts to clear anymore) + // Clear RX batch buffer (including per-repeater timeout timers) if (state.rxBatchBuffer && state.rxBatchBuffer.size > 0) { + // Clear all timeout timers before clearing the buffer + for (const [repeaterId, buffer] of state.rxBatchBuffer.entries()) { + if (buffer.timeoutId) { + clearTimeout(buffer.timeoutId); + debugLog(`[RX BATCH] Cleared timeout timer for repeater ${repeaterId} during cleanup`); + } + } state.rxBatchBuffer.clear(); debugLog("[RX BATCH] RX batch buffer cleared"); } } function enableControls(connected) { - connectBtn.disabled = false; + setConnectButtonDisabled(false); channelInfoEl.textContent = CHANNEL_NAME; updateControlsForCooldown(); @@ -695,22 +782,112 @@ function updateAutoButton() { } } function buildCoverageEmbedUrl(lat, lon) { + // Use current zone code from preflight check, fallback to default + const zoneCode = (state.currentZone?.code || WARDIVE_IATA_CODE).toLowerCase(); const base = - "https://yow.meshmapper.net/embed.php?cov_grid=1&fail_grid=1&pings=0&repeaters=1&rep_coverage=0&grid_lines=0&dir=1&meters=1500"; + `https://${zoneCode}.meshmapper.net/embed.php?cov_grid=1&fail_grid=1&pings=0&repeaters=1&rep_coverage=0&grid_lines=0&dir=1&meters=1500`; return `${base}&lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`; } let coverageRefreshTimer = null; +let bufferLoadHandler = null; // Track current load handler for cleanup + +/** + * Schedule a coverage map refresh using double-buffered iframe swap + * Loads new content in hidden iframe, swaps visibility when ready (no flicker) + * @param {number} lat - Latitude + * @param {number} lon - Longitude + * @param {number} delayMs - Delay before starting load (default 0) + */ function scheduleCoverageRefresh(lat, lon, delayMs = 0) { - if (!coverageFrameEl) return; + if (!coverageFrameA || !coverageFrameB) return; if (coverageRefreshTimer) clearTimeout(coverageRefreshTimer); coverageRefreshTimer = setTimeout(() => { const url = buildCoverageEmbedUrl(lat, lon); - debugLog("[UI] Coverage iframe URL:", url); - coverageFrameEl.src = url; + debugLog("[UI] Coverage iframe loading:", url); + + // Determine which frame is hidden (the buffer) + const bufferFrame = (activeFrame === coverageFrameA) ? coverageFrameB : coverageFrameA; + + // Clean up any previous load handler + if (bufferLoadHandler) { + bufferFrame.removeEventListener('load', bufferLoadHandler); + bufferLoadHandler = null; + } + + // Create new load handler that swaps visibility + bufferLoadHandler = function onBufferLoad() { + // Delay after load to ensure iframe content is fully rendered + // Cross-origin iframes may fire load before paint is complete + setTimeout(() => { + // Swap opacity: fade out current active, fade in buffer + activeFrame.classList.remove('coverage-frame-active'); + activeFrame.classList.add('coverage-frame-hidden'); + bufferFrame.classList.remove('coverage-frame-hidden'); + bufferFrame.classList.add('coverage-frame-active'); + + // Update active frame reference + activeFrame = bufferFrame; + debugLog("[UI] Coverage iframe swapped (double-buffer)"); + + // Clean up + bufferFrame.removeEventListener('load', bufferLoadHandler); + bufferLoadHandler = null; + }, 300); // 300ms delay for content to render + }; + + // Set up load listener and start loading in buffer + bufferFrame.addEventListener('load', bufferLoadHandler); + bufferFrame.src = url; }, delayMs); } + +/** + * Update map and GPS overlay after a zone check + * - Updates GPS coordinates and accuracy on the map overlay + * - Refreshes the map iframe with new coordinates (unless auto mode is running) + * @param {Object} coords - Coordinates object with lat, lon, accuracy_m properties + */ +function updateMapOnZoneCheck(coords) { + if (!coords) return; + + const { lat, lon, accuracy_m } = coords; + + // Update GPS overlay + if (gpsInfoEl) { + gpsInfoEl.textContent = `${lat.toFixed(5)}, ${lon.toFixed(5)}`; + } + if (gpsAccEl && accuracy_m) { + gpsAccEl.textContent = `±${Math.round(accuracy_m)}m`; + } + + // Skip map refresh if auto mode is running (ping completion handles map refresh) + if (state.txRxAutoRunning || state.rxAutoRunning) { + debugLog(`[GEO AUTH] Skipping map refresh - auto mode running`); + return; + } + + // Refresh map iframe with new coordinates + scheduleCoverageRefresh(lat, lon); + debugLog(`[GEO AUTH] Map updated: lat=${lat.toFixed(5)}, lon=${lon.toFixed(5)}, accuracy=${accuracy_m ? Math.round(accuracy_m) + 'm' : 'N/A'}`); +} + +/** + * Set Connect button visual disabled state + * Updates opacity and cursor to indicate whether button is clickable + * @param {boolean} disabled - Whether button should appear disabled + */ +function setConnectButtonDisabled(disabled) { + if (!connectBtn) return; + connectBtn.disabled = disabled; + if (disabled) { + connectBtn.classList.add("opacity-50", "cursor-not-allowed"); + } else { + connectBtn.classList.remove("opacity-50", "cursor-not-allowed"); + } +} + function setConnectButton(connected) { if (!connectBtn) return; if (connected) { @@ -750,12 +927,50 @@ function setConnectButton(connected) { function setConnStatus(text, color) { const connectionStatusEl = document.getElementById("connectionStatus"); const statusIndicatorEl = document.getElementById("statusIndicator"); + const noiseDisplayEl = document.getElementById("noiseDisplay"); if (!connectionStatusEl) return; - debugLog(`[UI] Connection status: "${text}"`); - connectionStatusEl.textContent = text; - connectionStatusEl.className = `font-medium ${color}`; + // Only log when status actually changes + if (text !== lastConnStatusText) { + debugLog(`[UI] Connection status: "${text}"`); + lastConnStatusText = text; + } + + // Format based on connection state + if (text === "Connected") { + // Show device name on left, noise on right + const deviceName = state.deviceName || "[No device]"; + let noiseText = "-"; + if (state.lastNoiseFloor === null) { + noiseText = "Firmware 1.11+"; + } else if (state.lastNoiseFloor === 'ERR') { + noiseText = "ERR"; + } else { + noiseText = `${state.lastNoiseFloor}dBm`; + } + connectionStatusEl.textContent = deviceName; + connectionStatusEl.className = 'font-medium text-slate-300'; + + // Update noise display on right side + if (noiseDisplayEl) { + noiseDisplayEl.textContent = `🔊 ${noiseText}`; + } + } else if (text === "Disconnected") { + // Show disconnected status, clear noise + connectionStatusEl.textContent = text; + connectionStatusEl.className = `font-medium ${color}`; + if (noiseDisplayEl) { + noiseDisplayEl.textContent = ''; + } + } else { + // Connecting, Disconnecting - show as-is, clear noise + connectionStatusEl.textContent = text; + connectionStatusEl.className = `font-medium ${color}`; + if (noiseDisplayEl) { + noiseDisplayEl.textContent = ''; + } + } // Update status indicator dot color to match if (statusIndicatorEl) { @@ -771,11 +986,19 @@ function setConnStatus(text, color) { * Connection status words (Connected/Connecting/Disconnecting/Disconnected) are blocked * and replaced with em dash (—) placeholder. * + * When outsideZoneError is set, all other messages are blocked until the error is cleared. + * * @param {string} text - Status message text (null/empty shows "—") * @param {string} color - Status color class from STATUS_COLORS * @param {boolean} immediate - If true, bypass minimum visibility (for countdown timers) */ function setDynamicStatus(text, color = STATUS_COLORS.idle, immediate = false) { + // If outside zone error is active, block all other messages (except clearing it) + if (statusMessageState.outsideZoneError && text !== statusMessageState.outsideZoneError) { + debugLog(`[UI] Dynamic status blocked by persistent outside zone error: "${text}"`); + return; + } + // Normalize empty/null/whitespace to em dash if (!text || text.trim() === '') { text = '—'; @@ -794,6 +1017,353 @@ function setDynamicStatus(text, color = STATUS_COLORS.idle, immediate = false) { setStatus(text, color, immediate); } +/** + * Update zone status UI based on zone check response + * @param {Object} zoneData - Zone status response from checkZoneStatus() + */ +function updateZoneStatusUI(zoneData) { + debugLog(`[GEO AUTH] [UI] Updating zone status UI`); + + if (!zoneData) { + debugWarn(`[GEO AUTH] [UI] No zone data provided, setting error state`); + locationDisplay.textContent = "Unknown"; + locationDisplay.className = "font-medium text-red-400"; + updateSlotsDisplay(null); + return; + } + + // Handle success with in_zone + if (zoneData.success && zoneData.in_zone) { + const zone = zoneData.zone; + const atCapacity = zone.at_capacity; + const statusColor = atCapacity ? "text-amber-300" : "text-emerald-300"; + + // Clear persistent outside zone error if it was set + if (statusMessageState.outsideZoneError) { + debugLog(`[GEO AUTH] [UI] Clearing persistent outside zone error - now in zone ${zone.code}`); + statusMessageState.outsideZoneError = null; + // Only clear to Idle if not showing a disconnect error + // Check if disconnectReason is an error (not normal/null/undefined) + const isErrorDisconnect = state.disconnectReason && + state.disconnectReason !== "normal" && + state.disconnectReason !== null; + if (!isErrorDisconnect) { + setDynamicStatus("—", STATUS_COLORS.idle); // Clear the dynamic status bar + } else { + debugLog(`[GEO AUTH] [UI] Preserving disconnect error status (reason: ${state.disconnectReason})`); + } + } + + locationDisplay.textContent = zone.code; + locationDisplay.className = `font-medium ${statusColor}`; + + updateSlotsDisplay(zone); + + debugLog(`[GEO AUTH] [UI] Zone status: in zone ${zone.code}, slots ${zone.slots_available}/${zone.slots_max}, at_capacity=${atCapacity}`); + return; + } + + // Handle success but outside zone + if (zoneData.success && !zoneData.in_zone) { + const nearest = zoneData.nearest_zone; + const distText = `Outside zone (${nearest.distance_km}km to ${nearest.code})`; + + // Set persistent outside zone error - blocks all other dynamic status messages + statusMessageState.outsideZoneError = distText; + debugLog(`[GEO AUTH] [UI] Set persistent outside zone error: "${distText}"`); + + // Show error in dynamic status bar (red) - this overrides "Select external antenna" message + setDynamicStatus(distText, STATUS_COLORS.error); + + // Log as error + debugError(`[GEO AUTH] ${distText}`); + + locationDisplay.textContent = "—"; + locationDisplay.className = "font-medium text-slate-400"; + + updateSlotsDisplay(null); + + debugLog(`[GEO AUTH] [UI] Zone status: outside zone, nearest is ${nearest.code} at ${nearest.distance_km}km`); + return; + } + + // Handle error states + if (!zoneData.success) { + const reason = zoneData.reason || "unknown"; + let statusText = "Zone check failed"; + let dynamicStatusText = null; // For persistent errors in dynamic status bar + + if (reason === "gps_stale") { + statusText = "GPS: stale"; + } else if (reason === "gps_inaccurate") { + statusText = "GPS: inaccurate"; + } else if (reason === "outofdate") { + // App version outdated - show persistent error in dynamic status bar + statusText = ""; // Clear location display + dynamicStatusText = zoneData.message || "App version outdated, please update"; + + // Set persistent error - blocks all other dynamic status messages + statusMessageState.outsideZoneError = dynamicStatusText; + debugLog(`[GEO AUTH] Set persistent outofdate error: "${dynamicStatusText}"`); + + // Show error in dynamic status bar (red) + setDynamicStatus(dynamicStatusText, STATUS_COLORS.error); + + // Disable Connect button - can't use app with outdated version + setConnectButtonDisabled(true); + + // Clear current zone to stop slot refresh timer from running + state.currentZone = null; + + // Log as error (single consolidated message) + debugError(`[GEO AUTH] ${dynamicStatusText}`); + } + + locationDisplay.textContent = statusText || "Unknown"; + locationDisplay.className = "font-medium text-red-400"; + + updateSlotsDisplay(null); + + // Only log if not already logged above (outofdate case) + if (reason !== "outofdate") { + debugError(`[GEO AUTH] [UI] Zone check error: reason=${reason}, message=${zoneData.message}`); + } + return; + } +} + +/** + * Update slots display in settings panel + * @param {Object|null} zone - Zone object with slots_available and slots_max, or null for N/A + */ +function updateSlotsDisplay(zone) { + if (!zone) { + slotsDisplay.textContent = "N/A"; + slotsDisplay.className = "font-medium text-slate-400"; + debugLog(`[UI] Slots display: N/A`); + return; + } + + const { slots_available, slots_max, at_capacity, code } = zone; + + if (at_capacity || slots_available === 0) { + slotsDisplay.textContent = `Full (0/${slots_max})`; + slotsDisplay.className = "font-medium text-amber-300"; + + // Update location display to amber (slots full) + locationDisplay.className = "font-medium text-amber-300"; + + // Show warning in dynamic status bar (yellow, not blocking - user can still connect for RX) + const warnMsg = `No TX wardriving slots for ${code}. RX only.`; + setDynamicStatus(warnMsg, STATUS_COLORS.warning); + debugLog(`[GEO AUTH] ${warnMsg}`); + + debugLog(`[UI] Slots display: Full (0/${slots_max})`); + } else { + slotsDisplay.textContent = `${slots_available} available`; + slotsDisplay.className = "font-medium text-emerald-300"; + + // Update location display to green (slots available) + locationDisplay.className = "font-medium text-emerald-300"; + + debugLog(`[UI] Slots display: ${slots_available} available (${slots_available}/${slots_max})`); + + // Re-check connect button state now that slots are available + // Note: updateConnectButtonState() will set the appropriate status message + // based on both zone status AND antenna selection + updateConnectButtonState(); + } +} + +/** + * Start/restart the 30s slot refresh timer (disconnected mode) + * Called on initial zone check success, after disconnect, and after connection failure + */ +function startSlotRefreshTimer() { + // Clear any existing timer + if (state.slotRefreshTimerId) { + clearInterval(state.slotRefreshTimerId); + } + + state.slotRefreshTimerId = setInterval(async () => { + const mode = state.connection ? "connected" : "disconnected"; + debugLog(`[GEO AUTH] [SLOT REFRESH] 30s timer triggered (${mode} mode)`); + // Continue checking even while connected to keep slot display current + // Re-check zone to refresh slots or detect zone re-entry + const coords = await getValidGpsForZoneCheck(); + if (coords) { + const result = await checkZoneStatus(coords); + if (result.success && result.in_zone && result.zone) { + // In zone (or returned to zone) - update slots display + const wasOutside = !state.currentZone; + state.currentZone = result.zone; + state.lastZoneCheckCoords = { lat: coords.lat, lon: coords.lon }; + updateZoneStatusUI(result); + updateMapOnZoneCheck(coords); // Update map and GPS overlay + if (wasOutside) { + debugLog(`[GEO AUTH] [SLOT REFRESH] ✅ Returned to zone: ${result.zone.name}, slots: ${result.zone.slots_available}/${result.zone.slots_max}`); + } else { + debugLog(`[GEO AUTH] [SLOT REFRESH] Updated slots: ${result.zone.slots_available}/${result.zone.slots_max}`); + } + } else if (result.success && !result.in_zone) { + // Outside zone - update UI to show outside zone status + state.currentZone = null; + state.lastZoneCheckCoords = { lat: coords.lat, lon: coords.lon }; + updateZoneStatusUI(result); + updateMapOnZoneCheck(coords); + debugLog(`[GEO AUTH] [SLOT REFRESH] Outside zone, nearest: ${result.nearest_zone?.name} at ${result.nearest_zone?.distance_km}km`); + } else if (result && !result.success) { + // Handle error states (outofdate, etc.) - this will disable button and clear currentZone + state.currentZone = null; + updateZoneStatusUI(result); + debugLog(`[GEO AUTH] [SLOT REFRESH] Zone check failed: ${result.reason || 'unknown'}`); + } + } + }, 30000); // 30 seconds + debugLog("[GEO AUTH] Started 30s slot refresh timer"); +} + +/** + * Perform zone check on app launch + * - Disables Connect button initially + * - Shows "Checking zone..." status + * - Gets GPS and performs zone check + * - Updates UI and enables Connect if in valid zone + * - Starts 30s slot refresh timer + * - Centers map on checked location + */ +async function performAppLaunchZoneCheck() { + debugLog("[GEO AUTH] [INIT] Performing app launch zone check"); + + // Disable Connect button initially + setConnectButtonDisabled(true); + debugLog("[GEO AUTH] [INIT] Connect button disabled during zone check"); + + // Show "Checking zone..." in location display + locationDisplay.textContent = "Checking..."; + locationDisplay.className = "font-medium text-slate-400"; + debugLog("[GEO AUTH] [INIT] Location display set to 'Checking...'"); + + try { + // Get valid GPS coordinates + debugLog("[GEO AUTH] [INIT] Getting valid GPS coordinates for zone check"); + const coords = await getValidGpsForZoneCheck(); + + if (!coords) { + debugWarn("[GEO AUTH] [INIT] Failed to get valid GPS coordinates after retries"); + updateZoneStatusUI(null, "gps_unavailable"); + // Connect button remains disabled + return; + } + + debugLog(`[GEO AUTH] [INIT] Valid GPS acquired: ${coords.lat.toFixed(6)}, ${coords.lon.toFixed(6)}`); + + // Perform zone check + debugLog("[GEO AUTH] [INIT] Calling checkZoneStatus()"); + const result = await checkZoneStatus(coords); + + // Store result in state based on response + if (result.success && result.in_zone && result.zone) { + // User is inside a valid zone + state.currentZone = result.zone; + state.lastZoneCheckCoords = { lat: coords.lat, lon: coords.lon }; + debugLog(`[GEO AUTH] [INIT] ✅ Zone check successful: ${result.zone.name} (${result.zone.code})`); + debugLog(`[GEO AUTH] [INIT] In zone: ${result.in_zone}, At capacity: ${result.zone.at_capacity}`); + } else if (result.success && !result.in_zone) { + // User is outside all zones - this is a valid response, not a failure + state.currentZone = null; + state.lastZoneCheckCoords = { lat: coords.lat, lon: coords.lon }; + const nearest = result.nearest_zone; + debugLog(`[GEO AUTH] [INIT] ⚠️ Outside all zones, nearest: ${nearest.name} (${nearest.code}) at ${nearest.distance_km}km`); + } else { + // Actual failure (API error, network error, etc.) + state.currentZone = null; + state.lastZoneCheckCoords = null; + debugWarn(`[GEO AUTH] [INIT] Zone check failed: ${result.error || "Unknown error"}`); + } + + // Update UI with result + updateZoneStatusUI(result, null); + + // Update map and GPS overlay with zone check coordinates + updateMapOnZoneCheck(coords); + + // Enable Connect button only if in valid zone AND external antenna selected + if (result.success && result.in_zone) { + updateConnectButtonState(); // Checks both zone and antenna + debugLog("[GEO AUTH] [INIT] ✅ Zone check passed, updateConnectButtonState() called"); + + // Start 30s slot refresh timer (disconnected mode) + startSlotRefreshTimer(); + } else { + setConnectButtonDisabled(true); + debugLog("[GEO AUTH] [INIT] ❌ Connect button remains disabled (not in valid zone or check failed)"); + } + + } catch (err) { + debugError(`[GEO AUTH] [INIT] Exception during app launch zone check: ${err.message}`); + updateZoneStatusUI(null, "error"); + setConnectButtonDisabled(true); + } +} + +/** + * Handle zone recheck when GPS moves >= 100m from last zone check + * Called from GPS watch callback ONLY when disconnected + * Updates zone status display for user awareness + * @param {Object} newCoords - Current GPS coordinates {lat, lon} + */ +async function handleZoneCheckOnMove(newCoords) { + // Skip if no previous zone check or check already in progress + if (!state.lastZoneCheckCoords || state.zoneCheckInProgress) { + return; + } + + // Calculate distance from last zone check location + const distance = calculateHaversineDistance( + state.lastZoneCheckCoords.lat, + state.lastZoneCheckCoords.lon, + newCoords.lat, + newCoords.lon + ); + + debugLog(`[GEO AUTH] [GPS MOVEMENT] Distance from last zone check: ${distance.toFixed(1)}m (threshold: ${ZONE_CHECK_DISTANCE_M}m)`); + + // Trigger zone check if moved >= 100m + if (distance >= ZONE_CHECK_DISTANCE_M) { + debugLog(`[GEO AUTH] [GPS MOVEMENT] ⚠️ Moved ${distance.toFixed(1)}m - triggering zone recheck (disconnected mode)`); + + state.zoneCheckInProgress = true; + + try { + // Perform zone check with current coordinates + const result = await checkZoneStatus(newCoords); + + // Update state + if (result.success && result.zone) { + state.currentZone = result.zone; + state.lastZoneCheckCoords = { lat: newCoords.lat, lon: newCoords.lon }; + debugLog(`[GEO AUTH] [GPS MOVEMENT] ✅ Zone recheck successful: ${result.zone.name} (${result.zone.code})`); + } else { + state.currentZone = null; + state.lastZoneCheckCoords = null; + debugWarn(`[GEO AUTH] [GPS MOVEMENT] Zone recheck failed: ${result.error || "Unknown error"}`); + } + + // Update UI with new zone status + updateZoneStatusUI(result, null); + + // Update map and GPS overlay with new coordinates + updateMapOnZoneCheck(newCoords); + + } catch (err) { + debugError(`[GEO AUTH] [GPS MOVEMENT] Exception during zone recheck: ${err.message}`); + } finally { + state.zoneCheckInProgress = false; + } + } +} + // ---- Wake Lock helpers ---- @@ -881,17 +1451,6 @@ function calculateHaversineDistance(lat1, lon1, lat2, lon2) { * @param {number} lon - Longitude to check * @returns {boolean} True if within geofence, false otherwise */ -function validateGeofence(lat, lon) { - debugLog(`[GEOFENCE] Validating geofence for coordinates: (${lat.toFixed(5)}, ${lon.toFixed(5)})`); - debugLog(`[GEOFENCE] Geofence center: (${OTTAWA_CENTER_LAT}, ${OTTAWA_CENTER_LON}), radius: ${OTTAWA_GEOFENCE_RADIUS_M}m`); - - const distance = calculateHaversineDistance(lat, lon, OTTAWA_CENTER_LAT, OTTAWA_CENTER_LON); - const isWithinGeofence = distance <= OTTAWA_GEOFENCE_RADIUS_M; - - debugLog(`[GEOFENCE] Geofence validation: distance=${distance.toFixed(2)}m, within_geofence=${isWithinGeofence}`); - return isWithinGeofence; -} - /** * Validate that current GPS coordinates are at least 25m from last successful ping * @param {number} lat - Current latitude @@ -946,7 +1505,51 @@ function updateDistanceUi() { } } +/** + * Start periodic noise floor updates (5 second interval) + * Only called if feature is supported by firmware + */ +function startNoiseFloorUpdates() { + // Clear any existing timer + stopNoiseFloorUpdates(); + + // Start periodic updates every 5 seconds + state.noiseFloorUpdateTimer = setInterval(async () => { + if (!state.connection) { + debugLog("[BLE] No connection, stopping noise floor updates"); + stopNoiseFloorUpdates(); + return; + } + + try { + // Don't pass timeout - let the interval handle cadence, avoids library timeout bug + const stats = await state.connection.getRadioStats(null); + if (stats && typeof stats.noiseFloor !== 'undefined') { + state.lastNoiseFloor = stats.noiseFloor; + debugLog(`[BLE] Noise floor updated: ${state.lastNoiseFloor}`); + // Update connection bar + if (state.connection) { + setConnStatus("Connected", STATUS_COLORS.success); + } + } + } catch (e) { + // Silently ignore periodic update failures - keep showing last known value + } + }, 5000); + + debugLog("[BLE] Noise floor update timer started (5s interval)"); +} +/** + * Stop periodic noise floor updates + */ +function stopNoiseFloorUpdates() { + if (state.noiseFloorUpdateTimer) { + clearInterval(state.noiseFloorUpdateTimer); + state.noiseFloorUpdateTimer = null; + debugLog("[BLE] Noise floor update timer stopped"); + } +} // ---- Geolocation ---- async function getCurrentPosition() { @@ -1041,6 +1644,12 @@ function startGeoWatch() { if (state.rxTracking. isWardriving && state.rxBatchBuffer.size > 0) { checkAllRxBatchesForDistanceTrigger({ lat: pos.coords. latitude, lon: pos.coords. longitude }); } + + // Check if GPS movement triggers zone recheck (100m threshold) + // Only monitor while disconnected - zone validation while connected happens via /wardrive posts + if (!state.connection) { + handleZoneCheckOnMove({ lat: pos.coords.latitude, lon: pos.coords.longitude }); + } }, (err) => { debugError(`[GPS] GPS watch error: ${err.code} - ${err.message}`); @@ -1116,6 +1725,9 @@ async function primeGpsOnce() { * This allows any hashtag channel to be used (e.g., #wardriving, #wardrive, #test). * Channel names must start with # and contain only a-z, 0-9, and dashes. * + * NOTE: This function is ONLY for hashtag channels. The "Public" channel (without hashtag) + * uses a fixed key defined in PUBLIC_CHANNEL_FIXED_KEY constant. + * * Algorithm: sha256(channelName).subarray(0, 16) * * @param {string} channelName - The hashtag channel name (e.g., "#wardriving") @@ -1163,6 +1775,21 @@ async function deriveChannelKey(channelName) { return channelKey; } +/** + * Get channel key for any channel (handles both Public and hashtag channels) + * Provides a unified interface for retrieving channel keys regardless of type + * @param {string} channelName - Channel name (e.g., "Public", "#wardriving", "#testing") + * @returns {Promise} The 16-byte channel key + */ +async function getChannelKey(channelName) { + if (channelName === 'Public') { + debugLog(`[CHANNEL] Using fixed key for Public channel`); + return PUBLIC_CHANNEL_FIXED_KEY; + } else { + return await deriveChannelKey(channelName); + } +} + // ---- Channel helpers ---- async function createWardriveChannel() { if (!state.connection) throw new Error("Not connected"); @@ -1260,6 +1887,58 @@ function getGpsMaximumAge(minAge = 1000) { return Math.max(minAge, intervalMs - GPS_FRESHNESS_BUFFER_MS); } +// ---- Device Model Parsing Functions ---- + +/** + * Parse device model string by removing build suffix (e.g., "nightly-e31c46f") + * @param {string} rawModel - Raw manufacturer model string from deviceQuery + * @returns {string} Cleaned model string without build suffix + */ +function parseDeviceModel(rawModel) { + if (!rawModel || rawModel === "-") return ""; + + // Strip null characters (\u0000) that may be present in firmware strings + const sanitizedModel = rawModel.replace(/\u0000/g, ''); + + // Strip build suffix like "nightly-e31c46f", "stable-a1b2c3d", etc. + // Match pattern: word-hexstring at end of string + const cleanedModel = sanitizedModel.replace(/(nightly|stable|beta|alpha|dev)-[a-f0-9]{7,}$/i, '').trim(); + + debugLog(`[DEVICE MODEL] Parsed model: "${rawModel.substring(0, 50)}..." -> "${cleanedModel}"`); + return cleanedModel; +} + +/** + * Find device configuration in DEVICE_MODELS database + * @param {string} modelString - Cleaned model string (without build suffix) + * @returns {Object|null} Device config object with {manufacturer, shortName, power, txPower} or null if not found + */ +function findDeviceConfig(modelString) { + if (!modelString || !DEVICE_MODELS || DEVICE_MODELS.length === 0) { + return null; + } + + // Try exact match first + let device = DEVICE_MODELS.find(d => d.manufacturer === modelString); + if (device) { + debugLog(`[DEVICE MODEL] Exact match found: "${device.manufacturer}"`); + return device; + } + + // Try partial match (model string contains manufacturer string or vice versa) + device = DEVICE_MODELS.find(d => + modelString.includes(d.manufacturer) || d.manufacturer.includes(modelString) + ); + + if (device) { + debugLog(`[DEVICE MODEL] Partial match found: "${device.manufacturer}"`); + return device; + } + + debugLog(`[DEVICE MODEL] No match found for: "${modelString}"`); + return null; +} + function getCurrentPowerSetting() { const checkedPower = document.querySelector('input[name="power"]:checked'); return checkedPower ? checkedPower.value : ""; @@ -1283,115 +1962,320 @@ function buildPayload(lat, lon) { * @returns {string} Device name or default identifier */ function getDeviceIdentifier() { - const deviceText = deviceInfoEl?.textContent; - return (deviceText && deviceText !== "—") ? deviceText : MESHMAPPER_DEFAULT_WHO; + // Use state.deviceName which is set during connect from selfInfo.name + if (state.deviceName && state.deviceName !== "[No device]") { + return state.deviceName; + } + return MESHMAPPER_DEFAULT_WHO; +} + +// ---- Geo-Auth Zone Checking ---- + +/** + * Get valid GPS coordinates for zone checking with retry logic + * @param {number} maxRetries - Maximum number of retry attempts (default: 3) + * @param {number} retryDelayMs - Delay between retries in milliseconds (default: 5000) + * @returns {Promise} GPS object {lat, lon, accuracy_m, timestamp} or null if failed + */ +async function getValidGpsForZoneCheck(maxRetries = 3, retryDelayMs = 5000) { + debugLog(`[GPS] [GEO AUTH] Getting valid GPS for zone check (max retries: ${maxRetries})`); + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + debugLog(`[GPS] [GEO AUTH] GPS acquisition attempt ${attempt}/${maxRetries}`); + + const position = await getCurrentPosition(); + const lat = position.coords.latitude; + const lng = position.coords.longitude; + const accuracy_m = position.coords.accuracy; + const timestamp = Math.floor(position.timestamp / 1000); // Convert to Unix seconds + + // Validate freshness (< 60 seconds old) + const ageMs = Date.now() - position.timestamp; + if (ageMs > 60000) { + debugWarn(`[GPS] [GEO AUTH] GPS too stale: ${ageMs}ms old (max 60000ms)`); + if (attempt < maxRetries) { + debugLog(`[GPS] [GEO AUTH] Retrying in ${retryDelayMs}ms...`); + await new Promise(resolve => setTimeout(resolve, retryDelayMs)); + continue; + } + return null; + } + + // Validate accuracy (< 50 meters) + if (accuracy_m > 50) { + debugWarn(`[GPS] [GEO AUTH] GPS too inaccurate: ${accuracy_m}m (max 50m)`); + if (attempt < maxRetries) { + debugLog(`[GPS] [GEO AUTH] Retrying in ${retryDelayMs}ms...`); + await new Promise(resolve => setTimeout(resolve, retryDelayMs)); + continue; + } + return null; + } + + debugLog(`[GPS] [GEO AUTH] Valid GPS acquired: lat=${lat.toFixed(6)}, lon=${lng.toFixed(6)}, accuracy=${accuracy_m.toFixed(1)}m, age=${ageMs}ms`); + return { lat, lon: lng, accuracy_m, timestamp }; + + } catch (error) { + debugError(`[GPS] [GEO AUTH] GPS acquisition failed (attempt ${attempt}/${maxRetries}): ${error.message}`); + if (attempt < maxRetries) { + debugLog(`[GPS] [GEO AUTH] Retrying in ${retryDelayMs}ms...`); + await new Promise(resolve => setTimeout(resolve, retryDelayMs)); + } + } + } + + debugError(`[GPS] [GEO AUTH] GPS acquisition failed after ${maxRetries} attempts`); + return null; } /** - * Check capacity / slot availability with MeshMapper API - * @param {string} reason - Either "connect" (acquire slot) or "disconnect" (release slot) + * Check zone status via geo-auth API + * @param {Object} coords - GPS coordinates object from getValidGpsForZoneCheck() + * @param {number} coords.lat - Latitude + * @param {number} coords.lon - Longitude + * @param {number} coords.accuracy_m - GPS accuracy in meters + * @param {number} coords.timestamp - Unix timestamp in seconds + * @returns {Promise} Zone status response or null on error + */ +async function checkZoneStatus(coords) { + const { lat, lon, accuracy_m, timestamp } = coords; + debugLog(`[GEO AUTH] Checking zone status: lat=${lat.toFixed(6)}, lon=${lon.toFixed(6)}, accuracy=${accuracy_m.toFixed(1)}m, timestamp=${timestamp}`); + + try { + // API expects lng, so convert lon to lng for the payload + // Include app version for version checking + const payload = { lat, lng: lon, accuracy_m, ver: APP_VERSION, timestamp }; + + debugLog(`[GEO AUTH] Sending POST to ${GEO_AUTH_STATUS_URL}`); + + const response = await fetch(GEO_AUTH_STATUS_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + debugError(`[GEO AUTH] Zone status API returned error status ${response.status}`); + return null; + } + + const data = await response.json(); + debugLog(`[GEO AUTH] Zone status response:`, data); + + // Log detailed response based on result + if (data.success && data.in_zone) { + debugLog(`[GEO AUTH] ✅ In zone: ${data.zone.name} (${data.zone.code}), slots: ${data.zone.slots_available}/${data.zone.slots_max}, at_capacity: ${data.zone.at_capacity}`); + } else if (data.success && !data.in_zone) { + debugLog(`[GEO AUTH] ⚠️ Outside all zones, nearest: ${data.nearest_zone.name} (${data.nearest_zone.code}) at ${data.nearest_zone.distance_km}km`); + } else if (!data.success) { + // Only log non-outofdate failures here (outofdate logged in updateZoneStatusUI) + if (data.reason !== "outofdate") { + debugError(`[GEO AUTH] ❌ Zone check failed: reason=${data.reason}, message=${data.message}`); + } + } + + return data; + + } catch (error) { + debugError(`[GEO AUTH] Network error during zone check: ${error.message}`); + return null; + } +} + +/** + * Request authentication with MeshMapper geo-auth API + * Handles both connect (acquire session) and disconnect (release session) + * @param {string} reason - Either "connect" (acquire session) or "disconnect" (release session) * @returns {Promise} True if allowed to continue, false otherwise */ -async function checkCapacity(reason) { +async function requestAuth(reason) { // Validate public key exists if (!state.devicePublicKey) { - debugError("[CAPACITY] checkCapacity called but no public key stored"); + debugError("[AUTH] requestAuth called but no public key stored"); return reason === "connect" ? false : true; // Fail closed on connect, allow disconnect } // Set status for connect requests if (reason === "connect") { - setDynamicStatus("Acquiring wardriving slot", STATUS_COLORS.info); + setDynamicStatus("Authenticating to MeshMapper", STATUS_COLORS.info); } try { + // Build base payload const payload = { key: MESHMAPPER_API_KEY, public_key: state.devicePublicKey, - ver: APP_VERSION, - who: getDeviceIdentifier(), - ver: APP_VERSION, reason: reason }; - debugLog(`[CAPACITY] Checking capacity: reason=${reason}, public_key=${state.devicePublicKey.substring(0, 16)}..., who=${payload.who}`); + // For connect: add device metadata and GPS coords + if (reason === "connect") { + // Acquire fresh GPS for auth + debugLog("[AUTH] Acquiring fresh GPS for auth request"); + const coords = await getValidGpsForZoneCheck(); + + if (!coords) { + debugError("[AUTH] Failed to acquire GPS for auth"); + state.disconnectReason = "gps_unavailable"; + return false; + } + + // Add device metadata (bound to session at auth time) + payload.who = getDeviceIdentifier(); + payload.ver = APP_VERSION; + payload.power = getCurrentPowerSetting(); + payload.iata = state.currentZone?.code || WARDIVE_IATA_CODE; + + // Get short model name from database, or sanitized raw model if unknown + const parsedModel = parseDeviceModel(state.deviceModel); + const deviceConfig = findDeviceConfig(parsedModel); + payload.model = deviceConfig?.shortName || parsedModel || "Unknown"; + + // Add GPS coords (use lng for API, internally we use lon) + payload.coords = { + lat: coords.lat, + lng: coords.lon, // Convert lon → lng for API + accuracy_m: coords.accuracy_m, + timestamp: coords.timestamp + }; + + debugLog(`[AUTH] Connect request: public_key=${state.devicePublicKey.substring(0, 16)}..., who=${payload.who}, iata=${payload.iata}`); + } else { + // For disconnect: add session_id + payload.session_id = state.wardriveSessionId; + debugLog(`[AUTH] Disconnect request: public_key=${state.devicePublicKey.substring(0, 16)}..., session_id=${payload.session_id}`); + } - const response = await fetch(MESHMAPPER_CAPACITY_CHECK_URL, { + const response = await fetch(GEO_AUTH_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); + // Parse JSON body (even on error responses - server returns error codes in body) + let data; + try { + data = await response.json(); + } catch (parseError) { + debugError(`[AUTH] Failed to parse response JSON: ${parseError.message}`); + if (reason === "connect") { + state.disconnectReason = "app_down"; + return false; + } + return true; // Allow disconnect to proceed + } + + // Handle HTTP-level errors with known error codes in body if (!response.ok) { - debugError(`[CAPACITY] Capacity check API returned error status ${response.status}`); - // Fail closed on network errors for connect + const serverMsg = data?.message || 'No message'; + addErrorLogEntry(`API returned error status ${response.status}: ${serverMsg}`, "AUTH"); + debugError(`[AUTH] API returned error status ${response.status}: ${serverMsg}`); + + // Check if server returned a known error code + if (data && data.reason && REASON_MESSAGES[data.reason]) { + debugLog(`[AUTH] Known error code: ${data.reason} - ${data.message || REASON_MESSAGES[data.reason]}`); + if (reason === "connect") { + state.disconnectReason = data.reason; + return false; + } + return true; // Allow disconnect to proceed + } + + // Unknown error - fail closed for connect, include server message if (reason === "connect") { - debugError("[CAPACITY] Failing closed (denying connection) due to API error"); - state.disconnectReason = "app_down"; // Track disconnect reason + addErrorLogEntry(`Auth failed: ${data?.reason || 'unknown'} - ${serverMsg}`, "AUTH"); + debugError(`[AUTH] Failing closed (denying connection) due to unknown API error: ${data?.reason || 'unknown'}`); + state.disconnectReason = "app_down"; return false; } return true; // Always allow disconnect to proceed } + debugLog(`[AUTH] Response: success=${data.success}, tx_allowed=${data.tx_allowed}, rx_allowed=${data.rx_allowed}, session_id=${data.session_id || 'none'}, reason=${data.reason || 'none'}`); - const data = await response.json(); - debugLog(`[CAPACITY] Capacity check response: allowed=${data.allowed}, session_id=${data.session_id || 'missing'}, debug_mode=${data.debug_mode || 'not set'}, reason=${data.reason || 'none'}`); - - // Handle capacity full vs. allowed cases separately - if (data.allowed === false && reason === "connect") { - // Check if a reason code is provided - if (data.reason) { - debugLog(`[CAPACITY] API returned reason code: ${data.reason}`); - state.disconnectReason = data.reason; // Store the reason code directly - } else { - state.disconnectReason = "capacity_full"; // Default to capacity_full + // Handle connect response + if (reason === "connect") { + // Check for full denial + if (data.success === false) { + debugLog(`[AUTH] Connect denied: ${data.reason} - ${data.message || ''}`); + state.disconnectReason = data.reason || "auth_denied"; + return false; } - return false; - } - - // For connect requests, validate session_id and check debug_mode - if (reason === "connect" && data.allowed === true) { + + // Success - store session info if (!data.session_id) { - debugError("[CAPACITY] Capacity check returned allowed=true but session_id is missing"); - state.disconnectReason = "session_id_error"; // Track disconnect reason + debugError("[AUTH] Auth returned success=true but session_id is missing"); + state.disconnectReason = "session_id_error"; return false; } - // Store the session_id for use in MeshMapper API posts + // Store session data state.wardriveSessionId = data.session_id; - debugLog(`[CAPACITY] Wardrive session ID received and stored: ${state.wardriveSessionId}`); + state.txAllowed = data.tx_allowed === true; + state.rxAllowed = data.rx_allowed === true; + state.sessionExpiresAt = data.expires_at || null; + + debugLog(`[AUTH] Session acquired: id=${state.wardriveSessionId}, tx=${state.txAllowed}, rx=${state.rxAllowed}, expires=${state.sessionExpiresAt}`); + + // Schedule heartbeat to keep session alive + if (state.sessionExpiresAt) { + scheduleHeartbeat(state.sessionExpiresAt); + } + + // Check for RX-only scenario (zone_full) + if (!state.txAllowed && state.rxAllowed) { + debugLog(`[AUTH] RX-only mode: TX slots full, reason=${data.reason}`); + // Don't set disconnectReason - this is a partial success + } // Check for debug_mode flag (optional field) if (data.debug_mode === 1) { state.debugMode = true; - debugLog(`[CAPACITY] 🐛 DEBUG MODE ENABLED by API`); + debugLog(`[AUTH] 🐛 DEBUG MODE ENABLED by API`); } else { state.debugMode = false; - debugLog(`[CAPACITY] Debug mode NOT enabled`); } + + return true; // Success (full or RX-only) } - // For disconnect requests, clear the session_id and debug mode + // Handle disconnect response if (reason === "disconnect") { - if (state.wardriveSessionId) { - debugLog(`[CAPACITY] Clearing wardrive session ID on disconnect: ${state.wardriveSessionId}`); - state.wardriveSessionId = null; - } + // Clear session state regardless of server response + debugLog(`[AUTH] Clearing session state on disconnect`); + state.wardriveSessionId = null; + state.txAllowed = false; + state.rxAllowed = false; + state.sessionExpiresAt = null; state.debugMode = false; - debugLog(`[CAPACITY] Debug mode cleared on disconnect`); + + if (data.success === true && data.disconnected === true) { + debugLog(`[AUTH] Disconnect confirmed by server`); + } else if (data.success === false) { + debugWarn(`[AUTH] Server reported disconnect error: ${data.reason} - ${data.message || ''}`); + // Don't fail - we still clean up locally + } + + return true; // Always return true for disconnect } - - return data.allowed === true; } catch (error) { - debugError(`[CAPACITY] Capacity check failed: ${error.message}`); + debugError(`[AUTH] Request failed: ${error.message}`); // Fail closed on network errors for connect if (reason === "connect") { - debugError("[CAPACITY] Failing closed (denying connection) due to network error"); - state.disconnectReason = "app_down"; // Track disconnect reason + debugError("[AUTH] Failing closed (denying connection) due to network error"); + state.disconnectReason = "app_down"; return false; } + // For disconnect, clear state even on error + state.wardriveSessionId = null; + state.txAllowed = false; + state.rxAllowed = false; + state.sessionExpiresAt = null; + state.debugMode = false; + return true; // Always allow disconnect to proceed } } @@ -1431,186 +2315,55 @@ function buildDebugData(metadata, heardByte, repeaterId) { } /** - * Post wardrive ping data to MeshMapper API - * @param {number} lat - Latitude - * @param {number} lon - Longitude - * @param {string} heardRepeats - Heard repeats string (e.g., "4e(1.75),b7(-0.75)" or "None") - */ -async function postToMeshMapperAPI(lat, lon, heardRepeats) { - try { - // Validate session_id exists before posting - if (!state.wardriveSessionId) { - debugError("[API QUEUE] Cannot post to MeshMapper API: no session_id available"); - setDynamicStatus("Missing session ID", STATUS_COLORS.error); - state.disconnectReason = "session_id_error"; // Track disconnect reason - // Disconnect after a brief delay to ensure user sees the error message - setTimeout(() => { - disconnect().catch(err => debugError(`[BLE] Disconnect after missing session_id failed: ${err.message}`)); - }, 1500); - return; // Exit early - } - - const payload = { - key: MESHMAPPER_API_KEY, - lat, - lon, - who: getDeviceIdentifier(), - power: getCurrentPowerSetting(), - external_antenna: getExternalAntennaSetting(), - heard_repeats: heardRepeats, - ver: APP_VERSION, - test: 0, - iata: WARDIVE_IATA_CODE, - session_id: state.wardriveSessionId, - WARDRIVE_TYPE: "TX" - }; - - // Add debug data if debug mode is enabled and repeater data is available - if (state.debugMode && state.tempTxRepeaterData && state.tempTxRepeaterData.length > 0) { - debugLog(`[API QUEUE] 🐛 Debug mode active - building debug_data array for TX`); - - const debugDataArray = []; - - for (const repeater of state.tempTxRepeaterData) { - if (repeater.metadata) { - const heardByte = repeater.repeaterId; // First byte of path - const debugData = buildDebugData(repeater.metadata, heardByte, repeater.repeaterId); - debugDataArray.push(debugData); - debugLog(`[API QUEUE] 🐛 Added debug data for TX repeater: ${repeater.repeaterId}`); - } - } - - if (debugDataArray.length > 0) { - payload.debug_data = debugDataArray; - debugLog(`[API QUEUE] 🐛 TX payload includes ${debugDataArray.length} debug_data entries`); - } - - // Clear temp data after use - state.tempTxRepeaterData = null; - } - - debugLog(`[API QUEUE] Posting to MeshMapper API: lat=${lat.toFixed(5)}, lon=${lon.toFixed(5)}, who=${payload.who}, power=${payload.power}, heard_repeats=${heardRepeats}, ver=${payload.ver}, iata=${payload.iata}, session_id=${payload.session_id}, WARDRIVE_TYPE=${payload.WARDRIVE_TYPE}${payload.debug_data ? `, debug_data=${payload.debug_data.length} entries` : ''}`); - - const response = await fetch(MESHMAPPER_API_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) - }); - - debugLog(`[API QUEUE] MeshMapper API response status: ${response.status}`); - - // Always try to parse the response body to check for slot revocation - // regardless of HTTP status code - try { - const data = await response.json(); - debugLog(`[API QUEUE] MeshMapper API response data: ${JSON.stringify(data)}`); - - // Check if slot has been revoked - if (data.allowed === false) { - debugError("[API QUEUE] MeshMapper slot has been revoked"); - setDynamicStatus("API post failed (revoked)", STATUS_COLORS.error); - state.disconnectReason = "slot_revoked"; // Track disconnect reason - // Disconnect after a brief delay to ensure user sees the error message - setTimeout(() => { - disconnect().catch(err => debugError(`[BLE] Disconnect after slot revocation failed: ${err.message}`)); - }, 1500); - return; // Exit early after slot revocation - } else if (data.allowed === true) { - debugLog("[API QUEUE] MeshMapper API allowed check passed: device still has an active MeshMapper slot"); - } else { - debugError(`[API QUEUE] MeshMapper API response missing 'allowed' field: ${JSON.stringify(data)}`); - } - } catch (parseError) { - debugError(`[API QUEUE] Failed to parse MeshMapper API response: ${parseError.message}`); - // Continue operation if we can't parse the response - } - - if (!response.ok) { - debugError(`[API QUEUE] MeshMapper API returned error status ${response.status}`); - } else { - debugLog(`[API QUEUE] MeshMapper API post successful (status ${response.status})`); - } - } catch (error) { - // Log error but don't fail the ping - debugError(`[API QUEUE] MeshMapper API post failed: ${error.message}`); - } -} - -/** - * Post to MeshMapper API in background (non-blocking) - * This function runs asynchronously after the RX listening window completes - * UI status messages are suppressed for successful posts, errors are shown + * Queue a TX wardrive entry for batch submission + * Called after RX listening window completes with final heard_repeats data * @param {number} lat - Latitude * @param {number} lon - Longitude * @param {number} accuracy - GPS accuracy in meters * @param {string} heardRepeats - Heard repeats string (e.g., "4e(1.75),b7(-0.75)" or "None") + * @param {number|null} noisefloor - Noisefloor value captured at ping time + * @param {number} timestamp - Unix timestamp captured at ping time */ -async function postApiInBackground(lat, lon, accuracy, heardRepeats) { - debugLog(`[API QUEUE] postApiInBackground called with heard_repeats="${heardRepeats}"`); +function queueTxEntry(lat, lon, accuracy, heardRepeats, noisefloor, timestamp) { + debugLog(`[WARDRIVE QUEUE] queueTxEntry called: lat=${lat.toFixed(5)}, lon=${lon.toFixed(5)}, heard_repeats="${heardRepeats}", noisefloor=${noisefloor}, timestamp=${timestamp}`); - // Hidden 3-second delay before API POST (no user-facing status message) - debugLog("[API QUEUE] Starting 3-second delay before API POST"); - await new Promise(resolve => setTimeout(resolve, 3000)); - - // Check if we're still connected before posting (disconnect may have happened during delay) - if (!state.connection || !state.wardriveSessionId) { - debugLog("[API QUEUE] Skipping background API post - disconnected or no session_id"); - return; - } - - debugLog("[API QUEUE] 3-second delay complete, posting to API"); - try { - await postToMeshMapperAPI(lat, lon, heardRepeats); - debugLog("[API QUEUE] Background API post completed successfully"); - // No success status message - suppress from UI - } catch (error) { - debugError("[API QUEUE] Background API post failed:", error); - // Errors are propagated to caller for user notification - throw error; - } - - // Update map after API post - debugLog("[UI] Scheduling coverage map refresh"); - setTimeout(() => { - const shouldRefreshMap = accuracy && accuracy < GPS_ACCURACY_THRESHOLD_M; - - if (shouldRefreshMap) { - debugLog(`[UI] Refreshing coverage map (accuracy ${accuracy}m within threshold)`); - scheduleCoverageRefresh(lat, lon); - } else { - debugLog(`[UI] Skipping map refresh (accuracy ${accuracy}m exceeds threshold)`); - } - }, MAP_REFRESH_DELAY_MS); -} - -/** - * Post to MeshMapper API and refresh coverage map after heard repeats are finalized - * This function now queues TX messages instead of posting immediately - * @param {number} lat - Latitude - * @param {number} lon - Longitude - * @param {number} accuracy - GPS accuracy in meters - * @param {string} heardRepeats - Heard repeats string (e.g., "4e(1.75),b7(-0.75)" or "None") - */ -async function postApiAndRefreshMap(lat, lon, accuracy, heardRepeats) { - debugLog(`[API QUEUE] postApiAndRefreshMap called with heard_repeats="${heardRepeats}"`); - - // Build payload - const payload = { - key: MESHMAPPER_API_KEY, + // Build entry-only payload (wrapper added by submitWardriveData) + const entry = { + type: "TX", lat, lon, - who: getDeviceIdentifier(), - power: getCurrentPowerSetting(), + noisefloor, heard_repeats: heardRepeats, - ver: APP_VERSION, - test: 0, - iata: WARDIVE_IATA_CODE, - session_id: state.wardriveSessionId + timestamp }; - // Queue message instead of posting immediately - queueApiMessage(payload, "TX"); - debugLog(`[API QUEUE] TX message queued: lat=${lat.toFixed(5)}, lon=${lon.toFixed(5)}, heard_repeats="${heardRepeats}"`); + // Add debug data if debug mode is enabled and repeater data is available + if (state.debugMode && state.tempTxRepeaterData && state.tempTxRepeaterData.length > 0) { + debugLog(`[WARDRIVE QUEUE] 🐛 Debug mode active - building debug_data array for TX`); + + const debugDataArray = []; + + for (const repeater of state.tempTxRepeaterData) { + if (repeater.metadata) { + const heardByte = repeater.repeaterId; // First byte of path + const debugData = buildDebugData(repeater.metadata, heardByte, repeater.repeaterId); + debugDataArray.push(debugData); + debugLog(`[WARDRIVE QUEUE] 🐛 Added debug data for TX repeater: ${repeater.repeaterId}`); + } + } + + if (debugDataArray.length > 0) { + entry.debug_data = debugDataArray; + debugLog(`[WARDRIVE QUEUE] 🐛 TX entry includes ${debugDataArray.length} debug_data entries`); + } + + // Clear temp data after use + state.tempTxRepeaterData = null; + } + + // Queue entry for batch submission + queueWardriveEntry(entry); + debugLog(`[WARDRIVE QUEUE] TX entry queued: lat=${lat.toFixed(5)}, lon=${lon.toFixed(5)}, heard_repeats="${heardRepeats}"`); // Update map after queueing setTimeout(() => { @@ -1623,62 +2376,42 @@ async function postApiAndRefreshMap(lat, lon, accuracy, heardRepeats) { debugLog(`[UI] Skipping map refresh (accuracy ${accuracy}m exceeds threshold)`); } - // Unlock ping controls now that message is queued - unlockPingControls("after TX message queued"); + // Unlock ping controls now that entry is queued + unlockPingControls("after TX entry queued"); - // Update status based on current mode - if (state.connection) { - if (state.txRxAutoRunning) { - // Check if we should resume a paused auto countdown (manual ping during auto mode) - const resumed = resumeAutoCountdown(); - if (!resumed) { - // No paused timer to resume, schedule new auto ping (this was an auto ping) - debugLog("[TX/RX AUTO] Scheduling next auto ping"); - scheduleNextAutoPing(); - } else { - debugLog("[TX/RX AUTO] Resumed auto countdown after manual ping"); - } - } else { - debugLog("[TX/RX AUTO] Setting dynamic status to show queue size"); - // Status already set by queueApiMessage() - } - } + // NOTE: Auto ping scheduling is handled in the RX listening window completion callback (line ~4939) + // Do NOT schedule here - that would create duplicate timers causing rapid-fire pings + debugLog("[TX/RX AUTO] TX entry queued, map refresh timer complete"); }, MAP_REFRESH_DELAY_MS); } -// ---- API Batch Queue System ---- +// ---- Wardrive Batch Queue System ---- /** - * Queue an API message for batch posting - * @param {Object} payload - The API payload object - * @param {string} wardriveType - "TX" or "RX" wardrive type + * Queue a wardrive entry for batch submission + * Entry must include 'type' field ("TX" or "RX") + * @param {Object} entry - The wardrive entry with type, lat, lon, noisefloor, heard_repeats, timestamp, debug_data? */ -function queueApiMessage(payload, wardriveType) { - debugLog(`[API QUEUE] Queueing ${wardriveType} message`); +function queueWardriveEntry(entry) { + debugLog(`[WARDRIVE QUEUE] Queueing ${entry.type} entry`); - // Add WARDRIVE_TYPE to payload - const messagePayload = { - ...payload, - WARDRIVE_TYPE: wardriveType - }; - - apiQueue.messages.push(messagePayload); - debugLog(`[API QUEUE] Queue size: ${apiQueue.messages.length}/${API_BATCH_MAX_SIZE}`); + wardriveQueue.messages.push(entry); + debugLog(`[WARDRIVE QUEUE] Queue size: ${wardriveQueue.messages.length}/${API_BATCH_MAX_SIZE}`); - // Start periodic flush timer if this is the first message - if (apiQueue.messages.length === 1 && !apiQueue.flushTimerId) { - startFlushTimer(); + // Start periodic flush timer if this is the first entry + if (wardriveQueue.messages.length === 1 && !wardriveQueue.flushTimerId) { + startWardriveFlushTimer(); } // If TX type: start/reset 3-second flush timer - if (wardriveType === "TX") { - scheduleTxFlush(); + if (entry.type === "TX") { + scheduleWardriveFlush(); } // If queue reaches max size: flush immediately - if (apiQueue.messages.length >= API_BATCH_MAX_SIZE) { - debugLog(`[API QUEUE] Queue reached max size (${API_BATCH_MAX_SIZE}), flushing immediately`); - flushApiQueue(); + if (wardriveQueue.messages.length >= API_BATCH_MAX_SIZE) { + debugLog(`[WARDRIVE QUEUE] Queue reached max size (${API_BATCH_MAX_SIZE}), flushing immediately`); + submitWardriveData(); } // Queue depth is logged above for debugging - no need to show in dynamic status bar @@ -1688,38 +2421,38 @@ function queueApiMessage(payload, wardriveType) { * Schedule flush 3 seconds after TX ping * Resets timer if called again (coalesces rapid TX pings) */ -function scheduleTxFlush() { - debugLog(`[API QUEUE] Scheduling TX flush in ${API_TX_FLUSH_DELAY_MS}ms`); +function scheduleWardriveFlush() { + debugLog(`[WARDRIVE QUEUE] Scheduling TX flush in ${API_TX_FLUSH_DELAY_MS}ms`); // Clear existing TX flush timer if present - if (apiQueue.txFlushTimerId) { - clearTimeout(apiQueue.txFlushTimerId); - debugLog(`[API QUEUE] Cleared previous TX flush timer`); + if (wardriveQueue.txFlushTimerId) { + clearTimeout(wardriveQueue.txFlushTimerId); + debugLog(`[WARDRIVE QUEUE] Cleared previous TX flush timer`); } // Schedule new TX flush - apiQueue.txFlushTimerId = setTimeout(() => { - debugLog(`[API QUEUE] TX flush timer fired`); - flushApiQueue(); + wardriveQueue.txFlushTimerId = setTimeout(() => { + debugLog(`[WARDRIVE QUEUE] TX flush timer fired`); + submitWardriveData(); }, API_TX_FLUSH_DELAY_MS); } /** * Start the 30-second periodic flush timer */ -function startFlushTimer() { - debugLog(`[API QUEUE] Starting periodic flush timer (${API_BATCH_FLUSH_INTERVAL_MS}ms)`); +function startWardriveFlushTimer() { + debugLog(`[WARDRIVE QUEUE] Starting periodic flush timer (${API_BATCH_FLUSH_INTERVAL_MS}ms)`); // Clear existing timer if present - if (apiQueue.flushTimerId) { - clearInterval(apiQueue.flushTimerId); + if (wardriveQueue.flushTimerId) { + clearInterval(wardriveQueue.flushTimerId); } // Start periodic flush timer - apiQueue.flushTimerId = setInterval(() => { - if (apiQueue.messages.length > 0) { - debugLog(`[API QUEUE] Periodic flush timer fired, flushing ${apiQueue.messages.length} messages`); - flushApiQueue(); + wardriveQueue.flushTimerId = setInterval(() => { + if (wardriveQueue.messages.length > 0) { + debugLog(`[WARDRIVE QUEUE] Periodic flush timer fired, flushing ${wardriveQueue.messages.length} messages`); + submitWardriveData(); } }, API_BATCH_FLUSH_INTERVAL_MS); } @@ -1727,135 +2460,358 @@ function startFlushTimer() { /** * Stop all flush timers (periodic and TX) */ -function stopFlushTimers() { - debugLog(`[API QUEUE] Stopping all flush timers`); +function stopWardriveTimers() { + debugLog(`[WARDRIVE QUEUE] Stopping all flush timers`); - if (apiQueue.flushTimerId) { - clearInterval(apiQueue.flushTimerId); - apiQueue.flushTimerId = null; - debugLog(`[API QUEUE] Periodic flush timer stopped`); + if (wardriveQueue.flushTimerId) { + clearInterval(wardriveQueue.flushTimerId); + wardriveQueue.flushTimerId = null; + debugLog(`[WARDRIVE QUEUE] Periodic flush timer stopped`); } - if (apiQueue.txFlushTimerId) { - clearTimeout(apiQueue.txFlushTimerId); - apiQueue.txFlushTimerId = null; - debugLog(`[API QUEUE] TX flush timer stopped`); + if (wardriveQueue.txFlushTimerId) { + clearTimeout(wardriveQueue.txFlushTimerId); + wardriveQueue.txFlushTimerId = null; + debugLog(`[WARDRIVE QUEUE] TX flush timer stopped`); } } /** - * Flush all queued messages to the API - * Prevents concurrent flushes with isProcessing flag + * Submit all queued wardrive entries to the API + * Wraps entries with key/session_id and posts to WARDRIVE_ENDPOINT + * Prevents concurrent submissions with isProcessing flag + * Single retry on network failure * @returns {Promise} */ -async function flushApiQueue() { - // Prevent concurrent flushes - if (apiQueue.isProcessing) { - debugWarn(`[API QUEUE] Flush already in progress, skipping`); +async function submitWardriveData() { + // Prevent concurrent submissions + if (wardriveQueue.isProcessing) { + debugWarn(`[WARDRIVE QUEUE] Submission already in progress, skipping`); + return; + } + + // Nothing to submit + if (wardriveQueue.messages.length === 0) { + debugLog(`[WARDRIVE QUEUE] Queue is empty, nothing to submit`); return; } - // Nothing to flush - if (apiQueue.messages.length === 0) { - debugLog(`[API QUEUE] Queue is empty, nothing to flush`); + // Validate session_id exists + if (!state.wardriveSessionId) { + debugError("[WARDRIVE QUEUE] Cannot submit: no session_id available"); + setDynamicStatus("Missing session ID", STATUS_COLORS.error); + handleWardriveApiError("session_id_missing", "Cannot submit: no session_id"); return; } // Lock processing - apiQueue.isProcessing = true; - debugLog(`[API QUEUE] Starting flush of ${apiQueue.messages.length} messages`); + wardriveQueue.isProcessing = true; + debugLog(`[WARDRIVE QUEUE] Starting submission of ${wardriveQueue.messages.length} entries`); - // Clear TX flush timer when flushing - if (apiQueue.txFlushTimerId) { - clearTimeout(apiQueue.txFlushTimerId); - apiQueue.txFlushTimerId = null; + // Clear TX flush timer when submitting + if (wardriveQueue.txFlushTimerId) { + clearTimeout(wardriveQueue.txFlushTimerId); + wardriveQueue.txFlushTimerId = null; } - // Take all messages from queue - const batch = [...apiQueue.messages]; - apiQueue.messages = []; + // Take all entries from queue + const entries = [...wardriveQueue.messages]; + wardriveQueue.messages = []; + + // Count TX and RX entries for logging + const txCount = entries.filter(e => e.type === "TX").length; + const rxCount = entries.filter(e => e.type === "RX").length; + debugLog(`[WARDRIVE QUEUE] Batch composition: ${txCount} TX, ${rxCount} RX`); - // Count TX and RX messages for logging - const txCount = batch.filter(m => m.WARDRIVE_TYPE === "TX").length; - const rxCount = batch.filter(m => m.WARDRIVE_TYPE === "RX").length; - debugLog(`[API QUEUE] Batch composition: ${txCount} TX, ${rxCount} RX`); + // Build wrapper payload + const payload = { + key: MESHMAPPER_API_KEY, + session_id: state.wardriveSessionId, + data: entries + }; - // Status removed from dynamic status bar - debug log above is sufficient for debugging + debugLog(`[WARDRIVE QUEUE] POST to ${WARDRIVE_ENDPOINT} with ${entries.length} entries`); - try { - // Validate session_id exists - if (!state.wardriveSessionId) { - debugError("[API QUEUE] Cannot flush: no session_id available"); - setDynamicStatus("Missing session ID", STATUS_COLORS.error); - state.disconnectReason = "session_id_error"; - setTimeout(() => { - disconnect().catch(err => debugError(`[BLE] Disconnect after missing session_id failed: ${err.message}`)); - }, 1500); - return; + // Attempt submission with single retry + let lastError = null; + for (let attempt = 1; attempt <= 2; attempt++) { + try { + const response = await fetch(WARDRIVE_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + debugLog(`[WARDRIVE QUEUE] Response status: ${response.status} (attempt ${attempt})`); + + // Parse response + const data = await response.json(); + debugLog(`[WARDRIVE QUEUE] Response data: ${JSON.stringify(data)}`); + + // Check for success + if (data.success === true) { + debugLog(`[WARDRIVE QUEUE] Submission successful: ${txCount} TX, ${rxCount} RX`); + + // Schedule heartbeat if expires_at is provided + if (data.expires_at) { + scheduleHeartbeat(data.expires_at); + } + + // Clear status after successful post + if (state.connection && !state.txRxAutoRunning) { + setDynamicStatus("Idle"); + } + + // Success - exit retry loop + wardriveQueue.isProcessing = false; + return; + } + + // Handle error response + if (data.success === false) { + debugError(`[WARDRIVE QUEUE] API error: ${data.reason || 'unknown'} - ${data.message || ''}`); + handleWardriveApiError(data.reason, data.message); + wardriveQueue.isProcessing = false; + return; + } + + // Unexpected response format + debugError(`[WARDRIVE QUEUE] Unexpected response format: ${JSON.stringify(data)}`); + lastError = new Error("Unexpected response format"); + + } catch (error) { + debugError(`[WARDRIVE QUEUE] Submission failed (attempt ${attempt}): ${error.message}`); + lastError = error; + + // If first attempt failed, wait before retry + if (attempt === 1) { + debugLog(`[WARDRIVE QUEUE] Retrying in ${WARDRIVE_RETRY_DELAY_MS}ms...`); + await new Promise(resolve => setTimeout(resolve, WARDRIVE_RETRY_DELAY_MS)); + } } - - debugLog(`[API QUEUE] POST to ${MESHMAPPER_API_URL} with ${batch.length} messages`); - - const response = await fetch(MESHMAPPER_API_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(batch) - }); - - debugLog(`[API QUEUE] Response status: ${response.status}`); - - // Parse response to check for slot revocation + } + + // Both attempts failed + debugError(`[WARDRIVE QUEUE] Submission failed after 2 attempts: ${lastError?.message}`); + setDynamicStatus("Error: API submission failed", STATUS_COLORS.error); + + // Re-queue entries for next attempt (unless queue is full) + if (wardriveQueue.messages.length + entries.length <= API_BATCH_MAX_SIZE) { + wardriveQueue.messages.unshift(...entries); + debugLog(`[WARDRIVE QUEUE] Re-queued ${entries.length} entries for next attempt`); + } else { + debugWarn(`[WARDRIVE QUEUE] Cannot re-queue entries, queue would exceed max size. ${entries.length} entries lost.`); + } + + wardriveQueue.isProcessing = false; +} + +/** + * Get queue status for debugging + * @returns {Object} Queue status object + */ +function getWardriveQueueStatus() { + return { + queueSize: wardriveQueue.messages.length, + isProcessing: wardriveQueue.isProcessing, + hasPeriodicTimer: wardriveQueue.flushTimerId !== null, + hasTxTimer: wardriveQueue.txFlushTimerId !== null + }; +} + +// ---- Heartbeat System ---- + +/** + * Schedule heartbeat to fire before session expires + * @param {number} expiresAt - Unix timestamp when session expires + */ +function scheduleHeartbeat(expiresAt) { + // Cancel any existing heartbeat timer + cancelHeartbeat(); + + // Calculate when to send heartbeat (5 minutes before expiry) + const now = Math.floor(Date.now() / 1000); + const msUntilExpiry = (expiresAt - now) * 1000; + const msUntilHeartbeat = msUntilExpiry - HEARTBEAT_BUFFER_MS; + + if (msUntilHeartbeat <= 0) { + // Session is about to expire or already expired - send heartbeat immediately + debugWarn(`[HEARTBEAT] Session expires in ${Math.floor(msUntilExpiry / 1000)}s, sending heartbeat immediately`); + sendHeartbeat(); + return; + } + + debugLog(`[HEARTBEAT] Scheduling heartbeat in ${Math.floor(msUntilHeartbeat / 1000)}s (session expires in ${Math.floor(msUntilExpiry / 1000)}s)`); + + state.heartbeatTimerId = setTimeout(() => { + debugLog(`[HEARTBEAT] Heartbeat timer fired`); + sendHeartbeat(); + }, msUntilHeartbeat); +} + +/** + * Cancel any scheduled heartbeat + */ +function cancelHeartbeat() { + if (state.heartbeatTimerId) { + clearTimeout(state.heartbeatTimerId); + state.heartbeatTimerId = null; + debugLog(`[HEARTBEAT] Heartbeat timer cancelled`); + } +} + +/** + * Send heartbeat to keep session alive + * Uses current GPS position for heartbeat coords + * Single retry on network failure + */ +async function sendHeartbeat() { + // Validate we have a session + if (!state.wardriveSessionId) { + debugWarn(`[HEARTBEAT] Cannot send heartbeat: no session_id`); + return; + } + + // Get current GPS position for heartbeat + const currentCoords = state.lastFix; + const coords = currentCoords ? { + lat: currentCoords.coords.latitude, + lon: currentCoords.coords.longitude, + timestamp: Math.floor(Date.now() / 1000) + } : null; + + // Build heartbeat payload + const payload = { + key: MESHMAPPER_API_KEY, + session_id: state.wardriveSessionId, + heartbeat: true, + coords + }; + + debugLog(`[HEARTBEAT] Sending heartbeat: session_id=${state.wardriveSessionId}, coords=${coords ? `${coords.lat.toFixed(5)},${coords.lon.toFixed(5)}` : 'null'}`); + + // Attempt heartbeat with single retry + let lastError = null; + for (let attempt = 1; attempt <= 2; attempt++) { try { + const response = await fetch(WARDRIVE_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + debugLog(`[HEARTBEAT] Response status: ${response.status} (attempt ${attempt})`); + + // Parse response const data = await response.json(); - debugLog(`[API QUEUE] Response data: ${JSON.stringify(data)}`); + debugLog(`[HEARTBEAT] Response data: ${JSON.stringify(data)}`); - // Check if slot has been revoked - if (data.allowed === false) { - debugError("[API QUEUE] MeshMapper slot has been revoked"); - setDynamicStatus("API post failed (revoked)", STATUS_COLORS.error); - state.disconnectReason = "slot_revoked"; - setTimeout(() => { - disconnect().catch(err => debugError(`[BLE] Disconnect after slot revocation failed: ${err.message}`)); - }, 1500); + // Check for success + if (data.success === true) { + debugLog(`[HEARTBEAT] Heartbeat successful`); + + // Schedule next heartbeat if expires_at is provided + if (data.expires_at) { + scheduleHeartbeat(data.expires_at); + } + + return; // Success - exit + } + + // Handle error response + if (data.success === false) { + debugError(`[HEARTBEAT] Heartbeat failed: ${data.reason || 'unknown'} - ${data.message || ''}`); + handleWardriveApiError(data.reason, data.message); return; - } else if (data.allowed === true) { - debugLog("[API QUEUE] Slot check passed"); } - } catch (parseError) { - debugError(`[API QUEUE] Failed to parse response: ${parseError.message}`); - } - - if (!response.ok) { - debugError(`[API QUEUE] API returned error status ${response.status}`); - setDynamicStatus("Error: API batch post failed", STATUS_COLORS.error); - } else { - debugLog(`[API QUEUE] Batch post successful: ${txCount} TX, ${rxCount} RX`); - // Clear status after successful post - if (state.connection && !state.txRxAutoRunning) { - setDynamicStatus("Idle"); + + // Unexpected response format + debugError(`[HEARTBEAT] Unexpected response format: ${JSON.stringify(data)}`); + lastError = new Error("Unexpected response format"); + + } catch (error) { + debugError(`[HEARTBEAT] Heartbeat failed (attempt ${attempt}): ${error.message}`); + lastError = error; + + // If first attempt failed, wait before retry + if (attempt === 1) { + debugLog(`[HEARTBEAT] Retrying in ${WARDRIVE_RETRY_DELAY_MS}ms...`); + await new Promise(resolve => setTimeout(resolve, WARDRIVE_RETRY_DELAY_MS)); } } - } catch (error) { - debugError(`[API QUEUE] Batch post failed: ${error.message}`); - setDynamicStatus("Error: API batch post failed", STATUS_COLORS.error); - } finally { - // Unlock processing - apiQueue.isProcessing = false; - debugLog(`[API QUEUE] Flush complete`); } + + // Both attempts failed + debugError(`[HEARTBEAT] Heartbeat failed after 2 attempts: ${lastError?.message}`); + // Don't disconnect on heartbeat failure - the next data submission will also schedule a heartbeat + // Just log the error and let the session expire naturally if needed } +// ---- Wardrive API Error Handler ---- + /** - * Get queue status for debugging - * @returns {Object} Queue status object + * Centralized error handler for wardrive API errors + * Handles session expiry, revocation, and other error conditions + * @param {string} reason - Error reason code from API + * @param {string} message - Human-readable error message */ -function getQueueStatus() { - return { - queueSize: apiQueue.messages.length, - isProcessing: apiQueue.isProcessing, - hasPeriodicTimer: apiQueue.flushTimerId !== null, - hasTxTimer: apiQueue.txFlushTimerId !== null - }; +function handleWardriveApiError(reason, message) { + debugError(`[WARDRIVE API] Error: reason=${reason}, message=${message}`); + + switch (reason) { + case "session_expired": + case "session_invalid": + case "session_revoked": + case "bad_session": + // Session is no longer valid - disconnect immediately + // Error message will be shown by BLE disconnect handler using REASON_MESSAGES + debugError(`[WARDRIVE API] Session error (${reason}): triggering disconnect`); + state.disconnectReason = reason; + disconnect().catch(err => debugError(`[BLE] Disconnect after ${reason} failed: ${err.message}`)); + break; + + case "invalid_key": + case "unauthorized": + case "bad_key": + // API key issue - disconnect immediately + debugError(`[WARDRIVE API] Authorization error (${reason}): triggering disconnect`); + state.disconnectReason = reason; + disconnect().catch(err => debugError(`[BLE] Disconnect after ${reason} failed: ${err.message}`)); + break; + + case "session_id_missing": + // Missing session - disconnect immediately + debugError(`[WARDRIVE API] Missing session_id: triggering disconnect`); + state.disconnectReason = "session_id_error"; + disconnect().catch(err => debugError(`[BLE] Disconnect after missing session_id failed: ${err.message}`)); + break; + + case "outside_zone": + // User has moved outside their assigned zone - disconnect immediately + debugError(`[WARDRIVE API] Outside zone: triggering disconnect`); + state.disconnectReason = reason; + disconnect().catch(err => debugError(`[BLE] Disconnect after ${reason} failed: ${err.message}`)); + break; + + case "zone_full": + // Zone capacity changed during active session - disconnect immediately + debugError(`[WARDRIVE API] Zone full during wardrive: triggering disconnect`); + state.disconnectReason = reason; + disconnect().catch(err => debugError(`[BLE] Disconnect after ${reason} failed: ${err.message}`)); + break; + + case "rate_limited": + // Rate limited - show warning but don't disconnect + debugWarn(`[WARDRIVE API] Rate limited: ${message}`); + setDynamicStatus("Rate limited - slow down", STATUS_COLORS.warning); + break; + + default: + // Unknown error - log to error log but don't disconnect + debugError(`[WARDRIVE API] Unknown error: ${reason} - ${message}`); + setDynamicStatus(`API error: ${message || reason}`, STATUS_COLORS.error); + break; + } } // ---- Repeater Echo Tracking ---- @@ -2078,18 +3034,42 @@ function getPrintableRatio(str) { */ function parseAdvertName(payload) { try { - // ADVERT structure: [32 bytes pubkey][4 bytes timestamp][64 bytes signature][1 byte flags][name...] + // ADVERT structure: [32 bytes pubkey][4 bytes timestamp][64 bytes signature][appData...] + // appData structure: [1 byte flags][optional 8 bytes lat/lon if flag set][name if flag set] const PUBKEY_SIZE = 32; const TIMESTAMP_SIZE = 4; const SIGNATURE_SIZE = 64; - const FLAGS_SIZE = 1; - const NAME_OFFSET = PUBKEY_SIZE + TIMESTAMP_SIZE + SIGNATURE_SIZE + FLAGS_SIZE; + const APP_DATA_OFFSET = PUBKEY_SIZE + TIMESTAMP_SIZE + SIGNATURE_SIZE; // 100 - if (payload.length <= NAME_OFFSET) { + if (payload.length <= APP_DATA_OFFSET) { + return { valid: false, name: '', reason: 'payload too short for appData' }; + } + + // Read flags byte from appData + const flags = payload[APP_DATA_OFFSET]; + debugLog(`[RX FILTER] ADVERT flags: 0x${flags.toString(16).padStart(2, '0')}`); + + // Flag masks (from advert.js) + const ADV_LATLON_MASK = 0x10; + const ADV_NAME_MASK = 0x80; + + // Check if name is present + if (!(flags & ADV_NAME_MASK)) { + return { valid: false, name: '', reason: 'no name in advert' }; + } + + // Calculate name offset: skip flags byte and optional lat/lon + let nameOffset = APP_DATA_OFFSET + 1; // +1 for flags byte (offset 101) + if (flags & ADV_LATLON_MASK) { + nameOffset += 8; // Skip 4 bytes lat + 4 bytes lon (offset 109) + debugLog(`[RX FILTER] ADVERT has lat/lon, skipping 8 bytes`); + } + + if (payload.length <= nameOffset) { return { valid: false, name: '', reason: 'payload too short for name' }; } - const nameBytes = payload.slice(NAME_OFFSET); + const nameBytes = payload.slice(nameOffset); const decoder = new TextDecoder('utf-8', { fatal: false }); const name = decoder.decode(nameBytes).replace(/\0+$/, '').trim(); @@ -2493,9 +3473,8 @@ async function handleRxLogging(metadata, data) { // A packet's path array contains the sequence of repeater IDs that forwarded the message. // Packets with no path are direct transmissions (node-to-node) and don't provide // information about repeater coverage, so we skip them for RX wardriving purposes. + // NOTE: This is NOT a drop - direct packets are valid, just not useful for wardriving. if (metadata.pathLength === 0) { - rxLogState.dropCount++; - updateRxLogSummary(); debugLog(`[RX LOG] Ignoring: no path (direct transmission, not via repeater)`); return; } @@ -2602,24 +3581,6 @@ function stopUnifiedRxListening() { } - -/** - * Future: Post RX log data to MeshMapper API - * @param {Array} entries - Array of RX log entries - */ -async function postRxLogToMeshMapperAPI(entries) { - if (!MESHMAPPER_RX_LOG_API_URL) { - debugLog('[RX LOG] RX Log API posting not configured yet'); - return; - } - - // Future implementation: - // - Batch post accumulated RX log entries - // - Include session_id from state.wardriveSessionId - // - Format: { observations: [{ repeaterId, snr, lat, lon, timestamp }] } - debugLog(`[RX LOG] Would post ${entries.length} RX log entries to API (not implemented yet)`); -} - // ---- Passive RX Batch API Integration ---- /** @@ -2640,7 +3601,7 @@ function handleRxBatching(repeaterId, snr, rssi, pathLength, header, currentLoca if (!buffer) { // First time hearing this repeater - create new entry buffer = { - firstLocation: { lat: currentLocation.lat, lng: currentLocation.lon }, + firstLocation: { lat: currentLocation.lat, lon: currentLocation.lon }, bestObservation: { snr, rssi, @@ -2648,12 +3609,21 @@ function handleRxBatching(repeaterId, snr, rssi, pathLength, header, currentLoca header, lat: currentLocation.lat, lon: currentLocation.lon, - timestamp: Date.now(), + noisefloor: state.lastNoiseFloor ?? null, + timestamp: Math.floor(Date.now() / 1000), metadata: metadata // Store full metadata for debug mode - } + }, + timeoutId: null // Timer ID for 30-second timeout flush }; state.rxBatchBuffer.set(repeaterId, buffer); - debugLog(`[RX BATCH] First observation for repeater ${repeaterId}: SNR=${snr}`); + debugLog(`[RX BATCH] First observation for repeater ${repeaterId}: SNR=${snr}, noisefloor=${buffer.bestObservation.noisefloor}`); + + // Start 30-second timeout timer for this repeater + buffer.timeoutId = setTimeout(() => { + debugLog(`[RX BATCH] 30s timeout triggered for repeater ${repeaterId}`); + flushRepeater(repeaterId); + }, RX_BATCH_TIMEOUT_MS); + debugLog(`[RX BATCH] Started 30s timeout timer for repeater ${repeaterId}`); } else { // Already tracking this repeater - check if new SNR is better if (snr > buffer.bestObservation.snr) { @@ -2665,7 +3635,8 @@ function handleRxBatching(repeaterId, snr, rssi, pathLength, header, currentLoca header, lat: currentLocation.lat, lon: currentLocation.lon, - timestamp: Date.now(), + noisefloor: state.lastNoiseFloor ?? null, + timestamp: Math.floor(Date.now() / 1000), metadata: metadata // Store full metadata for debug mode }; } else { @@ -2678,7 +3649,7 @@ function handleRxBatching(repeaterId, snr, rssi, pathLength, header, currentLoca currentLocation.lat, currentLocation.lon, buffer.firstLocation.lat, - buffer.firstLocation.lng + buffer.firstLocation.lon ); debugLog(`[RX BATCH] Distance check for repeater ${repeaterId}: ${distance.toFixed(2)}m from first observation (threshold=${RX_BATCH_DISTANCE_M}m)`); @@ -2709,7 +3680,7 @@ function checkAllRxBatchesForDistanceTrigger(currentLocation) { currentLocation.lat, currentLocation.lon, buffer.firstLocation.lat, - buffer.firstLocation.lng + buffer.firstLocation.lon ); debugLog(`[RX BATCH] Distance check for repeater ${repeaterId}: ${distance.toFixed(2)}m from first observation (threshold=${RX_BATCH_DISTANCE_M}m)`); @@ -2743,16 +3714,24 @@ function flushRepeater(repeaterId) { return; } + // Clear timeout timer if it exists + if (buffer.timeoutId) { + clearTimeout(buffer.timeoutId); + buffer.timeoutId = null; + debugLog(`[RX BATCH] Cleared timeout timer for repeater ${repeaterId}`); + } + const best = buffer.bestObservation; // Build API entry using BEST observation's location const entry = { repeater_id: repeaterId, - location: { lat: best.lat, lng: best.lon }, // Location of BEST SNR packet + location: { lat: best.lat, lon: best.lon }, // Location of BEST SNR packet snr: best.snr, rssi: best.rssi, pathLength: best.pathLength, header: best.header, + noisefloor: best.noisefloor, // Noisefloor captured at observation time timestamp: best.timestamp, metadata: best.metadata // For debug mode }; @@ -2760,7 +3739,7 @@ function flushRepeater(repeaterId) { debugLog(`[RX BATCH] Posting repeater ${repeaterId}: snr=${best.snr}, location=${best.lat.toFixed(5)},${best.lon.toFixed(5)}`); // Queue for API posting - queueRxApiPost(entry); + queueRxEntry(entry); // Remove from buffer state.rxBatchBuffer.delete(repeaterId); @@ -2793,7 +3772,7 @@ function flushAllRxBatches(trigger = 'session_end') { * Uses the batch queue system to aggregate RX messages * @param {Object} entry - The entry to post (with best observation data) */ -function queueRxApiPost(entry) { +function queueRxEntry(entry) { // Validate session_id exists if (!state.wardriveSessionId) { debugWarn(`[RX BATCH API] Cannot queue: no session_id available`); @@ -2804,18 +3783,14 @@ function queueRxApiPost(entry) { // Use absolute value and format with one decimal place const heardRepeats = `${entry.repeater_id}(${Math.abs(entry.snr).toFixed(1)})`; - const payload = { - key: MESHMAPPER_API_KEY, + // Build entry-only payload (wrapper added by submitWardriveData) + const rxEntry = { + type: "RX", lat: entry.location.lat, - lon: entry.location.lng, - who: getDeviceIdentifier(), - power: getCurrentPowerSetting(), - external_antenna: getExternalAntennaSetting(), + lon: entry.location.lon, + noisefloor: entry.noisefloor ?? null, heard_repeats: heardRepeats, - ver: APP_VERSION, - test: 0, - iata: WARDIVE_IATA_CODE, - session_id: state.wardriveSessionId + timestamp: entry.timestamp }; // Add debug data if debug mode is enabled @@ -2827,14 +3802,14 @@ function queueRxApiPost(entry) { const heardByte = lastHopId.toString(16).padStart(2, '0').toUpperCase(); const debugData = buildDebugData(entry.metadata, heardByte, entry.repeater_id); - payload.debug_data = debugData; + rxEntry.debug_data = debugData; - debugLog(`[RX BATCH API] 🐛 RX payload includes debug_data for repeater ${entry.repeater_id}`); + debugLog(`[RX BATCH API] 🐛 RX entry includes debug_data for repeater ${entry.repeater_id}`); } - // Queue message instead of posting immediately - queueApiMessage(payload, "RX"); - debugLog(`[RX BATCH API] RX message queued: repeater=${entry.repeater_id}, snr=${entry.snr.toFixed(1)}, location=${entry.location.lat.toFixed(5)},${entry.location.lng.toFixed(5)}`); + // Queue entry for batch submission + queueWardriveEntry(rxEntry); + debugLog(`[RX BATCH API] RX entry queued: repeater=${entry.repeater_id}, snr=${entry.snr.toFixed(1)}, location=${entry.location.lat.toFixed(5)},${entry.location.lon.toFixed(5)}`); } // ---- Mobile Session Log Bottom Sheet ---- @@ -2978,7 +3953,7 @@ function updateTxLogSummary() { if (!txLogCount || !txLogLastTime || !txLogLastSnr) return; const count = txLogState.entries.length; - txLogCount.textContent = count === 1 ? '1 ping' : `${count} pings`; + txLogCount.textContent = `Pings: ${count}`; if (count === 0) { txLogLastTime.textContent = 'No data'; @@ -3167,9 +4142,7 @@ function updateRxLogSummary() { if (!rxLogCount || !rxLogLastTime || !rxLogLastRepeater) return; const count = rxLogState.entries.length; - const dropText = `${rxLogState.dropCount} dropped`; - const obsText = count === 1 ? '1 observation' : `${count} observations`; - rxLogCount.textContent = `${obsText}, ${dropText}`; + rxLogCount.textContent = `Handled: ${count} Drop: ${rxLogState.dropCount}`; if (count === 0) { rxLogLastTime.textContent = 'No data'; @@ -3392,7 +4365,7 @@ function updateErrorLogSummary() { const count = errorLogState.entries.length; if (count === 0) { - errorLogCount.textContent = '0 errors'; + errorLogCount.textContent = 'Events: 0'; errorLogLastTime.textContent = 'No errors'; errorLogLastTime.classList.add('hidden'); if (errorLogLastError) { @@ -3403,7 +4376,7 @@ function updateErrorLogSummary() { } const lastEntry = errorLogState.entries[errorLogState.entries.length - 1]; - errorLogCount.textContent = `${count} error${count !== 1 ? 's' : ''}`; + errorLogCount.textContent = `Events: ${count}`; const date = new Date(lastEntry.timestamp); errorLogLastTime.textContent = date.toLocaleTimeString(); @@ -3825,10 +4798,6 @@ function logTxPingToUI(payload, lat, lon) { // Use ISO format for data storage but user-friendly format for display const now = new Date(); const isoStr = now.toISOString(); - - if (lastPingEl) { - lastPingEl.textContent = `${now.toLocaleString()} — ${payload}`; - } // Create log entry with placeholder for repeater data const logData = { @@ -3907,12 +4876,21 @@ function updateCurrentTxLogEntryWithLiveRepeaters() { */ async function sendPing(manual = false) { debugLog(`[PING] sendPing called (manual=${manual})`); + + // Early guard: prevent concurrent ping execution (critical for preventing BLE GATT errors) + if (state.pingInProgress) { + debugLog("[PING] Ping already in progress, ignoring duplicate call"); + return; + } + state.pingInProgress = true; + try { // Check cooldown only for manual pings if (manual && isInCooldown()) { const remainingSec = getRemainingCooldownSeconds(); debugLog(`[PING] Manual ping blocked by cooldown (${remainingSec}s remaining)`); setDynamicStatus(`Wait ${remainingSec}s before sending another ping`, STATUS_COLORS.warning); + state.pingInProgress = false; return; } @@ -3930,6 +4908,25 @@ async function sendPing(manual = false) { // Manual ping when auto is not running setDynamicStatus("Sending manual ping", STATUS_COLORS.info); } + // Refresh radio stats (noise floor) before attempting ping so UI shows fresh value + if (state.connection && state.lastNoiseFloor !== null) { + // Only attempt refresh if firmware supports it (detected on connect) + debugLog("[PING] Refreshing radio stats before ping"); + try { + // Don't pass timeout - avoids library timeout bug + const stats = await state.connection.getRadioStats(null); + debugLog(`[PING] getRadioStats returned: ${JSON.stringify(stats)}`); + if (stats && typeof stats.noiseFloor !== 'undefined') { + state.lastNoiseFloor = stats.noiseFloor; + debugLog(`[PING] Radio stats refreshed before ping: noiseFloor=${state.lastNoiseFloor}`); + } else { + debugWarn(`[PING] Radio stats response missing noiseFloor field: ${JSON.stringify(stats)}`); + } + } catch (e) { + // Silently skip on error - firmware might not support it + debugLog(`[PING] getRadioStats skipped: ${e && e.message ? e.message : String(e)}`); + } + } // Get GPS coordinates const coords = await getGpsCoordinatesForPing(!manual && state.txRxAutoRunning); @@ -3943,34 +4940,14 @@ async function sendPing(manual = false) { if (manual) { handleManualPingBlockedDuringAutoMode(); } + state.pingInProgress = false; return; } const { lat, lon, accuracy } = coords; - // VALIDATION 1: Geofence check (FIRST - must be within Ottawa 150km) - debugLog("[PING] Starting geofence validation"); - if (!validateGeofence(lat, lon)) { - debugLog("[PING] Ping blocked: outside geofence"); - - // Set skip reason for auto mode countdown display - state.skipReason = "outside geofence"; - - if (manual) { - // Manual ping: show skip message that persists - setDynamicStatus("Ping skipped, outside of geofenced region", STATUS_COLORS.warning); - // If auto mode is running, resume the paused countdown - handleManualPingBlockedDuringAutoMode(); - } else if (state.txRxAutoRunning) { - // Auto ping: schedule next ping and show countdown with skip message - scheduleNextAutoPing(); - } - - return; - } - debugLog("[PING] Geofence validation passed"); - - // VALIDATION 2: Distance check (SECOND - must be ≥ 25m from last successful ping) + // VALIDATION: Distance check (must be ≥ 25m from last successful ping) + // Note: Zone validation happens server-side via /wardrive endpoint (Phase 4.4) debugLog("[PING] Starting distance validation"); if (!validateMinimumDistance(lat, lon)) { debugLog("[PING] Ping blocked: too close to last ping"); @@ -3988,6 +4965,7 @@ async function sendPing(manual = false) { scheduleNextAutoPing(); } + state.pingInProgress = false; return; } debugLog("[PING] Distance validation passed"); @@ -3995,8 +4973,7 @@ async function sendPing(manual = false) { // Both validations passed - execute ping operation (Mesh + API) debugLog("[PING] All validations passed, executing ping operation"); - // Lock ping controls for the entire ping lifecycle (until API post completes) - state.pingInProgress = true; + // pingInProgress already set at function start - just update UI controls updateControlsForCooldown(); debugLog("[PING] Ping controls locked (pingInProgress=true)"); @@ -4006,8 +4983,14 @@ async function sendPing(manual = false) { const ch = await ensureChannel(); // Capture GPS coordinates at ping time - these will be used for API post after 10s delay - state.capturedPingCoords = { lat, lon, accuracy }; - debugLog(`[PING] GPS coordinates captured at ping time: lat=${lat.toFixed(5)}, lon=${lon.toFixed(5)}, accuracy=${accuracy}m`); + state.capturedPingCoords = { + lat, + lon, + accuracy, + noisefloor: state.lastNoiseFloor ?? null, + timestamp: Math.floor(Date.now() / 1000) + }; + debugLog(`[PING] GPS coordinates captured at ping time: lat=${lat.toFixed(5)}, lon=${lon.toFixed(5)}, accuracy=${accuracy}m, noisefloor=${state.capturedPingCoords.noisefloor}, timestamp=${state.capturedPingCoords.timestamp}`); // Start repeater echo tracking BEFORE sending the ping debugLog(`[PING] Channel ping transmission: timestamp=${new Date().toISOString()}, channel=${ch.channelIdx}, payload="${payload}"`); @@ -4074,15 +5057,12 @@ async function sendPing(manual = false) { // This is the key change: we don't wait for API to complete if (state.connection) { if (state.txRxAutoRunning) { - // Check if we should resume a paused auto countdown (manual ping during auto mode) - const resumed = resumeAutoCountdown(); - if (!resumed) { - // No paused timer to resume, schedule new auto ping (this was an auto ping) - debugLog("[TX/RX AUTO] Scheduling next auto ping immediately after RX window"); - scheduleNextAutoPing(); - } else { - debugLog("[TX/RX AUTO] Resumed auto countdown after manual ping"); - } + // Always schedule a fresh auto ping from the full interval + // (whether this was a manual or auto ping, the timer restarts) + debugLog("[TX/RX AUTO] Scheduling next auto ping after ping completion"); + // Clear any paused timer state since we're restarting fresh + state.pausedAutoTimerRemainingMs = null; + scheduleNextAutoPing(); } else { debugLog("[UI] Setting dynamic status to Idle (manual mode)"); setDynamicStatus("Idle"); @@ -4092,31 +5072,17 @@ async function sendPing(manual = false) { // Unlock ping controls immediately (don't wait for API) unlockPingControls("after RX listening window completion"); - // Background the API posting (runs asynchronously, doesn't block) - // Use captured coordinates for API post (not current GPS position) + // Queue TX entry for batch submission (uses captured coordinates, not current GPS position) if (capturedCoords) { - const { lat: apiLat, lon: apiLon, accuracy: apiAccuracy } = capturedCoords; - debugLog(`[API QUEUE] Backgrounding API post for coordinates: lat=${apiLat.toFixed(5)}, lon=${apiLon.toFixed(5)}, accuracy=${apiAccuracy}m`); - - // Post to API in background and track the promise - const apiPromise = postApiInBackground(apiLat, apiLon, apiAccuracy, heardRepeatsStr).catch(error => { - debugError(`[API QUEUE] Background API post failed: ${error.message}`, error); - // Show error to user only if API fails - setDynamicStatus("Error: API post failed", STATUS_COLORS.error); - }).finally(() => { - // Remove from pending list when complete - const index = state.pendingApiPosts.indexOf(apiPromise); - if (index > -1) { - state.pendingApiPosts.splice(index, 1); - } - }); + const { lat: apiLat, lon: apiLon, accuracy: apiAccuracy, noisefloor: apiNoisefloor, timestamp: apiTimestamp } = capturedCoords; + debugLog(`[WARDRIVE QUEUE] Queueing TX entry: lat=${apiLat.toFixed(5)}, lon=${apiLon.toFixed(5)}, accuracy=${apiAccuracy}m, noisefloor=${apiNoisefloor}, timestamp=${apiTimestamp}`); - // Track this promise so disconnect can wait for it - state.pendingApiPosts.push(apiPromise); + // Queue TX entry (will be submitted with next batch) + queueTxEntry(apiLat, apiLon, apiAccuracy, heardRepeatsStr, apiNoisefloor, apiTimestamp); } else { // This should never happen as coordinates are always captured before ping - debugError(`[API QUEUE] CRITICAL: No captured ping coordinates available for API post - this indicates a logic error`); - debugError(`[API QUEUE] Skipping API post to avoid posting incorrect coordinates`); + debugError(`[WARDRIVE QUEUE] CRITICAL: No captured ping coordinates available for API post - this indicates a logic error`); + debugError(`[WARDRIVE QUEUE] Skipping TX entry queue to avoid posting incorrect coordinates`); } // Clear timer reference @@ -4172,6 +5138,11 @@ function stopAutoPing(stopGps = false) { updateAutoButton(); updateControlsForCooldown(); // Re-enable RX Auto button releaseWakeLock(); + + // Unlock wardrive settings after auto mode stops + unlockWardriveSettings(); + debugLog("[TX/RX AUTO] Wardrive settings unlocked after auto mode stop"); + debugLog("[TX/RX AUTO] TX/RX Auto stopped"); } @@ -4204,7 +5175,7 @@ function startRxAuto() { // Acquire wake lock debugLog("[RX AUTO] Acquiring wake lock"); - acquireWakeLock().catch(console.error); + acquireWakeLock().catch((e) => debugWarn("[RX AUTO] Wake lock failed (non-critical):", e?.message || String(e))); setDynamicStatus("RX Auto started", STATUS_COLORS.success); debugLog("[RX AUTO] RX Auto mode started successfully"); @@ -4244,6 +5215,13 @@ function scheduleNextAutoPing() { return; } + // Clear any existing timer to prevent accumulation (CRITICAL: prevents duplicate timers) + if (state.autoTimerId) { + debugLog(`[TX/RX AUTO] Clearing existing timer (id=${state.autoTimerId}) before scheduling new one`); + clearTimeout(state.autoTimerId); + state.autoTimerId = null; + } + const intervalMs = getSelectedIntervalMs(); debugLog(`[TX/RX AUTO] Scheduling next auto ping in ${intervalMs}ms`); @@ -4252,13 +5230,25 @@ function scheduleNextAutoPing() { // Schedule the next ping state.autoTimerId = setTimeout(() => { - if (state.txRxAutoRunning) { - // Clear skip reason before next attempt - state.skipReason = null; - debugLog("[TX/RX AUTO] Auto ping timer fired, sending ping"); - sendPing(false).catch(console.error); + debugLog(`[TX/RX AUTO] Auto ping timer fired (id=${state.autoTimerId})`); + + // Double-check guards before sending ping + if (!state.txRxAutoRunning) { + debugLog("[TX/RX AUTO] Auto mode no longer running, ignoring timer"); + return; } + if (state.pingInProgress) { + debugLog("[TX/RX AUTO] Ping already in progress, ignoring timer"); + return; + } + + // Clear skip reason before next attempt + state.skipReason = null; + debugLog("[TX/RX AUTO] Sending auto ping"); + sendPing(false).catch((e) => debugError("[TX/RX AUTO] Scheduled auto ping error:", e?.message || String(e))); }, intervalMs); + + debugLog(`[TX/RX AUTO] New timer scheduled (id=${state.autoTimerId})`); } function startAutoPing() { @@ -4294,6 +5284,10 @@ function startAutoPing() { startUnifiedRxListening(); } + // Lock wardrive settings during auto mode + lockWardriveSettings(); + debugLog("[TX/RX AUTO] Wardrive settings locked during auto mode"); + // ENABLE RX wardriving state.rxTracking.isWardriving = true; debugLog("[TX/RX AUTO] RX wardriving enabled"); @@ -4308,11 +5302,139 @@ function startAutoPing() { // Acquire wake lock for auto mode debugLog("[TX/RX AUTO] Acquiring wake lock for auto mode"); - acquireWakeLock().catch(console.error); + acquireWakeLock().catch((e) => debugWarn("[TX/RX AUTO] Wake lock failed (non-critical):", e?.message || String(e))); // Send first ping immediately debugLog("[TX/RX AUTO] Sending initial auto ping"); - sendPing(false).catch(console.error); + sendPing(false).catch((e) => debugError("[TX/RX AUTO] Initial auto ping error:", e?.message || String(e))); +} + +// ---- Device Auto-Power Configuration ---- + +/** + * Automatically configure radio power based on detected device model + * Called after deviceQuery() in connect() flow + * Updates power radio selection and label based on device database lookup + */ +async function autoSetPowerLevel() { + debugLog("[DEVICE MODEL] Starting auto-power configuration"); + + // Get power label status element for updating + const powerLabelStatus = document.getElementById("powerLabelStatus"); + + if (!state.deviceModel || state.deviceModel === "-") { + debugLog("[DEVICE MODEL] No device model available, skipping auto-power"); + return; + } + + // Parse device model (strip build suffix) + const cleanedModel = parseDeviceModel(state.deviceModel); + + // Look up device in database + const deviceConfig = findDeviceConfig(cleanedModel); + + if (deviceConfig) { + // Known device - auto-configure power + debugLog(`[DEVICE MODEL] Known device found: ${deviceConfig.shortName}`); + debugLog(`[DEVICE MODEL] Auto-configuring power to ${deviceConfig.power.toFixed(1)}w`); + + // Select the matching power radio button + // Format power value to match HTML format exactly (e.g., 1.0 → "1.0w", 0.3 → "0.3w") + // Use toFixed(1) to ensure one decimal place + const powerValue = `${deviceConfig.power.toFixed(1)}w`; + debugLog(`[DEVICE MODEL] Looking for power radio with value: ${powerValue}`); + const powerRadio = document.querySelector(`input[name="power"][value="${powerValue}"]`); + + if (powerRadio) { + powerRadio.checked = true; + state.autoPowerSet = true; + + // Show auto-configured power display, hide manual selection and placeholder + const powerPlaceholder = document.getElementById("powerPlaceholder"); + const powerAutoDisplay = document.getElementById("powerAutoDisplay"); + const powerManualSelection = document.getElementById("powerManualSelection"); + const powerAutoValue = document.getElementById("powerAutoValue"); + + if (powerPlaceholder) { + powerPlaceholder.style.display = "none"; + } + if (powerAutoDisplay) { + powerAutoDisplay.classList.remove("hidden"); + powerAutoDisplay.style.display = "flex"; + } + if (powerManualSelection) { + powerManualSelection.classList.add("hidden"); + powerManualSelection.style.display = "none"; + } + if (powerAutoValue) { + powerAutoValue.textContent = powerValue; + } + + // Update label to show "⚡ Auto" + if (powerLabelStatus) { + powerLabelStatus.textContent = "⚡ Auto"; + powerLabelStatus.className = "text-emerald-400"; + } + + // Show status message + setDynamicStatus(`Auto-configured: ${deviceConfig.shortName} at ${deviceConfig.power}w`, STATUS_COLORS.success); + + // Update controls to enable ping buttons now that power is selected + updateControlsForCooldown(); + + debugLog(`[DEVICE MODEL] ✅ Auto-power configuration complete: ${powerValue}`); + } else { + debugError(`[DEVICE MODEL] Power radio button not found for value: ${powerValue}`); + debugLog(`[DEVICE MODEL] Available power buttons:`); + document.querySelectorAll('input[name="power"]').forEach(radio => { + debugLog(`[DEVICE MODEL] - ${radio.value}`); + }); + state.autoPowerSet = false; + } + + // Update device model display to show short name + if (deviceModelEl) { + deviceModelEl.textContent = deviceConfig.shortName; + } + + } else { + // Unknown device - log to error log and require manual selection + debugLog(`[DEVICE MODEL] Unknown device: ${state.deviceModel}`); + addErrorLogEntry(`Unknown device: ${state.deviceModel}`, "DEVICE MODEL"); + state.autoPowerSet = false; + + // Hide auto-configured power display and placeholder, show manual selection + const powerPlaceholder = document.getElementById("powerPlaceholder"); + const powerAutoDisplay = document.getElementById("powerAutoDisplay"); + const powerManualSelection = document.getElementById("powerManualSelection"); + + if (powerPlaceholder) { + powerPlaceholder.style.display = "none"; + } + if (powerAutoDisplay) { + powerAutoDisplay.classList.add("hidden"); + powerAutoDisplay.style.display = "none"; + } + if (powerManualSelection) { + powerManualSelection.classList.remove("hidden"); + powerManualSelection.style.display = "flex"; + } + + // Update label to show "⚠️ Required" + if (powerLabelStatus) { + powerLabelStatus.textContent = "⚠️ Required"; + powerLabelStatus.className = "text-amber-400"; + } + + // Update device model display to show "Unknown" + if (deviceModelEl) { + deviceModelEl.textContent = "Unknown"; + } + + // Don't show status message here - will be shown after connection completes + + debugLog("[DEVICE MODEL] Auto-power skipped, user must select power manually"); + } } // ---- BLE connect / disconnect ---- @@ -4323,7 +5445,26 @@ async function connect() { alert("Web Bluetooth not supported in this browser."); return; } - connectBtn.disabled = true; + setConnectButtonDisabled(true); + + // CLEAR all logs immediately on connect (new session) + txLogState.entries = []; + renderTxLogEntries(true); + updateTxLogSummary(); + + rxLogState.entries = []; + rxLogState.dropCount = 0; + renderRxLogEntries(true); + updateRxLogSummary(); + + errorLogState.entries = []; + renderErrorLogEntries(true); + updateErrorLogSummary(); + + debugLog("[BLE] All logs cleared on connect start (new session)"); + + // Clear any previous disconnect reason so error status doesn't persist + state.disconnectReason = null; // Set connection bar to "Connecting" - will remain until GPS init completes setConnStatus("Connecting", STATUS_COLORS.info); @@ -4341,7 +5482,7 @@ async function connect() { // Keep "Connecting" status visible during the full connection process // Don't show "Connected" until everything is complete setConnectButton(true); - connectBtn.disabled = false; + setConnectButtonDisabled(false); const selfInfo = await conn.getSelfInfo(); debugLog(`[BLE] Device info: ${selfInfo?.name || "[No device]"}`); @@ -4361,7 +5502,58 @@ async function connect() { state.devicePublicKey = BufferUtils.bytesToHex(selfInfo.publicKey); debugLog(`[BLE] Device public key stored: ${state.devicePublicKey.substring(0, 16)}...`); - deviceInfoEl.textContent = selfInfo?.name || "[No device]"; + // Store device name from selfInfo + state.deviceName = selfInfo?.name || "[No device]"; + debugLog(`[BLE] Device name stored: ${state.deviceName}`); + + // Get device model from deviceQuery (contains manufacturerModel) + debugLog("[BLE] Requesting device info via deviceQuery"); + try { + const deviceInfo = await conn.deviceQuery(1); + debugLog(`[BLE] deviceQuery response received: firmwareVer=${deviceInfo?.firmwareVer}, model=${deviceInfo?.manufacturerModel}`); + state.deviceModel = deviceInfo?.manufacturerModel || "-"; + debugLog(`[BLE] Device model stored: ${state.deviceModel}`); + // Don't update deviceModelEl here - autoSetPowerLevel() will set it to shortName or "Unknown" + } catch (e) { + debugError(`[BLE] deviceQuery failed: ${e && e.message ? e.message : e}`); + state.deviceModel = "-"; + if (deviceModelEl) deviceModelEl.textContent = "-"; + } + + // Auto-configure radio power based on device model + await autoSetPowerLevel(); + + // Immediately attempt to read radio stats (noise floor) on connect + debugLog("[BLE] Requesting radio stats on connect"); + try { + // Don't pass timeout - avoids library timeout bug + const stats = await conn.getRadioStats(null); + debugLog(`[BLE] getRadioStats returned: ${JSON.stringify(stats)}`); + if (stats && typeof stats.noiseFloor !== 'undefined') { + state.lastNoiseFloor = stats.noiseFloor; + debugLog(`[BLE] Radio stats acquired on connect: noiseFloor=${state.lastNoiseFloor}`); + } else { + debugWarn(`[BLE] Radio stats response missing noiseFloor field: ${JSON.stringify(stats)}`); + state.lastNoiseFloor = null; + } + } catch (e) { + // Timeout likely means firmware doesn't support GetStats command yet + if (e && e.message && e.message.includes('timeout')) { + debugLog(`[BLE] getRadioStats not supported by companion firmware (timeout)`); + } else { + debugWarn(`[BLE] getRadioStats failed on connect: ${e && e.message ? e.message : String(e)}`); + } + state.lastNoiseFloor = null; // Show '--' instead of 'ERR' for unsupported feature + } + + // Start periodic noise floor updates if feature is supported + if (state.lastNoiseFloor !== null) { + startNoiseFloorUpdates(); + debugLog("[BLE] Started periodic noise floor updates (5s interval)"); + } else { + debugLog("[BLE] Noise floor updates not started (feature unsupported by firmware)"); + } + updateAutoButton(); try { await conn.syncDeviceTime?.(); @@ -4370,22 +5562,29 @@ async function connect() { debugLog("[BLE] Device time sync not available or failed"); } try { - // Check capacity immediately after time sync, before channel setup and GPS init - const allowed = await checkCapacity("connect"); + // Request auth immediately after time sync, before channel setup and GPS init + // Note: requestAuth acquires fresh GPS internally + const allowed = await requestAuth("connect"); if (!allowed) { - debugWarn("[CAPACITY] Capacity check denied, disconnecting"); - // disconnectReason already set by checkCapacity() + debugWarn("[AUTH] Auth request denied, disconnecting"); + // disconnectReason already set by requestAuth() // Status message will be set by disconnected event handler based on disconnectReason // Disconnect after a brief delay to ensure "Acquiring wardriving slot" is visible setTimeout(() => { - disconnect().catch(err => debugError(`[BLE] Disconnect after capacity denial failed: ${err.message}`)); + disconnect().catch(err => debugError(`[BLE] Disconnect after auth denial failed: ${err.message}`)); }, 1500); return; } - // Capacity check passed - setDynamicStatus("Acquired wardriving slot", STATUS_COLORS.success); - debugLog("[BLE] Wardriving slot acquired successfully"); + // Auth passed - check if full access or RX-only + if (state.txAllowed && state.rxAllowed) { + setDynamicStatus("Acquired wardriving slot", STATUS_COLORS.success); + debugLog("[AUTH] Full access granted (TX + RX)"); + } else if (state.rxAllowed) { + setDynamicStatus("TX slots full - RX only", STATUS_COLORS.warning); + debugLog("[AUTH] RX-only access granted (TX slots full)"); + } + debugLog(`[AUTH] Session acquired: tx=${state.txAllowed}, rx=${state.rxAllowed}`); // Proceed with channel setup and GPS initialization await ensureChannel(); @@ -4394,33 +5593,41 @@ async function connect() { startUnifiedRxListening(); debugLog("[BLE] Unified RX listener started on connect"); - // CLEAR all logs on connect (new session) - txLogState.entries = []; - renderTxLogEntries(true); - updateTxLogSummary(); - - rxLogState.entries = []; - rxLogState.dropCount = 0; - renderRxLogEntries(true); - updateRxLogSummary(); - - errorLogState.entries = []; - renderErrorLogEntries(true); - updateErrorLogSummary(); + // GPS initialization (primeGpsOnce for watch mode) + // Note: Fresh GPS was already acquired by requestAuth, this starts continuous watching + debugLog("[BLE] Starting GPS watch mode"); + await primeGpsOnce(); - debugLog("[BLE] All logs cleared on connect (new session)"); + // Connection complete - show status based on TX+RX vs RX-only + if (state.txAllowed && state.rxAllowed) { + setConnStatus("Connected", STATUS_COLORS.success); + } else if (state.rxAllowed) { + setConnStatus("Connected (RX Only)", STATUS_COLORS.warning); + } - // GPS initialization - setDynamicStatus("Priming GPS", STATUS_COLORS.info); - debugLog("[BLE] Starting GPS initialization"); - await primeGpsOnce(); + // If device is unknown and power not selected, show warning message + if (!state.autoPowerSet && !getCurrentPowerSetting()) { + setDynamicStatus("Unknown device - select power manually", STATUS_COLORS.warning, true); + debugLog("[BLE] Connection complete - showing unknown device warning"); + } else { + setDynamicStatus("Idle"); // Clear dynamic status to em dash + } - // Connection complete, show Connected status in connection bar - setConnStatus("Connected", STATUS_COLORS.success); - setDynamicStatus("Idle"); // Clear dynamic status to em dash + // Note: Settings are NOT locked on connect - only when auto mode starts + // This allows users to change power after connection if device was unknown - // Lock wardrive settings after successful connection - lockWardriveSettings(); + // Immediate zone check after connect to update slot display + debugLog("[GEO AUTH] Performing zone check after successful connect"); + const coords = await getValidGpsForZoneCheck(); + if (coords) { + const result = await checkZoneStatus(coords); + if (result.success && result.zone) { + state.currentZone = result.zone; + state.lastZoneCheckCoords = { lat: coords.lat, lon: coords.lon }; + updateZoneStatusUI(result); + debugLog(`[GEO AUTH] Post-connect zone check: ${result.zone.name}, slots: ${result.zone.slots_available}/${result.zone.slots_max}`); + } + } debugLog("[BLE] Full connection process completed successfully"); } catch (e) { @@ -4434,6 +5641,12 @@ async function connect() { debugLog("[BLE] BLE disconnected event fired"); debugLog(`[BLE] Disconnect reason: ${state.disconnectReason}`); + // Guard against duplicate disconnect events - if connection is already null, skip + if (state.connection === null) { + debugLog("[BLE] Ignoring duplicate disconnect event (connection already null)"); + return; + } + // Always set connection bar to "Disconnected" setConnStatus("Disconnected", STATUS_COLORS.error); @@ -4442,37 +5655,70 @@ async function connect() { if (state.disconnectReason && REASON_MESSAGES[state.disconnectReason]) { debugLog(`[BLE] Branch: known reason code (${state.disconnectReason})`); const errorMsg = REASON_MESSAGES[state.disconnectReason]; + addErrorLogEntry(`Disconnected: ${errorMsg} (reason: ${state.disconnectReason})`, "CONNECTION"); setDynamicStatus(errorMsg, STATUS_COLORS.error, true); debugLog(`[BLE] Setting terminal status for reason: ${state.disconnectReason}`); } else if (state.disconnectReason === "capacity_full") { debugLog("[BLE] Branch: capacity_full"); + addErrorLogEntry("Disconnected: MeshMapper server at capacity - too many active connections", "CONNECTION"); setDynamicStatus("MeshMapper at capacity", STATUS_COLORS.error, true); debugLog("[BLE] Setting terminal status for capacity full"); } else if (state.disconnectReason === "app_down") { debugLog("[BLE] Branch: app_down"); + addErrorLogEntry("Disconnected: MeshMapper server unavailable - check service status", "CONNECTION"); setDynamicStatus("MeshMapper unavailable", STATUS_COLORS.error, true); debugLog("[BLE] Setting terminal status for app down"); } else if (state.disconnectReason === "slot_revoked") { debugLog("[BLE] Branch: slot_revoked"); + addErrorLogEntry("Disconnected: Wardriving slot revoked by server - exceeded limits or policy violation", "CONNECTION"); setDynamicStatus("MeshMapper slot revoked", STATUS_COLORS.error, true); debugLog("[BLE] Setting terminal status for slot revocation"); } else if (state.disconnectReason === "session_id_error") { debugLog("[BLE] Branch: session_id_error"); + addErrorLogEntry("Disconnected: Session ID error - failed to establish valid wardrive session", "CONNECTION"); setDynamicStatus("Session error - reconnect", STATUS_COLORS.error, true); debugLog("[BLE] Setting terminal status for session_id error"); } else if (state.disconnectReason === "public_key_error") { debugLog("[BLE] Branch: public_key_error"); + addErrorLogEntry("Disconnected: Device public key error - invalid or missing key from companion", "CONNECTION"); setDynamicStatus("Device key error - reconnect", STATUS_COLORS.error, true); debugLog("[BLE] Setting terminal status for public key error"); + } else if (state.disconnectReason === "zone_disabled") { + debugLog("[GEO AUTH] Branch: zone_disabled"); + addErrorLogEntry("Disconnected: Zone disabled - wardriving not allowed in this area", "CONNECTION"); + setDynamicStatus("Zone disabled", STATUS_COLORS.error, true); + debugLog("[GEO AUTH] Setting terminal status for zone disabled"); + } else if (state.disconnectReason === "outside_zone") { + debugLog("[GEO AUTH] Branch: outside_zone"); + addErrorLogEntry("Disconnected: Outside zone - moved outside wardriving zone boundary", "CONNECTION"); + setDynamicStatus("Outside zone", STATUS_COLORS.error, true); + debugLog("[GEO AUTH] Setting terminal status for outside zone"); + } else if (state.disconnectReason === "at_capacity") { + debugLog("[GEO AUTH] Branch: at_capacity"); + addErrorLogEntry("Disconnected: Zone at capacity - too many active wardrivers in this zone", "CONNECTION"); + setDynamicStatus("Zone at capacity", STATUS_COLORS.error, true); + debugLog("[GEO AUTH] Setting terminal status for zone at capacity"); + } else if (state.disconnectReason === "gps_unavailable") { + debugLog("[GEO AUTH] Branch: gps_unavailable"); + addErrorLogEntry("Disconnected: GPS unavailable - could not acquire valid GPS coordinates for zone check", "CONNECTION"); + setDynamicStatus("GPS unavailable", STATUS_COLORS.error, true); + debugLog("[GEO AUTH] Setting terminal status for GPS unavailable"); + } else if (state.disconnectReason === "zone_check_failed") { + debugLog("[GEO AUTH] Branch: zone_check_failed"); + addErrorLogEntry("Disconnected: Zone check failed - unable to verify wardriving zone", "CONNECTION"); + setDynamicStatus("Zone check failed", STATUS_COLORS.error, true); + debugLog("[GEO AUTH] Setting terminal status for zone check failed"); } else if (state.disconnectReason === "channel_setup_error") { debugLog("[BLE] Branch: channel_setup_error"); const errorMsg = state.channelSetupErrorMessage || "Channel setup failed"; + addErrorLogEntry(`Disconnected: #wardriving channel setup failed - ${errorMsg}`, "CONNECTION"); setDynamicStatus(errorMsg, STATUS_COLORS.error, true); debugLog("[BLE] Setting terminal status for channel setup error"); state.channelSetupErrorMessage = null; // Clear after use (also cleared in cleanup as safety net) } else if (state.disconnectReason === "ble_disconnect_error") { debugLog("[BLE] Branch: ble_disconnect_error"); const errorMsg = state.bleDisconnectErrorMessage || "BLE disconnect failed"; + addErrorLogEntry(`Disconnected: BLE connection error - ${errorMsg}`, "CONNECTION"); setDynamicStatus(errorMsg, STATUS_COLORS.error, true); debugLog("[BLE] Setting terminal status for BLE disconnect error"); state.bleDisconnectErrorMessage = null; // Clear after use (also cleared in cleanup as safety net) @@ -4483,20 +5729,63 @@ async function connect() { debugLog(`[BLE] Branch: else (unknown reason: ${state.disconnectReason})`); // For unknown disconnect reasons from API, show a generic message debugLog(`[BLE] Showing generic error for unknown reason: ${state.disconnectReason}`); - setDynamicStatus(`Connection not allowed: ${state.disconnectReason}`, STATUS_COLORS.error, true); + const errorMsg = `Connection not allowed: ${state.disconnectReason}`; + addErrorLogEntry(`Disconnected: Connection not allowed by server (reason: ${state.disconnectReason})`, "CONNECTION"); + setDynamicStatus(errorMsg, STATUS_COLORS.error, true); } setConnectButton(false); - deviceInfoEl.textContent = "—"; + if (deviceModelEl) deviceModelEl.textContent = "-"; + + // Stop periodic noise floor updates + stopNoiseFloorUpdates(); + + debugLog("[BLE] Clearing device model and noise floor on disconnect"); + state.deviceModel = null; + state.deviceName = null; + state.lastNoiseFloor = null; state.connection = null; state.channel = null; state.devicePublicKey = null; // Clear public key state.wardriveSessionId = null; // Clear wardrive session ID + state.txAllowed = false; // Clear TX permission + state.rxAllowed = false; // Clear RX permission + state.sessionExpiresAt = null; // Clear session expiration state.debugMode = false; // Clear debug mode state.tempTxRepeaterData = null; // Clear temp TX data - state.disconnectReason = null; // Reset disconnect reason + // NOTE: state.disconnectReason is NOT cleared here - it's cleared in connect() + // so error status persists until user starts a new connection state.channelSetupErrorMessage = null; // Clear error message state.bleDisconnectErrorMessage = null; // Clear error message + state.autoPowerSet = false; // Reset auto-power flag + + // Show placeholder, hide both power displays and clear label + const powerPlaceholder = document.getElementById("powerPlaceholder"); + const powerAutoDisplay = document.getElementById("powerAutoDisplay"); + const powerManualSelection = document.getElementById("powerManualSelection"); + const powerLabelStatus = document.getElementById("powerLabelStatus"); + + if (powerPlaceholder) { + powerPlaceholder.style.display = "flex"; + } + if (powerAutoDisplay) { + powerAutoDisplay.classList.add("hidden"); + powerAutoDisplay.style.display = "none"; + } + if (powerManualSelection) { + powerManualSelection.classList.add("hidden"); + powerManualSelection.style.display = "none"; + } + if (powerLabelStatus) { + powerLabelStatus.textContent = ""; + powerLabelStatus.className = ""; + } + + // Uncheck all power radio buttons + const powerInputs = document.querySelectorAll('input[name="power"]'); + powerInputs.forEach(input => { + input.checked = false; + }); // Unlock wardrive settings after disconnect unlockWardriveSettings(); @@ -4518,19 +5807,13 @@ async function connect() { // Flush all pending RX batch data before cleanup flushAllRxBatches('disconnect'); - // Clear API queue messages (timers already stopped in cleanupAllTimers) - apiQueue.messages = []; - debugLog(`[API QUEUE] Queue cleared on disconnect`); + // Clear wardrive queue messages (timers already stopped in cleanupAllTimers) + wardriveQueue.messages = []; + debugLog(`[WARDRIVE QUEUE] Queue cleared on disconnect`); // Clean up all timers cleanupAllTimers(); - // Clear RX log entries on disconnect - rxLogState.entries = []; - renderRxLogEntries(true); // Full render to show placeholder - updateRxLogSummary(); - debugLog("[BLE] RX log cleared on disconnect"); - state.lastFix = null; state.lastSuccessfulPingLocation = null; state.gpsState = "idle"; @@ -4543,7 +5826,10 @@ async function connect() { debugError(`[BLE] BLE connection failed: ${e.message}`, e); setConnStatus("Disconnected", STATUS_COLORS.error); setDynamicStatus("Connection failed", STATUS_COLORS.error); - connectBtn.disabled = false; + updateConnectButtonState(); // Re-check zone and antenna requirements + + // Restart slot refresh timer since connection failed + startSlotRefreshTimer(); } } async function disconnect() { @@ -4553,7 +5839,7 @@ async function disconnect() { return; } - connectBtn.disabled = true; + setConnectButtonDisabled(true); // Set disconnectReason to "normal" if not already set (for user-initiated disconnects) if (state.disconnectReason === null || state.disconnectReason === undefined) { @@ -4572,21 +5858,21 @@ async function disconnect() { debugLog(`[BLE] All pending background API posts completed`); } - // 2. Flush API queue (session_id still valid) - if (apiQueue.messages.length > 0) { - debugLog(`[BLE] Flushing ${apiQueue.messages.length} queued messages before disconnect`); - await flushApiQueue(); + // 2. Flush wardrive queue (session_id still valid) + if (wardriveQueue.messages.length > 0) { + debugLog(`[BLE] Flushing ${wardriveQueue.messages.length} queued messages before disconnect`); + await submitWardriveData(); } - stopFlushTimers(); + stopWardriveTimers(); - // 3. THEN release capacity slot if we have a public key - if (state.devicePublicKey) { + // 3. THEN release session via auth API if we have a public key + if (state.devicePublicKey && state.wardriveSessionId) { try { - debugLog("[BLE] Releasing capacity slot"); - await checkCapacity("disconnect"); + debugLog("[AUTH] Releasing session via /auth disconnect"); + await requestAuth("disconnect"); } catch (e) { - debugWarn(`[CAPACITY] Failed to release capacity slot: ${e.message}`); - // Don't fail disconnect if capacity release fails + debugWarn(`[AUTH] Failed to release session: ${e.message}`); + // Don't fail disconnect if auth release fails } } @@ -4617,12 +5903,34 @@ async function disconnect() { } else { debugWarn("[BLE] No known disconnect method on connection object"); } + + // Restart 30s slot refresh timer + startSlotRefreshTimer(); + + // Immediate zone check after disconnect to update slot display + debugLog("[GEO AUTH] Performing zone check after disconnect"); + const coords = await getValidGpsForZoneCheck(); + if (coords) { + const result = await checkZoneStatus(coords); + if (result.success && result.zone) { + state.currentZone = result.zone; + state.lastZoneCheckCoords = { lat: coords.lat, lon: coords.lon }; + updateZoneStatusUI(result); + debugLog(`[GEO AUTH] Post-disconnect zone check: ${result.zone.name}, slots: ${result.zone.slots_available}/${result.zone.slots_max}`); + } else if (result.success && !result.in_zone) { + state.currentZone = null; + state.lastZoneCheckCoords = { lat: coords.lat, lon: coords.lon }; + updateZoneStatusUI(result); + debugLog(`[GEO AUTH] Post-disconnect zone check: outside zone, nearest: ${result.nearest_zone?.name}`); + } + } + } catch (e) { debugError(`[BLE] BLE disconnect failed: ${e.message}`, e); state.disconnectReason = "ble_disconnect_error"; // Mark specific disconnect reason state.bleDisconnectErrorMessage = e.message || "Disconnect failed"; // Store error message } finally { - connectBtn.disabled = false; + updateConnectButtonState(); // Re-check zone and antenna requirements } } @@ -4668,30 +5976,42 @@ document.addEventListener("visibilitychange", async () => { }); /** - * Update Connect button state based on radio power and external antenna selection + * Update Connect button state based on external antenna selection AND zone status + * Connect requires: external antenna selected AND in valid zone (no persistent error) */ function updateConnectButtonState() { - const radioPowerSelected = getCurrentPowerSetting() !== ""; const externalAntennaSelected = getExternalAntennaSetting() !== ""; const isConnected = !!state.connection; + const hasZoneError = !!statusMessageState.outsideZoneError; + const inValidZone = !!state.currentZone; if (!isConnected) { - // Only enable Connect if both settings are selected - connectBtn.disabled = !radioPowerSelected || !externalAntennaSelected; - - // Update dynamic status based on selections - if (!radioPowerSelected && !externalAntennaSelected) { - debugLog("[UI] Radio power and external antenna not selected - showing message in status bar"); - setDynamicStatus("Select radio power and external antenna to connect", STATUS_COLORS.warning); - } else if (!radioPowerSelected) { - debugLog("[UI] Radio power not selected - showing message in status bar"); - setDynamicStatus("Select radio power to connect", STATUS_COLORS.warning); + // Enable Connect only if: antenna selected AND in valid zone AND no persistent error + const canConnect = externalAntennaSelected && inValidZone && !hasZoneError; + setConnectButtonDisabled(!canConnect); + + // Update dynamic status based on what's blocking connection + // Priority: zone error > antenna not selected > ready + if (hasZoneError) { + // Zone error already shown as persistent message, don't override + debugLog("[UI] Connect blocked by zone error (persistent message already shown)"); } else if (!externalAntennaSelected) { debugLog("[UI] External antenna not selected - showing message in status bar"); setDynamicStatus("Select external antenna to connect", STATUS_COLORS.warning); + } else if (!inValidZone) { + debugLog("[UI] Not in valid zone - waiting for zone check"); + // Don't show message here, zone check will update status } else { - debugLog("[UI] Radio power and external antenna selected - clearing message from status bar"); - setDynamicStatus("Idle"); + debugLog("[UI] External antenna selected and in valid zone - ready to connect"); + // Only set Idle if not showing a disconnect error + const isErrorDisconnect = state.disconnectReason && + state.disconnectReason !== "normal" && + state.disconnectReason !== null; + if (!isErrorDisconnect) { + setDynamicStatus("Idle", STATUS_COLORS.idle); + } else { + debugLog(`[UI] Preserving disconnect error status (reason: ${state.disconnectReason})`); + } } } } @@ -4713,6 +6033,14 @@ function lockWardriveSettings() { } }); + // Lock Override button + const powerOverrideBtn = document.getElementById("powerOverrideBtn"); + if (powerOverrideBtn) { + powerOverrideBtn.disabled = true; + powerOverrideBtn.classList.add("cursor-not-allowed", "pointer-events-none"); + powerOverrideBtn.style.opacity = "0.5"; + } + // Lock all external antenna inputs and labels const antennaInputs = document.querySelectorAll('input[name="externalAntenna"]'); antennaInputs.forEach(input => { @@ -4742,6 +6070,14 @@ function unlockWardriveSettings() { } }); + // Unlock Override button + const powerOverrideBtn = document.getElementById("powerOverrideBtn"); + if (powerOverrideBtn) { + powerOverrideBtn.disabled = false; + powerOverrideBtn.classList.remove("cursor-not-allowed", "pointer-events-none"); + powerOverrideBtn.style.opacity = ""; + } + // Unlock all external antenna inputs and labels const antennaInputs = document.querySelectorAll('input[name="externalAntenna"]'); antennaInputs.forEach(input => { @@ -4757,10 +6093,32 @@ function unlockWardriveSettings() { // ---- Bind UI & init ---- export async function onLoad() { debugLog("[INIT] wardrive.js onLoad() called - initializing"); + + // Initialize double-buffered iframe references (in case module loaded before DOM) + if (!coverageFrameA) coverageFrameA = document.getElementById("coverageFrameA"); + if (!coverageFrameB) coverageFrameB = document.getElementById("coverageFrameB"); + if (coverageFrameA && !activeFrame) activeFrame = coverageFrameA; + debugLog(`[INIT] Coverage iframes: A=${!!coverageFrameA}, B=${!!coverageFrameB}, active=${activeFrame?.id || 'none'}`); + setConnStatus("Disconnected", STATUS_COLORS.error); enableControls(false); updateAutoButton(); + // Load device models database + try { + debugLog("[INIT] Loading device models database from device-models.json"); + const response = await fetch('content/device-models.json'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const data = await response.json(); + DEVICE_MODELS = data.devices || []; + debugLog(`[INIT] ✅ Loaded ${DEVICE_MODELS.length} device models from database`); + } catch (e) { + debugError(`[INIT] Failed to load device-models.json: ${e.message}`); + DEVICE_MODELS = []; // Ensure it's an empty array on failure + } + // Disable RX Auto button (backend API not ready) rxAutoBtn.disabled = true; rxAutoBtn.title = "RX Auto temporarily disabled - backend API not ready"; @@ -4783,7 +6141,7 @@ export async function onLoad() { }); txPingBtn.addEventListener("click", () => { debugLog("[UI] Manual ping button clicked"); - sendPing(true).catch(console.error); + sendPing(true).catch((e) => debugError("[PING] Manual ping error:", e?.message || String(e))); }); txRxAutoBtn.addEventListener("click", () => { debugLog("[UI] Auto toggle button clicked"); @@ -4840,11 +6198,122 @@ export async function onLoad() { }); } - // Add event listeners to radio power options to update Connect button state + // Add event listeners to radio power options const powerRadios = document.querySelectorAll('input[name="power"]'); + let previousPowerValue = null; // Track previous selection for revert on cancel + + // Add event listener to Override button + const powerOverrideBtn = document.getElementById("powerOverrideBtn"); + if (powerOverrideBtn) { + powerOverrideBtn.addEventListener("click", () => { + debugLog("[UI] Override button clicked"); + + // Show custom confirmation modal + const modal = document.getElementById("overrideModal"); + debugLog(`[UI] Modal element lookup result: ${modal ? 'found' : 'NOT FOUND'}`); + if (modal) { + debugLog(`[UI] Modal current display: ${modal.style.display}, classes: ${modal.className}`); + modal.classList.remove("hidden"); + modal.style.display = "flex"; + debugLog(`[UI] Modal after show - display: ${modal.style.display}, classes: ${modal.className}`); + } else { + debugError("[UI] overrideModal element not found in DOM!"); + } + }); + } else { + debugError("[UI] powerOverrideBtn element not found!"); + } + + // Add event listeners to modal buttons + const overrideModalConfirm = document.getElementById("overrideModalConfirm"); + const overrideModalCancel = document.getElementById("overrideModalCancel"); + const overrideModal = document.getElementById("overrideModal"); + + if (overrideModalConfirm && overrideModal) { + overrideModalConfirm.addEventListener("click", () => { + debugLog("[UI] Override confirmed via custom modal"); + + // Hide modal + overrideModal.classList.add("hidden"); + + // Hide auto display, show manual selection + const powerAutoDisplay = document.getElementById("powerAutoDisplay"); + const powerManualSelection = document.getElementById("powerManualSelection"); + const powerLabelStatus = document.getElementById("powerLabelStatus"); + + if (powerAutoDisplay) { + powerAutoDisplay.classList.add("hidden"); + powerAutoDisplay.style.display = "none"; + } + if (powerManualSelection) { + powerManualSelection.classList.remove("hidden"); + powerManualSelection.style.display = "flex"; + } + + // Update state and label + state.autoPowerSet = false; + if (powerLabelStatus) { + powerLabelStatus.textContent = "⚙️ Manual"; + powerLabelStatus.className = "text-slate-400"; + } + + debugLog("[UI] Power override confirmed, switched to manual selection"); + }); + } + + if (overrideModalCancel && overrideModal) { + overrideModalCancel.addEventListener("click", () => { + debugLog("[UI] Override canceled via custom modal"); + overrideModal.classList.add("hidden"); + overrideModal.style.display = "none"; + }); + } + + // Close modal when clicking backdrop + if (overrideModal) { + overrideModal.addEventListener("click", (e) => { + if (e.target === overrideModal) { + debugLog("[UI] Override modal closed via backdrop click"); + overrideModal.classList.add("hidden"); + overrideModal.style.display = "none"; + } + }); + } + powerRadios.forEach(radio => { - radio.addEventListener("change", () => { - debugLog(`[UI] Radio power changed to: ${getCurrentPowerSetting()}`); + radio.addEventListener("change", (e) => { + const newValue = getCurrentPowerSetting(); + debugLog(`[UI] Radio power changed to: ${newValue}`); + + const powerLabelStatus = document.getElementById("powerLabelStatus"); + + // If user is selecting after Override button was clicked (autoPowerSet is false after override) + // Update label to show "⚙️ Manual" + if (!state.autoPowerSet && state.connection) { + if (powerLabelStatus) { + // Check if this was an unknown device (label is "⚠️ Required") + const wasUnknown = powerLabelStatus.textContent === "⚠️ Required"; + + if (wasUnknown) { + // Clear the "Required" warning for unknown device + powerLabelStatus.textContent = "⚙️ Manual"; + powerLabelStatus.className = "text-slate-400"; + setDynamicStatus("Idle"); + debugLog("[UI] Cleared unknown device status after manual power selection"); + } else { + // This was an override, show Manual indicator + powerLabelStatus.textContent = "⚙️ Manual"; + powerLabelStatus.className = "text-slate-400"; + debugLog("[UI] Manual power selection after override"); + } + } + } + + // Store current value as previous for next change + previousPowerValue = newValue; + + // Update controls to enable/disable ping buttons based on power selection + updateControlsForCooldown(); updateConnectButtonState(); }); }); @@ -4915,5 +6384,9 @@ export async function onLoad() { } catch (e) { debugLog(`[GPS] Initial location permission not granted: ${e.message}`); } + + // Perform app launch zone check + await performAppLaunchZoneCheck(); + debugLog("[INIT] wardrive.js initialization complete"); } diff --git a/content/wardrive.png b/content/wardrive.png index 9015f85..cd81ec3 100644 Binary files a/content/wardrive.png and b/content/wardrive.png differ diff --git a/docs/CONNECTION_WORKFLOW.md b/docs/CONNECTION_WORKFLOW.md index 875674b..708a557 100644 --- a/docs/CONNECTION_WORKFLOW.md +++ b/docs/CONNECTION_WORKFLOW.md @@ -66,6 +66,8 @@ 10. **GPS Init** → Starts GPS tracking 11. **Connected** → Enables all controls, ready for wardriving +> **Note:** Zone validation happens server-side via `/auth` endpoint (Phase 4.2). The preflight zone check (Phase 4.1) only updates UI before connect. + ### Detailed Connection Steps See `content/wardrive.js` lines 2020-2150 for the main `connect()` function. @@ -113,15 +115,37 @@ connectBtn.addEventListener("click", async () => { - **Connection Status**: `"Connecting"` (blue, maintained) - **Dynamic Status**: `"—"` (em dash) -5. **Get Device Info** +5. **Get Device Info & Auto-Power Configuration** - Retrieves device name, public key (32 bytes), settings - **CRITICAL**: Validates public key length - Converts to hex string - Stores in `state.devicePublicKey` - Updates UI with device name + - **NEW: Device Model Query & Auto-Power**: + - Calls `deviceQuery(1)` to get manufacturer model string + - Example: `"Ikoka Stick-E22-30dBm (Xiao_nrf52)nightly-e31c46f"` + - Stores full string in `state.deviceModel` + - Calls `autoSetPowerLevel()`: + - Parses model (strips build suffix like "nightly-e31c46f") + - Looks up in `DEVICE_MODELS` database (loaded from `device-models.json` at startup) + - **If known device found**: + - Automatically selects matching power radio button (0.3w/0.6w/1.0w/2.0w) + - Updates power label: "Radio Power ⚡ Auto" (emerald text) + - Displays shortName in connection bar (e.g., "Ikoka Stick 1w") + - Shows status: "Auto-configured: [shortName] at X.Xw" (green, 500ms min) + - Enables ping controls (calls `updateControlsForCooldown()`) + - Sets `state.autoPowerSet = true` + - **If unknown device**: + - Leaves power unselected + - Displays "Unknown" in connection bar + - Logs error: `"[DEVICE MODEL] Unknown device: [full model string]"` + - Shows status: "Unknown device - select power manually" (yellow, persistent until power selected) + - Ping controls remain disabled + - Sets `state.autoPowerSet = false` + - Requests radio statistics from the companion (noise floor, last RSSI, SNR) and stores the result in application state. The connection bar displays `Noise: `. If stats fetch fails, an error marker is shown (`Noise: ERR`) but connection proceeds. - Changes button to "Disconnect" (red) - **Connection Status**: `"Connecting"` (blue, maintained) - - **Dynamic Status**: `"—"` (em dash) + - **Dynamic Status**: Auto-power status message (green) or unknown device warning (yellow) 6. **Sync Device Time** - Sends current Unix timestamp @@ -206,11 +230,12 @@ connectBtn.addEventListener("click", async () => { - Refreshes coverage map if accuracy < 100m 11. **Connection Complete** - - **Connection Status**: `"Connected"` (green) - **NOW shown after GPS init** + - **Connection Status**: `"Connected"` (green) - **Dynamic Status**: `"—"` (em dash - cleared to show empty state) - Enables all UI controls - Ready for wardriving operations - Passive RX listening running in background + - **Note**: Zone validation happens server-side via `/auth` endpoint (Phase 4.2) and ongoing `/wardrive` posts (Phase 4.4). Phase 4.1 only provides preflight zone status UI before user clicks Connect. ## Disconnection Workflow @@ -223,9 +248,10 @@ connectBtn.addEventListener("click", async () => { 5. **Capacity Release** → Returns API slot to MeshMapper 6. **Channel Deletion** → Removes #wardriving channel from device 7. **BLE Disconnect** → Closes GATT connection -8. **Cleanup** → Stops timers, GPS, wake locks, clears queue -9. **State Reset** → Clears all connection state -10. **Disconnected** → Connection Status shows "Disconnected", Dynamic Status shows em dash or error message +8. **Zone Status Update** → Shows zone status, triggers zone recheck, starts 30s slot refresh timer +9. **Cleanup** → Stops timers, GPS, wake locks, clears queue +10. **State Reset** → Clears all connection state +11. **Disconnected** → Connection Status shows "Disconnected", Dynamic Status shows em dash or error message ### Detailed Disconnection Steps @@ -238,6 +264,7 @@ See `content/wardrive.js` for the main `disconnect()` function. - Channel setup failure - BLE connection lost (device out of range) - Slot revocation during active session +- **Phase 4.2+**: Zone validation failures (server-side via `/auth` or `/wardrive` endpoints) **Disconnection Sequence:** @@ -283,7 +310,22 @@ See `content/wardrive.js` for the main `disconnect()` function. - Last resort: `device.gatt.disconnect()` - Triggers "gattserverdisconnected" event -9. **Disconnected Event Handler** +9. **Zone Status Update (Post-BLE Close)** + - **Purpose**: Show zone status display and restart 30s slot refresh timer (Phase 4.1 - UI only) + - **Actions**: + - Shows zone status in connection bar (unhides `#zoneStatus` element) + - Starts 30s disconnected mode slot refresh timer + - **Zone Status Display**: + - Connection bar shows: `"Zone: [code]"` (green) or error text (red) + - Settings panel shows: Full zone name (white) or error text (red) + - Slots display shows: Available slots (green), "Full" (red), or "N/A" (gray) + - **Notes**: + - **No immediate zone recheck** - current zone status from `state.currentZone` is displayed + - Next zone check happens via 30s slot refresh timer or 100m GPS movement (if disconnected) + - Server-side validation will occur on next connect via `/auth` endpoint (Phase 4.2) + - Debug: `[GEO AUTH] Showing zone status after disconnect, starting 30s refresh timer` + +10. **Disconnected Event Handler** - Fires on BLE disconnect - **Connection Status**: `"Disconnected"` (red) - ALWAYS set regardless of reason - **Dynamic Status**: Set based on `state.disconnectReason` (WITHOUT "Disconnected:" prefix): @@ -295,6 +337,11 @@ See `content/wardrive.js` for the main `disconnect()` function. - `session_id_error` → `"Session error - reconnect"` (red) - `channel_setup_error` → Error message (red) - `ble_disconnect_error` → Error message (red) + - `zone_disabled` → `"Zone disabled"` (red) - **Phase 4.2+ only** (server-side validation) + - `outside_zone` → `"Outside zone"` (red) - **Phase 4.2+ only** (server-side validation) + - `at_capacity` → `"Zone at capacity"` (red) - **Phase 4.2+ only** (server-side validation) + - `gps_unavailable` → `"GPS unavailable"` (red) - Phase 4.1 (client-side GPS failure) + - `zone_check_failed` → `"Zone check failed"` (red) - Phase 4.1 (preflight network error) - `normal` / `null` / `undefined` → `"—"` (em dash) - Unknown reason codes → `"Connection not allowed: [reason]"` (red) - Runs comprehensive cleanup: @@ -311,21 +358,21 @@ See `content/wardrive.js` for the main `disconnect()` function. - Clears connection state - Clears device public key -10. **UI Cleanup** +11. **UI Cleanup** - Disables all controls except "Connect" - Clears device info display - Clears GPS display - Clears distance display - Changes button to "Connect" (green) -11. **State Reset** +12. **State Reset** - `state.connection = null` - `state.channel = null` - `state.lastFix = null` - `state.lastSuccessfulPingLocation = null` - `state.gpsState = "idle"` -12. **Disconnected Complete** +13. **Disconnected Complete** - **Connection Status**: `"Disconnected"` (red) - **Dynamic Status**: `"—"` (em dash) or error message based on disconnect reason - All resources released @@ -504,6 +551,195 @@ stateDiagram-v2 Disconnecting --> Disconnected: Cleanup complete ``` +## Geo-Auth Zone Check System (Phase 4.1 - Preflight UI Only) + +### Overview + +**Phase 4.1 Scope**: The Geo-Auth system provides **preflight UI feedback** about zone status to inform users before they click Connect. It does NOT enforce zone validation client-side. + +**Server-Side Validation** (Phase 4.2+): +- `/auth` endpoint (Phase 4.2): Validates zone on connect +- `/wardrive` endpoint (Phase 4.4): Validates zone on every ping with GPS coordinates +- Heartbeat (Phase 4.3): Maintains server-side session state + +Client-side zone checks are for **UI display only** - real enforcement happens server-side. + +### Zone Check Triggers (Disconnected Mode Only) + +Phase 4.1 performs zone checks only while disconnected to update UI: + +1. **App Launch Check** + - **When**: Immediately after initial GPS permission granted on page load + - **Function**: `performAppLaunchZoneCheck()` + - **Behavior**: + - Disables Connect button initially + - Shows "Checking zone..." status in connection bar + - Gets GPS coordinates via `getValidGpsForZoneCheck()` + - Calls `checkZoneStatus(coords)` API + - Updates UI with zone name/code + - Enables Connect button ONLY if in valid, enabled zone with available capacity + - Starts 30s slot refresh timer (disconnected mode) + - **On Failure**: Connect button remains disabled, error shown in connection bar and settings panel + - **Notes**: Provides immediate feedback to user about wardriving availability at current location + +2. **100m GPS Movement Recheck (Disconnected Only)** + - **When**: While disconnected, triggered when user moves ≥100m from last zone check location + - **Function**: `handleZoneCheckOnMove(newCoords)` called from GPS watch callback (only if `!state.connection`) + - **Behavior**: + - Calculates distance from `state.lastZoneCheckCoords` using Haversine formula + - If distance ≥ `ZONE_CHECK_DISTANCE_M` (100m): + - Calls `checkZoneStatus(newCoords)` API + - Updates `state.currentZone` and `state.lastZoneCheckCoords` + - Updates UI with new zone status (connection bar, settings panel) + - Centers map on new location + - **On Success**: Updates zone display, updates Connect button enabled/disabled state + - **On Failure**: Shows error in connection bar and settings panel, disables Connect button + - **Notes**: Keeps zone status fresh as user moves around while disconnected. Does NOT run while connected (server validates via `/wardrive` posts). + +### Zone Validation Logic + +**API Endpoint**: `POST https://yow.meshmapper.net/status` + +**Request Payload**: +```json +{ + "lat": 45.4215, + "lon": -75.6972, + "apikey": "...", + "apiver": "1.6.0" +} +``` + +**Response Payload**: +```json +{ + "valid": true, + "in_zone": true, + "zone": { + "name": "Ottawa, ON", + "code": "YOW", + "enabled": true, + "at_capacity": false, + "slots_available": 8, + "slots_max": 10 + } +} +``` + +**Validation Requirements** (for successful zone check): +- `valid === true` - API accepted the request +- `in_zone === true` - GPS coordinates within a zone boundary +- `zone.enabled === true` - Zone is enabled for wardriving +- `zone.at_capacity === false` - Zone has available capacity + +**GPS Requirements**: +- **Freshness**: < 60 seconds old +- **Accuracy**: < 50 meters +- **Retry Logic**: Up to 3 attempts with exponential backoff (100ms, 200ms, 400ms delays) +- **Function**: `getValidGpsForZoneCheck()` validates GPS before zone API call + +### Slot Refresh Timers (Phase 4.1 - Disconnected Mode Only) + +**Disconnected Mode (30s interval)**: +- Started after: App launch zone check success +- Stopped when: Connect button pressed +- Purpose: Keep slot availability fresh while user is deciding whether to connect +- Behavior: Re-calls `checkZoneStatus()` every 30s, updates `#slotsDisplay` in settings panel +- Shown after: Disconnect completes (30s timer restarted) + +**Phase 4.2+ (Connected Mode)**: +- Server-side only - no client-side refresh timer needed +- Slot capacity validated by `/auth` on connect and `/wardrive` on each ping + +### UI Display Behavior + +**Connection Bar (`#zoneStatus` element)**: +- **When Disconnected**: Visible, shows zone status (green "Zone: YOW" or red error) +- **When Connected**: Hidden (makes room for device name and noise floor) +- **When Disconnect Completes**: Shown again (zone status display, 30s timer restarts) +- **Color Coding**: + - Green: In valid, enabled zone + - Amber/Orange: "Checking zone..." during validation + - Red: Outside zone, zone disabled, at capacity, GPS unavailable, or check failed + +**Settings Panel (`#locationDisplay` and `#slotsDisplay` elements)**: +- **Location Display**: + - Always visible regardless of connection state + - Shows full zone name (e.g., "Ottawa, ON") in white on success + - Shows error text (e.g., "Outside zone", "GPS unavailable") in red on failure +- **Slots Display**: + - Shows available slots: "8 available" (green) + - Shows capacity reached: "Full (0/10)" (red) + - Shows unavailable: "N/A" (gray) when zone check not performed or failed +- **Update Frequency**: 30s while disconnected (Phase 4.1) + +**Connect Button State**: +- Disabled during: App launch zone check, zone check failures, GPS unavailable +- Enabled only when: Zone check succeeds AND zone enabled AND not at capacity AND in_zone +- Behavior: Provides clear feedback about wardriving availability at current location + +### Error Handling (Phase 4.1 - UI Display Only) + +**Note**: These disconnect reasons are placeholders for Phase 4.2+ server-side validation. In Phase 4.1, they appear only in UI when preflight zone checks fail (preventing connect). + +**Disconnect Reasons** (server-side validation in Phase 4.2+): +- `zone_disabled` → Will be set by server `/auth` or `/wardrive` response (Phase 4.2+) +- `outside_zone` → Will be set by server `/auth` or `/wardrive` response (Phase 4.2+) +- `at_capacity` → Will be set by server `/auth` or `/wardrive` response (Phase 4.2+) +- `gps_unavailable` → Client-side GPS acquisition failure (Phase 4.1) +- `zone_check_failed` → Network/API error during preflight zone check (Phase 4.1) + +**Phase 4.1 Behavior** (no automatic disconnects): +- App launch check fails → Connect button disabled, error shown in UI +- 100m movement check fails while disconnected → Error shown in UI, Connect button disabled +- User can retry after GPS available or zone status changes (30s refresh timer) + +**Error Recovery**: +- GPS can be re-acquired if permissions are granted +- Zone may become enabled/available again (detected by 30s refresh timer) +- Connect button re-enabled when zone check succeeds + +### State Management + +**Global State Properties**: +- `state.currentZone` - Currently validated zone object (null if no zone or check failed) +- `state.lastZoneCheckCoords` - `{lat, lon}` of last successful zone check (for 100m movement detection) +- `state.zoneCheckInProgress` - Boolean flag prevents concurrent zone checks +- `state.slotRefreshTimerId` - Timer ID for 30s slot refresh interval (disconnected mode only) + +**State Lifecycle**: +- Initialized to null on page load +- Set after successful zone check (app launch, disconnected movement recheck) +- Cleared on zone check failure +- Persists across connection/disconnection (allows comparison for movement detection) + +### Debug Logging + +All zone check operations use the `[GEO AUTH]` debug tag: +- `[GEO AUTH] [INIT]` - App launch zone check +- `[GEO AUTH] [GPS MOVEMENT]` - 100m movement recheck (disconnected mode only) +- `[GEO AUTH] [SLOT REFRESH]` - Periodic slot availability updates (30s disconnected) + +Example log sequence (app launch): +``` +[GEO AUTH] [INIT] Performing app launch zone check +[GEO AUTH] [INIT] Getting valid GPS coordinates for zone check +[GPS] GPS fix acquired: lat=45.42150, lon=-75.69720, accuracy=12m +[GEO AUTH] [INIT] Valid GPS acquired: 45.421500, -75.697200 +[GEO AUTH] [INIT] Calling checkZoneStatus() +[GEO AUTH] Zone check API request: {"lat":45.4215,"lon":-75.6972,"apikey":"...","apiver":"1.6.0"} +[GEO AUTH] Zone check API response: {"valid":true,"in_zone":true,"zone":{...}} +[GEO AUTH] [INIT] ✅ Zone check successful: Ottawa, ON (YOW) +[GEO AUTH] [INIT] In zone: true, At capacity: false +[GEO AUTH] [INIT] ✅ Connect button enabled (in valid zone) +[GEO AUTH] [INIT] Started 30s slot refresh timer +``` + +**Phase 4.2+ Tags** (not yet implemented): +- `[GEO AUTH] [CONNECT]` - Server-side validation via `/auth` endpoint +- `[GEO AUTH] [WARDRIVE]` - Server-side validation via `/wardrive` posts +- `[GEO AUTH] [DISCONNECT]` - Server response handling + ## Code References ### Connect Entry Points diff --git a/docs/Change1.md b/docs/Change1.md deleted file mode 100644 index 4c44605..0000000 --- a/docs/Change1.md +++ /dev/null @@ -1,1424 +0,0 @@ -# MeshCore GOME WarDriver - Development Guidelines - -## Overview -This document defines the coding standards and requirements for all changes to the MeshCore GOME WarDriver repository. AI agents and contributors must follow these guidelines for every modification. - ---- - -## Code Style & Standards - -### Debug Logging -- **ALWAYS** include debug console logging for significant operations -- Use the existing debug helper functions: - - `debugLog(message, ...args)` - For general debug information - - `debugWarn(message, ... args)` - For warning conditions - - `debugError(message, ... args)` - For error conditions -- Debug logging is controlled by the `DEBUG_ENABLED` flag (URL parameter `? debug=true`) -- Log at key points: function entry, API calls, state changes, errors, and decision branches - -#### Debug Log Tagging Convention - -All debug log messages **MUST** include a descriptive tag in square brackets immediately after `[DEBUG]` that identifies the subsystem or feature area. This enables easier filtering and understanding of debug output. - -**Format:** `[DEBUG] [TAG] Message here` - -**Required Tags:** - -| Tag | Description | -|-----|-------------| -| `[BLE]` | Bluetooth connection and device communication | -| `[GPS]` | GPS/geolocation operations | -| `[PING]` | Ping sending and validation | -| `[API QUEUE]` | API batch queue operations | -| `[RX BATCH]` | RX batch buffer operations | -| `[PASSIVE RX]` | Passive RX logging logic | -| `[PASSIVE RX UI]` | Passive RX UI rendering | -| `[SESSION LOG]` | Session log tracking | -| `[UNIFIED RX]` | Unified RX handler | -| `[DECRYPT]` | Message decryption | -| `[UI]` | General UI updates (status bar, buttons, etc.) | -| `[CHANNEL]` | Channel setup and management | -| `[TIMER]` | Timer and countdown operations | -| `[WAKE LOCK]` | Wake lock acquisition/release | -| `[GEOFENCE]` | Geofence and distance validation | -| `[CAPACITY]` | Capacity check API calls | -| `[AUTO]` | Auto ping mode operations | -| `[INIT]` | Initialization and setup | -| `[ERROR LOG]` | Error log UI operations | - -**Examples:** -```javascript -// ✅ Correct - includes tag -debugLog("[BLE] Connection established"); -debugLog("[GPS] Fresh position acquired: lat=45.12345, lon=-75.12345"); -debugLog("[PING] Sending ping to channel 2"); - -// ❌ Incorrect - missing tag -debugLog("Connection established"); -debugLog("Fresh position acquired"); -``` - -### Status Messages -- **ALWAYS** update `STATUS_MESSAGES.md` when adding or modifying user-facing status messages -- Use the `setStatus(message, color)` function for all UI status updates -- Use appropriate `STATUS_COLORS` constants: - - `STATUS_COLORS.idle` - Default/waiting state - - `STATUS_COLORS. success` - Successful operations - - `STATUS_COLORS.warning` - Warning conditions - - `STATUS_COLORS.error` - Error states - - `STATUS_COLORS.info` - Informational/in-progress states - ---- - -## Documentation Requirements - -### Code Comments -- Document complex logic with inline comments -- Use JSDoc-style comments for functions: - - `@param` for parameters - - `@returns` for return values - - Brief description of purpose - -### docs/STATUS_MESSAGES.md Updates -When adding new status messages, include: -- The exact status message text -- When it appears (trigger condition) -- The status color used -- Any follow-up actions or states - -### `docs/CONNECTION_WORKFLOW.md` Updates -When **modifying connect or disconnect logic**, you must: -- Read `docs/CONNECTION_WORKFLOW.md` before making the change (to understand current intended behavior). -- Update `docs/CONNECTION_WORKFLOW.md` so it remains accurate after the change: - - Steps/sequence of the workflow - - Any new states, retries, timeouts, or error handling - - Any UI impacts (buttons, indicators, status messages) - -### docs/PING_AUTO_PING_WORKFLOW.md Updates -When **modifying ping or auto-ping logic**, you must: -- Read `docs/PING_AUTO_PING_WORKFLOW.md` before making the change (to understand current intended behavior). -- Update `docs/PING_AUTO_PING_WORKFLOW.md` so it remains accurate after the change: - - Ping flows (manual `sendPing()`, auto-ping lifecycle) - - Validation logic (geofence, distance, cooldown) - - GPS acquisition and payload construction - - Repeater tracking and MeshMapper API posting - - Control locking and cooldown management - - Auto mode behavior (intervals, wake lock, page visibility) - - Any UI impacts (buttons, status messages, countdown displays) - ---- - -### Requested Change - -# Unified Refactor: RX Parsing Architecture, Naming Standardization, and RX Auto Mode - -## Overview -This is a comprehensive refactor covering three major tasks: -1. **Unified RX Parsing Architecture**: Single parsing point for RX packet metadata -2. **Complete Naming Standardization**: TX/RX terminology consistency across entire codebase -3. **RX Auto Mode**: New passive-only wardriving mode with always-on unified listener - -## Repository Context -- **Repository**: MrAlders0n/MeshCore-GOME-WarDriver -- **Branch**: dev -- **Language**: JavaScript (vanilla), HTML, CSS -- **Type**: Progressive Web App (PWA) for Meshtastic wardriving - ---- - -## Task 1: Unified RX Parsing Architecture - -### Objective -Refactor RX packet handling to use a single unified parsing function that extracts header/path metadata once, then routes to TX or RX wardriving handlers. This eliminates duplicate parsing, ensures consistency, and fixes debug data accuracy issues. - -### Current Problems -1. Header and path are parsed separately in `handleSessionLogTracking()` and `handlePassiveRxLogging()` -2. Debug data uses `packet.path` from decrypted packet instead of actual raw path bytes -3. Performance waste - same bytes parsed multiple times per packet -4. Inconsistency risk - two different code paths doing same extraction -5. Debug mode `parsed_path` shows incorrect data (e.g., "0" instead of "4E") - -### Required Changes - -#### 1. Create Unified Metadata Parser - -Create new function `parseRxPacketMetadata(data)` in `content/wardrive.js`: - -**Location**: Add after `computeChannelHash()` function (around line 1743) - -**Implementation**: -- Extract header byte from `data.raw[0]` -- Extract path length from header upper 4 bits: `(header >> 4) & 0x0F` -- Extract raw path bytes as array: `data.raw.slice(1, 1 + pathLength)` -- Derive first hop (for TX repeater ID): `pathBytes[0]` -- Derive last hop (for RX repeater ID): `pathBytes[pathLength - 1]` -- Extract encrypted payload: `data.raw.slice(1 + pathLength)` - -**Return object structure**: -{ - raw: data.raw, // Full raw packet bytes - header: header, // Header byte - pathLength: pathLength, // Number of hops - pathBytes: pathBytes, // Raw path bytes array - firstHop: pathBytes[0], // First hop ID (TX) - lastHop: pathBytes[pathLength-1], // Last hop ID (RX) - snr: data.lastSnr, // SNR value - rssi: data.lastRssi, // RSSI value - encryptedPayload: payload // Rest of packet -} - -**JSDoc**: -/** - * Parse RX packet metadata from raw bytes - * Single source of truth for header/path extraction - * @param {Object} data - LogRxData event data (contains lastSnr, lastRssi, raw) - * @returns {Object} Parsed metadata object - */ - -**Debug logging**: -- Log when parsing starts -- Log extracted values (header, pathLength, firstHop, lastHop) -- Use `[RX PARSE]` debug tag - -#### 2. Refactor TX Handler - -Update `handleSessionLogTracking()` (will also be renamed to `handleTxLogging()` in Task 2): - -**Changes**: -- Accept metadata object as first parameter instead of packet object -- Remove duplicate header/path parsing code -- Use `metadata.header` for header validation -- Use `metadata.firstHop` for repeater ID extraction -- Use `metadata.pathBytes` for debug data -- Store full metadata in repeater tracking for debug mode -- Decrypt payload using `metadata.encryptedPayload` if needed -- Update to work with pre-parsed metadata throughout - -**Signature change**: -// OLD: -async function handleSessionLogTracking(packet, data) - -// NEW (after Task 2 rename): -async function handleTxLogging(metadata, data) - -**Key changes**: -- Replace `packet.header` with `metadata.header` -- Replace `packet.path[0]` with `metadata.firstHop` -- Replace `packet.payload` with `metadata.encryptedPayload` -- Store metadata object (not just SNR) in `state.txTracking.repeaters` for debug mode -- For decryption, use metadata.encryptedPayload - -#### 3. Refactor RX Handler - -Update `handlePassiveRxLogging()` (will also be renamed to `handleRxLogging()` in Task 2): - -**Changes**: -- Accept metadata object as first parameter instead of packet object -- Remove all header/path parsing code (already done in parseRxPacketMetadata) -- Use `metadata.lastHop` for repeater ID extraction -- Use `metadata.pathLength` for path length -- Use `metadata.header` for header value -- Pass metadata to batching function - -**Signature change**: -// OLD: -async function handlePassiveRxLogging(packet, data) - -// NEW (after Task 2 rename): -async function handleRxLogging(metadata, data) - -**Key changes**: -- Replace `packet.path.length` with `metadata.pathLength` -- Replace `packet.path[packet.path.length - 1]` with `metadata.lastHop` -- Replace `packet.header` with `metadata.header` -- Pass metadata to handleRxBatching() instead of building separate rawPacketData object - -#### 4. Fix Debug Data Generation - -Update `buildDebugData()` function: - -**Changes**: -- Accept metadata object as first parameter -- Use `metadata.pathBytes` for `parsed_path` field (NOT packet.path) -- Use `metadata.header` for `parsed_header` field -- Convert `metadata.pathBytes` directly to hex string -- Ensure repeaterId matches first/last byte of pathBytes - -**Signature change**: -// OLD: -function buildDebugData(rawPacketData, heardByte) - -// NEW: -function buildDebugData(metadata, heardByte, repeaterId) - -**Implementation**: -function buildDebugData(metadata, heardByte, repeaterId) { - // Convert path bytes to hex string - these are the ACTUAL bytes used - const parsedPathHex = Array.from(metadata. pathBytes) - .map(byte => byte.toString(16).padStart(2, '0').toUpperCase()) - .join(''); - - return { - raw_packet: bytesToHex(metadata.raw), - raw_snr: metadata.snr, - raw_rssi: metadata.rssi, - parsed_header: metadata.header. toString(16).padStart(2, '0').toUpperCase(), - parsed_path_length: metadata.pathLength, - parsed_path: parsedPathHex, // ACTUAL raw bytes - parsed_payload: bytesToHex(metadata. encryptedPayload), - parsed_heard: heardByte, - repeaterId: repeaterId - }; -} - -#### 5. Update Unified RX Handler - -Update `handleUnifiedRxLogEvent()`: - -**Changes**: -- Call `parseRxPacketMetadata(data)` FIRST before any routing -- Pass metadata to handlers instead of packet -- Remove `Packet.fromBytes()` call from unified handler (moved to individual handlers if needed) -- Keep decrypt logic in TX handler only (TX needs encrypted content) - -**Updated flow**: -async function handleUnifiedRxLogEvent(data) { - try { - // Parse metadata ONCE - const metadata = parseRxPacketMetadata(data); - - debugLog(`[UNIFIED RX] Packet received: header=0x${metadata.header.toString(16)}, pathLength=${metadata.pathLength}`); - - // Route to TX tracking if active - if (state.txTracking.isListening) { - debugLog("[UNIFIED RX] TX tracking active - delegating to TX handler"); - const wasEcho = await handleTxLogging(metadata, data); - if (wasEcho) { - debugLog("[UNIFIED RX] Packet was TX echo, done"); - return; - } - } - - // Route to RX wardriving if active - if (state.rxTracking.isWardriving) { - debugLog("[UNIFIED RX] RX wardriving active - delegating to RX handler"); - await handleRxLogging(metadata, data); - } - } catch (error) { - debugError("[UNIFIED RX] Error processing rx_log entry", error); - } -} - -#### 6. Update Debug Mode Integration - -Update debug data usage in: -- `postToMeshMapperAPI()` (TX debug data) - around line 1409 -- `queueRxApiPost()` (RX debug data) - around line 2378 - -**Changes for TX debug data**: -- Access metadata from stored repeater data: `repeater.metadata` -- Call `buildDebugData(repeater.metadata, heardByte, repeater.repeaterId)` -- For TX: heardByte is the repeaterId (first hop) - -**Changes for RX debug data**: -- Access metadata from batch entry: `entry.metadata` -- Call `buildDebugData(entry.metadata, heardByte, entry.repeater_id)` -- For RX: heardByte is last hop from metadata. pathBytes - -**Example TX integration**: -if (state.debugMode && state.tempTxRepeaterData && state.tempTxRepeaterData.length > 0) { - const debugDataArray = []; - for (const repeater of state.tempTxRepeaterData) { - if (repeater.metadata) { - const heardByte = repeater.repeaterId; - const debugData = buildDebugData(repeater.metadata, heardByte, repeater.repeaterId); - debugDataArray.push(debugData); - } - } - if (debugDataArray.length > 0) { - payload.debug_data = debugDataArray; - } -} - -**Example RX integration**: -if (state.debugMode && entry.metadata) { - const lastHopId = entry.metadata.lastHop; - const heardByte = lastHopId. toString(16).padStart(2, '0').toUpperCase(); - const debugData = buildDebugData(entry.metadata, heardByte, entry.repeater_id); - payload.debug_data = debugData; -} - -#### 7. Update Repeater Tracking Storage - -In `handleTxLogging()` (renamed from handleSessionLogTracking): - -**Store full metadata**: -state.txTracking.repeaters. set(pathHex, { - snr: data.lastSnr, - seenCount: 1, - metadata: metadata // Store full metadata for debug mode -}); - -In `stopTxTracking()` (renamed from stopRepeaterTracking): - -**Return metadata with repeater data**: -const repeaters = Array.from(state.txTracking.repeaters.entries()).map(([id, data]) => ({ - repeaterId: id, - snr: data.snr, - metadata: data.metadata // Include metadata for debug mode -})); - -#### 8. Update RX Batching Storage - -In `handleRxBatching()` (renamed from handlePassiveRxForAPI): - -**Store metadata in buffer**: -buffer = { - firstLocation: { lat: currentLocation.lat, lng: currentLocation.lon }, - bestObservation: { - snr, - rssi, - pathLength, - header, - lat: currentLocation.lat, - lon: currentLocation.lon, - timestamp: Date.now(), - metadata: metadata // Store full metadata for debug mode - } -}; - -In `flushRxBatch()` (renamed from flushBatch): - -**Include metadata in entry**: -const entry = { - repeater_id: repeaterId, - location: { lat: best.lat, lng: best.lon }, - snr: best.snr, - rssi: best.rssi, - pathLength: best.pathLength, - header: best.header, - timestamp: best.timestamp, - metadata: best.metadata // For debug mode -}; - -### Validation Requirements -- Debug data `parsed_path` must show actual raw bytes used for repeater ID determination -- For TX: `parsed_path` first byte must equal `repeaterId` and `parsed_heard` -- For RX: `parsed_path` last byte must equal `repeaterId` used in API post -- No duplicate parsing - single call to parseRxPacketMetadata() per packet -- All existing functionality preserved (TX/RX tracking, API posting, UI updates) -- Debug logging at each step with [RX PARSE] tag - ---- - -## Task 2: Complete Naming Standardization - -### Objective -Standardize all naming conventions across the codebase to use consistent TX/RX terminology, eliminating legacy "session log" and "repeater tracking" names. - -### Naming Convention Rules -- **TX** = Active ping wardriving (send ping, track echoes) -- **RX** = Passive observation wardriving (listen to all packets) -- Use `Tracking` for operational state (lifecycle management) -- Use `Log` for UI state (display/export) -- Use `TxRxAuto` for combined TX + RX auto mode -- Use `RxAuto` for RX-only auto mode - -### Required Changes - -#### 1. State Object Renames - -**File**: `content/wardrive.js` - -**Main operational state (state object)**: - -RENAME state.repeaterTracking TO state.txTracking - - All properties: - - state.txTracking.isListening - - state.txTracking. sentTimestamp - - state.txTracking.sentPayload - - state.txTracking.channelIdx - - state.txTracking.repeaters - - state. txTracking.listenTimeout - - state.txTracking.rxLogHandler - - state.txTracking.currentLogEntry - -RENAME state.passiveRxTracking TO state. rxTracking - - Properties: - - state.rxTracking.isListening - - state.rxTracking.rxLogHandler - - REMOVE state.rxTracking.entries (unused array) - -RENAME state.running TO state.txRxAutoRunning - - This is the flag for TX/RX Auto mode - - Find ALL references throughout codebase - -state.rxAutoRunning - NO CHANGE (already correct) - -**UI state (standalone consts)**: - -RENAME sessionLogState TO txLogState - - All references to sessionLogState - -rxLogState - NO CHANGE -errorLogState - NO CHANGE - -#### 2. Function Renames - -**File**: `content/wardrive.js` - -**Core handlers**: -- RENAME handleSessionLogTracking() TO handleTxLogging() -- RENAME handlePassiveRxLogging() TO handleRxLogging() -- RENAME handlePassiveRxForAPI() TO handleRxBatching() -- handleUnifiedRxLogEvent() - NO CHANGE - -**Lifecycle functions**: -- RENAME startRepeaterTracking() TO startTxTracking() -- RENAME stopRepeaterTracking() TO stopTxTracking() -- startUnifiedRxListening() - NO CHANGE -- stopUnifiedRxListening() - NO CHANGE - -**UI functions**: -- RENAME addLogEntry() TO addTxLogEntry() -- RENAME updateLogSummary() TO updateTxLogSummary() -- RENAME renderLogEntries() TO renderTxLogEntries() -- RENAME toggleBottomSheet() TO toggleTxLogBottomSheet() -- RENAME updateCurrentLogEntryWithLiveRepeaters() TO updateCurrentTxLogEntryWithLiveRepeaters() -- RENAME updatePingLogWithRepeaters() TO updateTxLogWithRepeaters() -- RENAME logPingToUI() TO logTxPingToUI() -- addRxLogEntry() - NO CHANGE -- updateRxLogSummary() - NO CHANGE -- renderRxLogEntries() - NO CHANGE -- toggleRxLogBottomSheet() - NO CHANGE - -**Export functions**: -- RENAME sessionLogToCSV() TO txLogToCSV() -- rxLogToCSV() - NO CHANGE -- errorLogToCSV() - NO CHANGE - -**Batch/API functions**: -- RENAME flushBatch() TO flushRxBatch() -- RENAME flushAllBatches() TO flushAllRxBatches() -- RENAME queueApiPost() TO queueRxApiPost() - -**Helper functions**: -- formatRepeaterTelemetry() - NO CHANGE (generic) - -#### 3. DOM Element Reference Renames - -**File**: `content/wardrive.js` - -RENAME all Session Log DOM references: -- sessionPingsEl TO txPingsEl -- logSummaryBar TO txLogSummaryBar -- logBottomSheet TO txLogBottomSheet -- logScrollContainer TO txLogScrollContainer -- logCount TO txLogCount -- logLastTime TO txLogLastTime -- logLastSnr TO txLogLastSnr -- sessionLogCopyBtn TO txLogCopyBtn - -RENAME button references: -- sendPingBtn TO txPingBtn -- autoToggleBtn TO txRxAutoBtn - -RX Log DOM references - NO CHANGE (already correct): -- rxLogSummaryBar -- rxLogBottomSheet -- rxLogScrollContainer -- rxLogCount -- rxLogLastTime -- rxLogLastRepeater -- rxLogSnrChip -- rxLogEntries -- rxLogExpandArrow -- rxLogCopyBtn - -#### 4. HTML Element ID Renames - -**File**: `index.html` - -UPDATE all Session Log element IDs: -- id="sessionPings" TO id="txPings" -- id="logSummaryBar" TO id="txLogSummaryBar" -- id="logBottomSheet" TO id="txLogBottomSheet" -- id="logScrollContainer" TO id="txLogScrollContainer" -- id="logCount" TO id="txLogCount" -- id="logLastTime" TO id="txLogLastTime" -- id="logLastSnr" TO id="txLogLastSnr" -- id="sessionLogCopyBtn" TO id="txLogCopyBtn" -- id="logExpandArrow" TO id="txLogExpandArrow" - -UPDATE button IDs: -- id="sendPingBtn" TO id="txPingBtn" -- id="autoToggleBtn" TO id="txRxAutoBtn" - -UPDATE user-facing labels: -- H2 heading text: "Session Log" TO "TX Log" -- Button text: "Send Ping" TO "TX Ping" -- Button text: "Start Auto Ping" / "Stop Auto Ping" TO "TX/RX Auto" / "Stop TX/RX" - -#### 5. Debug Log Tag Updates - -**Files**: `content/wardrive.js`, all documentation files - -REPLACE debug tags throughout: -- [SESSION LOG] TO [TX LOG] -- [PASSIVE RX] TO [RX LOG] -- [PASSIVE RX UI] TO [RX LOG UI] -- [AUTO] TO [TX/RX AUTO] (when referring to auto ping mode) - -KEEP unchanged: -- [RX BATCH] (API batching operations) -- [API QUEUE] -- [UNIFIED RX] -- [BLE] -- [GPS] -- [PING] -- etc. - -#### 6. CSS Comments Update - -**File**: `content/style.css` - -UPDATE comment: -/* Session Log - Static Expandable Section */ -TO -/* TX Log - Static Expandable Section */ - -#### 7. Documentation File Updates - -**Files to update**: - -**docs/DEVELOPMENT_REQUIREMENTS.md**: -- Update debug tag table: [SESSION LOG] → [TX LOG] -- Update debug tag table: [AUTO] → [TX/RX AUTO] -- Update debug tag table: [PASSIVE RX] → [RX LOG] -- Update debug tag table: [PASSIVE RX UI] → [RX LOG UI] - -**docs/PING_WORKFLOW.md**: -- Replace "session log" with "TX log" throughout -- Replace "Session Log" with "TX Log" throughout -- Replace "repeater tracking" with "TX tracking" (when referring to TX) -- Replace "auto mode" with "TX/RX Auto mode" -- Replace "Auto Ping" with "TX/RX Auto" -- Update function references to new names -- Update state variable references to new names - -**docs/CONNECTION_WORKFLOW.md**: -- Replace "session log" with "TX log" -- Replace "repeater tracking" with "TX tracking" -- Update function references: stopRepeaterTracking() → stopTxTracking() - -**docs/FLOW_WARDRIVE_TX_DIAGRAM.md**: -- Replace "SESSION LOG HANDLER" with "TX LOG HANDLER" -- Replace "Session Log" with "TX Log" throughout -- Update all function names in diagram - -**docs/FLOW_WARDRIVE_RX_DIAGRAM. md**: -- Replace "PASSIVE RX HANDLER" with "RX LOG HANDLER" -- Update function names in diagram - -**CHANGES_SUMMARY.md**: -- Update historical references (optional, for consistency) - -#### 8. Code Comments and JSDoc Updates - -**File**: `content/wardrive.js` - -UPDATE all inline comments: -- "session log" → "TX log" -- "Session Log" → "TX Log" -- "repeater tracking" (when referring to TX) → "TX tracking" -- "passive RX" (when referring to logging) → "RX logging" -- "auto mode" → "TX/RX Auto mode" -- "Auto Ping" → "TX/RX Auto" - -UPDATE all JSDoc comments: -- Function descriptions mentioning "session log" → "TX log" -- Function descriptions mentioning "repeater" → "TX tracking" or "repeater telemetry" (as appropriate) -- Parameter descriptions -- Return value descriptions - -#### 9. Event Listener Updates - -**File**: `content/wardrive.js` (in onLoad function) - -UPDATE event listeners: -- sendPingBtn. addEventListener → txPingBtn.addEventListener -- autoToggleBtn.addEventListener → txRxAutoBtn.addEventListener -- logSummaryBar.addEventListener → txLogSummaryBar.addEventListener -- sessionLogCopyBtn.addEventListener → txLogCopyBtn.addEventListener - -#### 10. Copy to Clipboard Function Updates - -**File**: `content/wardrive.js` (copyLogToCSV function) - -UPDATE switch statement: -case 'session': - csv = txLogToCSV(); - logTag = '[TX LOG]'; - break; - -### Validation Requirements -- All references to old names must be updated -- No broken references (undefined variables/functions) -- All functionality preserved (no behavior changes) -- Debug logging uses new tags consistently -- Documentation matches code -- UI labels updated for user-facing text -- HTML IDs match JavaScript selectors - ---- - -## Task 3: RX Auto Mode with Always-On Unified Listener - -### Objective -Add a new "RX Auto" button that enables RX-only wardriving (no transmission), while restructuring the unified RX listener to be always active when connected. This enables three distinct modes: TX Ping (manual), TX/RX Auto (current auto behavior), and RX Auto (new passive-only mode). - -### Current Behavior -- Unified RX listener starts when TX/RX Auto button clicked -- Unified RX listener stops when TX/RX Auto button clicked again -- No way to do RX wardriving without TX transmission - -### New Behavior -- Unified RX listener starts IMMEDIATELY on connect and stays on entire connection -- Unified listener NEVER stops except on disconnect -- RX wardriving subscription controlled by flag: `state.rxTracking.isWardriving` -- Three buttons: TX Ping, TX/RX Auto, RX Auto - -### Required Changes - -#### 1. Add New State Properties - -**File**: `content/wardrive.js` - -ADD to state. rxTracking object: -state.rxTracking = { - isListening: true, // TRUE when connected (unified listener) - isWardriving: false, // TRUE when TX/RX Auto OR RX Auto enabled - rxLogHandler: null - // entries removed in Task 2 -}; - -ADD new top-level state property: -state.rxAutoRunning = false; // TRUE when RX Auto mode active - -state.txRxAutoRunning already exists (renamed from state.running in Task 2) - -#### 2. Update Connection Flow - -**File**: `content/wardrive.js` (connect function) - -**Changes in connect() function**: - -MOVE startUnifiedRxListening() to run IMMEDIATELY after channel setup: -async function connect() { - // ... BLE connection ... - // ... Channel setup (ensureChannel) ... - - // START unified RX listener immediately after channel ready - startUnifiedRxListening(); - debugLog("[BLE] Unified RX listener started on connect"); - - // CLEAR all logs on connect (new session) - txLogState.entries = []; - renderTxLogEntries(true); - updateTxLogSummary(); - - rxLogState.entries = []; - renderRxLogEntries(true); - updateRxLogSummary(); - - errorLogState.entries = []; - renderErrorLogEntries(true); - updateErrorLogSummary(); - - debugLog("[BLE] All logs cleared on connect (new session)"); - - // ... GPS initialization ... - // ... Connection complete ... -} - -#### 3. Update Disconnect Flow - -**File**: `content/wardrive.js` (disconnect handler) - -**Changes in disconnected event handler**: - -KEEP stopUnifiedRxListening() on disconnect (this is the ONLY place it should be called): -conn.on("disconnected", () => { - // ... cleanup ... - - stopUnifiedRxListening(); // Stop unified listener on disconnect - debugLog("[BLE] Unified RX listener stopped on disconnect"); - - // DO NOT clear logs on disconnect (preserve for user review) - // Logs are only cleared on connect - - // ... rest of cleanup ... -}); - -#### 4. Make startUnifiedRxListening() Idempotent - -**File**: `content/wardrive.js` - -UPDATE startUnifiedRxListening() to be safe to call multiple times: - -function startUnifiedRxListening() { - // Idempotent: safe to call multiple times - if (state.rxTracking.isListening && state.rxTracking.rxLogHandler) { - debugLog("[UNIFIED RX] Already listening, skipping start"); - return; - } - - if (!state.connection) { - debugWarn("[UNIFIED RX] Cannot start: no connection"); - return; - } - - debugLog("[UNIFIED RX] Starting unified RX listening"); - - const handler = (data) => handleUnifiedRxLogEvent(data); - state.rxTracking.rxLogHandler = handler; - state.connection.on(Constants.PushCodes.LogRxData, handler); - state.rxTracking.isListening = true; - - debugLog("[UNIFIED RX] ✅ Unified listening started successfully"); -} - -#### 5. Add Defensive Check in Unified Handler - -**File**: `content/wardrive.js` - -UPDATE handleUnifiedRxLogEvent() with defensive check: - -async function handleUnifiedRxLogEvent(data) { - try { - // Defensive check: ensure listener is marked as active - if (!state.rxTracking.isListening) { - debugWarn("[UNIFIED RX] Received event but listener marked inactive - reactivating"); - state.rxTracking.isListening = true; - } - - // Parse metadata ONCE (Task 1) - const metadata = parseRxPacketMetadata(data); - - // Route to TX tracking if active (during 7s echo window) - if (state.txTracking.isListening) { - debugLog("[UNIFIED RX] TX tracking active - checking for echo"); - const wasEcho = await handleTxLogging(metadata, data); - if (wasEcho) { - debugLog("[UNIFIED RX] Packet was TX echo, done"); - return; - } - } - - // Route to RX wardriving if active (when TX/RX Auto OR RX Auto enabled) - if (state.rxTracking.isWardriving) { - debugLog("[UNIFIED RX] RX wardriving active - logging observation"); - await handleRxLogging(metadata, data); - } - - // If neither active, packet is received but ignored - // Listener stays on, just not processing for wardriving - - } catch (error) { - debugError("[UNIFIED RX] Error processing rx_log entry", error); - } -} - -#### 6. Update TX/RX Auto Functions - -**File**: `content/wardrive.js` - -UPDATE startAutoPing() function (will be renamed to startTxRxAuto): - -function startAutoPing() { // Function name will stay as is, but references updated - debugLog("[TX/RX AUTO] Starting TX/RX Auto mode"); - - if (!state.connection) { - debugError("[TX/RX AUTO] Cannot start - not connected"); - alert("Connect to a MeshCore device first."); - return; - } - - // Check cooldown - if (isInCooldown()) { - const remainingSec = getRemainingCooldownSeconds(); - debugLog(`[TX/RX AUTO] Start blocked by cooldown (${remainingSec}s remaining)`); - setDynamicStatus(`Wait ${remainingSec}s before toggling TX/RX Auto`, STATUS_COLORS.warning); - return; - } - - // Defensive check: ensure unified listener is running - if (state.connection && !state.rxTracking.isListening) { - debugWarn("[TX/RX AUTO] Unified listener not active - restarting"); - startUnifiedRxListening(); - } - - // Clear any existing auto timer - if (state.autoTimerId) { - debugLog("[TX/RX AUTO] Clearing existing auto timer"); - clearTimeout(state.autoTimerId); - state.autoTimerId = null; - } - stopAutoCountdown(); - - // Clear any previous skip reason - state.skipReason = null; - - // ENABLE RX wardriving - state.rxTracking.isWardriving = true; - debugLog("[TX/RX AUTO] RX wardriving enabled"); - - // Start GPS watch for continuous updates - debugLog("[TX/RX AUTO] Starting GPS watch"); - startGeoWatch(); - - // Set TX/RX Auto mode flag - state.txRxAutoRunning = true; // Renamed from state.running - updateAutoButton(); - updateControlsForCooldown(); // Disable RX Auto button - - // Acquire wake lock - debugLog("[TX/RX AUTO] Acquiring wake lock"); - acquireWakeLock().catch(console.error); - - // Send first ping - debugLog("[TX/RX AUTO] Sending initial auto ping"); - sendPing(false).catch(console.error); -} - -UPDATE stopAutoPing() function: - -function stopAutoPing(stopGps = false) { - debugLog(`[TX/RX AUTO] Stopping TX/RX Auto mode (stopGps=${stopGps})`); - - // Check cooldown (unless stopGps is true for disconnect) - if (!stopGps && isInCooldown()) { - const remainingSec = getRemainingCooldownSeconds(); - debugLog(`[TX/RX AUTO] Stop blocked by cooldown (${remainingSec}s remaining)`); - setDynamicStatus(`Wait ${remainingSec}s before toggling TX/RX Auto`, STATUS_COLORS.warning); - return; - } - - // Clear auto timer - if (state.autoTimerId) { - debugLog("[TX/RX AUTO] Clearing auto timer"); - clearTimeout(state.autoTimerId); - state.autoTimerId = null; - } - stopAutoCountdown(); - - // Clear skip reason and paused timer state - state.skipReason = null; - state.pausedAutoTimerRemainingMs = null; - - // DISABLE RX wardriving - state.rxTracking.isWardriving = false; - debugLog("[TX/RX AUTO] RX wardriving disabled"); - - // DO NOT stop unified listener (stays on) - // REMOVED: stopUnifiedRxListening(); - - // Stop GPS watch if requested - if (stopGps) { - stopGeoWatch(); - } - - // Clear TX/RX Auto mode flag - state.txRxAutoRunning = false; // Renamed from state.running - updateAutoButton(); - updateControlsForCooldown(); // Re-enable RX Auto button - releaseWakeLock(); - - debugLog("[TX/RX AUTO] TX/RX Auto mode stopped"); -} - -#### 7. Add RX Auto Mode Functions - -**File**: `content/wardrive.js` - -ADD new startRxAuto() function: - -function startRxAuto() { - debugLog("[RX AUTO] Starting RX Auto mode"); - - if (!state.connection) { - debugError("[RX AUTO] Cannot start - not connected"); - alert("Connect to a MeshCore device first."); - return; - } - - // Defensive check: ensure unified listener is running - if (state.connection && !state.rxTracking.isListening) { - debugWarn("[RX AUTO] Unified listener not active - restarting"); - startUnifiedRxListening(); - } - - // ENABLE RX wardriving - state.rxTracking.isWardriving = true; - debugLog("[RX AUTO] RX wardriving enabled"); - - // Set RX Auto mode flag - state.rxAutoRunning = true; - updateAutoButton(); - updateControlsForCooldown(); // Disable TX/RX Auto button - - // Acquire wake lock - debugLog("[RX AUTO] Acquiring wake lock"); - acquireWakeLock().catch(console.error); - - setDynamicStatus("RX Auto started", STATUS_COLORS.success); - debugLog("[RX AUTO] RX Auto mode started successfully"); -} - -ADD new stopRxAuto() function: - -function stopRxAuto() { - debugLog("[RX AUTO] Stopping RX Auto mode"); - - if (!state.rxAutoRunning) { - debugLog("[RX AUTO] RX Auto not running, nothing to stop"); - return; - } - - // DISABLE RX wardriving - state.rxTracking.isWardriving = false; - debugLog("[RX AUTO] RX wardriving disabled"); - - // DO NOT stop unified listener (stays on) - // REMOVED: stopUnifiedRxListening(); - - // Clear RX Auto mode flag - state.rxAutoRunning = false; - updateAutoButton(); - updateControlsForCooldown(); // Re-enable TX/RX Auto button - releaseWakeLock(); - - setDynamicStatus("RX Auto stopped", STATUS_COLORS.idle); - debugLog("[RX AUTO] RX Auto mode stopped"); -} - -#### 8. Update Button Control Logic - -**File**: `content/wardrive.js` - -UPDATE updateControlsForCooldown() function: - -function updateControlsForCooldown() { - const connected = !!state.connection; - const inCooldown = isInCooldown(); - - debugLog(`[UI] updateControlsForCooldown: connected=${connected}, inCooldown=${inCooldown}, pingInProgress=${state.pingInProgress}, txRxAutoRunning=${state.txRxAutoRunning}, rxAutoRunning=${state.rxAutoRunning}`); - - // TX Ping button - disabled during cooldown or ping in progress - txPingBtn.disabled = ! connected || inCooldown || state.pingInProgress; - - // TX/RX Auto button - disabled during cooldown, ping in progress, OR when RX Auto running - txRxAutoBtn. disabled = !connected || inCooldown || state.pingInProgress || state.rxAutoRunning; - - // RX Auto button - disabled when TX/RX Auto running (no cooldown restriction for RX-only mode) - rxAutoBtn.disabled = !connected || state.txRxAutoRunning; -} - -UPDATE updateAutoButton() function: - -function updateAutoButton() { - // Update TX/RX Auto button - if (state.txRxAutoRunning) { // Renamed from state.running - txRxAutoBtn.textContent = "Stop TX/RX"; - txRxAutoBtn. classList.remove("bg-indigo-600", "hover:bg-indigo-500"); - txRxAutoBtn.classList.add("bg-amber-600", "hover:bg-amber-500"); - } else { - txRxAutoBtn.textContent = "TX/RX Auto"; - txRxAutoBtn.classList.add("bg-indigo-600", "hover:bg-indigo-500"); - txRxAutoBtn.classList.remove("bg-amber-600", "hover:bg-amber-500"); - } - - // Update RX Auto button - if (state. rxAutoRunning) { - rxAutoBtn.textContent = "Stop RX"; - rxAutoBtn.classList. remove("bg-indigo-600", "hover:bg-indigo-500"); - rxAutoBtn.classList.add("bg-amber-600", "hover:bg-amber-500"); - } else { - rxAutoBtn.textContent = "RX Auto"; - rxAutoBtn.classList.add("bg-indigo-600", "hover:bg-indigo-500"); - rxAutoBtn.classList.remove("bg-amber-600", "hover:bg-amber-500"); - } -} - -#### 9. Update Page Visibility Handler - -**File**: `content/wardrive.js` - -UPDATE page visibility event listener: - -document.addEventListener("visibilitychange", async () => { - if (document.hidden) { - debugLog("[UI] Page visibility changed to hidden"); - - // Stop TX/RX Auto if running - if (state.txRxAutoRunning) { - debugLog("[UI] Stopping TX/RX Auto due to page hidden"); - stopAutoPing(true); // Ignore cooldown, stop GPS - setDynamicStatus("Lost focus, TX/RX Auto stopped", STATUS_COLORS.warning); - } - - // Stop RX Auto if running - if (state.rxAutoRunning) { - debugLog("[UI] Stopping RX Auto due to page hidden"); - stopRxAuto(); - setDynamicStatus("Lost focus, RX Auto stopped", STATUS_COLORS.warning); - } - - // Release wake lock if neither mode running - if (!state.txRxAutoRunning && !state. rxAutoRunning) { - debugLog("[UI] Releasing wake lock due to page hidden"); - releaseWakeLock(); - } - - // DO NOT stop unified listener - - } else { - debugLog("[UI] Page visibility changed to visible"); - - // Defensive check: ensure unified listener is running if connected - if (state.connection && !state.rxTracking.isListening) { - debugWarn("[UI] Page visible but unified listener inactive - restarting"); - startUnifiedRxListening(); - } - - // User must manually restart auto modes - } -}); - -#### 10. Update Disconnect Handler - -**File**: `content/wardrive.js` - -UPDATE disconnected event handler: - -conn.on("disconnected", () => { - debugLog("[BLE] BLE disconnected event fired"); - debugLog(`[BLE] Disconnect reason: ${state.disconnectReason}`); - - // ... set connection/dynamic status ... - - setConnectButton(false); - deviceInfoEl.textContent = "—"; - state.connection = null; - state.channel = null; - state.devicePublicKey = null; - state.wardriveSessionId = null; - state. disconnectReason = null; - state.channelSetupErrorMessage = null; - state.bleDisconnectErrorMessage = null; - - // Stop auto modes - stopAutoPing(true); // Ignore cooldown, stop GPS - stopRxAuto(); // Stop RX Auto - - enableControls(false); - updateAutoButton(); - stopGeoWatch(); - stopGpsAgeUpdater(); - stopTxTracking(); // Renamed from stopRepeaterTracking - - // Stop unified RX listening on disconnect - stopUnifiedRxListening(); - debugLog("[BLE] Unified RX listener stopped on disconnect"); - - // Flush all pending RX batch data - flushAllRxBatches('disconnect'); // Renamed from flushAllBatches - - // Clear API queue - apiQueue. messages = []; - debugLog("[API QUEUE] Queue cleared on disconnect"); - - // Clean up all timers - cleanupAllTimers(); - - // DO NOT clear logs on disconnect (preserve for user review) - // Logs are only cleared on connect - - state.lastFix = null; - state.lastSuccessfulPingLocation = null; - state.gpsState = "idle"; - updateGpsUi(); - updateDistanceUi(); - - debugLog("[BLE] Disconnect cleanup complete"); -}); - -#### 11. Add RX Auto Button to HTML - -**File**: `index.html` - -UPDATE ping controls section: - -
- - - -
- -#### 12. Add RX Auto Button Event Listener - -**File**: `content/wardrive.js` (in onLoad function) - -ADD event listener for RX Auto button: - -export async function onLoad() { - // ... existing initialization ... - - // Existing button listeners - connectBtn.addEventListener("click", async () => { /* ... */ }); - txPingBtn.addEventListener("click", () => { /* ... */ }); - txRxAutoBtn.addEventListener("click", () => { /* ... */ }); - - // NEW: RX Auto button listener - rxAutoBtn.addEventListener("click", () => { - debugLog("[UI] RX Auto button clicked"); - if (state.rxAutoRunning) { - stopRxAuto(); - } else { - startRxAuto(); - } - }); - - // ... rest of initialization ... -} - -#### 13. Add RX Auto Debug Tag to Documentation - -**File**: `docs/DEVELOPMENT_REQUIREMENTS.md` - -ADD to debug tag table: - -| Tag | Description | -|-----|-------------| -| `[TX/RX AUTO]` | TX/RX Auto mode operations | -| `[RX AUTO]` | RX Auto mode operations | - -#### 14. Update Status Messages Documentation - -**File**: `docs/STATUS_MESSAGES.md` - -ADD RX Auto status messages: - -##### RX Auto started -- **Message**: "RX Auto started" -- **Color**: Green (success) -- **When**: User clicks "RX Auto" button to start passive RX-only listening -- **Source**: `content/wardrive.js:startRxAuto()` - -##### RX Auto stopped -- **Message**: "RX Auto stopped" -- **Color**: Slate (idle) -- **When**: User clicks "Stop RX" button -- **Source**: `content/wardrive.js:stopRxAuto()` - -##### Lost focus, RX Auto stopped -- **Message**: "Lost focus, RX Auto stopped" -- **Color**: Amber (warning) -- **When**: Browser tab hidden while RX Auto mode running -- **Source**: `content/wardrive.js:visibilitychange handler` - -UPDATE existing status messages: -- "Lost focus, auto mode stopped" → "Lost focus, TX/RX Auto stopped" -- "Auto mode stopped" → "TX/RX Auto stopped" - -#### 15. Update Workflow Documentation - -**File**: `docs/PING_WORKFLOW.md` - -ADD new section "RX Auto Mode Workflow": - -## RX Auto Mode Workflow - -### Overview -RX Auto mode provides passive-only wardriving without transmitting on the mesh network. It listens for all mesh traffic and logs received packets to the RX Log, which are then batched and posted to MeshMapper API. - -### RX Auto Start Sequence -1. User clicks "RX Auto" button -2. Verify BLE connection active -3. Defensive check: ensure unified listener running -4. Set `state.rxTracking.isWardriving = true` -5. Set `state.rxAutoRunning = true` -6. Update button to "Stop RX" (amber) -7. Disable TX/RX Auto button (mutual exclusivity) -8. Acquire wake lock -9. Show "RX Auto started" status (green) - -### RX Auto Stop Sequence -1. User clicks "Stop RX" button -2. Set `state.rxTracking.isWardriving = false` -3. Set `state.rxAutoRunning = false` -4. Update button to "RX Auto" (indigo) -5. Re-enable TX/RX Auto button -6. Release wake lock -7. Show "RX Auto stopped" status (idle) - -### RX Auto Characteristics -- **Zero mesh TX** (no network impact) -- **No GPS requirement** to start -- **No cooldown restrictions** -- **Mutually exclusive** with TX/RX Auto mode -- **Unified listener stays on** (does not stop when mode stops) - -### Behavior Comparison - -| Feature | TX Ping | TX/RX Auto | RX Auto | -|---------|---------|------------|---------| -| Transmits | Yes (once) | Yes (auto) | No | -| TX Echo Tracking | Yes (7s) | Yes (per ping) | No | -| RX Wardriving | No | Yes | Yes | -| Mesh Load | Low | High | None | -| Cooldown | Yes (7s) | Yes (7s) | No | -| GPS Required | Yes | Yes | No | -| Wake Lock | No | Yes | Yes | -| Unified Listener | Always on | Always on | Always on | -| TX Tracking Flag | True (7s) | True (per ping) | False | -| RX Wardriving Flag | False | True | True | - -### Validation Requirements -- Unified listener must start on connect and stay on entire connection -- Unified listener only stops on disconnect -- RX wardriving flag controls whether packets are logged -- TX/RX Auto and RX Auto are mutually exclusive -- All logs cleared on connect, preserved on disconnect -- Defensive checks ensure listener stays active -- startUnifiedRxListening() is idempotent (safe to call multiple times) - ---- - -## Development Guidelines Compliance - -### Debug Logging -- **ALWAYS** include debug logging for significant operations -- Use proper debug tags: - - `[RX PARSE]` for metadata parsing - - `[TX LOG]` for TX logging operations (renamed from [SESSION LOG]) - - `[RX LOG]` for RX logging operations (renamed from [PASSIVE RX]) - - `[TX/RX AUTO]` for TX/RX Auto mode (renamed from [AUTO]) - - `[RX AUTO]` for RX Auto mode (new) - - `[UNIFIED RX]` for unified listener operations -- Log at key points: function entry, state changes, routing decisions, errors - -### Status Messages -- Update `STATUS_MESSAGES.md` with all new status messages -- Use `setDynamicStatus()` for all UI status updates -- Use appropriate `STATUS_COLORS` constants - -### Documentation Updates -When modifying connection, disconnect, or ping logic: -- Read relevant workflow docs before making changes -- Update workflow docs to remain accurate after changes -- Document new modes, states, behaviors -- Update function references, state variables, button labels - -### Code Comments -- Document complex logic with inline comments -- Use JSDoc-style comments for new functions -- Update existing JSDoc when function signatures change -- Explain defensive checks and idempotent patterns - ---- - -## Testing Recommendations - -Since this is a browser-based PWA with no automated tests, perform thorough manual testing: - -### Connection Testing -- [ ] Connect to device - unified listener starts immediately -- [ ] Check debug log confirms listener started -- [ ] Verify all logs cleared on connect -- [ ] Disconnect - listener stops -- [ ] Reconnect - listener restarts - -### TX Ping Testing -- [ ] Single TX Ping works -- [ ] TX log shows echoes -- [ ] Debug data shows correct parsed_path -- [ ] RX wardriving stays OFF during TX Ping - -### TX/RX Auto Testing -- [ ] Start TX/RX Auto - both TX and RX wardriving active -- [ ] TX pings send automatically -- [ ] RX observations logged continuously -- [ ] RX Auto button disabled during TX/RX Auto -- [ ] Stop TX/RX Auto - both modes stop, listener stays on -- [ ] Unified listener still receiving events - -### RX Auto Testing -- [ ] Start RX Auto - only RX wardriving active -- [ ] No TX transmissions -- [ ] RX observations logged continuously -- [ ] TX/RX Auto button disabled during RX Auto -- [ ] Stop RX Auto - RX wardriving stops, listener stays on -- [ ] Unified listener still receiving events - -### Mutual Exclusivity Testing -- [ ] Cannot start TX/RX Auto when RX Auto running -- [ ] Cannot start RX Auto when TX/RX Auto running -- [ ] Buttons properly disabled/enabled - -### Edge Case Testing -- [ ] Switch browser tab away - modes stop, listener stays on -- [ ] Switch browser tab back - listener still active -- [ ] Disconnect during TX/RX Auto - clean shutdown -- [ ] Disconnect during RX Auto - clean shutdown -- [ ] Multiple connect/disconnect cycles - no memory leaks - -### Debug Mode Testing (with `? debug=true`) -- [ ] TX debug data shows correct parsed_path (actual raw bytes) -- [ ] RX debug data shows correct parsed_path (actual raw bytes) -- [ ] parsed_path matches repeaterId for TX (first hop) -- [ ] parsed_path matches repeaterId for RX (last hop) - -### Log Clearing Testing -- [ ] All logs clear on connect -- [ ] All logs preserved on disconnect -- [ ] User can review RX data after disconnecting - ---- - -## Summary - -This comprehensive refactor accomplishes three major improvements: - -1. **Unified RX Parsing**: Single parsing point eliminates duplication, improves performance, and fixes debug data accuracy -2. **Naming Standardization**: Consistent TX/RX terminology throughout codebase improves maintainability and clarity -3. **RX Auto Mode**: New passive-only wardriving mode with always-on unified listener architecture - -**Key architectural changes**: -- Unified RX listener always on when connected (never stops for mode changes) -- RX wardriving controlled by subscription flag (not listener lifecycle) -- Three distinct modes: TX Ping (manual), TX/RX Auto (active + passive), RX Auto (passive only) -- Defensive checks ensure listener stays active across edge cases -- Single metadata parsing eliminates duplication and inconsistency - -**User-facing improvements**: -- Clear TX/RX button labels -- New RX Auto mode for zero-impact wardriving -- Consistent log naming (TX Log, RX Log) -- Logs preserved on disconnect for review - -**Developer improvements**: -- Consistent naming conventions -- Single source of truth for packet parsing -- Idempotent functions prevent double-initialization -- Comprehensive debug logging -- Well-documented behavior \ No newline at end of file diff --git a/docs/DEVELOPMENT_REQUIREMENTS.md b/docs/DEVELOPMENT_REQUIREMENTS.md index a873f90..69d2550 100644 --- a/docs/DEVELOPMENT_REQUIREMENTS.md +++ b/docs/DEVELOPMENT_REQUIREMENTS.md @@ -29,7 +29,7 @@ All debug log messages **MUST** include a descriptive tag in square brackets imm | `[BLE]` | Bluetooth connection and device communication | | `[GPS]` | GPS/geolocation operations | | `[PING]` | Ping sending and validation | -| `[API QUEUE]` | API batch queue operations | +| `[WARDRIVE QUEUE]` | Wardrive batch queue operations | | `[RX BATCH]` | RX batch buffer operations | | `[RX LOG]` | RX logging logic | | `[RX LOG UI]` | RX UI rendering | @@ -47,6 +47,9 @@ All debug log messages **MUST** include a descriptive tag in square brackets imm | `[RX AUTO]` | RX Auto mode operations | | `[INIT]` | Initialization and setup | | `[ERROR LOG]` | Error log UI operations | +| `[AUTH]` | Authentication API operations | +| `[HEARTBEAT]` | Session heartbeat operations | +| `[WARDRIVE API]` | Wardrive API error handling | **Examples:** ```javascript diff --git a/docs/DEVICE_MODEL_MAPPING.md b/docs/DEVICE_MODEL_MAPPING.md new file mode 100644 index 0000000..6098680 --- /dev/null +++ b/docs/DEVICE_MODEL_MAPPING.md @@ -0,0 +1,448 @@ +# Device Model Mapping & Auto-Power Selection + +## Overview + +The MeshCore GOME WarDriver implements automatic power level selection based on detected device models. This feature ensures that users transmit at the correct power level for their specific hardware variant, particularly important for devices with Power Amplifiers (PAs) that require specific input power to avoid hardware damage. + +**Key Changes (2026-01-06)**: +- Connection no longer requires pre-selecting power (auto-configured after deviceQuery) +- Settings lock moved from connection time to auto mode start +- Unknown devices require manual power selection (no default value) +- 2.0w power option added for 2W PA devices (33dBm output) + +## Architecture + +### Components + +1. **Device Model Database** (`content/device-models.json`) + - JSON file containing 32+ supported MeshCore device variants + - Includes manufacturer strings, short names, recommended power settings (0.3w, 0.6w, 1.0w, 2.0w), and hardware notes + - Generated from MeshCore firmware repository platformio.ini and Board.h files + +2. **Device Model Parser** (`wardrive.js:parseDeviceModel()`) + - Strips build suffixes (e.g., "nightly-e31c46f") from full manufacturer strings + - Enables consistent matching regardless of firmware build version + +3. **Device Lookup** (`wardrive.js:findDeviceConfig()`) + - Searches database for exact or partial manufacturer string match + - Returns device configuration with recommended power level or null if not found + +4. **Auto-Power Configurator** (`wardrive.js:autoSetPowerLevel()`) + - Called during connection flow after deviceQuery() succeeds + - **If device found**: Automatically selects matching power radio button, shows "⚡ Auto" label, enables ping controls + - **If device unknown**: Logs error, displays "Unknown device - select power manually" status, leaves ping controls disabled + - Tracks auto-set state for override confirmation + +5. **Model Display** + - Updates connection bar with `shortName` from database (e.g., "Ikoka Stick 1w") + - Shows "Unknown" for unrecognized devices + +## Data Flow + +### Connection Workflow Integration + +``` +1. User clicks "Connect" (no power pre-selection required) +2. BLE GATT connection established +3. Protocol handshake complete +4. deviceQuery() called → Full manufacturer string retrieved + Example: "Ikoka Stick-E22-30dBm (Xiao_nrf52)nightly-e31c46f" + +5. **Device Model Auto-Power** (occurs after deviceQuery, before capacity check): + a. Store full model in state.deviceModel + b. Call autoSetPowerLevel(): + - Parse model string (strip build suffix) + - Lookup in DEVICE_MODELS database + + **Known Device Path**: + - Select power radio button (0.3w/0.6w/1.0w/2.0w) + - Update label: "Radio Power ⚡ Auto" + - Display shortName in connection bar + - Show status: "Auto-configured: [shortName] at X.Xw" + - Enable ping controls (updateControlsForCooldown) + - Set state.autoPowerSet = true + + **Unknown Device Path**: + - Leave power unselected + - Display "Unknown" in connection bar + - Log error: "Unknown device: [full model string]" + - Show status: "Unknown device - select power manually" (persistent) + - Ping controls remain disabled + - Set state.autoPowerSet = false + +6. Continue normal connection flow (capacity check, channel setup, GPS init) +7. Connection complete - settings remain UNLOCKED until auto mode starts +``` + +### Settings Lock Behavior (NEW) + +**Previous Behavior**: Settings locked on connection completion +**New Behavior**: Settings lock only during auto mode + +| State | Power Changeable? | Override Confirmation? | +|-------|-------------------|------------------------| +| Disconnected | ✅ Yes | No | +| Connected (idle) | ✅ Yes | ✅ Yes (if auto-set) | +| Auto Mode Running | ❌ No (locked) | N/A (disabled) | +| After Auto Stop | ✅ Yes | ✅ Yes (if auto-set) | + +**Lock Timing**: +- `lockWardriveSettings()` called in `startAutoPing()` before enabling RX wardriving +- `unlockWardriveSettings()` called in `stopAutoPing()` after releasing wake lock +- `unlockWardriveSettings()` called in `disconnect()` cleanup + +### Override Confirmation Flow + +When user changes power after auto-configuration (while connected but NOT in auto mode): + +``` +1. User clicks different power radio button +2. Event listener checks: state.autoPowerSet === true && !settingsLocked +3. Show confirm() dialog: "Are you sure you want to override the auto-configured power setting?" +4a. If CANCELED: Revert to previous radio selection +4b. If CONFIRMED: + - Set state.autoPowerSet = false + - Clear "⚡ Auto" label (remove text and class) + - Allow change to proceed +5. If unknown device (autoPowerSet === false): Clear "Unknown device" status → "Idle" +6. updateControlsForCooldown() to re-enable ping buttons +``` + +### Build Suffix Handling + +**Problem**: MeshCore firmware appends git commit hash at build time +- Example: `"Ikoka Stick-E22-30dBm (Xiao_nrf52)nightly-e31c46f"` + +**Solution**: `parseDeviceModel()` strips suffix using regex +- Pattern: `/(nightly|release|dev)-[a-f0-9]+$/i` +- Result: `"Ikoka Stick-E22-30dBm (Xiao_nrf52)"` + +**Why This Matters**: +- Database contains clean base strings (without build suffixes) +- Matching works consistently across firmware versions +- UI displays clean short names without git hashes + +## Device Model Database Format + +### JSON Structure + +```json +{ + "version": "1.0.0", + "generated": "2026-01-04", + "source": "MeshCore firmware repository", + "devices": [ + { + "manufacturer": "Ikoka Stick-E22-30dBm (Xiao_nrf52)", + "shortName": "Ikoka Stick-E22-30dBm", + "power": 1.0, + "platform": "nrf52", + "txPower": 20, + "notes": "EBYTE E22-900M30S, 1W PA: 20dBm input → 30dBm output" + } + ], + "powerMapping": { ... }, + "notes": [ ... ] +} +``` + +### Field Definitions + +| Field | Type | Description | +|-------|------|-------------| +| `manufacturer` | string | Full manufacturer string (without build suffix) | +| `shortName` | string | Clean display name for UI | +| `power` | number | Wardrive.js radio power setting (0.05-2.0) | +| `platform` | string | Hardware platform (nrf52, esp32, stm32, rp2040) | +| `txPower` | number | Firmware LORA_TX_POWER value in dBm | +| `notes` | string | Hardware details, PA info, safety warnings | + +### Power Level Mapping + +| Wardrive Setting | Firmware dBm | Output Power | Use Case | +|------------------|--------------|--------------|----------| +| 0.3w | ≤24 dBm | Standard | Standard devices without PA | +| 0.6w | 10 dBm | 28 dBm | Heltec V4, Heltec Tracker V2 (firmware 10dBm, PA amplifies to 22dBm actual) | +| 1.0w | 20 dBm | 30 dBm | 1W PA modules (E22-900M30S): 20dBm input → 30dBm output | +| 2.0w | 9 dBm | 33 dBm | 2W PA modules (E22-900M33S): 9dBm input → 33dBm output | + +### Critical Power Amplifier Cases + +### Ikoka 33dBm Models (2W PA) - CRITICAL SAFETY + +**Devices**: +- Ikoka Stick-E22-33dBm (Ikoka Stick 2w) +- Ikoka Nano-E22-33dBm (Ikoka Nano 2w) + +**Auto-configured**: Power 2.0w (9dBm firmware input) +- Radio module: EBYTE E22-900M33S +- PA amplification: 9dBm → 33dBm (2W output) +- **CRITICAL**: Higher input power causes hardware damage to PA amplifier +- Firmware safety limit enforced in platformio.ini + +### Ikoka 30dBm Models (1W PA) + +**Devices**: +- Ikoka Stick-E22-30dBm +- Ikoka Nano-E22-30dBm +- Ikoka Handheld E22 30dBm + +**Recommended**: Power 1.0 (20dBm firmware input) +- Radio module: EBYTE E22-900M30S +- PA amplification: 20dBm → 30dBm (1W output) +- **Higher input causes distortion** +- Firmware comment: "limit txpower to 20dBm on E22-900M30S" + +### Standard 22dBm Models + +**Most devices**: Power 2.0 (22dBm firmware) +- No PA amplifier +- Direct RF output from LoRa chip +- Safe to use maximum power + +## Implementation Details + +### State Management + +```javascript +state = { + deviceModel: null, // Full manufacturer string from device + autoPowerSet: false, // Track if power was automatically configured + // ... other state fields +} +``` + +### Global Variables + +```javascript +DEVICE_MODELS = null; // Array of device configs from JSON +deviceModelsLoaded = false; // Loading state flag +``` + +### Function Summary + +#### `loadDeviceModels()` - Async +**When**: Called on page load in `onLoad()` +**Purpose**: Fetch and parse device-models.json +**Returns**: Boolean success/failure + +#### `parseDeviceModel(fullModel)` - Sync +**Input**: Full manufacturer string (may include build suffix) +**Output**: Cleaned string for database matching +**Example**: +- Input: `"Ikoka Stick-E22-30dBm (Xiao_nrf52)nightly-e31c46f"` +- Output: `"Ikoka Stick-E22-30dBm (Xiao_nrf52)"` + +#### `findDeviceConfig(manufacturerString)` - Sync +**Input**: Manufacturer string from deviceQuery() +**Output**: Device config object or null +**Logic**: +1. Strip build suffix via parseDeviceModel() +2. Try exact match in DEVICE_MODELS array +3. Fall back to partial match (case-insensitive) +4. Return null if no match + +#### `updateDeviceModelDisplay(manufacturerString)` - Sync +**Input**: Full manufacturer string +**Output**: None (updates UI) +**DOM Target**: `#deviceModel` in Settings panel +**Logic**: +- If device in database → show `shortName` +- If unknown → show full string as-is + +#### `autoSetPowerLevel(manufacturerString)` - Sync +**Input**: Manufacturer string from deviceQuery() +**Output**: Boolean (true if power was auto-set) +**When**: Called during connection flow after deviceQuery() +**Logic**: +1. Look up device config +2. If unknown → return false (use manual selection) +3. Map config.power to select option value +4. Set powerSelect.value +5. Mark state.autoPowerSet = true +6. Return true + +### Error Handling + +**JSON Load Failure**: +- Logged as error with full stack trace +- DEVICE_MODELS set to empty array +- Connection continues without auto-power feature +- User must manually select power (existing behavior) + +**Unknown Device**: +- Full manufacturer string shown in UI +- No auto-power selection +- User manually selects power (existing behavior) +- Logged as warning with device string + +**Invalid Power Value**: +- Logged as warning +- Returns false (no auto-set) +- User manually selects power + +## Database Maintenance + +### Adding New Devices + +1. **Locate firmware source**: + - For devices with MANUFACTURER_STRING in platformio.ini: + - File: `variants/{VARIANT_NAME}/platformio.ini` + - Extract: `MANUFACTURER_STRING` and `LORA_TX_POWER` + + - For devices with hardcoded strings: + - File: `variants/{VARIANT_NAME}/*Board.h` + - Extract: `getManufacturerName()` return value + - Check platformio.ini for `LORA_TX_POWER` + +2. **Determine power mapping**: + - Check for PA comments in platformio.ini + - E22-900M33S (2W PA) → 9dBm → power: 0.05 + - E22-900M30S (1W PA) → 20dBm → power: 1.0 + - Standard modules → 22dBm → power: 2.0 + +3. **Add to device-models.json**: + ```json + { + "manufacturer": "Full String (no build suffix)", + "shortName": "Short Display Name", + "power": 1.0, + "platform": "nrf52", + "txPower": 20, + "notes": "Hardware details" + } + ``` + +4. **Test**: + - Connect to device + - Check DevTools console for `[DEVICE MODEL]` logs + - Verify power auto-selection + - Confirm Settings panel shows short name + +### Updating Existing Devices + +**When firmware changes**: +- Check if MANUFACTURER_STRING or LORA_TX_POWER changed +- Update device-models.json accordingly +- Test with device if available + +**Version tracking**: +- Update `version` field in JSON (semantic versioning) +- Update `generated` field with current date + +## Debug Logging + +All device model operations log with `[DEVICE MODEL]` tag: + +```javascript +debugLog("[DEVICE MODEL] Loading device models from device-models.json"); +debugLog("[DEVICE MODEL] ✅ Loaded 32 device models"); +debugLog("[DEVICE MODEL] Parsed model: \"Full String\" → \"Clean String\""); +debugLog("[DEVICE MODEL] ✅ Exact match found: \"Model Name\""); +debugLog("[DEVICE MODEL] ❌ No match found for: \"Unknown Model\""); +debugLog("[DEVICE MODEL] ✅ Auto-set power to 1.0 (20dBm firmware) for \"Device Name\""); +``` + +Enable debug mode: Add `?debug=true` to URL or set `DEBUG_ENABLED = true` in code. + +## User Experience + +### With Known Device + +1. User connects → device detected +2. Settings panel shows clean short name +3. Power automatically set to recommended value +4. User can manually override if desired (selection works normally) +5. On disconnect → auto-power flag clears + +### With Unknown Device + +1. User connects → device detected +2. Settings panel shows full manufacturer string (with build suffix) +3. No auto-power selection +4. User must manually select power (existing behavior) +5. Warning logged in console for debugging + +### Manual Override Behavior + +**Current Implementation**: +- Auto-power only sets value during connection +- User can manually change power after connection +- Manual selection takes precedence for that session +- On reconnect → auto-power runs again (resets to recommended) + +**Future Enhancement** (not implemented): +- Could track user preference in localStorage +- Could show "(auto)" indicator in UI when power is auto-set +- Could ask user confirmation before overriding manual selection + +## Testing Checklist + +### Connection Flow +- [ ] Load page → device-models.json fetched successfully +- [ ] Connect to known device → power auto-set +- [ ] Connect to unknown device → no auto-set, manual selection works +- [ ] Settings panel shows correct model display (short name or full string) +- [ ] Disconnect → state clears, autoPowerSet flag resets + +### Power Selection +- [ ] 33dBm device → auto-selects 0.05 +- [ ] 30dBm device → auto-selects 1.0 +- [ ] 22dBm device → auto-selects 2.0 +- [ ] Unknown device → no auto-select, manual works +- [ ] Manual override after auto-set → selection works + +### Error Conditions +- [ ] JSON load failure → logged, feature disabled gracefully +- [ ] Invalid device config → logged, no crash +- [ ] Missing power select element → logged, no crash +- [ ] Malformed manufacturer string → parsed safely + +### Debug Logging +- [ ] All operations log with [DEVICE MODEL] tag +- [ ] Success/failure states clearly indicated (✅/❌/⚠️) +- [ ] Full context in logs for troubleshooting + +## Future Enhancements + +1. **User Preference Persistence**: + - Store manual power overrides in localStorage + - Key by device public key or manufacturer string + - Honor user preference on reconnect + +2. **UI Power Indicator**: + - Show "(auto)" badge when power is auto-set + - Visual indicator for manual override + - Tooltip explaining recommended vs custom power + +3. **Database Version Check**: + - Add API endpoint to check for database updates + - Notify user if new devices are available + - Auto-refresh database from server + +4. **Device Alias Support**: + - Allow users to set custom display names + - Store aliases in localStorage + - Show alias instead of manufacturer string + +5. **Power Level Education**: + - Add help text explaining PA amplifiers + - Warning modal for dangerous power levels + - Link to hardware documentation + +## References + +- MeshCore Firmware: https://github.com/meshcore-dev/MeshCore +- Ikoka Stick platformio.ini: `/variants/ikoka_stick_nrf/platformio.ini` +- Ikoka Nano platformio.ini: `/variants/ikoka_nano_nrf/platformio.ini` +- Board.h pattern: `/variants/*/[BoardName]Board.h` +- EBYTE E22 Datasheets: Various (check hardware specs) + +## Changelog + +### v1.0.0 (2026-01-04) +- Initial implementation of device model mapping +- Database created from MeshCore firmware repository +- Auto-power selection integrated into connection flow +- Support for 32+ device variants across 4 platforms +- Critical safety handling for PA amplifier models diff --git a/docs/FLOW_WARDRIVE_API_QUEUE_DIAGRAM.md b/docs/FLOW_WARDRIVE_API_QUEUE_DIAGRAM.md index b52a23e..9f96813 100644 --- a/docs/FLOW_WARDRIVE_API_QUEUE_DIAGRAM.md +++ b/docs/FLOW_WARDRIVE_API_QUEUE_DIAGRAM.md @@ -1,4 +1,4 @@ -# MESHMAPPER API QUEUE SYSTEM +# MESHMAPPER WARDRIVE QUEUE SYSTEM ```diagram ┌─────────────────────────────────┐ ┌─────────────────────────────────┐ @@ -13,40 +13,50 @@ │ │ ▼ │ ┌─────────────────┐ │ - │ Ping sent │ │ - │ + GPS coords │ │ + │ Capture at │ │ + │ ping time: │ │ + │ • lat/lon │ │ + │ • noisefloor │ │ + │ • timestamp │ │ └────────┬────────┘ │ │ │ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ - │ queueApiMessage │ │ queueApiMessage │ - │ (type: "TX") │ │ (type: "RX") │ + │ queueTxEntry() │ │ queueRxEntry() │ + │ Entry-only: │ │ Entry-only: │ │ │ │ │ + │ • type: "TX" │ │ • type: "RX" │ │ • lat/lon │ │ • lat/lon │ - │ • who │ │ • who │ - │ • power │ │ • power │ - │ • heard │ │ • heard │ - │ • session_id │ │ • session_id │ + │ • noisefloor │ │ • noisefloor │ + │ • heard_repeats │ │ • heard_repeats │ + │ • timestamp │ │ • timestamp │ + │ • debug_data? │ │ • debug_data? │ └────────┬────────┘ └────────┬────────┘ │ │ │ ┌──────────────────────────────────┘ │ │ ▼ ▼ + ┌─────────────────┐ + │queueWardriveEntry│ + │ (push to queue) │ + └────────┬────────┘ + │ + ▼ ┌───────────────────────────────────────────────────────────────────────────────────────┐ │ │ │ ┌─────────────────────────────────────────────────────────────────────────────┐ │ -│ │ API QUEUE (apiQueue. messages) │ │ +│ │ WARDRIVE QUEUE (wardriveQueue.messages) │ │ │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ -│ │ │ TX │ │ RX │ │ RX │ │ TX │ │ RX │ ... │ ?? │ max: 50 │ │ -│ │ │ msg1 │ │ msg2 │ │ msg3 │ │ msg4 │ │ msg5 │ │msg50 │ │ │ +│ │ │ TX │ │ RX │ │ RX │ │ TX │ │ RX │ ... │ ?? │ max: 50 │ │ +│ │ │entry1│ │entry2│ │entry3│ │entry4│ │entry5│ │entry50 │ │ │ │ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ │ │ │ │ QUEUE STATE: │ -│ • messages: [] ─── Array of pending payloads │ +│ • messages: [] ─── Array of entry-only payloads │ │ • flushTimerId: null ─── 30s periodic timer │ │ • txFlushTimerId: null ─── 3s TX flush timer │ -│ • isProcessing: false ─── Lock to prevent concurrent flushes │ +│ • isProcessing: false ─── Lock to prevent concurrent submissions │ │ │ └───────────────────────────────────────────────────────────────────────────────────────┘ │ @@ -60,14 +70,14 @@ ▼ ▼ ▼ ▼ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │ │ │ │ │ │ │ │ -│ TX PING QUEUED │ │ 30s PERIODIC │ │ QUEUE SIZE = 50 │ │ disconnect() │ +│ TX ENTRY QUEUED │ │ 30s PERIODIC │ │ QUEUE SIZE = 50 │ │ disconnect() │ │ │ │ │ │ │ │ │ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │ │ Start/Reset │ │ │ │ setInterval │ │ │ │ Immediate │ │ │ │ Flush before │ │ -│ │ 3s timer │ │ │ │ (30000ms) │ │ │ │ flush │ │ │ │ capacity │ │ +│ │ 3s timer │ │ │ │ (30000ms) │ │ │ │ submit │ │ │ │ session │ │ │ └───────────────┘ │ │ └───────────────┘ │ │ └───────────────┘ │ │ │ release │ │ │ │ │ │ │ │ │ └───────────────┘ │ -│ Real-time map │ │ Catches RX msgs │ │ Batch limit │ │ │ +│ Real-time map │ │ Catches RX entries │ │ Batch limit │ │ │ │ updates for your │ │ when no TX pings │ │ protection │ │ Clean shutdown │ │ ping locations │ │ happening │ │ │ │ │ │ │ │ │ │ │ │ │ @@ -77,100 +87,178 @@ │ ▼ ┌─────────────────────────┐ - │ flushApiQueue() │ + │ submitWardriveData() │ │ │ │ 1. Check isProcessing │ - │ 2. Set isProcessing │ - │ 3. Grab & clear queue │ - │ 4. POST batch to API │ - │ 5. Handle response │ - │ 6. Clear isProcessing │ + │ 2. Validate session_id │ + │ 3. Set isProcessing │ + │ 4. Grab & clear queue │ + │ 5. Build wrapper: │ + │ {key, session_id, │ + │ data: [entries]} │ + │ 6. POST to API (retry) │ + │ 7. Handle response │ + │ 8. Schedule heartbeat │ + │ 9. Clear isProcessing │ └────────────┬────────────┘ │ ▼ ┌─────────────────────────┐ - │ MESHMAPPER API │ + │ WARDRIVE API │ + │ /wardrive endpoint │ │ │ - │ POST [ │ - │ {TX, lat, lon... }, │ - │ {RX, lat, lon...}, │ - │ {RX, lat, lon...}, │ - │ ... │ - │ ] │ + │ POST { │ + │ key: "...", │ + │ session_id: "...", │ + │ data: [ │ + │ {type:"TX",...}, │ + │ {type:"RX",...}, │ + │ ... │ + │ ] │ + │ } │ │ │ - │ Max: 50 per request │ + │ Max: 50 entries │ └────────────┬────────────┘ │ ┌─────────────────┴─────────────────┐ ▼ ▼ ┌─────────────────────┐ ┌─────────────────────┐ - │ allowed: true │ │ allowed: false │ + │ success: true │ │ success: false │ │ │ │ │ - │ ✓ Success │ │ ✗ Slot Revoked │ - │ ✓ Continue │ │ ✗ Stop timers │ - │ │ │ ✗ Disconnect │ + │ + expires_at │ │ + reason code │ + │ → Schedule │ │ → handleWardriveApiError() + │ heartbeat │ │ │ + │ ✓ Continue │ │ Session errors: │ + │ │ │ → Disconnect │ + │ │ │ │ + │ │ │ Rate limit: │ + │ │ │ → Warning only │ └─────────────────────┘ └─────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ -│ TIMING EXAMPLES │ +│ HEARTBEAT SYSTEM │ +└─────────────────────────────────────────────────────────────────────────────────────────┘ + + Session acquired (auth) + │ + ├─► expires_at returned + │ + ▼ + ┌──────────────────┐ + │ scheduleHeartbeat│ + │ (expires_at) │ + └────────┬─────────┘ + │ + │ 5 minutes before expiry + │ (HEARTBEAT_BUFFER_MS) + │ + ▼ + ┌──────────────────┐ + │ sendHeartbeat() │ + │ │ + │ POST { │ + │ key, session_id│ + │ heartbeat:true │ + │ coords:{...} │ + │ } │ + └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ Response: │ + │ success: true │ + │ + new expires_at │ + └────────┬─────────┘ + │ + └─────────► scheduleHeartbeat() again + (continuous loop while connected) + + +┌─────────────────────────────────────────────────────────────────────────────────────────┐ +│ ERROR HANDLING │ +└─────────────────────────────────────────────────────────────────────────────────────────┘ + + submitWardriveData() or sendHeartbeat() + │ + ▼ + ┌──────────────────┐ + │ success: false │ + │ + reason code │ + └────────┬─────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────────────────┐ + │ handleWardriveApiError(reason, message) │ + │ │ + │ ┌────────────────────┬────────────────────────────────────┐ │ + │ │ Reason Code │ Action │ │ + │ ├────────────────────┼────────────────────────────────────┤ │ + │ │ session_expired │ → Disconnect │ │ + │ │ session_invalid │ → Disconnect │ │ + │ │ session_revoked │ → Disconnect │ │ + │ │ invalid_key │ → Disconnect │ │ + │ │ unauthorized │ → Disconnect │ │ + │ │ session_id_missing │ → Disconnect │ │ + │ │ rate_limited │ → Warning only (no disconnect) │ │ + │ │ (unknown) │ → Show error (no disconnect) │ │ + │ └────────────────────┴────────────────────────────────────┘ │ + │ │ + └────────────────────────────────────────────────────────────────┘ + + +┌─────────────────────────────────────────────────────────────────────────────────────────┐ +│ RETRY LOGIC │ └─────────────────────────────────────────────────────────────────────────────────────────┘ -Example 1: TX triggers fast flush, nearby RX messages ride along -──────────────────────────────────────────────────────────────── - 0s 1s 2s 3s 4s - │ │ │ │ │ - │ RX │ │ │ │ - │ (heard) │ │ │ - │ │ TX │ │ │ - │ │ (ping) │ │ │ - │ │ │ │ │ │ - │ │ └────┼─────────┼─►FLUSH │ - │ │ │ │(TX+RX) │ - └─────────┴─────────┴─────────┴─────────┘ - 3 second delay from TX - - -Example 2: RX only (no pings) - 30s periodic flush -────────────────────────────────────────────────── - 0s 10s 20s 30s - │ │ │ │ - RX────────┼─────────┼─────────┼─►FLUSH - │ RX │ RX │ │ (3x RX) - │ │ RX │ │ - │ │ │ │ - └─────────┴─────────┴─────────┘ - (listening continuously, no TX pings sent) - - -Example 3: Busy session - multiple TX pings with RX traffic -─────────────────────────────────────────────────────────── - 0s 3s 6s 9s 12s - │ │ │ │ │ - TX────────┼─►FLUSH │ │ │ - │ RX │ (TX+RX) │ │ │ - │ │ TX────────┼─►FLUSH │ - │ │ │ RX │ (TX+RX) │ - │ │ │ RX │ │ - └─────────┴─────────┴─────────┴─────────┘ - - -Example 4: Disconnect flushes everything -──────────────────────────────────────── - 0s 1s 2s - │ │ │ - TX────────┼─────────┤ - │ RX │ RX │ - │ │ disconnect() - │ │ │ - │ │ ▼ - │ │ FLUSH (TX + 2x RX) ◄── session_id still valid - │ │ │ - │ │ ▼ - │ │ checkCapacity("disconnect") ◄── releases slot - │ │ │ - │ │ ▼ - │ │ BLE cleanup - └─────────┴─────────┘ + submitWardriveData() / sendHeartbeat() + │ + ▼ + ┌──────────────────┐ + │ Attempt 1 │ + │ POST to API │ + └────────┬─────────┘ + │ + ┌────┴────┐ + │ Success?│ + └────┬────┘ + │ + YES │ NO (network error) + │ │ │ + │ │ ▼ + │ │ ┌──────────────────┐ + │ │ │ Wait 2 seconds │ + │ │ │ (WARDRIVE_RETRY_ │ + │ │ │ DELAY_MS) │ + │ │ └────────┬─────────┘ + │ │ │ + │ │ ▼ + │ │ ┌──────────────────┐ + │ │ │ Attempt 2 │ + │ │ │ POST to API │ + │ │ └────────┬─────────┘ + │ │ │ + │ │ ┌────┴────┐ + │ │ │ Success?│ + │ │ └────┬────┘ + │ │ │ + │ │ YES │ NO + │ │ │ │ │ + │ │ │ │ ▼ + │ │ │ │ ┌──────────────────┐ + │ │ │ │ │ Re-queue entries │ + │ │ │ │ │ for next attempt │ + │ │ │ │ │ (data submission)│ + │ │ │ │ │ │ + │ │ │ │ │ OR │ + │ │ │ │ │ │ + │ │ │ │ │ Log error │ + │ │ │ │ │ (heartbeat) │ + │ │ │ │ └──────────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ + ┌──────────────────────┐ + │ Continue operation │ + └──────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ @@ -184,7 +272,7 @@ Example 4: Disconnect flushes everything │ ▼ ┌──────────────────┐ ┌─────────────────────────────────────┐ - │ Queue empty? │─NO─►│ flushApiQueue() │ + │ Queue empty? │─NO─►│ submitWardriveData() │ └────────┬─────────┘ │ • session_id still valid ✓ │ │ │ • POST all pending TX + RX │ YES └──────────────────┬──────────────────┘ @@ -193,14 +281,19 @@ Example 4: Disconnect flushes everything │ ▼ ┌──────────────────┐ - │ stopFlushTimers()│ + │stopWardriveTimers│ │ • Clear 30s timer│ │ • Clear TX timer │ └────────┬─────────┘ │ ▼ ┌──────────────────┐ - │ checkCapacity │ + │ cancelHeartbeat()│◄─── Stop heartbeat timer + └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ requestAuth │ │ ("disconnect") │◄─── Releases session_id / slot └────────┬─────────┘ │ @@ -210,4 +303,96 @@ Example 4: Disconnect flushes everything │ State cleanup │ │ UI updates │ └──────────────────┘ - ``` \ No newline at end of file +``` + +## Entry Payload Structure + +Each entry in the queue contains only the wardrive data. The wrapper (key, session_id) is added by `submitWardriveData()`: + +### TX Entry + +```json +{ + "type": "TX", + "lat": 45.12345, + "lon": -75.12345, + "noisefloor": -110, + "heard_repeats": "4e(1.75),b7(-0.75)", + "timestamp": 1704654321, + "debug_data": [...] +} +``` + +### RX Entry + +```json +{ + "type": "RX", + "lat": 45.12345, + "lon": -75.12345, + "noisefloor": -110, + "heard_repeats": "4e(12.0)", + "timestamp": 1704654321, + "debug_data": {...} +} +``` + +### Submission Wrapper + +```json +{ + "key": "api_key_here", + "session_id": "uuid-session-id", + "data": [ + { "type": "TX", "lat": ..., "lon": ..., ... }, + { "type": "RX", "lat": ..., "lon": ..., ... } + ] +} +``` + +### Heartbeat Payload + +```json +{ + "key": "api_key_here", + "session_id": "uuid-session-id", + "heartbeat": true, + "coords": { + "lat": 45.12345, + "lon": -75.12345, + "timestamp": 1704654321 + } +} +``` + +## API Response Format + +### Success Response + +```json +{ + "success": true, + "expires_at": 1704657921 +} +``` + +### Error Response + +```json +{ + "success": false, + "reason": "session_expired", + "message": "Session has expired, please reconnect" +} +``` + +## Constants + +| Constant | Value | Description | +|----------|-------|-------------| +| `API_BATCH_MAX_SIZE` | 50 | Maximum entries per submission | +| `API_TX_FLUSH_DELAY_MS` | 3000 | Delay after TX before submission | +| `API_BATCH_FLUSH_INTERVAL_MS` | 30000 | Periodic submission interval | +| `HEARTBEAT_BUFFER_MS` | 300000 | 5 minutes before session expiry | +| `WARDRIVE_RETRY_DELAY_MS` | 2000 | Delay between retry attempts | +| `WARDRIVE_ENDPOINT` | `https://meshmapper.net/wardrive-api.php/wardrive` | API endpoint | diff --git a/docs/GEO_AUTH_DESIGN.md b/docs/GEO_AUTH_DESIGN.md index e8a75ce..ef7fad0 100644 --- a/docs/GEO_AUTH_DESIGN.md +++ b/docs/GEO_AUTH_DESIGN.md @@ -1,29 +1,1054 @@ -# Geo Auth Design +# Geo-Auth Design Document -## Goals +This document outlines the design for geographic authentication in the MeshCore GOME WarDriver system. -- Provide a secure authentication mechanism for wardrive post submissions. -- Prevent accidental token leakage via URLs, logs, or referrers. -- Keep the client implementation simple and standards-based. +## Overview -## Confirmed Decisions +The Geo-Auth system provides location-based authentication for wardriving sessions. Devices must be within designated geographic zones to connect and submit data. -- **Token transport for wardrive posts MUST use the HTTP `Authorization: Bearer ` header.** -- **Tokens MUST NEVER be accepted via query string parameters.** -- Tokens are treated as sensitive secrets and must be stored securely on device. -- If a token is compromised, it can be rotated/revoked without requiring a client app update. +## Architecture -## Open Items +### Components -- Ensure server and reverse-proxy logging does **not** record `Authorization` headers (or otherwise redact them), especially for wardrive endpoints. +1. **Zone Manager** — Manages geographic zone definitions and capacity +2. **Auth Service** — Handles authentication requests and token management +3. **GPS Validator** — Validates GPS coordinates for freshness and accuracy +4. **Device Registry** — Tracks known devices heard on mesh -## Notes +### Flow + +1. Device advertises on mesh (prerequisite) +2. Device checks zone status (preflight) +3. Device requests auth with coordinates +4. Server validates device is known + location, issues session +5. Device submits wardrive data with keepalive +6. Device disconnects when session ends + +## Device Registration (Mesh-Based) + +Devices must be "heard on mesh" before they can authenticate for wardriving. This provides attribution and prevents anonymous abuse. + +### How It Works + +``` +1. Device advertises on mesh (any normal mesh activity) +2. Advert packet is collected by observer on letsmesh +3. Backend parses adverts, adds public_key to known_devices table +4. Device can now authenticate for wardriving +``` + +### Manual Registration + +Admins can manually add a companion's public key via the admin panel. This is useful for: +- Testing new devices +- Onboarding users in areas with no observer coverage +- Troubleshooting registration issues + +### Known Devices Table + +| Field | Type | Description | +|-------|------|-------------| +| `public_key` | string | Device's unique public key | +| `first_heard` | timestamp | When device was first heard on mesh | +| `last_heard` | timestamp | When device was last heard on mesh | +| `last_wardrive` | timestamp | When device last authenticated for wardriving | +| `expires_at` | timestamp | 60 days after most recent activity | +| `registered_by` | string | `mesh` (automatic) or `admin` (manual) | + +### Retention Policy + +- Devices are retained for **60 days** after last activity +- Activity that resets the 60-day expiry: + - Device heard on mesh (updates `last_heard`) + - Device authenticates for wardriving (updates `last_wardrive`) +- If a device has no activity for 60 days, it is removed from known_devices +- Device must advertise on mesh again (or be manually re-added) to re-register + +### Expiry Reset Logic + +``` +on device heard on mesh: + update last_heard = now + update expires_at = now + 60 days + +on device authenticates for wardriving: + update last_wardrive = now + update expires_at = now + 60 days +``` + +### Unknown Device Flow + +``` +Device tries to auth with public_key "ABC123" + ↓ +Server checks: Is "ABC123" in known_devices? + ↓ +NO → Return error: "unknown_device" + Message: "Unknown public key. Please advertise yourself on the mesh." + ↓ +YES → Update last_wardrive and expires_at + Proceed with normal auth flow +``` + +> **Note:** The backend handles how observers report heard devices. This is outside the scope of the wardrive client. + +--- + +## Zone Configuration + +Zones are defined as circular regions with: +- Center coordinates (lat/lng) +- Radius in kilometers +- Maximum concurrent slots (TX only) +- Enable/disable flag + +## Token Management + +Tokens are opaque bearer tokens with: +- 30-minute expiration +- Session binding +- Zone assignment + +## GPS Validation Rules + +- **Staleness threshold:** 60 seconds max age +- **Accuracy threshold:** 50 meters max horizontal accuracy +- **Coordinate bounds:** Valid lat (-90 to 90), lng (-180 to 180) + +## Session Types (WARDRIVE_TYPE) + +Each wardrive data entry includes a `type` field: + +| Type | Description | Slot Limited | +|------|-------------|--------------| +| `TX` | Transmit — Device is actively broadcasting/probing | **Yes** — Counts toward zone capacity | +| `RX` | Receive — Device is passively listening/capturing | **No** — Unlimited within zone | ### Rationale -Using the `Authorization: Bearer` header: +- **TX sessions** consume zone slots because active transmissions need coordination within a geographic area. +- **RX sessions** are passive and do not interfere with other devices, so they are not subject to slot limits. + +### Slot Allocation Behavior + +- When a session is granted `tx_allowed: true`, it holds a TX slot for the duration of the session. +- Sessions with only `rx_allowed: true` do not consume slots. +- If zone is at TX capacity, device can still start an RX-only session. + +--- + +## Response Format + +All API responses follow a consistent format: + +### Success Response +```json +{ + "success": true, + ... endpoint-specific fields ... +} +``` + +### Error Response +```json +{ + "success": false, + "reason": "error_code", + "message": "Human-readable description" +} +``` + +### Standard Fields + +| Field | Type | Present | Description | +|-------|------|---------|-------------| +| `success` | boolean | Always | Whether the request succeeded | +| `reason` | string | On error, or partial success | Machine-readable error code | +| `message` | string | On error | Human-readable error description | + +### Error Codes + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| `unknown_device` | 403 | Public key not recognized — device must advertise on mesh first | +| `outside_zone` | 403 | Device is not within any configured zone | +| `zone_disabled` | 403 | Zone is administratively disabled | +| `zone_full` | 200 | Zone at TX capacity (RX still allowed) | +| `gps_stale` | 403 | GPS timestamp is too old | +| `gps_inaccurate` | 403 | GPS accuracy exceeds threshold | +| `bad_session` | 401 | Session ID is invalid or doesn't match | +| `session_expired` | 401 | Session has timed out | +| `bad_key` | 401 | API key is invalid | +| `invalid_request` | 400 | Missing or invalid parameters | + +--- + +## API Endpoint Examples + +This section captures sample requests and responses for the major endpoints described in this document. See implementation notes for exact contract details. + +> **HTTP Status Code Conventions:** +> - `200 OK` — Success (includes partial success like RX-only when TX denied) +> - `400 Bad Request` — Missing/invalid parameters (`invalid_request`) +> - `401 Unauthorized` — Invalid session or API key (`bad_session`, `bad_key`) +> - `403 Forbidden` — Valid request but fully denied (`outside_zone`, `zone_disabled`, `gps_stale`, `gps_inaccurate`, `unknown_device`) +> - `429 Too Many Requests` — Rate limit exceeded (applies to `/status`) + +--- + +### Preflight — Zone Status Check + +**Endpoint:** +`POST /status` +Content-Type: `application/json` + +> **Note:** POST is used (instead of GET) to allow structured JSON coordinates in the request body. + +**Request:** +```json +{ + "lat": 45.4215, + "lng": -75.6972, + "accuracy_m": 15. 3, + "timestamp": 1703980800 +} +``` + +#### Server Logic + +``` +validate lat/lng are within valid bounds +validate timestamp is not stale (< 60 seconds old) +validate accuracy_m is acceptable (< 50 meters) + +find zone containing coordinates: + if in zone: + return zone info with capacity status + else: + find nearest zone + return nearest zone with distance +``` + +#### Client Logic + +``` +ZONE_CHECK_DISTANCE_M = 100 // recheck zone status every 100 meters + +on app launch: + disable buttons: [Connect], [TX Ping], [TX/RX Auto], [RX Auto] + get current GPS coordinates + store lastZoneCheckCoords = current coords + POST /status with current coords + + if success == true AND in_zone == true: + show zone info to user + enable buttons: [Connect] + disable buttons: [TX Ping], [TX/RX Auto], [RX Auto] // until session acquired + else if success == true AND in_zone == false: + show nearest zone and distance + disable buttons: [Connect], [TX Ping], [TX/RX Auto], [RX Auto] + show "Outside zone" warning + else: + show error message + disable buttons: [Connect], [TX Ping], [TX/RX Auto], [RX Auto] + +on location change (while not connected): + calculate distance from lastZoneCheckCoords + if distance >= ZONE_CHECK_DISTANCE_M: + POST /status with new coords + store lastZoneCheckCoords = new coords + update UI based on response (same logic as app launch) +``` + +#### Preflight Responses + +**In Zone (200 OK):** +Device is within a configured zone — shows capacity status. +```json +{ + "success": true, + "in_zone": true, + "zone": { + "name": "Ottawa", + "code": "YOW", + "enabled": true, + "at_capacity": false, + "slots_available": 5, + "slots_max": 10 + } +} +``` + +**Outside All Zones (200 OK):** +Device is not within any zone — shows nearest zone for navigation. +```json +{ + "success": true, + "in_zone": false, + "nearest_zone": { + "name": "Ottawa", + "code": "YOW", + "distance_km": 2.3 + } +} +``` + +**GPS Too Stale (403 Forbidden):** +GPS timestamp is too old — device should get fresh location. +```json +{ + "success": false, + "reason": "gps_stale", + "message": "GPS timestamp is too old" +} +``` + +**GPS Too Inaccurate (403 Forbidden):** +GPS accuracy exceeds acceptable threshold. +```json +{ + "success": false, + "reason": "gps_inaccurate", + "message": "GPS accuracy exceeds 50 meter threshold" +} +``` + +**Invalid Request (400 Bad Request):** +Missing or invalid coordinates. +```json +{ + "success": false, + "reason": "invalid_request", + "message": "Missing required field: lat" +} +``` + +--- + +### Auth — Connect Request + +**Endpoint:** +`POST /auth` +Content-Type: `application/json` + +Device metadata (`who`, `ver`, `power`, `iata`) is captured at authentication time and bound to the session. These fields do not need to be sent with subsequent wardrive posts. + +**Request:** +```json +{ + "key": "", + "public_key": "", + "who": "Alice's Pixel 8", + "ver": "2.1.0", + "power": "1.0", + "iata": "YOW", + "model": "Ikoka Stick-E22-30dBm (Xiao_nrf52)", + "reason": "connect", + "coords": { + "lat": 45.4216, + "lng": -75.6970, + "accuracy_m": 12.0, + "timestamp": 1703980842 + } +} + +``` + +#### Server Logic + +``` +validate api key: + if invalid: + return success: false, reason: bad_key + +validate public_key is in known_devices: + if not found: + return success: false, reason: unknown_device, + message: "Unknown public key. Please advertise yourself on the mesh." + +// Device is known — reset expiry +update known_devices: + set last_wardrive = now + set expires_at = now + 60 days + +validate coords are fresh and accurate +find zone containing coords: + if not in zone: + return success: false, reason: outside_zone + + if zone is disabled: + return success: false, reason: zone_disabled + +check TX slot availability: + if slots available: + acquire TX slot for public_key + create session with tx_allowed: true, rx_allowed: true + else: + create session with tx_allowed: false, rx_allowed: true, reason: zone_full + +store session metadata (who, ver, power, iata, model) +set session expiration to now + 30 minutes +return success: true, session_id, and expires_at +``` + +#### Client Logic + +``` +on connect button pressed: + disable buttons: [Connect] // prevent double-tap + get fresh GPS coordinates + POST /auth with device info and coords + + if success == true AND tx_allowed AND rx_allowed: + store session_id + schedule heartbeat timer for (expires_at - 5 min) + enable buttons: [TX Ping], [TX/RX Auto], [RX Auto] + update [Connect] to show [Disconnect] + show "Connected - Full Access" message + + else if success == true AND rx_allowed only: + store session_id + schedule heartbeat timer for (expires_at - 5 min) + disable buttons: [TX Ping], [TX/RX Auto] + enable buttons: [RX Auto] + update [Connect] to show [Disconnect] + show "Connected - RX Only (TX at capacity)" message + + else if reason == unknown_device: + enable buttons: [Connect] // allow retry after advertising + disable buttons: [TX Ping], [TX/RX Auto], [RX Auto] + show error: "Unknown device. Please advertise yourself on the mesh and try again." + + else: + enable buttons: [Connect] // allow retry + disable buttons: [TX Ping], [TX/RX Auto], [RX Auto] + show error: response. message + if nearest_zone provided: + show distance to nearest zone +``` + +#### Auth Response Fields + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | Whether the auth request succeeded | +| `tx_allowed` | boolean | Whether TX wardriving is permitted (slot acquired) | +| `rx_allowed` | boolean | Whether RX wardriving is permitted (always true if in zone) | +| `session_id` | string | Session ID (present if either TX or RX is allowed) | +| `reason` | string | Why TX was denied or request failed | +| `message` | string | Human-readable description (on error) | +| `zone` | object | Zone info (present if in zone) | +| `expires_at` | number | Session expiration timestamp (Unix epoch) | +| `nearest_zone` | object | Nearest zone info (only if outside all zones) | + +#### Auth Responses + +**Full Access, TX + RX (200 OK):** +Device is in zone and a TX slot is available — full wardriving permitted. +```json +{ + "success": true, + "tx_allowed": true, + "rx_allowed": true, + "session_id": "", + "zone": { + "name": "Ottawa", + "code": "YOW" + }, + "expires_at": 1703982642 +} +``` + +**RX Only, Zone at TX Capacity (200 OK):** +Device is in zone but all TX slots are occupied — RX wardriving still permitted. +```json +{ + "success": true, + "tx_allowed": false, + "rx_allowed": true, + "reason": "zone_full", + "session_id": "", + "zone": { + "name": "Ottawa", + "code": "YOW" + }, + "expires_at": 1703982642 +} +``` + +**Fully Denied, Unknown Device (403 Forbidden):** +Device's public key has not been heard on mesh — must advertise first. +```json +{ + "success": false, + "reason": "unknown_device", + "message": "Unknown public key. Please advertise yourself on the mesh." +} +``` + +**Fully Denied, Outside Zone (403 Forbidden):** +Device is not within any configured zone — no wardriving permitted. +```json +{ + "success": false, + "reason": "outside_zone", + "message": "Device is not within any configured zone", + "nearest_zone": { + "name": "Ottawa", + "code": "YOW", + "distance_km": 1.2 + } +} +``` + +**Fully Denied, Zone Disabled (403 Forbidden):** +Device is in zone but the zone is administratively disabled — no wardriving permitted. +```json +{ + "success": false, + "reason": "zone_disabled", + "message": "Zone is currently disabled" +} +``` + +**Fully Denied, Bad API Key (401 Unauthorized):** +API key is invalid or missing. +```json +{ + "success": false, + "reason": "bad_key", + "message": "API key is invalid" +} +``` + +--- + +### Wardrive Post (Keepalive + Data Submission) + +**Endpoint:** +`POST /wardrive` +Content-Type: `application/json` + +The `/wardrive` endpoint serves two purposes: +1. **Data submission** — Post wardrive data (TX and/or RX entries) +2. **Heartbeat** — Keep session alive when no data is available (e.g., quiet RX wardriving) + +Both modes refresh the session expiration. + +#### Request Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `key` | string | Yes | API key for authentication | +| `session_id` | string | Yes | Session ID from auth response | +| `data` | array | If not heartbeat | Array of wardrive entries | +| `heartbeat` | boolean | If no data | Set to `true` for keepalive only | +| `coords` | object | If heartbeat | Current location for zone validation | + +#### Wardrive Data Entry Fields + +| Field | Type | Description | +|-------|------|-------------| +| `type` | string | `TX` or `RX` | +| `lat` | float | Latitude | +| `lon` | float | Longitude | +| `heard_repeats` | string | Repeaters heard with SNR, e.g. `"4e(11. 5)"` or `"None"` | +| `noisefloor` | float | Radio noise floor in dBm at time of capture | +| `timestamp` | number | Unix epoch (seconds) when this data was captured | + +#### Heartbeat Coords Fields + +| Field | Type | Description | +|-------|------|-------------| +| `lat` | float | Latitude | +| `lon` | float | Longitude | +| `timestamp` | number | Unix epoch (seconds) of current position | + +> **Note:** Device metadata (`who`, `ver`, `power`, `iata`) is already bound to the session at auth time — no need to include in wardrive posts. + +#### Server Logic + +``` +validate api key: + if invalid: + return success: false, reason: bad_key +validate session_id exists and matches key: + if invalid: + return success: false, reason: bad_session +check session not expired: + if expired: + return success: false, reason: session_expired + +if heartbeat == true: + validate coords object is present: + if missing: + return success: false, reason: invalid_request + validate coords are in session's assigned zone: + if outside zone: + return success: false, reason: outside_zone + refresh session expiration to now + 30 minutes + return success: true, expires_at + +else: + validate data array is present and not empty: + if missing or empty: + return success: false, reason: invalid_request + find entry with highest timestamp + validate that entry's coords are in session's assigned zone: + if outside zone: + return success: false, reason: outside_zone + store all wardrive data entries + refresh session expiration to now + 30 minutes + return success: true, expires_at +``` + +#### Client Logic + +``` +on session start: + set lastPostTime = now + schedule heartbeatTimer for (expires_at - 5 min) + +on wardrive data collected: + add to local queue + +on queue flush (every 30s or on TX): + POST /wardrive with data array + if success == true: + lastPostTime = now + cancel heartbeatTimer + schedule heartbeatTimer for (new expires_at - 5 min) + else if reason == outside_zone: + show "Outside zone" warning + stop wardriving + cancel heartbeatTimer + clear local session_id + disable buttons: [TX Ping], [TX/RX Auto], [RX Auto] + update [Disconnect] to show [Connect] + enable buttons: [Connect] + else if reason == session_expired: + show "Session expired" warning + stop wardriving + cancel heartbeatTimer + clear local session_id + disable buttons: [TX Ping], [TX/RX Auto], [RX Auto] + update [Disconnect] to show [Connect] + enable buttons: [Connect] + +on heartbeat timer fire: + get fresh GPS coordinates + POST /wardrive with heartbeat: true, coords + if success == true: + schedule heartbeatTimer for (new expires_at - 5 min) + else if reason == outside_zone: + show "Outside zone" warning + stop wardriving + cancel heartbeatTimer + clear local session_id + disable buttons: [TX Ping], [TX/RX Auto], [RX Auto] + update [Disconnect] to show [Connect] + enable buttons: [Connect] + else if reason == session_expired: + show "Session expired" warning + stop wardriving + cancel heartbeatTimer + clear local session_id + disable buttons: [TX Ping], [TX/RX Auto], [RX Auto] + update [Disconnect] to show [Connect] + enable buttons: [Connect] +``` + +#### When to Use Heartbeat + +The heartbeat flag is a **safety net** for quiet RX wardriving sessions when no repeaters are heard for extended periods. + +| Situation | What Happens | +|-----------|--------------| +| Active TX wardriving | `/wardrive` posts with data keep session alive — heartbeat not needed | +| Active RX wardriving | `/wardrive` posts with data keep session alive — heartbeat not needed | +| Quiet RX wardriving | No data to post → heartbeat timer fires → `/wardrive` with `heartbeat: true` keeps session alive | +| Device crashes | No heartbeat, no wardrive → session expires → slot released | + +#### Wardrive Request Examples + +**Data Submission (normal wardrive post):** +```json +{ + "key": "", + "session_id": "", + "data": [ + { + "type": "TX", + "lat": 45.4217, + "lon": -75.6975, + "heard_repeats": "4e(11.5),b7(9.75)", + "noisefloor": -95.5, + "timestamp": 1703980860 + }, + { + "type": "RX", + "lat": 45.4218, + "lon": -75.6973, + "heard_repeats": "22(8.2)", + "noisefloor": -92.3, + "timestamp": 1703980875 + } + ] +} +``` + +**Heartbeat (keepalive, no data):** +Used when RX wardriving is quiet and no repeaters have been heard. +```json +{ + "key": "", + "session_id": "", + "heartbeat": true, + "coords": { + "lat": 45.4218, + "lon": -75.6973, + "timestamp": 1703980900 + } +} +``` + +#### Wardrive Responses + +**Success, Session Extended (200 OK):** +Data accepted (or heartbeat acknowledged) and session expiration extended. +```json +{ + "success": true, + "expires_at": 1703982660 +} +``` + +**Denied, Outside Assigned Zone (403 Forbidden):** +Device has moved outside the zone it authenticated in. +```json +{ + "success": false, + "reason": "outside_zone", + "message": "Device has moved outside the assigned zone" +} +``` + +**Denied, Invalid Session (401 Unauthorized):** +Session ID is invalid or does not match the API key. +```json +{ + "success": false, + "reason": "bad_session", + "message": "Session ID is invalid or does not match the API key" +} +``` + +**Denied, Session Expired (401 Unauthorized):** +Session has timed out and requires re-authentication. +```json +{ + "success": false, + "reason": "session_expired", + "message": "Session has timed out and requires re-authentication" +} +``` + +**Denied, Invalid Request (400 Bad Request):** +Request must include either `data` array or `heartbeat: true`. +```json +{ + "success": false, + "reason": "invalid_request", + "message": "Request must include either data array or heartbeat flag" +} +``` + +--- + +### Disconnect + +**Endpoint:** +`POST /auth` +Content-Type: `application/json` + +**Request:** +```json +{ + "key": "", + "public_key": "", + "reason": "disconnect", + "session_id": "" +} +``` + +#### Server Logic + +``` +validate api key: + if invalid: + return success: false, reason: bad_key +validate session_id exists and belongs to public_key: + if invalid: + return success: false, reason: bad_session + +if session held a TX slot: + release TX slot back to zone pool + +delete session +return success: true, disconnected: true +``` + +#### Client Logic + +``` +on disconnect button pressed: + cancel heartbeatTimer + flush any pending wardrive data + POST /auth with reason: disconnect + if success == true: + clear local session_id + disable buttons: [TX Ping], [TX/RX Auto], [RX Auto] + update [Disconnect] to show [Connect] + enable buttons: [Connect] + show "Disconnected" message + else: + // still clean up locally even if server fails + clear local session_id + disable buttons: [TX Ping], [TX/RX Auto], [RX Auto] + update [Disconnect] to show [Connect] + enable buttons: [Connect] + show "Disconnected (server error: " + response.message + ")" + +on app close: + same as disconnect button pressed (best effort) + +on connection lost unexpectedly: + cancel heartbeatTimer + clear local session_id + disable buttons: [TX Ping], [TX/RX Auto], [RX Auto] + update [Disconnect] to show [Connect] + enable buttons: [Connect] + show "Connection lost - tap Connect to reconnect" message + attempt POST /auth disconnect (best effort, don't block UI) +``` + +#### Disconnect Responses + +**Success (200 OK):** +Session terminated and TX slot released (if held). +```json +{ + "success": true, + "disconnected": true +} +``` + +**Failed, Bad Session (401 Unauthorized):** +Session ID is invalid or doesn't exist. +```json +{ + "success": false, + "reason": "bad_session", + "message": "Session ID is invalid or does not exist" +} +``` + +**Failed, Bad API Key (401 Unauthorized):** +API key is invalid. +```json +{ + "success": false, + "reason": "bad_key", + "message": "API key is invalid" +} +``` + +--- + +_Add further endpoint examples as APIs expand._ + +## Open Items / Next Brainstorm + +### API Design +- [ ] **API Versioning** — Implement `/v1/auth`, `/v1/wardrive`, etc. Add `API_VERSION` variable to client app for easy switching between dev/prod API versions +- [ ] **API Key Necessity** — Is `api_key` still required now that `public_key` (mesh-based identity) is the primary auth mechanism? Consider removing or making optional + +### Security +- [ ] **Session Signing** — Should `session_id` be signed with `public_key` to prevent session hijacking? + +### Error Handling +- [ ] **Network Failures** — Client logic doesn't explicitly handle network errors (timeout, DNS failure, etc.). Define retry strategy, offline queueing, and user feedback +- [ ] **Concurrent Sessions** — Define behavior when same `public_key` tries to auth twice. Options: Allow multiple sessions? Replace old session? Reject second attempt? +- [ ] **GPS Jitter Tolerance** — Consider zone boundary hysteresis to prevent false `outside_zone` errors when GPS fluctuates near zone edge + +### Slot Management +- [ ] **Slot Starvation** — If TX slots are always full, RX-only users never get upgraded. Consider: Queue for TX slot? Notification when slot becomes available? Auto-upgrade RX session to TX when slot frees up? + +### Zone Behavior +- [ ] **Zone Boundary Crossing** — What happens when user crosses zone boundary mid-session? Options: End session immediately? Grace period? Allow session to continue until next wardrive post fails validation? + +### Admin Endpoints +- [ ] **Zone CRUD** — Create, read, update, disable zones via admin API +- [ ] **Manual Device Registration** — Admin endpoint to add companion public keys manually +- [ ] **Metrics Dashboard** — Admin dashboard for monitoring (active sessions, zone capacity, auth rates, etc.) — Maybe? + + +--- + +## Implementation Plan + +This section outlines the step-by-step implementation tasks for the Geo-Auth system. + +### Phase 1: Backend Infrastructure + +#### 1.1 Database Schema +- [ ] Create `zones` table with fields: `zone_id`, `name`, `code`, `lat`, `lng`, `radius_km`, `max_tx_slots`, `enabled` + +#### 1.2 Zone Management Service +- [ ] Implement zone definition storage and retrieval +- [ ] Implement point-in-circle zone detection algorithm +- [ ] Implement nearest zone calculation (haversine distance) +- [ ] Implement TX slot tracking per zone (acquire/release) +- [ ] Add zone enable/disable flag handling + +#### 1.3 Device Registration System +- [ ] Implement automatic device registration on first advert +- [ ] Implement 60-day expiry cleanup job (cron/scheduled task) +- [ ] Implement expiry reset on `last_heard` and `last_wardrive` updates +- [ ] Create admin panel UI for manual device registration + +### Phase 2: Authentication Endpoints + +#### 2.1 Preflight — `/status` +- [ ] Implement POST endpoint handler +- [ ] Implement GPS validation (staleness, accuracy, bounds) +- [ ] Implement zone detection logic (in zone vs. nearest zone) +- [ ] Implement capacity status response (slots_available, at_capacity) +- [ ] Add rate limiting (429 Too Many Requests) +- [ ] Write API tests for all response scenarios + +#### 2.2 Auth — `/auth` (Connect) +- [ ] Implement POST endpoint handler +- [ ] Implement API key validation +- [ ] Implement `known_devices` lookup and `unknown_device` error +- [ ] Implement GPS validation +- [ ] Implement zone detection and assignment +- [ ] Implement TX slot acquisition logic (full vs. RX-only) +- [ ] Implement session creation with metadata storage (`who`, `ver`, `power`, `iata`, `model`) +- [ ] Implement 30-minute session expiration +- [ ] Write API tests for all response scenarios (full access, RX-only, denied) + +#### 2.3 Auth — `/auth` (Disconnect) +- [ ] Implement disconnect handler (same endpoint, `reason: "disconnect"`) +- [ ] Implement session lookup and validation +- [ ] Implement TX slot release logic +- [ ] Implement session deletion +- [ ] Write API tests for disconnect scenarios + +### Phase 3: Wardrive Data Endpoints + +#### 3.1 Wardrive Post — `/wardrive` +- [ ] Implement POST endpoint handler +- [ ] Implement API key and session_id validation +- [ ] Implement session expiration check +- [ ] Implement heartbeat-only mode (validate coords in zone, refresh expiration) +- [ ] Implement data submission mode (validate coords, store entries, refresh expiration) +- [ ] Implement zone boundary validation (reject if moved outside zone) +- [ ] Write API tests for data submission, heartbeat, and error scenarios + +### Phase 4: Client Integration + +#### 4.1 Preflight UI & Logic +- [ ] Add zone status check on app launch +- [ ] Add 100m movement trigger for re-checking zone status +- [ ] Update UI to show zone info (name, code, capacity) +- [ ] Update UI to show nearest zone when outside (name, distance) +- [ ] Disable/enable Connect button based on zone status +- [ ] Handle GPS errors (stale, inaccurate) + +#### 4.2 Connect/Disconnect Workflow +- [ ] Update connect flow to call `/auth` with device metadata (`who`, `ver`, `power`, `iata`, `model`) +- [ ] Store `session_id` locally on successful auth +- [ ] Handle full access vs. RX-only response (enable/disable TX buttons) +- [ ] Implement disconnect flow (flush data, call `/auth` disconnect, clean up state) +- [ ] Handle `unknown_device` error (show instruction to advertise on mesh) +- [ ] Handle connection lost scenario (clean up local state, show reconnect message) + +#### 4.3 Heartbeat System +- [ ] Implement heartbeat timer (fires at `expires_at - 5 min`) +- [ ] Implement heartbeat POST to `/wardrive` with fresh GPS coords +- [ ] Handle heartbeat errors (outside_zone, session_expired) +- [ ] Cancel and reschedule heartbeat on data submission +- [ ] Handle visibility changes (pause/resume heartbeat when app hidden/shown) + +#### 4.4 Wardrive Data Submission +- [ ] Update data queue to include `noisefloor` field for both TX and RX entries +- [ ] Update POST payload to match `/wardrive` contract (no `who`, `ver`, `power`, `iata` in data) +- [ ] Handle session validation errors (bad_session, session_expired, outside_zone) +- [ ] Update UI on errors (stop wardriving, disconnect session, show message) + +#### 4.5 Error Handling & UX +- [ ] Add user-facing error messages for all error codes +- [ ] Add zone boundary crossing detection and graceful handling +- [ ] Add network error handling (timeout, DNS failure, offline mode) +- [ ] Add retry logic for transient failures + +### Phase 5: Admin Tools + +#### 5.1 Manual Device Registration +- [ ] Create admin UI for adding public keys manually +- [ ] Add validation for public key format +- [ ] Add notes field for admin context (why manually added) + +#### 5.2 Zone Management UI +- [ ] Create admin UI for creating/editing zones +- [ ] Add map picker for zone center coordinates +- [ ] Add controls for radius, max TX slots, enable/disable +- [ ] Add zone deletion with safety confirmation + +#### 5.3 Monitoring Dashboard (Optional) +- [ ] Display active sessions count per zone +- [ ] Display TX slot utilization per zone +- [ ] Display auth request rate and error rate +- [ ] Display wardrive data submission rate +- [ ] Add alerts for unusual activity (too many errors, too many unknowns) + +### Phase 6: Testing & Deployment + +#### 6.1 Integration Testing +- [ ] Test full client workflow (preflight → auth → wardrive → disconnect) +- [ ] Test heartbeat keepalive during quiet RX mode +- [ ] Test zone boundary crossing scenarios +- [ ] Test TX slot capacity limits (RX-only fallback) +- [ ] Test session expiration and re-auth flow +- [ ] Test unknown device rejection and mesh registration flow + +#### 6.2 Load Testing +- [ ] Simulate 10+ concurrent TX sessions per zone +- [ ] Simulate 50+ concurrent RX sessions per zone +- [ ] Test slot acquisition race conditions +- [ ] Test database performance under load + +#### 6.3 Documentation +- [ ] Update client-facing documentation (connection workflow, error codes) +- [ ] Update backend API documentation (endpoint contracts) +- [ ] Create troubleshooting guide (common errors and fixes) +- [ ] Document admin tools usage (zone management, device registration) + +#### 6.4 Deployment +- [ ] Deploy backend to staging environment +- [ ] Deploy client to staging (feature flag or separate domain) +- [ ] Run end-to-end tests in staging +- [ ] Deploy backend to production +- [ ] Deploy client to production +- [ ] Monitor error logs and metrics for first 24 hours -- Aligns with standard HTTP auth practices. -- Avoids token exposure in shared links, browser history, referrer headers, and many default access logs. +### Phase 7: Future Enhancements -Query string tokens are explicitly forbidden because they are commonly logged and leaked. +- [ ] API versioning (`/v1/auth`, `/v1/wardrive`) +- [ ] Session signing with public key to prevent hijacking +- [ ] Zone boundary hysteresis to prevent GPS jitter issues +- [ ] TX slot queue/notification system for RX-only users +- [ ] Auto-upgrade RX session to TX when slot becomes available +- [ ] Offline mode with local data queueing and sync on reconnect +- [ ] Multi-zone support (allow session to span multiple adjacent zones) \ No newline at end of file diff --git a/docs/PING_WORKFLOW.md b/docs/PING_WORKFLOW.md index 01526e4..98359f6 100644 --- a/docs/PING_WORKFLOW.md +++ b/docs/PING_WORKFLOW.md @@ -51,15 +51,16 @@ 1. **User Initiates** → User clicks "Send Ping" button 2. **Cooldown Check** → Verify not in 7-second cooldown period -3. **GPS Acquisition** → Get current GPS coordinates -4. **Geofence Validation** → Verify location is within Ottawa 150km -5. **Distance Validation** → Verify ≥25m from last successful ping -6. **Control Lock** → Lock ping controls for entire ping lifecycle -7. **Payload Build** → Format ping message with coordinates and power -8. **Channel Send** → Send payload to `#wardriving` channel -9. **Repeater Tracking** → Start 7-second RX listening window -10. **API Post** → Post to MeshMapper API after listening window -11. **Control Unlock** → Unlock ping controls +3. **Refresh Radio Stats** → Request radio statistics (noise floor) from companion to provide fresh noise reading for UI and decision telemetry +4. **GPS Acquisition** → Get current GPS coordinates +5. **Geofence Validation** → Verify location is within Ottawa 150km +6. **Distance Validation** → Verify ≥25m from last successful ping +7. **Control Lock** → Lock ping controls for entire ping lifecycle +8. **Payload Build** → Format ping message with coordinates and power +9. **Channel Send** → Send payload to `#wardriving` channel +10. **Repeater Tracking** → Start 7-second RX listening window +11. **API Post** → Post to MeshMapper API after listening window +12. **Control Unlock** → Unlock ping controls ### Detailed Manual Ping Steps diff --git a/docs/STATUS_MESSAGES.md b/docs/STATUS_MESSAGES.md index cd44a49..59d5786 100644 --- a/docs/STATUS_MESSAGES.md +++ b/docs/STATUS_MESSAGES.md @@ -239,7 +239,79 @@ These messages appear in the Dynamic App Status Bar. They NEVER include connecti - **When**: GPS data is stale and needs refresh (auto or manual ping modes) - **Source**: `content/wardrive.js:getGpsCoordinatesForPing()` -#### 4. Ping Operation Messages +#### 4. Geo-Auth Zone Check Messages (Phase 4.1 - Preflight UI Only) + +**Phase 4.1 Scope**: Zone checks provide **preflight UI feedback** while disconnected. Real validation happens server-side in Phase 4.2+ via `/auth` (connect) and `/wardrive` (ongoing) endpoints. + +**Note**: Zone status appears in the **Settings Panel** (`#locationDisplay`) only. Errors (outside zone, outdated app) appear as persistent messages in the **Dynamic Status Bar**. + +##### Checking... +- **Message (Settings Panel)**: `"Checking..."` +- **Color**: Gray (slate-400) +- **When** (Phase 4.1 - disconnected mode only): + - During app launch zone check (before Connect button enabled) + - After 100m GPS movement while disconnected triggers zone recheck +- **Source**: `content/wardrive.js:performAppLaunchZoneCheck()`, `handleZoneCheckOnMove()` + +##### Zone Code (e.g., YOW) +- **Message (Settings Panel)**: `"YOW"` (or other zone code) +- **Color**: Green (emerald-300) when available, Amber (amber-300) when at capacity +- **When**: Successfully validated location within enabled wardriving zone (Phase 4.1 preflight check) +- **Source**: `content/wardrive.js:updateZoneStatusUI()` + +##### Outside zone (distance to nearest) +- **Message (Dynamic Status Bar)**: `"Outside zone (Xkm to CODE)"` +- **Message (Settings Panel)**: `"—"` (dash) +- **Color**: Red (error) - persistent message +- **When**: + - **Phase 4.1**: GPS coordinates outside any enabled wardriving zone boundary (preflight check, Connect button disabled) +- **Terminal State**: Yes (Connect button disabled, persistent error blocks other status messages) +- **Source**: `content/wardrive.js:updateZoneStatusUI()` + +##### GPS/Zone Errors +- **Message (Settings Panel)**: `"GPS: stale"`, `"GPS: inaccurate"`, `"Unknown"` +- **Color**: Red (error) +- **When** (Phase 4.1 client-side GPS failure): + - GPS data too stale (>60s) → "GPS: stale" + - GPS accuracy too poor (>50m) → "GPS: inaccurate" + - GPS permissions denied or network error → "Unknown" +- **Terminal State**: Yes (Connect button disabled) +- **Source**: `content/wardrive.js:updateZoneStatusUI()` + +##### App Version Outdated +- **Message (Dynamic Status Bar)**: API message or `"App version outdated, please update"` +- **Message (Settings Panel)**: `""` (empty) +- **Color**: Red (error) - persistent message +- **When**: Server returns `reason: "outofdate"` during zone check +- **Terminal State**: Yes (Connect button disabled, persistent error blocks other status messages) +- **Source**: `content/wardrive.js:updateZoneStatusUI()` + +**Slot Availability Display** (Settings Panel only): +- **Location**: Settings panel "Status Info" section, Slots row +- **Display Format**: + - `"N/A"` (gray) - Zone not checked yet or check failed + - `"X available"` (green) - X slots available in zone + - `"Full (0/Y)"` (red) - Zone at capacity, Y total slots +- **Update Frequency** (Phase 4.1): + - 30 seconds while disconnected + - Immediate when zone check completes +- **Source**: `content/wardrive.js:updateSlotsDisplay()` + +**Zone Check Triggers** (Phase 4.1 - disconnected mode only): +1. **App Launch**: Automatic check on page load after GPS permission granted +2. **100m Movement (Disconnected)**: Continuous monitoring during GPS watch while disconnected, triggers recheck if moved ≥100m from last check +3. **30s Slot Refresh (Disconnected)**: Periodic timer updates slot availability while disconnected + +**Phase 4.2+ Server-Side Triggers** (not yet implemented): +- `/auth` endpoint validation on connect +- `/wardrive` endpoint validation on every ping with GPS coordinates + +**Connect Button Behavior**: +- Disabled initially during app launch zone check +- Enabled only if: `zone.enabled === true` AND `in_zone === true` AND `zone.at_capacity === false` +- Remains disabled on zone check failure or GPS unavailable + +#### 6. Ping Operation Messages ##### Sending manual ping - **Message**: `"Sending manual ping"` @@ -290,7 +362,7 @@ These messages appear in the Dynamic App Status Bar. They NEVER include connecti - **When**: User attempts manual ping during 7-second cooldown - **Source**: `content/wardrive.js:sendPing()` -#### 5. Countdown Timer Messages +#### 7. Countdown Timer Messages These messages use a hybrid approach: **first display respects 500ms minimum**, then updates occur immediately every second. @@ -338,7 +410,7 @@ These messages use a hybrid approach: **first display respects 500ms minimum**, - **Minimum Visibility**: 500ms for first message, immediate for updates - **Source**: `content/wardrive.js:autoCountdownTimer` -#### 6. API and Map Update Messages +#### 8. API and Map Update Messages ##### Queued (X/50) - **Message**: `"Queued (X/50)"` (X is current queue size) @@ -362,18 +434,81 @@ These messages use a hybrid approach: **first display respects 500ms minimum**, - **Notes**: As of the batch queue implementation, individual API posts have been replaced by batched posts. Messages are queued and flushed in batches. - **Source**: ~~`content/wardrive.js:postApiAndRefreshMap()`~~ Replaced by batch queue system -##### Error: API batch post failed +##### Error: API batch post failed (DEPRECATED) - **Message**: `"Error: API batch post failed"` - **Color**: Red (error) -- **When**: Batch API POST fails during flush operation -- **Notes**: Batch posting failed, but queue system will continue accepting new messages. -- **Source**: `content/wardrive.js:flushApiQueue()` error handler +- **When**: ~~Batch API POST fails during flush operation~~ **REPLACED BY NEW WARDRIVE API** +- **Notes**: Replaced by "Error: API submission failed" in new wardrive API system. +- **Source**: ~~`content/wardrive.js:flushApiQueue()`~~ Replaced by `submitWardriveData()` + +##### Error: API submission failed +- **Message**: `"Error: API submission failed"` +- **Color**: Red (error) +- **When**: Wardrive data submission fails after 2 retry attempts +- **Notes**: Entries are re-queued for next submission attempt (unless queue is full). Does not trigger disconnect. +- **Source**: `content/wardrive.js:submitWardriveData()` error handler + +##### Session expired +- **Message**: `"Session expired"` +- **Color**: Red (error) +- **When**: Wardrive API returns `success=false` with reason `session_expired`, `session_invalid`, or `session_revoked` +- **Terminal State**: Yes (triggers disconnect) +- **Notes**: Session is no longer valid, triggers automatic disconnect after 1.5 seconds. +- **Source**: `content/wardrive.js:handleWardriveApiError()` + +##### Invalid session +- **Message**: `"Invalid session"` +- **Color**: Red (error) +- **When**: Wardrive API returns `success=false` with reason `bad_session` +- **Terminal State**: Yes (triggers disconnect) +- **Notes**: Session ID is invalid or doesn't match API key, triggers automatic disconnect after 1.5 seconds. +- **Source**: `content/wardrive.js:handleWardriveApiError()` + +##### Authorization failed +- **Message**: `"Authorization failed"` +- **Color**: Red (error) +- **When**: Wardrive API returns `success=false` with reason `invalid_key`, `unauthorized`, or `bad_key` +- **Terminal State**: Yes (triggers disconnect) +- **Notes**: API key issue, triggers automatic disconnect after 1.5 seconds. +- **Source**: `content/wardrive.js:handleWardriveApiError()` + +##### Outside zone +- **Message**: `"Outside zone"` +- **Color**: Red (error) +- **When**: Wardrive API returns `success=false` with reason `outside_zone` +- **Terminal State**: Yes (triggers disconnect) +- **Notes**: User has moved outside their assigned zone during active wardrive session, triggers automatic disconnect after 1.5 seconds. +- **Source**: `content/wardrive.js:handleWardriveApiError()` + +##### Zone capacity changed +- **Message**: `"Zone capacity changed"` +- **Color**: Red (error) +- **When**: Wardrive API returns `success=false` with reason `zone_full` during active wardrive session +- **Terminal State**: Yes (triggers disconnect) +- **Notes**: Zone TX capacity changed during active session (unexpected mid-session), triggers automatic disconnect after 1.5 seconds. Note: `zone_full` during auth is handled as RX-only mode (partial success), not an error. +- **Source**: `content/wardrive.js:handleWardriveApiError()` + +##### Rate limited - slow down +- **Message**: `"Rate limited - slow down"` +- **Color**: Yellow (warning) +- **When**: Wardrive API returns `success=false` with reason `rate_limited` +- **Terminal State**: No (does not trigger disconnect) +- **Notes**: Submitting data too quickly. Does not trigger disconnect, user should slow down pings. +- **Source**: `content/wardrive.js:handleWardriveApiError()` + +##### API error: [message] +- **Message**: `"API error: [message]"` (where [message] is the API-provided error message) +- **Color**: Red (error) +- **When**: Wardrive API returns `success=false` with an unknown reason code +- **Terminal State**: No (does not trigger disconnect) +- **Notes**: Fallback message for unknown error codes. Shows raw API message to help with debugging. Logged to Error Log but does not trigger disconnect (allows recovery from transient/unknown errors). +- **Source**: `content/wardrive.js:handleWardriveApiError()` ##### Error: API post failed (DEPRECATED) - **Message**: `"Error: API post failed"` - **Color**: Red (error) - **When**: ~~Background API POST fails during asynchronous posting~~ **REPLACED BY BATCH QUEUE** -- **Notes**: Replaced by "Error: API batch post failed" in batch queue system. +- **Notes**: Replaced by "Error: API submission failed" in new wardrive API system. - **Source**: ~~`content/wardrive.js:postApiInBackground()`~~ Replaced by batch queue system ##### — (em dash) @@ -388,7 +523,7 @@ These messages use a hybrid approach: **first display respects 500ms minimum**, - **Notes**: With the new ping/repeat listener flow, the em dash appears immediately after the 10-second RX window, not after API posting (which now runs in background) - **Source**: Multiple locations - `content/wardrive.js` -#### 7. Auto Mode Messages +#### 9. Auto Mode Messages ##### TX/RX Auto stopped - **Message**: `"Auto mode stopped"` @@ -426,7 +561,7 @@ These messages use a hybrid approach: **first display respects 500ms minimum**, - **When**: Browser tab hidden while RX Auto mode running - **Source**: `content/wardrive.js:visibilitychange handler` -#### 8. Error Messages +#### 10. Error Messages ##### Select radio power to connect - **Message**: `"Select radio power to connect"` @@ -540,10 +675,11 @@ Status messages follow these consistent conventions: **Connection Status Bar**: 4 fixed messages (Connected, Connecting, Disconnected, Disconnecting) -**Dynamic App Status Bar**: ~30+ unique message patterns covering: +**Dynamic App Status Bar**: ~40+ unique message patterns covering: - Capacity check: 9 messages (including session_id error messages) - Channel setup: 4 messages - GPS initialization: 3 messages +- Geo-auth zone check: 7 messages (with dual display in connection bar and settings panel) - Ping operations: 6 messages - Countdown timers: 6 message patterns - API/Map: 2 messages (including em dash placeholder) @@ -556,3 +692,4 @@ Status messages follow these consistent conventions: - Em dash (`—`) placeholder for empty dynamic status - Connection words blocked from dynamic bar - All error reasons appear WITHOUT "Disconnected:" prefix +- Zone status has dual display: connection bar (when disconnected) + settings panel (always visible) diff --git a/docs/zOther/javascript/test_single_packet.mjs b/docs/zOther/javascript/test_single_packet.mjs new file mode 100644 index 0000000..789eb03 --- /dev/null +++ b/docs/zOther/javascript/test_single_packet.mjs @@ -0,0 +1,437 @@ +// Test single packet decoding against RX filter +// Run with: node test_single_packet.js + +const crypto = require('crypto'); +const aesjs = require('aes-js'); + +// Configuration +const CHANNEL_NAME = '#wardriving'; +const MAX_RX_PATH_LENGTH = 9; +const CHANNEL_GROUP_TEXT_HEADER = 0x15; // GRP_TXT (FLOOD) +const ADVERT_HEADER = 0x11; // ADVERT (FLOOD) +const GROUP_DATA_HEADER = 0x19; // GRP_DATA (FLOOD) +const TRACE_HEADER = 0x25; // TRACE (FLOOD) +const RX_PRINTABLE_THRESHOLD = 0.80; + +// Test packets - uncomment the one you want to test +// GRP_TXT packet (current working example) +const TEST_PACKET_HEX = '15014E81ADF6994196D67F3F3286F4525F0E81C5D522D79FF9216519D973F80CE73CB4685CBFDE96700FCE9FE98E58C26C003A1414437B05D40949711DAF8488436FA5511B18'; +// ADVERT packet example (would need real data) +// const TEST_PACKET_HEX = '11...'; +// GRP_DATA packet example (would need real data) +// const TEST_PACKET_HEX = '19...'; +// TRACE packet example (would need real data) +// const TEST_PACKET_HEX = '25...'; + +const TEST_PACKET = Buffer.from(TEST_PACKET_HEX.replace(/\s+/g, ''), 'hex'); + +console.log('========== RX PACKET FILTER TEST =========='); +console.log(`Testing packet: ${TEST_PACKET_HEX}`); +console.log(`Packet length: ${TEST_PACKET.length} bytes\n`); + +// Derive channel key +async function deriveChannelKey(channelName) { + const normalizedName = channelName.toLowerCase(); + const data = Buffer.from(normalizedName, 'utf-8'); + + // Hash using SHA-256 (matching wardrive.js implementation) + const hash = crypto.createHash('sha256').update(data).digest(); + + // Take first 16 bytes + return hash.slice(0, 16); +} + +// Compute channel hash +async function computeChannelHash(channelSecret) { + const hash = crypto.createHash('sha256').update(channelSecret).digest(); + return hash[0]; +} + +// Get printable ratio +function getPrintableRatio(str) { + if (str.length === 0) return 0; + let printableCount = 0; + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if ((code >= 32 && code <= 126) || code === 9 || code === 10 || code === 13) { + printableCount++; + } + } + return printableCount / str.length; +} + +// Check if string contains only strict ASCII characters (32-126) +function isStrictAscii(str) { + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code < 32 || code > 126) { + return false; + } + } + return true; +} + +// Parse ADVERT packet name +function parseAdvertName(payload) { + try { + // ADVERT structure: [32 bytes pubkey][4 bytes timestamp][64 bytes signature][1 byte flags][name...] + const PUBKEY_SIZE = 32; + const TIMESTAMP_SIZE = 4; + const SIGNATURE_SIZE = 64; + const FLAGS_SIZE = 1; + const NAME_OFFSET = PUBKEY_SIZE + TIMESTAMP_SIZE + SIGNATURE_SIZE + FLAGS_SIZE; + + if (payload.length <= NAME_OFFSET) { + return { valid: false, name: '', reason: 'payload too short for name' }; + } + + const nameBytes = payload.slice(NAME_OFFSET); + const decoder = new TextDecoder('utf-8', { fatal: false }); + const name = decoder.decode(nameBytes).replace(/\0+$/, '').trim(); + + console.log(` ADVERT name extracted: "${name}" (${name.length} chars)`); + + if (name.length === 0) { + return { valid: false, name: '', reason: 'name empty' }; + } + + // Check if name is printable + const printableRatio = getPrintableRatio(name); + console.log(` ADVERT name printable ratio: ${(printableRatio * 100).toFixed(1)}%`); + + if (printableRatio < 0.9) { + return { valid: false, name: name, reason: 'name not printable' }; + } + + // Check strict ASCII (no extended characters) + if (!isStrictAscii(name)) { + return { valid: false, name: name, reason: 'name contains non-ASCII chars' }; + } + + return { valid: true, name: name, reason: 'kept' }; + + } catch (error) { + console.error(` Error parsing ADVERT name: ${error.message}`); + return { valid: false, name: '', reason: 'parse error' }; + } +} + +// Decrypt GRP_TXT payload +function decryptGroupTextPayload(payload, channelKey) { + try { + if (payload.length < 3) { + console.log(' ❌ Payload too short for decryption'); + return null; + } + + const channelHash = payload[0]; + const cipherMAC = payload.slice(1, 3); + const encryptedData = payload.slice(3); + + console.log(` Channel hash: 0x${channelHash.toString(16).padStart(2, '0')}`); + console.log(` Cipher MAC: ${cipherMAC.toString('hex')}`); + console.log(` Encrypted data: ${encryptedData.length} bytes`); + + if (encryptedData.length === 0) { + console.log(' ❌ No encrypted data'); + return null; + } + + // AES-ECB decryption + const aesCbc = new aesjs.ModeOfOperation.ecb(Array.from(channelKey)); + const blockSize = 16; + + // Calculate how many full blocks we have + const numBlocks = Math.floor(encryptedData.length / blockSize); + const decryptedBytes = Buffer.alloc(numBlocks * blockSize); + + for (let i = 0; i < numBlocks; i++) { + const blockStart = i * blockSize; + const block = Array.from(encryptedData.slice(blockStart, blockStart + blockSize)); + const decryptedBlock = aesCbc.decrypt(block); + decryptedBytes.set(decryptedBlock, blockStart); + } + + console.log(` Decrypted bytes (hex): ${decryptedBytes.slice(0, 32).toString('hex')}...`); + + // Parse: [4 bytes timestamp][1 byte flags][message] + if (decryptedBytes.length < 5) { + console.log(' ❌ Decrypted data too short'); + return null; + } + + const timestamp = decryptedBytes.readUInt32LE(0); + const flags = decryptedBytes[4]; + const messageBytes = decryptedBytes.slice(5); + + // Find null terminator + let endIdx = messageBytes.indexOf(0); + if (endIdx === -1) endIdx = messageBytes.length; + + const messageText = messageBytes.slice(0, endIdx).toString('utf-8').trim(); + + console.log(` Timestamp: ${timestamp} (${new Date(timestamp * 1000).toISOString()})`); + console.log(` Flags: 0x${flags.toString(16).padStart(2, '0')}`); + console.log(` Message: "${messageText}"`); + + return messageText; + + } catch (error) { + console.log(` ❌ Decryption error: ${error.message}`); + return null; + } +} + +// Validate GRP_TXT packet +async function validateGrpTxtPacket(metadata) { + console.log('Step 4: Derive channel key and hash'); + console.log('─────────────────────────────────'); + const channelKey = await deriveChannelKey(CHANNEL_NAME); + const channelHash = await computeChannelHash(channelKey); + + console.log(`Channel: ${CHANNEL_NAME}`); + console.log(`Derived key: ${channelKey.toString('hex')}`); + console.log(`Computed hash: 0x${channelHash.toString(16).padStart(2, '0')}\n`); + + console.log('Step 5: Validate channel hash'); + console.log('─────────────────────────────────'); + const packetChannelHash = metadata.encryptedPayload[0]; + console.log(`Packet channel hash: 0x${packetChannelHash.toString(16).padStart(2, '0')}`); + console.log(`Expected hash: 0x${channelHash.toString(16).padStart(2, '0')}`); + + if (packetChannelHash !== channelHash) { + console.log(`❌ DROPPED: unknown channel hash`); + return; + } + console.log(`✓ Channel hash matches!\n`); + + console.log('Step 6: Decrypt message'); + console.log('─────────────────────────────────'); + const plaintext = decryptGroupTextPayload(metadata.encryptedPayload, channelKey); + + if (!plaintext) { + console.log(`❌ DROPPED: decrypt failed`); + return; + } + console.log(`✓ Decryption successful\n`); + + console.log('Step 7: Validate printable ratio'); + console.log('─────────────────────────────────'); + const printableRatio = getPrintableRatio(plaintext); + console.log(`Printable ratio: ${(printableRatio * 100).toFixed(1)}%`); + console.log(`Threshold: ${(RX_PRINTABLE_THRESHOLD * 100).toFixed(1)}%`); + + if (printableRatio < RX_PRINTABLE_THRESHOLD) { + console.log(`❌ DROPPED: plaintext not printable`); + return; + } + console.log(`✓ Printable ratio OK\n`); + + console.log('═══════════════════════════════════════'); + console.log('✅ GRP_TXT PACKET PASSED ALL VALIDATIONS!'); + console.log('═══════════════════════════════════════'); + console.log(`\nFinal decrypted message: "${plaintext}"`); +} + +// Validate ADVERT packet +function validateAdvertPacket(metadata) { + console.log('Step 4: Parse ADVERT name'); + console.log('─────────────────────────────────'); + const nameResult = parseAdvertName(metadata.encryptedPayload); + + if (!nameResult.valid) { + console.log(`❌ DROPPED: ${nameResult.reason}`); + return; + } + + console.log('═══════════════════════════════════════'); + console.log('✅ ADVERT PACKET PASSED ALL VALIDATIONS!'); + console.log('═══════════════════════════════════════'); + console.log(`\nNode name: "${nameResult.name}"`); +} + +// Validate GRP_DATA packet (placeholder - would need real implementation) +function validateGrpDataPacket(metadata) { + console.log('Step 4: Validate GRP_DATA'); + console.log('─────────────────────────────────'); + console.log('⚠️ GRP_DATA validation not fully implemented yet'); + console.log(' This would validate channel hash and decrypt structured data'); + console.log(' (Similar to GRP_TXT but for binary data instead of text)'); + + // For now, just check minimum payload length + if (metadata.encryptedPayload.length < 3) { + console.log(`❌ DROPPED: GRP_DATA payload too short (${metadata.encryptedPayload.length} bytes)`); + return; + } + + console.log(`✓ Minimum payload length OK (${metadata.encryptedPayload.length} bytes)`); + console.log('═══════════════════════════════════════'); + console.log('✅ GRP_DATA PACKET PASSED BASIC VALIDATION!'); + console.log('═══════════════════════════════════════'); +} + +// Validate TRACE packet (placeholder - would need real implementation) +function validateTracePacket(metadata) { + console.log('Step 4: Validate TRACE'); + console.log('─────────────────────────────────'); + console.log('⚠️ TRACE validation not fully implemented yet'); + console.log(' This would parse SNR data for each hop in the path'); + console.log(' Very valuable for coverage mapping!'); + + // For now, just check minimum payload length + if (metadata.encryptedPayload.length < 1) { + console.log(`❌ DROPPED: TRACE payload too short (${metadata.encryptedPayload.length} bytes)`); + return; + } + + console.log(`✓ Minimum payload length OK (${metadata.encryptedPayload.length} bytes)`); + console.log('═══════════════════════════════════════'); + console.log('✅ TRACE PACKET PASSED BASIC VALIDATION!'); + console.log('═══════════════════════════════════════'); +} + +// Parse packet metadata +function parseRxPacketMetadata(raw) { + const header = raw[0]; + const routeType = header & 0x03; + + // For FLOOD (0x01), path length is in byte 1 + // For DIRECT (0x02), path length is in byte 2 + let pathLengthOffset = 1; + if (routeType === 0x02) { + pathLengthOffset = 2; + } + + const pathLength = raw[pathLengthOffset]; + + const pathStartOffset = pathLengthOffset + 1; + const pathBytes = raw.slice(pathStartOffset, pathStartOffset + pathLength); + + const firstHop = pathLength > 0 ? pathBytes[0] : null; + const lastHop = pathLength > 0 ? pathBytes[pathLength - 1] : null; + + const encryptedPayload = raw.slice(pathStartOffset + pathLength); + + return { + raw: raw, + header: header, + routeType: routeType, + pathLength: pathLength, + pathBytes: pathBytes, + firstHop: firstHop, + lastHop: lastHop, + encryptedPayload: encryptedPayload + }; +} + +// Main test +async function testPacket() { + try { + console.log('Step 1: Parse packet metadata'); + console.log('─────────────────────────────────'); + const metadata = parseRxPacketMetadata(TEST_PACKET); + + console.log(`Header: 0x${metadata.header.toString(16).padStart(2, '0')}`); + console.log(`Route type: ${metadata.routeType} (${metadata.routeType === 1 ? 'FLOOD' : 'OTHER'})`); + console.log(`Path length: ${metadata.pathLength} bytes`); + console.log(`Path: ${Array.from(metadata.pathBytes).map(b => b.toString(16).padStart(2, '0')).join(' ')}`); + console.log(`First hop: 0x${metadata.firstHop?.toString(16).padStart(2, '0')}`); + console.log(`Last hop: 0x${metadata.lastHop?.toString(16).padStart(2, '0')}`); + console.log(`Encrypted payload: ${metadata.encryptedPayload.length} bytes\n`); + + console.log('Step 2: Validate path length'); + console.log('─────────────────────────────────'); + if (metadata.pathLength > MAX_RX_PATH_LENGTH) { + console.log(`❌ DROPPED: pathLen>${MAX_RX_PATH_LENGTH} (${metadata.pathLength} hops)`); + return; + } + console.log(`✓ Path length OK (${metadata.pathLength} ≤ ${MAX_RX_PATH_LENGTH})\n`); + + console.log('Step 3: Validate packet type'); + console.log('─────────────────────────────────'); + + // Handle different packet types + if (metadata.header === CHANNEL_GROUP_TEXT_HEADER) { + console.log(`✓ Packet type: GRP_TXT (0x15)\n`); + await validateGrpTxtPacket(metadata); + + } else if (metadata.header === ADVERT_HEADER) { + console.log(`✓ Packet type: ADVERT (0x11)\n`); + validateAdvertPacket(metadata); + + } else if (metadata.header === GROUP_DATA_HEADER) { + console.log(`✓ Packet type: GRP_DATA (0x19)\n`); + validateGrpDataPacket(metadata); + + } else if (metadata.header === TRACE_HEADER) { + console.log(`✓ Packet type: TRACE (0x25)\n`); + validateTracePacket(metadata); + + } else { + console.log(`❌ DROPPED: unsupported ptype (header=0x${metadata.header.toString(16).padStart(2, '0')})`); + return; + } + + } catch (error) { + console.error('❌ Test error:', error.message); + console.error(error.stack); + } +} + + console.log('Step 4: Derive channel key and hash'); + console.log('─────────────────────────────────'); + const channelKey = await deriveChannelKey(CHANNEL_NAME); + const channelHash = await computeChannelHash(channelKey); + + console.log(`Channel: ${CHANNEL_NAME}`); + console.log(`Derived key: ${channelKey.toString('hex')}`); + console.log(`Computed hash: 0x${channelHash.toString(16).padStart(2, '0')}\n`); + + console.log('Step 5: Validate channel hash'); + console.log('─────────────────────────────────'); + const packetChannelHash = metadata.encryptedPayload[0]; + console.log(`Packet channel hash: 0x${packetChannelHash.toString(16).padStart(2, '0')}`); + console.log(`Expected hash: 0x${channelHash.toString(16).padStart(2, '0')}`); + + if (packetChannelHash !== channelHash) { + console.log(`❌ DROPPED: unknown channel hash`); + return; + } + console.log(`✓ Channel hash matches!\n`); + + console.log('Step 6: Decrypt message'); + console.log('─────────────────────────────────'); + const plaintext = decryptGroupTextPayload(metadata.encryptedPayload, channelKey); + + if (!plaintext) { + console.log(`❌ DROPPED: decrypt failed`); + return; + } + console.log(`✓ Decryption successful\n`); + + console.log('Step 7: Validate printable ratio'); + console.log('─────────────────────────────────'); + const printableRatio = getPrintableRatio(plaintext); + console.log(`Printable ratio: ${(printableRatio * 100).toFixed(1)}%`); + console.log(`Threshold: ${(RX_PRINTABLE_THRESHOLD * 100).toFixed(1)}%`); + + if (printableRatio < RX_PRINTABLE_THRESHOLD) { + console.log(`❌ DROPPED: plaintext not printable`); + return; + } + console.log(`✓ Printable ratio OK\n`); + + console.log('═══════════════════════════════════════'); + console.log('✅ PACKET PASSED ALL VALIDATIONS!'); + console.log('═══════════════════════════════════════'); + console.log(`\nFinal decrypted message: "${plaintext}"`); + + } catch (error) { + console.error('❌ Test error:', error.message); + console.error(error.stack); + } +} + +(async () => { + await testPacket(); +})(); diff --git a/index.html b/index.html index 195d219..9d19af0 100644 --- a/index.html +++ b/index.html @@ -10,59 +10,67 @@ - + - - - - - - + + -
+
-

- MeshCore +

+ MeshCore MeshMapper

- v1.0 + v1.0
-
-
- - Disconnected -
-
- Device: - - -
+
+ + Disconnected +
+
+ +
-
@@ -95,59 +103,90 @@

Settings<

- +
-
- +
-
+ + +
+ Waiting for connection... +
+ + + + + +
- +
- -
-
- Bluetooth: - - + + + +
+
+ Model: + +
+
+ Channel: +
-
+
+ + +
+
Location: - - +
-
- Channel: - - +
+ Slots: + N/A
@@ -160,15 +199,21 @@

Settings< -
+
+ + @@ -224,7 +269,7 @@

Settings<

TX Log

| - 0 pings + Pings: 0
@@ -256,7 +301,7 @@

RX Log

| - 0 observations • 0 dropped + Handled: 0 Drop: 0
@@ -286,7 +331,7 @@

Error Log

| - 0 errors + Events: 0
@@ -315,14 +360,8 @@

Notes

  • Requires Bluetooth and Location permissions
  • Keep app in foreground with screen on & unlocked
  • -
  • YOW region only
  • -
  • Sends location to #wardriving for coverage map
  • ⚠️ Not supported in Safari — Use Bluefy on iOS
-

- Fork of kallanreed/mesh-map, - modified for meshmapper.net -

@@ -334,5 +373,26 @@

Notes

import { onLoad } from './content/wardrive.js'; onLoad(); + + +