tweaks
This commit is contained in:
parent
d3924ea395
commit
7a57739ed7
19 changed files with 2233 additions and 189 deletions
310
assets/js/app.js
310
assets/js/app.js
|
|
@ -33,9 +33,45 @@ Hooks.APRSMap = {
|
||||||
const initialCenter = JSON.parse(this.el.dataset.center);
|
const initialCenter = JSON.parse(this.el.dataset.center);
|
||||||
const initialZoom = parseInt(this.el.dataset.zoom);
|
const initialZoom = parseInt(this.el.dataset.zoom);
|
||||||
|
|
||||||
|
console.log("Initializing map with center:", initialCenter, "and zoom:", initialZoom);
|
||||||
|
console.log("Map container element:", this.el);
|
||||||
|
console.log("Container dimensions:", this.el.offsetWidth, "x", this.el.offsetHeight);
|
||||||
|
console.log("Parsed coordinates:", initialCenter.lat, initialCenter.lng, "zoom:", initialZoom);
|
||||||
|
|
||||||
// Initialize the map with the server-provided location
|
// Initialize the map with the server-provided location
|
||||||
const map = L.map(this.el).setView([initialCenter.lat, initialCenter.lng], initialZoom);
|
const map = L.map(this.el, {
|
||||||
console.log("Map initialized:", map);
|
zoomControl: true,
|
||||||
|
attributionControl: true,
|
||||||
|
closePopupOnClick: true,
|
||||||
|
}).setView([initialCenter.lat, initialCenter.lng], initialZoom);
|
||||||
|
|
||||||
|
console.log("Map setView called with:", [initialCenter.lat, initialCenter.lng], initialZoom);
|
||||||
|
console.log("Map object created:", map);
|
||||||
|
|
||||||
|
// Validate container size and force refresh if needed
|
||||||
|
if (this.el.offsetWidth === 0 || this.el.offsetHeight === 0) {
|
||||||
|
console.warn("Map container has zero dimensions, forcing size refresh");
|
||||||
|
setTimeout(() => {
|
||||||
|
map.invalidateSize();
|
||||||
|
map.setView([initialCenter.lat, initialCenter.lng], initialZoom);
|
||||||
|
console.log("Map size refreshed and view reset");
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force initial size calculation
|
||||||
|
map.invalidateSize();
|
||||||
|
|
||||||
|
// Track when map is ready
|
||||||
|
map.whenReady(() => {
|
||||||
|
console.log("Map is fully ready and rendered");
|
||||||
|
console.log("Map center after ready:", map.getCenter());
|
||||||
|
console.log("Map zoom after ready:", map.getZoom());
|
||||||
|
console.log("Map bounds after ready:", map.getBounds());
|
||||||
|
this.mapReady = true;
|
||||||
|
this.pushEvent("map_ready", {});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Map initialized");
|
||||||
|
|
||||||
// Handle geolocation requests from server
|
// Handle geolocation requests from server
|
||||||
this.handleEvent("request_geolocation", () => {
|
this.handleEvent("request_geolocation", () => {
|
||||||
|
|
@ -112,25 +148,107 @@ Hooks.APRSMap = {
|
||||||
|
|
||||||
// Store markers to avoid duplicates
|
// Store markers to avoid duplicates
|
||||||
this.markers = new Map();
|
this.markers = new Map();
|
||||||
|
this.historicalMarkers = new Map();
|
||||||
this.packetCount = 0;
|
this.packetCount = 0;
|
||||||
|
|
||||||
// Store map instance
|
// Store map instance and initialize state
|
||||||
this.map = map;
|
this.map = map;
|
||||||
|
this.mapReady = false;
|
||||||
|
this.userLocationMarker = null;
|
||||||
|
|
||||||
// Send initial bounds to server
|
// Send initial bounds to server
|
||||||
this.sendBoundsToServer();
|
this.sendBoundsToServer();
|
||||||
|
|
||||||
// Listen for new packets from the server
|
// Listen for new packets from the server
|
||||||
this.handleEvent("new_packet", (packet) => {
|
this.handleEvent("new_packet", (packet) => {
|
||||||
console.log("Received new packet:", packet);
|
|
||||||
this.addPacketMarker(packet);
|
this.addPacketMarker(packet);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for historical packets
|
||||||
|
this.handleEvent("historical_packet", (packet) => {
|
||||||
|
this.addPacketMarker(packet, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for zoom to location event
|
||||||
|
this.handleEvent("zoom_to_location", (data) => {
|
||||||
|
console.log("Received zoom_to_location event:", data);
|
||||||
|
|
||||||
|
if (data.lat && data.lng) {
|
||||||
|
// Convert to numbers to ensure proper handling
|
||||||
|
const lat = parseFloat(data.lat);
|
||||||
|
const lng = parseFloat(data.lng);
|
||||||
|
const zoom = parseInt(data.zoom || 12);
|
||||||
|
|
||||||
|
console.log(`Setting map view to [${lat}, ${lng}] with zoom ${zoom}`);
|
||||||
|
|
||||||
|
// Force map invalidation before setting view
|
||||||
|
this.map.invalidateSize();
|
||||||
|
|
||||||
|
// Check container dimensions before zoom
|
||||||
|
if (this.el.offsetWidth === 0 || this.el.offsetHeight === 0) {
|
||||||
|
console.warn(
|
||||||
|
"Container has zero dimensions during zoom, width:",
|
||||||
|
this.el.offsetWidth,
|
||||||
|
"height:",
|
||||||
|
this.el.offsetHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay to ensure map is ready
|
||||||
|
setTimeout(() => {
|
||||||
|
// Force another size refresh right before setView
|
||||||
|
this.map.invalidateSize();
|
||||||
|
console.log("About to call setView with:", [lat, lng], zoom);
|
||||||
|
this.map.setView([lat, lng], zoom, {
|
||||||
|
animate: true,
|
||||||
|
duration: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force final size refresh after setView
|
||||||
|
setTimeout(() => {
|
||||||
|
this.map.invalidateSize();
|
||||||
|
}, 100);
|
||||||
|
console.log("Map view updated after delay");
|
||||||
|
console.log("New map center:", this.map.getCenter());
|
||||||
|
console.log("New map zoom:", this.map.getZoom());
|
||||||
|
|
||||||
|
// Add a marker at the user's location
|
||||||
|
if (this.userLocationMarker) {
|
||||||
|
this.map.removeLayer(this.userLocationMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.userLocationMarker = L.marker([lat, lng], {
|
||||||
|
icon: L.divIcon({
|
||||||
|
html: '<div style="background-color: #4299e1; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.4);"></div>',
|
||||||
|
className: "user-location-marker",
|
||||||
|
iconSize: [16, 16],
|
||||||
|
iconAnchor: [8, 8],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.addTo(this.map)
|
||||||
|
.bindPopup("Your location")
|
||||||
|
.openPopup();
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
console.error("Invalid coordinates in zoom_to_location event:", data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for clearing historical packets
|
||||||
|
this.handleEvent("clear_historical_packets", () => {
|
||||||
|
this.clearHistoricalMarkers();
|
||||||
|
});
|
||||||
|
|
||||||
// Listen for clear markers event
|
// Listen for clear markers event
|
||||||
this.handleEvent("clear_markers", () => {
|
this.handleEvent("clear_markers", () => {
|
||||||
this.clearAllMarkers();
|
this.clearAllMarkers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for refresh markers event
|
||||||
|
this.handleEvent("refresh_markers", () => {
|
||||||
|
this.refreshMarkers();
|
||||||
|
});
|
||||||
|
|
||||||
// Handle geolocation button clicks
|
// Handle geolocation button clicks
|
||||||
this.handleEvent("request_geolocation", () => {
|
this.handleEvent("request_geolocation", () => {
|
||||||
if ("geolocation" in navigator) {
|
if ("geolocation" in navigator) {
|
||||||
|
|
@ -151,7 +269,11 @@ Hooks.APRSMap = {
|
||||||
|
|
||||||
// Update bounds when map moves or zooms
|
// Update bounds when map moves or zooms
|
||||||
map.on("moveend", () => {
|
map.on("moveend", () => {
|
||||||
this.sendBoundsToServer();
|
// Debounce the bounds update to avoid too many server calls
|
||||||
|
if (this.boundsTimer) clearTimeout(this.boundsTimer);
|
||||||
|
this.boundsTimer = setTimeout(() => {
|
||||||
|
this.sendBoundsToServer();
|
||||||
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle map resize when window is resized
|
// Handle map resize when window is resized
|
||||||
|
|
@ -184,8 +306,12 @@ Hooks.APRSMap = {
|
||||||
this.markers.forEach((marker) => {
|
this.markers.forEach((marker) => {
|
||||||
this.map.removeLayer(marker);
|
this.map.removeLayer(marker);
|
||||||
});
|
});
|
||||||
|
this.historicalMarkers.forEach((marker) => {
|
||||||
|
this.map.removeLayer(marker);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this.markers.clear();
|
this.markers.clear();
|
||||||
|
this.historicalMarkers.clear();
|
||||||
this.packetCount = 0;
|
this.packetCount = 0;
|
||||||
const counterElement = document.getElementById("packet-count");
|
const counterElement = document.getElementById("packet-count");
|
||||||
if (counterElement) {
|
if (counterElement) {
|
||||||
|
|
@ -193,36 +319,70 @@ Hooks.APRSMap = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
removeMarkersOutsideBounds(bounds) {
|
refreshMarkers() {
|
||||||
// With clustering, the cluster group handles visibility automatically
|
// More efficient refresh that doesn't require clearing all markers
|
||||||
// We'll just track the count of markers within bounds
|
// Filter markers to keep only those added in the last hour
|
||||||
let visibleCount = 0;
|
const now = new Date();
|
||||||
|
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// First, collect markers to remove
|
||||||
|
const markersToRemove = [];
|
||||||
this.markers.forEach((marker, callsign) => {
|
this.markers.forEach((marker, callsign) => {
|
||||||
const latLng = marker.getLatLng();
|
if (marker.addedAt && marker.addedAt < oneHourAgo) {
|
||||||
if (bounds.contains(latLng)) {
|
markersToRemove.push(callsign);
|
||||||
visibleCount++;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update counter to show visible markers
|
// Then remove them from the map and collection
|
||||||
|
markersToRemove.forEach((callsign) => {
|
||||||
|
const marker = this.markers.get(callsign);
|
||||||
|
if (marker) {
|
||||||
|
if (this.markerClusterGroup) {
|
||||||
|
this.markerClusterGroup.removeLayer(marker);
|
||||||
|
} else {
|
||||||
|
this.map.removeLayer(marker);
|
||||||
|
}
|
||||||
|
this.markers.delete(callsign);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update counter
|
||||||
|
this.packetCount = this.markers.size;
|
||||||
const counterElement = document.getElementById("packet-count");
|
const counterElement = document.getElementById("packet-count");
|
||||||
if (counterElement) {
|
if (counterElement) {
|
||||||
counterElement.textContent = visibleCount;
|
counterElement.textContent = this.packetCount;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Notify server of the updated packet count
|
clearHistoricalMarkers() {
|
||||||
|
// Remove only historical markers
|
||||||
|
if (this.markerClusterGroup) {
|
||||||
|
this.historicalMarkers.forEach((marker) => {
|
||||||
|
this.markerClusterGroup.removeLayer(marker);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: remove markers directly from map
|
||||||
|
this.historicalMarkers.forEach((marker) => {
|
||||||
|
this.map.removeLayer(marker);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.historicalMarkers.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
removeMarkersOutsideBounds(bounds) {
|
||||||
|
// Let LiveView handle the packet count
|
||||||
|
// The cluster handles marker visibility automatically
|
||||||
|
const visibleCount = this.markers.size;
|
||||||
this.pushEvent("update_packet_count", { count: visibleCount });
|
this.pushEvent("update_packet_count", { count: visibleCount });
|
||||||
},
|
},
|
||||||
|
|
||||||
addPacketMarker(packet) {
|
addPacketMarker(packet, isHistorical = false) {
|
||||||
console.log("addPacketMarker called with:", packet);
|
// Skip packets without required data
|
||||||
if (
|
if (
|
||||||
!packet["data_extended"] ||
|
!packet["data_extended"] ||
|
||||||
!packet["data_extended"]["latitude"] ||
|
!packet["data_extended"]["latitude"] ||
|
||||||
!packet["data_extended"]["longitude"]
|
!packet["data_extended"]["longitude"]
|
||||||
) {
|
) {
|
||||||
console.warn("Packet missing required location data:", packet);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,76 +391,112 @@ Hooks.APRSMap = {
|
||||||
|
|
||||||
// Validate coordinates are within valid ranges
|
// Validate coordinates are within valid ranges
|
||||||
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
||||||
console.error("Invalid coordinates:", { lat, lng, packet });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log("Valid coordinates:", { lat, lng, callsign: packet["base_callsign"] });
|
|
||||||
const callsign = packet["base_callsign"] + (packet["ssid"] ? "-" + packet["ssid"] : "");
|
|
||||||
|
|
||||||
// Create popup content
|
// Generate a unique ID for the marker
|
||||||
|
const callsign = packet["base_callsign"] + (packet["ssid"] ? "-" + packet["ssid"] : "");
|
||||||
|
// For historical packets, add a timestamp or unique ID to distinguish them
|
||||||
|
const markerId = isHistorical ? `hist_${callsign}_${Date.now()}` : callsign;
|
||||||
|
|
||||||
|
// Create popup content with historical indicator if needed
|
||||||
|
const timestamp =
|
||||||
|
isHistorical && packet["timestamp"]
|
||||||
|
? new Date(packet["timestamp"]).toLocaleString()
|
||||||
|
: new Date().toLocaleTimeString();
|
||||||
|
|
||||||
const popupContent = `
|
const popupContent = `
|
||||||
<div style="min-width: 200px;">
|
<div style="min-width: 200px;">
|
||||||
<h4 style="margin: 0 0 5px 0; font-weight: bold;">${callsign}</h4>
|
<h4 style="margin: 0 0 5px 0; font-weight: bold;">${callsign} ${isHistorical ? "(Historical)" : ""}</h4>
|
||||||
<p style="margin: 2px 0; font-size: 12px;">
|
<p style="margin: 2px 0; font-size: 12px;">
|
||||||
<strong>Position:</strong> ${lat.toFixed(4)}°, ${lng.toFixed(4)}°<br>
|
<strong>Position:</strong> ${lat.toFixed(4)}°, ${lng.toFixed(4)}°<br>
|
||||||
<strong>Type:</strong> ${packet["data_type"]}<br>
|
<strong>Type:</strong> ${packet["data_type"]}<br>
|
||||||
${packet["data_extended"]["comment"] ? `<strong>Comment:</strong> ${packet["data_extended"]["comment"]}<br>` : ""}
|
${packet["data_extended"]["comment"] ? `<strong>Comment:</strong> ${packet["data_extended"]["comment"]}<br>` : ""}
|
||||||
<strong>Path:</strong> ${packet["path"]}<br>
|
<strong>Path:</strong> ${packet["path"]}<br>
|
||||||
<strong>Time:</strong> ${new Date().toLocaleTimeString()}
|
<strong>Time:</strong> ${timestamp}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Check if marker already exists
|
// Determine which marker collection to use
|
||||||
if (this.markers.has(callsign)) {
|
const markerCollection = isHistorical ? this.historicalMarkers : this.markers;
|
||||||
// Update existing marker
|
|
||||||
const existingMarker = this.markers.get(callsign);
|
// Check if marker already exists (only for non-historical markers)
|
||||||
if (this.markerClusterGroup) {
|
if (!isHistorical && this.markers.has(markerId)) {
|
||||||
// Remove and re-add to cluster group
|
try {
|
||||||
this.markerClusterGroup.removeLayer(existingMarker);
|
// Update existing marker
|
||||||
existingMarker.setLatLng([lat, lng]);
|
const existingMarker = this.markers.get(markerId);
|
||||||
existingMarker.setPopupContent(popupContent);
|
if (this.markerClusterGroup) {
|
||||||
this.markerClusterGroup.addLayer(existingMarker);
|
// Remove and re-add to cluster group
|
||||||
} else {
|
this.markerClusterGroup.removeLayer(existingMarker);
|
||||||
// Fallback: just update position
|
existingMarker.setLatLng([lat, lng]);
|
||||||
existingMarker.setLatLng([lat, lng]);
|
existingMarker.setPopupContent(popupContent);
|
||||||
existingMarker.setPopupContent(popupContent);
|
this.markerClusterGroup.addLayer(existingMarker);
|
||||||
|
} else {
|
||||||
|
// Fallback: just update position
|
||||||
|
existingMarker.setLatLng([lat, lng]);
|
||||||
|
existingMarker.setPopupContent(popupContent);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If there's an error updating, proceed with creating a new marker
|
||||||
|
this.markers.delete(markerId);
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
// Create new marker if it doesn't exist or if we're handling a historical packet
|
||||||
|
if (isHistorical || !this.markers.has(markerId)) {
|
||||||
// Create new marker
|
// Create new marker
|
||||||
const icon = this.createAPRSIcon(
|
const icon = this.createAPRSIcon(
|
||||||
packet["data_extended"]["symbol_table_id"] || "/",
|
packet["data_extended"]["symbol_table_id"] || "/",
|
||||||
packet["data_extended"]["symbol_code"] || ">",
|
packet["data_extended"]["symbol_code"] || ">",
|
||||||
|
isHistorical,
|
||||||
);
|
);
|
||||||
|
|
||||||
const marker = L.marker([lat, lng], { icon: icon }).bindPopup(popupContent);
|
const marker = L.marker([lat, lng], { icon: icon }).bindPopup(popupContent);
|
||||||
|
marker.addedAt = new Date(); // Track when the marker was added
|
||||||
|
|
||||||
// Add to cluster group or directly to map
|
// Add CSS class for historical markers
|
||||||
if (this.markerClusterGroup) {
|
if (isHistorical) {
|
||||||
this.markerClusterGroup.addLayer(marker);
|
marker.options.className = "historical-marker";
|
||||||
} else {
|
|
||||||
marker.addTo(this.map);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.markers.set(callsign, marker);
|
// Add to cluster group or directly to map
|
||||||
console.log("New marker added for:", callsign, "Total markers:", this.markers.size);
|
try {
|
||||||
|
if (this.markerClusterGroup) {
|
||||||
|
this.markerClusterGroup.addLayer(marker);
|
||||||
|
} else {
|
||||||
|
marker.addTo(this.map);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silently ignore errors when adding markers
|
||||||
|
}
|
||||||
|
|
||||||
// Update packet counter
|
// Store the marker
|
||||||
this.packetCount++;
|
markerCollection.set(markerId, marker);
|
||||||
const counterElement = document.getElementById("packet-count");
|
|
||||||
if (counterElement) {
|
// Update packet counter (only for live packets)
|
||||||
counterElement.textContent = this.packetCount;
|
if (!isHistorical) {
|
||||||
|
this.packetCount++;
|
||||||
|
const counterElement = document.getElementById("packet-count");
|
||||||
|
if (counterElement) {
|
||||||
|
counterElement.textContent = this.packetCount;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
createAPRSIcon(symbolTable, symbolCode) {
|
createAPRSIcon(symbolTable, symbolCode, isHistorical = false) {
|
||||||
// Default icon color based on symbol table
|
// Default icon color based on symbol table
|
||||||
const color = symbolTable === "/" ? "#2563eb" : "#dc2626";
|
let color = symbolTable === "/" ? "#2563eb" : "#dc2626";
|
||||||
|
|
||||||
|
// Use a different color for historical markers
|
||||||
|
if (isHistorical) {
|
||||||
|
color = symbolTable === "/" ? "#90b4ed" : "#e98a84";
|
||||||
|
}
|
||||||
|
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
html: `<div style="background-color: ${color}; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.4);"></div>`,
|
html: `<div style="background-color: ${color}; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.4);"></div>`,
|
||||||
className: "aprs-marker",
|
className: isHistorical ? "aprs-marker historical-marker" : "aprs-marker",
|
||||||
iconSize: [16, 16],
|
iconSize: [16, 16],
|
||||||
iconAnchor: [8, 8],
|
iconAnchor: [8, 8],
|
||||||
popupAnchor: [0, -8],
|
popupAnchor: [0, -8],
|
||||||
|
|
@ -308,16 +504,18 @@ Hooks.APRSMap = {
|
||||||
},
|
},
|
||||||
|
|
||||||
destroyed() {
|
destroyed() {
|
||||||
console.log("APRSMap hook destroyed");
|
if (this.boundsTimer) {
|
||||||
|
clearTimeout(this.boundsTimer);
|
||||||
|
}
|
||||||
if (this.markerClusterGroup) {
|
if (this.markerClusterGroup) {
|
||||||
this.markerClusterGroup.clearLayers();
|
this.markerClusterGroup.clearLayers();
|
||||||
}
|
}
|
||||||
if (this.map) {
|
if (this.map) {
|
||||||
console.log("Removing map instance");
|
|
||||||
this.map.remove();
|
this.map.remove();
|
||||||
this.map = null;
|
this.map = null;
|
||||||
}
|
}
|
||||||
this.markers.clear();
|
this.markers.clear();
|
||||||
|
this.historicalMarkers.clear();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,18 @@ config :aprs, AprsWeb.Endpoint,
|
||||||
pubsub_server: Aprs.PubSub,
|
pubsub_server: Aprs.PubSub,
|
||||||
live_view: [signing_salt: "ees098qG"]
|
live_view: [signing_salt: "ees098qG"]
|
||||||
|
|
||||||
|
# Configure Oban for background jobs
|
||||||
|
config :aprs, Oban,
|
||||||
|
repo: Aprs.Repo,
|
||||||
|
plugins: [
|
||||||
|
{Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 7},
|
||||||
|
{Oban.Plugins.Cron,
|
||||||
|
crontab: [
|
||||||
|
{"0 0 * * *", Aprs.Workers.PacketCleanupWorker}
|
||||||
|
]}
|
||||||
|
],
|
||||||
|
queues: [default: 10, maintenance: 2]
|
||||||
|
|
||||||
config :aprs,
|
config :aprs,
|
||||||
ecto_repos: [Aprs.Repo]
|
ecto_repos: [Aprs.Repo]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ defmodule Aprs.Application do
|
||||||
|
|
||||||
use Application
|
use Application
|
||||||
|
|
||||||
|
# Configure Oban for background jobs
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
topologies = Application.get_env(:libcluster, :topologies) || []
|
topologies = Application.get_env(:libcluster, :topologies) || []
|
||||||
|
|
@ -24,6 +26,10 @@ defmodule Aprs.Application do
|
||||||
# {Aprs.Worker, arg}
|
# {Aprs.Worker, arg}
|
||||||
{Registry, keys: :duplicate, name: Registry.PubSub, partitions: System.schedulers_online()},
|
{Registry, keys: :duplicate, name: Registry.PubSub, partitions: System.schedulers_online()},
|
||||||
{Cluster.Supervisor, [topologies, [name: Aprs.ClusterSupervisor]]},
|
{Cluster.Supervisor, [topologies, [name: Aprs.ClusterSupervisor]]},
|
||||||
|
# Start Oban for background jobs
|
||||||
|
{Oban, :aprs |> Application.get_env(Oban, []) |> Keyword.put(:queues, default: 10, maintenance: 2)},
|
||||||
|
# Start the scheduler for recurring Oban jobs
|
||||||
|
Aprs.Workers.Scheduler,
|
||||||
Aprs.Presence
|
Aprs.Presence
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
139
lib/aprs/db_test.ex
Normal file
139
lib/aprs/db_test.ex
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
defmodule Aprs.DbTest do
|
||||||
|
@moduledoc """
|
||||||
|
Helper module for testing database connections and operations from IEx.
|
||||||
|
Run with:
|
||||||
|
iex> Aprs.DbTest.test_packet_storage()
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Aprs.Packet
|
||||||
|
alias Aprs.Packets
|
||||||
|
alias Aprs.Repo
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Tests packet storage functionality by creating and storing a test packet.
|
||||||
|
"""
|
||||||
|
def test_packet_storage do
|
||||||
|
IO.puts("Starting database test...")
|
||||||
|
|
||||||
|
# Test database connection
|
||||||
|
case check_db_connection() do
|
||||||
|
:ok ->
|
||||||
|
# Create a test packet with minimum required fields
|
||||||
|
test_packet = %{
|
||||||
|
base_callsign: "TEST",
|
||||||
|
ssid: "1",
|
||||||
|
sender: "TEST-1",
|
||||||
|
destination: "APRS",
|
||||||
|
data_type: "position",
|
||||||
|
path: "TCPIP*",
|
||||||
|
information_field: "Test packet",
|
||||||
|
received_at: DateTime.utc_now(),
|
||||||
|
lat: 33.5,
|
||||||
|
lon: -97.5,
|
||||||
|
region: "test",
|
||||||
|
has_position: true,
|
||||||
|
data_extended: %{
|
||||||
|
latitude: 33.5,
|
||||||
|
longitude: -97.5,
|
||||||
|
symbol_table_id: "/",
|
||||||
|
symbol_code: ">",
|
||||||
|
comment: "Test comment",
|
||||||
|
aprs_messaging: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IO.puts("Created test packet: #{inspect(test_packet, pretty: true)}")
|
||||||
|
|
||||||
|
# Attempt to store the packet
|
||||||
|
case Packets.store_packet(test_packet) do
|
||||||
|
{:ok, stored_packet} ->
|
||||||
|
IO.puts("Successfully stored packet!")
|
||||||
|
IO.puts("Stored packet ID: #{stored_packet.id}")
|
||||||
|
{:ok, stored_packet}
|
||||||
|
|
||||||
|
{:error, changeset} ->
|
||||||
|
IO.puts("Failed to store packet!")
|
||||||
|
IO.puts("Errors: #{inspect(changeset.errors, pretty: true)}")
|
||||||
|
{:error, changeset}
|
||||||
|
end
|
||||||
|
|
||||||
|
error ->
|
||||||
|
IO.puts("Database connection test failed: #{inspect(error)}")
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Tests if the database is accessible.
|
||||||
|
"""
|
||||||
|
def check_db_connection do
|
||||||
|
IO.puts("Checking database connection...")
|
||||||
|
|
||||||
|
try do
|
||||||
|
# Try to get a connection from the pool
|
||||||
|
Repo.checkout(fn conn ->
|
||||||
|
IO.puts("Got database connection!")
|
||||||
|
Postgrex.query!(conn, "SELECT 1", [])
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Try a simple query through Ecto
|
||||||
|
count = Repo.aggregate(Packet, :count, :id)
|
||||||
|
IO.puts("Current packet count in database: #{count}")
|
||||||
|
|
||||||
|
:ok
|
||||||
|
rescue
|
||||||
|
e ->
|
||||||
|
IO.puts("Database connection error: #{inspect(e)}")
|
||||||
|
{:error, e}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Count packets with position data.
|
||||||
|
"""
|
||||||
|
def count_packets_with_position do
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
IO.puts("Counting packets with position data...")
|
||||||
|
|
||||||
|
try do
|
||||||
|
count = Repo.aggregate(from(p in Packet, where: p.has_position == true), :count, :id)
|
||||||
|
|
||||||
|
IO.puts("Found #{count} packets with position data")
|
||||||
|
{:ok, count}
|
||||||
|
rescue
|
||||||
|
e ->
|
||||||
|
IO.puts("Query error: #{inspect(e)}")
|
||||||
|
{:error, e}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
List the most recent packets with position data.
|
||||||
|
"""
|
||||||
|
def list_recent_packets(limit \\ 10) do
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
IO.puts("Fetching #{limit} most recent packets...")
|
||||||
|
|
||||||
|
try do
|
||||||
|
packets = Repo.all(from(p in Packet, where: p.has_position == true, order_by: [desc: p.received_at], limit: ^limit))
|
||||||
|
|
||||||
|
IO.puts("Found #{length(packets)} recent packets")
|
||||||
|
|
||||||
|
if length(packets) > 0 do
|
||||||
|
Enum.each(packets, fn packet ->
|
||||||
|
IO.puts("#{packet.sender} at #{packet.lat}, #{packet.lon} - #{packet.received_at}")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, packets}
|
||||||
|
rescue
|
||||||
|
e ->
|
||||||
|
IO.puts("Query error: #{inspect(e)}")
|
||||||
|
{:error, e}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -279,37 +279,45 @@ defmodule Aprs.Is do
|
||||||
def dispatch(message) do
|
def dispatch(message) do
|
||||||
case Parser.parse(message) do
|
case Parser.parse(message) do
|
||||||
{:ok, parsed_message} ->
|
{:ok, parsed_message} ->
|
||||||
# IO.inspect(parsed_message)
|
# Store the packet in the database for future replay
|
||||||
# time_message_received =
|
# Use Task to avoid slowing down the main process
|
||||||
# :calendar.universal_time() |> :calendar.datetime_to_gregorian_seconds()
|
Task.start(fn ->
|
||||||
|
require Logger
|
||||||
|
# Store the packet if it has position data
|
||||||
|
if has_position_data?(parsed_message) do
|
||||||
|
Logger.info("Storing packet with position data: #{inspect(parsed_message.sender)}")
|
||||||
|
# Always set received_at timestamp to ensure consistency
|
||||||
|
current_time = DateTime.truncate(DateTime.utc_now(), :microsecond)
|
||||||
|
packet_data = Map.put(parsed_message, :received_at, current_time)
|
||||||
|
|
||||||
# This doesnt work if dispatch if spawned as a task, since it does not own the table
|
# Convert to map before storing to avoid struct conversion issues
|
||||||
# :ets.insert(:aprs, {parsed_message.sender, message, time_message_received})
|
attrs = Map.from_struct(packet_data)
|
||||||
|
|
||||||
# Get messages since last time the callsign was seen
|
# Normalize data_type to string if it's an atom
|
||||||
# TODO: Get last seen timestamp and use that
|
attrs = normalize_data_type(attrs)
|
||||||
# _message_count =
|
|
||||||
# case :ets.lookup(:aprs_messages, parsed_message.sender) do
|
|
||||||
# [{_callsign, _message, timestamp}] ->
|
|
||||||
# recent_messages_for(parsed_message.sender, timestamp)
|
|
||||||
|
|
||||||
# [] ->
|
# Ensure SSID is never nil
|
||||||
# 0
|
attrs =
|
||||||
|
if Map.has_key?(attrs, :ssid) and is_nil(attrs.ssid) do
|
||||||
|
Map.put(attrs, :ssid, "0")
|
||||||
|
else
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
|
||||||
# _ ->
|
# Store in database through the Packets context
|
||||||
# 0
|
case Aprs.Packets.store_packet(attrs) do
|
||||||
# end
|
{:ok, packet} ->
|
||||||
|
Logger.info("Successfully stored packet from #{packet.sender}")
|
||||||
|
|
||||||
# Logger.debug("#{message_count} recent messages for #{parsed_message.sender}")
|
{:error, changeset} ->
|
||||||
|
Logger.error("Failed to store packet: #{inspect(changeset.errors)}")
|
||||||
# Do something interesting with the message
|
end
|
||||||
# process(parsed_message, parsed_message.data_type)
|
else
|
||||||
|
Logger.debug("Skipping packet without position data: #{inspect(parsed_message.sender)}")
|
||||||
# Publish the parsed message to all interested parties
|
end
|
||||||
# Registry.dispatch(Registry.PubSub, "aprs_messages", fn entries ->
|
end)
|
||||||
# for {pid, _} <- entries, do: send(pid, {:broadcast, parsed_message})
|
|
||||||
# end)
|
|
||||||
|
|
||||||
|
# Broadcast to live clients
|
||||||
AprsWeb.Endpoint.broadcast("aprs_messages", "packet", parsed_message)
|
AprsWeb.Endpoint.broadcast("aprs_messages", "packet", parsed_message)
|
||||||
|
|
||||||
# Logger.debug("BROADCAST: " <> inspect(parsed_message))
|
# Logger.debug("BROADCAST: " <> inspect(parsed_message))
|
||||||
|
|
@ -333,21 +341,98 @@ defmodule Aprs.Is do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# def process(message, message_type) when message_type == :message do
|
# Helper to check if a packet has position data worth storing
|
||||||
# # time_message_received =
|
defp has_position_data?(packet) do
|
||||||
# # :calendar.universal_time() |> :calendar.datetime_to_gregorian_seconds()
|
require Logger
|
||||||
|
|
||||||
# # :ets.insert(
|
result =
|
||||||
# # :aprs_messages,
|
case packet do
|
||||||
# # {message.data_extended.to, message.data_extended, time_message_received}
|
%{data_extended: nil} ->
|
||||||
# # )
|
Logger.debug("Packet has nil data_extended: #{inspect(packet.sender)}")
|
||||||
# end
|
false
|
||||||
|
|
||||||
# def process(_, _), do: nil
|
%{data_extended: %{latitude: lat, longitude: lon}} when not is_nil(lat) and not is_nil(lon) ->
|
||||||
|
# Check if coordinates are valid numbers
|
||||||
|
valid = are_valid_coords?(lat, lon)
|
||||||
|
|
||||||
|
if !valid do
|
||||||
|
Logger.debug("Invalid coordinates: lat=#{inspect(lat)}, lon=#{inspect(lon)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
valid
|
||||||
|
|
||||||
|
%{data_extended: %Parser.Types.MicE{} = mic_e} ->
|
||||||
|
# MicE packets have lat/lon in separate components
|
||||||
|
valid =
|
||||||
|
is_number(mic_e.lat_degrees) and is_number(mic_e.lat_minutes) and
|
||||||
|
is_number(mic_e.lon_degrees) and is_number(mic_e.lon_minutes)
|
||||||
|
|
||||||
|
Logger.debug("MicE packet position check: #{valid} for #{inspect(packet.sender)}")
|
||||||
|
valid
|
||||||
|
|
||||||
|
%{lat: lat, lon: lon} when not is_nil(lat) and not is_nil(lon) ->
|
||||||
|
# Handle case where coordinates are at top level
|
||||||
|
are_valid_coords?(lat, lon)
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
Logger.debug("Unrecognized packet format: #{inspect(Map.keys(packet))}")
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
Logger.debug("Position data check for #{inspect(packet.sender)}: #{result}")
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to validate coordinate values
|
||||||
|
defp are_valid_coords?(lat, lon) do
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
# Convert to float if possible
|
||||||
|
lat_float = to_float(lat)
|
||||||
|
lon_float = to_float(lon)
|
||||||
|
|
||||||
|
# Log coordinate conversion
|
||||||
|
if !is_number(lat_float) do
|
||||||
|
Logger.debug("Could not convert latitude to float: #{inspect(lat)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
if !is_number(lon_float) do
|
||||||
|
Logger.debug("Could not convert longitude to float: #{inspect(lon)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if conversion succeeded and values are in valid ranges
|
||||||
|
result =
|
||||||
|
is_number(lat_float) and is_number(lon_float) and
|
||||||
|
lat_float >= -90 and lat_float <= 90 and lon_float >= -180 and lon_float <= 180
|
||||||
|
|
||||||
|
if !result do
|
||||||
|
Logger.debug("Invalid coordinates: lat=#{inspect(lat_float)}, lon=#{inspect(lon_float)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to convert various types to float
|
||||||
|
defp to_float(value) when is_float(value), do: value
|
||||||
|
defp to_float(value) when is_integer(value), do: value * 1.0
|
||||||
|
defp to_float(%Decimal{} = value), do: Decimal.to_float(value)
|
||||||
|
|
||||||
|
defp to_float(value) when is_binary(value) do
|
||||||
|
case Float.parse(value) do
|
||||||
|
{float, _} -> float
|
||||||
|
:error -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp to_float(_), do: nil
|
||||||
|
|
||||||
|
# Normalize data_type to ensure proper storage
|
||||||
|
defp normalize_data_type(%{data_type: data_type} = attrs) when is_atom(data_type) do
|
||||||
|
%{attrs | data_type: to_string(data_type)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_data_type(attrs), do: attrs
|
||||||
|
|
||||||
# def recent_messages_for(callsign, since_time) do
|
|
||||||
# callsign_guard = {:==, :"$1", {:const, callsign}}
|
|
||||||
# timestamp_guard = {:>=, :"$2", {:const, since_time}}
|
|
||||||
# total_spec = [{{:"$1", :_, :"$2"}, [{:andalso, callsign_guard, timestamp_guard}], [true]}]
|
# total_spec = [{{:"$1", :_, :"$2"}, [{:andalso, callsign_guard, timestamp_guard}], [true]}]
|
||||||
# :ets.select_count(:aprs_messages, total_spec)
|
# :ets.select_count(:aprs_messages, total_spec)
|
||||||
# end
|
# end
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ defmodule Aprs.Packet do
|
||||||
field(:path, :string)
|
field(:path, :string)
|
||||||
field(:sender, :string)
|
field(:sender, :string)
|
||||||
field(:ssid, :string)
|
field(:ssid, :string)
|
||||||
|
field(:received_at, :utc_datetime_usec)
|
||||||
|
field(:region, :string)
|
||||||
|
field(:lat, :float)
|
||||||
|
field(:lon, :float)
|
||||||
|
field(:has_position, :boolean, default: false)
|
||||||
embeds_one(:data_extended, DataExtended)
|
embeds_one(:data_extended, DataExtended)
|
||||||
|
|
||||||
timestamps()
|
timestamps()
|
||||||
|
|
@ -21,6 +26,9 @@ defmodule Aprs.Packet do
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
def changeset(packet, attrs) do
|
def changeset(packet, attrs) do
|
||||||
|
# Convert atom data_type to string
|
||||||
|
attrs = normalize_data_type(attrs)
|
||||||
|
|
||||||
packet
|
packet
|
||||||
|> cast(attrs, [
|
|> cast(attrs, [
|
||||||
:base_callsign,
|
:base_callsign,
|
||||||
|
|
@ -29,7 +37,12 @@ defmodule Aprs.Packet do
|
||||||
:information_field,
|
:information_field,
|
||||||
:path,
|
:path,
|
||||||
:sender,
|
:sender,
|
||||||
:ssid
|
:ssid,
|
||||||
|
:received_at,
|
||||||
|
:region,
|
||||||
|
:lat,
|
||||||
|
:lon,
|
||||||
|
:has_position
|
||||||
])
|
])
|
||||||
|> validate_required([
|
|> validate_required([
|
||||||
:base_callsign,
|
:base_callsign,
|
||||||
|
|
@ -38,7 +51,40 @@ defmodule Aprs.Packet do
|
||||||
:information_field,
|
:information_field,
|
||||||
:path,
|
:path,
|
||||||
:sender,
|
:sender,
|
||||||
:ssid
|
:ssid,
|
||||||
|
:received_at
|
||||||
])
|
])
|
||||||
|
|> maybe_set_has_position()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_set_has_position(changeset) do
|
||||||
|
if (get_field(changeset, :lat) && get_field(changeset, :lon)) ||
|
||||||
|
(get_change(changeset, :data_extended) &&
|
||||||
|
get_change(changeset, :data_extended).latitude &&
|
||||||
|
get_change(changeset, :data_extended).longitude) do
|
||||||
|
put_change(changeset, :has_position, true)
|
||||||
|
else
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Convert atom data_type to string for storage
|
||||||
|
defp normalize_data_type(%{data_type: data_type} = attrs) when is_atom(data_type) do
|
||||||
|
%{attrs | data_type: to_string(data_type)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_data_type(%{"data_type" => data_type} = attrs) when is_atom(data_type) do
|
||||||
|
%{attrs | "data_type" => to_string(data_type)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle :data_type key access format
|
||||||
|
defp normalize_data_type(attrs) when is_map(attrs) do
|
||||||
|
if Map.has_key?(attrs, :data_type) and is_atom(attrs.data_type) do
|
||||||
|
%{attrs | data_type: to_string(attrs.data_type)}
|
||||||
|
else
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_data_type(attrs), do: attrs
|
||||||
end
|
end
|
||||||
|
|
|
||||||
401
lib/aprs/packet_replay.ex
Normal file
401
lib/aprs/packet_replay.ex
Normal file
|
|
@ -0,0 +1,401 @@
|
||||||
|
defmodule Aprs.PacketReplay do
|
||||||
|
@moduledoc """
|
||||||
|
Handles replaying of historical APRS packets alongside live packets.
|
||||||
|
|
||||||
|
This module provides functionality to:
|
||||||
|
1. Start a replay session for historical packets
|
||||||
|
2. Stream historical packets to a client with timing similar to original reception
|
||||||
|
3. Merge historical packet streams with live packets
|
||||||
|
4. Control replay parameters (speed, filters, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
alias Aprs.Packets
|
||||||
|
alias AprsWeb.Endpoint
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@default_replay_window_minutes 60
|
||||||
|
@default_replay_speed 5.0
|
||||||
|
|
||||||
|
# Client API
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Starts a packet replay session for a specific user/connection.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
* `:user_id` - Unique identifier for the user/session (required)
|
||||||
|
* `:bounds` - Bounding box for visible map area [min_lon, min_lat, max_lon, max_lat] (required)
|
||||||
|
* `:callsign` - Filter packets by callsign (optional)
|
||||||
|
* `:start_time` - Start time for replay (default: 60 minutes ago)
|
||||||
|
* `:end_time` - End time for replay (default: now)
|
||||||
|
* `:replay_speed` - Speed multiplier for replay (default: 5.0)
|
||||||
|
* `:limit` - Maximum number of packets to replay (default: 5000)
|
||||||
|
* `:with_position` - Only include packets with position data (default: true)
|
||||||
|
"""
|
||||||
|
def start_replay(opts) do
|
||||||
|
user_id = Keyword.fetch!(opts, :user_id)
|
||||||
|
|
||||||
|
# Ensure bounds are provided for map area filtering
|
||||||
|
bounds = Keyword.get(opts, :bounds)
|
||||||
|
|
||||||
|
if !bounds do
|
||||||
|
raise ArgumentError, "Map bounds are required for packet replay"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Convert user_id to process name
|
||||||
|
name = via_tuple(user_id)
|
||||||
|
|
||||||
|
GenServer.start_link(__MODULE__, opts, name: name)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Stops an active replay session for a user.
|
||||||
|
"""
|
||||||
|
def stop_replay(user_id) do
|
||||||
|
name = via_tuple(user_id)
|
||||||
|
|
||||||
|
if GenServer.whereis(name) do
|
||||||
|
GenServer.stop(name)
|
||||||
|
else
|
||||||
|
{:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Pauses an active replay session.
|
||||||
|
"""
|
||||||
|
def pause_replay(user_id) do
|
||||||
|
name = via_tuple(user_id)
|
||||||
|
GenServer.call(name, :pause)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Resumes a paused replay session.
|
||||||
|
"""
|
||||||
|
def resume_replay(user_id) do
|
||||||
|
name = via_tuple(user_id)
|
||||||
|
GenServer.call(name, :resume)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Changes the replay speed.
|
||||||
|
"""
|
||||||
|
def set_replay_speed(user_id, speed) when is_number(speed) and speed > 0 do
|
||||||
|
name = via_tuple(user_id)
|
||||||
|
GenServer.call(name, {:set_speed, speed})
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates replay filters (region, bounds, callsign, etc.)
|
||||||
|
"""
|
||||||
|
def update_filters(user_id, filters) when is_list(filters) do
|
||||||
|
name = via_tuple(user_id)
|
||||||
|
GenServer.call(name, {:update_filters, filters})
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets information about the current replay session.
|
||||||
|
"""
|
||||||
|
def get_replay_info(user_id) do
|
||||||
|
name = via_tuple(user_id)
|
||||||
|
GenServer.call(name, :get_info)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Server implementation
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(opts) do
|
||||||
|
user_id = Keyword.fetch!(opts, :user_id)
|
||||||
|
|
||||||
|
# Default time range - always use last hour at most
|
||||||
|
now = DateTime.utc_now()
|
||||||
|
window_minutes = @default_replay_window_minutes
|
||||||
|
|
||||||
|
# Force start time to be at most 1 hour ago
|
||||||
|
default_start_time = DateTime.add(now, -window_minutes * 60, :second)
|
||||||
|
user_start_time = Keyword.get(opts, :start_time)
|
||||||
|
|
||||||
|
# If user provided a start time, make sure it's not older than 1 hour
|
||||||
|
effective_start_time =
|
||||||
|
if user_start_time && DateTime.before?(user_start_time, default_start_time) do
|
||||||
|
default_start_time
|
||||||
|
else
|
||||||
|
user_start_time || default_start_time
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set up initial state
|
||||||
|
state = %{
|
||||||
|
user_id: user_id,
|
||||||
|
replay_topic: "replay:#{user_id}",
|
||||||
|
replay_speed: Keyword.get(opts, :replay_speed, @default_replay_speed),
|
||||||
|
start_time: effective_start_time,
|
||||||
|
end_time: Keyword.get(opts, :end_time, now),
|
||||||
|
# Not using region - using bounds instead
|
||||||
|
region: nil,
|
||||||
|
bounds: Keyword.fetch!(opts, :bounds),
|
||||||
|
callsign: Keyword.get(opts, :callsign),
|
||||||
|
with_position: Keyword.get(opts, :with_position, true),
|
||||||
|
limit: Keyword.get(opts, :limit, 5000),
|
||||||
|
paused: false,
|
||||||
|
packets_sent: 0,
|
||||||
|
replay_started_at: now,
|
||||||
|
replay_timer: nil,
|
||||||
|
last_packet_time: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start the replay immediately
|
||||||
|
send(self(), :start_replay)
|
||||||
|
|
||||||
|
{:ok, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(:start_replay, state) do
|
||||||
|
# Fetch historical packets based on filters
|
||||||
|
# Always filter by bounds (visible map area) and limit to packets with position
|
||||||
|
replay_opts = [
|
||||||
|
start_time: state.start_time,
|
||||||
|
end_time: state.end_time,
|
||||||
|
limit: state.limit,
|
||||||
|
callsign: state.callsign,
|
||||||
|
with_position: true,
|
||||||
|
bounds: state.bounds
|
||||||
|
]
|
||||||
|
|
||||||
|
# Log the start of replay with map bounds
|
||||||
|
Logger.info("Starting packet replay for user #{state.user_id} in map area #{inspect(state.bounds)} for the last hour")
|
||||||
|
|
||||||
|
# Send notification to client that replay is starting
|
||||||
|
Endpoint.broadcast(state.replay_topic, "replay_started", %{
|
||||||
|
total_packets: Packets.get_historical_packet_count(replay_opts),
|
||||||
|
start_time: state.start_time,
|
||||||
|
end_time: state.end_time,
|
||||||
|
replay_speed: state.replay_speed,
|
||||||
|
bounds: state.bounds
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get packets and start streaming
|
||||||
|
stream = Packets.stream_packets_for_replay(Keyword.put(replay_opts, :playback_speed, state.replay_speed))
|
||||||
|
|
||||||
|
# Schedule the first packet
|
||||||
|
case stream |> Stream.take(1) |> Enum.to_list() do
|
||||||
|
[{delay, packet}] ->
|
||||||
|
# Convert delay to milliseconds
|
||||||
|
delay_ms = trunc(delay * 1000)
|
||||||
|
timer = Process.send_after(self(), {:send_packet, packet, stream}, delay_ms)
|
||||||
|
|
||||||
|
{:noreply, %{state | replay_timer: timer, last_packet_time: packet.received_at}}
|
||||||
|
|
||||||
|
[] ->
|
||||||
|
# No packets found, end replay immediately
|
||||||
|
Endpoint.broadcast(state.replay_topic, "replay_complete", %{
|
||||||
|
packets_sent: 0,
|
||||||
|
message: "No matching packets found for replay"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:stop, :normal, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:send_packet, packet, stream}, %{paused: true} = state) do
|
||||||
|
# If paused, reschedule the current packet
|
||||||
|
timer = Process.send_after(self(), {:send_packet, packet, stream}, 1000)
|
||||||
|
{:noreply, %{state | replay_timer: timer}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:send_packet, packet, stream}, state) do
|
||||||
|
# Send the packet to the client
|
||||||
|
Endpoint.broadcast(state.replay_topic, "historical_packet", %{
|
||||||
|
packet: sanitize_packet_for_transport(packet),
|
||||||
|
timestamp: packet.received_at,
|
||||||
|
is_historical: true
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update counter
|
||||||
|
new_packets_sent = state.packets_sent + 1
|
||||||
|
|
||||||
|
# Schedule the next packet
|
||||||
|
case stream |> Stream.take(1) |> Enum.to_list() do
|
||||||
|
[{delay, next_packet}] ->
|
||||||
|
# Convert delay to milliseconds
|
||||||
|
delay_ms = trunc(delay * 1000)
|
||||||
|
timer = Process.send_after(self(), {:send_packet, next_packet, stream}, delay_ms)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
%{state | replay_timer: timer, packets_sent: new_packets_sent, last_packet_time: next_packet.received_at}}
|
||||||
|
|
||||||
|
[] ->
|
||||||
|
# No more packets, end replay
|
||||||
|
Endpoint.broadcast(state.replay_topic, "replay_complete", %{
|
||||||
|
packets_sent: new_packets_sent,
|
||||||
|
message: "Replay complete"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:stop, :normal, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(:pause, _from, state) do
|
||||||
|
if state.replay_timer do
|
||||||
|
Process.cancel_timer(state.replay_timer)
|
||||||
|
end
|
||||||
|
|
||||||
|
Endpoint.broadcast(state.replay_topic, "replay_paused", %{
|
||||||
|
packets_sent: state.packets_sent,
|
||||||
|
last_packet_time: state.last_packet_time
|
||||||
|
})
|
||||||
|
|
||||||
|
{:reply, :ok, %{state | paused: true, replay_timer: nil}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(:resume, _from, %{paused: true} = state) do
|
||||||
|
# Force the next packet to be sent soon
|
||||||
|
send(self(), {:continue_replay})
|
||||||
|
|
||||||
|
Endpoint.broadcast(state.replay_topic, "replay_resumed", %{
|
||||||
|
packets_sent: state.packets_sent,
|
||||||
|
last_packet_time: state.last_packet_time
|
||||||
|
})
|
||||||
|
|
||||||
|
{:reply, :ok, %{state | paused: false}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(:resume, _from, state) do
|
||||||
|
# Already running, just acknowledge
|
||||||
|
{:reply, :ok, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:set_speed, speed}, _from, state) do
|
||||||
|
# Update speed and notify client
|
||||||
|
Endpoint.broadcast(state.replay_topic, "replay_speed_changed", %{
|
||||||
|
replay_speed: speed
|
||||||
|
})
|
||||||
|
|
||||||
|
{:reply, :ok, %{state | replay_speed: speed}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:update_filters, filters}, _from, state) do
|
||||||
|
# Update filters - this requires restarting the replay
|
||||||
|
if state.replay_timer do
|
||||||
|
Process.cancel_timer(state.replay_timer)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update state with new filters
|
||||||
|
new_state =
|
||||||
|
Enum.reduce(filters, state, fn {key, value}, acc ->
|
||||||
|
Map.put(acc, key, value)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Always ensure we're filtering by map bounds
|
||||||
|
new_state =
|
||||||
|
if not Map.has_key?(new_state, :bounds) or is_nil(new_state.bounds) do
|
||||||
|
# Keep existing bounds if none provided
|
||||||
|
new_state
|
||||||
|
else
|
||||||
|
# Validate new bounds if provided
|
||||||
|
case new_state.bounds do
|
||||||
|
[min_lon, min_lat, max_lon, max_lat]
|
||||||
|
when is_number(min_lon) and is_number(min_lat) and
|
||||||
|
is_number(max_lon) and is_number(max_lat) ->
|
||||||
|
new_state
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
# Invalid bounds format, keep old bounds
|
||||||
|
%{new_state | bounds: state.bounds}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Restart replay with new filters
|
||||||
|
send(self(), :start_replay)
|
||||||
|
|
||||||
|
{:reply, :ok, %{new_state | replay_timer: nil, packets_sent: 0}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(:get_info, _from, state) do
|
||||||
|
info = %{
|
||||||
|
user_id: state.user_id,
|
||||||
|
replay_speed: state.replay_speed,
|
||||||
|
start_time: state.start_time,
|
||||||
|
end_time: state.end_time,
|
||||||
|
bounds: state.bounds,
|
||||||
|
callsign: state.callsign,
|
||||||
|
with_position: state.with_position,
|
||||||
|
packets_sent: state.packets_sent,
|
||||||
|
paused: state.paused,
|
||||||
|
replay_started_at: state.replay_started_at,
|
||||||
|
last_packet_time: state.last_packet_time
|
||||||
|
}
|
||||||
|
|
||||||
|
{:reply, info, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def terminate(_reason, state) do
|
||||||
|
# Clean up any timers
|
||||||
|
if state.replay_timer do
|
||||||
|
Process.cancel_timer(state.replay_timer)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Notify client that replay has ended
|
||||||
|
Endpoint.broadcast(state.replay_topic, "replay_stopped", %{
|
||||||
|
packets_sent: state.packets_sent,
|
||||||
|
message: "Replay stopped"
|
||||||
|
})
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper functions
|
||||||
|
|
||||||
|
defp via_tuple(user_id) do
|
||||||
|
{:via, Registry, {Aprs.ReplayRegistry, "replay:#{user_id}"}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanitize_packet_for_transport(packet) do
|
||||||
|
# Convert to map and ensure all fields are JSON-safe
|
||||||
|
packet
|
||||||
|
|> Map.from_struct()
|
||||||
|
|> Map.drop([:__meta__, :__struct__])
|
||||||
|
|> sanitize_map_values()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanitize_map_values(map) when is_map(map) do
|
||||||
|
Enum.reduce(map, %{}, fn {k, v}, acc ->
|
||||||
|
Map.put(acc, k, sanitize_value(v))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanitize_value(value) when is_map(value) do
|
||||||
|
sanitize_map_values(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanitize_value(value) when is_list(value) do
|
||||||
|
Enum.map(value, &sanitize_value/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanitize_value(%DateTime{} = dt) do
|
||||||
|
DateTime.to_iso8601(dt)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanitize_value(%NaiveDateTime{} = dt) do
|
||||||
|
NaiveDateTime.to_iso8601(dt)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanitize_value(%Decimal{} = d) do
|
||||||
|
Decimal.to_float(d)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanitize_value(value) do
|
||||||
|
value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -4,4 +4,300 @@ defmodule Aprs.Packets do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
|
|
||||||
|
alias Aprs.Packet
|
||||||
|
alias Aprs.Repo
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Stores a packet in the database.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
* `packet_data` - Map containing packet data to be stored
|
||||||
|
"""
|
||||||
|
def store_packet(packet_data) do
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
# Convert to map if it's a struct, or use as is if already a map
|
||||||
|
packet_attrs =
|
||||||
|
case packet_data do
|
||||||
|
%Packet{} = packet ->
|
||||||
|
packet
|
||||||
|
|> Map.from_struct()
|
||||||
|
|> Map.delete(:__meta__)
|
||||||
|
|
||||||
|
%{} ->
|
||||||
|
packet_data
|
||||||
|
end
|
||||||
|
|
||||||
|
# Convert data_type to string if it's an atom
|
||||||
|
packet_attrs =
|
||||||
|
case packet_attrs do
|
||||||
|
%{data_type: data_type} when is_atom(data_type) ->
|
||||||
|
Map.put(packet_attrs, :data_type, to_string(data_type))
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
packet_attrs
|
||||||
|
end
|
||||||
|
|
||||||
|
# Make sure received_at is set with explicit UTC DateTime
|
||||||
|
current_time = DateTime.truncate(DateTime.utc_now(), :microsecond)
|
||||||
|
packet_attrs = Map.put(packet_attrs, :received_at, current_time)
|
||||||
|
|
||||||
|
# Extract position data
|
||||||
|
{lat, lon} = extract_position(packet_attrs)
|
||||||
|
|
||||||
|
# Set position fields if found
|
||||||
|
packet_attrs =
|
||||||
|
if lat && lon do
|
||||||
|
packet_attrs
|
||||||
|
|> Map.put(:lat, lat)
|
||||||
|
|> Map.put(:lon, lon)
|
||||||
|
|> Map.put(:has_position, true)
|
||||||
|
|> Map.put(:region, "#{Float.round(lat, 1)},#{Float.round(lon, 1)}")
|
||||||
|
else
|
||||||
|
# Set region based on callsign if no position
|
||||||
|
sender_region = if packet_attrs[:sender], do: String.slice(packet_attrs.sender || "", 0, 3), else: "unknown"
|
||||||
|
Map.put(packet_attrs, :region, "call:#{sender_region}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Insert the packet
|
||||||
|
%Packet{}
|
||||||
|
|> Packet.changeset(packet_attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
# Extracts position data from packet, checking various possible locations
|
||||||
|
defp extract_position(packet_data) do
|
||||||
|
cond do
|
||||||
|
# Check for lat/lon at top level
|
||||||
|
not is_nil(packet_data[:lat]) and not is_nil(packet_data[:lon]) ->
|
||||||
|
{to_float(packet_data.lat), to_float(packet_data.lon)}
|
||||||
|
|
||||||
|
# Check data_extended struct or map
|
||||||
|
not is_nil(packet_data[:data_extended]) ->
|
||||||
|
data_extended = packet_data.data_extended
|
||||||
|
|
||||||
|
cond do
|
||||||
|
# Standard position format
|
||||||
|
is_map(data_extended) and not is_nil(data_extended[:latitude]) and not is_nil(data_extended[:longitude]) ->
|
||||||
|
{to_float(data_extended.latitude), to_float(data_extended.longitude)}
|
||||||
|
|
||||||
|
# MicE packet format with components
|
||||||
|
is_map(data_extended) and data_extended.__struct__ == Parser.Types.MicE ->
|
||||||
|
mic_e = data_extended
|
||||||
|
|
||||||
|
if is_number(mic_e.lat_degrees) and is_number(mic_e.lat_minutes) and
|
||||||
|
is_number(mic_e.lon_degrees) and is_number(mic_e.lon_minutes) do
|
||||||
|
# Convert MicE components to decimal degrees
|
||||||
|
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0
|
||||||
|
lat = if mic_e.lat_direction == :south, do: -lat, else: lat
|
||||||
|
|
||||||
|
lon = mic_e.lon_degrees + mic_e.lon_minutes / 60.0
|
||||||
|
lon = if mic_e.lon_direction == :west, do: -lon, else: lon
|
||||||
|
|
||||||
|
{lat, lon}
|
||||||
|
else
|
||||||
|
{nil, nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
true ->
|
||||||
|
{nil, nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
true ->
|
||||||
|
{nil, nil}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets packets for replay.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
* `opts` - Map of options for filtering and pagination:
|
||||||
|
* `:lat` - Latitude for center point filtering
|
||||||
|
* `:lon` - Longitude for center point filtering
|
||||||
|
* `:radius` - Radius in kilometers for filtering
|
||||||
|
* `:callsign` - Filter by callsign
|
||||||
|
* `:region` - Filter by region
|
||||||
|
* `:start_time` - Start time for replay (DateTime)
|
||||||
|
* `:end_time` - End time for replay (DateTime)
|
||||||
|
* `:limit` - Maximum number of packets to return
|
||||||
|
* `:page` - Page number for pagination
|
||||||
|
"""
|
||||||
|
def get_packets_for_replay(opts \\ %{}) do
|
||||||
|
base_query = from(p in Packet, order_by: [asc: p.received_at], where: p.has_position == true)
|
||||||
|
|
||||||
|
query =
|
||||||
|
base_query
|
||||||
|
|> filter_by_time(opts)
|
||||||
|
|> filter_by_region(opts)
|
||||||
|
|> filter_by_callsign(opts)
|
||||||
|
|> filter_by_map_bounds(opts)
|
||||||
|
|> limit_results(opts)
|
||||||
|
|
||||||
|
Repo.all(query)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets historical packet count for a map area.
|
||||||
|
"""
|
||||||
|
def get_historical_packet_count(opts \\ %{}) do
|
||||||
|
base_query = from(p in Packet, select: count(p.id), where: p.has_position == true)
|
||||||
|
|
||||||
|
query =
|
||||||
|
base_query
|
||||||
|
|> filter_by_time(opts)
|
||||||
|
|> filter_by_region(opts)
|
||||||
|
|> filter_by_callsign(opts)
|
||||||
|
|> filter_by_map_bounds(opts)
|
||||||
|
|
||||||
|
Repo.one(query)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Query building helpers
|
||||||
|
# Handle both start_time and end_time
|
||||||
|
defp filter_by_time(query, %{start_time: start_time, end_time: end_time}) do
|
||||||
|
# Ensure we prioritize packets from the last hour
|
||||||
|
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||||
|
effective_start_time = if DateTime.before?(start_time, one_hour_ago), do: one_hour_ago, else: start_time
|
||||||
|
|
||||||
|
from p in query,
|
||||||
|
where: p.received_at >= ^effective_start_time and p.received_at <= ^end_time
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle only start_time
|
||||||
|
defp filter_by_time(query, %{start_time: start_time}) do
|
||||||
|
from p in query, where: p.received_at >= ^start_time
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle only end_time
|
||||||
|
defp filter_by_time(query, %{end_time: end_time}) do
|
||||||
|
from p in query, where: p.received_at <= ^end_time
|
||||||
|
end
|
||||||
|
|
||||||
|
# Default case
|
||||||
|
defp filter_by_time(query, _), do: query
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Get packets only from the last hour for current display.
|
||||||
|
This is used for initial map loading to show only recent packets.
|
||||||
|
"""
|
||||||
|
def get_recent_packets(opts \\ %{}) do
|
||||||
|
# Always limit to the last hour
|
||||||
|
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||||
|
|
||||||
|
# Merge the one-hour limit with any other filters
|
||||||
|
opts_with_time = Map.put(opts, :start_time, one_hour_ago)
|
||||||
|
|
||||||
|
get_packets_for_replay(opts_with_time)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Retrieves a continuous stream of stored packets for replay in chronological order.
|
||||||
|
|
||||||
|
This function returns a Stream that can be used to process packets in chronological
|
||||||
|
order, preserving the timing between packets.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
* `opts` - The same options as `get_packets_for_replay/1`
|
||||||
|
* `:playback_speed` - Speed multiplier (1.0 = real-time, 2.0 = 2x speed, etc.)
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
* Stream of packets with timing information
|
||||||
|
"""
|
||||||
|
def stream_packets_for_replay(opts \\ %{}) do
|
||||||
|
packets = get_packets_for_replay(opts)
|
||||||
|
playback_speed = Map.get(opts, :playback_speed, 1.0)
|
||||||
|
|
||||||
|
# Return a stream that emits packets with their original timing
|
||||||
|
Stream.unfold({packets, nil}, fn
|
||||||
|
{[], _} ->
|
||||||
|
nil
|
||||||
|
|
||||||
|
{[packet | rest], nil} ->
|
||||||
|
{{0, packet}, {rest, packet}}
|
||||||
|
|
||||||
|
{[next | rest], prev} ->
|
||||||
|
# Calculate delay between packets in milliseconds, then convert to seconds
|
||||||
|
delay_ms = DateTime.diff(next.received_at, prev.received_at, :millisecond)
|
||||||
|
adjusted_delay = delay_ms / (playback_speed * 1000)
|
||||||
|
{{adjusted_delay, next}, {rest, next}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filter_by_region(query, %{region: region}) do
|
||||||
|
from p in query, where: p.region == ^region
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filter_by_region(query, _), do: query
|
||||||
|
|
||||||
|
defp filter_by_callsign(query, %{callsign: callsign}) do
|
||||||
|
pattern = "%#{callsign}%"
|
||||||
|
from p in query, where: ilike(p.sender, ^pattern) or ilike(p.base_callsign, ^pattern)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filter_by_callsign(query, _), do: query
|
||||||
|
|
||||||
|
defp filter_by_map_bounds(query, %{bounds: [min_lon, min_lat, max_lon, max_lat]})
|
||||||
|
when not is_nil(min_lon) and not is_nil(min_lat) and not is_nil(max_lon) and not is_nil(max_lat) do
|
||||||
|
from p in query,
|
||||||
|
where: p.has_position == true,
|
||||||
|
where: p.lat >= ^min_lat and p.lat <= ^max_lat,
|
||||||
|
where: p.lon >= ^min_lon and p.lon <= ^max_lon
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filter_by_map_bounds(query, _), do: query
|
||||||
|
|
||||||
|
defp limit_results(query, %{limit: limit, page: page}) when not is_nil(limit) and not is_nil(page) do
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
from p in query, limit: ^limit, offset: ^offset
|
||||||
|
end
|
||||||
|
|
||||||
|
defp limit_results(query, %{limit: limit}) when not is_nil(limit) do
|
||||||
|
from p in query, limit: ^limit
|
||||||
|
end
|
||||||
|
|
||||||
|
defp limit_results(query, _), do: query
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Configure packet retention policy.
|
||||||
|
|
||||||
|
Packets are retained based on these rules:
|
||||||
|
- Default retention is 30 days (configurable via :packet_retention_days)
|
||||||
|
- Returns the number of packets deleted
|
||||||
|
"""
|
||||||
|
def clean_old_packets do
|
||||||
|
retention_days = Application.get_env(:aprs, :packet_retention_days, 30)
|
||||||
|
cutoff_time = DateTime.add(DateTime.utc_now(), -retention_days * 86_400, :second)
|
||||||
|
|
||||||
|
{deleted_count, _} = Repo.delete_all(from(p in Packet, where: p.received_at < ^cutoff_time))
|
||||||
|
|
||||||
|
deleted_count
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to convert various types to float
|
||||||
|
defp to_float(value) when is_float(value), do: value
|
||||||
|
defp to_float(value) when is_integer(value), do: value * 1.0
|
||||||
|
defp to_float(%Decimal{} = value), do: Decimal.to_float(value)
|
||||||
|
|
||||||
|
defp to_float(value) when is_binary(value) do
|
||||||
|
case Float.parse(value) do
|
||||||
|
{float, _} -> float
|
||||||
|
:error -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp to_float(_), do: nil
|
||||||
|
|
||||||
|
# Get packets from last hour only - used to initialize the map
|
||||||
|
def get_last_hour_packets do
|
||||||
|
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||||
|
|
||||||
|
Packet
|
||||||
|
|> where([p], p.has_position == true)
|
||||||
|
|> where([p], p.received_at >= ^one_hour_ago)
|
||||||
|
|> order_by([p], asc: p.received_at)
|
||||||
|
|> limit(500)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
68
lib/aprs/scheduled/packet_cleanup.ex
Normal file
68
lib/aprs/scheduled/packet_cleanup.ex
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
defmodule Aprs.Scheduled.PacketCleanup do
|
||||||
|
@moduledoc """
|
||||||
|
Scheduled task to clean up old APRS packet data.
|
||||||
|
|
||||||
|
This module is responsible for:
|
||||||
|
1. Removing packets older than the retention period (30 days by default)
|
||||||
|
2. Logging statistics about the cleanup operation
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
alias Aprs.Packets
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
# Run cleanup once per day
|
||||||
|
@cleanup_interval_ms 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
def start_link(_opts) do
|
||||||
|
GenServer.start_link(__MODULE__, %{})
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(state) do
|
||||||
|
# Schedule first cleanup
|
||||||
|
schedule_cleanup()
|
||||||
|
{:ok, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(:cleanup, state) do
|
||||||
|
# Run the cleanup
|
||||||
|
_cleanup_count = perform_cleanup()
|
||||||
|
|
||||||
|
# Schedule next cleanup
|
||||||
|
schedule_cleanup()
|
||||||
|
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp perform_cleanup do
|
||||||
|
Logger.info("Starting scheduled APRS packet cleanup")
|
||||||
|
|
||||||
|
# Count packets before cleanup for statistics
|
||||||
|
_total_before = count_total_packets()
|
||||||
|
|
||||||
|
# Perform the cleanup
|
||||||
|
deleted_count = Packets.clean_old_packets()
|
||||||
|
|
||||||
|
# Log results
|
||||||
|
retention_days = Application.get_env(:aprs, :packet_retention_days, 30)
|
||||||
|
Logger.info("APRS packet cleanup complete: removed #{deleted_count} packets older than #{retention_days} days")
|
||||||
|
|
||||||
|
# Return deleted count
|
||||||
|
deleted_count
|
||||||
|
end
|
||||||
|
|
||||||
|
defp count_total_packets do
|
||||||
|
# Import modules needed for database operations
|
||||||
|
|
||||||
|
Aprs.Repo.aggregate(Aprs.Packet, :count, :id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp schedule_cleanup do
|
||||||
|
# Schedule next cleanup
|
||||||
|
Process.send_after(self(), :cleanup, @cleanup_interval_ms)
|
||||||
|
end
|
||||||
|
end
|
||||||
77
lib/aprs/workers/packet_cleanup_worker.ex
Normal file
77
lib/aprs/workers/packet_cleanup_worker.ex
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
defmodule Aprs.Workers.PacketCleanupWorker do
|
||||||
|
@moduledoc """
|
||||||
|
Oban worker for cleaning up old APRS packet data.
|
||||||
|
|
||||||
|
This worker is responsible for:
|
||||||
|
1. Removing packets older than the retention period (30 days by default)
|
||||||
|
2. Removing old packets from the displayed map (older than 1 hour)
|
||||||
|
3. Logging statistics about the cleanup operation
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Oban.Worker, queue: :maintenance, max_attempts: 3
|
||||||
|
|
||||||
|
# Import modules needed for database operations
|
||||||
|
|
||||||
|
alias Aprs.Packet
|
||||||
|
alias Aprs.Packets
|
||||||
|
alias Aprs.Repo
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@impl Oban.Worker
|
||||||
|
def perform(%Oban.Job{args: _args}) do
|
||||||
|
Logger.info("Starting scheduled APRS packet cleanup")
|
||||||
|
|
||||||
|
# Count packets before cleanup for statistics
|
||||||
|
_total_before = count_total_packets()
|
||||||
|
|
||||||
|
# Perform the cleanup of old packets (older than 30 days)
|
||||||
|
deleted_count = cleanup_old_packets()
|
||||||
|
|
||||||
|
# Log results
|
||||||
|
retention_days = Application.get_env(:aprs, :packet_retention_days, 30)
|
||||||
|
Logger.info("APRS packet cleanup complete: removed #{deleted_count} packets older than #{retention_days} days")
|
||||||
|
|
||||||
|
# Return success
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Schedule this job to run daily
|
||||||
|
def schedule_cleanup do
|
||||||
|
%{}
|
||||||
|
|> Oban.Job.new(worker: __MODULE__, queue: :maintenance)
|
||||||
|
|> Oban.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
# Schedule this job to run daily at midnight UTC
|
||||||
|
def schedule_daily_cleanup do
|
||||||
|
# Calculate when the next midnight UTC is
|
||||||
|
now = DateTime.utc_now()
|
||||||
|
tomorrow = Date.add(now, 1)
|
||||||
|
|
||||||
|
next_midnight = %{
|
||||||
|
now
|
||||||
|
| year: tomorrow.year,
|
||||||
|
month: tomorrow.month,
|
||||||
|
day: tomorrow.day,
|
||||||
|
hour: 0,
|
||||||
|
minute: 0,
|
||||||
|
second: 0,
|
||||||
|
microsecond: {0, 6}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Schedule the job
|
||||||
|
%{}
|
||||||
|
|> Oban.Job.new(worker: __MODULE__, queue: :maintenance)
|
||||||
|
|> Oban.insert(scheduled_at: next_midnight)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp count_total_packets do
|
||||||
|
Repo.aggregate(Packet, :count, :id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cleanup_old_packets do
|
||||||
|
# Use the existing function from Packets context
|
||||||
|
Packets.clean_old_packets()
|
||||||
|
end
|
||||||
|
end
|
||||||
81
lib/aprs/workers/scheduler.ex
Normal file
81
lib/aprs/workers/scheduler.ex
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
defmodule Aprs.Workers.Scheduler do
|
||||||
|
@moduledoc """
|
||||||
|
Scheduler for Oban jobs.
|
||||||
|
|
||||||
|
This module is responsible for scheduling recurring jobs in the application.
|
||||||
|
It should be started as part of the application supervision tree.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
alias Aprs.Workers.PacketCleanupWorker
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
# Start the scheduler on application boot
|
||||||
|
def start_link(_opts) do
|
||||||
|
GenServer.start_link(__MODULE__, %{})
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(state) do
|
||||||
|
# Schedule initial jobs
|
||||||
|
schedule_initial_jobs()
|
||||||
|
|
||||||
|
# Return with state
|
||||||
|
{:ok, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(:schedule_daily_jobs, state) do
|
||||||
|
# Schedule daily cleanup job
|
||||||
|
schedule_daily_jobs()
|
||||||
|
|
||||||
|
# Schedule this function to run again tomorrow
|
||||||
|
schedule_next_day_scheduler()
|
||||||
|
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Schedule jobs when the application starts
|
||||||
|
defp schedule_initial_jobs do
|
||||||
|
Logger.info("Scheduling initial Oban jobs")
|
||||||
|
|
||||||
|
# Schedule the packet cleanup job
|
||||||
|
{:ok, _job} = PacketCleanupWorker.schedule_cleanup()
|
||||||
|
|
||||||
|
# Schedule the daily scheduler to run at midnight
|
||||||
|
schedule_next_day_scheduler()
|
||||||
|
end
|
||||||
|
|
||||||
|
# Schedule jobs that should run daily
|
||||||
|
defp schedule_daily_jobs do
|
||||||
|
Logger.info("Scheduling daily Oban jobs")
|
||||||
|
|
||||||
|
# Schedule the packet cleanup job
|
||||||
|
{:ok, _job} = PacketCleanupWorker.schedule_cleanup()
|
||||||
|
end
|
||||||
|
|
||||||
|
# Schedule the next day's job scheduler to run at midnight UTC
|
||||||
|
defp schedule_next_day_scheduler do
|
||||||
|
# Calculate when the next midnight UTC is
|
||||||
|
now = DateTime.utc_now()
|
||||||
|
tomorrow = Date.add(now, 1)
|
||||||
|
|
||||||
|
next_midnight = %{
|
||||||
|
now
|
||||||
|
| year: tomorrow.year,
|
||||||
|
month: tomorrow.month,
|
||||||
|
day: tomorrow.day,
|
||||||
|
hour: 0,
|
||||||
|
minute: 0,
|
||||||
|
second: 0,
|
||||||
|
microsecond: {0, 6}
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds_until_midnight = DateTime.diff(next_midnight, now)
|
||||||
|
|
||||||
|
# Schedule this function to run at midnight
|
||||||
|
Process.send_after(self(), :schedule_daily_jobs, seconds_until_midnight * 1000)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,40 +1,3 @@
|
||||||
<header class="px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex items-center justify-between border-b border-zinc-100 py-3">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<a href="/">
|
|
||||||
<svg viewBox="0 0 71 48" class="h-6" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
|
|
||||||
fill="#FD4F00"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<p class="rounded-full bg-brand/5 px-2 text-[0.8125rem] font-medium leading-6 text-brand">
|
|
||||||
v1.7
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<a
|
|
||||||
href="https://twitter.com/elixirphoenix"
|
|
||||||
class="text-[0.8125rem] font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
|
||||||
>
|
|
||||||
@elixirphoenix
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://github.com/phoenixframework/phoenix"
|
|
||||||
class="text-[0.8125rem] font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://hexdocs.pm/phoenix/overview.html"
|
|
||||||
class="rounded-lg bg-zinc-100 px-2 py-1 text-[0.8125rem] font-semibold leading-6 text-zinc-900 hover:bg-zinc-200/80 active:text-zinc-900/70"
|
|
||||||
>
|
|
||||||
Get Started <span aria-hidden="true">→</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main class="px-4 py-20 sm:px-6 lg:px-8">
|
<main class="px-4 py-20 sm:px-6 lg:px-8">
|
||||||
<div class="mx-auto max-w-2xl">
|
<div class="mx-auto max-w-2xl">
|
||||||
<.flash kind={:info} title="Success!" flash={@flash} />
|
<.flash kind={:info} title="Success!" flash={@flash} />
|
||||||
|
|
|
||||||
|
|
@ -12,26 +12,6 @@
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class={body_class(assigns)}>
|
<body class={body_class(assigns)}>
|
||||||
<ul>
|
|
||||||
<%= if @current_user do %>
|
|
||||||
<li>
|
|
||||||
{@current_user.email}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link href={~p"/users/settings"}>Settings</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link href={~p"/users/log_out"} method="delete">Log out</.link>
|
|
||||||
</li>
|
|
||||||
<% else %>
|
|
||||||
<li>
|
|
||||||
<.link href={~p"/users/register"}>Register</.link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<.link href={~p"/users/log_in"}>Log in</.link>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
{@inner_content}
|
{@inner_content}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,15 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
|
|
||||||
@default_center %{lat: 39.8283, lng: -98.5795}
|
@default_center %{lat: 39.8283, lng: -98.5795}
|
||||||
@default_zoom 5
|
@default_zoom 5
|
||||||
@ip_api_url "http://ip-api.com/json/"
|
@ip_api_url "https://ip-api.com/json/"
|
||||||
@finch_name Aprs.Finch
|
@finch_name Aprs.Finch
|
||||||
|
@default_replay_speed 1000
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
# Calculate one hour ago for packet age filtering
|
||||||
|
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
assign(socket,
|
assign(socket,
|
||||||
packets: [],
|
packets: [],
|
||||||
|
|
@ -30,12 +34,35 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
west: -125.0
|
west: -125.0
|
||||||
},
|
},
|
||||||
map_center: @default_center,
|
map_center: @default_center,
|
||||||
map_zoom: @default_zoom
|
map_zoom: @default_zoom,
|
||||||
|
# Replay controls
|
||||||
|
replay_active: false,
|
||||||
|
replay_speed: @default_replay_speed,
|
||||||
|
replay_paused: false,
|
||||||
|
replay_packets: [],
|
||||||
|
replay_index: 0,
|
||||||
|
replay_timer_ref: nil,
|
||||||
|
replay_start_time: nil,
|
||||||
|
replay_end_time: nil,
|
||||||
|
# Map of packet IDs to packet data for historical packets
|
||||||
|
historical_packets: %{},
|
||||||
|
# Timestamp for filtering out old packets
|
||||||
|
packet_age_threshold: one_hour_ago,
|
||||||
|
# Flag to indicate if map is ready for replay
|
||||||
|
map_ready: false,
|
||||||
|
# Flag to prevent multiple replay starts
|
||||||
|
replay_started: false,
|
||||||
|
# Pending geolocation to zoom to after map is ready
|
||||||
|
pending_geolocation: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
if connected?(socket) do
|
if connected?(socket) do
|
||||||
|
IO.puts("Socket is connected, attempting to get IP location")
|
||||||
Endpoint.subscribe("aprs_messages")
|
Endpoint.subscribe("aprs_messages")
|
||||||
# Get IP-based location on initial load
|
# Get IP-based location on initial load
|
||||||
|
IO.puts("Connect info: #{inspect(socket.private[:connect_info])}")
|
||||||
|
IO.puts("Peer data: #{inspect(socket.private[:connect_info][:peer_data])}")
|
||||||
|
|
||||||
ip =
|
ip =
|
||||||
case socket.private[:connect_info][:peer_data][:address] do
|
case socket.private[:connect_info][:peer_data][:address] do
|
||||||
{a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}"
|
{a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}"
|
||||||
|
|
@ -43,7 +70,29 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
_ -> nil
|
_ -> nil
|
||||||
end
|
end
|
||||||
|
|
||||||
if ip, do: Task.async(fn -> get_ip_location(ip) end)
|
IO.puts("Extracted IP address: #{inspect(ip)}")
|
||||||
|
|
||||||
|
if ip do
|
||||||
|
IO.puts("Starting IP geolocation task for IP: #{ip}")
|
||||||
|
# Start as a separate task and await the result
|
||||||
|
Task.start(fn ->
|
||||||
|
try do
|
||||||
|
get_ip_location(ip)
|
||||||
|
rescue
|
||||||
|
error ->
|
||||||
|
IO.puts("Error in IP geolocation task: #{inspect(error)}")
|
||||||
|
IO.puts("Stacktrace: #{inspect(__STACKTRACE__)}")
|
||||||
|
send(self(), {:ip_location, @default_center})
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
IO.puts("No IP address found, skipping geolocation")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Schedule regular cleanup of old packets from the map
|
||||||
|
if connected?(socket), do: Process.send_after(self(), :cleanup_old_packets, 60_000)
|
||||||
|
# Schedule initialization of replay after a short delay
|
||||||
|
if connected?(socket), do: Process.send_after(self(), :initialize_replay, 2000)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
|
|
@ -70,6 +119,25 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
|
|
||||||
packet_count = map_size(visible_packets)
|
packet_count = map_size(visible_packets)
|
||||||
|
|
||||||
|
# If replay is not active, update the replay packets based on the new bounds
|
||||||
|
socket =
|
||||||
|
if socket.assigns.replay_active do
|
||||||
|
socket
|
||||||
|
else
|
||||||
|
# Clear any existing replay data when bounds change
|
||||||
|
socket =
|
||||||
|
assign(socket,
|
||||||
|
replay_packets: [],
|
||||||
|
replay_index: 0,
|
||||||
|
historical_packets: %{},
|
||||||
|
map_ready: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Don't automatically start replay on every bounds change
|
||||||
|
# We'll handle this in initialize_replay to avoid too many restarts
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
{:noreply, assign(socket, map_bounds: map_bounds, visible_packets: visible_packets, packet_count: packet_count)}
|
{:noreply, assign(socket, map_bounds: map_bounds, visible_packets: visible_packets, packet_count: packet_count)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -82,38 +150,182 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("locate_me", _params, socket) do
|
def handle_event("locate_me", _params, socket) do
|
||||||
# Send JavaScript command to request browser geolocation
|
# Send JavaScript command to request browser geolocation
|
||||||
|
IO.puts("locate_me event received, requesting geolocation")
|
||||||
{:noreply, push_event(socket, "request_geolocation", %{})}
|
{:noreply, push_event(socket, "request_geolocation", %{})}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket) do
|
def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket) do
|
||||||
# Update map center and zoom when location is received
|
# Update map center and zoom when location is received
|
||||||
{:noreply, assign(socket, map_center: %{lat: lat, lng: lng}, map_zoom: 12)}
|
IO.puts("set_location event received with lat=#{lat}, lng=#{lng}")
|
||||||
|
|
||||||
|
# Ensure coordinates are floats
|
||||||
|
lat_float =
|
||||||
|
cond do
|
||||||
|
is_binary(lat) -> String.to_float(lat)
|
||||||
|
is_integer(lat) -> lat / 1.0
|
||||||
|
true -> lat
|
||||||
|
end
|
||||||
|
|
||||||
|
lng_float =
|
||||||
|
cond do
|
||||||
|
is_binary(lng) -> String.to_float(lng)
|
||||||
|
is_integer(lng) -> lng / 1.0
|
||||||
|
true -> lng
|
||||||
|
end
|
||||||
|
|
||||||
|
IO.puts("Sending zoom_to_location event from set_location with lat=#{lat_float}, lng=#{lng_float}")
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(map_center: %{lat: lat_float, lng: lng_float}, map_zoom: 12)
|
||||||
|
|> push_event("zoom_to_location", %{lat: lat_float, lng: lng_float, zoom: 12})
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("toggle_replay", _params, socket) do
|
||||||
|
if socket.assigns.replay_active do
|
||||||
|
# Stop replay
|
||||||
|
if socket.assigns.replay_timer_ref, do: Process.cancel_timer(socket.assigns.replay_timer_ref)
|
||||||
|
|
||||||
|
# Clear historical packets from the map
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> push_event("clear_historical_packets", %{})
|
||||||
|
|> assign(
|
||||||
|
replay_active: false,
|
||||||
|
replay_timer_ref: nil,
|
||||||
|
replay_paused: false,
|
||||||
|
replay_packets: [],
|
||||||
|
replay_index: 0,
|
||||||
|
historical_packets: %{},
|
||||||
|
replay_started: false
|
||||||
|
)
|
||||||
|
|
||||||
|
# Restart replay after a short delay
|
||||||
|
Process.send_after(self(), :initialize_replay, 1000)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
else
|
||||||
|
# If not active, the user manually requested a replay restart
|
||||||
|
{:noreply, start_historical_replay(socket)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("pause_replay", _params, socket) do
|
||||||
|
if socket.assigns.replay_active do
|
||||||
|
if socket.assigns.replay_paused do
|
||||||
|
# Resume replay
|
||||||
|
timer_ref = Process.send_after(self(), :replay_next_packet, 1000)
|
||||||
|
{:noreply, assign(socket, replay_paused: false, replay_timer_ref: timer_ref)}
|
||||||
|
else
|
||||||
|
# Pause replay
|
||||||
|
if socket.assigns.replay_timer_ref, do: Process.cancel_timer(socket.assigns.replay_timer_ref)
|
||||||
|
{:noreply, assign(socket, replay_paused: true, replay_timer_ref: nil)}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("adjust_replay_speed", %{"speed" => speed}, socket) do
|
||||||
|
speed_float = String.to_float(speed)
|
||||||
|
{:noreply, assign(socket, replay_speed: speed_float)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("map_ready", _params, socket) do
|
||||||
|
IO.puts("Map ready event received")
|
||||||
|
socket = assign(socket, map_ready: true)
|
||||||
|
|
||||||
|
# If we have pending geolocation, zoom to it now
|
||||||
|
socket =
|
||||||
|
if socket.assigns.pending_geolocation do
|
||||||
|
%{lat: lat, lng: lng} = socket.assigns.pending_geolocation
|
||||||
|
IO.puts("Map ready - zooming to pending location: lat=#{lat}, lng=#{lng}")
|
||||||
|
push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12})
|
||||||
|
else
|
||||||
|
IO.puts("Map ready - no pending geolocation")
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info(msg, socket) do
|
def handle_info(msg, socket) do
|
||||||
case msg do
|
case msg do
|
||||||
|
{:delayed_zoom, %{lat: lat, lng: lng}} ->
|
||||||
|
IO.puts("Processing delayed zoom to lat=#{lat}, lng=#{lng}")
|
||||||
|
socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12})
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
{:ip_location, %{lat: lat, lng: lng}} ->
|
{:ip_location, %{lat: lat, lng: lng}} ->
|
||||||
{:noreply, assign(socket, map_center: %{lat: lat, lng: lng}, map_zoom: 12)}
|
# Log IP location received
|
||||||
|
IO.puts("IP location received: lat=#{lat}, lng=#{lng}")
|
||||||
|
|
||||||
|
# Ensure we're using numeric values for coordinates
|
||||||
|
lat_float =
|
||||||
|
cond do
|
||||||
|
is_binary(lat) -> String.to_float(lat)
|
||||||
|
is_integer(lat) -> lat / 1.0
|
||||||
|
true -> lat
|
||||||
|
end
|
||||||
|
|
||||||
|
lng_float =
|
||||||
|
cond do
|
||||||
|
is_binary(lng) -> String.to_float(lng)
|
||||||
|
is_integer(lng) -> lng / 1.0
|
||||||
|
true -> lng
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update map center first
|
||||||
|
socket = assign(socket, map_center: %{lat: lat_float, lng: lng_float}, map_zoom: 12)
|
||||||
|
|
||||||
|
# If map is ready, zoom to location immediately, otherwise store for later
|
||||||
|
socket =
|
||||||
|
if socket.assigns.map_ready do
|
||||||
|
IO.puts("Map is ready, zooming to location immediately to lat=#{lat_float}, lng=#{lng_float}")
|
||||||
|
push_event(socket, "zoom_to_location", %{lat: lat_float, lng: lng_float, zoom: 12})
|
||||||
|
else
|
||||||
|
IO.puts("Map not ready yet, storing location lat=#{lat_float}, lng=#{lng_float} for later")
|
||||||
|
assign(socket, pending_geolocation: %{lat: lat_float, lng: lng_float})
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
|
:initialize_replay ->
|
||||||
|
# Only start replay if it hasn't been started yet
|
||||||
|
if not socket.assigns.replay_started and socket.assigns.map_ready do
|
||||||
|
socket = start_historical_replay(socket)
|
||||||
|
{:noreply, assign(socket, replay_started: true)}
|
||||||
|
else
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
:replay_next_packet ->
|
||||||
|
handle_replay_next_packet(socket)
|
||||||
|
|
||||||
|
:cleanup_old_packets ->
|
||||||
|
# Clean up packets older than 1 hour from the map display
|
||||||
|
handle_cleanup_old_packets(socket)
|
||||||
|
|
||||||
%{event: "packet", payload: payload} ->
|
%{event: "packet", payload: payload} ->
|
||||||
# Sanitize the packet to prevent encoding errors
|
# Sanitize the packet to prevent encoding errors
|
||||||
sanitized_packet = EncodingUtils.sanitize_packet(payload)
|
sanitized_packet = EncodingUtils.sanitize_packet(payload)
|
||||||
|
|
||||||
# Log packet type for debugging
|
# Add received timestamp if not present
|
||||||
IO.inspect(sanitized_packet.data_type, label: "Packet type")
|
sanitized_packet = Map.put_new(sanitized_packet, :received_at, DateTime.utc_now())
|
||||||
|
|
||||||
if sanitized_packet.data_extended do
|
|
||||||
# Check if data_extended is a struct before accessing __struct__
|
|
||||||
case sanitized_packet.data_extended do
|
|
||||||
%{__struct__: module} -> IO.inspect(module, label: "Data extended type")
|
|
||||||
_ -> IO.inspect("Plain map", label: "Data extended type")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Only process packets with position data that are within current map bounds
|
# Only process packets with position data that are within current map bounds
|
||||||
if has_position_data?(sanitized_packet) && within_bounds?(sanitized_packet, socket.assigns.map_bounds) do
|
# AND are not older than 1 hour
|
||||||
|
if has_position_data?(sanitized_packet) &&
|
||||||
|
within_bounds?(sanitized_packet, socket.assigns.map_bounds) &&
|
||||||
|
packet_within_time_threshold?(sanitized_packet, socket.assigns.packet_age_threshold) do
|
||||||
# Convert to a simple map structure for JSON encoding
|
# Convert to a simple map structure for JSON encoding
|
||||||
packet_data = build_packet_data(sanitized_packet)
|
packet_data = build_packet_data(sanitized_packet)
|
||||||
|
|
||||||
|
|
@ -139,12 +351,75 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
# Ignore packets without position data or outside bounds
|
# Ignore packets without position data, outside bounds, or too old
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Handle replaying the next historical packet
|
||||||
|
defp handle_replay_next_packet(socket) do
|
||||||
|
%{
|
||||||
|
replay_packets: packets,
|
||||||
|
replay_index: index,
|
||||||
|
replay_speed: speed,
|
||||||
|
historical_packets: historical_packets
|
||||||
|
} = socket.assigns
|
||||||
|
|
||||||
|
if index < length(packets) do
|
||||||
|
# Get the next packet and advance the index
|
||||||
|
packet = Enum.at(packets, index)
|
||||||
|
next_index = index + 1
|
||||||
|
|
||||||
|
# Convert to a simple map structure for JSON encoding
|
||||||
|
packet_data = build_packet_data(packet)
|
||||||
|
|
||||||
|
# Add is_historical flag and timestamp
|
||||||
|
packet_data =
|
||||||
|
Map.merge(packet_data, %{
|
||||||
|
"is_historical" => true,
|
||||||
|
"timestamp" => if(Map.has_key?(packet, :received_at), do: DateTime.to_iso8601(packet.received_at))
|
||||||
|
})
|
||||||
|
|
||||||
|
# Generate a unique key for this packet
|
||||||
|
packet_id = "hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}"
|
||||||
|
|
||||||
|
# Update historical packets tracking
|
||||||
|
new_historical_packets = Map.put(historical_packets, packet_id, packet)
|
||||||
|
|
||||||
|
# Calculate delay for next packet (faster based on replay speed)
|
||||||
|
delay_ms = trunc(1000 / speed)
|
||||||
|
|
||||||
|
# Schedule the next packet
|
||||||
|
timer_ref = Process.send_after(self(), :replay_next_packet, delay_ms)
|
||||||
|
|
||||||
|
# Push the packet to the client-side JavaScript
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> push_event("historical_packet", packet_data)
|
||||||
|
|> assign(
|
||||||
|
replay_index: next_index,
|
||||||
|
replay_timer_ref: timer_ref,
|
||||||
|
historical_packets: new_historical_packets
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
else
|
||||||
|
# All packets replayed, start over with a short delay
|
||||||
|
Process.send_after(self(), :initialize_replay, 10_000)
|
||||||
|
|
||||||
|
# Set active to false but don't clear packets - will auto-restart
|
||||||
|
socket =
|
||||||
|
assign(socket,
|
||||||
|
replay_active: false,
|
||||||
|
replay_timer_ref: nil,
|
||||||
|
replay_started: false
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -192,6 +467,7 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.locate-button {
|
.locate-button {
|
||||||
|
|
@ -214,12 +490,116 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-badge {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { opacity: 0.7; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
100% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-controls {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-controls button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #7f8c8d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-control {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-control input {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-progress {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 6px;
|
||||||
|
background-color: #eee;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: #3498db;
|
||||||
|
transition: width 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aprs-marker {
|
.aprs-marker {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.historical-marker {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -235,7 +615,50 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
|
|
||||||
<div class="map-overlay">
|
<div class="map-overlay">
|
||||||
<div class="packet-counter">
|
<div class="packet-counter">
|
||||||
<span id="packet-count">{@packet_count}</span> packets in view
|
<span id="packet-count">{@packet_count}</span>
|
||||||
|
packets in view
|
||||||
|
<%= if @replay_active do %>
|
||||||
|
<div class="replay-status">
|
||||||
|
<span class="replay-badge">Replaying historical packets</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="replay-controls">
|
||||||
|
<%= if @replay_active do %>
|
||||||
|
<div class="control-buttons">
|
||||||
|
<button phx-click="toggle_replay" class="btn-action">
|
||||||
|
<span class="icon">⟳</span> Restart
|
||||||
|
</button>
|
||||||
|
<button phx-click="pause_replay" class="btn-secondary">
|
||||||
|
<span class="icon">{if @replay_paused, do: "▶", else: "⏸"}</span>
|
||||||
|
{if @replay_paused, do: "Resume", else: "Pause"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="speed-control">
|
||||||
|
<label>Speed: {@replay_speed}x</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
step="1"
|
||||||
|
value={@replay_speed}
|
||||||
|
phx-change="adjust_replay_speed"
|
||||||
|
name="speed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="replay-progress">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div
|
||||||
|
class="progress-fill"
|
||||||
|
style={"width: #{if length(@replay_packets) > 0, do: @replay_index * 100 / length(@replay_packets), else: 0}%"}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-text">
|
||||||
|
{@replay_index} of {length(@replay_packets)} packets
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -259,8 +682,120 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Handle cleanup of old packets
|
||||||
|
defp handle_cleanup_old_packets(socket) do
|
||||||
|
# Update the packet age threshold to current time minus one hour
|
||||||
|
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||||
|
|
||||||
|
# Filter out packets older than one hour
|
||||||
|
visible_packets =
|
||||||
|
socket.assigns.visible_packets
|
||||||
|
|> Enum.filter(fn {_key, packet} ->
|
||||||
|
packet_within_time_threshold?(packet, one_hour_ago)
|
||||||
|
end)
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
|
# Get updated packet count
|
||||||
|
packet_count = map_size(visible_packets)
|
||||||
|
|
||||||
|
# Push event to remove old markers from the map
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> push_event("refresh_markers", %{})
|
||||||
|
|> assign(
|
||||||
|
visible_packets: visible_packets,
|
||||||
|
packet_count: packet_count,
|
||||||
|
packet_age_threshold: one_hour_ago
|
||||||
|
)
|
||||||
|
|
||||||
|
# Schedule the next cleanup in 1 minute
|
||||||
|
if connected?(socket), do: Process.send_after(self(), :cleanup_old_packets, 60_000)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if a packet is within the time threshold (not too old)
|
||||||
|
defp packet_within_time_threshold?(packet, threshold) do
|
||||||
|
case packet do
|
||||||
|
%{received_at: received_at} when not is_nil(received_at) ->
|
||||||
|
DateTime.compare(received_at, threshold) in [:gt, :eq]
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
# If no timestamp, treat as current
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Helper functions
|
# Helper functions
|
||||||
|
|
||||||
|
# Fetch historical packets from the database
|
||||||
|
# Helper function to start historical replay
|
||||||
|
defp start_historical_replay(socket) do
|
||||||
|
# Get time range for historical data
|
||||||
|
now = DateTime.utc_now()
|
||||||
|
one_hour_ago = DateTime.add(now, -60 * 60, :second)
|
||||||
|
|
||||||
|
# Convert map bounds to the format expected by the database query
|
||||||
|
bounds = [
|
||||||
|
socket.assigns.map_bounds.west,
|
||||||
|
socket.assigns.map_bounds.south,
|
||||||
|
socket.assigns.map_bounds.east,
|
||||||
|
socket.assigns.map_bounds.north
|
||||||
|
]
|
||||||
|
|
||||||
|
# Fetch historical packets with position data within the current map bounds
|
||||||
|
historical_packets = fetch_historical_packets(bounds, one_hour_ago, now)
|
||||||
|
|
||||||
|
if Enum.empty?(historical_packets) do
|
||||||
|
# No historical packets found - silently continue without replay
|
||||||
|
socket
|
||||||
|
else
|
||||||
|
# Clear any previous historical packets from the map
|
||||||
|
socket = push_event(socket, "clear_historical_packets", %{})
|
||||||
|
|
||||||
|
# Start replay
|
||||||
|
timer_ref = Process.send_after(self(), :replay_next_packet, 1000)
|
||||||
|
|
||||||
|
assign(socket,
|
||||||
|
replay_active: true,
|
||||||
|
replay_packets: historical_packets,
|
||||||
|
replay_index: 0,
|
||||||
|
replay_timer_ref: timer_ref,
|
||||||
|
replay_start_time: one_hour_ago,
|
||||||
|
replay_end_time: now,
|
||||||
|
historical_packets: %{},
|
||||||
|
replay_started: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fetch historical packets from the database
|
||||||
|
defp fetch_historical_packets(bounds, start_time, end_time) do
|
||||||
|
# Force start_time to be at most 1 hour ago
|
||||||
|
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||||
|
|
||||||
|
effective_start_time =
|
||||||
|
if DateTime.before?(start_time, one_hour_ago),
|
||||||
|
do: one_hour_ago,
|
||||||
|
else: start_time
|
||||||
|
|
||||||
|
# Use the Packets context to retrieve historical packets
|
||||||
|
packets_params = %{
|
||||||
|
bounds: bounds,
|
||||||
|
start_time: effective_start_time,
|
||||||
|
end_time: end_time,
|
||||||
|
with_position: true,
|
||||||
|
# Reasonable limit to prevent overwhelming the client
|
||||||
|
limit: 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
# Call the database through the Packets context
|
||||||
|
packets = Aprs.Packets.get_packets_for_replay(packets_params)
|
||||||
|
|
||||||
|
# Sort packets by received_at timestamp to ensure chronological replay
|
||||||
|
Enum.sort_by(packets, fn packet -> packet.received_at end)
|
||||||
|
end
|
||||||
|
|
||||||
defp has_position_data?(packet) do
|
defp has_position_data?(packet) do
|
||||||
case packet.data_extended do
|
case packet.data_extended do
|
||||||
%MicE{} = mic_e ->
|
%MicE{} = mic_e ->
|
||||||
|
|
@ -314,15 +849,14 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
defp build_packet_data(packet) do
|
defp build_packet_data(packet) do
|
||||||
data_extended = build_data_extended(packet.data_extended)
|
data_extended = build_data_extended(packet.data_extended)
|
||||||
|
|
||||||
if data_extended do
|
# Always return a map, even when data_extended is nil
|
||||||
%{
|
%{
|
||||||
"base_callsign" => packet.base_callsign || "",
|
"base_callsign" => packet.base_callsign || "",
|
||||||
"ssid" => packet.ssid || "",
|
"ssid" => packet.ssid || "",
|
||||||
"data_type" => to_string(packet.data_type || "unknown"),
|
"data_type" => to_string(packet.data_type || "unknown"),
|
||||||
"path" => packet.path || "",
|
"path" => packet.path || "",
|
||||||
"data_extended" => data_extended
|
"data_extended" => data_extended || %{}
|
||||||
}
|
}
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get IP location from external service
|
# Get IP location from external service
|
||||||
|
|
@ -332,26 +866,66 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
|
|
||||||
defp get_ip_location(ip) do
|
defp get_ip_location(ip) do
|
||||||
url = "#{@ip_api_url}#{ip}"
|
url = "#{@ip_api_url}#{ip}"
|
||||||
request = Finch.build(:get, url)
|
IO.puts("Fetching location for IP: #{ip} from URL: #{url}")
|
||||||
|
|
||||||
case Finch.request(request, @finch_name) do
|
# Add headers to make the request more likely to succeed
|
||||||
|
request =
|
||||||
|
Finch.build(:get, url, [
|
||||||
|
{"User-Agent", "APRS.me/1.0"},
|
||||||
|
{"Accept", "application/json"}
|
||||||
|
])
|
||||||
|
|
||||||
|
# Add a small delay to ensure the client is connected
|
||||||
|
Process.sleep(2000)
|
||||||
|
|
||||||
|
IO.puts("Making HTTP request to IP API...")
|
||||||
|
|
||||||
|
case Finch.request(request, @finch_name, receive_timeout: 10_000) do
|
||||||
{:ok, %{status: 200, body: body}} ->
|
{:ok, %{status: 200, body: body}} ->
|
||||||
|
IO.puts("IP API response received successfully")
|
||||||
|
IO.puts("Response body: #{String.slice(body, 0, 200)}...")
|
||||||
|
|
||||||
case Jason.decode(body) do
|
case Jason.decode(body) do
|
||||||
{:ok, %{"lat" => lat, "lon" => lng}} when is_number(lat) and is_number(lng) ->
|
{:ok, %{"status" => "success", "lat" => lat, "lon" => lng}} when is_number(lat) and is_number(lng) ->
|
||||||
|
IO.puts("Valid coordinates found: lat=#{lat}, lng=#{lng}")
|
||||||
|
|
||||||
if lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
|
if lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
|
||||||
send(self(), {:ip_location, %{lat: lat, lng: lng}})
|
IO.puts("Sending IP location message with coordinates")
|
||||||
|
# Force numeric values
|
||||||
|
lat_float = lat / 1.0
|
||||||
|
lng_float = lng / 1.0
|
||||||
|
send(self(), {:ip_location, %{lat: lat_float, lng: lng_float}})
|
||||||
else
|
else
|
||||||
|
IO.puts("Coordinates out of range: lat=#{lat}, lng=#{lng}, using default center")
|
||||||
send(self(), {:ip_location, @default_center})
|
send(self(), {:ip_location, @default_center})
|
||||||
end
|
end
|
||||||
|
|
||||||
_ ->
|
{:ok, %{"status" => status}} ->
|
||||||
|
IO.puts("IP API returned non-success status: #{status}")
|
||||||
|
send(self(), {:ip_location, @default_center})
|
||||||
|
|
||||||
|
{:ok, data} ->
|
||||||
|
IO.puts("IP API returned unexpected format: #{inspect(data)}")
|
||||||
|
send(self(), {:ip_location, @default_center})
|
||||||
|
|
||||||
|
{:error, decode_error} ->
|
||||||
|
IO.puts("Failed to decode JSON response: #{inspect(decode_error)}")
|
||||||
|
IO.puts("Raw response: #{body}")
|
||||||
send(self(), {:ip_location, @default_center})
|
send(self(), {:ip_location, @default_center})
|
||||||
end
|
end
|
||||||
|
|
||||||
{:ok, _} ->
|
{:ok, response} ->
|
||||||
|
IO.puts("IP API request failed with status: #{response.status}")
|
||||||
|
IO.puts("Response headers: #{inspect(response.headers)}")
|
||||||
|
IO.puts("Response body: #{String.slice(response.body || "", 0, 200)}...")
|
||||||
send(self(), {:ip_location, @default_center})
|
send(self(), {:ip_location, @default_center})
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, %{reason: :timeout}} ->
|
||||||
|
IO.puts("IP API request timed out after 10 seconds")
|
||||||
|
send(self(), {:ip_location, @default_center})
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
IO.puts("IP API request error: #{inspect(error)}")
|
||||||
send(self(), {:ip_location, @default_center})
|
send(self(), {:ip_location, @default_center})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,11 @@ defmodule Parser do
|
||||||
information_field: data,
|
information_field: data,
|
||||||
data_type: data_type,
|
data_type: data_type,
|
||||||
base_callsign: base_callsign,
|
base_callsign: base_callsign,
|
||||||
ssid: ssid,
|
# Ensure ssid is never nil
|
||||||
data_extended: data_extended
|
ssid: ssid || "0",
|
||||||
|
data_extended: data_extended,
|
||||||
|
# Set received_at when creating packet
|
||||||
|
received_at: DateTime.truncate(DateTime.utc_now(), :microsecond)
|
||||||
}}
|
}}
|
||||||
else
|
else
|
||||||
true ->
|
true ->
|
||||||
|
|
@ -49,7 +52,8 @@ defmodule Parser do
|
||||||
if String.contains?(callsign, "-") do
|
if String.contains?(callsign, "-") do
|
||||||
String.split(callsign, "-")
|
String.split(callsign, "-")
|
||||||
else
|
else
|
||||||
[callsign, nil]
|
# Default SSID to "0" instead of nil
|
||||||
|
[callsign, "0"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ defmodule Parser.Types.MicE do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Type struct for MicE
|
Type struct for MicE
|
||||||
"""
|
"""
|
||||||
|
@behaviour Access
|
||||||
|
|
||||||
defstruct lat_degrees: 0,
|
defstruct lat_degrees: 0,
|
||||||
lat_minutes: 0,
|
lat_minutes: 0,
|
||||||
lat_fractional: 0,
|
lat_fractional: 0,
|
||||||
|
|
@ -18,4 +20,62 @@ defmodule Parser.Types.MicE do
|
||||||
speed: 0,
|
speed: 0,
|
||||||
manufacturer: :unknown,
|
manufacturer: :unknown,
|
||||||
message: ""
|
message: ""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Implements the Access behaviour for MicE struct.
|
||||||
|
This allows us to access fields using the access syntax: struct[:field]
|
||||||
|
Additionally, it provides support for dynamic access via get_in/update_in/etc.
|
||||||
|
|
||||||
|
Fetch a key from the MicE struct.
|
||||||
|
Special handling for :latitude and :longitude which are calculated from components.
|
||||||
|
"""
|
||||||
|
def fetch(mic_e, :latitude) do
|
||||||
|
# Calculate decimal latitude from components
|
||||||
|
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0
|
||||||
|
lat = if mic_e.lat_direction == :south, do: -lat, else: lat
|
||||||
|
{:ok, lat}
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch(mic_e, :longitude) do
|
||||||
|
# Calculate decimal longitude from components
|
||||||
|
lon = mic_e.lon_degrees + mic_e.lon_minutes / 60.0
|
||||||
|
lon = if mic_e.lon_direction == :west, do: -lon, else: lon
|
||||||
|
{:ok, lon}
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch(mic_e, key) when is_atom(key) do
|
||||||
|
# Fall back to struct field access for other keys
|
||||||
|
Map.fetch(mic_e, key)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a value and updates it with the given function.
|
||||||
|
"""
|
||||||
|
def get_and_update(mic_e, key, fun) do
|
||||||
|
value =
|
||||||
|
case key do
|
||||||
|
:latitude ->
|
||||||
|
lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0
|
||||||
|
if mic_e.lat_direction == :south, do: -lat, else: lat
|
||||||
|
|
||||||
|
:longitude ->
|
||||||
|
lon = mic_e.lon_degrees + mic_e.lon_minutes / 60.0
|
||||||
|
if mic_e.lon_direction == :west, do: -lon, else: lon
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
Map.get(mic_e, key)
|
||||||
|
end
|
||||||
|
|
||||||
|
case fun.(value) do
|
||||||
|
{get, update} -> {get, Map.put(mic_e, key, update)}
|
||||||
|
:pop -> {value, Map.put(mic_e, key, nil)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Removes the given key from the struct with the default implementation.
|
||||||
|
"""
|
||||||
|
def pop(mic_e, key) do
|
||||||
|
{Map.get(mic_e, key), Map.put(mic_e, key, nil)}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
11
priv/repo/migrations/20230600000000_add_oban_jobs_table.exs
Normal file
11
priv/repo/migrations/20230600000000_add_oban_jobs_table.exs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
defmodule Aprs.Repo.Migrations.AddObanJobsTable do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
Oban.Migrations.up(version: 11)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
Oban.Migrations.down(version: 11)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
defmodule Aprs.Repo.Migrations.AddPacketReplayFields do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:packets) do
|
||||||
|
# Timestamp when the packet was received (can be different from inserted_at)
|
||||||
|
add :received_at, :utc_datetime_usec
|
||||||
|
|
||||||
|
# Geographic region information to help with filtering/querying
|
||||||
|
add :region, :string
|
||||||
|
|
||||||
|
# Store lat/lon as separate fields for simpler queries
|
||||||
|
add :lat, :float
|
||||||
|
add :lon, :float
|
||||||
|
|
||||||
|
# Add indices for efficient querying
|
||||||
|
add :has_position, :boolean, default: false
|
||||||
|
end
|
||||||
|
|
||||||
|
# Index for time-based queries (useful for replay)
|
||||||
|
create index(:packets, [:received_at])
|
||||||
|
|
||||||
|
# Index for region-based filtering
|
||||||
|
create index(:packets, [:region])
|
||||||
|
|
||||||
|
# Index for filtering packets with position data
|
||||||
|
create index(:packets, [:has_position])
|
||||||
|
|
||||||
|
# Indices for geographic filtering
|
||||||
|
create index(:packets, [:lat])
|
||||||
|
create index(:packets, [:lon])
|
||||||
|
|
||||||
|
# Compound index for efficient replay queries (region + time)
|
||||||
|
create index(:packets, [:region, :received_at])
|
||||||
|
end
|
||||||
|
end
|
||||||
7
priv/repo/migrations/20250616014248_updateoban.exs
Normal file
7
priv/repo/migrations/20250616014248_updateoban.exs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
defmodule Aprs.Repo.Migrations.Updateoban do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up, do: Oban.Migrations.up()
|
||||||
|
|
||||||
|
def down, do: Oban.Migrations.down()
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue