Phase 3: JavaScript pattern decoder implementation

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 <noreply@anthropic.com>
This commit is contained in:
2026-01-14 23:08:53 -08:00
parent 4237c4bdb8
commit 01b06fadc6
8 changed files with 2334 additions and 0 deletions
+429
View File
@@ -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);
}