class IdpModemAsyncioClient: """A satellite IoT messaging modem on Inmarsat's IsatData Pro service. Attributes: port: The serial port name e.g. `/dev/ttyUSB0` baudrate: The baudrate of the serial port e.g. `9600` crc: A boolean used if CRC-16 is enabled for long serial cables loop: The asyncio event loop (uses default if not provided) """ def __init__(self, port: str = '/dev/ttyUSB0', baudrate: int = 9600, loop: AbstractEventLoop = None, logger: logging.Logger = None, log_level: int = logging.INFO): """Initializes the class. Args: port: The serial port name e.g. `/dev/ttyUSB0` baudrate: The serial port baudrate crc: enables CRC-16 for long serial cables loop: (optional) external asyncio event loop to use logger: (optional) external logger to use log_level: Level for the logger to record """ self._log = logger or get_wrapping_logger(log_level=log_level) self.port = port self.baudrate = baudrate self.crc = None self.loop = loop self._thread = current_thread() self._event = Event() self._serial = None self._pending_command = None self._pending_command_time = None self._retry_count = 0 self._serial_async_error_count = 0 @property def port(self): return self._port @port.setter def port(self, value): valid = len(glob(value)) == 1 if not valid: err_msg = 'Serial port {} not found'.format(value) self._log.error(err_msg) raise ValueError(err_msg) self._port = value @property def baudrate(self): return self._baudrate @baudrate.setter def baudrate(self, value): if value not in BAUDRATES: raise ValueError('Unsupported baudrate {}'.format(value)) self._baudrate = value def _handle_at_error(self, at_command: str, err_code: Union[str, int], return_value: any = None) -> any: """Manages log and/or raising errors. Args: at_command: The command that experienced an error err_code: The error code received return_value: The value to return after logging Raises: Re-raises the exceptions """ error_str = AT_ERROR_CODES[int(err_code)] self._log.error("{} Exception: {}".format(at_command, error_str)) if return_value is None: raise AtException(error_str) return return_value async def _send(self, data: str) -> str: """Coroutine encodes and sends an AT command. Args: writer: A serial_asyncio writer data: An AT command string Returns: A string with the original data. """ if self.crc: data = get_crc(data) self._pending_command = data to_send = self._pending_command + '\r' self._log.verbose('Sending {}'.format(_printable(to_send))) self._pending_command_time = time() await self._serial.write_async(to_send.encode()) return data async def _recv(self, timeout: int = 5) -> list: """Coroutine receives and decodes data from the serial port. Parsing stops when 'OK' or 'ERROR' is found. Args: reader: A serial_asyncio reader Returns: A list of response strings with empty lines removed. Raises: AtTimeout if the response timed out. """ CRC_DELAY = 1 #: seconds after response body response = [] verbose_response = '' msg = '' try: while True: chars = (await wait_for( self._serial.read_until_async(b'\r\n'), timeout=timeout)).decode() msg += chars verbose_response += chars if msg.endswith('\r\n'): self._log.verbose('Processing {}'.format(_printable(msg))) msg = msg.strip() if msg != self._pending_command: if msg != '': # empty lines are not included in response list # but are preserved in verbose_response for CRC response.append(msg) else: # remove echo for possible CRC calculation echo = self._pending_command + '\r' self._log.verbose('Removing echo {}'.format( _printable(echo))) verbose_response = verbose_response.replace(echo, '') if msg in ['OK', 'ERROR']: try: response_crc = (await wait_for( self._serial.read_until_async(b'\r\n'), timeout=CRC_DELAY)).decode() if response_crc: response_crc = response_crc.strip() if _serial_asyncio_lost_bytes(verbose_response): self._serial_async_error_count += 1 if not validate_crc(response=verbose_response, candidate=response_crc): err_msg = '{} CRC error for {}'.format( response_crc, _printable(verbose_response)) self._log.error(err_msg) raise AtCrcError(err_msg) else: self._log.verbose('CRC {} ok for {}'.format( response_crc, _printable(verbose_response))) if not self.crc: # raise AtCrcConfigError('CRC found but unexpected') #: new <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< self.crc = True except TimeoutError: if self.crc: raise AtCrcConfigError('CRC expected but not found') #: new <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< self.crc = False break msg = '' except TimeoutError: timeout_time = time() - self._pending_command_time err = ('AT timeout {} after {} seconds ({}s after command)'.format( self._pending_command, timeout, timeout_time)) raise AtTimeout(err) return response async def command(self, at_command: str, timeout: int = 5, retries: int = 0) -> list: """Submits an AT command and returns the response asynchronously. Proxies a private function to allow for multi-threaded operation. Args: at_command: The AT command string timeout: The maximum time in seconds to await a response. retries: Optional number of additional attempts on failure. Returns: A list of response strings finishing with 'OK', or ['ERROR', '<error_code>'] Raises: AtException if no response was received. AtException if bad CRC response count exceeds retries """ if current_thread() != self._thread: self._log.warning('Call from external thread may crash or hang') loop = get_running_loop() set_event_loop(loop) await sleep(1) #: add a slight delay to mitigate race condition while self._event.is_set(): pass concurrentfuture = run_coroutine_threadsafe( self._command(at_command, timeout, retries), loop) asyncfuture = wrap_future(concurrentfuture) return await asyncfuture else: return await self._command(at_command, timeout, retries) async def _command(self, at_command: str, timeout: int, retries: int) -> list: """Submits an AT command and returns the response asynchronously. Args: at_command: The AT command string timeout: The maximum time in seconds to await a response. retries: Optional number of additional attempts on failure. Returns: A list of response strings finishing with 'OK', or ['ERROR', '<error_code>'] Raises: AtException if no response was received. AtException if bad CRC response count exceeds retries """ try: self._event.set() try: self._log.verbose('Opening serial port {}'.format(self.port)) self._serial = AioSerial(port=self.port, baudrate=self.baudrate, loop=self.loop) except Exception as e: self._log.error('Error connecting to aioserial: {}'.format(e)) try: self._log.verbose('Checking unsolicited data prior to {}'.format( at_command)) self._pending_command_time = time() unsolicited = await self._recv(timeout=0.25) if unsolicited: self._log.warning('Unsolicited data: {}'.format(unsolicited)) # raise AtUnsolicited('Unsolicited data: {}'.format(unsolicited)) except AtTimeout: self._log.verbose('No unsolicited data found') tasks = [self._send(at_command), self._recv(timeout=timeout)] echo, response = await gather(*tasks) if echo in response: response.remove(echo) if len(response) > 0: self._retry_count = 0 if response[0] == 'ERROR': self._log.debug('AT error detected - getting reason') error_code = await self.command('ATS80?') if error_code is not None: response.append(error_code[0]) else: self._log.error('Failed to get error_code from S80') return response raise AtException('No response received for {}'.format(at_command)) except AtCrcError: self._retry_count += 1 if self._retry_count < retries: self._log.error('CRC error retrying') return await self.command( at_command, timeout=timeout, retries=retries) else: error_message = 'Too many failed CRC ({})'.format( self._retry_count) self._retry_count = 0 raise AtException(error_message) finally: if self._serial: self._log.verbose('Closing serial port {}'.format(self.port)) self._serial.close() self._serial = None self._event.clear() async def initialize(self, crc: bool = False) -> bool: """Initializes the modem using ATZ and sets up CRC. Args: crc: desired initial CRC enabled if True Returns: True if successful Raises: AtException on errors other than CRC enabled """ self._log.debug('Initializing modem{}'.format( ' (CRC enabled)' if crc else '')) cmd = 'ATZ;E1;V1' cmd += ';%CRC=1' if crc else '' success = await self.command(cmd) if success[0] == 'ERROR': if int(success[1]) == 100: if crc and self.crc: self._log.debug('CRC already enabled') return True else: self.crc = True await self.initialize(crc) else: return self._handle_at_error(cmd, success[1], return_value=False) self.crc = crc return True async def config_restore_nvm(self) -> bool: """Sends the ATZ command to restore from non-volatile memory. Returns: Boolean success. """ self._log.debug('Restoring non-volatile configuration') cmd = 'ATZ' response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], return_value=False) return True async def config_restore_factory(self) -> bool: """Sends the AT&F command and returns True on success.""" self._log.debug('Restoring factory defaults') cmd = 'AT&F' response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], return_value=False) return True async def config_report(self) -> Tuple[dict, dict]: """Sends the AT&V command to retrive S-register settings. Returns: A tuple with two dictionaries or both None if failed at_config with booleans crc, echo, quiet and verbose reg_config with S-register tags and integer values """ self._log.debug('Querying configuration') cmd = 'AT&V' response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], (None, None)) at_config = response[1] s_regs = response[2] echo, quiet, verbose, crc = at_config.split(' ') at_config = { "crc": bool(int(crc[4])), "echo": bool(int(echo[1])), "quiet": bool(int(quiet[1])), "verbose": bool(int(verbose[1])), } reg_config = {} for reg in s_regs.split(' '): name, value = reg.split(':') reg_config[name] = int(value) return (at_config, reg_config) async def config_save(self) -> bool: """Sends the AT&W command and returns True if successful.""" self._log.debug('Saving S-registers to non-volatile memory') cmd = 'AT&W' response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], False) return True async def config_crc_enable(self, crc: bool) -> bool: """Enables or disables CRC error checking (for long serial cable). Args: crc: enable CRC if true """ self._log.debug('{} CRC'.format('Enabling' if crc else 'Disabling')) cmd = 'AT%CRC={}'.format(1 if crc else 0) response = await self.command(cmd) if response[0] == 'ERROR' and self.crc != crc: return self._handle_at_error(cmd, response[1], False) self.crc = crc return True async def device_mobile_id(self) -> str: """Returns the unique Mobile ID (Inmarsat serial number). Returns: MobileID string. Raises: AtException """ self._log.debug('Querying device Mobile ID') cmd = 'AT+GSN' response = await self.command(cmd) if response[0] == 'ERROR': self._handle_at_error(cmd, response[1]) return response[0].replace('+GSN:', '').strip() async def device_version(self) -> Tuple[str, str, str]: """Returns the hardware, firmware and AT versions. Returns: Dict with hardware, firmware, at version. Raises: AtException """ self._log.debug('Querying device version info') cmd = 'AT+GMR' response = await self.command(cmd) if response[0] == 'ERROR': self._handle_at_error(cmd, response[1]) versions = response[0].replace('+GMR:', '').strip() fw_ver, hw_ver, at_ver = versions.split(',') return {'hardware': hw_ver, 'firmware': fw_ver, 'at': at_ver} async def gnss_continuous_set(self, interval: int=0, doppler: bool=True) -> bool: """Sets the GNSS continous mode (0 = on-demand). Args: interval: Seconds between GNSS refresh. doppler: Often required for moving assets. Returns: True if successful setting. """ self._log.debug('Setting GNSS refresh to {} seconds'.format(interval)) cmd = 'AT%TRK={}{}'.format(interval, ',{}'.format(1 if doppler else 0)) if interval < 0 or interval > 30: raise ValueError('GNSS continuous interval must be in range 0..30') response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], False) return True async def gnss_nmea_get(self, stale_secs: int = 1, wait_secs: int = 35, sentences: list = ['RMC', 'GSA', 'GGA', 'GSV'] ) -> Union[list, str]: """Returns a list of NMEA-formatted sentences from GNSS. Args: stale_secs: Maximum age of fix in seconds (1..600) wait_secs: Maximum time to wait for fix (1..600) sentences: Optional list of NMEA sentence types to get Returns: List of NMEA sentences Raises: ValueError if parameter out of range AtGnssTimeout if no response from GNSS AtException """ self._log.debug('Requesting GNSS fix information') NMEA_SUPPORTED = ['RMC', 'GGA', 'GSA', 'GSV'] BUFFER_SECONDS = 5 if (stale_secs not in range(1, 600+1) or wait_secs not in range(1, 600+1)): raise ValueError('stale_secs and wait_secs must be 1..600') sentence_list = '' for sentence in sentences: sentence = sentence.upper() if sentence in NMEA_SUPPORTED: if len(sentence_list) > 0: sentence_list += ',' sentence_list += '"{}"'.format(sentence) else: raise ValueError('Unsupported NMEA sentence: {}' .format(sentence)) cmd = 'AT%GPS={},{},{}'.format(stale_secs, wait_secs, sentence_list) response = await self.command(cmd, timeout=wait_secs + BUFFER_SECONDS) if response[0] == 'ERROR': if int(response[1]) == 108: raise AtGnssTimeout('Timed out waiting for GNSS fix') else: return self._handle_at_error(cmd, response[1], None) if 'OK' in response: response.remove('OK') response[0] = response[0].replace('%GPS: ', '') return response async def location(self, stale_secs: int = 1, wait_secs: int = 35) -> Location: """Returns a location object. Args: stale_secs: the maximum fix age to accept wait_secs: the maximum time to wait for a new fix Returns: nmea.Location object Raises: AtGnssTimeout if no location data is available """ self._log.debug('Querying location') nmea_sentences = await self.gnss_nmea_get(stale_secs, wait_secs) return location_get(nmea_sentences) async def lowpower_mode_set(self, power_mode: int) -> bool: """Sets the modem power mode (for blockage recovery). Args: power_mode (int): The new power mode Returns: True if successful Raises: ValueError on invalid power_mode """ if power_mode not in POWER_MODES: raise ValueError('Invalid power mode {}'.format(power_mode)) self._log.debug('Setting power mode {}'.format( POWER_MODES[power_mode])) cmd = 'ATS50={}'.format(power_mode) response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], False) return True async def lowpower_mode_get(self) -> int: """Gets the modem power mode. Returns: The integer value of the power mode Raises: AtException if an error was returned """ self._log.debug('Getting power mode') cmd = 'ATS50?' response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], None) return int(response[0]) async def lowpower_wakeup_set(self, wakeup_period: int) -> bool: """Sets the modem wakeup period. Args: wakeup_period (int): The new wakeup period Returns: True if successful Raises: ValueError on invalid wakeup_period """ if wakeup_period not in WAKEUP_PERIODS: raise ValueError('Invalid wakeup period {}'.format(wakeup_period)) self._log.debug('Setting wakeup period {}'.format( WAKEUP_PERIODS[wakeup_period])) cmd = 'ATS51={}'.format(wakeup_period) response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], False) return True async def lowpower_wakeup_get(self) -> int: """Gets the modem wakeup period. Returns: The integer value of the wakeup period Raises: AtException if an error was returned """ self._log.debug('Getting wakeup period') cmd = 'ATS51?' response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], None) return int(response[0]) async def lowpower_notifications_enable(self) -> bool: """Configures low power satellite status and notification assertion. The following events trigger assertion of the notification output: - New Forward Message received - Return Message completed (success or failure) - Trace event update (satellite status change) Returns: True if successful """ self._log.debug('Enabling low power notifications') cmd = 'AT%EVMON=3.1;S88=1030' response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], False) return True async def lowpower_notifications_check(self) -> list: """Returns a list of relevant events.""" self._log.debug('Querying low power notifications') relevant = [] try: reason = await self.notification_check() if reason is not None: if reason['event_cached'] == True: relevant.append('event_cached') if reason['message_mt_received'] == True: relevant.append('message_mt_received') if reason['message_mo_complete'] == True: relevant.append('message_mo_complete') except AtException: self._log.warning('Notification check returned AT exception') finally: return relevant async def message_mo_send(self, data: str, data_format: int, sin: int, min: int = None, name: str = None, priority: int = 4) -> str: """Submits a mobile-originated message to send. Args: data: The data to be sent formatted as base64, hex or text according to `data_format`. data_format: 1: Text, 2: ASCII-Hex, 3: Base64 (MIME) name: (Optional) A unique name for the message, if none is provided a name based on unix timestamp will be assigned priority: 1: High .. 4: Low (default) sin: Service Identification Number (15..255) becomes the first byte of message payload min: (Optional) Message Identification Number (0..255) becomes the second byte of message payload if specified Returns: Name of the message if successful, or the error string """ self._log.debug('Submitting message named {}'.format(name)) if name is None: # Use the 8 least-signficant numbers of unix timestamp as unique name = str(int(time()))[-8:] self._log.debug('Assigned name {}'.format(name)) elif len(name) > 8: name = name[0:8] # risk duplicates create an ERROR resposne self._log.warning('Truncated name to {}'.format(name)) _min = '.{}'.format(min) if min is not None else '' if data_format == 1: data = '"{}"'.format(data) cmd = ('AT%MGRT="{}",{},{}{},{},{}'.format(name, priority, sin, _min, data_format, data)) response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], None) return name async def message_mo_formatted_send(self, message: MobileOriginatedMessage) -> str: """Submits a MobileOriginatedMessage. TODO: not implemented Args: message: A MobileOriginatedMessage object Returns: Name of the message if successful, or the error string """ self._log.debug('Submitting MobileOriginatedMessage') raise NotImplementedError('Formatted message send future feature') async def message_mo_state(self, name: str = None) -> list: """Returns the message state(s) requested. If no name filter is passed in, all available messages states are returned. Returns False is the request failed. Args: name: The unique message name in the modem queue Returns: `list` of `dict` with `name`, `state`, `size` and `sent` Raises: AtException """ self._log.debug('Querying transmit message state{}'.format( ' ={}'.format(name) if name else 's')) cmd = 'AT%MGRS{}'.format('="{}"'.format(name) if name else '') response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], None) # %MGRS: "<name>",<msg_no>,<priority>,<sin>,<state>,<size>,<sent_bytes> if 'OK' in response: response.remove('OK') states = [] for res in response: res = res.replace('%MGRS:', '').strip() if len(res) > 0: name, number, priority, sin, state, size, sent = res.split(',') del number del priority del sin states.append({ 'name': name.replace('"', ''), 'state': int(state), 'size': int(size), 'bytes_sent': int(sent), }) return states @staticmethod def message_state_name(state: int): if state not in MESSAGE_STATES: raise ValueError('Message state {} not defined'.format(state)) return MESSAGE_STATES[state] async def message_mo_cancel(self, name: str) -> bool: """Cancels a mobile-originated message in the Tx ready state.""" self._log.debug('Cancelling message {}'.format(name)) cmd = 'AT%MGRC="{}"'.format(name) response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], False) return True async def message_mo_clear(self) -> int: """Clears the modem transmit queue. Returns: Count of messages deleted Raises: AtException """ self._log.debug('Clearing transmit queue of return messages') cancelled_count = 0 open_count = 0 cmd = 'AT%MGRS' response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1]) if 'OK' in response: response.remove('OK') if '%MGRS:' in response: response.remove('%MGRS:') for message in response: if '%MGRS:' in message: message = message.replace('%MGRS:', '').strip() parts = message.split(',') status = int(parts[4]) name = parts[0].replace('"', '') if status < 6: cancel_explicit = await self.message_mo_cancel(name) if not cancel_explicit: open_count += 1 else: cancelled_count += 1 if open_count > 0: self._log.warning('{} messages still in transmit queue'.format( open_count)) return cancelled_count async def message_mt_waiting(self) -> list: """Returns a list of received mobile-terminated message information. Returns: List of (name, number, priority, sin, state, length, received) Raises: AtException """ self._log.debug('Checking receive queue for forward messages') cmd = 'AT%MGFN' response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1]) if 'OK' in response: response.remove('OK') waiting = [] #: %MGFN: name, number, priority, sin, state, length, bytes_received for res in response: msg = res.replace('%MGFN:', '').strip() if msg.startswith('"FM'): parts = msg.split(',') name, number, priority, sin, state, length, received = parts del number #: unused waiting.append({'name': name.replace('"', ''), 'sin': int(sin), 'priority': int(priority), 'state': int(state), 'length': int(length), 'received': int(received)}) return waiting @staticmethod def _message_mt_parse(mgfg_response: str, data_format: int) -> dict: #:%MGFG:"<msgName>",<msgNum>,<priority>,<sin>,<state>,<length>,<data_format>,<data> parts = mgfg_response.replace('%MGFG:', '').strip().split(',') sys_msg_num, sys_msg_seq = parts[1].split('.') msg_sin = int(parts[3]) data_str_no_sin = parts[7] if data_format == FORMAT_HEX: data = '{:02X}'.format(msg_sin) + data_str_no_sin databytes = bytes.fromhex(data) elif data_format == FORMAT_B64: databytes = bytes([msg_sin]) + b64decode(data_str_no_sin) data = b64encode(databytes).decode('ascii') elif data_format == FORMAT_TEXT: data_str_no_sin = data_str_no_sin[1:len(data_str_no_sin) - 1] data = '\\{:02x}'.format(msg_sin) + data_str_no_sin databytes = bytes([msg_sin]) i = 0 while i < len(data_str_no_sin): if data_str_no_sin[i] == '\\' and i < len(data_str_no_sin) - 1: if data_str_no_sin[i + 1] in '0123456789ABCDEF': databytes += bytes([int(data_str_no_sin[i+1:i+3], 16)]) i += 3 else: databytes += data_str_no_sin[i].encode('utf-8') i += 1 return { 'name': parts[0].replace('"', ''), 'system_message_number': int(sys_msg_num), 'system_message_sequence': int(sys_msg_seq), 'priority': int(parts[2]), 'sin': msg_sin, 'min': databytes[1], 'state': int(parts[4]), 'length': int(parts[5]), 'data_format': data_format, 'raw_payload': data, 'bytes': databytes, } async def message_mt_get(self, name: str, data_format: int = FORMAT_B64, verbose: bool = True) -> Union[dict, bytes]: """Returns the payload of a specified mobile-terminated message. Payload is presented as a string with encoding based on data_format. Args: name: The unique name in the modem queue e.g. FM01.01 data_format: text=1, hex=2, base64=3 (default) verbose: if True returns a dictionary, otherwise raw payload bytes Returns: The encoded data as a string Raises: AtException """ self._log.debug('Retrieving forward message {}'.format(name)) cmd = 'AT%MGFG="{}",{}'.format(name, data_format) response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1]) message = self._message_mt_parse(response[0], data_format=data_format) return message if verbose else message['bytes'] async def message_mt_formatted_get(self, name: str) -> MobileTerminatedMessage: """Retreives a decoded mobile-terminated message from the receive queue. Args: name: The unique name in the modem queue e.g. FM01.01 Returns: The MobileTerminatedMessage """ self._log.debug('Retrieving forward message {}'.format(name)) raise NotImplementedError('Formatted receive message future feature') async def message_mt_delete(self, name: str) -> bool: """Marks a Return message for deletion by the modem. Args: name: The unique mobile-terminated name in the queue Returns: True if the operation succeeded """ self._log.debug('Marking forward message {} for deletion'.format(name)) cmd = 'AT%MGFM="{}"'.format(name) try: response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], False) return True except: return False async def event_monitor_get(self) -> list: """Returns a list of monitored/cached events. As a list of <class.subclass> strings which includes an asterisk for each new event that can be retrieved. Returns: list of strings <class.subclass[*]> or None Raises: AtException """ self._log.debug('Querying monitored events') cmd = 'AT%EVMON' response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1]) events = response[0].replace('%EVMON: ', '').split(',') ''' for i in range(len(events)): c, s = events[i].strip().split('.') if s[-1] == '*': s = s.replace('*', '') # TODO flag change for retrieval events[i] = (int(c), int(s)) ''' return [event for event in events if event != ''] async def event_monitor_set(self, eventlist: list) -> bool: """Sets trace events to monitor. Args: eventlist: list of tuples (class, subclass) Returns: True if successfully set """ self._log.debug('Setting event monitors: {}'.format(eventlist)) #: AT%EVMON{ = <c1.s1>[, <c2.s2> ..]} cmd = 'AT%EVMON=' if eventlist is not None: for monitor in eventlist: if isinstance(monitor, tuple): if len(cmd) > 9: cmd += ',' cmd += '{}.{}'.format(monitor[0], monitor[1]) response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], False) return True async def event_get(self, event: tuple, raw: bool = True) -> Union[str, dict]: """Gets the cached event by class/subclass. Args: event: tuple of (class, subclass) raw: Returns the raw text string if True Returns: String if raw=True, dictionary if raw=False Raises: AtException """ self._log.debug('Querying events: {}'.format(event)) #: AT%EVNT=c,s #: res %EVNT: <dataCount>,<signedBitmask>,<MTID>,<timestamp>, # <class>,<subclass>,<priority>,<data0>,<data1>,..,<dataN> if not (isinstance(event, tuple) and len(event) == 2): raise AtException('event_get expects (class, subclass)') cmd = 'AT%EVNT={},{}'.format(event[0], event[1]) response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1]) eventdata = response[0].replace('%EVNT: ', '').split(',') event = { 'data_count': int(eventdata[0]), 'signed_bitmask': bin(int(eventdata[1]))[2:], 'mobile_id': eventdata[2], 'timestamp': eventdata[3], 'class': eventdata[4], 'subclass': eventdata[5], 'priority': eventdata[6], 'data': eventdata[7:] } bitmask = event['signed_bitmask'] while len(bitmask) < event['data_count']: bitmask = '0' + bitmask i = 0 for bit in reversed(bitmask): #: 32-bit signed conversion redundant since response is string if bit == '1': event['data'][i] = _to_signed32(int(event['data'][i])) else: event['data'][i] = int(event['data'][i]) i += 1 # TODO lookup class/subclass definitions return response[0] if raw else event async def notification_control_set(self, event_map: list) -> bool: """Sets the event notification bitmask. Args: event_map: list of tuples (event_name, bool) Returns: True if successful. """ self._log.debug('Setting event notifications: {}'.format(event_map)) #: ATS88=bitmask notifications_changed = False old_notifications = await self.notification_control_get() if old_notifications is None: return False bitmask = list('0' * len(old_notifications)) i = 0 for event in event_map: if event[0] not in NOTIFICATION_BITMASK: raise ValueError('Invalid event {}'.format(event[0])) i = 0 for key in reversed(old_notifications): bit = '1' if old_notifications[key] or bitmask[i] == '1' else '0' if key == event[0]: notify = event[1] if old_notifications[key] != notify: bit = '1' if notify else '0' notifications_changed = True # self.notifications[key] = notify bitmask[i] = bit i += 1 if notifications_changed: cmd = 'ATS88={}'.format(int('0b' + ''.join(bitmask), 2)) response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], False) return True async def notification_control_get(self) -> OrderedDict: """Returns the current notification configuration bitmask. Returns: OrderedDict Raises: AtException """ self._log.debug('Querying event notification controls') cmd = 'ATS88?' response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1]) return _notifications_dict(int(response[0])) async def notification_check(self) -> OrderedDict: """Returns the current active event notification bitmask (S89). The value of S89 register is cleared upon reading. Returns: OrderedDict Raises: AtException """ self._log.debug('Querying event notification triggers') cmd = 'ATS89?' response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1]) return _notifications_dict(int(response[0])) async def satellite_status(self) -> dict: """Returns the control state and C/No. Returns: Dictionary with state (int), snr (float), beamsearch (int), state_name (str), beamsearch_name (str), or None if error. Raises: AtException """ self._log.debug('Querying satellite status/SNR') cmd = 'ATS90=3 S91=1 S92=1 S116? S122? S123?' response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1]) if 'OK' in response: response.remove('OK') cn_0, ctrl_state, beamsearch_state = response cn_0 = int(cn_0) / 100.0 ctrl_state = int(ctrl_state) beamsearch_state = int(beamsearch_state) return { 'state': ctrl_state, 'state_name': CONTROL_STATES[ctrl_state], 'snr': cn_0, 'beamsearch': beamsearch_state, 'beamsearch_name': BEAMSEARCH_STATES[beamsearch_state], } @staticmethod def sat_status_name(ctrl_state: int) -> str: """Returns human-readable definition of a control state value. Raises: ValueError if ctrl_state is not found. """ if ctrl_state not in CONTROL_STATES: raise ValueError('Control state {} not found'.format(ctrl_state)) return CONTROL_STATES[ctrl_state] @staticmethod def sat_beamsearch_name(beamsearch_state: int) -> str: if beamsearch_state not in BEAMSEARCH_STATES: raise ValueError('Beam search state {} not found'.format(beamsearch_state)) return BEAMSEARCH_STATES[beamsearch_state] async def transmit_status(self) -> dict: """Returns the transmitter status. Returns: Transmit status (5 = OK) Raises: AtException if error returned by modem """ self._log.debug('Querying transmitter status') cmd = 'ATS54?' response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1]) status = int(response[0]) if status in TRANSMIT_STATUS: #: if status != 5 if TRANSMIT_STATUS[status] != 'OK': self._log.warning('Transmit status {}'.format( TRANSMIT_STATUS[status])) else: self._log.warning('Undocumented transmit status {}'.format(status)) return status async def shutdown(self) -> bool: """Tell the modem to prepare for power-down.""" self._log.debug('Requesting power down') cmd = 'AT%OFF' response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1], False) return True async def time_utc(self) -> str: """Returns current UTC time of the modem in ISO format. Returns: UTC as ISO-formatted string Raises: AtException """ self._log.debug('Requesting UTC network time') cmd = 'AT%UTC' response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1]) return response[0].replace('%UTC: ', '').replace(' ', 'T') + 'Z' async def s_register_get(self, register: int) -> Union[int, None]: """Returns the value of the S-register requested. Args: register: The S-register number Returns: integer value of register Raises: AtException """ self._log.debug('Querying register value S{}'.format(register)) cmd = 'ATS{}?'.format(register) response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[1]) return int(response[0]) async def s_register_get_all(self) -> list: """Returns a list of S-register definitions. R=read-only, S=signed, V=volatile Returns: tuple(register, RSV, current, default, minimum, maximum) Raises: AtException """ self._log.debug('Querying S-register values') cmd = 'AT%SREG' #: Sreg, RSV, CurrentVal, DefaultVal, MinimumVal, MaximumVal response = await self.command(cmd) if response[0] == 'ERROR': return self._handle_at_error(cmd, response[0]) if 'OK' in response: response.remove('OK') reg_defs = response[2:] registers = [] for row in reg_defs: reg_def = row.split(' ') reg_def = tuple(filter(None, reg_def)) registers.append(reg_def) return registers
class SerialConnection: def __init__(self, port=None, baudrate=9600, timeout=1, inter_byte_delay=None): self.ser = AioSerial(port, baudrate, timeout=timeout) self.inter_byte_delay = inter_byte_delay def open(self): """ Open serial port connection """ if not self.ser.is_open: self.ser.open() def readline(self): """ Read line from serial port :return: One line from serial stream """ try: output = self.ser.readline() return output except SerialException as se: log.error('Serial connection read error: {}'.format(se)) return None async def readline_async(self): """ Asynchronously read line from serial port :return: One line from serial stream """ try: output = await self.ser.readline_async() return output except asyncio.CancelledError: log.error(f'readline_async() future cancelled') return None def write(self, data): """ Write data to serial port :param data: Data to send """ try: if self.inter_byte_delay: for byte in data: self.ser.write(bytes([byte])) sleep(self.inter_byte_delay) else: self.ser.write(data) except SerialException as se: log.error('Serial connection write error: {}'.format(se)) async def write_async(self, data): """ Asynchronously write data to serial port :param data: Data to send """ try: if self.inter_byte_delay: for byte in data: await self.ser.write_async(bytes([byte])) await asyncio.sleep(self.inter_byte_delay) else: await self.ser.write_async(data) except asyncio.CancelledError: log.error(f'write_async() future cancelled') def send_break(self, duration=0.25): """ Send break condition to serial port :param duration: Break duration """ try: self.ser.send_break(duration) except SerialException as se: log.error('Serial connection send break error: {}'.format(se)) def close(self): """ Close serial port connection """ self.ser.close()