Commit graph

1290 commits

Author SHA1 Message Date
69152e86b3
refactor: split PacketProducer.handle_cast into pattern-matched heads
The original handle_cast had nested `if`s: outer branch on demand > 0,
inner branch on buffer-over-capacity. Split into:

- handle_cast with a `when demand > 0` guard for the hot path that
  skips the buffer entirely.
- A fallback clause that computes whether the new size would overflow
  and delegates to buffer_packet/3.
- buffer_packet/3 has two clauses — one for the drop-oldest path
  (with telemetry + warning) and one for the simple append path. Both
  share the final `maybe_activate_backpressure |> reply_noemit` step.

All 19 existing PacketProducer tests pass; dialyzer stays clean.
2026-04-23 14:26:23 -05:00
FluxCD
7ce011bddf chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776972295-c910f9a [skip ci] 2026-04-23 19:25:21 +00:00
c910f9a432
refactor: split if/else in Accounts.User password helpers
- do_hash_password/3: move the changeset.valid? branch out of the
  `if` into apply_hash/3, dispatched on the boolean. The bcrypt 72-byte
  cap and plaintext-scrubbing steps now live in one clear function head.
- validate_current_password/2: dispatch on valid_password?/2 via
  check_current_password/2 so the add_error branch is its own clause.

No behavior change; all 24 Accounts tests still pass and dialyzer stays
clean.
2026-04-23 14:24:29 -05:00
FluxCD
edd5a8b30c chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776972222-1d68868 [skip ci] 2026-04-23 19:24:08 +00:00
1d68868d27
refactor: pattern-match InfoLive.Show path parsing + add tests
- parse_path_element_with_link splits its fat `if`/or-chain into three
  focused helpers: linkable_callsign?/1 (regex + alias check),
  path_alias?/1 (four String.starts_with guards), and
  classify_path_element/2 (link vs. text dispatched on the boolean).
- display_for_element/2 chooses the callsign form with/without the
  asterisk via two function heads instead of an inline `if`.

Adds 7 tests covering the parse_path_with_links/1 public API — plain
callsigns, heard-via asterisk preservation, non-linkable aliases
(WIDE/TRACE/RELAY/TCPIP), q-constructs, whitespace trimming, and mixed
paths.
2026-04-23 14:23:16 -05:00
FluxCD
bcbaddc18b chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776972102-282d3c1 [skip ci] 2026-04-23 19:22:10 +00:00
282d3c1ccb
refactor: pattern-match ShutdownHandler + add tests
- handle_call(:shutdown, _, state): split into a shutting_down=true
  fast-path clause and a normal initiate_shutdown clause. No more
  `if state.shutting_down` inside the callback body.
- terminate/2: extract graceful_shutdown_needed?/1 (four function
  heads on the reason tag) and maybe_graceful_drain/2 (guarded on
  state.shutting_down) so the shutdown side-effects are isolated to
  a single, pattern-matched entry point.

Adds 4 unit tests for the pure callback paths
(shutting_down?, shutdown-already-in-progress, EXIT pass-through,
rescue fallback on unavailable GenServer).
2026-04-23 14:21:15 -05:00
FluxCD
dd0fe397f1 chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776971969-fc5e914 [skip ci] 2026-04-23 19:20:17 +00:00
fc5e91479b
refactor: pattern-match CoreComponents.safe_row_id + CallsignController
- CoreComponents.safe_row_id: four function heads dispatch on the row
  shape (atom-key map, string-key map, other map fallback, non-map)
  instead of a cond with Map.has_key? checks.
- Api.V1.CallsignController.validate_callsign: check_callsign_format/2
  splits the validation success/failure branches into two clauses; the
  binary-vs-everything-else dispatch stays in validate_callsign/1.

Adds 5 integration tests for the CallsignController covering invalid,
not-found, success, case-normalization, and length-limit paths. The
test file is async:false because it shares the rate-limiter ETS with
other API tests and could otherwise trip the 100/min per-IP cap.
2026-04-23 14:18:30 -05:00
FluxCD
ed993aa4de chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776971587-d879b54 [skip ci] 2026-04-23 19:13:36 +00:00
d879b54fd4
refactor: replace more cond expressions with guarded clauses
- CoordinateUtils.normalize_longitude / normalize_latitude: recursion
  and clamping move into the function head via numeric guards.
- Shared.PacketUtils.map_label: dispatch on data_type + fallback via
  multi-clause helpers (map_label_for/2, prefer_name/2) instead of a
  three-branch cond with inner `if`s.
- MapLive.DataBuilder.get_received_at: pattern-match the atom- and
  string-key packet shapes in the function head.

No behavior change; all 1567 tests and 27 property checks still pass,
dialyzer remains clean.
2026-04-23 14:12:45 -05:00
FluxCD
8d777b2cf8 chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776971420-35bd098 [skip ci] 2026-04-23 19:10:58 +00:00
35bd098b30
refactor: more pattern matching + drop unreachable get_field_value clause
- Aprsme.Packet.put_phg_fields: dispatch on the shape of the phg value
  (map / 4-char binary / anything else) via put_phg_fields_for/2.
- Aprsme.Packet.get_field_value: remove the catch-all nil clause. After
  the put_phg_fields refactor, dialyzer can prove every call site passes
  a map and flagged the fallback as unreachable — so it's gone, and
  a future mistyped caller now fails loudly instead of silently.
- LogSanitizer.sanitize_map: extract redact_field/2 for readability.
- DeviceIdentification.pattern_matches?: match_pattern/3 dispatched on
  String.contains?/2 keeps the rescue tight around the regex path.
- InfoLive.Show: get_received_at pattern-matches on key shape;
  format_distance splits locale × range via four format_*_distance
  helpers; calculate_course pipes through normalize_bearing/1.

Adds 15 tests exercising the InfoLive.Show helpers (imperial vs. metric
formatting, cardinal bearings, haversine symmetry, Decimal inputs).
Coverage 67.43 → 67.72%.
2026-04-23 14:09:53 -05:00
FluxCD
bb080fd1dd chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776971001-ee02769 [skip ci] 2026-04-23 19:04:11 +00:00
ee02769d38
refactor: pattern-match in HealthCheck, EncodingUtils, and StatusLive helpers
- HealthCheck.check_health(:readiness): collapse the 3-branch cond into
  readiness_status/2 dispatched on (health_status, shutting_down?).
- HealthCheck.shutting_down?: the whereis + alive? + try/catch nest
  becomes a small pipeline of function heads (ask_shutting_down?/1 +
  ask_if_alive/2), with the catch isolated to the single place it fires.
- EncodingUtils.sanitize_string_fields and sanitize_nested_map shared a
  three-branch atom-or-string key lookup. Extract update_existing_key/3
  + first_present_key/3 + nil_aware/1 helpers; the original functions
  become two-liners.
- StatusLive.Index.format_uptime: splits the cond into
  format_uptime_parts/4 clauses with numeric guards.
- StatusLive.Index.calculate_health_score: struct-shape pattern match
  on %{connected: false} / %{uptime_seconds: s} guards replaces the cond.
- StatusLive.Index.format_time_ago: pipe the diff through
  format_seconds_ago/1 with four clauses.

Adds tests for the four public StatusLive.Index helpers
(format_uptime, format_time_ago, get_health_description, format_number).
Coverage 67.29 → 67.43%.
2026-04-23 14:02:50 -05:00
FluxCD
c0afd21620 chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776970743-564d21b [skip ci] 2026-04-23 18:59:29 +00:00
564d21bb26
refactor: pattern-match in TimeHelpers.to_datetime and PageController
- TimeHelpers.to_datetime: `cond` with match?/2 guards becomes five
  function heads dispatched by input type — DateTime passes through,
  NaiveDateTime anchors to UTC, integer is a millisecond Unix epoch,
  binary tries ISO 8601, everything else is nil.
- PageController.health: the three-branch `cond` + inner `if db_healthy`
  becomes a chain of small respond_* helpers, each pattern-matched on a
  single axis (health_status, shutting_down?, db_healthy?). db_healthy?/0
  isolates the rescue.
- format_uptime splits the cond into four format_uptime_parts/4 clauses.

Adds tests for the PageController actions (health 200 / 503-draining,
ready, sitemap.xml, .well-known/api-catalog, status.json). Coverage
66.81 → 67.29%.
2026-04-23 13:58:42 -05:00
FluxCD
73342df543 chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776970532-7db7867 [skip ci] 2026-04-23 18:55:57 +00:00
7db78675a2
refactor: collapse nested if/case into pattern-matched helpers
- SharedPacketHandler.handle_packet_update: dispatch on filter-result,
  and use maybe_enrich/2 dispatched on the enrich? boolean instead of
  an inner `if`. lookup_device_info/1 is now a trio of function heads.
- Cluster.Topology.child_spec: build_spec/3 dispatches on
  (cluster_enabled?, topologies) tuples — no more nested `if`.
- Cluster.PacketDistributor.distribute_packet: maybe_broadcast/2
  dispatches on the leader-and-enabled check; the non-leader / disabled
  paths return :ok explicitly instead of relying on an `if` expression's
  implicit nil.

Adds tests for SharedPacketHandler (filter/enrich/match paths,
callsign_filter + callsign_and_weather_filter factories) and extends
Cluster.Topology tests to cover the nil-topology and opts-merge cases.
Coverage 66.41 → 66.81%.
2026-04-23 13:55:11 -05:00
FluxCD
281f99e39e chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776970302-e43107a [skip ci] 2026-04-23 18:54:45 +00:00
e43107a25f
refactor(packet_processor): split cond/if into multi-clause helpers
- render_for_zoom/3: zoom ≤ 8 uses heatmap; high zoom with marker_data
  pushes to the client; high zoom + nil marker_data is a no-op. Each
  branch is now a separate function head.
- push_new_packet/3: a boolean-dispatched pair replaces the popup-open
  conditional on the push_event payload.
- prepare_packet_state: the two rebinding `if`s turn into a pipeline
  through prune_if_over_cap/1 (pattern-matched on map_size/1) and
  build_marker_data/5 with a zoom ≤ 8 short-circuit clause, plus
  maybe_tag_convert_from/3 for the replacement-marker tag.
- moved_significantly?/3: the duplicated guard check between
  dispatch_visibility and batch_dispatch collapses into one helper that
  pattern-matches on the coordinate tuple returned by CoordinateUtils.

Adds extra tests covering the new significance / stale-coords branches
and the connection card LiveComponent render paths. Coverage 65.89 → 66.41%.
2026-04-23 13:51:17 -05:00
FluxCD
7e47966f67 chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776969983-aa0c6e6 [skip ci] 2026-04-23 18:46:49 +00:00
aa0c6e6566
refactor: pattern-match in DeviceCache and DatabaseMetrics + add tests
- DeviceCache.pattern_matches?: replace a 3-branch `cond` with a
  two-clause `case` on `String.contains?(pattern, "?")`. The old `*`
  branch was dead code (it did the same exact-match compare as the
  fallback), so it's removed.
- Telemetry.DatabaseMetrics.collect_postgres_metrics: dispatch on
  environment atom via multi-clause private functions instead of
  `if Application.get_env(...) == :test`.

New tests:
- DeviceCache wildcard/exact lookup paths exercised via a seeded cache.
- MobileUserSocket connect/3 for x_headers/peer_data/empty info and
  rate-limit deny.
- DatabaseMetrics pool-metrics emission captured via :telemetry handler
  and the :test-env short-circuit.
2026-04-23 13:46:00 -05:00
FluxCD
fa970b5637 chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776969532-c5a82b7 [skip ci] 2026-04-23 18:39:19 +00:00
c5a82b77c1
refactor: pattern-match over conditionals in Navigation and InsertOptimizer
Swaps `if`/`cond` branches for multi-clause function heads or head-pattern
matching, making the control flow visible in the function signatures:

- Navigation.determine_map_location: resolve_location/4 now dispatches on
  the shape of the geolocation input and whether URL params are explicit.
- Navigation.handle_callsign_tracking: per-case clauses for empty callsign
  and explicit-URL-params short-circuits, plus a final clause that does the
  DB lookup and rescues once.
- Navigation.handle_callsign_search: dispatch_callsign_search/3 separates
  the valid/invalid callsign branches.
- InsertOptimizer.optimize_based_on_performance: pattern-match on
  [_, _, _ | _] to require ≥3 samples without calling length/1.
- InsertOptimizer.calculate_insert_options and throughput helpers split
  into guarded clauses instead of inline `if`.
- maybe_emit_optimization uses same-var pattern matching to skip the
  telemetry hit when nothing changed.

Adds unit tests covering every branch of both modules plus SignalHandler,
LocaleHook, and the InsertOptimizer's :noproc fallback path. Coverage
64.69% → 65.72%.
2026-04-23 13:38:24 -05:00
FluxCD
cbf9a9bb5c chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776969151-403300f [skip ci] 2026-04-23 18:33:10 +00:00
403300f696
test: broaden unit & property-test coverage (61.8% → 64.7%)
Adds ~800 new tests (incl. 27 StreamData properties) across 22 modules
that previously had little or no coverage. Highlights:

- Pure utility modules (LogSanitizer, PacketFieldWhitelist,
  PacketSanitizer, DeviceParser, CoordinateUtils, BoundsUtils,
  ParamUtils, Convert, WeatherUnits) get thorough unit + property
  coverage including UTF-8 truncation boundaries, antimeridian
  longitude wrap, Haversine symmetry, and unit-conversion inverses.
- JSON view modules (CallsignJSON, ErrorJSON, ChangesetJSON) verified
  for envelope shape and error templating.
- Plugs (HealthCheck, RateLimiter, ApiCSRF) exercised for happy-path
  and halt paths.
- GenServer modules (RegexCache, CleanupScheduler, DeploymentNotifier)
  tested without touching the supervised singleton where possible.
- Drops the orphaned map_live/map_helpers_test.exs whose module name
  collided with the new live/shared/coordinate_utils_test.exs.
2026-04-23 13:32:09 -05:00
1ad1bef59f
fix(devices): make upsert_devices/1 work with Repo.insert_all
Three related bugs prevented upsert_devices/1 from ever succeeding in
production:

- The JSON map keys ("identifier", "vendor", etc.) were passed through
  to Repo.insert_all, which only accepts atom field names.
- updated_at was a %DateTime{} but the Devices schema uses :naive_datetime.
- inserted_at was never set, violating the NOT NULL constraint.

Fix by whitelisting known string keys and converting them to atoms via
String.to_existing_atom/1 (safe against atom exhaustion), using
NaiveDateTime.utc_now/1, and setting both timestamps explicitly.
2026-04-23 13:31:49 -05:00
5ef97adaf7
refactor: promote hot-path GenServer state maps to structs
Converts the inline state maps in Is, PacketConsumer, LeaderElection, and
CircuitBreaker into structs with @type t specs. Enforced keys catch typos
on state updates and give dialyzer a concrete type to check against
instead of map() in every handle_* clause.

Is' nested login_params and packet_stats are extracted to their own
modules (Aprsme.Is.LoginParams, Aprsme.Is.PacketStats) rather than
defined inline, per the project's no-nested-modules rule.
2026-04-21 10:26:20 -05:00
FluxCD
d1409bcc7d chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776784731-256e61e [skip ci] 2026-04-21 15:19:18 +00:00
256e61ec76
types: add @type t to last two untyped GenServer structs
Every other defstruct in the app already has a @type t declaration;
these two GenServer states were the last holdouts. Declaring t here
means the whole codebase is ready for the typed-struct milestone of
Elixir's set-theoretic type system (the next phase after the inference
pass already shipping in 1.19).
2026-04-21 10:18:26 -05:00
FluxCD
3a163028e3 chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776784530-f225f57 [skip ci] 2026-04-21 15:15:56 +00:00
f225f57044
perf(packets): keyset-based pagination for recent-packet queries
get_recent_packets/1 and get_recent_packets_for_map/1 now accept a
:cursor option (%{received_at: DateTime, id: uuid}) instead of :offset
for deep paging. On the 100M-row partitioned packets table, offset
scanning was linear in page depth; keyset is constant.

Ordering tightened to (received_at DESC, id DESC) so the cursor is
stable across ties. idx_packets_received_desc still covers the lookup;
a dedicated (received_at DESC, id DESC) index isn't worth its size
given microsecond timestamp granularity.

historical_loader migrated from batch_offset*batch_size scheduling to
a sequential cursor chain. The old loader fanned out N Process.send_after
batches up-front which could arrive out-of-order; the cursor version
waits for batch N before scheduling N+1, and also short-circuits the
remaining schedule when an under-full batch signals exhaustion.

mobile_channel load_historical_packets dropped its meaningless
`offset: 0`; callsign history already lacked offset.

No LiveView or WebSocket payload shapes changed. offset: 0 (or omitted)
still behaves as first page; non-zero offsets are silently ignored.
2026-04-21 10:15:04 -05:00
ead0be4434
db: drop 10 unused legacy columns from packets table
Dead fields left over from the original parser schema: srccallsign,
dstcallsign, origpacket, header, body, alive, posambiguity,
symboltable, symbolcode, messaging. None are declared in the Packet
schema, none are read anywhere in lib/ or test/, and Packet.strip_dead_fields/1
already removed them from attrs before insert.

In environments where ConsolidatePacketsColumns (20260220230000)
already DROP/RECREATEd the table without these columns the migration
is a safety-net no-op thanks to IF EXISTS; otherwise it ALTERs the
parent partitioned table and Postgres cascades to all partitions.
2026-04-21 10:15:04 -05:00
FluxCD
7cd9ccf2a0 chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776784048-b8a9b8a [skip ci] 2026-04-21 15:08:46 +00:00
b8a9b8a465
chore(dialyzer): enable stricter flags and fix 97 resulting findings
Turned on :error_handling, :underspecs, and :unmatched_returns in
mix.exs dialyzer config. The 97 warnings this surfaced were fixed in
place rather than suppressed:

- unmatched_return (79): explicit discard with `_ = ...` for
  fire-and-forget side effects (Process.cancel_timer, :ets.new,
  send/2), and pattern-matched `:ok = ...` for control-plane
  Phoenix.PubSub subscribe/unsubscribe/broadcast calls so a future
  return-shape change fails loud.

- contract_supertype (18): tightened @spec arg and return types on
  data_builder, historical_loader, url_params, packet_utils,
  encoding_utils, aprs_symbol, weather_controller, packet_replay to
  match each function's actual success typing.

No behavioural change. mix compile clean, 1008 tests pass, dialyzer
count is now 0.
2026-04-21 10:07:01 -05:00
FluxCD
8332e5c53c chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776782437-8a0381d [skip ci] 2026-04-21 14:41:06 +00:00
8a0381d9bd
perf(regex_cache): replace half-wipe eviction with LRU
Stored entries now carry a monotonic access counter refreshed on every
hit. When the cache fills, the 10% least-recently-used entries are
dropped instead of an arbitrary half of the table, which previously
caused thrashing under diverse input by evicting hot entries.
2026-04-21 09:40:20 -05:00
FluxCD
8fd62fba26 chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776782357-09f92c6 [skip ci] 2026-04-21 14:40:12 +00:00
09f92c6738
feat(mobile): rate-limit socket + batch historical packet delivery
- MobileUserSocket: cap new socket connections per IP at 30/min via
  Aprsme.RateLimiter. Denials log and return :error at handshake.
  Tests bypass the check (no real peer_data).

- MobileChannel: rate-limit the expensive client-initiated handlers
  (subscribe_bounds, subscribe_callsign, search_callsign) at
  30/minute per socket. Streaming packet delivery is unaffected.

- MobileChannel: historical packet loads now arrive as a batched
  `packets` event (100 per frame) instead of one `packet` frame per
  row. Live streaming still uses the single `packet` event.

- Updated docs/mobile-api.md with the `packets` event and rate-limit
  semantics; migration note preserves compatibility for clients that
  only handle live `packet` events.
2026-04-21 09:38:51 -05:00
FluxCD
8f64d0e999 chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776781963-c64ad4d [skip ci] 2026-04-21 14:33:13 +00:00
c64ad4d571
fix(data_builder): clear weather cache in after block
If build_packet_data raises, the :weather_callsigns_cache process-dict
entry would stick around and poison subsequent unrelated calls on the
same LiveView process. Wrap the building pass in try/after so the key
is always removed.
2026-04-21 09:32:17 -05:00
2fc4706d3d
fix: stop silent packet loss, timer leak, and seq-scans
- packet_consumer: when a batch exceeds max_batch_size, carry the
  excess over to the next cycle instead of dropping it. Previously
  any overage was split off into a drop list that was only logged,
  losing packets the producer had already acknowledged.

- map_live: cancel the hover_end_timer in terminate/2 so a user
  disconnecting while a marker is still highlighted doesn't leave
  an orphaned timer behind.

- packet_consumer: rescue/log exceptions inside the async broadcast
  task so PubSub serialization bugs or outages are observable
  instead of silently killing the Task.

- prepared_queries: swap ST_Y/ST_X BETWEEN predicates for the && /
  ST_MakeEnvelope pattern so bounds queries hit the GIST index on
  packets.location instead of sequentially scanning.
2026-04-21 09:29:06 -05:00
FluxCD
acd596e172 chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776781097-9aa836e [skip ci] 2026-04-21 14:18:41 +00:00
9aa836e462
refactor(map): share isValidCoordinate and unwrapLongitudes helpers
The coord-validation and antimeridian-unwrap logic added in the last
pass had been duplicated across map.ts, trail_manager.ts, and
info_map.ts. Move both into map_helpers.ts and import from there so
there is one authoritative implementation.
2026-04-21 09:17:52 -05:00
FluxCD
c41e47ed95 chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776726355-e90226d [skip ci] 2026-04-20 23:06:18 +00:00
e90226dc21
fix(map): tighten edge cases in heatmap, info map, and trail color
- Heatmap: filter points with invalid coords or out-of-range intensity
  so malformed rows can't distort the layer.
- InfoMap: validate coords for range (not just NaN) and use an epsilon
  when comparing positions, so float noise doesn't cause needless
  re-renders or accept bogus data.
- TrailManager: getTrailCenter returns null for empty input instead of
  the ambiguous (0, 0) null-island; callers guard accordingly.
2026-04-20 18:05:31 -05:00
FluxCD
52418a7b41 chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776726224-f777dcb [skip ci] 2026-04-20 23:04:07 +00:00
f777dcbb35
fix(map): stop trail spidering across antimeridian and close gaps
- Normalize longitude delta in trail_manager Haversine so wraps across
  180/-180 no longer compute a near-global distance and spuriously
  split the trail into short-segment-filtered pieces.
- Unwrap longitudes before building segments and drawing polylines in
  both the per-station TrailManager and the show_trail_line handler so
  a date-line crossing renders as a short hop instead of a line that
  wraps around the world.
- Raise maxHopDistance (10 -> 100 km) and drop minSegmentPoints (3 -> 2)
  so legitimate fast movers (aircraft, balloons) and short valid hops
  are drawn instead of being dropped.
- Unbind mousemove/mouseout handlers on a trail before removing it so
  queued events can't fire against discarded state.
- Fix self.isValidCoordinate TypeError in updateMarker (function is
  module-level, not on the hook) and add coord validation to
  show_trail_line.
- Null boundsTimer after clearTimeout for cleanup consistency.
2026-04-20 18:03:25 -05:00
FluxCD
0ed2dd3806 chore: update aprs.me image to git.mcintire.me/graham/aprs.me:main-1776724752-4417961 [skip ci] 2026-04-20 22:39:43 +00:00