Skip to main content

peat_btle/
document.rs

1// Copyright (c) 2025-2026 (r)evolve - Revolve Team LLC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! Peat Document wire format for BLE mesh sync
17//!
18//! This module provides the unified document format used across all platforms
19//! (iOS, Android, ESP32) for mesh synchronization. The format is designed for
20//! efficient BLE transmission while supporting CRDT semantics.
21//!
22//! ## Wire Format
23//!
24//! ```text
25//! Header (8 bytes):
26//!   version:  4 bytes (LE) - document version number
27//!   node_id:  4 bytes (LE) - source node identifier
28//!
29//! GCounter (4 + N*12 bytes):
30//!   num_entries: 4 bytes (LE)
31//!   entries[N]:
32//!     node_id: 4 bytes (LE)
33//!     count:   8 bytes (LE)
34//!
35//! Extended Section (optional, when peripheral data present):
36//!   marker:         1 byte (0xAB)
37//!   reserved:       1 byte
38//!   peripheral_len: 2 bytes (LE)
39//!   peripheral:     variable (34-43 bytes)
40//! ```
41
42#[cfg(not(feature = "std"))]
43use alloc::vec::Vec;
44
45#[cfg(feature = "legacy-chat")]
46use crate::sync::crdt::ChatCRDT;
47use crate::sync::crdt::{EmergencyEvent, EventType, GCounter, Peripheral, PeripheralEvent};
48use crate::NodeId;
49
50/// Marker byte indicating extended section with peripheral data
51pub const EXTENDED_MARKER: u8 = 0xAB;
52
53/// Marker byte indicating emergency event section
54pub const EMERGENCY_MARKER: u8 = 0xAC;
55
56/// Marker byte indicating chat CRDT section
57///
58/// Used to include persisted chat messages in the document for CRDT sync.
59///
60/// ```text
61/// marker:   1 byte (0xAD)
62/// reserved: 1 byte (0x00)
63/// len:      2 bytes (LE) - length of chat CRDT data
64/// chat:     variable - ChatCRDT encoded data
65/// ```
66pub const CHAT_MARKER: u8 = 0xAD;
67
68/// Marker byte indicating encrypted document (mesh-wide)
69///
70/// When present, the entire document payload following the marker is encrypted
71/// using ChaCha20-Poly1305. The marker format is:
72///
73/// ```text
74/// marker:   1 byte (0xAE)
75/// reserved: 1 byte (0x00)
76/// payload:  12 bytes nonce + variable ciphertext (includes 16-byte auth tag)
77/// ```
78///
79/// Encryption happens at the PeatMesh layer before transmission, and decryption
80/// happens upon receipt before document parsing.
81pub const ENCRYPTED_MARKER: u8 = 0xAE;
82
83/// Marker byte indicating per-peer E2EE message
84///
85/// Used for end-to-end encrypted messages between specific peer pairs.
86/// Only the sender and recipient (who share a session key) can decrypt.
87///
88/// ```text
89/// marker:     1 byte (0xAF)
90/// flags:      1 byte (bit 0: key_exchange, bit 1: forward_secrecy)
91/// recipient:  4 bytes (LE) - recipient node ID
92/// sender:     4 bytes (LE) - sender node ID
93/// counter:    8 bytes (LE) - message counter for replay protection
94/// nonce:      12 bytes
95/// ciphertext: variable (includes 16-byte auth tag)
96/// ```
97pub const PEER_E2EE_MARKER: u8 = 0xAF;
98
99/// Marker byte indicating key exchange message for per-peer E2EE
100///
101/// Used to establish E2EE sessions between peers via X25519 key exchange.
102///
103/// ```text
104/// marker:     1 byte (0xB0)
105/// sender:     4 bytes (LE) - sender node ID
106/// flags:      1 byte (bit 0: is_ephemeral)
107/// public_key: 32 bytes - X25519 public key
108/// ```
109pub const KEY_EXCHANGE_MARKER: u8 = 0xB0;
110
111/// Marker byte indicating relay envelope for multi-hop transmission
112///
113/// Used to wrap documents for multi-hop relay with deduplication and TTL.
114/// See [`crate::relay`] module for details.
115///
116/// ```text
117/// marker:        1 byte (0xB1)
118/// flags:         1 byte (bit 0: requires_ack, bit 1: is_broadcast)
119/// message_id:    16 bytes (UUID for deduplication)
120/// hop_count:     1 byte (current hop count)
121/// max_hops:      1 byte (TTL)
122/// origin_node:   4 bytes (LE) - original sender node ID
123/// payload_len:   4 bytes (LE)
124/// payload:       variable (encrypted document)
125/// ```
126pub const RELAY_ENVELOPE_MARKER: u8 = 0xB1;
127
128/// Marker byte indicating delta document for bandwidth-efficient sync
129///
130/// Used to send only changed operations instead of full state snapshots.
131/// See [`crate::sync::delta_document`] module for details.
132///
133/// ```text
134/// marker:        1 byte (0xB2)
135/// flags:         1 byte (bit 0: has_vector_clock, bit 1: is_response)
136/// origin_node:   4 bytes (LE) - origin node ID
137/// timestamp_ms:  8 bytes (LE) - creation timestamp
138/// vector_clock:  variable (if flag set)
139/// op_count:      2 bytes (LE) - number of operations
140/// operations:    variable
141/// ```
142pub const DELTA_DOCUMENT_MARKER: u8 = 0xB2;
143
144/// Top-level marker for ADR-059 translator frames (peat-mesh cross-transport bridging).
145///
146/// Frame layout:
147/// ```text
148/// marker:        1 byte (0xB6)
149/// collection:    1 byte (code, see translator::COLLECTION_CODE_*)
150/// payload:       N bytes (postcard-encoded BlePosition/BlePeripheral/etc.)
151/// ```
152///
153/// 0xB6 is the first free top-level marker above the ADR-001 trust-architecture
154/// reservations (0xB3 IDENTITY_ATTESTATION, 0xB4 REVOCATION, 0xB5 KEY_ROTATION;
155/// see docs/adr/ADR-001-trust-architecture.md "Wire Format Summary") and below
156/// the 0xC0..=0xCF DocumentType registry range. Routed through
157/// `BleTranslator::decode_inbound` on receive; produced by
158/// `BleTranslator::encode_outbound` on send. See ADR-059 Amendment 1 for the
159/// wire-format rationale and mixed-fleet rollout sequencing.
160pub const TRANSLATOR_FRAME_MARKER: u8 = 0xB6;
161
162/// Reserved marker range for future translator-frame variants (multi-frame,
163/// fragmented, etc.). Receivers running this peat-btle release MUST silently
164/// drop any frame whose first byte falls in this range so a future variant
165/// rollout does not pollute legacy decoders. See ADR-059 Amendment 1
166/// §"Backwards compatibility… 2".
167pub const TRANSLATOR_RESERVED_MARKER_START: u8 = 0xB7;
168/// Inclusive upper bound on the reserved translator-marker range.
169pub const TRANSLATOR_RESERVED_MARKER_END: u8 = 0xBF;
170
171/// Minimum document size (header only, no counter entries)
172pub const MIN_DOCUMENT_SIZE: usize = 8;
173
174/// Maximum recommended mesh size for reliable single-packet sync
175///
176/// Beyond this, documents may exceed typical BLE MTU (244 bytes).
177/// Size calculation: 8 (header) + 4 + (N × 12) (GCounter) + 42 (Peripheral)
178///   20 nodes = 8 + 244 + 42 = 294 bytes
179pub const MAX_MESH_SIZE: usize = 20;
180
181/// Target document size for single-packet transmission
182///
183/// Based on typical negotiated BLE MTU (247 bytes - 3 ATT overhead).
184pub const TARGET_DOCUMENT_SIZE: usize = 244;
185
186/// Hard limit before fragmentation would be required
187///
188/// BLE 5.0+ supports up to 517 byte MTU, but 512 is practical max payload.
189pub const MAX_DOCUMENT_SIZE: usize = 512;
190
191/// A Peat document for mesh synchronization
192///
193/// Contains header information, a CRDT G-Counter for tracking mesh activity,
194/// optional peripheral data for events, optional emergency event with ACK tracking,
195/// and optional chat CRDT for mesh-wide messaging.
196#[derive(Debug, Clone)]
197pub struct PeatDocument {
198    /// Document version (incremented on each change)
199    pub version: u32,
200
201    /// Source node ID that created/last modified this document
202    pub node_id: NodeId,
203
204    /// CRDT G-Counter tracking activity across the mesh
205    pub counter: GCounter,
206
207    /// Optional peripheral data (sensor info, events)
208    pub peripheral: Option<Peripheral>,
209
210    /// Optional active emergency event with distributed ACK tracking
211    pub emergency: Option<EmergencyEvent>,
212
213    /// Optional chat CRDT for mesh-wide messaging (requires `legacy-chat` feature)
214    ///
215    /// Contains persisted chat messages that sync across the mesh using
216    /// add-only set semantics. Messages are identified by (origin_node, timestamp)
217    /// and automatically deduplicated during merge.
218    #[cfg(feature = "legacy-chat")]
219    pub chat: Option<ChatCRDT>,
220}
221
222impl Default for PeatDocument {
223    fn default() -> Self {
224        Self {
225            version: 1,
226            node_id: NodeId::default(),
227            counter: GCounter::new(),
228            peripheral: None,
229            emergency: None,
230            #[cfg(feature = "legacy-chat")]
231            chat: None,
232        }
233    }
234}
235
236impl PeatDocument {
237    /// Create a new document for the given node
238    pub fn new(node_id: NodeId) -> Self {
239        Self {
240            version: 1,
241            node_id,
242            counter: GCounter::new(),
243            peripheral: None,
244            emergency: None,
245            #[cfg(feature = "legacy-chat")]
246            chat: None,
247        }
248    }
249
250    /// Create with an initial peripheral
251    pub fn with_peripheral(mut self, peripheral: Peripheral) -> Self {
252        self.peripheral = Some(peripheral);
253        self
254    }
255
256    /// Create with an initial emergency event
257    pub fn with_emergency(mut self, emergency: EmergencyEvent) -> Self {
258        self.emergency = Some(emergency);
259        self
260    }
261
262    /// Create with an initial chat CRDT (requires `legacy-chat` feature)
263    #[cfg(feature = "legacy-chat")]
264    pub fn with_chat(mut self, chat: ChatCRDT) -> Self {
265        self.chat = Some(chat);
266        self
267    }
268
269    /// Increment the document version
270    pub fn increment_version(&mut self) {
271        self.version = self.version.wrapping_add(1);
272    }
273
274    /// Increment the counter for this node
275    pub fn increment_counter(&mut self) {
276        self.counter.increment(&self.node_id, 1);
277        self.increment_version();
278    }
279
280    /// Set an event on the peripheral
281    pub fn set_event(&mut self, event_type: EventType, timestamp: u64) {
282        if let Some(ref mut peripheral) = self.peripheral {
283            peripheral.set_event(event_type, timestamp);
284            self.increment_counter();
285        }
286    }
287
288    /// Clear the event from the peripheral
289    pub fn clear_event(&mut self) {
290        if let Some(ref mut peripheral) = self.peripheral {
291            peripheral.clear_event();
292            self.increment_version();
293        }
294    }
295
296    /// Set an emergency event
297    ///
298    /// Creates a new emergency event with the given source node, timestamp,
299    /// and list of known peers to track for ACKs.
300    pub fn set_emergency(&mut self, source_node: u32, timestamp: u64, known_peers: &[u32]) {
301        self.emergency = Some(EmergencyEvent::new(source_node, timestamp, known_peers));
302        self.increment_counter();
303    }
304
305    /// Record an ACK for the current emergency
306    ///
307    /// Returns true if the ACK was new (state changed)
308    pub fn ack_emergency(&mut self, node_id: u32) -> bool {
309        if let Some(ref mut emergency) = self.emergency {
310            if emergency.ack(node_id) {
311                self.increment_version();
312                return true;
313            }
314        }
315        false
316    }
317
318    /// Clear the emergency event
319    pub fn clear_emergency(&mut self) {
320        if self.emergency.is_some() {
321            self.emergency = None;
322            self.increment_version();
323        }
324    }
325
326    /// Get the current emergency event (if any)
327    pub fn get_emergency(&self) -> Option<&EmergencyEvent> {
328        self.emergency.as_ref()
329    }
330
331    /// Check if there's an active emergency
332    pub fn has_emergency(&self) -> bool {
333        self.emergency.is_some()
334    }
335
336    // --- Chat CRDT methods (requires `legacy-chat` feature) ---
337
338    /// Get the chat CRDT (if any)
339    #[cfg(feature = "legacy-chat")]
340    pub fn get_chat(&self) -> Option<&ChatCRDT> {
341        self.chat.as_ref()
342    }
343
344    /// Get mutable reference to the chat CRDT, creating it if needed
345    #[cfg(feature = "legacy-chat")]
346    pub fn get_or_create_chat(&mut self) -> &mut ChatCRDT {
347        if self.chat.is_none() {
348            self.chat = Some(ChatCRDT::new());
349        }
350        self.chat.as_mut().unwrap()
351    }
352
353    /// Add a chat message to the document
354    ///
355    /// Returns true if the message was new (not a duplicate)
356    #[cfg(feature = "legacy-chat")]
357    pub fn add_chat_message(
358        &mut self,
359        origin_node: u32,
360        timestamp: u64,
361        sender: &str,
362        text: &str,
363    ) -> bool {
364        use crate::sync::crdt::ChatMessage;
365
366        let mut msg = ChatMessage::new(origin_node, timestamp, sender, text);
367        msg.is_broadcast = true;
368
369        let chat = self.get_or_create_chat();
370        if chat.add_message(msg) {
371            self.increment_counter();
372            true
373        } else {
374            false
375        }
376    }
377
378    /// Add a chat message with reply-to information
379    #[cfg(feature = "legacy-chat")]
380    pub fn add_chat_reply(
381        &mut self,
382        origin_node: u32,
383        timestamp: u64,
384        sender: &str,
385        text: &str,
386        reply_to_node: u32,
387        reply_to_timestamp: u64,
388    ) -> bool {
389        use crate::sync::crdt::ChatMessage;
390
391        let mut msg = ChatMessage::new(origin_node, timestamp, sender, text);
392        msg.is_broadcast = true;
393        msg.set_reply_to(reply_to_node, reply_to_timestamp);
394
395        let chat = self.get_or_create_chat();
396        if chat.add_message(msg) {
397            self.increment_counter();
398            true
399        } else {
400            false
401        }
402    }
403
404    /// Check if the document has any chat messages
405    #[cfg(feature = "legacy-chat")]
406    pub fn has_chat(&self) -> bool {
407        self.chat.as_ref().is_some_and(|c| !c.is_empty())
408    }
409
410    /// Get the number of chat messages
411    #[cfg(feature = "legacy-chat")]
412    pub fn chat_count(&self) -> usize {
413        self.chat.as_ref().map_or(0, |c| c.len())
414    }
415
416    /// Merge with another document using CRDT semantics
417    ///
418    /// Returns true if our state changed (useful for triggering re-broadcast)
419    pub fn merge(&mut self, other: &PeatDocument) -> bool {
420        let mut changed = false;
421
422        // Merge counter
423        let old_value = self.counter.value();
424        self.counter.merge(&other.counter);
425        if self.counter.value() != old_value {
426            changed = true;
427        }
428
429        // Merge emergency event
430        if let Some(ref other_emergency) = other.emergency {
431            match &mut self.emergency {
432                Some(ref mut our_emergency) => {
433                    if our_emergency.merge(other_emergency) {
434                        changed = true;
435                    }
436                }
437                None => {
438                    self.emergency = Some(other_emergency.clone());
439                    changed = true;
440                }
441            }
442        }
443
444        // Merge chat CRDT
445        #[cfg(feature = "legacy-chat")]
446        if let Some(ref other_chat) = other.chat {
447            match &mut self.chat {
448                Some(ref mut our_chat) => {
449                    if our_chat.merge(other_chat) {
450                        changed = true;
451                    }
452                }
453                None => {
454                    if !other_chat.is_empty() {
455                        self.chat = Some(other_chat.clone());
456                        changed = true;
457                    }
458                }
459            }
460        }
461
462        if changed {
463            self.increment_version();
464        }
465        changed
466    }
467
468    /// Get the current event type (if any)
469    pub fn current_event(&self) -> Option<EventType> {
470        self.peripheral
471            .as_ref()
472            .and_then(|p| p.last_event.as_ref())
473            .map(|e| e.event_type)
474    }
475
476    /// Encode to bytes for BLE transmission
477    ///
478    /// Alias: [`Self::to_bytes()`]
479    pub fn encode(&self) -> Vec<u8> {
480        let counter_data = self.counter.encode();
481        let peripheral_data = self.peripheral.as_ref().map(|p| p.encode());
482        let emergency_data = self.emergency.as_ref().map(|e| e.encode());
483        #[cfg(feature = "legacy-chat")]
484        let chat_data = self
485            .chat
486            .as_ref()
487            .filter(|c| !c.is_empty())
488            .map(|c| c.encode());
489        #[cfg(not(feature = "legacy-chat"))]
490        let chat_data: Option<Vec<u8>> = None;
491
492        // Calculate total size
493        let mut size = 8 + counter_data.len(); // header + counter
494        if let Some(ref pdata) = peripheral_data {
495            size += 4 + pdata.len(); // marker + reserved + len + peripheral
496        }
497        if let Some(ref edata) = emergency_data {
498            size += 4 + edata.len(); // marker + reserved + len + emergency
499        }
500        if let Some(ref cdata) = chat_data {
501            size += 4 + cdata.len(); // marker + reserved + len + chat
502        }
503
504        let mut buf = Vec::with_capacity(size);
505
506        // Header
507        buf.extend_from_slice(&self.version.to_le_bytes());
508        buf.extend_from_slice(&self.node_id.as_u32().to_le_bytes());
509
510        // GCounter
511        buf.extend_from_slice(&counter_data);
512
513        // Extended section (if peripheral present)
514        if let Some(pdata) = peripheral_data {
515            buf.push(EXTENDED_MARKER);
516            buf.push(0); // reserved
517            buf.extend_from_slice(&(pdata.len() as u16).to_le_bytes());
518            buf.extend_from_slice(&pdata);
519        }
520
521        // Emergency section (if emergency present)
522        if let Some(edata) = emergency_data {
523            buf.push(EMERGENCY_MARKER);
524            buf.push(0); // reserved
525            buf.extend_from_slice(&(edata.len() as u16).to_le_bytes());
526            buf.extend_from_slice(&edata);
527        }
528
529        // Chat section (if chat has messages)
530        if let Some(cdata) = chat_data {
531            buf.push(CHAT_MARKER);
532            buf.push(0); // reserved
533            buf.extend_from_slice(&(cdata.len() as u16).to_le_bytes());
534            buf.extend_from_slice(&cdata);
535        }
536
537        buf
538    }
539
540    /// Encode to bytes for transmission (alias for [`Self::encode()`])
541    ///
542    /// This is the conventional name used by external crates like peat-ffi
543    /// for transport-agnostic document serialization.
544    #[inline]
545    pub fn to_bytes(&self) -> Vec<u8> {
546        self.encode()
547    }
548
549    /// Decode from bytes received over BLE
550    ///
551    /// Alias: [`Self::from_bytes()`]
552    pub fn decode(data: &[u8]) -> Option<Self> {
553        if data.len() < MIN_DOCUMENT_SIZE {
554            return None;
555        }
556
557        // Header
558        let version = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
559        let node_id = NodeId::new(u32::from_le_bytes([data[4], data[5], data[6], data[7]]));
560
561        // GCounter (starts at offset 8)
562        let counter = GCounter::decode(&data[8..])?;
563
564        // Calculate where counter ends
565        let num_entries = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
566        let mut offset = 8 + 4 + num_entries * 12;
567
568        let mut peripheral = None;
569        let mut emergency = None;
570        #[cfg(feature = "legacy-chat")]
571        let mut chat = None;
572
573        // Parse extended sections (can have peripheral, emergency, and/or chat)
574        while offset < data.len() {
575            let marker = data[offset];
576
577            if marker == EXTENDED_MARKER {
578                // Parse peripheral section
579                if data.len() < offset + 4 {
580                    break;
581                }
582                let _reserved = data[offset + 1];
583                let section_len = u16::from_le_bytes([data[offset + 2], data[offset + 3]]) as usize;
584
585                let section_start = offset + 4;
586                if data.len() < section_start + section_len {
587                    break;
588                }
589
590                peripheral = Peripheral::decode(&data[section_start..section_start + section_len]);
591                offset = section_start + section_len;
592            } else if marker == EMERGENCY_MARKER {
593                // Parse emergency section
594                if data.len() < offset + 4 {
595                    break;
596                }
597                let _reserved = data[offset + 1];
598                let section_len = u16::from_le_bytes([data[offset + 2], data[offset + 3]]) as usize;
599
600                let section_start = offset + 4;
601                if data.len() < section_start + section_len {
602                    break;
603                }
604
605                emergency =
606                    EmergencyEvent::decode(&data[section_start..section_start + section_len]);
607                offset = section_start + section_len;
608            } else if marker == CHAT_MARKER {
609                // Skip/parse chat section (silently skip if legacy-chat not enabled)
610                if data.len() < offset + 4 {
611                    break;
612                }
613                let _reserved = data[offset + 1];
614                let section_len = u16::from_le_bytes([data[offset + 2], data[offset + 3]]) as usize;
615
616                let section_start = offset + 4;
617                if data.len() < section_start + section_len {
618                    break;
619                }
620
621                #[cfg(feature = "legacy-chat")]
622                {
623                    chat = ChatCRDT::decode(&data[section_start..section_start + section_len]);
624                }
625                offset = section_start + section_len;
626            } else {
627                // Unknown marker, stop parsing
628                break;
629            }
630        }
631
632        Some(Self {
633            version,
634            node_id,
635            counter,
636            peripheral,
637            emergency,
638            #[cfg(feature = "legacy-chat")]
639            chat,
640        })
641    }
642
643    /// Decode from bytes (alias for [`Self::decode()`])
644    ///
645    /// This is the conventional name used by external crates like peat-ffi
646    /// for transport-agnostic document deserialization.
647    #[inline]
648    pub fn from_bytes(data: &[u8]) -> Option<Self> {
649        Self::decode(data)
650    }
651
652    /// Get the total counter value
653    pub fn total_count(&self) -> u64 {
654        self.counter.value()
655    }
656
657    /// Get the encoded size of this document
658    ///
659    /// Use this to check if the document fits within BLE MTU constraints.
660    pub fn encoded_size(&self) -> usize {
661        let counter_size = 4 + self.counter.node_count_total() * 12;
662        let peripheral_size = self.peripheral.as_ref().map_or(0, |p| 4 + p.encode().len());
663        let emergency_size = self.emergency.as_ref().map_or(0, |e| 4 + e.encode().len());
664        #[cfg(feature = "legacy-chat")]
665        let chat_size = self
666            .chat
667            .as_ref()
668            .filter(|c| !c.is_empty())
669            .map_or(0, |c| 4 + c.encoded_size());
670        #[cfg(not(feature = "legacy-chat"))]
671        let chat_size = 0;
672        8 + counter_size + peripheral_size + emergency_size + chat_size
673    }
674
675    /// Check if the document exceeds the target size for single-packet transmission
676    ///
677    /// Returns `true` if the document is larger than [`TARGET_DOCUMENT_SIZE`].
678    pub fn exceeds_target_size(&self) -> bool {
679        self.encoded_size() > TARGET_DOCUMENT_SIZE
680    }
681
682    /// Check if the document exceeds the maximum size
683    ///
684    /// Returns `true` if the document is larger than [`MAX_DOCUMENT_SIZE`].
685    pub fn exceeds_max_size(&self) -> bool {
686        self.encoded_size() > MAX_DOCUMENT_SIZE
687    }
688}
689
690/// Result from merging a received document
691#[derive(Debug, Clone)]
692pub struct MergeResult {
693    /// Node ID that sent this document
694    pub source_node: NodeId,
695
696    /// Event contained in the document (if any)
697    pub event: Option<PeripheralEvent>,
698
699    /// Peripheral data from the document (if any)
700    ///
701    /// Contains the sender's callsign, location, health data etc.
702    /// This allows tracking peer state from received documents.
703    pub peer_peripheral: Option<Peripheral>,
704
705    /// Whether the counter changed (indicates new data)
706    pub counter_changed: bool,
707
708    /// Whether the emergency state changed (new emergency or ACK updates)
709    pub emergency_changed: bool,
710
711    /// Whether chat messages changed (new messages received)
712    pub chat_changed: bool,
713
714    /// Updated total count after merge
715    pub total_count: u64,
716}
717
718impl MergeResult {
719    /// Check if this result contains an emergency event
720    pub fn is_emergency(&self) -> bool {
721        self.event
722            .as_ref()
723            .is_some_and(|e| e.event_type == EventType::Emergency)
724    }
725
726    /// Check if this result contains an ACK event
727    pub fn is_ack(&self) -> bool {
728        self.event
729            .as_ref()
730            .is_some_and(|e| e.event_type == EventType::Ack)
731    }
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use crate::sync::crdt::PeripheralType;
738
739    // Valid timestamp for tests: 2024-01-15 00:00:00 UTC
740    const TEST_TIMESTAMP: u64 = 1705276800000;
741
742    #[test]
743    fn test_document_encode_decode_minimal() {
744        let node_id = NodeId::new(0x12345678);
745        let doc = PeatDocument::new(node_id);
746
747        let encoded = doc.encode();
748        assert_eq!(encoded.len(), 12); // 8 header + 4 counter (0 entries)
749
750        let decoded = PeatDocument::decode(&encoded).unwrap();
751        assert_eq!(decoded.version, 1);
752        assert_eq!(decoded.node_id.as_u32(), 0x12345678);
753        assert_eq!(decoded.counter.value(), 0);
754        assert!(decoded.peripheral.is_none());
755    }
756
757    #[test]
758    fn test_document_encode_decode_with_counter() {
759        let node_id = NodeId::new(0x12345678);
760        let mut doc = PeatDocument::new(node_id);
761        doc.increment_counter();
762        doc.increment_counter();
763
764        let encoded = doc.encode();
765        // 8 header + 4 num_entries + 1 entry (12 bytes) = 24
766        assert_eq!(encoded.len(), 24);
767
768        let decoded = PeatDocument::decode(&encoded).unwrap();
769        assert_eq!(decoded.counter.value(), 2);
770    }
771
772    #[test]
773    fn test_document_encode_decode_with_peripheral() {
774        let node_id = NodeId::new(0x12345678);
775        let peripheral =
776            Peripheral::new(0xAABBCCDD, PeripheralType::SoldierSensor).with_callsign("ALPHA-1");
777
778        let doc = PeatDocument::new(node_id).with_peripheral(peripheral);
779
780        let encoded = doc.encode();
781        let decoded = PeatDocument::decode(&encoded).unwrap();
782
783        assert!(decoded.peripheral.is_some());
784        let p = decoded.peripheral.unwrap();
785        assert_eq!(p.id, 0xAABBCCDD);
786        assert_eq!(p.callsign_str(), "ALPHA-1");
787    }
788
789    #[test]
790    fn test_document_encode_decode_with_event() {
791        let node_id = NodeId::new(0x12345678);
792        let mut peripheral = Peripheral::new(0xAABBCCDD, PeripheralType::SoldierSensor);
793        peripheral.set_event(EventType::Emergency, TEST_TIMESTAMP);
794
795        let doc = PeatDocument::new(node_id).with_peripheral(peripheral);
796
797        let encoded = doc.encode();
798        let decoded = PeatDocument::decode(&encoded).unwrap();
799
800        assert!(decoded.peripheral.is_some());
801        let p = decoded.peripheral.unwrap();
802        assert!(p.last_event.is_some());
803        let event = p.last_event.unwrap();
804        assert_eq!(event.event_type, EventType::Emergency);
805        assert_eq!(event.timestamp, TEST_TIMESTAMP);
806    }
807
808    #[test]
809    fn test_document_merge() {
810        let node1 = NodeId::new(0x11111111);
811        let node2 = NodeId::new(0x22222222);
812
813        let mut doc1 = PeatDocument::new(node1);
814        doc1.increment_counter();
815
816        let mut doc2 = PeatDocument::new(node2);
817        doc2.counter.increment(&node2, 3);
818
819        // Merge doc2 into doc1
820        let changed = doc1.merge(&doc2);
821        assert!(changed);
822        assert_eq!(doc1.counter.value(), 4); // 1 + 3
823    }
824
825    #[test]
826    fn test_merge_result_helpers() {
827        let emergency_event = PeripheralEvent::new(EventType::Emergency, 123);
828        let result = MergeResult {
829            source_node: NodeId::new(0x12345678),
830            event: Some(emergency_event),
831            peer_peripheral: None,
832            counter_changed: true,
833            emergency_changed: false,
834            chat_changed: false,
835            total_count: 10,
836        };
837
838        assert!(result.is_emergency());
839        assert!(!result.is_ack());
840
841        let ack_event = PeripheralEvent::new(EventType::Ack, 456);
842        let result = MergeResult {
843            source_node: NodeId::new(0x12345678),
844            event: Some(ack_event),
845            peer_peripheral: None,
846            counter_changed: false,
847            emergency_changed: false,
848            chat_changed: false,
849            total_count: 10,
850        };
851
852        assert!(!result.is_emergency());
853        assert!(result.is_ack());
854    }
855
856    #[test]
857    fn test_document_size_calculation() {
858        use crate::sync::crdt::PeripheralType;
859
860        let node_id = NodeId::new(0x12345678);
861
862        // Minimal document: 8 header + 4 counter (0 entries) = 12 bytes
863        let doc = PeatDocument::new(node_id);
864        assert_eq!(doc.encoded_size(), 12);
865        assert!(!doc.exceeds_target_size());
866
867        // With one counter entry: 8 + (4 + 12) = 24 bytes
868        let mut doc = PeatDocument::new(node_id);
869        doc.increment_counter();
870        assert_eq!(doc.encoded_size(), 24);
871
872        // With peripheral: adds ~42 bytes (4 marker/len + 38 data)
873        let peripheral = Peripheral::new(0xAABBCCDD, PeripheralType::SoldierSensor);
874        let doc = PeatDocument::new(node_id).with_peripheral(peripheral);
875        let encoded = doc.encode();
876        assert_eq!(doc.encoded_size(), encoded.len());
877
878        // Verify size stays under target for reasonable mesh
879        let mut doc = PeatDocument::new(node_id);
880        for i in 0..10 {
881            doc.counter.increment(&NodeId::new(i), 1);
882        }
883        assert!(doc.encoded_size() < TARGET_DOCUMENT_SIZE);
884        assert!(!doc.exceeds_max_size());
885    }
886
887    // ============================================================================
888    // Chat CRDT Document Tests (gated behind legacy-chat)
889    // ============================================================================
890
891    #[cfg(feature = "legacy-chat")]
892    mod chat_document_tests {
893        use super::*;
894
895        #[test]
896        fn test_document_add_chat_message() {
897            let node_id = NodeId::new(0x12345678);
898            let mut doc = PeatDocument::new(node_id);
899
900            assert!(!doc.has_chat());
901            assert_eq!(doc.chat_count(), 0);
902
903            // Add a message
904            assert!(doc.add_chat_message(0x12345678, TEST_TIMESTAMP, "ALPHA", "Hello mesh!"));
905            assert!(doc.has_chat());
906            assert_eq!(doc.chat_count(), 1);
907
908            // Duplicate should be rejected
909            assert!(!doc.add_chat_message(0x12345678, TEST_TIMESTAMP, "ALPHA", "Hello mesh!"));
910            assert_eq!(doc.chat_count(), 1);
911
912            // Different message should be accepted
913            assert!(doc.add_chat_message(
914                0x12345678,
915                TEST_TIMESTAMP + 1000,
916                "ALPHA",
917                "Second message"
918            ));
919            assert_eq!(doc.chat_count(), 2);
920        }
921
922        #[test]
923        fn test_document_add_chat_reply() {
924            let node_id = NodeId::new(0x12345678);
925            let mut doc = PeatDocument::new(node_id);
926
927            // Add original message
928            doc.add_chat_message(0xAABBCCDD, TEST_TIMESTAMP, "BRAVO", "Need assistance");
929
930            // Add reply
931            assert!(doc.add_chat_reply(
932                0x12345678,
933                TEST_TIMESTAMP + 1000,
934                "ALPHA",
935                "Copy that",
936                0xAABBCCDD,     // reply to node
937                TEST_TIMESTAMP  // reply to timestamp
938            ));
939
940            assert_eq!(doc.chat_count(), 2);
941
942            // Verify reply-to info
943            let chat = doc.get_chat().unwrap();
944            let reply = chat.get_message(0x12345678, TEST_TIMESTAMP + 1000).unwrap();
945            assert!(reply.is_reply());
946            assert_eq!(reply.reply_to_node, 0xAABBCCDD);
947            assert_eq!(reply.reply_to_timestamp, TEST_TIMESTAMP);
948        }
949
950        #[test]
951        fn test_document_encode_decode_with_chat() {
952            let node_id = NodeId::new(0x12345678);
953            let mut doc = PeatDocument::new(node_id);
954
955            doc.add_chat_message(0x12345678, TEST_TIMESTAMP, "ALPHA", "First message");
956            doc.add_chat_message(0xAABBCCDD, TEST_TIMESTAMP + 1000, "BRAVO", "Second message");
957
958            let encoded = doc.encode();
959            let decoded = PeatDocument::decode(&encoded).unwrap();
960
961            assert!(decoded.has_chat());
962            assert_eq!(decoded.chat_count(), 2);
963
964            let chat = decoded.get_chat().unwrap();
965            let msg1 = chat.get_message(0x12345678, TEST_TIMESTAMP).unwrap();
966            assert_eq!(msg1.sender(), "ALPHA");
967            assert_eq!(msg1.text(), "First message");
968
969            let msg2 = chat.get_message(0xAABBCCDD, TEST_TIMESTAMP + 1000).unwrap();
970            assert_eq!(msg2.sender(), "BRAVO");
971            assert_eq!(msg2.text(), "Second message");
972        }
973
974        #[test]
975        fn test_document_merge_with_chat() {
976            let node1 = NodeId::new(0x11111111);
977            let node2 = NodeId::new(0x22222222);
978
979            let mut doc1 = PeatDocument::new(node1);
980            doc1.add_chat_message(0x11111111, TEST_TIMESTAMP, "ALPHA", "From node 1");
981
982            let mut doc2 = PeatDocument::new(node2);
983            doc2.add_chat_message(0x22222222, TEST_TIMESTAMP + 1000, "BRAVO", "From node 2");
984
985            // Merge doc2 into doc1
986            let changed = doc1.merge(&doc2);
987            assert!(changed);
988            assert_eq!(doc1.chat_count(), 2);
989
990            // Merge again - no changes
991            let changed = doc1.merge(&doc2);
992            assert!(!changed);
993
994            // Verify both messages present
995            let chat = doc1.get_chat().unwrap();
996            assert!(chat.get_message(0x11111111, TEST_TIMESTAMP).is_some());
997            assert!(chat
998                .get_message(0x22222222, TEST_TIMESTAMP + 1000)
999                .is_some());
1000        }
1001
1002        #[test]
1003        fn test_document_chat_encoded_size() {
1004            let node_id = NodeId::new(0x12345678);
1005            let mut doc = PeatDocument::new(node_id);
1006
1007            let base_size = doc.encoded_size();
1008
1009            // Add a message
1010            doc.add_chat_message(0x12345678, TEST_TIMESTAMP, "ALPHA", "Test");
1011
1012            // Size should increase
1013            let with_chat_size = doc.encoded_size();
1014            assert!(with_chat_size > base_size);
1015
1016            // Encoded size should match actual encoded length
1017            let encoded = doc.encode();
1018            assert_eq!(doc.encoded_size(), encoded.len());
1019        }
1020    } // mod chat_document_tests
1021}