feat: RTL_433 protocol database import - iteration 1/5
- Expanded protocol database from 18 → 299 signatures (16.6x increase) - Imported 281 protocols from RTL_433 open-source database (286 total devices) - Created automated import script: scripts/import_rtl433_protocols.py - Generated rtl433_protocols_imported.py with timing/frequency/modulation data - Updated protocol_database.py to include RTL433_PROTOCOLS - All 26 tests passing Breakdown by category: - Weather: 116 protocols - Sensors: 36 protocols - TPMS: 25 protocols - Security: 23 protocols - Home Automation: 18 protocols - Other: 50+ protocols Frequency coverage: - 433.92 MHz: 248 protocols - 315.00 MHz: 32 protocols - 915.00 MHz: 1 protocol This provides comprehensive coverage of Sub-GHz IoT devices for accurate identification from raw RF captures.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Client-Side Error Reporter
|
||||
*
|
||||
* Automatically captures and reports JavaScript errors during file uploads
|
||||
* to help diagnose issues during beta testing.
|
||||
*/
|
||||
|
||||
class ErrorReporter {
|
||||
constructor() {
|
||||
this.uploadId = null;
|
||||
this.errorQueue = [];
|
||||
this.reportEndpoint = '/api/v1/captures/report_client_error';
|
||||
|
||||
// Setup global error handler
|
||||
this.setupGlobalErrorHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current upload ID for error tracking
|
||||
*/
|
||||
setUploadId(uploadId) {
|
||||
this.uploadId = uploadId;
|
||||
console.log(`[ErrorReporter] Tracking upload: ${uploadId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup global error handlers
|
||||
*/
|
||||
setupGlobalErrorHandler() {
|
||||
// Catch unhandled JavaScript errors
|
||||
window.addEventListener('error', (event) => {
|
||||
this.reportError({
|
||||
error_type: event.error?.name || 'Error',
|
||||
error_message: event.message,
|
||||
stack_trace: event.error?.stack,
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno,
|
||||
source: 'window.error'
|
||||
});
|
||||
});
|
||||
|
||||
// Catch unhandled promise rejections
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
this.reportError({
|
||||
error_type: 'UnhandledPromiseRejection',
|
||||
error_message: event.reason?.message || String(event.reason),
|
||||
stack_trace: event.reason?.stack,
|
||||
source: 'unhandledrejection'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report error to server
|
||||
*/
|
||||
async reportError(errorData) {
|
||||
const errorReport = {
|
||||
upload_id: this.uploadId || 'no_upload_id',
|
||||
timestamp: new Date().toISOString(),
|
||||
...errorData,
|
||||
browser_info: this.getBrowserInfo(),
|
||||
page_url: window.location.href
|
||||
};
|
||||
|
||||
// Log to console
|
||||
console.error('[ErrorReporter] Reporting error:', errorReport);
|
||||
|
||||
// Queue error (in case of network issues)
|
||||
this.errorQueue.push(errorReport);
|
||||
|
||||
// Try to send immediately
|
||||
try {
|
||||
await this.sendErrorReport(errorReport);
|
||||
// Remove from queue if successful
|
||||
this.errorQueue = this.errorQueue.filter(e => e !== errorReport);
|
||||
} catch (e) {
|
||||
console.warn('[ErrorReporter] Failed to send error report:', e);
|
||||
// Will retry later
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error report to server
|
||||
*/
|
||||
async sendErrorReport(errorReport) {
|
||||
const response = await fetch(this.reportEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(errorReport)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Report parse error specifically
|
||||
*/
|
||||
reportParseError(filename, error, fileContentSample = '') {
|
||||
this.reportError({
|
||||
error_type: 'SubFileParseError',
|
||||
error_message: error.message || String(error),
|
||||
stack_trace: error.stack,
|
||||
filename: filename,
|
||||
file_content_sample: fileContentSample.substring(0, 500),
|
||||
source: 'sub_parser'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report upload error
|
||||
*/
|
||||
reportUploadError(filename, error, stage = 'unknown') {
|
||||
this.reportError({
|
||||
error_type: 'UploadError',
|
||||
error_message: error.message || String(error),
|
||||
stack_trace: error.stack,
|
||||
filename: filename,
|
||||
upload_stage: stage,
|
||||
source: 'upload_handler'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report validation error
|
||||
*/
|
||||
reportValidationError(field, value, reason) {
|
||||
this.reportError({
|
||||
error_type: 'ValidationError',
|
||||
error_message: `${field} validation failed: ${reason}`,
|
||||
validation_field: field,
|
||||
validation_value: String(value),
|
||||
source: 'validation'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Report network error
|
||||
*/
|
||||
reportNetworkError(url, status, statusText) {
|
||||
this.reportError({
|
||||
error_type: 'NetworkError',
|
||||
error_message: `${status} ${statusText}`,
|
||||
request_url: url,
|
||||
http_status: status,
|
||||
source: 'network'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser information
|
||||
*/
|
||||
getBrowserInfo() {
|
||||
return {
|
||||
user_agent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
language: navigator.language,
|
||||
screen_width: screen.width,
|
||||
screen_height: screen.height,
|
||||
viewport_width: window.innerWidth,
|
||||
viewport_height: window.innerHeight,
|
||||
connection_type: navigator.connection?.effectiveType,
|
||||
device_memory: navigator.deviceMemory,
|
||||
hardware_concurrency: navigator.hardwareConcurrency,
|
||||
online: navigator.onLine
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush error queue (send all pending errors)
|
||||
*/
|
||||
async flushErrorQueue() {
|
||||
const pending = [...this.errorQueue];
|
||||
this.errorQueue = [];
|
||||
|
||||
for (const error of pending) {
|
||||
try {
|
||||
await this.sendErrorReport(error);
|
||||
} catch (e) {
|
||||
// If still fails, put back in queue
|
||||
this.errorQueue.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sent: pending.length - this.errorQueue.length,
|
||||
failed: this.errorQueue.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate upload ID
|
||||
*/
|
||||
generateUploadId() {
|
||||
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '_').substring(0, 15);
|
||||
const random = Math.random().toString(36).substring(2, 10);
|
||||
return `upload_${timestamp}_${random}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create global instance
|
||||
window.errorReporter = new ErrorReporter();
|
||||
|
||||
// Export for module use
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = ErrorReporter;
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Flipper Zero .sub File Parser (JavaScript)
|
||||
*
|
||||
* Parses .sub files in the browser for real-time device identification
|
||||
* Port of src/parser/sub_parser.py
|
||||
*/
|
||||
|
||||
// Modulation types
|
||||
const Modulation = {
|
||||
OOK: 'OOK',
|
||||
FSK2: '2FSK',
|
||||
FSK4: '4FSK',
|
||||
ASK: 'ASK',
|
||||
UNKNOWN: 'UNKNOWN'
|
||||
};
|
||||
|
||||
// Preset mapping (Flipper firmware presets)
|
||||
const PRESET_TO_MODULATION = {
|
||||
'FuriHalSubGhzPresetOok270Async': Modulation.OOK,
|
||||
'FuriHalSubGhzPresetOok650Async': Modulation.OOK,
|
||||
'FuriHalSubGhzPreset2FSKDev238Async': Modulation.FSK2,
|
||||
'FuriHalSubGhzPreset2FSKDev476Async': Modulation.FSK2,
|
||||
'FuriHalSubGhzPresetMSK99_97KbAsync': Modulation.FSK2,
|
||||
'FuriHalSubGhzPresetGFSK9_99KbAsync': Modulation.FSK2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Signal Metadata Structure
|
||||
*/
|
||||
class SignalMetadata {
|
||||
constructor() {
|
||||
this.filetype = null;
|
||||
this.version = null;
|
||||
this.frequency = null; // Hz
|
||||
this.preset = null;
|
||||
this.protocol = null;
|
||||
this.modulation = null;
|
||||
this.bit_length = null;
|
||||
this.key_data = null; // Hex string
|
||||
this.timing_element = null; // Microseconds
|
||||
this.raw_data = null; // Array of integers [pulse, -gap, pulse, -gap, ...]
|
||||
this.raw_format = null; // 'RAW', 'BinRAW', or 'KEY'
|
||||
|
||||
// Computed statistics (for RAW files)
|
||||
this.pulse_count = null;
|
||||
this.average_pulse_width = null;
|
||||
this.total_duration_ms = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute statistics from raw pulse data
|
||||
*/
|
||||
computeStatistics() {
|
||||
if (!this.raw_data || this.raw_data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pulses = this.raw_data.filter(x => x > 0);
|
||||
const gaps = this.raw_data.filter(x => x < 0).map(x => Math.abs(x));
|
||||
|
||||
this.pulse_count = pulses.length;
|
||||
|
||||
if (pulses.length > 0) {
|
||||
this.average_pulse_width = pulses.reduce((a, b) => a + b, 0) / pulses.length;
|
||||
}
|
||||
|
||||
// Total duration in milliseconds
|
||||
const total_us = this.raw_data.map(Math.abs).reduce((a, b) => a + b, 0);
|
||||
this.total_duration_ms = total_us / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a .sub file from text content
|
||||
*
|
||||
* @param {string} content - The text content of the .sub file
|
||||
* @returns {SignalMetadata} Parsed metadata
|
||||
*/
|
||||
function parseSubFile(content) {
|
||||
const metadata = new SignalMetadata();
|
||||
const lines = content.split('\n').map(line => line.trim()).filter(line => line.length > 0);
|
||||
|
||||
for (const line of lines) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex === -1) continue;
|
||||
|
||||
const key = line.substring(0, colonIndex).trim();
|
||||
const value = line.substring(colonIndex + 1).trim();
|
||||
|
||||
// Parse fields
|
||||
switch (key) {
|
||||
case 'Filetype':
|
||||
metadata.filetype = value;
|
||||
break;
|
||||
|
||||
case 'Version':
|
||||
metadata.version = parseInt(value);
|
||||
break;
|
||||
|
||||
case 'Frequency':
|
||||
metadata.frequency = parseInt(value);
|
||||
|
||||
// Validate frequency range (300 MHz - 928 MHz)
|
||||
if (metadata.frequency < 300000000 || metadata.frequency > 928000000) {
|
||||
console.warn(`Frequency ${metadata.frequency} Hz outside Sub-GHz range`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Preset':
|
||||
metadata.preset = value;
|
||||
metadata.modulation = PRESET_TO_MODULATION[value] || Modulation.UNKNOWN;
|
||||
break;
|
||||
|
||||
case 'Protocol':
|
||||
metadata.protocol = value;
|
||||
|
||||
// Determine format type
|
||||
if (value === 'RAW') {
|
||||
metadata.raw_format = 'RAW';
|
||||
} else if (value === 'BinRAW') {
|
||||
metadata.raw_format = 'BinRAW';
|
||||
} else {
|
||||
metadata.raw_format = 'KEY';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Bit':
|
||||
metadata.bit_length = parseInt(value);
|
||||
break;
|
||||
|
||||
case 'Key':
|
||||
metadata.key_data = value;
|
||||
break;
|
||||
|
||||
case 'TE':
|
||||
metadata.timing_element = parseInt(value);
|
||||
break;
|
||||
|
||||
case 'RAW_Data':
|
||||
// Parse timing array
|
||||
const timings = value.split(/\s+/)
|
||||
.map(x => parseInt(x))
|
||||
.filter(x => !isNaN(x));
|
||||
|
||||
metadata.raw_data = timings;
|
||||
break;
|
||||
|
||||
default:
|
||||
// Ignore unknown fields
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute statistics for RAW files
|
||||
if (metadata.raw_data) {
|
||||
metadata.computeStatistics();
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract statistical features from RAW pulse data
|
||||
* (For use with statistical ML classifier)
|
||||
*
|
||||
* @param {SignalMetadata} metadata
|
||||
* @returns {Float32Array} Feature vector (length 47)
|
||||
*/
|
||||
function extractStatisticalFeatures(metadata) {
|
||||
const features = new Float32Array(47);
|
||||
|
||||
if (!metadata.raw_data || metadata.raw_data.length === 0) {
|
||||
return features; // All zeros
|
||||
}
|
||||
|
||||
const pulses = metadata.raw_data.filter(x => x > 0);
|
||||
const gaps = metadata.raw_data.filter(x => x < 0).map(x => Math.abs(x));
|
||||
|
||||
// Helper functions
|
||||
const mean = arr => arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||
const median = arr => {
|
||||
const sorted = [...arr].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
};
|
||||
const std = arr => {
|
||||
const m = mean(arr);
|
||||
return Math.sqrt(arr.reduce((sum, x) => sum + (x - m) ** 2, 0) / arr.length);
|
||||
};
|
||||
|
||||
// Timing features (16)
|
||||
let idx = 0;
|
||||
features[idx++] = mean(pulses);
|
||||
features[idx++] = median(pulses);
|
||||
features[idx++] = std(pulses);
|
||||
features[idx++] = Math.min(...pulses);
|
||||
features[idx++] = Math.max(...pulses);
|
||||
|
||||
features[idx++] = mean(gaps);
|
||||
features[idx++] = median(gaps);
|
||||
features[idx++] = std(gaps);
|
||||
features[idx++] = Math.min(...gaps);
|
||||
features[idx++] = Math.max(...gaps);
|
||||
|
||||
features[idx++] = mean(pulses) / mean(gaps); // Pulse/gap ratio
|
||||
features[idx++] = mean(pulses) / (mean(pulses) + mean(gaps)); // Duty cycle
|
||||
|
||||
// K-means-like clustering for short/long pulses (simplified)
|
||||
const pulsesSorted = [...pulses].sort((a, b) => a - b);
|
||||
const shortPulse = pulsesSorted[Math.floor(pulsesSorted.length * 0.25)];
|
||||
const longPulse = pulsesSorted[Math.floor(pulsesSorted.length * 0.75)];
|
||||
features[idx++] = shortPulse;
|
||||
features[idx++] = longPulse;
|
||||
|
||||
features[idx++] = std(pulses) / mean(pulses); // Coefficient of variation (pulses)
|
||||
features[idx++] = std(gaps) / mean(gaps); // Coefficient of variation (gaps)
|
||||
|
||||
// Frequency domain (12) - simplified FFT features
|
||||
// (Full FFT would require library like FFT.js - simplified here)
|
||||
features[idx++] = 1000000 / mean(pulses); // Estimated dominant frequency (Hz)
|
||||
features[idx++] = metadata.total_duration_ms || 0; // Total energy proxy
|
||||
features[idx++] = metadata.pulse_count || 0;
|
||||
|
||||
// Zero-crossing rate
|
||||
let zero_crossings = 0;
|
||||
for (let i = 1; i < metadata.raw_data.length; i++) {
|
||||
if (metadata.raw_data[i] * metadata.raw_data[i-1] < 0) {
|
||||
zero_crossings++;
|
||||
}
|
||||
}
|
||||
features[idx++] = zero_crossings / metadata.raw_data.length;
|
||||
|
||||
// Fill remaining frequency features with zeros (would need FFT)
|
||||
for (let i = 0; i < 8; i++) {
|
||||
features[idx++] = 0;
|
||||
}
|
||||
|
||||
// Pattern features (10)
|
||||
// Entropy of pulse width distribution (simplified)
|
||||
const pulseBins = {};
|
||||
for (const p of pulses) {
|
||||
const bin = Math.floor(p / 100) * 100;
|
||||
pulseBins[bin] = (pulseBins[bin] || 0) + 1;
|
||||
}
|
||||
const probs = Object.values(pulseBins).map(c => c / pulses.length);
|
||||
const entropy = -probs.reduce((sum, p) => sum + (p > 0 ? p * Math.log2(p) : 0), 0);
|
||||
features[idx++] = entropy;
|
||||
|
||||
// Peak/valley counts (simplified)
|
||||
features[idx++] = pulses.length;
|
||||
features[idx++] = gaps.length;
|
||||
|
||||
// Longest run of similar pulses
|
||||
let longestRun = 1;
|
||||
let currentRun = 1;
|
||||
for (let i = 1; i < pulses.length; i++) {
|
||||
if (Math.abs(pulses[i] - pulses[i-1]) < pulses[i] * 0.2) {
|
||||
currentRun++;
|
||||
} else {
|
||||
longestRun = Math.max(longestRun, currentRun);
|
||||
currentRun = 1;
|
||||
}
|
||||
}
|
||||
features[idx++] = longestRun;
|
||||
|
||||
// Fill remaining pattern features
|
||||
for (let i = 0; i < 6; i++) {
|
||||
features[idx++] = 0;
|
||||
}
|
||||
|
||||
// Metadata features (9)
|
||||
features[idx++] = (metadata.frequency || 433920000) / 1000000; // Frequency in MHz
|
||||
|
||||
// Modulation one-hot encoding (3 features)
|
||||
features[idx++] = metadata.modulation === Modulation.OOK ? 1 : 0;
|
||||
features[idx++] = metadata.modulation === Modulation.FSK2 ? 1 : 0;
|
||||
features[idx++] = metadata.modulation === Modulation.ASK ? 1 : 0;
|
||||
|
||||
features[idx++] = metadata.pulse_count || 0;
|
||||
features[idx++] = metadata.total_duration_ms || 0;
|
||||
|
||||
// Estimated bit rate
|
||||
const bitRate = metadata.total_duration_ms > 0
|
||||
? (metadata.bit_length || 0) / (metadata.total_duration_ms / 1000)
|
||||
: 0;
|
||||
features[idx++] = bitRate;
|
||||
|
||||
// Fill remaining metadata features
|
||||
for (let i = 0; i < 2; i++) {
|
||||
features[idx++] = 0;
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw pulse data for CNN input
|
||||
* Pads or truncates to fixed length (512 samples) and normalizes to [-1, 1]
|
||||
*
|
||||
* @param {number[]} rawData - Raw pulse array
|
||||
* @param {number} targetLength - Target length (default 512)
|
||||
* @returns {Float32Array} Normalized array
|
||||
*/
|
||||
function normalizeForCNN(rawData, targetLength = 512) {
|
||||
const result = new Float32Array(targetLength);
|
||||
|
||||
if (!rawData || rawData.length === 0) {
|
||||
return result; // All zeros
|
||||
}
|
||||
|
||||
// Find max absolute value for normalization
|
||||
const maxAbs = Math.max(...rawData.map(Math.abs));
|
||||
|
||||
// Pad or truncate
|
||||
for (let i = 0; i < targetLength; i++) {
|
||||
if (i < rawData.length) {
|
||||
result[i] = rawData[i] / maxAbs; // Normalize to [-1, 1]
|
||||
} else {
|
||||
result[i] = 0; // Padding
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a parsed .sub file
|
||||
*
|
||||
* @param {SignalMetadata} metadata
|
||||
* @returns {object} {valid: boolean, errors: string[]}
|
||||
*/
|
||||
function validateSubFile(metadata) {
|
||||
const errors = [];
|
||||
|
||||
if (!metadata.frequency) {
|
||||
errors.push("Missing frequency");
|
||||
} else if (metadata.frequency < 300000000 || metadata.frequency > 928000000) {
|
||||
errors.push(`Frequency ${metadata.frequency / 1e6} MHz outside Sub-GHz range (300-928 MHz)`);
|
||||
}
|
||||
|
||||
if (!metadata.protocol) {
|
||||
errors.push("Missing protocol");
|
||||
}
|
||||
|
||||
if (metadata.raw_format === 'RAW' && (!metadata.raw_data || metadata.raw_data.length === 0)) {
|
||||
errors.push("RAW format but no RAW_Data found");
|
||||
}
|
||||
|
||||
if (metadata.raw_format === 'KEY' && !metadata.key_data) {
|
||||
errors.push("KEY format but no Key field found");
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
// Node.js
|
||||
module.exports = {
|
||||
parseSubFile,
|
||||
extractStatisticalFeatures,
|
||||
normalizeForCNN,
|
||||
validateSubFile,
|
||||
SignalMetadata,
|
||||
Modulation
|
||||
};
|
||||
} else {
|
||||
// Browser
|
||||
window.SubParser = {
|
||||
parseSubFile,
|
||||
extractStatisticalFeatures,
|
||||
normalizeForCNN,
|
||||
validateSubFile,
|
||||
SignalMetadata,
|
||||
Modulation
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
/**
|
||||
* Enhanced Upload Functionality with Device Labeling
|
||||
*
|
||||
* Features:
|
||||
* - Real-time .sub file parsing in browser
|
||||
* - Manual device identification for training data
|
||||
* - Device search and autocomplete
|
||||
* - Photo upload for unknown devices
|
||||
* - Batch labeling for multiple files
|
||||
*/
|
||||
|
||||
// Import .sub parser
|
||||
// Assumes sub_parser.js is loaded first
|
||||
|
||||
let selectedFiles = [];
|
||||
let parsedMetadata = new Map(); // filename -> SignalMetadata
|
||||
let manualLabels = new Map(); // filename -> {device_id, manufacturer, model, ...}
|
||||
|
||||
// Device database cache (fetched from API)
|
||||
let deviceDatabase = [];
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadDeviceDatabase();
|
||||
setupEnhancedUpload();
|
||||
});
|
||||
|
||||
/**
|
||||
* Load device database from API for autocomplete
|
||||
*/
|
||||
async function loadDeviceDatabase() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/devices?limit=1000');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
deviceDatabase = data.devices || [];
|
||||
console.log(`Loaded ${deviceDatabase.length} devices for autocomplete`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load device database:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup enhanced upload functionality
|
||||
*/
|
||||
function setupEnhancedUpload() {
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
|
||||
// Drag & drop
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('drag-over');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('drag-over');
|
||||
});
|
||||
dropZone.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('drag-over');
|
||||
const files = Array.from(e.dataTransfer.files).filter(f => f.name.endsWith('.sub'));
|
||||
await addFilesWithParsing(files);
|
||||
});
|
||||
|
||||
// File input
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
await addFilesWithParsing(files);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add files and parse them immediately
|
||||
*/
|
||||
async function addFilesWithParsing(files) {
|
||||
if (files.length === 0) {
|
||||
alert('Please select .sub files');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading indicator
|
||||
showParsingIndicator(files.length);
|
||||
|
||||
for (const file of files) {
|
||||
selectedFiles.push(file);
|
||||
|
||||
// Parse .sub file in browser
|
||||
try {
|
||||
const content = await file.text();
|
||||
const metadata = SubParser.parseSubFile(content);
|
||||
parsedMetadata.set(file.name, metadata);
|
||||
|
||||
console.log(`Parsed ${file.name}:`, metadata);
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse ${file.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
hideParsingIndicator();
|
||||
|
||||
// Show file list with device labeling UI
|
||||
document.getElementById('file-list').style.display = 'block';
|
||||
renderEnhancedFileList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render file list with device labeling options
|
||||
*/
|
||||
function renderEnhancedFileList() {
|
||||
const container = document.getElementById('files-container');
|
||||
|
||||
container.innerHTML = selectedFiles.map((file, index) => {
|
||||
const metadata = parsedMetadata.get(file.name);
|
||||
const label = manualLabels.get(file.name);
|
||||
|
||||
return `
|
||||
<div class="file-item-enhanced" data-index="${index}">
|
||||
<div class="file-header">
|
||||
<div class="file-info">
|
||||
<div class="file-name">${file.name}</div>
|
||||
<div class="file-meta">
|
||||
${formatFileSize(file.size)}
|
||||
${metadata ? ` | ${(metadata.frequency / 1e6).toFixed(2)} MHz` : ''}
|
||||
${metadata?.protocol ? ` | ${metadata.protocol}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="removeFileEnhanced(${index})">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${metadata ? renderSignalDetails(metadata) : ''}
|
||||
${renderDeviceLabelingUI(file.name, index, label)}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Setup autocomplete for each file
|
||||
selectedFiles.forEach((file, index) => {
|
||||
setupDeviceAutocomplete(file.name, index);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render parsed signal details
|
||||
*/
|
||||
function renderSignalDetails(metadata) {
|
||||
return `
|
||||
<div class="signal-details">
|
||||
<div class="signal-param">
|
||||
<span class="label">Frequency:</span>
|
||||
<span class="value">${(metadata.frequency / 1e6).toFixed(3)} MHz</span>
|
||||
</div>
|
||||
<div class="signal-param">
|
||||
<span class="label">Modulation:</span>
|
||||
<span class="value">${metadata.modulation || 'Unknown'}</span>
|
||||
</div>
|
||||
${metadata.protocol ? `
|
||||
<div class="signal-param">
|
||||
<span class="label">Protocol:</span>
|
||||
<span class="value">${metadata.protocol}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${metadata.bit_length ? `
|
||||
<div class="signal-param">
|
||||
<span class="label">Bit Length:</span>
|
||||
<span class="value">${metadata.bit_length} bits</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${metadata.raw_format === 'RAW' ? `
|
||||
<div class="signal-param">
|
||||
<span class="label">Format:</span>
|
||||
<span class="value">RAW (${metadata.pulse_count || 0} pulses)</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render device labeling UI for a file
|
||||
*/
|
||||
function renderDeviceLabelingUI(filename, index, existingLabel) {
|
||||
return `
|
||||
<div class="device-labeling">
|
||||
<h4>Device Identification (Optional - Helps Train AI)</h4>
|
||||
<p class="help-text">Know what device this is? Help us improve identification accuracy!</p>
|
||||
|
||||
<div class="labeling-options">
|
||||
<!-- Option 1: Search existing devices -->
|
||||
<div class="label-option">
|
||||
<label>
|
||||
<input type="radio" name="label-type-${index}" value="existing"
|
||||
${existingLabel?.type === 'existing' ? 'checked' : ''}
|
||||
onchange="showLabelingSection(${index}, 'existing')">
|
||||
Select from known devices
|
||||
</label>
|
||||
<div id="existing-device-${index}" class="label-section"
|
||||
style="display: ${existingLabel?.type === 'existing' ? 'block' : 'none'};">
|
||||
<input type="text"
|
||||
id="device-search-${index}"
|
||||
class="device-search-input"
|
||||
placeholder="Search device (e.g., Chamberlain, LaCrosse, Oregon Scientific...)"
|
||||
value="${existingLabel?.search || ''}">
|
||||
<div id="device-suggestions-${index}" class="device-suggestions"></div>
|
||||
<input type="hidden" id="selected-device-id-${index}" value="${existingLabel?.device_id || ''}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Option 2: Add new device -->
|
||||
<div class="label-option">
|
||||
<label>
|
||||
<input type="radio" name="label-type-${index}" value="new"
|
||||
${existingLabel?.type === 'new' ? 'checked' : ''}
|
||||
onchange="showLabelingSection(${index}, 'new')">
|
||||
Add new device (not in database)
|
||||
</label>
|
||||
<div id="new-device-${index}" class="label-section"
|
||||
style="display: ${existingLabel?.type === 'new' ? 'block' : 'none'};">
|
||||
<div class="form-group">
|
||||
<label>Device Type:</label>
|
||||
<select id="device-type-${index}" class="form-control">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="Garage Door Opener">Garage Door Opener</option>
|
||||
<option value="Gate Opener">Gate Opener</option>
|
||||
<option value="Weather Sensor">Weather Sensor</option>
|
||||
<option value="Doorbell">Doorbell</option>
|
||||
<option value="Door Sensor">Door Sensor</option>
|
||||
<option value="Motion Sensor">Motion Sensor</option>
|
||||
<option value="Remote Control">Remote Control</option>
|
||||
<option value="Car Key Fob">Car Key Fob</option>
|
||||
<option value="TPMS">TPMS (Tire Pressure)</option>
|
||||
<option value="Security System">Security System</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Manufacturer:</label>
|
||||
<input type="text" id="manufacturer-${index}"
|
||||
placeholder="e.g., Chamberlain, LaCrosse, Oregon Scientific"
|
||||
class="form-control"
|
||||
value="${existingLabel?.manufacturer || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Model:</label>
|
||||
<input type="text" id="model-${index}"
|
||||
placeholder="e.g., 891LM, TX141TH-Bv2"
|
||||
class="form-control"
|
||||
value="${existingLabel?.model || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notes (optional):</label>
|
||||
<textarea id="notes-${index}"
|
||||
placeholder="Any additional information..."
|
||||
class="form-control"
|
||||
rows="2">${existingLabel?.notes || ''}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Photo (optional):</label>
|
||||
<input type="file" id="photo-${index}" accept="image/*" class="form-control">
|
||||
<small>Upload a photo of the physical device to help verification</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Option 3: Skip labeling -->
|
||||
<div class="label-option">
|
||||
<label>
|
||||
<input type="radio" name="label-type-${index}" value="skip"
|
||||
${!existingLabel || existingLabel.type === 'skip' ? 'checked' : ''}
|
||||
onchange="showLabelingSection(${index}, 'skip')">
|
||||
Skip labeling (let AI identify automatically)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide labeling sections based on selected option
|
||||
*/
|
||||
function showLabelingSection(index, type) {
|
||||
document.getElementById(`existing-device-${index}`).style.display =
|
||||
type === 'existing' ? 'block' : 'none';
|
||||
document.getElementById(`new-device-${index}`).style.display =
|
||||
type === 'new' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup device autocomplete for search input
|
||||
*/
|
||||
function setupDeviceAutocomplete(filename, index) {
|
||||
const searchInput = document.getElementById(`device-search-${index}`);
|
||||
if (!searchInput) return;
|
||||
|
||||
const suggestionsDiv = document.getElementById(`device-suggestions-${index}`);
|
||||
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const query = e.target.value.toLowerCase().trim();
|
||||
|
||||
if (query.length < 2) {
|
||||
suggestionsDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter devices
|
||||
const matches = deviceDatabase.filter(device => {
|
||||
const searchStr = `${device.manufacturer} ${device.model} ${device.device_type}`.toLowerCase();
|
||||
return searchStr.includes(query);
|
||||
}).slice(0, 10); // Top 10 results
|
||||
|
||||
if (matches.length === 0) {
|
||||
suggestionsDiv.innerHTML = '<div class="no-results">No devices found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render suggestions
|
||||
suggestionsDiv.innerHTML = matches.map(device => `
|
||||
<div class="device-suggestion" onclick="selectDevice(${index}, ${device.id}, '${escapeHtml(device.manufacturer)}', '${escapeHtml(device.model)}')">
|
||||
<div class="device-name">
|
||||
<strong>${escapeHtml(device.manufacturer || 'Unknown')}</strong> ${escapeHtml(device.model || 'Unknown')}
|
||||
</div>
|
||||
<div class="device-type">${escapeHtml(device.device_type || '')}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
});
|
||||
|
||||
// Hide suggestions when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!searchInput.contains(e.target) && !suggestionsDiv.contains(e.target)) {
|
||||
suggestionsDiv.innerHTML = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a device from autocomplete
|
||||
*/
|
||||
function selectDevice(index, deviceId, manufacturer, model) {
|
||||
const filename = selectedFiles[index].name;
|
||||
|
||||
// Update hidden input
|
||||
document.getElementById(`selected-device-id-${index}`).value = deviceId;
|
||||
|
||||
// Update search input
|
||||
document.getElementById(`device-search-${index}`).value = `${manufacturer} ${model}`;
|
||||
|
||||
// Clear suggestions
|
||||
document.getElementById(`device-suggestions-${index}`).innerHTML = '';
|
||||
|
||||
// Store label
|
||||
manualLabels.set(filename, {
|
||||
type: 'existing',
|
||||
device_id: deviceId,
|
||||
manufacturer,
|
||||
model,
|
||||
search: `${manufacturer} ${model}`
|
||||
});
|
||||
|
||||
console.log(`Selected device for ${filename}:`, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect manual labels from all files before upload
|
||||
*/
|
||||
function collectManualLabels() {
|
||||
const labels = [];
|
||||
|
||||
selectedFiles.forEach((file, index) => {
|
||||
const labelType = document.querySelector(`input[name="label-type-${index}"]:checked`)?.value;
|
||||
|
||||
if (labelType === 'existing') {
|
||||
const deviceId = document.getElementById(`selected-device-id-${index}`).value;
|
||||
if (deviceId) {
|
||||
labels.push({
|
||||
filename: file.name,
|
||||
device_id: parseInt(deviceId),
|
||||
source: 'manual_existing'
|
||||
});
|
||||
}
|
||||
} else if (labelType === 'new') {
|
||||
const deviceType = document.getElementById(`device-type-${index}`).value;
|
||||
const manufacturer = document.getElementById(`manufacturer-${index}`).value.trim();
|
||||
const model = document.getElementById(`model-${index}`).value.trim();
|
||||
const notes = document.getElementById(`notes-${index}`).value.trim();
|
||||
|
||||
if (deviceType && manufacturer && model) {
|
||||
labels.push({
|
||||
filename: file.name,
|
||||
device_type: deviceType,
|
||||
manufacturer: manufacturer,
|
||||
model: model,
|
||||
notes: notes,
|
||||
source: 'manual_new'
|
||||
});
|
||||
}
|
||||
}
|
||||
// 'skip' type: no label added
|
||||
});
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced upload function with manual labels
|
||||
*/
|
||||
async function uploadFilesEnhanced() {
|
||||
if (selectedFiles.length === 0) {
|
||||
alert('Please select files to upload');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate GPS
|
||||
const lat = parseFloat(document.getElementById('default-lat').value);
|
||||
const lon = parseFloat(document.getElementById('default-lon').value);
|
||||
|
||||
if (isNaN(lat) || isNaN(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) {
|
||||
alert('Please provide valid GPS coordinates');
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect manual labels
|
||||
const manualLabels = collectManualLabels();
|
||||
|
||||
// Show progress
|
||||
document.getElementById('file-list').style.display = 'none';
|
||||
document.getElementById('upload-progress').style.display = 'block';
|
||||
|
||||
try {
|
||||
// Build manifest
|
||||
const sessionUuid = document.getElementById('session-uuid').value || generateUUID();
|
||||
const accuracy = parseFloat(document.getElementById('default-accuracy').value) || 5.0;
|
||||
const altitude = parseFloat(document.getElementById('default-altitude').value) || null;
|
||||
|
||||
const manifest = {
|
||||
session_uuid: sessionUuid,
|
||||
captures: selectedFiles.map(file => ({
|
||||
filename: file.name,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
accuracy: accuracy,
|
||||
altitude: altitude,
|
||||
timestamp: new Date().toISOString()
|
||||
})),
|
||||
manual_labels: manualLabels // Include manual device labels
|
||||
};
|
||||
|
||||
// Build FormData
|
||||
const formData = new FormData();
|
||||
formData.append('manifest', JSON.stringify(manifest));
|
||||
|
||||
selectedFiles.forEach(file => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
// Upload photos (if any)
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
const photoInput = document.getElementById(`photo-${i}`);
|
||||
if (photoInput && photoInput.files.length > 0) {
|
||||
formData.append(`photo_${selectedFiles[i].name}`, photoInput.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload
|
||||
updateProgress(0, 'Uploading files...');
|
||||
|
||||
const response = await fetch('/api/v1/captures/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
updateProgress(100, 'Processing...');
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Upload failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Show results with device identification
|
||||
showEnhancedUploadResults(result);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
alert(`Upload failed: ${error.message}`);
|
||||
|
||||
// Reset
|
||||
document.getElementById('upload-progress').style.display = 'none';
|
||||
document.getElementById('file-list').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show upload results with device matches
|
||||
*/
|
||||
function showEnhancedUploadResults(result) {
|
||||
document.getElementById('upload-progress').style.display = 'none';
|
||||
const resultsDiv = document.getElementById('upload-results');
|
||||
const container = document.getElementById('results-container');
|
||||
|
||||
resultsDiv.style.display = 'block';
|
||||
|
||||
const successCount = result.successful?.length || 0;
|
||||
const errorCount = result.failed?.length || 0;
|
||||
|
||||
let html = `
|
||||
<div class="result-summary">
|
||||
<h3>Upload Complete!</h3>
|
||||
<p>Successfully uploaded: <strong>${successCount}</strong> files</p>
|
||||
${errorCount > 0 ? `<p>Failed: <strong>${errorCount}</strong> files</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (result.successful && result.successful.length > 0) {
|
||||
html += '<h4>Device Identification Results:</h4>';
|
||||
result.successful.forEach(item => {
|
||||
const bestMatch = item.matches?.[0]; // Highest confidence match
|
||||
|
||||
html += `
|
||||
<div class="result-item ${bestMatch ? 'matched' : 'unmatched'}">
|
||||
<div class="file-name">${item.filename}</div>
|
||||
<div class="signal-info">
|
||||
${(item.frequency / 1e6).toFixed(2)} MHz | ${item.protocol || 'RAW'}
|
||||
</div>
|
||||
${bestMatch ? `
|
||||
<div class="device-match">
|
||||
<span class="confidence-badge confidence-${getConfidenceLevelClass(bestMatch.confidence)}">
|
||||
${(bestMatch.confidence * 100).toFixed(0)}% confidence
|
||||
</span>
|
||||
<span class="device-name">
|
||||
${bestMatch.manufacturer || 'Unknown'} ${bestMatch.model || 'Unknown'}
|
||||
</span>
|
||||
<span class="device-type">${bestMatch.device_type || ''}</span>
|
||||
</div>
|
||||
` : `
|
||||
<div class="device-match unknown">
|
||||
<span class="no-match">Unknown Device</span>
|
||||
${item.manual_label ? '<span class="manual-label">✓ Manually labeled</span>' : ''}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS class for confidence level
|
||||
*/
|
||||
function getConfidenceLevelClass(confidence) {
|
||||
if (confidence >= 0.85) return 'high';
|
||||
if (confidence >= 0.65) return 'medium';
|
||||
if (confidence >= 0.40) return 'low';
|
||||
return 'very-low';
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove file from enhanced list
|
||||
*/
|
||||
function removeFileEnhanced(index) {
|
||||
const filename = selectedFiles[index].name;
|
||||
selectedFiles.splice(index, 1);
|
||||
parsedMetadata.delete(filename);
|
||||
manualLabels.delete(filename);
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
document.getElementById('file-list').style.display = 'none';
|
||||
} else {
|
||||
renderEnhancedFileList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show parsing indicator
|
||||
*/
|
||||
function showParsingIndicator(fileCount) {
|
||||
const indicator = document.createElement('div');
|
||||
indicator.id = 'parsing-indicator';
|
||||
indicator.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
z-index: 10000;
|
||||
text-align: center;
|
||||
`;
|
||||
indicator.innerHTML = `
|
||||
<div class="spinner"></div>
|
||||
<p>Parsing ${fileCount} file${fileCount > 1 ? 's' : ''}...</p>
|
||||
`;
|
||||
document.body.appendChild(indicator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide parsing indicator
|
||||
*/
|
||||
function hideParsingIndicator() {
|
||||
const indicator = document.getElementById('parsing-indicator');
|
||||
if (indicator) indicator.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility functions
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function updateProgress(percent, text) {
|
||||
document.getElementById('progress-fill').style.width = `${percent}%`;
|
||||
document.getElementById('progress-text').textContent = text;
|
||||
}
|
||||
|
||||
function generateUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
// Export for global access
|
||||
window.uploadFilesEnhanced = uploadFilesEnhanced;
|
||||
window.removeFileEnhanced = removeFileEnhanced;
|
||||
window.showLabelingSection = showLabelingSection;
|
||||
window.selectDevice = selectDevice;
|
||||
Reference in New Issue
Block a user