APCO P25 (Project 25) - Public safety digital radio standard used by police, fire, and EMS.
| 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) |
┌─────────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────────┘
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.
The IMBE and AMBE+2 codecs are owned by DVSI (Digital Voice Systems, Inc.).
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/
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;
}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 purposesP25 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);
}
}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,
// ...
}
}
}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()),
}
}// 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()
}
}// 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,
// ...
}
}
}# 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#[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);
}
}
}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));
}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
Since P25 is primarily unclassified/proprietary:
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