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>
238 lines
7.0 KiB
JavaScript
238 lines
7.0 KiB
JavaScript
/**
|
|
* GigLez Pulse Width Analysis using K-means clustering
|
|
* JavaScript port of Python pattern_decoder.py pulse analysis
|
|
*
|
|
* @module pulse-analyzer
|
|
*/
|
|
|
|
/**
|
|
* Simple K-means clustering for 1D data
|
|
* Used to identify SHORT and LONG pulse widths
|
|
*
|
|
* @param {number[]} data - Array of pulse widths
|
|
* @param {number} k - Number of clusters (default: 2 for SHORT/LONG)
|
|
* @param {number} maxIterations - Maximum iterations (default: 100)
|
|
* @returns {number[]} Array of cluster centers
|
|
*/
|
|
export function kMeans(data, k = 2, maxIterations = 100) {
|
|
if (data.length < k) {
|
|
return data.map(v => v);
|
|
}
|
|
|
|
// Initialize centroids using min and max values
|
|
const sorted = [...data].sort((a, b) => a - b);
|
|
let centroids = k === 2
|
|
? [sorted[0], sorted[sorted.length - 1]]
|
|
: Array.from({ length: k }, (_, i) =>
|
|
sorted[Math.floor((i / k) * sorted.length)]
|
|
);
|
|
|
|
for (let iter = 0; iter < maxIterations; iter++) {
|
|
// Assign points to nearest centroid
|
|
const clusters = Array.from({ length: k }, () => []);
|
|
|
|
for (const point of data) {
|
|
const distances = centroids.map(c => Math.abs(point - c));
|
|
const nearestIdx = distances.indexOf(Math.min(...distances));
|
|
clusters[nearestIdx].push(point);
|
|
}
|
|
|
|
// Update centroids (mean of each cluster)
|
|
const newCentroids = clusters.map(cluster => {
|
|
if (cluster.length === 0) return centroids[0]; // Handle empty cluster
|
|
return cluster.reduce((sum, val) => sum + val, 0) / cluster.length;
|
|
});
|
|
|
|
// Check convergence
|
|
const converged = centroids.every((c, i) =>
|
|
Math.abs(c - newCentroids[i]) < 1
|
|
);
|
|
|
|
centroids = newCentroids;
|
|
|
|
if (converged) break;
|
|
}
|
|
|
|
return centroids.sort((a, b) => a - b);
|
|
}
|
|
|
|
/**
|
|
* Identify SHORT and LONG pulse widths from pulse train
|
|
* Uses K-means clustering to automatically detect the two pulse durations
|
|
*
|
|
* @param {number[]} pulses - Raw pulse data (positive = HIGH, negative = LOW)
|
|
* @returns {Object} { shortPulse, longPulse, shortGap, longGap }
|
|
*/
|
|
export function identifyPulseWidths(pulses) {
|
|
// Separate HIGH pulses (positive) and LOW gaps (negative)
|
|
const highPulses = pulses.filter(p => p > 0).map(Math.abs);
|
|
const lowPulses = pulses.filter(p => p < 0).map(Math.abs);
|
|
|
|
// Cluster into SHORT/LONG
|
|
let shortPulse = 0, longPulse = 0;
|
|
if (highPulses.length >= 2) {
|
|
const [short, long] = kMeans(highPulses, 2);
|
|
shortPulse = short;
|
|
longPulse = long;
|
|
} else if (highPulses.length === 1) {
|
|
shortPulse = longPulse = highPulses[0];
|
|
}
|
|
|
|
let shortGap = 0, longGap = 0;
|
|
if (lowPulses.length >= 2) {
|
|
const [short, long] = kMeans(lowPulses, 2);
|
|
shortGap = short;
|
|
longGap = long;
|
|
} else if (lowPulses.length === 1) {
|
|
shortGap = longGap = lowPulses[0];
|
|
}
|
|
|
|
return { shortPulse, longPulse, shortGap, longGap };
|
|
}
|
|
|
|
/**
|
|
* Decode pulse train to binary string using PWM encoding
|
|
* SHORT pulse = 0, LONG pulse = 1
|
|
*
|
|
* @param {number[]} pulses - Raw pulse data
|
|
* @param {number} shortPulse - SHORT pulse width (microseconds)
|
|
* @param {number} longPulse - LONG pulse width (microseconds)
|
|
* @returns {string} Binary string (e.g., "101010110")
|
|
*/
|
|
export function decodeToBits(pulses, shortPulse, longPulse) {
|
|
const threshold = (shortPulse + longPulse) / 2;
|
|
const bits = [];
|
|
|
|
for (const pulse of pulses) {
|
|
if (pulse > 0) { // Only decode HIGH pulses
|
|
const duration = Math.abs(pulse);
|
|
bits.push(duration < threshold ? '0' : '1');
|
|
}
|
|
}
|
|
|
|
return bits.join('');
|
|
}
|
|
|
|
/**
|
|
* Analyze pulse train and extract timing information
|
|
* Returns comprehensive timing analysis for debugging
|
|
*
|
|
* @param {number[]} pulses - Raw pulse data
|
|
* @returns {Object} Timing analysis including pulse widths, counts, patterns
|
|
*/
|
|
export function analyzePulseTrain(pulses) {
|
|
const highPulses = pulses.filter(p => p > 0).map(Math.abs);
|
|
const lowPulses = pulses.filter(p => p < 0).map(Math.abs);
|
|
|
|
const { shortPulse, longPulse, shortGap, longGap } = identifyPulseWidths(pulses);
|
|
const bitPattern = decodeToBits(pulses, shortPulse, longPulse);
|
|
|
|
return {
|
|
// Pulse width identification
|
|
shortPulse,
|
|
longPulse,
|
|
shortGap,
|
|
longGap,
|
|
|
|
// Bit pattern
|
|
bitPattern,
|
|
bitCount: bitPattern.length,
|
|
|
|
// Counts
|
|
totalPulses: pulses.length,
|
|
highPulseCount: highPulses.length,
|
|
lowPulseCount: lowPulses.length,
|
|
|
|
// High pulse statistics
|
|
highPulseStats: {
|
|
min: Math.min(...highPulses),
|
|
max: Math.max(...highPulses),
|
|
mean: highPulses.reduce((a, b) => a + b, 0) / highPulses.length,
|
|
median: highPulses.sort((a, b) => a - b)[Math.floor(highPulses.length / 2)]
|
|
},
|
|
|
|
// Low pulse (gap) statistics
|
|
lowPulseStats: {
|
|
min: Math.min(...lowPulses),
|
|
max: Math.max(...lowPulses),
|
|
mean: lowPulses.reduce((a, b) => a + b, 0) / lowPulses.length,
|
|
median: lowPulses.sort((a, b) => a - b)[Math.floor(lowPulses.length / 2)]
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate pulse data quality
|
|
* Checks for common issues in RF captures
|
|
*
|
|
* @param {number[]} pulses - Raw pulse data
|
|
* @returns {Object} { valid: boolean, issues: string[] }
|
|
*/
|
|
export function validatePulseData(pulses) {
|
|
const issues = [];
|
|
|
|
if (!pulses || pulses.length === 0) {
|
|
return { valid: false, issues: ['No pulse data provided'] };
|
|
}
|
|
|
|
if (pulses.length < 20) {
|
|
issues.push(`Very short capture (${pulses.length} pulses) - may not be decodable`);
|
|
}
|
|
|
|
const allPositive = pulses.every(p => p > 0);
|
|
const allNegative = pulses.every(p => p < 0);
|
|
|
|
if (allPositive || allNegative) {
|
|
issues.push('Pulse data is all positive or all negative - should alternate');
|
|
}
|
|
|
|
const highPulses = pulses.filter(p => p > 0).map(Math.abs);
|
|
const lowPulses = pulses.filter(p => p < 0).map(Math.abs);
|
|
|
|
if (highPulses.length < 5) {
|
|
issues.push('Too few HIGH pulses for reliable analysis');
|
|
}
|
|
|
|
if (lowPulses.length < 5) {
|
|
issues.push('Too few LOW pulses (gaps) for reliable analysis');
|
|
}
|
|
|
|
// Check for outliers (pulses > 100ms are suspicious)
|
|
const outliers = pulses.filter(p => Math.abs(p) > 100000);
|
|
if (outliers.length > 0) {
|
|
issues.push(`${outliers.length} suspiciously long pulses (>100ms) detected`);
|
|
}
|
|
|
|
return {
|
|
valid: issues.length === 0,
|
|
issues
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate Signal-to-Noise Ratio (SNR) estimate
|
|
* Based on pulse width consistency
|
|
*
|
|
* @param {number[]} pulses - Raw pulse data
|
|
* @returns {number} SNR estimate (0-1, higher is better)
|
|
*/
|
|
export function estimateSNR(pulses) {
|
|
const { shortPulse, longPulse } = identifyPulseWidths(pulses);
|
|
const highPulses = pulses.filter(p => p > 0).map(Math.abs);
|
|
|
|
if (highPulses.length < 10) return 0;
|
|
|
|
// Count how many pulses are close to SHORT or LONG
|
|
const threshold = (shortPulse + longPulse) / 2;
|
|
const shortTolerance = shortPulse * 0.3;
|
|
const longTolerance = longPulse * 0.3;
|
|
|
|
const consistentPulses = highPulses.filter(p => {
|
|
const nearShort = Math.abs(p - shortPulse) <= shortTolerance;
|
|
const nearLong = Math.abs(p - longPulse) <= longTolerance;
|
|
return nearShort || nearLong;
|
|
});
|
|
|
|
return consistentPulses.length / highPulses.length;
|
|
}
|