#!/usr/bin/env -S python3 -B
#
#   ft8sync - synchronize frequency of two CAT connections.
#
#   Copyright (C) 2025 by Matt Roberts.
#   License: GNU GPL3 (www.gnu.org)
#
#

#
#  TODO:
#     0. rename this to vfosync or similar
#        + alternatively, integrate the logic into ft8sdr directly
#     1. add to Makefile install targets
#     2. scan the slave's FA, to not send unnecessary startup commands
#        + alternatively, only connect to the slave intermittently
#

# system modules
import subprocess
import getopt
import socket
import select
import time
import math
import sys
import os

# local modules
from fdutils import *
from messages import *
from version import *


#
#  globals - default values
#

# the amount of time between each scan
TimeoutCAT = 2.0 # sec

# the VFO resolution, measured in Hz; change with -r option (TODO)
Resolution = 1 # Hz


#
#  usage()
#
def usage():
	# where to write usage text
	where = sys.stdout

	where.write("\n")
	where.write("Usage: %s [options] <tcp_master_port> <tcp_slave_port>\n" % os.path.basename(sys.argv[0]))
	where.write("\n")
	where.write("    This application connects to two instances of rigctld, and keeps\n")
	where.write("    the slave copy frequency synchronized with the master.\n")
	where.write("\n")
	sys.exit(1)


# last message sent; used to de-dupe messages
last_msg = None

#
#  unique_trace - send a trace message but not repeatedly
#
def unique_trace(msg):
	global last_msg

	# send the message only if it is different than the last one
	if msg != last_msg:
		last_msg = msg
		send_trace(msg)


#
#  unique_warn - send a warning message but not repeatedly
#
def unique_warn(msg):
	global last_msg

	# send the message only if it is different than the last one
	if msg != last_msg:
		last_msg = msg
		send_warning(msg)


#
#  main()
#
def main():
	global TimeoutCAT, Resolution

	# read the command line
	cmdline = None
	try:
		optlist, cmdline = getopt.getopt(sys.argv[1:], 'v')
		for opt in optlist:
			if opt[0] == '-v':
				sys.stdout.write("ft8sync version %s\n" % GetModemVersion())
				return 0
	except Exception as ex:
		sys.stderr.write("Error: %s\n" % str(ex))
		return 1
	except getopt.GetoptError as ex:
		sys.stderr.write("Error: %s\n" % str(ex))
		return 1

	# and validate the ft8modem command line
	if not cmdline or len(cmdline) < 2:
		usage()
	
	# minimum time delay between frequency changes
	min_delay = 5.0 # sec

	# last time the slave was moved
	last_move = 0

	# last master frequency
	last_master = 0

	# last slave frequency
	last_slave = 0

	# minimum dial frequency allowed
	min_dial = 10000 # Hz

	# how long should select(...) sleep at most
	select_delay = 0.5 # sec

	# commands to scan periodically
	catscan = [ 'f' ]  # (READ: FA)

	# bytes per read() or recv() call
	block_size = 128
	
	# I/O buffers
	catbuf = b''        # ingress buffer for CAT socket (bytes object for raw I/O)

	# CAT sockets and scan state
	ports = list(map(int, cmdline))  # the port numbers
	catsocks = [ None ] * len(ports) # the sockets (master, slave)
	catcons = [ 0 ] * len(ports)     # last time connected
	catidx = -1               # index of last cat command in rotation
	cattime = 0               # time last command sent

	#
	#  main program loop
	#
	while True:
		# read the clock
		now = time.time()

		try:
			#
			#  SOCKET: keep CAT connections going if they stop
			#
			for i in range(len(ports)):
				if not catsocks[i] and (now - catcons[i] >= 1.0):
					catcons[i] = now
					try:
						catsocks[i] = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
						catsocks[i].connect(('localhost', ports[i]))
						os.set_blocking(catsocks[i].fileno(), False)
						unique_trace("Connected to CAT port %d" % ports[i])
					except:
						unique_warn("CAT connect failed [%d]" % i)
						catsocks[i] = None

			#
			#  select(...) - watch the pipes and sockets
			#
			r = [ ]
			for sd in catsocks:
				if sd: r.append(sd)
			w = [ ] # TODO: include CAT sockets??
			e = [ ]
			r, w, e = select.select(r, w, e, select_delay)

			# handle the slave sockets closing
			for i in range(1, len(catsocks)):
				if catsocks[i] in r:
					# read a block of raw bytes from the socket and update the buffer
					buf = catsocks[i].recv(block_size)

					# if the socket disconnected, close and null it
					if not buf:
						try:
							catsocks[i].close()
							unique_warn("CAT socket closed [%d]" % i)
						except: pass
						catsocks[i] = None

			#
			#  DATA: CAT responses (radio -> ft8sync)
			#
			if catsocks[0] in r:
				# read a block of raw bytes from the socket and update the buffer
				buf = catsocks[0].recv(block_size)
				if not buf:
					try: catsocks[0].close()
					except: pass
					catsocks[0] = None
				else:
					catbuf += buf

				# for each complete line in the buffer...
				while b'\n' in catbuf:
					# get one line, update buffer
					line, catbuf = catbuf.split(b'\n', 1)
					line = line.decode('ascii')

					# skip empty lines
					if not line:
						continue
					line = line.strip()
					if not line:
						continue

					#
					#  handle specific messages
					#
					#  NOTE: Most cases check to see which command was sent most
					#        recently, since many CAT responses don't echo the
					#        command, just the data requested.
					#

					# 'f' - the VFO-A query response
					if cmd == 'f':
						last_master = int(line)     # read the new value as integer (Hz)
						cmd = None

						# DEBUG:
						# unique_trace("master FA frequency report: " + str(last_master))

						# if 
						if last_master != last_slave:
							# DEBUG:
							unique_trace("master FA != slave FA")

							# if enough time has gone by
							if (now - last_move) >= min_delay:
								# compute effective FA respecting global 'Resolution'
								new_slave = math.trunc(math.trunc(last_master / Resolution) * Resolution)

								# DEBUG:
								unique_trace("effective slave FB is: " + str(new_slave))

								# if the slave target frequency has changed, move the hardware
								if new_slave != last_slave:
									# DEBUG:
									unique_trace("set slave FB to " + str(new_slave))

									# (try to) send a 'F' command to the slave
									cmd = 'F %d' % new_slave
									for s in catsocks[1:]:
										if s: socket_send_all(s, (cmd + '\r\n').encode('ascii'))

									last_slave = new_slave

			# re-read the current time
			now = time.time()

			# SCAN: run periodic CAT commands to update state
			if catsocks[0] and (now - cattime) >= TimeoutCAT:
				cmd = None
				while not cmd:
					catidx = (catidx + 1) % len(catscan)
					cmd = catscan[catidx]

					# skip certain commands under specific circumstances
					if cmd == 'i':
						if not split or not fb_scan:
							cmd = None
					elif cmd == 't' and not UsePTT:
						cmd = None

				if cmd == 'f':
					last_op = "Get VFO-A"
				# elif ...

				try:
					#unique_trace("CAT scan: %s" % last_op) # this is very verbose; use for debugging only
					socket_send_all(catsocks[0], (cmd + '\r\n').encode('ascii'))
					cattime = now
				except Exception as ex:
					send_error("CAT command '%s' failed: %s" % (cmd, str(ex)))
		except KeyboardInterrupt as ex:
			try:
				ft8modem.stdin.close()
			except:
				pass
			sys.exit(0)
		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]
			send_error("Caught unexpected: %s (%s) in %s at line %d" % (exc_type, str(ex), fname, exc_tb.tb_lineno))
	
	# close the CAT sockets
	for i in [ 0, 1 ]:
		if catsocks[i]:
			try:
				# close the connection
				catsocks[i].close()
			except:
				pass # nop, we're shutting down anyway

	# all done
	sys.exit(0)


#
#  entry point
#
if __name__ == '__main__':
	main()

# EOF
