You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dvmhost/tools/dvmcfggen/wizard.py

1978 lines
76 KiB

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
#/**
#* Digital Voice Modem - Host Configuration Generator
#* GPLv2 Open Source. Use is subject to license terms.
#* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
#*
#*/
#/*
#* Copyright (C) 2026 by Bryan Biedenkapp N2PLL
#*
#* This program is free software; you can redistribute it and/or modify
#* it under the terms of the GNU General Public License as published by
#* the Free Software Foundation; either version 2 of the License, or
#* (at your option) any later version.
#*
#* This program is distributed in the hope that it will be useful,
#* but WITHOUT ANY WARRANTY; without even the implied warranty of
#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
#* GNU General Public License for more details.
#*
#* You should have received a copy of the GNU General Public License
#* along with this program; if not, write to the Free Software
#* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
#*/
"""
dvmcfggen - DVMHost Configuration Generator
Interactive Configuration Wizard
Step-by-step guided configuration creation
"""
from pathlib import Path
from typing import Optional, Any, Dict, Tuple
import secrets
import string
from rich.console import Console
from rich.prompt import Prompt, Confirm, IntPrompt
from rich.panel import Panel
from rich.table import Table
from rich import print as rprint
from config_manager import DVMConfig, ConfigValidator
from templates import get_template, TEMPLATES
from trunking_manager import TrunkingSystem
from answers_loader import AnswersLoader
from iden_table import (IdenTable, create_default_iden_table, calculate_channel_assignment,
BAND_PRESETS, create_iden_entry_from_preset)
from network_ids import (
get_network_id_info, validate_dmr_color_code, validate_dmr_network_id, validate_dmr_site_id,
validate_p25_nac, validate_p25_network_id, validate_p25_system_id, validate_p25_rfss_id,
validate_p25_site_id, validate_nxdn_ran, validate_nxdn_location_id,
DMRSiteModel, get_dmr_site_model_from_string, format_hex_or_decimal
)
from version import __BANNER__, __VER__
console = Console()
validator = ConfigValidator()
def generate_random_password(length: int = 24) -> str:
"""Generate a random password using letters, digits, and special characters"""
chars = string.ascii_letters + string.digits + "!@#$%^&*-_=+"
return ''.join(secrets.choice(chars) for _ in range(length))
class ConfigWizard:
"""Interactive configuration wizard"""
def __init__(self, answers: Optional[Dict[str, Any]] = None):
self.answers = answers or {}
self.config: Optional[DVMConfig] = None
self.template_name: Optional[str] = None
self.iden_table: IdenTable = create_default_iden_table()
self.iden_file_path: Optional[Path] = None
self.config_dir: str = "."
self.rpc_password: Optional[str] = None
self.rest_password: Optional[str] = None
def _get_answer(self, key: str, prompt_func, *args, **kwargs) -> Any:
"""
Get answer from answers file or prompt user
Args:
key: Answer key to look up
prompt_func: Function to call if answer not found (e.g., Prompt.ask)
*args: Arguments to pass to prompt_func
**kwargs: Keyword arguments to pass to prompt_func
Returns:
Answer value (from file or user prompt)
"""
if key in self.answers:
value = self.answers[key]
# Display the value that was loaded from answers file
if isinstance(value, bool):
display_value = "Yes" if value else "No"
else:
display_value = str(value)
console.print(f"[dim]{key}: {display_value}[/dim]")
return value
# Prompt user for input
return prompt_func(*args, **kwargs)
def run(self) -> Optional[Path]:
"""Run the interactive wizard"""
# Don't clear or show banner here - run_wizard handles it
console.print()
console.print(Panel.fit(
"[bold cyan]Conventional System Configuration Wizard[/bold cyan]\n"
"Create a conventional system configuration",
border_style="cyan"
))
console.print()
# Step 1: Choose template
self.template_name = self._choose_template()
if not self.template_name:
return None
console.print()
# Step 2: Create base config
self.config = DVMConfig()
self.config.config = get_template(self.template_name)
# Step 3: Collect configuration
if self.template_name in ['conventional', 'enhanced']:
return self._configure_single_instance()
elif 'control-channel' in self.template_name or 'voice-channel' in self.template_name:
console.print("[yellow]For trunked systems, use the trunking wizard instead.[/yellow]")
if Confirm.ask("Start trunking wizard?"):
return self._run_trunking_wizard()
return None
return None
def _choose_template(self) -> Optional[str]:
"""Choose configuration template"""
console.print("[bold]Step 1: Choose Configuration Template[/bold]\n")
table = Table(show_header=True, header_style="bold cyan")
table.add_column("#", style="dim", width=3)
table.add_column("Template", style="cyan")
table.add_column("Type", style="yellow")
table.add_column("Description", style="white")
templates = [
('conventional', 'Single', 'Conventional repeater/hotspot'),
('enhanced', 'Single', 'Enhanced conventional repeater/hotspot with grants'),
]
for i, (name, type_, desc) in enumerate(templates, 1):
table.add_row(str(i), name, type_, desc)
console.print(table)
console.print()
choice = IntPrompt.ask(
"Select template",
choices=[str(i) for i in range(1, len(templates) + 1)],
default="2" # Enhanced template
)
return templates[int(choice) - 1][0]
def _configure_single_instance(self) -> Optional[Path]:
"""Configure a single instance"""
console.print("\n[bold]Step 2: Basic Configuration[/bold]\n")
# Configuration directory
self.config_dir = self._get_answer(
'config_dir',
Prompt.ask,
"Configuration directory",
default="."
)
# System Identity
identity = self._get_answer(
'system_identity',
Prompt.ask,
"System identity (callsign or site name)",
default="SITE001"
)
self.config.set('system.identity', identity)
self.config.set('system.cwId.callsign', identity[:8]) # Truncate for CW
# Peer ID
peer_id = self._get_answer(
'network_peer_id',
IntPrompt.ask,
"Network peer ID",
default=100000
)
self.config.set('network.id', peer_id)
# Logging Configuration
console.print("\n[bold]Step 3: Logging Configuration[/bold]\n")
configure_logging = self._get_answer(
'configure_logging',
Confirm.ask,
"Configure logging settings?",
default=False
)
if configure_logging:
# Log file path
log_path = self._get_answer(
'log_path',
Prompt.ask,
"Log file directory",
default="."
)
self.config.set('log.filePath', log_path)
# Activity log file path
activity_log_path = self._get_answer(
'activity_log_path',
Prompt.ask,
"Activity log directory",
default="."
)
self.config.set('log.activityFilePath', activity_log_path)
# Log file root/prefix
log_root = self._get_answer(
'log_root',
Prompt.ask,
"Log filename prefix (used for filenames and syslog)",
default="DVM"
)
self.config.set('log.fileRoot', log_root)
# Syslog usage
use_syslog = self._get_answer(
'use_syslog',
Confirm.ask,
"Enable syslog output?",
default=False
)
self.config.set('log.useSysLog', use_syslog)
# Disable non-authoritive logging
disable_non_auth = self._get_answer(
'disable_non_auth_logging',
Confirm.ask,
"Disable non-authoritative logging?",
default=False
)
self.config.set('log.disableNonAuthoritiveLogging', disable_non_auth)
console.print("\n[bold]Step 4: Modem Configuration[/bold]\n")
# Modem Type
console.print("Modem type:")
console.print(" uart - Serial UART (most common)")
console.print(" null - Test mode (no hardware)\n")
modem_type = self._get_answer(
'modem_type',
Prompt.ask,
"Modem type",
choices=['uart', 'null'],
default='uart'
)
self.config.set('system.modem.protocol.type', modem_type)
# Modem Mode
console.print("\nModem mode:")
console.print(" air - Standard air interface (repeater/hotspot)")
console.print(" dfsi - DFSI mode for interfacing with V.24 repeaters\n")
modem_mode = self._get_answer(
'modem_mode',
Prompt.ask,
"Modem mode",
choices=['air', 'dfsi'],
default='air'
)
self.config.set('system.modem.protocol.mode', modem_mode)
if modem_type == 'uart':
serial_port = self._get_answer(
'serial_port',
Prompt.ask,
"Serial port",
default="/dev/ttyUSB0"
)
self.config.set('system.modem.protocol.uart.port', serial_port)
console.print("\n[dim]Leave at 50 unless you know specific values[/dim]")
rx_level = self._get_answer(
'rx_level',
IntPrompt.ask,
"RX level (0-100)",
default=50
)
self.config.set('system.modem.rxLevel', rx_level)
tx_level = self._get_answer(
'tx_level',
IntPrompt.ask,
"TX level (0-100)",
default=50
)
self.config.set('system.modem.txLevel', tx_level)
# DFSI Configuration (if dfsi mode selected)
if modem_mode == 'dfsi':
console.print("\n[bold]Step 3b: DFSI Settings[/bold]\n")
if self._get_answer('configure_dfsi', Confirm.ask, "Configure DFSI settings?", default=False):
rtrt = self._get_answer(
'dfsi_rtrt',
Confirm.ask,
"Enable DFSI RT/RT?",
default=True
)
self.config.set('system.modem.dfsiRtrt', rtrt)
jitter = self._get_answer(
'dfsi_jitter',
IntPrompt.ask,
"DFSI Jitter (ms)",
default=200
)
self.config.set('system.modem.dfsiJitter', jitter)
call_timeout = self._get_answer(
'dfsi_call_timeout',
IntPrompt.ask,
"DFSI Call Timeout (seconds)",
default=200
)
self.config.set('system.modem.dfsiCallTimeout', call_timeout)
full_duplex = self._get_answer(
'dfsi_full_duplex',
Confirm.ask,
"Enable DFSI Full Duplex?",
default=False
)
self.config.set('system.modem.dfsiFullDuplex', full_duplex)
console.print("\n[bold]Step 5: Network Settings[/bold]\n")
# FNE Settings
fne_address = self._get_answer(
'fne_address',
Prompt.ask,
"FNE address",
default="127.0.0.1"
)
while not validator.validate_ip_address(fne_address):
console.print("[red]Invalid IP address[/red]")
fne_address = self._get_answer(
'fne_address',
Prompt.ask,
"FNE address",
default="127.0.0.1"
)
self.config.set('network.address', fne_address)
fne_port = self._get_answer(
'fne_port',
IntPrompt.ask,
"FNE port",
default=62031
)
self.config.set('network.port', fne_port)
fne_password = self._get_answer(
'fne_password',
Prompt.ask,
"FNE password",
default="PASSWORD",
password=True
)
self.config.set('network.password', fne_password)
# RPC Configuration
console.print("\n[bold]Step 5a: RPC & REST API Configuration[/bold]\n")
# Generate random RPC password
rpc_password = generate_random_password()
console.print(f"[cyan]Generated RPC password:[/cyan] {rpc_password}")
self.config.set('network.rpcPassword', rpc_password)
# Store for display in summary
self.rpc_password = rpc_password
# Ask about REST API
if self._get_answer(
'rest_enable',
Confirm.ask,
"Enable REST API?",
default=False
):
self.config.set('network.restEnable', True)
rest_password = generate_random_password()
console.print(f"[cyan]Generated REST API password:[/cyan] {rest_password}")
self.config.set('network.restPassword', rest_password)
self.rest_password = rest_password
else:
self.config.set('network.restEnable', False)
self.rest_password = None
# Network Lookup and Transfer Settings
console.print("\n[bold]Step 5b: Network Lookup & Transfer Settings[/bold]\n")
update_lookups = self._get_answer(
'update_lookups',
Confirm.ask,
"Update lookups from network?",
default=True
)
self.config.set('network.updateLookups', update_lookups)
if update_lookups:
self.config.set('system.radio_id.time', 0)
self.config.set('system.talkgroup_id.time', 0)
save_lookups = self._get_answer(
'save_lookups',
Confirm.ask,
"Save lookups from network?",
default=False
)
self.config.set('network.saveLookups', save_lookups)
allow_activity = self._get_answer(
'allow_activity_transfer',
Confirm.ask,
"Allow activity transfer to FNE?",
default=True
)
self.config.set('network.allowActivityTransfer', allow_activity)
allow_diagnostic = self._get_answer(
'allow_diagnostic_transfer',
Confirm.ask,
"Allow diagnostic transfer to FNE?",
default=False
)
self.config.set('network.allowDiagnosticTransfer', allow_diagnostic)
allow_status = self._get_answer(
'allow_status_transfer',
Confirm.ask,
"Allow status transfer to FNE?",
default=True)
self.config.set('network.allowStatusTransfer', allow_status)
# Radio ID and Talkgroup ID Configuration
console.print("\n[bold]Step 5c: Radio ID & Talkgroup ID Configuration[/bold]\n")
if self._get_answer(
'configure_radio_talkgroup_ids',
Confirm.ask,
"Configure Radio ID and Talkgroup ID settings?",
default=False
):
# Radio ID Configuration
console.print("\n[dim]Radio ID Settings:[/dim]")
radio_id_file = self._get_answer(
'radio_id_file',
Prompt.ask,
"Radio ID ACL file (path, or leave empty to skip)",
default=""
)
if radio_id_file:
self.config.set('system.radio_id.file', radio_id_file)
radio_id_time = self._get_answer(
'radio_id_time',
IntPrompt.ask,
"Radio ID update time (seconds, 0 = disabled)",
default=0
)
self.config.set('system.radio_id.time', radio_id_time)
radio_id_acl = self._get_answer(
'radio_id_acl',
Confirm.ask,
"Enforce Radio ID ACLs?",
default=False
)
self.config.set('system.radio_id.acl', radio_id_acl)
# Talkgroup ID Configuration
console.print("\n[dim]Talkgroup ID Settings:[/dim]")
talkgroup_id_file = self._get_answer(
'talkgroup_id_file',
Prompt.ask,
"Talkgroup ID ACL file (path, or leave empty to skip)",
default=""
)
if talkgroup_id_file:
self.config.set('system.talkgroup_id.file', talkgroup_id_file)
talkgroup_id_time = self._get_answer(
'talkgroup_id_time',
IntPrompt.ask,
"Talkgroup ID update time (seconds, 0 = disabled)",
default=0
)
self.config.set('system.talkgroup_id.time', talkgroup_id_time)
talkgroup_id_acl = self._get_answer(
'talkgroup_id_acl',
Confirm.ask,
"Enforce Talkgroup ID ACLs?",
default=False
)
self.config.set('system.talkgroup_id.acl', talkgroup_id_acl)
# Protocol Selection
console.print("\n[bold]Step 6: Protocol Selection[/bold]\n")
console.print("[yellow]⚠ WARNING:[/yellow] Hotspots only support a [bold]single[/bold] digital mode.")
console.print("[yellow] Multi-mode is only supported on repeaters.\n[/yellow]")
console.print("Select primary digital mode:")
console.print(" 1. P25")
console.print(" 2. DMR")
console.print(" 3. NXDN\n")
primary_mode = self._get_answer(
'primary_mode',
IntPrompt.ask,
"Primary mode",
choices=['1', '2', '3'],
default='1'
)
# Set primary mode
enable_p25 = (int(primary_mode) == 1)
enable_dmr = (int(primary_mode) == 2)
enable_nxdn = (int(primary_mode) == 3)
self.config.set('protocols.p25.enable', enable_p25)
self.config.set('protocols.dmr.enable', enable_dmr)
self.config.set('protocols.nxdn.enable', enable_nxdn)
# Ask about additional modes (for repeaters)
console.print("\n[dim]Additional modes (for multi-mode repeaters only):[/dim]")
if not enable_p25:
if self._get_answer(
'also_enable_p25',
Confirm.ask,
"Also enable P25?",
default=False
):
enable_p25 = True
self.config.set('protocols.p25.enable', True)
if not enable_dmr:
if self._get_answer(
'also_enable_dmr',
Confirm.ask,
"Also enable DMR?",
default=False
):
enable_dmr = True
self.config.set('protocols.dmr.enable', True)
if not enable_nxdn:
if self._get_answer(
'also_enable_nxdn',
Confirm.ask,
"Also enable NXDN?",
default=False
):
enable_nxdn = True
self.config.set('protocols.nxdn.enable', True)
# Radio Parameters
console.print("\n[bold]Step 7: Network/System ID Configuration[/bold]\n")
# DMR Configuration
if enable_dmr:
console.print("[cyan]DMR Configuration:[/cyan]")
# DMR Site Model (affects netId and siteId ranges)
console.print("\nDMR Site Model (affects Network ID and Site ID ranges):")
console.print(" 1. SMALL - Most common (NetID: 1-127, SiteID: 1-31)")
console.print(" 2. TINY - Large NetID range (NetID: 1-511, SiteID: 1-7)")
console.print(" 3. LARGE - Large SiteID range (NetID: 1-31, SiteID: 1-127)")
console.print(" 4. HUGE - Very large SiteID (NetID: 1-3, SiteID: 1-1023)")
site_model_choice = self._get_answer(
'dmr_site_model',
IntPrompt.ask,
"Select site model",
default=1
)
site_model_map = {1: 'small', 2: 'tiny', 3: 'large', 4: 'huge'}
site_model_str = site_model_map.get(site_model_choice, 'small')
site_model = get_dmr_site_model_from_string(site_model_str)
dmr_info = get_network_id_info('dmr', site_model_str)
# Color Code
color_code = self._get_answer(
'dmr_color_code',
IntPrompt.ask,
f"DMR Color Code ({dmr_info['colorCode']['min']}-{dmr_info['colorCode']['max']})",
default=dmr_info['colorCode']['default']
)
while True:
valid, error = validate_dmr_color_code(color_code)
if valid:
break
console.print(f"[red]{error}[/red]")
color_code = IntPrompt.ask("DMR Color Code", default=1)
self.config.set('system.config.colorCode', color_code)
# DMR Network ID
dmr_net_id = self._get_answer(
'dmr_network_id',
IntPrompt.ask,
f"DMR Network ID ({dmr_info['dmrNetId']['min']}-{dmr_info['dmrNetId']['max']})",
default=dmr_info['dmrNetId']['default']
)
while True:
valid, error = validate_dmr_network_id(dmr_net_id, site_model)
if valid:
break
console.print(f"[red]{error}[/red]")
dmr_net_id = IntPrompt.ask("DMR Network ID", default=1)
self.config.set('system.config.dmrNetId', dmr_net_id)
# DMR Site ID
dmr_site_id = self._get_answer(
'dmr_site_id',
IntPrompt.ask,
f"DMR Site ID ({dmr_info['siteId']['min']}-{dmr_info['siteId']['max']})",
default=dmr_info['siteId']['default']
)
while True:
valid, error = validate_dmr_site_id(dmr_site_id, site_model)
if valid:
break
console.print(f"[red]{error}[/red]")
dmr_site_id = IntPrompt.ask("DMR Site ID", default=1)
self.config.set('system.config.siteId', dmr_site_id)
# P25 Configuration
if enable_p25:
console.print("\n[cyan]P25 Configuration:[/cyan]")
p25_info = get_network_id_info('p25')
# NAC
nac_str = self._get_answer(
'p25_nac',
Prompt.ask,
f"P25 NAC (hex: 0x000-0x{p25_info['nac']['max']:03X}, decimal: {p25_info['nac']['min']}-{p25_info['nac']['max']})",
default=f"0x{p25_info['nac']['default']:03X}"
)
while True:
try:
nac = int(nac_str, 0)
valid, error = validate_p25_nac(nac)
if valid:
break
console.print(f"[red]{error}[/red]")
nac_str = Prompt.ask("P25 NAC", default="0x293")
except ValueError:
console.print("[red]Invalid format. Use hex (0x293) or decimal (659)[/red]")
nac_str = Prompt.ask("P25 NAC", default="0x293")
self.config.set('system.config.nac', nac)
# Network ID (WACN)
net_id_str = self._get_answer(
'p25_network_id',
Prompt.ask,
f"P25 Network ID/WACN (hex: 0x1-0x{p25_info['netId']['max']:X}, decimal: {p25_info['netId']['min']}-{p25_info['netId']['max']})",
default=f"0x{p25_info['netId']['default']:X}"
)
while True:
try:
net_id = int(net_id_str, 0)
valid, error = validate_p25_network_id(net_id)
if valid:
break
console.print(f"[red]{error}[/red]")
net_id_str = Prompt.ask("P25 Network ID", default="0xBB800")
except ValueError:
console.print("[red]Invalid format. Use hex (0xBB800) or decimal (768000)[/red]")
net_id_str = Prompt.ask("P25 Network ID", default="0xBB800")
self.config.set('system.config.netId', net_id)
# System ID
sys_id_str = self._get_answer(
'p25_system_id',
Prompt.ask,
f"P25 System ID (hex: 0x1-0x{p25_info['sysId']['max']:X}, decimal: {p25_info['sysId']['min']}-{p25_info['sysId']['max']})",
default=f"0x{p25_info['sysId']['default']:03X}"
)
while True:
try:
sys_id = int(sys_id_str, 0)
valid, error = validate_p25_system_id(sys_id)
if valid:
break
console.print(f"[red]{error}[/red]")
sys_id_str = Prompt.ask("P25 System ID", default="0x001")
except ValueError:
console.print("[red]Invalid format. Use hex (0x001) or decimal (1)[/red]")
sys_id_str = Prompt.ask("P25 System ID", default="0x001")
self.config.set('system.config.sysId', sys_id)
# RFSS ID
rfss_id = self._get_answer(
'p25_rfss_id',
IntPrompt.ask,
f"P25 RFSS ID ({p25_info['rfssId']['min']}-{p25_info['rfssId']['max']})",
default=p25_info['rfssId']['default']
)
while True:
valid, error = validate_p25_rfss_id(rfss_id)
if valid:
break
console.print(f"[red]{error}[/red]")
rfss_id = IntPrompt.ask("P25 RFSS ID", default=1)
self.config.set('system.config.rfssId', rfss_id)
# P25 Site ID
p25_site_id = self._get_answer(
'p25_site_id',
IntPrompt.ask,
f"P25 Site ID ({p25_info['siteId']['min']}-{p25_info['siteId']['max']})",
default=p25_info['siteId']['default']
)
while True:
valid, error = validate_p25_site_id(p25_site_id)
if valid:
break
console.print(f"[red]{error}[/red]")
p25_site_id = IntPrompt.ask("P25 Site ID", default=1)
# Note: siteId is shared, only set if DMR didn't set it
if not enable_dmr:
self.config.set('system.config.siteId', p25_site_id)
# NXDN Configuration
if enable_nxdn:
console.print("\n[cyan]NXDN Configuration:[/cyan]")
nxdn_info = get_network_id_info('nxdn')
# RAN
ran = self._get_answer(
'nxdn_ran',
IntPrompt.ask,
f"NXDN RAN ({nxdn_info['ran']['min']}-{nxdn_info['ran']['max']})",
default=nxdn_info['ran']['default']
)
while True:
valid, error = validate_nxdn_ran(ran)
if valid:
break
console.print(f"[red]{error}[/red]")
ran = IntPrompt.ask("NXDN RAN", default=1)
self.config.set('system.config.ran', ran)
# System ID (Location ID)
nxdn_sys_id_str = self._get_answer(
'nxdn_system_id',
Prompt.ask,
f"NXDN System ID (hex: 0x1-0x{nxdn_info['sysId']['max']:X}, decimal: {nxdn_info['sysId']['min']}-{nxdn_info['sysId']['max']})",
default=f"0x{nxdn_info['sysId']['default']:03X}"
)
while True:
try:
nxdn_sys_id = int(nxdn_sys_id_str, 0)
valid, error = validate_nxdn_location_id(nxdn_sys_id)
if valid:
break
console.print(f"[red]{error}[/red]")
nxdn_sys_id_str = Prompt.ask("NXDN System ID", default="0x001")
except ValueError:
console.print("[red]Invalid format. Use hex (0x001) or decimal (1)[/red]")
nxdn_sys_id_str = Prompt.ask("NXDN System ID", default="0x001")
# Note: sysId is shared with P25, only set if P25 didn't set it
if not enable_p25:
self.config.set('system.config.sysId', nxdn_sys_id)
# NXDN Site ID
nxdn_site_id_str = self._get_answer(
'nxdn_site_id',
Prompt.ask,
f"NXDN Site ID (hex: 0x1-0x{nxdn_info['siteId']['max']:X}, decimal: {nxdn_info['siteId']['min']}-{nxdn_info['siteId']['max']})",
default=str(nxdn_info['siteId']['default'])
)
while True:
try:
nxdn_site_id = int(nxdn_site_id_str, 0)
valid, error = validate_nxdn_location_id(nxdn_site_id)
if valid:
break
console.print(f"[red]{error}[/red]")
nxdn_site_id_str = Prompt.ask("NXDN Site ID", default="1")
except ValueError:
console.print("[red]Invalid format. Use hex or decimal[/red]")
nxdn_site_id_str = Prompt.ask("NXDN Site ID", default="1")
# Note: siteId is shared, only set if neither DMR nor P25 set it
if not enable_dmr and not enable_p25:
self.config.set('system.config.siteId', nxdn_site_id)
# If no protocols enabled DMR/P25/NXDN individually, still prompt for generic site ID
if not enable_dmr and not enable_p25 and not enable_nxdn:
site_id = self._get_answer(
'site_id',
IntPrompt.ask,
"Site ID",
default=1
)
self.config.set('system.config.siteId', site_id)
# Frequency Configuration
console.print("\n[bold]Step 8: Frequency Configuration[/bold]\n")
# Always configure frequency for conventional systems
self._configure_frequency()
# Location (optional)
console.print("\n[bold]Step 9: Location Information (optional)[/bold]\n")
if self._get_answer(
'configure_location',
Confirm.ask,
"Configure location information?",
default=False
):
try:
latitude = float(self._get_answer(
'latitude',
Prompt.ask,
"Latitude",
default="0.0"
))
self.config.set('system.info.latitude', latitude)
longitude = float(self._get_answer(
'longitude',
Prompt.ask,
"Longitude",
default="0.0"
))
self.config.set('system.info.longitude', longitude)
location = self._get_answer(
'location_description',
Prompt.ask,
"Location description",
default=""
)
if location:
self.config.set('system.info.location', location)
power = self._get_answer(
'tx_power',
IntPrompt.ask,
"TX power (watts)",
default=10
)
self.config.set('system.info.power', power)
except ValueError:
console.print("[yellow]Invalid location data, skipping[/yellow]")
# Summary and Save
return self._show_summary_and_save()
def _configure_frequency(self):
"""Configure frequency and channel assignment"""
# Show available bands
console.print("[cyan]Available Frequency Bands:[/cyan]\n")
table = Table(show_header=True, header_style="bold cyan")
table.add_column("#", style="dim", width=3)
table.add_column("Band", style="cyan")
table.add_column("TX Range", style="yellow")
table.add_column("Offset", style="green")
band_list = list(BAND_PRESETS.items())
for i, (key, band) in enumerate(band_list, 1):
tx_min, tx_max = band['tx_range']
offset = band['input_offset']
table.add_row(
str(i),
band['name'],
f"{tx_min:.1f}-{tx_max:.1f} MHz",
f"{offset:+.1f} MHz"
)
console.print(table)
console.print()
# Select band
band_choice = self._get_answer(
'frequency_band',
IntPrompt.ask,
"Select frequency band",
choices=[str(i) for i in range(1, len(band_list) + 1)],
default="3"
)
band_index = int(band_choice) - 1
preset_key = band_list[band_index][0]
preset = BAND_PRESETS[preset_key]
# Confirm 800MHz selection
if preset_key == '800mhz':
if not self._get_answer(
'confirm_800mhz',
Confirm.ask,
f"\n[yellow]Are you sure {preset['name']} is the frequency band you want?[/yellow]",
default=False
):
return self._configure_frequency()
# Use band index as channel ID (0-15 range), except 900MHz is always channel ID 15
channel_id = 15 if preset_key == '900mhz' else band_index
# Get TX frequency
console.print(f"\n[cyan]Selected: {preset['name']}[/cyan]")
console.print(f"TX Range: {preset['tx_range'][0]:.1f}-{preset['tx_range'][1]:.1f} MHz")
console.print(f"Input Offset: {preset['input_offset']:+.1f} MHz\n")
tx_freq_mhz = None
while tx_freq_mhz is None:
try:
tx_input = self._get_answer(
'tx_frequency',
Prompt.ask,
"Transmit frequency (MHz)",
default=f"{preset['tx_range'][0]:.4f}"
)
tx_freq_mhz = float(tx_input)
# Validate frequency is in range
if not (preset['tx_range'][0] <= tx_freq_mhz <= preset['tx_range'][1]):
console.print(f"[red]Frequency must be between {preset['tx_range'][0]:.1f} and "
f"{preset['tx_range'][1]:.1f} MHz[/red]")
tx_freq_mhz = None
continue
# Calculate channel assignment
channel_id_result, channel_no, tx_hz, rx_hz = calculate_channel_assignment(
tx_freq_mhz, preset_key, channel_id
)
# Show calculated values
console.print(f"\n[green]✓ Channel Assignment Calculated:[/green]")
console.print(f" Channel ID: {channel_id_result}")
console.print(f" Channel Number: {channel_no} (0x{channel_no:03X})")
console.print(f" TX Frequency: {tx_hz/1000000:.6f} MHz")
console.print(f" RX Frequency: {rx_hz/1000000:.6f} MHz")
# Apply to configuration
self.config.set('system.config.channelId', channel_id_result)
self.config.set('system.config.channelNo', channel_no)
self.config.set('system.config.txFrequency', tx_hz)
self.config.set('system.config.rxFrequency', rx_hz)
# Ensure the band is in the IDEN table
if channel_id_result not in self.iden_table.entries:
entry = create_iden_entry_from_preset(channel_id_result, preset_key)
self.iden_table.add_entry(entry)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
tx_freq_mhz = None
def _show_summary_and_save(self) -> Optional[Path]:
"""Show configuration summary, allow corrections, and save"""
while True:
console.print("\n[bold cyan]Configuration Summary[/bold cyan]\n")
summary = self.config.get_summary()
table = Table(show_header=False)
table.add_column("Parameter", style="cyan")
table.add_column("Value", style="yellow")
table.add_row("Template", self.template_name)
table.add_row("Identity", summary['identity'])
table.add_row("Peer ID", str(summary['peer_id']))
table.add_row("FNE", f"{summary['fne_address']}:{summary['fne_port']}")
protocols = []
if summary['protocols']['dmr']:
protocols.append(f"DMR (CC:{summary['color_code']})")
if summary['protocols']['p25']:
protocols.append(f"P25 (NAC:0x{summary['nac']:03X})")
if summary['protocols']['nxdn']:
protocols.append("NXDN")
table.add_row("Protocols", ", ".join(protocols))
table.add_row("Modem Type", summary['modem_type'])
table.add_row("Modem Mode", summary['modem_mode'].upper())
if summary['modem_port'] != 'N/A':
table.add_row("Modem Port", summary['modem_port'])
table.add_row("RPC Password", summary['rpc_password'])
if summary['rest_enabled']:
table.add_row("REST API", "Enabled")
table.add_row("REST API Password", summary['rest_password'])
else:
table.add_row("REST API", "Disabled")
# Add frequency information if configured
if summary['channel_id'] >= 0:
table.add_row("Channel ID", str(summary['channel_id']))
table.add_row("Channel Number", f"0x{summary['channel_no']:03X}")
if summary['tx_frequency'] > 0:
tx_mhz = summary['tx_frequency'] / 1000000
rx_mhz = summary['rx_frequency'] / 1000000
table.add_row("TX Frequency", f"{tx_mhz:.6f} MHz")
table.add_row("RX Frequency", f"{rx_mhz:.6f} MHz")
console.print(table)
console.print()
# Validate
errors = self.config.validate()
if errors:
console.print("[yellow]Configuration has warnings:[/yellow]")
for error in errors:
console.print(f" [yellow]•[/yellow] {error}")
console.print()
if not Confirm.ask("Continue anyway?", default=True):
return None
else:
console.print("[green]✓ Configuration is valid[/green]\n")
# Review and correction
console.print("[bold]Review Your Configuration[/bold]\n")
choice = Prompt.ask(
"Is this configuration correct?",
choices=["yes", "no"],
default="yes"
)
if choice.lower() == "no":
console.print("\n[yellow]Configuration returned for editing.[/yellow]")
console.print("[dim]Please restart the wizard or manually edit the configuration files.[/dim]\n")
return None
console.print()
default_filename = f"{summary['identity'].lower()}.yml"
default_path = str(Path(self.config_dir) / default_filename)
output_path = Prompt.ask(
"Output file name",
default=default_path
)
output_file = Path(str(Path(self.config_dir) / Path(output_path)))
# Check if file exists
if output_file.exists():
if not Confirm.ask(f"[yellow]{output_file} already exists. Overwrite?[/yellow]", default=False):
output_path = Prompt.ask("Output file path")
output_file = Path(output_path)
try:
self.config.save(output_file)
console.print(f"\n[green]✓ Conventional system created successfully![/green]")
console.print(f"\nConfiguration files saved in: [cyan]{self.config_dir}[/cyan]")
console.print(f" • Conventional Channel: [yellow]{output_file}[/yellow]")
# Save IDEN table if configured
if len(self.iden_table) > 0:
iden_file = Path(self.config_dir) / "iden_table.dat"
self.iden_table.save(iden_file)
console.print(f" • Identity Table: [yellow]iden_table.dat[/yellow]")
return output_file
except Exception as e:
console.print(f"[red]Error saving configuration:[/red] {e}")
return None
def _run_trunking_wizard(self) -> Optional[Path]:
"""Run trunking system wizard"""
wizard = TrunkingWizard(self.answers)
return wizard.run()
class TrunkingWizard:
"""Interactive trunking system wizard"""
def __init__(self, answers: Optional[Dict[str, Any]] = None):
self.answers = answers or {}
self.iden_table: IdenTable = create_default_iden_table()
def _get_answer(self, key: str, prompt_func, *args, **kwargs) -> Any:
"""
Get answer from answers file or prompt user
Args:
key: Answer key to look up
prompt_func: Function to call if answer not found (e.g., Prompt.ask)
*args: Arguments to pass to prompt_func
**kwargs: Keyword arguments to pass to prompt_func
Returns:
Answer value (from file or user prompt)
"""
if key in self.answers:
value = self.answers[key]
# Display the value that was loaded from answers file
if isinstance(value, bool):
display_value = "Yes" if value else "No"
else:
display_value = str(value)
console.print(f"[dim]{key}: {display_value}[/dim]")
return value
# Prompt user for input
return prompt_func(*args, **kwargs)
def run(self) -> Optional[Path]:
"""Run the trunking wizard"""
# Don't clear or show banner here - run_wizard handles it
console.print()
console.print(Panel.fit(
"[bold cyan]Trunked System Configuration Wizard[/bold cyan]\n"
"Create a trunked system configuration with control and voice channels",
border_style="cyan"
))
console.print()
# System basics
console.print("[bold]Step 1: System Configuration[/bold]\n")
system_name = self._get_answer(
'system_name',
Prompt.ask,
"System name (for filenames)",
default="trunked"
)
base_dir = self._get_answer(
'base_dir',
Prompt.ask,
"Configuration directory",
default="."
)
identity = self._get_answer(
'identity',
Prompt.ask,
"System identity prefix",
default="SITE001"
)
protocol = self._get_answer(
'protocol',
Prompt.ask,
"Protocol",
choices=['p25', 'dmr'],
default='p25'
)
vc_count = self._get_answer(
'vc_count',
IntPrompt.ask,
"Number of voice channels",
default=2
)
# Logging Configuration
console.print("\n[bold]Step 2: Logging Configuration[/bold]\n")
log_path = None
activity_log_path = None
log_root = None
use_syslog = False
disable_non_auth_logging = False
configure_logging = self._get_answer(
'configure_logging',
Confirm.ask,
"Configure logging settings?",
default=False
)
if configure_logging:
# Log file path
log_path = self._get_answer(
'log_path',
Prompt.ask,
"Log file directory",
default="."
)
# Activity log file path
activity_log_path = self._get_answer(
'activity_log_path',
Prompt.ask,
"Activity log directory",
default="."
)
# Log file root/prefix
log_root = self._get_answer(
'log_root',
Prompt.ask,
"Log filename prefix (used for filenames and syslog)",
default="DVM"
)
# Syslog usage
use_syslog = self._get_answer(
'use_syslog',
Confirm.ask,
"Enable syslog output?",
default=False
)
# Disable non-authoritive logging
disable_non_auth_logging = self._get_answer(
'disable_non_auth_logging',
Confirm.ask,
"Disable non-authoritative logging?",
default=False
)
# Network settings
console.print("\n[bold]Step 3: Network Settings[/bold]\n")
fne_address = self._get_answer(
'fne_address',
Prompt.ask,
"FNE address",
default="127.0.0.1"
)
fne_port = self._get_answer(
'fne_port',
IntPrompt.ask,
"FNE port",
default=62031
)
fne_password = self._get_answer(
'fne_password',
Prompt.ask,
"FNE password",
default="PASSWORD",
password=True
)
base_peer_id = self._get_answer(
'base_peer_id',
IntPrompt.ask,
"Base peer ID (CC will use this, VCs increment)",
default=100000
)
base_rpc_port = self._get_answer(
'base_rpc_port',
IntPrompt.ask,
"Base RPC port (CC will use this, VCs increment)",
default=9890
)
# Network Lookup and Transfer Settings
console.print("\n[bold]Step 3a: Network Lookup & Transfer Settings[/bold]\n")
update_lookups = self._get_answer(
'update_lookups',
Confirm.ask,
"Update lookups from network?",
default=True
)
save_lookups = self._get_answer(
'save_lookups',
Confirm.ask,
"Save lookups from network?",
default=False
)
allow_activity = self._get_answer(
'allow_activity_transfer',
Confirm.ask,
"Allow activity transfer to FNE?",
default=True
)
allow_diagnostic = self._get_answer(
'allow_diagnostic_transfer',
Confirm.ask,
"Allow diagnostic transfer to FNE?",
default=False
)
allow_status = self._get_answer(
'allow_status_transfer',
Confirm.ask,
"Allow status transfer to FNE?",
default=True
)
# Radio ID and Talkgroup ID Configuration
console.print("\n[bold]Step 3b: Radio ID & Talkgroup ID Configuration[/bold]\n")
radio_id_file = None
radio_id_time = 0
radio_id_acl = False
talkgroup_id_file = None
talkgroup_id_time = 0
talkgroup_id_acl = False
if self._get_answer(
'configure_radio_talkgroup_ids',
Confirm.ask,
"Configure Radio ID and Talkgroup ID settings?",
default=False
):
# Radio ID Configuration
console.print("\n[dim]Radio ID Settings:[/dim]")
radio_id_file = self._get_answer(
'radio_id_file',
Prompt.ask,
"Radio ID ACL file (path, or leave empty to skip)",
default=""
)
if not radio_id_file:
radio_id_file = None
radio_id_time = self._get_answer(
'radio_id_time',
IntPrompt.ask,
"Radio ID update time (seconds, 0 = disabled)",
default=0
)
radio_id_acl = self._get_answer(
'radio_id_acl',
Confirm.ask,
"Enforce Radio ID ACLs?",
default=False
)
# Talkgroup ID Configuration
console.print("\n[dim]Talkgroup ID Settings:[/dim]")
talkgroup_id_file = self._get_answer(
'talkgroup_id_file',
Prompt.ask,
"Talkgroup ID ACL file (path, or leave empty to skip)",
default=""
)
if not talkgroup_id_file:
talkgroup_id_file = None
talkgroup_id_time = self._get_answer(
'talkgroup_id_time',
IntPrompt.ask,
"Talkgroup ID update time (seconds, 0 = disabled)",
default=0
)
talkgroup_id_acl = self._get_answer(
'talkgroup_id_acl',
Confirm.ask,
"Enforce Talkgroup ID ACLs?",
default=False
)
# RPC & REST API Configuration
console.print("\n[bold]Step 3c: RPC & REST API Configuration[/bold]\n")
# Generate random RPC password
rpc_password = generate_random_password()
console.print(f"[cyan]Generated RPC password:[/cyan] {rpc_password}")
base_rest_port = self._get_answer(
'base_rest_port',
IntPrompt.ask,
"Base REST API port (CC will use this, VCs increment)",
default=8080
)
# Ask about REST API
rest_enabled = self._get_answer(
'rest_enable',
Confirm.ask,
"Enable REST API?",
default=False
)
if rest_enabled:
rest_password = generate_random_password()
console.print(f"[cyan]Generated REST API password:[/cyan] {rest_password}")
else:
rest_password = None
# Network/System ID Configuration
console.print("\n[bold]Step 4: Network/System ID Configuration[/bold]\n")
console.print(f"[cyan]Protocol: {protocol.upper()}[/cyan]\n")
# Initialize variables
color_code = 1
dmr_net_id = 1
nac = 0x293
net_id = 0xBB800
sys_id = 0x001
rfss_id = 1
site_id = 1
ran = 1
if protocol == 'p25':
# P25-specific configuration
p25_info = get_network_id_info('p25')
# NAC
nac_str = self._get_answer(
'p25_nac',
Prompt.ask,
f"P25 NAC (hex: 0x000-0x{p25_info['nac']['max']:03X}, decimal: {p25_info['nac']['min']}-{p25_info['nac']['max']})",
default=f"0x{p25_info['nac']['default']:03X}"
)
while True:
try:
nac = int(nac_str, 0)
valid, error = validate_p25_nac(nac)
if valid:
break
console.print(f"[red]{error}[/red]")
nac_str = Prompt.ask("P25 NAC", default="0x293")
except ValueError:
console.print("[red]Invalid format. Use hex (0x293) or decimal (659)[/red]")
nac_str = Prompt.ask("P25 NAC", default="0x293")
# Network ID (WACN)
net_id_str = self._get_answer(
'p25_network_id',
Prompt.ask,
f"P25 Network ID/WACN (hex: 0x1-0x{p25_info['netId']['max']:X}, decimal: {p25_info['netId']['min']}-{p25_info['netId']['max']})",
default=f"0x{p25_info['netId']['default']:X}"
)
while True:
try:
net_id = int(net_id_str, 0)
valid, error = validate_p25_network_id(net_id)
if valid:
break
console.print(f"[red]{error}[/red]")
net_id_str = Prompt.ask("P25 Network ID", default="0xBB800")
except ValueError:
console.print("[red]Invalid format. Use hex (0xBB800) or decimal (768000)[/red]")
net_id_str = Prompt.ask("P25 Network ID", default="0xBB800")
# System ID
sys_id_str = self._get_answer(
'p25_system_id',
Prompt.ask,
f"P25 System ID (hex: 0x1-0x{p25_info['sysId']['max']:X}, decimal: {p25_info['sysId']['min']}-{p25_info['sysId']['max']})",
default=f"0x{p25_info['sysId']['default']:03X}"
)
while True:
try:
sys_id = int(sys_id_str, 0)
valid, error = validate_p25_system_id(sys_id)
if valid:
break
console.print(f"[red]{error}[/red]")
sys_id_str = Prompt.ask("P25 System ID", default="0x001")
except ValueError:
console.print("[red]Invalid format. Use hex (0x001) or decimal (1)[/red]")
sys_id_str = Prompt.ask("P25 System ID", default="0x001")
# RFSS ID
rfss_id = self._get_answer(
'p25_rfss_id',
IntPrompt.ask,
f"P25 RFSS ID ({p25_info['rfssId']['min']}-{p25_info['rfssId']['max']})",
default=p25_info['rfssId']['default']
)
while True:
valid, error = validate_p25_rfss_id(rfss_id)
if valid:
break
console.print(f"[red]{error}[/red]")
rfss_id = IntPrompt.ask("P25 RFSS ID", default=1)
# Site ID
site_id = self._get_answer(
'p25_site_id',
IntPrompt.ask,
f"P25 Site ID ({p25_info['siteId']['min']}-{p25_info['siteId']['max']})",
default=p25_info['siteId']['default']
)
while True:
valid, error = validate_p25_site_id(site_id)
if valid:
break
console.print(f"[red]{error}[/red]")
site_id = IntPrompt.ask("P25 Site ID", default=1)
elif protocol == 'dmr':
# DMR-specific configuration
console.print("DMR Site Model (affects Network ID and Site ID ranges):")
console.print(" 1. SMALL - Most common (NetID: 1-127, SiteID: 1-31)")
console.print(" 2. TINY - Large NetID range (NetID: 1-511, SiteID: 1-7)")
console.print(" 3. LARGE - Large SiteID range (NetID: 1-31, SiteID: 1-127)")
console.print(" 4. HUGE - Very large SiteID (NetID: 1-3, SiteID: 1-1023)")
site_model_choice = self._get_answer(
'dmr_site_model',
IntPrompt.ask,
"Select site model",
default=1
)
site_model_map = {1: 'small', 2: 'tiny', 3: 'large', 4: 'huge'}
site_model_str = site_model_map.get(site_model_choice, 'small')
site_model = get_dmr_site_model_from_string(site_model_str)
dmr_info = get_network_id_info('dmr', site_model_str)
# Color Code
color_code = self._get_answer(
'dmr_color_code',
IntPrompt.ask,
f"DMR Color Code ({dmr_info['colorCode']['min']}-{dmr_info['colorCode']['max']})",
default=dmr_info['colorCode']['default']
)
while True:
valid, error = validate_dmr_color_code(color_code)
if valid:
break
console.print(f"[red]{error}[/red]")
color_code = IntPrompt.ask("DMR Color Code", default=1)
# DMR Network ID
dmr_net_id = self._get_answer(
'dmr_network_id',
IntPrompt.ask,
f"DMR Network ID ({dmr_info['dmrNetId']['min']}-{dmr_info['dmrNetId']['max']})",
default=dmr_info['dmrNetId']['default']
)
while True:
valid, error = validate_dmr_network_id(dmr_net_id, site_model)
if valid:
break
console.print(f"[red]{error}[/red]")
dmr_net_id = IntPrompt.ask("DMR Network ID", default=1)
# DMR Site ID
site_id = self._get_answer(
'dmr_site_id',
IntPrompt.ask,
f"DMR Site ID ({dmr_info['siteId']['min']}-{dmr_info['siteId']['max']})",
default=dmr_info['siteId']['default']
)
while True:
valid, error = validate_dmr_site_id(site_id, site_model)
if valid:
break
console.print(f"[red]{error}[/red]")
site_id = IntPrompt.ask("DMR Site ID", default=1)
console.print(f"\n[dim]These values will be applied consistently across all channels (CC and VCs)[/dim]")
# Frequency configuration for control channel
console.print("\n[bold]Step 5: Control Channel Frequency and Modem[/bold]\n")
cc_channel_id, cc_channel_no, cc_band, cc_tx_hz, cc_rx_hz = self._configure_channel_frequency("Control Channel")
cc_modem_config = self._configure_channel_modem("Control Channel")
# Voice channel frequency and modem configuration
console.print("\n[bold]Step 6: Voice Channel Frequencies and Modems[/bold]\n")
vc_channels = []
for i in range(1, vc_count + 1):
console.print(f"\n[cyan]Voice Channel {i}:[/cyan]")
vc_channel_id, vc_channel_no, _, vc_tx_hz, vc_rx_hz = self._configure_channel_frequency(f"VC{i}", cc_band)
vc_modem_config = self._configure_channel_modem(f"VC{i}")
vc_channels.append({
'channel_id': vc_channel_id,
'channel_no': vc_channel_no,
'tx_hz': vc_tx_hz,
'rx_hz': vc_rx_hz,
'modem_type': vc_modem_config['modem_type'],
'modem_mode': vc_modem_config['modem_mode'],
'modem_port': vc_modem_config.get('modem_port', 'N/A'),
'dfsi_rtrt': vc_modem_config.get('dfsi_rtrt'),
'dfsi_jitter': vc_modem_config.get('dfsi_jitter'),
'dfsi_call_timeout': vc_modem_config.get('dfsi_call_timeout'),
'dfsi_full_duplex': vc_modem_config.get('dfsi_full_duplex')
})
console.print()
# Create system
console.print("\n[bold cyan]Creating trunked system...[/bold cyan]\n")
try:
system = TrunkingSystem(Path(base_dir), system_name)
# Show configuration summary
console.print("\n[bold cyan]Trunked System Configuration Summary[/bold cyan]\n")
table = Table(show_header=False)
table.add_column("Parameter", style="cyan")
table.add_column("Value", style="yellow")
table.add_row("System Name", system_name)
table.add_row("Base Directory", base_dir)
table.add_row("Protocol", protocol.upper())
table.add_row("Voice Channels", str(vc_count))
table.add_row("FNE Address", fne_address)
table.add_row("FNE Port", str(fne_port))
table.add_row("Base Peer ID", str(base_peer_id))
table.add_row("Base RPC Port", str(base_rpc_port))
table.add_row("RPC Password", rpc_password)
if rest_enabled:
table.add_row("REST API", "Enabled")
table.add_row("Base REST Port", str(base_rest_port))
table.add_row("REST API Password", rest_password)
else:
table.add_row("REST API", "Disabled")
if protocol == 'dmr':
table.add_row("DMR Color Code", str(color_code))
table.add_row("DMR Network ID", str(dmr_net_id))
elif protocol == 'p25':
table.add_row("P25 NAC", f"0x{nac:03X}")
table.add_row("P25 Network ID", str(net_id))
table.add_row("P25 System ID", str(sys_id))
console.print(table)
console.print()
# Show channel frequency details
console.print("[bold cyan]Channel Configuration[/bold cyan]\n")
# Control Channel
cc_tx_mhz = cc_tx_hz / 1000000
cc_rx_mhz = cc_rx_hz / 1000000
console.print("[bold]Control Channel (CC):[/bold]")
console.print(f" Channel ID: {cc_channel_id}")
console.print(f" Channel Number: 0x{cc_channel_no:03X}")
console.print(f" TX Frequency: {cc_tx_mhz:.6f} MHz")
console.print(f" RX Frequency: {cc_rx_mhz:.6f} MHz")
console.print(f" Modem Type: {cc_modem_config['modem_type']}")
console.print(f" Modem Mode: {cc_modem_config['modem_mode'].upper()}")
if cc_modem_config.get('modem_port'):
console.print(f" Modem Port: {cc_modem_config['modem_port']}")
console.print()
# Voice Channels
console.print("[bold]Voice Channels:[/bold]")
for i, vc in enumerate(vc_channels, 1):
vc_tx_mhz = vc['tx_hz'] / 1000000
vc_rx_mhz = vc['rx_hz'] / 1000000
console.print(f" VC{i}:")
console.print(f" Channel ID: {vc['channel_id']}")
console.print(f" Channel Number: 0x{vc['channel_no']:03X}")
console.print(f" TX Frequency: {vc_tx_mhz:.6f} MHz")
console.print(f" RX Frequency: {vc_rx_mhz:.6f} MHz")
console.print(f" Modem Type: {vc['modem_type']}")
console.print(f" Modem Mode: {vc['modem_mode'].upper()}")
if vc.get('modem_port'):
console.print(f" Modem Port: {vc['modem_port']}")
# Review and correction
console.print("[bold]Review Your Configuration[/bold]\n")
choice = Prompt.ask(
"Is this configuration correct?",
choices=["yes", "no"],
default="yes"
)
if choice.lower() == "no":
console.print("\n[yellow]Configuration creation cancelled.[/yellow]")
console.print("[dim]Please restart the wizard or manually edit the configuration files: [cyan]" + base_dir + "[/cyan][/dim]\n")
return Path(base_dir)
console.print()
# Prepare kwargs based on protocol
create_kwargs = {
'protocol': protocol,
'vc_count': vc_count,
'fne_address': fne_address,
'fne_port': fne_port,
'fne_password': fne_password,
'base_peer_id': base_peer_id,
'base_rpc_port': base_rpc_port,
'rpc_password': rpc_password,
'base_rest_port': base_rest_port,
'rest_password': rest_password,
'update_lookups': update_lookups,
'save_lookups': save_lookups,
'allow_activity_transfer': allow_activity,
'allow_diagnostic_transfer': allow_diagnostic,
'allow_status_transfer': allow_status,
'radio_id_file': radio_id_file,
'radio_id_time': radio_id_time,
'radio_id_acl': radio_id_acl,
'talkgroup_id_file': talkgroup_id_file,
'talkgroup_id_time': talkgroup_id_time,
'talkgroup_id_acl': talkgroup_id_acl,
'system_identity': identity,
'modem_type': cc_modem_config['modem_type'],
'cc_dfsi_rtrt': cc_modem_config.get('dfsi_rtrt'),
'cc_dfsi_jitter': cc_modem_config.get('dfsi_jitter'),
'cc_dfsi_call_timeout': cc_modem_config.get('dfsi_call_timeout'),
'cc_dfsi_full_duplex': cc_modem_config.get('dfsi_full_duplex'),
'cc_channel_id': cc_channel_id,
'cc_channel_no': cc_channel_no,
'vc_channels': vc_channels,
'log_path': log_path,
'activity_log_path': activity_log_path,
'log_root': log_root,
'use_syslog': use_syslog,
'disable_non_auth_logging': disable_non_auth_logging
}
# Add protocol-specific network/system IDs
if protocol == 'p25':
create_kwargs.update({
'nac': nac,
'net_id': net_id,
'sys_id': sys_id,
'rfss_id': rfss_id,
'site_id': site_id
})
elif protocol == 'dmr':
create_kwargs.update({
'color_code': color_code,
'dmr_net_id': dmr_net_id,
'site_id': site_id
})
system.create_system(**create_kwargs)
# Save IDEN table
if len(self.iden_table) > 0:
iden_file = Path(base_dir) / "iden_table.dat"
self.iden_table.save(iden_file)
console.print(f"[green]✓[/green] Identity table saved: {iden_file}")
console.print(f"[dim] ({len(self.iden_table)} channel identity entries)[/dim]\n")
console.print(f"\n[green]✓ Trunked system created successfully![/green]")
console.print(f"\nConfiguration files saved in: [cyan]{base_dir}[/cyan]")
console.print(f" • Control Channel: [yellow]{system_name}-cc.yml[/yellow]")
for i in range(1, vc_count + 1):
console.print(f" • Voice Channel {i}: [yellow]{system_name}-vc{i:02d}.yml[/yellow]")
console.print(f" • Identity Table: [yellow]iden_table.dat[/yellow]")
return Path(base_dir)
except Exception as e:
console.print(f"[red]Error creating system:[/red] {e}")
return None
def _configure_channel_frequency(self, channel_name: str, default_band: Optional[str] = None) -> Tuple[int, int, str, int, int]:
"""
Configure frequency for a channel
Args:
channel_name: Name of the channel (for display)
default_band: Optional band preset key to use as default selection
Returns:
Tuple of (channel_id, channel_number, band_preset_key, tx_hz, rx_hz)
"""
# Show available bands
console.print(f"[cyan]Configure {channel_name} Frequency:[/cyan]\n")
table = Table(show_header=True, header_style="bold cyan")
table.add_column("#", style="dim", width=3)
table.add_column("Band", style="cyan")
table.add_column("TX Range", style="yellow")
band_list = list(BAND_PRESETS.items())
for i, (key, band) in enumerate(band_list, 1):
tx_min, tx_max = band['tx_range']
table.add_row(
str(i),
band['name'],
f"{tx_min:.1f}-{tx_max:.1f} MHz"
)
console.print(table)
console.print()
# Determine default band choice
default_choice = "3"
if default_band:
# Find the index of the default band
for i, (key, _) in enumerate(band_list, 1):
if key == default_band:
default_choice = str(i)
break
# Select band
band_choice = IntPrompt.ask(
"Select frequency band",
choices=[str(i) for i in range(1, len(band_list) + 1)],
default=default_choice
)
band_index = int(band_choice) - 1
preset_key = band_list[band_index][0]
preset = BAND_PRESETS[preset_key]
# Confirm 800MHz selection
if preset_key == '800mhz':
if not Confirm.ask(f"\n[yellow]Are you sure {preset['name']} is the frequency band you want?[/yellow]", default=False):
return self._configure_channel_frequency(channel_name, default_band)
# Use band index as channel ID, except 900MHz is always channel ID 15
channel_id = 15 if preset_key == '900mhz' else band_index
# Get TX frequency
console.print(f"\n[cyan]{preset['name']}[/cyan]")
console.print(f"TX Range: {preset['tx_range'][0]:.1f}-{preset['tx_range'][1]:.1f} MHz\n")
while True:
try:
tx_input = Prompt.ask(
f"{channel_name} TX frequency (MHz)",
default=f"{preset['tx_range'][0]:.4f}"
)
tx_freq_mhz = float(tx_input)
# Validate and calculate
if not (preset['tx_range'][0] <= tx_freq_mhz <= preset['tx_range'][1]):
console.print(f"[red]Frequency must be between {preset['tx_range'][0]:.1f} and "
f"{preset['tx_range'][1]:.1f} MHz[/red]")
continue
channel_id_result, channel_no, tx_hz, rx_hz = calculate_channel_assignment(
tx_freq_mhz, preset_key, channel_id
)
console.print(f"[green]✓ Assigned:[/green] Ch ID {channel_id_result}, Ch# {channel_no} (0x{channel_no:03X}), "
f"RX {rx_hz/1000000:.6f} MHz\n")
# Ensure the band is in the IDEN table
if channel_id_result not in self.iden_table.entries:
entry = create_iden_entry_from_preset(channel_id_result, preset_key)
self.iden_table.add_entry(entry)
return (channel_id_result, channel_no, preset_key, tx_hz, rx_hz)
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
def _configure_channel_modem(self, channel_name: str) -> Dict[str, Any]:
"""
Configure modem for a channel
Args:
channel_name: Name of the channel (for display)
Returns:
Dict with modem configuration (type, mode, port, and DFSI settings if applicable)
"""
console.print(f"\n[cyan]Configure {channel_name} Modem:[/cyan]\n")
# Modem Type
modem_type = Prompt.ask(
f"{channel_name} modem type",
choices=['uart', 'null'],
default='uart'
)
# Modem Mode
modem_mode = Prompt.ask(
f"{channel_name} modem mode",
choices=['air', 'dfsi'],
default='air'
)
# Serial port (only if UART)
modem_port = None
if modem_type == 'uart':
modem_port = Prompt.ask(
f"{channel_name} serial port",
default="/dev/ttyUSB0"
)
# DFSI Configuration (if dfsi mode selected)
dfsi_rtrt = None
dfsi_jitter = None
dfsi_call_timeout = None
dfsi_full_duplex = None
if modem_mode == 'dfsi':
if Confirm.ask(f"\nConfigure {channel_name} DFSI settings?", default=False):
dfsi_rtrt = Confirm.ask(
"Enable DFSI RT/RT?",
default=True
)
dfsi_jitter = IntPrompt.ask(
"DFSI Jitter (ms)",
default=200
)
dfsi_call_timeout = IntPrompt.ask(
"DFSI Call Timeout (seconds)",
default=200
)
dfsi_full_duplex = Confirm.ask(
"Enable DFSI Full Duplex?",
default=False
)
return {
'modem_type': modem_type,
'modem_mode': modem_mode,
'modem_port': modem_port,
'dfsi_rtrt': dfsi_rtrt,
'dfsi_jitter': dfsi_jitter,
'dfsi_call_timeout': dfsi_call_timeout,
'dfsi_full_duplex': dfsi_full_duplex
}
def run_wizard(wizard_type: str = 'auto', answers: Optional[Dict[str, Any]] = None) -> Optional[Path]:
"""
Run configuration wizard
Args:
wizard_type: 'single', 'trunk', or 'auto' (asks user)
answers: Optional dictionary of answers to pre-populate prompts
Returns:
Path to saved configuration or None
"""
try:
if wizard_type == 'auto':
console.clear()
console.print()
# Display banner
console.print(f"[cyan]{__BANNER__}[/cyan]")
console.print(f"[bold cyan]Digital Voice Modem (DVM) Configuration Generator {__VER__}[/bold cyan]")
console.print("[dim]Copyright (c) 2026 Bryan Biedenkapp, N2PLL and DVMProject (https://github.com/dvmproject) Authors.[/dim]")
console.print()
console.print("[bold]Configuration Wizard[/bold]\n")
console.print("What would you like to create?\n")
console.print("1. Single instance (repeater, hotspot, etc.)")
console.print("2. Trunked system (control + voice channels)")
console.print()
choice = IntPrompt.ask(
"Select type",
choices=['1', '2'],
default='1'
)
wizard_type = 'single' if int(choice) == 1 else 'trunk'
if wizard_type == 'single':
wizard = ConfigWizard(answers)
return wizard.run()
else:
wizard = TrunkingWizard(answers)
return wizard.run()
except KeyboardInterrupt:
console.print("\n\n[yellow]Wizard cancelled by user.[/yellow]")
return None
if __name__ == '__main__':
result = run_wizard()
if result:
console.print(f"\n[green]Done! Configuration saved to: {result}[/green]")

Powered by TurnKey Linux.