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