/** * GigLez Pattern-Based Decoder * JavaScript port of Python pattern_decoder.py * * Main decoder class combining timing analysis, fingerprinting, * and protocol matching for single-transmission RF captures * * @module pattern-decoder */ import { protocolDb } from './protocol-database.js'; import { identifyPulseWidths, decodeToBits, analyzePulseTrain, validatePulseData, estimateSNR } from './pulse-analyzer.js'; import { extractFingerprint, calculateSimilarity, compareToProtocol, classifySignal } from './fingerprint.js'; /** * Device Match Result * Standardized format matching Python MatchResult */ export class DeviceMatch { constructor({ deviceName, manufacturer = 'Unknown', confidence = 0.0, matchMethod = 'pattern', matchDetails = {} }) { this.deviceName = deviceName; this.manufacturer = manufacturer; this.confidence = confidence; this.matchMethod = matchMethod; this.matchDetails = matchDetails; } /** * Convert to plain object for JSON serialization */ toObject() { return { device_name: this.deviceName, manufacturer: this.manufacturer, confidence: this.confidence, match_method: this.matchMethod, match_details: this.matchDetails }; } /** * Human-readable string representation */ toString() { return `${this.manufacturer} ${this.deviceName} (${(this.confidence * 100).toFixed(1)}% via ${this.matchMethod})`; } } /** * Pattern-Based RF Signal Decoder * * Decodes single-transmission Sub-GHz captures from Flipper Zero, * LilyGo T-Embed CC1101, and other devices */ export class PatternDecoder { constructor(options = {}) { this.minConfidence = options.minConfidence || 0.4; this.maxResults = options.maxResults || 10; this.debug = options.debug || false; // Load protocol database this.protocolDb = protocolDb; // Statistics this.stats = { totalDecodes: 0, successfulDecodes: 0, failedDecodes: 0, averageConfidence: 0 }; } /** * Main decoding method - analyze .sub file RAW_Data * * @param {Object} metadata - Parsed .sub file metadata * @param {number} metadata.frequency - Frequency in Hz * @param {string} metadata.preset - Modulation preset * @param {string} metadata.protocol - Protocol name (may be "RAW") * @param {number[]} metadata.raw_data - Array of pulse timings (positive=HIGH, negative=LOW) * @returns {DeviceMatch[]} Array of device matches sorted by confidence */ decode(metadata) { this.stats.totalDecodes++; try { // Validate input if (!metadata.raw_data || metadata.raw_data.length === 0) { if (this.debug) console.log('❌ No RAW_Data found in metadata'); this.stats.failedDecodes++; return []; } // Validate pulse data quality const validation = validatePulseData(metadata.raw_data); if (!validation.valid) { if (this.debug) { console.log('⚠️ Pulse data quality issues:'); validation.issues.forEach(issue => console.log(` - ${issue}`)); } // Continue anyway, but flag low quality } // Estimate signal quality const snr = estimateSNR(metadata.raw_data); if (this.debug) { console.log(`📊 Signal Quality: ${(snr * 100).toFixed(1)}%`); } const matches = []; // Strategy 1: Timing Pattern Analysis const timingMatches = this._decodeTimingPatterns(metadata); matches.push(...timingMatches); // Strategy 2: Statistical Fingerprint Matching const fingerprintMatches = this._matchFingerprint(metadata); matches.push(...fingerprintMatches); // Strategy 3: Heuristic Classification (fallback) if (matches.length === 0) { const heuristicMatches = this._classifyByHeuristics(metadata); matches.push(...heuristicMatches); } // Deduplicate and rank matches const rankedMatches = this._rankMatches(matches); // Filter by minimum confidence const filteredMatches = rankedMatches.filter(m => m.confidence >= this.minConfidence); // Limit results const finalMatches = filteredMatches.slice(0, this.maxResults); // Update statistics if (finalMatches.length > 0) { this.stats.successfulDecodes++; const avgConf = finalMatches.reduce((sum, m) => sum + m.confidence, 0) / finalMatches.length; this.stats.averageConfidence = (this.stats.averageConfidence * (this.stats.successfulDecodes - 1) + avgConf) / this.stats.successfulDecodes; } else { this.stats.failedDecodes++; } if (this.debug) { console.log(`✅ Found ${finalMatches.length} matches`); finalMatches.forEach((m, i) => { console.log(` ${i + 1}. ${m.toString()}`); }); } return finalMatches; } catch (error) { if (this.debug) { console.error('❌ Decode error:', error); } this.stats.failedDecodes++; return []; } } /** * Strategy 1: Timing Pattern Analysis * Uses K-means clustering to identify pulse widths and match against protocol library * * @private */ _decodeTimingPatterns(metadata) { const matches = []; try { // Identify SHORT and LONG pulse widths using K-means const { shortPulse, longPulse, shortGap, longGap } = identifyPulseWidths(metadata.raw_data); if (this.debug) { console.log(`🔍 Timing Analysis:`); console.log(` Short Pulse: ${shortPulse.toFixed(1)} μs`); console.log(` Long Pulse: ${longPulse.toFixed(1)} μs`); console.log(` Short Gap: ${shortGap.toFixed(1)} μs`); console.log(` Long Gap: ${longGap.toFixed(1)} μs`); } // Match against protocol database const protocolMatches = this.protocolDb.findByTiming( shortPulse, longPulse, metadata.frequency ); if (this.debug && protocolMatches.length > 0) { console.log(`📚 Protocol Database Matches: ${protocolMatches.length}`); } // Convert protocol matches to DeviceMatch objects for (const proto of protocolMatches) { // Calculate confidence based on timing accuracy const shortError = Math.abs(shortPulse - proto.shortPulseUs) / proto.shortPulseUs; const longError = Math.abs(longPulse - proto.longPulseUs) / proto.longPulseUs; const avgError = (shortError + longError) / 2; const timingConfidence = Math.max(0, 1 - avgError); // Frequency match bonus const freqMatch = proto.matchesFrequency(metadata.frequency); const freqBonus = freqMatch ? 0.1 : 0; const confidence = Math.min(1.0, timingConfidence + freqBonus); // Decode bit pattern const bitPattern = decodeToBits(metadata.raw_data, shortPulse, longPulse); matches.push(new DeviceMatch({ deviceName: proto.name, manufacturer: proto.manufacturer || proto.category, confidence: confidence, matchMethod: 'timing_pattern', matchDetails: { protocol_name: proto.name, category: proto.category, short_pulse_us: shortPulse, long_pulse_us: longPulse, expected_short: proto.shortPulseUs, expected_long: proto.longPulseUs, timing_error: `${(avgError * 100).toFixed(2)}%`, frequency_match: freqMatch, bit_pattern: bitPattern, bit_count: bitPattern.length } })); } } catch (error) { if (this.debug) { console.error('⚠️ Timing pattern analysis failed:', error); } } return matches; } /** * Strategy 2: Statistical Fingerprint Matching * Extracts statistical characteristics and compares against protocol library * * @private */ _matchFingerprint(metadata) { const matches = []; try { // Extract statistical fingerprint const fingerprint = extractFingerprint(metadata.raw_data); if (this.debug) { console.log(`🔬 Fingerprint: ${fingerprint.toString()}`); } // Compare against all protocols for (const proto of this.protocolDb.getAll()) { const comparison = compareToProtocol(fingerprint, { shortPulseUs: proto.shortPulseUs, longPulseUs: proto.longPulseUs, typicalPulseCount: proto.typicalPulseCount }); // Only include if confidence is reasonable if (comparison.overall >= 0.3) { matches.push(new DeviceMatch({ deviceName: proto.name, manufacturer: proto.manufacturer || proto.category, confidence: comparison.overall, matchMethod: 'fingerprint', matchDetails: { protocol_name: proto.name, category: proto.category, pulse_similarity: comparison.pulseSimilarity.toFixed(3), count_similarity: comparison.countSimilarity.toFixed(3), pulse_error: comparison.pulseError, count_error: comparison.countError, mean_pulse_width: fingerprint.meanPulseWidth.toFixed(1), duty_cycle: `${(fingerprint.dutyCycle * 100).toFixed(1)}%`, pulse_count: fingerprint.pulseCount } })); } } } catch (error) { if (this.debug) { console.error('⚠️ Fingerprint matching failed:', error); } } return matches; } /** * Strategy 3: Heuristic Classification (Fallback) * Uses signal characteristics to guess device type when no protocol match * * @private */ _classifyByHeuristics(metadata) { const matches = []; try { const fingerprint = extractFingerprint(metadata.raw_data); const classification = classifySignal(fingerprint); if (classification.confidence >= 0.3) { matches.push(new DeviceMatch({ deviceName: classification.type, manufacturer: 'Unknown', confidence: classification.confidence, matchMethod: 'heuristic', matchDetails: { classification: classification.type, reason: classification.reason, mean_pulse_width: fingerprint.meanPulseWidth.toFixed(1), pulse_count: fingerprint.pulseCount, duty_cycle: `${(fingerprint.dutyCycle * 100).toFixed(1)}%` } })); } } catch (error) { if (this.debug) { console.error('⚠️ Heuristic classification failed:', error); } } return matches; } /** * Deduplicate and rank matches by confidence * * @private * @param {DeviceMatch[]} matches - Array of device matches * @returns {DeviceMatch[]} Sorted and deduplicated matches */ _rankMatches(matches) { // Group by device name + manufacturer const grouped = {}; for (const match of matches) { const key = `${match.manufacturer}::${match.deviceName}`; if (!grouped[key]) { grouped[key] = match; } else { // Keep match with higher confidence if (match.confidence > grouped[key].confidence) { grouped[key] = match; } } } // Convert back to array and sort by confidence const deduplicated = Object.values(grouped); deduplicated.sort((a, b) => b.confidence - a.confidence); return deduplicated; } /** * Analyze pulse train for debugging * Returns detailed timing information * * @param {number[]} pulses - Raw pulse data * @returns {Object} Detailed analysis */ analyze(pulses) { const analysis = analyzePulseTrain(pulses); const fingerprint = extractFingerprint(pulses); const validation = validatePulseData(pulses); const snr = estimateSNR(pulses); return { timing: analysis, fingerprint: fingerprint.toObject(), validation, snr, quality: snr > 0.8 ? 'excellent' : snr > 0.6 ? 'good' : snr > 0.4 ? 'fair' : 'poor' }; } /** * Get decoder statistics * @returns {Object} Decoder stats */ getStats() { return { ...this.stats, successRate: this.stats.totalDecodes > 0 ? (this.stats.successfulDecodes / this.stats.totalDecodes * 100).toFixed(1) + '%' : '0%' }; } /** * Reset statistics */ resetStats() { this.stats = { totalDecodes: 0, successfulDecodes: 0, failedDecodes: 0, averageConfidence: 0 }; } } /** * Convenience function to decode a .sub file metadata object * * @param {Object} metadata - Parsed .sub file * @param {Object} options - Decoder options * @returns {DeviceMatch[]} Device matches */ export function decode(metadata, options = {}) { const decoder = new PatternDecoder(options); return decoder.decode(metadata); }