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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user