# coding=utf-8 ''' ebb_serial.py Serial connection utilities for EiBotBoard https://github.com/evil-mad/plotink Intended to provide some common interfaces that can be used by EggBot, WaterColorBot, AxiDraw, and similar machines. See below for version information Thanks to Shel Michaels for bug fixes and helpful suggestions. The MIT License (MIT) Copyright (c) 2022 Windell H. Oskay, Evil Mad Scientist Laboratories Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ''' import logging from packaging.version import parse from .plot_utils_import import from_dependency_import inkex = from_dependency_import('ink_extensions.inkex') serial = from_dependency_import('serial') from serial.tools.list_ports import comports \ #pylint: disable=wrong-import-position, wrong-import-order logger = logging.getLogger(__name__) def version(): '''Version number for this document''' return "0.19" # Dated 2022-10-05 def findPort(): ''' Find first available EiBotBoard by searching USB ports. Return serial port object. ''' try: com_ports_list = list(comports()) except TypeError: return None ebb_port = None for port in com_ports_list: if port[0].startswith("/dev/ttyUSB"): ebb_port = port[0] # Success; EBB found by name match. #raise Exception(ebb_port) break # stop searching-- we are done. for port in com_ports_list: if port[1].startswith("EiBotBoard"): ebb_port = port[0] # Success; EBB found by name match. break # stop searching-- we are done. if ebb_port is None: for port in com_ports_list: if port[2].startswith("USB VID:PID=04D8:FD92"): ebb_port = port[0] # Success; EBB found by VID/PID match. break # stop searching-- we are done. return ebb_port def find_named_ebb(port_name): ''' Find a specific EiBotBoard identified by a string giving either: The enumerated serial port, or An EBB "Name tag" Names should be 3-16 characters long. Comparisons are not case sensitive. (Name tags may assigned with the ST command on firmware 2.5.5 and later.) If found: Return serial port name (enumeration) If not found, Return None ''' if port_name is not None: needle = 'SER=' + port_name # pyserial 3 needle2 = 'SNR=' + port_name # pyserial 2.7 needle3 = '(' + port_name + ')' # e.g., "(COM4)" needle = needle.lower() needle2 = needle2.lower() needle3 = needle3.lower() plower = port_name.lower() try: com_ports_list = list(comports()) except TypeError: return None for port in com_ports_list: p_0 = port[0].lower() p_1 = port[1].lower() p_2 = port[2].lower() if needle in p_2: return port[0] # Success; EBB found by name match. if needle2 in p_2: return port[0] # Success; EBB found by name match. if needle3 in p_1: return port[0] # Success; EBB found by port match. p_1 = p_1[11:] if p_1.startswith(plower): return port[0] # Success; EBB found by name match. if p_0.startswith(plower): return port[0] # Success; EBB found by port match. needle.replace(" ", "_") # SN on Windows has underscores, not spaces. if needle in p_2: return port[0] # Success; EBB found by port match. needle2.replace(" ", "_") # SN on Windows has underscores, not spaces. if needle2 in p_2: return port[0] # Success; EBB found by port match. return None def query_nickname(port_name, verbose=True): ''' Query the EBB nickname and report it. If verbose is True or omitted, the result will be human readable. A short version is returned if verbose is False. Requires firmware version 2.5.5 or newer. http://evil-mad.github.io/EggBot/ebb.html#QT ''' if port_name is not None: version_status = min_version(port_name, "2.5.5") if version_status: raw_string = (query(port_name, 'QT\r')) if raw_string.isspace(): if verbose: return "This AxiDraw does not have a nickname assigned." return None if verbose: return "AxiDraw nickname: " + raw_string return str(raw_string).strip() if version_status is False: if verbose: return "AxiDraw naming requires firmware version 2.5.5 or higher." return None def write_nickname(port_name, nickname): ''' Write the EBB nickname. Requires firmware version 2.5.5 or newer. http://evil-mad.github.io/EggBot/ebb.html#ST ''' if port_name is not None: version_status = min_version(port_name, "2.5.5") if version_status: try: cmd = 'ST,' + nickname + '\r' command(port_name,cmd) return True except: return False return None def reboot(port_name): ''' Reboot the EBB, as though it were just powered on. Requires firmware version 2.5.5 or newer. http://evil-mad.github.io/EggBot/ebb.html#RB ''' if port_name is not None: version_status = min_version(port_name, "2.5.5") if version_status: try: command(port_name,'RB\r') except: pass def list_port_info(): '''Find and return a list of all USB devices and their information.''' try: com_ports_list = list(comports()) except TypeError: return None port_info_list = [] for port in com_ports_list: port_info_list.append(port[0]) # port name port_info_list.append(port[1]) # Identifier port_info_list.append(port[2]) # VID/PID if port_info_list: return port_info_list return None def listEBBports(): '''Find and return a list of all EiBotBoard units connected via USB port.''' try: com_ports_list = list(comports()) except TypeError: return None ebb_ports_list = [] for port in com_ports_list: port_has_ebb = False if port[0].startswith("/dev/ttyUSB"): port_has_ebb = True elif port[1].startswith("EiBotBoard"): port_has_ebb = True elif port[2].startswith("USB VID:PID=04D8:FD92"): port_has_ebb = True if port_has_ebb: ebb_ports_list.append(port) if ebb_ports_list: return ebb_ports_list return None def list_named_ebbs(): '''Return descriptive list of all EiBotBoard units''' ebb_ports_list = listEBBports() if not ebb_ports_list: return None ebb_names_list = [] for port in ebb_ports_list: name_found = False p_0 = port[0] p_1 = port[1] p_2 = port[2] if p_1.startswith("EiBotBoard"): temp_string = p_1[11:] if temp_string: if temp_string is not None: ebb_names_list.append(temp_string) name_found = True if not name_found: # Look for "SER=XXXX LOCAT" pattern, # typical of Pyserial 3 on Windows. if 'SER=' in p_2 and ' LOCAT' in p_2: index1 = p_2.find('SER=') + len('SER=') index2 = p_2.find(' LOCAT', index1) temp_string = p_2[index1:index2] if len(temp_string) < 3: temp_string = None if temp_string is not None: ebb_names_list.append(temp_string) name_found = True if not name_found: # Look for "...SNR=XXXX" pattern, # typical of Pyserial 2.7 on Windows if 'SNR=' in p_2: index1 = p_2.find('SNR=') + len('SNR=') index2 = len(p_2) temp_string = p_2[index1:index2] if len(temp_string) < 3: temp_string = None if temp_string is not None: ebb_names_list.append(temp_string) name_found = True if not name_found: ebb_names_list.append(p_0) return ebb_names_list def testPort(port_name): """ Open a given serial port, verify that it is an EiBotBoard, and return a SerialPort object that we can reference later. This routine only opens the port; it will need to be closed as well, for example with closePort( port_name ). You, who open the port, are responsible for closing it as well. """ if port_name is not None: try: serial_port = serial.Serial(port_name, timeout=1.0) # 1 second timeout! serial_port.flushInput() # deprecated function name; # use serial_port.reset_input_buffer() # if we can be sure that we have pySerial 3+. serial_port.write('v\r'.encode('ascii')) str_version = serial_port.readline() if str_version and str_version.startswith("EBB".encode('ascii')): return serial_port serial_port.write('v\r'.encode('ascii')) str_version = serial_port.readline() if str_version and str_version.startswith("EBB".encode('ascii')): return serial_port serial_port.close() except serial.SerialException as err: logger.error("Error testing serial port `{}` connection".format(port_name)) logger.info("Error context:", exc_info=err) return None def openPort(): ''' Find and open a port to a single attached EiBotBoard. The first port located will be used. ''' found_port = findPort() serial_port = testPort(found_port) if serial_port: return serial_port return None def open_named_port(port_name): ''' Find and open a port to a single attached EiBotBoard, indicated by name. The first port located will be used. ''' found_port = find_named_ebb(port_name) serial_port = testPort(found_port) if serial_port: return serial_port return None def closePort(port_name): '''Close the given serial port.''' if port_name is not None: try: port_name.close() except serial.SerialException: pass def query(port_name, cmd, verbose=True): '''General command to send a query to the EiBotBoard''' if port_name is not None and cmd is not None: response = '' try: port_name.write(cmd.encode('ascii')) response = port_name.readline().decode('ascii') n_retry_count = 0 while len(response) == 0 and n_retry_count < 100: # get new response to replace null response if necessary response = port_name.readline() n_retry_count += 1 if cmd.split(",")[0].strip().lower() not in ["a", "i", "mr", "pi", "qm", "qg", "v"]: # Most queries return an "OK" after the data requested. # We skip this for those few queries that do not return an extra line. unused_response = port_name.readline() # read in extra blank/OK line n_retry_count = 0 while len(unused_response) == 0 and n_retry_count < 100: # get new response to replace null response if necessary unused_response = port_name.readline() n_retry_count += 1 except (serial.SerialException, IOError, RuntimeError, OSError) as err: if verbose: logger.error("Error reading serial data") else: logger.info("Error reading serial data") logger.info("Error context:", exc_info=err) if 'Err:' in response: error_msg = '\n'.join(('Unexpected response from EBB.', ' Command: {0}'.format(cmd.strip()), ' Response: {0}'.format(response.strip()))) if verbose: logger.error(error_msg) else: logger.info(error_msg) return response return None def command(port_name, cmd, verbose=True): '''General command to send a command to the EiBotBoard''' if port_name is not None and cmd is not None: try: port_name.write(cmd.encode('ascii')) response = port_name.readline().decode('ascii') n_retry_count = 0 while len(response) == 0 and n_retry_count < 100: # get new response to replace null response if necessary response = port_name.readline().decode('ascii') n_retry_count += 1 if response.strip().startswith("OK"): # Debug option: indicate which command: # inkex.errormsg( 'OK after command: ' + cmd ) pass else: if response: error_msg = '\n'.join(('Unexpected response from EBB.', ' Command: {0}'.format(cmd.strip()), ' Response: {0}'.format(response.strip()))) else: error_msg = 'EBB Serial Timeout after command: {0}'.format(cmd) if verbose: logger.error(error_msg) else: logger.info(error_msg) except (serial.SerialException, IOError, RuntimeError, OSError) as err: if cmd.strip().lower() not in ["rb"]: # Ignore error on reboot (RB) command if verbose: logger.error('Failed after command: {0}'.format(cmd)) else: logger.info('Failed after command: {0}'.format(cmd)) logger.info("Error context:", exc_info=err) def bootload(port_name): '''Enter bootloader mode. Do not try to read back data.''' if port_name is not None: try: port_name.write('BL\r'.encode('ascii')) return True except: return False return None def min_version(port_name, version_string): ''' Query the EBB firmware version for the EBB located at port_name. Return True if the EBB firmware version is at least version_string. Return False if the EBB firmware version is below version_string. Return None if we are unable to determine True or False. ''' if port_name is not None: ebb_version_string = queryVersion(port_name) # Full string, human readable ebb_version_string = ebb_version_string.split("Firmware Version ", 1) if len(ebb_version_string) > 1: ebb_version_string = ebb_version_string[1] else: return None # We haven't received a reasonable version number response. ebb_version_string = ebb_version_string.strip() # Stripped copy, for number comparisons if parse(ebb_version_string) >= parse(version_string): return True return False return None def queryVersion(port_name): '''Query EBB Version String''' return query(port_name, 'V\r', True)