Compare commits

...

9 Commits
main ... chat

Author SHA1 Message Date
Arkitu
bfdf18c1da working utf8 messages 2025-05-06 22:22:00 +02:00
Arkitu
21e6a50e36 save 2025-05-05 22:08:48 +02:00
Arkitu
4521697f48 multiple chat clients + handle showing long discussion to new client 2025-05-04 13:37:31 +02:00
Arkitu
f523ec812c chat works 2025-05-04 11:59:10 +02:00
Arkitu
82c86bba16 working send + view messages 2025-05-03 23:11:55 +02:00
Arkitu
c629ae5ce3 save 2025-05-02 16:18:52 +02:00
Arkitu
1c0108cefa messages in mutex 2025-05-02 14:08:14 +02:00
Arkitu
e6bc8561d4 save 2025-05-02 00:37:11 +02:00
Arkitu
b90c978a38 save 2025-05-01 21:30:55 +02:00
9 changed files with 397 additions and 65 deletions

15
Cargo.lock generated
View File

@ -1063,6 +1063,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "percent-encoding"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "petgraph"
version = "0.7.1"
@ -1103,14 +1109,17 @@ dependencies = [
"embassy-executor",
"embassy-net",
"embassy-rp",
"embassy-sync",
"embassy-time",
"embassy-usb-logger",
"embedded-io-async",
"heapless",
"log",
"panic-probe",
"percent-encoding",
"portable-atomic",
"rand_core",
"ringbuffer",
"serde",
"serde-json-core",
"static_cell",
@ -1304,6 +1313,12 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "ringbuffer"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3df6368f71f205ff9c33c076d170dd56ebf68e8161c733c0caa07a7a5509ed53"
[[package]]
name = "rp-pac"
version = "7.0.0"

View File

@ -36,6 +36,7 @@ embassy-net = { git = "https://github.com/embassy-rs/embassy", features = [
"udp",
"dhcpv4",
] }
embassy-sync = { git = "https://github.com/embassy-rs/embassy" }
cyw43-pio = { git = "https://github.com/embassy-rs/embassy" }
cyw43 = { git = "https://github.com/embassy-rs/embassy" }
@ -56,4 +57,6 @@ serde = { version = "*", optional = true, default-features = false, features = [
"derive",
] }
dhcparse = { version = "*", default-features = false, optional = true }
dnsparse = { version = "*", optional = true }
dnsparse = { version = "*", optional = true }
ringbuffer = { version = "*", default-features = false }
percent-encoding = { version = "*", default-features = false }

41
src/apps/chat.html Normal file
View File

@ -0,0 +1,41 @@
<!doctype html>
<head>
<script src="./htmx.js"></script>
<style type="text/css">
body {
/* #grid {
.cell {
border: 1px dotted black;
padding: 33%;
}
display: grid;
border: 1px solid black;
grid-template-rows: 1fr 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr;
} */
}
</style>
</head>
<html>
<body>
<h1>Chat</h1>
<form id="login-page">
Enter your name :
<input
name="username"
type="text"
autocomplete="username"
minlength="3"
maxlength="16"
required
/>
<button
hx-post="/chat/connect"
hx-target="#login-page"
hx-swap="outerHTML"
>
Connect
</button>
</form>
</body>
</html>

276
src/apps/chat.rs Normal file
View File

@ -0,0 +1,276 @@
use core::fmt::Write;
use dhcparse::dhcpv4::MAX_MESSAGE_SIZE;
use embassy_sync::{blocking_mutex::raw::ThreadModeRawMutex, mutex::Mutex};
use heapless::{String, Vec};
use log::{info, warn};
use percent_encoding::percent_decode_str;
use pico_website::unwrap;
use ringbuffer::{ConstGenericRingBuffer, RingBuffer};
use crate::socket::{HttpRequestType, HttpResCode};
use super::App;
const MEMORY_SIZE: usize = 16;
const USERNAME_MIN_SIZE: usize = 3;
const USERNAME_SIZE: usize = 16;
const MSG_SIZE: usize = 128;
static MESSAGES: Mutex<ThreadModeRawMutex, Messages> = Mutex::new(Messages::new());
pub struct ChatApp {
res_buf: Vec<u8, 1100>,
}
impl ChatApp {
pub fn new() -> Self {
Self {
res_buf: Vec::new(),
}
}
}
impl App for ChatApp {
fn socket_name(&self) -> &'static str {
"chat"
}
async fn handle_request<'a>(
&'a mut self,
path: &str,
req_type: HttpRequestType,
content: &str,
) -> (HttpResCode, &'static str, &'a [u8]) {
match (req_type, path) {
(HttpRequestType::Get, "/" | "/index" | "/index.html" | "/chat" | "/chat.html") => {
(HttpResCode::Ok, "html", include_bytes!("./chat.html"))
}
(_, path) => {
let (path, args) = path.split_once('?').unwrap_or((path, ""));
let mut load = None;
let mut username = None;
let mut msg_content = None;
let mut poll = false;
for arg in args.split('&').chain(content.split('&')) {
match arg.split_once('=') {
Some(("load", n)) => {
let n: u16 = match n.parse() {
Ok(v) => v,
Err(_) => return (HttpResCode::BadRequest, "", &[]),
};
if n > 0 {
load = Some(n);
}
}
Some(("username", u)) => {
let mut name = String::<USERNAME_SIZE>::new();
for c in percent_decode_str(u) {
if let Err(_) = name.push(c as char) {
return (HttpResCode::BadRequest, "", &[]);
}
}
if u.len() < USERNAME_MIN_SIZE {
return (HttpResCode::BadRequest, "", &[]);
}
username = Some(name);
}
Some(("msg", m)) => {
let mut msg = Vec::<u8, MSG_SIZE>::new();
let mut i = 0;
while i < m.len() {
let c = if m.as_bytes()[i] == b'%' {
let c = match m
.get(i + 1..=i + 2)
.map(|s| u8::from_str_radix(s, 16))
{
Some(Ok(c)) => c,
_ => {
warn!("Invalid percent encoding of msg argument");
return (HttpResCode::BadRequest, "", &[]);
}
};
i += 2;
c
} else {
m.as_bytes()[i]
};
if let Err(_) = msg.push(c) {
return (HttpResCode::BadRequest, "", &[]);
}
i += 1;
}
msg_content = Some(match String::from_utf8(msg) {
Ok(msg) => msg,
Err(_) => {
warn!("Invalid utf8 msg argument");
return (HttpResCode::BadRequest, "", &[]);
}
});
}
Some(("poll", "true")) => poll = true,
_ => {}
}
}
info!(
"load:{:?} | username:{:?} | msg:{:?}",
load, username, msg_content
);
if path == "/chat/connect" && username.is_some() {
self.res_buf.clear();
unwrap(write!(
&mut self.res_buf,
"<input id=\"username\" style=\"display: none;\" name=\"username\" value=\"{}\">
<div id=\"messages\" >\
<div \
class=\"message\" \
hx-get=\"/chat/message/0?load={}\" \
hx-target=\"this\" \
hx-swap=\"outerHTML\" \
hx-trigger=\"load\" \
></div>\
</div>\
<form id=\"send-message\" \
hx-post=\"/chat/send\" \
hx-include=\"#username\" \
hx-target=\"this\" \
hx-swap=\"innerHTML\"\
>\
<input \
id=\"msg\" \
name=\"msg\" \
maxlength=\"{}\"\
>\
<button>Send</button>
</form>",
username.unwrap(),
MEMORY_SIZE,
MAX_MESSAGE_SIZE
))
.await;
return (HttpResCode::Ok, "html", &self.res_buf);
} else if path == "/chat/send" && username.is_some() && msg_content.is_some() {
let mut msgs = MESSAGES.lock().await;
msgs.push(Message {
author: username.unwrap(),
content: msg_content.unwrap(),
});
self.res_buf.clear();
unwrap(write!(
&mut self.res_buf,
"<input \
id=\"msg\" \
name=\"msg\" \
maxlength=\"{}\"\
>\
<button>Send</button>",
MAX_MESSAGE_SIZE
))
.await;
return (HttpResCode::Ok, "html", &self.res_buf);
} else if path.starts_with("/chat/message/") && path.len() > 14 {
let msg_id: u16 = match path[14..].parse() {
Ok(n) => n,
Err(_) => return (HttpResCode::BadRequest, "", &[]),
};
let msgs = MESSAGES.lock().await;
if msg_id > msgs.next {
return (HttpResCode::BadRequest, "", &[]);
}
self.res_buf.clear();
unwrap(write!(&mut self.res_buf, "<div class=\"message\"")).await;
if msg_id == msgs.next {
if poll {
return (HttpResCode::NoContent, "", &[]);
}
unwrap(write!(
&mut self.res_buf,
" style=\"display: none;\" \
hx-get=\"/chat/message/{}?load={}&poll=true\" \
hx-target=\"this\" \
hx-swap=\"outerHTML\" \
hx-trigger=\"every 100ms\"",
msg_id,
load.unwrap_or(0)
))
.await;
} else {
if let Some(n) = load {
unwrap(write!(
&mut self.res_buf,
" hx-get=\"/chat/message/{}?load={}\" \
hx-target=\"this\" \
hx-swap=\"afterend\" \
hx-trigger=\"load\"",
msg_id + 1,
n - 1,
))
.await;
}
match msgs.get_abs(msg_id) {
Some(msg) => {
unwrap(write!(
&mut self.res_buf,
"><b>{}</b>: {}</div>",
msg.author, msg.content
))
.await
}
None => {
if load.is_some() {
if (msg_id as isize)
== (msgs.next as isize - MEMORY_SIZE as isize - 1)
{
unwrap(write!(
&mut self.res_buf,
"><em>Older messages forgotten</em></div>"
))
.await;
} else {
unwrap(write!(
&mut self.res_buf,
" style=\"display: none;\"></div>"
))
.await;
}
} else {
return (HttpResCode::NoContent, "", &[]);
}
}
};
};
return (HttpResCode::Ok, "html", &self.res_buf);
} else {
(HttpResCode::NotFound, "", &[])
}
}
}
}
}
struct Message {
author: String<USERNAME_SIZE>,
content: String<MSG_SIZE>,
}
struct Messages {
inner: ConstGenericRingBuffer<Message, MEMORY_SIZE>,
next: u16,
}
impl Messages {
const fn new() -> Self {
Self {
inner: ConstGenericRingBuffer::new(),
next: 0,
}
}
fn get_abs(&self, id: u16) -> Option<&Message> {
if (id as isize) < (self.next as isize - MEMORY_SIZE as isize) {
return None;
}
self.inner.get_signed((id as isize) - (self.next as isize))
}
fn push(&mut self, msg: Message) {
info!("{}: {}", msg.author, msg.content);
self.inner.push(msg);
self.next += 1;
}
}

View File

@ -1,4 +1,4 @@
use crate::socket::HttpResCode;
use crate::socket::{HttpRequestType, HttpResCode};
use super::App;
@ -7,7 +7,12 @@ impl App for IndexApp {
fn socket_name(&self) -> &'static str {
"index"
}
async fn handle_request<'a>(&'a mut self, path: &str) -> (HttpResCode, &'static str, &'a [u8]) {
async fn handle_request<'a>(
&'a mut self,
path: &str,
_req_type: HttpRequestType,
_content: &str,
) -> (HttpResCode, &'static str, &'a [u8]) {
match path {
"/" | "/index" | "/index.html" => {
(HttpResCode::Ok, "html", include_bytes!("./index.html"))

View File

@ -1,9 +1,15 @@
use crate::socket::HttpResCode;
use crate::socket::{HttpRequestType, HttpResCode};
pub mod chat;
pub mod index;
pub mod ttt;
pub trait App {
fn socket_name(&self) -> &'static str;
async fn handle_request<'a>(&'a mut self, path: &str) -> (HttpResCode, &'static str, &'a [u8]);
async fn handle_request<'a>(
&'a mut self,
path: &str,
req_type: HttpRequestType,
content: &str,
) -> (HttpResCode, &'static str, &'a [u8]);
}

View File

@ -5,7 +5,7 @@ use heapless::Vec;
use pico_website::unwrap;
use portable_atomic::{AtomicBool, AtomicU32};
use crate::socket::HttpResCode;
use crate::socket::{HttpRequestType, HttpResCode};
use super::App;
@ -14,7 +14,7 @@ static TURN: AtomicBool = AtomicBool::new(false);
static BOARD: AtomicU32 = AtomicU32::new(0);
pub struct TttApp {
res_buf: Vec<u8, 4096>,
res_buf: Vec<u8, 2048>,
/// State of the board last time it has been sent
last_board: u32,
team: Team,
@ -154,7 +154,12 @@ impl App for TttApp {
fn socket_name(&self) -> &'static str {
self.team.name()
}
async fn handle_request<'a>(&'a mut self, path: &str) -> (HttpResCode, &'static str, &'a [u8]) {
async fn handle_request<'a>(
&'a mut self,
path: &str,
_req_type: HttpRequestType,
_content: &str,
) -> (HttpResCode, &'static str, &'a [u8]) {
match path {
"/" | "/index" | "/index.html" | "/ttt" | "/ttt.html" => {
(HttpResCode::Ok, "html", include_bytes!("./ttt.html"))

View File

@ -115,7 +115,7 @@ async fn main(spawner: Spawner) {
let seed = rng.next_u64();
// Init network stack
static RESOURCES: StaticCell<StackResources<10>> = StaticCell::new();
static RESOURCES: StaticCell<StackResources<20>> = StaticCell::new();
let (stack, runner) = embassy_net::new(
net_device,
config,
@ -170,18 +170,11 @@ async fn main(spawner: Spawner) {
unwrap(spawner.spawn(dns::dns_server(stack))).await;
unwrap(spawner.spawn(socket::index_listen_task(stack, 80))).await;
unwrap(spawner.spawn(socket::ttt_listen_task(
stack,
apps::ttt::TttApp::new(apps::ttt::Team::Zero),
8080,
)))
.await;
unwrap(spawner.spawn(socket::ttt_listen_task(
stack,
apps::ttt::TttApp::new(apps::ttt::Team::One),
8081,
)))
.await;
unwrap(spawner.spawn(socket::ttt_listen_task(stack, apps::ttt::Team::Zero, 8080))).await;
unwrap(spawner.spawn(socket::ttt_listen_task(stack, apps::ttt::Team::One, 8081))).await;
for _ in 0..4 {
unwrap(spawner.spawn(socket::chat_listen_task(stack, 8082))).await;
}
}
#[cfg(feature = "wifi-connect")]

View File

@ -6,11 +6,11 @@ use embedded_io_async::Write as _;
use heapless::Vec;
use log::{info, warn};
use crate::apps::{App, index::IndexApp, ttt};
use crate::apps::{App, chat, index::IndexApp, ttt};
#[embassy_executor::task(pool_size = 2)]
pub async fn ttt_listen_task(stack: embassy_net::Stack<'static>, app: ttt::TttApp, port: u16) {
listen_task(stack, app, port).await
pub async fn ttt_listen_task(stack: embassy_net::Stack<'static>, team: ttt::Team, port: u16) {
listen_task(stack, ttt::TttApp::new(team), port).await
}
#[embassy_executor::task(pool_size = 2)]
@ -18,15 +18,20 @@ pub async fn index_listen_task(stack: embassy_net::Stack<'static>, port: u16) {
listen_task(stack, IndexApp, port).await
}
#[embassy_executor::task(pool_size = 4)]
pub async fn chat_listen_task(stack: embassy_net::Stack<'static>, port: u16) {
listen_task(stack, chat::ChatApp::new(), port).await
}
pub async fn listen_task(stack: embassy_net::Stack<'static>, mut app: impl App, port: u16) {
// loop {
// info!("team:{:?}", team);
// Timer::after_millis(0).await;
// }
let mut rx_buffer = [0; 4096];
let mut tx_buffer = [0; 4096];
let mut buf = [0; 4096];
let mut res_head_buf = Vec::<u8, 4096>::new();
let mut rx_buffer = [0; 1024];
let mut tx_buffer = [0; 2048];
let mut buf = [0; 1024];
let mut res_head_buf = Vec::<u8, 128>::new();
loop {
Timer::after_secs(0).await;
@ -59,41 +64,31 @@ pub async fn listen_task(stack: embassy_net::Stack<'static>, mut app: impl App,
}
};
let mut headers: &[u8] = &buf[..n];
let mut _content: &[u8] = &[];
for i in 0..(n - 1) {
if &buf[i..i + 1] == b"\r\n" {
headers = &buf[0..i];
if i + 2 < n {
_content = &buf[i + 2..n];
}
let (headers, content) = match from_utf8(&buf[..n]) {
Ok(b) => match b.split_once("\r\n\r\n") {
Some(t) => t,
None => (b, ""),
},
Err(_) => {
warn!("Non utf8 http request");
break;
}
}
};
let mut headers = headers.split(|x| *x == b'\n');
let (request_type, path) = match headers.next() {
// let mut headers = headers.split(|x| *x == b'\n');
let (request_type, path) = match headers.lines().next() {
None => {
warn!("Empty request");
break;
}
Some(l1) => {
let mut l1 = l1.split(|x| *x == b' ');
let mut l1 = l1.split(' ');
(
match l1.next() {
Some(b"GET") => HttpRequestType::Get,
Some(b"POST") => HttpRequestType::Post,
Some("GET") => HttpRequestType::Get,
Some("POST") => HttpRequestType::Post,
Some(t) => {
match from_utf8(t) {
Ok(t) => {
warn!("Unknown request type : {}", t);
}
Err(e) => {
warn!(
"Error while parsing request type : {}\nRaw type : {:?}",
e, t
);
}
}
warn!("Unknown request type : {}", t);
break;
}
None => {
@ -102,16 +97,7 @@ pub async fn listen_task(stack: embassy_net::Stack<'static>, mut app: impl App,
}
},
match l1.next() {
Some(path) => match from_utf8(path) {
Ok(p) => p,
Err(e) => {
warn!(
"Error while parsing requested path : {}\nRaw path : {:?}",
e, path
);
break;
}
},
Some(path) => path,
None => {
warn!("No path");
break;
@ -138,7 +124,7 @@ pub async fn listen_task(stack: embassy_net::Stack<'static>, mut app: impl App,
#[cfg(not(debug_assertions))]
include_bytes!("../static/htmx.min.js"),
),
p => app.handle_request(p).await,
p => app.handle_request(p, request_type, content).await,
};
res_head_buf.clear();
@ -174,7 +160,7 @@ pub async fn listen_task(stack: embassy_net::Stack<'static>, mut app: impl App,
}
#[derive(Clone, Copy, Debug)]
enum HttpRequestType {
pub enum HttpRequestType {
Get,
Post,
}
@ -191,6 +177,7 @@ impl Into<&str> for HttpRequestType {
pub enum HttpResCode {
Ok,
NoContent,
BadRequest,
NotFound,
Forbidden,
}
@ -199,6 +186,7 @@ impl Into<&str> for HttpResCode {
match self {
HttpResCode::Ok => "HTTP/1.1 200 OK",
HttpResCode::NoContent => "HTTP/1.1 204 NO CONTENT",
HttpResCode::BadRequest => "HTTP/1.1 400 BAD REQUEST",
HttpResCode::NotFound => "HTTP/1.1 404 NOT FOUND",
HttpResCode::Forbidden => "HTTP/1.1 403 FORBIDDEN",
}