diff --git a/DejaVuSansMono.ttf b/DejaVuSansMono.ttf
new file mode 100644
index 0000000..1d299ee
Binary files /dev/null and b/DejaVuSansMono.ttf differ
diff --git a/README.md b/README.md
index 853cd24..8a68533 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,113 @@
-# nrsc5-dui
\ No newline at end of file
+NRSC5-DUI is a graphical interface for [nrsc5](https://github.com/theori-io/nrsc5).
+It makes it easy to play your favorite FM HD radio stations using an RTL-SDR dongle.
+It will also display weather radar and traffic maps found on most iHeart radio
+stations.
+
+This version is really a fork of a fork of the original nrsc5-gui: The first
+developed by [cmnybo](https://github.com/cmnybo/nrsc5-gui) and subsequently modified
+by [zefie](https://github.com/zefie/nrsc5-gui). It merges the features of the former
+to the architecture of the latter, while adding several additional control and display
+features.
+
+As such, we have changed the name to 'DUI' as a play on the Italian word for 'two',
+this being a second generation graphical user interface for nrsc5. (I'll be here all
+week. Please tip your waitresses.)
+
+# Dependencies
+
+The folowing programs are required to run NRSC5-DUI
+
+* [Python 3](https://www.python.org/downloads/release)
+* [PyGObject](https://pygobject.readthedocs.io/en/latest/)
+* [Pillow](https://pillow.readthedocs.io/en/stable/)
+* [Python Imaging Library](http://pythonware.com/products/pil)
+* [NumPy](http://www.numpy.org)
+* [Python Dateutil](https://pypi.org/project/python-dateutil)
+* [nrsc5](https://github.com/theori-io/nrsc5)
+* [sox](https://github.com/chirlu/sox)
+
+# Setup
+1. Install the latest version of Python 3.9, PyGObject, Pillow, etc.
+2. Compile and install nrsc5.
+3. Install sox
+4. Install nrsc5-gui files in a directory where you have write permissions.
+
+The configuration and resource directories will be created in a new cfg and res directory
+under where nrsc5-dui.py resides. Similarly, an aas directory will be created for downloaded
+files and a map directory will be created to store weather & traffic maps.
+
+nrsc5 should be installed in a directory that is in your `$PATH` environment variable.
+Otherwise you can edit lines 27 & 28 of nrsc5-dui.py to provide a full path to nrsc5.
+
+# Usage
+Please ensure your RTL-SDR dongle is connected to an available USB port.
+From the terminal, start nrsc5-dui by entering: `python3 nrsc5-dui.py`.
+
+## Settings
+You may first change some optional parameters of how nrsc5 works from the Settings tab in nrsc5-dui:
+Set the gain to Auto, or optionally enter an RF gain in dB that has known to work well for some stations.
+Enter a PPM correction value if your RTL-SDR dongle has an offset.
+Enter the number of the desired device if you have more than one RTL-SDR dongle.
+Check `Log to file` to enable writing debug information from nrsc5 to nrsc5.log.
+
+## Playing
+Enter the frequency in MHz of the station you want to play and click the triangular Play button on the toolbar, or just hit return.
+When the receiver attains synchronization, the pilot in the lower left corner of the status bar turn green. It return to gray if synchronization is lost.
+If the device itself becomes 'lost', the pilot will turn red to indicate an error has occurred (this is the theory, though I've yet to see this status message happen in practice).
+The synchronization process may take about 10 seconds, and the station will begin to play. This depends upon signal strength and whether it's relatively free from adjacent interference.
+After a short while, the station name will appear to the right of the frequency, and the available streams will show on a row of buttons just beneath the frequency entry.
+Clicking one of these buttons will change to that particular stream.
+Note: No settings other than stream may be changed while the device is playing.
+
+## Album Art & Track Info
+Some stations will send album art and station logos. These will fill the Album Art tab, as they are made available by the station.
+Most stations will send the song title, artist, album, and genre. These are displayed in the Track Info pane, also if available.
+
+## Bookmarks
+When a station is playing, you can click the Bookmark Station button to add it to the bookmarks list.
+You can click on the Name in the bookmarks list to edit it.
+Double click the Station to tune to that particular station and stream.
+Click the Delete Bookmark button to delete it.
+
+## Station Info
+The station name, slogan, message, and optional alert message will display if the station as pre-programmed them.
+The current audio bit rate will be displayed here as well as on the status bar.
+The stations available streams and data services, with a description of each will display, as the station has pre-programmed them.
+This is a useful feature for noting which stations have [Total Traffic & Weather Network](https://www.ttwnetwork.com/) traffic and weather images.
+
+### Signal Strength
+The Modulation Error Ratio for the lower and upper sidebands are displayed as they are determined.
+Important: High MER values for both sidebands indicates a strong signal.
+The current, average, minimum and maximum Bit Error Rates will also be displayed as they are determined.
+High BER values will cause the audio to glitch or drop out.
+The current BER is also shown on the status bar and may be used as a tuning tool.
+
+## Maps
+When listening to radio stations operated by [iHeartMedia](http://iheartmedia.com/iheartmedia/stations), you may view live traffic maps
+and weather radar. The images are typically sent every few minutes and will fill the tab area once received, processed, and loaded.
+Clicking the Map Viewer button on the toolbar will open a larger window to view the maps at full size.
+The weather radar information from the last 12 hours will be stored and can be played back by selecting the Animate Radar option.
+The delay between frames (in seconds) can be adjusted by changing the Animation Speed value.
+Other stations provide [Navteq/HERE](https://www.here.com) navigation information... it's on the TODO 'like to have' list.
+
+### Map Customization
+The default map used for the weather radar comes from [OpenStreetMap](https://www.openstreetmap.org).
+You can replace the map.png image with a map from any website that will let you export map tiles.
+The tiles used are (35,84) to (81,110) at zoom level 8. The image is 12032x6912 pixels.
+The portion of the map used for your area is cached in the map directory.
+If you change the map image, you will have to delete the BaseMap images in the map directory so they will be recreated with the new map.
+
+## Screenshots
+![album art tab](https://raw.githubusercontent.com/markjfine/nrsc5-dui/master/screenshots/Album_Art_Tab.png "Album Art Tab")
+![info tab](https://raw.githubusercontent.com/markjfine/nrsc5-dui/master/screenshots/Info_Tab.png "Info Tab")
+![settings tab](https://raw.githubusercontent.com/markjfine/nrsc5-dui/master/screenshots/Settings_Tab.png "Settings Tab")
+![bookmarks tab](https://raw.githubusercontent.com/markjfine/nrsc5-dui/master/screenshots/Bookmarks_Tab.png "Bookmarks Tab")
+![map tab](https://raw.githubusercontent.com/markjfine/nrsc5-dui/master/screenshots/Map_Tab.png "Map Tab")
+
+## Version History
+1.0.0 Initial Release
+1.0.1 Fixed compatibility with display scailing
+1.1.0 Added weather radar and traffic map viewer
+1.2.0 zefie update to modern nrsc5 build
+2.0.0 Updated to use the nrsc5 API
+2.1.0 Updated and enhanced operation and use
\ No newline at end of file
diff --git a/logo.png b/logo.png
new file mode 100644
index 0000000..6d41849
Binary files /dev/null and b/logo.png differ
diff --git a/lostdevice.png b/lostdevice.png
new file mode 100644
index 0000000..f18e0cb
Binary files /dev/null and b/lostdevice.png differ
diff --git a/mainForm.glade b/mainForm.glade
new file mode 100644
index 0000000..234eb0c
--- /dev/null
+++ b/mainForm.glade
@@ -0,0 +1,1693 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/map.png b/map.png
new file mode 100644
index 0000000..a472b0a
Binary files /dev/null and b/map.png differ
diff --git a/mapForm.glade b/mapForm.glade
new file mode 100644
index 0000000..cd0b037
--- /dev/null
+++ b/mapForm.glade
@@ -0,0 +1,248 @@
+
+
+
+
+
+ 0.10000000000000001
+ 2
+ 0.5
+ 0.050000000000000003
+ 0.25
+
+
+ False
+ Map Viewer
+ True
+
+
+ True
+ False
+ 2
+ 5
+
+
+ True
+ False
+ 0
+ in
+
+
+ True
+ False
+ 6
+ 6
+ 6
+ 6
+
+
+ True
+ True
+ automatic
+ automatic
+
+
+ True
+ False
+
+
+ True
+ False
+ gtk-missing-image
+
+
+
+
+
+
+
+
+
+
+ True
+ False
+ <b>Map Viewer</b>
+ True
+
+
+
+
+
+
+ True
+ False
+ 0
+ in
+
+
+ True
+ False
+ 6
+ 6
+ 6
+ 6
+
+
+ True
+ False
+ 7
+ 5
+
+
+ Weather Radar
+ True
+ True
+ False
+ Display Weather Radar
+ False
+ True
+ True
+
+
+
+ GTK_FILL
+ GTK_FILL
+ 10
+
+
+
+
+ Traffic Map
+ True
+ True
+ False
+ Display Traffic Map
+ False
+ True
+ radMapWeather
+
+
+
+ 1
+ 2
+ GTK_FILL
+ GTK_FILL
+ 10
+
+
+
+
+ Animate Radar
+ True
+ True
+ False
+ Play the animated radar
+ False
+ True
+
+
+
+ 3
+ 4
+ GTK_FILL
+ GTK_FILL
+ 10
+
+
+
+
+ Scale Radar
+ True
+ True
+ False
+ Scale radar to 600x600 px
+ False
+ True
+ True
+
+
+
+ 2
+ 3
+ GTK_FILL
+ GTK_FILL
+ 10
+
+
+
+
+ True
+ True
+ Time between frames (seconds)
+ ●
+ False
+ False
+ True
+ True
+ adjSpeed
+ 2
+
+
+
+ 6
+ 7
+ GTK_FILL
+ GTK_FILL
+
+
+
+
+ True
+ False
+ 0
+ 5
+ Animation Speed
+
+
+ 7
+ 6
+ GTK_FILL
+ GTK_FILL
+
+
+
+
+ True
+ False
+
+
+ 4
+ 5
+ GTK_FILL
+ GTK_FILL
+
+
+
+
+ True
+ False
+ 1
+ radar_key.png
+
+
+ 7
+ 8
+
+
+
+
+
+
+
+
+ True
+ False
+ <b>Settings</b>
+ True
+
+
+
+
+ 1
+ 2
+ GTK_FILL
+
+
+
+
+
+
diff --git a/nosynch.png b/nosynch.png
new file mode 100644
index 0000000..77bbd09
Binary files /dev/null and b/nosynch.png differ
diff --git a/nrsc5-dui.py b/nrsc5-dui.py
new file mode 100644
index 0000000..32339e3
--- /dev/null
+++ b/nrsc5-dui.py
@@ -0,0 +1,1654 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# NRSC5 DUI - A graphical interface for nrsc5
+# Copyright (C) 2017 Cody Nybo
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+# Updated by zefie for modern nrsc5 ~ 2019
+# Updated and enhanced by markjfine ~ 2021
+
+#import os, sys, shutil, re, gtk, gobject, json, datetime, numpy, glob, time, platform
+import os, sys, shutil, re, json, datetime, numpy, glob, time, platform
+from subprocess import Popen, PIPE
+from threading import Timer, Thread
+from dateutil import tz
+from PIL import Image, ImageFont, ImageDraw
+
+import gi
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, GObject, Gdk, GdkPixbuf, GLib
+
+# print debug messages to stdout (if debugger is attached)
+debugMessages = (sys.gettrace() != None)
+debugAutoStart = True
+
+if hasattr(sys, 'frozen'):
+ runtimeDir = os.path.dirname(sys.executable) # for py2exe
+else:
+ runtimeDir = sys.path[0]
+
+aasDir = os.path.join(runtimeDir, "aas") # aas (data from nrsc5) file directory
+mapDir = os.path.join(runtimeDir, "map") # map (data we process) file directory
+resDir = os.path.join(runtimeDir, "res") # resource (application dependencies) file directory
+cfgDir = os.path.join(runtimeDir, "cfg") # config file directory
+
+
+class NRSC5_DUI(object):
+ def __init__(self):
+ #gobject.threads_init()
+ Gdk.threads_init()
+ self.windowsOS = False # save our determination as a var in case we change how we determine.
+
+ self.getControls() # get controls and windows
+ self.initStreamInfo() # initilize stream info and clear status widgets
+
+ self.debugLog("Local path determined as " + runtimeDir)
+
+ if (platform.system() == 'Windows'):
+ # Windows release layout
+ self.windowsOS = True
+ self.binDir = os.path.join(runtimeDir, "bin") # windows binaries directory
+ self.nrsc5Path = os.path.join(self.binDir,'nrsc5.exe')
+ else:
+ # Linux/Mac/proper posix
+ # if nrsc5 and transcoder are not in the system path, set the full path here
+ self.nrsc5Path = "nrsc5"
+
+ self.debugLog("OS Determination: Windows = {}".format(self.windowsOS))
+
+ self.mapFile = os.path.join(resDir, "map.png")
+ self.defaultSize = [490,250] # default width,height of main app
+ self.nrsc5 = None # nrsc5 process
+ self.playerThread = None # player thread
+ self.playing = False # currently playing
+ self.statusTimer = None # status update timer
+ self.imageChanged = False # has the album art changed
+ self.xhdrChanged = False # has the HDDR data changed
+ self.nrsc5Args = [] # arguments for nrsc5
+ self.logFile = None # nrsc5 log file
+ self.lastImage = "" # last image file displayed
+ self.coverImage = ""
+ self.lastXHDR = ["", -1] # the last XHDR data received
+ self.stationStr = "" # current station frequency (string)
+ self.streamNum = 1 # current station stream number
+ self.update_btns = True
+ self.set_program_btns()
+ self.bookmarks = [] # station bookmarks
+ self.stationLogos = {} # station logos
+ self.bookmarked = False # is current station bookmarked
+ self.mapViewer = None # map viewer window
+ self.weatherMaps = [] # list of current weathermaps sorted by time
+ self.waittime = 10 # time in seconds to wait for file to exist
+ self.waitdivider = 4 # check this many times per second for file
+ self.pixbuf = None # store image buffer for rescaling on resize
+ self.mimeTypes = { # as defined by iHeartRadio anyway, defined here for possible future use
+ "4F328CA0":["image/png","png"],
+ "1E653E9C":["image/jpg","jpg"],
+ "BB492AAC":["text/plain","txt"]
+ }
+ self.mapData = {
+ "mapMode" : 1,
+ "mapTiles" : [[0,0,0],[0,0,0],[0,0,0]],
+ "mapComplete" : False,
+ "weatherTime" : 0,
+ "weatherPos" : [0,0,0,0],
+ "weatherNow" : "",
+ "weatherID" : "",
+ "viewerConfig" : {
+ "mode" : 1,
+ "animate" : False,
+ "scale" : True,
+ "windowPos" : (0,0),
+ "windowSize" : (764,632),
+ "animationSpeed" : 0.5
+ }
+ }
+
+ self.ServiceDataType = {
+ 0 : "Non_Specific",
+ 1 : "News",
+ 3 : "Sports",
+ 29 : "Weather",
+ 31 : "Emergency",
+ 65 : "Traffic",
+ 66 : "Image Maps",
+ 80 : "Text",
+ 256 : "Advertising",
+ 257 : "Financial",
+ 258 : "Stock Ticker",
+ 259 : "Navigation",
+ 260 : "Electronic Program Guide",
+ 261 : "Audio",
+ 262 : "Private Data Network",
+ 263 : "Service Maintenance",
+ 264 : "HD Radio System Services",
+ 265 : "Audio Related Data",
+ 511 : "Test_Str_E"
+ }
+
+ self.ProgramType = {
+ 0 : "Undefined",
+ 1 : "News",
+ 2 : "Information",
+ 3 : "Sports",
+ 4 : "Talk",
+ 5 : "Rock",
+ 6 : "Classic Rock",
+ 7 : "Adult Hits",
+ 8 : "Soft Rock",
+ 9 : "Top 40",
+ 10 : "Country",
+ 11 : "Oldies",
+ 12 : "Soft",
+ 13 : "Nostalgia",
+ 14 : "Jazz",
+ 15 : "Classical",
+ 16 : "Rhythm And Blues",
+ 17 : "Soft Rhythm And Blues",
+ 18 : "Foreign Language",
+ 19 : "Religious Music",
+ 20 : "Religious Talk",
+ 21 : "Personality",
+ 22 : "Public",
+ 23 : "College",
+ 24 : "Spanish Talk",
+ 25 : "Spanish Music",
+ 26 : "Hip Hop",
+ 29 : "Weather",
+ 30 : "Emergency Test",
+ 31 : "Emergency",
+ 65 : "Traffic",
+ 76 : "Special Reading Services"
+ }
+
+ # set events on info labels
+ self.btnAudioPrgs0.set_property("name","btn_prg0")
+ self.btnAudioPrgs0.set_events(Gdk.EventMask.BUTTON_PRESS_MASK)
+ self.btnAudioPrgs0.connect("button-press-event", self.on_program_select)
+ self.btnAudioPrgs1.set_property("name","btn_prg1")
+ self.btnAudioPrgs1.set_events(Gdk.EventMask.BUTTON_PRESS_MASK)
+ self.btnAudioPrgs1.connect("button-press-event", self.on_program_select)
+ self.btnAudioPrgs2.set_property("name","btn_prg2")
+ self.btnAudioPrgs2.set_events(Gdk.EventMask.BUTTON_PRESS_MASK)
+ self.btnAudioPrgs2.connect("button-press-event", self.on_program_select)
+ self.btnAudioPrgs3.set_property("name","btn_prg3")
+ self.btnAudioPrgs3.set_events(Gdk.EventMask.BUTTON_PRESS_MASK)
+ self.btnAudioPrgs3.connect("button-press-event", self.on_program_select)
+ self.lblAudioPrgs0.set_property("name","prg0")
+ self.lblAudioPrgs0.set_has_window(True)
+ self.lblAudioPrgs0.set_events(Gdk.EventMask.BUTTON_PRESS_MASK)
+ self.lblAudioPrgs0.connect("button-press-event", self.on_program_select)
+ self.lblAudioPrgs1.set_property("name","prg1")
+ self.lblAudioPrgs1.set_has_window(True)
+ self.lblAudioPrgs1.set_events(Gdk.EventMask.BUTTON_PRESS_MASK)
+ self.lblAudioPrgs1.connect("button-press-event", self.on_program_select)
+ self.lblAudioPrgs2.set_property("name","prg2")
+ self.lblAudioPrgs2.set_has_window(True)
+ self.lblAudioPrgs2.set_events(Gdk.EventMask.BUTTON_PRESS_MASK)
+ self.lblAudioPrgs2.connect("button-press-event", self.on_program_select)
+ self.lblAudioPrgs3.set_property("name","prg3")
+ self.lblAudioPrgs3.set_has_window(True)
+ self.lblAudioPrgs3.set_events(Gdk.EventMask.BUTTON_PRESS_MASK)
+ self.lblAudioPrgs3.connect("button-press-event", self.on_program_select)
+ self.lblAudioSvcs0.set_property("name","svc0")
+ self.lblAudioSvcs0.set_has_window(True)
+ self.lblAudioSvcs0.set_events(Gdk.EventMask.BUTTON_PRESS_MASK)
+ self.lblAudioSvcs0.connect("button-press-event", self.on_program_select)
+ self.lblAudioSvcs1.set_property("name","svc1")
+ self.lblAudioSvcs1.set_has_window(True)
+ self.lblAudioSvcs1.set_events(Gdk.EventMask.BUTTON_PRESS_MASK)
+ self.lblAudioSvcs1.connect("button-press-event", self.on_program_select)
+ self.lblAudioSvcs2.set_property("name","svc2")
+ self.lblAudioSvcs2.set_has_window(True)
+ self.lblAudioSvcs2.set_events(Gdk.EventMask.BUTTON_PRESS_MASK)
+ self.lblAudioSvcs2.connect("button-press-event", self.on_program_select)
+ self.lblAudioSvcs3.set_property("name","svc3")
+ self.lblAudioSvcs3.set_has_window(True)
+ self.lblAudioSvcs3.set_events(Gdk.EventMask.BUTTON_PRESS_MASK)
+ self.lblAudioSvcs3.connect("button-press-event", self.on_program_select)
+
+ # setup bookmarks listview
+ nameRenderer = Gtk.CellRendererText()
+ nameRenderer.set_property("editable", True)
+ nameRenderer.connect("edited", self.on_bookmarkNameEdited)
+
+ colStation = Gtk.TreeViewColumn("Station", Gtk.CellRendererText(), text=0)
+ colName = Gtk.TreeViewColumn("Name", nameRenderer, text=1)
+
+ colStation.set_resizable(True)
+ colStation.set_sort_column_id(2)
+ colName.set_resizable(True)
+ colName.set_sort_column_id(1)
+
+ self.lvBookmarks.append_column(colStation)
+ self.lvBookmarks.append_column(colName)
+
+ # regex for getting nrsc5 output
+ self.regex = [
+ #re.compile("^.*main\.c:[\d]+: Station name: (.*)$"), # 0 match station name
+ #re.compile("^.*main\.c:[\d]+: Station location: (-?[\d]+\.[\d]+) (-?[\d]+\.[\d]+), ([\d]+)m$"), # 1 match station location
+ #re.compile("^.*main\.c:[\d]+: Slogan: (.*)$"), # 2 match station slogan
+ #re.compile("^.*main\.c:[\d]+: Audio bit rate: (.*) kbps$"), # 3 match audio bit rate
+ #re.compile("^.*main\.c:[\d]+: Title: (.*)$"), # 4 match title
+ #re.compile("^.*main\.c:[\d]+: Artist: (.*)$"), # 5 match artist
+ #re.compile("^.*main\.c:[\d]+: Album: (.*)$"), # 6 match album
+ #re.compile("^.*main\.c:[\d]+: LOT file: port=([\d]+) lot=([\d]+) name=(.*\.(?:jpg|png|txt)) size=([\d]+) mime=([\w]+)$"), # 7 match file (album art, maps, weather info)
+ #re.compile("^.*main\.c:[\d]+: MER: (-?[\d]+\.[\d]+) dB \(lower\), (-?[\d]+\.[\d]+) dB \(upper\)$"), # 8 match MER
+ #re.compile("^.*main\.c:[\d]+: BER: (0\.[\d]+), avg: (0\.[\d]+), min: (0\.[\d]+), max: (0\.[\d]+)$"), # 9 match BER
+ #re.compile("^.*nrsc5\.c:[\d]+: Best gain: (.*) dB,.*$"), # 10 match gain
+ #re.compile("^.*main\.c:[\d]+: SIG Service: type=(.*) number=(.*) name=(.*)$"), # 11 match stream
+ #re.compile("^.*main\.c:[\d]+: .*Data component:.* port=([\d]+).* type=([\d]+) .*$"), # 12 match port
+ #re.compile("^.*main\.c:[\d]+: XHDR: .* ([0-9A-Fa-f]{8}) (.*)$"), # 13 match xhdr tag
+ #re.compile("^.*main\.c:[\d]+: Unique file identifier: PPC;07; ([\S]+).*$") # 14 match unique file id
+ re.compile("^[0-9\:]{8,8} Station name: (.*)$"), # 0 match station name
+ re.compile("^[0-9\:]{8,8} Station location: (-?[\d]+\.[\d]+) (-?[\d]+\.[\d]+), ([\d]+)m$"), # 1 match station location
+ re.compile("^[0-9\:]{8,8} Slogan: (.*)$"), # 2 match station slogan
+ re.compile("^[0-9\:]{8,8} Audio bit rate: (.*) kbps$"), # 3 match audio bit rate
+ re.compile("^[0-9\:]{8,8} Title: (.*)$"), # 4 match title
+ re.compile("^[0-9\:]{8,8} Artist: (.*)$"), # 5 match artist
+ re.compile("^[0-9\:]{8,8} Album: (.*)$"), # 6 match album
+ re.compile("^[0-9\:]{8,8} LOT file: port=([\d]+) lot=([\d]+) name=(.*\.(?:jpg|png|txt)) size=([\d]+) mime=([\w]+)$"), # 7 match file (album art, maps, weather info)
+ re.compile("^[0-9\:]{8,8} MER: (-?[\d]+\.[\d]+) dB \(lower\), (-?[\d]+\.[\d]+) dB \(upper\)$"), # 8 match MER
+ re.compile("^[0-9\:]{8,8} BER: (0\.[\d]+), avg: (0\.[\d]+), min: (0\.[\d]+), max: (0\.[\d]+)$"), # 9 match BER
+ re.compile("^[0-9\:]{8,8} Best gain: (.*) dB,.*$"), # 10 match gain
+ re.compile("^[0-9\:]{8,8} SIG Service: type=(.*) number=(.*) name=(.*)$"), # 11 match stream
+ re.compile("^[0-9\:]{8,8} .*Data component:.* id=([\d]+).* port=([\d]+).* service_data_type=([\d]+) .*$"), # 12 match port (and data_service_type)
+ re.compile("^[0-9\:]{8,8} XHDR: .* ([0-9A-Fa-f]{8}) (.*)$"), # 13 match xhdr tag
+ re.compile("^[0-9\:]{8,8} Unique file identifier: PPC;07; ([\S]+).*$"), # 14 match unique file id
+ re.compile("^[0-9\:]{8,8} Genre: (.*)$"), # 15 match genre
+ re.compile("^[0-9\:]{8,8} Message: (.*)$"), # 16 match message
+ re.compile("^[0-9\:]{8,8} Alert: (.*)$"), # 17 match alert
+ re.compile("^[0-9\:]{8,8} .*Audio component:.* id=([\d]+).* port=([\d]+).* type=([\d]+) .*$"), # 18 match port (and type)
+ re.compile("^[0-9\:]{8,8} Synchronized$"), # 19 synchronized
+ re.compile("^[0-9\:]{8,8} Lost synchronization$"), # 20 lost synch
+ re.compile("^[0-9\:]{8,8} Lost device$") # 21 lost device
+ ]
+
+ self.loadSettings()
+ self.proccessWeatherMaps()
+ #self..connect('check-resize',self.on_window_resized) # TODO: fix on resize infinite loop
+
+ def img_to_pixbuf(self,img):
+ """convert PIL.Image to GdkPixbuf.Pixbuf"""
+ data = GLib.Bytes.new(img.tobytes())
+ return GdkPixbuf.Pixbuf.new_from_bytes(data, GdkPixbuf.Colorspace.RGB, 'A' in img.getbands(),8, img.width, img.height, len(img.getbands())*img.width)
+
+ def on_cover_resize(self, container):
+ global mapDir
+ if self.coverImage != "":
+ img_size = min(self.alignmentCover.get_allocated_height(), self.alignmentCover.get_allocated_width()) - 12
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file(self.coverImage)
+ pixbuf = pixbuf.scale_simple(img_size, img_size, GdkPixbuf.InterpType.BILINEAR)
+ self.imgCover.set_from_pixbuf(pixbuf)
+
+ img_size = min(self.alignmentMap.get_allocated_height(), self.alignmentMap.get_allocated_width()) - 12
+ if (self.mapData["mapMode"] == 0):
+ map_file = os.path.join(mapDir, "TrafficMap.png")
+ if os.path.isfile(map_file):
+ map_img = Image.open(map_file).resize((img_size, img_size), Image.LANCZOS)
+ self.imgMap.set_from_pixbuf(self.img_to_pixbuf(map_img))
+ else:
+ self.imgMap.set_from_stock(Gtk.STOCK_MISSING_IMAGE, Gtk.IconSize.LARGE_TOOLBAR)
+ elif (self.mapData["mapMode"] == 1):
+ if os.path.isfile(self.mapData["weatherNow"]):
+ map_img = Image.open(self.mapData["weatherNow"]).resize((img_size, img_size), Image.LANCZOS)
+ self.imgMap.set_from_pixbuf(self.img_to_pixbuf(map_img))
+ else:
+ self.imgMap.set_from_stock(Gtk.STOCK_MISSING_IMAGE, Gtk.IconSize.LARGE_TOOLBAR)
+
+
+
+ def service_data_type_name(self, type):
+ for key, value in self.ServiceDataType.items():
+ if (key == type):
+ return value
+
+ def program_type_name(self, type):
+ for key, value in self.ProgramType.items():
+ if (key == type):
+ return value
+
+ def handle_window_resize(self):
+ if (self.pixbuf != None):
+ #allocation = self.imgCover.get_allocation()
+ #desired_width = int(allocation.width / 2.5)
+ #desired_height = desired_width
+ desired_size = min(self.alignmentCover.get_allocated_height(), self.alignmentCover.get_allocated_width()) - 12
+ #self.pixbuf = self.pixbuf.scale_simple(desired_width, desired_height, Gtk.gdk.INTERP_HYPER)
+ self.pixbuf = self.pixbuf.scale_simple(desired_size, desired_size, GdkPixbuf.InterpType.BILINEAR)
+ self.imgCover.set_from_pixbuf(self.pixbuf)
+
+ def on_window_resized(self,window):
+ self.handle_window_resize()
+
+
+ def on_btnPlay_clicked(self, btn):
+ global aasDir
+ # start playback
+ if (not self.playing):
+
+ self.nrsc5Args = [self.nrsc5Path]
+
+ # update all of the spin buttons to prevent the text from sticking
+ self.spinFreq.update()
+ #self.spinStream.update()
+ self.spinGain.update()
+ self.spinPPM.update()
+ self.spinRTL.update()
+
+ # enable aas output if temp dir was created
+ if (aasDir is not None):
+ self.nrsc5Args.append("--dump-aas-files")
+ self.nrsc5Args.append(aasDir)
+
+ # set gain if auto gain is not selected
+ if (not self.cbAutoGain.get_active()):
+ self.streamInfo["Gain"] = self.spinGain.get_value()
+ self.nrsc5Args.append("-g")
+ #self.nrsc5Args.append(str(int(self.streamInfo["Gain"]*10)))
+ self.nrsc5Args.append(str(int(self.streamInfo["Gain"])))
+
+ # set ppm error if not zero
+ if (self.spinPPM.get_value() != 0):
+ self.nrsc5Args.append("-p")
+ self.nrsc5Args.append(str(int(self.spinPPM.get_value())))
+
+ # set rtl device number if not zero
+ if (self.spinRTL.get_value() != 0):
+ self.nrsc5Args.append("-d")
+ self.nrsc5Args.append(str(int(self.spinRTL.get_value())))
+
+ # set frequency and stream
+ self.nrsc5Args.append(str(self.spinFreq.get_value()))
+ #self.nrsc5Args.append(str(int(self.spinStream.get_value()-1)))
+ self.nrsc5Args.append(str(int(self.streamNum-1)))
+
+ #print(self.nrsc5Args)
+
+ # start the timer
+ self.statusTimer = Timer(1, self.checkStatus)
+ self.statusTimer.start()
+
+ # disable the controls
+ self.spinFreq.set_sensitive(False)
+ #self.spinStream.set_sensitive(False)
+ self.spinGain.set_sensitive(False)
+ self.spinPPM.set_sensitive(False)
+ self.spinRTL.set_sensitive(False)
+ self.btnPlay.set_sensitive(False)
+ self.btnStop.set_sensitive(True)
+ self.cbAutoGain.set_sensitive(False)
+ self.playing = True
+ self.lastXHDR = ["", -1]
+
+ # start the player thread
+ self.playerThread = Thread(target=self.play)
+ self.playerThread.start()
+
+ self.stationStr = str(self.spinFreq.get_value())
+ #self.stationNum = int(self.spinStream.get_value())-1
+ self.stationNum = int(self.streamNum)-1
+
+ #if (self.stationLogos.has_key(self.stationStr)):
+ if (self.stationStr in self.stationLogos):
+ # show station logo if it's cached
+ logo = os.path.join(aasDir, self.stationLogos[self.stationStr][self.stationNum])
+ if (os.path.isfile(logo)):
+ self.streamInfo["Logo"] = self.stationLogos[self.stationStr][self.stationNum]
+ #self.pixbuf = Gtk.gdk.pixbuf_new_from_file(logo)
+ self.pixbuf = GdkPixbuf.Pixbuf.new_from_file(logo)
+ self.coverImage = logo
+ self.handle_window_resize()
+ else:
+ # add entry in database for the station if it doesn't exist
+ self.stationLogos[self.stationStr] = ["", "", "", ""]
+
+ # check if station is bookmarked
+ self.bookmarked = False
+ #freq = int((self.spinFreq.get_value()+0.005)*100) + int(self.spinStream.get_value())
+ freq = int((self.spinFreq.get_value()+0.005)*100) + int(self.streamNum)
+ for b in self.bookmarks:
+ if (b[2] == freq):
+ self.bookmarked = True
+ break
+
+ self.btnBookmark.set_sensitive(not self.bookmarked)
+ if (self.notebookMain.get_current_page() != 3):
+ self.btnDelete.set_sensitive(self.bookmarked)
+
+ def on_btnStop_clicked(self, btn):
+ # stop playback
+ if (self.playing):
+ self.playing = False
+
+ # shutdown nrsc5
+ if (self.nrsc5 is not None and not self.nrsc5.poll()):
+ self.nrsc5.terminate()
+
+ if (self.playerThread is not None):
+ self.playerThread.join(1)
+
+ # stop timer
+ self.statusTimer.cancel()
+ self.statusTimer = None
+
+ # enable controls
+ if (not self.cbAutoGain.get_active()):
+ self.spinGain.set_sensitive(True)
+ self.spinFreq.set_sensitive(True)
+ #self.spinStream.set_sensitive(True)
+ self.spinPPM.set_sensitive(True)
+ self.spinRTL.set_sensitive(True)
+ self.btnPlay.set_sensitive(True)
+ self.btnStop.set_sensitive(False)
+ self.btnBookmark.set_sensitive(False)
+ self.cbAutoGain.set_sensitive(True)
+
+ # clear stream info
+ self.initStreamInfo()
+
+ self.btnBookmark.set_sensitive(False)
+ if (self.notebookMain.get_current_page() != 3):
+ self.btnDelete.set_sensitive(False)
+
+ def on_btnBookmark_clicked(self, btn):
+ # pack frequency and channel number into one int
+ #freq = int((self.spinFreq.get_value()+0.005)*100) + int(self.spinStream.get_value())
+ freq = int((self.spinFreq.get_value()+0.005)*100) + int(self.streamNum)
+
+ # create bookmark
+ bookmark = [
+ #"{:4.1f}-{:1.0f}".format(self.spinFreq.get_value(), self.spinStream.get_value()),
+ "{:4.1f}-{:1.0f}".format(self.spinFreq.get_value(), self.streamNum),
+ self.streamInfo["Callsign"],
+ freq
+ ]
+ self.bookmarked = True # mark as bookmarked
+ self.bookmarks.append(bookmark) # store bookmark in array
+ self.lsBookmarks.append(bookmark) # add bookmark to listview
+ self.btnBookmark.set_sensitive(False) # disable bookmark button
+
+ if (self.notebookMain.get_current_page() != 3):
+ self.btnDelete.set_sensitive(True) # enable delete button
+
+ def on_btnDelete_clicked(self, btn):
+ # select current station if not on bookmarks page
+ if (self.notebookMain.get_current_page() != 3):
+ station = int((self.spinFreq.get_value()+0.005)*100) + int(self.streamNum)
+ for i in range(0, len(self.lsBookmarks)):
+ if (self.lsBookmarks[i][2] == station):
+ self.lvBookmarks.set_cursor(i)
+ break
+
+ # get station of selected row
+ (model, iter) = self.lvBookmarks.get_selection().get_selected()
+ station = model.get_value(iter, 2)
+
+ # remove row
+ model.remove(iter)
+
+ # remove bookmark
+ for i in range(0, len(self.bookmarks)):
+ if (self.bookmarks[i][2] == station):
+ self.bookmarks.pop(i)
+ break
+
+ if (self.notebookMain.get_current_page() != 3 and self.playing):
+ self.btnBookmark.set_sensitive(True)
+ self.bookmarked = False
+
+ def on_btnAbout_activate(self, btn):
+ # sets up and displays about dialog
+ if self.about_dialog:
+ self.about_dialog.present()
+ return
+
+ authors = [
+ "Cody Nybo "
+ ]
+
+ license = """
+ NRSC5 DUI - A graphical interface for nrsc5
+ Copyright (C) 2017-2018 Cody Nybo
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see ."""
+
+ about_dialog = Gtk.AboutDialog()
+ about_dialog.set_transient_for(self.mainWindow)
+ about_dialog.set_destroy_with_parent(True)
+ about_dialog.set_name("NRSC5 DUI")
+ about_dialog.set_version("2.1.0")
+ about_dialog.set_copyright("Copyright \xc2\xa9 2017-2018 Cody Nybo, 2019 zefie, 2021 markjfine")
+ about_dialog.set_website("https://github.com/markjfine/nrsc5-dui")
+ about_dialog.set_comments("A graphical interface for nrsc5.")
+ about_dialog.set_authors(authors)
+ about_dialog.set_license(license)
+ #about_dialog.set_logo(Gtk.gdk.pixbuf_new_from_file("logo.png"))
+ about_dialog.set_logo(GdkPixbuf.Pixbuf.new_from_file("logo.png"))
+
+ # callbacks for destroying the dialog
+ def close(dialog, response, editor):
+ editor.about_dialog = None
+ dialog.destroy()
+
+ def delete_event(dialog, event, editor):
+ editor.about_dialog = None
+ return True
+
+ about_dialog.connect("response", close, self)
+ about_dialog.connect("delete-event", delete_event, self)
+
+ self.about_dialog = about_dialog
+ about_dialog.show()
+
+ def set_program_btns(self):
+ self.btnAudioPrgs0.set_active(self.update_btns and self.streamNum == 1)
+ self.btnAudioPrgs1.set_active(self.update_btns and self.streamNum == 2)
+ self.btnAudioPrgs2.set_active(self.update_btns and self.streamNum == 3)
+ self.btnAudioPrgs3.set_active(self.update_btns and self.streamNum == 4)
+ self.update_btns = True
+
+ def on_program_select(self, _label, evt):
+ stream_num = int(_label.get_property("name")[-1])
+ self.update_btns = not (_label.get_property("name")[0] == "b")
+ self.streamNum = stream_num + 1
+ self.set_program_btns()
+ #self.on_stream_changed()
+ #TODO: fix so stream change is smoother - should be able to pipe new stream number to running application.
+ if (self.playing): self.on_btnStop_clicked(None)
+ self.on_btnPlay_clicked(None)
+
+ def on_cbAutoGain_toggled(self, btn):
+ self.spinGain.set_sensitive(not btn.get_active())
+ self.lblGain.set_visible(btn.get_active())
+
+ def on_listviewBookmarks_row_activated(self, treeview, path, view_column):
+ if (len(path) != 0):
+ # get station from bookmark row
+ tree_iter = treeview.get_model().get_iter(path[0])
+ station = treeview.get_model().get_value(tree_iter, 2)
+
+ # set frequency and stream
+ self.spinFreq.set_value(float(int(station/10)/10.0))
+ #self.spinStream.set_value(station%10)
+ self.streamNum = (station%10)
+ self.set_program_btns()
+
+ # stop playback if playing
+ if (self.playing): self.on_btnStop_clicked(None)
+
+ # play bookmarked station
+ self.on_btnPlay_clicked(None)
+
+ def on_lvBookmarks_selection_changed(self, tree_selection):
+ # enable delete button if bookmark is selected
+ (model, pathlist) = self.lvBookmarks.get_selection().get_selected_rows()
+ self.btnDelete.set_sensitive(len(pathlist) != 0)
+
+ def on_bookmarkNameEdited(self, cell, path, text, data=None):
+ # update name in listview
+ iter = self.lsBookmarks.get_iter(path)
+ self.lsBookmarks.set(iter, 1, text)
+
+ # update name in bookmarks array
+ for b in self.bookmarks:
+ if (b[2] == self.lsBookmarks[path][2]):
+ b[1] = text
+ break
+
+ def on_notebookMain_switch_page(self, notebook, page, page_num):
+ # disable delete button if not on bookmarks page and station is not bookmarked
+ if (page_num != 3 and (not self.bookmarked or not self.playing)):
+ self.btnDelete.set_sensitive(False)
+ # enable delete button if not on bookmarks page and station is bookmarked
+ elif (page_num != 3 and self.bookmarked):
+ self.btnDelete.set_sensitive(True)
+ # enable delete button if on bookmarks page and a bookmark is selected
+ else:
+ (model, iter) = self.lvBookmarks.get_selection().get_selected()
+ self.btnDelete.set_sensitive(iter is not None)
+
+ def on_radMap_toggled(self, btn):
+ global mapDir
+ if (btn.get_active()):
+ if (btn == self.radMapTraffic):
+ self.mapData["mapMode"] = 0
+ mapFile = os.path.join(mapDir, "TrafficMap.png")
+ if (os.path.isfile(mapFile)): # check if map exists
+ mapImg = Image.open(mapFile).resize((200,200), Image.LANCZOS) # scale map to fit window
+ self.imgMap.set_from_pixbuf(imgToPixbuf(mapImg)) # convert image to pixbuf and display
+ else:
+ #self.imgMap.set_from_stock(Gtk.STOCK_MISSING_IMAGE, Gtk.ICON_SIZE_LARGE_TOOLBAR) # display missing image if file is not found
+ self.imgMap.set_from_stock(Gtk.STOCK_MISSING_IMAGE, Gtk.IconSize.LARGE_TOOLBAR) # display missing image if file is not found
+
+ elif (btn == self.radMapWeather):
+ self.mapData["mapMode"] = 1
+ if (os.path.isfile(self.mapData["weatherNow"])):
+ mapImg = Image.open(self.mapData["weatherNow"]).resize((200,200), Image.LANCZOS) # scale map to fit window
+ self.imgMap.set_from_pixbuf(imgToPixbuf(mapImg)) # convert image to pixbuf and display
+ else:
+ #self.imgMap.set_from_stock(Gtk.STOCK_MISSING_IMAGE, Gtk.ICON_SIZE_LARGE_TOOLBAR) # display missing image if file is not found
+ self.imgMap.set_from_stock(Gtk.STOCK_MISSING_IMAGE, Gtk.IconSize.LARGE_TOOLBAR) # display missing image if file is not found
+
+ def on_btnMap_clicked(self, btn):
+ # open map viewer window
+ if (self.mapViewer is None):
+ self.mapViewer = NRSC5_Map(self, self.mapViewerCallback, self.mapData)
+ self.mapViewer.mapWindow.show()
+
+ def mapViewerCallback(self):
+ # delete the map viewer
+ self.mapViewer = None
+
+ def play(self):
+ FNULL = open(os.devnull, 'w')
+ FTMP = open('tmp.log','w')
+
+ # run nrsc5 and output stdout & stderr to pipes
+ self.nrsc5 = Popen(self.nrsc5Args, stderr=PIPE, stdout=PIPE, universal_newlines=True)
+
+ while True:
+ # read output from nrsc5
+ output = self.nrsc5.stderr.readline()
+ # parse the output
+ self.parseFeedback(output)
+
+ # write output to log file if enabled
+ if (self.cbLog.get_active() and self.logFile is not None):
+ self.logFile.write(output)
+ self.logFile.flush()
+
+ # check if nrsc5 has exited
+ if (self.nrsc5.poll() and not self.playing):
+ # cleanup if shutdown
+ self.debugLog("Process Terminated")
+ self.nrsc5 = None
+ break
+ elif (self.nrsc5.poll() and self.playing):
+ # restart nrsc5 if it crashes
+ self.debugLog("Restarting NRSC5")
+ time.sleep(1)
+ self.nrsc5 = Popen(self.nrsc5Args, stderr=PIPE, stdout=PIPE, universal_newlines=True)
+
+ def set_synchronization(self, state):
+ self.imgNoSynch.set_visible(state == 0)
+ self.imgSynch.set_visible(state == 1)
+ self.imgLostDevice.set_visible(state == -1)
+
+ def checkStatus(self):
+ # update status information
+ def update():
+ Gdk.threads_enter()
+ try:
+ imagePath = ""
+ image = ""
+ ber = [self.streamInfo["BER"][0]*100,self.streamInfo["BER"][1]*100,self.streamInfo["BER"][2]*100,self.streamInfo["BER"][3]*100]
+ self.txtTitle.set_text(self.streamInfo["Title"])
+ self.txtArtist.set_text(self.streamInfo["Artist"])
+ self.txtAlbum.set_text(self.streamInfo["Album"])
+ self.txtGenre.set_text(self.streamInfo["Genre"])
+ self.lblBitRate.set_label("{:3.1f} kbps".format(self.streamInfo["Bitrate"]))
+ self.lblBitRate2.set_label("{:3.1f} kbps".format(self.streamInfo["Bitrate"]))
+ #self.lblError.set_label("{:2.2f}% BER ".format(self.streamInfo["BER"][1]*100))
+ self.lblError.set_label("{:2.2f}% BER ".format(self.streamInfo["BER"][0]*100))
+ self.lblCall.set_label(" " + self.streamInfo["Callsign"])
+ self.lblName.set_label(self.streamInfo["Callsign"])
+ self.btnAudioLbl0.set_label(self.streamInfo["Streams"][0])
+ self.btnAudioLbl1.set_label(self.streamInfo["Streams"][1])
+ self.btnAudioLbl2.set_label(self.streamInfo["Streams"][2])
+ self.btnAudioLbl3.set_label(self.streamInfo["Streams"][3])
+ self.lblSlogan.set_label(self.streamInfo["Slogan"])
+ self.lblSlogan.set_tooltip_text(self.streamInfo["Slogan"])
+ self.lblMessage.set_label(self.streamInfo["Message"])
+ self.lblMessage.set_tooltip_text(self.streamInfo["Message"])
+ self.lblAlert.set_label(self.streamInfo["Alert"])
+ self.lblAlert.set_tooltip_text(self.streamInfo["Alert"])
+ self.lblAudioPrgs0.set_label(self.streamInfo["Streams"][0])
+ self.lblAudioPrgs1.set_label(self.streamInfo["Streams"][1])
+ self.lblAudioPrgs2.set_label(self.streamInfo["Streams"][2])
+ self.lblAudioPrgs3.set_label(self.streamInfo["Streams"][3])
+ self.lblAudioSvcs0.set_label(self.streamInfo["Programs"][0])
+ self.lblAudioSvcs1.set_label(self.streamInfo["Programs"][1])
+ self.lblAudioSvcs2.set_label(self.streamInfo["Programs"][2])
+ self.lblAudioSvcs3.set_label(self.streamInfo["Programs"][3])
+ self.lblDataSvcs0.set_label(self.streamInfo["Services"][0])
+ self.lblDataSvcs1.set_label(self.streamInfo["Services"][1])
+ self.lblDataSvcs2.set_label(self.streamInfo["Services"][2])
+ self.lblDataSvcs3.set_label(self.streamInfo["Services"][3])
+ self.lblDataType0.set_label(self.streamInfo["SvcTypes"][0])
+ self.lblDataType1.set_label(self.streamInfo["SvcTypes"][1])
+ self.lblDataType2.set_label(self.streamInfo["SvcTypes"][2])
+ self.lblDataType3.set_label(self.streamInfo["SvcTypes"][3])
+ self.lblMerLower.set_label("{:1.2f} dB".format(self.streamInfo["MER"][0]))
+ self.lblMerUpper.set_label("{:1.2f} dB".format(self.streamInfo["MER"][1]))
+ self.lblBerNow.set_label("{:1.3f}% (Now)".format(ber[0]))
+ self.lblBerAvg.set_label("{:1.3f}% (Avg)".format(ber[1]))
+ self.lblBerMin.set_label("{:1.3f}% (Min)".format(ber[2]))
+ self.lblBerMax.set_label("{:1.3f}% (Max)".format(ber[3]))
+
+ if (self.cbAutoGain.get_active()):
+ self.spinGain.set_value(self.streamInfo["Gain"])
+ self.lblGain.set_label("{:2.1f}dB".format(self.streamInfo["Gain"]))
+
+ # second param is lot id, if -1, show cover, otherwise show cover
+ # technically we should show the file with the matching lot id
+
+ if (int(self.lastXHDR[1]) > 0 and self.streamInfo["Cover"] != None):
+ imagePath = os.path.join(aasDir, self.streamInfo["Cover"])
+ image = self.streamInfo["Cover"]
+ elif (int(self.lastXHDR[1]) < 0 or self.streamInfo["Cover"] == None):
+ imagePath = os.path.join(aasDir, self.streamInfo["Logo"])
+ image = self.streamInfo["Logo"]
+ if (not os.path.isfile(imagePath)):
+ self.imgCover.clear()
+ self.coverImage = ""
+
+ # resize and display image if it changed and exists
+ if (self.xhdrChanged and self.lastImage != image and os.path.isfile(imagePath)):
+ self.xhdrChanged = False
+ self.lastImage = image
+ #self.pixbuf = Gtk.gdk.pixbuf_new_from_file(imagePath)
+ self.pixbuf = GdkPixbuf.Pixbuf.new_from_file(imagePath)
+ self.coverImage = imagePath
+ self.handle_window_resize()
+ self.debugLog("Image Changed")
+ finally:
+ Gdk.threads_leave()
+
+ if (self.playing):
+ #gobject.idle_add(update)
+ GLib.idle_add(update)
+ self.statusTimer = Timer(1, self.checkStatus)
+ self.statusTimer.start()
+
+ def processTrafficMap(self, fileName):
+ global aasDir, mapDir
+ r = re.compile("^[\d]+_TMT_.*_([1-3])_([1-3])_([\d]{4})([\d]{2})([\d]{2})_([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})_([0-9A-Fa-f]{4})\..*$") # match file name
+ m = r.match(fileName)
+
+ if (m):
+ x = int(m.group(1))-1 # X position
+ y = int(m.group(2))-1 # Y position
+
+ # get time from map tile and convert to local time
+ dt = datetime.datetime(int(m.group(3)), int(m.group(4)), int(m.group(5)), int(m.group(6)), int(m.group(7)), tzinfo=tz.tzutc())
+ t = dt.astimezone(tz.tzlocal()) # local time
+ ts = dtToTs(dt) # unix timestamp (utc)
+
+ # check if the tile has already been loaded
+ if (self.mapData["mapTiles"][x][y] == ts):
+ try:
+ os.remove(os.path.join(aasDir, fileName)) # delete this tile, it's not needed
+ except:
+ pass
+ return # no need to recreate the map if it hasn't changed
+
+ self.debugLog("Got Traffic Map Tile: {:g},{:g}".format(x,y))
+
+ self.mapData["mapComplete"] = False # new tiles are coming in, the map is nolonger complete
+ self.mapData["mapTiles"][x][y] = ts # store time for current tile
+
+ try:
+ currentPath = os.path.join(aasDir,fileName)
+ newPath = os.path.join(mapDir, "TrafficMap_{:g}_{:g}.png".format(x,y)) # create path to new tile location
+ if(os.path.exists(newPath)): os.remove(newPath) # delete old image if it exists (only necessary on windows)
+ shutil.move(currentPath, newPath) # move and rename map tile
+ except:
+ self.debugLog("Error moving map tile (src: "+currentPath+", dest: "+newPath+")", True)
+ self.mapData["mapTiles"][x][y] = 0
+
+ # check if all of the tiles are loaded
+ if (self.checkTiles(ts)):
+ self.debugLog("Got complete traffic map")
+ self.mapData["mapComplete"] = True # map is complete
+
+ # stitch the map tiles into one image
+ imgMap = Image.new("RGB", (600, 600), "white") # create blank image for traffic map
+ for i in range(0,3):
+ for j in range(0,3):
+ tileFile = os.path.join(mapDir, "TrafficMap_{:g}_{:g}.png".format(i,j)) # get path to tile
+ imgMap.paste(Image.open(tileFile), (j*200, i*200)) # paste tile into map
+ os.remove(tileFile) # delete tile image
+
+ imgMap.save(os.path.join(mapDir, "TrafficMap.png")) # save traffic map
+
+ # display on map page
+ if (self.radMapTraffic.get_active()):
+ img_size = min(self.alignmentMap.get_allocated_height(), self.alignmentMap.get_allocated_width()) - 12
+ #imgMap = imgMap.resize((200,200), Image.LANCZOS) # scale map to fit window
+ imgMap = imgMap.resize((img_size, img_size), Image.LANCZOS)
+ self.imgMap.set_from_pixbuf(imgToPixbuf(imgMap)) # convert image to pixbuf and display
+
+ if (self.mapViewer is not None): self.mapViewer.updated(0) # notify map viwerer if it's open
+
+ def processWeatherOverlay(self, fileName):
+ global mapDir
+ r = re.compile("^[\d]+_DWRO_(.*)_.*_([\d]{4})([\d]{2})([\d]{2})_([\d]{2})([\d]{2})_([0-9A-Fa-f]+)\..*$") # match file name
+ m = r.match(fileName)
+
+ if (m):
+ # get time from map tile and convert to local time
+ dt = datetime.datetime(int(m.group(2)), int(m.group(3)), int(m.group(4)), int(m.group(5)), int(m.group(6)), tzinfo=tz.tzutc())
+ t = dt.astimezone(tz.tzlocal()) # local time
+ ts = dtToTs(dt) # unix timestamp (utc)
+ id = self.mapData["weatherID"]
+
+ if (m.group(1) != id):
+ if (id == ""):
+ self.debugLog("Received weather overlay before metadata, ignoring...");
+ else:
+ self.debugLog("Received weather overlay with the wrong ID: " + m.group(1) + " (wanted " + id +")")
+ return
+
+ if (self.mapData["weatherTime"] == ts):
+ try:
+ os.remove(os.path.join(aasDir, fileName)) # delete this tile, it's not needed
+ except:
+ pass
+ return # no need to recreate the map if it hasn't changed
+
+ self.debugLog("Got Weather Overlay")
+
+ self.mapData["weatherTime"] = ts # store time for current overlay
+ wxOlPath = os.path.join(mapDir,"WeatherOverlay_{:s}_{:}.png".format(id, ts))
+ wxMapPath = os.path.join(mapDir,"WeatherMap_{:s}_{:}.png".format(id, ts))
+
+ # move new overlay to map directory
+ try:
+ if(os.path.exists(wxOlPath)): os.remove(wxOlPath) # delete old image if it exists (only necessary on windows)
+ shutil.move(os.path.join(aasDir, fileName), wxOlPath) # move and rename map tile
+ except:
+ self.debugLog("Error moving weather overlay", True)
+ self.mapData["weatherTime"] = 0
+
+ # create weather map
+ try:
+ mapPath = os.path.join(mapDir, "BaseMap_" + id + ".png") # get path to base map
+ if (os.path.isfile(mapPath) == False): # make sure base map exists
+ self.makeBaseMap(self.mapData["weatherID"], self.mapData["weatherPos"]) # create base map if it doesn't exist
+
+ imgMap = Image.open(mapPath).convert("RGBA") # open map image
+ posTS = (imgMap.size[0]-235, imgMap.size[1]-29) # calculate position to put timestamp (bottom right)
+ imgTS = self.mkTimestamp(t, imgMap.size, posTS) # create timestamp
+ imgRadar = Image.open(wxOlPath).convert("RGBA") # open radar overlay
+ imgRadar = imgRadar.resize(imgMap.size, Image.LANCZOS) # resize radar overlay to fit the map
+ imgMap = Image.alpha_composite(imgMap, imgRadar) # overlay radar image on map
+ imgMap = Image.alpha_composite(imgMap, imgTS) # overlay timestamp
+ imgMap.save(wxMapPath) # save weather map
+ os.remove(wxOlPath) # remove overlay image
+ self.mapData["weatherNow"] = wxMapPath
+
+ # display on map page
+ if (self.radMapWeather.get_active()):
+ img_size = min(self.alignmentMap.get_allocated_height(), self.alignmentMap.get_allocated_width()) - 12
+ #imgMap = imgMap.resize((200,200), Image.LANCZOS) # scale map to fit window
+ imgMap = imgMap.resize((img_size, img_size), Image.LANCZOS) # scale map to fit window
+ self.imgMap.set_from_pixbuf(imgToPixbuf(imgMap)) # convert image to pixbuf and display
+
+ self.proccessWeatherMaps() # get rid of old maps and add new ones to the list
+ if (self.mapViewer is not None): self.mapViewer.updated(1) # notify map viwerer if it's open
+
+ except:
+ self.debugLog("Error creating weather map", True)
+ self.mapData["weatherTime"] = 0
+
+ def proccessWeatherInfo(self, fileName):
+ global aasDir
+ weatherID = None
+ weatherPos = None
+
+ try:
+ with open(os.path.join(aasDir, fileName)) as weatherInfo: # open weather info file
+ for line in weatherInfo: # read line by line
+ if ("DWR_Area_ID=" in line): # look for line with "DWR_Area_ID=" in it
+ # get ID from line
+ r = re.compile("^DWR_Area_ID=\"(.+)\"$")
+ m = r.match(line)
+ weatherID = m.group(1)
+
+ elif ("Coordinates=" in line): # look for line with "Coordinates=" in it
+ # get coordinates from line
+ r = re.compile("^Coordinates=.*\((-?[\d]+\.[\d]+),(-?[\d]+\.[\d]+)\).*\((-?[\d]+\.[\d]+),(-?[\d]+\.[\d]+)\).*$")
+ m = r.match(line)
+ weatherPos = [float(m.group(1)),float(m.group(2)), float(m.group(3)), float(m.group(4))]
+ except:
+ self.debugLog("Error opening weather info", True)
+
+ if (weatherID is not None and weatherPos is not None): # check if ID and position were found
+ if (self.mapData["weatherID"] != weatherID or self.mapData["weatherPos"] != weatherPos): # check if ID or position has changed
+ self.debugLog("Got position: ({:n}, {:n}) ({:n}, {:n})".format(*weatherPos))
+ self.mapData["weatherID"] = weatherID # set weather ID
+ self.mapData["weatherPos"] = weatherPos # set weather map position
+
+ self.makeBaseMap(weatherID, weatherPos)
+ self.weatherMaps = []
+ self.proccessWeatherMaps()
+
+ def proccessWeatherMaps(self):
+ global mapDir
+ numberOfMaps = 0
+ r = re.compile("^map.WeatherMap_([a-zA-Z0-9]+)_([0-9]+).png")
+ now = dtToTs(datetime.datetime.now(tz.tzutc())) # get current time
+ files = glob.glob(os.path.join(mapDir, "WeatherMap_") + "*.png") # look for weather map files
+ files.sort() # sort files
+ for f in files:
+ m = r.match(f) # match regex
+ if (m):
+ id = m.group(1) # location ID
+ ts = int(m.group(2)) # timestamp (UTC)
+
+ # remove weather maps older than 12 hours
+ if (now - ts > 60*60*12):
+ try:
+ if (f in self.weatherMaps): self.weatherMaps.pop(self.weatherMaps.index(f)) # remove from list
+ os.remove(f) # remove file
+ self.debugLog("Deleted old weather map: " + f)
+ except:
+ self.debugLog("Error Failed to Delete: " + f)
+
+ # skip if not the correct location
+ elif (id == self.mapData["weatherID"]):
+ if (f not in self.weatherMaps): self.weatherMaps.append(f) # add to list
+ numberOfMaps += 1
+
+
+ self.debugLog("Found {} weather maps".format(numberOfMaps))
+
+ def getMapArea(self, lat1, lon1, lat2, lon2):
+ from math import asinh, tan, radians
+
+ # get pixel coordinates from latitude and longitude
+ # calculations taken from https://github.com/KYDronePilot/hdfm
+ top = asinh(tan(radians(52.482780)))
+ lat1 = top - asinh(tan(radians(lat1)))
+ lat2 = top - asinh(tan(radians(lat2)))
+ x1 = (lon1 + 130.781250) * 7162 / 39.34135
+ x2 = (lon2 + 130.781250) * 7162 / 39.34135
+ y1 = lat1 * 3565 / (top - asinh(tan(radians(38.898))))
+ y2 = lat2 * 3565 / (top - asinh(tan(radians(38.898))))
+
+ return (int(round(x1)), int(round(y1)), int(round(x2)), int(round(y2)))
+
+ def makeBaseMap(self, id, pos):
+ global mapDir
+ mapPath = os.path.join(mapDir, "BaseMap_" + id + ".png") # get map path
+ if (os.path.isfile(self.mapFile)):
+ if (os.path.isfile(mapPath) == False): # check if the map has already been created for this location
+ self.debugLog("Creating new map: " + mapPath)
+ px = self.getMapArea(*pos) # convert map locations to pixel coordinates
+ mapImg = Image.open(self.mapFile).crop(px) # open the full map and crop it to the coordinates
+ mapImg.save(mapPath) # save the cropped map to disk for later use
+ self.debugLog("Finished creating map")
+ else:
+ self.debugLog("Error map file not found: " + self.mapFile, True)
+ mapImg = Image.new("RGBA", (pos[2]-pos[1], pos[3]-pos[1]), "white") # if the full map is not available, use a blank image
+ mapImg.save(mapPath)
+
+ def checkTiles(self, t):
+ # check if all the tiles have been received
+ for i in range(0,3):
+ for j in range(0,3):
+ if (self.mapData["mapTiles"][i][j] != t):
+ return False
+ return True
+
+ def mkTimestamp(self, t, size, pos):
+ # create a timestamp image to overlay on the weathermap
+ x,y = pos
+ text = "{:04g}-{:02g}-{:02g} {:02g}:{:02g}".format(t.year, t.month, t.day, t.hour, t.minute) # format timestamp
+ imgTS = Image.new("RGBA", size, (0,0,0,0)) # create a blank image
+ draw = ImageDraw.Draw(imgTS) # the drawing object
+ font = ImageFont.truetype(os.path.join(resDir,"DejaVuSansMono.ttf"), 24) # DejaVu Sans Mono 24pt font
+ draw.rectangle((x,y, x+231,y+25), outline="black", fill=(128,128,128,96)) # draw a box around the text
+ draw.text((x+3,y), text, fill="black", font=font) # draw the text
+ return imgTS # return the image
+
+ def parseFeedback(self, line):
+ global aasDir, mapDir
+ line = line.strip()
+ #print(line)
+ if (self.regex[4].match(line)):
+ # match title
+ m = self.regex[4].match(line)
+ self.streamInfo["Title"] = m.group(1)
+ elif (self.regex[5].match(line)):
+ # match artist
+ m = self.regex[5].match(line)
+ self.streamInfo["Artist"] = m.group(1)
+ elif (self.regex[6].match(line)):
+ # match album
+ m = self.regex[6].match(line)
+ self.streamInfo["Album"] = m.group(1)
+ elif (self.regex[15].match(line)):
+ # match genre
+ m = self.regex[15].match(line)
+ self.streamInfo["Genre"] = m.group(1)
+ elif (self.regex[3].match(line)):
+ # match audio bit rate
+ m = self.regex[3].match(line)
+ self.streamInfo["Bitrate"] = float(m.group(1))
+ elif (self.regex[8].match(line)):
+ # match MER
+ m = self.regex[8].match(line)
+ self.streamInfo["MER"] = [float(m.group(1)), float(m.group(2))]
+ elif (self.regex[9].match(line)):
+ # match BER
+ m = self.regex[9].match(line)
+ self.streamInfo["BER"] = [float(m.group(1)), float(m.group(2)), float(m.group(3)), float(m.group(4))]
+ elif (self.regex[13].match(line)):
+ # match xhdr
+ m = self.regex[13].match(line)
+ xhdr = [m.group(1),m.group(2)]
+ if (xhdr != self.lastXHDR):
+ self.lastXHDR = xhdr
+ self.xhdrChanged = True
+ self.debugLog("XHDR Changed: {:s} (lot {:s})".format(xhdr[0],xhdr[1]))
+ elif (self.regex[7].match(line)):
+ # match album art
+ m = self.regex[7].match(line)
+ if (m):
+ fileName = "{}_{}".format(m.group(2),m.group(3))
+ fileSize = int(m.group(4))
+ headerOffset = int(len(m.group(2))) + 1
+
+ p = int(m.group(1),16)
+
+ # check file existance and size .. right now we just debug log
+ if (not os.path.isfile(os.path.join(aasDir,fileName))):
+ self.debugLog("Missing file: " + fileName)
+ else:
+ actualFileSize = os.path.getsize(os.path.join(aasDir,fileName))
+ if (fileSize != actualFileSize):
+ self.debugLog("Corrupt file: " + fileName + " (expected: "+fileSize+" bytes, got "+actualFileSize+" bytes)")
+
+ #tmp = self.streams[int(self.spinStream.get_value()-1)][0]
+ tmp = self.streams[int(self.streamNum-1)][0]
+
+ #if (p == self.streams[int(self.spinStream.get_value()-1)][0]):
+ if (p == self.streams[int(self.streamNum-1)][0]):
+ self.streamInfo["Cover"] = fileName
+ self.debugLog("Got Album Cover: " + fileName)
+ #elif (p == self.streams[int(self.spinStream.get_value()-1)][1]):
+ elif (p == self.streams[int(self.streamNum-1)][1]):
+ self.streamInfo["Logo"] = fileName
+ self.stationLogos[self.stationStr][self.stationNum] = fileName # add station logo to database
+ self.debugLog("Got Station Logo: " + fileName)
+
+ elif(fileName[headerOffset:(5+headerOffset)] == "DWRO_" and mapDir is not None):
+ self.processWeatherOverlay(fileName)
+ elif(fileName[headerOffset:(4+headerOffset)] == "TMT_" and mapDir is not None):
+ self.processTrafficMap(fileName) # proccess traffic map tile
+ elif(fileName[headerOffset:(5+headerOffset)] == "DWRI_" and mapDir is not None):
+ self.proccessWeatherInfo(fileName)
+
+ elif (self.regex[0].match(line)):
+ # match station name
+ m = self.regex[0].match(line)
+ self.streamInfo["Callsign"] = m.group(1)
+ elif (self.regex[2].match(line)):
+ # match station slogan
+ m = self.regex[2].match(line)
+ self.streamInfo["Slogan"] = m.group(1)
+ elif (self.regex[16].match(line)):
+ # match message
+ m = self.regex[16].match(line)
+ self.streamInfo["Message"] = m.group(1)
+ elif (self.regex[17].match(line)):
+ # match alert
+ m = self.regex[17].match(line)
+ self.streamInfo["Alert"] = m.group(1)
+ elif (self.regex[10].match(line)):
+ # match gain
+ m = self.regex[10].match(line)
+ self.streamInfo["Gain"] = float(m.group(1))/10
+ elif (self.regex[11].match(line)):
+ # match stream
+ m = self.regex[11].match(line)
+ t = m.group(1) # stream type
+ s = int(m.group(2), 16) # stream number
+ n = m.group(3)
+
+ self.debugLog("Found Stream: Type {:s}, Number {:02X}". format(t, s))
+ self.lastType = t
+ if (t == "audio" and s >= 1 and s <= 4):
+ self.numStreams = s
+ self.streamInfo["Streams"][s-1] = n
+ if (t == "data"):
+ self.streamInfo["Services"][self.numServices] = n
+ self.numServices += 1
+ elif (self.regex[12].match(line)):
+ # match port and data_service_type
+ m = self.regex[12].match(line)
+ id = int(m.group(1), 10)
+ p = int(m.group(2), 16)
+ t = int(m.group(3), 10)
+ self.debugLog("\tFound Port: {:03X}". format(p))
+
+ if (self.lastType == "audio" and self.numStreams > 0):
+ self.streams[self.numStreams-1].append(p)
+ if ((self.lastType == "data") and (id == 0) and (self.numServices > 0)):
+ self.streamInfo["SvcTypes"][self.numServices-1] = self.service_data_type_name(t)
+ elif (self.regex[18].match(line)):
+ # match program type
+ m = self.regex[18].match(line)
+ id = int(m.group(1), 10)
+ p = int(m.group(2), 16)
+ t = int(m.group(3), 10)
+
+ if ((self.lastType == "audio") and (id == 0) and (self.numStreams > 0)):
+ self.streamInfo["Programs"][self.numStreams-1] = self.program_type_name(t)
+ elif (self.regex[19].match(line)):
+ # match synchronized
+ self.set_synchronization(1)
+ elif (self.regex[20].match(line)):
+ # match lost synch
+ self.set_synchronization(0)
+ elif (self.regex[21].match(line)):
+ # match lost device
+ self.set_synchronization(-1)
+
+ def getControls(self):
+ global resDir
+ # setup gui
+ builder = Gtk.Builder()
+ builder.add_from_file(os.path.join(resDir,"mainForm.glade"))
+ builder.connect_signals(self)
+
+ # Windows
+ self.mainWindow = builder.get_object("mainWindow")
+ self.mainWindow.connect("delete-event", self.shutdown)
+ self.mainWindow.connect("destroy", Gtk.main_quit)
+ self.about_dialog = None
+
+ # get controls
+ self.notebookMain = builder.get_object("notebookMain")
+ self.alignmentCover = builder.get_object("alignmentCover")
+ self.imgCover = builder.get_object("imgCover")
+ self.alignmentMap = builder.get_object("alignment_map")
+ self.imgMap = builder.get_object("imgMap")
+ self.spinFreq = builder.get_object("spinFreq")
+ #self.spinStream = builder.get_object("spinStream")
+ self.spinGain = builder.get_object("spinGain")
+ self.spinPPM = builder.get_object("spinPPM")
+ self.spinRTL = builder.get_object("spinRTL")
+ self.cbAutoGain = builder.get_object("cbAutoGain")
+ self.cbLog = builder.get_object("cbLog")
+ self.btnPlay = builder.get_object("btnPlay")
+ self.btnStop = builder.get_object("btnStop")
+ self.btnBookmark = builder.get_object("btnBookmark")
+ self.btnDelete = builder.get_object("btnDelete")
+ self.radMapTraffic = builder.get_object("radMapTraffic")
+ self.radMapWeather = builder.get_object("radMapWeather")
+ self.txtTitle = builder.get_object("txtTitle")
+ self.txtArtist = builder.get_object("txtArtist")
+ self.txtAlbum = builder.get_object("txtAlbum")
+ self.txtGenre = builder.get_object("txtGenre")
+ self.lblName = builder.get_object("lblName")
+ self.lblSlogan = builder.get_object("lblSlogan")
+ self.lblMessage = builder.get_object("lblMessage")
+ self.lblAlert = builder.get_object("lblAlert")
+ self.btnAudioPrgs0 = builder.get_object("btn_audio_prgs0")
+ self.btnAudioPrgs1 = builder.get_object("btn_audio_prgs1")
+ self.btnAudioPrgs2 = builder.get_object("btn_audio_prgs2")
+ self.btnAudioPrgs3 = builder.get_object("btn_audio_prgs3")
+ self.btnAudioLbl0 = builder.get_object("btn_audio_lbl0")
+ self.btnAudioLbl1 = builder.get_object("btn_audio_lbl1")
+ self.btnAudioLbl2 = builder.get_object("btn_audio_lbl2")
+ self.btnAudioLbl3 = builder.get_object("btn_audio_lbl3")
+ self.lblAudioPrgs0 = builder.get_object("lbl_audio_prgs0")
+ self.lblAudioPrgs1 = builder.get_object("lbl_audio_prgs1")
+ self.lblAudioPrgs2 = builder.get_object("lbl_audio_prgs2")
+ self.lblAudioPrgs3 = builder.get_object("lbl_audio_prgs3")
+ self.lblAudioSvcs0 = builder.get_object("lbl_audio_svcs0")
+ self.lblAudioSvcs1 = builder.get_object("lbl_audio_svcs1")
+ self.lblAudioSvcs2 = builder.get_object("lbl_audio_svcs2")
+ self.lblAudioSvcs3 = builder.get_object("lbl_audio_svcs3")
+ self.lblDataSvcs0 = builder.get_object("lbl_data_svcs0")
+ self.lblDataSvcs1 = builder.get_object("lbl_data_svcs1")
+ self.lblDataSvcs2 = builder.get_object("lbl_data_svcs2")
+ self.lblDataSvcs3 = builder.get_object("lbl_data_svcs3")
+ self.lblDataType0 = builder.get_object("lbl_data_svcs10")
+ self.lblDataType1 = builder.get_object("lbl_data_svcs11")
+ self.lblDataType2 = builder.get_object("lbl_data_svcs12")
+ self.lblDataType3 = builder.get_object("lbl_data_svcs13")
+ self.lblCall = builder.get_object("lblCall")
+ self.lblGain = builder.get_object("lblGain")
+ self.lblBitRate = builder.get_object("lblBitRate")
+ self.lblBitRate2 = builder.get_object("lblBitRate2")
+ self.lblError = builder.get_object("lblError")
+ self.lblMerLower = builder.get_object("lblMerLower")
+ self.lblMerUpper = builder.get_object("lblMerUpper")
+ self.lblBerNow = builder.get_object("lblBerNow")
+ self.lblBerAvg = builder.get_object("lblBerAvg")
+ self.lblBerMin = builder.get_object("lblBerMin")
+ self.lblBerMax = builder.get_object("lblBerMax")
+ self.imgNoSynch = builder.get_object("img_nosynch")
+ self.imgSynch = builder.get_object("img_synchpilot")
+ self.imgLostDevice = builder.get_object("img_lostdevice")
+ self.lvBookmarks = builder.get_object("listviewBookmarks")
+ self.lsBookmarks = Gtk.ListStore(str, str, int)
+
+ self.lvBookmarks.set_model(self.lsBookmarks)
+ self.lvBookmarks.get_selection().connect("changed", self.on_lvBookmarks_selection_changed)
+
+ self.mainWindow.connect("check-resize", self.on_cover_resize)
+
+ def initStreamInfo(self):
+ # stream information
+ self.streamInfo = {
+ "Callsign": "", # station callsign
+ "Slogan": "", # station slogan
+ "Message": "", # station message
+ "Alert": "", # station alert
+ "Streams": ["","","",""], # audio stream names
+ "Programs": ["","","",""], # audio stream types
+ "Services": ["","","",""], # data service names
+ "SvcTypes": ["","","",""], # data service types
+ "Title": "", # track title
+ "Album": "", # track album
+ "Genre": "", # track genre
+ "Artist": "", # track artist
+ "Cover": "", # filename of track cover
+ "Logo": "", # station logo
+ "Bitrate": 0, # current stream bit rate
+ "MER": [0,0], # modulation error ratio: lower, upper
+ "BER": [0,0,0,0], # bit error rate: current, average, min, max
+ "Gain": 0 # automatic gain
+ }
+
+ self.streams = [[],[],[],[]]
+ self.numStreams = 0
+ self.numServices = 0
+ self.lastType = 0
+
+ # clear status info
+ self.lblCall.set_label("")
+ self.btnAudioLbl0.set_label("")
+ self.btnAudioLbl1.set_label("")
+ self.btnAudioLbl2.set_label("")
+ self.btnAudioLbl3.set_label("")
+ self.lblBitRate.set_label("")
+ self.lblBitRate2.set_label("")
+ self.lblError.set_label("")
+ self.lblGain.set_label("")
+ self.txtTitle.set_text("")
+ self.txtArtist.set_text("")
+ self.txtAlbum.set_text("")
+ self.txtGenre.set_text("")
+ self.imgCover.clear()
+ self.coverImage = ""
+ self.lblName.set_label("")
+ self.lblSlogan.set_label("")
+ self.lblSlogan.set_tooltip_text("")
+ self.lblMessage.set_label("")
+ self.lblMessage.set_tooltip_text("")
+ self.lblAudioPrgs0.set_label("")
+ self.lblAudioPrgs1.set_label("")
+ self.lblAudioPrgs2.set_label("")
+ self.lblAudioPrgs3.set_label("")
+ self.lblAudioSvcs0.set_label("")
+ self.lblAudioSvcs1.set_label("")
+ self.lblAudioSvcs2.set_label("")
+ self.lblAudioSvcs3.set_label("")
+ self.lblDataSvcs0.set_label("")
+ self.lblDataSvcs1.set_label("")
+ self.lblDataSvcs2.set_label("")
+ self.lblDataSvcs3.set_label("")
+ self.lblDataType0.set_label("")
+ self.lblDataType1.set_label("")
+ self.lblDataType2.set_label("")
+ self.lblDataType3.set_label("")
+ self.lblMerLower.set_label("")
+ self.lblMerUpper.set_label("")
+ self.lblBerNow.set_label("")
+ self.lblBerAvg.set_label("")
+ self.lblBerMin.set_label("")
+ self.lblBerMax.set_label("")
+ self.set_synchronization(0)
+
+ def loadSettings(self):
+ global aasDir, cfgDir, mapDir
+
+ # load station logos
+ try:
+ with open(os.path.join(cfgDir,"stationLogos.json"), mode='r') as f:
+ self.stationLogos = json.load(f)
+ except:
+ self.debugLog("Error: Unable to load station logo database", True)
+
+ self.mainWindow.resize(self.defaultSize[0],self.defaultSize[1])
+
+ # load settings
+ try:
+ with open(os.path.join(cfgDir,"config.json"), mode='r') as f:
+ config = json.load(f)
+
+ if "MapData" in config:
+ self.mapData = config["MapData"]
+ if (self.mapData["mapMode"] == 0):
+ self.radMapTraffic.set_active(True)
+ self.radMapTraffic.toggled()
+ elif (self.mapData["mapMode"] == 1):
+ self.radMapWeather.set_active(True)
+ self.radMapWeather.toggled()
+
+ if "Width" and "Height" in config:
+ self.mainWindow.resize(config["Width"],config["Height"])
+ else:
+ self.mainWindow.resize(self.defaultSize)
+
+ self.mainWindow.move(config["WindowX"], config["WindowY"])
+ self.spinFreq.set_value(config["Frequency"])
+ #self.spinStream.set_value(config["Stream"])
+ self.streamNum = config["Stream"]
+ if (self.streamNum < 0):
+ self.streamNum = 1
+ self.set_program_btns()
+ self.spinGain.set_value(config["Gain"])
+ self.cbAutoGain.set_active(config["AutoGain"])
+ self.spinPPM.set_value(config["PPMError"])
+ self.spinRTL.set_value(config["RTL"])
+ self.cbLog.set_active(config["LogToFile"])
+ self.bookmarks = config["Bookmarks"]
+ for bookmark in self.bookmarks:
+ self.lsBookmarks.append(bookmark)
+ except:
+ self.debugLog("Error: Unable to load config", True)
+
+ # create aas directory
+ if (not os.path.isdir(aasDir)):
+ try:
+ os.mkdir(aasDir)
+ except:
+ self.debugLog("Error: Unable to create AAS directory", True)
+ aasDir = None
+
+ # create map directory
+ if (not os.path.isdir(mapDir)):
+ try:
+ os.mkdir(mapDir)
+ except:
+ self.debugLog("Error: Unable to create Map directory", True)
+ mapDir = None
+
+ # open log file
+ try:
+ self.logFile = open("nrsc5.log", mode='a')
+ except:
+ self.debugLog("Error: Unable to create log file", True)
+
+ def shutdown(self, *args):
+ # stop map viewer animation if it's running
+ if (self.mapViewer is not None and self.mapViewer.animateTimer is not None):
+ self.mapViewer.animateTimer.cancel()
+ self.mapViewer.animateStop = True
+
+ while (self.mapViewer.animateBusy):
+ self.debugLog("Animation Busy - Stopping")
+ if (self.mapViewer.animateTimer is not None):
+ self.mapViewer.animateTimer.cancel()
+ time.sleep(0.25)
+
+ self.playing = False
+
+ # kill nrsc5 if it's running
+ if (self.nrsc5 is not None and not self.nrsc5.poll()):
+ self.nrsc5.kill()
+
+ # shut down status timer if it's running
+ if (self.statusTimer is not None):
+ self.statusTimer.cancel()
+
+ # wair for player thread to exit
+ #if (self.playerThread is not None and self.playerThread.isAlive()):
+ if (self.playerThread is not None and self.playerThread.is_alive()):
+ self.playerThread.join(1)
+
+ # close log file if it's enabled
+ if (self.logFile is not None):
+ self.logFile.close()
+
+ # save settings
+ try:
+ with open(os.path.join(cfgDir,"config.json"), mode='w') as f:
+ winX, winY = self.mainWindow.get_position()
+ width, height = self.mainWindow.get_size()
+ config = {
+ "CfgVersion": "1.1.0",
+ "WindowX" : winX,
+ "WindowY" : winY,
+ "Width" : width,
+ "Height" : height,
+ "Frequency" : self.spinFreq.get_value(),
+ #"Stream" : int(self.spinStream.get_value()),
+ "Stream" : int(self.streamNum),
+ "Gain" : self.spinGain.get_value(),
+ "AutoGain" : self.cbAutoGain.get_active(),
+ "PPMError" : int(self.spinPPM.get_value()),
+ "RTL" : int(self.spinRTL.get_value()),
+ "LogToFile" : self.cbLog.get_active(),
+ "Bookmarks" : self.bookmarks,
+ "MapData" : self.mapData,
+ }
+ # sort bookmarks
+ config["Bookmarks"].sort(key=lambda t: t[2])
+
+ json.dump(config, f, indent=2)
+
+ with open(os.path.join(cfgDir,"stationLogos.json"), mode='w') as f:
+ json.dump(self.stationLogos, f, indent=2)
+ except:
+ self.debugLog("Error: Unable to save config", True)
+
+ def debugLog(self, message, force=False):
+ if (debugMessages or force):
+ now = datetime.datetime.now()
+ print (now.strftime("%b %d %H:%M:%S : ") + message)
+
+class NRSC5_Map(object):
+ def __init__(self, parent, callback, data):
+ # setup gui
+ builder = Gtk.Builder()
+ builder.add_from_file(os.path.join(resDir,"mapForm.glade"))
+
+ self.parent = parent # parent class
+ self.callback = callback # callback function
+ self.data = data # map data
+ self.animateTimer = None # timer used to animate weather maps
+ self.animateBusy = False
+ self.animateStop = False
+ self.weatherMaps = parent.weatherMaps # list of weather maps sorted by time
+ self.mapIndex = 0 # the index of the next weather map to display
+
+ # get the controls
+ self.mapWindow = builder.get_object("mapWindow")
+ self.imgMap = builder.get_object("imgMap")
+ self.radMapWeather = builder.get_object("radMapWeather")
+ self.radMapTraffic = builder.get_object("radMapTraffic")
+ self.chkAnimate = builder.get_object("chkAnimate")
+ self.chkScale = builder.get_object("chkScale")
+ self.spnSpeed = builder.get_object("spnSpeed")
+ self.adjSpeed = builder.get_object("adjSpeed")
+ self.imgKey = builder.get_object("imgKey")
+
+ self.mapWindow.connect("delete-event", self.on_mapWindow_delete)
+
+ self.config = data["viewerConfig"] # get the map viewer config
+ self.mapWindow.resize(*self.config["windowSize"]) # set the window size
+ self.mapWindow.move(*self.config["windowPos"]) # set the window position
+ if (self.config["mode"] == 0): self.radMapTraffic.set_active(True) # set the map radio buttons
+ elif (self.config["mode"] == 1): self.radMapWeather.set_active(True)
+ self.setMap(self.config["mode"]) # display the current map
+
+ self.chkAnimate.set_active(self.config["animate"]) # set the animation mode
+ self.chkScale.set_active(self.config["scale"]) # set the scale mode
+ self.spnSpeed.set_value(self.config["animationSpeed"]) # set the animation speed
+
+ def on_radMap_toggled(self, btn):
+ if (btn.get_active()):
+ if (btn == self.radMapTraffic):
+ self.config["mode"] = 0
+ self.imgKey.set_visible(False) # hide the key for the weather radar
+
+ # stop animation if it's enabled
+ if (self.animateTimer is not None):
+ self.animateTimer.cancel()
+ self.animateTimer = None
+
+ self.setMap(0) # show the traffic map
+
+ elif (btn == self.radMapWeather):
+ self.config["mode"] = 1
+ self.imgKey.set_visible(True) # show the key for the weather radar
+
+ # check if animate is enabled and start animation
+ if (self.config["animate"] and self.animateTimer is None):
+ self.animateTimer = Timer(0.05, self.animate)
+ self.animateTimer.start()
+
+ # no animation, just show the current map
+ elif(not self.config["animate"]):
+ self.setMap(1)
+
+ def on_chkAnimate_toggled(self, btn):
+ self.config["animate"] = self.chkAnimate.get_active()
+
+ if (self.config["animate"] and self.config["mode"] == 1):
+ # start animation
+ self.animateTimer = Timer(self.config["animationSpeed"], self.animate) # create the animation timer
+ self.animateTimer.start() # start the animation timer
+ else:
+ # stop animation
+ if (self.animateTimer is not None):
+ self.animateTimer.cancel() # cancel the animation timer
+ self.animateTimer = None
+ self.mapIndex = len(self.weatherMaps)-1 # reset the animation index
+ self.setMap(self.config["mode"]) # show the most recent map
+
+ def on_chkScale_toggled(self, btn):
+ self.config["scale"] = btn.get_active()
+ if (self.config["mode"] == 1):
+ if (self.config["animate"]):
+ i = len(self.weatherMaps)-1 if (self.mapIndex-1<0) else self.mapIndex-1 # get the index for the current map in the animation
+ self.showImage(self.weatherMaps[i], self.config["scale"]) # show the current map in the animation
+ else:
+ self.showImage(self.data["weatherNow"], self.config["scale"]) # show the most recent map
+
+ def on_spnSpeed_value_changed(self, spn):
+ self.config["animationSpeed"] = self.adjSpeed.get_value() # get the animation speed
+
+ def on_mapWindow_delete(self, *args):
+ # cancel the timer if it's running
+ if (self.animateTimer is not None):
+ self.animateTimer.cancel()
+ self.animateStop = True
+
+ # wait for animation to finish
+ while (self.animateBusy):
+ self.parent.debugLog("Waiting for animation to finish")
+ if (self.animateTimer is not None):
+ self.animateTimer.cancel()
+ time.sleep(0.25)
+
+ self.config["windowPos"] = self.mapWindow.get_position() # store current window position
+ self.config["windowSize"] = self.mapWindow.get_size() # store current window size
+ self.callback() # run the callback
+
+ def animate(self):
+ fileName = self.weatherMaps[self.mapIndex] if len(self.weatherMaps) else ""
+ if (os.path.isfile(fileName)):
+ self.animateBusy = True # set busy to true
+
+ if (self.config["scale"]):
+ mapImg = imgToPixbuf(Image.open(fileName).resize((600,600), Image.LANCZOS)) # open weather map, resize to 600x600, and convert to pixbuf
+ else:
+ mapImg = imgToPixbuf(Image.open(fileName)) # open weather map and convert to pixbuf
+
+ if (self.config["animate"] and self.config["mode"] == 1 and not self.animateStop): # check if the viwer is set to animated weather map
+ self.imgMap.set_from_pixbuf(mapImg) # display image
+ self.mapIndex += 1 # incriment image index
+ if (self.mapIndex >= len(self.weatherMaps)): # check if this is the last image
+ self.mapIndex = 0 # reset the map index
+ self.animateTimer = Timer(2, self.animate) # show the last image for a longer time
+ else:
+ self.animateTimer = Timer(self.config["animationSpeed"], self.animate) # set the timer to the normal speed
+
+ self.animateTimer.start() # start the timer
+ else:
+ self.animateTimer = None # clear the timer
+
+ self.animateBusy = False # set busy to false
+ else:
+ self.chkAnimate.set_active(False) # stop animation if image was not found
+ self.mapIndex = 0
+
+ def showImage(self, fileName, scale):
+ if (os.path.isfile(fileName)):
+ if (scale): mapImg = Image.open(fileName).resize((600,600), Image.LANCZOS) # open and scale map to fit window
+ else: mapImg = Image.open(fileName) # open map
+
+ self.imgMap.set_from_pixbuf(imgToPixbuf(mapImg)) # convert image to pixbuf and display
+ else:
+ #self.imgMap.set_from_stock(Gtk.STOCK_MISSING_IMAGE, Gtk.ICON_SIZE_LARGE_TOOLBAR) # display missing image if file is not found
+ self.imgMap.set_from_stock(Gtk.STOCK_MISSING_IMAGE, Gtk.IconSize.LARGE_TOOLBAR) # display missing image if file is not found
+
+ def setMap(self, map):
+ if (map == 0): self.showImage(os.path.join(mapDir, "TrafficMap.png"), False) # show traffic map
+ elif (map == 1):self.showImage(self.data["weatherNow"], self.config["scale"]) # show weather map
+
+ def updated(self, imageType):
+ if (self.config["mode"] == 0):
+ self.setMap(0)
+ elif (self.config["mode"] == 1):
+ self.setMap(1)
+ self.mapIndex = len(self.weatherMaps)-1
+
+def dtToTs(dt):
+ # convert datetime to timestamp
+ return int((dt - datetime.datetime(1970, 1, 1, tzinfo=tz.tzutc())).total_seconds())
+
+def tsToDt(ts):
+ # convert timestamp to datetime
+ return datetime.datetime.utcfromtimestamp(ts)
+
+def imgToPixbuf(img):
+ # convert PIL.Image to gdk.pixbuf
+ #imgArr = numpy.array(img.convert("RGB"))
+ #return gtk.gdk.pixbuf_new_from_array(imgArr, gtk.gdk.COLORSPACE_RGB, 8)
+ data = GLib.Bytes.new(img.tobytes())
+ return GdkPixbuf.Pixbuf.new_from_bytes(data, GdkPixbuf.Colorspace.RGB, 'A' in img.getbands(),
+ 8, img.width, img.height, len(img.getbands())*img.width)
+
+
+if __name__ == "__main__":
+ # show main window and start main thread
+ nrsc5_dui = NRSC5_DUI()
+ nrsc5_dui.mainWindow.show()
+ if (debugMessages and debugAutoStart):
+ nrsc5_dui.on_btnPlay_clicked(nrsc5_dui)
+
+ Gtk.main()
diff --git a/radar_key.png b/radar_key.png
new file mode 100644
index 0000000..8f8f0a3
Binary files /dev/null and b/radar_key.png differ
diff --git a/radar_key.svg b/radar_key.svg
new file mode 100644
index 0000000..a8ebc13
--- /dev/null
+++ b/radar_key.svg
@@ -0,0 +1,246 @@
+
+
+
+
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..ee69183
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,95 @@
+from distutils.core import setup
+import os, sys, glob, py2exe
+
+# Find GTK+ installation path
+__import__('gtk')
+m = sys.modules['gtk']
+gtk_base_path = m.__path__[0]
+
+def get_files_recursive(directory):
+ paths = []
+ for (path, directories, filenames) in os.walk(directory):
+ for filename in filenames:
+ paths.append(os.path.join('..', path, filename))
+ return paths
+
+gtk_package_files = []
+gtk_package_files.append([os.path.join('share','themes'), get_files_recursive(os.path.join(gtk_base_path,'..','runtime','share','themes'))])
+gtk_package_files.append([os.path.join('lib','gtk-2.0','2.10.0'), get_files_recursive(os.path.join(gtk_base_path,'..','runtime','lib','gtk-2.0','2.10.0'))])
+gtk_package_files.append([os.path.join('lib','gtk-2.0','modules'), get_files_recursive(os.path.join(gtk_base_path,'..','runtime','lib','gtk-2.0','modules'))])
+# TODO: make script copy empty dirs so this works
+gtk_package_files.append([os.path.join('share','icons'), get_files_recursive(os.path.join(gtk_base_path,'..','runtime','share','icons'))])
+
+setup(
+ name = 'nrsc5-gui',
+ description = 'Graphical frontend for nrsc5 cli utility',
+ version = '1.0',
+
+ windows = [
+ {
+ 'script': 'nrsc5-gui.py',
+ 'icon_resources': [(1, os.path.join("res","nrsc5-gui.ico"))],
+ }
+ ],
+
+ options = {
+ 'py2exe': {
+ 'packages':'encodings',
+ 'includes': 'gtk, gtk.cairo, gio, pango, pangocairo, atk, gobject, datetime, numpy',
+ 'dll_excludes': [
+ 'CRYPT32.DLL', # required by ssl
+ 'DNSAPI.DLL',
+ 'IPHLPAPI.DLL', # psutil
+ 'MPR.dll',
+ 'MSIMG32.DLL',
+ 'MSWSOCK.dll',
+ 'NSI.dll', # psutil
+ 'PSAPI.DLL',
+ 'POWRPROF.dll',
+ 'USP10.DLL',
+ 'WTSAPI32.DLL', # psutil
+ 'api-ms-win-core-apiquery-l1-1-0.dll',
+ 'api-ms-win-core-crt-l1-1-0.dll',
+ 'api-ms-win-core-crt-l2-1-0.dll',
+ 'api-ms-win-core-debug-l1-1-1.dll',
+ 'api-ms-win-core-delayload-l1-1-1.dll',
+ 'api-ms-win-core-errorhandling-l1-1-1.dll',
+ 'api-ms-win-core-file-l1-2-1.dll',
+ 'api-ms-win-core-handle-l1-1-0.dll',
+ 'api-ms-win-core-heap-l1-2-0.dll',
+ 'api-ms-win-core-heap-obsolete-l1-1-0.dll',
+ 'api-ms-win-core-io-l1-1-1.dll',
+ 'api-ms-win-core-libraryloader-l1-2-0.dll',
+ 'api-ms-win-core-localization-l1-2-1.dll',
+ 'api-ms-win-core-memory-l1-1-2.dll',
+ 'api-ms-win-core-processenvironment-l1-2-0.dll',
+ 'api-ms-win-core-processthreads-l1-1-2.dll',
+ 'api-ms-win-core-profile-l1-1-0.dll',
+ 'api-ms-win-core-registry-l1-1-0.dll',
+ 'api-ms-win-core-string-l1-1-0.dll',
+ 'api-ms-win-core-string-obsolete-l1-1-0.dll',
+ 'api-ms-win-core-synch-l1-2-0.dll',
+ 'api-ms-win-core-sysinfo-l1-2-1.dll',
+ 'api-ms-win-core-threadpool-l1-2-0.dll',
+ 'api-ms-win-core-timezone-l1-1-0.dll',
+ 'api-ms-win-core-util-l1-1-0.dll',
+ 'api-ms-win-security-base-l1-2-0.dll',
+ 'w9xpopen.exe', # not needed after Windows 9x
+ ],
+ 'compressed': True # create a compressed zipfile
+ },
+ },
+
+ data_files=[
+ 'README.md',
+ "aas"+os.path.sep+"placeholder.txt",
+ "map"+os.path.sep+"placeholder.txt",
+ ("bin", glob.glob("bin/*")),
+ ("res", glob.glob("res/*")),
+ ("cfg", glob.glob("cfg/*")),
+ gtk_package_files[0],
+ gtk_package_files[1],
+ gtk_package_files[2],
+ gtk_package_files[3],
+ ]
+)
\ No newline at end of file
diff --git a/synchpilot.png b/synchpilot.png
new file mode 100644
index 0000000..649d840
Binary files /dev/null and b/synchpilot.png differ
diff --git a/weather.png b/weather.png
new file mode 100644
index 0000000..2112966
Binary files /dev/null and b/weather.png differ