#!/usr/bin/env python
"""
A little hack to configure cell modems and show their configuration
"""

import argparse
import collections
import os
import queue
import re
import select
import signal
import sys
import termios
import threading
import time
import traceback

class Error(Exception):
	pass

class Timeout(Error):
	pass

class TryAgain(Error):
	pass

class BadResponse(Error):
	pass

class CriticalError(Error):
	pass


def parse_gsm_int(literal):
	val = int(literal)
	return None if val==99 else val


class Action(object):
	"""A piece of communication with the modem.

	This usually consists of sending a command in enter, waiting, and
	then parsing a response in process.

	Process should raise BadResponse if the response didn't match
	what the system expected.

	The system will cancel the action after timeout seconds (and usually
	try to reset the modem).

	You can, if necessary, also override the run(modem) method if
	the enter - timeout? - process (or reset) thing doesn't work
	for an action.

	In your communication with the modem, always pass timeout=timeout_after.
	"""
	timeout_after = 0.3

	def enter(self, modem):
		"""override to perform actions on entering the state.
		"""

	def process(self, modem, res_line):
		"""override to perform actions on input received.

		If this returns anything else than None, the state machine will enter
		that state.
		"""

	def timeout(self, modem):
		"""override to change default behaviour (quietly ignore)
		"""
		if modem.debug:
			print("! Timeout on %s"%self.__class__.__name__)

	def bad_response(self, modem, exception):
		"""override to change the default behaviour (gobble chars and report).
		"""
		modem.gobble_chars()
		sys.stderr.write("Bad response in %s (%r)\n"%(
			self.__class__.__name__, exception))

	def run(self, modem):
		if modem.debug:
			print("<%s>"%self.__class__.__name__)

		try:
			self.enter(modem)

			while True:
				try:
					self.process(modem, modem.readln(timeout=self.timeout_after))
				except TryAgain:
					continue
				except Timeout:
					self.timeout(modem)
				except BadResponse as ex:
					self.bad_response(modem, ex)
				break

		except CriticalError:
			raise
		except Exception:
			sys.stderr.write("Unhandled exception in %s:\n"%self.__class__.__name__)
			traceback.print_exc()

		if modem.debug:
			print("</%s>"%self.__class__.__name__)


class SimpleAction(Action):
	"""An action in which the command attribute is sent to the modem.

	You will still have to write process.
	"""
	command = None

	def enter(self, modem):
		modem.writeln(self.command, timeout=self.timeout_after)


class OkedAction(SimpleAction):
	"""A SimpleAction that just checks whether the response is OK.

	Just define command for these and all is fine.
	"""
	def process(self, modem, res_line):
		if res_line.strip()!="OK":
			raise BadResponse(res_line)


class InquiryAction(SimpleAction):
	"""A SimpleAction that has a single response line, followed by an
	ok as expected response.

	Instanciating classes must define a parse_input(modem, res_line)
	class method.

	The main thread will usually need to wait for the completion of these
	guys, so do some modem.signal_queue.put(...) into your parse_input
	and then in_queue.get in the main thread.
	"""
	def process(self, modem, res_line):
		try:
			self.parse_input(modem, res_line)
		except BadResponse:
			raise
		except Exception as exc:
			traceback.print_exc()
			modem.signal_queue.put(("ERROR", "Response parse error: %s"%exc))
		status = modem.readln(0.1).strip()
		if status!="OK":
			raise BadResponse(status)


class ParameterisedAction(SimpleAction):
	"""An Action with a parameter.

	These need to be constructed with one parameter; this parameter
	is available as self.parameter in the enter method, which should
	be overridden to send the desired command.
	"""
	def __init__(self, parameter):
		self.parameter = parameter
	

class Stop(SimpleAction):
	"""stops the modem thread.

	After you've executed that, call modem.handling_thread.join() in the
	main thread.

	This will also delete the queue attribute, so any attempt to
	queue further commands will end in an AttributeError.
	"""
	command = "ATZ"
	timeout_after = 1

	def process(self, modem, res_line):
		# ignore response from modem, just shut everything down
		modem.run_thread = False
		del modem.queue

	def timeout(self, modem):
		self.process(modem, "")


class Reset(Action):
	"""gobble up all characters and reset the modem.
	"""
	def enter(self, modem):
		modem.gobble_chars()
		modem.writeln("ATZ")

	def process(self, modem, res_line):
		if res_line.strip()=="ATZ":
			raise TryAgain()

		if res_line.strip() in ["OK", "ABORTED"]:
			modem.run(DisableEcho())
			modem.run(UpdateRegistrationStatus())

		else:
			raise CriticalError("Cannot reset modem")


class Reboot(Action):
	"""try to reboot the card.
	"""
	def enter(self, modem):
		modem.gobble_chars()
		modem.writeln("AT+CFUN=16")

	def process(self, modem, res_line):
		if res_line.strip().startswith("AT"):
			raise TryAgain()

		if not res_line.strip()=="OK":
			raise CriticalError("Modem reboot failed.")


class DisableEcho(SimpleAction):
	"""disables command echo (automatically run after a reset)
	"""
	command = "ate0"

	def process(self, modem, res_line):
		if res_line.strip()!="OK":
			raise TryAgain()


class EnableCellUpdates(OkedAction):
	"""enables automatic cell updates (automatically run after a reset)
	"""
	command = "at+creg=2"


class UpdateRegistrationStatus(InquiryAction):
	"""sets status, la, call from res_line.

	Status names by GSM code "offline" (0), "home" (1), "searching" (2),
	"rejected" (3), "unknown" (4), "roaming" (5).  LA and cell number
	are None if not either home or roaming.
	"""
	command = "at+creg?"
	regcode_to_name = {
		0: "offline",
		1: "home",
		2: "searching",
		3: "rejected",
		4: "unknown",
		5: "roaming",}

	def parse_input(self, modem, res_line):
		modem.la = modem.cell = None
		mat = re.match(
			r'\+CREG:\s*(\d+),(\d+),"([A-F\d]+)","([A-F\d]+)",(\d+)', res_line)
		if mat:
			modem.status = self.regcode_to_name[int(mat.group(2))]
			modem.la, modem.cell = mat.group(3), mat.group(4)

		else:
			# We're probably offline
			mat = re.match(r'\+CREG:\s*(\d+),(\d+)', res_line)
			if mat:
				modem.status = self.regcode_to_name[int(mat.group(2))]
			else:
				raise BadResponse(res_line)


class UpdateOperator(InquiryAction):
	command = "at+cops?"

	def parse_input(self, modem, res_line):
		mat = re.match(r'\+COPS:\s*(\d*)(?:,(\d*),("[^"]*"|\d+),(\d*))?',
			res_line)
		if not mat:
			raise BadResponse(res_line)

		if (mat.group(3)) is None: # not registered
			modem.carrier = None
		else:
			modem.carrier = mat.group(3)


class UpdateSignalQuality(InquiryAction):
	command = "at+csq"

	def parse_input(self, modem, res_line):
		mat = re.match(r"\+CSQ:\s*(\d+),(\d+)", res_line)
		if not mat:
			raise BadResponse()
		modem.signal_strength, _ = list(map(parse_gsm_int, mat.groups()))

	def timeout(self, modem):
		# don't reset the modem just because signal strength couldn't be read
		pass


class GetXReg(InquiryAction):
	"""inquire the band the modem is running on.

	puts XREG and a tuple into the queue; tuple[2] is a code for the
	band.
	"""
	command = "at+xreg?"

	def parse_input(self, modem, res_line):
		mat = re.match(r"\+XREG:\s*(\d+),(\d+),(\w+),(\d+)", res_line)
		if not mat:
			raise BadResponse(res_line)
		modem.signal_queue.put(("XREG", mat.groups()))


class GetAccessTech(InquiryAction):
	"""inquire the access technology used by the modem.

	puts XACT and a tuple (tech, gibberish) on the queue.
	"""
	command = "at+xact?"

	def parse_input(self, modem, res_line):
		mat = re.match(r"\+XACT:\s*(\d+),(.*)", res_line)
		if not mat:
			raise BadResponse(res_line)
		code, gibberish = mat.groups()
		label = {
			'0': "GSM",
			'1': "UMTS",
			'2': "LTE",}.get(code, "Unknown")
		modem.signal_queue.put(("XACT", (label, gibberish)))


class SetAccessTech(ParameterisedAction):
	"""sets the access method to one of GSM, UMTS, or LTE.
	"""
	def enter(self, modem):
		par = {
			"GSM": "0",
			"UMTS": "1",
			"LTE": "2"}[self.parameter]
		modem.writeln("AT+XACT=%s"%par, timeout=self.timeout_after)


class UpdateCFUN(InquiryAction):
	"""updates the cfun attribute on the modem.

	This is probably Sierra-specific, but on these chips
	this should be 1, 0, or you need to run the EnableModem function.
	(I have no idea what I'm doing here).
	"""
	command = "at+cfun?"

	def parse_input(self, modem, res_line):
		mat = re.match(r"\+CFUN:\s*([\w,]+)", res_line)
		if not mat:
			raise BadResponse()
		modem.cfun = mat.group(1)


class EnableModem(OkedAction):
	"""Sets the CFUN of a (Sierra?) modem to 1.

	(without which it won't run as a modem).
	"""
	command = "at+cfun=1"


class Register(SimpleAction):
	command = "at+cops=0,0"
	timeout_after = 40

	def process(self, modem, res_line):
		modem.run(UpdateRegistrationStatus())
		
	def timeout(self, modem):
		# abort the registration attempt
		modem.run(Reset())


class Unregister(OkedAction):
	command = "at+cops=2,0"
	timeout_after = 40

	def timeout(self, modem):
		# abort the unregistration attempt
		modem.run(Reset())


class EnableLongIds(OkedAction):
	"""configure modem to report long provider ids.
	"""
	command = "at+cops=3,0"


class ListProviders(InquiryAction):
	command = "at+cops=?"
	timeout_after = 20

	def parse_input(self, modem, res_line):
		if not res_line.startswith("+COPS: "):
			raise BadResponse(res_line)

		lines = []
		for mat in re.finditer(
				r'\((?P<status>\d),'
				r'(?P<network>"[^"]*"),'
				r'(?P<whatever>"[^"]*")?,'
				r'"(?P<provid>\d+)"'
				r'[^)]*\)', res_line):
			fillers = mat.groupdict()
			fillers["clearstatus"] = {
					'1': "available", 
					"2": "active", 
					"3": "forbidden"}.get(mat.group("status"), "unknown")
			lines.append("{network} ({provid}) -- {clearstatus}".format(**fillers))

		modem.signal_queue.put(("PROVLIST", lines))
			
	def timeout(self, modem):
		print("Timeout while inquiring networks?")

	def bad_response(self, modem, exc):
		modem.signal_queue.put(("ERROR", "No providers found"))


class SerialConnection(object):
	"""a thin facade for a serial port operated through a select loop.

	The idea is that you derive from this class and do something interesting
	in your process_line method; send your own data using writeln.

	This thing waits in an event loop most of the time.  To let you do
	your thing, you can register timed events that are being called out
	of the select loop.
	"""
	def __init__(self, dev_path="/dev/ttyS0", 
			bitrate=9600, debug=0):
		self.debug = debug
		self.old_term_status = None
		self.bitrate, self.dev_path = bitrate, dev_path
		self.action_failed = False
		self._open_ser_port()

	def __del__(self):
		"""tries to restore the old status if possible (important if
		we're talking to stdin).
		"""
		self._close_ser_port()

	def _close_ser_port(self, urgent=False):
		if self.debug:
			print("Closing serial line on %s"%self.ser_port)

		if getattr(self, "ser_port", None) is None:
			return
		if not urgent and getattr(self, "old_term_status", None):
			termios.tcsetattr(self.ser_port, termios.TCSANOW, self.old_term_status)
		os.close(self.ser_port)
		self.ser_port = None

	def _reopen_ser_port(self):
		try:
			self._close_ser_port()
		except os.error:
			pass
		self._open_ser_port()
	
	def _open_ser_port(self):
		for i in range(30):
			# Try a couple of times -- maybe the device will yet come up.
			try:
				self.ser_port = os.open(self.dev_path, os.O_RDWR | os.O_NDELAY)
			except os.error:
				if self.debug:
					print("Open %s failed.  Waiting and trying again later."%(
						self.dev_path))
				time.sleep(1)
			else:
				break
		else:
			# last time, propagate the exception
			self.ser_port = os.open(self.dev_path, os.O_RDWR | os.O_NDELAY)

		if self.debug:
			print("Opened serial line on %s"%self.ser_port)
		self.old_term_status = termios.tcgetattr(self.ser_port)
		self.output_buffer = collections.deque()
		self.line_hook = None
		self.input_buffer = []
		self.timed_jobs = []
		self.timer_lock = threading.Lock()
		self._init_ser_port()

	def _init_ser_port(self):
		t = self.old_term_status
		c_iflag, c_oflag, c_cflag, c_lflag, c_ispeed, c_ospeed, c_cc  = list(range(7))
		# set to raw mode
		t[c_iflag] = t[c_iflag] & \
			 ~(termios.IGNBRK|termios.BRKINT|termios.PARMRK
			 	|termios.ISTRIP|termios.INLCR|termios.IGNCR
			 	|termios.ICRNL|termios.IXON)
		t[c_oflag] = t[c_oflag] & ~termios.OPOST;
		t[c_lflag] = t[c_lflag] & ~(termios.ECHO|termios.ECHONL
			|termios.ICANON|termios.ISIG|termios.IEXTEN);
		t[c_cflag] = t[c_cflag] & ~termios.CSIZE;
		t[c_cflag] = t[c_cflag] | termios.CS8;
		t[c_cflag] = t[c_cflag] & ~(termios.CSTOPB|termios.PARENB|termios.CRTSCTS)
		t[c_ispeed] = t[c_ospeed] = self._get_baudconst(self.bitrate)
		termios.tcsetattr(self.ser_port, termios.TCSANOW, t)

	def _get_baudconst(self, bitrate):
		baudconst = "B%d"%bitrate
		if baudconst not in termios.__dict__:
			raise Error(
				"Baud rate %d not supported by this system"%bitrate)
		return termios.__dict__[baudconst]

	def close(self):
		self._close_ser_port()

	def set_bitrate(self, bitrate):
		c_iflag, c_oflag, c_cflag, c_lflag, c_ispeed, c_ospeed, c_cc  = list(range(7))
		t = termios.tcgetattr(self.ser_port)
		t[c_ispeed] = t[c_ospeed] = self._get_baudconst(bitrate)
		termios.tcsetattr(self.ser_port, termios.TCSANOW, t)

	def writeln(self, data, timeout=None):
		self.write((data+"\r\n"), timeout)

	def write(self, data, timeout=None):
		if self.debug:
			print(">>>> %s"%repr(data.strip()))
		for c in data:
			self.send_char(c, timeout)

	def read_char(self, timeout=None):
		"""gets the next char with timeout
		"""
		try:
			in_ready, out_r, excs = select.select([self.ser_port], [], [], timeout)
		except select.error:
			# Most likely interrupted syscall
			raise Timeout("Select for read was interrupted")
		if self.ser_port in in_ready:
			c = os.read(self.ser_port, 1)
			return c.decode("ascii")
		else:
			raise Timeout("Read timeout on serial port")

	def gobble_chars(self):
		"""reads all remaining input from the serial port and discards it.
		"""
		while True:
			try:
				self.read_char(timeout=0.01)
			except Timeout:
				break

	def send_char(self, ch, timeout=None):
		"""sends the ch reliably
		"""
		for i in range(3):
			try:
				_, out_ready, _ = select.select([], [self.ser_port], [], timeout)
			except select.error:
				# Most likely interrupted syscall, try again a few times
				pass
			else:
				break
		else:
			raise Timeout("Cannot send char '%s'"%ch)

		if self.ser_port in out_ready:
			bytes_written = os.write(self.ser_port, ch.encode("ascii"))
			if bytes_written==1:
				return
		raise Timeout("Write timeout on serial port")

	def wait_for_input(self):
		"""waits until something happens on my line
		"""
		while True:
			try:
				if self.serPort in select.select([self.serPort], [], [])[0]:
					return
			except select.error:
				pass

	def process_out_of_order(self, res_line):
		"""override this to process unsolicited input from the serial port.

		If this returns True, res_line will not be passed through to the
		action.
		"""
		return False

	def readln(self, timeout=None):
		resp = []
		while True:
			resp.append(self.read_char(timeout))
			if resp[-1]=='\n':
				if "".join(resp).strip(): # skip empty lines
					break
				else:
					resp = []
		
		res = ''.join(resp)
		if self.debug:
			print("<<<< %s"%repr(res.strip()))

		if self.process_out_of_order(res):
			# out-of-order signal received, read next line
			res = self.readln(timeout)

		return res


class NotifyingAttribute(object):
	"""An attribute that will send notices of changes down a queue on 
	the parent.

	This is for CellModem, where signal_queue is used to interface with a
	UI.

	You must give the name that's going to be used for notification with
	the constructor.  That should be the attribute name this is stored
	under.
	"""
	def __init__(self, name, default=None):
		self.name = name
		self.default = default
		self.attr_name = "__"+name
	
	def __set__(self, object, value):
		setattr(object, self.attr_name, value)
		if object.signal_queue:
			object.signal_queue.put((self.name, value))
	
	def __get__(self, object, cls):
		return getattr(object, self.attr_name, self.default)


class CellModem(SerialConnection):
	"""A facade for the cell modem.

	You'll usually want to call start on these (this starts a thread).
	Then, call modem.queue.put(action), where action is one of the
	actions within this module.

	Use the Stop action to shut down the thread before exiting the
	program; call the CellModem's join method then.

	You can set a signal_queue on this; this must be a Queue instance,
	and some changes will be posted there as pairs of (label, new_value)
	there.
	"""
	signal_strength = NotifyingAttribute("signal_strength")
	status = NotifyingAttribute("status")
	carrier = NotifyingAttribute("carrier")
	la = NotifyingAttribute("la")
	cell = NotifyingAttribute("cell")
	cfun = NotifyingAttribute("cfun")

	def __init__(self, *args, **kwargs):
		self.signal_queue = None
		do_reboot = kwargs.pop("reboot_first", False)
		SerialConnection.__init__(self, *args, **kwargs)
		if do_reboot:
			self.run(Reboot())
		self._setup()

	def _close_ser_port(self, urgent=False):
		# overridden to let us reset the various attributes that we have
		SerialConnection._close_ser_port(self, urgent)
		self.status = "unknown"
		self.carrier = None

	def _open_ser_port(self):
		# overridden to let us configure the modem
		SerialConnection._open_ser_port(self)
		self.status = "unknown"
		self.run(DisableEcho())
		self.run(EnableCellUpdates())

	def _setup(self):
		self.run(Reset())
		self._update_for_status()
	
	def _update_for_status(self):
		self.run(UpdateRegistrationStatus())

		if self.status in ["home", "roaming"]:
			# update?  let's see...
			self.run(UpdateOperator())

		elif self.status in ["offline", "searching", "rejected", "unknown"]:
			self.carrier = None

	def run(self, action):
		"""runs an Action.

		Client code should usually start a thread and send messages through the
		queue attribute.
		"""
		try:
			action.run(self)
			self.action_failed = False
		except os.error:
			self._reopen_ser_port()
			try:
				action.run(self)
			except os.error:
				self.action_failed = True

	_unsolicited_creg_pattern = re.compile(
		r'\+CREG: (\d+)(?:,"([A-F0-9]+)","([A-F0-9]+)",\d)?\s*$')

	def process_out_of_order(self, res_line):
		mat = self._unsolicited_creg_pattern.match(res_line)
		if mat:
			self.status = UpdateRegistrationStatus.regcode_to_name[
				int(mat.group(1))]

			if mat.group(2):
				self.la, self.cell = mat.group(2), mat.group(3)
			else:
				self.la = self.cell = None

			return True

		if res_line.startswith("+PBREADY"): # whatever this is...
			return True

	def _handling_thread(self):
		while self.run_thread:
			if self.queue.qsize==0:
				with self.idle_condition:
					self.idle_condition.notify_all()

			action = self.queue.get()
			try:
				self.run(action)
			except CriticalError:
				raise
			except Exception:
				sys.stderr.write("Action errored out (fix!)")
				traceback.print_exc()

	def sync(self, timeout):
		"""if a handling thread is running, wait until the queue is empty
		and no action is running.

		Unfortunately, there's no way to figure out whether a timeout occurred
		or the notification actually is in.

		So far, we only use it in regression tests, and there tests will fail
		on timeouts.  Let's hope that's good enough for now.
		"""
		self.idle_condition.acquire()
		self.idle_condition.wait(timeout)

	def start_handling_thread(self):
		"""starts a thread communicating with the modem.
		"""
		if hasattr(self, "queue"):
			raise Exception("Queue on modem already defined, cannot start"
				" a handling thread.")
		# these are undone by the Stop action
		self.run_thread = True
		self.queue = queue.Queue()
		self.idle_condition = threading.Condition()
		self.handling_thread = threading.Thread(target=self._handling_thread)
		self.handling_thread.daemon = True
		self.handling_thread.start()

	def join(self, timeout):
		self.handling_thread.join(timeout)
		del self.handling_thread


def interact(opts, modem):
	in_queue = queue.Queue()
	modem.signal_queue = in_queue

	modem.queue.put(UpdateCFUN())
	resp, val = in_queue.get(True, 0.2)
	assert resp=='cfun'

	print("Modem is >%s<"%modem.status)

	if val!='1,0':
		print("Function was %s, enabling modem"%val)
		modem.queue.put(EnableModem())

	try:
		modem.queue.put(GetAccessTech())
		resp, val = in_queue.get(True, 0.2)
		print("Using %s"%val[0])
	except BadResponse as ex:
		print("Could not get current standard: %s"%ex)

	try:
		modem.queue.put(GetXReg())
		resp, val = in_queue.get(True, 0.2)
		print("Running on band %s"%val[2])
	except BadResponse as ex:
		print("Could not get registration status: %s"%ex)

	if opts.set_method:
		modem.queue.put(SetAccessTech(opts.set_method))

	if opts.do_connect:
		modem.queue.put(Register())
	
	if opts.do_list:
		modem.queue.put(EnableLongIds())
		modem.queue.put(ListProviders())
		resp, val = in_queue.get(True, 30)
		if resp=="PROVLIST":
			print("\nProviders available:")
			print("\n".join(val))
		else:
			print("Failure while listing providers:", val)


def parse_command_line():
	parser = argparse.ArgumentParser(description="Configure/inquire"
		" cell modems.")
	parser.add_argument("-d", "--device", metavar="DEV", dest="devpath",
		action="store", default="/dev/ttyACM0", help="Use DEV to send"
		" AT commands.")
	parser.add_argument("-a", "--access-method", metavar="METH", 
		dest="set_method", action="store", default=None, 
		help="Set access method to one of GSM, UMTS, or LTE.",
		choices=["GSM", "UMTS", "LTE"])
	parser.add_argument("-r", "--reboot", 
		dest="do_reboot", action="store_true", default=False, 
		help="Reboot the card before doing anything else")
	parser.add_argument("-c", "--connect", 
		dest="do_connect", action="store_true",
		help="Explicitly ask the modem to connect to the infrastructure"
			" network (not usually necessary in most setups).")
	parser.add_argument("-l", "--list",
		dest="do_list", action="store_true", 
		help="List providers available for current access method")

	parser.add_argument("--debug", dest="debug",
		action="store_true", help="Dump serial port communications to stdout")
	
	return parser.parse_args()


def main():
	opts = parse_command_line()
	modem = CellModem(dev_path=opts.devpath, 
		bitrate=115200, debug=opts.debug, reboot_first=opts.do_reboot)
	modem.start_handling_thread()

	try:
		interact(opts, modem)
	finally:
		modem.queue.put(Stop())
		modem.join(3)


if __name__=="__main__":
	try:
		main()
	except queue.Empty:
		sys.stderr.write("Read from modem timed out.  Perhaps try -r, or"
			" suspend/resume.\n")

