mirror of
https://github.com/smittix/intercept.git
synced 2026-06-09 22:43:32 -07:00
chore: Bump version to v2.18.0
Bluetooth enhancements (service data inspector, appearance codes, MAC cluster tracking, behavioral flags, IRK badges, distance estimation), ACARS SoapySDR multi-backend support, dump1090 stale process cleanup, GPS error state, and proximity radar/signal card UI improvements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+127
-8
@@ -4119,7 +4119,7 @@ header h1 .tagline {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
height: 140px;
|
||||
max-height: 340px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -4140,7 +4140,9 @@ header h1 .tagline {
|
||||
|
||||
.bt-detail-body {
|
||||
padding: 8px 10px;
|
||||
height: calc(100% - 30px);
|
||||
height: auto;
|
||||
max-height: calc(100% - 30px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bt-detail-placeholder {
|
||||
@@ -4319,6 +4321,110 @@ header h1 .tagline {
|
||||
color: #9fffd1;
|
||||
}
|
||||
|
||||
/* Service Data Inspector */
|
||||
.bt-detail-service-inspector {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.bt-inspector-toggle {
|
||||
font-size: 10px;
|
||||
color: var(--accent-cyan);
|
||||
cursor: pointer;
|
||||
padding: 3px 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bt-inspector-toggle:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bt-inspector-arrow {
|
||||
display: inline-block;
|
||||
transition: transform 0.2s;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.bt-inspector-arrow.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.bt-inspector-content {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 3px;
|
||||
padding: 6px 8px;
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bt-inspector-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
.bt-inspector-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bt-inspector-label {
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-inspector-value {
|
||||
color: var(--text-primary);
|
||||
text-align: right;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* MAC Cluster Badge */
|
||||
.bt-mac-cluster-badge {
|
||||
display: inline-block;
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #f59e0b;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Behavioral Flag Badges */
|
||||
.bt-flag-badge {
|
||||
display: inline-block;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
margin-left: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.bt-flag-badge.persistent {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.bt-flag-badge.beacon-like {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.bt-flag-badge.strong-stable {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* Selected device highlight */
|
||||
.bt-device-row.selected {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
@@ -4469,14 +4575,15 @@ header h1 .tagline {
|
||||
.bt-row-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bt-row-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
@@ -4521,13 +4628,25 @@ header h1 .tagline {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.bt-irk-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: #a855f7;
|
||||
border: 1px solid rgba(168, 85, 247, 0.3);
|
||||
}
|
||||
|
||||
.bt-device-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bt-rssi-container {
|
||||
|
||||
@@ -59,6 +59,11 @@
|
||||
box-shadow: 0 0 6px rgba(255, 170, 0, 0.4);
|
||||
}
|
||||
|
||||
.gps-status-dot.error {
|
||||
background: #ff4444;
|
||||
box-shadow: 0 0 6px rgba(255, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.gps-status-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -36,6 +36,7 @@ const ProximityRadar = (function() {
|
||||
let isHovered = false;
|
||||
let renderPending = false;
|
||||
let renderTimer = null;
|
||||
let interactionLockUntil = 0; // timestamp: suppress renders briefly after click
|
||||
|
||||
/**
|
||||
* Initialize the radar component
|
||||
@@ -119,6 +120,36 @@ const ProximityRadar = (function() {
|
||||
|
||||
svg = container.querySelector('svg');
|
||||
|
||||
// Event delegation on the devices group (survives innerHTML rebuilds)
|
||||
const devicesGroup = svg.querySelector('.radar-devices');
|
||||
|
||||
devicesGroup.addEventListener('click', (e) => {
|
||||
const deviceEl = e.target.closest('.radar-device');
|
||||
if (!deviceEl) return;
|
||||
const deviceKey = deviceEl.getAttribute('data-device-key');
|
||||
if (onDeviceClick && deviceKey) {
|
||||
// Lock out re-renders briefly so the DOM stays stable after click
|
||||
interactionLockUntil = Date.now() + 500;
|
||||
onDeviceClick(deviceKey);
|
||||
}
|
||||
});
|
||||
|
||||
devicesGroup.addEventListener('mouseenter', (e) => {
|
||||
if (e.target.closest('.radar-device')) {
|
||||
isHovered = true;
|
||||
}
|
||||
}, true); // capture phase so we catch enter on child elements
|
||||
|
||||
devicesGroup.addEventListener('mouseleave', (e) => {
|
||||
if (e.target.closest('.radar-device')) {
|
||||
isHovered = false;
|
||||
if (renderPending) {
|
||||
renderPending = false;
|
||||
renderDevices();
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Add sweep animation
|
||||
animateSweep();
|
||||
}
|
||||
@@ -165,8 +196,8 @@ const ProximityRadar = (function() {
|
||||
devices.set(device.device_key, device);
|
||||
});
|
||||
|
||||
// Defer render while user is hovering to prevent DOM rebuild flicker
|
||||
if (isHovered) {
|
||||
// Defer render while user is hovering or interacting to prevent DOM rebuild flicker
|
||||
if (isHovered || Date.now() < interactionLockUntil) {
|
||||
renderPending = true;
|
||||
return;
|
||||
}
|
||||
@@ -229,7 +260,7 @@ const ProximityRadar = (function() {
|
||||
style="cursor: pointer;">
|
||||
<!-- Invisible hit area to prevent hover flicker -->
|
||||
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
|
||||
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
||||
${isSelected ? `<circle class="radar-select-ring" r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
||||
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
|
||||
</circle>` : ''}
|
||||
@@ -244,24 +275,6 @@ const ProximityRadar = (function() {
|
||||
}).join('');
|
||||
|
||||
devicesGroup.innerHTML = dots;
|
||||
|
||||
// Attach event handlers
|
||||
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
const deviceKey = el.getAttribute('data-device-key');
|
||||
if (onDeviceClick && deviceKey) {
|
||||
onDeviceClick(deviceKey);
|
||||
}
|
||||
});
|
||||
el.addEventListener('mouseenter', () => { isHovered = true; });
|
||||
el.addEventListener('mouseleave', () => {
|
||||
isHovered = false;
|
||||
if (renderPending) {
|
||||
renderPending = false;
|
||||
renderDevices();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,19 +358,125 @@ const ProximityRadar = (function() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight a specific device on the radar
|
||||
* Highlight a specific device on the radar (in-place update, no full re-render)
|
||||
*/
|
||||
function highlightDevice(deviceKey) {
|
||||
const prev = selectedDeviceKey;
|
||||
selectedDeviceKey = deviceKey;
|
||||
renderDevices();
|
||||
|
||||
if (!svg) { return; }
|
||||
const devicesGroup = svg.querySelector('.radar-devices');
|
||||
if (!devicesGroup) { return; }
|
||||
|
||||
// Remove highlight from previously selected node
|
||||
if (prev && prev !== deviceKey) {
|
||||
const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`);
|
||||
if (oldEl) {
|
||||
oldEl.classList.remove('selected');
|
||||
// Remove animated selection ring
|
||||
const ring = oldEl.querySelector('.radar-select-ring');
|
||||
if (ring) ring.remove();
|
||||
// Restore dot opacity
|
||||
const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||
const device = devices.get(prev);
|
||||
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||
dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5);
|
||||
dot.setAttribute('stroke', dot.getAttribute('fill'));
|
||||
dot.setAttribute('stroke-width', '1');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add highlight to newly selected node
|
||||
if (deviceKey) {
|
||||
const newEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(deviceKey)}"]`);
|
||||
if (newEl) {
|
||||
applySelectionToElement(newEl, deviceKey);
|
||||
} else {
|
||||
// Node not in DOM yet; full render needed on next cycle
|
||||
renderDevices();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear device highlighting
|
||||
* Apply selection styling to a radar device element in-place
|
||||
*/
|
||||
function applySelectionToElement(el, deviceKey) {
|
||||
el.classList.add('selected');
|
||||
const device = devices.get(deviceKey);
|
||||
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
|
||||
|
||||
// Update dot styling
|
||||
const dot = el.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||
dot.setAttribute('fill-opacity', '1');
|
||||
dot.setAttribute('stroke', '#00d4ff');
|
||||
dot.setAttribute('stroke-width', '2');
|
||||
}
|
||||
|
||||
// Add animated selection ring if not already present
|
||||
if (!el.querySelector('.radar-select-ring')) {
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
const ring = document.createElementNS(ns, 'circle');
|
||||
ring.classList.add('radar-select-ring');
|
||||
ring.setAttribute('r', dotSize + 8);
|
||||
ring.setAttribute('fill', 'none');
|
||||
ring.setAttribute('stroke', '#00d4ff');
|
||||
ring.setAttribute('stroke-width', '2');
|
||||
ring.setAttribute('stroke-opacity', '0.8');
|
||||
|
||||
const animR = document.createElementNS(ns, 'animate');
|
||||
animR.setAttribute('attributeName', 'r');
|
||||
animR.setAttribute('values', `${dotSize + 6};${dotSize + 10};${dotSize + 6}`);
|
||||
animR.setAttribute('dur', '1.5s');
|
||||
animR.setAttribute('repeatCount', 'indefinite');
|
||||
ring.appendChild(animR);
|
||||
|
||||
const animO = document.createElementNS(ns, 'animate');
|
||||
animO.setAttribute('attributeName', 'stroke-opacity');
|
||||
animO.setAttribute('values', '0.8;0.4;0.8');
|
||||
animO.setAttribute('dur', '1.5s');
|
||||
animO.setAttribute('repeatCount', 'indefinite');
|
||||
ring.appendChild(animO);
|
||||
|
||||
// Insert after the hit area
|
||||
const hitArea = el.querySelector('.radar-device-hitarea');
|
||||
if (hitArea && hitArea.nextSibling) {
|
||||
el.insertBefore(ring, hitArea.nextSibling);
|
||||
} else {
|
||||
el.insertBefore(ring, el.firstChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear device highlighting (in-place update, no full re-render)
|
||||
*/
|
||||
function clearHighlight() {
|
||||
const prev = selectedDeviceKey;
|
||||
selectedDeviceKey = null;
|
||||
renderDevices();
|
||||
|
||||
if (!svg || !prev) { return; }
|
||||
const devicesGroup = svg.querySelector('.radar-devices');
|
||||
if (!devicesGroup) { return; }
|
||||
|
||||
const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`);
|
||||
if (oldEl) {
|
||||
oldEl.classList.remove('selected');
|
||||
const ring = oldEl.querySelector('.radar-select-ring');
|
||||
if (ring) ring.remove();
|
||||
const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||
const device = devices.get(prev);
|
||||
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||
dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5);
|
||||
dot.setAttribute('stroke', dot.getAttribute('fill'));
|
||||
dot.setAttribute('stroke-width', '1');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -302,7 +302,13 @@ const SignalCards = (function() {
|
||||
*/
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
let date = new Date(timestamp);
|
||||
// Handle time-only strings like "HH:MM:SS" (from pager/sensor backends)
|
||||
if (isNaN(date.getTime()) && /^\d{1,2}:\d{2}(:\d{2})?$/.test(timestamp)) {
|
||||
const today = new Date();
|
||||
date = new Date(today.toDateString() + ' ' + timestamp);
|
||||
}
|
||||
if (isNaN(date.getTime())) return timestamp;
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now - date) / 1000);
|
||||
|
||||
|
||||
@@ -356,7 +356,9 @@ const BluetoothMode = (function() {
|
||||
|
||||
// Update panel elements
|
||||
document.getElementById('btDetailName').textContent = device.name || formatDeviceId(device.address);
|
||||
document.getElementById('btDetailAddress').textContent = device.address;
|
||||
document.getElementById('btDetailAddress').textContent = isUuidAddress(device)
|
||||
? 'CB: ' + device.address
|
||||
: device.address;
|
||||
|
||||
// RSSI
|
||||
const rssiEl = document.getElementById('btDetailRssi');
|
||||
@@ -458,8 +460,98 @@ const BluetoothMode = (function() {
|
||||
? new Date(device.last_seen).toLocaleTimeString()
|
||||
: '--';
|
||||
|
||||
// New stat cells
|
||||
document.getElementById('btDetailTxPower').textContent = device.tx_power != null
|
||||
? device.tx_power + ' dBm' : '--';
|
||||
document.getElementById('btDetailSeenRate').textContent = device.seen_rate != null
|
||||
? device.seen_rate.toFixed(1) + '/min' : '--';
|
||||
|
||||
// Stability from variance
|
||||
const stabilityEl = document.getElementById('btDetailStability');
|
||||
if (device.rssi_variance != null) {
|
||||
let stabLabel, stabColor;
|
||||
if (device.rssi_variance <= 5) { stabLabel = 'Stable'; stabColor = '#22c55e'; }
|
||||
else if (device.rssi_variance <= 25) { stabLabel = 'Moderate'; stabColor = '#eab308'; }
|
||||
else { stabLabel = 'Unstable'; stabColor = '#ef4444'; }
|
||||
stabilityEl.textContent = stabLabel;
|
||||
stabilityEl.style.color = stabColor;
|
||||
} else {
|
||||
stabilityEl.textContent = '--';
|
||||
stabilityEl.style.color = '';
|
||||
}
|
||||
|
||||
// Distance with confidence
|
||||
const distEl = document.getElementById('btDetailDistance');
|
||||
if (device.estimated_distance_m != null) {
|
||||
const confPct = Math.round((device.distance_confidence || 0) * 100);
|
||||
distEl.textContent = device.estimated_distance_m.toFixed(1) + 'm ±' + confPct + '%';
|
||||
} else {
|
||||
distEl.textContent = '--';
|
||||
}
|
||||
|
||||
// Appearance badge
|
||||
if (device.appearance_name) {
|
||||
badgesHtml += '<span class="bt-detail-badge flag">' + escapeHtml(device.appearance_name) + '</span>';
|
||||
badgesEl.innerHTML = badgesHtml;
|
||||
}
|
||||
|
||||
// MAC cluster indicator
|
||||
const macClusterEl = document.getElementById('btDetailMacCluster');
|
||||
if (macClusterEl) {
|
||||
if (device.mac_cluster_count > 1) {
|
||||
macClusterEl.textContent = device.mac_cluster_count + ' MACs';
|
||||
macClusterEl.style.display = '';
|
||||
} else {
|
||||
macClusterEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Service data inspector
|
||||
const inspectorEl = document.getElementById('btDetailServiceInspector');
|
||||
const inspectorContent = document.getElementById('btInspectorContent');
|
||||
if (inspectorEl && inspectorContent) {
|
||||
const hasData = device.manufacturer_bytes || device.appearance != null ||
|
||||
(device.service_data && Object.keys(device.service_data).length > 0);
|
||||
if (hasData) {
|
||||
inspectorEl.style.display = '';
|
||||
let inspHtml = '';
|
||||
if (device.appearance != null) {
|
||||
const name = device.appearance_name || '';
|
||||
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">Appearance</span><span class="bt-inspector-value">0x' + device.appearance.toString(16).toUpperCase().padStart(4, '0') + (name ? ' (' + escapeHtml(name) + ')' : '') + '</span></div>';
|
||||
}
|
||||
if (device.manufacturer_bytes) {
|
||||
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">Mfr Data</span><span class="bt-inspector-value">' + escapeHtml(device.manufacturer_bytes) + '</span></div>';
|
||||
}
|
||||
if (device.service_data) {
|
||||
Object.entries(device.service_data).forEach(([uuid, hex]) => {
|
||||
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">' + escapeHtml(uuid) + '</span><span class="bt-inspector-value">' + escapeHtml(hex) + '</span></div>';
|
||||
});
|
||||
}
|
||||
inspectorContent.innerHTML = inspHtml;
|
||||
} else {
|
||||
inspectorEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updateWatchlistButton(device);
|
||||
|
||||
// IRK
|
||||
const irkContainer = document.getElementById('btDetailIrk');
|
||||
if (irkContainer) {
|
||||
if (device.has_irk) {
|
||||
irkContainer.style.display = 'block';
|
||||
const irkVal = document.getElementById('btDetailIrkValue');
|
||||
if (irkVal) {
|
||||
const label = device.irk_source_name
|
||||
? device.irk_source_name + ' — ' + device.irk_hex
|
||||
: device.irk_hex;
|
||||
irkVal.textContent = label;
|
||||
}
|
||||
} else {
|
||||
irkContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Services
|
||||
const servicesContainer = document.getElementById('btDetailServices');
|
||||
const servicesList = document.getElementById('btDetailServicesList');
|
||||
@@ -600,9 +692,25 @@ const BluetoothMode = (function() {
|
||||
if (parts.length === 6) {
|
||||
return parts[0] + ':' + parts[1] + ':...:' + parts[4] + ':' + parts[5];
|
||||
}
|
||||
// CoreBluetooth UUID format (8-4-4-4-12)
|
||||
if (/^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(address)) {
|
||||
return address.substring(0, 8) + '...';
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
function isUuidAddress(device) {
|
||||
return device.address_type === 'uuid';
|
||||
}
|
||||
|
||||
function formatAddress(device) {
|
||||
if (!device || !device.address) return '--';
|
||||
if (isUuidAddress(device)) {
|
||||
return device.address.substring(0, 8) + '-...' + device.address.slice(-4);
|
||||
}
|
||||
return device.address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check system capabilities
|
||||
*/
|
||||
@@ -660,6 +768,12 @@ const BluetoothMode = (function() {
|
||||
hideCapabilityWarning();
|
||||
}
|
||||
|
||||
// Show/hide Ubertooth option based on capabilities
|
||||
const ubertoothOption = document.getElementById('btScanModeUbertooth');
|
||||
if (ubertoothOption) {
|
||||
ubertoothOption.style.display = data.has_ubertooth ? '' : 'none';
|
||||
}
|
||||
|
||||
if (scanModeSelect && data.preferred_backend) {
|
||||
const option = scanModeSelect.querySelector(`option[value="${data.preferred_backend}"]`);
|
||||
if (option) option.selected = true;
|
||||
@@ -1085,7 +1199,7 @@ const BluetoothMode = (function() {
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;justify-content:space-between;margin-top:3px;">' +
|
||||
'<span style="font-size:9px;color:#888;font-family:monospace;">' + t.address + '</span>' +
|
||||
'<span style="font-size:9px;color:#888;font-family:monospace;">' + (t.address_type === 'uuid' ? formatAddress(t) : t.address) + '</span>' +
|
||||
'<span style="font-size:9px;color:#666;">Seen ' + (t.seen_count || 0) + 'x</span>' +
|
||||
'</div>' +
|
||||
evidenceHtml +
|
||||
@@ -1142,7 +1256,7 @@ const BluetoothMode = (function() {
|
||||
|
||||
const displayName = device.name || formatDeviceId(device.address);
|
||||
const name = escapeHtml(displayName);
|
||||
const addr = escapeHtml(device.address || 'Unknown');
|
||||
const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown'));
|
||||
const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
|
||||
const seenCount = device.seen_count || 0;
|
||||
const deviceIdEscaped = escapeHtml(device.device_id).replace(/'/g, "\\'");
|
||||
@@ -1167,6 +1281,12 @@ const BluetoothMode = (function() {
|
||||
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:1px 4px;border-radius:3px;margin-left:4px;font-weight:600;">' + typeLabel + '</span>';
|
||||
}
|
||||
|
||||
// IRK badge - show if paired IRK is available
|
||||
let irkBadge = '';
|
||||
if (device.has_irk) {
|
||||
irkBadge = '<span class="bt-irk-badge">IRK</span>';
|
||||
}
|
||||
|
||||
// Risk badge - show if risk score is significant
|
||||
let riskBadge = '';
|
||||
if (riskScore >= 0.3) {
|
||||
@@ -1184,9 +1304,36 @@ const BluetoothMode = (function() {
|
||||
statusDot = '<span class="bt-status-dot known"></span>';
|
||||
}
|
||||
|
||||
// Distance display
|
||||
const distM = device.estimated_distance_m;
|
||||
let distStr = '';
|
||||
if (distM != null) {
|
||||
distStr = '~' + distM.toFixed(1) + 'm';
|
||||
}
|
||||
|
||||
// Behavioral flag badges
|
||||
const hFlags = device.heuristic_flags || [];
|
||||
let flagBadges = '';
|
||||
if (device.is_persistent || hFlags.includes('persistent')) {
|
||||
flagBadges += '<span class="bt-flag-badge persistent">PERSIST</span>';
|
||||
}
|
||||
if (device.is_beacon_like || hFlags.includes('beacon_like')) {
|
||||
flagBadges += '<span class="bt-flag-badge beacon-like">BEACON</span>';
|
||||
}
|
||||
if (device.is_strong_stable || hFlags.includes('strong_stable')) {
|
||||
flagBadges += '<span class="bt-flag-badge strong-stable">STABLE</span>';
|
||||
}
|
||||
|
||||
// MAC cluster badge
|
||||
let clusterBadge = '';
|
||||
if (device.mac_cluster_count > 1) {
|
||||
clusterBadge = '<span class="bt-mac-cluster-badge">' + device.mac_cluster_count + ' MACs</span>';
|
||||
}
|
||||
|
||||
// Build secondary info line
|
||||
let secondaryParts = [addr];
|
||||
if (mfr) secondaryParts.push(mfr);
|
||||
if (distStr) secondaryParts.push(distStr);
|
||||
secondaryParts.push('Seen ' + seenCount + '×');
|
||||
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
||||
// Add agent name if not Local
|
||||
@@ -1205,7 +1352,10 @@ const BluetoothMode = (function() {
|
||||
protoBadge +
|
||||
'<span class="bt-device-name">' + name + '</span>' +
|
||||
trackerBadge +
|
||||
irkBadge +
|
||||
riskBadge +
|
||||
flagBadges +
|
||||
clusterBadge +
|
||||
'</div>' +
|
||||
'<div class="bt-row-right">' +
|
||||
'<div class="bt-rssi-container">' +
|
||||
@@ -1300,6 +1450,18 @@ const BluetoothMode = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the service data inspector panel
|
||||
*/
|
||||
function toggleServiceInspector() {
|
||||
const content = document.getElementById('btInspectorContent');
|
||||
const arrow = document.getElementById('btInspectorArrow');
|
||||
if (!content) return;
|
||||
const open = content.style.display === 'none';
|
||||
content.style.display = open ? '' : 'none';
|
||||
if (arrow) arrow.classList.toggle('open', open);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Agent Handling
|
||||
// ==========================================================================
|
||||
@@ -1425,9 +1587,15 @@ const BluetoothMode = (function() {
|
||||
BtLocate.handoff({
|
||||
device_id: device.device_id,
|
||||
mac_address: device.address,
|
||||
address_type: device.address_type || null,
|
||||
irk_hex: device.irk_hex || null,
|
||||
known_name: device.name || null,
|
||||
known_manufacturer: device.manufacturer_name || null,
|
||||
last_known_rssi: device.rssi_current
|
||||
last_known_rssi: device.rssi_current,
|
||||
tx_power: device.tx_power || null,
|
||||
appearance_name: device.appearance_name || null,
|
||||
fingerprint_id: device.fingerprint_id || null,
|
||||
mac_cluster_count: device.mac_cluster_count || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1447,6 +1615,7 @@ const BluetoothMode = (function() {
|
||||
toggleWatchlist,
|
||||
locateDevice,
|
||||
locateById,
|
||||
toggleServiceInspector,
|
||||
|
||||
// Agent handling
|
||||
handleAgentChange,
|
||||
|
||||
@@ -322,7 +322,8 @@ const BtLocate = (function() {
|
||||
const t = data.target;
|
||||
const name = t.known_name || t.name_pattern || '';
|
||||
const addr = t.mac_address || t.device_id || '';
|
||||
targetEl.textContent = name ? (name + (addr ? ' (' + addr.substring(0, 8) + '...)' : '')) : addr || '--';
|
||||
const addrDisplay = formatAddr(addr);
|
||||
targetEl.textContent = name ? (name + (addrDisplay ? ' (' + addrDisplay + ')' : '')) : addrDisplay || '--';
|
||||
}
|
||||
|
||||
// Environment info
|
||||
@@ -602,6 +603,16 @@ const BtLocate = (function() {
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function isUuid(addr) {
|
||||
return addr && /^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(addr);
|
||||
}
|
||||
|
||||
function formatAddr(addr) {
|
||||
if (!addr) return '';
|
||||
if (isUuid(addr)) return addr.substring(0, 8) + '-...' + addr.slice(-4);
|
||||
return addr;
|
||||
}
|
||||
|
||||
function handoff(deviceInfo) {
|
||||
console.log('[BtLocate] Handoff received:', deviceInfo);
|
||||
handoffData = deviceInfo;
|
||||
@@ -617,15 +628,21 @@ const BtLocate = (function() {
|
||||
const nameEl = document.getElementById('btLocateHandoffName');
|
||||
const metaEl = document.getElementById('btLocateHandoffMeta');
|
||||
if (card) card.style.display = '';
|
||||
if (nameEl) nameEl.textContent = deviceInfo.known_name || deviceInfo.mac_address || 'Unknown';
|
||||
if (nameEl) nameEl.textContent = deviceInfo.known_name || formatAddr(deviceInfo.mac_address) || 'Unknown';
|
||||
if (metaEl) {
|
||||
const parts = [];
|
||||
if (deviceInfo.mac_address) parts.push(deviceInfo.mac_address);
|
||||
if (deviceInfo.mac_address) parts.push(formatAddr(deviceInfo.mac_address));
|
||||
if (deviceInfo.known_manufacturer) parts.push(deviceInfo.known_manufacturer);
|
||||
if (deviceInfo.last_known_rssi != null) parts.push(deviceInfo.last_known_rssi + ' dBm');
|
||||
metaEl.textContent = parts.join(' \u00b7 ');
|
||||
}
|
||||
|
||||
// Auto-fill IRK if available from scanner
|
||||
if (deviceInfo.irk_hex) {
|
||||
const irkInput = document.getElementById('btLocateIrk');
|
||||
if (irkInput) irkInput.value = deviceInfo.irk_hex;
|
||||
}
|
||||
|
||||
// Switch to bt_locate mode
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode('bt_locate');
|
||||
|
||||
+43
-38
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
const GPS = (function() {
|
||||
let eventSource = null;
|
||||
let connected = false;
|
||||
let lastPosition = null;
|
||||
let lastSky = null;
|
||||
@@ -26,6 +25,7 @@ const GPS = (function() {
|
||||
}
|
||||
|
||||
function connect() {
|
||||
updateConnectionUI(false, false, 'connecting');
|
||||
fetch('/gps/auto-connect', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
@@ -40,23 +40,24 @@ const GPS = (function() {
|
||||
lastSky = data.sky;
|
||||
updateSkyUI(data.sky);
|
||||
}
|
||||
startStream();
|
||||
subscribeToStream();
|
||||
// Ensure the global GPS stream is running
|
||||
if (typeof startGpsStream === 'function' && !gpsEventSource) {
|
||||
startGpsStream();
|
||||
}
|
||||
} else {
|
||||
connected = false;
|
||||
updateConnectionUI(false);
|
||||
updateConnectionUI(false, false, 'error', data.message || 'gpsd not available');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
connected = false;
|
||||
updateConnectionUI(false);
|
||||
updateConnectionUI(false, false, 'error', 'Connection failed — is the server running?');
|
||||
});
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
unsubscribeFromStream();
|
||||
fetch('/gps/stop', { method: 'POST' })
|
||||
.then(() => {
|
||||
connected = false;
|
||||
@@ -64,36 +65,36 @@ const GPS = (function() {
|
||||
});
|
||||
}
|
||||
|
||||
function startStream() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
function onGpsStreamData(data) {
|
||||
if (!connected) return;
|
||||
if (data.type === 'position') {
|
||||
lastPosition = data;
|
||||
updatePositionUI(data);
|
||||
updateConnectionUI(true, true);
|
||||
} else if (data.type === 'sky') {
|
||||
lastSky = data;
|
||||
updateSkyUI(data);
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToStream() {
|
||||
// Subscribe to the global GPS stream instead of opening a separate SSE connection
|
||||
if (typeof addGpsStreamSubscriber === 'function') {
|
||||
addGpsStreamSubscriber(onGpsStreamData);
|
||||
}
|
||||
}
|
||||
|
||||
function unsubscribeFromStream() {
|
||||
if (typeof removeGpsStreamSubscriber === 'function') {
|
||||
removeGpsStreamSubscriber(onGpsStreamData);
|
||||
}
|
||||
eventSource = new EventSource('/gps/stream');
|
||||
eventSource.onmessage = function(e) {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'position') {
|
||||
lastPosition = data;
|
||||
updatePositionUI(data);
|
||||
updateConnectionUI(true, true);
|
||||
} else if (data.type === 'sky') {
|
||||
lastSky = data;
|
||||
updateSkyUI(data);
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
eventSource.onerror = function() {
|
||||
// Reconnect handled by browser automatically
|
||||
};
|
||||
}
|
||||
|
||||
// ========================
|
||||
// UI Updates
|
||||
// ========================
|
||||
|
||||
function updateConnectionUI(isConnected, hasFix) {
|
||||
function updateConnectionUI(isConnected, hasFix, state, message) {
|
||||
const dot = document.getElementById('gpsStatusDot');
|
||||
const text = document.getElementById('gpsStatusText');
|
||||
const connectBtn = document.getElementById('gpsConnectBtn');
|
||||
@@ -102,15 +103,22 @@ const GPS = (function() {
|
||||
|
||||
if (dot) {
|
||||
dot.className = 'gps-status-dot';
|
||||
if (isConnected && hasFix) dot.classList.add('connected');
|
||||
if (state === 'connecting') dot.classList.add('waiting');
|
||||
else if (state === 'error') dot.classList.add('error');
|
||||
else if (isConnected && hasFix) dot.classList.add('connected');
|
||||
else if (isConnected) dot.classList.add('waiting');
|
||||
}
|
||||
if (text) {
|
||||
if (isConnected && hasFix) text.textContent = 'Connected (Fix)';
|
||||
if (state === 'connecting') text.textContent = 'Connecting...';
|
||||
else if (state === 'error') text.textContent = message || 'Connection failed';
|
||||
else if (isConnected && hasFix) text.textContent = 'Connected (Fix)';
|
||||
else if (isConnected) text.textContent = 'Connected (No Fix)';
|
||||
else text.textContent = 'Disconnected';
|
||||
}
|
||||
if (connectBtn) connectBtn.style.display = isConnected ? 'none' : '';
|
||||
if (connectBtn) {
|
||||
connectBtn.style.display = isConnected ? 'none' : '';
|
||||
connectBtn.disabled = state === 'connecting';
|
||||
}
|
||||
if (disconnectBtn) disconnectBtn.style.display = isConnected ? '' : 'none';
|
||||
if (devicePath) devicePath.textContent = isConnected ? 'gpsd://localhost:2947' : '';
|
||||
}
|
||||
@@ -386,10 +394,7 @@ const GPS = (function() {
|
||||
// ========================
|
||||
|
||||
function destroy() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
unsubscribeFromStream();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user