feat: add Home Assistant integration and improve CLI/UI
Home Assistant Integration: - New homeassistant.py module with webhook support - Webhooks for scan results, new devices, and device departures - Absence detection with configurable timeout - Documentation in docs/HOME_ASSISTANT.md CLI Improvements: - Replace 'web' command with start/stop/restart/status - Background daemon mode with PID file management - Foreground mode for debugging (--foreground) Web UI Enhancements: - Improved device list styling and layout - Better floor assignment UI - Enhanced map visualization Documentation: - Add CHANGELOG.md - Add docs/API.md with full endpoint reference - Add docs/CHEATSHEET.md for quick reference - Update project documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,10 +8,16 @@ let scanData = null;
|
||||
let map = null;
|
||||
let markers = [];
|
||||
let filters = {
|
||||
wifi: true,
|
||||
wifi: false,
|
||||
bluetooth: true
|
||||
};
|
||||
|
||||
// Device trails state - stores trail data per device
|
||||
let deviceTrails = {}; // { deviceId: { type, name, points: [{ timestamp, distance, lat, lon }, ...] } }
|
||||
|
||||
// Device manual positions - loaded from database
|
||||
let manualPositions = {}; // { deviceId: { lat_offset, lon_offset } }
|
||||
|
||||
// Auto-scan state
|
||||
let autoScanEnabled = false;
|
||||
let autoScanPollInterval = null;
|
||||
@@ -29,6 +35,10 @@ const MIN_SAMPLES_FOR_MOVEMENT = 3; // Need at least this many samples before de
|
||||
// Store distance history per device: { address: { samples: [], timestamps: [] } }
|
||||
let deviceDistanceHistory = {};
|
||||
|
||||
// Track consecutive missed detections per device: { address: missCount }
|
||||
let deviceMissCount = {};
|
||||
const MAX_MISSED_SCANS = 5; // Remove device after this many consecutive misses (~20s with 4s interval)
|
||||
|
||||
// Calculate mean of array
|
||||
function mean(arr) {
|
||||
if (arr.length === 0) return 0;
|
||||
@@ -110,6 +120,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
initFloorSelector();
|
||||
loadLatestScan();
|
||||
loadAutoScanStatus();
|
||||
loadDevicePositions(); // Load saved manual positions
|
||||
|
||||
// Initialize 3D map as default view
|
||||
setTimeout(() => {
|
||||
@@ -483,6 +494,9 @@ function drawRadar() {
|
||||
|
||||
const size = Math.max(3, 10 + (dev.rssi + 90) / 5);
|
||||
const isMoving = dev.is_moving === true;
|
||||
const missCount = dev.miss_count || 0;
|
||||
// Calculate opacity: 1.0 -> 0.6 -> 0.3 based on miss count
|
||||
const opacity = missCount === 0 ? 1.0 : (missCount === 1 ? 0.6 : 0.3);
|
||||
const color = isMoving ? '#9b59b6' : APP_CONFIG.colors.bluetooth;
|
||||
|
||||
// Store position for hit detection
|
||||
@@ -494,6 +508,10 @@ function drawRadar() {
|
||||
radius: Math.max(size / 2, 8) // Minimum clickable area
|
||||
});
|
||||
|
||||
// Save context for opacity
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity;
|
||||
|
||||
// Draw glow
|
||||
const gradient = ctx.createRadialGradient(x, y, 0, x, y, size * 2);
|
||||
if (isMoving) {
|
||||
@@ -522,6 +540,9 @@ function drawRadar() {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, size / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Restore context (for opacity)
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
// Legend
|
||||
@@ -609,11 +630,16 @@ function updateMapMarkers() {
|
||||
const hasCustomDist = dev.custom_distance_m !== null && dev.custom_distance_m !== undefined;
|
||||
const distLabel = hasCustomDist ? `${dist}m (custom)` : `~${dev.estimated_distance_m}m`;
|
||||
|
||||
// Calculate opacity based on miss count
|
||||
const missCount = dev.miss_count || 0;
|
||||
const opacity = missCount === 0 ? 0.6 : (missCount === 1 ? 0.4 : 0.2);
|
||||
|
||||
const marker = L.circleMarker([lat + latOffset, lon + lonOffset], {
|
||||
radius: Math.max(4, 8 + (dev.rssi + 90) / 10),
|
||||
color: APP_CONFIG.colors.bluetooth,
|
||||
fillColor: APP_CONFIG.colors.bluetooth,
|
||||
fillOpacity: 0.6,
|
||||
fillOpacity: opacity,
|
||||
opacity: opacity + 0.2,
|
||||
weight: 2
|
||||
}).addTo(map).bindPopup(`
|
||||
<strong>🔵 ${escapeHtml(dev.name)}</strong><br>
|
||||
@@ -991,6 +1017,168 @@ async function stopAutoScan() {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Device Position Functions ==========
|
||||
|
||||
// Load saved device positions from database
|
||||
async function loadDevicePositions() {
|
||||
try {
|
||||
const response = await fetch('/api/device/floors');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Handle both old format (just floors) and new format (floors + positions)
|
||||
if (data.positions) {
|
||||
manualPositions = data.positions;
|
||||
console.log('[Positions] Loaded', Object.keys(manualPositions).length, 'manual positions');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading device positions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get device position (manual or RSSI-based)
|
||||
function getDevicePosition(device, scannerLat, scannerLon, minDistanceM) {
|
||||
const deviceId = device.bssid || device.address;
|
||||
const customPos = manualPositions[deviceId];
|
||||
|
||||
// If device has manual position, use it
|
||||
if (customPos && customPos.lat_offset != null && customPos.lon_offset != null) {
|
||||
return {
|
||||
lat: scannerLat + customPos.lat_offset,
|
||||
lon: scannerLon + customPos.lon_offset,
|
||||
isManual: true
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise calculate from RSSI/distance
|
||||
const effectiveDist = getEffectiveDistance(device);
|
||||
const dist = Math.max(effectiveDist, minDistanceM);
|
||||
const angle = hashString(deviceId) % 360;
|
||||
|
||||
const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000;
|
||||
const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(scannerLat * Math.PI / 180));
|
||||
|
||||
return {
|
||||
lat: scannerLat + latOffset,
|
||||
lon: scannerLon + lonOffset,
|
||||
isManual: false
|
||||
};
|
||||
}
|
||||
|
||||
// Update device position via API (called after drag)
|
||||
async function updateDevicePosition(deviceId, latOffset, lonOffset) {
|
||||
try {
|
||||
const response = await fetch(`/api/device/${encodeURIComponent(deviceId)}/position`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
lat_offset: latOffset,
|
||||
lon_offset: lonOffset
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('[Position] Updated', deviceId, 'to offset', latOffset.toFixed(6), lonOffset.toFixed(6));
|
||||
// Update local cache
|
||||
manualPositions[deviceId] = { lat_offset: latOffset, lon_offset: lonOffset };
|
||||
return true;
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('[Position] Failed to update:', error.error);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Position] Error updating position:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset device position to auto (RSSI-based)
|
||||
async function resetDevicePosition(deviceId) {
|
||||
try {
|
||||
const response = await fetch(`/api/device/${encodeURIComponent(deviceId)}/position`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
lat_offset: null,
|
||||
lon_offset: null
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('[Position] Reset', deviceId, 'to auto');
|
||||
// Remove from local cache
|
||||
delete manualPositions[deviceId];
|
||||
// Refresh markers to show new position
|
||||
update3DMarkers();
|
||||
return true;
|
||||
} else {
|
||||
console.error('[Position] Failed to reset position');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Position] Error resetting position:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle scanner (primary source) drag end
|
||||
async function onScannerDragEnd(marker) {
|
||||
const lngLat = marker.getLngLat();
|
||||
const newLat = lngLat.lat;
|
||||
const newLon = lngLat.lng;
|
||||
|
||||
console.log('[Scanner] Repositioned to', newLat.toFixed(6), newLon.toFixed(6));
|
||||
|
||||
// Update the input fields
|
||||
document.getElementById('lat-input').value = newLat.toFixed(6);
|
||||
document.getElementById('lon-input').value = newLon.toFixed(6);
|
||||
|
||||
// Persist to config file (survives restarts)
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gps: { latitude: newLat, longitude: newLon },
|
||||
save: true
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('[Scanner] Position saved to config');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Scanner] Error saving position:', error);
|
||||
}
|
||||
|
||||
// Refresh all markers (device positions are relative to scanner)
|
||||
update3DMarkers();
|
||||
}
|
||||
|
||||
// Handle marker drag end
|
||||
async function onMarkerDragEnd(marker, deviceId, scannerLat, scannerLon) {
|
||||
const lngLat = marker.getLngLat();
|
||||
const latOffset = lngLat.lat - scannerLat;
|
||||
const lonOffset = lngLat.lng - scannerLon;
|
||||
|
||||
const success = await updateDevicePosition(deviceId, latOffset, lonOffset);
|
||||
|
||||
if (success) {
|
||||
// Update marker element to show manual position indicator
|
||||
const el = marker.getElement();
|
||||
if (el) {
|
||||
el.classList.add('has-manual-position');
|
||||
}
|
||||
// Refresh markers to update popup content
|
||||
update3DMarkers();
|
||||
} else {
|
||||
// Revert marker to original position on failure
|
||||
update3DMarkers();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 3D Map Functions ==========
|
||||
|
||||
// Initialize 3D map with MapLibre GL
|
||||
@@ -1153,17 +1341,31 @@ function update3DMarkers() {
|
||||
const pixelsPerFloor = 18;
|
||||
const groundFloor = buildingConfig.groundFloorNumber || 0;
|
||||
|
||||
// Add scanner position marker at center
|
||||
// Add scanner position marker at center (draggable for fine-grained positioning)
|
||||
const scannerEl = document.createElement('div');
|
||||
scannerEl.className = 'marker-3d center';
|
||||
scannerEl.className = 'marker-3d center draggable';
|
||||
scannerEl.innerHTML = `<div class="marker-icon">📍</div><div class="marker-floor">F${scannerFloor}</div>`;
|
||||
scannerEl.title = `Your Position - Floor ${scannerFloor}`;
|
||||
scannerEl.title = `Your Position - Floor ${scannerFloor} (drag to reposition)`;
|
||||
|
||||
const scannerOffset = (scannerFloor - groundFloor) * pixelsPerFloor;
|
||||
const scannerMarker = new maplibregl.Marker({ element: scannerEl, offset: [0, -scannerOffset] })
|
||||
const scannerMarker = new maplibregl.Marker({
|
||||
element: scannerEl,
|
||||
offset: [0, -scannerOffset],
|
||||
draggable: true
|
||||
})
|
||||
.setLngLat([lon, lat])
|
||||
.setPopup(new maplibregl.Popup().setHTML(`<strong>📍 Your Position</strong><br>Floor: ${scannerFloor}`))
|
||||
.setPopup(new maplibregl.Popup().setHTML(`
|
||||
<strong>📍 Your Position</strong><br>
|
||||
Floor: ${scannerFloor}<br>
|
||||
Lat: ${lat.toFixed(6)}<br>
|
||||
Lon: ${lon.toFixed(6)}<br>
|
||||
<div style="font-size:0.7rem;color:#888;margin-top:4px;">Drag marker to reposition</div>
|
||||
`))
|
||||
.addTo(map3d);
|
||||
|
||||
// Handle scanner marker drag
|
||||
scannerMarker.on('dragend', () => onScannerDragEnd(scannerMarker));
|
||||
scannerMarker._deviceId = '__scanner__';
|
||||
map3dMarkers.push(scannerMarker);
|
||||
|
||||
if (!scanData) return;
|
||||
@@ -1179,24 +1381,32 @@ function update3DMarkers() {
|
||||
|
||||
// Add WiFi markers
|
||||
filteredWifi.forEach((net) => {
|
||||
const effectiveDist = getEffectiveDistance(net);
|
||||
const dist = Math.max(effectiveDist, minDistanceM);
|
||||
const angle = hashString(net.bssid) % 360;
|
||||
|
||||
const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000;
|
||||
const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(lat * Math.PI / 180));
|
||||
|
||||
const wifiDeviceId = net.bssid;
|
||||
const deviceFloor = net.floor !== null && net.floor !== undefined ? net.floor : null;
|
||||
const hasCustomDist = net.custom_distance_m !== null && net.custom_distance_m !== undefined;
|
||||
const effectiveDist = getEffectiveDistance(net);
|
||||
|
||||
// Get position (manual or RSSI-based)
|
||||
const pos = getDevicePosition(net, lat, lon, minDistanceM);
|
||||
const hasManualPosition = pos.isManual;
|
||||
|
||||
// Determine if marker should be draggable (only floor-assigned devices)
|
||||
const isDraggable = deviceFloor !== null;
|
||||
|
||||
const floorLabel = deviceFloor !== null ? `F${deviceFloor}` : 'F?';
|
||||
const el = document.createElement('div');
|
||||
el.className = 'marker-3d wifi';
|
||||
if (isDraggable) el.classList.add('draggable');
|
||||
if (hasManualPosition) el.classList.add('has-manual-position');
|
||||
el.innerHTML = `<div class="marker-icon">📶</div><div class="marker-floor">${floorLabel}</div>`;
|
||||
el.title = `${net.ssid} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}`;
|
||||
el.title = `${net.ssid} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${hasManualPosition ? ' (Manual position)' : ''}`;
|
||||
|
||||
const wifiDeviceId = net.bssid;
|
||||
const distLabel = hasCustomDist ? `${effectiveDist}m (custom)` : `~${net.estimated_distance_m}m`;
|
||||
const positionStatus = hasManualPosition ? 'Manual' : 'Auto';
|
||||
const positionClass = hasManualPosition ? 'manual' : 'auto';
|
||||
const resetBtn = hasManualPosition ? `<button class="popup-reset-btn" onclick="resetDevicePosition('${wifiDeviceId}')">Reset to Auto</button>` : '';
|
||||
const dragHint = isDraggable && !hasManualPosition ? '<div style="font-size:0.7rem;color:#888;margin-top:4px;">Drag marker to set position</div>' : '';
|
||||
|
||||
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(`
|
||||
<strong>📶 ${escapeHtml(net.ssid)}</strong><br>
|
||||
Signal: ${net.rssi} dBm<br>
|
||||
@@ -1216,14 +1426,30 @@ function update3DMarkers() {
|
||||
placeholder="${net.estimated_distance_m}"
|
||||
onchange="updateDeviceDistance('${wifiDeviceId}', this.value)">
|
||||
</div>
|
||||
<div class="popup-position-status">
|
||||
<span class="status-label">Position:</span>
|
||||
<span class="status-value ${positionClass}">${positionStatus}</span>
|
||||
</div>
|
||||
${resetBtn}
|
||||
${dragHint}
|
||||
`);
|
||||
|
||||
// Offset marker up based on floor (unknown floors at ground level)
|
||||
const wifiOffset = deviceFloor !== null ? (deviceFloor - groundFloor) * pixelsPerFloor : 0;
|
||||
const marker = new maplibregl.Marker({ element: el, offset: [0, -wifiOffset] })
|
||||
.setLngLat([lon + lonOffset, lat + latOffset])
|
||||
const marker = new maplibregl.Marker({
|
||||
element: el,
|
||||
offset: [0, -wifiOffset],
|
||||
draggable: isDraggable
|
||||
})
|
||||
.setLngLat([pos.lon, pos.lat])
|
||||
.setPopup(popup)
|
||||
.addTo(map3d);
|
||||
|
||||
// Handle drag end for floor-assigned devices
|
||||
if (isDraggable) {
|
||||
marker.on('dragend', () => onMarkerDragEnd(marker, wifiDeviceId, lat, lon));
|
||||
}
|
||||
|
||||
marker._deviceId = wifiDeviceId;
|
||||
popup.on('open', () => { openPopupDeviceId = wifiDeviceId; });
|
||||
popup.on('close', () => { if (openPopupDeviceId === wifiDeviceId) openPopupDeviceId = null; });
|
||||
@@ -1232,27 +1458,45 @@ function update3DMarkers() {
|
||||
|
||||
// Add Bluetooth markers
|
||||
filteredBt.forEach((dev) => {
|
||||
const effectiveDist = getEffectiveDistance(dev);
|
||||
const dist = Math.max(effectiveDist, minDistanceM);
|
||||
const angle = hashString(dev.address) % 360;
|
||||
|
||||
const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000;
|
||||
const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(lat * Math.PI / 180));
|
||||
|
||||
const btDeviceId = dev.address;
|
||||
const deviceFloor = dev.floor !== null && dev.floor !== undefined ? dev.floor : null;
|
||||
const hasCustomDist = dev.custom_distance_m !== null && dev.custom_distance_m !== undefined;
|
||||
const effectiveDist = getEffectiveDistance(dev);
|
||||
|
||||
// Get position (manual or RSSI-based)
|
||||
const pos = getDevicePosition(dev, lat, lon, minDistanceM);
|
||||
const hasManualPosition = pos.isManual;
|
||||
|
||||
// Determine if marker should be draggable (only floor-assigned devices)
|
||||
const isDraggable = deviceFloor !== null;
|
||||
|
||||
const btFloorLabel = deviceFloor !== null ? `F${deviceFloor}` : 'F?';
|
||||
const isMoving = dev.is_moving === true;
|
||||
const missCount = dev.miss_count || 0;
|
||||
// Calculate opacity: 1.0 -> 0.6 -> 0.3 based on miss count
|
||||
const opacity = missCount === 0 ? 1.0 : (missCount === 1 ? 0.6 : 0.3);
|
||||
const el = document.createElement('div');
|
||||
el.className = `marker-3d bluetooth${isMoving ? ' moving' : ''}`;
|
||||
if (isDraggable) el.classList.add('draggable');
|
||||
if (hasManualPosition) el.classList.add('has-manual-position');
|
||||
el.style.opacity = opacity;
|
||||
el.style.transition = 'opacity 0.5s ease';
|
||||
el.innerHTML = `<div class="marker-icon">${isMoving ? '🟣' : '🔵'}</div><div class="marker-floor">${btFloorLabel}</div>`;
|
||||
el.title = `${dev.name} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${isMoving ? ' (MOVING)' : ''}`;
|
||||
el.title = `${dev.name} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${isMoving ? ' (MOVING)' : ''}${hasManualPosition ? ' (Manual position)' : ''}${missCount > 0 ? ` (fading: ${missCount}/${MAX_MISSED_SCANS})` : ''}`;
|
||||
|
||||
const btDeviceId = dev.address;
|
||||
const btDistLabel = hasCustomDist ? `${effectiveDist}m (custom)` : `~${dev.estimated_distance_m}m`;
|
||||
const positionStatus = hasManualPosition ? 'Manual' : 'Auto';
|
||||
const positionClass = hasManualPosition ? 'manual' : 'auto';
|
||||
const resetBtn = hasManualPosition ? `<button class="popup-reset-btn" onclick="resetDevicePosition('${btDeviceId}')">Reset to Auto</button>` : '';
|
||||
const dragHint = isDraggable && !hasManualPosition ? '<div style="font-size:0.7rem;color:#888;margin-top:4px;">Drag marker to set position</div>' : '';
|
||||
const trailBtnHtml = isMoving ? `
|
||||
<button class="popup-trail-btn" id="trail-btn-${btDeviceId.replace(/:/g, '')}"
|
||||
onclick="toggleDeviceTrail('${btDeviceId}', '${escapeHtml(dev.name)}', 'bluetooth')">
|
||||
Show Trail
|
||||
</button>` : '';
|
||||
|
||||
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(`
|
||||
<strong>🔵 ${escapeHtml(dev.name)}</strong><br>
|
||||
<strong>${isMoving ? '🟣' : '🔵'} ${escapeHtml(dev.name)}</strong>${isMoving ? ' <span style="color:#9b59b6;font-size:0.8em;">(Moving)</span>' : ''}<br>
|
||||
Signal: ${dev.rssi} dBm<br>
|
||||
Distance: ${btDistLabel}<br>
|
||||
Type: ${escapeHtml(dev.device_type)}<br>
|
||||
@@ -1270,14 +1514,31 @@ function update3DMarkers() {
|
||||
placeholder="${dev.estimated_distance_m}"
|
||||
onchange="updateDeviceDistance('${btDeviceId}', this.value)">
|
||||
</div>
|
||||
<div class="popup-position-status">
|
||||
<span class="status-label">Position:</span>
|
||||
<span class="status-value ${positionClass}">${positionStatus}</span>
|
||||
</div>
|
||||
${resetBtn}
|
||||
${dragHint}
|
||||
${trailBtnHtml}
|
||||
`);
|
||||
|
||||
// Offset marker up based on floor (unknown floors at ground level)
|
||||
const btOffset = deviceFloor !== null ? (deviceFloor - groundFloor) * pixelsPerFloor : 0;
|
||||
const marker = new maplibregl.Marker({ element: el, offset: [0, -btOffset] })
|
||||
.setLngLat([lon + lonOffset, lat + latOffset])
|
||||
const marker = new maplibregl.Marker({
|
||||
element: el,
|
||||
offset: [0, -btOffset],
|
||||
draggable: isDraggable
|
||||
})
|
||||
.setLngLat([pos.lon, pos.lat])
|
||||
.setPopup(popup)
|
||||
.addTo(map3d);
|
||||
|
||||
// Handle drag end for floor-assigned devices
|
||||
if (isDraggable) {
|
||||
marker.on('dragend', () => onMarkerDragEnd(marker, btDeviceId, lat, lon));
|
||||
}
|
||||
|
||||
marker._deviceId = btDeviceId;
|
||||
popup.on('open', () => { openPopupDeviceId = btDeviceId; });
|
||||
popup.on('close', () => { if (openPopupDeviceId === btDeviceId) openPopupDeviceId = null; });
|
||||
@@ -1560,11 +1821,14 @@ async function performLiveBTScan() {
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const newBt = data.bluetooth_devices || [];
|
||||
|
||||
// Track which devices were detected in this scan
|
||||
const detectedAddresses = new Set(newBt.map(d => d.address));
|
||||
|
||||
// Merge BT data with existing scan data, preserving custom distances and floors
|
||||
if (scanData) {
|
||||
const existingBt = scanData.bluetooth_devices || [];
|
||||
const newBt = data.bluetooth_devices || [];
|
||||
|
||||
// Update existing devices with new RSSI, add new devices
|
||||
newBt.forEach(newDev => {
|
||||
@@ -1574,12 +1838,16 @@ async function performLiveBTScan() {
|
||||
// Check for movement using statistical analysis
|
||||
const moving = isDeviceMoving(newDev.address, newDist);
|
||||
|
||||
// Reset miss count - device was detected
|
||||
deviceMissCount[newDev.address] = 0;
|
||||
|
||||
if (existing) {
|
||||
// Update RSSI and estimated distance, preserve custom values
|
||||
existing.rssi = newDev.rssi;
|
||||
existing.estimated_distance_m = newDev.estimated_distance_m;
|
||||
existing.signal_quality = newDev.signal_quality;
|
||||
existing.is_moving = moving;
|
||||
existing.miss_count = 0;
|
||||
// Preserve floor and custom_distance_m if set
|
||||
} else {
|
||||
// New device, add it
|
||||
@@ -1588,13 +1856,39 @@ async function performLiveBTScan() {
|
||||
}
|
||||
});
|
||||
|
||||
scanData.bluetooth_devices = existingBt;
|
||||
// Increment miss count for devices not detected in this scan
|
||||
existingBt.forEach(dev => {
|
||||
if (!detectedAddresses.has(dev.address)) {
|
||||
deviceMissCount[dev.address] = (deviceMissCount[dev.address] || 0) + 1;
|
||||
dev.miss_count = deviceMissCount[dev.address];
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out devices that have been missed too many times
|
||||
const filteredBt = existingBt.filter(dev => {
|
||||
const missCount = deviceMissCount[dev.address] || 0;
|
||||
if (missCount >= MAX_MISSED_SCANS) {
|
||||
// Clean up tracking data for removed device
|
||||
delete deviceMissCount[dev.address];
|
||||
delete deviceDistanceHistory[dev.address];
|
||||
// Clear trail if showing
|
||||
if (deviceTrails[dev.address]) {
|
||||
clearDeviceTrail(dev.address);
|
||||
}
|
||||
console.log(`[Live] Removed ${dev.name} (missed ${missCount} scans)`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
scanData.bluetooth_devices = filteredBt;
|
||||
} else {
|
||||
// No existing scan data, use BT-only data
|
||||
data.bluetooth_devices.forEach(dev => {
|
||||
// Initialize history with first sample, not moving yet
|
||||
isDeviceMoving(dev.address, dev.estimated_distance_m);
|
||||
dev.is_moving = false;
|
||||
deviceMissCount[dev.address] = 0;
|
||||
});
|
||||
scanData = {
|
||||
wifi_networks: [],
|
||||
@@ -1607,7 +1901,7 @@ async function performLiveBTScan() {
|
||||
const status = document.getElementById('scan-status');
|
||||
if (status) {
|
||||
const movingCount = scanData.bluetooth_devices.filter(d => d.is_moving).length;
|
||||
status.textContent = `Live: ${data.bluetooth_devices.length} BT (${movingCount} moving) @ ${new Date().toLocaleTimeString()}`;
|
||||
status.textContent = `Live: ${scanData.bluetooth_devices.length} BT (${movingCount} moving) @ ${new Date().toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
// Update BT count
|
||||
@@ -1623,3 +1917,250 @@ async function performLiveBTScan() {
|
||||
console.error('Live BT scan error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Device Trails Functions ==========
|
||||
|
||||
// Toggle trail for a specific device (called from popup button)
|
||||
async function toggleDeviceTrail(deviceId, deviceName, deviceType) {
|
||||
const btnId = `trail-btn-${deviceId.replace(/:/g, '')}`;
|
||||
const btn = document.getElementById(btnId);
|
||||
|
||||
// Check if trail already exists for this device
|
||||
if (deviceTrails[deviceId]) {
|
||||
// Hide trail
|
||||
clearDeviceTrail(deviceId);
|
||||
if (btn) {
|
||||
btn.textContent = 'Show Trail';
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
console.log(`[Trails] Hidden trail for ${deviceName}`);
|
||||
} else {
|
||||
// Show trail - fetch and render
|
||||
if (btn) {
|
||||
btn.textContent = 'Loading...';
|
||||
btn.classList.add('loading');
|
||||
}
|
||||
|
||||
await fetchDeviceTrail(deviceId, deviceName, deviceType);
|
||||
renderDeviceTrail(deviceId);
|
||||
|
||||
if (btn) {
|
||||
btn.textContent = 'Hide Trail';
|
||||
btn.classList.remove('loading');
|
||||
btn.classList.add('active');
|
||||
}
|
||||
console.log(`[Trails] Showing trail for ${deviceName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch trail data for a single device
|
||||
async function fetchDeviceTrail(deviceId, deviceName, deviceType) {
|
||||
try {
|
||||
// Get last 100 observations for trail
|
||||
const response = await fetch(`/api/history/devices/${encodeURIComponent(deviceId)}/rssi?limit=100`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[Trails] Failed to fetch trail for ${deviceId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const observations = data.observations || [];
|
||||
|
||||
if (observations.length < 2) {
|
||||
console.log(`[Trails] Not enough data points for ${deviceId} (${observations.length})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert observations to trail points with positions
|
||||
const lat = parseFloat(document.getElementById('lat-input').value) || APP_CONFIG.defaultLat;
|
||||
const lon = parseFloat(document.getElementById('lon-input').value) || APP_CONFIG.defaultLon;
|
||||
const angle = hashString(deviceId) % 360;
|
||||
|
||||
const trailPoints = observations.map(obs => {
|
||||
const dist = obs.distance_m || 5;
|
||||
const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000;
|
||||
const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(lat * Math.PI / 180));
|
||||
|
||||
return {
|
||||
timestamp: obs.timestamp,
|
||||
distance: dist,
|
||||
lat: lat + latOffset,
|
||||
lon: lon + lonOffset,
|
||||
rssi: obs.rssi,
|
||||
floor: obs.floor
|
||||
};
|
||||
});
|
||||
|
||||
// Store trail data
|
||||
deviceTrails[deviceId] = {
|
||||
type: deviceType,
|
||||
name: deviceName,
|
||||
points: trailPoints.reverse() // Oldest first for line drawing
|
||||
};
|
||||
|
||||
console.log(`[Trails] Fetched ${trailPoints.length} points for ${deviceName}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[Trails] Error fetching trail for ${deviceId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Render trail for a single device on the 3D map
|
||||
function renderDeviceTrail(deviceId) {
|
||||
if (!map3d || !map3dLoaded) return;
|
||||
|
||||
const trail = deviceTrails[deviceId];
|
||||
if (!trail || trail.points.length < 2) return;
|
||||
|
||||
const safeId = deviceId.replace(/:/g, '-');
|
||||
const sourceId = `trail-source-${safeId}`;
|
||||
const layerId = `trail-layer-${safeId}`;
|
||||
const pointsLayerId = `trail-points-${safeId}`;
|
||||
|
||||
// Remove existing trail for this device if any
|
||||
clearDeviceTrailLayers(safeId);
|
||||
|
||||
// Create GeoJSON line coordinates
|
||||
const coordinates = trail.points.map(p => [p.lon, p.lat]);
|
||||
|
||||
// Purple color for moving devices
|
||||
const color = '#9b59b6';
|
||||
|
||||
// Add source for trail line
|
||||
map3d.addSource(sourceId, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
deviceId: deviceId,
|
||||
name: trail.name,
|
||||
type: trail.type
|
||||
},
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: coordinates
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add trail line layer
|
||||
map3d.addLayer({
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: sourceId,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': color,
|
||||
'line-width': 3,
|
||||
'line-opacity': 0.8,
|
||||
'line-dasharray': [2, 1]
|
||||
}
|
||||
});
|
||||
|
||||
// Add trail points (circles at each observation)
|
||||
const pointFeatures = trail.points.map((p, i) => ({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
index: i,
|
||||
timestamp: p.timestamp,
|
||||
rssi: p.rssi,
|
||||
distance: p.distance
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [p.lon, p.lat]
|
||||
}
|
||||
}));
|
||||
|
||||
map3d.addSource(`${sourceId}-points`, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: pointFeatures
|
||||
}
|
||||
});
|
||||
|
||||
map3d.addLayer({
|
||||
id: pointsLayerId,
|
||||
type: 'circle',
|
||||
source: `${sourceId}-points`,
|
||||
paint: {
|
||||
'circle-radius': 4,
|
||||
'circle-color': color,
|
||||
'circle-opacity': 0.6,
|
||||
'circle-stroke-width': 1,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-stroke-opacity': 0.5
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[Trails] Rendered trail for ${trail.name} with ${trail.points.length} points`);
|
||||
}
|
||||
|
||||
// Clear trail for a specific device
|
||||
function clearDeviceTrail(deviceId) {
|
||||
const safeId = deviceId.replace(/:/g, '-');
|
||||
clearDeviceTrailLayers(safeId);
|
||||
delete deviceTrails[deviceId];
|
||||
}
|
||||
|
||||
// Clear trail layers for a device (by safe ID)
|
||||
function clearDeviceTrailLayers(safeId) {
|
||||
if (!map3d || !map3dLoaded) return;
|
||||
|
||||
const sourceId = `trail-source-${safeId}`;
|
||||
const layerId = `trail-layer-${safeId}`;
|
||||
const pointsLayerId = `trail-points-${safeId}`;
|
||||
|
||||
// Remove layers first
|
||||
if (map3d.getLayer(layerId)) {
|
||||
map3d.removeLayer(layerId);
|
||||
}
|
||||
if (map3d.getLayer(pointsLayerId)) {
|
||||
map3d.removeLayer(pointsLayerId);
|
||||
}
|
||||
|
||||
// Remove sources
|
||||
if (map3d.getSource(sourceId)) {
|
||||
map3d.removeSource(sourceId);
|
||||
}
|
||||
if (map3d.getSource(`${sourceId}-points`)) {
|
||||
map3d.removeSource(`${sourceId}-points`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all trails from the 3D map
|
||||
function clearAllTrails() {
|
||||
if (!map3d || !map3dLoaded) return;
|
||||
|
||||
// Remove all trail layers and sources
|
||||
const style = map3d.getStyle();
|
||||
if (!style || !style.layers) return;
|
||||
|
||||
// Find and remove trail layers
|
||||
const layersToRemove = style.layers
|
||||
.filter(layer => layer.id.startsWith('trail-'))
|
||||
.map(layer => layer.id);
|
||||
|
||||
layersToRemove.forEach(layerId => {
|
||||
if (map3d.getLayer(layerId)) {
|
||||
map3d.removeLayer(layerId);
|
||||
}
|
||||
});
|
||||
|
||||
// Find and remove trail sources
|
||||
const sources = Object.keys(style.sources || {});
|
||||
sources.forEach(sourceId => {
|
||||
if (sourceId.startsWith('trail-')) {
|
||||
if (map3d.getSource(sourceId)) {
|
||||
map3d.removeSource(sourceId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
deviceTrails = {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user