stargazer/tier1/pistar.rs
1//! Pi-Star JSON host file fetcher.
2//!
3//! Fetches the canonical D-STAR reflector host list from
4//! `http://www.pistar.uk/downloads/DStar_Hosts.json` and upserts each
5//! reflector into the `reflectors` Postgres table.
6//!
7//! The Pi-Star host file is the most comprehensive public registry of D-STAR
8//! reflectors, maintained by the Pi-Star project. It maps reflector callsigns
9//! to IP addresses and categorises them by protocol type (REF/XRF/DCS).
10//!
11//! **Poll interval:** daily (default 86 400 s). The host file changes rarely —
12//! new reflectors appear at most a few times per month.
13
14use serde::Deserialize;
15
16use super::error::FetchError;
17use crate::db;
18
19/// Pi-Star host file download URL.
20const PISTAR_URL: &str = "http://www.pistar.uk/downloads/DStar_Hosts.json";
21
22/// Top-level JSON envelope returned by the Pi-Star host endpoint.
23///
24/// The response contains a single `"reflectors"` array with one object per
25/// reflector.
26#[derive(Debug, Deserialize)]
27struct PiStarResponse {
28 /// Array of reflector entries.
29 reflectors: Vec<PiStarReflector>,
30}
31
32/// A single reflector entry from the Pi-Star host file.
33///
34/// Each entry provides the reflector's callsign (name), protocol type, and
35/// IPv4 address. There is no dashboard URL or country information in this
36/// data source — those fields are left as `None` during upsert.
37#[derive(Debug, Deserialize)]
38struct PiStarReflector {
39 /// Reflector callsign, e.g. `"REF001"`, `"XRF320"`, `"DCS001"`.
40 name: String,
41
42 /// Protocol type as labelled by Pi-Star: `"REF"`, `"XRF"`, or `"DCS"`.
43 reflector_type: String,
44
45 /// IPv4 address of the reflector.
46 ipv4: String,
47}
48
49/// Maps Pi-Star's protocol type labels to the internal protocol identifiers
50/// used in the `reflectors` table.
51///
52/// - `"REF"` → `"dplus"` (REF reflectors use the `DPlus` protocol)
53/// - `"XRF"` → `"dextra"` (XRF reflectors use the `DExtra` protocol)
54/// - `"DCS"` → `"dcs"` (DCS reflectors use the DCS protocol)
55///
56/// Returns `None` for unrecognised types so the caller can skip them.
57fn map_protocol(reflector_type: &str) -> Option<&'static str> {
58 match reflector_type {
59 "REF" => Some("dplus"),
60 "XRF" => Some("dextra"),
61 "DCS" => Some("dcs"),
62 _ => None,
63 }
64}
65
66/// Fetches the Pi-Star host file and upserts all reflectors into Postgres.
67///
68/// Returns the number of reflectors successfully upserted. Reflectors with
69/// unrecognised protocol types are skipped with a debug log.
70///
71/// # Errors
72///
73/// - [`FetchError::Http`] if the HTTP request fails or returns a non-2xx status.
74/// - [`FetchError::Database`] if any database upsert fails. Note: a database
75/// error on one row aborts the entire batch (fail-fast) — this is acceptable
76/// because the next poll cycle will retry the full set.
77pub(crate) async fn fetch_and_store(
78 client: &reqwest::Client,
79 pool: &sqlx::PgPool,
80) -> Result<usize, FetchError> {
81 // Fetch and deserialize the JSON host file.
82 let response: PiStarResponse = client.get(PISTAR_URL).send().await?.json().await?;
83
84 let mut count = 0usize;
85 for entry in &response.reflectors {
86 let Some(protocol) = map_protocol(&entry.reflector_type) else {
87 tracing::debug!(
88 name = %entry.name,
89 reflector_type = %entry.reflector_type,
90 "skipping reflector with unrecognised protocol type"
91 );
92 continue;
93 };
94
95 // Pi-Star provides IP and protocol but no dashboard URL or country.
96 db::reflectors::upsert(
97 pool,
98 &entry.name,
99 protocol,
100 Some(entry.ipv4.as_str()),
101 None, // dashboard_url — not available from Pi-Star
102 None, // country — not available from Pi-Star
103 )
104 .await?;
105
106 count += 1;
107 }
108
109 tracing::info!(count, "pi-star: upserted reflectors");
110 Ok(count)
111}