- Extract PathParser.valid_callsign?/1 and reuse from the calibration Mix task instead of duplicating the regex. - Document raise behavior on Aprs.recent_packets_with_paths/1 and Aprs.station_positions/1 (already noted in moduledoc; @doc reinforces for callers reading the function head). - Note in the BandConfig guard comment why it sits before the Oban pause (so a misconfig exits without leaving queues paused). - Reword the band-mismatched-negatives note as a v1 limitation (no TODO tag — codebase convention is zero TODOs).
130 lines
4.4 KiB
Elixir
130 lines
4.4 KiB
Elixir
defmodule Microwaveprop.Aprs do
|
|
@moduledoc """
|
|
Read-only access to aprs.me's `packets` table for 144 MHz calibration.
|
|
|
|
This module never writes, never schemas, never mirrors. Each call
|
|
issues a SELECT against `Microwaveprop.AprsRepo` (which connects to
|
|
aprs.me's database) and returns raw maps for downstream parsing by
|
|
`Microwaveprop.Aprs.PathParser` and consumption by the
|
|
`Calibrate.Aprs144` mix task.
|
|
|
|
aprs.me retains 24 h of packet history in production
|
|
(`PACKET_RETENTION_DAYS=1`), so any "historical" query is bounded by
|
|
that window.
|
|
|
|
Tests run against a local `aprsme_test` database whose schema mirrors
|
|
aprs.me's partitioned `packets` table; rows are inserted via raw SQL
|
|
in a setup block under the `Ecto.Adapters.SQL.Sandbox`.
|
|
|
|
> #### Error semantics {: .warning}
|
|
>
|
|
> Both queries call `Ecto.Adapters.SQL.query!/3` and will raise on
|
|
> connection or SQL failures. Today the only caller is the
|
|
> `Calibrate.Aprs144` mix task, where a hard exit is the right
|
|
> ergonomic. Async callers (`Task.async_stream`, LiveView
|
|
> `start_async`, etc.) MUST catch and log per the project's
|
|
> swallowed-error rule in CLAUDE.md.
|
|
"""
|
|
|
|
alias Ecto.Adapters.SQL
|
|
alias Microwaveprop.AprsRepo
|
|
|
|
@type packet_row :: %{
|
|
id: binary(),
|
|
sender: String.t(),
|
|
base_callsign: String.t(),
|
|
lat: float(),
|
|
lon: float(),
|
|
path: String.t(),
|
|
received_at: DateTime.t()
|
|
}
|
|
|
|
@type position :: {lat :: float(), lon :: float(), last_heard_at :: DateTime.t()}
|
|
|
|
@recent_packets_sql """
|
|
SELECT id, sender, base_callsign, lat, lon, path, received_at
|
|
FROM packets
|
|
WHERE has_position = true
|
|
AND lat IS NOT NULL
|
|
AND lon IS NOT NULL
|
|
AND path IS NOT NULL
|
|
AND path <> ''
|
|
AND is_item = false
|
|
AND is_object = false
|
|
AND sender IS NOT NULL
|
|
AND received_at >= $1
|
|
ORDER BY received_at ASC
|
|
LIMIT $2
|
|
"""
|
|
|
|
@station_positions_sql """
|
|
SELECT DISTINCT ON (base_callsign) base_callsign, lat, lon, received_at
|
|
FROM packets
|
|
WHERE base_callsign = ANY($1)
|
|
AND has_position = true
|
|
AND lat IS NOT NULL
|
|
AND lon IS NOT NULL
|
|
ORDER BY base_callsign, received_at DESC
|
|
"""
|
|
|
|
@doc """
|
|
Returns recent position-bearing packets with non-empty paths, oldest first.
|
|
|
|
Filters out item/object packets and rows missing position or path.
|
|
Raises on connection or SQL failure — see moduledoc on async-context safety.
|
|
|
|
## Options
|
|
|
|
* `:since` — `%DateTime{}`. Default `now - 1h`.
|
|
* `:limit` — non-neg integer. Default `50_000`.
|
|
"""
|
|
@spec recent_packets_with_paths(keyword()) :: [packet_row()]
|
|
def recent_packets_with_paths(opts \\ []) do
|
|
since = Keyword.get_lazy(opts, :since, fn -> DateTime.add(DateTime.utc_now(), -3600, :second) end)
|
|
limit = Keyword.get(opts, :limit, 50_000)
|
|
|
|
# Force the bound to UTC so a non-UTC %DateTime{} doesn't silently
|
|
# match a wrong window after `to_naive` strips the tz tag.
|
|
naive_since = since |> DateTime.shift_zone!("Etc/UTC") |> DateTime.to_naive()
|
|
|
|
%Postgrex.Result{rows: rows} =
|
|
SQL.query!(AprsRepo, @recent_packets_sql, [naive_since, limit])
|
|
|
|
Enum.map(rows, &decode_packet_row/1)
|
|
end
|
|
|
|
@doc """
|
|
Returns the most recent known position for each callsign in `callsigns`.
|
|
|
|
Callsigns with no positioned packets simply don't appear in the result.
|
|
Empty input list short-circuits to `%{}` without querying.
|
|
|
|
Raises on connection or SQL failure — see moduledoc on async-context safety.
|
|
"""
|
|
@spec station_positions([String.t()]) :: %{String.t() => position()}
|
|
def station_positions([]), do: %{}
|
|
|
|
def station_positions(callsigns) when is_list(callsigns) do
|
|
%Postgrex.Result{rows: rows} = SQL.query!(AprsRepo, @station_positions_sql, [callsigns])
|
|
|
|
Map.new(rows, fn [base_callsign, lat, lon, received_at] ->
|
|
{base_callsign, {to_float(lat), to_float(lon), to_utc_datetime(received_at)}}
|
|
end)
|
|
end
|
|
|
|
defp decode_packet_row([id, sender, base_callsign, lat, lon, path, received_at]) do
|
|
%{
|
|
id: id,
|
|
sender: sender,
|
|
base_callsign: base_callsign,
|
|
lat: Decimal.to_float(lat),
|
|
lon: Decimal.to_float(lon),
|
|
path: path,
|
|
received_at: DateTime.from_naive!(received_at, "Etc/UTC")
|
|
}
|
|
end
|
|
|
|
defp to_float(%Decimal{} = d), do: Decimal.to_float(d)
|
|
|
|
defp to_utc_datetime(%NaiveDateTime{} = naive), do: DateTime.from_naive!(naive, "Etc/UTC")
|
|
end
|