diff options
-rw-r--r-- | Cargo.lock | 1 | ||||
-rw-r--r-- | Cargo.toml | 4 | ||||
-rw-r--r-- | README.md | 11 | ||||
-rw-r--r-- | src/bin/fake-radio.rs | 91 | ||||
-rw-r--r-- | src/main.rs | 35 | ||||
-rw-r--r-- | src/radio.rs | 18 | ||||
-rw-r--r-- | src/ui.rs | 58 | ||||
-rw-r--r-- | static/main.js | 28 | ||||
-rw-r--r-- | templates/head.html | 2 | ||||
-rw-r--r-- | templates/send.html | 12 |
10 files changed, 242 insertions, 18 deletions
@@ -375,6 +375,7 @@ dependencies = [ "askama_axum", "async-stream 0.2.1", "axum 0.7.3", + "half", "ham-cats", "log", "rand", @@ -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" @@ -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<Vec<u8>> { + 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<SqliteConnection> + db : Mutex<SqliteConnection>, + transmit_queue : mpsc::Sender<Vec<u8>>, } type SharedState = Arc<Mutex<AppState>>; @@ -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::<Vec<u8>>(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<Spi, OutputPin, OutputPin, Delay>, - tx: Sender<(Vec<u8>, f64)>, - rx: Receiver<Vec<u8>>, + receive_queue: Sender<(Vec<u8>, f64)>, + transmit_queue: Receiver<Vec<u8>>, rx_buf: [u8; MAX_PACKET_LEN], temperature: Arc<Mutex<f32>>, } impl RadioManager { - pub fn new(tx: Sender<(Vec<u8>, f64)>, rx: Receiver<Vec<u8>>) -> anyhow::Result<Self> { + pub fn new(receive_queue: Sender<(Vec<u8>, f64)>, transmit_queue: Receiver<Vec<u8>>) -> anyhow::Result<Self> { 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(()) @@ -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<SharedState>) -> SendTemplate<'static> { } } +#[derive(Deserialize, Debug)] +struct ApiSendPacket { + comment : Option<String>, +} + +fn build_packet(config: config::Config, comment: Option<String>) -> anyhow::Result<Vec<u8>> { + 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<SharedState>, Json(payload): Json<ApiSendPacket>) -> 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 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="/static/style.css" type="text/css"> <link rel="stylesheet" href="/static/font-awesome/css/font-awesome.min.css"> - <!-- <script src="/static/index.js" defer></script> --> + <script src="/static/main.js" defer></script> </head> <body> <div class="flex"> 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" %} <div class=""> - Send! + <h1>Send a frame</h1> + + <div> + <h2>Comment Whisker</h2> + <div> + <input type="checkbox" id="with_comment" value="Include Comment" checked> + <input class="textinput" type="text" id="whisker_comment" placeholder="Type comment here"> + </div> + + <button class="btn" type="button" onclick="btn_send_packet()">Send</button> + </div> </div> {% include "foot.html" %} {# vi:set et sw=2 ts=2: #} |