From 87f94b837a5e4927fee53e91597c02204852d717 Mon Sep 17 00:00:00 2001 From: Bryan Biedenkapp Date: Thu, 5 Feb 2026 00:48:25 -0500 Subject: [PATCH] update gitignore to include some python stuff; add very very preliminary Python tool that helps generate dvmhost configurations; --- .gitignore | 3 + tools/dvmcfggen/EXAMPLES.md | 326 ++++++ tools/dvmcfggen/QUICKREF.md | 147 +++ tools/dvmcfggen/README.md | 67 ++ tools/dvmcfggen/USAGE.md | 330 ++++++ tools/dvmcfggen/__init__.py | 45 + tools/dvmcfggen/config_manager.py | 273 +++++ tools/dvmcfggen/dvmcfg | 44 + tools/dvmcfggen/dvmcfg.py | 928 ++++++++++++++++ tools/dvmcfggen/iden_table.py | 331 ++++++ tools/dvmcfggen/network_ids.py | 441 ++++++++ tools/dvmcfggen/requirements.txt | 3 + tools/dvmcfggen/templates.py | 284 +++++ tools/dvmcfggen/trunking_manager.py | 497 +++++++++ tools/dvmcfggen/version.py | 51 + tools/dvmcfggen/wizard.py | 1549 +++++++++++++++++++++++++++ 16 files changed, 5319 insertions(+) create mode 100644 tools/dvmcfggen/EXAMPLES.md create mode 100644 tools/dvmcfggen/QUICKREF.md create mode 100644 tools/dvmcfggen/README.md create mode 100644 tools/dvmcfggen/USAGE.md create mode 100644 tools/dvmcfggen/__init__.py create mode 100644 tools/dvmcfggen/config_manager.py create mode 100755 tools/dvmcfggen/dvmcfg create mode 100755 tools/dvmcfggen/dvmcfg.py create mode 100644 tools/dvmcfggen/iden_table.py create mode 100644 tools/dvmcfggen/network_ids.py create mode 100644 tools/dvmcfggen/requirements.txt create mode 100644 tools/dvmcfggen/templates.py create mode 100644 tools/dvmcfggen/trunking_manager.py create mode 100644 tools/dvmcfggen/version.py create mode 100644 tools/dvmcfggen/wizard.py diff --git a/.gitignore b/.gitignore index b815d749..247a1564 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,9 @@ package/ *.ini .vs .idea/ +venv/ +__pycache__/ +*.pyc # Prerequisites *.d diff --git a/tools/dvmcfggen/EXAMPLES.md b/tools/dvmcfggen/EXAMPLES.md new file mode 100644 index 00000000..030032b5 --- /dev/null +++ b/tools/dvmcfggen/EXAMPLES.md @@ -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 diff --git a/tools/dvmcfggen/QUICKREF.md b/tools/dvmcfggen/QUICKREF.md new file mode 100644 index 00000000..d7b7a9c6 --- /dev/null +++ b/tools/dvmcfggen/QUICKREF.md @@ -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 --output [options] + +# Validate +./dvmcfg validate [--summary] + +# Edit value +./dvmcfg edit + +# List templates +./dvmcfg templates +``` + +### Trunked Systems +```bash +# Create system +./dvmcfg trunk create --base-dir --vc-count [options] + +# Validate system +./dvmcfg trunk validate --base-dir --name + +# Update all configs +./dvmcfg trunk update --base-dir --name +``` + +## 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. diff --git a/tools/dvmcfggen/README.md b/tools/dvmcfggen/README.md new file mode 100644 index 00000000..a8b1fbf4 --- /dev/null +++ b/tools/dvmcfggen/README.md @@ -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. diff --git a/tools/dvmcfggen/USAGE.md b/tools/dvmcfggen/USAGE.md new file mode 100644 index 00000000..3c8b8004 --- /dev/null +++ b/tools/dvmcfggen/USAGE.md @@ -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 --output [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 [--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 +``` + +**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 [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 [--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 --name +``` + +**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 diff --git a/tools/dvmcfggen/__init__.py b/tools/dvmcfggen/__init__.py new file mode 100644 index 00000000..9cd4e0c9 --- /dev/null +++ b/tools/dvmcfggen/__init__.py @@ -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', +] diff --git a/tools/dvmcfggen/config_manager.py b/tools/dvmcfggen/config_manager.py new file mode 100644 index 00000000..2dd9e155 --- /dev/null +++ b/tools/dvmcfggen/config_manager.py @@ -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") diff --git a/tools/dvmcfggen/dvmcfg b/tools/dvmcfggen/dvmcfg new file mode 100755 index 00000000..91af5611 --- /dev/null +++ b/tools/dvmcfggen/dvmcfg @@ -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 "$@" diff --git a/tools/dvmcfggen/dvmcfg.py b/tools/dvmcfggen/dvmcfg.py new file mode 100755 index 00000000..ed4c5ef3 --- /dev/null +++ b/tools/dvmcfggen/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() diff --git a/tools/dvmcfggen/iden_table.py b/tools/dvmcfggen/iden_table.py new file mode 100644 index 00000000..73399a32 --- /dev/null +++ b/tools/dvmcfggen/iden_table.py @@ -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 diff --git a/tools/dvmcfggen/network_ids.py b/tools/dvmcfggen/network_ids.py new file mode 100644 index 00000000..731d3e6a --- /dev/null +++ b/tools/dvmcfggen/network_ids.py @@ -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) diff --git a/tools/dvmcfggen/requirements.txt b/tools/dvmcfggen/requirements.txt new file mode 100644 index 00000000..648db42f --- /dev/null +++ b/tools/dvmcfggen/requirements.txt @@ -0,0 +1,3 @@ +pyyaml>=6.0 +textual>=0.47.0 +rich>=13.0.0 diff --git a/tools/dvmcfggen/templates.py b/tools/dvmcfggen/templates.py new file mode 100644 index 00000000..4cae9f88 --- /dev/null +++ b/tools/dvmcfggen/templates.py @@ -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', +} diff --git a/tools/dvmcfggen/trunking_manager.py b/tools/dvmcfggen/trunking_manager.py new file mode 100644 index 00000000..5a58c585 --- /dev/null +++ b/tools/dvmcfggen/trunking_manager.py @@ -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 + ) diff --git a/tools/dvmcfggen/version.py b/tools/dvmcfggen/version.py new file mode 100644 index 00000000..d28e51a9 --- /dev/null +++ b/tools/dvmcfggen/version.py @@ -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. +""" diff --git a/tools/dvmcfggen/wizard.py b/tools/dvmcfggen/wizard.py new file mode 100644 index 00000000..c197959b --- /dev/null +++ b/tools/dvmcfggen/wizard.py @@ -0,0 +1,1549 @@ +#!/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 + +Interactive Configuration Wizard +Step-by-step guided configuration creation +""" + +from pathlib import Path +from typing import Optional, Any, Dict, Tuple +import secrets +import string +from rich.console import Console +from rich.prompt import Prompt, Confirm, IntPrompt +from rich.panel import Panel +from rich.table import Table +from rich import print as rprint + +from config_manager import DVMConfig, ConfigValidator +from templates import get_template, TEMPLATES +from trunking_manager import TrunkingSystem +from iden_table import (IdenTable, create_default_iden_table, calculate_channel_assignment, + BAND_PRESETS, create_iden_entry_from_preset) +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, + DMRSiteModel, get_dmr_site_model_from_string, format_hex_or_decimal +) +from version import __BANNER__, __VER__ + + +console = Console() +validator = ConfigValidator() + + +def generate_random_password(length: int = 24) -> str: + """Generate a random password using letters, digits, and special characters""" + chars = string.ascii_letters + string.digits + "!@#$%^&*-_=+" + return ''.join(secrets.choice(chars) for _ in range(length)) + + +class ConfigWizard: + """Interactive configuration wizard""" + + def __init__(self): + self.config: Optional[DVMConfig] = None + self.template_name: Optional[str] = None + self.iden_table: IdenTable = create_default_iden_table() + self.iden_file_path: Optional[Path] = None + self.config_dir: str = "." + self.rpc_password: Optional[str] = None + self.rest_password: Optional[str] = None + + def run(self) -> Optional[Path]: + """Run the interactive wizard""" + # Don't clear or show banner here - run_wizard handles it + console.print() + console.print(Panel.fit( + "[bold cyan]Conventional System Configuration Wizard[/bold cyan]\n" + "Create a conventional system configuration", + border_style="cyan" + )) + console.print() + + # Step 1: Choose template + self.template_name = self._choose_template() + if not self.template_name: + return None + + console.print() + + # Step 2: Create base config + self.config = DVMConfig() + self.config.config = get_template(self.template_name) + + # Step 3: Collect configuration + if self.template_name in ['conventional', 'enhanced']: + return self._configure_single_instance() + elif 'control-channel' in self.template_name or 'voice-channel' in self.template_name: + console.print("[yellow]For trunked systems, use the trunking wizard instead.[/yellow]") + if Confirm.ask("Start trunking wizard?"): + return self._run_trunking_wizard() + return None + + return None + + def _choose_template(self) -> Optional[str]: + """Choose configuration template""" + console.print("[bold]Step 1: Choose Configuration Template[/bold]\n") + + table = Table(show_header=True, header_style="bold cyan") + table.add_column("#", style="dim", width=3) + table.add_column("Template", style="cyan") + table.add_column("Type", style="yellow") + table.add_column("Description", style="white") + + templates = [ + ('conventional', 'Single', 'Conventional repeater/hotspot'), + ('enhanced', 'Single', 'Enhanced conventional repeater/hotspot with grants'), + ] + + for i, (name, type_, desc) in enumerate(templates, 1): + table.add_row(str(i), name, type_, desc) + + console.print(table) + console.print() + + choice = IntPrompt.ask( + "Select template", + choices=[str(i) for i in range(1, len(templates) + 1)], + default="2" # Enhanced template + ) + + return templates[int(choice) - 1][0] + + def _configure_single_instance(self) -> Optional[Path]: + """Configure a single instance""" + console.print("\n[bold]Step 2: Basic Configuration[/bold]\n") + + # Configuration directory + self.config_dir = Prompt.ask( + "Configuration directory", + default="." + ) + + # System Identity + identity = Prompt.ask( + "System identity (callsign or site name)", + default="SITE001" + ) + self.config.set('system.identity', identity) + self.config.set('system.cwId.callsign', identity[:8]) # Truncate for CW + + # Peer ID + peer_id = IntPrompt.ask( + "Network peer ID", + default=100000 + ) + self.config.set('network.id', peer_id) + + console.print("\n[bold]Step 3: Modem Configuration[/bold]\n") + + # Modem Type + console.print("Modem type:") + console.print(" uart - Serial UART (most common)") + console.print(" null - Test mode (no hardware)\n") + + modem_type = Prompt.ask( + "Modem type", + choices=['uart', 'null'], + default='uart' + ) + self.config.set('system.modem.protocol.type', modem_type) + + # Modem Mode + console.print("\nModem mode:") + console.print(" air - Standard air interface (repeater/hotspot)") + console.print(" dfsi - DFSI mode for interfacing with V.24 repeaters\n") + + modem_mode = Prompt.ask( + "Modem mode", + choices=['air', 'dfsi'], + default='air' + ) + self.config.set('system.modem.protocol.mode', modem_mode) + + if modem_type == 'uart': + serial_port = Prompt.ask( + "Serial port", + default="/dev/ttyUSB0" + ) + self.config.set('system.modem.protocol.uart.port', serial_port) + + console.print("\n[dim]Leave at 50 unless you know specific values[/dim]") + rx_level = IntPrompt.ask( + "RX level (0-100)", + default=50 + ) + self.config.set('system.modem.rxLevel', rx_level) + + tx_level = IntPrompt.ask( + "TX level (0-100)", + default=50 + ) + self.config.set('system.modem.txLevel', tx_level) + + # DFSI Configuration (if dfsi mode selected) + if modem_mode == 'dfsi': + console.print("\n[bold]Step 3b: DFSI Settings[/bold]\n") + + if Confirm.ask("Configure DFSI settings?", default=False): + rtrt = Confirm.ask( + "Enable DFSI RT/RT?", + default=True + ) + self.config.set('system.modem.dfsiRtrt', rtrt) + + jitter = IntPrompt.ask( + "DFSI Jitter (ms)", + default=200 + ) + self.config.set('system.modem.dfsiJitter', jitter) + + call_timeout = IntPrompt.ask( + "DFSI Call Timeout (seconds)", + default=200 + ) + self.config.set('system.modem.dfsiCallTimeout', call_timeout) + + full_duplex = Confirm.ask( + "Enable DFSI Full Duplex?", + default=False + ) + self.config.set('system.modem.dfsiFullDuplex', full_duplex) + + console.print("\n[bold]Step 4: Network Settings[/bold]\n") + + # FNE Settings + fne_address = Prompt.ask( + "FNE address", + default="127.0.0.1" + ) + while not validator.validate_ip_address(fne_address): + console.print("[red]Invalid IP address[/red]") + fne_address = Prompt.ask("FNE address", default="127.0.0.1") + self.config.set('network.address', fne_address) + + fne_port = IntPrompt.ask( + "FNE port", + default=62031 + ) + self.config.set('network.port', fne_port) + + fne_password = Prompt.ask( + "FNE password", + default="PASSWORD", + password=True + ) + self.config.set('network.password', fne_password) + + # RPC Configuration + console.print("\n[bold]Step 4a: RPC & REST API Configuration[/bold]\n") + + # Generate random RPC password + rpc_password = generate_random_password() + console.print(f"[cyan]Generated RPC password:[/cyan] {rpc_password}") + self.config.set('network.rpcPassword', rpc_password) + + # Store for display in summary + self.rpc_password = rpc_password + + # Ask about REST API + if Confirm.ask("Enable REST API?", default=False): + self.config.set('network.restEnable', True) + rest_password = generate_random_password() + console.print(f"[cyan]Generated REST API password:[/cyan] {rest_password}") + self.config.set('network.restPassword', rest_password) + self.rest_password = rest_password + else: + self.config.set('network.restEnable', False) + self.rest_password = None + + # Network Lookup and Transfer Settings + console.print("\n[bold]Step 4b: Network Lookup & Transfer Settings[/bold]\n") + + update_lookups = Confirm.ask("Update lookups from network?", default=True) + self.config.set('network.updateLookups', update_lookups) + if update_lookups: + self.config.set('system.radio_id.time', 0) + self.config.set('system.talkgroup_id.time', 0) + + save_lookups = Confirm.ask("Save lookups from network?", default=False) + self.config.set('network.saveLookups', save_lookups) + + allow_activity = Confirm.ask("Allow activity transfer to FNE?", default=True) + self.config.set('network.allowActivityTransfer', allow_activity) + + allow_diagnostic = Confirm.ask("Allow diagnostic transfer to FNE?", default=False) + self.config.set('network.allowDiagnosticTransfer', allow_diagnostic) + + allow_status = Confirm.ask("Allow status transfer to FNE?", default=True) + self.config.set('network.allowStatusTransfer', allow_status) + + # Radio ID and Talkgroup ID Configuration + console.print("\n[bold]Step 4c: Radio ID & Talkgroup ID Configuration[/bold]\n") + + if Confirm.ask("Configure Radio ID and Talkgroup ID settings?", default=False): + # Radio ID Configuration + console.print("\n[dim]Radio ID Settings:[/dim]") + radio_id_file = Prompt.ask( + "Radio ID ACL file (path, or leave empty to skip)", + default="" + ) + if radio_id_file: + self.config.set('system.radio_id.file', radio_id_file) + + radio_id_time = IntPrompt.ask( + "Radio ID update time (seconds, 0 = disabled)", + default=0 + ) + self.config.set('system.radio_id.time', radio_id_time) + + radio_id_acl = Confirm.ask( + "Enforce Radio ID ACLs?", + default=False + ) + self.config.set('system.radio_id.acl', radio_id_acl) + + # Talkgroup ID Configuration + console.print("\n[dim]Talkgroup ID Settings:[/dim]") + talkgroup_id_file = Prompt.ask( + "Talkgroup ID ACL file (path, or leave empty to skip)", + default="" + ) + if talkgroup_id_file: + self.config.set('system.talkgroup_id.file', talkgroup_id_file) + + talkgroup_id_time = IntPrompt.ask( + "Talkgroup ID update time (seconds, 0 = disabled)", + default=0 + ) + self.config.set('system.talkgroup_id.time', talkgroup_id_time) + + talkgroup_id_acl = Confirm.ask( + "Enforce Talkgroup ID ACLs?", + default=False + ) + self.config.set('system.talkgroup_id.acl', talkgroup_id_acl) + + # Protocol Selection + console.print("\n[bold]Step 5: Protocol Selection[/bold]\n") + + console.print("[yellow]⚠ WARNING:[/yellow] Hotspots only support a [bold]single[/bold] digital mode.") + console.print("[yellow] Multi-mode is only supported on repeaters.\n[/yellow]") + + console.print("Select primary digital mode:") + console.print(" 1. P25") + console.print(" 2. DMR") + console.print(" 3. NXDN\n") + + primary_mode = IntPrompt.ask( + "Primary mode", + choices=['1', '2', '3'], + default='1' + ) + + # Set primary mode + enable_p25 = (int(primary_mode) == 1) + enable_dmr = (int(primary_mode) == 2) + enable_nxdn = (int(primary_mode) == 3) + + self.config.set('protocols.p25.enable', enable_p25) + self.config.set('protocols.dmr.enable', enable_dmr) + self.config.set('protocols.nxdn.enable', enable_nxdn) + + # Ask about additional modes (for repeaters) + console.print("\n[dim]Additional modes (for multi-mode repeaters only):[/dim]") + + if not enable_p25: + if Confirm.ask("Also enable P25?", default=False): + enable_p25 = True + self.config.set('protocols.p25.enable', True) + + if not enable_dmr: + if Confirm.ask("Also enable DMR?", default=False): + enable_dmr = True + self.config.set('protocols.dmr.enable', True) + + if not enable_nxdn: + if Confirm.ask("Also enable NXDN?", default=False): + enable_nxdn = True + self.config.set('protocols.nxdn.enable', True) + + # Radio Parameters + console.print("\n[bold]Step 6: Network/System ID Configuration[/bold]\n") + + # DMR Configuration + if enable_dmr: + console.print("[cyan]DMR Configuration:[/cyan]") + + # DMR Site Model (affects netId and siteId ranges) + console.print("\nDMR Site Model (affects Network ID and Site ID ranges):") + console.print(" 1. SMALL - Most common (NetID: 1-127, SiteID: 1-31)") + console.print(" 2. TINY - Large NetID range (NetID: 1-511, SiteID: 1-7)") + console.print(" 3. LARGE - Large SiteID range (NetID: 1-31, SiteID: 1-127)") + console.print(" 4. HUGE - Very large SiteID (NetID: 1-3, SiteID: 1-1023)") + site_model_choice = IntPrompt.ask("Select site model", default=1) + site_model_map = {1: 'small', 2: 'tiny', 3: 'large', 4: 'huge'} + site_model_str = site_model_map.get(site_model_choice, 'small') + site_model = get_dmr_site_model_from_string(site_model_str) + + dmr_info = get_network_id_info('dmr', site_model_str) + + # Color Code + color_code = IntPrompt.ask( + f"DMR Color Code ({dmr_info['colorCode']['min']}-{dmr_info['colorCode']['max']})", + default=dmr_info['colorCode']['default'] + ) + while True: + valid, error = validate_dmr_color_code(color_code) + if valid: + break + console.print(f"[red]{error}[/red]") + color_code = IntPrompt.ask("DMR Color Code", default=1) + self.config.set('system.config.colorCode', color_code) + + # DMR Network ID + dmr_net_id = IntPrompt.ask( + f"DMR Network ID ({dmr_info['dmrNetId']['min']}-{dmr_info['dmrNetId']['max']})", + default=dmr_info['dmrNetId']['default'] + ) + while True: + valid, error = validate_dmr_network_id(dmr_net_id, site_model) + if valid: + break + console.print(f"[red]{error}[/red]") + dmr_net_id = IntPrompt.ask("DMR Network ID", default=1) + self.config.set('system.config.dmrNetId', dmr_net_id) + + # DMR Site ID + dmr_site_id = IntPrompt.ask( + f"DMR Site ID ({dmr_info['siteId']['min']}-{dmr_info['siteId']['max']})", + default=dmr_info['siteId']['default'] + ) + while True: + valid, error = validate_dmr_site_id(dmr_site_id, site_model) + if valid: + break + console.print(f"[red]{error}[/red]") + dmr_site_id = IntPrompt.ask("DMR Site ID", default=1) + self.config.set('system.config.siteId', dmr_site_id) + + # P25 Configuration + if enable_p25: + console.print("\n[cyan]P25 Configuration:[/cyan]") + p25_info = get_network_id_info('p25') + + # NAC + nac_str = Prompt.ask( + f"P25 NAC (hex: 0x000-0x{p25_info['nac']['max']:03X}, decimal: {p25_info['nac']['min']}-{p25_info['nac']['max']})", + default=f"0x{p25_info['nac']['default']:03X}" + ) + while True: + try: + nac = int(nac_str, 0) + valid, error = validate_p25_nac(nac) + if valid: + break + console.print(f"[red]{error}[/red]") + nac_str = Prompt.ask("P25 NAC", default="0x293") + except ValueError: + console.print("[red]Invalid format. Use hex (0x293) or decimal (659)[/red]") + nac_str = Prompt.ask("P25 NAC", default="0x293") + self.config.set('system.config.nac', nac) + + # Network ID (WACN) + net_id_str = Prompt.ask( + f"P25 Network ID/WACN (hex: 0x1-0x{p25_info['netId']['max']:X}, decimal: {p25_info['netId']['min']}-{p25_info['netId']['max']})", + default=f"0x{p25_info['netId']['default']:X}" + ) + while True: + try: + net_id = int(net_id_str, 0) + valid, error = validate_p25_network_id(net_id) + if valid: + break + console.print(f"[red]{error}[/red]") + net_id_str = Prompt.ask("P25 Network ID", default="0xBB800") + except ValueError: + console.print("[red]Invalid format. Use hex (0xBB800) or decimal (768000)[/red]") + net_id_str = Prompt.ask("P25 Network ID", default="0xBB800") + self.config.set('system.config.netId', net_id) + + # System ID + sys_id_str = Prompt.ask( + f"P25 System ID (hex: 0x1-0x{p25_info['sysId']['max']:X}, decimal: {p25_info['sysId']['min']}-{p25_info['sysId']['max']})", + default=f"0x{p25_info['sysId']['default']:03X}" + ) + while True: + try: + sys_id = int(sys_id_str, 0) + valid, error = validate_p25_system_id(sys_id) + if valid: + break + console.print(f"[red]{error}[/red]") + sys_id_str = Prompt.ask("P25 System ID", default="0x001") + except ValueError: + console.print("[red]Invalid format. Use hex (0x001) or decimal (1)[/red]") + sys_id_str = Prompt.ask("P25 System ID", default="0x001") + self.config.set('system.config.sysId', sys_id) + + # RFSS ID + rfss_id = IntPrompt.ask( + f"P25 RFSS ID ({p25_info['rfssId']['min']}-{p25_info['rfssId']['max']})", + default=p25_info['rfssId']['default'] + ) + while True: + valid, error = validate_p25_rfss_id(rfss_id) + if valid: + break + console.print(f"[red]{error}[/red]") + rfss_id = IntPrompt.ask("P25 RFSS ID", default=1) + self.config.set('system.config.rfssId', rfss_id) + + # P25 Site ID + p25_site_id = IntPrompt.ask( + f"P25 Site ID ({p25_info['siteId']['min']}-{p25_info['siteId']['max']})", + default=p25_info['siteId']['default'] + ) + while True: + valid, error = validate_p25_site_id(p25_site_id) + if valid: + break + console.print(f"[red]{error}[/red]") + p25_site_id = IntPrompt.ask("P25 Site ID", default=1) + # Note: siteId is shared, only set if DMR didn't set it + if not enable_dmr: + self.config.set('system.config.siteId', p25_site_id) + + # NXDN Configuration + if enable_nxdn: + console.print("\n[cyan]NXDN Configuration:[/cyan]") + nxdn_info = get_network_id_info('nxdn') + + # RAN + ran = IntPrompt.ask( + f"NXDN RAN ({nxdn_info['ran']['min']}-{nxdn_info['ran']['max']})", + default=nxdn_info['ran']['default'] + ) + while True: + valid, error = validate_nxdn_ran(ran) + if valid: + break + console.print(f"[red]{error}[/red]") + ran = IntPrompt.ask("NXDN RAN", default=1) + self.config.set('system.config.ran', ran) + + # System ID (Location ID) + nxdn_sys_id_str = Prompt.ask( + f"NXDN System ID (hex: 0x1-0x{nxdn_info['sysId']['max']:X}, decimal: {nxdn_info['sysId']['min']}-{nxdn_info['sysId']['max']})", + default=f"0x{nxdn_info['sysId']['default']:03X}" + ) + while True: + try: + nxdn_sys_id = int(nxdn_sys_id_str, 0) + valid, error = validate_nxdn_location_id(nxdn_sys_id) + if valid: + break + console.print(f"[red]{error}[/red]") + nxdn_sys_id_str = Prompt.ask("NXDN System ID", default="0x001") + except ValueError: + console.print("[red]Invalid format. Use hex (0x001) or decimal (1)[/red]") + nxdn_sys_id_str = Prompt.ask("NXDN System ID", default="0x001") + # Note: sysId is shared with P25, only set if P25 didn't set it + if not enable_p25: + self.config.set('system.config.sysId', nxdn_sys_id) + + # NXDN Site ID + nxdn_site_id_str = Prompt.ask( + f"NXDN Site ID (hex: 0x1-0x{nxdn_info['siteId']['max']:X}, decimal: {nxdn_info['siteId']['min']}-{nxdn_info['siteId']['max']})", + default=str(nxdn_info['siteId']['default']) + ) + while True: + try: + nxdn_site_id = int(nxdn_site_id_str, 0) + valid, error = validate_nxdn_location_id(nxdn_site_id) + if valid: + break + console.print(f"[red]{error}[/red]") + nxdn_site_id_str = Prompt.ask("NXDN Site ID", default="1") + except ValueError: + console.print("[red]Invalid format. Use hex or decimal[/red]") + nxdn_site_id_str = Prompt.ask("NXDN Site ID", default="1") + # Note: siteId is shared, only set if neither DMR nor P25 set it + if not enable_dmr and not enable_p25: + self.config.set('system.config.siteId', nxdn_site_id) + + # If no protocols enabled DMR/P25/NXDN individually, still prompt for generic site ID + if not enable_dmr and not enable_p25 and not enable_nxdn: + site_id = IntPrompt.ask("Site ID", default=1) + self.config.set('system.config.siteId', site_id) + + # Frequency Configuration + console.print("\n[bold]Step 7: Frequency Configuration[/bold]\n") + + # Always configure frequency for conventional systems + self._configure_frequency() + + # Location (optional) + console.print("\n[bold]Step 8: Location Information (optional)[/bold]\n") + + if Confirm.ask("Configure location information?", default=False): + try: + latitude = float(Prompt.ask("Latitude", default="0.0")) + self.config.set('system.info.latitude', latitude) + + longitude = float(Prompt.ask("Longitude", default="0.0")) + self.config.set('system.info.longitude', longitude) + + location = Prompt.ask("Location description", default="") + if location: + self.config.set('system.info.location', location) + + power = IntPrompt.ask("TX power (watts)", default=10) + self.config.set('system.info.power', power) + except ValueError: + console.print("[yellow]Invalid location data, skipping[/yellow]") + + # Summary and Save + return self._show_summary_and_save() + + def _configure_frequency(self): + """Configure frequency and channel assignment""" + # Show available bands + console.print("[cyan]Available Frequency Bands:[/cyan]\n") + + table = Table(show_header=True, header_style="bold cyan") + table.add_column("#", style="dim", width=3) + table.add_column("Band", style="cyan") + table.add_column("TX Range", style="yellow") + table.add_column("Offset", style="green") + + band_list = list(BAND_PRESETS.items()) + for i, (key, band) in enumerate(band_list, 1): + tx_min, tx_max = band['tx_range'] + offset = band['input_offset'] + table.add_row( + str(i), + band['name'], + f"{tx_min:.1f}-{tx_max:.1f} MHz", + f"{offset:+.1f} MHz" + ) + + console.print(table) + console.print() + + # Select band + band_choice = IntPrompt.ask( + "Select frequency band", + choices=[str(i) for i in range(1, len(band_list) + 1)], + default="3" + ) + + band_index = int(band_choice) - 1 + preset_key = band_list[band_index][0] + preset = BAND_PRESETS[preset_key] + + # Confirm 800MHz selection + if preset_key == '800mhz': + if not Confirm.ask(f"\n[yellow]Are you sure {preset['name']} is the frequency band you want?[/yellow]", default=False): + return self._configure_frequency() + + # Use band index as channel ID (0-15 range), except 900MHz is always channel ID 15 + channel_id = 15 if preset_key == '900mhz' else band_index + + # Get TX frequency + console.print(f"\n[cyan]Selected: {preset['name']}[/cyan]") + console.print(f"TX Range: {preset['tx_range'][0]:.1f}-{preset['tx_range'][1]:.1f} MHz") + console.print(f"Input Offset: {preset['input_offset']:+.1f} MHz\n") + + tx_freq_mhz = None + while tx_freq_mhz is None: + try: + tx_input = Prompt.ask( + "Transmit frequency (MHz)", + default=f"{preset['tx_range'][0]:.4f}" + ) + tx_freq_mhz = float(tx_input) + + # Validate frequency is in range + if not (preset['tx_range'][0] <= tx_freq_mhz <= preset['tx_range'][1]): + console.print(f"[red]Frequency must be between {preset['tx_range'][0]:.1f} and " + f"{preset['tx_range'][1]:.1f} MHz[/red]") + tx_freq_mhz = None + continue + + # Calculate channel assignment + channel_id_result, channel_no, tx_hz, rx_hz = calculate_channel_assignment( + tx_freq_mhz, preset_key, channel_id + ) + + # Show calculated values + console.print(f"\n[green]✓ Channel Assignment Calculated:[/green]") + console.print(f" Channel ID: {channel_id_result}") + console.print(f" Channel Number: {channel_no} (0x{channel_no:03X})") + console.print(f" TX Frequency: {tx_hz/1000000:.6f} MHz") + console.print(f" RX Frequency: {rx_hz/1000000:.6f} MHz") + + # Apply to configuration + self.config.set('system.config.channelId', channel_id_result) + self.config.set('system.config.channelNo', channel_no) + self.config.set('system.config.txFrequency', tx_hz) + self.config.set('system.config.rxFrequency', rx_hz) + + # Ensure the band is in the IDEN table + if channel_id_result not in self.iden_table.entries: + entry = create_iden_entry_from_preset(channel_id_result, preset_key) + self.iden_table.add_entry(entry) + + except ValueError as e: + console.print(f"[red]Error: {e}[/red]") + tx_freq_mhz = None + + def _show_summary_and_save(self) -> Optional[Path]: + """Show configuration summary, allow corrections, and save""" + while True: + console.print("\n[bold cyan]Configuration Summary[/bold cyan]\n") + + summary = self.config.get_summary() + + table = Table(show_header=False) + table.add_column("Parameter", style="cyan") + table.add_column("Value", style="yellow") + + table.add_row("Template", self.template_name) + table.add_row("Identity", summary['identity']) + table.add_row("Peer ID", str(summary['peer_id'])) + table.add_row("FNE", f"{summary['fne_address']}:{summary['fne_port']}") + + protocols = [] + if summary['protocols']['dmr']: + protocols.append(f"DMR (CC:{summary['color_code']})") + if summary['protocols']['p25']: + protocols.append(f"P25 (NAC:0x{summary['nac']:03X})") + if summary['protocols']['nxdn']: + protocols.append("NXDN") + table.add_row("Protocols", ", ".join(protocols)) + + table.add_row("Modem Type", summary['modem_type']) + table.add_row("Modem Mode", summary['modem_mode'].upper()) + if summary['modem_port'] != 'N/A': + table.add_row("Modem Port", summary['modem_port']) + + table.add_row("RPC Password", summary['rpc_password']) + if summary['rest_enabled']: + table.add_row("REST API", "Enabled") + table.add_row("REST API Password", summary['rest_password']) + else: + table.add_row("REST API", "Disabled") + + # Add frequency information if configured + if summary['channel_id'] >= 0: + table.add_row("Channel ID", str(summary['channel_id'])) + table.add_row("Channel Number", f"0x{summary['channel_no']:03X}") + if summary['tx_frequency'] > 0: + tx_mhz = summary['tx_frequency'] / 1000000 + rx_mhz = summary['rx_frequency'] / 1000000 + table.add_row("TX Frequency", f"{tx_mhz:.6f} MHz") + table.add_row("RX Frequency", f"{rx_mhz:.6f} MHz") + + console.print(table) + console.print() + + # Validate + errors = self.config.validate() + if errors: + console.print("[yellow]Configuration has warnings:[/yellow]") + for error in errors: + console.print(f" [yellow]•[/yellow] {error}") + console.print() + + if not Confirm.ask("Continue anyway?", default=True): + return None + else: + console.print("[green]✓ Configuration is valid[/green]\n") + + # Review and correction + console.print("[bold]Review Your Configuration[/bold]\n") + choice = Prompt.ask( + "Is this configuration correct?", + choices=["yes", "no"], + default="yes" + ) + + if choice.lower() == "no": + console.print("\n[yellow]Configuration returned for editing.[/yellow]") + console.print("[dim]Please restart the wizard or manually edit the configuration files.[/dim]\n") + return None + + console.print() + + default_filename = f"{summary['identity'].lower()}.yml" + default_path = str(Path(self.config_dir) / default_filename) + output_path = Prompt.ask( + "Output file name", + default=default_path + ) + + output_file = Path(str(Path(self.config_dir) / Path(output_path))) + + # Check if file exists + if output_file.exists(): + if not Confirm.ask(f"[yellow]{output_file} already exists. Overwrite?[/yellow]", default=False): + output_path = Prompt.ask("Output file path") + output_file = Path(output_path) + + try: + self.config.save(output_file) + + console.print(f"\n[green]✓ Conventional system created successfully![/green]") + console.print(f"\nConfiguration files saved in: [cyan]{self.config_dir}[/cyan]") + console.print(f" • Conventional Channel: [yellow]{output_file}[/yellow]") + + # Save IDEN table if configured + if len(self.iden_table) > 0: + iden_file = Path(self.config_dir) / "iden_table.dat" + self.iden_table.save(iden_file) + console.print(f" • Identity Table: [yellow]iden_table.dat[/yellow]") + + return output_file + except Exception as e: + console.print(f"[red]Error saving configuration:[/red] {e}") + return None + + def _run_trunking_wizard(self) -> Optional[Path]: + """Run trunking system wizard""" + wizard = TrunkingWizard() + return wizard.run() + + +class TrunkingWizard: + """Interactive trunking system wizard""" + + def __init__(self): + self.iden_table: IdenTable = create_default_iden_table() + + def run(self) -> Optional[Path]: + """Run the trunking wizard""" + # Don't clear or show banner here - run_wizard handles it + console.print() + console.print(Panel.fit( + "[bold cyan]Trunked System Configuration Wizard[/bold cyan]\n" + "Create a trunked system configuration with control and voice channels", + border_style="cyan" + )) + console.print() + + # System basics + console.print("[bold]Step 1: System Configuration[/bold]\n") + + system_name = Prompt.ask( + "System name (for filenames)", + default="trunked" + ) + + base_dir = Prompt.ask( + "Configuration directory", + default="." + ) + + identity = Prompt.ask( + "System identity prefix", + default="SITE001" + ) + + protocol = Prompt.ask( + "Protocol", + choices=['p25', 'dmr'], + default='p25' + ) + + vc_count = IntPrompt.ask( + "Number of voice channels", + default=2 + ) + + # Network settings + console.print("\n[bold]Step 2: Network Settings[/bold]\n") + + fne_address = Prompt.ask( + "FNE address", + default="127.0.0.1" + ) + + fne_port = IntPrompt.ask( + "FNE port", + default=62031 + ) + + fne_password = Prompt.ask( + "FNE password", + default="PASSWORD", + password=True + ) + + base_peer_id = IntPrompt.ask( + "Base peer ID (CC will use this, VCs increment)", + default=100000 + ) + + base_rpc_port = IntPrompt.ask( + "Base RPC port (CC will use this, VCs increment)", + default=9890 + ) + + # Network Lookup and Transfer Settings + console.print("\n[bold]Step 2a: Network Lookup & Transfer Settings[/bold]\n") + + update_lookups = Confirm.ask("Update lookups from network?", default=True) + save_lookups = Confirm.ask("Save lookups from network?", default=False) + allow_activity = Confirm.ask("Allow activity transfer to FNE?", default=True) + allow_diagnostic = Confirm.ask("Allow diagnostic transfer to FNE?", default=False) + allow_status = Confirm.ask("Allow status transfer to FNE?", default=True) + + # Radio ID and Talkgroup ID Configuration + console.print("\n[bold]Step 2b: Radio ID & Talkgroup ID Configuration[/bold]\n") + + radio_id_file = None + radio_id_time = 0 + radio_id_acl = False + talkgroup_id_file = None + talkgroup_id_time = 0 + talkgroup_id_acl = False + + if Confirm.ask("Configure Radio ID and Talkgroup ID settings?", default=False): + # Radio ID Configuration + console.print("\n[dim]Radio ID Settings:[/dim]") + radio_id_file = Prompt.ask( + "Radio ID ACL file (path, or leave empty to skip)", + default="" + ) + if not radio_id_file: + radio_id_file = None + + radio_id_time = IntPrompt.ask( + "Radio ID update time (seconds, 0 = disabled)", + default=0 + ) + + radio_id_acl = Confirm.ask( + "Enforce Radio ID ACLs?", + default=False + ) + + # Talkgroup ID Configuration + console.print("\n[dim]Talkgroup ID Settings:[/dim]") + talkgroup_id_file = Prompt.ask( + "Talkgroup ID ACL file (path, or leave empty to skip)", + default="" + ) + if not talkgroup_id_file: + talkgroup_id_file = None + + talkgroup_id_time = IntPrompt.ask( + "Talkgroup ID update time (seconds, 0 = disabled)", + default=0 + ) + + talkgroup_id_acl = Confirm.ask( + "Enforce Talkgroup ID ACLs?", + default=False + ) + + # RPC & REST API Configuration + console.print("\n[bold]Step 2c: RPC & REST API Configuration[/bold]\n") + + # Generate random RPC password + rpc_password = generate_random_password() + console.print(f"[cyan]Generated RPC password:[/cyan] {rpc_password}") + + base_rest_port = IntPrompt.ask( + "Base REST API port (CC will use this, VCs increment)", + default=8080 + ) + + # Ask about REST API + rest_enabled = Confirm.ask("Enable REST API?", default=False) + if rest_enabled: + rest_password = generate_random_password() + console.print(f"[cyan]Generated REST API password:[/cyan] {rest_password}") + else: + rest_password = None + + # Network/System ID Configuration + console.print("\n[bold]Step 3: Network/System ID Configuration[/bold]\n") + console.print(f"[cyan]Protocol: {protocol.upper()}[/cyan]\n") + + # Initialize variables + color_code = 1 + dmr_net_id = 1 + nac = 0x293 + net_id = 0xBB800 + sys_id = 0x001 + rfss_id = 1 + site_id = 1 + ran = 1 + + if protocol == 'p25': + # P25-specific configuration + p25_info = get_network_id_info('p25') + + # NAC + nac_str = Prompt.ask( + f"P25 NAC (hex: 0x000-0x{p25_info['nac']['max']:03X}, decimal: {p25_info['nac']['min']}-{p25_info['nac']['max']})", + default=f"0x{p25_info['nac']['default']:03X}" + ) + while True: + try: + nac = int(nac_str, 0) + valid, error = validate_p25_nac(nac) + if valid: + break + console.print(f"[red]{error}[/red]") + nac_str = Prompt.ask("P25 NAC", default="0x293") + except ValueError: + console.print("[red]Invalid format. Use hex (0x293) or decimal (659)[/red]") + nac_str = Prompt.ask("P25 NAC", default="0x293") + + # Network ID (WACN) + net_id_str = Prompt.ask( + f"P25 Network ID/WACN (hex: 0x1-0x{p25_info['netId']['max']:X}, decimal: {p25_info['netId']['min']}-{p25_info['netId']['max']})", + default=f"0x{p25_info['netId']['default']:X}" + ) + while True: + try: + net_id = int(net_id_str, 0) + valid, error = validate_p25_network_id(net_id) + if valid: + break + console.print(f"[red]{error}[/red]") + net_id_str = Prompt.ask("P25 Network ID", default="0xBB800") + except ValueError: + console.print("[red]Invalid format. Use hex (0xBB800) or decimal (768000)[/red]") + net_id_str = Prompt.ask("P25 Network ID", default="0xBB800") + + # System ID + sys_id_str = Prompt.ask( + f"P25 System ID (hex: 0x1-0x{p25_info['sysId']['max']:X}, decimal: {p25_info['sysId']['min']}-{p25_info['sysId']['max']})", + default=f"0x{p25_info['sysId']['default']:03X}" + ) + while True: + try: + sys_id = int(sys_id_str, 0) + valid, error = validate_p25_system_id(sys_id) + if valid: + break + console.print(f"[red]{error}[/red]") + sys_id_str = Prompt.ask("P25 System ID", default="0x001") + except ValueError: + console.print("[red]Invalid format. Use hex (0x001) or decimal (1)[/red]") + sys_id_str = Prompt.ask("P25 System ID", default="0x001") + + # RFSS ID + rfss_id = IntPrompt.ask( + f"P25 RFSS ID ({p25_info['rfssId']['min']}-{p25_info['rfssId']['max']})", + default=p25_info['rfssId']['default'] + ) + while True: + valid, error = validate_p25_rfss_id(rfss_id) + if valid: + break + console.print(f"[red]{error}[/red]") + rfss_id = IntPrompt.ask("P25 RFSS ID", default=1) + + # Site ID + site_id = IntPrompt.ask( + f"P25 Site ID ({p25_info['siteId']['min']}-{p25_info['siteId']['max']})", + default=p25_info['siteId']['default'] + ) + while True: + valid, error = validate_p25_site_id(site_id) + if valid: + break + console.print(f"[red]{error}[/red]") + site_id = IntPrompt.ask("P25 Site ID", default=1) + + elif protocol == 'dmr': + # DMR-specific configuration + console.print("DMR Site Model (affects Network ID and Site ID ranges):") + console.print(" 1. SMALL - Most common (NetID: 1-127, SiteID: 1-31)") + console.print(" 2. TINY - Large NetID range (NetID: 1-511, SiteID: 1-7)") + console.print(" 3. LARGE - Large SiteID range (NetID: 1-31, SiteID: 1-127)") + console.print(" 4. HUGE - Very large SiteID (NetID: 1-3, SiteID: 1-1023)") + site_model_choice = IntPrompt.ask("Select site model", default=1) + site_model_map = {1: 'small', 2: 'tiny', 3: 'large', 4: 'huge'} + site_model_str = site_model_map.get(site_model_choice, 'small') + site_model = get_dmr_site_model_from_string(site_model_str) + + dmr_info = get_network_id_info('dmr', site_model_str) + + # Color Code + color_code = IntPrompt.ask( + f"DMR Color Code ({dmr_info['colorCode']['min']}-{dmr_info['colorCode']['max']})", + default=dmr_info['colorCode']['default'] + ) + while True: + valid, error = validate_dmr_color_code(color_code) + if valid: + break + console.print(f"[red]{error}[/red]") + color_code = IntPrompt.ask("DMR Color Code", default=1) + + # DMR Network ID + dmr_net_id = IntPrompt.ask( + f"DMR Network ID ({dmr_info['dmrNetId']['min']}-{dmr_info['dmrNetId']['max']})", + default=dmr_info['dmrNetId']['default'] + ) + while True: + valid, error = validate_dmr_network_id(dmr_net_id, site_model) + if valid: + break + console.print(f"[red]{error}[/red]") + dmr_net_id = IntPrompt.ask("DMR Network ID", default=1) + + # DMR Site ID + site_id = IntPrompt.ask( + f"DMR Site ID ({dmr_info['siteId']['min']}-{dmr_info['siteId']['max']})", + default=dmr_info['siteId']['default'] + ) + while True: + valid, error = validate_dmr_site_id(site_id, site_model) + if valid: + break + console.print(f"[red]{error}[/red]") + site_id = IntPrompt.ask("DMR Site ID", default=1) + + console.print(f"\n[dim]These values will be applied consistently across all channels (CC and VCs)[/dim]") + + # Frequency configuration for control channel + console.print("\n[bold]Step 4: Control Channel Frequency and Modem[/bold]\n") + + cc_channel_id, cc_channel_no, cc_band, cc_tx_hz, cc_rx_hz = self._configure_channel_frequency("Control Channel") + cc_modem_config = self._configure_channel_modem("Control Channel") + + # Voice channel frequency and modem configuration + console.print("\n[bold]Step 5: Voice Channel Frequencies and Modems[/bold]\n") + + vc_channels = [] + for i in range(1, vc_count + 1): + console.print(f"\n[cyan]Voice Channel {i}:[/cyan]") + vc_channel_id, vc_channel_no, _, vc_tx_hz, vc_rx_hz = self._configure_channel_frequency(f"VC{i}", cc_band) + vc_modem_config = self._configure_channel_modem(f"VC{i}") + vc_channels.append({ + 'channel_id': vc_channel_id, + 'channel_no': vc_channel_no, + 'tx_hz': vc_tx_hz, + 'rx_hz': vc_rx_hz, + 'modem_type': vc_modem_config['modem_type'], + 'modem_mode': vc_modem_config['modem_mode'], + 'modem_port': vc_modem_config.get('modem_port', 'N/A'), + 'dfsi_rtrt': vc_modem_config.get('dfsi_rtrt'), + 'dfsi_jitter': vc_modem_config.get('dfsi_jitter'), + 'dfsi_call_timeout': vc_modem_config.get('dfsi_call_timeout'), + 'dfsi_full_duplex': vc_modem_config.get('dfsi_full_duplex') + }) + console.print() + + # Create system + console.print("\n[bold cyan]Creating trunked system...[/bold cyan]\n") + + try: + system = TrunkingSystem(Path(base_dir), system_name) + + # Show configuration summary + console.print("\n[bold cyan]Trunked System Configuration Summary[/bold cyan]\n") + + table = Table(show_header=False) + table.add_column("Parameter", style="cyan") + table.add_column("Value", style="yellow") + + table.add_row("System Name", system_name) + table.add_row("Base Directory", base_dir) + table.add_row("Protocol", protocol.upper()) + table.add_row("Voice Channels", str(vc_count)) + table.add_row("FNE Address", fne_address) + table.add_row("FNE Port", str(fne_port)) + table.add_row("Base Peer ID", str(base_peer_id)) + table.add_row("Base RPC Port", str(base_rpc_port)) + table.add_row("RPC Password", rpc_password) + if rest_enabled: + table.add_row("REST API", "Enabled") + table.add_row("Base REST Port", str(base_rest_port)) + table.add_row("REST API Password", rest_password) + else: + table.add_row("REST API", "Disabled") + + if protocol == 'dmr': + table.add_row("DMR Color Code", str(color_code)) + table.add_row("DMR Network ID", str(dmr_net_id)) + elif protocol == 'p25': + table.add_row("P25 NAC", f"0x{nac:03X}") + table.add_row("P25 Network ID", str(net_id)) + table.add_row("P25 System ID", str(sys_id)) + + console.print(table) + console.print() + + # Show channel frequency details + console.print("[bold cyan]Channel Configuration[/bold cyan]\n") + + # Control Channel + cc_tx_mhz = cc_tx_hz / 1000000 + cc_rx_mhz = cc_rx_hz / 1000000 + console.print("[bold]Control Channel (CC):[/bold]") + console.print(f" Channel ID: {cc_channel_id}") + console.print(f" Channel Number: 0x{cc_channel_no:03X}") + console.print(f" TX Frequency: {cc_tx_mhz:.6f} MHz") + console.print(f" RX Frequency: {cc_rx_mhz:.6f} MHz") + console.print(f" Modem Type: {cc_modem_config['modem_type']}") + console.print(f" Modem Mode: {cc_modem_config['modem_mode'].upper()}") + if cc_modem_config.get('modem_port'): + console.print(f" Modem Port: {cc_modem_config['modem_port']}") + console.print() + + # Voice Channels + console.print("[bold]Voice Channels:[/bold]") + for i, vc in enumerate(vc_channels, 1): + vc_tx_mhz = vc['tx_hz'] / 1000000 + vc_rx_mhz = vc['rx_hz'] / 1000000 + console.print(f" VC{i}:") + console.print(f" Channel ID: {vc['channel_id']}") + console.print(f" Channel Number: 0x{vc['channel_no']:03X}") + console.print(f" TX Frequency: {vc_tx_mhz:.6f} MHz") + console.print(f" RX Frequency: {vc_rx_mhz:.6f} MHz") + console.print(f" Modem Type: {vc['modem_type']}") + console.print(f" Modem Mode: {vc['modem_mode'].upper()}") + if vc.get('modem_port'): + console.print(f" Modem Port: {vc['modem_port']}") + + # Review and correction + console.print("[bold]Review Your Configuration[/bold]\n") + choice = Prompt.ask( + "Is this configuration correct?", + choices=["yes", "no"], + default="yes" + ) + + if choice.lower() == "no": + console.print("\n[yellow]Configuration creation cancelled.[/yellow]") + console.print("[dim]Please restart the wizard or manually edit the configuration files: [cyan]" + base_dir + "[/cyan][/dim]\n") + return Path(base_dir) + + console.print() + + # Prepare kwargs based on protocol + create_kwargs = { + 'protocol': protocol, + 'vc_count': vc_count, + 'fne_address': fne_address, + 'fne_port': fne_port, + 'fne_password': fne_password, + 'base_peer_id': base_peer_id, + 'base_rpc_port': base_rpc_port, + 'rpc_password': rpc_password, + 'base_rest_port': base_rest_port, + 'rest_password': rest_password, + 'update_lookups': update_lookups, + 'save_lookups': save_lookups, + 'allow_activity_transfer': allow_activity, + 'allow_diagnostic_transfer': allow_diagnostic, + 'allow_status_transfer': allow_status, + 'radio_id_file': radio_id_file, + 'radio_id_time': radio_id_time, + 'radio_id_acl': radio_id_acl, + 'talkgroup_id_file': talkgroup_id_file, + 'talkgroup_id_time': talkgroup_id_time, + 'talkgroup_id_acl': talkgroup_id_acl, + 'system_identity': identity, + 'modem_type': cc_modem_config['modem_type'], + 'cc_dfsi_rtrt': cc_modem_config.get('dfsi_rtrt'), + 'cc_dfsi_jitter': cc_modem_config.get('dfsi_jitter'), + 'cc_dfsi_call_timeout': cc_modem_config.get('dfsi_call_timeout'), + 'cc_dfsi_full_duplex': cc_modem_config.get('dfsi_full_duplex'), + 'cc_channel_id': cc_channel_id, + 'cc_channel_no': cc_channel_no, + 'vc_channels': vc_channels + } + + # Add protocol-specific network/system IDs + if protocol == 'p25': + create_kwargs.update({ + 'nac': nac, + 'net_id': net_id, + 'sys_id': sys_id, + 'rfss_id': rfss_id, + 'site_id': site_id + }) + elif protocol == 'dmr': + create_kwargs.update({ + 'color_code': color_code, + 'dmr_net_id': dmr_net_id, + 'site_id': site_id + }) + + system.create_system(**create_kwargs) + + # Save IDEN table + if len(self.iden_table) > 0: + iden_file = Path(base_dir) / "iden_table.dat" + self.iden_table.save(iden_file) + console.print(f"[green]✓[/green] Identity table saved: {iden_file}") + console.print(f"[dim] ({len(self.iden_table)} channel identity entries)[/dim]\n") + + console.print(f"\n[green]✓ Trunked system created successfully![/green]") + console.print(f"\nConfiguration files saved in: [cyan]{base_dir}[/cyan]") + console.print(f" • Control Channel: [yellow]{system_name}-cc.yml[/yellow]") + for i in range(1, vc_count + 1): + console.print(f" • Voice Channel {i}: [yellow]{system_name}-vc{i:02d}.yml[/yellow]") + console.print(f" • Identity Table: [yellow]iden_table.dat[/yellow]") + + return Path(base_dir) + + except Exception as e: + console.print(f"[red]Error creating system:[/red] {e}") + return None + + def _configure_channel_frequency(self, channel_name: str, default_band: Optional[str] = None) -> Tuple[int, int, str, int, int]: + """ + Configure frequency for a channel + + Args: + channel_name: Name of the channel (for display) + default_band: Optional band preset key to use as default selection + + Returns: + Tuple of (channel_id, channel_number, band_preset_key, tx_hz, rx_hz) + """ + # Show available bands + console.print(f"[cyan]Configure {channel_name} Frequency:[/cyan]\n") + + table = Table(show_header=True, header_style="bold cyan") + table.add_column("#", style="dim", width=3) + table.add_column("Band", style="cyan") + table.add_column("TX Range", style="yellow") + + band_list = list(BAND_PRESETS.items()) + for i, (key, band) in enumerate(band_list, 1): + tx_min, tx_max = band['tx_range'] + table.add_row( + str(i), + band['name'], + f"{tx_min:.1f}-{tx_max:.1f} MHz" + ) + + console.print(table) + console.print() + + # Determine default band choice + default_choice = "3" + if default_band: + # Find the index of the default band + for i, (key, _) in enumerate(band_list, 1): + if key == default_band: + default_choice = str(i) + break + + # Select band + band_choice = IntPrompt.ask( + "Select frequency band", + choices=[str(i) for i in range(1, len(band_list) + 1)], + default=default_choice + ) + + band_index = int(band_choice) - 1 + preset_key = band_list[band_index][0] + preset = BAND_PRESETS[preset_key] + + # Confirm 800MHz selection + if preset_key == '800mhz': + if not Confirm.ask(f"\n[yellow]Are you sure {preset['name']} is the frequency band you want?[/yellow]", default=False): + return self._configure_channel_frequency(channel_name, default_band) + + # Use band index as channel ID, except 900MHz is always channel ID 15 + channel_id = 15 if preset_key == '900mhz' else band_index + + # Get TX frequency + console.print(f"\n[cyan]{preset['name']}[/cyan]") + console.print(f"TX Range: {preset['tx_range'][0]:.1f}-{preset['tx_range'][1]:.1f} MHz\n") + + while True: + try: + tx_input = Prompt.ask( + f"{channel_name} TX frequency (MHz)", + default=f"{preset['tx_range'][0]:.4f}" + ) + tx_freq_mhz = float(tx_input) + + # Validate and calculate + if not (preset['tx_range'][0] <= tx_freq_mhz <= preset['tx_range'][1]): + console.print(f"[red]Frequency must be between {preset['tx_range'][0]:.1f} and " + f"{preset['tx_range'][1]:.1f} MHz[/red]") + continue + + channel_id_result, channel_no, tx_hz, rx_hz = calculate_channel_assignment( + tx_freq_mhz, preset_key, channel_id + ) + + console.print(f"[green]✓ Assigned:[/green] Ch ID {channel_id_result}, Ch# {channel_no} (0x{channel_no:03X}), " + f"RX {rx_hz/1000000:.6f} MHz\n") + + # Ensure the band is in the IDEN table + if channel_id_result not in self.iden_table.entries: + entry = create_iden_entry_from_preset(channel_id_result, preset_key) + self.iden_table.add_entry(entry) + + return (channel_id_result, channel_no, preset_key, tx_hz, rx_hz) + + except ValueError as e: + console.print(f"[red]Error: {e}[/red]") + + def _configure_channel_modem(self, channel_name: str) -> Dict[str, Any]: + """ + Configure modem for a channel + + Args: + channel_name: Name of the channel (for display) + + Returns: + Dict with modem configuration (type, mode, port, and DFSI settings if applicable) + """ + console.print(f"\n[cyan]Configure {channel_name} Modem:[/cyan]\n") + + # Modem Type + modem_type = Prompt.ask( + f"{channel_name} modem type", + choices=['uart', 'null'], + default='uart' + ) + + # Modem Mode + modem_mode = Prompt.ask( + f"{channel_name} modem mode", + choices=['air', 'dfsi'], + default='air' + ) + + # Serial port (only if UART) + modem_port = None + if modem_type == 'uart': + modem_port = Prompt.ask( + f"{channel_name} serial port", + default="/dev/ttyUSB0" + ) + + # DFSI Configuration (if dfsi mode selected) + dfsi_rtrt = None + dfsi_jitter = None + dfsi_call_timeout = None + dfsi_full_duplex = None + + if modem_mode == 'dfsi': + if Confirm.ask(f"\nConfigure {channel_name} DFSI settings?", default=False): + dfsi_rtrt = Confirm.ask( + "Enable DFSI RT/RT?", + default=True + ) + + dfsi_jitter = IntPrompt.ask( + "DFSI Jitter (ms)", + default=200 + ) + + dfsi_call_timeout = IntPrompt.ask( + "DFSI Call Timeout (seconds)", + default=200 + ) + + dfsi_full_duplex = Confirm.ask( + "Enable DFSI Full Duplex?", + default=False + ) + + return { + 'modem_type': modem_type, + 'modem_mode': modem_mode, + 'modem_port': modem_port, + 'dfsi_rtrt': dfsi_rtrt, + 'dfsi_jitter': dfsi_jitter, + 'dfsi_call_timeout': dfsi_call_timeout, + 'dfsi_full_duplex': dfsi_full_duplex + } + + +def run_wizard(wizard_type: str = 'auto') -> Optional[Path]: + """ + Run configuration wizard + + Args: + wizard_type: 'single', 'trunk', or 'auto' (asks user) + + Returns: + Path to saved configuration or None + """ + try: + if wizard_type == 'auto': + console.clear() + console.print() + + # Display banner + 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() + + console.print("[bold]Configuration Wizard[/bold]\n") + console.print("What would you like to create?\n") + console.print("1. Single instance (repeater, hotspot, etc.)") + console.print("2. Trunked system (control + voice channels)") + console.print() + + choice = IntPrompt.ask( + "Select type", + choices=['1', '2'], + default='1' + ) + + wizard_type = 'single' if int(choice) == 1 else 'trunk' + + if wizard_type == 'single': + wizard = ConfigWizard() + return wizard.run() + else: + wizard = TrunkingWizard() + return wizard.run() + + except KeyboardInterrupt: + console.print("\n\n[yellow]Wizard cancelled by user.[/yellow]") + return None + + +if __name__ == '__main__': + result = run_wizard() + if result: + console.print(f"\n[green]Done! Configuration saved to: {result}[/green]")