├── README.md ├── pyUC.ini ├── pyUC.py ├── setup.py └── test.md /README.md: -------------------------------------------------------------------------------- 1 | 2 | # USRP_Client (pyUC) 3 | 4 | ## Introduction 5 | The pyUC python application is a GUI front end for accessing ham radio digital networks from your PC. It is the front end app for the DVSwitch suite of software and connects to the Analog_Bridge component. 6 | ## Features 7 | The user can: 8 | 9 | - Select digital network 10 | - Select "talk group" or reflector from a list 11 | - Transmit and receive to the network using their speakers and mic 12 | - Record a list of stations received in the session 13 | - See pictures of the hams from QRZ.com 14 | 15 | ## Installation 16 | Download and unzip https://github.com/DVSwitch/USRP_Client/archive/master.zip 17 | 18 | Install instructions by platform: 19 | 20 | - Windows 10 21 | 22 | Use Python 3.7 from the Microsoft Store 23 | Open a command prompt 24 | **python -m pip install --upgrade pip** 25 | Download PyAudio from https://www.lfd.uci.edu/~gohlke/pythonlibs/ for your version (32 or 64 bit) 26 | 27 | **pip install PyAudio-0.2.11-cp37-cp37m-win_XXX.whl 28 | pip install bs4 29 | pip install Pillow 30 | pip install requests** 31 | Edit pyUC.ini 32 | 33 | If you get an error about MSVCP140.DLL, then you will need to install the MSVC C++ runtime library. 34 | Get it from: https://support.microsoft.com/en-us/help/2977003/the-latest-supported-visual-c-downloads 35 | 36 | - Linux (Tested on a Raspberry Pi running Buster and Linux Mint 19) 37 | 38 | Open a command prompt 39 | **sudo apt-get install python3-pyaudio 40 | sudo apt-get install portaudio19-dev 41 | sudo apt-get install python3-pil.imagetk** 42 | Edit pyUC.ini 43 | 44 | - Mac 45 | 46 | **ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" 47 | brew install python 48 | brew install portaudio 49 | pip3 install pyaudio 50 | pip3 install bs4 Pillow requests** 51 | Edit pyUC.ini 52 | 53 | ## Contributing 54 | We encourage others to submit pull request to this repository. We only ask that you submit the pull request on the development branch. Your pull will be reviewed and merged into the master branch. 55 | ## Related projects 56 | DVSwitch 57 | ## Licensing 58 | This software is for use on amateur radio networks only, it is to be used 59 | for educational purposes only. Its use on commercial networks is strictly 60 | prohibited. Permission to use, copy, modify, and/or distribute this software 61 | hereby granted, provided that the above copyright notice and this permission 62 | notice appear in all copies. 63 | 64 | THE SOFTWARE IS PROVIDED "AS IS" AND DVSWITCH DISCLAIMS ALL WARRANTIES WITH 65 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 66 | AND FITNESS. IN NO EVENT SHALL N4IRR BE LIABLE FOR ANY SPECIAL, DIRECT, 67 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 68 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE 69 | OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 70 | PERFORMANCE OF THIS SOFTWARE. 71 | -------------------------------------------------------------------------------- /pyUC.ini: -------------------------------------------------------------------------------- 1 | ######################################################################################## 2 | # pyUC configuration file. 3 | ######################################################################################## 4 | # This file is used to configure the pyUC ("puck") digital client. It is 5 | # composed of several sections, [DEFAULTS], [DMR], [P25], [YSF], 6 | # [NXDN] and [DSTAR]. Each mode section is a list of "talkgroups" 7 | # you can show in the pyUC list. Each entry is composed of the 8 | # text to show the user and the dial string to send AB when selected. 9 | # Note that some dial strings are quoted, that is to preserve spaces (DSTAR) 10 | # or special characters (private call). You can include any valid dial string 11 | # including macros in the section. It is up to *you* to maintain this file 12 | # as it is not a complete list of ALL digital nodes/TGs you can connect to. 13 | ######################################################################################## 14 | 15 | # This section defines general information on how to configure the UI of pyUC. You must 16 | # set your call, DMR ID and ip address/ports of the server at the very minimum. 17 | [DEFAULTS] 18 | myCall = N0CALL ; You callsign 19 | subscriberID = 3112000 ; Your DMR/CCS7 ID 20 | repeaterID = 311200 ; Your repeater ID 21 | ipAddress = 1.2.3.4 ; IP address or hostname of DVSwitch Server (AB) 22 | usrpTxPort = 12345 ; Port on which AB is listening 23 | usrpRxPort = 12345 ; Local port to listen for packets from AB 24 | defaultServer = DMR ; Start up UI on this mode (AB will override) 25 | slot = 2 ; Slot to transmit on for DMR 26 | in_index = Default ; pyaudio index for input device (0-N or -1 to disable) 27 | out_index = Default ; pyaudio index for output device (0-N or Default) 28 | loopback = 1 ; NOT USED 29 | dongleMode = 1 ; NOT USED 30 | micVol = 50 ; NOT USED 31 | spVol = 50 ; NOT USED 32 | voxEnable = 0 ; Enable = 1, disable = 0 33 | voxThreshold = 200 ; This seems to be a good value for me 34 | voxDelay = 50 ; 50 samples (which is 1 second) 35 | aslMode = 0 ; For VERY limited use with chan_usrp (ASL experimental). 36 | backgroundColor = Default 37 | textColor = Default 38 | 39 | # This section defines the talkgroups used when pyUC is in DMR mode 40 | [DMR] 41 | Disconnect = 4000 ; Must be first entry in list 42 | Parrot = "9990#" ; Note the pound sign? (private call) 43 | North America = 3100 44 | TAC310 = 310 45 | World Wide = 91 46 | Florida = 3112 47 | Georgia = 3113 48 | Texas = 3148 49 | California = 3106 50 | DVSwitch = 3166 51 | Call Area 4 = 31094 52 | BYRG = 31201 53 | SNARS = 31268 54 | QuadNet = 31012 55 | N4IRS = "3112138#" ; Note the pound sign? (private call) 56 | Alabama Link = 31010 57 | Colorado HD = 31088 58 | The Guild = 31674 59 | 60 | # This section defines the talkgroups used when pyUC is in P25 mode 61 | [P25] 62 | Disconnect = 9999 ; Must be first entry in list 63 | Parrot = 10 ; The local parrot 64 | World Wide = 10100 65 | North America = 10200 66 | North America TAC1 = 10201 67 | Europe = 10300 68 | Europe TAC1 = 10301 69 | Pacific = 10400 70 | Pacific TAC1 = 10401 71 | Pacific TAC2 = 10402 72 | Pacific TAC3 = 10403 73 | Pacific TAC4 = 10404 74 | HAMNET HAMCLOUD = 10310 75 | Wires-x,NXDN,YSF,XLX(D-Star & DMR),BM Bridge = 4 76 | Rural Minnesota - Bridge to TGIF707, YSF US RuralMN-707 = 707 77 | VK7 TAS = 5057 78 | Indiana Digital Ham Radio P25 Reflector = 6935 79 | Germany = 10320 80 | German Pegasus Project = 10328 81 | UK = 10342 82 | GB WARC = 10350 83 | Australia NSW Bridge to AU NSW YSF = 10700 84 | Austria = 23255 85 | Russia P25 Net = 25641 86 | America-Ragchew= 28299 87 | NorCal-Bridge / Multimode-P25-TG30639 = 30639 88 | Alabama Link = 31010 89 | Mountain West = 31062 90 | Colorado HD = 31088 91 | Connecticut Chat = 31092 92 | Illinois = 31171 93 | Southern Indiana = 31188 94 | TGIF Network = 31665 95 | P25 Pi-Star chat = 31672 96 | South Jersey = 31341 97 | Oklahoma Link = 31403 98 | DX-LINK = 31777 99 | KG4JPL North-Central Florida = 31888 100 | Fusion Canada Fr = 40721 101 | Bridge to YSF, NXDN and DMR = 50525 102 | New Zealand bridge to D-Star, DMR and NXDN = 53099 103 | Ontario Crosslink = 3023 104 | 105 | # This section defines the reflectors used when pyUC is in YSF mode 106 | # YSF nodes are addressed by a ip or hostname, colon and a port number. 107 | # If you want to use ysfgateway to access FCS, address a 127.0.0.1:xxxx 108 | # on your server 109 | [YSF] 110 | Disconnect = disconnect ;Must be first entry in list 111 | Parrot = "register.ysfreflector.de:42020" 112 | America Link = "americalink.hamfm.com:42000" 113 | America-RC = "arcysf.duckdns.org:42001" 114 | Alabama-Link = "96.47.95.121:42000" 115 | US Texas-Nexus = "ysf.texas-nexus.dyndns.org:42000" 116 | Ohio-Link = "ysf.glorb.com:42000" 117 | US Triangle NC = "ysfnet.trianglenc.net:42000" 118 | EA C4FM = "212.237.3.141:42000" 119 | GB CQ-UK = "81.150.10.62:42200" 120 | 121 | # This section defines the talkgroups used when pyUC is in NXDN mode 122 | [NXDN] 123 | Unlink = 9999 ; Must be first entry in list 124 | Parrot = 10 ; The local parrot 125 | North America = 10200 126 | World Wide = 65000 127 | Florida = 1200 128 | DVSwitch = 3166 129 | Pacific = 10400 130 | Alabama Link = 31010 131 | Carolina Digital Group = 31374 132 | KenWood bridge NXCore = 9000 133 | CT NXCore = 25000 134 | NXDN 10302 Multimode BM = 10302 135 | America-Ragchew = 28299 136 | NorCal-Bridge / Multimode-NXDN = 30639 137 | Colorado HD = 31088 138 | Illinois = 31171 139 | Southern Indiana = 31188 140 | Rhode Island Digital Link = 31444 141 | Pi-Star NXDN Reflector = 31672 142 | DX-LINK SYSTEM = 31777 143 | DMR TG50525 bridge = 50525 144 | VKCore 505 = 505 145 | New Zealand = 53099 146 | New Zealand, 530 = 530 147 | French = 65208 148 | Spanish = 10301 149 | Italian speaking = 10303 150 | Europe, German speaking = 20000 151 | Portuguese speaking test = 26810 152 | REM-ADER Spain Group = 10304 153 | China = 46000 154 | Russia NXDN Net = 25641 155 | 156 | # This section defines the reflectors used when pyUC is in DSTAR mode 157 | # Please note all DSTAR reflector trngs are EXACTLY 8 characters long 158 | # Quotes are used to ensure the spaces are preserved in the dial string. 159 | [DSTAR] 160 | Unlink = " U" ; Must be first entry in list 161 | Echo = "REF001EL" ; a good ECHO address 162 | REF001C = REF001CL ; High traffic international reflector 163 | REF004C = REF004CL ; More medium high traffic 164 | REF012A = REF012AL ; PAPA system DSTAR reflector 165 | XRF012A = XRF012AL ; PAPA cross link to analog 166 | REF014A = REF014AL ; Western reflector 167 | REF030B = REF030BL ; Southeast reflector 168 | REF030C = REF030CL ; High traffic NA reflector 169 | REF038C = REF038CL 170 | REF050C = REF050CL ; Eastern MA 171 | REF058B = REF058BL ; Alabama 172 | REF078B = REF078BL 173 | REF078C = REF078CL 174 | DCS006F = DCS006FL ; US DCS reflector Alabama 175 | DCS059A = DCS059AL 176 | 177 | # This section defines a set of macros that can be uased on the TG popup. Each macro 178 | # is defined by a display value and the string to "execute". The execute string 179 | # can be any valid dialer string Analog_Bridge understands, including TG numbers, 180 | # YSF addresses and macros. The TG/Macro popup can also be redefined by the receipt 181 | # of a MACRO command from dvswitch.sh 182 | [MACROS] 183 | ;Display Value = Tune Value 184 | Kill Gateways = *666 185 | TGIF = *TGIF 186 | BM = *BM 187 | INFO = *INFO 188 | TIME = *TIME 189 | TG = *TG -------------------------------------------------------------------------------- /pyUC.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | ################################################################################### 3 | # pyUC ("puck") 4 | # Copyright (C) 2014, 2015, 2016, 2019, 2020 N4IRR 5 | # 6 | # This software is for use on amateur radio networks only, it is to be used 7 | # for educational purposes only. Its use on commercial networks is strictly 8 | # prohibited. Permission to use, copy, modify, and/or distribute this software 9 | # hereby granted, provided that the above copyright notice and this permission 10 | # notice appear in all copies. 11 | # 12 | # THE SOFTWARE IS PROVIDED "AS IS" AND DVSWITCH DISCLAIMS ALL WARRANTIES WITH 13 | # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 14 | # AND FITNESS. IN NO EVENT SHALL N4IRR BE LIABLE FOR ANY SPECIAL, DIRECT, 15 | # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 16 | # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE 17 | # OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 18 | # PERFORMANCE OF THIS SOFTWARE. 19 | ################################################################################### 20 | 21 | from tkinter import * 22 | from tkinter import ttk 23 | from time import time, sleep, localtime, strftime 24 | from random import randint 25 | from tkinter import messagebox 26 | import socket 27 | import struct 28 | import _thread 29 | import shlex 30 | import configparser, traceback 31 | import pyaudio 32 | import audioop 33 | import json 34 | import logging 35 | import webbrowser 36 | import os 37 | import io 38 | import base64 39 | import urllib.request 40 | import queue 41 | from pathlib import Path 42 | import hashlib 43 | from tkinter import font 44 | import sys 45 | 46 | UC_VERSION = "1.2.3" 47 | 48 | ################################################################################### 49 | # Declare input and output ports for communication with AB 50 | ################################################################################### 51 | usrp_tx_port = 12345 52 | usrp_rx_port = 12345 53 | 54 | ################################################################################### 55 | # USRP packet types 56 | ################################################################################### 57 | USRP_TYPE_VOICE = 0 58 | USRP_TYPE_DTMF = 1 59 | USRP_TYPE_TEXT = 2 60 | USRP_TYPE_PING = 3 61 | USRP_TYPE_TLV = 4 62 | USRP_TYPE_VOICE_ADPCM = 5 63 | USRP_TYPE_VOICE_ULAW = 6 64 | 65 | ################################################################################### 66 | # TLV tags 67 | ################################################################################### 68 | TLV_TAG_BEGIN_TX = 0 69 | TLV_TAG_AMBE = 1 70 | TLV_TAG_END_TX = 2 71 | TLV_TAG_TG_TUNE = 3 72 | TLV_TAG_PLAY_AMBE = 4 73 | TLV_TAG_REMOTE_CMD = 5 74 | TLV_TAG_AMBE_49 = 6 75 | TLV_TAG_AMBE_72 = 7 76 | TLV_TAG_SET_INFO = 8 77 | TLV_TAG_IMBE = 9 78 | TLV_TAG_DSAMBE = 10 79 | TLV_TAG_FILE_XFER = 11 80 | 81 | ################################################################################### 82 | # Globals (gah) 83 | ################################################################################### 84 | noTrace = False # Boolean to control recursion when a new mode is selected 85 | usrpSeq = 0 # Each USRP packet has a unique sequence number 86 | udp = None # UDP socket for USRP traffic 87 | out_index = None # Current output (speaker) index in the pyaudio device list 88 | in_index = None # Current input (mic) index in the pyaudio device list 89 | regState = False # Global registration state boolean 90 | noQuote = {ord('"'): ''} 91 | empty_photo = ("photo", "", "", "") # instance of a blank photo 92 | SAMPLE_RATE = 48000 # Default audio sample rate for pyaudio (will be resampled to 8K) 93 | toast_frame = None # A toplevel window used to display toast messages 94 | ipc_queue = None # Queue used to pass info to main hread (UI) 95 | ptt = False # Current ptt state 96 | tx_start_time = 0 # TX timer 97 | done = False # Thread stop flag 98 | transmit_enable = True # Make sure that UC is half duplex 99 | useQRZ = True 100 | level_every_sample = 1 101 | NAT_ping_timer = 0 102 | 103 | listbox = None # tk object (talkgroup) 104 | transmitButton = None # tk object 105 | logList = None # tk object 106 | macros = {} 107 | 108 | uc_background_color = "gray25" 109 | uc_text_color = "white" 110 | 111 | ################################################################################### 112 | # Strings 113 | ################################################################################### 114 | STRING_USRP_CLIENT = "USRP Client" 115 | STRING_FATAL_ERROR = "fatal error, python package not found: " 116 | STRING_TALKGROUP = "Talk Group" 117 | STRING_OK = "OK" 118 | STRING_REGISTERED = "Registered" 119 | STRING_WINDOWS_PORT_REUSE = "On Windows, ignore the port reuse" 120 | STRING_FATAL_OUTPUT_STREAM = "fatal error, can not open output audio stream" 121 | STRING_OUTPUT_STREAM_ERROR = "Output stream open error" 122 | STRING_FATAL_INPUT_STREAM = "fatal error, can not open input audio stream" 123 | STRING_INPUT_STREAM_ERROR = "Input stream open error" 124 | STRING_CONNECTION_FAILURE = "Connection failure" 125 | STRING_SOCKET_FAILURE = "Socket failure" 126 | STRING_CONNECTED_TO = "Connected to" 127 | STRING_DISCONNECTED = "Disconnected " 128 | STRING_SERVER = "Server" 129 | STRING_READ = "Read" 130 | STRING_WRITE = "Write" 131 | STRING_AUDIO = "Audio" 132 | STRING_MIC = "Mic" 133 | STRING_SPEAKER = "Speaker" 134 | STRING_INPUT = "Input" 135 | STRING_OUTPUT = "Output" 136 | STRING_TALKGROUPS = "Talk Groups" 137 | STRING_TG = "TG" 138 | STRING_TS = "TS" 139 | STRING_CONNECT = "Connect" 140 | STRING_DISCONNECT = "Disconnect" 141 | STRING_DATE = "Date" 142 | STRING_TIME = "Time" 143 | STRING_CALL = "Call" 144 | STRING_SLOT = "Slot" 145 | STRING_LOSS = "Loss" 146 | STRING_DURATION = "Duration" 147 | STRING_MODE = "MODE" 148 | STRING_REPEATER_ID = "Repeater ID" 149 | STRING_SUBSCRIBER_ID = "Subscriber ID" 150 | STRING_TAB_MAIN = "Main" 151 | STRING_TAB_SETTINGS = "Settings" 152 | STRING_TAB_ABOUT = "About" 153 | STRING_CONFIG_NOT_EDITED = 'Please edit the configuration file and set it up correctly. Exiting...' 154 | STRING_CONFIG_FILE_ERROR = "Config (ini) file error: " 155 | STRING_EXITING = "Exiting pyUC..." 156 | STRING_VOX = "Vox" 157 | STRING_DONGLE_MODE = "Dongle Mode" 158 | STRING_VOX_ENABLE = "Vox Enable" 159 | STRING_VOX_THRESHOLD = "Threshold" 160 | STRING_VOX_DELAY = "Delay" 161 | STRING_NETWORK = "Network" 162 | STRING_LOOPBACK = "Loopback" 163 | STRING_IP_ADDRESS = "IP Address" 164 | STRING_PRIVATE = "Private" 165 | STRING_GROUP = "Group" 166 | STRING_TRANSMIT = "Transmit" 167 | 168 | ################################################################################### 169 | # HTML/QRZ import libraries 170 | try: 171 | from urllib.request import urlopen 172 | from bs4 import BeautifulSoup 173 | from PIL import Image, ImageTk 174 | import requests 175 | except: 176 | print(STRING_FATAL_ERROR + str(sys.exc_info()[1])) 177 | exit(1) 178 | 179 | qrz_label = None 180 | qrz_cache = {} # we use this cache to 1) speed execution 2) limit the lookup count on qrz.com. 3) cache the thumbnails we do find 181 | html_queue = None # IPC queue to pass in lookup requests. Each request is a callsign to lookup. Successful lookups place the result in the ipc_queue 182 | 183 | # Place the HTML lookup and image ownload on a different thread so as to not block the UI 184 | def html_thread(): 185 | global html_queue 186 | html_queue = queue.Queue() # Create the queue 187 | while done == False: 188 | try: 189 | callsign, name = html_queue.get(0) # wait forever for a message to be placed in the queue (a callsign) 190 | photo = getQRZImage( callsign ) if useQRZ else "" # lookup the call and return an image 191 | ipc_queue.put(("photo", callsign, photo, name)) 192 | except queue.Empty: 193 | pass 194 | sleep(0.1) 195 | 196 | # Return the URL of an image associated with the callsign. The URL may be cached or scraped from QRZ 197 | def getImgUrl( callsign ): 198 | img = "" 199 | if callsign in qrz_cache: 200 | return qrz_cache[callsign]['url'] 201 | 202 | try: 203 | # specify the url 204 | quote_page = 'https://qrz.com/lookup/' + callsign 205 | 206 | # query the website and return the html to the variable ‘page’ 207 | page = urlopen(quote_page, timeout=20).read() 208 | 209 | # parse the html using beautiful soup and store in variable `soup` 210 | soup = BeautifulSoup(page, 'html.parser') 211 | img = soup.find(id='mypic')['src'] 212 | except: 213 | pass 214 | qrz_cache[callsign] = {'url' : img} 215 | return img 216 | 217 | # Given a URL, download the image from the web and return it. 218 | def getQRZImage( callsign ): 219 | photo = "" # If not found, this will be returned (causes the image to blank out) 220 | if len(callsign) > 0: 221 | image_url = getImgUrl(callsign) 222 | if len(image_url) > 0: 223 | if 'image' in qrz_cache[callsign]: 224 | return qrz_cache[callsign]['image'] 225 | resp = requests.get(image_url, stream=True).raw 226 | try: 227 | image = Image.open(resp) 228 | image.thumbnail((170,110), Image.LANCZOS) 229 | photo = ImageTk.PhotoImage(image) 230 | except: 231 | pass 232 | qrz_cache[callsign]['image'] = photo 233 | return photo 234 | 235 | # Run on the main thread, show the image in the passed UI element (label) 236 | def showQRZImage( msg, in_label ): 237 | photo = msg[2] 238 | in_label.configure(image=photo) 239 | in_label.image = photo 240 | in_label.callsign = msg[1] 241 | current_call.set(msg[1]) 242 | current_name.set(msg[3]) 243 | 244 | ################################################################################### 245 | def ping_thread(): 246 | while done == False: 247 | sleep(20.0) 248 | sendUSRPCommand(bytes("PING", 'ASCII'), USRP_TYPE_PING) 249 | 250 | ################################################################################### 251 | # Log output to console 252 | ################################################################################### 253 | logging.basicConfig(format='%(asctime)s - %(message)s', level=logging.INFO) 254 | 255 | ################################################################################### 256 | # Manage a popup dialog for on the fly TGs 257 | ################################################################################### 258 | class MyDialog: 259 | 260 | def __init__(self, parent): 261 | 262 | win_offset = "250x100+{}+{}".format(root.winfo_x()+40, root.winfo_y()+65) 263 | 264 | top = self.top = Toplevel(parent) 265 | self.top.transient(parent) 266 | self.top.grab_set() 267 | 268 | top.geometry(win_offset) 269 | top.configure(bg=uc_background_color) 270 | 271 | Label(top, text=STRING_TALKGROUP, fg=uc_text_color, bg=uc_background_color).pack() 272 | 273 | if len(macros) == 0: 274 | self.e = Entry(top, fg=uc_text_color, bg=uc_background_color) 275 | else: 276 | self.e = ttk.Combobox(top, values=list(macros.values())) 277 | 278 | self.e.bind("", self.ok) 279 | self.e.bind("", self.cancel) 280 | 281 | self.e.pack(padx=5) 282 | 283 | b = ttk.Button(top, text=STRING_OK, command=self.ok) 284 | b.pack(pady=5) 285 | self.e.focus_set() 286 | 287 | def popdown(self, popdown_state): 288 | if ((popdown_state != None) and (popdown_state == True)): 289 | self.e.event_generate('') 290 | 291 | def cancel(self, event=None): 292 | self.top.destroy() 293 | 294 | def ok(self, event=None): 295 | 296 | item = self.e.get() 297 | if len(item): 298 | logging.info( "value is %s", item ) 299 | mode = master.get() 300 | tg_name = tg = item 301 | lst = item.split(',') 302 | if len(lst) == 1: 303 | if item in macros.values(): 304 | i = list(macros.values()).index(item) 305 | tg = list(macros.keys())[i] 306 | else: 307 | tg_name = lst[0] 308 | tg = lst[1] 309 | connect((tg, tg_name)) 310 | if tg.startswith('*') == False: 311 | i = None 312 | for x in talk_groups[mode]: 313 | if x[1] == tg: 314 | i = x 315 | if i == None: # tg not found? 316 | talk_groups[mode].append((tg_name, tg)) 317 | fillTalkgroupList(master.get()) 318 | selectTGByValue(tg) 319 | listbox.see(listbox.curselection()) 320 | self.top.destroy() 321 | 322 | ################################################################################### 323 | # Open the UDP socket for TX and RX 324 | ################################################################################### 325 | def openStream(): 326 | global usrpSeq 327 | global udp 328 | 329 | usrpSeq = 0 330 | udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 331 | try: 332 | udp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 333 | except: 334 | logging.info(STRING_WINDOWS_PORT_REUSE) 335 | pass 336 | if (usrp_rx_port in usrp_tx_port) == False: # single port reply does not need a bind 337 | udp.bind(('', usrp_rx_port)) 338 | 339 | def sendto(usrp): 340 | for port in usrp_tx_port: 341 | udp.sendto(usrp, (ip_address.get(), port)) 342 | 343 | from ctypes import * 344 | from contextlib import contextmanager 345 | 346 | ERROR_HANDLER_FUNC = CFUNCTYPE(None, c_char_p, c_int, c_char_p, c_int, c_char_p) 347 | 348 | def py_error_handler(filename, line, function, err, fmt): 349 | pass 350 | 351 | c_error_handler = ERROR_HANDLER_FUNC(py_error_handler) 352 | 353 | @contextmanager 354 | def noalsaerr(): 355 | try: 356 | asound = cdll.LoadLibrary('libasound.so') 357 | asound.snd_lib_error_set_handler(c_error_handler) 358 | yield 359 | asound.snd_lib_error_set_handler(None) 360 | except: 361 | yield 362 | pass 363 | 364 | ################################################################################### 365 | # Log the EOT 366 | ################################################################################### 367 | def log_end_of_transmission(call,rxslot,tg,loss,start_time): 368 | logging.info('End TX: {} {} {} {} {:.2f}s'.format(call, rxslot, tg, loss, time() - start_time)) 369 | logList.see(logList.insert('', 'end', None, values=( 370 | strftime(" %m/%d/%y", localtime(start_time)), 371 | strftime("%H:%M:%S", localtime(start_time)), 372 | call.ljust(10), rxslot, tg, loss, '{:.2f}s'.format(time() - start_time)))) 373 | root.after(1000, logList.yview_moveto, 1) 374 | current_tx_value.set(my_call) 375 | html_queue.put(("", "")) # clear the photo, use queue for short transmissions 376 | 377 | ################################################################################### 378 | # RX thread, collect audio and metadata from AB 379 | ################################################################################### 380 | def rxAudioStream(): 381 | global ip_address 382 | global noTrace 383 | global regState 384 | global transmit_enable 385 | global macros 386 | 387 | logging.info('Start rx audio thread') 388 | USRP = bytes("USRP", 'ASCII') 389 | REG = bytes("REG:", 'ASCII') 390 | UNREG = bytes("UNREG", 'ASCII') 391 | OK = bytes("OK", 'ASCII') 392 | INFO = bytes("INFO:", 'ASCII') 393 | EXITING = bytes("EXITING", 'ASCII') 394 | 395 | FORMAT = pyaudio.paInt16 396 | CHUNK = 160 if SAMPLE_RATE == 8000 else (160*6) # Size of chunk to read 397 | CHANNELS = 1 398 | RATE = SAMPLE_RATE 399 | 400 | try: 401 | stream = p.open(format=FORMAT, 402 | channels = CHANNELS, 403 | rate = RATE, 404 | output = True, 405 | frames_per_buffer = CHUNK, 406 | output_device_index=out_index 407 | ) 408 | except: 409 | logging.critical(STRING_FATAL_OUTPUT_STREAM + str(sys.exc_info()[1])) 410 | messagebox.showinfo(STRING_USRP_CLIENT, STRING_OUTPUT_STREAM_ERROR) 411 | os._exit(1) 412 | 413 | _i = p.get_default_output_device_info().get('index') if out_index == None else out_index 414 | logging.info("Output Device: {} Index: {}".format(p.get_device_info_by_host_api_device_index(0, _i).get('name'), _i)) 415 | 416 | lastKey = -1 417 | start_time = time() 418 | call = '' 419 | name = '' 420 | tg = '' 421 | lastSeq = 0 422 | seq = 0 423 | loss = '0.00%' 424 | rxslot = '0' 425 | state = None 426 | 427 | while done == False: 428 | soundData, addr = udp.recvfrom(1024) 429 | if addr[0] != ip_address.get(): 430 | ip_address.set(addr[0]) # OK, this was supposed to help set the ip to a server, but multiple servers ping/pong. I may remove it. 431 | if (soundData[0:4] == USRP): 432 | eye = soundData[0:4] 433 | seq, = struct.unpack(">i", soundData[4:8]) 434 | memory, = struct.unpack(">i", soundData[8:12]) 435 | keyup, = struct.unpack(">i", soundData[12:16]) 436 | talkgroup, = struct.unpack(">i", soundData[16:20]) 437 | type, = struct.unpack("i", soundData[20:24]) 438 | mpxid, = struct.unpack(">i", soundData[24:28]) 439 | reserved, = struct.unpack(">i", soundData[28:32]) 440 | audio = soundData[32:] 441 | if (type == USRP_TYPE_VOICE): # voice 442 | audio = soundData[32:] 443 | #print(eye, seq, memory, keyup, talkgroup, type, mpxid, reserved, audio, len(audio), len(soundData)) 444 | if (len(audio) == 320): 445 | if RATE == 48000: 446 | (audio48, state) = audioop.ratecv(audio, 2, 1, 8000, 48000, state) 447 | stream.write(bytes(audio48), CHUNK) 448 | if (seq % level_every_sample) == 0: 449 | rms = audioop.rms(audio, 2) # Get a relative power value for the sample 450 | audio_level.set(int(rms/100)) 451 | else: 452 | stream.write(audio, CHUNK) 453 | if (keyup != lastKey): 454 | logging.debug('key' if keyup else 'unkey') 455 | if keyup: 456 | start_time = time() 457 | if keyup == False: 458 | log_end_of_transmission(call, rxslot, tg, loss, start_time) 459 | transmit_enable = True # Idle state, allow local transmit 460 | audio_level.set(0) 461 | lastKey = keyup 462 | elif (type == USRP_TYPE_TEXT): #metadata 463 | if (audio[0:4] == REG): 464 | if (audio[4:6] == OK): 465 | connected_msg.set(STRING_REGISTERED) 466 | sendMetadata() 467 | requestInfo() 468 | if in_index == -1: 469 | transmitButton.configure(state='disabled') 470 | else: 471 | transmitButton.configure(state='normal') 472 | regState = True 473 | elif (audio[4:9] == UNREG): 474 | disconnect() 475 | transmitButton.configure(state='disabled') 476 | regState = False 477 | pass 478 | elif (audio[4:11] == EXITING): 479 | disconnect() 480 | tmp = audio[:audio.find(b'\x00')].decode('ASCII') # C string 481 | args = tmp.split(" ") 482 | sleepTime = int(args[2]) 483 | logging.info("AB is exiting and wants a re-reg in %s seconds...", sleepTime) 484 | if (sleepTime > 0): 485 | sleep(sleepTime) 486 | registerWithAB() 487 | logging.info(audio[:audio.find(b'\x00')].decode('ASCII')) 488 | elif (audio[0:5] == INFO): 489 | _json = audio[5:audio.find(b'\x00')].decode('ASCII') 490 | if (_json[0:4] == "MSG:"): 491 | logging.info("Text Message: " + _json[4:]) 492 | ipc_queue.put(("toast", "Text Message", _json[4:])) 493 | elif (_json[0:6] == "MACRO:"): # An ad-hoc macro menu 494 | logging.info("Macro: " + _json[6:]) 495 | macs = _json[6:] 496 | macrosx = dict(x.split(",") for x in macs.split("|")) 497 | macros = { k:v.strip() for k, v in macrosx.items()} 498 | ipc_queue.put(("macro", "")) # popup the menu 499 | elif (_json[0:5] == "MENU:"): # An ad-hoc macro menu 500 | logging.info("Menu: " + _json[5:]) 501 | macs = _json[5:] 502 | macrosx = dict(x.split(",") for x in macs.split("|")) 503 | macros = { k:v.strip() for k, v in macrosx.items()} 504 | else: 505 | obj=json.loads(audio[5:audio.find(b'\x00')].decode('ASCII')) 506 | noTrace = True # ignore the event generated by setting the combo box 507 | if (obj["tlv"]["ambe_mode"][:3] == "YSF"): 508 | master.set("YSF") 509 | else: 510 | master.set(obj["tlv"]["ambe_mode"]) 511 | noTrace = False 512 | logging.info(audio[:audio.find(b'\x00')].decode('ASCII')) 513 | connected_msg.set( STRING_CONNECTED_TO + " " + obj["last_tune"] ) 514 | selectTGByValue(obj["last_tune"]) 515 | else: 516 | # Tunnel a TLV inside of a USRP packet 517 | if audio[0] == TLV_TAG_SET_INFO: 518 | if transmit_enable == False: #EOT missed? 519 | log_end_of_transmission(call, rxslot, tg, loss, start_time) 520 | rid = (audio[2] << 16) + (audio[3] << 8) + audio[4] # Source 521 | tg = (audio[9] << 16) + (audio[10] << 8) + audio[11] # Dest 522 | rxslot = audio[12] 523 | rxcc = audio[13] 524 | mode = STRING_PRIVATE if (rxcc & 0x80) else STRING_GROUP 525 | name = "" 526 | if audio[14] == 0: # C string termintor for call 527 | call = str(rid) 528 | else: 529 | call = audio[14:audio.find(b'\x00', 14)].decode('ASCII') 530 | if call[0] == '{': # its a json dict 531 | obj=json.loads(call) 532 | call = obj['call'] 533 | name = obj['name'].split(' ')[0] if 'name' in obj else "" 534 | listName = master.get() 535 | if (listName == 'DSTAR') or (listName == "YSF"): # for these modes the TG is not valid 536 | tg = getCurrentTGName() 537 | elif tg == subscriber_id.get(): # is the dest TG my dmr ID? (private call) 538 | tg = my_call 539 | for item in talk_groups[listName]: 540 | if item[1] == str(tg): 541 | tg = item[0] # Found the TG number in the list, so we can use its friendly name 542 | current_tx_value.set('{} -> {}'.format(call, tg)) 543 | logging.info('Begin TX: {} {} {} {}'.format(call, rxslot, tg, mode)) 544 | transmit_enable = False # Transmission from network will disable local transmit 545 | if call.isdigit() == False: 546 | html_queue.put((call, name)) 547 | if ((rxcc & 0x80) and (rid > 10000)): # > 10000 to exclude "4000" from BM 548 | # logging.info('rid {} ctg {}'.format(rid, getCurrentTG())) 549 | # a dial string with a pound is a private call, see if the current TG matches 550 | privateTG = str(rid) + '#' 551 | if (privateTG != getCurrentTG()): 552 | #Tune to tg 553 | sendRemoteControlCommandASCII("txTg=" + privateTG) 554 | talk_groups[listName].append((call + " Private", privateTG)) 555 | fillTalkgroupList(listName) 556 | tg = privateTG # Make log entries say the right thing 557 | selectTGByValue(privateTG) 558 | elif (type == USRP_TYPE_PING): 559 | if transmit_enable == False: # Do we think we receiving packets?, lets test for EOT missed 560 | if (lastSeq+1) == seq: 561 | logging.info("missed EOT") 562 | log_end_of_transmission(call, rxslot, tg, loss, start_time) 563 | transmit_enable = True # Idle state, allow local transmit 564 | lastSeq = seq 565 | # logging.debug(audio[:audio.find('\x00')]) 566 | pass 567 | elif (type == USRP_TYPE_TLV): 568 | tag = audio[0] 569 | length = audio[1] 570 | value = audio[2:] 571 | if tag == TLV_TAG_FILE_XFER: 572 | FILE_SUBCOMMAND_NAME = 0 573 | FILE_SUBCOMMAND_PAYLOAD = 1 574 | FILE_SUBCOMMAND_WRITE = 2 575 | FILE_SUBCOMMAND_READ = 3 576 | FILE_SUBCOMMAND_ERROR = 4 577 | if value[0] == FILE_SUBCOMMAND_NAME: 578 | file_len = (value[1] << 24) + (value[2] << 16) + (value[3] << 8) + value[4] 579 | file_name = value[5:] 580 | zero = file_name.find(0) 581 | file_name = file_name[:zero].decode('ASCII') 582 | logging.info("File transfer name: " + file_name) 583 | m = hashlib.md5() 584 | if value[0] == FILE_SUBCOMMAND_PAYLOAD: 585 | logging.debug("payload len = " + str(length-1)) 586 | payload = value[1:length] 587 | m.update(payload) 588 | #logging.debug(payload.hex()) 589 | #logging.debug(payload) 590 | if value[0] == FILE_SUBCOMMAND_WRITE: 591 | digest = m.digest().hex().upper() 592 | file_md5 = value[1:33].decode('ASCII') 593 | if (digest == file_md5): 594 | logging.info("File digest matches") 595 | else: 596 | logging.info("File digest does not match {} vs {}".format(digest, file_md5)) 597 | #logging.info("write (md5): " + value[1:33].hex()) 598 | if value[0] == FILE_SUBCOMMAND_ERROR: 599 | logging.info("error") 600 | else: 601 | # logging.info(soundData, len(soundData)) 602 | pass 603 | 604 | # udp.close() 605 | 606 | ################################################################################### 607 | # TX thread, send audio to AB 608 | ################################################################################### 609 | def txAudioStream(): 610 | global usrpSeq 611 | global ptt 612 | global transmit_enable 613 | FORMAT = pyaudio.paInt16 # 16 bit signed ints 614 | CHUNK = 160 if SAMPLE_RATE == 8000 else (160*6) # Size of chunk to read 615 | CHANNELS = 1 # mono 616 | RATE = SAMPLE_RATE 617 | state = None # resample state between fragments 618 | 619 | try: 620 | stream = p.open(format=FORMAT, 621 | channels = CHANNELS, 622 | rate = RATE, 623 | input = True, 624 | frames_per_buffer = CHUNK, 625 | input_device_index=in_index 626 | ) 627 | except: 628 | logging.critical(STRING_FATAL_INPUT_STREAM + str(sys.exc_info()[1])) 629 | transmit_enable = False 630 | ipc_queue.put(("dialog", "Text Message", STRING_INPUT_STREAM_ERROR)) 631 | return 632 | 633 | _i = p.get_default_output_device_info().get('index') if in_index == None else in_index 634 | logging.info("Input Device: {} Index: {}".format(p.get_device_info_by_host_api_device_index(0, _i).get('name'), _i)) 635 | 636 | lastPtt = ptt 637 | while done == False: 638 | try: 639 | 640 | if RATE == 48000: # If we are reading at 48K we need to resample to 8K 641 | audio48 = stream.read(CHUNK, exception_on_overflow=False) 642 | (audio, state) = audioop.ratecv(audio48, 2, 1, 48000, 8000, state) 643 | else: 644 | audio = stream.read(CHUNK, exception_on_overflow=False) 645 | 646 | rms = audioop.rms(audio, 2) # Get a relative power value for the sample 647 | ###### Vox processing ##### 648 | if vox_enable.get(): 649 | if rms > vox_threshold.get(): # is it loud enough? 650 | decay = vox_delay.get() # Yes, reset the decay value (wont unkey for N samples) 651 | if (ptt == False) and (transmit_enable == True): # Are we changing ptt state to True? 652 | ptt = True # Set it 653 | showPTTState(0) # Update the UI (turn transmit button red, etc) 654 | elif ptt == True: # Are we too soft and transmitting? 655 | decay -= 1 # Decrement the decay counter 656 | if decay <= 0: # Have we passed N samples, all of them less then the threshold? 657 | ptt = False # Unkey 658 | showPTTState(1) # Update the UI 659 | ########################### 660 | 661 | if ptt != lastPtt: 662 | usrp = 'USRP'.encode('ASCII') + struct.pack('>iiiiiii',usrpSeq, 0, ptt, 0, USRP_TYPE_VOICE, 0, 0) + audio 663 | sendto(usrp) 664 | usrpSeq = usrpSeq + 1 665 | lastPtt = ptt 666 | if ptt: 667 | usrp = 'USRP'.encode('ASCII') + struct.pack('>iiiiiii',usrpSeq, 0, ptt, 0, USRP_TYPE_VOICE, 0, 0) + audio 668 | sendto(usrp) 669 | usrpSeq = usrpSeq + 1 670 | audio_level.set(int(rms/100)) 671 | except: 672 | logging.warning("TX thread:" + str(sys.exc_info()[1])) 673 | 674 | def debugAudio(): 675 | p = pyaudio.PyAudio() 676 | info = p.get_host_api_info_by_index(0) 677 | print("------------------------------------") 678 | print("Info: ", info) 679 | print("------------------------------------") 680 | numdevices = info.get('deviceCount') 681 | for i in range(0, numdevices): 682 | if (p.get_device_info_by_host_api_device_index(0, i).get('maxInputChannels')) > 0: 683 | print("Input Device id ", i, " - ", p.get_device_info_by_host_api_device_index(0, i).get('name')) 684 | print("Device: ", p.get_device_info_by_host_api_device_index(0, i)) 685 | print("===============================") 686 | print("Output: ", p.get_default_output_device_info()) 687 | print("Input: ", p.get_default_input_device_info()) 688 | 689 | def listAudioDevices(want_input): 690 | devices = [] 691 | p = pyaudio.PyAudio() 692 | info = p.get_host_api_info_by_index(0) 693 | numdevices = info.get('deviceCount') 694 | for i in range(0, numdevices): 695 | is_input = p.get_device_info_by_host_api_device_index(0, i).get('maxInputChannels') > 0 696 | if (is_input and want_input) or (want_input == False and is_input == False): 697 | devices.append(p.get_device_info_by_host_api_device_index(0, i).get('name')) 698 | logging.info("Device id {} - {}".format(i, p.get_device_info_by_host_api_device_index(0, i).get('name'))) 699 | return devices 700 | 701 | #debugAudio() 702 | #exit(1) 703 | 704 | ################################################################################### 705 | # Catch and display any socket errors 706 | ################################################################################### 707 | def socketFailure(): 708 | connected_msg.set( STRING_CONNECTION_FAILURE ) 709 | logging.error(STRING_SOCKET_FAILURE) 710 | 711 | ################################################################################### 712 | # Send command to AB 713 | ################################################################################### 714 | def sendUSRPCommand( cmd, packetType ): 715 | global usrpSeq 716 | logging.info("sendUSRPCommand: "+ str(cmd)) 717 | try: 718 | # Send "text" packet to AB. 719 | usrp = 'USRP'.encode('ASCII') + (struct.pack('>iiiiiii',usrpSeq, 0, 0, 0, packetType << 24, 0, 0)) + cmd 720 | usrpSeq = (usrpSeq + 1) & 0xffff 721 | sendto(usrp) 722 | except: 723 | traceback.print_exc() 724 | socketFailure() 725 | 726 | ################################################################################### 727 | # Send command to AB 728 | ################################################################################### 729 | def sendRemoteControlCommand( cmd ): 730 | logging.info("sendRemoteControlCommand: "+ str(cmd)) 731 | # Use TLV to send command (wrapped in a USRP packet). 732 | tlv = struct.pack("BB", TLV_TAG_REMOTE_CMD, len(cmd))[0:2] + cmd 733 | sendUSRPCommand(tlv, USRP_TYPE_TLV) 734 | 735 | def sendRemoteControlCommandASCII( cmd ): 736 | sendRemoteControlCommand(bytes(cmd, 'ASCII')) 737 | ################################################################################### 738 | # Send command to DMRGateway 739 | ################################################################################### 740 | def sendToGateway( cmd ): 741 | logging.info("sendToGateway: " + cmd) 742 | 743 | ################################################################################### 744 | # Begin the registration sequence 745 | ################################################################################### 746 | def registerWithAB(): 747 | sendUSRPCommand(bytes("REG:DVSWITCH", 'ASCII'), USRP_TYPE_TEXT) 748 | 749 | ################################################################################### 750 | # Unregister from server 751 | ################################################################################### 752 | def unregisterWithAB(): 753 | sendUSRPCommand(bytes("REG:UNREG", 'ASCII'), USRP_TYPE_TEXT) 754 | 755 | ################################################################################### 756 | # Request the INFO json from AB 757 | ################################################################################### 758 | def requestInfo(): 759 | sendUSRPCommand(bytes("INFO:", 'ASCII'), USRP_TYPE_TEXT) 760 | 761 | ################################################################################### 762 | # 763 | ################################################################################### 764 | def sendMetadata(): 765 | dmr_id = subscriber_id.get() 766 | call = bytes(my_call, 'ASCII')+bytes(chr(0), 'ASCII') 767 | tlvLen = 3 + 4 + 3 + 1 + 1 + len(my_call) + 1 # dmrID, repeaterID, tg, ts, cc, call, 0 768 | cmd = struct.pack("BBBBBBBBBBBBBB", TLV_TAG_SET_INFO, tlvLen, ((dmr_id >> 16) & 0xff),((dmr_id >> 8) & 0xff),(dmr_id & 0xff),0,0,0,0,0,0,0,0,0)[0:14] + call 769 | sendUSRPCommand(cmd, USRP_TYPE_TEXT) 770 | 771 | ################################################################################### 772 | # Set the size (number of bits) of each AMBE sample 773 | ################################################################################### 774 | def setAMBESize(size): 775 | sendRemoteControlCommandASCII("ambeSize="+size) 776 | 777 | ################################################################################### 778 | # Set the AMBE mode to DMR|DSTAR|YSF|NXDN|P25 779 | ################################################################################### 780 | def setAMBEMode(mode): 781 | sendRemoteControlCommandASCII("ambeMode="+mode) 782 | 783 | ################################################################################### 784 | # 785 | ################################################################################### 786 | def getInfo(): 787 | logging.info("getInfo") 788 | 789 | ################################################################################### 790 | # xx_Bridge command: section 791 | ################################################################################### 792 | def setRemoteNetwork( netName ): 793 | logging.info("setRemoteNetwork") 794 | 795 | ################################################################################### 796 | # Set the AB mode by running the named macro 797 | ################################################################################### 798 | def setMode( mode ): 799 | sendUSRPCommand(bytes("*" + mode, 'ASCII'), USRP_TYPE_DTMF) 800 | 801 | ################################################################################### 802 | # Tell AB to select the passed tg 803 | ################################################################################### 804 | def setRemoteTG( tg ): 805 | 806 | items = map(int, listbox.curselection()) 807 | if len(list(items)) > 1: 808 | tgs="tgs=" 809 | comma = "" 810 | for atg in items: 811 | foo = listbox.get(atg) 812 | tgs = tgs + comma + foo.split(',')[1] 813 | comma = "," 814 | sendRemoteControlCommandASCII(tgs) 815 | sendRemoteControlCommandASCII("txTg=0") 816 | connected_msg.set(STRING_CONNECTED_TO) 817 | transmitButton.configure(state='disabled') 818 | else : 819 | sendRemoteControlCommandASCII("tgs=" + str(tg)) 820 | sendUSRPCommand(bytes(str(tg), 'ASCII'), USRP_TYPE_DTMF) 821 | transmit_enable = True 822 | ## setAMBEMode(master.get()) 823 | setDMRInfo() 824 | 825 | ################################################################################### 826 | # Set the slot 827 | ################################################################################### 828 | def setRemoteTS( ts ): 829 | sendRemoteControlCommandASCII("txTs=" + str(ts)) 830 | 831 | ################################################################################### 832 | # 833 | ################################################################################### 834 | def setDMRID( id ): 835 | sendRemoteControlCommandASCII("gateway_dmr_id=" + str(id)) 836 | 837 | ################################################################################### 838 | # 839 | ################################################################################### 840 | def setPeerID( id ): 841 | sendRemoteControlCommandASCII("gateway_peer_id=" + str(id)) 842 | 843 | ################################################################################### 844 | # 845 | ################################################################################### 846 | def setDMRCall( call ): 847 | sendRemoteControlCommandASCII("gateway_call=" + call) 848 | 849 | ################################################################################### 850 | # 851 | ################################################################################### 852 | def setDMRInfo(): 853 | sendToGateway("set info " + str(subscriber_id.get()) + ',' + str(repeater_id.get()) + ',' + str(getCurrentTG()) + ',' + str(slot.get()) + ',1') 854 | 855 | ################################################################################### 856 | # 857 | ################################################################################### 858 | def setVoxData(): 859 | v = "true" if vox_enable.get() > 0 else "false" 860 | sendToGateway("set vox " + v) 861 | sendToGateway("set vox_threshold " + str(vox_threshold.get())) 862 | sendToGateway("set vox_delay " + str(vox_delay.get())) 863 | 864 | ################################################################################### 865 | # 866 | ################################################################################### 867 | def getVoxData(): 868 | sendToGateway("get vox") 869 | sendToGateway("get vox_threshold ") 870 | sendToGateway("get vox_delay ") 871 | 872 | ################################################################################### 873 | # 874 | ################################################################################### 875 | def setAudioData(): 876 | dm = "true" if dongle_mode.get() > 0 else "false" 877 | sendToGateway("set dongle_mode " + dm) 878 | sendToGateway("set sp_level " + str(sp_vol.get())) 879 | sendToGateway("set mic_level " + str(mic_vol.get())) 880 | 881 | ################################################################################### 882 | # 883 | ################################################################################### 884 | def getCurrentTG(): 885 | items = map(int, listbox.curselection()) # get the item selected in the list 886 | _first = next(iter(items)) 887 | tg = talk_groups[master.get()][_first][1].translate(noQuote) # get the tg at that index 888 | return tg 889 | 890 | ################################################################################### 891 | # 892 | ################################################################################### 893 | def selectTGByValue(val): 894 | count = 0 895 | listName = master.get() 896 | for item in talk_groups[listName]: 897 | if item[1].translate(noQuote) == val: 898 | listbox.selection_clear(0,listbox.size()-1) 899 | listbox.selection_set(count) 900 | count = count + 1 901 | 902 | ################################################################################### 903 | # 904 | ################################################################################### 905 | def findTG(tg): 906 | listName = master.get() 907 | itemNum = 0 908 | for item in talk_groups[listName]: 909 | if item[1] == tg: 910 | return itemNum 911 | itemNum = itemNum+1 912 | return -1 913 | 914 | ################################################################################### 915 | # 916 | ################################################################################### 917 | def getCurrentTGName(): 918 | items = map(int, listbox.curselection()) 919 | _first = next(iter(items)) 920 | tg = talk_groups[master.get()][_first][0] 921 | return tg 922 | 923 | ################################################################################### 924 | # Connect to a specific set of TS/TG values 925 | ################################################################################### 926 | def connect(tup): 927 | if regState == False: 928 | start() 929 | if tup != None: 930 | tg = tup[0] 931 | tg_name = tup[1] 932 | else: 933 | tg = getCurrentTG() 934 | tg_name = getCurrentTGName() 935 | if tg.startswith('*') == False: # If it is not a macro, do a full dial sequence 936 | connected_msg.set( STRING_CONNECTED_TO + " " + tg_name ) 937 | # transmitButton.configure(state='normal') 938 | 939 | setRemoteNetwork(master.get()) 940 | setRemoteTS(slot.get()) 941 | setRemoteTG(tg) # set the TG (or a macro command) 942 | 943 | ################################################################################### 944 | # Mute all TS/TGs 945 | ################################################################################### 946 | def disconnect(): 947 | connected_msg.set(STRING_DISCONNECTED) 948 | # transmitButton.configure(state='disabled') 949 | 950 | ################################################################################### 951 | # Create a toast popup and begin the display and fade out 952 | ################################################################################### 953 | def popup_toast(msg): 954 | global toast_frame 955 | if toast_frame != None: # If a toast is still on the screen, kill it first 956 | toast_frame.destroy() 957 | toast_frame = Toplevel() 958 | toast_frame.wm_title(msg[1]) 959 | toast_frame.overrideredirect(1) 960 | 961 | x = root.winfo_x() 962 | y = root.winfo_y() 963 | toast_frame.geometry("+%d+%d" % (x + 250, y + 360)) 964 | 965 | l = Label(toast_frame, text=msg[2]) 966 | l.grid(row=0, column=0, padx=(10, 10)) 967 | toast_frame.after(2000, toast_fade_away) 968 | 969 | def toast_fade_away(): 970 | global toast_frame 971 | if toast_frame != None: 972 | alpha = toast_frame.attributes("-alpha") 973 | if alpha > 0: 974 | alpha -= .1 975 | toast_frame.attributes("-alpha", alpha) 976 | toast_frame.after(100, toast_fade_away) 977 | else: 978 | toast_frame.destroy() 979 | toast_frame = None 980 | 981 | def process_queue(): 982 | try: 983 | msg = ipc_queue.get(0) # wait forever for a message to be placed in the queue 984 | if msg[0] == "toast": # a toast is a tupple of title and text 985 | popup_toast(msg) 986 | if msg[0] == "photo": # an image is just a string containing the call to display 987 | showQRZImage(msg, qrz_label) 988 | if msg[0] == "macro": 989 | tgDialog(True) 990 | if msg[0] == "dialog": 991 | messagebox.showinfo(STRING_USRP_CLIENT, msg[2], parent=root) 992 | except queue.Empty: 993 | pass 994 | root.after(100, process_queue) 995 | 996 | def init_queue(): 997 | global ipc_queue 998 | ipc_queue = queue.Queue() 999 | root.after(100, process_queue) 1000 | 1001 | ################################################################################### 1002 | # Process the button press for disconnect 1003 | ################################################################################### 1004 | def disconnectButton(): 1005 | tg = talk_groups[master.get()][0][1].translate(noQuote) # get the tg at that index 1006 | setRemoteTG(tg) 1007 | disconnect() 1008 | 1009 | ################################################################################### 1010 | # 1011 | ################################################################################### 1012 | def start(): 1013 | global regState 1014 | if asl_mode.get() != 0: # Does this look like a ASL connection to USRP? 1015 | transmitButton.configure(state='normal') # Yes, fake the registration 1016 | regState = True 1017 | else: 1018 | registerWithAB() 1019 | 1020 | ################################################################################### 1021 | # Combined command to get all values from servers and display them on UI 1022 | ################################################################################### 1023 | def getValuesFromServer(): 1024 | # ip_address.set("127.0.0.1") 1025 | # loopback.set(1) 1026 | 1027 | # get values from Analog_Bridge (repeater ID, Sub ID, master, tg, slot) 1028 | ### Old Command ### sendRemoteControlCommand('get_info') 1029 | sendToGateway('get info') 1030 | # current_tx_value.set(my_call) #Subscriber call 1031 | # master.set(servers[0]) #DMR Master 1032 | # repeater_id.set(311317) #DMR Peer ID 1033 | # subscriber_id.set(3113043) #DMR Subscriber radio ID 1034 | slot.set(2) #current slot 1035 | listbox.selection_set(0) #current TG 1036 | connected_msg.set(STRING_CONNECTED_TO) #current TG 1037 | 1038 | # get values from Analog_Bridge (vox enable, delay and threshold) (not yet: sp level, mic level, audio devices) 1039 | getVoxData() #vox enable, delay and threshold 1040 | dongle_mode.set(1) #dongle mode enable 1041 | mic_vol.set(50) #microphone level 1042 | sp_vol.set(50) #speaker level 1043 | 1044 | ################################################################################### 1045 | # Update server data state to match GUI values 1046 | ################################################################################### 1047 | def sendValuesToServer(): 1048 | # send values to Analog_Bridge 1049 | setDMRInfo() 1050 | # tg = getCurrentTG() 1051 | # setRemoteNetwork(master.get()) #DMR Master 1052 | # setRemoteTG(tg) #DMR TG 1053 | # setRemoteTS(slot.get()) #DMR slot 1054 | # setDMRID(subscriber_id.get()) #DMR Subscriber ID 1055 | # setDMRCall(my_call) #Subscriber call 1056 | # setPeerID(repeater_id.get()) #DMR Peer ID 1057 | 1058 | # send values to 1059 | setVoxData() #vox enable, delay and threshold 1060 | setAudioData() #sp level, mic level, dongle mode 1061 | 1062 | ################################################################################### 1063 | # Toggle PTT and display new state 1064 | ################################################################################### 1065 | def transmit(): 1066 | global ptt 1067 | 1068 | if (transmit_enable == False) and (ptt == False): # Do not allow transmit key if rx is active 1069 | return 1070 | 1071 | ptt = not ptt 1072 | if ptt: 1073 | showPTTState(0) 1074 | else: 1075 | showPTTState(1) 1076 | 1077 | ################################################################################### 1078 | # Update UI with PTT state. 1079 | ################################################################################### 1080 | def showPTTState(flag): 1081 | global tx_start_time 1082 | if ptt: 1083 | transmitButton.configure(highlightbackground='red') 1084 | ttk.Style(root).configure("bar.Horizontal.TProgressbar", troughcolor=uc_background_color, bordercolor=uc_text_color, background="red", lightcolor="red", darkcolor="red") 1085 | tx_start_time = time() 1086 | current_tx_value.set('{} -> {}'.format(my_call, getCurrentTG())) 1087 | html_queue.put((my_call, "")) # Show my own pic when I transmit 1088 | logging.info("PTT ON") 1089 | else: 1090 | transmitButton.configure(highlightbackground=uc_background_color) 1091 | ttk.Style(root).configure("bar.Horizontal.TProgressbar", troughcolor=uc_background_color, bordercolor=uc_text_color, background="green", lightcolor="green", darkcolor="green") 1092 | if flag == 1: 1093 | _date = strftime("%m/%d/%y", localtime(time())) 1094 | _time = strftime("%H:%M:%S", localtime(time())) 1095 | _duration = '{:.2f}'.format(time() - tx_start_time) 1096 | logList.see(logList.insert('', 'end', None, values=(_date, _time, my_call, str(slot.get()), str(getCurrentTGName()), '0.00%', str(_duration)+'s'))) 1097 | current_tx_value.set(my_call) 1098 | ipc_queue.put(empty_photo) # clear the pic when in idle state 1099 | logging.info("PTT OFF") 1100 | 1101 | ################################################################################### 1102 | # Convience method to help with ttk values 1103 | ################################################################################### 1104 | def makeTkVar( constructor, val, trace=None ): 1105 | avar = constructor() 1106 | avar.set(val) 1107 | if trace: 1108 | avar.trace('w', trace) 1109 | return avar 1110 | 1111 | ################################################################################### 1112 | # Callback when the master has changed 1113 | ################################################################################### 1114 | def masterChanged(*args): 1115 | fillTalkgroupList(master.get()) # fill the TG list with the items from the new mode 1116 | current_tx_value.set(my_call) # Status bar back to idle 1117 | ipc_queue.put(empty_photo) # Remove any picture from screen 1118 | if (noTrace != True): # ignore the event generated by setting the combo box (requestInfo side effect) 1119 | logging.info("New mode selected: %s", master.get()) 1120 | setMode(master.get()) 1121 | transmit_enable = True 1122 | root.after(1000, requestInfo()) 1123 | 1124 | ################################################################################### 1125 | # Callback when a button is pressed 1126 | ################################################################################### 1127 | def buttonPress(*args): 1128 | messagebox.showinfo(STRING_USRP_CLIENT, "This is just a prototype") 1129 | 1130 | ################################################################################### 1131 | # Used for debug 1132 | ################################################################################### 1133 | def cb(value): 1134 | logging.info("value = %s", value.get()) 1135 | 1136 | ################################################################################### 1137 | # Create a simple while label 1138 | ################################################################################### 1139 | def whiteLabel(parent, textVal): 1140 | l = Label(parent, text=textVal, fg=uc_text_color, bg = uc_background_color, anchor=W) 1141 | return l 1142 | 1143 | ################################################################################### 1144 | # Popup the Talkgroup dialog. This dialog lets the user enter a custom TG into the list 1145 | ################################################################################### 1146 | def tgDialog(popdown_state): 1147 | d = MyDialog(root) 1148 | d.popdown(popdown_state) 1149 | root.wait_window(d.top) 1150 | 1151 | ################################################################################### 1152 | # 1153 | ################################################################################### 1154 | def makeModeFrame( parent ): 1155 | modeFrame = LabelFrame(parent, text = STRING_SERVER, pady = 5, padx = 5, fg=uc_text_color, bg = uc_background_color, bd = 1, relief = SUNKEN) 1156 | ttk.Button(modeFrame, text=STRING_READ, command=getValuesFromServer).grid(column=1, row=1, sticky=W) 1157 | ttk.Button(modeFrame, text=STRING_WRITE, command=sendValuesToServer).grid(column=1, row=2, sticky=W) 1158 | return modeFrame 1159 | 1160 | ################################################################################### 1161 | # 1162 | ################################################################################### 1163 | def makeAudioFrame( parent ): 1164 | audioFrame = LabelFrame(parent, text = STRING_AUDIO, pady = 5, padx = 5, fg=uc_text_color, bg = uc_background_color, bd = 1, relief = SUNKEN) 1165 | whiteLabel(audioFrame, STRING_MIC).grid(column=1, row=1, sticky=W, padx = 5, pady=1) 1166 | whiteLabel(audioFrame, STRING_SPEAKER).grid(column=1, row=2, sticky=W, padx = 5, pady=1) 1167 | ttk.Scale(audioFrame, from_=0, to=100, orient=HORIZONTAL, variable=mic_vol, 1168 | command=lambda x: cb(mic_vol)).grid(column=2, row=1, sticky=(W,E), pady=1) 1169 | ttk.Scale(audioFrame, from_=0, to=100, orient=HORIZONTAL, variable=sp_vol, 1170 | command=lambda x: cb(sp_vol)).grid(column=2, row=2, sticky=(W,E), pady=1) 1171 | 1172 | devices = listAudioDevices(True) 1173 | if len(devices) > 0: 1174 | whiteLabel(audioFrame, STRING_INPUT).grid(column=1, row=3, sticky=W, padx = 5) 1175 | invar = StringVar(root) 1176 | invar.set(devices[0]) # default value 1177 | inp = OptionMenu(audioFrame, invar, *devices) 1178 | inp.config(width=20, bg=uc_background_color) 1179 | inp.grid(column=2, row=3, sticky=W) 1180 | 1181 | whiteLabel(audioFrame, STRING_OUTPUT).grid(column=1, row=4, sticky=W, padx = 5) 1182 | devices = listAudioDevices(False) 1183 | outvar = StringVar(root) 1184 | outvar.set(devices[0]) # default value 1185 | out = OptionMenu(audioFrame, outvar, *devices) 1186 | out.config(width=20, bg=uc_background_color) 1187 | out.grid(column=2, row=4, sticky=W) 1188 | 1189 | return audioFrame 1190 | 1191 | ################################################################################### 1192 | # Populate the talkgroup list with the entries loaded from the configuration file 1193 | ################################################################################### 1194 | def fillTalkgroupList( listName ): 1195 | listbox.delete(0, END) 1196 | for item in talk_groups[listName]: 1197 | listbox.insert(END, item[0]) 1198 | listbox.selection_set(0) 1199 | 1200 | ################################################################################### 1201 | # 1202 | ################################################################################### 1203 | def makeGroupFrame( parent ): 1204 | global listbox 1205 | dmrFrame = LabelFrame(parent, text = STRING_TALKGROUPS, pady = 5, padx = 5, fg=uc_text_color, bg = uc_background_color, bd = 1, relief = SUNKEN) 1206 | whiteLabel(dmrFrame, STRING_TS).grid(column=1, row=1, sticky=W, padx = 5) 1207 | Spinbox(dmrFrame, from_=1, to=2, width = 5, fg=uc_text_color, bg=uc_background_color, textvariable = slot).grid(column=2, row=1, sticky=W) 1208 | whiteLabel(dmrFrame, STRING_TG).grid(column=1, row=2, sticky=(N, W), padx = 5) 1209 | 1210 | listFrame = Frame(dmrFrame, bd=1, highlightbackground="black", highlightcolor="black", highlightthickness=1) 1211 | listFrame.grid(column=2, row=2, sticky=W, columnspan=2) 1212 | listbox = Listbox(listFrame, selectmode=EXTENDED, bd=0, bg=uc_background_color) 1213 | listbox.configure(fg=uc_text_color, exportselection=False) 1214 | listbox.grid(column=1, row=1, sticky=W) 1215 | 1216 | scrollbar = Scrollbar(listFrame, orient="vertical") 1217 | scrollbar.config(command=listbox.yview) 1218 | scrollbar.grid(column=3, row=1, sticky=(N,S)) 1219 | listbox.config(yscrollcommand=scrollbar.set) 1220 | 1221 | fillTalkgroupList(defaultServer) 1222 | ttk.Button(dmrFrame, text=STRING_TG, command= lambda: tgDialog(False), width = 3).grid(column=1, row=3, sticky=W) 1223 | ttk.Button(dmrFrame, text=STRING_CONNECT, command= lambda: connect(None)).grid(column=2, row=3, sticky=W) 1224 | ttk.Button(dmrFrame, text=STRING_DISCONNECT, command=disconnectButton).grid(column=3, row=3, sticky=W) 1225 | return dmrFrame 1226 | 1227 | ################################################################################### 1228 | # 1229 | ################################################################################### 1230 | def makeLogFrame( parent ): 1231 | global logList 1232 | logFrame = Frame(parent, pady = 5, padx = 5, bg = uc_background_color, bd = 1, relief = SUNKEN) 1233 | 1234 | logList = ttk.Treeview(logFrame) 1235 | logList.grid(column=1, row=2, sticky=W, columnspan=5) 1236 | 1237 | cols = (STRING_DATE, STRING_TIME, STRING_CALL, STRING_SLOT, STRING_TG, STRING_LOSS, STRING_DURATION) 1238 | widths = [85, 85, 80, 55, 150, 70, 95] 1239 | logList.config(columns=cols) 1240 | logList.column("#0", width=1 ) 1241 | i = 0 1242 | for item in cols: 1243 | a = 'w' if i < 6 else 'e' 1244 | logList.column(item, width=widths[i], anchor=a ) 1245 | logList.heading(item, text=item) 1246 | i += 1 1247 | 1248 | setup_rightmouse_menu(root, logList) 1249 | return logFrame 1250 | 1251 | ################################################################################### 1252 | # 1253 | ################################################################################### 1254 | def makeTransmitFrame(parent): 1255 | global transmitButton 1256 | transmitFrame = Frame(parent, pady = 5, padx = 5, bg = uc_background_color, bd = 1) 1257 | transmitButton = Button(transmitFrame, text=STRING_TRANSMIT, command=transmit, width = 40, font='Helvetica 18 bold', state='disabled') 1258 | transmitButton.grid(column=1, row=1, sticky=W) 1259 | transmitButton.configure(highlightbackground=uc_background_color) 1260 | 1261 | 1262 | #ttk.Scale(transmitFrame, from_=0, to=100, orient=HORIZONTAL, variable=audio_level,).grid(column=1, row=2, sticky=(W,E), pady=1) 1263 | 1264 | ttk.Progressbar(transmitFrame, style="bar.Horizontal.TProgressbar", orient=HORIZONTAL, variable=audio_level).grid(column=1, row=2, sticky=(W,E), pady=1) 1265 | 1266 | 1267 | return transmitFrame 1268 | 1269 | ################################################################################### 1270 | # Handle the user clicking on the pic, launch a browser with the URL pointint to the 1271 | # lookup. 1272 | ################################################################################### 1273 | def clickQRZImage(event): 1274 | call = event.widget.callsign 1275 | if len(call) > 0: 1276 | webbrowser.open_new_tab("http://www.qrz.com/lookup/"+call) 1277 | 1278 | def makeQRZFrame(parent): 1279 | global qrz_label, qrz_call, qrz_name 1280 | qrzFrame = Frame(parent, bg = uc_background_color, bd = 1) 1281 | lx = Label(qrzFrame, text="", anchor=W, bg = uc_background_color, cursor="hand2") 1282 | lx.grid(column=1, row=1, sticky=W) 1283 | qrz_label = lx 1284 | qrz_label.bind("", clickQRZImage) 1285 | 1286 | meta_frame = Frame(qrzFrame, bg = uc_background_color, bd = 1) 1287 | meta_frame.grid(column=2, row=1, sticky=N) 1288 | 1289 | qrz_call = Label(meta_frame, textvariable=current_call, anchor=W, fg=uc_text_color, bg = uc_background_color, font='Helvetica 18 bold') 1290 | qrz_call.grid(column=1, row=1, sticky=EW) 1291 | qrz_name = Label(meta_frame, textvariable=current_name, anchor=W, fg=uc_text_color, bg = uc_background_color, font='Helvetica 18 bold') 1292 | qrz_name.grid(column=1, row=2, sticky=EW) 1293 | 1294 | return qrzFrame 1295 | 1296 | ################################################################################### 1297 | # 1298 | ################################################################################### 1299 | def makeAppFrame( parent ): 1300 | appFrame = Frame(parent, pady = 5, padx = 5, bg = uc_background_color, bd = 1, relief = SUNKEN) 1301 | appFrame.grid(column=0, row=0, sticky=(N, W, E, S)) 1302 | appFrame.columnconfigure(0, weight=1) 1303 | appFrame.rowconfigure(0, weight=1) 1304 | 1305 | makeModeSettingsFrame(appFrame).grid(column=0, row=1, sticky=(N,W), padx = 5) 1306 | makeQRZFrame(appFrame).grid(column=0, row=2, sticky=W, padx=5) 1307 | makeGroupFrame(appFrame).grid(column=2, row=1, sticky=N, rowspan=2) 1308 | makeTransmitFrame(appFrame).grid(column=0, row=3, sticky=N, columnspan=3, pady = 10) 1309 | 1310 | return appFrame 1311 | 1312 | ################################################################################### 1313 | # 1314 | ################################################################################### 1315 | def makeModeSettingsFrame( parent ): 1316 | ypad = 4 1317 | dmrgroup = LabelFrame(parent, text=STRING_MODE, padx=5, pady=ypad, fg=uc_text_color, bg = uc_background_color, relief = SUNKEN) 1318 | whiteLabel(dmrgroup, "Mode").grid(column=1, row=1, sticky=W, padx = 5, pady = ypad) 1319 | w = OptionMenu(dmrgroup, master, *servers) 1320 | w.grid(column=2, row=1, sticky=W, padx = 5, pady = ypad) 1321 | w.config(fg=uc_text_color, bg=uc_background_color) 1322 | w["menu"].config(fg=uc_text_color) 1323 | w["menu"].config(bg=uc_background_color) 1324 | 1325 | whiteLabel(dmrgroup, STRING_REPEATER_ID).grid(column=1, row=2, sticky=W, padx = 5, pady = ypad) 1326 | Entry(dmrgroup, width = 20, bg = uc_background_color, fg=uc_text_color, textvariable = repeater_id).grid(column=2, row=2, pady = ypad) 1327 | whiteLabel(dmrgroup, STRING_SUBSCRIBER_ID).grid(column=1, row=3, sticky=W, padx = 5, pady = ypad) 1328 | Entry(dmrgroup, width = 20, bg = uc_background_color, fg=uc_text_color, textvariable = subscriber_id).grid(column=2, row=3, pady = ypad) 1329 | 1330 | return dmrgroup 1331 | 1332 | ################################################################################### 1333 | # 1334 | ################################################################################### 1335 | def makeVoxSettingsFrame( parent ): 1336 | ypad = 4 1337 | voxSettings = LabelFrame(parent, text=STRING_VOX, padx=5, pady = ypad, fg=uc_text_color, bg = uc_background_color, relief = SUNKEN) 1338 | Checkbutton(voxSettings, text = STRING_DONGLE_MODE, variable=dongle_mode, command=lambda: cb(dongle_mode), fg=uc_text_color, bg = uc_background_color, bd = 0, highlightthickness = 0).grid(column=1, row=1, sticky=W) 1339 | Checkbutton(voxSettings, text = STRING_VOX_ENABLE, variable=vox_enable, command=lambda: cb(vox_enable), fg=uc_text_color, bg = uc_background_color, bd = 0, highlightthickness = 0).grid(column=1, row=2, sticky=W) 1340 | whiteLabel(voxSettings, STRING_VOX_THRESHOLD).grid(column=1, row=3, sticky=W, padx = 5, pady = ypad) 1341 | Spinbox(voxSettings, from_=1, to=32767, width = 5, fg=uc_text_color, bg=uc_background_color, textvariable = vox_threshold).grid(column=2, row=3, sticky=W, pady = ypad) 1342 | whiteLabel(voxSettings, STRING_VOX_DELAY).grid(column=1, row=4, sticky=W, padx = 5, pady = ypad) 1343 | Spinbox(voxSettings, from_=1, to=500, width = 5, fg=uc_text_color, bg=uc_background_color, textvariable = vox_delay).grid(column=2, row=4, sticky=W, pady = ypad) 1344 | 1345 | return voxSettings 1346 | 1347 | ################################################################################### 1348 | # 1349 | ################################################################################### 1350 | def makeIPSettingsFrame( parent ): 1351 | ypad = 4 1352 | ipSettings = LabelFrame(parent, text=STRING_NETWORK, padx=5, pady = ypad, fg=uc_text_color, bg = uc_background_color, relief = SUNKEN) 1353 | Checkbutton(ipSettings, text = STRING_LOOPBACK, variable=loopback, command=lambda: cb(loopback), fg=uc_text_color, bg = uc_background_color, bd = 0, highlightthickness = 0).grid(column=1, row=1, sticky=W) 1354 | whiteLabel(ipSettings, STRING_IP_ADDRESS).grid(column=1, row=2, sticky=W, padx = 5, pady = ypad) 1355 | Entry(ipSettings, width = 20, fg=uc_text_color, bg=uc_background_color, textvariable = ip_address).grid(column=2, row=2, pady = ypad) 1356 | return ipSettings 1357 | 1358 | ################################################################################### 1359 | # 1360 | ################################################################################### 1361 | def makeSettingsFrame( parent ): 1362 | settingsFrame = Frame(parent, width = 500, height = 500,pady = 5, padx = 5, bg = uc_background_color, bd = 1, relief = SUNKEN) 1363 | makeModeFrame(settingsFrame).grid(column=1, row=1, sticky=(N,W), padx = 5) 1364 | makeIPSettingsFrame(settingsFrame).grid(column=2, row=1, sticky=(N,W), padx = 5, pady = 5, columnspan=2) 1365 | makeVoxSettingsFrame(settingsFrame).grid(column=1, row=2, sticky=(N,W), padx = 5) 1366 | makeAudioFrame(settingsFrame).grid(column=2, row=2, sticky=(N,W), padx = 5) 1367 | return settingsFrame 1368 | 1369 | ################################################################################### 1370 | # 1371 | ################################################################################### 1372 | def makeAboutFrame( parent ): 1373 | aboutFrame = Frame(parent, width = parent.winfo_width(), height = parent.winfo_height(),pady = 5, padx = 5, bg = uc_background_color, bd = 1, relief = SUNKEN) 1374 | aboutText = "USRP Client (pyUC) Version " + UC_VERSION + "\n" 1375 | aboutText += "(C) 2019, 2020 DVSwitch, INAD.\n" 1376 | aboutText += "Created by Mike N4IRR and Steve N4IRS\n" 1377 | aboutText += "pyUC comes with ABSOLUTELY NO WARRANTY\n\n" 1378 | aboutText += "This software is for use on amateur radio networks only,\n" 1379 | aboutText += "it is to be used for educational purposes only. Its use on\n" 1380 | aboutText += "commercial networks is strictly prohibited.\n\n" 1381 | aboutText += "Code improvements are encouraged, please\n" 1382 | aboutText += "contribute to the development branch located at" 1383 | linkText = "https://github.com/DVSwitch/USRP_Client\n" 1384 | 1385 | background = None 1386 | try: 1387 | image_url = "https://media.boingboing.net/wp-content/uploads/2017/06/giphy-2.gif" 1388 | image_byt = urllib.request.urlopen(image_url).read() 1389 | image_b64 = base64.encodebytes(image_byt) 1390 | background = PhotoImage(data=image_b64) 1391 | background = background.subsample(3, 3) 1392 | lx = Label(aboutFrame, text="maz", anchor=W, image=background, cursor="hand2") 1393 | lx.photo = background 1394 | lx.callsign = "n4irr" 1395 | lx.bind("", clickQRZImage) 1396 | lx.grid(column=1, row=1, sticky=NW, padx = 5, pady = 5) 1397 | 1398 | except: 1399 | logging.warning("no image:" + str(sys.exc_info()[1])) 1400 | msg = Message(aboutFrame, text=aboutText, fg=uc_text_color, bg = uc_background_color, anchor=W, width=500) 1401 | msg.grid(column=2, row=1, sticky=NW, padx = 5, pady = 0) 1402 | 1403 | link = Label(aboutFrame, text=linkText, bg = uc_background_color, fg='blue', anchor=W, cursor="hand2") 1404 | link.grid(column=2, row=2, sticky=NW, padx = 5, pady = 0) 1405 | link.bind("", lambda e: webbrowser.open_new("https://github.com/DVSwitch/USRP_Client")) 1406 | f = font.Font(link, link.cget("font")) 1407 | f.configure(underline=True) 1408 | link.configure(font=f) 1409 | 1410 | return aboutFrame 1411 | 1412 | ################################################################################### 1413 | # Each second this function will be called, update the status bar 1414 | ################################################################################### 1415 | def update_clock(obj): 1416 | now = strftime("%H:%M:%S") 1417 | obj.configure(text=now) 1418 | root.after(1000, update_clock, obj) 1419 | 1420 | ################################################################################### 1421 | # 1422 | ################################################################################### 1423 | def makeStatusBar( parent ): 1424 | w = 25 1425 | statusBar = Frame(parent, pady = 5, padx = 5, bg = uc_background_color) 1426 | Label(statusBar, fg=uc_text_color, bg = uc_background_color, textvariable=connected_msg, anchor='w', width = w).grid(column=1, row=1, sticky=W) 1427 | Label(statusBar, fg=uc_text_color, bg = uc_background_color, textvariable=current_tx_value, anchor='center', width = w).grid(column=2, row=1, sticky=N) 1428 | obj = Label(statusBar, fg=uc_text_color, bg = uc_background_color, text="", anchor='e', width = w) 1429 | obj.grid(column=3, row=1, sticky=E) 1430 | root.after(1000, update_clock, obj) 1431 | return statusBar 1432 | 1433 | def setStyles(): 1434 | style = ttk.Style(root) 1435 | # set ttk theme to "clam" which support the fieldbackground option 1436 | style.theme_use("clam") 1437 | style.configure("Treeview", background=uc_background_color, fieldbackground=uc_background_color, foreground=uc_text_color) 1438 | style.configure('TNotebook.Tab', foreground=uc_text_color, background=uc_background_color) 1439 | style.map('TNotebook.Tab', background=[('disabled', 'magenta')]) 1440 | style.configure('TButton', foreground=uc_text_color, background=uc_background_color) 1441 | style.configure("bar.Horizontal.TProgressbar", troughcolor=uc_background_color, bordercolor=uc_text_color, background="green", lightcolor="green", darkcolor="green") 1442 | 1443 | ################################################################################### 1444 | # Read an int value from the ini file. If an error or value is Default, return the 1445 | # valDefault passed in. 1446 | ################################################################################### 1447 | def readValue( config, stanza, valName, valDefault, func ): 1448 | try: 1449 | val = config.get(stanza, valName).split(None)[0] 1450 | if val.lower() == "default": # This is a special case for the in and out index settings 1451 | return valDefault 1452 | return func(val) 1453 | except: 1454 | return valDefault 1455 | 1456 | ################################################################################### 1457 | # It is required that the user edit the ini file and fill in at least three values. 1458 | # The callsign, DMR Id and the USRP server address must be set to something other 1459 | # than the default values to be valid. 1460 | ################################################################################### 1461 | def validateConfigInfo(): 1462 | valid = (my_call != "N0CALL") # Make sure they set a ham radio callsign 1463 | valid &= (subscriber_id != 3112000) # Make sure they set a DMR/CCS7 ID 1464 | valid &= (ip_address.get() != "1.2.3.4") # Make sure they have a valid address for AB 1465 | return valid 1466 | 1467 | ################################################################################### 1468 | # Close down the app when the main window closes. Signal the threads to terminate 1469 | # and tell AB we are done. 1470 | ################################################################################### 1471 | def on_closing(): 1472 | global done 1473 | logging.info(STRING_EXITING) 1474 | done = True # Signal the threads to terminate 1475 | if regState == True: # If we were registered, tell AB we are done 1476 | sleep(1) # wait just a moment for them to die 1477 | unregisterWithAB() 1478 | root.destroy() 1479 | 1480 | ############################################################################################################ 1481 | # 1482 | ############################################################################################################ 1483 | def get_rt_menu_call(): 1484 | iid = logList.selection() 1485 | call = logList.item(iid)['values'][2].strip() 1486 | is_valid = False 1487 | if len(call) > 0: 1488 | if call.isdigit() == False: 1489 | is_valid = True 1490 | return (is_valid, call) 1491 | 1492 | def lookup_call_on_web( service, url): 1493 | is_valid, call = get_rt_menu_call() 1494 | if is_valid == True: 1495 | logging.info("Lookup call " + call + " on service " + service) 1496 | webbrowser.open_new_tab(url+call) 1497 | 1498 | def menu1(): 1499 | lookup_call_on_web( "QRZ", "http://www.qrz.com/lookup/") 1500 | pass 1501 | def menu2(): 1502 | lookup_call_on_web( "aprs.fi", "https://aprs.fi/#!call=a%2F") 1503 | pass 1504 | def menu3(): 1505 | lookup_call_on_web( "Brandmeister", "https://brandmeister.network/index.php?page=profile&call=") 1506 | pass 1507 | def menu4(): 1508 | lookup_call_on_web( "Hamdata.com", "http://hamdata.com/getcall.html?callsign=") 1509 | pass 1510 | def menu5(): 1511 | pass 1512 | 1513 | def setup_rightmouse_menu(master, tree): 1514 | tree.aMenu = Menu(master, tearoff=0) 1515 | tree.aMenu.add_command(label='QRZ', command=menu1) 1516 | tree.aMenu.add_command(label='aprs.fi', command=menu2) 1517 | tree.aMenu.add_command(label='Brandmeister', command=menu3) 1518 | tree.aMenu.add_command(label='Hamdata lookup', command=menu4) 1519 | tree.aMenu.add_command(label='Private Call', command=menu5) 1520 | 1521 | # attach popup to treeview widget 1522 | tree.bind("", popup) 1523 | tree.bind("", popup) 1524 | tree.aMenu.bind("",popupFocusOut) 1525 | 1526 | def popup(event): 1527 | iid = logList.identify_row(event.y) 1528 | if iid: 1529 | # mouse pointer over item 1530 | logList.selection_set(iid) 1531 | logList.aMenu.post(event.x_root, event.y_root) 1532 | logList.aMenu.focus_set() 1533 | else: 1534 | pass 1535 | def popupFocusOut(self,event=None): 1536 | logList.aMenu.unpost() 1537 | 1538 | ############################################################################################################ 1539 | # Global commands 1540 | ############################################################################################################ 1541 | 1542 | root = Tk() 1543 | root.title(STRING_USRP_CLIENT) 1544 | root.resizable(width=FALSE, height=FALSE) 1545 | root.configure(bg=uc_background_color) 1546 | 1547 | nb = ttk.Notebook(root) # A tabbed interface container 1548 | 1549 | # Load data from the config file 1550 | if len(sys.argv) > 1: 1551 | config_file_name = sys.argv[1] # Use the command line argument for the path to the config file 1552 | else: 1553 | config_file_name = str(Path(sys.argv[0]).parent) + "/pyUC.ini" # Use the default config file name in the same dir as .py file 1554 | config = configparser.ConfigParser(inline_comment_prefixes=(';',)) 1555 | config.optionxform = lambda option: option 1556 | try: 1557 | config.read(config_file_name) 1558 | my_call = config.get('DEFAULTS', "myCall").split(None)[0] 1559 | loopback = makeTkVar(IntVar, config.get('DEFAULTS', "loopback").split(None)[0]) 1560 | dongle_mode = makeTkVar(IntVar, config.get('DEFAULTS', "dongleMode").split(None)[0]) 1561 | vox_enable = makeTkVar(IntVar, config.get('DEFAULTS', "voxEnable").split(None)[0]) 1562 | mic_vol = makeTkVar(IntVar, config.get('DEFAULTS', "micVol").split(None)[0]) 1563 | sp_vol = makeTkVar(IntVar, config.get('DEFAULTS', "spVol").split(None)[0]) 1564 | repeater_id = makeTkVar(IntVar, config.get('DEFAULTS', "repeaterID").split(None)[0]) 1565 | subscriber_id = makeTkVar(IntVar, config.get('DEFAULTS', "subscriberID").split(None)[0]) 1566 | vox_threshold = makeTkVar(IntVar, config.get('DEFAULTS', "voxThreshold").split(None)[0]) 1567 | vox_delay = makeTkVar(IntVar, config.get('DEFAULTS', "voxDelay").split(None)[0]) 1568 | ip_address = makeTkVar(StringVar, config.get('DEFAULTS', "ipAddress").split(None)[0]) 1569 | usrp_tx_port = [int(i) for i in config.get('DEFAULTS', "usrpTxPort").split(',')] 1570 | usrp_rx_port = int(config.get('DEFAULTS', "usrpRxPort").split(None)[0]) 1571 | slot = makeTkVar(IntVar, config.get('DEFAULTS', "slot").split(None)[0]) 1572 | defaultServer = config.get('DEFAULTS', "defaultServer").split(None)[0] 1573 | asl_mode = makeTkVar(IntVar, config.get('DEFAULTS', "aslMode").split(None)[0]) 1574 | useQRZ = bool(readValue(config, 'DEFAULTS', 'useQRZ', True, int)) 1575 | level_every_sample = int(readValue(config, 'DEFAULTS', 'levelEverySample', 2, int)) 1576 | NAT_ping_timer = int(readValue(config, 'DEFAULTS', 'pingTimer', 0, int)) 1577 | 1578 | in_index = readValue(config, 'DEFAULTS', 'in_index', None, int) 1579 | out_index = readValue(config, 'DEFAULTS', 'out_index', None, int) 1580 | 1581 | uc_background_color = readValue(config, 'DEFAULTS', 'backgroundColor', 'gray25', str) 1582 | uc_text_color = readValue(config, 'DEFAULTS', 'textColor', 'white', str) 1583 | 1584 | talk_groups = {} 1585 | for sect in config.sections(): 1586 | if (sect != "DEFAULTS") and (sect != "MACROS"): 1587 | talk_groups[sect] = config.items(sect) 1588 | 1589 | if "MACROS" in config.sections(): 1590 | for x in config.items("MACROS"): 1591 | macros[x[1]] = x[0] 1592 | 1593 | if validateConfigInfo() == False: 1594 | logging.error(STRING_CONFIG_NOT_EDITED) 1595 | os._exit(1) 1596 | 1597 | except: 1598 | logging.error(STRING_CONFIG_FILE_ERROR + str(sys.exc_info()[1])) 1599 | sys.exit('Configuration file \''+config_file_name+'\' is not a valid configuration file! Exiting...') 1600 | 1601 | servers = sorted(talk_groups.keys()) 1602 | master = makeTkVar(StringVar, defaultServer, masterChanged) 1603 | connected_msg = makeTkVar(StringVar, STRING_CONNECTED_TO) 1604 | current_tx_value = makeTkVar(StringVar, my_call) 1605 | current_call = makeTkVar(StringVar, "") 1606 | current_name = makeTkVar(StringVar, "") 1607 | audio_level = makeTkVar(IntVar, 0) 1608 | 1609 | setStyles() 1610 | 1611 | # Add each frame to the "notebook" (tabs) 1612 | nb.add(makeAppFrame( nb ), text=STRING_TAB_MAIN) 1613 | nb.add(makeSettingsFrame( nb ), text=STRING_TAB_SETTINGS) 1614 | nb.add(makeAboutFrame( nb ), text=STRING_TAB_ABOUT) 1615 | nb.grid(column=1, row=1, sticky='EW') 1616 | 1617 | # Create the other frames 1618 | makeLogFrame(root).grid(column=1, row=2) 1619 | makeStatusBar(root).grid(column=1, row=3, sticky=W+E) 1620 | 1621 | init_queue() # Create the queue for thread to main app communications 1622 | openStream() # Open the UDP stream to AB 1623 | with noalsaerr(): 1624 | p = pyaudio.PyAudio() 1625 | _thread.start_new_thread( rxAudioStream, () ) 1626 | if in_index != -1: # Do not launch the TX thread if the user wants RX only access 1627 | _thread.start_new_thread( txAudioStream, () ) 1628 | _thread.start_new_thread( html_thread, () ) # Start up the HTML thread for background image loads 1629 | if NAT_ping_timer > 0: 1630 | _thread.start_new_thread( ping_thread, () ) 1631 | 1632 | disconnect() # Start out in the disconnected state 1633 | start() # Begin the handshake with AB (register) 1634 | 1635 | root.protocol("WM_DELETE_WINDOW", on_closing) 1636 | root.mainloop() 1637 | 1638 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | def readme(): 6 | with open('README.rst') as file: 7 | return file.read() 8 | 9 | setup(name='pyUC', 10 | version='1.1.0', 11 | description='USRP Client for DVSwitch', 12 | long_description='A GUI client to access the DVSwitch digital ham software suite', 13 | classifiers=[ 14 | 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)' 15 | 'Programming Language :: Python :: 3' 16 | 'Intended Audience :: Ham Radio Operators' 17 | 'Natural Language :: English' 18 | 'Operating System :: OS Independent' 19 | 'Programming Language :: Python :: Implementation :: CPython' 20 | 'Topic :: Communications :: Ham Radio' 21 | 'Topic :: Software Development :: Libraries :: Python Modules' 22 | 'Topic :: Utilities' 23 | ], 24 | keywords='dmr ysf nxdn p25 dstar radio digital mmdvm ham amateur radio', 25 | author='Michael Zingman, N4IRR', 26 | author_email='n4irr@amsat.org', 27 | install_requires=['pyaudio', 'ImageTk', 'BeautifulSoup4', 'pillow', 'requests'], 28 | license='GPLv3', 29 | url='https://github.com/DVSwitch/USRP_Client', 30 | packages=['pyUC'], 31 | #packages=find_packages() 32 | ) 33 | 34 | # apt-get install portaudio19-dev python3-pil python3-pil.imagetk 35 | -------------------------------------------------------------------------------- /test.md: -------------------------------------------------------------------------------- 1 | 2 | # USRP_Client (pyUC) 3 | 4 | ## Introduction 5 | The pyUC python application is a GUI front end for accessing ham radio digital networks from your PC. It is the front end app for the DVSwitch suite of software and connects to the Analog_Bridge component. 6 | ## Features 7 | The user can: 8 | 9 | - Select digital network 10 | - Select "talk group" or reflector from a list 11 | - Transmit and receive to the network using their speakers and mic 12 | - Record a list of stations received in the session 13 | - See pictures of the hams from QRZ.com 14 | 15 | ## Installation 16 | Download and unzip https://github.com/DVSwitch/USRP_Client/archive/master.zip 17 | 18 | Install instructions by platform: 19 | 20 | - Windows 10 21 | 22 | Use Python 3.7 from the Microsoft Store 23 | Open a command prompt 24 | **python -m pip install --upgrade pip** 25 | Download PyAudio from https://www.lfd.uci.edu/~gohlke/pythonlibs/ for your version (32 or 64 bit) 26 | 27 | **pip install PyAudio-0.2.11-cp37-cp37m-win_XXX.whl 28 | pip install bs4 29 | pip install Pillow 30 | pip install requests** 31 | Edit pyUC.ini 32 | 33 | - Linux (Tested on a Raspberry Pi running Buster and Linux Mint 19) 34 | 35 | Open a command prompt 36 | **sudo apt-get install python3-pyaudio 37 | sudo apt-get install portaudio19-dev 38 | sudo apt-get install python3-pil.imagetk** 39 | Edit pyUC.ini 40 | 41 | - Mac 42 | 43 | **ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" 44 | brew install python 45 | brew install portaudio 46 | pip3 install pyaudio 47 | pip3 install bs4 Pillow requests** 48 | Edit pyUC.ini 49 | 50 | ## Contributing 51 | We encourage others of submit pull request tp this repository. We only ask that you submit the pull request on the development branch. Your pull will be reviewed and merged into the master branch. 52 | ## Related projects 53 | DVSwitch 54 | ## Licensing 55 | This software is for use on amateur radio networks only, it is to be used 56 | for educational purposes only. Its use on commercial networks is strictly 57 | prohibited. Permission to use, copy, modify, and/or distribute this software 58 | hereby granted, provided that the above copyright notice and this permission 59 | notice appear in all copies. 60 | 61 | THE SOFTWARE IS PROVIDED "AS IS" AND DVSWITCH DISCLAIMS ALL WARRANTIES WITH 62 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 63 | AND FITNESS. IN NO EVENT SHALL N4IRR BE LIABLE FOR ANY SPECIAL, DIRECT, 64 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 65 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE 66 | OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 67 | PERFORMANCE OF THIS SOFTWARE. 68 | --------------------------------------------------------------------------------