aprs.me/lib/aprsme/packet_replay.ex

438 lines
12 KiB
Elixir

defmodule Aprsme.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 Aprsme.Packets
alias AprsmeWeb.Endpoint
require Logger
@default_replay_window_minutes 60
@default_replay_speed 5.0
@type state :: %{
user_id: String.t(),
replay_topic: String.t(),
replay_speed: float(),
start_time: DateTime.t(),
end_time: DateTime.t(),
region: String.t() | nil,
bounds: map(),
callsign: String.t() | nil,
with_position: boolean(),
limit: pos_integer(),
paused: boolean(),
packets_sent: non_neg_integer(),
replay_started_at: DateTime.t(),
replay_timer: reference() | nil,
last_packet_time: DateTime.t() | nil
}
# 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)
"""
@spec start_replay(keyword()) :: {:ok, pid()} | {:error, any()}
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.
"""
@spec stop_replay(String.t()) :: :ok
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.
"""
@spec pause_replay(String.t()) :: :ok | :already_paused
def pause_replay(user_id) do
name = via_tuple(user_id)
GenServer.call(name, :pause)
end
@doc """
Resumes a paused replay session.
"""
@spec resume_replay(String.t()) :: :ok
def resume_replay(user_id) do
name = via_tuple(user_id)
GenServer.call(name, :resume)
end
@doc """
Changes the replay speed.
"""
@spec set_replay_speed(String.t(), number()) :: :ok
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.)
"""
@spec update_filters(String.t(), keyword()) :: :ok
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.
"""
@spec get_replay_info(String.t()) :: map()
def get_replay_info(user_id) do
name = via_tuple(user_id)
GenServer.call(name, :get_info)
end
# Server implementation
@impl true
@spec init(keyword()) :: {:ok, state()}
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
@spec handle_info(any(), state()) :: {:noreply, state()} | {:stop, :normal, state()}
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(Map.new(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
replay_opts_map =
replay_opts
|> Keyword.put(:playback_speed, state.replay_speed)
|> Map.new()
stream = Packets.stream_packets_for_replay(replay_opts_map)
# 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
@spec handle_call(any(), {pid(), any()}, state()) :: {:reply, any(), state()}
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
@spec terminate(any(), state()) :: :ok
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
@spec via_tuple(String.t()) :: {:via, Registry, {atom(), String.t()}}
defp via_tuple(user_id) do
{:via, Registry, {Aprsme.ReplayRegistry, "replay:#{user_id}"}}
end
@spec sanitize_packet_for_transport(any()) :: map()
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
@spec sanitize_map_values(map()) :: map()
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