diff --git a/routes/tscm/analysis.py b/routes/tscm/analysis.py
index 9b09c74..66e5aa0 100644
--- a/routes/tscm/analysis.py
+++ b/routes/tscm/analysis.py
@@ -281,6 +281,8 @@ def get_pdf_report():
return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404
categories = _parse_categories_param(request.args.get('categories', ''))
+ site_name = request.args.get('site_name', '').strip()[:200]
+ examiner_name = request.args.get('examiner_name', '').strip()[:200]
# Get data for report
correlation = get_correlation_engine()
@@ -298,6 +300,8 @@ def get_pdf_report():
capabilities=caps,
timelines=timelines,
categories=categories,
+ site_name=site_name,
+ examiner_name=examiner_name,
)
pdf_content = get_pdf_report(report)
@@ -339,6 +343,8 @@ def get_technical_annex():
return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404
categories = _parse_categories_param(request.args.get('categories', ''))
+ site_name = request.args.get('site_name', '').strip()[:200]
+ examiner_name = request.args.get('examiner_name', '').strip()[:200]
# Get data for report
correlation = get_correlation_engine()
@@ -356,6 +362,8 @@ def get_technical_annex():
capabilities=caps,
timelines=timelines,
categories=categories,
+ site_name=site_name,
+ examiner_name=examiner_name,
)
if format_type == 'csv':
diff --git a/static/css/modes/tscm.css b/static/css/modes/tscm.css
index 37da1e5..e02887e 100644
--- a/static/css/modes/tscm.css
+++ b/static/css/modes/tscm.css
@@ -229,6 +229,45 @@
text-transform: uppercase;
letter-spacing: 0.4px;
}
+.cleared-badge {
+ margin-left: 6px;
+ font-size: 9px;
+ padding: 1px 4px;
+ border-radius: 3px;
+ background: rgba(0, 204, 0, 0.15);
+ color: #00cc00;
+ border: 1px solid rgba(0, 204, 0, 0.4);
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+}
+.tscm-cleared {
+ opacity: 0.55;
+}
+.tscm-device-actions {
+ display: flex;
+ gap: 4px;
+ margin-top: 6px;
+}
+.tscm-action-btn {
+ font-size: 9px;
+ padding: 2px 7px;
+ border: 1px solid var(--border-color);
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--text-secondary);
+ border-radius: 3px;
+ cursor: pointer;
+ transition: opacity 0.15s;
+}
+.tscm-action-btn:hover { opacity: 0.75; }
+.tscm-action-btn.cleared {
+ border-color: #00cc00;
+ color: #00cc00;
+ background: rgba(0, 204, 0, 0.1);
+}
+.tscm-action-btn.ignore {
+ border-color: rgba(255, 107, 107, 0.5);
+ color: #ff6b6b;
+}
.tscm-device-header {
display: flex;
justify-content: space-between;
diff --git a/templates/index.html b/templates/index.html
index 5997f30..c34f5d0 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -4915,6 +4915,7 @@
if (mode === 'tscm') {
loadTscmBaselines();
refreshTscmDevices();
+ updateTscmIgnoreListUI();
}
// Initialize Drone mode when selected
@@ -12036,6 +12037,13 @@
let tscmWifiDevices = [];
let tscmWifiClients = [];
let tscmBtDevices = [];
+ // Devices marked as cleared by investigator (reset each sweep)
+ let tscmClearedDevices = new Set();
+ // Persistent ignore list — examiner's own devices, excluded from display and reports
+ let tscmIgnoreList = (() => {
+ try { return new Set(JSON.parse(localStorage.getItem('tscmIgnoreList') || '[]')); }
+ catch(e) { return new Set(); }
+ })();
let tscmBaselineComparison = null;
let tscmIdentityClusters = [];
let tscmIdentitySummary = null;
@@ -12286,6 +12294,7 @@
tscmIdentityClusters = [];
tscmIdentitySummary = null;
tscmHighInterestDevices = [];
+ tscmClearedDevices = new Set();
updateTscmDisplays();
updateTscmThreatCounts();
@@ -12378,12 +12387,18 @@
const durationMin = Math.floor(durationMs / 60000);
const durationSec = Math.floor((durationMs % 60000) / 1000);
- // Categorize devices by classification
- const allDevices = [
- ...tscmWifiDevices.map(d => ({ ...d, protocol: 'WiFi' })),
- ...tscmBtDevices.map(d => ({ ...d, protocol: 'Bluetooth' })),
- ...tscmRfSignals.map(d => ({ ...d, protocol: 'RF' }))
+ // Read sweep metadata
+ const siteName = document.getElementById('tscmSiteName')?.value?.trim() || '';
+ const examinerName = document.getElementById('tscmExaminerName')?.value?.trim() || '';
+
+ // Categorize devices by classification — exclude cleared and ignored devices from reports
+ const allDevicesRaw = [
+ ...tscmWifiDevices.map(d => ({ ...d, protocol: 'WiFi', _key: _tscmDeviceKey(d, 'wifi') })),
+ ...tscmBtDevices.map(d => ({ ...d, protocol: 'Bluetooth', _key: _tscmDeviceKey(d, 'bluetooth') })),
+ ...tscmRfSignals.map(d => ({ ...d, protocol: 'RF', _key: _tscmDeviceKey(d, 'rf') }))
];
+ const clearedCount = allDevicesRaw.filter(d => tscmClearedDevices.has(d._key)).length;
+ const allDevices = allDevicesRaw.filter(d => !tscmClearedDevices.has(d._key));
const allHighInterest = allDevices.filter(d => d.classification === 'high_interest' || d.score >= 6);
const allNeedsReview = allDevices.filter(d => d.classification === 'review' || (d.score >= 3 && d.score < 6));
@@ -12753,6 +12768,8 @@
${allDevices.length}
Total Devices
+ ${siteName ? `` : ''}
+ ${examinerName ? `` : ''}
@@ -12780,6 +12797,7 @@
Assessment: ${assessment}
${categoryNote}
+ ${clearedCount > 0 ? `${clearedCount} device(s) marked as cleared by investigator — excluded from this report.
` : ''}
${highInterest.length > 0 ? `
@@ -13131,6 +13149,8 @@
}
function addTscmWifiDevice(device) {
+ // Check ignore list
+ if (tscmIgnoreList.has(`wifi:${device.bssid}`)) return;
// Check if already exists
const exists = tscmWifiDevices.some(d => d.bssid === device.bssid);
if (!exists) {
@@ -13159,6 +13179,7 @@
function addTscmWifiClient(client) {
const mac = client.mac || client.address || '';
if (!mac) return;
+ if (tscmIgnoreList.has(`wifi:${mac}`)) return;
const exists = tscmWifiClients.some(d => (d.mac || d.address) === mac);
if (!exists) {
if (!client.mac) client.mac = mac;
@@ -13181,6 +13202,7 @@
function addTscmBtDevice(device) {
const mac = device.mac || device.address || '';
+ if (tscmIgnoreList.has(`bt:${mac}`)) return;
// Check if already exists
const exists = tscmBtDevices.some(d => (d.mac || d.address) === mac);
if (!exists) {
@@ -13212,6 +13234,7 @@
function addTscmRfSignal(signal) {
// Clear any error message since we're receiving signals
tscmRfStatusMessage = null;
+ if (tscmIgnoreList.has(`rf:${(signal.frequency || 0).toFixed(3)}`)) return;
// Check if already exists (within 0.1 MHz)
const exists = tscmRfSignals.some(s => Math.abs(s.frequency - signal.frequency) < 0.1);
const powerDbm = signal.power_dbm ?? signal.power ?? signal.level;
@@ -13399,6 +13422,83 @@
if (rfPanel) rfPanel.style.display = showRf ? '' : 'none';
}
+ // --- Ignore list + cleared device helpers ---
+
+ function _tscmDeviceKey(device, protocol) {
+ if (protocol === 'wifi') return `wifi:${device.bssid || device.mac || 'unknown'}`;
+ if (protocol === 'bluetooth') return `bt:${device.mac || device.address || 'unknown'}`;
+ if (protocol === 'rf') return `rf:${(device.frequency || 0).toFixed(3)}`;
+ return `unknown:${protocol}`;
+ }
+
+ function tscmMarkCleared(key) {
+ if (tscmClearedDevices.has(key)) {
+ tscmClearedDevices.delete(key);
+ } else {
+ tscmClearedDevices.add(key);
+ }
+ debouncedUpdateTscmDisplays();
+ }
+
+ function _saveTscmIgnoreList() {
+ try { localStorage.setItem('tscmIgnoreList', JSON.stringify([...tscmIgnoreList])); } catch(e) {}
+ }
+
+ function updateTscmIgnoreListUI() {
+ const el = document.getElementById('tscmIgnoreListItems');
+ if (!el) return;
+ if (tscmIgnoreList.size === 0) {
+ el.innerHTML = 'No devices ignored
';
+ return;
+ }
+ el.innerHTML = [...tscmIgnoreList].map(k => {
+ const safe = escapeHtml(k);
+ return `
+ ${safe}
+
+
`;
+ }).join('');
+ }
+
+ function tscmAddToIgnoreList(key) {
+ if (!key || key.endsWith(':unknown')) return;
+ tscmIgnoreList.add(key);
+ _saveTscmIgnoreList();
+ updateTscmIgnoreListUI();
+ // Remove from current sweep arrays and re-render
+ const colon = key.indexOf(':');
+ const proto = key.slice(0, colon);
+ const id = key.slice(colon + 1);
+ if (proto === 'wifi') {
+ tscmWifiDevices = tscmWifiDevices.filter(d => d.bssid !== id);
+ if (typeof tscmWifiClients !== 'undefined')
+ tscmWifiClients = tscmWifiClients.filter(d => (d.mac || d.address) !== id);
+ } else if (proto === 'bt') {
+ tscmBtDevices = tscmBtDevices.filter(d => (d.mac || d.address) !== id);
+ } else if (proto === 'rf') {
+ const freq = parseFloat(id);
+ tscmRfSignals = tscmRfSignals.filter(s => Math.abs(s.frequency - freq) >= 0.001);
+ }
+ updateTscmDisplays();
+ updateTscmThreatCounts();
+ }
+
+ function tscmRemoveFromIgnoreList(key) {
+ tscmIgnoreList.delete(key);
+ _saveTscmIgnoreList();
+ updateTscmIgnoreListUI();
+ }
+
+ function tscmClearIgnoreList() {
+ if (tscmIgnoreList.size === 0) return;
+ if (!confirm('Clear all devices from the ignore list?')) return;
+ tscmIgnoreList.clear();
+ _saveTscmIgnoreList();
+ updateTscmIgnoreListUI();
+ }
+
+ // --- End ignore list helpers ---
+
function matchesTscmFilters(device, protocol, options = {}) {
if (tscmFilters.protocol !== 'all' && protocol !== tscmFilters.protocol) return false;
@@ -14712,12 +14812,16 @@
} else {
// Sort by score (highest first)
const sorted = [...filtered.wifi].sort((a, b) => (b.score || 0) - (a.score || 0));
- wifiList.innerHTML = sorted.map(d => `
-
+ wifiList.innerHTML = sorted.map(d => {
+ const dkey = _tscmDeviceKey(d, 'wifi');
+ const cleared = tscmClearedDevices.has(dkey);
+ return `
+
${d.indicators && d.indicators.length > 0 ? `
${formatIndicators(d.indicators)}
` : ''}
${d.recommended_action && d.recommended_action !== 'monitor' ? `
Action: ${d.recommended_action}
` : ''}
-
- `).join('');
+
+
+
+
+
`;
+ }).join('');
}
document.getElementById('tscmWifiCount').textContent = filtered.wifi.length;
@@ -14740,13 +14848,17 @@
wifiClientList.innerHTML = `${filtersActive ? 'No WiFi clients match filters' : 'No WiFi clients detected'}
`;
} else {
const sortedClients = [...filtered.wifi_clients].sort((a, b) => (b.score || 0) - (a.score || 0));
- wifiClientList.innerHTML = sortedClients.map(c => `
-
+ wifiClientList.innerHTML = sortedClients.map(c => {
+ const ckey = `wifi:${c.mac || c.address}`;
+ const cleared = tscmClearedDevices.has(ckey);
+ return `
+
${c.indicators && c.indicators.length > 0 ? `
${formatIndicators(c.indicators)}
` : ''}
${c.recommended_action && c.recommended_action !== 'monitor' ? `
Action: ${c.recommended_action}
` : ''}
-
- `).join('');
+
+
+
+
+
`;
+ }).join('');
}
document.getElementById('tscmWifiClientCount').textContent = filtered.wifi_clients.length;
@@ -14770,14 +14886,18 @@
} else {
// Sort by score (highest first)
const sorted = [...filtered.bt].sort((a, b) => (b.score || 0) - (a.score || 0));
- btList.innerHTML = sorted.map(d => `
-
+ btList.innerHTML = sorted.map(d => {
+ const dkey = _tscmDeviceKey(d, 'bluetooth');
+ const cleared = tscmClearedDevices.has(dkey);
+ return `
+
${d.indicators && d.indicators.length > 0 ? `
${formatIndicators(d.indicators)}
` : ''}
${d.recommended_action && d.recommended_action !== 'monitor' ? `
Action: ${d.recommended_action}
` : ''}
-
- `).join('');
+
+
+
+
+
`;
+ }).join('');
}
document.getElementById('tscmBtCount').textContent = filtered.bt.length;
@@ -14805,12 +14929,16 @@
} else {
// Sort by score (highest first)
const sorted = [...filtered.rf].sort((a, b) => (b.score || 0) - (a.score || 0));
- rfList.innerHTML = sorted.map(s => `
-
+ rfList.innerHTML = sorted.map(s => {
+ const skey = _tscmDeviceKey(s, 'rf');
+ const cleared = tscmClearedDevices.has(skey);
+ return `
+
${s.indicators && s.indicators.length > 0 ? `
${formatIndicators(s.indicators)}
` : ''}
${s.recommended_action && s.recommended_action !== 'monitor' ? `
Action: ${s.recommended_action}
` : ''}
-
- `).join('');
+
+
+
+
+
`;
+ }).join('');
}
document.getElementById('tscmRfCount').textContent = filtered.rf.length;
@@ -16239,10 +16371,19 @@
return cats.length < 3 ? `categories=${cats.join(',')}` : '';
}
+ function _tscmMetaParams() {
+ const params = [];
+ const site = document.getElementById('tscmSiteName')?.value?.trim();
+ const examiner = document.getElementById('tscmExaminerName')?.value?.trim();
+ if (site) params.push(`site_name=${encodeURIComponent(site)}`);
+ if (examiner) params.push(`examiner_name=${encodeURIComponent(examiner)}`);
+ return params.join('&');
+ }
+
async function tscmDownloadPdf() {
try {
- const catParam = _tscmCategoryParam();
- const response = await fetch(`/tscm/report/pdf${catParam ? '?' + catParam : ''}`);
+ const parts = [_tscmCategoryParam(), _tscmMetaParams()].filter(Boolean);
+ const response = await fetch(`/tscm/report/pdf${parts.length ? '?' + parts.join('&') : ''}`);
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
@@ -16265,8 +16406,8 @@
async function tscmDownloadAnnex(format) {
try {
- const catParam = _tscmCategoryParam();
- const response = await fetch(`/tscm/report/annex?format=${format}${catParam ? '&' + catParam : ''}`);
+ const parts = [_tscmCategoryParam(), _tscmMetaParams()].filter(Boolean);
+ const response = await fetch(`/tscm/report/annex?format=${format}${parts.length ? '&' + parts.join('&') : ''}`);
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
diff --git a/templates/partials/modes/tscm.html b/templates/partials/modes/tscm.html
index 0084eb0..ecb15e9 100644
--- a/templates/partials/modes/tscm.html
+++ b/templates/partials/modes/tscm.html
@@ -4,6 +4,16 @@
TSCM Sweep Alpha
+
+
+
+
+
+
+
+
+
+
+
+
+
Examiner Ignore List
+
Your own devices — excluded from all sweeps and reports. Press Ignore on any detected device to add it here.
+
+
+
+
Advanced
diff --git a/utils/tscm/reports.py b/utils/tscm/reports.py
index 61a0b3c..5cfafca 100644
--- a/utils/tscm/reports.py
+++ b/utils/tscm/reports.py
@@ -75,6 +75,7 @@ class TSCMReport:
# Location and context
location: str | None = None
+ examiner_name: str = ''
baseline_id: int | None = None
baseline_name: str | None = None
@@ -322,6 +323,10 @@ def generate_pdf_content(report: TSCMReport) -> str:
sections.append(f"Report ID: {report.report_id}")
sections.append(f"Generated: {report.generated_at.strftime('%Y-%m-%d %H:%M:%S')}")
sections.append(f"Sweep ID: {report.sweep_id}")
+ if report.location:
+ sections.append(f"Site / Location: {report.location}")
+ if report.examiner_name:
+ sections.append(f"Examiner: {report.examiner_name}")
sections.append("")
# Executive Summary
@@ -614,6 +619,10 @@ class TSCMReportBuilder:
self.report.location = location
return self
+ def set_examiner(self, examiner_name: str) -> TSCMReportBuilder:
+ self.report.examiner_name = examiner_name
+ return self
+
def set_baseline(self, baseline_id: int, baseline_name: str) -> TSCMReportBuilder:
self.report.baseline_id = baseline_id
self.report.baseline_name = baseline_name
@@ -848,6 +857,8 @@ def generate_report(
meeting_summaries: list[dict] | None = None,
correlations: list[dict] | None = None,
categories: list[str] | None = None,
+ site_name: str = '',
+ examiner_name: str = '',
) -> TSCMReport:
"""
Generate a complete TSCM report from sweep data.
@@ -869,6 +880,10 @@ def generate_report(
# Basic info
builder.set_sweep_type(sweep_data.get('sweep_type', 'standard'))
+ if site_name:
+ builder.set_location(site_name)
+ if examiner_name:
+ builder.set_examiner(examiner_name)
# Parse times
started_at = sweep_data.get('started_at')