Overhaul firmware: BLE-only detection, web dashboard, GPS wardriving, session persistence

Complete rewrite of standalone Flock-You firmware:
- Remove WiFi promiscuous mode, all detection is now BLE-only
- Add web dashboard served on AP "flockyou" at 192.168.4.1
- GPS wardriving via phone browser Geolocation API
- SPIFFS session persistence with auto-save every 60s
- Prior session tab (PREV) survives reboots
- KML export for Google Earth (current + prior session)
- JSON/CSV export with GPS coordinates
- Serial JSON output for Flask live ingestion
- Crow call boot sounds with detection/heartbeat alerts
- 200 unique device storage with FreeRTOS mutex
- Flask app: add KML import endpoint, GPS data handling
- Update platformio.ini with AsyncWebServer, ArduinoJson 7, SPIFFS partition
- Rewrite README to reflect current functionality
This commit is contained in:
Colonel Panic
2026-02-07 12:53:58 -05:00
parent be1f3acde2
commit 3606f1f812
6 changed files with 1332 additions and 993 deletions
+235
View File
@@ -270,6 +270,18 @@ def flock_reader():
try:
data = json.loads(line)
if 'detection_method' in data:
# Map ESP32 GPS from phone to Flask GPS format
esp_gps = data.get('gps')
if esp_gps:
data['gps'] = {
'latitude': esp_gps.get('latitude'),
'longitude': esp_gps.get('longitude'),
'fix_quality': 1,
'match_quality': 'esp32_phone_gps',
'time_diff': 0,
}
if esp_gps.get('accuracy') is not None:
data['gps']['accuracy'] = esp_gps['accuracy']
# This is a detection, add it
add_detection_from_serial(data)
else:
@@ -964,6 +976,229 @@ def export_kml():
return send_file(filepath, as_attachment=True, download_name=filename)
@app.route('/api/import/json', methods=['POST'])
def import_json():
"""Import detections from a JSON file (exported from ESP32 Flock-You dashboard)"""
global detections, cumulative_detections, next_detection_id
if 'file' not in request.files:
return jsonify({'status': 'error', 'message': 'No file provided'}), 400
file = request.files['file']
if not file.filename:
return jsonify({'status': 'error', 'message': 'No file selected'}), 400
try:
content = file.read().decode('utf-8')
imported = json.loads(content)
if not isinstance(imported, list):
imported = [imported]
count = 0
for item in imported:
# Map ESP32 export fields to Flask detection format
data = {
'detection_method': item.get('method', item.get('detection_method', 'unknown')),
'protocol': 'bluetooth_le',
'mac_address': item.get('mac', item.get('mac_address', '')),
'device_name': item.get('name', item.get('device_name', '')),
'rssi': item.get('rssi', 0),
'detection_count': item.get('count', item.get('detection_count', 1)),
}
# Raven fields
if item.get('raven') or item.get('is_raven'):
data['is_raven'] = True
data['raven_fw'] = item.get('fw', item.get('raven_fw', ''))
# GPS fields from ESP32 wardriving export
gps_obj = item.get('gps')
if gps_obj and (gps_obj.get('lat') or gps_obj.get('latitude')):
data['gps'] = {
'latitude': gps_obj.get('lat', gps_obj.get('latitude')),
'longitude': gps_obj.get('lon', gps_obj.get('longitude')),
'altitude': gps_obj.get('alt', gps_obj.get('altitude', 0)),
'fix_quality': 1,
'match_quality': 'esp32_phone_gps',
'time_diff': 0,
}
if gps_obj.get('acc') is not None:
data['gps']['accuracy'] = gps_obj['acc']
add_detection_from_serial(data)
count += 1
print(f"Imported {count} detections from JSON file: {file.filename}")
return jsonify({'status': 'success', 'message': f'Imported {count} detections', 'count': count})
except json.JSONDecodeError as e:
return jsonify({'status': 'error', 'message': f'Invalid JSON: {str(e)}'}), 400
except Exception as e:
print(f"JSON import error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/api/import/csv', methods=['POST'])
def import_csv():
"""Import detections from a CSV file (exported from ESP32 Flock-You dashboard)"""
global detections, cumulative_detections, next_detection_id
if 'file' not in request.files:
return jsonify({'status': 'error', 'message': 'No file provided'}), 400
file = request.files['file']
if not file.filename:
return jsonify({'status': 'error', 'message': 'No file selected'}), 400
try:
content = file.read().decode('utf-8')
reader = csv.DictReader(content.splitlines())
count = 0
for row in reader:
data = {
'detection_method': row.get('method', row.get('detection_method', 'unknown')),
'protocol': 'bluetooth_le',
'mac_address': row.get('mac', row.get('mac_address', '')),
'device_name': row.get('name', row.get('device_name', '')),
'rssi': int(row.get('rssi', 0)) if row.get('rssi') else 0,
'detection_count': int(row.get('count', row.get('detection_count', 1))) if row.get('count', row.get('detection_count')) else 1,
}
# Raven fields
is_raven = row.get('is_raven', row.get('raven', 'false'))
if is_raven and is_raven.lower() == 'true':
data['is_raven'] = True
data['raven_fw'] = row.get('raven_fw', row.get('fw', ''))
# GPS fields from ESP32 wardriving CSV export
lat_str = row.get('latitude', '')
lon_str = row.get('longitude', '')
if lat_str and lon_str:
try:
data['gps'] = {
'latitude': float(lat_str),
'longitude': float(lon_str),
'fix_quality': 1,
'match_quality': 'esp32_phone_gps',
'time_diff': 0,
}
acc_str = row.get('gps_accuracy', '')
if acc_str:
data['gps']['accuracy'] = float(acc_str)
except (ValueError, TypeError):
pass # Skip GPS if values are invalid
add_detection_from_serial(data)
count += 1
print(f"Imported {count} detections from CSV file: {file.filename}")
return jsonify({'status': 'success', 'message': f'Imported {count} detections', 'count': count})
except Exception as e:
print(f"CSV import error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/api/import/kml', methods=['POST'])
def import_kml():
"""Import detections from a KML file (exported from ESP32 Flock-You dashboard)"""
import xml.etree.ElementTree as ET
if 'file' not in request.files:
return jsonify({'status': 'error', 'message': 'No file provided'}), 400
file = request.files['file']
if not file.filename:
return jsonify({'status': 'error', 'message': 'No file selected'}), 400
try:
content = file.read().decode('utf-8')
root = ET.fromstring(content)
# Handle KML namespace
ns = {'kml': 'http://www.opengis.net/kml/2.2'}
placemarks = root.findall('.//kml:Placemark', ns)
if not placemarks:
# Try without namespace (some KML generators omit it)
placemarks = root.findall('.//Placemark')
count = 0
for pm in placemarks:
name_el = pm.find('kml:name', ns) or pm.find('name')
desc_el = pm.find('kml:description', ns) or pm.find('description')
coord_el = pm.find('.//kml:coordinates', ns) or pm.find('.//coordinates')
mac = name_el.text.strip() if name_el is not None and name_el.text else f"unknown_{count}"
# Parse coordinates (lon,lat,alt)
gps_data = None
if coord_el is not None and coord_el.text:
parts = coord_el.text.strip().split(',')
if len(parts) >= 2:
try:
lon = float(parts[0])
lat = float(parts[1])
alt = float(parts[2]) if len(parts) > 2 else 0
gps_data = {
'latitude': lat,
'longitude': lon,
'altitude': alt,
'fix_quality': 1,
'match_quality': 'esp32_kml_import',
'time_diff': 0,
}
except (ValueError, TypeError):
pass
# Parse description for metadata
desc_text = desc_el.text.strip() if desc_el is not None and desc_el.text else ""
device_name = ""
method = "kml_import"
rssi = 0
det_count = 1
# Try to extract fields from CDATA description
import re
name_match = re.search(r'<b>Name:</b>\s*([^<]+)', desc_text)
if name_match:
device_name = name_match.group(1).strip()
method_match = re.search(r'<b>Method:</b>\s*([^<]+)', desc_text)
if method_match:
method = method_match.group(1).strip()
rssi_match = re.search(r'<b>RSSI:</b>\s*(-?\d+)', desc_text)
if rssi_match:
rssi = int(rssi_match.group(1))
count_match = re.search(r'<b>Count:</b>\s*(\d+)', desc_text)
if count_match:
det_count = int(count_match.group(1))
data = {
'detection_method': method,
'protocol': 'bluetooth_le',
'mac_address': mac,
'device_name': device_name,
'rssi': rssi,
'detection_count': det_count,
}
if gps_data:
data['gps'] = gps_data
add_detection_from_serial(data)
count += 1
print(f"Imported {count} detections from KML file: {file.filename}")
return jsonify({'status': 'success', 'message': f'Imported {count} detections from KML', 'count': count})
except ET.ParseError as e:
return jsonify({'status': 'error', 'message': f'Invalid KML: {str(e)}'}), 400
except Exception as e:
print(f"KML import error: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@app.route('/api/clear', methods=['POST'])
def clear_detections():
"""Clear session detections"""
+73 -3
View File
@@ -1376,6 +1376,15 @@
<a href="#" onclick="exportKML('cumulative')">Cumulative KML</a>
</div>
</div>
<div class="export-dropdown">
<button class="export-btn dropdown-toggle" style="background:linear-gradient(135deg,#059669 0%,#10b981 100%);border-color:#047857" onclick="toggleImportDropdown()">Import ▼</button>
<div class="export-dropdown-content" id="importDropdown">
<a href="#" onclick="importFile('json')">Import JSON (from ESP32)</a>
<a href="#" onclick="importFile('csv')">Import CSV (from ESP32)</a>
<a href="#" onclick="importFile('kml')">Import KML (from ESP32)</a>
</div>
</div>
<input type="file" id="importFileInput" accept=".json,.csv,.kml" style="display:none" onchange="handleImportFile()">
<button class="clear-btn" onclick="clearDetections()">Clear All</button>
</div>
</div>
@@ -2023,17 +2032,78 @@
function toggleExportDropdown() {
const dropdown = document.getElementById('exportDropdown');
dropdown.style.display = dropdown.style.display === 'block' ? 'none' : 'block';
document.getElementById('importDropdown').style.display = 'none';
}
function toggleImportDropdown() {
const dropdown = document.getElementById('importDropdown');
dropdown.style.display = dropdown.style.display === 'block' ? 'none' : 'block';
document.getElementById('exportDropdown').style.display = 'none';
}
function closeExportDropdown() {
document.getElementById('exportDropdown').style.display = 'none';
}
// Close dropdown when clicking outside
function closeImportDropdown() {
document.getElementById('importDropdown').style.display = 'none';
}
let pendingImportType = 'json';
function importFile(type) {
pendingImportType = type;
const input = document.getElementById('importFileInput');
input.accept = type === 'json' ? '.json' : type === 'csv' ? '.csv' : '.kml';
input.click();
closeImportDropdown();
}
function handleImportFile() {
const input = document.getElementById('importFileInput');
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
const endpoint = pendingImportType === 'json' ? '/api/import/json' : pendingImportType === 'csv' ? '/api/import/csv' : '/api/import/kml';
fetch(endpoint, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
alert(data.message);
loadDetections();
loadCumulativeDetections();
updateStats();
} else {
alert('Import error: ' + data.message);
}
})
.catch(error => {
console.error('Import error:', error);
alert('Import failed: ' + error.message);
});
// Reset input so same file can be re-selected
input.value = '';
}
// Close dropdowns when clicking outside
document.addEventListener('click', function(event) {
const dropdown = document.querySelector('.export-dropdown');
if (!dropdown.contains(event.target)) {
const exportDD = document.querySelector('.export-dropdown');
const allDropdowns = document.querySelectorAll('.export-dropdown');
let clickedInside = false;
allDropdowns.forEach(dd => {
if (dd.contains(event.target)) clickedInside = true;
});
if (!clickedInside) {
closeExportDropdown();
closeImportDropdown();
}
});