#!/usr/bin/env -S python3 -B
#
#    ft8con
#
#    Text-based monitor for FT8 receivers.
#
#    Copyright (C) 2019,2023,2024,2025 by Matt Roberts.
#    License: GNU GPL3 (www.gnu.org)
#
#

#
#  constants
#

# the ALL file default path
DefaultAllDotTxt = '~/.local/share/WSJT-X/ALL.TXT' 

# the BLOCKED file default path
DefaultBlockedTxt = '~/.local/share/WSJT-X/BLOCKED.TXT'

# ncurses color pairs
WHITE_ON_CLEAR = 1
YELLOW_ON_CLEAR = 2
GREEN_ON_CLEAR = 3
RED_ON_CLEAR = 4
BLUE_ON_CLEAR = 5
BLACK_ON_WHITE = 6
BLACK_ON_YELLOW = 7
BLACK_ON_GREEN = 8
BLACK_ON_RED = 9
BLACK_ON_BLUE = 10
WHITE_ON_BLUE = 11
GRAY_ON_CLEAR = 12

# assign color pairs for specific UI items
CqColor = WHITE_ON_CLEAR
TxColor = YELLOW_ON_CLEAR
MeColor = GREEN_ON_CLEAR
BkColor = RED_ON_CLEAR
RxColor = WHITE_ON_CLEAR

# version string
VERSION_NUMBER = "2025-11-12"


#
#  config_t - wraps up configuration in a single type
#
class config_t:
	def __init__(self):
		# number of history lines in waterfall
		self.waterfall_lines = 1 # only support one for now

		# waterfall edges
		self.waterfall_fmin = 200 # Hz
		self.waterfall_fmax = 3000 # Hz

		# spots older than this are not shown
		self.waterfall_age = 30 # slots

		# spots older than this are shown, but with dimmer video
		self.waterfall_old = 20 # slots

		# single-panel mode
		self.single_panel = False

		# the local callsign (use -m option to change this)
		self.my_call = 'N0CALL'

		# show rolling channel load
		self.show_load = False

		# enable exception display
		self.show_exceptions = True

		# status style options
		self.status_is_bold = True
		self.status_is_reverse = False

		# spot style
		self.spots_are_bold = False
		self.blocked_are_hidden = True

		# multiband mode
		self.multiband = False

		# follow stations we call, regardless of who they answer (zero to disable)
		self.ghost_tail = 6 # slots

		# show receiver count
		self.use_rx_count = False

# global configuration object
config = config_t()


#
#  state_t - wraps up global state in a single type
#
class state_t:
	def __init__(self):
		# list of input files
		self.all_txt_names = [ ]   # this is a list of files
		self.blocked_txt = None # this is a single file

		# width of left and right panels
		self.left_width = 0
		self.right_width = 0

		# line counters
		self.monitor_lines = 0
		self.qso_lines = 0

		# blocked calls
		self.blocked = [ ]

		# ghost tail timer
		self.ghost_call = None
		self.ghost_when = 0

		# the receiver counters
		self.rx_updates = { }

# global state object
state = state_t()


# system modules
import sys
import os
import os.path
import time
import curses
import threading
import getopt
from datetime import datetime, timezone

# local modules
from tailall import tailall
from blocked import *


#
#  read_rc() - read the rc file
#
def read_rc():
	rc_path = os.path.expanduser('~/.ft8conrc')
	try:
		with io.open(rc_path, 'r') as rc:
			c = rc.readline()
			if c: c = c.strip()
			if c:
				c = c.split()
				if len(c) == 2:
					if (c[0].upper() == 'MYCALL'):
						config.my_call = c[1]
					#
					#  TODO: other future settings go here
					#
	except:
		pass


#
#  write_rc() - write the rc file
#
def write_rc():
	rc_path = os.path.expanduser('~/.ft8conrc')
	try:
		if os.path.exists(rc_path):
			os.rename(rc_path, rc_path + '.bak')
		with io.open(rc_path, 'w') as rc:
			rc.write("MYCALL %s\n" % config.my_call)
			#
			#  TODO: other future settings go here
			#
	except:
		pass


#
#  slot_width(...) in Hz
#
def slot_width(mode):
	if mode == 'FT4':
		return 60
	if mode == 'FT8':
		return 50
	return 100
	

#
#  slot_length(...) in sec
#
def slot_length(mode):
	if mode == 'FT4':
		return 7.5
	if mode == 'FT8':
		return 15
	return 60


#
#  watch_thread(...)
#
class watch_thread():
	#
	#  set_show_dt(...)
	#
	def set_show_dt(self, sdt):
		self.show_dt = True if sdt else False
		if state.qso_lines:
			self.qsowin.addstr('\n')
		state.qso_lines += 1
		self.qsowin.addstr("Show Delta-T now %s" % self.show_dt)
		self.qsowin.refresh()

	#
	#  set_show_snr(...)
	#
	def set_show_snr(self, ssnr):
		self.show_snr = True if ssnr else False
		if state.qso_lines:
			self.qsowin.addstr('\n')
		state.qso_lines += 1
		self.qsowin.addstr("Show SNR now %s" % self.show_snr)
		self.qsowin.refresh()

	#
	#  set_cq_only(...)
	#
	def set_cq_only(self, cq_only):
		self.cq_only = True if cq_only else False
		if state.qso_lines:
			self.qsowin.addstr('\n')
		state.qso_lines += 1
		self.qsowin.addstr("CQ-only now %s" % self.cq_only)
		self.qsowin.refresh()
	
	#
	#  show_mycall(...)
	#
	def show_mycall(self):
		if state.qso_lines:
			self.qsowin.addstr('\n')
		state.qso_lines += 1
		self.qsowin.addstr("My call is %s\n" % config.my_call)
		self.qsowin.addstr("Following %d files" % len(state.all_txt_names))
		self.qsowin.refresh()

	#
	#  status_update - update the status line at the top of the display
	#
	def status_update(self):
		with self.lock:
			try:
				# read the size of the screen
				y, x = self.stdscr.getmaxyx()

				# read the clock in UTC
				now = datetime.now(timezone.utc)

				# build the new status text, adding fields depending on enabled options
				statxt = " - RF %s MHz - AF %d Hz - %s " % (self.freq, self.tx_audio, self.mode)
				if config.show_load:
					statxt += "- %d%% " % self.LoadPct
				if config.use_rx_count:
					nowticks = int(time.time())
					current = 0
					for i in state.rx_updates:
						if state.rx_updates[i] >= nowticks - 60:
							current += 1
					statxt += "- %d RX " % current
				statxt += "- %02d:%02d:%02d Z - " % (now.hour, now.minute, now.second)

				# if the status text was too long, render only the dial, mode, and time
				if len(statxt) >= x:
					statxt = " - RF %s MHz - %s - %02d:%02d:%02d Z - " % (
						self.freq, self.mode, now.hour, now.minute, now.second)

				# only update if necessary
				if statxt == self.last_stat:
					return

				# save the rendered text, to compare to the next call, to avoid redrawing the same string
				self.last_stat = statxt

				# compute L/R padding
				pad1 = ' ' * int((x - len(statxt)) / 2)

				# decide on the text style and color
				style = curses.A_BOLD if config.status_is_bold else 0
				color = BLACK_ON_WHITE if config.status_is_reverse else WHITE_ON_CLEAR
				last_tx_ago = time.time() - self.tx_time
				slot_len = slot_length(self.mode)
				blink = False
				if last_tx_ago <= (slot_len - 2):
					# color for TX
					color = BLACK_ON_YELLOW if config.status_is_reverse else YELLOW_ON_CLEAR
					blink = True
				elif last_tx_ago <= (2 * slot_len):
					# color for RX immediately after TX
					color = BLACK_ON_GREEN if config.status_is_reverse else GREEN_ON_CLEAR
					blink = True

				# add style to the selected color
				style = style | curses.color_pair(color)

				# blink the dashes if something is going on
				if blink and (now.second % 2):
					statxt = statxt.replace('-', ' ')

				# draw
				self.stawin.addstr(0, 0, pad1 + statxt, style)
				self.stawin.clrtoeol()
				self.stawin.refresh()

			except Exception as ex:
				if config.show_exceptions:
					if state.qso_lines:
						self.qsowin.addstr('\n')
					state.qso_lines += 1
					self.qsowin.addstr("STA: " + str(ex))
					self.qsowin.refresh()

	#
	#  waterfall_legend - draw the waterfall legend at the bottom of the screen
	#
	def waterfall_legend(self):
		with self.lock:
			try:
				y, x = self.stdscr.getmaxyx()
				step = (config.waterfall_fmax - config.waterfall_fmin) / x
				wfcolor = curses.color_pair(WHITE_ON_CLEAR)
				self.wfwin.addstr(1, 0, "^" + str(config.waterfall_fmin), wfcolor)
				self.wfwin.clrtoeol()
				self.wfwin.addstr(1, x - 6, str(config.waterfall_fmax) + "^", wfcolor)
				start = 500
				tab = 500
				if x < 70:
					start = 1000
					tab = 1000
				for f in [ start * i for i in range(1, int(config.waterfall_fmax / tab)) ]:
					self.wfwin.addstr(1, int((f - config.waterfall_fmin) / step), "^" + str(f), wfcolor)
			except Exception as ex:
				if config.show_exceptions:
					if state.qso_lines:
						self.qsowin.addstr('\n')
					state.qso_lines += 1
					self.qsowin.addstr("WFL: " + str(ex))
					self.qsowin.refresh()

	#
	#  waterfall_update - update the list of signals shown in the waterfall
	#
	def waterfall_update(self):
		with self.lock:
			# don't redraw if no changes
			if not self.spots_dirty:
				return
			self.spots_dirty = False

			try:
				now = time.time()
				y, x = self.stdscr.getmaxyx()
				step = (config.waterfall_fmax - config.waterfall_fmin) / x
				chunk = int(slot_width(self.mode) / step)
				if chunk < 1:
					chunk = 1
				cols = [ ]

				# clear the line
				self.wfwin.move(0, 0)
				self.wfwin.clrtoeol()

				# calculate the spot columns and age
				toMe = [ ]

				# sort by SNR, so that the higher SNR show on top of lower ones
				thespots = sorted(self.spots, key = lambda s: (int(s['snr']), int(s['when'])))

				# for each spot...
				for spot in thespots:
					col = int((int(spot['audio']) - config.waterfall_fmin) / step)
					if col < 0 or col >= x:
						continue
					age = now - spot['when']
					snr = int(spot['snr'])
					if config.my_call.upper() in spot['what'] and age <= slot_length(self.mode):
						toMe.append(col)
					elif age < (config.waterfall_age * slot_length(self.mode)):
						cols.append((col, age, snr))

				# then draw them
				for col, age, snr in cols:
					coloridx = 0
					if age >= (config.waterfall_old) * slot_length(self.mode): # old(er) spots
						if snr >= 0:
							coloridx = GREEN_ON_CLEAR
						elif snr >= -10:
							coloridx = WHITE_ON_CLEAR
						elif snr >= -20:
							coloridx = YELLOW_ON_CLEAR
						else:
							coloridx = RED_ON_CLEAR
					else: # new(er) spot
						if snr >= 0:
							coloridx = BLACK_ON_GREEN
						elif snr >= -10:
							coloridx = BLACK_ON_WHITE
						elif snr >= -20:
							coloridx = BLACK_ON_YELLOW
						else:
							coloridx = BLACK_ON_RED
					for i in range(chunk):
						sx = col + i  # the X coordinate
						if sx < x:
							self.wfwin.addstr(0, sx, '#', curses.color_pair(coloridx))

				# draw special color for people calling me
				for col in toMe:
					for i in range(chunk):
						sx = col + i  # the X coordinate
						if sx < x:
							self.wfwin.addstr(0, sx, '$', curses.color_pair(BLACK_ON_GREEN))

				# draw the TX caret
				if now - self.last_tx <= slot_length(self.mode):
					col = int((int(self.tx_audio) - config.waterfall_fmin) / step)
					sym = '^' if col in toMe else '+'
					for i in range(chunk):
						sx = col + i  # the X coordinate
						if sx < x:
							self.wfwin.addstr(0, col + i, sym, curses.color_pair(WHITE_ON_BLUE))

				# redraw the legend
				self.waterfall_legend()
					
				# refresh the window
				self.wfwin.refresh()

			except Exception as ex:
				if config.show_exceptions:
					if state.qso_lines:
						self.qsowin.addstr('\n')
					state.qso_lines += 1
					self.qsowin.addstr("WFU: " + str(ex))
					self.qsowin.refresh()

	#
	#  callback - raised for each received decoded message
	#
	def callback(self, d):
		# if no data, do nothing
		if not d: return

		# if no sender, do nothing
		if not 'from' in d or not d['from']: return

		try:
			# get the source ID, and update its most recent timer
			srcid = d['srcid']
			state.rx_updates[srcid] = int(time.time())

			# de-dupe the data if more than one receiver is driving the input
			max_history = 500

			# handle CQ -> To
			to = d['to']
			cq = d['cq']
			if cq and not to:
				to = 'CQ'

			# extract the 'what' if possible
			what = 'None'
			if d['what']:
				what = d['what'].copy()
				while what and what[-1] in [ 'a1', 'a2', 'a3', '?' ]:
					what = what[:-1]
				what = what[-1] if what else 'None'

			# form the unique key for this message
			key = "%s-%s-%s-%s" % (d['time'], d['from'], to, what)

			# if the key already exists in history, don't duplicate it
			if key in self.history:
				return
			self.history.append(key)

			# clean old history records
			while len(self.history) > max_history:
				del(self.history[0])
		except Exception as ex:
			if config.show_exceptions:
				self.qsowin.addstr("MKW: " + str(ex))
				self.qsowin.refresh()

		line = None
		with self.lock:
			try:
				mon_dirty = False
				qso_dirty = False
				wf_dirty = False

				# update the load computation
				if self.LastTS != d['time'] or d['tx']:
					full_load = (2800 - 200) / slot_width(self.mode)
					this_load = int(100 * self.LoadCount / full_load)

					# one-shot to prime the smoother
					if not self.LastTS:
						self.LoadPct = this_load

					# low-pass the load
					if not self.LastTX:
						self.LoadPct = (self.alpha * this_load) + ((1.0 - self.alpha) * self.LoadPct)

					# reset state for next slot
					self.LoadCount = 0
					self.LastTS = d['time']
					self.LastTX = d['tx']

				self.LoadCount += 1

				# update the CQ table
				if d['cq'] and d['from']:
					self.cqs[d['from']] = d

				# fetch the mode and frequency
				self.mode = d['mode']
				self.freq = d['freq']

				# if the mode or freq have changed, clear the QSO window
				if not config.multiband:
					if self.mode != self.oldmode or self.freq != self.oldfreq:
						self.qsowin.clear()
						state.qso_lines = 0
						qso_dirty = True

						if not config.single_panel:
							self.monwin.clear()
							state.monitor_lines = 0
							mon_dirty = True

						self.wfwin.move(0, 0)
						self.wfwin.clrtoeol()
						self.spots = [ ]
						wf_dirty = True

				# store the old freq and mode for next comparison
				self.oldmode = self.mode
				self.oldfreq = self.freq

				# form the reception report
				dtx = '*' if (abs(float(d['dt'])) > 0.3) else ' '
				snr = ' ' if d['tx'] else d['snr']
				if snr and not d['tx'] and snr[0] != '-':
					snr = '+' + snr
				what = (' '.join(d['what'])).strip()

				# trim decoder reliability reports
				idx = what.find(' ?')
				if idx > 0:
					what = what[:idx].strip()
				if what.endswith('a3') or what.endswith('a2') or what.endswith('a1'):
					what = what[:-2].strip()

				# format the line, start with time
				line = "%s " % d['time']

				# add SNR and DT if enabled
				if self.show_snr:
					if self.show_dt:
						line += "%3s " % snr
					else:
						line += "%3s%1s " % (snr, dtx)
				if self.show_dt:
					dtstr = "     " if d['tx'] or len(d['dt']) == 0 else "%0.1f " % float(d['dt'])
					if dtstr[0] not in ' -':
						dtstr = '+' + dtstr
					line += dtstr

				# finish with AF and message
				line += "%4s  %s" % (d['audio'], what)

				is_cq = False
				for i in [ ' CQ ', ' CQDX ', ' CQFD ' ]:
					if i in line:
						is_cq = True
						break

				# add some left-padding in single-panel mode, if there is space
				if config.single_panel:
					pad = state.right_width - 40
					if pad > 2: pad = 2
					if pad < 0: pad = 0
					if pad:
						line = (' ' * pad) + line

				# read the clock
				now = time.time()

				# TX update to QSO window
				if d['tx'] == True:
					# trim the line if needed to prevent word wrap
					to_draw = line
					if len(to_draw) > state.right_width:
						to_draw = to_draw[:state.right_width]

					self.last_cq = (' CQ ' in to_draw)
					self.last_tx = time.time()
					TX_QSO_WINDOW = curses.color_pair(TxColor)
					if config.spots_are_bold:
						TX_QSO_WINDOW = TX_QSO_WINDOW | curses.A_BOLD
					if state.qso_lines:
						self.qsowin.addstr('\n')
					state.qso_lines += 1
					self.qsowin.addstr(to_draw, TX_QSO_WINDOW)
					self.tx_audio = int(d['audio'])
					self.tx_time = int(time.time())
					self.spots_dirty = True
					qso_dirty = True

					# when transmitting, update the ghost call info
					if d['cq'] or d['what'][-1] in [ '73', 'RR73', 'RRR' ]: # if I send CQ or 73, clear the ghost data
						state.ghost_call = None
						state.ghost_when = 0
					elif d['to']:  # if I call somebody else, set new ghost data
						state.ghost_call = d['to']
						state.ghost_when = now

				# RX update to one or both windows
				else:
					d['when'] = now
					self.spots.append(d)
					self.spots_dirty = True
					for spot in self.spots:
						if (now - spot['when']) >= (config.waterfall_age * slot_length(self.mode)):
							self.spots.remove(spot)

					# QSO window spot update
					my_upper = config.my_call.upper()
					is_mine = my_upper == d['to']
					is_ghost = False
					if not is_mine:
						# True if this is a recent ghost call matching the last called station
						is_ghost = (
							config.ghost_tail             and
							state.ghost_call              and
							state.ghost_call == d['from'] and
							(now - state.ghost_when) <= (slot_length(self.mode) * config.ghost_tail)
						)
 
						# reset the ghost data, to show no more than one ghost frame
						if is_ghost:
							state.ghost_call = None
							state.ghost_when = 0
							
					if is_mine or is_ghost:
						#
						#  look for call in blocked list
						#
						suppress = False
						for i in d['what']:
							if i in state.blocked:
								# skip blocked calls in QSO window
								suppress = True
								if config.blocked_are_hidden:
									return # do nothing with the spot
								break # stop searching; will style this spot differently

						# trim the line if needed to prevent word wrap
						to_draw = line
						if len(to_draw) >= state.right_width:
							to_draw = to_draw[:state.right_width - 1]

						if state.qso_lines:
							self.qsowin.addstr('\n')
						state.qso_lines += 1

						# pick the color
						rx_qso_window = curses.color_pair(MeColor)
						if suppress:
							rx_qso_window = curses.color_pair(BkColor)
						elif is_ghost:
							rx_qso_window = curses.color_pair(GRAY_ON_CLEAR) # WHITE_ON_CLEAR | curses.A_DIM)
							
						# selectively enable blold
						if config.spots_are_bold and not is_ghost:
							rx_qso_window = rx_qso_window | curses.A_BOLD

						# write the spot to the QSO window
						self.qsowin.addstr(to_draw, rx_qso_window)
						qso_dirty = True

					# monitor window update
					else:
						# default is normal text
						pair = curses.color_pair(RxColor)

						# clobber detection
						clobber = False
						if self.tx_audio and self.last_tx and self.last_cq:
							offset = abs(self.tx_audio - int(d['audio']))
							if (offset < slot_width(self.mode)) and (time.time() - self.last_tx <= (4.0 * slot_width(self.mode))):
								pair = curses.color_pair(RED_ON_CLEAR)
								if config.spots_are_bold:
									pair = pair | curses.A_BOLD
								clobber = True

						# CQ detection
						if not clobber and 'CQ' in d['what']:
							pair = curses.color_pair(CqColor)
							if config.spots_are_bold:
								pair = pair | curses.A_BOLD

						# trim the line if needed to prevent word wrap
						to_draw = line
						if len(to_draw) >= state.left_width:
							to_draw = to_draw[:state.left_width - 1]

						# draw CQ lines in the monitor window
						if not config.single_panel:
							if is_cq or not self.cq_only:
								if state.monitor_lines:
									self.monwin.addstr('\n')
								state.monitor_lines += 1
								self.monwin.addstr(to_draw, pair)
							mon_dirty = True

				# update the spot windows
				if mon_dirty and not config.single_panel:
					self.monwin.refresh()
				if qso_dirty:
					self.qsowin.refresh()
				if wf_dirty:
					self.wfwin.refresh()

				# status update
				self.status_update()

			except Exception as ex:
				if config.show_exceptions:
					if state.qso_lines:
						self.qsowin.addstr('\n')
					state.qso_lines += 1
					self.qsowin.addstr('CB: ' + str(ex))
					self.qsowin.refresh()


	#
	#  worker(fn) - wrapper function for background source reader threads
	#
	def worker(self, fn):
		tailall(fn, self.callback, ecallback=None)


	#
	#  start() - start all the background threads
	#
	def start(self):
		for t in self.threads:
			t.start()


	#
	#  ctor
	#
	def __init__(self, stdscr, monwin, qsowin, stawin, wfwin):
		self.threads = [ ]
		for fn in state.all_txt_names:
			thread = threading.Thread(target=self.worker, args=(fn,), daemon=True)
			self.threads.append(thread)
		self.stdscr = stdscr
		self.monwin = monwin
		self.qsowin = qsowin
		self.stawin = stawin
		self.wfwin = wfwin
		self.lock = threading.RLock()
		self.tx_audio = 0
		self.tx_time = 0
		self.last_tx = 0
		self.last_cq = False
		self.mode = "None"
		self.freq = "0.0"
		self.oldmode = "None"
		self.oldfreq = "0.0"
		self.spots = [ ]
		self.spots_dirty = False
		self.last_stat = ""
		self.cq_only = False
		self.show_snr = True
		self.show_dt = True
		self.cqs = { }
		self.LoadPct = 0 # rolling channel load (int %)
		self.LastTS = None
		self.LastTX = False
		self.LoadCount = 0
		self.alpha = 0.5
		self.history = [ ]


#
#  initialize()
#
def initialize(stdscr):
	stdscr.clear()
	curses.start_color()
	curses.use_default_colors()
	curses.cbreak()
	curses.noecho()
	curses.curs_set(0)
	stdscr.keypad(True)
	stdscr.notimeout(True)
	curses.halfdelay(2) # 0.2s

	# normal colors
	curses.init_pair(WHITE_ON_CLEAR, curses.COLOR_WHITE, -1) # w/bk
	curses.init_pair(YELLOW_ON_CLEAR, curses.COLOR_YELLOW, -1) # y/bk
	curses.init_pair(GREEN_ON_CLEAR, curses.COLOR_GREEN, -1) # g/bk
	curses.init_pair(RED_ON_CLEAR, curses.COLOR_RED, -1) # r/bk
	curses.init_pair(BLUE_ON_CLEAR, curses.COLOR_BLUE, -1) # r/bk
	if curses.COLORS - 1 >= 240 and curses.COLOR_PAIRS - 1 >= GRAY_ON_CLEAR:
		curses.init_pair(GRAY_ON_CLEAR, 240, -1) # g/bk
	else:
		curses.init_pair(GRAY_ON_CLEAR, -1, -1) # default

	# inverted colors
	curses.init_pair(BLACK_ON_WHITE, curses.COLOR_BLACK, curses.COLOR_WHITE) # bk/w
	curses.init_pair(BLACK_ON_YELLOW, curses.COLOR_BLACK, curses.COLOR_YELLOW) # bk/y
	curses.init_pair(BLACK_ON_GREEN, curses.COLOR_BLACK, curses.COLOR_GREEN) # bk/g
	curses.init_pair(BLACK_ON_RED, curses.COLOR_BLACK, curses.COLOR_RED) # bk/r
	curses.init_pair(BLACK_ON_BLUE, curses.COLOR_BLACK, curses.COLOR_BLUE) # bk/bl
	curses.init_pair(WHITE_ON_BLUE, curses.COLOR_WHITE, curses.COLOR_BLUE) # wh/bl


#
#  make_wins() - create the ncurses windows
#
def make_wins(stdscr):
	monwin = None
	qsowin = None
	stawin = None
	wfwin = None
	try:
		y, x = stdscr.getmaxyx()

		# lines consumed by header and footer
		hf_lines = 1 + config.waterfall_lines + 1

		# the MONITOR window
		if not config.single_panel:
			state.left_width = int(x / 2)
			monwin = curses.newwin(y - hf_lines, state.left_width, 1, 0)
			monwin.scrollok(True)
			monwin.clear()
			#monwin.notimeout(True)

		# the QSO window
		state.right_width = int(x) if config.single_panel else int(x / 2) - 1
		right_origin = 0 if config.single_panel else int(x / 2) + 1
		qsowin = curses.newwin(y - hf_lines, state.right_width, 1, right_origin)
		qsowin.scrollok(True)
		qsowin.clear()
		#qsowin.notimeout(True)

		# the STATUS window
		stawin = curses.newwin(1, x, 0, 0)
		stawin.scrollok(False)
		stawin.clear()
		#stawin.notimeout(True)

		# the WATERFALL window
		wfwin = curses.newwin(1 + config.waterfall_lines, x, y - (config.waterfall_lines + 1), 0)
		wfwin.scrollok(False) # we'll place things manually on this one
		wfwin.clear()

	except Exception as ex:
		if config.show_exceptions:
			qsowin.addstr("MKW: " + str(ex))
			qsowin.refresh()

	# return the set
	return monwin, qsowin, stawin, wfwin


#
#  force_redraw()
#
def force_redraw(monwin, qsowin, stawin, wfwin):
	stawin.redrawwin()
	stawin.refresh()
	if not config.single_panel:
		monwin.redrawwin()
		monwin.refresh()
	qsowin.redrawwin()
	qsowin.refresh()
	wfwin.redrawwin()
	wfwin.refresh()


#
#  main() - application function; called by ncurses wrapper
#
def main(stdscr):
	# read the rc file if it exists
	read_rc()

	# read command line
	show_snr = True
	show_dt = False
	cq_only = False
	optlist, cmdline = getopt.getopt(sys.argv[1:], '1a:b:cghlm:npqst')
	for opt in optlist:
		if opt[0] == '-1':      # single-panel mode
			config.single_panel = True
		elif opt[0] == '-a':    # provide ALL.TXT path
			state.all_txt_names.append(opt[1])
		elif opt[0] == '-b':    # provide BLOCKED.TXT path
			state.blocked_txt = opt[1]
		elif opt[0] == '-c':    # enable display of blocked calls
			state.blocked_are_hidden = False
		elif opt[0] == '-g':    # ghost tailing
			# if the -g option given with argument...
			if opt[1]:
				# try to parse out a new value
				try:
					new_value = int(opt[1])
					config.ghost_tail = new_value
				except:
					sys.stderr.write("Error: -g option must be integer\n")
					sys.exit(1)
		elif opt[0] == '-h':    # usage information (help)
			return 1   # will show help text in caller
		elif opt[0] == '-m':    # change my_call
			config.my_call = opt[1].upper()
		elif opt[0] == '-q':    # show only CQ in pane #1
			cq_only = True
		elif opt[0] == '-s':    # hide SNR columns
			show_snr = False
		elif opt[0] == '-n':    # show receiver count
			config.use_rx_count = True
		elif opt[0] == '-t':    # show SNR columns
			show_dt = True
		elif opt[0] == '-p':    # multiband mode
			config.multiband = True
		elif opt[0] == '-l':    # show rolling channel load
			config.show_load = True

	# use defaults if no files provided
	if not state.all_txt_names:
		state.all_txt_names = [ DefaultAllDotTxt ] # this one is a list of files
	if not state.blocked_txt:
		state.blocked_txt = DefaultBlockedTxt   # this one is a single file

	# perform tilde expansion on file paths
	for i in range(len(state.all_txt_names)):
		state.all_txt_names[i] = os.path.expanduser(state.all_txt_names[i])
	state.blocked_txt = os.path.expanduser(state.blocked_txt)

	# setup
	initialize(stdscr)
	monwin, qsowin, stawin, wfwin = make_wins(stdscr)

	# run the background thread to watch the ALL.TXT and update the windows
	w = watch_thread(stdscr, monwin, qsowin, stawin, wfwin)
	w.set_cq_only(cq_only)			
	w.set_show_snr(show_snr)
	w.set_show_dt(show_dt)
	w.waterfall_legend()
	w.show_mycall()
	w.start()

	# tickle the update logic (TODO: this shouldn't be necessary)
	ch = stdscr.getch()
	force_redraw(monwin, qsowin, stawin, wfwin)

	# loop vars
	last_wf = 0 # last waterfall timer refresh
	last_bt = 0 # last BLOCKED.TXT change


	# wait for a keypress
	while True:
		# status update
		w.status_update()

		# waterfall update
		w.waterfall_update()

		# read key input
		ch = None
		try:
			ch = stdscr.getch()
			if ch == curses.ERR:
				#monwin.addstr("DEBUG: Update cycle.\n")
				#monwin.refresh()

				# update waterfall now and then
				now = time.time()
				if now - last_wf > 5:
					last_wf = now
					w.spots_dirty = True

				# also update blocked call list
				mtime = get_blocked_age(state.blocked_txt)
				if mtime > 0 and mtime > last_bt:
					last_bt = mtime
					new_blocked = get_blocked_calls(state.blocked_txt)
					if config.my_call.upper() in new_blocked:
						new_blocked.remove(config.my_call.upper())
					state.blocked = new_blocked
					#monwin.addstr("DEBUG: Loaded %d blocks.\n" % len(blocked))
					#monwin.refresh()

				continue
		except TimeoutError as ex:
			# no key available
			continue

		# handle keystroke
		if ch == curses.KEY_RESIZE: #  ** SIGWINCH **
			y, x = stdscr.getmaxyx()
			if x > 0 and y > 0:
				# lines consumed by header and footer
				hf_lines = 1 + config.waterfall_lines + 1

				state.left_width = int(x / 2)
				state.right_width = x if config.single_panel else int(x / 2) - 1

				if not config.single_panel:
					monwin.resize(y - hf_lines, state.left_width)
				qsowin.resize(y - hf_lines, state.right_width)
				stawin.resize(1, x)
				wfwin.resize(1 + config.waterfall_lines, x)

				if not config.single_panel:
					monwin.mvwin(1, 0)
				new_x = 0 if config.single_panel else int(x / 2) + 1
				qsowin.mvwin(1, new_x)
				stawin.mvwin(0, 0)
				wfwin.mvwin(y - (1 + config.waterfall_lines), 0)

				# redraw the waterfall
				w.waterfall_update()

				# redraw the legend
				w.waterfall_legend()

				stdscr.clear()
				stdscr.refresh()
				force_redraw(monwin, qsowin, stawin, wfwin)
		elif ch == ord('a'):
			cq_only = not cq_only
			w.set_cq_only(cq_only)			
		elif ch == ord('c'):
			qsowin.clear()
			state.qso_lines = 0
			force_redraw(monwin, qsowin, stawin, wfwin)
		elif ch == ord('C'):
			monwin.clear()
			state.monitor_lines = 0
			force_redraw(monwin, qsowin, stawin, wfwin)
		elif ch == ord('K'):
			stdscr.clear()
			stdscr.refresh()
			force_redraw(monwin, qsowin, stawin, wfwin)
		elif ch == ord('q'):
			break
		elif ch == ord('r'):
			force_redraw(monwin, qsowin, stawin, wfwin)

	# shut down curses and exit
	curses.endwin()
	write_rc()
	sys.exit(0)


#
#  entry point
#
if __name__ == '__main__':
	# handle version number request
	if '-v' in sys.argv[1:]:
		sys.stdout.write("ft8con version %s\n" % VERSION_NUMBER)
		sys.exit(0)

	result = None
	try:
		result = curses.wrapper(main)
	except KeyboardInterrupt:
		pass
	except getopt.GetoptError:
		sys.stderr.write("Error: invalid options given.\n\n")
		result = 1 # show the help text
	except Exception as ex:
		# extract location information and display exception as an error
		exc_type, exc_obj, exc_tb = sys.exc_info()
		fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1]
		sys.stderr.write("Error: Caught unexpected %s (%s) in %s at line %d\n" % (exc_type, str(ex), fname, exc_tb.tb_lineno))
		sys.exit(1)

	# if 'main' returns 1, show help text
	if result == 1:
		sys.stderr.write("Usage: %s <options>\n" % sys.argv[0])
		sys.stderr.write("       -h - this help text\n")
		sys.stderr.write("       -1 - single-panel mode\n")
		sys.stderr.write("       -a - path to ALL.TXT (-a can be given multiple times)\n")
		sys.stderr.write("       -b - path to BLOCKED.TXT\n")
		sys.stderr.write("       -c - decodes from blocked calls are shown\n")
		sys.stderr.write("       -g - show ghost frames in QSO window (optional argument is slot count)\n")
		sys.stderr.write("       -l - show rolling channel load estimate\n")
		sys.stderr.write("       -q - show only CQ lines in left column\n")
		sys.stderr.write("       -n - show count of recent receivers\n")
		sys.stderr.write("       -s - disable SNR column\n")
		sys.stderr.write("       -t - enable delta-T column\n")
		sys.stderr.write("       -p - multi-band mode\n")
		sys.stderr.write("       -m - my call\n")

# EOF: ft8con
