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>
312 lines
8.9 KiB
JavaScript
312 lines
8.9 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|