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 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
|
||||
const map = L.map(this.el).setView([initialCenter.lat, initialCenter.lng], initialZoom);
|
||||
console.log("Map initialized:", map);
|
||||
const map = L.map(this.el, {
|
||||
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
|
||||
this.handleEvent("request_geolocation", () => {
|
||||
|
|
@ -112,25 +148,107 @@ Hooks.APRSMap = {
|
|||
|
||||
// Store markers to avoid duplicates
|
||||
this.markers = new Map();
|
||||
this.historicalMarkers = new Map();
|
||||
this.packetCount = 0;
|
||||
|
||||
// Store map instance
|
||||
// Store map instance and initialize state
|
||||
this.map = map;
|
||||
this.mapReady = false;
|
||||
this.userLocationMarker = null;
|
||||
|
||||
// Send initial bounds to server
|
||||
this.sendBoundsToServer();
|
||||
|
||||
// Listen for new packets from the server
|
||||
this.handleEvent("new_packet", (packet) => {
|
||||
console.log("Received new packet:", 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
|
||||
this.handleEvent("clear_markers", () => {
|
||||
this.clearAllMarkers();
|
||||
});
|
||||
|
||||
// Listen for refresh markers event
|
||||
this.handleEvent("refresh_markers", () => {
|
||||
this.refreshMarkers();
|
||||
});
|
||||
|
||||
// Handle geolocation button clicks
|
||||
this.handleEvent("request_geolocation", () => {
|
||||
if ("geolocation" in navigator) {
|
||||
|
|
@ -151,7 +269,11 @@ Hooks.APRSMap = {
|
|||
|
||||
// Update bounds when map moves or zooms
|
||||
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
|
||||
|
|
@ -184,8 +306,12 @@ Hooks.APRSMap = {
|
|||
this.markers.forEach((marker) => {
|
||||
this.map.removeLayer(marker);
|
||||
});
|
||||
this.historicalMarkers.forEach((marker) => {
|
||||
this.map.removeLayer(marker);
|
||||
});
|
||||
}
|
||||
this.markers.clear();
|
||||
this.historicalMarkers.clear();
|
||||
this.packetCount = 0;
|
||||
const counterElement = document.getElementById("packet-count");
|
||||
if (counterElement) {
|
||||
|
|
@ -193,36 +319,70 @@ Hooks.APRSMap = {
|
|||
}
|
||||
},
|
||||
|
||||
removeMarkersOutsideBounds(bounds) {
|
||||
// With clustering, the cluster group handles visibility automatically
|
||||
// We'll just track the count of markers within bounds
|
||||
let visibleCount = 0;
|
||||
refreshMarkers() {
|
||||
// More efficient refresh that doesn't require clearing all markers
|
||||
// Filter markers to keep only those added in the last hour
|
||||
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) => {
|
||||
const latLng = marker.getLatLng();
|
||||
if (bounds.contains(latLng)) {
|
||||
visibleCount++;
|
||||
if (marker.addedAt && marker.addedAt < oneHourAgo) {
|
||||
markersToRemove.push(callsign);
|
||||
}
|
||||
});
|
||||
|
||||
// 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");
|
||||
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 });
|
||||
},
|
||||
|
||||
addPacketMarker(packet) {
|
||||
console.log("addPacketMarker called with:", packet);
|
||||
addPacketMarker(packet, isHistorical = false) {
|
||||
// Skip packets without required data
|
||||
if (
|
||||
!packet["data_extended"] ||
|
||||
!packet["data_extended"]["latitude"] ||
|
||||
!packet["data_extended"]["longitude"]
|
||||
) {
|
||||
console.warn("Packet missing required location data:", packet);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -231,76 +391,112 @@ Hooks.APRSMap = {
|
|||
|
||||
// Validate coordinates are within valid ranges
|
||||
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
||||
console.error("Invalid coordinates:", { lat, lng, packet });
|
||||
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 = `
|
||||
<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;">
|
||||
<strong>Position:</strong> ${lat.toFixed(4)}°, ${lng.toFixed(4)}°<br>
|
||||
<strong>Type:</strong> ${packet["data_type"]}<br>
|
||||
${packet["data_extended"]["comment"] ? `<strong>Comment:</strong> ${packet["data_extended"]["comment"]}<br>` : ""}
|
||||
<strong>Path:</strong> ${packet["path"]}<br>
|
||||
<strong>Time:</strong> ${new Date().toLocaleTimeString()}
|
||||
<strong>Time:</strong> ${timestamp}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Check if marker already exists
|
||||
if (this.markers.has(callsign)) {
|
||||
// Update existing marker
|
||||
const existingMarker = this.markers.get(callsign);
|
||||
if (this.markerClusterGroup) {
|
||||
// Remove and re-add to cluster group
|
||||
this.markerClusterGroup.removeLayer(existingMarker);
|
||||
existingMarker.setLatLng([lat, lng]);
|
||||
existingMarker.setPopupContent(popupContent);
|
||||
this.markerClusterGroup.addLayer(existingMarker);
|
||||
} else {
|
||||
// Fallback: just update position
|
||||
existingMarker.setLatLng([lat, lng]);
|
||||
existingMarker.setPopupContent(popupContent);
|
||||
// Determine which marker collection to use
|
||||
const markerCollection = isHistorical ? this.historicalMarkers : this.markers;
|
||||
|
||||
// Check if marker already exists (only for non-historical markers)
|
||||
if (!isHistorical && this.markers.has(markerId)) {
|
||||
try {
|
||||
// Update existing marker
|
||||
const existingMarker = this.markers.get(markerId);
|
||||
if (this.markerClusterGroup) {
|
||||
// Remove and re-add to cluster group
|
||||
this.markerClusterGroup.removeLayer(existingMarker);
|
||||
existingMarker.setLatLng([lat, lng]);
|
||||
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
|
||||
const icon = this.createAPRSIcon(
|
||||
packet["data_extended"]["symbol_table_id"] || "/",
|
||||
packet["data_extended"]["symbol_code"] || ">",
|
||||
isHistorical,
|
||||
);
|
||||
|
||||
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
|
||||
if (this.markerClusterGroup) {
|
||||
this.markerClusterGroup.addLayer(marker);
|
||||
} else {
|
||||
marker.addTo(this.map);
|
||||
// Add CSS class for historical markers
|
||||
if (isHistorical) {
|
||||
marker.options.className = "historical-marker";
|
||||
}
|
||||
|
||||
this.markers.set(callsign, marker);
|
||||
console.log("New marker added for:", callsign, "Total markers:", this.markers.size);
|
||||
// Add to cluster group or directly to map
|
||||
try {
|
||||
if (this.markerClusterGroup) {
|
||||
this.markerClusterGroup.addLayer(marker);
|
||||
} else {
|
||||
marker.addTo(this.map);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently ignore errors when adding markers
|
||||
}
|
||||
|
||||
// Update packet counter
|
||||
this.packetCount++;
|
||||
const counterElement = document.getElementById("packet-count");
|
||||
if (counterElement) {
|
||||
counterElement.textContent = this.packetCount;
|
||||
// Store the marker
|
||||
markerCollection.set(markerId, marker);
|
||||
|
||||
// Update packet counter (only for live packets)
|
||||
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
|
||||
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({
|
||||
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],
|
||||
iconAnchor: [8, 8],
|
||||
popupAnchor: [0, -8],
|
||||
|
|
@ -308,16 +504,18 @@ Hooks.APRSMap = {
|
|||
},
|
||||
|
||||
destroyed() {
|
||||
console.log("APRSMap hook destroyed");
|
||||
if (this.boundsTimer) {
|
||||
clearTimeout(this.boundsTimer);
|
||||
}
|
||||
if (this.markerClusterGroup) {
|
||||
this.markerClusterGroup.clearLayers();
|
||||
}
|
||||
if (this.map) {
|
||||
console.log("Removing map instance");
|
||||
this.map.remove();
|
||||
this.map = null;
|
||||
}
|
||||
this.markers.clear();
|
||||
this.historicalMarkers.clear();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,18 @@ config :aprs, AprsWeb.Endpoint,
|
|||
pubsub_server: Aprs.PubSub,
|
||||
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,
|
||||
ecto_repos: [Aprs.Repo]
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ defmodule Aprs.Application do
|
|||
|
||||
use Application
|
||||
|
||||
# Configure Oban for background jobs
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
topologies = Application.get_env(:libcluster, :topologies) || []
|
||||
|
|
@ -24,6 +26,10 @@ defmodule Aprs.Application do
|
|||
# {Aprs.Worker, arg}
|
||||
{Registry, keys: :duplicate, name: Registry.PubSub, partitions: System.schedulers_online()},
|
||||
{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
|
||||
]
|
||||
|
||||
|
|
|
|||
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
|
||||
case Parser.parse(message) do
|
||||
{:ok, parsed_message} ->
|
||||
# IO.inspect(parsed_message)
|
||||
# time_message_received =
|
||||
# :calendar.universal_time() |> :calendar.datetime_to_gregorian_seconds()
|
||||
# Store the packet in the database for future replay
|
||||
# Use Task to avoid slowing down the main process
|
||||
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
|
||||
# :ets.insert(:aprs, {parsed_message.sender, message, time_message_received})
|
||||
# Convert to map before storing to avoid struct conversion issues
|
||||
attrs = Map.from_struct(packet_data)
|
||||
|
||||
# Get messages since last time the callsign was seen
|
||||
# TODO: Get last seen timestamp and use that
|
||||
# _message_count =
|
||||
# case :ets.lookup(:aprs_messages, parsed_message.sender) do
|
||||
# [{_callsign, _message, timestamp}] ->
|
||||
# recent_messages_for(parsed_message.sender, timestamp)
|
||||
# Normalize data_type to string if it's an atom
|
||||
attrs = normalize_data_type(attrs)
|
||||
|
||||
# [] ->
|
||||
# 0
|
||||
# Ensure SSID is never nil
|
||||
attrs =
|
||||
if Map.has_key?(attrs, :ssid) and is_nil(attrs.ssid) do
|
||||
Map.put(attrs, :ssid, "0")
|
||||
else
|
||||
attrs
|
||||
end
|
||||
|
||||
# _ ->
|
||||
# 0
|
||||
# end
|
||||
# Store in database through the Packets context
|
||||
case Aprs.Packets.store_packet(attrs) do
|
||||
{:ok, packet} ->
|
||||
Logger.info("Successfully stored packet from #{packet.sender}")
|
||||
|
||||
# Logger.debug("#{message_count} recent messages for #{parsed_message.sender}")
|
||||
|
||||
# Do something interesting with the message
|
||||
# process(parsed_message, parsed_message.data_type)
|
||||
|
||||
# Publish the parsed message to all interested parties
|
||||
# Registry.dispatch(Registry.PubSub, "aprs_messages", fn entries ->
|
||||
# for {pid, _} <- entries, do: send(pid, {:broadcast, parsed_message})
|
||||
# end)
|
||||
{:error, changeset} ->
|
||||
Logger.error("Failed to store packet: #{inspect(changeset.errors)}")
|
||||
end
|
||||
else
|
||||
Logger.debug("Skipping packet without position data: #{inspect(parsed_message.sender)}")
|
||||
end
|
||||
end)
|
||||
|
||||
# Broadcast to live clients
|
||||
AprsWeb.Endpoint.broadcast("aprs_messages", "packet", parsed_message)
|
||||
|
||||
# Logger.debug("BROADCAST: " <> inspect(parsed_message))
|
||||
|
|
@ -333,21 +341,98 @@ defmodule Aprs.Is do
|
|||
end
|
||||
end
|
||||
|
||||
# def process(message, message_type) when message_type == :message do
|
||||
# # time_message_received =
|
||||
# # :calendar.universal_time() |> :calendar.datetime_to_gregorian_seconds()
|
||||
# Helper to check if a packet has position data worth storing
|
||||
defp has_position_data?(packet) do
|
||||
require Logger
|
||||
|
||||
# # :ets.insert(
|
||||
# # :aprs_messages,
|
||||
# # {message.data_extended.to, message.data_extended, time_message_received}
|
||||
# # )
|
||||
# end
|
||||
result =
|
||||
case packet do
|
||||
%{data_extended: nil} ->
|
||||
Logger.debug("Packet has nil data_extended: #{inspect(packet.sender)}")
|
||||
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]}]
|
||||
# :ets.select_count(:aprs_messages, total_spec)
|
||||
# end
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ defmodule Aprs.Packet do
|
|||
field(:path, :string)
|
||||
field(:sender, :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)
|
||||
|
||||
timestamps()
|
||||
|
|
@ -21,6 +26,9 @@ defmodule Aprs.Packet do
|
|||
|
||||
@doc false
|
||||
def changeset(packet, attrs) do
|
||||
# Convert atom data_type to string
|
||||
attrs = normalize_data_type(attrs)
|
||||
|
||||
packet
|
||||
|> cast(attrs, [
|
||||
:base_callsign,
|
||||
|
|
@ -29,7 +37,12 @@ defmodule Aprs.Packet do
|
|||
:information_field,
|
||||
:path,
|
||||
:sender,
|
||||
:ssid
|
||||
:ssid,
|
||||
:received_at,
|
||||
:region,
|
||||
:lat,
|
||||
:lon,
|
||||
:has_position
|
||||
])
|
||||
|> validate_required([
|
||||
:base_callsign,
|
||||
|
|
@ -38,7 +51,40 @@ defmodule Aprs.Packet do
|
|||
:information_field,
|
||||
:path,
|
||||
:sender,
|
||||
:ssid
|
||||
:ssid,
|
||||
:received_at
|
||||
])
|
||||
|> maybe_set_has_position()
|
||||
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
|
||||
|
|
|
|||
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
|
||||
|
||||
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
|
||||
|
|
|
|||
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">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<.flash kind={:info} title="Success!" flash={@flash} />
|
||||
|
|
|
|||
|
|
@ -12,26 +12,6 @@
|
|||
</script>
|
||||
</head>
|
||||
<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}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -10,11 +10,15 @@ defmodule AprsWeb.MapLive.Index do
|
|||
|
||||
@default_center %{lat: 39.8283, lng: -98.5795}
|
||||
@default_zoom 5
|
||||
@ip_api_url "http://ip-api.com/json/"
|
||||
@ip_api_url "https://ip-api.com/json/"
|
||||
@finch_name Aprs.Finch
|
||||
@default_replay_speed 1000
|
||||
|
||||
@impl true
|
||||
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 =
|
||||
assign(socket,
|
||||
packets: [],
|
||||
|
|
@ -30,12 +34,35 @@ defmodule AprsWeb.MapLive.Index do
|
|||
west: -125.0
|
||||
},
|
||||
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
|
||||
IO.puts("Socket is connected, attempting to get IP location")
|
||||
Endpoint.subscribe("aprs_messages")
|
||||
# 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 =
|
||||
case socket.private[:connect_info][:peer_data][:address] do
|
||||
{a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}"
|
||||
|
|
@ -43,7 +70,29 @@ defmodule AprsWeb.MapLive.Index do
|
|||
_ -> nil
|
||||
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
|
||||
|
||||
{:ok, socket}
|
||||
|
|
@ -70,6 +119,25 @@ defmodule AprsWeb.MapLive.Index do
|
|||
|
||||
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)}
|
||||
end
|
||||
|
||||
|
|
@ -82,38 +150,182 @@ defmodule AprsWeb.MapLive.Index do
|
|||
@impl true
|
||||
def handle_event("locate_me", _params, socket) do
|
||||
# Send JavaScript command to request browser geolocation
|
||||
IO.puts("locate_me event received, requesting geolocation")
|
||||
{:noreply, push_event(socket, "request_geolocation", %{})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket) do
|
||||
# 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
|
||||
|
||||
@impl true
|
||||
def handle_info(msg, socket) 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}} ->
|
||||
{: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} ->
|
||||
# Sanitize the packet to prevent encoding errors
|
||||
sanitized_packet = EncodingUtils.sanitize_packet(payload)
|
||||
|
||||
# Log packet type for debugging
|
||||
IO.inspect(sanitized_packet.data_type, label: "Packet type")
|
||||
|
||||
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
|
||||
# Add received timestamp if not present
|
||||
sanitized_packet = Map.put_new(sanitized_packet, :received_at, DateTime.utc_now())
|
||||
|
||||
# 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
|
||||
packet_data = build_packet_data(sanitized_packet)
|
||||
|
||||
|
|
@ -139,12 +351,75 @@ defmodule AprsWeb.MapLive.Index do
|
|||
{:noreply, socket}
|
||||
end
|
||||
else
|
||||
# Ignore packets without position data or outside bounds
|
||||
# Ignore packets without position data, outside bounds, or too old
|
||||
{:noreply, socket}
|
||||
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
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -192,6 +467,7 @@ defmodule AprsWeb.MapLive.Index do
|
|||
border-radius: 5px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
z-index: 1000;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.locate-button {
|
||||
|
|
@ -214,12 +490,116 @@ defmodule AprsWeb.MapLive.Index do
|
|||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
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 {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.historical-marker {
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div
|
||||
|
|
@ -235,7 +615,50 @@ defmodule AprsWeb.MapLive.Index do
|
|||
|
||||
<div class="map-overlay">
|
||||
<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>
|
||||
|
||||
|
|
@ -259,8 +682,120 @@ defmodule AprsWeb.MapLive.Index do
|
|||
"""
|
||||
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
|
||||
|
||||
# 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
|
||||
case packet.data_extended do
|
||||
%MicE{} = mic_e ->
|
||||
|
|
@ -314,15 +849,14 @@ defmodule AprsWeb.MapLive.Index do
|
|||
defp build_packet_data(packet) do
|
||||
data_extended = build_data_extended(packet.data_extended)
|
||||
|
||||
if data_extended do
|
||||
%{
|
||||
"base_callsign" => packet.base_callsign || "",
|
||||
"ssid" => packet.ssid || "",
|
||||
"data_type" => to_string(packet.data_type || "unknown"),
|
||||
"path" => packet.path || "",
|
||||
"data_extended" => data_extended
|
||||
}
|
||||
end
|
||||
# Always return a map, even when data_extended is nil
|
||||
%{
|
||||
"base_callsign" => packet.base_callsign || "",
|
||||
"ssid" => packet.ssid || "",
|
||||
"data_type" => to_string(packet.data_type || "unknown"),
|
||||
"path" => packet.path || "",
|
||||
"data_extended" => data_extended || %{}
|
||||
}
|
||||
end
|
||||
|
||||
# Get IP location from external service
|
||||
|
|
@ -332,26 +866,66 @@ defmodule AprsWeb.MapLive.Index do
|
|||
|
||||
defp get_ip_location(ip) do
|
||||
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}} ->
|
||||
IO.puts("IP API response received successfully")
|
||||
IO.puts("Response body: #{String.slice(body, 0, 200)}...")
|
||||
|
||||
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
|
||||
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
|
||||
IO.puts("Coordinates out of range: lat=#{lat}, lng=#{lng}, using default center")
|
||||
send(self(), {:ip_location, @default_center})
|
||||
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})
|
||||
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})
|
||||
|
||||
{: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})
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -29,8 +29,11 @@ defmodule Parser do
|
|||
information_field: data,
|
||||
data_type: data_type,
|
||||
base_callsign: base_callsign,
|
||||
ssid: ssid,
|
||||
data_extended: data_extended
|
||||
# Ensure ssid is never nil
|
||||
ssid: ssid || "0",
|
||||
data_extended: data_extended,
|
||||
# Set received_at when creating packet
|
||||
received_at: DateTime.truncate(DateTime.utc_now(), :microsecond)
|
||||
}}
|
||||
else
|
||||
true ->
|
||||
|
|
@ -49,7 +52,8 @@ defmodule Parser do
|
|||
if String.contains?(callsign, "-") do
|
||||
String.split(callsign, "-")
|
||||
else
|
||||
[callsign, nil]
|
||||
# Default SSID to "0" instead of nil
|
||||
[callsign, "0"]
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ defmodule Parser.Types.MicE do
|
|||
@moduledoc """
|
||||
Type struct for MicE
|
||||
"""
|
||||
@behaviour Access
|
||||
|
||||
defstruct lat_degrees: 0,
|
||||
lat_minutes: 0,
|
||||
lat_fractional: 0,
|
||||
|
|
@ -18,4 +20,62 @@ defmodule Parser.Types.MicE do
|
|||
speed: 0,
|
||||
manufacturer: :unknown,
|
||||
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
|
||||
|
|
|
|||
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