mirror of
https://github.com/smittix/intercept.git
synced 2026-06-12 16:03:29 -07:00
Merge branch 'smittix:main' into main
This commit is contained in:
@@ -13,13 +13,11 @@
|
||||
}
|
||||
|
||||
.radar-device {
|
||||
transition: transform 0.2s ease;
|
||||
transform-origin: center center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radar-device:hover {
|
||||
transform: scale(1.2);
|
||||
.radar-device:hover .radar-dot {
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
|
||||
/* Invisible larger hit area to prevent hover flicker */
|
||||
|
||||
@@ -2172,6 +2172,10 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -2182,6 +2186,14 @@ header h1 .tagline {
|
||||
letter-spacing: 1px;
|
||||
transition: all 0.2s ease;
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.control-btn .icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
|
||||
@@ -33,10 +33,7 @@ const ProximityRadar = (function() {
|
||||
let activeFilter = null;
|
||||
let onDeviceClick = null;
|
||||
let selectedDeviceKey = null;
|
||||
let isHovered = false;
|
||||
let renderPending = false;
|
||||
let renderTimer = null;
|
||||
let interactionLockUntil = 0; // timestamp: suppress renders briefly after click
|
||||
|
||||
/**
|
||||
* Initialize the radar component
|
||||
@@ -128,28 +125,10 @@ const ProximityRadar = (function() {
|
||||
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();
|
||||
}
|
||||
@@ -191,17 +170,10 @@ const ProximityRadar = (function() {
|
||||
function updateDevices(deviceList) {
|
||||
if (isPaused) return;
|
||||
|
||||
// Update device map
|
||||
deviceList.forEach(device => {
|
||||
devices.set(device.device_key, device);
|
||||
});
|
||||
|
||||
// Defer render while user is hovering or interacting to prevent DOM rebuild flicker
|
||||
if (isHovered || Date.now() < interactionLockUntil) {
|
||||
renderPending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce rapid updates (e.g. per-device SSE events)
|
||||
if (renderTimer) clearTimeout(renderTimer);
|
||||
renderTimer = setTimeout(() => {
|
||||
@@ -211,7 +183,9 @@ const ProximityRadar = (function() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render device dots on the radar
|
||||
* Render device dots on the radar using in-place DOM updates.
|
||||
* Elements are never destroyed and recreated — only their attributes and
|
||||
* transforms are mutated — so hover state is never disturbed by a render.
|
||||
*/
|
||||
function renderDevices() {
|
||||
const devicesGroup = svg.querySelector('.radar-devices');
|
||||
@@ -219,6 +193,7 @@ const ProximityRadar = (function() {
|
||||
|
||||
const center = CONFIG.size / 2;
|
||||
const maxRadius = center - CONFIG.padding;
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
|
||||
// Filter devices
|
||||
let visibleDevices = Array.from(devices.values());
|
||||
@@ -234,69 +209,195 @@ const ProximityRadar = (function() {
|
||||
visibleDevices = visibleDevices.filter(d => !d.in_baseline);
|
||||
}
|
||||
|
||||
// Build SVG for each device
|
||||
const dots = visibleDevices.map(device => {
|
||||
// Calculate position
|
||||
const { x, y, radius } = calculateDevicePosition(device, center, maxRadius);
|
||||
const visibleKeys = new Set(visibleDevices.map(d => d.device_key));
|
||||
|
||||
// Calculate dot size based on confidence
|
||||
// Remove elements for devices no longer in the visible set
|
||||
devicesGroup.querySelectorAll('.radar-device-wrapper').forEach(el => {
|
||||
if (!visibleKeys.has(el.getAttribute('data-device-key'))) {
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Sort weakest signal first so strongest renders on top (SVG z-order)
|
||||
visibleDevices.sort((a, b) => (a.rssi_current || -100) - (b.rssi_current || -100));
|
||||
|
||||
// Compute all positions upfront so we can spread overlapping dots
|
||||
const posMap = new Map();
|
||||
visibleDevices.forEach(device => {
|
||||
posMap.set(device.device_key, calculateDevicePosition(device, center, maxRadius));
|
||||
});
|
||||
|
||||
// Spread dots that land too close together within the same band.
|
||||
// minGapPx = diameter of largest possible hit area + 2px breathing room.
|
||||
const maxHitArea = CONFIG.dotMaxSize + 4;
|
||||
spreadOverlappingDots(Array.from(posMap.values()), center, maxHitArea * 2 + 2);
|
||||
|
||||
visibleDevices.forEach(device => {
|
||||
const { x, y } = posMap.get(device.device_key);
|
||||
const confidence = device.distance_confidence || 0.5;
|
||||
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
|
||||
|
||||
// Get color based on proximity band
|
||||
const color = getBandColor(device.proximity_band);
|
||||
|
||||
// Check if newly seen (pulse animation)
|
||||
const isNew = device.age_seconds < 5;
|
||||
const pulseClass = isNew ? 'radar-dot-pulse' : '';
|
||||
const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey;
|
||||
const isSelected = !!(selectedDeviceKey && device.device_key === selectedDeviceKey);
|
||||
const hitAreaSize = dotSize + 4;
|
||||
const key = device.device_key;
|
||||
|
||||
// Hit area size (prevents hover flicker when scaling)
|
||||
const hitAreaSize = Math.max(dotSize * 2, 15);
|
||||
const existing = devicesGroup.querySelector(
|
||||
`.radar-device-wrapper[data-device-key="${CSS.escape(key)}"]`
|
||||
);
|
||||
|
||||
return `
|
||||
<g transform="translate(${x}, ${y})">
|
||||
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
|
||||
style="cursor: pointer;">
|
||||
<!-- Invisible hit area to prevent hover flicker -->
|
||||
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
|
||||
${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>` : ''}
|
||||
<circle r="${dotSize}" fill="${color}"
|
||||
fill-opacity="${isSelected ? 1 : 0.4 + confidence * 0.5}"
|
||||
stroke="${isSelected ? '#00d4ff' : color}" stroke-width="${isSelected ? 2 : 1}" />
|
||||
${device.is_new && !isSelected ? `<circle r="${dotSize + 3}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="2,2" />` : ''}
|
||||
<title>${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)</title>
|
||||
</g>
|
||||
</g>
|
||||
`;
|
||||
}).join('');
|
||||
if (existing) {
|
||||
// ── In-place update: mutate attributes, never recreate ──
|
||||
existing.setAttribute('transform', `translate(${x}, ${y})`);
|
||||
|
||||
devicesGroup.innerHTML = dots;
|
||||
const innerG = existing.querySelector('.radar-device');
|
||||
if (innerG) {
|
||||
innerG.className.baseVal =
|
||||
`radar-device${isNew ? ' radar-dot-pulse' : ''}${isSelected ? ' selected' : ''}`;
|
||||
|
||||
const hitArea = innerG.querySelector('.radar-device-hitarea');
|
||||
if (hitArea) hitArea.setAttribute('r', hitAreaSize);
|
||||
|
||||
const dot = innerG.querySelector('.radar-dot');
|
||||
if (dot) {
|
||||
dot.setAttribute('r', dotSize);
|
||||
dot.setAttribute('fill', color);
|
||||
dot.setAttribute('fill-opacity', isSelected ? 1 : 0.4 + confidence * 0.5);
|
||||
dot.setAttribute('stroke', isSelected ? '#00d4ff' : color);
|
||||
dot.setAttribute('stroke-width', isSelected ? 2 : 1);
|
||||
}
|
||||
|
||||
const title = innerG.querySelector('title');
|
||||
if (title) {
|
||||
title.textContent =
|
||||
`${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)`;
|
||||
}
|
||||
|
||||
// Selection ring: add if newly selected, remove if deselected
|
||||
let ring = innerG.querySelector('.radar-select-ring');
|
||||
if (isSelected && !ring) {
|
||||
ring = buildSelectRing(ns, dotSize);
|
||||
const hitAreaEl = innerG.querySelector('.radar-device-hitarea');
|
||||
innerG.insertBefore(ring, hitAreaEl ? hitAreaEl.nextSibling : innerG.firstChild);
|
||||
} else if (!isSelected && ring) {
|
||||
ring.remove();
|
||||
}
|
||||
|
||||
// New-device indicator ring
|
||||
let newRing = innerG.querySelector('.radar-new-ring');
|
||||
if (device.is_new && !isSelected) {
|
||||
if (!newRing) {
|
||||
newRing = document.createElementNS(ns, 'circle');
|
||||
newRing.classList.add('radar-new-ring');
|
||||
newRing.setAttribute('fill', 'none');
|
||||
newRing.setAttribute('stroke', '#3b82f6');
|
||||
newRing.setAttribute('stroke-width', '1');
|
||||
newRing.setAttribute('stroke-dasharray', '2,2');
|
||||
innerG.appendChild(newRing);
|
||||
}
|
||||
newRing.setAttribute('r', dotSize + 3);
|
||||
} else if (newRing) {
|
||||
newRing.remove();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Create new element ──
|
||||
const wrapperG = document.createElementNS(ns, 'g');
|
||||
wrapperG.classList.add('radar-device-wrapper');
|
||||
wrapperG.setAttribute('data-device-key', key);
|
||||
wrapperG.setAttribute('transform', `translate(${x}, ${y})`);
|
||||
|
||||
const innerG = document.createElementNS(ns, 'g');
|
||||
innerG.classList.add('radar-device');
|
||||
if (isNew) innerG.classList.add('radar-dot-pulse');
|
||||
if (isSelected) innerG.classList.add('selected');
|
||||
innerG.setAttribute('data-device-key', escapeAttr(key));
|
||||
innerG.style.cursor = 'pointer';
|
||||
|
||||
const hitArea = document.createElementNS(ns, 'circle');
|
||||
hitArea.classList.add('radar-device-hitarea');
|
||||
hitArea.setAttribute('r', hitAreaSize);
|
||||
hitArea.setAttribute('fill', 'transparent');
|
||||
innerG.appendChild(hitArea);
|
||||
|
||||
if (isSelected) {
|
||||
innerG.appendChild(buildSelectRing(ns, dotSize));
|
||||
}
|
||||
|
||||
const dot = document.createElementNS(ns, 'circle');
|
||||
dot.classList.add('radar-dot');
|
||||
dot.setAttribute('r', dotSize);
|
||||
dot.setAttribute('fill', color);
|
||||
dot.setAttribute('fill-opacity', isSelected ? 1 : 0.4 + confidence * 0.5);
|
||||
dot.setAttribute('stroke', isSelected ? '#00d4ff' : color);
|
||||
dot.setAttribute('stroke-width', isSelected ? 2 : 1);
|
||||
innerG.appendChild(dot);
|
||||
|
||||
if (device.is_new && !isSelected) {
|
||||
const newRing = document.createElementNS(ns, 'circle');
|
||||
newRing.classList.add('radar-new-ring');
|
||||
newRing.setAttribute('r', dotSize + 3);
|
||||
newRing.setAttribute('fill', 'none');
|
||||
newRing.setAttribute('stroke', '#3b82f6');
|
||||
newRing.setAttribute('stroke-width', '1');
|
||||
newRing.setAttribute('stroke-dasharray', '2,2');
|
||||
innerG.appendChild(newRing);
|
||||
}
|
||||
|
||||
const title = document.createElementNS(ns, 'title');
|
||||
title.textContent =
|
||||
`${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)`;
|
||||
innerG.appendChild(title);
|
||||
|
||||
wrapperG.appendChild(innerG);
|
||||
devicesGroup.appendChild(wrapperG);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an animated SVG selection ring element
|
||||
*/
|
||||
function buildSelectRing(ns, dotSize) {
|
||||
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);
|
||||
|
||||
return ring;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate device position on radar
|
||||
*/
|
||||
function calculateDevicePosition(device, center, maxRadius) {
|
||||
// Calculate radius based on proximity band/distance
|
||||
// Position is band-only — the band is computed server-side from rssi_ema
|
||||
// (already smoothed), so it changes infrequently and never jitters.
|
||||
// Using raw estimated_distance_m caused constant micro-movement as RSSI
|
||||
// fluctuated on every update cycle.
|
||||
let radiusRatio;
|
||||
const band = device.proximity_band || 'unknown';
|
||||
|
||||
if (device.estimated_distance_m != null) {
|
||||
// Use actual distance (log scale)
|
||||
const maxDistance = 15;
|
||||
radiusRatio = Math.min(1, Math.log10(device.estimated_distance_m + 1) / Math.log10(maxDistance + 1));
|
||||
} else {
|
||||
// Use band-based positioning
|
||||
switch (band) {
|
||||
case 'immediate': radiusRatio = 0.15; break;
|
||||
case 'near': radiusRatio = 0.4; break;
|
||||
case 'far': radiusRatio = 0.7; break;
|
||||
default: radiusRatio = 0.9; break;
|
||||
}
|
||||
switch (device.proximity_band || 'unknown') {
|
||||
case 'immediate': radiusRatio = 0.15; break;
|
||||
case 'near': radiusRatio = 0.40; break;
|
||||
case 'far': radiusRatio = 0.70; break;
|
||||
default: radiusRatio = 0.90; break;
|
||||
}
|
||||
|
||||
// Calculate angle based on device key hash (stable positioning)
|
||||
@@ -306,7 +407,53 @@ const ProximityRadar = (function() {
|
||||
const x = center + Math.sin(angle) * radius;
|
||||
const y = center - Math.cos(angle) * radius;
|
||||
|
||||
return { x, y, radius };
|
||||
return { x, y, angle, radius };
|
||||
}
|
||||
|
||||
/**
|
||||
* Spread dots within the same band that land too close together.
|
||||
* Groups entries by radius, sorts by angle, then nudges neighbours
|
||||
* apart until the arc gap between any two dots is at least minGapPx.
|
||||
* Positions are updated in-place on the entry objects.
|
||||
*/
|
||||
function spreadOverlappingDots(entries, center, minGapPx) {
|
||||
const groups = new Map();
|
||||
entries.forEach(e => {
|
||||
const key = Math.round(e.radius);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(e);
|
||||
});
|
||||
|
||||
groups.forEach((group, r) => {
|
||||
if (group.length < 2 || r < 1) return;
|
||||
const minSep = minGapPx / r; // radians
|
||||
|
||||
group.sort((a, b) => a.angle - b.angle);
|
||||
|
||||
// Iterative push-apart (up to 8 passes)
|
||||
for (let iter = 0; iter < 8; iter++) {
|
||||
let moved = false;
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
const j = (i + 1) % group.length;
|
||||
let gap = group[j].angle - group[i].angle;
|
||||
if (gap < 0) gap += 2 * Math.PI;
|
||||
if (gap < minSep) {
|
||||
const push = (minSep - gap) / 2;
|
||||
group[i].angle -= push;
|
||||
group[j].angle += push;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
|
||||
// Normalise angles back to [0, 2π) and recompute x/y
|
||||
group.forEach(e => {
|
||||
e.angle = ((e.angle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
||||
e.x = center + Math.sin(e.angle) * r;
|
||||
e.y = center - Math.cos(e.angle) * r;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -289,23 +289,10 @@ const SignalGuess = (function() {
|
||||
regions: ['GLOBAL']
|
||||
},
|
||||
|
||||
// LoRaWAN
|
||||
{
|
||||
label: 'LoRaWAN / LoRa Device',
|
||||
tags: ['iot', 'lora', 'lpwan', 'telemetry'],
|
||||
description: 'LoRa long-range IoT device',
|
||||
frequencyRanges: [[863000000, 870000000], [902000000, 928000000]],
|
||||
modulationHints: ['LoRa', 'CSS', 'FSK'],
|
||||
bandwidthRange: [125000, 500000],
|
||||
baseScore: 11,
|
||||
isBurstType: true,
|
||||
regions: ['UK/EU', 'US']
|
||||
},
|
||||
|
||||
// Key Fob
|
||||
{
|
||||
label: 'Remote Control / Key Fob',
|
||||
tags: ['remote', 'keyfob', 'automotive', 'burst', 'ism'],
|
||||
// Key Fob
|
||||
{
|
||||
label: 'Remote Control / Key Fob',
|
||||
tags: ['remote', 'keyfob', 'automotive', 'burst', 'ism'],
|
||||
description: 'Wireless remote control or vehicle key fob',
|
||||
frequencyRanges: [[314900000, 315100000], [433050000, 434790000], [867000000, 869000000]],
|
||||
modulationHints: ['OOK', 'ASK', 'FSK', 'rolling'],
|
||||
|
||||
@@ -24,7 +24,6 @@ const CommandPalette = (function() {
|
||||
{ mode: 'sstv_general', label: 'HF SSTV' },
|
||||
{ mode: 'gps', label: 'GPS' },
|
||||
{ mode: 'meshtastic', label: 'Meshtastic' },
|
||||
{ mode: 'dmr', label: 'Digital Voice' },
|
||||
{ mode: 'websdr', label: 'WebSDR' },
|
||||
{ mode: 'analytics', label: 'Analytics' },
|
||||
{ mode: 'spaceweather', label: 'Space Weather' },
|
||||
|
||||
@@ -2,7 +2,7 @@ const RunState = (function() {
|
||||
'use strict';
|
||||
|
||||
const REFRESH_MS = 5000;
|
||||
const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'dmr', 'subghz'];
|
||||
const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'subghz'];
|
||||
const MODE_ALIASES = {
|
||||
bt: 'bluetooth',
|
||||
bt_locate: 'bluetooth',
|
||||
@@ -21,7 +21,6 @@ const RunState = (function() {
|
||||
vdl2: 'VDL2',
|
||||
aprs: 'APRS',
|
||||
dsc: 'DSC',
|
||||
dmr: 'DMR',
|
||||
subghz: 'SubGHz',
|
||||
};
|
||||
|
||||
@@ -181,7 +180,6 @@ const RunState = (function() {
|
||||
if (normalized.includes('aprs')) return 'aprs';
|
||||
if (normalized.includes('dsc')) return 'dsc';
|
||||
if (normalized.includes('subghz')) return 'subghz';
|
||||
if (normalized.includes('dmr')) return 'dmr';
|
||||
if (normalized.includes('433')) return 'sensor';
|
||||
return 'pager';
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ const Settings = {
|
||||
// Default settings
|
||||
defaults: {
|
||||
'offline.enabled': false,
|
||||
'offline.assets_source': 'cdn',
|
||||
'offline.fonts_source': 'cdn',
|
||||
'offline.assets_source': 'local',
|
||||
'offline.fonts_source': 'local',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
},
|
||||
|
||||
+311
-77
@@ -31,13 +31,19 @@ const BtLocate = (function() {
|
||||
let movementHeadMarker = null;
|
||||
let strongestMarker = null;
|
||||
let confidenceCircle = null;
|
||||
let heatmapEnabled = true;
|
||||
let heatmapEnabled = false;
|
||||
let movementEnabled = true;
|
||||
let autoFollowEnabled = true;
|
||||
let smoothingEnabled = true;
|
||||
let lastRenderedDetectionKey = null;
|
||||
let pendingHeatSync = false;
|
||||
let mapStabilizeTimer = null;
|
||||
let modeActive = false;
|
||||
let queuedDetection = null;
|
||||
let queuedDetectionOptions = null;
|
||||
let queuedDetectionTimer = null;
|
||||
let lastDetectionRenderAt = 0;
|
||||
let startRequestInFlight = false;
|
||||
|
||||
const MAX_HEAT_POINTS = 1200;
|
||||
const MAX_TRAIL_POINTS = 1200;
|
||||
@@ -45,8 +51,9 @@ const BtLocate = (function() {
|
||||
const OUTLIER_HARD_JUMP_METERS = 2000;
|
||||
const OUTLIER_SOFT_JUMP_METERS = 450;
|
||||
const OUTLIER_MAX_SPEED_MPS = 50;
|
||||
const MAP_STABILIZE_INTERVAL_MS = 150;
|
||||
const MAP_STABILIZE_ATTEMPTS = 28;
|
||||
const MAP_STABILIZE_INTERVAL_MS = 220;
|
||||
const MAP_STABILIZE_ATTEMPTS = 8;
|
||||
const MIN_DETECTION_RENDER_MS = 220;
|
||||
const OVERLAY_STORAGE_KEYS = {
|
||||
heatmap: 'btLocateHeatmapEnabled',
|
||||
movement: 'btLocateMovementEnabled',
|
||||
@@ -66,6 +73,20 @@ const BtLocate = (function() {
|
||||
1.0: '#ef4444',
|
||||
},
|
||||
};
|
||||
const BT_LOCATE_DEBUG = (() => {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
return params.get('btlocate_debug') === '1' ||
|
||||
localStorage.getItem('btLocateDebug') === 'true';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
function debugLog() {
|
||||
if (!BT_LOCATE_DEBUG) return;
|
||||
console.log.apply(console, arguments);
|
||||
}
|
||||
|
||||
function getMapContainer() {
|
||||
if (!map || typeof map.getContainer !== 'function') return null;
|
||||
@@ -84,7 +105,71 @@ const BtLocate = (function() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function statusUrl() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
const debugFlag = params.get('btlocate_debug') === '1' ||
|
||||
localStorage.getItem('btLocateDebug') === 'true';
|
||||
return debugFlag ? '/bt_locate/status?debug=1' : '/bt_locate/status';
|
||||
} catch (_) {
|
||||
return '/bt_locate/status';
|
||||
}
|
||||
}
|
||||
|
||||
function coerceLocation(lat, lon) {
|
||||
const nLat = Number(lat);
|
||||
const nLon = Number(lon);
|
||||
if (!isFinite(nLat) || !isFinite(nLon)) return null;
|
||||
if (nLat < -90 || nLat > 90 || nLon < -180 || nLon > 180) return null;
|
||||
return { lat: nLat, lon: nLon };
|
||||
}
|
||||
|
||||
function resolveFallbackLocation() {
|
||||
try {
|
||||
if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) {
|
||||
const shared = ObserverLocation.getShared();
|
||||
const normalized = coerceLocation(shared?.lat, shared?.lon);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem('observerLocation');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
const normalized = coerceLocation(parsed?.lat, parsed?.lon);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
const normalized = coerceLocation(
|
||||
localStorage.getItem('observerLat'),
|
||||
localStorage.getItem('observerLon')
|
||||
);
|
||||
if (normalized) return normalized;
|
||||
} catch (_) {}
|
||||
|
||||
return coerceLocation(window.INTERCEPT_DEFAULT_LAT, window.INTERCEPT_DEFAULT_LON);
|
||||
}
|
||||
|
||||
function setStartButtonBusy(busy) {
|
||||
const startBtn = document.getElementById('btLocateStartBtn');
|
||||
if (!startBtn) return;
|
||||
if (busy) {
|
||||
if (!startBtn.dataset.defaultLabel) {
|
||||
startBtn.dataset.defaultLabel = startBtn.textContent || 'Start Locate';
|
||||
}
|
||||
startBtn.disabled = true;
|
||||
startBtn.textContent = 'Starting...';
|
||||
return;
|
||||
}
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = startBtn.dataset.defaultLabel || 'Start Locate';
|
||||
}
|
||||
|
||||
function init() {
|
||||
modeActive = true;
|
||||
loadOverlayPreferences();
|
||||
syncOverlayControls();
|
||||
|
||||
@@ -158,10 +243,10 @@ const BtLocate = (function() {
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
function checkStatus() {
|
||||
fetch('/bt_locate/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
function checkStatus() {
|
||||
fetch(statusUrl())
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.active) {
|
||||
sessionStartedAt = data.started_at ? new Date(data.started_at).getTime() : Date.now();
|
||||
showActiveUI();
|
||||
@@ -171,12 +256,25 @@ const BtLocate = (function() {
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function start() {
|
||||
const mac = document.getElementById('btLocateMac')?.value.trim();
|
||||
const namePattern = document.getElementById('btLocateNamePattern')?.value.trim();
|
||||
const irk = document.getElementById('btLocateIrk')?.value.trim();
|
||||
}
|
||||
|
||||
function normalizeMacInput(value) {
|
||||
const raw = (value || '').trim().toUpperCase().replace(/-/g, ':');
|
||||
if (!raw) return '';
|
||||
const compact = raw.replace(/[^0-9A-F]/g, '');
|
||||
if (compact.length === 12) {
|
||||
return compact.match(/.{1,2}/g).join(':');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (startRequestInFlight) {
|
||||
return;
|
||||
}
|
||||
const mac = normalizeMacInput(document.getElementById('btLocateMac')?.value);
|
||||
const namePattern = document.getElementById('btLocateNamePattern')?.value.trim();
|
||||
const irk = document.getElementById('btLocateIrk')?.value.trim();
|
||||
|
||||
const body = { environment: currentEnvironment };
|
||||
if (mac) body.mac_address = mac;
|
||||
@@ -188,30 +286,44 @@ const BtLocate = (function() {
|
||||
if (handoffData?.known_name) body.known_name = handoffData.known_name;
|
||||
if (handoffData?.known_manufacturer) body.known_manufacturer = handoffData.known_manufacturer;
|
||||
if (handoffData?.last_known_rssi) body.last_known_rssi = handoffData.last_known_rssi;
|
||||
|
||||
// Include user location as fallback when GPS unavailable
|
||||
const userLat = localStorage.getItem('observerLat');
|
||||
const userLon = localStorage.getItem('observerLon');
|
||||
if (userLat !== null && userLon !== null) {
|
||||
body.fallback_lat = parseFloat(userLat);
|
||||
body.fallback_lon = parseFloat(userLon);
|
||||
|
||||
// Include user location as fallback when GPS unavailable
|
||||
const fallbackLocation = resolveFallbackLocation();
|
||||
if (fallbackLocation) {
|
||||
body.fallback_lat = fallbackLocation.lat;
|
||||
body.fallback_lon = fallbackLocation.lon;
|
||||
}
|
||||
|
||||
console.log('[BtLocate] Starting with body:', body);
|
||||
debugLog('[BtLocate] Starting with body:', body);
|
||||
|
||||
if (!body.mac_address && !body.name_pattern && !body.irk_hex &&
|
||||
!body.device_id && !body.device_key && !body.fingerprint_id) {
|
||||
alert('Please provide at least one target identifier or use hand-off from Bluetooth mode.');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/bt_locate/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
|
||||
startRequestInFlight = true;
|
||||
setStartButtonBusy(true);
|
||||
|
||||
fetch('/bt_locate/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then(async (r) => {
|
||||
let data = null;
|
||||
try {
|
||||
data = await r.json();
|
||||
} catch (_) {
|
||||
data = {};
|
||||
}
|
||||
if (!r.ok || data.status !== 'started') {
|
||||
const message = data.error || data.message || ('HTTP ' + r.status);
|
||||
throw new Error(message);
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.then(data => {
|
||||
if (data.status === 'started') {
|
||||
sessionStartedAt = data.session?.started_at ? new Date(data.session.started_at).getTime() : Date.now();
|
||||
showActiveUI();
|
||||
@@ -222,36 +334,60 @@ const BtLocate = (function() {
|
||||
updateScanStatus(data.session);
|
||||
// Restore any existing trail (e.g. from a stop/start cycle)
|
||||
restoreTrail();
|
||||
pollStatus();
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[BtLocate] Start error:', err));
|
||||
.catch(err => {
|
||||
console.error('[BtLocate] Start error:', err);
|
||||
alert('BT Locate failed to start: ' + (err?.message || 'Unknown error'));
|
||||
showIdleUI();
|
||||
})
|
||||
.finally(() => {
|
||||
startRequestInFlight = false;
|
||||
setStartButtonBusy(false);
|
||||
});
|
||||
}
|
||||
|
||||
function stop() {
|
||||
fetch('/bt_locate/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
if (queuedDetectionTimer) {
|
||||
clearTimeout(queuedDetectionTimer);
|
||||
queuedDetectionTimer = null;
|
||||
}
|
||||
queuedDetection = null;
|
||||
queuedDetectionOptions = null;
|
||||
showIdleUI();
|
||||
disconnectSSE();
|
||||
stopAudio();
|
||||
})
|
||||
.catch(err => console.error('[BtLocate] Stop error:', err));
|
||||
}
|
||||
|
||||
function stop() {
|
||||
fetch('/bt_locate/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
showIdleUI();
|
||||
disconnectSSE();
|
||||
stopAudio();
|
||||
})
|
||||
.catch(err => console.error('[BtLocate] Stop error:', err));
|
||||
}
|
||||
|
||||
function showActiveUI() {
|
||||
const startBtn = document.getElementById('btLocateStartBtn');
|
||||
const stopBtn = document.getElementById('btLocateStopBtn');
|
||||
if (startBtn) startBtn.style.display = 'none';
|
||||
function showActiveUI() {
|
||||
setStartButtonBusy(false);
|
||||
const startBtn = document.getElementById('btLocateStartBtn');
|
||||
const stopBtn = document.getElementById('btLocateStopBtn');
|
||||
if (startBtn) startBtn.style.display = 'none';
|
||||
if (stopBtn) stopBtn.style.display = 'inline-block';
|
||||
show('btLocateHud');
|
||||
}
|
||||
|
||||
function showIdleUI() {
|
||||
const startBtn = document.getElementById('btLocateStartBtn');
|
||||
const stopBtn = document.getElementById('btLocateStopBtn');
|
||||
if (startBtn) startBtn.style.display = 'inline-block';
|
||||
if (stopBtn) stopBtn.style.display = 'none';
|
||||
hide('btLocateHud');
|
||||
function showIdleUI() {
|
||||
startRequestInFlight = false;
|
||||
setStartButtonBusy(false);
|
||||
if (queuedDetectionTimer) {
|
||||
clearTimeout(queuedDetectionTimer);
|
||||
queuedDetectionTimer = null;
|
||||
}
|
||||
queuedDetection = null;
|
||||
queuedDetectionOptions = null;
|
||||
const startBtn = document.getElementById('btLocateStartBtn');
|
||||
const stopBtn = document.getElementById('btLocateStopBtn');
|
||||
if (startBtn) startBtn.style.display = 'inline-block';
|
||||
if (stopBtn) stopBtn.style.display = 'none';
|
||||
hide('btLocateHud');
|
||||
hide('btLocateScanStatus');
|
||||
}
|
||||
|
||||
@@ -276,13 +412,13 @@ const BtLocate = (function() {
|
||||
|
||||
function connectSSE() {
|
||||
if (eventSource) eventSource.close();
|
||||
console.log('[BtLocate] Connecting SSE stream');
|
||||
debugLog('[BtLocate] Connecting SSE stream');
|
||||
eventSource = new EventSource('/bt_locate/stream');
|
||||
|
||||
eventSource.addEventListener('detection', function(e) {
|
||||
try {
|
||||
const event = JSON.parse(e.data);
|
||||
console.log('[BtLocate] Detection event:', event);
|
||||
debugLog('[BtLocate] Detection event:', event);
|
||||
handleDetection(event);
|
||||
} catch (err) {
|
||||
console.error('[BtLocate] Parse error:', err);
|
||||
@@ -295,15 +431,16 @@ const BtLocate = (function() {
|
||||
});
|
||||
|
||||
eventSource.onerror = function() {
|
||||
console.warn('[BtLocate] SSE error, polling fallback active');
|
||||
debugLog('[BtLocate] SSE error, polling fallback active');
|
||||
if (eventSource && eventSource.readyState === EventSource.CLOSED) {
|
||||
eventSource = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling fallback (catches data even if SSE fails)
|
||||
startPolling();
|
||||
}
|
||||
// Start polling fallback (catches data even if SSE fails)
|
||||
startPolling();
|
||||
pollStatus();
|
||||
}
|
||||
|
||||
function disconnectSSE() {
|
||||
if (eventSource) {
|
||||
@@ -349,10 +486,10 @@ const BtLocate = (function() {
|
||||
if (timeEl) timeEl.textContent = mins + ':' + String(secs).padStart(2, '0');
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
fetch('/bt_locate/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
function pollStatus() {
|
||||
fetch(statusUrl())
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data.active) {
|
||||
showIdleUI();
|
||||
disconnectSSE();
|
||||
@@ -447,7 +584,42 @@ const BtLocate = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
function flushQueuedDetection() {
|
||||
if (!queuedDetection) return;
|
||||
const event = queuedDetection;
|
||||
const options = queuedDetectionOptions || {};
|
||||
queuedDetection = null;
|
||||
queuedDetectionOptions = null;
|
||||
queuedDetectionTimer = null;
|
||||
renderDetection(event, options);
|
||||
}
|
||||
|
||||
function handleDetection(event, options = {}) {
|
||||
if (!modeActive) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (options.force || (now - lastDetectionRenderAt) >= MIN_DETECTION_RENDER_MS) {
|
||||
if (queuedDetectionTimer) {
|
||||
clearTimeout(queuedDetectionTimer);
|
||||
queuedDetectionTimer = null;
|
||||
}
|
||||
queuedDetection = null;
|
||||
queuedDetectionOptions = null;
|
||||
renderDetection(event, options);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep only the freshest event while throttled.
|
||||
queuedDetection = event;
|
||||
queuedDetectionOptions = options;
|
||||
if (!queuedDetectionTimer) {
|
||||
queuedDetectionTimer = setTimeout(flushQueuedDetection, MIN_DETECTION_RENDER_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetection(event, options = {}) {
|
||||
lastDetectionRenderAt = Date.now();
|
||||
const d = event?.data || event;
|
||||
if (!d) return;
|
||||
const detectionKey = buildDetectionKey(d);
|
||||
@@ -473,7 +645,7 @@ const BtLocate = (function() {
|
||||
try {
|
||||
mapPointAdded = addMapMarker(d, { suppressFollow: options.suppressFollow === true });
|
||||
} catch (error) {
|
||||
console.warn('[BtLocate] Map update skipped:', error);
|
||||
debugLog('[BtLocate] Map update skipped:', error);
|
||||
mapPointAdded = false;
|
||||
}
|
||||
}
|
||||
@@ -878,7 +1050,7 @@ const BtLocate = (function() {
|
||||
}
|
||||
|
||||
function ensureHeatLayer() {
|
||||
if (!map || typeof L === 'undefined' || typeof L.heatLayer !== 'function') return;
|
||||
if (!map || !heatmapEnabled || typeof L === 'undefined' || typeof L.heatLayer !== 'function') return;
|
||||
if (!heatLayer) {
|
||||
heatLayer = L.heatLayer([], HEAT_LAYER_OPTIONS);
|
||||
}
|
||||
@@ -886,9 +1058,19 @@ const BtLocate = (function() {
|
||||
|
||||
function syncHeatLayer() {
|
||||
if (!map) return;
|
||||
if (!heatmapEnabled) {
|
||||
if (heatLayer && map.hasLayer(heatLayer)) {
|
||||
map.removeLayer(heatLayer);
|
||||
}
|
||||
pendingHeatSync = false;
|
||||
return;
|
||||
}
|
||||
ensureHeatLayer();
|
||||
if (!heatLayer) return;
|
||||
if (!isMapContainerVisible()) {
|
||||
if (!modeActive || !isMapContainerVisible()) {
|
||||
if (map.hasLayer(heatLayer)) {
|
||||
map.removeLayer(heatLayer);
|
||||
}
|
||||
pendingHeatSync = true;
|
||||
return;
|
||||
}
|
||||
@@ -899,6 +1081,13 @@ const BtLocate = (function() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(heatPoints) || heatPoints.length === 0) {
|
||||
if (map.hasLayer(heatLayer)) {
|
||||
map.removeLayer(heatLayer);
|
||||
}
|
||||
pendingHeatSync = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
heatLayer.setLatLngs(heatPoints);
|
||||
if (heatmapEnabled) {
|
||||
@@ -914,10 +1103,52 @@ const BtLocate = (function() {
|
||||
if (map.hasLayer(heatLayer)) {
|
||||
map.removeLayer(heatLayer);
|
||||
}
|
||||
console.warn('[BtLocate] Heatmap redraw deferred:', error);
|
||||
debugLog('[BtLocate] Heatmap redraw deferred:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveMode(active) {
|
||||
modeActive = !!active;
|
||||
if (!map) return;
|
||||
|
||||
if (!modeActive) {
|
||||
stopMapStabilization();
|
||||
if (queuedDetectionTimer) {
|
||||
clearTimeout(queuedDetectionTimer);
|
||||
queuedDetectionTimer = null;
|
||||
}
|
||||
queuedDetection = null;
|
||||
queuedDetectionOptions = null;
|
||||
// Pause BT Locate frontend work when mode is hidden.
|
||||
disconnectSSE();
|
||||
if (heatLayer && map.hasLayer(heatLayer)) {
|
||||
map.removeLayer(heatLayer);
|
||||
}
|
||||
pendingHeatSync = true;
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!modeActive) return;
|
||||
safeInvalidateMap();
|
||||
flushPendingHeatSync();
|
||||
syncHeatLayer();
|
||||
syncMovementLayer();
|
||||
syncStrongestMarker();
|
||||
updateConfidenceLayer();
|
||||
scheduleMapStabilization(8);
|
||||
checkStatus();
|
||||
}, 80);
|
||||
|
||||
// A second pass after layout settles (sidebar/visual transitions).
|
||||
setTimeout(() => {
|
||||
if (!modeActive) return;
|
||||
safeInvalidateMap();
|
||||
flushPendingHeatSync();
|
||||
syncHeatLayer();
|
||||
}, 260);
|
||||
}
|
||||
|
||||
function isMapRenderable() {
|
||||
if (!map || !isMapContainerVisible()) return false;
|
||||
if (typeof map.getSize === 'function') {
|
||||
@@ -1370,7 +1601,7 @@ const BtLocate = (function() {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(title, message);
|
||||
} else {
|
||||
console.log('[BtLocate] ' + title + ': ' + message);
|
||||
debugLog('[BtLocate] ' + title + ': ' + message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1461,7 +1692,7 @@ const BtLocate = (function() {
|
||||
// Resume must happen within a user gesture handler
|
||||
const ctx = audioCtx;
|
||||
ctx.resume().then(() => {
|
||||
console.log('[BtLocate] AudioContext state:', ctx.state);
|
||||
debugLog('[BtLocate] AudioContext state:', ctx.state);
|
||||
// Confirmation beep so user knows audio is working
|
||||
playTone(600, 0.08);
|
||||
});
|
||||
@@ -1482,14 +1713,14 @@ const BtLocate = (function() {
|
||||
btn.classList.toggle('active', btn.dataset.env === env);
|
||||
});
|
||||
// Push to running session if active
|
||||
fetch('/bt_locate/status').then(r => r.json()).then(data => {
|
||||
if (data.active) {
|
||||
fetch('/bt_locate/environment', {
|
||||
fetch(statusUrl()).then(r => r.json()).then(data => {
|
||||
if (data.active) {
|
||||
fetch('/bt_locate/environment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ environment: env }),
|
||||
}).then(r => r.json()).then(res => {
|
||||
console.log('[BtLocate] Environment updated:', res);
|
||||
debugLog('[BtLocate] Environment updated:', res);
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
@@ -1506,7 +1737,7 @@ const BtLocate = (function() {
|
||||
}
|
||||
|
||||
function handoff(deviceInfo) {
|
||||
console.log('[BtLocate] Handoff received:', deviceInfo);
|
||||
debugLog('[BtLocate] Handoff received:', deviceInfo);
|
||||
handoffData = deviceInfo;
|
||||
|
||||
// Populate fields
|
||||
@@ -1633,10 +1864,11 @@ const BtLocate = (function() {
|
||||
scheduleMapStabilization(8);
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
start,
|
||||
stop,
|
||||
return {
|
||||
init,
|
||||
setActiveMode,
|
||||
start,
|
||||
stop,
|
||||
handoff,
|
||||
clearHandoff,
|
||||
setEnvironment,
|
||||
@@ -1651,4 +1883,6 @@ const BtLocate = (function() {
|
||||
invalidateMap,
|
||||
fetchPairedIrks,
|
||||
};
|
||||
})();
|
||||
})();
|
||||
|
||||
window.BtLocate = BtLocate;
|
||||
|
||||
@@ -1,852 +0,0 @@
|
||||
/**
|
||||
* Intercept - DMR / Digital Voice Mode
|
||||
* Decoding DMR, P25, NXDN, D-STAR digital voice protocols
|
||||
*/
|
||||
|
||||
// ============== STATE ==============
|
||||
let isDmrRunning = false;
|
||||
let dmrEventSource = null;
|
||||
let dmrCallCount = 0;
|
||||
let dmrSyncCount = 0;
|
||||
let dmrCallHistory = [];
|
||||
let dmrCurrentProtocol = '--';
|
||||
let dmrModeLabel = 'dmr'; // Protocol label for device reservation
|
||||
let dmrHasAudio = false;
|
||||
|
||||
// ============== BOOKMARKS ==============
|
||||
let dmrBookmarks = [];
|
||||
const DMR_BOOKMARKS_KEY = 'dmrBookmarks';
|
||||
const DMR_SETTINGS_KEY = 'dmrSettings';
|
||||
const DMR_BOOKMARK_PROTOCOLS = new Set(['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']);
|
||||
|
||||
// ============== SYNTHESIZER STATE ==============
|
||||
let dmrSynthCanvas = null;
|
||||
let dmrSynthCtx = null;
|
||||
let dmrSynthBars = [];
|
||||
let dmrSynthAnimationId = null;
|
||||
let dmrSynthInitialized = false;
|
||||
let dmrActivityLevel = 0;
|
||||
let dmrActivityTarget = 0;
|
||||
let dmrEventType = 'idle';
|
||||
let dmrLastEventTime = 0;
|
||||
const DMR_BAR_COUNT = 48;
|
||||
const DMR_DECAY_RATE = 0.015;
|
||||
const DMR_BURST_SYNC = 0.6;
|
||||
const DMR_BURST_CALL = 0.85;
|
||||
const DMR_BURST_VOICE = 0.95;
|
||||
|
||||
// ============== TOOLS CHECK ==============
|
||||
|
||||
function checkDmrTools() {
|
||||
fetch('/dmr/tools')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const warning = document.getElementById('dmrToolsWarning');
|
||||
const warningText = document.getElementById('dmrToolsWarningText');
|
||||
if (!warning) return;
|
||||
|
||||
const selectedType = (typeof getSelectedSDRType === 'function')
|
||||
? getSelectedSDRType()
|
||||
: 'rtlsdr';
|
||||
const missing = [];
|
||||
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
|
||||
if (selectedType === 'rtlsdr') {
|
||||
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
|
||||
} else if (!data.rx_fm) {
|
||||
missing.push('rx_fm (SoapySDR demodulator)');
|
||||
}
|
||||
if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)');
|
||||
|
||||
if (missing.length > 0) {
|
||||
warning.style.display = 'block';
|
||||
if (warningText) warningText.textContent = missing.join(', ');
|
||||
} else {
|
||||
warning.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update audio panel availability
|
||||
updateDmrAudioStatus(data.ffmpeg ? 'OFF' : 'UNAVAILABLE');
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ============== START / STOP ==============
|
||||
|
||||
function startDmr() {
|
||||
const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625);
|
||||
const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
|
||||
const gain = parseInt(document.getElementById('dmrGain')?.value || 40);
|
||||
const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0);
|
||||
const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false;
|
||||
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||
const sdrType = (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr';
|
||||
|
||||
// Use protocol name for device reservation so panel shows "D-STAR", "P25", etc.
|
||||
dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr';
|
||||
|
||||
// Check device availability before starting
|
||||
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability(dmrModeLabel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save settings to localStorage for persistence
|
||||
try {
|
||||
localStorage.setItem(DMR_SETTINGS_KEY, JSON.stringify({
|
||||
frequency, protocol, gain, ppm, relaxCrc
|
||||
}));
|
||||
} catch (e) { /* localStorage unavailable */ }
|
||||
|
||||
fetch('/dmr/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc, sdr_type: sdrType })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started') {
|
||||
isDmrRunning = true;
|
||||
dmrCallCount = 0;
|
||||
dmrSyncCount = 0;
|
||||
dmrCallHistory = [];
|
||||
updateDmrUI();
|
||||
connectDmrSSE();
|
||||
dmrEventType = 'idle';
|
||||
dmrActivityTarget = 0.1;
|
||||
dmrLastEventTime = Date.now();
|
||||
if (!dmrSynthInitialized) initDmrSynthesizer();
|
||||
updateDmrSynthStatus();
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'DECODING';
|
||||
if (typeof reserveDevice === 'function') {
|
||||
reserveDevice(parseInt(device), dmrModeLabel);
|
||||
}
|
||||
// Start audio if available
|
||||
dmrHasAudio = !!data.has_audio;
|
||||
if (dmrHasAudio) startDmrAudio();
|
||||
updateDmrAudioStatus(dmrHasAudio ? 'STREAMING' : 'UNAVAILABLE');
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Digital Voice', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
|
||||
}
|
||||
} else if (data.status === 'error' && data.message === 'Already running') {
|
||||
// Backend has an active session the frontend lost track of — resync
|
||||
isDmrRunning = true;
|
||||
updateDmrUI();
|
||||
connectDmrSSE();
|
||||
if (!dmrSynthInitialized) initDmrSynthesizer();
|
||||
dmrEventType = 'idle';
|
||||
dmrActivityTarget = 0.1;
|
||||
dmrLastEventTime = Date.now();
|
||||
updateDmrSynthStatus();
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'DECODING';
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('DMR', 'Reconnected to active session');
|
||||
}
|
||||
} else {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Error', data.message || 'Failed to start DMR');
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[DMR] Start error:', err));
|
||||
}
|
||||
|
||||
function stopDmr() {
|
||||
stopDmrAudio();
|
||||
fetch('/dmr/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
isDmrRunning = false;
|
||||
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
|
||||
updateDmrUI();
|
||||
dmrEventType = 'stopped';
|
||||
dmrActivityTarget = 0;
|
||||
updateDmrSynthStatus();
|
||||
updateDmrAudioStatus('OFF');
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||
if (typeof releaseDevice === 'function') {
|
||||
releaseDevice(dmrModeLabel);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[DMR] Stop error:', err));
|
||||
}
|
||||
|
||||
// ============== SSE STREAMING ==============
|
||||
|
||||
function connectDmrSSE() {
|
||||
if (dmrEventSource) dmrEventSource.close();
|
||||
dmrEventSource = new EventSource('/dmr/stream');
|
||||
|
||||
dmrEventSource.onmessage = function(event) {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleDmrMessage(msg);
|
||||
};
|
||||
|
||||
dmrEventSource.onerror = function() {
|
||||
if (isDmrRunning) {
|
||||
setTimeout(connectDmrSSE, 2000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleDmrMessage(msg) {
|
||||
if (dmrSynthInitialized) dmrSynthPulse(msg.type);
|
||||
|
||||
if (msg.type === 'sync') {
|
||||
dmrCurrentProtocol = msg.protocol || '--';
|
||||
const protocolEl = document.getElementById('dmrActiveProtocol');
|
||||
if (protocolEl) protocolEl.textContent = dmrCurrentProtocol;
|
||||
const mainProtocolEl = document.getElementById('dmrMainProtocol');
|
||||
if (mainProtocolEl) mainProtocolEl.textContent = dmrCurrentProtocol;
|
||||
dmrSyncCount++;
|
||||
const syncCountEl = document.getElementById('dmrSyncCount');
|
||||
if (syncCountEl) syncCountEl.textContent = dmrSyncCount;
|
||||
} else if (msg.type === 'call') {
|
||||
dmrCallCount++;
|
||||
const countEl = document.getElementById('dmrCallCount');
|
||||
if (countEl) countEl.textContent = dmrCallCount;
|
||||
const mainCountEl = document.getElementById('dmrMainCallCount');
|
||||
if (mainCountEl) mainCountEl.textContent = dmrCallCount;
|
||||
|
||||
// Update current call display
|
||||
const slotInfo = msg.slot != null ? `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Slot</span>
|
||||
<span style="color: var(--accent-orange); font-family: var(--font-mono);">${msg.slot}</span>
|
||||
</div>` : '';
|
||||
const callEl = document.getElementById('dmrCurrentCall');
|
||||
if (callEl) {
|
||||
callEl.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Talkgroup</span>
|
||||
<span style="color: var(--accent-green); font-weight: bold; font-family: var(--font-mono);">${msg.talkgroup}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Source ID</span>
|
||||
<span style="color: var(--accent-cyan); font-family: var(--font-mono);">${msg.source_id}</span>
|
||||
</div>${slotInfo}
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: var(--text-muted);">Time</span>
|
||||
<span style="color: var(--text-primary);">${msg.timestamp}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add to history
|
||||
dmrCallHistory.unshift({
|
||||
talkgroup: msg.talkgroup,
|
||||
source_id: msg.source_id,
|
||||
protocol: dmrCurrentProtocol,
|
||||
time: msg.timestamp,
|
||||
});
|
||||
if (dmrCallHistory.length > 50) dmrCallHistory.length = 50;
|
||||
renderDmrHistory();
|
||||
|
||||
} else if (msg.type === 'slot') {
|
||||
// Update slot info in current call
|
||||
} else if (msg.type === 'raw') {
|
||||
// Raw DSD output — triggers synthesizer activity via dmrSynthPulse
|
||||
} else if (msg.type === 'heartbeat') {
|
||||
// Decoder is alive and listening — keep synthesizer in listening state
|
||||
if (isDmrRunning && dmrSynthInitialized) {
|
||||
if (dmrEventType === 'idle' || dmrEventType === 'raw') {
|
||||
dmrEventType = 'raw';
|
||||
dmrActivityTarget = Math.max(dmrActivityTarget, 0.15);
|
||||
dmrLastEventTime = Date.now();
|
||||
updateDmrSynthStatus();
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'status') {
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (msg.text === 'started') {
|
||||
if (statusEl) statusEl.textContent = 'DECODING';
|
||||
} else if (msg.text === 'crashed') {
|
||||
isDmrRunning = false;
|
||||
stopDmrAudio();
|
||||
updateDmrUI();
|
||||
dmrEventType = 'stopped';
|
||||
dmrActivityTarget = 0;
|
||||
updateDmrSynthStatus();
|
||||
updateDmrAudioStatus('OFF');
|
||||
if (statusEl) statusEl.textContent = 'CRASHED';
|
||||
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
||||
const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`;
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('DMR Error', detail);
|
||||
}
|
||||
} else if (msg.text === 'stopped') {
|
||||
isDmrRunning = false;
|
||||
stopDmrAudio();
|
||||
updateDmrUI();
|
||||
dmrEventType = 'stopped';
|
||||
dmrActivityTarget = 0;
|
||||
updateDmrSynthStatus();
|
||||
updateDmrAudioStatus('OFF');
|
||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============== UI ==============
|
||||
|
||||
function updateDmrUI() {
|
||||
const startBtn = document.getElementById('startDmrBtn');
|
||||
const stopBtn = document.getElementById('stopDmrBtn');
|
||||
if (startBtn) startBtn.style.display = isDmrRunning ? 'none' : 'block';
|
||||
if (stopBtn) stopBtn.style.display = isDmrRunning ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function renderDmrHistory() {
|
||||
const container = document.getElementById('dmrHistoryBody');
|
||||
if (!container) return;
|
||||
|
||||
const historyCountEl = document.getElementById('dmrHistoryCount');
|
||||
if (historyCountEl) historyCountEl.textContent = `${dmrCallHistory.length} calls`;
|
||||
|
||||
if (dmrCallHistory.length === 0) {
|
||||
container.innerHTML = '<tr><td colspan="4" style="padding: 10px; text-align: center; color: var(--text-muted);">No calls recorded</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = dmrCallHistory.slice(0, 20).map(call => `
|
||||
<tr>
|
||||
<td style="padding: 3px 6px; font-family: var(--font-mono);">${call.time}</td>
|
||||
<td style="padding: 3px 6px; color: var(--accent-green);">${call.talkgroup}</td>
|
||||
<td style="padding: 3px 6px; color: var(--accent-cyan);">${call.source_id}</td>
|
||||
<td style="padding: 3px 6px;">${call.protocol}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ============== SYNTHESIZER ==============
|
||||
|
||||
function initDmrSynthesizer() {
|
||||
dmrSynthCanvas = document.getElementById('dmrSynthCanvas');
|
||||
if (!dmrSynthCanvas) return;
|
||||
|
||||
// Use the canvas element's own rendered size for the backing buffer
|
||||
const rect = dmrSynthCanvas.getBoundingClientRect();
|
||||
const w = Math.round(rect.width) || 600;
|
||||
const h = Math.round(rect.height) || 70;
|
||||
dmrSynthCanvas.width = w;
|
||||
dmrSynthCanvas.height = h;
|
||||
|
||||
dmrSynthCtx = dmrSynthCanvas.getContext('2d');
|
||||
|
||||
dmrSynthBars = [];
|
||||
for (let i = 0; i < DMR_BAR_COUNT; i++) {
|
||||
dmrSynthBars[i] = { height: 2, targetHeight: 2, velocity: 0 };
|
||||
}
|
||||
|
||||
dmrActivityLevel = 0;
|
||||
dmrActivityTarget = 0;
|
||||
dmrEventType = isDmrRunning ? 'idle' : 'stopped';
|
||||
dmrSynthInitialized = true;
|
||||
|
||||
updateDmrSynthStatus();
|
||||
|
||||
if (dmrSynthAnimationId) cancelAnimationFrame(dmrSynthAnimationId);
|
||||
drawDmrSynthesizer();
|
||||
}
|
||||
|
||||
function drawDmrSynthesizer() {
|
||||
if (!dmrSynthCtx || !dmrSynthCanvas) return;
|
||||
|
||||
const width = dmrSynthCanvas.width;
|
||||
const height = dmrSynthCanvas.height;
|
||||
const barWidth = (width / DMR_BAR_COUNT) - 2;
|
||||
const now = Date.now();
|
||||
|
||||
// Clear canvas
|
||||
dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
||||
dmrSynthCtx.fillRect(0, 0, width, height);
|
||||
|
||||
// Decay activity toward target. Window must exceed the backend
|
||||
// heartbeat interval (3s) so the status doesn't flip-flop between
|
||||
// LISTENING and IDLE on every heartbeat cycle.
|
||||
const timeSinceEvent = now - dmrLastEventTime;
|
||||
if (timeSinceEvent > 5000) {
|
||||
// No events for 5s — decay target toward idle
|
||||
dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE);
|
||||
if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') {
|
||||
dmrEventType = 'idle';
|
||||
updateDmrSynthStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth approach to target
|
||||
dmrActivityLevel += (dmrActivityTarget - dmrActivityLevel) * 0.08;
|
||||
|
||||
// Determine effective activity (idle breathing when stopped/idle)
|
||||
let effectiveActivity = dmrActivityLevel;
|
||||
if (dmrEventType === 'stopped') {
|
||||
effectiveActivity = 0;
|
||||
} else if (effectiveActivity < 0.1 && isDmrRunning) {
|
||||
// Visible idle breathing — shows decoder is alive and listening
|
||||
effectiveActivity = 0.12 + Math.sin(now / 1000) * 0.06;
|
||||
}
|
||||
|
||||
// Ripple timing for sync events
|
||||
const syncRippleAge = (dmrEventType === 'sync' && timeSinceEvent < 500) ? 1 - (timeSinceEvent / 500) : 0;
|
||||
// Voice ripple overlay
|
||||
const voiceRipple = (dmrEventType === 'voice') ? Math.sin(now / 60) * 0.15 : 0;
|
||||
|
||||
// Update bar targets and physics
|
||||
for (let i = 0; i < DMR_BAR_COUNT; i++) {
|
||||
const time = now / 200;
|
||||
const wave1 = Math.sin(time + i * 0.3) * 0.2;
|
||||
const wave2 = Math.sin(time * 1.7 + i * 0.5) * 0.15;
|
||||
const randomAmount = 0.05 + effectiveActivity * 0.25;
|
||||
const random = (Math.random() - 0.5) * randomAmount;
|
||||
|
||||
// Bell curve — center bars taller
|
||||
const centerDist = Math.abs(i - DMR_BAR_COUNT / 2) / (DMR_BAR_COUNT / 2);
|
||||
const centerBoost = 1 - centerDist * 0.5;
|
||||
|
||||
// Sync ripple: center-outward wave burst
|
||||
let rippleBoost = 0;
|
||||
if (syncRippleAge > 0) {
|
||||
const ripplePos = (1 - syncRippleAge) * DMR_BAR_COUNT / 2;
|
||||
const distFromRipple = Math.abs(i - DMR_BAR_COUNT / 2) - ripplePos;
|
||||
rippleBoost = Math.max(0, 1 - Math.abs(distFromRipple) / 4) * syncRippleAge * 0.4;
|
||||
}
|
||||
|
||||
const baseHeight = 0.1 + effectiveActivity * 0.55;
|
||||
dmrSynthBars[i].targetHeight = Math.max(2,
|
||||
(baseHeight + wave1 + wave2 + random + rippleBoost + voiceRipple) *
|
||||
effectiveActivity * centerBoost * height
|
||||
);
|
||||
|
||||
// Spring physics
|
||||
const springStrength = effectiveActivity > 0.3 ? 0.15 : 0.1;
|
||||
const diff = dmrSynthBars[i].targetHeight - dmrSynthBars[i].height;
|
||||
dmrSynthBars[i].velocity += diff * springStrength;
|
||||
dmrSynthBars[i].velocity *= 0.78;
|
||||
dmrSynthBars[i].height += dmrSynthBars[i].velocity;
|
||||
dmrSynthBars[i].height = Math.max(2, Math.min(height - 4, dmrSynthBars[i].height));
|
||||
}
|
||||
|
||||
// Draw bars
|
||||
for (let i = 0; i < DMR_BAR_COUNT; i++) {
|
||||
const x = i * (barWidth + 2) + 1;
|
||||
const barHeight = dmrSynthBars[i].height;
|
||||
const y = (height - barHeight) / 2;
|
||||
|
||||
// HSL color by event type
|
||||
let hue, saturation, lightness;
|
||||
if (dmrEventType === 'voice' && timeSinceEvent < 3000) {
|
||||
hue = 30; // Orange
|
||||
saturation = 85;
|
||||
lightness = 40 + (barHeight / height) * 25;
|
||||
} else if (dmrEventType === 'call' && timeSinceEvent < 3000) {
|
||||
hue = 120; // Green
|
||||
saturation = 80;
|
||||
lightness = 35 + (barHeight / height) * 30;
|
||||
} else if (dmrEventType === 'sync' && timeSinceEvent < 2000) {
|
||||
hue = 185; // Cyan
|
||||
saturation = 85;
|
||||
lightness = 38 + (barHeight / height) * 25;
|
||||
} else if (dmrEventType === 'stopped') {
|
||||
hue = 220;
|
||||
saturation = 20;
|
||||
lightness = 18 + (barHeight / height) * 8;
|
||||
} else {
|
||||
// Idle / decayed
|
||||
hue = 210;
|
||||
saturation = 40;
|
||||
lightness = 25 + (barHeight / height) * 15;
|
||||
}
|
||||
|
||||
// Vertical gradient per bar
|
||||
const gradient = dmrSynthCtx.createLinearGradient(x, y, x, y + barHeight);
|
||||
gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`);
|
||||
gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`);
|
||||
gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`);
|
||||
|
||||
dmrSynthCtx.fillStyle = gradient;
|
||||
dmrSynthCtx.fillRect(x, y, barWidth, barHeight);
|
||||
|
||||
// Glow on tall bars
|
||||
if (barHeight > height * 0.5 && effectiveActivity > 0.4) {
|
||||
dmrSynthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`;
|
||||
dmrSynthCtx.shadowBlur = 8;
|
||||
dmrSynthCtx.fillRect(x, y, barWidth, barHeight);
|
||||
dmrSynthCtx.shadowBlur = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Center line
|
||||
dmrSynthCtx.strokeStyle = 'rgba(0, 212, 255, 0.15)';
|
||||
dmrSynthCtx.lineWidth = 1;
|
||||
dmrSynthCtx.beginPath();
|
||||
dmrSynthCtx.moveTo(0, height / 2);
|
||||
dmrSynthCtx.lineTo(width, height / 2);
|
||||
dmrSynthCtx.stroke();
|
||||
|
||||
dmrSynthAnimationId = requestAnimationFrame(drawDmrSynthesizer);
|
||||
}
|
||||
|
||||
function dmrSynthPulse(type) {
|
||||
dmrLastEventTime = Date.now();
|
||||
|
||||
if (type === 'sync') {
|
||||
dmrActivityTarget = Math.max(dmrActivityTarget, DMR_BURST_SYNC);
|
||||
dmrEventType = 'sync';
|
||||
} else if (type === 'call') {
|
||||
dmrActivityTarget = DMR_BURST_CALL;
|
||||
dmrEventType = 'call';
|
||||
} else if (type === 'voice') {
|
||||
dmrActivityTarget = DMR_BURST_VOICE;
|
||||
dmrEventType = 'voice';
|
||||
} else if (type === 'slot' || type === 'nac') {
|
||||
dmrActivityTarget = Math.max(dmrActivityTarget, 0.5);
|
||||
} else if (type === 'raw') {
|
||||
// Any DSD output means the decoder is alive and processing
|
||||
dmrActivityTarget = Math.max(dmrActivityTarget, 0.25);
|
||||
if (dmrEventType === 'idle') dmrEventType = 'raw';
|
||||
}
|
||||
// keepalive and status don't change visuals
|
||||
|
||||
updateDmrSynthStatus();
|
||||
}
|
||||
|
||||
function updateDmrSynthStatus() {
|
||||
const el = document.getElementById('dmrSynthStatus');
|
||||
if (!el) return;
|
||||
|
||||
const labels = {
|
||||
stopped: 'STOPPED',
|
||||
idle: 'IDLE',
|
||||
raw: 'LISTENING',
|
||||
sync: 'SYNC',
|
||||
call: 'CALL',
|
||||
voice: 'VOICE'
|
||||
};
|
||||
const colors = {
|
||||
stopped: 'var(--text-muted)',
|
||||
idle: 'var(--text-muted)',
|
||||
raw: '#607d8b',
|
||||
sync: '#00e5ff',
|
||||
call: '#4caf50',
|
||||
voice: '#ff9800'
|
||||
};
|
||||
|
||||
el.textContent = labels[dmrEventType] || 'IDLE';
|
||||
el.style.color = colors[dmrEventType] || 'var(--text-muted)';
|
||||
}
|
||||
|
||||
function resizeDmrSynthesizer() {
|
||||
if (!dmrSynthCanvas) return;
|
||||
const rect = dmrSynthCanvas.getBoundingClientRect();
|
||||
if (rect.width > 0) {
|
||||
dmrSynthCanvas.width = Math.round(rect.width);
|
||||
dmrSynthCanvas.height = Math.round(rect.height) || 70;
|
||||
}
|
||||
}
|
||||
|
||||
function stopDmrSynthesizer() {
|
||||
if (dmrSynthAnimationId) {
|
||||
cancelAnimationFrame(dmrSynthAnimationId);
|
||||
dmrSynthAnimationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resizeDmrSynthesizer);
|
||||
|
||||
// ============== AUDIO ==============
|
||||
|
||||
function startDmrAudio() {
|
||||
const audioPlayer = document.getElementById('dmrAudioPlayer');
|
||||
if (!audioPlayer) return;
|
||||
const streamUrl = `/dmr/audio/stream?t=${Date.now()}`;
|
||||
audioPlayer.src = streamUrl;
|
||||
const volSlider = document.getElementById('dmrAudioVolume');
|
||||
if (volSlider) audioPlayer.volume = volSlider.value / 100;
|
||||
|
||||
audioPlayer.onplaying = () => updateDmrAudioStatus('STREAMING');
|
||||
audioPlayer.onerror = () => {
|
||||
// Retry if decoder is still running (stream may have dropped)
|
||||
if (isDmrRunning && dmrHasAudio) {
|
||||
console.warn('[DMR] Audio stream error, retrying in 2s...');
|
||||
updateDmrAudioStatus('RECONNECTING');
|
||||
setTimeout(() => {
|
||||
if (isDmrRunning && dmrHasAudio) startDmrAudio();
|
||||
}, 2000);
|
||||
} else {
|
||||
updateDmrAudioStatus('OFF');
|
||||
}
|
||||
};
|
||||
|
||||
audioPlayer.play().catch(e => {
|
||||
console.warn('[DMR] Audio autoplay blocked:', e);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Audio Ready', 'Click the page or interact to enable audio playback');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stopDmrAudio() {
|
||||
const audioPlayer = document.getElementById('dmrAudioPlayer');
|
||||
if (audioPlayer) {
|
||||
audioPlayer.pause();
|
||||
audioPlayer.src = '';
|
||||
}
|
||||
dmrHasAudio = false;
|
||||
}
|
||||
|
||||
function setDmrAudioVolume(value) {
|
||||
const audioPlayer = document.getElementById('dmrAudioPlayer');
|
||||
if (audioPlayer) audioPlayer.volume = value / 100;
|
||||
}
|
||||
|
||||
function updateDmrAudioStatus(status) {
|
||||
const el = document.getElementById('dmrAudioStatus');
|
||||
if (!el) return;
|
||||
el.textContent = status;
|
||||
const colors = {
|
||||
'OFF': 'var(--text-muted)',
|
||||
'STREAMING': 'var(--accent-green)',
|
||||
'ERROR': 'var(--accent-red)',
|
||||
'UNAVAILABLE': 'var(--text-muted)',
|
||||
};
|
||||
el.style.color = colors[status] || 'var(--text-muted)';
|
||||
}
|
||||
|
||||
// ============== SETTINGS PERSISTENCE ==============
|
||||
|
||||
function restoreDmrSettings() {
|
||||
try {
|
||||
const saved = localStorage.getItem(DMR_SETTINGS_KEY);
|
||||
if (!saved) return;
|
||||
const s = JSON.parse(saved);
|
||||
const freqEl = document.getElementById('dmrFrequency');
|
||||
const protoEl = document.getElementById('dmrProtocol');
|
||||
const gainEl = document.getElementById('dmrGain');
|
||||
const ppmEl = document.getElementById('dmrPPM');
|
||||
const crcEl = document.getElementById('dmrRelaxCrc');
|
||||
if (freqEl && s.frequency != null) freqEl.value = s.frequency;
|
||||
if (protoEl && s.protocol) protoEl.value = s.protocol;
|
||||
if (gainEl && s.gain != null) gainEl.value = s.gain;
|
||||
if (ppmEl && s.ppm != null) ppmEl.value = s.ppm;
|
||||
if (crcEl && s.relaxCrc != null) crcEl.checked = s.relaxCrc;
|
||||
} catch (e) { /* localStorage unavailable */ }
|
||||
}
|
||||
|
||||
// ============== BOOKMARKS ==============
|
||||
|
||||
function loadDmrBookmarks() {
|
||||
try {
|
||||
const saved = localStorage.getItem(DMR_BOOKMARKS_KEY);
|
||||
const parsed = saved ? JSON.parse(saved) : [];
|
||||
if (!Array.isArray(parsed)) {
|
||||
dmrBookmarks = [];
|
||||
} else {
|
||||
dmrBookmarks = parsed
|
||||
.map((entry) => {
|
||||
const freq = Number(entry?.freq);
|
||||
if (!Number.isFinite(freq) || freq <= 0) return null;
|
||||
const protocol = sanitizeDmrBookmarkProtocol(entry?.protocol);
|
||||
const rawLabel = String(entry?.label || '').trim();
|
||||
const label = rawLabel || `${freq.toFixed(4)} MHz`;
|
||||
return {
|
||||
freq,
|
||||
protocol,
|
||||
label,
|
||||
added: entry?.added,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch (e) {
|
||||
dmrBookmarks = [];
|
||||
}
|
||||
renderDmrBookmarks();
|
||||
}
|
||||
|
||||
function saveDmrBookmarks() {
|
||||
try {
|
||||
localStorage.setItem(DMR_BOOKMARKS_KEY, JSON.stringify(dmrBookmarks));
|
||||
} catch (e) { /* localStorage unavailable */ }
|
||||
}
|
||||
|
||||
function sanitizeDmrBookmarkProtocol(protocol) {
|
||||
const value = String(protocol || 'auto').toLowerCase();
|
||||
return DMR_BOOKMARK_PROTOCOLS.has(value) ? value : 'auto';
|
||||
}
|
||||
|
||||
function addDmrBookmark() {
|
||||
const freqInput = document.getElementById('dmrBookmarkFreq');
|
||||
const labelInput = document.getElementById('dmrBookmarkLabel');
|
||||
if (!freqInput) return;
|
||||
|
||||
const freq = parseFloat(freqInput.value);
|
||||
if (isNaN(freq) || freq <= 0) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Invalid Frequency', 'Enter a valid frequency');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = sanitizeDmrBookmarkProtocol(document.getElementById('dmrProtocol')?.value || 'auto');
|
||||
const label = (labelInput?.value || '').trim() || `${freq.toFixed(4)} MHz`;
|
||||
|
||||
// Duplicate check
|
||||
if (dmrBookmarks.some(b => b.freq === freq && b.protocol === protocol)) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Duplicate', 'This frequency/protocol is already bookmarked');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
dmrBookmarks.push({ freq, protocol, label, added: new Date().toISOString() });
|
||||
saveDmrBookmarks();
|
||||
renderDmrBookmarks();
|
||||
freqInput.value = '';
|
||||
if (labelInput) labelInput.value = '';
|
||||
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Bookmark Added', `${freq.toFixed(4)} MHz saved`);
|
||||
}
|
||||
}
|
||||
|
||||
function addCurrentDmrFreqBookmark() {
|
||||
const freqEl = document.getElementById('dmrFrequency');
|
||||
const freqInput = document.getElementById('dmrBookmarkFreq');
|
||||
if (freqEl && freqInput) {
|
||||
freqInput.value = freqEl.value;
|
||||
}
|
||||
addDmrBookmark();
|
||||
}
|
||||
|
||||
function removeDmrBookmark(index) {
|
||||
dmrBookmarks.splice(index, 1);
|
||||
saveDmrBookmarks();
|
||||
renderDmrBookmarks();
|
||||
}
|
||||
|
||||
function dmrQuickTune(freq, protocol) {
|
||||
const freqEl = document.getElementById('dmrFrequency');
|
||||
const protoEl = document.getElementById('dmrProtocol');
|
||||
if (freqEl && Number.isFinite(freq)) freqEl.value = freq;
|
||||
if (protoEl) protoEl.value = sanitizeDmrBookmarkProtocol(protocol);
|
||||
}
|
||||
|
||||
function renderDmrBookmarks() {
|
||||
const container = document.getElementById('dmrBookmarksList');
|
||||
if (!container) return;
|
||||
|
||||
container.replaceChildren();
|
||||
|
||||
if (dmrBookmarks.length === 0) {
|
||||
const emptyEl = document.createElement('div');
|
||||
emptyEl.style.color = 'var(--text-muted)';
|
||||
emptyEl.style.textAlign = 'center';
|
||||
emptyEl.style.padding = '10px';
|
||||
emptyEl.style.fontSize = '11px';
|
||||
emptyEl.textContent = 'No bookmarks saved';
|
||||
container.appendChild(emptyEl);
|
||||
return;
|
||||
}
|
||||
|
||||
dmrBookmarks.forEach((b, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.style.display = 'flex';
|
||||
row.style.justifyContent = 'space-between';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.padding = '4px 6px';
|
||||
row.style.background = 'rgba(0,0,0,0.2)';
|
||||
row.style.borderRadius = '3px';
|
||||
row.style.marginBottom = '3px';
|
||||
|
||||
const tuneBtn = document.createElement('button');
|
||||
tuneBtn.type = 'button';
|
||||
tuneBtn.style.cursor = 'pointer';
|
||||
tuneBtn.style.color = 'var(--accent-cyan)';
|
||||
tuneBtn.style.fontSize = '11px';
|
||||
tuneBtn.style.flex = '1';
|
||||
tuneBtn.style.background = 'none';
|
||||
tuneBtn.style.border = 'none';
|
||||
tuneBtn.style.textAlign = 'left';
|
||||
tuneBtn.style.padding = '0';
|
||||
tuneBtn.textContent = b.label;
|
||||
tuneBtn.title = `${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})`;
|
||||
tuneBtn.addEventListener('click', () => dmrQuickTune(b.freq, b.protocol));
|
||||
|
||||
const protocolEl = document.createElement('span');
|
||||
protocolEl.style.color = 'var(--text-muted)';
|
||||
protocolEl.style.fontSize = '9px';
|
||||
protocolEl.style.margin = '0 6px';
|
||||
protocolEl.textContent = b.protocol.toUpperCase();
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.type = 'button';
|
||||
deleteBtn.style.background = 'none';
|
||||
deleteBtn.style.border = 'none';
|
||||
deleteBtn.style.color = 'var(--accent-red)';
|
||||
deleteBtn.style.cursor = 'pointer';
|
||||
deleteBtn.style.fontSize = '12px';
|
||||
deleteBtn.style.padding = '0 4px';
|
||||
deleteBtn.textContent = '\u00d7';
|
||||
deleteBtn.addEventListener('click', () => removeDmrBookmark(i));
|
||||
|
||||
row.appendChild(tuneBtn);
|
||||
row.appendChild(protocolEl);
|
||||
row.appendChild(deleteBtn);
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// ============== STATUS SYNC ==============
|
||||
|
||||
function checkDmrStatus() {
|
||||
fetch('/dmr/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.running && !isDmrRunning) {
|
||||
// Backend is running but frontend lost track — resync
|
||||
isDmrRunning = true;
|
||||
updateDmrUI();
|
||||
connectDmrSSE();
|
||||
if (!dmrSynthInitialized) initDmrSynthesizer();
|
||||
dmrEventType = 'idle';
|
||||
dmrActivityTarget = 0.1;
|
||||
dmrLastEventTime = Date.now();
|
||||
updateDmrSynthStatus();
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'DECODING';
|
||||
} else if (!data.running && isDmrRunning) {
|
||||
// Backend stopped but frontend didn't know
|
||||
isDmrRunning = false;
|
||||
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
|
||||
updateDmrUI();
|
||||
dmrEventType = 'stopped';
|
||||
dmrActivityTarget = 0;
|
||||
updateDmrSynthStatus();
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ============== INIT ==============
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
restoreDmrSettings();
|
||||
loadDmrBookmarks();
|
||||
});
|
||||
|
||||
// ============== EXPORTS ==============
|
||||
|
||||
window.startDmr = startDmr;
|
||||
window.stopDmr = stopDmr;
|
||||
window.checkDmrTools = checkDmrTools;
|
||||
window.checkDmrStatus = checkDmrStatus;
|
||||
window.initDmrSynthesizer = initDmrSynthesizer;
|
||||
window.setDmrAudioVolume = setDmrAudioVolume;
|
||||
window.addDmrBookmark = addDmrBookmark;
|
||||
window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark;
|
||||
window.removeDmrBookmark = removeDmrBookmark;
|
||||
window.dmrQuickTune = dmrQuickTune;
|
||||
Reference in New Issue
Block a user