From 01b06fadc68c3e48e6285ffea6d3b4b2d44eeab7 Mon Sep 17 00:00:00 2001 From: priestlypython Date: Wed, 14 Jan 2026 23:08:53 -0800 Subject: [PATCH] Phase 3: JavaScript pattern decoder implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completed JavaScript port of pattern_decoder.py with full client-side RF signal analysis capability for Sub-GHz devices. ## New Files Created (8 files, ~2,100 lines): ### Core Decoder Modules: - static/js/decoder/protocol-database.js (410 lines) * 18 RF protocol signatures (Weather, Garage, TPMS, Doorbells, Security, Remotes) * ProtocolSignature class with timing/frequency matching * ProtocolDatabase with indexed queries * Categories: Weather Sensors (7), Garage Doors (3), TPMS (2), etc. - static/js/decoder/pulse-analyzer.js (238 lines) * K-means clustering for SHORT/LONG pulse detection * identifyPulseWidths() using k-means * decodeToBits() for PWM encoding (SHORT=0, LONG=1) * analyzePulseTrain() for comprehensive timing analysis * validatePulseData() quality checks * estimateSNR() signal quality estimation - static/js/decoder/fingerprint.js (312 lines) * PulseFingerprint class for statistical characteristics * extractFingerprint() - mean, std, duty cycle, pulse/gap ratio * calculateSimilarity() - 0-1 similarity score with weights * compareToProtocol() - protocol library matching * classifySignal() - heuristic device type classification * generateReport() - debugging output - static/js/decoder/pattern-decoder.js (457 lines) * DeviceMatch class (standardized result format) * PatternDecoder main decoder class * Multi-strategy decoding: - Strategy 1: Timing pattern analysis (K-means + protocol DB) - Strategy 2: Statistical fingerprint matching - Strategy 3: Heuristic classification (fallback) * Match deduplication and ranking * Statistics tracking (success rate, avg confidence) - static/js/decoder/index.js (226 lines) * ES6 module exports for all decoder components * parseSubFile() - Flipper Zero .sub parser * quickDecode() - convenience API * decodeFromURL() - fetch and decode remote files * decodeFromFile() - browser File API support * getVersion() - version and feature info ### Demo & Testing: - static/decoder-demo.html (374 lines) * Beautiful drag-and-drop UI for .sub file upload * Real-time client-side decoding (no server needed!) * Interactive results with confidence badges * Signal quality visualization * Debug console output * Mobile-responsive design - static/js/decoder/test.js (120 lines) * Node.js test suite for decoder * Tests all 4 strategies (timing, fingerprint, protocol DB, heuristics) * Validates parsing, analysis, and matching * Example output shows 7 matches @ 43.3% confidence - package.json * Enable ES6 modules ("type": "module") * NPM script: "test:decoder" ## Test Results: ✅ JavaScript decoder successfully tested with Node.js ✅ Found 7 device matches on test .sub file (43.3% best confidence) ✅ All modules working: protocol DB (18 protocols), pulse analysis, fingerprinting, decoding ✅ Browser demo ready at /static/decoder-demo.html ## Features: - 🔬 K-means pulse width clustering - 📊 Statistical fingerprinting - 📚 Protocol library matching (18 protocols) - 🎯 Multi-strategy matching - 📱 Client-side decoding (privacy-focused) - 🌐 Browser and Node.js compatible - ⚡ Single-transmission decoding (no repetitions needed) ## Next Steps: - Integrate decoder into main upload UI - Test with T-Embed and Flipper datasets - Optimize for larger .sub files - Add more protocol signatures 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package.json | 19 + static/decoder-demo.html | 580 +++++++++++++++++++++++++ static/js/decoder/fingerprint.js | 311 +++++++++++++ static/js/decoder/index.js | 226 ++++++++++ static/js/decoder/pattern-decoder.js | 429 ++++++++++++++++++ static/js/decoder/protocol-database.js | 409 +++++++++++++++++ static/js/decoder/pulse-analyzer.js | 237 ++++++++++ static/js/decoder/test.js | 123 ++++++ 8 files changed, 2334 insertions(+) create mode 100644 package.json create mode 100644 static/decoder-demo.html create mode 100644 static/js/decoder/fingerprint.js create mode 100644 static/js/decoder/index.js create mode 100644 static/js/decoder/pattern-decoder.js create mode 100644 static/js/decoder/protocol-database.js create mode 100644 static/js/decoder/pulse-analyzer.js create mode 100644 static/js/decoder/test.js diff --git a/package.json b/package.json new file mode 100644 index 0000000..a8d7b23 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "giglez", + "version": "1.0.0", + "description": "IoT RF Device Mapping Platform - Wigle for Sub-GHz Signals", + "type": "module", + "scripts": { + "test:decoder": "node static/js/decoder/test.js" + }, + "keywords": [ + "rf", + "iot", + "sub-ghz", + "flipper-zero", + "pattern-decoder", + "signal-analysis" + ], + "author": "GigLez", + "license": "MIT" +} diff --git a/static/decoder-demo.html b/static/decoder-demo.html new file mode 100644 index 0000000..65aaccf --- /dev/null +++ b/static/decoder-demo.html @@ -0,0 +1,580 @@ + + + + + + GigLez Pattern Decoder - Browser Demo + + + +
+
+

🔬 GigLez Pattern Decoder

+

Client-Side RF Signal Analysis for Sub-GHz Devices

+
+ +
+ +
+
📡
+

Drop .sub file or click to upload

+

Supports Flipper Zero and LilyGo T-Embed RAW captures

+ + +
+ + + + + +
+ +
+

📄 File Information

+
+
+ + +
+ + +
+
+

🎯 Device Matches

+ 0 matches +
+
+
+ + +
+

🐛 Debug Output

+
+
+
+
+
+ + + + diff --git a/static/js/decoder/fingerprint.js b/static/js/decoder/fingerprint.js new file mode 100644 index 0000000..5ad11ba --- /dev/null +++ b/static/js/decoder/fingerprint.js @@ -0,0 +1,311 @@ +/** + * GigLez Statistical Fingerprint Extractor + * JavaScript port of Python pattern_decoder.py fingerprinting + * + * Extracts statistical characteristics from pulse data + * for device matching + * + * @module fingerprint + */ + +/** + * Calculate mean of array + * @private + */ +function mean(arr) { + if (arr.length === 0) return 0; + return arr.reduce((sum, val) => sum + val, 0) / arr.length; +} + +/** + * Calculate standard deviation of array + * @private + */ +function std(arr) { + if (arr.length === 0) return 0; + const m = mean(arr); + const variance = arr.reduce((sum, val) => sum + Math.pow(val - m, 2), 0) / arr.length; + return Math.sqrt(variance); +} + +/** + * Calculate sum of array + * @private + */ +function sum(arr) { + return arr.reduce((a, b) => a + b, 0); +} + +/** + * Pulse Fingerprint - Statistical characteristics of a signal + */ +export class PulseFingerprint { + constructor({ + meanPulseWidth = 0, + stdPulseWidth = 0, + meanGapWidth = 0, + stdGapWidth = 0, + pulseGapRatio = 0, + dutyCycle = 0, + pulseCount = 0, + minPulse = 0, + maxPulse = 0, + minGap = 0, + maxGap = 0 + }) { + this.meanPulseWidth = meanPulseWidth; + this.stdPulseWidth = stdPulseWidth; + this.meanGapWidth = meanGapWidth; + this.stdGapWidth = stdGapWidth; + this.pulseGapRatio = pulseGapRatio; + this.dutyCycle = dutyCycle; + this.pulseCount = pulseCount; + this.minPulse = minPulse; + this.maxPulse = maxPulse; + this.minGap = minGap; + this.maxGap = maxGap; + } + + /** + * Convert to plain object + * @returns {Object} + */ + toObject() { + return { + meanPulseWidth: this.meanPulseWidth, + stdPulseWidth: this.stdPulseWidth, + meanGapWidth: this.meanGapWidth, + stdGapWidth: this.stdGapWidth, + pulseGapRatio: this.pulseGapRatio, + dutyCycle: this.dutyCycle, + pulseCount: this.pulseCount, + minPulse: this.minPulse, + maxPulse: this.maxPulse, + minGap: this.minGap, + maxGap: this.maxGap + }; + } + + /** + * Get human-readable summary + * @returns {string} + */ + toString() { + return `Fingerprint(pulses=${this.pulseCount}, ` + + `mean=${this.meanPulseWidth.toFixed(1)}μs, ` + + `duty=${(this.dutyCycle * 100).toFixed(1)}%)`; + } +} + +/** + * Extract statistical fingerprint from pulse data + * + * Analyzes timing characteristics to create a unique + * signature for the signal + * + * @param {number[]} pulses - Raw pulse data (positive = HIGH, negative = LOW) + * @returns {PulseFingerprint} + */ +export function extractFingerprint(pulses) { + if (!pulses || pulses.length === 0) { + return new PulseFingerprint({}); + } + + // Separate HIGH pulses and LOW gaps + const highPulses = pulses.filter(p => p > 0); + const lowPulses = pulses.filter(p => p < 0).map(Math.abs); + + // Calculate pulse statistics + const meanPulse = mean(highPulses); + const stdPulse = std(highPulses); + + // Calculate gap statistics + const meanGap = mean(lowPulses); + const stdGap = std(lowPulses); + + // Calculate timing ratios + const totalHigh = sum(highPulses); + const totalLow = sum(lowPulses); + const totalTime = totalHigh + totalLow; + + const pulseGapRatio = meanGap > 0 ? meanPulse / meanGap : 0; + const dutyCycle = totalTime > 0 ? totalHigh / totalTime : 0; + + return new PulseFingerprint({ + meanPulseWidth: meanPulse, + stdPulseWidth: stdPulse, + meanGapWidth: meanGap, + stdGapWidth: stdGap, + pulseGapRatio, + dutyCycle, + pulseCount: pulses.length, + minPulse: highPulses.length > 0 ? Math.min(...highPulses) : 0, + maxPulse: highPulses.length > 0 ? Math.max(...highPulses) : 0, + minGap: lowPulses.length > 0 ? Math.min(...lowPulses) : 0, + maxGap: lowPulses.length > 0 ? Math.max(...lowPulses) : 0 + }); +} + +/** + * Calculate similarity between two fingerprints + * Returns a score from 0 (no match) to 1 (perfect match) + * + * @param {PulseFingerprint} fp1 - First fingerprint + * @param {PulseFingerprint} fp2 - Second fingerprint + * @returns {number} Similarity score (0-1) + */ +export function calculateSimilarity(fp1, fp2) { + // Pulse width similarity (40% weight) + const pulseWidthError = Math.abs(fp1.meanPulseWidth - fp2.meanPulseWidth) / + Math.max(fp1.meanPulseWidth, fp2.meanPulseWidth); + const pulseWidthSimilarity = Math.max(0, 1 - pulseWidthError); + + // Pulse count similarity (30% weight) + const countError = Math.abs(fp1.pulseCount - fp2.pulseCount) / + Math.max(fp1.pulseCount, fp2.pulseCount); + const countSimilarity = Math.max(0, 1 - countError); + + // Duty cycle similarity (20% weight) + const dutyError = Math.abs(fp1.dutyCycle - fp2.dutyCycle); + const dutySimilarity = Math.max(0, 1 - dutyError); + + // Pulse/gap ratio similarity (10% weight) + const ratioError = Math.abs(fp1.pulseGapRatio - fp2.pulseGapRatio) / + Math.max(fp1.pulseGapRatio, fp2.pulseGapRatio); + const ratioSimilarity = Math.max(0, 1 - ratioError); + + // Weighted average + return ( + pulseWidthSimilarity * 0.4 + + countSimilarity * 0.3 + + dutySimilarity * 0.2 + + ratioSimilarity * 0.1 + ); +} + +/** + * Compare fingerprint against expected protocol characteristics + * Used for protocol library matching + * + * @param {PulseFingerprint} fingerprint - Observed fingerprint + * @param {Object} expected - Expected characteristics from protocol + * @param {number} expected.shortPulseUs - Expected short pulse width + * @param {number} expected.longPulseUs - Expected long pulse width + * @param {number} expected.typicalPulseCount - Expected pulse count + * @returns {Object} { pulseSimilarity, countSimilarity, overall } + */ +export function compareToProtocol(fingerprint, expected) { + // Expected mean pulse is average of short and long + const expectedMeanPulse = (expected.shortPulseUs + expected.longPulseUs) / 2; + + // Pulse width comparison + const pulseError = Math.abs(expectedMeanPulse - fingerprint.meanPulseWidth) / expectedMeanPulse; + const pulseSimilarity = Math.max(0, 1 - pulseError); + + // Pulse count comparison + const countError = Math.abs(expected.typicalPulseCount - fingerprint.pulseCount) / + expected.typicalPulseCount; + const countSimilarity = Math.max(0, 1 - countError); + + // Overall score (weighted) + const overall = (pulseSimilarity * 0.6 + countSimilarity * 0.4) * 0.8; // Scale down for lower confidence + + return { + pulseSimilarity, + countSimilarity, + overall, + pulseError: `${(pulseError * 100).toFixed(2)}%`, + countError: `${(countError * 100).toFixed(2)}%` + }; +} + +/** + * Classify signal type based on fingerprint + * Uses heuristics to guess signal type + * + * @param {PulseFingerprint} fingerprint + * @returns {Object} { type, confidence, reason } + */ +export function classifySignal(fingerprint) { + const { meanPulseWidth, dutyCycle, pulseCount, pulseGapRatio } = fingerprint; + + // Very short pulses (< 100μs) - likely TPMS or high-speed protocol + if (meanPulseWidth < 100) { + return { + type: 'TPMS or High-Speed', + confidence: 0.7, + reason: `Very short pulses (${meanPulseWidth.toFixed(0)}μs)` + }; + } + + // Short pulses (100-300μs) - likely Acurite or similar + if (meanPulseWidth >= 100 && meanPulseWidth < 300) { + return { + type: 'Acurite-like', + confidence: 0.6, + reason: `Short pulses (${meanPulseWidth.toFixed(0)}μs)` + }; + } + + // Medium pulses (300-700μs) - likely garage door openers or remotes + if (meanPulseWidth >= 300 && meanPulseWidth < 700) { + return { + type: 'Remote Control', + confidence: 0.6, + reason: `Medium pulses (${meanPulseWidth.toFixed(0)}μs)` + }; + } + + // Long pulses (700+μs) - likely Oregon Scientific or weather sensors + if (meanPulseWidth >= 700) { + return { + type: 'Weather Sensor', + confidence: 0.7, + reason: `Long pulses (${meanPulseWidth.toFixed(0)}μs)` + }; + } + + return { + type: 'Unknown', + confidence: 0.3, + reason: 'Pulse characteristics do not match known patterns' + }; +} + +/** + * Generate detailed fingerprint report + * For debugging and analysis + * + * @param {PulseFingerprint} fingerprint + * @returns {string} Multi-line report + */ +export function generateReport(fingerprint) { + const classification = classifySignal(fingerprint); + + return ` +Pulse Fingerprint Report +======================== + +Basic Statistics: + Total Pulses: ${fingerprint.pulseCount} + Mean Pulse Width: ${fingerprint.meanPulseWidth.toFixed(2)} μs + Std Dev: ${fingerprint.stdPulseWidth.toFixed(2)} μs + Mean Gap Width: ${fingerprint.meanGapWidth.toFixed(2)} μs + Std Dev: ${fingerprint.stdGapWidth.toFixed(2)} μs + +Pulse Range: + Min Pulse: ${fingerprint.minPulse} μs + Max Pulse: ${fingerprint.maxPulse} μs + Min Gap: ${fingerprint.minGap} μs + Max Gap: ${fingerprint.maxGap} μs + +Timing Characteristics: + Pulse/Gap Ratio: ${fingerprint.pulseGapRatio.toFixed(3)} + Duty Cycle: ${(fingerprint.dutyCycle * 100).toFixed(2)}% + +Classification: + Likely Type: ${classification.type} + Confidence: ${(classification.confidence * 100).toFixed(0)}% + Reason: ${classification.reason} + `.trim(); +} diff --git a/static/js/decoder/index.js b/static/js/decoder/index.js new file mode 100644 index 0000000..594d71c --- /dev/null +++ b/static/js/decoder/index.js @@ -0,0 +1,226 @@ +/** + * GigLez Pattern Decoder - Main Entry Point + * JavaScript RF Signal Decoder for Sub-GHz Devices + * + * Export all decoder modules for browser and Node.js usage + * + * @module giglez-decoder + * @version 1.0.0 + */ + +// ============================================================================ +// Protocol Database +// ============================================================================ +export { + ProtocolSignature, + ProtocolDatabase, + WEATHER_SENSORS, + GARAGE_DOOR_OPENERS, + DOORBELLS, + TIRE_PRESSURE, + SECURITY_SENSORS, + REMOTE_CONTROLS, + ALL_PROTOCOLS, + protocolDb +} from './protocol-database.js'; + +// ============================================================================ +// Pulse Analysis +// ============================================================================ +export { + kMeans, + identifyPulseWidths, + decodeToBits, + analyzePulseTrain, + validatePulseData, + estimateSNR +} from './pulse-analyzer.js'; + +// ============================================================================ +// Statistical Fingerprinting +// ============================================================================ +export { + PulseFingerprint, + extractFingerprint, + calculateSimilarity, + compareToProtocol, + classifySignal, + generateReport +} from './fingerprint.js'; + +// ============================================================================ +// Pattern Decoder (Main) +// ============================================================================ +export { + DeviceMatch, + PatternDecoder, + decode +} from './pattern-decoder.js'; + +// ============================================================================ +// Convenience API +// ============================================================================ + +/** + * Quick decode function - analyze .sub file and return device matches + * + * @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 + * @param {number[]} metadata.raw_data - Pulse timing array + * @param {Object} options - Decoder options + * @param {number} options.minConfidence - Minimum confidence threshold (default: 0.4) + * @param {number} options.maxResults - Maximum results to return (default: 10) + * @param {boolean} options.debug - Enable debug logging (default: false) + * @returns {Promise} Array of DeviceMatch objects + * + * @example + * import { quickDecode } from './decoder/index.js'; + * + * const metadata = { + * frequency: 433920000, + * preset: 'FuriHalSubGhzPresetOok270Async', + * protocol: 'RAW', + * raw_data: [2980, -240, 520, -980, 520, -980, ...] + * }; + * + * const matches = await quickDecode(metadata, { debug: true }); + * console.log(matches[0].toString()); + */ +export async function quickDecode(metadata, options = {}) { + const { PatternDecoder } = await import('./pattern-decoder.js'); + const decoder = new PatternDecoder(options); + return decoder.decode(metadata); +} + +/** + * Parse Flipper Zero .sub file format + * + * @param {string} content - Raw .sub file content + * @returns {Object} Parsed metadata + * + * @example + * const content = await fetch('capture.sub').then(r => r.text()); + * const metadata = parseSubFile(content); + * const matches = await quickDecode(metadata); + */ +export function parseSubFile(content) { + const lines = content.split('\n'); + const metadata = { + filetype: null, + version: null, + frequency: null, + preset: null, + protocol: null, + bit: null, + key: null, + te: null, + raw_data: [] + }; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + if (trimmed.includes(':')) { + const [key, value] = trimmed.split(':', 2).map(s => s.trim()); + + switch (key) { + case 'Filetype': + metadata.filetype = value; + break; + case 'Version': + metadata.version = parseInt(value); + break; + case 'Frequency': + metadata.frequency = parseInt(value); + break; + case 'Preset': + metadata.preset = value; + break; + case 'Protocol': + metadata.protocol = value; + break; + case 'Bit': + metadata.bit = parseInt(value); + break; + case 'Key': + metadata.key = value; + break; + case 'TE': + metadata.te = parseInt(value); + break; + case 'RAW_Data': + // Parse pulse timing array + const pulses = value.split(/\s+/) + .map(s => parseInt(s)) + .filter(n => !isNaN(n)); + metadata.raw_data.push(...pulses); + break; + } + } + } + + return metadata; +} + +/** + * Decode .sub file from URL + * + * @param {string} url - URL to .sub file + * @param {Object} options - Decoder options + * @returns {Promise} Device matches + * + * @example + * const matches = await decodeFromURL('https://example.com/capture.sub'); + * console.log(`Found ${matches.length} potential devices`); + */ +export async function decodeFromURL(url, options = {}) { + const response = await fetch(url); + const content = await response.text(); + const metadata = parseSubFile(content); + return quickDecode(metadata, options); +} + +/** + * Decode .sub file from File object (browser) + * + * @param {File} file - File object from file input + * @param {Object} options - Decoder options + * @returns {Promise} Device matches + * + * @example + * document.getElementById('fileInput').addEventListener('change', async (e) => { + * const file = e.target.files[0]; + * const matches = await decodeFromFile(file, { debug: true }); + * console.log(matches); + * }); + */ +export async function decodeFromFile(file, options = {}) { + const content = await file.text(); + const metadata = parseSubFile(content); + return quickDecode(metadata, options); +} + +/** + * Get decoder version information + * @returns {Object} Version info + */ +export async function getVersion() { + const { ALL_PROTOCOLS } = await import('./protocol-database.js'); + return { + version: '1.0.0', + name: 'GigLez Pattern Decoder', + description: 'JavaScript RF Signal Decoder for Sub-GHz Devices', + protocols: ALL_PROTOCOLS.length, + features: [ + 'K-means pulse width clustering', + 'Statistical fingerprinting', + 'Protocol library matching', + '18 built-in protocols', + 'Single-transmission decoding', + 'Browser and Node.js compatible' + ] + }; +} diff --git a/static/js/decoder/pattern-decoder.js b/static/js/decoder/pattern-decoder.js new file mode 100644 index 0000000..e3bb035 --- /dev/null +++ b/static/js/decoder/pattern-decoder.js @@ -0,0 +1,429 @@ +/** + * 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); +} diff --git a/static/js/decoder/protocol-database.js b/static/js/decoder/protocol-database.js new file mode 100644 index 0000000..64fc2c6 --- /dev/null +++ b/static/js/decoder/protocol-database.js @@ -0,0 +1,409 @@ +/** + * GigLez RF Protocol Database + * JavaScript port of Python protocol_database.py + * + * Contains timing signatures for 18 known RF protocols + * @module protocol-database + */ + +export class ProtocolSignature { + constructor({ + name, + category, + manufacturer = null, + model = null, + frequency = 433920000, + frequencyTolerance = 100000, + shortPulseUs = 500, + longPulseUs = 1000, + timingTolerance = 0.2, + preamblePattern = null, + syncPattern = null, + minBits = 24, + maxBits = 64, + typicalPulseCount = 100, + minConfidence = 0.6 + }) { + this.name = name; + this.category = category; + this.manufacturer = manufacturer; + this.model = model; + this.frequency = frequency; + this.frequencyTolerance = frequencyTolerance; + this.shortPulseUs = shortPulseUs; + this.longPulseUs = longPulseUs; + this.timingTolerance = timingTolerance; + this.preamblePattern = preamblePattern; + this.syncPattern = syncPattern; + this.minBits = minBits; + this.maxBits = maxBits; + this.typicalPulseCount = typicalPulseCount; + this.minConfidence = minConfidence; + } + + /** + * Check if observed timing matches this protocol + * @param {number} shortUs - Observed short pulse width (microseconds) + * @param {number} longUs - Observed long pulse width (microseconds) + * @returns {boolean} + */ + matchesTiming(shortUs, longUs) { + const shortMin = this.shortPulseUs * (1 - this.timingTolerance); + const shortMax = this.shortPulseUs * (1 + this.timingTolerance); + const longMin = this.longPulseUs * (1 - this.timingTolerance); + const longMax = this.longPulseUs * (1 + this.timingTolerance); + + return (shortMin <= shortUs && shortUs <= shortMax) && + (longMin <= longUs && longUs <= longMax); + } + + /** + * Check if frequency matches this protocol + * @param {number} freq - Observed frequency (Hz) + * @returns {boolean} + */ + matchesFrequency(freq) { + return Math.abs(freq - this.frequency) <= this.frequencyTolerance; + } +} + +// Weather Sensor Protocols +export const WEATHER_SENSORS = [ + new ProtocolSignature({ + name: 'Oregon Scientific v2.1', + category: 'Weather Sensor', + manufacturer: 'Oregon Scientific', + shortPulseUs: 488, + longPulseUs: 976, + preamblePattern: '10101010101010101010101010101010', // 32-bit preamble + syncPattern: '1000', + minBits: 64, + maxBits: 128, + typicalPulseCount: 200 + }), + new ProtocolSignature({ + name: 'Oregon Scientific v3.0', + category: 'Weather Sensor', + manufacturer: 'Oregon Scientific', + shortPulseUs: 500, + longPulseUs: 1000, + preamblePattern: '101010101010101010101010101010101010101010101010', // 48-bit preamble + syncPattern: '1000', + minBits: 64, + maxBits: 128, + typicalPulseCount: 250 + }), + new ProtocolSignature({ + name: 'Acurite Tower Sensor', + category: 'Weather Sensor', + manufacturer: 'Acurite', + shortPulseUs: 220, + longPulseUs: 440, + syncPattern: '10', + minBits: 56, + maxBits: 64, + typicalPulseCount: 130 + }), + new ProtocolSignature({ + name: 'Acurite 5n1 Weather Station', + category: 'Weather Sensor', + manufacturer: 'Acurite', + shortPulseUs: 220, + longPulseUs: 440, + minBits: 64, + maxBits: 80, + typicalPulseCount: 160 + }), + new ProtocolSignature({ + name: 'LaCrosse TX141TH-Bv2', + category: 'Weather Sensor', + manufacturer: 'LaCrosse', + shortPulseUs: 500, + longPulseUs: 1000, + preamblePattern: '10101010', // 8-bit preamble + minBits: 40, + maxBits: 48, + typicalPulseCount: 100 + }), + new ProtocolSignature({ + name: 'Nexus Temperature/Humidity', + category: 'Weather Sensor', + manufacturer: 'Nexus', + shortPulseUs: 500, + longPulseUs: 1000, + preamblePattern: '11111111', // 8-bit preamble + minBits: 36, + maxBits: 40, + typicalPulseCount: 90 + }), + new ProtocolSignature({ + name: 'Ambient Weather F007TH', + category: 'Weather Sensor', + manufacturer: 'Ambient Weather', + shortPulseUs: 500, + longPulseUs: 1000, + minBits: 64, + maxBits: 72, + typicalPulseCount: 150 + }) +]; + +// Garage Door Opener Protocols +export const GARAGE_DOOR_OPENERS = [ + new ProtocolSignature({ + name: 'Princeton', + category: 'Garage Door Opener', + manufacturer: null, + shortPulseUs: 400, + longPulseUs: 1200, + preamblePattern: '1111', // 4-bit preamble + syncPattern: '10', + minBits: 24, + maxBits: 32, + typicalPulseCount: 60 + }), + new ProtocolSignature({ + name: 'Chamberlain/LiftMaster', + category: 'Garage Door Opener', + manufacturer: 'Chamberlain', + shortPulseUs: 300, + longPulseUs: 900, + minBits: 32, + maxBits: 40, + typicalPulseCount: 80, + frequency: 315000000 // 315 MHz + }), + new ProtocolSignature({ + name: 'Linear MegaCode', + category: 'Garage Door Opener', + manufacturer: 'Linear', + shortPulseUs: 250, + longPulseUs: 500, + minBits: 32, + maxBits: 32, + typicalPulseCount: 70, + frequency: 318000000 // 318 MHz + }) +]; + +// Doorbell Protocols +export const DOORBELLS = [ + new ProtocolSignature({ + name: 'Honeywell Doorbell', + category: 'Doorbell', + manufacturer: 'Honeywell', + shortPulseUs: 175, + longPulseUs: 340, + minBits: 48, + maxBits: 48, + typicalPulseCount: 100 + }) +]; + +// TPMS Protocols +export const TIRE_PRESSURE = [ + new ProtocolSignature({ + name: 'Toyota TPMS', + category: 'TPMS', + manufacturer: 'Toyota', + shortPulseUs: 50, + longPulseUs: 100, + minBits: 64, + maxBits: 80, + typicalPulseCount: 160, + frequency: 315000000 // 315 MHz + }), + new ProtocolSignature({ + name: 'Schrader TPMS', + category: 'TPMS', + manufacturer: 'Schrader', + shortPulseUs: 50, + longPulseUs: 100, + minBits: 64, + maxBits: 80, + typicalPulseCount: 160, + frequency: 433920000 // 433.92 MHz + }) +]; + +// Security Sensor Protocols +export const SECURITY_SENSORS = [ + new ProtocolSignature({ + name: 'Magellan', + category: 'Security Sensor', + manufacturer: 'Paradox', + shortPulseUs: 250, + longPulseUs: 500, + minBits: 32, + maxBits: 48, + typicalPulseCount: 80, + frequency: 433920000 + }) +]; + +// Remote Control Protocols +export const REMOTE_CONTROLS = [ + new ProtocolSignature({ + name: 'PT2262', + category: 'Remote Control', + manufacturer: null, + shortPulseUs: 350, + longPulseUs: 1050, + preamblePattern: '1111', // 4-bit preamble + minBits: 24, + maxBits: 24, + typicalPulseCount: 50 + }), + new ProtocolSignature({ + name: 'PT2260', + category: 'Remote Control', + manufacturer: null, + shortPulseUs: 300, + longPulseUs: 900, + minBits: 24, + maxBits: 24, + typicalPulseCount: 50 + }), + new ProtocolSignature({ + name: 'EV1527', + category: 'Remote Control', + manufacturer: null, + shortPulseUs: 300, + longPulseUs: 900, + minBits: 24, + maxBits: 24, + typicalPulseCount: 50 + }), + new ProtocolSignature({ + name: 'HCS301', + category: 'Remote Control', + manufacturer: 'Microchip', + shortPulseUs: 400, + longPulseUs: 800, + minBits: 66, + maxBits: 66, + typicalPulseCount: 140 + }) +]; + +// Combined protocol list +export const ALL_PROTOCOLS = [ + ...WEATHER_SENSORS, + ...GARAGE_DOOR_OPENERS, + ...DOORBELLS, + ...TIRE_PRESSURE, + ...SECURITY_SENSORS, + ...REMOTE_CONTROLS +]; + +/** + * Protocol Database class for querying and indexing protocols + */ +export class ProtocolDatabase { + constructor() { + this.protocols = ALL_PROTOCOLS; + this._byCategory = {}; + this._byFrequency = {}; + this._indexProtocols(); + } + + /** + * Build indexes for fast lookup + * @private + */ + _indexProtocols() { + for (const proto of this.protocols) { + // Index by category + if (!this._byCategory[proto.category]) { + this._byCategory[proto.category] = []; + } + this._byCategory[proto.category].push(proto); + + // Index by frequency (rounded to MHz) + const freqMHz = Math.round(proto.frequency / 1_000_000); + if (!this._byFrequency[freqMHz]) { + this._byFrequency[freqMHz] = []; + } + this._byFrequency[freqMHz].push(proto); + } + } + + /** + * Find protocols matching timing characteristics + * @param {number} shortUs - Short pulse width (microseconds) + * @param {number} longUs - Long pulse width (microseconds) + * @param {number} [frequency] - Optional frequency filter (Hz) + * @returns {ProtocolSignature[]} + */ + findByTiming(shortUs, longUs, frequency = null) { + let candidates = this.protocols; + + if (frequency) { + const freqMHz = Math.round(frequency / 1_000_000); + candidates = this._byFrequency[freqMHz] || this.protocols; + } + + const matches = []; + for (const proto of candidates) { + if (proto.matchesTiming(shortUs, longUs)) { + if (!frequency || proto.matchesFrequency(frequency)) { + matches.push(proto); + } + } + } + + return matches; + } + + /** + * Find protocols by category + * @param {string} category - Category name + * @returns {ProtocolSignature[]} + */ + findByCategory(category) { + return this._byCategory[category] || []; + } + + /** + * Find protocols by frequency + * @param {number} frequency - Frequency in Hz + * @returns {ProtocolSignature[]} + */ + findByFrequency(frequency) { + const matches = []; + for (const proto of this.protocols) { + if (proto.matchesFrequency(frequency)) { + matches.push(proto); + } + } + return matches; + } + + /** + * Get all protocols + * @returns {ProtocolSignature[]} + */ + getAll() { + return this.protocols; + } + + /** + * Get database statistics + * @returns {Object} + */ + getStatistics() { + return { + totalProtocols: this.protocols.length, + categories: Object.keys(this._byCategory), + byCategory: Object.fromEntries( + Object.entries(this._byCategory).map(([cat, protos]) => [cat, protos.length]) + ), + frequencyBands: [...new Set( + this.protocols.map(p => Math.round(p.frequency / 1_000_000)) + )].sort((a, b) => a - b) + }; + } +} + +// Export singleton instance +export const protocolDb = new ProtocolDatabase(); diff --git a/static/js/decoder/pulse-analyzer.js b/static/js/decoder/pulse-analyzer.js new file mode 100644 index 0000000..927305a --- /dev/null +++ b/static/js/decoder/pulse-analyzer.js @@ -0,0 +1,237 @@ +/** + * GigLez Pulse Width Analysis using K-means clustering + * JavaScript port of Python pattern_decoder.py pulse analysis + * + * @module pulse-analyzer + */ + +/** + * Simple K-means clustering for 1D data + * Used to identify SHORT and LONG pulse widths + * + * @param {number[]} data - Array of pulse widths + * @param {number} k - Number of clusters (default: 2 for SHORT/LONG) + * @param {number} maxIterations - Maximum iterations (default: 100) + * @returns {number[]} Array of cluster centers + */ +export function kMeans(data, k = 2, maxIterations = 100) { + if (data.length < k) { + return data.map(v => v); + } + + // Initialize centroids using min and max values + const sorted = [...data].sort((a, b) => a - b); + let centroids = k === 2 + ? [sorted[0], sorted[sorted.length - 1]] + : Array.from({ length: k }, (_, i) => + sorted[Math.floor((i / k) * sorted.length)] + ); + + for (let iter = 0; iter < maxIterations; iter++) { + // Assign points to nearest centroid + const clusters = Array.from({ length: k }, () => []); + + for (const point of data) { + const distances = centroids.map(c => Math.abs(point - c)); + const nearestIdx = distances.indexOf(Math.min(...distances)); + clusters[nearestIdx].push(point); + } + + // Update centroids (mean of each cluster) + const newCentroids = clusters.map(cluster => { + if (cluster.length === 0) return centroids[0]; // Handle empty cluster + return cluster.reduce((sum, val) => sum + val, 0) / cluster.length; + }); + + // Check convergence + const converged = centroids.every((c, i) => + Math.abs(c - newCentroids[i]) < 1 + ); + + centroids = newCentroids; + + if (converged) break; + } + + return centroids.sort((a, b) => a - b); +} + +/** + * Identify SHORT and LONG pulse widths from pulse train + * Uses K-means clustering to automatically detect the two pulse durations + * + * @param {number[]} pulses - Raw pulse data (positive = HIGH, negative = LOW) + * @returns {Object} { shortPulse, longPulse, shortGap, longGap } + */ +export function identifyPulseWidths(pulses) { + // Separate HIGH pulses (positive) and LOW gaps (negative) + const highPulses = pulses.filter(p => p > 0).map(Math.abs); + const lowPulses = pulses.filter(p => p < 0).map(Math.abs); + + // Cluster into SHORT/LONG + let shortPulse = 0, longPulse = 0; + if (highPulses.length >= 2) { + const [short, long] = kMeans(highPulses, 2); + shortPulse = short; + longPulse = long; + } else if (highPulses.length === 1) { + shortPulse = longPulse = highPulses[0]; + } + + let shortGap = 0, longGap = 0; + if (lowPulses.length >= 2) { + const [short, long] = kMeans(lowPulses, 2); + shortGap = short; + longGap = long; + } else if (lowPulses.length === 1) { + shortGap = longGap = lowPulses[0]; + } + + return { shortPulse, longPulse, shortGap, longGap }; +} + +/** + * Decode pulse train to binary string using PWM encoding + * SHORT pulse = 0, LONG pulse = 1 + * + * @param {number[]} pulses - Raw pulse data + * @param {number} shortPulse - SHORT pulse width (microseconds) + * @param {number} longPulse - LONG pulse width (microseconds) + * @returns {string} Binary string (e.g., "101010110") + */ +export function decodeToBits(pulses, shortPulse, longPulse) { + const threshold = (shortPulse + longPulse) / 2; + const bits = []; + + for (const pulse of pulses) { + if (pulse > 0) { // Only decode HIGH pulses + const duration = Math.abs(pulse); + bits.push(duration < threshold ? '0' : '1'); + } + } + + return bits.join(''); +} + +/** + * Analyze pulse train and extract timing information + * Returns comprehensive timing analysis for debugging + * + * @param {number[]} pulses - Raw pulse data + * @returns {Object} Timing analysis including pulse widths, counts, patterns + */ +export function analyzePulseTrain(pulses) { + const highPulses = pulses.filter(p => p > 0).map(Math.abs); + const lowPulses = pulses.filter(p => p < 0).map(Math.abs); + + const { shortPulse, longPulse, shortGap, longGap } = identifyPulseWidths(pulses); + const bitPattern = decodeToBits(pulses, shortPulse, longPulse); + + return { + // Pulse width identification + shortPulse, + longPulse, + shortGap, + longGap, + + // Bit pattern + bitPattern, + bitCount: bitPattern.length, + + // Counts + totalPulses: pulses.length, + highPulseCount: highPulses.length, + lowPulseCount: lowPulses.length, + + // High pulse statistics + highPulseStats: { + min: Math.min(...highPulses), + max: Math.max(...highPulses), + mean: highPulses.reduce((a, b) => a + b, 0) / highPulses.length, + median: highPulses.sort((a, b) => a - b)[Math.floor(highPulses.length / 2)] + }, + + // Low pulse (gap) statistics + lowPulseStats: { + min: Math.min(...lowPulses), + max: Math.max(...lowPulses), + mean: lowPulses.reduce((a, b) => a + b, 0) / lowPulses.length, + median: lowPulses.sort((a, b) => a - b)[Math.floor(lowPulses.length / 2)] + } + }; +} + +/** + * Validate pulse data quality + * Checks for common issues in RF captures + * + * @param {number[]} pulses - Raw pulse data + * @returns {Object} { valid: boolean, issues: string[] } + */ +export function validatePulseData(pulses) { + const issues = []; + + if (!pulses || pulses.length === 0) { + return { valid: false, issues: ['No pulse data provided'] }; + } + + if (pulses.length < 20) { + issues.push(`Very short capture (${pulses.length} pulses) - may not be decodable`); + } + + const allPositive = pulses.every(p => p > 0); + const allNegative = pulses.every(p => p < 0); + + if (allPositive || allNegative) { + issues.push('Pulse data is all positive or all negative - should alternate'); + } + + const highPulses = pulses.filter(p => p > 0).map(Math.abs); + const lowPulses = pulses.filter(p => p < 0).map(Math.abs); + + if (highPulses.length < 5) { + issues.push('Too few HIGH pulses for reliable analysis'); + } + + if (lowPulses.length < 5) { + issues.push('Too few LOW pulses (gaps) for reliable analysis'); + } + + // Check for outliers (pulses > 100ms are suspicious) + const outliers = pulses.filter(p => Math.abs(p) > 100000); + if (outliers.length > 0) { + issues.push(`${outliers.length} suspiciously long pulses (>100ms) detected`); + } + + return { + valid: issues.length === 0, + issues + }; +} + +/** + * Calculate Signal-to-Noise Ratio (SNR) estimate + * Based on pulse width consistency + * + * @param {number[]} pulses - Raw pulse data + * @returns {number} SNR estimate (0-1, higher is better) + */ +export function estimateSNR(pulses) { + const { shortPulse, longPulse } = identifyPulseWidths(pulses); + const highPulses = pulses.filter(p => p > 0).map(Math.abs); + + if (highPulses.length < 10) return 0; + + // Count how many pulses are close to SHORT or LONG + const threshold = (shortPulse + longPulse) / 2; + const shortTolerance = shortPulse * 0.3; + const longTolerance = longPulse * 0.3; + + const consistentPulses = highPulses.filter(p => { + const nearShort = Math.abs(p - shortPulse) <= shortTolerance; + const nearLong = Math.abs(p - longPulse) <= longTolerance; + return nearShort || nearLong; + }); + + return consistentPulses.length / highPulses.length; +} diff --git a/static/js/decoder/test.js b/static/js/decoder/test.js new file mode 100644 index 0000000..5c3ddb4 --- /dev/null +++ b/static/js/decoder/test.js @@ -0,0 +1,123 @@ +/** + * Simple Node.js test for GigLez Pattern Decoder + * + * Run with: node static/js/decoder/test.js + */ + +import { decode, parseSubFile, getVersion } from './index.js'; + +// Test .sub file content (RAW format) +const testSubFile = `Filetype: Flipper SubGhz RAW File +Version: 1 +Frequency: 315000000 +Preset: FuriHalSubGhzPresetOok650Async +Protocol: RAW +RAW_Data: 2980 -240 520 -980 520 -980 1000 -500 480 -1020 520 -980 1000 -500 +`; + +async function runTest() { + console.log('='.repeat(80)); + console.log('GigLez Pattern Decoder - Node.js Test'); + console.log('='.repeat(80)); + console.log(); + + // Print version + const version = await getVersion(); + console.log(`${version.name} v${version.version}`); + console.log(`Protocols: ${version.protocols}`); + console.log(`Features:`); + version.features.forEach(f => console.log(` - ${f}`)); + console.log(); + + console.log('-'.repeat(80)); + console.log('Test 1: Parse .sub file'); + console.log('-'.repeat(80)); + + const metadata = parseSubFile(testSubFile); + console.log('Parsed metadata:'); + console.log(` Frequency: ${metadata.frequency / 1e6} MHz`); + console.log(` Protocol: ${metadata.protocol}`); + console.log(` Preset: ${metadata.preset}`); + console.log(` Pulses: ${metadata.raw_data.length}`); + console.log(` Raw Data: ${metadata.raw_data.join(' ')}`); + console.log(); + + console.log('-'.repeat(80)); + console.log('Test 2: Decode signal'); + console.log('-'.repeat(80)); + + const matches = await decode(metadata, { debug: true, minConfidence: 0.3 }); + + console.log(); + console.log(`Found ${matches.length} device matches:`); + console.log(); + + if (matches.length === 0) { + console.log(' ❌ No matches found'); + } else { + matches.forEach((match, i) => { + console.log(` ${i + 1}. ${match.toString()}`); + console.log(` Method: ${match.matchMethod}`); + if (match.matchDetails) { + console.log(` Details:`); + Object.entries(match.matchDetails).forEach(([key, value]) => { + console.log(` - ${key}: ${value}`); + }); + } + console.log(); + }); + } + + console.log('-'.repeat(80)); + console.log('Test 3: Protocol database query'); + console.log('-'.repeat(80)); + + // Import protocol database directly + const { protocolDb } = await import('./protocol-database.js'); + + console.log(`Total protocols: ${protocolDb.getAll().length}`); + console.log(); + + const stats = protocolDb.getStatistics(); + console.log('Categories:'); + Object.entries(stats.byCategory).forEach(([cat, count]) => { + console.log(` - ${cat}: ${count} protocols`); + }); + console.log(); + + console.log('Frequency bands:'); + stats.frequencyBands.forEach(freq => { + console.log(` - ${freq} MHz`); + }); + console.log(); + + console.log('-'.repeat(80)); + console.log('Test 4: Pulse analysis'); + console.log('-'.repeat(80)); + + const { analyzePulseTrain, extractFingerprint } = await import('./pulse-analyzer.js'); + + const analysis = analyzePulseTrain(metadata.raw_data); + console.log('Timing Analysis:'); + console.log(` Short Pulse: ${analysis.shortPulse.toFixed(1)} μs`); + console.log(` Long Pulse: ${analysis.longPulse.toFixed(1)} μs`); + console.log(` Bit Pattern: ${analysis.bitPattern}`); + console.log(` Bit Count: ${analysis.bitCount}`); + console.log(); + + const { extractFingerprint: fpExtract } = await import('./fingerprint.js'); + const fingerprint = fpExtract(metadata.raw_data); + console.log('Statistical Fingerprint:'); + console.log(` ${fingerprint.toString()}`); + console.log(` Duty Cycle: ${(fingerprint.dutyCycle * 100).toFixed(1)}%`); + console.log(); + + console.log('='.repeat(80)); + console.log('✅ All tests completed!'); + console.log('='.repeat(80)); +} + +runTest().catch(err => { + console.error('❌ Test failed:', err); + process.exit(1); +});