feat(daemon): add HTTP ingest listener with ChirpStack JSON parsing and InfluxDB writer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6d7409b156
commit
de6e92c6f5
119
LORAWEB_NEU/backend/daemon/src/http.rs
Normal file
119
LORAWEB_NEU/backend/daemon/src/http.rs
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
// backend/daemon/src/http.rs
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::post,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use crate::influx::InfluxWriter;
|
||||||
|
|
||||||
|
pub struct IngestState {
|
||||||
|
pub pool: SqlitePool,
|
||||||
|
pub influx: InfluxWriter,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct EventQuery {
|
||||||
|
pub event: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ChirpStackUplink {
|
||||||
|
time: Option<String>,
|
||||||
|
#[serde(rename = "deviceInfo")]
|
||||||
|
device_info: Option<DeviceInfo>,
|
||||||
|
dr: Option<i64>,
|
||||||
|
#[serde(rename = "fCnt")]
|
||||||
|
f_cnt: Option<i64>,
|
||||||
|
#[serde(rename = "fPort")]
|
||||||
|
f_port: Option<i64>,
|
||||||
|
#[serde(rename = "rxInfo")]
|
||||||
|
rx_info: Option<Vec<RxInfo>>,
|
||||||
|
object: Option<serde_json::Map<String, Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DeviceInfo {
|
||||||
|
#[serde(rename = "devEui")]
|
||||||
|
dev_eui: String,
|
||||||
|
#[serde(rename = "deviceName")]
|
||||||
|
device_name: Option<String>,
|
||||||
|
#[serde(rename = "applicationName")]
|
||||||
|
application_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RxInfo {
|
||||||
|
rssi: Option<f64>,
|
||||||
|
snr: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(state: Arc<IngestState>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/", post(handle_event))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_event(
|
||||||
|
State(state): State<Arc<IngestState>>,
|
||||||
|
Query(query): Query<EventQuery>,
|
||||||
|
Json(body): Json<Value>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let event = query.event.unwrap_or_default();
|
||||||
|
|
||||||
|
if event != "up" {
|
||||||
|
tracing::debug!(event = %event, "Non-uplink event received, ignoring");
|
||||||
|
return StatusCode::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uplink: ChirpStackUplink = match serde_json::from_value(body) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to parse uplink JSON: {}", e);
|
||||||
|
return StatusCode::BAD_REQUEST;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let device_info = match uplink.device_info {
|
||||||
|
Some(di) => di,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("Uplink missing deviceInfo");
|
||||||
|
return StatusCode::BAD_REQUEST;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let dev_eui = &device_info.dev_eui;
|
||||||
|
let device_name = device_info.device_name.as_deref().unwrap_or("");
|
||||||
|
let app_name = device_info.application_name.as_deref().unwrap_or("");
|
||||||
|
let timestamp = uplink.time.as_deref().unwrap_or("");
|
||||||
|
|
||||||
|
if let Err(e) = crate::db::register_topic(&state.pool, dev_eui).await {
|
||||||
|
tracing::warn!("Failed to register topic {}: {}", dev_eui, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !timestamp.is_empty() {
|
||||||
|
if let Err(e) = crate::db::update_last_seen(&state.pool, dev_eui, timestamp).await {
|
||||||
|
tracing::warn!("Failed to update last_seen for {}: {}", dev_eui, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(object) = &uplink.object {
|
||||||
|
let rx = uplink.rx_info.as_ref().and_then(|r| r.first());
|
||||||
|
let rssi = rx.and_then(|r| r.rssi);
|
||||||
|
let snr = rx.and_then(|r| r.snr);
|
||||||
|
|
||||||
|
if let Err(e) = state.influx.write_uplink(
|
||||||
|
dev_eui, device_name, app_name, timestamp,
|
||||||
|
object, rssi, snr, uplink.dr, uplink.f_cnt, uplink.f_port,
|
||||||
|
).await {
|
||||||
|
tracing::warn!("InfluxDB write failed for {}: {}", dev_eui, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
104
LORAWEB_NEU/backend/daemon/src/influx.rs
Normal file
104
LORAWEB_NEU/backend/daemon/src/influx.rs
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
// backend/daemon/src/influx.rs
|
||||||
|
use serde_json::Value;
|
||||||
|
use chrono::DateTime;
|
||||||
|
|
||||||
|
pub struct InfluxWriter {
|
||||||
|
client: reqwest::Client,
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
org: String,
|
||||||
|
bucket: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InfluxWriter {
|
||||||
|
pub fn new(url: &str, token: &str, org: &str, bucket: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
url: url.to_string(),
|
||||||
|
token: token.to_string(),
|
||||||
|
org: org.to_string(),
|
||||||
|
bucket: bucket.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write_uplink(
|
||||||
|
&self,
|
||||||
|
dev_eui: &str,
|
||||||
|
device_name: &str,
|
||||||
|
application_name: &str,
|
||||||
|
timestamp: &str,
|
||||||
|
object: &serde_json::Map<String, Value>,
|
||||||
|
rssi: Option<f64>,
|
||||||
|
snr: Option<f64>,
|
||||||
|
dr: Option<i64>,
|
||||||
|
f_cnt: Option<i64>,
|
||||||
|
f_port: Option<i64>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let ts_nanos = DateTime::parse_from_rfc3339(timestamp)?
|
||||||
|
.timestamp_nanos_opt()
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let tags = format!(
|
||||||
|
"sensor_data,dev_eui={},device_name={},application_name={}",
|
||||||
|
escape_tag(dev_eui),
|
||||||
|
escape_tag(device_name),
|
||||||
|
escape_tag(application_name),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut fields = Vec::new();
|
||||||
|
|
||||||
|
for (key, val) in object {
|
||||||
|
match val {
|
||||||
|
Value::Number(n) => {
|
||||||
|
if let Some(f) = n.as_f64() {
|
||||||
|
fields.push(format!("{}={}", escape_tag(key), f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Bool(b) => {
|
||||||
|
fields.push(format!("{}={}", escape_tag(key), b));
|
||||||
|
}
|
||||||
|
Value::String(s) => {
|
||||||
|
fields.push(format!("{}=\"{}\"", escape_tag(key), escape_field_value(s)));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(v) = rssi { fields.push(format!("rssi={}", v)); }
|
||||||
|
if let Some(v) = snr { fields.push(format!("snr={}", v)); }
|
||||||
|
if let Some(v) = dr { fields.push(format!("dr={}i", v)); }
|
||||||
|
if let Some(v) = f_cnt { fields.push(format!("f_cnt={}i", v)); }
|
||||||
|
if let Some(v) = f_port { fields.push(format!("f_port={}i", v)); }
|
||||||
|
|
||||||
|
if fields.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = format!("{} {} {}", tags, fields.join(","), ts_nanos);
|
||||||
|
|
||||||
|
let resp = self.client
|
||||||
|
.post(format!("{}/api/v2/write?org={}&bucket={}&precision=ns",
|
||||||
|
self.url, self.org, self.bucket))
|
||||||
|
.header("Authorization", format!("Token {}", self.token))
|
||||||
|
.header("Content-Type", "text/plain")
|
||||||
|
.body(line)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("InfluxDB write failed ({}): {}", status, body).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_tag(s: &str) -> String {
|
||||||
|
s.replace(',', "\\,").replace('=', "\\=").replace(' ', "\\ ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_field_value(s: &str) -> String {
|
||||||
|
s.replace('\\', "\\\\").replace('"', "\\\"")
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user