From b40299538a73d25d096d8f58f1468cd7f647a3f9 Mon Sep 17 00:00:00 2001 From: "Matthias P. Braendli" Date: Sun, 7 Jan 2024 17:43:05 +0100 Subject: Add first iteration of send page, and fake-radio --- Cargo.lock | 1 + Cargo.toml | 4 +++ README.md | 11 +++++++ src/bin/fake-radio.rs | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 35 ++++++++++++++++---- src/radio.rs | 18 +++++----- src/ui.rs | 58 +++++++++++++++++++++++++++++++- static/main.js | 28 ++++++++++++++++ templates/head.html | 2 +- templates/send.html | 12 ++++++- 10 files changed, 242 insertions(+), 18 deletions(-) create mode 100644 src/bin/fake-radio.rs create mode 100644 static/main.js diff --git a/Cargo.lock b/Cargo.lock index 6fd1f8d..bd58099 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -375,6 +375,7 @@ dependencies = [ "askama_axum", "async-stream 0.2.1", "axum 0.7.3", + "half", "ham-cats", "log", "rand", diff --git a/Cargo.toml b/Cargo.toml index 452c41e..048d13d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ tokio = { version = "1", features = ["full"] } tower-http = { version = "0.5.0", features = ["fs"] } ham-cats = { git = "https://gitlab.scd31.com/cats/ham-cats", commit = "d22f541c9a7e1c3a6c6e9449d87212b060f5edfb" } +half = { version = "2" } rf4463 = { git = "https://gitlab.scd31.com/stephen/rf4463-lib", commit = "79c8def87540f8ab2663bfa3c9fb13db344ef84e" } rppal = { version = "0.14", features = ["hal"] } tonic = { version = "0.10", features = ["tls", "tls-roots"] } @@ -27,3 +28,6 @@ rand = "0.8" # Websockets example in https://github.com/tokio-rs/axum/tree/main/examples/websockets # tokio-tungstenite = "0.21" + +[[bin]] +name = "fake-radio" diff --git a/README.md b/README.md index eddcfed..9414e9a 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,14 @@ This project contains a web user interface for controlling a Configuration read/write through UI is done. Pending: RF4463 integration, message decoding and presentation, igate integration, UI to send messages + +## Additional tools + +### fake-radio + +If no radio is available, frames can be sent and received over UDP for debugging. +cats-radio-node receives on 127.0.0.1:9073, and transmits to 127.0.0.1:9074. + +The `fake-radio` binary can be used to inject frames for that, and decodes those sent by cats-radio-node. + +Build with `cargo build --bin fake-radio` diff --git a/src/bin/fake-radio.rs b/src/bin/fake-radio.rs new file mode 100644 index 0000000..fd38149 --- /dev/null +++ b/src/bin/fake-radio.rs @@ -0,0 +1,91 @@ +use anyhow::{anyhow, Context}; +use tokio::net::UdpSocket; +use ham_cats::{ + buffer::Buffer, + whisker::{Arbitrary, Identification, Gps}, +}; + +const MAX_PACKET_LEN : usize = 8191; + +#[tokio::main] +async fn main() -> std::io::Result<()> { + eprintln!("Sending example packet"); + + let packet = build_example_packet().await.unwrap(); + let sock = UdpSocket::bind("127.0.0.1:9074").await.unwrap(); + sock.send_to(&packet, "127.0.0.1:9073").await.unwrap(); + + eprintln!("Receiving packets. Ctrl-C to stop"); + + let mut data = [0; MAX_PACKET_LEN]; + while let Ok((len, _addr)) = sock.recv_from(&mut data).await { + let mut buf = [0; MAX_PACKET_LEN]; + match ham_cats::packet::Packet::fully_decode(&data[2..len], &mut buf) { + Ok(packet) => { + if let Some(ident) = packet.identification() { + eprintln!(" Ident {}-{}", ident.callsign, ident.ssid); + } + + if let Some(gps) = packet.gps() { + eprintln!(" GPS {} {}", gps.latitude(), gps.longitude()); + } + + let mut comment = [0; 1024]; + if let Ok(c) = packet.comment(&mut comment) { + eprintln!(" Comment {}", c); + } + + eprintln!(" With {} Arbitrary whiskers", packet.arbitrary_iter().count()); + }, + Err(e) => { + eprintln!(" Cannot decode packet of length {} {:?}", len, e); + } + } + } + + Ok(()) +} + +async fn build_example_packet() -> anyhow::Result> { + let callsign = "EX4MPLE"; + let ssid = 0; + let icon = 0; + + let mut buf = [0; MAX_PACKET_LEN]; + let mut pkt = ham_cats::packet::Packet::new(&mut buf); + pkt.add_identification( + Identification::new(&callsign, ssid, icon) + .context("Invalid identification")?, + ) + .map_err(|e| anyhow!("Could not add identification to packet: {e}"))?; + + pkt.add_comment("Debugging packet") + .map_err(|e| anyhow!("Could not add comment to packet: {e}"))?; + + let latitude = 46.5; + let longitude = -6.2; + let altitude = 200u8.into(); + let max_error = 1; + let heading = 120.0; + let speed = 1u8.into(); + + pkt.add_gps(Gps::new( + latitude, + longitude, + altitude, + max_error, + heading, + speed) + ) + .map_err(|e| anyhow!("Could not add GPS to packet: {e}"))?; + + pkt.add_arbitrary(Arbitrary::new(&[0xA5; 8]).unwrap()) + .map_err(|e| anyhow!("Could not add arbitrary to packet: {e}"))?; + + let mut buf2 = [0; MAX_PACKET_LEN]; + let mut data = Buffer::new_empty(&mut buf2); + pkt.fully_encode(&mut data) + .map_err(|e| anyhow!("Could not encode packet: {e}"))?; + + Ok(data.to_vec()) +} diff --git a/src/main.rs b/src/main.rs index 9a4ae2b..0a427e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use anyhow::Context; use log::{debug, info, warn, error}; use std::sync::{Arc, Mutex}; +use tokio::net::UdpSocket; use tokio::sync::mpsc; use sqlx::{Connection, SqliteConnection}; use radio::{RadioManager, MAX_PACKET_LEN}; @@ -11,7 +12,8 @@ mod ui; struct AppState { conf : config::Config, - db : Mutex + db : Mutex, + transmit_queue : mpsc::Sender>, } type SharedState = Arc>; @@ -28,17 +30,37 @@ async fn main() -> std::io::Result<()> { let conf = config::Config::load().expect("Could not load config"); + let (radio_rx_queue, mut packet_receive) = mpsc::channel(16); + let (packet_send, mut radio_tx_queue) = mpsc::channel::>(16); + if conf.freq == 0 { - warn!("Frequency {0} is zero, disabling radio", conf.freq); + warn!("Frequency {0} is zero, disabling radio. Fake receiver udp 127.0.0.1:9073, sending to 9074", conf.freq); + let sock_r = Arc::new(UdpSocket::bind("127.0.0.1:9073").await?); + let sock_s = sock_r.clone(); + + // These two tasks behave like the radio, but use UDP instead of the RF channel. + tokio::spawn(async move { + let mut buf = [0; 1024]; + while let Ok((len, addr)) = sock_r.recv_from(&mut buf).await { + println!("{:?} bytes received from {:?}", len, addr); + let packet = buf[..len].to_vec(); + let rssi = 0f64; + radio_rx_queue.send((packet, rssi)).await.expect("Inject frame"); + } + }); + + tokio::spawn(async move { + while let Some(p) = radio_tx_queue.recv().await { + sock_s.send_to(&p, "127.0.0.1:9074").await.unwrap(); + } + }); } else if !(430000..=436380).contains(&conf.freq) { error!("Frequency {} kHz out of range (430MHz - 436.375MHz), skipping radio setup", conf.freq); } else { info!("Setting up radio"); - let (packet_tx, mut packet_receive) = mpsc::channel(16); - let (packet_send, packet_rx) = mpsc::channel(16); - let mut radio = RadioManager::new(packet_tx, packet_rx).expect("Could not initialize radio"); + let mut radio = RadioManager::new(radio_rx_queue, radio_tx_queue).expect("Could not initialize radio"); let channel = ((conf.freq - 430000) / 25) as u8; radio.set_channel(channel); @@ -82,7 +104,8 @@ async fn main() -> std::io::Result<()> { let shared_state = Arc::new(Mutex::new(AppState { conf, - db: Mutex::new(conn) + db: Mutex::new(conn), + transmit_queue: packet_send.clone(), })); let port = 3000; diff --git a/src/radio.rs b/src/radio.rs index 77f7c0f..239685f 100644 --- a/src/radio.rs +++ b/src/radio.rs @@ -20,14 +20,14 @@ pub const MAX_PACKET_LEN: usize = 8191; pub struct RadioManager { radio: Rf4463, - tx: Sender<(Vec, f64)>, - rx: Receiver>, + receive_queue: Sender<(Vec, f64)>, + transmit_queue: Receiver>, rx_buf: [u8; MAX_PACKET_LEN], temperature: Arc>, } impl RadioManager { - pub fn new(tx: Sender<(Vec, f64)>, rx: Receiver>) -> anyhow::Result { + pub fn new(receive_queue: Sender<(Vec, f64)>, transmit_queue: Receiver>) -> anyhow::Result { let spi = Spi::new(Bus::Spi0, SlaveSelect::Ss0, 1_000_000, Mode::Mode0)?; let gpio = Gpio::new()?; let sdn = gpio.get(22)?.into_output(); @@ -44,8 +44,8 @@ impl RadioManager { Ok(Self { radio, - tx, - rx, + receive_queue, + transmit_queue, rx_buf, temperature, }) @@ -65,13 +65,13 @@ impl RadioManager { *self.temperature.lock().await = self.radio.get_temp()?; - match self.rx.try_recv() { + match self.transmit_queue.try_recv() { Ok(pkt) => { self.tx(&pkt).await?; } Err(TryRecvError::Empty) => {} Err(TryRecvError::Disconnected) => { - bail!("RX channel disconnected") + bail!("TX channel disconnected") } } @@ -101,11 +101,11 @@ impl RadioManager { .start_rx(None, false) .map_err(|e| anyhow!("{e}"))?; - self.tx + self.receive_queue .send((data.data().to_vec(), data.rssi())) .await .ok() - .context("TX channel died")?; + .context("RX channel died")?; } Ok(()) diff --git a/src/ui.rs b/src/ui.rs index 5a13bc0..aedf56c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,9 +1,12 @@ +use anyhow::{anyhow, Context}; use std::str::FromStr; +use axum::Json; +use log::info; use serde::Deserialize; use askama::Template; use axum::{ extract::State, - routing::get, + routing::{get, post}, Router, response::Html, Form, @@ -11,6 +14,11 @@ use axum::{ }; use tower_http::services::ServeDir; +use ham_cats::{ + buffer::Buffer, + whisker::Identification, +}; + use crate::config; use crate::SharedState; @@ -19,6 +27,7 @@ pub async fn serve(port: u16, shared_state: SharedState) { .route("/", get(dashboard)) .route("/incoming", get(incoming)) .route("/send", get(send)) + .route("/api/send_packet", post(post_packet)) .route("/settings", get(show_settings).post(post_settings)) .nest_service("/static", ServeDir::new("static")) /* For an example for timeouts and tracing, have a look at the git history */ @@ -84,6 +93,53 @@ async fn send(State(state): State) -> SendTemplate<'static> { } } +#[derive(Deserialize, Debug)] +struct ApiSendPacket { + comment : Option, +} + +fn build_packet(config: config::Config, comment: Option) -> anyhow::Result> { + let mut buf = [0; crate::radio::MAX_PACKET_LEN]; + let mut pkt = ham_cats::packet::Packet::new(&mut buf); + pkt.add_identification( + Identification::new(&config.callsign, config.ssid, config.icon) + .context("Invalid identification")?, + ) + .map_err(|e| anyhow!("Could not add identification to packet: {e}"))?; + + if let Some(c) = comment { + pkt.add_comment(&c) + .map_err(|e| anyhow!("Could not add comment to packet: {e}"))?; + } + + let mut buf2 = [0; crate::radio::MAX_PACKET_LEN]; + let mut data = Buffer::new_empty(&mut buf2); + pkt.fully_encode(&mut data) + .map_err(|e| anyhow!("Could not encode packet: {e}"))?; + + Ok(data.to_vec()) +} + +async fn post_packet(State(state): State, Json(payload): Json) -> StatusCode { + let (config, transmit_queue) = { + let s = state.lock().unwrap(); + (s.conf.clone(), s.transmit_queue.clone()) + }; + + info!("send_packet {:?}", payload); + + match build_packet(config, payload.comment) { + Ok(p) => { + info!("Built packet of {} bytes", p.len()); + match transmit_queue.send(p).await { + Ok(()) => StatusCode::OK, + Err(_) => StatusCode::BAD_REQUEST, + } + }, + Err(_) =>StatusCode::INTERNAL_SERVER_ERROR, + } +} + #[derive(Template)] #[template(path = "settings.html")] struct SettingsTemplate<'a> { diff --git a/static/main.js b/static/main.js new file mode 100644 index 0000000..8687dc1 --- /dev/null +++ b/static/main.js @@ -0,0 +1,28 @@ +async function btn_send_packet() { + + let data = { + 'comment': null, + }; + + if (document.getElementById('with_comment').checked) { + data.comment = document.getElementById('whisker_comment').value; + } + + await post('/api/send_packet', data); +} + +async function post(url, data) { + const params = { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data), + }; + + let response = await fetch(url, params); + if (!response.ok) { + const text = await response.text(); + alert(`Error Sending: ${response.statusText} ${text}`); + } +} diff --git a/templates/head.html b/templates/head.html index 67eb7bd..10d2b13 100644 --- a/templates/head.html +++ b/templates/head.html @@ -6,7 +6,7 @@ - +
diff --git a/templates/send.html b/templates/send.html index b47f6b0..e287b18 100644 --- a/templates/send.html +++ b/templates/send.html @@ -1,6 +1,16 @@ {% include "head.html" %}
- Send! +

Send a frame

+ +
+

Comment Whisker

+
+ + +
+ + +
{% include "foot.html" %} {# vi:set et sw=2 ts=2: #} -- cgit v1.2.3