update gitignore to include some python stuff; add very very preliminary Python tool that helps generate dvmhost configurations;
parent
87ad34f539
commit
87f94b837a
@ -0,0 +1,326 @@
|
|||||||
|
# dvmcfggen - Examples
|
||||||
|
|
||||||
|
This file contains complete, copy-paste ready examples for common scenarios.
|
||||||
|
|
||||||
|
## Example 0: Using the Interactive Wizard (Easiest!)
|
||||||
|
|
||||||
|
The simplest way to create any configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the wizard
|
||||||
|
./dvmcfg wizard
|
||||||
|
|
||||||
|
# Follow the prompts:
|
||||||
|
# 1. Choose single instance or trunked system
|
||||||
|
# 2. Select template (hotspot, repeater, etc.)
|
||||||
|
# 3. Enter system details (identity, peer ID, etc.)
|
||||||
|
# 4. Configure network settings (FNE address, port, password)
|
||||||
|
# 5. Enable/disable protocols (DMR, P25, NXDN)
|
||||||
|
# 6. Set radio parameters (color code, NAC, site ID)
|
||||||
|
# 7. Configure modem (type, serial port, levels)
|
||||||
|
# 8. Optionally add location info
|
||||||
|
# 9. Review summary and save
|
||||||
|
```
|
||||||
|
|
||||||
|
The wizard validates all inputs and provides helpful defaults. Perfect for first-time users!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example 1: Basic Hotspot Setup (CLI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create hotspot configuration
|
||||||
|
./dvmcfg create \
|
||||||
|
--template hotspot \
|
||||||
|
--output /etc/dvm/hotspot.yml \
|
||||||
|
--identity "HOTSPOT-HOME" \
|
||||||
|
--peer-id 100001 \
|
||||||
|
--fne-address "fne.example.com" \
|
||||||
|
--fne-port 62031 \
|
||||||
|
--callsign "KC1ABC" \
|
||||||
|
--validate
|
||||||
|
|
||||||
|
# Customize serial port
|
||||||
|
./dvmcfg edit /etc/dvm/hotspot.yml \
|
||||||
|
system.modem.protocol.uart.port "/dev/ttyACM0"
|
||||||
|
|
||||||
|
# Adjust levels
|
||||||
|
./dvmcfg edit /etc/dvm/hotspot.yml system.modem.rxLevel 60
|
||||||
|
./dvmcfg edit /etc/dvm/hotspot.yml system.modem.txLevel 60
|
||||||
|
|
||||||
|
# Final validation
|
||||||
|
./dvmcfg validate /etc/dvm/hotspot.yml --summary
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example 2: Standalone Repeater
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create repeater configuration
|
||||||
|
./dvmcfg create \
|
||||||
|
--template repeater \
|
||||||
|
--output /etc/dvm/repeater.yml \
|
||||||
|
--identity "REPEATER-W1ABC" \
|
||||||
|
--peer-id 100002 \
|
||||||
|
--fne-address "10.0.0.100" \
|
||||||
|
--fne-port 62031 \
|
||||||
|
--callsign "W1ABC"
|
||||||
|
|
||||||
|
# Set location
|
||||||
|
./dvmcfg edit /etc/dvm/repeater.yml system.info.latitude 42.3601
|
||||||
|
./dvmcfg edit /etc/dvm/repeater.yml system.info.longitude -71.0589
|
||||||
|
./dvmcfg edit /etc/dvm/repeater.yml system.info.location "Boston, MA"
|
||||||
|
./dvmcfg edit /etc/dvm/repeater.yml system.info.power 50
|
||||||
|
|
||||||
|
# Configure modem
|
||||||
|
./dvmcfg edit /etc/dvm/repeater.yml \
|
||||||
|
system.modem.protocol.uart.port "/dev/ttyUSB0"
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
./dvmcfg validate /etc/dvm/repeater.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example 3: Small P25 Trunked System (2 VCs)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create trunked system
|
||||||
|
./dvmcfg trunk create \
|
||||||
|
--base-dir /etc/dvm/site1 \
|
||||||
|
--name site1 \
|
||||||
|
--protocol p25 \
|
||||||
|
--vc-count 2 \
|
||||||
|
--identity "SITE001" \
|
||||||
|
--base-peer-id 100000 \
|
||||||
|
--fne-address "10.0.0.1" \
|
||||||
|
--fne-port 62031 \
|
||||||
|
--nac 0x001 \
|
||||||
|
--site-id 1 \
|
||||||
|
--color-code 1
|
||||||
|
|
||||||
|
# Configure modem ports
|
||||||
|
./dvmcfg edit /etc/dvm/site1/site1-cc.yml \
|
||||||
|
system.modem.protocol.uart.port "/dev/ttyUSB0"
|
||||||
|
./dvmcfg edit /etc/dvm/site1/site1-vc01.yml \
|
||||||
|
system.modem.protocol.uart.port "/dev/ttyUSB1"
|
||||||
|
./dvmcfg edit /etc/dvm/site1/site1-vc02.yml \
|
||||||
|
system.modem.protocol.uart.port "/dev/ttyUSB2"
|
||||||
|
|
||||||
|
# Update location across all configs
|
||||||
|
./dvmcfg trunk update --base-dir /etc/dvm/site1 --name site1 \
|
||||||
|
system.info.latitude 42.3601
|
||||||
|
./dvmcfg trunk update --base-dir /etc/dvm/site1 --name site1 \
|
||||||
|
system.info.longitude -71.0589
|
||||||
|
./dvmcfg trunk update --base-dir /etc/dvm/site1 --name site1 \
|
||||||
|
system.info.location "Site 1, Boston"
|
||||||
|
|
||||||
|
# Validate system
|
||||||
|
./dvmcfg trunk validate --base-dir /etc/dvm/site1 --name site1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example 4: Large P25 Trunked System (6 VCs)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create large trunked system
|
||||||
|
./dvmcfg trunk create \
|
||||||
|
--base-dir /etc/dvm/hub \
|
||||||
|
--name hub \
|
||||||
|
--protocol p25 \
|
||||||
|
--vc-count 6 \
|
||||||
|
--identity "HUB" \
|
||||||
|
--base-peer-id 100000 \
|
||||||
|
--base-rpc-port 9890 \
|
||||||
|
--fne-address "172.16.0.1" \
|
||||||
|
--fne-port 62031 \
|
||||||
|
--fne-password "SecurePassword123" \
|
||||||
|
--nac 0x100 \
|
||||||
|
--site-id 10
|
||||||
|
|
||||||
|
# Enable verbose logging across all
|
||||||
|
./dvmcfg trunk update --base-dir /etc/dvm/hub --name hub \
|
||||||
|
protocols.p25.verbose true
|
||||||
|
|
||||||
|
# Configure individual modem ports
|
||||||
|
for i in {0..6}; do
|
||||||
|
if [ $i -eq 0 ]; then
|
||||||
|
file="hub-cc.yml"
|
||||||
|
else
|
||||||
|
file="hub-vc0${i}.yml"
|
||||||
|
fi
|
||||||
|
./dvmcfg edit /etc/dvm/hub/$file \
|
||||||
|
system.modem.protocol.uart.port "/dev/ttyUSB${i}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
./dvmcfg trunk validate --base-dir /etc/dvm/hub --name hub
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example 5: DMR Trunked System
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create DMR trunked system
|
||||||
|
./dvmcfg trunk create \
|
||||||
|
--base-dir /etc/dvm/dmr-site \
|
||||||
|
--name dmrsite \
|
||||||
|
--protocol dmr \
|
||||||
|
--vc-count 3 \
|
||||||
|
--identity "DMR001" \
|
||||||
|
--base-peer-id 200000 \
|
||||||
|
--color-code 2 \
|
||||||
|
--site-id 1
|
||||||
|
|
||||||
|
# Configure to use specific slots
|
||||||
|
./dvmcfg edit /etc/dvm/dmr-site/dmrsite-cc.yml \
|
||||||
|
protocols.dmr.control.slot 1
|
||||||
|
|
||||||
|
# Enable both slots for voice channels
|
||||||
|
./dvmcfg trunk update --base-dir /etc/dvm/dmr-site --name dmrsite \
|
||||||
|
network.slot1 true
|
||||||
|
./dvmcfg trunk update --base-dir /etc/dvm/dmr-site --name dmrsite \
|
||||||
|
network.slot2 true
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
./dvmcfg trunk validate --base-dir /etc/dvm/dmr-site --name dmrsite
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example 6: Conventional P25 System with Grants
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create conventional system
|
||||||
|
./dvmcfg create \
|
||||||
|
--template conventional \
|
||||||
|
--output /etc/dvm/conventional.yml \
|
||||||
|
--identity "CONV001" \
|
||||||
|
--peer-id 100010 \
|
||||||
|
--fne-address "10.0.0.1"
|
||||||
|
|
||||||
|
# Configure control channel settings
|
||||||
|
./dvmcfg edit /etc/dvm/conventional.yml \
|
||||||
|
protocols.p25.control.interval 300
|
||||||
|
./dvmcfg edit /etc/dvm/conventional.yml \
|
||||||
|
protocols.p25.control.duration 3
|
||||||
|
|
||||||
|
# Set as authoritative
|
||||||
|
./dvmcfg edit /etc/dvm/conventional.yml \
|
||||||
|
system.config.authoritative true
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
./dvmcfg validate /etc/dvm/conventional.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example 7: Multi-Site System Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create Site 1
|
||||||
|
./dvmcfg trunk create \
|
||||||
|
--base-dir /etc/dvm/sites/site1 \
|
||||||
|
--name site1 \
|
||||||
|
--vc-count 2 \
|
||||||
|
--identity "SITE001" \
|
||||||
|
--base-peer-id 100000 \
|
||||||
|
--site-id 1 \
|
||||||
|
--nac 0x001
|
||||||
|
|
||||||
|
# Create Site 2
|
||||||
|
./dvmcfg trunk create \
|
||||||
|
--base-dir /etc/dvm/sites/site2 \
|
||||||
|
--name site2 \
|
||||||
|
--vc-count 2 \
|
||||||
|
--identity "SITE002" \
|
||||||
|
--base-peer-id 100010 \
|
||||||
|
--site-id 2 \
|
||||||
|
--nac 0x001
|
||||||
|
|
||||||
|
# Update both sites to new FNE
|
||||||
|
for site in site1 site2; do
|
||||||
|
./dvmcfg trunk update \
|
||||||
|
--base-dir /etc/dvm/sites/$site \
|
||||||
|
--name $site \
|
||||||
|
network.address "newfne.example.com"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Validate both
|
||||||
|
./dvmcfg trunk validate --base-dir /etc/dvm/sites/site1 --name site1
|
||||||
|
./dvmcfg trunk validate --base-dir /etc/dvm/sites/site2 --name site2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example 8: Testing with Null Modem
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create test configuration with null modem
|
||||||
|
./dvmcfg create \
|
||||||
|
--template repeater \
|
||||||
|
--output /tmp/test-config.yml \
|
||||||
|
--identity "TEST" \
|
||||||
|
--peer-id 999999
|
||||||
|
|
||||||
|
# Set to null modem
|
||||||
|
./dvmcfg edit /tmp/test-config.yml system.modem.protocol.type "null"
|
||||||
|
|
||||||
|
# Enable all debug logging
|
||||||
|
./dvmcfg edit /tmp/test-config.yml protocols.dmr.debug true
|
||||||
|
./dvmcfg edit /tmp/test-config.yml protocols.p25.debug true
|
||||||
|
./dvmcfg edit /tmp/test-config.yml system.modem.debug true
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
./dvmcfg validate /tmp/test-config.yml --summary
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example 9: Enabling Encryption
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create secure configuration
|
||||||
|
./dvmcfg create \
|
||||||
|
--template repeater \
|
||||||
|
--output /etc/dvm/secure.yml \
|
||||||
|
--identity "SECURE001" \
|
||||||
|
--peer-id 100020
|
||||||
|
|
||||||
|
# Enable network encryption
|
||||||
|
./dvmcfg edit /etc/dvm/secure.yml network.encrypted true
|
||||||
|
|
||||||
|
# The preshared key is automatically generated
|
||||||
|
# You can update it if needed (must be 64 hex chars):
|
||||||
|
# ./dvmcfg edit /etc/dvm/secure.yml network.presharedKey "YOUR64HEXCHARACTERS..."
|
||||||
|
|
||||||
|
# Enable REST API with SSL
|
||||||
|
./dvmcfg edit /etc/dvm/secure.yml network.restEnable true
|
||||||
|
./dvmcfg edit /etc/dvm/secure.yml network.restSsl true
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
./dvmcfg validate /etc/dvm/secure.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example 10: Batch Configuration Update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# Update multiple configurations at once
|
||||||
|
|
||||||
|
CONFIGS=(
|
||||||
|
"/etc/dvm/repeater1.yml"
|
||||||
|
"/etc/dvm/repeater2.yml"
|
||||||
|
"/etc/dvm/repeater3.yml"
|
||||||
|
)
|
||||||
|
|
||||||
|
NEW_FNE="10.0.0.100"
|
||||||
|
NEW_PORT=62031
|
||||||
|
|
||||||
|
for config in "${CONFIGS[@]}"; do
|
||||||
|
echo "Updating $config..."
|
||||||
|
./dvmcfg edit "$config" network.address "$NEW_FNE"
|
||||||
|
./dvmcfg edit "$config" network.port "$NEW_PORT"
|
||||||
|
./dvmcfg validate "$config"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "All configs updated and validated!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pro Tips
|
||||||
|
|
||||||
|
1. **Always validate after edits**: Add `&& ./dvmcfg validate $CONFIG` to your commands
|
||||||
|
2. **Use variables in scripts**: Define common values as variables for consistency
|
||||||
|
3. **Test with null modem first**: Verify config before connecting hardware
|
||||||
|
4. **Sequential RPC ports**: Keep RPC ports in sequence for easier troubleshooting
|
||||||
|
5. **Document peer IDs**: Keep a spreadsheet of all peer IDs in your network
|
||||||
|
6. **Backup before bulk updates**: `cp config.yml config.yml.bak`
|
||||||
|
7. **Use trunk update for consistency**: Ensures all configs in a system match
|
||||||
|
8. **Validate entire trunk systems**: Use `trunk validate` to catch cross-config issues
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
# dvmcfggen - Quick Reference
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
```bash
|
||||||
|
cd dvmcfg
|
||||||
|
pip install -r requirements.txt
|
||||||
|
# or just run ./dvmcfg (auto-creates venv)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
### Interactive Wizard
|
||||||
|
```bash
|
||||||
|
# Guided configuration (recommended for beginners)
|
||||||
|
./dvmcfg wizard
|
||||||
|
./dvmcfg wizard --type single # Single instance
|
||||||
|
./dvmcfg wizard --type trunk # Trunked system
|
||||||
|
```
|
||||||
|
|
||||||
|
### Single Configuration
|
||||||
|
```bash
|
||||||
|
# Create from template
|
||||||
|
./dvmcfg create --template <name> --output <file> [options]
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
./dvmcfg validate <file> [--summary]
|
||||||
|
|
||||||
|
# Edit value
|
||||||
|
./dvmcfg edit <file> <key> <value>
|
||||||
|
|
||||||
|
# List templates
|
||||||
|
./dvmcfg templates
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trunked Systems
|
||||||
|
```bash
|
||||||
|
# Create system
|
||||||
|
./dvmcfg trunk create --base-dir <dir> --vc-count <n> [options]
|
||||||
|
|
||||||
|
# Validate system
|
||||||
|
./dvmcfg trunk validate --base-dir <dir> --name <name>
|
||||||
|
|
||||||
|
# Update all configs
|
||||||
|
./dvmcfg trunk update --base-dir <dir> --name <name> <key> <value>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Examples
|
||||||
|
|
||||||
|
### Hotspot
|
||||||
|
```bash
|
||||||
|
./dvmcfg create --template hotspot --output hotspot.yml \
|
||||||
|
--identity "HS001" --peer-id 100001 --callsign "KC1ABC"
|
||||||
|
```
|
||||||
|
|
||||||
|
### P25 Trunk (4 VCs)
|
||||||
|
```bash
|
||||||
|
./dvmcfg trunk create --base-dir /etc/dvm/trunk \
|
||||||
|
--protocol p25 --vc-count 4 --identity "SITE001" \
|
||||||
|
--base-peer-id 100000 --nac 0x001
|
||||||
|
```
|
||||||
|
|
||||||
|
### DMR Trunk (2 VCs)
|
||||||
|
```bash
|
||||||
|
./dvmcfg trunk create --base-dir /etc/dvm/dmr \
|
||||||
|
--protocol dmr --vc-count 2 --color-code 2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Config Keys
|
||||||
|
|
||||||
|
```
|
||||||
|
network.id - Peer ID
|
||||||
|
network.address - FNE address
|
||||||
|
network.port - FNE port
|
||||||
|
system.identity - System ID
|
||||||
|
system.config.nac - P25 NAC
|
||||||
|
system.config.colorCode - DMR color code
|
||||||
|
system.config.siteId - Site ID
|
||||||
|
system.modem.rxLevel - RX level (0-100)
|
||||||
|
system.modem.txLevel - TX level (0-100)
|
||||||
|
protocols.p25.enable - Enable P25
|
||||||
|
protocols.dmr.enable - Enable DMR
|
||||||
|
```
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
- `hotspot` - Simplex hotspot
|
||||||
|
- `repeater` - Duplex repeater
|
||||||
|
- `control-channel-p25` - P25 CC for trunking
|
||||||
|
- `control-channel-dmr` - DMR CC for trunking
|
||||||
|
- `voice-channel` - VC for trunking
|
||||||
|
- `conventional` - Conventional with grants
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
Single config:
|
||||||
|
```
|
||||||
|
config.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Trunked system (name: "test", 2 VCs):
|
||||||
|
```
|
||||||
|
test-cc.yml # Control channel
|
||||||
|
test-vc01.yml # Voice channel 1
|
||||||
|
test-vc02.yml # Voice channel 2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Default Values
|
||||||
|
|
||||||
|
| Parameter | Default | Notes |
|
||||||
|
|-----------|---------|-------|
|
||||||
|
| FNE Address | 127.0.0.1 | Localhost |
|
||||||
|
| FNE Port | 62031 | Standard FNE port |
|
||||||
|
| Base Peer ID | 100000 | CC gets this, VCs increment |
|
||||||
|
| Base RPC Port | 9890 | CC gets this, VCs increment |
|
||||||
|
| P25 NAC | 0x293 | Standard NAC |
|
||||||
|
| DMR Color Code | 1 | Standard CC |
|
||||||
|
| Site ID | 1 | First site |
|
||||||
|
| Modem Type | uart | Serial modem |
|
||||||
|
|
||||||
|
## Validation Checks
|
||||||
|
|
||||||
|
- IP addresses (format and range)
|
||||||
|
- Port numbers (1-65535)
|
||||||
|
- Hex keys (length and format)
|
||||||
|
- NAC (0-4095), Color Code (0-15), RAN (0-63)
|
||||||
|
- Identity/callsign (length limits)
|
||||||
|
- RX/TX levels (0-100)
|
||||||
|
- Trunk consistency (NAC, CC, Site ID match)
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
✓ Always validate after creation/editing
|
||||||
|
✓ Use `--summary` to see config overview
|
||||||
|
✓ Test with `--modem-type null` first
|
||||||
|
✓ Keep peer IDs sequential
|
||||||
|
✓ Use trunk update for system-wide changes
|
||||||
|
✓ Backup configs before bulk updates
|
||||||
|
|
||||||
|
## Help
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dvmcfg --help
|
||||||
|
./dvmcfg create --help
|
||||||
|
./dvmcfg trunk create --help
|
||||||
|
```
|
||||||
|
|
||||||
|
See USAGE.md and EXAMPLES.md for detailed documentation.
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
# dvmcfggen - DVMHost Configuration Generator
|
||||||
|
|
||||||
|
A comprehensive Python-based configuration utility for creating/managing DVMHost configurations, including support for multi-instance trunking configurations and automatic frequency planning.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Complete Configuration Files**: Generated configs include ALL 265+ parameters from DVMHost's config.example.yml
|
||||||
|
- **Interactive Wizard**: Step-by-step guided configuration creation
|
||||||
|
- **Frequency Configuration**: Automatic channel assignment and identity table generation
|
||||||
|
- 6 preset frequency bands (800/700/900 MHz, UHF, VHF)
|
||||||
|
- Automatic channel ID and channel number calculation
|
||||||
|
- RX frequency calculation with offset support
|
||||||
|
- Identity table (`iden_table.dat`) generation
|
||||||
|
- **Template System**: Pre-configured templates for common deployments
|
||||||
|
- Repeater/Hotspot (duplex)
|
||||||
|
- Control Channels (P25/DMR)
|
||||||
|
- Voice Channels
|
||||||
|
- Conventional Repeater
|
||||||
|
- **Configuration Validation**: Comprehensive validation of all parameters
|
||||||
|
- **Multi-Instance Trunking**: Manage complete trunked systems
|
||||||
|
- Automatic CC + VC configuration
|
||||||
|
- Peer ID and port management
|
||||||
|
- System-wide updates
|
||||||
|
- Consistent frequency planning across all instances
|
||||||
|
- **CLI Interface**: Command-line tools for automation
|
||||||
|
- **Rich Output**: Beautiful terminal formatting and tables
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dvmcfg
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Interactive Wizard (Easiest!)
|
||||||
|
```bash
|
||||||
|
./dvmcfg wizard
|
||||||
|
```
|
||||||
|
The wizard guides you step-by-step through configuration creation with prompts and validation.
|
||||||
|
|
||||||
|
### Create New Configuration (CLI)
|
||||||
|
```bash
|
||||||
|
./dvmcfg create --template hotspot --output /etc/dvm/config.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Trunked System
|
||||||
|
```bash
|
||||||
|
./dvmcfg trunk create --cc-port 62031 --vc-count 2 --base-dir /etc/dvm/trunked
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validate Configuration
|
||||||
|
```bash
|
||||||
|
./dvmcfg validate /etc/dvm/config.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
- **repeater**: Standalone repeater
|
||||||
|
- **control-channel**: Dedicated control channel for trunking
|
||||||
|
- **voice-channel**: Voice channel for trunking
|
||||||
|
- **conventional**: Conventional repeater with channel grants
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the GPLv2 License - see the [LICENSE](LICENSE) file for details. Use of this project is intended, for amateur and/or educational use ONLY. Any other use is at the risk of user and all commercial purposes is strictly discouraged.
|
||||||
@ -0,0 +1,330 @@
|
|||||||
|
# dvmcfggen - Usage Guide
|
||||||
|
# DVMCfg Usage Guide
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Interactive Wizard (Recommended)
|
||||||
|
|
||||||
|
The easiest way to create configurations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dvmcfg wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
The wizard provides:
|
||||||
|
- Step-by-step guided configuration
|
||||||
|
- Input validation in real-time
|
||||||
|
- Template selection help
|
||||||
|
- Configuration summary before saving
|
||||||
|
- Support for both single and trunked systems
|
||||||
|
|
||||||
|
**Wizard Types:**
|
||||||
|
```bash
|
||||||
|
# Auto-detect (asks user what to create)
|
||||||
|
./dvmcfg wizard
|
||||||
|
|
||||||
|
# Single instance wizard
|
||||||
|
./dvmcfg wizard --type single
|
||||||
|
|
||||||
|
# Trunking system wizard
|
||||||
|
./dvmcfg wizard --type trunk
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Command Reference
|
||||||
|
|
||||||
|
### Create Configuration
|
||||||
|
|
||||||
|
Create a new DVMHost configuration from a template:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dvmcfg create --template <template_name> --output <file_path> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--template` - Template to use (required)
|
||||||
|
- `--output, -o` - Output file path (required)
|
||||||
|
- `--identity` - System identity (e.g., "REPEATER01")
|
||||||
|
- `--peer-id` - Network peer ID
|
||||||
|
- `--fne-address` - FNE server address
|
||||||
|
- `--fne-port` - FNE server port
|
||||||
|
- `--callsign` - CWID callsign
|
||||||
|
- `--validate` - Validate configuration after creation
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a basic hotspot configuration
|
||||||
|
./dvmcfg create --template hotspot --output /etc/dvm/hotspot.yml \
|
||||||
|
--identity "HOTSPOT01" --peer-id 100001 --callsign "KC1ABC"
|
||||||
|
|
||||||
|
# Create a repeater configuration
|
||||||
|
./dvmcfg create --template repeater --output /etc/dvm/repeater.yml \
|
||||||
|
--identity "REPEATER01" --peer-id 100002 \
|
||||||
|
--fne-address "192.168.1.100" --fne-port 62031
|
||||||
|
|
||||||
|
# Create P25 control channel
|
||||||
|
./dvmcfg create --template control-channel-p25 --output /etc/dvm/cc.yml \
|
||||||
|
--identity "CC001" --peer-id 100000 --validate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validate Configuration
|
||||||
|
|
||||||
|
Validate an existing configuration file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dvmcfg validate <config_file> [--summary]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--summary, -s` - Display configuration summary
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic validation
|
||||||
|
./dvmcfg validate /etc/dvm/config.yml
|
||||||
|
|
||||||
|
# Validation with summary
|
||||||
|
./dvmcfg validate /etc/dvm/config.yml --summary
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit Configuration
|
||||||
|
|
||||||
|
Edit a specific configuration value:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dvmcfg edit <config_file> <key> <value>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Change system identity
|
||||||
|
./dvmcfg edit /etc/dvm/config.yml system.identity "NEWID"
|
||||||
|
|
||||||
|
# Update FNE address
|
||||||
|
./dvmcfg edit /etc/dvm/config.yml network.address "192.168.1.200"
|
||||||
|
|
||||||
|
# Enable DMR protocol
|
||||||
|
./dvmcfg edit /etc/dvm/config.yml protocols.dmr.enable true
|
||||||
|
|
||||||
|
# Change color code
|
||||||
|
./dvmcfg edit /etc/dvm/config.yml system.config.colorCode 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trunked System Management
|
||||||
|
|
||||||
|
#### Create Trunked System
|
||||||
|
|
||||||
|
Create a complete trunked system with control and voice channels:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dvmcfg trunk create --base-dir <directory> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `--base-dir` - Base directory for configs (required)
|
||||||
|
- `--name` - System name (default: "trunked")
|
||||||
|
- `--protocol` - Protocol type: p25 or dmr (default: p25)
|
||||||
|
- `--vc-count` - Number of voice channels (default: 2)
|
||||||
|
- `--fne-address` - FNE address (default: 127.0.0.1)
|
||||||
|
- `--fne-port` - FNE port (default: 62031)
|
||||||
|
- `--fne-password` - FNE password
|
||||||
|
- `--base-peer-id` - Base peer ID (default: 100000)
|
||||||
|
- `--base-rpc-port` - Base RPC port (default: 9890)
|
||||||
|
- `--identity` - System identity prefix (default: SITE001)
|
||||||
|
- `--nac` - P25 NAC in hex (default: 0x293)
|
||||||
|
- `--color-code` - DMR color code (default: 1)
|
||||||
|
- `--site-id` - Site ID (default: 1)
|
||||||
|
- `--modem-type` - Modem type: uart or null (default: uart)
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create basic P25 trunked system with 2 voice channels
|
||||||
|
./dvmcfg trunk create --base-dir /etc/dvm/trunk \
|
||||||
|
--name test --vc-count 2
|
||||||
|
|
||||||
|
# Create P25 trunked system with 4 voice channels
|
||||||
|
./dvmcfg trunk create --base-dir /etc/dvm/site1 \
|
||||||
|
--name site1 --protocol p25 --vc-count 4 \
|
||||||
|
--identity "SITE001" --base-peer-id 100000 \
|
||||||
|
--fne-address "10.0.0.1" --nac 0x001
|
||||||
|
|
||||||
|
# Create DMR trunked system
|
||||||
|
./dvmcfg trunk create --base-dir /etc/dvm/dmr_trunk \
|
||||||
|
--protocol dmr --vc-count 3 --color-code 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated Files:**
|
||||||
|
|
||||||
|
For a system named "test" with 2 voice channels:
|
||||||
|
- `test-cc.yml` - Control channel configuration
|
||||||
|
- `test-vc01.yml` - Voice channel 1 configuration
|
||||||
|
- `test-vc02.yml` - Voice channel 2 configuration
|
||||||
|
|
||||||
|
#### Validate Trunked System
|
||||||
|
|
||||||
|
Validate all configurations in a trunked system:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dvmcfg trunk validate --base-dir <directory> [--name <system_name>]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Validate trunked system
|
||||||
|
./dvmcfg trunk validate --base-dir /etc/dvm/trunk --name test
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Update Trunked System
|
||||||
|
|
||||||
|
Update a setting across all configurations in a system:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dvmcfg trunk update --base-dir <directory> --name <system_name> <key> <value>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update FNE address across all configs
|
||||||
|
./dvmcfg trunk update --base-dir /etc/dvm/trunk \
|
||||||
|
--name test network.address "10.0.0.100"
|
||||||
|
|
||||||
|
# Update NAC across all configs
|
||||||
|
./dvmcfg trunk update --base-dir /etc/dvm/trunk \
|
||||||
|
--name test system.config.nac 0x001
|
||||||
|
|
||||||
|
# Enable verbose logging on all instances
|
||||||
|
./dvmcfg trunk update --base-dir /etc/dvm/trunk \
|
||||||
|
--name test protocols.p25.verbose true
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Templates
|
||||||
|
|
||||||
|
Display all available configuration templates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dvmcfg templates
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Key Paths
|
||||||
|
|
||||||
|
Common configuration key paths for use with `edit` and `trunk update` commands:
|
||||||
|
|
||||||
|
### Network Settings
|
||||||
|
- `network.id` - Peer ID
|
||||||
|
- `network.address` - FNE address
|
||||||
|
- `network.port` - FNE port
|
||||||
|
- `network.password` - FNE password
|
||||||
|
- `network.encrypted` - Enable encryption (true/false)
|
||||||
|
- `network.rpcPort` - RPC port
|
||||||
|
- `network.rpcPassword` - RPC password
|
||||||
|
- `network.restEnable` - Enable REST API (true/false)
|
||||||
|
- `network.restPort` - REST API port
|
||||||
|
|
||||||
|
### System Settings
|
||||||
|
- `system.identity` - System identity
|
||||||
|
- `system.duplex` - Duplex mode (true/false)
|
||||||
|
- `system.timeout` - Call timeout (seconds)
|
||||||
|
- `system.config.colorCode` - DMR color code (0-15)
|
||||||
|
- `system.config.nac` - P25 NAC (0-4095)
|
||||||
|
- `system.config.ran` - NXDN RAN (0-63)
|
||||||
|
- `system.config.siteId` - Site ID
|
||||||
|
- `system.config.channelNo` - Channel number
|
||||||
|
|
||||||
|
### Protocol Settings
|
||||||
|
- `protocols.dmr.enable` - Enable DMR (true/false)
|
||||||
|
- `protocols.dmr.control.enable` - Enable DMR control (true/false)
|
||||||
|
- `protocols.dmr.verbose` - DMR verbose logging (true/false)
|
||||||
|
- `protocols.p25.enable` - Enable P25 (true/false)
|
||||||
|
- `protocols.p25.control.enable` - Enable P25 control (true/false)
|
||||||
|
- `protocols.p25.control.dedicated` - Dedicated control channel (true/false)
|
||||||
|
- `protocols.p25.verbose` - P25 verbose logging (true/false)
|
||||||
|
- `protocols.nxdn.enable` - Enable NXDN (true/false)
|
||||||
|
|
||||||
|
### Modem Settings
|
||||||
|
- `system.modem.protocol.type` - Modem type (uart/null)
|
||||||
|
- `system.modem.protocol.uart.port` - Serial port
|
||||||
|
- `system.modem.protocol.uart.speed` - Serial speed
|
||||||
|
- `system.modem.rxLevel` - RX level (0-100)
|
||||||
|
- `system.modem.txLevel` - TX level (0-100)
|
||||||
|
|
||||||
|
### CW ID Settings
|
||||||
|
- `system.cwId.enable` - Enable CWID (true/false)
|
||||||
|
- `system.cwId.time` - CWID interval (minutes)
|
||||||
|
- `system.cwId.callsign` - Callsign
|
||||||
|
|
||||||
|
## Workflow Examples
|
||||||
|
|
||||||
|
### Setting Up a Standalone Hotspot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create configuration
|
||||||
|
./dvmcfg create --template hotspot --output /etc/dvm/hotspot.yml \
|
||||||
|
--identity "HOTSPOT01" --peer-id 100001 \
|
||||||
|
--fne-address "fne.example.com" --fne-port 62031 \
|
||||||
|
--callsign "KC1ABC"
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
./dvmcfg validate /etc/dvm/hotspot.yml --summary
|
||||||
|
|
||||||
|
# Customize modem settings
|
||||||
|
./dvmcfg edit /etc/dvm/hotspot.yml system.modem.protocol.uart.port "/dev/ttyACM0"
|
||||||
|
./dvmcfg edit /etc/dvm/hotspot.yml system.modem.rxLevel 60
|
||||||
|
./dvmcfg edit /etc/dvm/hotspot.yml system.modem.txLevel 60
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setting Up a 4-Channel P25 Trunked System
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create trunked system
|
||||||
|
./dvmcfg trunk create --base-dir /etc/dvm/site1 \
|
||||||
|
--name site1 --protocol p25 --vc-count 4 \
|
||||||
|
--identity "SITE001" --base-peer-id 100000 \
|
||||||
|
--fne-address "10.0.0.1" --fne-port 62031 \
|
||||||
|
--nac 0x001 --site-id 1
|
||||||
|
|
||||||
|
# Validate system
|
||||||
|
./dvmcfg trunk validate --base-dir /etc/dvm/site1 --name site1
|
||||||
|
|
||||||
|
# Update modem type on all instances
|
||||||
|
./dvmcfg trunk update --base-dir /etc/dvm/site1 \
|
||||||
|
--name site1 system.modem.protocol.type uart
|
||||||
|
|
||||||
|
# Customize individual configs as needed
|
||||||
|
./dvmcfg edit /etc/dvm/site1/site1-cc.yml \
|
||||||
|
system.modem.protocol.uart.port "/dev/ttyUSB0"
|
||||||
|
./dvmcfg edit /etc/dvm/site1/site1-vc01.yml \
|
||||||
|
system.modem.protocol.uart.port "/dev/ttyUSB1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migrating Between FNE Servers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update single config
|
||||||
|
./dvmcfg edit /etc/dvm/config.yml network.address "newfne.example.com"
|
||||||
|
./dvmcfg edit /etc/dvm/config.yml network.port 62031
|
||||||
|
|
||||||
|
# Update entire trunked system
|
||||||
|
./dvmcfg trunk update --base-dir /etc/dvm/trunk \
|
||||||
|
--name test network.address "newfne.example.com"
|
||||||
|
./dvmcfg trunk update --base-dir /etc/dvm/trunk \
|
||||||
|
--name test network.port 62031
|
||||||
|
|
||||||
|
# Validate changes
|
||||||
|
./dvmcfg trunk validate --base-dir /etc/dvm/trunk --name test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
1. **Always validate** after creating or editing configurations
|
||||||
|
2. **Backup configurations** before making bulk updates
|
||||||
|
3. **Use consistent naming** for trunked system components
|
||||||
|
4. **Test with null modem** before connecting real hardware
|
||||||
|
5. **Keep RPC ports sequential** for easier management
|
||||||
|
6. **Document peer IDs** for your network topology
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
#/**
|
||||||
|
#* Digital Voice Modem - Host Configuration Generator
|
||||||
|
#* GPLv2 Open Source. Use is subject to license terms.
|
||||||
|
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
|
||||||
|
#*
|
||||||
|
#*/
|
||||||
|
#/*
|
||||||
|
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
|
||||||
|
#*
|
||||||
|
#* This program is free software; you can redistribute it and/or modify
|
||||||
|
#* it under the terms of the GNU General Public License as published by
|
||||||
|
#* the Free Software Foundation; either version 2 of the License, or
|
||||||
|
#* (at your option) any later version.
|
||||||
|
#*
|
||||||
|
#* This program is distributed in the hope that it will be useful,
|
||||||
|
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
#* GNU General Public License for more details.
|
||||||
|
#*
|
||||||
|
#* You should have received a copy of the GNU General Public License
|
||||||
|
#* along with this program; if not, write to the Free Software
|
||||||
|
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
|
||||||
|
#*/
|
||||||
|
"""
|
||||||
|
dvmcfggen Package
|
||||||
|
Main entry point for the DVMHost Configuration Generator
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
__author__ = "Bryan Biedenkapp N2PLL"
|
||||||
|
__license__ = "GPL-2.0-only"
|
||||||
|
|
||||||
|
from config_manager import DVMConfig, ConfigValidator
|
||||||
|
from templates import get_template, TEMPLATES
|
||||||
|
from trunking_manager import TrunkingSystem
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'DVMConfig',
|
||||||
|
'ConfigValidator',
|
||||||
|
'get_template',
|
||||||
|
'TEMPLATES',
|
||||||
|
'TrunkingSystem',
|
||||||
|
]
|
||||||
@ -0,0 +1,273 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
#/**
|
||||||
|
#* Digital Voice Modem - Host Configuration Generator
|
||||||
|
#* GPLv2 Open Source. Use is subject to license terms.
|
||||||
|
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
|
||||||
|
#*
|
||||||
|
#*/
|
||||||
|
#/*
|
||||||
|
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
|
||||||
|
#*
|
||||||
|
#* This program is free software; you can redistribute it and/or modify
|
||||||
|
#* it under the terms of the GNU General Public License as published by
|
||||||
|
#* the Free Software Foundation; either version 2 of the License, or
|
||||||
|
#* (at your option) any later version.
|
||||||
|
#*
|
||||||
|
#* This program is distributed in the hope that it will be useful,
|
||||||
|
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
#* GNU General Public License for more details.
|
||||||
|
#*
|
||||||
|
#* You should have received a copy of the GNU General Public License
|
||||||
|
#* along with this program; if not, write to the Free Software
|
||||||
|
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
|
||||||
|
#*/
|
||||||
|
"""
|
||||||
|
dvmcfggen - DVMHost Configuration Generator
|
||||||
|
Core module for managing DVMHost configuration files
|
||||||
|
"""
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
import copy
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigValidator:
|
||||||
|
"""Validates DVMHost configuration values"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_ip_address(ip: str) -> bool:
|
||||||
|
"""Validate IP address format"""
|
||||||
|
if ip in ['0.0.0.0', 'localhost']:
|
||||||
|
return True
|
||||||
|
pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
|
||||||
|
if not re.match(pattern, ip):
|
||||||
|
return False
|
||||||
|
parts = ip.split('.')
|
||||||
|
return all(0 <= int(part) <= 255 for part in parts)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_port(port: int) -> bool:
|
||||||
|
"""Validate port number"""
|
||||||
|
return 1 <= port <= 65535
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_hex_key(key: str, required_length: int) -> bool:
|
||||||
|
"""Validate hexadecimal key"""
|
||||||
|
if len(key) != required_length:
|
||||||
|
return False
|
||||||
|
return all(c in '0123456789ABCDEFabcdef' for c in key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_range(value: int, min_val: int, max_val: int) -> bool:
|
||||||
|
"""Validate value is within range"""
|
||||||
|
return min_val <= value <= max_val
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_identity(identity: str) -> bool:
|
||||||
|
"""Validate system identity format"""
|
||||||
|
return len(identity) > 0 and len(identity) <= 32
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_callsign(callsign: str) -> bool:
|
||||||
|
"""Validate callsign format"""
|
||||||
|
return len(callsign) > 0 and len(callsign) <= 16
|
||||||
|
|
||||||
|
|
||||||
|
class DVMConfig:
|
||||||
|
"""DVMHost configuration manager"""
|
||||||
|
|
||||||
|
def __init__(self, config_path: Optional[Path] = None):
|
||||||
|
"""
|
||||||
|
Initialize configuration manager
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Path to existing config file to load
|
||||||
|
"""
|
||||||
|
self.config_path = config_path
|
||||||
|
self.config: Dict[str, Any] = {}
|
||||||
|
self.validator = ConfigValidator()
|
||||||
|
|
||||||
|
if config_path and config_path.exists():
|
||||||
|
self.load(config_path)
|
||||||
|
|
||||||
|
def load(self, config_path: Path) -> None:
|
||||||
|
"""Load configuration from YAML file"""
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
self.config = yaml.safe_load(f)
|
||||||
|
self.config_path = config_path
|
||||||
|
|
||||||
|
def save(self, output_path: Optional[Path] = None) -> None:
|
||||||
|
"""Save configuration to YAML file"""
|
||||||
|
path = output_path or self.config_path
|
||||||
|
if not path:
|
||||||
|
raise ValueError("No output path specified")
|
||||||
|
|
||||||
|
# Create backup if file exists
|
||||||
|
if path.exists():
|
||||||
|
backup_path = path.with_suffix(path.suffix + '.bak')
|
||||||
|
path.rename(backup_path)
|
||||||
|
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
yaml.dump(self.config, f, default_flow_style=False, sort_keys=False)
|
||||||
|
|
||||||
|
def get(self, key_path: str, default: Any = None) -> Any:
|
||||||
|
"""
|
||||||
|
Get configuration value using dot notation
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_path: Path to value (e.g., 'network.id')
|
||||||
|
default: Default value if not found
|
||||||
|
"""
|
||||||
|
keys = key_path.split('.')
|
||||||
|
value = self.config
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
if isinstance(value, dict) and key in value:
|
||||||
|
value = value[key]
|
||||||
|
else:
|
||||||
|
return default
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def set(self, key_path: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Set configuration value using dot notation
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_path: Path to value (e.g., 'network.id')
|
||||||
|
value: Value to set
|
||||||
|
"""
|
||||||
|
keys = key_path.split('.')
|
||||||
|
config = self.config
|
||||||
|
|
||||||
|
# Navigate to the parent dict
|
||||||
|
for key in keys[:-1]:
|
||||||
|
if key not in config:
|
||||||
|
config[key] = {}
|
||||||
|
config = config[key]
|
||||||
|
|
||||||
|
# Set the value
|
||||||
|
config[keys[-1]] = value
|
||||||
|
|
||||||
|
def validate(self) -> List[str]:
|
||||||
|
"""
|
||||||
|
Validate configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of error messages (empty if valid)
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Network validation
|
||||||
|
if self.get('network.enable'):
|
||||||
|
if not self.validator.validate_ip_address(self.get('network.address', '')):
|
||||||
|
errors.append("Invalid network.address")
|
||||||
|
|
||||||
|
if not self.validator.validate_port(self.get('network.port', 0)):
|
||||||
|
errors.append("Invalid network.port")
|
||||||
|
|
||||||
|
if self.get('network.encrypted'):
|
||||||
|
psk = self.get('network.presharedKey', '')
|
||||||
|
if not self.validator.validate_hex_key(psk, 64):
|
||||||
|
errors.append("Invalid network.presharedKey (must be 64 hex characters)")
|
||||||
|
|
||||||
|
# RPC validation
|
||||||
|
rpc_addr = self.get('network.rpcAddress', '')
|
||||||
|
if rpc_addr and not self.validator.validate_ip_address(rpc_addr):
|
||||||
|
errors.append("Invalid network.rpcAddress")
|
||||||
|
|
||||||
|
rpc_port = self.get('network.rpcPort', 0)
|
||||||
|
if rpc_port and not self.validator.validate_port(rpc_port):
|
||||||
|
errors.append("Invalid network.rpcPort")
|
||||||
|
|
||||||
|
# REST API validation
|
||||||
|
if self.get('network.restEnable'):
|
||||||
|
rest_addr = self.get('network.restAddress', '')
|
||||||
|
if not self.validator.validate_ip_address(rest_addr):
|
||||||
|
errors.append("Invalid network.restAddress")
|
||||||
|
|
||||||
|
rest_port = self.get('network.restPort', 0)
|
||||||
|
if not self.validator.validate_port(rest_port):
|
||||||
|
errors.append("Invalid network.restPort")
|
||||||
|
|
||||||
|
# System validation
|
||||||
|
identity = self.get('system.identity', '')
|
||||||
|
if not self.validator.validate_identity(identity):
|
||||||
|
errors.append("Invalid system.identity")
|
||||||
|
|
||||||
|
# Color code / NAC / RAN validation
|
||||||
|
color_code = self.get('system.config.colorCode', 1)
|
||||||
|
if not self.validator.validate_range(color_code, 0, 15):
|
||||||
|
errors.append("Invalid system.config.colorCode (must be 0-15)")
|
||||||
|
|
||||||
|
nac = self.get('system.config.nac', 0)
|
||||||
|
if not self.validator.validate_range(nac, 0, 0xFFF):
|
||||||
|
errors.append("Invalid system.config.nac (must be 0-4095)")
|
||||||
|
|
||||||
|
ran = self.get('system.config.ran', 0)
|
||||||
|
if not self.validator.validate_range(ran, 0, 63):
|
||||||
|
errors.append("Invalid system.config.ran (must be 0-63)")
|
||||||
|
|
||||||
|
# CW ID validation
|
||||||
|
if self.get('system.cwId.enable'):
|
||||||
|
callsign = self.get('system.cwId.callsign', '')
|
||||||
|
if not self.validator.validate_callsign(callsign):
|
||||||
|
errors.append("Invalid system.cwId.callsign")
|
||||||
|
|
||||||
|
# Modem validation
|
||||||
|
rx_level = self.get('system.modem.rxLevel', 0)
|
||||||
|
if not self.validator.validate_range(rx_level, 0, 100):
|
||||||
|
errors.append("Invalid system.modem.rxLevel (must be 0-100)")
|
||||||
|
|
||||||
|
tx_level = self.get('system.modem.txLevel', 0)
|
||||||
|
if not self.validator.validate_range(tx_level, 0, 100):
|
||||||
|
errors.append("Invalid system.modem.txLevel (must be 0-100)")
|
||||||
|
|
||||||
|
# LLA key validation
|
||||||
|
lla_key = self.get('system.config.secure.key', '')
|
||||||
|
if lla_key and not self.validator.validate_hex_key(lla_key, 32):
|
||||||
|
errors.append("Invalid system.config.secure.key (must be 32 hex characters)")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def get_summary(self) -> Dict[str, Any]:
|
||||||
|
"""Get configuration summary"""
|
||||||
|
return {
|
||||||
|
'identity': self.get('system.identity', 'UNKNOWN'),
|
||||||
|
'peer_id': self.get('network.id', 0),
|
||||||
|
'fne_address': self.get('network.address', 'N/A'),
|
||||||
|
'fne_port': self.get('network.port', 0),
|
||||||
|
'rpc_password': self.get('network.rpcPassword', 'N/A'),
|
||||||
|
'rest_enabled': self.get('network.restEnable', False),
|
||||||
|
'rest_password': self.get('network.restPassword', 'N/A'),
|
||||||
|
'protocols': {
|
||||||
|
'dmr': self.get('protocols.dmr.enable', False),
|
||||||
|
'p25': self.get('protocols.p25.enable', False),
|
||||||
|
'nxdn': self.get('protocols.nxdn.enable', False),
|
||||||
|
},
|
||||||
|
'color_code': self.get('system.config.colorCode', 1),
|
||||||
|
'nac': self.get('system.config.nac', 0x293),
|
||||||
|
'site_id': self.get('system.config.siteId', 1),
|
||||||
|
'is_control': self.get('protocols.p25.control.enable', False) or
|
||||||
|
self.get('protocols.dmr.control.enable', False),
|
||||||
|
'modem_type': self.get('system.modem.protocol.type', 'null'),
|
||||||
|
'modem_mode': self.get('system.modem.protocol.mode', 'air'),
|
||||||
|
'modem_port': self.get('system.modem.protocol.uart.port', 'N/A'),
|
||||||
|
'mode': 'Duplex' if self.get('system.duplex', True) else 'Simplex',
|
||||||
|
'channel_id': self.get('system.config.channelId', 0),
|
||||||
|
'channel_no': self.get('system.config.channelNo', 0),
|
||||||
|
'tx_frequency': self.get('system.config.txFrequency', 0),
|
||||||
|
'rx_frequency': self.get('system.config.rxFrequency', 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# Quick test
|
||||||
|
config = DVMConfig()
|
||||||
|
config.set('system.identity', 'TEST001')
|
||||||
|
config.set('network.id', 100001)
|
||||||
|
print("Config manager initialized successfully")
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
#/**
|
||||||
|
#* Digital Voice Modem - Host Configuration Generator
|
||||||
|
#* GPLv2 Open Source. Use is subject to license terms.
|
||||||
|
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
|
||||||
|
#*
|
||||||
|
#*/
|
||||||
|
#/*
|
||||||
|
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
|
||||||
|
#*
|
||||||
|
#* This program is free software; you can redistribute it and/or modify
|
||||||
|
#* it under the terms of the GNU General Public License as published by
|
||||||
|
#* the Free Software Foundation; either version 2 of the License, or
|
||||||
|
#* (at your option) any later version.
|
||||||
|
#*
|
||||||
|
#* This program is distributed in the hope that it will be useful,
|
||||||
|
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
#* GNU General Public License for more details.
|
||||||
|
#*
|
||||||
|
#* You should have received a copy of the GNU General Public License
|
||||||
|
#* along with this program; if not, write to the Free Software
|
||||||
|
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
|
||||||
|
#*/
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Check if running in virtual environment
|
||||||
|
if [[ -z "$VIRTUAL_ENV" ]]; then
|
||||||
|
# Check if venv exists
|
||||||
|
if [[ ! -d "venv" ]]; then
|
||||||
|
echo "Creating virtual environment..."
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
pip install -q -r requirements.txt
|
||||||
|
else
|
||||||
|
source venv/bin/activate
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run dvmcfg
|
||||||
|
python3 dvmcfg.py "$@"
|
||||||
@ -0,0 +1,928 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
#/**
|
||||||
|
#* Digital Voice Modem - Host Configuration Generator
|
||||||
|
#* GPLv2 Open Source. Use is subject to license terms.
|
||||||
|
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
|
||||||
|
#*
|
||||||
|
#*/
|
||||||
|
#/*
|
||||||
|
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
|
||||||
|
#*
|
||||||
|
#* This program is free software; you can redistribute it and/or modify
|
||||||
|
#* it under the terms of the GNU General Public License as published by
|
||||||
|
#* the Free Software Foundation; either version 2 of the License, or
|
||||||
|
#* (at your option) any later version.
|
||||||
|
#*
|
||||||
|
#* This program is distributed in the hope that it will be useful,
|
||||||
|
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
#* GNU General Public License for more details.
|
||||||
|
#*
|
||||||
|
#* You should have received a copy of the GNU General Public License
|
||||||
|
#* along with this program; if not, write to the Free Software
|
||||||
|
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
|
||||||
|
#*/
|
||||||
|
"""
|
||||||
|
dvmcfggen - DVMHost Configuration Generator
|
||||||
|
|
||||||
|
Command-line interface for creating/managing DVMHost configurations
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
from rich import print as rprint
|
||||||
|
|
||||||
|
from config_manager import DVMConfig
|
||||||
|
from templates import TEMPLATES, get_template
|
||||||
|
from trunking_manager import TrunkingSystem
|
||||||
|
from wizard import run_wizard, generate_random_password
|
||||||
|
from version import __BANNER__, __VER__
|
||||||
|
from iden_table import IdenTable, calculate_channel_assignment, create_iden_entry_from_preset, BAND_PRESETS
|
||||||
|
from network_ids import (
|
||||||
|
get_network_id_info, validate_dmr_color_code, validate_dmr_network_id, validate_dmr_site_id,
|
||||||
|
validate_p25_nac, validate_p25_network_id, validate_p25_system_id, validate_p25_rfss_id,
|
||||||
|
validate_p25_site_id, validate_nxdn_ran, validate_nxdn_location_id,
|
||||||
|
get_dmr_site_model_from_string
|
||||||
|
)
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
def cmd_create(args):
|
||||||
|
"""Create new configuration from template"""
|
||||||
|
try:
|
||||||
|
config = DVMConfig()
|
||||||
|
config.config = get_template(args.template)
|
||||||
|
|
||||||
|
# Apply custom settings if provided
|
||||||
|
if args.identity:
|
||||||
|
config.set('system.identity', args.identity)
|
||||||
|
if args.peer_id:
|
||||||
|
config.set('network.id', args.peer_id)
|
||||||
|
if args.fne_address:
|
||||||
|
config.set('network.address', args.fne_address)
|
||||||
|
if args.fne_port:
|
||||||
|
config.set('network.port', args.fne_port)
|
||||||
|
if args.fne_password:
|
||||||
|
config.set('network.password', args.fne_password)
|
||||||
|
if args.callsign:
|
||||||
|
config.set('system.cwId.callsign', args.callsign)
|
||||||
|
|
||||||
|
# Handle modem configuration
|
||||||
|
if args.modem_type:
|
||||||
|
config.set('system.modem.protocol.type', args.modem_type)
|
||||||
|
if args.modem_mode:
|
||||||
|
config.set('system.modem.protocol.mode', args.modem_mode)
|
||||||
|
if args.modem_port:
|
||||||
|
config.set('system.modem.protocol.uart.port', args.modem_port)
|
||||||
|
if args.rx_level is not None:
|
||||||
|
config.set('system.modem.rxLevel', args.rx_level)
|
||||||
|
if args.tx_level is not None:
|
||||||
|
config.set('system.modem.txLevel', args.tx_level)
|
||||||
|
|
||||||
|
# Handle DFSI settings
|
||||||
|
if args.dfsi_rtrt is not None:
|
||||||
|
config.set('system.modem.dfsiRtrt', args.dfsi_rtrt)
|
||||||
|
if args.dfsi_jitter is not None:
|
||||||
|
config.set('system.modem.dfsiJitter', args.dfsi_jitter)
|
||||||
|
if args.dfsi_call_timeout is not None:
|
||||||
|
config.set('system.modem.dfsiCallTimeout', args.dfsi_call_timeout)
|
||||||
|
if args.dfsi_full_duplex is not None:
|
||||||
|
config.set('system.modem.dfsiFullDuplex', args.dfsi_full_duplex)
|
||||||
|
|
||||||
|
# Handle RPC/REST settings
|
||||||
|
if args.rpc_password:
|
||||||
|
config.set('network.rpcPassword', args.rpc_password)
|
||||||
|
elif args.generate_rpc_password:
|
||||||
|
rpc_pwd = generate_random_password()
|
||||||
|
config.set('network.rpcPassword', rpc_pwd)
|
||||||
|
console.print(f"[cyan]Generated RPC password:[/cyan] {rpc_pwd}")
|
||||||
|
|
||||||
|
if args.rest_enable:
|
||||||
|
config.set('network.restEnable', True)
|
||||||
|
if args.rest_password:
|
||||||
|
config.set('network.restPassword', args.rest_password)
|
||||||
|
elif args.generate_rest_password:
|
||||||
|
rest_pwd = generate_random_password()
|
||||||
|
config.set('network.restPassword', rest_pwd)
|
||||||
|
console.print(f"[cyan]Generated REST API password:[/cyan] {rest_pwd}")
|
||||||
|
|
||||||
|
# Handle network lookup/transfer settings
|
||||||
|
if args.update_lookups is not None:
|
||||||
|
config.set('network.updateLookups', args.update_lookups)
|
||||||
|
if args.save_lookups is not None:
|
||||||
|
config.set('network.saveLookups', args.save_lookups)
|
||||||
|
if args.allow_activity_transfer is not None:
|
||||||
|
config.set('network.allowActivityTransfer', args.allow_activity_transfer)
|
||||||
|
if args.allow_diagnostic_transfer is not None:
|
||||||
|
config.set('network.allowDiagnosticTransfer', args.allow_diagnostic_transfer)
|
||||||
|
if args.allow_status_transfer is not None:
|
||||||
|
config.set('network.allowStatusTransfer', args.allow_status_transfer)
|
||||||
|
|
||||||
|
# Handle Radio ID and Talkgroup ID settings
|
||||||
|
if args.radio_id_file:
|
||||||
|
config.set('system.radio_id.file', args.radio_id_file)
|
||||||
|
if args.radio_id_time is not None:
|
||||||
|
config.set('system.radio_id.time', args.radio_id_time)
|
||||||
|
if args.radio_id_acl is not None:
|
||||||
|
config.set('system.radio_id.acl', args.radio_id_acl)
|
||||||
|
if args.talkgroup_id_file:
|
||||||
|
config.set('system.talkgroup_id.file', args.talkgroup_id_file)
|
||||||
|
if args.talkgroup_id_time is not None:
|
||||||
|
config.set('system.talkgroup_id.time', args.talkgroup_id_time)
|
||||||
|
if args.talkgroup_id_acl is not None:
|
||||||
|
config.set('system.talkgroup_id.acl', args.talkgroup_id_acl)
|
||||||
|
|
||||||
|
# Handle protocol settings
|
||||||
|
if args.enable_p25 is not None:
|
||||||
|
config.set('protocols.p25.enable', args.enable_p25)
|
||||||
|
if args.enable_dmr is not None:
|
||||||
|
config.set('protocols.dmr.enable', args.enable_dmr)
|
||||||
|
if args.enable_nxdn is not None:
|
||||||
|
config.set('protocols.nxdn.enable', args.enable_nxdn)
|
||||||
|
|
||||||
|
# Handle DMR configuration
|
||||||
|
if args.color_code is not None:
|
||||||
|
valid, error = validate_dmr_color_code(args.color_code)
|
||||||
|
if not valid:
|
||||||
|
console.print(f"[red]Error:[/red] {error}")
|
||||||
|
sys.exit(1)
|
||||||
|
config.set('system.config.colorCode', args.color_code)
|
||||||
|
|
||||||
|
if args.dmr_net_id is not None:
|
||||||
|
site_model = get_dmr_site_model_from_string(args.dmr_site_model) if args.dmr_site_model else get_dmr_site_model_from_string('small')
|
||||||
|
valid, error = validate_dmr_network_id(args.dmr_net_id, site_model)
|
||||||
|
if not valid:
|
||||||
|
console.print(f"[red]Error:[/red] {error}")
|
||||||
|
sys.exit(1)
|
||||||
|
config.set('system.config.dmrNetId', args.dmr_net_id)
|
||||||
|
|
||||||
|
if args.dmr_site_id is not None:
|
||||||
|
site_model = get_dmr_site_model_from_string(args.dmr_site_model) if args.dmr_site_model else get_dmr_site_model_from_string('small')
|
||||||
|
valid, error = validate_dmr_site_id(args.dmr_site_id, site_model)
|
||||||
|
if not valid:
|
||||||
|
console.print(f"[red]Error:[/red] {error}")
|
||||||
|
sys.exit(1)
|
||||||
|
config.set('system.config.siteId', args.dmr_site_id)
|
||||||
|
|
||||||
|
# Handle P25 configuration
|
||||||
|
if args.nac is not None:
|
||||||
|
valid, error = validate_p25_nac(args.nac)
|
||||||
|
if not valid:
|
||||||
|
console.print(f"[red]Error:[/red] {error}")
|
||||||
|
sys.exit(1)
|
||||||
|
config.set('system.config.nac', args.nac)
|
||||||
|
|
||||||
|
if args.p25_net_id is not None:
|
||||||
|
valid, error = validate_p25_network_id(args.p25_net_id)
|
||||||
|
if not valid:
|
||||||
|
console.print(f"[red]Error:[/red] {error}")
|
||||||
|
sys.exit(1)
|
||||||
|
config.set('system.config.netId', args.p25_net_id)
|
||||||
|
|
||||||
|
if args.p25_sys_id is not None:
|
||||||
|
valid, error = validate_p25_system_id(args.p25_sys_id)
|
||||||
|
if not valid:
|
||||||
|
console.print(f"[red]Error:[/red] {error}")
|
||||||
|
sys.exit(1)
|
||||||
|
config.set('system.config.sysId', args.p25_sys_id)
|
||||||
|
|
||||||
|
if args.p25_rfss_id is not None:
|
||||||
|
valid, error = validate_p25_rfss_id(args.p25_rfss_id)
|
||||||
|
if not valid:
|
||||||
|
console.print(f"[red]Error:[/red] {error}")
|
||||||
|
sys.exit(1)
|
||||||
|
config.set('system.config.rfssId', args.p25_rfss_id)
|
||||||
|
|
||||||
|
if args.p25_site_id is not None:
|
||||||
|
valid, error = validate_p25_site_id(args.p25_site_id)
|
||||||
|
if not valid:
|
||||||
|
console.print(f"[red]Error:[/red] {error}")
|
||||||
|
sys.exit(1)
|
||||||
|
config.set('system.config.siteId', args.p25_site_id)
|
||||||
|
|
||||||
|
# Handle NXDN configuration
|
||||||
|
if args.nxdn_ran is not None:
|
||||||
|
valid, error = validate_nxdn_ran(args.nxdn_ran)
|
||||||
|
if not valid:
|
||||||
|
console.print(f"[red]Error:[/red] {error}")
|
||||||
|
sys.exit(1)
|
||||||
|
config.set('system.config.ran', args.nxdn_ran)
|
||||||
|
|
||||||
|
if args.site_id is not None:
|
||||||
|
config.set('system.config.siteId', args.site_id)
|
||||||
|
|
||||||
|
# Handle location settings
|
||||||
|
if args.latitude is not None:
|
||||||
|
config.set('system.info.latitude', args.latitude)
|
||||||
|
if args.longitude is not None:
|
||||||
|
config.set('system.info.longitude', args.longitude)
|
||||||
|
if args.location:
|
||||||
|
config.set('system.info.location', args.location)
|
||||||
|
if args.tx_power is not None:
|
||||||
|
config.set('system.info.power', args.tx_power)
|
||||||
|
|
||||||
|
# Handle frequency configuration
|
||||||
|
iden_table = None
|
||||||
|
if args.tx_freq and args.band:
|
||||||
|
if args.band not in BAND_PRESETS:
|
||||||
|
console.print(f"[red]Error:[/red] Invalid band '{args.band}'. Available: {', '.join(BAND_PRESETS.keys())}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
preset = BAND_PRESETS[args.band]
|
||||||
|
tx_freq_mhz = args.tx_freq
|
||||||
|
|
||||||
|
# Validate frequency is in band
|
||||||
|
if not (preset['tx_range'][0] <= tx_freq_mhz <= preset['tx_range'][1]):
|
||||||
|
console.print(f"[red]Error:[/red] TX frequency {tx_freq_mhz} MHz is outside {args.band} range "
|
||||||
|
f"({preset['tx_range'][0]}-{preset['tx_range'][1]} MHz)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Calculate channel assignment
|
||||||
|
# Use band index as channel ID, except 900MHz is always 15
|
||||||
|
band_list = list(BAND_PRESETS.keys())
|
||||||
|
band_index = band_list.index(args.band)
|
||||||
|
channel_id = 15 if args.band == '900mhz' else band_index
|
||||||
|
|
||||||
|
channel_id_result, channel_no, tx_hz, rx_hz = calculate_channel_assignment(
|
||||||
|
tx_freq_mhz, args.band, channel_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set channel configuration
|
||||||
|
config.set('system.config.channelId', channel_id_result)
|
||||||
|
config.set('system.config.channelNo', channel_no)
|
||||||
|
config.set('system.config.txFrequency', tx_hz)
|
||||||
|
config.set('system.config.rxFrequency', rx_hz)
|
||||||
|
|
||||||
|
console.print(f"[green]✓[/green] Frequency configured: TX {tx_freq_mhz:.6f} MHz, "
|
||||||
|
f"RX {rx_hz/1000000:.6f} MHz")
|
||||||
|
console.print(f"[green]✓[/green] Channel ID {channel_id_result}, Channel# {channel_no} (0x{channel_no:03X})")
|
||||||
|
|
||||||
|
# Create IDEN table entry
|
||||||
|
iden_table = IdenTable()
|
||||||
|
entry = create_iden_entry_from_preset(channel_id_result, args.band)
|
||||||
|
iden_table.add_entry(entry)
|
||||||
|
|
||||||
|
# Save configuration
|
||||||
|
output_path = Path(args.output)
|
||||||
|
config.save(output_path)
|
||||||
|
|
||||||
|
console.print(f"[green]✓[/green] Configuration created: {output_path}")
|
||||||
|
|
||||||
|
# Save IDEN table if frequency was configured
|
||||||
|
if iden_table and len(iden_table) > 0:
|
||||||
|
iden_path = output_path.parent / "iden_table.dat"
|
||||||
|
iden_table.save(iden_path)
|
||||||
|
console.print(f"[green]✓[/green] Identity table created: {iden_path}")
|
||||||
|
|
||||||
|
# Validate if requested
|
||||||
|
if args.validate:
|
||||||
|
errors = config.validate()
|
||||||
|
if errors:
|
||||||
|
console.print("\n[yellow]Validation warnings:[/yellow]")
|
||||||
|
for error in errors:
|
||||||
|
console.print(f" • {error}")
|
||||||
|
else:
|
||||||
|
console.print("[green]✓[/green] Configuration is valid")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Error:[/red] {e}", style="bold red")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_validate(args):
|
||||||
|
"""Validate configuration file"""
|
||||||
|
try:
|
||||||
|
config = DVMConfig(Path(args.config))
|
||||||
|
errors = config.validate()
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
console.print(f"[red]✗[/red] Configuration has {len(errors)} error(s):\n")
|
||||||
|
for error in errors:
|
||||||
|
console.print(f" [red]•[/red] {error}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
console.print(f"[green]✓[/green] Configuration is valid: {args.config}")
|
||||||
|
|
||||||
|
# Show summary if requested
|
||||||
|
if args.summary:
|
||||||
|
summary = config.get_summary()
|
||||||
|
table = Table(title="Configuration Summary")
|
||||||
|
table.add_column("Parameter", style="cyan")
|
||||||
|
table.add_column("Value", style="yellow")
|
||||||
|
|
||||||
|
table.add_row("Identity", summary['identity'])
|
||||||
|
table.add_row("Peer ID", str(summary['peer_id']))
|
||||||
|
table.add_row("FNE Address", summary['fne_address'])
|
||||||
|
table.add_row("FNE Port", str(summary['fne_port']))
|
||||||
|
table.add_row("DMR Color Code", str(summary['color_code']))
|
||||||
|
table.add_row("P25 NAC", f"0x{summary['nac']:03X}")
|
||||||
|
table.add_row("Site ID", str(summary['site_id']))
|
||||||
|
table.add_row("Modem Type", summary['modem_type'])
|
||||||
|
|
||||||
|
protocols = []
|
||||||
|
if summary['protocols']['dmr']:
|
||||||
|
protocols.append("DMR")
|
||||||
|
if summary['protocols']['p25']:
|
||||||
|
protocols.append("P25")
|
||||||
|
if summary['protocols']['nxdn']:
|
||||||
|
protocols.append("NXDN")
|
||||||
|
table.add_row("Protocols", ", ".join(protocols))
|
||||||
|
table.add_row("Control Channel", "Yes" if summary['is_control'] else "No")
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
console.print(f"[red]Error:[/red] File not found: {args.config}")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_edit(args):
|
||||||
|
"""Edit configuration value"""
|
||||||
|
try:
|
||||||
|
config_path = Path(args.config)
|
||||||
|
config = DVMConfig(config_path)
|
||||||
|
|
||||||
|
# Get current value
|
||||||
|
current = config.get(args.key)
|
||||||
|
console.print(f"Current value of '{args.key}': {current}")
|
||||||
|
|
||||||
|
# Set new value
|
||||||
|
# Try to preserve type
|
||||||
|
if isinstance(current, bool):
|
||||||
|
new_value = args.value.lower() in ('true', '1', 'yes', 'on')
|
||||||
|
elif isinstance(current, int):
|
||||||
|
new_value = int(args.value)
|
||||||
|
elif isinstance(current, float):
|
||||||
|
new_value = float(args.value)
|
||||||
|
else:
|
||||||
|
new_value = args.value
|
||||||
|
|
||||||
|
config.set(args.key, new_value)
|
||||||
|
config.save(config_path)
|
||||||
|
|
||||||
|
console.print(f"[green]✓[/green] Updated '{args.key}' to: {new_value}")
|
||||||
|
|
||||||
|
# Validate after edit
|
||||||
|
errors = config.validate()
|
||||||
|
if errors:
|
||||||
|
console.print("\n[yellow]Validation warnings after edit:[/yellow]")
|
||||||
|
for error in errors:
|
||||||
|
console.print(f" • {error}")
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
console.print(f"[red]Error:[/red] File not found: {args.config}")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_trunk_create(args):
|
||||||
|
"""Create trunked system"""
|
||||||
|
try:
|
||||||
|
system = TrunkingSystem(Path(args.base_dir), args.name)
|
||||||
|
|
||||||
|
console.print(f"[cyan]Creating trunked system '{args.name}'...[/cyan]\n")
|
||||||
|
|
||||||
|
# Handle frequency configuration
|
||||||
|
cc_channel_id = 0
|
||||||
|
cc_channel_no = 0
|
||||||
|
vc_channels = None
|
||||||
|
iden_table = None
|
||||||
|
|
||||||
|
# Control channel frequency
|
||||||
|
if args.cc_tx_freq and args.cc_band:
|
||||||
|
if args.cc_band not in BAND_PRESETS:
|
||||||
|
console.print(f"[red]Error:[/red] Invalid CC band '{args.cc_band}'. Available: {', '.join(BAND_PRESETS.keys())}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
preset = BAND_PRESETS[args.cc_band]
|
||||||
|
if not (preset['tx_range'][0] <= args.cc_tx_freq <= preset['tx_range'][1]):
|
||||||
|
console.print(f"[red]Error:[/red] CC TX frequency {args.cc_tx_freq} MHz is outside {args.cc_band} range")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
band_list = list(BAND_PRESETS.keys())
|
||||||
|
band_index = band_list.index(args.cc_band)
|
||||||
|
cc_channel_id = 15 if args.cc_band == '900mhz' else band_index
|
||||||
|
|
||||||
|
cc_channel_id, cc_channel_no, tx_hz, rx_hz = calculate_channel_assignment(
|
||||||
|
args.cc_tx_freq, args.cc_band, cc_channel_id
|
||||||
|
)
|
||||||
|
|
||||||
|
console.print(f"[green]✓[/green] CC: TX {args.cc_tx_freq:.6f} MHz, RX {rx_hz/1000000:.6f} MHz, "
|
||||||
|
f"Ch ID {cc_channel_id}, Ch# {cc_channel_no} (0x{cc_channel_no:03X})")
|
||||||
|
|
||||||
|
iden_table = IdenTable()
|
||||||
|
entry = create_iden_entry_from_preset(cc_channel_id, args.cc_band)
|
||||||
|
iden_table.add_entry(entry)
|
||||||
|
|
||||||
|
# Voice channel frequencies
|
||||||
|
if args.vc_tx_freqs and args.vc_bands:
|
||||||
|
vc_tx_freqs = [float(f) for f in args.vc_tx_freqs.split(',')]
|
||||||
|
vc_bands = args.vc_bands.split(',')
|
||||||
|
|
||||||
|
if len(vc_tx_freqs) != args.vc_count:
|
||||||
|
console.print(f"[red]Error:[/red] Number of VC frequencies ({len(vc_tx_freqs)}) "
|
||||||
|
f"doesn't match VC count ({args.vc_count})")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if len(vc_bands) != args.vc_count:
|
||||||
|
console.print(f"[red]Error:[/red] Number of VC bands ({len(vc_bands)}) "
|
||||||
|
f"doesn't match VC count ({args.vc_count})")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
vc_channels = []
|
||||||
|
if iden_table is None:
|
||||||
|
iden_table = IdenTable()
|
||||||
|
|
||||||
|
for i, (vc_tx_freq, vc_band) in enumerate(zip(vc_tx_freqs, vc_bands), 1):
|
||||||
|
if vc_band not in BAND_PRESETS:
|
||||||
|
console.print(f"[red]Error:[/red] Invalid VC{i} band '{vc_band}'")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
preset = BAND_PRESETS[vc_band]
|
||||||
|
if not (preset['tx_range'][0] <= vc_tx_freq <= preset['tx_range'][1]):
|
||||||
|
console.print(f"[red]Error:[/red] VC{i} TX frequency {vc_tx_freq} MHz is outside {vc_band} range")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
band_list = list(BAND_PRESETS.keys())
|
||||||
|
band_index = band_list.index(vc_band)
|
||||||
|
vc_channel_id = 15 if vc_band == '900mhz' else band_index
|
||||||
|
|
||||||
|
vc_channel_id, vc_channel_no, tx_hz, rx_hz = calculate_channel_assignment(
|
||||||
|
vc_tx_freq, vc_band, vc_channel_id
|
||||||
|
)
|
||||||
|
|
||||||
|
vc_channels.append({
|
||||||
|
'channel_id': vc_channel_id,
|
||||||
|
'channel_no': vc_channel_no
|
||||||
|
})
|
||||||
|
|
||||||
|
console.print(f"[green]✓[/green] VC{i}: TX {vc_tx_freq:.6f} MHz, RX {rx_hz/1000000:.6f} MHz, "
|
||||||
|
f"Ch ID {vc_channel_id}, Ch# {vc_channel_no} (0x{vc_channel_no:03X})")
|
||||||
|
|
||||||
|
# Add IDEN entry if not already present
|
||||||
|
if vc_channel_id not in iden_table.entries:
|
||||||
|
entry = create_iden_entry_from_preset(vc_channel_id, vc_band)
|
||||||
|
iden_table.add_entry(entry)
|
||||||
|
|
||||||
|
# Prepare base system creation kwargs
|
||||||
|
create_kwargs = {
|
||||||
|
'protocol': args.protocol,
|
||||||
|
'vc_count': args.vc_count,
|
||||||
|
'fne_address': args.fne_address,
|
||||||
|
'fne_port': args.fne_port,
|
||||||
|
'fne_password': args.fne_password,
|
||||||
|
'base_peer_id': args.base_peer_id,
|
||||||
|
'base_rpc_port': args.base_rpc_port,
|
||||||
|
'system_identity': args.identity,
|
||||||
|
'modem_type': args.modem_type,
|
||||||
|
'cc_channel_id': cc_channel_id,
|
||||||
|
'cc_channel_no': cc_channel_no,
|
||||||
|
'vc_channels': vc_channels
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add RPC/REST settings
|
||||||
|
if args.rpc_password:
|
||||||
|
create_kwargs['rpc_password'] = args.rpc_password
|
||||||
|
elif args.generate_rpc_password:
|
||||||
|
rpc_pwd = generate_random_password()
|
||||||
|
create_kwargs['rpc_password'] = rpc_pwd
|
||||||
|
console.print(f"[cyan]Generated RPC password:[/cyan] {rpc_pwd}")
|
||||||
|
|
||||||
|
if args.base_rest_port:
|
||||||
|
create_kwargs['base_rest_port'] = args.base_rest_port
|
||||||
|
|
||||||
|
if args.rest_enable:
|
||||||
|
if args.rest_password:
|
||||||
|
create_kwargs['rest_password'] = args.rest_password
|
||||||
|
elif args.generate_rest_password:
|
||||||
|
rest_pwd = generate_random_password()
|
||||||
|
create_kwargs['rest_password'] = rest_pwd
|
||||||
|
console.print(f"[cyan]Generated REST API password:[/cyan] {rest_pwd}")
|
||||||
|
|
||||||
|
# Add network lookup/transfer settings
|
||||||
|
if args.update_lookups is not None:
|
||||||
|
create_kwargs['update_lookups'] = args.update_lookups
|
||||||
|
if args.save_lookups is not None:
|
||||||
|
create_kwargs['save_lookups'] = args.save_lookups
|
||||||
|
if args.allow_activity_transfer is not None:
|
||||||
|
create_kwargs['allow_activity_transfer'] = args.allow_activity_transfer
|
||||||
|
if args.allow_diagnostic_transfer is not None:
|
||||||
|
create_kwargs['allow_diagnostic_transfer'] = args.allow_diagnostic_transfer
|
||||||
|
if args.allow_status_transfer is not None:
|
||||||
|
create_kwargs['allow_status_transfer'] = args.allow_status_transfer
|
||||||
|
|
||||||
|
# Add Radio ID and Talkgroup ID settings
|
||||||
|
if args.radio_id_file:
|
||||||
|
create_kwargs['radio_id_file'] = args.radio_id_file
|
||||||
|
if args.radio_id_time is not None:
|
||||||
|
create_kwargs['radio_id_time'] = args.radio_id_time
|
||||||
|
if args.radio_id_acl is not None:
|
||||||
|
create_kwargs['radio_id_acl'] = args.radio_id_acl
|
||||||
|
if args.talkgroup_id_file:
|
||||||
|
create_kwargs['talkgroup_id_file'] = args.talkgroup_id_file
|
||||||
|
if args.talkgroup_id_time is not None:
|
||||||
|
create_kwargs['talkgroup_id_time'] = args.talkgroup_id_time
|
||||||
|
if args.talkgroup_id_acl is not None:
|
||||||
|
create_kwargs['talkgroup_id_acl'] = args.talkgroup_id_acl
|
||||||
|
|
||||||
|
# Add protocol-specific settings
|
||||||
|
if args.protocol == 'p25':
|
||||||
|
create_kwargs['nac'] = args.nac
|
||||||
|
create_kwargs['net_id'] = args.p25_net_id or 0xBB800
|
||||||
|
create_kwargs['sys_id'] = args.p25_sys_id or 0x001
|
||||||
|
create_kwargs['rfss_id'] = args.p25_rfss_id or 1
|
||||||
|
create_kwargs['site_id'] = args.p25_site_id or args.site_id
|
||||||
|
elif args.protocol == 'dmr':
|
||||||
|
create_kwargs['color_code'] = args.color_code
|
||||||
|
create_kwargs['dmr_net_id'] = args.dmr_net_id or 1
|
||||||
|
create_kwargs['site_id'] = args.dmr_site_id or args.site_id
|
||||||
|
|
||||||
|
system.create_system(**create_kwargs)
|
||||||
|
|
||||||
|
# Save IDEN table if frequencies were configured
|
||||||
|
if iden_table and len(iden_table) > 0:
|
||||||
|
iden_path = Path(args.base_dir) / "iden_table.dat"
|
||||||
|
iden_table.save(iden_path)
|
||||||
|
console.print(f"[green]✓[/green] Identity table created: {iden_path}")
|
||||||
|
|
||||||
|
console.print(f"\n[green]✓[/green] Trunked system created successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_trunk_validate(args):
|
||||||
|
"""Validate trunked system"""
|
||||||
|
try:
|
||||||
|
system = TrunkingSystem(Path(args.base_dir), args.name)
|
||||||
|
system.load_system()
|
||||||
|
|
||||||
|
console.print(f"[cyan]Validating trunked system '{args.name}'...[/cyan]\n")
|
||||||
|
|
||||||
|
errors = system.validate_system()
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
console.print(f"[red]✗[/red] System has errors:\n")
|
||||||
|
for component, error_list in errors.items():
|
||||||
|
console.print(f"[yellow]{component}:[/yellow]")
|
||||||
|
for error in error_list:
|
||||||
|
console.print(f" [red]•[/red] {error}")
|
||||||
|
console.print()
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
console.print(f"[green]✓[/green] Trunked system is valid")
|
||||||
|
|
||||||
|
# Show summary
|
||||||
|
table = Table(title=f"Trunked System: {args.name}")
|
||||||
|
table.add_column("Component", style="cyan")
|
||||||
|
table.add_column("Identity", style="yellow")
|
||||||
|
table.add_column("Peer ID", style="magenta")
|
||||||
|
table.add_column("RPC Port", style="green")
|
||||||
|
|
||||||
|
cc = system.cc_config
|
||||||
|
table.add_row(
|
||||||
|
"Control Channel",
|
||||||
|
cc.get('system.identity'),
|
||||||
|
str(cc.get('network.id')),
|
||||||
|
str(cc.get('network.rpcPort'))
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, vc in enumerate(system.vc_configs, 1):
|
||||||
|
table.add_row(
|
||||||
|
f"Voice Channel {i}",
|
||||||
|
vc.get('system.identity'),
|
||||||
|
str(vc.get('network.id')),
|
||||||
|
str(vc.get('network.rpcPort'))
|
||||||
|
)
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_trunk_update(args):
|
||||||
|
"""Update trunked system setting"""
|
||||||
|
try:
|
||||||
|
system = TrunkingSystem(Path(args.base_dir), args.name)
|
||||||
|
system.load_system()
|
||||||
|
|
||||||
|
console.print(f"[cyan]Updating '{args.key}' across all configs...[/cyan]")
|
||||||
|
|
||||||
|
# Determine value type
|
||||||
|
value = args.value
|
||||||
|
if value.lower() in ('true', 'false'):
|
||||||
|
value = value.lower() == 'true'
|
||||||
|
elif value.isdigit():
|
||||||
|
value = int(value)
|
||||||
|
|
||||||
|
system.update_all(args.key, value)
|
||||||
|
system.save_all()
|
||||||
|
|
||||||
|
console.print(f"[green]✓[/green] Updated '{args.key}' to '{value}' in all configs")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list_templates(args):
|
||||||
|
"""List available templates"""
|
||||||
|
table = Table(title="Available Templates")
|
||||||
|
table.add_column("Template Name", style="cyan")
|
||||||
|
table.add_column("Description", style="yellow")
|
||||||
|
|
||||||
|
descriptions = {
|
||||||
|
'conventional': 'Standalone repeater',
|
||||||
|
'enhanced': 'Enhanced repeater with channel grants',
|
||||||
|
'control-channel-p25': 'P25 dedicated control channel for trunking',
|
||||||
|
'control-channel-dmr': 'DMR dedicated control channel for trunking',
|
||||||
|
'voice-channel': 'Voice channel for trunking system',
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in sorted(TEMPLATES.keys()):
|
||||||
|
desc = descriptions.get(name, 'N/A')
|
||||||
|
table.add_row(name, desc)
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_wizard(args):
|
||||||
|
"""Run interactive wizard"""
|
||||||
|
wizard_type = args.type if hasattr(args, 'type') else 'auto'
|
||||||
|
result = run_wizard(wizard_type)
|
||||||
|
if not result:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Prepare description with banner and copyright
|
||||||
|
description = f"""
|
||||||
|
Digital Voice Modem (DVM) Configuration Generator {__VER__}
|
||||||
|
Copyright (c) 2026 Bryan Biedenkapp, N2PLL and DVMProject (https://github.com/dvmproject) Authors.
|
||||||
|
|
||||||
|
DVMHost Configuration Manager"""
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=description,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
|
)
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(dest='command', help='Commands')
|
||||||
|
|
||||||
|
# create command
|
||||||
|
create_parser = subparsers.add_parser('create', help='Create new configuration')
|
||||||
|
create_parser.add_argument('--template', default='enhanced', choices=list(TEMPLATES.keys()),
|
||||||
|
help='Configuration template (default: enhanced)')
|
||||||
|
create_parser.add_argument('--output', '-o', required=True, help='Output file path')
|
||||||
|
create_parser.add_argument('--identity', help='System identity')
|
||||||
|
create_parser.add_argument('--peer-id', type=int, help='Network peer ID')
|
||||||
|
create_parser.add_argument('--fne-address', help='FNE address')
|
||||||
|
create_parser.add_argument('--fne-port', type=int, help='FNE port')
|
||||||
|
create_parser.add_argument('--fne-password', help='FNE password')
|
||||||
|
create_parser.add_argument('--callsign', help='CWID callsign')
|
||||||
|
|
||||||
|
# Modem configuration
|
||||||
|
create_parser.add_argument('--modem-type', choices=['uart', 'null'],
|
||||||
|
help='Modem type')
|
||||||
|
create_parser.add_argument('--modem-mode', choices=['air', 'dfsi'],
|
||||||
|
help='Modem mode')
|
||||||
|
create_parser.add_argument('--modem-port', help='Serial port for UART modem')
|
||||||
|
create_parser.add_argument('--rx-level', type=int, help='RX level (0-100)')
|
||||||
|
create_parser.add_argument('--tx-level', type=int, help='TX level (0-100)')
|
||||||
|
|
||||||
|
# DFSI settings
|
||||||
|
create_parser.add_argument('--dfsi-rtrt', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Enable DFSI RT/RT (true/false)')
|
||||||
|
create_parser.add_argument('--dfsi-jitter', type=int, help='DFSI jitter (ms)')
|
||||||
|
create_parser.add_argument('--dfsi-call-timeout', type=int, help='DFSI call timeout (seconds)')
|
||||||
|
create_parser.add_argument('--dfsi-full-duplex', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Enable DFSI full duplex (true/false)')
|
||||||
|
|
||||||
|
# RPC/REST settings
|
||||||
|
create_parser.add_argument('--rpc-password', help='RPC password')
|
||||||
|
create_parser.add_argument('--generate-rpc-password', action='store_true',
|
||||||
|
help='Generate random RPC password')
|
||||||
|
create_parser.add_argument('--rest-enable', action='store_true', help='Enable REST API')
|
||||||
|
create_parser.add_argument('--rest-password', help='REST API password')
|
||||||
|
create_parser.add_argument('--generate-rest-password', action='store_true',
|
||||||
|
help='Generate random REST API password')
|
||||||
|
|
||||||
|
# Network lookup/transfer settings
|
||||||
|
create_parser.add_argument('--update-lookups', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Update lookups from network (true/false)')
|
||||||
|
create_parser.add_argument('--save-lookups', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Save lookups from network (true/false)')
|
||||||
|
create_parser.add_argument('--allow-activity-transfer', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Allow activity transfer to FNE (true/false)')
|
||||||
|
create_parser.add_argument('--allow-diagnostic-transfer', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Allow diagnostic transfer to FNE (true/false)')
|
||||||
|
create_parser.add_argument('--allow-status-transfer', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Allow status transfer to FNE (true/false)')
|
||||||
|
|
||||||
|
# Radio ID and Talkgroup ID settings
|
||||||
|
create_parser.add_argument('--radio-id-file', help='Radio ID ACL file path')
|
||||||
|
create_parser.add_argument('--radio-id-time', type=int, help='Radio ID update time (seconds)')
|
||||||
|
create_parser.add_argument('--radio-id-acl', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Enforce Radio ID ACLs (true/false)')
|
||||||
|
create_parser.add_argument('--talkgroup-id-file', help='Talkgroup ID ACL file path')
|
||||||
|
create_parser.add_argument('--talkgroup-id-time', type=int, help='Talkgroup ID update time (seconds)')
|
||||||
|
create_parser.add_argument('--talkgroup-id-acl', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Enforce Talkgroup ID ACLs (true/false)')
|
||||||
|
|
||||||
|
# Protocol settings
|
||||||
|
create_parser.add_argument('--enable-p25', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Enable P25 protocol (true/false)')
|
||||||
|
create_parser.add_argument('--enable-dmr', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Enable DMR protocol (true/false)')
|
||||||
|
create_parser.add_argument('--enable-nxdn', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Enable NXDN protocol (true/false)')
|
||||||
|
|
||||||
|
# DMR settings
|
||||||
|
create_parser.add_argument('--color-code', type=int, help='DMR color code')
|
||||||
|
create_parser.add_argument('--dmr-net-id', type=int, help='DMR network ID')
|
||||||
|
create_parser.add_argument('--dmr-site-id', type=int, help='DMR site ID')
|
||||||
|
create_parser.add_argument('--dmr-site-model', choices=['small', 'tiny', 'large', 'huge'],
|
||||||
|
help='DMR site model')
|
||||||
|
|
||||||
|
# P25 settings
|
||||||
|
create_parser.add_argument('--nac', type=lambda x: int(x, 0), help='P25 NAC (hex or decimal)')
|
||||||
|
create_parser.add_argument('--p25-net-id', type=lambda x: int(x, 0), help='P25 network ID (hex or decimal)')
|
||||||
|
create_parser.add_argument('--p25-sys-id', type=lambda x: int(x, 0), help='P25 system ID (hex or decimal)')
|
||||||
|
create_parser.add_argument('--p25-rfss-id', type=int, help='P25 RFSS ID')
|
||||||
|
create_parser.add_argument('--p25-site-id', type=int, help='P25 site ID')
|
||||||
|
|
||||||
|
# NXDN settings
|
||||||
|
create_parser.add_argument('--nxdn-ran', type=int, help='NXDN RAN')
|
||||||
|
|
||||||
|
# Generic site ID
|
||||||
|
create_parser.add_argument('--site-id', type=int, help='Generic site ID')
|
||||||
|
|
||||||
|
# Location settings
|
||||||
|
create_parser.add_argument('--latitude', type=float, help='Location latitude')
|
||||||
|
create_parser.add_argument('--longitude', type=float, help='Location longitude')
|
||||||
|
create_parser.add_argument('--location', help='Location description')
|
||||||
|
create_parser.add_argument('--tx-power', type=int, help='TX power (watts)')
|
||||||
|
|
||||||
|
# Frequency configuration
|
||||||
|
create_parser.add_argument('--tx-freq', type=float, help='Transmit frequency in MHz')
|
||||||
|
create_parser.add_argument('--band', choices=list(BAND_PRESETS.keys()),
|
||||||
|
help='Frequency band (required with --tx-freq)')
|
||||||
|
create_parser.add_argument('--validate', action='store_true', help='Validate after creation')
|
||||||
|
create_parser.set_defaults(func=cmd_create)
|
||||||
|
|
||||||
|
# validate command
|
||||||
|
validate_parser = subparsers.add_parser('validate', help='Validate configuration')
|
||||||
|
validate_parser.add_argument('config', help='Configuration file path')
|
||||||
|
validate_parser.add_argument('--summary', '-s', action='store_true', help='Show summary')
|
||||||
|
validate_parser.set_defaults(func=cmd_validate)
|
||||||
|
|
||||||
|
# edit command
|
||||||
|
edit_parser = subparsers.add_parser('edit', help='Edit configuration value')
|
||||||
|
edit_parser.add_argument('config', help='Configuration file path')
|
||||||
|
edit_parser.add_argument('key', help='Configuration key (dot notation)')
|
||||||
|
edit_parser.add_argument('value', help='New value')
|
||||||
|
edit_parser.set_defaults(func=cmd_edit)
|
||||||
|
|
||||||
|
# trunk commands
|
||||||
|
trunk_parser = subparsers.add_parser('trunk', help='Trunking system management')
|
||||||
|
trunk_sub = trunk_parser.add_subparsers(dest='trunk_command')
|
||||||
|
|
||||||
|
# trunk create
|
||||||
|
trunk_create_parser = trunk_sub.add_parser('create', help='Create trunked system')
|
||||||
|
trunk_create_parser.add_argument('--name', default='trunked', help='System name')
|
||||||
|
trunk_create_parser.add_argument('--base-dir', required=True, help='Base directory')
|
||||||
|
trunk_create_parser.add_argument('--protocol', choices=['p25', 'dmr'], default='p25',
|
||||||
|
help='Protocol type')
|
||||||
|
trunk_create_parser.add_argument('--vc-count', type=int, default=2,
|
||||||
|
help='Number of voice channels')
|
||||||
|
trunk_create_parser.add_argument('--fne-address', default='127.0.0.1', help='FNE address')
|
||||||
|
trunk_create_parser.add_argument('--fne-port', type=int, default=62031, help='FNE port')
|
||||||
|
trunk_create_parser.add_argument('--fne-password', default='PASSWORD', help='FNE password')
|
||||||
|
trunk_create_parser.add_argument('--base-peer-id', type=int, default=100000,
|
||||||
|
help='Base peer ID')
|
||||||
|
trunk_create_parser.add_argument('--base-rpc-port', type=int, default=9890,
|
||||||
|
help='Base RPC port')
|
||||||
|
trunk_create_parser.add_argument('--identity', default='SKYNET', help='System identity')
|
||||||
|
trunk_create_parser.add_argument('--modem-type', choices=['uart', 'null'], default='uart',
|
||||||
|
help='Modem type')
|
||||||
|
|
||||||
|
# RPC/REST settings
|
||||||
|
trunk_create_parser.add_argument('--rpc-password', help='RPC password')
|
||||||
|
trunk_create_parser.add_argument('--generate-rpc-password', action='store_true',
|
||||||
|
help='Generate random RPC password')
|
||||||
|
trunk_create_parser.add_argument('--base-rest-port', type=int, help='Base REST API port')
|
||||||
|
trunk_create_parser.add_argument('--rest-enable', action='store_true', help='Enable REST API')
|
||||||
|
trunk_create_parser.add_argument('--rest-password', help='REST API password')
|
||||||
|
trunk_create_parser.add_argument('--generate-rest-password', action='store_true',
|
||||||
|
help='Generate random REST API password')
|
||||||
|
|
||||||
|
# Network lookup/transfer settings
|
||||||
|
trunk_create_parser.add_argument('--update-lookups', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Update lookups from network (true/false)')
|
||||||
|
trunk_create_parser.add_argument('--save-lookups', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Save lookups from network (true/false)')
|
||||||
|
trunk_create_parser.add_argument('--allow-activity-transfer', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Allow activity transfer to FNE (true/false)')
|
||||||
|
trunk_create_parser.add_argument('--allow-diagnostic-transfer', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Allow diagnostic transfer to FNE (true/false)')
|
||||||
|
trunk_create_parser.add_argument('--allow-status-transfer', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Allow status transfer to FNE (true/false)')
|
||||||
|
|
||||||
|
# Radio ID and Talkgroup ID settings
|
||||||
|
trunk_create_parser.add_argument('--radio-id-file', help='Radio ID ACL file path')
|
||||||
|
trunk_create_parser.add_argument('--radio-id-time', type=int, help='Radio ID update time (seconds)')
|
||||||
|
trunk_create_parser.add_argument('--radio-id-acl', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Enforce Radio ID ACLs (true/false)')
|
||||||
|
trunk_create_parser.add_argument('--talkgroup-id-file', help='Talkgroup ID ACL file path')
|
||||||
|
trunk_create_parser.add_argument('--talkgroup-id-time', type=int, help='Talkgroup ID update time (seconds)')
|
||||||
|
trunk_create_parser.add_argument('--talkgroup-id-acl', type=lambda x: x.lower() in ('true', '1', 'yes'),
|
||||||
|
help='Enforce Talkgroup ID ACLs (true/false)')
|
||||||
|
|
||||||
|
# Protocol-specific settings
|
||||||
|
trunk_create_parser.add_argument('--nac', type=lambda x: int(x, 0), default=0x293,
|
||||||
|
help='P25 NAC (hex or decimal)')
|
||||||
|
trunk_create_parser.add_argument('--p25-net-id', type=lambda x: int(x, 0),
|
||||||
|
help='P25 network ID (hex or decimal)')
|
||||||
|
trunk_create_parser.add_argument('--p25-sys-id', type=lambda x: int(x, 0),
|
||||||
|
help='P25 system ID (hex or decimal)')
|
||||||
|
trunk_create_parser.add_argument('--p25-rfss-id', type=int, help='P25 RFSS ID')
|
||||||
|
trunk_create_parser.add_argument('--p25-site-id', type=int, help='P25 site ID')
|
||||||
|
|
||||||
|
trunk_create_parser.add_argument('--color-code', type=int, default=1, help='DMR color code')
|
||||||
|
trunk_create_parser.add_argument('--dmr-net-id', type=int, help='DMR network ID')
|
||||||
|
trunk_create_parser.add_argument('--dmr-site-id', type=int, help='DMR site ID')
|
||||||
|
|
||||||
|
# Generic site ID
|
||||||
|
trunk_create_parser.add_argument('--site-id', type=int, default=1, help='Site ID')
|
||||||
|
|
||||||
|
# Frequency configuration
|
||||||
|
trunk_create_parser.add_argument('--cc-tx-freq', type=float,
|
||||||
|
help='Control channel TX frequency in MHz')
|
||||||
|
trunk_create_parser.add_argument('--cc-band', choices=list(BAND_PRESETS.keys()),
|
||||||
|
help='Control channel frequency band')
|
||||||
|
trunk_create_parser.add_argument('--vc-tx-freqs',
|
||||||
|
help='Voice channel TX frequencies (comma-separated, e.g., 851.0125,851.0250)')
|
||||||
|
trunk_create_parser.add_argument('--vc-bands',
|
||||||
|
help='Voice channel bands (comma-separated, e.g., 800mhz,800mhz)')
|
||||||
|
trunk_create_parser.set_defaults(func=cmd_trunk_create)
|
||||||
|
|
||||||
|
# trunk validate
|
||||||
|
trunk_validate_parser = trunk_sub.add_parser('validate', help='Validate trunked system')
|
||||||
|
trunk_validate_parser.add_argument('--name', default='trunked', help='System name')
|
||||||
|
trunk_validate_parser.add_argument('--base-dir', required=True, help='Base directory')
|
||||||
|
trunk_validate_parser.set_defaults(func=cmd_trunk_validate)
|
||||||
|
|
||||||
|
# trunk update
|
||||||
|
trunk_update_parser = trunk_sub.add_parser('update', help='Update all configs in system')
|
||||||
|
trunk_update_parser.add_argument('--name', default='trunked', help='System name')
|
||||||
|
trunk_update_parser.add_argument('--base-dir', required=True, help='Base directory')
|
||||||
|
trunk_update_parser.add_argument('key', help='Configuration key')
|
||||||
|
trunk_update_parser.add_argument('value', help='New value')
|
||||||
|
trunk_update_parser.set_defaults(func=cmd_trunk_update)
|
||||||
|
|
||||||
|
# templates command
|
||||||
|
templates_parser = subparsers.add_parser('templates', help='List available templates')
|
||||||
|
templates_parser.set_defaults(func=cmd_list_templates)
|
||||||
|
|
||||||
|
# wizard command
|
||||||
|
wizard_parser = subparsers.add_parser('wizard', help='Interactive configuration wizard')
|
||||||
|
wizard_parser.add_argument('--type', choices=['single', 'trunk', 'auto'], default='auto',
|
||||||
|
help='Wizard type (auto asks user)')
|
||||||
|
wizard_parser.set_defaults(func=cmd_wizard)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command is None:
|
||||||
|
# Display banner when run without command
|
||||||
|
console.print(f"[cyan]{__BANNER__}[/cyan]")
|
||||||
|
console.print(f"[bold cyan]Digital Voice Modem (DVM) Configuration Generator {__VER__}[/bold cyan]")
|
||||||
|
console.print("[dim]Copyright (c) 2026 Bryan Biedenkapp, N2PLL and DVMProject (https://github.com/dvmproject) Authors.[/dim]")
|
||||||
|
console.print()
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if hasattr(args, 'func'):
|
||||||
|
args.func(args)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@ -0,0 +1,331 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
#/**
|
||||||
|
#* Digital Voice Modem - Host Configuration Generator
|
||||||
|
#* GPLv2 Open Source. Use is subject to license terms.
|
||||||
|
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
|
||||||
|
#*
|
||||||
|
#*/
|
||||||
|
#/*
|
||||||
|
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
|
||||||
|
#*
|
||||||
|
#* This program is free software; you can redistribute it and/or modify
|
||||||
|
#* it under the terms of the GNU General Public License as published by
|
||||||
|
#* the Free Software Foundation; either version 2 of the License, or
|
||||||
|
#* (at your option) any later version.
|
||||||
|
#*
|
||||||
|
#* This program is distributed in the hope that it will be useful,
|
||||||
|
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
#* GNU General Public License for more details.
|
||||||
|
#*
|
||||||
|
#* You should have received a copy of the GNU General Public License
|
||||||
|
#* along with this program; if not, write to the Free Software
|
||||||
|
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
|
||||||
|
#*/
|
||||||
|
"""
|
||||||
|
dvmcfggen - DVMHost Configuration Generator
|
||||||
|
|
||||||
|
Identity Table (IDEN) Management
|
||||||
|
Handles frequency mapping and channel ID/number calculation
|
||||||
|
Based on iden_channel_calc.py from skynet_tools_host
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Tuple, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
MAX_FREQ_GAP = 30000000 # 30 MHz maximum offset from base frequency
|
||||||
|
HZ_MHZ = 1000000.0
|
||||||
|
KHZ_HZ = 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
class IdenEntry:
|
||||||
|
"""Represents a single identity table entry"""
|
||||||
|
|
||||||
|
def __init__(self, channel_id: int, base_freq: int, spacing: float,
|
||||||
|
input_offset: float, bandwidth: float):
|
||||||
|
"""
|
||||||
|
Initialize IDEN entry
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel_id: Channel identity (0-15)
|
||||||
|
base_freq: Base frequency in Hz
|
||||||
|
spacing: Channel spacing in kHz
|
||||||
|
input_offset: Input offset (RX-TX) in MHz
|
||||||
|
bandwidth: Channel bandwidth in kHz
|
||||||
|
"""
|
||||||
|
self.channel_id = channel_id
|
||||||
|
self.base_freq = base_freq
|
||||||
|
self.spacing = spacing
|
||||||
|
self.input_offset = input_offset
|
||||||
|
self.bandwidth = bandwidth
|
||||||
|
|
||||||
|
def to_line(self) -> str:
|
||||||
|
"""Convert to iden_table.dat format line"""
|
||||||
|
return f"{self.channel_id},{self.base_freq},{self.spacing:.2f},{self.input_offset:.5f},{self.bandwidth:.1f},"
|
||||||
|
|
||||||
|
def calculate_channel_number(self, tx_freq: int) -> int:
|
||||||
|
"""
|
||||||
|
Calculate channel number for a given transmit frequency
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tx_freq: Transmit frequency in Hz
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Channel number in hex
|
||||||
|
"""
|
||||||
|
if tx_freq < self.base_freq:
|
||||||
|
raise ValueError(f"TX frequency ({tx_freq/HZ_MHZ:.5f} MHz) is below base frequency "
|
||||||
|
f"({self.base_freq/HZ_MHZ:.5f} MHz)")
|
||||||
|
|
||||||
|
if tx_freq > (self.base_freq + MAX_FREQ_GAP):
|
||||||
|
raise ValueError(f"TX frequency ({tx_freq/HZ_MHZ:.5f} MHz) is too far above base frequency "
|
||||||
|
f"({self.base_freq/HZ_MHZ:.5f} MHz). Maximum gap is 25.5 MHz")
|
||||||
|
|
||||||
|
space_hz = int(self.spacing * KHZ_HZ)
|
||||||
|
root_freq = tx_freq - self.base_freq
|
||||||
|
ch_no = int(root_freq / space_hz)
|
||||||
|
|
||||||
|
return ch_no
|
||||||
|
|
||||||
|
def calculate_rx_frequency(self, tx_freq: int) -> int:
|
||||||
|
"""
|
||||||
|
Calculate receive frequency for a given transmit frequency
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tx_freq: Transmit frequency in Hz
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Receive frequency in Hz
|
||||||
|
"""
|
||||||
|
offset_hz = int(self.input_offset * HZ_MHZ)
|
||||||
|
rx_freq = tx_freq + offset_hz
|
||||||
|
|
||||||
|
# Note: For negative offsets (like -45 MHz on 800MHz band),
|
||||||
|
# RX will be lower than TX and may be below base freq - this is normal
|
||||||
|
# Only validate that the frequency is reasonable (above 0)
|
||||||
|
if rx_freq < 0:
|
||||||
|
raise ValueError(f"RX frequency ({rx_freq/HZ_MHZ:.5f} MHz) is invalid (negative)")
|
||||||
|
|
||||||
|
return rx_freq
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (f"IdenEntry(id={self.channel_id}, base={self.base_freq/HZ_MHZ:.3f}MHz, "
|
||||||
|
f"spacing={self.spacing}kHz, offset={self.input_offset}MHz, bw={self.bandwidth}kHz)")
|
||||||
|
|
||||||
|
|
||||||
|
class IdenTable:
|
||||||
|
"""Manages identity table (iden_table.dat)"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.entries: Dict[int, IdenEntry] = {}
|
||||||
|
|
||||||
|
def add_entry(self, entry: IdenEntry):
|
||||||
|
"""Add an identity table entry"""
|
||||||
|
if entry.channel_id < 0 or entry.channel_id > 15:
|
||||||
|
raise ValueError(f"Channel ID must be 0-15, got {entry.channel_id}")
|
||||||
|
self.entries[entry.channel_id] = entry
|
||||||
|
|
||||||
|
def get_entry(self, channel_id: int) -> Optional[IdenEntry]:
|
||||||
|
"""Get entry by channel ID"""
|
||||||
|
return self.entries.get(channel_id)
|
||||||
|
|
||||||
|
def find_entry_for_frequency(self, tx_freq: int) -> Optional[IdenEntry]:
|
||||||
|
"""
|
||||||
|
Find an identity entry that can accommodate the given TX frequency
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tx_freq: Transmit frequency in Hz
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
IdenEntry if found, None otherwise
|
||||||
|
"""
|
||||||
|
for entry in self.entries.values():
|
||||||
|
try:
|
||||||
|
# Check if this entry can accommodate the frequency
|
||||||
|
if entry.base_freq <= tx_freq <= (entry.base_freq + MAX_FREQ_GAP):
|
||||||
|
entry.calculate_channel_number(tx_freq) # Validate
|
||||||
|
return entry
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save(self, filepath: Path):
|
||||||
|
"""Save identity table to file"""
|
||||||
|
with open(filepath, 'w') as f:
|
||||||
|
f.write("#\n")
|
||||||
|
f.write("# Identity Table - Frequency Bandplan\n")
|
||||||
|
f.write("# Generated by DVMCfg\n")
|
||||||
|
f.write("#\n")
|
||||||
|
f.write("# ChId,Base Freq (Hz),Spacing (kHz),Input Offset (MHz),Bandwidth (kHz),\n")
|
||||||
|
f.write("#\n")
|
||||||
|
|
||||||
|
for ch_id in sorted(self.entries.keys()):
|
||||||
|
f.write(self.entries[ch_id].to_line() + "\n")
|
||||||
|
|
||||||
|
def load(self, filepath: Path):
|
||||||
|
"""Load identity table from file"""
|
||||||
|
self.entries.clear()
|
||||||
|
|
||||||
|
with open(filepath, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = [p.strip() for p in line.split(',') if p.strip()]
|
||||||
|
if len(parts) >= 5:
|
||||||
|
try:
|
||||||
|
entry = IdenEntry(
|
||||||
|
channel_id=int(parts[0]),
|
||||||
|
base_freq=int(parts[1]),
|
||||||
|
spacing=float(parts[2]),
|
||||||
|
input_offset=float(parts[3]),
|
||||||
|
bandwidth=float(parts[4])
|
||||||
|
)
|
||||||
|
self.add_entry(entry)
|
||||||
|
except (ValueError, IndexError) as e:
|
||||||
|
print(f"Warning: Skipping invalid line: {line} ({e})")
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.entries)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"IdenTable({len(self)} entries)"
|
||||||
|
|
||||||
|
|
||||||
|
# Common band presets
|
||||||
|
BAND_PRESETS = {
|
||||||
|
'800mhz': {
|
||||||
|
'name': '800 MHz Trunked (800T)',
|
||||||
|
'base_freq': 851006250,
|
||||||
|
'spacing': 6.25,
|
||||||
|
'input_offset': -45.0,
|
||||||
|
'bandwidth': 12.5,
|
||||||
|
'tx_range': (851.0, 870.0),
|
||||||
|
'rx_range': (806.0, 825.0),
|
||||||
|
},
|
||||||
|
'900mhz': {
|
||||||
|
'name': '900 MHz ISM/Business',
|
||||||
|
'base_freq': 935001250,
|
||||||
|
'spacing': 6.25,
|
||||||
|
'input_offset': -39.0,
|
||||||
|
'bandwidth': 12.5,
|
||||||
|
'tx_range': (935.0, 960.0),
|
||||||
|
'rx_range': (896.0, 901.0),
|
||||||
|
},
|
||||||
|
'uhf': {
|
||||||
|
'name': 'UHF 450-470 MHz',
|
||||||
|
'base_freq': 450000000,
|
||||||
|
'spacing': 6.25,
|
||||||
|
'input_offset': 5.0,
|
||||||
|
'bandwidth': 12.5,
|
||||||
|
'tx_range': (450.0, 470.0),
|
||||||
|
'rx_range': (455.0, 475.0),
|
||||||
|
},
|
||||||
|
'vhf': {
|
||||||
|
'name': 'VHF 146-148 MHz (2m Ham)',
|
||||||
|
'base_freq': 146000000,
|
||||||
|
'spacing': 6.25,
|
||||||
|
'input_offset': 0.6,
|
||||||
|
'bandwidth': 12.5,
|
||||||
|
'tx_range': (146.0, 148.0),
|
||||||
|
'rx_range': (146.6, 148.6),
|
||||||
|
},
|
||||||
|
'vhf-hi': {
|
||||||
|
'name': 'VHF-Hi 150-174 MHz',
|
||||||
|
'base_freq': 150000000,
|
||||||
|
'spacing': 6.25,
|
||||||
|
'input_offset': 0.6,
|
||||||
|
'bandwidth': 12.5,
|
||||||
|
'tx_range': (150.0, 174.0),
|
||||||
|
'rx_range': (155.0, 179.0),
|
||||||
|
},
|
||||||
|
'uhf-ham': {
|
||||||
|
'name': 'UHF 430-450 MHz (70cm Ham)',
|
||||||
|
'base_freq': 430000000,
|
||||||
|
'spacing': 6.25,
|
||||||
|
'input_offset': 5.0,
|
||||||
|
'bandwidth': 12.5,
|
||||||
|
'tx_range': (430.0, 450.0),
|
||||||
|
'rx_range': (435.0, 455.0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_iden_entry_from_preset(channel_id: int, preset: str) -> IdenEntry:
|
||||||
|
"""
|
||||||
|
Create an IdenEntry from a band preset
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel_id: Channel ID (0-15)
|
||||||
|
preset: Preset name (e.g., '800mhz', 'uhf', 'vhf')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
IdenEntry configured for the band
|
||||||
|
"""
|
||||||
|
if preset not in BAND_PRESETS:
|
||||||
|
raise ValueError(f"Unknown preset: {preset}. Available: {list(BAND_PRESETS.keys())}")
|
||||||
|
|
||||||
|
band = BAND_PRESETS[preset]
|
||||||
|
return IdenEntry(
|
||||||
|
channel_id=channel_id,
|
||||||
|
base_freq=band['base_freq'],
|
||||||
|
spacing=band['spacing'],
|
||||||
|
input_offset=band['input_offset'],
|
||||||
|
bandwidth=band['bandwidth']
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_channel_assignment(tx_freq_mhz: float, preset: str = None,
|
||||||
|
channel_id: int = None) -> Tuple[int, int, int, int]:
|
||||||
|
"""
|
||||||
|
Calculate channel ID and channel number for a given TX frequency
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tx_freq_mhz: Transmit frequency in MHz
|
||||||
|
preset: Band preset to use (optional)
|
||||||
|
channel_id: Specific channel ID to use (optional, 0-15)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (channel_id, channel_number, tx_freq_hz, rx_freq_hz)
|
||||||
|
"""
|
||||||
|
tx_freq_hz = int(tx_freq_mhz * HZ_MHZ)
|
||||||
|
|
||||||
|
# If preset specified, use it
|
||||||
|
if preset:
|
||||||
|
entry = create_iden_entry_from_preset(channel_id or 0, preset)
|
||||||
|
ch_no = entry.calculate_channel_number(tx_freq_hz)
|
||||||
|
rx_freq = entry.calculate_rx_frequency(tx_freq_hz)
|
||||||
|
return (entry.channel_id, ch_no, tx_freq_hz, rx_freq)
|
||||||
|
|
||||||
|
# Otherwise, try to find a matching preset
|
||||||
|
for preset_name, band in BAND_PRESETS.items():
|
||||||
|
if band['tx_range'][0] <= tx_freq_mhz <= band['tx_range'][1]:
|
||||||
|
entry = create_iden_entry_from_preset(channel_id or 0, preset_name)
|
||||||
|
try:
|
||||||
|
ch_no = entry.calculate_channel_number(tx_freq_hz)
|
||||||
|
rx_freq = entry.calculate_rx_frequency(tx_freq_hz)
|
||||||
|
return (entry.channel_id, ch_no, tx_freq_hz, rx_freq)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise ValueError(f"No suitable band preset found for {tx_freq_mhz} MHz. "
|
||||||
|
f"Please specify a preset or configure manually.")
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_iden_table() -> IdenTable:
|
||||||
|
"""Create a default identity table with common bands"""
|
||||||
|
table = IdenTable()
|
||||||
|
|
||||||
|
# Add common band presets with different channel IDs
|
||||||
|
table.add_entry(create_iden_entry_from_preset(0, '800mhz'))
|
||||||
|
table.add_entry(create_iden_entry_from_preset(2, 'uhf'))
|
||||||
|
table.add_entry(create_iden_entry_from_preset(3, 'vhf'))
|
||||||
|
table.add_entry(create_iden_entry_from_preset(4, 'vhf-hi'))
|
||||||
|
table.add_entry(create_iden_entry_from_preset(5, 'uhf-ham'))
|
||||||
|
table.add_entry(create_iden_entry_from_preset(15, '900mhz'))
|
||||||
|
|
||||||
|
return table
|
||||||
@ -0,0 +1,441 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
#/**
|
||||||
|
#* Digital Voice Modem - Host Configuration Generator
|
||||||
|
#* GPLv2 Open Source. Use is subject to license terms.
|
||||||
|
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
|
||||||
|
#*
|
||||||
|
#*/
|
||||||
|
#/*
|
||||||
|
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
|
||||||
|
#*
|
||||||
|
#* This program is free software; you can redistribute it and/or modify
|
||||||
|
#* it under the terms of the GNU General Public License as published by
|
||||||
|
#* the Free Software Foundation; either version 2 of the License, or
|
||||||
|
#* (at your option) any later version.
|
||||||
|
#*
|
||||||
|
#* This program is distributed in the hope that it will be useful,
|
||||||
|
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
#* GNU General Public License for more details.
|
||||||
|
#*
|
||||||
|
#* You should have received a copy of the GNU General Public License
|
||||||
|
#* along with this program; if not, write to the Free Software
|
||||||
|
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
|
||||||
|
#*/
|
||||||
|
"""
|
||||||
|
dvmcfggen - DVMHost Configuration Generator
|
||||||
|
|
||||||
|
Network and System ID validation for DVMHost protocols.
|
||||||
|
|
||||||
|
This module provides validation functions for DMR, P25, and NXDN network/system
|
||||||
|
identification parameters based on limits defined in DVMHost C++ code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Tuple, Dict, Any
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class DMRSiteModel(Enum):
|
||||||
|
"""DMR site model types (from dmr::defines::SiteModel)"""
|
||||||
|
TINY = 0 # netId: 0-0x1FF (511), siteId: 0-0x07 (7)
|
||||||
|
SMALL = 1 # netId: 0-0x7F (127), siteId: 0-0x1F (31)
|
||||||
|
LARGE = 2 # netId: 0-0x1F (31), siteId: 0-0x7F (127)
|
||||||
|
HUGE = 3 # netId: 0-0x03 (3), siteId: 0-0x3FF (1023)
|
||||||
|
|
||||||
|
|
||||||
|
# DMR Limits (from dvmhost/src/common/dmr/DMRUtils.h)
|
||||||
|
DMR_COLOR_CODE_MIN = 0
|
||||||
|
DMR_COLOR_CODE_MAX = 15
|
||||||
|
|
||||||
|
DMR_SITE_MODEL_LIMITS = {
|
||||||
|
DMRSiteModel.TINY: {
|
||||||
|
'netId': (1, 0x1FF), # 1-511
|
||||||
|
'siteId': (1, 0x07), # 1-7
|
||||||
|
},
|
||||||
|
DMRSiteModel.SMALL: {
|
||||||
|
'netId': (1, 0x7F), # 1-127
|
||||||
|
'siteId': (1, 0x1F), # 1-31
|
||||||
|
},
|
||||||
|
DMRSiteModel.LARGE: {
|
||||||
|
'netId': (1, 0x1F), # 1-31
|
||||||
|
'siteId': (1, 0x7F), # 1-127
|
||||||
|
},
|
||||||
|
DMRSiteModel.HUGE: {
|
||||||
|
'netId': (1, 0x03), # 1-3
|
||||||
|
'siteId': (1, 0x3FF), # 1-1023
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# P25 Limits (from dvmhost/src/common/p25/P25Utils.h)
|
||||||
|
P25_NAC_MIN = 0x000
|
||||||
|
P25_NAC_MAX = 0xF7F # 3967
|
||||||
|
|
||||||
|
P25_NET_ID_MIN = 1
|
||||||
|
P25_NET_ID_MAX = 0xFFFFE # 1048574
|
||||||
|
|
||||||
|
P25_SYS_ID_MIN = 1
|
||||||
|
P25_SYS_ID_MAX = 0xFFE # 4094
|
||||||
|
|
||||||
|
P25_RFSS_ID_MIN = 1
|
||||||
|
P25_RFSS_ID_MAX = 0xFE # 254
|
||||||
|
|
||||||
|
P25_SITE_ID_MIN = 1
|
||||||
|
P25_SITE_ID_MAX = 0xFE # 254
|
||||||
|
|
||||||
|
# NXDN Limits (from dvmhost/src/common/nxdn/SiteData.h and NXDNDefines.h)
|
||||||
|
NXDN_RAN_MIN = 0
|
||||||
|
NXDN_RAN_MAX = 63 # 6-bit field
|
||||||
|
|
||||||
|
NXDN_LOC_ID_MIN = 1
|
||||||
|
NXDN_LOC_ID_MAX = 0xFFFFFF # 16777215 (24-bit field)
|
||||||
|
|
||||||
|
NXDN_SITE_ID_MIN = 1
|
||||||
|
NXDN_SITE_ID_MAX = 0xFFFFFF # Same as LOC_ID
|
||||||
|
|
||||||
|
|
||||||
|
def validate_dmr_color_code(color_code: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate DMR color code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
color_code: Color code value (0-15)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not isinstance(color_code, int):
|
||||||
|
return False, "Color code must be an integer"
|
||||||
|
|
||||||
|
if color_code < DMR_COLOR_CODE_MIN or color_code > DMR_COLOR_CODE_MAX:
|
||||||
|
return False, f"Color code must be between {DMR_COLOR_CODE_MIN} and {DMR_COLOR_CODE_MAX}"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_dmr_network_id(net_id: int, site_model: DMRSiteModel) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate DMR network ID based on site model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
net_id: Network ID value
|
||||||
|
site_model: DMR site model
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not isinstance(net_id, int):
|
||||||
|
return False, "Network ID must be an integer"
|
||||||
|
|
||||||
|
limits = DMR_SITE_MODEL_LIMITS[site_model]
|
||||||
|
min_val, max_val = limits['netId']
|
||||||
|
|
||||||
|
if net_id < min_val or net_id > max_val:
|
||||||
|
return False, f"Network ID for {site_model.name} site model must be between {min_val} and {max_val} (0x{max_val:X})"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_dmr_site_id(site_id: int, site_model: DMRSiteModel) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate DMR site ID based on site model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site ID value
|
||||||
|
site_model: DMR site model
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not isinstance(site_id, int):
|
||||||
|
return False, "Site ID must be an integer"
|
||||||
|
|
||||||
|
limits = DMR_SITE_MODEL_LIMITS[site_model]
|
||||||
|
min_val, max_val = limits['siteId']
|
||||||
|
|
||||||
|
if site_id < min_val or site_id > max_val:
|
||||||
|
return False, f"Site ID for {site_model.name} site model must be between {min_val} and {max_val} (0x{max_val:X})"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_p25_nac(nac: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate P25 Network Access Code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nac: NAC value (0x000-0xF7F / 0-3967)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not isinstance(nac, int):
|
||||||
|
return False, "NAC must be an integer"
|
||||||
|
|
||||||
|
if nac < P25_NAC_MIN or nac > P25_NAC_MAX:
|
||||||
|
return False, f"NAC must be between {P25_NAC_MIN} (0x{P25_NAC_MIN:03X}) and {P25_NAC_MAX} (0x{P25_NAC_MAX:03X})"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_p25_network_id(net_id: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate P25 Network ID (WACN).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
net_id: Network ID value (1-0xFFFFE / 1-1048574)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not isinstance(net_id, int):
|
||||||
|
return False, "Network ID must be an integer"
|
||||||
|
|
||||||
|
if net_id < P25_NET_ID_MIN or net_id > P25_NET_ID_MAX:
|
||||||
|
return False, f"Network ID must be between {P25_NET_ID_MIN} and {P25_NET_ID_MAX} (0x{P25_NET_ID_MAX:X})"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_p25_system_id(sys_id: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate P25 System ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sys_id: System ID value (1-0xFFE / 1-4094)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not isinstance(sys_id, int):
|
||||||
|
return False, "System ID must be an integer"
|
||||||
|
|
||||||
|
if sys_id < P25_SYS_ID_MIN or sys_id > P25_SYS_ID_MAX:
|
||||||
|
return False, f"System ID must be between {P25_SYS_ID_MIN} and {P25_SYS_ID_MAX} (0x{P25_SYS_ID_MAX:X})"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_p25_rfss_id(rfss_id: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate P25 RFSS ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rfss_id: RFSS ID value (1-0xFE / 1-254)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not isinstance(rfss_id, int):
|
||||||
|
return False, "RFSS ID must be an integer"
|
||||||
|
|
||||||
|
if rfss_id < P25_RFSS_ID_MIN or rfss_id > P25_RFSS_ID_MAX:
|
||||||
|
return False, f"RFSS ID must be between {P25_RFSS_ID_MIN} and {P25_RFSS_ID_MAX} (0x{P25_RFSS_ID_MAX:X})"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_p25_site_id(site_id: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate P25 Site ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site ID value (1-0xFE / 1-254)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not isinstance(site_id, int):
|
||||||
|
return False, "Site ID must be an integer"
|
||||||
|
|
||||||
|
if site_id < P25_SITE_ID_MIN or site_id > P25_SITE_ID_MAX:
|
||||||
|
return False, f"Site ID must be between {P25_SITE_ID_MIN} and {P25_SITE_ID_MAX} (0x{P25_SITE_ID_MAX:X})"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_nxdn_ran(ran: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate NXDN Random Access Number.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ran: RAN value (0-63)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not isinstance(ran, int):
|
||||||
|
return False, "RAN must be an integer"
|
||||||
|
|
||||||
|
if ran < NXDN_RAN_MIN or ran > NXDN_RAN_MAX:
|
||||||
|
return False, f"RAN must be between {NXDN_RAN_MIN} and {NXDN_RAN_MAX}"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_nxdn_location_id(loc_id: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate NXDN Location ID (used as System ID).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
loc_id: Location ID value (1-0xFFFFFF / 1-16777215)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not isinstance(loc_id, int):
|
||||||
|
return False, "Location ID must be an integer"
|
||||||
|
|
||||||
|
if loc_id < NXDN_LOC_ID_MIN or loc_id > NXDN_LOC_ID_MAX:
|
||||||
|
return False, f"Location ID must be between {NXDN_LOC_ID_MIN} and {NXDN_LOC_ID_MAX} (0x{NXDN_LOC_ID_MAX:X})"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_nxdn_site_id(site_id: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate NXDN Site ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site ID value (1-0xFFFFFF / 1-16777215)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
return validate_nxdn_location_id(site_id) # Same limits
|
||||||
|
|
||||||
|
|
||||||
|
def get_dmr_site_model_from_string(model_str: str) -> DMRSiteModel:
|
||||||
|
"""
|
||||||
|
Convert site model string to enum.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_str: Site model string ('tiny', 'small', 'large', 'huge')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DMRSiteModel enum value
|
||||||
|
"""
|
||||||
|
model_map = {
|
||||||
|
'tiny': DMRSiteModel.TINY,
|
||||||
|
'small': DMRSiteModel.SMALL,
|
||||||
|
'large': DMRSiteModel.LARGE,
|
||||||
|
'huge': DMRSiteModel.HUGE,
|
||||||
|
}
|
||||||
|
return model_map.get(model_str.lower(), DMRSiteModel.SMALL)
|
||||||
|
|
||||||
|
|
||||||
|
def get_dmr_site_model_name(model: DMRSiteModel) -> str:
|
||||||
|
"""Get friendly name for DMR site model."""
|
||||||
|
return model.name.capitalize()
|
||||||
|
|
||||||
|
|
||||||
|
def get_network_id_info(protocol: str, site_model: str = 'small') -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get network ID parameter information for a protocol.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
protocol: Protocol name ('dmr', 'p25', 'nxdn')
|
||||||
|
site_model: DMR site model for DMR protocol
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with parameter info
|
||||||
|
"""
|
||||||
|
protocol = protocol.lower()
|
||||||
|
|
||||||
|
if protocol == 'dmr':
|
||||||
|
model = get_dmr_site_model_from_string(site_model)
|
||||||
|
limits = DMR_SITE_MODEL_LIMITS[model]
|
||||||
|
return {
|
||||||
|
'colorCode': {
|
||||||
|
'min': DMR_COLOR_CODE_MIN,
|
||||||
|
'max': DMR_COLOR_CODE_MAX,
|
||||||
|
'default': 1,
|
||||||
|
'description': 'DMR Color Code',
|
||||||
|
},
|
||||||
|
'dmrNetId': {
|
||||||
|
'min': limits['netId'][0],
|
||||||
|
'max': limits['netId'][1],
|
||||||
|
'default': 1,
|
||||||
|
'description': f'DMR Network ID ({model.name} site model)',
|
||||||
|
},
|
||||||
|
'siteId': {
|
||||||
|
'min': limits['siteId'][0],
|
||||||
|
'max': limits['siteId'][1],
|
||||||
|
'default': 1,
|
||||||
|
'description': f'DMR Site ID ({model.name} site model)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
elif protocol == 'p25':
|
||||||
|
return {
|
||||||
|
'nac': {
|
||||||
|
'min': P25_NAC_MIN,
|
||||||
|
'max': P25_NAC_MAX,
|
||||||
|
'default': 0x293, # 659 decimal (common default)
|
||||||
|
'description': 'P25 Network Access Code (NAC)',
|
||||||
|
},
|
||||||
|
'netId': {
|
||||||
|
'min': P25_NET_ID_MIN,
|
||||||
|
'max': P25_NET_ID_MAX,
|
||||||
|
'default': 0xBB800, # 768000 decimal
|
||||||
|
'description': 'P25 Network ID (WACN)',
|
||||||
|
},
|
||||||
|
'sysId': {
|
||||||
|
'min': P25_SYS_ID_MIN,
|
||||||
|
'max': P25_SYS_ID_MAX,
|
||||||
|
'default': 0x001,
|
||||||
|
'description': 'P25 System ID',
|
||||||
|
},
|
||||||
|
'rfssId': {
|
||||||
|
'min': P25_RFSS_ID_MIN,
|
||||||
|
'max': P25_RFSS_ID_MAX,
|
||||||
|
'default': 1,
|
||||||
|
'description': 'P25 RFSS (RF Sub-System) ID',
|
||||||
|
},
|
||||||
|
'siteId': {
|
||||||
|
'min': P25_SITE_ID_MIN,
|
||||||
|
'max': P25_SITE_ID_MAX,
|
||||||
|
'default': 1,
|
||||||
|
'description': 'P25 Site ID',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
elif protocol == 'nxdn':
|
||||||
|
return {
|
||||||
|
'ran': {
|
||||||
|
'min': NXDN_RAN_MIN,
|
||||||
|
'max': NXDN_RAN_MAX,
|
||||||
|
'default': 1,
|
||||||
|
'description': 'NXDN Random Access Number (RAN)',
|
||||||
|
},
|
||||||
|
'sysId': {
|
||||||
|
'min': NXDN_LOC_ID_MIN,
|
||||||
|
'max': NXDN_LOC_ID_MAX,
|
||||||
|
'default': 0x001,
|
||||||
|
'description': 'NXDN System ID (Location ID)',
|
||||||
|
},
|
||||||
|
'siteId': {
|
||||||
|
'min': NXDN_SITE_ID_MIN,
|
||||||
|
'max': NXDN_SITE_ID_MAX,
|
||||||
|
'default': 1,
|
||||||
|
'description': 'NXDN Site ID',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def format_hex_or_decimal(value: int, max_value: int) -> str:
|
||||||
|
"""
|
||||||
|
Format value as hex if it makes sense, otherwise decimal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Value to format
|
||||||
|
max_value: Maximum value for the field
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string
|
||||||
|
"""
|
||||||
|
if max_value > 999:
|
||||||
|
return f"{value} (0x{value:X})"
|
||||||
|
return str(value)
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
pyyaml>=6.0
|
||||||
|
textual>=0.47.0
|
||||||
|
rich>=13.0.0
|
||||||
@ -0,0 +1,284 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
#/**
|
||||||
|
#* Digital Voice Modem - Host Configuration Generator
|
||||||
|
#* GPLv2 Open Source. Use is subject to license terms.
|
||||||
|
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
|
||||||
|
#*
|
||||||
|
#*/
|
||||||
|
#/*
|
||||||
|
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
|
||||||
|
#*
|
||||||
|
#* This program is free software; you can redistribute it and/or modify
|
||||||
|
#* it under the terms of the GNU General Public License as published by
|
||||||
|
#* the Free Software Foundation; either version 2 of the License, or
|
||||||
|
#* (at your option) any later version.
|
||||||
|
#*
|
||||||
|
#* This program is distributed in the hope that it will be useful,
|
||||||
|
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
#* GNU General Public License for more details.
|
||||||
|
#*
|
||||||
|
#* You should have received a copy of the GNU General Public License
|
||||||
|
#* along with this program; if not, write to the Free Software
|
||||||
|
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
|
||||||
|
#*/
|
||||||
|
"""
|
||||||
|
dvmcfggen - DVMHost Configuration Generator
|
||||||
|
|
||||||
|
Pre-configured templates loaded from dvmhost config.example.yml
|
||||||
|
Ensures all configuration parameters are present
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
import secrets
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# Possible locations for config.example.yml
|
||||||
|
CONFIG_EXAMPLE_PATHS = [
|
||||||
|
Path(__file__).parent.parent / 'configs' / 'config.example.yml',
|
||||||
|
Path('/opt/dvm/config.example.yml'),
|
||||||
|
Path('./config.example.yml'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def find_config_example() -> Optional[Path]:
|
||||||
|
"""Find the config.example.yml file from dvmhost"""
|
||||||
|
for path in CONFIG_EXAMPLE_PATHS:
|
||||||
|
if path.exists():
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_base_config() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Load the base configuration from config.example.yml
|
||||||
|
Falls back to minimal config if not found
|
||||||
|
"""
|
||||||
|
example_path = find_config_example()
|
||||||
|
|
||||||
|
if example_path:
|
||||||
|
try:
|
||||||
|
with open(example_path, 'r') as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
if config:
|
||||||
|
return config
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not load {example_path}: {e}")
|
||||||
|
|
||||||
|
# Fallback to minimal config if example not found
|
||||||
|
print("Warning: config.example.yml not found, using minimal fallback config")
|
||||||
|
return get_minimal_fallback_config()
|
||||||
|
|
||||||
|
|
||||||
|
def get_minimal_fallback_config() -> Dict[str, Any]:
|
||||||
|
"""Minimal fallback configuration if config.example.yml is not available"""
|
||||||
|
return {
|
||||||
|
'daemon': True,
|
||||||
|
'log': {
|
||||||
|
'displayLevel': 1,
|
||||||
|
'fileLevel': 1,
|
||||||
|
'filePath': '.',
|
||||||
|
'activityFilePath': '.',
|
||||||
|
'fileRoot': 'DVM'
|
||||||
|
},
|
||||||
|
'network': {
|
||||||
|
'enable': True,
|
||||||
|
'id': 100000,
|
||||||
|
'address': '127.0.0.1',
|
||||||
|
'port': 62031,
|
||||||
|
'password': 'PASSWORD',
|
||||||
|
'rpcAddress': '127.0.0.1',
|
||||||
|
'rpcPort': 9890,
|
||||||
|
'rpcPassword': 'ULTRA-VERY-SECURE-DEFAULT',
|
||||||
|
},
|
||||||
|
'system': {
|
||||||
|
'identity': 'DVM',
|
||||||
|
'timeout': 180,
|
||||||
|
'duplex': True,
|
||||||
|
'modeHang': 10,
|
||||||
|
'rfTalkgroupHang': 10,
|
||||||
|
'activeTickDelay': 5,
|
||||||
|
'idleTickDelay': 5,
|
||||||
|
'info': {
|
||||||
|
'latitude': 0.0,
|
||||||
|
'longitude': 0.0,
|
||||||
|
'height': 1,
|
||||||
|
'power': 0,
|
||||||
|
'location': 'Anywhere'
|
||||||
|
},
|
||||||
|
'config': {
|
||||||
|
'authoritative': True,
|
||||||
|
'supervisor': False,
|
||||||
|
'channelId': 1,
|
||||||
|
'channelNo': 1,
|
||||||
|
'serviceClass': 1,
|
||||||
|
'siteId': 1,
|
||||||
|
'dmrNetId': 1,
|
||||||
|
'dmrColorCode': 1,
|
||||||
|
'p25NAC': 0x293,
|
||||||
|
'p25NetId': 0xBB800,
|
||||||
|
'nxdnRAN': 1
|
||||||
|
},
|
||||||
|
'cwId': {
|
||||||
|
'enable': True,
|
||||||
|
'time': 10,
|
||||||
|
'callsign': 'MYCALL'
|
||||||
|
},
|
||||||
|
'modem': {
|
||||||
|
'protocol': {
|
||||||
|
'type': 'uart',
|
||||||
|
'uart': {
|
||||||
|
'port': '/dev/ttyUSB0',
|
||||||
|
'speed': 115200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'rxLevel': 50,
|
||||||
|
'txLevel': 50,
|
||||||
|
'rxDCOffset': 0,
|
||||||
|
'txDCOffset': 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'protocols': {
|
||||||
|
'dmr': {
|
||||||
|
'enable': True,
|
||||||
|
'control': {
|
||||||
|
'enable': True,
|
||||||
|
'dedicated': False
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'p25': {
|
||||||
|
'enable': True,
|
||||||
|
'control': {
|
||||||
|
'enable': True,
|
||||||
|
'dedicated': False
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'nxdn': {
|
||||||
|
'enable': False,
|
||||||
|
'control': {
|
||||||
|
'enable': False,
|
||||||
|
'dedicated': False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_secure_key(length: int = 64) -> str:
|
||||||
|
"""Generate a secure random hex key"""
|
||||||
|
return secrets.token_hex(length // 2).upper()
|
||||||
|
|
||||||
|
|
||||||
|
def apply_template_customizations(config: Dict[str, Any], template_type: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Apply template-specific customizations to the base config
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Base configuration dict
|
||||||
|
template_type: Template name (repeater, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Customized configuration dict
|
||||||
|
"""
|
||||||
|
# Make a deep copy to avoid modifying the original
|
||||||
|
import copy
|
||||||
|
config = copy.deepcopy(config)
|
||||||
|
|
||||||
|
if template_type == 'control-channel-p25':
|
||||||
|
# P25 dedicated control channel
|
||||||
|
config['system']['duplex'] = True
|
||||||
|
config['system']['identity'] = 'CC-P25'
|
||||||
|
config['system']['config']['authoritative'] = True
|
||||||
|
config['system']['config']['supervisor'] = True
|
||||||
|
config['protocols']['dmr']['enable'] = False
|
||||||
|
config['protocols']['p25']['enable'] = True
|
||||||
|
config['protocols']['nxdn']['enable'] = False
|
||||||
|
config['protocols']['p25']['control']['enable'] = True
|
||||||
|
config['protocols']['p25']['control']['dedicated'] = True
|
||||||
|
|
||||||
|
elif template_type == 'control-channel-dmr':
|
||||||
|
# DMR dedicated control channel
|
||||||
|
config['system']['duplex'] = True
|
||||||
|
config['system']['identity'] = 'CC-DMR'
|
||||||
|
config['system']['config']['authoritative'] = True
|
||||||
|
config['system']['config']['supervisor'] = True
|
||||||
|
config['protocols']['dmr']['enable'] = True
|
||||||
|
config['protocols']['p25']['enable'] = False
|
||||||
|
config['protocols']['nxdn']['enable'] = False
|
||||||
|
config['protocols']['dmr']['control']['enable'] = True
|
||||||
|
config['protocols']['dmr']['control']['dedicated'] = True
|
||||||
|
|
||||||
|
elif template_type == 'voice-channel':
|
||||||
|
# Voice channel for trunking
|
||||||
|
config['system']['duplex'] = True
|
||||||
|
config['system']['identity'] = 'VC'
|
||||||
|
config['system']['config']['authoritative'] = False
|
||||||
|
config['system']['config']['supervisor'] = False
|
||||||
|
config['protocols']['dmr']['enable'] = True
|
||||||
|
config['protocols']['p25']['enable'] = True
|
||||||
|
config['protocols']['nxdn']['enable'] = False
|
||||||
|
config['protocols']['dmr']['control']['enable'] = False
|
||||||
|
config['protocols']['dmr']['control']['dedicated'] = False
|
||||||
|
config['protocols']['p25']['control']['enable'] = False
|
||||||
|
config['protocols']['p25']['control']['dedicated'] = False
|
||||||
|
|
||||||
|
elif template_type == 'enhanced':
|
||||||
|
# Enhanced conventional repeater with grants
|
||||||
|
config['system']['duplex'] = True
|
||||||
|
config['system']['identity'] = 'CONV'
|
||||||
|
config['protocols']['dmr']['enable'] = True
|
||||||
|
config['protocols']['p25']['enable'] = True
|
||||||
|
config['protocols']['nxdn']['enable'] = False
|
||||||
|
config['protocols']['dmr']['control']['enable'] = True
|
||||||
|
config['protocols']['dmr']['control']['dedicated'] = False
|
||||||
|
config['protocols']['p25']['control']['enable'] = True
|
||||||
|
config['protocols']['p25']['control']['dedicated'] = False
|
||||||
|
|
||||||
|
elif template_type == 'conventional':
|
||||||
|
# Conventional repeater
|
||||||
|
config['system']['duplex'] = True
|
||||||
|
config['system']['identity'] = 'RPT'
|
||||||
|
config['protocols']['dmr']['enable'] = False
|
||||||
|
config['protocols']['p25']['enable'] = True
|
||||||
|
config['protocols']['nxdn']['enable'] = False
|
||||||
|
config['protocols']['dmr']['control']['enable'] = False
|
||||||
|
config['protocols']['dmr']['control']['dedicated'] = False
|
||||||
|
config['protocols']['p25']['control']['enable'] = True
|
||||||
|
config['protocols']['p25']['control']['dedicated'] = False
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def get_template(template_name: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get a configuration template by name
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template_name: Name of the template
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Complete configuration dictionary with all parameters
|
||||||
|
"""
|
||||||
|
if template_name not in TEMPLATES:
|
||||||
|
raise ValueError(f"Unknown template: {template_name}")
|
||||||
|
|
||||||
|
# Load base config from config.example.yml
|
||||||
|
base_config = load_base_config()
|
||||||
|
|
||||||
|
# Apply template-specific customizations
|
||||||
|
customized_config = apply_template_customizations(base_config, template_name)
|
||||||
|
|
||||||
|
return customized_config
|
||||||
|
|
||||||
|
|
||||||
|
# Available templates
|
||||||
|
TEMPLATES = {
|
||||||
|
'conventional': 'Conventional repeater/hotspot',
|
||||||
|
'enhanced': 'Enhanced conventional repeater/hotspot with grants',
|
||||||
|
'control-channel-p25': 'P25 dedicated control channel for trunking',
|
||||||
|
'control-channel-dmr': 'DMR dedicated control channel for trunking',
|
||||||
|
'voice-channel': 'Voice channel for trunking system',
|
||||||
|
}
|
||||||
@ -0,0 +1,497 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
#/**
|
||||||
|
#* Digital Voice Modem - Host Configuration Generator
|
||||||
|
#* GPLv2 Open Source. Use is subject to license terms.
|
||||||
|
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
|
||||||
|
#*
|
||||||
|
#*/
|
||||||
|
#/*
|
||||||
|
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
|
||||||
|
#*
|
||||||
|
#* This program is free software; you can redistribute it and/or modify
|
||||||
|
#* it under the terms of the GNU General Public License as published by
|
||||||
|
#* the Free Software Foundation; either version 2 of the License, or
|
||||||
|
#* (at your option) any later version.
|
||||||
|
#*
|
||||||
|
#* This program is distributed in the hope that it will be useful,
|
||||||
|
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
#* GNU General Public License for more details.
|
||||||
|
#*
|
||||||
|
#* You should have received a copy of the GNU General Public License
|
||||||
|
#* along with this program; if not, write to the Free Software
|
||||||
|
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
|
||||||
|
#*/
|
||||||
|
"""
|
||||||
|
dvmcfggen - DVMHost Configuration Generator
|
||||||
|
|
||||||
|
Multi-Instance Trunking Manager
|
||||||
|
Manages complete trunked systems with control and voice channels
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
import yaml
|
||||||
|
from config_manager import DVMConfig
|
||||||
|
from templates import get_template
|
||||||
|
|
||||||
|
|
||||||
|
class TrunkingSystem:
|
||||||
|
"""Manages a complete trunked system configuration"""
|
||||||
|
|
||||||
|
def __init__(self, base_dir: Path, system_name: str = "trunked"):
|
||||||
|
"""
|
||||||
|
Initialize trunking system manager
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_dir: Base directory for configuration files
|
||||||
|
system_name: Name of the trunking system
|
||||||
|
"""
|
||||||
|
self.base_dir = Path(base_dir)
|
||||||
|
self.system_name = system_name
|
||||||
|
self.cc_config: Optional[DVMConfig] = None
|
||||||
|
self.vc_configs: List[DVMConfig] = []
|
||||||
|
|
||||||
|
def create_system(self,
|
||||||
|
protocol: str = 'p25',
|
||||||
|
vc_count: int = 2,
|
||||||
|
fne_address: str = '127.0.0.1',
|
||||||
|
fne_port: int = 62031,
|
||||||
|
fne_password: str = 'PASSWORD',
|
||||||
|
base_peer_id: int = 100000,
|
||||||
|
base_rpc_port: int = 9890,
|
||||||
|
rpc_password: str = 'PASSWORD',
|
||||||
|
base_rest_port: int = 8080,
|
||||||
|
rest_password: str = None,
|
||||||
|
update_lookups: bool = True,
|
||||||
|
save_lookups: bool = True,
|
||||||
|
allow_activity_transfer: bool = True,
|
||||||
|
allow_diagnostic_transfer: bool = False,
|
||||||
|
allow_status_transfer: bool = True,
|
||||||
|
radio_id_file: str = None,
|
||||||
|
radio_id_time: int = 0,
|
||||||
|
radio_id_acl: bool = False,
|
||||||
|
talkgroup_id_file: str = None,
|
||||||
|
talkgroup_id_time: int = 0,
|
||||||
|
talkgroup_id_acl: bool = False,
|
||||||
|
system_identity: str = 'SKYNET',
|
||||||
|
nac: int = 0x293,
|
||||||
|
color_code: int = 1,
|
||||||
|
site_id: int = 1,
|
||||||
|
modem_type: str = 'uart',
|
||||||
|
cc_dfsi_rtrt: int = None,
|
||||||
|
cc_dfsi_jitter: int = None,
|
||||||
|
cc_dfsi_call_timeout: int = None,
|
||||||
|
cc_dfsi_full_duplex: bool = None,
|
||||||
|
cc_channel_id: int = 0,
|
||||||
|
cc_channel_no: int = 0,
|
||||||
|
dmr_net_id: int = None,
|
||||||
|
net_id: int = None,
|
||||||
|
sys_id: int = None,
|
||||||
|
rfss_id: int = None,
|
||||||
|
ran: int = None,
|
||||||
|
vc_channels: List[Dict[str, int]] = None) -> None:
|
||||||
|
"""
|
||||||
|
Create a complete trunked system
|
||||||
|
|
||||||
|
Args:
|
||||||
|
protocol: Protocol type ('p25' or 'dmr')
|
||||||
|
vc_count: Number of voice channels
|
||||||
|
fne_address: FNE address
|
||||||
|
fne_port: FNE port
|
||||||
|
fne_password: FNE password
|
||||||
|
base_peer_id: Base peer ID (CC gets this, VCs increment)
|
||||||
|
base_rpc_port: Base RPC port (CC gets this, VCs increment)
|
||||||
|
rpc_password: RPC password
|
||||||
|
base_rest_port: Base REST API port (CC gets this, VCs increment)
|
||||||
|
rest_password: REST API password
|
||||||
|
update_lookups: Update lookups from network
|
||||||
|
save_lookups: Save lookups to network
|
||||||
|
allow_activity_transfer: Allow activity transfer to FNE
|
||||||
|
allow_diagnostic_transfer: Allow diagnostic transfer to FNE
|
||||||
|
allow_status_transfer: Allow status transfer to FNE
|
||||||
|
radio_id_file: Radio ID ACL file path
|
||||||
|
radio_id_time: Radio ID update time (seconds)
|
||||||
|
radio_id_acl: Enforce Radio ID ACLs
|
||||||
|
talkgroup_id_file: Talkgroup ID ACL file path
|
||||||
|
talkgroup_id_time: Talkgroup ID update time (seconds)
|
||||||
|
talkgroup_id_acl: Enforce Talkgroup ID ACLs
|
||||||
|
system_identity: System identity prefix
|
||||||
|
nac: P25 NAC (for P25 systems)
|
||||||
|
color_code: DMR color code (for DMR systems)
|
||||||
|
site_id: Site ID
|
||||||
|
modem_type: Modem type ('uart' or 'null')
|
||||||
|
cc_dfsi_rtrt: Control channel DFSI RTRT (Round Trip Response Time, ms)
|
||||||
|
cc_dfsi_jitter: Control channel DFSI Jitter (ms)
|
||||||
|
cc_dfsi_call_timeout: Control channel DFSI Call Timeout (seconds)
|
||||||
|
cc_dfsi_full_duplex: Control channel DFSI Full Duplex enabled
|
||||||
|
cc_channel_id: Control channel ID
|
||||||
|
cc_channel_no: Control channel number
|
||||||
|
dmr_net_id: DMR Network ID (for DMR systems)
|
||||||
|
net_id: P25 Network ID (for P25 systems)
|
||||||
|
sys_id: P25 System ID (for P25 systems)
|
||||||
|
rfss_id: P25 RFSS ID (for P25 systems)
|
||||||
|
ran: NXDN RAN (for NXDN systems)
|
||||||
|
vc_channels: List of voice channel configs with optional DFSI settings [{'channel_id': int, 'channel_no': int, 'dfsi_rtrt': int, ...}, ...]
|
||||||
|
"""
|
||||||
|
|
||||||
|
# If no VC channel info provided, use defaults (same ID as CC, sequential numbers)
|
||||||
|
if vc_channels is None:
|
||||||
|
vc_channels = []
|
||||||
|
for i in range(1, vc_count + 1):
|
||||||
|
vc_channels.append({
|
||||||
|
'channel_id': cc_channel_id,
|
||||||
|
'channel_no': cc_channel_no + i
|
||||||
|
})
|
||||||
|
# Create base directory
|
||||||
|
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create control channel
|
||||||
|
print(f"Creating control channel configuration...")
|
||||||
|
if protocol.lower() == 'p25':
|
||||||
|
cc_template = get_template('control-channel-p25')
|
||||||
|
else:
|
||||||
|
cc_template = get_template('control-channel-dmr')
|
||||||
|
|
||||||
|
self.cc_config = DVMConfig()
|
||||||
|
self.cc_config.config = cc_template
|
||||||
|
|
||||||
|
# Configure control channel
|
||||||
|
self.cc_config.set('system.identity', f'{system_identity}-CC')
|
||||||
|
self.cc_config.set('network.id', base_peer_id)
|
||||||
|
self.cc_config.set('network.address', fne_address)
|
||||||
|
self.cc_config.set('network.port', fne_port)
|
||||||
|
self.cc_config.set('network.password', fne_password)
|
||||||
|
self.cc_config.set('network.rpcPort', base_rpc_port)
|
||||||
|
self.cc_config.set('network.rpcPassword', rpc_password)
|
||||||
|
if rest_password is not None:
|
||||||
|
self.cc_config.set('network.restEnable', True)
|
||||||
|
self.cc_config.set('network.restPort', base_rest_port)
|
||||||
|
self.cc_config.set('network.restPassword', rest_password)
|
||||||
|
else:
|
||||||
|
self.cc_config.set('network.restEnable', False)
|
||||||
|
|
||||||
|
# Network lookup and transfer settings
|
||||||
|
self.cc_config.set('network.updateLookups', update_lookups)
|
||||||
|
self.cc_config.set('network.saveLookups', save_lookups)
|
||||||
|
self.cc_config.set('network.allowActivityTransfer', allow_activity_transfer)
|
||||||
|
self.cc_config.set('network.allowDiagnosticTransfer', allow_diagnostic_transfer)
|
||||||
|
self.cc_config.set('network.allowStatusTransfer', allow_status_transfer)
|
||||||
|
|
||||||
|
# If updating lookups, set time values to 0
|
||||||
|
if update_lookups:
|
||||||
|
self.cc_config.set('system.radio_id.time', 0)
|
||||||
|
self.cc_config.set('system.talkgroup_id.time', 0)
|
||||||
|
else:
|
||||||
|
# If not updating lookups, use provided values
|
||||||
|
if radio_id_time > 0:
|
||||||
|
self.cc_config.set('system.radio_id.time', radio_id_time)
|
||||||
|
if talkgroup_id_time > 0:
|
||||||
|
self.cc_config.set('system.talkgroup_id.time', talkgroup_id_time)
|
||||||
|
|
||||||
|
# Radio ID and Talkgroup ID ACL settings
|
||||||
|
if radio_id_file:
|
||||||
|
self.cc_config.set('system.radio_id.file', radio_id_file)
|
||||||
|
self.cc_config.set('system.radio_id.acl', radio_id_acl)
|
||||||
|
|
||||||
|
if talkgroup_id_file:
|
||||||
|
self.cc_config.set('system.talkgroup_id.file', talkgroup_id_file)
|
||||||
|
self.cc_config.set('system.talkgroup_id.acl', talkgroup_id_acl)
|
||||||
|
|
||||||
|
self.cc_config.set('system.modem.protocol.type', modem_type)
|
||||||
|
|
||||||
|
# DFSI Configuration for control channel (if provided)
|
||||||
|
if cc_dfsi_rtrt is not None:
|
||||||
|
self.cc_config.set('system.modem.dfsiRtrt', cc_dfsi_rtrt)
|
||||||
|
if cc_dfsi_jitter is not None:
|
||||||
|
self.cc_config.set('system.modem.dfsiJitter', cc_dfsi_jitter)
|
||||||
|
if cc_dfsi_call_timeout is not None:
|
||||||
|
self.cc_config.set('system.modem.dfsiCallTimeout', cc_dfsi_call_timeout)
|
||||||
|
if cc_dfsi_full_duplex is not None:
|
||||||
|
self.cc_config.set('system.modem.dfsiFullDuplex', cc_dfsi_full_duplex)
|
||||||
|
|
||||||
|
self.cc_config.set('system.config.channelId', cc_channel_id)
|
||||||
|
self.cc_config.set('system.config.channelNo', cc_channel_no)
|
||||||
|
|
||||||
|
# Set network/system IDs consistently
|
||||||
|
if site_id is not None:
|
||||||
|
self.cc_config.set('system.config.siteId', site_id)
|
||||||
|
if nac is not None:
|
||||||
|
self.cc_config.set('system.config.nac', nac)
|
||||||
|
if color_code is not None:
|
||||||
|
self.cc_config.set('system.config.colorCode', color_code)
|
||||||
|
if dmr_net_id is not None:
|
||||||
|
self.cc_config.set('system.config.dmrNetId', dmr_net_id)
|
||||||
|
if net_id is not None:
|
||||||
|
self.cc_config.set('system.config.netId', net_id)
|
||||||
|
if sys_id is not None:
|
||||||
|
self.cc_config.set('system.config.sysId', sys_id)
|
||||||
|
if rfss_id is not None:
|
||||||
|
self.cc_config.set('system.config.rfssId', rfss_id)
|
||||||
|
if ran is not None:
|
||||||
|
self.cc_config.set('system.config.ran', ran)
|
||||||
|
|
||||||
|
# Build voice channel list for CC
|
||||||
|
voice_channels = []
|
||||||
|
for i in range(vc_count):
|
||||||
|
vc_rpc_port = base_rpc_port + i + 1
|
||||||
|
vc_info = vc_channels[i]
|
||||||
|
voice_channels.append({
|
||||||
|
'channelId': vc_info['channel_id'],
|
||||||
|
'channelNo': vc_info['channel_no'],
|
||||||
|
'rpcAddress': '127.0.0.1',
|
||||||
|
'rpcPort': vc_rpc_port,
|
||||||
|
'rpcPassword': fne_password
|
||||||
|
})
|
||||||
|
|
||||||
|
self.cc_config.set('system.config.voiceChNo', voice_channels)
|
||||||
|
|
||||||
|
# Save control channel config
|
||||||
|
cc_path = self.base_dir / f'{self.system_name}-cc.yml'
|
||||||
|
self.cc_config.save(cc_path)
|
||||||
|
print(f" Saved: {cc_path}")
|
||||||
|
|
||||||
|
# Create voice channels
|
||||||
|
print(f"\nCreating {vc_count} voice channel configurations...")
|
||||||
|
for i in range(vc_count):
|
||||||
|
vc_index = i + 1
|
||||||
|
vc_peer_id = base_peer_id + vc_index
|
||||||
|
vc_rpc_port = base_rpc_port + vc_index
|
||||||
|
vc_rest_port = base_rest_port + vc_index if rest_password else None
|
||||||
|
vc_info = vc_channels[i]
|
||||||
|
channel_id = vc_info['channel_id']
|
||||||
|
channel_no = vc_info['channel_no']
|
||||||
|
|
||||||
|
vc_template = get_template('voice-channel')
|
||||||
|
|
||||||
|
vc_config = DVMConfig()
|
||||||
|
vc_config.config = vc_template
|
||||||
|
|
||||||
|
# Configure voice channel
|
||||||
|
vc_config.set('system.identity', f'{system_identity}-VC{vc_index:02d}')
|
||||||
|
vc_config.set('network.id', vc_peer_id)
|
||||||
|
vc_config.set('network.address', fne_address)
|
||||||
|
vc_config.set('network.port', fne_port)
|
||||||
|
vc_config.set('network.password', fne_password)
|
||||||
|
vc_config.set('network.rpcPort', vc_rpc_port)
|
||||||
|
vc_config.set('network.rpcPassword', rpc_password)
|
||||||
|
if rest_password is not None:
|
||||||
|
vc_config.set('network.restEnable', True)
|
||||||
|
vc_config.set('network.restPort', vc_rest_port)
|
||||||
|
vc_config.set('network.restPassword', rest_password)
|
||||||
|
else:
|
||||||
|
vc_config.set('network.restEnable', False)
|
||||||
|
|
||||||
|
# Network lookup and transfer settings
|
||||||
|
vc_config.set('network.updateLookups', update_lookups)
|
||||||
|
vc_config.set('network.saveLookups', save_lookups)
|
||||||
|
vc_config.set('network.allowActivityTransfer', allow_activity_transfer)
|
||||||
|
vc_config.set('network.allowDiagnosticTransfer', allow_diagnostic_transfer)
|
||||||
|
vc_config.set('network.allowStatusTransfer', allow_status_transfer)
|
||||||
|
|
||||||
|
# If updating lookups, set time values to 0
|
||||||
|
if update_lookups:
|
||||||
|
vc_config.set('system.radio_id.time', 0)
|
||||||
|
vc_config.set('system.talkgroup_id.time', 0)
|
||||||
|
else:
|
||||||
|
# If not updating lookups, use provided values
|
||||||
|
if radio_id_time > 0:
|
||||||
|
vc_config.set('system.radio_id.time', radio_id_time)
|
||||||
|
if talkgroup_id_time > 0:
|
||||||
|
vc_config.set('system.talkgroup_id.time', talkgroup_id_time)
|
||||||
|
|
||||||
|
# Radio ID and Talkgroup ID ACL settings
|
||||||
|
if radio_id_file:
|
||||||
|
vc_config.set('system.radio_id.file', radio_id_file)
|
||||||
|
vc_config.set('system.radio_id.acl', radio_id_acl)
|
||||||
|
|
||||||
|
if talkgroup_id_file:
|
||||||
|
vc_config.set('system.talkgroup_id.file', talkgroup_id_file)
|
||||||
|
vc_config.set('system.talkgroup_id.acl', talkgroup_id_acl)
|
||||||
|
|
||||||
|
vc_config.set('system.config.channelId', channel_id)
|
||||||
|
vc_config.set('system.config.channelNo', channel_no)
|
||||||
|
vc_config.set('system.modem.protocol.type', modem_type)
|
||||||
|
|
||||||
|
# DFSI Configuration for voice channel (if provided)
|
||||||
|
if 'dfsi_rtrt' in vc_info and vc_info['dfsi_rtrt'] is not None:
|
||||||
|
vc_config.set('system.modem.dfsiRtrt', vc_info['dfsi_rtrt'])
|
||||||
|
if 'dfsi_jitter' in vc_info and vc_info['dfsi_jitter'] is not None:
|
||||||
|
vc_config.set('system.modem.dfsiJitter', vc_info['dfsi_jitter'])
|
||||||
|
if 'dfsi_call_timeout' in vc_info and vc_info['dfsi_call_timeout'] is not None:
|
||||||
|
vc_config.set('system.modem.dfsiCallTimeout', vc_info['dfsi_call_timeout'])
|
||||||
|
if 'dfsi_full_duplex' in vc_info and vc_info['dfsi_full_duplex'] is not None:
|
||||||
|
vc_config.set('system.modem.dfsiFullDuplex', vc_info['dfsi_full_duplex'])
|
||||||
|
|
||||||
|
# Set network/system IDs consistently (same as CC)
|
||||||
|
if site_id is not None:
|
||||||
|
vc_config.set('system.config.siteId', site_id)
|
||||||
|
if nac is not None:
|
||||||
|
vc_config.set('system.config.nac', nac)
|
||||||
|
if color_code is not None:
|
||||||
|
vc_config.set('system.config.colorCode', color_code)
|
||||||
|
if dmr_net_id is not None:
|
||||||
|
vc_config.set('system.config.dmrNetId', dmr_net_id)
|
||||||
|
if net_id is not None:
|
||||||
|
vc_config.set('system.config.netId', net_id)
|
||||||
|
if sys_id is not None:
|
||||||
|
vc_config.set('system.config.sysId', sys_id)
|
||||||
|
if rfss_id is not None:
|
||||||
|
vc_config.set('system.config.rfssId', rfss_id)
|
||||||
|
if ran is not None:
|
||||||
|
vc_config.set('system.config.ran', ran)
|
||||||
|
|
||||||
|
# Ensure protocol consistency
|
||||||
|
if protocol.lower() == 'p25':
|
||||||
|
vc_config.set('protocols.p25.enable', True)
|
||||||
|
vc_config.set('protocols.dmr.enable', False)
|
||||||
|
else:
|
||||||
|
vc_config.set('protocols.dmr.enable', True)
|
||||||
|
vc_config.set('protocols.p25.enable', False)
|
||||||
|
|
||||||
|
# Configure control channel reference for voice channel
|
||||||
|
vc_config.set('system.config.controlCh.rpcAddress', '127.0.0.1')
|
||||||
|
vc_config.set('system.config.controlCh.rpcPort', base_rpc_port)
|
||||||
|
vc_config.set('system.config.controlCh.rpcPassword', fne_password)
|
||||||
|
vc_config.set('system.config.controlCh.notifyEnable', True)
|
||||||
|
|
||||||
|
self.vc_configs.append(vc_config)
|
||||||
|
|
||||||
|
# Save voice channel config
|
||||||
|
vc_path = self.base_dir / f'{self.system_name}-vc{vc_index:02d}.yml'
|
||||||
|
vc_config.save(vc_path)
|
||||||
|
print(f" Saved: {vc_path}")
|
||||||
|
|
||||||
|
print(f"\nTrunked system '{self.system_name}' created successfully!")
|
||||||
|
print(f" Control Channel: {cc_path}")
|
||||||
|
print(f" Voice Channels: {vc_count}")
|
||||||
|
print(f" Base Directory: {self.base_dir}")
|
||||||
|
|
||||||
|
def load_system(self) -> None:
|
||||||
|
"""Load existing trunked system"""
|
||||||
|
cc_path = self.base_dir / f'{self.system_name}-cc.yml'
|
||||||
|
if not cc_path.exists():
|
||||||
|
raise FileNotFoundError(f"Control channel config not found: {cc_path}")
|
||||||
|
|
||||||
|
self.cc_config = DVMConfig(cc_path)
|
||||||
|
|
||||||
|
# Load voice channels
|
||||||
|
self.vc_configs = []
|
||||||
|
i = 1
|
||||||
|
while True:
|
||||||
|
vc_path = self.base_dir / f'{self.system_name}-vc{i:02d}.yml'
|
||||||
|
if not vc_path.exists():
|
||||||
|
break
|
||||||
|
|
||||||
|
vc_config = DVMConfig(vc_path)
|
||||||
|
self.vc_configs.append(vc_config)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
def validate_system(self) -> Dict[str, List[str]]:
|
||||||
|
"""
|
||||||
|
Validate entire trunked system
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping config files to error lists
|
||||||
|
"""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
# Validate control channel
|
||||||
|
cc_errors = self.cc_config.validate()
|
||||||
|
if cc_errors:
|
||||||
|
errors['control_channel'] = cc_errors
|
||||||
|
|
||||||
|
# Validate voice channels
|
||||||
|
for i, vc in enumerate(self.vc_configs, 1):
|
||||||
|
vc_errors = vc.validate()
|
||||||
|
if vc_errors:
|
||||||
|
errors[f'voice_channel_{i}'] = vc_errors
|
||||||
|
|
||||||
|
# Check consistency
|
||||||
|
consistency_errors = self._check_consistency()
|
||||||
|
if consistency_errors:
|
||||||
|
errors['consistency'] = consistency_errors
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def _check_consistency(self) -> List[str]:
|
||||||
|
"""Check consistency across all configs"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if not self.cc_config or not self.vc_configs:
|
||||||
|
return errors
|
||||||
|
|
||||||
|
# Get reference values from CC
|
||||||
|
cc_nac = self.cc_config.get('system.config.nac')
|
||||||
|
cc_color_code = self.cc_config.get('system.config.colorCode')
|
||||||
|
cc_site_id = self.cc_config.get('system.config.siteId')
|
||||||
|
cc_net_id = self.cc_config.get('system.config.netId')
|
||||||
|
cc_dmr_net_id = self.cc_config.get('system.config.dmrNetId')
|
||||||
|
cc_sys_id = self.cc_config.get('system.config.sysId')
|
||||||
|
cc_rfss_id = self.cc_config.get('system.config.rfssId')
|
||||||
|
cc_ran = self.cc_config.get('system.config.ran')
|
||||||
|
cc_fne = self.cc_config.get('network.address')
|
||||||
|
cc_fne_port = self.cc_config.get('network.port')
|
||||||
|
|
||||||
|
# Check each voice channel
|
||||||
|
for i, vc in enumerate(self.vc_configs, 1):
|
||||||
|
if cc_nac is not None and vc.get('system.config.nac') != cc_nac:
|
||||||
|
errors.append(f"VC{i:02d}: NAC mismatch (expected {cc_nac})")
|
||||||
|
|
||||||
|
if cc_color_code is not None and vc.get('system.config.colorCode') != cc_color_code:
|
||||||
|
errors.append(f"VC{i:02d}: Color code mismatch (expected {cc_color_code})")
|
||||||
|
|
||||||
|
if cc_site_id is not None and vc.get('system.config.siteId') != cc_site_id:
|
||||||
|
errors.append(f"VC{i:02d}: Site ID mismatch (expected {cc_site_id})")
|
||||||
|
|
||||||
|
if cc_net_id is not None and vc.get('system.config.netId') != cc_net_id:
|
||||||
|
errors.append(f"VC{i:02d}: Network ID mismatch (expected {cc_net_id})")
|
||||||
|
|
||||||
|
if cc_dmr_net_id is not None and vc.get('system.config.dmrNetId') != cc_dmr_net_id:
|
||||||
|
errors.append(f"VC{i:02d}: DMR Network ID mismatch (expected {cc_dmr_net_id})")
|
||||||
|
|
||||||
|
if cc_sys_id is not None and vc.get('system.config.sysId') != cc_sys_id:
|
||||||
|
errors.append(f"VC{i:02d}: System ID mismatch (expected {cc_sys_id})")
|
||||||
|
|
||||||
|
if cc_rfss_id is not None and vc.get('system.config.rfssId') != cc_rfss_id:
|
||||||
|
errors.append(f"VC{i:02d}: RFSS ID mismatch (expected {cc_rfss_id})")
|
||||||
|
|
||||||
|
if cc_ran is not None and vc.get('system.config.ran') != cc_ran:
|
||||||
|
errors.append(f"VC{i:02d}: RAN mismatch (expected {cc_ran})")
|
||||||
|
|
||||||
|
if vc.get('network.address') != cc_fne:
|
||||||
|
errors.append(f"VC{i:02d}: FNE address mismatch (expected {cc_fne})")
|
||||||
|
|
||||||
|
if vc.get('network.port') != cc_fne_port:
|
||||||
|
errors.append(f"VC{i:02d}: FNE port mismatch (expected {cc_fne_port})")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def update_all(self, key_path: str, value: Any) -> None:
|
||||||
|
"""Update a setting across all configs"""
|
||||||
|
self.cc_config.set(key_path, value)
|
||||||
|
for vc in self.vc_configs:
|
||||||
|
vc.set(key_path, value)
|
||||||
|
|
||||||
|
def save_all(self) -> None:
|
||||||
|
"""Save all configurations"""
|
||||||
|
cc_path = self.base_dir / f'{self.system_name}-cc.yml'
|
||||||
|
self.cc_config.save(cc_path)
|
||||||
|
|
||||||
|
for i, vc in enumerate(self.vc_configs, 1):
|
||||||
|
vc_path = self.base_dir / f'{self.system_name}-vc{i:02d}.yml'
|
||||||
|
vc.save(vc_path)
|
||||||
|
|
||||||
|
self._create_summary()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# Test
|
||||||
|
system = TrunkingSystem(Path('./test_trunk'), 'skynet')
|
||||||
|
system.create_system(
|
||||||
|
protocol='p25',
|
||||||
|
vc_count=2,
|
||||||
|
system_identity='SKYNET',
|
||||||
|
base_peer_id=100000
|
||||||
|
)
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
#/**
|
||||||
|
#* Digital Voice Modem - Host Configuration Generator
|
||||||
|
#* GPLv2 Open Source. Use is subject to license terms.
|
||||||
|
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
|
||||||
|
#*
|
||||||
|
#*/
|
||||||
|
#/*
|
||||||
|
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
|
||||||
|
#*
|
||||||
|
#* This program is free software; you can redistribute it and/or modify
|
||||||
|
#* it under the terms of the GNU General Public License as published by
|
||||||
|
#* the Free Software Foundation; either version 2 of the License, or
|
||||||
|
#* (at your option) any later version.
|
||||||
|
#*
|
||||||
|
#* This program is distributed in the hope that it will be useful,
|
||||||
|
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
#* GNU General Public License for more details.
|
||||||
|
#*
|
||||||
|
#* You should have received a copy of the GNU General Public License
|
||||||
|
#* along with this program; if not, write to the Free Software
|
||||||
|
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
|
||||||
|
#*/
|
||||||
|
"""
|
||||||
|
dvmcfggen - DVMHost Configuration Generator
|
||||||
|
|
||||||
|
Version and banner constants
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Version information
|
||||||
|
VERSION_MAJOR = "05"
|
||||||
|
VERSION_MINOR = "04"
|
||||||
|
VERSION_REV = "A"
|
||||||
|
__VER__ = f"{VERSION_MAJOR}.{VERSION_MINOR}{VERSION_REV} (R{VERSION_MAJOR}{VERSION_REV}{VERSION_MINOR})"
|
||||||
|
|
||||||
|
# ASCII Banner
|
||||||
|
__BANNER__ = """
|
||||||
|
. .
|
||||||
|
8 888888888o. `8.`888b ,8' ,8. ,8.
|
||||||
|
8 8888 `^888. `8.`888b ,8' ,888. ,888.
|
||||||
|
8 8888 `88.`8.`888b ,8' .`8888. .`8888.
|
||||||
|
8 8888 `88 `8.`888b ,8' ,8.`8888. ,8.`8888.
|
||||||
|
8 8888 88 `8.`888b ,8' ,8'8.`8888,8^8.`8888.
|
||||||
|
8 8888 88 `8.`888b ,8' ,8' `8.`8888' `8.`8888.
|
||||||
|
8 8888 ,88 `8.`888b8' ,8' `8.`88' `8.`8888.
|
||||||
|
8 8888 ,88' `8.`888' ,8' `8.`' `8.`8888.
|
||||||
|
8 8888 ,o88P' `8.`8' ,8' `8 `8.`8888.
|
||||||
|
8 888888888P' `8.` ,8' ` `8.`8888.
|
||||||
|
"""
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue