diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index e8d3f54..c3a8426 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -98,9 +98,10 @@ def printDatagram(memo): # accumulate the CDI information resultingCDI = [] +# callbacks to get results of memory read def memoryReadSuccess(memo): - """createcallbacks to get results of memory read + """Handle a successful read Invoked when the memory read successfully returns, this queues a new read until the entire CDI has been returned. At that point, it invokes the XML processing below. @@ -162,7 +163,7 @@ def __init__(self): def startElement(self, name, attrs): print("Start: ", name) if attrs is not None and attrs : - print(" Atributes: ", attrs.getNames()) + print(" Attributes: ", attrs.getNames()) def endElement(self, name): print(name, "content:", self._flushCharBuffer()) diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index f407679..aa548ea 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -94,16 +94,17 @@ def printDatagram(memo): memoryService = MemoryService(datagramService) +# callbacks to get results of memory read # def memoryReadSuccess(memo): -# """createcallbacks to get results of memory read -# +# """Handle a successful read +# # Args: # memo (MemoryReadMemo): Event that was generated. # """ # print("successful memory read: {}".format(memo.data)) -# -# +# +# # def memoryReadFail(memo): # print("memory read failed: {}".format(memo.data)) diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index f437101..e18e08b 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -94,9 +94,10 @@ def printDatagram(memo): memoryService = MemoryService(datagramService) +# callbacks to get results of memory read def memoryReadSuccess(memo): - """createcallbacks to get results of memory read + """Handle a successful read Args: memo (MemoryReadMemo): Event that was generated. diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index 6169dd1..ad7b3b0 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -9,6 +9,8 @@ the address and port). Defaults to a hard-coded test address and port. ''' +import socket + # region same code as other examples from examples_settings import Settings # do 1st to fix path if no pip install settings = Settings() @@ -44,7 +46,12 @@ s = TcpSocket() # s.settimeout(30) -s.connect(settings['host'], settings['port']) +try: + s.connect(settings['host'], settings['port']) +except socket.gaierror: + print("Failure accessing {}:{}" + .format(settings.get('host'), settings.get('port'))) + raise print("RR, SR are raw socket interface receive and send;" " RL, SL are link interface; RM, SM are message interface") @@ -94,7 +101,8 @@ def printDatagram(memo): memoryService = MemoryService(datagramService) -# createcallbacks to get results of memory read +# callbacks to get results of memory read + def memoryReadSuccess(memo): print("successful memory read: {}".format(memo.data)) diff --git a/examples/example_string_serial_interface.py b/examples/example_string_serial_interface.py index c81e4ec..f50a4f1 100644 --- a/examples/example_string_serial_interface.py +++ b/examples/example_string_serial_interface.py @@ -1,6 +1,6 @@ ''' Example of raw socket communications over the physical connection, in this case -a socket. +a serial port. Usage: python3 example_string_interface.py [host|host:port] diff --git a/examples/example_tcp_message_interface.py b/examples/example_tcp_message_interface.py index 8b0cabf..8dd9905 100644 --- a/examples/example_tcp_message_interface.py +++ b/examples/example_tcp_message_interface.py @@ -57,25 +57,25 @@ def printMessage(msg): print("RM: {} from {}".format(msg, msg.source)) -tcpLinklayer = TcpLink(NodeID(100)) -tcpLinklayer.registerMessageReceivedListener(printMessage) -tcpLinklayer.linkPhysicalLayer(sendToSocket) +tcpLinkLayer = TcpLink(NodeID(100)) +tcpLinkLayer.registerMessageReceivedListener(printMessage) +tcpLinkLayer.linkPhysicalLayer(sendToSocket) ####################### # have the socket layer report up to bring the link layer up and get an alias print(" SL : link up") -tcpLinklayer.linkUp() +tcpLinkLayer.linkUp() # send an VerifyNodes message to provoke response message = Message(MTI.Verify_NodeID_Number_Global, NodeID(settings['localNodeID']), None) print("SM: {}".format(message)) -tcpLinklayer.sendMessage(message) +tcpLinkLayer.sendMessage(message) # process resulting activity while True: received = s.receive() print(" RR: {}".format(received)) # pass to link processor - tcpLinklayer.receiveListener(received) + tcpLinkLayer.receiveListener(received) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index 669f40a..e3e0bc8 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -20,6 +20,7 @@ from collections import OrderedDict from examples_settings import Settings +from openlcb.tcplink.mdnsconventions import id_from_tcp_service_name zeroconf_enabled = False try: @@ -90,7 +91,7 @@ class MainForm(ttk.Frame): key and the Button instance is the value. example_modules (OrderedDict[str]): The example module name is the key, and the full path is the value. If - examples are made modular, the value will not be nessary, but + examples are made modular, the value will not be necessary, but for now just run the file in another Python instance (See run_example method). @@ -291,7 +292,10 @@ def gui(self, parent): self.row = 0 self.add_field("service_name", "TCP Service name (optional, sets host&port)", - gui_class=ttk.Combobox, tooltip="") + gui_class=ttk.Combobox, tooltip="", + command=self.set_id_from_name, + command_text="Copy digits to Far Node ID") + self.fields["service_name"].button.configure(state=tk.DISABLED) self.fields["service_name"].var.trace('w', self.on_service_name_change) self.add_field("host", "IP address/hostname", command=self.detect_hosts, @@ -334,8 +338,8 @@ def gui(self, parent): self.add_field( "farNodeID", "Far Node ID", gui_class=ttk.Combobox, - # command=self.detect_nodes, # TODO: finish detect_nodes & use - # command_text="Detect", # TODO: finish detect_nodes & use + command=self.detect_nodes, # TODO: finish detect_nodes & use + command_text="Detect", # TODO: finish detect_nodes & use ) self.add_field( @@ -345,6 +349,17 @@ def gui(self, parent): command_text="Default", ) + self.add_field( + "timeout", "Remote nodes timeout (seconds)", + gui_class=ttk.Entry, + ) + + self.add_field( + "trace", "Remote nodes logging", + gui_class=ttk.Checkbutton, + text="Trace", + ) + # The status widget is the only widget other than self which # is directly inside the parent widget (forces it to bottom): self.statusLabel = ttk.Label(self.parent) @@ -362,28 +377,45 @@ def gui(self, parent): # self.rowconfigure(row, weight=1) # self.rowconfigure(self.row_count-1, weight=1) # make last row expand + def set_id_from_name(self): + id = self.get_id_from_name(update_button=True) + if not id: + return + self.fields['farNodeID'].var.set(id) + + def get_id_from_name(self, update_button=False): + lcc_id = id_from_tcp_service_name(self.fields['service_name'].var.get()) + if update_button: + if not lcc_id: + self.fields["service_name"].button.configure(state=tk.DISABLED) + else: + self.fields["service_name"].button.configure(state=tk.NORMAL) + return lcc_id + def on_service_name_change(self, index, value, op): key = self.fields['service_name'].get() + _ = self.get_id_from_name(update_button=True) info = self.detected_services.get(key) if not info: # The user may be typing, so don't spam screen with messages, # just ignore incomplete entries. return # We got info, so use the info to set *other* fields: - self.fields['host'].set(info['server']) + self.fields['host'].set(info['server'].rstrip(".")) + # ^ Remove trailing "." to prevent getaddrinfo failed. self.fields['port'].set(info['port']) self.set_status("Hostname & Port have been set ({server}:{port})" .format(**info)) - def add_field(self, key, text, gui_class=ttk.Entry, command=None, - command_text=None, tooltip=None): + def add_field(self, key, caption, gui_class=ttk.Entry, command=None, + command_text=None, tooltip=None, text=None): """Generate a uniform data field that may or may not affect a setting. The row(s) for the data field will start at self.row, and self.row will be incremented for (each) row added by this function. Args: - text (str): Text for the label. + caption (str): Text for the label. key (str): Key to store the widget. gui_class (Misc): The ttk widget class or function to use to create the data entry widget (field.widget). @@ -392,6 +424,8 @@ def add_field(self, key, text, gui_class=ttk.Entry, command=None, tooltip (str, optional): Add a tooltip tk.Label as field.tooltip with this text. Added even if "". Defaults to None (not added in that case). + text (str, optional): Text on the input widget itself (only + applies to gui_class Checkbutton). """ # self.row should already be set to an empty row. self.column = 0 # Return to beginning of row @@ -404,16 +438,27 @@ def add_field(self, key, text, gui_class=ttk.Entry, command=None, raise ValueError("command is required for command_caption.") field = DataField() - field.label = ttk.Label(self, text=text) + field.label = ttk.Label(self, text=caption) field.label.grid(row=self.row, column=self.column, **self.grid_args) self.host_column = self.column self.column += 1 self.fields[key] = field - field.var = tk.StringVar(self.w1) - field.widget = gui_class( - self, - textvariable=field.var, - ) + if gui_class in (ttk.Checkbutton, tk.Checkbutton): + field.var = tk.BooleanVar(self.w1) + # field.var.set(True) + field.widget = gui_class( + self, + # onvalue=True, + # offvalue=False, + variable=field.var, + text=text, + ) + else: + field.var = tk.StringVar(self.w1) + field.widget = gui_class( + self, + textvariable=field.var, + ) field.widget.grid(row=self.row, column=self.column, **self.grid_args) self.column += 1 @@ -550,14 +595,14 @@ def main(): window_h, )) # WxH+X+Y format root.minsize = (window_w, window_h) - mainform = MainForm(root) - mainform.master.title("Python OpenLCB Examples") + main_form = MainForm(root) + main_form.master.title("Python OpenLCB Examples") try: - mainform.mainloop() + main_form.mainloop() finally: - if mainform.zeroconf: - mainform.zeroconf.close() - mainform.zeroconf = None + if main_form.zeroconf: + main_form.zeroconf.close() + main_form.zeroconf = None return 0 diff --git a/examples/examples_settings.py b/examples/examples_settings.py index 1285cbc..9c1eb1c 100644 --- a/examples/examples_settings.py +++ b/examples/examples_settings.py @@ -27,7 +27,7 @@ "timeout": 0.5, # Such as for example_remote_nodes.py "device": "/dev/cu.usbmodemCC570001B1", # ^ serial device such as for example_string_serial_interface.py - # "service_name": "", # mdns service name (maybe more than 1 on host) + # "service_name": "", # MDNS service name (maybe more than 1 on host) # ^ service_name isn't saved here, since it is not used by LCC # examples (See examples_gui instead, which finds it dynamically, # and where it is associated with a host and port). @@ -140,6 +140,11 @@ def __next__(self): self._iterate_i += 1 return key + def get(self, key): + if key not in self: + return None + return self[key] + def keys(self): """Return the keys iterator from the settings dictionary. diff --git a/openlcb/__init__.py b/openlcb/__init__.py index e69de29..db88c0e 100644 --- a/openlcb/__init__.py +++ b/openlcb/__init__.py @@ -0,0 +1,19 @@ +import re + + +hex_pairs_rc = re.compile(r"^([0-9A-Fa-f]{2})+$") +# {2}: Exactly two characters found (only match if pair) +# +: at least one match plus 0 or more additional matches + + +def only_hex_pairs(value): + """Check if string contains only machine-readable hex pairs. + See openlcb.conventions submodule for LCC ID dot notation + functions (less restrictive). + """ + return hex_pairs_rc.fullmatch(value) + + +def emit_cast(value): + """Show type and value, such as for debug output.""" + return "{}({})".format(type(value).__name__, repr(value)) diff --git a/openlcb/canbus/canframe.py b/openlcb/canbus/canframe.py index f6ae8c5..b93c0ba 100644 --- a/openlcb/canbus/canframe.py +++ b/openlcb/canbus/canframe.py @@ -2,6 +2,40 @@ class CanFrame: + """OpenLCB-CAN frame + Use CAN extended format (29-bit header) + - However, operate error-free when network carries standard-format + (11-bit header) frames. + - Bit 28 of header is always sent as 1 and ignored upon receipt + NOTE: 0x10_00_00_00 is bit 28 in hex. + - See the "OpenLCB-CAN Frame Transfer" standard on openlcb.org for + more details. + + Constructor arguments are manually overloaded as follows: + - N_cid, nodeID, alias: If 2nd arg is NodeID. + - header, data: If 2nd arg is list. + - control, alias, data: If 2nd arg is int. + + Args: + N_cid (int, optional): Frame sequence number (becomes first + 3 bits of 15-bit Check ID (CID) frame). 4 to 7 inclusive + (for non-OpenLCB protocols, down to 1). See + "OpenLCB-CAN Frame Transfer" standard or comments under + "nodeCode" for how this affects nodeID bit field size. + nodeID (NodeID, optional): (becomes last 12 bits at right + side of Check ID (CID) frame, but which bits are used + depends upon N_cid). + alias (int, optional): Source NID Alias. A 12-bit version of the + nodeID shortened using bitwise overlays, used as a local key + for the device. See calls to createAlias12. + header (int, optional): The entire 29-bit header already + assembled (or received). + data (list[int], optional): Payload + control (int, optional): Frame type (1: OpenLCB = 0x0800_000, + 0: CAN Control Frame) | Content Field (3 bits, 3 nibbles, + mask = 0x07FF_F000). + """ + header = 0 data = [] @@ -13,14 +47,15 @@ def __str__(self): def __init__(self, arg1, arg2, arg3=[]): # three arguments as N_cid, nodeID, alias if isinstance(arg2, NodeID): - # cid must be 4 to 7 inclusive + # cid must be 4 to 7 inclusive (100 to 111 binary) # precondition(4 <= cid && cid <= 7) cid = arg1 nodeID = arg2 alias = arg3 nodeCode = ((nodeID.nodeId >> ((cid-4)*12)) & 0xFFF) - self.header = ((cid << 12) | nodeCode) << 12 | (alias & 0xFFF) | 0x10_000_000 # noqa: E501 + # ^ cid-4 results in 0 to 3. *12 results in 0 to 36 bit shift (nodeID size) + self.header = ((cid << 12) | nodeCode) << 12 | (alias & 0xFFF) | 0x10_00_00_00 # noqa: E501 self.data = [] # two arguments as header, data @@ -32,7 +67,7 @@ def __init__(self, arg1, arg2, arg3=[]): elif isinstance(arg2, int): control = arg1 alias = arg2 - self.header = (control << 12) | (alias & 0xFFF) | 0x10_000_000 + self.header = (control << 12) | (alias & 0xFFF) | 0x10_00_00_00 self.data = arg3 else: print("could not decode NodeID ctor arguments") diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 58ac3b5..a1cd80c 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -299,7 +299,7 @@ def handleReceivedData(self, frame): # CanFrame msg = Message(mti, sourceID, destID, self.accumulator[key]) self.fireListeners(msg) - # remove accumulution + # remove accumulation self.accumulator[key] = None else: # addressed message case @@ -336,7 +336,7 @@ def handleReceivedData(self, frame): # CanFrame logging.warning("Dropping non-start frame without" " accumulation started: {}" "".format(frame)) - return # early return to stop processing of this grame + return # early return to stop processing of this gram # add this data if len(frame.data) > 2: @@ -352,7 +352,7 @@ def handleReceivedData(self, frame): # CanFrame msg.originalMTI = ((frame.header >> 12) & 0xFFF) self.fireListeners(msg) - # remove accumulution + # remove accumulation self.accumulator[key] = None # end addressed message case @@ -369,7 +369,7 @@ def handleReceivedData(self, frame): # CanFrame def sendMessage(self, msg): # special case for datagram if msg.mti == MTI.Datagram: - header = 0x10_000_000 + header = 0x10_00_00_00 # datagram headers are # 1Adddsss - one frame # 1Bdddsss - first frame @@ -436,10 +436,10 @@ def sendMessage(self, msg): # Is a destination address needed? Could be long message if msg.isAddressed(): - destt = msg.destination - if destt is None: - destt = NodeID(0) - alias = self.nodeIdToAlias.get(destt) + dest = msg.destination + if dest is None: + dest = NodeID(0) + alias = self.nodeIdToAlias.get(dest) if alias is not None: # might not know it? # address and have alias, break up data dataSegments = self.segmentAddressedDataArray(alias, @@ -477,7 +477,7 @@ def segmentDatagramDataArray(self, data): # multiple frames retval = [] - for i in range(0, nSegments-2+1): # first enty of 2 has full data + for i in range(0, nSegments-2+1): # first entry of 2 has full data nextEntry = (data[i*8:i*8+7+1]).copy() retval.append(nextEntry) @@ -510,7 +510,7 @@ def segmentAddressedDataArray(self, alias, data): # multiple frames retval = [] - for i in range(0, nSegments-2+1): # first enty of 2 has full data + for i in range(0, nSegments-2+1): # first entry of 2 has full data nextEntry = [part0 | 0x30, part1]+(data[i*6:i*6+5+1]).copy() retval.append(nextEntry) diff --git a/openlcb/canbus/seriallink.py b/openlcb/canbus/seriallink.py index 95e05f1..9a51e16 100644 --- a/openlcb/canbus/seriallink.py +++ b/openlcb/canbus/seriallink.py @@ -34,12 +34,12 @@ def send(self, string): RuntimeError: If the string couldn't be written to the port. """ msg = string.encode('ascii') - totalsent = 0 - while totalsent < len(msg[totalsent:]): - sent = self.port.write(msg[totalsent:]) + total_sent = 0 + while total_sent < len(msg[total_sent:]): + sent = self.port.write(msg[total_sent:]) if sent == 0: raise RuntimeError("socket connection broken") - totalsent = totalsent + sent + total_sent = total_sent + sent def receive(self): '''Receive at least one GridConnect frame and return as string. @@ -66,6 +66,6 @@ def receive(self): break return (b''.join(chunks)).decode("utf-8") - def close(self): + def close(self): self.port.close() - return \ No newline at end of file + return diff --git a/openlcb/canbus/tcpsocket.py b/openlcb/canbus/tcpsocket.py index 1a24038..e5a7be1 100644 --- a/openlcb/canbus/tcpsocket.py +++ b/openlcb/canbus/tcpsocket.py @@ -30,12 +30,12 @@ def send(self, string): """Send a single string. """ msg = string.encode('ascii') - totalsent = 0 - while totalsent < len(msg[totalsent:]): - sent = self.sock.send(msg[totalsent:]) + total_sent = 0 + while total_sent < len(msg[total_sent:]): + sent = self.sock.send(msg[total_sent:]) if sent == 0: raise RuntimeError("socket connection broken") - totalsent = totalsent + sent + total_sent = total_sent + sent def receive(self): '''Receive at least one GridConnect frame and return as string. @@ -61,7 +61,6 @@ def receive(self): if 0x3B in chunk: break return b''.join(chunks).decode("utf-8") - + def close(self): self.sock.close() - diff --git a/openlcb/conventions.py b/openlcb/conventions.py new file mode 100644 index 0000000..cd29dad --- /dev/null +++ b/openlcb/conventions.py @@ -0,0 +1,98 @@ +from logging import getLogger + +from openlcb import ( + only_hex_pairs, + emit_cast, +) + +logger = getLogger(__name__) + +LCC_ID_SEP = "." + + +def hex_to_dotted_lcc_id(hex_s): + if (not isinstance(hex_s, str)) or (len(hex_s) != 12): + raise ValueError( + "Only 6 hex pairs (12 characters) allowed, but got {}." + .format(emit_cast(hex_s))) + return LCC_ID_SEP.join([hex_s[i*2:i*2+2] for i in range(len(hex_s)//2)]) + + +def validate_lcc_id(lcc_id_s): + """Convert an LCC ID in dot notation to a hex and error pair. + Get a tuple of a hex string and a validation error (or None) + suitable for form validation (Done that way so this is the + only function that does LCC ID analysis). + + Args: + lcc_id_s (str): An LCC ID string. Examples: 02.01.57.00.04.9C or + 2.1.57.0.4.9C (both give same 12-digit hex string). + + Returns: + tuple(str, str): tuple of hex string and error: + - Hex string is 12 characters uppercase, or None if input is bad. + - Error is only not None if hex string is None. + """ + if not lcc_id_s: + error = "[dotted_lcc_id_to_hex] Got {}".format(repr(lcc_id_s)) + # ^ repr shows '' or None + return None, error + parts = lcc_id_s.split(".") + if len(parts) != 6: + error = "Not 6 parts: {}".format(lcc_id_s) + return None, error + hex_s = "" + for part in parts: + if len(part) == 2: + hex_s += part + elif len(part) == 1: # Add leading 0 since not required. + hex_s += "0" + part + elif len(part) < 1: + error = "Extra '.' in {} (not an LCC ID)".format(repr(lcc_id_s)) + return None, error + else: + error = "Wrong length for {}".format(repr(part)) + return None, error + if not only_hex_pairs(hex_s): + error = "Non-hex found in {} (expected 0-9/A-F)".format(repr(lcc_id_s)) + return None, error + return hex_s.upper(), None + + +def dotted_lcc_id_to_hex(lcc_id_s): + hex_s, error = validate_lcc_id(lcc_id_s) + if error: + logger.info(error) + return None + return hex_s + + +def is_hex_lcc_id(value): + """Check if it is a 12-character LCC ID in pure hex format. + Uppercase or lowercase is valid if 12 characters. If dotted, you + must first use dotted_lcc_id_to_hex to make it machine readable + (including to add zero padding) or see if result is None from that + before calling this. + """ + # if (len(value) < 12) and (len(value) >= minimum_length): + # value = value.zfill(12) # pad left with zeroes + # ^ Commented since dotted_lcc_id_to_hex can be used to get + # a clean one if possible. + if len(value) != 12: + logger.info("Not 12 characters: {}".format(value)) + return False + + return only_hex_pairs(value) + + +def is_dotted_lcc_id(value): + """It is an LCC ID in dot notation (human readable) + Examples: 02.01.57.00.04.9C or 2.1.57.0.4.9C (same effect) + + To generate LCC IDs, first allocate a range at + https://registry.openlcb.org/uniqueidranges + """ + hex_str = dotted_lcc_id_to_hex(value) + if not hex_str: # warning/info logged by dotted_lcc_id_to_hex + return False + return only_hex_pairs(hex_str) diff --git a/openlcb/datagramservice.py b/openlcb/datagramservice.py index 448e9c1..32686a5 100644 --- a/openlcb/datagramservice.py +++ b/openlcb/datagramservice.py @@ -263,7 +263,7 @@ def matchToWriteMemo(self, message): # did not find one logging.error("Did not match memo to message {}" "".format(message)) - return None # this will prevent firther processing + return None # this will prevent further processing def sendNextDatagramFromQueue(self): # is there a next datagram request? diff --git a/openlcb/memoryservice.py b/openlcb/memoryservice.py index c14f85f..e67f81a 100644 --- a/openlcb/memoryservice.py +++ b/openlcb/memoryservice.py @@ -229,10 +229,10 @@ def datagramReceivedListener(self, dmemo): self.spaceLengthCallback = None return True # normal reply - address = (int(dmemo.data[3]) << 24) \ - + (int(dmemo.data[4]) << 16) \ - + (int(dmemo.data[5]) << 8) \ - + int(dmemo.data[6]) + address = ((int(dmemo.data[3]) << 24) + + (int(dmemo.data[4]) << 16) + + (int(dmemo.data[5]) << 8) + + int(dmemo.data[6])) self.spaceLengthCallback(address) self.spaceLengthCallback = None else: diff --git a/openlcb/nodeid.py b/openlcb/nodeid.py index 9905462..620e26b 100644 --- a/openlcb/nodeid.py +++ b/openlcb/nodeid.py @@ -1,5 +1,17 @@ +from openlcb import emit_cast + + class NodeID: - """Convert an integer, list, NodeID or string to a NodeID + """A 6-byte (48-bit) Node ID. + The constructor is manually overloaded as follows: + - nodeId (int): If int. + - nodeId (str): If str. Six dot-separated hex pairs. + - nodeId (NodeID): If NodeID. data.nodeID is used in this case. + - nodeId (list[int]): If list. Six ints. + + Args: + nodeId (Union[int,str,NodeID,list[int]]): Node ID in int, dotted + hex string, NodeID, or list[int] form. """ def __str__(self): '''Display in standard format''' @@ -14,6 +26,10 @@ def __init__(self, data): elif isinstance(data, str): parts = data.split(".") result = 0 + if len(parts) != 6: + raise ValueError( + "6 dot-separated hex digits/pairs required if arg is str," + " but got {}".format(emit_cast(data))) for part in parts: result = result*0x100+int(part, 16) self.nodeId = result diff --git a/openlcb/nodestore.py b/openlcb/nodestore.py index c22807f..2c3f984 100644 --- a/openlcb/nodestore.py +++ b/openlcb/nodestore.py @@ -53,5 +53,5 @@ def invokeProcessorsOnNodes(self, message) : publish = False # has any processor returned True? for processor in self.processors : for node in self.byIdMap.values() : - publish = processor.process(message, node) or publish # always invoke Processsor on node first # noqa: E501 + publish = processor.process(message, node) or publish # always invoke Processor on node first # noqa: E501 return publish diff --git a/openlcb/remotenodestore.py b/openlcb/remotenodestore.py index df48642..f3b0c4f 100644 --- a/openlcb/remotenodestore.py +++ b/openlcb/remotenodestore.py @@ -76,6 +76,6 @@ def processMessageFromLinkLayer(self, message) : if self.checkForNewNode(message) : self.createNewRemoteNode(message) publish = True - # always run invoke Processsors on nodes + # always run invoke Processors on nodes publish = self.invokeProcessorsOnNodes(message) or publish return publish diff --git a/openlcb/snip.py b/openlcb/snip.py index 538e74d..1932d03 100644 --- a/openlcb/snip.py +++ b/openlcb/snip.py @@ -63,7 +63,7 @@ def findString(self, n): Zero indexed. Is aware of the 2nd version code byte. - Logs and returns -1 if the string isn't found withn the buffer + Logs and returns -1 if the string isn't found within the buffer ''' if n == 0: @@ -103,17 +103,17 @@ def getString(self, first, maxLength): retval = ''.join([chr(i) for i in self.data[first:last]]) return retval - def addData(self, indata): + def addData(self, in_data): ''' Add additional bytes of SNIP data ''' - for i in range(0, len(indata)): + for i in range(0, len(in_data)): # protect against overlapping requests causing an overflow if (i+self.index) >= 253: logging.error("Overlapping SNIP requests, truncating") break - self.data[i+self.index] = indata[i] - self.index += len(indata) + self.data[i+self.index] = in_data[i] + self.index += len(in_data) self.updateStringsFromSnipData() def updateStringsFromSnipData(self): diff --git a/openlcb/tcplink/mdnsconventions.py b/openlcb/tcplink/mdnsconventions.py new file mode 100644 index 0000000..6cf4380 --- /dev/null +++ b/openlcb/tcplink/mdnsconventions.py @@ -0,0 +1,53 @@ +from logging import getLogger + +from openlcb import only_hex_pairs +from openlcb.conventions import hex_to_dotted_lcc_id + + +logger = getLogger(__name__) + + +def id_from_tcp_service_name(service_name): + """Scrape an MDNS TCP service name, assuming it uses conventions + (`"{org}_{model}_{id}._openlcb-can.{protocol}.{tld}".format(...)` + where: + - `"{org}_"` and `"{model}_"` are optional + - "{model}" can be a model name or product category abbreviation. + Examples: + "pythonopenlcb_02015700049C._openlcb-can._tcp.local." + or + "bobjacobsen_pythonopenlcb_02015700049C._openlcb-can._tcp.local." + becomes "02.01.57.00.04.9C" + + Args: + service_name (str): A service name containing a 12-digit + continuous string of digits, starting with "_" and ending + with ".". (See also mdnsconventions.py) + + Returns: + str: An LCC ID formatted as a human-readable string + ("."-separated hex pairs), or None if no 12-digit number + could be detected (See service_name). + """ + lcc_id = None + fqdn_parts = service_name.split(".") + # if len(fqdn_parts) < 2: + # error = "less than 2 parts in {}".format(service_name) + # return None + name_parts = fqdn_parts[0].split("_") + msg = "No part has 12 hex digits (0-9, A-F)" + for part in name_parts: + if len(part) != 12: + logger.debug("Not 12 characters: {}".format(repr(part))) + continue + msg = "No 12-character underscore-separated part, all hex" + if not only_hex_pairs(part): + logger.debug("Not hex digits: {}".format(repr(part))) + continue + lcc_id = hex_to_dotted_lcc_id(part) + logger.debug("id_from_tcp_service_name got {}".format(repr(lcc_id))) + msg = None + + if msg: + logger.info(msg) + return lcc_id diff --git a/openlcb/tcplink/tcpsocket.py b/openlcb/tcplink/tcpsocket.py index 617315e..3cf9d56 100644 --- a/openlcb/tcplink/tcpsocket.py +++ b/openlcb/tcplink/tcpsocket.py @@ -32,12 +32,12 @@ def send(self, data): '''Send a single message, provided as an [int] ''' msg = bytes(data) - totalsent = 0 - while totalsent < len(msg[totalsent:]): - sent = self.sock.send(msg[totalsent:]) + total_sent = 0 + while total_sent < len(msg[total_sent:]): + sent = self.sock.send(msg[total_sent:]) if sent == 0: raise RuntimeError("socket connection broken") - totalsent = totalsent + sent + total_sent = total_sent + sent def receive(self): '''Receive one or more bytes and return as an [int] @@ -51,6 +51,6 @@ def receive(self): raise RuntimeError("socket connection broken") return list(chunk) # convert from bytes - def close(self): + def close(self): self.sock.close() - return \ No newline at end of file + return diff --git a/python-openlcb.code-workspace b/python-openlcb.code-workspace index 5bd62e0..8700a8c 100644 --- a/python-openlcb.code-workspace +++ b/python-openlcb.code-workspace @@ -13,6 +13,53 @@ "ruff.lint.args": [ "--ignore=E701" ], - "editor.wordWrapColumn": 79 + "editor.wordWrapColumn": 79, + "cSpell.words": [ + "1Adddsss", + "1Bdddsss", + "1Cdddsss", + "1Ddddsss", + "AccumKey", + "ADCDI", + "autosummary", + "baudrate", + "bitmask", + "bitmasks", + "bobjacobsen", + "canbus", + "canframe", + "canlink", + "canphysicallayer", + "canphysicallayersimulation", + "columnspan", + "controlframe", + "datagram", + "datagrams", + "distros", + "dmemo", + "dunder", + "gridargs", + "JMRI", + "linklayer", + "localeventstore", + "localoverrides", + "MDNS", + "MSGLEN", + "nodeid", + "nodestore", + "openlcb", + "padx", + "pady", + "physicallayer", + "Poikilos", + "pyproject", + "servicetype", + "settingtypes", + "setuptools", + "textvariable", + "unformatting", + "usbmodem", + "zeroconf" + ] } } \ No newline at end of file diff --git a/tests/test_conventions.py b/tests/test_conventions.py new file mode 100644 index 0000000..6ccd0e7 --- /dev/null +++ b/tests/test_conventions.py @@ -0,0 +1,95 @@ +import os +import sys +import unittest + +from logging import getLogger + +logger = getLogger(__name__) + + +if __name__ == "__main__": + # Allow importing repo copy of openlcb if running tests from repo manually. + TESTS_DIR = os.path.dirname(os.path.realpath(__file__)) + REPO_DIR = os.path.dirname(TESTS_DIR) + if os.path.isfile(os.path.join(REPO_DIR, "openlcb", "__init__.py")): + sys.path.insert(0, REPO_DIR) + else: + logger.warning( + "Reverting to installed copy if present (or imports will fail)," + " since test running from repo but could not find openlcb in {}." + .format(repr(REPO_DIR))) + + +from openlcb.conventions import ( # noqa: E402 + dotted_lcc_id_to_hex, + is_dotted_lcc_id, + is_hex_lcc_id, + hex_to_dotted_lcc_id, +) + + +class TestConventions(unittest.TestCase): + """Cover conventions.py + (is_hex_lcc_id, dotted_lcc_id_to_hex, and is_dotted_lcc_id + cover validate_lcc_id other than checking which validation error + occurred) + """ + + def test_is_hex_lcc_id(self): + self.assertTrue(is_hex_lcc_id("02015700049C")) + self.assertTrue(is_hex_lcc_id("02015700049c")) + + self.assertFalse(is_hex_lcc_id("02")) # only_hex_pairs yet too short + self.assertFalse(is_hex_lcc_id("2.1.57.0.4.9C")) # not converted + self.assertFalse(is_hex_lcc_id("02.01.57.00.04.9C")) # not converted + self.assertFalse(is_hex_lcc_id("02015700049C.")) + self.assertFalse(is_hex_lcc_id("0")) + self.assertFalse(is_hex_lcc_id("_02015700049C")) # contains start character + self.assertFalse(is_hex_lcc_id("org_product_02015700049C")) # service name not split + + def test_dotted_lcc_id_to_hex(self): + self.assertEqual(dotted_lcc_id_to_hex("2.1.57.0.4.9C"), + "02015700049C") + self.assertEqual(dotted_lcc_id_to_hex("02.01.57.00.04.9C"), + "02015700049C") + self.assertEqual(dotted_lcc_id_to_hex("02.01.57.00.04.9c"), + "02015700049C") # converted to uppercase OK + + self.assertNotEqual(dotted_lcc_id_to_hex("02.01.57.00.04.9c"), + "02015700049c") # function should convert to uppercase + self.assertIsNone(dotted_lcc_id_to_hex("02015700049C")) + self.assertIsNone(dotted_lcc_id_to_hex("02015700049c")) + self.assertIsNone(dotted_lcc_id_to_hex("02")) # only_hex_pairs yet too short + self.assertIsNone(dotted_lcc_id_to_hex("02015700049C.")) + self.assertIsNone(dotted_lcc_id_to_hex("0")) + self.assertIsNone(dotted_lcc_id_to_hex("_02015700049C")) # contains start character + self.assertIsNone(dotted_lcc_id_to_hex("org_product_02015700049C")) # service name not split + + def test_is_dotted_lcc_id(self): + self.assertTrue(is_dotted_lcc_id("02.01.57.00.04.9C")) + self.assertTrue(is_dotted_lcc_id("2.01.57.00.04.9C")) + self.assertTrue(is_dotted_lcc_id("2.1.57.0.4.9C")) + self.assertTrue(is_dotted_lcc_id("2.1.57.0.4.9c")) + + self.assertFalse(is_dotted_lcc_id("02.01.57.00.04.9G")) # G is not hex + self.assertFalse(is_dotted_lcc_id(".01.57.00.04.9C")) # empty pair + self.assertFalse(is_dotted_lcc_id("01.57.00.04.9C")) # only 5 pairs + self.assertFalse(is_dotted_lcc_id("02015700049C")) + + def test_hex_to_dotted_lcc_id(self): + # NOTE: No case conversion occurs in this direction, + # so that doesn't need to be checked. + self.assertEqual(hex_to_dotted_lcc_id("02015700049C"), + "02.01.57.00.04.9C") + + def test_hex_to_dotted_lcc_id_fail(self): + exception = None + try: + _ = hex_to_dotted_lcc_id("2015700049C") + except ValueError as ex: + exception = ex + self.assertIsInstance(exception, ValueError) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_mdnsconventions.py b/tests/test_mdnsconventions.py new file mode 100644 index 0000000..c760fbf --- /dev/null +++ b/tests/test_mdnsconventions.py @@ -0,0 +1,37 @@ +import os +import sys +import unittest + +if __name__ == "__main__": + # Allow importing repo copy of openlcb if running tests from repo manually. + TESTS_DIR = os.path.dirname(os.path.realpath(__file__)) + REPO_DIR = os.path.dirname(TESTS_DIR) + sys.path.insert(0, REPO_DIR) + + +from openlcb.tcplink.mdnsconventions import id_from_tcp_service_name + + +class TestMDNSConventions(unittest.TestCase): + """Cover mdnsconventions.py + id_from_tcp_service_name requires hex_to_dotted_lcc_id to + work which is also covered by test_conventions.py. + """ + def test_id_from_tcp_service_name(self): + self.assertIsNone(id_from_tcp_service_name("aaaaa.local.")) + self.assertEqual(id_from_tcp_service_name( + "bobjacobsen_pythonopenlcb_02015700049C._openlcb-can._tcp.local."), + "02.01.57.00.04.9C" + ) + self.assertEqual(id_from_tcp_service_name( + "pythonopenlcb_02015700049C._openlcb-can._tcp.local."), + "02.01.57.00.04.9C" + ) + self.assertEqual(id_from_tcp_service_name( + "02015700049C._openlcb-can._tcp.local."), + "02.01.57.00.04.9C" + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_openlcb.py b/tests/test_openlcb.py new file mode 100644 index 0000000..1544b5f --- /dev/null +++ b/tests/test_openlcb.py @@ -0,0 +1,29 @@ +import os +import sys +import unittest + +if __name__ == "__main__": + TESTS_DIR = os.path.dirname(os.path.realpath(__file__)) + REPO_DIR = os.path.dirname(TESTS_DIR) + sys.path.insert(0, REPO_DIR) + +from openlcb import only_hex_pairs + + +class TestConventions(unittest.TestCase): + def test_only_hex_pairs(self): + self.assertTrue(only_hex_pairs("02015700049C")) + self.assertTrue(only_hex_pairs("02015700049c")) + self.assertTrue(only_hex_pairs("02")) + + self.assertFalse(only_hex_pairs("02.01.57.00.04.9C")) # contains separator + # ^ For the positive test (& allowing elements not zero-padded) see test_conventions.py + self.assertFalse(only_hex_pairs("02015700049C.")) # contains end character + self.assertFalse(only_hex_pairs("0")) # not a full pair + self.assertFalse(only_hex_pairs("_02015700049C")) # contains start character + self.assertFalse(only_hex_pairs("org_product_02015700049C")) # service name not split + + + +if __name__ == '__main__': + unittest.main()