mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Add signal strength classification with confidence-safe language
Introduces standardized RSSI-to-label mapping (minimal/weak/moderate/strong/very_strong) and duration-based confidence modifiers for client-facing reports and dashboards. - New signal_classification.py module with hedged language generation - Updated detector.py to use standardized signal descriptions - Enhanced reports.py with signal classification in findings - Added JS SignalClassification and signal indicator components - CSS styles for signal strength bars and assessment panels Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1136,3 +1136,129 @@
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SIGNAL STRENGTH INDICATOR
|
||||
Classification-based signal display
|
||||
============================================ */
|
||||
.signal-strength-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.signal-strength-indicator.compact {
|
||||
padding: 2px 4px;
|
||||
gap: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.signal-strength-bars {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.signal-strength-bars rect {
|
||||
transition: fill 0.2s ease;
|
||||
}
|
||||
|
||||
.signal-strength-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* Confidence-based styling */
|
||||
.signal-confidence-low {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.signal-confidence-medium {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.signal-confidence-high {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.signal-advanced-value.signal-confidence-low {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.signal-advanced-value.signal-confidence-medium {
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.signal-advanced-value.signal-confidence-high {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SIGNAL ASSESSMENT PANEL
|
||||
Detailed signal analysis in advanced panel
|
||||
============================================ */
|
||||
.signal-assessment {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.signal-assessment-summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.signal-assessment-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.signal-assessment-caveat {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Signal assessment confidence badges */
|
||||
.signal-assessment .signal-advanced-grid {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Range estimate styling */
|
||||
.signal-range-estimate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.signal-range-estimate .range-value {
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.signal-range-estimate .range-disclaimer {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,250 @@
|
||||
const SignalCards = (function() {
|
||||
'use strict';
|
||||
|
||||
// ==========================================================================
|
||||
// Signal Strength Classification
|
||||
// Translates RSSI values to confidence-safe, client-facing language
|
||||
// ==========================================================================
|
||||
|
||||
const SignalClassification = {
|
||||
// RSSI thresholds (dBm) - upper bounds
|
||||
THRESHOLDS: {
|
||||
MINIMAL: -85,
|
||||
WEAK: -70,
|
||||
MODERATE: -55,
|
||||
STRONG: -40
|
||||
// VERY_STRONG: > -40
|
||||
},
|
||||
|
||||
// Signal strength metadata
|
||||
STRENGTH_INFO: {
|
||||
minimal: {
|
||||
label: 'Minimal',
|
||||
description: 'At detection threshold',
|
||||
interpretation: 'may be ambient noise or distant source',
|
||||
confidence: 'low',
|
||||
color: '#888888',
|
||||
icon: 'signal-0',
|
||||
bars: 1
|
||||
},
|
||||
weak: {
|
||||
label: 'Weak',
|
||||
description: 'Detectable signal',
|
||||
interpretation: 'potentially distant or obstructed',
|
||||
confidence: 'low',
|
||||
color: '#6baed6',
|
||||
icon: 'signal-1',
|
||||
bars: 2
|
||||
},
|
||||
moderate: {
|
||||
label: 'Moderate',
|
||||
description: 'Consistent presence',
|
||||
interpretation: 'likely in proximity',
|
||||
confidence: 'medium',
|
||||
color: '#3182bd',
|
||||
icon: 'signal-2',
|
||||
bars: 3
|
||||
},
|
||||
strong: {
|
||||
label: 'Strong',
|
||||
description: 'Clear signal',
|
||||
interpretation: 'probable close proximity',
|
||||
confidence: 'medium',
|
||||
color: '#fd8d3c',
|
||||
icon: 'signal-3',
|
||||
bars: 4
|
||||
},
|
||||
very_strong: {
|
||||
label: 'Very Strong',
|
||||
description: 'High signal level',
|
||||
interpretation: 'indicates likely nearby source',
|
||||
confidence: 'high',
|
||||
color: '#e6550d',
|
||||
icon: 'signal-4',
|
||||
bars: 5
|
||||
}
|
||||
},
|
||||
|
||||
// Duration thresholds (seconds)
|
||||
DURATION_THRESHOLDS: {
|
||||
TRANSIENT: 5,
|
||||
SHORT: 30,
|
||||
SUSTAINED: 120
|
||||
// PERSISTENT: > 120
|
||||
},
|
||||
|
||||
DURATION_INFO: {
|
||||
transient: {
|
||||
label: 'Transient',
|
||||
modifier: 'briefly observed',
|
||||
confidence_impact: 'reduces confidence'
|
||||
},
|
||||
short: {
|
||||
label: 'Short-duration',
|
||||
modifier: 'observed for a short period',
|
||||
confidence_impact: 'limited confidence'
|
||||
},
|
||||
sustained: {
|
||||
label: 'Sustained',
|
||||
modifier: 'observed over sustained period',
|
||||
confidence_impact: 'supports confidence'
|
||||
},
|
||||
persistent: {
|
||||
label: 'Persistent',
|
||||
modifier: 'continuously observed',
|
||||
confidence_impact: 'increases confidence'
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Classify RSSI value into qualitative signal strength
|
||||
*/
|
||||
classifyStrength(rssi) {
|
||||
if (rssi === null || rssi === undefined || isNaN(rssi)) {
|
||||
return 'minimal';
|
||||
}
|
||||
const val = parseFloat(rssi);
|
||||
if (val <= -85) return 'minimal';
|
||||
if (val <= -70) return 'weak';
|
||||
if (val <= -55) return 'moderate';
|
||||
if (val <= -40) return 'strong';
|
||||
return 'very_strong';
|
||||
},
|
||||
|
||||
/**
|
||||
* Classify detection duration
|
||||
*/
|
||||
classifyDuration(seconds) {
|
||||
if (seconds === null || seconds === undefined || seconds < 0) {
|
||||
return 'transient';
|
||||
}
|
||||
const val = parseFloat(seconds);
|
||||
if (val < 5) return 'transient';
|
||||
if (val < 30) return 'short';
|
||||
if (val < 120) return 'sustained';
|
||||
return 'persistent';
|
||||
},
|
||||
|
||||
/**
|
||||
* Get full signal strength info
|
||||
*/
|
||||
getStrengthInfo(rssi) {
|
||||
const strength = this.classifyStrength(rssi);
|
||||
return {
|
||||
strength,
|
||||
rssi,
|
||||
...this.STRENGTH_INFO[strength]
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get full duration info
|
||||
*/
|
||||
getDurationInfo(seconds) {
|
||||
const duration = this.classifyDuration(seconds);
|
||||
return {
|
||||
duration,
|
||||
seconds,
|
||||
...this.DURATION_INFO[duration]
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate overall confidence from signal + duration + observations
|
||||
*/
|
||||
calculateConfidence(rssi, durationSeconds, observationCount = 1) {
|
||||
let score = 0;
|
||||
const strength = this.classifyStrength(rssi);
|
||||
const duration = this.classifyDuration(durationSeconds);
|
||||
|
||||
// Signal strength contribution
|
||||
if (strength === 'strong' || strength === 'very_strong') score += 2;
|
||||
else if (strength === 'moderate') score += 1;
|
||||
|
||||
// Duration contribution
|
||||
if (duration === 'persistent') score += 2;
|
||||
else if (duration === 'sustained') score += 1;
|
||||
|
||||
// Observation count contribution
|
||||
if (observationCount >= 5) score += 2;
|
||||
else if (observationCount >= 3) score += 1;
|
||||
|
||||
// Map to confidence level
|
||||
if (score >= 5) return 'high';
|
||||
if (score >= 3) return 'medium';
|
||||
return 'low';
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate hedged summary statement
|
||||
*/
|
||||
generateSummary(rssi, durationSeconds, observationCount = 1) {
|
||||
const strengthInfo = this.getStrengthInfo(rssi);
|
||||
const durationInfo = this.getDurationInfo(durationSeconds);
|
||||
const confidence = this.calculateConfidence(rssi, durationSeconds, observationCount);
|
||||
|
||||
if (confidence === 'high') {
|
||||
return `${strengthInfo.label}, ${durationInfo.label.toLowerCase()} signal with characteristics that suggest device presence in proximity`;
|
||||
} else if (confidence === 'medium') {
|
||||
return `${strengthInfo.label}, ${durationInfo.label.toLowerCase()} signal that may indicate device activity`;
|
||||
} else {
|
||||
return `${durationInfo.modifier.charAt(0).toUpperCase() + durationInfo.modifier.slice(1)} ${strengthInfo.label.toLowerCase()} signal consistent with possible device presence`;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate interpretation with hedging
|
||||
*/
|
||||
generateInterpretation(rssi, durationSeconds, observationCount = 1) {
|
||||
const strengthInfo = this.getStrengthInfo(rssi);
|
||||
const confidence = this.calculateConfidence(rssi, durationSeconds, observationCount);
|
||||
const base = strengthInfo.interpretation;
|
||||
|
||||
if (confidence === 'high') {
|
||||
return `Observed signal characteristics suggest ${base}`;
|
||||
} else if (confidence === 'medium') {
|
||||
return `Signal pattern may indicate ${base}`;
|
||||
} else {
|
||||
return `Limited data; signal could represent ${base} or environmental factors`;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Estimate range from RSSI (with heavy caveats)
|
||||
*/
|
||||
estimateRange(rssi) {
|
||||
if (rssi === null || rssi === undefined) {
|
||||
return { estimate: 'Unknown', disclaimer: 'Insufficient signal data' };
|
||||
}
|
||||
const val = parseFloat(rssi);
|
||||
let estimate, rangeMin, rangeMax;
|
||||
|
||||
if (val > -40) {
|
||||
estimate = '< 3 meters';
|
||||
rangeMin = 0; rangeMax = 3;
|
||||
} else if (val > -55) {
|
||||
estimate = '3-10 meters';
|
||||
rangeMin = 3; rangeMax = 10;
|
||||
} else if (val > -70) {
|
||||
estimate = '5-20 meters';
|
||||
rangeMin = 5; rangeMax = 20;
|
||||
} else if (val > -85) {
|
||||
estimate = '10-50 meters';
|
||||
rangeMin = 10; rangeMax = 50;
|
||||
} else {
|
||||
estimate = '> 30 meters or heavily obstructed';
|
||||
rangeMin = 30; rangeMax = null;
|
||||
}
|
||||
|
||||
return {
|
||||
estimate,
|
||||
rangeMin,
|
||||
rangeMax,
|
||||
disclaimer: 'Range estimates are approximate and affected by walls, interference, and transmit power'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Address tracking for new/repeated detection
|
||||
const addressHistory = {
|
||||
pager: new Map(), // address -> { count, firstSeen, lastSeen }
|
||||
@@ -211,6 +455,107 @@ const SignalCards = (function() {
|
||||
return /^[0-9\s\-\*\#U]+$/.test(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create signal strength indicator HTML
|
||||
* Shows bars + label + optional tooltip with interpretation
|
||||
*/
|
||||
function createSignalIndicator(rssi, options = {}) {
|
||||
if (rssi === null || rssi === undefined) return '';
|
||||
|
||||
const info = SignalClassification.getStrengthInfo(rssi);
|
||||
const showLabel = options.showLabel !== false;
|
||||
const showTooltip = options.showTooltip !== false;
|
||||
const compact = options.compact === true;
|
||||
|
||||
// Create signal bars SVG
|
||||
const bars = info.bars;
|
||||
const barsSvg = `
|
||||
<svg class="signal-strength-bars" viewBox="0 0 20 16" width="${compact ? 16 : 20}" height="${compact ? 12 : 16}">
|
||||
<rect x="0" y="12" width="3" height="4" fill="${bars >= 1 ? info.color : '#444'}"/>
|
||||
<rect x="4" y="9" width="3" height="7" fill="${bars >= 2 ? info.color : '#444'}"/>
|
||||
<rect x="8" y="6" width="3" height="10" fill="${bars >= 3 ? info.color : '#444'}"/>
|
||||
<rect x="12" y="3" width="3" height="13" fill="${bars >= 4 ? info.color : '#444'}"/>
|
||||
<rect x="16" y="0" width="3" height="16" fill="${bars >= 5 ? info.color : '#444'}"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// Build tooltip content
|
||||
let tooltipContent = '';
|
||||
if (showTooltip) {
|
||||
const rangeEst = SignalClassification.estimateRange(rssi);
|
||||
tooltipContent = `
|
||||
${info.label} signal (${rssi} dBm)
|
||||
${info.description}
|
||||
Est. range: ${rangeEst.estimate}
|
||||
Confidence: ${info.confidence}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
// Determine CSS class based on confidence
|
||||
const confidenceClass = `signal-confidence-${info.confidence}`;
|
||||
|
||||
if (compact) {
|
||||
return `
|
||||
<span class="signal-strength-indicator compact ${confidenceClass}"
|
||||
${showTooltip ? `title="${escapeHtml(tooltipContent)}"` : ''}>
|
||||
${barsSvg}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<span class="signal-strength-indicator ${confidenceClass}"
|
||||
${showTooltip ? `title="${escapeHtml(tooltipContent)}"` : ''}>
|
||||
${barsSvg}
|
||||
${showLabel ? `<span class="signal-strength-label" style="color: ${info.color}">${info.label}</span>` : ''}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create detailed signal assessment panel for advanced details
|
||||
*/
|
||||
function createSignalAssessmentPanel(rssi, durationSeconds, observationCount = 1) {
|
||||
if (rssi === null || rssi === undefined) return '';
|
||||
|
||||
const strengthInfo = SignalClassification.getStrengthInfo(rssi);
|
||||
const durationInfo = SignalClassification.getDurationInfo(durationSeconds);
|
||||
const confidence = SignalClassification.calculateConfidence(rssi, durationSeconds, observationCount);
|
||||
const rangeEst = SignalClassification.estimateRange(rssi);
|
||||
const interpretation = SignalClassification.generateInterpretation(rssi, durationSeconds, observationCount);
|
||||
|
||||
return `
|
||||
<div class="signal-advanced-section signal-assessment">
|
||||
<div class="signal-advanced-title">Signal Assessment</div>
|
||||
<div class="signal-assessment-summary">
|
||||
${createSignalIndicator(rssi, { compact: false, showTooltip: false })}
|
||||
<span class="signal-assessment-text">${escapeHtml(interpretation)}</span>
|
||||
</div>
|
||||
<div class="signal-advanced-grid">
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Signal Strength</span>
|
||||
<span class="signal-advanced-value">${strengthInfo.label} (${rssi} dBm)</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Detection</span>
|
||||
<span class="signal-advanced-value">${durationInfo.label}</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Est. Range</span>
|
||||
<span class="signal-advanced-value">${rangeEst.estimate}</span>
|
||||
</div>
|
||||
<div class="signal-advanced-item">
|
||||
<span class="signal-advanced-label">Confidence</span>
|
||||
<span class="signal-advanced-value signal-confidence-${confidence}">${confidence.charAt(0).toUpperCase() + confidence.slice(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-assessment-caveat">
|
||||
Note: ${rangeEst.disclaimer}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message type label
|
||||
*/
|
||||
@@ -482,6 +827,10 @@ const SignalCards = (function() {
|
||||
const stats = getAddressStats('sensor', msg.id);
|
||||
const seenCount = stats ? stats.count : 1;
|
||||
|
||||
// Get signal strength if available
|
||||
const rssi = msg.rssi || msg.signal_strength || msg.snr || null;
|
||||
const signalIndicator = createSignalIndicator(rssi, { compact: true });
|
||||
|
||||
// Determine sensor type icon
|
||||
let sensorIcon = '📡';
|
||||
const model = (msg.model || '').toLowerCase();
|
||||
@@ -497,6 +846,7 @@ const SignalCards = (function() {
|
||||
<div class="signal-card-badges">
|
||||
<span class="signal-proto-badge sensor">${sensorIcon} ${escapeHtml(msg.model || 'Unknown')}</span>
|
||||
<span class="signal-freq-badge">ID: ${escapeHtml(msg.id || 'N/A')}</span>
|
||||
${signalIndicator}
|
||||
</div>
|
||||
${status !== 'baseline' ? `
|
||||
<span class="signal-status-pill" data-status="${status}">
|
||||
@@ -570,6 +920,7 @@ const SignalCards = (function() {
|
||||
<div class="signal-advanced-panel">
|
||||
<div class="signal-advanced-inner">
|
||||
<div class="signal-advanced-content">
|
||||
${rssi !== null ? createSignalAssessmentPanel(rssi, stats?.lastSeen ? (Date.now() - stats.firstSeen) / 1000 : null, seenCount) : ''}
|
||||
<div class="signal-advanced-section">
|
||||
<div class="signal-advanced-title">Sensor Details</div>
|
||||
<div class="signal-advanced-grid">
|
||||
@@ -1336,6 +1687,11 @@ const SignalCards = (function() {
|
||||
createAcarsCard,
|
||||
createMeterCard,
|
||||
|
||||
// Signal classification
|
||||
SignalClassification,
|
||||
createSignalIndicator,
|
||||
createSignalAssessmentPanel,
|
||||
|
||||
// UI interactions
|
||||
toggleAdvanced,
|
||||
copyMessage,
|
||||
|
||||
@@ -20,6 +20,11 @@ from data.tscm_frequencies import (
|
||||
is_known_tracker,
|
||||
is_potential_camera,
|
||||
)
|
||||
from utils.tscm.signal_classification import (
|
||||
classify_signal_strength,
|
||||
get_signal_strength_info,
|
||||
SignalStrength,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.tscm.detector')
|
||||
|
||||
@@ -171,8 +176,11 @@ class ThreatDetector:
|
||||
signal_val = int(signal) if signal else -100
|
||||
except (ValueError, TypeError):
|
||||
signal_val = -100
|
||||
if not ssid and signal and signal_val > -60:
|
||||
reasons.append('Hidden SSID with strong signal')
|
||||
|
||||
# Use standardized signal classification
|
||||
signal_info = get_signal_strength_info(signal_val)
|
||||
if not ssid and signal_info['strength'] in ('strong', 'very_strong'):
|
||||
reasons.append(f"Hidden SSID with {signal_info['label'].lower()} signal")
|
||||
classification = 'high_interest'
|
||||
|
||||
# Repeat detections across scans
|
||||
@@ -181,11 +189,17 @@ class ThreatDetector:
|
||||
if classification != 'high_interest':
|
||||
classification = 'high_interest'
|
||||
|
||||
# Include standardized signal classification
|
||||
signal_info = get_signal_strength_info(signal_val)
|
||||
|
||||
return {
|
||||
'classification': classification,
|
||||
'reasons': reasons,
|
||||
'in_baseline': in_baseline,
|
||||
'times_seen': times_seen,
|
||||
'signal_strength': signal_info['strength'],
|
||||
'signal_label': signal_info['label'],
|
||||
'signal_confidence': signal_info['confidence'],
|
||||
}
|
||||
|
||||
def classify_bt_device(self, device: dict) -> dict:
|
||||
@@ -236,13 +250,15 @@ class ThreatDetector:
|
||||
reasons.append('Audio-capable BLE device')
|
||||
classification = 'high_interest'
|
||||
|
||||
# Strong signal from unknown device
|
||||
# Strong signal from unknown device - use standardized classification
|
||||
try:
|
||||
rssi_val = int(rssi) if rssi else -100
|
||||
except (ValueError, TypeError):
|
||||
rssi_val = -100
|
||||
if rssi and rssi_val > -50 and not name:
|
||||
reasons.append('Strong signal from unnamed device')
|
||||
|
||||
signal_info = get_signal_strength_info(rssi_val)
|
||||
if signal_info['strength'] in ('strong', 'very_strong') and not name:
|
||||
reasons.append(f"{signal_info['label']} signal from unnamed device")
|
||||
classification = 'high_interest'
|
||||
|
||||
# Repeat detections across scans
|
||||
@@ -251,6 +267,9 @@ class ThreatDetector:
|
||||
if classification != 'high_interest':
|
||||
classification = 'high_interest'
|
||||
|
||||
# Include standardized signal classification
|
||||
signal_info = get_signal_strength_info(rssi_val)
|
||||
|
||||
return {
|
||||
'classification': classification,
|
||||
'reasons': reasons,
|
||||
@@ -258,6 +277,9 @@ class ThreatDetector:
|
||||
'times_seen': times_seen,
|
||||
'is_tracker': tracker_info is not None,
|
||||
'is_audio_capable': _is_audio_capable_ble(name, device_type),
|
||||
'signal_strength': signal_info['strength'],
|
||||
'signal_label': signal_info['label'],
|
||||
'signal_confidence': signal_info['confidence'],
|
||||
}
|
||||
|
||||
def classify_rf_signal(self, signal: dict) -> dict:
|
||||
@@ -301,16 +323,25 @@ class ThreatDetector:
|
||||
reasons.append(f'High-risk surveillance band: {band_name}')
|
||||
classification = 'high_interest'
|
||||
|
||||
# Strong persistent signal
|
||||
if power and float(power) > -40:
|
||||
reasons.append('Strong persistent transmitter')
|
||||
classification = 'high_interest'
|
||||
# Strong persistent signal - use standardized classification
|
||||
if power:
|
||||
power_info = get_signal_strength_info(float(power))
|
||||
if power_info['strength'] in ('strong', 'very_strong'):
|
||||
reasons.append(f"{power_info['label']} persistent transmitter")
|
||||
classification = 'high_interest'
|
||||
|
||||
# Repeat detections (persistent transmitter)
|
||||
if times_seen >= 2:
|
||||
reasons.append(f'Persistent transmitter ({times_seen} detections)')
|
||||
classification = 'high_interest'
|
||||
|
||||
# Include standardized signal classification
|
||||
try:
|
||||
power_val = float(power) if power else -100
|
||||
except (ValueError, TypeError):
|
||||
power_val = -100
|
||||
signal_info = get_signal_strength_info(power_val)
|
||||
|
||||
return {
|
||||
'classification': classification,
|
||||
'reasons': reasons,
|
||||
@@ -318,6 +349,9 @@ class ThreatDetector:
|
||||
'times_seen': times_seen,
|
||||
'risk_level': risk,
|
||||
'band_name': band_name,
|
||||
'signal_strength': signal_info['strength'],
|
||||
'signal_label': signal_info['label'],
|
||||
'signal_confidence': signal_info['confidence'],
|
||||
}
|
||||
|
||||
def analyze_wifi_device(self, device: dict) -> dict | None:
|
||||
@@ -353,16 +387,18 @@ class ThreatDetector:
|
||||
'reason': 'Device matches WiFi camera patterns',
|
||||
})
|
||||
|
||||
# Check for hidden SSID with strong signal
|
||||
# Check for hidden SSID with strong signal - use standardized classification
|
||||
try:
|
||||
signal_int = int(signal) if signal else -100
|
||||
except (ValueError, TypeError):
|
||||
signal_int = -100
|
||||
if not ssid and signal and signal_int > -60:
|
||||
|
||||
signal_info = get_signal_strength_info(signal_int)
|
||||
if not ssid and signal_info['strength'] in ('strong', 'very_strong'):
|
||||
threats.append({
|
||||
'type': 'anomaly',
|
||||
'severity': 'medium',
|
||||
'reason': 'Hidden SSID with strong signal',
|
||||
'reason': f"Hidden SSID with {signal_info['label'].lower()} signal",
|
||||
})
|
||||
|
||||
if not threats:
|
||||
@@ -422,16 +458,18 @@ class ThreatDetector:
|
||||
'tracker_type': tracker_info.get('name'),
|
||||
})
|
||||
|
||||
# Check for suspicious BLE beacons (unnamed, persistent)
|
||||
# Check for suspicious BLE beacons (unnamed, persistent) - use standardized classification
|
||||
try:
|
||||
rssi_int = int(rssi) if rssi else -100
|
||||
except (ValueError, TypeError):
|
||||
rssi_int = -100
|
||||
if not name and rssi and rssi_int > -70:
|
||||
|
||||
signal_info = get_signal_strength_info(rssi_int)
|
||||
if not name and signal_info['strength'] in ('moderate', 'strong', 'very_strong'):
|
||||
threats.append({
|
||||
'type': 'anomaly',
|
||||
'severity': 'medium',
|
||||
'reason': 'Unnamed BLE device with strong signal',
|
||||
'reason': f"Unnamed BLE device with {signal_info['label'].lower()} signal",
|
||||
})
|
||||
|
||||
if not threats:
|
||||
|
||||
@@ -19,6 +19,17 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from utils.tscm.signal_classification import (
|
||||
SignalStrength,
|
||||
ConfidenceLevel,
|
||||
assess_signal,
|
||||
classify_signal_strength,
|
||||
describe_signal_for_report,
|
||||
format_signal_for_dashboard,
|
||||
generate_hedged_statement,
|
||||
SIGNAL_ANALYSIS_DISCLAIMER,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.tscm.reports')
|
||||
|
||||
# =============================================================================
|
||||
@@ -37,6 +48,11 @@ class ReportFinding:
|
||||
indicators: list[dict] = field(default_factory=list)
|
||||
recommended_action: str = ''
|
||||
playbook_reference: str = ''
|
||||
# Signal classification data
|
||||
signal_strength: Optional[str] = None # minimal, weak, moderate, strong, very_strong
|
||||
signal_confidence: Optional[str] = None # low, medium, high
|
||||
signal_interpretation: Optional[str] = None
|
||||
signal_caveats: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -225,7 +241,7 @@ def generate_executive_summary(report: TSCMReport) -> str:
|
||||
|
||||
|
||||
def generate_findings_section(findings: list[ReportFinding], title: str) -> str:
|
||||
"""Generate a findings section for the report."""
|
||||
"""Generate a findings section for the report with confidence-safe language."""
|
||||
if not findings:
|
||||
return f"{title}\n\nNo findings in this category.\n"
|
||||
|
||||
@@ -236,14 +252,33 @@ def generate_findings_section(findings: list[ReportFinding], title: str) -> str:
|
||||
lines.append(f" Protocol: {finding.protocol.upper()}")
|
||||
lines.append(f" Identifier: {finding.identifier}")
|
||||
lines.append(f" Risk Score: {finding.risk_score}")
|
||||
lines.append(f" Description: {finding.description}")
|
||||
|
||||
# Signal classification with confidence
|
||||
if finding.signal_strength:
|
||||
confidence_label = (finding.signal_confidence or 'low').capitalize()
|
||||
strength_label = finding.signal_strength.replace('_', ' ').title()
|
||||
lines.append(f" Signal: {strength_label} (Confidence: {confidence_label})")
|
||||
|
||||
lines.append(f" Assessment: {finding.description}")
|
||||
|
||||
# Interpretation with hedged language
|
||||
if finding.signal_interpretation:
|
||||
lines.append(f" Interpretation: {finding.signal_interpretation}")
|
||||
|
||||
if finding.indicators:
|
||||
lines.append(" Indicators:")
|
||||
for ind in finding.indicators[:5]: # Limit to 5 indicators
|
||||
lines.append(f" - {ind.get('type', 'unknown')}: {ind.get('description', '')}")
|
||||
|
||||
lines.append(f" Recommended Action: {finding.recommended_action}")
|
||||
|
||||
if finding.playbook_reference:
|
||||
lines.append(f" Reference: {finding.playbook_reference}")
|
||||
|
||||
# Include relevant caveats for high-interest findings
|
||||
if finding.signal_caveats and finding.risk_level == 'high_interest':
|
||||
lines.append(" Note: " + finding.signal_caveats[0])
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -345,6 +380,13 @@ def generate_pdf_content(report: TSCMReport) -> str:
|
||||
sections.append(f" - {limit}")
|
||||
sections.append("")
|
||||
|
||||
# Signal Analysis Note
|
||||
sections.append("-" * 70)
|
||||
sections.append("SIGNAL ANALYSIS METHODOLOGY")
|
||||
sections.append("=" * 27)
|
||||
sections.append(SIGNAL_ANALYSIS_DISCLAIMER.strip())
|
||||
sections.append("")
|
||||
|
||||
# Disclaimer
|
||||
sections.append("-" * 70)
|
||||
sections.append(REPORT_DISCLAIMER)
|
||||
@@ -407,6 +449,12 @@ def generate_technical_annex_json(report: TSCMReport) -> dict:
|
||||
'description': f.description,
|
||||
'indicators': f.indicators,
|
||||
'recommended_action': f.recommended_action,
|
||||
'signal_classification': {
|
||||
'strength': f.signal_strength,
|
||||
'confidence': f.signal_confidence,
|
||||
'interpretation': f.signal_interpretation,
|
||||
'caveats': f.signal_caveats,
|
||||
},
|
||||
}
|
||||
for f in report.high_interest_findings
|
||||
],
|
||||
@@ -418,6 +466,12 @@ def generate_technical_annex_json(report: TSCMReport) -> dict:
|
||||
'risk_score': f.risk_score,
|
||||
'description': f.description,
|
||||
'indicators': f.indicators,
|
||||
'signal_classification': {
|
||||
'strength': f.signal_strength,
|
||||
'confidence': f.signal_confidence,
|
||||
'interpretation': f.signal_interpretation,
|
||||
'caveats': f.signal_caveats,
|
||||
},
|
||||
}
|
||||
for f in report.needs_review_findings
|
||||
],
|
||||
@@ -504,7 +558,11 @@ def generate_technical_annex_csv(report: TSCMReport) -> str:
|
||||
# Also add findings summary
|
||||
writer.writerow([])
|
||||
writer.writerow(['--- FINDINGS SUMMARY ---'])
|
||||
writer.writerow(['identifier', 'protocol', 'risk_level', 'risk_score', 'description', 'recommended_action'])
|
||||
writer.writerow([
|
||||
'identifier', 'protocol', 'risk_level', 'risk_score',
|
||||
'signal_strength', 'signal_confidence',
|
||||
'description', 'interpretation', 'recommended_action'
|
||||
])
|
||||
|
||||
all_findings = (
|
||||
report.high_interest_findings +
|
||||
@@ -517,7 +575,10 @@ def generate_technical_annex_csv(report: TSCMReport) -> str:
|
||||
finding.protocol,
|
||||
finding.risk_level,
|
||||
finding.risk_score,
|
||||
finding.signal_strength or '',
|
||||
finding.signal_confidence or '',
|
||||
finding.description,
|
||||
finding.signal_interpretation or '',
|
||||
finding.recommended_action,
|
||||
])
|
||||
|
||||
@@ -591,6 +652,9 @@ class TSCMReportBuilder:
|
||||
def add_findings_from_profiles(self, profiles: list[dict]) -> 'TSCMReportBuilder':
|
||||
"""Add findings from correlation engine device profiles."""
|
||||
for profile in profiles:
|
||||
# Get signal classification data
|
||||
signal_data = self._classify_finding_signal(profile)
|
||||
|
||||
finding = ReportFinding(
|
||||
identifier=profile.get('identifier', ''),
|
||||
protocol=profile.get('protocol', ''),
|
||||
@@ -601,26 +665,90 @@ class TSCMReportBuilder:
|
||||
indicators=profile.get('indicators', []),
|
||||
recommended_action=profile.get('recommended_action', 'monitor'),
|
||||
playbook_reference=self._get_playbook_reference(profile),
|
||||
signal_strength=signal_data['signal_strength'],
|
||||
signal_confidence=signal_data['signal_confidence'],
|
||||
signal_interpretation=signal_data['signal_interpretation'],
|
||||
signal_caveats=signal_data['signal_caveats'],
|
||||
)
|
||||
self.add_finding(finding)
|
||||
|
||||
return self
|
||||
|
||||
def _generate_finding_description(self, profile: dict) -> str:
|
||||
"""Generate description from profile indicators."""
|
||||
"""Generate description from profile indicators using hedged language."""
|
||||
indicators = profile.get('indicators', [])
|
||||
if not indicators:
|
||||
return f"{profile.get('protocol', 'Unknown').upper()} device detected"
|
||||
protocol = profile.get('protocol', 'Unknown').upper()
|
||||
|
||||
# Use first indicator as primary description
|
||||
# Get signal data for context
|
||||
rssi = profile.get('rssi_mean') or profile.get('rssi')
|
||||
duration = profile.get('observation_duration_seconds')
|
||||
observation_count = profile.get('observation_count', 1)
|
||||
|
||||
# Assess signal to determine confidence
|
||||
assessment = assess_signal(rssi, duration, observation_count)
|
||||
confidence = assessment.confidence
|
||||
|
||||
if not indicators:
|
||||
# Use hedged language based on confidence
|
||||
return generate_hedged_statement(
|
||||
f"Observed {protocol} signal",
|
||||
'device_presence',
|
||||
confidence
|
||||
)
|
||||
|
||||
# Build description with hedged language
|
||||
primary = indicators[0]
|
||||
desc = primary.get('description', 'Pattern detected')
|
||||
indicator_type = primary.get('type', 'pattern')
|
||||
|
||||
# Map indicator types to hedged descriptions
|
||||
if indicator_type in ('airtag_detected', 'tile_detected', 'smarttag_detected', 'known_tracker'):
|
||||
desc = generate_hedged_statement(
|
||||
f"{protocol} signal characteristics",
|
||||
'device_presence',
|
||||
confidence
|
||||
)
|
||||
desc += f" - pattern consistent with {indicator_type.replace('_', ' ')}"
|
||||
elif indicator_type == 'audio_capable':
|
||||
desc = generate_hedged_statement(
|
||||
"Device characteristics",
|
||||
'surveillance_indicator',
|
||||
confidence
|
||||
)
|
||||
desc += " - audio-capable device type identified"
|
||||
elif indicator_type in ('hidden_identity', 'hidden_ssid'):
|
||||
desc = generate_hedged_statement(
|
||||
"Network configuration",
|
||||
'surveillance_indicator',
|
||||
confidence
|
||||
)
|
||||
desc += " - concealed identity pattern observed"
|
||||
else:
|
||||
desc = generate_hedged_statement(
|
||||
f"{protocol} signal pattern",
|
||||
'device_presence',
|
||||
confidence
|
||||
)
|
||||
|
||||
if len(indicators) > 1:
|
||||
desc += f" (+{len(indicators) - 1} additional indicators)"
|
||||
|
||||
return desc
|
||||
|
||||
def _classify_finding_signal(self, profile: dict) -> dict:
|
||||
"""Extract signal classification data for a finding."""
|
||||
rssi = profile.get('rssi_mean') or profile.get('rssi')
|
||||
duration = profile.get('observation_duration_seconds')
|
||||
observation_count = profile.get('observation_count', 1)
|
||||
|
||||
assessment = assess_signal(rssi, duration, observation_count)
|
||||
|
||||
return {
|
||||
'signal_strength': assessment.signal_strength.value,
|
||||
'signal_confidence': assessment.confidence.value,
|
||||
'signal_interpretation': assessment.interpretation,
|
||||
'signal_caveats': assessment.caveats,
|
||||
}
|
||||
|
||||
def _get_playbook_reference(self, profile: dict) -> str:
|
||||
"""Get playbook reference based on profile."""
|
||||
risk_level = profile.get('risk_level', 'informational')
|
||||
@@ -761,27 +889,27 @@ def generate_report(
|
||||
# Add findings from profiles
|
||||
builder.add_findings_from_profiles(device_profiles)
|
||||
|
||||
# Statistics
|
||||
results = sweep_data.get('results', {})
|
||||
wifi_count = results.get('wifi_count')
|
||||
if wifi_count is None:
|
||||
wifi_count = len(results.get('wifi_devices', results.get('wifi', [])))
|
||||
|
||||
bt_count = results.get('bt_count')
|
||||
if bt_count is None:
|
||||
bt_count = len(results.get('bt_devices', results.get('bluetooth', [])))
|
||||
|
||||
rf_count = results.get('rf_count')
|
||||
if rf_count is None:
|
||||
rf_count = len(results.get('rf_signals', results.get('rf', [])))
|
||||
|
||||
builder.add_statistics(
|
||||
wifi=wifi_count,
|
||||
bluetooth=bt_count,
|
||||
rf=rf_count,
|
||||
new=baseline_diff.get('summary', {}).get('new_devices', 0) if baseline_diff else 0,
|
||||
missing=baseline_diff.get('summary', {}).get('missing_devices', 0) if baseline_diff else 0,
|
||||
)
|
||||
# Statistics
|
||||
results = sweep_data.get('results', {})
|
||||
wifi_count = results.get('wifi_count')
|
||||
if wifi_count is None:
|
||||
wifi_count = len(results.get('wifi_devices', results.get('wifi', [])))
|
||||
|
||||
bt_count = results.get('bt_count')
|
||||
if bt_count is None:
|
||||
bt_count = len(results.get('bt_devices', results.get('bluetooth', [])))
|
||||
|
||||
rf_count = results.get('rf_count')
|
||||
if rf_count is None:
|
||||
rf_count = len(results.get('rf_signals', results.get('rf', [])))
|
||||
|
||||
builder.add_statistics(
|
||||
wifi=wifi_count,
|
||||
bluetooth=bt_count,
|
||||
rf=rf_count,
|
||||
new=baseline_diff.get('summary', {}).get('new_devices', 0) if baseline_diff else 0,
|
||||
missing=baseline_diff.get('summary', {}).get('missing_devices', 0) if baseline_diff else 0,
|
||||
)
|
||||
|
||||
# Technical data
|
||||
builder.add_device_timelines(timelines)
|
||||
|
||||
631
utils/tscm/signal_classification.py
Normal file
631
utils/tscm/signal_classification.py
Normal file
@@ -0,0 +1,631 @@
|
||||
"""
|
||||
Signal Classification Module
|
||||
|
||||
Translates technical RF measurements (RSSI, duration) into confidence-safe,
|
||||
client-facing language suitable for reports and dashboards.
|
||||
|
||||
All outputs use hedged language that avoids absolute claims.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Signal Strength Classification
|
||||
# =============================================================================
|
||||
|
||||
class SignalStrength(Enum):
|
||||
"""Qualitative signal strength labels."""
|
||||
MINIMAL = "minimal"
|
||||
WEAK = "weak"
|
||||
MODERATE = "moderate"
|
||||
STRONG = "strong"
|
||||
VERY_STRONG = "very_strong"
|
||||
|
||||
|
||||
# RSSI thresholds (dBm) - upper bounds for each category
|
||||
RSSI_THRESHOLDS = {
|
||||
SignalStrength.MINIMAL: -85, # -100 to -85
|
||||
SignalStrength.WEAK: -70, # -84 to -70
|
||||
SignalStrength.MODERATE: -55, # -69 to -55
|
||||
SignalStrength.STRONG: -40, # -54 to -40
|
||||
SignalStrength.VERY_STRONG: 0, # > -40
|
||||
}
|
||||
|
||||
SIGNAL_STRENGTH_DESCRIPTIONS = {
|
||||
SignalStrength.MINIMAL: {
|
||||
'label': 'Minimal',
|
||||
'description': 'At detection threshold',
|
||||
'interpretation': 'may be ambient noise or distant source',
|
||||
'confidence': 'low',
|
||||
},
|
||||
SignalStrength.WEAK: {
|
||||
'label': 'Weak',
|
||||
'description': 'Detectable signal',
|
||||
'interpretation': 'potentially distant or obstructed',
|
||||
'confidence': 'low',
|
||||
},
|
||||
SignalStrength.MODERATE: {
|
||||
'label': 'Moderate',
|
||||
'description': 'Consistent presence',
|
||||
'interpretation': 'likely in proximity',
|
||||
'confidence': 'medium',
|
||||
},
|
||||
SignalStrength.STRONG: {
|
||||
'label': 'Strong',
|
||||
'description': 'Clear signal',
|
||||
'interpretation': 'probable close proximity',
|
||||
'confidence': 'medium',
|
||||
},
|
||||
SignalStrength.VERY_STRONG: {
|
||||
'label': 'Very Strong',
|
||||
'description': 'High signal level',
|
||||
'interpretation': 'indicates likely nearby source',
|
||||
'confidence': 'high',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def classify_signal_strength(rssi: float | int | None) -> SignalStrength:
|
||||
"""
|
||||
Classify RSSI value into qualitative signal strength.
|
||||
|
||||
Args:
|
||||
rssi: Signal strength in dBm (typically -100 to 0)
|
||||
|
||||
Returns:
|
||||
SignalStrength enum value
|
||||
"""
|
||||
if rssi is None:
|
||||
return SignalStrength.MINIMAL
|
||||
|
||||
try:
|
||||
rssi_val = float(rssi)
|
||||
except (ValueError, TypeError):
|
||||
return SignalStrength.MINIMAL
|
||||
|
||||
if rssi_val <= -85:
|
||||
return SignalStrength.MINIMAL
|
||||
elif rssi_val <= -70:
|
||||
return SignalStrength.WEAK
|
||||
elif rssi_val <= -55:
|
||||
return SignalStrength.MODERATE
|
||||
elif rssi_val <= -40:
|
||||
return SignalStrength.STRONG
|
||||
else:
|
||||
return SignalStrength.VERY_STRONG
|
||||
|
||||
|
||||
def get_signal_strength_info(rssi: float | int | None) -> dict:
|
||||
"""
|
||||
Get full signal strength classification with metadata.
|
||||
|
||||
Returns dict with: strength, label, description, interpretation, confidence
|
||||
"""
|
||||
strength = classify_signal_strength(rssi)
|
||||
info = SIGNAL_STRENGTH_DESCRIPTIONS[strength].copy()
|
||||
info['strength'] = strength.value
|
||||
info['rssi'] = rssi
|
||||
return info
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Detection Duration / Confidence Modifiers
|
||||
# =============================================================================
|
||||
|
||||
class DetectionDuration(Enum):
|
||||
"""Qualitative duration labels."""
|
||||
TRANSIENT = "transient"
|
||||
SHORT = "short"
|
||||
SUSTAINED = "sustained"
|
||||
PERSISTENT = "persistent"
|
||||
|
||||
|
||||
# Duration thresholds (seconds)
|
||||
DURATION_THRESHOLDS = {
|
||||
DetectionDuration.TRANSIENT: 5, # < 5 seconds
|
||||
DetectionDuration.SHORT: 30, # 5-30 seconds
|
||||
DetectionDuration.SUSTAINED: 120, # 30s - 2 min
|
||||
DetectionDuration.PERSISTENT: float('inf'), # > 2 min
|
||||
}
|
||||
|
||||
DURATION_DESCRIPTIONS = {
|
||||
DetectionDuration.TRANSIENT: {
|
||||
'label': 'Transient',
|
||||
'modifier': 'briefly observed',
|
||||
'confidence_impact': 'reduces confidence',
|
||||
},
|
||||
DetectionDuration.SHORT: {
|
||||
'label': 'Short-duration',
|
||||
'modifier': 'observed for a short period',
|
||||
'confidence_impact': 'limited confidence',
|
||||
},
|
||||
DetectionDuration.SUSTAINED: {
|
||||
'label': 'Sustained',
|
||||
'modifier': 'observed over sustained period',
|
||||
'confidence_impact': 'supports confidence',
|
||||
},
|
||||
DetectionDuration.PERSISTENT: {
|
||||
'label': 'Persistent',
|
||||
'modifier': 'continuously observed',
|
||||
'confidence_impact': 'increases confidence',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def classify_duration(seconds: float | int | None) -> DetectionDuration:
|
||||
"""
|
||||
Classify detection duration into qualitative category.
|
||||
|
||||
Args:
|
||||
seconds: Duration of detection in seconds
|
||||
|
||||
Returns:
|
||||
DetectionDuration enum value
|
||||
"""
|
||||
if seconds is None or seconds < 0:
|
||||
return DetectionDuration.TRANSIENT
|
||||
|
||||
try:
|
||||
duration = float(seconds)
|
||||
except (ValueError, TypeError):
|
||||
return DetectionDuration.TRANSIENT
|
||||
|
||||
if duration < 5:
|
||||
return DetectionDuration.TRANSIENT
|
||||
elif duration < 30:
|
||||
return DetectionDuration.SHORT
|
||||
elif duration < 120:
|
||||
return DetectionDuration.SUSTAINED
|
||||
else:
|
||||
return DetectionDuration.PERSISTENT
|
||||
|
||||
|
||||
def get_duration_info(seconds: float | int | None) -> dict:
|
||||
"""Get full duration classification with metadata."""
|
||||
duration = classify_duration(seconds)
|
||||
info = DURATION_DESCRIPTIONS[duration].copy()
|
||||
info['duration'] = duration.value
|
||||
info['seconds'] = seconds
|
||||
return info
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Combined Confidence Assessment
|
||||
# =============================================================================
|
||||
|
||||
class ConfidenceLevel(Enum):
|
||||
"""Overall detection confidence."""
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignalAssessment:
|
||||
"""Complete signal assessment with confidence-safe language."""
|
||||
rssi: Optional[float]
|
||||
duration_seconds: Optional[float]
|
||||
observation_count: int
|
||||
|
||||
signal_strength: SignalStrength
|
||||
detection_duration: DetectionDuration
|
||||
confidence: ConfidenceLevel
|
||||
|
||||
# Client-safe descriptions
|
||||
strength_label: str
|
||||
duration_label: str
|
||||
summary: str
|
||||
interpretation: str
|
||||
caveats: list[str]
|
||||
|
||||
|
||||
def assess_signal(
|
||||
rssi: float | int | None = None,
|
||||
duration_seconds: float | int | None = None,
|
||||
observation_count: int = 1,
|
||||
has_corroborating_data: bool = False,
|
||||
) -> SignalAssessment:
|
||||
"""
|
||||
Produce a complete signal assessment with confidence-safe language.
|
||||
|
||||
Args:
|
||||
rssi: Signal strength in dBm
|
||||
duration_seconds: How long signal was detected
|
||||
observation_count: Number of separate observations
|
||||
has_corroborating_data: Whether other data supports this detection
|
||||
|
||||
Returns:
|
||||
SignalAssessment with hedged, client-safe language
|
||||
"""
|
||||
strength = classify_signal_strength(rssi)
|
||||
duration = classify_duration(duration_seconds)
|
||||
|
||||
# Calculate confidence based on multiple factors
|
||||
confidence = _calculate_confidence(
|
||||
strength, duration, observation_count, has_corroborating_data
|
||||
)
|
||||
|
||||
strength_info = SIGNAL_STRENGTH_DESCRIPTIONS[strength]
|
||||
duration_info = DURATION_DESCRIPTIONS[duration]
|
||||
|
||||
# Build client-safe summary
|
||||
summary = _build_summary(strength, duration, confidence)
|
||||
interpretation = _build_interpretation(strength, duration, confidence)
|
||||
caveats = _build_caveats(strength, duration, confidence)
|
||||
|
||||
return SignalAssessment(
|
||||
rssi=rssi,
|
||||
duration_seconds=duration_seconds,
|
||||
observation_count=observation_count,
|
||||
signal_strength=strength,
|
||||
detection_duration=duration,
|
||||
confidence=confidence,
|
||||
strength_label=strength_info['label'],
|
||||
duration_label=duration_info['label'],
|
||||
summary=summary,
|
||||
interpretation=interpretation,
|
||||
caveats=caveats,
|
||||
)
|
||||
|
||||
|
||||
def _calculate_confidence(
|
||||
strength: SignalStrength,
|
||||
duration: DetectionDuration,
|
||||
observation_count: int,
|
||||
has_corroborating_data: bool,
|
||||
) -> ConfidenceLevel:
|
||||
"""Calculate overall confidence from contributing factors."""
|
||||
score = 0
|
||||
|
||||
# Signal strength contribution
|
||||
if strength in (SignalStrength.STRONG, SignalStrength.VERY_STRONG):
|
||||
score += 2
|
||||
elif strength == SignalStrength.MODERATE:
|
||||
score += 1
|
||||
|
||||
# Duration contribution
|
||||
if duration == DetectionDuration.PERSISTENT:
|
||||
score += 2
|
||||
elif duration == DetectionDuration.SUSTAINED:
|
||||
score += 1
|
||||
|
||||
# Observation count contribution
|
||||
if observation_count >= 5:
|
||||
score += 2
|
||||
elif observation_count >= 3:
|
||||
score += 1
|
||||
|
||||
# Corroborating data bonus
|
||||
if has_corroborating_data:
|
||||
score += 1
|
||||
|
||||
# Map score to confidence level
|
||||
if score >= 5:
|
||||
return ConfidenceLevel.HIGH
|
||||
elif score >= 3:
|
||||
return ConfidenceLevel.MEDIUM
|
||||
else:
|
||||
return ConfidenceLevel.LOW
|
||||
|
||||
|
||||
def _build_summary(
|
||||
strength: SignalStrength,
|
||||
duration: DetectionDuration,
|
||||
confidence: ConfidenceLevel,
|
||||
) -> str:
|
||||
"""Build a confidence-safe summary statement."""
|
||||
strength_info = SIGNAL_STRENGTH_DESCRIPTIONS[strength]
|
||||
duration_info = DURATION_DESCRIPTIONS[duration]
|
||||
|
||||
if confidence == ConfidenceLevel.HIGH:
|
||||
return (
|
||||
f"{strength_info['label']}, {duration_info['label'].lower()} signal "
|
||||
f"with characteristics that suggest device presence in proximity"
|
||||
)
|
||||
elif confidence == ConfidenceLevel.MEDIUM:
|
||||
return (
|
||||
f"{strength_info['label']}, {duration_info['label'].lower()} signal "
|
||||
f"that may indicate device activity"
|
||||
)
|
||||
else:
|
||||
return (
|
||||
f"{duration_info['modifier'].capitalize()} {strength_info['label'].lower()} signal "
|
||||
f"consistent with possible device presence"
|
||||
)
|
||||
|
||||
|
||||
def _build_interpretation(
|
||||
strength: SignalStrength,
|
||||
duration: DetectionDuration,
|
||||
confidence: ConfidenceLevel,
|
||||
) -> str:
|
||||
"""Build interpretation text with appropriate hedging."""
|
||||
strength_info = SIGNAL_STRENGTH_DESCRIPTIONS[strength]
|
||||
|
||||
base = strength_info['interpretation']
|
||||
|
||||
if confidence == ConfidenceLevel.HIGH:
|
||||
return f"Observed signal characteristics suggest {base}"
|
||||
elif confidence == ConfidenceLevel.MEDIUM:
|
||||
return f"Signal pattern may indicate {base}"
|
||||
else:
|
||||
return f"Limited data; signal could represent {base} or environmental factors"
|
||||
|
||||
|
||||
def _build_caveats(
|
||||
strength: SignalStrength,
|
||||
duration: DetectionDuration,
|
||||
confidence: ConfidenceLevel,
|
||||
) -> list[str]:
|
||||
"""Build list of relevant caveats for the assessment."""
|
||||
caveats = []
|
||||
|
||||
# Always include general caveat
|
||||
caveats.append(
|
||||
"Signal strength is affected by environmental factors including walls, "
|
||||
"interference, and device orientation"
|
||||
)
|
||||
|
||||
# Strength-specific caveats
|
||||
if strength in (SignalStrength.MINIMAL, SignalStrength.WEAK):
|
||||
caveats.append(
|
||||
"Weak signals may represent background noise, distant devices, "
|
||||
"or heavily obstructed sources"
|
||||
)
|
||||
|
||||
# Duration-specific caveats
|
||||
if duration == DetectionDuration.TRANSIENT:
|
||||
caveats.append(
|
||||
"Brief detection may indicate passing device, intermittent transmission, "
|
||||
"or momentary interference"
|
||||
)
|
||||
|
||||
# Confidence-specific caveats
|
||||
if confidence == ConfidenceLevel.LOW:
|
||||
caveats.append(
|
||||
"Insufficient data for reliable assessment; additional observation recommended"
|
||||
)
|
||||
|
||||
return caveats
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Client-Facing Language Generators
|
||||
# =============================================================================
|
||||
|
||||
def describe_signal_for_report(
|
||||
rssi: float | int | None,
|
||||
duration_seconds: float | int | None = None,
|
||||
observation_count: int = 1,
|
||||
protocol: str = "RF",
|
||||
) -> dict:
|
||||
"""
|
||||
Generate client-facing signal description for reports.
|
||||
|
||||
Returns dict with:
|
||||
- headline: Short summary for quick scanning
|
||||
- description: Detailed description with hedged language
|
||||
- technical: Technical details (RSSI value, duration)
|
||||
- confidence: Confidence level
|
||||
- caveats: List of applicable caveats
|
||||
"""
|
||||
assessment = assess_signal(rssi, duration_seconds, observation_count)
|
||||
|
||||
# Estimate range (very approximate, with appropriate hedging)
|
||||
range_estimate = _estimate_range(rssi)
|
||||
|
||||
return {
|
||||
'headline': f"{assessment.strength_label} {protocol} signal, {assessment.duration_label.lower()}",
|
||||
'description': assessment.summary,
|
||||
'interpretation': assessment.interpretation,
|
||||
'technical': {
|
||||
'rssi_dbm': rssi,
|
||||
'strength_category': assessment.signal_strength.value,
|
||||
'duration_seconds': duration_seconds,
|
||||
'duration_category': assessment.detection_duration.value,
|
||||
'observations': observation_count,
|
||||
},
|
||||
'range_estimate': range_estimate,
|
||||
'confidence': assessment.confidence.value,
|
||||
'confidence_factors': {
|
||||
'signal_strength': assessment.strength_label,
|
||||
'detection_duration': assessment.duration_label,
|
||||
'observation_count': observation_count,
|
||||
},
|
||||
'caveats': assessment.caveats,
|
||||
}
|
||||
|
||||
|
||||
def _estimate_range(rssi: float | int | None) -> dict:
|
||||
"""
|
||||
Estimate approximate range from RSSI with heavy caveats.
|
||||
|
||||
Returns range as min/max estimate with disclaimer.
|
||||
"""
|
||||
if rssi is None:
|
||||
return {
|
||||
'estimate': 'Unknown',
|
||||
'disclaimer': 'Insufficient signal data for range estimation',
|
||||
}
|
||||
|
||||
try:
|
||||
rssi_val = float(rssi)
|
||||
except (ValueError, TypeError):
|
||||
return {
|
||||
'estimate': 'Unknown',
|
||||
'disclaimer': 'Invalid signal data',
|
||||
}
|
||||
|
||||
# Very rough estimates based on free-space path loss
|
||||
# These are intentionally wide ranges due to environmental variability
|
||||
if rssi_val > -40:
|
||||
estimate = "< 3 meters"
|
||||
range_min, range_max = 0, 3
|
||||
elif rssi_val > -55:
|
||||
estimate = "3-10 meters"
|
||||
range_min, range_max = 3, 10
|
||||
elif rssi_val > -70:
|
||||
estimate = "5-20 meters"
|
||||
range_min, range_max = 5, 20
|
||||
elif rssi_val > -85:
|
||||
estimate = "10-50 meters"
|
||||
range_min, range_max = 10, 50
|
||||
else:
|
||||
estimate = "> 30 meters or heavily obstructed"
|
||||
range_min, range_max = 30, None
|
||||
|
||||
return {
|
||||
'estimate': estimate,
|
||||
'range_min_meters': range_min,
|
||||
'range_max_meters': range_max,
|
||||
'disclaimer': (
|
||||
"Range estimates are approximate and significantly affected by "
|
||||
"walls, interference, antenna characteristics, and transmit power"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def format_signal_for_dashboard(
|
||||
rssi: float | int | None,
|
||||
duration_seconds: float | int | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Generate dashboard-friendly signal display data.
|
||||
|
||||
Returns dict with:
|
||||
- label: Short label for display
|
||||
- color: Suggested color code
|
||||
- icon: Suggested icon name
|
||||
- tooltip: Hover text with details
|
||||
"""
|
||||
strength = classify_signal_strength(rssi)
|
||||
duration = classify_duration(duration_seconds)
|
||||
|
||||
colors = {
|
||||
SignalStrength.MINIMAL: '#888888', # Gray
|
||||
SignalStrength.WEAK: '#6baed6', # Light blue
|
||||
SignalStrength.MODERATE: '#3182bd', # Blue
|
||||
SignalStrength.STRONG: '#fd8d3c', # Orange
|
||||
SignalStrength.VERY_STRONG: '#e6550d', # Red-orange
|
||||
}
|
||||
|
||||
icons = {
|
||||
SignalStrength.MINIMAL: 'signal-0',
|
||||
SignalStrength.WEAK: 'signal-1',
|
||||
SignalStrength.MODERATE: 'signal-2',
|
||||
SignalStrength.STRONG: 'signal-3',
|
||||
SignalStrength.VERY_STRONG: 'signal-4',
|
||||
}
|
||||
|
||||
strength_info = SIGNAL_STRENGTH_DESCRIPTIONS[strength]
|
||||
duration_info = DURATION_DESCRIPTIONS[duration]
|
||||
|
||||
tooltip = f"{strength_info['label']} signal ({rssi} dBm)"
|
||||
if duration_seconds is not None:
|
||||
tooltip += f", {duration_info['modifier']}"
|
||||
|
||||
return {
|
||||
'label': strength_info['label'],
|
||||
'color': colors[strength],
|
||||
'icon': icons[strength],
|
||||
'tooltip': tooltip,
|
||||
'strength': strength.value,
|
||||
'duration': duration.value,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Hedged Language Patterns
|
||||
# =============================================================================
|
||||
|
||||
# Vocabulary for generating hedged statements
|
||||
HEDGED_VERBS = {
|
||||
'high_confidence': [
|
||||
'suggests',
|
||||
'indicates',
|
||||
'is consistent with',
|
||||
],
|
||||
'medium_confidence': [
|
||||
'may indicate',
|
||||
'could suggest',
|
||||
'is potentially consistent with',
|
||||
],
|
||||
'low_confidence': [
|
||||
'might represent',
|
||||
'could possibly indicate',
|
||||
'may or may not suggest',
|
||||
],
|
||||
}
|
||||
|
||||
HEDGED_CONCLUSIONS = {
|
||||
'device_presence': {
|
||||
'high': 'likely device presence in proximity',
|
||||
'medium': 'possible device activity',
|
||||
'low': 'potential device presence, though environmental factors cannot be ruled out',
|
||||
},
|
||||
'surveillance_indicator': {
|
||||
'high': 'characteristics warranting further investigation',
|
||||
'medium': 'pattern that may warrant review',
|
||||
'low': 'inconclusive pattern requiring additional data',
|
||||
},
|
||||
'location': {
|
||||
'high': 'probable location within estimated range',
|
||||
'medium': 'possible location in general vicinity',
|
||||
'low': 'uncertain location; signal could originate from various distances',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def generate_hedged_statement(
|
||||
subject: str,
|
||||
conclusion_type: str,
|
||||
confidence: ConfidenceLevel | str,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a hedged statement for reports.
|
||||
|
||||
Args:
|
||||
subject: What we're describing (e.g., "The detected WiFi signal")
|
||||
conclusion_type: Type of conclusion (device_presence, surveillance_indicator, location)
|
||||
confidence: Confidence level
|
||||
|
||||
Returns:
|
||||
Hedged statement string
|
||||
"""
|
||||
if isinstance(confidence, ConfidenceLevel):
|
||||
conf_key = confidence.value
|
||||
else:
|
||||
conf_key = str(confidence).lower()
|
||||
|
||||
verbs = HEDGED_VERBS.get(f'{conf_key}_confidence', HEDGED_VERBS['low_confidence'])
|
||||
conclusions = HEDGED_CONCLUSIONS.get(conclusion_type, {})
|
||||
conclusion = conclusions.get(conf_key, conclusions.get('low', 'an inconclusive pattern'))
|
||||
|
||||
verb = verbs[0] # Use first verb for consistency
|
||||
|
||||
return f"{subject} {verb} {conclusion}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Standard Disclaimer Text
|
||||
# =============================================================================
|
||||
|
||||
SIGNAL_ANALYSIS_DISCLAIMER = """
|
||||
Signal analysis provides indicators for further investigation and should not be
|
||||
interpreted as definitive identification of devices or their purposes. Environmental
|
||||
factors such as building materials, electromagnetic interference, multipath
|
||||
propagation, and device orientation significantly affect signal measurements.
|
||||
All findings represent patterns observed at a specific point in time and location.
|
||||
"""
|
||||
|
||||
RANGE_ESTIMATION_DISCLAIMER = """
|
||||
Distance estimates are based on signal strength measurements and standard radio
|
||||
propagation models. Actual distances may vary significantly due to transmit power
|
||||
variations, antenna characteristics, physical obstructions, and environmental
|
||||
conditions. These estimates should be considered approximate guidelines only.
|
||||
"""
|
||||
Reference in New Issue
Block a user