From 24bbfb6d251fea657568042fc6ea13a0a5032e5b Mon Sep 17 00:00:00 2001 From: Bryan Biedenkapp Date: Sat, 21 Feb 2026 10:00:18 -0500 Subject: [PATCH] add support to dvmcfggen for logging configuration; add support to dvmcfggen for user supplied answers files to automate skipping certain wizard questions; --- tools/dvmcfggen/ANSWERS_FILE_USAGE.md | 378 ++++++++++ tools/dvmcfggen/answers_loader.py | 194 ++++++ tools/dvmcfggen/dvmcfg.py | 61 +- .../examples/conventional-answers.yml | 87 +++ tools/dvmcfggen/examples/trunked-answers.yml | 72 ++ tools/dvmcfggen/trunking_manager.py | 36 +- tools/dvmcfggen/wizard.py | 654 +++++++++++++++--- 7 files changed, 1367 insertions(+), 115 deletions(-) create mode 100644 tools/dvmcfggen/ANSWERS_FILE_USAGE.md create mode 100644 tools/dvmcfggen/answers_loader.py create mode 100644 tools/dvmcfggen/examples/conventional-answers.yml create mode 100644 tools/dvmcfggen/examples/trunked-answers.yml diff --git a/tools/dvmcfggen/ANSWERS_FILE_USAGE.md b/tools/dvmcfggen/ANSWERS_FILE_USAGE.md new file mode 100644 index 00000000..8aa38474 --- /dev/null +++ b/tools/dvmcfggen/ANSWERS_FILE_USAGE.md @@ -0,0 +1,378 @@ +# DVMCfgGen Answers File Feature + +## Overview + +The answers file feature allows you to pre-populate wizard prompts using a YAML file. This enables: + +- **Batch Configuration** - Generate multiple configurations with shared defaults +- **Automation** - Integrate with deployment scripts and CI/CD pipelines +- **Reproducibility** - Save and reuse configuration templates +- **Learning** - Understand what each prompt expects + +## Quick Start + +### 1. Use Example Files + +Example answers files are provided in the `examples/` directory: + +```bash +# For conventional systems +dvmcfg wizard -a examples/conventional-answers.yml + +# For trunked systems +dvmcfg wizard --type trunk -a examples/trunked-answers.yml +``` + +### 2. Create Your Own + +Copy an example file and customize it: + +```bash +cp examples/conventional-answers.yml my-config-answers.yml +vim my-config-answers.yml +dvmcfg wizard -a my-config-answers.yml +``` + +### 3. Run Without Answers File (Default Behavior) + +The answers file is completely optional. The wizard works normally without it: + +```bash +dvmcfg wizard +``` + +## Answers File Format + +Answers files are YAML format with one key-value pair per configuration option. All fields are optional. + +### Conventional System Template + +```yaml +# Step 2: Basic Configuration +template: enhanced +config_dir: . +system_identity: MYSITE001 +network_peer_id: 100001 + +# Step 3: Logging Configuration +configure_logging: true +log_path: /var/log/dvm +activity_log_path: /var/log/dvm/activity +log_root: DVM +use_syslog: false +disable_non_auth_logging: false + +# Step 4: Modem Configuration +modem_type: uart +modem_mode: air +serial_port: /dev/ttyUSB0 +rx_level: 50 +tx_level: 50 +``` + +### Trunked System Template + +```yaml +# Step 1: System Configuration +system_name: trunked_test +base_dir: . +identity: TRUNKED001 +protocol: p25 +vc_count: 2 + +# Step 2: Logging Configuration +configure_logging: true +log_path: /var/log/dvm +activity_log_path: /var/log/dvm/activity +log_root: TRUNKED +use_syslog: false +disable_non_auth_logging: false +``` + +## Supported Answer Keys + +### ConfigWizard (Conventional Systems) + +**Basic Configuration:** +- `template` - Template name (conventional, enhanced) +- `config_dir` - Configuration directory +- `system_identity` - System identity/callsign +- `network_peer_id` - Network peer ID (integer) + +**Logging Configuration:** +- `configure_logging` - Enable logging (true/false) +- `log_path` - Log file directory +- `activity_log_path` - Activity log directory +- `log_root` - Log filename prefix +- `use_syslog` - Enable syslog (true/false) +- `disable_non_auth_logging` - Disable non-authoritative logging (true/false) + +**Modem Configuration:** +- `modem_type` - Modem type (uart, null) +- `modem_mode` - Modem mode (air, dfsi) +- `serial_port` - Serial port path +- `rx_level` - RX level (0-100) +- `tx_level` - TX level (0-100) + +**Optional Settings:** +- `rpc_config` - Configure RPC (true/false) +- `generate_rpc_password` - Generate RPC password (true/false) +- `rest_enable` - Enable REST API (true/false) +- `generate_rest_password` - Generate REST password (true/false) +- `update_lookups` - Update lookups (true/false) +- `save_lookups` - Save lookups (true/false) +- `allow_activity_transfer` - Allow activity transfer (true/false) +- `allow_diagnostic_transfer` - Allow diagnostic transfer (true/false) +- `allow_status_transfer` - Allow status transfer (true/false) + +**Protocol Configuration (for Step 8/9):** +- `dmr_color_code` - DMR color code (integer) +- `dmr_network_id` - DMR network ID (integer) +- `dmr_site_id` - DMR site ID (integer) +- `dmr_site_model` - DMR site model (small, tiny, large, huge) +- `p25_nac` - P25 NAC code (hex string) +- `p25_network_id` - P25 network ID (integer) +- `p25_system_id` - P25 system ID (integer) +- `p25_rfss_id` - P25 RFSS ID (integer) +- `p25_site_id` - P25 site ID (integer) + +### TrunkingWizard (Trunked Systems) + +**System Configuration:** +- `system_name` - System name +- `base_dir` - Base directory +- `identity` - System identity +- `protocol` - Protocol (p25, dmr) +- `vc_count` - Number of voice channels (integer) + +**Logging Configuration:** +- `configure_logging` - Enable logging (true/false) +- `log_path` - Log file directory +- `activity_log_path` - Activity log directory +- `log_root` - Log filename prefix +- `use_syslog` - Enable syslog (true/false) +- `disable_non_auth_logging` - Disable non-authoritative logging (true/false) + +**Network Settings:** +- `fne_address` - FNE address +- `fne_port` - FNE port (integer) +- `fne_password` - FNE password + +**Optional Settings:** +- `base_peer_id` - Base peer ID (integer) +- `base_rpc_port` - Base RPC port (integer) +- `modem_type` - Modem type (uart, null) +- `generate_rpc_password` - Generate RPC password (true/false) +- `base_rest_port` - Base REST port (integer) +- `rest_enable` - Enable REST API (true/false) +- `generate_rest_password` - Generate REST password (true/false) + +## Usage Examples + +### Example 1: Single Configuration with All Defaults + +```yaml +# hotspot-answers.yml +template: enhanced +config_dir: /etc/dvm/hotspot +system_identity: MY_HOTSPOT +network_peer_id: 100001 +configure_logging: true +log_path: /var/log/dvm +log_root: HOTSPOT +modem_type: uart +modem_mode: air +serial_port: /dev/ttyUSB0 +``` + +Usage: +```bash +dvmcfg wizard -a hotspot-answers.yml +``` + +### Example 2: Trunked System + +```yaml +# trunk-p25-answers.yml +system_name: trunk_p25 +base_dir: /etc/dvm/trunk +identity: TRUNK_P25 +protocol: p25 +vc_count: 4 +configure_logging: true +log_path: /var/log/dvm +log_root: TRUNK_P25 +p25_nac: 0x293 +p25_network_id: 1 +p25_system_id: 1 +``` + +Usage: +```bash +dvmcfg wizard --type trunk -a trunk-p25-answers.yml +``` + +### Example 3: Batch Generation + +Create multiple configs programmatically: + +```bash +#!/bin/bash + +for SITE_NUM in {1..5}; do + SITE_ID=$((100000 + SITE_NUM)) + SITE_NAME="SITE$(printf '%03d' $SITE_NUM)" + + cat > /tmp/site-${SITE_NUM}-answers.yml << EOF +template: enhanced +config_dir: ./site-${SITE_NUM} +system_identity: ${SITE_NAME} +network_peer_id: ${SITE_ID} +configure_logging: true +log_root: ${SITE_NAME} +modem_type: uart +modem_mode: air +EOF + + dvmcfg wizard -a /tmp/site-${SITE_NUM}-answers.yml + echo "Generated config for ${SITE_NAME}" +done +``` + +### Example 4: CI/CD Pipeline + +```yaml +# .github/workflows/generate-test-configs.yml +name: Generate Test Configs + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: '3.9' + + - name: Generate conventional config + run: | + python3 dvmhost/tools/dvmcfggen/dvmcfg.py wizard \ + -a examples/conventional-answers.yml + + - name: Generate trunked config + run: | + python3 dvmhost/tools/dvmcfggen/dvmcfg.py wizard \ + --type trunk \ + -a examples/trunked-answers.yml + + - name: Validate configs + run: | + python3 dvmhost/tools/dvmcfggen/dvmcfg.py validate -c config.yml +``` + +## Advanced Usage + +### Partial Answers Files + +You only need to specify the fields you want to pre-populate. Missing fields will be prompted for interactively: + +```yaml +# minimal-answers.yml +system_identity: MYSITE +network_peer_id: 100001 +# User will be prompted for other values +``` + +### Saving Generated Answers + +After creating a configuration interactively, you can extract the answers: + +```python +from answers_loader import AnswersLoader +from pathlib import Path + +# After wizard completes +answers = { + 'template': 'enhanced', + 'system_identity': 'MYSITE001', + 'network_peer_id': 100001, + # ... etc +} + +AnswersLoader.save_answers(answers, Path('my-site-answers.yml')) +``` + +### Validation + +Answers files are validated automatically, but you can check them manually: + +```python +from answers_loader import AnswersLoader +from pathlib import Path + +answers = AnswersLoader.load_answers(Path('my-answers.yml')) + +# Non-strict validation (warns about unknown keys) +is_valid = AnswersLoader.validate_answers(answers, strict=False) + +# Strict validation (fails on unknown keys) +is_valid = AnswersLoader.validate_answers(answers, strict=True) +``` + +## Key Features + +### ✅ Backward Compatible +- Existing wizard usage works unchanged +- Answers file is completely optional +- No breaking changes + +### ✅ Flexible +- Answer any or all questions +- Interactive prompts for missing answers +- Easy to create custom templates + +### ✅ Easy to Use +- Simple YAML format +- Clear comments in example files +- Error messages for invalid files + +### ✅ Reproducible +- Save and version control answers files +- Generate identical configs across systems +- Document configuration decisions + +## Troubleshooting + +### "Unrecognized keys in answers file" + +This is a warning (not an error) if your answers file contains unknown keys. It won't stop the wizard from running. To suppress this, ensure you're only using valid keys from the lists above. + +### "Error loading answers file" + +Check that: +1. The file path is correct +2. The file is valid YAML (use `python3 -c "import yaml; yaml.safe_load(open('file.yml'))"` +3. The file has proper indentation (2 spaces per level) + +### Answers not being used + +Verify that: +1. The key name matches exactly (case-sensitive) +2. The file is being passed with `-a` or `--answers-file` +3. The value type is correct (string, integer, boolean) + +## File Reference + +### Core Files +- `answers_loader.py` - Utility for loading/validating answers +- `wizard.py` - ConfigWizard and TrunkingWizard classes (refactored) +- `dvmcfg.py` - CLI interface (updated with `--answers-file` support) + +### Example Files +- `examples/conventional-answers.yml` - Example for conventional systems +- `examples/trunked-answers.yml` - Example for trunked systems + diff --git a/tools/dvmcfggen/answers_loader.py b/tools/dvmcfggen/answers_loader.py new file mode 100644 index 00000000..c5533ac5 --- /dev/null +++ b/tools/dvmcfggen/answers_loader.py @@ -0,0 +1,194 @@ +#!/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. +#*/ +""" +answers_loader.py - Load and validate wizard answers from YAML files + +This module handles loading answers files that can be used to pre-populate +wizard defaults, enabling batch configuration generation and automation. +""" + +from pathlib import Path +from typing import Dict, Any, Optional +import yaml +from rich.console import Console + +console = Console() + + +class AnswersLoader: + """Load and validate wizard answers from YAML files""" + + # All supported answer keys for ConfigWizard + CONFIG_WIZARD_KEYS = { + 'template', 'config_dir', 'system_identity', 'network_peer_id', + 'configure_logging', 'log_path', 'activity_log_path', 'log_root', + 'use_syslog', 'disable_non_auth_logging', 'modem_type', 'modem_mode', + 'serial_port', 'rx_level', 'tx_level', 'rpc_config', 'generate_rpc_password', + 'rest_enable', 'generate_rest_password', 'update_lookups', 'save_lookups', + 'allow_activity_transfer', 'allow_diagnostic_transfer', 'allow_status_transfer', + 'radio_id_file', 'radio_id_time', 'radio_id_acl', 'talkgroup_id_file', + 'talkgroup_id_time', 'talkgroup_id_acl', 'dmr_color_code', 'dmr_network_id', + 'dmr_site_id', 'dmr_site_model', 'p25_nac', 'p25_network_id', 'p25_system_id', + 'p25_rfss_id', 'p25_site_id', 'nxdn_ran', 'nxdn_location_id', 'site_id', + 'latitude', 'longitude', 'location', 'tx_power', 'tx_freq', 'band' + } + + # All supported answer keys for TrunkingWizard + TRUNKING_WIZARD_KEYS = { + 'system_name', 'base_dir', 'identity', 'protocol', 'vc_count', + 'configure_logging', 'log_path', 'activity_log_path', 'log_root', + 'use_syslog', 'disable_non_auth_logging', 'fne_address', 'fne_port', + 'fne_password', 'base_peer_id', 'base_rpc_port', 'modem_type', + 'rpc_password', 'generate_rpc_password', 'base_rest_port', 'rest_enable', + 'rest_password', 'generate_rest_password', 'update_lookups', 'save_lookups', + 'allow_activity_transfer', 'allow_diagnostic_transfer', 'allow_status_transfer', + 'radio_id_file', 'radio_id_time', 'radio_id_acl', 'talkgroup_id_file', + 'talkgroup_id_time', 'talkgroup_id_acl', 'dmr_color_code', 'dmr_network_id', + 'dmr_site_id', 'p25_nac', 'p25_network_id', 'p25_system_id', 'p25_rfss_id', + 'p25_site_id', 'site_id', 'cc_tx_freq', 'cc_band', 'vc_tx_freqs', 'vc_bands' + } + + ALL_KEYS = CONFIG_WIZARD_KEYS | TRUNKING_WIZARD_KEYS + + @staticmethod + def load_answers(filepath: Path) -> Dict[str, Any]: + """ + Load answers from YAML file + + Args: + filepath: Path to YAML answers file + + Returns: + Dictionary of answers + + Raises: + FileNotFoundError: If file doesn't exist + yaml.YAMLError: If file is invalid YAML + """ + try: + with open(filepath, 'r') as f: + answers = yaml.safe_load(f) + + if answers is None: + return {} + + if not isinstance(answers, dict): + raise ValueError("Answers file must contain a YAML dictionary") + + return answers + except FileNotFoundError: + console.print(f"[red]Error: Answers file not found: {filepath}[/red]") + raise + except yaml.YAMLError as e: + console.print(f"[red]Error: Invalid YAML in answers file: {e}[/red]") + raise + + @staticmethod + def validate_answers(answers: Dict[str, Any], strict: bool = False) -> bool: + """ + Validate answers file has recognized keys + + Args: + answers: Dictionary of answers to validate + strict: If True, fail on unrecognized keys; if False, warn only + + Returns: + True if valid, False if invalid (in strict mode) + """ + invalid_keys = set(answers.keys()) - AnswersLoader.ALL_KEYS + + if invalid_keys: + msg = f"Unrecognized keys in answers file: {', '.join(sorted(invalid_keys))}" + if strict: + console.print(f"[red]{msg}[/red]") + return False + else: + console.print(f"[yellow]Warning: {msg}[/yellow]") + + return True + + @staticmethod + def save_answers(answers: Dict[str, Any], filepath: Path) -> None: + """ + Save answers to YAML file + + Args: + answers: Dictionary of answers to save + filepath: Path to save answers to + """ + filepath.parent.mkdir(parents=True, exist_ok=True) + + with open(filepath, 'w') as f: + yaml.dump(answers, f, default_flow_style=False, sort_keys=False) + + @staticmethod + def merge_answers(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: + """ + Merge two answers dictionaries (override takes precedence) + + Args: + base: Base answers dictionary + override: Override answers dictionary + + Returns: + Merged dictionary + """ + merged = base.copy() + merged.update(override) + return merged + + @staticmethod + def create_template() -> Dict[str, Any]: + """ + Create a template answers dictionary with common fields + + Returns: + Template dictionary with helpful comments + """ + return { + # Wizard type + 'template': 'enhanced', + + # Basic configuration + 'config_dir': '.', + 'system_identity': 'SITE001', + 'network_peer_id': 100000, + + # Logging configuration + 'configure_logging': True, + 'log_path': '/var/log/dvm', + 'activity_log_path': '/var/log/dvm/activity', + 'log_root': 'DVM', + 'use_syslog': False, + 'disable_non_auth_logging': False, + + # Modem configuration + 'modem_type': 'uart', + 'modem_mode': 'air', + 'serial_port': '/dev/ttyUSB0', + 'rx_level': 50, + 'tx_level': 50, + } diff --git a/tools/dvmcfggen/dvmcfg.py b/tools/dvmcfggen/dvmcfg.py index ed4c5ef3..0f13c01f 100755 --- a/tools/dvmcfggen/dvmcfg.py +++ b/tools/dvmcfggen/dvmcfg.py @@ -39,6 +39,7 @@ from rich import print as rprint from config_manager import DVMConfig from templates import TEMPLATES, get_template from trunking_manager import TrunkingSystem +from answers_loader import AnswersLoader 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 @@ -225,6 +226,18 @@ def cmd_create(args): if args.tx_power is not None: config.set('system.info.power', args.tx_power) + # Handle logging configuration + if args.log_path: + config.set('log.filePath', args.log_path) + if args.activity_log_path: + config.set('log.activityFilePath', args.activity_log_path) + if args.log_root: + config.set('log.fileRoot', args.log_root) + if args.use_syslog is not None: + config.set('log.useSysLog', args.use_syslog) + if args.disable_non_auth_logging is not None: + config.set('log.disableNonAuthoritiveLogging', args.disable_non_auth_logging) + # Handle frequency configuration iden_table = None if args.tx_freq and args.band: @@ -535,6 +548,18 @@ def cmd_trunk_create(args): if args.talkgroup_id_acl is not None: create_kwargs['talkgroup_id_acl'] = args.talkgroup_id_acl + # Add logging settings + if args.log_path: + create_kwargs['log_path'] = args.log_path + if args.activity_log_path: + create_kwargs['activity_log_path'] = args.activity_log_path + if args.log_root: + create_kwargs['log_root'] = args.log_root + if args.use_syslog is not None: + create_kwargs['use_syslog'] = args.use_syslog + if args.disable_non_auth_logging is not None: + create_kwargs['disable_non_auth_logging'] = args.disable_non_auth_logging + # Add protocol-specific settings if args.protocol == 'p25': create_kwargs['nac'] = args.nac @@ -665,8 +690,20 @@ def cmd_list_templates(args): def cmd_wizard(args): """Run interactive wizard""" + answers = {} + + # Load answers from file if provided + if hasattr(args, 'answers_file') and args.answers_file: + try: + answers = AnswersLoader.load_answers(args.answers_file) + # Optionally validate answers + AnswersLoader.validate_answers(answers, strict=False) + except Exception as e: + console.print(f"[red]Error loading answers file:[/red] {e}") + sys.exit(1) + wizard_type = args.type if hasattr(args, 'type') else 'auto' - result = run_wizard(wizard_type) + result = run_wizard(wizard_type, answers) if not result: sys.exit(1) @@ -784,6 +821,16 @@ DVMHost Configuration Manager""" 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)') + + # Logging configuration + create_parser.add_argument('--log-path', help='Log file directory path') + create_parser.add_argument('--activity-log-path', help='Activity log directory path') + create_parser.add_argument('--log-root', help='Log filename prefix and syslog prefix') + create_parser.add_argument('--use-syslog', type=lambda x: x.lower() in ('true', '1', 'yes'), + help='Enable syslog output (true/false)') + create_parser.add_argument('--disable-non-auth-logging', type=lambda x: x.lower() in ('true', '1', 'yes'), + help='Disable non-authoritative logging (true/false)') + create_parser.add_argument('--validate', action='store_true', help='Validate after creation') create_parser.set_defaults(func=cmd_create) @@ -881,6 +928,16 @@ DVMHost Configuration Manager""" 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)') + + # Logging configuration + trunk_create_parser.add_argument('--log-path', help='Log file directory path') + trunk_create_parser.add_argument('--activity-log-path', help='Activity log directory path') + trunk_create_parser.add_argument('--log-root', help='Log filename prefix and syslog prefix') + trunk_create_parser.add_argument('--use-syslog', type=lambda x: x.lower() in ('true', '1', 'yes'), + help='Enable syslog output (true/false)') + trunk_create_parser.add_argument('--disable-non-auth-logging', type=lambda x: x.lower() in ('true', '1', 'yes'), + help='Disable non-authoritative logging (true/false)') + trunk_create_parser.set_defaults(func=cmd_trunk_create) # trunk validate @@ -905,6 +962,8 @@ DVMHost Configuration Manager""" 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.add_argument('--answers-file', '-a', type=Path, + help='Optional YAML file with wizard answers (uses as defaults)') wizard_parser.set_defaults(func=cmd_wizard) args = parser.parse_args() diff --git a/tools/dvmcfggen/examples/conventional-answers.yml b/tools/dvmcfggen/examples/conventional-answers.yml new file mode 100644 index 00000000..026e2140 --- /dev/null +++ b/tools/dvmcfggen/examples/conventional-answers.yml @@ -0,0 +1,87 @@ +# +# Digital Voice Modem - Host Configuration Generator +# +# Example answers file for conventional system configuration +# This file can be used with: dvmcfg wizard -a conventional-answers.yml +# All fields are optional; prompts will appear for missing values + +# Conventional system with enhanced template +template: enhanced + +# Basic Configuration (Step 2) +config_dir: . +system_identity: MYSITE001 +network_peer_id: 100001 + +# Logging Configuration (Step 3) +configure_logging: true +log_path: /var/log/dvm +activity_log_path: /var/log/dvm/activity +log_root: MYSITE +use_syslog: false +disable_non_auth_logging: false + +# Modem Configuration (Step 4) +modem_type: uart +modem_mode: air +serial_port: /dev/ttyUSB0 +rx_level: 50 +tx_level: 50 + +# Uncomment and modify the sections below as needed: + +# RPC Configuration (Step 5a) - optional +# rpc_config: true +# generate_rpc_password: true + +# REST API Configuration (Step 5b) - optional +# rest_enable: true +# generate_rest_password: true + +# Network Lookups (Step 5c) - optional +# update_lookups: true +# save_lookups: true + +# File Transfer Settings (Step 5d) - optional +# allow_activity_transfer: true +# allow_diagnostic_transfer: true +# allow_status_transfer: true + +# Radio ID File Settings (Step 6) - optional +# radio_id_file: /path/to/rid.csv +# radio_id_time: 3600 +# radio_id_acl: blacklist + +# Talkgroup ID File Settings (Step 7) - optional +# talkgroup_id_file: /path/to/talkgroups.csv +# talkgroup_id_time: 3600 +# talkgroup_id_acl: whitelist + +# Protocol Configuration (Step 8) - optional +# For DMR: +# dmr_color_code: 1 +# dmr_network_id: 1 +# dmr_site_id: 1 +# dmr_site_model: small + +# For P25: +# p25_nac: 0x293 +# p25_network_id: 1 +# p25_system_id: 1 +# p25_rfss_id: 1 +# p25_site_id: 1 + +# For NXDN: +# nxdn_ran: 1 +# nxdn_location_id: 1 + +# Frequency Configuration (Step 9) - optional +# site_id: 1 +# tx_power: 50 +# tx_freq: 453.0125 +# band: uhf + +# Location Information (Step 10) - optional +# latitude: 40.7128 +# longitude: -74.0060 +# location: "New York, NY" diff --git a/tools/dvmcfggen/examples/trunked-answers.yml b/tools/dvmcfggen/examples/trunked-answers.yml new file mode 100644 index 00000000..e7d46bb8 --- /dev/null +++ b/tools/dvmcfggen/examples/trunked-answers.yml @@ -0,0 +1,72 @@ +# +# Digital Voice Modem - Host Configuration Generator +# +# Example answers file for trunked system configuration +# This file can be used with: dvmcfg wizard --type trunk -a trunked-answers.yml +# All fields are optional; prompts will appear for missing values + +# System Configuration (Step 1) +system_name: trunked_test +base_dir: . +identity: TRUNKED001 +protocol: p25 +vc_count: 2 + +# Logging Configuration (Step 2) +configure_logging: true +log_path: /var/log/dvm +activity_log_path: /var/log/dvm/activity +log_root: TRUNKED +use_syslog: false +disable_non_auth_logging: false + +# Network Settings (Step 3) - optional +# fne_address: 127.0.0.1 +# fne_port: 62031 +# fne_password: PASSWORD + +# Control Channel Settings (Step 4) - optional +# base_peer_id: 100000 +# base_rpc_port: 9000 +# modem_type: uart + +# Voice Channel Settings (Step 5) - optional +# cc_tx_freq: 453.0125 +# cc_band: uhf +# vc_tx_freqs: "453.0375,453.0625" +# vc_bands: "uhf,uhf" + +# Network/System IDs (Step 6) - optional +# For P25: +# p25_nac: 0x293 +# p25_network_id: 1 +# p25_system_id: 1 +# p25_rfss_id: 1 +# p25_site_id: 1 + +# For DMR: +# dmr_color_code: 1 +# dmr_network_id: 1 +# dmr_site_id: 1 + +# RPC Configuration (Step 6a) - optional +# generate_rpc_password: true + +# Network Lookups (Step 6b) - optional +# update_lookups: true +# save_lookups: true + +# File Transfer Settings (Step 6c) - optional +# allow_activity_transfer: true +# allow_diagnostic_transfer: true +# allow_status_transfer: true + +# Radio ID File Settings (Step 7) - optional +# radio_id_file: /path/to/rid.csv +# radio_id_time: 3600 +# radio_id_acl: blacklist + +# Talkgroup ID File Settings (Step 8) - optional +# talkgroup_id_file: /path/to/talkgroups.csv +# talkgroup_id_time: 3600 +# talkgroup_id_acl: whitelist diff --git a/tools/dvmcfggen/trunking_manager.py b/tools/dvmcfggen/trunking_manager.py index 5a58c585..e6935cda 100644 --- a/tools/dvmcfggen/trunking_manager.py +++ b/tools/dvmcfggen/trunking_manager.py @@ -91,7 +91,12 @@ class TrunkingSystem: sys_id: int = None, rfss_id: int = None, ran: int = None, - vc_channels: List[Dict[str, int]] = None) -> None: + vc_channels: List[Dict[str, int]] = None, + log_path: str = None, + activity_log_path: str = None, + log_root: str = None, + use_syslog: bool = False, + disable_non_auth_logging: bool = False) -> None: """ Create a complete trunked system @@ -134,6 +139,11 @@ class TrunkingSystem: 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, ...}, ...] + log_path: Log file directory path + activity_log_path: Activity log directory path + log_root: Log filename prefix and syslog prefix + use_syslog: Enable syslog output + disable_non_auth_logging: Disable non-authoritative logging """ # If no VC channel info provided, use defaults (same ID as CC, sequential numbers) @@ -199,6 +209,18 @@ class TrunkingSystem: self.cc_config.set('system.talkgroup_id.file', talkgroup_id_file) self.cc_config.set('system.talkgroup_id.acl', talkgroup_id_acl) + # Logging settings + if log_path: + self.cc_config.set('log.filePath', log_path) + if activity_log_path: + self.cc_config.set('log.activityFilePath', activity_log_path) + if log_root: + self.cc_config.set('log.fileRoot', log_root) + if use_syslog: + self.cc_config.set('log.useSysLog', use_syslog) + if disable_non_auth_logging: + self.cc_config.set('log.disableNonAuthoritiveLogging', disable_non_auth_logging) + self.cc_config.set('system.modem.protocol.type', modem_type) # DFSI Configuration for control channel (if provided) @@ -310,6 +332,18 @@ class TrunkingSystem: vc_config.set('system.talkgroup_id.file', talkgroup_id_file) vc_config.set('system.talkgroup_id.acl', talkgroup_id_acl) + # Logging settings + if log_path: + vc_config.set('log.filePath', log_path) + if activity_log_path: + vc_config.set('log.activityFilePath', activity_log_path) + if log_root: + vc_config.set('log.fileRoot', log_root) + if use_syslog: + vc_config.set('log.useSysLog', use_syslog) + if disable_non_auth_logging: + vc_config.set('log.disableNonAuthoritiveLogging', disable_non_auth_logging) + 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) diff --git a/tools/dvmcfggen/wizard.py b/tools/dvmcfggen/wizard.py index c197959b..1cf4d7cc 100644 --- a/tools/dvmcfggen/wizard.py +++ b/tools/dvmcfggen/wizard.py @@ -43,6 +43,7 @@ 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 ( @@ -67,7 +68,8 @@ def generate_random_password(length: int = 24) -> str: class ConfigWizard: """Interactive configuration wizard""" - def __init__(self): + 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() @@ -75,6 +77,32 @@ class ConfigWizard: 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""" @@ -143,13 +171,17 @@ class ConfigWizard: console.print("\n[bold]Step 2: Basic Configuration[/bold]\n") # Configuration directory - self.config_dir = Prompt.ask( + self.config_dir = self._get_answer( + 'config_dir', + Prompt.ask, "Configuration directory", default="." ) # System Identity - identity = Prompt.ask( + identity = self._get_answer( + 'system_identity', + Prompt.ask, "System identity (callsign or site name)", default="SITE001" ) @@ -157,20 +189,80 @@ class ConfigWizard: self.config.set('system.cwId.callsign', identity[:8]) # Truncate for CW # Peer ID - peer_id = IntPrompt.ask( + peer_id = self._get_answer( + 'network_peer_id', + IntPrompt.ask, "Network peer ID", default=100000 ) self.config.set('network.id', peer_id) - console.print("\n[bold]Step 3: Modem Configuration[/bold]\n") + # 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 = Prompt.ask( + modem_type = self._get_answer( + 'modem_type', + Prompt.ask, "Modem type", choices=['uart', 'null'], default='uart' @@ -182,7 +274,9 @@ class ConfigWizard: console.print(" air - Standard air interface (repeater/hotspot)") console.print(" dfsi - DFSI mode for interfacing with V.24 repeaters\n") - modem_mode = Prompt.ask( + modem_mode = self._get_answer( + 'modem_mode', + Prompt.ask, "Modem mode", choices=['air', 'dfsi'], default='air' @@ -190,20 +284,26 @@ class ConfigWizard: self.config.set('system.modem.protocol.mode', modem_mode) if modem_type == 'uart': - serial_port = Prompt.ask( + 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 = IntPrompt.ask( + 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 = IntPrompt.ask( + tx_level = self._get_answer( + 'tx_level', + IntPrompt.ask, "TX level (0-100)", default=50 ) @@ -213,50 +313,69 @@ class ConfigWizard: if modem_mode == 'dfsi': console.print("\n[bold]Step 3b: DFSI Settings[/bold]\n") - if Confirm.ask("Configure DFSI settings?", default=False): - rtrt = Confirm.ask( + 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 = IntPrompt.ask( + jitter = self._get_answer( + 'dfsi_jitter', + IntPrompt.ask, "DFSI Jitter (ms)", default=200 ) self.config.set('system.modem.dfsiJitter', jitter) - call_timeout = IntPrompt.ask( + 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 = Confirm.ask( + 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 4: Network Settings[/bold]\n") + console.print("\n[bold]Step 5: Network Settings[/bold]\n") # FNE Settings - fne_address = Prompt.ask( + 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 = Prompt.ask("FNE address", default="127.0.0.1") + 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 = IntPrompt.ask( + fne_port = self._get_answer( + 'fne_port', + IntPrompt.ask, "FNE port", default=62031 ) self.config.set('network.port', fne_port) - fne_password = Prompt.ask( + fne_password = self._get_answer( + 'fne_password', + Prompt.ask, "FNE password", default="PASSWORD", password=True @@ -264,7 +383,7 @@ class ConfigWizard: self.config.set('network.password', fne_password) # RPC Configuration - console.print("\n[bold]Step 4a: RPC & REST API Configuration[/bold]\n") + console.print("\n[bold]Step 5a: RPC & REST API Configuration[/bold]\n") # Generate random RPC password rpc_password = generate_random_password() @@ -275,7 +394,12 @@ class ConfigWizard: self.rpc_password = rpc_password # Ask about REST API - if Confirm.ask("Enable REST API?", default=False): + 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}") @@ -286,46 +410,81 @@ class ConfigWizard: self.rest_password = None # Network Lookup and Transfer Settings - console.print("\n[bold]Step 4b: Network Lookup & Transfer Settings[/bold]\n") + console.print("\n[bold]Step 5b: Network Lookup & Transfer Settings[/bold]\n") - update_lookups = Confirm.ask("Update lookups from network?", default=True) + 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 = Confirm.ask("Save lookups from network?", default=False) + save_lookups = self._get_answer( + 'save_lookups', + Confirm.ask, + "Save lookups from network?", + default=False + ) self.config.set('network.saveLookups', save_lookups) - allow_activity = Confirm.ask("Allow activity transfer to FNE?", default=True) + 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 = Confirm.ask("Allow diagnostic transfer to FNE?", default=False) + 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 = Confirm.ask("Allow status transfer to FNE?", default=True) + 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 4c: Radio ID & Talkgroup ID Configuration[/bold]\n") - - if Confirm.ask("Configure Radio ID and Talkgroup ID settings?", default=False): + 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 = Prompt.ask( + 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 = IntPrompt.ask( + 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 = Confirm.ask( + radio_id_acl = self._get_answer( + 'radio_id_acl', + Confirm.ask, "Enforce Radio ID ACLs?", default=False ) @@ -333,27 +492,33 @@ class ConfigWizard: # Talkgroup ID Configuration console.print("\n[dim]Talkgroup ID Settings:[/dim]") - talkgroup_id_file = Prompt.ask( + 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 = IntPrompt.ask( + 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 = Confirm.ask( + 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 5: Protocol Selection[/bold]\n") + 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]") @@ -363,7 +528,9 @@ class ConfigWizard: console.print(" 2. DMR") console.print(" 3. NXDN\n") - primary_mode = IntPrompt.ask( + primary_mode = self._get_answer( + 'primary_mode', + IntPrompt.ask, "Primary mode", choices=['1', '2', '3'], default='1' @@ -382,22 +549,37 @@ class ConfigWizard: console.print("\n[dim]Additional modes (for multi-mode repeaters only):[/dim]") if not enable_p25: - if Confirm.ask("Also enable P25?", default=False): + 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 Confirm.ask("Also enable DMR?", default=False): + 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 Confirm.ask("Also enable NXDN?", default=False): + 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 6: Network/System ID Configuration[/bold]\n") + console.print("\n[bold]Step 7: Network/System ID Configuration[/bold]\n") # DMR Configuration if enable_dmr: @@ -409,7 +591,12 @@ class ConfigWizard: console.print(" 2. TINY - Large NetID range (NetID: 1-511, SiteID: 1-7)") console.print(" 3. LARGE - Large SiteID range (NetID: 1-31, SiteID: 1-127)") console.print(" 4. HUGE - Very large SiteID (NetID: 1-3, SiteID: 1-1023)") - site_model_choice = IntPrompt.ask("Select site model", default=1) + site_model_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) @@ -417,7 +604,9 @@ class ConfigWizard: dmr_info = get_network_id_info('dmr', site_model_str) # Color Code - color_code = IntPrompt.ask( + 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'] ) @@ -430,7 +619,9 @@ class ConfigWizard: self.config.set('system.config.colorCode', color_code) # DMR Network ID - dmr_net_id = IntPrompt.ask( + 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'] ) @@ -443,7 +634,9 @@ class ConfigWizard: self.config.set('system.config.dmrNetId', dmr_net_id) # DMR Site ID - dmr_site_id = IntPrompt.ask( + 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'] ) @@ -461,7 +654,9 @@ class ConfigWizard: p25_info = get_network_id_info('p25') # NAC - nac_str = Prompt.ask( + 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}" ) @@ -479,7 +674,9 @@ class ConfigWizard: self.config.set('system.config.nac', nac) # Network ID (WACN) - net_id_str = Prompt.ask( + 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}" ) @@ -497,7 +694,9 @@ class ConfigWizard: self.config.set('system.config.netId', net_id) # System ID - sys_id_str = Prompt.ask( + 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}" ) @@ -515,7 +714,9 @@ class ConfigWizard: self.config.set('system.config.sysId', sys_id) # RFSS ID - rfss_id = IntPrompt.ask( + 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'] ) @@ -528,7 +729,9 @@ class ConfigWizard: self.config.set('system.config.rfssId', rfss_id) # P25 Site ID - p25_site_id = IntPrompt.ask( + 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'] ) @@ -548,7 +751,9 @@ class ConfigWizard: nxdn_info = get_network_id_info('nxdn') # RAN - ran = IntPrompt.ask( + ran = self._get_answer( + 'nxdn_ran', + IntPrompt.ask, f"NXDN RAN ({nxdn_info['ran']['min']}-{nxdn_info['ran']['max']})", default=nxdn_info['ran']['default'] ) @@ -561,7 +766,9 @@ class ConfigWizard: self.config.set('system.config.ran', ran) # System ID (Location ID) - nxdn_sys_id_str = Prompt.ask( + 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}" ) @@ -581,7 +788,9 @@ class ConfigWizard: self.config.set('system.config.sysId', nxdn_sys_id) # NXDN Site ID - nxdn_site_id_str = Prompt.ask( + 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']) ) @@ -602,31 +811,61 @@ class ConfigWizard: # If no protocols enabled DMR/P25/NXDN individually, still prompt for generic site ID if not enable_dmr and not enable_p25 and not enable_nxdn: - site_id = IntPrompt.ask("Site ID", default=1) + 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 7: Frequency Configuration[/bold]\n") + 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 8: Location Information (optional)[/bold]\n") - - if Confirm.ask("Configure location information?", default=False): + 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(Prompt.ask("Latitude", default="0.0")) + latitude = float(self._get_answer( + 'latitude', + Prompt.ask, + "Latitude", + default="0.0" + )) self.config.set('system.info.latitude', latitude) - longitude = float(Prompt.ask("Longitude", default="0.0")) + longitude = float(self._get_answer( + 'longitude', + Prompt.ask, + "Longitude", + default="0.0" + )) self.config.set('system.info.longitude', longitude) - location = Prompt.ask("Location description", default="") + location = self._get_answer( + 'location_description', + Prompt.ask, + "Location description", + default="" + ) if location: self.config.set('system.info.location', location) - power = IntPrompt.ask("TX power (watts)", default=10) + 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]") @@ -660,7 +899,9 @@ class ConfigWizard: console.print() # Select band - band_choice = IntPrompt.ask( + 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" @@ -672,7 +913,12 @@ class ConfigWizard: # 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): + 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 @@ -686,7 +932,9 @@ class ConfigWizard: tx_freq_mhz = None while tx_freq_mhz is None: try: - tx_input = Prompt.ask( + tx_input = self._get_answer( + 'tx_frequency', + Prompt.ask, "Transmit frequency (MHz)", default=f"{preset['tx_range'][0]:.4f}" ) @@ -839,16 +1087,43 @@ class ConfigWizard: def _run_trunking_wizard(self) -> Optional[Path]: """Run trunking system wizard""" - wizard = TrunkingWizard() + wizard = TrunkingWizard(self.answers) return wizard.run() class TrunkingWizard: """Interactive trunking system wizard""" - def __init__(self): + 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 @@ -863,72 +1138,174 @@ class TrunkingWizard: # System basics console.print("[bold]Step 1: System Configuration[/bold]\n") - system_name = Prompt.ask( + system_name = self._get_answer( + 'system_name', + Prompt.ask, "System name (for filenames)", default="trunked" ) - base_dir = Prompt.ask( + base_dir = self._get_answer( + 'base_dir', + Prompt.ask, "Configuration directory", default="." ) - identity = Prompt.ask( + identity = self._get_answer( + 'identity', + Prompt.ask, "System identity prefix", default="SITE001" ) - protocol = Prompt.ask( + protocol = self._get_answer( + 'protocol', + Prompt.ask, "Protocol", choices=['p25', 'dmr'], default='p25' ) - vc_count = IntPrompt.ask( + 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 2: Network Settings[/bold]\n") + console.print("\n[bold]Step 3: Network Settings[/bold]\n") - fne_address = Prompt.ask( + fne_address = self._get_answer( + 'fne_address', + Prompt.ask, "FNE address", default="127.0.0.1" ) - fne_port = IntPrompt.ask( + fne_port = self._get_answer( + 'fne_port', + IntPrompt.ask, "FNE port", default=62031 ) - fne_password = Prompt.ask( + fne_password = self._get_answer( + 'fne_password', + Prompt.ask, "FNE password", default="PASSWORD", password=True ) - base_peer_id = IntPrompt.ask( + 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 = IntPrompt.ask( + 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 2a: Network Lookup & Transfer Settings[/bold]\n") + console.print("\n[bold]Step 3a: Network Lookup & Transfer Settings[/bold]\n") - update_lookups = Confirm.ask("Update lookups from network?", default=True) - save_lookups = Confirm.ask("Save lookups from network?", default=False) - allow_activity = Confirm.ask("Allow activity transfer to FNE?", default=True) - allow_diagnostic = Confirm.ask("Allow diagnostic transfer to FNE?", default=False) - allow_status = Confirm.ask("Allow status transfer to FNE?", default=True) + 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 2b: Radio ID & Talkgroup ID Configuration[/bold]\n") + console.print("\n[bold]Step 3b: Radio ID & Talkgroup ID Configuration[/bold]\n") radio_id_file = None radio_id_time = 0 @@ -937,59 +1314,83 @@ class TrunkingWizard: talkgroup_id_time = 0 talkgroup_id_acl = False - if Confirm.ask("Configure Radio ID and Talkgroup ID settings?", default=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 = Prompt.ask( + 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 = IntPrompt.ask( + radio_id_time = self._get_answer( + 'radio_id_time', + IntPrompt.ask, "Radio ID update time (seconds, 0 = disabled)", default=0 ) - radio_id_acl = Confirm.ask( + 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 = Prompt.ask( + 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 = IntPrompt.ask( + talkgroup_id_time = self._get_answer( + 'talkgroup_id_time', + IntPrompt.ask, "Talkgroup ID update time (seconds, 0 = disabled)", default=0 ) - talkgroup_id_acl = Confirm.ask( + 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 2c: RPC & REST API Configuration[/bold]\n") + 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 = IntPrompt.ask( + 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 = Confirm.ask("Enable REST API?", default=False) + 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}") @@ -997,7 +1398,7 @@ class TrunkingWizard: rest_password = None # Network/System ID Configuration - console.print("\n[bold]Step 3: Network/System ID Configuration[/bold]\n") + console.print("\n[bold]Step 4: Network/System ID Configuration[/bold]\n") console.print(f"[cyan]Protocol: {protocol.upper()}[/cyan]\n") # Initialize variables @@ -1015,7 +1416,9 @@ class TrunkingWizard: p25_info = get_network_id_info('p25') # NAC - nac_str = Prompt.ask( + 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}" ) @@ -1032,7 +1435,9 @@ class TrunkingWizard: nac_str = Prompt.ask("P25 NAC", default="0x293") # Network ID (WACN) - net_id_str = Prompt.ask( + 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}" ) @@ -1049,7 +1454,9 @@ class TrunkingWizard: net_id_str = Prompt.ask("P25 Network ID", default="0xBB800") # System ID - sys_id_str = Prompt.ask( + 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}" ) @@ -1066,7 +1473,9 @@ class TrunkingWizard: sys_id_str = Prompt.ask("P25 System ID", default="0x001") # RFSS ID - rfss_id = IntPrompt.ask( + 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'] ) @@ -1078,7 +1487,9 @@ class TrunkingWizard: rfss_id = IntPrompt.ask("P25 RFSS ID", default=1) # Site ID - site_id = IntPrompt.ask( + 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'] ) @@ -1096,7 +1507,12 @@ class TrunkingWizard: console.print(" 2. TINY - Large NetID range (NetID: 1-511, SiteID: 1-7)") console.print(" 3. LARGE - Large SiteID range (NetID: 1-31, SiteID: 1-127)") console.print(" 4. HUGE - Very large SiteID (NetID: 1-3, SiteID: 1-1023)") - site_model_choice = IntPrompt.ask("Select site model", default=1) + site_model_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) @@ -1104,7 +1520,9 @@ class TrunkingWizard: dmr_info = get_network_id_info('dmr', site_model_str) # Color Code - color_code = IntPrompt.ask( + 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'] ) @@ -1116,7 +1534,9 @@ class TrunkingWizard: color_code = IntPrompt.ask("DMR Color Code", default=1) # DMR Network ID - dmr_net_id = IntPrompt.ask( + 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'] ) @@ -1128,7 +1548,9 @@ class TrunkingWizard: dmr_net_id = IntPrompt.ask("DMR Network ID", default=1) # DMR Site ID - site_id = IntPrompt.ask( + 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'] ) @@ -1142,13 +1564,13 @@ class TrunkingWizard: console.print(f"\n[dim]These values will be applied consistently across all channels (CC and VCs)[/dim]") # Frequency configuration for control channel - console.print("\n[bold]Step 4: Control Channel Frequency and Modem[/bold]\n") + 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 5: Voice Channel Frequencies and Modems[/bold]\n") + console.print("\n[bold]Step 6: Voice Channel Frequencies and Modems[/bold]\n") vc_channels = [] for i in range(1, vc_count + 1): @@ -1288,7 +1710,12 @@ class TrunkingWizard: '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 + '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 @@ -1496,12 +1923,13 @@ class TrunkingWizard: } -def run_wizard(wizard_type: str = 'auto') -> Optional[Path]: +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 @@ -1532,10 +1960,10 @@ def run_wizard(wizard_type: str = 'auto') -> Optional[Path]: wizard_type = 'single' if int(choice) == 1 else 'trunk' if wizard_type == 'single': - wizard = ConfigWizard() + wizard = ConfigWizard(answers) return wizard.run() else: - wizard = TrunkingWizard() + wizard = TrunkingWizard(answers) return wizard.run() except KeyboardInterrupt: