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,580 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GigLez Pattern Decoder - Browser Demo</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1.1em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
background: #f7f9fc;
|
||||
border: 2px dashed #667eea;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.upload-section:hover {
|
||||
border-color: #764ba2;
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.upload-section.dragover {
|
||||
background: #e3e9f3;
|
||||
border-color: #764ba2;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 3em;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 30px;
|
||||
font-size: 1em;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.results-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.results-section.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
background: #f7f9fc;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.file-info h3 {
|
||||
color: #667eea;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
background: white;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.info-item value {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.matches-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.matches-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.matches-header h3 {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.match-card {
|
||||
background: white;
|
||||
border: 2px solid #e3e9f3;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 15px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.match-card:hover {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
.match-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: 1.3em;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.manufacturer {
|
||||
color: #666;
|
||||
font-size: 0.95em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.confidence-badge {
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.confidence-high {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.confidence-medium {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.confidence-low {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.match-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #e3e9f3;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #666;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.no-matches {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-matches-icon {
|
||||
font-size: 3em;
|
||||
margin-bottom: 15px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.debug-section {
|
||||
margin-top: 30px;
|
||||
background: #f7f9fc;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.debug-section h3 {
|
||||
color: #667eea;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.debug-output {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85em;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2em;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid #f3f4f6;
|
||||
border-top: 4px solid #667eea;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 15px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔬 GigLez Pattern Decoder</h1>
|
||||
<p>Client-Side RF Signal Analysis for Sub-GHz Devices</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- Upload Section -->
|
||||
<div class="upload-section" id="uploadSection">
|
||||
<div class="upload-icon">📡</div>
|
||||
<h2>Drop .sub file or click to upload</h2>
|
||||
<p style="margin: 15px 0;">Supports Flipper Zero and LilyGo T-Embed RAW captures</p>
|
||||
<button class="btn" onclick="document.getElementById('fileInput').click()">
|
||||
Choose File
|
||||
</button>
|
||||
<input type="file" id="fileInput" class="file-input" accept=".sub,.txt">
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div class="loading" id="loadingSection" style="display: none;">
|
||||
<div class="spinner"></div>
|
||||
<p>Analyzing RF signal...</p>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div class="results-section" id="resultsSection">
|
||||
<!-- File Info -->
|
||||
<div class="file-info">
|
||||
<h3>📄 File Information</h3>
|
||||
<div class="info-grid" id="fileInfoGrid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Decoder Stats -->
|
||||
<div class="stats-grid" id="statsGrid"></div>
|
||||
|
||||
<!-- Matches -->
|
||||
<div class="matches-container">
|
||||
<div class="matches-header">
|
||||
<h3>🎯 Device Matches</h3>
|
||||
<span class="badge" id="matchCount">0 matches</span>
|
||||
</div>
|
||||
<div id="matchesContainer"></div>
|
||||
</div>
|
||||
|
||||
<!-- Debug Section -->
|
||||
<div class="debug-section">
|
||||
<h3>🐛 Debug Output</h3>
|
||||
<div class="debug-output" id="debugOutput"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { decodeFromFile, parseSubFile, getVersion } from './js/decoder/index.js';
|
||||
|
||||
let debugLog = [];
|
||||
|
||||
// Override console.log for debug output
|
||||
const originalLog = console.log;
|
||||
console.log = function(...args) {
|
||||
debugLog.push(args.join(' '));
|
||||
originalLog.apply(console, args);
|
||||
};
|
||||
|
||||
// Print version info
|
||||
const version = getVersion();
|
||||
console.log(`${version.name} v${version.version}`);
|
||||
console.log(`Loaded ${version.protocols} protocols`);
|
||||
|
||||
// File upload handling
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const uploadSection = document.getElementById('uploadSection');
|
||||
const loadingSection = document.getElementById('loadingSection');
|
||||
const resultsSection = document.getElementById('resultsSection');
|
||||
|
||||
fileInput.addEventListener('change', handleFileSelect);
|
||||
|
||||
// Drag and drop
|
||||
uploadSection.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadSection.classList.add('dragover');
|
||||
});
|
||||
|
||||
uploadSection.addEventListener('dragleave', () => {
|
||||
uploadSection.classList.remove('dragover');
|
||||
});
|
||||
|
||||
uploadSection.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
uploadSection.classList.remove('dragover');
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleFileSelect(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
await handleFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
debugLog = [];
|
||||
console.log(`\n=== Analyzing ${file.name} ===\n`);
|
||||
|
||||
// Show loading
|
||||
uploadSection.style.display = 'none';
|
||||
loadingSection.style.display = 'block';
|
||||
resultsSection.classList.remove('visible');
|
||||
|
||||
try {
|
||||
// Parse file
|
||||
const content = await file.text();
|
||||
const metadata = parseSubFile(content);
|
||||
|
||||
console.log('Parsed metadata:', metadata);
|
||||
|
||||
// Decode with debug enabled
|
||||
const matches = await decodeFromFile(file, { debug: true, minConfidence: 0.3 });
|
||||
|
||||
console.log(`\nFound ${matches.length} device matches\n`);
|
||||
|
||||
// Display results
|
||||
displayResults(file.name, metadata, matches);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Failed to decode file: ' + error.message);
|
||||
uploadSection.style.display = 'block';
|
||||
loadingSection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(filename, metadata, matches) {
|
||||
// Hide loading, show results
|
||||
loadingSection.style.display = 'none';
|
||||
resultsSection.classList.add('visible');
|
||||
|
||||
// File info
|
||||
const fileInfoGrid = document.getElementById('fileInfoGrid');
|
||||
fileInfoGrid.innerHTML = `
|
||||
<div class="info-item">
|
||||
<label>Filename</label>
|
||||
<value>${filename}</value>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>Protocol</label>
|
||||
<value>${metadata.protocol || 'Unknown'}</value>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>Frequency</label>
|
||||
<value>${metadata.frequency ? (metadata.frequency / 1e6).toFixed(2) + ' MHz' : 'Unknown'}</value>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>Preset</label>
|
||||
<value>${metadata.preset || 'Unknown'}</value>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>Pulses</label>
|
||||
<value>${metadata.raw_data?.length || 0}</value>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Stats
|
||||
const statsGrid = document.getElementById('statsGrid');
|
||||
const avgConfidence = matches.length > 0
|
||||
? (matches.reduce((sum, m) => sum + m.confidence, 0) / matches.length * 100).toFixed(1)
|
||||
: 0;
|
||||
|
||||
statsGrid.innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${matches.length}</div>
|
||||
<div class="stat-label">Matches Found</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${avgConfidence}%</div>
|
||||
<div class="stat-label">Avg Confidence</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${matches.length > 0 ? (matches[0].confidence * 100).toFixed(1) + '%' : 'N/A'}</div>
|
||||
<div class="stat-label">Best Match</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Match count badge
|
||||
document.getElementById('matchCount').textContent = `${matches.length} match${matches.length !== 1 ? 'es' : ''}`;
|
||||
|
||||
// Matches
|
||||
const matchesContainer = document.getElementById('matchesContainer');
|
||||
if (matches.length === 0) {
|
||||
matchesContainer.innerHTML = `
|
||||
<div class="no-matches">
|
||||
<div class="no-matches-icon">🔍</div>
|
||||
<p>No device matches found</p>
|
||||
<p style="margin-top: 10px; font-size: 0.9em;">Try adjusting the minimum confidence threshold or check the debug output below.</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
matchesContainer.innerHTML = matches.map((match, index) => {
|
||||
const confidence = (match.confidence * 100).toFixed(1);
|
||||
const confidenceClass = confidence >= 70 ? 'confidence-high' :
|
||||
confidence >= 50 ? 'confidence-medium' : 'confidence-low';
|
||||
|
||||
const details = Object.entries(match.matchDetails || {})
|
||||
.map(([key, value]) => `
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">${key.replace(/_/g, ' ')}</div>
|
||||
<div class="detail-value">${value}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<div class="match-card">
|
||||
<div class="match-header">
|
||||
<div>
|
||||
<div class="device-name">${index + 1}. ${match.deviceName}</div>
|
||||
<div class="manufacturer">${match.manufacturer}</div>
|
||||
</div>
|
||||
<div class="confidence-badge ${confidenceClass}">
|
||||
${confidence}%
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<span style="background: #e3e9f3; padding: 4px 10px; border-radius: 12px; font-size: 0.85em; color: #667eea;">
|
||||
${match.matchMethod}
|
||||
</span>
|
||||
</div>
|
||||
${details ? `<div class="match-details">${details}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Debug output
|
||||
document.getElementById('debugOutput').textContent = debugLog.join('\n');
|
||||
}
|
||||
|
||||
// Allow reset
|
||||
document.getElementById('resultsSection').addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('btn')) {
|
||||
uploadSection.style.display = 'block';
|
||||
resultsSection.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* GigLez Pattern Decoder - Main Entry Point
|
||||
* JavaScript RF Signal Decoder for Sub-GHz Devices
|
||||
*
|
||||
* Export all decoder modules for browser and Node.js usage
|
||||
*
|
||||
* @module giglez-decoder
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Protocol Database
|
||||
// ============================================================================
|
||||
export {
|
||||
ProtocolSignature,
|
||||
ProtocolDatabase,
|
||||
WEATHER_SENSORS,
|
||||
GARAGE_DOOR_OPENERS,
|
||||
DOORBELLS,
|
||||
TIRE_PRESSURE,
|
||||
SECURITY_SENSORS,
|
||||
REMOTE_CONTROLS,
|
||||
ALL_PROTOCOLS,
|
||||
protocolDb
|
||||
} from './protocol-database.js';
|
||||
|
||||
// ============================================================================
|
||||
// Pulse Analysis
|
||||
// ============================================================================
|
||||
export {
|
||||
kMeans,
|
||||
identifyPulseWidths,
|
||||
decodeToBits,
|
||||
analyzePulseTrain,
|
||||
validatePulseData,
|
||||
estimateSNR
|
||||
} from './pulse-analyzer.js';
|
||||
|
||||
// ============================================================================
|
||||
// Statistical Fingerprinting
|
||||
// ============================================================================
|
||||
export {
|
||||
PulseFingerprint,
|
||||
extractFingerprint,
|
||||
calculateSimilarity,
|
||||
compareToProtocol,
|
||||
classifySignal,
|
||||
generateReport
|
||||
} from './fingerprint.js';
|
||||
|
||||
// ============================================================================
|
||||
// Pattern Decoder (Main)
|
||||
// ============================================================================
|
||||
export {
|
||||
DeviceMatch,
|
||||
PatternDecoder,
|
||||
decode
|
||||
} from './pattern-decoder.js';
|
||||
|
||||
// ============================================================================
|
||||
// Convenience API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Quick decode function - analyze .sub file and return device matches
|
||||
*
|
||||
* @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
|
||||
* @param {number[]} metadata.raw_data - Pulse timing array
|
||||
* @param {Object} options - Decoder options
|
||||
* @param {number} options.minConfidence - Minimum confidence threshold (default: 0.4)
|
||||
* @param {number} options.maxResults - Maximum results to return (default: 10)
|
||||
* @param {boolean} options.debug - Enable debug logging (default: false)
|
||||
* @returns {Promise<Array>} Array of DeviceMatch objects
|
||||
*
|
||||
* @example
|
||||
* import { quickDecode } from './decoder/index.js';
|
||||
*
|
||||
* const metadata = {
|
||||
* frequency: 433920000,
|
||||
* preset: 'FuriHalSubGhzPresetOok270Async',
|
||||
* protocol: 'RAW',
|
||||
* raw_data: [2980, -240, 520, -980, 520, -980, ...]
|
||||
* };
|
||||
*
|
||||
* const matches = await quickDecode(metadata, { debug: true });
|
||||
* console.log(matches[0].toString());
|
||||
*/
|
||||
export async function quickDecode(metadata, options = {}) {
|
||||
const { PatternDecoder } = await import('./pattern-decoder.js');
|
||||
const decoder = new PatternDecoder(options);
|
||||
return decoder.decode(metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Flipper Zero .sub file format
|
||||
*
|
||||
* @param {string} content - Raw .sub file content
|
||||
* @returns {Object} Parsed metadata
|
||||
*
|
||||
* @example
|
||||
* const content = await fetch('capture.sub').then(r => r.text());
|
||||
* const metadata = parseSubFile(content);
|
||||
* const matches = await quickDecode(metadata);
|
||||
*/
|
||||
export function parseSubFile(content) {
|
||||
const lines = content.split('\n');
|
||||
const metadata = {
|
||||
filetype: null,
|
||||
version: null,
|
||||
frequency: null,
|
||||
preset: null,
|
||||
protocol: null,
|
||||
bit: null,
|
||||
key: null,
|
||||
te: null,
|
||||
raw_data: []
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
if (trimmed.includes(':')) {
|
||||
const [key, value] = trimmed.split(':', 2).map(s => s.trim());
|
||||
|
||||
switch (key) {
|
||||
case 'Filetype':
|
||||
metadata.filetype = value;
|
||||
break;
|
||||
case 'Version':
|
||||
metadata.version = parseInt(value);
|
||||
break;
|
||||
case 'Frequency':
|
||||
metadata.frequency = parseInt(value);
|
||||
break;
|
||||
case 'Preset':
|
||||
metadata.preset = value;
|
||||
break;
|
||||
case 'Protocol':
|
||||
metadata.protocol = value;
|
||||
break;
|
||||
case 'Bit':
|
||||
metadata.bit = parseInt(value);
|
||||
break;
|
||||
case 'Key':
|
||||
metadata.key = value;
|
||||
break;
|
||||
case 'TE':
|
||||
metadata.te = parseInt(value);
|
||||
break;
|
||||
case 'RAW_Data':
|
||||
// Parse pulse timing array
|
||||
const pulses = value.split(/\s+/)
|
||||
.map(s => parseInt(s))
|
||||
.filter(n => !isNaN(n));
|
||||
metadata.raw_data.push(...pulses);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode .sub file from URL
|
||||
*
|
||||
* @param {string} url - URL to .sub file
|
||||
* @param {Object} options - Decoder options
|
||||
* @returns {Promise<Array>} Device matches
|
||||
*
|
||||
* @example
|
||||
* const matches = await decodeFromURL('https://example.com/capture.sub');
|
||||
* console.log(`Found ${matches.length} potential devices`);
|
||||
*/
|
||||
export async function decodeFromURL(url, options = {}) {
|
||||
const response = await fetch(url);
|
||||
const content = await response.text();
|
||||
const metadata = parseSubFile(content);
|
||||
return quickDecode(metadata, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode .sub file from File object (browser)
|
||||
*
|
||||
* @param {File} file - File object from file input
|
||||
* @param {Object} options - Decoder options
|
||||
* @returns {Promise<Array>} Device matches
|
||||
*
|
||||
* @example
|
||||
* document.getElementById('fileInput').addEventListener('change', async (e) => {
|
||||
* const file = e.target.files[0];
|
||||
* const matches = await decodeFromFile(file, { debug: true });
|
||||
* console.log(matches);
|
||||
* });
|
||||
*/
|
||||
export async function decodeFromFile(file, options = {}) {
|
||||
const content = await file.text();
|
||||
const metadata = parseSubFile(content);
|
||||
return quickDecode(metadata, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get decoder version information
|
||||
* @returns {Object} Version info
|
||||
*/
|
||||
export async function getVersion() {
|
||||
const { ALL_PROTOCOLS } = await import('./protocol-database.js');
|
||||
return {
|
||||
version: '1.0.0',
|
||||
name: 'GigLez Pattern Decoder',
|
||||
description: 'JavaScript RF Signal Decoder for Sub-GHz Devices',
|
||||
protocols: ALL_PROTOCOLS.length,
|
||||
features: [
|
||||
'K-means pulse width clustering',
|
||||
'Statistical fingerprinting',
|
||||
'Protocol library matching',
|
||||
'18 built-in protocols',
|
||||
'Single-transmission decoding',
|
||||
'Browser and Node.js compatible'
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* GigLez RF Protocol Database
|
||||
* JavaScript port of Python protocol_database.py
|
||||
*
|
||||
* Contains timing signatures for 18 known RF protocols
|
||||
* @module protocol-database
|
||||
*/
|
||||
|
||||
export class ProtocolSignature {
|
||||
constructor({
|
||||
name,
|
||||
category,
|
||||
manufacturer = null,
|
||||
model = null,
|
||||
frequency = 433920000,
|
||||
frequencyTolerance = 100000,
|
||||
shortPulseUs = 500,
|
||||
longPulseUs = 1000,
|
||||
timingTolerance = 0.2,
|
||||
preamblePattern = null,
|
||||
syncPattern = null,
|
||||
minBits = 24,
|
||||
maxBits = 64,
|
||||
typicalPulseCount = 100,
|
||||
minConfidence = 0.6
|
||||
}) {
|
||||
this.name = name;
|
||||
this.category = category;
|
||||
this.manufacturer = manufacturer;
|
||||
this.model = model;
|
||||
this.frequency = frequency;
|
||||
this.frequencyTolerance = frequencyTolerance;
|
||||
this.shortPulseUs = shortPulseUs;
|
||||
this.longPulseUs = longPulseUs;
|
||||
this.timingTolerance = timingTolerance;
|
||||
this.preamblePattern = preamblePattern;
|
||||
this.syncPattern = syncPattern;
|
||||
this.minBits = minBits;
|
||||
this.maxBits = maxBits;
|
||||
this.typicalPulseCount = typicalPulseCount;
|
||||
this.minConfidence = minConfidence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if observed timing matches this protocol
|
||||
* @param {number} shortUs - Observed short pulse width (microseconds)
|
||||
* @param {number} longUs - Observed long pulse width (microseconds)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
matchesTiming(shortUs, longUs) {
|
||||
const shortMin = this.shortPulseUs * (1 - this.timingTolerance);
|
||||
const shortMax = this.shortPulseUs * (1 + this.timingTolerance);
|
||||
const longMin = this.longPulseUs * (1 - this.timingTolerance);
|
||||
const longMax = this.longPulseUs * (1 + this.timingTolerance);
|
||||
|
||||
return (shortMin <= shortUs && shortUs <= shortMax) &&
|
||||
(longMin <= longUs && longUs <= longMax);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if frequency matches this protocol
|
||||
* @param {number} freq - Observed frequency (Hz)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
matchesFrequency(freq) {
|
||||
return Math.abs(freq - this.frequency) <= this.frequencyTolerance;
|
||||
}
|
||||
}
|
||||
|
||||
// Weather Sensor Protocols
|
||||
export const WEATHER_SENSORS = [
|
||||
new ProtocolSignature({
|
||||
name: 'Oregon Scientific v2.1',
|
||||
category: 'Weather Sensor',
|
||||
manufacturer: 'Oregon Scientific',
|
||||
shortPulseUs: 488,
|
||||
longPulseUs: 976,
|
||||
preamblePattern: '10101010101010101010101010101010', // 32-bit preamble
|
||||
syncPattern: '1000',
|
||||
minBits: 64,
|
||||
maxBits: 128,
|
||||
typicalPulseCount: 200
|
||||
}),
|
||||
new ProtocolSignature({
|
||||
name: 'Oregon Scientific v3.0',
|
||||
category: 'Weather Sensor',
|
||||
manufacturer: 'Oregon Scientific',
|
||||
shortPulseUs: 500,
|
||||
longPulseUs: 1000,
|
||||
preamblePattern: '101010101010101010101010101010101010101010101010', // 48-bit preamble
|
||||
syncPattern: '1000',
|
||||
minBits: 64,
|
||||
maxBits: 128,
|
||||
typicalPulseCount: 250
|
||||
}),
|
||||
new ProtocolSignature({
|
||||
name: 'Acurite Tower Sensor',
|
||||
category: 'Weather Sensor',
|
||||
manufacturer: 'Acurite',
|
||||
shortPulseUs: 220,
|
||||
longPulseUs: 440,
|
||||
syncPattern: '10',
|
||||
minBits: 56,
|
||||
maxBits: 64,
|
||||
typicalPulseCount: 130
|
||||
}),
|
||||
new ProtocolSignature({
|
||||
name: 'Acurite 5n1 Weather Station',
|
||||
category: 'Weather Sensor',
|
||||
manufacturer: 'Acurite',
|
||||
shortPulseUs: 220,
|
||||
longPulseUs: 440,
|
||||
minBits: 64,
|
||||
maxBits: 80,
|
||||
typicalPulseCount: 160
|
||||
}),
|
||||
new ProtocolSignature({
|
||||
name: 'LaCrosse TX141TH-Bv2',
|
||||
category: 'Weather Sensor',
|
||||
manufacturer: 'LaCrosse',
|
||||
shortPulseUs: 500,
|
||||
longPulseUs: 1000,
|
||||
preamblePattern: '10101010', // 8-bit preamble
|
||||
minBits: 40,
|
||||
maxBits: 48,
|
||||
typicalPulseCount: 100
|
||||
}),
|
||||
new ProtocolSignature({
|
||||
name: 'Nexus Temperature/Humidity',
|
||||
category: 'Weather Sensor',
|
||||
manufacturer: 'Nexus',
|
||||
shortPulseUs: 500,
|
||||
longPulseUs: 1000,
|
||||
preamblePattern: '11111111', // 8-bit preamble
|
||||
minBits: 36,
|
||||
maxBits: 40,
|
||||
typicalPulseCount: 90
|
||||
}),
|
||||
new ProtocolSignature({
|
||||
name: 'Ambient Weather F007TH',
|
||||
category: 'Weather Sensor',
|
||||
manufacturer: 'Ambient Weather',
|
||||
shortPulseUs: 500,
|
||||
longPulseUs: 1000,
|
||||
minBits: 64,
|
||||
maxBits: 72,
|
||||
typicalPulseCount: 150
|
||||
})
|
||||
];
|
||||
|
||||
// Garage Door Opener Protocols
|
||||
export const GARAGE_DOOR_OPENERS = [
|
||||
new ProtocolSignature({
|
||||
name: 'Princeton',
|
||||
category: 'Garage Door Opener',
|
||||
manufacturer: null,
|
||||
shortPulseUs: 400,
|
||||
longPulseUs: 1200,
|
||||
preamblePattern: '1111', // 4-bit preamble
|
||||
syncPattern: '10',
|
||||
minBits: 24,
|
||||
maxBits: 32,
|
||||
typicalPulseCount: 60
|
||||
}),
|
||||
new ProtocolSignature({
|
||||
name: 'Chamberlain/LiftMaster',
|
||||
category: 'Garage Door Opener',
|
||||
manufacturer: 'Chamberlain',
|
||||
shortPulseUs: 300,
|
||||
longPulseUs: 900,
|
||||
minBits: 32,
|
||||
maxBits: 40,
|
||||
typicalPulseCount: 80,
|
||||
frequency: 315000000 // 315 MHz
|
||||
}),
|
||||
new ProtocolSignature({
|
||||
name: 'Linear MegaCode',
|
||||
category: 'Garage Door Opener',
|
||||
manufacturer: 'Linear',
|
||||
shortPulseUs: 250,
|
||||
longPulseUs: 500,
|
||||
minBits: 32,
|
||||
maxBits: 32,
|
||||
typicalPulseCount: 70,
|
||||
frequency: 318000000 // 318 MHz
|
||||
})
|
||||
];
|
||||
|
||||
// Doorbell Protocols
|
||||
export const DOORBELLS = [
|
||||
new ProtocolSignature({
|
||||
name: 'Honeywell Doorbell',
|
||||
category: 'Doorbell',
|
||||
manufacturer: 'Honeywell',
|
||||
shortPulseUs: 175,
|
||||
longPulseUs: 340,
|
||||
minBits: 48,
|
||||
maxBits: 48,
|
||||
typicalPulseCount: 100
|
||||
})
|
||||
];
|
||||
|
||||
// TPMS Protocols
|
||||
export const TIRE_PRESSURE = [
|
||||
new ProtocolSignature({
|
||||
name: 'Toyota TPMS',
|
||||
category: 'TPMS',
|
||||
manufacturer: 'Toyota',
|
||||
shortPulseUs: 50,
|
||||
longPulseUs: 100,
|
||||
minBits: 64,
|
||||
maxBits: 80,
|
||||
typicalPulseCount: 160,
|
||||
frequency: 315000000 // 315 MHz
|
||||
}),
|
||||
new ProtocolSignature({
|
||||
name: 'Schrader TPMS',
|
||||
category: 'TPMS',
|
||||
manufacturer: 'Schrader',
|
||||
shortPulseUs: 50,
|
||||
longPulseUs: 100,
|
||||
minBits: 64,
|
||||
maxBits: 80,
|
||||
typicalPulseCount: 160,
|
||||
frequency: 433920000 // 433.92 MHz
|
||||
})
|
||||
];
|
||||
|
||||
// Security Sensor Protocols
|
||||
export const SECURITY_SENSORS = [
|
||||
new ProtocolSignature({
|
||||
name: 'Magellan',
|
||||
category: 'Security Sensor',
|
||||
manufacturer: 'Paradox',
|
||||
shortPulseUs: 250,
|
||||
longPulseUs: 500,
|
||||
minBits: 32,
|
||||
maxBits: 48,
|
||||
typicalPulseCount: 80,
|
||||
frequency: 433920000
|
||||
})
|
||||
];
|
||||
|
||||
// Remote Control Protocols
|
||||
export const REMOTE_CONTROLS = [
|
||||
new ProtocolSignature({
|
||||
name: 'PT2262',
|
||||
category: 'Remote Control',
|
||||
manufacturer: null,
|
||||
shortPulseUs: 350,
|
||||
longPulseUs: 1050,
|
||||
preamblePattern: '1111', // 4-bit preamble
|
||||
minBits: 24,
|
||||
maxBits: 24,
|
||||
typicalPulseCount: 50
|
||||
}),
|
||||
new ProtocolSignature({
|
||||
name: 'PT2260',
|
||||
category: 'Remote Control',
|
||||
manufacturer: null,
|
||||
shortPulseUs: 300,
|
||||
longPulseUs: 900,
|
||||
minBits: 24,
|
||||
maxBits: 24,
|
||||
typicalPulseCount: 50
|
||||
}),
|
||||
new ProtocolSignature({
|
||||
name: 'EV1527',
|
||||
category: 'Remote Control',
|
||||
manufacturer: null,
|
||||
shortPulseUs: 300,
|
||||
longPulseUs: 900,
|
||||
minBits: 24,
|
||||
maxBits: 24,
|
||||
typicalPulseCount: 50
|
||||
}),
|
||||
new ProtocolSignature({
|
||||
name: 'HCS301',
|
||||
category: 'Remote Control',
|
||||
manufacturer: 'Microchip',
|
||||
shortPulseUs: 400,
|
||||
longPulseUs: 800,
|
||||
minBits: 66,
|
||||
maxBits: 66,
|
||||
typicalPulseCount: 140
|
||||
})
|
||||
];
|
||||
|
||||
// Combined protocol list
|
||||
export const ALL_PROTOCOLS = [
|
||||
...WEATHER_SENSORS,
|
||||
...GARAGE_DOOR_OPENERS,
|
||||
...DOORBELLS,
|
||||
...TIRE_PRESSURE,
|
||||
...SECURITY_SENSORS,
|
||||
...REMOTE_CONTROLS
|
||||
];
|
||||
|
||||
/**
|
||||
* Protocol Database class for querying and indexing protocols
|
||||
*/
|
||||
export class ProtocolDatabase {
|
||||
constructor() {
|
||||
this.protocols = ALL_PROTOCOLS;
|
||||
this._byCategory = {};
|
||||
this._byFrequency = {};
|
||||
this._indexProtocols();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build indexes for fast lookup
|
||||
* @private
|
||||
*/
|
||||
_indexProtocols() {
|
||||
for (const proto of this.protocols) {
|
||||
// Index by category
|
||||
if (!this._byCategory[proto.category]) {
|
||||
this._byCategory[proto.category] = [];
|
||||
}
|
||||
this._byCategory[proto.category].push(proto);
|
||||
|
||||
// Index by frequency (rounded to MHz)
|
||||
const freqMHz = Math.round(proto.frequency / 1_000_000);
|
||||
if (!this._byFrequency[freqMHz]) {
|
||||
this._byFrequency[freqMHz] = [];
|
||||
}
|
||||
this._byFrequency[freqMHz].push(proto);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find protocols matching timing characteristics
|
||||
* @param {number} shortUs - Short pulse width (microseconds)
|
||||
* @param {number} longUs - Long pulse width (microseconds)
|
||||
* @param {number} [frequency] - Optional frequency filter (Hz)
|
||||
* @returns {ProtocolSignature[]}
|
||||
*/
|
||||
findByTiming(shortUs, longUs, frequency = null) {
|
||||
let candidates = this.protocols;
|
||||
|
||||
if (frequency) {
|
||||
const freqMHz = Math.round(frequency / 1_000_000);
|
||||
candidates = this._byFrequency[freqMHz] || this.protocols;
|
||||
}
|
||||
|
||||
const matches = [];
|
||||
for (const proto of candidates) {
|
||||
if (proto.matchesTiming(shortUs, longUs)) {
|
||||
if (!frequency || proto.matchesFrequency(frequency)) {
|
||||
matches.push(proto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find protocols by category
|
||||
* @param {string} category - Category name
|
||||
* @returns {ProtocolSignature[]}
|
||||
*/
|
||||
findByCategory(category) {
|
||||
return this._byCategory[category] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find protocols by frequency
|
||||
* @param {number} frequency - Frequency in Hz
|
||||
* @returns {ProtocolSignature[]}
|
||||
*/
|
||||
findByFrequency(frequency) {
|
||||
const matches = [];
|
||||
for (const proto of this.protocols) {
|
||||
if (proto.matchesFrequency(frequency)) {
|
||||
matches.push(proto);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all protocols
|
||||
* @returns {ProtocolSignature[]}
|
||||
*/
|
||||
getAll() {
|
||||
return this.protocols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database statistics
|
||||
* @returns {Object}
|
||||
*/
|
||||
getStatistics() {
|
||||
return {
|
||||
totalProtocols: this.protocols.length,
|
||||
categories: Object.keys(this._byCategory),
|
||||
byCategory: Object.fromEntries(
|
||||
Object.entries(this._byCategory).map(([cat, protos]) => [cat, protos.length])
|
||||
),
|
||||
frequencyBands: [...new Set(
|
||||
this.protocols.map(p => Math.round(p.frequency / 1_000_000))
|
||||
)].sort((a, b) => a - b)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const protocolDb = new ProtocolDatabase();
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Simple Node.js test for GigLez Pattern Decoder
|
||||
*
|
||||
* Run with: node static/js/decoder/test.js
|
||||
*/
|
||||
|
||||
import { decode, parseSubFile, getVersion } from './index.js';
|
||||
|
||||
// Test .sub file content (RAW format)
|
||||
const testSubFile = `Filetype: Flipper SubGhz RAW File
|
||||
Version: 1
|
||||
Frequency: 315000000
|
||||
Preset: FuriHalSubGhzPresetOok650Async
|
||||
Protocol: RAW
|
||||
RAW_Data: 2980 -240 520 -980 520 -980 1000 -500 480 -1020 520 -980 1000 -500
|
||||
`;
|
||||
|
||||
async function runTest() {
|
||||
console.log('='.repeat(80));
|
||||
console.log('GigLez Pattern Decoder - Node.js Test');
|
||||
console.log('='.repeat(80));
|
||||
console.log();
|
||||
|
||||
// Print version
|
||||
const version = await getVersion();
|
||||
console.log(`${version.name} v${version.version}`);
|
||||
console.log(`Protocols: ${version.protocols}`);
|
||||
console.log(`Features:`);
|
||||
version.features.forEach(f => console.log(` - ${f}`));
|
||||
console.log();
|
||||
|
||||
console.log('-'.repeat(80));
|
||||
console.log('Test 1: Parse .sub file');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
const metadata = parseSubFile(testSubFile);
|
||||
console.log('Parsed metadata:');
|
||||
console.log(` Frequency: ${metadata.frequency / 1e6} MHz`);
|
||||
console.log(` Protocol: ${metadata.protocol}`);
|
||||
console.log(` Preset: ${metadata.preset}`);
|
||||
console.log(` Pulses: ${metadata.raw_data.length}`);
|
||||
console.log(` Raw Data: ${metadata.raw_data.join(' ')}`);
|
||||
console.log();
|
||||
|
||||
console.log('-'.repeat(80));
|
||||
console.log('Test 2: Decode signal');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
const matches = await decode(metadata, { debug: true, minConfidence: 0.3 });
|
||||
|
||||
console.log();
|
||||
console.log(`Found ${matches.length} device matches:`);
|
||||
console.log();
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.log(' ❌ No matches found');
|
||||
} else {
|
||||
matches.forEach((match, i) => {
|
||||
console.log(` ${i + 1}. ${match.toString()}`);
|
||||
console.log(` Method: ${match.matchMethod}`);
|
||||
if (match.matchDetails) {
|
||||
console.log(` Details:`);
|
||||
Object.entries(match.matchDetails).forEach(([key, value]) => {
|
||||
console.log(` - ${key}: ${value}`);
|
||||
});
|
||||
}
|
||||
console.log();
|
||||
});
|
||||
}
|
||||
|
||||
console.log('-'.repeat(80));
|
||||
console.log('Test 3: Protocol database query');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
// Import protocol database directly
|
||||
const { protocolDb } = await import('./protocol-database.js');
|
||||
|
||||
console.log(`Total protocols: ${protocolDb.getAll().length}`);
|
||||
console.log();
|
||||
|
||||
const stats = protocolDb.getStatistics();
|
||||
console.log('Categories:');
|
||||
Object.entries(stats.byCategory).forEach(([cat, count]) => {
|
||||
console.log(` - ${cat}: ${count} protocols`);
|
||||
});
|
||||
console.log();
|
||||
|
||||
console.log('Frequency bands:');
|
||||
stats.frequencyBands.forEach(freq => {
|
||||
console.log(` - ${freq} MHz`);
|
||||
});
|
||||
console.log();
|
||||
|
||||
console.log('-'.repeat(80));
|
||||
console.log('Test 4: Pulse analysis');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
const { analyzePulseTrain, extractFingerprint } = await import('./pulse-analyzer.js');
|
||||
|
||||
const analysis = analyzePulseTrain(metadata.raw_data);
|
||||
console.log('Timing Analysis:');
|
||||
console.log(` Short Pulse: ${analysis.shortPulse.toFixed(1)} μs`);
|
||||
console.log(` Long Pulse: ${analysis.longPulse.toFixed(1)} μs`);
|
||||
console.log(` Bit Pattern: ${analysis.bitPattern}`);
|
||||
console.log(` Bit Count: ${analysis.bitCount}`);
|
||||
console.log();
|
||||
|
||||
const { extractFingerprint: fpExtract } = await import('./fingerprint.js');
|
||||
const fingerprint = fpExtract(metadata.raw_data);
|
||||
console.log('Statistical Fingerprint:');
|
||||
console.log(` ${fingerprint.toString()}`);
|
||||
console.log(` Duty Cycle: ${(fingerprint.dutyCycle * 100).toFixed(1)}%`);
|
||||
console.log();
|
||||
|
||||
console.log('='.repeat(80));
|
||||
console.log('✅ All tests completed!');
|
||||
console.log('='.repeat(80));
|
||||
}
|
||||
|
||||
runTest().catch(err => {
|
||||
console.error('❌ Test failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user