1use alloc::{string::String, vec::Vec, vec};
2use alloc::collections::BTreeMap;
3use base64::{Engine as _, engine::general_purpose};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7pub const PROTOCOL_VERSION: &str = "0.4.0";
9
10pub const BROADCAST_ID: &str = "00000000-0000-0000-0000-000000000000";
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub enum Intent {
17 #[serde(rename = "Handshake")]
18 Handshake,
19 #[serde(rename = "Task_Request")]
20 TaskRequest,
21 #[serde(rename = "State_Sync")]
22 StateSync,
23 #[serde(rename = "State_Sync_Vector")]
24 StateSyncVector,
25 #[serde(rename = "Media_Share")]
26 MediaShare,
27 #[serde(rename = "Critique")]
28 Critique,
29 #[serde(rename = "Terminate")]
30 Terminate,
31 #[serde(rename = "ACK")]
32 Ack,
33 #[serde(rename = "NACK")]
34 Nack,
35 #[serde(rename = "Broadcast")]
36 Broadcast,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct AgentIdentity {
42 pub agent_id: String,
43 pub framework: String,
44 #[serde(default)]
45 pub capabilities: Vec<String>,
46 pub public_key: String,
47 #[serde(default = "default_modality")]
48 pub modality: Vec<String>,
49}
50
51fn default_modality() -> Vec<String> {
52 vec!["text".into()]
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct MessageHeader {
58 pub message_id: String,
59 pub timestamp: String,
60 pub sender_id: String,
61 pub receiver_id: String,
62 pub intent: Intent,
63 #[serde(default = "default_ttl")]
64 pub ttl: u32,
65 pub protocol_version: String,
66}
67
68fn default_ttl() -> u32 {
69 30
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct AckInfo {
75 pub acked_message_id: String,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ChunkInfo {
81 pub chunk_index: u32,
82 pub total_chunks: u32,
83 pub transfer_id: String,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct TPCPEnvelope {
89 pub header: MessageHeader,
90 pub payload: Value,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub signature: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub ack_info: Option<AckInfo>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub chunk_info: Option<ChunkInfo>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct TextPayload {
104 pub payload_type: String,
105 pub content: String,
106 #[serde(default = "default_language")]
107 pub language: String,
108}
109
110fn default_language() -> String {
111 "en".into()
112}
113
114impl TextPayload {
115 pub fn new(content: impl Into<String>) -> Self {
116 Self {
117 payload_type: "text".into(),
118 content: content.into(),
119 language: "en".into(),
120 }
121 }
122
123 pub fn validate(&self) -> Result<(), String> {
124 if self.content.is_empty() {
125 return Err("content must not be empty".into());
126 }
127 Ok(())
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct VectorEmbeddingPayload {
134 pub payload_type: String,
135 pub model_id: String,
136 pub dimensions: u32,
137 pub vector: Vec<f64>,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub raw_text_fallback: Option<String>,
140}
141
142impl VectorEmbeddingPayload {
143 pub fn new(model_id: impl Into<String>, dimensions: u32, vector: Vec<f64>) -> Self {
144 Self {
145 payload_type: "vector_embedding".into(),
146 model_id: model_id.into(),
147 dimensions,
148 vector,
149 raw_text_fallback: None,
150 }
151 }
152
153 pub fn validate(&self) -> Result<(), String> {
154 if self.model_id.is_empty() {
155 return Err("model_id must not be empty".into());
156 }
157 if self.dimensions == 0 {
158 return Err("dimensions must be greater than zero".into());
159 }
160 if self.vector.len() != self.dimensions as usize {
161 return Err(alloc::format!(
162 "vector length {} does not match dimensions {}",
163 self.vector.len(),
164 self.dimensions
165 ));
166 }
167 Ok(())
168 }
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct CRDTSyncPayload {
174 pub payload_type: String,
175 pub crdt_type: String,
176 pub state: Value,
177 pub vector_clock: BTreeMap<String, i64>,
178}
179
180impl CRDTSyncPayload {
181 pub fn new(crdt_type: impl Into<String>, state: Value, vector_clock: BTreeMap<String, i64>) -> Self {
182 Self {
183 payload_type: "crdt_sync".into(),
184 crdt_type: crdt_type.into(),
185 state,
186 vector_clock,
187 }
188 }
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct ImagePayload {
194 pub payload_type: String,
195 pub data_base64: String,
196 #[serde(default = "default_image_mime")]
197 pub mime_type: String,
198 #[serde(skip_serializing_if = "Option::is_none")]
199 pub width: Option<u32>,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 pub height: Option<u32>,
202 #[serde(skip_serializing_if = "Option::is_none")]
203 pub source_model: Option<String>,
204 #[serde(skip_serializing_if = "Option::is_none")]
205 pub caption: Option<String>,
206}
207
208fn default_image_mime() -> String { "image/png".into() }
209
210impl ImagePayload {
211 pub fn new(data_base64: impl Into<String>, mime_type: impl Into<String>) -> Self {
212 Self {
213 payload_type: "image".into(),
214 data_base64: data_base64.into(),
215 mime_type: mime_type.into(),
216 width: None, height: None, source_model: None, caption: None,
217 }
218 }
219
220 pub fn validate(&self) -> Result<(), String> {
221 if !self.mime_type.starts_with("image/") {
222 return Err(alloc::format!(
223 "mime_type must start with 'image/', got '{}'",
224 self.mime_type
225 ));
226 }
227 if self.data_base64.is_empty() {
228 return Err("data_base64 must not be empty".into());
229 }
230 general_purpose::STANDARD
231 .decode(&self.data_base64)
232 .map_err(|e| alloc::format!("data_base64 is not valid base64: {}", e))?;
233 Ok(())
234 }
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct AudioPayload {
240 pub payload_type: String,
241 pub data_base64: String,
242 #[serde(default = "default_audio_mime")]
243 pub mime_type: String,
244 #[serde(skip_serializing_if = "Option::is_none")]
245 pub sample_rate: Option<u32>,
246 #[serde(skip_serializing_if = "Option::is_none")]
247 pub duration_seconds: Option<f64>,
248 #[serde(skip_serializing_if = "Option::is_none")]
249 pub source_model: Option<String>,
250 #[serde(skip_serializing_if = "Option::is_none")]
251 pub transcript: Option<String>,
252}
253
254fn default_audio_mime() -> String { "audio/wav".into() }
255
256impl AudioPayload {
257 pub fn new(data_base64: impl Into<String>, mime_type: impl Into<String>) -> Self {
258 Self {
259 payload_type: "audio".into(),
260 data_base64: data_base64.into(),
261 mime_type: mime_type.into(),
262 sample_rate: None, duration_seconds: None, source_model: None, transcript: None,
263 }
264 }
265
266 pub fn validate(&self) -> Result<(), String> {
267 if !self.mime_type.starts_with("audio/") {
268 return Err(alloc::format!(
269 "mime_type must start with 'audio/', got '{}'",
270 self.mime_type
271 ));
272 }
273 if self.data_base64.is_empty() {
274 return Err("data_base64 must not be empty".into());
275 }
276 general_purpose::STANDARD
277 .decode(&self.data_base64)
278 .map_err(|e| alloc::format!("data_base64 is not valid base64: {}", e))?;
279 Ok(())
280 }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct VideoPayload {
286 pub payload_type: String,
287 pub data_base64: String,
288 #[serde(default = "default_video_mime")]
289 pub mime_type: String,
290 #[serde(skip_serializing_if = "Option::is_none")]
291 pub width: Option<u32>,
292 #[serde(skip_serializing_if = "Option::is_none")]
293 pub height: Option<u32>,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 pub duration_seconds: Option<f64>,
296 #[serde(skip_serializing_if = "Option::is_none")]
297 pub fps: Option<f64>,
298 #[serde(skip_serializing_if = "Option::is_none")]
299 pub source_model: Option<String>,
300 #[serde(skip_serializing_if = "Option::is_none")]
301 pub description: Option<String>,
302}
303
304fn default_video_mime() -> String { "video/mp4".into() }
305
306impl VideoPayload {
307 pub fn new(data_base64: impl Into<String>, mime_type: impl Into<String>) -> Self {
308 Self {
309 payload_type: "video".into(),
310 data_base64: data_base64.into(),
311 mime_type: mime_type.into(),
312 width: None, height: None, duration_seconds: None,
313 fps: None, source_model: None, description: None,
314 }
315 }
316
317 pub fn validate(&self) -> Result<(), String> {
318 if !self.mime_type.starts_with("video/") {
319 return Err(alloc::format!(
320 "mime_type must start with 'video/', got '{}'",
321 self.mime_type
322 ));
323 }
324 if self.data_base64.is_empty() {
325 return Err("data_base64 must not be empty".into());
326 }
327 general_purpose::STANDARD
328 .decode(&self.data_base64)
329 .map_err(|e| alloc::format!("data_base64 is not valid base64: {}", e))?;
330 Ok(())
331 }
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct BinaryPayload {
337 pub payload_type: String,
338 pub data_base64: String,
339 #[serde(default = "default_binary_mime")]
340 pub mime_type: String,
341 #[serde(skip_serializing_if = "Option::is_none")]
342 pub filename: Option<String>,
343 #[serde(skip_serializing_if = "Option::is_none")]
344 pub description: Option<String>,
345}
346
347fn default_binary_mime() -> String { "application/octet-stream".into() }
348
349impl BinaryPayload {
350 pub fn new(data_base64: impl Into<String>, mime_type: impl Into<String>) -> Self {
351 Self {
352 payload_type: "binary".into(),
353 data_base64: data_base64.into(),
354 mime_type: mime_type.into(),
355 filename: None, description: None,
356 }
357 }
358
359 pub fn validate(&self) -> Result<(), String> {
360 if self.mime_type.is_empty() {
361 return Err("mime_type must not be empty".into());
362 }
363 if self.data_base64.is_empty() {
364 return Err("data_base64 must not be empty".into());
365 }
366 general_purpose::STANDARD
367 .decode(&self.data_base64)
368 .map_err(|e| alloc::format!("data_base64 is not valid base64: {}", e))?;
369 Ok(())
370 }
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct TelemetryReading {
376 pub value: f64,
377 pub timestamp_ms: i64,
378 #[serde(skip_serializing_if = "Option::is_none")]
379 pub quality: Option<String>,
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct TelemetryPayload {
385 pub payload_type: String,
386 pub sensor_id: String,
387 pub unit: String,
388 pub readings: Vec<TelemetryReading>,
389 pub source_protocol: String,
390}
391
392impl TelemetryPayload {
393 pub fn new(sensor_id: impl Into<String>, unit: impl Into<String>,
394 source_protocol: impl Into<String>, readings: Vec<TelemetryReading>) -> Self {
395 Self {
396 payload_type: "telemetry".into(),
397 sensor_id: sensor_id.into(),
398 unit: unit.into(),
399 readings,
400 source_protocol: source_protocol.into(),
401 }
402 }
403
404 pub fn validate(&self) -> Result<(), String> {
405 if self.sensor_id.is_empty() {
406 return Err("sensor_id must not be empty".into());
407 }
408 if self.unit.is_empty() {
409 return Err("unit must not be empty".into());
410 }
411 if self.readings.is_empty() {
412 return Err("readings must not be empty".into());
413 }
414 Ok(())
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use alloc::string::ToString;
422
423 #[test]
424 fn intent_serialization_matches_wire_format() {
425 let cases = vec![
426 (Intent::Handshake, "\"Handshake\""),
427 (Intent::TaskRequest, "\"Task_Request\""),
428 (Intent::StateSync, "\"State_Sync\""),
429 (Intent::StateSyncVector, "\"State_Sync_Vector\""),
430 (Intent::MediaShare, "\"Media_Share\""),
431 (Intent::Critique, "\"Critique\""),
432 (Intent::Terminate, "\"Terminate\""),
433 (Intent::Ack, "\"ACK\""),
434 (Intent::Nack, "\"NACK\""),
435 (Intent::Broadcast, "\"Broadcast\""),
436 ];
437 for (intent, expected) in cases {
438 let json = serde_json::to_string(&intent).unwrap();
439 assert_eq!(json, expected, "Intent wire format mismatch");
440 }
441 }
442
443 #[test]
444 fn text_payload_round_trip() {
445 let payload = TextPayload::new("hello world");
446 let json = serde_json::to_string(&payload).unwrap();
447 let parsed: TextPayload = serde_json::from_str(&json).unwrap();
448 assert_eq!(parsed.payload_type, "text");
449 assert_eq!(parsed.content, "hello world");
450 assert_eq!(parsed.language, "en");
451 }
452
453 #[test]
454 fn envelope_with_ack_and_chunk_info() {
455 let envelope = TPCPEnvelope {
456 header: MessageHeader {
457 message_id: "msg-1".to_string(),
458 timestamp: "2026-03-15T00:00:00Z".to_string(),
459 sender_id: "sender-1".to_string(),
460 receiver_id: "receiver-1".to_string(),
461 intent: Intent::Ack,
462 ttl: 30,
463 protocol_version: PROTOCOL_VERSION.to_string(),
464 },
465 payload: serde_json::json!({"payload_type": "text", "content": "ack"}),
466 signature: Some("sig123".to_string()),
467 ack_info: Some(AckInfo {
468 acked_message_id: "orig-msg-1".to_string(),
469 }),
470 chunk_info: Some(ChunkInfo {
471 chunk_index: 0,
472 total_chunks: 3,
473 transfer_id: "transfer-1".to_string(),
474 }),
475 };
476
477 let json = serde_json::to_string(&envelope).unwrap();
478 let parsed: TPCPEnvelope = serde_json::from_str(&json).unwrap();
479
480 assert_eq!(parsed.ack_info.as_ref().unwrap().acked_message_id, "orig-msg-1");
481 assert_eq!(parsed.chunk_info.as_ref().unwrap().chunk_index, 0);
482 assert_eq!(parsed.chunk_info.as_ref().unwrap().total_chunks, 3);
483 assert_eq!(parsed.chunk_info.as_ref().unwrap().transfer_id, "transfer-1");
484 }
485
486 #[test]
487 fn telemetry_payload_round_trip() {
488 let payload = TelemetryPayload::new(
489 "sensor_1", "celsius", "opcua",
490 vec![TelemetryReading { value: 42.5, timestamp_ms: 1000, quality: Some("Good".to_string()) }],
491 );
492 let json = serde_json::to_string(&payload).unwrap();
493 let parsed: TelemetryPayload = serde_json::from_str(&json).unwrap();
494 assert_eq!(parsed.sensor_id, "sensor_1");
495 assert_eq!(parsed.readings.len(), 1);
496 assert_eq!(parsed.readings[0].value, 42.5);
497 }
498}
499
500#[cfg(test)]
501mod validation_tests {
502 use super::*;
503
504 #[test]
505 fn text_payload_rejects_empty() {
506 let p = TextPayload { payload_type: "text".into(), content: "".into(), language: "en".into() };
507 assert!(p.validate().is_err());
508 }
509
510 #[test]
511 fn text_payload_accepts_nonempty() {
512 let p = TextPayload { payload_type: "text".into(), content: "hello".into(), language: "en".into() };
513 assert!(p.validate().is_ok());
514 }
515
516 #[test]
517 fn vector_rejects_dimension_mismatch() {
518 let p = VectorEmbeddingPayload {
519 payload_type: "vector_embedding".into(),
520 model_id: "test".into(),
521 dimensions: 3,
522 vector: vec![1.0, 2.0],
523 raw_text_fallback: None,
524 };
525 assert!(p.validate().is_err());
526 }
527
528 #[test]
529 fn vector_accepts_matching_dimensions() {
530 let p = VectorEmbeddingPayload {
531 payload_type: "vector_embedding".into(),
532 model_id: "test".into(),
533 dimensions: 3,
534 vector: vec![1.0, 2.0, 3.0],
535 raw_text_fallback: None,
536 };
537 assert!(p.validate().is_ok());
538 }
539
540 #[test]
541 fn image_payload_rejects_bad_mime() {
542 let p = ImagePayload::new("aGVsbG8=", "text/plain");
543 assert!(p.validate().is_err());
544 }
545
546 #[test]
547 fn image_payload_rejects_invalid_base64() {
548 let p = ImagePayload::new("not!!valid@@base64", "image/png");
549 assert!(p.validate().is_err());
550 }
551
552 #[test]
553 fn image_payload_accepts_valid() {
554 let p = ImagePayload::new("aGVsbG8=", "image/png");
556 assert!(p.validate().is_ok());
557 }
558
559 #[test]
560 fn audio_payload_rejects_bad_mime() {
561 let p = AudioPayload::new("aGVsbG8=", "image/png");
562 assert!(p.validate().is_err());
563 }
564
565 #[test]
566 fn audio_payload_accepts_valid() {
567 let p = AudioPayload::new("aGVsbG8=", "audio/wav");
568 assert!(p.validate().is_ok());
569 }
570
571 #[test]
572 fn video_payload_rejects_bad_mime() {
573 let p = VideoPayload::new("aGVsbG8=", "audio/wav");
574 assert!(p.validate().is_err());
575 }
576
577 #[test]
578 fn video_payload_accepts_valid() {
579 let p = VideoPayload::new("aGVsbG8=", "video/mp4");
580 assert!(p.validate().is_ok());
581 }
582
583 #[test]
584 fn binary_payload_rejects_empty_data() {
585 let p = BinaryPayload::new("", "application/octet-stream");
586 assert!(p.validate().is_err());
587 }
588
589 #[test]
590 fn binary_payload_accepts_valid() {
591 let p = BinaryPayload::new("aGVsbG8=", "application/octet-stream");
592 assert!(p.validate().is_ok());
593 }
594
595 #[test]
596 fn telemetry_rejects_empty_sensor_id() {
597 let p = TelemetryPayload {
598 payload_type: "telemetry".into(),
599 sensor_id: "".into(),
600 unit: "celsius".into(),
601 readings: vec![TelemetryReading { value: 1.0, timestamp_ms: 0, quality: None }],
602 source_protocol: "opcua".into(),
603 };
604 assert!(p.validate().is_err());
605 }
606
607 #[test]
608 fn telemetry_rejects_empty_unit() {
609 let p = TelemetryPayload {
610 payload_type: "telemetry".into(),
611 sensor_id: "s1".into(),
612 unit: "".into(),
613 readings: vec![TelemetryReading { value: 1.0, timestamp_ms: 0, quality: None }],
614 source_protocol: "opcua".into(),
615 };
616 assert!(p.validate().is_err());
617 }
618
619 #[test]
620 fn telemetry_rejects_empty_readings() {
621 let p = TelemetryPayload {
622 payload_type: "telemetry".into(),
623 sensor_id: "s1".into(),
624 unit: "celsius".into(),
625 readings: vec![],
626 source_protocol: "opcua".into(),
627 };
628 assert!(p.validate().is_err());
629 }
630
631 #[test]
632 fn telemetry_accepts_valid() {
633 let p = TelemetryPayload::new(
634 "sensor_1", "celsius", "opcua",
635 vec![TelemetryReading { value: 42.5, timestamp_ms: 1000, quality: None }],
636 );
637 assert!(p.validate().is_ok());
638 }
639}