update gitignore to include some python stuff; add very very preliminary Python tool that helps generate dvmhost configurations;

pull/115/head
Bryan Biedenkapp 2 months ago
parent 87ad34f539
commit 87f94b837a

3
.gitignore vendored

@ -65,6 +65,9 @@ package/
*.ini
.vs
.idea/
venv/
__pycache__/
*.pyc
# Prerequisites
*.d

@ -0,0 +1,326 @@
# dvmcfggen - Examples
This file contains complete, copy-paste ready examples for common scenarios.
## Example 0: Using the Interactive Wizard (Easiest!)
The simplest way to create any configuration:
```bash
# Start the wizard
./dvmcfg wizard
# Follow the prompts:
# 1. Choose single instance or trunked system
# 2. Select template (hotspot, repeater, etc.)
# 3. Enter system details (identity, peer ID, etc.)
# 4. Configure network settings (FNE address, port, password)
# 5. Enable/disable protocols (DMR, P25, NXDN)
# 6. Set radio parameters (color code, NAC, site ID)
# 7. Configure modem (type, serial port, levels)
# 8. Optionally add location info
# 9. Review summary and save
```
The wizard validates all inputs and provides helpful defaults. Perfect for first-time users!
---
## Example 1: Basic Hotspot Setup (CLI)
```bash
# Create hotspot configuration
./dvmcfg create \
--template hotspot \
--output /etc/dvm/hotspot.yml \
--identity "HOTSPOT-HOME" \
--peer-id 100001 \
--fne-address "fne.example.com" \
--fne-port 62031 \
--callsign "KC1ABC" \
--validate
# Customize serial port
./dvmcfg edit /etc/dvm/hotspot.yml \
system.modem.protocol.uart.port "/dev/ttyACM0"
# Adjust levels
./dvmcfg edit /etc/dvm/hotspot.yml system.modem.rxLevel 60
./dvmcfg edit /etc/dvm/hotspot.yml system.modem.txLevel 60
# Final validation
./dvmcfg validate /etc/dvm/hotspot.yml --summary
```
## Example 2: Standalone Repeater
```bash
# Create repeater configuration
./dvmcfg create \
--template repeater \
--output /etc/dvm/repeater.yml \
--identity "REPEATER-W1ABC" \
--peer-id 100002 \
--fne-address "10.0.0.100" \
--fne-port 62031 \
--callsign "W1ABC"
# Set location
./dvmcfg edit /etc/dvm/repeater.yml system.info.latitude 42.3601
./dvmcfg edit /etc/dvm/repeater.yml system.info.longitude -71.0589
./dvmcfg edit /etc/dvm/repeater.yml system.info.location "Boston, MA"
./dvmcfg edit /etc/dvm/repeater.yml system.info.power 50
# Configure modem
./dvmcfg edit /etc/dvm/repeater.yml \
system.modem.protocol.uart.port "/dev/ttyUSB0"
# Validate
./dvmcfg validate /etc/dvm/repeater.yml
```
## Example 3: Small P25 Trunked System (2 VCs)
```bash
# Create trunked system
./dvmcfg trunk create \
--base-dir /etc/dvm/site1 \
--name site1 \
--protocol p25 \
--vc-count 2 \
--identity "SITE001" \
--base-peer-id 100000 \
--fne-address "10.0.0.1" \
--fne-port 62031 \
--nac 0x001 \
--site-id 1 \
--color-code 1
# Configure modem ports
./dvmcfg edit /etc/dvm/site1/site1-cc.yml \
system.modem.protocol.uart.port "/dev/ttyUSB0"
./dvmcfg edit /etc/dvm/site1/site1-vc01.yml \
system.modem.protocol.uart.port "/dev/ttyUSB1"
./dvmcfg edit /etc/dvm/site1/site1-vc02.yml \
system.modem.protocol.uart.port "/dev/ttyUSB2"
# Update location across all configs
./dvmcfg trunk update --base-dir /etc/dvm/site1 --name site1 \
system.info.latitude 42.3601
./dvmcfg trunk update --base-dir /etc/dvm/site1 --name site1 \
system.info.longitude -71.0589
./dvmcfg trunk update --base-dir /etc/dvm/site1 --name site1 \
system.info.location "Site 1, Boston"
# Validate system
./dvmcfg trunk validate --base-dir /etc/dvm/site1 --name site1
```
## Example 4: Large P25 Trunked System (6 VCs)
```bash
# Create large trunked system
./dvmcfg trunk create \
--base-dir /etc/dvm/hub \
--name hub \
--protocol p25 \
--vc-count 6 \
--identity "HUB" \
--base-peer-id 100000 \
--base-rpc-port 9890 \
--fne-address "172.16.0.1" \
--fne-port 62031 \
--fne-password "SecurePassword123" \
--nac 0x100 \
--site-id 10
# Enable verbose logging across all
./dvmcfg trunk update --base-dir /etc/dvm/hub --name hub \
protocols.p25.verbose true
# Configure individual modem ports
for i in {0..6}; do
if [ $i -eq 0 ]; then
file="hub-cc.yml"
else
file="hub-vc0${i}.yml"
fi
./dvmcfg edit /etc/dvm/hub/$file \
system.modem.protocol.uart.port "/dev/ttyUSB${i}"
done
# Validate
./dvmcfg trunk validate --base-dir /etc/dvm/hub --name hub
```
## Example 5: DMR Trunked System
```bash
# Create DMR trunked system
./dvmcfg trunk create \
--base-dir /etc/dvm/dmr-site \
--name dmrsite \
--protocol dmr \
--vc-count 3 \
--identity "DMR001" \
--base-peer-id 200000 \
--color-code 2 \
--site-id 1
# Configure to use specific slots
./dvmcfg edit /etc/dvm/dmr-site/dmrsite-cc.yml \
protocols.dmr.control.slot 1
# Enable both slots for voice channels
./dvmcfg trunk update --base-dir /etc/dvm/dmr-site --name dmrsite \
network.slot1 true
./dvmcfg trunk update --base-dir /etc/dvm/dmr-site --name dmrsite \
network.slot2 true
# Validate
./dvmcfg trunk validate --base-dir /etc/dvm/dmr-site --name dmrsite
```
## Example 6: Conventional P25 System with Grants
```bash
# Create conventional system
./dvmcfg create \
--template conventional \
--output /etc/dvm/conventional.yml \
--identity "CONV001" \
--peer-id 100010 \
--fne-address "10.0.0.1"
# Configure control channel settings
./dvmcfg edit /etc/dvm/conventional.yml \
protocols.p25.control.interval 300
./dvmcfg edit /etc/dvm/conventional.yml \
protocols.p25.control.duration 3
# Set as authoritative
./dvmcfg edit /etc/dvm/conventional.yml \
system.config.authoritative true
# Validate
./dvmcfg validate /etc/dvm/conventional.yml
```
## Example 7: Multi-Site System Migration
```bash
# Create Site 1
./dvmcfg trunk create \
--base-dir /etc/dvm/sites/site1 \
--name site1 \
--vc-count 2 \
--identity "SITE001" \
--base-peer-id 100000 \
--site-id 1 \
--nac 0x001
# Create Site 2
./dvmcfg trunk create \
--base-dir /etc/dvm/sites/site2 \
--name site2 \
--vc-count 2 \
--identity "SITE002" \
--base-peer-id 100010 \
--site-id 2 \
--nac 0x001
# Update both sites to new FNE
for site in site1 site2; do
./dvmcfg trunk update \
--base-dir /etc/dvm/sites/$site \
--name $site \
network.address "newfne.example.com"
done
# Validate both
./dvmcfg trunk validate --base-dir /etc/dvm/sites/site1 --name site1
./dvmcfg trunk validate --base-dir /etc/dvm/sites/site2 --name site2
```
## Example 8: Testing with Null Modem
```bash
# Create test configuration with null modem
./dvmcfg create \
--template repeater \
--output /tmp/test-config.yml \
--identity "TEST" \
--peer-id 999999
# Set to null modem
./dvmcfg edit /tmp/test-config.yml system.modem.protocol.type "null"
# Enable all debug logging
./dvmcfg edit /tmp/test-config.yml protocols.dmr.debug true
./dvmcfg edit /tmp/test-config.yml protocols.p25.debug true
./dvmcfg edit /tmp/test-config.yml system.modem.debug true
# Validate
./dvmcfg validate /tmp/test-config.yml --summary
```
## Example 9: Enabling Encryption
```bash
# Create secure configuration
./dvmcfg create \
--template repeater \
--output /etc/dvm/secure.yml \
--identity "SECURE001" \
--peer-id 100020
# Enable network encryption
./dvmcfg edit /etc/dvm/secure.yml network.encrypted true
# The preshared key is automatically generated
# You can update it if needed (must be 64 hex chars):
# ./dvmcfg edit /etc/dvm/secure.yml network.presharedKey "YOUR64HEXCHARACTERS..."
# Enable REST API with SSL
./dvmcfg edit /etc/dvm/secure.yml network.restEnable true
./dvmcfg edit /etc/dvm/secure.yml network.restSsl true
# Validate
./dvmcfg validate /etc/dvm/secure.yml
```
## Example 10: Batch Configuration Update
```bash
#!/bin/bash
# Update multiple configurations at once
CONFIGS=(
"/etc/dvm/repeater1.yml"
"/etc/dvm/repeater2.yml"
"/etc/dvm/repeater3.yml"
)
NEW_FNE="10.0.0.100"
NEW_PORT=62031
for config in "${CONFIGS[@]}"; do
echo "Updating $config..."
./dvmcfg edit "$config" network.address "$NEW_FNE"
./dvmcfg edit "$config" network.port "$NEW_PORT"
./dvmcfg validate "$config"
done
echo "All configs updated and validated!"
```
## Pro Tips
1. **Always validate after edits**: Add `&& ./dvmcfg validate $CONFIG` to your commands
2. **Use variables in scripts**: Define common values as variables for consistency
3. **Test with null modem first**: Verify config before connecting hardware
4. **Sequential RPC ports**: Keep RPC ports in sequence for easier troubleshooting
5. **Document peer IDs**: Keep a spreadsheet of all peer IDs in your network
6. **Backup before bulk updates**: `cp config.yml config.yml.bak`
7. **Use trunk update for consistency**: Ensures all configs in a system match
8. **Validate entire trunk systems**: Use `trunk validate` to catch cross-config issues

@ -0,0 +1,147 @@
# dvmcfggen - Quick Reference
## Installation
```bash
cd dvmcfg
pip install -r requirements.txt
# or just run ./dvmcfg (auto-creates venv)
```
## Common Commands
### Interactive Wizard
```bash
# Guided configuration (recommended for beginners)
./dvmcfg wizard
./dvmcfg wizard --type single # Single instance
./dvmcfg wizard --type trunk # Trunked system
```
### Single Configuration
```bash
# Create from template
./dvmcfg create --template <name> --output <file> [options]
# Validate
./dvmcfg validate <file> [--summary]
# Edit value
./dvmcfg edit <file> <key> <value>
# List templates
./dvmcfg templates
```
### Trunked Systems
```bash
# Create system
./dvmcfg trunk create --base-dir <dir> --vc-count <n> [options]
# Validate system
./dvmcfg trunk validate --base-dir <dir> --name <name>
# Update all configs
./dvmcfg trunk update --base-dir <dir> --name <name> <key> <value>
```
## Quick Examples
### Hotspot
```bash
./dvmcfg create --template hotspot --output hotspot.yml \
--identity "HS001" --peer-id 100001 --callsign "KC1ABC"
```
### P25 Trunk (4 VCs)
```bash
./dvmcfg trunk create --base-dir /etc/dvm/trunk \
--protocol p25 --vc-count 4 --identity "SITE001" \
--base-peer-id 100000 --nac 0x001
```
### DMR Trunk (2 VCs)
```bash
./dvmcfg trunk create --base-dir /etc/dvm/dmr \
--protocol dmr --vc-count 2 --color-code 2
```
## Common Config Keys
```
network.id - Peer ID
network.address - FNE address
network.port - FNE port
system.identity - System ID
system.config.nac - P25 NAC
system.config.colorCode - DMR color code
system.config.siteId - Site ID
system.modem.rxLevel - RX level (0-100)
system.modem.txLevel - TX level (0-100)
protocols.p25.enable - Enable P25
protocols.dmr.enable - Enable DMR
```
## Templates
- `hotspot` - Simplex hotspot
- `repeater` - Duplex repeater
- `control-channel-p25` - P25 CC for trunking
- `control-channel-dmr` - DMR CC for trunking
- `voice-channel` - VC for trunking
- `conventional` - Conventional with grants
## File Structure
Single config:
```
config.yml
```
Trunked system (name: "test", 2 VCs):
```
test-cc.yml # Control channel
test-vc01.yml # Voice channel 1
test-vc02.yml # Voice channel 2
```
## Default Values
| Parameter | Default | Notes |
|-----------|---------|-------|
| FNE Address | 127.0.0.1 | Localhost |
| FNE Port | 62031 | Standard FNE port |
| Base Peer ID | 100000 | CC gets this, VCs increment |
| Base RPC Port | 9890 | CC gets this, VCs increment |
| P25 NAC | 0x293 | Standard NAC |
| DMR Color Code | 1 | Standard CC |
| Site ID | 1 | First site |
| Modem Type | uart | Serial modem |
## Validation Checks
- IP addresses (format and range)
- Port numbers (1-65535)
- Hex keys (length and format)
- NAC (0-4095), Color Code (0-15), RAN (0-63)
- Identity/callsign (length limits)
- RX/TX levels (0-100)
- Trunk consistency (NAC, CC, Site ID match)
## Tips
✓ Always validate after creation/editing
✓ Use `--summary` to see config overview
✓ Test with `--modem-type null` first
✓ Keep peer IDs sequential
✓ Use trunk update for system-wide changes
✓ Backup configs before bulk updates
## Help
```bash
./dvmcfg --help
./dvmcfg create --help
./dvmcfg trunk create --help
```
See USAGE.md and EXAMPLES.md for detailed documentation.

@ -0,0 +1,67 @@
# dvmcfggen - DVMHost Configuration Generator
A comprehensive Python-based configuration utility for creating/managing DVMHost configurations, including support for multi-instance trunking configurations and automatic frequency planning.
## Features
- **Complete Configuration Files**: Generated configs include ALL 265+ parameters from DVMHost's config.example.yml
- **Interactive Wizard**: Step-by-step guided configuration creation
- **Frequency Configuration**: Automatic channel assignment and identity table generation
- 6 preset frequency bands (800/700/900 MHz, UHF, VHF)
- Automatic channel ID and channel number calculation
- RX frequency calculation with offset support
- Identity table (`iden_table.dat`) generation
- **Template System**: Pre-configured templates for common deployments
- Repeater/Hotspot (duplex)
- Control Channels (P25/DMR)
- Voice Channels
- Conventional Repeater
- **Configuration Validation**: Comprehensive validation of all parameters
- **Multi-Instance Trunking**: Manage complete trunked systems
- Automatic CC + VC configuration
- Peer ID and port management
- System-wide updates
- Consistent frequency planning across all instances
- **CLI Interface**: Command-line tools for automation
- **Rich Output**: Beautiful terminal formatting and tables
## Installation
```bash
cd dvmcfg
pip install -r requirements.txt
```
## Quick Start
### Interactive Wizard (Easiest!)
```bash
./dvmcfg wizard
```
The wizard guides you step-by-step through configuration creation with prompts and validation.
### Create New Configuration (CLI)
```bash
./dvmcfg create --template hotspot --output /etc/dvm/config.yml
```
### Create Trunked System
```bash
./dvmcfg trunk create --cc-port 62031 --vc-count 2 --base-dir /etc/dvm/trunked
```
### Validate Configuration
```bash
./dvmcfg validate /etc/dvm/config.yml
```
## Templates
- **repeater**: Standalone repeater
- **control-channel**: Dedicated control channel for trunking
- **voice-channel**: Voice channel for trunking
- **conventional**: Conventional repeater with channel grants
## License
This project is licensed under the GPLv2 License - see the [LICENSE](LICENSE) file for details. Use of this project is intended, for amateur and/or educational use ONLY. Any other use is at the risk of user and all commercial purposes is strictly discouraged.

@ -0,0 +1,330 @@
# dvmcfggen - Usage Guide
# DVMCfg Usage Guide
## Getting Started
### Interactive Wizard (Recommended)
The easiest way to create configurations:
```bash
./dvmcfg wizard
```
The wizard provides:
- Step-by-step guided configuration
- Input validation in real-time
- Template selection help
- Configuration summary before saving
- Support for both single and trunked systems
**Wizard Types:**
```bash
# Auto-detect (asks user what to create)
./dvmcfg wizard
# Single instance wizard
./dvmcfg wizard --type single
# Trunking system wizard
./dvmcfg wizard --type trunk
```
---
## Command Reference
### Create Configuration
Create a new DVMHost configuration from a template:
```bash
./dvmcfg create --template <template_name> --output <file_path> [options]
```
**Options:**
- `--template` - Template to use (required)
- `--output, -o` - Output file path (required)
- `--identity` - System identity (e.g., "REPEATER01")
- `--peer-id` - Network peer ID
- `--fne-address` - FNE server address
- `--fne-port` - FNE server port
- `--callsign` - CWID callsign
- `--validate` - Validate configuration after creation
**Examples:**
```bash
# Create a basic hotspot configuration
./dvmcfg create --template hotspot --output /etc/dvm/hotspot.yml \
--identity "HOTSPOT01" --peer-id 100001 --callsign "KC1ABC"
# Create a repeater configuration
./dvmcfg create --template repeater --output /etc/dvm/repeater.yml \
--identity "REPEATER01" --peer-id 100002 \
--fne-address "192.168.1.100" --fne-port 62031
# Create P25 control channel
./dvmcfg create --template control-channel-p25 --output /etc/dvm/cc.yml \
--identity "CC001" --peer-id 100000 --validate
```
### Validate Configuration
Validate an existing configuration file:
```bash
./dvmcfg validate <config_file> [--summary]
```
**Options:**
- `--summary, -s` - Display configuration summary
**Examples:**
```bash
# Basic validation
./dvmcfg validate /etc/dvm/config.yml
# Validation with summary
./dvmcfg validate /etc/dvm/config.yml --summary
```
### Edit Configuration
Edit a specific configuration value:
```bash
./dvmcfg edit <config_file> <key> <value>
```
**Examples:**
```bash
# Change system identity
./dvmcfg edit /etc/dvm/config.yml system.identity "NEWID"
# Update FNE address
./dvmcfg edit /etc/dvm/config.yml network.address "192.168.1.200"
# Enable DMR protocol
./dvmcfg edit /etc/dvm/config.yml protocols.dmr.enable true
# Change color code
./dvmcfg edit /etc/dvm/config.yml system.config.colorCode 2
```
### Trunked System Management
#### Create Trunked System
Create a complete trunked system with control and voice channels:
```bash
./dvmcfg trunk create --base-dir <directory> [options]
```
**Options:**
- `--base-dir` - Base directory for configs (required)
- `--name` - System name (default: "trunked")
- `--protocol` - Protocol type: p25 or dmr (default: p25)
- `--vc-count` - Number of voice channels (default: 2)
- `--fne-address` - FNE address (default: 127.0.0.1)
- `--fne-port` - FNE port (default: 62031)
- `--fne-password` - FNE password
- `--base-peer-id` - Base peer ID (default: 100000)
- `--base-rpc-port` - Base RPC port (default: 9890)
- `--identity` - System identity prefix (default: SITE001)
- `--nac` - P25 NAC in hex (default: 0x293)
- `--color-code` - DMR color code (default: 1)
- `--site-id` - Site ID (default: 1)
- `--modem-type` - Modem type: uart or null (default: uart)
**Examples:**
```bash
# Create basic P25 trunked system with 2 voice channels
./dvmcfg trunk create --base-dir /etc/dvm/trunk \
--name test --vc-count 2
# Create P25 trunked system with 4 voice channels
./dvmcfg trunk create --base-dir /etc/dvm/site1 \
--name site1 --protocol p25 --vc-count 4 \
--identity "SITE001" --base-peer-id 100000 \
--fne-address "10.0.0.1" --nac 0x001
# Create DMR trunked system
./dvmcfg trunk create --base-dir /etc/dvm/dmr_trunk \
--protocol dmr --vc-count 3 --color-code 2
```
**Generated Files:**
For a system named "test" with 2 voice channels:
- `test-cc.yml` - Control channel configuration
- `test-vc01.yml` - Voice channel 1 configuration
- `test-vc02.yml` - Voice channel 2 configuration
#### Validate Trunked System
Validate all configurations in a trunked system:
```bash
./dvmcfg trunk validate --base-dir <directory> [--name <system_name>]
```
**Examples:**
```bash
# Validate trunked system
./dvmcfg trunk validate --base-dir /etc/dvm/trunk --name test
```
#### Update Trunked System
Update a setting across all configurations in a system:
```bash
./dvmcfg trunk update --base-dir <directory> --name <system_name> <key> <value>
```
**Examples:**
```bash
# Update FNE address across all configs
./dvmcfg trunk update --base-dir /etc/dvm/trunk \
--name test network.address "10.0.0.100"
# Update NAC across all configs
./dvmcfg trunk update --base-dir /etc/dvm/trunk \
--name test system.config.nac 0x001
# Enable verbose logging on all instances
./dvmcfg trunk update --base-dir /etc/dvm/trunk \
--name test protocols.p25.verbose true
```
### List Templates
Display all available configuration templates:
```bash
./dvmcfg templates
```
## Configuration Key Paths
Common configuration key paths for use with `edit` and `trunk update` commands:
### Network Settings
- `network.id` - Peer ID
- `network.address` - FNE address
- `network.port` - FNE port
- `network.password` - FNE password
- `network.encrypted` - Enable encryption (true/false)
- `network.rpcPort` - RPC port
- `network.rpcPassword` - RPC password
- `network.restEnable` - Enable REST API (true/false)
- `network.restPort` - REST API port
### System Settings
- `system.identity` - System identity
- `system.duplex` - Duplex mode (true/false)
- `system.timeout` - Call timeout (seconds)
- `system.config.colorCode` - DMR color code (0-15)
- `system.config.nac` - P25 NAC (0-4095)
- `system.config.ran` - NXDN RAN (0-63)
- `system.config.siteId` - Site ID
- `system.config.channelNo` - Channel number
### Protocol Settings
- `protocols.dmr.enable` - Enable DMR (true/false)
- `protocols.dmr.control.enable` - Enable DMR control (true/false)
- `protocols.dmr.verbose` - DMR verbose logging (true/false)
- `protocols.p25.enable` - Enable P25 (true/false)
- `protocols.p25.control.enable` - Enable P25 control (true/false)
- `protocols.p25.control.dedicated` - Dedicated control channel (true/false)
- `protocols.p25.verbose` - P25 verbose logging (true/false)
- `protocols.nxdn.enable` - Enable NXDN (true/false)
### Modem Settings
- `system.modem.protocol.type` - Modem type (uart/null)
- `system.modem.protocol.uart.port` - Serial port
- `system.modem.protocol.uart.speed` - Serial speed
- `system.modem.rxLevel` - RX level (0-100)
- `system.modem.txLevel` - TX level (0-100)
### CW ID Settings
- `system.cwId.enable` - Enable CWID (true/false)
- `system.cwId.time` - CWID interval (minutes)
- `system.cwId.callsign` - Callsign
## Workflow Examples
### Setting Up a Standalone Hotspot
```bash
# Create configuration
./dvmcfg create --template hotspot --output /etc/dvm/hotspot.yml \
--identity "HOTSPOT01" --peer-id 100001 \
--fne-address "fne.example.com" --fne-port 62031 \
--callsign "KC1ABC"
# Validate
./dvmcfg validate /etc/dvm/hotspot.yml --summary
# Customize modem settings
./dvmcfg edit /etc/dvm/hotspot.yml system.modem.protocol.uart.port "/dev/ttyACM0"
./dvmcfg edit /etc/dvm/hotspot.yml system.modem.rxLevel 60
./dvmcfg edit /etc/dvm/hotspot.yml system.modem.txLevel 60
```
### Setting Up a 4-Channel P25 Trunked System
```bash
# Create trunked system
./dvmcfg trunk create --base-dir /etc/dvm/site1 \
--name site1 --protocol p25 --vc-count 4 \
--identity "SITE001" --base-peer-id 100000 \
--fne-address "10.0.0.1" --fne-port 62031 \
--nac 0x001 --site-id 1
# Validate system
./dvmcfg trunk validate --base-dir /etc/dvm/site1 --name site1
# Update modem type on all instances
./dvmcfg trunk update --base-dir /etc/dvm/site1 \
--name site1 system.modem.protocol.type uart
# Customize individual configs as needed
./dvmcfg edit /etc/dvm/site1/site1-cc.yml \
system.modem.protocol.uart.port "/dev/ttyUSB0"
./dvmcfg edit /etc/dvm/site1/site1-vc01.yml \
system.modem.protocol.uart.port "/dev/ttyUSB1"
```
### Migrating Between FNE Servers
```bash
# Update single config
./dvmcfg edit /etc/dvm/config.yml network.address "newfne.example.com"
./dvmcfg edit /etc/dvm/config.yml network.port 62031
# Update entire trunked system
./dvmcfg trunk update --base-dir /etc/dvm/trunk \
--name test network.address "newfne.example.com"
./dvmcfg trunk update --base-dir /etc/dvm/trunk \
--name test network.port 62031
# Validate changes
./dvmcfg trunk validate --base-dir /etc/dvm/trunk --name test
```
## Tips
1. **Always validate** after creating or editing configurations
2. **Backup configurations** before making bulk updates
3. **Use consistent naming** for trunked system components
4. **Test with null modem** before connecting real hardware
5. **Keep RPC ports sequential** for easier management
6. **Document peer IDs** for your network topology

@ -0,0 +1,45 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
#/**
#* Digital Voice Modem - Host Configuration Generator
#* GPLv2 Open Source. Use is subject to license terms.
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
#*
#*/
#/*
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
#*
#* This program is free software; you can redistribute it and/or modify
#* it under the terms of the GNU General Public License as published by
#* the Free Software Foundation; either version 2 of the License, or
#* (at your option) any later version.
#*
#* This program is distributed in the hope that it will be useful,
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
#* GNU General Public License for more details.
#*
#* You should have received a copy of the GNU General Public License
#* along with this program; if not, write to the Free Software
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
#*/
"""
dvmcfggen Package
Main entry point for the DVMHost Configuration Generator
"""
__version__ = "1.0.0"
__author__ = "Bryan Biedenkapp N2PLL"
__license__ = "GPL-2.0-only"
from config_manager import DVMConfig, ConfigValidator
from templates import get_template, TEMPLATES
from trunking_manager import TrunkingSystem
__all__ = [
'DVMConfig',
'ConfigValidator',
'get_template',
'TEMPLATES',
'TrunkingSystem',
]

@ -0,0 +1,273 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
#/**
#* Digital Voice Modem - Host Configuration Generator
#* GPLv2 Open Source. Use is subject to license terms.
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
#*
#*/
#/*
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
#*
#* This program is free software; you can redistribute it and/or modify
#* it under the terms of the GNU General Public License as published by
#* the Free Software Foundation; either version 2 of the License, or
#* (at your option) any later version.
#*
#* This program is distributed in the hope that it will be useful,
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
#* GNU General Public License for more details.
#*
#* You should have received a copy of the GNU General Public License
#* along with this program; if not, write to the Free Software
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
#*/
"""
dvmcfggen - DVMHost Configuration Generator
Core module for managing DVMHost configuration files
"""
import yaml
import copy
from pathlib import Path
from typing import Dict, Any, Optional, List
import re
class ConfigValidator:
"""Validates DVMHost configuration values"""
@staticmethod
def validate_ip_address(ip: str) -> bool:
"""Validate IP address format"""
if ip in ['0.0.0.0', 'localhost']:
return True
pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
if not re.match(pattern, ip):
return False
parts = ip.split('.')
return all(0 <= int(part) <= 255 for part in parts)
@staticmethod
def validate_port(port: int) -> bool:
"""Validate port number"""
return 1 <= port <= 65535
@staticmethod
def validate_hex_key(key: str, required_length: int) -> bool:
"""Validate hexadecimal key"""
if len(key) != required_length:
return False
return all(c in '0123456789ABCDEFabcdef' for c in key)
@staticmethod
def validate_range(value: int, min_val: int, max_val: int) -> bool:
"""Validate value is within range"""
return min_val <= value <= max_val
@staticmethod
def validate_identity(identity: str) -> bool:
"""Validate system identity format"""
return len(identity) > 0 and len(identity) <= 32
@staticmethod
def validate_callsign(callsign: str) -> bool:
"""Validate callsign format"""
return len(callsign) > 0 and len(callsign) <= 16
class DVMConfig:
"""DVMHost configuration manager"""
def __init__(self, config_path: Optional[Path] = None):
"""
Initialize configuration manager
Args:
config_path: Path to existing config file to load
"""
self.config_path = config_path
self.config: Dict[str, Any] = {}
self.validator = ConfigValidator()
if config_path and config_path.exists():
self.load(config_path)
def load(self, config_path: Path) -> None:
"""Load configuration from YAML file"""
with open(config_path, 'r') as f:
self.config = yaml.safe_load(f)
self.config_path = config_path
def save(self, output_path: Optional[Path] = None) -> None:
"""Save configuration to YAML file"""
path = output_path or self.config_path
if not path:
raise ValueError("No output path specified")
# Create backup if file exists
if path.exists():
backup_path = path.with_suffix(path.suffix + '.bak')
path.rename(backup_path)
with open(path, 'w') as f:
yaml.dump(self.config, f, default_flow_style=False, sort_keys=False)
def get(self, key_path: str, default: Any = None) -> Any:
"""
Get configuration value using dot notation
Args:
key_path: Path to value (e.g., 'network.id')
default: Default value if not found
"""
keys = key_path.split('.')
value = self.config
for key in keys:
if isinstance(value, dict) and key in value:
value = value[key]
else:
return default
return value
def set(self, key_path: str, value: Any) -> None:
"""
Set configuration value using dot notation
Args:
key_path: Path to value (e.g., 'network.id')
value: Value to set
"""
keys = key_path.split('.')
config = self.config
# Navigate to the parent dict
for key in keys[:-1]:
if key not in config:
config[key] = {}
config = config[key]
# Set the value
config[keys[-1]] = value
def validate(self) -> List[str]:
"""
Validate configuration
Returns:
List of error messages (empty if valid)
"""
errors = []
# Network validation
if self.get('network.enable'):
if not self.validator.validate_ip_address(self.get('network.address', '')):
errors.append("Invalid network.address")
if not self.validator.validate_port(self.get('network.port', 0)):
errors.append("Invalid network.port")
if self.get('network.encrypted'):
psk = self.get('network.presharedKey', '')
if not self.validator.validate_hex_key(psk, 64):
errors.append("Invalid network.presharedKey (must be 64 hex characters)")
# RPC validation
rpc_addr = self.get('network.rpcAddress', '')
if rpc_addr and not self.validator.validate_ip_address(rpc_addr):
errors.append("Invalid network.rpcAddress")
rpc_port = self.get('network.rpcPort', 0)
if rpc_port and not self.validator.validate_port(rpc_port):
errors.append("Invalid network.rpcPort")
# REST API validation
if self.get('network.restEnable'):
rest_addr = self.get('network.restAddress', '')
if not self.validator.validate_ip_address(rest_addr):
errors.append("Invalid network.restAddress")
rest_port = self.get('network.restPort', 0)
if not self.validator.validate_port(rest_port):
errors.append("Invalid network.restPort")
# System validation
identity = self.get('system.identity', '')
if not self.validator.validate_identity(identity):
errors.append("Invalid system.identity")
# Color code / NAC / RAN validation
color_code = self.get('system.config.colorCode', 1)
if not self.validator.validate_range(color_code, 0, 15):
errors.append("Invalid system.config.colorCode (must be 0-15)")
nac = self.get('system.config.nac', 0)
if not self.validator.validate_range(nac, 0, 0xFFF):
errors.append("Invalid system.config.nac (must be 0-4095)")
ran = self.get('system.config.ran', 0)
if not self.validator.validate_range(ran, 0, 63):
errors.append("Invalid system.config.ran (must be 0-63)")
# CW ID validation
if self.get('system.cwId.enable'):
callsign = self.get('system.cwId.callsign', '')
if not self.validator.validate_callsign(callsign):
errors.append("Invalid system.cwId.callsign")
# Modem validation
rx_level = self.get('system.modem.rxLevel', 0)
if not self.validator.validate_range(rx_level, 0, 100):
errors.append("Invalid system.modem.rxLevel (must be 0-100)")
tx_level = self.get('system.modem.txLevel', 0)
if not self.validator.validate_range(tx_level, 0, 100):
errors.append("Invalid system.modem.txLevel (must be 0-100)")
# LLA key validation
lla_key = self.get('system.config.secure.key', '')
if lla_key and not self.validator.validate_hex_key(lla_key, 32):
errors.append("Invalid system.config.secure.key (must be 32 hex characters)")
return errors
def get_summary(self) -> Dict[str, Any]:
"""Get configuration summary"""
return {
'identity': self.get('system.identity', 'UNKNOWN'),
'peer_id': self.get('network.id', 0),
'fne_address': self.get('network.address', 'N/A'),
'fne_port': self.get('network.port', 0),
'rpc_password': self.get('network.rpcPassword', 'N/A'),
'rest_enabled': self.get('network.restEnable', False),
'rest_password': self.get('network.restPassword', 'N/A'),
'protocols': {
'dmr': self.get('protocols.dmr.enable', False),
'p25': self.get('protocols.p25.enable', False),
'nxdn': self.get('protocols.nxdn.enable', False),
},
'color_code': self.get('system.config.colorCode', 1),
'nac': self.get('system.config.nac', 0x293),
'site_id': self.get('system.config.siteId', 1),
'is_control': self.get('protocols.p25.control.enable', False) or
self.get('protocols.dmr.control.enable', False),
'modem_type': self.get('system.modem.protocol.type', 'null'),
'modem_mode': self.get('system.modem.protocol.mode', 'air'),
'modem_port': self.get('system.modem.protocol.uart.port', 'N/A'),
'mode': 'Duplex' if self.get('system.duplex', True) else 'Simplex',
'channel_id': self.get('system.config.channelId', 0),
'channel_no': self.get('system.config.channelNo', 0),
'tx_frequency': self.get('system.config.txFrequency', 0),
'rx_frequency': self.get('system.config.rxFrequency', 0),
}
if __name__ == '__main__':
# Quick test
config = DVMConfig()
config.set('system.identity', 'TEST001')
config.set('network.id', 100001)
print("Config manager initialized successfully")

@ -0,0 +1,44 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-2.0-only
#/**
#* Digital Voice Modem - Host Configuration Generator
#* GPLv2 Open Source. Use is subject to license terms.
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
#*
#*/
#/*
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
#*
#* This program is free software; you can redistribute it and/or modify
#* it under the terms of the GNU General Public License as published by
#* the Free Software Foundation; either version 2 of the License, or
#* (at your option) any later version.
#*
#* This program is distributed in the hope that it will be useful,
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
#* GNU General Public License for more details.
#*
#* You should have received a copy of the GNU General Public License
#* along with this program; if not, write to the Free Software
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
#*/
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Check if running in virtual environment
if [[ -z "$VIRTUAL_ENV" ]]; then
# Check if venv exists
if [[ ! -d "venv" ]]; then
echo "Creating virtual environment..."
python3 -m venv venv
source venv/bin/activate
echo "Installing dependencies..."
pip install -q -r requirements.txt
else
source venv/bin/activate
fi
fi
# Run dvmcfg
python3 dvmcfg.py "$@"

@ -0,0 +1,928 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
#/**
#* Digital Voice Modem - Host Configuration Generator
#* GPLv2 Open Source. Use is subject to license terms.
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
#*
#*/
#/*
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
#*
#* This program is free software; you can redistribute it and/or modify
#* it under the terms of the GNU General Public License as published by
#* the Free Software Foundation; either version 2 of the License, or
#* (at your option) any later version.
#*
#* This program is distributed in the hope that it will be useful,
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
#* GNU General Public License for more details.
#*
#* You should have received a copy of the GNU General Public License
#* along with this program; if not, write to the Free Software
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
#*/
"""
dvmcfggen - DVMHost Configuration Generator
Command-line interface for creating/managing DVMHost configurations
"""
import argparse
import sys
from pathlib import Path
from rich.console import Console
from rich.table import Table
from rich import print as rprint
from config_manager import DVMConfig
from templates import TEMPLATES, get_template
from trunking_manager import TrunkingSystem
from wizard import run_wizard, generate_random_password
from version import __BANNER__, __VER__
from iden_table import IdenTable, calculate_channel_assignment, create_iden_entry_from_preset, BAND_PRESETS
from network_ids import (
get_network_id_info, validate_dmr_color_code, validate_dmr_network_id, validate_dmr_site_id,
validate_p25_nac, validate_p25_network_id, validate_p25_system_id, validate_p25_rfss_id,
validate_p25_site_id, validate_nxdn_ran, validate_nxdn_location_id,
get_dmr_site_model_from_string
)
console = Console()
def cmd_create(args):
"""Create new configuration from template"""
try:
config = DVMConfig()
config.config = get_template(args.template)
# Apply custom settings if provided
if args.identity:
config.set('system.identity', args.identity)
if args.peer_id:
config.set('network.id', args.peer_id)
if args.fne_address:
config.set('network.address', args.fne_address)
if args.fne_port:
config.set('network.port', args.fne_port)
if args.fne_password:
config.set('network.password', args.fne_password)
if args.callsign:
config.set('system.cwId.callsign', args.callsign)
# Handle modem configuration
if args.modem_type:
config.set('system.modem.protocol.type', args.modem_type)
if args.modem_mode:
config.set('system.modem.protocol.mode', args.modem_mode)
if args.modem_port:
config.set('system.modem.protocol.uart.port', args.modem_port)
if args.rx_level is not None:
config.set('system.modem.rxLevel', args.rx_level)
if args.tx_level is not None:
config.set('system.modem.txLevel', args.tx_level)
# Handle DFSI settings
if args.dfsi_rtrt is not None:
config.set('system.modem.dfsiRtrt', args.dfsi_rtrt)
if args.dfsi_jitter is not None:
config.set('system.modem.dfsiJitter', args.dfsi_jitter)
if args.dfsi_call_timeout is not None:
config.set('system.modem.dfsiCallTimeout', args.dfsi_call_timeout)
if args.dfsi_full_duplex is not None:
config.set('system.modem.dfsiFullDuplex', args.dfsi_full_duplex)
# Handle RPC/REST settings
if args.rpc_password:
config.set('network.rpcPassword', args.rpc_password)
elif args.generate_rpc_password:
rpc_pwd = generate_random_password()
config.set('network.rpcPassword', rpc_pwd)
console.print(f"[cyan]Generated RPC password:[/cyan] {rpc_pwd}")
if args.rest_enable:
config.set('network.restEnable', True)
if args.rest_password:
config.set('network.restPassword', args.rest_password)
elif args.generate_rest_password:
rest_pwd = generate_random_password()
config.set('network.restPassword', rest_pwd)
console.print(f"[cyan]Generated REST API password:[/cyan] {rest_pwd}")
# Handle network lookup/transfer settings
if args.update_lookups is not None:
config.set('network.updateLookups', args.update_lookups)
if args.save_lookups is not None:
config.set('network.saveLookups', args.save_lookups)
if args.allow_activity_transfer is not None:
config.set('network.allowActivityTransfer', args.allow_activity_transfer)
if args.allow_diagnostic_transfer is not None:
config.set('network.allowDiagnosticTransfer', args.allow_diagnostic_transfer)
if args.allow_status_transfer is not None:
config.set('network.allowStatusTransfer', args.allow_status_transfer)
# Handle Radio ID and Talkgroup ID settings
if args.radio_id_file:
config.set('system.radio_id.file', args.radio_id_file)
if args.radio_id_time is not None:
config.set('system.radio_id.time', args.radio_id_time)
if args.radio_id_acl is not None:
config.set('system.radio_id.acl', args.radio_id_acl)
if args.talkgroup_id_file:
config.set('system.talkgroup_id.file', args.talkgroup_id_file)
if args.talkgroup_id_time is not None:
config.set('system.talkgroup_id.time', args.talkgroup_id_time)
if args.talkgroup_id_acl is not None:
config.set('system.talkgroup_id.acl', args.talkgroup_id_acl)
# Handle protocol settings
if args.enable_p25 is not None:
config.set('protocols.p25.enable', args.enable_p25)
if args.enable_dmr is not None:
config.set('protocols.dmr.enable', args.enable_dmr)
if args.enable_nxdn is not None:
config.set('protocols.nxdn.enable', args.enable_nxdn)
# Handle DMR configuration
if args.color_code is not None:
valid, error = validate_dmr_color_code(args.color_code)
if not valid:
console.print(f"[red]Error:[/red] {error}")
sys.exit(1)
config.set('system.config.colorCode', args.color_code)
if args.dmr_net_id is not None:
site_model = get_dmr_site_model_from_string(args.dmr_site_model) if args.dmr_site_model else get_dmr_site_model_from_string('small')
valid, error = validate_dmr_network_id(args.dmr_net_id, site_model)
if not valid:
console.print(f"[red]Error:[/red] {error}")
sys.exit(1)
config.set('system.config.dmrNetId', args.dmr_net_id)
if args.dmr_site_id is not None:
site_model = get_dmr_site_model_from_string(args.dmr_site_model) if args.dmr_site_model else get_dmr_site_model_from_string('small')
valid, error = validate_dmr_site_id(args.dmr_site_id, site_model)
if not valid:
console.print(f"[red]Error:[/red] {error}")
sys.exit(1)
config.set('system.config.siteId', args.dmr_site_id)
# Handle P25 configuration
if args.nac is not None:
valid, error = validate_p25_nac(args.nac)
if not valid:
console.print(f"[red]Error:[/red] {error}")
sys.exit(1)
config.set('system.config.nac', args.nac)
if args.p25_net_id is not None:
valid, error = validate_p25_network_id(args.p25_net_id)
if not valid:
console.print(f"[red]Error:[/red] {error}")
sys.exit(1)
config.set('system.config.netId', args.p25_net_id)
if args.p25_sys_id is not None:
valid, error = validate_p25_system_id(args.p25_sys_id)
if not valid:
console.print(f"[red]Error:[/red] {error}")
sys.exit(1)
config.set('system.config.sysId', args.p25_sys_id)
if args.p25_rfss_id is not None:
valid, error = validate_p25_rfss_id(args.p25_rfss_id)
if not valid:
console.print(f"[red]Error:[/red] {error}")
sys.exit(1)
config.set('system.config.rfssId', args.p25_rfss_id)
if args.p25_site_id is not None:
valid, error = validate_p25_site_id(args.p25_site_id)
if not valid:
console.print(f"[red]Error:[/red] {error}")
sys.exit(1)
config.set('system.config.siteId', args.p25_site_id)
# Handle NXDN configuration
if args.nxdn_ran is not None:
valid, error = validate_nxdn_ran(args.nxdn_ran)
if not valid:
console.print(f"[red]Error:[/red] {error}")
sys.exit(1)
config.set('system.config.ran', args.nxdn_ran)
if args.site_id is not None:
config.set('system.config.siteId', args.site_id)
# Handle location settings
if args.latitude is not None:
config.set('system.info.latitude', args.latitude)
if args.longitude is not None:
config.set('system.info.longitude', args.longitude)
if args.location:
config.set('system.info.location', args.location)
if args.tx_power is not None:
config.set('system.info.power', args.tx_power)
# Handle frequency configuration
iden_table = None
if args.tx_freq and args.band:
if args.band not in BAND_PRESETS:
console.print(f"[red]Error:[/red] Invalid band '{args.band}'. Available: {', '.join(BAND_PRESETS.keys())}")
sys.exit(1)
preset = BAND_PRESETS[args.band]
tx_freq_mhz = args.tx_freq
# Validate frequency is in band
if not (preset['tx_range'][0] <= tx_freq_mhz <= preset['tx_range'][1]):
console.print(f"[red]Error:[/red] TX frequency {tx_freq_mhz} MHz is outside {args.band} range "
f"({preset['tx_range'][0]}-{preset['tx_range'][1]} MHz)")
sys.exit(1)
# Calculate channel assignment
# Use band index as channel ID, except 900MHz is always 15
band_list = list(BAND_PRESETS.keys())
band_index = band_list.index(args.band)
channel_id = 15 if args.band == '900mhz' else band_index
channel_id_result, channel_no, tx_hz, rx_hz = calculate_channel_assignment(
tx_freq_mhz, args.band, channel_id
)
# Set channel configuration
config.set('system.config.channelId', channel_id_result)
config.set('system.config.channelNo', channel_no)
config.set('system.config.txFrequency', tx_hz)
config.set('system.config.rxFrequency', rx_hz)
console.print(f"[green]✓[/green] Frequency configured: TX {tx_freq_mhz:.6f} MHz, "
f"RX {rx_hz/1000000:.6f} MHz")
console.print(f"[green]✓[/green] Channel ID {channel_id_result}, Channel# {channel_no} (0x{channel_no:03X})")
# Create IDEN table entry
iden_table = IdenTable()
entry = create_iden_entry_from_preset(channel_id_result, args.band)
iden_table.add_entry(entry)
# Save configuration
output_path = Path(args.output)
config.save(output_path)
console.print(f"[green]✓[/green] Configuration created: {output_path}")
# Save IDEN table if frequency was configured
if iden_table and len(iden_table) > 0:
iden_path = output_path.parent / "iden_table.dat"
iden_table.save(iden_path)
console.print(f"[green]✓[/green] Identity table created: {iden_path}")
# Validate if requested
if args.validate:
errors = config.validate()
if errors:
console.print("\n[yellow]Validation warnings:[/yellow]")
for error in errors:
console.print(f"{error}")
else:
console.print("[green]✓[/green] Configuration is valid")
except Exception as e:
console.print(f"[red]Error:[/red] {e}", style="bold red")
sys.exit(1)
def cmd_validate(args):
"""Validate configuration file"""
try:
config = DVMConfig(Path(args.config))
errors = config.validate()
if errors:
console.print(f"[red]✗[/red] Configuration has {len(errors)} error(s):\n")
for error in errors:
console.print(f" [red]•[/red] {error}")
sys.exit(1)
else:
console.print(f"[green]✓[/green] Configuration is valid: {args.config}")
# Show summary if requested
if args.summary:
summary = config.get_summary()
table = Table(title="Configuration Summary")
table.add_column("Parameter", style="cyan")
table.add_column("Value", style="yellow")
table.add_row("Identity", summary['identity'])
table.add_row("Peer ID", str(summary['peer_id']))
table.add_row("FNE Address", summary['fne_address'])
table.add_row("FNE Port", str(summary['fne_port']))
table.add_row("DMR Color Code", str(summary['color_code']))
table.add_row("P25 NAC", f"0x{summary['nac']:03X}")
table.add_row("Site ID", str(summary['site_id']))
table.add_row("Modem Type", summary['modem_type'])
protocols = []
if summary['protocols']['dmr']:
protocols.append("DMR")
if summary['protocols']['p25']:
protocols.append("P25")
if summary['protocols']['nxdn']:
protocols.append("NXDN")
table.add_row("Protocols", ", ".join(protocols))
table.add_row("Control Channel", "Yes" if summary['is_control'] else "No")
console.print()
console.print(table)
except FileNotFoundError:
console.print(f"[red]Error:[/red] File not found: {args.config}")
sys.exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
def cmd_edit(args):
"""Edit configuration value"""
try:
config_path = Path(args.config)
config = DVMConfig(config_path)
# Get current value
current = config.get(args.key)
console.print(f"Current value of '{args.key}': {current}")
# Set new value
# Try to preserve type
if isinstance(current, bool):
new_value = args.value.lower() in ('true', '1', 'yes', 'on')
elif isinstance(current, int):
new_value = int(args.value)
elif isinstance(current, float):
new_value = float(args.value)
else:
new_value = args.value
config.set(args.key, new_value)
config.save(config_path)
console.print(f"[green]✓[/green] Updated '{args.key}' to: {new_value}")
# Validate after edit
errors = config.validate()
if errors:
console.print("\n[yellow]Validation warnings after edit:[/yellow]")
for error in errors:
console.print(f"{error}")
except FileNotFoundError:
console.print(f"[red]Error:[/red] File not found: {args.config}")
sys.exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
def cmd_trunk_create(args):
"""Create trunked system"""
try:
system = TrunkingSystem(Path(args.base_dir), args.name)
console.print(f"[cyan]Creating trunked system '{args.name}'...[/cyan]\n")
# Handle frequency configuration
cc_channel_id = 0
cc_channel_no = 0
vc_channels = None
iden_table = None
# Control channel frequency
if args.cc_tx_freq and args.cc_band:
if args.cc_band not in BAND_PRESETS:
console.print(f"[red]Error:[/red] Invalid CC band '{args.cc_band}'. Available: {', '.join(BAND_PRESETS.keys())}")
sys.exit(1)
preset = BAND_PRESETS[args.cc_band]
if not (preset['tx_range'][0] <= args.cc_tx_freq <= preset['tx_range'][1]):
console.print(f"[red]Error:[/red] CC TX frequency {args.cc_tx_freq} MHz is outside {args.cc_band} range")
sys.exit(1)
band_list = list(BAND_PRESETS.keys())
band_index = band_list.index(args.cc_band)
cc_channel_id = 15 if args.cc_band == '900mhz' else band_index
cc_channel_id, cc_channel_no, tx_hz, rx_hz = calculate_channel_assignment(
args.cc_tx_freq, args.cc_band, cc_channel_id
)
console.print(f"[green]✓[/green] CC: TX {args.cc_tx_freq:.6f} MHz, RX {rx_hz/1000000:.6f} MHz, "
f"Ch ID {cc_channel_id}, Ch# {cc_channel_no} (0x{cc_channel_no:03X})")
iden_table = IdenTable()
entry = create_iden_entry_from_preset(cc_channel_id, args.cc_band)
iden_table.add_entry(entry)
# Voice channel frequencies
if args.vc_tx_freqs and args.vc_bands:
vc_tx_freqs = [float(f) for f in args.vc_tx_freqs.split(',')]
vc_bands = args.vc_bands.split(',')
if len(vc_tx_freqs) != args.vc_count:
console.print(f"[red]Error:[/red] Number of VC frequencies ({len(vc_tx_freqs)}) "
f"doesn't match VC count ({args.vc_count})")
sys.exit(1)
if len(vc_bands) != args.vc_count:
console.print(f"[red]Error:[/red] Number of VC bands ({len(vc_bands)}) "
f"doesn't match VC count ({args.vc_count})")
sys.exit(1)
vc_channels = []
if iden_table is None:
iden_table = IdenTable()
for i, (vc_tx_freq, vc_band) in enumerate(zip(vc_tx_freqs, vc_bands), 1):
if vc_band not in BAND_PRESETS:
console.print(f"[red]Error:[/red] Invalid VC{i} band '{vc_band}'")
sys.exit(1)
preset = BAND_PRESETS[vc_band]
if not (preset['tx_range'][0] <= vc_tx_freq <= preset['tx_range'][1]):
console.print(f"[red]Error:[/red] VC{i} TX frequency {vc_tx_freq} MHz is outside {vc_band} range")
sys.exit(1)
band_list = list(BAND_PRESETS.keys())
band_index = band_list.index(vc_band)
vc_channel_id = 15 if vc_band == '900mhz' else band_index
vc_channel_id, vc_channel_no, tx_hz, rx_hz = calculate_channel_assignment(
vc_tx_freq, vc_band, vc_channel_id
)
vc_channels.append({
'channel_id': vc_channel_id,
'channel_no': vc_channel_no
})
console.print(f"[green]✓[/green] VC{i}: TX {vc_tx_freq:.6f} MHz, RX {rx_hz/1000000:.6f} MHz, "
f"Ch ID {vc_channel_id}, Ch# {vc_channel_no} (0x{vc_channel_no:03X})")
# Add IDEN entry if not already present
if vc_channel_id not in iden_table.entries:
entry = create_iden_entry_from_preset(vc_channel_id, vc_band)
iden_table.add_entry(entry)
# Prepare base system creation kwargs
create_kwargs = {
'protocol': args.protocol,
'vc_count': args.vc_count,
'fne_address': args.fne_address,
'fne_port': args.fne_port,
'fne_password': args.fne_password,
'base_peer_id': args.base_peer_id,
'base_rpc_port': args.base_rpc_port,
'system_identity': args.identity,
'modem_type': args.modem_type,
'cc_channel_id': cc_channel_id,
'cc_channel_no': cc_channel_no,
'vc_channels': vc_channels
}
# Add RPC/REST settings
if args.rpc_password:
create_kwargs['rpc_password'] = args.rpc_password
elif args.generate_rpc_password:
rpc_pwd = generate_random_password()
create_kwargs['rpc_password'] = rpc_pwd
console.print(f"[cyan]Generated RPC password:[/cyan] {rpc_pwd}")
if args.base_rest_port:
create_kwargs['base_rest_port'] = args.base_rest_port
if args.rest_enable:
if args.rest_password:
create_kwargs['rest_password'] = args.rest_password
elif args.generate_rest_password:
rest_pwd = generate_random_password()
create_kwargs['rest_password'] = rest_pwd
console.print(f"[cyan]Generated REST API password:[/cyan] {rest_pwd}")
# Add network lookup/transfer settings
if args.update_lookups is not None:
create_kwargs['update_lookups'] = args.update_lookups
if args.save_lookups is not None:
create_kwargs['save_lookups'] = args.save_lookups
if args.allow_activity_transfer is not None:
create_kwargs['allow_activity_transfer'] = args.allow_activity_transfer
if args.allow_diagnostic_transfer is not None:
create_kwargs['allow_diagnostic_transfer'] = args.allow_diagnostic_transfer
if args.allow_status_transfer is not None:
create_kwargs['allow_status_transfer'] = args.allow_status_transfer
# Add Radio ID and Talkgroup ID settings
if args.radio_id_file:
create_kwargs['radio_id_file'] = args.radio_id_file
if args.radio_id_time is not None:
create_kwargs['radio_id_time'] = args.radio_id_time
if args.radio_id_acl is not None:
create_kwargs['radio_id_acl'] = args.radio_id_acl
if args.talkgroup_id_file:
create_kwargs['talkgroup_id_file'] = args.talkgroup_id_file
if args.talkgroup_id_time is not None:
create_kwargs['talkgroup_id_time'] = args.talkgroup_id_time
if args.talkgroup_id_acl is not None:
create_kwargs['talkgroup_id_acl'] = args.talkgroup_id_acl
# Add protocol-specific settings
if args.protocol == 'p25':
create_kwargs['nac'] = args.nac
create_kwargs['net_id'] = args.p25_net_id or 0xBB800
create_kwargs['sys_id'] = args.p25_sys_id or 0x001
create_kwargs['rfss_id'] = args.p25_rfss_id or 1
create_kwargs['site_id'] = args.p25_site_id or args.site_id
elif args.protocol == 'dmr':
create_kwargs['color_code'] = args.color_code
create_kwargs['dmr_net_id'] = args.dmr_net_id or 1
create_kwargs['site_id'] = args.dmr_site_id or args.site_id
system.create_system(**create_kwargs)
# Save IDEN table if frequencies were configured
if iden_table and len(iden_table) > 0:
iden_path = Path(args.base_dir) / "iden_table.dat"
iden_table.save(iden_path)
console.print(f"[green]✓[/green] Identity table created: {iden_path}")
console.print(f"\n[green]✓[/green] Trunked system created successfully!")
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
def cmd_trunk_validate(args):
"""Validate trunked system"""
try:
system = TrunkingSystem(Path(args.base_dir), args.name)
system.load_system()
console.print(f"[cyan]Validating trunked system '{args.name}'...[/cyan]\n")
errors = system.validate_system()
if errors:
console.print(f"[red]✗[/red] System has errors:\n")
for component, error_list in errors.items():
console.print(f"[yellow]{component}:[/yellow]")
for error in error_list:
console.print(f" [red]•[/red] {error}")
console.print()
sys.exit(1)
else:
console.print(f"[green]✓[/green] Trunked system is valid")
# Show summary
table = Table(title=f"Trunked System: {args.name}")
table.add_column("Component", style="cyan")
table.add_column("Identity", style="yellow")
table.add_column("Peer ID", style="magenta")
table.add_column("RPC Port", style="green")
cc = system.cc_config
table.add_row(
"Control Channel",
cc.get('system.identity'),
str(cc.get('network.id')),
str(cc.get('network.rpcPort'))
)
for i, vc in enumerate(system.vc_configs, 1):
table.add_row(
f"Voice Channel {i}",
vc.get('system.identity'),
str(vc.get('network.id')),
str(vc.get('network.rpcPort'))
)
console.print()
console.print(table)
except FileNotFoundError as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
def cmd_trunk_update(args):
"""Update trunked system setting"""
try:
system = TrunkingSystem(Path(args.base_dir), args.name)
system.load_system()
console.print(f"[cyan]Updating '{args.key}' across all configs...[/cyan]")
# Determine value type
value = args.value
if value.lower() in ('true', 'false'):
value = value.lower() == 'true'
elif value.isdigit():
value = int(value)
system.update_all(args.key, value)
system.save_all()
console.print(f"[green]✓[/green] Updated '{args.key}' to '{value}' in all configs")
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
def cmd_list_templates(args):
"""List available templates"""
table = Table(title="Available Templates")
table.add_column("Template Name", style="cyan")
table.add_column("Description", style="yellow")
descriptions = {
'conventional': 'Standalone repeater',
'enhanced': 'Enhanced repeater with channel grants',
'control-channel-p25': 'P25 dedicated control channel for trunking',
'control-channel-dmr': 'DMR dedicated control channel for trunking',
'voice-channel': 'Voice channel for trunking system',
}
for name in sorted(TEMPLATES.keys()):
desc = descriptions.get(name, 'N/A')
table.add_row(name, desc)
console.print(table)
def cmd_wizard(args):
"""Run interactive wizard"""
wizard_type = args.type if hasattr(args, 'type') else 'auto'
result = run_wizard(wizard_type)
if not result:
sys.exit(1)
def main():
# Prepare description with banner and copyright
description = f"""
Digital Voice Modem (DVM) Configuration Generator {__VER__}
Copyright (c) 2026 Bryan Biedenkapp, N2PLL and DVMProject (https://github.com/dvmproject) Authors.
DVMHost Configuration Manager"""
parser = argparse.ArgumentParser(
description=description,
formatter_class=argparse.RawDescriptionHelpFormatter
)
subparsers = parser.add_subparsers(dest='command', help='Commands')
# create command
create_parser = subparsers.add_parser('create', help='Create new configuration')
create_parser.add_argument('--template', default='enhanced', choices=list(TEMPLATES.keys()),
help='Configuration template (default: enhanced)')
create_parser.add_argument('--output', '-o', required=True, help='Output file path')
create_parser.add_argument('--identity', help='System identity')
create_parser.add_argument('--peer-id', type=int, help='Network peer ID')
create_parser.add_argument('--fne-address', help='FNE address')
create_parser.add_argument('--fne-port', type=int, help='FNE port')
create_parser.add_argument('--fne-password', help='FNE password')
create_parser.add_argument('--callsign', help='CWID callsign')
# Modem configuration
create_parser.add_argument('--modem-type', choices=['uart', 'null'],
help='Modem type')
create_parser.add_argument('--modem-mode', choices=['air', 'dfsi'],
help='Modem mode')
create_parser.add_argument('--modem-port', help='Serial port for UART modem')
create_parser.add_argument('--rx-level', type=int, help='RX level (0-100)')
create_parser.add_argument('--tx-level', type=int, help='TX level (0-100)')
# DFSI settings
create_parser.add_argument('--dfsi-rtrt', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Enable DFSI RT/RT (true/false)')
create_parser.add_argument('--dfsi-jitter', type=int, help='DFSI jitter (ms)')
create_parser.add_argument('--dfsi-call-timeout', type=int, help='DFSI call timeout (seconds)')
create_parser.add_argument('--dfsi-full-duplex', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Enable DFSI full duplex (true/false)')
# RPC/REST settings
create_parser.add_argument('--rpc-password', help='RPC password')
create_parser.add_argument('--generate-rpc-password', action='store_true',
help='Generate random RPC password')
create_parser.add_argument('--rest-enable', action='store_true', help='Enable REST API')
create_parser.add_argument('--rest-password', help='REST API password')
create_parser.add_argument('--generate-rest-password', action='store_true',
help='Generate random REST API password')
# Network lookup/transfer settings
create_parser.add_argument('--update-lookups', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Update lookups from network (true/false)')
create_parser.add_argument('--save-lookups', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Save lookups from network (true/false)')
create_parser.add_argument('--allow-activity-transfer', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Allow activity transfer to FNE (true/false)')
create_parser.add_argument('--allow-diagnostic-transfer', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Allow diagnostic transfer to FNE (true/false)')
create_parser.add_argument('--allow-status-transfer', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Allow status transfer to FNE (true/false)')
# Radio ID and Talkgroup ID settings
create_parser.add_argument('--radio-id-file', help='Radio ID ACL file path')
create_parser.add_argument('--radio-id-time', type=int, help='Radio ID update time (seconds)')
create_parser.add_argument('--radio-id-acl', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Enforce Radio ID ACLs (true/false)')
create_parser.add_argument('--talkgroup-id-file', help='Talkgroup ID ACL file path')
create_parser.add_argument('--talkgroup-id-time', type=int, help='Talkgroup ID update time (seconds)')
create_parser.add_argument('--talkgroup-id-acl', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Enforce Talkgroup ID ACLs (true/false)')
# Protocol settings
create_parser.add_argument('--enable-p25', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Enable P25 protocol (true/false)')
create_parser.add_argument('--enable-dmr', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Enable DMR protocol (true/false)')
create_parser.add_argument('--enable-nxdn', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Enable NXDN protocol (true/false)')
# DMR settings
create_parser.add_argument('--color-code', type=int, help='DMR color code')
create_parser.add_argument('--dmr-net-id', type=int, help='DMR network ID')
create_parser.add_argument('--dmr-site-id', type=int, help='DMR site ID')
create_parser.add_argument('--dmr-site-model', choices=['small', 'tiny', 'large', 'huge'],
help='DMR site model')
# P25 settings
create_parser.add_argument('--nac', type=lambda x: int(x, 0), help='P25 NAC (hex or decimal)')
create_parser.add_argument('--p25-net-id', type=lambda x: int(x, 0), help='P25 network ID (hex or decimal)')
create_parser.add_argument('--p25-sys-id', type=lambda x: int(x, 0), help='P25 system ID (hex or decimal)')
create_parser.add_argument('--p25-rfss-id', type=int, help='P25 RFSS ID')
create_parser.add_argument('--p25-site-id', type=int, help='P25 site ID')
# NXDN settings
create_parser.add_argument('--nxdn-ran', type=int, help='NXDN RAN')
# Generic site ID
create_parser.add_argument('--site-id', type=int, help='Generic site ID')
# Location settings
create_parser.add_argument('--latitude', type=float, help='Location latitude')
create_parser.add_argument('--longitude', type=float, help='Location longitude')
create_parser.add_argument('--location', help='Location description')
create_parser.add_argument('--tx-power', type=int, help='TX power (watts)')
# Frequency configuration
create_parser.add_argument('--tx-freq', type=float, help='Transmit frequency in MHz')
create_parser.add_argument('--band', choices=list(BAND_PRESETS.keys()),
help='Frequency band (required with --tx-freq)')
create_parser.add_argument('--validate', action='store_true', help='Validate after creation')
create_parser.set_defaults(func=cmd_create)
# validate command
validate_parser = subparsers.add_parser('validate', help='Validate configuration')
validate_parser.add_argument('config', help='Configuration file path')
validate_parser.add_argument('--summary', '-s', action='store_true', help='Show summary')
validate_parser.set_defaults(func=cmd_validate)
# edit command
edit_parser = subparsers.add_parser('edit', help='Edit configuration value')
edit_parser.add_argument('config', help='Configuration file path')
edit_parser.add_argument('key', help='Configuration key (dot notation)')
edit_parser.add_argument('value', help='New value')
edit_parser.set_defaults(func=cmd_edit)
# trunk commands
trunk_parser = subparsers.add_parser('trunk', help='Trunking system management')
trunk_sub = trunk_parser.add_subparsers(dest='trunk_command')
# trunk create
trunk_create_parser = trunk_sub.add_parser('create', help='Create trunked system')
trunk_create_parser.add_argument('--name', default='trunked', help='System name')
trunk_create_parser.add_argument('--base-dir', required=True, help='Base directory')
trunk_create_parser.add_argument('--protocol', choices=['p25', 'dmr'], default='p25',
help='Protocol type')
trunk_create_parser.add_argument('--vc-count', type=int, default=2,
help='Number of voice channels')
trunk_create_parser.add_argument('--fne-address', default='127.0.0.1', help='FNE address')
trunk_create_parser.add_argument('--fne-port', type=int, default=62031, help='FNE port')
trunk_create_parser.add_argument('--fne-password', default='PASSWORD', help='FNE password')
trunk_create_parser.add_argument('--base-peer-id', type=int, default=100000,
help='Base peer ID')
trunk_create_parser.add_argument('--base-rpc-port', type=int, default=9890,
help='Base RPC port')
trunk_create_parser.add_argument('--identity', default='SKYNET', help='System identity')
trunk_create_parser.add_argument('--modem-type', choices=['uart', 'null'], default='uart',
help='Modem type')
# RPC/REST settings
trunk_create_parser.add_argument('--rpc-password', help='RPC password')
trunk_create_parser.add_argument('--generate-rpc-password', action='store_true',
help='Generate random RPC password')
trunk_create_parser.add_argument('--base-rest-port', type=int, help='Base REST API port')
trunk_create_parser.add_argument('--rest-enable', action='store_true', help='Enable REST API')
trunk_create_parser.add_argument('--rest-password', help='REST API password')
trunk_create_parser.add_argument('--generate-rest-password', action='store_true',
help='Generate random REST API password')
# Network lookup/transfer settings
trunk_create_parser.add_argument('--update-lookups', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Update lookups from network (true/false)')
trunk_create_parser.add_argument('--save-lookups', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Save lookups from network (true/false)')
trunk_create_parser.add_argument('--allow-activity-transfer', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Allow activity transfer to FNE (true/false)')
trunk_create_parser.add_argument('--allow-diagnostic-transfer', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Allow diagnostic transfer to FNE (true/false)')
trunk_create_parser.add_argument('--allow-status-transfer', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Allow status transfer to FNE (true/false)')
# Radio ID and Talkgroup ID settings
trunk_create_parser.add_argument('--radio-id-file', help='Radio ID ACL file path')
trunk_create_parser.add_argument('--radio-id-time', type=int, help='Radio ID update time (seconds)')
trunk_create_parser.add_argument('--radio-id-acl', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Enforce Radio ID ACLs (true/false)')
trunk_create_parser.add_argument('--talkgroup-id-file', help='Talkgroup ID ACL file path')
trunk_create_parser.add_argument('--talkgroup-id-time', type=int, help='Talkgroup ID update time (seconds)')
trunk_create_parser.add_argument('--talkgroup-id-acl', type=lambda x: x.lower() in ('true', '1', 'yes'),
help='Enforce Talkgroup ID ACLs (true/false)')
# Protocol-specific settings
trunk_create_parser.add_argument('--nac', type=lambda x: int(x, 0), default=0x293,
help='P25 NAC (hex or decimal)')
trunk_create_parser.add_argument('--p25-net-id', type=lambda x: int(x, 0),
help='P25 network ID (hex or decimal)')
trunk_create_parser.add_argument('--p25-sys-id', type=lambda x: int(x, 0),
help='P25 system ID (hex or decimal)')
trunk_create_parser.add_argument('--p25-rfss-id', type=int, help='P25 RFSS ID')
trunk_create_parser.add_argument('--p25-site-id', type=int, help='P25 site ID')
trunk_create_parser.add_argument('--color-code', type=int, default=1, help='DMR color code')
trunk_create_parser.add_argument('--dmr-net-id', type=int, help='DMR network ID')
trunk_create_parser.add_argument('--dmr-site-id', type=int, help='DMR site ID')
# Generic site ID
trunk_create_parser.add_argument('--site-id', type=int, default=1, help='Site ID')
# Frequency configuration
trunk_create_parser.add_argument('--cc-tx-freq', type=float,
help='Control channel TX frequency in MHz')
trunk_create_parser.add_argument('--cc-band', choices=list(BAND_PRESETS.keys()),
help='Control channel frequency band')
trunk_create_parser.add_argument('--vc-tx-freqs',
help='Voice channel TX frequencies (comma-separated, e.g., 851.0125,851.0250)')
trunk_create_parser.add_argument('--vc-bands',
help='Voice channel bands (comma-separated, e.g., 800mhz,800mhz)')
trunk_create_parser.set_defaults(func=cmd_trunk_create)
# trunk validate
trunk_validate_parser = trunk_sub.add_parser('validate', help='Validate trunked system')
trunk_validate_parser.add_argument('--name', default='trunked', help='System name')
trunk_validate_parser.add_argument('--base-dir', required=True, help='Base directory')
trunk_validate_parser.set_defaults(func=cmd_trunk_validate)
# trunk update
trunk_update_parser = trunk_sub.add_parser('update', help='Update all configs in system')
trunk_update_parser.add_argument('--name', default='trunked', help='System name')
trunk_update_parser.add_argument('--base-dir', required=True, help='Base directory')
trunk_update_parser.add_argument('key', help='Configuration key')
trunk_update_parser.add_argument('value', help='New value')
trunk_update_parser.set_defaults(func=cmd_trunk_update)
# templates command
templates_parser = subparsers.add_parser('templates', help='List available templates')
templates_parser.set_defaults(func=cmd_list_templates)
# wizard command
wizard_parser = subparsers.add_parser('wizard', help='Interactive configuration wizard')
wizard_parser.add_argument('--type', choices=['single', 'trunk', 'auto'], default='auto',
help='Wizard type (auto asks user)')
wizard_parser.set_defaults(func=cmd_wizard)
args = parser.parse_args()
if args.command is None:
# Display banner when run without command
console.print(f"[cyan]{__BANNER__}[/cyan]")
console.print(f"[bold cyan]Digital Voice Modem (DVM) Configuration Generator {__VER__}[/bold cyan]")
console.print("[dim]Copyright (c) 2026 Bryan Biedenkapp, N2PLL and DVMProject (https://github.com/dvmproject) Authors.[/dim]")
console.print()
parser.print_help()
sys.exit(1)
if hasattr(args, 'func'):
args.func(args)
else:
parser.print_help()
if __name__ == '__main__':
main()

@ -0,0 +1,331 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
#/**
#* Digital Voice Modem - Host Configuration Generator
#* GPLv2 Open Source. Use is subject to license terms.
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
#*
#*/
#/*
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
#*
#* This program is free software; you can redistribute it and/or modify
#* it under the terms of the GNU General Public License as published by
#* the Free Software Foundation; either version 2 of the License, or
#* (at your option) any later version.
#*
#* This program is distributed in the hope that it will be useful,
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
#* GNU General Public License for more details.
#*
#* You should have received a copy of the GNU General Public License
#* along with this program; if not, write to the Free Software
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
#*/
"""
dvmcfggen - DVMHost Configuration Generator
Identity Table (IDEN) Management
Handles frequency mapping and channel ID/number calculation
Based on iden_channel_calc.py from skynet_tools_host
"""
from typing import Dict, List, Tuple, Optional
from pathlib import Path
# Constants
MAX_FREQ_GAP = 30000000 # 30 MHz maximum offset from base frequency
HZ_MHZ = 1000000.0
KHZ_HZ = 1000.0
class IdenEntry:
"""Represents a single identity table entry"""
def __init__(self, channel_id: int, base_freq: int, spacing: float,
input_offset: float, bandwidth: float):
"""
Initialize IDEN entry
Args:
channel_id: Channel identity (0-15)
base_freq: Base frequency in Hz
spacing: Channel spacing in kHz
input_offset: Input offset (RX-TX) in MHz
bandwidth: Channel bandwidth in kHz
"""
self.channel_id = channel_id
self.base_freq = base_freq
self.spacing = spacing
self.input_offset = input_offset
self.bandwidth = bandwidth
def to_line(self) -> str:
"""Convert to iden_table.dat format line"""
return f"{self.channel_id},{self.base_freq},{self.spacing:.2f},{self.input_offset:.5f},{self.bandwidth:.1f},"
def calculate_channel_number(self, tx_freq: int) -> int:
"""
Calculate channel number for a given transmit frequency
Args:
tx_freq: Transmit frequency in Hz
Returns:
Channel number in hex
"""
if tx_freq < self.base_freq:
raise ValueError(f"TX frequency ({tx_freq/HZ_MHZ:.5f} MHz) is below base frequency "
f"({self.base_freq/HZ_MHZ:.5f} MHz)")
if tx_freq > (self.base_freq + MAX_FREQ_GAP):
raise ValueError(f"TX frequency ({tx_freq/HZ_MHZ:.5f} MHz) is too far above base frequency "
f"({self.base_freq/HZ_MHZ:.5f} MHz). Maximum gap is 25.5 MHz")
space_hz = int(self.spacing * KHZ_HZ)
root_freq = tx_freq - self.base_freq
ch_no = int(root_freq / space_hz)
return ch_no
def calculate_rx_frequency(self, tx_freq: int) -> int:
"""
Calculate receive frequency for a given transmit frequency
Args:
tx_freq: Transmit frequency in Hz
Returns:
Receive frequency in Hz
"""
offset_hz = int(self.input_offset * HZ_MHZ)
rx_freq = tx_freq + offset_hz
# Note: For negative offsets (like -45 MHz on 800MHz band),
# RX will be lower than TX and may be below base freq - this is normal
# Only validate that the frequency is reasonable (above 0)
if rx_freq < 0:
raise ValueError(f"RX frequency ({rx_freq/HZ_MHZ:.5f} MHz) is invalid (negative)")
return rx_freq
def __repr__(self) -> str:
return (f"IdenEntry(id={self.channel_id}, base={self.base_freq/HZ_MHZ:.3f}MHz, "
f"spacing={self.spacing}kHz, offset={self.input_offset}MHz, bw={self.bandwidth}kHz)")
class IdenTable:
"""Manages identity table (iden_table.dat)"""
def __init__(self):
self.entries: Dict[int, IdenEntry] = {}
def add_entry(self, entry: IdenEntry):
"""Add an identity table entry"""
if entry.channel_id < 0 or entry.channel_id > 15:
raise ValueError(f"Channel ID must be 0-15, got {entry.channel_id}")
self.entries[entry.channel_id] = entry
def get_entry(self, channel_id: int) -> Optional[IdenEntry]:
"""Get entry by channel ID"""
return self.entries.get(channel_id)
def find_entry_for_frequency(self, tx_freq: int) -> Optional[IdenEntry]:
"""
Find an identity entry that can accommodate the given TX frequency
Args:
tx_freq: Transmit frequency in Hz
Returns:
IdenEntry if found, None otherwise
"""
for entry in self.entries.values():
try:
# Check if this entry can accommodate the frequency
if entry.base_freq <= tx_freq <= (entry.base_freq + MAX_FREQ_GAP):
entry.calculate_channel_number(tx_freq) # Validate
return entry
except ValueError:
continue
return None
def save(self, filepath: Path):
"""Save identity table to file"""
with open(filepath, 'w') as f:
f.write("#\n")
f.write("# Identity Table - Frequency Bandplan\n")
f.write("# Generated by DVMCfg\n")
f.write("#\n")
f.write("# ChId,Base Freq (Hz),Spacing (kHz),Input Offset (MHz),Bandwidth (kHz),\n")
f.write("#\n")
for ch_id in sorted(self.entries.keys()):
f.write(self.entries[ch_id].to_line() + "\n")
def load(self, filepath: Path):
"""Load identity table from file"""
self.entries.clear()
with open(filepath, 'r') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
parts = [p.strip() for p in line.split(',') if p.strip()]
if len(parts) >= 5:
try:
entry = IdenEntry(
channel_id=int(parts[0]),
base_freq=int(parts[1]),
spacing=float(parts[2]),
input_offset=float(parts[3]),
bandwidth=float(parts[4])
)
self.add_entry(entry)
except (ValueError, IndexError) as e:
print(f"Warning: Skipping invalid line: {line} ({e})")
def __len__(self) -> int:
return len(self.entries)
def __repr__(self) -> str:
return f"IdenTable({len(self)} entries)"
# Common band presets
BAND_PRESETS = {
'800mhz': {
'name': '800 MHz Trunked (800T)',
'base_freq': 851006250,
'spacing': 6.25,
'input_offset': -45.0,
'bandwidth': 12.5,
'tx_range': (851.0, 870.0),
'rx_range': (806.0, 825.0),
},
'900mhz': {
'name': '900 MHz ISM/Business',
'base_freq': 935001250,
'spacing': 6.25,
'input_offset': -39.0,
'bandwidth': 12.5,
'tx_range': (935.0, 960.0),
'rx_range': (896.0, 901.0),
},
'uhf': {
'name': 'UHF 450-470 MHz',
'base_freq': 450000000,
'spacing': 6.25,
'input_offset': 5.0,
'bandwidth': 12.5,
'tx_range': (450.0, 470.0),
'rx_range': (455.0, 475.0),
},
'vhf': {
'name': 'VHF 146-148 MHz (2m Ham)',
'base_freq': 146000000,
'spacing': 6.25,
'input_offset': 0.6,
'bandwidth': 12.5,
'tx_range': (146.0, 148.0),
'rx_range': (146.6, 148.6),
},
'vhf-hi': {
'name': 'VHF-Hi 150-174 MHz',
'base_freq': 150000000,
'spacing': 6.25,
'input_offset': 0.6,
'bandwidth': 12.5,
'tx_range': (150.0, 174.0),
'rx_range': (155.0, 179.0),
},
'uhf-ham': {
'name': 'UHF 430-450 MHz (70cm Ham)',
'base_freq': 430000000,
'spacing': 6.25,
'input_offset': 5.0,
'bandwidth': 12.5,
'tx_range': (430.0, 450.0),
'rx_range': (435.0, 455.0),
},
}
def create_iden_entry_from_preset(channel_id: int, preset: str) -> IdenEntry:
"""
Create an IdenEntry from a band preset
Args:
channel_id: Channel ID (0-15)
preset: Preset name (e.g., '800mhz', 'uhf', 'vhf')
Returns:
IdenEntry configured for the band
"""
if preset not in BAND_PRESETS:
raise ValueError(f"Unknown preset: {preset}. Available: {list(BAND_PRESETS.keys())}")
band = BAND_PRESETS[preset]
return IdenEntry(
channel_id=channel_id,
base_freq=band['base_freq'],
spacing=band['spacing'],
input_offset=band['input_offset'],
bandwidth=band['bandwidth']
)
def calculate_channel_assignment(tx_freq_mhz: float, preset: str = None,
channel_id: int = None) -> Tuple[int, int, int, int]:
"""
Calculate channel ID and channel number for a given TX frequency
Args:
tx_freq_mhz: Transmit frequency in MHz
preset: Band preset to use (optional)
channel_id: Specific channel ID to use (optional, 0-15)
Returns:
Tuple of (channel_id, channel_number, tx_freq_hz, rx_freq_hz)
"""
tx_freq_hz = int(tx_freq_mhz * HZ_MHZ)
# If preset specified, use it
if preset:
entry = create_iden_entry_from_preset(channel_id or 0, preset)
ch_no = entry.calculate_channel_number(tx_freq_hz)
rx_freq = entry.calculate_rx_frequency(tx_freq_hz)
return (entry.channel_id, ch_no, tx_freq_hz, rx_freq)
# Otherwise, try to find a matching preset
for preset_name, band in BAND_PRESETS.items():
if band['tx_range'][0] <= tx_freq_mhz <= band['tx_range'][1]:
entry = create_iden_entry_from_preset(channel_id or 0, preset_name)
try:
ch_no = entry.calculate_channel_number(tx_freq_hz)
rx_freq = entry.calculate_rx_frequency(tx_freq_hz)
return (entry.channel_id, ch_no, tx_freq_hz, rx_freq)
except ValueError:
continue
raise ValueError(f"No suitable band preset found for {tx_freq_mhz} MHz. "
f"Please specify a preset or configure manually.")
def create_default_iden_table() -> IdenTable:
"""Create a default identity table with common bands"""
table = IdenTable()
# Add common band presets with different channel IDs
table.add_entry(create_iden_entry_from_preset(0, '800mhz'))
table.add_entry(create_iden_entry_from_preset(2, 'uhf'))
table.add_entry(create_iden_entry_from_preset(3, 'vhf'))
table.add_entry(create_iden_entry_from_preset(4, 'vhf-hi'))
table.add_entry(create_iden_entry_from_preset(5, 'uhf-ham'))
table.add_entry(create_iden_entry_from_preset(15, '900mhz'))
return table

@ -0,0 +1,441 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
#/**
#* Digital Voice Modem - Host Configuration Generator
#* GPLv2 Open Source. Use is subject to license terms.
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
#*
#*/
#/*
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
#*
#* This program is free software; you can redistribute it and/or modify
#* it under the terms of the GNU General Public License as published by
#* the Free Software Foundation; either version 2 of the License, or
#* (at your option) any later version.
#*
#* This program is distributed in the hope that it will be useful,
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
#* GNU General Public License for more details.
#*
#* You should have received a copy of the GNU General Public License
#* along with this program; if not, write to the Free Software
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
#*/
"""
dvmcfggen - DVMHost Configuration Generator
Network and System ID validation for DVMHost protocols.
This module provides validation functions for DMR, P25, and NXDN network/system
identification parameters based on limits defined in DVMHost C++ code.
"""
from typing import Tuple, Dict, Any
from enum import Enum
class DMRSiteModel(Enum):
"""DMR site model types (from dmr::defines::SiteModel)"""
TINY = 0 # netId: 0-0x1FF (511), siteId: 0-0x07 (7)
SMALL = 1 # netId: 0-0x7F (127), siteId: 0-0x1F (31)
LARGE = 2 # netId: 0-0x1F (31), siteId: 0-0x7F (127)
HUGE = 3 # netId: 0-0x03 (3), siteId: 0-0x3FF (1023)
# DMR Limits (from dvmhost/src/common/dmr/DMRUtils.h)
DMR_COLOR_CODE_MIN = 0
DMR_COLOR_CODE_MAX = 15
DMR_SITE_MODEL_LIMITS = {
DMRSiteModel.TINY: {
'netId': (1, 0x1FF), # 1-511
'siteId': (1, 0x07), # 1-7
},
DMRSiteModel.SMALL: {
'netId': (1, 0x7F), # 1-127
'siteId': (1, 0x1F), # 1-31
},
DMRSiteModel.LARGE: {
'netId': (1, 0x1F), # 1-31
'siteId': (1, 0x7F), # 1-127
},
DMRSiteModel.HUGE: {
'netId': (1, 0x03), # 1-3
'siteId': (1, 0x3FF), # 1-1023
},
}
# P25 Limits (from dvmhost/src/common/p25/P25Utils.h)
P25_NAC_MIN = 0x000
P25_NAC_MAX = 0xF7F # 3967
P25_NET_ID_MIN = 1
P25_NET_ID_MAX = 0xFFFFE # 1048574
P25_SYS_ID_MIN = 1
P25_SYS_ID_MAX = 0xFFE # 4094
P25_RFSS_ID_MIN = 1
P25_RFSS_ID_MAX = 0xFE # 254
P25_SITE_ID_MIN = 1
P25_SITE_ID_MAX = 0xFE # 254
# NXDN Limits (from dvmhost/src/common/nxdn/SiteData.h and NXDNDefines.h)
NXDN_RAN_MIN = 0
NXDN_RAN_MAX = 63 # 6-bit field
NXDN_LOC_ID_MIN = 1
NXDN_LOC_ID_MAX = 0xFFFFFF # 16777215 (24-bit field)
NXDN_SITE_ID_MIN = 1
NXDN_SITE_ID_MAX = 0xFFFFFF # Same as LOC_ID
def validate_dmr_color_code(color_code: int) -> Tuple[bool, str]:
"""
Validate DMR color code.
Args:
color_code: Color code value (0-15)
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(color_code, int):
return False, "Color code must be an integer"
if color_code < DMR_COLOR_CODE_MIN or color_code > DMR_COLOR_CODE_MAX:
return False, f"Color code must be between {DMR_COLOR_CODE_MIN} and {DMR_COLOR_CODE_MAX}"
return True, ""
def validate_dmr_network_id(net_id: int, site_model: DMRSiteModel) -> Tuple[bool, str]:
"""
Validate DMR network ID based on site model.
Args:
net_id: Network ID value
site_model: DMR site model
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(net_id, int):
return False, "Network ID must be an integer"
limits = DMR_SITE_MODEL_LIMITS[site_model]
min_val, max_val = limits['netId']
if net_id < min_val or net_id > max_val:
return False, f"Network ID for {site_model.name} site model must be between {min_val} and {max_val} (0x{max_val:X})"
return True, ""
def validate_dmr_site_id(site_id: int, site_model: DMRSiteModel) -> Tuple[bool, str]:
"""
Validate DMR site ID based on site model.
Args:
site_id: Site ID value
site_model: DMR site model
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(site_id, int):
return False, "Site ID must be an integer"
limits = DMR_SITE_MODEL_LIMITS[site_model]
min_val, max_val = limits['siteId']
if site_id < min_val or site_id > max_val:
return False, f"Site ID for {site_model.name} site model must be between {min_val} and {max_val} (0x{max_val:X})"
return True, ""
def validate_p25_nac(nac: int) -> Tuple[bool, str]:
"""
Validate P25 Network Access Code.
Args:
nac: NAC value (0x000-0xF7F / 0-3967)
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(nac, int):
return False, "NAC must be an integer"
if nac < P25_NAC_MIN or nac > P25_NAC_MAX:
return False, f"NAC must be between {P25_NAC_MIN} (0x{P25_NAC_MIN:03X}) and {P25_NAC_MAX} (0x{P25_NAC_MAX:03X})"
return True, ""
def validate_p25_network_id(net_id: int) -> Tuple[bool, str]:
"""
Validate P25 Network ID (WACN).
Args:
net_id: Network ID value (1-0xFFFFE / 1-1048574)
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(net_id, int):
return False, "Network ID must be an integer"
if net_id < P25_NET_ID_MIN or net_id > P25_NET_ID_MAX:
return False, f"Network ID must be between {P25_NET_ID_MIN} and {P25_NET_ID_MAX} (0x{P25_NET_ID_MAX:X})"
return True, ""
def validate_p25_system_id(sys_id: int) -> Tuple[bool, str]:
"""
Validate P25 System ID.
Args:
sys_id: System ID value (1-0xFFE / 1-4094)
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(sys_id, int):
return False, "System ID must be an integer"
if sys_id < P25_SYS_ID_MIN or sys_id > P25_SYS_ID_MAX:
return False, f"System ID must be between {P25_SYS_ID_MIN} and {P25_SYS_ID_MAX} (0x{P25_SYS_ID_MAX:X})"
return True, ""
def validate_p25_rfss_id(rfss_id: int) -> Tuple[bool, str]:
"""
Validate P25 RFSS ID.
Args:
rfss_id: RFSS ID value (1-0xFE / 1-254)
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(rfss_id, int):
return False, "RFSS ID must be an integer"
if rfss_id < P25_RFSS_ID_MIN or rfss_id > P25_RFSS_ID_MAX:
return False, f"RFSS ID must be between {P25_RFSS_ID_MIN} and {P25_RFSS_ID_MAX} (0x{P25_RFSS_ID_MAX:X})"
return True, ""
def validate_p25_site_id(site_id: int) -> Tuple[bool, str]:
"""
Validate P25 Site ID.
Args:
site_id: Site ID value (1-0xFE / 1-254)
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(site_id, int):
return False, "Site ID must be an integer"
if site_id < P25_SITE_ID_MIN or site_id > P25_SITE_ID_MAX:
return False, f"Site ID must be between {P25_SITE_ID_MIN} and {P25_SITE_ID_MAX} (0x{P25_SITE_ID_MAX:X})"
return True, ""
def validate_nxdn_ran(ran: int) -> Tuple[bool, str]:
"""
Validate NXDN Random Access Number.
Args:
ran: RAN value (0-63)
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(ran, int):
return False, "RAN must be an integer"
if ran < NXDN_RAN_MIN or ran > NXDN_RAN_MAX:
return False, f"RAN must be between {NXDN_RAN_MIN} and {NXDN_RAN_MAX}"
return True, ""
def validate_nxdn_location_id(loc_id: int) -> Tuple[bool, str]:
"""
Validate NXDN Location ID (used as System ID).
Args:
loc_id: Location ID value (1-0xFFFFFF / 1-16777215)
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(loc_id, int):
return False, "Location ID must be an integer"
if loc_id < NXDN_LOC_ID_MIN or loc_id > NXDN_LOC_ID_MAX:
return False, f"Location ID must be between {NXDN_LOC_ID_MIN} and {NXDN_LOC_ID_MAX} (0x{NXDN_LOC_ID_MAX:X})"
return True, ""
def validate_nxdn_site_id(site_id: int) -> Tuple[bool, str]:
"""
Validate NXDN Site ID.
Args:
site_id: Site ID value (1-0xFFFFFF / 1-16777215)
Returns:
Tuple of (is_valid, error_message)
"""
return validate_nxdn_location_id(site_id) # Same limits
def get_dmr_site_model_from_string(model_str: str) -> DMRSiteModel:
"""
Convert site model string to enum.
Args:
model_str: Site model string ('tiny', 'small', 'large', 'huge')
Returns:
DMRSiteModel enum value
"""
model_map = {
'tiny': DMRSiteModel.TINY,
'small': DMRSiteModel.SMALL,
'large': DMRSiteModel.LARGE,
'huge': DMRSiteModel.HUGE,
}
return model_map.get(model_str.lower(), DMRSiteModel.SMALL)
def get_dmr_site_model_name(model: DMRSiteModel) -> str:
"""Get friendly name for DMR site model."""
return model.name.capitalize()
def get_network_id_info(protocol: str, site_model: str = 'small') -> Dict[str, Any]:
"""
Get network ID parameter information for a protocol.
Args:
protocol: Protocol name ('dmr', 'p25', 'nxdn')
site_model: DMR site model for DMR protocol
Returns:
Dictionary with parameter info
"""
protocol = protocol.lower()
if protocol == 'dmr':
model = get_dmr_site_model_from_string(site_model)
limits = DMR_SITE_MODEL_LIMITS[model]
return {
'colorCode': {
'min': DMR_COLOR_CODE_MIN,
'max': DMR_COLOR_CODE_MAX,
'default': 1,
'description': 'DMR Color Code',
},
'dmrNetId': {
'min': limits['netId'][0],
'max': limits['netId'][1],
'default': 1,
'description': f'DMR Network ID ({model.name} site model)',
},
'siteId': {
'min': limits['siteId'][0],
'max': limits['siteId'][1],
'default': 1,
'description': f'DMR Site ID ({model.name} site model)',
},
}
elif protocol == 'p25':
return {
'nac': {
'min': P25_NAC_MIN,
'max': P25_NAC_MAX,
'default': 0x293, # 659 decimal (common default)
'description': 'P25 Network Access Code (NAC)',
},
'netId': {
'min': P25_NET_ID_MIN,
'max': P25_NET_ID_MAX,
'default': 0xBB800, # 768000 decimal
'description': 'P25 Network ID (WACN)',
},
'sysId': {
'min': P25_SYS_ID_MIN,
'max': P25_SYS_ID_MAX,
'default': 0x001,
'description': 'P25 System ID',
},
'rfssId': {
'min': P25_RFSS_ID_MIN,
'max': P25_RFSS_ID_MAX,
'default': 1,
'description': 'P25 RFSS (RF Sub-System) ID',
},
'siteId': {
'min': P25_SITE_ID_MIN,
'max': P25_SITE_ID_MAX,
'default': 1,
'description': 'P25 Site ID',
},
}
elif protocol == 'nxdn':
return {
'ran': {
'min': NXDN_RAN_MIN,
'max': NXDN_RAN_MAX,
'default': 1,
'description': 'NXDN Random Access Number (RAN)',
},
'sysId': {
'min': NXDN_LOC_ID_MIN,
'max': NXDN_LOC_ID_MAX,
'default': 0x001,
'description': 'NXDN System ID (Location ID)',
},
'siteId': {
'min': NXDN_SITE_ID_MIN,
'max': NXDN_SITE_ID_MAX,
'default': 1,
'description': 'NXDN Site ID',
},
}
return {}
def format_hex_or_decimal(value: int, max_value: int) -> str:
"""
Format value as hex if it makes sense, otherwise decimal.
Args:
value: Value to format
max_value: Maximum value for the field
Returns:
Formatted string
"""
if max_value > 999:
return f"{value} (0x{value:X})"
return str(value)

@ -0,0 +1,3 @@
pyyaml>=6.0
textual>=0.47.0
rich>=13.0.0

@ -0,0 +1,284 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
#/**
#* Digital Voice Modem - Host Configuration Generator
#* GPLv2 Open Source. Use is subject to license terms.
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
#*
#*/
#/*
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
#*
#* This program is free software; you can redistribute it and/or modify
#* it under the terms of the GNU General Public License as published by
#* the Free Software Foundation; either version 2 of the License, or
#* (at your option) any later version.
#*
#* This program is distributed in the hope that it will be useful,
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
#* GNU General Public License for more details.
#*
#* You should have received a copy of the GNU General Public License
#* along with this program; if not, write to the Free Software
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
#*/
"""
dvmcfggen - DVMHost Configuration Generator
Pre-configured templates loaded from dvmhost config.example.yml
Ensures all configuration parameters are present
"""
from typing import Dict, Any, Optional
import secrets
import yaml
from pathlib import Path
# Possible locations for config.example.yml
CONFIG_EXAMPLE_PATHS = [
Path(__file__).parent.parent / 'configs' / 'config.example.yml',
Path('/opt/dvm/config.example.yml'),
Path('./config.example.yml'),
]
def find_config_example() -> Optional[Path]:
"""Find the config.example.yml file from dvmhost"""
for path in CONFIG_EXAMPLE_PATHS:
if path.exists():
return path
return None
def load_base_config() -> Dict[str, Any]:
"""
Load the base configuration from config.example.yml
Falls back to minimal config if not found
"""
example_path = find_config_example()
if example_path:
try:
with open(example_path, 'r') as f:
config = yaml.safe_load(f)
if config:
return config
except Exception as e:
print(f"Warning: Could not load {example_path}: {e}")
# Fallback to minimal config if example not found
print("Warning: config.example.yml not found, using minimal fallback config")
return get_minimal_fallback_config()
def get_minimal_fallback_config() -> Dict[str, Any]:
"""Minimal fallback configuration if config.example.yml is not available"""
return {
'daemon': True,
'log': {
'displayLevel': 1,
'fileLevel': 1,
'filePath': '.',
'activityFilePath': '.',
'fileRoot': 'DVM'
},
'network': {
'enable': True,
'id': 100000,
'address': '127.0.0.1',
'port': 62031,
'password': 'PASSWORD',
'rpcAddress': '127.0.0.1',
'rpcPort': 9890,
'rpcPassword': 'ULTRA-VERY-SECURE-DEFAULT',
},
'system': {
'identity': 'DVM',
'timeout': 180,
'duplex': True,
'modeHang': 10,
'rfTalkgroupHang': 10,
'activeTickDelay': 5,
'idleTickDelay': 5,
'info': {
'latitude': 0.0,
'longitude': 0.0,
'height': 1,
'power': 0,
'location': 'Anywhere'
},
'config': {
'authoritative': True,
'supervisor': False,
'channelId': 1,
'channelNo': 1,
'serviceClass': 1,
'siteId': 1,
'dmrNetId': 1,
'dmrColorCode': 1,
'p25NAC': 0x293,
'p25NetId': 0xBB800,
'nxdnRAN': 1
},
'cwId': {
'enable': True,
'time': 10,
'callsign': 'MYCALL'
},
'modem': {
'protocol': {
'type': 'uart',
'uart': {
'port': '/dev/ttyUSB0',
'speed': 115200
}
},
'rxLevel': 50,
'txLevel': 50,
'rxDCOffset': 0,
'txDCOffset': 0
}
},
'protocols': {
'dmr': {
'enable': True,
'control': {
'enable': True,
'dedicated': False
}
},
'p25': {
'enable': True,
'control': {
'enable': True,
'dedicated': False
}
},
'nxdn': {
'enable': False,
'control': {
'enable': False,
'dedicated': False
}
}
}
}
def generate_secure_key(length: int = 64) -> str:
"""Generate a secure random hex key"""
return secrets.token_hex(length // 2).upper()
def apply_template_customizations(config: Dict[str, Any], template_type: str) -> Dict[str, Any]:
"""
Apply template-specific customizations to the base config
Args:
config: Base configuration dict
template_type: Template name (repeater, etc.)
Returns:
Customized configuration dict
"""
# Make a deep copy to avoid modifying the original
import copy
config = copy.deepcopy(config)
if template_type == 'control-channel-p25':
# P25 dedicated control channel
config['system']['duplex'] = True
config['system']['identity'] = 'CC-P25'
config['system']['config']['authoritative'] = True
config['system']['config']['supervisor'] = True
config['protocols']['dmr']['enable'] = False
config['protocols']['p25']['enable'] = True
config['protocols']['nxdn']['enable'] = False
config['protocols']['p25']['control']['enable'] = True
config['protocols']['p25']['control']['dedicated'] = True
elif template_type == 'control-channel-dmr':
# DMR dedicated control channel
config['system']['duplex'] = True
config['system']['identity'] = 'CC-DMR'
config['system']['config']['authoritative'] = True
config['system']['config']['supervisor'] = True
config['protocols']['dmr']['enable'] = True
config['protocols']['p25']['enable'] = False
config['protocols']['nxdn']['enable'] = False
config['protocols']['dmr']['control']['enable'] = True
config['protocols']['dmr']['control']['dedicated'] = True
elif template_type == 'voice-channel':
# Voice channel for trunking
config['system']['duplex'] = True
config['system']['identity'] = 'VC'
config['system']['config']['authoritative'] = False
config['system']['config']['supervisor'] = False
config['protocols']['dmr']['enable'] = True
config['protocols']['p25']['enable'] = True
config['protocols']['nxdn']['enable'] = False
config['protocols']['dmr']['control']['enable'] = False
config['protocols']['dmr']['control']['dedicated'] = False
config['protocols']['p25']['control']['enable'] = False
config['protocols']['p25']['control']['dedicated'] = False
elif template_type == 'enhanced':
# Enhanced conventional repeater with grants
config['system']['duplex'] = True
config['system']['identity'] = 'CONV'
config['protocols']['dmr']['enable'] = True
config['protocols']['p25']['enable'] = True
config['protocols']['nxdn']['enable'] = False
config['protocols']['dmr']['control']['enable'] = True
config['protocols']['dmr']['control']['dedicated'] = False
config['protocols']['p25']['control']['enable'] = True
config['protocols']['p25']['control']['dedicated'] = False
elif template_type == 'conventional':
# Conventional repeater
config['system']['duplex'] = True
config['system']['identity'] = 'RPT'
config['protocols']['dmr']['enable'] = False
config['protocols']['p25']['enable'] = True
config['protocols']['nxdn']['enable'] = False
config['protocols']['dmr']['control']['enable'] = False
config['protocols']['dmr']['control']['dedicated'] = False
config['protocols']['p25']['control']['enable'] = True
config['protocols']['p25']['control']['dedicated'] = False
return config
def get_template(template_name: str) -> Dict[str, Any]:
"""
Get a configuration template by name
Args:
template_name: Name of the template
Returns:
Complete configuration dictionary with all parameters
"""
if template_name not in TEMPLATES:
raise ValueError(f"Unknown template: {template_name}")
# Load base config from config.example.yml
base_config = load_base_config()
# Apply template-specific customizations
customized_config = apply_template_customizations(base_config, template_name)
return customized_config
# Available templates
TEMPLATES = {
'conventional': 'Conventional repeater/hotspot',
'enhanced': 'Enhanced conventional repeater/hotspot with grants',
'control-channel-p25': 'P25 dedicated control channel for trunking',
'control-channel-dmr': 'DMR dedicated control channel for trunking',
'voice-channel': 'Voice channel for trunking system',
}

@ -0,0 +1,497 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
#/**
#* Digital Voice Modem - Host Configuration Generator
#* GPLv2 Open Source. Use is subject to license terms.
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
#*
#*/
#/*
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
#*
#* This program is free software; you can redistribute it and/or modify
#* it under the terms of the GNU General Public License as published by
#* the Free Software Foundation; either version 2 of the License, or
#* (at your option) any later version.
#*
#* This program is distributed in the hope that it will be useful,
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
#* GNU General Public License for more details.
#*
#* You should have received a copy of the GNU General Public License
#* along with this program; if not, write to the Free Software
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
#*/
"""
dvmcfggen - DVMHost Configuration Generator
Multi-Instance Trunking Manager
Manages complete trunked systems with control and voice channels
"""
from pathlib import Path
from typing import List, Dict, Any, Optional
import yaml
from config_manager import DVMConfig
from templates import get_template
class TrunkingSystem:
"""Manages a complete trunked system configuration"""
def __init__(self, base_dir: Path, system_name: str = "trunked"):
"""
Initialize trunking system manager
Args:
base_dir: Base directory for configuration files
system_name: Name of the trunking system
"""
self.base_dir = Path(base_dir)
self.system_name = system_name
self.cc_config: Optional[DVMConfig] = None
self.vc_configs: List[DVMConfig] = []
def create_system(self,
protocol: str = 'p25',
vc_count: int = 2,
fne_address: str = '127.0.0.1',
fne_port: int = 62031,
fne_password: str = 'PASSWORD',
base_peer_id: int = 100000,
base_rpc_port: int = 9890,
rpc_password: str = 'PASSWORD',
base_rest_port: int = 8080,
rest_password: str = None,
update_lookups: bool = True,
save_lookups: bool = True,
allow_activity_transfer: bool = True,
allow_diagnostic_transfer: bool = False,
allow_status_transfer: bool = True,
radio_id_file: str = None,
radio_id_time: int = 0,
radio_id_acl: bool = False,
talkgroup_id_file: str = None,
talkgroup_id_time: int = 0,
talkgroup_id_acl: bool = False,
system_identity: str = 'SKYNET',
nac: int = 0x293,
color_code: int = 1,
site_id: int = 1,
modem_type: str = 'uart',
cc_dfsi_rtrt: int = None,
cc_dfsi_jitter: int = None,
cc_dfsi_call_timeout: int = None,
cc_dfsi_full_duplex: bool = None,
cc_channel_id: int = 0,
cc_channel_no: int = 0,
dmr_net_id: int = None,
net_id: int = None,
sys_id: int = None,
rfss_id: int = None,
ran: int = None,
vc_channels: List[Dict[str, int]] = None) -> None:
"""
Create a complete trunked system
Args:
protocol: Protocol type ('p25' or 'dmr')
vc_count: Number of voice channels
fne_address: FNE address
fne_port: FNE port
fne_password: FNE password
base_peer_id: Base peer ID (CC gets this, VCs increment)
base_rpc_port: Base RPC port (CC gets this, VCs increment)
rpc_password: RPC password
base_rest_port: Base REST API port (CC gets this, VCs increment)
rest_password: REST API password
update_lookups: Update lookups from network
save_lookups: Save lookups to network
allow_activity_transfer: Allow activity transfer to FNE
allow_diagnostic_transfer: Allow diagnostic transfer to FNE
allow_status_transfer: Allow status transfer to FNE
radio_id_file: Radio ID ACL file path
radio_id_time: Radio ID update time (seconds)
radio_id_acl: Enforce Radio ID ACLs
talkgroup_id_file: Talkgroup ID ACL file path
talkgroup_id_time: Talkgroup ID update time (seconds)
talkgroup_id_acl: Enforce Talkgroup ID ACLs
system_identity: System identity prefix
nac: P25 NAC (for P25 systems)
color_code: DMR color code (for DMR systems)
site_id: Site ID
modem_type: Modem type ('uart' or 'null')
cc_dfsi_rtrt: Control channel DFSI RTRT (Round Trip Response Time, ms)
cc_dfsi_jitter: Control channel DFSI Jitter (ms)
cc_dfsi_call_timeout: Control channel DFSI Call Timeout (seconds)
cc_dfsi_full_duplex: Control channel DFSI Full Duplex enabled
cc_channel_id: Control channel ID
cc_channel_no: Control channel number
dmr_net_id: DMR Network ID (for DMR systems)
net_id: P25 Network ID (for P25 systems)
sys_id: P25 System ID (for P25 systems)
rfss_id: P25 RFSS ID (for P25 systems)
ran: NXDN RAN (for NXDN systems)
vc_channels: List of voice channel configs with optional DFSI settings [{'channel_id': int, 'channel_no': int, 'dfsi_rtrt': int, ...}, ...]
"""
# If no VC channel info provided, use defaults (same ID as CC, sequential numbers)
if vc_channels is None:
vc_channels = []
for i in range(1, vc_count + 1):
vc_channels.append({
'channel_id': cc_channel_id,
'channel_no': cc_channel_no + i
})
# Create base directory
self.base_dir.mkdir(parents=True, exist_ok=True)
# Create control channel
print(f"Creating control channel configuration...")
if protocol.lower() == 'p25':
cc_template = get_template('control-channel-p25')
else:
cc_template = get_template('control-channel-dmr')
self.cc_config = DVMConfig()
self.cc_config.config = cc_template
# Configure control channel
self.cc_config.set('system.identity', f'{system_identity}-CC')
self.cc_config.set('network.id', base_peer_id)
self.cc_config.set('network.address', fne_address)
self.cc_config.set('network.port', fne_port)
self.cc_config.set('network.password', fne_password)
self.cc_config.set('network.rpcPort', base_rpc_port)
self.cc_config.set('network.rpcPassword', rpc_password)
if rest_password is not None:
self.cc_config.set('network.restEnable', True)
self.cc_config.set('network.restPort', base_rest_port)
self.cc_config.set('network.restPassword', rest_password)
else:
self.cc_config.set('network.restEnable', False)
# Network lookup and transfer settings
self.cc_config.set('network.updateLookups', update_lookups)
self.cc_config.set('network.saveLookups', save_lookups)
self.cc_config.set('network.allowActivityTransfer', allow_activity_transfer)
self.cc_config.set('network.allowDiagnosticTransfer', allow_diagnostic_transfer)
self.cc_config.set('network.allowStatusTransfer', allow_status_transfer)
# If updating lookups, set time values to 0
if update_lookups:
self.cc_config.set('system.radio_id.time', 0)
self.cc_config.set('system.talkgroup_id.time', 0)
else:
# If not updating lookups, use provided values
if radio_id_time > 0:
self.cc_config.set('system.radio_id.time', radio_id_time)
if talkgroup_id_time > 0:
self.cc_config.set('system.talkgroup_id.time', talkgroup_id_time)
# Radio ID and Talkgroup ID ACL settings
if radio_id_file:
self.cc_config.set('system.radio_id.file', radio_id_file)
self.cc_config.set('system.radio_id.acl', radio_id_acl)
if talkgroup_id_file:
self.cc_config.set('system.talkgroup_id.file', talkgroup_id_file)
self.cc_config.set('system.talkgroup_id.acl', talkgroup_id_acl)
self.cc_config.set('system.modem.protocol.type', modem_type)
# DFSI Configuration for control channel (if provided)
if cc_dfsi_rtrt is not None:
self.cc_config.set('system.modem.dfsiRtrt', cc_dfsi_rtrt)
if cc_dfsi_jitter is not None:
self.cc_config.set('system.modem.dfsiJitter', cc_dfsi_jitter)
if cc_dfsi_call_timeout is not None:
self.cc_config.set('system.modem.dfsiCallTimeout', cc_dfsi_call_timeout)
if cc_dfsi_full_duplex is not None:
self.cc_config.set('system.modem.dfsiFullDuplex', cc_dfsi_full_duplex)
self.cc_config.set('system.config.channelId', cc_channel_id)
self.cc_config.set('system.config.channelNo', cc_channel_no)
# Set network/system IDs consistently
if site_id is not None:
self.cc_config.set('system.config.siteId', site_id)
if nac is not None:
self.cc_config.set('system.config.nac', nac)
if color_code is not None:
self.cc_config.set('system.config.colorCode', color_code)
if dmr_net_id is not None:
self.cc_config.set('system.config.dmrNetId', dmr_net_id)
if net_id is not None:
self.cc_config.set('system.config.netId', net_id)
if sys_id is not None:
self.cc_config.set('system.config.sysId', sys_id)
if rfss_id is not None:
self.cc_config.set('system.config.rfssId', rfss_id)
if ran is not None:
self.cc_config.set('system.config.ran', ran)
# Build voice channel list for CC
voice_channels = []
for i in range(vc_count):
vc_rpc_port = base_rpc_port + i + 1
vc_info = vc_channels[i]
voice_channels.append({
'channelId': vc_info['channel_id'],
'channelNo': vc_info['channel_no'],
'rpcAddress': '127.0.0.1',
'rpcPort': vc_rpc_port,
'rpcPassword': fne_password
})
self.cc_config.set('system.config.voiceChNo', voice_channels)
# Save control channel config
cc_path = self.base_dir / f'{self.system_name}-cc.yml'
self.cc_config.save(cc_path)
print(f" Saved: {cc_path}")
# Create voice channels
print(f"\nCreating {vc_count} voice channel configurations...")
for i in range(vc_count):
vc_index = i + 1
vc_peer_id = base_peer_id + vc_index
vc_rpc_port = base_rpc_port + vc_index
vc_rest_port = base_rest_port + vc_index if rest_password else None
vc_info = vc_channels[i]
channel_id = vc_info['channel_id']
channel_no = vc_info['channel_no']
vc_template = get_template('voice-channel')
vc_config = DVMConfig()
vc_config.config = vc_template
# Configure voice channel
vc_config.set('system.identity', f'{system_identity}-VC{vc_index:02d}')
vc_config.set('network.id', vc_peer_id)
vc_config.set('network.address', fne_address)
vc_config.set('network.port', fne_port)
vc_config.set('network.password', fne_password)
vc_config.set('network.rpcPort', vc_rpc_port)
vc_config.set('network.rpcPassword', rpc_password)
if rest_password is not None:
vc_config.set('network.restEnable', True)
vc_config.set('network.restPort', vc_rest_port)
vc_config.set('network.restPassword', rest_password)
else:
vc_config.set('network.restEnable', False)
# Network lookup and transfer settings
vc_config.set('network.updateLookups', update_lookups)
vc_config.set('network.saveLookups', save_lookups)
vc_config.set('network.allowActivityTransfer', allow_activity_transfer)
vc_config.set('network.allowDiagnosticTransfer', allow_diagnostic_transfer)
vc_config.set('network.allowStatusTransfer', allow_status_transfer)
# If updating lookups, set time values to 0
if update_lookups:
vc_config.set('system.radio_id.time', 0)
vc_config.set('system.talkgroup_id.time', 0)
else:
# If not updating lookups, use provided values
if radio_id_time > 0:
vc_config.set('system.radio_id.time', radio_id_time)
if talkgroup_id_time > 0:
vc_config.set('system.talkgroup_id.time', talkgroup_id_time)
# Radio ID and Talkgroup ID ACL settings
if radio_id_file:
vc_config.set('system.radio_id.file', radio_id_file)
vc_config.set('system.radio_id.acl', radio_id_acl)
if talkgroup_id_file:
vc_config.set('system.talkgroup_id.file', talkgroup_id_file)
vc_config.set('system.talkgroup_id.acl', talkgroup_id_acl)
vc_config.set('system.config.channelId', channel_id)
vc_config.set('system.config.channelNo', channel_no)
vc_config.set('system.modem.protocol.type', modem_type)
# DFSI Configuration for voice channel (if provided)
if 'dfsi_rtrt' in vc_info and vc_info['dfsi_rtrt'] is not None:
vc_config.set('system.modem.dfsiRtrt', vc_info['dfsi_rtrt'])
if 'dfsi_jitter' in vc_info and vc_info['dfsi_jitter'] is not None:
vc_config.set('system.modem.dfsiJitter', vc_info['dfsi_jitter'])
if 'dfsi_call_timeout' in vc_info and vc_info['dfsi_call_timeout'] is not None:
vc_config.set('system.modem.dfsiCallTimeout', vc_info['dfsi_call_timeout'])
if 'dfsi_full_duplex' in vc_info and vc_info['dfsi_full_duplex'] is not None:
vc_config.set('system.modem.dfsiFullDuplex', vc_info['dfsi_full_duplex'])
# Set network/system IDs consistently (same as CC)
if site_id is not None:
vc_config.set('system.config.siteId', site_id)
if nac is not None:
vc_config.set('system.config.nac', nac)
if color_code is not None:
vc_config.set('system.config.colorCode', color_code)
if dmr_net_id is not None:
vc_config.set('system.config.dmrNetId', dmr_net_id)
if net_id is not None:
vc_config.set('system.config.netId', net_id)
if sys_id is not None:
vc_config.set('system.config.sysId', sys_id)
if rfss_id is not None:
vc_config.set('system.config.rfssId', rfss_id)
if ran is not None:
vc_config.set('system.config.ran', ran)
# Ensure protocol consistency
if protocol.lower() == 'p25':
vc_config.set('protocols.p25.enable', True)
vc_config.set('protocols.dmr.enable', False)
else:
vc_config.set('protocols.dmr.enable', True)
vc_config.set('protocols.p25.enable', False)
# Configure control channel reference for voice channel
vc_config.set('system.config.controlCh.rpcAddress', '127.0.0.1')
vc_config.set('system.config.controlCh.rpcPort', base_rpc_port)
vc_config.set('system.config.controlCh.rpcPassword', fne_password)
vc_config.set('system.config.controlCh.notifyEnable', True)
self.vc_configs.append(vc_config)
# Save voice channel config
vc_path = self.base_dir / f'{self.system_name}-vc{vc_index:02d}.yml'
vc_config.save(vc_path)
print(f" Saved: {vc_path}")
print(f"\nTrunked system '{self.system_name}' created successfully!")
print(f" Control Channel: {cc_path}")
print(f" Voice Channels: {vc_count}")
print(f" Base Directory: {self.base_dir}")
def load_system(self) -> None:
"""Load existing trunked system"""
cc_path = self.base_dir / f'{self.system_name}-cc.yml'
if not cc_path.exists():
raise FileNotFoundError(f"Control channel config not found: {cc_path}")
self.cc_config = DVMConfig(cc_path)
# Load voice channels
self.vc_configs = []
i = 1
while True:
vc_path = self.base_dir / f'{self.system_name}-vc{i:02d}.yml'
if not vc_path.exists():
break
vc_config = DVMConfig(vc_path)
self.vc_configs.append(vc_config)
i += 1
def validate_system(self) -> Dict[str, List[str]]:
"""
Validate entire trunked system
Returns:
Dictionary mapping config files to error lists
"""
errors = {}
# Validate control channel
cc_errors = self.cc_config.validate()
if cc_errors:
errors['control_channel'] = cc_errors
# Validate voice channels
for i, vc in enumerate(self.vc_configs, 1):
vc_errors = vc.validate()
if vc_errors:
errors[f'voice_channel_{i}'] = vc_errors
# Check consistency
consistency_errors = self._check_consistency()
if consistency_errors:
errors['consistency'] = consistency_errors
return errors
def _check_consistency(self) -> List[str]:
"""Check consistency across all configs"""
errors = []
if not self.cc_config or not self.vc_configs:
return errors
# Get reference values from CC
cc_nac = self.cc_config.get('system.config.nac')
cc_color_code = self.cc_config.get('system.config.colorCode')
cc_site_id = self.cc_config.get('system.config.siteId')
cc_net_id = self.cc_config.get('system.config.netId')
cc_dmr_net_id = self.cc_config.get('system.config.dmrNetId')
cc_sys_id = self.cc_config.get('system.config.sysId')
cc_rfss_id = self.cc_config.get('system.config.rfssId')
cc_ran = self.cc_config.get('system.config.ran')
cc_fne = self.cc_config.get('network.address')
cc_fne_port = self.cc_config.get('network.port')
# Check each voice channel
for i, vc in enumerate(self.vc_configs, 1):
if cc_nac is not None and vc.get('system.config.nac') != cc_nac:
errors.append(f"VC{i:02d}: NAC mismatch (expected {cc_nac})")
if cc_color_code is not None and vc.get('system.config.colorCode') != cc_color_code:
errors.append(f"VC{i:02d}: Color code mismatch (expected {cc_color_code})")
if cc_site_id is not None and vc.get('system.config.siteId') != cc_site_id:
errors.append(f"VC{i:02d}: Site ID mismatch (expected {cc_site_id})")
if cc_net_id is not None and vc.get('system.config.netId') != cc_net_id:
errors.append(f"VC{i:02d}: Network ID mismatch (expected {cc_net_id})")
if cc_dmr_net_id is not None and vc.get('system.config.dmrNetId') != cc_dmr_net_id:
errors.append(f"VC{i:02d}: DMR Network ID mismatch (expected {cc_dmr_net_id})")
if cc_sys_id is not None and vc.get('system.config.sysId') != cc_sys_id:
errors.append(f"VC{i:02d}: System ID mismatch (expected {cc_sys_id})")
if cc_rfss_id is not None and vc.get('system.config.rfssId') != cc_rfss_id:
errors.append(f"VC{i:02d}: RFSS ID mismatch (expected {cc_rfss_id})")
if cc_ran is not None and vc.get('system.config.ran') != cc_ran:
errors.append(f"VC{i:02d}: RAN mismatch (expected {cc_ran})")
if vc.get('network.address') != cc_fne:
errors.append(f"VC{i:02d}: FNE address mismatch (expected {cc_fne})")
if vc.get('network.port') != cc_fne_port:
errors.append(f"VC{i:02d}: FNE port mismatch (expected {cc_fne_port})")
return errors
def update_all(self, key_path: str, value: Any) -> None:
"""Update a setting across all configs"""
self.cc_config.set(key_path, value)
for vc in self.vc_configs:
vc.set(key_path, value)
def save_all(self) -> None:
"""Save all configurations"""
cc_path = self.base_dir / f'{self.system_name}-cc.yml'
self.cc_config.save(cc_path)
for i, vc in enumerate(self.vc_configs, 1):
vc_path = self.base_dir / f'{self.system_name}-vc{i:02d}.yml'
vc.save(vc_path)
self._create_summary()
if __name__ == '__main__':
# Test
system = TrunkingSystem(Path('./test_trunk'), 'skynet')
system.create_system(
protocol='p25',
vc_count=2,
system_identity='SKYNET',
base_peer_id=100000
)

@ -0,0 +1,51 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
#/**
#* Digital Voice Modem - Host Configuration Generator
#* GPLv2 Open Source. Use is subject to license terms.
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
#*
#*/
#/*
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
#*
#* This program is free software; you can redistribute it and/or modify
#* it under the terms of the GNU General Public License as published by
#* the Free Software Foundation; either version 2 of the License, or
#* (at your option) any later version.
#*
#* This program is distributed in the hope that it will be useful,
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
#* GNU General Public License for more details.
#*
#* You should have received a copy of the GNU General Public License
#* along with this program; if not, write to the Free Software
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
#*/
"""
dvmcfggen - DVMHost Configuration Generator
Version and banner constants
"""
# Version information
VERSION_MAJOR = "05"
VERSION_MINOR = "04"
VERSION_REV = "A"
__VER__ = f"{VERSION_MAJOR}.{VERSION_MINOR}{VERSION_REV} (R{VERSION_MAJOR}{VERSION_REV}{VERSION_MINOR})"
# ASCII Banner
__BANNER__ = """
. .
8 888888888o. `8.`888b ,8' ,8. ,8.
8 8888 `^888. `8.`888b ,8' ,888. ,888.
8 8888 `88.`8.`888b ,8' .`8888. .`8888.
8 8888 `88 `8.`888b ,8' ,8.`8888. ,8.`8888.
8 8888 88 `8.`888b ,8' ,8'8.`8888,8^8.`8888.
8 8888 88 `8.`888b ,8' ,8' `8.`8888' `8.`8888.
8 8888 ,88 `8.`888b8' ,8' `8.`88' `8.`8888.
8 8888 ,88' `8.`888' ,8' `8.`' `8.`8888.
8 8888 ,o88P' `8.`8' ,8' `8 `8.`8888.
8 888888888P' `8.` ,8' ` `8.`8888.
"""

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save

Powered by TurnKey Linux.