/** * 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 }; }