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.
332 lines
11 KiB
332 lines
11 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
|
|
|
|
Identity Table (IDEN) Management
|
|
Handles frequency mapping and channel ID/number calculation
|
|
Based on iden_channel_calc.py from skynet_tools_host
|
|
"""
|
|
|
|
from typing import Dict, List, Tuple, Optional
|
|
from pathlib import Path
|
|
|
|
|
|
# Constants
|
|
MAX_FREQ_GAP = 30000000 # 30 MHz maximum offset from base frequency
|
|
HZ_MHZ = 1000000.0
|
|
KHZ_HZ = 1000.0
|
|
|
|
|
|
class IdenEntry:
|
|
"""Represents a single identity table entry"""
|
|
|
|
def __init__(self, channel_id: int, base_freq: int, spacing: float,
|
|
input_offset: float, bandwidth: float):
|
|
"""
|
|
Initialize IDEN entry
|
|
|
|
Args:
|
|
channel_id: Channel identity (0-15)
|
|
base_freq: Base frequency in Hz
|
|
spacing: Channel spacing in kHz
|
|
input_offset: Input offset (RX-TX) in MHz
|
|
bandwidth: Channel bandwidth in kHz
|
|
"""
|
|
self.channel_id = channel_id
|
|
self.base_freq = base_freq
|
|
self.spacing = spacing
|
|
self.input_offset = input_offset
|
|
self.bandwidth = bandwidth
|
|
|
|
def to_line(self) -> str:
|
|
"""Convert to iden_table.dat format line"""
|
|
return f"{self.channel_id},{self.base_freq},{self.spacing:.2f},{self.input_offset:.5f},{self.bandwidth:.1f},"
|
|
|
|
def calculate_channel_number(self, tx_freq: int) -> int:
|
|
"""
|
|
Calculate channel number for a given transmit frequency
|
|
|
|
Args:
|
|
tx_freq: Transmit frequency in Hz
|
|
|
|
Returns:
|
|
Channel number in hex
|
|
"""
|
|
if tx_freq < self.base_freq:
|
|
raise ValueError(f"TX frequency ({tx_freq/HZ_MHZ:.5f} MHz) is below base frequency "
|
|
f"({self.base_freq/HZ_MHZ:.5f} MHz)")
|
|
|
|
if tx_freq > (self.base_freq + MAX_FREQ_GAP):
|
|
raise ValueError(f"TX frequency ({tx_freq/HZ_MHZ:.5f} MHz) is too far above base frequency "
|
|
f"({self.base_freq/HZ_MHZ:.5f} MHz). Maximum gap is 25.5 MHz")
|
|
|
|
space_hz = int(self.spacing * KHZ_HZ)
|
|
root_freq = tx_freq - self.base_freq
|
|
ch_no = int(root_freq / space_hz)
|
|
|
|
return ch_no
|
|
|
|
def calculate_rx_frequency(self, tx_freq: int) -> int:
|
|
"""
|
|
Calculate receive frequency for a given transmit frequency
|
|
|
|
Args:
|
|
tx_freq: Transmit frequency in Hz
|
|
|
|
Returns:
|
|
Receive frequency in Hz
|
|
"""
|
|
offset_hz = int(self.input_offset * HZ_MHZ)
|
|
rx_freq = tx_freq + offset_hz
|
|
|
|
# Note: For negative offsets (like -45 MHz on 800MHz band),
|
|
# RX will be lower than TX and may be below base freq - this is normal
|
|
# Only validate that the frequency is reasonable (above 0)
|
|
if rx_freq < 0:
|
|
raise ValueError(f"RX frequency ({rx_freq/HZ_MHZ:.5f} MHz) is invalid (negative)")
|
|
|
|
return rx_freq
|
|
|
|
def __repr__(self) -> str:
|
|
return (f"IdenEntry(id={self.channel_id}, base={self.base_freq/HZ_MHZ:.3f}MHz, "
|
|
f"spacing={self.spacing}kHz, offset={self.input_offset}MHz, bw={self.bandwidth}kHz)")
|
|
|
|
|
|
class IdenTable:
|
|
"""Manages identity table (iden_table.dat)"""
|
|
|
|
def __init__(self):
|
|
self.entries: Dict[int, IdenEntry] = {}
|
|
|
|
def add_entry(self, entry: IdenEntry):
|
|
"""Add an identity table entry"""
|
|
if entry.channel_id < 0 or entry.channel_id > 15:
|
|
raise ValueError(f"Channel ID must be 0-15, got {entry.channel_id}")
|
|
self.entries[entry.channel_id] = entry
|
|
|
|
def get_entry(self, channel_id: int) -> Optional[IdenEntry]:
|
|
"""Get entry by channel ID"""
|
|
return self.entries.get(channel_id)
|
|
|
|
def find_entry_for_frequency(self, tx_freq: int) -> Optional[IdenEntry]:
|
|
"""
|
|
Find an identity entry that can accommodate the given TX frequency
|
|
|
|
Args:
|
|
tx_freq: Transmit frequency in Hz
|
|
|
|
Returns:
|
|
IdenEntry if found, None otherwise
|
|
"""
|
|
for entry in self.entries.values():
|
|
try:
|
|
# Check if this entry can accommodate the frequency
|
|
if entry.base_freq <= tx_freq <= (entry.base_freq + MAX_FREQ_GAP):
|
|
entry.calculate_channel_number(tx_freq) # Validate
|
|
return entry
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
def save(self, filepath: Path):
|
|
"""Save identity table to file"""
|
|
with open(filepath, 'w') as f:
|
|
f.write("#\n")
|
|
f.write("# Identity Table - Frequency Bandplan\n")
|
|
f.write("# Generated by DVMCfg\n")
|
|
f.write("#\n")
|
|
f.write("# ChId,Base Freq (Hz),Spacing (kHz),Input Offset (MHz),Bandwidth (kHz),\n")
|
|
f.write("#\n")
|
|
|
|
for ch_id in sorted(self.entries.keys()):
|
|
f.write(self.entries[ch_id].to_line() + "\n")
|
|
|
|
def load(self, filepath: Path):
|
|
"""Load identity table from file"""
|
|
self.entries.clear()
|
|
|
|
with open(filepath, 'r') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line or line.startswith('#'):
|
|
continue
|
|
|
|
parts = [p.strip() for p in line.split(',') if p.strip()]
|
|
if len(parts) >= 5:
|
|
try:
|
|
entry = IdenEntry(
|
|
channel_id=int(parts[0]),
|
|
base_freq=int(parts[1]),
|
|
spacing=float(parts[2]),
|
|
input_offset=float(parts[3]),
|
|
bandwidth=float(parts[4])
|
|
)
|
|
self.add_entry(entry)
|
|
except (ValueError, IndexError) as e:
|
|
print(f"Warning: Skipping invalid line: {line} ({e})")
|
|
|
|
def __len__(self) -> int:
|
|
return len(self.entries)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"IdenTable({len(self)} entries)"
|
|
|
|
|
|
# Common band presets
|
|
BAND_PRESETS = {
|
|
'800mhz': {
|
|
'name': '800 MHz Trunked (800T)',
|
|
'base_freq': 851006250,
|
|
'spacing': 6.25,
|
|
'input_offset': -45.0,
|
|
'bandwidth': 12.5,
|
|
'tx_range': (851.0, 870.0),
|
|
'rx_range': (806.0, 825.0),
|
|
},
|
|
'900mhz': {
|
|
'name': '900 MHz ISM/Business',
|
|
'base_freq': 935001250,
|
|
'spacing': 6.25,
|
|
'input_offset': -39.0,
|
|
'bandwidth': 12.5,
|
|
'tx_range': (935.0, 960.0),
|
|
'rx_range': (896.0, 901.0),
|
|
},
|
|
'uhf': {
|
|
'name': 'UHF 450-470 MHz',
|
|
'base_freq': 450000000,
|
|
'spacing': 6.25,
|
|
'input_offset': 5.0,
|
|
'bandwidth': 12.5,
|
|
'tx_range': (450.0, 470.0),
|
|
'rx_range': (455.0, 475.0),
|
|
},
|
|
'vhf': {
|
|
'name': 'VHF 146-148 MHz (2m Ham)',
|
|
'base_freq': 146000000,
|
|
'spacing': 6.25,
|
|
'input_offset': 0.6,
|
|
'bandwidth': 12.5,
|
|
'tx_range': (146.0, 148.0),
|
|
'rx_range': (146.6, 148.6),
|
|
},
|
|
'vhf-hi': {
|
|
'name': 'VHF-Hi 150-174 MHz',
|
|
'base_freq': 150000000,
|
|
'spacing': 6.25,
|
|
'input_offset': 0.6,
|
|
'bandwidth': 12.5,
|
|
'tx_range': (150.0, 174.0),
|
|
'rx_range': (155.0, 179.0),
|
|
},
|
|
'uhf-ham': {
|
|
'name': 'UHF 430-450 MHz (70cm Ham)',
|
|
'base_freq': 430000000,
|
|
'spacing': 6.25,
|
|
'input_offset': 5.0,
|
|
'bandwidth': 12.5,
|
|
'tx_range': (430.0, 450.0),
|
|
'rx_range': (435.0, 455.0),
|
|
},
|
|
}
|
|
|
|
|
|
def create_iden_entry_from_preset(channel_id: int, preset: str) -> IdenEntry:
|
|
"""
|
|
Create an IdenEntry from a band preset
|
|
|
|
Args:
|
|
channel_id: Channel ID (0-15)
|
|
preset: Preset name (e.g., '800mhz', 'uhf', 'vhf')
|
|
|
|
Returns:
|
|
IdenEntry configured for the band
|
|
"""
|
|
if preset not in BAND_PRESETS:
|
|
raise ValueError(f"Unknown preset: {preset}. Available: {list(BAND_PRESETS.keys())}")
|
|
|
|
band = BAND_PRESETS[preset]
|
|
return IdenEntry(
|
|
channel_id=channel_id,
|
|
base_freq=band['base_freq'],
|
|
spacing=band['spacing'],
|
|
input_offset=band['input_offset'],
|
|
bandwidth=band['bandwidth']
|
|
)
|
|
|
|
|
|
def calculate_channel_assignment(tx_freq_mhz: float, preset: str = None,
|
|
channel_id: int = None) -> Tuple[int, int, int, int]:
|
|
"""
|
|
Calculate channel ID and channel number for a given TX frequency
|
|
|
|
Args:
|
|
tx_freq_mhz: Transmit frequency in MHz
|
|
preset: Band preset to use (optional)
|
|
channel_id: Specific channel ID to use (optional, 0-15)
|
|
|
|
Returns:
|
|
Tuple of (channel_id, channel_number, tx_freq_hz, rx_freq_hz)
|
|
"""
|
|
tx_freq_hz = int(tx_freq_mhz * HZ_MHZ)
|
|
|
|
# If preset specified, use it
|
|
if preset:
|
|
entry = create_iden_entry_from_preset(channel_id or 0, preset)
|
|
ch_no = entry.calculate_channel_number(tx_freq_hz)
|
|
rx_freq = entry.calculate_rx_frequency(tx_freq_hz)
|
|
return (entry.channel_id, ch_no, tx_freq_hz, rx_freq)
|
|
|
|
# Otherwise, try to find a matching preset
|
|
for preset_name, band in BAND_PRESETS.items():
|
|
if band['tx_range'][0] <= tx_freq_mhz <= band['tx_range'][1]:
|
|
entry = create_iden_entry_from_preset(channel_id or 0, preset_name)
|
|
try:
|
|
ch_no = entry.calculate_channel_number(tx_freq_hz)
|
|
rx_freq = entry.calculate_rx_frequency(tx_freq_hz)
|
|
return (entry.channel_id, ch_no, tx_freq_hz, rx_freq)
|
|
except ValueError:
|
|
continue
|
|
|
|
raise ValueError(f"No suitable band preset found for {tx_freq_mhz} MHz. "
|
|
f"Please specify a preset or configure manually.")
|
|
|
|
|
|
def create_default_iden_table() -> IdenTable:
|
|
"""Create a default identity table with common bands"""
|
|
table = IdenTable()
|
|
|
|
# Add common band presets with different channel IDs
|
|
table.add_entry(create_iden_entry_from_preset(0, '800mhz'))
|
|
table.add_entry(create_iden_entry_from_preset(2, 'uhf'))
|
|
table.add_entry(create_iden_entry_from_preset(3, 'vhf'))
|
|
table.add_entry(create_iden_entry_from_preset(4, 'vhf-hi'))
|
|
table.add_entry(create_iden_entry_from_preset(5, 'uhf-ham'))
|
|
table.add_entry(create_iden_entry_from_preset(15, '900mhz'))
|
|
|
|
return table
|