mirror of
https://github.com/colonelpanichacks/flock-you.git
synced 2026-06-10 14:13:30 -07:00
host command protocol — pull SPIFFS history + live table over USB
Firmware (main.cpp): adds a line-based CMD:* protocol on the same
USB-CDC port that already streams live detection JSON, so Flask can pull
state without re-flashing:
- CMD:STATUS emits {"event":"status",...} with det count,
SPIFFS state, free heap, uptime, channel
- CMD:VERSION emits firmware identifier + compile-time constants
- CMD:DUMP_LIVE streams the in-RAM detection table as replay
JSON lines, then a replay_complete sentinel
- CMD:DUMP_PREV same, but reads /prev_session.json from SPIFFS
(parses the CRC envelope and the embedded array)
- CMD:CLEAR_LIVE wipes fyDet[] and dirties the autosave
- CMD:CLEAR_PREV deletes /prev_session.json and any /session.tmp
Implementation:
- Minimal string-aware JSON object reader (string-aware brace counter,
backslash handling) lifts entries from the SPIFFS array one at a
time without slurping the whole file
- jsonGetString / jsonGetInt field extractors over flat objects
- emitReplayDetection() reuses the existing Flask schema and adds
replay / replay_source / detection_count / device_first_ms /
device_last_ms so the host can tell historical from live
- serialCmdTick() runs once per loop() and only acts on completed
lines — non-blocking and safe alongside the live detection path
- dualPrintf buffer bumped 384 → 1024 B to fit the longer replay line
(and to remove a latent truncation risk on a long-SSID live line)
Flask (api/flockyou.py): turns the protocol into REST endpoints and
ingests replayed detections without confusing them with live ones:
- flock_reader now dispatches {"event":"status"|"version"|"clear"|
"replay_complete"|"error"} responses to threading.Event slots, and
routes {"replay":true,"detection_method":...} lines through a new
add_replay_detection_from_serial() that skips GPS temporal matching,
flags timestamp_source="device_replay", and merges into an existing
fresher live entry instead of overwriting it
- send_command(cmd, response_event_name, timeout) serializes one
command at a time and blocks until the matching event arrives
- new endpoints: /api/flock/{status,version,dump_prev,dump_live,
clear_prev,clear_live}
Verified: pio run completes clean (RAM 19.1%, flash 12.0%); flockyou.py
passes py_compile. README documents the protocol, the per-event shape,
and the canonical post-wardrive dump_prev → clear_prev workflow.
This commit is contained in:
@@ -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:
|
||||
|
||||
+236
-2
@@ -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"""
|
||||
|
||||
@@ -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 `"<key>":` 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
|
||||
|
||||
Reference in New Issue
Block a user