From 16b1509a57cde8e9b9207373b8c79dec976a6b4e Mon Sep 17 00:00:00 2001 From: Alix ANNERAUD Date: Sun, 10 May 2026 15:02:58 +0200 Subject: [PATCH 01/15] remove unused file_system.rs from espressif driver --- drivers/espressif/src/shared/f_ile_system.rs | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 drivers/espressif/src/shared/f_ile_system.rs diff --git a/drivers/espressif/src/shared/f_ile_system.rs b/drivers/espressif/src/shared/f_ile_system.rs deleted file mode 100644 index e69de29b..00000000 From 4c0b88708708b54b13b855a844ec91331d79113b Mon Sep 17 00:00:00 2001 From: Alix ANNERAUD Date: Sun, 10 May 2026 15:03:20 +0200 Subject: [PATCH 02/15] prevent decrementing reference count below zero in virtual file system --- .../virtual_file_system/src/file_system/mod.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/modules/virtual_file_system/src/file_system/mod.rs b/modules/virtual_file_system/src/file_system/mod.rs index cfcca582..3c3d1164 100644 --- a/modules/virtual_file_system/src/file_system/mod.rs +++ b/modules/virtual_file_system/src/file_system/mod.rs @@ -709,7 +709,9 @@ impl VirtualFileSystem { .iter_mut() .find_map(|fs| { if ptr::eq(fs.file_system, *file_system) { - fs.reference_count -= 1; + if fs.reference_count > 0 { + fs.reference_count -= 1; + } Some(()) } else { None @@ -724,7 +726,9 @@ impl VirtualFileSystem { .iter_mut() .find_map(|(key, p)| { if ptr::eq(p.pipe, *pipe) { - p.reference_count -= 1; + if p.reference_count > 0 { + p.reference_count -= 1; + } if p.reference_count == 1 { Some(Some(*key)) } else { @@ -747,7 +751,9 @@ impl VirtualFileSystem { .iter_mut() .find_map(|(_, d)| { if ptr::eq(d.device, *device) { - d.reference_count -= 1; + if d.reference_count > 0 { + d.reference_count -= 1; + } Some(()) } else { None @@ -762,7 +768,9 @@ impl VirtualFileSystem { .iter_mut() .find_map(|(_, d)| { if ptr::eq(d.device, *device) { - d.reference_count -= 1; + if d.reference_count > 0 { + d.reference_count -= 1; + } Some(()) } else { None From 23e49bdc760a9bc01875369645245107fb6fdad8 Mon Sep 17 00:00:00 2001 From: Alix ANNERAUD Date: Sun, 10 May 2026 15:03:41 +0200 Subject: [PATCH 03/15] add count_ones method to flags macro for counting set flags --- modules/shared/src/flags.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modules/shared/src/flags.rs b/modules/shared/src/flags.rs index bc8bfebe..0db78f5b 100644 --- a/modules/shared/src/flags.rs +++ b/modules/shared/src/flags.rs @@ -211,6 +211,13 @@ macro_rules! flags { $visibility const unsafe fn from_bits_unchecked(bits: $t) -> Self { Self(bits) } + + /// Count the number of flags set in the flag set + #[allow(dead_code)] + $visibility const fn count_ones(&self) -> u32 { + self.0.count_ones() + } + } impl core::fmt::Debug for $identifier { From 5fd4024ac334b070f53ec1132ed8b9ea1d16043c Mon Sep 17 00:00:00 2001 From: Alix ANNERAUD Date: Sun, 10 May 2026 15:03:54 +0200 Subject: [PATCH 04/15] fix: remove trailing CRLF from status line in HttpResponseBuilder --- modules/shared/src/http/response.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/shared/src/http/response.rs b/modules/shared/src/http/response.rs index 2fce8190..71671e28 100644 --- a/modules/shared/src/http/response.rs +++ b/modules/shared/src/http/response.rs @@ -105,7 +105,7 @@ impl<'a> HttpResponseBuilder<'a> { } pub fn add_status_code(&mut self, status_code: u16) -> Option<()> { - let status_line = &["HTTP/1.1 ", &status_code.to_string(), " \r\n"].concat(); + let status_line = &["HTTP/1.1 ", &status_code.to_string(), " "].concat(); self.add_line(status_line.as_bytes()) } From 8544300bc69b9ccf8dac8ec5e77dcaaee6f7fae0 Mon Sep 17 00:00:00 2001 From: Alix ANNERAUD Date: Sun, 10 May 2026 15:04:13 +0200 Subject: [PATCH 05/15] refactor: consolidate DNS functionality into stack and remove DnsSocket module --- modules/network/src/manager/mod.rs | 60 +++++++++--- modules/network/src/manager/stack.rs | 82 ++++++++++++++-- modules/network/src/socket/dns.rs | 139 --------------------------- modules/network/src/socket/mod.rs | 2 - 4 files changed, 121 insertions(+), 162 deletions(-) delete mode 100644 modules/network/src/socket/dns.rs diff --git a/modules/network/src/manager/mod.rs b/modules/network/src/manager/mod.rs index 1034e245..80dd0421 100644 --- a/modules/network/src/manager/mod.rs +++ b/modules/network/src/manager/mod.rs @@ -3,13 +3,15 @@ mod device; mod runner; mod stack; +use core::future::poll_fn; + use alloc::{vec, vec::Vec}; pub use context::*; use file_system::{DirectCharacterDevice, Path}; pub use runner::*; use smoltcp::{ phy::Device, - socket::{dns, icmp, tcp, udp}, + socket::{icmp, tcp, udp}, }; use synchronization::once_lock::OnceLock; use synchronization::{ @@ -19,7 +21,7 @@ use task::{SpawnerIdentifier, TaskIdentifier}; use virtual_file_system::VirtualFileSystem; use crate::{ - DnsSocket, Error, IcmpSocket, Result, TcpSocket, UdpSocket, + DnsQueryKind, Error, IcmpSocket, IpAddress, Result, TcpSocket, UdpSocket, manager::{ device::NetworkDevice, stack::{Stack, StackInner}, @@ -171,7 +173,17 @@ impl Manager { Ok(()) } - pub async fn new_dns_socket(&self, interface_name: Option<&str>) -> Result { + pub async fn resolve( + &self, + host: &str, + kind: DnsQueryKind, + stop_on_first_match: bool, + interface_name: Option<&str>, + ) -> Result> { + if let Ok(host) = IpAddress::try_from(host) { + return Ok(vec![host]); + } + let stacks = self.stacks.read().await; let stack = if let Some(name) = interface_name { @@ -184,21 +196,39 @@ impl Manager { .ok_or(Error::NotFound)? }; - let handle = stack - .with_mutable(|s| { - let socket = dns::Socket::new(&s.dns_servers, vec![]); - s.add_socket(socket) + let query_iterator = &[ + DnsQueryKind::A, + DnsQueryKind::Aaaa, + DnsQueryKind::Cname, + DnsQueryKind::Soa, + DnsQueryKind::Ns, + ]; + + let mut resolved_addresses = vec![]; + + for query_kind in query_iterator { + if !kind.contains(*query_kind) { + continue; + } + + let handle = stack + .with_mutable(|s| s.start_dns_query(host, kind)) + .await?; + + let result = poll_fn(|cx| { + stack + .poll_with_mutable(cx, |s, cx| s.get_dns_query_result(handle, Some(cx.waker()))) }) - .await; + .await?; - let context = SocketContext { - handle, - stack: stack.clone(), - closed: false, - }; - let socket = DnsSocket::new(context); + resolved_addresses.extend(result); - Ok(socket) + if !resolved_addresses.is_empty() && stop_on_first_match { + break; + } + } + + Ok(resolved_addresses) } pub async fn new_tcp_socket( diff --git a/modules/network/src/manager/stack.rs b/modules/network/src/manager/stack.rs index 8e01b59c..bb83a850 100644 --- a/modules/network/src/manager/stack.rs +++ b/modules/network/src/manager/stack.rs @@ -1,10 +1,10 @@ use crate::{ - Error, IpAddress, IpCidr, Ipv4, Ipv6, MacAddress, Port, Result, Route, WakeSignal, - get_smoltcp_time, + DnsQueryKind, Error, IpAddress, IpCidr, Ipv4, Ipv6, MacAddress, Port, Result, Route, + WakeSignal, get_smoltcp_time, }; use alloc::{boxed::Box, vec, vec::Vec}; use core::{ - task::{Context, Poll}, + task::{Context, Poll, Waker}, time::Duration, }; use file_system::DirectCharacterDevice; @@ -12,10 +12,10 @@ use shared::poll_pin_ready; use smol_str::SmolStr; use smoltcp::{ config::{DNS_MAX_SERVER_COUNT, IFACE_MAX_ADDR_COUNT}, - iface::{self, SocketSet}, + iface::{self, SocketHandle, SocketSet}, phy::{Device, Medium}, - socket::{AnySocket, Socket, dhcpv4}, - wire::{self, EthernetAddress}, + socket::{AnySocket, Socket, dhcpv4, dns}, + wire::{self, DnsQueryType, EthernetAddress}, }; use synchronization::{Arc, blocking_mutex::raw::CriticalSectionRawMutex, mutex::Mutex}; @@ -27,6 +27,7 @@ pub struct StackInner { pub controller: Box, pub sockets: smoltcp::iface::SocketSet<'static>, pub dhcp_socket: Option, + pub dns_socket: Option, pub dns_servers: Vec, pub maximum_transmission_unit: usize, pub maximum_burst_size: Option, @@ -158,6 +159,7 @@ impl StackInner { controller: Box::new(controller_device), sockets, dhcp_socket: None, + dns_socket: None, dns_servers: Vec::with_capacity(DNS_MAX_SERVER_COUNT), maximum_transmission_unit: capabilities.max_transmission_unit, maximum_burst_size: capabilities.max_burst_size, @@ -237,6 +239,7 @@ impl StackInner { } self.dns_servers.push(server.into_smoltcp()); + self.update_socket_dns_servers(); Ok(()) } @@ -246,9 +249,17 @@ impl StackInner { } let server = self.dns_servers.remove(index); + self.update_socket_dns_servers(); Some(IpAddress::from_smoltcp(&server)) } + fn update_socket_dns_servers(&mut self) { + if let Some(handle) = self.dns_socket { + let socket = self.sockets.get_mut::(handle); + socket.update_servers(&self.dns_servers); + } + } + pub fn get_dns_servers(&self) -> &[wire::IpAddress] { &self.dns_servers } @@ -380,4 +391,63 @@ impl StackInner { port } + + pub fn get_dns_socket_handle(&mut self) -> SocketHandle { + match self.dns_socket { + Some(handle) => handle, + None => { + let dns_socket = dns::Socket::new(&self.dns_servers, vec![]); + let handle = self.sockets.add(dns_socket); + self.dns_socket = Some(handle); + handle + } + } + } + + pub fn start_dns_query(&mut self, name: &str, kind: DnsQueryKind) -> Result { + let query_type = if kind.contains(DnsQueryKind::A) { + DnsQueryType::A + } else if kind.contains(DnsQueryKind::Aaaa) { + DnsQueryType::Aaaa + } else if kind.contains(DnsQueryKind::Cname) { + DnsQueryType::Cname + } else if kind.contains(DnsQueryKind::Ns) { + DnsQueryType::Ns + } else if kind.contains(DnsQueryKind::Soa) { + DnsQueryType::Soa + } else { + return Err(Error::UnsupportedProtocol); + }; + + let socket_handle = self.get_dns_socket_handle(); + let query_handle = self + .sockets + .get_mut::(socket_handle) + .start_query(self.interface.context(), name, query_type)?; + + Ok(query_handle) + } + + pub fn get_dns_query_result( + &mut self, + query_handle: dns::QueryHandle, + waker: Option<&Waker>, + ) -> Poll>> { + let socket_handle = self.get_dns_socket_handle(); + let socket = self.sockets.get_mut::(socket_handle); + + match socket.get_query_result(query_handle) { + Err(dns::GetQueryResultError::Pending) => { + if let Some(waker) = waker { + socket.register_query_waker(query_handle, waker); + } + Poll::Pending + } + Err(e) => Poll::Ready(Err::<_, Error>(e.into())), + Ok(query_result) => Poll::Ready(Ok(query_result + .iter() + .map(IpAddress::from_smoltcp) + .collect())), + } + } } diff --git a/modules/network/src/socket/dns.rs b/modules/network/src/socket/dns.rs deleted file mode 100644 index 39fe4834..00000000 --- a/modules/network/src/socket/dns.rs +++ /dev/null @@ -1,139 +0,0 @@ -use core::{ - future::poll_fn, - task::{Context, Poll}, -}; - -use alloc::{vec, vec::Vec}; -use embassy_futures::block_on; -use smoltcp::{socket::dns, wire::DnsQueryType}; - -use crate::{DnsQueryKind, IpAddress, Result, SocketContext}; - -pub struct DnsSocket { - context: SocketContext, -} - -impl DnsSocket { - pub fn new(context: SocketContext) -> Self { - Self { context } - } - - fn poll_with_mutable(&self, context: &mut Context<'_>, f: F) -> Poll - where - F: FnOnce(&mut dns::Socket<'static>, &mut Context<'_>) -> Poll, - { - self.context.poll_with_mutable(context, f) - } - - pub async fn update_servers(&self) -> Result<()> { - self.context - .stack - .with_mutable(|s| { - let dns_servers = s.get_dns_servers().to_vec(); - let socket = s.sockets.get_mut::(self.context.handle); - socket.update_servers(&dns_servers); - }) - .await; - - Ok(()) - } - - pub async fn resolve_for_kind(&self, host: &str, kind: DnsQueryType) -> Result> { - if let Ok(host) = IpAddress::try_from(host) { - return Ok(vec![host]); - } - - let query = self - .context - .stack - .with_mutable(|s| { - let socket = s.sockets.get_mut::(self.context.handle); - - socket.start_query(s.interface.context(), host, kind) - }) - .await?; - - self.context.stack.wake_up(); - - poll_fn(|cx| { - self.poll_with_mutable(cx, |socket, cx| match socket.get_query_result(query) { - Err(dns::GetQueryResultError::Pending) => { - socket.register_query_waker(query, cx.waker()); - Poll::Pending - } - Err(e) => Poll::Ready(Err(e.into())), - Ok(ip_addresses) => { - let ip_addresses = ip_addresses - .into_iter() - .map(|a| IpAddress::from_smoltcp(&a)) - .collect(); - - Poll::Ready(Ok(ip_addresses)) - } - }) - }) - .await - } - - pub async fn resolve(&self, host: &str, kind: DnsQueryKind) -> Result> { - let mut results = Vec::new(); - - if kind.contains(DnsQueryKind::A) { - let mut a_results = self.resolve_for_kind(host, DnsQueryType::A).await?; - results.append(&mut a_results); - } - - if kind.contains(DnsQueryKind::Aaaa) { - let mut aaaa_results = self.resolve_for_kind(host, DnsQueryType::Aaaa).await?; - results.append(&mut aaaa_results); - } - - if kind.contains(DnsQueryKind::Cname) { - let mut cname_results = self.resolve_for_kind(host, DnsQueryType::Cname).await?; - results.append(&mut cname_results); - } - - if kind.contains(DnsQueryKind::Ns) { - let mut ns_results = self.resolve_for_kind(host, DnsQueryType::Ns).await?; - results.append(&mut ns_results); - } - - if kind.contains(DnsQueryKind::Soa) { - let mut soa_results = self.resolve_for_kind(host, DnsQueryType::Soa).await?; - results.append(&mut soa_results); - } - - Ok(results) - } - - pub async fn close(mut self) -> Result<()> { - if self.context.closed { - return Ok(()); - } - - self.context - .stack - .with_mutable(|s| { - let _ = s.remove_socket(self.context.handle); - }) - .await; - - self.context.closed = true; - - Ok(()) - } -} - -impl Drop for DnsSocket { - fn drop(&mut self) { - if self.context.closed { - return; - } - - log::warning!("DNS socket dropped without being closed. Forcing closure..."); - - block_on(self.context.stack.with_mutable(|s| { - let _ = s.remove_socket(self.context.handle); - })); - } -} diff --git a/modules/network/src/socket/mod.rs b/modules/network/src/socket/mod.rs index b9d852d6..9f060f7e 100644 --- a/modules/network/src/socket/mod.rs +++ b/modules/network/src/socket/mod.rs @@ -1,9 +1,7 @@ -mod dns; mod icmp; mod tcp; mod udp; -pub use dns::*; pub use icmp::*; pub use tcp::*; pub use udp::*; From cb27561ed5ac931c5e521e52f42abce8c5618141 Mon Sep 17 00:00:00 2001 From: Alix ANNERAUD Date: Sun, 10 May 2026 15:04:31 +0200 Subject: [PATCH 06/15] refactor: streamline target resolution in PingCommand by removing separate resolve_target function --- .../shell/command_line/src/commands/ping.rs | 44 +++++++------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/executables/shell/command_line/src/commands/ping.rs b/executables/shell/command_line/src/commands/ping.rs index 373994c2..865039da 100644 --- a/executables/shell/command_line/src/commands/ping.rs +++ b/executables/shell/command_line/src/commands/ping.rs @@ -39,27 +39,6 @@ struct PingArguments<'a> { size: usize, } -async fn resolve_target(target: &str) -> crate::Result> { - let network = network::get_instance(); - let dns_socket = network - .new_dns_socket(None) - .await - .map_err(Error::FailedToCreateSocket)?; - - let resolved_target = dns_socket - .resolve(target, DnsQueryKind::A | DnsQueryKind::Aaaa) - .await - .map(|s| s.first().cloned()) - .map_err(Error::FailedToResolve)?; - - dns_socket - .close() - .await - .map_err(Error::FailedToCreateSocket)?; - - Ok(resolved_target) -} - async fn write_ping_line( context: &mut C, target: &str, @@ -147,12 +126,23 @@ where size: payload_size, } = PingArguments::parse(options)?; - let Some(resolved_target) = resolve_target(target).await? else { - context.write_out_fmt(format_args!( - "{}\n", - format_args!(translate!("Cannot resolve {}: Unknown host"), target) - ))?; - return Ok(()); + let manager = network::get_instance(); + + let resolved_target = manager + .resolve(target, DnsQueryKind::A | DnsQueryKind::Aaaa, true, None) + .await + .ok() + .and_then(|ips| ips.first().cloned()); + + let resolved_target = match resolved_target { + Some(ip) => ip, + None => { + context.write_out_fmt(format_args!( + "{}\n", + format_args!(translate!("Cannot resolve {}: Unknown host"), target) + ))?; + return Ok(()); + } }; write_ping_line(context, target, &resolved_target).await?; From 6e618ebfe2dc02fd2afee8de5e7be8a1bbde0cfa Mon Sep 17 00:00:00 2001 From: Alix ANNERAUD Date: Sun, 10 May 2026 15:04:39 +0200 Subject: [PATCH 07/15] refactor: replace DnsSocket with network manager in DNS resolution --- .../shell/command_line/src/commands/dns.rs | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/executables/shell/command_line/src/commands/dns.rs b/executables/shell/command_line/src/commands/dns.rs index f4ee4d0f..d90cdde7 100644 --- a/executables/shell/command_line/src/commands/dns.rs +++ b/executables/shell/command_line/src/commands/dns.rs @@ -1,10 +1,10 @@ -use crate::{Error, Result}; +use crate::Result; use executable_macros::GetArgs; use getargs::Options; use xila::{ file_system::Path, internationalization::translate, - network::{self, DnsQueryKind, DnsSocket}, + network::{self, DnsQueryKind}, }; use super::{CommandContext, UserCommand}; @@ -54,11 +54,11 @@ fn format_kind(kind: DnsQueryKind) -> &'static str { async fn resolve_record( context: &mut C, - socket: &DnsSocket, + network_manager: &network::Manager, domain: &str, kind: DnsQueryKind, ) -> Result<()> { - match socket.resolve(domain, kind).await { + match network_manager.resolve(domain, kind, true, None).await { Ok(ip) => { context.write_out_fmt(format_args!( "{}\n", @@ -117,29 +117,24 @@ where let default = !a_enabled && !aaaa_enabled && !cname_enabled && !ns_enabled && !soa_enabled; - let socket = network::get_instance() - .new_dns_socket(None) - .await - .map_err(Error::FailedToCreateSocket)?; + let network_manager = network::get_instance(); if a_enabled || default { - resolve_record(context, &socket, domain, DnsQueryKind::A).await?; + resolve_record(context, network_manager, domain, DnsQueryKind::A).await?; } if aaaa_enabled || default { - resolve_record(context, &socket, domain, DnsQueryKind::Aaaa).await?; + resolve_record(context, network_manager, domain, DnsQueryKind::Aaaa).await?; } if cname_enabled { - resolve_record(context, &socket, domain, DnsQueryKind::Cname).await?; + resolve_record(context, network_manager, domain, DnsQueryKind::Cname).await?; } if ns_enabled { - resolve_record(context, &socket, domain, DnsQueryKind::Ns).await?; + resolve_record(context, network_manager, domain, DnsQueryKind::Ns).await?; } if soa_enabled { - resolve_record(context, &socket, domain, DnsQueryKind::Soa).await?; + resolve_record(context, network_manager, domain, DnsQueryKind::Soa).await?; } - socket.close().await.map_err(Error::FailedToCreateSocket)?; - Ok(()) } From 6c7ae236df96ca6d462fee562feab6202cd75b86 Mon Sep 17 00:00:00 2001 From: Alix ANNERAUD Date: Sun, 10 May 2026 15:05:31 +0200 Subject: [PATCH 08/15] feat: implement sleep request handling in ABI context and update thread sleep functions --- modules/abi/context/src/lib.rs | 23 ++++++++++++++++++++++ modules/abi/definitions/Cargo.toml | 1 + modules/abi/definitions/src/task/thread.rs | 22 +++++++++------------ 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/modules/abi/context/src/lib.rs b/modules/abi/context/src/lib.rs index bf558f78..5269caec 100644 --- a/modules/abi/context/src/lib.rs +++ b/modules/abi/context/src/lib.rs @@ -10,6 +10,7 @@ use core::fmt::Debug; pub use file::*; use alloc::{collections::btree_map::BTreeMap, vec, vec::Vec}; +use core::time::Duration; use file_system::{Path, PathOwned}; use smol_str::SmolStr; use synchronization::{blocking_mutex::raw::CriticalSectionRawMutex, rwlock::RwLock}; @@ -34,6 +35,7 @@ type FileEntry = SynchronousFile; struct Inner { task: Option, + sleep_requests: BTreeMap, directories: BTreeMap, files: BTreeMap, } @@ -50,6 +52,7 @@ impl Context { pub const fn new() -> Self { Self(RwLock::new(Inner { task: None, + sleep_requests: BTreeMap::new(), directories: BTreeMap::new(), files: BTreeMap::new(), })) @@ -59,6 +62,26 @@ impl Context { block_on(self.0.read()).task.expect("No current task set") } + pub fn try_get_current_task_identifier(&self) -> Option { + self.0.try_read().ok().and_then(|inner| inner.task) + } + + pub fn request_sleep(&self, task: TaskIdentifier, duration: Duration) { + let mut inner = self + .0 + .try_write() + .expect("Failed to lock ABI context for sleep request"); + inner.sleep_requests.insert(task, duration); + } + + pub fn take_sleep_request(&self, task: TaskIdentifier) -> Option { + let mut inner = self + .0 + .try_write() + .expect("Failed to lock ABI context for sleep request"); + inner.sleep_requests.remove(&task) + } + fn get_new_identifier( map: &BTreeMap, task: TaskIdentifier, diff --git a/modules/abi/definitions/Cargo.toml b/modules/abi/definitions/Cargo.toml index 32fe6ec5..82f43a4d 100644 --- a/modules/abi/definitions/Cargo.toml +++ b/modules/abi/definitions/Cargo.toml @@ -11,6 +11,7 @@ time = { workspace = true } memory = { workspace = true } network = { workspace = true } synchronization = { workspace = true } +users = { workspace = true } log = { workspace = true } abi_context = { workspace = true } diff --git a/modules/abi/definitions/src/task/thread.rs b/modules/abi/definitions/src/task/thread.rs index 5e10fbf3..71bca790 100644 --- a/modules/abi/definitions/src/task/thread.rs +++ b/modules/abi/definitions/src/task/thread.rs @@ -2,9 +2,6 @@ use core::ffi::c_int; use core::ptr::null_mut; use core::{ffi::c_void, time::Duration}; -use task::Manager; -use task::block_on; - use abi_context as context; pub type XilaThreadIdentifier = usize; @@ -18,12 +15,14 @@ pub extern "C" fn xila_get_current_thread_identifier() -> usize { #[unsafe(no_mangle)] pub extern "C" fn xila_thread_sleep(duration: u64) { - block_on(Manager::sleep(Duration::from_millis(duration))); + if let Some(task) = context::get_instance().try_get_current_task_identifier() { + context::get_instance().request_sleep(task, Duration::from_millis(duration)); + } } #[unsafe(no_mangle)] -pub extern "C" fn xila_thread_sleep_exact(_duration: u32) { - todo!() +pub extern "C" fn xila_thread_sleep_exact(duration: u32) { + xila_thread_sleep(duration as u64); } #[unsafe(no_mangle)] @@ -57,14 +56,10 @@ pub extern "C" fn xila_thread_create( } #[unsafe(no_mangle)] -pub extern "C" fn xila_thread_begin_blocking_operation() { - todo!() -} +pub extern "C" fn xila_thread_begin_blocking_operation() {} #[unsafe(no_mangle)] -pub extern "C" fn xila_thread_end_blocking_operation() { - todo!() -} +pub extern "C" fn xila_thread_end_blocking_operation() {} #[unsafe(no_mangle)] pub extern "C" fn xila_thread_wake_up(_thread: XilaThreadIdentifier) -> u32 { @@ -73,5 +68,6 @@ pub extern "C" fn xila_thread_wake_up(_thread: XilaThreadIdentifier) -> u32 { #[unsafe(no_mangle)] pub extern "C" fn xila_thread_yield() -> c_int { - todo!() + xila_thread_sleep(0); + 0 } From 3fc9e6e670153c57a6e939ffa0659c3cb113501d Mon Sep 17 00:00:00 2001 From: Alix ANNERAUD Date: Sun, 10 May 2026 15:06:16 +0200 Subject: [PATCH 09/15] feat: add textarea text retrieval functions in WASM bindings --- .../wasm/build/utilities/additional.rs | 4 +++ executables/wasm/build/utilities/context.rs | 1 + .../src/host/bindings/graphics/additionnal.rs | 35 +++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/executables/wasm/build/utilities/additional.rs b/executables/wasm/build/utilities/additional.rs index cf2fdb1d..6de5e582 100644 --- a/executables/wasm/build/utilities/additional.rs +++ b/executables/wasm/build/utilities/additional.rs @@ -28,6 +28,10 @@ pub fn get() -> TokenStream { ); pub fn percentage(value: i32) -> i32; + + pub fn textarea_get_text(textarea: *mut lv_obj_t, buffer: *mut i8, buffer_size: usize) -> i32; + + pub fn textarea_get_text_length(textarea: *mut lv_obj_t) -> i32; } } } diff --git a/executables/wasm/build/utilities/context.rs b/executables/wasm/build/utilities/context.rs index 53d69828..8d956ea8 100644 --- a/executables/wasm/build/utilities/context.rs +++ b/executables/wasm/build/utilities/context.rs @@ -79,6 +79,7 @@ impl LvglContext { "lv_obj_delete_async", "lv_obj_null_on_delete", "lv_buttonmatrix_get_map", + "lv_textarea_get_text", ]; let signature_ident_str = signature.ident.to_string(); diff --git a/executables/wasm/src/host/bindings/graphics/additionnal.rs b/executables/wasm/src/host/bindings/graphics/additionnal.rs index 25e3ab80..3b7ea6e3 100644 --- a/executables/wasm/src/host/bindings/graphics/additionnal.rs +++ b/executables/wasm/src/host/bindings/graphics/additionnal.rs @@ -100,3 +100,38 @@ pub unsafe fn window_set_icon( pub unsafe fn percentage(value: i32) -> i32 { unsafe { lvgl::lv_pct(value) } } + +pub unsafe fn textarea_get_text( + textarea: *mut lvgl::lv_obj_t, + buffer: *mut i8, + buffer_size: usize, +) -> i32 { + let text = unsafe { + let text = lvgl::lv_textarea_get_text(textarea); + if text.is_null() { + log::warning!("lv_textarea_get_text returned null"); + return 0; + } + CStr::from_ptr(text).to_string_lossy() + }; + + let len = core::cmp::min(text.len(), buffer_size - 1); + unsafe { + core::ptr::copy_nonoverlapping(text.as_ptr(), buffer as *mut u8, len); + *buffer.add(len) = 0; // Null-terminate + } + len as i32 +} + +pub unsafe fn textarea_get_text_length(textarea: *mut lvgl::lv_obj_t) -> i32 { + let text = unsafe { + let text = lvgl::lv_textarea_get_text(textarea); + if text.is_null() { + log::warning!("lv_textarea_get_text returned null"); + return 0; + } + CStr::from_ptr(text).to_string_lossy() + }; + + text.len() as i32 +} From 77b1cbb2ff7865dc0ec2397b10d11e10c6ecf5df Mon Sep 17 00:00:00 2001 From: Alix ANNERAUD Date: Sun, 10 May 2026 15:06:27 +0200 Subject: [PATCH 10/15] feat: implement sleep request handling in WASM execution flow --- .../wasm/src/host/virtual_machine/runtime.rs | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/executables/wasm/src/host/virtual_machine/runtime.rs b/executables/wasm/src/host/virtual_machine/runtime.rs index 960652c9..052379c0 100644 --- a/executables/wasm/src/host/virtual_machine/runtime.rs +++ b/executables/wasm/src/host/virtual_machine/runtime.rs @@ -20,7 +20,8 @@ use wamr_rust_sdk::{ }; use xila::{ abi_context::{self, FileIdentifier}, - task::{TaskIdentifier, yield_now}, + log, + task::{self, TaskIdentifier, yield_now}, virtual_file_system::File, }; @@ -274,6 +275,23 @@ impl Runtime { let exception = Self::current_exception_string(instance.get_inner_reference()); if exception.contains("instruction limit exceeded") { + if let Some(requested_sleep) = + abi_context::get_instance().take_sleep_request(task) + { + if !requested_sleep.is_zero() { + log::information!( + "Task {} requested sleep for {:?} ms", + task.into_inner(), + requested_sleep + ); + task::sleep(requested_sleep).await; + } else { + yield_now().await; + } + } else { + yield_now().await; + } + unsafe { wasm_runtime_clear_exception( instance.get_inner_reference().get_inner_instance(), @@ -286,11 +304,11 @@ impl Runtime { resume = true; - yield_now().await; - continue; } + let _ = abi_context::get_instance().take_sleep_request(task); + break if exception.is_empty() { Err(Error::Execution("WASM execution failed".into())) } else { From 9baf6c7c9bde9445f55ece3160d1a62f6ba6678f Mon Sep 17 00:00:00 2001 From: Alix ANNERAUD Date: Sun, 10 May 2026 15:06:46 +0200 Subject: [PATCH 11/15] feat: enhance file system error logging and add directory statistics handling --- .../definitions/src/file_system/functions.rs | 57 +++++++++++++++++-- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/modules/abi/definitions/src/file_system/functions.rs b/modules/abi/definitions/src/file_system/functions.rs index fc26aa04..2bb8c2f2 100644 --- a/modules/abi/definitions/src/file_system/functions.rs +++ b/modules/abi/definitions/src/file_system/functions.rs @@ -5,8 +5,12 @@ use core::{ num::NonZeroU32, ptr::copy_nonoverlapping, }; -use file_system::{AccessFlags, CreateFlags, Flags, StateFlags, character_device}; +use file_system::{ + AccessFlags, CreateFlags, Flags, Kind, Permissions, StateFlags, Statistics, Time, + character_device, +}; use task::block_on; +use users::{GroupIdentifier, UserIdentifier}; use virtual_file_system::{ Error, SynchronousDirectory, SynchronousFile, get_instance as get_file_system_instance, }; @@ -30,8 +34,21 @@ where Err(error) => { let non_zero: NonZeroU32 = error.into(); - log::error!("File system error: {:?} ({})", error, non_zero); - log::error!("Context debug info: {:?}", context::get_instance()); + if matches!( + error, + Error::RessourceBusy | Error::FileSystem(file_system::Error::RessourceBusy) + ) { + log::debug!( + "File system busy (expected while polling): {:?} ({})", + error, + non_zero + ); + } else { + log::error!("File system error: {:?} ({})", error, non_zero); + log::error!("Context debug info: {:?}", context::get_instance()); + } + + //panic!("File system error: {:?} ({})", error, non_zero.get()); non_zero.get() } @@ -63,6 +80,10 @@ pub unsafe extern "C" fn xila_file_system_get_statistics( file.try_into()?, SynchronousDirectory::get_statistics, ) { + log::information!( + "File identifier {:?} is a directory, getting statistics", + file + ); result.inspect_err(|&e| { log::error!( "Performing operation on directory to get statistics: {:?}", @@ -70,12 +91,38 @@ pub unsafe extern "C" fn xila_file_system_get_statistics( ); })? } else { - context + log::information!( + "File identifier {:?} is not a directory, trying as a file", + file + ); + match context .perform_operation_on_file(file.try_into()?, SynchronousFile::get_statistics) .ok_or(Error::InvalidParameter) .inspect_err(|&e| { log::error!("Performing operation on file to get statistics: {:?}", e); - })?? + })? { + Ok(statistics) => statistics, + // Some character devices don't expose attribute operations. + // Return minimal synthetic metadata so POSIX callers (e.g. WASI libc) + // can proceed after open/fstat. + Err(Error::UnsupportedOperation) + | Err(Error::FileSystem(file_system::Error::UnsupportedOperation)) => { + Statistics::new( + 0, + 1, + 0, + Time::new(0), + Time::new(0), + Time::new(0), + Time::new(0), + Kind::CharacterDevice, + Permissions::DEVICE_DEFAULT, + UserIdentifier::ROOT, + GroupIdentifier::ROOT, + ) + } + Err(error) => return Err(error), + } }; *statistics = XilaFileSystemStatistics::from_statistics(s); From d042978676d83bbd78ef0d20386d7c6a784e6cd1 Mon Sep 17 00:00:00 2001 From: Alix ANNERAUD Date: Sun, 10 May 2026 15:07:18 +0200 Subject: [PATCH 12/15] feat: refactor HTTP and HTTPS client to improve DNS resolution and header parsing --- drivers/shared/src/devices/http_client.rs | 16 +-- drivers/shared/src/devices/http_common.rs | 34 ++++- drivers/shared/src/devices/https_client.rs | 141 ++++++++++----------- 3 files changed, 101 insertions(+), 90 deletions(-) diff --git a/drivers/shared/src/devices/http_client.rs b/drivers/shared/src/devices/http_client.rs index eb0e0e09..d917db80 100644 --- a/drivers/shared/src/devices/http_client.rs +++ b/drivers/shared/src/devices/http_client.rs @@ -163,17 +163,13 @@ async fn create_tcp_connection(host: &str, port: u16) -> Result { log::information!("http_client: create session host='{}' port={}", host, port); let manager = network::get_instance(); - let dns_socket = manager - .new_dns_socket(None) + let address = manager + .resolve(host, DnsQueryKind::A | DnsQueryKind::Aaaa, true, None) .await - .map_err(map_network_error)?; - let resolved = dns_socket - .resolve(host, DnsQueryKind::A | DnsQueryKind::Aaaa) - .await - .map_err(map_network_error)?; - dns_socket.close().await.map_err(map_network_error)?; - - let address = resolved.into_iter().next().ok_or(Error::NotFound)?; + .map_err(map_network_error)? + .first() + .cloned() + .ok_or(Error::NotFound)?; let mut socket = manager .new_tcp_socket(4096, 4096, None) diff --git a/drivers/shared/src/devices/http_common.rs b/drivers/shared/src/devices/http_common.rs index 58d0f9de..627e39ef 100644 --- a/drivers/shared/src/devices/http_common.rs +++ b/drivers/shared/src/devices/http_common.rs @@ -91,10 +91,36 @@ pub fn build_serialized_response_headers(raw_headers: &[u8], output: &mut [u8]) .add_status_code(status_code) .ok_or(Error::InternalError)?; - for (name, value) in parser.get_headers() { - builder - .add_header(name, value.as_bytes()) - .ok_or(Error::FileTooLarge)?; + // Manually parse headers line by line to handle edge cases like + // servers that omit reason phrase (HTTP/1.1 200) causing an empty line after status + let lines = parser.split_lines(); + let mut header_started = false; + + for line in lines { + let trimmed = core::str::from_utf8(line).unwrap_or("").trim(); + + // Skip the status line (first non-empty line) + if !header_started && !trimmed.is_empty() { + header_started = true; + continue; + } + + // Stop at empty line (end of headers) + if header_started && trimmed.is_empty() { + break; + } + + // Skip empty lines in the header section + if trimmed.is_empty() { + continue; + } + + // Parse and add the header + if let Some((name, value)) = shared::parse_header(line) { + builder + .add_header(name, value.as_bytes()) + .ok_or(Error::FileTooLarge)?; + } } builder.add_line(b"").ok_or(Error::FileTooLarge)?; diff --git a/drivers/shared/src/devices/https_client.rs b/drivers/shared/src/devices/https_client.rs index 1df3f8da..6b3ffe9e 100644 --- a/drivers/shared/src/devices/https_client.rs +++ b/drivers/shared/src/devices/https_client.rs @@ -5,7 +5,10 @@ use embassy_futures::select::{Either, select}; use embedded_io; use embedded_io_async; use embedded_tls::{Aes128GcmSha256, TlsConfig, TlsConnection, TlsContext, UnsecureProvider}; -use file_system::{BaseOperations, CharacterDevice, Context, Error, MountOperations, Result, Size}; +use file_system::{ + BaseOperations, CharacterDevice, Context, DirectCharacterDevice, Error, MountOperations, + Result, Size, +}; use network::{DnsQueryKind, Duration as NetworkDuration, Port, TcpSocket}; use rand_core::{CryptoRng, RngCore}; use shared::HttpRequestParser; @@ -15,36 +18,44 @@ use super::http_common::{ build_serialized_response_headers, compute_request_length, map_network_error, split_host_port, }; -const TLS_RECORD_BUFFER_SIZE: usize = 4096; -const RESPONSE_SCAN_BUFFER_SIZE: usize = 3072; -const RESPONSE_SERIALIZED_HEADER_SIZE: usize = 2048; +const TLS_RECORD_BUFFER_SIZE: usize = 16384; // TLS 1.2 max record size (2^14 bytes) +const RESPONSE_SCAN_BUFFER_SIZE: usize = 4096; // HTTP headers buffer (typically 2-5 KB) +const RESPONSE_SERIALIZED_HEADER_SIZE: usize = 2048; // Parsed header output buffer const TLS_READ_CHUNK_SIZE: usize = 512; const DEFAULT_HTTPS_PORT: u16 = 443; const IO_TIMEOUT_SECONDS: u64 = 15; -struct SystemRng; +struct RandomNumberGenerator(&'static T); -impl CryptoRng for SystemRng {} +impl CryptoRng for RandomNumberGenerator {} -impl RngCore for SystemRng { +impl RngCore for RandomNumberGenerator { fn next_u32(&mut self) -> u32 { let mut bytes = [0u8; 4]; - getrandom::fill(&mut bytes).expect("SystemRng failed to gather u32 entropy"); + self.0 + .read(&mut bytes, 0) + .expect("Random device read failed"); u32::from_le_bytes(bytes) } fn next_u64(&mut self) -> u64 { let mut bytes = [0u8; 8]; - getrandom::fill(&mut bytes).expect("SystemRng failed to gather u64 entropy"); + self.0 + .read(&mut bytes, 0) + .expect("Random device read failed"); u64::from_le_bytes(bytes) } fn fill_bytes(&mut self, dest: &mut [u8]) { - getrandom::fill(dest).expect("SystemRng failed to gather entropy"); + self.0.read(dest, 0).expect("Random device read failed"); } fn try_fill_bytes(&mut self, dest: &mut [u8]) -> core::result::Result<(), rand_core::Error> { - getrandom::fill(dest).map_err(|_| rand_core::Error::from(core::num::NonZeroU32::MIN)) + self.0.read(dest, 0).map_err(|e| { + log::error!("Random device read failed: {:?}", e); + rand_core::Error::from(e.get_discriminant()) + })?; + Ok(()) } } @@ -271,30 +282,23 @@ async fn write_tls_all( Ok(()) } -async fn create_tls_connection<'a>( +async fn create_tls_connection<'a, T: DirectCharacterDevice + 'static>( host: &str, port: u16, read_record: &'a mut [u8; TLS_RECORD_BUFFER_SIZE], write_record: &'a mut [u8; TLS_RECORD_BUFFER_SIZE], + random_device: &'static T, ) -> Result> { - log::information!("https_client: create session host='{}' port={}", host, port); let manager = network::get_instance(); - log::information!("https_client: resolving host='{}'", host); - let dns_socket = manager - .new_dns_socket(None) - .await - .map_err(map_network_error)?; - let resolved = dns_socket - .resolve(host, DnsQueryKind::A | DnsQueryKind::Aaaa) + let address = manager + .resolve(host, DnsQueryKind::A | DnsQueryKind::Aaaa, true, None) .await - .map_err(map_network_error)?; - dns_socket.close().await.map_err(map_network_error)?; - - let address = resolved.into_iter().next().ok_or(Error::NotFound)?; - log::information!("https_client: dns resolved host='{}'", host); + .map_err(map_network_error)? + .first() + .cloned() + .ok_or(Error::NotFound)?; - log::information!("https_client: creating tcp socket"); let mut socket = manager .new_tcp_socket(4096, 4096, None) .await @@ -302,11 +306,7 @@ async fn create_tls_connection<'a>( socket .set_timeout(Some(NetworkDuration::from_seconds(IO_TIMEOUT_SECONDS))) .await; - log::information!( - "https_client: connecting tcp socket to {}:{}", - address, - port - ); + socket .connect(address, Port::from_inner(port)) .await @@ -314,30 +314,24 @@ async fn create_tls_connection<'a>( socket .set_timeout(Some(NetworkDuration::from_seconds(IO_TIMEOUT_SECONDS))) .await; - log::information!("https_client: tcp connected"); let mut tls = TlsConnection::new(TcpSocketAdapter { socket }, read_record, write_record); let configuration = TlsConfig::new().with_server_name(host); - let provider = UnsecureProvider::new::(SystemRng); + let provider = UnsecureProvider::new::(RandomNumberGenerator(random_device)); let context = TlsContext::new(&configuration, provider); - log::information!("https_client: starting tls handshake"); tls.open(context).await.map_err(map_tls_error)?; - log::information!("https_client: tls handshake done"); Ok(tls) } -async fn run_request( +async fn run_request( inner: Arc>, request: Vec, + random_device: &'static T, ) { let result = async { - log::information!( - "https_client: run_request begin (buffer_len={})", - request.len() - ); let parser = HttpRequestParser::from_buffer(&request); let _ = parser.get_request().ok_or(Error::InvalidParameter)?; @@ -348,18 +342,21 @@ async fn run_request( .ok_or(Error::InvalidParameter)?; let (host, port) = split_host_port(host_header, DEFAULT_HTTPS_PORT); - log::information!("https_client: parsed host='{}' port={}", host, port); let mut read_record = [0u8; TLS_RECORD_BUFFER_SIZE]; let mut write_record = [0u8; TLS_RECORD_BUFFER_SIZE]; - let mut tls = - create_tls_connection(host, port, &mut read_record, &mut write_record).await?; + let mut tls = create_tls_connection( + host, + port, + &mut read_record, + &mut write_record, + random_device, + ) + .await?; let request_length = compute_request_length(&request, parser)?; let payload = &request[..request_length]; - log::information!("https_client: request length computed = {}", request_length); - log::information!("https_client: tls write_all begin"); write_tls_all(&mut tls, payload).await?; let has_header_terminator = payload.windows(4).any(|window| window == b"\r\n\r\n"); @@ -378,22 +375,12 @@ async fn run_request( write_tls_all(&mut tls, suffix).await?; } - log::information!("https_client: tls write_all done"); - - log::information!("https_client: tls flush begin"); tls.flush().await.map_err(map_tls_error)?; - log::information!("https_client: tls flush done"); let mut raw_headers = [0u8; RESPONSE_SCAN_BUFFER_SIZE]; - log::information!("https_client: waiting response headers"); let (raw_headers_end, response_body) = read_response_headers_and_body(&mut tls, &mut raw_headers).await?; - log::information!( - "https_client: response headers received (headers_end={}, body_size={})", - raw_headers_end, - response_body.len() - ); let mut response_headers = [0u8; RESPONSE_SERIALIZED_HEADER_SIZE]; let serialized_headers_len = build_serialized_response_headers( @@ -401,14 +388,10 @@ async fn run_request( &mut response_headers, )?; - match tls.close().await { - Ok(mut adapter) => { - adapter.socket.close().await; - } - Err((mut adapter, _)) => { - adapter.socket.close().await; - } - } + // Skip explicit TLS close as it may block indefinitely. + // The TLS connection and socket will be dropped naturally when this scope ends. + // This avoids deadlock while still cleaning up resources. + drop(tls); Ok::<(usize, [u8; RESPONSE_SERIALIZED_HEADER_SIZE], Vec), Error>(( serialized_headers_len, @@ -423,6 +406,7 @@ async fn run_request( match result { Ok((serialized_headers_len, response_headers, response_body)) => { if !matches!(guard.state, State::InFlight) { + log::warning!("https_client: state is not InFlight, returning early"); return; } @@ -435,11 +419,6 @@ async fn run_request( guard.response_body_cursor = 0; guard.state = State::HeadersReady; - - log::information!( - "https_client: request complete, headers ready len={}", - serialized_headers_len - ); } Err(error) => { if matches!(guard.state, State::InFlight) { @@ -450,9 +429,15 @@ async fn run_request( } } -pub struct HttpsClientDevice; +pub struct HttpsClientDevice(&'static T); -impl BaseOperations for HttpsClientDevice { +impl HttpsClientDevice { + pub const fn new(random_device: &'static T) -> Self { + Self(random_device) + } +} + +impl BaseOperations for HttpsClientDevice { fn open(&self, context: &mut Context) -> Result<()> { context.set_private_data(Box::new(HttpsClientContext::new())); Ok(()) @@ -476,7 +461,6 @@ impl BaseOperations for HttpsClientDevice { } fn write(&self, context: &mut Context, buffer: &[u8], _: Size) -> Result { - log::information!("https_client: write called size={}", buffer.len()); let context = context .get_private_data_mutable_of_type::() .ok_or(Error::InvalidParameter)?; @@ -489,12 +473,14 @@ impl BaseOperations for HttpsClientDevice { let task_manager = task::get_instance(); let parent = task::Manager::ROOT_TASK_IDENTIFIER; + let random_device = self.0; + if let Err(spawn_error) = task::block_on( task_manager.spawn(parent, "HTTPS request worker", None, move |_| { let inner_clone = inner.clone(); let request_owned = request; - async move { run_request(inner_clone, request_owned).await } + async move { run_request(inner_clone, request_owned, random_device).await } }), ) { @@ -507,7 +493,6 @@ impl BaseOperations for HttpsClientDevice { return Err(Error::RessourceBusy); } - log::information!("https_client: write submitted successfully"); Ok(buffer.len()) } @@ -522,9 +507,9 @@ impl BaseOperations for HttpsClientDevice { } } -impl MountOperations for HttpsClientDevice {} +impl MountOperations for HttpsClientDevice {} -impl CharacterDevice for HttpsClientDevice {} +impl CharacterDevice for HttpsClientDevice {} fn write_state_gate(context: &mut HttpsClientContext) -> Result<()> { let mut inner = task::block_on(context.inner.lock()); @@ -570,7 +555,11 @@ fn read_state_transition(context: &mut HttpsClientContext, buffer: &mut [u8]) -> } State::BodyStreaming => { if inner.response_body_cursor >= inner.response_body.len() { - inner.reset(); + // Only reset if we actually had a body (headers_len > 0). + // If headers_len is 0, we're being called prematurely during request setup. + if inner.response_headers_len > 0 { + inner.reset(); + } return Ok(0); } From 0b6af42d38f151e3742f5231631f13be83aa2742 Mon Sep 17 00:00:00 2001 From: Alix ANNERAUD Date: Sun, 10 May 2026 15:07:54 +0200 Subject: [PATCH 13/15] feat: add HTTPS client device initialization and mounting in main and testing modules --- examples/native/src/main.rs | 6 +++++- modules/testing/src/lib.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/examples/native/src/main.rs b/examples/native/src/main.rs index e8eb05cd..a611883b 100644 --- a/examples/native/src/main.rs +++ b/examples/native/src/main.rs @@ -124,6 +124,10 @@ async fn main() { log::information!("Default hierarchy created."); + static HTTP_CLIENT_DEVICE: drivers_shared::devices::HttpsClientDevice< + drivers_shared::devices::RandomDevice, + > = drivers_shared::devices::HttpsClientDevice::new(&drivers_shared::devices::RandomDevice); + mount_static!( virtual_file_system, task, @@ -167,7 +171,7 @@ async fn main() { ( &"/devices/https_client", CharacterDevice, - drivers_shared::devices::HttpsClientDevice + HTTP_CLIENT_DEVICE ), ( &"/devices/hasher", diff --git a/modules/testing/src/lib.rs b/modules/testing/src/lib.rs index 6e53dbde..12f60dfa 100644 --- a/modules/testing/src/lib.rs +++ b/modules/testing/src/lib.rs @@ -172,6 +172,24 @@ pub async fn initialize(graphics_enabled: bool, network_enabled: bool) -> Standa .await .unwrap(); + if network_enabled { + static HTTPS_CLIENT_DEVICE: drivers_shared::devices::HttpsClientDevice< + drivers_shared::devices::RandomDevice, + > = drivers_shared::devices::HttpsClientDevice::new(&drivers_shared::devices::RandomDevice); + + mount_static!( + virtual_file_system, + task, + &[( + &"/devices/https_client", + CharacterDevice, + HTTPS_CLIENT_DEVICE + )] + ) + .await + .unwrap(); + } + let group_identifier = GroupIdentifier::new(1000); authentication::create_group(virtual_file_system, "administrator", Some(group_identifier)) From 294e905180d306ceed4d7226c5e8ecd42fac4845 Mon Sep 17 00:00:00 2001 From: Alix ANNERAUD Date: Sun, 10 May 2026 15:08:17 +0200 Subject: [PATCH 14/15] feat(weather): add weather executable with API integration - Introduced a new executable for weather functionality. - Added Cargo.toml configuration for the weather module. - Implemented API calls to Open Meteo for geocoding and weather forecasts. - Created data models for handling API responses. - Developed formatting functions for displaying weather data in the UI. - Implemented a user interface for city input and weather display. - Added tests for API response parsing and UI behavior. - Included installation script for creating a shortcut to the weather executable. --- Cargo.toml | 2 + executables/weather/Cargo.toml | 36 +++ executables/weather/README.md | 19 ++ executables/weather/src/api.rs | 49 ++++ executables/weather/src/docs.rs | 9 + executables/weather/src/format.rs | 164 ++++++++++++++ executables/weather/src/install.rs | 20 ++ executables/weather/src/interface.rs | 181 +++++++++++++++ executables/weather/src/lib.rs | 14 ++ executables/weather/src/main.rs | 18 ++ executables/weather/src/model.rs | 85 +++++++ executables/weather/src/net.rs | 282 ++++++++++++++++++++++++ executables/weather/src/state.rs | 177 +++++++++++++++ executables/weather/src/trigger.rs | 31 +++ executables/weather/tests/build_test.rs | 17 ++ executables/weather/tests/test.rs | 59 +++++ 16 files changed, 1163 insertions(+) create mode 100644 executables/weather/Cargo.toml create mode 100644 executables/weather/README.md create mode 100644 executables/weather/src/api.rs create mode 100644 executables/weather/src/docs.rs create mode 100644 executables/weather/src/format.rs create mode 100644 executables/weather/src/install.rs create mode 100644 executables/weather/src/interface.rs create mode 100644 executables/weather/src/lib.rs create mode 100644 executables/weather/src/main.rs create mode 100644 executables/weather/src/model.rs create mode 100644 executables/weather/src/net.rs create mode 100644 executables/weather/src/state.rs create mode 100644 executables/weather/src/trigger.rs create mode 100644 executables/weather/tests/build_test.rs create mode 100644 executables/weather/tests/test.rs diff --git a/Cargo.toml b/Cargo.toml index caffc6c1..e92336ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,6 +112,7 @@ wasm = { path = "executables/wasm" } terminal = { path = "executables/terminal" } settings = { path = "executables/settings" } calculator = { path = "executables/calculator" } +weather = { path = "executables/weather" } # - External crates miniserde = { version = "0.1" } @@ -183,6 +184,7 @@ members = [ "modules/graphics/fonts_generator", "modules/testing", "modules/device", + "executables/weather", ] [patch.crates-io] diff --git a/executables/weather/Cargo.toml b/executables/weather/Cargo.toml new file mode 100644 index 00000000..59298430 --- /dev/null +++ b/executables/weather/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "weather" +version = "0.1.0" +edition = "2024" + +[dependencies] +internationalization = { workspace = true } +miniserde = { workspace = true } +shared = { workspace = true } +task = { workspace = true } +virtual_file_system = { workspace = true } +file_system = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm = { workspace = true, features = ["guest", "graphics"] } + +[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))'.dev-dependencies] +xila = { path = "../../", features = [ + "host", + "executable_building", + "graphics_rendering_xrgb8888", +] } +drivers_core = { workspace = true } +drivers_std = { workspace = true } +drivers_shared = { workspace = true } +drivers_native = { workspace = true } +testing = { workspace = true } +wasm = { workspace = true, features = ["default_host"] } + +[[test]] +name = "build_test" +path = "tests/build_test.rs" + +[[test]] +name = "weather_test" +path = "tests/test.rs" diff --git a/executables/weather/README.md b/executables/weather/README.md new file mode 100644 index 00000000..b8ab0f3d --- /dev/null +++ b/executables/weather/README.md @@ -0,0 +1,19 @@ +# Weather executable + +Weather demo app for Xila WASM runtime. + +## Data sources + +- Geocoding: `https://geocoding-api.open-meteo.com/v1/search` +- Forecast: `https://api.open-meteo.com/v1/forecast` + +## Runtime path + +Uses internal HTTPS device mounted at `/devices/https_client`. + +## UX behavior + +- Enter city name +- Press Refresh +- Auto-picks first geocoding result +- Displays Current, Hourly, Daily, and Meta tabs diff --git a/executables/weather/src/api.rs b/executables/weather/src/api.rs new file mode 100644 index 00000000..6b670124 --- /dev/null +++ b/executables/weather/src/api.rs @@ -0,0 +1,49 @@ +use alloc::{format, string::String}; + +fn encode_city(value: &str) -> String { + value.replace(' ', "+") +} + +pub fn build_geocoding_url(city: &str) -> String { + format!( + "https://geocoding-api.open-meteo.com/v1/search?name={}&count=10&language=en&format=json", + encode_city(city) + ) +} + +pub fn build_forecast_url(latitude: f64, longitude: f64) -> String { + let current = "temperature_2m,apparent_temperature,relative_humidity_2m,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m"; + let hourly = "temperature_2m,precipitation_probability,wind_speed_10m"; + let daily = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,sunrise,sunset"; + + format!( + "https://api.open-meteo.com/v1/forecast?latitude={latitude:.4}&longitude={longitude:.4}¤t={current}&hourly={hourly}&daily={daily}&forecast_days=10&timezone=auto" + ) +} + +#[cfg(test)] +mod tests { + use super::{build_forecast_url, build_geocoding_url}; + + #[test] + fn geocoding_url_contains_required_params() { + let url = build_geocoding_url("Paris"); + assert!(url.starts_with("https://geocoding-api.open-meteo.com/v1/search?")); + assert!(url.contains("name=Paris")); + assert!(url.contains("count=10")); + assert!(url.contains("language=en")); + assert!(url.contains("format=json")); + } + + #[test] + fn forecast_url_contains_demo_rich_fields() { + let url = build_forecast_url(48.8566, 2.3522); + assert!(url.starts_with("https://api.open-meteo.com/v1/forecast?")); + assert!(url.contains("latitude=48.8566")); + assert!(url.contains("longitude=2.3522")); + assert!(url.contains("current=")); + assert!(url.contains("hourly=")); + assert!(url.contains("daily=")); + assert!(url.contains("timezone=auto")); + } +} diff --git a/executables/weather/src/docs.rs b/executables/weather/src/docs.rs new file mode 100644 index 00000000..26565766 --- /dev/null +++ b/executables/weather/src/docs.rs @@ -0,0 +1,9 @@ +#[cfg(test)] +mod tests { + #[test] + fn readme_mentions_open_meteo_endpoints() { + let text = include_str!("../README.md"); + assert!(text.contains("geocoding-api.open-meteo.com")); + assert!(text.contains("api.open-meteo.com/v1/forecast")); + } +} diff --git a/executables/weather/src/format.rs b/executables/weather/src/format.rs new file mode 100644 index 00000000..a8447d6b --- /dev/null +++ b/executables/weather/src/format.rs @@ -0,0 +1,164 @@ +use alloc::{format, string::String}; + +use crate::model::{CurrentWeather, DailyWeather, ForecastResponse, HourlyWeather}; + +pub fn format_current_tab(current: Option<&CurrentWeather>) -> String { + let Some(c) = current else { + return String::from("No current data."); + }; + + format!( + "Time: {}\nTemperature: {:.1} C\nFeels like: {:.1} C\nHumidity: {}%\nPrecipitation: {:.1} mm\nWeather code: {}\nWind: {:.1} km/h @ {} deg\nWind gusts: {:.1} km/h", + c.time.as_deref().unwrap_or("?"), + c.temperature_2m.unwrap_or(0.0), + c.apparent_temperature.unwrap_or(0.0), + c.relative_humidity_2m.unwrap_or(0), + c.precipitation.unwrap_or(0.0), + c.weather_code.unwrap_or(0), + c.wind_speed_10m.unwrap_or(0.0), + c.wind_direction_10m.unwrap_or(0), + c.wind_gusts_10m.unwrap_or(0.0) + ) +} + +pub fn format_hourly_tab(hourly: Option<&HourlyWeather>) -> String { + let Some(h) = hourly else { + return String::from("No hourly data."); + }; + + let times = h.time.as_ref().map(|v| v.as_slice()).unwrap_or(&[]); + let temps = h + .temperature_2m + .as_ref() + .map(|v| v.as_slice()) + .unwrap_or(&[]); + let probs = h + .precipitation_probability + .as_ref() + .map(|v| v.as_slice()) + .unwrap_or(&[]); + let winds = h + .wind_speed_10m + .as_ref() + .map(|v| v.as_slice()) + .unwrap_or(&[]); + + let mut out = String::from("Next 24h\n"); + let count = core::cmp::min(times.len(), 24); + + for i in 0..count { + let temp = temps.get(i).copied().unwrap_or_default(); + let prob = probs.get(i).copied().unwrap_or_default(); + let wind = winds.get(i).copied().unwrap_or_default(); + out.push_str(&format!( + "{} | {:.1} C | {}% rain | {:.1} km/h\n", + times[i], temp, prob, wind + )); + } + + out +} + +pub fn format_daily_tab(daily: Option<&DailyWeather>) -> String { + let Some(d) = daily else { + return String::from("No daily data."); + }; + + let times = d.time.as_ref().map(|v| v.as_slice()).unwrap_or(&[]); + let maxs = d + .temperature_2m_max + .as_ref() + .map(|v| v.as_slice()) + .unwrap_or(&[]); + let mins = d + .temperature_2m_min + .as_ref() + .map(|v| v.as_slice()) + .unwrap_or(&[]); + let weather = d.weather_code.as_ref().map(|v| v.as_slice()).unwrap_or(&[]); + let precip = d + .precipitation_probability_max + .as_ref() + .map(|v| v.as_slice()) + .unwrap_or(&[]); + let sunrise = d.sunrise.as_ref().map(|v| v.as_slice()).unwrap_or(&[]); + let sunset = d.sunset.as_ref().map(|v| v.as_slice()).unwrap_or(&[]); + + let mut out = String::from("Next days\n"); + for i in 0..times.len().min(10) { + out.push_str(&format!( + "{} | min {:.1} / max {:.1} C | code {} | rain {}%\n sunrise {} sunset {}\n", + times[i], + mins.get(i).copied().unwrap_or_default(), + maxs.get(i).copied().unwrap_or_default(), + weather.get(i).copied().unwrap_or_default(), + precip.get(i).copied().unwrap_or_default(), + sunrise.get(i).map(|s| s.as_str()).unwrap_or("?"), + sunset.get(i).map(|s| s.as_str()).unwrap_or("?"), + )); + } + + out +} + +pub fn format_meta_tab( + city: &str, + latitude: f64, + longitude: f64, + forecast: &ForecastResponse, +) -> String { + format!( + "Location: {}\nLatitude: {:.4}\nLongitude: {:.4}\nTimezone: {} ({})\nElevation: {:.1} m", + city, + latitude, + longitude, + forecast.timezone.as_deref().unwrap_or("?"), + forecast.timezone_abbreviation.as_deref().unwrap_or("?"), + forecast.elevation.unwrap_or(0.0) + ) +} + +#[cfg(test)] +mod tests { + use super::{format_current_tab, format_daily_tab, format_hourly_tab, format_meta_tab}; + use crate::model::{CurrentWeather, ForecastResponse}; + + #[test] + fn current_tab_includes_key_fields() { + let current = CurrentWeather { + time: Some("2026-03-29T12:00".into()), + temperature_2m: Some(18.2), + apparent_temperature: Some(17.4), + relative_humidity_2m: Some(47), + precipitation: Some(0.0), + weather_code: Some(1), + wind_speed_10m: Some(12.3), + wind_direction_10m: Some(230), + wind_gusts_10m: Some(20.5), + }; + let text = format_current_tab(Some(¤t)); + assert!(text.contains("Temperature")); + assert!(text.contains("Wind")); + } + + #[test] + fn meta_tab_includes_timezone_and_elevation() { + let forecast = ForecastResponse { + timezone: Some("Europe/Paris".into()), + timezone_abbreviation: Some("CEST".into()), + elevation: Some(36.0), + current: None, + hourly: None, + daily: None, + }; + let text = format_meta_tab("Paris", 48.85, 2.35, &forecast); + assert!(text.contains("Europe/Paris")); + assert!(text.contains("Elevation")); + } + + #[test] + fn hourly_and_daily_handle_missing_data() { + assert!(format_hourly_tab(None).contains("No hourly data")); + assert!(format_daily_tab(None).contains("No daily data")); + } +} diff --git a/executables/weather/src/install.rs b/executables/weather/src/install.rs new file mode 100644 index 00000000..174e2a63 --- /dev/null +++ b/executables/weather/src/install.rs @@ -0,0 +1,20 @@ +use std::fs; + +pub static SHORTCUT: &str = r#" +{ + "name": "Weather", + "command": "/binaries/wasm", + "arguments": ["/binaries/weather"], + "terminal": false, + "icon_string": "We", + "icon_color": [33, 150, 243] +}"#; + +pub const SHORTCUT_PATH: &str = "/configuration/shared/shortcuts/weather.json"; + +#[unsafe(no_mangle)] +pub extern "C" fn __install() { + println!("Installing Weather shortcut at {}", SHORTCUT_PATH); + fs::write(SHORTCUT_PATH, SHORTCUT).unwrap(); + println!("Weather shortcut installed."); +} diff --git a/executables/weather/src/interface.rs b/executables/weather/src/interface.rs new file mode 100644 index 00000000..edaecd2f --- /dev/null +++ b/executables/weather/src/interface.rs @@ -0,0 +1,181 @@ +use core::ffi::CStr; +use core::ptr::null_mut; +use std::string::ToString; +use std::thread::sleep; + +use wasm::{ + EventCode, FlexFlow, Object, button_create, label_create, label_set_text, object_create, + object_set_flex_flow, object_set_flex_grow, object_set_height, object_set_width, percentage, + size_content, tabview_add_tab, tabview_create, textarea_create, textarea_get_text, + textarea_get_text_length, textarea_set_one_line, window_create, window_pop_event, +}; + +use weather::{ + format::{format_current_tab, format_daily_tab, format_hourly_tab, format_meta_tab}, + state::{fetch_weather, map_status_message}, + trigger::should_refresh, +}; + +pub struct Interface { + window: *mut Object, + city_input: *mut Object, + refresh_button: *mut Object, + status_label: *mut Object, + current_label: *mut Object, + hourly_label: *mut Object, + daily_label: *mut Object, + meta_label: *mut Object, + in_flight: bool, + buffer: Vec, +} + +impl Interface { + pub fn new() -> wasm::Result { + unsafe { + let window = window_create()?; + object_set_flex_flow(window, FlexFlow::Column)?; + + let search_row = object_create(window)?; + object_set_flex_flow(search_row, FlexFlow::Row)?; + object_set_width(search_row, percentage(100)?)?; + object_set_height(search_row, size_content())?; + + let city_input = textarea_create(search_row)?; + textarea_set_one_line(city_input, true)?; + object_set_flex_grow(city_input, 1)?; + + let refresh_button = button_create(search_row)?; + let refresh_label = label_create(refresh_button)?; + label_set_text(refresh_label, c"Refresh".as_ptr() as *mut _)?; + + let status_label = label_create(window)?; + label_set_text(status_label, c"Ready".as_ptr() as *mut _)?; + + let tabs = tabview_create(window)?; + object_set_width(tabs, percentage(100)?)?; + object_set_flex_grow(tabs, 1)?; + + let current_tab = tabview_add_tab(tabs, c"Current".as_ptr())?; + let hourly_tab = tabview_add_tab(tabs, c"Hourly".as_ptr())?; + let daily_tab = tabview_add_tab(tabs, c"Daily".as_ptr())?; + let meta_tab = tabview_add_tab(tabs, c"Meta".as_ptr())?; + + let current_label = label_create(current_tab)?; + let hourly_label = label_create(hourly_tab)?; + let daily_label = label_create(daily_tab)?; + let meta_label = label_create(meta_tab)?; + + Ok(Self { + window, + city_input, + refresh_button, + status_label, + current_label, + hourly_label, + daily_label, + meta_label, + in_flight: false, + buffer: Vec::new(), + }) + } + } + + fn set_label_text(&self, label: *mut Object, text: &str) { + let mut owned = text.to_string(); + owned.push('\0'); + + unsafe { + let _ = label_set_text(label, owned.as_ptr() as *mut _); + } + } + + fn set_status(&self, text: &str) { + self.set_label_text(self.status_label, text); + } + + unsafe fn on_refresh(&mut self) { + if self.in_flight { + return; + } + self.in_flight = true; + + let text_length = unsafe { + textarea_get_text_length(self.city_input).expect("Failed to get textarea text length") + }; + + self.buffer.clear(); + self.buffer.reserve(text_length as usize + 1); // +1 for null terminator + + unsafe { + textarea_get_text( + self.city_input, + self.buffer.as_mut_ptr(), + self.buffer.capacity(), + ) + .expect("Failed to get textarea text"); + } + + let city = unsafe { CStr::from_ptr(self.buffer.as_ptr()).to_string_lossy() }; + + let city = city.trim(); + if city.is_empty() { + self.set_status("Enter a city name"); + self.in_flight = false; + return; + } + + self.set_status("Loading..."); + + match fetch_weather(city) { + Ok(data) => { + let current = format_current_tab(data.forecast.current.as_ref()); + let hourly = format_hourly_tab(data.forecast.hourly.as_ref()); + let daily = format_daily_tab(data.forecast.daily.as_ref()); + let meta = format_meta_tab( + &data.city_label, + data.latitude, + data.longitude, + &data.forecast, + ); + + self.set_label_text(self.current_label, ¤t); + self.set_label_text(self.hourly_label, &hourly); + self.set_label_text(self.daily_label, &daily); + self.set_label_text(self.meta_label, &meta); + self.set_status("Updated"); + } + Err(error) => { + let message = map_status_message(&error); + self.set_status(&message); + } + } + + self.in_flight = false; + } + + pub unsafe fn run(&mut self) { + loop { + let mut code = EventCode::All; + let mut target: *mut Object = null_mut(); + let _ = unsafe { + window_pop_event( + self.window, + &mut code as *mut _ as *mut _, + &mut target as *mut _ as *mut _, + ) + }; + + if should_refresh( + code as u32, + target == self.refresh_button, + target == self.city_input, + ) { + unsafe { + self.on_refresh(); + } + } else if code == EventCode::All { + sleep(core::time::Duration::from_millis(10)); + } + } + } +} diff --git a/executables/weather/src/lib.rs b/executables/weather/src/lib.rs new file mode 100644 index 00000000..2fb4e765 --- /dev/null +++ b/executables/weather/src/lib.rs @@ -0,0 +1,14 @@ +#![no_std] + +extern crate alloc; +#[cfg(target_arch = "wasm32")] +extern crate std; + +pub mod api; +#[cfg(test)] +mod docs; +pub mod format; +pub mod model; +pub mod net; +pub mod state; +pub mod trigger; diff --git a/executables/weather/src/main.rs b/executables/weather/src/main.rs new file mode 100644 index 00000000..1126e095 --- /dev/null +++ b/executables/weather/src/main.rs @@ -0,0 +1,18 @@ +#[cfg(any(target_arch = "wasm32", test))] +mod install; + +#[cfg(target_arch = "wasm32")] +mod interface; + +#[cfg(target_arch = "wasm32")] +fn main() { + let mut interface = interface::Interface::new().unwrap(); + unsafe { + interface.run(); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn main() { + println!("This executable is intended to be run in a WASM environment."); +} diff --git a/executables/weather/src/model.rs b/executables/weather/src/model.rs new file mode 100644 index 00000000..0f9a7b61 --- /dev/null +++ b/executables/weather/src/model.rs @@ -0,0 +1,85 @@ +use alloc::{string::String, vec::Vec}; +use miniserde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct GeocodingResponse { + pub results: Option>, + pub generationtime_ms: Option, +} + +#[derive(Debug, Deserialize)] +pub struct GeocodingResult { + pub name: String, + pub country: Option, + pub timezone: Option, + pub latitude: f64, + pub longitude: f64, +} + +#[derive(Debug, Deserialize)] +pub struct ForecastResponse { + pub timezone: Option, + pub timezone_abbreviation: Option, + pub elevation: Option, + pub current: Option, + pub hourly: Option, + pub daily: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CurrentWeather { + pub time: Option, + pub temperature_2m: Option, + pub apparent_temperature: Option, + pub relative_humidity_2m: Option, + pub precipitation: Option, + pub weather_code: Option, + pub wind_speed_10m: Option, + pub wind_direction_10m: Option, + pub wind_gusts_10m: Option, +} + +#[derive(Debug, Deserialize)] +pub struct HourlyWeather { + pub time: Option>, + pub temperature_2m: Option>, + pub precipitation_probability: Option>, + pub wind_speed_10m: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct DailyWeather { + pub time: Option>, + pub temperature_2m_max: Option>, + pub temperature_2m_min: Option>, + pub weather_code: Option>, + pub precipitation_probability_max: Option>, + pub sunrise: Option>, + pub sunset: Option>, +} + +#[cfg(test)] +mod tests { + use miniserde::json; + + use super::{ForecastResponse, GeocodingResponse}; + + #[test] + fn parses_geocoding_first_result_fields() { + let raw = r#"{"results":[{"name":"Paris","latitude":48.8566,"longitude":2.3522,"country":"France","timezone":"Europe/Paris"}],"generationtime_ms":0.7}"#; + let parsed: GeocodingResponse = json::from_str(raw).unwrap(); + let first = parsed.results.as_ref().unwrap().first().unwrap(); + assert_eq!(first.name, "Paris"); + assert!((first.latitude - 48.8566).abs() < 0.01); + } + + #[test] + fn parses_forecast_core_blocks() { + let raw = r#"{"timezone":"Europe/Paris","timezone_abbreviation":"CEST","elevation":36.0,"current":{"time":"2026-03-29T12:00","temperature_2m":18.2,"relative_humidity_2m":47,"apparent_temperature":17.4,"precipitation":0.0,"weather_code":1,"wind_speed_10m":12.3,"wind_direction_10m":230},"hourly":{"time":["2026-03-29T12:00"],"temperature_2m":[18.2],"precipitation_probability":[5],"wind_speed_10m":[12.3]},"daily":{"time":["2026-03-29"],"temperature_2m_max":[20.0],"temperature_2m_min":[11.0],"weather_code":[1],"sunrise":["2026-03-29T07:19"],"sunset":["2026-03-29T20:08"]}}"#; + let parsed: ForecastResponse = json::from_str(raw).unwrap(); + assert_eq!(parsed.timezone.as_deref(), Some("Europe/Paris")); + assert!(parsed.current.is_some()); + assert!(parsed.hourly.is_some()); + assert!(parsed.daily.is_some()); + } +} diff --git a/executables/weather/src/net.rs b/executables/weather/src/net.rs new file mode 100644 index 00000000..54a5c23d --- /dev/null +++ b/executables/weather/src/net.rs @@ -0,0 +1,282 @@ +#[cfg(target_arch = "wasm32")] +use alloc::{format, string::String, vec, vec::Vec}; +use shared::HttpResponseParser; +#[cfg(target_arch = "wasm32")] +use shared::{HttpRequestBuilder, Url}; +#[cfg(target_arch = "wasm32")] +use std::fs::OpenOptions; +#[cfg(target_arch = "wasm32")] +use std::io::{ErrorKind, Read, Write}; +#[cfg(target_arch = "wasm32")] +use std::println; +#[cfg(target_arch = "wasm32")] +use std::thread::{sleep, yield_now}; + +#[cfg(target_arch = "wasm32")] +const RESOURCE_BUSY_OS_ERROR: i32 = 277; + +#[cfg(target_arch = "wasm32")] +const HTTPS_READ_MAX_RETRIES: usize = 16_384; + +#[cfg(target_arch = "wasm32")] +fn is_transient_read_error(error: &std::io::Error) -> bool { + matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::Interrupted) + || error.raw_os_error() == Some(RESOURCE_BUSY_OS_ERROR) +} + +#[cfg(target_arch = "wasm32")] +fn read_with_retry( + file: &mut std::fs::File, + buffer: &mut [u8], + error_label: &str, +) -> Result { + for attempt in 0..HTTPS_READ_MAX_RETRIES { + match file.read(buffer) { + Ok(count) => return Ok(count), + Err(error) if is_transient_read_error(&error) => { + yield_now(); + + if attempt % 64 == 0 { + sleep(std::time::Duration::from_millis(1)); + } + } + Err(error) => { + return Err(String::from(error_label)); + } + } + } + + Err(String::from("timed out waiting for https response")) +} + +pub fn split_headers_body(input: &[u8]) -> Option<(&[u8], &[u8])> { + let marker = b"\r\n\r\n"; + let index = input.windows(marker.len()).position(|w| w == marker)?; + Some(( + &input[..index + marker.len()], + &input[index + marker.len()..], + )) +} + +pub fn extract_http_status(headers: &[u8]) -> Option { + HttpResponseParser::from_buffer(headers).get_status_code() +} + +#[cfg(target_arch = "wasm32")] +fn extract_content_length(headers: &[u8]) -> Option { + HttpResponseParser::from_buffer(headers) + .get_headers() + .find(|(name, _)| *name == "Content-Length") + .and_then(|(_, value)| value.trim().parse::().ok()) +} + +#[cfg(target_arch = "wasm32")] +fn has_chunked_encoding(headers: &[u8]) -> bool { + let parser = HttpResponseParser::from_buffer(headers); + let headers_iter = parser.get_headers(); + + for (name, value) in headers_iter { + if name.eq_ignore_ascii_case("Transfer-Encoding") { + let is_chunked = value.contains("chunked"); + + return is_chunked; + } + } + + false +} + +#[cfg(target_arch = "wasm32")] +fn decode_chunked_body(body: &[u8]) -> Result, String> { + let mut decoded = Vec::new(); + let mut pos = 0; + let mut chunk_count = 0; + + while pos < body.len() { + // Find the line ending for chunk size (look for \r\n) + let mut line_end = pos; + while line_end + 1 < body.len() { + if body[line_end] == b'\r' && body[line_end + 1] == b'\n' { + break; + } + line_end += 1; + } + + if line_end + 1 >= body.len() { + // No line ending found + break; + } + + // Parse chunk size from bytes + let size_bytes = &body[pos..line_end]; + let size_str = core::str::from_utf8(size_bytes) + .map_err(|_| String::from("invalid utf8 in chunk size"))?; + let chunk_size = usize::from_str_radix(size_str.trim(), 16) + .map_err(|_| String::from("invalid chunk size format"))?; + + if chunk_size == 0 { + // Last chunk reached + + break; + } + + // Move past the chunk size line and \r\n + pos = line_end + 2; + + // Read chunk data + if pos + chunk_size > body.len() { + return Err(String::from("chunk data extends beyond body")); + } + + decoded.extend_from_slice(&body[pos..pos + chunk_size]); + pos += chunk_size; + + // Skip trailing \r\n after chunk data + if pos + 1 < body.len() && body[pos] == b'\r' && body[pos + 1] == b'\n' { + pos += 2; + } + + chunk_count += 1; + } + + Ok(decoded) +} + +#[cfg(target_arch = "wasm32")] +pub fn https_get(url: &str) -> Result, String> { + let parsed = Url::parse(url).ok_or_else(|| String::from("invalid url"))?; + + let mut request_buffer = vec![0u8; 4096]; + let request_length = { + let mut builder = HttpRequestBuilder::from_buffer(&mut request_buffer); + builder + .add_request("GET", parsed.path, HttpRequestBuilder::HTTP_VERSION_1_1) + .ok_or_else(|| String::from("request line overflow"))? + .add_header("Host", parsed.host.as_bytes()) + .ok_or_else(|| String::from("host header overflow"))? + .add_header("Connection", b"close") + .ok_or_else(|| String::from("connection header overflow"))? + .add_header("Accept", b"application/json") + .ok_or_else(|| String::from("accept header overflow"))? + .add_body(b"") + .ok_or_else(|| String::from("request finalization overflow"))?; + builder.get_position() + }; + + let mut file = OpenOptions::new() + .read(true) + .write(true) + .open("/devices/https_client") + .map_err(|e| String::from("failed to open https device"))?; + + file.write_all(&request_buffer[..request_length]) + .map_err(|_| String::from("failed to write request"))?; + + // Read headers first (device returns headers, then transitions to body streaming) + let mut headers_buffer = vec![0u8; 2048]; + let headers_len = read_with_retry( + &mut file, + &mut headers_buffer, + "failed to read response headers", + )?; + + if headers_len == 0 { + return Err(String::from("empty response")); + } + + let status = extract_http_status(&headers_buffer[..headers_len]) + .ok_or_else(|| String::from("missing status in response headers"))?; + if status != 200 { + return Err(format!("api error: {status}")); + } + + let expected_body_length = extract_content_length(&headers_buffer[..headers_len]); + + // Read body from subsequent reads + let mut body = Vec::new(); + let mut chunk = [0u8; 4096]; + let mut retry_count = 0usize; + loop { + if let Some(expected_length) = expected_body_length { + if body.len() >= expected_length { + break; + } + } + + let count = read_with_retry(&mut file, &mut chunk, "failed to read body")?; + if count == 0 { + if let Some(expected_length) = expected_body_length { + if body.len() < expected_length { + retry_count += 1; + if retry_count >= HTTPS_READ_MAX_RETRIES { + return Err(String::from("timed out waiting for https response body")); + } + + yield_now(); + continue; + } + } + + break; + } + retry_count = 0; + body.extend_from_slice(&chunk[..count]); + } + + // Decode chunked transfer encoding if present + let final_body = if has_chunked_encoding(&headers_buffer[..headers_len]) { + let decoded = decode_chunked_body(&body)?; + + decoded + } else { + body + }; + + Ok(final_body) +} + +#[cfg(test)] +mod tests { + use super::{extract_http_status, split_headers_body}; + + #[test] + fn split_headers_and_body_works() { + let bytes = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"ok\":true}"; + let (headers, body) = split_headers_body(bytes).unwrap(); + assert!(core::str::from_utf8(headers).unwrap().contains("200")); + assert_eq!(body, b"{\"ok\":true}"); + } + + #[test] + fn extracts_status_code() { + let headers = b"HTTP/1.1 404 Not Found\r\nX-Test: 1\r\n\r\n"; + assert_eq!(extract_http_status(headers), Some(404)); + } + + #[test] + #[cfg(target_arch = "wasm32")] + fn detects_chunked_encoding() { + use super::has_chunked_encoding; + let headers = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Type: application/json\r\n\r\n"; + assert!(has_chunked_encoding(headers)); + } + + #[test] + #[cfg(target_arch = "wasm32")] + fn detects_no_chunked_encoding() { + use super::has_chunked_encoding; + let headers = + b"HTTP/1.1 200 OK\r\nContent-Length: 1234\r\nContent-Type: application/json\r\n\r\n"; + assert!(!has_chunked_encoding(headers)); + } + + #[test] + #[cfg(target_arch = "wasm32")] + fn exposes_sync_https_get_api() { + use alloc::{string::String, vec::Vec}; + + use super::https_get; + + let _: fn(&str) -> Result, String> = https_get; + } +} diff --git a/executables/weather/src/state.rs b/executables/weather/src/state.rs new file mode 100644 index 00000000..43ae9563 --- /dev/null +++ b/executables/weather/src/state.rs @@ -0,0 +1,177 @@ +use crate::model::{ForecastResponse, GeocodingResponse, GeocodingResult}; +use alloc::string::String; + +#[cfg(target_arch = "wasm32")] +use std::println; + +#[derive(Debug, Clone)] +pub enum AppError { + NoLocationFound, + Network, + Parse, + ApiError(u16), +} + +pub fn map_status_message(error: &AppError) -> String { + match error { + AppError::NoLocationFound => String::from("No location found"), + AppError::Network => String::from("Network error"), + AppError::Parse => String::from("Invalid API response"), + AppError::ApiError(code) => alloc::format!("API error: {code}"), + } +} + +pub struct WeatherData { + pub city_label: String, + pub latitude: f64, + pub longitude: f64, + pub forecast: ForecastResponse, +} + +#[cfg(target_arch = "wasm32")] +type FetchFn = fn(&str) -> Result, alloc::string::String>; + +#[allow(dead_code)] +fn pick_best_location(response: GeocodingResponse) -> Option { + response + .results + .and_then(|results| results.into_iter().next()) +} + +#[cfg(target_arch = "wasm32")] +pub fn fetch_weather(city: &str) -> Result { + fetch_weather_with(city, crate::net::https_get) +} + +#[cfg(target_arch = "wasm32")] +fn fetch_weather_with(city: &str, https_get: FetchFn) -> Result { + use miniserde::json; + + use crate::api::{build_forecast_url, build_geocoding_url}; + + #[cfg(target_arch = "wasm32")] + fn log_parse_preview(label: &str, bytes: &[u8]) { + let preview_len = bytes.len().min(256); + let preview = core::str::from_utf8(&bytes[..preview_len]).unwrap_or(""); + println!( + "{} response preview ({} bytes): {}", + label, + bytes.len(), + preview + ); + } + + let geocoding_url = build_geocoding_url(city); + let geo_bytes = https_get(&geocoding_url).map_err(|error| { + if let Some(status) = parse_api_error_status(&error) { + AppError::ApiError(status) + } else { + AppError::Network + } + })?; + let geo_text = core::str::from_utf8(&geo_bytes).map_err(|_| AppError::Parse)?; + let geo: GeocodingResponse = json::from_str(geo_text).map_err(|_| { + #[cfg(target_arch = "wasm32")] + log_parse_preview("geocoding", &geo_bytes); + AppError::Parse + })?; + let best = pick_best_location(geo).ok_or(AppError::NoLocationFound)?; + + let forecast_url = build_forecast_url(best.latitude, best.longitude); + + let mut forecast_bytes = https_get(&forecast_url).map_err(|error| { + if let Some(status) = parse_api_error_status(&error) { + AppError::ApiError(status) + } else { + AppError::Network + } + }); + + if forecast_bytes.is_err() { + forecast_bytes = https_get(&forecast_url).map_err(|error| { + if let Some(status) = parse_api_error_status(&error) { + AppError::ApiError(status) + } else { + AppError::Network + } + }); + } + + let forecast_bytes = forecast_bytes?; + let forecast_text = core::str::from_utf8(&forecast_bytes).map_err(|_| AppError::Parse)?; + let forecast: ForecastResponse = json::from_str(forecast_text).map_err(|_| { + #[cfg(target_arch = "wasm32")] + log_parse_preview("forecast", &forecast_bytes); + AppError::Parse + })?; + + let city_label = if let Some(country) = best.country { + alloc::format!("{}, {}", best.name, country) + } else { + best.name + }; + + Ok(WeatherData { + city_label, + latitude: best.latitude, + longitude: best.longitude, + forecast, + }) +} + +#[allow(dead_code)] +fn parse_api_error_status(message: &str) -> Option { + message + .strip_prefix("api error: ") + .and_then(|value| value.parse::().ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn picks_first_geocoding_result() { + let response = GeocodingResponse { + results: Some(alloc::vec![ + GeocodingResult { + name: String::from("First"), + country: None, + timezone: None, + latitude: 1.0, + longitude: 2.0, + }, + GeocodingResult { + name: String::from("Second"), + country: None, + timezone: None, + latitude: 3.0, + longitude: 4.0, + }, + ]), + generationtime_ms: None, + }; + + let selected = pick_best_location(response).unwrap(); + assert_eq!(selected.name, "First"); + } + + #[test] + fn parses_api_error_status_code() { + assert_eq!(parse_api_error_status("api error: 503"), Some(503)); + } + + #[cfg(target_arch = "wasm32")] + #[test] + fn maps_api_error_from_transport_in_fetch_flow() { + fn failing_get(_: &str) -> Result, alloc::string::String> { + Err(alloc::string::String::from("api error: 429")) + } + + let error = fetch_weather_with("Paris", failing_get).unwrap_err(); + match error { + AppError::ApiError(429) => {} + _ => panic!("expected API error 429"), + } + } +} diff --git a/executables/weather/src/trigger.rs b/executables/weather/src/trigger.rs new file mode 100644 index 00000000..50eeacda --- /dev/null +++ b/executables/weather/src/trigger.rs @@ -0,0 +1,31 @@ +pub const EVENT_CLICKED: u32 = 10; +pub const EVENT_READY: u32 = 38; + +pub fn should_refresh( + event_code: u32, + target_is_refresh_button: bool, + target_is_city_input: bool, +) -> bool { + (event_code == EVENT_CLICKED && target_is_refresh_button) + || (event_code == EVENT_READY && (target_is_refresh_button || target_is_city_input)) +} + +#[cfg(test)] +mod tests { + use super::{EVENT_CLICKED, EVENT_READY, should_refresh}; + + #[test] + fn refresh_click_triggers_update() { + assert!(should_refresh(EVENT_CLICKED, true, false)); + } + + #[test] + fn city_input_ready_triggers_update() { + assert!(should_refresh(EVENT_READY, false, true)); + } + + #[test] + fn unrelated_event_does_not_trigger_update() { + assert!(!should_refresh(0, false, false)); + } +} diff --git a/executables/weather/tests/build_test.rs b/executables/weather/tests/build_test.rs new file mode 100644 index 00000000..ebc93a7b --- /dev/null +++ b/executables/weather/tests/build_test.rs @@ -0,0 +1,17 @@ +use std::fs; +use std::path::Path; + +#[test] +fn workspace_registers_weather_crate() { + let workspace_manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../Cargo.toml"); + let workspace_manifest = fs::read_to_string(workspace_manifest).unwrap(); + + assert!( + workspace_manifest.contains("weather = { path = \"executables/weather\" }"), + "workspace.dependencies must declare weather path dependency" + ); + assert!( + workspace_manifest.contains("\"executables/weather\""), + "workspace members must include executables/weather" + ); +} diff --git a/executables/weather/tests/test.rs b/executables/weather/tests/test.rs new file mode 100644 index 00000000..a10de006 --- /dev/null +++ b/executables/weather/tests/test.rs @@ -0,0 +1,59 @@ +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +#[xila::task::test(task_path = xila::task)] +#[ignore = "This test is meant to be run interactively"] +async fn main() { + drivers_std::memory::instantiate_global_allocator!(); + + extern crate alloc; + + use alloc::boxed::Box; + use alloc::vec; + use drivers_std::loader::load_to_virtual_file_system; + use wasm::WasmExecutable; + use xila::executable; + use xila::executable::{build_crate, mount_executables}; + use xila::task; + use xila::virtual_file_system; + + let standard = testing::initialize(true, true).await; + + let virtual_file_system = virtual_file_system::get_instance(); + let task_instance = task::get_instance(); + let task = task_instance.get_current_task_identifier().await; + + let binary_path = build_crate(&"weather").unwrap(); + load_to_virtual_file_system(virtual_file_system, binary_path, "/binaries/weather.wasm") + .await + .unwrap(); + + fn new_thread_executor_wrapper() + -> core::pin::Pin + Send>> { + use drivers_std::executor::new_thread_executor; + + Box::pin(new_thread_executor()) + } + + mount_executables!( + virtual_file_system, + task, + &[( + "/binaries/wasm", + WasmExecutable::new(Some(new_thread_executor_wrapper)) + )] + ) + .await + .unwrap(); + + let result = executable::execute( + "/binaries/wasm", + vec!["/binaries/weather.wasm".to_string()], + standard, + None, + ) + .await + .unwrap() + .join() + .await; + + assert!(result == 0); +} From 150523552c9e4abff8da3a3d397e34e5f427c9da Mon Sep 17 00:00:00 2001 From: Alix ANNERAUD Date: Sun, 10 May 2026 15:08:24 +0200 Subject: [PATCH 15/15] feat(weather): load and execute weather binary in WASM environment --- examples/native/src/main.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/examples/native/src/main.rs b/examples/native/src/main.rs index a611883b..501335bf 100644 --- a/examples/native/src/main.rs +++ b/examples/native/src/main.rs @@ -282,9 +282,33 @@ async fn main() { .await .unwrap(); + let weather_binary_path = build_crate("weather").unwrap(); + + load_to_virtual_file_system( + virtual_file_system, + &weather_binary_path, + "/binaries/weather", + ) + .await + .unwrap(); + let _ = executable::execute( "/binaries/wasm", vec!["--install".to_string(), "/binaries/calculator".to_string()], + standard + .duplicate() + .await + .expect("Failed to duplicate standard for calculator."), + None, + ) + .await + .unwrap() + .join() + .await; + + let _ = executable::execute( + "/binaries/wasm", + vec!["--install".to_string(), "/binaries/weather".to_string()], standard, None, )