01b06fadc6
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>
430 lines
13 KiB
JavaScript
430 lines
13 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|