1use std::ffi::{c_char, CString};
12use std::future::Future;
13use std::panic::{catch_unwind, AssertUnwindSafe};
14use std::ptr;
15use std::sync::Arc;
16use std::time::Duration;
17
18use tokio::sync::Mutex;
19
20use pamoja_core::{Error, Transport};
21use pamoja_mqtt::{MqttConfig, MqttTransport, QualityOfService};
22
23use crate::{read_bytes, read_str, runtime, set_last_error, PamojaStatus};
24
25#[allow(clippy::enum_variant_names)]
29#[repr(C)]
30#[derive(Clone, Copy)]
31pub enum PamojaQos {
32 AtMostOnce = 0,
34 AtLeastOnce = 1,
36 ExactlyOnce = 2,
38}
39
40impl From<PamojaQos> for QualityOfService {
41 fn from(value: PamojaQos) -> Self {
42 match value {
43 PamojaQos::AtMostOnce => QualityOfService::AtMostOnce,
44 PamojaQos::AtLeastOnce => QualityOfService::AtLeastOnce,
45 PamojaQos::ExactlyOnce => QualityOfService::ExactlyOnce,
46 }
47 }
48}
49
50#[repr(C)]
55pub struct PamojaMqttConfig {
56 pub client_id: *const c_char,
58 pub host: *const c_char,
60 pub port: u16,
62 pub keep_alive_secs: u32,
64 pub capacity: u32,
66 pub qos: PamojaQos,
68}
69
70pub struct PamojaMqttClient {
72 inner: Arc<Mutex<MqttTransport>>,
73}
74
75pub struct PamojaMqttMessage {
77 topic: CString,
78 payload: Vec<u8>,
79}
80
81#[no_mangle]
94pub unsafe extern "C" fn pamoja_mqtt_client_new(
95 config: *const PamojaMqttConfig,
96) -> *mut PamojaMqttClient {
97 if config.is_null() {
98 set_last_error("config must not be null".to_owned());
99 return ptr::null_mut();
100 }
101 let Some(settings) = mqtt_settings(config) else {
102 return ptr::null_mut();
103 };
104
105 let client = PamojaMqttClient {
106 inner: Arc::new(Mutex::new(MqttTransport::new(settings))),
107 };
108 Box::into_raw(Box::new(client))
109}
110
111pub(crate) unsafe fn mqtt_settings(config: *const PamojaMqttConfig) -> Option<MqttConfig> {
122 if config.is_null() {
123 set_last_error("config must not be null".to_owned());
124 return None;
125 }
126 let config = &*config;
127 let client_id = read_str(config.client_id, "client_id")?;
128 let host = read_str(config.host, "host")?;
129
130 let mut settings = MqttConfig::new(client_id, host, config.port);
131 if config.keep_alive_secs != 0 {
132 settings = settings.keep_alive(Duration::from_secs(u64::from(config.keep_alive_secs)));
133 }
134 if config.capacity != 0 {
135 settings = settings.capacity(config.capacity as usize);
136 }
137 Some(settings.qos(config.qos.into()))
138}
139
140#[no_mangle]
152pub unsafe extern "C" fn pamoja_mqtt_client_connect(client: *mut PamojaMqttClient) -> PamojaStatus {
153 let Some(client) = client_handle(client) else {
154 return PamojaStatus::InvalidArgument;
155 };
156 let inner = Arc::clone(&client.inner);
157 run(async move { inner.lock().await.connect().await })
158}
159
160#[no_mangle]
173pub unsafe extern "C" fn pamoja_mqtt_client_publish(
174 client: *mut PamojaMqttClient,
175 topic: *const c_char,
176 payload: *const u8,
177 payload_len: usize,
178) -> PamojaStatus {
179 let Some(client) = client_handle(client) else {
180 return PamojaStatus::InvalidArgument;
181 };
182 let Some(topic) = read_str(topic, "topic") else {
183 return PamojaStatus::InvalidArgument;
184 };
185 let payload = match read_bytes(payload, payload_len) {
186 Ok(payload) => payload,
187 Err(status) => return status,
188 };
189 let topic = topic.to_owned();
190 let inner = Arc::clone(&client.inner);
191 run(async move { inner.lock().await.send(&topic, &payload).await })
192}
193
194#[no_mangle]
205pub unsafe extern "C" fn pamoja_mqtt_client_subscribe(
206 client: *mut PamojaMqttClient,
207 topic: *const c_char,
208) -> PamojaStatus {
209 let Some(client) = client_handle(client) else {
210 return PamojaStatus::InvalidArgument;
211 };
212 let Some(topic) = read_str(topic, "topic") else {
213 return PamojaStatus::InvalidArgument;
214 };
215 let topic = topic.to_owned();
216 let inner = Arc::clone(&client.inner);
217 run(async move { inner.lock().await.subscribe(&topic).await })
218}
219
220#[no_mangle]
235pub unsafe extern "C" fn pamoja_mqtt_client_recv(
236 client: *mut PamojaMqttClient,
237 out_message: *mut *mut PamojaMqttMessage,
238) -> PamojaStatus {
239 if out_message.is_null() {
240 set_last_error("out_message must not be null".to_owned());
241 return PamojaStatus::InvalidArgument;
242 }
243 *out_message = ptr::null_mut();
244 let Some(client) = client_handle(client) else {
245 return PamojaStatus::InvalidArgument;
246 };
247 let inner = Arc::clone(&client.inner);
248
249 match catch_unwind(AssertUnwindSafe(|| {
250 runtime().block_on(async move { inner.lock().await.recv().await })
251 })) {
252 Ok(Ok(Some(message))) => {
253 let boxed = Box::new(PamojaMqttMessage {
254 topic: CString::new(message.topic)
255 .unwrap_or_else(|_| CString::new("").expect("static")),
256 payload: message.payload,
257 });
258 *out_message = Box::into_raw(boxed);
259 PamojaStatus::Ok
260 }
261 Ok(Ok(None)) => PamojaStatus::Ok,
262 Ok(Err(error)) => {
263 set_last_error(error.to_string());
264 PamojaStatus::from_error(&error)
265 }
266 Err(_) => {
267 set_last_error("panic at the FFI boundary".to_owned());
268 PamojaStatus::Panic
269 }
270 }
271}
272
273#[no_mangle]
284pub unsafe extern "C" fn pamoja_mqtt_client_is_connected(client: *mut PamojaMqttClient) -> bool {
285 let Some(client) = client_handle(client) else {
286 return false;
287 };
288 let inner = Arc::clone(&client.inner);
289 catch_unwind(AssertUnwindSafe(|| {
290 runtime().block_on(async move { inner.lock().await.is_connected() })
291 }))
292 .unwrap_or(false)
293}
294
295#[no_mangle]
305pub unsafe extern "C" fn pamoja_mqtt_client_disconnect(
306 client: *mut PamojaMqttClient,
307) -> PamojaStatus {
308 let Some(client) = client_handle(client) else {
309 return PamojaStatus::InvalidArgument;
310 };
311 let inner = Arc::clone(&client.inner);
312 run(async move { inner.lock().await.disconnect().await })
313}
314
315#[no_mangle]
324pub unsafe extern "C" fn pamoja_mqtt_client_free(client: *mut PamojaMqttClient) {
325 if !client.is_null() {
326 drop(Box::from_raw(client));
327 }
328}
329
330#[no_mangle]
341pub unsafe extern "C" fn pamoja_mqtt_message_topic(
342 message: *const PamojaMqttMessage,
343) -> *const c_char {
344 if message.is_null() {
345 return ptr::null();
346 }
347 (*message).topic.as_ptr()
348}
349
350#[no_mangle]
363pub unsafe extern "C" fn pamoja_mqtt_message_payload(
364 message: *const PamojaMqttMessage,
365) -> *const u8 {
366 if message.is_null() {
367 return ptr::null();
368 }
369 (*message).payload.as_ptr()
370}
371
372#[no_mangle]
382pub unsafe extern "C" fn pamoja_mqtt_message_payload_len(
383 message: *const PamojaMqttMessage,
384) -> usize {
385 if message.is_null() {
386 return 0;
387 }
388 (*message).payload.len()
389}
390
391#[no_mangle]
400pub unsafe extern "C" fn pamoja_mqtt_message_free(message: *mut PamojaMqttMessage) {
401 if !message.is_null() {
402 drop(Box::from_raw(message));
403 }
404}
405
406fn run<F>(future: F) -> PamojaStatus
411where
412 F: Future<Output = Result<(), Error>>,
413{
414 match catch_unwind(AssertUnwindSafe(|| runtime().block_on(future))) {
415 Ok(Ok(())) => PamojaStatus::Ok,
416 Ok(Err(error)) => {
417 set_last_error(error.to_string());
418 PamojaStatus::from_error(&error)
419 }
420 Err(_) => {
421 set_last_error("panic at the FFI boundary".to_owned());
422 PamojaStatus::Panic
423 }
424 }
425}
426
427unsafe fn client_handle<'a>(client: *mut PamojaMqttClient) -> Option<&'a PamojaMqttClient> {
433 if client.is_null() {
434 set_last_error("client must not be null".to_owned());
435 None
436 } else {
437 Some(&*client)
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn qos_maps_to_core_levels() {
447 assert_eq!(
448 QualityOfService::from(PamojaQos::AtMostOnce),
449 QualityOfService::AtMostOnce
450 );
451 assert_eq!(
452 QualityOfService::from(PamojaQos::AtLeastOnce),
453 QualityOfService::AtLeastOnce
454 );
455 assert_eq!(
456 QualityOfService::from(PamojaQos::ExactlyOnce),
457 QualityOfService::ExactlyOnce
458 );
459 }
460
461 #[test]
462 fn read_bytes_treats_zero_length_as_empty() {
463 let bytes = unsafe { read_bytes(ptr::null(), 0) }.expect("empty payload");
465 assert!(bytes.is_empty());
466 }
467
468 #[test]
469 fn new_with_null_config_returns_null() {
470 let client = unsafe { pamoja_mqtt_client_new(ptr::null()) };
472 assert!(client.is_null());
473 }
474
475 #[test]
476 fn calls_on_a_null_client_are_rejected() {
477 let status =
479 unsafe { pamoja_mqtt_client_publish(ptr::null_mut(), ptr::null(), ptr::null(), 0) };
480 assert_eq!(status, PamojaStatus::InvalidArgument);
481 unsafe { pamoja_mqtt_client_free(ptr::null_mut()) };
483 }
484
485 #[test]
486 fn a_null_topic_is_rejected_before_any_network_use() {
487 let client_id = CString::new("audit").expect("no null byte");
488 let host = CString::new("localhost").expect("no null byte");
489 let config = PamojaMqttConfig {
490 client_id: client_id.as_ptr(),
491 host: host.as_ptr(),
492 port: 1883,
493 keep_alive_secs: 0,
494 capacity: 0,
495 qos: PamojaQos::AtMostOnce,
496 };
497 let client = unsafe { pamoja_mqtt_client_new(&config) };
499 assert!(!client.is_null());
500 let status = unsafe { pamoja_mqtt_client_publish(client, ptr::null(), ptr::null(), 0) };
502 assert_eq!(status, PamojaStatus::InvalidArgument);
503 unsafe { pamoja_mqtt_client_free(client) };
505 }
506}