Fix TSCM sweep scan resilience and add per-device error isolation

The sweep loop's WiFi/BT/RF scan processing had unprotected
timeline_manager.add_observation() calls that could crash an entire
scan iteration, silently preventing all device events from reaching
the frontend. Additionally, scan interval timestamps were only updated
at the end of processing, causing tight retry loops on persistent errors.

- Wrap timeline observation calls in try/except for all three protocols
- Move last_*_scan timestamp updates immediately after scan completes
- Add per-device try/except so one bad device doesn't block others
- Emit sweep_progress after WiFi scan for real-time status visibility
- Log warning when WiFi scan returns 0 networks for easier diagnosis
- Add known_device and score_modifier fields to correlation engine
- Add TSCM scheduling, cases, known devices, and advanced WiFi indicators

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-05 16:07:34 +00:00
parent 11941bedad
commit 5d4b19aef2
11 changed files with 6202 additions and 3555 deletions
+26 -3
View File
@@ -3122,6 +3122,7 @@ class ModeManager:
wifi_interface = params.get('wifi_interface') or params.get('interface') wifi_interface = params.get('wifi_interface') or params.get('interface')
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0') bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
sdr_device = params.get('sdr_device', params.get('device', 0)) sdr_device = params.get('sdr_device', params.get('device', 0))
sweep_type = params.get('sweep_type')
# Get baseline_id for comparison (same as local mode) # Get baseline_id for comparison (same as local mode)
baseline_id = params.get('baseline_id') baseline_id = params.get('baseline_id')
@@ -3131,7 +3132,7 @@ class ModeManager:
# Start the combined TSCM scanner thread using existing Intercept functions # Start the combined TSCM scanner thread using existing Intercept functions
thread = threading.Thread( thread = threading.Thread(
target=self._tscm_scanner_thread, target=self._tscm_scanner_thread,
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id), args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type),
daemon=True daemon=True
) )
thread.start() thread.start()
@@ -3154,7 +3155,7 @@ class ModeManager:
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool, def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
wifi_interface: str | None, bt_adapter: str, sdr_device: int, wifi_interface: str | None, bt_adapter: str, sdr_device: int,
baseline_id: int | None = None): baseline_id: int | None = None, sweep_type: str | None = None):
"""Combined TSCM scanner using existing Intercept functions. """Combined TSCM scanner using existing Intercept functions.
NOTE: This matches local mode behavior exactly: NOTE: This matches local mode behavior exactly:
@@ -3170,6 +3171,15 @@ class ModeManager:
from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals
logger.info("TSCM imports successful") logger.info("TSCM imports successful")
sweep_ranges = None
if sweep_type:
try:
from data.tscm_frequencies import get_sweep_preset, SWEEP_PRESETS
preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard')
sweep_ranges = preset.get('ranges') if preset else None
except Exception:
sweep_ranges = None
# Load baseline if specified (same as local mode) # Load baseline if specified (same as local mode)
baseline = None baseline = None
if baseline_id and HAS_BASELINE_DB and get_tscm_baseline: if baseline_id and HAS_BASELINE_DB and get_tscm_baseline:
@@ -3243,6 +3253,9 @@ class ModeManager:
profile = self._tscm_correlation.analyze_wifi_device(enriched) profile = self._tscm_correlation.analyze_wifi_device(enriched)
enriched['classification'] = profile.risk_level.value enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score enriched['score'] = profile.total_score
enriched['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [ enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description} {'type': i.type.value, 'desc': i.description}
for i in profile.indicators for i in profile.indicators
@@ -3289,6 +3302,9 @@ class ModeManager:
profile = self._tscm_correlation.analyze_bluetooth_device(enriched) profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
enriched['classification'] = profile.risk_level.value enriched['classification'] = profile.risk_level.value
enriched['score'] = profile.total_score enriched['score'] = profile.total_score
enriched['score_modifier'] = profile.score_modifier
enriched['known_device'] = profile.known_device
enriched['known_device_name'] = profile.known_device_name
enriched['indicators'] = [ enriched['indicators'] = [
{'type': i.type.value, 'desc': i.description} {'type': i.type.value, 'desc': i.description}
for i in profile.indicators for i in profile.indicators
@@ -3304,7 +3320,11 @@ class ModeManager:
try: try:
# Pass a stop check that uses our stop_event (not the module's _sweep_running) # Pass a stop check that uses our stop_event (not the module's _sweep_running)
agent_stop_check = lambda: stop_event and stop_event.is_set() agent_stop_check = lambda: stop_event and stop_event.is_set()
rf_signals = _scan_rf_signals(sdr_device, stop_check=agent_stop_check) rf_signals = _scan_rf_signals(
sdr_device,
stop_check=agent_stop_check,
sweep_ranges=sweep_ranges
)
# Analyze each RF signal like local mode does # Analyze each RF signal like local mode does
analyzed_signals = [] analyzed_signals = []
@@ -3328,6 +3348,9 @@ class ModeManager:
profile = self._tscm_correlation.analyze_rf_signal(signal) profile = self._tscm_correlation.analyze_rf_signal(signal)
analyzed['classification'] = profile.risk_level.value analyzed['classification'] = profile.risk_level.value
analyzed['score'] = profile.total_score analyzed['score'] = profile.total_score
analyzed['score_modifier'] = profile.score_modifier
analyzed['known_device'] = profile.known_device
analyzed['known_device_name'] = profile.known_device_name
analyzed['indicators'] = [ analyzed['indicators'] = [
{'type': i.type.value, 'desc': i.description} {'type': i.type.value, 'desc': i.description}
for i in profile.indicators for i in profile.indicators
+43
View File
@@ -17,6 +17,8 @@ import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Generator from typing import Generator
import requests
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
from utils.database import ( from utils.database import (
@@ -480,6 +482,47 @@ def proxy_mode_data(agent_id: int, mode: str):
}), 502 }), 502
@controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
def proxy_mode_stream(agent_id: int, mode: str):
"""Proxy SSE stream from a remote agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
client = create_client_from_agent(agent)
query = request.query_string.decode('utf-8')
url = f"{client.base_url}/{mode}/stream"
if query:
url = f"{url}?{query}"
headers = {'Accept': 'text/event-stream'}
if agent.get('api_key'):
headers['X-API-Key'] = agent['api_key']
def generate() -> Generator[str, None, None]:
try:
with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp:
resp.raise_for_status()
for chunk in resp.iter_content(chunk_size=1024):
if not chunk:
continue
yield chunk.decode('utf-8', errors='ignore')
except Exception as e:
logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}")
yield format_sse({
'type': 'error',
'message': str(e),
'agent_id': agent_id,
'mode': mode,
})
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST']) @controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
def proxy_wifi_monitor(agent_id: int): def proxy_wifi_monitor(agent_id: int):
"""Toggle monitor mode on a remote agent's WiFi interface.""" """Toggle monitor mode on a remote agent's WiFi interface."""
+731 -134
View File
File diff suppressed because it is too large Load Diff
+213
View File
@@ -45,6 +45,7 @@
padding: 12px; padding: 12px;
background: rgba(0,0,0,0.3); background: rgba(0,0,0,0.3);
border-radius: 8px; border-radius: 8px;
flex-wrap: wrap;
} }
.tscm-threat-banner .threat-card { .tscm-threat-banner .threat-card {
flex: 1; flex: 1;
@@ -200,6 +201,17 @@
margin-left: 6px; margin-left: 6px;
font-size: 10px; font-size: 10px;
} }
.known-badge {
margin-left: 6px;
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
background: rgba(0, 255, 136, 0.2);
color: #00ff88;
border: 1px solid rgba(0, 255, 136, 0.4);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.tscm-device-header { .tscm-device-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -465,6 +477,18 @@
color: var(--text-dim); color: var(--text-dim);
width: 40%; width: 40%;
} }
.device-detail-id {
display: inline-block;
margin-left: 6px;
font-size: 10px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.tscm-more-hint {
margin-top: 6px;
font-size: 10px;
color: var(--text-muted);
}
.indicator-list { .indicator-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -882,6 +906,42 @@
margin-left: auto; margin-left: auto;
} }
/* Filters */
.tscm-filter-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 12px;
margin-bottom: 12px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
align-items: flex-end;
}
.tscm-filter-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.tscm-filter-group label {
font-size: 9px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.6px;
}
.tscm-filter-group select {
background: rgba(0, 0, 0, 0.4);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 4px 6px;
font-size: 10px;
}
.tscm-filter-status {
margin-left: auto;
font-size: 10px;
color: var(--text-muted);
}
/* Advanced Modal Styles */ /* Advanced Modal Styles */
.tscm-advanced-modal { .tscm-advanced-modal {
max-width: 600px; max-width: 600px;
@@ -1461,3 +1521,156 @@
width: 10px; width: 10px;
height: 10px; height: 10px;
} }
/* Meeting banner actions */
.tscm-meeting-banner {
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.tscm-meeting-banner .meeting-actions {
margin-left: auto;
}
/* Case linking */
.tscm-case-link-banner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 8px 10px;
margin-bottom: 10px;
background: rgba(74, 158, 255, 0.12);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 6px;
font-size: 11px;
}
.case-actions {
margin-top: 8px;
}
.tscm-case-link-btn {
margin-left: auto;
font-size: 9px;
padding: 2px 6px;
background: rgba(74, 158, 255, 0.2);
color: #9ed0ff;
border: 1px solid rgba(74, 158, 255, 0.4);
border-radius: 3px;
cursor: pointer;
}
/* Schedules */
.tscm-schedule-form {
display: grid;
gap: 10px;
}
.tscm-schedule-list {
display: grid;
gap: 10px;
}
.tscm-schedule-item {
padding: 10px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
}
.tscm-schedule-item.enabled {
border-color: rgba(0, 255, 136, 0.35);
}
.tscm-schedule-item.disabled {
opacity: 0.7;
}
.tscm-schedule-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.tscm-schedule-status {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.tscm-schedule-meta {
font-size: 10px;
color: var(--text-muted);
margin-bottom: 4px;
}
.tscm-schedule-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
}
/* Meeting summary */
.tscm-summary-list {
display: grid;
gap: 8px;
}
.tscm-summary-item {
padding: 8px 10px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.tscm-summary-meta {
font-size: 10px;
color: var(--text-muted);
margin-top: 4px;
}
.tscm-summary-risk {
font-size: 10px;
color: #ff9933;
margin-top: 4px;
}
/* Case notes */
.tscm-case-notes {
display: grid;
gap: 8px;
margin-bottom: 10px;
}
.tscm-case-note {
padding: 8px 10px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.tscm-case-note-meta {
display: flex;
justify-content: space-between;
font-size: 9px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.tscm-case-note-type {
color: var(--accent-cyan);
}
.tscm-case-note-content {
font-size: 11px;
line-height: 1.4;
white-space: pre-wrap;
}
.tscm-case-note-author {
font-size: 9px;
color: var(--text-muted);
margin-top: 4px;
}
.tscm-case-note-form {
display: grid;
gap: 6px;
margin-top: 8px;
}
.tscm-case-note-form textarea {
min-height: 80px;
}
.tscm-case-note-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
}
+2 -13
View File
@@ -868,11 +868,8 @@ function connectAgentStream(mode, onMessage) {
if (currentAgent === 'local') { if (currentAgent === 'local') {
streamUrl = `/${mode}/stream`; streamUrl = `/${mode}/stream`;
} else { } else {
// For remote agents, we could either: // For remote agents, proxy SSE through controller
// 1. Use the multi-agent stream: /controller/stream/all streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`;
// 2. Or proxy through controller (not implemented yet)
// For now, use multi-agent stream which includes agent_name tagging
streamUrl = '/controller/stream/all';
} }
agentEventSource = new EventSource(streamUrl); agentEventSource = new EventSource(streamUrl);
@@ -881,14 +878,6 @@ function connectAgentStream(mode, onMessage) {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
// If using multi-agent stream, filter by current agent if needed
if (streamUrl === '/controller/stream/all' && currentAgent !== 'local') {
const agent = agents.find(a => a.id == currentAgent);
if (agent && data.agent_name && data.agent_name !== agent.name) {
return; // Skip messages from other agents
}
}
onMessage(data); onMessage(data);
} catch (e) { } catch (e) {
console.error('Error parsing SSE message:', e); console.error('Error parsing SSE message:', e);
+8 -1
View File
@@ -879,6 +879,7 @@ const WiFiMode = (function() {
updateNetworkRow(network); updateNetworkRow(network);
updateStats(); updateStats();
updateProximityRadar(); updateProximityRadar();
updateChannelChart();
if (onNetworkUpdate) onNetworkUpdate(network); if (onNetworkUpdate) onNetworkUpdate(network);
} }
@@ -1420,9 +1421,15 @@ const WiFiMode = (function() {
return Object.values(stats).filter(s => s.ap_count > 0 || [1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161, 165].includes(s.channel)); return Object.values(stats).filter(s => s.ap_count > 0 || [1, 6, 11, 36, 40, 44, 48, 149, 153, 157, 161, 165].includes(s.channel));
} }
function updateChannelChart(band = '2.4') { function updateChannelChart(band) {
if (typeof ChannelChart === 'undefined') return; if (typeof ChannelChart === 'undefined') return;
// Use the currently active band tab if no band specified
if (!band) {
const activeTab = elements.channelBandTabs && elements.channelBandTabs.querySelector('.channel-band-tab.active');
band = activeTab ? activeTab.dataset.band : '2.4';
}
// Recalculate channel stats from networks if needed // Recalculate channel stats from networks if needed
if (channelStats.length === 0 && networks.size > 0) { if (channelStats.length === 0 && networks.size > 0) {
channelStats = calculateChannelStats(); channelStats = calculateChannelStats();
+1624 -54
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -146,6 +146,9 @@
<button class="stop-btn" id="tscmEndMeetingBtn" onclick="tscmEndMeeting()" style="width: 100%; padding: 8px; display: none;"> <button class="stop-btn" id="tscmEndMeetingBtn" onclick="tscmEndMeeting()" style="width: 100%; padding: 8px; display: none;">
End Meeting Window End Meeting Window
</button> </button>
<button class="preset-btn" id="tscmMeetingSummaryBtn" onclick="tscmShowMeetingSummary()" style="width: 100%; padding: 8px; margin-top: 6px; display: none;">
View Meeting Summary
</button>
<div style="font-size: 9px; color: var(--text-muted); margin-top: 4px;"> <div style="font-size: 9px; color: var(--text-muted); margin-top: 4px;">
Devices detected during meetings get flagged Devices detected during meetings get flagged
</div> </div>
@@ -159,12 +162,18 @@
<button class="preset-btn" onclick="tscmShowCapabilities()" style="font-size: 10px; padding: 8px;"> <button class="preset-btn" onclick="tscmShowCapabilities()" style="font-size: 10px; padding: 8px;">
Capabilities Capabilities
</button> </button>
<button class="preset-btn" onclick="tscmShowWifiIndicators()" style="font-size: 10px; padding: 8px;">
WiFi Indicators
</button>
<button class="preset-btn" onclick="tscmShowKnownDevices()" style="font-size: 10px; padding: 8px;"> <button class="preset-btn" onclick="tscmShowKnownDevices()" style="font-size: 10px; padding: 8px;">
Known Devices Known Devices
</button> </button>
<button class="preset-btn" onclick="tscmShowCases()" style="font-size: 10px; padding: 8px;"> <button class="preset-btn" onclick="tscmShowCases()" style="font-size: 10px; padding: 8px;">
Cases Cases
</button> </button>
<button class="preset-btn" onclick="tscmShowSchedules()" style="font-size: 10px; padding: 8px;">
Schedules
</button>
<button class="preset-btn" onclick="tscmShowPlaybooks()" style="font-size: 10px; padding: 8px;"> <button class="preset-btn" onclick="tscmShowPlaybooks()" style="font-size: 10px; padding: 8px;">
Playbooks Playbooks
</button> </button>
+106
View File
@@ -1215,6 +1215,112 @@ def delete_known_device(identifier: str) -> bool:
return cursor.rowcount > 0 return cursor.rowcount > 0
# =============================================================================
# TSCM Schedule Functions
# =============================================================================
def create_tscm_schedule(
name: str,
cron_expression: str,
sweep_type: str = 'standard',
baseline_id: int | None = None,
zone_name: str | None = None,
enabled: bool = True,
notify_on_threat: bool = True,
notify_email: str | None = None,
last_run: str | None = None,
next_run: str | None = None,
) -> int:
"""Create a new TSCM sweep schedule."""
with get_db() as conn:
cursor = conn.execute('''
INSERT INTO tscm_schedules
(name, baseline_id, zone_name, cron_expression, sweep_type,
enabled, last_run, next_run, notify_on_threat, notify_email)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
name,
baseline_id,
zone_name,
cron_expression,
sweep_type,
1 if enabled else 0,
last_run,
next_run,
1 if notify_on_threat else 0,
notify_email,
))
return cursor.lastrowid
def get_tscm_schedule(schedule_id: int) -> dict | None:
"""Get a TSCM schedule by ID."""
with get_db() as conn:
cursor = conn.execute(
'SELECT * FROM tscm_schedules WHERE id = ?',
(schedule_id,)
)
row = cursor.fetchone()
return dict(row) if row else None
def get_all_tscm_schedules(
enabled: bool | None = None,
limit: int = 200
) -> list[dict]:
"""Get all TSCM schedules."""
conditions = []
params = []
if enabled is not None:
conditions.append('enabled = ?')
params.append(1 if enabled else 0)
where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
params.append(limit)
with get_db() as conn:
cursor = conn.execute(f'''
SELECT * FROM tscm_schedules
{where_clause}
ORDER BY id DESC
LIMIT ?
''', params)
return [dict(row) for row in cursor]
def update_tscm_schedule(schedule_id: int, **fields) -> bool:
"""Update a TSCM schedule."""
if not fields:
return False
updates = []
params = []
for key, value in fields.items():
updates.append(f'{key} = ?')
params.append(value)
params.append(schedule_id)
with get_db() as conn:
cursor = conn.execute(
f'UPDATE tscm_schedules SET {", ".join(updates)} WHERE id = ?',
params
)
return cursor.rowcount > 0
def delete_tscm_schedule(schedule_id: int) -> bool:
"""Delete a TSCM schedule."""
with get_db() as conn:
cursor = conn.execute(
'DELETE FROM tscm_schedules WHERE id = ?',
(schedule_id,)
)
return cursor.rowcount > 0
def is_known_good_device(identifier: str, location: str | None = None) -> dict | None: def is_known_good_device(identifier: str, location: str | None = None) -> dict | None:
"""Check if a device is in the known-good registry for a location.""" """Check if a device is in the known-good registry for a location."""
with get_db() as conn: with get_db() as conn:
+8 -3
View File
@@ -246,6 +246,7 @@ class SSTVDecoder:
self._doppler_tracker = DopplerTracker('ISS') self._doppler_tracker = DopplerTracker('ISS')
self._doppler_enabled = False self._doppler_enabled = False
self._last_doppler_info: DopplerInfo | None = None self._last_doppler_info: DopplerInfo | None = None
self._file_decoder: str | None = None
# Ensure output directory exists # Ensure output directory exists
self._output_dir.mkdir(parents=True, exist_ok=True) self._output_dir.mkdir(parents=True, exist_ok=True)
@@ -268,6 +269,7 @@ class SSTVDecoder:
try: try:
result = subprocess.run(['which', 'slowrx'], capture_output=True, timeout=5) result = subprocess.run(['which', 'slowrx'], capture_output=True, timeout=5)
if result.returncode == 0: if result.returncode == 0:
self._file_decoder = 'slowrx'
return 'slowrx' return 'slowrx'
except Exception: except Exception:
pass pass
@@ -277,7 +279,8 @@ class SSTVDecoder:
# Check for Python sstv package # Check for Python sstv package
try: try:
import sstv import sstv
return 'python-sstv' self._file_decoder = 'python-sstv'
return None
except ImportError: except ImportError:
pass pass
@@ -693,7 +696,9 @@ class SSTVDecoder:
images = [] images = []
if self._decoder == 'slowrx': decoder = self._decoder or self._file_decoder
if decoder == 'slowrx':
# Use slowrx with file input # Use slowrx with file input
output_file = self._output_dir / f"sstv_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png" output_file = self._output_dir / f"sstv_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
@@ -715,7 +720,7 @@ class SSTVDecoder:
) )
images.append(image) images.append(image)
elif self._decoder == 'python-sstv': elif decoder == 'python-sstv':
# Use Python sstv library # Use Python sstv library
try: try:
from sstv.decode import SSTVDecoder as PythonSSTVDecoder from sstv.decode import SSTVDecoder as PythonSSTVDecoder
+85
View File
@@ -157,6 +157,9 @@ class DeviceProfile:
# Output # Output
confidence: float = 0.0 confidence: float = 0.0
recommended_action: str = 'monitor' recommended_action: str = 'monitor'
known_device: bool = False
known_device_name: Optional[str] = None
score_modifier: int = 0
def add_rssi_sample(self, rssi: int) -> None: def add_rssi_sample(self, rssi: int) -> None:
"""Add an RSSI sample with timestamp.""" """Add an RSSI sample with timestamp."""
@@ -208,6 +211,26 @@ class DeviceProfile:
indicator_count = len(self.indicators) indicator_count = len(self.indicators)
self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05)) self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05))
def apply_score_modifier(self, modifier: int | None) -> None:
"""Apply a score modifier (e.g., known-good device adjustment)."""
base_score = sum(i.score for i in self.indicators)
modifier_val = int(modifier) if modifier is not None else 0
self.score_modifier = modifier_val
self.total_score = max(0, base_score + modifier_val)
if self.total_score >= 6:
self.risk_level = RiskLevel.HIGH_INTEREST
self.recommended_action = 'investigate'
elif self.total_score >= 3:
self.risk_level = RiskLevel.NEEDS_REVIEW
self.recommended_action = 'review'
else:
self.risk_level = RiskLevel.INFORMATIONAL
self.recommended_action = 'monitor'
indicator_count = len(self.indicators)
self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05))
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization.""" """Convert to dictionary for JSON serialization."""
return { return {
@@ -232,10 +255,13 @@ class DeviceProfile:
for i in self.indicators for i in self.indicators
], ],
'total_score': self.total_score, 'total_score': self.total_score,
'score_modifier': self.score_modifier,
'risk_level': self.risk_level.value, 'risk_level': self.risk_level.value,
'confidence': round(self.confidence, 2), 'confidence': round(self.confidence, 2),
'recommended_action': self.recommended_action, 'recommended_action': self.recommended_action,
'correlated_devices': self.correlated_devices, 'correlated_devices': self.correlated_devices,
'known_device': self.known_device,
'known_device_name': self.known_device_name,
} }
@@ -286,6 +312,7 @@ class CorrelationEngine:
self.device_profiles: dict[str, DeviceProfile] = {} self.device_profiles: dict[str, DeviceProfile] = {}
self.meeting_windows: list[tuple[datetime, datetime]] = [] self.meeting_windows: list[tuple[datetime, datetime]] = []
self.correlation_window = timedelta(minutes=5) self.correlation_window = timedelta(minutes=5)
self._known_device_cache: dict[str, dict | None] = {}
def start_meeting_window(self) -> None: def start_meeting_window(self) -> None:
"""Mark the start of a sensitive period (meeting).""" """Mark the start of a sensitive period (meeting)."""
@@ -310,6 +337,54 @@ class CorrelationEngine:
return True return True
return False return False
def _lookup_known_device(self, identifier: str, protocol: str) -> dict | None:
"""Lookup known-good device details with light normalization."""
cache_key = f"{protocol}:{identifier}"
if cache_key in self._known_device_cache:
return self._known_device_cache[cache_key]
try:
from utils.database import is_known_good_device
candidates = []
if identifier:
candidates.append(str(identifier))
if protocol == 'rf':
try:
freq_val = float(identifier)
candidates.append(f"{freq_val:.3f}")
candidates.append(f"{freq_val:.1f}")
except (ValueError, TypeError):
pass
known = None
for cand in candidates:
if not cand:
continue
known = is_known_good_device(str(cand).upper())
if known:
break
except Exception:
known = None
self._known_device_cache[cache_key] = known
return known
def _apply_known_device_modifier(self, profile: DeviceProfile, identifier: str, protocol: str) -> None:
"""Apply known-good score modifier and update profile metadata."""
known = self._lookup_known_device(identifier, protocol)
if known:
profile.known_device = True
profile.known_device_name = known.get('name') if isinstance(known, dict) else None
modifier = known.get('score_modifier', 0) if isinstance(known, dict) else 0
else:
profile.known_device = False
profile.known_device_name = None
modifier = 0
profile.apply_score_modifier(modifier)
def get_or_create_profile(self, identifier: str, protocol: str) -> DeviceProfile: def get_or_create_profile(self, identifier: str, protocol: str) -> DeviceProfile:
"""Get existing profile or create new one.""" """Get existing profile or create new one."""
key = f"{protocol}:{identifier}" key = f"{protocol}:{identifier}"
@@ -583,6 +658,8 @@ class CorrelationEngine:
) )
profile.device_type = 'Samsung SmartTag' profile.device_type = 'Samsung SmartTag'
self._apply_known_device_modifier(profile, mac, 'bluetooth')
return profile return profile
def analyze_wifi_device(self, device: dict) -> DeviceProfile: def analyze_wifi_device(self, device: dict) -> DeviceProfile:
@@ -695,6 +772,8 @@ class CorrelationEngine:
{'rssi': latest_rssi} {'rssi': latest_rssi}
) )
self._apply_known_device_modifier(profile, bssid, 'wifi')
return profile return profile
def analyze_rf_signal(self, signal: dict) -> DeviceProfile: def analyze_rf_signal(self, signal: dict) -> DeviceProfile:
@@ -785,6 +864,8 @@ class CorrelationEngine:
{'during_meeting': True} {'during_meeting': True}
) )
self._apply_known_device_modifier(profile, freq_key, 'rf')
return profile return profile
def correlate_devices(self) -> list[dict]: def correlate_devices(self) -> list[dict]:
@@ -887,6 +968,10 @@ class CorrelationEngine:
} }
correlations.append(correlation) correlations.append(correlation)
# Re-apply known-good modifiers after correlation boosts
for profile in self.device_profiles.values():
self._apply_known_device_modifier(profile, profile.identifier, profile.protocol)
return correlations return correlations
def get_high_interest_devices(self) -> list[DeviceProfile]: def get_high_interest_devices(self) -> list[DeviceProfile]: