ping-status sent "gauge feed stale" around midnight and "feed recovered" around 07:00 on each of the last three nights (ntfy and, since phase 3, the Matrix room). RID publishes no hourly report overnight: the 21:00 reading is the last until the 22:00..06:00 hours arrive together (06:35, 06:55, 07:22 on 2026-10-17/18/19). The ThaiWater overlay was meant to bridge that, but ThaiWater's copies of the RID gauges come from the same feed with the same delay (P.1's mirror still has no value for 22:00-00:00 this morning), so it does not. Between 21:00 and FEED_NIGHT_UNTIL_HOUR (default 9, 0 = off) the feed of a quiet river is no longer reported stale; one still without new readings at 09:00 is. While a station is at warning/danger or the P.1 outlook is on, the 3 h rule holds all night. A feed already stale stays stale through the night (no false "recovered"), and a real recovery is announced at any hour. The replay of the Python notifier runs with the window off. Docs no longer claim that ThaiWater keeps the feed current at night.
307 lines
12 KiB
Rust
307 lines
12 KiB
Rust
//! Configuration from the environment / `.env` file
|
|
//! (`/opt/thailand-water-monitor/.env`), which the Python training tools
|
|
//! (src/config.py) read too. Names and defaults are the ones the Python app
|
|
//! used.
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use std::env;
|
|
use std::path::PathBuf;
|
|
|
|
pub const RID_API_URL: &str =
|
|
"https://hyd-app-db.rid.go.th/webservice/getGroupHourlyWaterLevelReportAllHL.ashx";
|
|
pub const HII_API_BASE: &str = "https://api-v3.thaiwater.net/api/v1/thaiwater30/public";
|
|
pub const RID_DAMS_URL: &str = "https://app.rid.go.th/reservoir/api/dams";
|
|
pub const OPENMETEO_URL: &str = "https://api.open-meteo.com/v1/forecast";
|
|
/// The RID endpoint serves the same JSON to any client; keep the UA the
|
|
/// Python collector has used for years.
|
|
pub const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
|
|
(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36";
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Config {
|
|
pub database_url: String,
|
|
pub rid_api_url: String,
|
|
pub hii_api_base: String,
|
|
pub dams_url: String,
|
|
pub openmeteo_url: String,
|
|
pub hii_enabled: bool,
|
|
pub dams_enabled: bool,
|
|
pub rain_enabled: bool,
|
|
pub hii_basin_code: i64,
|
|
/// RID is polled this many minutes past each hour.
|
|
pub poll_offset_minutes: u32,
|
|
/// While the current hour is not published yet, retry this often.
|
|
pub retry_minutes: u32,
|
|
/// Days of RID holes re-fetched at startup (0 = off).
|
|
pub gap_fill_days_on_start: u32,
|
|
pub station_config_path: PathBuf,
|
|
/// Status JSON for /scraping/status when the website runs in a separate
|
|
/// process (`ping-collector serve`).
|
|
pub status_file: Option<PathBuf>,
|
|
/// FORECAST_IN_COLLECTOR: forecast and notify (off: collect only).
|
|
pub forecast_enabled: bool,
|
|
/// Exported bundles (`flood_<station>.json.gz`, src/ml/export.py).
|
|
pub models_dir: PathBuf,
|
|
/// Where to POST ntfy messages; `None` (NTFY_SERVER unset) = off.
|
|
pub ntfy_publish_url: Option<String>,
|
|
pub ntfy_token: Option<String>,
|
|
pub ntfy_topic_prefix: String,
|
|
/// Dashboard link in notifications.
|
|
pub public_url: String,
|
|
/// Report RID lagging this long while ThaiWater is current (0 = never).
|
|
pub rid_stale_alert_hours: f64,
|
|
/// FEED_NIGHT_UNTIL_HOUR: from 21:00 until this hour a quiet river's feed
|
|
/// is not reported stale (RID publishes nothing overnight); 0 = off.
|
|
pub feed_night_until_hour: u32,
|
|
/// Public ntfy URL (what subscribers use), shown by /api/notifications.
|
|
pub ntfy_server: Option<String>,
|
|
/// MATRIX_*: the notifications also go to this room (off when unset).
|
|
pub matrix: Option<MatrixConfig>,
|
|
pub web: WebConfig,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct MatrixConfig {
|
|
pub homeserver: String,
|
|
pub access_token: String,
|
|
pub room_id: String,
|
|
}
|
|
|
|
/// The website and read API (setting names as the retired Python app had them).
|
|
#[derive(Clone, Debug)]
|
|
pub struct WebConfig {
|
|
/// WEB_LISTEN: `run` also serves the website on this address
|
|
/// (e.g. 0.0.0.0:8000); unset = collection only.
|
|
pub listen: Option<String>,
|
|
/// Dashboard, robots/llms/sitemap and /static files.
|
|
pub static_dir: PathBuf,
|
|
/// X-API-Key for the station editing, /config and /scrape/trigger;
|
|
/// unset = those endpoints are disabled.
|
|
pub admin_api_key: Option<String>,
|
|
pub cors_allow_origins: Vec<String>,
|
|
pub umami_api_url: String,
|
|
/// Empty = no server-side analytics.
|
|
pub umami_website_id: String,
|
|
pub umami_track_api: bool,
|
|
pub hii_cache_ttl_s: u64,
|
|
pub latest_cache_ttl_s: u64,
|
|
pub health_cache_ttl_s: u64,
|
|
/// A stored forecast older than this is served marked `X-Data-Stale`.
|
|
pub forecast_max_age_s: u64,
|
|
pub thaiwater_api_key: Option<String>,
|
|
pub request_timeout_s: u64,
|
|
}
|
|
|
|
/// Variable lookup (the process environment, or a map in tests).
|
|
struct Vars<'a>(&'a dyn Fn(&str) -> Option<String>);
|
|
|
|
impl Vars<'_> {
|
|
fn var(&self, name: &str) -> Option<String> {
|
|
(self.0)(name)
|
|
.map(|v| v.trim().to_string())
|
|
.filter(|v| !v.is_empty())
|
|
}
|
|
|
|
/// `os.getenv(name, default)`: a variable set to "" stays "".
|
|
fn raw_var(&self, name: &str, default: &str) -> String {
|
|
(self.0)(name).map_or_else(|| default.to_string(), |v| v.trim().to_string())
|
|
}
|
|
|
|
fn flag(&self, name: &str, default: bool) -> bool {
|
|
self.var(name).map_or(default, |v| {
|
|
matches!(v.to_lowercase().as_str(), "1" | "true" | "yes")
|
|
})
|
|
}
|
|
|
|
fn number<T: std::str::FromStr>(&self, name: &str, default: T) -> Result<T>
|
|
where
|
|
T::Err: std::fmt::Display,
|
|
{
|
|
match self.var(name) {
|
|
None => Ok(default),
|
|
Some(v) => v.parse().map_err(|e| anyhow::anyhow!("{name}={v:?}: {e}")),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Percent-encode everything outside RFC 3986 "unreserved" (as Python's
|
|
/// `urllib.parse.quote(password, safe="")`).
|
|
fn encode_userinfo(s: &str) -> String {
|
|
s.bytes()
|
|
.map(|b| match b {
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
|
(b as char).to_string()
|
|
}
|
|
_ => format!("%{b:02X}"),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// SQLAlchemy URLs carry a driver suffix (`postgresql+psycopg2://`) that
|
|
/// sqlx does not understand.
|
|
pub fn normalize_database_url(url: &str) -> String {
|
|
match url.split_once("://") {
|
|
Some((scheme, rest))
|
|
if scheme.starts_with("postgresql") || scheme.starts_with("postgres") =>
|
|
{
|
|
format!("postgres://{rest}")
|
|
}
|
|
_ => url.to_string(),
|
|
}
|
|
}
|
|
|
|
impl Config {
|
|
pub fn from_env() -> Result<Self> {
|
|
Self::from_vars(&|name| env::var(name).ok())
|
|
}
|
|
|
|
pub fn from_vars(get: &dyn Fn(&str) -> Option<String>) -> Result<Self> {
|
|
let v = Vars(get);
|
|
let database_url = match v.var("POSTGRES_CONNECTION_STRING") {
|
|
Some(url) => normalize_database_url(&url),
|
|
None => {
|
|
let password = v
|
|
.var("POSTGRES_PASSWORD")
|
|
.context("POSTGRES_CONNECTION_STRING or POSTGRES_PASSWORD must be set")?;
|
|
format!(
|
|
"postgres://{}:{}@{}:{}/{}",
|
|
encode_userinfo(&v.var("POSTGRES_USER").unwrap_or_else(|| "postgres".into())),
|
|
encode_userinfo(&password),
|
|
v.var("POSTGRES_HOST").unwrap_or_else(|| "localhost".into()),
|
|
v.var("POSTGRES_PORT").unwrap_or_else(|| "5432".into()),
|
|
v.var("POSTGRES_DB")
|
|
.unwrap_or_else(|| "water_monitoring".into()),
|
|
)
|
|
}
|
|
};
|
|
if let Some(db_type) = v.var("DB_TYPE")
|
|
&& db_type != "postgresql"
|
|
{
|
|
bail!("ping-collector supports PostgreSQL only (DB_TYPE={db_type})");
|
|
}
|
|
let cfg = Config {
|
|
database_url,
|
|
rid_api_url: v.var("RID_API_URL").unwrap_or_else(|| RID_API_URL.into()),
|
|
hii_api_base: v.var("HII_API_BASE").unwrap_or_else(|| HII_API_BASE.into()),
|
|
dams_url: v.var("RID_DAMS_URL").unwrap_or_else(|| RID_DAMS_URL.into()),
|
|
openmeteo_url: v
|
|
.var("OPENMETEO_URL")
|
|
.unwrap_or_else(|| OPENMETEO_URL.into()),
|
|
hii_enabled: v.flag("ENABLE_HII_COLLECTION", true),
|
|
dams_enabled: v.flag("ENABLE_RESERVOIR_COLLECTION", true),
|
|
rain_enabled: v.flag("ENABLE_RAIN_COLLECTION", true),
|
|
hii_basin_code: v.number("HII_BASIN_CODE", 6)?,
|
|
poll_offset_minutes: v.number("RID_POLL_OFFSET_MINUTES", 5)?,
|
|
retry_minutes: v.number("RID_RETRY_MINUTES", 10)?,
|
|
gap_fill_days_on_start: v.number("GAP_FILL_DAYS_ON_START", 14)?,
|
|
station_config_path: v
|
|
.var("STATION_CONFIG_PATH")
|
|
.unwrap_or_else(|| "stations.json".into())
|
|
.into(),
|
|
status_file: v.var("SCRAPING_STATUS_FILE").map(PathBuf::from),
|
|
forecast_enabled: v.flag("FORECAST_IN_COLLECTOR", false),
|
|
models_dir: v
|
|
.var("MODELS_DIR")
|
|
.unwrap_or_else(|| "models".into())
|
|
.into(),
|
|
// as src/config.py: off unless NTFY_SERVER is set
|
|
ntfy_publish_url: v
|
|
.var("NTFY_SERVER")
|
|
.map(|server| v.var("NTFY_PUBLISH_URL").unwrap_or(server)),
|
|
ntfy_token: v.var("NTFY_TOKEN"),
|
|
ntfy_topic_prefix: v.var("NTFY_TOPIC_PREFIX").unwrap_or_else(|| "ping".into()),
|
|
public_url: v
|
|
.var("PUBLIC_URL")
|
|
.unwrap_or_else(|| "https://water.buildfor.life/".into()),
|
|
rid_stale_alert_hours: v.number("RID_STALE_ALERT_HOURS", 18.0)?,
|
|
feed_night_until_hour: match v.number("FEED_NIGHT_UNTIL_HOUR", 9u32)? {
|
|
h if h < 21 => h,
|
|
h => bail!("FEED_NIGHT_UNTIL_HOUR must be 0 (off) to 20, got {h}"),
|
|
},
|
|
ntfy_server: v.var("NTFY_SERVER"),
|
|
matrix: match (v.var("MATRIX_ACCESS_TOKEN"), v.var("MATRIX_ROOM_ID")) {
|
|
(Some(access_token), Some(room_id)) => Some(MatrixConfig {
|
|
homeserver: v
|
|
.var("MATRIX_HOMESERVER")
|
|
.unwrap_or_else(|| "https://matrix.org".into()),
|
|
access_token,
|
|
room_id,
|
|
}),
|
|
_ => None,
|
|
},
|
|
web: WebConfig {
|
|
listen: v.var("WEB_LISTEN"),
|
|
static_dir: v
|
|
.var("STATIC_DIR")
|
|
.unwrap_or_else(|| "src/static".into())
|
|
.into(),
|
|
admin_api_key: v.var("ADMIN_API_KEY"),
|
|
cors_allow_origins: v
|
|
.raw_var("CORS_ALLOW_ORIGINS", "")
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|o| !o.is_empty())
|
|
.map(String::from)
|
|
.collect(),
|
|
umami_api_url: v
|
|
.var("UMAMI_API_URL")
|
|
.unwrap_or_else(|| "https://stats.buildfor.life/api/send".into()),
|
|
umami_website_id: v
|
|
.raw_var("UMAMI_WEBSITE_ID", "00b2be73-8f5f-4400-9029-3be852eb08f7"),
|
|
umami_track_api: v.flag("UMAMI_TRACK_API", true),
|
|
hii_cache_ttl_s: v.number("HII_CACHE_TTL_SECONDS", 120)?,
|
|
latest_cache_ttl_s: v.number("LATEST_CACHE_TTL_SECONDS", 45)?,
|
|
health_cache_ttl_s: v.number("HEALTH_CACHE_TTL_SECONDS", 30)?,
|
|
forecast_max_age_s: v.number("FORECAST_MAX_AGE_SECONDS", 7200)?,
|
|
thaiwater_api_key: v.var("THAIWATER_API_KEY"),
|
|
request_timeout_s: v.number("REQUEST_TIMEOUT", 30)?,
|
|
},
|
|
};
|
|
if cfg.poll_offset_minutes >= 60 {
|
|
bail!("RID_POLL_OFFSET_MINUTES must be between 0 and 59");
|
|
}
|
|
if cfg.retry_minutes == 0 {
|
|
bail!("RID_RETRY_MINUTES must be positive");
|
|
}
|
|
Ok(cfg)
|
|
}
|
|
|
|
/// The URL with the password masked, for logs.
|
|
pub fn redacted_database_url(&self) -> String {
|
|
match (self.database_url.find("://"), self.database_url.rfind('@')) {
|
|
(Some(s), Some(at)) if at > s => {
|
|
let creds = &self.database_url[s + 3..at];
|
|
let user = creds.split(':').next().unwrap_or("");
|
|
format!(
|
|
"{}{user}:***{}",
|
|
&self.database_url[..s + 3],
|
|
&self.database_url[at..]
|
|
)
|
|
}
|
|
_ => self.database_url.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn sqlalchemy_urls_are_normalized() {
|
|
assert_eq!(
|
|
normalize_database_url("postgresql+psycopg2://u:p@h:5432/db"),
|
|
"postgres://u:p@h:5432/db"
|
|
);
|
|
assert_eq!(
|
|
normalize_database_url("postgresql://u@h/db"),
|
|
"postgres://u@h/db"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn passwords_are_percent_encoded() {
|
|
assert_eq!(encode_userinfo("p@ss:w/rd"), "p%40ss%3Aw%2Frd");
|
|
}
|
|
}
|