P25 Porting Guide

APCO P25 (Project 25) - Public safety digital radio standard used by police, fire, and EMS.

Overview

Property Value
Frequency Bands VHF, UHF, 700/800 MHz
Phase 1 Modulation C4FM (Continuous 4-level FM) / CQPSK (LSM)
Phase 2 Modulation H-DQPSK TDMA
Symbol Rate 4800 baud (Phase 1), 6000 baud (Phase 2)
Voice Codec IMBE (Phase 1), AMBE+2 (Phase 2)
Channel Bandwidth 12.5 kHz (Phase 1), 6.25 kHz equivalent (Phase 2)

Implementation Status

┌─────────────────────────────────────────────────────────────────┐
│ P25 Implementation Status                                       │
├─────────────────────────────────────────────────────────────────┤
│ [██████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 47%          │
├─────────────────────────────────────────────────────────────────┤
│ ✅ C4FM Modulation               (12% effort) - Complete        │
│ ✅ CQPSK Modulation              (10% effort) - Complete        │
│ ✅ H-DQPSK (Phase 2)             (12% effort) - Complete        │
│ ✅ Frame Sync                    (8% effort)  - Complete        │
│ ✅ NID/NAC Encoding              (5% effort)  - Complete        │
│ ❌ IMBE Voice Codec              (18% effort) - Not implemented │
│ ❌ AMBE+2 Voice Codec            (15% effort) - Not implemented │
│ ❌ Trunking Protocol             (12% effort) - Not implemented │
│ ❌ AES Encryption                (8% effort)  - Not implemented │
└─────────────────────────────────────────────────────────────────┘

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                        P25 Radio                                │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌──────────────┐              ┌──────────────┐                 │
│  │    Voice     │              │    Data      │                 │
│  └──────┬───────┘              └──────┬───────┘                 │
│         │                             │                         │
│  ┌──────▼───────┐              ┌──────▼───────┐                 │
│  │ Voice Codec  │              │    TSBK/PDU  │                 │
│  │ IMBE/AMBE+2  │              │   Formatting │                 │
│  │ (PROPRIETARY)│              │(Unclassified)│                 │
│  └──────┬───────┘              └──────┬───────┘                 │
│         │                             │                         │
│         └─────────────┬───────────────┘                         │
│                       │                                         │
│         ┌─────────────▼─────────────┐                           │
│         │      Optional AES         │                           │
│         │      Encryption           │                           │
│         │    (Unclassified)         │                           │
│         └─────────────┬─────────────┘                           │
│                       │                                         │
│         ┌─────────────▼─────────────┐                           │
│         │    Frame Assembly         │                           │
│         │  NID, NAC, DUID, FEC      │                           │
│         │    (Unclassified)         │                           │
│         └─────────────┬─────────────┘                           │
│                       │                                         │
│         ┌─────────────▼─────────────┐                           │
│         │     Modulator             │                           │
│         │  C4FM / CQPSK / H-DQPSK   │                           │
│         │    (Unclassified)         │                           │
│         └─────────────┬─────────────┘                           │
│                       │                                         │
│                       ▼                                         │
│                  RF Output                                      │
└─────────────────────────────────────────────────────────────────┘

Components to Implement

Unlike classified military waveforms, P25’s missing components are proprietary (voice codecs) or unclassified (trunking, encryption). These can be implemented directly in the open-source project.

1. Voice Codec Options (33% of total effort)

The IMBE and AMBE+2 codecs are owned by DVSI (Digital Voice Systems, Inc.).

Option A: DVSI License (Full Compatibility)

Purchase a license from DVSI for legal codec implementation.

// With DVSI license, implement codec directly
pub struct ImbeCodec {
    // Licensed implementation
}

impl VoiceCodec for ImbeCodec {
    fn encode(&self, pcm: &[i16]) -> Vec<u8> {
        // IMBE encoding (licensed)
    }

    fn decode(&self, imbe_frame: &[u8]) -> Vec<i16> {
        // IMBE decoding (licensed)
    }
}

Contact: Digital Voice Systems, Inc. - https://www.dvsinc.com/

Option B: Hardware Vocoder (DV3000)

Use DVSI’s DV3000 USB dongle for codec operations.

// crates/r4w-core/src/waveform/p25/dv3000.rs
use serialport::SerialPort;

pub struct Dv3000Codec {
    port: Box<dyn SerialPort>,
}

impl Dv3000Codec {
    pub fn new(device: &str) -> Result<Self, Error> {
        let port = serialport::new(device, 460800)
            .timeout(Duration::from_millis(100))
            .open()?;

        Ok(Self { port })
    }

    /// Send PCM audio, receive AMBE frames
    pub fn encode_frame(&mut self, pcm: &[i16; 160]) -> Result<[u8; 9], Error> {
        // Send PCM to DV3000
        let cmd = self.build_encode_command(pcm);
        self.port.write_all(&cmd)?;

        // Read AMBE response
        let mut response = [0u8; 9];
        self.port.read_exact(&mut response)?;

        Ok(response)
    }

    /// Send AMBE frames, receive PCM audio
    pub fn decode_frame(&mut self, ambe: &[u8; 9]) -> Result<[i16; 160], Error> {
        // Send AMBE to DV3000
        let cmd = self.build_decode_command(ambe);
        self.port.write_all(&cmd)?;

        // Read PCM response
        let mut response = [0u8; 320];
        self.port.read_exact(&mut response)?;

        // Convert bytes to i16
        let pcm: [i16; 160] = // ...
        Ok(pcm)
    }
}

impl VoiceCodec for Dv3000Codec {
    fn encode(&self, pcm: &[i16]) -> Vec<u8> {
        // Process in 160-sample (20ms) frames
        pcm.chunks(160)
            .map(|chunk| {
                let mut frame = [0i16; 160];
                frame[..chunk.len()].copy_from_slice(chunk);
                self.encode_frame(&frame).unwrap()
            })
            .flatten()
            .collect()
    }

    fn decode(&self, encoded: &[u8]) -> Vec<i16> {
        // Process in 9-byte AMBE frames
        encoded.chunks(9)
            .map(|chunk| {
                let mut frame = [0u8; 9];
                frame[..chunk.len()].copy_from_slice(chunk);
                self.decode_frame(&frame).unwrap()
            })
            .flatten()
            .collect()
    }
}

The open-source mbelib can decode (not encode) IMBE/AMBE. Legal status is uncertain.

// FFI to mbelib - USE AT YOUR OWN RISK
#[link(name = "mbe")]
extern "C" {
    fn mbe_initMbeParms(
        cur_mp: *mut mbe_parms,
        prev_mp: *mut mbe_parms,
        prev_mp_enhanced: *mut mbe_parms,
    );

    fn mbe_processImbe4400Dataf(
        audio_out: *mut f32,
        errors: *mut i32,
        errors2: *mut i32,
        err_str: *mut c_char,
        imbe_d: *const c_char,
        cur_mp: *mut mbe_parms,
        prev_mp: *mut mbe_parms,
        prev_mp_enhanced: *mut mbe_parms,
        uvquality: i32,
    ) -> i32;
}

Option D: Codec2 (Open Source - Not Compatible)

Use Codec2 for voice, but it won’t interoperate with real P25 radios.

// For training/simulation only
use codec2::Codec2;

pub struct Codec2Voice {
    codec: Codec2,
}

impl Codec2Voice {
    pub fn new() -> Self {
        Self {
            codec: Codec2::new(Codec2Mode::Mode3200),
        }
    }
}

// Note: This will NOT work with actual P25 radios
// Only for simulation/training purposes

2. Trunking Protocol (12% of total effort)

P25 trunking is fully unclassified and documented in TIA standards.

// crates/r4w-core/src/waveform/p25/trunking.rs

pub struct TrunkingController {
    control_channel: Frequency,
    talkgroups: Vec<Talkgroup>,
    current_voice_channel: Option<Frequency>,
    state: TrunkingState,
}

#[derive(Clone, Debug)]
pub struct Talkgroup {
    pub id: u16,
    pub name: String,
    pub priority: u8,
    pub encrypted: bool,
}

#[derive(Clone, Copy, Debug)]
pub enum TrunkingState {
    Idle,
    Scanning,
    MonitoringControl,
    OnVoiceChannel(Frequency),
    Transmitting,
}

impl TrunkingController {
    /// Process Trunking Signaling Block (TSBK)
    pub fn process_tsbk(&mut self, tsbk: &[u8]) -> Option<TrunkingEvent> {
        let opcode = tsbk[0];

        match opcode {
            0x00 => self.process_grp_v_ch_grant(tsbk),      // Group Voice Channel Grant
            0x02 => self.process_grp_v_ch_grant_updt(tsbk), // Grant Update
            0x20 => self.process_iden_up(tsbk),              // Identifier Update
            0x34 => self.process_net_stat_bcast(tsbk),       // Network Status
            0x3D => self.process_adj_st_bcast(tsbk),         // Adjacent Status
            _ => None,
        }
    }

    /// Request voice channel for talkgroup
    pub fn request_channel(&mut self, talkgroup: u16) -> TrunkingRequest {
        TrunkingRequest::GroupVoiceRequest { talkgroup }
    }

    /// Follow channel grant
    pub fn follow_grant(&mut self, channel: Frequency) {
        self.current_voice_channel = Some(channel);
        self.state = TrunkingState::OnVoiceChannel(channel);
    }
}

3. AES Encryption (8% of total effort)

P25 uses standard AES-256, which is unclassified.

// crates/r4w-core/src/waveform/p25/encryption.rs
use aes::Aes256;
use cipher::{KeyIvInit, StreamCipher};
use ofb::Ofb;

type Aes256Ofb = Ofb<Aes256>;

pub struct P25Encryption {
    key: Option<[u8; 32]>,
    key_id: u16,
    algorithm_id: u8,  // 0x80 = unencrypted, 0x81 = AES-256
}

impl P25Encryption {
    pub fn new() -> Self {
        Self {
            key: None,
            key_id: 0,
            algorithm_id: 0x80,  // Unencrypted by default
        }
    }

    pub fn load_key(&mut self, key_id: u16, key: [u8; 32]) {
        self.key_id = key_id;
        self.key = Some(key);
        self.algorithm_id = 0x81;  // AES-256
    }

    pub fn encrypt(&self, plaintext: &[u8], mi: &[u8; 9]) -> Vec<u8> {
        if self.algorithm_id == 0x80 {
            return plaintext.to_vec();
        }

        let key = self.key.as_ref().expect("No key loaded");

        // Build IV from Message Indicator
        let mut iv = [0u8; 16];
        iv[..9].copy_from_slice(mi);

        let mut cipher = Aes256Ofb::new(key.into(), &iv.into());
        let mut ciphertext = plaintext.to_vec();
        cipher.apply_keystream(&mut ciphertext);

        ciphertext
    }

    pub fn decrypt(&self, ciphertext: &[u8], mi: &[u8; 9]) -> Result<Vec<u8>, P25Error> {
        // AES OFB is symmetric
        Ok(self.encrypt(ciphertext, mi))
    }

    /// Build Link Control Word with encryption info
    pub fn build_lcw(&self) -> LinkControlWord {
        LinkControlWord {
            algorithm_id: self.algorithm_id,
            key_id: self.key_id,
            // ...
        }
    }
}

Step-by-Step Implementation

Phase 1: Voice Codec Integration

Choose a codec option and implement:

// crates/r4w-core/src/waveform/p25/codec.rs

/// Trait for P25 voice codecs
pub trait VoiceCodec: Send + Sync {
    /// Encode PCM audio to codec frames
    fn encode(&self, pcm: &[i16]) -> Vec<u8>;

    /// Decode codec frames to PCM audio
    fn decode(&self, frames: &[u8]) -> Vec<i16>;

    /// Get codec name
    fn name(&self) -> &str;

    /// Samples per frame (typically 160 for 20ms at 8kHz)
    fn samples_per_frame(&self) -> usize { 160 }

    /// Bytes per encoded frame
    fn bytes_per_frame(&self) -> usize;
}

/// Factory for codec selection
pub fn create_codec(codec_type: CodecType) -> Box<dyn VoiceCodec> {
    match codec_type {
        CodecType::Dv3000(device) => Box::new(Dv3000Codec::new(&device).unwrap()),
        CodecType::Codec2 => Box::new(Codec2Voice::new()),
        CodecType::Null => Box::new(NullCodec::new()),
    }
}

Phase 2: Trunking Implementation

// Main trunking integration
pub struct P25Trunked {
    p25: P25,
    trunking: TrunkingController,
    scanner: Option<Scanner>,
}

impl P25Trunked {
    /// Scan for active talkgroups
    pub fn scan(&mut self) -> Vec<ActiveCall> {
        let control_data = self.p25.demodulate_control();

        for tsbk in control_data.tsbks() {
            if let Some(event) = self.trunking.process_tsbk(tsbk) {
                match event {
                    TrunkingEvent::VoiceGrant { channel, talkgroup } => {
                        self.trunking.follow_grant(channel);
                        // Tune to voice channel and decode
                    }
                    // ... handle other events
                }
            }
        }

        self.trunking.get_active_calls()
    }
}

Phase 3: Full Integration

// crates/r4w-core/src/waveform/p25/mod.rs

pub struct P25Builder {
    sample_rate: f64,
    codec: Option<Box<dyn VoiceCodec>>,
    encryption: Option<P25Encryption>,
    trunking: Option<TrunkingController>,
    phase: Phase,
}

impl P25Builder {
    pub fn new() -> Self {
        Self {
            sample_rate: 48000.0,
            codec: None,
            encryption: None,
            trunking: None,
            phase: Phase::Phase1,
        }
    }

    pub fn with_dv3000(mut self, device: &str) -> Self {
        self.codec = Some(Box::new(Dv3000Codec::new(device).unwrap()));
        self
    }

    pub fn with_encryption(mut self, key_id: u16, key: [u8; 32]) -> Self {
        let mut enc = P25Encryption::new();
        enc.load_key(key_id, key);
        self.encryption = Some(enc);
        self
    }

    pub fn with_trunking(mut self, control_freq: Frequency) -> Self {
        self.trunking = Some(TrunkingController::new(control_freq));
        self
    }

    pub fn build(self) -> P25 {
        P25 {
            sample_rate: self.sample_rate,
            codec: self.codec.unwrap_or_else(|| Box::new(NullCodec::new())),
            encryption: self.encryption.unwrap_or_else(P25Encryption::new),
            trunking: self.trunking,
            phase: self.phase,
            // ...
        }
    }
}

Building and Testing

# Build with DV3000 support
cargo build --release --features "p25-dv3000"

# Build with Codec2 (simulation only)
cargo build --release --features "p25-codec2"

# Run tests
cargo test -p r4w-core waveform::p25

# Test with SDR
cargo run --bin r4w -- receive --waveform P25 \
    --frequency 851.0125 --device rtlsdr

Testing with Real P25 Systems

SDR Reception Test

#[test]
#[ignore]  // Requires SDR hardware
fn test_receive_local_p25() {
    let mut p25 = P25Builder::new()
        .phase(Phase::Phase1)
        .with_trunking(Frequency::from_mhz(851.0125))
        .build();

    // Receive from RTL-SDR
    let sdr = RtlSdr::open(0).unwrap();
    sdr.set_center_freq(851_012_500).unwrap();
    sdr.set_sample_rate(2_400_000).unwrap();

    loop {
        let samples = sdr.read_samples(2_400_000).unwrap();
        let baseband = downconvert(&samples, 0.0, 2_400_000.0);
        let resampled = resample(&baseband, 2_400_000.0, 48000.0);

        if let Some(frame) = p25.demodulate(&resampled) {
            println!("Received: {:?}", frame);
        }
    }
}

Interoperability with Commercial Radios

Test against Motorola, Harris, or other P25 radios:

#[test]
fn test_interop_motorola() {
    // Load reference capture from Motorola APX
    let reference = load_wav("test_data/motorola_apx_p25.wav");

    let p25 = P25Builder::new()
        .phase(Phase::Phase1)
        .build();

    let frames = p25.demodulate(&reference);

    // Verify we can decode Motorola transmission
    assert!(!frames.is_empty());
    assert!(frames.iter().any(|f| f.frame_type == FrameType::Voice));
}

Cross-Compilation

P25 is often deployed on embedded systems:

Platform Target Use Case
Raspberry Pi aarch64-unknown-linux-gnu Scanner, gateway
BeagleBone armv7-unknown-linux-gnueabihf Embedded radio
Jetson Nano aarch64-unknown-linux-gnu AI-assisted scanning
# Cross-compile for Raspberry Pi
cross build --release --target aarch64-unknown-linux-gnu

# Include DV3000 support
cross build --release --target aarch64-unknown-linux-gnu --features "p25-dv3000"
Component Legal Status Notes
RF Layer (C4FM, etc.) ✅ Open Fully documented in TIA standards
Frame Structure ✅ Open TIA-102 series
Trunking ✅ Open TIA-102.AABC
AES Encryption ✅ Open Standard AES-256
IMBE Codec ⚠️ Licensed DVSI patent, need license
AMBE+2 Codec ⚠️ Licensed DVSI patent, need license
mbelib ⚠️ Gray Area May violate patents

Recommendation: For production use, either: 1. License codecs from DVSI 2. Use DV3000 hardware vocoder 3. Use for receive-only with mbelib (at your own risk) 4. Use Codec2 for simulation/training only

Contributing

Since P25 is primarily unclassified/proprietary:

  1. RF layer improvements - Submit PR directly
  2. Trunking implementation - Submit PR directly
  3. Encryption - Submit PR directly (uses standard AES)
  4. Voice codecs - Requires DVSI license coordination
git checkout -b feature/p25-trunking
# Implement trunking protocol
cargo test
git commit -m "feat(p25): implement Phase 1 trunking controller"
git push origin feature/p25-trunking
# Create PR

See Also