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>
581 lines
17 KiB
HTML
581 lines
17 KiB
HTML
<!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>
|