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()