Files
giglez/static/js/decoder/fingerprint.js
T
Trilltechnician 01b06fadc6 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>
2026-01-14 23:08:53 -08:00

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();
}