From 4cbe2b5ae1cd3b908457b3a4559c88288bcbfb3a Mon Sep 17 00:00:00 2001 From: bullshit Date: Sun, 8 Feb 2026 22:01:48 +0100 Subject: [PATCH] Adds oevsv.at repeater diretory Fixes #12361 --- chirp/sources/oevsv.py | 285 ++++++++++++++++++++++++++++++++++++ chirp/wxui/main.py | 7 + chirp/wxui/query_sources.py | 86 +++++++++++ 3 files changed, 378 insertions(+) create mode 100644 chirp/sources/oevsv.py diff --git a/chirp/sources/oevsv.py b/chirp/sources/oevsv.py new file mode 100644 index 000000000..519554c14 --- /dev/null +++ b/chirp/sources/oevsv.py @@ -0,0 +1,285 @@ +# CHIRP Source Plugin for OEVSV (Österreichischer Versuchssenderverband) +# Austrian Amateur Radio Repeater Database +# +# API Documentation: https://repeater.oevsv.at/api/ +# + +import logging +import json +import requests + +from chirp import chirp_common +from chirp.sources import base + +LOG = logging.getLogger(__name__) + +# API Base URL +OEVSV_API_BASE = "https://repeater.oevsv.at/api/trx" + +# Valid bands available in the OEVSV database +BANDS = [ + ("10m (28 MHz)", "10m"), + ("6m (50 MHz)", "6m"), + ("2m (144 MHz)", "2m"), + ("70cm (430 MHz)", "70cm"), + ("23cm (1296 MHz)", "23cm"), +] + +# Station types available +STATION_TYPES = [ + ("Voice Repeaters", "repeater_voice"), + ("Digital Repeaters", "repeater_digi"), + ("Beacons", "beacon"), + ("APRS Digipeaters", "digipeater"), +] + +# Valid CTCSS tones for normalization +VALID_CTCSS_TONES = [ + 67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5, + 94.8, 97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3, + 131.8, 136.5, 141.3, 146.2, 151.4, 156.7, 159.8, 162.2, 165.5, 167.9, + 171.3, 173.8, 177.3, 179.9, 183.5, 186.2, 189.9, 192.8, 196.6, 199.5, + 203.5, 206.5, 210.7, 218.1, 225.7, 229.1, 233.6, 241.8, 250.3, 254.1 +] + + +def normalize_ctcss(tone): + """Normalize a CTCSS tone to the nearest valid value.""" + if tone is None: + return None + try: + tone = float(tone) + return min(VALID_CTCSS_TONES, key=lambda x: abs(x - tone)) + except (ValueError, TypeError): + return None + + +class OESVVData(base.NetworkResultRadio): + VENDOR = "oevsv.at" + MODEL = "Austria" + + def do_fetch(self, status, params): + status.send_status('Querying repeater.oevsv.at', 0) + memories = fetch_repeaters( + bands=params['bands'], + station_type=params['station_type'], + active_only=params['active_only'], + status_cb=lambda msg: status.send_status(msg, 50)) + self._memories = memories + status.send_status('Complete', 100) + status.send_end() + + +def fetch_repeaters(bands, station_type, active_only, status_cb=None): + """ + Fetch repeaters from the OEVSV API. + + Args: + bands: List of band strings (e.g., ['2m', '70cm']) + station_type: Station type filter (e.g., 'repeater_voice') + active_only: Boolean to filter active repeaters only + status_cb: Optional callback function for status updates + + Returns: + List of chirp_common.Memory objects + """ + if isinstance(bands, str): + bands = [bands] + + all_repeaters = [] + + for band in bands: + if status_cb: + status_cb(f"Fetching {band} repeaters...") + + try: + repeaters = _fetch_band(band, station_type, active_only) + for rpt in repeaters: + rpt['_band'] = band + all_repeaters.extend(repeaters) + LOG.info(f"Retrieved {len(repeaters)} repeaters for {band}") + except Exception as e: + LOG.error(f"Error fetching {band}: {e}") + if status_cb: + status_cb(f"Warning: Could not fetch {band}") + + if not all_repeaters: + return [] + + # Sort by frequency + all_repeaters.sort(key=lambda x: x.get('frequency_tx') or 0) + + # Convert to memories + memories = [] + for idx, rpt in enumerate(all_repeaters): + mem = _repeater_to_memory(rpt, idx) + if mem: + memories.append(mem) + + return memories + + +def _fetch_band(band, station_type, active_only): + """Fetch repeaters for a specific band from the API.""" + params = [f"band=eq.{band}"] + + if station_type: + params.append(f"type_of_station=eq.{station_type}") + + if active_only: + params.append("status=eq.active") + + # Build URL with query string manually + query_string = "&".join(params) + url = f"{OEVSV_API_BASE}?{query_string}" + LOG.warning(f"Fetching from: {url}") + + try: + r = requests.get(url, + headers=base.HEADERS | {'Accept': 'application/json'}) + + if r.status_code != 200: + LOG.error('OEVSV query %r returned %i (%s)', + r.url, r.status_code, r.reason) + raise requests.HTTPError(f"Server error: {r.status_code} - {r.reason}") + LOG.info(r); + return r.json() + + except requests.HTTPError as e: + LOG.error(f"HTTP error: {e}") + raise Exception(f"Server error: {e}") + except requests.ConnectionError as e: + LOG.error(f"Connection error: {e}") + raise Exception(f"Connection error: {e}") + except requests.Timeout as e: + LOG.error(f"Timeout error: {e}") + raise Exception(f"Request timeout: {e}") + except requests.RequestException as e: + LOG.error(f"Request error: {e}") + raise Exception(f"Request error: {e}") + except json.JSONDecodeError as e: + LOG.error(f"JSON decode error: {e}") + raise Exception("Invalid response from server") + + +def _repeater_to_memory(rpt, number): + """ + Convert an OEVSV repeater record to a CHIRP Memory object. + + Args: + rpt: Dictionary containing repeater data from API + number: Memory channel number + + Returns: + chirp_common.Memory object or None if conversion fails + """ + try: + tx_freq = rpt.get("frequency_tx") + rx_freq = rpt.get("frequency_rx") + + if not tx_freq and not rx_freq: + LOG.warning("Skipping entry with missing frequencies: %s", + rpt.get('callsign', rpt.get('site_name', 'Unknown'))) + return None + + # If only TX exists (transmit-only stations like POCSAG) + if tx_freq and not rx_freq: + rx_freq = tx_freq + + # If only RX exists (receive-only stations like digipeater) + if rx_freq and not tx_freq: + tx_freq = rx_freq + + mem = chirp_common.Memory() + mem.number = number + + # Frequency - we listen on repeater's TX frequency + mem.freq = int(tx_freq * 1000000) + + # Name - callsign with optional site info + callsign = rpt.get("callsign", "") + site = rpt.get("site_name", "") + if callsign: + name = f"{callsign} {site[:5]}" + mem.name = name[:16] + elif site: + mem.name = site[:16] + + # Duplex and offset + # We transmit on repeater's RX, receive on repeater's TX + offset_hz = round((rx_freq - tx_freq) * 1000000) + if abs(offset_hz) < 1000: # Less than 1 kHz = simplex + mem.duplex = "" + mem.offset = 0 + elif offset_hz > 0: + mem.duplex = "+" + mem.offset = abs(offset_hz) + else: + mem.duplex = "-" + mem.offset = abs(offset_hz) + + # CTCSS tones + # ctcss_tx = what the repeater transmits (we receive) + # ctcss_rx = what the repeater expects from us (we transmit) + ctcss_tx = rpt.get("ctcss_tx") + ctcss_rx = rpt.get("ctcss_rx") + + if ctcss_tx and ctcss_rx: + if ctcss_tx == ctcss_rx: + mem.tmode = "TSQL" + mem.ctone = normalize_ctcss(ctcss_tx) + mem.rtone = normalize_ctcss(ctcss_tx) + else: + mem.tmode = "Cross" + mem.cross_mode = "Tone->Tone" + mem.ctone = normalize_ctcss(ctcss_rx) # We transmit + mem.rtone = normalize_ctcss(ctcss_tx) # We receive + elif ctcss_rx: + # Repeater expects tone from us + mem.tmode = "Tone" + mem.rtone = normalize_ctcss(ctcss_rx) + elif ctcss_tx: + # Repeater sends tone (use TSQL) + mem.tmode = "TSQL" + mem.ctone = normalize_ctcss(ctcss_tx) + mem.rtone = normalize_ctcss(ctcss_tx) + else: + mem.tmode = "" + + + # Comment with additional info + comment_parts = [] + band = rpt.get("_band", rpt.get("band", "70cm")) + comment_parts.append(f"[{band}]") + if site: + comment_parts.append(site) + if rpt.get("sysop"): + comment_parts.append(f"Sysop:{rpt['sysop']}") + if rpt.get("echolink") and rpt.get("echolink_id"): + comment_parts.append(f"EL:{rpt['echolink_id']}") + if rpt.get("fm_wakeup"): + comment_parts.append(f"Wakeup:{rpt['fm_wakeup']}") + + # mode indicators + mem.mode = "FM" + modes = [] + if rpt.get("dmr"): + mem.mode = 'DMR' + modes.append("DMR") + if rpt.get("c4fm"): + mem.mode = "DN" + modes.append("C4FM") + if rpt.get("dstar"): + mem.mode = "DV" + modes.append("D-STAR") + + if modes: + comment_parts.append("/".join(modes)) + + mem.comment = " | ".join(comment_parts)[:255] + + return mem + + except Exception as e: + LOG.error(f"Error converting repeater {rpt.get('callsign')}: {e}") + return None diff --git a/chirp/wxui/main.py b/chirp/wxui/main.py index b7380d7e9..57170e505 100644 --- a/chirp/wxui/main.py +++ b/chirp/wxui/main.py @@ -908,6 +908,10 @@ def make_menubar(self): self.Bind(wx.EVT_MENU, self._menu_query_mapy73pl, query_mapy73pl_item) source_menu.Append(query_mapy73pl_item) + query_oevsv_item = wx.MenuItem(source_menu, wx.NewId(), 'oevsv.at') + self.Bind(wx.EVT_MENU, self._menu_query_oevsv, query_oevsv_item) + source_menu.Append(query_oevsv_item) + radio_menu.Append(wx.MenuItem(radio_menu, wx.ID_SEPARATOR)) auto_edits = wx.MenuItem(radio_menu, wx.NewId(), @@ -2100,6 +2104,9 @@ def _menu_query_przeu(self, event): def _menu_query_mapy73pl(self, event): self._do_network_query(query_sources.Mapy73PlQueryDialog) + def _menu_query_oevsv(self, event): + self._do_network_query(query_sources.OESVVQueryDialog) + def display_update_notice(version): LOG.info('Server reports %s is latest' % version) diff --git a/chirp/wxui/query_sources.py b/chirp/wxui/query_sources.py index c4df55328..b1aeafdfb 100644 --- a/chirp/wxui/query_sources.py +++ b/chirp/wxui/query_sources.py @@ -31,6 +31,7 @@ from chirp.sources import przemienniki_net from chirp.sources import przemienniki_eu from chirp.sources import mapy73pl +from chirp.sources import oevsv from chirp.wxui import common from chirp.wxui import config @@ -1223,3 +1224,88 @@ def get_params(self): # CA return {'zipcounty': '%s' % self._ca_county_id, 'country': 'CA'} + + +class OESVVQueryDialog(QuerySourceDialog): + NAME = "OEVSV Austria" + + BANDS = [ + ("10m (28 MHz)", "10m"), + ("6m (50 MHz)", "6m"), + ("2m (144 MHz)", "2m"), + ("70cm (430 MHz)", "70cm"), + ("23cm (1296 MHz)", "23cm"), + ] + + STATION_TYPES = [ + (_("Voice Repeaters"), "repeater_voice"), + (_("Digital Repeaters"), "repeater_digi"), + (_("Beacons"), "beacon"), + (_("APRS Digipeaters"), "digipeater"), + ] + + def _add_grid(self, grid, label, widget): + grid.Add(wx.StaticText(widget.GetParent(), label=label), + border=20, flag=wx.ALIGN_CENTER | wx.RIGHT | wx.LEFT) + grid.Add(widget, 1, border=20, flag=wx.EXPAND | wx.RIGHT | wx.LEFT) + + def build(self): + vbox = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(vbox) + panel = wx.Panel(self) + vbox.Add(panel, 1, flag=wx.EXPAND | wx.ALL, border=20) + grid = wx.FlexGridSizer(2, 5, 0) + grid.AddGrowableCol(1) + panel.SetSizer(grid) + + # Bands selection (checkboxes) + bands_panel = wx.Panel(panel) + bands_sizer = wx.BoxSizer(wx.VERTICAL) + self._band_checks = {} + for display_name, band_id in self.BANDS: + cb = wx.CheckBox(bands_panel, label=display_name) + self._band_checks[band_id] = cb + bands_sizer.Add(cb, 0, wx.ALL, 2) + bands_panel.SetSizer(bands_sizer) + self._add_grid(grid, _('Bands'), bands_panel) + + # Set default bands (2m and 70cm) + self._band_checks["2m"].SetValue(True) + self._band_checks["70cm"].SetValue(True) + + # Station type dropdown + self._type_choice = wx.Choice(panel) + for display_name, type_id in self.STATION_TYPES: + self._type_choice.Append(display_name, type_id) + self._type_choice.SetSelection(0) + self._add_grid(grid, _('Station Type'), self._type_choice) + + # Active Only checkbox + self._active_only = wx.CheckBox(panel, + label=_("Active repeaters only")) + self._active_only.SetValue(True) + self._add_grid(grid, '', self._active_only) + + return vbox + + def get_info(self): + return _('OEVSV Austrian Repeater Database') + + def get_link(self): + return 'https://repeater.oevsv.at/' + + def do_query(self): + self.result_radio = oevsv.OESVVData() + super().do_query() + + def get_params(self): + bands = [band_id for band_id, cb in self._band_checks.items() + if cb.GetValue()] + selection = self._type_choice.GetSelection() + station_type = (self._type_choice.GetClientData(selection) + if selection >= 0 else 'repeater_voice') + return { + 'bands': bands, + 'station_type': station_type, + 'active_only': self._active_only.GetValue(), + }