diff --git a/res/nrsc5-dui.py b/res/nrsc5-dui.py
new file mode 100644
index 0000000..7bfe15e
--- /dev/null
+++ b/res/nrsc5-dui.py
@@ -0,0 +1,1897 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# NRSC5 DUI - A graphical interface for nrsc5
+# Copyright (C) 2017-2019 Cody Nybo & Clayton Smith, 2019 zefie, 2021 Mark J. Fine
+#
+# 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, pty, select, 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
+
+import urllib3
+from OpenSSL import SSL
+
+# 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):
+ global runtimeDir, resDir
+ #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.http = urllib3.PoolManager()
+
+ 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
+ arg1 = ""
+ if (len(sys.argv[1:]) > 0):
+ arg1 = sys.argv[1].strip()
+ self.nrsc5Path = arg1+"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.nrsc5master = None
+ self.nrsc5slave = None
+ 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.id3Changed = False
+ #self.lastXHDR = ["", -1] # the last XHDR data received
+ self.lastXHDR = "" # the last XHDR data received
+ self.stationStr = "" # current station frequency (string)
+ self.streamNum = 0 # current station stream number
+ self.nrsc5msg = "" # send key command to nrsc5 (streamNum)
+ self.update_btns = True
+ self.set_program_btns()
+ self.bookmarks = [] # station bookmarks
+ self.booknames = ["","","",""] # station bookmark names
+ 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 Objects",
+ 511 : "Test_Str_E"
+ }
+
+ self.ProgramType = {
+ 0 : "None",
+ 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"
+ }
+
+ self.pointer_cursor = Gdk.Cursor(Gdk.CursorType.LEFT_PTR)
+ self.hand_cursor = Gdk.Cursor(Gdk.CursorType.HAND2)
+ #self.missing_image = Gtk.Image.new_from_icon_name("MISSING_IMAGE",Gtk.IconSize.LARGE_TOOLBAR)
+
+ # set events on info labels
+ self.set_tuning_actions(self.btnAudioPrgs0, "btn_prg0", False, False)
+ self.set_tuning_actions(self.btnAudioPrgs1, "btn_prg1", False, False)
+ self.set_tuning_actions(self.btnAudioPrgs2, "btn_prg2", False, False)
+ self.set_tuning_actions(self.btnAudioPrgs3, "btn_prg3", False, False)
+
+ self.set_tuning_actions(self.lblAudioPrgs0, "prg0", True, True)
+ self.set_tuning_actions(self.lblAudioPrgs1, "prg1", True, True)
+ self.set_tuning_actions(self.lblAudioPrgs2, "prg2", True, True)
+ self.set_tuning_actions(self.lblAudioPrgs3, "prg3", True, True)
+
+ self.set_tuning_actions(self.lblAudioSvcs0, "svc0", True, True)
+ self.set_tuning_actions(self.lblAudioSvcs1, "svc1", True, True)
+ self.set_tuning_actions(self.lblAudioSvcs2, "svc2", True, True)
+ self.set_tuning_actions(self.lblAudioSvcs3, "svc3", True, True)
+
+ # 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
+ re.compile("^[0-9\:]{8,8} Open device failed.$") # 22 No device
+ ]
+
+ self.loadSettings()
+ self.proccessWeatherMaps()
+ #self..connect('check-resize',self.on_window_resized) # TODO: fix on resize infinite loop
+
+ # set up pty
+ self.nrsc5master,self.nrsc5slave = pty.openpty()
+
+ def set_tuning_actions(self, widget, name, has_win, set_curs):
+ widget.set_property("name",name)
+ widget.set_sensitive(False)
+ if has_win:
+ widget.set_has_window(True)
+ widget.set_events(Gdk.EventMask.BUTTON_PRESS_MASK)
+ widget.connect("button-press-event", self.on_program_select)
+ if set_curs:
+ widget.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK)
+ widget.connect("enter-notify-event", self.on_enter_set_cursor)
+
+ def on_enter_set_cursor(self, widget, event):
+ if (widget.get_label() != ""):
+ widget.get_window().set_cursor(self.hand_cursor)
+
+ 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)
+ self.showArtwork(self.coverImage)
+
+ 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)
+ self.imgMap.set_from_icon_name("MISSING_IMAGE", Gtk.IconSize.DIALOG)
+ 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)
+ self.imgMap.set_from_icon_name("MISSING_IMAGE", Gtk.IconSize.DIALOG)
+
+ def id3_did_change(self):
+ oldTitle = self.txtTitle.get_label()
+ oldArtist = self.txtArtist.get_label()
+ newTitle = self.streamInfo["Title"]
+ newArtist = self.streamInfo["Artist"]
+ return ((newArtist != oldArtist) and (newTitle != oldTitle))
+
+ def get_cover_data(self, response):
+ check = -1
+ resultUrl = ""
+ resultArtist = ""
+ m = re.search(r"card card_large float_fix",response)
+ if (m.start() > -1):
+ response = response[m.start():]
+ m = re.search(r" -1):
+ response = response[m.start()+15:]
+ m = re.search(r"\"",response)
+ if (m.start() > -1):
+ resultUrl = response[:m.start()]
+ response = response[m.start()+1:]
+ m = re.search(r" -1):
+ response = response[m.start()+13:]
+ m = re.search(r"\"",response)
+ if (m.start() > -1):
+ resultArtist = response[:m.start()]
+ response = response[m.start()+1:]
+ check = 0
+ return check, response, resultUrl, resultArtist
+
+ def fix_artist(self):
+ newArtist = self.streamInfo["Artist"]
+ if ("/" in newArtist):
+ m = re.search(r"/",newArtist)
+ if (m.start() > -1):
+ newArtist = newArtist[:m.start()].strip()
+ return newArtist
+
+ def get_cover_image_online(self):
+ global aasDir
+ got_cover = False
+
+ # only change when there's a new ID3
+ if (self.id3Changed):
+ # only care about the first artist listed if separated by slashes
+ newArtist = self.fix_artist()
+ baseStr = str(newArtist +" - "+self.streamInfo["Title"]).replace("/","_").replace(":","_")
+ #saveStr = "aas/"+ baseStr.replace(" ","_")+".jpg"
+ saveStr = os.path.join(aasDir, baseStr.replace(" ","_")+".jpg")
+ searchStr = baseStr.replace(" ","+")
+
+ # does it already exist?
+ if (os.path.isfile(saveStr)):
+ self.coverImage = saveStr
+
+ # if not, get it from Discogs
+ else:
+ try:
+ searchStr = "https://www.discogs.com/search/?q="+searchStr+"&type=all"
+ r = self.http.request('GET',searchStr)
+ if (r.status == 200):
+ response = r.data.decode('utf-8')
+
+ # loop through the page until you either get an artist match or you run out of page (check)
+ while (not got_cover):
+ resultUrl = ""
+ resultArtist = ""
+ check, response, resultUrl, resultArtist = self.get_cover_data(response)
+ got_cover = (newArtist.lower() in resultArtist.lower()) and (check == 0)
+
+ # if you got a match, save it
+ if (resultUrl != ""):
+ with self.http.request('GET', resultUrl, preload_content=False) as r, open(saveStr, 'wb') as out_file:
+ if (r.status == 200):
+ shutil.copyfileobj(r, out_file)
+ self.coverImage = saveStr
+
+ # If no match use the station logo if there is one
+ else:
+ self.coverImage = os.path.join(aasDir, self.stationLogos[self.stationStr][self.streamNum])
+ except:
+ pass
+
+ # now display it by simulating a window resize
+ self.on_cover_resize(self.mainWindow)
+
+ def showArtwork(self, art):
+ img_size = min(self.alignmentCover.get_allocated_height(), self.alignmentCover.get_allocated_width()) - 12
+ self.pixbuf = GdkPixbuf.Pixbuf.new_from_file(art)
+ self.pixbuf = self.pixbuf.scale_simple(img_size, img_size, GdkPixbuf.InterpType.BILINEAR)
+ self.imgCover.set_from_pixbuf(self.pixbuf)
+
+ def displayLogo(self):
+ global aasDir
+ #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.streamNum])
+ if (os.path.isfile(logo)):
+ self.streamInfo["Logo"] = self.stationLogos[self.stationStr][self.streamNum]
+ #img_size = min(self.alignmentCover.get_allocated_height(), self.alignmentCover.get_allocated_width()) - 12
+ #self.pixbuf = Gtk.gdk.pixbuf_new_from_file(logo)
+ #self.pixbuf = GdkPixbuf.Pixbuf.new_from_file(logo)
+ self.coverImage = logo
+ #self.handle_window_resize()
+ #self.pixbuf = self.pixbuf.scale_simple(img_size, img_size, GdkPixbuf.InterpType.BILINEAR)
+ #self.imgCover.set_from_pixbuf(self.pixbuf)
+ self.showArtwork(logo)
+ else:
+ # add entry in database for the station if it doesn't exist
+ self.stationLogos[self.stationStr] = ["", "", "", ""]
+
+ 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)))
+
+ #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]
+ self.lastXHDR = ""
+
+ # 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.set_program_btns()
+
+ self.displayLogo()
+
+ # 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 + 1)
+ for b in self.bookmarks:
+ if (b[2] == freq):
+ self.bookmarked = True
+ break
+
+ self.get_bookmark_names()
+
+ self.btnBookmark.set_sensitive(not self.bookmarked)
+ if (self.notebookMain.get_current_page() != 3):
+ self.btnDelete.set_sensitive(self.bookmarked)
+
+ def get_bookmark_names(self):
+ self.booknames = ["","","",""]
+ freq = str(int((self.spinFreq.get_value()+0.005)*10))
+ for b in self.bookmarks:
+ test = str(b[2])
+ if (test[:-1] == freq):
+ self.booknames[int(test[-1])-1] = b[1]
+
+ 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) and (btn 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 + 1)
+
+ # 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 + 1),
+ 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
+
+ self.get_bookmark_names()
+
+ 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 + 1)
+ 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
+
+ self.get_bookmark_names()
+
+ def on_btnAbout_activate(self, btn):
+ global resDir
+ # sets up and displays about dialog
+ if self.about_dialog:
+ self.about_dialog.present()
+ return
+
+ authors = [
+ "Cody Nybo ",
+ "Clayton Smith ",
+ "nefie ",
+ "Mark J. Fine "
+ ]
+
+ license = """
+ NRSC5 DUI - A second-generation graphical interface for nrsc5
+ Copyright (C) 2017-2019 Cody Nybo & Clayton Smith, 2019 zefie, 2021 Mark J. Fine
+ 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_program_name("NRSC5 DUI")
+ about_dialog.set_version("2.1.0")
+ about_dialog.set_copyright("Copyright © 2017-2019 Cody Nybo & Clayton Smith, 2019 zefie, 2021 Mark J. Fine")
+ about_dialog.set_website("https://github.com/markjfine/nrsc5-dui")
+ about_dialog.set_comments("A second-generation 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(os.path.join(resDir,"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 on_stream_changed(self):
+ self.lastXHDR = ""
+ self.streamInfo["Title"] = ""
+ self.streamInfo["Album"] = ""
+ self.streamInfo["Artist"] = ""
+ self.streamInfo["Genre"] = ""
+ self.streamInfo["Cover"] = ""
+ self.streamInfo["Logo"] = ""
+ self.streamInfo["Bitrate"] = 0
+ self.set_program_btns()
+ if self.playing:
+ self.nrsc5msg = str(self.streamNum)
+ self.displayLogo()
+ #TODO: fix so stream change is smoother - should be able to pipe new stream number to running application and update display_logo()
+ # For now, just restart
+ #if (self.playing):
+ # self.on_btnStop_clicked(None)
+ # self.on_btnPlay_clicked(None)
+
+ def set_program_btns(self):
+ self.btnAudioPrgs0.set_active(self.update_btns and self.streamNum == 0)
+ self.btnAudioPrgs1.set_active(self.update_btns and self.streamNum == 1)
+ self.btnAudioPrgs2.set_active(self.update_btns and self.streamNum == 2)
+ self.btnAudioPrgs3.set_active(self.update_btns and self.streamNum == 3)
+ self.update_btns = True
+
+ def on_program_select(self, _label, evt):
+ stream_num = int(_label.get_property("name")[-1])
+ is_lbl = _label.get_property("name")[0] != "b"
+ self.update_btns = is_lbl
+ self.streamNum = stream_num
+ self.on_stream_changed()
+
+ 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)-1
+ self.on_stream_changed()
+
+ # 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
+ self.imgMap.set_from_icon_name("MISSING_IMAGE", Gtk.IconSize.DIALOG)
+
+ 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
+ self.imgMap.set_from_icon_name("MISSING_IMAGE", Gtk.IconSize.DIALOG)
+
+ 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, stdout=PIPE, stderr=PIPE, universal_newlines=True)
+ self.nrsc5 = Popen(self.nrsc5Args, shell=False, stdin=self.nrsc5slave, stdout=PIPE, stderr=PIPE, universal_newlines=True)
+
+ while True:
+ # send input to nrsc5 if needed
+ if (self.nrsc5msg != ""):
+ select.select([],[self.nrsc5master],[])
+ os.write(self.nrsc5master,str.encode(self.nrsc5msg))
+ #print(self.nrsc5msg)
+ self.nrsc5msg = ""
+ # 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, shell=False, stdin=self.nrsc5slave, stdout=PIPE, stderr=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 set_button_name(self, btnWidget, lblWidget, stream):
+ temp = self.streamInfo["Streams"][stream]
+ if ((temp == "") or (temp == "MPS") or (temp[0:3] == "SPS") or (temp[0:2] == "HD") ):
+ if (self.booknames[stream] != ""):
+ temp = self.booknames[stream]
+ lblWidget.set_label(temp)
+ btnWidget.set_sensitive(temp != "")
+
+ def set_label_name(self, lblWidget, inString, doSens):
+ lblWidget.set_label(inString)
+ lblWidget.set_tooltip_text(inString)
+ if (doSens):
+ lblWidget.set_sensitive(inString != "")
+
+ def checkStatus(self):
+ # update status information
+ def update():
+ global aasDir
+ #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]
+ ber = [self.streamInfo["BER"][i]*100 for i in range(4)]
+ self.id3Changed = self.id3_did_change()
+ self.txtTitle.set_text(self.streamInfo["Title"])
+ self.txtTitle.set_tooltip_text(self.streamInfo["Title"])
+ self.txtArtist.set_text(self.streamInfo["Artist"])
+ self.txtArtist.set_tooltip_text(self.streamInfo["Artist"])
+ self.txtAlbum.set_text(self.streamInfo["Album"])
+ self.txtAlbum.set_tooltip_text(self.streamInfo["Album"])
+ self.txtGenre.set_text(self.streamInfo["Genre"])
+ self.txtGenre.set_tooltip_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.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.set_button_name(self.btnAudioPrgs0,self.btnAudioLbl0,0)
+ self.set_button_name(self.btnAudioPrgs1,self.btnAudioLbl1,1)
+ self.set_button_name(self.btnAudioPrgs2,self.btnAudioLbl2,2)
+ self.set_button_name(self.btnAudioPrgs3,self.btnAudioLbl3,3)
+ self.set_label_name(self.lblAudioPrgs0, self.streamInfo["Streams"][0], True)
+ self.set_label_name(self.lblAudioPrgs1, self.streamInfo["Streams"][1], True)
+ self.set_label_name(self.lblAudioPrgs2, self.streamInfo["Streams"][2], True)
+ self.set_label_name(self.lblAudioPrgs3, self.streamInfo["Streams"][3], True)
+ self.set_label_name(self.lblAudioSvcs0, self.streamInfo["Programs"][0], True)
+ self.set_label_name(self.lblAudioSvcs1, self.streamInfo["Programs"][1], True)
+ self.set_label_name(self.lblAudioSvcs2, self.streamInfo["Programs"][2], True)
+ self.set_label_name(self.lblAudioSvcs3, self.streamInfo["Programs"][3], True)
+ self.set_label_name(self.lblDataSvcs0, self.streamInfo["Services"][0], False)
+ self.set_label_name(self.lblDataSvcs1, self.streamInfo["Services"][1], False)
+ self.set_label_name(self.lblDataSvcs2, self.streamInfo["Services"][2], False)
+ self.set_label_name(self.lblDataSvcs3, self.streamInfo["Services"][3], False)
+ self.set_label_name(self.lblDataType0, self.streamInfo["SvcTypes"][0], False)
+ self.set_label_name(self.lblDataType1, self.streamInfo["SvcTypes"][1], False)
+ self.set_label_name(self.lblDataType2, self.streamInfo["SvcTypes"][2], False)
+ self.set_label_name(self.lblDataType3, self.streamInfo["SvcTypes"][3], False)
+ 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):
+ if self.lastXHDR == "0":
+ imagePath = os.path.join(aasDir, self.streamInfo["Cover"])
+ image = self.streamInfo["Cover"]
+ #elif (int(self.lastXHDR[1]) < 0 or self.streamInfo["Cover"] == None):
+ elif self.lastXHDR == "1":
+ 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)):
+ if (self.lastImage != image) and os.path.isfile(imagePath):
+ self.xhdrChanged = False
+ self.lastImage = image
+ #img_size = min(self.alignmentCover.get_allocated_height(), self.alignmentCover.get_allocated_width()) - 12
+ #self.pixbuf = Gtk.gdk.pixbuf_new_from_file(imagePath)
+ #self.pixbuf = GdkPixbuf.Pixbuf.new_from_file(imagePath)
+ self.coverImage = imagePath
+ #self.pixbuf = self.pixbuf.scale_simple(img_size, img_size, GdkPixbuf.InterpType.BILINEAR)
+ #self.imgCover.set_from_pixbuf(self.pixbuf)
+ self.showArtwork(imagePath)
+ #self.handle_window_resize()
+ self.debugLog("Image Changed")
+
+ if (self.cbCovers.get_active() and self.id3Changed):
+ self.get_cover_image_online()
+
+ finally:
+ #Gdk.threads_leave()
+ pass
+
+ 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
+
+ # now put a timestamp on it.
+ imgMap = imgMap.convert("RGBA")
+ imgBig = (981,981) # size of a weather map
+ #posTS = (imgMap.size[0]-235, imgMap.size[1]-29) # calculate position to put timestamp (bottom right)
+ posTS = (imgBig[0]-235, imgBig[1]-29) # calculate position to put timestamp (bottom right)
+ #imgTS = self.mkTimestamp(t, imgMap.size, posTS) # create timestamp
+ imgTS = self.mkTimestamp(t, imgBig, posTS) # create timestamp for a weather map
+ imgTS = imgTS.resize((imgMap.size[0], imgMap.size[1]), Image.LANCZOS) # resize it so it's proportional to the size of a traffic map (981 -> 600)
+ imgMap = Image.alpha_composite(imgMap, imgTS) # overlay timestamp on traffic map
+
+ 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 aasDir, 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")
+ 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
+ #print(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):
+ global resDir
+ # 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)]
+ xhdr = m.group(1)
+ mime = m.group(2)
+ lot = m.group(3)
+ if (xhdr != self.lastXHDR):
+ self.lastXHDR = xhdr
+ self.xhdrChanged = True
+ #self.debugLog("XHDR Changed: {:s} (lot {:s})".format(xhdr[0],xhdr[1]))
+ self.debugLog("XHDR Changed: {:s} (lot {:s})".format(xhdr,lot))
+ 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)][0]
+
+ #if (p == self.streams[int(self.spinStream.get_value()-1)][0]):
+ if (p == self.streams[int(self.streamNum)][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]):
+ self.streamInfo["Logo"] = fileName
+ self.stationLogos[self.stationStr][self.streamNum] = 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), 10) # 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)
+ elif (self.regex[22].match(line)):
+ # match Open device failed
+ self.on_btnStop_clicked(None)
+ 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.set_icon_from_file(os.path.join(resDir,"logo.png"))
+ self.mainWindow.connect("delete-event", self.shutdown)
+ self.mainWindow.connect("destroy", Gtk.main_quit)
+ self.about_dialog = None
+
+ # get controls
+ self.image1 = builder.get_object("image1")
+ 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.cbCovers = builder.get_object("cbCovers")
+ 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.btnMap = builder.get_object("btnMap")
+ 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.image1.set_from_pixbuf(GdkPixbuf.Pixbuf.new_from_file(os.path.join(resDir,"weather.png")))
+ self.imgNoSynch.set_from_pixbuf(GdkPixbuf.Pixbuf.new_from_file(os.path.join(resDir,"nosynch.png")))
+ self.imgSynch.set_from_pixbuf(GdkPixbuf.Pixbuf.new_from_file(os.path.join(resDir,"synchpilot.png")))
+ self.imgLostDevice.set_from_pixbuf(GdkPixbuf.Pixbuf.new_from_file(os.path.join(resDir,"lostdevice.png")))
+ self.btnMap.set_icon_widget(self.image1)
+
+ 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
+ "Title": "", # track title
+ "Album": "", # track album
+ "Genre": "", # track genre
+ "Artist": "", # track artist
+ "Cover": "", # filename of track cover
+ "Logo": "", # station logo
+ "Streams": ["","","",""], # audio stream names
+ "Programs": ["","","",""], # audio stream types
+ "Services": ["","","",""], # data service names
+ "SvcTypes": ["","","",""], # data service types
+ "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.btnAudioPrgs0.set_sensitive(False)
+ self.btnAudioPrgs1.set_sensitive(False)
+ self.btnAudioPrgs2.set_sensitive(False)
+ self.btnAudioPrgs3.set_sensitive(False)
+ self.lblAudioPrgs0.set_label("")
+ self.lblAudioPrgs0.set_sensitive(False)
+ self.lblAudioPrgs1.set_label("")
+ self.lblAudioPrgs1.set_sensitive(False)
+ self.lblAudioPrgs2.set_label("")
+ self.lblAudioPrgs2.set_sensitive(False)
+ self.lblAudioPrgs3.set_label("")
+ self.lblAudioPrgs3.set_sensitive(False)
+ self.lblAudioSvcs0.set_label("")
+ self.lblAudioSvcs0.set_sensitive(False)
+ self.lblAudioSvcs1.set_label("")
+ self.lblAudioSvcs1.set_sensitive(False)
+ self.lblAudioSvcs2.set_label("")
+ self.lblAudioSvcs2.set_sensitive(False)
+ self.lblAudioSvcs3.set_label("")
+ self.lblAudioSvcs3.set_sensitive(False)
+ 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"]-1
+ if (self.streamNum < 0):
+ self.streamNum = 0
+ 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"])
+ if ("DLoadArt" in config):
+ self.cbCovers.set_active(config["DLoadArt"])
+ 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):
+ global cfgDir
+ # 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()
+
+ # wait 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)+1,
+ "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(),
+ "DLoadArt" : self.cbCovers.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):
+ global resDir
+ # setup gui
+ builder = Gtk.Builder()
+ builder.add_from_file(os.path.join(resDir,"mapForm.glade"))
+ builder.connect_signals(self)
+
+ 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.imgKey.set_from_pixbuf(GdkPixbuf.Pixbuf.new_from_file(os.path.join(resDir,"radar_key.png")))
+ 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
+ self.imgMap.set_from_icon_name("MISSING_IMAGE", Gtk.IconSize.DIALOG)
+
+ def setMap(self, map):
+ global mapDir
+ 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()