diff --git a/README.md b/README.md index 12ce720..07e8abc 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ Boot recovery: 1. If `/session.json` validates, promote it to `/prev_session.json` 2. Otherwise try `/session.tmp` (interrupted save) 3. Delete both working files, start with an empty live table -4. `/prev_session.json` stays around for inspection +4. `/prev_session.json` stays around — host pulls it via `CMD:DUMP_PREV` (see "Host command protocol" below) CRC32 uses the standard `0xEDB88320` polynomial so the same file can be verified on a host with any off-the-shelf CRC tool. @@ -169,6 +169,57 @@ The firmware emits one JSON line per detection in the same schema the BLE detect - `wifi_oui_addr3` — BSSID OUI match (mgmt frames only; disabled by default) - `wifi_ssid` — SSID keyword match (disabled by default) +### Host command protocol + +The firmware also accepts line-delimited ASCII commands on the same +USB-CDC port so Flask (or any host) can pull stored detections, query +device status, or wipe state without re-flashing. All commands are +terminated with `\n`; every reply is a single JSON object on its own +line, matching the existing `{"event":...}` schema. + +| Command | Reply event | Notes | +|---|---|---| +| `CMD:STATUS` | `status` | Live counters: `fy_det`, `oui_count`, `spiffs`, `prev_session`, `uptime_ms`, `free_heap`, `channel`, `rssi_min` | +| `CMD:VERSION` | `version` | Firmware identifier + compile-time constants (`oui_count`, `max_detections`, `autosave_ms`) | +| `CMD:DUMP_LIVE` | N × `detection` then `replay_complete` | Streams the current in-RAM detection table; each line has `"replay":true,"replay_source":"live"` | +| `CMD:DUMP_PREV` | N × `detection` then `replay_complete` | Same shape but reads `/prev_session.json` from SPIFFS — i.e. what the device caught before the last reboot | +| `CMD:CLEAR_LIVE` | `clear` | Empties `fyDet[]`; the next autosave overwrites the persisted session | +| `CMD:CLEAR_PREV` | `clear` | Deletes `/prev_session.json` and any leftover `/session.tmp` | + +A replayed detection line: + +```json +{"event":"detection","replay":true,"replay_source":"prev","detection_method":"wifi_oui_addr2","protocol":"wifi_2_4ghz","mac_address":"aa:bb:cc:dd:ee:ff","oui":"aa:bb:cc","device_name":"","rssi":-62,"channel":6,"frequency":2437,"ssid":"","detection_count":17,"device_first_ms":12345678,"device_last_ms":18900000} +``` + +`device_first_ms` / `device_last_ms` are the device's monotonic millis at +the time of recording — useful for ordering, but not wall-clock. Flask +treats replayed entries as historical (`timestamp_source: device_replay`), +skips GPS temporal matching, and does not overwrite a fresher live entry +for the same MAC. + +Flask exposes the protocol as REST endpoints: + +| Endpoint | Method | Sends | Returns when | +|---|---|---|---| +| `/api/flock/status` | GET | `CMD:STATUS` | `status` event arrives | +| `/api/flock/version` | GET | `CMD:VERSION` | `version` event arrives | +| `/api/flock/dump_prev` | POST | `CMD:DUMP_PREV` | `replay_complete` arrives (or 30 s timeout) | +| `/api/flock/dump_live` | POST | `CMD:DUMP_LIVE` | `replay_complete` arrives (or 30 s timeout) | +| `/api/flock/clear_prev` | POST | `CMD:CLEAR_PREV` | `clear` event arrives | +| `/api/flock/clear_live` | POST | `CMD:CLEAR_LIVE` | `clear` event arrives | + +The typical "I just plugged the device back in after wardriving" workflow: + +```bash +curl -X POST http://localhost:5000/api/flock/dump_prev +curl -X POST http://localhost:5000/api/flock/clear_prev +``` + +The first call pulls everything the device caught since you last had it +connected and adds it to the cumulative dataset; the second wipes the +file from SPIFFS so the next run starts clean. + ### GPS wardriving GPS is handled Flask-side, since the ESP32 radio is dedicated to sniffing and there's no on-device AP. Two options: diff --git a/api/flockyou.py b/api/flockyou.py index 81ea010..4167447 100644 --- a/api/flockyou.py +++ b/api/flockyou.py @@ -40,6 +40,19 @@ serial_queue = queue.Queue() next_detection_id = 1 # Unique ID counter settings = {'gps_port': '', 'flock_port': '', 'filter': 'all'} +# Host ↔ firmware command coordination (matches the CMD:* protocol in +# main.cpp). One serialized command at a time; replies arrive on the +# normal serial reader thread and are dispatched by `event` type. +command_lock = threading.Lock() +_cmd_state = { + 'status': {'data': None, 'event': threading.Event()}, + 'version': {'data': None, 'event': threading.Event()}, + 'replay_complete': {'data': None, 'event': threading.Event()}, + 'clear': {'data': None, 'event': threading.Event()}, + 'error': {'data': None, 'event': threading.Event()}, +} +replay_progress = {'in_progress': False, 'source': None, 'received': 0} + # Data storage paths DATA_DIR = Path('data') CUMULATIVE_DATA_FILE = DATA_DIR / 'cumulative_detections.pkl' @@ -269,7 +282,13 @@ def flock_reader(): # Try to parse as detection data try: data = json.loads(line) - if 'detection_method' in data: + if data.get('event') in ('status', 'version', 'replay_complete', 'clear', 'error'): + # Command response — wake any blocked caller and emit to UI. + handle_command_event(data) + elif data.get('replay') and 'detection_method' in data: + # Historical detection replayed from device memory. + add_replay_detection_from_serial(data) + elif 'detection_method' in data: # Map ESP32 GPS from phone to Flask GPS format esp_gps = data.get('gps') if esp_gps: @@ -496,6 +515,118 @@ def add_detection_from_serial(data): safe_socket_emit('new_detection', data) print(f"New detection added: ID {data['id']}, Method: {data.get('detection_method')}, MAC: {mac_address}") + +def add_replay_detection_from_serial(data): + """Ingest a replayed historical detection from the device's SPIFFS or + live table. These don't get GPS temporal matching (no wall-clock at the + time the device recorded them) and don't overwrite a fresher live entry + if we've already seen the MAC in this Flask session.""" + global detections, cumulative_detections, next_detection_id + + mac_address = data.get('mac_address') + if not mac_address: + return + + if 'mac_address' in data: + data['manufacturer'] = lookup_manufacturer(mac_address) + + # Stamp the replay arrival time so the UI has SOMETHING to show, but + # flag the source as device-memory so it isn't confused with a live hit. + arrival = datetime.now().isoformat() + data.setdefault('server_timestamp', arrival) + data['timestamp_source'] = 'device_replay' + + # The device wrote `device_first_ms` / `device_last_ms` as monotonic + # millis since its boot. They're meaningless as wall-clock, but useful + # for ordering — preserve them verbatim. + + replay_progress['received'] = replay_progress.get('received', 0) + 1 + + existing = None + for det in detections: + if det.get('mac_address') == mac_address: + existing = det + break + + if existing: + # Live data is fresher than memory dump — keep first_seen and the + # most recent live RSSI/channel. Only bump the counter so the user + # sees that the device had additional historical hits. + device_count = data.get('detection_count') or 0 + if device_count > existing.get('detection_count', 0): + existing['detection_count'] = device_count + existing['replay_merged'] = True + existing['device_first_ms'] = data.get('device_first_ms') + existing['device_last_ms'] = data.get('device_last_ms') + + for cum in cumulative_detections: + if cum.get('mac_address') == mac_address: + cum.update(existing) + break + save_cumulative_detections() + safe_socket_emit('detection_updated', existing) + else: + data['id'] = next_detection_id + next_detection_id += 1 + data['alias'] = '' + data.setdefault('detection_count', 1) + # We don't know the real first/last_seen wall-clock — mark as N/A + # so the UI can show "from device memory" instead of misleading + # current time stamps. + data.setdefault('first_seen', None) + data.setdefault('last_seen', None) + + detections.append(data) + cumulative_detections.append(data.copy()) + save_cumulative_detections() + safe_socket_emit('replay_detection', data) + print(f"Replay detection added: ID {data['id']}, MAC: {mac_address}, " + f"src: {data.get('replay_source')}, count: {data.get('detection_count')}") + + +def handle_command_event(data): + """Dispatch a {"event":...} response from the firmware to whichever + caller is blocked waiting on it, and forward to the UI.""" + ev = data.get('event') + if ev == 'replay_complete': + replay_progress['in_progress'] = False + replay_progress['source'] = data.get('source') + holder = _cmd_state.get(ev) + if holder: + holder['data'] = data + holder['event'].set() + safe_socket_emit(f'flock_{ev}', data) + print(f"Flock cmd event: {ev} → {data}") + + +def send_command(cmd, response_event_name, timeout=10.0): + """Send a CMD:* line to the device and block until the firmware emits + the matching response event. Returns the response dict or None on + timeout / disconnect.""" + global flock_serial_connection + with command_lock: + if not flock_serial_connection or not flock_serial_connection.is_open: + return None + holder = _cmd_state[response_event_name] + holder['data'] = None + holder['event'].clear() + if response_event_name == 'replay_complete': + replay_progress['in_progress'] = True + replay_progress['source'] = None + replay_progress['received'] = 0 + try: + flock_serial_connection.write((cmd + '\n').encode('ascii')) + flock_serial_connection.flush() + except Exception as e: + print(f"send_command write failed: {e}") + replay_progress['in_progress'] = False + return None + if holder['event'].wait(timeout): + return holder['data'] + replay_progress['in_progress'] = False + return None + + def connection_monitor(): """Background thread for monitoring device connections""" global gps_enabled, flock_device_connected, serial_connection, reconnect_attempts @@ -765,9 +896,112 @@ def disconnect_flock(): if flock_serial_connection and flock_serial_connection.is_open: flock_serial_connection.close() flock_serial_connection = None - + return jsonify({'status': 'success', 'message': 'Flock You device disconnected'}) + +def _require_flock_connected(): + if not flock_device_connected or not flock_serial_connection or not flock_serial_connection.is_open: + return jsonify({'status': 'error', 'message': 'Flock device not connected'}), 400 + return None + + +@app.route('/api/flock/status', methods=['GET']) +def flock_status(): + """Query the firmware for live status (det count, SPIFFS state, uptime). + + Sends CMD:STATUS to the device and waits up to 2 seconds for the + `{"event":"status",...}` reply.""" + err = _require_flock_connected() + if err is not None: + return err + reply = send_command('CMD:STATUS', 'status', timeout=2.0) + if reply is None: + return jsonify({'status': 'error', 'message': 'Device did not respond (timeout)'}), 504 + return jsonify({'status': 'success', 'firmware_status': reply}) + + +@app.route('/api/flock/version', methods=['GET']) +def flock_version(): + """Query the firmware for its version / OUI count / max detections.""" + err = _require_flock_connected() + if err is not None: + return err + reply = send_command('CMD:VERSION', 'version', timeout=2.0) + if reply is None: + return jsonify({'status': 'error', 'message': 'Device did not respond (timeout)'}), 504 + return jsonify({'status': 'success', 'firmware_version': reply}) + + +@app.route('/api/flock/dump_prev', methods=['POST']) +def flock_dump_prev(): + """Pull the previous session's persisted detections from device SPIFFS. + + Detection lines stream in via the serial reader and are added to the + live + cumulative detection lists. Returns when replay_complete arrives + (or after a 30-second timeout).""" + err = _require_flock_connected() + if err is not None: + return err + reply = send_command('CMD:DUMP_PREV', 'replay_complete', timeout=30.0) + if reply is None: + return jsonify({'status': 'error', 'message': 'Replay timed out', + 'received': replay_progress.get('received', 0)}), 504 + return jsonify({ + 'status': 'success' if reply.get('ok') else 'error', + 'count': reply.get('count', 0), + 'received': replay_progress.get('received', 0), + 'source': reply.get('source'), + 'reason': reply.get('reason'), + }) + + +@app.route('/api/flock/dump_live', methods=['POST']) +def flock_dump_live(): + """Pull the device's current in-RAM detection table. Same flow as + dump_prev, but reads fyDet[] instead of /prev_session.json.""" + err = _require_flock_connected() + if err is not None: + return err + reply = send_command('CMD:DUMP_LIVE', 'replay_complete', timeout=30.0) + if reply is None: + return jsonify({'status': 'error', 'message': 'Replay timed out', + 'received': replay_progress.get('received', 0)}), 504 + return jsonify({ + 'status': 'success' if reply.get('ok') else 'error', + 'count': reply.get('count', 0), + 'received': replay_progress.get('received', 0), + 'source': reply.get('source'), + }) + + +@app.route('/api/flock/clear_prev', methods=['POST']) +def flock_clear_prev(): + """Delete /prev_session.json on the device (and any leftover /session.tmp).""" + err = _require_flock_connected() + if err is not None: + return err + reply = send_command('CMD:CLEAR_PREV', 'clear', timeout=2.0) + if reply is None: + return jsonify({'status': 'error', 'message': 'Device did not respond (timeout)'}), 504 + return jsonify({'status': 'success' if reply.get('ok') else 'error', + 'firmware': reply}) + + +@app.route('/api/flock/clear_live', methods=['POST']) +def flock_clear_live(): + """Wipe the device's in-RAM detection table. Forces the next autosave + to overwrite the persisted session.""" + err = _require_flock_connected() + if err is not None: + return err + reply = send_command('CMD:CLEAR_LIVE', 'clear', timeout=2.0) + if reply is None: + return jsonify({'status': 'error', 'message': 'Device did not respond (timeout)'}), 504 + return jsonify({'status': 'success' if reply.get('ok') else 'error', + 'firmware': reply}) + + @app.route('/api/status', methods=['GET']) def get_status(): """Get connection status of both devices""" diff --git a/main.cpp b/main.cpp index e610564..7494e1c 100644 --- a/main.cpp +++ b/main.cpp @@ -237,8 +237,11 @@ typedef struct __attribute__((packed)) { // HELPERS // ============================================================ -// Dual-output: prints to both Serial (USB) and Serial1 (GPIO43) -static char _dualBuf[384]; +// Dual-output: prints to both Serial (USB) and Serial1 (GPIO43). +// Sized to fit the longest line we emit: a replay-detection JSON record +// with worst-case JSON-escaped SSID (32 chars → up to 192 bytes) plus the +// envelope fields — ~600 B comfortably under 1024. +static char _dualBuf[1024]; static void dualPrintf(const char* fmt, ...) __attribute__((format(printf, 1, 2))); static void dualPrintf(const char* fmt, ...) { @@ -792,6 +795,318 @@ static void emitDetectionJSON(const char* mac, const char* method, (unsigned)ch, (unsigned)channelFreqMhz(ch), ssidEsc); } +// Replay emission — used for both live-table dumps and SPIFFS-backed +// historical dumps. Same Flask JSON shape as live detections, but flagged +// with "replay":true and the source ("live"|"prev") plus the device's +// monotonic millis() snapshots so the host can decide how to present them. +static void emitReplayDetection(const char* mac, const char* method, + int8_t rssi, uint8_t ch, const char* ssid, + uint16_t count, + uint32_t firstMs, uint32_t lastMs, + const char* source) { + char ssidEsc[sizeof(((FYDetection*)0)->ssid) * 6 + 1]; + jsonEscape(ssidEsc, sizeof(ssidEsc), ssid ? ssid : ""); + char oui[9]; + uint8_t mbytes[6] = {0}; + sscanf(mac, "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", + &mbytes[0], &mbytes[1], &mbytes[2], &mbytes[3], &mbytes[4], &mbytes[5]); + ouiFromMac(mbytes, oui, sizeof(oui)); + + dualPrintf( + "{\"event\":\"detection\"," + "\"replay\":true," + "\"replay_source\":\"%s\"," + "\"detection_method\":\"wifi_%s\"," + "\"protocol\":\"wifi_2_4ghz\"," + "\"mac_address\":\"%s\"," + "\"oui\":\"%s\"," + "\"device_name\":\"\"," + "\"rssi\":%d," + "\"channel\":%u," + "\"frequency\":%u," + "\"ssid\":\"%s\"," + "\"detection_count\":%u," + "\"device_first_ms\":%lu," + "\"device_last_ms\":%lu}\n", + source, method, mac, oui, rssi, + (unsigned)ch, (unsigned)channelFreqMhz(ch), ssidEsc, + (unsigned)count, (unsigned long)firstMs, (unsigned long)lastMs); +} + +// ============================================================ +// HOST COMMAND INTERFACE (Flask ↔ firmware over USB-CDC) +// ============================================================ +// +// Line-based protocol. The host writes one ASCII command per line +// terminated by \n (or \r\n). The firmware replies with one or more JSON +// objects, each on its own line, in the same `{"event":...}` schema the +// Flask reader already understands. +// +// Commands: +// CMD:STATUS → emits one {"event":"status",...} line +// CMD:DUMP_LIVE → streams the current in-RAM detection table as +// replay-detection lines, then a replay_complete +// sentinel with source="live" +// CMD:DUMP_PREV → same, but reads /prev_session.json from SPIFFS +// (the previous boot's persisted session) +// CMD:CLEAR_LIVE → wipes the in-RAM detection table +// CMD:CLEAR_PREV → deletes /prev_session.json (and any /session.tmp) +// CMD:VERSION → emits {"event":"version",...} +// +// Commands are case-sensitive. Unknown commands emit an "error" event. +// Lines longer than CMD_BUF_SIZE-1 are silently truncated at the boundary. + +#define CMD_BUF_SIZE 80 +#define REPLAY_OBJ_CAP 384 // generous: longest serialized entry ~330 B + +static char cmdBuf[CMD_BUF_SIZE]; +static size_t cmdLen = 0; + +// Find the start of `"":` inside a flat JSON object string. +// Returns pointer to the byte after the closing `:` (i.e. start of the value), +// or null. The caller must skip whitespace. +static const char* jsonValueStart(const char* obj, const char* key) { + char pat[24]; + int n = snprintf(pat, sizeof(pat), "\"%s\":", key); + if (n <= 0 || (size_t)n >= sizeof(pat)) return nullptr; + const char* p = strstr(obj, pat); + if (!p) return nullptr; + return p + n; +} + +// Copy the contents of a JSON string field into dst (un-escaped). +// Returns false if the field isn't a string or doesn't exist. +static bool jsonGetString(const char* obj, const char* key, char* dst, size_t cap) { + const char* p = jsonValueStart(obj, key); + if (!p) return false; + while (*p == ' ' || *p == '\t') p++; + if (*p != '"') return false; + p++; + size_t out = 0; + bool esc = false; + while (*p && out < cap - 1) { + if (esc) { + dst[out++] = *p++; + esc = false; + } else if (*p == '\\') { + esc = true; p++; + } else if (*p == '"') { + break; + } else { + dst[out++] = *p++; + } + } + dst[out] = '\0'; + return true; +} + +static bool jsonGetInt(const char* obj, const char* key, long* out) { + const char* p = jsonValueStart(obj, key); + if (!p) return false; + while (*p == ' ' || *p == '\t') p++; + char* endp = nullptr; + long v = strtol(p, &endp, 10); + if (endp == p) return false; + *out = v; + return true; +} + +// Stream-read one top-level `{...}` JSON object from `f` into `buf`. +// Skips whitespace, commas, and the array `[`. Returns false on `]`, EOF, +// or malformed input. String-aware brace counting handles `{`/`}` inside +// SSID values (the writer doesn't escape those). +static bool readNextJSONObject(File& f, char* buf, size_t cap) { + int c; + while ((c = f.read()) >= 0) { + if (c == '{') break; + if (c == ']') return false; + } + if (c != '{') return false; + + size_t pos = 0; + buf[pos++] = '{'; + int depth = 1; + bool in_str = false; + bool esc = false; + while ((c = f.read()) >= 0) { + if (pos >= cap - 1) return false; + buf[pos++] = (char)c; + if (esc) { esc = false; continue; } + if (in_str) { + if (c == '\\') esc = true; + else if (c == '"') in_str = false; + } else { + if (c == '"') in_str = true; + else if (c == '{') depth++; + else if (c == '}') { + depth--; + if (depth == 0) { buf[pos] = '\0'; return true; } + } + } + } + return false; +} + +static void cmdEmitStatus() { + size_t prevSize = 0; + bool prevExists = false; + if (fySpiffsReady && SPIFFS.exists(FY_PREV_FILE)) { + prevExists = true; + File v = SPIFFS.open(FY_PREV_FILE, "r"); + if (v) { prevSize = v.size(); v.close(); } + } + dualPrintf( + "{\"event\":\"status\"," + "\"fy_det\":%d," + "\"oui_count\":%u," + "\"spiffs\":%d," + "\"prev_session\":%d," + "\"prev_bytes\":%u," + "\"uptime_ms\":%lu," + "\"free_heap\":%u," + "\"channel\":%u," + "\"channel_mode\":\"%s\"," + "\"rssi_min\":%d}\n", + fyDetCount, (unsigned)OUI_COUNT, fySpiffsReady ? 1 : 0, + prevExists ? 1 : 0, (unsigned)prevSize, + (unsigned long)millis(), (unsigned)ESP.getFreeHeap(), + (unsigned)currentChannel, channelModeName(), RSSI_MIN); +} + +static void cmdEmitVersion() { + dualPrintf( + "{\"event\":\"version\"," + "\"firmware\":\"flock-you-promiscious\"," + "\"branch\":\"promiscious\"," + "\"oui_count\":%u," + "\"max_detections\":%d," + "\"autosave_ms\":%lu}\n", + (unsigned)OUI_COUNT, MAX_DETECTIONS, (unsigned long)AUTOSAVE_INTERVAL_MS); +} + +static int cmdDumpLive() { + int n = 0; + for (int i = 0; i < fyDetCount; i++) { + const FYDetection& d = fyDet[i]; + emitReplayDetection(d.mac, d.method, d.rssi, d.channel, + d.ssid, d.count, d.firstSeen, d.lastSeen, "live"); + n++; + } + return n; +} + +static int cmdDumpPrev() { + if (!fySpiffsReady) return -2; + if (!SPIFFS.exists(FY_PREV_FILE)) return -1; + if (!fyValidateSessionFile(FY_PREV_FILE)) return -3; + + File f = SPIFFS.open(FY_PREV_FILE, "r"); + if (!f) return -4; + // Discard envelope header line; the array starts on line 2. + f.readStringUntil('\n'); + + char obj[REPLAY_OBJ_CAP]; + int n = 0; + while (readNextJSONObject(f, obj, sizeof(obj))) { + char mac[18] = {0}; + char method[16]= {0}; + char ssid[33] = {0}; + long rssi = 0, channel = 0, count = 1, firstMs = 0, lastMs = 0; + + if (!jsonGetString(obj, "mac", mac, sizeof(mac))) continue; + if (!jsonGetString(obj, "method", method, sizeof(method))) continue; + jsonGetInt(obj, "rssi", &rssi); + jsonGetInt(obj, "channel", &channel); + jsonGetInt(obj, "count", &count); + jsonGetInt(obj, "first", &firstMs); + jsonGetInt(obj, "last", &lastMs); + jsonGetString(obj, "ssid", ssid, sizeof(ssid)); + + if (rssi < -128) rssi = -128; else if (rssi > 127) rssi = 127; + if (channel < 0) channel = 0; else if (channel > 255) channel = 255; + if (count < 0) count = 0; else if (count > 0xFFFF) count = 0xFFFF; + + emitReplayDetection(mac, method, (int8_t)rssi, (uint8_t)channel, + ssid, (uint16_t)count, + (uint32_t)firstMs, (uint32_t)lastMs, "prev"); + n++; + } + f.close(); + return n; +} + +static void cmdClearLive() { + fyDetCount = 0; + fyDirty = true; // force the next autosave to overwrite the file + dualPrintf("{\"event\":\"clear\",\"target\":\"live\",\"ok\":true}\n"); +} + +static void cmdClearPrev() { + bool ok = false; + if (fySpiffsReady) { + if (SPIFFS.exists(FY_PREV_FILE)) ok = SPIFFS.remove(FY_PREV_FILE) || ok; + // Also sweep any stray /session.tmp left over from an aborted save. + if (SPIFFS.exists(FY_SESSION_TMP)) SPIFFS.remove(FY_SESSION_TMP); + if (!SPIFFS.exists(FY_PREV_FILE)) ok = true; + } + dualPrintf("{\"event\":\"clear\",\"target\":\"prev\",\"ok\":%s}\n", + ok ? "true" : "false"); +} + +static void handleCommand(const char* cmd) { + if (strcmp(cmd, "CMD:STATUS") == 0) { + cmdEmitStatus(); + } else if (strcmp(cmd, "CMD:VERSION") == 0) { + cmdEmitVersion(); + } else if (strcmp(cmd, "CMD:DUMP_LIVE") == 0) { + int n = cmdDumpLive(); + dualPrintf("{\"event\":\"replay_complete\",\"source\":\"live\"," + "\"count\":%d,\"ok\":true}\n", n); + } else if (strcmp(cmd, "CMD:DUMP_PREV") == 0) { + int n = cmdDumpPrev(); + if (n >= 0) { + dualPrintf("{\"event\":\"replay_complete\",\"source\":\"prev\"," + "\"count\":%d,\"ok\":true}\n", n); + } else { + const char* reason = + (n == -1) ? "no_file" : + (n == -2) ? "spiffs_down" : + (n == -3) ? "crc_mismatch" : + (n == -4) ? "open_failed" : "unknown"; + dualPrintf("{\"event\":\"replay_complete\",\"source\":\"prev\"," + "\"count\":0,\"ok\":false,\"reason\":\"%s\"}\n", reason); + } + } else if (strcmp(cmd, "CMD:CLEAR_LIVE") == 0) { + cmdClearLive(); + } else if (strcmp(cmd, "CMD:CLEAR_PREV") == 0) { + cmdClearPrev(); + } else { + char escCmd[CMD_BUF_SIZE * 2]; + jsonEscape(escCmd, sizeof(escCmd), cmd); + dualPrintf("{\"event\":\"error\",\"reason\":\"unknown_command\"," + "\"cmd\":\"%s\"}\n", escCmd); + } +} + +static void serialCmdTick() { + while (Serial.available() > 0) { + int b = Serial.read(); + if (b < 0) break; + if (b == '\n' || b == '\r') { + if (cmdLen > 0) { + cmdBuf[cmdLen] = '\0'; + handleCommand(cmdBuf); + cmdLen = 0; + } + } else if (cmdLen < CMD_BUF_SIZE - 1) { + cmdBuf[cmdLen++] = (char)b; + } + // Lines longer than CMD_BUF_SIZE-1 silently truncate; the closing + // newline still flushes whatever fits and handleCommand sees garbage, + // which gets rejected as "unknown_command". + } +} + // ============================================================ // PROMISCUOUS CALLBACK — keep it fast, no Serial, no malloc // ============================================================ @@ -1121,6 +1436,7 @@ void setup() { void loop() { updateChannelMode(); drainAlertQueue(); // Serial.printf happens here, not in callback + serialCmdTick(); // CMD:STATUS / CMD:DUMP_* / CMD:CLEAR_* over USB-CDC autosaveTick(); // periodic SPIFFS write if dirty heartbeatTick(); // audible beep-pair while a target is still in range ledTick(); // turn off LED after LED_FLASH_MS