mirror of
https://github.com/smittix/intercept.git
synced 2026-04-26 07:40:01 -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:
@@ -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