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