├── .gitignore
├── DejaVuSansMono.ttf
├── README.md
├── build_map.py
├── logo.png
├── main_form.glade
├── map.png
├── map_form.glade
├── nrsc5.py
├── nrsc5_gui.py
├── radar_key.png
├── radar_key.svg
├── screenshots
├── album_art_tab.png
├── bookmarks_tab.png
├── info_tab.png
├── map_tab.png
└── settings_tab.png
└── weather.png
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 | /aas/
3 | /map/
4 | /config.json
5 | /station_logos.json
6 |
--------------------------------------------------------------------------------
/DejaVuSansMono.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/DejaVuSansMono.ttf
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | NRSC5-GUI is a graphical interface for [nrsc5](https://github.com/theori-io/nrsc5).
2 | It makes it easy to play your favorite FM HD radio stations using an RTL-SDR dongle.
3 | It will also display weather radar and traffic maps if the radio station provides them.
4 |
5 | # Dependencies
6 |
7 | The folowing programs are required to run NRSC5-GUI
8 |
9 | * [Python 3](https://www.python.org/downloads/release)
10 | * [PyGObject](https://pygobject.readthedocs.io/en/latest/)
11 | * [Pillow](https://pillow.readthedocs.io/en/stable/)
12 | * [PyAudio](https://people.csail.mit.edu/hubert/pyaudio/)
13 | * [nrsc5](https://github.com/theori-io/nrsc5)
14 |
15 |
16 | # Setup
17 | 1. Install the latest version of Python 3, PyGObject, Pillow and PyAudio.
18 | 2. Compile and install nrsc5.
19 | 3. Install nrsc5-gui files in a directory where you have write permissions.
20 |
21 | The configuration files will be created in the same directory as nrsc5-gui.py.
22 | An aas directory will be created for downloaded files and a map directory will be created to
23 | store weather & traffic maps in.
24 |
25 | # Usage
26 | Open the Settings tab and enter the frequency in MHz of the station you want to play.
27 | Select the stream (1 is the main stream, some stations have additional streams).
28 | Set the gain to Auto (you can specify the RF gain in dB in case auto doesn't work for your station).
29 | You can enter a PPM correction value if your RTL-SDR dongle has an offset.
30 | If you have more than one RTL-SDR dongle, you can enter the device number for the one you want to use.
31 |
32 | After setting your station, click the play button to start playing the station.
33 | It will take about 10 seconds to begin playing if the signal strength is good.
34 | Note: The settings cannot be changed while playing.
35 |
36 | ## Album Art & Track Info
37 | Some stations will send album art and station logos. These will be displayed in the Album Art tab if available.
38 | Most stations will send the song title, artist, and album. These are displayed in the Track Info pane if available.
39 |
40 | ## Bookmarks
41 | When a station is playing, you can click the Bookmark Station button to add it to the bookmarks list.
42 | You can click on the name in the bookmarks list to edit it.
43 | Double click the station to switch to it.
44 | Click the Delete Bookmark button to delete it.
45 |
46 | ## Station Info
47 | The station name and slogan is displayed in the Info tab.
48 | The current audio bit rate is displayed in the Info tab. The bit rate is also shown on the status bar.
49 |
50 | ### Signal Strength
51 | The Modulation Error Ratio for the lower and upper sidebands is displayed in the Info tab.
52 | High MER values for both sidebands indicates a strong signal.
53 | The Bit Error Rate is shown in the Info tab. High BER values will cause the audio to glitch or drop out.
54 | The average BER is also shown on the status bar.
55 |
56 | ## Maps
57 | When listening to radio stations operated by [iHeartMedia](https://iheartmedia.com/iheartmedia/stations),
58 | you can view live traffic maps and weather radar. The maps are typically sent every few minutes and
59 | will be displayed once loaded.
60 | Clicking the Map Viewer button on the toolbar will open a larger window to view the maps at full size.
61 | The weather radar information from the last 12 hours will be stored and can be played back by
62 | selecting the Animate Radar option. The delay between frames (in seconds) can be adjusted by changing
63 | the Animation Speed value.
64 |
65 | ### Map Customization
66 | The default map used for the weather radar comes from [OpenStreetMap](https://www.openstreetmap.org).
67 | You can replace the map.png image with a map from any website that will let you export map tiles.
68 | The tiles used are (35,84) to (81,110) at zoom level 8. The image is 12032x6912 pixels.
69 | The portion of the map used for your area is cached in the map directory.
70 | If you change the map image, you will have to delete the base_map images in the map directory so
71 | they will be recreated with the new map.
72 |
73 | ## Screenshots
74 | 
75 | 
76 | 
77 |
78 | 
79 | 
80 |
81 | ## Version History
82 | 1.0.0 Initial Release
83 | 1.0.1 Fixed compatibility with display scaling
84 | 1.1.0 Added weather radar and traffic map viewer
85 | 2.0.0 Updated to use the nrsc5 API
86 |
--------------------------------------------------------------------------------
/build_map.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | """This program generates the base map for weather radar images.
4 | It fetches map tiles from OpenStreetMap and assembles them into a PNG file."""
5 |
6 | import io
7 | import urllib.request
8 | from PIL import Image
9 |
10 | START_X, START_Y = 35, 84
11 | END_X, END_Y = 81, 110
12 | ZOOM_LEVEL = 8
13 | TILE_SERVER = "https://a.tile.openstreetmap.org"
14 |
15 | WIDTH = END_X - START_X + 1
16 | HEIGHT = END_Y - START_Y + 1
17 | BASE_MAP = Image.new("RGB", (WIDTH*256, HEIGHT*256), "white")
18 |
19 | for x in range(WIDTH):
20 | for y in range(HEIGHT):
21 | tile_url = "{}/{}/{}/{}.png".format(TILE_SERVER, ZOOM_LEVEL, START_X + x, START_Y + y)
22 | print(tile_url)
23 | with urllib.request.urlopen(tile_url) as response:
24 | tile_png = response.read()
25 | BASE_MAP.paste(Image.open(io.BytesIO(tile_png)), (x*256, y*256))
26 |
27 | BASE_MAP.save("map.png")
28 |
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/logo.png
--------------------------------------------------------------------------------
/main_form.glade:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
12 |
17 |
23 |
28 |
35 |
40 |
1144 |
1145 |
--------------------------------------------------------------------------------
/map.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/map.png
--------------------------------------------------------------------------------
/map_form.glade:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 0.10000000000000001
7 | 2
8 | 0.5
9 | 0.050000000000000003
10 | 0.25
11 |
12 |
13 | False
14 | Map Viewer
15 | True
16 |
17 |
18 |
19 |
20 |
21 | True
22 | False
23 | 2
24 | 5
25 |
26 |
27 | True
28 | False
29 | 0
30 | in
31 |
32 |
33 | True
34 | False
35 | 6
36 | 6
37 | 6
38 | 6
39 |
40 |
41 | True
42 | True
43 |
44 |
45 | True
46 | False
47 |
48 |
49 | True
50 | False
51 | gtk-missing-image
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | True
63 | False
64 | <b>Map Viewer</b>
65 | True
66 |
67 |
68 |
69 |
70 |
71 |
72 | True
73 | False
74 | 0
75 | in
76 |
77 |
78 | True
79 | False
80 | 6
81 | 6
82 | 6
83 | 6
84 |
85 |
86 | True
87 | False
88 | 7
89 | 5
90 |
91 |
92 | Weather Radar
93 | False
94 | True
95 | True
96 | False
97 | Display Weather Radar
98 | True
99 | True
100 |
101 |
102 |
103 | GTK_FILL
104 | GTK_FILL
105 | 10
106 |
107 |
108 |
109 |
110 | Traffic Map
111 | False
112 | True
113 | True
114 | False
115 | Display Traffic Map
116 | True
117 | rad_map_weather
118 |
119 |
120 |
121 | 1
122 | 2
123 | GTK_FILL
124 | GTK_FILL
125 | 10
126 |
127 |
128 |
129 |
130 | Animate Radar
131 | False
132 | True
133 | True
134 | False
135 | Play the animated radar
136 | True
137 |
138 |
139 |
140 | 3
141 | 4
142 | GTK_FILL
143 | GTK_FILL
144 | 10
145 |
146 |
147 |
148 |
149 | Scale Radar
150 | False
151 | True
152 | True
153 | False
154 | Scale radar to 600x600 px
155 | True
156 | True
157 |
158 |
159 |
160 | 2
161 | 3
162 | GTK_FILL
163 | GTK_FILL
164 | 10
165 |
166 |
167 |
168 |
169 | True
170 | True
171 | Time between frames (seconds)
172 | ●
173 | False
174 | False
175 | adj_speed
176 | 2
177 |
178 |
179 |
180 | 6
181 | 7
182 | GTK_FILL
183 | GTK_FILL
184 |
185 |
186 |
187 |
188 | True
189 | False
190 | 5
191 | Animation Speed
192 | 0
193 |
194 |
195 | 7
196 | 6
197 | GTK_FILL
198 | GTK_FILL
199 |
200 |
201 |
202 |
203 | True
204 | False
205 |
206 |
207 | 4
208 | 5
209 | GTK_FILL
210 | GTK_FILL
211 |
212 |
213 |
214 |
215 | True
216 | False
217 | 1
218 | radar_key.png
219 |
220 |
221 | 7
222 | 8
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 | True
232 | False
233 | <b>Settings</b>
234 | True
235 |
236 |
237 |
238 |
239 | 1
240 | 2
241 | GTK_FILL
242 |
243 |
244 |
245 |
246 |
247 |
248 |
--------------------------------------------------------------------------------
/nrsc5.py:
--------------------------------------------------------------------------------
1 | import collections
2 | import ctypes
3 | import enum
4 | import math
5 | import platform
6 | import socket
7 |
8 |
9 | class EventType(enum.Enum):
10 | LOST_DEVICE = 0
11 | IQ = 1
12 | SYNC = 2
13 | LOST_SYNC = 3
14 | MER = 4
15 | BER = 5
16 | HDC = 6
17 | AUDIO = 7
18 | ID3 = 8
19 | SIG = 9
20 | LOT = 10
21 | SIS = 11
22 |
23 |
24 | class ServiceType(enum.Enum):
25 | AUDIO = 0
26 | DATA = 1
27 |
28 |
29 | class ComponentType(enum.Enum):
30 | AUDIO = 0
31 | DATA = 1
32 |
33 |
34 | class MIMEType(enum.Enum):
35 | PRIMARY_IMAGE = 0xBE4B7536
36 | STATION_LOGO = 0xD9C72536
37 | NAVTEQ = 0x2D42AC3E
38 | HERE_TPEG = 0x82F03DFC
39 | HERE_IMAGE = 0xB7F03DFC
40 | HD_TMC = 0xEECB55B6
41 | HDC = 0x4DC66C5A
42 | TEXT = 0xBB492AAC
43 | JPEG = 0x1E653E9C
44 | PNG = 0x4F328CA0
45 | TTN_TPEG_1 = 0xB39EBEB2
46 | TTN_TPEG_2 = 0x4EB03469
47 | TTN_TPEG_3 = 0x52103469
48 | TTN_STM_TRAFFIC = 0xFF8422D7
49 | TTN_STM_WEATHER = 0xEF042E96
50 |
51 |
52 | class Access(enum.Enum):
53 | PUBLIC = 0
54 | RESTRICTED = 1
55 |
56 |
57 | class ServiceDataType(enum.Enum):
58 | NON_SPECIFIC = 0
59 | NEWS = 1
60 | SPORTS = 3
61 | WEATHER = 29
62 | EMERGENCY = 31
63 | TRAFFIC = 65
64 | IMAGE_MAPS = 66
65 | TEXT = 80
66 | ADVERTISING = 256
67 | FINANCIAL = 257
68 | STOCK_TICKER = 258
69 | NAVIGATION = 259
70 | ELECTRONIC_PROGRAM_GUIDE = 260
71 | AUDIO = 261
72 | PRIVATE_DATA_NETWORK = 262
73 | SERVICE_MAINTENANCE = 263
74 | HD_RADIO_SYSTEM_SERVICES = 264
75 | AUDIO_RELATED_DATA = 265
76 |
77 |
78 | class ProgramType(enum.Enum):
79 | UNDEFINED = 0
80 | NEWS = 1
81 | INFORMATION = 2
82 | SPORTS = 3
83 | TALK = 4
84 | ROCK = 5
85 | CLASSIC_ROCK = 6
86 | ADULT_HITS = 7
87 | SOFT_ROCK = 8
88 | TOP_40 = 9
89 | COUNTRY = 10
90 | OLDIES = 11
91 | SOFT = 12
92 | NOSTALGIA = 13
93 | JAZZ = 14
94 | CLASSICAL = 15
95 | RHYTHM_AND_BLUES = 16
96 | SOFT_RHYTHM_AND_BLUES = 17
97 | FOREIGN_LANGUAGE = 18
98 | RELIGIOUS_MUSIC = 19
99 | RELIGIOUS_TALK = 20
100 | PERSONALITY = 21
101 | PUBLIC = 22
102 | COLLEGE = 23
103 | SPANISH_TALK = 24
104 | SPANISH_MUSIC = 25
105 | HIP_HOP = 26
106 | WEATHER = 29
107 | EMERGENCY_TEST = 30
108 | EMERGENCY = 31
109 | TRAFFIC = 65
110 | SPECIAL_READING_SERVICES = 76
111 |
112 |
113 | IQ = collections.namedtuple("IQ", ["data"])
114 | MER = collections.namedtuple("MER", ["lower", "upper"])
115 | BER = collections.namedtuple("BER", ["cber"])
116 | HDC = collections.namedtuple("HDC", ["program", "data"])
117 | Audio = collections.namedtuple("Audio", ["program", "data"])
118 | UFID = collections.namedtuple("UFID", ["owner", "id"])
119 | XHDR = collections.namedtuple("XHDR", ["mime", "param", "lot"])
120 | ID3 = collections.namedtuple("ID3", ["program", "title", "artist", "album", "genre", "ufid", "xhdr"])
121 | SIGAudioComponent = collections.namedtuple("SIGAudioComponent", ["port", "type", "mime"])
122 | SIGDataComponent = collections.namedtuple("SIGDataComponent", ["port", "service_data_type", "type", "mime"])
123 | SIGComponent = collections.namedtuple("SIGComponent", ["type", "id", "audio", "data"])
124 | SIGService = collections.namedtuple("SIGService", ["type", "number", "name", "components"])
125 | SIG = collections.namedtuple("SIG", ["services"])
126 | LOT = collections.namedtuple("LOT", ["port", "lot", "mime", "name", "data"])
127 | SISAudioService = collections.namedtuple("SISAudioService", ["program", "access", "type", "sound_exp"])
128 | SISDataService = collections.namedtuple("SISDataService", ["access", "type", "mime_type"])
129 | SIS = collections.namedtuple("SIS", ["country_code", "fcc_facility_id", "name", "slogan", "message", "alert",
130 | "latitude", "longitude", "altitude", "audio_services", "data_services"])
131 |
132 |
133 | class _IQ(ctypes.Structure):
134 | _fields_ = [
135 | ("data", ctypes.POINTER(ctypes.c_char)),
136 | ("count", ctypes.c_size_t),
137 | ]
138 |
139 |
140 | class _MER(ctypes.Structure):
141 | _fields_ = [
142 | ("lower", ctypes.c_float),
143 | ("upper", ctypes.c_float),
144 | ]
145 |
146 |
147 | class _BER(ctypes.Structure):
148 | _fields_ = [
149 | ("cber", ctypes.c_float),
150 | ]
151 |
152 |
153 | class _HDC(ctypes.Structure):
154 | _fields_ = [
155 | ("program", ctypes.c_uint),
156 | ("data", ctypes.POINTER(ctypes.c_char)),
157 | ("count", ctypes.c_size_t),
158 | ]
159 |
160 |
161 | class _Audio(ctypes.Structure):
162 | _fields_ = [
163 | ("program", ctypes.c_uint),
164 | ("data", ctypes.POINTER(ctypes.c_char)),
165 | ("count", ctypes.c_size_t),
166 | ]
167 |
168 |
169 | class _UFID(ctypes.Structure):
170 | _fields_ = [
171 | ("owner", ctypes.c_char_p),
172 | ("id", ctypes.c_char_p),
173 | ]
174 |
175 |
176 | class _XHDR(ctypes.Structure):
177 | _fields_ = [
178 | ("mime", ctypes.c_uint32),
179 | ("param", ctypes.c_int),
180 | ("lot", ctypes.c_int),
181 | ]
182 |
183 |
184 | class _ID3(ctypes.Structure):
185 | _fields_ = [
186 | ("program", ctypes.c_uint),
187 | ("title", ctypes.c_char_p),
188 | ("artist", ctypes.c_char_p),
189 | ("album", ctypes.c_char_p),
190 | ("genre", ctypes.c_char_p),
191 | ("ufid", _UFID),
192 | ("xhdr", _XHDR),
193 | ]
194 |
195 |
196 | class _SIGData(ctypes.Structure):
197 | _fields_ = [
198 | ("port", ctypes.c_uint16),
199 | ("service_data_type", ctypes.c_uint16),
200 | ("type", ctypes.c_uint8),
201 | ("mime", ctypes.c_uint32),
202 | ]
203 |
204 |
205 | class _SIGAudio(ctypes.Structure):
206 | _fields_ = [
207 | ("port", ctypes.c_uint8),
208 | ("type", ctypes.c_uint8),
209 | ("mime", ctypes.c_uint32),
210 | ]
211 |
212 |
213 | class _SIGUnion(ctypes.Union):
214 | _fields_ = [
215 | ("audio", _SIGAudio),
216 | ("data", _SIGData),
217 | ]
218 |
219 |
220 | class _SIGComponent(ctypes.Structure):
221 | pass
222 |
223 |
224 | _SIGComponent._fields_ = [
225 | ("next", ctypes.POINTER(_SIGComponent)),
226 | ("type", ctypes.c_uint8),
227 | ("id", ctypes.c_uint8),
228 | ("u", _SIGUnion),
229 | ]
230 |
231 |
232 | class _SIGService(ctypes.Structure):
233 | pass
234 |
235 |
236 | _SIGService._fields_ = [
237 | ("next", ctypes.POINTER(_SIGService)),
238 | ("type", ctypes.c_uint8),
239 | ("number", ctypes.c_uint16),
240 | ("name", ctypes.c_char_p),
241 | ("components", ctypes.POINTER(_SIGComponent)),
242 | ]
243 |
244 |
245 | class _SIG(ctypes.Structure):
246 | _fields_ = [
247 | ("services", ctypes.POINTER(_SIGService)),
248 | ]
249 |
250 |
251 | class _LOT(ctypes.Structure):
252 | _fields_ = [
253 | ("port", ctypes.c_uint16),
254 | ("lot", ctypes.c_uint),
255 | ("size", ctypes.c_uint),
256 | ("mime", ctypes.c_uint32),
257 | ("name", ctypes.c_char_p),
258 | ("data", ctypes.POINTER(ctypes.c_char)),
259 | ]
260 |
261 |
262 | class _SISAudioService(ctypes.Structure):
263 | pass
264 |
265 |
266 | _SISAudioService._fields_ = [
267 | ("next", ctypes.POINTER(_SISAudioService)),
268 | ("program", ctypes.c_uint),
269 | ("access", ctypes.c_uint),
270 | ("type", ctypes.c_uint),
271 | ("sound_exp", ctypes.c_uint),
272 | ]
273 |
274 |
275 | class _SISDataService(ctypes.Structure):
276 | pass
277 |
278 |
279 | _SISDataService._fields_ = [
280 | ("next", ctypes.POINTER(_SISDataService)),
281 | ("access", ctypes.c_uint),
282 | ("type", ctypes.c_uint),
283 | ("mime_type", ctypes.c_uint32),
284 | ]
285 |
286 |
287 | class _SIS(ctypes.Structure):
288 | _fields_ = [
289 | ("country_code", ctypes.c_char_p),
290 | ("fcc_facility_id", ctypes.c_int),
291 | ("name", ctypes.c_char_p),
292 | ("slogan", ctypes.c_char_p),
293 | ("message", ctypes.c_char_p),
294 | ("alert", ctypes.c_char_p),
295 | ("latitude", ctypes.c_float),
296 | ("longitude", ctypes.c_float),
297 | ("altitude", ctypes.c_int),
298 | ("audio_services", ctypes.POINTER(_SISAudioService)),
299 | ("data_services", ctypes.POINTER(_SISDataService)),
300 | ]
301 |
302 |
303 | class _EventUnion(ctypes.Union):
304 | _fields_ = [
305 | ("iq", _IQ),
306 | ("mer", _MER),
307 | ("ber", _BER),
308 | ("hdc", _HDC),
309 | ("audio", _Audio),
310 | ("id3", _ID3),
311 | ("sig", _SIG),
312 | ("lot", _LOT),
313 | ("sis", _SIS),
314 | ]
315 |
316 |
317 | class _Event(ctypes.Structure):
318 | _fields_ = [
319 | ("event", ctypes.c_uint),
320 | ("u", _EventUnion),
321 | ]
322 |
323 |
324 | class NRSC5Error(Exception):
325 | pass
326 |
327 |
328 | class NRSC5:
329 | libnrsc5 = None
330 |
331 | def _load_library(self):
332 | if NRSC5.libnrsc5 is None:
333 | if platform.system() == "Windows":
334 | lib_name = "libnrsc5.dll"
335 | elif platform.system() == "Linux":
336 | lib_name = "libnrsc5.so"
337 | elif platform.system() == "Darwin":
338 | lib_name = "libnrsc5.dylib"
339 | else:
340 | raise NRSC5Error("Unsupported platform: " + platform.system())
341 | NRSC5.libnrsc5 = ctypes.cdll.LoadLibrary(lib_name)
342 | self.radio = ctypes.c_void_p()
343 |
344 | @staticmethod
345 | def _decode(string):
346 | if string is None:
347 | return string
348 | return string.decode()
349 |
350 | def _callback_wrapper(self, c_evt):
351 | c_evt = c_evt.contents
352 | evt = None
353 |
354 | try:
355 | evt_type = EventType(c_evt.event)
356 | except ValueError:
357 | return
358 |
359 | if evt_type == EventType.IQ:
360 | iq = c_evt.u.iq
361 | evt = IQ(iq.data[:iq.count])
362 | elif evt_type == EventType.MER:
363 | mer = c_evt.u.mer
364 | evt = MER(mer.lower, mer.upper)
365 | elif evt_type == EventType.BER:
366 | ber = c_evt.u.ber
367 | evt = BER(ber.cber)
368 | elif evt_type == EventType.HDC:
369 | hdc = c_evt.u.hdc
370 | evt = HDC(hdc.program, hdc.data[:hdc.count])
371 | elif evt_type == EventType.AUDIO:
372 | audio = c_evt.u.audio
373 | evt = Audio(audio.program, audio.data[:audio.count * 2])
374 | elif evt_type == EventType.ID3:
375 | id3 = c_evt.u.id3
376 |
377 | ufid = None
378 | if id3.ufid.owner or id3.ufid.id:
379 | ufid = UFID(self._decode(id3.ufid.owner), self._decode(id3.ufid.id))
380 |
381 | xhdr = None
382 | if id3.xhdr.mime != 0 or id3.xhdr.param != -1 or id3.xhdr.lot != -1:
383 | xhdr = XHDR(None if id3.xhdr.mime == 0 else MIMEType(id3.xhdr.mime),
384 | None if id3.xhdr.param == -1 else id3.xhdr.param,
385 | None if id3.xhdr.lot == -1 else id3.xhdr.lot)
386 |
387 | evt = ID3(id3.program, self._decode(id3.title), self._decode(id3.artist),
388 | self._decode(id3.album), self._decode(id3.genre), ufid, xhdr)
389 | elif evt_type == EventType.SIG:
390 | evt = []
391 | service_ptr = c_evt.u.sig.services
392 | while service_ptr:
393 | service = service_ptr.contents
394 | components = []
395 | component_ptr = service.components
396 | while component_ptr:
397 | component = component_ptr.contents
398 | component_type = ComponentType(component.type)
399 | if component_type == ComponentType.AUDIO:
400 | audio = SIGAudioComponent(component.u.audio.port, ProgramType(component.u.audio.type),
401 | MIMEType(component.u.audio.mime))
402 | components.append(SIGComponent(component_type, component.id, audio, None))
403 | if component_type == ComponentType.DATA:
404 | data = SIGDataComponent(component.u.data.port,
405 | ServiceDataType(component.u.data.service_data_type),
406 | component.u.data.type, MIMEType(component.u.data.mime))
407 | components.append(SIGComponent(component_type, component.id, None, data))
408 | component_ptr = component.next
409 | evt.append(SIGService(ServiceType(service.type), service.number,
410 | self._decode(service.name), components))
411 | service_ptr = service.next
412 | elif evt_type == EventType.LOT:
413 | lot = c_evt.u.lot
414 | evt = LOT(lot.port, lot.lot, MIMEType(lot.mime), self._decode(lot.name), lot.data[:lot.size])
415 | elif evt_type == EventType.SIS:
416 | sis = c_evt.u.sis
417 |
418 | latitude, longitude, altitude = None, None, None
419 | if not math.isnan(sis.latitude):
420 | latitude, longitude, altitude = sis.latitude, sis.longitude, sis.altitude
421 |
422 | audio_services = []
423 | audio_service_ptr = sis.audio_services
424 | while audio_service_ptr:
425 | asd = audio_service_ptr.contents
426 | audio_services.append(SISAudioService(asd.program, Access(asd.access),
427 | ProgramType(asd.type), asd.sound_exp))
428 | audio_service_ptr = asd.next
429 |
430 | data_services = []
431 | data_service_ptr = sis.data_services
432 | while data_service_ptr:
433 | dsd = data_service_ptr.contents
434 | data_services.append(SISDataService(Access(dsd.access), ServiceDataType(dsd.type), dsd.mime_type))
435 | data_service_ptr = dsd.next
436 |
437 | evt = SIS(self._decode(sis.country_code), sis.fcc_facility_id, self._decode(sis.name),
438 | self._decode(sis.slogan), self._decode(sis.message), self._decode(sis.alert),
439 | latitude, longitude, altitude, audio_services, data_services)
440 | self.callback(evt_type, evt)
441 |
442 | def __init__(self, callback):
443 | self._load_library()
444 | self.radio = ctypes.c_void_p()
445 | self.callback = callback
446 |
447 | @staticmethod
448 | def get_version():
449 | version = ctypes.c_char_p()
450 | NRSC5.libnrsc5.nrsc5_get_version(ctypes.byref(version))
451 | return version.value.decode()
452 |
453 | @staticmethod
454 | def service_data_type_name(type):
455 | name = ctypes.c_char_p()
456 | NRSC5.libnrsc5.nrsc5_service_data_type_name(type.value, ctypes.byref(name))
457 | return name.value.decode()
458 |
459 | @staticmethod
460 | def program_type_name(type):
461 | name = ctypes.c_char_p()
462 | NRSC5.libnrsc5.nrsc5_program_type_name(type.value, ctypes.byref(name))
463 | return name.value.decode()
464 |
465 | def open(self, device_index):
466 | result = NRSC5.libnrsc5.nrsc5_open(ctypes.byref(self.radio), device_index)
467 | if result != 0:
468 | raise NRSC5Error("Failed to open RTL-SDR.")
469 | self._set_callback()
470 |
471 | def open_pipe(self):
472 | result = NRSC5.libnrsc5.nrsc5_open_pipe(ctypes.byref(self.radio))
473 | if result != 0:
474 | raise NRSC5Error("Failed to open pipe.")
475 | self._set_callback()
476 |
477 | def open_rtltcp(self, host, port):
478 | s = socket.create_connection((host, port))
479 | result = NRSC5.libnrsc5.nrsc5_open_rtltcp(ctypes.byref(self.radio), s.detach())
480 | if result != 0:
481 | raise NRSC5Error("Failed to open rtl_tcp.")
482 | self._set_callback()
483 |
484 | def close(self):
485 | NRSC5.libnrsc5.nrsc5_close(self.radio)
486 |
487 | def start(self):
488 | NRSC5.libnrsc5.nrsc5_start(self.radio)
489 |
490 | def stop(self):
491 | NRSC5.libnrsc5.nrsc5_stop(self.radio)
492 |
493 | def set_freq_correction(self, ppm_error):
494 | result = NRSC5.libnrsc5.nrsc5_set_freq_correction(self.radio, ppm_error)
495 | if result != 0:
496 | raise NRSC5Error("Failed to set frequency correction.")
497 |
498 | def get_frequency(self):
499 | frequency = ctypes.c_float()
500 | NRSC5.libnrsc5.nrsc5_get_frequency(self.radio, ctypes.byref(frequency))
501 | return frequency.value
502 |
503 | def set_frequency(self, freq):
504 | result = NRSC5.libnrsc5.nrsc5_set_frequency(self.radio, ctypes.c_float(freq))
505 | if result != 0:
506 | raise NRSC5Error("Failed to set frequency.")
507 |
508 | def get_gain(self):
509 | gain = ctypes.c_float()
510 | NRSC5.libnrsc5.nrsc5_get_gain(self.radio, ctypes.byref(gain))
511 | return gain.value
512 |
513 | def set_gain(self, gain):
514 | result = NRSC5.libnrsc5.nrsc5_set_gain(self.radio, ctypes.c_float(gain))
515 | if result != 0:
516 | raise NRSC5Error("Failed to set gain.")
517 |
518 | def set_auto_gain(self, enabled):
519 | NRSC5.libnrsc5.nrsc5_set_auto_gain(self.radio, int(enabled))
520 |
521 | def _set_callback(self):
522 | def callback_closure(evt, opaque):
523 | self._callback_wrapper(evt)
524 |
525 | self.callback_func = ctypes.CFUNCTYPE(None, ctypes.POINTER(_Event), ctypes.c_void_p)(callback_closure)
526 | NRSC5.libnrsc5.nrsc5_set_callback(self.radio, self.callback_func, None)
527 |
528 | def pipe_samples_cu8(self, samples):
529 | if len(samples) % 4 != 0:
530 | raise NRSC5Error("len(samples) must be a multiple of 4.")
531 | result = NRSC5.libnrsc5.nrsc5_pipe_samples_cu8(self.radio, samples, len(samples))
532 | if result != 0:
533 | raise NRSC5Error("Failed to pipe samples.")
534 |
535 | def pipe_samples_cs16(self, samples):
536 | if len(samples) % 4 != 0:
537 | raise NRSC5Error("len(samples) must be a multiple of 4.")
538 | result = NRSC5.libnrsc5.nrsc5_pipe_samples_cs16(self.radio, samples, len(samples) // 2)
539 | if result != 0:
540 | raise NRSC5Error("Failed to pipe samples.")
541 |
--------------------------------------------------------------------------------
/nrsc5_gui.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | # NRSC5 GUI - A graphical interface for nrsc5
4 | # Copyright (C) 2017-2019 Cody Nybo & Clayton Smith
5 | #
6 | # This program is free software: you can redistribute it and/or modify
7 | # it under the terms of the GNU General Public License as published by
8 | # the Free Software Foundation, either version 3 of the License, or
9 | # (at your option) any later version.
10 | #
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 | #
16 | # You should have received a copy of the GNU General Public License
17 | # along with this program. If not, see .
18 |
19 | import glob
20 | import io
21 | import json
22 | import logging
23 | import math
24 | import os
25 | import queue
26 | import re
27 | import sys
28 | import threading
29 | import time
30 | from datetime import datetime, timezone
31 | from PIL import Image, ImageFont, ImageDraw
32 | import pyaudio
33 |
34 | import gi
35 | gi.require_version("Gtk", "3.0")
36 | from gi.repository import Gtk, GObject, Gdk, GdkPixbuf, GLib
37 |
38 | import nrsc5
39 |
40 |
41 | class NRSC5GUI(object):
42 | AUDIO_SAMPLE_RATE = 44100
43 | AUDIO_SAMPLES_PER_FRAME = 2048
44 | MAP_FILE = "map.png"
45 | VERSION = "2.0.0"
46 |
47 | log_level = 20 # decrease to 10 to enable debug logs
48 |
49 | def __init__(self):
50 | logging.basicConfig(level=self.log_level,
51 | format="%(asctime)s %(levelname)-5s %(filename)s:%(lineno)d: %(message)s",
52 | datefmt="%H:%M:%S")
53 |
54 | GObject.threads_init()
55 |
56 | self.get_controls() # get controls and windows
57 | self.init_stream_info() # initilize stream info and clear status widgets
58 |
59 | self.radio = None
60 | self.audio_queue = queue.Queue(maxsize=64)
61 | self.audio_thread = threading.Thread(target=self.audio_worker)
62 | self.playing = False
63 | self.status_timer = None
64 | self.image_changed = False
65 | self.xhdr_changed = False
66 | self.last_image = ""
67 | self.last_xhdr = ""
68 | self.station_str = "" # current station frequency (string)
69 | self.stream_num = 0
70 | self.bookmarks = []
71 | self.station_logos = {}
72 | self.bookmarked = False
73 | self.map_viewer = None
74 | self.weather_maps = [] # list of current weathermaps sorted by time
75 | self.traffic_map = Image.new("RGB", (600, 600), "white")
76 | self.map_tiles = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]
77 | self.map_data = {
78 | "map_mode": 1,
79 | "weather_time": 0,
80 | "weather_pos": [0, 0, 0, 0],
81 | "weather_now": "",
82 | "weather_id": "",
83 | "viewer_config": {
84 | "mode": 1,
85 | "animate": False,
86 | "scale": True,
87 | "window_pos": (0, 0),
88 | "window_size": (782, 632),
89 | "animation_speed": 0.5
90 | }
91 | }
92 |
93 | # setup bookmarks listview
94 | name_renderer = Gtk.CellRendererText()
95 | name_renderer.set_property("editable", True)
96 | name_renderer.connect("edited", self.on_bookmark_name_edited)
97 |
98 | col_station = Gtk.TreeViewColumn("Station", Gtk.CellRendererText(), text=0)
99 | col_name = Gtk.TreeViewColumn("Name", name_renderer, text=1)
100 |
101 | col_station.set_resizable(True)
102 | col_station.set_sort_column_id(2)
103 | col_name.set_resizable(True)
104 | col_name.set_sort_column_id(1)
105 |
106 | self.lv_bookmarks.append_column(col_station)
107 | self.lv_bookmarks.append_column(col_name)
108 |
109 | self.load_settings()
110 | self.process_weather_maps()
111 |
112 | self.audio_thread.start()
113 |
114 | def display_logo(self):
115 | if self.station_str in self.station_logos:
116 | # show station logo if it's cached
117 | logo = os.path.join(self.aas_dir, self.station_logos[self.station_str][self.stream_num])
118 | if os.path.isfile(logo):
119 | self.stream_info["logo"] = self.station_logos[self.station_str][self.stream_num]
120 | pixbuf = GdkPixbuf.Pixbuf.new_from_file(logo)
121 | pixbuf = pixbuf.scale_simple(200, 200, GdkPixbuf.InterpType.HYPER)
122 | self.img_cover.set_from_pixbuf(pixbuf)
123 | else:
124 | # add entry in database for the station if it doesn't exist
125 | self.station_logos[self.station_str] = ["", "", "", ""]
126 |
127 | def on_btn_play_clicked(self, _btn):
128 | """start playback"""
129 | if not self.playing:
130 |
131 | # update all of the spin buttons to prevent the text from sticking
132 | self.spin_freq.update()
133 | self.spin_stream.update()
134 | self.spin_gain.update()
135 | self.spin_ppm.update()
136 | self.spin_rtl.update()
137 |
138 | # start the timer
139 | self.status_timer = threading.Timer(1, self.check_status)
140 | self.status_timer.start()
141 |
142 | # disable the controls
143 | self.spin_freq.set_sensitive(False)
144 | self.spin_gain.set_sensitive(False)
145 | self.spin_ppm.set_sensitive(False)
146 | self.spin_rtl.set_sensitive(False)
147 | self.btn_play.set_sensitive(False)
148 | self.btn_stop.set_sensitive(True)
149 | self.cb_auto_gain.set_sensitive(False)
150 | self.playing = True
151 | self.last_xhdr = ""
152 |
153 | self.play()
154 |
155 | self.station_str = str(self.spin_freq.get_value())
156 | self.stream_num = int(self.spin_stream.get_value())-1
157 |
158 | self.display_logo()
159 |
160 | # check if station is bookmarked
161 | self.bookmarked = False
162 | freq = int((self.spin_freq.get_value()+0.005)*100) + int(self.spin_stream.get_value())
163 | for bookmark in self.bookmarks:
164 | if bookmark[2] == freq:
165 | self.bookmarked = True
166 | break
167 |
168 | self.btn_bookmark.set_sensitive(not self.bookmarked)
169 | if self.notebook_main.get_current_page() != 3:
170 | self.btn_delete.set_sensitive(self.bookmarked)
171 |
172 | def on_btn_stop_clicked(self, _btn):
173 | """stop playback"""
174 | if self.playing:
175 | self.playing = False
176 |
177 | # shutdown nrsc5
178 | if self.radio:
179 | self.radio.stop()
180 | self.radio.close()
181 | self.radio = None
182 |
183 | # stop timer
184 | self.status_timer.cancel()
185 | self.status_timer = None
186 |
187 | # enable controls
188 | if not self.cb_auto_gain.get_active():
189 | self.spin_gain.set_sensitive(True)
190 | self.spin_freq.set_sensitive(True)
191 | self.spin_ppm.set_sensitive(True)
192 | self.spin_rtl.set_sensitive(True)
193 | self.btn_play.set_sensitive(True)
194 | self.btn_stop.set_sensitive(False)
195 | self.btn_bookmark.set_sensitive(False)
196 | self.cb_auto_gain.set_sensitive(True)
197 |
198 | # clear stream info
199 | self.init_stream_info()
200 |
201 | self.btn_bookmark.set_sensitive(False)
202 | if self.notebook_main.get_current_page() != 3:
203 | self.btn_delete.set_sensitive(False)
204 |
205 | def on_btn_bookmark_clicked(self, _btn):
206 | # pack frequency and channel number into one int
207 | freq = int((self.spin_freq.get_value()+0.005)*100) + int(self.spin_stream.get_value())
208 |
209 | # create bookmark
210 | bookmark = [
211 | "{:4.1f}-{:1.0f}".format(self.spin_freq.get_value(), self.spin_stream.get_value()),
212 | self.stream_info["callsign"],
213 | freq
214 | ]
215 | self.bookmarked = True
216 | self.bookmarks.append(bookmark)
217 | self.ls_bookmarks.append(bookmark)
218 | self.btn_bookmark.set_sensitive(False)
219 |
220 | if self.notebook_main.get_current_page() != 3:
221 | self.btn_delete.set_sensitive(True)
222 |
223 | def on_btn_delete_clicked(self, _btn):
224 | # select current station if not on bookmarks page
225 | if self.notebook_main.get_current_page() != 3:
226 | station = int((self.spin_freq.get_value()+0.005)*100) + int(self.spin_stream.get_value())
227 | for i in range(len(self.ls_bookmarks)):
228 | if self.ls_bookmarks[i][2] == station:
229 | self.lv_bookmarks.set_cursor(i)
230 | break
231 |
232 | # get station of selected row
233 | model, tree_iter = self.lv_bookmarks.get_selection().get_selected()
234 | station = model.get_value(tree_iter, 2)
235 |
236 | # remove row
237 | model.remove(tree_iter)
238 |
239 | # remove bookmark
240 | for i in range(len(self.bookmarks)):
241 | if self.bookmarks[i][2] == station:
242 | self.bookmarks.pop(i)
243 | break
244 |
245 | if self.notebook_main.get_current_page() != 3 and self.playing:
246 | self.btn_bookmark.set_sensitive(True)
247 | self.bookmarked = False
248 |
249 | def on_btn_about_activate(self, _btn):
250 | """sets up and displays about dialog"""
251 | if self.about_dialog:
252 | self.about_dialog.present()
253 | return
254 |
255 | authors = [
256 | "Cody Nybo ",
257 | "Clayton Smith ",
258 | ]
259 |
260 | nrsc5_gui_license = """
261 | NRSC5 GUI - A graphical interface for nrsc5
262 | Copyright (C) 2017-2019 Cody Nybo & Clayton Smith
263 |
264 | This program is free software: you can redistribute it and/or modify
265 | it under the terms of the GNU General Public License as published by
266 | the Free Software Foundation, either version 3 of the License, or
267 | (at your option) any later version.
268 |
269 | This program is distributed in the hope that it will be useful,
270 | but WITHOUT ANY WARRANTY; without even the implied warranty of
271 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
272 | GNU General Public License for more details.
273 |
274 | You should have received a copy of the GNU General Public License
275 | along with this program. If not, see ."""
276 |
277 | about_dialog = Gtk.AboutDialog()
278 | about_dialog.set_transient_for(self.main_window)
279 | about_dialog.set_destroy_with_parent(True)
280 | about_dialog.set_name("NRSC5 GUI")
281 | about_dialog.set_version(self.VERSION)
282 | about_dialog.set_copyright("Copyright © 2017-2019 Cody Nybo & Clayton Smith")
283 | about_dialog.set_website("https://github.com/cmnybo/nrsc5-gui")
284 | about_dialog.set_comments("A graphical interface for nrsc5.")
285 | about_dialog.set_authors(authors)
286 | about_dialog.set_license(nrsc5_gui_license)
287 | about_dialog.set_logo(GdkPixbuf.Pixbuf.new_from_file("logo.png"))
288 |
289 | # callbacks for destroying the dialog
290 | def close(dialog, _response, editor):
291 | editor.about_dialog = None
292 | dialog.destroy()
293 |
294 | def delete_event(_dialog, _event, editor):
295 | editor.about_dialog = None
296 | return True
297 |
298 | about_dialog.connect("response", close, self)
299 | about_dialog.connect("delete-event", delete_event, self)
300 |
301 | self.about_dialog = about_dialog
302 | about_dialog.show()
303 |
304 | def on_spin_stream_value_changed(self, _spin):
305 | self.last_xhdr = ""
306 | self.stream_info["title"] = ""
307 | self.stream_info["album"] = ""
308 | self.stream_info["artist"] = ""
309 | self.stream_info["cover"] = ""
310 | self.stream_info["logo"] = ""
311 | self.stream_info["bitrate"] = 0
312 | self.stream_num = int(self.spin_stream.get_value())-1
313 | if self.playing:
314 | self.display_logo()
315 |
316 | def on_cb_auto_gain_toggled(self, btn):
317 | self.spin_gain.set_sensitive(not btn.get_active())
318 | self.lbl_gain.set_visible(btn.get_active())
319 |
320 | def on_lv_bookmarks_row_activated(self, treeview, path, _view_column):
321 | if path:
322 | # get station from bookmark row
323 | tree_iter = treeview.get_model().get_iter(path[0])
324 | station = treeview.get_model().get_value(tree_iter, 2)
325 |
326 | # set frequency and stream
327 | self.spin_freq.set_value(float(int(station/10)/10.0))
328 | self.spin_stream.set_value(station % 10)
329 |
330 | # stop playback if playing
331 | if self.playing:
332 | self.on_btn_stop_clicked(None)
333 |
334 | # play bookmarked station
335 | self.on_btn_play_clicked(None)
336 |
337 | def on_lv_bookmarks_sel_changed(self, _tree_selection):
338 | # enable delete button if bookmark is selected
339 | _, pathlist = self.lv_bookmarks.get_selection().get_selected_rows()
340 | self.btn_delete.set_sensitive(len(pathlist) != 0)
341 |
342 | def on_bookmark_name_edited(self, _cell, path, text, _data=None):
343 | # update name in listview
344 | tree_iter = self.ls_bookmarks.get_iter(path)
345 | self.ls_bookmarks.set(tree_iter, 1, text)
346 |
347 | # update name in bookmarks array
348 | for bookmark in self.bookmarks:
349 | if bookmark[2] == self.ls_bookmarks[path][2]:
350 | bookmark[1] = text
351 | break
352 |
353 | def on_notebook_main_switch_page(self, _notebook, _page, page_num):
354 | # disable delete button if not on bookmarks page and station is not bookmarked
355 | if page_num != 3 and (not self.bookmarked or not self.playing):
356 | self.btn_delete.set_sensitive(False)
357 | # enable delete button if not on bookmarks page and station is bookmarked
358 | elif page_num != 3 and self.bookmarked:
359 | self.btn_delete.set_sensitive(True)
360 | # enable delete button if on bookmarks page and a bookmark is selected
361 | else:
362 | _, tree_iter = self.lv_bookmarks.get_selection().get_selected()
363 | self.btn_delete.set_sensitive(tree_iter is not None)
364 |
365 | def on_rad_map_toggled(self, btn):
366 | if btn.get_active():
367 | if btn == self.rad_map_traffic:
368 | self.map_data["map_mode"] = 0
369 | map_file = os.path.join("map", "traffic_map.png")
370 | if os.path.isfile(map_file):
371 | map_img = Image.open(map_file).resize((200, 200), Image.LANCZOS)
372 | self.img_map.set_from_pixbuf(img_to_pixbuf(map_img))
373 | else:
374 | self.img_map.set_from_stock(Gtk.STOCK_MISSING_IMAGE, Gtk.IconSize.LARGE_TOOLBAR)
375 |
376 | elif btn == self.rad_map_weather:
377 | self.map_data["map_mode"] = 1
378 | if os.path.isfile(self.map_data["weather_now"]):
379 | map_img = Image.open(self.map_data["weather_now"]).resize((200, 200), Image.LANCZOS)
380 | self.img_map.set_from_pixbuf(img_to_pixbuf(map_img))
381 | else:
382 | self.img_map.set_from_stock(Gtk.STOCK_MISSING_IMAGE, Gtk.IconSize.LARGE_TOOLBAR)
383 |
384 | def on_btn_map_clicked(self, _btn):
385 | """open map viewer window"""
386 | if self.map_viewer is None:
387 | self.map_viewer = NRSC5Map(self, self.map_viewer_callback, self.map_data)
388 | self.map_viewer.map_window.show()
389 |
390 | def map_viewer_callback(self):
391 | """delete the map viewer"""
392 | self.map_viewer = None
393 |
394 | def play(self):
395 | self.radio = nrsc5.NRSC5(lambda type, evt: self.callback(type, evt))
396 | self.radio.open(int(self.spin_rtl.get_value()))
397 | self.radio.set_auto_gain(self.cb_auto_gain.get_active())
398 | self.radio.set_freq_correction(int(self.spin_ppm.get_value()))
399 |
400 | # set gain if auto gain is not selected
401 | if not self.cb_auto_gain.get_active():
402 | self.stream_info["gain"] = self.spin_gain.get_value()
403 | self.radio.set_gain(self.stream_info["gain"])
404 |
405 | self.radio.set_frequency(self.spin_freq.get_value() * 1e6)
406 | self.radio.start()
407 |
408 | def check_status(self):
409 | """update status information"""
410 | def update():
411 | Gdk.threads_enter()
412 | try:
413 | image_path = ""
414 | image = ""
415 | ber = [self.stream_info["ber"][i]*100 for i in range(4)]
416 | self.txt_title.set_text(self.stream_info["title"])
417 | self.txt_artist.set_text(self.stream_info["artist"])
418 | self.txt_album.set_text(self.stream_info["album"])
419 | self.lbl_bitrate.set_label("{:3.1f} kbps".format(self.stream_info["bitrate"]))
420 | self.lbl_bitrate2.set_label("{:3.1f} kbps".format(self.stream_info["bitrate"]))
421 | self.lbl_error.set_label("{:2.2f}% Error ".format(ber[1]))
422 | self.lbl_callsign.set_label(" " + self.stream_info["callsign"])
423 | self.lbl_name.set_label(self.stream_info["callsign"])
424 | self.lbl_slogan.set_label(self.stream_info["slogan"])
425 | self.lbl_slogan.set_tooltip_text(self.stream_info["slogan"])
426 | self.lbl_mer_lower.set_label("{:1.2f} dB".format(self.stream_info["mer"][0]))
427 | self.lbl_mer_upper.set_label("{:1.2f} dB".format(self.stream_info["mer"][1]))
428 | self.lbl_ber_now.set_label("{:1.3f}% (Now)".format(ber[0]))
429 | self.lbl_ber_avg.set_label("{:1.3f}% (Avg)".format(ber[1]))
430 | self.lbl_ber_min.set_label("{:1.3f}% (Min)".format(ber[2]))
431 | self.lbl_ber_max.set_label("{:1.3f}% (Max)".format(ber[3]))
432 |
433 | if self.cb_auto_gain.get_active():
434 | self.spin_gain.set_value(self.stream_info["gain"])
435 | self.lbl_gain.set_label("{:2.1f}dB".format(self.stream_info["gain"]))
436 |
437 | if self.last_xhdr == 0:
438 | image_path = os.path.join(self.aas_dir, self.stream_info["cover"])
439 | image = self.stream_info["cover"]
440 | elif self.last_xhdr == 1:
441 | image_path = os.path.join(self.aas_dir, self.stream_info["logo"])
442 | image = self.stream_info["logo"]
443 | if not os.path.isfile(image_path):
444 | self.img_cover.clear()
445 |
446 | # resize and display image if it changed and exists
447 | if self.xhdr_changed and self.last_image != image and os.path.isfile(image_path):
448 | self.xhdr_changed = False
449 | self.last_image = image
450 | pixbuf = GdkPixbuf.Pixbuf.new_from_file(image_path)
451 | pixbuf = pixbuf.scale_simple(200, 200, GdkPixbuf.InterpType.HYPER)
452 | self.img_cover.set_from_pixbuf(pixbuf)
453 | logging.debug("Image changed")
454 | finally:
455 | Gdk.threads_leave()
456 |
457 | if self.playing:
458 | GObject.idle_add(update)
459 | self.status_timer = threading.Timer(1, self.check_status)
460 | self.status_timer.start()
461 |
462 | def process_traffic_map(self, filename, data):
463 | regex = re.compile(r"^TMT_.*_([1-3])_([1-3])_(\d{8}_\d{4}).*$")
464 | match = regex.match(filename)
465 |
466 | if match:
467 | tile_x = int(match.group(1))-1
468 | tile_y = int(match.group(2))-1
469 | utc_time = datetime.strptime(match.group(3), "%Y%m%d_%H%M").replace(tzinfo=timezone.utc)
470 | timestamp = int(utc_time.timestamp())
471 |
472 | # check if the tile has already been loaded
473 | if self.map_tiles[tile_x][tile_y] == timestamp:
474 | return # no need to recreate the map if it hasn't changed
475 |
476 | logging.debug("Got traffic map tile: %s, %s", tile_x, tile_y)
477 |
478 | self.map_tiles[tile_x][tile_y] = timestamp
479 | self.traffic_map.paste(Image.open(io.BytesIO(data)), (tile_y*200, tile_x*200))
480 |
481 | # check if all of the tiles are loaded
482 | if self.check_tiles(timestamp):
483 | logging.debug("Got complete traffic map")
484 | self.traffic_map.save(os.path.join("map", "traffic_map.png"))
485 |
486 | # display on map page
487 | if self.rad_map_traffic.get_active():
488 | img_map = self.traffic_map.resize((200, 200), Image.LANCZOS)
489 | self.img_map.set_from_pixbuf(img_to_pixbuf(img_map))
490 |
491 | if self.map_viewer is not None:
492 | self.map_viewer.updated()
493 |
494 | def process_weather_overlay(self, filename, data):
495 | regex = re.compile(r"^DWRO_(.*)_.*_(\d{8}_\d{4}).*$")
496 | match = regex.match(filename)
497 |
498 | if match:
499 | utc_time = datetime.strptime(match.group(2), "%Y%m%d_%H%M").replace(tzinfo=timezone.utc)
500 | timestamp = int(utc_time.timestamp())
501 | map_id = self.map_data["weather_id"]
502 |
503 | if match.group(1) != map_id:
504 | logging.error("Received weather overlay with the wrong ID: %s", match.group(1))
505 | return
506 |
507 | if self.map_data["weather_time"] == timestamp:
508 | return # no need to recreate the map if it hasn't changed
509 |
510 | logging.debug("Got weather overlay")
511 |
512 | self.map_data["weather_time"] = timestamp
513 | weather_map_path = os.path.join("map", "weather_map_{}_{}.png".format(map_id, timestamp))
514 |
515 | # create weather map
516 | try:
517 | map_path = os.path.join("map", "base_map_" + map_id + ".png")
518 | if not os.path.isfile(map_path):
519 | self.make_base_map(self.map_data["weather_id"], self.map_data["weather_pos"])
520 |
521 | img_map = Image.open(map_path).convert("RGBA")
522 | timestamp_pos = (img_map.size[0]-235, img_map.size[1]-29)
523 | img_ts = self.make_timestamp(utc_time.astimezone(), img_map.size, timestamp_pos)
524 | img_radar = Image.open(io.BytesIO(data)).convert("RGBA")
525 | img_radar = img_radar.resize(img_map.size, Image.LANCZOS)
526 | img_map = Image.alpha_composite(img_map, img_radar)
527 | img_map = Image.alpha_composite(img_map, img_ts)
528 | img_map.save(weather_map_path)
529 | self.map_data["weather_now"] = weather_map_path
530 |
531 | # display on map page
532 | if self.rad_map_weather.get_active():
533 | img_map = img_map.resize((200, 200), Image.LANCZOS)
534 | self.img_map.set_from_pixbuf(img_to_pixbuf(img_map))
535 |
536 | self.process_weather_maps() # get rid of old maps and add new ones to the list
537 | if self.map_viewer is not None:
538 | self.map_viewer.updated()
539 |
540 | except OSError:
541 | logging.error("Error creating weather map")
542 | self.map_data["weather_time"] = 0
543 |
544 | def process_weather_info(self, data):
545 | weather_id = None
546 | weather_pos = None
547 |
548 | for line in data.decode().split("\n"):
549 | if "DWR_Area_ID=" in line:
550 | regex = re.compile("^DWR_Area_ID=\"(.+)\"$")
551 | match = regex.match(line)
552 | weather_id = match.group(1)
553 |
554 | elif "Coordinates=" in line:
555 | regex = re.compile(r"^Coordinates=.*\((.*),(.*)\).*\((.*),(.*)\).*$")
556 | match = regex.match(line)
557 | weather_pos = [float(match.group(i)) for i in range(1, 5)]
558 |
559 | if weather_id is not None and weather_pos is not None:
560 | if self.map_data["weather_id"] != weather_id or self.map_data["weather_pos"] != weather_pos:
561 | logging.debug("Got position: (%n, %n) (%n, %n)", *weather_pos)
562 | self.map_data["weather_id"] = weather_id
563 | self.map_data["weather_pos"] = weather_pos
564 |
565 | self.make_base_map(weather_id, weather_pos)
566 | self.weather_maps = []
567 | self.process_weather_maps()
568 |
569 | def process_weather_maps(self):
570 | number_of_maps = 0
571 | regex = re.compile("^map.weather_map_([a-zA-Z0-9]+)_([0-9]+).png")
572 | now = time.time()
573 | files = glob.glob(os.path.join("map", "weather_map_") + "*.png")
574 | files.sort()
575 | for file in files:
576 | match = regex.match(file)
577 | if match:
578 | map_id = match.group(1)
579 | timestamp = int(match.group(2))
580 |
581 | # remove weather maps older than 12 hours
582 | if now - timestamp > 60*60*12:
583 | try:
584 | if file in self.weather_maps:
585 | self.weather_maps.pop(self.weather_maps.index(file))
586 | os.remove(file)
587 | logging.debug("Deleted old weather map: %s", file)
588 | except OSError:
589 | logging.error("Failed to delete old weather map: %s", file)
590 |
591 | # skip if not the correct location
592 | elif map_id == self.map_data["weather_id"]:
593 | if file not in self.weather_maps:
594 | self.weather_maps.append(file)
595 | number_of_maps += 1
596 |
597 | logging.debug("Found %s weather maps", number_of_maps)
598 |
599 | @staticmethod
600 | def map_image_coordinates(lat_degrees, lon_degrees):
601 | """convert latitude & longitude to x & y cooordinates in the map"""
602 | first_tile_x, first_tile_y = 35, 84
603 | zoom_level = 8
604 | tile_size = 256
605 |
606 | map_x = (1 + math.radians(lon_degrees) / math.pi) / 2
607 | map_y = (1 - math.asinh(math.tan(math.radians(lat_degrees))) / math.pi) / 2
608 | tile_x = map_x * (2**zoom_level) - first_tile_x
609 | tile_y = map_y * (2**zoom_level) - first_tile_y
610 | return int(round(tile_x * tile_size)), int(round(tile_y * tile_size))
611 |
612 | def make_base_map(self, map_id, pos):
613 | """crop the map to the area needed for weather radar"""
614 | map_path = os.path.join("map", "base_map_" + map_id + ".png")
615 | if os.path.isfile(self.MAP_FILE):
616 | if not os.path.isfile(map_path):
617 | logging.debug("Creating new map: %s", map_path)
618 | map_upper_left = self.map_image_coordinates(pos[0], pos[1])
619 | map_lower_right = self.map_image_coordinates(pos[2], pos[3])
620 | map_img = Image.open(self.MAP_FILE).crop(map_upper_left + map_lower_right)
621 | map_img.save(map_path)
622 | logging.debug("Finished creating map")
623 | else:
624 | logging.error("Map file not found: %s", self.MAP_FILE)
625 | map_img = Image.new("RGBA", (pos[2]-pos[1], pos[3]-pos[1]), "white")
626 | map_img.save(map_path)
627 |
628 | def check_tiles(self, timestamp):
629 | """check if all the tiles have been received"""
630 | for i in range(3):
631 | for j in range(3):
632 | if self.map_tiles[i][j] != timestamp:
633 | return False
634 | return True
635 |
636 | @staticmethod
637 | def make_timestamp(local_time, size, pos):
638 | """create a timestamp image to overlay on the weathermap"""
639 | pos_x, pos_y = pos
640 | text = datetime.strftime(local_time, "%Y-%m-%d %H:%M")
641 | img_ts = Image.new("RGBA", size, (0, 0, 0, 0))
642 | draw = ImageDraw.Draw(img_ts)
643 | font = ImageFont.truetype("DejaVuSansMono.ttf", 24)
644 | draw.rectangle((pos_x, pos_y, pos_x+231, pos_y+25), outline="black", fill=(128, 128, 128, 96))
645 | draw.text((pos_x+3, pos_y), text, fill="black", font=font)
646 | return img_ts
647 |
648 | def audio_worker(self):
649 | audio = pyaudio.PyAudio()
650 | try:
651 | index = audio.get_default_output_device_info()["index"]
652 | stream = audio.open(format=pyaudio.paInt16,
653 | channels=2,
654 | rate=self.AUDIO_SAMPLE_RATE,
655 | output_device_index=index,
656 | output=True)
657 | except OSError:
658 | logging.warning("No audio output device available")
659 | stream = None
660 |
661 | while True:
662 | samples = self.audio_queue.get()
663 | if samples is None:
664 | break
665 | if stream:
666 | stream.write(samples)
667 | self.audio_queue.task_done()
668 |
669 | if stream:
670 | stream.stop_stream()
671 | stream.close()
672 | audio.terminate()
673 |
674 | def update_bitrate(self, bits):
675 | kbps = bits * self.AUDIO_SAMPLE_RATE / self.AUDIO_SAMPLES_PER_FRAME / 1000
676 | if self.stream_info["bitrate"] == 0:
677 | self.stream_info["bitrate"] = kbps
678 | else:
679 | self.stream_info["bitrate"] = 0.99 * self.stream_info["bitrate"] + 0.01 * kbps
680 |
681 | def update_ber(self, cber):
682 | ber = self.stream_info["ber"]
683 | if ber[0] == ber[1] == ber[2] == ber[3] == 0:
684 | ber[0] = cber
685 | ber[1] = cber
686 | ber[2] = cber
687 | ber[3] = cber
688 | else:
689 | ber[0] = cber
690 | ber[1] = 0.9 * ber[1] + 0.1 * cber
691 | if cber < ber[2]:
692 | ber[2] = cber
693 | if cber > ber[3]:
694 | ber[3] = cber
695 |
696 | def callback(self, evt_type, evt):
697 | if evt_type == nrsc5.EventType.LOST_DEVICE:
698 | pass # TODO: update the GUI?
699 | elif evt_type == nrsc5.EventType.SYNC:
700 | self.stream_info["gain"] = self.radio.get_gain()
701 | # TODO: update the GUI?
702 | elif evt_type == nrsc5.EventType.LOST_SYNC:
703 | pass # TODO: update the GUI?
704 | elif evt_type == nrsc5.EventType.MER:
705 | self.stream_info["mer"] = [evt.lower, evt.upper]
706 | elif evt_type == nrsc5.EventType.BER:
707 | self.update_ber(evt.cber)
708 | elif evt_type == nrsc5.EventType.HDC:
709 | if evt.program == self.stream_num:
710 | self.update_bitrate(len(evt.data) * 8)
711 | elif evt_type == nrsc5.EventType.AUDIO:
712 | if evt.program == self.stream_num:
713 | self.audio_queue.put(evt.data)
714 | elif evt_type == nrsc5.EventType.ID3:
715 | if evt.program == self.stream_num:
716 | if evt.title:
717 | self.stream_info["title"] = evt.title
718 | if evt.artist:
719 | self.stream_info["artist"] = evt.artist
720 | if evt.album:
721 | self.stream_info["album"] = evt.album
722 | if evt.xhdr:
723 | if evt.xhdr.param != self.last_xhdr:
724 | self.last_xhdr = evt.xhdr.param
725 | self.xhdr_changed = True
726 | logging.debug("XHDR changed: %s", evt.xhdr.param)
727 | elif evt_type == nrsc5.EventType.SIG:
728 | for service in evt:
729 | if service.type == nrsc5.ServiceType.AUDIO:
730 | for component in service.components:
731 | if component.type == nrsc5.ComponentType.DATA:
732 | if component.data.mime == nrsc5.MIMEType.PRIMARY_IMAGE:
733 | self.streams[service.number-1]["image"] = component.data.port
734 | elif component.data.mime == nrsc5.MIMEType.STATION_LOGO:
735 | self.streams[service.number-1]["logo"] = component.data.port
736 | elif service.type == nrsc5.ServiceType.DATA:
737 | for component in service.components:
738 | if component.type == nrsc5.ComponentType.DATA:
739 | if component.data.mime == nrsc5.MIMEType.TTN_STM_TRAFFIC:
740 | self.traffic_port = component.data.port
741 | elif component.data.mime == nrsc5.MIMEType.TTN_STM_WEATHER:
742 | self.weather_port = component.data.port
743 | elif evt_type == nrsc5.EventType.LOT:
744 | logging.debug("LOT port=%s", evt.port)
745 |
746 | if self.map_dir is not None:
747 | if evt.port == self.traffic_port:
748 | if evt.name.startswith("TMT_"):
749 | self.process_traffic_map(evt.name, evt.data)
750 | elif evt.port == self.weather_port:
751 | if evt.name.startswith("DWRO_"):
752 | self.process_weather_overlay(evt.name, evt.data)
753 | elif evt.name.startswith("DWRI_"):
754 | self.process_weather_info(evt.data)
755 |
756 | if self.aas_dir is not None:
757 | path = os.path.join(self.aas_dir, evt.name)
758 | for i, stream in enumerate(self.streams):
759 | if evt.port == stream.get("image"):
760 | logging.debug("Got album cover: %s", evt.name)
761 | with open(path, "wb") as file:
762 | file.write(evt.data)
763 | if i == self.stream_num:
764 | self.stream_info["cover"] = evt.name
765 | elif evt.port == stream.get("logo"):
766 | logging.debug("Got station logo: %s", evt.name)
767 | with open(path, "wb") as file:
768 | file.write(evt.data)
769 | self.station_logos[self.station_str][i] = evt.name
770 | if i == self.stream_num:
771 | self.stream_info["logo"] = evt.name
772 |
773 | elif evt_type == nrsc5.EventType.SIS:
774 | if evt.name:
775 | self.stream_info["callsign"] = evt.name
776 | if evt.slogan:
777 | self.stream_info["slogan"] = evt.slogan
778 |
779 | def get_controls(self):
780 | # setup gui
781 | builder = Gtk.Builder()
782 | builder.add_from_file("main_form.glade")
783 | builder.connect_signals(self)
784 |
785 | # Windows
786 | self.main_window = builder.get_object("main_window")
787 | self.main_window.connect("delete-event", self.shutdown)
788 | self.main_window.connect("destroy", Gtk.main_quit)
789 | self.about_dialog = None
790 |
791 | # get controls
792 | self.notebook_main = builder.get_object("notebook_main")
793 | self.img_cover = builder.get_object("img_cover")
794 | self.img_map = builder.get_object("img_map")
795 | self.spin_freq = builder.get_object("spin_freq")
796 | self.spin_stream = builder.get_object("spin_stream")
797 | self.spin_gain = builder.get_object("spin_gain")
798 | self.spin_ppm = builder.get_object("spin_ppm")
799 | self.spin_rtl = builder.get_object("spin_rtl")
800 | self.cb_auto_gain = builder.get_object("cb_auto_gain")
801 | self.btn_play = builder.get_object("btn_play")
802 | self.btn_stop = builder.get_object("btn_stop")
803 | self.btn_bookmark = builder.get_object("btn_bookmark")
804 | self.btn_delete = builder.get_object("btn_delete")
805 | self.rad_map_traffic = builder.get_object("rad_map_traffic")
806 | self.rad_map_weather = builder.get_object("rad_map_weather")
807 | self.txt_title = builder.get_object("txt_title")
808 | self.txt_artist = builder.get_object("txt_artist")
809 | self.txt_album = builder.get_object("txt_album")
810 | self.lbl_name = builder.get_object("lbl_name")
811 | self.lbl_slogan = builder.get_object("lbl_slogan")
812 | self.lbl_callsign = builder.get_object("lbl_callsign")
813 | self.lbl_gain = builder.get_object("lbl_gain")
814 | self.lbl_bitrate = builder.get_object("lbl_bitrate")
815 | self.lbl_bitrate2 = builder.get_object("lbl_bitrate2")
816 | self.lbl_error = builder.get_object("lbl_error")
817 | self.lbl_mer_lower = builder.get_object("lbl_mer_lower")
818 | self.lbl_mer_upper = builder.get_object("lbl_mer_upper")
819 | self.lbl_ber_now = builder.get_object("lbl_ber_now")
820 | self.lbl_ber_avg = builder.get_object("lbl_ber_avg")
821 | self.lbl_ber_min = builder.get_object("lbl_ber_min")
822 | self.lbl_ber_max = builder.get_object("lbl_ber_max")
823 | self.lv_bookmarks = builder.get_object("lv_bookmarks")
824 | self.ls_bookmarks = Gtk.ListStore(str, str, int)
825 |
826 | self.lv_bookmarks.set_model(self.ls_bookmarks)
827 | self.lv_bookmarks.get_selection().connect("changed", self.on_lv_bookmarks_sel_changed)
828 |
829 | def init_stream_info(self):
830 | self.stream_info = {
831 | "callsign": "",
832 | "slogan": "",
833 | "title": "",
834 | "album": "",
835 | "artist": "",
836 | "cover": "",
837 | "logo": "",
838 | "bitrate": 0,
839 | "mer": [0, 0],
840 | "ber": [0, 0, 0, 0],
841 | "gain": 0
842 | }
843 |
844 | self.streams = [{}, {}, {}, {}]
845 | self.traffic_port = -1
846 | self.weather_port = -1
847 |
848 | # clear status info
849 | self.lbl_callsign.set_label("")
850 | self.lbl_bitrate.set_label("")
851 | self.lbl_bitrate2.set_label("")
852 | self.lbl_error.set_label("")
853 | self.lbl_gain.set_label("")
854 | self.txt_title.set_text("")
855 | self.txt_artist.set_text("")
856 | self.txt_album.set_text("")
857 | self.img_cover.clear()
858 | self.lbl_name.set_label("")
859 | self.lbl_slogan.set_label("")
860 | self.lbl_slogan.set_tooltip_text("")
861 | self.lbl_mer_lower.set_label("")
862 | self.lbl_mer_upper.set_label("")
863 | self.lbl_ber_now.set_label("")
864 | self.lbl_ber_avg.set_label("")
865 | self.lbl_ber_min.set_label("")
866 | self.lbl_ber_max.set_label("")
867 |
868 | def load_settings(self):
869 | try:
870 | with open("station_logos.json", mode="r") as file:
871 | self.station_logos = json.load(file)
872 | except (OSError, json.decoder.JSONDecodeError):
873 | logging.warning("Unable to load station logo database")
874 |
875 | # load settings
876 | try:
877 | with open("config.json", mode="r") as file:
878 | config = json.load(file)
879 |
880 | if "map_data" in config:
881 | self.map_data = config["map_data"]
882 | if self.map_data["map_mode"] == 0:
883 | self.rad_map_traffic.set_active(True)
884 | self.rad_map_traffic.toggled()
885 | elif self.map_data["map_mode"] == 1:
886 | self.rad_map_weather.set_active(True)
887 | self.rad_map_weather.toggled()
888 |
889 | if "width" and "height" in config:
890 | self.main_window.resize(config["width"], config["height"])
891 |
892 | self.main_window.move(config["window_x"], config["window_y"])
893 | self.spin_freq.set_value(config["frequency"])
894 | self.spin_stream.set_value(config["stream"])
895 | self.spin_gain.set_value(config["gain"])
896 | self.cb_auto_gain.set_active(config["auto_gain"])
897 | self.spin_ppm.set_value(config["ppm_error"])
898 | self.spin_rtl.set_value(config["rtl"])
899 | self.bookmarks = config["bookmarks"]
900 | for bookmark in self.bookmarks:
901 | self.ls_bookmarks.append(bookmark)
902 | except (OSError, json.decoder.JSONDecodeError, KeyError):
903 | logging.warning("Unable to load config")
904 |
905 | # create aas directory
906 | self.aas_dir = os.path.join(sys.path[0], "aas")
907 | if not os.path.isdir(self.aas_dir):
908 | try:
909 | os.mkdir(self.aas_dir)
910 | except OSError:
911 | logging.error("Unable to create AAS directory")
912 | self.aas_dir = None
913 |
914 | # create map directory
915 | self.map_dir = os.path.join(sys.path[0], "map")
916 | if not os.path.isdir(self.map_dir):
917 | try:
918 | os.mkdir(self.map_dir)
919 | except OSError:
920 | logging.error("Unable to create map directory")
921 | self.map_dir = None
922 |
923 | def shutdown(self, *_args):
924 | # stop map viewer animation if it's running
925 | if self.map_viewer is not None and self.map_viewer.animate_timer is not None:
926 | self.map_viewer.animate_timer.cancel()
927 | self.map_viewer.animate_stop = True
928 |
929 | while self.map_viewer.animate_busy:
930 | logging.debug("Animation busy - stopping")
931 | if self.map_viewer.animate_timer is not None:
932 | self.map_viewer.animate_timer.cancel()
933 | time.sleep(0.25)
934 |
935 | self.playing = False
936 |
937 | # kill nrsc5 if it's running
938 | if self.radio:
939 | self.radio.stop()
940 | self.radio.close()
941 | self.radio = None
942 |
943 | # shut down status timer if it's running
944 | if self.status_timer is not None:
945 | self.status_timer.cancel()
946 |
947 | self.audio_queue.put(None)
948 | self.audio_thread.join()
949 |
950 | # save settings
951 | try:
952 | with open("config.json", mode="w") as file:
953 | window_x, window_y = self.main_window.get_position()
954 | width, height = self.main_window.get_size()
955 | config = {
956 | "config_version": self.VERSION,
957 | "window_x": window_x,
958 | "window_y": window_y,
959 | "width": width,
960 | "height": height,
961 | "frequency": self.spin_freq.get_value(),
962 | "stream": int(self.spin_stream.get_value()),
963 | "gain": self.spin_gain.get_value(),
964 | "auto_gain": self.cb_auto_gain.get_active(),
965 | "ppm_error": int(self.spin_ppm.get_value()),
966 | "rtl": int(self.spin_rtl.get_value()),
967 | "bookmarks": self.bookmarks,
968 | "map_data": self.map_data,
969 | }
970 | # sort bookmarks
971 | config["bookmarks"].sort(key=lambda t: t[2])
972 |
973 | json.dump(config, file, indent=2)
974 |
975 | with open("station_logos.json", mode="w") as file:
976 | json.dump(self.station_logos, file, indent=2)
977 | except OSError:
978 | logging.error("Unable to save config")
979 |
980 |
981 | class NRSC5Map(object):
982 | def __init__(self, parent, callback, data):
983 | # setup gui
984 | builder = Gtk.Builder()
985 | builder.add_from_file("map_form.glade")
986 | builder.connect_signals(self)
987 |
988 | self.parent = parent
989 | self.callback = callback
990 | self.data = data # map data
991 | self.animate_timer = None
992 | self.animate_busy = False
993 | self.animate_stop = False
994 | self.weather_maps = parent.weather_maps # list of weather maps sorted by time
995 | self.map_index = 0 # the index of the next weather map to display
996 |
997 | # get the controls
998 | self.map_window = builder.get_object("map_window")
999 | self.img_map = builder.get_object("img_map")
1000 | self.rad_map_weather = builder.get_object("rad_map_weather")
1001 | self.rad_map_traffic = builder.get_object("rad_map_traffic")
1002 | self.chk_animate = builder.get_object("chk_animate")
1003 | self.chk_scale = builder.get_object("chk_scale")
1004 | self.spin_speed = builder.get_object("spin_speed")
1005 | self.adj_speed = builder.get_object("adj_speed")
1006 | self.img_key = builder.get_object("img_key")
1007 |
1008 | self.map_window.connect("delete-event", self.on_map_window_delete)
1009 |
1010 | self.config = data["viewer_config"]
1011 | self.map_window.resize(*self.config["window_size"])
1012 | self.map_window.move(*self.config["window_pos"])
1013 | if self.config["mode"] == 0:
1014 | self.rad_map_traffic.set_active(True)
1015 | elif self.config["mode"] == 1:
1016 | self.rad_map_weather.set_active(True)
1017 | self.set_map(self.config["mode"])
1018 |
1019 | self.chk_animate.set_active(self.config["animate"])
1020 | self.chk_scale.set_active(self.config["scale"])
1021 | self.spin_speed.set_value(self.config["animation_speed"])
1022 |
1023 | def on_rad_map_toggled(self, btn):
1024 | if btn.get_active():
1025 | if btn == self.rad_map_traffic:
1026 | self.config["mode"] = 0
1027 | self.img_key.set_visible(False)
1028 |
1029 | # stop animation if it's enabled
1030 | if self.animate_timer is not None:
1031 | self.animate_timer.cancel()
1032 | self.animate_timer = None
1033 |
1034 | self.set_map(0) # show the traffic map
1035 |
1036 | elif btn == self.rad_map_weather:
1037 | self.config["mode"] = 1
1038 | self.img_key.set_visible(True) # show the key for the weather radar
1039 |
1040 | # check if animate is enabled and start animation
1041 | if self.config["animate"] and self.animate_timer is None:
1042 | self.animate_timer = threading.Timer(0.05, self.animate)
1043 | self.animate_timer.start()
1044 |
1045 | # no animation, just show the current map
1046 | elif not self.config["animate"]:
1047 | self.set_map(1)
1048 |
1049 | def on_chk_animate_toggled(self, _btn):
1050 | self.config["animate"] = self.chk_animate.get_active()
1051 |
1052 | if self.config["animate"] and self.config["mode"] == 1:
1053 | # start animation
1054 | self.animate_timer = threading.Timer(self.config["animation_speed"], self.animate)
1055 | self.animate_timer.start()
1056 | else:
1057 | # stop animation
1058 | if self.animate_timer is not None:
1059 | self.animate_timer.cancel()
1060 | self.animate_timer = None
1061 | self.map_index = len(self.weather_maps)-1 # reset the animation index
1062 | self.set_map(self.config["mode"]) # show the most recent map
1063 |
1064 | def on_chk_scale_toggled(self, btn):
1065 | self.config["scale"] = btn.get_active()
1066 | if self.config["mode"] == 1:
1067 | if self.config["animate"]:
1068 | i = len(self.weather_maps)-1 if (self.map_index-1 < 0) else self.map_index-1
1069 | self.show_image(self.weather_maps[i], self.config["scale"])
1070 | else:
1071 | self.show_image(self.data["weather_now"], self.config["scale"])
1072 |
1073 | def on_spin_speed_value_changed(self, _spn):
1074 | self.config["animation_speed"] = self.adj_speed.get_value()
1075 |
1076 | def on_map_window_delete(self, *_args):
1077 | # cancel the timer if it's running
1078 | if self.animate_timer is not None:
1079 | self.animate_timer.cancel()
1080 | self.animate_stop = True
1081 |
1082 | # wait for animation to finish
1083 | while self.animate_busy:
1084 | self.parent.debugLog("Waiting for animation to finish")
1085 | if self.animate_timer is not None:
1086 | self.animate_timer.cancel()
1087 | time.sleep(0.25)
1088 |
1089 | self.config["window_pos"] = self.map_window.get_position()
1090 | self.config["window_size"] = self.map_window.get_size()
1091 | self.callback()
1092 |
1093 | def animate(self):
1094 | filename = self.weather_maps[self.map_index] if self.weather_maps else ""
1095 | if os.path.isfile(filename):
1096 | self.animate_busy = True
1097 |
1098 | if self.config["scale"]:
1099 | map_img = img_to_pixbuf(Image.open(filename).resize((600, 600), Image.LANCZOS))
1100 | else:
1101 | map_img = img_to_pixbuf(Image.open(filename))
1102 |
1103 | if self.config["animate"] and self.config["mode"] == 1 and not self.animate_stop:
1104 | self.img_map.set_from_pixbuf(map_img)
1105 | self.map_index += 1
1106 | if self.map_index >= len(self.weather_maps):
1107 | self.map_index = 0
1108 | self.animate_timer = threading.Timer(2, self.animate) # show the last image for a longer time
1109 | else:
1110 | self.animate_timer = threading.Timer(self.config["animation_speed"], self.animate)
1111 |
1112 | self.animate_timer.start()
1113 | else:
1114 | self.animate_timer = None
1115 |
1116 | self.animate_busy = False
1117 | else:
1118 | self.chk_animate.set_active(False) # stop animation if image was not found
1119 | self.map_index = 0
1120 |
1121 | def show_image(self, filename, scale):
1122 | if os.path.isfile(filename):
1123 | if scale:
1124 | map_img = Image.open(filename).resize((600, 600), Image.LANCZOS)
1125 | else:
1126 | map_img = Image.open(filename)
1127 |
1128 | self.img_map.set_from_pixbuf(img_to_pixbuf(map_img))
1129 | else:
1130 | self.img_map.set_from_stock(Gtk.STOCK_MISSING_IMAGE, Gtk.IconSize.LARGE_TOOLBAR)
1131 |
1132 | def set_map(self, map_type):
1133 | if map_type == 0:
1134 | self.show_image(os.path.join("map", "traffic_map.png"), False)
1135 | elif map_type == 1:
1136 | self.show_image(self.data["weather_now"], self.config["scale"])
1137 |
1138 | def updated(self):
1139 | if self.config["mode"] == 0:
1140 | self.set_map(0)
1141 | elif self.config["mode"] == 1:
1142 | self.set_map(1)
1143 | self.map_index = len(self.weather_maps)-1
1144 |
1145 |
1146 | def img_to_pixbuf(img):
1147 | """convert PIL.Image to GdkPixbuf.Pixbuf"""
1148 | data = GLib.Bytes.new(img.tobytes())
1149 | return GdkPixbuf.Pixbuf.new_from_bytes(data, GdkPixbuf.Colorspace.RGB, 'A' in img.getbands(),
1150 | 8, img.width, img.height, len(img.getbands())*img.width)
1151 |
1152 |
1153 | if __name__ == "__main__":
1154 | os.chdir(sys.path[0])
1155 | nrsc5_gui = NRSC5GUI()
1156 | nrsc5_gui.main_window.show()
1157 | Gtk.main()
1158 |
--------------------------------------------------------------------------------
/radar_key.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/radar_key.png
--------------------------------------------------------------------------------
/radar_key.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
247 |
--------------------------------------------------------------------------------
/screenshots/album_art_tab.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/screenshots/album_art_tab.png
--------------------------------------------------------------------------------
/screenshots/bookmarks_tab.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/screenshots/bookmarks_tab.png
--------------------------------------------------------------------------------
/screenshots/info_tab.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/screenshots/info_tab.png
--------------------------------------------------------------------------------
/screenshots/map_tab.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/screenshots/map_tab.png
--------------------------------------------------------------------------------
/screenshots/settings_tab.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/screenshots/settings_tab.png
--------------------------------------------------------------------------------
/weather.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cmnybo/nrsc5-gui/1113d5a16f3dba834c6ff378d35880433fbd752f/weather.png
--------------------------------------------------------------------------------