From 066403c209e22806e11d42bf0c35f394180c5b3a Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Wed, 5 Feb 2025 09:59:03 -0500 Subject: [PATCH 01/99] Rename _charBuffer to _chunks since it is a list, and utilize the list correctly in flush (Fix a breakage from my utf8 PR). Enforce str all the way. Improve comments. --- examples/example_cdi_access.py | 37 ++++++++++++++++++---------------- examples/examples_gui.py | 27 +++++++++++++++++++------ openlcb/memoryservice.py | 16 +++++++-------- openlcb/snip.py | 3 +-- 4 files changed, 50 insertions(+), 33 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index f668854..2ad0567 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -161,48 +161,51 @@ def memoryReadFail(memo): class MyHandler(xml.sax.handler.ContentHandler): - """XML SAX callbacks in a handler object""" + """XML SAX callbacks in a handler object + + Attributes: + _chunks (list[str]): Collects chunks of data. + This is implementation-specific, and not + required if streaming (parser.feed). + """ + def __init__(self): - self._charBuffer = bytearray() + self._chunks = [] def startElement(self, name, attrs): - """_summary_ - - Args: - name (_type_): _description_ - attrs (_type_): _description_ - """ + """See xml.sax.handler.ContentHandler documentation.""" print("Start: ", name) if attrs is not None and attrs : print(" Attributes: ", attrs.getNames()) def endElement(self, name): - """_summary_ - - Args: - name (_type_): _description_ - """ + """See xml.sax.handler.ContentHandler documentation.""" print(name, "content:", self._flushCharBuffer()) print("End: ", name) pass def _flushCharBuffer(self): """Decode the buffer, clear it, and return all content. + See xml.sax.handler.ContentHandler documentation. Returns: str: The content of the bytes buffer decoded as utf-8. """ - s = self._charBuffer.decode("utf-8") - self._charBuffer.clear() + s = ''.join(self._chunks) + self._chunks.clear() return s def characters(self, data): - """Received characters handler + """Received characters handler. + See xml.sax.handler.ContentHandler documentation. + Args: data (Union[bytearray, bytes, list[int]]): any data (any type accepted by bytearray extend). """ - self._charBuffer.extend(data) + if not isinstance(data, str): + raise TypeError("Expected str, got {}".format(type(data).__name__)) + self._chunks.append(data) handler = MyHandler() diff --git a/examples/examples_gui.py b/examples/examples_gui.py index e3e0bc8..3eb563a 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -102,6 +102,7 @@ class MainForm(ttk.Frame): """ def __init__(self, parent): + self.run_button = None self.zeroconf = None self.listener = None self.browser = None @@ -222,12 +223,26 @@ def run_example(self, module_name=None): args = (sys.executable, module_path) self.set_status("Running {} (see console for results)..." "".format(module_name)) - self.proc = subprocess.Popen( - args, - shell=True, - # close_fds=True, close file descriptors >= 3 before running - # stdin=None, stdout=None, stderr=None, - ) + + self.enable_buttons(False) + try: + self.proc = subprocess.Popen( + args, + shell=True, + # close_fds=True, close file descriptors >= 3 before running + # stdin=None, stdout=None, stderr=None, + ) + finally: + self.enable_buttons(True) + + def enable_buttons(self, enable): + state = tk.NORMAL if enable else tk.DISABLED + if self.run_button: + self.run_button.configure(state=state) + for field in self.fields.values(): + if not hasattr(field, 'button') or not field.button: + continue + field.button.configure(state=state) def load_settings(self): # import json diff --git a/openlcb/memoryservice.py b/openlcb/memoryservice.py index 487bbb9..a4de041 100644 --- a/openlcb/memoryservice.py +++ b/openlcb/memoryservice.py @@ -5,7 +5,7 @@ TODO: Read requests are serialized, but write requests are not yet -Datagram retry handles the link being queisced/restarted, so it's not +Datagram retry handles the link being quiesced/restarted, so it's not explicitly handled here. Does memory read and write requests. @@ -44,11 +44,11 @@ class MemoryReadMemo: rejectedReply (Callable[MemoryReadMemo]): Callback function to handle rejected read responses. The callback will receive this MemoryReadMemo instance. - dataReply (Callable[MemoryReadMemo]): Callback function to handle successful - read responses (called after okReply which is handled by - MemoryService). The callback will receive the data read from - memory. This is passed as a MemoryReadMemo object with the - data member set + dataReply (Callable[MemoryReadMemo]): Callback function to + handle successful read responses (called after okReply which + is handled by MemoryService). The callback will receive the + data read from memory. This is passed as a MemoryReadMemo + object with the data member set. Attributes: data(bytearray): The data that was read. @@ -310,8 +310,8 @@ def requestSpaceLength(self, space, nodeID, callback): space (int): Encoded memory space identifier. This can be a value within a specific range, as defined in the `spaceDecode` method. - nodeID (NodeID): ID of remote node from which the memory space length is - requested. + nodeID (NodeID): ID of remote node from which the memory + space length is requested. callback (Callable): Callback function that will receive the response. The callback will receive an integer address as a parameter, representing the address of the diff --git a/openlcb/snip.py b/openlcb/snip.py index 9a840f9..d01bd03 100644 --- a/openlcb/snip.py +++ b/openlcb/snip.py @@ -46,7 +46,7 @@ def __init__(self, mfgName="", self.updateSnipDataFromStrings() self.index = 0 - # OLCB Strings are fixed length null terminated. + # OpenLCB Strings are fixed length null terminated. # We don't (yet) support later versions with e.g. larger strings, etc. def getStringN(self, n): @@ -124,7 +124,6 @@ def getString(self, first, maxLength): # terminate_i should point at the first zero or exclusive end return self.data[first:terminate_i].decode("utf-8") - def addData(self, in_data): ''' Add additional bytes of SNIP data From e776630dc3e69ef214732dbda95aa6fc7bf36225 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Wed, 5 Feb 2025 17:16:37 -0500 Subject: [PATCH 02/99] Use bytearray more thoroughly in examples as required by new module code. --- examples/example_datagram_transfer.py | 2 +- openlcb/memoryservice.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index 50b2256..c026b77 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -110,7 +110,7 @@ def datagramWrite(): writeMemo = DatagramWriteMemo( NodeID(farNodeID), - [0x20, 0x43, 0x00, 0x00, 0x00, 0x00, 0x14], + bytearray([0x20, 0x43, 0x00, 0x00, 0x00, 0x00, 0x14]), writeCallBackCheck ) datagramService.sendDatagram(writeMemo) diff --git a/openlcb/memoryservice.py b/openlcb/memoryservice.py index a4de041..2fe324c 100644 --- a/openlcb/memoryservice.py +++ b/openlcb/memoryservice.py @@ -327,7 +327,7 @@ def requestSpaceLength(self, space, nodeID, callback): # send request dgReqMemo = DatagramWriteMemo( nodeID, - [DatagramService.ProtocolID.MemoryOperation.value, 0x84, space] + bytearray([DatagramService.ProtocolID.MemoryOperation.value, 0x84, space]) ) self.service.sendDatagram(dgReqMemo) From 6f622acabb366a20884201a8c2d5ac857071d165 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Wed, 5 Feb 2025 17:17:23 -0500 Subject: [PATCH 03/99] Add the pyserial requirement (not same as serial package). --- pyproject.toml | 4 ++-- python-openlcb.code-workspace | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 40fd0b1..ef06f79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,8 +38,8 @@ requires-python = ">= 3.8" # ^ allows version to be overridden by a git tag using # setuptools_scm, or by a __version__ Python attribute. # See https://packaging.python.org/en/latest/guides/single-sourcing-package-version/#single-sourcing-the-version -# dependencies = [ -# ] +dependencies = ["pyserial"] +# ^ "pyserial" package has correct serial module (not the "serial" package). [project.optional-dependencies] gui = ["zeroconf"] diff --git a/python-openlcb.code-workspace b/python-openlcb.code-workspace index 9fae6c1..151890e 100644 --- a/python-openlcb.code-workspace +++ b/python-openlcb.code-workspace @@ -57,6 +57,7 @@ "physicallayer", "Poikilos", "pyproject", + "pyserial", "servicetype", "settingtypes", "setuptools", From 5acb7d9ef2813a2c48a67573ce6b0cbe021740e6 Mon Sep 17 00:00:00 2001 From: poikilos <7557867+poikilos@users.noreply.github.com> Date: Sat, 8 Feb 2025 14:10:22 -0500 Subject: [PATCH 04/99] Fix Checkbutton handling. --- examples/examples_gui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index 3eb563a..570277b 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -458,7 +458,7 @@ def add_field(self, key, caption, gui_class=ttk.Entry, command=None, self.host_column = self.column self.column += 1 self.fields[key] = field - if gui_class in (ttk.Checkbutton, tk.Checkbutton): + if gui_class in (ttk.Checkbutton, ttk.Checkbutton): field.var = tk.BooleanVar(self.w1) # field.var.set(True) field.widget = gui_class( From 2dba727edb3280743427be31853f1653df475611 Mon Sep 17 00:00:00 2001 From: poikilos <7557867+poikilos@users.noreply.github.com> Date: Sat, 8 Feb 2025 14:49:46 -0500 Subject: [PATCH 05/99] Use a more modern interface theme when using Linux. --- examples/examples_gui.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index 570277b..684d643 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -13,6 +13,7 @@ """ import json import os +import platform import subprocess import sys import tkinter as tk @@ -601,6 +602,28 @@ def exit_clicked(self): def main(): root = tk.Tk() + root.style = ttk.Style() + if platform.system() == "Windows": + if 'winnative' in root.style.theme_names(): + root.style.theme_use('winnative') + elif platform.system() == "Darwin": + if 'aqua' in root.style.theme_names(): + root.style.theme_use('aqua') + else: + # Linux (such as Linux Mint 22.1) usually has + # 'clam', 'alt', 'default', 'classic' + if 'alt' in root.style.theme_names(): + # Use 'alt' since: + # - 'default' and 'classic' (like 'default' but fatter + # shading lines) may be motif-like :( (diamond-shaped + # radio buttons etc) + # - 'clam' is "3D" (Windows 95-like, warm gray) + # - 'alt' is "3D" (Windows 2000-like, cool gray) + root.style.theme_use('alt') + else: + print("No theme selected. Themes: {}" + .format(root.style.theme_names())) + screen_w = root.winfo_screenwidth() screen_h = root.winfo_screenheight() window_w = round(screen_w / 2) From 14331057074fff025fb1d987556ff24dd7f0c439 Mon Sep 17 00:00:00 2001 From: poikilos <7557867+poikilos@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:43:19 -0500 Subject: [PATCH 06/99] Place examples in a tab (to prepare for other tabs). --- examples/examples_gui.py | 62 ++++++++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index 684d643..d739ea5 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -104,6 +104,7 @@ class MainForm(ttk.Frame): def __init__(self, parent): self.run_button = None + self.examples_label = None self.zeroconf = None self.listener = None self.browser = None @@ -137,8 +138,9 @@ def on_form_loaded(self): count = self.show_next_error() if not count: self.set_status( - "Welcome! Select an example. Run also saves settings." + "Welcome!" ) + # else show_next_error should have already set status label text. def show_next_error(self): if not self.errors: @@ -153,11 +155,37 @@ def remove_examples(self): for module_name, button in self.example_buttons.items(): button.grid_forget() self.row -= 1 + if self.examples_label: + self.examples_label.grid_forget() + self.examples_label = None + if self.run_button: + self.run_button.grid_forget() + self.run_button = None self.example_buttons.clear() self.example_modules.clear() def load_examples(self): self.remove_examples() + self.example_row = 0 + self.examples_label = ttk.Label( + self.example_tab, + text=("These examples run in the background without a GUI." + "\nHowever, the interface above can setup settings.json" + "\n (usable by any of them, saved when run is clicked)."), + ) + self.examples_label.grid(row=0, column=1) + + self.run_button = ttk.Button( + self.example_tab, + text="Run", + command=self.run_example, + # command=lambda x=name: self.run_example(module_name=x), + # x=name is necessary for early binding, otherwise all + # lambdas will have the *last* value in the loop. + ) + self.run_button.grid(row=0, column=0, sticky=tk.W) + self.example_row += 1 + repo_dir = os.path.dirname(os.path.realpath(__file__)) self.example_var = tk.IntVar() # Shared by *all* in radio group. # ^ The value refers to an entry in examples: @@ -171,7 +199,7 @@ def load_examples(self): name, _ = os.path.splitext(sub) # name, dot+extension self.example_modules[name] = sub_path button = ttk.Radiobutton( - self, + self.example_group_box, text=name, variable=self.example_var, value=len(self.examples), @@ -180,19 +208,9 @@ def load_examples(self): # lambdas will have the *last* value in the loop. ) self.examples.append(name) - button.grid(row=self.row, column=1) + button.grid(row=self.example_row, column=0, sticky=tk.W) self.example_buttons[name] = button - self.row += 1 - self.run_button = ttk.Button( - self, - text="Run", - command=self.run_example, - # command=lambda x=name: self.run_example(module_name=x), - # x=name is necessary for early binding, otherwise all - # lambdas will have the *last* value in the loop. - ) - self.run_button.grid(row=self.row, column=1) - self.row += 1 + self.example_row += 1 def run_example(self, module_name=None): """Run the selected example. @@ -376,6 +394,20 @@ def gui(self, parent): text="Trace", ) + # NOTE: load_examples (See on_form_loaded) fills Examples tab. + self.notebook = ttk.Notebook(self) + self.notebook.grid(sticky=tk.NSEW, row=self.row, column=0, + columnspan=self.column_count) + self.row += 1 + self.example_tab = ttk.Frame(self.notebook) + self.example_tab.columnconfigure(index=0, weight=1) + self.example_tab.columnconfigure(index=1, weight=1) + self.example_tab.rowconfigure(index=0, weight=1) + self.example_tab.rowconfigure(index=1, weight=1) + self.notebook.add(self.example_tab, text="Examples") + + self.example_group_box = self.example_tab + # 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) @@ -627,7 +659,7 @@ def main(): screen_w = root.winfo_screenwidth() screen_h = root.winfo_screenheight() window_w = round(screen_w / 2) - window_h = round(screen_h * .75) + window_h = round(screen_h * .9) root.geometry("{}x{}".format( window_w, window_h, From fc196157c103eae075553803318ff259f5fc752d Mon Sep 17 00:00:00 2001 From: poikilos <7557867+poikilos@users.noreply.github.com> Date: Sat, 8 Feb 2025 17:47:01 -0500 Subject: [PATCH 07/99] Rename s so sock for clarity. --- examples/example_cdi_access.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index 2ad0567..d02c38e 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -42,9 +42,9 @@ # farNodeID = "02.01.57.00.04.9C" # endregion moved to settings -s = TcpSocket() +sock = TcpSocket() # s.settimeout(30) -s.connect(settings['host'], settings['port']) +sock.connect(settings['host'], settings['port']) # print("RR, SR are raw socket interface receive and send;" @@ -53,7 +53,7 @@ def sendToSocket(string): # print(" SR: {}".format(string.strip())) - s.send(string) + sock.send(string) def printFrame(frame): @@ -253,7 +253,7 @@ def memoryRead(): # process resulting activity while True: - received = s.receive() + received = sock.receive() # print(" RR: {}".format(received.strip())) # pass to link processor canPhysicalLayerGridConnect.receiveString(received) From 282ed183f8b74ee130d306f0a3fff3071500f790 Mon Sep 17 00:00:00 2001 From: poikilos <7557867+poikilos@users.noreply.github.com> Date: Sat, 8 Feb 2025 17:48:04 -0500 Subject: [PATCH 08/99] Add preliminary CDIFrame that shows tags (TODO: create a GUI element in endElement). Add to sys.path only if can be done accurately. --- doc/conf.py | 1 + examples/examples_gui.py | 62 +++++++- examples/examples_settings.py | 9 +- examples/tkexamples/__init__.py | 0 examples/tkexamples/cdiframe.py | 259 ++++++++++++++++++++++++++++++++ tests/test_mdnsconventions.py | 11 +- tests/test_memoryservice.py | 11 +- tests/test_openlcb.py | 11 +- tests/test_snip.py | 10 +- 9 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 examples/tkexamples/__init__.py create mode 100644 examples/tkexamples/cdiframe.py diff --git a/doc/conf.py b/doc/conf.py index 0233999..c952516 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -8,6 +8,7 @@ import os import sys + sys.path.insert(0, os.path.abspath('..')) project = 'python-openlcb' diff --git a/examples/examples_gui.py b/examples/examples_gui.py index d739ea5..af14a8b 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -16,11 +16,17 @@ import platform import subprocess import sys +import threading import tkinter as tk from tkinter import ttk from collections import OrderedDict +from tkexamples.cdiframe import CDIFrame + from examples_settings import Settings +# ^ adds parent of module to sys.path, so openlcb imports *after* this + +from openlcb import emit_cast from openlcb.tcplink.mdnsconventions import id_from_tcp_service_name zeroconf_enabled = False @@ -109,6 +115,7 @@ def __init__(self, parent): self.listener = None self.browser = None self.errors = [] + self.root = parent try: self.settings = Settings() except json.decoder.JSONDecodeError as ex: @@ -399,12 +406,32 @@ def gui(self, parent): self.notebook.grid(sticky=tk.NSEW, row=self.row, column=0, columnspan=self.column_count) self.row += 1 + self.cdi_row = 0 + # region based on ttk Forest Theme + self.cdi_tab = ttk.Frame(self.notebook) + self.cdi_tab.columnconfigure(index=0, weight=1) + self.cdi_tab.columnconfigure(index=1, weight=1) + self.cdi_tab.rowconfigure(index=0, weight=1) + self.cdi_tab.rowconfigure(index=1, weight=1) + self.notebook.add(self.cdi_tab, text="Node Configuration (CDI)") + # endregion based on ttk Forest Theme + + self.cdi_connect_button = ttk.Button( + self.cdi_tab, + text="Connect", + command=self.cdi_connect_clicked, + ) + self.cdi_connect_button.grid(row=self.cdi_row) + self.cdi_row += 1 + self.cdi_frame = CDIFrame(self.cdi_tab) + self.cdi_frame.grid(row=self.cdi_row) + self.example_tab = ttk.Frame(self.notebook) self.example_tab.columnconfigure(index=0, weight=1) self.example_tab.columnconfigure(index=1, weight=1) self.example_tab.rowconfigure(index=0, weight=1) self.example_tab.rowconfigure(index=1, weight=1) - self.notebook.add(self.example_tab, text="Examples") + self.notebook.add(self.example_tab, text="Other Examples") self.example_group_box = self.example_tab @@ -425,6 +452,39 @@ def gui(self, parent): # self.rowconfigure(row, weight=1) # self.rowconfigure(self.row_count-1, weight=1) # make last row expand + def callback(self, event_d): + """Handle a dict event from a different thread + (this type of event is specific to examples) + """ + # Trigger the main thread (only the main thread can access the GUI) + self.root.after(0, self._callback, event_d) + + def _callback(self, event_d): + message = event_d.get('message') + if message: + self.set_status(message) + + def cdi_connect_clicked(self): + host_var = self.fields.get('host') + host = host_var.get() + port_var = self.fields.get('port') + port = port_var.get() + if port: + port = int(port) + else: + raise TypeError("Expected int, got {}".format(emit_cast(port))) + localNodeID_var = self.fields.get('localNodeID') + localNodeID = localNodeID_var.get() + # self.cdi_frame.connect(host, port, localNodeID) + threading.Thread( + target=self.cdi_frame.connect, + args=(host, port, localNodeID), + kwargs={'callback': self.callback}, + daemon=True, + ).start() + self.cdi_connect_button.configure(state=tk.DISABLED) + # daemon=True ensures the thread does not block program exit if the user closes the application. + def set_id_from_name(self): id = self.get_id_from_name(update_button=True) if not id: diff --git a/examples/examples_settings.py b/examples/examples_settings.py index 9c1eb1c..dea0942 100644 --- a/examples/examples_settings.py +++ b/examples/examples_settings.py @@ -8,12 +8,19 @@ import shutil import sys +from logging import getLogger +logger = getLogger(__name__) + REPO_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) if os.path.isfile(os.path.join(REPO_DIR, "openlcb", "__init__.py")): # User is running from the repo # (generally true if using examples_settings) 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))) CONFIGS_DIR = os.path.dirname(os.path.realpath(__file__)) DEFAULT_SETTINGS = { diff --git a/examples/tkexamples/__init__.py b/examples/tkexamples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/tkexamples/cdiframe.py b/examples/tkexamples/cdiframe.py new file mode 100644 index 0000000..1c13ca2 --- /dev/null +++ b/examples/tkexamples/cdiframe.py @@ -0,0 +1,259 @@ +""" +CDI Frame + +This file is part of the python-openlcb project +(). + +Contributors: Poikilos, Bob Jacobsen (code from example_cdi_access) + +Purpose: Provide a reusable widget for editing LCC node settings +as described by the node's Configuration Description Information (CDI). +""" +import json +import os +import platform +import subprocess +import sys +import tkinter as tk +from tkinter import ttk +from collections import OrderedDict + +import xml.etree.ElementTree as ET + +from xml.etree.ElementTree import Element +from logging import getLogger + +logger = getLogger(__name__) + +TKEXAMPLES_DIR = os.path.dirname(os.path.realpath(__file__)) +EXAMPLES_DIR = os.path.dirname(TKEXAMPLES_DIR) +REPO_DIR = os.path.dirname(EXAMPLES_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))) +try: + from openlcb.canbus.tcpsocket import TcpSocket +except ImportError as ex: + print("{}: {}".format(type(ex).__name__, ex), file=sys.stderr) + print("* You must run this from a venv that has openlcb installed" + " or adds it to sys.path like examples_settings does.", + file=sys.stderr) + raise # sys.exit(1) + + +from openlcb.canbus.canphysicallayergridconnect import ( + CanPhysicalLayerGridConnect, +) +from openlcb.canbus.canlink import CanLink +from openlcb.nodeid import NodeID +from openlcb.datagramservice import ( + DatagramService, +) +from openlcb.memoryservice import ( + MemoryReadMemo, + MemoryService, +) + + +import xml.sax # noqa: E402 + + +class CDIFrame(ttk.Frame, xml.sax.handler.ContentHandler): + def __init__(self, *args, **kwargs): + self.parent = None + if args: + self.parent = args[0] + self.realtime = True + + ttk.Frame.__init__(self, *args, **kwargs) + xml.sax.handler.ContentHandler.__init__(self) + # region ContentHandler + # self._chunks = [] + self._tag_stack = [] + # endregion ContentHandler + self.container = self # where to put visible widgets + self.treeview = None + self.gui(self.container) + + def callback_msg(self, msg): + if self.callback: + self.callback({ + 'message': msg, + }) + else: + logger.warning("No callback, but set status: {}".format(msg)) + + def gui(self, container): + self.treeview = ttk.Treeview(container) + self.grid(sticky=tk.NSEW) + + def connect(self, host, port, localNodeID, callback=None): + self.callback = callback + self.callback_msg("connecting to {}...".format(host)) + self.sock = TcpSocket() + # s.settimeout(30) + self.sock.connect(host, port) + logger.warning("CanPhysicalLayerGridConnect...") + self.canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(self.sendToSocket) + self.canPhysicalLayerGridConnect.registerFrameReceivedListener(self.printFrame) + + + self.callback_msg("CanLink...") + self.canLink = CanLink(NodeID(localNodeID)) + self.canLink.linkPhysicalLayer(self.canPhysicalLayerGridConnect) + self.canLink.registerMessageReceivedListener(self.printMessage) + + self.callback_msg("DatagramService...") + self.datagramService = DatagramService(self.canLink) + self.canLink.registerMessageReceivedListener(self.datagramService.process) + + self.datagramService.registerDatagramReceivedListener(self.printDatagram) + + self.callback_msg("MemoryService...") + self.memoryService = MemoryService(self.datagramService) + + # accumulate the CDI information + self.resultingCDI = bytearray() + + def sendToSocket(self, string): + # print(" SR: {}".format(string.strip())) + self.sock.send(string) + + + def printFrame(self, frame): + # print(" RL: {}".format(frame)) + pass + + + def printMessage(self, message): + # print("RM: {} from {}".format(message, message.source)) + pass + + + def printDatagram(self, memo): + """A call-back for when datagrams received + + Args: + DatagramReadMemo: The datagram object + + Returns: + bool: Always False (True would mean we sent a reply to the datagram, + but let the MemoryService do that). + """ + # print("Datagram receive call back: {}".format(memo.data)) + return False + + + def memoryReadSuccess(self, memo): + """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. + + Args: + memo (MemoryReadMemo): Successful MemoryReadMemo + """ + # print("successful memory read: {}".format(memo.data)) + if self.realtime: + if len(memo.data) == 64 and 0 not in memo.data: + chunk = memo.data.decode("utf-8") + else: + null_i = memo.data.find(b'\0') + terminate_i = len(memo.data) + if null_i > -1: + terminate_i = min(null_i, terminate_i) + chunk = memo.data[:terminate_i].decode("utf-8") + + self.feed(chunk) + # is this done? + else: + if len(memo.data) == 64 and 0 not in memo.data: + # save content + self.resultingCDI += memo.data + # update the address + memo.address = memo.address+64 + # and read again + self.memoryService.requestMemoryRead(memo) + # The last packet is not yet reached, so don't parse (However, + # parser.feed could be called for realtime processing). + else : + # and we're done! + # save content + self.resultingCDI += memo.data + # concert resultingCDI to a string up to 1st zero + cdiString = "" + null_i = self.resultingCDI.find(b'\0') + terminate_i = len(self.resultingCDI) + if null_i > -1: + terminate_i = min(null_i, terminate_i) + cdiString = self.resultingCDI[:terminate_i].decode("utf-8") + # print (cdiString) + + # and process that + self.parse(cdiString) + self.callback_msg("Done loading CDI.") + + # done + + + def memoryReadFail(self, memo): + print("memory read failed: {}".format(memo.data)) + + + def startElement(self, name, attrs): + """See xml.sax.handler.ContentHandler documentation.""" + self._start_name = name + self._start_attrs = attrs + tab = " " * len(self._tag_stack) + print(tab, "Start: ", name) + if attrs is not None and attrs : + print(tab, " Attributes: ", attrs.getNames()) + el = Element(name, attrs) + self.callback_msg( + "loaded: {}{}".format(tab, ET.tostring(el, encoding="unicode"))) + self._tag_stack.append(el) + + def endElement(self, name): + """See xml.sax.handler.ContentHandler documentation.""" + indent = len(self._tag_stack) + if indent: + indent -= 1 # account for closing the tag + tab = " " * indent + # print(tab, name, "content:", self._flushCharBuffer()) + print(tab, "End: ", name) + if len(self._tag_stack): + print(tab+"Warning: before any start tag" + .format(name)) + return + top_el = self._tag_stack[-1] + if name != top_el.tag: + print(tab+"Warning: before ".format(name, top_el.tag)) + return + del self._tag_stack[-1] + + # def _flushCharBuffer(self): + # """Decode the buffer, clear it, and return all content. + # See xml.sax.handler.ContentHandler documentation. + + # Returns: + # str: The content of the bytes buffer decoded as utf-8. + # """ + # s = ''.join(self._chunks) + # self._chunks.clear() + # return s + + # def characters(self, data): + # """Received characters handler. + # See xml.sax.handler.ContentHandler documentation. + + # Args: + # data (Union[bytearray, bytes, list[int]]): any + # data (any type accepted by bytearray extend). + # """ + # if not isinstance(data, str): + # raise TypeError("Expected str, got {}".format(type(data).__name__)) + # self._chunks.append(data) \ No newline at end of file diff --git a/tests/test_mdnsconventions.py b/tests/test_mdnsconventions.py index c760fbf..5eef1f6 100644 --- a/tests/test_mdnsconventions.py +++ b/tests/test_mdnsconventions.py @@ -2,11 +2,20 @@ 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) - sys.path.insert(0, REPO_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.tcplink.mdnsconventions import id_from_tcp_service_name diff --git a/tests/test_memoryservice.py b/tests/test_memoryservice.py index 72c3f67..451002e 100644 --- a/tests/test_memoryservice.py +++ b/tests/test_memoryservice.py @@ -4,11 +4,20 @@ 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) - sys.path.insert(0, REPO_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.nodeid import NodeID from openlcb.linklayer import LinkLayer diff --git a/tests/test_openlcb.py b/tests/test_openlcb.py index 9bbdb5d..aa806ae 100644 --- a/tests/test_openlcb.py +++ b/tests/test_openlcb.py @@ -2,10 +2,19 @@ import sys import unittest +from logging import getLogger +logger = getLogger(__name__) + 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) + 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 import ( emit_cast, diff --git a/tests/test_snip.py b/tests/test_snip.py index c4bfebf..9c5c3b4 100644 --- a/tests/test_snip.py +++ b/tests/test_snip.py @@ -3,12 +3,20 @@ 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) - sys.path.insert(0, REPO_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.snip import SNIP From 2ac3655bc97f752dcc9533900e341656787f7848 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+poikilos@users.noreply.github.com> Date: Mon, 10 Feb 2025 14:40:55 -0500 Subject: [PATCH 09/99] Rename CDIFrame to CDIForm to match non-Tk (more common) naming conventions. --- examples/examples_gui.py | 12 +++++++++--- examples/tkexamples/cdiframe.py | 7 +++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index af14a8b..f627923 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -17,11 +17,17 @@ import subprocess import sys import threading -import tkinter as tk +try: + import tkinter as tk +except ImportError: + print("\nYou must first install python3-tk if using apt or other system" + " that requires tkinter to be in a separate package from Python.", + file=sys.stderr) + raise from tkinter import ttk from collections import OrderedDict -from tkexamples.cdiframe import CDIFrame +from tkexamples.cdiframe import CDIForm from examples_settings import Settings # ^ adds parent of module to sys.path, so openlcb imports *after* this @@ -423,7 +429,7 @@ def gui(self, parent): ) self.cdi_connect_button.grid(row=self.cdi_row) self.cdi_row += 1 - self.cdi_frame = CDIFrame(self.cdi_tab) + self.cdi_frame = CDIForm(self.cdi_tab) self.cdi_frame.grid(row=self.cdi_row) self.example_tab = ttk.Frame(self.notebook) diff --git a/examples/tkexamples/cdiframe.py b/examples/tkexamples/cdiframe.py index 1c13ca2..962c2be 100644 --- a/examples/tkexamples/cdiframe.py +++ b/examples/tkexamples/cdiframe.py @@ -62,7 +62,7 @@ import xml.sax # noqa: E402 -class CDIFrame(ttk.Frame, xml.sax.handler.ContentHandler): +class CDIForm(ttk.Frame, xml.sax.handler.ContentHandler): def __init__(self, *args, **kwargs): self.parent = None if args: @@ -81,6 +81,7 @@ def __init__(self, *args, **kwargs): def callback_msg(self, msg): if self.callback: + print("CDIForm callback_msg({})".format(repr(msg))) self.callback({ 'message': msg, }) @@ -97,14 +98,16 @@ def connect(self, host, port, localNodeID, callback=None): self.sock = TcpSocket() # s.settimeout(30) self.sock.connect(host, port) - logger.warning("CanPhysicalLayerGridConnect...") + self.callback_msg("CanPhysicalLayerGridConnect...") self.canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(self.sendToSocket) self.canPhysicalLayerGridConnect.registerFrameReceivedListener(self.printFrame) self.callback_msg("CanLink...") self.canLink = CanLink(NodeID(localNodeID)) + self.callback_msg("CanLink...linkPhysicalLayer...") self.canLink.linkPhysicalLayer(self.canPhysicalLayerGridConnect) + self.callback_msg("CanLink...linkPhysicalLayer...registerMessageReceivedListener...") self.canLink.registerMessageReceivedListener(self.printMessage) self.callback_msg("DatagramService...") From 6af5a866e73b2d9c2dfa4b54bca6919ed19e0a02 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+poikilos@users.noreply.github.com> Date: Mon, 10 Feb 2025 14:42:02 -0500 Subject: [PATCH 10/99] Rename submodule and instance to match new class name. --- examples/examples_gui.py | 10 +++++----- examples/tkexamples/{cdiframe.py => cdiform.py} | 0 2 files changed, 5 insertions(+), 5 deletions(-) rename examples/tkexamples/{cdiframe.py => cdiform.py} (100%) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index f627923..1eb1b35 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -27,7 +27,7 @@ from tkinter import ttk from collections import OrderedDict -from tkexamples.cdiframe import CDIForm +from examples.tkexamples.cdiform import CDIForm from examples_settings import Settings # ^ adds parent of module to sys.path, so openlcb imports *after* this @@ -429,8 +429,8 @@ def gui(self, parent): ) self.cdi_connect_button.grid(row=self.cdi_row) self.cdi_row += 1 - self.cdi_frame = CDIForm(self.cdi_tab) - self.cdi_frame.grid(row=self.cdi_row) + self.cdi_form = CDIForm(self.cdi_tab) + self.cdi_form.grid(row=self.cdi_row) self.example_tab = ttk.Frame(self.notebook) self.example_tab.columnconfigure(index=0, weight=1) @@ -481,9 +481,9 @@ def cdi_connect_clicked(self): raise TypeError("Expected int, got {}".format(emit_cast(port))) localNodeID_var = self.fields.get('localNodeID') localNodeID = localNodeID_var.get() - # self.cdi_frame.connect(host, port, localNodeID) + # self.cdi_form.connect(host, port, localNodeID) threading.Thread( - target=self.cdi_frame.connect, + target=self.cdi_form.connect, args=(host, port, localNodeID), kwargs={'callback': self.callback}, daemon=True, diff --git a/examples/tkexamples/cdiframe.py b/examples/tkexamples/cdiform.py similarity index 100% rename from examples/tkexamples/cdiframe.py rename to examples/tkexamples/cdiform.py From fc09eea440475a702ea4a26d4be91e85bc26d4f7 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+poikilos@users.noreply.github.com> Date: Fri, 14 Feb 2025 13:08:35 -0500 Subject: [PATCH 11/99] Add CDIHandler and CDIForm as example subclass (FIXME: downloadCDI Uses localNodeID instead of farNodeID). --- examples/examples_gui.py | 58 ++++- examples/tkexamples/cdiform.py | 333 ++++++++++----------------- openlcb/cdihandler.py | 396 +++++++++++++++++++++++++++++++++ 3 files changed, 565 insertions(+), 222 deletions(-) create mode 100644 openlcb/cdihandler.py diff --git a/examples/examples_gui.py b/examples/examples_gui.py index 1eb1b35..d91a10f 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -17,6 +17,9 @@ import subprocess import sys import threading + +from logging import getLogger + try: import tkinter as tk except ImportError: @@ -52,6 +55,8 @@ class ServiceBrowser: """Placeholder for when zeroconf is *not* present""" pass +logger = getLogger(__name__) + class MyListener(ServiceListener): pass @@ -74,6 +79,8 @@ def __init__(self): self.widget = None self.button = None self.tooltip = None + self.groups = None + self.segment = None def get(self): return self.var.get() @@ -134,7 +141,7 @@ def __init__(self, parent): self.detected_services = OrderedDict() self.fields = OrderedDict() self.proc = None - self.gui(parent) + self._gui(parent) self.w1.after(1, self.on_form_loaded) # must go after gui self.example_modules = OrderedDict() self.example_buttons = OrderedDict() @@ -311,7 +318,7 @@ def save_settings(self): self.settings[key] = value self.settings.save() - def gui(self, parent): + def _gui(self, parent): print("Using {}".format(self.settings.settings_path)) # import json # print(json.dumps(self.settings._meta, indent=1, sort_keys=True)) @@ -427,7 +434,16 @@ def gui(self, parent): text="Connect", command=self.cdi_connect_clicked, ) - self.cdi_connect_button.grid(row=self.cdi_row) + self.cdi_connect_button.grid(row=self.cdi_row, column=0) + + self.cdi_refresh_button = ttk.Button( + self.cdi_tab, + text="Refresh", + command=self.cdi_refresh_clicked, + state=tk.DISABLED, # enabled on connect success callback + ) + self.cdi_refresh_button.grid(row=self.cdi_row, column=1) + self.cdi_row += 1 self.cdi_form = CDIForm(self.cdi_tab) self.cdi_form.grid(row=self.cdi_row) @@ -458,7 +474,7 @@ def gui(self, parent): # self.rowconfigure(row, weight=1) # self.rowconfigure(self.row_count-1, weight=1) # make last row expand - def callback(self, event_d): + def connect_callback(self, event_d): """Handle a dict event from a different thread (this type of event is specific to examples) """ @@ -469,6 +485,16 @@ def _callback(self, event_d): message = event_d.get('message') if message: self.set_status(message) + done = event_d.get('done') + if done: + self.cdi_refresh_button.configure(state=tk.NORMAL) + if message: + logger.warning( + "Done, but skipped message: {}".format(repr(message))) + # if not message: + message = 'Done. Ready to load CDI (click "Refresh")' + print(message) + self.set_status(message) def cdi_connect_clicked(self): host_var = self.fields.get('host') @@ -485,12 +511,34 @@ def cdi_connect_clicked(self): threading.Thread( target=self.cdi_form.connect, args=(host, port, localNodeID), - kwargs={'callback': self.callback}, + kwargs={'callback': self.connect_callback}, daemon=True, ).start() self.cdi_connect_button.configure(state=tk.DISABLED) + self.cdi_refresh_button.configure(state=tk.DISABLED) # daemon=True ensures the thread does not block program exit if the user closes the application. + def cdi_refresh_clicked(self): + self.cdi_connect_button.configure(state=tk.DISABLED) + self.cdi_refresh_button.configure(state=tk.DISABLED) + farNodeID = self.get_value('farNodeID') + if not farNodeID: + self.set_status('Set "Far node ID" first.') + return + print("Querying farNodeID={}".format(repr(farNodeID))) + threading.Thread( + target=self.cdi_form.downloadCDI, + args=(farNodeID,), + kwargs={'callback': self.cdi_form.cdi_refresh_callback}, + daemon=True, + ).start() + + def get_value(self, key): + field = self.fields.get(key) + if not field: + raise KeyError("Invalid form field {}".format(repr(key))) + return field.get() + def set_id_from_name(self): id = self.get_id_from_name(update_button=True) if not id: diff --git a/examples/tkexamples/cdiform.py b/examples/tkexamples/cdiform.py index 962c2be..37f4225 100644 --- a/examples/tkexamples/cdiform.py +++ b/examples/tkexamples/cdiform.py @@ -1,28 +1,23 @@ """ CDI Frame +A reusable widget for editing LCC node settings as described by the +node's Configuration Description Information (CDI). + This file is part of the python-openlcb project (). -Contributors: Poikilos, Bob Jacobsen (code from example_cdi_access) - -Purpose: Provide a reusable widget for editing LCC node settings -as described by the node's Configuration Description Information (CDI). +Contributors: Poikilos """ -import json import os -import platform -import subprocess import sys import tkinter as tk from tkinter import ttk -from collections import OrderedDict - -import xml.etree.ElementTree as ET -from xml.etree.ElementTree import Element from logging import getLogger + + logger = getLogger(__name__) TKEXAMPLES_DIR = os.path.dirname(os.path.realpath(__file__)) @@ -36,7 +31,7 @@ " since test running from repo but could not find openlcb in {}." .format(repr(REPO_DIR))) try: - from openlcb.canbus.tcpsocket import TcpSocket + from openlcb.cdihandler import CDIHandler except ImportError as ex: print("{}: {}".format(type(ex).__name__, ex), file=sys.stderr) print("* You must run this from a venv that has openlcb installed" @@ -45,218 +40,122 @@ raise # sys.exit(1) -from openlcb.canbus.canphysicallayergridconnect import ( - CanPhysicalLayerGridConnect, -) -from openlcb.canbus.canlink import CanLink -from openlcb.nodeid import NodeID -from openlcb.datagramservice import ( - DatagramService, -) -from openlcb.memoryservice import ( - MemoryReadMemo, - MemoryService, -) - - import xml.sax # noqa: E402 +class CDIForm(ttk.Frame, CDIHandler): + """A GUI frame to represent the CDI visually as a tree. -class CDIForm(ttk.Frame, xml.sax.handler.ContentHandler): + Args: + parent (TkWidget): Typically a ttk.Frame or tk.Frame with "root" + attribute set. + """ def __init__(self, *args, **kwargs): - self.parent = None - if args: - self.parent = args[0] - self.realtime = True - + CDIHandler.__init__(self, *args, **kwargs) ttk.Frame.__init__(self, *args, **kwargs) - xml.sax.handler.ContentHandler.__init__(self) - # region ContentHandler - # self._chunks = [] - self._tag_stack = [] - # endregion ContentHandler - self.container = self # where to put visible widgets - self.treeview = None - self.gui(self.container) - - def callback_msg(self, msg): - if self.callback: - print("CDIForm callback_msg({})".format(repr(msg))) - self.callback({ - 'message': msg, - }) - else: - logger.warning("No callback, but set status: {}".format(msg)) - - def gui(self, container): - self.treeview = ttk.Treeview(container) - self.grid(sticky=tk.NSEW) - - def connect(self, host, port, localNodeID, callback=None): - self.callback = callback - self.callback_msg("connecting to {}...".format(host)) - self.sock = TcpSocket() - # s.settimeout(30) - self.sock.connect(host, port) - self.callback_msg("CanPhysicalLayerGridConnect...") - self.canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(self.sendToSocket) - self.canPhysicalLayerGridConnect.registerFrameReceivedListener(self.printFrame) - - - self.callback_msg("CanLink...") - self.canLink = CanLink(NodeID(localNodeID)) - self.callback_msg("CanLink...linkPhysicalLayer...") - self.canLink.linkPhysicalLayer(self.canPhysicalLayerGridConnect) - self.callback_msg("CanLink...linkPhysicalLayer...registerMessageReceivedListener...") - self.canLink.registerMessageReceivedListener(self.printMessage) - - self.callback_msg("DatagramService...") - self.datagramService = DatagramService(self.canLink) - self.canLink.registerMessageReceivedListener(self.datagramService.process) - - self.datagramService.registerDatagramReceivedListener(self.printDatagram) - - self.callback_msg("MemoryService...") - self.memoryService = MemoryService(self.datagramService) - - # accumulate the CDI information - self.resultingCDI = bytearray() - - def sendToSocket(self, string): - # print(" SR: {}".format(string.strip())) - self.sock.send(string) - - - def printFrame(self, frame): - # print(" RL: {}".format(frame)) - pass - - - def printMessage(self, message): - # print("RM: {} from {}".format(message, message.source)) - pass - - - def printDatagram(self, memo): - """A call-back for when datagrams received + self._top_widgets = [] + if len(args) < 1: + raise ValueError("at least one argument (parent) is required") + self.parent = args[0] + self.root = args[0] + if hasattr(self.parent, 'root'): + self.root = self.parent.root + self._container = self # where to put visible widgets + self._treeview = None + self._gui(self._container) + + def _gui(self, container): + if self._top_widgets: + raise RuntimeError("gui can only be called once unless reset") + self._status_var = tk.StringVar(self) + self._status_label = ttk.Label(container, textvariable=self._status_var) + self.grid(sticky=tk.NSEW, row=len(self._top_widgets)) + self._top_widgets.append(self._status_label) + self._overview = ttk.Frame(container) + self.grid(sticky=tk.NSEW, row=len(self._top_widgets)) + self._top_widgets.append(self._overview) + self._treeview = ttk.Treeview(container) + self.grid(sticky=tk.NSEW, row=len(self._top_widgets)) + self.rowconfigure(len(self._top_widgets), weight=1) # weight=1 allows expansion + self._top_widgets.append(self._treeview) + self._branch = "" # top level of a Treeview is "" + self._current_iid = 0 # id of Treeview element + + def clear(self): + while self._top_widgets: + widget = self._top_widgets.pop() + widget.grid_forget() + self._gui() + self.set_status("Display reset.") + + def downloadCDI(self, farNodeID, callback=None): + self.set_status("Downloading CDI...") + super().downloadCDI(farNodeID, callback=callback) + + def set_status(self, message): + self._status_var.set(message) + + def cdi_refresh_callback(self, event_d): + """Handler for incoming CDI tag + (Use this for callback in downloadCDI, which sets parser's + _download_callback) Args: - DatagramReadMemo: The datagram object - - Returns: - bool: Always False (True would mean we sent a reply to the datagram, - but let the MemoryService do that). + event_d (dict): Document parsing state info: + - 'element' (SubElement): The element + that has been completely parsed ('' reached) + - 'error' (str): Message of failure (requires 'done' if stopped). + - 'done' (bool): If True, downloadCDI is finished. + Though document itself may be incomplete if 'error' is + also set, stop tracking status of downloadCDI + regardless. """ - # print("Datagram receive call back: {}".format(memo.data)) - return False - - - def memoryReadSuccess(self, memo): - """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. - - Args: - memo (MemoryReadMemo): Successful MemoryReadMemo - """ - # print("successful memory read: {}".format(memo.data)) - if self.realtime: - if len(memo.data) == 64 and 0 not in memo.data: - chunk = memo.data.decode("utf-8") - else: - null_i = memo.data.find(b'\0') - terminate_i = len(memo.data) - if null_i > -1: - terminate_i = min(null_i, terminate_i) - chunk = memo.data[:terminate_i].decode("utf-8") - - self.feed(chunk) - # is this done? - else: - if len(memo.data) == 64 and 0 not in memo.data: - # save content - self.resultingCDI += memo.data - # update the address - memo.address = memo.address+64 - # and read again - self.memoryService.requestMemoryRead(memo) - # The last packet is not yet reached, so don't parse (However, - # parser.feed could be called for realtime processing). - else : - # and we're done! - # save content - self.resultingCDI += memo.data - # concert resultingCDI to a string up to 1st zero - cdiString = "" - null_i = self.resultingCDI.find(b'\0') - terminate_i = len(self.resultingCDI) - if null_i > -1: - terminate_i = min(null_i, terminate_i) - cdiString = self.resultingCDI[:terminate_i].decode("utf-8") - # print (cdiString) - - # and process that - self.parse(cdiString) - self.callback_msg("Done loading CDI.") - - # done - - - def memoryReadFail(self, memo): - print("memory read failed: {}".format(memo.data)) - - - def startElement(self, name, attrs): - """See xml.sax.handler.ContentHandler documentation.""" - self._start_name = name - self._start_attrs = attrs - tab = " " * len(self._tag_stack) - print(tab, "Start: ", name) - if attrs is not None and attrs : - print(tab, " Attributes: ", attrs.getNames()) - el = Element(name, attrs) - self.callback_msg( - "loaded: {}{}".format(tab, ET.tostring(el, encoding="unicode"))) - self._tag_stack.append(el) - - def endElement(self, name): - """See xml.sax.handler.ContentHandler documentation.""" - indent = len(self._tag_stack) - if indent: - indent -= 1 # account for closing the tag - tab = " " * indent - # print(tab, name, "content:", self._flushCharBuffer()) - print(tab, "End: ", name) - if len(self._tag_stack): - print(tab+"Warning: before any start tag" - .format(name)) + done = event_d.get('done') + error = event_d.get('error') + message = event_d.get('message') + show_message = None + if error: + show_message = error + elif message: + show_message = message + elif done: + show_message = "Done loading CDI." + if show_message: + self.root.after(0, self.set_status, show_message) + if done: return - top_el = self._tag_stack[-1] - if name != top_el.tag: - print(tab+"Warning: before ".format(name, top_el.tag)) + self.root.after(0, self._add_cdi_element, event_d) + + def _add_cdi_element(self, event_d): + element = event_d.get('element') + segment = event_d.get('segment') + groups = event_d.get('groups') + tag = element.tag + if not tag: + logger.warning("Ignored blank tag for event: {}".format(event_d)) return - del self._tag_stack[-1] - - # def _flushCharBuffer(self): - # """Decode the buffer, clear it, and return all content. - # See xml.sax.handler.ContentHandler documentation. - - # Returns: - # str: The content of the bytes buffer decoded as utf-8. - # """ - # s = ''.join(self._chunks) - # self._chunks.clear() - # return s - - # def characters(self, data): - # """Received characters handler. - # See xml.sax.handler.ContentHandler documentation. - - # Args: - # data (Union[bytearray, bytes, list[int]]): any - # data (any type accepted by bytearray extend). - # """ - # if not isinstance(data, str): - # raise TypeError("Expected str, got {}".format(type(data).__name__)) - # self._chunks.append(data) \ No newline at end of file + tag = tag.lower() + # TODO: handle start tags separately (Branches are too late tobe + # created here since all children are done). + index = "end" # "end" is at end of current branch (otherwise use int) + if tag == "segment": + pass + elif tag == "group": + pass + elif tag == "acdi": + # Configuration Description Information - Standard - section 5.1 + pass + elif tag in ("int", "string", "float"): + name = "" + for child in element: + if child.tag == "name": + name = child.text + break + self._treeview.insert(self._branch, index, iid=self._current_iid, + text=name) + # values=(), image=None + self._current_iid += 1 # TODO: associate with SubElement + # and/or set values keyword argument to create association(s) + elif tag == "cdi": + pass + else: + logger.warning("Ignored {}".format(tag)) diff --git a/openlcb/cdihandler.py b/openlcb/cdihandler.py new file mode 100644 index 0000000..5a4446e --- /dev/null +++ b/openlcb/cdihandler.py @@ -0,0 +1,396 @@ + +""" +CDI Frame + +A reusable superclass for Configuration Description Information +(CDI) processing and editing. + +This file is part of the python-openlcb project +(). + +Contributors: Poikilos, Bob Jacobsen (code from example_cdi_access) +""" +import json +import platform +import subprocess +from collections import OrderedDict +import sys +import xml.sax # noqa: E402 +import xml.etree.ElementTree as ET + +from logging import getLogger + +from openlcb.canbus.tcpsocket import TcpSocket + +from openlcb.canbus.canphysicallayergridconnect import ( + CanPhysicalLayerGridConnect, +) +from openlcb.canbus.canlink import CanLink +from openlcb.nodeid import NodeID +from openlcb.datagramservice import ( + DatagramService, +) +from openlcb.memoryservice import ( + MemoryReadMemo, + MemoryService, +) + +logger = getLogger(__name__) + + +class CDIHandler(xml.sax.handler.ContentHandler): + """Manage Configuration Description Information. + - Send events to downloadCDI caller describing the state and content + of the document construction. + - Collect and traverse XML in a CDI-specific way. + + Attributes: + etree (Element): The XML root element (Does not correspond to an + XML tag but rather the document itself, and contains all + actual top-level elements as children). + _open_el (SubElement): Tracks currently-open tag (no `` + yet) during parsing, or if no tags are open then equals + etree. + _tag_stack(list[SubElement]): Tracks scope during parse since + self.etree doesn't have awareness of whether end tag is + finished (and therefore doesn't know which element is the + parent of a new startElement). + """ + def __init__(self, *args, **kwargs): + self._download_callback = None + self._connect_callback = None + # ^ In case some parsing step happens early, + # prepare these for _callback_msg. + super().__init__() # takes no arguments + + self._realtime = True + + # region ContentHandler + # self._chunks = [] + self._tag_stack = [] + # endregion ContentHandler + + # region connect + self._sock = None + self._canPhysicalLayerGridConnect = None + self._canLink = None + self._datagramService = None + self._memoryService = None + self._resultingCDI = None + # endregion connect + + def _reset_tree(self): + self.etree = ET.Element("root") + self._open_el = self.etree + + def _callback_msg(self, msg, callback=None): + if callback is None: + callback = self._download_callback + if callback is None: + callback = self._connect_callback + if callback: + print("CDIForm callback_msg({})".format(repr(msg))) + self._connect_callback({ + 'message': msg, + }) + else: + logger.warning("No callback, but set status: {}".format(msg)) + + def connect(self, host, port, localNodeID, callback=None): + self._connect_callback = callback + self._callback_msg("connecting to {}...".format(host)) + self._sock = TcpSocket() + # s.settimeout(30) + self._sock.connect(host, port) + self._callback_msg("CanPhysicalLayerGridConnect...") + self._canPhysicalLayerGridConnect = \ + CanPhysicalLayerGridConnect(self._sendToSocket) + self._canPhysicalLayerGridConnect.registerFrameReceivedListener( + self._printFrame + ) + + self._callback_msg("CanLink...") + self._canLink = CanLink(NodeID(localNodeID)) + self._callback_msg("CanLink...linkPhysicalLayer...") + self._canLink.linkPhysicalLayer(self._canPhysicalLayerGridConnect) + self._callback_msg("CanLink...linkPhysicalLayer" + "...registerMessageReceivedListener...") + self._canLink.registerMessageReceivedListener(self._printMessage) + + self._callback_msg("DatagramService...") + self._datagramService = DatagramService(self._canLink) + self._canLink.registerMessageReceivedListener( + self._datagramService.process + ) + + self._datagramService.registerDatagramReceivedListener( + self._printDatagram + ) + + self._callback_msg("MemoryService...") + self._memoryService = MemoryService(self._datagramService) + + self._callback_msg("physicalLayerUp...") + self._canPhysicalLayerGridConnect.physicalLayerUp() + + # accumulate the CDI information + self._resultingCDI = bytearray() # only used if not self.realtime + event_d = { + 'message': "Ready to receive.", + 'done': True, + } + if callback: + callback(event_d) + + return event_d # return it in case running synchronously (no thread) + + + def _memoryRead(self, farNodeID, offset): + """Create and send a read datagram. + This is a read of 20 bytes from the start of CDI space. + We will fire it on a separate thread to give time for other nodes to + reply to AME + """ + # time.sleep(1) + + # read 64 bytes from the CDI space starting at address zero + memMemo = MemoryReadMemo(NodeID(farNodeID), 64, 0xFF, offset, + self._memoryReadFail, self._memoryReadSuccess) + self._memoryService.requestMemoryRead(memMemo) + + def downloadCDI(self, farNodeID, callback=None): + if not farNodeID or not farNodeID.strip(): + raise ValueError("No farNodeID specified.") + self._farNodeID = farNodeID + self._string_terminated = False + if callback is None: + def callback(event_d): + print("downloadCDI default callback: {}".format(event_d), + file=sys.stderr) + self._download_callback = callback + if not self._sock: + raise RuntimeError("No TCPSocket. Call connect first.") + if not self._canPhysicalLayerGridConnect: + raise RuntimeError( + "No canPhysicalLayerGridConnect. Call connect first.") + self._cdi_offset = 0 + self._reset_tree() + self._memoryRead(farNodeID, self._cdi_offset) + while True: + try: + received = self._sock.receive() + # print(" RR: {}".format(received.strip())) + # pass to link processor + self._canPhysicalLayerGridConnect.receiveString(received) + # ^ will trigger self._printFrame since that was added + # via registerFrameReceivedListener during connect. + except RuntimeError as ex: + # May be raised by canbus.tcpsocket.TCPSocket.receive + # manually. Usually "socket connection broken" due to + # no more bytes to read, but ok if "\0" terminator + # was reached. + if not self._string_terminated: + # This boolean is managed by the memoryReadSuccess + # callback. + callback({ # same as self._download_callback here + 'error': "{}: {}".format(type(ex).__name__, ex), + 'done': True, # stop progress in gui/other main thread + }) + raise # re-raise since incomplete (prevent done OK state) + break + # If we got here, the RuntimeError was ok since the + # null terminator '\0' was reached (otherwise re-raise occurs above) + event_d = { + 'message': "Done reading CDI.", + # 'done': True, # NOTE: not really done until endElement("cdi") + } + return event_d # return it in case running synchronously (no thread) + + def _sendToSocket(self, string): + # print(" SR: {}".format(string.strip())) + self._sock.send(string) + + def _printFrame(self, frame): + # print(" RL: {}".format(frame)) + pass + + def _printMessage(self, message): + # print("RM: {} from {}".format(message, message.source)) + pass + + def _printDatagram(self, memo): + """A call-back for when datagrams received + + Args: + DatagramReadMemo: The datagram object + + Returns: + bool: Always False (True would mean we sent a reply to the datagram, + but let the MemoryService do that). + """ + # print("Datagram receive call back: {}".format(memo.data)) + return False + + def _memoryReadSuccess(self, memo): + """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. + + Args: + memo (MemoryReadMemo): Successful MemoryReadMemo + """ + # print("successful memory read: {}".format(memo.data)) + if len(memo.data) == 64 and 0 not in memo.data: # *not* last chunk + self._string_terminated = False + chunk_str = memo.data.decode("utf-8") + # save content + self._resultingCDI += memo.data + # update the address + memo.address = memo.address + 64 + # and read again (read next) + self._memoryService.requestMemoryRead(memo) + # The last packet is not yet reached, so don't parse (but + # feed if self._realtime) + else: # last chunk + self._string_terminated = True + # and we're done! + # save content + self._resultingCDI += memo.data + # concert resultingCDI to a string up to 1st zero + # and process that + if self._realtime: + # If _realtime, last chunk is treated same as another + # (since _realtime uses feed) except stop at '\0'. + null_i = memo.data.find(b'\0') + terminate_i = len(memo.data) + if null_i > -1: + terminate_i = min(null_i, terminate_i) + chunk_str = memo.data[:terminate_i].decode("utf-8") + else: + # *not* realtime (but got to end, so parse all at once) + cdiString = "" + null_i = self._resultingCDI.find(b'\0') + terminate_i = len(self._resultingCDI) + if null_i > -1: + terminate_i = min(null_i, terminate_i) + cdiString = self._resultingCDI[:terminate_i].decode("utf-8") + # print (cdiString) + self.parse(cdiString) + # ^ startElement, endElement, etc. all consecutive using parse + self._callback_msg("Done loading CDI.") + # done reading + if self._realtime: + self.feed(chunk_str) # startElement, endElement etc. are automatic + + def _memoryReadFail(self, memo): + error = "memory read failed: {}".format(memo.data) + if self._download_callback: + self._download_callback({ + 'error': error, + 'done': True, # stop progress in gui/other main thread + }) + else: + logger.error(error) + + def startElement(self, name, attrs): + """See xml.sax.handler.ContentHandler documentation.""" + tab = " " * len(self._tag_stack) + print(tab, "Start: ", name) + if attrs is not None and attrs : + print(tab, " Attributes: ", attrs.getNames()) + # el = ET.Element(name, attrs) + el = ET.SubElement(self._open_el, "element1") + # if self._tag_stack: + # parent = self._tag_stack[-1] + self._callback_msg( + "loaded: {}{}".format(tab, ET.tostring(el, encoding="unicode"))) + self._tag_stack.append(el) + self._open_el = el + + def checkDone(self, event_d): + """Notify the caller if parsing is over. + Calls _download_callback with `'done': True` in the argument if + 'name' is "cdi" (case-insensitive). That notifies the + downloadCDI caller that parsing is over, so that caller should + end progress bar/other status tracking for downloadCDI in that + case. + + Returns: + dict: Reserved for use without events (doesn't need to be + processed if self._download_callback is set since that + also gets the dict if 'done'). 'done' is only True if + 'name' is "cdi" (case-insensitive). + """ + event_d['done'] = False + name = event_d.get('name') + if not name or name.lower() != "cdi": + # Not , so not done yet + return event_d + event_d['done'] = True # since "cdi" if avoided conditional return + if self._download_callback: + self._download_callback(event_d) + return event_d + + def endElement(self, name): + """See xml.sax.handler.ContentHandler documentation.""" + indent = len(self._tag_stack) + top_el = self._tag_stack[-1] + if name != top_el.tag: + print(tab+"Warning: before ".format(name, top_el.tag)) + elif indent: # top element found and indent not 0 + indent -= 1 # dedent since scope ended + tab = " " * indent + # print(tab, name, "content:", self._flushCharBuffer()) + print(tab, "End: ", name) + event_d = {'name': name} + if not self._tag_stack: + event_d['error'] = " before any start tag".format(name) + print(tab+"Warning: {}".format(event_d['error'])) + self.checkDone(event_d) + return + if name != top_el.tag: + event_d['error'] = ( + " before top tag <{} ...> closed" + .format(name, top_el.tag)) + print(tab+"Warning: {}".format(event_d['error'])) + self.checkDone(event_d) + return + del self._tag_stack[-1] + if self._tag_stack: + self._open_el = self._tag_stack[-1] + else: + self._open_el = self.etree + if self._tag_stack: + event_d['parent'] = self._tag_stack[-1] + event_d['element'] = top_el + result = self.checkDone(event_d) + if not result.get('done'): + # Notify downloadCDI's caller since it can potentially add + # UI widget(s) for at least one setting/segment/group + # using this 'element'. + self._download_callback(event_d) + + # def _flushCharBuffer(self): + # """Decode the buffer, clear it, and return all content. + # See xml.sax.handler.ContentHandler documentation. + + # Returns: + # str: The content of the bytes buffer decoded as utf-8. + # """ + # s = ''.join(self._chunks) + # self._chunks.clear() + # return s + + # def characters(self, data): + # """Received characters handler. + # See xml.sax.handler.ContentHandler documentation. + + # Args: + # data (Union[bytearray, bytes, list[int]]): any + # data (any type accepted by bytearray extend). + # """ + # if not isinstance(data, str): + # raise TypeError( + # "Expected str, got {}".format(type(data).__name__)) + # self._chunks.append(data) \ No newline at end of file From 6673e30d4bfb4a3f7575465c25d8d311f5e6c364 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+poikilos@users.noreply.github.com> Date: Wed, 26 Feb 2025 17:09:16 -0500 Subject: [PATCH 12/99] Show the exception and re-add sleep (from code in cdihandler.py based on example_cdi_access.py) to diagnose #62. --- openlcb/canbus/canlink.py | 6 +++--- openlcb/cdihandler.py | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 32012d0..c945c52 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -403,10 +403,10 @@ def sendMessage(self, msg): header |= ((dddAlias) & 0xFFF) << 12 except KeyboardInterrupt: raise - except: + except Exception as ex: logging.warning( - "Did not know destination = {} on datagram send" - "".format(msg.source) + "Did not know destination = {} on datagram send ({}: {})" + "".format(msg.destination, type(ex).__name__, ex) ) if len(msg.data) <= 8: diff --git a/openlcb/cdihandler.py b/openlcb/cdihandler.py index 5a4446e..77a639d 100644 --- a/openlcb/cdihandler.py +++ b/openlcb/cdihandler.py @@ -13,11 +13,12 @@ import json import platform import subprocess -from collections import OrderedDict +import time import sys import xml.sax # noqa: E402 import xml.etree.ElementTree as ET +from collections import OrderedDict from logging import getLogger from openlcb.canbus.tcpsocket import TcpSocket @@ -151,7 +152,7 @@ def _memoryRead(self, farNodeID, offset): We will fire it on a separate thread to give time for other nodes to reply to AME """ - # time.sleep(1) + time.sleep(1) # read 64 bytes from the CDI space starting at address zero memMemo = MemoryReadMemo(NodeID(farNodeID), 64, 0xFF, offset, From 35e6a27f66748d39a4154123ee4ce41a891769ba Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 27 Feb 2025 08:39:27 -0500 Subject: [PATCH 13/99] Rename s to sock for clarity. --- examples/example_datagram_transfer.py | 8 ++++---- examples/example_frame_interface.py | 8 ++++---- examples/example_memory_length_query.py | 8 ++++---- examples/example_memory_transfer.py | 8 ++++---- examples/example_message_interface.py | 8 ++++---- examples/example_node_implementation.py | 8 ++++---- examples/example_remote_nodes.py | 8 ++++---- examples/example_string_interface.py | 8 ++++---- examples/example_string_serial_interface.py | 8 ++++---- examples/example_tcp_message_interface.py | 8 ++++---- 10 files changed, 40 insertions(+), 40 deletions(-) diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index c026b77..7cc45af 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -38,9 +38,9 @@ localNodeID = "05.01.01.01.03.01" farNodeID = "09.00.99.03.00.35" -s = TcpSocket() +sock = TcpSocket() # s.settimeout(30) -s.connect(settings['host'], settings['port']) +sock.connect(settings['host'], settings['port']) print("RR, SR are raw socket interface receive and send;" " RL, SL are link interface; RM, SM are message interface") @@ -48,7 +48,7 @@ def sendToSocket(string): print(" SR: "+string.strip()) - s.send(string) + sock.send(string) def printFrame(frame): @@ -121,7 +121,7 @@ def datagramWrite(): # process resulting activity while True: - received = s.receive() + received = sock.receive() print(" RR: {}".format(received.strip())) # pass to link processor canPhysicalLayerGridConnect.receiveString(received) diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index d18c09a..9aaac95 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -31,9 +31,9 @@ # port = 12021 # endregion replaced by settings -s = TcpSocket() +sock = TcpSocket() # s.settimeout(30) -s.connect(settings['host'], settings['port']) +sock.connect(settings['host'], settings['port']) print("RR, SR are raw socket interface receive and send;" " RL, SL are link (frame) interface") @@ -41,7 +41,7 @@ def sendToSocket(string): print(" SR: {}".format(string.strip())) - s.send(string) + sock.send(string) def printFrame(frame): @@ -58,7 +58,7 @@ def printFrame(frame): # display response - should be RID from nodes while True: - received = s.receive() + received = sock.receive() print(" RR: {}".format(received.strip())) # pass to link processor canPhysicalLayerGridConnect.receiveString(received) diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index aa548ea..0764119 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -43,9 +43,9 @@ farNodeID = "09.00.99.03.00.35" # endregion replaced by settings -s = TcpSocket() +sock = TcpSocket() # s.settimeout(30) -s.connect(settings['host'], settings['port']) +sock.connect(settings['host'], settings['port']) print("RR, SR are raw socket interface receive and send;" " RL, SL are link interface; RM, SM are message interface") @@ -53,7 +53,7 @@ def sendToSocket(string): print(" SR: {}".format(string.strip())) - s.send(string) + sock.send(string) def printFrame(frame): @@ -140,7 +140,7 @@ def memoryRequest(): # process resulting activity while True: - received = s.receive() + received = sock.receive() print(" RR: {}".format(received.strip())) # pass to link processor canPhysicalLayerGridConnect.receiveString(received) diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index e18e08b..1d84f69 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -43,9 +43,9 @@ # farNodeID = "09.00.99.03.00.35" # endregion replaced by settings -s = TcpSocket() +sock = TcpSocket() # s.settimeout(30) -s.connect(settings['host'], settings['port']) +sock.connect(settings['host'], settings['port']) print("RR, SR are raw socket interface receive and send;" " RL, SL are link interface; RM, SM are message interface") @@ -53,7 +53,7 @@ def sendToSocket(string): print(" SR: {}".format(string.strip())) - s.send(string) + sock.send(string) def printFrame(frame): @@ -138,7 +138,7 @@ def memoryRead(): # process resulting activity while True: - received = s.receive() + received = sock.receive() print(" RR: {}".format(received.strip())) # pass to link processor canPhysicalLayerGridConnect.receiveString(received) diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index af0aabe..35fa8ce 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -36,9 +36,9 @@ # localNodeID = "05.01.01.01.03.01" # endregion replaced by settings -s = TcpSocket() +sock = TcpSocket() # s.settimeout(30) -s.connect(settings['host'], settings['port']) +sock.connect(settings['host'], settings['port']) print("RR, SR are raw socket interface receive and send; RL," " SL are link interface; RM, SM are message interface") @@ -46,7 +46,7 @@ def sendToSocket(string): print(" SR: {}".format(string.strip())) - s.send(string) + sock.send(string) def printFrame(frame): @@ -79,7 +79,7 @@ def printMessage(msg): # process resulting activity while True: - received = s.receive() + received = sock.receive() print(" RR: {}".format(received.strip())) # pass to link processor canPhysicalLayerGridConnect.receiveString(received) diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index 742dadc..185fc99 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -44,10 +44,10 @@ # farNodeID = "09.00.99.03.00.35" # endregion moved to settings -s = TcpSocket() +sock = TcpSocket() # s.settimeout(30) try: - s.connect(settings['host'], settings['port']) + sock.connect(settings['host'], settings['port']) except socket.gaierror: print("Failure accessing {}:{}" .format(settings.get('host'), settings.get('port'))) @@ -59,7 +59,7 @@ def sendToSocket(string): print(" SR: {}".format(string.strip())) - s.send(string) + sock.send(string) def printFrame(frame): @@ -153,7 +153,7 @@ def displayOtherNodeIds(message) : # process resulting activity while True: - input = s.receive() + input = sock.receive() print(" RR: "+input.strip()) # pass to link processor canPhysicalLayerGridConnect.receiveString(input) diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index a1d2efc..3c8ee1f 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -48,9 +48,9 @@ # timeout = 0.5 # endregion replaced by settings -s = TcpSocket() +sock = TcpSocket() # s.settimeout(30) -s.connect(settings['host'], settings['port']) +sock.connect(settings['host'], settings['port']) if settings['trace'] : print("RR, SR are raw socket interface receive and send;" @@ -59,7 +59,7 @@ def sendToSocket(string) : if settings['trace'] : print(" SR: "+string.strip()) - s.send(string) + sock.send(string) def receiveFrame(frame) : @@ -110,7 +110,7 @@ def receiveLoop() : if settings['trace'] : print(" SL : link up") canPhysicalLayerGridConnect.physicalLayerUp() while True: - input = s.receive() + input = sock.receive() if settings['trace'] : print(" RR: "+input.strip()) # pass to link processor canPhysicalLayerGridConnect.receiveString(input) diff --git a/examples/example_string_interface.py b/examples/example_string_interface.py index e1f7668..30a8d8e 100644 --- a/examples/example_string_interface.py +++ b/examples/example_string_interface.py @@ -26,18 +26,18 @@ # port = 12021 # endregion replaced by settings -s = TcpSocket() +sock = TcpSocket() # s.settimeout(30) -s.connect(settings['host'], settings['port']) +sock.connect(settings['host'], settings['port']) ####################### # send a AME frame in GridConnect string format with arbitrary source alias to # elicit response AME = ":X10702001N;" -s.send(AME) +sock.send(AME) print("SR: {}".format(AME.strip())) # display response - should be RID from node(s) while True: # have to kill this manually - print("RR: {}".format(s.receive().strip())) + print("RR: {}".format(sock.receive().strip())) diff --git a/examples/example_string_serial_interface.py b/examples/example_string_serial_interface.py index f50a4f1..b5b1d38 100644 --- a/examples/example_string_serial_interface.py +++ b/examples/example_string_serial_interface.py @@ -26,17 +26,17 @@ # endregion replaced by settings -s = SerialLink() -s.connect(settings['device']) +sock = SerialLink() +sock.connect(settings['device']) ####################### # send a AME frame in GridConnect string format with arbitrary source alias to # elicit response AME = ":X10702001N;" -s.send(AME) +sock.send(AME) print("SR: {}".format(AME.strip())) # display response - should be RID from node(s) while True: # have to kill this manually - print("RR: {}".format(s.receive().strip())) + print("RR: {}".format(sock.receive().strip())) diff --git a/examples/example_tcp_message_interface.py b/examples/example_tcp_message_interface.py index 8dd9905..660ee9a 100644 --- a/examples/example_tcp_message_interface.py +++ b/examples/example_tcp_message_interface.py @@ -33,11 +33,11 @@ # localNodeID = "05.01.01.01.03.01" # endregion moved to settings -s = TcpSocket() +sock = TcpSocket() # s.settimeout(30) print("Using settings:") print(settings.dumps()) -s.connect(settings['host'], settings['port']) +sock.connect(settings['host'], settings['port']) print("RR, SR are raw socket interface receive and send; " " RM, SM are message interface") @@ -50,7 +50,7 @@ def sendToSocket(data): # .format(type(data).__name__, data) # ) print(" SR: {}".format(data)) - s.send(data) + sock.send(data) def printMessage(msg): @@ -75,7 +75,7 @@ def printMessage(msg): # process resulting activity while True: - received = s.receive() + received = sock.receive() print(" RR: {}".format(received)) # pass to link processor tcpLinkLayer.receiveListener(received) From fa8a2c053c9740db60c7e7b3860a953cd8996d90 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 27 Feb 2025 08:54:00 -0500 Subject: [PATCH 14/99] Fix import order to take advantage of sys.path tweak (run examples from any folder). --- examples/examples_gui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index d91a10f..77d2366 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -30,11 +30,11 @@ from tkinter import ttk from collections import OrderedDict -from examples.tkexamples.cdiform import CDIForm - from examples_settings import Settings # ^ adds parent of module to sys.path, so openlcb imports *after* this +from examples.tkexamples.cdiform import CDIForm + from openlcb import emit_cast from openlcb.tcplink.mdnsconventions import id_from_tcp_service_name From b9b45136db9866ca3623879ff765ef6ab869518e Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 27 Feb 2025 09:00:40 -0500 Subject: [PATCH 15/99] Remove lint. --- examples/examples_gui.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index 77d2366..8e1f16f 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -516,7 +516,8 @@ def cdi_connect_clicked(self): ).start() self.cdi_connect_button.configure(state=tk.DISABLED) self.cdi_refresh_button.configure(state=tk.DISABLED) - # daemon=True ensures the thread does not block program exit if the user closes the application. + # daemon=True ensures the thread does not block program exit if + # the user closes the application. def cdi_refresh_clicked(self): self.cdi_connect_button.configure(state=tk.DISABLED) @@ -546,7 +547,8 @@ def set_id_from_name(self): 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()) + 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) From 19ec5580622020044bceea97576b9ad507951239 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 27 Feb 2025 09:00:51 -0500 Subject: [PATCH 16/99] Update cSpell dict. --- python-openlcb.code-workspace | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/python-openlcb.code-workspace b/python-openlcb.code-workspace index 151890e..b11eb3b 100644 --- a/python-openlcb.code-workspace +++ b/python-openlcb.code-workspace @@ -31,6 +31,7 @@ "canlink", "canphysicallayer", "canphysicallayersimulation", + "cdiform", "columnspan", "controlframe", "datagram", @@ -46,11 +47,14 @@ "localeventstore", "localoverrides", "MDNS", + "mdnsconventions", "memoryservice", "metas", "MSGLEN", "nodeid", "nodestore", + "offvalue", + "onvalue", "openlcb", "padx", "pady", @@ -61,9 +65,12 @@ "servicetype", "settingtypes", "setuptools", + "tcplink", "textvariable", + "tkexamples", "unformatting", "usbmodem", + "winnative", "zeroconf" ] } From c9035ec86465e69df5ee6ac6c3cf272772533248 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 27 Feb 2025 09:04:22 -0500 Subject: [PATCH 17/99] Improve Window position. --- examples/examples_gui.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index 8e1f16f..c193ece 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -775,10 +775,14 @@ def main(): screen_w = root.winfo_screenwidth() screen_h = root.winfo_screenheight() window_w = round(screen_w / 2) - window_h = round(screen_h * .9) - root.geometry("{}x{}".format( + window_h = round(screen_h * .8) + x = (screen_w - window_w) // 2 + y = (screen_h - window_h) // 12 + root.geometry("{}x{}+{}+{}".format( window_w, window_h, + x, + y, )) # WxH+X+Y format root.minsize = (window_w, window_h) main_form = MainForm(root) From 5318b21724b3ded18f16725af2a3f8d5d3f9f7ad Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 4 Mar 2025 18:11:40 -0500 Subject: [PATCH 18/99] Add grep-able "setting nodeIdToAlias" messages (only shown if logging level manually set to logging.INFO or more verbose). Switch create a logger (instead of using logging directly) to make the source of messages clear, that being canlink.py in this case. Add & improve docstrings. --- openlcb/canbus/canlink.py | 158 ++++++++++++++++++++++++++++---------- 1 file changed, 118 insertions(+), 40 deletions(-) diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index c945c52..cf7219d 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -20,7 +20,7 @@ from enum import Enum -import logging +from logging import getLogger from openlcb.canbus.canframe import CanFrame from openlcb.canbus.controlframe import ControlFrame @@ -30,6 +30,8 @@ from openlcb.mti import MTI from openlcb.nodeid import NodeID +logger = getLogger(__name__) + class CanLink(LinkLayer): @@ -45,7 +47,18 @@ def __init__(self, localNodeID): # a NodeID self.nextInternallyAssignedNodeID = 1 LinkLayer.__init__(self, localNodeID) - def linkPhysicalLayer(self, cpl): # CanPhysicalLayer + def linkPhysicalLayer(self, cpl): + """Set the physical layer to use. + Also registers self.receiveListener as a listener on the given + physical layer. Before using sendMessage, wait for the + connection phase to finish, as the phase receives aliases + (populating nodeIdToAlias) and reserves a unique alias as per + section 6.2.1 of CAN Frame Transfer Standard: + https://openlcb.org/wp-content/uploads/2021/08/S-9.7.2.1-CanFrameTransfer-2021-04-25.pdf + + Args: + cpl (CanPhysicalLayer): The physical layer to use. + """ self.link = cpl cpl.registerFrameReceivedListener(self.receiveListener) @@ -56,6 +69,9 @@ class State(Enum): def receiveListener(self, frame): """Call the correct handler if any for a received frame. + Typically this is called by CanPhysicalLayer since the + linkPhysicalLayer method in this class registers this method as + a listener in the given CanPhysicalLayer instance. Args: frame (CanFrame): Any CanFrame, OpenLCB/LCC or not (if @@ -68,11 +84,14 @@ def receiveListener(self, frame): self.handleReceivedLinkRestarted(frame) elif control_frame in (ControlFrame.LinkCollision, # noqa: E501 ControlFrame.LinkError): - logging.warning("Unexpected error report {:08X}" - "".format(frame.header)) + logger.warning( + "Unexpected error report {:08X}" + .format(frame.header)) elif control_frame == ControlFrame.LinkDown: self.handleReceivedLinkDown(frame) elif control_frame == ControlFrame.CID: + # NOTE: We may process other bits of frame.header + # that were stripped from control_frame self.handleReceivedCID(frame) elif control_frame == ControlFrame.RID: self.handleReceivedRID(frame) @@ -88,18 +107,22 @@ def receiveListener(self, frame): ControlFrame.EIR3): pass # ignored upon receipt elif control_frame == ControlFrame.Data: + # NOTE: We may process other bits of frame.header + # that were stripped from control_frame self.handleReceivedData(frame) elif (control_frame == ControlFrame.UnknownFormat): - logging.warning("Unexpected CAN header 0x{:08X}" - "".format(frame.header)) + logger.warning( + "Unexpected CAN header 0x{:08X}" + .format(frame.header)) else: # This should never happen due to how # decodeControlFrameFormat works, but this is a "not # implemented" output for ensuring completeness (If this # case occurs, some code is missing above). - logging.warning("Invalid control frame format 0x{:08X}" - "".format(control_frame)) + logger.warning( + "Invalid control frame format 0x{:08X}" + .format(control_frame)) def handleReceivedLinkUp(self, frame): """Link started, update state, start process to create alias. @@ -137,6 +160,10 @@ def defineAndReserveAlias(self): self.state = CanLink.State.Permitted # add to map self.aliasToNodeID[self.localAlias] = self.localNodeID + logger.info( + "defineAndReserveAlias setting nodeIdToAlias[{}]" + " from a datagram from an unknown source" + .format(self.localNodeID)) self.nodeIdToAlias[self.localNodeID] = self.localAlias # send AME with no NodeID to get full alias map self.link.sendCanFrame(CanFrame(ControlFrame.AME.value, @@ -162,8 +189,12 @@ def handleReceivedLinkDown(self, frame): # notify upper levels self.linkStateChange(self.state) - # invoked when the link layer comes up and down - def linkStateChange(self, state): # state is of the State enum + def linkStateChange(self, state): + """invoked when the link layer comes up and down + + Args: + state (CanLink.State): See CanLink. + """ if state == CanLink.State.Permitted: msg = Message(MTI.Link_Layer_Up, NodeID(0), None, bytearray()) else: @@ -171,6 +202,10 @@ def linkStateChange(self, state): # state is of the State enum self.fireListeners(msg) def handleReceivedCID(self, frame): # CanFrame + """Handle a Check ID (CID) frame only if addressed to us + (used to verify node uniqueness). Additional arguments may be + encoded in lower bits of frame.header (below ControlFrame.CID). + """ # Does this carry our alias? if (frame.header & 0xFFF) != self.localAlias: return # no match @@ -179,10 +214,15 @@ def handleReceivedCID(self, frame): # CanFrame self.localAlias)) def handleReceivedRID(self, frame): # CanFrame + """Handle a Reserve ID (RID) frame + (used for alias reservation).""" if self.checkAndHandleAliasCollision(frame): return def handleReceivedAMD(self, frame): # CanFrame + """Handle an Alias Map Definition (AMD) frame + (Defines a mapping between an alias and a full Node ID). + """ if self.checkAndHandleAliasCollision(frame): return # check for matching node ID, which is a collision @@ -195,9 +235,15 @@ def handleReceivedAMD(self, frame): # CanFrame # This defines an alias, so store it alias = frame.header & 0xFFF self.aliasToNodeID[alias] = nodeID + logger.info( + "handleReceivedAMD setting nodeIdToAlias[{}]" + .format(nodeID)) self.nodeIdToAlias[nodeID] = alias def handleReceivedAME(self, frame): # CanFrame + """Handle an Alias Mapping Enquiry (AME) frame + (a node requested alias information from other nodes). + """ if self.checkAndHandleAliasCollision(frame): return if self.state != CanLink.State.Permitted: @@ -214,6 +260,9 @@ def handleReceivedAME(self, frame): # CanFrame self.link.sendCanFrame(returnFrame) def handleReceivedAMR(self, frame): # CanFrame + """Handle an Alias Map Reset (AMR) frame + (A node is asking to remove an alias from mappings). + """ if (self.checkAndHandleAliasCollision(frame)): return # Alias Map Reset - drop from maps @@ -228,6 +277,10 @@ def handleReceivedAMR(self, frame): # CanFrame pass def handleReceivedData(self, frame): # CanFrame + """Handle a data frame. + Additional arguments may be encoded in lower bits (below + ControlFrame.Data) in frame.header. + """ if self.checkAndHandleAliasCollision(frame): return # get proper MTI @@ -243,18 +296,24 @@ def handleReceivedData(self, frame): # CanFrame # VerifiedNodeID but not AMD if mti == MTI.Verified_NodeID: sourceID = NodeID(frame.data) - logging.info("Verified_NodeID from unknown source alias: {}," - " continue with observed ID {}" - "".format(frame, sourceID)) + logger.info( + "Verified_NodeID from unknown source alias: {}," + " continue with observed ID {}" + .format(frame, sourceID)) else: sourceID = NodeID(self.nextInternallyAssignedNodeID) self.nextInternallyAssignedNodeID += 1 - logging.warning("message from unknown source alias: {}," - " continue with created ID {}" - "".format(frame, sourceID)) + logger.warning( + "message from unknown source alias: {}," + " continue with created ID {}" + .format(frame, sourceID)) # register that internally-generated nodeID-alias association self.aliasToNodeID[frame.header & 0xFFF] = sourceID + logger.info( + "handleReceivedData setting nodeIdToAlias[{}]" + " from a datagram from an unknown source" + .format(sourceID)) self.nodeIdToAlias[sourceID] = frame.header & 0xFFF destID = NodeID(0) @@ -273,12 +332,17 @@ def handleReceivedData(self, frame): # CanFrame destID = self.aliasToNodeID[destAlias] else: destID = NodeID(self.nextInternallyAssignedNodeID) - logging.warning("message from unknown dest alias: {}," - " continue with {}" - .format(str(frame), str(destID))) + logger.warning( + "message from unknown dest alias: {}," + " continue with {}" + .format(str(frame), str(destID))) # register that internally-generated nodeID-alias # association self.aliasToNodeID[destAlias] = destID + logger.info( + "handleReceivedData setting nodeIdToAlias[{}]" + " from a datagram from an unknown node" + .format(destID)) self.nodeIdToAlias[destID] = destAlias # check for start and end bits @@ -291,7 +355,7 @@ def handleReceivedData(self, frame): # CanFrame # check for never properly started, this is an error if key not in self.accumulator: # have not-start frame, but never started - logging.warning( + logger.warning( "Dropping non-start datagram frame" " without accumulation started:" " {}".format(frame) @@ -327,12 +391,17 @@ def handleReceivedData(self, frame): # CanFrame raise except: destID = NodeID(self.nextInternallyAssignedNodeID) - logging.warning("message from unknown dest alias:" - " 0x{:04X}, continue with 0x{}" - "".format(destAlias, destID)) + logger.warning( + "message from unknown dest alias:" + " 0x{:04X}, continue with 0x{}" + .format(destAlias, destID)) # register that internally-generated nodeID-alias # association self.aliasToNodeID[destAlias] = destID + logger.info( + "handleReceivedData setting nodeIdToAlias[{}]" + " to destID due to message from unknown dest." + .format(destID)) self.nodeIdToAlias[destID] = destAlias # check for start and end bits @@ -345,9 +414,10 @@ def handleReceivedData(self, frame): # CanFrame # check for first bit set never seen if key not in self.accumulator: # have not-start frame, but never started - logging.warning("Dropping non-start frame without" - " accumulation started: {}" - "".format(frame)) + logger.warning( + "Dropping non-start frame without" + " accumulation started: {}" + .format(frame)) return # early return to stop processing of this gram # add this data @@ -393,7 +463,7 @@ def sendMessage(self, msg): except KeyboardInterrupt: raise except: - logging.warning( + logger.warning( "Did not know source = {} on datagram send" "".format(msg.source) ) @@ -404,7 +474,7 @@ def sendMessage(self, msg): except KeyboardInterrupt: raise except Exception as ex: - logging.warning( + logger.warning( "Did not know destination = {} on datagram send ({}: {})" "".format(msg.destination, type(ex).__name__, ex) ) @@ -443,8 +513,9 @@ def sendMessage(self, msg): if alias is not None: # might not know it if error header |= (alias & 0xFFF) else: - logging.warning("Did not know source = {} on message send" - "".format(msg.source)) + logger.warning( + "Did not know source = {} on message send" + .format(msg.source)) # Is a destination address needed? Could be long message if msg.isAddressed(): @@ -461,8 +532,9 @@ def sendMessage(self, msg): frame = CanFrame(header, content) self.link.sendCanFrame(frame) else: - logging.warning("Don't know alias for destination = {}" - "".format(msg.destination or NodeID(0))) + logger.warning( + "Don't know alias for destination = {}" + .format(msg.destination or NodeID(0))) else: # global still can hold data; assume length is correct by # protocol send the resulting frame @@ -550,8 +622,9 @@ def checkAndHandleAliasCollision(self, frame): def processCollision(self, frame) : ''' Collision! ''' - logging.warning("alias collision in {}, we restart with AMR" - " and attempt to get new alias".format(frame)) + logger.warning( + "alias collision in {}, we restart with AMR" + " and attempt to get new alias".format(frame)) self.link.sendCanFrame(CanFrame(ControlFrame.AMR.value, self.localAlias, self.localNodeID.toArray())) @@ -602,8 +675,10 @@ def createAlias12(self, rnd): def decodeControlFrameFormat(self, frame): if (frame.header & 0x0800_0000) == 0x0800_0000: # data case; not checking leading 1 bit + # NOTE: handleReceivedData can get all header bits via frame return ControlFrame.Data if (frame.header & 0x4_000_000) != 0: # CID case + # NOTE: handleReceivedCID can get all header bits via frame return ControlFrame.CID try: @@ -612,8 +687,9 @@ def decodeControlFrameFormat(self, frame): except KeyboardInterrupt: raise except: - logging.warning("Could not decode header 0x{:08X}" - "".format(frame.header)) + logger.warning( + "Could not decode header 0x{:08X}" + .format(frame.header)) return ControlFrame.UnknownFormat def canHeaderToFullFormat(self, frame): @@ -625,8 +701,9 @@ def canHeaderToFullFormat(self, frame): try : okMTI = MTI(canMTI) except ValueError: - logging.warning("unhandled canMTI: {}, marked Unknown" - "".format(frame)) + logger.warning( + "unhandled canMTI: {}, marked Unknown" + .format(frame)) return MTI.Unknown return okMTI @@ -635,8 +712,9 @@ def canHeaderToFullFormat(self, frame): return MTI.Datagram # not handling reserver and stream type except to log - logging.warning("unhandled canMTI: {}, marked Unknown" - "".format(frame)) + logger.warning( + "unhandled canMTI: {}, marked Unknown" + .format(frame)) return MTI.Unknown class AccumKey: From e3e5fc95e6985b35cba709adf3e447532da776de Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 4 Mar 2025 18:12:27 -0500 Subject: [PATCH 19/99] Clarify a warning. Save settings on "Connect" click. --- examples/examples_gui.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index c193ece..6b27da2 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -488,13 +488,11 @@ def _callback(self, event_d): done = event_d.get('done') if done: self.cdi_refresh_button.configure(state=tk.NORMAL) + custom_message = 'Ready to load CDI (click "Refresh").' if message: - logger.warning( - "Done, but skipped message: {}".format(repr(message))) - # if not message: - message = 'Done. Ready to load CDI (click "Refresh")' - print(message) - self.set_status(message) + custom_message += " " + message + print(custom_message) + self.set_status(custom_message) def cdi_connect_clicked(self): host_var = self.fields.get('host') @@ -508,6 +506,7 @@ def cdi_connect_clicked(self): localNodeID_var = self.fields.get('localNodeID') localNodeID = localNodeID_var.get() # self.cdi_form.connect(host, port, localNodeID) + self.save_settings() threading.Thread( target=self.cdi_form.connect, args=(host, port, localNodeID), From 76c3cc2ccb2ccf98863c5b6d51fd956c0a40868e Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 4 Mar 2025 18:13:38 -0500 Subject: [PATCH 20/99] Improve the docstring for the ControlFrame enum. --- openlcb/canbus/controlframe.py | 51 ++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/openlcb/canbus/controlframe.py b/openlcb/canbus/controlframe.py index 846a3e0..e3bd4bf 100644 --- a/openlcb/canbus/controlframe.py +++ b/openlcb/canbus/controlframe.py @@ -7,7 +7,54 @@ class ControlFrame(Enum): - '''link-layer control frame''' + """Link-layer control frame values for OpenLCB/LCC. + + These values represent control frames used in the Layout Command + Control (LCC) and OpenLCB link layer. They define specific types of + link-layer interactions, including address management, error + indications, and internal signaling. + + Attributes: + RID (int): Reserve ID (RID) frame, used for alias reservation. + AMD (int): Alias Map Definition (AMD) frame, used to define a + mapping between an alias and a full Node ID. + AME (int): Alias Mapping Enquiry (AME) frame, used to request + alias information from other nodes. + AMR (int): Alias Map Reset (AMR) frame, used to reset alias + mappings. + EIR0 (int): Error Information Report 0, emitted when a node + transitions from the "Error Active" state to the "Error + Passive" state. + EIR1 (int): Error Information Report 1, emitted when a node + transitions from the "Bus Off" state to the "Error Passive" + state. + EIR2 (int): Error Information Report 2, emitted when a node + transitions from the "Error Passive" state to the "Error + Active" state. + EIR3 (int): Error Information Report 3, emitted when a node + transitions from the "Bus Off" state to the "Error Active" + state. + + CID (int): Check ID (CID) frame, used to verify Node ID + uniqueness. Only the upper bits are specified; additional + arguments are encoded in the lower bits. + See CAN Frame Transfer - Standard for details. + Data (int): Data frame. Only the upper bits are specified; + additional arguments are encoded in the lower bits. + + LinkUp (int): Internal signal indicating that the link has been + established. Non-OpenLCB value. + LinkRestarted (int): Internal signal indicating that the link has been + restarted. Non-OpenLCB value. + LinkCollision (int): Internal signal indicating that a link collision + has been detected. Non-OpenLCB value. + LinkError (int): Internal signal indicating that a link error has + occurred. Non-OpenLCB value. + LinkDown (int): Internal signal indicating that the link has gone down. + Non-OpenLCB value. + UnknownFormat (int): Internal signal indicating that an unknown frame + format has been received. Non-OpenLCB value. + """ RID = 0x0700 AMD = 0x0701 AME = 0x0702 @@ -22,7 +69,7 @@ class ControlFrame(Enum): CID = 0x4000 Data = 0x18000 - # these are non-OLCB values used for internal signaling + # these are non-openlcb values used for internal signaling # their values have a bit set above what can come from a CAN Frame LinkUp = 0x20000 LinkRestarted = 0x20001 From 33e7973e85114371a517518015b4a3883ce707e1 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 4 Mar 2025 18:17:06 -0500 Subject: [PATCH 21/99] Wait 200 ms as per Standard before allowing message sending (Wait for nodeIdToAlias to populate: Fix #62). Stream XML continuously (use parser.feed) so CDI branches trigger callbacks as they download. --- openlcb/cdihandler.py | 55 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/openlcb/cdihandler.py b/openlcb/cdihandler.py index 77a639d..91d9964 100644 --- a/openlcb/cdihandler.py +++ b/openlcb/cdihandler.py @@ -63,6 +63,8 @@ def __init__(self, *args, **kwargs): # ^ In case some parsing step happens early, # prepare these for _callback_msg. super().__init__() # takes no arguments + self._parser = xml.sax.make_parser() + self._parser.setContentHandler(self) self._realtime = True @@ -80,6 +82,8 @@ def __init__(self, *args, **kwargs): self._resultingCDI = None # endregion connect + self._connecting_t = None + def _reset_tree(self): self.etree = ET.Element("root") self._open_el = self.etree @@ -143,8 +147,35 @@ def connect(self, host, port, localNodeID, callback=None): if callback: callback(event_d) - return event_d # return it in case running synchronously (no thread) + self._connecting_t = time.perf_counter() + while time.perf_counter() - self._connecting_t < .2: + # Wait 200 ms for all nodes to announce, as per + # section 6.2.1 of CAN Frame Transfer Standard + # (sendMessage requires ) + try: + received = self._sock.receive() + # print(" RR: {}".format(received.strip())) + # pass to link processor + self._canPhysicalLayerGridConnect.receiveString(received) + # ^ will trigger self._printFrame since that was added + # via registerFrameReceivedListener during connect. + except RuntimeError as ex: + # May be raised by canbus.tcpsocket.TCPSocket.receive + # manually. Usually "socket connection broken" due to + # no more bytes to read, but ok if "\0" terminator + # was reached. + if not self._string_terminated: + # This boolean is managed by the memoryReadSuccess + # callback. + callback({ # same as self._download_callback here + 'error': "{}: {}".format(type(ex).__name__, ex), + 'done': True, # stop progress in gui/other main thread + }) + raise # re-raise since incomplete (prevent done OK state) + break + + return event_d # return it in case running synchronously (no thread) def _memoryRead(self, farNodeID, offset): """Create and send a read datagram. @@ -197,7 +228,7 @@ def callback(event_d): 'error': "{}: {}".format(type(ex).__name__, ex), 'done': True, # stop progress in gui/other main thread }) - raise # re-raise since incomplete (prevent done OK state) + raise # re-raise since incomplete (prevent done OK state) break # If we got here, the RuntimeError was ok since the # null terminator '\0' was reached (otherwise re-raise occurs above) @@ -226,8 +257,8 @@ def _printDatagram(self, memo): DatagramReadMemo: The datagram object Returns: - bool: Always False (True would mean we sent a reply to the datagram, - but let the MemoryService do that). + bool: Always False (True would mean we sent a reply to the + datagram, but let the MemoryService do that). """ # print("Datagram receive call back: {}".format(memo.data)) return False @@ -282,7 +313,7 @@ def _memoryReadSuccess(self, memo): self._callback_msg("Done loading CDI.") # done reading if self._realtime: - self.feed(chunk_str) # startElement, endElement etc. are automatic + self._parser.feed(chunk_str) # auto-calls startElement/endElement def _memoryReadFail(self, memo): error = "memory read failed: {}".format(memo.data) @@ -304,8 +335,12 @@ def startElement(self, name, attrs): el = ET.SubElement(self._open_el, "element1") # if self._tag_stack: # parent = self._tag_stack[-1] - self._callback_msg( - "loaded: {}{}".format(tab, ET.tostring(el, encoding="unicode"))) + event_d = {'name': name, 'end': False, 'attrs': attrs} + if self._download_callback: + self._download_callback(event_d) + + # self._callback_msg( + # "loaded: {}{}".format(tab, ET.tostring(el, encoding="unicode"))) self._tag_stack.append(el) self._open_el = el @@ -336,15 +371,15 @@ def checkDone(self, event_d): def endElement(self, name): """See xml.sax.handler.ContentHandler documentation.""" indent = len(self._tag_stack) + tab = " " * indent top_el = self._tag_stack[-1] if name != top_el.tag: print(tab+"Warning: before ".format(name, top_el.tag)) elif indent: # top element found and indent not 0 indent -= 1 # dedent since scope ended - tab = " " * indent # print(tab, name, "content:", self._flushCharBuffer()) print(tab, "End: ", name) - event_d = {'name': name} + event_d = {'name': name, 'end': True} if not self._tag_stack: event_d['error'] = " before any start tag".format(name) print(tab+"Warning: {}".format(event_d['error'])) @@ -394,4 +429,4 @@ def endElement(self, name): # if not isinstance(data, str): # raise TypeError( # "Expected str, got {}".format(type(data).__name__)) - # self._chunks.append(data) \ No newline at end of file + # self._chunks.append(data) From 5cb128aa677069747cca16a45c3b0511682750f1 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 4 Mar 2025 18:18:40 -0500 Subject: [PATCH 22/99] Handle incoming branches of the CDI tree in real time. --- examples/tkexamples/cdiform.py | 63 +++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/examples/tkexamples/cdiform.py b/examples/tkexamples/cdiform.py index 37f4225..f0d6e09 100644 --- a/examples/tkexamples/cdiform.py +++ b/examples/tkexamples/cdiform.py @@ -17,7 +17,6 @@ from logging import getLogger - logger = getLogger(__name__) TKEXAMPLES_DIR = os.path.dirname(os.path.realpath(__file__)) @@ -40,8 +39,6 @@ raise # sys.exit(1) -import xml.sax # noqa: E402 - class CDIForm(ttk.Frame, CDIHandler): """A GUI frame to represent the CDI visually as a tree. @@ -77,7 +74,7 @@ def _gui(self, container): self.grid(sticky=tk.NSEW, row=len(self._top_widgets)) self.rowconfigure(len(self._top_widgets), weight=1) # weight=1 allows expansion self._top_widgets.append(self._treeview) - self._branch = "" # top level of a Treeview is "" + self._populating_stack = [] # no parent when of top of Treeview self._current_iid = 0 # id of Treeview element def clear(self): @@ -103,11 +100,16 @@ def cdi_refresh_callback(self, event_d): event_d (dict): Document parsing state info: - 'element' (SubElement): The element that has been completely parsed ('' reached) - - 'error' (str): Message of failure (requires 'done' if stopped). + - 'error' (str): Message of failure (requires 'done' if + stopped). - 'done' (bool): If True, downloadCDI is finished. Though document itself may be incomplete if 'error' is also set, stop tracking status of downloadCDI regardless. + - 'end' (bool): False to start a deeper scope, or True + for end tag, which exits current scope (last created + Treeview branch in this case, or top if empty + self._populating_stack). """ done = event_d.get('done') error = event_d.get('error') @@ -123,7 +125,23 @@ def cdi_refresh_callback(self, event_d): self.root.after(0, self.set_status, show_message) if done: return - self.root.after(0, self._add_cdi_element, event_d) + if event_d.get('end'): + self.root.after(0, self._pop_cdi_element, event_d) + else: + self.root.after(0, self._add_cdi_element, event_d) + + def _pop_cdi_element(self, event_d): + if not self._populating_stack: + raise IndexError( + "Got stray end tag in top level of XML: {}" + .format(event_d)) + # pop would also raise IndexError, but this message is more clear. + return self._populating_stack.pop() + + def _populating_branch(self): + if not self._populating_stack: + return "" # "" is the top of a ttk.Treeview + return self._populating_stack[-1] def _add_cdi_element(self, event_d): element = event_d.get('element') @@ -137,12 +155,28 @@ def _add_cdi_element(self, event_d): # TODO: handle start tags separately (Branches are too late tobe # created here since all children are done). index = "end" # "end" is at end of current branch (otherwise use int) - if tag == "segment": - pass - elif tag == "group": - pass + if tag in ("segment", "group"): + name = "" + for child in element: + if child.tag == "name": + name = child.text + break + # if not name: + name = element.attrs['space'] + new_branch = self._treeview.insert( + self._populating_branch(), + index, + iid=self._current_iid, + text=name, + ) + self._populating_stack.append(new_branch) + # values=(), image=None + self._current_iid += 1 # TODO: associate with SubElement elif tag == "acdi": + # "Indicates that certain configuration information in the + # node has a standardized simplified format." # Configuration Description Information - Standard - section 5.1 + # (self-closing tag; triggers startElement and endElement) pass elif tag in ("int", "string", "float"): name = "" @@ -150,8 +184,13 @@ def _add_cdi_element(self, event_d): if child.tag == "name": name = child.text break - self._treeview.insert(self._branch, index, iid=self._current_iid, - text=name) + new_branch = self._treeview.insert( + self._populating_branch(), + index, + iid=self._current_iid, + text=name, + ) + self._populating_stack.append(new_branch) # values=(), image=None self._current_iid += 1 # TODO: associate with SubElement # and/or set values keyword argument to create association(s) From a20dd68858086e06cd4a326e342da8cb56809ff2 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 4 Mar 2025 18:18:53 -0500 Subject: [PATCH 23/99] Update cSpell dict. --- python-openlcb.code-workspace | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python-openlcb.code-workspace b/python-openlcb.code-workspace index b11eb3b..bb96b47 100644 --- a/python-openlcb.code-workspace +++ b/python-openlcb.code-workspace @@ -30,6 +30,7 @@ "canframe", "canlink", "canphysicallayer", + "canphysicallayergridconnect", "canphysicallayersimulation", "cdiform", "columnspan", @@ -66,6 +67,7 @@ "settingtypes", "setuptools", "tcplink", + "tcpsocket", "textvariable", "tkexamples", "unformatting", From 462330381527a12c5f34a9f0269f1780f9243f6d Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 6 Mar 2025 14:11:39 -0500 Subject: [PATCH 24/99] Add a ValueError before getting too far (prevent obscure out-of-range [value>255] bytearray error). --- openlcb/canbus/canphysicallayergridconnect.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index 24cdf14..fe5ff2f 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -68,7 +68,12 @@ def receiveChars(self, data): part1 = (byte1 & 0xF)+9 if byte1 > 0x39 else byte1 & 0xF # noqa: E501 byte2 = self.inboundBuffer[index+11+2*dataItem+1] part2 = (byte2 & 0xF)+9 if byte2 > 0x39 else byte2 & 0xF # noqa: E501 - outData += bytearray([part1 << 4 | part2]) + high_nibble = part1 << 4 + if part1 > 0xF: # can't fit more than 0b1111 in nibble + raise ValueError( + "Got {} for high nibble (part1 << 4 == {})." + .format(part1, high_nibble)) + outData += bytearray([high_nibble | part2]) lastByte += 2 # lastByte is index of ; in this message From 2d3a79bf70af1232413aaeea921a5cacd228bba5 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Fri, 28 Mar 2025 11:48:56 -0400 Subject: [PATCH 25/99] Rename lastByte to processedCount since it is exclusive (last value is start of next). Comment questionable check until question in issue #63 is answered. --- openlcb/canbus/canphysicallayergridconnect.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index fe5ff2f..2ec79d9 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -42,7 +42,7 @@ def receiveString(self, string): # Provide characters from the outside link to be parsed def receiveChars(self, data): self.inboundBuffer += data - lastByte = 0 + processedCount = 0 if 0x3B in self.inboundBuffer: # ^ ';' ends message so we have at least one (CR/LF not required) # found end, now find start of that same message, earlier in buffer @@ -59,7 +59,7 @@ def receiveChars(self, data): header = (header << 4)+nextByte # offset 10 is N # offset 11 might be data, might be ; - lastByte = index+11 + processedCount = index+11 for dataItem in range(0, 8): if self.inboundBuffer[index+11+2*dataItem] == 0x3B: break @@ -69,16 +69,19 @@ def receiveChars(self, data): byte2 = self.inboundBuffer[index+11+2*dataItem+1] part2 = (byte2 & 0xF)+9 if byte2 > 0x39 else byte2 & 0xF # noqa: E501 high_nibble = part1 << 4 - if part1 > 0xF: # can't fit more than 0b1111 in nibble - raise ValueError( - "Got {} for high nibble (part1 << 4 == {})." - .format(part1, high_nibble)) + # if part1 > 0xF: # can't fit > 0b1111 in nibble + # # possible overflow caused by +9 above + # # (but should only happen on bad packet)? + # # Commented since not sure if ok + # raise ValueError( + # "Got {} for high nibble (part1 << 4 == {})." + # .format(part1, high_nibble)) outData += bytearray([high_nibble | part2]) - lastByte += 2 + processedCount += 2 # lastByte is index of ; in this message cf = CanFrame(header, outData) self.fireListeners(cf) # shorten buffer by removing the processed message - self.inboundBuffer = self.inboundBuffer[lastByte:] + self.inboundBuffer = self.inboundBuffer[processedCount:] From 97be5ec9d3d3fe1e05d129dd3e27a2c068ec8a81 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Fri, 28 Mar 2025 23:24:31 -0400 Subject: [PATCH 26/99] Add precise_sleep. Remove lint (re-order imports). --- openlcb/__init__.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/openlcb/__init__.py b/openlcb/__init__.py index 2c72ccc..c93766e 100644 --- a/openlcb/__init__.py +++ b/openlcb/__init__.py @@ -1,5 +1,7 @@ -from collections import OrderedDict import re +import time + +from collections import OrderedDict hex_pairs_rc = re.compile(r"^([0-9A-Fa-f]{2})+$") @@ -40,3 +42,22 @@ def list_type_names(values): raise TypeError("list_type_names is only implemented for" " list, tuple, dict, and OrderedDict, but got a(n) {}" .format(type(values).__name__)) + + +def precise_sleep(seconds, start=None): + """Wait for a precise number of seconds + (precise to hundredths approximately, depending on accuracy of + platform's sleep). Since time.sleep(seconds) is generally not + accurate, perf_counter is checked. + + Args: + seconds (float): Number of seconds to wait. + start (float, optional): The start time--*must* be a + time.perf_counter() value. Defaults to time.perf_counter(). + """ + if start is None: + start = time.perf_counter() + # NOTE: timeit.default_timer is usually Python 3-only perf_counter + # in Python 3 + while (time.perf_counter() - start) < seconds: + time.sleep(.01) From 49cd4383669913418f3df6955c8d084b75e5b92c Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Fri, 28 Mar 2025 23:26:01 -0400 Subject: [PATCH 27/99] Wait 200ms before finishing sendAliasAllocationSequence *and stop* current reservation process (collision handler runs it again) unless no collision (fix #62) as per section 6.2.1 of CAN Frame Tansfer Standard. Fix type for CanLink.State values (some were tuples due to trailing comma, now all are ints--still would compare if the value was always set and compared from the Statet(Enum) subclass, but not technically correct) (related to issue #62). Add a related docstring. --- openlcb/canbus/canlink.py | 89 ++++++++++++++++--- openlcb/canbus/canphysicallayergridconnect.py | 10 +++ openlcb/cdihandler.py | 73 +++++++++------ openlcb/tcplink/tcplink.py | 5 +- 4 files changed, 135 insertions(+), 42 deletions(-) diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index cf7219d..89e6060 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -22,6 +22,7 @@ from logging import getLogger +from openlcb import precise_sleep from openlcb.canbus.canframe import CanFrame from openlcb.canbus.controlframe import ControlFrame @@ -41,6 +42,8 @@ def __init__(self, localNodeID): # a NodeID self.localNodeID = localNodeID self.state = CanLink.State.Initial self.link = None + self._frameCount = 0 + self._reserveAliasCollisions = 0 self.aliasToNodeID = {} self.nodeIdToAlias = {} self.accumulator = {} @@ -63,8 +66,8 @@ def linkPhysicalLayer(self, cpl): cpl.registerFrameReceivedListener(self.receiveListener) class State(Enum): - Initial = 1, # a special case of .Inhibited where init hasn't started - Inhibited = 2, + Initial = 1 # a special case of .Inhibited where init hasn't started + Inhibited = 2 Permitted = 3 def receiveListener(self, frame): @@ -110,8 +113,7 @@ def receiveListener(self, frame): # NOTE: We may process other bits of frame.header # that were stripped from control_frame self.handleReceivedData(frame) - elif (control_frame - == ControlFrame.UnknownFormat): + elif (control_frame == ControlFrame.UnknownFormat): logger.warning( "Unexpected CAN header 0x{:08X}" .format(frame.header)) @@ -133,9 +135,12 @@ def handleReceivedLinkUp(self, frame): """ # start the alias allocation in Inhibited state self.state = CanLink.State.Inhibited - self.defineAndReserveAlias() - # notify upper layers - self.linkStateChange(self.state) + if self.defineAndReserveAlias(): + print("[CanLink] Notifying upper layers of LinkUp.") + else: + logger.warning( + "[CanLink] Not notifying upper layers of LinkUp" + " since reserve alias failed (will retry).") def handleReceivedLinkRestarted(self, frame): """Send a LinkRestarted message upstream. @@ -148,10 +153,12 @@ def handleReceivedLinkRestarted(self, frame): self.fireListeners(msg) def defineAndReserveAlias(self): - self.sendAliasAllocationSequence() - - # TODO: wait 200 msec before declaring ready to go (and doing - # steps following the call here) + previousLocalAliasSeed = self.localAliasSeed + if not self.sendAliasAllocationSequence(): + logger.warning( + "Alias collision for {}. will try again." + .format(previousLocalAliasSeed)) + return False # send AMD frame, go to Permitted state self.link.sendCanFrame(CanFrame(ControlFrame.AMD.value, @@ -168,6 +175,8 @@ def defineAndReserveAlias(self): # send AME with no NodeID to get full alias map self.link.sendCanFrame(CanFrame(ControlFrame.AME.value, self.localAlias)) + self.linkStateChange(self.state) # Notify upper layers + return True # TODO: (restart) Should this set inhibited every time? LinkUp not # called on restart @@ -189,6 +198,7 @@ def handleReceivedLinkDown(self, frame): # notify upper levels self.linkStateChange(self.state) + def linkStateChange(self, state): """invoked when the link layer comes up and down @@ -281,6 +291,7 @@ def handleReceivedData(self, frame): # CanFrame Additional arguments may be encoded in lower bits (below ControlFrame.Data) in frame.header. """ + self._frameCount += 1 if self.checkAndHandleAliasCollision(frame): return # get proper MTI @@ -622,6 +633,7 @@ def checkAndHandleAliasCollision(self, frame): def processCollision(self, frame) : ''' Collision! ''' + self._reserveAliasCollisions += 1 logger.warning( "alias collision in {}, we restart with AMR" " and attempt to get new alias".format(frame)) @@ -636,13 +648,66 @@ def processCollision(self, frame) : self.defineAndReserveAlias() def sendAliasAllocationSequence(self): - '''Send the alias allocation sequence''' + '''Send the alias allocation sequence + This *must not block* the frame receive thread, since we must + wait 200ms and start sendAliasAllocationSequence over if + transmission error occurs, or an announcement with a Node ID + same as ours is received. + - In either case this method must *not* complete (*not* sending + RID is implied). + - In the latter case, our ID must be incremented before + sendAliasAllocationSequence starts over, and repeat this until + it is unique (no packets with senders matching it are + received) + - See section 6.2.1 of LCC "CAN Frame Transfer" Standard + + Returns: + bool: True if succeeded, False if collision. + ''' self.link.sendCanFrame(CanFrame(7, self.localNodeID, self.localAlias)) self.link.sendCanFrame(CanFrame(6, self.localNodeID, self.localAlias)) self.link.sendCanFrame(CanFrame(5, self.localNodeID, self.localAlias)) self.link.sendCanFrame(CanFrame(4, self.localNodeID, self.localAlias)) + previousCollisions = self._reserveAliasCollisions + previousFrameCount = self._frameCount + previousLocalAliasSeed = self.localAliasSeed + precise_sleep(.2) # wait 200ms as per section 6.2.1 + # ("Reserving a Node ID Alias") of + # LCC "CAN Frame Transfer" Standard + responseCount = self._frameCount - previousFrameCount + if responseCount < 1: + logger.warning( + "sendAliasAllocationSequence is blocking the receive thread" + " or the network is taking too long to respond (200ms is LCC" + " standard time for all node reservation replies." + " If there any other nodes, this is an error and this method" + " should *not* continue sending Reserve ID (RID) frame)...") + if self._reserveAliasCollisions > previousCollisions: + # processCollision will increment the non-unique alias try + # defineAndReserveAlias again (so stop before completing + # the sequence as per Standard) + logger.warning( + "Cancelled reservation of duplicate local alias seed {}" + " (processCollision increments ID to avoid," + " & restarts sequence)." + .format(previousLocalAliasSeed)) + return False + if responseCount < 1: + logger.warning( + "Continuing to send Reservation (RID) anyway" + "--no response, so assuming alias seed {} is unique" + " (If there are any other nodes on the network then" + " a thread, the call order, or network connection failed!)." + .format(self.localAliasSeed)) + # NOTE: If we were to stop here, then we would have to + # trigger defineAndReserveAlias again, since + # processCollision usually does that, but collision didn't + # occur. However, stopping here would not be valid anyway + # since we can't know for sure we aren't the only node, + # and if we are the only node no responses are expected. self.link.sendCanFrame(CanFrame(ControlFrame.RID.value, self.localAlias)) + return True def incrementAlias48(self, oldAlias): ''' diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index 2ec79d9..d533b7f 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -15,6 +15,16 @@ class CanPhysicalLayerGridConnect(CanPhysicalLayer): + """CAN physical layer subclass for GridConnect + + Args: + callback (Callable): A string send method for the platform and + hardware being used. It must be associated with an active + connection before used as the arg, and must raise exception + on failure so that sendAliasAllocationSequence is + interrupted in order to prevent canlink.state from + proceeding to CanLink.State.Permitted) + """ def __init__(self, callback): CanPhysicalLayer.__init__(self) diff --git a/openlcb/cdihandler.py b/openlcb/cdihandler.py index 91d9964..e635e9c 100644 --- a/openlcb/cdihandler.py +++ b/openlcb/cdihandler.py @@ -13,6 +13,7 @@ import json import platform import subprocess +import threading import time import sys import xml.sax # noqa: E402 @@ -21,6 +22,7 @@ from collections import OrderedDict from logging import getLogger +from openlcb import precise_sleep from openlcb.canbus.tcpsocket import TcpSocket from openlcb.canbus.canphysicallayergridconnect import ( @@ -147,44 +149,59 @@ def connect(self, host, port, localNodeID, callback=None): if callback: callback(event_d) - self._connecting_t = time.perf_counter() + # self._connecting_t = time.perf_counter() - while time.perf_counter() - self._connecting_t < .2: - # Wait 200 ms for all nodes to announce, as per - # section 6.2.1 of CAN Frame Transfer Standard - # (sendMessage requires ) - try: - received = self._sock.receive() - # print(" RR: {}".format(received.strip())) - # pass to link processor - self._canPhysicalLayerGridConnect.receiveString(received) - # ^ will trigger self._printFrame since that was added - # via registerFrameReceivedListener during connect. - except RuntimeError as ex: - # May be raised by canbus.tcpsocket.TCPSocket.receive - # manually. Usually "socket connection broken" due to - # no more bytes to read, but ok if "\0" terminator - # was reached. - if not self._string_terminated: - # This boolean is managed by the memoryReadSuccess - # callback. - callback({ # same as self._download_callback here - 'error': "{}: {}".format(type(ex).__name__, ex), - 'done': True, # stop progress in gui/other main thread - }) - raise # re-raise since incomplete (prevent done OK state) + self.listen() + + # precise_sleep(1, start=self._connecting_t) + while True: + precise_sleep(.25) + if self._canLink.state == CanLink.State.Permitted: break + logger.warning( + "CanLink is not ready yet." + " There must have been a collision" + "--processCollision increments node alias in this case," + " so trying again.") return event_d # return it in case running synchronously (no thread) + def listen(self): + self._listen_thread = threading.Thread( + target=self._listen, + daemon=True, # True to terminate on program exit + ) + self._listen_thread.start() + + def _receive(self): + """Receive data from the network. + Override this if serial/other subclass not using TCP. + """ + return self._sock.receive() + + def _listen(self): + while time.perf_counter() - self._connecting_t < .2: + # Wait 200 ms for all nodes to announce (and for alias + # reservation to complete), as per section 6.2.1 of CAN + # Frame Transfer Standard (sendMessage requires ) + received = self._receive() + # print(" RR: {}".format(received.strip())) + # pass to link processor + self._canPhysicalLayerGridConnect.receiveString(received) + # ^ will trigger self._printFrame if that was added + # via registerFrameReceivedListener during connect. + def _memoryRead(self, farNodeID, offset): """Create and send a read datagram. This is a read of 20 bytes from the start of CDI space. We will fire it on a separate thread to give time for other nodes to - reply to AME - """ - time.sleep(1) + reply to AME. + Before calling this, ensure connect returns (or that you + manually do the 200 ms wait it has built in). That ensures nodes + announce, otherwise sendMessage (triggered by requestMemoryRead) + will have a KeyError when trying to use the farNodeID. + """ # read 64 bytes from the CDI space starting at address zero memMemo = MemoryReadMemo(NodeID(farNodeID), 64, 0xFF, offset, self._memoryReadFail, self._memoryReadSuccess) diff --git a/openlcb/tcplink/tcplink.py b/openlcb/tcplink/tcplink.py index 15da4fd..0695986 100644 --- a/openlcb/tcplink/tcplink.py +++ b/openlcb/tcplink/tcplink.py @@ -92,8 +92,9 @@ def receivedPart(self, messagePart, flags, length): and groups them into single OpenLCB messages as needed Args: - messagePart (bytearray) : Raw message data. A single TCP-level message, - which may include all or part of a single OpenLCB message. + messagePart (bytearray) : Raw message data. A single + TCP-level message, which may include all or part of a + single OpenLCB message. """ # set the source NodeID from the data gatewayNodeID = NodeID(messagePart[5:11]) From 9c652f65fa3caee138ff21bb5b87a4d4a425024d Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Mon, 31 Mar 2025 17:16:24 -0400 Subject: [PATCH 28/99] Handle connection state robustly according to standard, and check if Permitted before sending messages (related to issue #62). Use only one receive thread in cdihandler to avoid missing packets (and rename CDIHandler to PortHandler so as to allow handling different types of messages and memos in future versions). Rename "add" and "pop" to "start" and "end" for clarity. Isolate CDI-specific data by adding Mode to PortHandler (based on what data string was requested and not yet terminated). Add formatted_ex for logging. --- examples/example_cdi_access.py | 15 +- examples/examples_gui.py | 135 +++++++++--- examples/tkexamples/cdiform.py | 43 ++-- openlcb/__init__.py | 4 + openlcb/cdihandler.py | 389 ++++++++++++++++++++------------- 5 files changed, 388 insertions(+), 198 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index d02c38e..b19be1c 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -239,7 +239,20 @@ def memoryRead(): to AME """ import time - time.sleep(1) + time.sleep(.21) + # ^ 200ms is the time span in which all nodes must reply to ensure + # our alias is ok according to the LCC CAN Frame Transfer + # Standard, but wait slightly more for OS latency. + # - Then wait longer below if there was a failure/retry, before + # trying to use the LCC network: + while canLink.state != CanLink.State.Permitted: + # Would only take more than ~200ms (possibly a few nanoseconds + # more for latency on the part of this program itself) + # if multiple alias collisions + # (alias is incremented to find a unique one) + print("Waiting for connection sequence to complete...") + # This delay could be .2, but longer to reduce console messages: + time.sleep(.5) # read 64 bytes from the CDI space starting at address zero memMemo = MemoryReadMemo(NodeID(settings['farNodeID']), 64, 0xFF, 0, diff --git a/examples/examples_gui.py b/examples/examples_gui.py index 6b27da2..d3204fd 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -33,9 +33,10 @@ from examples_settings import Settings # ^ adds parent of module to sys.path, so openlcb imports *after* this +from openlcb.canbus.tcpsocket import TcpSocket from examples.tkexamples.cdiform import CDIForm -from openlcb import emit_cast +from openlcb import emit_cast, formatted_ex from openlcb.tcplink.mdnsconventions import id_from_tcp_service_name zeroconf_enabled = False @@ -129,6 +130,7 @@ def __init__(self, parent): self.browser = None self.errors = [] self.root = parent + self._connect_thread = None try: self.settings = Settings() except json.decoder.JSONDecodeError as ex: @@ -445,7 +447,7 @@ def _gui(self, parent): self.cdi_refresh_button.grid(row=self.cdi_row, column=1) self.cdi_row += 1 - self.cdi_form = CDIForm(self.cdi_tab) + self.cdi_form = CDIForm(self.cdi_tab) # CDIHandler() subclass self.cdi_form.grid(row=self.cdi_row) self.example_tab = ttk.Frame(self.notebook) @@ -474,27 +476,85 @@ def _gui(self, parent): # self.rowconfigure(row, weight=1) # self.rowconfigure(self.row_count-1, weight=1) # make last row expand - def connect_callback(self, event_d): - """Handle a dict event from a different thread - (this type of event is specific to examples) - """ - # Trigger the main thread (only the main thread can access the GUI) - self.root.after(0, self._callback, event_d) + def _connect_state_changed(self, event_d): + # SEE _handleMessage INSTEAD + """Handle connection events. + + This is an example of how to handle different combinations of + errors and messages. It could be simplified slightly by having a + multi-line log panel, which would allow adding both 'error' and + 'message' on the same run on different lines (but still only + show ready_message if 'done' or not 'error'). - def _callback(self, event_d): - message = event_d.get('message') - if message: - self.set_status(message) + Args: + event_d (dict): Information sent by CDIHandler's + connect method during the connection steps + including alias reservation. Potential keys: + - 'error' (str): Indicates a failure + - 'status' (str): Status message + - 'done' (bool): Indicates the process is done, but + *only ready to send messages if 'error' is None*. + - 'message' + """ + status = event_d.get('status') + if status: + self.set_status(status) + error = event_d.get('error') + if error: + if status: + raise ValueError( + "openlcb should set message or error, not both") + self.set_status(error) + status = error + logger.error("[_connect_state_changed] {}".format(error)) done = event_d.get('done') if done: - self.cdi_refresh_button.configure(state=tk.NORMAL) - custom_message = 'Ready to load CDI (click "Refresh").' - if message: - custom_message += " " + message - print(custom_message) - self.set_status(custom_message) + ready_message = 'Ready to load CDI (click "Refresh").' + # if event_d.get('command') == "connect": + if status: + ready_message += " " + status + if not error: + self.cdi_refresh_button.configure(state=tk.NORMAL) + self.set_status(ready_message) + print(ready_message) + else: + # Only would be enabled if done without error before, + # but maybe connection went down, so disable the + # refresh button since we cannot read CDI (can't send + # any read/write messages to the LCC network) in this + # situation: + self.cdi_refresh_button.configure(state=tk.DISABLED) + # Already called self.set_status(error) above. + + def connect_state_changed(self, event_d): + """Handle a dict event from a different thread + by sending the event to the main (GUI) thread. + + This handles changes in the network connection, whether + triggered by an LCC Message, TcpSocket or the OS's network + implementation (called by _listen directly unless triggered by + LCC Message). + + In this program, this is added to PortHandler via + set_connect_listener. + + Therefore in this program, this is triggered during _listen in + PortHandler: Connecting is actually done until + sendAliasAllocationSequence detects success and marks + canLink.state to CanLink.State.Permitted (which triggers + _handleMessage which calls this). + - May also be directly called by _listen directly in case + stopped listening (RuntimeError reading port, or other reason + lower in the stack than LCC). + - PortHandler's _connect_listener attribute is a method + reference to this if set via set_connect_listener. + """ + # Trigger the main thread (only the main thread can access the + # GUI): + self.root.after(0, self._connect_state_changed, event_d) + return True # indicate that the message was handled. - def cdi_connect_clicked(self): + def _connect(self): host_var = self.fields.get('host') host = host_var.get() port_var = self.fields.get('port') @@ -507,16 +567,35 @@ def cdi_connect_clicked(self): localNodeID = localNodeID_var.get() # self.cdi_form.connect(host, port, localNodeID) self.save_settings() - threading.Thread( - target=self.cdi_form.connect, - args=(host, port, localNodeID), - kwargs={'callback': self.connect_callback}, - daemon=True, - ).start() self.cdi_connect_button.configure(state=tk.DISABLED) self.cdi_refresh_button.configure(state=tk.DISABLED) - # daemon=True ensures the thread does not block program exit if - # the user closes the application. + msg = "connecting to {}...".format(host) + self.cdi_form.set_status(msg) + self.connect_state_changed({'status': msg}) # self._callback_msg(msg) + result = None + try: + self._tcp_socket = TcpSocket() + # self._sock.settimeout(30) + self._tcp_socket.connect(host, port) + self.cdi_form.set_connect_listener(self.connect_state_changed) + result = self.cdi_form.start_listening( + self._tcp_socket, + localNodeID, + ) + self._connect_thread = None + except Exception as ex: + self.set_status("Connect failed. {}".format(formatted_ex(ex))) + raise # show traceback still, in case in an IDE or Terminal. + return result + + def cdi_connect_clicked(self): + self._connect_thread = threading.Thread( + target=self._connect, + daemon=True, # True prevents continuing when trying to exit + ) + self._connect_thread.start() + # This thread may end quickly after connection since + # start_receiving starts a thread. def cdi_refresh_clicked(self): self.cdi_connect_button.configure(state=tk.DISABLED) @@ -529,7 +608,7 @@ def cdi_refresh_clicked(self): threading.Thread( target=self.cdi_form.downloadCDI, args=(farNodeID,), - kwargs={'callback': self.cdi_form.cdi_refresh_callback}, + kwargs={'callback': self.cdi_form.on_cdi_element}, daemon=True, ).start() diff --git a/examples/tkexamples/cdiform.py b/examples/tkexamples/cdiform.py index f0d6e09..9212cdd 100644 --- a/examples/tkexamples/cdiform.py +++ b/examples/tkexamples/cdiform.py @@ -30,7 +30,7 @@ " since test running from repo but could not find openlcb in {}." .format(repr(REPO_DIR))) try: - from openlcb.cdihandler import CDIHandler + from openlcb.cdihandler import PortHandler except ImportError as ex: print("{}: {}".format(type(ex).__name__, ex), file=sys.stderr) print("* You must run this from a venv that has openlcb installed" @@ -39,7 +39,7 @@ raise # sys.exit(1) -class CDIForm(ttk.Frame, CDIHandler): +class CDIForm(ttk.Frame, PortHandler): """A GUI frame to represent the CDI visually as a tree. Args: @@ -47,7 +47,7 @@ class CDIForm(ttk.Frame, CDIHandler): attribute set. """ def __init__(self, *args, **kwargs): - CDIHandler.__init__(self, *args, **kwargs) + PortHandler.__init__(self, *args, **kwargs) ttk.Frame.__init__(self, *args, **kwargs) self._top_widgets = [] if len(args) < 1: @@ -64,7 +64,8 @@ def _gui(self, container): if self._top_widgets: raise RuntimeError("gui can only be called once unless reset") self._status_var = tk.StringVar(self) - self._status_label = ttk.Label(container, textvariable=self._status_var) + self._status_label = ttk.Label(container, + textvariable=self._status_var) self.grid(sticky=tk.NSEW, row=len(self._top_widgets)) self._top_widgets.append(self._status_label) self._overview = ttk.Frame(container) @@ -72,7 +73,7 @@ def _gui(self, container): self._top_widgets.append(self._overview) self._treeview = ttk.Treeview(container) self.grid(sticky=tk.NSEW, row=len(self._top_widgets)) - self.rowconfigure(len(self._top_widgets), weight=1) # weight=1 allows expansion + self.rowconfigure(len(self._top_widgets), weight=1) # weight=1: expand self._top_widgets.append(self._treeview) self._populating_stack = [] # no parent when of top of Treeview self._current_iid = 0 # id of Treeview element @@ -84,6 +85,10 @@ def clear(self): self._gui() self.set_status("Display reset.") + # def connect(self, new_socket, localNodeID, callback=None): + # return CDIHandler.connect(self, new_socket, localNodeID, + # callback=callback) + def downloadCDI(self, farNodeID, callback=None): self.set_status("Downloading CDI...") super().downloadCDI(farNodeID, callback=callback) @@ -91,10 +96,10 @@ def downloadCDI(self, farNodeID, callback=None): def set_status(self, message): self._status_var.set(message) - def cdi_refresh_callback(self, event_d): + def on_cdi_element(self, event_d): """Handler for incoming CDI tag (Use this for callback in downloadCDI, which sets parser's - _download_callback) + _element_listener) Args: event_d (dict): Document parsing state info: @@ -113,24 +118,24 @@ def cdi_refresh_callback(self, event_d): """ done = event_d.get('done') error = event_d.get('error') - message = event_d.get('message') - show_message = None + status = event_d.get('status') + show_status = None if error: - show_message = error - elif message: - show_message = message + show_status = error + elif status: + show_status = status elif done: - show_message = "Done loading CDI." - if show_message: - self.root.after(0, self.set_status, show_message) + show_status = "Done loading CDI." + if show_status: + self.root.after(0, self.set_status, show_status) if done: return if event_d.get('end'): - self.root.after(0, self._pop_cdi_element, event_d) + self.root.after(0, self._on_cdi_element_start, event_d) else: - self.root.after(0, self._add_cdi_element, event_d) + self.root.after(0, self._on_cdi_element_end, event_d) - def _pop_cdi_element(self, event_d): + def _on_cdi_element_start(self, event_d): if not self._populating_stack: raise IndexError( "Got stray end tag in top level of XML: {}" @@ -143,7 +148,7 @@ def _populating_branch(self): return "" # "" is the top of a ttk.Treeview return self._populating_stack[-1] - def _add_cdi_element(self, event_d): + def _on_cdi_element_end(self, event_d): element = event_d.get('element') segment = event_d.get('segment') groups = event_d.get('groups') diff --git a/openlcb/__init__.py b/openlcb/__init__.py index c93766e..9b2f1c3 100644 --- a/openlcb/__init__.py +++ b/openlcb/__init__.py @@ -61,3 +61,7 @@ def precise_sleep(seconds, start=None): # in Python 3 while (time.perf_counter() - start) < seconds: time.sleep(.01) + + +def formatted_ex(ex): + return "{}: {}".format(type(ex).__name__, ex) \ No newline at end of file diff --git a/openlcb/cdihandler.py b/openlcb/cdihandler.py index e635e9c..8a1454d 100644 --- a/openlcb/cdihandler.py +++ b/openlcb/cdihandler.py @@ -10,25 +10,22 @@ Contributors: Poikilos, Bob Jacobsen (code from example_cdi_access) """ -import json -import platform -import subprocess +from enum import Enum import threading import time import sys import xml.sax # noqa: E402 import xml.etree.ElementTree as ET -from collections import OrderedDict from logging import getLogger -from openlcb import precise_sleep -from openlcb.canbus.tcpsocket import TcpSocket +from openlcb import formatted_ex, precise_sleep from openlcb.canbus.canphysicallayergridconnect import ( CanPhysicalLayerGridConnect, ) from openlcb.canbus.canlink import CanLink +from openlcb.mti import MTI from openlcb.nodeid import NodeID from openlcb.datagramservice import ( DatagramService, @@ -41,7 +38,7 @@ logger = getLogger(__name__) -class CDIHandler(xml.sax.handler.ContentHandler): +class PortHandler(xml.sax.handler.ContentHandler): """Manage Configuration Description Information. - Send events to downloadCDI caller describing the state and content of the document construction. @@ -54,17 +51,33 @@ class CDIHandler(xml.sax.handler.ContentHandler): _open_el (SubElement): Tracks currently-open tag (no `` yet) during parsing, or if no tags are open then equals etree. - _tag_stack(list[SubElement]): Tracks scope during parse since + _tag_stack (list[SubElement]): Tracks scope during parse since self.etree doesn't have awareness of whether end tag is finished (and therefore doesn't know which element is the parent of a new startElement). + _element_listener (Callable): Called if an XML element is + received (including either a start or end tag). + Typically set as `callback` argument to downloadCDI. """ + class Mode(Enum): + """Track what data is expected, if any. + Attributes: + Idle: No data (memory read request response) is expected. + CDI: The data expected from the memory read is CDI XML. + """ + Initializing = 0 + Disconnected = 1 + Idle = 2 + CDI = 3 + def __init__(self, *args, **kwargs): - self._download_callback = None - self._connect_callback = None + self._element_listener = None + self._connect_listener = None + self._mode = PortHandler.Mode.Initializing # ^ In case some parsing step happens early, # prepare these for _callback_msg. super().__init__() # takes no arguments + self._string_terminated = None # None means no read is occurring. self._parser = xml.sax.make_parser() self._parser.setContentHandler(self) @@ -76,7 +89,7 @@ def __init__(self, *args, **kwargs): # endregion ContentHandler # region connect - self._sock = None + self._port = None self._canPhysicalLayerGridConnect = None self._canLink = None self._datagramService = None @@ -90,41 +103,47 @@ def _reset_tree(self): self.etree = ET.Element("root") self._open_el = self.etree - def _callback_msg(self, msg, callback=None): + def _callback_status(self, status, callback=None): if callback is None: - callback = self._download_callback + callback = self._element_listener if callback is None: - callback = self._connect_callback + callback = self._connect_listener if callback: - print("CDIForm callback_msg({})".format(repr(msg))) - self._connect_callback({ - 'message': msg, + print("CDIForm callback_msg({})".format(repr(status))) + self._connect_listener({ + 'status': status, }) else: - logger.warning("No callback, but set status: {}".format(msg)) - - def connect(self, host, port, localNodeID, callback=None): - self._connect_callback = callback - self._callback_msg("connecting to {}...".format(host)) - self._sock = TcpSocket() - # s.settimeout(30) - self._sock.connect(host, port) - self._callback_msg("CanPhysicalLayerGridConnect...") + logger.warning("No callback, but set status: {}".format(status)) + + def set_element_listener(self, listener): + self._element_listener = listener + + def set_connect_listener(self, listener): + self._connect_listener = listener + + def start_listening(self, connected_port, localNodeID): + self._port = connected_port + self._callback_status("CanPhysicalLayerGridConnect...") self._canPhysicalLayerGridConnect = \ - CanPhysicalLayerGridConnect(self._sendToSocket) + CanPhysicalLayerGridConnect(self._sendToPort) self._canPhysicalLayerGridConnect.registerFrameReceivedListener( self._printFrame ) - self._callback_msg("CanLink...") + self._callback_status("CanLink...") self._canLink = CanLink(NodeID(localNodeID)) - self._callback_msg("CanLink...linkPhysicalLayer...") + self._callback_status("CanLink...linkPhysicalLayer...") self._canLink.linkPhysicalLayer(self._canPhysicalLayerGridConnect) - self._callback_msg("CanLink...linkPhysicalLayer" - "...registerMessageReceivedListener...") - self._canLink.registerMessageReceivedListener(self._printMessage) - - self._callback_msg("DatagramService...") + self._callback_status("CanLink...linkPhysicalLayer" + "...registerMessageReceivedListener...") + self._canLink.registerMessageReceivedListener(self._handleMessage) + # NOTE: Incoming data (Memo) is handled by _memoryReadSuccess + # and _memoryReadFail. + # - These are set when constructing the MemoryReadMemo which + # is provided to openlcb's requestMemoryRead method. + + self._callback_status("DatagramService...") self._datagramService = DatagramService(self._canLink) self._canLink.registerMessageReceivedListener( self._datagramService.process @@ -134,37 +153,15 @@ def connect(self, host, port, localNodeID, callback=None): self._printDatagram ) - self._callback_msg("MemoryService...") + self._callback_status("MemoryService...") self._memoryService = MemoryService(self._datagramService) - self._callback_msg("physicalLayerUp...") + self._callback_status("physicalLayerUp...") self._canPhysicalLayerGridConnect.physicalLayerUp() - # accumulate the CDI information - self._resultingCDI = bytearray() # only used if not self.realtime - event_d = { - 'message': "Ready to receive.", - 'done': True, - } - if callback: - callback(event_d) - - # self._connecting_t = time.perf_counter() - - self.listen() - - # precise_sleep(1, start=self._connecting_t) - while True: - precise_sleep(.25) - if self._canLink.state == CanLink.State.Permitted: - break - logger.warning( - "CanLink is not ready yet." - " There must have been a collision" - "--processCollision increments node alias in this case," - " so trying again.") - - return event_d # return it in case running synchronously (no thread) + self.listen() # Must listen for alias reservation responses + # (sendAliasConnectionSequence will occur for another 200ms + # once, then another 200ms on each alias collision if any) def listen(self): self._listen_thread = threading.Thread( @@ -174,22 +171,71 @@ def listen(self): self._listen_thread.start() def _receive(self): - """Receive data from the network. - Override this if serial/other subclass not using TCP. + """Receive data from the port. + Override this if serial/other subclass not using TCP + (or better yet, make all ports including TcpSocket inherit from + a standard port interface) """ - return self._sock.receive() + return self._port.receive() def _listen(self): - while time.perf_counter() - self._connecting_t < .2: - # Wait 200 ms for all nodes to announce (and for alias - # reservation to complete), as per section 6.2.1 of CAN - # Frame Transfer Standard (sendMessage requires ) - received = self._receive() - # print(" RR: {}".format(received.strip())) - # pass to link processor - self._canPhysicalLayerGridConnect.receiveString(received) - # ^ will trigger self._printFrame if that was added - # via registerFrameReceivedListener during connect. + self._connecting_t = time.perf_counter() + self._message_t = None + self._mode = PortHandler.Mode.Idle # Idle until data type is known + try: + while True: + # Wait 200 ms for all nodes to announce (and for alias + # reservation to complete), as per section 6.2.1 of CAN + # Frame Transfer Standard (sendMessage requires ) + received = self._receive() + # print(" RR: {}".format(received.strip())) + # pass to link processor + self._canPhysicalLayerGridConnect.receiveString(received) + # ^ will trigger self._printFrame if that was added + # via registerFrameReceivedListener during connect. + precise_sleep(.01) # let processor sleep briefly before read + if time.perf_counter() - self._connecting_t > .2: + if self._canLink.state != CanLink.State.Permitted: + if ((self._message_t is None) + or (time.perf_counter() - self._message_t + > 1)): + logger.warning( + "CanLink is not ready yet." + " There must have been a collision" + "--processCollision increments node alias" + " in this case and tries again.") + # else _on_link_state_change will be called + + except RuntimeError as ex: + # If _port is a TcpSocket: + # May be raised by canbus.tcpsocket.TCPSocket.receive + # manually. + # - Usually "socket connection broken" due to no more + # bytes to read, but ok if "\0" terminator was reached. + if self._resultingCDI is not None and not self._string_terminated: + # This boolean is managed by the memoryReadSuccess + # callback. + event_d = { # same as self._event_listener here + 'error': formatted_ex(ex), + 'done': True, # stop progress in gui/other main thread + } + if self._element_listener: + self._element_listener(event_d) + self._mode = PortHandler.Mode.Disconnected + raise # re-raise since incomplete (prevent done OK state) + self._listen_thread = None + + self._mode = PortHandler.Mode.Disconnected + # If we got here, the RuntimeError was ok since the + # null terminator '\0' was reached (otherwise re-raise occurs above) + event_d = { + 'error': "Listen loop stopped.", + 'done': True, + } + if not (self._connect_listener and self._connect_listener(event_d)): + # The message was not handled, so log it. + logger.error(event_d['error']) + return event_d # return it in case running synchronously (no thread) def _memoryRead(self, farNodeID, offset): """Create and send a read datagram. @@ -216,56 +262,63 @@ def downloadCDI(self, farNodeID, callback=None): def callback(event_d): print("downloadCDI default callback: {}".format(event_d), file=sys.stderr) - self._download_callback = callback - if not self._sock: - raise RuntimeError("No TCPSocket. Call connect first.") + self._element_listener = callback + if not self._port: + raise RuntimeError( + "No port connection. Call start_listening first.") if not self._canPhysicalLayerGridConnect: raise RuntimeError( - "No canPhysicalLayerGridConnect. Call connect first.") + "No canPhysicalLayerGridConnect. Call start_listening first.") self._cdi_offset = 0 self._reset_tree() + self._mode = PortHandler.Mode.CDI self._memoryRead(farNodeID, self._cdi_offset) - while True: - try: - received = self._sock.receive() - # print(" RR: {}".format(received.strip())) - # pass to link processor - self._canPhysicalLayerGridConnect.receiveString(received) - # ^ will trigger self._printFrame since that was added - # via registerFrameReceivedListener during connect. - except RuntimeError as ex: - # May be raised by canbus.tcpsocket.TCPSocket.receive - # manually. Usually "socket connection broken" due to - # no more bytes to read, but ok if "\0" terminator - # was reached. - if not self._string_terminated: - # This boolean is managed by the memoryReadSuccess - # callback. - callback({ # same as self._download_callback here - 'error': "{}: {}".format(type(ex).__name__, ex), - 'done': True, # stop progress in gui/other main thread - }) - raise # re-raise since incomplete (prevent done OK state) - break - # If we got here, the RuntimeError was ok since the - # null terminator '\0' was reached (otherwise re-raise occurs above) - event_d = { - 'message': "Done reading CDI.", - # 'done': True, # NOTE: not really done until endElement("cdi") - } - return event_d # return it in case running synchronously (no thread) + # ^ On a successful memory read, _memoryReadSuccess will trigger + # _memoryRead again and again until end/fail. - def _sendToSocket(self, string): + def _sendToPort(self, string): # print(" SR: {}".format(string.strip())) - self._sock.send(string) + self._port.send(string) def _printFrame(self, frame): # print(" RL: {}".format(frame)) pass - def _printMessage(self, message): + def _handleMessage(self, message): + """Handle a Message from the LCC network. + The Message Type Indicator (MTI) is checked in case the + application should visualize a change in the connection state + etc. + + Data (Memo) is not handled here (See _memoryReadSuccess and + _memoryReadFail for that). + + Args: + message (Message): Any message instance received from the + LCC network. + + Returns: + bool: If message was handled (always True in this + method) + """ # print("RM: {} from {}".format(message, message.source)) - pass + if message.mti == MTI.Link_Layer_Down: + if self._connect_listener: + self._connect_listener({ + 'done': True, + 'error': "Disconnected", + 'message': message, + }) + self._message_t = None # prevent _listen from discarding error + return True + elif message.mti == MTI.Link_Layer_Up: + if self._connect_listener: + self._connect_listener({ + 'done': True, # 'done' without error indicates connected. + 'message': message, + }) + return True + return False def _printDatagram(self, memo): """A call-back for when datagrams received @@ -280,6 +333,58 @@ def _printDatagram(self, memo): # print("Datagram receive call back: {}".format(memo.data)) return False + def _CDIReadPartial(self, memo): + """Handle partial CDI XML (any packet except last) + The last packet is not yet reached, so don't parse (but + feed if self._realtime) + + Args: + memo (MemoryReadMemo): successful read memo containing data. + """ + self._resultingCDI += memo.data + partial_str = memo.data.decode("utf-8") + if self._realtime: + self._parser.feed(partial_str) # may call startElement/endElement + + def _CDIReadDone(self, memo): + """Handle end of CDI XML (last packet) + End of data, so parse (or feed if self._realtime) + + Args: + memo (MemoryReadMemo): successful read memo containing data. + """ + partial_str = memo.data.decode("utf-8") + # save content + self._resultingCDI += memo.data + # concert resultingCDI to a string up to 1st zero + # and process that + if self._realtime: + # If _realtime, last chunk is treated same as another + # (since _realtime uses feed) except stop at '\0'. + null_i = memo.data.find(b'\0') + terminate_i = len(memo.data) + if null_i > -1: + terminate_i = min(null_i, terminate_i) + partial_str = memo.data[:terminate_i].decode("utf-8") + else: + # *not* realtime (but got to end, so parse all at once) + cdiString = "" + null_i = self._resultingCDI.find(b'\0') + terminate_i = len(self._resultingCDI) + if null_i > -1: + terminate_i = min(null_i, terminate_i) + cdiString = self._resultingCDI[:terminate_i].decode("utf-8") + # print (cdiString) + self.parse(cdiString) + # ^ startElement, endElement, etc. all consecutive using parse + # self._callback_status("Done loading CDI.") + if self._element_listener: + self._element_listener({ + 'done': True, # 'done' and not 'error' means got all + }) + if self._realtime: + self._parser.feed(partial_str) # may call startElement/endElement + def _memoryReadSuccess(self, memo): """Handle a successful read Invoked when the memory read successfully returns, @@ -292,50 +397,34 @@ def _memoryReadSuccess(self, memo): # print("successful memory read: {}".format(memo.data)) if len(memo.data) == 64 and 0 not in memo.data: # *not* last chunk self._string_terminated = False - chunk_str = memo.data.decode("utf-8") - # save content - self._resultingCDI += memo.data + if self._mode == PortHandler.Mode.CDI: + # save content + self._CDIReadPartial(memo, False) + else: + logger.error( + "Unknown data packet received" + " (memory read not triggered by PortHandler)") # update the address memo.address = memo.address + 64 # and read again (read next) self._memoryService.requestMemoryRead(memo) - # The last packet is not yet reached, so don't parse (but - # feed if self._realtime) + # The last packet is not yet reached else: # last chunk self._string_terminated = True # and we're done! - # save content - self._resultingCDI += memo.data - # concert resultingCDI to a string up to 1st zero - # and process that - if self._realtime: - # If _realtime, last chunk is treated same as another - # (since _realtime uses feed) except stop at '\0'. - null_i = memo.data.find(b'\0') - terminate_i = len(memo.data) - if null_i > -1: - terminate_i = min(null_i, terminate_i) - chunk_str = memo.data[:terminate_i].decode("utf-8") + if self._mode == PortHandler.Mode.CDI: + self._CDIReadDone(memo) else: - # *not* realtime (but got to end, so parse all at once) - cdiString = "" - null_i = self._resultingCDI.find(b'\0') - terminate_i = len(self._resultingCDI) - if null_i > -1: - terminate_i = min(null_i, terminate_i) - cdiString = self._resultingCDI[:terminate_i].decode("utf-8") - # print (cdiString) - self.parse(cdiString) - # ^ startElement, endElement, etc. all consecutive using parse - self._callback_msg("Done loading CDI.") + logger.error( + "Unknown last data packet received" + " (memory read not triggered by PortHandler)") + self._mode = PortHandler.Mode.Idle # CDI no longer expected # done reading - if self._realtime: - self._parser.feed(chunk_str) # auto-calls startElement/endElement def _memoryReadFail(self, memo): error = "memory read failed: {}".format(memo.data) - if self._download_callback: - self._download_callback({ + if self._element_listener: + self._element_listener({ 'error': error, 'done': True, # stop progress in gui/other main thread }) @@ -353,8 +442,8 @@ def startElement(self, name, attrs): # if self._tag_stack: # parent = self._tag_stack[-1] event_d = {'name': name, 'end': False, 'attrs': attrs} - if self._download_callback: - self._download_callback(event_d) + if self._element_listener: + self._element_listener(event_d) # self._callback_msg( # "loaded: {}{}".format(tab, ET.tostring(el, encoding="unicode"))) @@ -363,7 +452,7 @@ def startElement(self, name, attrs): def checkDone(self, event_d): """Notify the caller if parsing is over. - Calls _download_callback with `'done': True` in the argument if + Calls _element_listener with `'done': True` in the argument if 'name' is "cdi" (case-insensitive). That notifies the downloadCDI caller that parsing is over, so that caller should end progress bar/other status tracking for downloadCDI in that @@ -371,7 +460,7 @@ def checkDone(self, event_d): Returns: dict: Reserved for use without events (doesn't need to be - processed if self._download_callback is set since that + processed if self._element_listener is set since that also gets the dict if 'done'). 'done' is only True if 'name' is "cdi" (case-insensitive). """ @@ -381,8 +470,8 @@ def checkDone(self, event_d): # Not , so not done yet return event_d event_d['done'] = True # since "cdi" if avoided conditional return - if self._download_callback: - self._download_callback(event_d) + if self._element_listener: + self._element_listener(event_d) return event_d def endElement(self, name): @@ -422,7 +511,7 @@ def endElement(self, name): # Notify downloadCDI's caller since it can potentially add # UI widget(s) for at least one setting/segment/group # using this 'element'. - self._download_callback(event_d) + self._element_listener(event_d) # def _flushCharBuffer(self): # """Decode the buffer, clear it, and return all content. From a008e313662d2b14f99447b16032c5fbf66b5f98 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Mon, 31 Mar 2025 17:16:50 -0400 Subject: [PATCH 29/99] Move a comment to a new docstring. Remove lint. --- openlcb/canbus/canlink.py | 1 - openlcb/linklayer.py | 9 ++++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 89e6060..6cbcb7b 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -198,7 +198,6 @@ def handleReceivedLinkDown(self, frame): # notify upper levels self.linkStateChange(self.state) - def linkStateChange(self, state): """invoked when the link layer comes up and down diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index 4cdf462..8d9328c 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -17,6 +17,13 @@ class LinkLayer: + """Abstract Link Layer interface + + Attributes: + listeners (list[Callback]): local list of listener callbacks. + See subclass for default listener and more specific + callbacks called from there. + """ def __init__(self, localNodeID): self.localNodeID = localNodeID @@ -28,7 +35,7 @@ def sendMessage(self, msg): def registerMessageReceivedListener(self, listener): self.listeners.append(listener) - listeners = [] # local list of listener callbacks + listeners = [] def fireListeners(self, msg): for listener in self.listeners: From 3537a0558ba9b168b052763c5e377c5a362f83c1 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 1 Apr 2025 13:05:32 -0400 Subject: [PATCH 30/99] Add isInternal for ControlFrame analysis (to determine if frame is from network or is internal). --- openlcb/canbus/controlframe.py | 39 +++++++++++++++++++++++++++++++++ tests/test_canframe.py | 1 + tests/test_canlink.py | 40 ++++++++++++++++++++++++++++++---- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/openlcb/canbus/controlframe.py b/openlcb/canbus/controlframe.py index e3bd4bf..2268bad 100644 --- a/openlcb/canbus/controlframe.py +++ b/openlcb/canbus/controlframe.py @@ -77,3 +77,42 @@ class ControlFrame(Enum): LinkError = 0x20003 LinkDown = 0x20004 UnknownFormat = 0x21000 + + @classmethod + def isInternal(cls, control_frame): + """Check if enum value is internal. + + Args: + control_frame (ControlFrame): A ControlFrame (enum + subclass). + + Returns: + boolean: True if it is a non-network frame generated by + openlcb itself for internal use only (False if from the + LCC network). + """ + if isinstance(control_frame, ControlFrame): + return (control_frame in ( + cls.LinkUp, + cls.LinkRestarted, + cls.LinkCollision, + cls.LinkError, + cls.LinkDown, + cls.UnknownFormat, + )) + + if control_frame not in ControlFrameValues: + raise ValueError( + "Got {}, expected ControlFrame, or int from: {}" + .format(int(control_frame), ControlFrameValues)) + return (control_frame in ( + cls.LinkUp.value, + cls.LinkRestarted.value, + cls.LinkCollision.value, + cls.LinkError.value, + cls.LinkDown.value, + cls.UnknownFormat.value, + )) + + +ControlFrameValues = [e.value for e in ControlFrame] diff --git a/tests/test_canframe.py b/tests/test_canframe.py index 9d7dfcb..f7721ac 100644 --- a/tests/test_canframe.py +++ b/tests/test_canframe.py @@ -44,6 +44,7 @@ def testControlFrame(self): frame0703 = CanFrame(0x0701, 0x123) self.assertEqual(frame0703.header, 0x10701123) self.assertEqual(frame0703.data, bytearray()) + # For ControlFrame itself, see test_canlink.py if __name__ == '__main__': diff --git a/tests/test_canlink.py b/tests/test_canlink.py index f199d85..31d1e10 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -1,5 +1,6 @@ import unittest + from openlcb.canbus.canlink import CanLink from openlcb.canbus.canframe import CanFrame @@ -216,6 +217,41 @@ def testControlFrameDecode(self): self.assertEqual(canLink.decodeControlFrameFormat(frame), ControlFrame.UnknownFormat) + def testControlFrameIsInternal(self): + self.assertFalse(ControlFrame.isInternal(ControlFrame.AMD)) + self.assertFalse(ControlFrame.isInternal(ControlFrame.CID)) + self.assertFalse(ControlFrame.isInternal(ControlFrame.Data)) + + # These are non-openlcb values used for internal signaling + # their values have a bit set above what can come from a CAN Frame. + self.assertTrue(ControlFrame.isInternal(ControlFrame.LinkUp)) + self.assertTrue(ControlFrame.isInternal(ControlFrame.LinkRestarted)) + self.assertTrue(ControlFrame.isInternal(ControlFrame.LinkCollision)) + self.assertTrue(ControlFrame.isInternal(ControlFrame.LinkError)) + self.assertTrue(ControlFrame.isInternal(ControlFrame.LinkDown)) + self.assertTrue(ControlFrame.isInternal(ControlFrame.UnknownFormat)) + + # Test bad ControlFrame.*.value (only possible if *not* + # ControlFrame type, so assertRaises is not necessary above). + self.assertRaises( + ValueError, + lambda x=0x21001: ControlFrame.isInternal(x), + ) + + # If it is not ControlFrame nor int, it is "not in" values: + self.assertRaises( + ValueError, + lambda x="21000": ControlFrame.isInternal(x), + ) + self.assertRaises( + ValueError, + lambda x=21000.0: ControlFrame.isInternal(x), + ) + + # Allow int if not ControlFrame but is a ControlFrame.*.value + self.assertTrue(ControlFrame.isInternal(0x20000)) + self.assertTrue(ControlFrame.isInternal(0x21000)) + def testSimpleGlobalData(self): canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLink(NodeID("05.01.01.01.03.01")) @@ -689,7 +725,3 @@ def testSegmentDatagramDataArray(self): [bytearray([0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8]), bytearray([0x9, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF, 0x10]), bytearray([0x11])]) # noqa: E501 - - -if __name__ == '__main__': - unittest.main() From da00ce73c50c804488317a2b66d87ad735fde1b7 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 1 Apr 2025 17:19:39 -0400 Subject: [PATCH 31/99] Use threads to ensure receieve is occurring during alias reservation to make backend more able to solve internal caveats of LCC (FIXME: CDI GUI alias reservation doesn't complete since aspects of issue #62 remain). --- examples/example_cdi_access.py | 2 ++ examples/examples_gui.py | 12 ++++++--- openlcb/canbus/canlink.py | 13 ++++++++-- openlcb/cdihandler.py | 45 ++++++++++++++++++++++++++-------- openlcb/linklayer.py | 1 + 5 files changed, 58 insertions(+), 15 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index b19be1c..01a92af 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -221,6 +221,8 @@ def processXML(content) : # only called when there is a null terminator, which indicates the # last packet was reached for the requested read. # - See memoryReadSuccess comments for details. + with open("cached-cdi.xml", 'w') as stream: + stream.write(content) xml.sax.parseString(content, handler) print("\nParser done") diff --git a/examples/examples_gui.py b/examples/examples_gui.py index d3204fd..5405598 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -477,7 +477,6 @@ def _gui(self, parent): # self.rowconfigure(self.row_count-1, weight=1) # make last row expand def _connect_state_changed(self, event_d): - # SEE _handleMessage INSTEAD """Handle connection events. This is an example of how to handle different combinations of @@ -486,6 +485,10 @@ def _connect_state_changed(self, event_d): 'message' on the same run on different lines (but still only show ready_message if 'done' or not 'error'). + This method must run on the main thread to affect the GUI, so it + is triggered indirectly (by connect_state_changed which runs on + the connect or _listen thread). + Args: event_d (dict): Information sent by CDIHandler's connect method during the connection steps @@ -494,8 +497,8 @@ def _connect_state_changed(self, event_d): - 'status' (str): Status message - 'done' (bool): Indicates the process is done, but *only ready to send messages if 'error' is None*. - - 'message' """ + # logger.debug("Connect state changed: {}".format(event_d)) status = event_d.get('status') if status: self.set_status(status) @@ -864,7 +867,10 @@ def main(): )) # WxH+X+Y format root.minsize = (window_w, window_h) main_form = MainForm(root) - main_form.master.title("Python OpenLCB Examples") + main_form.master.title( + "Python OpenLCB Examples (Python {}.{}.{})" + .format(sys.version_info.major, sys.version_info.minor, + sys.version_info.micro)) try: main_form.mainloop() finally: diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 6cbcb7b..5fb2d52 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -81,6 +81,12 @@ def receiveListener(self, frame): not then ignored). """ control_frame = self.decodeControlFrameFormat(frame) + if not ControlFrame.isInternal(control_frame): + self._frameCount += 1 + else: + print("[CanLink receiveListener] control_frame={}" + .format(control_frame)) + if control_frame == ControlFrame.LinkUp: self.handleReceivedLinkUp(frame) elif control_frame == ControlFrame.LinkRestarted: # noqa: E501 @@ -205,6 +211,7 @@ def linkStateChange(self, state): state (CanLink.State): See CanLink. """ if state == CanLink.State.Permitted: + print("[linkStateChange] Link_Layer_Up") msg = Message(MTI.Link_Layer_Up, NodeID(0), None, bytearray()) else: msg = Message(MTI.Link_Layer_Down, NodeID(0), None, bytearray()) @@ -290,9 +297,9 @@ def handleReceivedData(self, frame): # CanFrame Additional arguments may be encoded in lower bits (below ControlFrame.Data) in frame.header. """ - self._frameCount += 1 if self.checkAndHandleAliasCollision(frame): return + # ^ may affect _reserveAliasCollisions (not _frameCount) # get proper MTI mti = self.canHeaderToFullFormat(frame) sourceID = NodeID(0) @@ -691,13 +698,15 @@ def sendAliasAllocationSequence(self): " & restarts sequence)." .format(previousLocalAliasSeed)) return False - if responseCount < 1: + while responseCount < 1: logger.warning( "Continuing to send Reservation (RID) anyway" "--no response, so assuming alias seed {} is unique" " (If there are any other nodes on the network then" " a thread, the call order, or network connection failed!)." .format(self.localAliasSeed)) + precise_sleep(.2) # wait for another collision wait term + responseCount = self._frameCount - previousFrameCount # NOTE: If we were to stop here, then we would have to # trigger defineAndReserveAlias again, since # processCollision usually does that, but collision didn't diff --git a/openlcb/cdihandler.py b/openlcb/cdihandler.py index 8a1454d..97f6188 100644 --- a/openlcb/cdihandler.py +++ b/openlcb/cdihandler.py @@ -127,9 +127,13 @@ def start_listening(self, connected_port, localNodeID): self._callback_status("CanPhysicalLayerGridConnect...") self._canPhysicalLayerGridConnect = \ CanPhysicalLayerGridConnect(self._sendToPort) - self._canPhysicalLayerGridConnect.registerFrameReceivedListener( - self._printFrame - ) + + # self._canPhysicalLayerGridConnect.registerFrameReceivedListener( + # self._printFrame + # ) + # ^ Commented since canlink already adds CanLink's default + # receiveListener to CanLinkPhysicalLayer & that's all we need + # for this application. self._callback_status("CanLink...") self._canLink = CanLink(NodeID(localNodeID)) @@ -156,18 +160,27 @@ def start_listening(self, connected_port, localNodeID): self._callback_status("MemoryService...") self._memoryService = MemoryService(self._datagramService) - self._callback_status("physicalLayerUp...") - self._canPhysicalLayerGridConnect.physicalLayerUp() + self._callback_status("listen...") self.listen() # Must listen for alias reservation responses # (sendAliasConnectionSequence will occur for another 200ms # once, then another 200ms on each alias collision if any) + self._callback_status("physicalLayerUp...") + self._canPhysicalLayerGridConnect.physicalLayerUp() + # ^ triggers fireListeners which calls CanLink's default + # receiveListener by default since added on CanPhysicalLayer + # arg of linkPhysicalLayer. + # - Must happen *after* listen thread starts, since + # generates ControlFrame.LinkUp and calls fireListeners + # which calls sendAliasConnectionSequence on this thread! + def listen(self): self._listen_thread = threading.Thread( target=self._listen, daemon=True, # True to terminate on program exit ) + print("[listen] Starting port receive loop...") self._listen_thread.start() def _receive(self): @@ -183,11 +196,21 @@ def _listen(self): self._message_t = None self._mode = PortHandler.Mode.Idle # Idle until data type is known try: + # NOTE: self._canLink.state is *definitely not* + # CanLink.State.Permitted yet, but that's ok because + # CanLink's default receiveHandler has to provide + # the alias from each node (collision or not) + # to has to get the expected replies to the alias + # reservation sequence below. + precise_sleep(.05) # Wait for physicalLayerUp to complete while True: # Wait 200 ms for all nodes to announce (and for alias # reservation to complete), as per section 6.2.1 of CAN # Frame Transfer Standard (sendMessage requires ) - received = self._receive() + print("[_listen] _receive...") + received = self._receive() # set timeout to prevent hang? + print("[_listen] received {} byte(s)".format(len(received)), + file=sys.stderr) # print(" RR: {}".format(received.strip())) # pass to link processor self._canPhysicalLayerGridConnect.receiveString(received) @@ -280,9 +303,9 @@ def _sendToPort(self, string): # print(" SR: {}".format(string.strip())) self._port.send(string) - def _printFrame(self, frame): - # print(" RL: {}".format(frame)) - pass + # def _printFrame(self, frame): + # # print(" RL: {}".format(frame)) + # pass def _handleMessage(self, message): """Handle a Message from the LCC network. @@ -301,7 +324,9 @@ def _handleMessage(self, message): bool: If message was handled (always True in this method) """ - # print("RM: {} from {}".format(message, message.source)) + print("[_handleMessage] RM: {} from {}" + .format(message, message.source)) + print("[_handleMessage] message.mti={}".format(message.mti)) if message.mti == MTI.Link_Layer_Down: if self._connect_listener: self._connect_listener({ diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index 8d9328c..4a8cca1 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -40,3 +40,4 @@ def registerMessageReceivedListener(self, listener): def fireListeners(self, msg): for listener in self.listeners: listener(msg) + From 3a367b006a0d14dee23285db9bdaad511f00f93a Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Wed, 2 Apr 2025 18:45:27 -0400 Subject: [PATCH 32/99] Create a PortInterface to prevent threads reading and writing at the same time on a serial or tcp port (prevent undefined behavior related to issue #62). Merge str-based TcpLink duplicate class into receiveString and sendString methods in new PortInterface. FIXME: Use select and use recv in non-blocking mode, and handle readyToSend and readyToReceive to make client code benefit from the _busy_message semaphor (prevent RuntimeError on overlapping port use). Add GC constants for clarity. Read only how much we want, and add GridConnectObserver to collect and split packets instead of holding up the recv loop. Add _errorCount (reserved for future us if necessary, see issue). --- examples/example_cdi_access.py | 18 +- examples/example_datagram_transfer.py | 17 +- examples/example_frame_interface.py | 15 +- examples/example_memory_length_query.py | 15 +- examples/example_memory_transfer.py | 15 +- examples/example_message_interface.py | 15 +- examples/example_node_implementation.py | 17 +- examples/example_remote_nodes.py | 19 ++- examples/example_string_interface.py | 17 +- examples/example_string_serial_interface.py | 11 +- examples/examples_gui.py | 2 +- openlcb/__init__.py | 3 +- openlcb/canbus/canlink.py | 22 +-- openlcb/canbus/canphysicallayergridconnect.py | 9 +- openlcb/canbus/gridconnectobserver.py | 32 ++++ openlcb/canbus/seriallink.py | 48 +++--- openlcb/canbus/tcpsocket.py | 66 -------- openlcb/cdihandler.py | 8 +- openlcb/linklayer.py | 1 - openlcb/portinterface.py | 156 ++++++++++++++++++ openlcb/tcplink/tcpsocket.py | 56 +++++-- python-openlcb.code-workspace | 2 + tests/test_canphysicallayergridconnect.py | 17 +- 23 files changed, 409 insertions(+), 172 deletions(-) create mode 100644 openlcb/canbus/gridconnectobserver.py delete mode 100644 openlcb/canbus/tcpsocket.py create mode 100644 openlcb/portinterface.py diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index 01a92af..4b3fe4a 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -12,14 +12,14 @@ ''' # region same code as other examples from examples_settings import Settings # do 1st to fix path if no pip install +from openlcb.canbus.gridconnectobserver import GridConnectObserver +from openlcb.tcplink.tcpsocket import TcpSocket settings = Settings() if __name__ == "__main__": settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb.canbus.tcpsocket import TcpSocket - from openlcb.canbus.canphysicallayergridconnect import ( CanPhysicalLayerGridConnect, ) @@ -53,7 +53,7 @@ def sendToSocket(string): # print(" SR: {}".format(string.strip())) - sock.send(string) + sock.sendString(string) def printFrame(frame): @@ -266,9 +266,17 @@ def memoryRead(): thread = threading.Thread(target=memoryRead) thread.start() +observer = GridConnectObserver() + # process resulting activity while True: received = sock.receive() - # print(" RR: {}".format(received.strip())) + if settings['trace']: + observer.push(received) + packet_str = observer.pop_gc_packet_str() + if packet_str: + pass + # print(" RR: "+packet_str.strip()) + # ^ commented since MyHandler shows parsed XML fields instead # pass to link processor - canPhysicalLayerGridConnect.receiveString(received) + canPhysicalLayerGridConnect.receiveChars(received) diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index 7cc45af..3c5e670 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -10,7 +10,8 @@ address and port. ''' # region same code as other examples -from examples_settings import Settings # do 1st to fix path if no pip install +from examples_settings import Settings +from openlcb.canbus.gridconnectobserver import GridConnectObserver # do 1st to fix path if no pip install settings = Settings() if __name__ == "__main__": @@ -19,7 +20,7 @@ import threading -from openlcb.canbus.tcpsocket import TcpSocket +from openlcb.tcplink.tcpsocket import TcpSocket from openlcb.canbus.canphysicallayergridconnect import ( CanPhysicalLayerGridConnect, ) @@ -48,7 +49,7 @@ def sendToSocket(string): print(" SR: "+string.strip()) - sock.send(string) + sock.sendString(string) def printFrame(frame): @@ -119,9 +120,15 @@ def datagramWrite(): thread = threading.Thread(target=datagramWrite) thread.start() +observer = GridConnectObserver() + # process resulting activity while True: received = sock.receive() - print(" RR: {}".format(received.strip())) + if settings['trace']: + observer.push(received) + packet_str = observer.pop_gc_packet_str() + if packet_str: + print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.receiveString(received) + canPhysicalLayerGridConnect.receiveChars(received) diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index 9aaac95..1d1baba 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -18,7 +18,8 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb.canbus.tcpsocket import TcpSocket +from openlcb.canbus.gridconnectobserver import GridConnectObserver +from openlcb.tcplink.tcpsocket import TcpSocket from openlcb.canbus.canphysicallayergridconnect import ( CanPhysicalLayerGridConnect, ) @@ -41,7 +42,7 @@ def sendToSocket(string): print(" SR: {}".format(string.strip())) - sock.send(string) + sock.sendString(string) def printFrame(frame): @@ -56,9 +57,15 @@ def printFrame(frame): print("SL: {}".format(frame)) canPhysicalLayerGridConnect.sendCanFrame(frame) +observer = GridConnectObserver() + # display response - should be RID from nodes while True: received = sock.receive() - print(" RR: {}".format(received.strip())) + if settings['trace']: + observer.push(received) + packet_str = observer.pop_gc_packet_str() + if packet_str: + print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.receiveString(received) + canPhysicalLayerGridConnect.receiveChars(received) diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index 0764119..bc29e57 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -17,7 +17,8 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb.canbus.tcpsocket import TcpSocket +from openlcb.canbus.gridconnectobserver import GridConnectObserver +from openlcb.tcplink.tcpsocket import TcpSocket from openlcb.canbus.canphysicallayergridconnect import ( CanPhysicalLayerGridConnect, @@ -53,7 +54,7 @@ def sendToSocket(string): print(" SR: {}".format(string.strip())) - sock.send(string) + sock.sendString(string) def printFrame(frame): @@ -138,9 +139,15 @@ def memoryRequest(): thread = threading.Thread(target=memoryRequest) thread.start() +observer = GridConnectObserver() + # process resulting activity while True: received = sock.receive() - print(" RR: {}".format(received.strip())) + if settings['trace']: + observer.push(received) + packet_str = observer.pop_gc_packet_str() + if packet_str: + print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.receiveString(received) + canPhysicalLayerGridConnect.receiveChars(received) diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index 1d84f69..9c41645 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -17,7 +17,8 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb.canbus.tcpsocket import TcpSocket +from openlcb.canbus.gridconnectobserver import GridConnectObserver +from openlcb.tcplink.tcpsocket import TcpSocket from openlcb.canbus.canphysicallayergridconnect import ( CanPhysicalLayerGridConnect, @@ -53,7 +54,7 @@ def sendToSocket(string): print(" SR: {}".format(string.strip())) - sock.send(string) + sock.sendString(string) def printFrame(frame): @@ -136,9 +137,15 @@ def memoryRead(): thread = threading.Thread(target=memoryRead) thread.start() +observer = GridConnectObserver() + # process resulting activity while True: received = sock.receive() - print(" RR: {}".format(received.strip())) + if settings['trace']: + observer.push(received) + packet_str = observer.pop_gc_packet_str() + if packet_str: + print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.receiveString(received) + canPhysicalLayerGridConnect.receiveChars(received) diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index 35fa8ce..379e950 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -19,7 +19,8 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb.canbus.tcpsocket import TcpSocket +from openlcb.canbus.gridconnectobserver import GridConnectObserver +from openlcb.tcplink.tcpsocket import TcpSocket from openlcb.canbus.canphysicallayergridconnect import ( CanPhysicalLayerGridConnect, @@ -46,7 +47,7 @@ def sendToSocket(string): print(" SR: {}".format(string.strip())) - sock.send(string) + sock.sendString(string) def printFrame(frame): @@ -77,9 +78,15 @@ def printMessage(msg): print("SM: {}".format(message)) canLink.sendMessage(message) +observer = GridConnectObserver() + # process resulting activity while True: received = sock.receive() - print(" RR: {}".format(received.strip())) + if settings['trace']: + observer.push(received) + packet_str = observer.pop_gc_packet_str() + if packet_str: + print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.receiveString(received) + canPhysicalLayerGridConnect.receiveChars(received) diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index 185fc99..cf140ba 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -19,7 +19,8 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb.canbus.tcpsocket import TcpSocket +from openlcb.canbus.gridconnectobserver import GridConnectObserver +from openlcb.tcplink.tcpsocket import TcpSocket from openlcb.canbus.canphysicallayergridconnect import ( CanPhysicalLayerGridConnect, @@ -59,7 +60,7 @@ def sendToSocket(string): print(" SR: {}".format(string.strip())) - sock.send(string) + sock.sendString(string) def printFrame(frame): @@ -151,9 +152,15 @@ def displayOtherNodeIds(message) : NodeID(settings['localNodeID']), None) canLink.sendMessage(message) +observer = GridConnectObserver() + # process resulting activity while True: - input = sock.receive() - print(" RR: "+input.strip()) + received = sock.receive() + if settings['trace']: + observer.push(received) + packet_str = observer.pop_gc_packet_str() + if packet_str: + print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.receiveString(input) + canPhysicalLayerGridConnect.receiveChars(received) diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index 3c8ee1f..baf3504 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -11,7 +11,8 @@ address and port. ''' # region same code as other examples -from examples_settings import Settings # do 1st to fix path if no pip install +from examples_settings import Settings +from openlcb.canbus.gridconnectobserver import GridConnectObserver # do 1st to fix path if no pip install settings = Settings() if __name__ == "__main__": @@ -24,7 +25,7 @@ # from openlcb.canbus.canframe import CanFrame from openlcb.canbus.canlink import CanLink # from openlcb.canbus.controlframe import ControlFrame -from openlcb.canbus.tcpsocket import TcpSocket +from openlcb.tcplink.tcpsocket import TcpSocket from openlcb.node import Node from openlcb.nodeid import NodeID @@ -59,7 +60,7 @@ def sendToSocket(string) : if settings['trace'] : print(" SR: "+string.strip()) - sock.send(string) + sock.sendString(string) def receiveFrame(frame) : @@ -103,6 +104,8 @@ def printMessage(msg): readQueue = Queue() +observer = GridConnectObserver() + def receiveLoop() : """put the read on a separate thread""" @@ -110,10 +113,14 @@ def receiveLoop() : if settings['trace'] : print(" SL : link up") canPhysicalLayerGridConnect.physicalLayerUp() while True: - input = sock.receive() - if settings['trace'] : print(" RR: "+input.strip()) + received = sock.receive() + if settings['trace']: + observer.push(received) + packet_str = observer.pop_gc_packet_str() + if packet_str: + print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.receiveString(input) + canPhysicalLayerGridConnect.receiveChars(received) import threading # noqa E402 diff --git a/examples/example_string_interface.py b/examples/example_string_interface.py index 30a8d8e..bc972a2 100644 --- a/examples/example_string_interface.py +++ b/examples/example_string_interface.py @@ -18,7 +18,8 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb.canbus.tcpsocket import TcpSocket +from openlcb.canbus.gridconnectobserver import GridConnectObserver +from openlcb.tcplink.tcpsocket import TcpSocket # specify connection information # region replaced by settings @@ -34,10 +35,16 @@ # send a AME frame in GridConnect string format with arbitrary source alias to # elicit response -AME = ":X10702001N;" -sock.send(AME) -print("SR: {}".format(AME.strip())) +AME_packet_str = ":X10702001N;" +sock.sendString(AME_packet_str) +print("SR: {}".format(AME_packet_str.strip())) + +observer = GridConnectObserver() # display response - should be RID from node(s) while True: # have to kill this manually - print("RR: {}".format(sock.receive().strip())) + received = sock.receive() + observer.push(received) + packet_str = observer.pop_gc_packet_str() + if packet_str: + print(" RR: "+packet_str.strip()) diff --git a/examples/example_string_serial_interface.py b/examples/example_string_serial_interface.py index b5b1d38..0fab227 100644 --- a/examples/example_string_serial_interface.py +++ b/examples/example_string_serial_interface.py @@ -18,6 +18,7 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples +from openlcb.canbus.gridconnectobserver import GridConnectObserver from openlcb.canbus.seriallink import SerialLink # specify connection information @@ -34,9 +35,15 @@ # send a AME frame in GridConnect string format with arbitrary source alias to # elicit response AME = ":X10702001N;" -sock.send(AME) +sock.sendString(AME) print("SR: {}".format(AME.strip())) +observer = GridConnectObserver() + # display response - should be RID from node(s) while True: # have to kill this manually - print("RR: {}".format(sock.receive().strip())) + received = sock.receive() + observer.push(received) + packet_str = observer.pop_gc_packet_str() + if packet_str: + print(" RR: "+packet_str.strip()) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index 5405598..350e745 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -33,7 +33,7 @@ from examples_settings import Settings # ^ adds parent of module to sys.path, so openlcb imports *after* this -from openlcb.canbus.tcpsocket import TcpSocket +from openlcb.tcplink.tcpsocket import TcpSocket from examples.tkexamples.cdiform import CDIForm from openlcb import emit_cast, formatted_ex diff --git a/openlcb/__init__.py b/openlcb/__init__.py index 9b2f1c3..8e8d4ea 100644 --- a/openlcb/__init__.py +++ b/openlcb/__init__.py @@ -2,6 +2,7 @@ import time from collections import OrderedDict +from typing import Union hex_pairs_rc = re.compile(r"^([0-9A-Fa-f]{2})+$") @@ -64,4 +65,4 @@ def precise_sleep(seconds, start=None): def formatted_ex(ex): - return "{}: {}".format(type(ex).__name__, ex) \ No newline at end of file + return "{}: {}".format(type(ex).__name__, ex) diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 5fb2d52..7c4a73b 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -44,6 +44,7 @@ def __init__(self, localNodeID): # a NodeID self.link = None self._frameCount = 0 self._reserveAliasCollisions = 0 + self._errorCount = 0 self.aliasToNodeID = {} self.nodeIdToAlias = {} self.accumulator = {} @@ -114,7 +115,7 @@ def receiveListener(self, frame): ControlFrame.EIR1, ControlFrame.EIR2, ControlFrame.EIR3): - pass # ignored upon receipt + self._errorCount += 1 elif control_frame == ControlFrame.Data: # NOTE: We may process other bits of frame.header # that were stripped from control_frame @@ -683,11 +684,12 @@ def sendAliasAllocationSequence(self): responseCount = self._frameCount - previousFrameCount if responseCount < 1: logger.warning( - "sendAliasAllocationSequence is blocking the receive thread" - " or the network is taking too long to respond (200ms is LCC" - " standard time for all node reservation replies." - " If there any other nodes, this is an error and this method" - " should *not* continue sending Reserve ID (RID) frame)...") + "sendAliasAllocationSequence may be blocking the receive" + " thread or the network is taking too long to respond" + " (200ms is LCC standard time for all nodes to respond to" + " reservation request. If there any other nodes, this is" + " an error and this method should *not* continue sending" + " Reserve ID (RID) frame)...") if self._reserveAliasCollisions > previousCollisions: # processCollision will increment the non-unique alias try # defineAndReserveAlias again (so stop before completing @@ -698,16 +700,16 @@ def sendAliasAllocationSequence(self): " & restarts sequence)." .format(previousLocalAliasSeed)) return False - while responseCount < 1: + if responseCount < 1: logger.warning( "Continuing to send Reservation (RID) anyway" "--no response, so assuming alias seed {} is unique" " (If there are any other nodes on the network then" " a thread, the call order, or network connection failed!)." .format(self.localAliasSeed)) - precise_sleep(.2) # wait for another collision wait term - responseCount = self._frameCount - previousFrameCount - # NOTE: If we were to stop here, then we would have to + # precise_sleep(.2) # wait for another collision wait term + # responseCount = self._frameCount - previousFrameCount + # NOTE: If we were to loop here, then we would have to # trigger defineAndReserveAlias again, since # processCollision usually does that, but collision didn't # occur. However, stopping here would not be valid anyway diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index d533b7f..1cac89c 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -13,6 +13,9 @@ from openlcb.canbus.canphysicallayer import CanPhysicalLayer from openlcb.canbus.canframe import CanFrame +GC_START_BYTE = 0x3a # : +GC_END_BYTE = 0x3b # ; + class CanPhysicalLayerGridConnect(CanPhysicalLayer): """CAN physical layer subclass for GridConnect @@ -53,12 +56,12 @@ def receiveString(self, string): def receiveChars(self, data): self.inboundBuffer += data processedCount = 0 - if 0x3B in self.inboundBuffer: + if GC_END_BYTE in self.inboundBuffer: # ^ ';' ends message so we have at least one (CR/LF not required) # found end, now find start of that same message, earlier in buffer for index in range(0, len(self.inboundBuffer)): outData = bytearray() - if 0x3B not in self.inboundBuffer[index:]: + if GC_END_BYTE not in self.inboundBuffer[index:]: break if self.inboundBuffer[index] == 0x3A: # ':' starts message # now start to accumulate data from entire message @@ -71,7 +74,7 @@ def receiveChars(self, data): # offset 11 might be data, might be ; processedCount = index+11 for dataItem in range(0, 8): - if self.inboundBuffer[index+11+2*dataItem] == 0x3B: + if self.inboundBuffer[index+11+2*dataItem] == GC_END_BYTE: break # two characters are data byte1 = self.inboundBuffer[index+11+2*dataItem] diff --git a/openlcb/canbus/gridconnectobserver.py b/openlcb/canbus/gridconnectobserver.py new file mode 100644 index 0000000..937bebd --- /dev/null +++ b/openlcb/canbus/gridconnectobserver.py @@ -0,0 +1,32 @@ + +from typing import Union + +from openlcb.canbus.canphysicallayergridconnect import GC_END_BYTE + + +class GridConnectObserver: + def __init__(self): + self._buffer = bytearray() + self._gc_packets = [] + + def push(self, data: Union[bytearray, bytes]): + self._buffer += data + last_idx = self._buffer.find(GC_END_BYTE) + if last_idx < 0: # no ";", packet not yet complete + return + packet_bytes = self._buffer[:last_idx+1] # +1 to keep ";" + self._buffer = self._buffer[last_idx+1:] # +1 to discard ";" + self._onGridConnectFrame(packet_bytes) + + def pop_gc_packet_str(self) -> Union[str, None]: + if not self._gc_packets: + return None + return self.pop_gc_packet().decode("utf-8") + + def pop_gc_packet(self) -> Union[bytearray, None]: + if not self._gc_packets: + return None + return self._gc_packets.pop(0) + + def _onGridConnectFrame(self, data: bytes) -> None: + self._gc_packets.append(data) diff --git a/openlcb/canbus/seriallink.py b/openlcb/canbus/seriallink.py index 65c31a8..6667883 100644 --- a/openlcb/canbus/seriallink.py +++ b/openlcb/canbus/seriallink.py @@ -4,18 +4,33 @@ ''' import serial +from logging import getLogger +from typing import Union + +from openlcb.canbus.canphysicallayergridconnect import GC_END_BYTE +from openlcb.portinterface import PortInterface + +logger = getLogger(__name__) + MSGLEN = 35 -class SerialLink: +class SerialLink(PortInterface): """simple serial input for string send and receive""" def __init__(self): + super(SerialLink, self).__init__() + + def _settimeout(self, seconds): + logger.warning("settimeout is not implemented for SerialLink") pass - def connect(self, device, baudrate=230400): + def _connect(self, _, device, baudrate=230400): """Connect to a serial port. Args: + _ (NoneType): Host (Unused since host is always local + machine for serial; placeholder for + compatibility with the interface). device (str): A string that identifies a serial port for the serial.Serial constructor. baudrate (int, optional): Desired serial speed. Defaults to @@ -24,48 +39,43 @@ def connect(self, device, baudrate=230400): self.port = serial.Serial(device, baudrate) self.port.reset_input_buffer() # drop anything that's just sitting there already # noqa: E501 - def send(self, string): - """send a single string + def _send(self, msg: Union[bytes, bytearray]): + """send bytes Args: - string (str): Any string. + data (Union[bytes,bytearray]): data such as a GridConnect + string encoded as utf-8. Raises: RuntimeError: If the string couldn't be written to the port. """ - msg = string.encode('utf-8') total_sent = 0 while total_sent < len(msg[total_sent:]): sent = self.port.write(msg[total_sent:]) if sent == 0: + self.setOpen(False) raise RuntimeError("socket connection broken") total_sent = total_sent + sent - def receive(self): - '''Receive at least one GridConnect frame and return as string. - - - Guarantee: If input is valid, there will be at least one ";" - in the response. - - - This makes it nicer to display the raw data. - - - Note that the response may end with a partial frame. + def _receive(self) -> bytearray: + '''Receive data Returns: - str: A GridConnect frame as a string. + bytearray: A (usually partial) GridConnect frame ''' data = bytearray() bytes_recd = 0 while bytes_recd < MSGLEN: chunk = self.port.read(1) if chunk == b'': + self.setOpen(False) raise RuntimeError("serial connection broken") data.extend(chunk) bytes_recd = bytes_recd + len(chunk) - if 0x3B in chunk: + if GC_END_BYTE in chunk: break - return data.decode("utf-8") + return data - def close(self): + def _close(self): self.port.close() return diff --git a/openlcb/canbus/tcpsocket.py b/openlcb/canbus/tcpsocket.py deleted file mode 100644 index 6d2bfbe..0000000 --- a/openlcb/canbus/tcpsocket.py +++ /dev/null @@ -1,66 +0,0 @@ -''' -simple TCP socket input for string send and receive -expects prior setting of host and port variables -''' -# https://docs.python.org/3/howto/sockets.html -import socket -MSGLEN = 35 # longest GC frame is 31 letters; forward if getting non-GC input - - -class TcpSocket: - def __init__(self, sock=None): - if sock is None: - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - else: - self.sock = sock - - def settimeout(self, seconds): - """Set the timeout for connect and transfer. - - Args: - seconds (float): The number of seconds to wait before - a timeout error occurs. - """ - self.sock.settimeout(seconds) - - def connect(self, host, port): - self.sock.connect((host, port)) - - def send(self, string): - """Send a single string. - """ - msg = string.encode('utf-8') - 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") - total_sent = total_sent + sent - - def receive(self): - '''Receive at least one GridConnect frame and return as string. - - - Guarantee: If input is valid, there will be at least one ";" in the - response. - - - This makes it nicer to display the raw data. - - - Note that the response may end with a partial frame. - - Returns: - str: The received bytes decoded into a UTF-8 string. - ''' - data = bytearray() - bytes_recd = 0 - while bytes_recd < MSGLEN: - chunk = self.sock.recv(min(MSGLEN - bytes_recd, 1)) - if chunk == b'': - raise RuntimeError("socket connection broken") - data.extend(chunk) - bytes_recd = bytes_recd + len(chunk) - if 0x3B in chunk: - break - return data.decode("utf-8") - - def close(self): - self.sock.close() diff --git a/openlcb/cdihandler.py b/openlcb/cdihandler.py index 97f6188..f296f1e 100644 --- a/openlcb/cdihandler.py +++ b/openlcb/cdihandler.py @@ -183,7 +183,7 @@ def listen(self): print("[listen] Starting port receive loop...") self._listen_thread.start() - def _receive(self): + def _receive(self) -> bytearray: """Receive data from the port. Override this if serial/other subclass not using TCP (or better yet, make all ports including TcpSocket inherit from @@ -213,7 +213,7 @@ def _listen(self): file=sys.stderr) # print(" RR: {}".format(received.strip())) # pass to link processor - self._canPhysicalLayerGridConnect.receiveString(received) + self._canPhysicalLayerGridConnect.receiveChars(received) # ^ will trigger self._printFrame if that was added # via registerFrameReceivedListener during connect. precise_sleep(.01) # let processor sleep briefly before read @@ -231,7 +231,7 @@ def _listen(self): except RuntimeError as ex: # If _port is a TcpSocket: - # May be raised by canbus.tcpsocket.TCPSocket.receive + # May be raised by tcplink.tcpsocket.TCPSocket.receive # manually. # - Usually "socket connection broken" due to no more # bytes to read, but ok if "\0" terminator was reached. @@ -301,7 +301,7 @@ def callback(event_d): def _sendToPort(self, string): # print(" SR: {}".format(string.strip())) - self._port.send(string) + self._port.sendString(string) # def _printFrame(self, frame): # # print(" RL: {}".format(frame)) diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index 4a8cca1..8d9328c 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -40,4 +40,3 @@ def registerMessageReceivedListener(self, listener): def fireListeners(self, msg): for listener in self.listeners: listener(msg) - diff --git a/openlcb/portinterface.py b/openlcb/portinterface.py new file mode 100644 index 0000000..b33aa89 --- /dev/null +++ b/openlcb/portinterface.py @@ -0,0 +1,156 @@ +"""Thread-safe port interface. + +In threading scenarios, trying to send and receive at the same time +results in undefined behavior (at OS-level serial or socket +implementation). + +Therefore, all port implementations must inherit this if threads are used, +and threads must be used in a typical implementation +- Unless: alias reservation sequence is split into separate events + and receiveChars will run, in a non-blocking manner, before each send + call in defineAndReserveAlias. +""" +from logging import getLogger +from typing import Union + +logger = getLogger(__name__) + + +class PortInterface: + """Manage send and receive in a thread-safe way. + + In each subclass: + - The private methods must be overridden. + - Set self._open to False on any exception that indicates a + disconnect. + - The public methods must *not* be overridden unless there are + similar measures taken to prevent send and recv from occurring at + once (on different threads), which would cause undefined behavior + (in OS-level implementation of serial port or socket). + """ + ports = [] + + def __init__(self): + """This must run for each subclass, such as using super""" + self._busy_message = None + self._open = False + self._onReadyToSend = None + self._onReadyToReceive = None + + def busy(self) -> bool: + return self._busy_message is not None + + def _setBusy(self, caller): + self.assertNotBusy(caller) + self._busy_message = caller + + def _unsetBusy(self, caller): + if caller != self._busy_message: + raise RuntimeError( + "Untracked {} ended during {}" + .format(caller, self._busy_message)) + self._busy_message = None + + def assertNotBusy(self, caller): + if self._busy_message: + raise RuntimeError( + "{} was called during {}." + " Check busy() first or setListeners" + " and wait for {} ready." + .format(caller, self._busy_message, caller)) + + def setListeners(self, onReadyToSend, onReadyToReceive): + self._onReadyToReceive = onReadyToReceive + self._onReadyToSend = onReadyToSend + + def _settimeout(self, seconds): + """Abstract method. Return: implementation-specific or None.""" + raise NotImplementedError("Subclass must implement this.") + + def settimeout(self, seconds): + return self._settimeout(seconds) + + def _connect(self, host, port): + """Abstract interface. Return: implementation-specific or None + See connect for details. + raise exception on failure to prevent self._open = True. + """ + raise NotImplementedError("Subclass must implement this.") + + def connect(self, host, port): + """Connect to a port. + + Args: + host (str): hostname/IP, or None for local such as in serial + implementation. + port (Union[int, str]): Port number (int for network + implementation, str for serial implementation, such as + "COM1" or other on Windows or "/dev/" followed by port + path on other operating systems) + """ + self._setBusy("connect") + result = self._connect(host, port) + self.setOpen(True) + self._unsetBusy("connect") + return result # may be implementation-specific + + def _send(self, data: Union[bytes, bytearray]) -> None: + """Abstract method. Return: implementation-specific or None""" + raise NotImplementedError("Subclass must implement this.") + + def send(self, data: Union[bytes, bytearray]) -> None: + self._setBusy("send") + self._busy_message = "send" + try: + self._send(data) + finally: + self._unsetBusy("send") + if self._onReadyToReceive: + self._onReadyToReceive() + + def _receive(self) -> bytearray: + """Abstract method. Return (bytes): data""" + raise NotImplementedError("Subclass must implement this.") + + def receive(self) -> bytearray: + self._setBusy("receive") + self._busy_message = "receive" + try: + result = self._receive() + finally: + self._unsetBusy("send") + if self._onReadyToSend: + self._onReadyToSend() + return result + + def _close(self) -> None: + """Abstract method. Return: implementation-specific or None""" + raise NotImplementedError("Subclass must implement this.") + + def setOpen(self, is_open): + if self._open != is_open: + logger.warning( + "{} open state changing from {} to {}" + .format(type(self).__name__, self._open, is_open)) + self._open = is_open + else: + logger.warning("Port open state already {}".format(self._open)) + + def close(self) -> None: + return self._close() + + def receiveString(self): + '''Receive (usually partial) GridConnect frame and return as string. + + Returns: + str: The received bytes decoded into a UTF-8 string. + ''' + data = self.receive() + # Use receive (uses required semaphores) not _receive (not thread safe) + return data.decode("utf-8") + + def sendString(self, string): + """Send a single string. + """ + self.send(string.encode('utf-8')) + # Use send (uses required semaphores) not _send (not thread safe) diff --git a/openlcb/tcplink/tcpsocket.py b/openlcb/tcplink/tcpsocket.py index 3cf9d56..383c201 100644 --- a/openlcb/tcplink/tcpsocket.py +++ b/openlcb/tcplink/tcpsocket.py @@ -5,9 +5,26 @@ # https://docs.python.org/3/howto/sockets.html import socket +from typing import Union -class TcpSocket: +from openlcb.portinterface import PortInterface + + +class TcpSocket(PortInterface): + """TCP socket implementation + + NOTE: This will probably not work in a browser (compiled web + assembly) since most/all socket features are not in + WebAssembly System Interface (WASI): + + + Args: + sock (socket.socket, optional): A socket such as from Python's + builtin socket module. Defaults to a new socket.socket + instance. + """ def __init__(self, sock=None): + super(TcpSocket, self).__init__() if sock is None: self.sock = socket.socket( socket.AF_INET, @@ -16,7 +33,7 @@ def __init__(self, sock=None): else: self.sock = sock - def settimeout(self, seconds): + def _settimeout(self, seconds): """Set the timeout for connect and transfer. Args: @@ -25,32 +42,41 @@ def settimeout(self, seconds): """ self.sock.settimeout(seconds) - def connect(self, host, port): + def _connect(self, host, port): self.sock.connect((host, port)) - def send(self, data): - '''Send a single message, provided as an [int] - ''' - msg = bytes(data) + def _send(self, data: Union[bytes, bytearray]): + """Send a single message (bytes) + Args: + data (Union[bytes, bytearray]): (list[int] is equivalent + but not explicitly valid in int range) + """ + # assert isinstance(data, (bytes, bytearray)) # See type hint instead total_sent = 0 - while total_sent < len(msg[total_sent:]): - sent = self.sock.send(msg[total_sent:]) + while total_sent < len(data[total_sent:]): + sent = self.sock.send(data[total_sent:]) if sent == 0: + self.setOpen(False) raise RuntimeError("socket connection broken") total_sent = total_sent + sent - def receive(self): + def _receive(self) -> bytes: '''Receive one or more bytes and return as an [int] Blocks until at least one byte is received, but may return more. Returns: list(int): one or more bytes, converted to a list of ints. ''' - chunk = self.sock.recv(128) - if chunk == b'': + data = self.sock.recv(128) + # ^ For block/fail scenarios (based on options previously set) see + # + # as cited at + # + if data == b'': + self.setOpen(False) raise RuntimeError("socket connection broken") - return list(chunk) # convert from bytes + return data - def close(self): + def _close(self): self.sock.close() - return + return None diff --git a/python-openlcb.code-workspace b/python-openlcb.code-workspace index bb96b47..092d5c1 100644 --- a/python-openlcb.code-workspace +++ b/python-openlcb.code-workspace @@ -61,6 +61,7 @@ "pady", "physicallayer", "Poikilos", + "portinterface", "pyproject", "pyserial", "servicetype", @@ -72,6 +73,7 @@ "tkexamples", "unformatting", "usbmodem", + "WASI", "winnative", "zeroconf" ] diff --git a/tests/test_canphysicallayergridconnect.py b/tests/test_canphysicallayergridconnect.py index 2f0c81a..46a9d23 100644 --- a/tests/test_canphysicallayergridconnect.py +++ b/tests/test_canphysicallayergridconnect.py @@ -1,6 +1,7 @@ import unittest from openlcb.canbus.canphysicallayergridconnect import ( + GC_END_BYTE, CanPhysicalLayerGridConnect, ) from openlcb.canbus.canframe import CanFrame @@ -41,7 +42,7 @@ def testOneFrameReceivedExactlyHeaderOnly(self): gc.registerFrameReceivedListener(self.receiveListener) bytes = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, - 0x4e, 0x3b, 0x0a]) # :X19490365N; + 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n gc.receiveChars(bytes) @@ -54,7 +55,7 @@ def testOneFrameReceivedExactlyWithData(self): 0x3a, 0x58, 0x31, 0x39, 0x31, 0x42, 0x30, 0x33, 0x36, 0x35, 0x4e, 0x30, 0x32, 0x30, 0x31, 0x31, 0x32, 0x46, 0x45, 0x30, 0x35, 0x36, - 0x43, 0x3b]) + 0x43, GC_END_BYTE]) # :X19170365N020112FE056C; gc.receiveChars(bytes) @@ -70,7 +71,7 @@ def testOneFrameReceivedHeaderOnlyTwice(self): gc.registerFrameReceivedListener(self.receiveListener) bytes = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, - 0x4e, 0x3b, 0x0a]) # :X19490365N; + 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n gc.receiveChars(bytes+bytes) @@ -84,7 +85,7 @@ def testOneFrameReceivedHeaderOnlyPlusPartOfAnother(self): gc.registerFrameReceivedListener(self.receiveListener) bytes = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, - 0x35, 0x4e, 0x3b, 0x0a, # :X19490365N; + 0x35, 0x4e, GC_END_BYTE, 0x0a, # :X19490365N;\n 0x3a, 0x58]) gc.receiveChars(bytes) @@ -93,7 +94,7 @@ def testOneFrameReceivedHeaderOnlyPlusPartOfAnother(self): bytes = bytearray([ 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, - 0x36, 0x35, 0x4e, 0x3b, 0x0a]) + 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) gc.receiveChars(bytes) self.assertEqual(self.receivedFrames[1], @@ -111,7 +112,7 @@ def testOneFrameReceivedInTwoChunks(self): bytes2 = bytearray([ 0x32, 0x30, 0x31, 0x31, 0x32, 0x46, 0x45, 0x30, 0x35, 0x36, - 0x43, 0x3b]) + 0x43, GC_END_BYTE]) gc.receiveChars(bytes2) self.assertEqual( @@ -125,8 +126,8 @@ def testSequence(self): gc.registerFrameReceivedListener(self.receiveListener) bytes = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, - 0x36, 0x35, 0x4e, 0x3b, 0x0a]) - # :X19490365N; + 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) + # :X19490365N;\n gc.receiveChars(bytes) From 1365ad57f4c5811cb35068a7142455a9f8ad9daf Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 3 Apr 2025 14:36:48 -0400 Subject: [PATCH 33/99] Add tests for precise_sleep and formatted_ex functions added in recent commits. Add some type hints. Remove lint (and add some reasonable per-line lint ignores). --- openlcb/__init__.py | 22 ++++++---- openlcb/canbus/canphysicallayergridconnect.py | 2 +- openlcb/memoryservice.py | 6 ++- tests/test_canphysicallayergridconnect.py | 5 ++- tests/test_openlcb.py | 41 ++++++++++++++++--- 5 files changed, 60 insertions(+), 16 deletions(-) diff --git a/openlcb/__init__.py b/openlcb/__init__.py index 8e8d4ea..6c64d7f 100644 --- a/openlcb/__init__.py +++ b/openlcb/__init__.py @@ -2,7 +2,10 @@ import time from collections import OrderedDict -from typing import Union +from typing import ( + List, # in case list doesn't support `[` in this Python version + Union, # in case `|` doesn't support 'type' in this Python version +) hex_pairs_rc = re.compile(r"^([0-9A-Fa-f]{2})+$") @@ -10,7 +13,7 @@ # +: at least one match plus 0 or more additional matches -def only_hex_pairs(value): +def only_hex_pairs(value: str) -> bool: """Check if string contains only machine-readable hex pairs. See openlcb.conventions submodule for LCC ID dot notation functions (less restrictive). @@ -18,7 +21,7 @@ def only_hex_pairs(value): return hex_pairs_rc.fullmatch(value) -def emit_cast(value): +def emit_cast(value) -> str: """Get type and value, such as for debug output.""" repr_str = repr(value) if repr_str.startswith(type(value).__name__): @@ -26,11 +29,16 @@ def emit_cast(value): return "{}({})".format(type(value).__name__, repr_str) -def list_type_names(values): +def list_type_names(values) -> List[str]: """Get the type of several values, such as for debug output. Args: values (Union[list,tuple,dict,OrderedDict]): A collection where each element's type is to be analyzed. + + Raises: + TypeError: If how to traverse the iterator is unknown (the type + of `values` is not implemented). + Returns: list[str]: A list where each element is a type name. If values argument is dict-like, each element is formatted as @@ -39,13 +47,13 @@ def list_type_names(values): if isinstance(values, (list, tuple)): return [type(value).__name__ for value in values] if isinstance(values, (dict, OrderedDict)): - return ["{}: {}".format(k, type(v).__name__) for k, v in values.items()] + return ["{}: {}".format(k, type(v).__name__) for k, v in values.items()] # noqa: E501 raise TypeError("list_type_names is only implemented for" " list, tuple, dict, and OrderedDict, but got a(n) {}" .format(type(values).__name__)) -def precise_sleep(seconds, start=None): +def precise_sleep(seconds: Union[float, int], start: float = None) -> None: """Wait for a precise number of seconds (precise to hundredths approximately, depending on accuracy of platform's sleep). Since time.sleep(seconds) is generally not @@ -64,5 +72,5 @@ def precise_sleep(seconds, start=None): time.sleep(.01) -def formatted_ex(ex): +def formatted_ex(ex) -> str: return "{}: {}".format(type(ex).__name__, ex) diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index 1cac89c..85f9f58 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -74,7 +74,7 @@ def receiveChars(self, data): # offset 11 might be data, might be ; processedCount = index+11 for dataItem in range(0, 8): - if self.inboundBuffer[index+11+2*dataItem] == GC_END_BYTE: + if self.inboundBuffer[index+11+2*dataItem] == GC_END_BYTE: # noqa: E501 break # two characters are data byte1 = self.inboundBuffer[index+11+2*dataItem] diff --git a/openlcb/memoryservice.py b/openlcb/memoryservice.py index 2fe324c..5fb06a7 100644 --- a/openlcb/memoryservice.py +++ b/openlcb/memoryservice.py @@ -327,7 +327,11 @@ def requestSpaceLength(self, space, nodeID, callback): # send request dgReqMemo = DatagramWriteMemo( nodeID, - bytearray([DatagramService.ProtocolID.MemoryOperation.value, 0x84, space]) + bytearray([ + DatagramService.ProtocolID.MemoryOperation.value, + 0x84, + space + ]) ) self.service.sendDatagram(dgReqMemo) diff --git a/tests/test_canphysicallayergridconnect.py b/tests/test_canphysicallayergridconnect.py index 46a9d23..4f3c858 100644 --- a/tests/test_canphysicallayergridconnect.py +++ b/tests/test_canphysicallayergridconnect.py @@ -46,7 +46,10 @@ def testOneFrameReceivedExactlyHeaderOnly(self): gc.receiveChars(bytes) - self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) + self.assertEqual( + self.receivedFrames[0], + CanFrame(0x19490365, bytearray()) + ) def testOneFrameReceivedExactlyWithData(self): gc = CanPhysicalLayerGridConnect(self.captureString) diff --git a/tests/test_openlcb.py b/tests/test_openlcb.py index aa806ae..3270d84 100644 --- a/tests/test_openlcb.py +++ b/tests/test_openlcb.py @@ -1,5 +1,6 @@ import os import sys +import time import unittest from logging import getLogger @@ -16,8 +17,12 @@ " since test running from repo but could not find openlcb in {}." .format(repr(REPO_DIR))) -from openlcb import ( +import openlcb # noqa: E402 + +# for brevity: +from openlcb import ( # noqa: E402 emit_cast, + formatted_ex, list_type_names, only_hex_pairs, ) @@ -29,12 +34,12 @@ def test_only_hex_pairs(self): 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("02.01.57.00.04.9C")) # contains separator # noqa:E501 + # ^ For the positive test (& allowing elements not zero-padded) see test_conventions.py # noqa:E501 + self.assertFalse(only_hex_pairs("02015700049C.")) # contains end character # noqa:E501 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 + self.assertFalse(only_hex_pairs("_02015700049C")) # contains start character # noqa:E501 + self.assertFalse(only_hex_pairs("org_product_02015700049C")) # service name not split # noqa:E501 def test_list_type_names(self): self.assertEqual(list_type_names({"a": 1, "b": "B"}), @@ -60,6 +65,30 @@ def test_list_type_names_fail(self): def test_emit_cast(self): self.assertEqual(emit_cast(1), "int(1)") + def test_precise_sleep(self): + start = time.perf_counter() + openlcb.precise_sleep(0.2) + # NOTE: Using .3 in both assertions below only asserts accuracy + # down to 100ms increments (when using the values .2 then .1), + # though OS-level calls (used internally in precise_sleep) are + # probably far more accurate (though that may depend on the OS + # and scenario). + self.assertLess( + time.perf_counter() - start, + .3 + ) + openlcb.precise_sleep(0.1) + self.assertGreaterEqual( + time.perf_counter() - start, + .3 + ) + + def test_formatted_ex(self): + self.assertEqual( + formatted_ex(ValueError("hello")), + "ValueError: hello" + ) + if __name__ == '__main__': unittest.main() From 1babdf0078bf9f1fe38bdccd6537ce46fd9af57e Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 3 Apr 2025 14:38:50 -0400 Subject: [PATCH 34/99] Add some type hints. Make GridConnectObserver more like Java Scanner to be more conventional. Add related tests. --- examples/example_cdi_access.py | 5 +- examples/example_datagram_transfer.py | 4 +- examples/example_frame_interface.py | 4 +- examples/example_memory_length_query.py | 4 +- examples/example_memory_transfer.py | 4 +- examples/example_message_interface.py | 4 +- examples/example_node_implementation.py | 4 +- examples/example_remote_nodes.py | 4 +- examples/example_string_interface.py | 4 +- examples/example_string_serial_interface.py | 4 +- openlcb/canbus/gridconnectobserver.py | 30 +----- openlcb/scanner.py | 102 ++++++++++++++++++++ tests/test_scanner.py | 52 ++++++++++ 13 files changed, 177 insertions(+), 48 deletions(-) create mode 100644 openlcb/scanner.py create mode 100644 tests/test_scanner.py diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index 4b3fe4a..634d77f 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -273,9 +273,8 @@ def memoryRead(): received = sock.receive() if settings['trace']: observer.push(received) - packet_str = observer.pop_gc_packet_str() - if packet_str: - pass + if observer.hasNext(): + packet_str = observer.next() # print(" RR: "+packet_str.strip()) # ^ commented since MyHandler shows parsed XML fields instead # pass to link processor diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index 3c5e670..518971e 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -127,8 +127,8 @@ def datagramWrite(): received = sock.receive() if settings['trace']: observer.push(received) - packet_str = observer.pop_gc_packet_str() - if packet_str: + if observer.hasNext(): + packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor canPhysicalLayerGridConnect.receiveChars(received) diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index 1d1baba..7fb13d4 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -64,8 +64,8 @@ def printFrame(frame): received = sock.receive() if settings['trace']: observer.push(received) - packet_str = observer.pop_gc_packet_str() - if packet_str: + if observer.hasNext(): + packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor canPhysicalLayerGridConnect.receiveChars(received) diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index bc29e57..ccfb212 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -146,8 +146,8 @@ def memoryRequest(): received = sock.receive() if settings['trace']: observer.push(received) - packet_str = observer.pop_gc_packet_str() - if packet_str: + if observer.hasNext(): + packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor canPhysicalLayerGridConnect.receiveChars(received) diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index 9c41645..ae7afa0 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -144,8 +144,8 @@ def memoryRead(): received = sock.receive() if settings['trace']: observer.push(received) - packet_str = observer.pop_gc_packet_str() - if packet_str: + if observer.hasNext(): + packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor canPhysicalLayerGridConnect.receiveChars(received) diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index 379e950..b6a5055 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -85,8 +85,8 @@ def printMessage(msg): received = sock.receive() if settings['trace']: observer.push(received) - packet_str = observer.pop_gc_packet_str() - if packet_str: + if observer.hasNext(): + packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor canPhysicalLayerGridConnect.receiveChars(received) diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index cf140ba..7d144c4 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -159,8 +159,8 @@ def displayOtherNodeIds(message) : received = sock.receive() if settings['trace']: observer.push(received) - packet_str = observer.pop_gc_packet_str() - if packet_str: + if observer.hasNext(): + packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor canPhysicalLayerGridConnect.receiveChars(received) diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index baf3504..2cabd7f 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -116,8 +116,8 @@ def receiveLoop() : received = sock.receive() if settings['trace']: observer.push(received) - packet_str = observer.pop_gc_packet_str() - if packet_str: + if observer.hasNext(): + packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor canPhysicalLayerGridConnect.receiveChars(received) diff --git a/examples/example_string_interface.py b/examples/example_string_interface.py index bc972a2..9c70694 100644 --- a/examples/example_string_interface.py +++ b/examples/example_string_interface.py @@ -45,6 +45,6 @@ while True: # have to kill this manually received = sock.receive() observer.push(received) - packet_str = observer.pop_gc_packet_str() - if packet_str: + if observer.hasNext(): + packet_str = observer.next() print(" RR: "+packet_str.strip()) diff --git a/examples/example_string_serial_interface.py b/examples/example_string_serial_interface.py index 0fab227..acdc441 100644 --- a/examples/example_string_serial_interface.py +++ b/examples/example_string_serial_interface.py @@ -44,6 +44,6 @@ while True: # have to kill this manually received = sock.receive() observer.push(received) - packet_str = observer.pop_gc_packet_str() - if packet_str: + if observer.hasNext(): + packet_str = observer.next() print(" RR: "+packet_str.strip()) diff --git a/openlcb/canbus/gridconnectobserver.py b/openlcb/canbus/gridconnectobserver.py index 937bebd..e69df95 100644 --- a/openlcb/canbus/gridconnectobserver.py +++ b/openlcb/canbus/gridconnectobserver.py @@ -1,32 +1,8 @@ -from typing import Union - from openlcb.canbus.canphysicallayergridconnect import GC_END_BYTE +from openlcb.scanner import Scanner -class GridConnectObserver: +class GridConnectObserver(Scanner): def __init__(self): - self._buffer = bytearray() - self._gc_packets = [] - - def push(self, data: Union[bytearray, bytes]): - self._buffer += data - last_idx = self._buffer.find(GC_END_BYTE) - if last_idx < 0: # no ";", packet not yet complete - return - packet_bytes = self._buffer[:last_idx+1] # +1 to keep ";" - self._buffer = self._buffer[last_idx+1:] # +1 to discard ";" - self._onGridConnectFrame(packet_bytes) - - def pop_gc_packet_str(self) -> Union[str, None]: - if not self._gc_packets: - return None - return self.pop_gc_packet().decode("utf-8") - - def pop_gc_packet(self) -> Union[bytearray, None]: - if not self._gc_packets: - return None - return self._gc_packets.pop(0) - - def _onGridConnectFrame(self, data: bytes) -> None: - self._gc_packets.append(data) + Scanner.__init__(self, delimiter=GC_END_BYTE) diff --git a/openlcb/scanner.py b/openlcb/scanner.py new file mode 100644 index 0000000..37d29e3 --- /dev/null +++ b/openlcb/scanner.py @@ -0,0 +1,102 @@ + +from typing import Union +from openlcb import emit_cast + + +class Scanner: + """Collect bytes and check for a token + (Similar to Scanner class in Java) + + Attributes: + EOF: If delimiter is set to this, then regardless of _buffer + type if len(_buffer) > 0 then get all data (and trigger + _onHasNext on every push). Mimic Java behavior where + "\\Z" can be added as the delimiter for EOF. + """ + EOF = "\\Z" # must *not* be a valid byte (special case, not searched) + + def __init__(self, delimiter=EOF): + self._delimiter = delimiter + self._buffer = bytearray() + + def push(self, data: Union[bytearray, bytes, int]): + if isinstance(data, int): + self._buffer.append(data) + else: + self._buffer += data + if self._delimiter == Scanner.EOF: + self._onHasNext() + return + self.assertDelimiterType() + last_idx = self._buffer.find(self._delimiter) + if last_idx < 0: # no ";", packet not yet complete + return + self._onHasNext() + + def nextByte(self): + if not self._buffer: + raise EOFError("No more bytes (_buffer={})" + .format(emit_cast(self._buffer))) + if not isinstance(self._buffer, (bytes, bytearray)): + raise TypeError("Buffer is {} (nextByte is for bytes/bytearray)" + .format(type(self._buffer).__name__)) + return self._buffer.pop(0) + + def hasNextByte(self): + return True if self._buffer else False + + def hasNext(self): + if self._delimiter == Scanner.EOF: + return self.hasNextByte() + return self._delimiter in self._buffer + + def next(self) -> str: + self.assertDelimiterType() + return self.nextBytes().decode("utf-8") + + def assertDelimiterType(self): + """Assert that delimiter is correct type for _buffer.find arg, + or is Scanner.EOF which does not trigger find. + """ + if self._delimiter == Scanner.EOF: + return # OK since EOF doesn't trigger find in _buffer + if isinstance(self._buffer, (bytes, bytearray)): + assert isinstance(self._delimiter, (int, bytes, bytearray)) + else: + assert isinstance( + self._delimiter, + (type(self._buffer[0]), type(self._buffer[0:1])) + ) + + def nextBytes(self) -> bytearray: + if not self._buffer: + raise EOFError( + "There are no bytes in the buffer." + " Check hasNext first or handle this exception" + " in client code.") + assert isinstance(self._buffer, (bytes, bytearray)) + if self._delimiter == Scanner.EOF: + result = self._buffer + self._buffer = type(self._buffer)() + return result + self.assertDelimiterType() + last_idx = self._buffer.find(self._delimiter) + if last_idx < 0: # no ";", packet not yet complete + raise EOFError( + "Delimiter not found before EOF." + " Check hasNext first or handle this exception" + " in client code.") + packet_bytes = self._buffer[:last_idx+1] # +1 to keep ";" + self._buffer = self._buffer[last_idx+1:] # +1 to discard ";" + return packet_bytes + + def _onHasNext(self) -> None: + """abstract handler (occurs immediately on push) + If overridden/polyfilled, this is the soonest possible time to + call next (It is guaranteed to not throw EOFError at this + point, barring threads incorrectly calling next after this + method starts but before it ends, and as long as you handle + it right away and don't allow push to trigger another call + before the implementation of this calls next). + """ + pass # next_str = self.next diff --git a/tests/test_scanner.py b/tests/test_scanner.py new file mode 100644 index 0000000..1bba6df --- /dev/null +++ b/tests/test_scanner.py @@ -0,0 +1,52 @@ +import unittest + +from openlcb.canbus.canphysicallayergridconnect import GC_END_BYTE +from openlcb.canbus.gridconnectobserver import GridConnectObserver +from openlcb.scanner import Scanner + + +class TestScanner(unittest.TestCase) : + def test_scanner(self): + bytes = bytearray([ + 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, + 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n + scanner = Scanner() + self.assertFalse(scanner.hasNext()) + self.assertFalse(scanner.hasNextByte()) + scanner.push(bytes[0]) + self.assertTrue(scanner.hasNext()) # True since default is EOF + self.assertTrue(scanner.hasNextByte()) + self.assertTrue(scanner.hasNextByte()) # make sure not mutated + # test_gridconnectobserver covers more details + # of Scanner since Scanner is the superclass + # and defines all behaviors other than _delimiter for now. + + def test_gridconnectobserver(self): + bytes = bytearray([ + 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, + 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n + gc_end_byte_idx = bytes.index(GC_END_BYTE) + scanner = GridConnectObserver() + self.assertFalse(scanner.hasNext()) + self.assertFalse(scanner.hasNextByte()) + scanner.push(bytes[0]) + self.assertFalse(scanner.hasNext()) # False since default is + # GC_END_BYTE in GridConnectObserver and we haven't added + # GC_END_BYTE yet (see below) + self.assertTrue(scanner.hasNextByte()) + self.assertTrue(scanner.hasNextByte()) # make sure not mutated + scanner.push(bytes[1:-2]) # all except ;\n + self.assertFalse(scanner.hasNext()) + self.assertTrue(scanner.hasNextByte()) # make sure not mutated + assert gc_end_byte_idx < len(len(bytes))-1, "test is flawed" + scanner.push(bytes[gc_end_byte_idx:gc_end_byte_idx+1]) # GC_END_BYTE + self.assertTrue(scanner.hasNext()) + data = scanner.nextBytes() + self.assertEqual(data, bytes[:gc_end_byte_idx+1]) # make sure got + # everything up to and including GC_END_BYTE + self.assertFalse(scanner.hasNext()) # make sure *was* mutated by next + self.assertEqual(len(scanner._buffer), 1) + self.assertEqual(scanner._buffer[0], bytes[-1]) # last byte is after + # delimiter, so it should remain. + + From ee96089957e435239405925bead7eda0ea53d044 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 3 Apr 2025 15:08:02 -0400 Subject: [PATCH 35/99] Fix the Scanner test data. --- openlcb/scanner.py | 10 ++++++++++ tests/test_scanner.py | 18 +++++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/openlcb/scanner.py b/openlcb/scanner.py index 37d29e3..cab73a5 100644 --- a/openlcb/scanner.py +++ b/openlcb/scanner.py @@ -1,7 +1,11 @@ +from logging import getLogger from typing import Union + from openlcb import emit_cast +logger = getLogger(__name__) + class Scanner: """Collect bytes and check for a token @@ -86,6 +90,12 @@ def nextBytes(self) -> bytearray: "Delimiter not found before EOF." " Check hasNext first or handle this exception" " in client code.") + # logger.debug("Getting {} to {} exclusive of {} in {}" + # .format(0, last_idx+1, len(self._buffer), + # emit_cast(self._buffer))) + # logger.debug("Leaving {} to {} exclusive of {}" + # .format(last_idx+1, len(self._buffer), + # len(self._buffer))) packet_bytes = self._buffer[:last_idx+1] # +1 to keep ";" self._buffer = self._buffer[last_idx+1:] # +1 to discard ";" return packet_bytes diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 1bba6df..8785671 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -26,7 +26,9 @@ def test_gridconnectobserver(self): 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n gc_end_byte_idx = bytes.index(GC_END_BYTE) + assert gc_end_byte_idx == len(bytes) - 2, "test is flawed" scanner = GridConnectObserver() + self.assertEqual(scanner._delimiter, GC_END_BYTE) # subclass' default self.assertFalse(scanner.hasNext()) self.assertFalse(scanner.hasNextByte()) scanner.push(bytes[0]) @@ -38,15 +40,21 @@ def test_gridconnectobserver(self): scanner.push(bytes[1:-2]) # all except ;\n self.assertFalse(scanner.hasNext()) self.assertTrue(scanner.hasNextByte()) # make sure not mutated - assert gc_end_byte_idx < len(len(bytes))-1, "test is flawed" + assert gc_end_byte_idx < len(bytes)-1, "test is flawed" scanner.push(bytes[gc_end_byte_idx:gc_end_byte_idx+1]) # GC_END_BYTE self.assertTrue(scanner.hasNext()) data = scanner.nextBytes() self.assertEqual(data, bytes[:gc_end_byte_idx+1]) # make sure got # everything up to and including GC_END_BYTE - self.assertFalse(scanner.hasNext()) # make sure *was* mutated by next - self.assertEqual(len(scanner._buffer), 1) + self.assertFalse(scanner.hasNextByte()) + self.assertEqual(len(scanner._buffer), 0) # we didn't yet add "\n" + scanner.push(bytes[-1]) # finally add the "\n", and this also makes + # sure int append is allowed though += (bytes or bytearray) is + # the most common usage (bytearray and bytes are equivalent to + # list[int] merely with further constraints, so allow int arg). + self.assertEqual(len(scanner._buffer), 1) # should have 1 left ("\n") + self.assertTrue(scanner.hasNextByte()) # should have 1 left ("\n") + self.assertEqual(scanner._buffer[0], b"\n"[0]) # should have 1 ("\n") + self.assertFalse(scanner.hasNext()) # False:ensure _delimiter consumed self.assertEqual(scanner._buffer[0], bytes[-1]) # last byte is after # delimiter, so it should remain. - - From 0a87a39756a196f6a8bd10043d9389672de6b8ec Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 3 Apr 2025 15:59:47 -0400 Subject: [PATCH 36/99] Move initialization out of TcpSocket constructor. Conform subclasses to interface better. --- examples/example_string_serial_interface.py | 2 +- openlcb/canbus/seriallink.py | 21 +++++++++----- openlcb/portinterface.py | 15 ++++++++-- openlcb/tcplink/tcpsocket.py | 32 ++++++++++++--------- 4 files changed, 45 insertions(+), 25 deletions(-) diff --git a/examples/example_string_serial_interface.py b/examples/example_string_serial_interface.py index acdc441..8bb94ce 100644 --- a/examples/example_string_serial_interface.py +++ b/examples/example_string_serial_interface.py @@ -28,7 +28,7 @@ sock = SerialLink() -sock.connect(settings['device']) +sock.connectLocal(settings['device']) ####################### diff --git a/openlcb/canbus/seriallink.py b/openlcb/canbus/seriallink.py index 6667883..8eb0630 100644 --- a/openlcb/canbus/seriallink.py +++ b/openlcb/canbus/seriallink.py @@ -24,20 +24,27 @@ def _settimeout(self, seconds): logger.warning("settimeout is not implemented for SerialLink") pass - def _connect(self, _, device, baudrate=230400): + def _connect(self, _, port, device=None, baudrate=230400): """Connect to a serial port. Args: _ (NoneType): Host (Unused since host is always local machine for serial; placeholder for compatibility with the interface). - device (str): A string that identifies a serial port for the + port (str): A string that identifies a serial port for the serial.Serial constructor. baudrate (int, optional): Desired serial speed. Defaults to 230400 bits per second. + device (serial.Serial): Existing hardware abstraction. + Defaults to serial.Serial(port, baudrate). """ - self.port = serial.Serial(device, baudrate) - self.port.reset_input_buffer() # drop anything that's just sitting there already # noqa: E501 + assert _ is None, "Serial ports are always on machine not {}".format(_) + # ^ Use None or connectLocal for non-network connections. + if device is None: + self._device = serial.Serial(port, baudrate) + else: + self._device = device + self._device.reset_input_buffer() # drop anything that's just sitting there already # noqa: E501 def _send(self, msg: Union[bytes, bytearray]): """send bytes @@ -51,7 +58,7 @@ def _send(self, msg: Union[bytes, bytearray]): """ total_sent = 0 while total_sent < len(msg[total_sent:]): - sent = self.port.write(msg[total_sent:]) + sent = self._device.write(msg[total_sent:]) if sent == 0: self.setOpen(False) raise RuntimeError("socket connection broken") @@ -66,7 +73,7 @@ def _receive(self) -> bytearray: data = bytearray() bytes_recd = 0 while bytes_recd < MSGLEN: - chunk = self.port.read(1) + chunk = self._device.read(1) if chunk == b'': self.setOpen(False) raise RuntimeError("serial connection broken") @@ -77,5 +84,5 @@ def _receive(self) -> bytearray: return data def _close(self): - self.port.close() + self._device.close() return diff --git a/openlcb/portinterface.py b/openlcb/portinterface.py index b33aa89..6dadbbc 100644 --- a/openlcb/portinterface.py +++ b/openlcb/portinterface.py @@ -36,6 +36,7 @@ def __init__(self): self._open = False self._onReadyToSend = None self._onReadyToReceive = None + self._device = None def busy(self) -> bool: return self._busy_message is not None @@ -70,14 +71,14 @@ def _settimeout(self, seconds): def settimeout(self, seconds): return self._settimeout(seconds) - def _connect(self, host, port): + def _connect(self, host, port, device=None): """Abstract interface. Return: implementation-specific or None See connect for details. raise exception on failure to prevent self._open = True. """ raise NotImplementedError("Subclass must implement this.") - def connect(self, host, port): + def connect(self, host, port, device=None): """Connect to a port. Args: @@ -87,13 +88,21 @@ def connect(self, host, port): implementation, str for serial implementation, such as "COM1" or other on Windows or "/dev/" followed by port path on other operating systems) + device (Union[socket.socket, serial.Serial, None]): Existing + hardware abstraction: Type depends on implementation. """ self._setBusy("connect") - result = self._connect(host, port) + result = self._connect(host, port, device=device) self.setOpen(True) self._unsetBusy("connect") return result # may be implementation-specific + def connectLocal(self, port): + """Convenience method for connecting local port such as serial + (where host is not applicable since host is this machine). + """ + self.connect(None, port) + def _send(self, data: Union[bytes, bytearray]) -> None: """Abstract method. Return: implementation-specific or None""" raise NotImplementedError("Subclass must implement this.") diff --git a/openlcb/tcplink/tcpsocket.py b/openlcb/tcplink/tcpsocket.py index 383c201..6f6862a 100644 --- a/openlcb/tcplink/tcpsocket.py +++ b/openlcb/tcplink/tcpsocket.py @@ -23,15 +23,8 @@ class TcpSocket(PortInterface): builtin socket module. Defaults to a new socket.socket instance. """ - def __init__(self, sock=None): + def __init__(self): super(TcpSocket, self).__init__() - if sock is None: - self.sock = socket.socket( - socket.AF_INET, - socket.SOCK_STREAM, - ) - else: - self.sock = sock def _settimeout(self, seconds): """Set the timeout for connect and transfer. @@ -40,10 +33,19 @@ def _settimeout(self, seconds): seconds (float): The number of seconds to wait before a timeout error occurs. """ - self.sock.settimeout(seconds) + self._device.settimeout(seconds) + + def _connect(self, host, port, device=None): + # public connect (do not overload) asserts no overlapping call + if device is None: + self._device = socket.socket( + socket.AF_INET, + socket.SOCK_STREAM, + ) + else: + self._device = device - def _connect(self, host, port): - self.sock.connect((host, port)) + self._device.connect((host, port)) def _send(self, data: Union[bytes, bytearray]): """Send a single message (bytes) @@ -51,10 +53,11 @@ def _send(self, data: Union[bytes, bytearray]): data (Union[bytes, bytearray]): (list[int] is equivalent but not explicitly valid in int range) """ + # public send (do not overload) asserts no overlapping call # assert isinstance(data, (bytes, bytearray)) # See type hint instead total_sent = 0 while total_sent < len(data[total_sent:]): - sent = self.sock.send(data[total_sent:]) + sent = self._device.send(data[total_sent:]) if sent == 0: self.setOpen(False) raise RuntimeError("socket connection broken") @@ -67,7 +70,8 @@ def _receive(self) -> bytes: Returns: list(int): one or more bytes, converted to a list of ints. ''' - data = self.sock.recv(128) + # public receive (do not overload) asserts no overlapping call + data = self._device.recv(128) # ^ For block/fail scenarios (based on options previously set) see # # as cited at @@ -78,5 +82,5 @@ def _receive(self) -> bytes: return data def _close(self): - self.sock.close() + self._device.close() return None From 741cfc6c9ed32a378f071f270649489a42f11835 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 3 Apr 2025 16:28:06 -0400 Subject: [PATCH 37/99] Rename openlcb stack's own receive* methods to push* for clarity (performs on received handling, not receive itself). --- examples/example_cdi_access.py | 2 +- examples/example_datagram_transfer.py | 2 +- examples/example_frame_interface.py | 2 +- examples/example_memory_length_query.py | 2 +- examples/example_memory_transfer.py | 2 +- examples/example_message_interface.py | 2 +- examples/example_node_implementation.py | 2 +- examples/example_remote_nodes.py | 2 +- openlcb/canbus/canphysicallayergridconnect.py | 16 ++++++++++------ openlcb/cdihandler.py | 2 +- openlcb/portinterface.py | 2 +- tests/test_canphysicallayergridconnect.py | 18 +++++++++--------- 12 files changed, 29 insertions(+), 25 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index 634d77f..c3159e7 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -278,4 +278,4 @@ def memoryRead(): # print(" RR: "+packet_str.strip()) # ^ commented since MyHandler shows parsed XML fields instead # pass to link processor - canPhysicalLayerGridConnect.receiveChars(received) + canPhysicalLayerGridConnect.pushChars(received) diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index 518971e..4baadd1 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -131,4 +131,4 @@ def datagramWrite(): packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.receiveChars(received) + canPhysicalLayerGridConnect.pushChars(received) diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index 7fb13d4..6a5aab0 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -68,4 +68,4 @@ def printFrame(frame): packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.receiveChars(received) + canPhysicalLayerGridConnect.pushChars(received) diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index ccfb212..72bbebe 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -150,4 +150,4 @@ def memoryRequest(): packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.receiveChars(received) + canPhysicalLayerGridConnect.pushChars(received) diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index ae7afa0..e537672 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -148,4 +148,4 @@ def memoryRead(): packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.receiveChars(received) + canPhysicalLayerGridConnect.pushChars(received) diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index b6a5055..0aa9ceb 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -89,4 +89,4 @@ def printMessage(msg): packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.receiveChars(received) + canPhysicalLayerGridConnect.pushChars(received) diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index 7d144c4..1108585 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -163,4 +163,4 @@ def displayOtherNodeIds(message) : packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.receiveChars(received) + canPhysicalLayerGridConnect.pushChars(received) diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index 2cabd7f..7abce6e 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -120,7 +120,7 @@ def receiveLoop() : packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.receiveChars(received) + canPhysicalLayerGridConnect.pushChars(received) import threading # noqa E402 diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index 85f9f58..05fb51c 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -44,16 +44,20 @@ def sendCanFrame(self, frame): output += ";\n" self.canSendCallback(output) - def receiveString(self, string): - '''Receive a string from the outside link to be parsed + def pushString(self, string): + '''Provide string from the outside link to be parsed Args: - string (str): A UTF-8 string to parse. + string (str): A new UTF-8 string from outside link ''' - self.receiveChars(string.encode("utf-8")) + self.pushChars(string.encode("utf-8")) - # Provide characters from the outside link to be parsed - def receiveChars(self, data): + def pushChars(self, data): + """Provide characters from the outside link to be parsed + + Args: + data (Union[bytes,bytearray]): new data from outside link + """ self.inboundBuffer += data processedCount = 0 if GC_END_BYTE in self.inboundBuffer: diff --git a/openlcb/cdihandler.py b/openlcb/cdihandler.py index f296f1e..75b7df3 100644 --- a/openlcb/cdihandler.py +++ b/openlcb/cdihandler.py @@ -213,7 +213,7 @@ def _listen(self): file=sys.stderr) # print(" RR: {}".format(received.strip())) # pass to link processor - self._canPhysicalLayerGridConnect.receiveChars(received) + self._canPhysicalLayerGridConnect.pushChars(received) # ^ will trigger self._printFrame if that was added # via registerFrameReceivedListener during connect. precise_sleep(.01) # let processor sleep briefly before read diff --git a/openlcb/portinterface.py b/openlcb/portinterface.py index 6dadbbc..aebb5bb 100644 --- a/openlcb/portinterface.py +++ b/openlcb/portinterface.py @@ -7,7 +7,7 @@ Therefore, all port implementations must inherit this if threads are used, and threads must be used in a typical implementation - Unless: alias reservation sequence is split into separate events - and receiveChars will run, in a non-blocking manner, before each send + and pushChars will run, in a non-blocking manner, before each send call in defineAndReserveAlias. """ from logging import getLogger diff --git a/tests/test_canphysicallayergridconnect.py b/tests/test_canphysicallayergridconnect.py index 4f3c858..7753bcb 100644 --- a/tests/test_canphysicallayergridconnect.py +++ b/tests/test_canphysicallayergridconnect.py @@ -44,7 +44,7 @@ def testOneFrameReceivedExactlyHeaderOnly(self): 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n - gc.receiveChars(bytes) + gc.pushChars(bytes) self.assertEqual( self.receivedFrames[0], @@ -61,7 +61,7 @@ def testOneFrameReceivedExactlyWithData(self): 0x43, GC_END_BYTE]) # :X19170365N020112FE056C; - gc.receiveChars(bytes) + gc.pushChars(bytes) self.assertEqual( self.receivedFrames[0], @@ -76,7 +76,7 @@ def testOneFrameReceivedHeaderOnlyTwice(self): 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n - gc.receiveChars(bytes+bytes) + gc.pushChars(bytes+bytes) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) @@ -90,7 +90,7 @@ def testOneFrameReceivedHeaderOnlyPlusPartOfAnother(self): 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a, # :X19490365N;\n 0x3a, 0x58]) - gc.receiveChars(bytes) + gc.pushChars(bytes) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) @@ -98,7 +98,7 @@ def testOneFrameReceivedHeaderOnlyPlusPartOfAnother(self): bytes = bytearray([ 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) - gc.receiveChars(bytes) + gc.pushChars(bytes) self.assertEqual(self.receivedFrames[1], CanFrame(0x19490365, bytearray())) @@ -111,12 +111,12 @@ def testOneFrameReceivedInTwoChunks(self): 0x4e, 0x30]) # :X19170365N020112FE056C; - gc.receiveChars(bytes1) + gc.pushChars(bytes1) bytes2 = bytearray([ 0x32, 0x30, 0x31, 0x31, 0x32, 0x46, 0x45, 0x30, 0x35, 0x36, 0x43, GC_END_BYTE]) - gc.receiveChars(bytes2) + gc.pushChars(bytes2) self.assertEqual( self.receivedFrames[0], @@ -132,14 +132,14 @@ def testSequence(self): 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n - gc.receiveChars(bytes) + gc.pushChars(bytes) self.assertEqual(len(self.receivedFrames), 1) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) self.receivedFrames = [] - gc.receiveChars(bytes) + gc.pushChars(bytes) self.assertEqual(len(self.receivedFrames), 1) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) From b7b08edf121ced44b0bfe10285c1fd04fa7dc226 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Fri, 4 Apr 2025 16:52:01 -0400 Subject: [PATCH 38/99] Fix #62: sendCanFrame adds CanFrame to deque so the thread that also does recv can process it to prevent overlapping calls which would cause undefined behavior (Has added benefit of making listeners symmetrical, as in, send accepts frame [as opposed to str] like receive listener does)--TODO: Move more init code out of constructors, and wiring code from Dispatcher to constructors, as per issue #62 architecture comment (and consider using InternalEvent instead of Message for each MTI value that is not in the MTI specification and for each ControlFrame ID that is not for CAN frame control_frame range, and have Dispatcher handle those when it handles _sends). Rename PortHandler to Dispatcher. Finish preliminary CDI tree GUI (with asynchronous loading). --- examples/example_cdi_access.py | 3 +- examples/example_datagram_transfer.py | 3 +- examples/example_frame_interface.py | 3 +- examples/example_memory_length_query.py | 3 +- examples/example_memory_transfer.py | 3 +- examples/example_message_interface.py | 3 +- examples/example_node_implementation.py | 3 +- examples/example_remote_nodes.py | 3 +- examples/examples_gui.py | 13 +- examples/tkexamples/cdiform.py | 104 +++++++++--- openlcb/canbus/canframe.py | 50 +++++- openlcb/canbus/canlink.py | 25 ++- openlcb/canbus/canphysicallayer.py | 10 +- openlcb/canbus/canphysicallayergridconnect.py | 21 ++- openlcb/canbus/controlframe.py | 5 +- openlcb/cdihandler.py | 157 +++++++++++++++--- openlcb/internalevent.py | 35 ++++ openlcb/mti.py | 5 + openlcb/physicallayer.py | 12 +- openlcb/platformextras.py | 64 +++++++ openlcb/portinterface.py | 23 ++- openlcb/tcplink/tcpsocket.py | 9 + python-openlcb.code-workspace | 4 + tests/test_platformextras.py | 49 ++++++ 24 files changed, 529 insertions(+), 81 deletions(-) create mode 100644 openlcb/internalevent.py create mode 100644 openlcb/platformextras.py create mode 100644 tests/test_platformextras.py diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index c3159e7..b10b4de 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -51,7 +51,8 @@ # " RL, SL are link interface; RM, SM are message interface") -def sendToSocket(string): +def sendToSocket(frame): + string = frame.encodeAsString() # print(" SR: {}".format(string.strip())) sock.sendString(string) diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index 4baadd1..203f75e 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -47,7 +47,8 @@ " RL, SL are link interface; RM, SM are message interface") -def sendToSocket(string): +def sendToSocket(frame): + string = frame.encodeAsString() print(" SR: "+string.strip()) sock.sendString(string) diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index 6a5aab0..df30777 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -40,7 +40,8 @@ " RL, SL are link (frame) interface") -def sendToSocket(string): +def sendToSocket(frame): + string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index 72bbebe..29bf767 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -52,7 +52,8 @@ " RL, SL are link interface; RM, SM are message interface") -def sendToSocket(string): +def sendToSocket(frame): + string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index e537672..375ee8e 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -52,7 +52,8 @@ " RL, SL are link interface; RM, SM are message interface") -def sendToSocket(string): +def sendToSocket(frame): + string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index 0aa9ceb..8434337 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -45,7 +45,8 @@ " SL are link interface; RM, SM are message interface") -def sendToSocket(string): +def sendToSocket(frame): + string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index 1108585..a8fc24c 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -58,7 +58,8 @@ " RL, SL are link interface; RM, SM are message interface") -def sendToSocket(string): +def sendToSocket(frame): + string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index 7abce6e..3762c45 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -58,7 +58,8 @@ " RL, SL are link (frame) interface") -def sendToSocket(string) : +def sendToSocket(frame) : + string = frame.encodeAsString() if settings['trace'] : print(" SR: "+string.strip()) sock.sendString(string) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index 350e745..43b6854 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -538,18 +538,18 @@ def connect_state_changed(self, event_d): implementation (called by _listen directly unless triggered by LCC Message). - In this program, this is added to PortHandler via + In this program, this is added to Dispatcher via set_connect_listener. Therefore in this program, this is triggered during _listen in - PortHandler: Connecting is actually done until + Dispatcher: Connecting is actually done until sendAliasAllocationSequence detects success and marks canLink.state to CanLink.State.Permitted (which triggers _handleMessage which calls this). - May also be directly called by _listen directly in case stopped listening (RuntimeError reading port, or other reason lower in the stack than LCC). - - PortHandler's _connect_listener attribute is a method + - Dispatcher's _connect_listener attribute is a method reference to this if set via set_connect_listener. """ # Trigger the main thread (only the main thread can access the @@ -608,6 +608,7 @@ def cdi_refresh_clicked(self): self.set_status('Set "Far node ID" first.') return print("Querying farNodeID={}".format(repr(farNodeID))) + self.set_status("Downloading CDI...") threading.Thread( target=self.cdi_form.downloadCDI, args=(farNodeID,), @@ -624,8 +625,14 @@ def get_value(self, key): def set_id_from_name(self): id = self.get_id_from_name(update_button=True) if not id: + self.set_status( + "The service name {} does not contain an LCC ID" + " (Does not follow hardware convention).") return self.fields['farNodeID'].var.set(id) + self.set_status( + "Far Node ID has been set to {} portion of service name." + .format(repr(id))) def get_id_from_name(self, update_button=False): lcc_id = id_from_tcp_service_name( diff --git a/examples/tkexamples/cdiform.py b/examples/tkexamples/cdiform.py index 9212cdd..8db3e70 100644 --- a/examples/tkexamples/cdiform.py +++ b/examples/tkexamples/cdiform.py @@ -9,13 +9,16 @@ Contributors: Poikilos """ +from collections import deque import os import sys import tkinter as tk from tkinter import ttk from logging import getLogger +from xml.etree import ElementTree as ET +from openlcb.cdihandler import element_to_dict logger = getLogger(__name__) @@ -30,7 +33,7 @@ " since test running from repo but could not find openlcb in {}." .format(repr(REPO_DIR))) try: - from openlcb.cdihandler import PortHandler + from openlcb.cdihandler import Dispatcher except ImportError as ex: print("{}: {}".format(type(ex).__name__, ex), file=sys.stderr) print("* You must run this from a venv that has openlcb installed" @@ -39,7 +42,7 @@ raise # sys.exit(1) -class CDIForm(ttk.Frame, PortHandler): +class CDIForm(ttk.Frame, Dispatcher): """A GUI frame to represent the CDI visually as a tree. Args: @@ -47,13 +50,14 @@ class CDIForm(ttk.Frame, PortHandler): attribute set. """ def __init__(self, *args, **kwargs): - PortHandler.__init__(self, *args, **kwargs) + Dispatcher.__init__(self, *args, **kwargs) ttk.Frame.__init__(self, *args, **kwargs) self._top_widgets = [] if len(args) < 1: raise ValueError("at least one argument (parent) is required") self.parent = args[0] self.root = args[0] + self.ignore_non_gui_tags = None if hasattr(self.parent, 'root'): self.root = self.parent.root self._container = self # where to put visible widgets @@ -66,16 +70,16 @@ def _gui(self, container): self._status_var = tk.StringVar(self) self._status_label = ttk.Label(container, textvariable=self._status_var) - self.grid(sticky=tk.NSEW, row=len(self._top_widgets)) + self._status_label.grid(sticky=tk.NSEW, row=len(self._top_widgets)) self._top_widgets.append(self._status_label) self._overview = ttk.Frame(container) - self.grid(sticky=tk.NSEW, row=len(self._top_widgets)) + self._overview.grid(sticky=tk.NSEW, row=len(self._top_widgets)) self._top_widgets.append(self._overview) self._treeview = ttk.Treeview(container) - self.grid(sticky=tk.NSEW, row=len(self._top_widgets)) + self._treeview.grid(sticky=tk.NSEW, row=len(self._top_widgets)) self.rowconfigure(len(self._top_widgets), weight=1) # weight=1: expand self._top_widgets.append(self._treeview) - self._populating_stack = [] # no parent when of top of Treeview + self._populating_stack = None # no parent when top of Treeview self._current_iid = 0 # id of Treeview element def clear(self): @@ -91,6 +95,8 @@ def clear(self): def downloadCDI(self, farNodeID, callback=None): self.set_status("Downloading CDI...") + self.ignore_non_gui_tags = deque() + self._populating_stack = deque() super().downloadCDI(farNodeID, callback=callback) def set_status(self, message): @@ -119,6 +125,9 @@ def on_cdi_element(self, event_d): done = event_d.get('done') error = event_d.get('error') status = event_d.get('status') + element = event_d.get('element') + if element is None: + raise ValueError("No element for tag event") show_status = None if error: show_status = error @@ -131,43 +140,79 @@ def on_cdi_element(self, event_d): if done: return if event_d.get('end'): - self.root.after(0, self._on_cdi_element_start, event_d) - else: self.root.after(0, self._on_cdi_element_end, event_d) + else: + self.root.after(0, self._on_cdi_element_start, event_d) - def _on_cdi_element_start(self, event_d): + def _on_cdi_element_end(self, event_d): + name = event_d['name'] + nameLower = name.lower() + if (self.ignore_non_gui_tags + and (nameLower == self.ignore_non_gui_tags[-1])): + print("Done ignoring {}".format(name)) + self.ignore_non_gui_tags.pop() + return if not self._populating_stack: + element = event_d.get('element') + if nameLower in ("acdi", "cdi"): + raise ValueError( + "Can't close acdi, is self-closing (no branch pushed)") + tag = None + element_d = None + if element is not None: + tag = element.tag # same as name in startElement + element_d = element_to_dict(element) + logger.error("Unexpected element_d={}".format(element_d)) raise IndexError( - "Got stray end tag in top level of XML: {}" - .format(event_d)) + "Got stray end tag in top level of XML (event_d={}," + " name={}, element_d={}, ignore_non_gui_tags={})" + .format(event_d, tag, element_d, + self.ignore_non_gui_tags)) # pop would also raise IndexError, but this message is more clear. return self._populating_stack.pop() def _populating_branch(self): if not self._populating_stack: - return "" # "" is the top of a ttk.Treeview - return self._populating_stack[-1] + return "" # "" (empty str) is magic value for top of ttk.Treeview + return self._populating_stack.pop() - def _on_cdi_element_end(self, event_d): + def _on_cdi_element_start(self, event_d): element = event_d.get('element') segment = event_d.get('segment') groups = event_d.get('groups') + prev_ignore_size = len(self.ignore_non_gui_tags) tag = element.tag if not tag: logger.warning("Ignored blank tag for event: {}".format(event_d)) return - tag = tag.lower() - # TODO: handle start tags separately (Branches are too late tobe + tagLower = tag.lower() + # TODO: handle start tags separately (Branches are too late to be # created here since all children are done). index = "end" # "end" is at end of current branch (otherwise use int) - if tag in ("segment", "group"): + prev_stack_size = len(self._populating_stack) + if tagLower in ("segment", "group"): name = "" for child in element: - if child.tag == "name": + if child.tag.lower() == "name": name = child.text + # FIXME: move to end tag when done populating break + # element = ET.Element(element) # for autocomplete only # if not name: - name = element.attrs['space'] + if tagLower == "segment": + space = element.attrib['space'] + name = space + origin = None + if 'origin' in element.attrib: + origin = element.attrib['origin'] + elif tagLower == "group": + if 'offset' in element.attrib: + name = element.attrib['offset'] + # else must be a subgroup (offset optional in that case) + else: + raise NotImplementedError(tagLower) + + # ^ 'xml.etree.ElementTree.Element' object has no attribute 'attrs' new_branch = self._treeview.insert( self._populating_branch(), index, @@ -177,13 +222,13 @@ def _on_cdi_element_end(self, event_d): self._populating_stack.append(new_branch) # values=(), image=None self._current_iid += 1 # TODO: associate with SubElement - elif tag == "acdi": + elif tagLower == "acdi": # "Indicates that certain configuration information in the # node has a standardized simplified format." # Configuration Description Information - Standard - section 5.1 # (self-closing tag; triggers startElement and endElement) - pass - elif tag in ("int", "string", "float"): + self.ignore_non_gui_tags.append(tagLower) + elif tagLower in ("int", "string", "float"): name = "" for child in element: if child.tag == "name": @@ -199,7 +244,16 @@ def _on_cdi_element_end(self, event_d): # values=(), image=None self._current_iid += 1 # TODO: associate with SubElement # and/or set values keyword argument to create association(s) - elif tag == "cdi": - pass + elif tagLower == "cdi": + self.ignore_non_gui_tags.append(tagLower) else: logger.warning("Ignored {}".format(tag)) + self.ignore_non_gui_tags.append(tagLower) + + if len(self.ignore_non_gui_tags) <= prev_ignore_size: + if len(self._populating_stack) <= prev_stack_size: + raise NotImplementedError( + "Must either ignore tag (to prevent pop" + " during _on_cdi_element_end)" + " or add to GUI stack so end tag can pop {}" + .format(tagLower)) diff --git a/openlcb/canbus/canframe.py b/openlcb/canbus/canframe.py index 21908eb..520566c 100644 --- a/openlcb/canbus/canframe.py +++ b/openlcb/canbus/canframe.py @@ -1,7 +1,22 @@ import openlcb + from collections import OrderedDict +from logging import getLogger + from openlcb.nodeid import NodeID +logger = getLogger(__name__) + + +class NoEncoder: + def encodeFrameAsString(self, _): + raise AssertionError( + "You must set encoder on frame to a PhysicalLayer instance" + " or class that has an encodeFrameAsString method that accepts" + " a single CanFrame argument (Example in canFrameSend" + " method of CanPhysicalLayerGridConnect:" + " frame.encoder = self).") + class CanFrame: """OpenLCB-CAN frame @@ -36,6 +51,10 @@ class CanFrame: control (int, optional): Frame type (1: OpenLCB = 0x0800_000, 0: CAN Control Frame) | Content Field (3 bits, 3 nibbles, mask = 0x07FF_F000). + encoder (object): a required encoder object (set to a + PhysicalLayer subclass, since that layer determines + the encoding). Must have an encodeFrameAsString method that + accepts a CanFrame. """ ARG_LISTS = [ @@ -61,10 +80,19 @@ def __str__(self): list(self.data), # cast to list to format bytearray(b'') as [] ) + def encodeAsString(self): + return self.encoder.encodeFrameAsString(self) + + @property + def alias(self) -> int: + return self._alias + def __init__(self, *args): + self.encoder = NoEncoder() arg1 = None arg2 = None arg3 = None + self._alias = None if len(args) > 0: arg1 = args[0] if len(args) > 1: @@ -76,9 +104,11 @@ def __init__(self, *args): # There are three ctor forms. # - See "Args" in class for docstring. self.header = 0 + self.direction = None # See deque for usage self.data = bytearray() # three arguments as N_cid, nodeID, alias args_error = None + alias_warning_case = None if isinstance(arg2, NodeID): # Other args' types will be enforced by doing math on them # (duck typing) in this case. @@ -88,11 +118,11 @@ def __init__(self, *args): # precondition(4 <= cid && cid <= 7) cid = arg1 nodeID = arg2 - alias = arg3 + self._alias = arg3 nodeCode = ((nodeID.nodeId >> ((cid-4)*12)) & 0xFFF) # ^ cid-4 results in 0 to 3. *12 results in 0 to 36 bit shift (nodeID size) # noqa: E501 - self.header = ((cid << 12) | nodeCode) << 12 | (alias & 0xFFF) | 0x10_00_00_00 # noqa: E501 + self.header = ((cid << 12) | nodeCode) << 12 | (self._alias & 0xFFF) | 0x10_00_00_00 # noqa: E501 # self.data = bytearray() # two arguments as header, data @@ -104,6 +134,7 @@ def __init__(self, *args): self.data = arg2 if len(args) > 2: args_error = "2nd argument is data, but got extra argument(s)" + alias_warning_case = "bytearray overload of constructor" # two arguments as header, data elif isinstance(arg2, list): @@ -116,8 +147,9 @@ def __init__(self, *args): elif isinstance(arg2, int): # Types of all 3 are enforced by usage (duck typing) in this case. control = arg1 - alias = arg2 - self.header = (control << 12) | (alias & 0xFFF) | 0x10_00_00_00 + self._alias = arg2 + self.header = \ + (control << 12) | (self._alias & 0xFFF) | 0x10_00_00_00 if not isinstance(arg3, bytearray): args_error = ("Expected bytearray (formerly list[int])" " 2nd if 1st argument is header int") @@ -130,6 +162,16 @@ def __init__(self, *args): args_error.rstrip(".") + ". Valid constructors:" + CanFrame.constructor_help() + ". Got: " + openlcb.list_type_names(args)) + if self._alias is not None: + if self._alias & 0xFFF != self._alias: + raise ValueError( + "Alias overflow: {} > 0xFFF".format(self._alias)) + else: + if not alias_warning_case: + alias_warning_case = "untracked constructor case" + logger.info( + "[CanFrame] Alias set/decode is not implemented in {}" + .format(alias_warning_case)) def __eq__(self, other): if other is None: diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 7c4a73b..a3cf555 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -22,7 +22,7 @@ from logging import getLogger -from openlcb import precise_sleep +from openlcb import emit_cast, formatted_ex, precise_sleep from openlcb.canbus.canframe import CanFrame from openlcb.canbus.controlframe import ControlFrame @@ -48,6 +48,7 @@ def __init__(self, localNodeID): # a NodeID self.aliasToNodeID = {} self.nodeIdToAlias = {} self.accumulator = {} + self.duplicateAliases = [] self.nextInternallyAssignedNodeID = 1 LinkLayer.__init__(self, localNodeID) @@ -492,9 +493,15 @@ def sendMessage(self, msg): except KeyboardInterrupt: raise except Exception as ex: - logger.warning( - "Did not know destination = {} on datagram send ({}: {})" - "".format(msg.destination, type(ex).__name__, ex) + logger.error( + "Did not know destination = {} on datagram send ({})" + " self.nodeIdToAlias={}. Ensure recv loop" + " (such as Dispatcher's _listen thread) is running" + " before and during alias reservation sequence delay." + " Check previous log messages for an exception" + " that may have ended the recv loop." + .format(msg.destination, formatted_ex(ex), + self.nodeIdToAlias) ) if len(msg.data) <= 8: @@ -638,12 +645,22 @@ def checkAndHandleAliasCollision(self, frame): self.processCollision(frame) return abort + def markDuplicateAlias(self, alias): + if not isinstance(alias, int): + raise NotImplementedError( + "Can't mark collision due to alias not stored as int." + " bytearray paring must be implemented in CanFrame constructor" + " if this markDuplicateAlias scenario is valid (alias={})." + .format(emit_cast(alias))) + self.duplicateAliases.append(alias) + def processCollision(self, frame) : ''' Collision! ''' self._reserveAliasCollisions += 1 logger.warning( "alias collision in {}, we restart with AMR" " and attempt to get new alias".format(frame)) + self.markDuplicateAlias(frame.alias) self.link.sendCanFrame(CanFrame(ControlFrame.AMR.value, self.localAlias, self.localNodeID.toArray())) diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index d521a94..8120367 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -14,9 +14,15 @@ class CanPhysicalLayer(PhysicalLayer): def __init__(self): self.listeners = [] - def sendCanFrame(self, frame): + def sendCanFrame(self, frame: CanFrame): '''basic abstract interface''' - pass + raise NotImplementedError( + "Each subclass must implement this, and set" + " frame.encoder = self") + + def encode(self, frame) -> str: + '''abstract interface (encode frame to string)''' + raise NotImplementedError("Each subclass must implement this.") def registerFrameReceivedListener(self, listener): self.listeners.append(listener) diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index 05fb51c..c06aea9 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -10,6 +10,7 @@ - :X19170365N020112FE056C; ''' +from typing import Union from openlcb.canbus.canphysicallayer import CanPhysicalLayer from openlcb.canbus.canframe import CanFrame @@ -31,20 +32,26 @@ class CanPhysicalLayerGridConnect(CanPhysicalLayer): def __init__(self, callback): CanPhysicalLayer.__init__(self) - self.canSendCallback = callback + self.setCallBack(callback) self.inboundBuffer = bytearray() def setCallBack(self, callback): + assert callable(callback) self.canSendCallback = callback - def sendCanFrame(self, frame): - output = ":X{:08X}N".format(frame.header) + def sendCanFrame(self, frame: CanFrame) -> None: + frame.encoder = self + self.canSendCallback(frame) + + def encodeFrameAsString(self, frame) -> str: + '''Encode frame to string.''' + output = ":X{:08X}N".format(frame.header) # at least 8 chars, hex for byte in frame.data: - output += "{:02X}".format(byte) + output += "{:02X}".format(byte) # at least 2 chars, hex output += ";\n" - self.canSendCallback(output) + return output - def pushString(self, string): + def pushString(self, string: str): '''Provide string from the outside link to be parsed Args: @@ -52,7 +59,7 @@ def pushString(self, string): ''' self.pushChars(string.encode("utf-8")) - def pushChars(self, data): + def pushChars(self, data: Union[bytes, bytearray]): """Provide characters from the outside link to be parsed Args: diff --git a/openlcb/canbus/controlframe.py b/openlcb/canbus/controlframe.py index 2268bad..35929c8 100644 --- a/openlcb/canbus/controlframe.py +++ b/openlcb/canbus/controlframe.py @@ -69,8 +69,11 @@ class ControlFrame(Enum): CID = 0x4000 Data = 0x18000 - # these are non-openlcb values used for internal signaling + # These are non-openlcb values used for internal signaling # their values have a bit set above what can come from a CAN Frame + # TODO: Consider moving these and non-MTI values in MTI enum + # all to InternalEvent (or creating a related event if necessary + # using a listener, so Dispatcher can manage runlevel). LinkUp = 0x20000 LinkRestarted = 0x20001 LinkCollision = 0x20002 diff --git a/openlcb/cdihandler.py b/openlcb/cdihandler.py index 75b7df3..e198f45 100644 --- a/openlcb/cdihandler.py +++ b/openlcb/cdihandler.py @@ -10,7 +10,9 @@ Contributors: Poikilos, Bob Jacobsen (code from example_cdi_access) """ +from collections import deque from enum import Enum +import os import threading import time import sys @@ -18,9 +20,11 @@ import xml.etree.ElementTree as ET from logging import getLogger +# from xml.sax.xmlreader import AttributesImpl # for autocomplete only from openlcb import formatted_ex, precise_sleep +from openlcb.canbus.canframe import CanFrame from openlcb.canbus.canphysicallayergridconnect import ( CanPhysicalLayerGridConnect, ) @@ -34,11 +38,33 @@ MemoryReadMemo, MemoryService, ) +from openlcb.platformextras import SysDirs, clean_file_name logger = getLogger(__name__) -class PortHandler(xml.sax.handler.ContentHandler): +def element_to_dict(element): + element = ET.Element(element) # for autocomplete only + return { + 'tag': element.tag, + 'attrib': element.attrib, # already dict[str,str] + } + + +def attrs_to_dict(attrs) -> dict: + """Convert parser tag attrs. + + Args: + attrs (AttributesImpl): attrs from xml parser startElement event + (Not the same as element.attrib which is already dict). + """ + # attrs = AttributesImpl(attrs) + # attrs_dict = attrs.__dict__ # may have private members, so: + return {key: attrs.getValue(key) for key in attrs.getNames()} + + +# TODO: split Dispatcher (socket & event handler) from ContentHandler +class Dispatcher(xml.sax.handler.ContentHandler): """Manage Configuration Description Information. - Send events to downloadCDI caller describing the state and content of the document construction. @@ -58,6 +84,13 @@ class PortHandler(xml.sax.handler.ContentHandler): _element_listener (Callable): Called if an XML element is received (including either a start or end tag). Typically set as `callback` argument to downloadCDI. + _resultingCDI (str): CDI document being collected from the + network stream (successful read request memo handler). To + ensure valid state: + - Initialize to None at program start, end download, or + failed download. + - Assert is None at start of download, then set to + bytearray(). """ class Mode(Enum): """Track what data is expected, if any. @@ -71,9 +104,12 @@ class Mode(Enum): CDI = 3 def __init__(self, *args, **kwargs): + caches_dir = SysDirs.Cache + self._my_cache_dir = os.path.join(caches_dir, "python-openlcb") self._element_listener = None self._connect_listener = None - self._mode = PortHandler.Mode.Initializing + self._sends = deque() + self._mode = Dispatcher.Mode.Initializing # ^ In case some parsing step happens early, # prepare these for _callback_msg. super().__init__() # takes no arguments @@ -123,10 +159,13 @@ def set_connect_listener(self, listener): self._connect_listener = listener def start_listening(self, connected_port, localNodeID): + if self._port is not None: + logger.warning( + "[start_listening] A previous _port will be discarded.") self._port = connected_port self._callback_status("CanPhysicalLayerGridConnect...") self._canPhysicalLayerGridConnect = \ - CanPhysicalLayerGridConnect(self._sendToPort) + CanPhysicalLayerGridConnect(self.sendAfter) # self._canPhysicalLayerGridConnect.registerFrameReceivedListener( # self._printFrame @@ -194,7 +233,8 @@ def _receive(self) -> bytearray: def _listen(self): self._connecting_t = time.perf_counter() self._message_t = None - self._mode = PortHandler.Mode.Idle # Idle until data type is known + self._mode = Dispatcher.Mode.Idle # Idle until data type is known + caught_ex = None try: # NOTE: self._canLink.state is *definitely not* # CanLink.State.Permitted yet, but that's ok because @@ -207,8 +247,37 @@ def _listen(self): # Wait 200 ms for all nodes to announce (and for alias # reservation to complete), as per section 6.2.1 of CAN # Frame Transfer Standard (sendMessage requires ) - print("[_listen] _receive...") - received = self._receive() # set timeout to prevent hang? + logger.debug("[_listen] _receive...") + try: + while self._sends: + # *Always* do send in the receive thread to + # avoid overlapping calls to socket + # (causes undefined behavior)! + frame = self._sends.pop() + if isinstance(frame, CanFrame): + if frame.alias in self._canLink.duplicateAliases: + logger.warning( + "Discarded remnant of previous" + " alias reservation attempt" + " (duplicate alias={})" + .format(frame.alias)) + continue + logger.debug("[_listen] _sendString...") + self._port.sendString(frame.encodeAsString()) + else: + raise NotImplementedError( + "Event type {} is not handled." + .format(type(frame).__name__)) + received = self._receive() # requires setblocking(False) + # so that it doesn't block (or occur during) recv + # (overlapping calls would cause undefined behavior)! + # TODO: move *all* send calls to this loop. + except BlockingIOError: + # delay = random.uniform(.005,.02) + # ^ random delay may help if send is on another thread + # (but avoid that for stability and speed) + precise_sleep(.01) + continue print("[_listen] received {} byte(s)".format(len(received)), file=sys.stderr) # print(" RR: {}".format(received.strip())) @@ -230,6 +299,7 @@ def _listen(self): # else _on_link_state_change will be called except RuntimeError as ex: + caught_ex = ex # If _port is a TcpSocket: # May be raised by tcplink.tcpsocket.TCPSocket.receive # manually. @@ -244,15 +314,16 @@ def _listen(self): } if self._element_listener: self._element_listener(event_d) - self._mode = PortHandler.Mode.Disconnected + self._mode = Dispatcher.Mode.Disconnected raise # re-raise since incomplete (prevent done OK state) self._listen_thread = None - self._mode = PortHandler.Mode.Disconnected + self._mode = Dispatcher.Mode.Disconnected # If we got here, the RuntimeError was ok since the # null terminator '\0' was reached (otherwise re-raise occurs above) event_d = { - 'error': "Listen loop stopped.", + 'error': ("Listen loop stopped (caught_ex={})." + .format(formatted_ex(caught_ex))), 'done': True, } if not (self._connect_listener and self._connect_listener(event_d)): @@ -294,14 +365,33 @@ def callback(event_d): "No canPhysicalLayerGridConnect. Call start_listening first.") self._cdi_offset = 0 self._reset_tree() - self._mode = PortHandler.Mode.CDI + self._mode = Dispatcher.Mode.CDI + if self._resultingCDI is not None: + raise ValueError( + "A previous downloadCDI operation is in progress" + " or failed (Set _resultingCDI to None first if failed)") + self._resultingCDI = bytearray() self._memoryRead(farNodeID, self._cdi_offset) # ^ On a successful memory read, _memoryReadSuccess will trigger # _memoryRead again and again until end/fail. def _sendToPort(self, string): # print(" SR: {}".format(string.strip())) - self._port.sendString(string) + self.sendAfter(string) + + def sendAfter(self, string): + """Enqueue: *IMPORTANT* Main/other thread may have + called this, or called this via _sendToPort. Any other thread + sending other than the _listen thread is bad, since overlapping + calls to socket cause undefined behavior. + - CanPhysicalLayerGridConnect constructor sets + canSendCallback, and CanLink sets canSendCallback to this + (formerly set to _sendToPort which was formerly a direct call + to _port which was not thread-safe) + - Could a refactor help with this? See issue #62 + - Add a generalized LocalEvent queue avoid deep callstack? + """ + self._sends.appendleft(string) # def _printFrame(self, frame): # # print(" RL: {}".format(frame)) @@ -383,6 +473,7 @@ def _CDIReadDone(self, memo): self._resultingCDI += memo.data # concert resultingCDI to a string up to 1st zero # and process that + cdiString = None if self._realtime: # If _realtime, last chunk is treated same as another # (since _realtime uses feed) except stop at '\0'. @@ -409,6 +500,32 @@ def _CDIReadDone(self, memo): }) if self._realtime: self._parser.feed(partial_str) # may call startElement/endElement + # memo = MemoryReadMemo(memo) + path = self.cache_cdi_path(memo.nodeID) + with open(path, 'w') as stream: + if cdiString is None: + cdiString = self._resultingCDI.rstrip(b'\0').decode("utf-8") + stream.write(cdiString) + print('Saved "{}"'.format(path)) + self._resultingCDI = None # Ensure isn't reused for more than one doc + + def cache_cdi_path(self, item_id): + cdi_cache_dir = os.path.join(self._my_cache_dir, "cdi") + if not os.path.isdir(cdi_cache_dir): + os.makedirs(cdi_cache_dir) + # TODO: add hardware name and firmware version and from SNIP to + # name file to avoid cache file from a different + # device/version. + item_id = str(item_id) # Convert NodeID or other + clean_name = clean_file_name(item_id.replace(":", ".")) + # ^ replace ":" to avoid converting that one to default "_" + # ^ will raise error if path instead of name + path = os.path.join(cdi_cache_dir, clean_name) + if path == clean_name: + # just to be safe, even though clean_file_name + # should prevent. If this occurs, fix clean_file_name. + raise ValueError("Cannot specify absolute path.") + return path + ".xml" def _memoryReadSuccess(self, memo): """Handle a successful read @@ -422,13 +539,13 @@ def _memoryReadSuccess(self, memo): # print("successful memory read: {}".format(memo.data)) if len(memo.data) == 64 and 0 not in memo.data: # *not* last chunk self._string_terminated = False - if self._mode == PortHandler.Mode.CDI: + if self._mode == Dispatcher.Mode.CDI: # save content - self._CDIReadPartial(memo, False) + self._CDIReadPartial(memo) else: logger.error( "Unknown data packet received" - " (memory read not triggered by PortHandler)") + " (memory read not triggered by Dispatcher)") # update the address memo.address = memo.address + 64 # and read again (read next) @@ -437,13 +554,13 @@ def _memoryReadSuccess(self, memo): else: # last chunk self._string_terminated = True # and we're done! - if self._mode == PortHandler.Mode.CDI: + if self._mode == Dispatcher.Mode.CDI: self._CDIReadDone(memo) else: logger.error( "Unknown last data packet received" - " (memory read not triggered by PortHandler)") - self._mode = PortHandler.Mode.Idle # CDI no longer expected + " (memory read not triggered by Dispatcher)") + self._mode = Dispatcher.Mode.Idle # CDI no longer expected # done reading def _memoryReadFail(self, memo): @@ -463,10 +580,12 @@ def startElement(self, name, attrs): if attrs is not None and attrs : print(tab, " Attributes: ", attrs.getNames()) # el = ET.Element(name, attrs) - el = ET.SubElement(self._open_el, "element1") + attrib = attrs_to_dict(attrs) + el = ET.SubElement(self._open_el, name, attrib) # if self._tag_stack: # parent = self._tag_stack[-1] - event_d = {'name': name, 'end': False, 'attrs': attrs} + event_d = {'name': name, 'end': False, 'attrs': attrs, + 'element': el} if self._element_listener: self._element_listener(event_d) diff --git a/openlcb/internalevent.py b/openlcb/internalevent.py new file mode 100644 index 0000000..d3a1177 --- /dev/null +++ b/openlcb/internalevent.py @@ -0,0 +1,35 @@ +class InternalEvent: + """An event for internal use by the framework + (framework state events) + - Should be eventually all handled by Dispatcher so + that it can handle state ("runlevel") as per issue #62. + - For OpenLCB/LCC Events, see Events that are not + subclasses of this. + """ + # TODO: move non-LCC MTI values to here. + + +class SendAliasReservationEvent(InternalEvent): + """A CanFrame container representing an alias reservation attempt. + Reserved for future use (For now, ignore each CanFrame from deque + that has has alias in invalidAliases) + + Args: + attempt_number (int): Specify the same attempt number for all + frames in a single call to defineAndReserveAlias--required + so lower-numbered attempts can be deleted from the deque + when a new attempt is started. + """ + attempt_counter = 0 + + @classmethod + def nextAttemptNumber(cls): + """Get an alias reservation attempt number + (See attempt_number constructor arg in class docstring). + """ + cls.attempt_counter += 1 # ok since 0 is invalid + return cls.attempt_counter + + def __init__(self, attempt_number, canFrame): + self.attempt_number = attempt_number + self.canFrame = canFrame diff --git a/openlcb/mti.py b/openlcb/mti.py index 2899d4c..122187d 100644 --- a/openlcb/mti.py +++ b/openlcb/mti.py @@ -5,6 +5,8 @@ class MTI(Enum): + """Message Type Identifier + """ Initialization_Complete = 0x0100 Initialization_Complete_Simple = 0x0101 @@ -50,6 +52,9 @@ class MTI(Enum): # These are used for internal signalling and are not present in the MTI # specification. + # TODO: Consider moving these and non-CAN values in ControlFrame + # all to InternalEvent (or creating a related event if necessary + # using a listener, so Dispatcher can manage runlevel). Link_Layer_Up = 0x2000 # entered Permitted state; needs to be marked global # noqa: E501 Link_Layer_Quiesce = 0x2010 # Link needs to be drained, will come back with Link_Layer_Restarted next # noqa: E501 Link_Layer_Restarted = 0x2020 # link cycled without change of node state; needs to be marked global # noqa: E501 diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index 4db1b82..d89aded 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -8,10 +8,16 @@ class PhysicalLayer: def physicalLayerUp(self): - pass # abstract method + """abstract method""" + raise NotImplementedError("Each subclass must implement this.") def physicalLayerRestart(self): - pass # abstract method + """abstract method""" + raise NotImplementedError("Each subclass must implement this.") def physicalLayerDown(self): - pass # abstract method + """abstract method""" + raise NotImplementedError("Each subclass must implement this.") + + def encodeFrameAsString(self, frame): + raise NotImplementedError("Each subclass must implement this.") \ No newline at end of file diff --git a/openlcb/platformextras.py b/openlcb/platformextras.py new file mode 100644 index 0000000..8ca6681 --- /dev/null +++ b/openlcb/platformextras.py @@ -0,0 +1,64 @@ +""" +Platform-specific information and processing that is not in Python's +builtin modules. +""" +import os +import platform + +from openlcb import emit_cast + +if platform.system() == "Windows": + _cache_dir = os.path.join(os.environ['LOCALAPPDATA'], "cache") +elif platform.system() == "Darwin": + _cache_dir = os.path.expanduser("~/Library/Caches") +else: + _cache_dir = os.path.expanduser("~/.cache") + + +class ConstantClassMeta(type): + def __setattr__(cls, name, value): + if name in cls.__dict__: + raise AttributeError( + "Cannot modify constant attribute '{}'".format(name)) + super().__setattr__(name, value) + + +class SysDirs(metaclass=ConstantClassMeta): + Cache = _cache_dir + + +file_name_extra_symbols = "-_=+.,() ~" +# ^ isalnum plus this is allowed +# (lowest common denominator for now) + + +def is_file_name_char(c: str) -> bool: + if (not isinstance(c, str)) or (len(c) != 1): + raise TypeError("Expected 1-length str, got {}" + .format(emit_cast(c))) + return c.isalnum() or (c in file_name_extra_symbols) + + +def clean_file_name_char(c: str, placeholder: str = None) -> str: + if placeholder is None: + placeholder = "_" + else: + assert isinstance(placeholder, str) + assert len(placeholder) == 1 + if is_file_name_char(c): + return c + return placeholder + + +def clean_file_name(name: str, placeholder: str = None) -> str: + assert isinstance(name, str) + if (os.path.sep in name) or ("/" in name): + # or "/" since Python uses that even on Windows + # in some module(s) such as tkinter + raise ValueError( + "Must only specify name not path ({} contained {})" + .format(repr(name), repr(os.path.sep))) + result = "" + for c in name: + result += clean_file_name_char(c, placeholder=placeholder) + return result diff --git a/openlcb/portinterface.py b/openlcb/portinterface.py index aebb5bb..3f00cba 100644 --- a/openlcb/portinterface.py +++ b/openlcb/portinterface.py @@ -47,17 +47,21 @@ def _setBusy(self, caller): def _unsetBusy(self, caller): if caller != self._busy_message: - raise RuntimeError( + raise InterruptedError( "Untracked {} ended during {}" + " Check busy() first or setListeners" + " (implementation problem: See Dispatcher" + " for correct example)" .format(caller, self._busy_message)) self._busy_message = None def assertNotBusy(self, caller): if self._busy_message: - raise RuntimeError( + raise InterruptedError( "{} was called during {}." " Check busy() first or setListeners" - " and wait for {} ready." + " and wait for {} ready" + " (or use Dispatcher to send&receive)" .format(caller, self._busy_message, caller)) def setListeners(self, onReadyToSend, onReadyToReceive): @@ -108,6 +112,16 @@ def _send(self, data: Union[bytes, bytearray]) -> None: raise NotImplementedError("Subclass must implement this.") def send(self, data: Union[bytes, bytearray]) -> None: + """ + + Raises: + InterruptedError: (raised by assertNotBusy) if + port is in use. Use sendAfter in + Dispatcher to avoid this. + + Args: + data (Union[bytes, bytearray]): _description_ + """ self._setBusy("send") self._busy_message = "send" try: @@ -123,11 +137,10 @@ def _receive(self) -> bytearray: def receive(self) -> bytearray: self._setBusy("receive") - self._busy_message = "receive" try: result = self._receive() finally: - self._unsetBusy("send") + self._unsetBusy("receive") if self._onReadyToSend: self._onReadyToSend() return result diff --git a/openlcb/tcplink/tcpsocket.py b/openlcb/tcplink/tcpsocket.py index 6f6862a..982b446 100644 --- a/openlcb/tcplink/tcpsocket.py +++ b/openlcb/tcplink/tcpsocket.py @@ -46,6 +46,15 @@ def _connect(self, host, port, device=None): self._device = device self._device.connect((host, port)) + # ^ `port` here is only remote port. OS automatically assigns a + # random local ephemeral port (obtainable in + # sock.getsockname() tuple) for send and receive unless `bind` + # is used. + self._device.setblocking(False) + # ^ False: Make sure listen thread can also send so 2 threads + # don't access port (part of missing implementation discussed + # in issue #62). This requires a loop with both send and recv + # (sleep on BlockingIOError to use less CPU). def _send(self, data: Union[bytes, bytearray]): """Send a single message (bytes) diff --git a/python-openlcb.code-workspace b/python-openlcb.code-workspace index 092d5c1..c34883b 100644 --- a/python-openlcb.code-workspace +++ b/python-openlcb.code-workspace @@ -21,6 +21,7 @@ "1Ddddsss", "AccumKey", "ADCDI", + "appendleft", "autosummary", "baudrate", "bitmask", @@ -45,6 +46,7 @@ "gridargs", "JMRI", "linklayer", + "LOCALAPPDATA", "localeventstore", "localoverrides", "MDNS", @@ -60,6 +62,7 @@ "padx", "pady", "physicallayer", + "platformextras", "Poikilos", "portinterface", "pyproject", @@ -67,6 +70,7 @@ "servicetype", "settingtypes", "setuptools", + "sysdirs", "tcplink", "tcpsocket", "textvariable", diff --git a/tests/test_platformextras.py b/tests/test_platformextras.py new file mode 100644 index 0000000..57bf0d0 --- /dev/null +++ b/tests/test_platformextras.py @@ -0,0 +1,49 @@ +import os +import platform +import unittest + +from openlcb.platformextras import SysDirs, clean_file_name + + +class TestPlatformExtras(unittest.TestCase): + + def test_clean_file_name(self): + not_pathable_name = "hello:world" + got_name = clean_file_name(not_pathable_name) + self.assertEqual(got_name, "hello_world") + + got_name = clean_file_name(not_pathable_name, placeholder="-") + self.assertEqual(got_name, "hello-world") + + with self.assertRaises(ValueError): + _ = clean_file_name("contains_path{}not_just_file" + .format(os.path.sep)) + if os.path.sep != "/": + with self.assertRaises(ValueError): + # Manually check "/" since tkinter and possibly + # other Python modules insert "/" regardless of platform: + _ = clean_file_name("contains_path{}not_just_file" + .format("/")) + + def test_sysdirs(self): + try_to_set_constant = "some value" + with self.assertRaises(AttributeError): + SysDirs.Cache = try_to_set_constant + self.assertNotEqual(SysDirs.Cache, try_to_set_constant) + + self.assertIsInstance(SysDirs.Cache, str) + if platform.system() == "Windows": + self.assertGreater(len(SysDirs.Cache), 3) + # ^ `>` instead of `>=`, since "C:/" would be bad & useless + # - same for "//" even if had folder (len("//x") == 3 + # but is still bad): Disallow network folder for cache. + else: + self.assertGreater(len(SysDirs.Cache), 1) + # ^ `>` instead of `>=`, since "/" would be bad & useless + self.assertEqual(SysDirs.Cache[0], "/") # ensure is absolute + # - relative path to cache would be hard to find/clear + # - even a path like "/t" is technically allowable for cache + # on unix-like os, though should be ~/.cache usually + # Can't think of anything else to test, since + # SysDirs is the authority on the values + # (and result is platform-specific). From 8edc8a85efed3f4d9c98c93ccc666993ca79d3dc Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Fri, 4 Apr 2025 16:53:17 -0400 Subject: [PATCH 39/99] Rename module according to new class name. --- examples/examples_gui.py | 4 ++-- examples/tkexamples/cdiform.py | 6 +++--- openlcb/{cdihandler.py => dispatcher.py} | 0 3 files changed, 5 insertions(+), 5 deletions(-) rename openlcb/{cdihandler.py => dispatcher.py} (100%) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index 43b6854..92acfc9 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -447,7 +447,7 @@ def _gui(self, parent): self.cdi_refresh_button.grid(row=self.cdi_row, column=1) self.cdi_row += 1 - self.cdi_form = CDIForm(self.cdi_tab) # CDIHandler() subclass + self.cdi_form = CDIForm(self.cdi_tab) # Dispatcher() subclass self.cdi_form.grid(row=self.cdi_row) self.example_tab = ttk.Frame(self.notebook) @@ -490,7 +490,7 @@ def _connect_state_changed(self, event_d): the connect or _listen thread). Args: - event_d (dict): Information sent by CDIHandler's + event_d (dict): Information sent by Dispatcher's connect method during the connection steps including alias reservation. Potential keys: - 'error' (str): Indicates a failure diff --git a/examples/tkexamples/cdiform.py b/examples/tkexamples/cdiform.py index 8db3e70..ae49229 100644 --- a/examples/tkexamples/cdiform.py +++ b/examples/tkexamples/cdiform.py @@ -18,7 +18,7 @@ from logging import getLogger from xml.etree import ElementTree as ET -from openlcb.cdihandler import element_to_dict +from openlcb.dispatcher import element_to_dict logger = getLogger(__name__) @@ -33,7 +33,7 @@ " since test running from repo but could not find openlcb in {}." .format(repr(REPO_DIR))) try: - from openlcb.cdihandler import Dispatcher + from openlcb.dispatcher import Dispatcher except ImportError as ex: print("{}: {}".format(type(ex).__name__, ex), file=sys.stderr) print("* You must run this from a venv that has openlcb installed" @@ -90,7 +90,7 @@ def clear(self): self.set_status("Display reset.") # def connect(self, new_socket, localNodeID, callback=None): - # return CDIHandler.connect(self, new_socket, localNodeID, + # return Dispatcher.connect(self, new_socket, localNodeID, # callback=callback) def downloadCDI(self, farNodeID, callback=None): diff --git a/openlcb/cdihandler.py b/openlcb/dispatcher.py similarity index 100% rename from openlcb/cdihandler.py rename to openlcb/dispatcher.py From f22e756c76e657179e05b67b9b67431c96a34b97 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 8 Apr 2025 10:38:05 -0400 Subject: [PATCH 40/99] Fix GC test to use CanFrame send handler. Add Comments. --- openlcb/scanner.py | 3 +- tests/test_canphysicallayergridconnect.py | 70 +++++++++++++---------- 2 files changed, 42 insertions(+), 31 deletions(-) diff --git a/openlcb/scanner.py b/openlcb/scanner.py index cab73a5..72e8a45 100644 --- a/openlcb/scanner.py +++ b/openlcb/scanner.py @@ -81,7 +81,8 @@ def nextBytes(self) -> bytearray: assert isinstance(self._buffer, (bytes, bytearray)) if self._delimiter == Scanner.EOF: result = self._buffer - self._buffer = type(self._buffer)() + self._buffer = type(self._buffer)() # a.k.a. .copy() + # (bytearray has .copy but bytes does not, so use constructor) return result self.assertDelimiterType() last_idx = self._buffer.find(self._delimiter) diff --git a/tests/test_canphysicallayergridconnect.py b/tests/test_canphysicallayergridconnect.py index 7753bcb..90f89e8 100644 --- a/tests/test_canphysicallayergridconnect.py +++ b/tests/test_canphysicallayergridconnect.py @@ -12,39 +12,49 @@ class CanPhysicalLayerGridConnectTest(unittest.TestCase): def __init__(self, *args, **kwargs): super(CanPhysicalLayerGridConnectTest, self).__init__(*args, **kwargs) - self.capturedString = "" + # self.capturedString = "" + self.capturedFrame = None self.receivedFrames = [] # PHY side - def captureString(self, string): - self.capturedString = string + # def captureString(self, string): + # self.capturedString = string + + # PHY side + def frameSocketSendDummy(self, frame): + # formerly captureString(self, string) + self.capturedFrame = frame + self.capturedFrame.encoder = self.gc # Link Layer side def receiveListener(self, frame): self.receivedFrames += [frame] def testCID4Sent(self): - gc = CanPhysicalLayerGridConnect(self.captureString) + self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) - gc.sendCanFrame(CanFrame(4, NodeID(0x010203040506), 0xABC)) - self.assertEqual(self.capturedString, ":X14506ABCN;\n") + self.gc.sendCanFrame(CanFrame(4, NodeID(0x010203040506), 0xABC)) + # self.assertEqual(self.capturedString, ":X14506ABCN;\n") + self.assertEqual(self.capturedFrame.encodeAsString(), ":X14506ABCN;\n") def testVerifyNodeSent(self): - gc = CanPhysicalLayerGridConnect(self.captureString) + self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) - gc.sendCanFrame(CanFrame(0x19170, 0x365, bytearray([ + self.gc.sendCanFrame(CanFrame(0x19170, 0x365, bytearray([ 0x02, 0x01, 0x12, 0xFE, 0x05, 0x6C]))) - self.assertEqual(self.capturedString, ":X19170365N020112FE056C;\n") + # self.assertEqual(self.capturedString, ":X19170365N020112FE056C;\n") + self.assertEqual(self.capturedFrame.encodeAsString(), + ":X19170365N020112FE056C;\n") def testOneFrameReceivedExactlyHeaderOnly(self): - gc = CanPhysicalLayerGridConnect(self.captureString) - gc.registerFrameReceivedListener(self.receiveListener) + self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) + self.gc.registerFrameReceivedListener(self.receiveListener) bytes = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n - gc.pushChars(bytes) + self.gc.pushChars(bytes) self.assertEqual( self.receivedFrames[0], @@ -52,8 +62,8 @@ def testOneFrameReceivedExactlyHeaderOnly(self): ) def testOneFrameReceivedExactlyWithData(self): - gc = CanPhysicalLayerGridConnect(self.captureString) - gc.registerFrameReceivedListener(self.receiveListener) + self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) + self.gc.registerFrameReceivedListener(self.receiveListener) bytes = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x31, 0x42, 0x30, 0x33, 0x36, 0x35, 0x4e, 0x30, @@ -61,7 +71,7 @@ def testOneFrameReceivedExactlyWithData(self): 0x43, GC_END_BYTE]) # :X19170365N020112FE056C; - gc.pushChars(bytes) + self.gc.pushChars(bytes) self.assertEqual( self.receivedFrames[0], @@ -70,13 +80,13 @@ def testOneFrameReceivedExactlyWithData(self): ) def testOneFrameReceivedHeaderOnlyTwice(self): - gc = CanPhysicalLayerGridConnect(self.captureString) - gc.registerFrameReceivedListener(self.receiveListener) + self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) + self.gc.registerFrameReceivedListener(self.receiveListener) bytes = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n - gc.pushChars(bytes+bytes) + self.gc.pushChars(bytes+bytes) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) @@ -84,13 +94,13 @@ def testOneFrameReceivedHeaderOnlyTwice(self): CanFrame(0x19490365, bytearray())) def testOneFrameReceivedHeaderOnlyPlusPartOfAnother(self): - gc = CanPhysicalLayerGridConnect(self.captureString) - gc.registerFrameReceivedListener(self.receiveListener) + self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) + self.gc.registerFrameReceivedListener(self.receiveListener) bytes = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a, # :X19490365N;\n 0x3a, 0x58]) - gc.pushChars(bytes) + self.gc.pushChars(bytes) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) @@ -98,25 +108,25 @@ def testOneFrameReceivedHeaderOnlyPlusPartOfAnother(self): bytes = bytearray([ 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) - gc.pushChars(bytes) + self.gc.pushChars(bytes) self.assertEqual(self.receivedFrames[1], CanFrame(0x19490365, bytearray())) def testOneFrameReceivedInTwoChunks(self): - gc = CanPhysicalLayerGridConnect(self.captureString) - gc.registerFrameReceivedListener(self.receiveListener) + self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) + self.gc.registerFrameReceivedListener(self.receiveListener) bytes1 = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x31, 0x37, 0x30, 0x33, 0x36, 0x35, 0x4e, 0x30]) # :X19170365N020112FE056C; - gc.pushChars(bytes1) + self.gc.pushChars(bytes1) bytes2 = bytearray([ 0x32, 0x30, 0x31, 0x31, 0x32, 0x46, 0x45, 0x30, 0x35, 0x36, 0x43, GC_END_BYTE]) - gc.pushChars(bytes2) + self.gc.pushChars(bytes2) self.assertEqual( self.receivedFrames[0], @@ -125,21 +135,21 @@ def testOneFrameReceivedInTwoChunks(self): ) def testSequence(self): - gc = CanPhysicalLayerGridConnect(self.captureString) - gc.registerFrameReceivedListener(self.receiveListener) + self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) + self.gc.registerFrameReceivedListener(self.receiveListener) bytes = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n - gc.pushChars(bytes) + self.gc.pushChars(bytes) self.assertEqual(len(self.receivedFrames), 1) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) self.receivedFrames = [] - gc.pushChars(bytes) + self.gc.pushChars(bytes) self.assertEqual(len(self.receivedFrames), 1) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) From af9ddc9f5ff98dd88b8ebe77d234e47e81a7ef13 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 8 Apr 2025 10:54:32 -0400 Subject: [PATCH 41/99] Fix test: Rename duplicate method definition. Remove "Code Spell Check" lint. --- python-openlcb.code-workspace | 6 ++++++ tests/test_canlink.py | 15 +++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/python-openlcb.code-workspace b/python-openlcb.code-workspace index c34883b..3735fee 100644 --- a/python-openlcb.code-workspace +++ b/python-openlcb.code-workspace @@ -19,6 +19,7 @@ "1Bdddsss", "1Cdddsss", "1Ddddsss", + "ABCN", "AccumKey", "ADCDI", "appendleft", @@ -44,10 +45,12 @@ "Dmitry", "dunder", "gridargs", + "gridconnectobserver", "JMRI", "linklayer", "LOCALAPPDATA", "localeventstore", + "localnodeprocessor", "localoverrides", "MDNS", "mdnsconventions", @@ -67,6 +70,9 @@ "portinterface", "pyproject", "pyserial", + "pythonopenlcb", + "remotenodeprocessor", + "remotenodestore", "servicetype", "settingtypes", "setuptools", diff --git a/tests/test_canlink.py b/tests/test_canlink.py index 31d1e10..ec501b1 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -5,7 +5,9 @@ from openlcb.canbus.canframe import CanFrame from openlcb.canbus.canphysicallayer import CanPhysicalLayer -from openlcb.canbus.canphysicallayersimulation import CanPhysicalLayerSimulation +from openlcb.canbus.canphysicallayersimulation import ( + CanPhysicalLayerSimulation +) from openlcb.message import Message from openlcb.mti import MTI from openlcb.nodeid import NodeID @@ -109,7 +111,7 @@ def testLinkDownSequence(self): self.assertEqual(canLink.state, CanLink.State.Inhibited) self.assertEqual(len(messageLayer.receivedMessages), 1) - def testAEIE2noData(self): + def testEIR2NoData(self): canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLink(NodeID("05.01.01.01.03.01")) canLink.linkPhysicalLayer(canPhysicalLayer) @@ -119,7 +121,7 @@ def testAEIE2noData(self): self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) # MARK: - Test AME (Local Node) - def testAMEnoData(self): + def testAMENoData(self): canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLink(NodeID("05.01.01.01.03.01")) canLink.linkPhysicalLayer(canPhysicalLayer) @@ -158,7 +160,7 @@ def testAMEMatchEvent(self): CanFrame(ControlFrame.AMD.value, ourAlias, canLink.localNodeID.toArray())) - def testAMEnotMatchEvent(self): + def testAMENotMatchEvent(self): canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLink(NodeID("05.01.01.01.03.01")) canLink.linkPhysicalLayer(canPhysicalLayer) @@ -202,7 +204,7 @@ def testRIDreceivedMatch(self): bytearray([5, 1, 1, 1, 3, 1]))) # new alias self.assertEqual(canLink.state, CanLink.State.Permitted) - def testCheckMTImapping(self): + def testCheckMTIMapping(self): canLink = CanLink(NodeID("05.01.01.01.03.01")) self.assertEqual( @@ -480,7 +482,7 @@ def testSimpleDatagram(self): # Test start=yes, end=yes frame self.assertEqual(messageLayer.receivedMessages[1].data[2], 12) self.assertEqual(messageLayer.receivedMessages[1].data[3], 13) - def testThreeFrameDatagrm(self): + def testMultiFrameDatagram(self): canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLink(NodeID("05.01.01.01.03.01")) canLink.linkPhysicalLayer(canPhysicalLayer) @@ -584,6 +586,7 @@ def testTwoFrameDatagram(self): ) def testThreeFrameDatagram(self): + # FIXME: Why was testThreeFrameDatagram named same? What should it be? canPhysicalLayer = PhyMockLayer() canLink = CanLink(NodeID("05.01.01.01.03.01")) canLink.linkPhysicalLayer(canPhysicalLayer) From f25a5d373a3abb3f4689a3c6beb907c921f3ff3c Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 8 Apr 2025 10:55:27 -0400 Subject: [PATCH 42/99] Remove placeholder test file since PhysicalLayer is an interface. Remove lint, and add some lint ignores. --- tests/test_canphysicallayergridconnect.py | 7 ++++--- tests/test_conventions.py | 12 ++++++------ tests/test_localeventstore.py | 2 +- tests/test_mdnsconventions.py | 2 +- tests/test_memoryservice.py | 12 ++++++------ tests/test_physicallayer.py | 15 --------------- tests/test_snip.py | 8 ++++---- tests/test_tcplink.py | 2 +- 8 files changed, 23 insertions(+), 37 deletions(-) delete mode 100644 tests/test_physicallayer.py diff --git a/tests/test_canphysicallayergridconnect.py b/tests/test_canphysicallayergridconnect.py index 90f89e8..3ad67fe 100644 --- a/tests/test_canphysicallayergridconnect.py +++ b/tests/test_canphysicallayergridconnect.py @@ -40,9 +40,10 @@ def testCID4Sent(self): def testVerifyNodeSent(self): self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) - self.gc.sendCanFrame(CanFrame(0x19170, 0x365, bytearray([ - 0x02, 0x01, 0x12, 0xFE, - 0x05, 0x6C]))) + self.gc.sendCanFrame( + CanFrame(0x19170, 0x365, bytearray([ + 0x02, 0x01, 0x12, 0xFE, + 0x05, 0x6C]))) # self.assertEqual(self.capturedString, ":X19170365N020112FE056C;\n") self.assertEqual(self.capturedFrame.encodeAsString(), ":X19170365N020112FE056C;\n") diff --git a/tests/test_conventions.py b/tests/test_conventions.py index 6ccd0e7..f4afbbd 100644 --- a/tests/test_conventions.py +++ b/tests/test_conventions.py @@ -44,8 +44,8 @@ def test_is_hex_lcc_id(self): 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 + self.assertFalse(is_hex_lcc_id("_02015700049C")) # contains start character # noqa: E501 + self.assertFalse(is_hex_lcc_id("org_product_02015700049C")) # service name not split # noqa: E501 def test_dotted_lcc_id_to_hex(self): self.assertEqual(dotted_lcc_id_to_hex("2.1.57.0.4.9C"), @@ -56,14 +56,14 @@ def test_dotted_lcc_id_to_hex(self): "02015700049C") # converted to uppercase OK self.assertNotEqual(dotted_lcc_id_to_hex("02.01.57.00.04.9c"), - "02015700049c") # function should convert to uppercase + "02015700049c") # function should convert to uppercase # noqa: E501 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("02")) # only_hex_pairs yet too short # noqa: E501 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 + self.assertIsNone(dotted_lcc_id_to_hex("_02015700049C")) # contains start character # noqa: E501 + self.assertIsNone(dotted_lcc_id_to_hex("org_product_02015700049C")) # service name not split # noqa: E501 def test_is_dotted_lcc_id(self): self.assertTrue(is_dotted_lcc_id("02.01.57.00.04.9C")) diff --git a/tests/test_localeventstore.py b/tests/test_localeventstore.py index 5127bcb..076b0c3 100644 --- a/tests/test_localeventstore.py +++ b/tests/test_localeventstore.py @@ -5,7 +5,7 @@ from openlcb.eventid import EventID -class TestLocalEventStorelass(unittest.TestCase): +class TestLocalEventStore(unittest.TestCase): def testConsumes(self) : store = LocalEventStore() diff --git a/tests/test_mdnsconventions.py b/tests/test_mdnsconventions.py index 5eef1f6..6856245 100644 --- a/tests/test_mdnsconventions.py +++ b/tests/test_mdnsconventions.py @@ -18,7 +18,7 @@ .format(repr(REPO_DIR))) -from openlcb.tcplink.mdnsconventions import id_from_tcp_service_name +from openlcb.tcplink.mdnsconventions import id_from_tcp_service_name # noqa: E402,E501 class TestMDNSConventions(unittest.TestCase): diff --git a/tests/test_memoryservice.py b/tests/test_memoryservice.py index 451002e..ddb53eb 100644 --- a/tests/test_memoryservice.py +++ b/tests/test_memoryservice.py @@ -19,16 +19,16 @@ " since test running from repo but could not find openlcb in {}." .format(repr(REPO_DIR))) -from openlcb.nodeid import NodeID -from openlcb.linklayer import LinkLayer -from openlcb.mti import MTI -from openlcb.message import Message -from openlcb.memoryservice import ( +from openlcb.nodeid import NodeID # noqa: E402 +from openlcb.linklayer import LinkLayer # noqa: E402 +from openlcb.mti import MTI # noqa: E402 +from openlcb.message import Message # noqa: E402 +from openlcb.memoryservice import ( # noqa: E402 MemoryReadMemo, MemoryWriteMemo, MemoryService, ) -from openlcb.datagramservice import ( +from openlcb.datagramservice import ( # noqa: E402 # DatagramWriteMemo, # DatagramReadMemo, DatagramService, diff --git a/tests/test_physicallayer.py b/tests/test_physicallayer.py deleted file mode 100644 index a9c234b..0000000 --- a/tests/test_physicallayer.py +++ /dev/null @@ -1,15 +0,0 @@ - -import unittest - -from openlcb.physicallayer import PhysicalLayer - - -class TestPhysicalLayerClass(unittest.TestCase): - - def testExample(self): - # TODO: Test what is possible without hardware. - pass - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_snip.py b/tests/test_snip.py index 9c5c3b4..4f4adb1 100644 --- a/tests/test_snip.py +++ b/tests/test_snip.py @@ -19,7 +19,7 @@ .format(repr(REPO_DIR))) -from openlcb.snip import SNIP +from openlcb.snip import SNIP # noqa: E402 class TestSnipClass(unittest.TestCase): @@ -156,12 +156,12 @@ def testReturnCyrillicStrings(self): s.modelName = "DEF" s.hardwareVersion = "1EF" s.softwareVersion = "2EF" - s.userProvidedNodeName = b'\xd0\x94\xd0\xbc\xd0\xb8\xd1\x82\xd1\x80\xd0\xb8\xd0\xb9'.decode("utf-8") # Cyrillic spelling of the name Dmitry + s.userProvidedNodeName = b'\xd0\x94\xd0\xbc\xd0\xb8\xd1\x82\xd1\x80\xd0\xb8\xd0\xb9'.decode("utf-8") # Cyrillic spelling of the name Dmitry # noqa: E501 s.userProvidedDescription = "4EF" s.updateSnipDataFromStrings() - self.assertEqual(s.getStringN(4), "Дмитрий") # Cyrillic spelling of the name Dmitry. This string should appear as 7 Cyrillic characters like Cyrillic-demo-Dmitry.png in doc (14 bytes in a hex editor), otherwise your editor does not support utf-8 and editing this file with it could break it. - # TODO: Russian version is Дми́трий according to . See Cyrillic-demo-Dmitry-Russian.png in doc. + self.assertEqual(s.getStringN(4), "Дмитрий") # Cyrillic spelling of the name Dmitry. This string should appear as 7 Cyrillic characters like Cyrillic-demo-Dmitry.png in doc (14 bytes in a hex editor), otherwise your editor does not support utf-8 and editing this file with it could break it. # noqa: E501 + # TODO: Russian version is Дми́трий according to . See Cyrillic-demo-Dmitry-Russian.png in doc. # noqa: E501 def testName(self): s = SNIP() # init to all zeros diff --git a/tests/test_tcplink.py b/tests/test_tcplink.py index 7033230..9999c5a 100644 --- a/tests/test_tcplink.py +++ b/tests/test_tcplink.py @@ -230,7 +230,7 @@ def testOneMessageThreePartsOneClump(self) : 0x00, 0x00, 0x00, 0x00, 0x01, 0x23, # source node ID 0x00, 0x00, 0x11, 0x00, 0x00, 0x00, # time 0x90, # second half MTI - # no data + # no data 0x80, 0x80, # part 3 0x00, 0x00, 18, From 5acc312fd9a780451b75047fd4558518ada4964a Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 24 Apr 2025 17:59:03 -0400 Subject: [PATCH 43/99] Add more states to CanLink to process alias reservation in steps (thread-safe but also works single-threaded application). Related to #62. FIXME: Needs debugging. --- .gitignore | 1 + examples/example_cdi_access.py | 12 +- examples/example_datagram_transfer.py | 8 + examples/example_frame_interface.py | 2 + examples/example_memory_length_query.py | 7 + examples/example_memory_transfer.py | 8 + examples/example_message_interface.py | 7 + examples/example_node_implementation.py | 7 + examples/example_remote_nodes.py | 8 + examples/examples_gui.py | 2 +- openlcb/canbus/canframe.py | 15 +- openlcb/canbus/canlink.py | 435 ++++++++++++++---- openlcb/canbus/canphysicallayer.py | 21 +- openlcb/canbus/canphysicallayergridconnect.py | 9 +- openlcb/dispatcher.py | 13 +- openlcb/eventid.py | 54 ++- openlcb/linklayer.py | 38 +- openlcb/message.py | 21 +- openlcb/node.py | 4 +- openlcb/nodeid.py | 50 +- openlcb/physicallayer.py | 8 +- openlcb/portinterface.py | 2 + openlcb/tcplink/tcplink.py | 5 + tests/test_canlink.py | 106 +++-- tests/test_canphysicallayergridconnect.py | 6 + tests/test_controlframe.py | 20 + tests/test_datagramservice.py | 11 + tests/test_dispatcher | 20 + tests/test_linklayer.py | 7 + tests/test_localnodeprocessor.py | 4 + tests/test_memoryservice.py | 4 + tests/test_mti.py | 8 + tests/test_node.py | 8 + tests/test_pip.py | 8 + tests/test_remotenodeprocessor.py | 6 +- 35 files changed, 762 insertions(+), 183 deletions(-) create mode 100644 tests/test_controlframe.py create mode 100644 tests/test_dispatcher diff --git a/.gitignore b/.gitignore index 3f3d8ae..8613e5d 100644 --- a/.gitignore +++ b/.gitignore @@ -183,3 +183,4 @@ cython_debug/ /build /doc/_autosummary /examples/settings.json +/.venv-3.12/ diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index b10b4de..40201ff 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -12,6 +12,8 @@ ''' # region same code as other examples from examples_settings import Settings # do 1st to fix path if no pip install +from openlcb import precise_sleep +from openlcb.canbus.canframe import CanFrame from openlcb.canbus.gridconnectobserver import GridConnectObserver from openlcb.tcplink.tcpsocket import TcpSocket settings = Settings() @@ -51,10 +53,12 @@ # " RL, SL are link interface; RM, SM are message interface") -def sendToSocket(frame): +def sendToSocket(frame: CanFrame): string = frame.encodeAsString() # print(" SR: {}".format(string.strip())) sock.sendString(string) + if frame.afterSendState: + canLink.setState(frame.afterSendState) def printFrame(frame): @@ -234,6 +238,8 @@ def processXML(content) : # print(" SL : link up") canPhysicalLayerGridConnect.physicalLayerUp() +while canLink.pollState() != CanLink.State.Permitted: + precise_sleep(.02) def memoryRead(): """Create and send a read datagram. @@ -248,7 +254,7 @@ def memoryRead(): # Standard, but wait slightly more for OS latency. # - Then wait longer below if there was a failure/retry, before # trying to use the LCC network: - while canLink.state != CanLink.State.Permitted: + while canLink._state != CanLink.State.Permitted: # Would only take more than ~200ms (possibly a few nanoseconds # more for latency on the part of this program itself) # if multiple alias collisions @@ -280,3 +286,5 @@ def memoryRead(): # ^ commented since MyHandler shows parsed XML fields instead # pass to link processor canPhysicalLayerGridConnect.pushChars(received) + +canLink.onDisconnect() diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index 203f75e..67468fe 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -11,6 +11,7 @@ ''' # region same code as other examples from examples_settings import Settings +from openlcb import precise_sleep from openlcb.canbus.gridconnectobserver import GridConnectObserver # do 1st to fix path if no pip install settings = Settings() @@ -51,6 +52,8 @@ def sendToSocket(frame): string = frame.encodeAsString() print(" SR: "+string.strip()) sock.sendString(string) + if frame.afterSendState: + canLink.setState(frame.afterSendState) def printFrame(frame): @@ -99,6 +102,8 @@ def datagramReceiver(memo): # have the socket layer report up to bring the link layer up and get an alias print(" SL : link up") canPhysicalLayerGridConnect.physicalLayerUp() +while canLink.pollState() != CanLink.State.Permitted: + precise_sleep(.02) def datagramWrite(): @@ -133,3 +138,6 @@ def datagramWrite(): print(" RR: "+packet_str.strip()) # pass to link processor canPhysicalLayerGridConnect.pushChars(received) + canLink.pollState() + +canLink.onDisconnect() diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index df30777..ce1b869 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -44,6 +44,8 @@ def sendToSocket(frame): string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) + if frame.afterSendState: + canLink.setState(frame.afterSendState) def printFrame(frame): diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index 29bf767..521e65c 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -17,6 +17,7 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples +from openlcb import precise_sleep from openlcb.canbus.gridconnectobserver import GridConnectObserver from openlcb.tcplink.tcpsocket import TcpSocket @@ -56,6 +57,8 @@ def sendToSocket(frame): string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) + if frame.afterSendState: + canLink.setState(frame.afterSendState) def printFrame(frame): @@ -118,6 +121,8 @@ def memoryLengthReply(address) : # have the socket layer report up to bring the link layer up and get an alias print(" SL : link up") canPhysicalLayerGridConnect.physicalLayerUp() +while canLink.pollState() != CanLink.State.Permitted: + precise_sleep(.02) def memoryRequest(): @@ -152,3 +157,5 @@ def memoryRequest(): print(" RR: "+packet_str.strip()) # pass to link processor canPhysicalLayerGridConnect.pushChars(received) + +canLink.onDisconnect() diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index 375ee8e..2a0a554 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -17,6 +17,7 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples +from openlcb import precise_sleep from openlcb.canbus.gridconnectobserver import GridConnectObserver from openlcb.tcplink.tcpsocket import TcpSocket @@ -56,6 +57,8 @@ def sendToSocket(frame): string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) + if frame.afterSendState: + canLink.setState(frame.afterSendState) def printFrame(frame): @@ -98,6 +101,7 @@ def printDatagram(memo): # callbacks to get results of memory read + def memoryReadSuccess(memo): """Handle a successful read @@ -116,6 +120,8 @@ def memoryReadFail(memo): # have the socket layer report up to bring the link layer up and get an alias print(" SL : link up") canPhysicalLayerGridConnect.physicalLayerUp() +while canLink.pollState() != CanLink.State.Permitted: + precise_sleep(.02) def memoryRead(): @@ -150,3 +156,5 @@ def memoryRead(): print(" RR: "+packet_str.strip()) # pass to link processor canPhysicalLayerGridConnect.pushChars(received) + +canLink.onDisconnect() \ No newline at end of file diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index 8434337..4af6644 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -19,6 +19,7 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples +from openlcb import precise_sleep from openlcb.canbus.gridconnectobserver import GridConnectObserver from openlcb.tcplink.tcpsocket import TcpSocket @@ -49,6 +50,8 @@ def sendToSocket(frame): string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) + if frame.afterSendState: + canLink.setState(frame.afterSendState) def printFrame(frame): @@ -72,6 +75,8 @@ def printMessage(msg): # have the socket layer report up to bring the link layer up and get an alias print(" SL : link up") canPhysicalLayerGridConnect.physicalLayerUp() +while canLink.pollState() != CanLink.State.Permitted: + precise_sleep(.02) # send an VerifyNodes message to provoke response message = Message(MTI.Verify_NodeID_Number_Global, @@ -91,3 +96,5 @@ def printMessage(msg): print(" RR: "+packet_str.strip()) # pass to link processor canPhysicalLayerGridConnect.pushChars(received) + +canLink.onDisconnect() diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index a8fc24c..e51501e 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -19,6 +19,7 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples +from openlcb import precise_sleep from openlcb.canbus.gridconnectobserver import GridConnectObserver from openlcb.tcplink.tcpsocket import TcpSocket @@ -62,6 +63,8 @@ def sendToSocket(frame): string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) + if frame.afterSendState: + canLink.setState(frame.afterSendState) def printFrame(frame): @@ -147,6 +150,8 @@ def displayOtherNodeIds(message) : # have the socket layer report up to bring the link layer up and get an alias print(" SL : link up") canPhysicalLayerGridConnect.physicalLayerUp() +while canLink.pollState() != CanLink.State.Permitted: + precise_sleep(.02) # request that nodes identify themselves so that we can print their node IDs message = Message(MTI.Verify_NodeID_Number_Global, @@ -165,3 +170,5 @@ def displayOtherNodeIds(message) : print(" RR: "+packet_str.strip()) # pass to link processor canPhysicalLayerGridConnect.pushChars(received) + +canLink.onDisconnect() diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index 3762c45..783f9e9 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -12,6 +12,7 @@ ''' # region same code as other examples from examples_settings import Settings +from openlcb import precise_sleep from openlcb.canbus.gridconnectobserver import GridConnectObserver # do 1st to fix path if no pip install settings = Settings() @@ -62,6 +63,8 @@ def sendToSocket(frame) : string = frame.encodeAsString() if settings['trace'] : print(" SR: "+string.strip()) sock.sendString(string) + if frame.afterSendState: + canLink.setState(frame.afterSendState) def receiveFrame(frame) : @@ -113,6 +116,9 @@ def receiveLoop() : # bring the CAN level up if settings['trace'] : print(" SL : link up") canPhysicalLayerGridConnect.physicalLayerUp() + while canLink.pollState() != CanLink.State.Permitted: + precise_sleep(.02) + while True: received = sock.receive() if settings['trace']: @@ -193,3 +199,5 @@ def result(arg1, arg2=None, arg3=None, result=True) : node.snip.userProvidedNodeName) # this ends here, which takes the local node offline + +canLink.onDisconnect() diff --git a/examples/examples_gui.py b/examples/examples_gui.py index 92acfc9..df2141b 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -544,7 +544,7 @@ def connect_state_changed(self, event_d): Therefore in this program, this is triggered during _listen in Dispatcher: Connecting is actually done until sendAliasAllocationSequence detects success and marks - canLink.state to CanLink.State.Permitted (which triggers + canLink._state to CanLink.State.Permitted (which triggers _handleMessage which calls this). - May also be directly called by _listen directly in case stopped listening (RuntimeError reading port, or other reason diff --git a/openlcb/canbus/canframe.py b/openlcb/canbus/canframe.py index 520566c..3137182 100644 --- a/openlcb/canbus/canframe.py +++ b/openlcb/canbus/canframe.py @@ -9,7 +9,7 @@ class NoEncoder: - def encodeFrameAsString(self, _): + def encodeFrameAsString(self, _) -> str: raise AssertionError( "You must set encoder on frame to a PhysicalLayer instance" " or class that has an encodeFrameAsString method that accepts" @@ -55,6 +55,10 @@ class CanFrame: PhysicalLayer subclass, since that layer determines the encoding). Must have an encodeFrameAsString method that accepts a CanFrame. + afterSendState (CanLink.State, optional): The frame incurs + a new state in the CanLink instance *after* socket send is + *complete*, at which time the PortInterface should call + setState(frame.afterSendState). """ ARG_LISTS = [ @@ -80,14 +84,15 @@ def __str__(self): list(self.data), # cast to list to format bytearray(b'') as [] ) - def encodeAsString(self): + def encodeAsString(self) -> str: return self.encoder.encodeFrameAsString(self) @property def alias(self) -> int: return self._alias - def __init__(self, *args): + def __init__(self, *args, afterSendState=None): + self.afterSendState = afterSendState self.encoder = NoEncoder() arg1 = None arg2 = None @@ -120,13 +125,15 @@ def __init__(self, *args): nodeID = arg2 self._alias = arg3 - nodeCode = ((nodeID.nodeId >> ((cid-4)*12)) & 0xFFF) + nodeCode = ((nodeID.value >> ((cid-4)*12)) & 0xFFF) # ^ cid-4 results in 0 to 3. *12 results in 0 to 36 bit shift (nodeID size) # noqa: E501 self.header = ((cid << 12) | nodeCode) << 12 | (self._alias & 0xFFF) | 0x10_00_00_00 # noqa: E501 # self.data = bytearray() # two arguments as header, data elif isinstance(arg2, bytearray): + # TODO: decode (header?) if self._alias is necessary in this case, + # otherwise is remains None! if not isinstance(arg1, int): args_error = "Expected int since 2nd argument is bytearray." # Types of both args are enforced by this point. diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index a3cf555..c7f7992 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -21,6 +21,7 @@ from enum import Enum from logging import getLogger +from timeit import default_timer from openlcb import emit_cast, formatted_ex, precise_sleep from openlcb.canbus.canframe import CanFrame @@ -35,12 +36,55 @@ class CanLink(LinkLayer): - - def __init__(self, localNodeID): # a NodeID - self.localAliasSeed = localNodeID.nodeId - self.localAlias = self.createAlias12(self.localAliasSeed) + """CAN link layer (manage stack's link state). + + Attributes: + ALIASES_RECEIVED_TIMEOUT (float): (seconds) CAN Frame Transfer - + Standard says to wait 200 ms for collisions, and if there + are no replies, the alias is good, otherwise increment and + restart alias reservation. + - However, in this implementation, require_remote_nodes + is True by default (See require_remote_nodes). + + Args: + localNodeID (NodeID): The node ID of the device itself + (localhost) running python-openlcb, or a virtual node + controlled by it. A node ID should be universally unique + (serialized by device), but each NodeID is mapped to an + alias that CanLink generates and guarantees is unique within + the network. + - Therefore, technically any valid NodeID can be used by + this openlcb stack as long as it is the same one used to + construct the CanLink (See getLocalAlias for details), but + use a unique one within a valid range at: + or discuss + reserving your own range there with OpenLCB if your + application/hardware does not apply to one of those + ranges. + require_remote_nodes (bool): If True, getting no external frames + (See isInternal) within ALIASES_RECEIVED_TIMEOUT (seconds) + causes an exception in pollState. Defaults to True, which is + non-standard: + - CAN Frame Transfer - Standard specifies that after 200ms + the node should assume _localAlias is ok (even if there + are 0 responses, in which case assume no other LCC nodes + are connected). + - In this implementation we at least expect an LCC hub + (otherwise there is no hardware connection, or an issue + with socket timing, call order, or another hard-coded + problem in the stack or application). + """ + + ALIAS_RESPONSE_DELAY = .2 # See docstring. + + def __init__(self, localNodeID, require_remote_nodes=True): # a NodeID + # See class docstring for args + self.require_remote_nodes = require_remote_nodes + self._waitingForAliasStart = None + self._localAliasSeed = localNodeID.value + self._localAlias = self.createAlias12(self._localAliasSeed) self.localNodeID = localNodeID - self.state = CanLink.State.Initial + self._state = CanLink.State.Initial self.link = None self._frameCount = 0 self._reserveAliasCollisions = 0 @@ -52,6 +96,61 @@ def __init__(self, localNodeID): # a NodeID self.nextInternallyAssignedNodeID = 1 LinkLayer.__init__(self, localNodeID) + # This method may never actually be necessary, as + # sendMessage uses nodeIdToAlias (which has localNodeID + # *only after* a successful reservation) + def getLocalAlias(self): + """Get the local alias, since it may differ from original + localNodeID given at construction: It may have been + reassigned (via incrementAlias48 and createAlias12 in + processCollision), therefore don't call this until state == + State.Permitted that indicates the alias is reserved (after + definedAndReserveAlias is successful). Before that, the + stack has no validated alias for sending a Message. + + Raises: + InterruptedError: When the state is not Permitted that + indicates that the alias reservation is not complete + (alias is not reserved, and may not be unique). + + Returns: + int: The local alias. + """ + if self._state != CanLink.State.Permitted: + raise InterruptedError( + "The alias reservation is not complete (state={})." + " Make sure defineAliasReservation (linkLayerUp) isn't" + " called in a way that blocks the socket receive thread," + " and that your application has a Message received listener" + " registered via registerMessageReceivedListener that" + " checks for MTI.Link_Layer_Up and MTI.Link_Layer_Down" + " and inhibits the usage of the openlcb stack if not up" + " unless you poll for" + " canlink.getState() == CanLink.State.Permitted in a" + " non-blocking manner." + .format(self._state) + ) + return self._localAlias + + def ready(self): + """Check if state == CanLink.State.Permitted + To find out if ready right away, check for + MTI.Link_Layer_Up and MTI.Link_Layer_Down and use them + to track the state in the application code. + """ + assert isinstance(self._state, CanLink.State) + return self._state == CanLink.State.Permitted + + def isDuplicateAlias(self, alias): + if not isinstance(alias, int): + raise NotImplementedError( + "Can't check for duplicate due to alias not stored as int." + " bytearray parsing must be implemented in CanFrame" + " constructor if this markDuplicateAlias scenario is valid" + " (alias={})." + .format(emit_cast(alias))) + return alias in self.duplicateAliases + def linkPhysicalLayer(self, cpl): """Set the physical layer to use. Also registers self.receiveListener as a listener on the given @@ -68,9 +167,70 @@ def linkPhysicalLayer(self, cpl): cpl.registerFrameReceivedListener(self.receiveListener) class State(Enum): - Initial = 1 # a special case of .Inhibited where init hasn't started + """This now behaves as a linux-like "runlevel" + so that defineAndReserveAlias is non-blocking. + + Attributes: + AllocatingAlias (State): Waiting for send of the last + reservation packet (after collision detection fully + done) to be success (wait for socket to notify us, + sendAfter is not enough) + """ + Initial = LinkLayer.State.Undefined.value # special case of .Inhibited + # where init hasn't started. Inhibited = 2 - Permitted = 3 + EnqueueAliasAllocationRequest = 3 + # enqueueCIDSequence sets: + BusyLocalCIDSequence = 4 + WaitingForSendCIDSequence = 5 + WaitForAliases = 6 # queued via last frame it sends + EnqueueAliasReservation = 7 # called by pollState if got aliases + # (or after fixed delay if require_remote_nodes is False) + # enqueueReserveID sets: + BusyLocalReserveID = 8 + WaitingForSendReserveID = 9 + + NotifyAliasReservation = 14 + + BusyLocalNotifyReservation = 11 + WaitingForLocalNotifyReservation = 12 + RecordAliasReservation = 13 + + + BusyLocalMappingAlias = 18 + Permitted = 20 # formerly 3 + + def _onStateChanged(self, _, newState): + # return super()._onStateChanged(oldState, newState) + assert isinstance(newState, CanLink.State) + if newState == CanLink.State.EnqueueAliasAllocationRequest: + self.enqueueCIDSequence() + # - sets state to BusyLocalCIDSequence + # - then at the end to WaitingForSendCIDSequence + # - then a packet sent sets state to WaitForAliases + # - then if wait is over, + # pollState sets state to EnqueueAliasReservation + elif newState == CanLink.State.EnqueueAliasReservation: + self.enqueueReserveID() # sets _state to + # - BusyLocalReserveID + # - WaitingForSendReserveID + # - NotifyAliasReservation (queued for after frame is sent) + elif newState == CanLink.State.NotifyAliasReservation: + self._notifyReservation() # sets _state to + # BusyLocalNotifyReservation + # then WaitingForLocalNotifyReservation + # then frame sent sets state to RecordAliasReservation + elif newState == CanLink.State.RecordAliasReservation: + # formerly _recordReservation was part of _notifyReservation + self._recordReservation() + # - BusyLocalMappingAlias + # (then adds our alias to the map) + # - sets _state to Permitted (queued for after frame is sent) + # - (state was formerly set to Permitted at end of the + # _notifyReservation code, before _recordReservation + # code) + + self.linkStateChange(newState) # Notify upper layers def receiveListener(self, frame): """Call the correct handler if any for a received frame. @@ -142,7 +302,7 @@ def handleReceivedLinkUp(self, frame): frame (CanFrame): A LinkUp frame. """ # start the alias allocation in Inhibited state - self.state = CanLink.State.Inhibited + self._state = CanLink.State.Inhibited if self.defineAndReserveAlias(): print("[CanLink] Notifying upper layers of LinkUp.") else: @@ -161,30 +321,75 @@ def handleReceivedLinkRestarted(self, frame): self.fireListeners(msg) def defineAndReserveAlias(self): - previousLocalAliasSeed = self.localAliasSeed - if not self.sendAliasAllocationSequence(): - logger.warning( - "Alias collision for {}. will try again." - .format(previousLocalAliasSeed)) - return False - + self.setState(CanLink.State.EnqueueAliasAllocationRequest) + # + # Use self.enqueueCIDSequence() instead, + # but actually trigger it in _onStateChanged + # via setState(CanLink.State.EnqueueAliasAllocationRequest) + # self.sendAliasAllocationSequence() + # + # Split up to the following (which was its docstring): + """ + This *must not block* the frame receive thread, since we must + wait 200ms and start sendAliasAllocationSequence over if + transmission error occurs, or an announcement with a Node ID + same as ours is received. + - In either case this method must *not* complete (*not* sending + RID is implied). + - In the latter case, our ID must be incremented before + sendAliasAllocationSequence starts over, and repeat this until + it is unique (no packets with senders matching it are + received) + - See section 6.2.1 of LCC "CAN Frame Transfer" Standard + + Returns: + bool: True if succeeded, False if collision. + """ + + def _notifyReservation(self): + """Send Alias Map Definition (AMD) + Triggered by last frame sent that was enqueued by + _recordReservation (NotifyAliasReservation) + """ + # formerly ran in defineAndReserveAlias since + # sendAliasAllocationSequence used to run all steps + # in a blocking manner before this code + # (and prevented this code on return False) + self.setState(CanLink.State.BusyLocalNotifyReservation) # send AMD frame, go to Permitted state - self.link.sendCanFrame(CanFrame(ControlFrame.AMD.value, - self.localAlias, - self.localNodeID.toArray())) - self.state = CanLink.State.Permitted + self.link.sendCanFrame( + CanFrame(ControlFrame.AMD.value, self._localAlias, + self.localNodeID.toArray(), + afterSendState=CanLink.State.RecordAliasReservation) + ) + self.setState(CanLink.State.WaitingForLocalNotifyReservation) + # self._state = CanLink.State.Permitted # not really ready + # (commented since network hasn't been notified yet)! + # and now WaitingForLocalNotifyReservation allows local packets + # (formerly Permitted was required even for ) + # wait for RecordAliasReservation state to call _recordReservation + + def _recordReservation(self): + """Triggered by RecordAliasReservation + + Formerly ran directly after code in _notifyReservation + but now we wait for the network to be aware of the node. + - Call from _notifyReservation instead if alias needs to be + mapped sooner. + """ + self.setState(CanLink.State.BusyLocalMappingAlias) # add to map - self.aliasToNodeID[self.localAlias] = self.localNodeID + self.aliasToNodeID[self._localAlias] = self.localNodeID logger.info( "defineAndReserveAlias setting nodeIdToAlias[{}]" " from a datagram from an unknown source" .format(self.localNodeID)) - self.nodeIdToAlias[self.localNodeID] = self.localAlias + self.nodeIdToAlias[self.localNodeID] = self._localAlias # send AME with no NodeID to get full alias map - self.link.sendCanFrame(CanFrame(ControlFrame.AME.value, - self.localAlias)) - self.linkStateChange(self.state) # Notify upper layers - return True + self.link.sendCanFrame( + CanFrame(ControlFrame.AME.value, self._localAlias, + afterSendState=CanLink.State.Permitted) + ) # TODO: (restart) Should this set inhibited every time? LinkUp not # called on restart @@ -197,14 +402,14 @@ def handleReceivedLinkDown(self, frame): frame (CanFrame): an link down frame. """ # NOTE: since no working link, not sending the AMR frame - self.state = CanLink.State.Inhibited + self._state = CanLink.State.Inhibited # print("***** received link down") # import traceback # traceback.print_stack() # notify upper levels - self.linkStateChange(self.state) + self.linkStateChange(self._state) def linkStateChange(self, state): """invoked when the link layer comes up and down @@ -225,11 +430,11 @@ def handleReceivedCID(self, frame): # CanFrame encoded in lower bits of frame.header (below ControlFrame.CID). """ # Does this carry our alias? - if (frame.header & 0xFFF) != self.localAlias: + if (frame.header & 0xFFF) != self._localAlias: return # no match # send an RID in response self.link.sendCanFrame(CanFrame(ControlFrame.RID.value, - self.localAlias)) + self._localAlias)) def handleReceivedRID(self, frame): # CanFrame """Handle a Reserve ID (RID) frame @@ -264,7 +469,7 @@ def handleReceivedAME(self, frame): # CanFrame """ if self.checkAndHandleAliasCollision(frame): return - if self.state != CanLink.State.Permitted: + if self._state != CanLink.State.Permitted: return # check node ID matchNodeID = self.localNodeID @@ -273,7 +478,7 @@ def handleReceivedAME(self, frame): # CanFrame if self.localNodeID == matchNodeID : # matched, send RID - returnFrame = CanFrame(ControlFrame.AMD.value, self.localAlias, + returnFrame = CanFrame(ControlFrame.AMD.value, self._localAlias, self.localNodeID.toArray()) self.link.sendCanFrame(returnFrame) @@ -337,15 +542,15 @@ def handleReceivedData(self, frame): # CanFrame destID = NodeID(0) # handle destination for addressed messages - dgCode = frame.header & 0x00F_000_000 - if frame.header & 0x008_000 != 0 \ - or (dgCode >= 0x00A_000_000 and dgCode <= 0x00F_000_000) : + dgCode = frame.header & 0x0_0F_00_00_00 + if frame.header & 0x00_80_00 != 0 \ + or (dgCode >= 0x0_0A_00_00_00 and dgCode <= 0x0_0F_00_00_00) : # Addressed bit is active 1 # decoder regular addressed message from Datagram - if (dgCode >= 0x00A_000_000 and dgCode <= 0x00F_000_000): + if (dgCode >= 0x0_0A_00_00_00 and dgCode <= 0x0_0F_00_00_00): # datagram case - destAlias = (frame.header & 0x00_FFF_000) >> 12 + destAlias = (frame.header & 0x00_FF_F0_00) >> 12 if destAlias in self.aliasToNodeID : destID = self.aliasToNodeID[destAlias] @@ -366,7 +571,7 @@ def handleReceivedData(self, frame): # CanFrame # check for start and end bits key = CanLink.AccumKey(mti, sourceID, destID) - if dgCode == 0x00A_000_000 or dgCode == 0x00B_000_000: + if dgCode == 0x0_0A_00_00_00 or dgCode == 0x0_0B_00_00_00: # start of message, create the entry in the accumulator self.accumulator[key] = bytearray() else: @@ -388,7 +593,7 @@ def handleReceivedData(self, frame): # CanFrame if len(frame.data) > 0: self.accumulator[key].extend(frame.data) - if dgCode == 0x00A_000_000 or dgCode == 0x00D_000_000: + if dgCode == 0x0_0A_00_00_00 or dgCode == 0x0_0D_00_00_00: # is end, ship and remove accumulation msg = Message(mti, sourceID, destID, self.accumulator[key]) self.fireListeners(msg) @@ -513,26 +718,26 @@ def sendMessage(self, msg): # multi-frame datagram dataSegments = self.segmentDatagramDataArray(msg.data) # send the first one - frame = CanFrame(header | 0x0B_000_000, dataSegments[0]) + frame = CanFrame(header | 0x0B_00_00_00, dataSegments[0]) self.link.sendCanFrame(frame) # send middles if len(dataSegments) >= 3: for index in range(1, len(dataSegments) - 2 + 1): # upper limit leaves one - frame = CanFrame(header | 0x0C_000_000, + frame = CanFrame(header | 0x0C_00_00_00, dataSegments[index]) self.link.sendCanFrame(frame) # send last one frame = CanFrame( - header | 0x0D_000_000, + header | 0x0D_00_00_00, dataSegments[len(dataSegments) - 1] ) self.link.sendCanFrame(frame) else: # all non-datagram cases # Remap the mti - header = 0x19_000_000 | ((msg.mti.value & 0xFFF) << 12) + header = 0x19_00_00_00 | ((msg.mti.value & 0xFFF) << 12) alias = self.nodeIdToAlias.get(msg.source) if alias is not None: # might not know it if error @@ -637,10 +842,10 @@ def segmentAddressedDataArray(self, alias, data): # MARK: common code def checkAndHandleAliasCollision(self, frame): - if self.state != CanLink.State.Permitted: + if self._state != CanLink.State.Permitted: return False - receivedAlias = frame.header & 0x0000_FFF - abort = (receivedAlias == self.localAlias) + receivedAlias = frame.header & 0x0_00_0F_FF + abort = (receivedAlias == self._localAlias) if abort: self.processCollision(frame) return abort @@ -649,8 +854,9 @@ def markDuplicateAlias(self, alias): if not isinstance(alias, int): raise NotImplementedError( "Can't mark collision due to alias not stored as int." - " bytearray paring must be implemented in CanFrame constructor" - " if this markDuplicateAlias scenario is valid (alias={})." + " bytearray parsing must be implemented in CanFrame" + " constructor if this markDuplicateAlias scenario is valid" + " (alias={})." .format(emit_cast(alias))) self.duplicateAliases.append(alias) @@ -662,43 +868,100 @@ def processCollision(self, frame) : " and attempt to get new alias".format(frame)) self.markDuplicateAlias(frame.alias) self.link.sendCanFrame(CanFrame(ControlFrame.AMR.value, - self.localAlias, + self._localAlias, self.localNodeID.toArray())) # Standard 6.2.5 - self.state = CanLink.State.Inhibited + self._state = CanLink.State.Inhibited # attempt to get a new alias and go back to .Permitted - self.localAliasSeed = self.incrementAlias48(self.localAliasSeed) - self.localAlias = self.createAlias12(self.localAliasSeed) + self._localAliasSeed = self.incrementAlias48(self._localAliasSeed) + self._localAlias = self.createAlias12(self._localAliasSeed) self.defineAndReserveAlias() - def sendAliasAllocationSequence(self): - '''Send the alias allocation sequence - This *must not block* the frame receive thread, since we must - wait 200ms and start sendAliasAllocationSequence over if - transmission error occurs, or an announcement with a Node ID - same as ours is received. - - In either case this method must *not* complete (*not* sending - RID is implied). - - In the latter case, our ID must be incremented before - sendAliasAllocationSequence starts over, and repeat this until - it is unique (no packets with senders matching it are - received) - - See section 6.2.1 of LCC "CAN Frame Transfer" Standard + # def sendAliasAllocationSequence(self): + # # actually, call self.enqueueCIDSequence() # sets _state and sends data + # raise DeprecationWarning("Use setState to BusyLocalCIDSequence instead.") + + def pollState(self): + """You must keep polling state after every time + a state change frame is sent, and after + every call to pushString or pushChars + for the stack to keep operating. + - calling this automatically *must not* be + implemented there, because this exists to + untether the processing from the socket + to make those calls non-blocking + (were blocking since sendAliasAllocationSequence + could be called in the case of processCollision) + - This being separate has the added benefit of the + stack being able to work in the same thread + as the application's (or Dispatcher's) + socket calls. + """ + assert isinstance(self._state, CanLink.State) + if self._state == CanLink.State.WaitForAliases: + if self._waitingForAliasStart is None: + self._waitingForAliasStart = default_timer() + else: + if ((default_timer() - self._waitingForAliasStart) + > CanLink.ALIAS_RESPONSE_DELAY): + if self.require_remote_nodes: + # keep the current state, in case + # application wants to try again. + raise ConnectionError( + "At least an LCC node was expected within 200ms." + " See require_remote_nodes documentation and" + " only set to True for Standard" + " (permissive) behavior") + # finish the sends for the alias reservation: + self.setState(CanLink.State.EnqueueAliasReservation) + elif self._state == CanLink.State.RecordAliasReservation: + self.finalizeAlias() + + return self.getState() + + def enqueueCIDSequence(self): + """Enqueue the four alias reservation step1 frames + (N_cid values 7, 6, 5, 4 respectively) + It is the responsibility of the application code + (socket/PortInterface thread) to set the next state using + frame.afterSendState. See afterSendState in CanFrame + documentation. + + Triggered by EnqueueAliasReservation + """ + self._previousLocalAliasSeed = self._localAliasSeed + self.setState(CanLink.State.BusyLocalCIDSequence) + # sending 7, 6, 5, 4 tells the LCC network we are a node, and other LCC + # nodes will respond with their NodeIDs and aliases (populates + # NodeIdToAlias, permitting openlcb to send to those + # destinations) + self.link.sendCanFrame(CanFrame(7, self.localNodeID, self._localAlias)) + self.link.sendCanFrame(CanFrame(6, self.localNodeID, self._localAlias)) + self.link.sendCanFrame(CanFrame(5, self.localNodeID, self._localAlias)) + self.link.sendCanFrame( + CanFrame(4, self.localNodeID, self._localAlias, + afterSendState=CanLink.State.WaitForAliases) + ) + self._previousCollisions = self._reserveAliasCollisions + self._previousFrameCount = self._frameCount + self._previousLocalAliasSeed = self._localAliasSeed + self.setState(CanLink.State.WaitingForSendCIDSequence) + + def enqueueReserveID(self): + """Send Reserve ID (RID) + If no collision after `CanLink.ALIAS_RESPONSE_DELAY`, + but this will not be called in no-response case if + `require_remote_nodes` is `True`. + """ + self._waitingForAliasStart = None # done waiting for reply to 7,6,5,4 + self.setState(CanLink.State.BusyLocalReserveID) + # precise_sleep(.2) # Waiting 200ms as per section 6.2.1 + # is now done by pollState (application must keep polling after + # sending and receiving data) based on _waitingForAliasStart. - Returns: - bool: True if succeeded, False if collision. - ''' - self.link.sendCanFrame(CanFrame(7, self.localNodeID, self.localAlias)) - self.link.sendCanFrame(CanFrame(6, self.localNodeID, self.localAlias)) - self.link.sendCanFrame(CanFrame(5, self.localNodeID, self.localAlias)) - self.link.sendCanFrame(CanFrame(4, self.localNodeID, self.localAlias)) - previousCollisions = self._reserveAliasCollisions - previousFrameCount = self._frameCount - previousLocalAliasSeed = self.localAliasSeed - precise_sleep(.2) # wait 200ms as per section 6.2.1 - # ("Reserving a Node ID Alias") of + # See ("Reserving a Node ID Alias") of # LCC "CAN Frame Transfer" Standard - responseCount = self._frameCount - previousFrameCount + responseCount = self._frameCount - self._previousFrameCount if responseCount < 1: logger.warning( "sendAliasAllocationSequence may be blocking the receive" @@ -707,7 +970,7 @@ def sendAliasAllocationSequence(self): " reservation request. If there any other nodes, this is" " an error and this method should *not* continue sending" " Reserve ID (RID) frame)...") - if self._reserveAliasCollisions > previousCollisions: + if self._reserveAliasCollisions > self._previousCollisions: # processCollision will increment the non-unique alias try # defineAndReserveAlias again (so stop before completing # the sequence as per Standard) @@ -715,7 +978,7 @@ def sendAliasAllocationSequence(self): "Cancelled reservation of duplicate local alias seed {}" " (processCollision increments ID to avoid," " & restarts sequence)." - .format(previousLocalAliasSeed)) + .format(self._previousLocalAliasSeed)) return False if responseCount < 1: logger.warning( @@ -723,7 +986,7 @@ def sendAliasAllocationSequence(self): "--no response, so assuming alias seed {} is unique" " (If there are any other nodes on the network then" " a thread, the call order, or network connection failed!)." - .format(self.localAliasSeed)) + .format(self._localAliasSeed)) # precise_sleep(.2) # wait for another collision wait term # responseCount = self._frameCount - previousFrameCount # NOTE: If we were to loop here, then we would have to @@ -732,9 +995,11 @@ def sendAliasAllocationSequence(self): # occur. However, stopping here would not be valid anyway # since we can't know for sure we aren't the only node, # and if we are the only node no responses are expected. - self.link.sendCanFrame(CanFrame(ControlFrame.RID.value, - self.localAlias)) - return True + self.link.sendCanFrame( + CanFrame(ControlFrame.RID.value, self._localAlias, + afterSendState=CanLink.State.NotifyAliasReservation) + ) + self.setState(CanLink.State.WaitingForSendReserveID) def incrementAlias48(self, oldAlias): ''' @@ -744,7 +1009,7 @@ def incrementAlias48(self, oldAlias): where c = 29,741,096,258,473 or 0x1B0CA37A4BA9 ''' - newProduct = (oldAlias << 9) + oldAlias + (0x1B0CA37A4BA9) + newProduct = (oldAlias << 9) + oldAlias + (0x1B_0C_A3_7A_4B_A9) maskedProduct = newProduct & 0xFFFF_FFFF_FFFF return maskedProduct @@ -774,7 +1039,7 @@ def decodeControlFrameFormat(self, frame): return ControlFrame.CID try: - retval = ControlFrame((frame.header >> 12) & 0x2FFFF) + retval = ControlFrame((frame.header >> 12) & 0x2_FF_FF) return retval # top 1 bit for out-of-band messages except KeyboardInterrupt: raise diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index 8120367..567134f 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -4,15 +4,34 @@ This is a class because it represents a single physical connection to a layout and is subclassed. ''' +import sys from openlcb.canbus.canframe import CanFrame from openlcb.canbus.controlframe import ControlFrame from openlcb.physicallayer import PhysicalLayer class CanPhysicalLayer(PhysicalLayer): + """Can implementation of PhysicalLayer, still partly abstract + (No encodeFrameAsString, since this binary layer may be wrapped by + the higher layer such as the text-based CanPhysicalLayerGridConnect) - def __init__(self): + Args: + waitForSendCallback (callable): This *must* be a thread-blocking + callback so that the caller knows the timeline for when to + expect a response. + """ + + def __init__(self, waitForSendCallback): self.listeners = [] + if not waitForSendCallback: + raise ValueError("Provide a blocking waitForSend function") + sys.stderr.write("Validating waitForSendCallback...") + sys.stderr.flush() + waitForSendCallback() # asserts that the callback works. + # If it raises an error or halts the program, the value is bad + # (The application code is incorrect, so prevent startup). + print("OK", file=sys.stderr) + self.waitForSend = waitForSendCallback def sendCanFrame(self, frame: CanFrame): '''basic abstract interface''' diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index c06aea9..b2c6b00 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -28,10 +28,13 @@ class CanPhysicalLayerGridConnect(CanPhysicalLayer): on failure so that sendAliasAllocationSequence is interrupted in order to prevent canlink.state from proceeding to CanLink.State.Permitted) + waitForSendCallback (callable): This *must* be a thread-blocking + callback so that the caller knows the timeline for when to + expect a response (Since that would be sometime after the + actual socket sends all queued frame(s)). """ - - def __init__(self, callback): - CanPhysicalLayer.__init__(self) + def __init__(self, callback, waitForSendCallback): + CanPhysicalLayer.__init__(self, waitForSendCallback) self.setCallBack(callback) self.inboundBuffer = bytearray() diff --git a/openlcb/dispatcher.py b/openlcb/dispatcher.py index e198f45..2705fbc 100644 --- a/openlcb/dispatcher.py +++ b/openlcb/dispatcher.py @@ -207,6 +207,9 @@ def start_listening(self, connected_port, localNodeID): self._callback_status("physicalLayerUp...") self._canPhysicalLayerGridConnect.physicalLayerUp() + while canLink.pollState() != CanLink.State.Permitted: + precise_sleep(.02) + # ^ triggers fireListeners which calls CanLink's default # receiveListener by default since added on CanPhysicalLayer # arg of linkPhysicalLayer. @@ -255,15 +258,17 @@ def _listen(self): # (causes undefined behavior)! frame = self._sends.pop() if isinstance(frame, CanFrame): - if frame.alias in self._canLink.duplicateAliases: + if self._canLink.isDuplicateAlias(frame.alias): logger.warning( - "Discarded remnant of previous" + "Discarded frame from a previous" " alias reservation attempt" " (duplicate alias={})" .format(frame.alias)) continue logger.debug("[_listen] _sendString...") self._port.sendString(frame.encodeAsString()) + if frame.afterSendState: + self._canLink.setState(frame.afterSendState) else: raise NotImplementedError( "Event type {} is not handled." @@ -287,7 +292,7 @@ def _listen(self): # via registerFrameReceivedListener during connect. precise_sleep(.01) # let processor sleep briefly before read if time.perf_counter() - self._connecting_t > .2: - if self._canLink.state != CanLink.State.Permitted: + if self._canLink._state != CanLink.State.Permitted: if ((self._message_t is None) or (time.perf_counter() - self._message_t > 1)): @@ -316,6 +321,8 @@ def _listen(self): self._element_listener(event_d) self._mode = Dispatcher.Mode.Disconnected raise # re-raise since incomplete (prevent done OK state) + finally: + self._canLink.onDisconnect() self._listen_thread = None self._mode = Dispatcher.Mode.Disconnected diff --git a/openlcb/eventid.py b/openlcb/eventid.py index 1fcbbb7..829e20e 100644 --- a/openlcb/eventid.py +++ b/openlcb/eventid.py @@ -3,9 +3,7 @@ Created by Bob Jacobsen on 6/1/22. -Represents an 8-byte event ID. -Provides conversion to and from Ints and Strings in standard form. ''' @@ -13,6 +11,14 @@ class EventID: + """Represents an 8-byte event ID. + Provides conversion to and from Ints and Strings in standard form. + + Attributes: + value (int): 8-byte event ID (ints are scalable in Python, but + it represents a UInt64). Formerly eventId (renamed for + clarity since it is an int not an EventID instance). + """ def __str__(self): '''Display in standard format''' c = self.toArray() @@ -22,33 +28,33 @@ def __str__(self): # Convert an integer, list, EventID or string to an EventID def __init__(self, data): if isinstance(data, int): # create from an integer value - self.eventId = data + self.value = data elif isinstance(data, str): # need to allow for 1 digit numbers parts = data.split(".") result = 0 for part in parts: result = result*0x100+int(part, 16) - self.eventId = result + self.value = result elif isinstance(data, EventID): - self.eventId = data.eventId + self.value = data.value elif isinstance(data, bytearray): - self.eventId = 0 + self.value = 0 if (len(data) > 0): - self.eventId |= (data[0] & 0xFF) << 56 + self.value |= (data[0] & 0xFF) << 56 if (len(data) > 1): - self.eventId |= (data[1] & 0xFF) << 48 + self.value |= (data[1] & 0xFF) << 48 if (len(data) > 2): - self.eventId |= (data[2] & 0xFF) << 40 + self.value |= (data[2] & 0xFF) << 40 if (len(data) > 3): - self.eventId |= (data[3] & 0xFF) << 32 + self.value |= (data[3] & 0xFF) << 32 if (len(data) > 4): - self.eventId |= (data[4] & 0xFF) << 24 + self.value |= (data[4] & 0xFF) << 24 if (len(data) > 5): - self.eventId |= (data[5] & 0xFF) << 16 + self.value |= (data[5] & 0xFF) << 16 if (len(data) > 6): - self.eventId |= (data[6] & 0xFF) << 8 + self.value |= (data[6] & 0xFF) << 8 if (len(data) > 7): - self.eventId |= (data[7] & 0xFF) + self.value |= (data[7] & 0xFF) # elif isinstance(data, list): else: raise TypeError("invalid data type to EventID constructor: {}" @@ -56,20 +62,20 @@ def __init__(self, data): def toArray(self): return bytearray([ - (self.eventId >> 56) & 0xFF, - (self.eventId >> 48) & 0xFF, - (self.eventId >> 40) & 0xFF, - (self.eventId >> 32) & 0xFF, - (self.eventId >> 24) & 0xFF, - (self.eventId >> 16) & 0xFF, - (self.eventId >> 8) & 0xFF, - (self.eventId) & 0xFF + (self.value >> 56) & 0xFF, + (self.value >> 48) & 0xFF, + (self.value >> 40) & 0xFF, + (self.value >> 32) & 0xFF, + (self.value >> 24) & 0xFF, + (self.value >> 16) & 0xFF, + (self.value >> 8) & 0xFF, + (self.value) & 0xFF ]) def __eq__(self, other): - if self.eventId != other.eventId: + if self.value != other.value: return False return True def __hash__(self): - return hash(self.eventId) + return hash(self.value) diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index 8d9328c..360e721 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -16,6 +16,9 @@ ''' +from enum import Enum + + class LinkLayer: """Abstract Link Layer interface @@ -23,10 +26,43 @@ class LinkLayer: listeners (list[Callback]): local list of listener callbacks. See subclass for default listener and more specific callbacks called from there. + _state: The state (a.k.a. "runlevel" in linux terms) + of the network link. This may be moved to an overall + stack handler such as Dispatcher. + State (class(Enum)): values for _state. Implement in subclass. + This may be moved to an overall stack handler such as + Dispatcher. """ + class State(Enum): + Undefined = 1 # subclass constructor did not run (implement states) + def __init__(self, localNodeID): self.localNodeID = localNodeID + self.listeners = [] + self._state = LinkLayer.State.Undefined + + def onDisconnect(self): + """Run this whenever the socket connection is lost + and override _onStateChanged to handle the change. + * If you override this, you *must* call + `LinkLayer.onDisconnect(self)` to trigger _onStateChanged + if the implementation utilizes getState. + """ + self._setState(LinkLayer.State.Undefined) + + def getState(self): + return self._state + + def setState(self): + oldState = self._state + newState = 0 # keep a copy for _onStateChanged, for thread safety + self._state = newState + self._onStateChanged(self, oldState, newState) + + def _onStateChanged(self, oldState, newState): + raise NotImplementedError( + "[LinkLayer] abstract _onStateChanged not implemented") def sendMessage(self, msg): '''This is the basic abstract interface @@ -35,8 +71,6 @@ def sendMessage(self, msg): def registerMessageReceivedListener(self, listener): self.listeners.append(listener) - listeners = [] - def fireListeners(self, msg): for listener in self.listeners: listener(msg) diff --git a/openlcb/message.py b/openlcb/message.py index d6d281f..bbfe308 100644 --- a/openlcb/message.py +++ b/openlcb/message.py @@ -4,6 +4,10 @@ ''' +from openlcb.mti import MTI +from openlcb.node import Node + + class Message: """basic message, with an MTI, source, destination? and data content @@ -15,16 +19,24 @@ class Message: empty bytearray(). """ - def __init__(self, mti, source, destination, data=bytearray()): + def __init__(self, mti, source, destination, data=None): # For args, see class docstring. + if data is None: + data = bytearray() self.mti = mti self.source = source self.destination = destination + self.assertTypes() if not isinstance(data, bytearray): raise TypeError("Expected bytearray, got {}" .format(type(data).__name__)) self.data = data + def assertTypes(self): + assert isinstance(self.mti, MTI) + assert isinstance(self.source, Node) + assert isinstance(self.destination, Node) + def isGlobal(self): return self.mti.value & 0x0008 == 0 @@ -37,12 +49,19 @@ def __str__(self): def __eq__(lhs, rhs): if rhs is None: return False + lhs.assertTypes() + rhs.assertTypes() if rhs.mti != lhs.mti: return False if rhs.source != lhs.source: return False if rhs.destination != lhs.destination: return False + if not isinstance(rhs.data, type(lhs.data)): + raise TypeError( + "Tried to compare a {} to a {}" + " (expected bytearray for Message().data)" + .format(type(lhs.data).__name__, type(rhs.data).__name__)) if rhs.data != lhs.data: return False return True diff --git a/openlcb/node.py b/openlcb/node.py index dda078b..53a9d21 100644 --- a/openlcb/node.py +++ b/openlcb/node.py @@ -67,7 +67,7 @@ def __hash__(self): return hash(self.id) def __gt__(lhs, rhs): - return lhs.id.nodeId > rhs.id.nodeId + return lhs.id.value > rhs.id.value def __lt__(lhs, rhs): - return lhs.id.nodeId < rhs.id.nodeId + return lhs.id.value < rhs.id.value diff --git a/openlcb/nodeid.py b/openlcb/nodeid.py index a150191..da34ed6 100644 --- a/openlcb/nodeid.py +++ b/openlcb/nodeid.py @@ -4,18 +4,20 @@ class 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 (bytearray): If bytearray (formerly list[int]). Six ints. + - NodeID_value (int): If int. + - NodeID_value (str): If str. Six dot-separated hex pairs. + - NodeID_value (NodeID): If NodeID. data.nodeID is used in this case. + - NodeID_value (bytearray): If bytearray (formerly list[int]). Six ints. Args: data (Union[int,str,NodeID,list[int]]): Node ID in int, dotted hex string, NodeID, or list[int] form. Attributes: - nodeId (int): The node id in int form (uses 48 bits, so Python - will allocate 64-bit or larger int) + value (int): The node id in int form (uses 48 bits, so Python + will allocate 64-bit or larger int). Formerly nodeID + (renamed for clarity especially when using it in other code + since it is an int not a NodeID) """ def __str__(self): '''Display in standard format''' @@ -26,7 +28,7 @@ def __str__(self): def __init__(self, data): # For args see class docstring. if isinstance(data, int): # create from an integer value - self.nodeId = data + self.value = data elif isinstance(data, str): parts = data.split(".") result = 0 @@ -36,23 +38,23 @@ def __init__(self, data): " but got {}".format(emit_cast(data))) for part in parts: result = result*0x100+int(part, 16) - self.nodeId = result + self.value = result elif isinstance(data, NodeID): - self.nodeId = data.nodeId + self.value = data.value elif isinstance(data, bytearray): - self.nodeId = 0 + self.value = 0 if (len(data) > 0): - self.nodeId |= (data[0] & 0xFF) << 40 + self.value |= (data[0] & 0xFF) << 40 if (len(data) > 1): - self.nodeId |= (data[1] & 0xFF) << 32 + self.value |= (data[1] & 0xFF) << 32 if (len(data) > 2): - self.nodeId |= (data[2] & 0xFF) << 24 + self.value |= (data[2] & 0xFF) << 24 if (len(data) > 3): - self.nodeId |= (data[3] & 0xFF) << 16 + self.value |= (data[3] & 0xFF) << 16 if (len(data) > 4): - self.nodeId |= (data[4] & 0xFF) << 8 + self.value |= (data[4] & 0xFF) << 8 if (len(data) > 5): - self.nodeId |= (data[5] & 0xFF) + self.value |= (data[5] & 0xFF) elif isinstance(data, list): print("invalid data type to nodeid constructor." " Expected bytearray (formerly list[int])" @@ -62,21 +64,21 @@ def __init__(self, data): def toArray(self): return bytearray([ - (self.nodeId >> 40) & 0xFF, - (self.nodeId >> 32) & 0xFF, - (self.nodeId >> 24) & 0xFF, - (self.nodeId >> 16) & 0xFF, - (self.nodeId >> 8) & 0xFF, - (self.nodeId) & 0xFF + (self.value >> 40) & 0xFF, + (self.value >> 32) & 0xFF, + (self.value >> 24) & 0xFF, + (self.value >> 16) & 0xFF, + (self.value >> 8) & 0xFF, + (self.value) & 0xFF ]) def __eq__(self, other): if other is None: return False - if self.nodeId != other.nodeId: + if self.value != other.value: return False return True def __hash__(self): - return hash(self.nodeId) + return hash(self.value) diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index d89aded..382ad7d 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -19,5 +19,9 @@ def physicalLayerDown(self): """abstract method""" raise NotImplementedError("Each subclass must implement this.") - def encodeFrameAsString(self, frame): - raise NotImplementedError("Each subclass must implement this.") \ No newline at end of file + def waitForSend(self): + """abstract method (*must* block thread: See implementation(s))""" + raise NotImplementedError("Each subclass must implement this.") + + def encodeFrameAsString(self, frame) -> str: + raise NotImplementedError("Each subclass must implement this.") diff --git a/openlcb/portinterface.py b/openlcb/portinterface.py index 3f00cba..33544a2 100644 --- a/openlcb/portinterface.py +++ b/openlcb/portinterface.py @@ -28,6 +28,8 @@ class PortInterface: once (on different threads), which would cause undefined behavior (in OS-level implementation of serial port or socket). """ + # FIXME: enforce frame.afterSendState (and deprecate waitForSend in + # sendAliasAllocationSequence) ports = [] def __init__(self): diff --git a/openlcb/tcplink/tcplink.py b/openlcb/tcplink/tcplink.py index 0695986..cd0a3fa 100644 --- a/openlcb/tcplink/tcplink.py +++ b/openlcb/tcplink/tcplink.py @@ -49,6 +49,10 @@ def linkPhysicalLayer(self, lpl): """ self.linkCall = lpl + def _onStateChanged(self, oldState, newState): + print(f"[TcpLink] _onStateChanged from {oldState} to {newState}" + " (nothing to do since TcpLink)") + def receiveListener(self, inputData): # [] input """Receives bytes from lower level and accumulates them into individual message parts. @@ -56,6 +60,7 @@ def receiveListener(self, inputData): # [] input Args: inputData ([int]) : next chunk of the input stream """ + assert isinstance(inputData, bytearray) self.accumulatedData.extend(inputData) # Now check it if has one or more complete message. while len(self.accumulatedData) > 0 : diff --git a/tests/test_canlink.py b/tests/test_canlink.py index ec501b1..358b57d 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -1,6 +1,7 @@ import unittest +from openlcb import precise_sleep from openlcb.canbus.canlink import CanLink from openlcb.canbus.canframe import CanFrame @@ -45,6 +46,7 @@ def testIncrementAlias48(self): # test shift and multiplication operations next = canLink.incrementAlias48(0x0000_0000_0001) self.assertEqual(next, 0x1B0C_A37A_4DAA) + canLink.onDisconnect() def testIncrementAliasSequence(self): canLink = CanLink(NodeID("05.01.01.01.03.01")) @@ -64,6 +66,7 @@ def testIncrementAliasSequence(self): next = canLink.incrementAlias48(next) self.assertEqual(next, 0xE5_82_F9_B4_AE_4D) + canLink.onDisconnect() def testCreateAlias12(self): canLink = CanLink(NodeID("05.01.01.01.03.01")) @@ -81,21 +84,25 @@ def testCreateAlias12(self): self.assertEqual(canLink.createAlias12(0x0000), 0xAEF, "zero input check") + canLink.onDisconnect() # MARK: - Test PHY Up def testLinkUpSequence(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLink(NodeID("05.01.01.01.03.01"), require_remote_nodes=False) canLink.linkPhysicalLayer(canPhysicalLayer) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() + while canLink.pollState() != CanLink.State.Permitted: + precise_sleep(.02) self.assertEqual(len(canPhysicalLayer.receivedFrames), 7) - self.assertEqual(canLink.state, CanLink.State.Permitted) + self.assertEqual(canLink._state, CanLink.State.Permitted) self.assertEqual(len(messageLayer.receivedMessages), 1) + canLink.onDisconnect() # MARK: - Test PHY Down, Up, Error Information def testLinkDownSequence(self): @@ -104,29 +111,31 @@ def testLinkDownSequence(self): canLink.linkPhysicalLayer(canPhysicalLayer) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) - canLink.state = CanLink.State.Permitted + canLink._state = CanLink.State.Permitted canPhysicalLayer.physicalLayerDown() - self.assertEqual(canLink.state, CanLink.State.Inhibited) + self.assertEqual(canLink._state, CanLink.State.Inhibited) self.assertEqual(len(messageLayer.receivedMessages), 1) + canLink.onDisconnect() def testEIR2NoData(self): canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLink(NodeID("05.01.01.01.03.01")) canLink.linkPhysicalLayer(canPhysicalLayer) - canLink.state = CanLink.State.Permitted + canLink._state = CanLink.State.Permitted canPhysicalLayer.fireListeners(CanFrame(ControlFrame.EIR2.value, 0)) self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + canLink.onDisconnect() # MARK: - Test AME (Local Node) def testAMENoData(self): canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLink(NodeID("05.01.01.01.03.01")) canLink.linkPhysicalLayer(canPhysicalLayer) - ourAlias = canLink.localAlias # 576 with NodeID(0x05_01_01_01_03_01) - canLink.state = CanLink.State.Permitted + ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) + canLink._state = CanLink.State.Permitted canPhysicalLayer.fireListeners(CanFrame(ControlFrame.AME.value, 0)) self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) @@ -135,22 +144,24 @@ def testAMENoData(self): CanFrame(ControlFrame.AMD.value, ourAlias, canLink.localNodeID.toArray()) ) + canLink.onDisconnect() def testAMEnoDataInhibited(self): canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLink(NodeID("05.01.01.01.03.01")) canLink.linkPhysicalLayer(canPhysicalLayer) - canLink.state = CanLink.State.Inhibited + canLink._state = CanLink.State.Inhibited canPhysicalLayer.fireListeners(CanFrame(ControlFrame.AME.value, 0)) self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + canLink.onDisconnect() def testAMEMatchEvent(self): canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLink(NodeID("05.01.01.01.03.01")) - ourAlias = canLink.localAlias # 576 with NodeID(0x05_01_01_01_03_01) + ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink.linkPhysicalLayer(canPhysicalLayer) - canLink.state = CanLink.State.Permitted + canLink._state = CanLink.State.Permitted frame = CanFrame(ControlFrame.AME.value, 0) frame.data = bytearray([5, 1, 1, 1, 3, 1]) @@ -159,38 +170,41 @@ def testAMEMatchEvent(self): self.assertEqual(canPhysicalLayer.receivedFrames[0], CanFrame(ControlFrame.AMD.value, ourAlias, canLink.localNodeID.toArray())) + canLink.onDisconnect() def testAMENotMatchEvent(self): canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLink(NodeID("05.01.01.01.03.01")) canLink.linkPhysicalLayer(canPhysicalLayer) - canLink.state = CanLink.State.Permitted + canLink._state = CanLink.State.Permitted frame = CanFrame(ControlFrame.AME.value, 0) frame.data = bytearray([0, 0, 0, 0, 0, 0]) canPhysicalLayer.fireListeners(frame) self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + canLink.onDisconnect() # MARK: - Test Alias Collisions (Local Node) def testCIDreceivedMatch(self): canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLink(NodeID("05.01.01.01.03.01")) - ourAlias = canLink.localAlias # 576 with NodeID(0x05_01_01_01_03_01) + ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink.linkPhysicalLayer(canPhysicalLayer) - canLink.state = CanLink.State.Permitted + canLink._state = CanLink.State.Permitted canPhysicalLayer.fireListeners(CanFrame(7, canLink.localNodeID, ourAlias)) self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) self.assertEqual(canPhysicalLayer.receivedFrames[0], CanFrame(ControlFrame.RID.value, ourAlias)) + canLink.onDisconnect() def testRIDreceivedMatch(self): canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLink(NodeID("05.01.01.01.03.01")) - ourAlias = canLink.localAlias # 576 with NodeID(0x05_01_01_01_03_01) + ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink.linkPhysicalLayer(canPhysicalLayer) - canLink.state = CanLink.State.Permitted + canLink._state = CanLink.State.Permitted canPhysicalLayer.fireListeners(CanFrame(ControlFrame.RID.value, ourAlias)) @@ -202,7 +216,8 @@ def testRIDreceivedMatch(self): self.assertEqual(canPhysicalLayer.receivedFrames[6], CanFrame(ControlFrame.AMD.value, 0x539, bytearray([5, 1, 1, 1, 3, 1]))) # new alias - self.assertEqual(canLink.state, CanLink.State.Permitted) + self.assertEqual(canLink._state, CanLink.State.Permitted) + canLink.onDisconnect() def testCheckMTIMapping(self): @@ -218,6 +233,7 @@ def testControlFrameDecode(self): frame = CanFrame(0x1000, 0x000) # invalid control frame content self.assertEqual(canLink.decodeControlFrameFormat(frame), ControlFrame.UnknownFormat) + canLink.onDisconnect() def testControlFrameIsInternal(self): self.assertFalse(ControlFrame.isInternal(ControlFrame.AMD)) @@ -260,7 +276,7 @@ def testSimpleGlobalData(self): canLink.linkPhysicalLayer(canPhysicalLayer) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) - canLink.state = CanLink.State.Permitted + canLink._state = CanLink.State.Permitted # map an alias we'll use amd = CanFrame(0x0701, 0x247) @@ -279,6 +295,7 @@ def testSimpleGlobalData(self): MTI.Verify_NodeID_Number_Global) self.assertEqual(messageLayer.receivedMessages[0].source, NodeID(0x010203040506)) + canLink.onDisconnect() def testVerifiedNodeInDestAliasMap(self): # JMRI doesn't send AMD, so gets assigned 00.00.00.00.00.00 @@ -289,7 +306,7 @@ def testVerifiedNodeInDestAliasMap(self): canLink.linkPhysicalLayer(canPhysicalLayer) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) - canLink.state = CanLink.State.Permitted + canLink._state = CanLink.State.Permitted # Don't map an alias with an AMD for this test @@ -306,6 +323,7 @@ def testVerifiedNodeInDestAliasMap(self): MTI.Verified_NodeID) self.assertEqual(messageLayer.receivedMessages[0].source, NodeID(0x080706050403)) + canLink.onDisconnect() def testNoDestInAliasMap(self): '''Tests handling of a message with a destination alias not in map @@ -317,7 +335,7 @@ def testNoDestInAliasMap(self): canLink.linkPhysicalLayer(canPhysicalLayer) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) - canLink.state = CanLink.State.Permitted + canLink._state = CanLink.State.Permitted # Don't map an alias with an AMD for this test @@ -334,22 +352,25 @@ def testNoDestInAliasMap(self): MTI.Identify_Events_Addressed) self.assertEqual(messageLayer.receivedMessages[0].source, NodeID(0x000000000001)) + canLink.onDisconnect() def testSimpleAddressedData(self): # Test start=yes, end=yes frame canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLink(NodeID("05.01.01.01.03.01"), require_remote_nodes=False) canLink.linkPhysicalLayer(canPhysicalLayer) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() + while canLink.pollState() != CanLink.State.Permitted: + precise_sleep(.02) # map an alias we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) canPhysicalLayer.fireListeners(amd) - ourAlias = canLink.localAlias # 576 with NodeID(0x05_01_01_01_03_01) + ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) frame = CanFrame(0x19488, 0x247) # Verify Node ID Addressed frame.data = bytearray([((ourAlias & 0x700) >> 8), (ourAlias & 0xFF), 12, 13]) @@ -367,21 +388,24 @@ def testSimpleAddressedData(self): # Test start=yes, end=yes frame self.assertEqual(len(messageLayer.receivedMessages[1].data), 2) self.assertEqual(messageLayer.receivedMessages[1].data[0], 12) self.assertEqual(messageLayer.receivedMessages[1].data[1], 13) + canLink.onDisconnect() def testSimpleAddressedDataNoAliasYet(self): '''Test start=yes, end=yes frame with no alias match''' canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLink(NodeID("05.01.01.01.03.01"), require_remote_nodes=False) canLink.linkPhysicalLayer(canPhysicalLayer) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() + while canLink.pollState() != CanLink.State.Permitted: + precise_sleep(.02) # don't map alias with AMD # send Verify Node ID Addressed from unknown alias - ourAlias = canLink.localAlias # 576 with NodeID(0x05_01_01_01_03_01) + ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) frame = CanFrame(0x19488, 0x247) # Verify Node ID Addressed frame.data = bytearray( [((ourAlias & 0x700) >> 8), (ourAlias & 0xFF), 12, 13] @@ -401,25 +425,28 @@ def testSimpleAddressedDataNoAliasYet(self): self.assertEqual(len(messageLayer.receivedMessages[1].data), 2) self.assertEqual(messageLayer.receivedMessages[1].data[0], 12) self.assertEqual(messageLayer.receivedMessages[1].data[1], 13) + canLink.onDisconnect() def testMultiFrameAddressedData(self): '''multi-frame addressed messages - SNIP reply Test message in 3 frames ''' canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLink(NodeID("05.01.01.01.03.01"), require_remote_nodes=False) canLink.linkPhysicalLayer(canPhysicalLayer) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() + while canLink.pollState() != CanLink.State.Permitted: + precise_sleep(.02) # map an alias we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) canPhysicalLayer.fireListeners(amd) - ourAlias = canLink.localAlias # 576 with NodeID(0x05_01_01_01_03_01) + ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) frame = CanFrame(0x19488, 0x247) # Verify Node ID Addressed frame.data = bytearray([(((ourAlias & 0x700) >> 8) | 0x10), (ourAlias & 0xFF), 1, 2]) @@ -445,15 +472,18 @@ def testMultiFrameAddressedData(self): NodeID(0x01_02_03_04_05_06)) self.assertEqual(messageLayer.receivedMessages[1].destination, NodeID(0x05_01_01_01_03_01)) + canLink.onDisconnect() def testSimpleDatagram(self): # Test start=yes, end=yes frame canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLink(NodeID("05.01.01.01.03.01"), require_remote_nodes=False) canLink.linkPhysicalLayer(canPhysicalLayer) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() + while canLink.pollState() != CanLink.State.Permitted: + precise_sleep(.02) # map two aliases we'll use amd = CanFrame(0x0701, 0x247) @@ -481,15 +511,18 @@ def testSimpleDatagram(self): # Test start=yes, end=yes frame self.assertEqual(messageLayer.receivedMessages[1].data[1], 11) self.assertEqual(messageLayer.receivedMessages[1].data[2], 12) self.assertEqual(messageLayer.receivedMessages[1].data[3], 13) + canLink.onDisconnect() def testMultiFrameDatagram(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLink(NodeID("05.01.01.01.03.01"), require_remote_nodes=False) canLink.linkPhysicalLayer(canPhysicalLayer) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() + while canLink.pollState() != CanLink.State.Permitted: + precise_sleep(.02) # map two aliases we'll use amd = CanFrame(0x0701, 0x247) @@ -531,6 +564,7 @@ def testMultiFrameDatagram(self): self.assertEqual(messageLayer.receivedMessages[1].data[9], 31) self.assertEqual(messageLayer.receivedMessages[1].data[10], 32) self.assertEqual(messageLayer.receivedMessages[1].data[11], 33) + canLink.onDisconnect() def testZeroLengthDatagram(self): canPhysicalLayer = PhyMockLayer() @@ -545,6 +579,7 @@ def testZeroLengthDatagram(self): self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) self.assertEqual(str(canPhysicalLayer.receivedFrames[0]), "CanFrame header: 0x1A000000 []") + canLink.onDisconnect() def testOneFrameDatagram(self): canPhysicalLayer = PhyMockLayer() @@ -562,6 +597,7 @@ def testOneFrameDatagram(self): str(canPhysicalLayer.receivedFrames[0]), "CanFrame header: 0x1A000000 [1, 2, 3, 4, 5, 6, 7, 8]" ) + canLink.onDisconnect() def testTwoFrameDatagram(self): canPhysicalLayer = PhyMockLayer() @@ -584,6 +620,7 @@ def testTwoFrameDatagram(self): str(canPhysicalLayer.receivedFrames[1]), "CanFrame header: 0x1D000000 [9, 10, 11, 12, 13, 14, 15, 16]" ) + canLink.onDisconnect() def testThreeFrameDatagram(self): # FIXME: Why was testThreeFrameDatagram named same? What should it be? @@ -610,12 +647,13 @@ def testThreeFrameDatagram(self): ) self.assertEqual(str(canPhysicalLayer.receivedFrames[2]), "CanFrame header: 0x1D000000 [17, 18, 19]") + canLink.onDisconnect() # MARK: - Test Remote Node Alias Tracking def testAmdAmrSequence(self): canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLink(NodeID("05.01.01.01.03.01")) - ourAlias = canLink.localAlias # 576 with NodeID(0x05_01_01_01_03_01) + ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink.linkPhysicalLayer(canPhysicalLayer) canPhysicalLayer.fireListeners(CanFrame(0x0701, ourAlias+1)) @@ -635,6 +673,7 @@ def testAmdAmrSequence(self): self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) # ^ nothing back down to CAN + canLink.onDisconnect() # MARK: - Data size handling def testSegmentAddressedDataArray(self): @@ -681,6 +720,7 @@ def testSegmentAddressedDataArray(self): [bytearray([0x11,0x23, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6]), # noqa:E231 bytearray([0x31,0x23, 0x7, 0x8, 0x9, 0xA, 0xB, 0xC]), # noqa:E231 bytearray([0x21, 0x23, 0xD, 0xE])]) # noqa: E231 + canLink.onDisconnect() def testSegmentDatagramDataArray(self): canLink = CanLink(NodeID("05.01.01.01.03.01")) @@ -728,3 +768,13 @@ def testSegmentDatagramDataArray(self): [bytearray([0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8]), bytearray([0x9, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF, 0x10]), bytearray([0x11])]) # noqa: E501 + canLink.onDisconnect() + + def testEnum(self): + usedValues = set() + # ensure values are unique: + for entry in CanLink.State: + self.assertNotIn(entry.value, usedValues) + usedValues.add(entry.value) + # print('{} = {}'.format(entry.name, entry.value)) + self.assertIsInstance(entry, int) diff --git a/tests/test_canphysicallayergridconnect.py b/tests/test_canphysicallayergridconnect.py index 3ad67fe..d409756 100644 --- a/tests/test_canphysicallayergridconnect.py +++ b/tests/test_canphysicallayergridconnect.py @@ -26,6 +26,12 @@ def frameSocketSendDummy(self, frame): self.capturedFrame = frame self.capturedFrame.encoder = self.gc + if frame.afterSendState: + pass + # NOTE: skipping canLink.setState since testing only + # physical layer not link layer. + # canLink.setState(frame.afterSendState) + # Link Layer side def receiveListener(self, frame): self.receivedFrames += [frame] diff --git a/tests/test_controlframe.py b/tests/test_controlframe.py new file mode 100644 index 0000000..528f64d --- /dev/null +++ b/tests/test_controlframe.py @@ -0,0 +1,20 @@ +import unittest + +from openlcb.canbus.controlframe import ControlFrame + + +class ControlFrameTest(unittest.TestCase): + def setUp(self): + pass + + def testEnum(self): + usedValues = set() + # ensure values are unique: + for entry in ControlFrame: + self.assertNotIn(entry.value, usedValues) + usedValues.add(entry.value) + # print('{} = {}'.format(entry.name, entry.value)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_datagramservice.py b/tests/test_datagramservice.py index e0daa3e..8c917fc 100644 --- a/tests/test_datagramservice.py +++ b/tests/test_datagramservice.py @@ -17,6 +17,10 @@ class LinkMockLayer(LinkLayer): def sendMessage(self, message): LinkMockLayer.sentMessages.append(message) + def _onStateChanged(self, oldState, newState): + print(f"[TcpLink] _onStateChanged from {oldState} to {newState}" + " (nothing to clean up since LinkMockLayer)") + class DatagramServiceTest(unittest.TestCase): def setUp(self): @@ -167,6 +171,13 @@ def testReceiveDatagramOK(self): # check message came through self.assertEqual(len(LinkMockLayer.sentMessages), 1) + def testEnum(self): + usedValues = set() + # ensure values are unique: + for entry in DatagramService.ProtocolID: + self.assertNotIn(entry.value, usedValues) + usedValues.add(entry.value) + # print('{} = {}'.format(entry.name, entry.value)) if __name__ == '__main__': unittest.main() diff --git a/tests/test_dispatcher b/tests/test_dispatcher new file mode 100644 index 0000000..9e7b149 --- /dev/null +++ b/tests/test_dispatcher @@ -0,0 +1,20 @@ +import unittest + +from openlcb.dispatcher import Dispatcher + + +class DispatcherTest(unittest.TestCase): + def setUp(self): + pass + + def testEnum(self): + usedValues = set() + # ensure values are unique: + for entry in Dispatcher.Mode: + self.assertNotIn(entry.value, usedValues) + usedValues.add(entry.value) + # print('{} = {}'.format(entry.name, entry.value)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_linklayer.py b/tests/test_linklayer.py index d2f0aea..209aefc 100644 --- a/tests/test_linklayer.py +++ b/tests/test_linklayer.py @@ -26,6 +26,13 @@ def testReceipt(self): self.assertTrue(self.received) + def testEnum(self): + usedValues = set() + # ensure values are unique: + for entry in LinkLayer.State: + self.assertNotIn(entry.value, usedValues) + usedValues.add(entry.value) + # print('{} = {}'.format(entry.name, entry.value)) if __name__ == '__main__': unittest.main() diff --git a/tests/test_localnodeprocessor.py b/tests/test_localnodeprocessor.py index 5cb0fa1..d0ccfec 100644 --- a/tests/test_localnodeprocessor.py +++ b/tests/test_localnodeprocessor.py @@ -15,6 +15,10 @@ class LinkMockLayer(LinkLayer): def sendMessage(self, message): LinkMockLayer.sentMessages.append(message) + def _onStateChanged(self, oldState, newState): + print(f"[TcpLink] _onStateChanged from {oldState} to {newState}" + " (nothing to clean up since LinkMockLayer)") + class TestLocalNodeProcessorClass(unittest.TestCase): diff --git a/tests/test_memoryservice.py b/tests/test_memoryservice.py index ddb53eb..3a8bc8b 100644 --- a/tests/test_memoryservice.py +++ b/tests/test_memoryservice.py @@ -41,6 +41,10 @@ class LinkMockLayer(LinkLayer): def sendMessage(self, message): LinkMockLayer.sentMessages.append(message) + def _onStateChanged(self, oldState, newState): + print(f"State changed from {oldState} to {newState}" + " (nothing to clean up since LinkMockLayer).") + class TestMemoryServiceClass(unittest.TestCase): diff --git a/tests/test_mti.py b/tests/test_mti.py index f75fb99..fc3d74d 100644 --- a/tests/test_mti.py +++ b/tests/test_mti.py @@ -34,6 +34,14 @@ def testIsGlobal(self): self.assertTrue(MTI.Link_Layer_Down.isGlobal()) # ^ needs to be global so all node implementations see it + def testEnum(self): + usedValues = set() + # ensure values are unique: + for entry in MTI: + self.assertNotIn(entry.value, usedValues) + usedValues.add(entry.value) + # print('{} = {}'.format(entry.name, entry.value)) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_node.py b/tests/test_node.py index ac59b38..6eda4b8 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -90,6 +90,14 @@ def testConvenienceCtors(self): n2 = Node(NodeID(13), snip) self.assertTrue(n2.snip.modelName == "modelX") + def testEnum(self): + usedValues = set() + # ensure values are unique: + for entry in Node.State: + self.assertNotIn(entry.value, usedValues) + usedValues.add(entry.value) + # print('{} = {}'.format(entry.name, entry.value)) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_pip.py b/tests/test_pip.py index 00bc949..c7dd479 100644 --- a/tests/test_pip.py +++ b/tests/test_pip.py @@ -58,6 +58,14 @@ def testContentsNameUSet2(self): result = PIP.contentsNamesFromList(input) self.assertEqual(result, ["ADCDI Protocol"]) + def testEnum(self): + usedValues = set() + # ensure values are unique: + for entry in PIP: + self.assertNotIn(entry.value, usedValues) + usedValues.add(entry.value) + # print('{} = {}'.format(entry.name, entry.value)) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_remotenodeprocessor.py b/tests/test_remotenodeprocessor.py index c17cbb1..f9778e1 100644 --- a/tests/test_remotenodeprocessor.py +++ b/tests/test_remotenodeprocessor.py @@ -15,7 +15,11 @@ class TesRemoteNodeProcessorClass(unittest.TestCase): def setUp(self) : self.node21 = Node(NodeID(21)) - self.processor = RemoteNodeProcessor(CanLink(NodeID(100))) + self.canLink = CanLink(NodeID(100)) + self.processor = RemoteNodeProcessor(self.canLink) + + def tearDown(self): + self.canLink.onDisconnect() def testInitializationComplete(self) : # not related to node From 88dd0c060c8aab083112b50cc5d96b40af71ddb7 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 24 Apr 2025 18:25:39 -0400 Subject: [PATCH 44/99] Improve docstring and comments for new CanLink State code. --- openlcb/canbus/canlink.py | 71 ++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index c7f7992..93d9884 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -171,47 +171,56 @@ class State(Enum): so that defineAndReserveAlias is non-blocking. Attributes: - AllocatingAlias (State): Waiting for send of the last - reservation packet (after collision detection fully - done) to be success (wait for socket to notify us, - sendAfter is not enough) + EnqueueAliasAllocationRequest (State): This state triggers + the first phase of the alias reservation process. + Normally set by calling defineAndReserveAlias. If + a collision occurs, processCollision increments the + alias before calling defineAndReserveAlias. + + WaitingForSendCIDSequence (State): Waiting for send of the last + CID sequence packet (first phase of reserving an alias). + - The last frame sets state to WaitForAliases *after* + sent by socket (wait for socket code in application or + Dispatcher to notify us, sendAfter is too soon to be + sure our 200ms delay starts after send). + EnqueueAliasReservation (State): After collision detection fully + determined to be success, this state triggers + _enqueueReserveID. """ Initial = LinkLayer.State.Undefined.value # special case of .Inhibited # where init hasn't started. Inhibited = 2 EnqueueAliasAllocationRequest = 3 - # enqueueCIDSequence sets: + # _enqueueCIDSequence sets: BusyLocalCIDSequence = 4 WaitingForSendCIDSequence = 5 - WaitForAliases = 6 # queued via last frame it sends - EnqueueAliasReservation = 7 # called by pollState if got aliases - # (or after fixed delay if require_remote_nodes is False) - # enqueueReserveID sets: + WaitForAliases = 6 # queued via frame + EnqueueAliasReservation = 7 # called by pollState (see comments there) + # _enqueueReserveID sets: BusyLocalReserveID = 8 WaitingForSendReserveID = 9 - - NotifyAliasReservation = 14 - + NotifyAliasReservation = 14 # queued via frame + # _notifyReservation sets: BusyLocalNotifyReservation = 11 WaitingForLocalNotifyReservation = 12 - RecordAliasReservation = 13 - - + RecordAliasReservation = 13 # queued via frame + # _recordReservation sets: BusyLocalMappingAlias = 18 - Permitted = 20 # formerly 3 + Permitted = 20 # formerly 3. queued via frame + # (formerly set at end of _notifyReservation code) def _onStateChanged(self, _, newState): # return super()._onStateChanged(oldState, newState) assert isinstance(newState, CanLink.State) if newState == CanLink.State.EnqueueAliasAllocationRequest: - self.enqueueCIDSequence() + self._enqueueCIDSequence() # - sets state to BusyLocalCIDSequence # - then at the end to WaitingForSendCIDSequence # - then a packet sent sets state to WaitForAliases # - then if wait is over, # pollState sets state to EnqueueAliasReservation elif newState == CanLink.State.EnqueueAliasReservation: - self.enqueueReserveID() # sets _state to + self._enqueueReserveID() # sets _state to # - BusyLocalReserveID # - WaitingForSendReserveID # - NotifyAliasReservation (queued for after frame is sent) @@ -303,12 +312,8 @@ def handleReceivedLinkUp(self, frame): """ # start the alias allocation in Inhibited state self._state = CanLink.State.Inhibited - if self.defineAndReserveAlias(): - print("[CanLink] Notifying upper layers of LinkUp.") - else: - logger.warning( - "[CanLink] Not notifying upper layers of LinkUp" - " since reserve alias failed (will retry).") + self.defineAndReserveAlias() + print("[CanLink] done calling defineAndReserveAlias.") def handleReceivedLinkRestarted(self, frame): """Send a LinkRestarted message upstream. @@ -323,7 +328,7 @@ def handleReceivedLinkRestarted(self, frame): def defineAndReserveAlias(self): self.setState(CanLink.State.EnqueueAliasAllocationRequest) # - # Use self.enqueueCIDSequence() instead, + # Use self._enqueueCIDSequence() instead, # but actually trigger it in _onStateChanged # via setState(CanLink.State.EnqueueAliasAllocationRequest) # self.sendAliasAllocationSequence() @@ -878,7 +883,7 @@ def processCollision(self, frame) : self.defineAndReserveAlias() # def sendAliasAllocationSequence(self): - # # actually, call self.enqueueCIDSequence() # sets _state and sends data + # # actually, call self._enqueueCIDSequence() # sets _state and sends data # raise DeprecationWarning("Use setState to BusyLocalCIDSequence instead.") def pollState(self): @@ -898,7 +903,13 @@ def pollState(self): socket calls. """ assert isinstance(self._state, CanLink.State) - if self._state == CanLink.State.WaitForAliases: + if self._state in (CanLink.State.Inhibited, CanLink.State.Initial): + # Do nothing. Dispatcher or application must first call + # physicalLayerUp + # - which triggers handleReceivedLinkUp + # - which calls defineAndReserveAlias + pass + elif self._state == CanLink.State.WaitForAliases: if self._waitingForAliasStart is None: self._waitingForAliasStart = default_timer() else: @@ -914,12 +925,10 @@ def pollState(self): " (permissive) behavior") # finish the sends for the alias reservation: self.setState(CanLink.State.EnqueueAliasReservation) - elif self._state == CanLink.State.RecordAliasReservation: - self.finalizeAlias() return self.getState() - def enqueueCIDSequence(self): + def _enqueueCIDSequence(self): """Enqueue the four alias reservation step1 frames (N_cid values 7, 6, 5, 4 respectively) It is the responsibility of the application code @@ -947,7 +956,7 @@ def enqueueCIDSequence(self): self._previousLocalAliasSeed = self._localAliasSeed self.setState(CanLink.State.WaitingForSendCIDSequence) - def enqueueReserveID(self): + def _enqueueReserveID(self): """Send Reserve ID (RID) If no collision after `CanLink.ALIAS_RESPONSE_DELAY`, but this will not be called in no-response case if From 7d92188613375436f8b9b7899657af257aefa44c Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Sat, 26 Apr 2025 05:03:36 -0400 Subject: [PATCH 45/99] Move some construction to PhysicalLayer (no more callback, it is the queue mechanism. Application code determines flow via its socket code--see issue #62). Rename push methods (formerly ambigously named receieve) to processChars and processString for clarity (They process incoming data). Rename sendCanFrame to sendFrameAfter since any PhysicalLayer (non-CAN) can implement it, and "After" indicates it is queued not disrupting the application's control of the socket. Improve docstrings. Set MIN_STATE_VALUE and MAX_STATE_VALUE. FIXME: needs debugging. --- examples/example_cdi_access.py | 2 +- examples/example_datagram_transfer.py | 2 +- examples/example_frame_interface.py | 4 +- examples/example_memory_length_query.py | 2 +- examples/example_memory_transfer.py | 2 +- examples/example_message_interface.py | 2 +- examples/example_node_implementation.py | 2 +- examples/example_remote_nodes.py | 2 +- openlcb/canbus/canlink.py | 297 ++++++++++-------- openlcb/canbus/canphysicallayer.py | 30 +- openlcb/canbus/canphysicallayergridconnect.py | 36 ++- openlcb/canbus/canphysicallayersimulation.py | 2 +- openlcb/dispatcher.py | 31 +- openlcb/physicallayer.py | 83 ++++- openlcb/portinterface.py | 6 +- python-openlcb.code-workspace | 1 + tests/test_canlink.py | 2 +- tests/test_canphysicallayergridconnect.py | 22 +- 18 files changed, 306 insertions(+), 222 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index 40201ff..18d7e40 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -285,6 +285,6 @@ def memoryRead(): # print(" RR: "+packet_str.strip()) # ^ commented since MyHandler shows parsed XML fields instead # pass to link processor - canPhysicalLayerGridConnect.pushChars(received) + canPhysicalLayerGridConnect.processChars(received) canLink.onDisconnect() diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index 67468fe..99403f5 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -137,7 +137,7 @@ def datagramWrite(): packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.pushChars(received) + canPhysicalLayerGridConnect.processChars(received) canLink.pollState() canLink.onDisconnect() diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index ce1b869..48914fc 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -58,7 +58,7 @@ def printFrame(frame): # send an AME frame with arbitrary alias to provoke response frame = CanFrame(ControlFrame.AME.value, 1, bytearray()) print("SL: {}".format(frame)) -canPhysicalLayerGridConnect.sendCanFrame(frame) +canPhysicalLayerGridConnect.sendFrameAfter(frame) observer = GridConnectObserver() @@ -71,4 +71,4 @@ def printFrame(frame): packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.pushChars(received) + canPhysicalLayerGridConnect.processChars(received) diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index 521e65c..808d20a 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -156,6 +156,6 @@ def memoryRequest(): packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.pushChars(received) + canPhysicalLayerGridConnect.processChars(received) canLink.onDisconnect() diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index 2a0a554..b916960 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -155,6 +155,6 @@ def memoryRead(): packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.pushChars(received) + canPhysicalLayerGridConnect.processChars(received) canLink.onDisconnect() \ No newline at end of file diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index 4af6644..c98669e 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -95,6 +95,6 @@ def printMessage(msg): packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.pushChars(received) + canPhysicalLayerGridConnect.processChars(received) canLink.onDisconnect() diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index e51501e..bb23d8b 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -169,6 +169,6 @@ def displayOtherNodeIds(message) : packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.pushChars(received) + canPhysicalLayerGridConnect.processChars(received) canLink.onDisconnect() diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index 783f9e9..d840461 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -127,7 +127,7 @@ def receiveLoop() : packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.pushChars(received) + canPhysicalLayerGridConnect.processChars(received) import threading # noqa E402 diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 93d9884..7f920d0 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -23,7 +23,7 @@ from logging import getLogger from timeit import default_timer -from openlcb import emit_cast, formatted_ex, precise_sleep +from openlcb import emit_cast, formatted_ex from openlcb.canbus.canframe import CanFrame from openlcb.canbus.controlframe import ControlFrame @@ -73,22 +73,76 @@ class CanLink(LinkLayer): (otherwise there is no hardware connection, or an issue with socket timing, call order, or another hard-coded problem in the stack or application). + physicalLayer (PhysicalLayer): The physical layer should + set this member by accepting a CanLink its constructor, + unless that is flipped around and added to this + constructor. See commented linkPhysicalLayer. """ + # MIN_STATE_VALUE & MAX_STATE_VALUE are set statically below the + # State class declaration: ALIAS_RESPONSE_DELAY = .2 # See docstring. + class State(Enum): + """Used as a linux-like "runlevel" + + Attributes: + EnqueueAliasAllocationRequest (State): This state triggers + the first phase of the alias reservation process. + Normally set by calling defineAndReserveAlias. If + a collision occurs, processCollision increments the + alias before calling defineAndReserveAlias. + + WaitingForSendCIDSequence (State): Waiting for send of the last + CID sequence packet (first phase of reserving an alias). + - The last frame sets state to WaitForAliases *after* + sent by socket (wait for socket code in application or + Dispatcher to notify us, sendFrameAfter is too soon to be + sure our 200ms delay starts after send). + EnqueueAliasReservation (State): After collision detection fully + determined to be success, this state triggers + _enqueueReserveID. + """ + Initial = LinkLayer.State.Undefined.value # special case of .Inhibited + # where init hasn't started. + Inhibited = 2 + EnqueueAliasAllocationRequest = 3 + # _enqueueCIDSequence sets: + BusyLocalCIDSequence = 4 + WaitingForSendCIDSequence = 5 + WaitForAliases = 6 # queued via frame + EnqueueAliasReservation = 7 # called by pollState (see comments there) + # _enqueueReserveID sets: + BusyLocalReserveID = 8 + WaitingForSendReserveID = 9 + NotifyAliasReservation = 14 # queued via frame + # _notifyReservation sets: + BusyLocalNotifyReservation = 11 + WaitingForLocalNotifyReservation = 12 + RecordAliasReservation = 13 # queued via frame + # _recordReservation sets: + BusyLocalMappingAlias = 18 + Permitted = 20 # formerly 3. queued via frame + # (formerly set at end of _notifyReservation code) + + MIN_STATE_VALUE = min(entry.value for entry in State) + MAX_STATE_VALUE = max(entry.value for entry in State) + def __init__(self, localNodeID, require_remote_nodes=True): # a NodeID # See class docstring for args + self._previousLocalAliasSeed = None self.require_remote_nodes = require_remote_nodes self._waitingForAliasStart = None self._localAliasSeed = localNodeID.value self._localAlias = self.createAlias12(self._localAliasSeed) self.localNodeID = localNodeID self._state = CanLink.State.Initial - self.link = None + self.physicalLayer = None self._frameCount = 0 - self._reserveAliasCollisions = 0 + self._aliasCollisionCount = 0 self._errorCount = 0 + self._previousAliasCollisionCount = None + self._previousFrameCount = None self.aliasToNodeID = {} self.nodeIdToAlias = {} self.accumulator = {} @@ -119,7 +173,7 @@ def getLocalAlias(self): if self._state != CanLink.State.Permitted: raise InterruptedError( "The alias reservation is not complete (state={})." - " Make sure defineAliasReservation (linkLayerUp) isn't" + " Make sure defineAliasReservation (physicalLayerUp) isn't" " called in a way that blocks the socket receive thread," " and that your application has a Message received listener" " registered via registerMessageReceivedListener that" @@ -132,14 +186,15 @@ def getLocalAlias(self): ) return self._localAlias - def ready(self): - """Check if state == CanLink.State.Permitted - To find out if ready right away, check for - MTI.Link_Layer_Up and MTI.Link_Layer_Down and use them - to track the state in the application code. - """ - assert isinstance(self._state, CanLink.State) - return self._state == CanLink.State.Permitted + # Use pollState instead, which keeps the state machine moving + # def ready(self): + # """Check if state == CanLink.State.Permitted + # To find out if ready right away, check for + # MTI.Link_Layer_Up and MTI.Link_Layer_Down and use them + # to track the state in the application code. + # """ + # assert isinstance(self._state, CanLink.State) + # return self._state == CanLink.State.Permitted def isDuplicateAlias(self, alias): if not isinstance(alias, int): @@ -151,63 +206,23 @@ def isDuplicateAlias(self, alias): .format(emit_cast(alias))) return alias in self.duplicateAliases - def linkPhysicalLayer(self, cpl): - """Set the physical layer to use. - Also registers self.receiveListener as a listener on the given - physical layer. Before using sendMessage, wait for the - connection phase to finish, as the phase receives aliases - (populating nodeIdToAlias) and reserves a unique alias as per - section 6.2.1 of CAN Frame Transfer Standard: - https://openlcb.org/wp-content/uploads/2021/08/S-9.7.2.1-CanFrameTransfer-2021-04-25.pdf - - Args: - cpl (CanPhysicalLayer): The physical layer to use. - """ - self.link = cpl - cpl.registerFrameReceivedListener(self.receiveListener) - - class State(Enum): - """This now behaves as a linux-like "runlevel" - so that defineAndReserveAlias is non-blocking. - - Attributes: - EnqueueAliasAllocationRequest (State): This state triggers - the first phase of the alias reservation process. - Normally set by calling defineAndReserveAlias. If - a collision occurs, processCollision increments the - alias before calling defineAndReserveAlias. - - WaitingForSendCIDSequence (State): Waiting for send of the last - CID sequence packet (first phase of reserving an alias). - - The last frame sets state to WaitForAliases *after* - sent by socket (wait for socket code in application or - Dispatcher to notify us, sendAfter is too soon to be - sure our 200ms delay starts after send). - EnqueueAliasReservation (State): After collision detection fully - determined to be success, this state triggers - _enqueueReserveID. - """ - Initial = LinkLayer.State.Undefined.value # special case of .Inhibited - # where init hasn't started. - Inhibited = 2 - EnqueueAliasAllocationRequest = 3 - # _enqueueCIDSequence sets: - BusyLocalCIDSequence = 4 - WaitingForSendCIDSequence = 5 - WaitForAliases = 6 # queued via frame - EnqueueAliasReservation = 7 # called by pollState (see comments there) - # _enqueueReserveID sets: - BusyLocalReserveID = 8 - WaitingForSendReserveID = 9 - NotifyAliasReservation = 14 # queued via frame - # _notifyReservation sets: - BusyLocalNotifyReservation = 11 - WaitingForLocalNotifyReservation = 12 - RecordAliasReservation = 13 # queued via frame - # _recordReservation sets: - BusyLocalMappingAlias = 18 - Permitted = 20 # formerly 3. queued via frame - # (formerly set at end of _notifyReservation code) + # def linkPhysicalLayer(self, cpl): + # """Set the physical layer to use. + # Also registers self.receiveListener as a listener on the given + # physical layer. Before using sendMessage, wait for the + # connection phase to finish, as the phase receives aliases + # (populating nodeIdToAlias) and reserves a unique alias as per + # section 6.2.1 of CAN Frame Transfer Standard: + # https://openlcb.org/wp-content/uploads/2021/08/S-9.7.2.1-CanFrameTransfer-2021-04-25.pdf + + # Args: + # cpl (CanPhysicalLayer): The physical layer to use. + # """ + # self.physicalLayer = cpl # self.link = cpl + # cpl.registerFrameReceivedListener(self.receiveListener) + # # ^ Commented since it makes more sense for its + # # constructor to do this, since it needs a LinkLayer + # # in order to do anything def _onStateChanged(self, _, newState): # return super()._onStateChanged(oldState, newState) @@ -240,6 +255,9 @@ def _onStateChanged(self, _, newState): # code) self.linkStateChange(newState) # Notify upper layers + # TODO: Make sure upper layers handle any states + # necessary (formerly only states other than Initial were + # Inhibited & Permitted). def receiveListener(self, frame): """Call the correct handler if any for a received frame. @@ -325,32 +343,6 @@ def handleReceivedLinkRestarted(self, frame): bytearray()) self.fireListeners(msg) - def defineAndReserveAlias(self): - self.setState(CanLink.State.EnqueueAliasAllocationRequest) - # - # Use self._enqueueCIDSequence() instead, - # but actually trigger it in _onStateChanged - # via setState(CanLink.State.EnqueueAliasAllocationRequest) - # self.sendAliasAllocationSequence() - # - # Split up to the following (which was its docstring): - """ - This *must not block* the frame receive thread, since we must - wait 200ms and start sendAliasAllocationSequence over if - transmission error occurs, or an announcement with a Node ID - same as ours is received. - - In either case this method must *not* complete (*not* sending - RID is implied). - - In the latter case, our ID must be incremented before - sendAliasAllocationSequence starts over, and repeat this until - it is unique (no packets with senders matching it are - received) - - See section 6.2.1 of LCC "CAN Frame Transfer" Standard - - Returns: - bool: True if succeeded, False if collision. - """ - def _notifyReservation(self): """Send Alias Map Definition (AMD) Triggered by last frame sent that was enqueued by @@ -362,7 +354,7 @@ def _notifyReservation(self): # (and prevented this code on return False) self.setState(CanLink.State.BusyLocalNotifyReservation) # send AMD frame, go to Permitted state - self.link.sendCanFrame( + self.physicalLayer.sendFrameAfter( CanFrame(ControlFrame.AMD.value, self._localAlias, self.localNodeID.toArray(), afterSendState=CanLink.State.RecordAliasReservation) @@ -391,7 +383,7 @@ def _recordReservation(self): .format(self.localNodeID)) self.nodeIdToAlias[self.localNodeID] = self._localAlias # send AME with no NodeID to get full alias map - self.link.sendCanFrame( + self.physicalLayer.sendFrameAfter( CanFrame(ControlFrame.AME.value, self._localAlias, afterSendState=CanLink.State.Permitted) ) @@ -438,8 +430,8 @@ def handleReceivedCID(self, frame): # CanFrame if (frame.header & 0xFFF) != self._localAlias: return # no match # send an RID in response - self.link.sendCanFrame(CanFrame(ControlFrame.RID.value, - self._localAlias)) + self.physicalLayer.sendFrameAfter(CanFrame(ControlFrame.RID.value, + self._localAlias)) def handleReceivedRID(self, frame): # CanFrame """Handle a Reserve ID (RID) frame @@ -485,7 +477,7 @@ def handleReceivedAME(self, frame): # CanFrame # matched, send RID returnFrame = CanFrame(ControlFrame.AMD.value, self._localAlias, self.localNodeID.toArray()) - self.link.sendCanFrame(returnFrame) + self.physicalLayer.sendFrameAfter(returnFrame) def handleReceivedAMR(self, frame): # CanFrame """Handle an Alias Map Reset (AMR) frame @@ -511,7 +503,7 @@ def handleReceivedData(self, frame): # CanFrame """ if self.checkAndHandleAliasCollision(frame): return - # ^ may affect _reserveAliasCollisions (not _frameCount) + # ^ may affect _aliasCollisionCount (not _frameCount) # get proper MTI mti = self.canHeaderToFullFormat(frame) sourceID = NodeID(0) @@ -718,27 +710,27 @@ def sendMessage(self, msg): # single frame header |= 0x0A_000_000 frame = CanFrame(header, msg.data) - self.link.sendCanFrame(frame) + self.physicalLayer.sendFrameAfter(frame) else: # multi-frame datagram dataSegments = self.segmentDatagramDataArray(msg.data) # send the first one frame = CanFrame(header | 0x0B_00_00_00, dataSegments[0]) - self.link.sendCanFrame(frame) + self.physicalLayer.sendFrameAfter(frame) # send middles if len(dataSegments) >= 3: for index in range(1, len(dataSegments) - 2 + 1): # upper limit leaves one frame = CanFrame(header | 0x0C_00_00_00, dataSegments[index]) - self.link.sendCanFrame(frame) + self.physicalLayer.sendFrameAfter(frame) # send last one frame = CanFrame( header | 0x0D_00_00_00, dataSegments[len(dataSegments) - 1] ) - self.link.sendCanFrame(frame) + self.physicalLayer.sendFrameAfter(frame) else: # all non-datagram cases # Remap the mti @@ -765,7 +757,7 @@ def sendMessage(self, msg): for content in dataSegments: # send the resulting frame frame = CanFrame(header, content) - self.link.sendCanFrame(frame) + self.physicalLayer.sendFrameAfter(frame) else: logger.warning( "Don't know alias for destination = {}" @@ -774,7 +766,7 @@ def sendMessage(self, msg): # global still can hold data; assume length is correct by # protocol send the resulting frame frame = CanFrame(header, msg.data) - self.link.sendCanFrame(frame) + self.physicalLayer.sendFrameAfter(frame) def segmentDatagramDataArray(self, data): """Segment data into zero or more arrays @@ -867,14 +859,15 @@ def markDuplicateAlias(self, alias): def processCollision(self, frame) : ''' Collision! ''' - self._reserveAliasCollisions += 1 + self._aliasCollisionCount += 1 logger.warning( "alias collision in {}, we restart with AMR" " and attempt to get new alias".format(frame)) self.markDuplicateAlias(frame.alias) - self.link.sendCanFrame(CanFrame(ControlFrame.AMR.value, - self._localAlias, - self.localNodeID.toArray())) + self.physicalLayer.sendFrameAfter(CanFrame( + ControlFrame.AMR.value, + self._localAlias, + self.localNodeID.toArray())) # Standard 6.2.5 self._state = CanLink.State.Inhibited # attempt to get a new alias and go back to .Permitted @@ -883,13 +876,13 @@ def processCollision(self, frame) : self.defineAndReserveAlias() # def sendAliasAllocationSequence(self): - # # actually, call self._enqueueCIDSequence() # sets _state and sends data - # raise DeprecationWarning("Use setState to BusyLocalCIDSequence instead.") + # # actually, call self._enqueueCIDSequence() # set _state&send data + # raise DeprecationWarning("Use setState to BusyLocalCIDSequence") def pollState(self): """You must keep polling state after every time a state change frame is sent, and after - every call to pushString or pushChars + every call to processString or processChars for the stack to keep operating. - calling this automatically *must not* be implemented there, because this exists to @@ -928,6 +921,23 @@ def pollState(self): return self.getState() + # May use self._enqueueCIDSequence() instead, + # but actually trigger it in _onStateChanged + # via setState(CanLink.State.EnqueueAliasAllocationRequest) + # self.sendAliasAllocationSequence() + def defineAndReserveAlias(self): + """Enqueue EnqueueAliasAllocationRequest frames. + See section 6.2.1 of LCC "CAN Frame Transfer" Standard + + Implementation details: The application must call popFrames() + and send them as usual in its socket streaming loop, as well as + continue calling pollState() and check its return against + CanLink.State.Permitted before trying to send a CanFrame or + Message instance. The application manages flow and the + openlcb stack (this Python module) manages state. + """ + self.setState(CanLink.State.EnqueueAliasAllocationRequest) + def _enqueueCIDSequence(self): """Enqueue the four alias reservation step1 frames (N_cid values 7, 6, 5, 4 respectively) @@ -944,14 +954,17 @@ def _enqueueCIDSequence(self): # nodes will respond with their NodeIDs and aliases (populates # NodeIdToAlias, permitting openlcb to send to those # destinations) - self.link.sendCanFrame(CanFrame(7, self.localNodeID, self._localAlias)) - self.link.sendCanFrame(CanFrame(6, self.localNodeID, self._localAlias)) - self.link.sendCanFrame(CanFrame(5, self.localNodeID, self._localAlias)) - self.link.sendCanFrame( + self.physicalLayer.sendFrameAfter(CanFrame(7, self.localNodeID, + self._localAlias)) + self.physicalLayer.sendFrameAfter(CanFrame(6, self.localNodeID, + self._localAlias)) + self.physicalLayer.sendFrameAfter(CanFrame(5, self.localNodeID, + self._localAlias)) + self.physicalLayer.sendFrameAfter( CanFrame(4, self.localNodeID, self._localAlias, afterSendState=CanLink.State.WaitForAliases) ) - self._previousCollisions = self._reserveAliasCollisions + self._previousAliasCollisionCount = self._aliasCollisionCount self._previousFrameCount = self._frameCount self._previousLocalAliasSeed = self._localAliasSeed self.setState(CanLink.State.WaitingForSendCIDSequence) @@ -961,6 +974,8 @@ def _enqueueReserveID(self): If no collision after `CanLink.ALIAS_RESPONSE_DELAY`, but this will not be called in no-response case if `require_remote_nodes` is `True`. + + Triggered by CanLink.State.EnqueueAliasReservation """ self._waitingForAliasStart = None # done waiting for reply to 7,6,5,4 self.setState(CanLink.State.BusyLocalReserveID) @@ -979,7 +994,7 @@ def _enqueueReserveID(self): " reservation request. If there any other nodes, this is" " an error and this method should *not* continue sending" " Reserve ID (RID) frame)...") - if self._reserveAliasCollisions > self._previousCollisions: + if self._aliasCollisionCount > self._previousAliasCollisionCount: # processCollision will increment the non-unique alias try # defineAndReserveAlias again (so stop before completing # the sequence as per Standard) @@ -988,23 +1003,35 @@ def _enqueueReserveID(self): " (processCollision increments ID to avoid," " & restarts sequence)." .format(self._previousLocalAliasSeed)) + # TODO: maybe raise an exception since we should never get + # here (since CanLink is a state machine now, the state + # leading to this would have been rolled back by processCollision) + return False if responseCount < 1: - logger.warning( - "Continuing to send Reservation (RID) anyway" - "--no response, so assuming alias seed {} is unique" - " (If there are any other nodes on the network then" - " a thread, the call order, or network connection failed!)." - .format(self._localAliasSeed)) - # precise_sleep(.2) # wait for another collision wait term - # responseCount = self._frameCount - previousFrameCount - # NOTE: If we were to loop here, then we would have to - # trigger defineAndReserveAlias again, since - # processCollision usually does that, but collision didn't - # occur. However, stopping here would not be valid anyway - # since we can't know for sure we aren't the only node, - # and if we are the only node no responses are expected. - self.link.sendCanFrame( + if self.require_remote_nodes: + # TODO: Instead, just do setState(CanLink.State.Inhibited?) + raise ConnectionRefusedError( + "pollState should not set EnqueueAliasReservation when" + " responseCount < 1 and" + " remote_nodes_required={}" + .format(emit_cast(self.require_remote_nodes))) + else: + logger.warning( + "Continuing to send Reservation (RID) anyway" + "(Using Standard behavior, since" + " remote_nodes_required={})" + "--no response, so assuming alias seed {} is unique" + " (If there are any other nodes on the network then" + " thread management, the python-openlcb stack" + " construction or call order," + " or network connection failed!)." + .format(emit_cast(self.require_remote_nodes), + self._localAliasSeed)) + else: + print("Got {} new frame(s) during reservation." + " No collisions, so completing reservation!") + self.physicalLayer.sendFrameAfter( CanFrame(ControlFrame.RID.value, self._localAlias, afterSendState=CanLink.State.NotifyAliasReservation) ) diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index 567134f..178198f 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -14,30 +14,20 @@ class CanPhysicalLayer(PhysicalLayer): """Can implementation of PhysicalLayer, still partly abstract (No encodeFrameAsString, since this binary layer may be wrapped by the higher layer such as the text-based CanPhysicalLayerGridConnect) - - Args: - waitForSendCallback (callable): This *must* be a thread-blocking - callback so that the caller knows the timeline for when to - expect a response. """ - def __init__(self, waitForSendCallback): + def __init__(self,): + PhysicalLayer.__init__(self) self.listeners = [] - if not waitForSendCallback: - raise ValueError("Provide a blocking waitForSend function") - sys.stderr.write("Validating waitForSendCallback...") - sys.stderr.flush() - waitForSendCallback() # asserts that the callback works. - # If it raises an error or halts the program, the value is bad - # (The application code is incorrect, so prevent startup). - print("OK", file=sys.stderr) - self.waitForSend = waitForSendCallback - def sendCanFrame(self, frame: CanFrame): - '''basic abstract interface''' - raise NotImplementedError( - "Each subclass must implement this, and set" - " frame.encoder = self") + def sendFrameAfter(self, frame: CanFrame): + """See sendFrameAfter documentation in PhysicalLayer. + This implementation behaves the same except requires + a specific type (CanFrame). + """ + # formerly sendCanFrame, but now behavior is defined by superclass. + assert isinstance(frame, CanFrame) + PhysicalLayer.sendFrameAfter(self, frame) def encode(self, frame) -> str: '''abstract interface (encode frame to string)''' diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index b2c6b00..7db74d3 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -10,7 +10,9 @@ - :X19170365N020112FE056C; ''' +from collections import deque from typing import Union +from openlcb.canbus.canlink import CanLink from openlcb.canbus.canphysicallayer import CanPhysicalLayer from openlcb.canbus.canframe import CanFrame @@ -21,6 +23,10 @@ class CanPhysicalLayerGridConnect(CanPhysicalLayer): """CAN physical layer subclass for GridConnect + This acts as frame.encoder for canLink, and manages the packet + _sends queue (deque is used for speed; defined & managed in base + class: PhysicalLayer) + Args: callback (Callable): A string send method for the platform and hardware being used. It must be associated with an active @@ -28,23 +34,23 @@ class CanPhysicalLayerGridConnect(CanPhysicalLayer): on failure so that sendAliasAllocationSequence is interrupted in order to prevent canlink.state from proceeding to CanLink.State.Permitted) - waitForSendCallback (callable): This *must* be a thread-blocking - callback so that the caller knows the timeline for when to - expect a response (Since that would be sometime after the - actual socket sends all queued frame(s)). """ - def __init__(self, callback, waitForSendCallback): - CanPhysicalLayer.__init__(self, waitForSendCallback) - self.setCallBack(callback) + def __init__(self, canLink: CanLink): + assert hasattr(canLink, 'pollState') + CanPhysicalLayer.__init__(self) + # canLink.linkPhysicalLayer(self) # self.setCallBack(callback) + canLink.physicalLayer = self + self.registerFrameReceivedListener(canLink.receiveListener) + self.inboundBuffer = bytearray() - def setCallBack(self, callback): - assert callable(callback) - self.canSendCallback = callback + # def setCallBack(self, callback): + # assert callable(callback) + # self.canSendCallback = callback - def sendCanFrame(self, frame: CanFrame) -> None: + def sendFrameAfter(self, frame: CanFrame) -> None: frame.encoder = self - self.canSendCallback(frame) + self._sends.appendleft(frame) # self.canSendCallback(frame) def encodeFrameAsString(self, frame) -> str: '''Encode frame to string.''' @@ -54,15 +60,15 @@ def encodeFrameAsString(self, frame) -> str: output += ";\n" return output - def pushString(self, string: str): + def processString(self, string: str): '''Provide string from the outside link to be parsed Args: string (str): A new UTF-8 string from outside link ''' - self.pushChars(string.encode("utf-8")) + self.processChars(string.encode("utf-8")) - def pushChars(self, data: Union[bytes, bytearray]): + def processChars(self, data: Union[bytes, bytearray]): """Provide characters from the outside link to be parsed Args: diff --git a/openlcb/canbus/canphysicallayersimulation.py b/openlcb/canbus/canphysicallayersimulation.py index 208745f..4b38766 100644 --- a/openlcb/canbus/canphysicallayersimulation.py +++ b/openlcb/canbus/canphysicallayersimulation.py @@ -11,5 +11,5 @@ def __init__(self): self.receivedFrames = [] CanPhysicalLayer.__init__(self) - def sendCanFrame(self, frame): + def sendFrameAfter(self, frame): self.receivedFrames.append(frame) diff --git a/openlcb/dispatcher.py b/openlcb/dispatcher.py index 2705fbc..90abffa 100644 --- a/openlcb/dispatcher.py +++ b/openlcb/dispatcher.py @@ -108,7 +108,6 @@ def __init__(self, *args, **kwargs): self._my_cache_dir = os.path.join(caches_dir, "python-openlcb") self._element_listener = None self._connect_listener = None - self._sends = deque() self._mode = Dispatcher.Mode.Initializing # ^ In case some parsing step happens early, # prepare these for _callback_msg. @@ -165,7 +164,7 @@ def start_listening(self, connected_port, localNodeID): self._port = connected_port self._callback_status("CanPhysicalLayerGridConnect...") self._canPhysicalLayerGridConnect = \ - CanPhysicalLayerGridConnect(self.sendAfter) + CanPhysicalLayerGridConnect(self.sendFrameAfter) # self._canPhysicalLayerGridConnect.registerFrameReceivedListener( # self._printFrame @@ -252,11 +251,12 @@ def _listen(self): # Frame Transfer Standard (sendMessage requires ) logger.debug("[_listen] _receive...") try: - while self._sends: + sends = self._canPhysicalLayerGridConnect.popFrames() + while sends: # *Always* do send in the receive thread to # avoid overlapping calls to socket # (causes undefined behavior)! - frame = self._sends.pop() + frame = sends.pop() if isinstance(frame, CanFrame): if self._canLink.isDuplicateAlias(frame.alias): logger.warning( @@ -287,7 +287,7 @@ def _listen(self): file=sys.stderr) # print(" RR: {}".format(received.strip())) # pass to link processor - self._canPhysicalLayerGridConnect.pushChars(received) + self._canPhysicalLayerGridConnect.processChars(received) # ^ will trigger self._printFrame if that was added # via registerFrameReceivedListener during connect. precise_sleep(.01) # let processor sleep briefly before read @@ -382,23 +382,10 @@ def callback(event_d): # ^ On a successful memory read, _memoryReadSuccess will trigger # _memoryRead again and again until end/fail. - def _sendToPort(self, string): - # print(" SR: {}".format(string.strip())) - self.sendAfter(string) - - def sendAfter(self, string): - """Enqueue: *IMPORTANT* Main/other thread may have - called this, or called this via _sendToPort. Any other thread - sending other than the _listen thread is bad, since overlapping - calls to socket cause undefined behavior. - - CanPhysicalLayerGridConnect constructor sets - canSendCallback, and CanLink sets canSendCallback to this - (formerly set to _sendToPort which was formerly a direct call - to _port which was not thread-safe) - - Could a refactor help with this? See issue #62 - - Add a generalized LocalEvent queue avoid deep callstack? - """ - self._sends.appendleft(string) + # def _sendToPort(self, string): + # # print(" SR: {}".format(string.strip())) + # DeprecationWarning("Use a PhysicalLayer subclass' sendFrameAfter") + # self.sendFrameAfter(string) # def _printFrame(self, frame): # # print(" RL: {}".format(frame)) diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index 382ad7d..90e48a0 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -3,10 +3,89 @@ Parent of `CanPhysicalLayer` +To change implementation of popFrames or other methods without +NotImplementedError, call super() normally, as such methods are used +similarly to how a template or generic class would be used in a strictly +OOP language. However, re-implementation is typically not necessary +since Python allows any type to be used for the elements of _sends. + +We implement logic here not only because it is convenient but because +_sends (and the subclass being a state machine with states specific to +the physical layer type) is a the paradigm used by the entire stack +(flow determined by application's port handler, state determined by +stack). This allows single-threaded use or thread-safe multi-threaded +use like the C version of openlcb in OpenMRN. Issue #62 comments discuss +this paradigm (among other necessary structure beyond that) as central +to stability and predictable use in applications. +-Poikilos ''' +from collections import deque + + class PhysicalLayer: + + def __init__(self): + self._sends = deque() + + def sendFrameAfter(self, frame): + """Enqueue: *IMPORTANT* Main/other thread may have + called this. Any other thread sending other than the _listen + thread is bad, since overlapping calls to socket cause undefined + behavior, so this just adds to a deque (double ended queue, used + as FIFO). + - CanPhysicalLayerGridConnect constructor sets + canSendCallback, and CanLink sets canSendCallback to this + (formerly set to a sendToPort function which was formerly a + direct call to a port which was not thread-safe) + - Add a generalized LocalEvent queue avoid deep callstack? + - See issue #62 comment about a local event queue. + For now, CanFrame is used (improved since issue #62 + was solved by adding more states to CanLink so it + can have incremental states instead of requiring two-way + communication [race condition] during a single + blocking call to defineAndReserveAlias) + """ + self._sends.appendleft(frame) + + def popFrames(self): + """empty and return content of _sends + Subclass may reimplement this or enforce types after calling + frames = PhysicalLayer.popFrames(self) (or use super) + Then return frames. + """ + frames = deque() + startCount = self._sends + frame = True + # Do them one at a time to make *really* sure someone else isn't + # editing _sends (it would be a shame if we set frames = + # self._sends and set self._frames = deque() and another + # thread pushed to self._frames in between the two lines of + # code [possible even with GIL probably, since they are + # separate lines]--then the data would be lost). + try: + while True: + if len(self._sends) > startCount: + raise InterruptedError( + "the openlcb stack must be controlled by only one" + " thread (typically the socket thread for" + " predictability and thread safety) but _sends" + " increased during popFrames" + "(don't call pollState until return from" + " popFrames, or before calling it)") + frame = self._sends.pop() # pop is from right + frames.appendleft(frame) + except IndexError as ex: + if str(ex) != "pop from an empty queue": + raise + # else everything is ok (no more frames to get) + + # Stop is done with the exception to avoid a race condition + # between `while len(_sends) > 0` and other operations and + # checks during the loop. + return frames + def physicalLayerUp(self): """abstract method""" raise NotImplementedError("Each subclass must implement this.") @@ -19,9 +98,5 @@ def physicalLayerDown(self): """abstract method""" raise NotImplementedError("Each subclass must implement this.") - def waitForSend(self): - """abstract method (*must* block thread: See implementation(s))""" - raise NotImplementedError("Each subclass must implement this.") - def encodeFrameAsString(self, frame) -> str: raise NotImplementedError("Each subclass must implement this.") diff --git a/openlcb/portinterface.py b/openlcb/portinterface.py index 33544a2..c53df4d 100644 --- a/openlcb/portinterface.py +++ b/openlcb/portinterface.py @@ -7,7 +7,7 @@ Therefore, all port implementations must inherit this if threads are used, and threads must be used in a typical implementation - Unless: alias reservation sequence is split into separate events - and pushChars will run, in a non-blocking manner, before each send + and processChars will run, in a non-blocking manner, before each send call in defineAndReserveAlias. """ from logging import getLogger @@ -28,8 +28,6 @@ class PortInterface: once (on different threads), which would cause undefined behavior (in OS-level implementation of serial port or socket). """ - # FIXME: enforce frame.afterSendState (and deprecate waitForSend in - # sendAliasAllocationSequence) ports = [] def __init__(self): @@ -118,7 +116,7 @@ def send(self, data: Union[bytes, bytearray]) -> None: Raises: InterruptedError: (raised by assertNotBusy) if - port is in use. Use sendAfter in + port is in use. Use sendFrameAfter in Dispatcher to avoid this. Args: diff --git a/python-openlcb.code-workspace b/python-openlcb.code-workspace index 3735fee..5429f43 100644 --- a/python-openlcb.code-workspace +++ b/python-openlcb.code-workspace @@ -73,6 +73,7 @@ "pythonopenlcb", "remotenodeprocessor", "remotenodestore", + "runlevel", "servicetype", "settingtypes", "setuptools", diff --git a/tests/test_canlink.py b/tests/test_canlink.py index 358b57d..1c05461 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -20,7 +20,7 @@ def __init__(self): self.receivedFrames = [] CanPhysicalLayer.__init__(self) - def sendCanFrame(self, frame): + def sendFrameAfter(self, frame): self.receivedFrames.append(frame) diff --git a/tests/test_canphysicallayergridconnect.py b/tests/test_canphysicallayergridconnect.py index d409756..b135dde 100644 --- a/tests/test_canphysicallayergridconnect.py +++ b/tests/test_canphysicallayergridconnect.py @@ -39,14 +39,14 @@ def receiveListener(self, frame): def testCID4Sent(self): self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) - self.gc.sendCanFrame(CanFrame(4, NodeID(0x010203040506), 0xABC)) + self.gc.sendFrameAfter(CanFrame(4, NodeID(0x010203040506), 0xABC)) # self.assertEqual(self.capturedString, ":X14506ABCN;\n") self.assertEqual(self.capturedFrame.encodeAsString(), ":X14506ABCN;\n") def testVerifyNodeSent(self): self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) - self.gc.sendCanFrame( + self.gc.sendFrameAfter( CanFrame(0x19170, 0x365, bytearray([ 0x02, 0x01, 0x12, 0xFE, 0x05, 0x6C]))) @@ -61,7 +61,7 @@ def testOneFrameReceivedExactlyHeaderOnly(self): 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n - self.gc.pushChars(bytes) + self.gc.processChars(bytes) self.assertEqual( self.receivedFrames[0], @@ -78,7 +78,7 @@ def testOneFrameReceivedExactlyWithData(self): 0x43, GC_END_BYTE]) # :X19170365N020112FE056C; - self.gc.pushChars(bytes) + self.gc.processChars(bytes) self.assertEqual( self.receivedFrames[0], @@ -93,7 +93,7 @@ def testOneFrameReceivedHeaderOnlyTwice(self): 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n - self.gc.pushChars(bytes+bytes) + self.gc.processChars(bytes+bytes) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) @@ -107,7 +107,7 @@ def testOneFrameReceivedHeaderOnlyPlusPartOfAnother(self): 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a, # :X19490365N;\n 0x3a, 0x58]) - self.gc.pushChars(bytes) + self.gc.processChars(bytes) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) @@ -115,7 +115,7 @@ def testOneFrameReceivedHeaderOnlyPlusPartOfAnother(self): bytes = bytearray([ 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) - self.gc.pushChars(bytes) + self.gc.processChars(bytes) self.assertEqual(self.receivedFrames[1], CanFrame(0x19490365, bytearray())) @@ -128,12 +128,12 @@ def testOneFrameReceivedInTwoChunks(self): 0x4e, 0x30]) # :X19170365N020112FE056C; - self.gc.pushChars(bytes1) + self.gc.processChars(bytes1) bytes2 = bytearray([ 0x32, 0x30, 0x31, 0x31, 0x32, 0x46, 0x45, 0x30, 0x35, 0x36, 0x43, GC_END_BYTE]) - self.gc.pushChars(bytes2) + self.gc.processChars(bytes2) self.assertEqual( self.receivedFrames[0], @@ -149,14 +149,14 @@ def testSequence(self): 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n - self.gc.pushChars(bytes) + self.gc.processChars(bytes) self.assertEqual(len(self.receivedFrames), 1) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) self.receivedFrames = [] - self.gc.pushChars(bytes) + self.gc.processChars(bytes) self.assertEqual(len(self.receivedFrames), 1) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) From d3944798abf6a6ea8901a7171ffd326fb5e1c880 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Sat, 26 Apr 2025 06:10:19 -0400 Subject: [PATCH 46/99] Move some construction to PhysicalLayer (no more callback, it is the queue mechanism. Application code should determine flow from its socket code--see issue #62). Rename pushChars and pushString methods (formerly ambigously named receieveChars and receieveString) to handleData and handleDataString for clarity (They handle incoming data). Rename sendCanFrame to sendFrameAfter since any PhysicalLayer (non-CAN) can implement it, and "After" indicates it is queued not disrupting the application's control of the socket. Improve docstrings. Set MIN_STATE_VALUE and MAX_STATE_VALUE. FIXME: needs debugging. --- examples/example_cdi_access.py | 10 ++--- examples/example_datagram_transfer.py | 10 ++--- examples/example_frame_interface.py | 8 ++-- examples/example_memory_length_query.py | 10 ++--- examples/example_memory_transfer.py | 10 ++--- examples/example_message_interface.py | 10 ++--- examples/example_node_implementation.py | 10 ++--- examples/example_remote_nodes.py | 10 ++--- openlcb/canbus/canlink.py | 2 +- openlcb/canbus/canphysicallayer.py | 3 +- openlcb/canbus/canphysicallayergridconnect.py | 7 ++-- openlcb/dispatcher.py | 4 +- openlcb/physicallayer.py | 42 +++++++++++++++---- openlcb/portinterface.py | 2 +- openlcb/tcplink/tcpsocket.py | 1 + tests/test_canphysicallayergridconnect.py | 18 ++++---- 16 files changed, 93 insertions(+), 64 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index 18d7e40..8d6cf6f 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -85,11 +85,11 @@ def printDatagram(memo): return False -canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(sendToSocket) -canPhysicalLayerGridConnect.registerFrameReceivedListener(printFrame) +physicalLayer = CanPhysicalLayerGridConnect(sendToSocket) +physicalLayer.registerFrameReceivedListener(printFrame) canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(canPhysicalLayerGridConnect) +canLink.linkPhysicalLayer(physicalLayer) canLink.registerMessageReceivedListener(printMessage) datagramService = DatagramService(canLink) @@ -236,7 +236,7 @@ def processXML(content) : # have the socket layer report up to bring the link layer up and get an alias # print(" SL : link up") -canPhysicalLayerGridConnect.physicalLayerUp() +physicalLayer.physicalLayerUp() while canLink.pollState() != CanLink.State.Permitted: precise_sleep(.02) @@ -285,6 +285,6 @@ def memoryRead(): # print(" RR: "+packet_str.strip()) # ^ commented since MyHandler shows parsed XML fields instead # pass to link processor - canPhysicalLayerGridConnect.processChars(received) + physicalLayer.handleData(received) canLink.onDisconnect() diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index 99403f5..466d8c3 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -60,8 +60,8 @@ def printFrame(frame): print(" RL: "+str(frame)) -canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(sendToSocket) -canPhysicalLayerGridConnect.registerFrameReceivedListener(printFrame) +physicalLayer = CanPhysicalLayerGridConnect(sendToSocket) +physicalLayer.registerFrameReceivedListener(printFrame) def printMessage(message): @@ -69,7 +69,7 @@ def printMessage(message): canLink = CanLink(NodeID(localNodeID)) -canLink.linkPhysicalLayer(canPhysicalLayerGridConnect) +canLink.linkPhysicalLayer(physicalLayer) canLink.registerMessageReceivedListener(printMessage) datagramService = DatagramService(canLink) @@ -101,7 +101,7 @@ def datagramReceiver(memo): # have the socket layer report up to bring the link layer up and get an alias print(" SL : link up") -canPhysicalLayerGridConnect.physicalLayerUp() +physicalLayer.physicalLayerUp() while canLink.pollState() != CanLink.State.Permitted: precise_sleep(.02) @@ -137,7 +137,7 @@ def datagramWrite(): packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.processChars(received) + physicalLayer.handleData(received) canLink.pollState() canLink.onDisconnect() diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index 48914fc..3371491 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -52,13 +52,13 @@ def printFrame(frame): print("RL: {}".format(frame)) -canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(sendToSocket) -canPhysicalLayerGridConnect.registerFrameReceivedListener(printFrame) +physicalLayer = CanPhysicalLayerGridConnect(sendToSocket) +physicalLayer.registerFrameReceivedListener(printFrame) # send an AME frame with arbitrary alias to provoke response frame = CanFrame(ControlFrame.AME.value, 1, bytearray()) print("SL: {}".format(frame)) -canPhysicalLayerGridConnect.sendFrameAfter(frame) +physicalLayer.sendFrameAfter(frame) observer = GridConnectObserver() @@ -71,4 +71,4 @@ def printFrame(frame): packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.processChars(received) + physicalLayer.handleData(received) diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index 808d20a..3252c12 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -65,8 +65,8 @@ def printFrame(frame): print(" RL: {}".format(frame)) -canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(sendToSocket) -canPhysicalLayerGridConnect.registerFrameReceivedListener(printFrame) +physicalLayer = CanPhysicalLayerGridConnect(sendToSocket) +physicalLayer.registerFrameReceivedListener(printFrame) def printMessage(message): @@ -74,7 +74,7 @@ def printMessage(message): canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(canPhysicalLayerGridConnect) +canLink.linkPhysicalLayer(physicalLayer) canLink.registerMessageReceivedListener(printMessage) datagramService = DatagramService(canLink) @@ -120,7 +120,7 @@ def memoryLengthReply(address) : # have the socket layer report up to bring the link layer up and get an alias print(" SL : link up") -canPhysicalLayerGridConnect.physicalLayerUp() +physicalLayer.physicalLayerUp() while canLink.pollState() != CanLink.State.Permitted: precise_sleep(.02) @@ -156,6 +156,6 @@ def memoryRequest(): packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.processChars(received) + physicalLayer.handleData(received) canLink.onDisconnect() diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index b916960..0fc9243 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -65,8 +65,8 @@ def printFrame(frame): print(" RL: {}".format(frame)) -canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(sendToSocket) -canPhysicalLayerGridConnect.registerFrameReceivedListener(printFrame) +physicalLayer = CanPhysicalLayerGridConnect(sendToSocket) +physicalLayer.registerFrameReceivedListener(printFrame) def printMessage(message): @@ -74,7 +74,7 @@ def printMessage(message): canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(canPhysicalLayerGridConnect) +canLink.linkPhysicalLayer(physicalLayer) canLink.registerMessageReceivedListener(printMessage) datagramService = DatagramService(canLink) @@ -119,7 +119,7 @@ def memoryReadFail(memo): # have the socket layer report up to bring the link layer up and get an alias print(" SL : link up") -canPhysicalLayerGridConnect.physicalLayerUp() +physicalLayer.physicalLayerUp() while canLink.pollState() != CanLink.State.Permitted: precise_sleep(.02) @@ -155,6 +155,6 @@ def memoryRead(): packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.processChars(received) + physicalLayer.handleData(received) canLink.onDisconnect() \ No newline at end of file diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index c98669e..d93a93a 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -58,8 +58,8 @@ def printFrame(frame): print(" RL: {}".format(frame)) -canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(sendToSocket) -canPhysicalLayerGridConnect.registerFrameReceivedListener(printFrame) +physicalLayer = CanPhysicalLayerGridConnect(sendToSocket) +physicalLayer.registerFrameReceivedListener(printFrame) def printMessage(msg): @@ -67,14 +67,14 @@ def printMessage(msg): canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(canPhysicalLayerGridConnect) +canLink.linkPhysicalLayer(physicalLayer) canLink.registerMessageReceivedListener(printMessage) ####################### # have the socket layer report up to bring the link layer up and get an alias print(" SL : link up") -canPhysicalLayerGridConnect.physicalLayerUp() +physicalLayer.physicalLayerUp() while canLink.pollState() != CanLink.State.Permitted: precise_sleep(.02) @@ -95,6 +95,6 @@ def printMessage(msg): packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.processChars(received) + physicalLayer.handleData(received) canLink.onDisconnect() diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index bb23d8b..f193e04 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -71,8 +71,8 @@ def printFrame(frame): print(" RL: {}".format(frame)) -canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(sendToSocket) -canPhysicalLayerGridConnect.registerFrameReceivedListener(printFrame) +physicalLayer = CanPhysicalLayerGridConnect(sendToSocket) +physicalLayer.registerFrameReceivedListener(printFrame) def printMessage(message): @@ -80,7 +80,7 @@ def printMessage(message): canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(canPhysicalLayerGridConnect) +canLink.linkPhysicalLayer(physicalLayer) canLink.registerMessageReceivedListener(printMessage) datagramService = DatagramService(canLink) @@ -149,7 +149,7 @@ def displayOtherNodeIds(message) : # have the socket layer report up to bring the link layer up and get an alias print(" SL : link up") -canPhysicalLayerGridConnect.physicalLayerUp() +physicalLayer.physicalLayerUp() while canLink.pollState() != CanLink.State.Permitted: precise_sleep(.02) @@ -169,6 +169,6 @@ def displayOtherNodeIds(message) : packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.processChars(received) + physicalLayer.handleData(received) canLink.onDisconnect() diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index d840461..229802e 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -71,8 +71,8 @@ def receiveFrame(frame) : if settings['trace']: print("RL: "+str(frame)) -canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(sendToSocket) -canPhysicalLayerGridConnect.registerFrameReceivedListener(receiveFrame) +physicalLayer = CanPhysicalLayerGridConnect(sendToSocket) +physicalLayer.registerFrameReceivedListener(receiveFrame) def printMessage(msg): @@ -81,7 +81,7 @@ def printMessage(msg): canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(canPhysicalLayerGridConnect) +canLink.linkPhysicalLayer(physicalLayer) canLink.registerMessageReceivedListener(printMessage) # create a node and connect it update @@ -115,7 +115,7 @@ def receiveLoop() : """put the read on a separate thread""" # bring the CAN level up if settings['trace'] : print(" SL : link up") - canPhysicalLayerGridConnect.physicalLayerUp() + physicalLayer.physicalLayerUp() while canLink.pollState() != CanLink.State.Permitted: precise_sleep(.02) @@ -127,7 +127,7 @@ def receiveLoop() : packet_str = observer.next() print(" RR: "+packet_str.strip()) # pass to link processor - canPhysicalLayerGridConnect.processChars(received) + physicalLayer.handleData(received) import threading # noqa E402 diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 7f920d0..76586df 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -882,7 +882,7 @@ def processCollision(self, frame) : def pollState(self): """You must keep polling state after every time a state change frame is sent, and after - every call to processString or processChars + every call to handleDataString or handleData for the stack to keep operating. - calling this automatically *must not* be implemented there, because this exists to diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index 178198f..90c79a0 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -25,7 +25,8 @@ def sendFrameAfter(self, frame: CanFrame): This implementation behaves the same except requires a specific type (CanFrame). """ - # formerly sendCanFrame, but now behavior is defined by superclass. + # formerly sendCan Frame, but now behavior is defined by superclass + # (regardless of frame type, it is just added to self._sends) assert isinstance(frame, CanFrame) PhysicalLayer.sendFrameAfter(self, frame) diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index 7db74d3..d896d0f 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -60,15 +60,16 @@ def encodeFrameAsString(self, frame) -> str: output += ";\n" return output - def processString(self, string: str): + def handleDataString(self, string: str): '''Provide string from the outside link to be parsed Args: string (str): A new UTF-8 string from outside link ''' - self.processChars(string.encode("utf-8")) + # formerly pushString formerly receiveString + self.handleData(string.encode("utf-8")) - def processChars(self, data: Union[bytes, bytearray]): + def handleData(self, data: Union[bytes, bytearray]): """Provide characters from the outside link to be parsed Args: diff --git a/openlcb/dispatcher.py b/openlcb/dispatcher.py index 90abffa..de25773 100644 --- a/openlcb/dispatcher.py +++ b/openlcb/dispatcher.py @@ -287,7 +287,7 @@ def _listen(self): file=sys.stderr) # print(" RR: {}".format(received.strip())) # pass to link processor - self._canPhysicalLayerGridConnect.processChars(received) + self._canPhysicalLayerGridConnect.handleData(received) # ^ will trigger self._printFrame if that was added # via registerFrameReceivedListener during connect. precise_sleep(.01) # let processor sleep briefly before read @@ -369,7 +369,7 @@ def callback(event_d): "No port connection. Call start_listening first.") if not self._canPhysicalLayerGridConnect: raise RuntimeError( - "No canPhysicalLayerGridConnect. Call start_listening first.") + "No physicalLayer. Call start_listening first.") self._cdi_offset = 0 self._reset_tree() self._mode = Dispatcher.Mode.CDI diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index 90e48a0..1471b7f 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -9,14 +9,15 @@ OOP language. However, re-implementation is typically not necessary since Python allows any type to be used for the elements of _sends. -We implement logic here not only because it is convenient but because -_sends (and the subclass being a state machine with states specific to -the physical layer type) is a the paradigm used by the entire stack -(flow determined by application's port handler, state determined by -stack). This allows single-threaded use or thread-safe multi-threaded -use like the C version of openlcb in OpenMRN. Issue #62 comments discuss -this paradigm (among other necessary structure beyond that) as central -to stability and predictable use in applications. +We implement logic here not only because it is convenient but also +because _sends (and the subclass being a state machine with states +specific to the physical layer type) is a the paradigm used by this +openlcb stack (Python module) as a whole (connection and flow determined +by application's port code, state determined by the openlcb stack). This +allows single-threaded use or thread-safe multi-threaded use like the C +version of openlcb in OpenMRN. Issue #62 comments discuss this paradigm +(among other necessary structure beyond that) as central to stability +and predictable use in applications. -Poikilos ''' @@ -25,6 +26,28 @@ class PhysicalLayer: + """Generalize access to the physical layer;. + + Parent of `CanPhysicalLayer` + + The PhysicalLayer class enforces restrictions on how many node instances + can be created on a single machine. + + If you need more than one node (such as to create virtual nodes), call: + ``` + PhysicalLayer.moreThanOneNodeOnMyMachine(count) + ``` + with `count` higher than 1. + + If you did that and this warning still appears, set: + ``` + PhysicalLayer.allowDynamicNodes(true) + ``` + ONLY if you are really sure you require the number of nodes *on this machine* + (*not* including remote network nodes) to be more or less at different times + (and stack memory allocations don't need to be manually optimized on + the target platform). + """ def __init__(self): self._sends = deque() @@ -86,6 +109,9 @@ def popFrames(self): # checks during the loop. return frames + def pollFrame(self): + return self._sends.pop() + def physicalLayerUp(self): """abstract method""" raise NotImplementedError("Each subclass must implement this.") diff --git a/openlcb/portinterface.py b/openlcb/portinterface.py index c53df4d..10cebdc 100644 --- a/openlcb/portinterface.py +++ b/openlcb/portinterface.py @@ -7,7 +7,7 @@ Therefore, all port implementations must inherit this if threads are used, and threads must be used in a typical implementation - Unless: alias reservation sequence is split into separate events - and processChars will run, in a non-blocking manner, before each send + and handleData will run, in a non-blocking manner, before each send call in defineAndReserveAlias. """ from logging import getLogger diff --git a/openlcb/tcplink/tcpsocket.py b/openlcb/tcplink/tcpsocket.py index 982b446..01ee853 100644 --- a/openlcb/tcplink/tcpsocket.py +++ b/openlcb/tcplink/tcpsocket.py @@ -55,6 +55,7 @@ def _connect(self, host, port, device=None): # don't access port (part of missing implementation discussed # in issue #62). This requires a loop with both send and recv # (sleep on BlockingIOError to use less CPU). + self.setState... def _send(self, data: Union[bytes, bytearray]): """Send a single message (bytes) diff --git a/tests/test_canphysicallayergridconnect.py b/tests/test_canphysicallayergridconnect.py index b135dde..1da7e72 100644 --- a/tests/test_canphysicallayergridconnect.py +++ b/tests/test_canphysicallayergridconnect.py @@ -61,7 +61,7 @@ def testOneFrameReceivedExactlyHeaderOnly(self): 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n - self.gc.processChars(bytes) + self.gc.handleData(bytes) self.assertEqual( self.receivedFrames[0], @@ -78,7 +78,7 @@ def testOneFrameReceivedExactlyWithData(self): 0x43, GC_END_BYTE]) # :X19170365N020112FE056C; - self.gc.processChars(bytes) + self.gc.handleData(bytes) self.assertEqual( self.receivedFrames[0], @@ -93,7 +93,7 @@ def testOneFrameReceivedHeaderOnlyTwice(self): 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n - self.gc.processChars(bytes+bytes) + self.gc.handleData(bytes+bytes) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) @@ -107,7 +107,7 @@ def testOneFrameReceivedHeaderOnlyPlusPartOfAnother(self): 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a, # :X19490365N;\n 0x3a, 0x58]) - self.gc.processChars(bytes) + self.gc.handleData(bytes) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) @@ -115,7 +115,7 @@ def testOneFrameReceivedHeaderOnlyPlusPartOfAnother(self): bytes = bytearray([ 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) - self.gc.processChars(bytes) + self.gc.handleData(bytes) self.assertEqual(self.receivedFrames[1], CanFrame(0x19490365, bytearray())) @@ -128,12 +128,12 @@ def testOneFrameReceivedInTwoChunks(self): 0x4e, 0x30]) # :X19170365N020112FE056C; - self.gc.processChars(bytes1) + self.gc.handleData(bytes1) bytes2 = bytearray([ 0x32, 0x30, 0x31, 0x31, 0x32, 0x46, 0x45, 0x30, 0x35, 0x36, 0x43, GC_END_BYTE]) - self.gc.processChars(bytes2) + self.gc.handleData(bytes2) self.assertEqual( self.receivedFrames[0], @@ -149,14 +149,14 @@ def testSequence(self): 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) # :X19490365N;\n - self.gc.processChars(bytes) + self.gc.handleData(bytes) self.assertEqual(len(self.receivedFrames), 1) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) self.receivedFrames = [] - self.gc.processChars(bytes) + self.gc.handleData(bytes) self.assertEqual(len(self.receivedFrames), 1) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) From 212c5a7b8738c9919dcb0b5cf53932a79852f5e1 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Sun, 27 Apr 2025 02:02:23 -0400 Subject: [PATCH 47/99] Conform examples and some tests to new LinkLayer state machine. FIXME: needs debugging. --- examples/example_cdi_access.py | 56 +++++++----- examples/example_datagram_transfer.py | 54 +++++++----- examples/example_frame_interface.py | 44 +++++++--- examples/example_memory_length_query.py | 57 ++++++++----- examples/example_memory_transfer.py | 36 +++++--- examples/example_message_interface.py | 48 +++++++---- examples/example_node_implementation.py | 52 +++++++----- examples/example_remote_nodes.py | 67 +++++++++------ examples/example_string_interface.py | 6 ++ examples/example_string_serial_interface.py | 6 ++ examples/example_tcp_message_interface.py | 45 +++++++--- examples/examples_gui.py | 5 +- examples/examples_settings.py | 5 +- examples/tkexamples/cdiform.py | 5 +- openlcb/canbus/canframe.py | 3 + openlcb/canbus/canlink.py | 27 ++++-- openlcb/canbus/canphysicallayer.py | 17 ++++ openlcb/canbus/canphysicallayergridconnect.py | 15 ++-- openlcb/dispatcher.py | 28 +++--- openlcb/linklayer.py | 31 ++++++- openlcb/message.py | 13 ++- openlcb/nodeid.py | 3 + openlcb/physicallayer.py | 85 ++++++++----------- openlcb/portinterface.py | 19 +++-- openlcb/rawphysicallayer.py | 31 +++++++ openlcb/tcplink/tcplink.py | 29 ++++--- openlcb/tcplink/tcpsocket.py | 5 +- tests/test_canlink.py | 73 ++++++---------- tests/test_conventions.py | 5 +- tests/test_mdnsconventions.py | 5 +- tests/test_memoryservice.py | 5 +- tests/test_openlcb.py | 5 +- tests/test_snip.py | 5 +- tests/test_tcplink.py | 4 +- 34 files changed, 580 insertions(+), 314 deletions(-) create mode 100644 openlcb/rawphysicallayer.py diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index 8d6cf6f..5744544 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -53,12 +53,12 @@ # " RL, SL are link interface; RM, SM are message interface") -def sendToSocket(frame: CanFrame): - string = frame.encodeAsString() - # print(" SR: {}".format(string.strip())) - sock.sendString(string) - if frame.afterSendState: - canLink.setState(frame.afterSendState) +# def sendToSocket(frame: CanFrame): +# string = frame.encodeAsString() +# # print(" SR: {}".format(string.strip())) +# sock.sendString(string) +# if frame.afterSendState: +# canLink.setState(frame.afterSendState) def printFrame(frame): @@ -85,11 +85,10 @@ def printDatagram(memo): return False -physicalLayer = CanPhysicalLayerGridConnect(sendToSocket) +physicalLayer = CanPhysicalLayerGridConnect() physicalLayer.registerFrameReceivedListener(printFrame) -canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(physicalLayer) +canLink = CanLink(physicalLayer, NodeID(settings['localNodeID'])) canLink.registerMessageReceivedListener(printMessage) datagramService = DatagramService(canLink) @@ -234,12 +233,36 @@ def processXML(content) : ####################### +def pumpEvents(): + try: + received = sock.receive() + if settings['trace']: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + # print(" RR: "+packet_str.strip()) + # ^ commented since MyHandler shows parsed XML fields instead + # pass to link processor + physicalLayer.handleData(received) + except BlockingIOError: + pass + canLink.pollState() + frame = physicalLayer.pollFrame() + if frame: + string = frame.encodeAsString() + print(" SR: "+string.strip()) + sock.sendString(string) + + # have the socket layer report up to bring the link layer up and get an alias -# print(" SL : link up") +print(" SL : link up...") physicalLayer.physicalLayerUp() - +print(" SL : link up...waiting...") while canLink.pollState() != CanLink.State.Permitted: + pumpEvents() precise_sleep(.02) +print(" SL : link up") + def memoryRead(): """Create and send a read datagram. @@ -275,16 +298,9 @@ def memoryRead(): observer = GridConnectObserver() + # process resulting activity while True: - received = sock.receive() - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - # print(" RR: "+packet_str.strip()) - # ^ commented since MyHandler shows parsed XML fields instead - # pass to link processor - physicalLayer.handleData(received) + pumpEvents() canLink.onDisconnect() diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index 466d8c3..5295794 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -12,6 +12,7 @@ # region same code as other examples from examples_settings import Settings from openlcb import precise_sleep +from openlcb.canbus.canframe import CanFrame from openlcb.canbus.gridconnectobserver import GridConnectObserver # do 1st to fix path if no pip install settings = Settings() @@ -48,19 +49,19 @@ " RL, SL are link interface; RM, SM are message interface") -def sendToSocket(frame): - string = frame.encodeAsString() - print(" SR: "+string.strip()) - sock.sendString(string) - if frame.afterSendState: - canLink.setState(frame.afterSendState) +# def sendToSocket(frame: CanFrame): +# string = frame.encodeAsString() +# print(" SR: "+string.strip()) +# sock.sendString(string) +# if frame.afterSendState: +# canLink.setState(frame.afterSendState) def printFrame(frame): print(" RL: "+str(frame)) -physicalLayer = CanPhysicalLayerGridConnect(sendToSocket) +physicalLayer = CanPhysicalLayerGridConnect() physicalLayer.registerFrameReceivedListener(printFrame) @@ -68,8 +69,7 @@ def printMessage(message): print("RM: {} from {}".format(message, message.source)) -canLink = CanLink(NodeID(localNodeID)) -canLink.linkPhysicalLayer(physicalLayer) +canLink = CanLink(physicalLayer, NodeID(localNodeID)) canLink.registerMessageReceivedListener(printMessage) datagramService = DatagramService(canLink) @@ -99,10 +99,33 @@ def datagramReceiver(memo): ####################### + +def pumpEvents(): + received = sock.receive() + if settings['trace']: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + print(" RR: "+packet_str.strip()) + # pass to link processor + physicalLayer.handleData(received) + canLink.pollState() + frame = physicalLayer.pollFrame() + if frame: + sock.sendString(frame.encodeAsString()) + if frame.afterSendState: + canLink.setState(frame.afterSendState) + + # have the socket layer report up to bring the link layer up and get an alias -print(" SL : link up") +print(" SL : link up...") physicalLayer.physicalLayerUp() +print(" SL : link up...waiting...") +physicalLayer.physicalLayerUp() +print(" SL : link up") + while canLink.pollState() != CanLink.State.Permitted: + pumpEvents() precise_sleep(.02) @@ -130,14 +153,7 @@ def datagramWrite(): # process resulting activity while True: - received = sock.receive() - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) - canLink.pollState() + pumpEvents() + canLink.onDisconnect() diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index 3371491..3a39056 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -40,19 +40,40 @@ " RL, SL are link (frame) interface") -def sendToSocket(frame): +def sendToSocket(frame: CanFrame): string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) - if frame.afterSendState: - canLink.setState(frame.afterSendState) + # if frame.afterSendState: + # canLink.setState(frame.afterSendState) + + +def pumpEvents(): + received = sock.receive() + if settings['trace']: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + print(" RR: "+packet_str.strip()) + # pass to link processor + physicalLayer.handleData(received) + # canLink.pollState() + frame = physicalLayer.pollFrame() + if frame: + string = frame.encodeAsString() + print(" SR: {}".format(string.strip())) + sock.sendString(string) + if frame.afterSendState: + print("Next state (unexpected, no link layer): {}" + .format(frame.afterSendState)) + # canLink.setState(frame.afterSendState) def printFrame(frame): print("RL: {}".format(frame)) -physicalLayer = CanPhysicalLayerGridConnect(sendToSocket) +physicalLayer = CanPhysicalLayerGridConnect() physicalLayer.registerFrameReceivedListener(printFrame) # send an AME frame with arbitrary alias to provoke response @@ -60,15 +81,14 @@ def printFrame(frame): print("SL: {}".format(frame)) physicalLayer.sendFrameAfter(frame) +while True: + frame = physicalLayer.pollFrame() + if not frame: + break + sendToSocket(frame) + observer = GridConnectObserver() # display response - should be RID from nodes while True: - received = sock.receive() - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) + pumpEvents() diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index 3252c12..bc5bd5b 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -53,19 +53,19 @@ " RL, SL are link interface; RM, SM are message interface") -def sendToSocket(frame): - string = frame.encodeAsString() - print(" SR: {}".format(string.strip())) - sock.sendString(string) - if frame.afterSendState: - canLink.setState(frame.afterSendState) +# def sendToSocket(frame: CanFrame): +# string = frame.encodeAsString() +# print(" SR: {}".format(string.strip())) +# sock.sendString(string) +# if frame.afterSendState: +# canLink.setState(frame.afterSendState) def printFrame(frame): print(" RL: {}".format(frame)) -physicalLayer = CanPhysicalLayerGridConnect(sendToSocket) +physicalLayer = CanPhysicalLayerGridConnect() physicalLayer.registerFrameReceivedListener(printFrame) @@ -73,8 +73,7 @@ def printMessage(message): print("RM: {} from {}".format(message, message.source)) -canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(physicalLayer) +canLink = CanLink(physicalLayer, NodeID(settings['localNodeID'])) canLink.registerMessageReceivedListener(printMessage) datagramService = DatagramService(canLink) @@ -118,12 +117,36 @@ def memoryLengthReply(address) : ####################### +def pumpEvents(): + received = sock.receive() + if settings['trace']: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + print(" RR: "+packet_str.strip()) + # pass to link processor + physicalLayer.handleData(received) + canLink.pollState() + frame = physicalLayer.pollFrame() + if frame: + string = frame.encodeAsString() + print(" SR: {}".format(string.strip())) + sock.sendString(string) + if frame.afterSendState: + canLink.setState(frame.afterSendState) + # have the socket layer report up to bring the link layer up and get an alias -print(" SL : link up") + +print(" SL : link up...") physicalLayer.physicalLayerUp() +print(" SL : link up...waiting...") +physicalLayer.physicalLayerUp() + + while canLink.pollState() != CanLink.State.Permitted: + pumpEvents() precise_sleep(.02) - +print(" SL : link up") def memoryRequest(): """Create and send a read datagram. @@ -147,15 +170,11 @@ def memoryRequest(): observer = GridConnectObserver() + + # process resulting activity while True: - received = sock.receive() - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) + pumpEvents() + canLink.onDisconnect() diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index 0fc9243..b927886 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -53,7 +53,7 @@ " RL, SL are link interface; RM, SM are message interface") -def sendToSocket(frame): +def sendToSocket(frame: CanFrame): string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) @@ -73,8 +73,7 @@ def printMessage(message): print("RM: {} from {}".format(message, message.source)) -canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(physicalLayer) +canLink = CanLink(physicalLayer, NodeID(settings['localNodeID'])) canLink.registerMessageReceivedListener(printMessage) datagramService = DatagramService(canLink) @@ -116,13 +115,31 @@ def memoryReadFail(memo): ####################### +def pumpEvents(): + received = sock.receive() + if settings['trace']: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + print(" RR: "+packet_str.strip()) + # pass to link processor + physicalLayer.handleData(received) + canLink.pollState() + frame = physicalLayer.pollFrame() + if frame: + sock.sendString(frame.encodeAsString()) + if frame.afterSendState: + canLink.setState(frame.afterSendState) # have the socket layer report up to bring the link layer up and get an alias -print(" SL : link up") + +print(" SL : link up...") physicalLayer.physicalLayerUp() +print(" SL : link up...waiting...") while canLink.pollState() != CanLink.State.Permitted: + pumpEvents() precise_sleep(.02) - +print(" SL : link up") def memoryRead(): """Create and send a read datagram. @@ -148,13 +165,6 @@ def memoryRead(): # process resulting activity while True: - received = sock.receive() - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) + pumpEvents() canLink.onDisconnect() \ No newline at end of file diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index d93a93a..cd77fee 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -46,12 +46,12 @@ " SL are link interface; RM, SM are message interface") -def sendToSocket(frame): - string = frame.encodeAsString() - print(" SR: {}".format(string.strip())) - sock.sendString(string) - if frame.afterSendState: - canLink.setState(frame.afterSendState) +# def sendToSocket(frame: CanFrame): +# string = frame.encodeAsString() +# print(" SR: {}".format(string.strip())) +# sock.sendString(string) +# if frame.afterSendState: +# canLink.setState(frame.afterSendState) def printFrame(frame): @@ -67,17 +67,38 @@ def printMessage(msg): canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(physicalLayer) canLink.registerMessageReceivedListener(printMessage) ####################### +def pumpEvents(): + received = sock.receive() + if settings['trace']: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + print(" RR: "+packet_str.strip()) + # pass to link processor + physicalLayer.handleData(received) + canLink.pollState() + frame = physicalLayer.pollFrame() + if frame: + string = frame.encodeAsString() + print(" SR: {}".format(string.strip())) + sock.sendString(string) + if frame.afterSendState: + canLink.setState(frame.afterSendState) + # have the socket layer report up to bring the link layer up and get an alias -print(" SL : link up") + +print(" SL : link up...") +physicalLayer.physicalLayerUp() +print(" SL : link up...waiting...") physicalLayer.physicalLayerUp() while canLink.pollState() != CanLink.State.Permitted: + pumpEvents() precise_sleep(.02) - +print(" SL : link up") # send an VerifyNodes message to provoke response message = Message(MTI.Verify_NodeID_Number_Global, NodeID(settings['localNodeID']), None) @@ -88,13 +109,6 @@ def printMessage(msg): # process resulting activity while True: - received = sock.receive() - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) + pumpEvents() canLink.onDisconnect() diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index f193e04..5dbf39d 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -59,19 +59,19 @@ " RL, SL are link interface; RM, SM are message interface") -def sendToSocket(frame): - string = frame.encodeAsString() - print(" SR: {}".format(string.strip())) - sock.sendString(string) - if frame.afterSendState: - canLink.setState(frame.afterSendState) +# def sendToSocket(frame: CanFrame): +# string = frame.encodeAsString() +# print(" SR: {}".format(string.strip())) +# sock.sendString(string) +# if frame.afterSendState: +# canLink.setState(frame.afterSendState) def printFrame(frame): print(" RL: {}".format(frame)) -physicalLayer = CanPhysicalLayerGridConnect(sendToSocket) +physicalLayer = CanPhysicalLayerGridConnect() physicalLayer.registerFrameReceivedListener(printFrame) @@ -79,8 +79,7 @@ def printMessage(message): print("RM: {} from {}".format(message, message.source)) -canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(physicalLayer) +canLink = CanLink(physicalLayer, NodeID(settings['localNodeID'])) canLink.registerMessageReceivedListener(printMessage) datagramService = DatagramService(canLink) @@ -147,12 +146,34 @@ def displayOtherNodeIds(message) : ####################### +def pumpEvents(): + received = sock.receive() + if settings['trace']: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + print(" RR: "+packet_str.strip()) + # pass to link processor + physicalLayer.handleData(received) + canLink.pollState() + frame = physicalLayer.pollFrame() + if frame: + string = frame.encodeAsString() + print(" SR: {}".format(string.strip())) + sock.sendString(string) + if frame.afterSendState: + canLink.setState(frame.afterSendState) + + # have the socket layer report up to bring the link layer up and get an alias -print(" SL : link up") + +print(" SL : link up...") physicalLayer.physicalLayerUp() +print(" SL : link up...waiting...") while canLink.pollState() != CanLink.State.Permitted: + pumpEvents() precise_sleep(.02) - +print(" SL : link up") # request that nodes identify themselves so that we can print their node IDs message = Message(MTI.Verify_NodeID_Number_Global, NodeID(settings['localNodeID']), None) @@ -162,13 +183,6 @@ def displayOtherNodeIds(message) : # process resulting activity while True: - received = sock.receive() - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) + pumpEvents() canLink.onDisconnect() diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index 229802e..1a7f7b0 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -59,19 +59,19 @@ " RL, SL are link (frame) interface") -def sendToSocket(frame) : - string = frame.encodeAsString() - if settings['trace'] : print(" SR: "+string.strip()) - sock.sendString(string) - if frame.afterSendState: - canLink.setState(frame.afterSendState) +# def sendToSocket(frame: CanFrame) : + # string = frame.encodeAsString() + # if settings['trace'] : print(" SR: "+string.strip()) + # sock.sendString(string) + # if frame.afterSendState: + # canLink.setState(frame.afterSendState) def receiveFrame(frame) : if settings['trace']: print("RL: "+str(frame)) -physicalLayer = CanPhysicalLayerGridConnect(sendToSocket) +physicalLayer = CanPhysicalLayerGridConnect() physicalLayer.registerFrameReceivedListener(receiveFrame) @@ -80,8 +80,7 @@ def printMessage(msg): readQueue.put(msg) -canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(physicalLayer) +canLink = CanLink(physicalLayer, NodeID(settings['localNodeID'])) canLink.registerMessageReceivedListener(printMessage) # create a node and connect it update @@ -105,29 +104,47 @@ def printMessage(msg): remoteNodeStore.processMessageFromLinkLayer ) - readQueue = Queue() observer = GridConnectObserver() - -def receiveLoop() : +def pumpEvents(): + received = sock.receive() + if settings['trace']: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + print(" RR: "+packet_str.strip()) + # pass to link processor + physicalLayer.handleData(received) + canLink.pollState() + frame = physicalLayer.pollFrame() + if frame: + string = frame.encodeAsString() + if settings['trace'] : print(" SR: "+string.strip()) + sock.sendString(string) + if frame.afterSendState: + canLink.setState(frame.afterSendState) + + +# bring the CAN level up + +print(" SL : link up...") +physicalLayer.physicalLayerUp() +print(" SL : link up...waiting...") +physicalLayer.physicalLayerUp() +print(" SL : link up") + +while canLink.pollState() != CanLink.State.Permitted: + pumpEvents() + precise_sleep(.02) + + +def receiveLoop(): """put the read on a separate thread""" - # bring the CAN level up - if settings['trace'] : print(" SL : link up") - physicalLayer.physicalLayerUp() - while canLink.pollState() != CanLink.State.Permitted: - precise_sleep(.02) while True: - received = sock.receive() - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) + pumpEvents() import threading # noqa E402 diff --git a/examples/example_string_interface.py b/examples/example_string_interface.py index 9c70694..9680beb 100644 --- a/examples/example_string_interface.py +++ b/examples/example_string_interface.py @@ -48,3 +48,9 @@ if observer.hasNext(): packet_str = observer.next() print(" RR: "+packet_str.strip()) + canLink.pollState() + frame = physicalLayer.pollFrame() + if frame: + sock.sendString(frame.encodeAsString()) + if frame.afterSendState: + canLink.setState(frame.afterSendState) diff --git a/examples/example_string_serial_interface.py b/examples/example_string_serial_interface.py index 8bb94ce..fad32d9 100644 --- a/examples/example_string_serial_interface.py +++ b/examples/example_string_serial_interface.py @@ -47,3 +47,9 @@ if observer.hasNext(): packet_str = observer.next() print(" RR: "+packet_str.strip()) + canLink.pollState() + frame = physicalLayer.pollFrame() + if frame: + sock.sendString(frame.encodeAsString()) + if frame.afterSendState: + canLink.setState(frame.afterSendState) diff --git a/examples/example_tcp_message_interface.py b/examples/example_tcp_message_interface.py index 660ee9a..56efc2f 100644 --- a/examples/example_tcp_message_interface.py +++ b/examples/example_tcp_message_interface.py @@ -11,20 +11,29 @@ the address and port). Defaults to a hard-coded test address and port. ''' +from logging import getLogger # region same code as other examples -from examples_settings import Settings # do 1st to fix path if no pip install +from examples_settings import Settings +from openlcb.rawphysicallayer import RealtimeRawPhysicalLayer # do 1st to fix path if no pip install settings = Settings() if __name__ == "__main__": settings.load_cli_args(docstring=__doc__) # endregion same code as other examples +import openlcb.physicallayer from openlcb.tcplink.tcpsocket import TcpSocket from openlcb.tcplink.tcplink import TcpLink from openlcb.nodeid import NodeID from openlcb.message import Message from openlcb.mti import MTI +from openlcb.physicallayer import PhysicalLayer + +if __name__ == "__main__": + logger = getLogger(__file__) +else: + logger = getLogger(__name__) # specify connection information # region moved to settings @@ -43,29 +52,30 @@ " RM, SM are message interface") -def sendToSocket(data): - # if isinstance(data, list): - # raise TypeError( - # "Got {}({}) but expected str" - # .format(type(data).__name__, data) - # ) - print(" SR: {}".format(data)) - sock.send(data) +# def sendToSocket(data: Union(bytes, bytearray)): +# assert isinstance(data, (bytes, bytearray)) +# print(" SR: {}".format(data)) +# sock.send(data) +# ^ Moved to RealtimeRawPhysicalLayer sendFrameAfter override def printMessage(msg): print("RM: {} from {}".format(msg, msg.source)) -tcpLinkLayer = TcpLink(NodeID(100)) +physicalLayer = RealtimeRawPhysicalLayer(sock) +# ^ this was not in the example before +# (just gave sendToSocket to TcpLink) + +tcpLinkLayer = TcpLink(physicalLayer, 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") +print(" SL : link up...") tcpLinkLayer.linkUp() +print(" SL : link up") # send an VerifyNodes message to provoke response message = Message(MTI.Verify_NodeID_Number_Global, @@ -73,9 +83,20 @@ def printMessage(msg): print("SM: {}".format(message)) tcpLinkLayer.sendMessage(message) +# N/A +# while not tcpLinkLayer.getState() == TcpLink.State.Permitted: +# time.sleep(.02) + # process resulting activity while True: received = sock.receive() print(" RR: {}".format(received)) # pass to link processor tcpLinkLayer.receiveListener(received) + # Normally we would do (Probably N/A here): + # canLink.pollState() + # frame = physicalLayer.pollFrame() + # if frame: + # sock.sendString(frame.encodeAsString()) + # if frame.afterSendState: + # canLink.setState(frame.afterSendState) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index df2141b..777ba83 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -56,7 +56,10 @@ class ServiceBrowser: """Placeholder for when zeroconf is *not* present""" pass -logger = getLogger(__name__) +if __name__ == "__main__": + logger = getLogger(__file__) +else: + logger = getLogger(__name__) class MyListener(ServiceListener): diff --git a/examples/examples_settings.py b/examples/examples_settings.py index dea0942..b9ea92d 100644 --- a/examples/examples_settings.py +++ b/examples/examples_settings.py @@ -9,7 +9,10 @@ import sys from logging import getLogger -logger = getLogger(__name__) +if __name__ == "__main__": + logger = getLogger(__file__) +else: + logger = getLogger(__name__) REPO_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) if os.path.isfile(os.path.join(REPO_DIR, "openlcb", "__init__.py")): diff --git a/examples/tkexamples/cdiform.py b/examples/tkexamples/cdiform.py index ae49229..50e3b3b 100644 --- a/examples/tkexamples/cdiform.py +++ b/examples/tkexamples/cdiform.py @@ -20,7 +20,10 @@ from openlcb.dispatcher import element_to_dict -logger = getLogger(__name__) +if __name__ == "__main__": + logger = getLogger(__file__) +else: + logger = getLogger(__name__) TKEXAMPLES_DIR = os.path.dirname(os.path.realpath(__file__)) EXAMPLES_DIR = os.path.dirname(TKEXAMPLES_DIR) diff --git a/openlcb/canbus/canframe.py b/openlcb/canbus/canframe.py index 3137182..19fb09d 100644 --- a/openlcb/canbus/canframe.py +++ b/openlcb/canbus/canframe.py @@ -87,6 +87,9 @@ def __str__(self): def encodeAsString(self) -> str: return self.encoder.encodeFrameAsString(self) + def encodeAsBytes(self) -> bytes: + return self.encodeAsString().encode("utf-8") + @property def alias(self) -> int: return self._alias diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 76586df..aa89906 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -31,6 +31,7 @@ from openlcb.message import Message from openlcb.mti import MTI from openlcb.nodeid import NodeID +from openlcb.physicallayer import PhysicalLayer logger = getLogger(__name__) @@ -128,7 +129,8 @@ class State(Enum): MIN_STATE_VALUE = min(entry.value for entry in State) MAX_STATE_VALUE = max(entry.value for entry in State) - def __init__(self, localNodeID, require_remote_nodes=True): # a NodeID + def __init__(self, physicalLayer: PhysicalLayer, localNodeID, + require_remote_nodes=True): # See class docstring for args self._previousLocalAliasSeed = None self.require_remote_nodes = require_remote_nodes @@ -148,7 +150,7 @@ def __init__(self, localNodeID, require_remote_nodes=True): # a NodeID self.accumulator = {} self.duplicateAliases = [] self.nextInternallyAssignedNodeID = 1 - LinkLayer.__init__(self, localNodeID) + LinkLayer.__init__(self, physicalLayer, localNodeID) # This method may never actually be necessary, as # sendMessage uses nodeIdToAlias (which has localNodeID @@ -206,6 +208,8 @@ def isDuplicateAlias(self, alias): .format(emit_cast(alias))) return alias in self.duplicateAliases + # Commented since instead, socket code should call linkLayerUp and linkLayerDown. + # Constructors should construct the openlcb stack. # def linkPhysicalLayer(self, cpl): # """Set the physical layer to use. # Also registers self.receiveListener as a listener on the given @@ -221,10 +225,10 @@ def isDuplicateAlias(self, alias): # self.physicalLayer = cpl # self.link = cpl # cpl.registerFrameReceivedListener(self.receiveListener) # # ^ Commented since it makes more sense for its - # # constructor to do this, since it needs a LinkLayer + # # constructor to do this, since it needs a PhysicalLayer # # in order to do anything - def _onStateChanged(self, _, newState): + def _onStateChanged(self, oldState, newState): # return super()._onStateChanged(oldState, newState) assert isinstance(newState, CanLink.State) if newState == CanLink.State.EnqueueAliasAllocationRequest: @@ -253,8 +257,10 @@ def _onStateChanged(self, _, newState): # - (state was formerly set to Permitted at end of the # _notifyReservation code, before _recordReservation # code) - - self.linkStateChange(newState) # Notify upper layers + if ((oldState != CanLink.State.Permitted) + and (newState == CanLink.State.Permitted)): + self.linkStateChange(newState) # Notify upper layers + # - formerly done at end of _recordReservation code. # TODO: Make sure upper layers handle any states # necessary (formerly only states other than Initial were # Inhibited & Permitted). @@ -408,17 +414,21 @@ def handleReceivedLinkDown(self, frame): # notify upper levels self.linkStateChange(self._state) - def linkStateChange(self, state): + def linkStateChange(self, state: State): """invoked when the link layer comes up and down Args: state (CanLink.State): See CanLink. """ + assert isinstance(state, CanLink.State) if state == CanLink.State.Permitted: print("[linkStateChange] Link_Layer_Up") msg = Message(MTI.Link_Layer_Up, NodeID(0), None, bytearray()) - else: + elif state.value <= CanLink.State.Inhibited.value: msg = Message(MTI.Link_Layer_Down, NodeID(0), None, bytearray()) + else: + raise TypeError( + "The other layers don't need to know the intermediate steps.") self.fireListeners(msg) def handleReceivedCID(self, frame): # CanFrame @@ -917,6 +927,7 @@ def pollState(self): " only set to True for Standard" " (permissive) behavior") # finish the sends for the alias reservation: + self._waitingForAliasStart = None self.setState(CanLink.State.EnqueueAliasReservation) return self.getState() diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index 90c79a0..2044fa1 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -5,10 +5,14 @@ and is subclassed. ''' import sys +from logging import getLogger + from openlcb.canbus.canframe import CanFrame from openlcb.canbus.controlframe import ControlFrame from openlcb.physicallayer import PhysicalLayer +logger = getLogger(__name__) + class CanPhysicalLayer(PhysicalLayer): """Can implementation of PhysicalLayer, still partly abstract @@ -30,6 +34,13 @@ def sendFrameAfter(self, frame: CanFrame): assert isinstance(frame, CanFrame) PhysicalLayer.sendFrameAfter(self, frame) + def pollFrame(self) -> CanFrame: # overloaded for type hinting. + """Check if there is another frame queued and get it. + Returns: + CanFrame: next frame in FIFO buffer (_sends). + """ + return PhysicalLayer.pollFrame(self) + def encode(self, frame) -> str: '''abstract interface (encode frame to string)''' raise NotImplementedError("Each subclass must implement this.") @@ -38,6 +49,12 @@ def registerFrameReceivedListener(self, listener): self.listeners.append(listener) def fireListeners(self, frame): + if not self.listeners: + logger.warning( + "No listeners for frame received." + " CanLink (see LinkLayer superclass constructor)" + " should at least register its receiveFrame method" + " with a physical layer implementation.") for listener in self.listeners: listener(frame) diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index d896d0f..69c3b3b 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -35,12 +35,17 @@ class CanPhysicalLayerGridConnect(CanPhysicalLayer): interrupted in order to prevent canlink.state from proceeding to CanLink.State.Permitted) """ - def __init__(self, canLink: CanLink): - assert hasattr(canLink, 'pollState') + def __init__(self): + # ^ A CanLink requires a physical layer to operate, + # so CanLink now requires a PhysicalLayer instance + # such as this in its constructor. CanPhysicalLayer.__init__(self) - # canLink.linkPhysicalLayer(self) # self.setCallBack(callback) - canLink.physicalLayer = self - self.registerFrameReceivedListener(canLink.receiveListener) + + # region moved to CanLink constructor + # from canLink.linkPhysicalLayer(self) # self.setCallBack(callback): + # canLink.physicalLayer = self + # self.registerFrameReceivedListener(canLink.receiveListener) + # endregion moved to CanLink constructor self.inboundBuffer = bytearray() diff --git a/openlcb/dispatcher.py b/openlcb/dispatcher.py index de25773..6e4e6b0 100644 --- a/openlcb/dispatcher.py +++ b/openlcb/dispatcher.py @@ -40,7 +40,10 @@ ) from openlcb.platformextras import SysDirs, clean_file_name -logger = getLogger(__name__) +if __name__ == "__main__": + logger = getLogger(__file__) +else: + logger = getLogger(__name__) def element_to_dict(element): @@ -125,7 +128,7 @@ def __init__(self, *args, **kwargs): # region connect self._port = None - self._canPhysicalLayerGridConnect = None + self._physicalLayer = None self._canLink = None self._datagramService = None self._memoryService = None @@ -163,10 +166,9 @@ def start_listening(self, connected_port, localNodeID): "[start_listening] A previous _port will be discarded.") self._port = connected_port self._callback_status("CanPhysicalLayerGridConnect...") - self._canPhysicalLayerGridConnect = \ - CanPhysicalLayerGridConnect(self.sendFrameAfter) + self._physicalLayer = CanPhysicalLayerGridConnect() - # self._canPhysicalLayerGridConnect.registerFrameReceivedListener( + # self._physicalLayer.registerFrameReceivedListener( # self._printFrame # ) # ^ Commented since canlink already adds CanLink's default @@ -174,11 +176,9 @@ def start_listening(self, connected_port, localNodeID): # for this application. self._callback_status("CanLink...") - self._canLink = CanLink(NodeID(localNodeID)) - self._callback_status("CanLink...linkPhysicalLayer...") - self._canLink.linkPhysicalLayer(self._canPhysicalLayerGridConnect) - self._callback_status("CanLink...linkPhysicalLayer" - "...registerMessageReceivedListener...") + self._canLink = CanLink(self._physicalLayer, NodeID(localNodeID)) + self._callback_status("CanLink..." + "registerMessageReceivedListener...") self._canLink.registerMessageReceivedListener(self._handleMessage) # NOTE: Incoming data (Memo) is handled by _memoryReadSuccess # and _memoryReadFail. @@ -205,7 +205,7 @@ def start_listening(self, connected_port, localNodeID): # once, then another 200ms on each alias collision if any) self._callback_status("physicalLayerUp...") - self._canPhysicalLayerGridConnect.physicalLayerUp() + self._physicalLayer.physicalLayerUp() while canLink.pollState() != CanLink.State.Permitted: precise_sleep(.02) @@ -251,7 +251,7 @@ def _listen(self): # Frame Transfer Standard (sendMessage requires ) logger.debug("[_listen] _receive...") try: - sends = self._canPhysicalLayerGridConnect.popFrames() + sends = self._physicalLayer.popFrames() while sends: # *Always* do send in the receive thread to # avoid overlapping calls to socket @@ -287,7 +287,7 @@ def _listen(self): file=sys.stderr) # print(" RR: {}".format(received.strip())) # pass to link processor - self._canPhysicalLayerGridConnect.handleData(received) + self._physicalLayer.handleData(received) # ^ will trigger self._printFrame if that was added # via registerFrameReceivedListener during connect. precise_sleep(.01) # let processor sleep briefly before read @@ -367,7 +367,7 @@ def callback(event_d): if not self._port: raise RuntimeError( "No port connection. Call start_listening first.") - if not self._canPhysicalLayerGridConnect: + if not self._physicalLayer: raise RuntimeError( "No physicalLayer. Call start_listening first.") self._cdi_offset = 0 diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index 360e721..7949dc4 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -17,6 +17,11 @@ from enum import Enum +from logging import getLogger + +from openlcb.physicallayer import PhysicalLayer + +logger = getLogger(__name__) class LinkLayer: @@ -37,10 +42,27 @@ class LinkLayer: class State(Enum): Undefined = 1 # subclass constructor did not run (implement states) - def __init__(self, localNodeID): + def __init__(self, physicalLayer: PhysicalLayer, localNodeID): + assert isinstance(physicalLayer, PhysicalLayer) # allows any subclass + # subclass should check type of localNodeID technically self.localNodeID = localNodeID self.listeners = [] self._state = LinkLayer.State.Undefined + # region moved from CanLink linkPhysicalLayer + self.physicalLayer = physicalLayer # formerly self.link = cpl + # if physicalLayer is not None: + physicalLayer.registerFrameReceivedListener(self.receiveListener) + # else: + # print("Using {} without" + # " registerFrameReceivedListener(self.receiveListener)" + # " on physicalLayer, since no physicalLayer specified." + # .format()) + # endregion moved from CanLink linkPhysicalLayer + + def receiveListener(self, frame): + logger.warning( + "{} abstract receiveListener called (expected implementation)" + .format(type(self).__name__)) def onDisconnect(self): """Run this whenever the socket connection is lost @@ -54,11 +76,12 @@ def onDisconnect(self): def getState(self): return self._state - def setState(self): + def setState(self, state): oldState = self._state - newState = 0 # keep a copy for _onStateChanged, for thread safety + newState = state # keep a copy for _onStateChanged, for thread safety + # (ensure value doesn't change between two lines below) self._state = newState - self._onStateChanged(self, oldState, newState) + self._onStateChanged(oldState, newState) def _onStateChanged(self, oldState, newState): raise NotImplementedError( diff --git a/openlcb/message.py b/openlcb/message.py index bbfe308..07ded70 100644 --- a/openlcb/message.py +++ b/openlcb/message.py @@ -4,8 +4,10 @@ ''' +from openlcb import emit_cast from openlcb.mti import MTI from openlcb.node import Node +from openlcb.nodeid import NodeID class Message: @@ -19,7 +21,7 @@ class Message: empty bytearray(). """ - def __init__(self, mti, source, destination, data=None): + def __init__(self, mti, source: NodeID, destination: NodeID, data=None): # For args, see class docstring. if data is None: data = bytearray() @@ -34,8 +36,13 @@ def __init__(self, mti, source, destination, data=None): def assertTypes(self): assert isinstance(self.mti, MTI) - assert isinstance(self.source, Node) - assert isinstance(self.destination, Node) + assert isinstance(self.source, NodeID), \ + "expected NodeID, got {}".format(emit_cast(self.source)) + if self.destination is not None: + assert isinstance(self.destination, NodeID), \ + "expected NodeID, got {}".format(emit_cast(self.destination)) + # allowed to be None. See linkUp in tcplink.py + # TODO: Only allow in certain conditions? def isGlobal(self): return self.mti.value & 0x0008 == 0 diff --git a/openlcb/nodeid.py b/openlcb/nodeid.py index da34ed6..87e4cc4 100644 --- a/openlcb/nodeid.py +++ b/openlcb/nodeid.py @@ -25,6 +25,9 @@ def __str__(self): return ("{:02X}.{:02X}.{:02X}.{:02X}.{:02X}.{:02X}" "".format(c[0], c[1], c[2], c[3], c[4], c[5])) + def __repr__(self): + return self.__str__() + def __init__(self, data): # For args see class docstring. if isinstance(data, int): # create from an integer value diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index 1471b7f..e5eb67f 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -21,8 +21,12 @@ -Poikilos ''' - from collections import deque +from logging import getLogger + +from openlcb.canbus.canframe import CanFrame + +logger = getLogger(__name__) class PhysicalLayer: @@ -30,10 +34,11 @@ class PhysicalLayer: Parent of `CanPhysicalLayer` - The PhysicalLayer class enforces restrictions on how many node instances - can be created on a single machine. + The PhysicalLayer class enforces restrictions on how many node + instances can be created on a single machine. - If you need more than one node (such as to create virtual nodes), call: + If you need more than one node (such as to create virtual nodes), + call: ``` PhysicalLayer.moreThanOneNodeOnMyMachine(count) ``` @@ -43,10 +48,10 @@ class PhysicalLayer: ``` PhysicalLayer.allowDynamicNodes(true) ``` - ONLY if you are really sure you require the number of nodes *on this machine* - (*not* including remote network nodes) to be more or less at different times - (and stack memory allocations don't need to be manually optimized on - the target platform). + ONLY if you are really sure you require the number of nodes *on this + machine* (*not* including remote network nodes) to be more or less + at different times (and stack memory allocations don't need to be + manually optimized on the target platform). """ def __init__(self): @@ -58,10 +63,13 @@ def sendFrameAfter(self, frame): thread is bad, since overlapping calls to socket cause undefined behavior, so this just adds to a deque (double ended queue, used as FIFO). - - CanPhysicalLayerGridConnect constructor sets - canSendCallback, and CanLink sets canSendCallback to this - (formerly set to a sendToPort function which was formerly a - direct call to a port which was not thread-safe) + - CanPhysicalLayerGridConnect formerly had canSendCallback + but now it uses its own frame deque, and the socket code pops + and sends the frames. + (formerly canSendCallback was set to a sendToPort function + which was formerly a direct call to a port which was not + thread-safe and could be called from anywhere in the + openlcb stack) - Add a generalized LocalEvent queue avoid deep callstack? - See issue #62 comment about a local event queue. For now, CanFrame is used (improved since issue #62 @@ -72,45 +80,26 @@ def sendFrameAfter(self, frame): """ self._sends.appendleft(frame) - def popFrames(self): - """empty and return content of _sends - Subclass may reimplement this or enforce types after calling - frames = PhysicalLayer.popFrames(self) (or use super) - Then return frames. + def pollFrame(self): + """Check if there is another frame queued and get it. + Returns: + Any: next frame in FIFO buffer (_sends). In a + CanPhysicalLayer or subclass of that, type is CanFrame. + In a raw implementation it is either bytes or bytearray. """ - frames = deque() - startCount = self._sends - frame = True - # Do them one at a time to make *really* sure someone else isn't - # editing _sends (it would be a shame if we set frames = - # self._sends and set self._frames = deque() and another - # thread pushed to self._frames in between the two lines of - # code [possible even with GIL probably, since they are - # separate lines]--then the data would be lost). try: - while True: - if len(self._sends) > startCount: - raise InterruptedError( - "the openlcb stack must be controlled by only one" - " thread (typically the socket thread for" - " predictability and thread safety) but _sends" - " increased during popFrames" - "(don't call pollState until return from" - " popFrames, or before calling it)") - frame = self._sends.pop() # pop is from right - frames.appendleft(frame) - except IndexError as ex: - if str(ex) != "pop from an empty queue": - raise - # else everything is ok (no more frames to get) - - # Stop is done with the exception to avoid a race condition - # between `while len(_sends) > 0` and other operations and - # checks during the loop. - return frames + return self._sends.pop() + except IndexError: # "pop from an empty deque" + pass + return None - def pollFrame(self): - return self._sends.pop() + def registerFrameReceivedListener(self, listener): + """abstract method""" + # raise NotImplementedError("Each subclass must implement this.") + logger.warning( + "{} abstract registerFrameReceivedListener called" + " (expected implementation)" + .format(type(self).__name__)) def physicalLayerUp(self): """abstract method""" diff --git a/openlcb/portinterface.py b/openlcb/portinterface.py index 10cebdc..4f9822a 100644 --- a/openlcb/portinterface.py +++ b/openlcb/portinterface.py @@ -161,15 +161,16 @@ def setOpen(self, is_open): def close(self) -> None: return self._close() - def receiveString(self): - '''Receive (usually partial) GridConnect frame and return as string. - - Returns: - str: The received bytes decoded into a UTF-8 string. - ''' - data = self.receive() - # Use receive (uses required semaphores) not _receive (not thread safe) - return data.decode("utf-8") + # replaced with pollFrame + # def receiveString(self): + # '''Receive (usually partial) GridConnect frame and return as string. + + # Returns: + # str: The received bytes decoded into a UTF-8 string. + # ''' + # data = self.receive() + # # Use receive (uses required semaphores) not _receive (not thread safe) + # return data.decode("utf-8") def sendString(self, string): """Send a single string. diff --git a/openlcb/rawphysicallayer.py b/openlcb/rawphysicallayer.py new file mode 100644 index 0000000..2d8616d --- /dev/null +++ b/openlcb/rawphysicallayer.py @@ -0,0 +1,31 @@ + +from logging import getLogger + +from openlcb.physicallayer import PhysicalLayer + +logger = getLogger(__name__) + + +class RealtimeRawPhysicalLayer(PhysicalLayer): + + def __init__(self, socket): + # sock to distinguish from socket module or socket.socket class! + self.sock = socket + + def sendFrameAfter(self, data): + # if isinstance(data, list): + # raise TypeError( + # "Got {}({}) but expected str" + # .format(type(data).__name__, data) + # ) + print(" SR: {}".format(data)) + self.sock.send(data) + + def registerFrameReceivedListener(self, listener): + """_summary_ + + Args: + listener (callable): A method that accepts decoded frame + objects from the network. + """ + logger.warning("registerFrameReceivedListener skipped (That is a link-layer issue, but you are using a Raw physical layer subclass).") diff --git a/openlcb/tcplink/tcplink.py b/openlcb/tcplink/tcplink.py index cd0a3fa..6eb1226 100644 --- a/openlcb/tcplink/tcplink.py +++ b/openlcb/tcplink/tcplink.py @@ -32,22 +32,27 @@ class TcpLink(LinkLayer): software-defined node connecting to the LCC network via TCP. """ - def __init__(self, localNodeID): + def __init__(self, physicalLayer, localNodeID): + LinkLayer.__init__(self, physicalLayer, localNodeID) # See class docstring for argument(s) and attributes. - self.localNodeID = localNodeID - self.linkCall = None + self.physicalLayer = physicalLayer self.accumulatedParts = {} self.nextInternallyAssignedNodeID = 1 self.accumulatedData = bytearray() + self.physicalLayer = physicalLayer # formerly linkCall + self.localNodeID = localNodeID # unused here - def linkPhysicalLayer(self, lpl): - """Register the handler for when the layer is up. + # def linkPhysicalLayer(self, lpl): + # """Register the handler for when the layer is up. - Args: - lpl (Callable): A handler that accepts a bytes object, usually a - socket connection send() method. - """ - self.linkCall = lpl + # Args: + # lpl (Callable): A handler that accepts a bytes object, usually a + # socket connection send() method. + # """ + # raise NotImplementedError( + # "Instead, we should just call linkLayerUp and linkLayerDown." + # " Constructors should construct the openlcb stack.") + # self.physicalLayer = lpl def _onStateChanged(self, oldState, newState): print(f"[TcpLink] _onStateChanged from {oldState} to {newState}" @@ -220,4 +225,6 @@ def sendMessage(self, message): outputBytes.extend(message.data) - self.linkCall(outputBytes) + self.physicalLayer.sendFrameAfter(outputBytes) + # ^ The physical layer should be one with "Raw" in the name + # since takes bytes. See example_tcp_message_interface. diff --git a/openlcb/tcplink/tcpsocket.py b/openlcb/tcplink/tcpsocket.py index 01ee853..e1c2784 100644 --- a/openlcb/tcplink/tcpsocket.py +++ b/openlcb/tcplink/tcpsocket.py @@ -8,6 +8,9 @@ from typing import Union from openlcb.portinterface import PortInterface +from logging import getLogger + +logger = getLogger(__name__) class TcpSocket(PortInterface): @@ -55,7 +58,7 @@ def _connect(self, host, port, device=None): # don't access port (part of missing implementation discussed # in issue #62). This requires a loop with both send and recv # (sleep on BlockingIOError to use less CPU). - self.setState... + logger.warning("You must call physicalLayerUp after this") def _send(self, data: Union[bytes, bytearray]): """Send a single message (bytes) diff --git a/tests/test_canlink.py b/tests/test_canlink.py index 1c05461..b53f45a 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -88,9 +88,9 @@ def testCreateAlias12(self): # MARK: - Test PHY Up def testLinkUpSequence(self): - canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01"), require_remote_nodes=False) - canLink.linkPhysicalLayer(canPhysicalLayer) + = CanPhysicalLayerSimulation() + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01"), + require_remote_nodes=False) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) @@ -107,8 +107,7 @@ def testLinkUpSequence(self): # MARK: - Test PHY Down, Up, Error Information def testLinkDownSequence(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canLink._state = CanLink.State.Permitted @@ -121,8 +120,7 @@ def testLinkDownSequence(self): def testEIR2NoData(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) canLink._state = CanLink.State.Permitted canPhysicalLayer.fireListeners(CanFrame(ControlFrame.EIR2.value, 0)) @@ -132,8 +130,7 @@ def testEIR2NoData(self): # MARK: - Test AME (Local Node) def testAMENoData(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink._state = CanLink.State.Permitted @@ -148,8 +145,7 @@ def testAMENoData(self): def testAMEnoDataInhibited(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) canLink._state = CanLink.State.Inhibited canPhysicalLayer.fireListeners(CanFrame(ControlFrame.AME.value, 0)) @@ -158,9 +154,8 @@ def testAMEnoDataInhibited(self): def testAMEMatchEvent(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) - canLink.linkPhysicalLayer(canPhysicalLayer) canLink._state = CanLink.State.Permitted frame = CanFrame(ControlFrame.AME.value, 0) @@ -174,8 +169,7 @@ def testAMEMatchEvent(self): def testAMENotMatchEvent(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) canLink._state = CanLink.State.Permitted frame = CanFrame(ControlFrame.AME.value, 0) @@ -187,9 +181,8 @@ def testAMENotMatchEvent(self): # MARK: - Test Alias Collisions (Local Node) def testCIDreceivedMatch(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) - canLink.linkPhysicalLayer(canPhysicalLayer) canLink._state = CanLink.State.Permitted canPhysicalLayer.fireListeners(CanFrame(7, canLink.localNodeID, @@ -201,9 +194,8 @@ def testCIDreceivedMatch(self): def testRIDreceivedMatch(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) - canLink.linkPhysicalLayer(canPhysicalLayer) canLink._state = CanLink.State.Permitted canPhysicalLayer.fireListeners(CanFrame(ControlFrame.RID.value, @@ -272,8 +264,7 @@ def testControlFrameIsInternal(self): def testSimpleGlobalData(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canLink._state = CanLink.State.Permitted @@ -302,8 +293,7 @@ def testVerifiedNodeInDestAliasMap(self): # This tests that a VerifiedNode will update that. canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canLink._state = CanLink.State.Permitted @@ -331,8 +321,7 @@ def testNoDestInAliasMap(self): ''' canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canLink._state = CanLink.State.Permitted @@ -356,8 +345,8 @@ def testNoDestInAliasMap(self): def testSimpleAddressedData(self): # Test start=yes, end=yes frame canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01"), require_remote_nodes=False) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01"), + require_remote_nodes=False) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) @@ -393,8 +382,8 @@ def testSimpleAddressedData(self): # Test start=yes, end=yes frame def testSimpleAddressedDataNoAliasYet(self): '''Test start=yes, end=yes frame with no alias match''' canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01"), require_remote_nodes=False) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01"), + require_remote_nodes=False) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) @@ -432,8 +421,8 @@ def testMultiFrameAddressedData(self): Test message in 3 frames ''' canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01"), require_remote_nodes=False) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01"), + require_remote_nodes=False) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) @@ -476,8 +465,8 @@ def testMultiFrameAddressedData(self): def testSimpleDatagram(self): # Test start=yes, end=yes frame canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01"), require_remote_nodes=False) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01"), + require_remote_nodes=False) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) @@ -515,8 +504,7 @@ def testSimpleDatagram(self): # Test start=yes, end=yes frame def testMultiFrameDatagram(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01"), require_remote_nodes=False) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01"), require_remote_nodes=False) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) @@ -568,8 +556,7 @@ def testMultiFrameDatagram(self): def testZeroLengthDatagram(self): canPhysicalLayer = PhyMockLayer() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) message = Message(MTI.Datagram, NodeID("05.01.01.01.03.01"), NodeID("05.01.01.01.03.01")) @@ -583,8 +570,7 @@ def testZeroLengthDatagram(self): def testOneFrameDatagram(self): canPhysicalLayer = PhyMockLayer() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) message = Message(MTI.Datagram, NodeID("05.01.01.01.03.01"), NodeID("05.01.01.01.03.01"), @@ -601,8 +587,7 @@ def testOneFrameDatagram(self): def testTwoFrameDatagram(self): canPhysicalLayer = PhyMockLayer() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) message = Message(MTI.Datagram, NodeID("05.01.01.01.03.01"), NodeID("05.01.01.01.03.01"), @@ -625,8 +610,7 @@ def testTwoFrameDatagram(self): def testThreeFrameDatagram(self): # FIXME: Why was testThreeFrameDatagram named same? What should it be? canPhysicalLayer = PhyMockLayer() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) message = Message(MTI.Datagram, NodeID("05.01.01.01.03.01"), NodeID("05.01.01.01.03.01"), @@ -652,9 +636,8 @@ def testThreeFrameDatagram(self): # MARK: - Test Remote Node Alias Tracking def testAmdAmrSequence(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) - canLink.linkPhysicalLayer(canPhysicalLayer) canPhysicalLayer.fireListeners(CanFrame(0x0701, ourAlias+1)) # ^ AMD from some other alias diff --git a/tests/test_conventions.py b/tests/test_conventions.py index f4afbbd..626625c 100644 --- a/tests/test_conventions.py +++ b/tests/test_conventions.py @@ -4,7 +4,10 @@ from logging import getLogger -logger = getLogger(__name__) +if __name__ == "__main__": + logger = getLogger(__file__) +else: + logger = getLogger(__name__) if __name__ == "__main__": diff --git a/tests/test_mdnsconventions.py b/tests/test_mdnsconventions.py index 6856245..b3fc7c5 100644 --- a/tests/test_mdnsconventions.py +++ b/tests/test_mdnsconventions.py @@ -3,7 +3,10 @@ import unittest from logging import getLogger -logger = getLogger(__name__) +if __name__ == "__main__": + logger = getLogger(__file__) +else: + logger = getLogger(__name__) if __name__ == "__main__": # Allow importing repo copy of openlcb if running tests from repo manually. diff --git a/tests/test_memoryservice.py b/tests/test_memoryservice.py index 3a8bc8b..7d43287 100644 --- a/tests/test_memoryservice.py +++ b/tests/test_memoryservice.py @@ -5,7 +5,10 @@ import unittest from logging import getLogger -logger = getLogger(__name__) +if __name__ == "__main__": + logger = getLogger(__file__) +else: + logger = getLogger(__name__) if __name__ == "__main__": # Allow importing repo copy of openlcb if running tests from repo manually. diff --git a/tests/test_openlcb.py b/tests/test_openlcb.py index 3270d84..c803003 100644 --- a/tests/test_openlcb.py +++ b/tests/test_openlcb.py @@ -4,7 +4,10 @@ import unittest from logging import getLogger -logger = getLogger(__name__) +if __name__ == "__main__": + logger = getLogger(__file__) +else: + logger = getLogger(__name__) if __name__ == "__main__": TESTS_DIR = os.path.dirname(os.path.realpath(__file__)) diff --git a/tests/test_snip.py b/tests/test_snip.py index 4f4adb1..8761ff6 100644 --- a/tests/test_snip.py +++ b/tests/test_snip.py @@ -4,7 +4,10 @@ import unittest from logging import getLogger -logger = getLogger(__name__) +if __name__ == "__main__": + logger = getLogger(__file__) +else: + logger = getLogger(__name__) if __name__ == "__main__": # Allow importing repo copy of openlcb if running tests from repo manually. diff --git a/tests/test_tcplink.py b/tests/test_tcplink.py index 9999c5a..167371a 100644 --- a/tests/test_tcplink.py +++ b/tests/test_tcplink.py @@ -1,5 +1,6 @@ import unittest +from openlcb.rawphysicallayer import RealtimeRawPhysicalLayer from openlcb.tcplink.tcplink import TcpLink from openlcb.message import Message @@ -30,9 +31,8 @@ def testLinkUpSequence(self): messageLayer = MessageMockLayer() tcpLayer = TcpMockLayer() - linkLayer = TcpLink(NodeID(100)) + linkLayer = TcpLink(RealtimeRawPhysicalLayer, NodeID(100)) linkLayer.registerMessageReceivedListener(messageLayer.receiveMessage) - linkLayer.linkPhysicalLayer(tcpLayer.send) linkLayer.linkUp() From 62aff80bdcd16f70132b00123dc30fe80ed545b4 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Wed, 30 Apr 2025 15:54:52 -0400 Subject: [PATCH 48/99] Represent value of enum with emit_cast for more explicit debug output. --- openlcb/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openlcb/__init__.py b/openlcb/__init__.py index 6c64d7f..9760096 100644 --- a/openlcb/__init__.py +++ b/openlcb/__init__.py @@ -1,3 +1,4 @@ +from enum import Enum import re import time @@ -24,6 +25,8 @@ def only_hex_pairs(value: str) -> bool: def emit_cast(value) -> str: """Get type and value, such as for debug output.""" repr_str = repr(value) + if isinstance(value, Enum): + repr_str = "{}".format(value.value) if repr_str.startswith(type(value).__name__): return repr(value) # type already included, such as bytearray(...) return "{}({})".format(type(value).__name__, repr_str) From ca088348017dc2dbd0fdd51b71c407372d092aea Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:31:32 -0400 Subject: [PATCH 49/99] Make a single and required frame handler in physicalLayer for enforcing that the stack exists (Setting physicalLayer.onReceivedFrame to CanLink's receiveListener during CanLink constructor is enforced). --- examples/example_tcp_message_interface.py | 6 +- examples/examples_gui.py | 6 +- openlcb/canbus/canlink.py | 11 ++- openlcb/canbus/canphysicallayer.py | 60 ++++++++++----- openlcb/canbus/canphysicallayergridconnect.py | 17 ++-- openlcb/canbus/canphysicallayersimulation.py | 6 +- openlcb/dispatcher.py | 77 +++++++++++-------- openlcb/frameencoder.py | 10 +++ openlcb/linklayer.py | 52 +++++++++++-- openlcb/physicallayer.py | 56 +++++++------- ...sicallayer.py => realtimephysicallayer.py} | 15 +++- openlcb/scanner.py | 2 +- openlcb/tcplink/tcplink.py | 6 +- python-openlcb.code-workspace | 1 + tests/test_canlink.py | 63 +++++++-------- tests/test_canphysicallayergridconnect.py | 56 +++++++++----- tests/test_tcplink.py | 4 +- 17 files changed, 283 insertions(+), 165 deletions(-) create mode 100644 openlcb/frameencoder.py rename openlcb/{rawphysicallayer.py => realtimephysicallayer.py} (63%) diff --git a/examples/example_tcp_message_interface.py b/examples/example_tcp_message_interface.py index 56efc2f..e424ea0 100644 --- a/examples/example_tcp_message_interface.py +++ b/examples/example_tcp_message_interface.py @@ -14,7 +14,7 @@ from logging import getLogger # region same code as other examples from examples_settings import Settings -from openlcb.rawphysicallayer import RealtimeRawPhysicalLayer # do 1st to fix path if no pip install +from openlcb.realtimephysicallayer import RealtimePhysicalLayer # do 1st to fix path if no pip install settings = Settings() if __name__ == "__main__": @@ -56,14 +56,14 @@ # assert isinstance(data, (bytes, bytearray)) # print(" SR: {}".format(data)) # sock.send(data) -# ^ Moved to RealtimeRawPhysicalLayer sendFrameAfter override +# ^ Moved to RealtimePhysicalLayer sendFrameAfter override def printMessage(msg): print("RM: {} from {}".format(msg, msg.source)) -physicalLayer = RealtimeRawPhysicalLayer(sock) +physicalLayer = RealtimePhysicalLayer(sock) # ^ this was not in the example before # (just gave sendToSocket to TcpLink) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index 777ba83..b098e8e 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -28,7 +28,7 @@ file=sys.stderr) raise from tkinter import ttk -from collections import OrderedDict +from collections import OrderedDict, deque from examples_settings import Settings # ^ adds parent of module to sys.path, so openlcb imports *after* this @@ -131,7 +131,7 @@ def __init__(self, parent): self.zeroconf = None self.listener = None self.browser = None - self.errors = [] + self.errors = deque() self.root = parent self._connect_thread = None try: @@ -170,7 +170,7 @@ def on_form_loaded(self): def show_next_error(self): if not self.errors: return 0 - error = self.errors.pop(0) + error = self.errors.popleft() if not error: return 0 self.set_status(error) diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index aa89906..46f09b0 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -82,7 +82,8 @@ class CanLink(LinkLayer): # MIN_STATE_VALUE & MAX_STATE_VALUE are set statically below the # State class declaration: - ALIAS_RESPONSE_DELAY = .2 # See docstring. + STANDARD_ALIAS_RESPONSE_DELAY = .2 + ALIAS_RESPONSE_DELAY = 20 # See docstring. class State(Enum): """Used as a linux-like "runlevel" @@ -104,7 +105,7 @@ class State(Enum): determined to be success, this state triggers _enqueueReserveID. """ - Initial = LinkLayer.State.Undefined.value # special case of .Inhibited + Initial = 1 # special case of .Inhibited # where init hasn't started. Inhibited = 2 EnqueueAliasAllocationRequest = 3 @@ -126,6 +127,9 @@ class State(Enum): Permitted = 20 # formerly 3. queued via frame # (formerly set at end of _notifyReservation code) + InitialState = State.Initial + DisconnectedState = State.Inhibited + MIN_STATE_VALUE = min(entry.value for entry in State) MAX_STATE_VALUE = max(entry.value for entry in State) @@ -230,7 +234,8 @@ def isDuplicateAlias(self, alias): def _onStateChanged(self, oldState, newState): # return super()._onStateChanged(oldState, newState) - assert isinstance(newState, CanLink.State) + assert isinstance(newState, CanLink.State), \ + "expected a CanLink.State, got {}".format(emit_cast(newState)) if newState == CanLink.State.EnqueueAliasAllocationRequest: self._enqueueCIDSequence() # - sets state to BusyLocalCIDSequence diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index 2044fa1..06ccd81 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -4,8 +4,8 @@ This is a class because it represents a single physical connection to a layout and is subclassed. ''' -import sys from logging import getLogger +import warnings from openlcb.canbus.canframe import CanFrame from openlcb.canbus.controlframe import ControlFrame @@ -24,37 +24,61 @@ def __init__(self,): PhysicalLayer.__init__(self) self.listeners = [] + def onReceivedFrame(self): + raise NotImplementedError( + "Your LinkLayer/subclass must set this manually (monkeypatch)" + " to the CanLink instance's receiveListener method.") + def sendFrameAfter(self, frame: CanFrame): - """See sendFrameAfter documentation in PhysicalLayer. - This implementation behaves the same except requires - a specific type (CanFrame). + """Enqueue: *IMPORTANT* Main/other thread may have + called this. Any other thread sending other than the _listen + thread is bad, since overlapping calls to socket cause undefined + behavior, so this just adds to a deque (double ended queue, used + as FIFO). + - CanPhysicalLayerGridConnect formerly had canSendCallback + but now it uses its own frame deque, and the socket code pops + and sends the frames. + (formerly canSendCallback was set to a sendToPort function + which was formerly a direct call to a port which was not + thread-safe and could be called from anywhere in the + openlcb stack) + - Add a generalized LocalEvent queue avoid deep callstack? + - See issue #62 comment about a local event queue. + For now, CanFrame is used (improved since issue #62 + was solved by adding more states to CanLink so it + can have incremental states instead of requiring two-way + communication [race condition] during a single + blocking call to defineAndReserveAlias) """ - # formerly sendCan Frame, but now behavior is defined by superclass - # (regardless of frame type, it is just added to self._sends) assert isinstance(frame, CanFrame) + frame.encoder = self PhysicalLayer.sendFrameAfter(self, frame) - def pollFrame(self) -> CanFrame: # overloaded for type hinting. - """Check if there is another frame queued and get it. - Returns: - CanFrame: next frame in FIFO buffer (_sends). - """ - return PhysicalLayer.pollFrame(self) + def pollFrame(self) -> CanFrame: + frame = super().pollFrame() + if frame is None: + return None + assert isinstance(frame, CanFrame) + return frame def encode(self, frame) -> str: '''abstract interface (encode frame to string)''' raise NotImplementedError("Each subclass must implement this.") def registerFrameReceivedListener(self, listener): + assert listener is not None + warnings.warn( + "You don't really need to listen to packets." + " Use pollFrame instead, which will collect and decode" + " packets into frames (this layer communicates to upper layers" + " using self.onReceivedFrame set in LinkLayer/subclass" + " constructor).") self.listeners.append(listener) def fireListeners(self, frame): - if not self.listeners: - logger.warning( - "No listeners for frame received." - " CanLink (see LinkLayer superclass constructor)" - " should at least register its receiveFrame method" - " with a physical layer implementation.") + """At least the LinkLayer (CanLink in this case) + should register one listener.""" + for listener in self.listeners: listener(frame) diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index 69c3b3b..06ca519 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -12,19 +12,19 @@ from collections import deque from typing import Union -from openlcb.canbus.canlink import CanLink from openlcb.canbus.canphysicallayer import CanPhysicalLayer from openlcb.canbus.canframe import CanFrame +from openlcb.frameencoder import FrameEncoder GC_START_BYTE = 0x3a # : GC_END_BYTE = 0x3b # ; -class CanPhysicalLayerGridConnect(CanPhysicalLayer): +class CanPhysicalLayerGridConnect(CanPhysicalLayer, FrameEncoder): """CAN physical layer subclass for GridConnect This acts as frame.encoder for canLink, and manages the packet - _sends queue (deque is used for speed; defined & managed in base + _send_frames queue (deque is used for speed; defined & managed in base class: PhysicalLayer) Args: @@ -39,7 +39,7 @@ def __init__(self): # ^ A CanLink requires a physical layer to operate, # so CanLink now requires a PhysicalLayer instance # such as this in its constructor. - CanPhysicalLayer.__init__(self) + CanPhysicalLayer.__init__(self) # creates self._send_frames # region moved to CanLink constructor # from canLink.linkPhysicalLayer(self) # self.setCallBack(callback): @@ -53,10 +53,6 @@ def __init__(self): # assert callable(callback) # self.canSendCallback = callback - def sendFrameAfter(self, frame: CanFrame) -> None: - frame.encoder = self - self._sends.appendleft(frame) # self.canSendCallback(frame) - def encodeFrameAsString(self, frame) -> str: '''Encode frame to string.''' output = ":X{:08X}N".format(frame.header) # at least 8 chars, hex @@ -65,6 +61,11 @@ def encodeFrameAsString(self, frame) -> str: output += ";\n" return output + def encodeFrameAsData(self, frame) -> Union[bytearray, bytes]: + # TODO: Consider doing this manually (in Python 3, + # bytes/bytearray has no attribute 'format') + return self.encodeFrameAsString(frame).encode("utf-8") + def handleDataString(self, string: str): '''Provide string from the outside link to be parsed diff --git a/openlcb/canbus/canphysicallayersimulation.py b/openlcb/canbus/canphysicallayersimulation.py index 4b38766..3c0d82f 100644 --- a/openlcb/canbus/canphysicallayersimulation.py +++ b/openlcb/canbus/canphysicallayersimulation.py @@ -8,8 +8,8 @@ class CanPhysicalLayerSimulation(CanPhysicalLayer): def __init__(self): - self.receivedFrames = [] + self.receivedPackets = [] CanPhysicalLayer.__init__(self) - def sendFrameAfter(self, frame): - self.receivedFrames.append(frame) + def handlePacket(self, frame): + self.receivedPackets.append(frame) diff --git a/openlcb/dispatcher.py b/openlcb/dispatcher.py index 6e4e6b0..0fcde3d 100644 --- a/openlcb/dispatcher.py +++ b/openlcb/dispatcher.py @@ -171,7 +171,7 @@ def start_listening(self, connected_port, localNodeID): # self._physicalLayer.registerFrameReceivedListener( # self._printFrame # ) - # ^ Commented since canlink already adds CanLink's default + # ^ Commented since CanLink constructor now registers its default # receiveListener to CanLinkPhysicalLayer & that's all we need # for this application. @@ -203,18 +203,21 @@ def start_listening(self, connected_port, localNodeID): self.listen() # Must listen for alias reservation responses # (sendAliasConnectionSequence will occur for another 200ms # once, then another 200ms on each alias collision if any) + # - must also keep doing frame = pollFrame() and sending + # if not None. self._callback_status("physicalLayerUp...") self._physicalLayer.physicalLayerUp() - while canLink.pollState() != CanLink.State.Permitted: + self._callback_status("Waiting for alias reservation...") + while self._canLink.pollState() != CanLink.State.Permitted: precise_sleep(.02) - # ^ triggers fireListeners which calls CanLink's default # receiveListener by default since added on CanPhysicalLayer # arg of linkPhysicalLayer. # - Must happen *after* listen thread starts, since # generates ControlFrame.LinkUp and calls fireListeners # which calls sendAliasConnectionSequence on this thread! + self._callback_status("Alias reservation complete.") def listen(self): self._listen_thread = threading.Thread( @@ -244,19 +247,49 @@ def _listen(self): # the alias from each node (collision or not) # to has to get the expected replies to the alias # reservation sequence below. - precise_sleep(.05) # Wait for physicalLayerUp to complete + precise_sleep(.05) # Wait for physicalLayerUp non-network Message while True: # Wait 200 ms for all nodes to announce (and for alias # reservation to complete), as per section 6.2.1 of CAN # Frame Transfer Standard (sendMessage requires ) logger.debug("[_listen] _receive...") try: - sends = self._physicalLayer.popFrames() - while sends: + # Receive mode (switches to write mode on BlockingIOError + # which is expected and used on purpose) + # print("Waiting for _receive") + received = self._receive() # requires setblocking(False) + print("[_listen] received {} byte(s)".format(len(received)), + file=sys.stderr) + # print(" RR: {}".format(received.strip())) + # pass to link processor + self._physicalLayer.handleData(received) + # ^ will trigger self._printFrame if that was added + # via registerFrameReceivedListener during connect. + precise_sleep(.01) # let processor sleep before read + if time.perf_counter() - self._connecting_t > .2: + if self._canLink._state != CanLink.State.Permitted: + if ((self._message_t is None) + or (time.perf_counter() - self._message_t + > 1)): + logger.warning( + "CanLink is not ready yet." + " There must have been a collision" + "--processCollision increments node alias" + " in this case and tries again.") + # else _on_link_state_change will be called + # TODO: move *all* send calls to this loop. + except BlockingIOError: + # Nothing to receive right now, so perform all sends + # This *must* occur (require socket.setblocking(False)) + # sends = self._physicalLayer.popFrames() + # while sends: + while True: # *Always* do send in the receive thread to # avoid overlapping calls to socket # (causes undefined behavior)! - frame = sends.pop() + frame = self._physicalLayer.pollFrame() + if frame is None: + break # allow receive to run! if isinstance(frame, CanFrame): if self._canLink.isDuplicateAlias(frame.alias): logger.warning( @@ -266,43 +299,23 @@ def _listen(self): .format(frame.alias)) continue logger.debug("[_listen] _sendString...") - self._port.sendString(frame.encodeAsString()) + packet = frame.encodeAsString() + assert isinstance(packet, str) + print("Sending {}".format(packet)) + self._port.sendString(packet) if frame.afterSendState: self._canLink.setState(frame.afterSendState) else: raise NotImplementedError( "Event type {} is not handled." .format(type(frame).__name__)) - received = self._receive() # requires setblocking(False) # so that it doesn't block (or occur during) recv # (overlapping calls would cause undefined behavior)! - # TODO: move *all* send calls to this loop. - except BlockingIOError: # delay = random.uniform(.005,.02) # ^ random delay may help if send is on another thread # (but avoid that for stability and speed) precise_sleep(.01) - continue - print("[_listen] received {} byte(s)".format(len(received)), - file=sys.stderr) - # print(" RR: {}".format(received.strip())) - # pass to link processor - self._physicalLayer.handleData(received) - # ^ will trigger self._printFrame if that was added - # via registerFrameReceivedListener during connect. - precise_sleep(.01) # let processor sleep briefly before read - if time.perf_counter() - self._connecting_t > .2: - if self._canLink._state != CanLink.State.Permitted: - if ((self._message_t is None) - or (time.perf_counter() - self._message_t - > 1)): - logger.warning( - "CanLink is not ready yet." - " There must have been a collision" - "--processCollision increments node alias" - " in this case and tries again.") - # else _on_link_state_change will be called - + # raise RuntimeError("We should never get here") except RuntimeError as ex: caught_ex = ex # If _port is a TcpSocket: diff --git a/openlcb/frameencoder.py b/openlcb/frameencoder.py new file mode 100644 index 0000000..89660b4 --- /dev/null +++ b/openlcb/frameencoder.py @@ -0,0 +1,10 @@ +from typing import Union + + +class FrameEncoder: + def encodeFrameAsString(self, frame) -> str: + '''Encode frame to string.''' + raise NotImplementedError("Implement this in each subclass.") + + def encodeFrameAsData(self, frame) -> Union[bytearray, bytes]: + raise NotImplementedError("Implement this in each subclass.") diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index 7949dc4..f635560 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -18,7 +18,9 @@ from enum import Enum from logging import getLogger +import warnings +from openlcb import emit_cast from openlcb.physicallayer import PhysicalLayer logger = getLogger(__name__) @@ -40,24 +42,42 @@ class LinkLayer: """ class State(Enum): - Undefined = 1 # subclass constructor did not run (implement states) + Undefined = 0 # subclass constructor did not run (implement states) + + DisconnectedState = State.Undefined # change in subclass! Only for tests! + # (enforced using type(self).__name__ != "LinkLayer" checks in methods) def __init__(self, physicalLayer: PhysicalLayer, localNodeID): assert isinstance(physicalLayer, PhysicalLayer) # allows any subclass # subclass should check type of localNodeID technically self.localNodeID = localNodeID self.listeners = [] - self._state = LinkLayer.State.Undefined + self._state = None # LinkLayer.State.Undefined # region moved from CanLink linkPhysicalLayer self.physicalLayer = physicalLayer # formerly self.link = cpl # if physicalLayer is not None: - physicalLayer.registerFrameReceivedListener(self.receiveListener) + # listener = self.receiveListener # try to prevent + # "new bound method" Python behavior in subclass from making "is" + # operator not work as expected in registerFrameReceivedListener. + physicalLayer.onReceivedFrame = self.receiveListener + # # ^ enforce queue paradigm (See use in PhysicalLayer subclass) + # physicalLayer.registerFrameReceivedListener(listener) + # ^ Doesn't work with "is" operator still! So just use + # physicalLayer.onReceivedFrame in fireListeners in PhysicalLayer. # else: # print("Using {} without" # " registerFrameReceivedListener(self.receiveListener)" # " on physicalLayer, since no physicalLayer specified." # .format()) # endregion moved from CanLink linkPhysicalLayer + if type(self).__name__ != "LinkLayer": + # ^ Use name, since isinstance returns True for any subclass. + if isinstance(type(self).DisconnectedState, LinkLayer.State): + raise NotImplementedError( + " LinkLayer.State and LinkLayer.DisconnectedState" + " are only for testing. Redefine them in each subclass" + " (got LinkLayer.State({}) for {}.DisconnectedState)" + .format(emit_cast(type(self).DisconnectedState), type(self).__name__)) def receiveListener(self, frame): logger.warning( @@ -68,20 +88,38 @@ def onDisconnect(self): """Run this whenever the socket connection is lost and override _onStateChanged to handle the change. * If you override this, you *must* call - `LinkLayer.onDisconnect(self)` to trigger _onStateChanged - if the implementation utilizes getState. + `LinkLayer.onDisconnect(self)` to trigger _onStateChanged + if the implementation utilizes getState. + * Override this in each subclass or state won't match! """ - self._setState(LinkLayer.State.Undefined) + if type(self).__name__ != "LinkLayer": + # ^ Use name, since isinstance returns True for any subclass. + if isinstance(type(self).DisconnectedState, LinkLayer.State): + raise NotImplementedError( + " LinkLayer.State and LinkLayer.DisconnectedState" + " are only for testing. Redefine them in each subclass.") + + self.setState(type(self).DisconnectedState) def getState(self): return self._state def setState(self, state): + """Reusable LinkLayer setState + (enforce type of state in _onStateChanged implementation in subclass) + """ oldState = self._state newState = state # keep a copy for _onStateChanged, for thread safety # (ensure value doesn't change between two lines below) self._state = newState - self._onStateChanged(oldState, newState) + if type(self).__name__ != "LinkLayer": + # ^ Use name, since isinstance returns True for any subclass. + if isinstance(state, LinkLayer.State): + raise NotImplementedError( + " LinkLayer.State and LinkLayer.DisconnectedState" + " are only for testing. Redefine them in each subclass.") + + self._onStateChanged(oldState, newState) # enforce type in subclass def _onStateChanged(self, oldState, newState): raise NotImplementedError( diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index e5eb67f..c4e1f05 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -7,10 +7,10 @@ NotImplementedError, call super() normally, as such methods are used similarly to how a template or generic class would be used in a strictly OOP language. However, re-implementation is typically not necessary -since Python allows any type to be used for the elements of _sends. +since Python allows any type to be used for the elements of _send_frames. We implement logic here not only because it is convenient but also -because _sends (and the subclass being a state machine with states +because _send_frames (and the subclass being a state machine with states specific to the physical layer type) is a the paradigm used by this openlcb stack (Python module) as a whole (connection and flow determined by application's port code, state determined by the openlcb stack). This @@ -23,8 +23,7 @@ from collections import deque from logging import getLogger - -from openlcb.canbus.canframe import CanFrame +from typing import Union logger = getLogger(__name__) @@ -55,44 +54,41 @@ class PhysicalLayer: """ def __init__(self): - self._sends = deque() + self._send_frames = deque() - def sendFrameAfter(self, frame): - """Enqueue: *IMPORTANT* Main/other thread may have - called this. Any other thread sending other than the _listen - thread is bad, since overlapping calls to socket cause undefined - behavior, so this just adds to a deque (double ended queue, used - as FIFO). - - CanPhysicalLayerGridConnect formerly had canSendCallback - but now it uses its own frame deque, and the socket code pops - and sends the frames. - (formerly canSendCallback was set to a sendToPort function - which was formerly a direct call to a port which was not - thread-safe and could be called from anywhere in the - openlcb stack) - - Add a generalized LocalEvent queue avoid deep callstack? - - See issue #62 comment about a local event queue. - For now, CanFrame is used (improved since issue #62 - was solved by adding more states to CanLink so it - can have incremental states instead of requiring two-way - communication [race condition] during a single - blocking call to defineAndReserveAlias) - """ - self._sends.appendleft(frame) + # def sendDataAfter(self, data): + # assert isinstance(data, (bytes, bytearray)) + # self._send_frames.append(data) def pollFrame(self): """Check if there is another frame queued and get it. + Subclass should call PhysicalLayer.pollFrame (or + super().pollFrame) then enforce type, only if not None, before + returning the return of this superclass method. + Returns: - Any: next frame in FIFO buffer (_sends). In a + Any: next frame in FIFO buffer (_send_frames). In a CanPhysicalLayer or subclass of that, type is CanFrame. In a raw implementation it is either bytes or bytearray. """ try: - return self._sends.pop() - except IndexError: # "pop from an empty deque" + data = self._send_frames.popleft() + return data + except IndexError: # "popleft from an empty deque" pass return None + def sendFrameAfter(self, frame): + """In subclass, enforce type and set frame.encoder to self + (which should inherit from both PhysicalLayer and FrameEncoder) + before calling this. + + This only adds to a queue, so use pollFrame() in your socket + code so application manages flow, physicalLayer manages data, + and link manages state. + """ + self._send_frames.append(frame) # append: queue-like if using popleft + def registerFrameReceivedListener(self, listener): """abstract method""" # raise NotImplementedError("Each subclass must implement this.") diff --git a/openlcb/rawphysicallayer.py b/openlcb/realtimephysicallayer.py similarity index 63% rename from openlcb/rawphysicallayer.py rename to openlcb/realtimephysicallayer.py index 2d8616d..557620f 100644 --- a/openlcb/rawphysicallayer.py +++ b/openlcb/realtimephysicallayer.py @@ -1,26 +1,37 @@ from logging import getLogger +from typing import Union from openlcb.physicallayer import PhysicalLayer logger = getLogger(__name__) -class RealtimeRawPhysicalLayer(PhysicalLayer): +class RealtimePhysicalLayer(PhysicalLayer): def __init__(self, socket): # sock to distinguish from socket module or socket.socket class! self.sock = socket - def sendFrameAfter(self, data): + def sendDataAfter(self, data: Union[bytearray, bytes]): # if isinstance(data, list): # raise TypeError( # "Got {}({}) but expected str" # .format(type(data).__name__, data) # ) + assert isinstance(data, (bytes, bytearray)) print(" SR: {}".format(data)) self.sock.send(data) + def sendFrameAfter(self, frame): + # if isinstance(data, list): + # raise TypeError( + # "Got {}({}) but expected str" + # .format(type(data).__name__, data) + # ) + print(" SR: {}".format(frame.encode())) + self.sock.send(frame.encode()) + def registerFrameReceivedListener(self, listener): """_summary_ diff --git a/openlcb/scanner.py b/openlcb/scanner.py index 72e8a45..86159a8 100644 --- a/openlcb/scanner.py +++ b/openlcb/scanner.py @@ -44,7 +44,7 @@ def nextByte(self): if not isinstance(self._buffer, (bytes, bytearray)): raise TypeError("Buffer is {} (nextByte is for bytes/bytearray)" .format(type(self._buffer).__name__)) - return self._buffer.pop(0) + return self._buffer.popleft(0) def hasNextByte(self): return True if self._buffer else False diff --git a/openlcb/tcplink/tcplink.py b/openlcb/tcplink/tcplink.py index 6eb1226..45a62eb 100644 --- a/openlcb/tcplink/tcplink.py +++ b/openlcb/tcplink/tcplink.py @@ -19,6 +19,8 @@ import logging import time +from openlcb.physicallayer import PhysicalLayer + class TcpLink(LinkLayer): """A TCP link layer. @@ -32,7 +34,7 @@ class TcpLink(LinkLayer): software-defined node connecting to the LCC network via TCP. """ - def __init__(self, physicalLayer, localNodeID): + def __init__(self, physicalLayer: PhysicalLayer, localNodeID): LinkLayer.__init__(self, physicalLayer, localNodeID) # See class docstring for argument(s) and attributes. self.physicalLayer = physicalLayer @@ -225,6 +227,6 @@ def sendMessage(self, message): outputBytes.extend(message.data) - self.physicalLayer.sendFrameAfter(outputBytes) + self.physicalLayer.sendDataAfter(outputBytes) # ^ The physical layer should be one with "Raw" in the name # since takes bytes. See example_tcp_message_interface. diff --git a/python-openlcb.code-workspace b/python-openlcb.code-workspace index 5429f43..53a9d2f 100644 --- a/python-openlcb.code-workspace +++ b/python-openlcb.code-workspace @@ -67,6 +67,7 @@ "physicallayer", "platformextras", "Poikilos", + "popleft", "portinterface", "pyproject", "pyserial", diff --git a/tests/test_canlink.py b/tests/test_canlink.py index b53f45a..300b1e3 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -17,11 +17,12 @@ class PhyMockLayer(CanPhysicalLayer): def __init__(self): - self.receivedFrames = [] + self.receivedPackets = [] CanPhysicalLayer.__init__(self) - def sendFrameAfter(self, frame): - self.receivedFrames.append(frame) + def sendDataAfter(self, data): + assert isinstance(data, (bytes, bytearray)) + self.receivedPackets.append(data) class MessageMockLayer: @@ -124,7 +125,7 @@ def testEIR2NoData(self): canLink._state = CanLink.State.Permitted canPhysicalLayer.fireListeners(CanFrame(ControlFrame.EIR2.value, 0)) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) canLink.onDisconnect() # MARK: - Test AME (Local Node) @@ -135,9 +136,9 @@ def testAMENoData(self): canLink._state = CanLink.State.Permitted canPhysicalLayer.fireListeners(CanFrame(ControlFrame.AME.value, 0)) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) + self.assertEqual(len(canPhysicalLayer.receivedPackets), 1) self.assertEqual( - canPhysicalLayer.receivedFrames[0], + canPhysicalLayer.receivedPackets[0], CanFrame(ControlFrame.AMD.value, ourAlias, canLink.localNodeID.toArray()) ) @@ -149,7 +150,7 @@ def testAMEnoDataInhibited(self): canLink._state = CanLink.State.Inhibited canPhysicalLayer.fireListeners(CanFrame(ControlFrame.AME.value, 0)) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) canLink.onDisconnect() def testAMEMatchEvent(self): @@ -161,8 +162,8 @@ def testAMEMatchEvent(self): frame = CanFrame(ControlFrame.AME.value, 0) frame.data = bytearray([5, 1, 1, 1, 3, 1]) canPhysicalLayer.fireListeners(frame) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) - self.assertEqual(canPhysicalLayer.receivedFrames[0], + self.assertEqual(len(canPhysicalLayer.receivedPackets), 1) + self.assertEqual(canPhysicalLayer.receivedPackets[0], CanFrame(ControlFrame.AMD.value, ourAlias, canLink.localNodeID.toArray())) canLink.onDisconnect() @@ -175,7 +176,7 @@ def testAMENotMatchEvent(self): frame = CanFrame(ControlFrame.AME.value, 0) frame.data = bytearray([0, 0, 0, 0, 0, 0]) canPhysicalLayer.fireListeners(frame) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) canLink.onDisconnect() # MARK: - Test Alias Collisions (Local Node) @@ -187,8 +188,8 @@ def testCIDreceivedMatch(self): canPhysicalLayer.fireListeners(CanFrame(7, canLink.localNodeID, ourAlias)) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) - self.assertEqual(canPhysicalLayer.receivedFrames[0], + self.assertEqual(len(canPhysicalLayer.receivedPackets), 1) + self.assertEqual(canPhysicalLayer.receivedPackets[0], CanFrame(ControlFrame.RID.value, ourAlias)) canLink.onDisconnect() @@ -200,12 +201,12 @@ def testRIDreceivedMatch(self): canPhysicalLayer.fireListeners(CanFrame(ControlFrame.RID.value, ourAlias)) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 8) + self.assertEqual(len(canPhysicalLayer.receivedPackets), 8) # ^ includes recovery of new alias 4 CID, RID, AMR, AME - self.assertEqual(canPhysicalLayer.receivedFrames[0], + self.assertEqual(canPhysicalLayer.receivedPackets[0], CanFrame(ControlFrame.AMR.value, ourAlias, bytearray([5, 1, 1, 1, 3, 1]))) - self.assertEqual(canPhysicalLayer.receivedFrames[6], + self.assertEqual(canPhysicalLayer.receivedPackets[6], CanFrame(ControlFrame.AMD.value, 0x539, bytearray([5, 1, 1, 1, 3, 1]))) # new alias self.assertEqual(canLink._state, CanLink.State.Permitted) @@ -277,7 +278,7 @@ def testSimpleGlobalData(self): canPhysicalLayer.fireListeners(CanFrame(0x19490, 0x247)) # ^ from previously seen alias - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) # ^ nothing back down to CAN self.assertEqual(len(messageLayer.receivedMessages), 1) # ^ one message forwarded @@ -304,7 +305,7 @@ def testVerifiedNodeInDestAliasMap(self): bytearray([8, 7, 6, 5, 4, 3]))) # ^ VerifiedNodeID from unique alias - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) # ^ nothing back down to CAN self.assertEqual(len(messageLayer.receivedMessages), 1) # ^ one message forwarded @@ -332,7 +333,7 @@ def testNoDestInAliasMap(self): bytearray([8, 7, 6, 5, 4, 3]))) # ^ Identify Events Addressed from unique alias - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) # ^ nothing back down to CAN self.assertEqual(len(messageLayer.receivedMessages), 1) # ^ one message forwarded @@ -563,8 +564,8 @@ def testZeroLengthDatagram(self): canLink.sendMessage(message) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) - self.assertEqual(str(canPhysicalLayer.receivedFrames[0]), + self.assertEqual(len(canPhysicalLayer.receivedPackets), 1) + self.assertEqual(str(canPhysicalLayer.receivedPackets[0]), "CanFrame header: 0x1A000000 []") canLink.onDisconnect() @@ -578,9 +579,9 @@ def testOneFrameDatagram(self): canLink.sendMessage(message) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) + self.assertEqual(len(canPhysicalLayer.receivedPackets), 1) self.assertEqual( - str(canPhysicalLayer.receivedFrames[0]), + str(canPhysicalLayer.receivedPackets[0]), "CanFrame header: 0x1A000000 [1, 2, 3, 4, 5, 6, 7, 8]" ) canLink.onDisconnect() @@ -596,13 +597,13 @@ def testTwoFrameDatagram(self): canLink.sendMessage(message) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 2) + self.assertEqual(len(canPhysicalLayer.receivedPackets), 2) self.assertEqual( - str(canPhysicalLayer.receivedFrames[0]), + str(canPhysicalLayer.receivedPackets[0]), "CanFrame header: 0x1B000000 [1, 2, 3, 4, 5, 6, 7, 8]" ) self.assertEqual( - str(canPhysicalLayer.receivedFrames[1]), + str(canPhysicalLayer.receivedPackets[1]), "CanFrame header: 0x1D000000 [9, 10, 11, 12, 13, 14, 15, 16]" ) canLink.onDisconnect() @@ -620,16 +621,16 @@ def testThreeFrameDatagram(self): canLink.sendMessage(message) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 3) + self.assertEqual(len(canPhysicalLayer.receivedPackets), 3) self.assertEqual( - str(canPhysicalLayer.receivedFrames[0]), + str(canPhysicalLayer.receivedPackets[0]), "CanFrame header: 0x1B000000 [1, 2, 3, 4, 5, 6, 7, 8]" ) self.assertEqual( - str(canPhysicalLayer.receivedFrames[1]), + str(canPhysicalLayer.receivedPackets[1]), "CanFrame header: 0x1C000000 [9, 10, 11, 12, 13, 14, 15, 16]" ) - self.assertEqual(str(canPhysicalLayer.receivedFrames[2]), + self.assertEqual(str(canPhysicalLayer.receivedPackets[2]), "CanFrame header: 0x1D000000 [17, 18, 19]") canLink.onDisconnect() @@ -645,7 +646,7 @@ def testAmdAmrSequence(self): self.assertEqual(len(canLink.aliasToNodeID), 1) self.assertEqual(len(canLink.nodeIdToAlias), 1) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) # ^ nothing back down to CAN canPhysicalLayer.fireListeners(CanFrame(0x0703, ourAlias+1)) @@ -654,7 +655,7 @@ def testAmdAmrSequence(self): self.assertEqual(len(canLink.aliasToNodeID), 0) self.assertEqual(len(canLink.nodeIdToAlias), 0) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) # ^ nothing back down to CAN canLink.onDisconnect() diff --git a/tests/test_canphysicallayergridconnect.py b/tests/test_canphysicallayergridconnect.py index 1da7e72..b816198 100644 --- a/tests/test_canphysicallayergridconnect.py +++ b/tests/test_canphysicallayergridconnect.py @@ -7,51 +7,67 @@ from openlcb.canbus.canframe import CanFrame from openlcb.nodeid import NodeID +class PhysicalLayerMock(CanPhysicalLayerGridConnect): + # PHY side + # def frameSocketSendDummy(self, frame): + def __init__(self): + CanPhysicalLayerGridConnect.__init__(self) + self.registerFrameQueuedListener(self.captureString) + + def captureString(self, packet): + # formerly was in CanPhysicalLayerGridConnectTest + # but there isn't a send callback anymore + # (to avoid port contention in issue #62) + # just a physical layer. + self. capturedFrame = packet + self. capturedFrame.encoder = self.gc + + if frame.afterSendState: + pass + # NOTE: skipping canLink.setState since testing only + # physical layer not link layer. + # canLink.setState(frame.afterSendState) class CanPhysicalLayerGridConnectTest(unittest.TestCase): def __init__(self, *args, **kwargs): super(CanPhysicalLayerGridConnectTest, self).__init__(*args, **kwargs) # self.capturedString = "" - self.capturedFrame = None + self.physicalLayer = PhysicalLayerMock() + self.physicalLayer.capturedFrame = None self.receivedFrames = [] # PHY side # def captureString(self, string): # self.capturedString = string - # PHY side - def frameSocketSendDummy(self, frame): - # formerly captureString(self, string) - self.capturedFrame = frame - self.capturedFrame.encoder = self.gc - - if frame.afterSendState: - pass - # NOTE: skipping canLink.setState since testing only - # physical layer not link layer. - # canLink.setState(frame.afterSendState) # Link Layer side def receiveListener(self, frame): self.receivedFrames += [frame] def testCID4Sent(self): - self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) + self.gc = CanPhysicalLayerGridConnect() + frame = CanFrame(4, NodeID(0x010203040506), 0xABC) + # self.linklayer.sendFrameAfter(frame) + # ^ It will use physical layer to encode and enqueue it + # (or send if using Realtime subclass of PhysicalLayer) + # but we are testing physical layer, so: + self.gc.sendFrameAfter(frame) - self.gc.sendFrameAfter(CanFrame(4, NodeID(0x010203040506), 0xABC)) # self.assertEqual(self.capturedString, ":X14506ABCN;\n") - self.assertEqual(self.capturedFrame.encodeAsString(), ":X14506ABCN;\n") + self.assertEqual(self.physicalLayer.capturedFrame.encodeAsString(), + ":X14506ABCN;\n") def testVerifyNodeSent(self): self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) - + frame = CanFrame(0x19170, 0x365, + bytearray([0x02, 0x01, 0x12, 0xFE, 0x05, 0x6C])) self.gc.sendFrameAfter( - CanFrame(0x19170, 0x365, bytearray([ - 0x02, 0x01, 0x12, 0xFE, - 0x05, 0x6C]))) + self.gc.encode(frame) + ) # self.assertEqual(self.capturedString, ":X19170365N020112FE056C;\n") - self.assertEqual(self.capturedFrame.encodeAsString(), + self.assertEqual(self.physicalLayer.capturedFrame, ":X19170365N020112FE056C;\n") def testOneFrameReceivedExactlyHeaderOnly(self): diff --git a/tests/test_tcplink.py b/tests/test_tcplink.py index 167371a..7892afb 100644 --- a/tests/test_tcplink.py +++ b/tests/test_tcplink.py @@ -1,6 +1,6 @@ import unittest -from openlcb.rawphysicallayer import RealtimeRawPhysicalLayer +from openlcb.realtimephysicallayer import RealtimePhysicalLayer from openlcb.tcplink.tcplink import TcpLink from openlcb.message import Message @@ -31,7 +31,7 @@ def testLinkUpSequence(self): messageLayer = MessageMockLayer() tcpLayer = TcpMockLayer() - linkLayer = TcpLink(RealtimeRawPhysicalLayer, NodeID(100)) + linkLayer = TcpLink(RealtimePhysicalLayer, NodeID(100)) linkLayer.registerMessageReceivedListener(messageLayer.receiveMessage) linkLayer.linkUp() From 8da25175b243867c40b635458c00bb296c425edd Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:45:22 -0400 Subject: [PATCH 50/99] Handle the new non-blocking paradigm correctly in Dispatcher and example_remote_nodes.py. Default to standard require_remote_nodes=False mode, but set to True in said files for code evaluation purposes. --- examples/example_remote_nodes.py | 26 +++++++++++++++----------- openlcb/canbus/canlink.py | 12 +++++++----- openlcb/dispatcher.py | 3 ++- openlcb/tcplink/tcpsocket.py | 6 +++++- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index 1a7f7b0..b5ca41c 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -80,7 +80,8 @@ def printMessage(msg): readQueue.put(msg) -canLink = CanLink(physicalLayer, NodeID(settings['localNodeID'])) +canLink = CanLink(physicalLayer, NodeID(settings['localNodeID']), + require_remote_nodes=True) canLink.registerMessageReceivedListener(printMessage) # create a node and connect it update @@ -108,15 +109,18 @@ def printMessage(msg): observer = GridConnectObserver() + def pumpEvents(): received = sock.receive() - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) + if received is not None: + # may be None if socket.setblocking(False) mode + if settings['trace']: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + print(" RR: "+packet_str.strip()) + # pass to link processor + physicalLayer.handleData(received) canLink.pollState() frame = physicalLayer.pollFrame() if frame: @@ -131,9 +135,9 @@ def pumpEvents(): print(" SL : link up...") physicalLayer.physicalLayerUp() -print(" SL : link up...waiting...") -physicalLayer.physicalLayerUp() -print(" SL : link up") +print(" SL : link up...waiting for alias reservation" + " (canLink.require_remote_nodes={})..." + .format(canLink.require_remote_nodes)) while canLink.pollState() != CanLink.State.Permitted: pumpEvents() diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 46f09b0..0022f5d 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -83,7 +83,7 @@ class CanLink(LinkLayer): # MIN_STATE_VALUE & MAX_STATE_VALUE are set statically below the # State class declaration: STANDARD_ALIAS_RESPONSE_DELAY = .2 - ALIAS_RESPONSE_DELAY = 20 # See docstring. + ALIAS_RESPONSE_DELAY = 5 # See docstring. class State(Enum): """Used as a linux-like "runlevel" @@ -134,8 +134,10 @@ class State(Enum): MAX_STATE_VALUE = max(entry.value for entry in State) def __init__(self, physicalLayer: PhysicalLayer, localNodeID, - require_remote_nodes=True): + require_remote_nodes=False): # See class docstring for args + self.physicalLayer = None + LinkLayer.__init__(self, physicalLayer, localNodeID) self._previousLocalAliasSeed = None self.require_remote_nodes = require_remote_nodes self._waitingForAliasStart = None @@ -143,7 +145,6 @@ def __init__(self, physicalLayer: PhysicalLayer, localNodeID, self._localAlias = self.createAlias12(self._localAliasSeed) self.localNodeID = localNodeID self._state = CanLink.State.Initial - self.physicalLayer = None self._frameCount = 0 self._aliasCollisionCount = 0 self._errorCount = 0 @@ -154,7 +155,7 @@ def __init__(self, physicalLayer: PhysicalLayer, localNodeID, self.accumulator = {} self.duplicateAliases = [] self.nextInternallyAssignedNodeID = 1 - LinkLayer.__init__(self, physicalLayer, localNodeID) + self._state = CanLink.State.Initial # This method may never actually be necessary, as # sendMessage uses nodeIdToAlias (which has localNodeID @@ -910,7 +911,8 @@ def pollState(self): as the application's (or Dispatcher's) socket calls. """ - assert isinstance(self._state, CanLink.State) + assert isinstance(self._state, CanLink.State), \ + "Expected a CanLink.State, got {}".format(emit_cast(self._state)) if self._state in (CanLink.State.Inhibited, CanLink.State.Initial): # Do nothing. Dispatcher or application must first call # physicalLayerUp diff --git a/openlcb/dispatcher.py b/openlcb/dispatcher.py index 0fcde3d..71c759b 100644 --- a/openlcb/dispatcher.py +++ b/openlcb/dispatcher.py @@ -176,7 +176,8 @@ def start_listening(self, connected_port, localNodeID): # for this application. self._callback_status("CanLink...") - self._canLink = CanLink(self._physicalLayer, NodeID(localNodeID)) + self._canLink = CanLink(self._physicalLayer, NodeID(localNodeID), + require_remote_nodes=True) self._callback_status("CanLink..." "registerMessageReceivedListener...") self._canLink.registerMessageReceivedListener(self._handleMessage) diff --git a/openlcb/tcplink/tcpsocket.py b/openlcb/tcplink/tcpsocket.py index e1c2784..e654cc3 100644 --- a/openlcb/tcplink/tcpsocket.py +++ b/openlcb/tcplink/tcpsocket.py @@ -84,7 +84,11 @@ def _receive(self) -> bytes: list(int): one or more bytes, converted to a list of ints. ''' # public receive (do not overload) asserts no overlapping call - data = self._device.recv(128) + try: + data = self._device.recv(128) + except BlockingIOError: + # None is only expected allowed in non-blocking mode + return None # ^ For block/fail scenarios (based on options previously set) see # # as cited at From 1499f4cf7920bb2b9ef24e4e42af7e0ecde134ce Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Mon, 5 May 2025 17:46:52 -0400 Subject: [PATCH 51/99] Fix: Run the (new) required frame handler (fix new code added for issue #62). --- openlcb/canbus/canphysicallayer.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index 06ccd81..5063828 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -71,14 +71,23 @@ def registerFrameReceivedListener(self, listener): "You don't really need to listen to packets." " Use pollFrame instead, which will collect and decode" " packets into frames (this layer communicates to upper layers" - " using self.onReceivedFrame set in LinkLayer/subclass" + " using physicalLayer.onReceivedFrame set by LinkLayer/subclass" " constructor).") self.listeners.append(listener) def fireListeners(self, frame): - """At least the LinkLayer (CanLink in this case) - should register one listener.""" - + """Monitor each frame that is constructed + as the application provides handleData raw data from the port. + - LinkLayer (CanLink in this case) must set onReceivedFrame, + so registerFrameReceivedListener is now optional, and + a Message handler should usually be used instead. + """ + # (onReceivedFrame was implemented to make it clear by way of + # constructor code that the handler is required in order for + # the openlcb network stack (This Python module) to + # operate--See + # + self.onReceivedFrame(frame) for listener in self.listeners: listener(frame) From a1cfb2d68760628926581bd55772bf3ceac75593 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Mon, 5 May 2025 17:47:55 -0400 Subject: [PATCH 52/99] Add debugging to diagnose port timing/flushing issues (https://github.com/bobjacobsen/python-openlcb/issues/62#issuecomment-2843375574). --- examples/example_remote_nodes.py | 51 ++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index b5ca41c..e4e7922 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -11,6 +11,7 @@ address and port. ''' # region same code as other examples +from timeit import default_timer from examples_settings import Settings from openlcb import precise_sleep from openlcb.canbus.gridconnectobserver import GridConnectObserver # do 1st to fix path if no pip install @@ -109,6 +110,9 @@ def printMessage(msg): observer = GridConnectObserver() +assert len(physicalLayer.listeners) == 1, \ + "{} listener(s) unexpectedly".format(len(physicalLayer.listeners)) + def pumpEvents(): received = sock.receive() @@ -118,14 +122,15 @@ def pumpEvents(): observer.push(received) if observer.hasNext(): packet_str = observer.next() - print(" RR: "+packet_str.strip()) + print("+ RECEIVED Remote: "+packet_str.strip()) # pass to link processor physicalLayer.handleData(received) canLink.pollState() frame = physicalLayer.pollFrame() if frame: string = frame.encodeAsString() - if settings['trace'] : print(" SR: "+string.strip()) + if True: # if settings['trace']: + print("- SENT Remote: "+string.strip()) sock.sendString(string) if frame.afterSendState: canLink.setState(frame.afterSendState) @@ -133,20 +138,54 @@ def pumpEvents(): # bring the CAN level up -print(" SL : link up...") +print("* QUEUE Message: link up...") physicalLayer.physicalLayerUp() -print(" SL : link up...waiting for alias reservation" +print(" QUEUED Message: link up...waiting for alias reservation" " (canLink.require_remote_nodes={})..." .format(canLink.require_remote_nodes)) -while canLink.pollState() != CanLink.State.Permitted: +# These checks are for debugging. See other examples for simpler pollState loop +cidSequenceStart = default_timer() +previousState = canLink.getState() +print("[main] CanLink previousState={}".format(previousState)) +while True: + # This is a pollState loop as our custom pumpEvents function + # calls pollState. + state = canLink.getState() + if state != previousState: + print("[main] CanLink state changed from {} to {}" + .format(previousState, state)) + previousState = state + if state == CanLink.State.Permitted: + break pumpEvents() + state = canLink.getState() + if state != previousState: + print("[main] CanLink state changed from {} to {}" + .format(previousState, state)) + previousState = state + precise_sleep(.02) + if default_timer() - cidSequenceStart > 2: # measured in seconds + print("[main] Warning, no response received, assuming no remote nodes" + " continuing alias reservation" + " (ok to do so after 200ms" + " according to CAN Frame Transfer - Standard)") + break + state = canLink.getState() + +if state != previousState: + print("[main] CanLink state changed from {} to {}" + .format(previousState, state)) +elif state == CanLink.State.Initial: + raise NotImplementedError("The CanLink state is still {}".format(state)) +else: + print("[main] CanLink state is still {} before moving on." + .format(state)) def receiveLoop(): """put the read on a separate thread""" - while True: pumpEvents() From 8e6603542cf1566b75aaf9ca6e6ff4323a69e0ff Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Mon, 5 May 2025 17:53:06 -0400 Subject: [PATCH 53/99] Show nodeIdToAlias after CID sequence is sent (for diagnosing port timing/flushing issue at https://github.com/bobjacobsen/python-openlcb/issues/62#issuecomment-2843375574). --- examples/example_remote_nodes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index e4e7922..17c1792 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -183,6 +183,7 @@ def pumpEvents(): print("[main] CanLink state is still {} before moving on." .format(state)) +print("nodeIdToAlias: {}".format(canLink.nodeIdToAlias)) def receiveLoop(): """put the read on a separate thread""" From 34cf62ae6bd44f17ba755bc6b9621020b2d01ee1 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 8 May 2025 14:32:17 -0400 Subject: [PATCH 54/99] Fix example_datagram_transfer: use farNodeId & localNodeId from settings instead of hard-coded ones. Fix rest of examples to use new CanLink state machine for issue #62 (serial not fully tested). --- examples/example_cdi_access.py | 86 +++++++++++++++------ examples/example_datagram_transfer.py | 39 +++++----- examples/example_frame_interface.py | 42 +++++++--- examples/example_memory_length_query.py | 34 ++++---- examples/example_memory_transfer.py | 36 +++++---- examples/example_message_interface.py | 35 +++++---- examples/example_node_implementation.py | 29 +++---- examples/example_remote_nodes.py | 15 ++-- examples/example_string_interface.py | 17 ++-- examples/example_string_serial_interface.py | 25 +++--- examples/example_tcp_message_interface.py | 19 +++-- openlcb/canbus/canlink.py | 14 ++-- openlcb/canbus/canphysicallayer.py | 2 +- openlcb/canbus/seriallink.py | 4 + openlcb/dispatcher.py | 3 +- openlcb/linklayer.py | 6 ++ openlcb/physicallayer.py | 6 ++ openlcb/realtimephysicallayer.py | 10 +++ openlcb/tcplink/tcplink.py | 5 ++ openlcb/tcplink/tcpsocket.py | 2 + tests/test_canphysicallayergridconnect.py | 12 +-- 21 files changed, 284 insertions(+), 157 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index 5744544..e90fad5 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -11,6 +11,8 @@ address and port. ''' # region same code as other examples +import copy +from timeit import default_timer from examples_settings import Settings # do 1st to fix path if no pip install from openlcb import precise_sleep from openlcb.canbus.canframe import CanFrame @@ -57,8 +59,7 @@ # string = frame.encodeAsString() # # print(" SR: {}".format(string.strip())) # sock.sendString(string) -# if frame.afterSendState: -# canLink.setState(frame.afterSendState) +# physicalLayer.onSentFrame(frame) def printFrame(frame): @@ -104,6 +105,10 @@ def printDatagram(memo): # callbacks to get results of memory read +observer = GridConnectObserver() + +complete_data = False +read_failed = False def memoryReadSuccess(memo): """Handle a successful read @@ -117,6 +122,7 @@ def memoryReadSuccess(memo): # print("successful memory read: {}".format(memo.data)) global resultingCDI + global complete_data # is this done? if len(memo.data) == 64 and 0 not in memo.data: @@ -143,12 +149,15 @@ def memoryReadSuccess(memo): # and process that processXML(cdiString) + complete_data = True # done def memoryReadFail(memo): + global read_failed print("memory read failed: {}".format(memo.data)) + read_failed = True ####################### @@ -236,32 +245,51 @@ def processXML(content) : def pumpEvents(): try: received = sock.receive() - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - # print(" RR: "+packet_str.strip()) - # ^ commented since MyHandler shows parsed XML fields instead - # pass to link processor - physicalLayer.handleData(received) + if received is not None: + if settings['trace']: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + # print(" RR: "+packet_str.strip()) + # ^ commented since MyHandler shows parsed XML + # fields instead + # pass to link processor + physicalLayer.handleData(received) except BlockingIOError: pass canLink.pollState() - frame = physicalLayer.pollFrame() - if frame: + while True: + frame = physicalLayer.pollFrame() + if not frame: + break string = frame.encodeAsString() - print(" SR: "+string.strip()) + # print(" SENT packet: "+string.strip()) + # ^ This is too verbose for this example (each is a + # request to read a 64 byte chunks of the CDI XML) sock.sendString(string) + physicalLayer.onSentFrame(frame) # have the socket layer report up to bring the link layer up and get an alias -print(" SL : link up...") +print(" QUEUE frames : link up...") physicalLayer.physicalLayerUp() -print(" SL : link up...waiting...") +print(" QUEUED frames : link up...waiting...") while canLink.pollState() != CanLink.State.Permitted: - pumpEvents() + pumpEvents() # provides incoming data to physicalLayer & sends queued + if canLink.getState() == CanLink.State.WaitForAliases: + pumpEvents() # prevent assertion error below, proceed to send. + if canLink.pollState() == CanLink.State.Permitted: + break + assert canLink.getWaitForAliasResponseStart() is not None, \ + "openlcb didn't send the 7,6,5,4 CID frames (state={})".format(canLink.getState()) + if default_timer() - canLink.getWaitForAliasResponseStart() > CanLink.ALIAS_RESPONSE_DELAY: + # 200ms = standard wait time for responses + if not canLink.require_remote_nodes: + raise TimeoutError( + "In Standard require_remote_nodes=False mode," + " but failed to proceed to Permitted state.") precise_sleep(.02) -print(" SL : link up") +print(" SENT frames : link up") def memoryRead(): @@ -295,12 +323,26 @@ def memoryRead(): import threading # noqa E402 thread = threading.Thread(target=memoryRead) thread.start() - -observer = GridConnectObserver() - - +previous_nodes = copy.deepcopy(canLink.nodeIdToAlias) # process resulting activity -while True: +print() +print("This example will exit on failure or complete data.") +while not complete_data and not read_failed: + # In this example, requests are initiate by the + # memoryRead thread, and pumpEvents actually + # receives the data from the requested memory space (CDI in this + # case) and offset (incremental position in the file/data, + # incremented by this example's memoryReadSuccess handler). pumpEvents() + if canLink.nodeIdToAlias != previous_nodes: + print("nodeIdToAlias updated: {}".format(canLink.nodeIdToAlias)) + precise_sleep(.01) + if canLink.nodeIdToAlias != previous_nodes: + previous_nodes = copy.deepcopy(canLink.nodeIdToAlias) canLink.onDisconnect() + +if read_failed: + print("Read complete (FAILED)") +else: + print("Read complete (OK)") diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index 5295794..183f381 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -39,8 +39,8 @@ # port = 12021 # endregion replaced by settings -localNodeID = "05.01.01.01.03.01" -farNodeID = "09.00.99.03.00.35" +# localNodeID = "05.01.01.01.03.01" +# farNodeID = "09.00.99.03.00.35" sock = TcpSocket() # s.settimeout(30) sock.connect(settings['host'], settings['port']) @@ -53,8 +53,7 @@ # string = frame.encodeAsString() # print(" SR: "+string.strip()) # sock.sendString(string) -# if frame.afterSendState: -# canLink.setState(frame.afterSendState) +# physicalLayer.onSentFrame(frame) def printFrame(frame): @@ -69,7 +68,7 @@ def printMessage(message): print("RM: {} from {}".format(message, message.source)) -canLink = CanLink(physicalLayer, NodeID(localNodeID)) +canLink = CanLink(physicalLayer, NodeID(settings['localNodeID'])) canLink.registerMessageReceivedListener(printMessage) datagramService = DatagramService(canLink) @@ -99,22 +98,26 @@ def datagramReceiver(memo): ####################### +observer = GridConnectObserver() + def pumpEvents(): received = sock.receive() - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) + if received is not None: + if settings['trace']: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + print(" RR: "+packet_str.strip()) + # pass to link processor + physicalLayer.handleData(received) canLink.pollState() - frame = physicalLayer.pollFrame() - if frame: + while True: + frame = physicalLayer.pollFrame() + if frame is None: + break sock.sendString(frame.encodeAsString()) - if frame.afterSendState: - canLink.setState(frame.afterSendState) + physicalLayer.onSentFrame(frame) # have the socket layer report up to bring the link layer up and get an alias @@ -139,7 +142,7 @@ def datagramWrite(): time.sleep(1) writeMemo = DatagramWriteMemo( - NodeID(farNodeID), + NodeID(settings['farNodeID']), bytearray([0x20, 0x43, 0x00, 0x00, 0x00, 0x00, 0x14]), writeCallBackCheck ) @@ -149,8 +152,6 @@ def datagramWrite(): thread = threading.Thread(target=datagramWrite) thread.start() -observer = GridConnectObserver() - # process resulting activity while True: pumpEvents() diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index 3a39056..cf8654e 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -18,6 +18,7 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples +from openlcb import precise_sleep from openlcb.canbus.gridconnectobserver import GridConnectObserver from openlcb.tcplink.tcpsocket import TcpSocket from openlcb.canbus.canphysicallayergridconnect import ( @@ -44,29 +45,45 @@ def sendToSocket(frame: CanFrame): string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) - # if frame.afterSendState: - # canLink.setState(frame.afterSendState) + physicalLayer.onSentFrame(frame) def pumpEvents(): received = sock.receive() - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) + if received is not None: + if settings['trace']: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + print(" RR: "+packet_str.strip()) + # pass to link processor + physicalLayer.handleData(received) # canLink.pollState() - frame = physicalLayer.pollFrame() - if frame: + + while True: + frame = physicalLayer.pollFrame() + if frame is None: + break string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) + physicalLayer.onSentFrame(frame) if frame.afterSendState: print("Next state (unexpected, no link layer): {}" .format(frame.afterSendState)) # canLink.setState(frame.afterSendState) + # ^ setState is done by onSentFrame now + # (physicalLayer.onSentFrame = self.handleSentFrame + # in LinkLayer constructor) + + +def handleSentFrame(frame): + # No state to manage since no link layer + pass + +def handleReceivedFrame(frame): + # No state to manage since no link layer + pass def printFrame(frame): @@ -74,6 +91,8 @@ def printFrame(frame): physicalLayer = CanPhysicalLayerGridConnect() +physicalLayer.onSentFrame = handleSentFrame +physicalLayer.onReceivedFrame = handleReceivedFrame physicalLayer.registerFrameReceivedListener(printFrame) # send an AME frame with arbitrary alias to provoke response @@ -92,3 +111,4 @@ def printFrame(frame): # display response - should be RID from nodes while True: pumpEvents() + precise_sleep(.01) diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index bc5bd5b..afa0bc3 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -57,8 +57,7 @@ # string = frame.encodeAsString() # print(" SR: {}".format(string.strip())) # sock.sendString(string) -# if frame.afterSendState: -# canLink.setState(frame.afterSendState) +# physicalLayer.onSentFrame(frame) def printFrame(frame): @@ -112,28 +111,34 @@ def printDatagram(memo): # def memoryReadFail(memo): # print("memory read failed: {}".format(memo.data)) + def memoryLengthReply(address) : print ("memory length reply: "+str(address)) + ####################### + def pumpEvents(): received = sock.receive() - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) + if received is not None: + if settings['trace']: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + print(" RR: "+packet_str.strip()) + # pass to link processor + physicalLayer.handleData(received) canLink.pollState() - frame = physicalLayer.pollFrame() - if frame: + + while True: + frame = physicalLayer.pollFrame() + if frame is None: + break string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) - if frame.afterSendState: - canLink.setState(frame.afterSendState) + physicalLayer.onSentFrame(frame) # have the socket layer report up to bring the link layer up and get an alias @@ -171,10 +176,9 @@ def memoryRequest(): observer = GridConnectObserver() - # process resulting activity while True: pumpEvents() - + precise_sleep(.01) canLink.onDisconnect() diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index b927886..00f0ad1 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -10,7 +10,8 @@ address and port. ''' # region same code as other examples -from examples_settings import Settings # do 1st to fix path if no pip install +from examples_settings import Settings +from openlcb.canbus.canframe import CanFrame # do 1st to fix path if no pip install settings = Settings() if __name__ == "__main__": @@ -57,15 +58,14 @@ def sendToSocket(frame: CanFrame): string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) - if frame.afterSendState: - canLink.setState(frame.afterSendState) + physicalLayer.onSentFrame(frame) def printFrame(frame): print(" RL: {}".format(frame)) -physicalLayer = CanPhysicalLayerGridConnect(sendToSocket) +physicalLayer = CanPhysicalLayerGridConnect() physicalLayer.registerFrameReceivedListener(printFrame) @@ -117,22 +117,26 @@ def memoryReadFail(memo): ####################### def pumpEvents(): received = sock.receive() - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) + if received is not None: + if settings['trace']: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + print(" RR: "+packet_str.strip()) + # pass to link processor + physicalLayer.handleData(received) canLink.pollState() - frame = physicalLayer.pollFrame() - if frame: + + while True: + frame = physicalLayer.pollFrame() + if frame is None: + break sock.sendString(frame.encodeAsString()) - if frame.afterSendState: - canLink.setState(frame.afterSendState) + physicalLayer.onSentFrame(frame) # have the socket layer report up to bring the link layer up and get an alias + print(" SL : link up...") physicalLayer.physicalLayerUp() print(" SL : link up...waiting...") @@ -141,6 +145,7 @@ def pumpEvents(): precise_sleep(.02) print(" SL : link up") + def memoryRead(): """Create and send a read datagram. This is a read of 20 bytes from the start of CDI space. @@ -166,5 +171,6 @@ def memoryRead(): # process resulting activity while True: pumpEvents() + precise_sleep(.01) canLink.onDisconnect() \ No newline at end of file diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index cd77fee..ad63c76 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -50,15 +50,14 @@ # string = frame.encodeAsString() # print(" SR: {}".format(string.strip())) # sock.sendString(string) -# if frame.afterSendState: -# canLink.setState(frame.afterSendState) +# physicalLayer.onSentFrame(frame) def printFrame(frame): print(" RL: {}".format(frame)) -physicalLayer = CanPhysicalLayerGridConnect(sendToSocket) +physicalLayer = CanPhysicalLayerGridConnect() physicalLayer.registerFrameReceivedListener(printFrame) @@ -66,31 +65,36 @@ def printMessage(msg): print("RM: {} from {}".format(msg, msg.source)) -canLink = CanLink(NodeID(settings['localNodeID'])) +canLink = CanLink(physicalLayer, NodeID(settings['localNodeID'])) canLink.registerMessageReceivedListener(printMessage) ####################### + def pumpEvents(): received = sock.receive() - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) + if received is not None: + if settings['trace']: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + print(" RR: "+packet_str.strip()) + # pass to link processor + physicalLayer.handleData(received) canLink.pollState() - frame = physicalLayer.pollFrame() - if frame: + + while True: + frame = physicalLayer.pollFrame() + if frame is None: + break string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) - if frame.afterSendState: - canLink.setState(frame.afterSendState) + physicalLayer.onSentFrame(frame) # have the socket layer report up to bring the link layer up and get an alias + print(" SL : link up...") physicalLayer.physicalLayerUp() print(" SL : link up...waiting...") @@ -110,5 +114,6 @@ def pumpEvents(): # process resulting activity while True: pumpEvents() + precise_sleep(.01) canLink.onDisconnect() diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index 5dbf39d..8e2206f 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -63,8 +63,7 @@ # string = frame.encodeAsString() # print(" SR: {}".format(string.strip())) # sock.sendString(string) -# if frame.afterSendState: -# canLink.setState(frame.afterSendState) +# physicalLayer.onSentFrame(frame) def printFrame(frame): @@ -148,21 +147,24 @@ def displayOtherNodeIds(message) : def pumpEvents(): received = sock.receive() - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) + if received is not None: + if settings['trace']: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + print(" RR: "+packet_str.strip()) + # pass to link processor + physicalLayer.handleData(received) canLink.pollState() - frame = physicalLayer.pollFrame() - if frame: + + while True: + frame = physicalLayer.pollFrame() + if frame is None: + break string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) - if frame.afterSendState: - canLink.setState(frame.afterSendState) + physicalLayer.onSentFrame(frame) # have the socket layer report up to bring the link layer up and get an alias @@ -184,5 +186,6 @@ def pumpEvents(): # process resulting activity while True: pumpEvents() + precise_sleep(.01) canLink.onDisconnect() diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index 17c1792..c7805f6 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -64,8 +64,7 @@ # string = frame.encodeAsString() # if settings['trace'] : print(" SR: "+string.strip()) # sock.sendString(string) - # if frame.afterSendState: - # canLink.setState(frame.afterSendState) + # physicalLayer.onSentFrame(frame) def receiveFrame(frame) : @@ -126,14 +125,16 @@ def pumpEvents(): # pass to link processor physicalLayer.handleData(received) canLink.pollState() - frame = physicalLayer.pollFrame() - if frame: + + while True: + frame = physicalLayer.pollFrame() + if frame is None: + break string = frame.encodeAsString() if True: # if settings['trace']: print("- SENT Remote: "+string.strip()) sock.sendString(string) - if frame.afterSendState: - canLink.setState(frame.afterSendState) + physicalLayer.onSentFrame(frame) # bring the CAN level up @@ -185,10 +186,12 @@ def pumpEvents(): print("nodeIdToAlias: {}".format(canLink.nodeIdToAlias)) + def receiveLoop(): """put the read on a separate thread""" while True: pumpEvents() + precise_sleep(.01) import threading # noqa E402 diff --git a/examples/example_string_interface.py b/examples/example_string_interface.py index 9680beb..724d566 100644 --- a/examples/example_string_interface.py +++ b/examples/example_string_interface.py @@ -18,6 +18,7 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples +from openlcb import precise_sleep from openlcb.canbus.gridconnectobserver import GridConnectObserver from openlcb.tcplink.tcpsocket import TcpSocket @@ -44,13 +45,9 @@ # display response - should be RID from node(s) while True: # have to kill this manually received = sock.receive() - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - canLink.pollState() - frame = physicalLayer.pollFrame() - if frame: - sock.sendString(frame.encodeAsString()) - if frame.afterSendState: - canLink.setState(frame.afterSendState) + if received is not None: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + print(" RR: "+packet_str.strip()) + precise_sleep(.01) diff --git a/examples/example_string_serial_interface.py b/examples/example_string_serial_interface.py index fad32d9..0b1ebe1 100644 --- a/examples/example_string_serial_interface.py +++ b/examples/example_string_serial_interface.py @@ -18,6 +18,7 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples +from openlcb import precise_sleep from openlcb.canbus.gridconnectobserver import GridConnectObserver from openlcb.canbus.seriallink import SerialLink @@ -43,13 +44,17 @@ # display response - should be RID from node(s) while True: # have to kill this manually received = sock.receive() - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - canLink.pollState() - frame = physicalLayer.pollFrame() - if frame: - sock.sendString(frame.encodeAsString()) - if frame.afterSendState: - canLink.setState(frame.afterSendState) + if received is not None: + observer.push(received) + if observer.hasNext(): + packet_str = observer.next() + print(" RR: "+packet_str.strip()) + # canLink.pollState() + + # while True: + # frame = physicalLayer.pollFrame() + # if frame is None: + # break + # sock.sendString(frame.encodeAsString()) + # physicalLayer.onSentFrame(frame) + precise_sleep(.01) diff --git a/examples/example_tcp_message_interface.py b/examples/example_tcp_message_interface.py index e424ea0..f04623a 100644 --- a/examples/example_tcp_message_interface.py +++ b/examples/example_tcp_message_interface.py @@ -14,6 +14,7 @@ from logging import getLogger # region same code as other examples from examples_settings import Settings +from openlcb import precise_sleep from openlcb.realtimephysicallayer import RealtimePhysicalLayer # do 1st to fix path if no pip install settings = Settings() @@ -90,13 +91,17 @@ def printMessage(msg): # process resulting activity while True: received = sock.receive() - print(" RR: {}".format(received)) - # pass to link processor - tcpLinkLayer.receiveListener(received) + if received is not None: + print(" RR: {}".format(received)) + # pass to link processor + tcpLinkLayer.receiveListener(received) # Normally we would do (Probably N/A here): # canLink.pollState() - # frame = physicalLayer.pollFrame() - # if frame: + # + # while True: + # frame = physicalLayer.pollFrame() + # if frame is None: + # break # sock.sendString(frame.encodeAsString()) - # if frame.afterSendState: - # canLink.setState(frame.afterSendState) + # physicalLayer.onSentFrame(frame) + precise_sleep(.01) diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 0022f5d..46a95ad 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -528,22 +528,24 @@ def handleReceivedData(self, frame): # CanFrame sourceID = mapped except KeyboardInterrupt: raise - except: + except Exception as ex: + unmapped = frame.header & 0xFFF + logger.warning("[CanLink]" + formatted_ex(ex)) # special case for JMRI before 5.1.5 which sends # VerifiedNodeID but not AMD if mti == MTI.Verified_NodeID: sourceID = NodeID(frame.data) logger.info( - "Verified_NodeID from unknown source alias: {}," + "Verified_NodeID frame {} from unknown source alias: {}," " continue with observed ID {}" - .format(frame, sourceID)) + .format(frame, unmapped, sourceID)) else: sourceID = NodeID(self.nextInternallyAssignedNodeID) self.nextInternallyAssignedNodeID += 1 logger.warning( - "message from unknown source alias: {}," + "message frame {} from unknown source alias: {}," " continue with created ID {}" - .format(frame, sourceID)) + .format(frame, unmapped, sourceID)) # register that internally-generated nodeID-alias association self.aliasToNodeID[frame.header & 0xFFF] = sourceID @@ -894,6 +896,8 @@ def processCollision(self, frame) : # def sendAliasAllocationSequence(self): # # actually, call self._enqueueCIDSequence() # set _state&send data # raise DeprecationWarning("Use setState to BusyLocalCIDSequence") + def getWaitForAliasResponseStart(self): + return self._waitingForAliasStart def pollState(self): """You must keep polling state after every time diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index 5063828..5a4de23 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -24,7 +24,7 @@ def __init__(self,): PhysicalLayer.__init__(self) self.listeners = [] - def onReceivedFrame(self): + def onReceivedFrame(self, frame): raise NotImplementedError( "Your LinkLayer/subclass must set this manually (monkeypatch)" " to the CanLink instance's receiveListener method.") diff --git a/openlcb/canbus/seriallink.py b/openlcb/canbus/seriallink.py index 8eb0630..aff91e0 100644 --- a/openlcb/canbus/seriallink.py +++ b/openlcb/canbus/seriallink.py @@ -13,6 +13,10 @@ logger = getLogger(__name__) MSGLEN = 35 +# TODO: use non-blocking mode and eliminate MSGLEN, since MSGLEN is only +# a convenience for CLI programs (PhysicalLayer is able to assemble +# packets from arbitrary chunks) and GridConnectObserver can replace +# that. class SerialLink(PortInterface): diff --git a/openlcb/dispatcher.py b/openlcb/dispatcher.py index 71c759b..8018765 100644 --- a/openlcb/dispatcher.py +++ b/openlcb/dispatcher.py @@ -304,8 +304,7 @@ def _listen(self): assert isinstance(packet, str) print("Sending {}".format(packet)) self._port.sendString(packet) - if frame.afterSendState: - self._canLink.setState(frame.afterSendState) + physicalLayer.onSentFrame(frame) else: raise NotImplementedError( "Event type {} is not handled." diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index f635560..30e675a 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -60,6 +60,7 @@ def __init__(self, physicalLayer: PhysicalLayer, localNodeID): # "new bound method" Python behavior in subclass from making "is" # operator not work as expected in registerFrameReceivedListener. physicalLayer.onReceivedFrame = self.receiveListener + physicalLayer.onSentFrame = self.handleSentFrame # # ^ enforce queue paradigm (See use in PhysicalLayer subclass) # physicalLayer.registerFrameReceivedListener(listener) # ^ Doesn't work with "is" operator still! So just use @@ -84,6 +85,11 @@ def receiveListener(self, frame): "{} abstract receiveListener called (expected implementation)" .format(type(self).__name__)) + def handleSentFrame(self, frame): + """Update state based on the frame having been sent.""" + if frame.afterSendState: + self.setState(frame.afterSendState) + def onDisconnect(self): """Run this whenever the socket connection is lost and override _onStateChanged to handle the change. diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index c4e1f05..32e5ae4 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -89,6 +89,12 @@ def sendFrameAfter(self, frame): """ self._send_frames.append(frame) # append: queue-like if using popleft + def onSentFrame(self, frame): + raise NotImplementedError( + "onSentFrame must be set (monkeypatched)" + " to use the LinkLayer subclass' one" + " so state can be updated if necessary.") + def registerFrameReceivedListener(self, listener): """abstract method""" # raise NotImplementedError("Each subclass must implement this.") diff --git a/openlcb/realtimephysicallayer.py b/openlcb/realtimephysicallayer.py index 557620f..61ffec9 100644 --- a/openlcb/realtimephysicallayer.py +++ b/openlcb/realtimephysicallayer.py @@ -1,4 +1,5 @@ +from enum import Enum from logging import getLogger from typing import Union @@ -9,6 +10,12 @@ class RealtimePhysicalLayer(PhysicalLayer): + class State(Enum): + Disconnected = 0 + Connected = 1 + + DisconnectedState = State.Disconnected + def __init__(self, socket): # sock to distinguish from socket module or socket.socket class! self.sock = socket @@ -31,6 +38,9 @@ def sendFrameAfter(self, frame): # ) print(" SR: {}".format(frame.encode())) self.sock.send(frame.encode()) + # TODO: finish onSentFrame + if frame.afterSendState: + self.onSentFrame(frame) def registerFrameReceivedListener(self, listener): """_summary_ diff --git a/openlcb/tcplink/tcplink.py b/openlcb/tcplink/tcplink.py index 45a62eb..9f099b6 100644 --- a/openlcb/tcplink/tcplink.py +++ b/openlcb/tcplink/tcplink.py @@ -33,6 +33,11 @@ class TcpLink(LinkLayer): localNodeID (NodeID): The node ID of the Configuration Tool or other software-defined node connecting to the LCC network via TCP. """ + class State: + Disconnected = 0 + Connected = 1 + + DisconnectedState = State.Disconnected def __init__(self, physicalLayer: PhysicalLayer, localNodeID): LinkLayer.__init__(self, physicalLayer, localNodeID) diff --git a/openlcb/tcplink/tcpsocket.py b/openlcb/tcplink/tcpsocket.py index e654cc3..fb6a342 100644 --- a/openlcb/tcplink/tcpsocket.py +++ b/openlcb/tcplink/tcpsocket.py @@ -83,6 +83,8 @@ def _receive(self) -> bytes: Returns: list(int): one or more bytes, converted to a list of ints. ''' + # MSGLEN feature is only a convenience for CLI, + # so was moved to GridConnectObserver. # public receive (do not overload) asserts no overlapping call try: data = self._device.recv(128) diff --git a/tests/test_canphysicallayergridconnect.py b/tests/test_canphysicallayergridconnect.py index b816198..4bfa09b 100644 --- a/tests/test_canphysicallayergridconnect.py +++ b/tests/test_canphysicallayergridconnect.py @@ -7,6 +7,7 @@ from openlcb.canbus.canframe import CanFrame from openlcb.nodeid import NodeID + class PhysicalLayerMock(CanPhysicalLayerGridConnect): # PHY side # def frameSocketSendDummy(self, frame): @@ -22,11 +23,11 @@ def captureString(self, packet): self. capturedFrame = packet self. capturedFrame.encoder = self.gc - if frame.afterSendState: - pass - # NOTE: skipping canLink.setState since testing only - # physical layer not link layer. - # canLink.setState(frame.afterSendState) + def onSentFrame(self, frame): + pass + # NOTE: not patching this method to be canLink.handleSentFrame + # since testing only physical layer not link layer. + class CanPhysicalLayerGridConnectTest(unittest.TestCase): @@ -41,7 +42,6 @@ def __init__(self, *args, **kwargs): # def captureString(self, string): # self.capturedString = string - # Link Layer side def receiveListener(self, frame): self.receivedFrames += [frame] From 82018c1c0193cd414dc41d7d0211572bffd5eb1d Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 8 May 2025 17:10:21 -0400 Subject: [PATCH 55/99] Fix tests to use the new CanLink states (states created for issue #62). Fix Message constructor call in testNewNodeSeen (may resolve issue #66). FIXME: CanFrame isn't constructed as expected in test_canlink.py. --- openlcb/canbus/canlink.py | 2 +- openlcb/canbus/canlinklayersimulation.py | 57 +++++ openlcb/canbus/canphysicallayer.py | 14 +- openlcb/canbus/canphysicallayersimulation.py | 17 +- openlcb/physicallayer.py | 20 +- openlcb/portinterface.py | 1 - openlcb/realtimephysicallayer.py | 7 +- tests/test_canlink.py | 206 +++++++++++-------- tests/test_canphysicallayer.py | 8 + tests/test_canphysicallayergridconnect.py | 53 +++-- tests/test_datagramservice.py | 17 +- tests/test_linklayer.py | 10 +- tests/test_localnodeprocessor.py | 16 +- tests/test_memoryservice.py | 18 +- tests/test_platformextras.py | 4 + tests/test_remotenodeprocessor.py | 10 +- tests/test_scanner.py | 4 + tests/test_tcplink.py | 39 ++-- 18 files changed, 352 insertions(+), 151 deletions(-) create mode 100644 openlcb/canbus/canlinklayersimulation.py diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 46a95ad..0f5174f 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -23,7 +23,7 @@ from logging import getLogger from timeit import default_timer -from openlcb import emit_cast, formatted_ex +from openlcb import emit_cast, formatted_ex, precise_sleep from openlcb.canbus.canframe import CanFrame from openlcb.canbus.controlframe import ControlFrame diff --git a/openlcb/canbus/canlinklayersimulation.py b/openlcb/canbus/canlinklayersimulation.py new file mode 100644 index 0000000..0d3459a --- /dev/null +++ b/openlcb/canbus/canlinklayersimulation.py @@ -0,0 +1,57 @@ +from timeit import default_timer + +from openlcb import precise_sleep +from openlcb.canbus.canlink import CanLink + + +class CanLinkLayerSimulation(CanLink): + # pumpEvents and waitForReady are based on examples + # and may be moved to CanLink or Dispatcher + # to make the Python module easier to use. + + def pumpEvents(self): + # try: + # received = sock.receive() + # if received is not None: + # if settings['trace']: + # observer.push(received) + # if observer.hasNext(): + # packet_str = observer.next() + # # print(" RR: "+packet_str.strip()) + # # ^ commented since MyHandler shows parsed XML + # # fields instead + # # pass to link processor + # physicalLayer.handleData(received) + # except BlockingIOError: + # pass + self.pollState() + while True: + frame = self.physicalLayer.pollFrame() + if not frame: + break + string = frame.encodeAsString() + # print(" SENT packet: "+string.strip()) + # ^ This is too verbose for this example (each is a + # request to read a 64 byte chunks of the CDI XML) + # sock.sendString(string) + self.physicalLayer.onSentFrame(frame) + + def waitForReady(self): + print("[CanLink] waitForReady...") + self = self + while self.pollState() != CanLink.State.Permitted: + self.pumpEvents() # provides incoming data to physicalLayer & sends queued + if self.getState() == CanLink.State.WaitForAliases: + self.pumpEvents() # prevent assertion error below, proceed to send. + if self.pollState() == CanLink.State.Permitted: + break + assert self.getWaitForAliasResponseStart() is not None, \ + "openlcb didn't send the 7,6,5,4 CID frames (state={})".format(self.getState()) + if default_timer() - self.getWaitForAliasResponseStart() > CanLink.ALIAS_RESPONSE_DELAY: + # 200ms = standard wait time for responses + if not self.require_remote_nodes: + raise TimeoutError( + "In Standard require_remote_nodes=False mode," + " but failed to proceed to Permitted state.") + precise_sleep(.02) + print("[CanLink] waitForReady...done") diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index 5a4de23..ede832e 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -26,8 +26,9 @@ def __init__(self,): def onReceivedFrame(self, frame): raise NotImplementedError( - "Your LinkLayer/subclass must set this manually (monkeypatch)" - " to the CanLink instance's receiveListener method.") + "Your LinkLayer/subclass must patch the instance:" + " Set this method manually to the CanLink instance's" + " receiveListener method.") def sendFrameAfter(self, frame: CanFrame): """Enqueue: *IMPORTANT* Main/other thread may have @@ -52,7 +53,7 @@ def sendFrameAfter(self, frame: CanFrame): """ assert isinstance(frame, CanFrame) frame.encoder = self - PhysicalLayer.sendFrameAfter(self, frame) + PhysicalLayer.sendFrameAfter(self, frame) # calls onQueuedFrame if set def pollFrame(self) -> CanFrame: frame = super().pollFrame() @@ -61,14 +62,11 @@ def pollFrame(self) -> CanFrame: assert isinstance(frame, CanFrame) return frame - def encode(self, frame) -> str: - '''abstract interface (encode frame to string)''' - raise NotImplementedError("Each subclass must implement this.") - def registerFrameReceivedListener(self, listener): assert listener is not None warnings.warn( - "You don't really need to listen to packets." + "[registerFrameReceivedListener]" + " You don't really need to listen to packets." " Use pollFrame instead, which will collect and decode" " packets into frames (this layer communicates to upper layers" " using physicalLayer.onReceivedFrame set by LinkLayer/subclass" diff --git a/openlcb/canbus/canphysicallayersimulation.py b/openlcb/canbus/canphysicallayersimulation.py index 3c0d82f..d6092e7 100644 --- a/openlcb/canbus/canphysicallayersimulation.py +++ b/openlcb/canbus/canphysicallayersimulation.py @@ -10,6 +10,21 @@ class CanPhysicalLayerSimulation(CanPhysicalLayer): def __init__(self): self.receivedPackets = [] CanPhysicalLayer.__init__(self) + self.onQueuedFrame = self._onQueuedFrame - def handlePacket(self, frame): + def _onQueuedFrame(self, frame): + raise AttributeError( + "This should not be used for simulation" + "--Make sendFrameAfter realtime instead.") + + def captureFrame(self, frame): self.receivedPackets.append(frame) + return "CanPhysicalLayerSimulation" + + def sendFrameAfter(self, frame): + return self.captureFrame(frame) # pretend it was sent + # (normally only onQueuedFrame would be called here, + # and would be encoded to packet str/bytes/bytearray + # and sent to socket later by the application's socket code, + # which would then call onSentFrame which is set + # to the LinkLayer subclass' handleSentFrame) diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index 32e5ae4..ff867fc 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -55,6 +55,7 @@ class PhysicalLayer: def __init__(self): self._send_frames = deque() + self.onQueuedFrame = None # def sendDataAfter(self, data): # assert isinstance(data, (bytes, bytearray)) @@ -88,11 +89,14 @@ def sendFrameAfter(self, frame): and link manages state. """ self._send_frames.append(frame) # append: queue-like if using popleft + if self.onQueuedFrame: + self.onQueuedFrame(frame) def onSentFrame(self, frame): raise NotImplementedError( - "onSentFrame must be set (monkeypatched)" - " to use the LinkLayer subclass' one" + "The subclass must patch the instance:" + " PhysicalLayer instance's onSentFrame must be manually set" + " to the LinkLayer subclass instance' handleSentFrame" " so state can be updated if necessary.") def registerFrameReceivedListener(self, listener): @@ -103,6 +107,18 @@ def registerFrameReceivedListener(self, listener): " (expected implementation)" .format(type(self).__name__)) + def encodeFrameAsString(self, frame) -> str: + '''abstract interface (encode frame to string)''' + raise NotImplementedError( + "If application uses this," + " the subclass there must also implement FrameEncoder.") + + def encodeFrameAsData(self, frame) -> Union[bytearray, bytes]: + '''abstract interface (encode frame to string)''' + raise NotImplementedError( + "If application uses this," + " the subclass there must also implement FrameEncoder.") + def physicalLayerUp(self): """abstract method""" raise NotImplementedError("Each subclass must implement this.") diff --git a/openlcb/portinterface.py b/openlcb/portinterface.py index 4f9822a..e83e2ad 100644 --- a/openlcb/portinterface.py +++ b/openlcb/portinterface.py @@ -28,7 +28,6 @@ class PortInterface: once (on different threads), which would cause undefined behavior (in OS-level implementation of serial port or socket). """ - ports = [] def __init__(self): """This must run for each subclass, such as using super""" diff --git a/openlcb/realtimephysicallayer.py b/openlcb/realtimephysicallayer.py index 61ffec9..37e0e9f 100644 --- a/openlcb/realtimephysicallayer.py +++ b/openlcb/realtimephysicallayer.py @@ -10,9 +10,10 @@ class RealtimePhysicalLayer(PhysicalLayer): - class State(Enum): - Disconnected = 0 - Connected = 1 + class State: + Initial = 0 + Disconnected = 1 + Permitted = 2 DisconnectedState = State.Disconnected diff --git a/tests/test_canlink.py b/tests/test_canlink.py index 300b1e3..2038dff 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -1,10 +1,11 @@ import unittest -from openlcb import precise_sleep +from openlcb import formatted_ex, precise_sleep from openlcb.canbus.canlink import CanLink from openlcb.canbus.canframe import CanFrame +from openlcb.canbus.canlinklayersimulation import CanLinkLayerSimulation from openlcb.canbus.canphysicallayer import CanPhysicalLayer from openlcb.canbus.canphysicallayersimulation import ( CanPhysicalLayerSimulation @@ -34,11 +35,19 @@ def receiveMessage(self, msg): self.receivedMessages.append(msg) +def getLocalNodeIDStr(): + return "05.01.01.01.03.01" + + +def getLocalNodeID(): + return NodeID(getLocalNodeIDStr()) + + class TestCanLinkClass(unittest.TestCase): # MARK: - Alias calculations def testIncrementAlias48(self): - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(PhyMockLayer(), getLocalNodeID()) # check precision of calculation self.assertEqual(canLink.incrementAlias48(0), 0x1B0C_A37A_4BA9, @@ -50,7 +59,7 @@ def testIncrementAlias48(self): canLink.onDisconnect() def testIncrementAliasSequence(self): - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(PhyMockLayer(), getLocalNodeID()) # sequence from TN next = canLink.incrementAlias48(0) @@ -70,7 +79,7 @@ def testIncrementAliasSequence(self): canLink.onDisconnect() def testCreateAlias12(self): - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(PhyMockLayer(), getLocalNodeID()) # check precision of calculation self.assertEqual(canLink.createAlias12(0x001), 0x001, "0x001 input") @@ -89,15 +98,14 @@ def testCreateAlias12(self): # MARK: - Test PHY Up def testLinkUpSequence(self): - = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01"), - require_remote_nodes=False) + canPhysicalLayer = CanPhysicalLayerSimulation() + canLink = CanLinkLayerSimulation( + canPhysicalLayer, getLocalNodeID(), require_remote_nodes=False) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() - while canLink.pollState() != CanLink.State.Permitted: - precise_sleep(.02) + canLink.waitForReady() self.assertEqual(len(canPhysicalLayer.receivedFrames), 7) self.assertEqual(canLink._state, CanLink.State.Permitted) @@ -108,7 +116,7 @@ def testLinkUpSequence(self): # MARK: - Test PHY Down, Up, Error Information def testLinkDownSequence(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canLink._state = CanLink.State.Permitted @@ -121,21 +129,21 @@ def testLinkDownSequence(self): def testEIR2NoData(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) canLink._state = CanLink.State.Permitted - canPhysicalLayer.fireListeners(CanFrame(ControlFrame.EIR2.value, 0)) + canPhysicalLayer.sendFrameAfter(CanFrame(ControlFrame.EIR2.value, 0)) self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) canLink.onDisconnect() # MARK: - Test AME (Local Node) def testAMENoData(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink._state = CanLink.State.Permitted - canPhysicalLayer.fireListeners(CanFrame(ControlFrame.AME.value, 0)) + canPhysicalLayer.sendFrameAfter(CanFrame(ControlFrame.AME.value, 0)) self.assertEqual(len(canPhysicalLayer.receivedPackets), 1) self.assertEqual( canPhysicalLayer.receivedPackets[0], @@ -146,22 +154,23 @@ def testAMENoData(self): def testAMEnoDataInhibited(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) canLink._state = CanLink.State.Inhibited - canPhysicalLayer.fireListeners(CanFrame(ControlFrame.AME.value, 0)) + canPhysicalLayer.sendFrameAfter(CanFrame(ControlFrame.AME.value, 0)) self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) canLink.onDisconnect() def testAMEMatchEvent(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink._state = CanLink.State.Permitted frame = CanFrame(ControlFrame.AME.value, 0) frame.data = bytearray([5, 1, 1, 1, 3, 1]) - canPhysicalLayer.fireListeners(frame) + result = canPhysicalLayer.sendFrameAfter(frame) + self.assertEqual(result, "CanPhysicalLayerSimulation") self.assertEqual(len(canPhysicalLayer.receivedPackets), 1) self.assertEqual(canPhysicalLayer.receivedPackets[0], CanFrame(ControlFrame.AMD.value, ourAlias, @@ -170,23 +179,23 @@ def testAMEMatchEvent(self): def testAMENotMatchEvent(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) canLink._state = CanLink.State.Permitted frame = CanFrame(ControlFrame.AME.value, 0) frame.data = bytearray([0, 0, 0, 0, 0, 0]) - canPhysicalLayer.fireListeners(frame) + canPhysicalLayer.sendFrameAfter(frame) self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) canLink.onDisconnect() # MARK: - Test Alias Collisions (Local Node) def testCIDreceivedMatch(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink._state = CanLink.State.Permitted - canPhysicalLayer.fireListeners(CanFrame(7, canLink.localNodeID, + canPhysicalLayer.sendFrameAfter(CanFrame(7, canLink.localNodeID, ourAlias)) self.assertEqual(len(canPhysicalLayer.receivedPackets), 1) self.assertEqual(canPhysicalLayer.receivedPackets[0], @@ -195,12 +204,12 @@ def testCIDreceivedMatch(self): def testRIDreceivedMatch(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink._state = CanLink.State.Permitted - canPhysicalLayer.fireListeners(CanFrame(ControlFrame.RID.value, - ourAlias)) + canPhysicalLayer.sendFrameAfter(CanFrame(ControlFrame.RID.value, + ourAlias)) self.assertEqual(len(canPhysicalLayer.receivedPackets), 8) # ^ includes recovery of new alias 4 CID, RID, AMR, AME self.assertEqual(canPhysicalLayer.receivedPackets[0], @@ -214,7 +223,7 @@ def testRIDreceivedMatch(self): def testCheckMTIMapping(self): - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(PhyMockLayer(), getLocalNodeID()) self.assertEqual( canLink.canHeaderToFullFormat(CanFrame(0x19490247, bytearray())), @@ -222,7 +231,7 @@ def testCheckMTIMapping(self): ) def testControlFrameDecode(self): - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(PhyMockLayer(), getLocalNodeID()) frame = CanFrame(0x1000, 0x000) # invalid control frame content self.assertEqual(canLink.decodeControlFrameFormat(frame), ControlFrame.UnknownFormat) @@ -265,7 +274,7 @@ def testControlFrameIsInternal(self): def testSimpleGlobalData(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canLink._state = CanLink.State.Permitted @@ -273,9 +282,9 @@ def testSimpleGlobalData(self): # map an alias we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) - canPhysicalLayer.fireListeners(amd) + canPhysicalLayer.sendFrameAfter(amd) - canPhysicalLayer.fireListeners(CanFrame(0x19490, 0x247)) + canPhysicalLayer.sendFrameAfter(CanFrame(0x19490, 0x247)) # ^ from previously seen alias self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) @@ -294,15 +303,15 @@ def testVerifiedNodeInDestAliasMap(self): # This tests that a VerifiedNode will update that. canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canLink._state = CanLink.State.Permitted # Don't map an alias with an AMD for this test - canPhysicalLayer.fireListeners(CanFrame(0x19170, 0x247, - bytearray([8, 7, 6, 5, 4, 3]))) + canPhysicalLayer.sendFrameAfter(CanFrame(0x19170, 0x247, + bytearray([8, 7, 6, 5, 4, 3]))) # ^ VerifiedNodeID from unique alias self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) @@ -322,15 +331,15 @@ def testNoDestInAliasMap(self): ''' canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canLink._state = CanLink.State.Permitted # Don't map an alias with an AMD for this test - canPhysicalLayer.fireListeners(CanFrame(0x19968, 0x247, - bytearray([8, 7, 6, 5, 4, 3]))) + canPhysicalLayer.sendFrameAfter( + CanFrame(0x19968, 0x247, bytearray([8, 7, 6, 5, 4, 3]))) # ^ Identify Events Addressed from unique alias self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) @@ -346,25 +355,24 @@ def testNoDestInAliasMap(self): def testSimpleAddressedData(self): # Test start=yes, end=yes frame canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01"), - require_remote_nodes=False) + canLink = CanLinkLayerSimulation( + canPhysicalLayer, getLocalNodeID(), require_remote_nodes=False) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() - while canLink.pollState() != CanLink.State.Permitted: - precise_sleep(.02) + canLink.waitForReady() # map an alias we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) - canPhysicalLayer.fireListeners(amd) + canPhysicalLayer.sendFrameAfter(amd) ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) frame = CanFrame(0x19488, 0x247) # Verify Node ID Addressed frame.data = bytearray([((ourAlias & 0x700) >> 8), (ourAlias & 0xFF), 12, 13]) - canPhysicalLayer.fireListeners(frame) # from previously seen alias + canPhysicalLayer.sendFrameAfter(frame) # from previously seen alias self.assertEqual(len(messageLayer.receivedMessages), 2) # ^ startup plus one message forwarded @@ -383,14 +391,13 @@ def testSimpleAddressedData(self): # Test start=yes, end=yes frame def testSimpleAddressedDataNoAliasYet(self): '''Test start=yes, end=yes frame with no alias match''' canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01"), - require_remote_nodes=False) + canLink = CanLinkLayerSimulation( + canPhysicalLayer, getLocalNodeID(), require_remote_nodes=False) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() - while canLink.pollState() != CanLink.State.Permitted: - precise_sleep(.02) + canLink.waitForReady() # don't map alias with AMD @@ -400,7 +407,7 @@ def testSimpleAddressedDataNoAliasYet(self): frame.data = bytearray( [((ourAlias & 0x700) >> 8), (ourAlias & 0xFF), 12, 13] ) - canPhysicalLayer.fireListeners(frame) # from previously seen alias + canPhysicalLayer.sendFrameAfter(frame) # from previously seen alias self.assertEqual(len(messageLayer.receivedMessages), 2) # ^ startup plus one message forwarded @@ -422,26 +429,25 @@ def testMultiFrameAddressedData(self): Test message in 3 frames ''' canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01"), - require_remote_nodes=False) + canLink = CanLinkLayerSimulation( + canPhysicalLayer, getLocalNodeID(), require_remote_nodes=False) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() - while canLink.pollState() != CanLink.State.Permitted: - precise_sleep(.02) + canLink.waitForReady() # map an alias we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) - canPhysicalLayer.fireListeners(amd) + canPhysicalLayer.sendFrameAfter(amd) ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) frame = CanFrame(0x19488, 0x247) # Verify Node ID Addressed frame.data = bytearray([(((ourAlias & 0x700) >> 8) | 0x10), (ourAlias & 0xFF), 1, 2]) # ^ start not end - canPhysicalLayer.fireListeners(frame) # from previously seen alias + canPhysicalLayer.sendFrameAfter(frame) # from previously seen alias self.assertEqual(len(messageLayer.receivedMessages), 1) # ^ startup only, no message forwarded yet @@ -450,7 +456,7 @@ def testMultiFrameAddressedData(self): frame.data = bytearray([(((ourAlias & 0x700) >> 8) | 0x20), (ourAlias & 0xFF), 3, 4]) # ^ end, not start - canPhysicalLayer.fireListeners(frame) # from previously seen alias + canPhysicalLayer.sendFrameAfter(frame) # from previously seen alias self.assertEqual(len(messageLayer.receivedMessages), 2) # ^ startup plus one message forwarded @@ -466,26 +472,25 @@ def testMultiFrameAddressedData(self): def testSimpleDatagram(self): # Test start=yes, end=yes frame canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01"), - require_remote_nodes=False) + canLink = CanLinkLayerSimulation( + canPhysicalLayer, getLocalNodeID(), require_remote_nodes=False) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() - while canLink.pollState() != CanLink.State.Permitted: - precise_sleep(.02) + canLink.waitForReady() # map two aliases we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) - canPhysicalLayer.fireListeners(amd) + canPhysicalLayer.sendFrameAfter(amd) amd = CanFrame(0x0701, 0x123) amd.data = bytearray([6, 5, 4, 3, 2, 1]) - canPhysicalLayer.fireListeners(amd) + canPhysicalLayer.sendFrameAfter(amd) frame = CanFrame(0x1A123, 0x247) # single frame datagram frame.data = bytearray([10, 11, 12, 13]) - canPhysicalLayer.fireListeners(frame) # from previously seen alias + canPhysicalLayer.sendFrameAfter(frame) # from previously seen alias self.assertEqual(len(messageLayer.receivedMessages), 2) # ^ startup plus one message forwarded @@ -505,31 +510,31 @@ def testSimpleDatagram(self): # Test start=yes, end=yes frame def testMultiFrameDatagram(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01"), require_remote_nodes=False) + canLink = CanLinkLayerSimulation( + canPhysicalLayer, getLocalNodeID(), require_remote_nodes=False) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() - while canLink.pollState() != CanLink.State.Permitted: - precise_sleep(.02) + canLink.waitForReady() # map two aliases we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) - canPhysicalLayer.fireListeners(amd) + canPhysicalLayer.sendFrameAfter(amd) amd = CanFrame(0x0701, 0x123) amd.data = bytearray([6, 5, 4, 3, 2, 1]) - canPhysicalLayer.fireListeners(amd) + canPhysicalLayer.sendFrameAfter(amd) frame = CanFrame(0x1B123, 0x247) # single frame datagram frame.data = bytearray([10, 11, 12, 13]) - canPhysicalLayer.fireListeners(frame) # from previously seen alias + canPhysicalLayer.sendFrameAfter(frame) # from previously seen alias frame = CanFrame(0x1C123, 0x247) # single frame datagram frame.data = bytearray([20, 21, 22, 23]) - canPhysicalLayer.fireListeners(frame) # from previously seen alias + canPhysicalLayer.sendFrameAfter(frame) # from previously seen alias frame = CanFrame(0x1D123, 0x247) # single frame datagram frame.data = bytearray([30, 31, 32, 33]) - canPhysicalLayer.fireListeners(frame) # from previously seen alias + canPhysicalLayer.sendFrameAfter(frame) # from previously seen alias self.assertEqual(len(messageLayer.receivedMessages), 2) # ^ startup plus one message forwarded @@ -557,10 +562,10 @@ def testMultiFrameDatagram(self): def testZeroLengthDatagram(self): canPhysicalLayer = PhyMockLayer() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) - message = Message(MTI.Datagram, NodeID("05.01.01.01.03.01"), - NodeID("05.01.01.01.03.01")) + message = Message(MTI.Datagram, getLocalNodeID(), + getLocalNodeID()) canLink.sendMessage(message) @@ -571,10 +576,10 @@ def testZeroLengthDatagram(self): def testOneFrameDatagram(self): canPhysicalLayer = PhyMockLayer() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) - message = Message(MTI.Datagram, NodeID("05.01.01.01.03.01"), - NodeID("05.01.01.01.03.01"), + message = Message(MTI.Datagram, getLocalNodeID(), + getLocalNodeID(), bytearray([1, 2, 3, 4, 5, 6, 7, 8])) canLink.sendMessage(message) @@ -588,10 +593,10 @@ def testOneFrameDatagram(self): def testTwoFrameDatagram(self): canPhysicalLayer = PhyMockLayer() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) - message = Message(MTI.Datagram, NodeID("05.01.01.01.03.01"), - NodeID("05.01.01.01.03.01"), + message = Message(MTI.Datagram, getLocalNodeID(), + getLocalNodeID(), bytearray([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16])) @@ -611,10 +616,10 @@ def testTwoFrameDatagram(self): def testThreeFrameDatagram(self): # FIXME: Why was testThreeFrameDatagram named same? What should it be? canPhysicalLayer = PhyMockLayer() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) - message = Message(MTI.Datagram, NodeID("05.01.01.01.03.01"), - NodeID("05.01.01.01.03.01"), + message = Message(MTI.Datagram, getLocalNodeID(), + getLocalNodeID(), bytearray([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19])) @@ -637,10 +642,10 @@ def testThreeFrameDatagram(self): # MARK: - Test Remote Node Alias Tracking def testAmdAmrSequence(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(canPhysicalLayer, NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) - canPhysicalLayer.fireListeners(CanFrame(0x0701, ourAlias+1)) + canPhysicalLayer.sendFrameAfter(CanFrame(0x0701, ourAlias+1)) # ^ AMD from some other alias self.assertEqual(len(canLink.aliasToNodeID), 1) @@ -649,7 +654,7 @@ def testAmdAmrSequence(self): self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) # ^ nothing back down to CAN - canPhysicalLayer.fireListeners(CanFrame(0x0703, ourAlias+1)) + canPhysicalLayer.sendFrameAfter(CanFrame(0x0703, ourAlias+1)) # ^ AMR from some other alias self.assertEqual(len(canLink.aliasToNodeID), 0) @@ -661,7 +666,7 @@ def testAmdAmrSequence(self): # MARK: - Data size handling def testSegmentAddressedDataArray(self): - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(PhyMockLayer(), getLocalNodeID()) # no data self.assertEqual( @@ -707,7 +712,7 @@ def testSegmentAddressedDataArray(self): canLink.onDisconnect() def testSegmentDatagramDataArray(self): - canLink = CanLink(NodeID("05.01.01.01.03.01")) + canLink = CanLinkLayerSimulation(PhyMockLayer(), getLocalNodeID()) # no data self.assertEqual( @@ -762,3 +767,32 @@ def testEnum(self): usedValues.add(entry.value) # print('{} = {}'.format(entry.name, entry.value)) self.assertIsInstance(entry, int) + + +if __name__ == '__main__': + # unittest.main() + # For debugging a test that was hanging: + testCase = TestCanLinkClass() + count = 0 + failedCount = 0 + exceptions = [] + errors = [] + testCase.testMultiFrameDatagram() + for name in dir(testCase): + if name.startswith("test"): + fn = getattr(testCase, name) + try: + fn() # Look at def test_* below if tracebacks start here + count += 1 + except AssertionError as ex: + # raise ex + error = name + ": " + formatted_ex(ex) + # print(error) + failedCount += 1 + exceptions.append(ex) + errors.append(error) + # for ex in exceptions: + # print(formatted_ex(ex)) + for error in errors: + print(error) + print("{} test(s) passed.".format(count)) diff --git a/tests/test_canphysicallayer.py b/tests/test_canphysicallayer.py index 4a3a7c6..04edaee 100644 --- a/tests/test_canphysicallayer.py +++ b/tests/test_canphysicallayer.py @@ -12,11 +12,19 @@ class TestCanPhysicalLayerClass(unittest.TestCase): def receiveListener(self, frame): self.received = True + def handleReceivedFrame(self, frame): + pass + + def handleSentFrame(self, frame): + pass + def testReceipt(self): self.received = False frame = CanFrame(0x000, bytearray()) receiver = self.receiveListener layer = CanPhysicalLayer() + layer.onReceivedFrame = self.handleReceivedFrame + layer.onSentFrame = self.handleSentFrame layer.registerFrameReceivedListener(receiver) layer.fireListeners(frame) diff --git a/tests/test_canphysicallayergridconnect.py b/tests/test_canphysicallayergridconnect.py index 4bfa09b..69dc4c2 100644 --- a/tests/test_canphysicallayergridconnect.py +++ b/tests/test_canphysicallayergridconnect.py @@ -1,5 +1,7 @@ +from typing import Callable import unittest +from openlcb import emit_cast from openlcb.canbus.canphysicallayergridconnect import ( GC_END_BYTE, CanPhysicalLayerGridConnect, @@ -13,21 +15,30 @@ class PhysicalLayerMock(CanPhysicalLayerGridConnect): # def frameSocketSendDummy(self, frame): def __init__(self): CanPhysicalLayerGridConnect.__init__(self) - self.registerFrameQueuedListener(self.captureString) + # ^ Sets onQueuedFrame on None, so set it afterward: + self.onQueuedFrame = self.captureString - def captureString(self, packet): + def captureString(self, frame): # formerly was in CanPhysicalLayerGridConnectTest - # but there isn't a send callback anymore - # (to avoid port contention in issue #62) - # just a physical layer. - self. capturedFrame = packet - self. capturedFrame.encoder = self.gc + # but there isn't a send callback anymore + # (to avoid port contention in issue #62) + # just a physical layer. + assert isinstance(frame, CanFrame), \ + "CanFrame expected, got {}".format(emit_cast(frame)) + self.capturedFrame = frame + self.capturedFrame.encoder = self + self.capturedString = frame.encodeAsString() def onSentFrame(self, frame): pass # NOTE: not patching this method to be canLink.handleSentFrame # since testing only physical layer not link layer. + def onReceivedFrame(self, frame): + pass + # NOTE: not patching this method to be canLink.handleReceivedFrame + # since testing only physical layer not link layer. + class CanPhysicalLayerGridConnectTest(unittest.TestCase): @@ -47,31 +58,31 @@ def receiveListener(self, frame): self.receivedFrames += [frame] def testCID4Sent(self): - self.gc = CanPhysicalLayerGridConnect() + self.gc = PhysicalLayerMock() frame = CanFrame(4, NodeID(0x010203040506), 0xABC) # self.linklayer.sendFrameAfter(frame) # ^ It will use physical layer to encode and enqueue it # (or send if using Realtime subclass of PhysicalLayer) # but we are testing physical layer, so: self.gc.sendFrameAfter(frame) - + assert self.gc.onQueuedFrame is not None # self.assertEqual(self.capturedString, ":X14506ABCN;\n") - self.assertEqual(self.physicalLayer.capturedFrame.encodeAsString(), + self.assertEqual(self.gc.capturedString, ":X14506ABCN;\n") def testVerifyNodeSent(self): - self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) + self.gc = PhysicalLayerMock() frame = CanFrame(0x19170, 0x365, bytearray([0x02, 0x01, 0x12, 0xFE, 0x05, 0x6C])) - self.gc.sendFrameAfter( - self.gc.encode(frame) - ) + frame.encoder = self.gc + assert self.gc.onQueuedFrame is not None + self.gc.sendFrameAfter(frame) # self.assertEqual(self.capturedString, ":X19170365N020112FE056C;\n") - self.assertEqual(self.physicalLayer.capturedFrame, + self.assertEqual(self.gc.capturedString, ":X19170365N020112FE056C;\n") def testOneFrameReceivedExactlyHeaderOnly(self): - self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) + self.gc = PhysicalLayerMock() self.gc.registerFrameReceivedListener(self.receiveListener) bytes = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, @@ -85,7 +96,7 @@ def testOneFrameReceivedExactlyHeaderOnly(self): ) def testOneFrameReceivedExactlyWithData(self): - self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) + self.gc = PhysicalLayerMock() self.gc.registerFrameReceivedListener(self.receiveListener) bytes = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x31, 0x42, 0x30, 0x33, 0x36, 0x35, @@ -103,7 +114,7 @@ def testOneFrameReceivedExactlyWithData(self): ) def testOneFrameReceivedHeaderOnlyTwice(self): - self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) + self.gc = PhysicalLayerMock() self.gc.registerFrameReceivedListener(self.receiveListener) bytes = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, 0x35, @@ -117,7 +128,7 @@ def testOneFrameReceivedHeaderOnlyTwice(self): CanFrame(0x19490365, bytearray())) def testOneFrameReceivedHeaderOnlyPlusPartOfAnother(self): - self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) + self.gc = PhysicalLayerMock() self.gc.registerFrameReceivedListener(self.receiveListener) bytes = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, 0x36, @@ -137,7 +148,7 @@ def testOneFrameReceivedHeaderOnlyPlusPartOfAnother(self): CanFrame(0x19490365, bytearray())) def testOneFrameReceivedInTwoChunks(self): - self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) + self.gc = PhysicalLayerMock() self.gc.registerFrameReceivedListener(self.receiveListener) bytes1 = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x31, 0x37, 0x30, 0x33, 0x36, 0x35, @@ -158,7 +169,7 @@ def testOneFrameReceivedInTwoChunks(self): ) def testSequence(self): - self.gc = CanPhysicalLayerGridConnect(self.frameSocketSendDummy) + self.gc = PhysicalLayerMock() self.gc.registerFrameReceivedListener(self.receiveListener) bytes = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, diff --git a/tests/test_datagramservice.py b/tests/test_datagramservice.py index 8c917fc..22cebf0 100644 --- a/tests/test_datagramservice.py +++ b/tests/test_datagramservice.py @@ -9,11 +9,24 @@ from openlcb.mti import MTI from openlcb.nodeid import NodeID from openlcb.message import Message +from openlcb.physicallayer import PhysicalLayer + + +class MockPhysicalLayer(PhysicalLayer): + pass class LinkMockLayer(LinkLayer): sentMessages = [] + class State: + Initial = 0 + Disconnected = 1 + Permitted = 2 + + DisconnectedState = State.Disconnected + + def sendMessage(self, message): LinkMockLayer.sentMessages.append(message) @@ -24,7 +37,9 @@ def _onStateChanged(self, oldState, newState): class DatagramServiceTest(unittest.TestCase): def setUp(self): - self.service = DatagramService(LinkMockLayer(NodeID(12))) + self.service = DatagramService( + LinkMockLayer(MockPhysicalLayer(), NodeID(12)) + ) LinkMockLayer.sentMessages = [] self.received = False self.readMemos = [] diff --git a/tests/test_linklayer.py b/tests/test_linklayer.py index 209aefc..b37a297 100644 --- a/tests/test_linklayer.py +++ b/tests/test_linklayer.py @@ -5,6 +5,11 @@ from openlcb.mti import MTI from openlcb.message import Message from openlcb.nodeid import NodeID +from openlcb.physicallayer import PhysicalLayer + + +class MockPhysicalLayer(PhysicalLayer): + pass class TestLinkLayerClass(unittest.TestCase): @@ -19,7 +24,10 @@ def testReceipt(self): self.received = False msg = Message(MTI.Initialization_Complete, NodeID(12), NodeID(21)) receiver = self.receiveListener - layer = LinkLayer(NodeID(100)) + layer = LinkLayer( + MockPhysicalLayer(), + NodeID(100) + ) layer.registerMessageReceivedListener(receiver) layer.fireListeners(msg) diff --git a/tests/test_localnodeprocessor.py b/tests/test_localnodeprocessor.py index d0ccfec..23f9823 100644 --- a/tests/test_localnodeprocessor.py +++ b/tests/test_localnodeprocessor.py @@ -5,13 +5,25 @@ from openlcb.linklayer import LinkLayer from openlcb.mti import MTI from openlcb.message import Message +from openlcb.physicallayer import PhysicalLayer from openlcb.pip import PIP from openlcb.node import Node +class MockPhysicalLayer(PhysicalLayer): + pass + + class LinkMockLayer(LinkLayer): sentMessages = [] + class State: + Initial = 0 + Disconnected = 1 + Permitted = 2 + + DisconnectedState = State.Disconnected + def sendMessage(self, message): LinkMockLayer.sentMessages.append(message) @@ -25,7 +37,9 @@ class TestLocalNodeProcessorClass(unittest.TestCase): def setUp(self): self.node21 = Node(NodeID(21)) LinkMockLayer.sentMessages = [] - self.processor = LocalNodeProcessor(LinkMockLayer(NodeID(100))) + self.processor = LocalNodeProcessor( + LinkMockLayer(MockPhysicalLayer(), NodeID(100)) + ) def testLinkUp(self): self.node21.state = Node.State.Uninitialized diff --git a/tests/test_memoryservice.py b/tests/test_memoryservice.py index 7d43287..eef5d0d 100644 --- a/tests/test_memoryservice.py +++ b/tests/test_memoryservice.py @@ -5,6 +5,8 @@ import unittest from logging import getLogger + +from openlcb.physicallayer import PhysicalLayer if __name__ == "__main__": logger = getLogger(__file__) else: @@ -38,7 +40,19 @@ ) +class MockPhysicalLayer(PhysicalLayer): + pass + + class LinkMockLayer(LinkLayer): + + class State: + Initial = 0 + Disconnected = 1 + Permitted = 2 + + DisconnectedState = State.Disconnected + sentMessages = [] def sendMessage(self, message): @@ -61,7 +75,9 @@ def setUp(self): LinkMockLayer.sentMessages = [] self.returnedMemoryReadMemo = [] self.returnedMemoryWriteMemo = [] - self.dService = DatagramService(LinkMockLayer(NodeID(12))) + self.dService = DatagramService( + LinkMockLayer(MockPhysicalLayer(), NodeID(12)) + ) self.mService = MemoryService(self.dService) def testReturnCyrillicStrings(self): diff --git a/tests/test_platformextras.py b/tests/test_platformextras.py index 57bf0d0..78d99b4 100644 --- a/tests/test_platformextras.py +++ b/tests/test_platformextras.py @@ -47,3 +47,7 @@ def test_sysdirs(self): # Can't think of anything else to test, since # SysDirs is the authority on the values # (and result is platform-specific). + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_remotenodeprocessor.py b/tests/test_remotenodeprocessor.py index f9778e1..dfd99dd 100644 --- a/tests/test_remotenodeprocessor.py +++ b/tests/test_remotenodeprocessor.py @@ -1,5 +1,6 @@ import unittest +from openlcb.physicallayer import PhysicalLayer from openlcb.remotenodeprocessor import RemoteNodeProcessor from openlcb.canbus.canlink import CanLink @@ -11,11 +12,15 @@ from openlcb.pip import PIP +class MockPhysicalLayer(PhysicalLayer): + pass + + class TesRemoteNodeProcessorClass(unittest.TestCase): def setUp(self) : self.node21 = Node(NodeID(21)) - self.canLink = CanLink(NodeID(100)) + self.canLink = CanLink(MockPhysicalLayer(), NodeID(100)) self.processor = RemoteNodeProcessor(self.canLink) def tearDown(self): @@ -160,7 +165,8 @@ def testConsumerIdentifiedDifferentNode(self) : def testNewNodeSeen(self) : self.node21.state = Node.State.Initialized - msg = Message(MTI.New_Node_Seen, NodeID(21), bytearray()) + msg = Message(MTI.New_Node_Seen, NodeID(21), NodeID(0), + data=bytearray()) self.processor.process(msg, self.node21) diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 8785671..2fa7ade 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -58,3 +58,7 @@ def test_gridconnectobserver(self): self.assertFalse(scanner.hasNext()) # False:ensure _delimiter consumed self.assertEqual(scanner._buffer[0], bytes[-1]) # last byte is after # delimiter, so it should remain. + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_tcplink.py b/tests/test_tcplink.py index 7892afb..b2d659b 100644 --- a/tests/test_tcplink.py +++ b/tests/test_tcplink.py @@ -1,5 +1,6 @@ import unittest +from openlcb.physicallayer import PhysicalLayer from openlcb.realtimephysicallayer import RealtimePhysicalLayer from openlcb.tcplink.tcplink import TcpLink @@ -8,7 +9,11 @@ from openlcb.nodeid import NodeID -class TcpMockLayer(): +# class MockPhysicalLayer(PhysicalLayer): +# pass + + +class TcpMockLayer(): # or TcpMockLayer(PortInterface): def __init__(self): self.receivedText = [] @@ -31,7 +36,7 @@ def testLinkUpSequence(self): messageLayer = MessageMockLayer() tcpLayer = TcpMockLayer() - linkLayer = TcpLink(RealtimePhysicalLayer, NodeID(100)) + linkLayer = TcpLink(RealtimePhysicalLayer(tcpLayer), NodeID(100)) linkLayer.registerMessageReceivedListener(messageLayer.receiveMessage) linkLayer.linkUp() @@ -43,9 +48,8 @@ def testLinkRestartSequence(self): messageLayer = MessageMockLayer() tcpLayer = TcpMockLayer() - linkLayer = TcpLink(NodeID(100)) + linkLayer = TcpLink(RealtimePhysicalLayer(tcpLayer), NodeID(100)) linkLayer.registerMessageReceivedListener(messageLayer.receiveMessage) - linkLayer.linkPhysicalLayer(tcpLayer.send) linkLayer.linkRestarted() @@ -56,9 +60,8 @@ def testLinkDownSequence(self): messageLayer = MessageMockLayer() tcpLayer = TcpMockLayer() - linkLayer = TcpLink(NodeID(100)) + linkLayer = TcpLink(RealtimePhysicalLayer(tcpLayer), NodeID(100)) linkLayer.registerMessageReceivedListener(messageLayer.receiveMessage) - linkLayer.linkPhysicalLayer(tcpLayer.send) linkLayer.linkDown() @@ -69,9 +72,8 @@ def testOneMessageOnePartOneClump(self) : messageLayer = MessageMockLayer() tcpLayer = TcpMockLayer() - linkLayer = TcpLink(NodeID(100)) + linkLayer = TcpLink(RealtimePhysicalLayer(tcpLayer), NodeID(100)) linkLayer.registerMessageReceivedListener(messageLayer.receiveMessage) - linkLayer.linkPhysicalLayer(tcpLayer.send) messageText = bytearray([ 0x80, 0x00, # full message @@ -92,9 +94,8 @@ def testOneMessageOnePartTwoClumps(self) : messageLayer = MessageMockLayer() tcpLayer = TcpMockLayer() - linkLayer = TcpLink(NodeID(100)) + linkLayer = TcpLink(RealtimePhysicalLayer(tcpLayer), NodeID(100)) linkLayer.registerMessageReceivedListener(messageLayer.receiveMessage) - linkLayer.linkPhysicalLayer(tcpLayer.send) messageText = bytearray([ 0x80, 0x00, # full message @@ -119,9 +120,8 @@ def testOneMessageOnePartThreeClumps(self) : messageLayer = MessageMockLayer() tcpLayer = TcpMockLayer() - linkLayer = TcpLink(NodeID(100)) + linkLayer = TcpLink(RealtimePhysicalLayer(tcpLayer), NodeID(100)) linkLayer.registerMessageReceivedListener(messageLayer.receiveMessage) - linkLayer.linkPhysicalLayer(tcpLayer.send) messageText = bytearray([ 0x80, 0x00, # full message @@ -150,9 +150,8 @@ def testTwoMessageOnePartTwoClumps(self) : messageLayer = MessageMockLayer() tcpLayer = TcpMockLayer() - linkLayer = TcpLink(NodeID(100)) + linkLayer = TcpLink(RealtimePhysicalLayer(tcpLayer), NodeID(100)) linkLayer.registerMessageReceivedListener(messageLayer.receiveMessage) - linkLayer.linkPhysicalLayer(tcpLayer.send) messageText = bytearray([ 0x80, 0x00, # full message @@ -185,9 +184,8 @@ def testOneMessageTwoPartsOneClump(self) : messageLayer = MessageMockLayer() tcpLayer = TcpMockLayer() - linkLayer = TcpLink(NodeID(100)) + linkLayer = TcpLink(RealtimePhysicalLayer(tcpLayer), NodeID(100)) linkLayer.registerMessageReceivedListener(messageLayer.receiveMessage) - linkLayer.linkPhysicalLayer(tcpLayer.send) messageText = bytearray([ 0x80, 0x40, # part 1 @@ -214,9 +212,8 @@ def testOneMessageThreePartsOneClump(self) : messageLayer = MessageMockLayer() tcpLayer = TcpMockLayer() - linkLayer = TcpLink(NodeID(100)) + linkLayer = TcpLink(RealtimePhysicalLayer(tcpLayer), NodeID(100)) linkLayer.registerMessageReceivedListener(messageLayer.receiveMessage) - linkLayer.linkPhysicalLayer(tcpLayer.send) messageText = bytearray([ 0x80, 0x40, # part 1 @@ -249,9 +246,8 @@ def testSendGlobalMessage(self) : messageLayer = MessageMockLayer() tcpLayer = TcpMockLayer() - linkLayer = TcpLink(NodeID(100)) + linkLayer = TcpLink(RealtimePhysicalLayer(tcpLayer), NodeID(100)) linkLayer.registerMessageReceivedListener(messageLayer.receiveMessage) - linkLayer.linkPhysicalLayer(tcpLayer.send) message = Message(MTI.Verify_NodeID_Number_Global, NodeID(0x123), None, NodeID(0x321).toArray()) @@ -280,9 +276,8 @@ def testSendAddressedMessage(self) : messageLayer = MessageMockLayer() tcpLayer = TcpMockLayer() - linkLayer = TcpLink(NodeID(100)) + linkLayer = TcpLink(RealtimePhysicalLayer(tcpLayer), NodeID(100)) linkLayer.registerMessageReceivedListener(messageLayer.receiveMessage) - linkLayer.linkPhysicalLayer(tcpLayer.send) message = Message(MTI.Verify_NodeID_Number_Addressed, NodeID(0x123), NodeID(0x321), NodeID(0x321).toArray()) From b4792599ff102aeabe4a75054ce9540443ec2cbd Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 8 May 2025 17:15:21 -0400 Subject: [PATCH 56/99] Improve a docstring and commment. --- openlcb/tcplink/tcpsocket.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openlcb/tcplink/tcpsocket.py b/openlcb/tcplink/tcpsocket.py index fb6a342..8bbc215 100644 --- a/openlcb/tcplink/tcpsocket.py +++ b/openlcb/tcplink/tcpsocket.py @@ -80,12 +80,15 @@ def _receive(self) -> bytes: '''Receive one or more bytes and return as an [int] Blocks until at least one byte is received, but may return more. + See also public receive method: Do not overload that, since + asserts no overlapping _receive call! + Returns: list(int): one or more bytes, converted to a list of ints. ''' - # MSGLEN feature is only a convenience for CLI, - # so was moved to GridConnectObserver. - # public receive (do not overload) asserts no overlapping call + # MSGLEN = 35 feature is only a convenience for CLI, so was + # moved to GridConnectObserver (use ";" not len 35 though). + try: data = self._device.recv(128) except BlockingIOError: From 1a597a197b9ea5757dc9c824ed4665798144e141 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 15 May 2025 18:50:16 -0400 Subject: [PATCH 57/99] Restart alias reservation if error occurs during related states as per Standard, remove require_remote_nodes since that only occurs during collision (See CAN Frame Transfer - Standard), and remove _previousAliasCollisionCount to avoid a potential race condition (Instead, rely on caller of pollFrame to check validity using isDuplicateAlias)--Fix #62. --- examples/example_cdi_access.py | 27 +-- examples/example_remote_nodes.py | 7 +- openlcb/canbus/canframe.py | 16 +- openlcb/canbus/canlink.py | 198 +++++++++---------- openlcb/canbus/canlinklayersimulation.py | 91 +++++++-- openlcb/canbus/canphysicallayer.py | 5 +- openlcb/canbus/canphysicallayersimulation.py | 42 ++-- openlcb/datagramservice.py | 3 +- openlcb/dispatcher.py | 6 +- openlcb/linklayer.py | 8 +- openlcb/physicallayer.py | 37 +++- openlcb/realtimephysicallayer.py | 5 +- python-openlcb.code-workspace | 2 + tests/test_canlink.py | 158 ++++++++------- 14 files changed, 362 insertions(+), 243 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index e90fad5..9f8ee08 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -281,13 +281,8 @@ def pumpEvents(): if canLink.pollState() == CanLink.State.Permitted: break assert canLink.getWaitForAliasResponseStart() is not None, \ - "openlcb didn't send the 7,6,5,4 CID frames (state={})".format(canLink.getState()) - if default_timer() - canLink.getWaitForAliasResponseStart() > CanLink.ALIAS_RESPONSE_DELAY: - # 200ms = standard wait time for responses - if not canLink.require_remote_nodes: - raise TimeoutError( - "In Standard require_remote_nodes=False mode," - " but failed to proceed to Permitted state.") + ("openlcb didn't send the 7,6,5,4 CID frames (state={})" + .format(canLink.getState())) precise_sleep(.02) print(" SENT frames : link up") @@ -300,11 +295,19 @@ def memoryRead(): """ import time time.sleep(.21) - # ^ 200ms is the time span in which all nodes must reply to ensure - # our alias is ok according to the LCC CAN Frame Transfer - # Standard, but wait slightly more for OS latency. - # - Then wait longer below if there was a failure/retry, before - # trying to use the LCC network: + # ^ 200ms is the time span in which a node with the same alias + # (*only if it has the same alias*) must reply to ensure our alias + # is ok according to the LCC CAN Frame Transfer Standard. + # - The countdown does not start until after the socket loop + # calls onSentFrame. This ensures that nodes had a chance to + # respond (the Standard only states to wait after sending, so + # any latency after send is the responsibility of the Standard). + # - Then wait longer below if there was a failure/retry: + # According to the Standard any error or collision (See + # processCollision in this implementation) must restart the + # sequence and prevent CanLink.State.Permitted until the + # sequence completes without error), before trying to use the + # LCC network: while canLink._state != CanLink.State.Permitted: # Would only take more than ~200ms (possibly a few nanoseconds # more for latency on the part of this program itself) diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index c7805f6..d787896 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -80,8 +80,7 @@ def printMessage(msg): readQueue.put(msg) -canLink = CanLink(physicalLayer, NodeID(settings['localNodeID']), - require_remote_nodes=True) +canLink = CanLink(physicalLayer, NodeID(settings['localNodeID'])) canLink.registerMessageReceivedListener(printMessage) # create a node and connect it update @@ -141,9 +140,7 @@ def pumpEvents(): print("* QUEUE Message: link up...") physicalLayer.physicalLayerUp() -print(" QUEUED Message: link up...waiting for alias reservation" - " (canLink.require_remote_nodes={})..." - .format(canLink.require_remote_nodes)) +print(" QUEUED Message: link up...waiting for alias reservation...") # These checks are for debugging. See other examples for simpler pollState loop cidSequenceStart = default_timer() diff --git a/openlcb/canbus/canframe.py b/openlcb/canbus/canframe.py index 19fb09d..3b03a6f 100644 --- a/openlcb/canbus/canframe.py +++ b/openlcb/canbus/canframe.py @@ -33,6 +33,11 @@ class CanFrame: - header, data: If 2nd arg is list. - control, alias, data: If 2nd arg is int. + Attributes: + encoder (object): A required (in non-test scenarios) encoder + object (set to a PhysicalLayer subclass, since that layer + determines the encoding). Must implement FrameEncoder. + Args: N_cid (int, optional): Frame sequence number (becomes first 3 bits of 15-bit Check ID (CID) frame). 4 to 7 inclusive @@ -51,14 +56,14 @@ class CanFrame: control (int, optional): Frame type (1: OpenLCB = 0x0800_000, 0: CAN Control Frame) | Content Field (3 bits, 3 nibbles, mask = 0x07FF_F000). - encoder (object): a required encoder object (set to a - PhysicalLayer subclass, since that layer determines - the encoding). Must have an encodeFrameAsString method that - accepts a CanFrame. afterSendState (CanLink.State, optional): The frame incurs a new state in the CanLink instance *after* socket send is *complete*, at which time the PortInterface should call setState(frame.afterSendState). + reservation (int): The reservation sequence attempt (incremented + on each attempt) to allow clearReservation to work (cancel + previous reservation without race condition related to + _send_frames). """ ARG_LISTS = [ @@ -94,9 +99,10 @@ def encodeAsBytes(self) -> bytes: def alias(self) -> int: return self._alias - def __init__(self, *args, afterSendState=None): + def __init__(self, *args, afterSendState=None, reservation=None): self.afterSendState = afterSendState self.encoder = NoEncoder() + self.reservation = reservation arg1 = None arg2 = None arg3 = None diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 0f5174f..3c50c25 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -25,6 +25,7 @@ from openlcb import emit_cast, formatted_ex, precise_sleep from openlcb.canbus.canframe import CanFrame +from openlcb.canbus.canphysicallayer import CanPhysicalLayer from openlcb.canbus.controlframe import ControlFrame from openlcb.linklayer import LinkLayer @@ -40,12 +41,13 @@ class CanLink(LinkLayer): """CAN link layer (manage stack's link state). Attributes: - ALIASES_RECEIVED_TIMEOUT (float): (seconds) CAN Frame Transfer - - Standard says to wait 200 ms for collisions, and if there - are no replies, the alias is good, otherwise increment and - restart alias reservation. - - However, in this implementation, require_remote_nodes - is True by default (See require_remote_nodes). + ALIASES_RECEIVED_TIMEOUT (float): (seconds) Section 6.2.1 of CAN + Frame Transfer - Standard says to wait 200 ms for + collisions, and if there are no replies, the alias is good + (nodes are only required to reply if they collide, as per + section 6.2.5 of CAN Frame Transfer - Standard). If a reply + has the same alias as self during this time, processCollision + increments alias and restarts reservation (sets lower state). Args: localNodeID (NodeID): The node ID of the device itself @@ -62,28 +64,14 @@ class CanLink(LinkLayer): reserving your own range there with OpenLCB if your application/hardware does not apply to one of those ranges. - require_remote_nodes (bool): If True, getting no external frames - (See isInternal) within ALIASES_RECEIVED_TIMEOUT (seconds) - causes an exception in pollState. Defaults to True, which is - non-standard: - - CAN Frame Transfer - Standard specifies that after 200ms - the node should assume _localAlias is ok (even if there - are 0 responses, in which case assume no other LCC nodes - are connected). - - In this implementation we at least expect an LCC hub - (otherwise there is no hardware connection, or an issue - with socket timing, call order, or another hard-coded - problem in the stack or application). - physicalLayer (PhysicalLayer): The physical layer should - set this member by accepting a CanLink its constructor, - unless that is flipped around and added to this - constructor. See commented linkPhysicalLayer. + physicalLayer (PhysicalLayer): The PhysicalLayer/subclass to + use for sending frames (enqueue them via sendFrameAfter). """ # MIN_STATE_VALUE & MAX_STATE_VALUE are set statically below the # State class declaration: STANDARD_ALIAS_RESPONSE_DELAY = .2 - ALIAS_RESPONSE_DELAY = 5 # See docstring. + ALIAS_RESPONSE_DELAY = STANDARD_ALIAS_RESPONSE_DELAY # See docstring. class State(Enum): """Used as a linux-like "runlevel" @@ -133,13 +121,12 @@ class State(Enum): MIN_STATE_VALUE = min(entry.value for entry in State) MAX_STATE_VALUE = max(entry.value for entry in State) - def __init__(self, physicalLayer: PhysicalLayer, localNodeID, - require_remote_nodes=False): + def __init__(self, physicalLayer: PhysicalLayer, localNodeID): # See class docstring for args - self.physicalLayer = None + self.physicalLayer: CanPhysicalLayer = None # set by super() below + # ^ typically CanPhysicalLayerGridConnect LinkLayer.__init__(self, physicalLayer, localNodeID) self._previousLocalAliasSeed = None - self.require_remote_nodes = require_remote_nodes self._waitingForAliasStart = None self._localAliasSeed = localNodeID.value self._localAlias = self.createAlias12(self._localAliasSeed) @@ -148,7 +135,6 @@ def __init__(self, physicalLayer: PhysicalLayer, localNodeID, self._frameCount = 0 self._aliasCollisionCount = 0 self._errorCount = 0 - self._previousAliasCollisionCount = None self._previousFrameCount = None self.aliasToNodeID = {} self.nodeIdToAlias = {} @@ -156,6 +142,7 @@ def __init__(self, physicalLayer: PhysicalLayer, localNodeID, self.duplicateAliases = [] self.nextInternallyAssignedNodeID = 1 self._state = CanLink.State.Initial + self._reservation = -1 # incremented on use. # This method may never actually be necessary, as # sendMessage uses nodeIdToAlias (which has localNodeID @@ -203,15 +190,21 @@ def getLocalAlias(self): # assert isinstance(self._state, CanLink.State) # return self._state == CanLink.State.Permitted - def isDuplicateAlias(self, alias): - if not isinstance(alias, int): - raise NotImplementedError( - "Can't check for duplicate due to alias not stored as int." - " bytearray parsing must be implemented in CanFrame" - " constructor if this markDuplicateAlias scenario is valid" - " (alias={})." - .format(emit_cast(alias))) - return alias in self.duplicateAliases + def isBadReservation(self, frame): + if frame.reservation is None: + return False + return frame.reservation < self._reservation + + # def isDuplicateAlias(self, alias): + # if not isinstance(alias, int): + # raise NotImplementedError( + # "Can't check for duplicate due to alias not stored as int." + # " bytearray parsing must be implemented in CanFrame" + # " constructor if this markDuplicateAlias scenario is valid" + # " (alias={})." + # .format(emit_cast(alias))) + # return alias in self.duplicateAliases + # ^ Commented since isBadReservation handles both collision and error. # Commented since instead, socket code should call linkLayerUp and linkLayerDown. # Constructors should construct the openlcb stack. @@ -316,6 +309,11 @@ def receiveListener(self, frame): ControlFrame.EIR2, ControlFrame.EIR3): self._errorCount += 1 + if self.isRunningAliasReservation(): + # Restart alias reservation process if an + # error occurs during it, as per section + # 6.2.1 of CAN Frame Transfer - Standard. + self.defineAndReserveAlias() elif control_frame == ControlFrame.Data: # NOTE: We may process other bits of frame.header # that were stripped from control_frame @@ -333,6 +331,17 @@ def receiveListener(self, frame): "Invalid control frame format 0x{:08X}" .format(control_frame)) + def isRunningAliasReservation(self): + return self._state in ( + CanLink.State.EnqueueAliasAllocationRequest, + CanLink.State.BusyLocalCIDSequence, + CanLink.State.WaitingForSendCIDSequence, + CanLink.State.WaitForAliases, + CanLink.State.EnqueueAliasReservation, + CanLink.State.BusyLocalReserveID, + CanLink.State.WaitingForSendReserveID + ) + def handleReceivedLinkUp(self, frame): """Link started, update state, start process to create alias. LinkUp message will be sent when alias process completes. @@ -464,8 +473,8 @@ def handleReceivedAMD(self, frame): # CanFrame # check for matching node ID, which is a collision nodeID = NodeID(frame.data) if nodeID == self.localNodeID : - print("collide") # collision, restart + print("Alias collision occurred. Restarting alias reservation...") self.processCollision(frame) return # This defines an alias, so store it @@ -929,15 +938,11 @@ def pollState(self): else: if ((default_timer() - self._waitingForAliasStart) > CanLink.ALIAS_RESPONSE_DELAY): - if self.require_remote_nodes: - # keep the current state, in case - # application wants to try again. - raise ConnectionError( - "At least an LCC node was expected within 200ms." - " See require_remote_nodes documentation and" - " only set to True for Standard" - " (permissive) behavior") - # finish the sends for the alias reservation: + # There were no alias collisions (any nodes with the + # same alias are required to respond within this + # time as per Section 6.2.5 of CAN Frame Transfer + # Standard) so finish the sends for the alias + # reservation: self._waitingForAliasStart = None self.setState(CanLink.State.EnqueueAliasReservation) @@ -958,6 +963,12 @@ def defineAndReserveAlias(self): Message instance. The application manages flow and the openlcb stack (this Python module) manages state. """ + if self._reservation > -1: + # If any reservation occurred before, clear it + # (prevent race condition, don't require pollFrame loop + # to check isDuplicateAlias) + self.physicalLayer.clearReservation(self._reservation) + self._reservation += 1 self.setState(CanLink.State.EnqueueAliasAllocationRequest) def _enqueueCIDSequence(self): @@ -977,85 +988,58 @@ def _enqueueCIDSequence(self): # NodeIdToAlias, permitting openlcb to send to those # destinations) self.physicalLayer.sendFrameAfter(CanFrame(7, self.localNodeID, - self._localAlias)) + self._localAlias, + reservation=self._reservation)) self.physicalLayer.sendFrameAfter(CanFrame(6, self.localNodeID, - self._localAlias)) + self._localAlias, + reservation=self._reservation)) self.physicalLayer.sendFrameAfter(CanFrame(5, self.localNodeID, - self._localAlias)) + self._localAlias, + reservation=self._reservation)) self.physicalLayer.sendFrameAfter( CanFrame(4, self.localNodeID, self._localAlias, - afterSendState=CanLink.State.WaitForAliases) + afterSendState=CanLink.State.WaitForAliases, + reservation=self._reservation) ) - self._previousAliasCollisionCount = self._aliasCollisionCount + self._previousErrorCount = self._errorCount self._previousFrameCount = self._frameCount self._previousLocalAliasSeed = self._localAliasSeed self.setState(CanLink.State.WaitingForSendCIDSequence) def _enqueueReserveID(self): """Send Reserve ID (RID) - If no collision after `CanLink.ALIAS_RESPONSE_DELAY`, - but this will not be called in no-response case if - `require_remote_nodes` is `True`. Triggered by CanLink.State.EnqueueAliasReservation + - If no collision during `CanLink.ALIAS_RESPONSE_DELAY`. """ self._waitingForAliasStart = None # done waiting for reply to 7,6,5,4 self.setState(CanLink.State.BusyLocalReserveID) - # precise_sleep(.2) # Waiting 200ms as per section 6.2.1 - # is now done by pollState (application must keep polling after - # sending and receiving data) based on _waitingForAliasStart. - - # See ("Reserving a Node ID Alias") of - # LCC "CAN Frame Transfer" Standard - responseCount = self._frameCount - self._previousFrameCount - if responseCount < 1: - logger.warning( - "sendAliasAllocationSequence may be blocking the receive" - " thread or the network is taking too long to respond" - " (200ms is LCC standard time for all nodes to respond to" - " reservation request. If there any other nodes, this is" - " an error and this method should *not* continue sending" - " Reserve ID (RID) frame)...") - if self._aliasCollisionCount > self._previousAliasCollisionCount: - # processCollision will increment the non-unique alias try - # defineAndReserveAlias again (so stop before completing - # the sequence as per Standard) - logger.warning( - "Cancelled reservation of duplicate local alias seed {}" - " (processCollision increments ID to avoid," - " & restarts sequence)." - .format(self._previousLocalAliasSeed)) - # TODO: maybe raise an exception since we should never get - # here (since CanLink is a state machine now, the state - # leading to this would have been rolled back by processCollision) + # Waiting 200ms as per section 6.2.1 of CAN Frame Transfer - + # Standard is now done by pollState (application must keep + # polling after sending and receiving data) + # - But based on _waitingForAliasStart, so pollState is not a + # blocking call. + + # The frame below must be cancelled by the application/other + # pollFrame loop if there was a collision in the meantime + # (simply don't send the result of pollFrame if + # isDuplicateAlias) + # TODO: ^ Test that. + # NOTE: Below may cause a race condition, but more than + # one thread *must not* be handling send, so this is the + # solution for now: + thisErrorCount = self._errorCount - self._previousErrorCount + if thisErrorCount > 1: + # Restart reservation on error as per section 6.2.1 of + # CAN Frame Transfer - Standard. + # - This is not a collision, so don't increment alias. + print("Error occurred, restarting alias reservation...") + self.defineAndReserveAlias() - return False - if responseCount < 1: - if self.require_remote_nodes: - # TODO: Instead, just do setState(CanLink.State.Inhibited?) - raise ConnectionRefusedError( - "pollState should not set EnqueueAliasReservation when" - " responseCount < 1 and" - " remote_nodes_required={}" - .format(emit_cast(self.require_remote_nodes))) - else: - logger.warning( - "Continuing to send Reservation (RID) anyway" - "(Using Standard behavior, since" - " remote_nodes_required={})" - "--no response, so assuming alias seed {} is unique" - " (If there are any other nodes on the network then" - " thread management, the python-openlcb stack" - " construction or call order," - " or network connection failed!)." - .format(emit_cast(self.require_remote_nodes), - self._localAliasSeed)) - else: - print("Got {} new frame(s) during reservation." - " No collisions, so completing reservation!") self.physicalLayer.sendFrameAfter( CanFrame(ControlFrame.RID.value, self._localAlias, - afterSendState=CanLink.State.NotifyAliasReservation) + afterSendState=CanLink.State.NotifyAliasReservation, + reservation=self._reservation) ) self.setState(CanLink.State.WaitingForSendReserveID) diff --git a/openlcb/canbus/canlinklayersimulation.py b/openlcb/canbus/canlinklayersimulation.py index 0d3459a..5da805a 100644 --- a/openlcb/canbus/canlinklayersimulation.py +++ b/openlcb/canbus/canlinklayersimulation.py @@ -1,9 +1,14 @@ from timeit import default_timer +from logging import getLogger + from openlcb import precise_sleep from openlcb.canbus.canlink import CanLink +logger = getLogger(__name__) + + class CanLinkLayerSimulation(CanLink): # pumpEvents and waitForReady are based on examples # and may be moved to CanLink or Dispatcher @@ -26,32 +31,96 @@ def pumpEvents(self): # pass self.pollState() while True: + # self.physicalLayer must be set by canLink constructor by + # passing a physicalLayer to it. frame = self.physicalLayer.pollFrame() if not frame: break + first = False string = frame.encodeAsString() - # print(" SENT packet: "+string.strip()) + print(" SENT (simulated socket) packet: "+string.strip()) # ^ This is too verbose for this example (each is a # request to read a 64 byte chunks of the CDI XML) # sock.sendString(string) self.physicalLayer.onSentFrame(frame) - def waitForReady(self): - print("[CanLink] waitForReady...") + def waitForReady(self, run_physical_link_up_test=False): + """ + Args: + run_physical_link_up_test (bool): Set to True only + if the last command that ran was + "physicalLayerUp". + Raises: + AssertionError: run_physical_link_up_test is True + but the state is not initially WaitingForSendCIDSequence + or successive states were not triggered by pollState + and onSentFrame. + """ self = self - while self.pollState() != CanLink.State.Permitted: - self.pumpEvents() # provides incoming data to physicalLayer & sends queued - if self.getState() == CanLink.State.WaitForAliases: - self.pumpEvents() # prevent assertion error below, proceed to send. - if self.pollState() == CanLink.State.Permitted: + first = True + state = self.pollState() + print("[CanLinkLayerSimulation] waitForReady...state={}..." + .format(state)) + first_state = state + if run_physical_link_up_test: + assert state == CanLink.State.WaitingForSendCIDSequence + debug_count = 0 + second_state = None + while True: + debug_count += 1 + # print("{}. state: {}".format(debug_count, state)) + if state == CanLink.State.Permitted: + # This could be the while condition, but + # is here so we can monitor it for testing. + break + if first is True: + first = False + if debug_count < 3: + print(" * pumpEvents") + self.pumpEvents() # pass received data to physicalLayer&send queue + if debug_count < 3: + print(" * state: {}".format(state)) + state = self.getState() + if first_state == CanLink.State.WaitingForSendCIDSequence: + # State should be set by onSentFrame (called by + # pumpEvents, or in non-simulation cases, the socket loop + # after dequeued and sent, as the next state is ) + if second_state is None: + assert state == CanLink.State.WaitForAliases, \ + ("expected onSentFrame (if properly set to" + " handleSentFrame or overridden for simulation) sent" + " frame's EnqueueAliasAllocationRequest state (CID" + " 4's afterSendState), but state is {}" + .format(state)) + second_state = state + # If pumpEvents blocks for at least 200ms after send + # then receives, responses may have already been send + # to handleReceivedFrame, in which case we may be in a + # later state. That isn't recommended except for + # realtime applications (or testing). However, if that + # is programmed, add + # `or state == CanLink.State.EnqueueAliasAllocationRequest` + # to the assertion. + if state == CanLink.State.WaitForAliases: + if debug_count < 3: + print(" * pumpEvents") + self.pumpEvents() # proceed to send: set _waitingForAliasStart + # (prevent getWaitForAliasResponseStart() None in assert below) + state = self.pollState() + if state == CanLink.State.Permitted: + print(" * state: {}".format(state)) break + st = self.getState() + # print(" * state: {}".format(state)) assert self.getWaitForAliasResponseStart() is not None, \ - "openlcb didn't send the 7,6,5,4 CID frames (state={})".format(self.getState()) - if default_timer() - self.getWaitForAliasResponseStart() > CanLink.ALIAS_RESPONSE_DELAY: + "openlcb didn't send 7,6,5,4 CID frames (state={})".format(st) + if ((default_timer() - self.getWaitForAliasResponseStart()) + > CanLink.ALIAS_RESPONSE_DELAY): # 200ms = standard wait time for responses if not self.require_remote_nodes: raise TimeoutError( "In Standard require_remote_nodes=False mode," " but failed to proceed to Permitted state.") precise_sleep(.02) - print("[CanLink] waitForReady...done") + state = self.pollState() + print("[CanLinkLayerSimulation] waitForReady...done") diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index ede832e..2168ad0 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -73,8 +73,9 @@ def registerFrameReceivedListener(self, listener): " constructor).") self.listeners.append(listener) - def fireListeners(self, frame): - """Monitor each frame that is constructed + def fireListeners(self, frame: CanFrame): + """Fire *CanFrame received* listeners. + Monitor each frame that is constructed as the application provides handleData raw data from the port. - LinkLayer (CanLink in this case) must set onReceivedFrame, so registerFrameReceivedListener is now optional, and diff --git a/openlcb/canbus/canphysicallayersimulation.py b/openlcb/canbus/canphysicallayersimulation.py index d6092e7..1268f58 100644 --- a/openlcb/canbus/canphysicallayersimulation.py +++ b/openlcb/canbus/canphysicallayersimulation.py @@ -2,29 +2,37 @@ Simulated CanPhysicalLayer to record frames requested to be sent. ''' +from openlcb.canbus.canframe import CanFrame from openlcb.canbus.canphysicallayer import CanPhysicalLayer +from openlcb.frameencoder import FrameEncoder -class CanPhysicalLayerSimulation(CanPhysicalLayer): +class CanPhysicalLayerSimulation(CanPhysicalLayer, FrameEncoder): def __init__(self): - self.receivedPackets = [] + self.receivedFrames = [] CanPhysicalLayer.__init__(self) self.onQueuedFrame = self._onQueuedFrame - def _onQueuedFrame(self, frame): + def _onQueuedFrame(self, frame: CanFrame): raise AttributeError( - "This should not be used for simulation" - "--Make sendFrameAfter realtime instead.") - - def captureFrame(self, frame): - self.receivedPackets.append(frame) - return "CanPhysicalLayerSimulation" - - def sendFrameAfter(self, frame): - return self.captureFrame(frame) # pretend it was sent - # (normally only onQueuedFrame would be called here, - # and would be encoded to packet str/bytes/bytearray - # and sent to socket later by the application's socket code, - # which would then call onSentFrame which is set - # to the LinkLayer subclass' handleSentFrame) + "Not implemented for simulation") + + def captureFrame(self, frame: CanFrame): + self.receivedFrames.append(frame) + + def encodeFrameAsString(self, frame: CanFrame): + return "(no encoding, only simulating CanPhysicalLayer superclass)" + + def encodeFrameAsData(self, frame: CanFrame): + return self.encodeFrameAsString(frame).encode("utf-8") + + def sendFrameAfter(self, frame: CanFrame): + frame.encoder = self + self.captureFrame(frame) + # NOTE: Can't actually do afterSendState here, because + # _enqueueCIDSequence sets state to + # CanLink.State.WaitingForSendCIDSequence + # *after* calling this (so we must use afterSendState + # later!) + self._send_frames.append(frame) diff --git a/openlcb/datagramservice.py b/openlcb/datagramservice.py index b17e773..8cf6a28 100644 --- a/openlcb/datagramservice.py +++ b/openlcb/datagramservice.py @@ -162,7 +162,8 @@ def registerDatagramReceivedListener(self, listener): ''' self.listeners.append(listener) - def fireListeners(self, dg): # internal for testing + def fireListeners(self, dg: DatagramReadMemo): # internal for testing + """Fire *datagram received* listeners.""" replied = False for listener in self.listeners: replied = listener(dg) or replied diff --git a/openlcb/dispatcher.py b/openlcb/dispatcher.py index 8018765..d69dbb7 100644 --- a/openlcb/dispatcher.py +++ b/openlcb/dispatcher.py @@ -176,8 +176,7 @@ def start_listening(self, connected_port, localNodeID): # for this application. self._callback_status("CanLink...") - self._canLink = CanLink(self._physicalLayer, NodeID(localNodeID), - require_remote_nodes=True) + self._canLink = CanLink(self._physicalLayer, NodeID(localNodeID)) self._callback_status("CanLink..." "registerMessageReceivedListener...") self._canLink.registerMessageReceivedListener(self._handleMessage) @@ -292,7 +291,8 @@ def _listen(self): if frame is None: break # allow receive to run! if isinstance(frame, CanFrame): - if self._canLink.isDuplicateAlias(frame.alias): + # if self._canLink.isDuplicateAlias(frame.alias): + if self._canLink.isBadReservation(frame): logger.warning( "Discarded frame from a previous" " alias reservation attempt" diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index 30e675a..aca0b59 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -21,6 +21,7 @@ import warnings from openlcb import emit_cast +from openlcb.message import Message from openlcb.physicallayer import PhysicalLayer logger = getLogger(__name__) @@ -42,7 +43,7 @@ class LinkLayer: """ class State(Enum): - Undefined = 0 # subclass constructor did not run (implement states) + Undefined = 0 # subclass constructor didn't run--Implement State there DisconnectedState = State.Undefined # change in subclass! Only for tests! # (enforced using type(self).__name__ != "LinkLayer" checks in methods) @@ -131,13 +132,14 @@ def _onStateChanged(self, oldState, newState): raise NotImplementedError( "[LinkLayer] abstract _onStateChanged not implemented") - def sendMessage(self, msg): + def sendMessage(self, msg: Message): '''This is the basic abstract interface ''' def registerMessageReceivedListener(self, listener): self.listeners.append(listener) - def fireListeners(self, msg): + def fireListeners(self, msg: Message): + """Fire *Message received* listeners.""" for listener in self.listeners: listener(msg) diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index ff867fc..e25ad87 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -79,6 +79,21 @@ def pollFrame(self): pass return None + def clearReservation(self, reservation: int): + """Clear a reservation attempt number. + Args: + reservation (int): Set this to LinkLayer subclass' + _reservation then call defineAndReserveAlias to + increment that. + """ + assert isinstance(reservation, int) + newFrames = \ + [frame for frame in self._send_frames if frame.reservation != reservation] + # ^ iterates from left to right, so this is ok (We use popleft, + # and 0 will become left) + self._send_frames.clear() + self._send_frames.extend(newFrames) + def sendFrameAfter(self, frame): """In subclass, enforce type and set frame.encoder to self (which should inherit from both PhysicalLayer and FrameEncoder) @@ -93,6 +108,25 @@ def sendFrameAfter(self, frame): self.onQueuedFrame(frame) def onSentFrame(self, frame): + """Stub patched at runtime: + LinkLayer subclass's constructor must set instance's onSentFrame + to LinkLayer subclass' handleSentFrame (The application must + pass this instance to LinkLayer subclass's constructor so it + will do that). + + Args: + frame (Any): The frame to mark as sent (such as for starting + reserve alias 200ms Standard delay). The subclass + determines type (typically CanFrame; may differ in Mock + subclasses etc). + + Raises: + NotImplementedError: If the class wasn't passed to + a PhysicalLayer subclass' constructor, or a test that + doesn't involve a PhysicalLayer didn't patch out this + method manually (See PhysicalLayerMock for proper + example of that if tests using it are passing). + """ raise NotImplementedError( "The subclass must patch the instance:" " PhysicalLayer instance's onSentFrame must be manually set" @@ -130,6 +164,3 @@ def physicalLayerRestart(self): def physicalLayerDown(self): """abstract method""" raise NotImplementedError("Each subclass must implement this.") - - def encodeFrameAsString(self, frame) -> str: - raise NotImplementedError("Each subclass must implement this.") diff --git a/openlcb/realtimephysicallayer.py b/openlcb/realtimephysicallayer.py index 37e0e9f..01ab073 100644 --- a/openlcb/realtimephysicallayer.py +++ b/openlcb/realtimephysicallayer.py @@ -38,10 +38,13 @@ def sendFrameAfter(self, frame): # .format(type(data).__name__, data) # ) print(" SR: {}".format(frame.encode())) + # send and fireListeners would usually occur after + # frame from _send_frames.popleft is sent, + # but we do all this here in the Realtime subclass: self.sock.send(frame.encode()) # TODO: finish onSentFrame if frame.afterSendState: - self.onSentFrame(frame) + self.fireListeners(frame) # also calls self.onSentFrame(frame) def registerFrameReceivedListener(self, listener): """_summary_ diff --git a/python-openlcb.code-workspace b/python-openlcb.code-workspace index 53a9d2f..260112c 100644 --- a/python-openlcb.code-workspace +++ b/python-openlcb.code-workspace @@ -31,6 +31,7 @@ "canbus", "canframe", "canlink", + "canlinklayersimulation", "canphysicallayer", "canphysicallayergridconnect", "canphysicallayersimulation", @@ -44,6 +45,7 @@ "dmemo", "Dmitry", "dunder", + "frameencoder", "gridargs", "gridconnectobserver", "JMRI", diff --git a/tests/test_canlink.py b/tests/test_canlink.py index 2038dff..23c7c87 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -1,7 +1,6 @@ import unittest - -from openlcb import formatted_ex, precise_sleep +from openlcb import formatted_ex from openlcb.canbus.canlink import CanLink from openlcb.canbus.canframe import CanFrame @@ -18,12 +17,15 @@ class PhyMockLayer(CanPhysicalLayer): def __init__(self): - self.receivedPackets = [] + # onSentFrame will not work until this instance is passed to the + # LinkLayer subclass' constructor (See onSentFrame + # docstring in PhysicalLayer) + self.receivedFrames = [] CanPhysicalLayer.__init__(self) def sendDataAfter(self, data): assert isinstance(data, (bytes, bytearray)) - self.receivedPackets.append(data) + self.receivedFrames.append(data) class MessageMockLayer: @@ -100,7 +102,7 @@ def testCreateAlias12(self): def testLinkUpSequence(self): canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLinkLayerSimulation( - canPhysicalLayer, getLocalNodeID(), require_remote_nodes=False) + canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) @@ -132,8 +134,8 @@ def testEIR2NoData(self): canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) canLink._state = CanLink.State.Permitted - canPhysicalLayer.sendFrameAfter(CanFrame(ControlFrame.EIR2.value, 0)) - self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) + canPhysicalLayer.fireListeners(CanFrame(ControlFrame.EIR2.value, 0)) + self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) canLink.onDisconnect() # MARK: - Test AME (Local Node) @@ -143,10 +145,10 @@ def testAMENoData(self): ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink._state = CanLink.State.Permitted - canPhysicalLayer.sendFrameAfter(CanFrame(ControlFrame.AME.value, 0)) - self.assertEqual(len(canPhysicalLayer.receivedPackets), 1) + canPhysicalLayer.fireListeners(CanFrame(ControlFrame.AME.value, 0)) + self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) self.assertEqual( - canPhysicalLayer.receivedPackets[0], + canPhysicalLayer.receivedFrames[0], CanFrame(ControlFrame.AMD.value, ourAlias, canLink.localNodeID.toArray()) ) @@ -157,8 +159,8 @@ def testAMEnoDataInhibited(self): canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) canLink._state = CanLink.State.Inhibited - canPhysicalLayer.sendFrameAfter(CanFrame(ControlFrame.AME.value, 0)) - self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) + canPhysicalLayer.fireListeners(CanFrame(ControlFrame.AME.value, 0)) + self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) canLink.onDisconnect() def testAMEMatchEvent(self): @@ -169,10 +171,9 @@ def testAMEMatchEvent(self): frame = CanFrame(ControlFrame.AME.value, 0) frame.data = bytearray([5, 1, 1, 1, 3, 1]) - result = canPhysicalLayer.sendFrameAfter(frame) - self.assertEqual(result, "CanPhysicalLayerSimulation") - self.assertEqual(len(canPhysicalLayer.receivedPackets), 1) - self.assertEqual(canPhysicalLayer.receivedPackets[0], + canPhysicalLayer.fireListeners(frame) + self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) + self.assertEqual(canPhysicalLayer.receivedFrames[0], CanFrame(ControlFrame.AMD.value, ourAlias, canLink.localNodeID.toArray())) canLink.onDisconnect() @@ -184,8 +185,8 @@ def testAMENotMatchEvent(self): frame = CanFrame(ControlFrame.AME.value, 0) frame.data = bytearray([0, 0, 0, 0, 0, 0]) - canPhysicalLayer.sendFrameAfter(frame) - self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) + canPhysicalLayer.fireListeners(frame) + self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) canLink.onDisconnect() # MARK: - Test Alias Collisions (Local Node) @@ -195,10 +196,10 @@ def testCIDreceivedMatch(self): ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink._state = CanLink.State.Permitted - canPhysicalLayer.sendFrameAfter(CanFrame(7, canLink.localNodeID, + canPhysicalLayer.fireListeners(CanFrame(7, canLink.localNodeID, ourAlias)) - self.assertEqual(len(canPhysicalLayer.receivedPackets), 1) - self.assertEqual(canPhysicalLayer.receivedPackets[0], + self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) + self.assertEqual(canPhysicalLayer.receivedFrames[0], CanFrame(ControlFrame.RID.value, ourAlias)) canLink.onDisconnect() @@ -208,14 +209,14 @@ def testRIDreceivedMatch(self): ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink._state = CanLink.State.Permitted - canPhysicalLayer.sendFrameAfter(CanFrame(ControlFrame.RID.value, - ourAlias)) - self.assertEqual(len(canPhysicalLayer.receivedPackets), 8) + canPhysicalLayer.fireListeners(CanFrame(ControlFrame.RID.value, + ourAlias)) + self.assertEqual(len(canPhysicalLayer.receivedFrames), 8) # ^ includes recovery of new alias 4 CID, RID, AMR, AME - self.assertEqual(canPhysicalLayer.receivedPackets[0], + self.assertEqual(canPhysicalLayer.receivedFrames[0], CanFrame(ControlFrame.AMR.value, ourAlias, bytearray([5, 1, 1, 1, 3, 1]))) - self.assertEqual(canPhysicalLayer.receivedPackets[6], + self.assertEqual(canPhysicalLayer.receivedFrames[6], CanFrame(ControlFrame.AMD.value, 0x539, bytearray([5, 1, 1, 1, 3, 1]))) # new alias self.assertEqual(canLink._state, CanLink.State.Permitted) @@ -282,12 +283,12 @@ def testSimpleGlobalData(self): # map an alias we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) - canPhysicalLayer.sendFrameAfter(amd) + canPhysicalLayer.fireListeners(amd) - canPhysicalLayer.sendFrameAfter(CanFrame(0x19490, 0x247)) + canPhysicalLayer.fireListeners(CanFrame(0x19490, 0x247)) # ^ from previously seen alias - self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) + self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) # ^ nothing back down to CAN self.assertEqual(len(messageLayer.receivedMessages), 1) # ^ one message forwarded @@ -306,15 +307,15 @@ def testVerifiedNodeInDestAliasMap(self): canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) - canLink._state = CanLink.State.Permitted + canLink._state = CanLink.State.Permitted # normally set via pollState # Don't map an alias with an AMD for this test - canPhysicalLayer.sendFrameAfter(CanFrame(0x19170, 0x247, - bytearray([8, 7, 6, 5, 4, 3]))) + canPhysicalLayer.fireListeners(CanFrame(0x19170, 0x247, + bytearray([8, 7, 6, 5, 4, 3]))) # ^ VerifiedNodeID from unique alias - self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) + self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) # ^ nothing back down to CAN self.assertEqual(len(messageLayer.receivedMessages), 1) # ^ one message forwarded @@ -338,11 +339,11 @@ def testNoDestInAliasMap(self): # Don't map an alias with an AMD for this test - canPhysicalLayer.sendFrameAfter( + canPhysicalLayer.fireListeners( CanFrame(0x19968, 0x247, bytearray([8, 7, 6, 5, 4, 3]))) # ^ Identify Events Addressed from unique alias - self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) + self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) # ^ nothing back down to CAN self.assertEqual(len(messageLayer.receivedMessages), 1) # ^ one message forwarded @@ -356,7 +357,7 @@ def testNoDestInAliasMap(self): def testSimpleAddressedData(self): # Test start=yes, end=yes frame canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLinkLayerSimulation( - canPhysicalLayer, getLocalNodeID(), require_remote_nodes=False) + canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) @@ -366,13 +367,14 @@ def testSimpleAddressedData(self): # Test start=yes, end=yes frame # map an alias we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) - canPhysicalLayer.sendFrameAfter(amd) + canPhysicalLayer.fireListeners(amd) - ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) + ourAlias = canLink.getLocalAlias() + # ^ 576 with NodeID(0x05_01_01_01_03_01) frame = CanFrame(0x19488, 0x247) # Verify Node ID Addressed frame.data = bytearray([((ourAlias & 0x700) >> 8), (ourAlias & 0xFF), 12, 13]) - canPhysicalLayer.sendFrameAfter(frame) # from previously seen alias + canPhysicalLayer.fireListeners(frame) # from previously seen alias self.assertEqual(len(messageLayer.receivedMessages), 2) # ^ startup plus one message forwarded @@ -392,7 +394,7 @@ def testSimpleAddressedDataNoAliasYet(self): '''Test start=yes, end=yes frame with no alias match''' canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLinkLayerSimulation( - canPhysicalLayer, getLocalNodeID(), require_remote_nodes=False) + canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) @@ -407,7 +409,7 @@ def testSimpleAddressedDataNoAliasYet(self): frame.data = bytearray( [((ourAlias & 0x700) >> 8), (ourAlias & 0xFF), 12, 13] ) - canPhysicalLayer.sendFrameAfter(frame) # from previously seen alias + canPhysicalLayer.fireListeners(frame) # from previously seen alias self.assertEqual(len(messageLayer.receivedMessages), 2) # ^ startup plus one message forwarded @@ -430,7 +432,7 @@ def testMultiFrameAddressedData(self): ''' canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLinkLayerSimulation( - canPhysicalLayer, getLocalNodeID(), require_remote_nodes=False) + canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) @@ -440,14 +442,14 @@ def testMultiFrameAddressedData(self): # map an alias we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) - canPhysicalLayer.sendFrameAfter(amd) + canPhysicalLayer.fireListeners(amd) ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) frame = CanFrame(0x19488, 0x247) # Verify Node ID Addressed frame.data = bytearray([(((ourAlias & 0x700) >> 8) | 0x10), (ourAlias & 0xFF), 1, 2]) # ^ start not end - canPhysicalLayer.sendFrameAfter(frame) # from previously seen alias + canPhysicalLayer.fireListeners(frame) # from previously seen alias self.assertEqual(len(messageLayer.receivedMessages), 1) # ^ startup only, no message forwarded yet @@ -456,7 +458,7 @@ def testMultiFrameAddressedData(self): frame.data = bytearray([(((ourAlias & 0x700) >> 8) | 0x20), (ourAlias & 0xFF), 3, 4]) # ^ end, not start - canPhysicalLayer.sendFrameAfter(frame) # from previously seen alias + canPhysicalLayer.fireListeners(frame) # from previously seen alias self.assertEqual(len(messageLayer.receivedMessages), 2) # ^ startup plus one message forwarded @@ -473,7 +475,7 @@ def testMultiFrameAddressedData(self): def testSimpleDatagram(self): # Test start=yes, end=yes frame canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLinkLayerSimulation( - canPhysicalLayer, getLocalNodeID(), require_remote_nodes=False) + canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) @@ -483,14 +485,14 @@ def testSimpleDatagram(self): # Test start=yes, end=yes frame # map two aliases we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) - canPhysicalLayer.sendFrameAfter(amd) + canPhysicalLayer.fireListeners(amd) amd = CanFrame(0x0701, 0x123) amd.data = bytearray([6, 5, 4, 3, 2, 1]) - canPhysicalLayer.sendFrameAfter(amd) + canPhysicalLayer.fireListeners(amd) frame = CanFrame(0x1A123, 0x247) # single frame datagram frame.data = bytearray([10, 11, 12, 13]) - canPhysicalLayer.sendFrameAfter(frame) # from previously seen alias + canPhysicalLayer.fireListeners(frame) # from previously seen alias self.assertEqual(len(messageLayer.receivedMessages), 2) # ^ startup plus one message forwarded @@ -511,30 +513,40 @@ def testSimpleDatagram(self): # Test start=yes, end=yes frame def testMultiFrameDatagram(self): canPhysicalLayer = CanPhysicalLayerSimulation() canLink = CanLinkLayerSimulation( - canPhysicalLayer, getLocalNodeID(), require_remote_nodes=False) + canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) - + print("[testMultiFrameDatagram] state={}" + .format(canLink.getState())) canPhysicalLayer.physicalLayerUp() canLink.waitForReady() # map two aliases we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) - canPhysicalLayer.sendFrameAfter(amd) + canPhysicalLayer.fireListeners(amd) amd = CanFrame(0x0701, 0x123) amd.data = bytearray([6, 5, 4, 3, 2, 1]) - canPhysicalLayer.sendFrameAfter(amd) + canPhysicalLayer.fireListeners(amd) frame = CanFrame(0x1B123, 0x247) # single frame datagram frame.data = bytearray([10, 11, 12, 13]) - canPhysicalLayer.sendFrameAfter(frame) # from previously seen alias + canPhysicalLayer.fireListeners(frame) # from previously seen alias frame = CanFrame(0x1C123, 0x247) # single frame datagram frame.data = bytearray([20, 21, 22, 23]) - canPhysicalLayer.sendFrameAfter(frame) # from previously seen alias + canPhysicalLayer.fireListeners(frame) # from previously seen alias frame = CanFrame(0x1D123, 0x247) # single frame datagram frame.data = bytearray([30, 31, 32, 33]) - canPhysicalLayer.sendFrameAfter(frame) # from previously seen alias + canPhysicalLayer.fireListeners(frame) # from previously seen alias + + while True: + frame = canPhysicalLayer.pollFrame() + if frame is None: + break + # FIXME: Pretending sent is not effective if dest is mock node + # (if its state will be checked in the test!) but if we are + # using pure CAN (not packed with LCC alias) it is P2P. + canPhysicalLayer.onSentFrame(frame) self.assertEqual(len(messageLayer.receivedMessages), 2) # ^ startup plus one message forwarded @@ -569,8 +581,8 @@ def testZeroLengthDatagram(self): canLink.sendMessage(message) - self.assertEqual(len(canPhysicalLayer.receivedPackets), 1) - self.assertEqual(str(canPhysicalLayer.receivedPackets[0]), + self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) + self.assertEqual(str(canPhysicalLayer.receivedFrames[0]), "CanFrame header: 0x1A000000 []") canLink.onDisconnect() @@ -584,9 +596,9 @@ def testOneFrameDatagram(self): canLink.sendMessage(message) - self.assertEqual(len(canPhysicalLayer.receivedPackets), 1) + self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) self.assertEqual( - str(canPhysicalLayer.receivedPackets[0]), + str(canPhysicalLayer.receivedFrames[0]), "CanFrame header: 0x1A000000 [1, 2, 3, 4, 5, 6, 7, 8]" ) canLink.onDisconnect() @@ -602,13 +614,13 @@ def testTwoFrameDatagram(self): canLink.sendMessage(message) - self.assertEqual(len(canPhysicalLayer.receivedPackets), 2) + self.assertEqual(len(canPhysicalLayer.receivedFrames), 2) self.assertEqual( - str(canPhysicalLayer.receivedPackets[0]), + str(canPhysicalLayer.receivedFrames[0]), "CanFrame header: 0x1B000000 [1, 2, 3, 4, 5, 6, 7, 8]" ) self.assertEqual( - str(canPhysicalLayer.receivedPackets[1]), + str(canPhysicalLayer.receivedFrames[1]), "CanFrame header: 0x1D000000 [9, 10, 11, 12, 13, 14, 15, 16]" ) canLink.onDisconnect() @@ -626,16 +638,16 @@ def testThreeFrameDatagram(self): canLink.sendMessage(message) - self.assertEqual(len(canPhysicalLayer.receivedPackets), 3) + self.assertEqual(len(canPhysicalLayer.receivedFrames), 3) self.assertEqual( - str(canPhysicalLayer.receivedPackets[0]), + str(canPhysicalLayer.receivedFrames[0]), "CanFrame header: 0x1B000000 [1, 2, 3, 4, 5, 6, 7, 8]" ) self.assertEqual( - str(canPhysicalLayer.receivedPackets[1]), + str(canPhysicalLayer.receivedFrames[1]), "CanFrame header: 0x1C000000 [9, 10, 11, 12, 13, 14, 15, 16]" ) - self.assertEqual(str(canPhysicalLayer.receivedPackets[2]), + self.assertEqual(str(canPhysicalLayer.receivedFrames[2]), "CanFrame header: 0x1D000000 [17, 18, 19]") canLink.onDisconnect() @@ -645,22 +657,22 @@ def testAmdAmrSequence(self): canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) - canPhysicalLayer.sendFrameAfter(CanFrame(0x0701, ourAlias+1)) + canPhysicalLayer.fireListeners(CanFrame(0x0701, ourAlias+1)) # ^ AMD from some other alias self.assertEqual(len(canLink.aliasToNodeID), 1) self.assertEqual(len(canLink.nodeIdToAlias), 1) - self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) + self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) # ^ nothing back down to CAN - canPhysicalLayer.sendFrameAfter(CanFrame(0x0703, ourAlias+1)) + canPhysicalLayer.fireListeners(CanFrame(0x0703, ourAlias+1)) # ^ AMR from some other alias self.assertEqual(len(canLink.aliasToNodeID), 0) self.assertEqual(len(canLink.nodeIdToAlias), 0) - self.assertEqual(len(canPhysicalLayer.receivedPackets), 0) + self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) # ^ nothing back down to CAN canLink.onDisconnect() @@ -766,7 +778,7 @@ def testEnum(self): self.assertNotIn(entry.value, usedValues) usedValues.add(entry.value) # print('{} = {}'.format(entry.name, entry.value)) - self.assertIsInstance(entry, int) + self.assertIsInstance(entry.value, int) if __name__ == '__main__': @@ -777,7 +789,7 @@ def testEnum(self): failedCount = 0 exceptions = [] errors = [] - testCase.testMultiFrameDatagram() + # testCase.testLinkUpSequence() for name in dir(testCase): if name.startswith("test"): fn = getattr(testCase, name) From 3f7c85e12f381a5d0aec2c7e64749941ddb1dd52 Mon Sep 17 00:00:00 2001 From: Poikilos <7557867+poikilos@users.noreply.github.com> Date: Fri, 16 May 2025 15:41:31 -0400 Subject: [PATCH 58/99] Ensure all tests are discovered (Fix invalid test left by f25a5d3 "Remove placeholder test file since PhysicalLayer is an interface."). --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 91ccf3a..95f8540 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,4 +13,4 @@ jobs: - uses: actions/checkout@v3 - name: check tests run run: | - python3 test_all.py + python3 -m unittest discover -s tests -p 'test_*.py' From 5727f6dae2d12ca244574ac12113178f372c750f Mon Sep 17 00:00:00 2001 From: Poikilos <7557867+poikilos@users.noreply.github.com> Date: Fri, 16 May 2025 15:47:16 -0400 Subject: [PATCH 59/99] Rename Dispatcher to OpenLCBNetwork. --- examples/examples_gui.py | 10 +++++----- examples/tkexamples/cdiform.py | 8 ++++---- openlcb/canbus/canlink.py | 8 ++++---- openlcb/canbus/canlinklayersimulation.py | 2 +- openlcb/canbus/controlframe.py | 2 +- openlcb/dispatcher.py | 24 ++++++++++++------------ openlcb/internalevent.py | 2 +- openlcb/linklayer.py | 4 ++-- openlcb/mti.py | 2 +- openlcb/portinterface.py | 6 +++--- tests/test_dispatcher | 4 ++-- 11 files changed, 36 insertions(+), 36 deletions(-) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index b098e8e..d579962 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -450,7 +450,7 @@ def _gui(self, parent): self.cdi_refresh_button.grid(row=self.cdi_row, column=1) self.cdi_row += 1 - self.cdi_form = CDIForm(self.cdi_tab) # Dispatcher() subclass + self.cdi_form = CDIForm(self.cdi_tab) # OpenLCBNetwork() subclass self.cdi_form.grid(row=self.cdi_row) self.example_tab = ttk.Frame(self.notebook) @@ -493,7 +493,7 @@ def _connect_state_changed(self, event_d): the connect or _listen thread). Args: - event_d (dict): Information sent by Dispatcher's + event_d (dict): Information sent by OpenLCBNetwork's connect method during the connection steps including alias reservation. Potential keys: - 'error' (str): Indicates a failure @@ -541,18 +541,18 @@ def connect_state_changed(self, event_d): implementation (called by _listen directly unless triggered by LCC Message). - In this program, this is added to Dispatcher via + In this program, this is added to OpenLCBNetwork via set_connect_listener. Therefore in this program, this is triggered during _listen in - Dispatcher: Connecting is actually done until + OpenLCBNetwork: Connecting is actually done until sendAliasAllocationSequence detects success and marks canLink._state to CanLink.State.Permitted (which triggers _handleMessage which calls this). - May also be directly called by _listen directly in case stopped listening (RuntimeError reading port, or other reason lower in the stack than LCC). - - Dispatcher's _connect_listener attribute is a method + - OpenLCBNetwork's _connect_listener attribute is a method reference to this if set via set_connect_listener. """ # Trigger the main thread (only the main thread can access the diff --git a/examples/tkexamples/cdiform.py b/examples/tkexamples/cdiform.py index 50e3b3b..c42b0ff 100644 --- a/examples/tkexamples/cdiform.py +++ b/examples/tkexamples/cdiform.py @@ -36,7 +36,7 @@ " since test running from repo but could not find openlcb in {}." .format(repr(REPO_DIR))) try: - from openlcb.dispatcher import Dispatcher + from openlcb.dispatcher import OpenLCBNetwork except ImportError as ex: print("{}: {}".format(type(ex).__name__, ex), file=sys.stderr) print("* You must run this from a venv that has openlcb installed" @@ -45,7 +45,7 @@ raise # sys.exit(1) -class CDIForm(ttk.Frame, Dispatcher): +class CDIForm(ttk.Frame, OpenLCBNetwork): """A GUI frame to represent the CDI visually as a tree. Args: @@ -53,7 +53,7 @@ class CDIForm(ttk.Frame, Dispatcher): attribute set. """ def __init__(self, *args, **kwargs): - Dispatcher.__init__(self, *args, **kwargs) + OpenLCBNetwork.__init__(self, *args, **kwargs) ttk.Frame.__init__(self, *args, **kwargs) self._top_widgets = [] if len(args) < 1: @@ -93,7 +93,7 @@ def clear(self): self.set_status("Display reset.") # def connect(self, new_socket, localNodeID, callback=None): - # return Dispatcher.connect(self, new_socket, localNodeID, + # return OpenLCBNetwork.connect(self, new_socket, localNodeID, # callback=callback) def downloadCDI(self, farNodeID, callback=None): diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 3c50c25..cc9827c 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -87,7 +87,7 @@ class State(Enum): CID sequence packet (first phase of reserving an alias). - The last frame sets state to WaitForAliases *after* sent by socket (wait for socket code in application or - Dispatcher to notify us, sendFrameAfter is too soon to be + OpenLCBNetwork to notify us, sendFrameAfter is too soon to be sure our 200ms delay starts after send). EnqueueAliasReservation (State): After collision detection fully determined to be success, this state triggers @@ -725,7 +725,7 @@ def sendMessage(self, msg): logger.error( "Did not know destination = {} on datagram send ({})" " self.nodeIdToAlias={}. Ensure recv loop" - " (such as Dispatcher's _listen thread) is running" + " (such as OpenLCBNetwork's _listen thread) is running" " before and during alias reservation sequence delay." " Check previous log messages for an exception" " that may have ended the recv loop." @@ -921,13 +921,13 @@ def pollState(self): could be called in the case of processCollision) - This being separate has the added benefit of the stack being able to work in the same thread - as the application's (or Dispatcher's) + as the application's (or OpenLCBNetwork's) socket calls. """ assert isinstance(self._state, CanLink.State), \ "Expected a CanLink.State, got {}".format(emit_cast(self._state)) if self._state in (CanLink.State.Inhibited, CanLink.State.Initial): - # Do nothing. Dispatcher or application must first call + # Do nothing. OpenLCBNetwork or application must first call # physicalLayerUp # - which triggers handleReceivedLinkUp # - which calls defineAndReserveAlias diff --git a/openlcb/canbus/canlinklayersimulation.py b/openlcb/canbus/canlinklayersimulation.py index 5da805a..61d6bb4 100644 --- a/openlcb/canbus/canlinklayersimulation.py +++ b/openlcb/canbus/canlinklayersimulation.py @@ -11,7 +11,7 @@ class CanLinkLayerSimulation(CanLink): # pumpEvents and waitForReady are based on examples - # and may be moved to CanLink or Dispatcher + # and may be moved to CanLink or OpenLCBNetwork # to make the Python module easier to use. def pumpEvents(self): diff --git a/openlcb/canbus/controlframe.py b/openlcb/canbus/controlframe.py index 35929c8..6e81ec8 100644 --- a/openlcb/canbus/controlframe.py +++ b/openlcb/canbus/controlframe.py @@ -73,7 +73,7 @@ class ControlFrame(Enum): # their values have a bit set above what can come from a CAN Frame # TODO: Consider moving these and non-MTI values in MTI enum # all to InternalEvent (or creating a related event if necessary - # using a listener, so Dispatcher can manage runlevel). + # using a listener, so OpenLCBNetwork can manage runlevel). LinkUp = 0x20000 LinkRestarted = 0x20001 LinkCollision = 0x20002 diff --git a/openlcb/dispatcher.py b/openlcb/dispatcher.py index d69dbb7..af498a5 100644 --- a/openlcb/dispatcher.py +++ b/openlcb/dispatcher.py @@ -66,8 +66,8 @@ def attrs_to_dict(attrs) -> dict: return {key: attrs.getValue(key) for key in attrs.getNames()} -# TODO: split Dispatcher (socket & event handler) from ContentHandler -class Dispatcher(xml.sax.handler.ContentHandler): +# TODO: split OpenLCBNetwork (socket & event handler) from ContentHandler +class OpenLCBNetwork(xml.sax.handler.ContentHandler): """Manage Configuration Description Information. - Send events to downloadCDI caller describing the state and content of the document construction. @@ -111,7 +111,7 @@ def __init__(self, *args, **kwargs): self._my_cache_dir = os.path.join(caches_dir, "python-openlcb") self._element_listener = None self._connect_listener = None - self._mode = Dispatcher.Mode.Initializing + self._mode = OpenLCBNetwork.Mode.Initializing # ^ In case some parsing step happens early, # prepare these for _callback_msg. super().__init__() # takes no arguments @@ -238,7 +238,7 @@ def _receive(self) -> bytearray: def _listen(self): self._connecting_t = time.perf_counter() self._message_t = None - self._mode = Dispatcher.Mode.Idle # Idle until data type is known + self._mode = OpenLCBNetwork.Mode.Idle # Idle until data type is known caught_ex = None try: # NOTE: self._canLink.state is *definitely not* @@ -332,13 +332,13 @@ def _listen(self): } if self._element_listener: self._element_listener(event_d) - self._mode = Dispatcher.Mode.Disconnected + self._mode = OpenLCBNetwork.Mode.Disconnected raise # re-raise since incomplete (prevent done OK state) finally: self._canLink.onDisconnect() self._listen_thread = None - self._mode = Dispatcher.Mode.Disconnected + self._mode = OpenLCBNetwork.Mode.Disconnected # If we got here, the RuntimeError was ok since the # null terminator '\0' was reached (otherwise re-raise occurs above) event_d = { @@ -385,7 +385,7 @@ def callback(event_d): "No physicalLayer. Call start_listening first.") self._cdi_offset = 0 self._reset_tree() - self._mode = Dispatcher.Mode.CDI + self._mode = OpenLCBNetwork.Mode.CDI if self._resultingCDI is not None: raise ValueError( "A previous downloadCDI operation is in progress" @@ -546,13 +546,13 @@ def _memoryReadSuccess(self, memo): # print("successful memory read: {}".format(memo.data)) if len(memo.data) == 64 and 0 not in memo.data: # *not* last chunk self._string_terminated = False - if self._mode == Dispatcher.Mode.CDI: + if self._mode == OpenLCBNetwork.Mode.CDI: # save content self._CDIReadPartial(memo) else: logger.error( "Unknown data packet received" - " (memory read not triggered by Dispatcher)") + " (memory read not triggered by OpenLCBNetwork)") # update the address memo.address = memo.address + 64 # and read again (read next) @@ -561,13 +561,13 @@ def _memoryReadSuccess(self, memo): else: # last chunk self._string_terminated = True # and we're done! - if self._mode == Dispatcher.Mode.CDI: + if self._mode == OpenLCBNetwork.Mode.CDI: self._CDIReadDone(memo) else: logger.error( "Unknown last data packet received" - " (memory read not triggered by Dispatcher)") - self._mode = Dispatcher.Mode.Idle # CDI no longer expected + " (memory read not triggered by OpenLCBNetwork)") + self._mode = OpenLCBNetwork.Mode.Idle # CDI no longer expected # done reading def _memoryReadFail(self, memo): diff --git a/openlcb/internalevent.py b/openlcb/internalevent.py index d3a1177..ba295d7 100644 --- a/openlcb/internalevent.py +++ b/openlcb/internalevent.py @@ -1,7 +1,7 @@ class InternalEvent: """An event for internal use by the framework (framework state events) - - Should be eventually all handled by Dispatcher so + - Should be eventually all handled by OpenLCBNetwork so that it can handle state ("runlevel") as per issue #62. - For OpenLCB/LCC Events, see Events that are not subclasses of this. diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index aca0b59..377439f 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -36,10 +36,10 @@ class LinkLayer: callbacks called from there. _state: The state (a.k.a. "runlevel" in linux terms) of the network link. This may be moved to an overall - stack handler such as Dispatcher. + stack handler such as OpenLCBNetwork. State (class(Enum)): values for _state. Implement in subclass. This may be moved to an overall stack handler such as - Dispatcher. + OpenLCBNetwork. """ class State(Enum): diff --git a/openlcb/mti.py b/openlcb/mti.py index 122187d..6353685 100644 --- a/openlcb/mti.py +++ b/openlcb/mti.py @@ -54,7 +54,7 @@ class MTI(Enum): # specification. # TODO: Consider moving these and non-CAN values in ControlFrame # all to InternalEvent (or creating a related event if necessary - # using a listener, so Dispatcher can manage runlevel). + # using a listener, so OpenLCBNetwork can manage runlevel). Link_Layer_Up = 0x2000 # entered Permitted state; needs to be marked global # noqa: E501 Link_Layer_Quiesce = 0x2010 # Link needs to be drained, will come back with Link_Layer_Restarted next # noqa: E501 Link_Layer_Restarted = 0x2020 # link cycled without change of node state; needs to be marked global # noqa: E501 diff --git a/openlcb/portinterface.py b/openlcb/portinterface.py index e83e2ad..2d8a637 100644 --- a/openlcb/portinterface.py +++ b/openlcb/portinterface.py @@ -49,7 +49,7 @@ def _unsetBusy(self, caller): raise InterruptedError( "Untracked {} ended during {}" " Check busy() first or setListeners" - " (implementation problem: See Dispatcher" + " (implementation problem: See OpenLCBNetwork" " for correct example)" .format(caller, self._busy_message)) self._busy_message = None @@ -60,7 +60,7 @@ def assertNotBusy(self, caller): "{} was called during {}." " Check busy() first or setListeners" " and wait for {} ready" - " (or use Dispatcher to send&receive)" + " (or use OpenLCBNetwork to send&receive)" .format(caller, self._busy_message, caller)) def setListeners(self, onReadyToSend, onReadyToReceive): @@ -116,7 +116,7 @@ def send(self, data: Union[bytes, bytearray]) -> None: Raises: InterruptedError: (raised by assertNotBusy) if port is in use. Use sendFrameAfter in - Dispatcher to avoid this. + OpenLCBNetwork to avoid this. Args: data (Union[bytes, bytearray]): _description_ diff --git a/tests/test_dispatcher b/tests/test_dispatcher index 9e7b149..2008729 100644 --- a/tests/test_dispatcher +++ b/tests/test_dispatcher @@ -1,6 +1,6 @@ import unittest -from openlcb.dispatcher import Dispatcher +from openlcb.dispatcher import OpenLCBNetwork class DispatcherTest(unittest.TestCase): @@ -10,7 +10,7 @@ class DispatcherTest(unittest.TestCase): def testEnum(self): usedValues = set() # ensure values are unique: - for entry in Dispatcher.Mode: + for entry in OpenLCBNetwork.Mode: self.assertNotIn(entry.value, usedValues) usedValues.add(entry.value) # print('{} = {}'.format(entry.name, entry.value)) From f05b024e3f1d94c467a142955da3ab628e9e37e8 Mon Sep 17 00:00:00 2001 From: Poikilos <7557867+poikilos@users.noreply.github.com> Date: Fri, 16 May 2025 15:48:02 -0400 Subject: [PATCH 60/99] Rename a test to reflect the new class name. --- tests/{test_dispatcher => test_openlcbnetwork.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_dispatcher => test_openlcbnetwork.py} (100%) diff --git a/tests/test_dispatcher b/tests/test_openlcbnetwork.py similarity index 100% rename from tests/test_dispatcher rename to tests/test_openlcbnetwork.py From 9720bdde57af596acabc2e4cb10a66eeb8514aa8 Mon Sep 17 00:00:00 2001 From: Poikilos <7557867+poikilos@users.noreply.github.com> Date: Fri, 16 May 2025 15:49:42 -0400 Subject: [PATCH 61/99] Rename submodule to reflect new classname. --- examples/tkexamples/cdiform.py | 4 ++-- openlcb/{dispatcher.py => openlcbnetwork.py} | 0 python-openlcb.code-workspace | 1 + tests/test_openlcbnetwork.py | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) rename openlcb/{dispatcher.py => openlcbnetwork.py} (100%) diff --git a/examples/tkexamples/cdiform.py b/examples/tkexamples/cdiform.py index c42b0ff..a807042 100644 --- a/examples/tkexamples/cdiform.py +++ b/examples/tkexamples/cdiform.py @@ -18,7 +18,7 @@ from logging import getLogger from xml.etree import ElementTree as ET -from openlcb.dispatcher import element_to_dict +from openlcb.openlcbnetwork import element_to_dict if __name__ == "__main__": logger = getLogger(__file__) @@ -36,7 +36,7 @@ " since test running from repo but could not find openlcb in {}." .format(repr(REPO_DIR))) try: - from openlcb.dispatcher import OpenLCBNetwork + from openlcb.openlcbnetwork import OpenLCBNetwork except ImportError as ex: print("{}: {}".format(type(ex).__name__, ex), file=sys.stderr) print("* You must run this from a venv that has openlcb installed" diff --git a/openlcb/dispatcher.py b/openlcb/openlcbnetwork.py similarity index 100% rename from openlcb/dispatcher.py rename to openlcb/openlcbnetwork.py diff --git a/python-openlcb.code-workspace b/python-openlcb.code-workspace index 260112c..b201fbe 100644 --- a/python-openlcb.code-workspace +++ b/python-openlcb.code-workspace @@ -64,6 +64,7 @@ "offvalue", "onvalue", "openlcb", + "openlcbnetwork", "padx", "pady", "physicallayer", diff --git a/tests/test_openlcbnetwork.py b/tests/test_openlcbnetwork.py index 2008729..e019f78 100644 --- a/tests/test_openlcbnetwork.py +++ b/tests/test_openlcbnetwork.py @@ -1,6 +1,6 @@ import unittest -from openlcb.dispatcher import OpenLCBNetwork +from openlcb.openlcbnetwork import OpenLCBNetwork class DispatcherTest(unittest.TestCase): From b163a2bd66564accccdbb2f38d642ecf8ab17ddd Mon Sep 17 00:00:00 2001 From: Poikilos <7557867+poikilos@users.noreply.github.com> Date: Fri, 16 May 2025 16:31:06 -0400 Subject: [PATCH 62/99] Add NotImplementedError to inform developer subclass was misused. --- openlcb/physicallayer.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index e25ad87..80196e1 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -55,11 +55,16 @@ class PhysicalLayer: def __init__(self): self._send_frames = deque() + # self._send_chunks = deque() self.onQueuedFrame = None - # def sendDataAfter(self, data): - # assert isinstance(data, (bytes, bytearray)) - # self._send_frames.append(data) + def sendDataAfter(self, data: Union[bytes, bytearray]): + raise NotImplementedError( + "This method is only for Realtime subclass(es)" + " (which should only be used when not using GridConnect" + " subclass, such for testing)") + # assert isinstance(data, (bytes, bytearray)) + # self._send_chunks.append(data) def pollFrame(self): """Check if there is another frame queued and get it. From c3412b7232d579d668de7d1392a318d2f2e27601 Mon Sep 17 00:00:00 2001 From: Poikilos <7557867+poikilos@users.noreply.github.com> Date: Fri, 16 May 2025 16:32:22 -0400 Subject: [PATCH 63/99] Fix remaining CanLink tests not passing after adding new states (check _send_frames instead of receivedFrames when queue not processed due to offline test, but process queue after collision, etc). --- tests/test_canlink.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/tests/test_canlink.py b/tests/test_canlink.py index 23c7c87..e8e3396 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -1,3 +1,4 @@ +from timeit import default_timer import unittest from openlcb import formatted_ex @@ -211,6 +212,8 @@ def testRIDreceivedMatch(self): canPhysicalLayer.fireListeners(CanFrame(ControlFrame.RID.value, ourAlias)) + # ^ collision + canLink.waitForReady() self.assertEqual(len(canPhysicalLayer.receivedFrames), 8) # ^ includes recovery of new alias 4 CID, RID, AMR, AME self.assertEqual(canPhysicalLayer.receivedFrames[0], @@ -581,8 +584,8 @@ def testZeroLengthDatagram(self): canLink.sendMessage(message) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) - self.assertEqual(str(canPhysicalLayer.receivedFrames[0]), + self.assertEqual(len(canPhysicalLayer._send_frames), 1) + self.assertEqual(str(canPhysicalLayer._send_frames[0]), "CanFrame header: 0x1A000000 []") canLink.onDisconnect() @@ -596,9 +599,9 @@ def testOneFrameDatagram(self): canLink.sendMessage(message) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) + self.assertEqual(len(canPhysicalLayer._send_frames), 1) self.assertEqual( - str(canPhysicalLayer.receivedFrames[0]), + str(canPhysicalLayer._send_frames[0]), "CanFrame header: 0x1A000000 [1, 2, 3, 4, 5, 6, 7, 8]" ) canLink.onDisconnect() @@ -614,13 +617,13 @@ def testTwoFrameDatagram(self): canLink.sendMessage(message) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 2) + self.assertEqual(len(canPhysicalLayer._send_frames), 2) self.assertEqual( - str(canPhysicalLayer.receivedFrames[0]), + str(canPhysicalLayer._send_frames[0]), "CanFrame header: 0x1B000000 [1, 2, 3, 4, 5, 6, 7, 8]" ) self.assertEqual( - str(canPhysicalLayer.receivedFrames[1]), + str(canPhysicalLayer._send_frames[1]), "CanFrame header: 0x1D000000 [9, 10, 11, 12, 13, 14, 15, 16]" ) canLink.onDisconnect() @@ -638,16 +641,18 @@ def testThreeFrameDatagram(self): canLink.sendMessage(message) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 3) + + + self.assertEqual(len(canPhysicalLayer._send_frames), 3) self.assertEqual( - str(canPhysicalLayer.receivedFrames[0]), + str(canPhysicalLayer._send_frames[0]), "CanFrame header: 0x1B000000 [1, 2, 3, 4, 5, 6, 7, 8]" ) self.assertEqual( - str(canPhysicalLayer.receivedFrames[1]), + str(canPhysicalLayer._send_frames[1]), "CanFrame header: 0x1C000000 [9, 10, 11, 12, 13, 14, 15, 16]" ) - self.assertEqual(str(canPhysicalLayer.receivedFrames[2]), + self.assertEqual(str(canPhysicalLayer._send_frames[2]), "CanFrame header: 0x1D000000 [17, 18, 19]") canLink.onDisconnect() @@ -789,7 +794,7 @@ def testEnum(self): failedCount = 0 exceptions = [] errors = [] - # testCase.testLinkUpSequence() + testCase.testRIDreceivedMatch() for name in dir(testCase): if name.startswith("test"): fn = getattr(testCase, name) From 894310d15ebd558cb1fd950ce7512268439572c0 Mon Sep 17 00:00:00 2001 From: Poikilos <7557867+poikilos@users.noreply.github.com> Date: Fri, 16 May 2025 16:34:54 -0400 Subject: [PATCH 64/99] Fix test_all.py. --- test_all.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_all.py b/test_all.py index 1a89625..71eaf00 100644 --- a/test_all.py +++ b/test_all.py @@ -11,7 +11,7 @@ from tests.test_canframe import * -from tests.test_physicallayer import * +# from tests.test_physicallayer import * # commented: test was empty file from tests.test_canphysicallayer import * from tests.test_canphysicallayergridconnect import * From f955a58cb651a9a8608b4cce54c9ea1c220bdd46 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Mon, 19 May 2025 10:41:08 -0400 Subject: [PATCH 65/99] Rename each listener list and method to denote the specific purpose. --- examples/example_remote_nodes.py | 4 +- openlcb/canbus/canlink.py | 10 ++-- openlcb/canbus/canphysicallayer.py | 14 ++--- openlcb/canbus/canphysicallayergridconnect.py | 2 +- openlcb/datagramservice.py | 10 ++-- openlcb/linklayer.py | 14 ++--- openlcb/tcplink/tcplink.py | 12 ++--- tests/test_canlink.py | 54 +++++++++---------- tests/test_canphysicallayer.py | 8 +-- tests/test_datagramservice.py | 2 +- tests/test_linklayer.py | 3 +- 11 files changed, 67 insertions(+), 66 deletions(-) diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index d787896..8154096 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -108,8 +108,8 @@ def printMessage(msg): observer = GridConnectObserver() -assert len(physicalLayer.listeners) == 1, \ - "{} listener(s) unexpectedly".format(len(physicalLayer.listeners)) +assert len(physicalLayer._frameReceivedListeners) == 1, \ + "{} listener(s) unexpectedly".format(len(physicalLayer._frameReceivedListeners)) def pumpEvents(): diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index cc9827c..297d9f9 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -362,7 +362,7 @@ def handleReceivedLinkRestarted(self, frame): """ msg = Message(MTI.Link_Layer_Restarted, NodeID(0), None, bytearray()) - self.fireListeners(msg) + self.fireMessageReceived(msg) def _notifyReservation(self): """Send Alias Map Definition (AMD) @@ -444,7 +444,7 @@ def linkStateChange(self, state: State): else: raise TypeError( "The other layers don't need to know the intermediate steps.") - self.fireListeners(msg) + self.fireMessageReceived(msg) def handleReceivedCID(self, frame): # CanFrame """Handle a Check ID (CID) frame only if addressed to us @@ -620,7 +620,7 @@ def handleReceivedData(self, frame): # CanFrame if dgCode == 0x0_0A_00_00_00 or dgCode == 0x0_0D_00_00_00: # is end, ship and remove accumulation msg = Message(mti, sourceID, destID, self.accumulator[key]) - self.fireListeners(msg) + self.fireMessageReceived(msg) # remove accumulation self.accumulator[key] = None @@ -680,7 +680,7 @@ def handleReceivedData(self, frame): # CanFrame # which needs to carry its original MTI value if mti is MTI.Unknown : msg.originalMTI = ((frame.header >> 12) & 0xFFF) - self.fireListeners(msg) + self.fireMessageReceived(msg) # remove accumulation self.accumulator[key] = None @@ -694,7 +694,7 @@ def handleReceivedData(self, frame): # CanFrame # to carry its original MTI value if mti is MTI.Unknown : msg.originalMTI = ((frame.header >> 12) & 0xFFF) - self.fireListeners(msg) + self.fireMessageReceived(msg) def sendMessage(self, msg): # special case for datagram diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index 2168ad0..9d20033 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -22,7 +22,7 @@ class CanPhysicalLayer(PhysicalLayer): def __init__(self,): PhysicalLayer.__init__(self) - self.listeners = [] + self._frameReceivedListeners = [] def onReceivedFrame(self, frame): raise NotImplementedError( @@ -71,9 +71,9 @@ def registerFrameReceivedListener(self, listener): " packets into frames (this layer communicates to upper layers" " using physicalLayer.onReceivedFrame set by LinkLayer/subclass" " constructor).") - self.listeners.append(listener) + self._frameReceivedListeners.append(listener) - def fireListeners(self, frame: CanFrame): + def fireFrameReceived(self, frame: CanFrame): """Fire *CanFrame received* listeners. Monitor each frame that is constructed as the application provides handleData raw data from the port. @@ -87,7 +87,7 @@ def fireListeners(self, frame: CanFrame): # operate--See # self.onReceivedFrame(frame) - for listener in self.listeners: + for listener in self._frameReceivedListeners: listener(frame) def physicalLayerUp(self): @@ -95,7 +95,7 @@ def physicalLayerUp(self): ''' # notify link layer cf = CanFrame(ControlFrame.LinkUp.value, 0) - self.fireListeners(cf) + self.fireFrameReceived(cf) def physicalLayerRestart(self): '''Invoked from OpenlcbNetwork when the physical link implementation @@ -103,7 +103,7 @@ def physicalLayerRestart(self): ''' # notify link layer cf = CanFrame(ControlFrame.LinkRestarted.value, 0) - self.fireListeners(cf) + self.fireFrameReceived(cf) def physicalLayerDown(self): '''Invoked from OpenlcbNetwork when the physical link implementation @@ -111,4 +111,4 @@ def physicalLayerDown(self): ''' # notify link layer cf = CanFrame(ControlFrame.LinkDown.value, 0) - self.fireListeners(cf) + self.fireFrameReceived(cf) diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index 06ca519..6ef0610 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -121,7 +121,7 @@ def handleData(self, data: Union[bytes, bytearray]): # lastByte is index of ; in this message cf = CanFrame(header, outData) - self.fireListeners(cf) + self.fireFrameReceived(cf) # shorten buffer by removing the processed message self.inboundBuffer = self.inboundBuffer[processedCount:] diff --git a/openlcb/datagramservice.py b/openlcb/datagramservice.py index 8cf6a28..6201705 100644 --- a/openlcb/datagramservice.py +++ b/openlcb/datagramservice.py @@ -97,7 +97,7 @@ def __init__(self, linkLayer): self.quiesced = False self.currentOutstandingMemo = None self.pendingWriteMemos = [] - self.listeners = [] + self._datagramReceivedListeners = [] def datagramType(self, data): """Determine the protocol type of the content of the datagram. @@ -160,12 +160,12 @@ def registerDatagramReceivedListener(self, listener): listener (Callable): A function that accepts a DatagramReadMemo as an argument. ''' - self.listeners.append(listener) + self._datagramReceivedListeners.append(listener) - def fireListeners(self, dg: DatagramReadMemo): # internal for testing + def fireDatagramReceived(self, dg: DatagramReadMemo): # internal for tests """Fire *datagram received* listeners.""" replied = False - for listener in self.listeners: + for listener in self._datagramReceivedListeners: replied = listener(dg) or replied # ^ order matters on that: Need to always make the call # If none of the listeners replied by now, send a negative reply @@ -200,7 +200,7 @@ def process(self, message): def handleDatagram(self, message): '''create a read memo and pass to listeners''' memo = DatagramReadMemo(message.source, message.data) - self.fireListeners(memo) + self.fireDatagramReceived(memo) # ^ destination listener calls back to # positiveReplyToDatagram/negativeReplyToDatagram before returning diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index 377439f..284cc97 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -31,9 +31,9 @@ class LinkLayer: """Abstract Link Layer interface Attributes: - listeners (list[Callback]): local list of listener callbacks. - See subclass for default listener and more specific - callbacks called from there. + _messageReceivedListeners (list[Callback]): local list of + listener callbacks. See subclass for default listener and + more specific callbacks called from there. _state: The state (a.k.a. "runlevel" in linux terms) of the network link. This may be moved to an overall stack handler such as OpenLCBNetwork. @@ -52,7 +52,7 @@ def __init__(self, physicalLayer: PhysicalLayer, localNodeID): assert isinstance(physicalLayer, PhysicalLayer) # allows any subclass # subclass should check type of localNodeID technically self.localNodeID = localNodeID - self.listeners = [] + self._messageReceivedListeners = [] self._state = None # LinkLayer.State.Undefined # region moved from CanLink linkPhysicalLayer self.physicalLayer = physicalLayer # formerly self.link = cpl @@ -137,9 +137,9 @@ def sendMessage(self, msg: Message): ''' def registerMessageReceivedListener(self, listener): - self.listeners.append(listener) + self._messageReceivedListeners.append(listener) - def fireListeners(self, msg: Message): + def fireMessageReceived(self, msg: Message): """Fire *Message received* listeners.""" - for listener in self.listeners: + for listener in self._messageReceivedListeners: listener(msg) diff --git a/openlcb/tcplink/tcplink.py b/openlcb/tcplink/tcplink.py index 9f099b6..5f40ab2 100644 --- a/openlcb/tcplink/tcplink.py +++ b/openlcb/tcplink/tcplink.py @@ -148,8 +148,8 @@ def receivedPart(self, messagePart, flags, length): def forwardMessage(self, messageBytes, gatewayNodeID) : # not sure why gatewayNodeID useful here... # noqa: E501 """ - Receives single message from receivedPart, converts - it in a Message object, and forwards to listeners + Receives single message from receivedPart, converts it in a + Message object, and forwards to Message received listeners. Args: messageBytes ([int]) : the bytes making up a @@ -168,28 +168,28 @@ def forwardMessage(self, messageBytes, gatewayNodeID) : # not sure why gatewayN # and finally create the message message = Message(mti, sourceNodeID, destNodeID, data) # forward to listeners - self.fireListeners(message) + self.fireMessageReceived(message) def linkUp(self): """ Link started, notify upper layers """ msg = Message(MTI.Link_Layer_Up, NodeID(0), None, bytearray()) - self.fireListeners(msg) + self.fireMessageReceived(msg) def linkRestarted(self): """ Send a LinkRestarted message upstream. """ msg = Message(MTI.Link_Layer_Restarted, NodeID(0), None, bytearray()) - self.fireListeners(msg) + self.fireMessageReceived(msg) def linkDown(self): """ Link dropped, notify upper layers """ msg = Message(MTI.Link_Layer_Down, NodeID(0), None, bytearray()) - self.fireListeners(msg) + self.fireMessageReceived(msg) def sendMessage(self, message): """ diff --git a/tests/test_canlink.py b/tests/test_canlink.py index e8e3396..9aec9da 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -135,7 +135,7 @@ def testEIR2NoData(self): canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) canLink._state = CanLink.State.Permitted - canPhysicalLayer.fireListeners(CanFrame(ControlFrame.EIR2.value, 0)) + canPhysicalLayer.fireFrameReceived(CanFrame(ControlFrame.EIR2.value, 0)) self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) canLink.onDisconnect() @@ -146,7 +146,7 @@ def testAMENoData(self): ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink._state = CanLink.State.Permitted - canPhysicalLayer.fireListeners(CanFrame(ControlFrame.AME.value, 0)) + canPhysicalLayer.fireFrameReceived(CanFrame(ControlFrame.AME.value, 0)) self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) self.assertEqual( canPhysicalLayer.receivedFrames[0], @@ -160,7 +160,7 @@ def testAMEnoDataInhibited(self): canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) canLink._state = CanLink.State.Inhibited - canPhysicalLayer.fireListeners(CanFrame(ControlFrame.AME.value, 0)) + canPhysicalLayer.fireFrameReceived(CanFrame(ControlFrame.AME.value, 0)) self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) canLink.onDisconnect() @@ -172,7 +172,7 @@ def testAMEMatchEvent(self): frame = CanFrame(ControlFrame.AME.value, 0) frame.data = bytearray([5, 1, 1, 1, 3, 1]) - canPhysicalLayer.fireListeners(frame) + canPhysicalLayer.fireFrameReceived(frame) self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) self.assertEqual(canPhysicalLayer.receivedFrames[0], CanFrame(ControlFrame.AMD.value, ourAlias, @@ -186,7 +186,7 @@ def testAMENotMatchEvent(self): frame = CanFrame(ControlFrame.AME.value, 0) frame.data = bytearray([0, 0, 0, 0, 0, 0]) - canPhysicalLayer.fireListeners(frame) + canPhysicalLayer.fireFrameReceived(frame) self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) canLink.onDisconnect() @@ -197,7 +197,7 @@ def testCIDreceivedMatch(self): ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink._state = CanLink.State.Permitted - canPhysicalLayer.fireListeners(CanFrame(7, canLink.localNodeID, + canPhysicalLayer.fireFrameReceived(CanFrame(7, canLink.localNodeID, ourAlias)) self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) self.assertEqual(canPhysicalLayer.receivedFrames[0], @@ -210,7 +210,7 @@ def testRIDreceivedMatch(self): ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink._state = CanLink.State.Permitted - canPhysicalLayer.fireListeners(CanFrame(ControlFrame.RID.value, + canPhysicalLayer.fireFrameReceived(CanFrame(ControlFrame.RID.value, ourAlias)) # ^ collision canLink.waitForReady() @@ -286,9 +286,9 @@ def testSimpleGlobalData(self): # map an alias we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) - canPhysicalLayer.fireListeners(amd) + canPhysicalLayer.fireFrameReceived(amd) - canPhysicalLayer.fireListeners(CanFrame(0x19490, 0x247)) + canPhysicalLayer.fireFrameReceived(CanFrame(0x19490, 0x247)) # ^ from previously seen alias self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) @@ -314,7 +314,7 @@ def testVerifiedNodeInDestAliasMap(self): # Don't map an alias with an AMD for this test - canPhysicalLayer.fireListeners(CanFrame(0x19170, 0x247, + canPhysicalLayer.fireFrameReceived(CanFrame(0x19170, 0x247, bytearray([8, 7, 6, 5, 4, 3]))) # ^ VerifiedNodeID from unique alias @@ -342,7 +342,7 @@ def testNoDestInAliasMap(self): # Don't map an alias with an AMD for this test - canPhysicalLayer.fireListeners( + canPhysicalLayer.fireFrameReceived( CanFrame(0x19968, 0x247, bytearray([8, 7, 6, 5, 4, 3]))) # ^ Identify Events Addressed from unique alias @@ -370,14 +370,14 @@ def testSimpleAddressedData(self): # Test start=yes, end=yes frame # map an alias we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) - canPhysicalLayer.fireListeners(amd) + canPhysicalLayer.fireFrameReceived(amd) ourAlias = canLink.getLocalAlias() # ^ 576 with NodeID(0x05_01_01_01_03_01) frame = CanFrame(0x19488, 0x247) # Verify Node ID Addressed frame.data = bytearray([((ourAlias & 0x700) >> 8), (ourAlias & 0xFF), 12, 13]) - canPhysicalLayer.fireListeners(frame) # from previously seen alias + canPhysicalLayer.fireFrameReceived(frame) # from previously seen alias self.assertEqual(len(messageLayer.receivedMessages), 2) # ^ startup plus one message forwarded @@ -412,7 +412,7 @@ def testSimpleAddressedDataNoAliasYet(self): frame.data = bytearray( [((ourAlias & 0x700) >> 8), (ourAlias & 0xFF), 12, 13] ) - canPhysicalLayer.fireListeners(frame) # from previously seen alias + canPhysicalLayer.fireFrameReceived(frame) # from previously seen alias self.assertEqual(len(messageLayer.receivedMessages), 2) # ^ startup plus one message forwarded @@ -445,14 +445,14 @@ def testMultiFrameAddressedData(self): # map an alias we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) - canPhysicalLayer.fireListeners(amd) + canPhysicalLayer.fireFrameReceived(amd) ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) frame = CanFrame(0x19488, 0x247) # Verify Node ID Addressed frame.data = bytearray([(((ourAlias & 0x700) >> 8) | 0x10), (ourAlias & 0xFF), 1, 2]) # ^ start not end - canPhysicalLayer.fireListeners(frame) # from previously seen alias + canPhysicalLayer.fireFrameReceived(frame) # from previously seen alias self.assertEqual(len(messageLayer.receivedMessages), 1) # ^ startup only, no message forwarded yet @@ -461,7 +461,7 @@ def testMultiFrameAddressedData(self): frame.data = bytearray([(((ourAlias & 0x700) >> 8) | 0x20), (ourAlias & 0xFF), 3, 4]) # ^ end, not start - canPhysicalLayer.fireListeners(frame) # from previously seen alias + canPhysicalLayer.fireFrameReceived(frame) # from previously seen alias self.assertEqual(len(messageLayer.receivedMessages), 2) # ^ startup plus one message forwarded @@ -488,14 +488,14 @@ def testSimpleDatagram(self): # Test start=yes, end=yes frame # map two aliases we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) - canPhysicalLayer.fireListeners(amd) + canPhysicalLayer.fireFrameReceived(amd) amd = CanFrame(0x0701, 0x123) amd.data = bytearray([6, 5, 4, 3, 2, 1]) - canPhysicalLayer.fireListeners(amd) + canPhysicalLayer.fireFrameReceived(amd) frame = CanFrame(0x1A123, 0x247) # single frame datagram frame.data = bytearray([10, 11, 12, 13]) - canPhysicalLayer.fireListeners(frame) # from previously seen alias + canPhysicalLayer.fireFrameReceived(frame) # from previously seen alias self.assertEqual(len(messageLayer.receivedMessages), 2) # ^ startup plus one message forwarded @@ -527,20 +527,20 @@ def testMultiFrameDatagram(self): # map two aliases we'll use amd = CanFrame(0x0701, 0x247) amd.data = bytearray([1, 2, 3, 4, 5, 6]) - canPhysicalLayer.fireListeners(amd) + canPhysicalLayer.fireFrameReceived(amd) amd = CanFrame(0x0701, 0x123) amd.data = bytearray([6, 5, 4, 3, 2, 1]) - canPhysicalLayer.fireListeners(amd) + canPhysicalLayer.fireFrameReceived(amd) frame = CanFrame(0x1B123, 0x247) # single frame datagram frame.data = bytearray([10, 11, 12, 13]) - canPhysicalLayer.fireListeners(frame) # from previously seen alias + canPhysicalLayer.fireFrameReceived(frame) # from previously seen alias frame = CanFrame(0x1C123, 0x247) # single frame datagram frame.data = bytearray([20, 21, 22, 23]) - canPhysicalLayer.fireListeners(frame) # from previously seen alias + canPhysicalLayer.fireFrameReceived(frame) # from previously seen alias frame = CanFrame(0x1D123, 0x247) # single frame datagram frame.data = bytearray([30, 31, 32, 33]) - canPhysicalLayer.fireListeners(frame) # from previously seen alias + canPhysicalLayer.fireFrameReceived(frame) # from previously seen alias while True: frame = canPhysicalLayer.pollFrame() @@ -662,7 +662,7 @@ def testAmdAmrSequence(self): canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) - canPhysicalLayer.fireListeners(CanFrame(0x0701, ourAlias+1)) + canPhysicalLayer.fireFrameReceived(CanFrame(0x0701, ourAlias+1)) # ^ AMD from some other alias self.assertEqual(len(canLink.aliasToNodeID), 1) @@ -671,7 +671,7 @@ def testAmdAmrSequence(self): self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) # ^ nothing back down to CAN - canPhysicalLayer.fireListeners(CanFrame(0x0703, ourAlias+1)) + canPhysicalLayer.fireFrameReceived(CanFrame(0x0703, ourAlias+1)) # ^ AMR from some other alias self.assertEqual(len(canLink.aliasToNodeID), 0) diff --git a/tests/test_canphysicallayer.py b/tests/test_canphysicallayer.py index 04edaee..8d892d1 100644 --- a/tests/test_canphysicallayer.py +++ b/tests/test_canphysicallayer.py @@ -9,13 +9,13 @@ class TestCanPhysicalLayerClass(unittest.TestCase): # test function marks that the listeners were fired received = False - def receiveListener(self, frame): + def receiveListener(self, frame: CanFrame): self.received = True - def handleReceivedFrame(self, frame): + def handleReceivedFrame(self, frame: CanFrame): pass - def handleSentFrame(self, frame): + def handleSentFrame(self, frame: CanFrame): pass def testReceipt(self): @@ -27,7 +27,7 @@ def testReceipt(self): layer.onSentFrame = self.handleSentFrame layer.registerFrameReceivedListener(receiver) - layer.fireListeners(frame) + layer.fireFrameReceived(frame) self.assertTrue(self.received) diff --git a/tests/test_datagramservice.py b/tests/test_datagramservice.py index 22cebf0..8771ae5 100644 --- a/tests/test_datagramservice.py +++ b/tests/test_datagramservice.py @@ -56,7 +56,7 @@ def testFireListeners(self): self.service.registerDatagramReceivedListener(receiver) - self.service.fireListeners(msg) + self.service.fireDatagramReceived(msg) self.assertTrue(self.received) diff --git a/tests/test_linklayer.py b/tests/test_linklayer.py index b37a297..12c70ce 100644 --- a/tests/test_linklayer.py +++ b/tests/test_linklayer.py @@ -30,7 +30,7 @@ def testReceipt(self): ) layer.registerMessageReceivedListener(receiver) - layer.fireListeners(msg) + layer.fireMessageReceived(msg) self.assertTrue(self.received) @@ -42,5 +42,6 @@ def testEnum(self): usedValues.add(entry.value) # print('{} = {}'.format(entry.name, entry.value)) + if __name__ == '__main__': unittest.main() From 7d4c6fe46c4e84d328d1b5e6f963737f4c570c5c Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Mon, 19 May 2025 10:43:03 -0400 Subject: [PATCH 66/99] Rename test class to match class being tested. --- tests/test_openlcbnetwork.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_openlcbnetwork.py b/tests/test_openlcbnetwork.py index e019f78..58162c9 100644 --- a/tests/test_openlcbnetwork.py +++ b/tests/test_openlcbnetwork.py @@ -3,7 +3,7 @@ from openlcb.openlcbnetwork import OpenLCBNetwork -class DispatcherTest(unittest.TestCase): +class OpenLCBNetworkTest(unittest.TestCase): def setUp(self): pass From eea8edde8df12e395db964da845399fe49adef21 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Mon, 19 May 2025 10:46:51 -0400 Subject: [PATCH 67/99] Fix: undefined variable (use self._physicalLayer). --- openlcb/openlcbnetwork.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openlcb/openlcbnetwork.py b/openlcb/openlcbnetwork.py index af498a5..3ee4342 100644 --- a/openlcb/openlcbnetwork.py +++ b/openlcb/openlcbnetwork.py @@ -258,7 +258,8 @@ def _listen(self): # which is expected and used on purpose) # print("Waiting for _receive") received = self._receive() # requires setblocking(False) - print("[_listen] received {} byte(s)".format(len(received)), + print("[_listen] received {} byte(s)" + .format(len(received)), file=sys.stderr) # print(" RR: {}".format(received.strip())) # pass to link processor @@ -304,7 +305,7 @@ def _listen(self): assert isinstance(packet, str) print("Sending {}".format(packet)) self._port.sendString(packet) - physicalLayer.onSentFrame(frame) + self._physicalLayer.onSentFrame(frame) else: raise NotImplementedError( "Event type {} is not handled." From d765d262c93dba8d7d87082f85357b7e5193be04 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Mon, 19 May 2025 11:38:21 -0400 Subject: [PATCH 68/99] For consistency rename receiveListener to handleFrameReceived and change noun-verb order on related methods. Add more type hinting. --- examples/example_cdi_access.py | 6 ++-- examples/example_datagram_transfer.py | 4 +-- examples/example_frame_interface.py | 17 +++++------ examples/example_memory_length_query.py | 4 +-- examples/example_memory_transfer.py | 4 +-- examples/example_message_interface.py | 4 +-- examples/example_node_implementation.py | 4 +-- examples/example_remote_nodes.py | 4 +-- examples/example_string_serial_interface.py | 2 +- examples/example_tcp_message_interface.py | 4 +-- openlcb/canbus/canlink.py | 32 +++++++++++---------- openlcb/canbus/canlinklayersimulation.py | 12 ++++---- openlcb/canbus/canphysicallayer.py | 14 +++------ openlcb/linklayer.py | 18 ++++++------ openlcb/openlcbnetwork.py | 17 ++++------- openlcb/physicallayer.py | 25 ++++++++++++---- openlcb/realtimephysicallayer.py | 15 ++++++---- openlcb/tcplink/tcplink.py | 2 +- tests/test_canlink.py | 6 ++-- tests/test_canphysicallayer.py | 8 +++--- tests/test_canphysicallayergridconnect.py | 8 +++--- tests/test_tcplink.py | 20 ++++++------- 22 files changed, 120 insertions(+), 110 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index 9f8ee08..04a220f 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -59,7 +59,7 @@ # string = frame.encodeAsString() # # print(" SR: {}".format(string.strip())) # sock.sendString(string) -# physicalLayer.onSentFrame(frame) +# physicalLayer.onFrameSent(frame) def printFrame(frame): @@ -267,7 +267,7 @@ def pumpEvents(): # ^ This is too verbose for this example (each is a # request to read a 64 byte chunks of the CDI XML) sock.sendString(string) - physicalLayer.onSentFrame(frame) + physicalLayer.onFrameSent(frame) # have the socket layer report up to bring the link layer up and get an alias @@ -299,7 +299,7 @@ def memoryRead(): # (*only if it has the same alias*) must reply to ensure our alias # is ok according to the LCC CAN Frame Transfer Standard. # - The countdown does not start until after the socket loop - # calls onSentFrame. This ensures that nodes had a chance to + # calls onFrameSent. This ensures that nodes had a chance to # respond (the Standard only states to wait after sending, so # any latency after send is the responsibility of the Standard). # - Then wait longer below if there was a failure/retry: diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index 183f381..dc97078 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -53,7 +53,7 @@ # string = frame.encodeAsString() # print(" SR: "+string.strip()) # sock.sendString(string) -# physicalLayer.onSentFrame(frame) +# physicalLayer.onFrameSent(frame) def printFrame(frame): @@ -117,7 +117,7 @@ def pumpEvents(): if frame is None: break sock.sendString(frame.encodeAsString()) - physicalLayer.onSentFrame(frame) + physicalLayer.onFrameSent(frame) # have the socket layer report up to bring the link layer up and get an alias diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index cf8654e..f7adadb 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -45,7 +45,7 @@ def sendToSocket(frame: CanFrame): string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) - physicalLayer.onSentFrame(frame) + physicalLayer.onFrameSent(frame) def pumpEvents(): @@ -67,21 +67,22 @@ def pumpEvents(): string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) - physicalLayer.onSentFrame(frame) + physicalLayer.onFrameSent(frame) if frame.afterSendState: print("Next state (unexpected, no link layer): {}" .format(frame.afterSendState)) # canLink.setState(frame.afterSendState) - # ^ setState is done by onSentFrame now - # (physicalLayer.onSentFrame = self.handleSentFrame + # ^ setState is done by onFrameSent now + # (physicalLayer.onFrameSent = self.handleFrameSent # in LinkLayer constructor) -def handleSentFrame(frame): +def handleFrameSent(frame): # No state to manage since no link layer pass -def handleReceivedFrame(frame): + +def handleFrameReceived(frame): # No state to manage since no link layer pass @@ -91,8 +92,8 @@ def printFrame(frame): physicalLayer = CanPhysicalLayerGridConnect() -physicalLayer.onSentFrame = handleSentFrame -physicalLayer.onReceivedFrame = handleReceivedFrame +physicalLayer.onFrameSent = handleFrameSent +physicalLayer.onFrameReceived = handleFrameReceived physicalLayer.registerFrameReceivedListener(printFrame) # send an AME frame with arbitrary alias to provoke response diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index afa0bc3..1db08cb 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -57,7 +57,7 @@ # string = frame.encodeAsString() # print(" SR: {}".format(string.strip())) # sock.sendString(string) -# physicalLayer.onSentFrame(frame) +# physicalLayer.onFrameSent(frame) def printFrame(frame): @@ -138,7 +138,7 @@ def pumpEvents(): string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) - physicalLayer.onSentFrame(frame) + physicalLayer.onFrameSent(frame) # have the socket layer report up to bring the link layer up and get an alias diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index 00f0ad1..e3c16eb 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -58,7 +58,7 @@ def sendToSocket(frame: CanFrame): string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) - physicalLayer.onSentFrame(frame) + physicalLayer.onFrameSent(frame) def printFrame(frame): @@ -132,7 +132,7 @@ def pumpEvents(): if frame is None: break sock.sendString(frame.encodeAsString()) - physicalLayer.onSentFrame(frame) + physicalLayer.onFrameSent(frame) # have the socket layer report up to bring the link layer up and get an alias diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index ad63c76..f1486ff 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -50,7 +50,7 @@ # string = frame.encodeAsString() # print(" SR: {}".format(string.strip())) # sock.sendString(string) -# physicalLayer.onSentFrame(frame) +# physicalLayer.onFrameSent(frame) def printFrame(frame): @@ -90,7 +90,7 @@ def pumpEvents(): string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) - physicalLayer.onSentFrame(frame) + physicalLayer.onFrameSent(frame) # have the socket layer report up to bring the link layer up and get an alias diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index 8e2206f..7b972f8 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -63,7 +63,7 @@ # string = frame.encodeAsString() # print(" SR: {}".format(string.strip())) # sock.sendString(string) -# physicalLayer.onSentFrame(frame) +# physicalLayer.onFrameSent(frame) def printFrame(frame): @@ -164,7 +164,7 @@ def pumpEvents(): string = frame.encodeAsString() print(" SR: {}".format(string.strip())) sock.sendString(string) - physicalLayer.onSentFrame(frame) + physicalLayer.onFrameSent(frame) # have the socket layer report up to bring the link layer up and get an alias diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index 8154096..a372d50 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -64,7 +64,7 @@ # string = frame.encodeAsString() # if settings['trace'] : print(" SR: "+string.strip()) # sock.sendString(string) - # physicalLayer.onSentFrame(frame) + # physicalLayer.onFrameSent(frame) def receiveFrame(frame) : @@ -133,7 +133,7 @@ def pumpEvents(): if True: # if settings['trace']: print("- SENT Remote: "+string.strip()) sock.sendString(string) - physicalLayer.onSentFrame(frame) + physicalLayer.onFrameSent(frame) # bring the CAN level up diff --git a/examples/example_string_serial_interface.py b/examples/example_string_serial_interface.py index 0b1ebe1..296eee5 100644 --- a/examples/example_string_serial_interface.py +++ b/examples/example_string_serial_interface.py @@ -56,5 +56,5 @@ # if frame is None: # break # sock.sendString(frame.encodeAsString()) - # physicalLayer.onSentFrame(frame) + # physicalLayer.onFrameSent(frame) precise_sleep(.01) diff --git a/examples/example_tcp_message_interface.py b/examples/example_tcp_message_interface.py index f04623a..de5c762 100644 --- a/examples/example_tcp_message_interface.py +++ b/examples/example_tcp_message_interface.py @@ -94,7 +94,7 @@ def printMessage(msg): if received is not None: print(" RR: {}".format(received)) # pass to link processor - tcpLinkLayer.receiveListener(received) + tcpLinkLayer.handleFrameReceived(received) # Normally we would do (Probably N/A here): # canLink.pollState() # @@ -103,5 +103,5 @@ def printMessage(msg): # if frame is None: # break # sock.sendString(frame.encodeAsString()) - # physicalLayer.onSentFrame(frame) + # physicalLayer.onFrameSent(frame) precise_sleep(.01) diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 297d9f9..5d07921 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -264,7 +264,7 @@ def _onStateChanged(self, oldState, newState): # necessary (formerly only states other than Initial were # Inhibited & Permitted). - def receiveListener(self, frame): + def handleFrameReceived(self, frame: CanFrame): """Call the correct handler if any for a received frame. Typically this is called by CanPhysicalLayer since the linkPhysicalLayer method in this class registers this method as @@ -310,6 +310,8 @@ def receiveListener(self, frame): ControlFrame.EIR3): self._errorCount += 1 if self.isRunningAliasReservation(): + print("Restarting alias reservation due to error ({})." + .format(control_frame)) # Restart alias reservation process if an # error occurs during it, as per section # 6.2.1 of CAN Frame Transfer - Standard. @@ -342,7 +344,7 @@ def isRunningAliasReservation(self): CanLink.State.WaitingForSendReserveID ) - def handleReceivedLinkUp(self, frame): + def handleReceivedLinkUp(self, frame: CanFrame): """Link started, update state, start process to create alias. LinkUp message will be sent when alias process completes. @@ -354,7 +356,7 @@ def handleReceivedLinkUp(self, frame): self.defineAndReserveAlias() print("[CanLink] done calling defineAndReserveAlias.") - def handleReceivedLinkRestarted(self, frame): + def handleReceivedLinkRestarted(self, frame: CanFrame): """Send a LinkRestarted message upstream. Args: @@ -413,7 +415,7 @@ def _recordReservation(self): # called on restart # TODO: (restart) This is not called; there's no callback for it in # Telnet library - def handleReceivedLinkDown(self, frame): + def handleReceivedLinkDown(self, frame: CanFrame): """return to Inhibited state until link back up Args: @@ -446,7 +448,7 @@ def linkStateChange(self, state: State): "The other layers don't need to know the intermediate steps.") self.fireMessageReceived(msg) - def handleReceivedCID(self, frame): # CanFrame + def handleReceivedCID(self, frame: CanFrame): """Handle a Check ID (CID) frame only if addressed to us (used to verify node uniqueness). Additional arguments may be encoded in lower bits of frame.header (below ControlFrame.CID). @@ -458,13 +460,13 @@ def handleReceivedCID(self, frame): # CanFrame self.physicalLayer.sendFrameAfter(CanFrame(ControlFrame.RID.value, self._localAlias)) - def handleReceivedRID(self, frame): # CanFrame + def handleReceivedRID(self, frame: CanFrame): """Handle a Reserve ID (RID) frame (used for alias reservation).""" if self.checkAndHandleAliasCollision(frame): return - def handleReceivedAMD(self, frame): # CanFrame + def handleReceivedAMD(self, frame: CanFrame): """Handle an Alias Map Definition (AMD) frame (Defines a mapping between an alias and a full Node ID). """ @@ -485,7 +487,7 @@ def handleReceivedAMD(self, frame): # CanFrame .format(nodeID)) self.nodeIdToAlias[nodeID] = alias - def handleReceivedAME(self, frame): # CanFrame + def handleReceivedAME(self, frame: CanFrame): """Handle an Alias Mapping Enquiry (AME) frame (a node requested alias information from other nodes). """ @@ -504,7 +506,7 @@ def handleReceivedAME(self, frame): # CanFrame self.localNodeID.toArray()) self.physicalLayer.sendFrameAfter(returnFrame) - def handleReceivedAMR(self, frame): # CanFrame + def handleReceivedAMR(self, frame: CanFrame): """Handle an Alias Map Reset (AMR) frame (A node is asking to remove an alias from mappings). """ @@ -521,7 +523,7 @@ def handleReceivedAMR(self, frame): # CanFrame except: pass - def handleReceivedData(self, frame): # CanFrame + def handleReceivedData(self, frame: CanFrame): """Handle a data frame. Additional arguments may be encoded in lower bits (below ControlFrame.Data) in frame.header. @@ -696,7 +698,7 @@ def handleReceivedData(self, frame): # CanFrame msg.originalMTI = ((frame.header >> 12) & 0xFFF) self.fireMessageReceived(msg) - def sendMessage(self, msg): + def sendMessage(self, msg: Message): # special case for datagram if msg.mti == MTI.Datagram: header = 0x10_00_00_00 @@ -865,7 +867,7 @@ def segmentAddressedDataArray(self, alias, data): return segments # MARK: common code - def checkAndHandleAliasCollision(self, frame): + def checkAndHandleAliasCollision(self, frame: CanFrame): if self._state != CanLink.State.Permitted: return False receivedAlias = frame.header & 0x0_00_0F_FF @@ -884,7 +886,7 @@ def markDuplicateAlias(self, alias): .format(emit_cast(alias))) self.duplicateAliases.append(alias) - def processCollision(self, frame) : + def processCollision(self, frame: CanFrame) : ''' Collision! ''' self._aliasCollisionCount += 1 logger.warning( @@ -1071,7 +1073,7 @@ def createAlias12(self, rnd): return ((part1+part2+part3+part4) & 0xFF) return 0xAEF # Why'd you say Burma? - def decodeControlFrameFormat(self, frame): + def decodeControlFrameFormat(self, frame: CanFrame): if (frame.header & 0x0800_0000) == 0x0800_0000: # data case; not checking leading 1 bit # NOTE: handleReceivedData can get all header bits via frame @@ -1091,7 +1093,7 @@ def decodeControlFrameFormat(self, frame): .format(frame.header)) return ControlFrame.UnknownFormat - def canHeaderToFullFormat(self, frame): + def canHeaderToFullFormat(self, frame: CanFrame): '''Returns a full 16-bit MTI from the full 29 bits of a CAN header''' frameType = (frame.header >> 24) & 0x7 canMTI = ((frame.header >> 12) & 0xFFF) diff --git a/openlcb/canbus/canlinklayersimulation.py b/openlcb/canbus/canlinklayersimulation.py index 61d6bb4..2ac68e2 100644 --- a/openlcb/canbus/canlinklayersimulation.py +++ b/openlcb/canbus/canlinklayersimulation.py @@ -42,7 +42,7 @@ def pumpEvents(self): # ^ This is too verbose for this example (each is a # request to read a 64 byte chunks of the CDI XML) # sock.sendString(string) - self.physicalLayer.onSentFrame(frame) + self.physicalLayer.onFrameSent(frame) def waitForReady(self, run_physical_link_up_test=False): """ @@ -54,7 +54,7 @@ def waitForReady(self, run_physical_link_up_test=False): AssertionError: run_physical_link_up_test is True but the state is not initially WaitingForSendCIDSequence or successive states were not triggered by pollState - and onSentFrame. + and onFrameSent. """ self = self first = True @@ -82,20 +82,20 @@ def waitForReady(self, run_physical_link_up_test=False): print(" * state: {}".format(state)) state = self.getState() if first_state == CanLink.State.WaitingForSendCIDSequence: - # State should be set by onSentFrame (called by + # State should be set by onFrameSent (called by # pumpEvents, or in non-simulation cases, the socket loop # after dequeued and sent, as the next state is ) if second_state is None: assert state == CanLink.State.WaitForAliases, \ - ("expected onSentFrame (if properly set to" - " handleSentFrame or overridden for simulation) sent" + ("expected onFrameSent (if properly set to" + " handleFrameSent or overridden for simulation) sent" " frame's EnqueueAliasAllocationRequest state (CID" " 4's afterSendState), but state is {}" .format(state)) second_state = state # If pumpEvents blocks for at least 200ms after send # then receives, responses may have already been send - # to handleReceivedFrame, in which case we may be in a + # to handleFrameReceived, in which case we may be in a # later state. That isn't recommended except for # realtime applications (or testing). However, if that # is programmed, add diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index 9d20033..7dfc584 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -24,12 +24,6 @@ def __init__(self,): PhysicalLayer.__init__(self) self._frameReceivedListeners = [] - def onReceivedFrame(self, frame): - raise NotImplementedError( - "Your LinkLayer/subclass must patch the instance:" - " Set this method manually to the CanLink instance's" - " receiveListener method.") - def sendFrameAfter(self, frame: CanFrame): """Enqueue: *IMPORTANT* Main/other thread may have called this. Any other thread sending other than the _listen @@ -69,7 +63,7 @@ def registerFrameReceivedListener(self, listener): " You don't really need to listen to packets." " Use pollFrame instead, which will collect and decode" " packets into frames (this layer communicates to upper layers" - " using physicalLayer.onReceivedFrame set by LinkLayer/subclass" + " using physicalLayer.onFrameReceived set by LinkLayer/subclass" " constructor).") self._frameReceivedListeners.append(listener) @@ -77,16 +71,16 @@ def fireFrameReceived(self, frame: CanFrame): """Fire *CanFrame received* listeners. Monitor each frame that is constructed as the application provides handleData raw data from the port. - - LinkLayer (CanLink in this case) must set onReceivedFrame, + - LinkLayer (CanLink in this case) must set onFrameReceived, so registerFrameReceivedListener is now optional, and a Message handler should usually be used instead. """ - # (onReceivedFrame was implemented to make it clear by way of + # (onFrameReceived was implemented to make it clear by way of # constructor code that the handler is required in order for # the openlcb network stack (This Python module) to # operate--See # - self.onReceivedFrame(frame) + self.onFrameReceived(frame) for listener in self._frameReceivedListeners: listener(frame) diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index 284cc97..94ca54e 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -18,7 +18,6 @@ from enum import Enum from logging import getLogger -import warnings from openlcb import emit_cast from openlcb.message import Message @@ -57,15 +56,15 @@ def __init__(self, physicalLayer: PhysicalLayer, localNodeID): # region moved from CanLink linkPhysicalLayer self.physicalLayer = physicalLayer # formerly self.link = cpl # if physicalLayer is not None: - # listener = self.receiveListener # try to prevent + # listener = self.handleFrameReceived # try to prevent # "new bound method" Python behavior in subclass from making "is" # operator not work as expected in registerFrameReceivedListener. - physicalLayer.onReceivedFrame = self.receiveListener - physicalLayer.onSentFrame = self.handleSentFrame + physicalLayer.onFrameReceived = self.handleFrameReceived + physicalLayer.onFrameSent = self.handleFrameSent # # ^ enforce queue paradigm (See use in PhysicalLayer subclass) # physicalLayer.registerFrameReceivedListener(listener) # ^ Doesn't work with "is" operator still! So just use - # physicalLayer.onReceivedFrame in fireListeners in PhysicalLayer. + # physicalLayer.onFrameReceived in fireFrameReceived in PhysicalLayer. # else: # print("Using {} without" # " registerFrameReceivedListener(self.receiveListener)" @@ -79,16 +78,17 @@ def __init__(self, physicalLayer: PhysicalLayer, localNodeID): " LinkLayer.State and LinkLayer.DisconnectedState" " are only for testing. Redefine them in each subclass" " (got LinkLayer.State({}) for {}.DisconnectedState)" - .format(emit_cast(type(self).DisconnectedState), type(self).__name__)) + .format(emit_cast(type(self).DisconnectedState), + type(self).__name__)) - def receiveListener(self, frame): + def handleFrameReceived(self, frame): logger.warning( "{} abstract receiveListener called (expected implementation)" .format(type(self).__name__)) - def handleSentFrame(self, frame): + def handleFrameSent(self, frame): """Update state based on the frame having been sent.""" - if frame.afterSendState: + if frame.afterSendState is not None: self.setState(frame.afterSendState) def onDisconnect(self): diff --git a/openlcb/openlcbnetwork.py b/openlcb/openlcbnetwork.py index 3ee4342..944632b 100644 --- a/openlcb/openlcbnetwork.py +++ b/openlcb/openlcbnetwork.py @@ -168,15 +168,10 @@ def start_listening(self, connected_port, localNodeID): self._callback_status("CanPhysicalLayerGridConnect...") self._physicalLayer = CanPhysicalLayerGridConnect() - # self._physicalLayer.registerFrameReceivedListener( - # self._printFrame - # ) - # ^ Commented since CanLink constructor now registers its default - # receiveListener to CanLinkPhysicalLayer & that's all we need - # for this application. - self._callback_status("CanLink...") self._canLink = CanLink(self._physicalLayer, NodeID(localNodeID)) + # ^ CanLink constructor sets _physicalLayer's onFrameReceived + # and onFrameSent to handlers in _canLink. self._callback_status("CanLink..." "registerMessageReceivedListener...") self._canLink.registerMessageReceivedListener(self._handleMessage) @@ -211,12 +206,12 @@ def start_listening(self, connected_port, localNodeID): self._callback_status("Waiting for alias reservation...") while self._canLink.pollState() != CanLink.State.Permitted: precise_sleep(.02) - # ^ triggers fireListeners which calls CanLink's default + # ^ triggers fireFrameReceived which calls CanLink's default # receiveListener by default since added on CanPhysicalLayer # arg of linkPhysicalLayer. # - Must happen *after* listen thread starts, since - # generates ControlFrame.LinkUp and calls fireListeners - # which calls sendAliasConnectionSequence on this thread! + # fireFrameReceived (ControlFrame.LinkUp) + # calls sendAliasConnectionSequence on this thread! self._callback_status("Alias reservation complete.") def listen(self): @@ -305,7 +300,7 @@ def _listen(self): assert isinstance(packet, str) print("Sending {}".format(packet)) self._port.sendString(packet) - self._physicalLayer.onSentFrame(frame) + self._physicalLayer.onFrameSent(frame) else: raise NotImplementedError( "Event type {} is not handled." diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index 80196e1..e1230ba 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -112,10 +112,23 @@ def sendFrameAfter(self, frame): if self.onQueuedFrame: self.onQueuedFrame(frame) - def onSentFrame(self, frame): - """Stub patched at runtime: - LinkLayer subclass's constructor must set instance's onSentFrame - to LinkLayer subclass' handleSentFrame (The application must + def onFrameReceived(self, frame): + """Stub method, patched at runtime: + LinkLayer subclass's constructor must set instance's + onFrameReceived to LinkLayer subclass' handleFrameReceived (The + application must pass this instance to LinkLayer subclass's + constructor so it will do that). + """ + raise NotImplementedError( + "Your LinkLayer/subclass must patch" + " the PhysicalLayer/subclass instance:" + " Set this method manually to LinkLayer/subclass instance's" + " handleFrameReceived method.") + + def onFrameSent(self, frame): + """Stub method, patched at runtime: + LinkLayer subclass's constructor must set instance's onFrameSent + to LinkLayer subclass' handleFrameSent (The application must pass this instance to LinkLayer subclass's constructor so it will do that). @@ -134,8 +147,8 @@ def onSentFrame(self, frame): """ raise NotImplementedError( "The subclass must patch the instance:" - " PhysicalLayer instance's onSentFrame must be manually set" - " to the LinkLayer subclass instance' handleSentFrame" + " PhysicalLayer instance's onFrameSent must be manually set" + " to the LinkLayer subclass instance' handleFrameSent" " so state can be updated if necessary.") def registerFrameReceivedListener(self, listener): diff --git a/openlcb/realtimephysicallayer.py b/openlcb/realtimephysicallayer.py index 01ab073..2f7a3c6 100644 --- a/openlcb/realtimephysicallayer.py +++ b/openlcb/realtimephysicallayer.py @@ -38,19 +38,24 @@ def sendFrameAfter(self, frame): # .format(type(data).__name__, data) # ) print(" SR: {}".format(frame.encode())) - # send and fireListeners would usually occur after + # send and fireFrameReceived would usually occur after # frame from _send_frames.popleft is sent, # but we do all this here in the Realtime subclass: self.sock.send(frame.encode()) - # TODO: finish onSentFrame + # TODO: finish onFrameSent if frame.afterSendState: - self.fireListeners(frame) # also calls self.onSentFrame(frame) + self.fireFrameReceived(frame) # also calls self.onFrameSent(frame) def registerFrameReceivedListener(self, listener): - """_summary_ + """Register a new frame received listener + (optional since LinkLayer subclass constructor sets + self.onFrameReceived to its handler). Args: listener (callable): A method that accepts decoded frame objects from the network. """ - logger.warning("registerFrameReceivedListener skipped (That is a link-layer issue, but you are using a Raw physical layer subclass).") + logger.warning( + "registerFrameReceivedListener skipped" + " (That is a link-layer issue, but you are using" + " a Raw physical layer subclass).") diff --git a/openlcb/tcplink/tcplink.py b/openlcb/tcplink/tcplink.py index 5f40ab2..93fcf1f 100644 --- a/openlcb/tcplink/tcplink.py +++ b/openlcb/tcplink/tcplink.py @@ -65,7 +65,7 @@ def _onStateChanged(self, oldState, newState): print(f"[TcpLink] _onStateChanged from {oldState} to {newState}" " (nothing to do since TcpLink)") - def receiveListener(self, inputData): # [] input + def handleFrameReceived(self, inputData: bytearray): # [] input """Receives bytes from lower level and accumulates them into individual message parts. diff --git a/tests/test_canlink.py b/tests/test_canlink.py index 9aec9da..25d1f6b 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -18,8 +18,8 @@ class PhyMockLayer(CanPhysicalLayer): def __init__(self): - # onSentFrame will not work until this instance is passed to the - # LinkLayer subclass' constructor (See onSentFrame + # onFrameSent will not work until this instance is passed to the + # LinkLayer subclass' constructor (See onFrameSent # docstring in PhysicalLayer) self.receivedFrames = [] CanPhysicalLayer.__init__(self) @@ -549,7 +549,7 @@ def testMultiFrameDatagram(self): # FIXME: Pretending sent is not effective if dest is mock node # (if its state will be checked in the test!) but if we are # using pure CAN (not packed with LCC alias) it is P2P. - canPhysicalLayer.onSentFrame(frame) + canPhysicalLayer.onFrameSent(frame) self.assertEqual(len(messageLayer.receivedMessages), 2) # ^ startup plus one message forwarded diff --git a/tests/test_canphysicallayer.py b/tests/test_canphysicallayer.py index 8d892d1..f8da1fa 100644 --- a/tests/test_canphysicallayer.py +++ b/tests/test_canphysicallayer.py @@ -12,10 +12,10 @@ class TestCanPhysicalLayerClass(unittest.TestCase): def receiveListener(self, frame: CanFrame): self.received = True - def handleReceivedFrame(self, frame: CanFrame): + def handleFrameReceived(self, frame: CanFrame): pass - def handleSentFrame(self, frame: CanFrame): + def handleFrameSent(self, frame: CanFrame): pass def testReceipt(self): @@ -23,8 +23,8 @@ def testReceipt(self): frame = CanFrame(0x000, bytearray()) receiver = self.receiveListener layer = CanPhysicalLayer() - layer.onReceivedFrame = self.handleReceivedFrame - layer.onSentFrame = self.handleSentFrame + layer.onFrameReceived = self.handleFrameReceived + layer.onFrameSent = self.handleFrameSent layer.registerFrameReceivedListener(receiver) layer.fireFrameReceived(frame) diff --git a/tests/test_canphysicallayergridconnect.py b/tests/test_canphysicallayergridconnect.py index 69dc4c2..ceb03e1 100644 --- a/tests/test_canphysicallayergridconnect.py +++ b/tests/test_canphysicallayergridconnect.py @@ -29,14 +29,14 @@ def captureString(self, frame): self.capturedFrame.encoder = self self.capturedString = frame.encodeAsString() - def onSentFrame(self, frame): + def onFrameSent(self, frame): pass - # NOTE: not patching this method to be canLink.handleSentFrame + # NOTE: not patching this method to be canLink.handleFrameSent # since testing only physical layer not link layer. - def onReceivedFrame(self, frame): + def onFrameReceived(self, frame): pass - # NOTE: not patching this method to be canLink.handleReceivedFrame + # NOTE: not patching self.onFrameReceived = canLink.handleFrameReceived # since testing only physical layer not link layer. diff --git a/tests/test_tcplink.py b/tests/test_tcplink.py index b2d659b..a319f54 100644 --- a/tests/test_tcplink.py +++ b/tests/test_tcplink.py @@ -83,7 +83,7 @@ def testOneMessageOnePartOneClump(self) : 0x04, 0x90, # MTI: VerifyNode 0x00, 0x00, 0x00, 0x00, 0x03, 0x21 # source NodeID ]) - linkLayer.receiveListener(messageText) + linkLayer.handleFrameReceived(messageText) self.assertEqual(len(tcpLayer.receivedText), 0) self.assertEqual(len(messageLayer.receivedMessages), 1) @@ -101,7 +101,7 @@ def testOneMessageOnePartTwoClumps(self) : 0x80, 0x00, # full message 0x00, 0x00, 20, ]) - linkLayer.receiveListener(messageText) + linkLayer.handleFrameReceived(messageText) messageText = bytearray([ 0x00, 0x00, 0x00, 0x00, 0x01, 0x23, # source node ID @@ -109,7 +109,7 @@ def testOneMessageOnePartTwoClumps(self) : 0x04, 0x90, # MTI: VerifyNode 0x00, 0x00, 0x00, 0x00, 0x03, 0x21 # source NodeID ]) - linkLayer.receiveListener(messageText) + linkLayer.handleFrameReceived(messageText) self.assertEqual(len(tcpLayer.receivedText), 0) self.assertEqual(len(messageLayer.receivedMessages), 1) @@ -127,19 +127,19 @@ def testOneMessageOnePartThreeClumps(self) : 0x80, 0x00, # full message 0x00, 0x00, 20, ]) - linkLayer.receiveListener(messageText) + linkLayer.handleFrameReceived(messageText) messageText = bytearray([ 0x00, 0x00, 0x00, 0x00, 0x01, 0x23, # source node ID 0x00, 0x00, 0x11, 0x00, 0x00, 0x00, # time ]) - linkLayer.receiveListener(messageText) + linkLayer.handleFrameReceived(messageText) messageText = bytearray([ 0x04, 0x90, # MTI: VerifyNode 0x00, 0x00, 0x00, 0x00, 0x03, 0x21 # source NodeID ]) - linkLayer.receiveListener(messageText) + linkLayer.handleFrameReceived(messageText) self.assertEqual(len(tcpLayer.receivedText), 0) self.assertEqual(len(messageLayer.receivedMessages), 1) @@ -161,7 +161,7 @@ def testTwoMessageOnePartTwoClumps(self) : 0x04, 0x90, # MTI: VerifyNode 0x00, 0x00, 0x00, 0x00, 0x03, 0x21 # source NodeID ]) - linkLayer.receiveListener(messageText) + linkLayer.handleFrameReceived(messageText) messageText = bytearray([ 0x80, 0x00, # full message @@ -171,7 +171,7 @@ def testTwoMessageOnePartTwoClumps(self) : 0x04, 0x90, # MTI: VerifyNode 0x00, 0x00, 0x00, 0x00, 0x04, 0x56 # source NodeID ]) - linkLayer.receiveListener(messageText) + linkLayer.handleFrameReceived(messageText) self.assertEqual(len(tcpLayer.receivedText), 0) self.assertEqual(len(messageLayer.receivedMessages), 2) @@ -201,7 +201,7 @@ def testOneMessageTwoPartsOneClump(self) : 0x90, # second half MTI 0x00, 0x00, 0x00, 0x00, 0x03, 0x21 # source NodeID ]) - linkLayer.receiveListener(messageText) + linkLayer.handleFrameReceived(messageText) self.assertEqual(len(tcpLayer.receivedText), 0) self.assertEqual(len(messageLayer.receivedMessages), 1) @@ -235,7 +235,7 @@ def testOneMessageThreePartsOneClump(self) : 0x00, 0x00, 0x11, 0x00, 0x00, 0x00, # time 0x00, 0x00, 0x00, 0x00, 0x03, 0x21 # source NodeID ]) - linkLayer.receiveListener(messageText) + linkLayer.handleFrameReceived(messageText) self.assertEqual(len(tcpLayer.receivedText), 0) self.assertEqual(len(messageLayer.receivedMessages), 1) From 3a3c08884bf25985a3476732a7df952c36c7f315 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Mon, 19 May 2025 14:20:49 -0400 Subject: [PATCH 69/99] Rename a test to match the new method name. --- tests/test_datagramservice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_datagramservice.py b/tests/test_datagramservice.py index 8771ae5..2b05ea9 100644 --- a/tests/test_datagramservice.py +++ b/tests/test_datagramservice.py @@ -50,7 +50,7 @@ def receiveListener(self, msg): self.readMemos.append(msg) return True - def testFireListeners(self): + def testFireDatagramReceived(self): msg = DatagramReadMemo(NodeID(12), bytearray()) receiver = self.receiveListener From b93aa26d587b77f07b62d9170035931b4f0eeb13 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Mon, 19 May 2025 14:21:36 -0400 Subject: [PATCH 70/99] Add a type hint. --- openlcb/datagramservice.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openlcb/datagramservice.py b/openlcb/datagramservice.py index 6201705..8af8d35 100644 --- a/openlcb/datagramservice.py +++ b/openlcb/datagramservice.py @@ -25,6 +25,7 @@ from enum import Enum import logging +from openlcb.linklayer import LinkLayer from openlcb.message import Message from openlcb.mti import MTI @@ -93,7 +94,7 @@ class ProtocolID(Enum): Unrecognized = 0xFF # Not formally assigned def __init__(self, linkLayer): - self.linkLayer = linkLayer + self.linkLayer: LinkLayer = linkLayer self.quiesced = False self.currentOutstandingMemo = None self.pendingWriteMemos = [] From 71a8e54982a7b9e9509ccb75925bf59aed749c01 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Mon, 19 May 2025 14:21:52 -0400 Subject: [PATCH 71/99] Improve comments related to name changes etc, and remove lint. --- examples/tkexamples/cdiform.py | 2 +- openlcb/canbus/canlink.py | 10 +++++----- openlcb/canbus/canphysicallayergridconnect.py | 2 +- openlcb/linklayer.py | 11 +++++------ openlcb/openlcbnetwork.py | 1 + openlcb/portinterface.py | 3 +-- openlcb/tcplink/tcplink.py | 4 ++-- tests/test_canlink.py | 4 ---- tests/test_canphysicallayergridconnect.py | 3 ++- 9 files changed, 18 insertions(+), 22 deletions(-) diff --git a/examples/tkexamples/cdiform.py b/examples/tkexamples/cdiform.py index a807042..2597bdd 100644 --- a/examples/tkexamples/cdiform.py +++ b/examples/tkexamples/cdiform.py @@ -94,7 +94,7 @@ def clear(self): # def connect(self, new_socket, localNodeID, callback=None): # return OpenLCBNetwork.connect(self, new_socket, localNodeID, - # callback=callback) + # callback=callback) def downloadCDI(self, farNodeID, callback=None): self.set_status("Downloading CDI...") diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 5d07921..8a0306a 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -87,8 +87,8 @@ class State(Enum): CID sequence packet (first phase of reserving an alias). - The last frame sets state to WaitForAliases *after* sent by socket (wait for socket code in application or - OpenLCBNetwork to notify us, sendFrameAfter is too soon to be - sure our 200ms delay starts after send). + OpenLCBNetwork to notify us, as sendFrameAfter is too + soon to be sure our 200ms delay starts after send). EnqueueAliasReservation (State): After collision detection fully determined to be success, this state triggers _enqueueReserveID. @@ -210,7 +210,7 @@ def isBadReservation(self, frame): # Constructors should construct the openlcb stack. # def linkPhysicalLayer(self, cpl): # """Set the physical layer to use. - # Also registers self.receiveListener as a listener on the given + # Also registers self.handleFrameReceived as a listener on the given # physical layer. Before using sendMessage, wait for the # connection phase to finish, as the phase receives aliases # (populating nodeIdToAlias) and reserves a unique alias as per @@ -221,7 +221,7 @@ def isBadReservation(self, frame): # cpl (CanPhysicalLayer): The physical layer to use. # """ # self.physicalLayer = cpl # self.link = cpl - # cpl.registerFrameReceivedListener(self.receiveListener) + # cpl.registerFrameReceivedListener(self.handleFrameReceived) # # ^ Commented since it makes more sense for its # # constructor to do this, since it needs a PhysicalLayer # # in order to do anything @@ -278,7 +278,7 @@ def handleFrameReceived(self, frame: CanFrame): if not ControlFrame.isInternal(control_frame): self._frameCount += 1 else: - print("[CanLink receiveListener] control_frame={}" + print("[CanLink handleFrameReceived] control_frame={}" .format(control_frame)) if control_frame == ControlFrame.LinkUp: diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index 6ef0610..0637a49 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -44,7 +44,7 @@ def __init__(self): # region moved to CanLink constructor # from canLink.linkPhysicalLayer(self) # self.setCallBack(callback): # canLink.physicalLayer = self - # self.registerFrameReceivedListener(canLink.receiveListener) + # self.registerFrameReceivedListener(canLink.handleFrameReceived) # endregion moved to CanLink constructor self.inboundBuffer = bytearray() diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index 94ca54e..24ce422 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -36,9 +36,8 @@ class LinkLayer: _state: The state (a.k.a. "runlevel" in linux terms) of the network link. This may be moved to an overall stack handler such as OpenLCBNetwork. - State (class(Enum)): values for _state. Implement in subclass. - This may be moved to an overall stack handler such as - OpenLCBNetwork. + State (class(Enum)): values for _state. Implement all necessary + states in subclass to handle connection phases etc. """ class State(Enum): @@ -64,10 +63,10 @@ def __init__(self, physicalLayer: PhysicalLayer, localNodeID): # # ^ enforce queue paradigm (See use in PhysicalLayer subclass) # physicalLayer.registerFrameReceivedListener(listener) # ^ Doesn't work with "is" operator still! So just use - # physicalLayer.onFrameReceived in fireFrameReceived in PhysicalLayer. + # physicalLayer.onFrameReceived in fireFrameReceived in PhysicalLayer # else: # print("Using {} without" - # " registerFrameReceivedListener(self.receiveListener)" + # " registerFrameReceivedListener(self.handleFrameReceived)" # " on physicalLayer, since no physicalLayer specified." # .format()) # endregion moved from CanLink linkPhysicalLayer @@ -83,7 +82,7 @@ def __init__(self, physicalLayer: PhysicalLayer, localNodeID): def handleFrameReceived(self, frame): logger.warning( - "{} abstract receiveListener called (expected implementation)" + "{} abstract handleFrameReceived called (expected implementation)" .format(type(self).__name__)) def handleFrameSent(self, frame): diff --git a/openlcb/openlcbnetwork.py b/openlcb/openlcbnetwork.py index 944632b..48ec888 100644 --- a/openlcb/openlcbnetwork.py +++ b/openlcb/openlcbnetwork.py @@ -67,6 +67,7 @@ def attrs_to_dict(attrs) -> dict: # TODO: split OpenLCBNetwork (socket & event handler) from ContentHandler +# and/or only handle data as XML if request is for CDI/FDI or other XML. class OpenLCBNetwork(xml.sax.handler.ContentHandler): """Manage Configuration Description Information. - Send events to downloadCDI caller describing the state and content diff --git a/openlcb/portinterface.py b/openlcb/portinterface.py index 2d8a637..3472025 100644 --- a/openlcb/portinterface.py +++ b/openlcb/portinterface.py @@ -115,8 +115,7 @@ def send(self, data: Union[bytes, bytearray]) -> None: Raises: InterruptedError: (raised by assertNotBusy) if - port is in use. Use sendFrameAfter in - OpenLCBNetwork to avoid this. + port is in use. Use sendFrameAfter to avoid this. Args: data (Union[bytes, bytearray]): _description_ diff --git a/openlcb/tcplink/tcplink.py b/openlcb/tcplink/tcplink.py index 93fcf1f..556b6e7 100644 --- a/openlcb/tcplink/tcplink.py +++ b/openlcb/tcplink/tcplink.py @@ -65,7 +65,7 @@ def _onStateChanged(self, oldState, newState): print(f"[TcpLink] _onStateChanged from {oldState} to {newState}" " (nothing to do since TcpLink)") - def handleFrameReceived(self, inputData: bytearray): # [] input + def handleFrameReceived(self, inputData: bytearray): """Receives bytes from lower level and accumulates them into individual message parts. @@ -105,7 +105,7 @@ def handleFrameReceived(self, inputData: bytearray): # [] input # and repeat def receivedPart(self, messagePart, flags, length): - """Receives message parts from receiveListener + """Receives message parts from handleFrameReceived and groups them into single OpenLCB messages as needed Args: diff --git a/tests/test_canlink.py b/tests/test_canlink.py index 25d1f6b..0105b6a 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -1,4 +1,3 @@ -from timeit import default_timer import unittest from openlcb import formatted_ex @@ -641,8 +640,6 @@ def testThreeFrameDatagram(self): canLink.sendMessage(message) - - self.assertEqual(len(canPhysicalLayer._send_frames), 3) self.assertEqual( str(canPhysicalLayer._send_frames[0]), @@ -794,7 +791,6 @@ def testEnum(self): failedCount = 0 exceptions = [] errors = [] - testCase.testRIDreceivedMatch() for name in dir(testCase): if name.startswith("test"): fn = getattr(testCase, name) diff --git a/tests/test_canphysicallayergridconnect.py b/tests/test_canphysicallayergridconnect.py index ceb03e1..b1731e9 100644 --- a/tests/test_canphysicallayergridconnect.py +++ b/tests/test_canphysicallayergridconnect.py @@ -36,7 +36,8 @@ def onFrameSent(self, frame): def onFrameReceived(self, frame): pass - # NOTE: not patching self.onFrameReceived = canLink.handleFrameReceived + # NOTE: not patching + # self.onFrameReceived = canLink.handleFrameReceived # since testing only physical layer not link layer. From f0d80ae6bf975cf5e5969eef9cc764aee0ef2950 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Mon, 19 May 2025 15:11:44 -0400 Subject: [PATCH 72/99] Fix type checking for Processor. Add more type hints. --- openlcb/processor.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/openlcb/processor.py b/openlcb/processor.py index fd06c1c..54eccbf 100644 --- a/openlcb/processor.py +++ b/openlcb/processor.py @@ -10,12 +10,15 @@ on processor struct to handle communications for multiple nodes. ''' +from typing import Union +from openlcb.message import Message +from openlcb.node import Node from openlcb.nodeid import NodeID class Processor: - def process(self, message, node=None): + def process(self, message: Message, node: Node = None): """abstract method to be implemented below only in subclasses Accept a Message, adjust state as needed, possibly reply. @@ -32,12 +35,12 @@ def process(self, message, node=None): # TODO: so maybe add _ to beginning of method names marked "# internal" # internal - def checkSourceID(self, message, arg): + def checkSourceID(self, message: Message, arg: Union[NodeID, Node]): """check whether a message came from a specific nodeID Args: message (Message): A message. - arg (Union[NodeID,int]): NodeID or Node ID int to compare against + arg (Union[NodeID,Node]): NodeID or Node ID int to compare against message.source. Returns: @@ -47,15 +50,16 @@ def checkSourceID(self, message, arg): return message.source == arg else: # assuming type is Node + assert isinstance(arg, Node) return message.source == arg.id # internal - def checkDestID(self, message, arg): + def checkDestID(self, message: Message, arg: Union[NodeID, Node]): """check whether a message is addressed to a specific nodeID Args: message (Message): A Message. - arg (Union[NodeID,int]): A Node ID. + arg (Union[NodeID,Node]): A Node ID. Returns: bool: Whether the message ID matches the arg. Global messages @@ -64,4 +68,5 @@ def checkDestID(self, message, arg): if isinstance(arg, NodeID): return message.destination == arg else: # assuming type is Node + assert isinstance(arg, Node) return message.destination == arg.id From 16b95de17be05c9e0cecd52411ec3e53938c800d Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Mon, 19 May 2025 16:49:55 -0400 Subject: [PATCH 73/99] Add more type hints. Remove lint. --- examples/example_cdi_access.py | 16 +- examples/example_datagram_transfer.py | 5 +- examples/example_frame_interface.py | 12 +- examples/example_memory_transfer.py | 20 +- examples/example_message_interface.py | 16 +- examples/example_node_implementation.py | 30 +-- examples/example_remote_nodes.py | 10 +- examples/example_string_interface.py | 6 +- examples/example_string_serial_interface.py | 6 +- examples/example_tcp_message_interface.py | 16 +- examples/examples_gui.py | 13 +- examples/tkexamples/cdiform.py | 4 +- openlcb/canbus/canlink.py | 36 ++-- openlcb/canbus/canlinklayersimulation.py | 1 - openlcb/canbus/canphysicallayer.py | 7 +- openlcb/canbus/canphysicallayergridconnect.py | 6 +- openlcb/canbus/canphysicallayersimulation.py | 5 +- openlcb/canbus/seriallink.py | 5 +- openlcb/datagramservice.py | 41 ++-- openlcb/localnodeprocessor.py | 23 +-- openlcb/memoryservice.py | 12 +- openlcb/message.py | 4 +- openlcb/mti.py | 10 +- openlcb/node.py | 5 +- openlcb/nodeid.py | 2 +- openlcb/nodestore.py | 16 +- openlcb/openlcbnetwork.py | 180 +++++++++--------- openlcb/physicallayer.py | 3 +- openlcb/pip.py | 56 ++++-- openlcb/portinterface.py | 1 + openlcb/realtimephysicallayer.py | 2 +- openlcb/remotenodeprocessor.py | 23 +-- openlcb/scanner.py | 10 +- openlcb/snip.py | 63 +++--- openlcb/tcplink/tcplink.py | 11 +- openlcb/tcplink/tcpsocket.py | 4 +- python-openlcb.code-workspace | 9 + tests/test_canlink.py | 19 +- tests/test_canphysicallayergridconnect.py | 9 +- tests/test_datagramservice.py | 2 +- tests/test_tcplink.py | 1 - 41 files changed, 389 insertions(+), 331 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index 04a220f..144fa5b 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -12,10 +12,8 @@ ''' # region same code as other examples import copy -from timeit import default_timer from examples_settings import Settings # do 1st to fix path if no pip install from openlcb import precise_sleep -from openlcb.canbus.canframe import CanFrame from openlcb.canbus.gridconnectobserver import GridConnectObserver from openlcb.tcplink.tcpsocket import TcpSocket settings = Settings() @@ -24,15 +22,15 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb.canbus.canphysicallayergridconnect import ( +from openlcb.canbus.canphysicallayergridconnect import ( # noqa:E402 CanPhysicalLayerGridConnect, ) -from openlcb.canbus.canlink import CanLink -from openlcb.nodeid import NodeID -from openlcb.datagramservice import ( +from openlcb.canbus.canlink import CanLink # noqa:E402 +from openlcb.nodeid import NodeID # noqa:E402 +from openlcb.datagramservice import ( # noqa:E402 DatagramService, ) -from openlcb.memoryservice import ( +from openlcb.memoryservice import ( # noqa:E402 MemoryReadMemo, MemoryService, ) @@ -110,6 +108,7 @@ def printDatagram(memo): complete_data = False read_failed = False + def memoryReadSuccess(memo): """Handle a successful read Invoked when the memory read successfully returns, @@ -249,7 +248,8 @@ def pumpEvents(): if settings['trace']: observer.push(received) if observer.hasNext(): - packet_str = observer.next() + _ = observer.next() + # packet_str = _ # print(" RR: "+packet_str.strip()) # ^ commented since MyHandler shows parsed XML # fields instead diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index dc97078..3dfc9dd 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -10,10 +10,9 @@ address and port. ''' # region same code as other examples -from examples_settings import Settings +from examples_settings import Settings # do 1st to fix path if no pip install from openlcb import precise_sleep -from openlcb.canbus.canframe import CanFrame -from openlcb.canbus.gridconnectobserver import GridConnectObserver # do 1st to fix path if no pip install +from openlcb.canbus.gridconnectobserver import GridConnectObserver settings = Settings() if __name__ == "__main__": diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index f7adadb..937646a 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -18,14 +18,14 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb import precise_sleep -from openlcb.canbus.gridconnectobserver import GridConnectObserver -from openlcb.tcplink.tcpsocket import TcpSocket -from openlcb.canbus.canphysicallayergridconnect import ( +from openlcb import precise_sleep # noqa: E402 +from openlcb.canbus.gridconnectobserver import GridConnectObserver # noqa:E402 +from openlcb.tcplink.tcpsocket import TcpSocket # noqa: E402 +from openlcb.canbus.canphysicallayergridconnect import ( # noqa: E402 CanPhysicalLayerGridConnect, ) -from openlcb.canbus.canframe import CanFrame -from openlcb.canbus.controlframe import ControlFrame +from openlcb.canbus.canframe import CanFrame # noqa: E402 +from openlcb.canbus.controlframe import ControlFrame # noqa: E402 # specify connection information # region replaced by settings diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index e3c16eb..7fb84f9 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -10,29 +10,29 @@ address and port. ''' # region same code as other examples -from examples_settings import Settings -from openlcb.canbus.canframe import CanFrame # do 1st to fix path if no pip install +from examples_settings import Settings # do 1st to fix path if no pip install +from openlcb.canbus.canframe import CanFrame settings = Settings() if __name__ == "__main__": settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb import precise_sleep -from openlcb.canbus.gridconnectobserver import GridConnectObserver -from openlcb.tcplink.tcpsocket import TcpSocket +from openlcb import precise_sleep # noqa: E402 +from openlcb.canbus.gridconnectobserver import GridConnectObserver # noqa:E402 +from openlcb.tcplink.tcpsocket import TcpSocket # noqa: E402 -from openlcb.canbus.canphysicallayergridconnect import ( +from openlcb.canbus.canphysicallayergridconnect import ( # noqa: E402 CanPhysicalLayerGridConnect, ) -from openlcb.canbus.canlink import CanLink -from openlcb.nodeid import NodeID -from openlcb.datagramservice import ( +from openlcb.canbus.canlink import CanLink # noqa: E402 +from openlcb.nodeid import NodeID # noqa: E402 +from openlcb.datagramservice import ( # noqa: E402 # DatagramWriteMemo, # DatagramReadMemo, DatagramService, ) -from openlcb.memoryservice import ( +from openlcb.memoryservice import ( # noqa: E402 MemoryReadMemo, # MemoryWriteMemo, MemoryService, diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index f1486ff..b19bf05 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -19,17 +19,17 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb import precise_sleep -from openlcb.canbus.gridconnectobserver import GridConnectObserver -from openlcb.tcplink.tcpsocket import TcpSocket +from openlcb import precise_sleep # noqa: E402 +from openlcb.canbus.gridconnectobserver import GridConnectObserver # noqa:E402 +from openlcb.tcplink.tcpsocket import TcpSocket # noqa: E402 -from openlcb.canbus.canphysicallayergridconnect import ( +from openlcb.canbus.canphysicallayergridconnect import ( # noqa: E402 CanPhysicalLayerGridConnect, ) -from openlcb.canbus.canlink import CanLink -from openlcb.nodeid import NodeID -from openlcb.message import Message -from openlcb.mti import MTI +from openlcb.canbus.canlink import CanLink # noqa: E402 +from openlcb.nodeid import NodeID # noqa: E402 +from openlcb.message import Message # noqa: E402 +from openlcb.mti import MTI # noqa: E402 # specify connection information # region replaced by settings diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index 7b972f8..0ce33ba 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -19,24 +19,24 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb import precise_sleep -from openlcb.canbus.gridconnectobserver import GridConnectObserver -from openlcb.tcplink.tcpsocket import TcpSocket +from openlcb import precise_sleep # noqa: E402 +from openlcb.canbus.gridconnectobserver import GridConnectObserver # noqa:E402 +from openlcb.tcplink.tcpsocket import TcpSocket # noqa: E402 -from openlcb.canbus.canphysicallayergridconnect import ( +from openlcb.canbus.canphysicallayergridconnect import ( # noqa: E402 CanPhysicalLayerGridConnect, ) -from openlcb.canbus.canlink import CanLink -from openlcb.nodeid import NodeID -from openlcb.datagramservice import DatagramService -from openlcb.memoryservice import MemoryService -from openlcb.message import Message -from openlcb.mti import MTI - -from openlcb.localnodeprocessor import LocalNodeProcessor -from openlcb.pip import PIP -from openlcb.snip import SNIP -from openlcb.node import Node +from openlcb.canbus.canlink import CanLink # noqa: E402 +from openlcb.nodeid import NodeID # noqa: E402 +from openlcb.datagramservice import DatagramService # noqa: E402 +from openlcb.memoryservice import MemoryService # noqa: E402 +from openlcb.message import Message # noqa: E402 +from openlcb.mti import MTI # noqa: E402 + +from openlcb.localnodeprocessor import LocalNodeProcessor # noqa: E402 +from openlcb.pip import PIP # noqa: E402 +from openlcb.snip import SNIP # noqa: E402 +from openlcb.node import Node # noqa: E402 # specify connection information # region moved to settings diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index a372d50..557a70e 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -12,9 +12,9 @@ ''' # region same code as other examples from timeit import default_timer -from examples_settings import Settings +from examples_settings import Settings # do 1st to fix path if no pip install from openlcb import precise_sleep -from openlcb.canbus.gridconnectobserver import GridConnectObserver # do 1st to fix path if no pip install +from openlcb.canbus.gridconnectobserver import GridConnectObserver settings = Settings() if __name__ == "__main__": @@ -107,9 +107,9 @@ def printMessage(msg): readQueue = Queue() observer = GridConnectObserver() - -assert len(physicalLayer._frameReceivedListeners) == 1, \ - "{} listener(s) unexpectedly".format(len(physicalLayer._frameReceivedListeners)) +_frameReceivedListeners = physicalLayer._frameReceivedListeners +assert len(_frameReceivedListeners) == 1, \ + "{} listener(s) unexpectedly".format(len(_frameReceivedListeners)) def pumpEvents(): diff --git a/examples/example_string_interface.py b/examples/example_string_interface.py index 724d566..cbd9f04 100644 --- a/examples/example_string_interface.py +++ b/examples/example_string_interface.py @@ -18,9 +18,9 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb import precise_sleep -from openlcb.canbus.gridconnectobserver import GridConnectObserver -from openlcb.tcplink.tcpsocket import TcpSocket +from openlcb import precise_sleep # noqa: E402 +from openlcb.canbus.gridconnectobserver import GridConnectObserver # noqa:E402 +from openlcb.tcplink.tcpsocket import TcpSocket # noqa: E402 # specify connection information # region replaced by settings diff --git a/examples/example_string_serial_interface.py b/examples/example_string_serial_interface.py index 296eee5..67dd9e1 100644 --- a/examples/example_string_serial_interface.py +++ b/examples/example_string_serial_interface.py @@ -18,9 +18,9 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb import precise_sleep -from openlcb.canbus.gridconnectobserver import GridConnectObserver -from openlcb.canbus.seriallink import SerialLink +from openlcb import precise_sleep # noqa: E402 +from openlcb.canbus.gridconnectobserver import GridConnectObserver # noqa:E402 +from openlcb.canbus.seriallink import SerialLink # noqa: E402 # specify connection information # region replaced by settings diff --git a/examples/example_tcp_message_interface.py b/examples/example_tcp_message_interface.py index de5c762..57a68cd 100644 --- a/examples/example_tcp_message_interface.py +++ b/examples/example_tcp_message_interface.py @@ -13,23 +13,21 @@ ''' from logging import getLogger # region same code as other examples -from examples_settings import Settings +from examples_settings import Settings # do 1st to fix path if no pip install from openlcb import precise_sleep -from openlcb.realtimephysicallayer import RealtimePhysicalLayer # do 1st to fix path if no pip install +from openlcb.realtimephysicallayer import RealtimePhysicalLayer settings = Settings() if __name__ == "__main__": settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -import openlcb.physicallayer -from openlcb.tcplink.tcpsocket import TcpSocket -from openlcb.tcplink.tcplink import TcpLink +from openlcb.tcplink.tcpsocket import TcpSocket # noqa: E402 +from openlcb.tcplink.tcplink import TcpLink # noqa: E402 -from openlcb.nodeid import NodeID -from openlcb.message import Message -from openlcb.mti import MTI -from openlcb.physicallayer import PhysicalLayer +from openlcb.nodeid import NodeID # noqa: E402 +from openlcb.message import Message # noqa: E402 +from openlcb.mti import MTI # noqa: E402 if __name__ == "__main__": logger = getLogger(__file__) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index d579962..6caaa49 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -30,8 +30,7 @@ from tkinter import ttk from collections import OrderedDict, deque -from examples_settings import Settings -# ^ adds parent of module to sys.path, so openlcb imports *after* this +from examples_settings import Settings # do 1st to fix path if no pip install from openlcb.tcplink.tcpsocket import TcpSocket from examples.tkexamples.cdiform import CDIForm @@ -542,7 +541,7 @@ def connect_state_changed(self, event_d): LCC Message). In this program, this is added to OpenLCBNetwork via - set_connect_listener. + setConnectHandler. Therefore in this program, this is triggered during _listen in OpenLCBNetwork: Connecting is actually done until @@ -552,8 +551,8 @@ def connect_state_changed(self, event_d): - May also be directly called by _listen directly in case stopped listening (RuntimeError reading port, or other reason lower in the stack than LCC). - - OpenLCBNetwork's _connect_listener attribute is a method - reference to this if set via set_connect_listener. + - OpenLCBNetwork's _onConnect attribute is a method + reference to this if set via setConnectHandler. """ # Trigger the main thread (only the main thread can access the # GUI): @@ -583,8 +582,8 @@ def _connect(self): self._tcp_socket = TcpSocket() # self._sock.settimeout(30) self._tcp_socket.connect(host, port) - self.cdi_form.set_connect_listener(self.connect_state_changed) - result = self.cdi_form.start_listening( + self.cdi_form.setConnectHandler(self.connect_state_changed) + result = self.cdi_form.startListening( self._tcp_socket, localNodeID, ) diff --git a/examples/tkexamples/cdiform.py b/examples/tkexamples/cdiform.py index 2597bdd..a7aa689 100644 --- a/examples/tkexamples/cdiform.py +++ b/examples/tkexamples/cdiform.py @@ -16,7 +16,7 @@ from tkinter import ttk from logging import getLogger -from xml.etree import ElementTree as ET +# from xml.etree import ElementTree as ET from openlcb.openlcbnetwork import element_to_dict @@ -108,7 +108,7 @@ def set_status(self, message): def on_cdi_element(self, event_d): """Handler for incoming CDI tag (Use this for callback in downloadCDI, which sets parser's - _element_listener) + _onElement) Args: event_d (dict): Document parsing state info: diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 8a0306a..6ffb8ba 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -23,7 +23,7 @@ from logging import getLogger from timeit import default_timer -from openlcb import emit_cast, formatted_ex, precise_sleep +from openlcb import emit_cast, formatted_ex from openlcb.canbus.canframe import CanFrame from openlcb.canbus.canphysicallayer import CanPhysicalLayer from openlcb.canbus.controlframe import ControlFrame @@ -121,7 +121,7 @@ class State(Enum): MIN_STATE_VALUE = min(entry.value for entry in State) MAX_STATE_VALUE = max(entry.value for entry in State) - def __init__(self, physicalLayer: PhysicalLayer, localNodeID): + def __init__(self, physicalLayer: PhysicalLayer, localNodeID: NodeID): # See class docstring for args self.physicalLayer: CanPhysicalLayer = None # set by super() below # ^ typically CanPhysicalLayerGridConnect @@ -147,7 +147,7 @@ def __init__(self, physicalLayer: PhysicalLayer, localNodeID): # This method may never actually be necessary, as # sendMessage uses nodeIdToAlias (which has localNodeID # *only after* a successful reservation) - def getLocalAlias(self): + def getLocalAlias(self) -> int: """Get the local alias, since it may differ from original localNodeID given at construction: It may have been reassigned (via incrementAlias48 and createAlias12 in @@ -190,7 +190,7 @@ def getLocalAlias(self): # assert isinstance(self._state, CanLink.State) # return self._state == CanLink.State.Permitted - def isBadReservation(self, frame): + def isBadReservation(self, frame: CanFrame) -> bool: if frame.reservation is None: return False return frame.reservation < self._reservation @@ -206,8 +206,9 @@ def isBadReservation(self, frame): # return alias in self.duplicateAliases # ^ Commented since isBadReservation handles both collision and error. - # Commented since instead, socket code should call linkLayerUp and linkLayerDown. - # Constructors should construct the openlcb stack. + # Commented since instead, socket code should call linkLayerUp and + # linkLayerDown. Constructors should construct the openlcb stack: + # github.com/bobjacobsen/python-openlcb/issues/62#issuecomment-2775668681 # def linkPhysicalLayer(self, cpl): # """Set the physical layer to use. # Also registers self.handleFrameReceived as a listener on the given @@ -226,7 +227,7 @@ def isBadReservation(self, frame): # # constructor to do this, since it needs a PhysicalLayer # # in order to do anything - def _onStateChanged(self, oldState, newState): + def _onStateChanged(self, oldState: State, newState: State): # return super()._onStateChanged(oldState, newState) assert isinstance(newState, CanLink.State), \ "expected a CanLink.State, got {}".format(emit_cast(newState)) @@ -333,7 +334,7 @@ def handleFrameReceived(self, frame: CanFrame): "Invalid control frame format 0x{:08X}" .format(control_frame)) - def isRunningAliasReservation(self): + def isRunningAliasReservation(self) -> bool: return self._state in ( CanLink.State.EnqueueAliasAllocationRequest, CanLink.State.BusyLocalCIDSequence, @@ -797,7 +798,7 @@ def sendMessage(self, msg: Message): frame = CanFrame(header, msg.data) self.physicalLayer.sendFrameAfter(frame) - def segmentDatagramDataArray(self, data): + def segmentDatagramDataArray(self, data: bytearray) -> list[bytearray]: """Segment data into zero or more arrays of no more than 8 bytes for datagram. @@ -828,7 +829,8 @@ def segmentDatagramDataArray(self, data): return segments - def segmentAddressedDataArray(self, alias, data): + def segmentAddressedDataArray(self, alias: int, + data: bytearray) -> list[bytearray]: '''Segment data into zero or more arrays of no more than 8 bytes, with the alias at the start of each, for addressed non-datagram messages. @@ -876,7 +878,7 @@ def checkAndHandleAliasCollision(self, frame: CanFrame): self.processCollision(frame) return abort - def markDuplicateAlias(self, alias): + def markDuplicateAlias(self, alias: int): if not isinstance(alias, int): raise NotImplementedError( "Can't mark collision due to alias not stored as int." @@ -886,7 +888,7 @@ def markDuplicateAlias(self, alias): .format(emit_cast(alias))) self.duplicateAliases.append(alias) - def processCollision(self, frame: CanFrame) : + def processCollision(self, frame: CanFrame): ''' Collision! ''' self._aliasCollisionCount += 1 logger.warning( @@ -910,7 +912,7 @@ def processCollision(self, frame: CanFrame) : def getWaitForAliasResponseStart(self): return self._waitingForAliasStart - def pollState(self): + def pollState(self) -> State: """You must keep polling state after every time a state change frame is sent, and after every call to handleDataString or handleData @@ -1045,7 +1047,7 @@ def _enqueueReserveID(self): ) self.setState(CanLink.State.WaitingForSendReserveID) - def incrementAlias48(self, oldAlias): + def incrementAlias48(self, oldAlias: int) -> int: ''' Implements the OpenLCB preferred alias generation mechanism: a 48-bit computation @@ -1057,7 +1059,7 @@ def incrementAlias48(self, oldAlias): maskedProduct = newProduct & 0xFFFF_FFFF_FFFF return maskedProduct - def createAlias12(self, rnd): + def createAlias12(self, rnd: int) -> int: '''Form 12 bit alias from 48-bit random number''' part1 = (rnd >> 36) & 0x0FFF @@ -1073,7 +1075,7 @@ def createAlias12(self, rnd): return ((part1+part2+part3+part4) & 0xFF) return 0xAEF # Why'd you say Burma? - def decodeControlFrameFormat(self, frame: CanFrame): + def decodeControlFrameFormat(self, frame: CanFrame) -> ControlFrame: if (frame.header & 0x0800_0000) == 0x0800_0000: # data case; not checking leading 1 bit # NOTE: handleReceivedData can get all header bits via frame @@ -1093,7 +1095,7 @@ def decodeControlFrameFormat(self, frame: CanFrame): .format(frame.header)) return ControlFrame.UnknownFormat - def canHeaderToFullFormat(self, frame: CanFrame): + def canHeaderToFullFormat(self, frame: CanFrame) -> MTI: '''Returns a full 16-bit MTI from the full 29 bits of a CAN header''' frameType = (frame.header >> 24) & 0x7 canMTI = ((frame.header >> 12) & 0xFFF) diff --git a/openlcb/canbus/canlinklayersimulation.py b/openlcb/canbus/canlinklayersimulation.py index 2ac68e2..1e0163f 100644 --- a/openlcb/canbus/canlinklayersimulation.py +++ b/openlcb/canbus/canlinklayersimulation.py @@ -36,7 +36,6 @@ def pumpEvents(self): frame = self.physicalLayer.pollFrame() if not frame: break - first = False string = frame.encodeAsString() print(" SENT (simulated socket) packet: "+string.strip()) # ^ This is too verbose for this example (each is a diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index 7dfc584..c54fada 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -5,6 +5,7 @@ and is subclassed. ''' from logging import getLogger +from typing import Callable import warnings from openlcb.canbus.canframe import CanFrame @@ -22,7 +23,7 @@ class CanPhysicalLayer(PhysicalLayer): def __init__(self,): PhysicalLayer.__init__(self) - self._frameReceivedListeners = [] + self._frameReceivedListeners: list[Callable[[CanFrame], None]] = [] def sendFrameAfter(self, frame: CanFrame): """Enqueue: *IMPORTANT* Main/other thread may have @@ -56,7 +57,9 @@ def pollFrame(self) -> CanFrame: assert isinstance(frame, CanFrame) return frame - def registerFrameReceivedListener(self, listener): + def registerFrameReceivedListener(self, + listener: Callable[[CanFrame], None]): + # ^ 2nd arg to Callable type is the return type. assert listener is not None warnings.warn( "[registerFrameReceivedListener]" diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index 0637a49..83a00ac 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -10,7 +10,7 @@ - :X19170365N020112FE056C; ''' -from collections import deque + from typing import Union from openlcb.canbus.canphysicallayer import CanPhysicalLayer from openlcb.canbus.canframe import CanFrame @@ -53,7 +53,7 @@ def __init__(self): # assert callable(callback) # self.canSendCallback = callback - def encodeFrameAsString(self, frame) -> str: + def encodeFrameAsString(self, frame: CanFrame) -> str: '''Encode frame to string.''' output = ":X{:08X}N".format(frame.header) # at least 8 chars, hex for byte in frame.data: @@ -61,7 +61,7 @@ def encodeFrameAsString(self, frame) -> str: output += ";\n" return output - def encodeFrameAsData(self, frame) -> Union[bytearray, bytes]: + def encodeFrameAsData(self, frame: CanFrame) -> Union[bytearray, bytes]: # TODO: Consider doing this manually (in Python 3, # bytes/bytearray has no attribute 'format') return self.encodeFrameAsString(frame).encode("utf-8") diff --git a/openlcb/canbus/canphysicallayersimulation.py b/openlcb/canbus/canphysicallayersimulation.py index 1268f58..df6a4d7 100644 --- a/openlcb/canbus/canphysicallayersimulation.py +++ b/openlcb/canbus/canphysicallayersimulation.py @@ -10,13 +10,12 @@ class CanPhysicalLayerSimulation(CanPhysicalLayer, FrameEncoder): def __init__(self): - self.receivedFrames = [] + self.receivedFrames: list[CanFrame] = [] CanPhysicalLayer.__init__(self) self.onQueuedFrame = self._onQueuedFrame def _onQueuedFrame(self, frame: CanFrame): - raise AttributeError( - "Not implemented for simulation") + raise AttributeError("Not implemented for simulation") def captureFrame(self, frame: CanFrame): self.receivedFrames.append(frame) diff --git a/openlcb/canbus/seriallink.py b/openlcb/canbus/seriallink.py index aff91e0..0a44318 100644 --- a/openlcb/canbus/seriallink.py +++ b/openlcb/canbus/seriallink.py @@ -24,11 +24,12 @@ class SerialLink(PortInterface): def __init__(self): super(SerialLink, self).__init__() - def _settimeout(self, seconds): + def _settimeout(self, seconds: float): logger.warning("settimeout is not implemented for SerialLink") pass - def _connect(self, _, port, device=None, baudrate=230400): + def _connect(self, _, port: str, device: serial.Serial = None, + baudrate: int = 230400): """Connect to a serial port. Args: diff --git a/openlcb/datagramservice.py b/openlcb/datagramservice.py index 8af8d35..4e24a40 100644 --- a/openlcb/datagramservice.py +++ b/openlcb/datagramservice.py @@ -24,10 +24,12 @@ from enum import Enum import logging +from typing import Callable, Union from openlcb.linklayer import LinkLayer from openlcb.message import Message from openlcb.mti import MTI +from openlcb.nodeid import NodeID def defaultIgnoreReply(memo): @@ -39,8 +41,9 @@ class DatagramWriteMemo: '''Immutable memo carrying write request and two reply callbacks. Source is automatically this node. ''' - def __init__(self, destID, data, okReply=defaultIgnoreReply, + def __init__(self, destID: NodeID, data, okReply=defaultIgnoreReply, rejectedReply=defaultIgnoreReply): + assert isinstance(destID, NodeID) self.destID = destID if not isinstance(data, bytearray): raise TypeError("Expected bytearray (formerly list[int]), got {}" @@ -61,7 +64,7 @@ class DatagramReadMemo: '''Immutable memo carrying read result. Destination of operations is automatically this node. ''' - def __init__(self, srcID, data): + def __init__(self, srcID: NodeID, data: bytearray): self.srcID = srcID self.data = data @@ -93,18 +96,18 @@ class ProtocolID(Enum): Unrecognized = 0xFF # Not formally assigned - def __init__(self, linkLayer): + def __init__(self, linkLayer: LinkLayer): self.linkLayer: LinkLayer = linkLayer self.quiesced = False self.currentOutstandingMemo = None self.pendingWriteMemos = [] self._datagramReceivedListeners = [] - def datagramType(self, data): + def datagramType(self, data: Union[bytearray, list[int]]): """Determine the protocol type of the content of the datagram. Args: - data (list[int]): datagram payload + data (bytearray): datagram payload Returns: DatagramService.ProtocolID: A detected protocol ID, or @@ -124,15 +127,16 @@ def datagramType(self, data): else: return DatagramService.ProtocolID.Unrecognized - def checkDestID(self, message, nodeID): + def checkDestID(self, message, nodeID: NodeID): '''check whether a message is addressed to a specific nodeID Returns: bool: Global messages return False: Not specifically addressed ''' + assert isinstance(nodeID, NodeID) return message.destination == nodeID - def sendDatagram(self, memo): + def sendDatagram(self, memo: Union[DatagramReadMemo, DatagramWriteMemo]): '''Queue a ``DatagramWriteMemo`` to send a datagram to another node on the network. ''' @@ -144,14 +148,15 @@ def sendDatagram(self, memo): if len(self.pendingWriteMemos) == 1: self.sendDatagramMessage(memo) - def sendDatagramMessage(self, memo): + def sendDatagramMessage(self, + memo: Union[DatagramReadMemo, DatagramWriteMemo]): '''Send datagram message''' message = Message(MTI.Datagram, self.linkLayer.localNodeID, memo.destID, memo.data) self.linkLayer.sendMessage(message) self.currentOutstandingMemo = memo - def registerDatagramReceivedListener(self, listener): + def registerDatagramReceivedListener(self, listener: Callable): '''Register a listener to be notified when each datagram arrives. One and only one listener should reply positively or negatively to the @@ -174,7 +179,7 @@ def fireDatagramReceived(self, dg: DatagramReadMemo): # internal for tests self.negativeReplyToDatagram(dg, 0x1042) # "Not implemented, datagram type unknown" - permanent error - def process(self, message): + def process(self, message: Message): '''Processor entry point. Returns: @@ -198,14 +203,14 @@ def process(self, message): self.handleLinkRestarted(message) return False - def handleDatagram(self, message): + def handleDatagram(self, message: Message): '''create a read memo and pass to listeners''' memo = DatagramReadMemo(message.source, message.data) self.fireDatagramReceived(memo) # ^ destination listener calls back to # positiveReplyToDatagram/negativeReplyToDatagram before returning - def handleDatagramReceivedOK(self, message): + def handleDatagramReceivedOK(self, message: Message): '''OK reply to write''' # match to the memo and remove from queue memo = self.matchToWriteMemo(message) @@ -223,7 +228,7 @@ def handleDatagramReceivedOK(self, message): self.sendNextDatagramFromQueue() - def handleDatagramRejected(self, message): + def handleDatagramRejected(self, message: Message): '''Not OK reply to write''' # match to the memo and remove from queue memo = self.matchToWriteMemo(message) @@ -241,11 +246,11 @@ def handleDatagramRejected(self, message): self.sendNextDatagramFromQueue() - def handleLinkQuiesce(self, message): + def handleLinkQuiesce(self, message: Message): '''Link quiesced before outage: stop operation''' self.quiesced = True - def handleLinkRestarted(self, message): + def handleLinkRestarted(self, message: Message): '''Link restarted after outage: if write datagram(s) pending reply, resend them ''' @@ -260,7 +265,7 @@ def handleLinkRestarted(self, message): if len(self.pendingWriteMemos) > 0: self.sendNextDatagramFromQueue() - def matchToWriteMemo(self, message): + def matchToWriteMemo(self, message: Message): for memo in self.pendingWriteMemos: if memo.destID != message.source: continue # keep looking @@ -282,7 +287,7 @@ def sendNextDatagramFromQueue(self): memo = self.pendingWriteMemos[0] self.sendDatagramMessage(memo) - def positiveReplyToDatagram(self, dg, flags=0): + def positiveReplyToDatagram(self, dg: DatagramReadMemo, flags: int = 0): """Send a positive reply to a received datagram. Args: @@ -294,7 +299,7 @@ def positiveReplyToDatagram(self, dg, flags=0): dg.srcID, bytearray([flags])) self.linkLayer.sendMessage(message) - def negativeReplyToDatagram(self, dg, err): + def negativeReplyToDatagram(self, dg: DatagramReadMemo, err: int): """Send a negative reply to a received datagram. Args: diff --git a/openlcb/localnodeprocessor.py b/openlcb/localnodeprocessor.py index 58c961f..7c5ed80 100644 --- a/openlcb/localnodeprocessor.py +++ b/openlcb/localnodeprocessor.py @@ -12,6 +12,7 @@ # multiple local nodes. import logging +from openlcb.linklayer import LinkLayer from openlcb.node import Node from openlcb.mti import MTI from openlcb.message import Message @@ -21,11 +22,11 @@ class LocalNodeProcessor(Processor): - def __init__(self, linkLayer=None, node=None): + def __init__(self, linkLayer: LinkLayer = None, node: Node = None): self.linkLayer = linkLayer self.node = node - def process(self, message, givenNode=None): + def process(self, message: Message, givenNode=None): if givenNode is None: node = self.node else: @@ -69,7 +70,7 @@ def process(self, message, givenNode=None): return False # private method - def linkUpMessage(self, message, node): + def linkUpMessage(self, message: Message, node: Node): node.state = Node.State.Initialized msgIC = Message(MTI.Initialization_Complete, node.id, None, node.id.toArray()) @@ -79,11 +80,11 @@ def linkUpMessage(self, message, node): # self.linkLayer.sendMessage(msgVN) # private method - def linkDownMessage(self, message, node): + def linkDownMessage(self, message: Message, node: Node): node.state = Node.State.Uninitialized # private method - def verifyNodeIDNumberGlobal(self, message, node): + def verifyNodeIDNumberGlobal(self, message: Message, node: Node): if not (len(message.data) == 0 or node.id == NodeID(message.data)): return # not to us msg = Message(MTI.Verified_NodeID, node.id, message.source, @@ -91,13 +92,13 @@ def verifyNodeIDNumberGlobal(self, message, node): self.linkLayer.sendMessage(msg) # private method - def verifyNodeIDNumberAddressed(self, message, node): + def verifyNodeIDNumberAddressed(self, message: Message, node: Node): msg = Message(MTI.Verified_NodeID, node.id, message.source, node.id.toArray()) self.linkLayer.sendMessage(msg) # private method - def protocolSupportInquiry(self, message, node): + def protocolSupportInquiry(self, message: Message, node: Node): pips = 0 for pip in node.pipSet: pips |= pip.value @@ -112,19 +113,19 @@ def protocolSupportInquiry(self, message, node): self.linkLayer.sendMessage(msg) # private method - def simpleNodeIdentInfoRequest(self, message, node): + def simpleNodeIdentInfoRequest(self, message: Message, node: Node): msg = Message(MTI.Simple_Node_Ident_Info_Reply, node.id, message.source, node.snip.returnStrings()) self.linkLayer.sendMessage(msg) # private method - def identifyEventsAddressed(self, message, node): + def identifyEventsAddressed(self, message: Message, node: Node): '''EventProtocol in PIP, but no Events here to reply about; no reply necessary ''' return - def _unrecognizedMTI(self, message, node): + def _unrecognizedMTI(self, message: Message, node: Node): '''Handle a message with an unrecognized MTI by returning OptionalInteractionRejected ''' @@ -150,6 +151,6 @@ def _unrecognizedMTI(self, message, node): self.linkLayer.sendMessage(msg) # private method - def errorMessageReceived(self, message, node): + def errorMessageReceived(self, message: Message, node: Node): # these are just logged until we have more complex interactions logging.info("received unexpected {}".format(message)) diff --git a/openlcb/memoryservice.py b/openlcb/memoryservice.py index 5fb06a7..e228332 100644 --- a/openlcb/memoryservice.py +++ b/openlcb/memoryservice.py @@ -22,8 +22,10 @@ ''' import logging +from typing import Union from openlcb.datagramservice import ( # DatagramReadMemo, + DatagramReadMemo, DatagramWriteMemo, DatagramService, ) @@ -182,13 +184,15 @@ def requestMemoryReadNext(self, memo): self.receivedOkReplyToWrite) self.service.sendDatagram(dgWriteMemo) - def receivedOkReplyToWrite(self, memo): + def receivedOkReplyToWrite(self, memo: DatagramWriteMemo): '''Wait for following response to be returned via listener. This is normal. ''' pass - def datagramReceivedListener(self, dmemo): + def datagramReceivedListener(self, + dmemo: Union[DatagramReadMemo, + DatagramWriteMemo]) -> bool: '''Process a datagram. Sends the positive reply and returns true if this is from our service. @@ -276,7 +280,7 @@ def datagramReceivedListener(self, dmemo): return True - def requestMemoryWrite(self, memo): + def requestMemoryWrite(self, memo: MemoryWriteMemo): """Request memory write. Args: @@ -335,7 +339,7 @@ def requestSpaceLength(self, space, nodeID, callback): ) self.service.sendDatagram(dgReqMemo) - def arrayToInt(self, data): + def arrayToInt(self, data: Union[bytes, bytearray, list[int]]) -> int: """Convert an array in MSB-first order to an integer Args: diff --git a/openlcb/message.py b/openlcb/message.py index 07ded70..fb91f86 100644 --- a/openlcb/message.py +++ b/openlcb/message.py @@ -44,10 +44,10 @@ def assertTypes(self): # allowed to be None. See linkUp in tcplink.py # TODO: Only allow in certain conditions? - def isGlobal(self): + def isGlobal(self) -> bool: return self.mti.value & 0x0008 == 0 - def isAddressed(self): + def isAddressed(self) -> bool: return self.mti.value & 0x0008 != 0 def __str__(self): diff --git a/openlcb/mti.py b/openlcb/mti.py index 6353685..0e3c447 100644 --- a/openlcb/mti.py +++ b/openlcb/mti.py @@ -62,17 +62,17 @@ class MTI(Enum): New_Node_Seen = 0x2048 # alias resolution found new node; marked addressed (0x8 bit) # noqa: E501 - def priority(self): + def priority(self) -> int: return (self.value & 0x0C00) >> 10 - def addressPresent(self): + def addressPresent(self) -> bool: return (self.value & 0x0008) != 0 - def eventIDPresent(self): + def eventIDPresent(self) -> bool: return (self.value & 0x0004) != 0 - def simpleProtocol(self): + def simpleProtocol(self) -> bool: return (self.value & 0x0010) != 0 - def isGlobal(self): + def isGlobal(self) -> bool: return (self.value & 0x0008) == 0 diff --git a/openlcb/node.py b/openlcb/node.py index 53a9d21..855a5a5 100644 --- a/openlcb/node.py +++ b/openlcb/node.py @@ -14,6 +14,7 @@ ''' from enum import Enum +from openlcb.pip import PIP from openlcb.snip import SNIP from openlcb.localeventstore import LocalEventStore @@ -38,7 +39,7 @@ class Node: events (LocalEventStore): The store for local events associated with the node. """ - def __init__(self, nodeID, snip=None, pipSet=None): + def __init__(self, nodeID, snip: SNIP = None, pipSet: set[PIP] = None): self.id = nodeID self.snip = snip if snip is None : self.snip = SNIP() @@ -50,7 +51,7 @@ def __init__(self, nodeID, snip=None, pipSet=None): def __str__(self): return "Node ("+str(self.id)+")" - def name(self): + def name(self) -> str: return self.snip.userProvidedNodeName class State(Enum): diff --git a/openlcb/nodeid.py b/openlcb/nodeid.py index 87e4cc4..af211ec 100644 --- a/openlcb/nodeid.py +++ b/openlcb/nodeid.py @@ -65,7 +65,7 @@ def __init__(self, data): else: print("invalid data type to nodeid constructor", data) - def toArray(self): + def toArray(self) -> bytearray: return bytearray([ (self.value >> 40) & 0xFF, (self.value >> 32) & 0xFF, diff --git a/openlcb/nodestore.py b/openlcb/nodestore.py index 2c3f984..e2bcc78 100644 --- a/openlcb/nodestore.py +++ b/openlcb/nodestore.py @@ -1,4 +1,8 @@ +from typing import Union +from openlcb.message import Message +from openlcb.node import Node from openlcb.nodeid import NodeID +from openlcb.processor import Processor class NodeStore : @@ -10,13 +14,13 @@ class NodeStore : ''' def __init__(self) : - self.byIdMap = {} - self.nodes = [] - self.processors = [] + self.byIdMap: dict = {} + self.nodes: list[Node] = [] + self.processors: list[Processor] = [] # Store a new node or replace an existing stored node # - Parameter node: new Node content - def store(self, node) : + def store(self, node: Node) : self.byIdMap[node.id] = node self.nodes.append(node) @@ -37,7 +41,7 @@ def asArray(self) : # userProvidedDescription: string to match SNIP content # nodeID: for direct lookup # - Returns: None if the there's no match - def lookup(self, parm) : + def lookup(self, parm: Union[NodeID, str]) -> Node: if isinstance(parm, NodeID) : if parm not in self.byIdMap : self.byIdMap[parm] = None @@ -49,7 +53,7 @@ def lookup(self, parm) : return None # Process a message across all nodes - def invokeProcessorsOnNodes(self, message) : + def invokeProcessorsOnNodes(self, message: Message) -> bool: publish = False # has any processor returned True? for processor in self.processors : for node in self.byIdMap.values() : diff --git a/openlcb/openlcbnetwork.py b/openlcb/openlcbnetwork.py index 48ec888..033d933 100644 --- a/openlcb/openlcbnetwork.py +++ b/openlcb/openlcbnetwork.py @@ -16,10 +16,13 @@ import threading import time import sys +from typing import Callable, Union import xml.sax # noqa: E402 import xml.etree.ElementTree as ET from logging import getLogger +import xml.sax.handler +from xml.sax.xmlreader import AttributesImpl # from xml.sax.xmlreader import AttributesImpl # for autocomplete only from openlcb import formatted_ex, precise_sleep @@ -29,9 +32,11 @@ CanPhysicalLayerGridConnect, ) from openlcb.canbus.canlink import CanLink +from openlcb.message import Message from openlcb.mti import MTI from openlcb.nodeid import NodeID from openlcb.datagramservice import ( + DatagramReadMemo, DatagramService, ) from openlcb.memoryservice import ( @@ -78,14 +83,14 @@ class OpenLCBNetwork(xml.sax.handler.ContentHandler): etree (Element): The XML root element (Does not correspond to an XML tag but rather the document itself, and contains all actual top-level elements as children). - _open_el (SubElement): Tracks currently-open tag (no `` + _openEl (SubElement): Tracks currently-open tag (no `` yet) during parsing, or if no tags are open then equals etree. _tag_stack (list[SubElement]): Tracks scope during parse since self.etree doesn't have awareness of whether end tag is finished (and therefore doesn't know which element is the parent of a new startElement). - _element_listener (Callable): Called if an XML element is + _onElement (Callable): Called if an XML element is received (including either a start or end tag). Typically set as `callback` argument to downloadCDI. _resultingCDI (str): CDI document being collected from the @@ -109,14 +114,14 @@ class Mode(Enum): def __init__(self, *args, **kwargs): caches_dir = SysDirs.Cache - self._my_cache_dir = os.path.join(caches_dir, "python-openlcb") - self._element_listener = None - self._connect_listener = None + self._myCacheDir = os.path.join(caches_dir, "python-openlcb") + self._onElement = None + self._onConnect = None self._mode = OpenLCBNetwork.Mode.Initializing # ^ In case some parsing step happens early, # prepare these for _callback_msg. super().__init__() # takes no arguments - self._string_terminated = None # None means no read is occurring. + self._stringTerminated = None # None means no read is occurring. self._parser = xml.sax.make_parser() self._parser.setContentHandler(self) @@ -136,52 +141,53 @@ def __init__(self, *args, **kwargs): self._resultingCDI = None # endregion connect - self._connecting_t = None + self._connectingStart: float = None - def _reset_tree(self): + def _resetTree(self): self.etree = ET.Element("root") - self._open_el = self.etree + self._openEl = self.etree - def _callback_status(self, status, callback=None): + def _fireStatus(self, status, callback=None): + """Fire status handlers with the given status.""" if callback is None: - callback = self._element_listener + callback = self._onElement if callback is None: - callback = self._connect_listener + callback = self._onConnect if callback: print("CDIForm callback_msg({})".format(repr(status))) - self._connect_listener({ + self._onConnect({ 'status': status, }) else: logger.warning("No callback, but set status: {}".format(status)) - def set_element_listener(self, listener): - self._element_listener = listener + def setElementHandler(self, handler: Callable): + self._onElement = handler - def set_connect_listener(self, listener): - self._connect_listener = listener + def setConnectHandler(self, handler: Callable): + self._onConnect = handler - def start_listening(self, connected_port, localNodeID): + def startListening(self, connected_port, + localNodeID: Union[NodeID, int, str, bytearray]): if self._port is not None: logger.warning( - "[start_listening] A previous _port will be discarded.") + "[startListening] A previous _port will be discarded.") self._port = connected_port - self._callback_status("CanPhysicalLayerGridConnect...") + self._fireStatus("CanPhysicalLayerGridConnect...") self._physicalLayer = CanPhysicalLayerGridConnect() - self._callback_status("CanLink...") + self._fireStatus("CanLink...") self._canLink = CanLink(self._physicalLayer, NodeID(localNodeID)) # ^ CanLink constructor sets _physicalLayer's onFrameReceived # and onFrameSent to handlers in _canLink. - self._callback_status("CanLink..." - "registerMessageReceivedListener...") + self._fireStatus("CanLink...registerMessageReceivedListener...") self._canLink.registerMessageReceivedListener(self._handleMessage) # NOTE: Incoming data (Memo) is handled by _memoryReadSuccess # and _memoryReadFail. # - These are set when constructing the MemoryReadMemo which # is provided to openlcb's requestMemoryRead method. - self._callback_status("DatagramService...") + self._fireStatus("DatagramService...") self._datagramService = DatagramService(self._canLink) self._canLink.registerMessageReceivedListener( self._datagramService.process @@ -191,10 +197,10 @@ def start_listening(self, connected_port, localNodeID): self._printDatagram ) - self._callback_status("MemoryService...") + self._fireStatus("MemoryService...") self._memoryService = MemoryService(self._datagramService) - self._callback_status("listen...") + self._fireStatus("listen...") self.listen() # Must listen for alias reservation responses # (sendAliasConnectionSequence will occur for another 200ms @@ -202,9 +208,9 @@ def start_listening(self, connected_port, localNodeID): # - must also keep doing frame = pollFrame() and sending # if not None. - self._callback_status("physicalLayerUp...") + self._fireStatus("physicalLayerUp...") self._physicalLayer.physicalLayerUp() - self._callback_status("Waiting for alias reservation...") + self._fireStatus("Waiting for alias reservation...") while self._canLink.pollState() != CanLink.State.Permitted: precise_sleep(.02) # ^ triggers fireFrameReceived which calls CanLink's default @@ -213,15 +219,15 @@ def start_listening(self, connected_port, localNodeID): # - Must happen *after* listen thread starts, since # fireFrameReceived (ControlFrame.LinkUp) # calls sendAliasConnectionSequence on this thread! - self._callback_status("Alias reservation complete.") + self._fireStatus("Alias reservation complete.") def listen(self): - self._listen_thread = threading.Thread( + self._listenThread = threading.Thread( target=self._listen, daemon=True, # True to terminate on program exit ) print("[listen] Starting port receive loop...") - self._listen_thread.start() + self._listenThread.start() def _receive(self) -> bytearray: """Receive data from the port. @@ -232,8 +238,8 @@ def _receive(self) -> bytearray: return self._port.receive() def _listen(self): - self._connecting_t = time.perf_counter() - self._message_t = None + self._connectingStart = time.perf_counter() + self._messageStart = None self._mode = OpenLCBNetwork.Mode.Idle # Idle until data type is known caught_ex = None try: @@ -263,11 +269,10 @@ def _listen(self): # ^ will trigger self._printFrame if that was added # via registerFrameReceivedListener during connect. precise_sleep(.01) # let processor sleep before read - if time.perf_counter() - self._connecting_t > .2: + if time.perf_counter() - self._connectingStart > .2: if self._canLink._state != CanLink.State.Permitted: - if ((self._message_t is None) - or (time.perf_counter() - self._message_t - > 1)): + delta = time.perf_counter() - self._messageStart + if ((self._messageStart is None) or (delta > 1)): logger.warning( "CanLink is not ready yet." " There must have been a collision" @@ -320,20 +325,20 @@ def _listen(self): # manually. # - Usually "socket connection broken" due to no more # bytes to read, but ok if "\0" terminator was reached. - if self._resultingCDI is not None and not self._string_terminated: + if self._resultingCDI is not None and not self._stringTerminated: # This boolean is managed by the memoryReadSuccess # callback. event_d = { # same as self._event_listener here 'error': formatted_ex(ex), 'done': True, # stop progress in gui/other main thread } - if self._element_listener: - self._element_listener(event_d) + if self._onElement: + self._onElement(event_d) self._mode = OpenLCBNetwork.Mode.Disconnected raise # re-raise since incomplete (prevent done OK state) finally: self._canLink.onDisconnect() - self._listen_thread = None + self._listenThread: threading.Thread = None self._mode = OpenLCBNetwork.Mode.Disconnected # If we got here, the RuntimeError was ok since the @@ -343,12 +348,13 @@ def _listen(self): .format(formatted_ex(caught_ex))), 'done': True, } - if not (self._connect_listener and self._connect_listener(event_d)): + if not (self._onConnect and self._onConnect(event_d)): # The message was not handled, so log it. logger.error(event_d['error']) return event_d # return it in case running synchronously (no thread) - def _memoryRead(self, farNodeID, offset): + def _memoryRead(self, farNodeID: Union[NodeID, int, str, bytearray], + offset: int): """Create and send a read datagram. This is a read of 20 bytes from the start of CDI space. We will fire it on a separate thread to give time for other nodes to @@ -364,24 +370,24 @@ def _memoryRead(self, farNodeID, offset): self._memoryReadFail, self._memoryReadSuccess) self._memoryService.requestMemoryRead(memMemo) - def downloadCDI(self, farNodeID, callback=None): + def downloadCDI(self, farNodeID: str, callback=None): if not farNodeID or not farNodeID.strip(): raise ValueError("No farNodeID specified.") self._farNodeID = farNodeID - self._string_terminated = False + self._stringTerminated = False if callback is None: def callback(event_d): print("downloadCDI default callback: {}".format(event_d), file=sys.stderr) - self._element_listener = callback + self._onElement = callback if not self._port: raise RuntimeError( - "No port connection. Call start_listening first.") + "No port connection. Call startListening first.") if not self._physicalLayer: raise RuntimeError( - "No physicalLayer. Call start_listening first.") + "No physicalLayer. Call startListening first.") self._cdi_offset = 0 - self._reset_tree() + self._resetTree() self._mode = OpenLCBNetwork.Mode.CDI if self._resultingCDI is not None: raise ValueError( @@ -392,16 +398,16 @@ def callback(event_d): # ^ On a successful memory read, _memoryReadSuccess will trigger # _memoryRead again and again until end/fail. - # def _sendToPort(self, string): + # def _sendToPort(self, string: str): # # print(" SR: {}".format(string.strip())) # DeprecationWarning("Use a PhysicalLayer subclass' sendFrameAfter") # self.sendFrameAfter(string) - # def _printFrame(self, frame): + # def _printFrame(self, frame: CanFrame): # # print(" RL: {}".format(frame)) # pass - def _handleMessage(self, message): + def _handleMessage(self, message: Message): """Handle a Message from the LCC network. The Message Type Indicator (MTI) is checked in case the application should visualize a change in the connection state @@ -422,28 +428,28 @@ def _handleMessage(self, message): .format(message, message.source)) print("[_handleMessage] message.mti={}".format(message.mti)) if message.mti == MTI.Link_Layer_Down: - if self._connect_listener: - self._connect_listener({ + if self._onConnect: + self._onConnect({ 'done': True, 'error': "Disconnected", 'message': message, }) - self._message_t = None # prevent _listen from discarding error + self._messageStart = None # so _listen won't discard error return True elif message.mti == MTI.Link_Layer_Up: - if self._connect_listener: - self._connect_listener({ + if self._onConnect: + self._onConnect({ 'done': True, # 'done' without error indicates connected. 'message': message, }) return True return False - def _printDatagram(self, memo): + def _printDatagram(self, memo: DatagramReadMemo): """A call-back for when datagrams received Args: - DatagramReadMemo: The datagram object + memo (DatagramReadMemo): The datagram object Returns: bool: Always False (True would mean we sent a reply to the @@ -452,7 +458,7 @@ def _printDatagram(self, memo): # print("Datagram receive call back: {}".format(memo.data)) return False - def _CDIReadPartial(self, memo): + def _CDIReadPartial(self, memo: MemoryReadMemo): """Handle partial CDI XML (any packet except last) The last packet is not yet reached, so don't parse (but feed if self._realtime) @@ -465,7 +471,7 @@ def _CDIReadPartial(self, memo): if self._realtime: self._parser.feed(partial_str) # may call startElement/endElement - def _CDIReadDone(self, memo): + def _CDIReadDone(self, memo: MemoryReadMemo): """Handle end of CDI XML (last packet) End of data, so parse (or feed if self._realtime) @@ -497,9 +503,9 @@ def _CDIReadDone(self, memo): # print (cdiString) self.parse(cdiString) # ^ startElement, endElement, etc. all consecutive using parse - # self._callback_status("Done loading CDI.") - if self._element_listener: - self._element_listener({ + # self._fireStatus("Done loading CDI.") + if self._onElement: + self._onElement({ 'done': True, # 'done' and not 'error' means got all }) if self._realtime: @@ -513,8 +519,8 @@ def _CDIReadDone(self, memo): print('Saved "{}"'.format(path)) self._resultingCDI = None # Ensure isn't reused for more than one doc - def cache_cdi_path(self, item_id): - cdi_cache_dir = os.path.join(self._my_cache_dir, "cdi") + def cache_cdi_path(self, item_id: Union[NodeID, str]): + cdi_cache_dir = os.path.join(self._myCacheDir, "cdi") if not os.path.isdir(cdi_cache_dir): os.makedirs(cdi_cache_dir) # TODO: add hardware name and firmware version and from SNIP to @@ -531,7 +537,7 @@ def cache_cdi_path(self, item_id): raise ValueError("Cannot specify absolute path.") return path + ".xml" - def _memoryReadSuccess(self, memo): + def _memoryReadSuccess(self, memo: MemoryReadMemo): """Handle a successful read Invoked when the memory read successfully returns, this queues a new read until the entire CDI has been @@ -542,7 +548,7 @@ def _memoryReadSuccess(self, memo): """ # print("successful memory read: {}".format(memo.data)) if len(memo.data) == 64 and 0 not in memo.data: # *not* last chunk - self._string_terminated = False + self._stringTerminated = False if self._mode == OpenLCBNetwork.Mode.CDI: # save content self._CDIReadPartial(memo) @@ -556,7 +562,7 @@ def _memoryReadSuccess(self, memo): self._memoryService.requestMemoryRead(memo) # The last packet is not yet reached else: # last chunk - self._string_terminated = True + self._stringTerminated = True # and we're done! if self._mode == OpenLCBNetwork.Mode.CDI: self._CDIReadDone(memo) @@ -567,17 +573,17 @@ def _memoryReadSuccess(self, memo): self._mode = OpenLCBNetwork.Mode.Idle # CDI no longer expected # done reading - def _memoryReadFail(self, memo): + def _memoryReadFail(self, memo: MemoryReadMemo): error = "memory read failed: {}".format(memo.data) - if self._element_listener: - self._element_listener({ + if self._onElement: + self._onElement({ 'error': error, 'done': True, # stop progress in gui/other main thread }) else: logger.error(error) - def startElement(self, name, attrs): + def startElement(self, name: str, attrs: AttributesImpl[str]): """See xml.sax.handler.ContentHandler documentation.""" tab = " " * len(self._tag_stack) print(tab, "Start: ", name) @@ -585,22 +591,22 @@ def startElement(self, name, attrs): print(tab, " Attributes: ", attrs.getNames()) # el = ET.Element(name, attrs) attrib = attrs_to_dict(attrs) - el = ET.SubElement(self._open_el, name, attrib) + el = ET.SubElement(self._openEl, name, attrib) # if self._tag_stack: # parent = self._tag_stack[-1] event_d = {'name': name, 'end': False, 'attrs': attrs, 'element': el} - if self._element_listener: - self._element_listener(event_d) + if self._onElement: + self._onElement(event_d) # self._callback_msg( # "loaded: {}{}".format(tab, ET.tostring(el, encoding="unicode"))) self._tag_stack.append(el) - self._open_el = el + self._openEl = el - def checkDone(self, event_d): + def checkDone(self, event_d: dict): """Notify the caller if parsing is over. - Calls _element_listener with `'done': True` in the argument if + Calls _onElement with `'done': True` in the argument if 'name' is "cdi" (case-insensitive). That notifies the downloadCDI caller that parsing is over, so that caller should end progress bar/other status tracking for downloadCDI in that @@ -608,7 +614,7 @@ def checkDone(self, event_d): Returns: dict: Reserved for use without events (doesn't need to be - processed if self._element_listener is set since that + processed if self._onElement is set since that also gets the dict if 'done'). 'done' is only True if 'name' is "cdi" (case-insensitive). """ @@ -618,11 +624,11 @@ def checkDone(self, event_d): # Not , so not done yet return event_d event_d['done'] = True # since "cdi" if avoided conditional return - if self._element_listener: - self._element_listener(event_d) + if self._onElement: + self._onElement(event_d) return event_d - def endElement(self, name): + def endElement(self, name: str): """See xml.sax.handler.ContentHandler documentation.""" indent = len(self._tag_stack) tab = " " * indent @@ -648,9 +654,9 @@ def endElement(self, name): return del self._tag_stack[-1] if self._tag_stack: - self._open_el = self._tag_stack[-1] + self._openEl = self._tag_stack[-1] else: - self._open_el = self.etree + self._openEl = self.etree if self._tag_stack: event_d['parent'] = self._tag_stack[-1] event_d['element'] = top_el @@ -659,7 +665,7 @@ def endElement(self, name): # Notify downloadCDI's caller since it can potentially add # UI widget(s) for at least one setting/segment/group # using this 'element'. - self._element_listener(event_d) + self._onElement(event_d) # def _flushCharBuffer(self): # """Decode the buffer, clear it, and return all content. @@ -672,7 +678,7 @@ def endElement(self, name): # self._chunks.clear() # return s - # def characters(self, data): + # def characters(self, data: Union[bytearray, bytes, list[int]]): # """Received characters handler. # See xml.sax.handler.ContentHandler documentation. diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index e1230ba..146f8ee 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -92,8 +92,9 @@ def clearReservation(self, reservation: int): increment that. """ assert isinstance(reservation, int) + idx = reservation newFrames = \ - [frame for frame in self._send_frames if frame.reservation != reservation] + [frame for frame in self._send_frames if frame.reservation != idx] # ^ iterates from left to right, so this is ok (We use popleft, # and 0 will become left) self._send_frames.clear() diff --git a/openlcb/pip.py b/openlcb/pip.py index c51782c..ae0878a 100644 --- a/openlcb/pip.py +++ b/openlcb/pip.py @@ -7,6 +7,7 @@ ''' from enum import Enum +from typing import Iterable, Union class PIP(Enum): @@ -35,31 +36,47 @@ class PIP(Enum): FIRMWARE_ACTIVE = 0x00_00_10_00 # get a list of all enum entries - def list(): + def list() -> list: return list(map(lambda c: c, PIP)) - # return an array of strings found in an int value - def contentsNamesFromInt(contents): + def contentsNamesFromInt(bitmask: int) -> list[str]: + """Convert protocol bits to strings. + + Args: + contents (int): 0 or more PIP values + (protocol bits) combined (as a single bitmask). + + Returns: + list[str]: Names found in an int value. + """ retval = [] for pip in PIP.list(): - if (pip.value & contents == pip.value): + if (pip.value & bitmask == pip.value): val = pip.name.replace("_", " ").title() if val.startswith("Adcdi"): val = "ADCDI Protocol" # Handle special case retval.append(val) return retval - # return an array of strings for all values included in a collection - def contentsNamesFromList(contents): + def contentsNamesFromList(pipList: Iterable) -> list[str]: + """Convert a list of PIP values to strings. + + Args: + contents (Iterable[PIP]): 0 or more PIP enums. + May be a list or any other collection. + + Returns: + list[str]: Names of PIP enums in contents. + """ retval = [] - for pip in contents: + for pip in pipList: val = pip.name.replace("_", " ").title() if val.startswith("Adcdi") : val = "ADCDI Protocol" # Handle special case retval.append(val) return retval - def setContentsFromInt(bitmask): + def setContentsFromInt(bitmask: int) -> set: """Get a set of contents from a single numeric bitmask Args: @@ -67,16 +84,17 @@ def setContentsFromInt(bitmask): protocol bits. Returns: - set (PIP): The set of protocol bits (bitmasks where 1 bit is on in + set(PIP): The set of protocol bits (bitmasks where 1 bit is on in each) derived from the bitmask. """ retVal = [] - for val in PIP.list(): - if (val.value & bitmask != 0): - retVal.append(val) + for pip in PIP.list(): # for each PIP + if (pip.value & bitmask != 0): + retVal.append(pip) return set(retVal) - def setContentsFromList(raw): + def setContentsFromList( + values: Union[bytearray, bytes, Iterable[int]]) -> set: """set contents from a list of numeric inputs Args: @@ -85,13 +103,13 @@ def setContentsFromList(raw): Returns: set (PIP): The set of protocol bits derived from the raw data. """ - data = 0 + bitmask = 0 if (len(raw) > 0): - data |= ((raw[0]) << 24) + bitmask |= ((raw[0]) << 24) if (len(raw) > 1): - data |= ((raw[1]) << 16) + bitmask |= ((raw[1]) << 16) if (len(raw) > 2): - data |= ((raw[2]) << 8) + bitmask |= ((raw[2]) << 8) if (len(raw) > 3): - data |= ((raw[3])) - return PIP.setContentsFromInt(data) + bitmask |= ((raw[3])) + return PIP.setContentsFromInt(bitmask) diff --git a/openlcb/portinterface.py b/openlcb/portinterface.py index 3472025..bd4a0ed 100644 --- a/openlcb/portinterface.py +++ b/openlcb/portinterface.py @@ -103,6 +103,7 @@ def connect(self, host, port, device=None): def connectLocal(self, port): """Convenience method for connecting local port such as serial (where host is not applicable since host is this machine). + See connect for documentation, but host is None in this case. """ self.connect(None, port) diff --git a/openlcb/realtimephysicallayer.py b/openlcb/realtimephysicallayer.py index 2f7a3c6..379ca23 100644 --- a/openlcb/realtimephysicallayer.py +++ b/openlcb/realtimephysicallayer.py @@ -52,7 +52,7 @@ def registerFrameReceivedListener(self, listener): self.onFrameReceived to its handler). Args: - listener (callable): A method that accepts decoded frame + listener (Callable): A method that accepts decoded frame objects from the network. """ logger.warning( diff --git a/openlcb/remotenodeprocessor.py b/openlcb/remotenodeprocessor.py index 668832a..0e94bf8 100644 --- a/openlcb/remotenodeprocessor.py +++ b/openlcb/remotenodeprocessor.py @@ -1,5 +1,6 @@ from openlcb.eventid import EventID +from openlcb.linklayer import LinkLayer from openlcb.node import Node # from openlcb.nodeid import NodeID from openlcb.message import Message @@ -17,10 +18,10 @@ class RemoteNodeProcessor(Processor) : track memory (config, CDI) contents due to size. ''' - def __init__(self, linkLayer=None) : + def __init__(self, linkLayer: LinkLayer = None) : self.linkLayer = linkLayer - def process(self, message, node) : + def process(self, message: Message, node: Node) : """Do a fast drop of messages not to us, from us, or global NOTE: linkLayer up/down are marked as global @@ -71,7 +72,7 @@ def process(self, message, node) : return False return False - def initializationComplete(self, message, node) : + def initializationComplete(self, message: Message, node: Node) : if self.checkSourceID(message, node) : # Send by us? node.state = Node.State.Initialized # clear out PIP, SNIP caches @@ -79,17 +80,17 @@ def initializationComplete(self, message, node) : node.pipSet = set(()) node.snip = SNIP() - def linkUpMessage(self, message, node) : + def linkUpMessage(self, message: Message, node: Node) : # affects everybody node.state = Node.State.Uninitialized # don't clear out PIP, SNIP caches, they're probably still good - def linkDownMessage(self, message, node) : + def linkDownMessage(self, message: Message, node: Node) : # affects everybody node.state = Node.State.Uninitialized # don't clear out PIP, SNIP caches, they're probably still good - def newNodeSeen(self, message, node) : + def newNodeSeen(self, message: Message, node: Node) : # send pip and snip requests for info from the new node pip = Message(MTI.Protocol_Support_Inquiry, self.linkLayer.localNodeID, node.id, bytearray()) @@ -104,7 +105,7 @@ def newNodeSeen(self, message, node) : self.linkLayer.localNodeID, node.id, bytearray()) self.linkLayer.sendMessage(eventReq) - def protocolSupportReply(self, message, node) : + def protocolSupportReply(self, message: Message, node: Node) : if self.checkSourceID(message, node) : # sent by us? part0 = ((message.data[0]) << 24) if (len(message.data) > 0) else 0 part1 = ((message.data[1]) << 16) if (len(message.data) > 1) else 0 @@ -114,26 +115,26 @@ def protocolSupportReply(self, message, node) : content = part0 | part1 | part2 | part3 node.pipSet = PIP.setContentsFromInt(content) - def simpleNodeIdentInfoRequest(self, message, node) : + def simpleNodeIdentInfoRequest(self, message: Message, node: Node) : if self.checkDestID(message, node) : # sent by us? - overlapping SNIP activity is otherwise confusing # noqa: E501 # clear SNIP in the node to start accumulating node.snip = SNIP() - def simpleNodeIdentInfoReply(self, message, node) : + def simpleNodeIdentInfoReply(self, message: Message, node: Node) : if self.checkSourceID(message, node) : # sent by this node? - overlapping SNIP activity is otherwise confusing # noqa: E501 # accumulate data in the node if len(message.data) > 2 : node.snip.addData(message.data) node.snip.updateStringsFromSnipData() - def producedEventIndicated(self, message, node) : + def producedEventIndicated(self, message: Message, node: Node) : if self.checkSourceID(message, node) : # produced by this node? # make an event id from the data eventID = EventID(message.data) # register it node.events.produces(eventID) - def consumedEventIndicated(self, message, node) : + def consumedEventIndicated(self, message: Message, node: Node) : if self.checkSourceID(message, node) : # consumed by this node? # make an event id from the data eventID = EventID(message.data) diff --git a/openlcb/scanner.py b/openlcb/scanner.py index 86159a8..439874c 100644 --- a/openlcb/scanner.py +++ b/openlcb/scanner.py @@ -37,19 +37,21 @@ def push(self, data: Union[bytearray, bytes, int]): return self._onHasNext() - def nextByte(self): + def nextByte(self) -> int: if not self._buffer: raise EOFError("No more bytes (_buffer={})" .format(emit_cast(self._buffer))) if not isinstance(self._buffer, (bytes, bytearray)): raise TypeError("Buffer is {} (nextByte is for bytes/bytearray)" .format(type(self._buffer).__name__)) - return self._buffer.popleft(0) + result = self._buffer[0] + del self._buffer[0] + return result - def hasNextByte(self): + def hasNextByte(self) -> bool: return True if self._buffer else False - def hasNext(self): + def hasNext(self) -> bool: if self._delimiter == Scanner.EOF: return self.hasNextByte() return self._delimiter in self._buffer diff --git a/openlcb/snip.py b/openlcb/snip.py index d01bd03..704dfa3 100644 --- a/openlcb/snip.py +++ b/openlcb/snip.py @@ -27,12 +27,18 @@ class SNIP: location. ''' - def __init__(self, mfgName="", - model="", - hVersion="", - sVersion="", - uName="", - uDesc=""): + def __init__(self, mfgName: str = "", + model: str = "", + hVersion: str = "", + sVersion: str = "", + uName: str = "", + uDesc: str = ""): + assert isinstance(mfgName, str) + assert isinstance(model, str) + assert isinstance(hVersion, str) + assert isinstance(sVersion, str) + assert isinstance(uName, str) + assert isinstance(uDesc, str) self.manufacturerName = mfgName self.modelName = model self.hardwareVersion = hVersion @@ -49,9 +55,8 @@ def __init__(self, mfgName="", # OpenLCB Strings are fixed length null terminated. # We don't (yet) support later versions with e.g. larger strings, etc. - def getStringN(self, n): - ''' - Get the desired string by string number in the data. + def getStringN(self, n: int) -> str: + '''Get the desired string by string number in the data. Args: n (int): 0-based number of the String @@ -74,13 +79,15 @@ def getStringN(self, n): return "" return self.getString(start, length) - def findString(self, n): - ''' - Find start index of the nth string. - - Zero indexed. + def findString(self, n: int) -> int: + '''Find start index of the nth string. Is aware of the 2nd version code byte. - Logs and returns -1 if the string isn't found within the buffer + + Args: + n (int): Zero indexed. + Returns: + int: 0, or logs and returns -1 if the string isn't found + within the buffer ''' if n == 0: @@ -105,7 +112,7 @@ def findString(self, n): # fell out without finding return 0 - def getString(self, first, maxLength): + def getString(self, first, maxLength) -> str: """Get the string at index `first` ending with either a null or having maxLength, whichever comes first. @@ -125,8 +132,7 @@ def getString(self, first, maxLength): return self.data[first:terminate_i].decode("utf-8") def addData(self, in_data): - ''' - Add additional bytes of SNIP data + '''Add additional bytes of SNIP data ''' for i in range(0, len(in_data)): # protect against overlapping requests causing an overflow @@ -138,8 +144,7 @@ def addData(self, in_data): self.updateStringsFromSnipData() def updateStringsFromSnipData(self): - ''' - Load strings from current SNIP accumulated data + '''Load strings from current SNIP accumulated data ''' self.manufacturerName = self.getStringN(0) self.modelName = self.getStringN(1) @@ -150,8 +155,7 @@ def updateStringsFromSnipData(self): self.userProvidedDescription = self.getStringN(5) def updateSnipDataFromStrings(self): - ''' - Store strings into SNIP accumulated data + '''Store strings into SNIP accumulated data ''' # clear string self.data = bytearray([0]*253) @@ -167,7 +171,7 @@ def updateSnipDataFromStrings(self): self.data[self.index] = mfgArray[i] self.index += 1 - self.data[self.index] = 0 + self.data[self.index] = 0 # null terminator self.index += 1 # mdlArray = Data(modelName.utf8.prefix(40)) @@ -177,7 +181,7 @@ def updateSnipDataFromStrings(self): self.data[self.index] = mdlArray[i] self.index += 1 - self.data[self.index] = 0 + self.data[self.index] = 0 # null terminator self.index += 1 # hdvArray = Data(hardwareVersion.utf8.prefix(20)) @@ -187,7 +191,7 @@ def updateSnipDataFromStrings(self): self.data[self.index] = hdvArray[i] self.index += 1 - self.data[self.index] = 0 + self.data[self.index] = 0 # null terminator self.index += 1 # sdvArray = Data(softwareVersion.utf8.prefix(20)) @@ -197,10 +201,11 @@ def updateSnipDataFromStrings(self): self.data[self.index] = sdvArray[i] self.index += 1 - self.data[self.index] = 0 + self.data[self.index] = 0 # null terminator self.index += 1 self.data[self.index] = 2 + # TODO: ^ comment what 2 means self.index += 1 # upnArray = Data(userProvidedNodeName.utf8.prefix(62)) @@ -210,7 +215,7 @@ def updateSnipDataFromStrings(self): self.data[self.index] = upnArray[i] self.index += 1 - self.data[self.index] = 0 + self.data[self.index] = 0 # null terminator self.index += 1 # updArray = Data(userProvidedDescription.utf8.prefix(63)) @@ -221,10 +226,10 @@ def updateSnipDataFromStrings(self): self.data[self.index] = updArray[i] self.index += 1 - self.data[self.index] = 0 + self.data[self.index] = 0 # null terminator self.index += 1 - def returnStrings(self): + def returnStrings(self) -> bytearray: '''copy out until the 6th zero byte''' stop = self.findString(6) retval = bytearray([0]*stop) diff --git a/openlcb/tcplink/tcplink.py b/openlcb/tcplink/tcplink.py index 556b6e7..48e8cac 100644 --- a/openlcb/tcplink/tcplink.py +++ b/openlcb/tcplink/tcplink.py @@ -11,6 +11,7 @@ ''' +from typing import Union from openlcb.linklayer import LinkLayer from openlcb.message import Message from openlcb.mti import MTI @@ -39,7 +40,7 @@ class State: DisconnectedState = State.Disconnected - def __init__(self, physicalLayer: PhysicalLayer, localNodeID): + def __init__(self, physicalLayer: PhysicalLayer, localNodeID: NodeID): LinkLayer.__init__(self, physicalLayer, localNodeID) # See class docstring for argument(s) and attributes. self.physicalLayer = physicalLayer @@ -104,7 +105,7 @@ def handleFrameReceived(self, inputData: bytearray): self.accumulatedData = self.accumulatedData[5+length:] # and repeat - def receivedPart(self, messagePart, flags, length): + def receivedPart(self, messagePart: bytearray, flags: int, length: int): """Receives message parts from handleFrameReceived and groups them into single OpenLCB messages as needed @@ -146,13 +147,13 @@ def receivedPart(self, messagePart, flags, length): # wait for next part return - def forwardMessage(self, messageBytes, gatewayNodeID) : # not sure why gatewayNodeID useful here... # noqa: E501 + def forwardMessage(self, messageBytes: Union[bytearray, list[int]], gatewayNodeID: NodeID) : # TODO: not sure why gatewayNodeID useful here... # noqa: E501 """ Receives single message from receivedPart, converts it in a Message object, and forwards to Message received listeners. Args: - messageBytes ([int]) : the bytes making up a + messageBytes (Union[bytearray, list[int]]) : the bytes making up a single OpenLCB message, starting with the MTI """ # extract MTI @@ -191,7 +192,7 @@ def linkDown(self): msg = Message(MTI.Link_Layer_Down, NodeID(0), None, bytearray()) self.fireMessageReceived(msg) - def sendMessage(self, message): + def sendMessage(self, message: Message): """ The message level calls this with an OpenLCB message. That is then converted to a byte diff --git a/openlcb/tcplink/tcpsocket.py b/openlcb/tcplink/tcpsocket.py index 8bbc215..37a6752 100644 --- a/openlcb/tcplink/tcpsocket.py +++ b/openlcb/tcplink/tcpsocket.py @@ -29,7 +29,7 @@ class TcpSocket(PortInterface): def __init__(self): super(TcpSocket, self).__init__() - def _settimeout(self, seconds): + def _settimeout(self, seconds: float): """Set the timeout for connect and transfer. Args: @@ -38,7 +38,7 @@ def _settimeout(self, seconds): """ self._device.settimeout(seconds) - def _connect(self, host, port, device=None): + def _connect(self, host: str, port: int, device=None): # public connect (do not overload) asserts no overlapping call if device is None: self._device = socket.socket( diff --git a/python-openlcb.code-workspace b/python-openlcb.code-workspace index b201fbe..9a22996 100644 --- a/python-openlcb.code-workspace +++ b/python-openlcb.code-workspace @@ -21,10 +21,13 @@ "1Ddddsss", "ABCN", "AccumKey", + "acdi", "ADCDI", "appendleft", "autosummary", "baudrate", + "bitfield", + "bitfields", "bitmask", "bitmasks", "bobjacobsen", @@ -41,13 +44,16 @@ "datagram", "datagrams", "datagramservice", + "deque", "distros", "dmemo", "Dmitry", "dunder", "frameencoder", + "gaierror", "gridargs", "gridconnectobserver", + "issuecomment", "JMRI", "linklayer", "LOCALAPPDATA", @@ -75,9 +81,11 @@ "pyproject", "pyserial", "pythonopenlcb", + "realtimephysicallayer", "remotenodeprocessor", "remotenodestore", "runlevel", + "seriallink", "servicetype", "settingtypes", "setuptools", @@ -90,6 +98,7 @@ "usbmodem", "WASI", "winnative", + "xscrollcommand", "zeroconf" ] } diff --git a/tests/test_canlink.py b/tests/test_canlink.py index 0105b6a..f7ac09b 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -134,7 +134,8 @@ def testEIR2NoData(self): canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) canLink._state = CanLink.State.Permitted - canPhysicalLayer.fireFrameReceived(CanFrame(ControlFrame.EIR2.value, 0)) + canPhysicalLayer.fireFrameReceived( + CanFrame(ControlFrame.EIR2.value, 0)) self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) canLink.onDisconnect() @@ -196,8 +197,8 @@ def testCIDreceivedMatch(self): ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink._state = CanLink.State.Permitted - canPhysicalLayer.fireFrameReceived(CanFrame(7, canLink.localNodeID, - ourAlias)) + canPhysicalLayer.fireFrameReceived( + CanFrame(7, canLink.localNodeID, ourAlias)) self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) self.assertEqual(canPhysicalLayer.receivedFrames[0], CanFrame(ControlFrame.RID.value, ourAlias)) @@ -209,8 +210,8 @@ def testRIDreceivedMatch(self): ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) canLink._state = CanLink.State.Permitted - canPhysicalLayer.fireFrameReceived(CanFrame(ControlFrame.RID.value, - ourAlias)) + canPhysicalLayer.fireFrameReceived( + CanFrame(ControlFrame.RID.value, ourAlias)) # ^ collision canLink.waitForReady() self.assertEqual(len(canPhysicalLayer.receivedFrames), 8) @@ -228,8 +229,8 @@ def testCheckMTIMapping(self): canLink = CanLinkLayerSimulation(PhyMockLayer(), getLocalNodeID()) self.assertEqual( - canLink.canHeaderToFullFormat(CanFrame(0x19490247, - bytearray())), + canLink.canHeaderToFullFormat( + CanFrame(0x19490247, bytearray())), MTI.Verify_NodeID_Number_Global ) @@ -313,8 +314,8 @@ def testVerifiedNodeInDestAliasMap(self): # Don't map an alias with an AMD for this test - canPhysicalLayer.fireFrameReceived(CanFrame(0x19170, 0x247, - bytearray([8, 7, 6, 5, 4, 3]))) + canPhysicalLayer.fireFrameReceived( + CanFrame(0x19170, 0x247, bytearray([8, 7, 6, 5, 4, 3]))) # ^ VerifiedNodeID from unique alias self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) diff --git a/tests/test_canphysicallayergridconnect.py b/tests/test_canphysicallayergridconnect.py index b1731e9..009ddb2 100644 --- a/tests/test_canphysicallayergridconnect.py +++ b/tests/test_canphysicallayergridconnect.py @@ -1,4 +1,3 @@ -from typing import Callable import unittest from openlcb import emit_cast @@ -18,7 +17,7 @@ def __init__(self): # ^ Sets onQueuedFrame on None, so set it afterward: self.onQueuedFrame = self.captureString - def captureString(self, frame): + def captureString(self, frame: CanFrame): # formerly was in CanPhysicalLayerGridConnectTest # but there isn't a send callback anymore # (to avoid port contention in issue #62) @@ -29,12 +28,12 @@ def captureString(self, frame): self.capturedFrame.encoder = self self.capturedString = frame.encodeAsString() - def onFrameSent(self, frame): + def onFrameSent(self, frame: CanFrame): pass # NOTE: not patching this method to be canLink.handleFrameSent # since testing only physical layer not link layer. - def onFrameReceived(self, frame): + def onFrameReceived(self, frame: CanFrame): pass # NOTE: not patching # self.onFrameReceived = canLink.handleFrameReceived @@ -55,7 +54,7 @@ def __init__(self, *args, **kwargs): # self.capturedString = string # Link Layer side - def receiveListener(self, frame): + def receiveListener(self, frame: CanFrame): self.receivedFrames += [frame] def testCID4Sent(self): diff --git a/tests/test_datagramservice.py b/tests/test_datagramservice.py index 2b05ea9..b3a0e70 100644 --- a/tests/test_datagramservice.py +++ b/tests/test_datagramservice.py @@ -26,7 +26,6 @@ class State: DisconnectedState = State.Disconnected - def sendMessage(self, message): LinkMockLayer.sentMessages.append(message) @@ -194,5 +193,6 @@ def testEnum(self): usedValues.add(entry.value) # print('{} = {}'.format(entry.name, entry.value)) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_tcplink.py b/tests/test_tcplink.py index a319f54..4681471 100644 --- a/tests/test_tcplink.py +++ b/tests/test_tcplink.py @@ -1,6 +1,5 @@ import unittest -from openlcb.physicallayer import PhysicalLayer from openlcb.realtimephysicallayer import RealtimePhysicalLayer from openlcb.tcplink.tcplink import TcpLink From 5e842e9abb49256feedaa349cf084ffd0b1dafd2 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 20 May 2025 08:47:50 -0400 Subject: [PATCH 74/99] Fix: Use backward-compatible typing class for List type hints. --- examples/example_cdi_access.py | 9 ++--- openlcb/canbus/canlink.py | 9 +++-- openlcb/canbus/canphysicallayersimulation.py | 3 +- openlcb/datagramservice.py | 8 +++-- openlcb/memoryservice.py | 9 +++-- openlcb/node.py | 3 +- openlcb/nodestore.py | 10 ++++-- openlcb/openlcbnetwork.py | 7 ++-- openlcb/pip.py | 35 +++++++++++--------- openlcb/tcplink/tcplink.py | 14 ++++---- 10 files changed, 69 insertions(+), 38 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index 144fa5b..bcb2658 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -12,6 +12,7 @@ ''' # region same code as other examples import copy +from xml.sax.expatreader import AttributesImpl from examples_settings import Settings # do 1st to fix path if no pip install from openlcb import precise_sleep from openlcb.canbus.gridconnectobserver import GridConnectObserver @@ -184,13 +185,13 @@ class MyHandler(xml.sax.handler.ContentHandler): def __init__(self): self._chunks = [] - def startElement(self, name, attrs): + def startElement(self, name: str, attrs): """See xml.sax.handler.ContentHandler documentation.""" print("Start: ", name) if attrs is not None and attrs : print(" Attributes: ", attrs.getNames()) - def endElement(self, name): + def endElement(self, name: str): """See xml.sax.handler.ContentHandler documentation.""" print(name, "content:", self._flushCharBuffer()) print("End: ", name) @@ -207,7 +208,7 @@ def _flushCharBuffer(self): self._chunks.clear() return s - def characters(self, data): + def characters(self, data: str): """Received characters handler. See xml.sax.handler.ContentHandler documentation. @@ -223,7 +224,7 @@ def characters(self, data): handler = MyHandler() -def processXML(content) : +def processXML(content: str) : """process the XML and invoke callbacks Args: diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 6ffb8ba..8bcdbd7 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -22,6 +22,11 @@ from logging import getLogger from timeit import default_timer +from typing import ( + # Iterable, + List, # in case list doesn't support `[` in this Python version + # Union, # in case `|` doesn't support 'type' in this Python version +) from openlcb import emit_cast, formatted_ex from openlcb.canbus.canframe import CanFrame @@ -798,7 +803,7 @@ def sendMessage(self, msg: Message): frame = CanFrame(header, msg.data) self.physicalLayer.sendFrameAfter(frame) - def segmentDatagramDataArray(self, data: bytearray) -> list[bytearray]: + def segmentDatagramDataArray(self, data: bytearray) -> List[bytearray]: """Segment data into zero or more arrays of no more than 8 bytes for datagram. @@ -830,7 +835,7 @@ def segmentDatagramDataArray(self, data: bytearray) -> list[bytearray]: return segments def segmentAddressedDataArray(self, alias: int, - data: bytearray) -> list[bytearray]: + data: bytearray) -> List[bytearray]: '''Segment data into zero or more arrays of no more than 8 bytes, with the alias at the start of each, for addressed non-datagram messages. diff --git a/openlcb/canbus/canphysicallayersimulation.py b/openlcb/canbus/canphysicallayersimulation.py index df6a4d7..1597850 100644 --- a/openlcb/canbus/canphysicallayersimulation.py +++ b/openlcb/canbus/canphysicallayersimulation.py @@ -2,6 +2,7 @@ Simulated CanPhysicalLayer to record frames requested to be sent. ''' +from typing import List from openlcb.canbus.canframe import CanFrame from openlcb.canbus.canphysicallayer import CanPhysicalLayer from openlcb.frameencoder import FrameEncoder @@ -10,7 +11,7 @@ class CanPhysicalLayerSimulation(CanPhysicalLayer, FrameEncoder): def __init__(self): - self.receivedFrames: list[CanFrame] = [] + self.receivedFrames: List[CanFrame] = [] CanPhysicalLayer.__init__(self) self.onQueuedFrame = self._onQueuedFrame diff --git a/openlcb/datagramservice.py b/openlcb/datagramservice.py index 4e24a40..082d3ec 100644 --- a/openlcb/datagramservice.py +++ b/openlcb/datagramservice.py @@ -24,7 +24,11 @@ from enum import Enum import logging -from typing import Callable, Union +from typing import ( + Callable, + List, # in case list doesn't support `[` in this Python version + Union, # in case `|` doesn't support 'type' in this Python version +) from openlcb.linklayer import LinkLayer from openlcb.message import Message @@ -103,7 +107,7 @@ def __init__(self, linkLayer: LinkLayer): self.pendingWriteMemos = [] self._datagramReceivedListeners = [] - def datagramType(self, data: Union[bytearray, list[int]]): + def datagramType(self, data: Union[bytearray, List[int]]): """Determine the protocol type of the content of the datagram. Args: diff --git a/openlcb/memoryservice.py b/openlcb/memoryservice.py index e228332..0feab24 100644 --- a/openlcb/memoryservice.py +++ b/openlcb/memoryservice.py @@ -22,7 +22,12 @@ ''' import logging -from typing import Union + +from typing import ( + List, # in case list doesn't support `[` in this Python version + Union, # in case `|` doesn't support 'type' in this Python version +) + from openlcb.datagramservice import ( # DatagramReadMemo, DatagramReadMemo, @@ -339,7 +344,7 @@ def requestSpaceLength(self, space, nodeID, callback): ) self.service.sendDatagram(dgReqMemo) - def arrayToInt(self, data: Union[bytes, bytearray, list[int]]) -> int: + def arrayToInt(self, data: Union[bytes, bytearray, List[int]]) -> int: """Convert an array in MSB-first order to an integer Args: diff --git a/openlcb/node.py b/openlcb/node.py index 855a5a5..2c5a72a 100644 --- a/openlcb/node.py +++ b/openlcb/node.py @@ -14,6 +14,7 @@ ''' from enum import Enum +from typing import Set from openlcb.pip import PIP from openlcb.snip import SNIP from openlcb.localeventstore import LocalEventStore @@ -39,7 +40,7 @@ class Node: events (LocalEventStore): The store for local events associated with the node. """ - def __init__(self, nodeID, snip: SNIP = None, pipSet: set[PIP] = None): + def __init__(self, nodeID, snip: SNIP = None, pipSet: Set[PIP] = None): self.id = nodeID self.snip = snip if snip is None : self.snip = SNIP() diff --git a/openlcb/nodestore.py b/openlcb/nodestore.py index e2bcc78..3a9965b 100644 --- a/openlcb/nodestore.py +++ b/openlcb/nodestore.py @@ -1,4 +1,8 @@ -from typing import Union +from typing import ( + List, # in case list doesn't support `[` in this Python version + Union, # in case `|` doesn't support 'type' in this Python version +) + from openlcb.message import Message from openlcb.node import Node from openlcb.nodeid import NodeID @@ -15,8 +19,8 @@ class NodeStore : def __init__(self) : self.byIdMap: dict = {} - self.nodes: list[Node] = [] - self.processors: list[Processor] = [] + self.nodes: List[Node] = [] + self.processors: List[Processor] = [] # Store a new node or replace an existing stored node # - Parameter node: new Node content diff --git a/openlcb/openlcbnetwork.py b/openlcb/openlcbnetwork.py index 033d933..821abf0 100644 --- a/openlcb/openlcbnetwork.py +++ b/openlcb/openlcbnetwork.py @@ -66,6 +66,7 @@ def attrs_to_dict(attrs) -> dict: attrs (AttributesImpl): attrs from xml parser startElement event (Not the same as element.attrib which is already dict). """ + # AttributesImpl[str] type hint fails on Python 3.8. For autocomplete: # attrs = AttributesImpl(attrs) # attrs_dict = attrs.__dict__ # may have private members, so: return {key: attrs.getValue(key) for key in attrs.getNames()} @@ -583,8 +584,10 @@ def _memoryReadFail(self, memo: MemoryReadMemo): else: logger.error(error) - def startElement(self, name: str, attrs: AttributesImpl[str]): + def startElement(self, name: str, attrs): """See xml.sax.handler.ContentHandler documentation.""" + # AttributesImpl[str] type hint fails on Python 3.8. For autocomplete: + # attrs = AttributesImpl(attrs) tab = " " * len(self._tag_stack) print(tab, "Start: ", name) if attrs is not None and attrs : @@ -678,7 +681,7 @@ def endElement(self, name: str): # self._chunks.clear() # return s - # def characters(self, data: Union[bytearray, bytes, list[int]]): + # def characters(self, data: Union[bytearray, bytes, List[int]]): # """Received characters handler. # See xml.sax.handler.ContentHandler documentation. diff --git a/openlcb/pip.py b/openlcb/pip.py index ae0878a..3b56c95 100644 --- a/openlcb/pip.py +++ b/openlcb/pip.py @@ -7,7 +7,12 @@ ''' from enum import Enum -from typing import Iterable, Union +from typing import ( + Iterable, + List, + Set, # in case list doesn't support `[` in this Python version + Union, # in case `|` doesn't support 'type' in this Python version +) class PIP(Enum): @@ -36,10 +41,10 @@ class PIP(Enum): FIRMWARE_ACTIVE = 0x00_00_10_00 # get a list of all enum entries - def list() -> list: + def list() -> List: return list(map(lambda c: c, PIP)) - def contentsNamesFromInt(bitmask: int) -> list[str]: + def contentsNamesFromInt(bitmask: int) -> List[str]: """Convert protocol bits to strings. Args: @@ -58,7 +63,7 @@ def contentsNamesFromInt(bitmask: int) -> list[str]: retval.append(val) return retval - def contentsNamesFromList(pipList: Iterable) -> list[str]: + def contentsNamesFromList(pipList: Iterable) -> List[str]: """Convert a list of PIP values to strings. Args: @@ -76,7 +81,7 @@ def contentsNamesFromList(pipList: Iterable) -> list[str]: retval.append(val) return retval - def setContentsFromInt(bitmask: int) -> set: + def setContentsFromInt(bitmask: int) -> Set: """Get a set of contents from a single numeric bitmask Args: @@ -94,22 +99,22 @@ def setContentsFromInt(bitmask: int) -> set: return set(retVal) def setContentsFromList( - values: Union[bytearray, bytes, Iterable[int]]) -> set: + values: Union[bytearray, bytes, Iterable[int]]) -> Set: """set contents from a list of numeric inputs Args: - raw (Union[bytes,list[int]]): a list of 1-byte values + values (Union[bytes,list[int]]): a list of 1-byte values Returns: set (PIP): The set of protocol bits derived from the raw data. """ bitmask = 0 - if (len(raw) > 0): - bitmask |= ((raw[0]) << 24) - if (len(raw) > 1): - bitmask |= ((raw[1]) << 16) - if (len(raw) > 2): - bitmask |= ((raw[2]) << 8) - if (len(raw) > 3): - bitmask |= ((raw[3])) + if (len(values) > 0): + bitmask |= ((values[0]) << 24) + if (len(values) > 1): + bitmask |= ((values[1]) << 16) + if (len(values) > 2): + bitmask |= ((values[2]) << 8) + if (len(values) > 3): + bitmask |= ((values[3])) return PIP.setContentsFromInt(bitmask) diff --git a/openlcb/tcplink/tcplink.py b/openlcb/tcplink/tcplink.py index 48e8cac..684bc51 100644 --- a/openlcb/tcplink/tcplink.py +++ b/openlcb/tcplink/tcplink.py @@ -10,16 +10,18 @@ Assembles messages parts, but does not break messages into parts. ''' +import logging +import time + +from typing import ( + List, # in case list doesn't support `[` in this Python version + Union, # in case `|` doesn't support 'type' in this Python version +) -from typing import Union from openlcb.linklayer import LinkLayer from openlcb.message import Message from openlcb.mti import MTI from openlcb.nodeid import NodeID - -import logging -import time - from openlcb.physicallayer import PhysicalLayer @@ -147,7 +149,7 @@ def receivedPart(self, messagePart: bytearray, flags: int, length: int): # wait for next part return - def forwardMessage(self, messageBytes: Union[bytearray, list[int]], gatewayNodeID: NodeID) : # TODO: not sure why gatewayNodeID useful here... # noqa: E501 + def forwardMessage(self, messageBytes: Union[bytearray, List[int]], gatewayNodeID: NodeID) : # TODO: not sure why gatewayNodeID useful here... # noqa: E501 """ Receives single message from receivedPart, converts it in a Message object, and forwards to Message received listeners. From 4fb2051c37e246fb89fbebbb2ddb4137edf5431d Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 20 May 2025 08:48:11 -0400 Subject: [PATCH 75/99] Remove lint. --- examples/example_datagram_transfer.py | 12 ++++----- examples/example_remote_nodes.py | 36 +++++++++++++-------------- python-openlcb.code-workspace | 1 + 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index 3dfc9dd..30142a8 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -19,15 +19,15 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -import threading +import threading # noqa:E402 -from openlcb.tcplink.tcpsocket import TcpSocket -from openlcb.canbus.canphysicallayergridconnect import ( +from openlcb.tcplink.tcpsocket import TcpSocket # noqa:E402 +from openlcb.canbus.canphysicallayergridconnect import ( # noqa:E402 CanPhysicalLayerGridConnect, ) -from openlcb.canbus.canlink import CanLink -from openlcb.nodeid import NodeID -from openlcb.datagramservice import ( +from openlcb.canbus.canlink import CanLink # noqa:E402 +from openlcb.nodeid import NodeID # noqa:E402 +from openlcb.datagramservice import ( # noqa:E402 DatagramService, DatagramWriteMemo, ) diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index 557a70e..7396b7b 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -21,26 +21,26 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb.canbus.canphysicallayergridconnect import ( +from openlcb.canbus.canphysicallayergridconnect import ( # noqa:E402 CanPhysicalLayerGridConnect, ) -# from openlcb.canbus.canframe import CanFrame -from openlcb.canbus.canlink import CanLink -# from openlcb.canbus.controlframe import ControlFrame -from openlcb.tcplink.tcpsocket import TcpSocket - -from openlcb.node import Node -from openlcb.nodeid import NodeID -from openlcb.message import Message -from openlcb.mti import MTI -from openlcb.localnodeprocessor import LocalNodeProcessor -from openlcb.pip import PIP -from openlcb.remotenodeprocessor import RemoteNodeProcessor -from openlcb.remotenodestore import RemoteNodeStore -from openlcb.snip import SNIP - -from queue import Queue -from queue import Empty +# from openlcb.canbus.canframe import CanFrame # noqa:E402 +from openlcb.canbus.canlink import CanLink # noqa:E402 +# from openlcb.canbus.controlframe import ControlFrame # noqa:E402 +from openlcb.tcplink.tcpsocket import TcpSocket # noqa:E402 + +from openlcb.node import Node # noqa:E402 +from openlcb.nodeid import NodeID # noqa:E402 +from openlcb.message import Message # noqa:E402 +from openlcb.mti import MTI # noqa:E402 +from openlcb.localnodeprocessor import LocalNodeProcessor # noqa:E402 +from openlcb.pip import PIP # noqa:E402 +from openlcb.remotenodeprocessor import RemoteNodeProcessor # noqa:E402 +from openlcb.remotenodestore import RemoteNodeStore # noqa:E402 +from openlcb.snip import SNIP # noqa:E402 + +from queue import Queue # noqa:E402 +from queue import Empty # noqa:E402 # specify default connection information # region replaced by settings diff --git a/python-openlcb.code-workspace b/python-openlcb.code-workspace index 9a22996..c35d10d 100644 --- a/python-openlcb.code-workspace +++ b/python-openlcb.code-workspace @@ -84,6 +84,7 @@ "realtimephysicallayer", "remotenodeprocessor", "remotenodestore", + "repr", "runlevel", "seriallink", "servicetype", From cae5664712056ddc2ad717714ede43858300be1d Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 20 May 2025 08:49:02 -0400 Subject: [PATCH 76/99] Add more type hints. --- examples/tkexamples/cdiform.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/examples/tkexamples/cdiform.py b/examples/tkexamples/cdiform.py index a7aa689..52e1dac 100644 --- a/examples/tkexamples/cdiform.py +++ b/examples/tkexamples/cdiform.py @@ -9,13 +9,14 @@ Contributors: Poikilos """ -from collections import deque import os import sys import tkinter as tk from tkinter import ttk +from collections import deque from logging import getLogger +from typing import Callable # from xml.etree import ElementTree as ET from openlcb.openlcbnetwork import element_to_dict @@ -67,7 +68,7 @@ def __init__(self, *args, **kwargs): self._treeview = None self._gui(self._container) - def _gui(self, container): + def _gui(self, container: tk.Widget): if self._top_widgets: raise RuntimeError("gui can only be called once unless reset") self._status_var = tk.StringVar(self) @@ -96,16 +97,17 @@ def clear(self): # return OpenLCBNetwork.connect(self, new_socket, localNodeID, # callback=callback) - def downloadCDI(self, farNodeID, callback=None): + def downloadCDI(self, farNodeID: str, + callback: Callable[[dict], None] = None): self.set_status("Downloading CDI...") self.ignore_non_gui_tags = deque() self._populating_stack = deque() super().downloadCDI(farNodeID, callback=callback) - def set_status(self, message): + def set_status(self, message: str): self._status_var.set(message) - def on_cdi_element(self, event_d): + def on_cdi_element(self, event_d: dict): """Handler for incoming CDI tag (Use this for callback in downloadCDI, which sets parser's _onElement) @@ -147,7 +149,7 @@ def on_cdi_element(self, event_d): else: self.root.after(0, self._on_cdi_element_start, event_d) - def _on_cdi_element_end(self, event_d): + def _on_cdi_element_end(self, event_d: dict): name = event_d['name'] nameLower = name.lower() if (self.ignore_non_gui_tags @@ -179,7 +181,7 @@ def _populating_branch(self): return "" # "" (empty str) is magic value for top of ttk.Treeview return self._populating_stack.pop() - def _on_cdi_element_start(self, event_d): + def _on_cdi_element_start(self, event_d: dict): element = event_d.get('element') segment = event_d.get('segment') groups = event_d.get('groups') From 64fee90dd8d95b35da23b3d20671e8056cd20754 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 20 May 2025 09:16:02 -0400 Subject: [PATCH 77/99] Show a message during latency for clarity. --- examples/example_cdi_access.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index bcb2658..df56847 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -317,7 +317,7 @@ def memoryRead(): print("Waiting for connection sequence to complete...") # This delay could be .2, but longer to reduce console messages: time.sleep(.5) - + print("Requesting memory read. Please wait...") # read 64 bytes from the CDI space starting at address zero memMemo = MemoryReadMemo(NodeID(settings['farNodeID']), 64, 0xFF, 0, memoryReadFail, memoryReadSuccess) From 7fbc4412efa8ade9b12d1f92e520e9bc5b45a95e Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 20 May 2025 09:16:15 -0400 Subject: [PATCH 78/99] Remove lint. --- examples/example_memory_length_query.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index 1db08cb..750db8a 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -17,22 +17,22 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb import precise_sleep -from openlcb.canbus.gridconnectobserver import GridConnectObserver -from openlcb.tcplink.tcpsocket import TcpSocket +from openlcb import precise_sleep # noqa: E402 +from openlcb.canbus.gridconnectobserver import GridConnectObserver # noqa:E402 +from openlcb.tcplink.tcpsocket import TcpSocket # noqa: E402 -from openlcb.canbus.canphysicallayergridconnect import ( +from openlcb.canbus.canphysicallayergridconnect import ( # noqa: E402 CanPhysicalLayerGridConnect, ) -from openlcb.canbus.canlink import CanLink -from openlcb.nodeid import NodeID -from openlcb.datagramservice import ( +from openlcb.canbus.canlink import CanLink # noqa: E402 +from openlcb.nodeid import NodeID # noqa: E402 +from openlcb.datagramservice import ( # noqa: E402 # DatagramWriteMemo, # DatagramReadMemo, DatagramService, ) -from openlcb.memoryservice import ( - MemoryReadMemo, +from openlcb.memoryservice import ( # noqa: E402 + # MemoryReadMemo, # MemoryWriteMemo, MemoryService, ) @@ -113,7 +113,7 @@ def printDatagram(memo): def memoryLengthReply(address) : - print ("memory length reply: "+str(address)) + print("memory length reply: "+str(address)) ####################### @@ -140,6 +140,7 @@ def pumpEvents(): sock.sendString(string) physicalLayer.onFrameSent(frame) + # have the socket layer report up to bring the link layer up and get an alias print(" SL : link up...") @@ -153,6 +154,7 @@ def pumpEvents(): precise_sleep(.02) print(" SL : link up") + def memoryRequest(): """Create and send a read datagram. This is a read of 20 bytes from the start of CDI space. @@ -166,7 +168,8 @@ def memoryRequest(): # memMemo = MemoryReadMemo(NodeID(settings['farNodeID']), # 64, 0xFF, 0, memoryReadFail, # memoryReadSuccess) - memoryService.requestSpaceLength(0xFF, NodeID(settings['farNodeID']), memoryLengthReply) + memoryService.requestSpaceLength(0xFF, NodeID(settings['farNodeID']), + memoryLengthReply) import threading # noqa E402 From a52db244fc920400113cb105a6c780000057f588 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 20 May 2025 12:12:34 -0400 Subject: [PATCH 79/99] Call pollState automatically (reduces extra looping in client code such as in examples, at least for non-delayed states). --- examples/example_cdi_access.py | 31 +++++++----------------- examples/example_datagram_transfer.py | 2 +- openlcb/canbus/canlink.py | 23 ++++++++++++++++++ openlcb/canbus/canlinklayersimulation.py | 9 ++++--- openlcb/linklayer.py | 11 ++++++++- openlcb/physicallayer.py | 8 +++++- 6 files changed, 56 insertions(+), 28 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index df56847..7322977 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -252,13 +252,13 @@ def pumpEvents(): _ = observer.next() # packet_str = _ # print(" RR: "+packet_str.strip()) - # ^ commented since MyHandler shows parsed XML - # fields instead + # ^ commented since very verbose (MyHandler shows + # parsed XML fields later anyway) # pass to link processor physicalLayer.handleData(received) except BlockingIOError: pass - canLink.pollState() + canLink.pollState() # Advance delayed state(s) if necessary while True: frame = physicalLayer.pollFrame() if not frame: @@ -296,26 +296,13 @@ def memoryRead(): """ import time time.sleep(.21) - # ^ 200ms is the time span in which a node with the same alias - # (*only if it has the same alias*) must reply to ensure our alias - # is ok according to the LCC CAN Frame Transfer Standard. - # - The countdown does not start until after the socket loop - # calls onFrameSent. This ensures that nodes had a chance to - # respond (the Standard only states to wait after sending, so - # any latency after send is the responsibility of the Standard). - # - Then wait longer below if there was a failure/retry: - # According to the Standard any error or collision (See - # processCollision in this implementation) must restart the - # sequence and prevent CanLink.State.Permitted until the - # sequence completes without error), before trying to use the - # LCC network: - while canLink._state != CanLink.State.Permitted: - # Would only take more than ~200ms (possibly a few nanoseconds - # more for latency on the part of this program itself) - # if multiple alias collisions - # (alias is incremented to find a unique one) + # ^ 200ms: See section 6.2.1 of CAN Frame Transfer Standard + # (CanLink.State.Permitted will only occur after that, but waiting + # now will reduce output & delays below in this example). + while canLink.getState() != CanLink.State.Permitted: print("Waiting for connection sequence to complete...") - # This delay could be .2, but longer to reduce console messages: + # This delay could be .2 (per alias collision), but longer to + # reduce console messages: time.sleep(.5) print("Requesting memory read. Please wait...") # read 64 bytes from the CDI space starting at address zero diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index 30142a8..d97f2ae 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -110,7 +110,7 @@ def pumpEvents(): print(" RR: "+packet_str.strip()) # pass to link processor physicalLayer.handleData(received) - canLink.pollState() + canLink.pollState() # Advance delayed state(s) if necessary while True: frame = physicalLayer.pollFrame() if frame is None: diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 8bcdbd7..b6dd9d7 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -269,6 +269,10 @@ def _onStateChanged(self, oldState: State, newState: State): # TODO: Make sure upper layers handle any states # necessary (formerly only states other than Initial were # Inhibited & Permitted). + self.pollState() # May enqueue frame(s) and/or change state + # (Calling it here may speed up certain state changes, but will + # not cause infinite recursion since it only should call this + # when state actually changed) def handleFrameReceived(self, frame: CanFrame): """Call the correct handler if any for a received frame. @@ -280,6 +284,7 @@ def handleFrameReceived(self, frame: CanFrame): frame (CanFrame): Any CanFrame, OpenLCB/LCC or not (if not then ignored). """ + handled = True # True if state may change, otherwise set False control_frame = self.decodeControlFrameFormat(frame) if not ControlFrame.isInternal(control_frame): self._frameCount += 1 @@ -296,6 +301,14 @@ def handleFrameReceived(self, frame: CanFrame): logger.warning( "Unexpected error report {:08X}" .format(frame.header)) + self._errorCount += 1 + if self.isRunningAliasReservation(): + print("Restarting alias reservation due to error ({})." + .format(control_frame)) + # Restart alias reservation process if an + # error occurs during it, as per section + # 6.2.1 of CAN Frame Transfer - Standard. + self.defineAndReserveAlias() elif control_frame == ControlFrame.LinkDown: self.handleReceivedLinkDown(frame) elif control_frame == ControlFrame.CID: @@ -330,7 +343,9 @@ def handleFrameReceived(self, frame: CanFrame): logger.warning( "Unexpected CAN header 0x{:08X}" .format(frame.header)) + handled = False else: + handled = False # This should never happen due to how # decodeControlFrameFormat works, but this is a "not # implemented" output for ensuring completeness (If this @@ -338,6 +353,8 @@ def handleFrameReceived(self, frame: CanFrame): logger.warning( "Invalid control frame format 0x{:08X}" .format(control_frame)) + if handled: + self.pollState() # May enqueue frame(s) and/or change state. def isRunningAliasReservation(self) -> bool: return self._state in ( @@ -954,6 +971,12 @@ def pollState(self) -> State: # reservation: self._waitingForAliasStart = None self.setState(CanLink.State.EnqueueAliasReservation) + # NOTE: *All* other state processing is done in _onStateChange + # which is always called by setState, so avoid infinite + # recursion by only calling setState from here if state is + # sure to have changed, and won't change to a state this + # handles since it calls this (and do all non-delayed state + # changes in _onStateChange not here). return self.getState() diff --git a/openlcb/canbus/canlinklayersimulation.py b/openlcb/canbus/canlinklayersimulation.py index 1e0163f..f2fe0cb 100644 --- a/openlcb/canbus/canlinklayersimulation.py +++ b/openlcb/canbus/canlinklayersimulation.py @@ -29,7 +29,7 @@ def pumpEvents(self): # physicalLayer.handleData(received) # except BlockingIOError: # pass - self.pollState() + self.pollState() # run first since may enqueue frame(s) while True: # self.physicalLayer must be set by canLink constructor by # passing a physicalLayer to it. @@ -66,6 +66,7 @@ def waitForReady(self, run_physical_link_up_test=False): debug_count = 0 second_state = None while True: + # NOTE: Must call handleData each read regardless of pollState(). debug_count += 1 # print("{}. state: {}".format(debug_count, state)) if state == CanLink.State.Permitted: @@ -93,7 +94,7 @@ def waitForReady(self, run_physical_link_up_test=False): .format(state)) second_state = state # If pumpEvents blocks for at least 200ms after send - # then receives, responses may have already been send + # then receives, responses may have already been sent # to handleFrameReceived, in which case we may be in a # later state. That isn't recommended except for # realtime applications (or testing). However, if that @@ -120,6 +121,8 @@ def waitForReady(self, run_physical_link_up_test=False): raise TimeoutError( "In Standard require_remote_nodes=False mode," " but failed to proceed to Permitted state.") - precise_sleep(.02) + precise_sleep(.02) # must be *less than* 200ms (.2) to process + # collisions (via handleData) if any during + # CanLink.State.WaitForAliases. state = self.pollState() print("[CanLinkLayerSimulation] waitForReady...done") diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index 24ce422..6906916 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -85,10 +85,15 @@ def handleFrameReceived(self, frame): "{} abstract handleFrameReceived called (expected implementation)" .format(type(self).__name__)) + def pollState(self): + print("Abstract pollState ran (implement in subclass)." + " Continuing anyway (assuming non-CAN or test subclass).") + def handleFrameSent(self, frame): """Update state based on the frame having been sent.""" if frame.afterSendState is not None: - self.setState(frame.afterSendState) + self.setState(frame.afterSendState) # may change again + # since setState calls pollState via _onStateChanged. def onDisconnect(self): """Run this whenever the socket connection is lost @@ -114,7 +119,11 @@ def setState(self, state): """Reusable LinkLayer setState (enforce type of state in _onStateChanged implementation in subclass) """ + # Run _onStateChange *even if state is same* as old state, to + # processes state as soon as possible (Let it catch up in case + # _state was set manually etc). oldState = self._state + newState = state # keep a copy for _onStateChanged, for thread safety # (ensure value doesn't change between two lines below) self._state = newState diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index 146f8ee..f14b505 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -62,10 +62,15 @@ def sendDataAfter(self, data: Union[bytes, bytearray]): raise NotImplementedError( "This method is only for Realtime subclass(es)" " (which should only be used when not using GridConnect" - " subclass, such for testing)") + " subclass, such for testing). Otherwise use" + " sendFrameAfter.") # assert isinstance(data, (bytes, bytearray)) # self._send_chunks.append(data) + def hasFrame(self) -> bool: + """Check if there is a frame queued to send.""" + return bool(self._send_frames) + def pollFrame(self): """Check if there is another frame queued and get it. Subclass should call PhysicalLayer.pollFrame (or @@ -81,6 +86,7 @@ def pollFrame(self): data = self._send_frames.popleft() return data except IndexError: # "popleft from an empty deque" + # (no problem, just fall through and return None) pass return None From c789eed13b67e709a0a20e4d39632849b5890328 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Tue, 20 May 2025 17:00:56 -0400 Subject: [PATCH 80/99] Clarify a comment. --- openlcb/canbus/canlink.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index b6dd9d7..bfa6197 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -269,10 +269,12 @@ def _onStateChanged(self, oldState: State, newState: State): # TODO: Make sure upper layers handle any states # necessary (formerly only states other than Initial were # Inhibited & Permitted). - self.pollState() # May enqueue frame(s) and/or change state - # (Calling it here may speed up certain state changes, but will - # not cause infinite recursion since it only should call this - # when state actually changed) + self.pollState() # May enqueue frame(s) via eventual recursion + # back to here, and/or change state. Calling it here may speed + # up certain state changes (prevent useless pollState loop + # iterations), but will not cause infinite recursion since + # pollState only should call this (via setState) when state + # actually changed. def handleFrameReceived(self, frame: CanFrame): """Call the correct handler if any for a received frame. From 0daf89a867311029d6f3a785e7615d44b8052a01 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Wed, 21 May 2025 11:55:09 -0400 Subject: [PATCH 81/99] Ignore cached XML in example. Add a comment regarding caching. --- .gitignore | 1 + examples/example_cdi_access.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 8613e5d..9f4e116 100644 --- a/.gitignore +++ b/.gitignore @@ -184,3 +184,4 @@ cython_debug/ /doc/_autosummary /examples/settings.json /.venv-3.12/ +/cached-cdi.xml diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index 7322977..212266e 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -235,6 +235,9 @@ def processXML(content: str) : # last packet was reached for the requested read. # - See memoryReadSuccess comments for details. with open("cached-cdi.xml", 'w') as stream: + # NOTE: Actual caching should key by all SNIP info that could + # affect CDI/FDI: manufacturer, model, and version. Without + # all 3 being present in SNIP, the cache may be incorrect. stream.write(content) xml.sax.parseString(content, handler) print("\nParser done") From 56d1fcf5198e10f07fc315e64c0cbab6029ef56b Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Wed, 21 May 2025 14:25:14 -0400 Subject: [PATCH 82/99] Add high-level send and receive (and thereby reduce examples to similar size as before _send_frames queue was added): Replace and rework simulation's pumpEvents with sendAll and receiveAll and move them and waitForReady (also reworked) to CanLink to make the features available to (non-simulation) programs. --- examples/example_cdi_access.py | 48 ++---- examples/example_datagram_transfer.py | 30 +--- examples/example_frame_interface.py | 7 +- examples/example_memory_length_query.py | 33 +--- examples/example_memory_transfer.py | 29 +--- examples/example_message_interface.py | 31 +--- examples/example_node_implementation.py | 28 +--- examples/example_remote_nodes.py | 52 +----- examples/example_string_interface.py | 2 + openlcb/canbus/canlink.py | 157 +++++++++++++++++- openlcb/canbus/canlinklayersimulation.py | 123 ++------------ openlcb/canbus/canphysicallayer.py | 2 +- openlcb/canbus/canphysicallayergridconnect.py | 59 ++++++- openlcb/canbus/canphysicallayersimulation.py | 9 + openlcb/openlcbnetwork.py | 1 - openlcb/physicallayer.py | 20 +++ tests/test_canlink.py | 29 +++- 17 files changed, 319 insertions(+), 341 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index 212266e..d26567f 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -12,10 +12,9 @@ ''' # region same code as other examples import copy -from xml.sax.expatreader import AttributesImpl +# from xml.sax.expatreader import AttributesImpl # only for IDE autocomplete from examples_settings import Settings # do 1st to fix path if no pip install from openlcb import precise_sleep -from openlcb.canbus.gridconnectobserver import GridConnectObserver from openlcb.tcplink.tcpsocket import TcpSocket settings = Settings() @@ -104,8 +103,6 @@ def printDatagram(memo): # callbacks to get results of memory read -observer = GridConnectObserver() - complete_data = False read_failed = False @@ -245,43 +242,19 @@ def processXML(content: str) : ####################### -def pumpEvents(): - try: - received = sock.receive() - if received is not None: - if settings['trace']: - observer.push(received) - if observer.hasNext(): - _ = observer.next() - # packet_str = _ - # print(" RR: "+packet_str.strip()) - # ^ commented since very verbose (MyHandler shows - # parsed XML fields later anyway) - # pass to link processor - physicalLayer.handleData(received) - except BlockingIOError: - pass - canLink.pollState() # Advance delayed state(s) if necessary - while True: - frame = physicalLayer.pollFrame() - if not frame: - break - string = frame.encodeAsString() - # print(" SENT packet: "+string.strip()) - # ^ This is too verbose for this example (each is a - # request to read a 64 byte chunks of the CDI XML) - sock.sendString(string) - physicalLayer.onFrameSent(frame) - - # have the socket layer report up to bring the link layer up and get an alias print(" QUEUE frames : link up...") physicalLayer.physicalLayerUp() print(" QUEUED frames : link up...waiting...") while canLink.pollState() != CanLink.State.Permitted: - pumpEvents() # provides incoming data to physicalLayer & sends queued + # provides incoming data to physicalLayer & sends queued: + canLink.receiveAll(sock, verbose=True) + canLink.sendAll(sock) + if canLink.getState() == CanLink.State.WaitForAliases: - pumpEvents() # prevent assertion error below, proceed to send. + # canLink.receiveAll(sock, verbose=True) + canLink.sendAll(sock) + # ^ prevent assertion error below, proceed to send. if canLink.pollState() == CanLink.State.Permitted: break assert canLink.getWaitForAliasResponseStart() is not None, \ @@ -323,11 +296,12 @@ def memoryRead(): print("This example will exit on failure or complete data.") while not complete_data and not read_failed: # In this example, requests are initiate by the - # memoryRead thread, and pumpEvents actually + # memoryRead thread, and receiveAll actually # receives the data from the requested memory space (CDI in this # case) and offset (incremental position in the file/data, # incremented by this example's memoryReadSuccess handler). - pumpEvents() + canLink.receiveAll(sock) + canLink.sendAll(sock) if canLink.nodeIdToAlias != previous_nodes: print("nodeIdToAlias updated: {}".format(canLink.nodeIdToAlias)) precise_sleep(.01) diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index d97f2ae..d943fe3 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -12,7 +12,6 @@ # region same code as other examples from examples_settings import Settings # do 1st to fix path if no pip install from openlcb import precise_sleep -from openlcb.canbus.gridconnectobserver import GridConnectObserver settings = Settings() if __name__ == "__main__": @@ -97,28 +96,6 @@ def datagramReceiver(memo): ####################### -observer = GridConnectObserver() - - -def pumpEvents(): - received = sock.receive() - if received is not None: - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) - canLink.pollState() # Advance delayed state(s) if necessary - while True: - frame = physicalLayer.pollFrame() - if frame is None: - break - sock.sendString(frame.encodeAsString()) - physicalLayer.onFrameSent(frame) - - # have the socket layer report up to bring the link layer up and get an alias print(" SL : link up...") physicalLayer.physicalLayerUp() @@ -127,7 +104,8 @@ def pumpEvents(): print(" SL : link up") while canLink.pollState() != CanLink.State.Permitted: - pumpEvents() + canLink.receiveAll(sock, verbose=settings['trace']) + canLink.sendAll(sock) precise_sleep(.02) @@ -153,7 +131,7 @@ def datagramWrite(): # process resulting activity while True: - pumpEvents() - + canLink.receiveAll(sock, verbose=settings['trace']) + canLink.sendAll(sock) canLink.onDisconnect() diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index 937646a..a9cd329 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -49,6 +49,9 @@ def sendToSocket(frame: CanFrame): def pumpEvents(): + # Normally receive call & case below can be replaced by + # canLink.receiveAll(sock), but in this example we have no link + # layer. received = sock.receive() if received is not None: if settings['trace']: @@ -58,8 +61,10 @@ def pumpEvents(): print(" RR: "+packet_str.strip()) # pass to link processor physicalLayer.handleData(received) - # canLink.pollState() + # Normally the lop below can be replaced by canLink.sendAll(sock), + # but in this example we have no link layer. + # canLink.pollState() while True: frame = physicalLayer.pollFrame() if frame is None: diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index 750db8a..6c04c3f 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -18,7 +18,6 @@ # endregion same code as other examples from openlcb import precise_sleep # noqa: E402 -from openlcb.canbus.gridconnectobserver import GridConnectObserver # noqa:E402 from openlcb.tcplink.tcpsocket import TcpSocket # noqa: E402 from openlcb.canbus.canphysicallayergridconnect import ( # noqa: E402 @@ -118,29 +117,6 @@ def memoryLengthReply(address) : ####################### - -def pumpEvents(): - received = sock.receive() - if received is not None: - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) - canLink.pollState() - - while True: - frame = physicalLayer.pollFrame() - if frame is None: - break - string = frame.encodeAsString() - print(" SR: {}".format(string.strip())) - sock.sendString(string) - physicalLayer.onFrameSent(frame) - - # have the socket layer report up to bring the link layer up and get an alias print(" SL : link up...") @@ -150,7 +126,8 @@ def pumpEvents(): while canLink.pollState() != CanLink.State.Permitted: - pumpEvents() + canLink.receiveAll(sock, verbose=settings['trace']) + canLink.sendAll(sock, verbose=True) precise_sleep(.02) print(" SL : link up") @@ -176,12 +153,10 @@ def memoryRequest(): thread = threading.Thread(target=memoryRequest) thread.start() -observer = GridConnectObserver() - - # process resulting activity while True: - pumpEvents() + canLink.receiveAll(sock, verbose=settings['trace']) + canLink.sendAll(sock, verbose=True) precise_sleep(.01) canLink.onDisconnect() diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index 7fb84f9..c522b45 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -19,7 +19,6 @@ # endregion same code as other examples from openlcb import precise_sleep # noqa: E402 -from openlcb.canbus.gridconnectobserver import GridConnectObserver # noqa:E402 from openlcb.tcplink.tcpsocket import TcpSocket # noqa: E402 from openlcb.canbus.canphysicallayergridconnect import ( # noqa: E402 @@ -115,24 +114,6 @@ def memoryReadFail(memo): ####################### -def pumpEvents(): - received = sock.receive() - if received is not None: - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) - canLink.pollState() - - while True: - frame = physicalLayer.pollFrame() - if frame is None: - break - sock.sendString(frame.encodeAsString()) - physicalLayer.onFrameSent(frame) # have the socket layer report up to bring the link layer up and get an alias @@ -141,7 +122,8 @@ def pumpEvents(): physicalLayer.physicalLayerUp() print(" SL : link up...waiting...") while canLink.pollState() != CanLink.State.Permitted: - pumpEvents() + canLink.receiveAll(sock, verbose=settings['trace']) + canLink.sendAll(sock) precise_sleep(.02) print(" SL : link up") @@ -166,11 +148,10 @@ def memoryRead(): thread = threading.Thread(target=memoryRead) thread.start() -observer = GridConnectObserver() - # process resulting activity while True: - pumpEvents() + canLink.receiveAll(sock, verbose=settings['trace']) + canLink.sendAll(sock) precise_sleep(.01) -canLink.onDisconnect() \ No newline at end of file +canLink.onDisconnect() diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index b19bf05..65c4d21 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -20,7 +20,6 @@ # endregion same code as other examples from openlcb import precise_sleep # noqa: E402 -from openlcb.canbus.gridconnectobserver import GridConnectObserver # noqa:E402 from openlcb.tcplink.tcpsocket import TcpSocket # noqa: E402 from openlcb.canbus.canphysicallayergridconnect import ( # noqa: E402 @@ -70,28 +69,6 @@ def printMessage(msg): ####################### - -def pumpEvents(): - received = sock.receive() - if received is not None: - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) - canLink.pollState() - - while True: - frame = physicalLayer.pollFrame() - if frame is None: - break - string = frame.encodeAsString() - print(" SR: {}".format(string.strip())) - sock.sendString(string) - physicalLayer.onFrameSent(frame) - # have the socket layer report up to bring the link layer up and get an alias @@ -100,7 +77,8 @@ def pumpEvents(): print(" SL : link up...waiting...") physicalLayer.physicalLayerUp() while canLink.pollState() != CanLink.State.Permitted: - pumpEvents() + canLink.receiveAll(sock, verbose=settings['trace']) + canLink.sendAll(sock, verbose=True) precise_sleep(.02) print(" SL : link up") # send an VerifyNodes message to provoke response @@ -109,11 +87,10 @@ def pumpEvents(): print("SM: {}".format(message)) canLink.sendMessage(message) -observer = GridConnectObserver() - # process resulting activity while True: - pumpEvents() + canLink.receiveAll(sock, verbose=settings['trace']) + canLink.sendAll(sock, verbose=True) precise_sleep(.01) canLink.onDisconnect() diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index 0ce33ba..d171e70 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -145,35 +145,14 @@ def displayOtherNodeIds(message) : ####################### -def pumpEvents(): - received = sock.receive() - if received is not None: - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) - canLink.pollState() - - while True: - frame = physicalLayer.pollFrame() - if frame is None: - break - string = frame.encodeAsString() - print(" SR: {}".format(string.strip())) - sock.sendString(string) - physicalLayer.onFrameSent(frame) - - # have the socket layer report up to bring the link layer up and get an alias print(" SL : link up...") physicalLayer.physicalLayerUp() print(" SL : link up...waiting...") while canLink.pollState() != CanLink.State.Permitted: - pumpEvents() + canLink.receiveAll(sock, verbose=settings['trace']) + canLink.sendAll(sock, verbose=True) precise_sleep(.02) print(" SL : link up") # request that nodes identify themselves so that we can print their node IDs @@ -185,7 +164,8 @@ def pumpEvents(): # process resulting activity while True: - pumpEvents() + canLink.receiveAll(sock, verbose=settings['trace']) + canLink.sendAll(sock, verbose=True) precise_sleep(.01) canLink.onDisconnect() diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index 7396b7b..538c419 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -111,31 +111,6 @@ def printMessage(msg): assert len(_frameReceivedListeners) == 1, \ "{} listener(s) unexpectedly".format(len(_frameReceivedListeners)) - -def pumpEvents(): - received = sock.receive() - if received is not None: - # may be None if socket.setblocking(False) mode - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print("+ RECEIVED Remote: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) - canLink.pollState() - - while True: - frame = physicalLayer.pollFrame() - if frame is None: - break - string = frame.encodeAsString() - if True: # if settings['trace']: - print("- SENT Remote: "+string.strip()) - sock.sendString(string) - physicalLayer.onFrameSent(frame) - - # bring the CAN level up print("* QUEUE Message: link up...") @@ -147,30 +122,13 @@ def pumpEvents(): previousState = canLink.getState() print("[main] CanLink previousState={}".format(previousState)) while True: - # This is a pollState loop as our custom pumpEvents function - # calls pollState. + # Wait for ready (See also waitForReady) state = canLink.getState() - if state != previousState: - print("[main] CanLink state changed from {} to {}" - .format(previousState, state)) - previousState = state if state == CanLink.State.Permitted: break - pumpEvents() - state = canLink.getState() - if state != previousState: - print("[main] CanLink state changed from {} to {}" - .format(previousState, state)) - previousState = state - - precise_sleep(.02) - if default_timer() - cidSequenceStart > 2: # measured in seconds - print("[main] Warning, no response received, assuming no remote nodes" - " continuing alias reservation" - " (ok to do so after 200ms" - " according to CAN Frame Transfer - Standard)") - break - state = canLink.getState() + canLink.receiveAll(sock, verbose=settings['trace']) + canLink.sendAll(sock, verbose=True) + if state != previousState: print("[main] CanLink state changed from {} to {}" @@ -187,7 +145,7 @@ def pumpEvents(): def receiveLoop(): """put the read on a separate thread""" while True: - pumpEvents() + canLink.receiveAll(sock, verbose=settings['trace']) precise_sleep(.01) diff --git a/examples/example_string_interface.py b/examples/example_string_interface.py index cbd9f04..16eb09f 100644 --- a/examples/example_string_interface.py +++ b/examples/example_string_interface.py @@ -44,6 +44,8 @@ # display response - should be RID from node(s) while True: # have to kill this manually + # Normally the receive call and case can be replaced by + # canLink.receiveAll, but we have no canLink in this example. received = sock.receive() if received is not None: observer.push(received) diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index bfa6197..b357f0e 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -19,7 +19,6 @@ ''' from enum import Enum - from logging import getLogger from timeit import default_timer from typing import ( @@ -28,16 +27,20 @@ # Union, # in case `|` doesn't support 'type' in this Python version ) -from openlcb import emit_cast, formatted_ex +from openlcb import ( + precise_sleep, + emit_cast, + formatted_ex, +) from openlcb.canbus.canframe import CanFrame from openlcb.canbus.canphysicallayer import CanPhysicalLayer from openlcb.canbus.controlframe import ControlFrame - from openlcb.linklayer import LinkLayer from openlcb.message import Message from openlcb.mti import MTI from openlcb.nodeid import NodeID from openlcb.physicallayer import PhysicalLayer +from openlcb.portinterface import PortInterface logger = getLogger(__name__) @@ -1150,6 +1153,154 @@ def canHeaderToFullFormat(self, frame: CanFrame) -> MTI: .format(frame)) return MTI.Unknown + def receiveAll(self, device: PortInterface, verbose=False): + """Receive all data on the given device. + Args: + device (PortInterface): Device which *must* be in + non-blocking mode (otherwise necessary two-way + communication such as alias reservation cannot occur). + verbose (bool, optional): If True, print each full packet. + """ + try: + data = device.receive() # If timeout, set non-blocking + if data is None: + return + self.physicalLayer.handleData(data, verbose=verbose) + except BlockingIOError: + # raised by receive if no data (non-blocking is + # what we want, so fall through). + pass + + def sendAll(self, device: PortInterface, mode="binary", verbose=False): + """Send all queued frames using the given device. + + Args: + device (PortInterface): A Serial or Socket device + implementation of PortInterface so as to provide a send + method (Since usually Socket has send & sendString but + Serial has write). + mode (str, optional): "binary" (use device.send) or "text" + (use device.sendString). Defaults to "binary". + verbose (bool, optional): Print the sent packet (not + recommended for numerous memory reads such as CDI/FDI). + Defaults to False. + + Returns: + int: The count of frames sent. If 0, None were queued by + sendFrameAfter (or internal python-openlcb methods which + call it) since the queue was created or since the last + time all frames were polled. + """ + self.pollState() # Advance delayed state(s) if necessary + # (done first since may enqueue frames). + return self.physicalLayer.sendAll(device, mode=mode, verbose=verbose) + + def waitForReady(self, device: PortInterface, mode="binary", + run_physical_link_up_test=False, verbose=True): + """Send and receive frames until. + Other thread(s) *must not* use the device during this (that + would cause "undefined behavior" at OS level). + + Args: + device (PortInterface): *Must* be in non-blocking + mode (or send & receive dialog will fail and time out): + A Serial or Socket wrapped in the PortInterface to + provide send and sendString (since Serial itself has + write not send). + mode (str, optional): "binary" to use device.send, or "text" + to attempt device.sendString. + run_physical_link_up_test (bool, optional): Set to True only + if the last command that ran was "physicalLayerUp". + verbose (bool, optional): If True, print status to + console. Defaults to False. + Raises: + AssertionError: run_physical_link_up_test is True + but the state is not initially WaitingForSendCIDSequence + or successive states were not triggered by pollState + and onFrameSent. + """ + assert device is not None + prefix = "[{}] ".format(type(self).__name__) # show subclass on print + first = True + state = self.pollState() + if verbose: + print(prefix+"waitForReady...state={}..." + .format(state)) + first_state = state + if run_physical_link_up_test: + assert state == CanLink.State.WaitingForSendCIDSequence + debug_count = 0 + second_state = None + while True: + # NOTE: Must call handleData each read regardless of pollState(). + debug_count += 1 + # if verbose: + # print("{}. state: {}".format(debug_count, state)) + if state == CanLink.State.Permitted: + # This could be the while condition, but + # is here so we can monitor it for testing. + break + if first is True: + first = False + if verbose and debug_count < 3: + print(" * sendAll") + self.sendAll(device, mode=mode, verbose=verbose) + if verbose and debug_count < 3: + print(" * state: {}".format(state)) + state = self.getState() + if first_state == CanLink.State.WaitingForSendCIDSequence: + # State should be set by onFrameSent (called by + # sendAll, or in non-simulation cases, the socket loop + # after dequeued and sent, as the next state is ) + if second_state is None: + assert state == CanLink.State.WaitForAliases, \ + ("expected onFrameSent (if properly set to" + " handleFrameSent or overridden for simulation) sent" + " frame's EnqueueAliasAllocationRequest state (CID" + " 4's afterSendState), but state is {}" + .format(state)) + second_state = state + # If sendAll blocks for at least 200ms after send + # then receives, responses may have already been sent + # to handleFrameReceived, in which case we may be in a + # later state. That isn't recommended except for + # realtime applications (or testing). However, if that + # is programmed, add + # `or state == CanLink.State.EnqueueAliasAllocationRequest` + # to the assertion. + if state == CanLink.State.WaitForAliases: + if verbose and debug_count < 3: + print(" * sendAll") + state = self.pollState() # set _waitingForAliasStart if None + # (prevent getWaitForAliasResponseStart() None in assert below) + if device is not None: + try: + data = device.receive() # If timeout, set non-blocking + self.physicalLayer.handleData(data) + except BlockingIOError: + # raised by receive if no data (non-blocking is + # what we want, so fall through). + pass + state = self.pollState() + if state == CanLink.State.Permitted: + if verbose: + print(" * state: {}".format(state)) + break + # if verbose: + # print(" * state: {}".format(state)) + assert self.getWaitForAliasResponseStart() is not None, \ + "openlcb didn't send 7,6,5,4 CIDs (state={})".format(state) + if ((default_timer() - self.getWaitForAliasResponseStart()) + > CanLink.ALIAS_RESPONSE_DELAY): + # 200ms = standard wait time for responses + pass # no collisions (fail collision test if doing that) + precise_sleep(.02) # must be *less than* 200ms (.2) to process + # collisions (via handleData) if any during + # CanLink.State.WaitForAliases. + state = self.pollState() + if verbose: + print(prefix+"waitForReady...done") + class AccumKey: '''Class that holds the ID for accumulating a multi-part message: diff --git a/openlcb/canbus/canlinklayersimulation.py b/openlcb/canbus/canlinklayersimulation.py index f2fe0cb..7be1cb6 100644 --- a/openlcb/canbus/canlinklayersimulation.py +++ b/openlcb/canbus/canlinklayersimulation.py @@ -1,8 +1,5 @@ -from timeit import default_timer - from logging import getLogger -from openlcb import precise_sleep from openlcb.canbus.canlink import CanLink @@ -10,25 +7,18 @@ class CanLinkLayerSimulation(CanLink): - # pumpEvents and waitForReady are based on examples - # and may be moved to CanLink or OpenLCBNetwork - # to make the Python module easier to use. - def pumpEvents(self): - # try: - # received = sock.receive() - # if received is not None: - # if settings['trace']: - # observer.push(received) - # if observer.hasNext(): - # packet_str = observer.next() - # # print(" RR: "+packet_str.strip()) - # # ^ commented since MyHandler shows parsed XML - # # fields instead - # # pass to link processor - # physicalLayer.handleData(received) - # except BlockingIOError: - # pass + def sendAll(self, _, mode="binary", verbose=True): + """Simulated sendAll + The simulation has no real communication, so no device argument + is necessary. See CanLink for a real implementation. + + Args: + verbose (bool, optional): If True, print the packet (not + recommended in the case of numerous sequential memory + read requests such as when reading CDI/FDI). + """ + self.pollState() # run first since may enqueue frame(s) while True: # self.physicalLayer must be set by canLink constructor by @@ -37,92 +27,7 @@ def pumpEvents(self): if not frame: break string = frame.encodeAsString() - print(" SENT (simulated socket) packet: "+string.strip()) - # ^ This is too verbose for this example (each is a - # request to read a 64 byte chunks of the CDI XML) - # sock.sendString(string) + # device.sendString(string) # commented since simulation + if verbose: + print(" SENT (simulated socket) packet: "+string.strip()) self.physicalLayer.onFrameSent(frame) - - def waitForReady(self, run_physical_link_up_test=False): - """ - Args: - run_physical_link_up_test (bool): Set to True only - if the last command that ran was - "physicalLayerUp". - Raises: - AssertionError: run_physical_link_up_test is True - but the state is not initially WaitingForSendCIDSequence - or successive states were not triggered by pollState - and onFrameSent. - """ - self = self - first = True - state = self.pollState() - print("[CanLinkLayerSimulation] waitForReady...state={}..." - .format(state)) - first_state = state - if run_physical_link_up_test: - assert state == CanLink.State.WaitingForSendCIDSequence - debug_count = 0 - second_state = None - while True: - # NOTE: Must call handleData each read regardless of pollState(). - debug_count += 1 - # print("{}. state: {}".format(debug_count, state)) - if state == CanLink.State.Permitted: - # This could be the while condition, but - # is here so we can monitor it for testing. - break - if first is True: - first = False - if debug_count < 3: - print(" * pumpEvents") - self.pumpEvents() # pass received data to physicalLayer&send queue - if debug_count < 3: - print(" * state: {}".format(state)) - state = self.getState() - if first_state == CanLink.State.WaitingForSendCIDSequence: - # State should be set by onFrameSent (called by - # pumpEvents, or in non-simulation cases, the socket loop - # after dequeued and sent, as the next state is ) - if second_state is None: - assert state == CanLink.State.WaitForAliases, \ - ("expected onFrameSent (if properly set to" - " handleFrameSent or overridden for simulation) sent" - " frame's EnqueueAliasAllocationRequest state (CID" - " 4's afterSendState), but state is {}" - .format(state)) - second_state = state - # If pumpEvents blocks for at least 200ms after send - # then receives, responses may have already been sent - # to handleFrameReceived, in which case we may be in a - # later state. That isn't recommended except for - # realtime applications (or testing). However, if that - # is programmed, add - # `or state == CanLink.State.EnqueueAliasAllocationRequest` - # to the assertion. - if state == CanLink.State.WaitForAliases: - if debug_count < 3: - print(" * pumpEvents") - self.pumpEvents() # proceed to send: set _waitingForAliasStart - # (prevent getWaitForAliasResponseStart() None in assert below) - state = self.pollState() - if state == CanLink.State.Permitted: - print(" * state: {}".format(state)) - break - st = self.getState() - # print(" * state: {}".format(state)) - assert self.getWaitForAliasResponseStart() is not None, \ - "openlcb didn't send 7,6,5,4 CID frames (state={})".format(st) - if ((default_timer() - self.getWaitForAliasResponseStart()) - > CanLink.ALIAS_RESPONSE_DELAY): - # 200ms = standard wait time for responses - if not self.require_remote_nodes: - raise TimeoutError( - "In Standard require_remote_nodes=False mode," - " but failed to proceed to Permitted state.") - precise_sleep(.02) # must be *less than* 200ms (.2) to process - # collisions (via handleData) if any during - # CanLink.State.WaitForAliases. - state = self.pollState() - print("[CanLinkLayerSimulation] waitForReady...done") diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index c54fada..5edeed9 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -83,7 +83,7 @@ def fireFrameReceived(self, frame: CanFrame): # the openlcb network stack (This Python module) to # operate--See # - self.onFrameReceived(frame) + self.onFrameReceived(frame) # canLink.handleFrameReceived reference for listener in self._frameReceivedListeners: listener(frame) diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index 83a00ac..6258488 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -15,6 +15,7 @@ from openlcb.canbus.canphysicallayer import CanPhysicalLayer from openlcb.canbus.canframe import CanFrame from openlcb.frameencoder import FrameEncoder +from openlcb.portinterface import PortInterface GC_START_BYTE = 0x3a # : GC_END_BYTE = 0x3b # ; @@ -66,6 +67,49 @@ def encodeFrameAsData(self, frame: CanFrame) -> Union[bytearray, bytes]: # bytes/bytearray has no attribute 'format') return self.encodeFrameAsString(frame).encode("utf-8") + def sendAll(self, device: PortInterface, mode="binary", verbose=False): + """Send all queued frames using the given socket. + Use your CanLink instance's sendAll instead for normal use + (high-level features). + + Args: + device (PortInterface): A Serial or Socket device + implementation of PortInterface so as to provide a send + method (Since usually Socket has send & sendString but + Serial has write). + mode (str, optional): "binary" (use device.send) or "text" + (use device.sendString). Defaults to "binary". + verbose (bool, optional): Print each packet sent (not + recommend for numerous read requests such as CDI/FDI). + + Returns: + int: The count of frames sent. If 0, None were queued by + sendFrameAfter (or internal python-openlcb methods which + call it) since the queue was created or since the last + time all frames were polled. + """ + assert mode in ("binary", "text") + count = 0 + try: + while True: + frame: CanFrame = self._send_frames.popleft() + # ^ exits loop with IndexError when done. + if mode == "binary": + data = self.encodeFrameAsData(frame) + device.send(data) + else: + data = self.encodeFrameAsString(frame) + device.sendString(data) + self.onFrameSent(frame) # Calls setState if necessary + # (if frame.afterSendState is not None). + if verbose: + print("SENT: {}".format(data)) + count += 1 + except IndexError: + # nothing more to do (queue is empty) + pass + return count + def handleDataString(self, string: str): '''Provide string from the outside link to be parsed @@ -75,14 +119,16 @@ def handleDataString(self, string: str): # formerly pushString formerly receiveString self.handleData(string.encode("utf-8")) - def handleData(self, data: Union[bytes, bytearray]): + def handleData(self, data: Union[bytes, bytearray], verbose=False): """Provide characters from the outside link to be parsed Args: data (Union[bytes,bytearray]): new data from outside link + verbose (bool, optional): If True, print each frame + detected. """ self.inboundBuffer += data - processedCount = 0 + lastByte = 0 # last index is at ';' if GC_END_BYTE in self.inboundBuffer: # ^ ';' ends message so we have at least one (CR/LF not required) # found end, now find start of that same message, earlier in buffer @@ -99,7 +145,7 @@ def handleData(self, data: Union[bytes, bytearray]): header = (header << 4)+nextByte # offset 10 is N # offset 11 might be data, might be ; - processedCount = index+11 + lastByte = index+11 for dataItem in range(0, 8): if self.inboundBuffer[index+11+2*dataItem] == GC_END_BYTE: # noqa: E501 break @@ -117,11 +163,14 @@ def handleData(self, data: Union[bytes, bytearray]): # "Got {} for high nibble (part1 << 4 == {})." # .format(part1, high_nibble)) outData += bytearray([high_nibble | part2]) - processedCount += 2 + lastByte += 2 # lastByte is index of ; in this message cf = CanFrame(header, outData) self.fireFrameReceived(cf) + if verbose: + print("- RECV {}".format( + self.inboundBuffer[index:lastByte+1].strip())) # shorten buffer by removing the processed message - self.inboundBuffer = self.inboundBuffer[processedCount:] + self.inboundBuffer = self.inboundBuffer[lastByte:] diff --git a/openlcb/canbus/canphysicallayersimulation.py b/openlcb/canbus/canphysicallayersimulation.py index 1597850..eb3e687 100644 --- a/openlcb/canbus/canphysicallayersimulation.py +++ b/openlcb/canbus/canphysicallayersimulation.py @@ -10,14 +10,23 @@ class CanPhysicalLayerSimulation(CanPhysicalLayer, FrameEncoder): + """Simulation CanPhysicalLayer and FrameEncoder implementation + Attributes: + received_chunks (list[bytearray]): Reserved for future use. + """ def __init__(self): self.receivedFrames: List[CanFrame] = [] CanPhysicalLayer.__init__(self) self.onQueuedFrame = self._onQueuedFrame + self.received_chunks = [] def _onQueuedFrame(self, frame: CanFrame): raise AttributeError("Not implemented for simulation") + def handleData(self, data: bytearray, verbose=False): + # Do not parse, since simulation. Just collect for later analysis + self.received_chunks.append(data) + def captureFrame(self, frame: CanFrame): self.receivedFrames.append(frame) diff --git a/openlcb/openlcbnetwork.py b/openlcb/openlcbnetwork.py index 821abf0..e331f59 100644 --- a/openlcb/openlcbnetwork.py +++ b/openlcb/openlcbnetwork.py @@ -22,7 +22,6 @@ from logging import getLogger import xml.sax.handler -from xml.sax.xmlreader import AttributesImpl # from xml.sax.xmlreader import AttributesImpl # for autocomplete only from openlcb import formatted_ex, precise_sleep diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index f14b505..9f445fc 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -25,6 +25,8 @@ from logging import getLogger from typing import Union +from openlcb.portinterface import PortInterface + logger = getLogger(__name__) @@ -71,6 +73,24 @@ def hasFrame(self) -> bool: """Check if there is a frame queued to send.""" return bool(self._send_frames) + def sendAll(self, device: PortInterface, mode="binary", verbose=False): + """Abstract method for sending queued frames""" + raise NotImplementedError( + "This must be implemented in a subclass" + " which implements FrameEncoder" + " (any real physical layer has an encoding).") + count = 0 + try: + while True: + data = self._send_chunks.popleft() + # ^ exits loop with IndexError when done. + device.send(data) + count += 1 + except IndexError: + # nothing more to do (queue is empty) + pass + return count + def pollFrame(self): """Check if there is another frame queued and get it. Subclass should call PhysicalLayer.pollFrame (or diff --git a/tests/test_canlink.py b/tests/test_canlink.py index f7ac09b..ae0f454 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -13,6 +13,7 @@ from openlcb.mti import MTI from openlcb.nodeid import NodeID from openlcb.canbus.controlframe import ControlFrame +from openlcb.portinterface import PortInterface class PhyMockLayer(CanPhysicalLayer): @@ -37,6 +38,17 @@ def receiveMessage(self, msg): self.receivedMessages.append(msg) +class MockPort(PortInterface): + def send(self, data): + pass + + def sendString(self, data): + pass + + def receive(self): + return None + + def getLocalNodeIDStr(): return "05.01.01.01.03.01" @@ -46,6 +58,9 @@ def getLocalNodeID(): class TestCanLinkClass(unittest.TestCase): + def __init__(self, *args, **kwargs): + self.device = MockPort() + super(TestCanLinkClass, self).__init__(*args, **kwargs) # MARK: - Alias calculations def testIncrementAlias48(self): @@ -107,7 +122,7 @@ def testLinkUpSequence(self): canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() - canLink.waitForReady() + canLink.waitForReady(self.device) self.assertEqual(len(canPhysicalLayer.receivedFrames), 7) self.assertEqual(canLink._state, CanLink.State.Permitted) @@ -213,7 +228,7 @@ def testRIDreceivedMatch(self): canPhysicalLayer.fireFrameReceived( CanFrame(ControlFrame.RID.value, ourAlias)) # ^ collision - canLink.waitForReady() + canLink.waitForReady(self.device) self.assertEqual(len(canPhysicalLayer.receivedFrames), 8) # ^ includes recovery of new alias 4 CID, RID, AMR, AME self.assertEqual(canPhysicalLayer.receivedFrames[0], @@ -365,7 +380,7 @@ def testSimpleAddressedData(self): # Test start=yes, end=yes frame canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() - canLink.waitForReady() + canLink.waitForReady(self.device) # map an alias we'll use amd = CanFrame(0x0701, 0x247) @@ -402,7 +417,7 @@ def testSimpleAddressedDataNoAliasYet(self): canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() - canLink.waitForReady() + canLink.waitForReady(self.device) # don't map alias with AMD @@ -440,7 +455,7 @@ def testMultiFrameAddressedData(self): canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() - canLink.waitForReady() + canLink.waitForReady(self.device) # map an alias we'll use amd = CanFrame(0x0701, 0x247) @@ -483,7 +498,7 @@ def testSimpleDatagram(self): # Test start=yes, end=yes frame canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() - canLink.waitForReady() + canLink.waitForReady(self.device) # map two aliases we'll use amd = CanFrame(0x0701, 0x247) @@ -522,7 +537,7 @@ def testMultiFrameDatagram(self): print("[testMultiFrameDatagram] state={}" .format(canLink.getState())) canPhysicalLayer.physicalLayerUp() - canLink.waitForReady() + canLink.waitForReady(self.device) # map two aliases we'll use amd = CanFrame(0x0701, 0x247) From f9a9be54d2b9b15b11e66402ad210a773e8ebe60 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Wed, 21 May 2025 17:05:05 -0400 Subject: [PATCH 83/99] Fix type-o. --- examples/example_frame_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index a9cd329..c452ee6 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -62,7 +62,7 @@ def pumpEvents(): # pass to link processor physicalLayer.handleData(received) - # Normally the lop below can be replaced by canLink.sendAll(sock), + # Normally the loop below can be replaced by canLink.sendAll(sock), # but in this example we have no link layer. # canLink.pollState() while True: From 087075e53c30e05b1959e7945a8bcf62f331bda3 Mon Sep 17 00:00:00 2001 From: Poikilos <7557867+poikilos@users.noreply.github.com> Date: Thu, 22 May 2025 01:57:45 -0400 Subject: [PATCH 84/99] Model the network layer order consistently (socket <-> [PhysicalLayer <-> other layers] <-> application). --- examples/example_cdi_access.py | 12 +- examples/example_datagram_transfer.py | 8 +- examples/example_frame_interface.py | 43 +----- examples/example_memory_length_query.py | 8 +- examples/example_memory_transfer.py | 8 +- examples/example_message_interface.py | 8 +- examples/example_node_implementation.py | 8 +- examples/example_remote_nodes.py | 6 +- examples/example_string_interface.py | 3 +- openlcb/canbus/canframe.py | 9 ++ openlcb/canbus/canlink.py | 53 +------ openlcb/canbus/canlinklayersimulation.py | 25 +--- openlcb/canbus/canphysicallayer.py | 2 + openlcb/canbus/canphysicallayergridconnect.py | 26 +++- openlcb/canbus/canphysicallayersimulation.py | 21 ++- openlcb/linklayer.py | 1 + tests/test_canlink.py | 139 +++++++++++------- 17 files changed, 180 insertions(+), 200 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index d26567f..98fac18 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -248,12 +248,12 @@ def processXML(content: str) : print(" QUEUED frames : link up...waiting...") while canLink.pollState() != CanLink.State.Permitted: # provides incoming data to physicalLayer & sends queued: - canLink.receiveAll(sock, verbose=True) - canLink.sendAll(sock) + physicalLayer.receiveAll(sock, verbose=True) + physicalLayer.sendAll(sock) if canLink.getState() == CanLink.State.WaitForAliases: - # canLink.receiveAll(sock, verbose=True) - canLink.sendAll(sock) + # physicalLayer.receiveAll(sock, verbose=True) + physicalLayer.sendAll(sock) # ^ prevent assertion error below, proceed to send. if canLink.pollState() == CanLink.State.Permitted: break @@ -300,8 +300,8 @@ def memoryRead(): # receives the data from the requested memory space (CDI in this # case) and offset (incremental position in the file/data, # incremented by this example's memoryReadSuccess handler). - canLink.receiveAll(sock) - canLink.sendAll(sock) + physicalLayer.receiveAll(sock) + physicalLayer.sendAll(sock) if canLink.nodeIdToAlias != previous_nodes: print("nodeIdToAlias updated: {}".format(canLink.nodeIdToAlias)) precise_sleep(.01) diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index d943fe3..3238348 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -104,8 +104,8 @@ def datagramReceiver(memo): print(" SL : link up") while canLink.pollState() != CanLink.State.Permitted: - canLink.receiveAll(sock, verbose=settings['trace']) - canLink.sendAll(sock) + physicalLayer.receiveAll(sock, verbose=settings['trace']) + physicalLayer.sendAll(sock) precise_sleep(.02) @@ -131,7 +131,7 @@ def datagramWrite(): # process resulting activity while True: - canLink.receiveAll(sock, verbose=settings['trace']) - canLink.sendAll(sock) + physicalLayer.receiveAll(sock, verbose=settings['trace']) + physicalLayer.sendAll(sock) canLink.onDisconnect() diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index c452ee6..6a4812a 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -48,40 +48,6 @@ def sendToSocket(frame: CanFrame): physicalLayer.onFrameSent(frame) -def pumpEvents(): - # Normally receive call & case below can be replaced by - # canLink.receiveAll(sock), but in this example we have no link - # layer. - received = sock.receive() - if received is not None: - if settings['trace']: - observer.push(received) - if observer.hasNext(): - packet_str = observer.next() - print(" RR: "+packet_str.strip()) - # pass to link processor - physicalLayer.handleData(received) - - # Normally the loop below can be replaced by canLink.sendAll(sock), - # but in this example we have no link layer. - # canLink.pollState() - while True: - frame = physicalLayer.pollFrame() - if frame is None: - break - string = frame.encodeAsString() - print(" SR: {}".format(string.strip())) - sock.sendString(string) - physicalLayer.onFrameSent(frame) - if frame.afterSendState: - print("Next state (unexpected, no link layer): {}" - .format(frame.afterSendState)) - # canLink.setState(frame.afterSendState) - # ^ setState is done by onFrameSent now - # (physicalLayer.onFrameSent = self.handleFrameSent - # in LinkLayer constructor) - - def handleFrameSent(frame): # No state to manage since no link layer pass @@ -105,16 +71,11 @@ def printFrame(frame): frame = CanFrame(ControlFrame.AME.value, 1, bytearray()) print("SL: {}".format(frame)) physicalLayer.sendFrameAfter(frame) - -while True: - frame = physicalLayer.pollFrame() - if not frame: - break - sendToSocket(frame) +physicalLayer.sendAll(sock, verbose=True) observer = GridConnectObserver() # display response - should be RID from nodes while True: - pumpEvents() + physicalLayer.receiveAll(sock, verbose=True) precise_sleep(.01) diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index 6c04c3f..5d41713 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -126,8 +126,8 @@ def memoryLengthReply(address) : while canLink.pollState() != CanLink.State.Permitted: - canLink.receiveAll(sock, verbose=settings['trace']) - canLink.sendAll(sock, verbose=True) + physicalLayer.receiveAll(sock, verbose=settings['trace']) + physicalLayer.sendAll(sock, verbose=True) precise_sleep(.02) print(" SL : link up") @@ -155,8 +155,8 @@ def memoryRequest(): # process resulting activity while True: - canLink.receiveAll(sock, verbose=settings['trace']) - canLink.sendAll(sock, verbose=True) + physicalLayer.receiveAll(sock, verbose=settings['trace']) + physicalLayer.sendAll(sock, verbose=True) precise_sleep(.01) canLink.onDisconnect() diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index c522b45..12031d1 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -122,8 +122,8 @@ def memoryReadFail(memo): physicalLayer.physicalLayerUp() print(" SL : link up...waiting...") while canLink.pollState() != CanLink.State.Permitted: - canLink.receiveAll(sock, verbose=settings['trace']) - canLink.sendAll(sock) + physicalLayer.receiveAll(sock, verbose=settings['trace']) + physicalLayer.sendAll(sock) precise_sleep(.02) print(" SL : link up") @@ -150,8 +150,8 @@ def memoryRead(): # process resulting activity while True: - canLink.receiveAll(sock, verbose=settings['trace']) - canLink.sendAll(sock) + physicalLayer.receiveAll(sock, verbose=settings['trace']) + physicalLayer.sendAll(sock) precise_sleep(.01) canLink.onDisconnect() diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index 65c4d21..86661d6 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -77,8 +77,8 @@ def printMessage(msg): print(" SL : link up...waiting...") physicalLayer.physicalLayerUp() while canLink.pollState() != CanLink.State.Permitted: - canLink.receiveAll(sock, verbose=settings['trace']) - canLink.sendAll(sock, verbose=True) + physicalLayer.receiveAll(sock, verbose=settings['trace']) + physicalLayer.sendAll(sock, verbose=True) precise_sleep(.02) print(" SL : link up") # send an VerifyNodes message to provoke response @@ -89,8 +89,8 @@ def printMessage(msg): # process resulting activity while True: - canLink.receiveAll(sock, verbose=settings['trace']) - canLink.sendAll(sock, verbose=True) + physicalLayer.receiveAll(sock, verbose=settings['trace']) + physicalLayer.sendAll(sock, verbose=True) precise_sleep(.01) canLink.onDisconnect() diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index d171e70..cf823d7 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -151,8 +151,8 @@ def displayOtherNodeIds(message) : physicalLayer.physicalLayerUp() print(" SL : link up...waiting...") while canLink.pollState() != CanLink.State.Permitted: - canLink.receiveAll(sock, verbose=settings['trace']) - canLink.sendAll(sock, verbose=True) + physicalLayer.receiveAll(sock, verbose=settings['trace']) + physicalLayer.sendAll(sock, verbose=True) precise_sleep(.02) print(" SL : link up") # request that nodes identify themselves so that we can print their node IDs @@ -164,8 +164,8 @@ def displayOtherNodeIds(message) : # process resulting activity while True: - canLink.receiveAll(sock, verbose=settings['trace']) - canLink.sendAll(sock, verbose=True) + physicalLayer.receiveAll(sock, verbose=settings['trace']) + physicalLayer.sendAll(sock, verbose=True) precise_sleep(.01) canLink.onDisconnect() diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index 538c419..9162c61 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -126,8 +126,8 @@ def printMessage(msg): state = canLink.getState() if state == CanLink.State.Permitted: break - canLink.receiveAll(sock, verbose=settings['trace']) - canLink.sendAll(sock, verbose=True) + physicalLayer.receiveAll(sock, verbose=settings['trace']) + physicalLayer.sendAll(sock, verbose=True) if state != previousState: @@ -145,7 +145,7 @@ def printMessage(msg): def receiveLoop(): """put the read on a separate thread""" while True: - canLink.receiveAll(sock, verbose=settings['trace']) + physicalLayer.receiveAll(sock, verbose=settings['trace']) precise_sleep(.01) diff --git a/examples/example_string_interface.py b/examples/example_string_interface.py index 16eb09f..681a4e8 100644 --- a/examples/example_string_interface.py +++ b/examples/example_string_interface.py @@ -45,7 +45,8 @@ # display response - should be RID from node(s) while True: # have to kill this manually # Normally the receive call and case can be replaced by - # canLink.receiveAll, but we have no canLink in this example. + # physicalLayer.receiveAll, but we have no physicalLayer in this + # example. received = sock.receive() if received is not None: observer.push(received) diff --git a/openlcb/canbus/canframe.py b/openlcb/canbus/canframe.py index 3b03a6f..1562259 100644 --- a/openlcb/canbus/canframe.py +++ b/openlcb/canbus/canframe.py @@ -198,3 +198,12 @@ def __eq__(self, other): if self.data != other.data: return False return True + + def difference(self, other): + if other is None: + return "other is None" + if self.header != other.header: + return "header {} != {}".format(self.header, other.header) + if self.data != other.data: + return "data {} != {}".format(self.data, other.data) + return None \ No newline at end of file diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index b357f0e..b22df05 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -1153,53 +1153,12 @@ def canHeaderToFullFormat(self, frame: CanFrame) -> MTI: .format(frame)) return MTI.Unknown - def receiveAll(self, device: PortInterface, verbose=False): - """Receive all data on the given device. - Args: - device (PortInterface): Device which *must* be in - non-blocking mode (otherwise necessary two-way - communication such as alias reservation cannot occur). - verbose (bool, optional): If True, print each full packet. - """ - try: - data = device.receive() # If timeout, set non-blocking - if data is None: - return - self.physicalLayer.handleData(data, verbose=verbose) - except BlockingIOError: - # raised by receive if no data (non-blocking is - # what we want, so fall through). - pass - - def sendAll(self, device: PortInterface, mode="binary", verbose=False): - """Send all queued frames using the given device. - - Args: - device (PortInterface): A Serial or Socket device - implementation of PortInterface so as to provide a send - method (Since usually Socket has send & sendString but - Serial has write). - mode (str, optional): "binary" (use device.send) or "text" - (use device.sendString). Defaults to "binary". - verbose (bool, optional): Print the sent packet (not - recommended for numerous memory reads such as CDI/FDI). - Defaults to False. - - Returns: - int: The count of frames sent. If 0, None were queued by - sendFrameAfter (or internal python-openlcb methods which - call it) since the queue was created or since the last - time all frames were polled. - """ - self.pollState() # Advance delayed state(s) if necessary - # (done first since may enqueue frames). - return self.physicalLayer.sendAll(device, mode=mode, verbose=verbose) - def waitForReady(self, device: PortInterface, mode="binary", run_physical_link_up_test=False, verbose=True): """Send and receive frames until. - Other thread(s) *must not* use the device during this (that - would cause "undefined behavior" at OS level). + Other thread(s) *must not* use the device during this + (overlapping read or write would cause "undefined behavior" at + OS level). Args: device (PortInterface): *Must* be in non-blocking @@ -1224,8 +1183,7 @@ def waitForReady(self, device: PortInterface, mode="binary", first = True state = self.pollState() if verbose: - print(prefix+"waitForReady...state={}..." - .format(state)) + print(prefix+"waitForReady...state={}...".format(state)) first_state = state if run_physical_link_up_test: assert state == CanLink.State.WaitingForSendCIDSequence @@ -1244,7 +1202,7 @@ def waitForReady(self, device: PortInterface, mode="binary", first = False if verbose and debug_count < 3: print(" * sendAll") - self.sendAll(device, mode=mode, verbose=verbose) + self.physicalLayer.sendAll(device, mode=mode, verbose=verbose) if verbose and debug_count < 3: print(" * state: {}".format(state)) state = self.getState() @@ -1274,6 +1232,7 @@ def waitForReady(self, device: PortInterface, mode="binary", state = self.pollState() # set _waitingForAliasStart if None # (prevent getWaitForAliasResponseStart() None in assert below) if device is not None: + # self.physicalLayer.receiveAll(device) try: data = device.receive() # If timeout, set non-blocking self.physicalLayer.handleData(data) diff --git a/openlcb/canbus/canlinklayersimulation.py b/openlcb/canbus/canlinklayersimulation.py index 7be1cb6..7a673ca 100644 --- a/openlcb/canbus/canlinklayersimulation.py +++ b/openlcb/canbus/canlinklayersimulation.py @@ -7,27 +7,4 @@ class CanLinkLayerSimulation(CanLink): - - def sendAll(self, _, mode="binary", verbose=True): - """Simulated sendAll - The simulation has no real communication, so no device argument - is necessary. See CanLink for a real implementation. - - Args: - verbose (bool, optional): If True, print the packet (not - recommended in the case of numerous sequential memory - read requests such as when reading CDI/FDI). - """ - - self.pollState() # run first since may enqueue frame(s) - while True: - # self.physicalLayer must be set by canLink constructor by - # passing a physicalLayer to it. - frame = self.physicalLayer.pollFrame() - if not frame: - break - string = frame.encodeAsString() - # device.sendString(string) # commented since simulation - if verbose: - print(" SENT (simulated socket) packet: "+string.strip()) - self.physicalLayer.onFrameSent(frame) + pass \ No newline at end of file diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index 5edeed9..756bfa7 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -10,6 +10,7 @@ from openlcb.canbus.canframe import CanFrame from openlcb.canbus.controlframe import ControlFrame +from openlcb.linklayer import LinkLayer from openlcb.physicallayer import PhysicalLayer logger = getLogger(__name__) @@ -23,6 +24,7 @@ class CanPhysicalLayer(PhysicalLayer): def __init__(self,): PhysicalLayer.__init__(self) + self.linkLayer: LinkLayer = None # CanLink would be circular import self._frameReceivedListeners: list[Callable[[CanFrame], None]] = [] def sendFrameAfter(self, frame: CanFrame): diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index 6258488..c266112 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -67,10 +67,26 @@ def encodeFrameAsData(self, frame: CanFrame) -> Union[bytearray, bytes]: # bytes/bytearray has no attribute 'format') return self.encodeFrameAsString(frame).encode("utf-8") + def receiveAll(self, device: PortInterface, verbose=False): + """Receive all data on the given device. + Args: + device (PortInterface): Device which *must* be in + non-blocking mode (otherwise necessary two-way + communication such as alias reservation cannot occur). + verbose (bool, optional): If True, print each full packet. + """ + try: + data = device.receive() # If timeout, set non-blocking + if data is None: + return + self.handleData(data, verbose=verbose) + except BlockingIOError: + # raised by receive if no data (non-blocking is + # what we want, so fall through). + pass + def sendAll(self, device: PortInterface, mode="binary", verbose=False): - """Send all queued frames using the given socket. - Use your CanLink instance's sendAll instead for normal use - (high-level features). + """Send all queued frames using the given device. Args: device (PortInterface): A Serial or Socket device @@ -81,6 +97,7 @@ def sendAll(self, device: PortInterface, mode="binary", verbose=False): (use device.sendString). Defaults to "binary". verbose (bool, optional): Print each packet sent (not recommend for numerous read requests such as CDI/FDI). + Defaults to False. Returns: int: The count of frames sent. If 0, None were queued by @@ -89,6 +106,9 @@ def sendAll(self, device: PortInterface, mode="binary", verbose=False): time all frames were polled. """ assert mode in ("binary", "text") + if self.linkLayer: + self.linkLayer.pollState() # Advance delayed state(s) if necessary + # (done first since may enqueue frames). count = 0 try: while True: diff --git a/openlcb/canbus/canphysicallayersimulation.py b/openlcb/canbus/canphysicallayersimulation.py index eb3e687..f5a6a9c 100644 --- a/openlcb/canbus/canphysicallayersimulation.py +++ b/openlcb/canbus/canphysicallayersimulation.py @@ -15,7 +15,9 @@ class CanPhysicalLayerSimulation(CanPhysicalLayer, FrameEncoder): received_chunks (list[bytearray]): Reserved for future use. """ def __init__(self): - self.receivedFrames: List[CanFrame] = [] + self.sentFrames: List[CanFrame] = [] + # ^ formerly receivedFrames but was appended in self.sendCanFrame! + CanPhysicalLayer.__init__(self) self.onQueuedFrame = self._onQueuedFrame self.received_chunks = [] @@ -27,9 +29,6 @@ def handleData(self, data: bytearray, verbose=False): # Do not parse, since simulation. Just collect for later analysis self.received_chunks.append(data) - def captureFrame(self, frame: CanFrame): - self.receivedFrames.append(frame) - def encodeFrameAsString(self, frame: CanFrame): return "(no encoding, only simulating CanPhysicalLayer superclass)" @@ -38,10 +37,22 @@ def encodeFrameAsData(self, frame: CanFrame): def sendFrameAfter(self, frame: CanFrame): frame.encoder = self - self.captureFrame(frame) # NOTE: Can't actually do afterSendState here, because # _enqueueCIDSequence sets state to # CanLink.State.WaitingForSendCIDSequence # *after* calling this (so we must use afterSendState # later!) self._send_frames.append(frame) + + def sendAll(self, device, mode="binary", verbose=False): + try: + while True: + frame = self._send_frames.popleft() + # ^ exits loop with IndexError when done. + # data = self.encodeFrameAsData(frame) + # device.send(data) # commented since simulation + self.onFrameSent(frame) + self.sentFrames.append(frame) + except IndexError: + # no more frames (no problem) + pass diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index 6906916..2871ac6 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -60,6 +60,7 @@ def __init__(self, physicalLayer: PhysicalLayer, localNodeID): # operator not work as expected in registerFrameReceivedListener. physicalLayer.onFrameReceived = self.handleFrameReceived physicalLayer.onFrameSent = self.handleFrameSent + physicalLayer.linkLayer = self # # ^ enforce queue paradigm (See use in PhysicalLayer subclass) # physicalLayer.registerFrameReceivedListener(listener) # ^ Doesn't work with "is" operator still! So just use diff --git a/tests/test_canlink.py b/tests/test_canlink.py index ae0f454..8683fc4 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -29,6 +29,31 @@ def sendDataAfter(self, data): self.receivedFrames.append(data) + def sendAll(self, _, mode="binary", verbose=True): + """Simulated sendAll + The simulation has no real communication, so no device argument + is necessary. See CanLink for a real implementation. + + Args: + verbose (bool, optional): If True, print the packet (not + recommended in the case of numerous sequential memory + read requests such as when reading CDI/FDI). + """ + if self.canLink: + self.canLink.pollState() # run first since may enqueue frame(s) + while True: + # self.physicalLayer must be set by canLink constructor by + # passing a physicalLayer to it. + frame = self.physicalLayer.pollFrame() + if not frame: + break + string = frame.encodeAsString() + # device.sendString(string) # commented since simulation + if verbose: + print(" SENT (simulated socket) packet: "+string.strip()) + self.physicalLayer.onFrameSent(frame) + + class MessageMockLayer: '''Mock Message to record messages requested to be sent''' def __init__(self): @@ -62,6 +87,9 @@ def __init__(self, *args, **kwargs): self.device = MockPort() super(TestCanLinkClass, self).__init__(*args, **kwargs) + def assertFrameEqual(self, frame: CanFrame, other: CanFrame): + self.assertEqual(frame, other, msg=frame.difference(other)) + # MARK: - Alias calculations def testIncrementAlias48(self): canLink = CanLinkLayerSimulation(PhyMockLayer(), getLocalNodeID()) @@ -124,7 +152,7 @@ def testLinkUpSequence(self): canPhysicalLayer.physicalLayerUp() canLink.waitForReady(self.device) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 7) + self.assertEqual(len(canPhysicalLayer.sentFrames), 7) self.assertEqual(canLink._state, CanLink.State.Permitted) self.assertEqual(len(messageLayer.receivedMessages), 1) @@ -151,7 +179,7 @@ def testEIR2NoData(self): canPhysicalLayer.fireFrameReceived( CanFrame(ControlFrame.EIR2.value, 0)) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.sentFrames), 0) canLink.onDisconnect() # MARK: - Test AME (Local Node) @@ -162,9 +190,11 @@ def testAMENoData(self): canLink._state = CanLink.State.Permitted canPhysicalLayer.fireFrameReceived(CanFrame(ControlFrame.AME.value, 0)) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) - self.assertEqual( - canPhysicalLayer.receivedFrames[0], + canLink.pollState() # add response to queue + canPhysicalLayer.sendAll(None) # add response to sentFrames + self.assertEqual(len(canPhysicalLayer.sentFrames), 1) + self.assertFrameEqual( + canPhysicalLayer.sentFrames[0], CanFrame(ControlFrame.AMD.value, ourAlias, canLink.localNodeID.toArray()) ) @@ -176,7 +206,7 @@ def testAMEnoDataInhibited(self): canLink._state = CanLink.State.Inhibited canPhysicalLayer.fireFrameReceived(CanFrame(ControlFrame.AME.value, 0)) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.sentFrames), 0) canLink.onDisconnect() def testAMEMatchEvent(self): @@ -188,10 +218,13 @@ def testAMEMatchEvent(self): frame = CanFrame(ControlFrame.AME.value, 0) frame.data = bytearray([5, 1, 1, 1, 3, 1]) canPhysicalLayer.fireFrameReceived(frame) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) - self.assertEqual(canPhysicalLayer.receivedFrames[0], - CanFrame(ControlFrame.AMD.value, ourAlias, - canLink.localNodeID.toArray())) + canLink.pollState() # add response to queue + canPhysicalLayer.sendAll(None) # add response to sentFrames + self.assertEqual(len(canPhysicalLayer.sentFrames), 1) + self.assertFrameEqual( + canPhysicalLayer.sentFrames[0], + CanFrame(ControlFrame.AMD.value, ourAlias, + canLink.localNodeID.toArray())) canLink.onDisconnect() def testAMENotMatchEvent(self): @@ -202,7 +235,7 @@ def testAMENotMatchEvent(self): frame = CanFrame(ControlFrame.AME.value, 0) frame.data = bytearray([0, 0, 0, 0, 0, 0]) canPhysicalLayer.fireFrameReceived(frame) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.sentFrames), 0) canLink.onDisconnect() # MARK: - Test Alias Collisions (Local Node) @@ -214,9 +247,11 @@ def testCIDreceivedMatch(self): canPhysicalLayer.fireFrameReceived( CanFrame(7, canLink.localNodeID, ourAlias)) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) - self.assertEqual(canPhysicalLayer.receivedFrames[0], - CanFrame(ControlFrame.RID.value, ourAlias)) + canLink.pollState() # add response to queue + canPhysicalLayer.sendAll(None) # add response to sentFrames + self.assertEqual(len(canPhysicalLayer.sentFrames), 1) + self.assertFrameEqual(canPhysicalLayer.sentFrames[0], + CanFrame(ControlFrame.RID.value, ourAlias)) canLink.onDisconnect() def testRIDreceivedMatch(self): @@ -229,14 +264,16 @@ def testRIDreceivedMatch(self): CanFrame(ControlFrame.RID.value, ourAlias)) # ^ collision canLink.waitForReady(self.device) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 8) + self.assertEqual(len(canPhysicalLayer.sentFrames), 8) # ^ includes recovery of new alias 4 CID, RID, AMR, AME - self.assertEqual(canPhysicalLayer.receivedFrames[0], - CanFrame(ControlFrame.AMR.value, ourAlias, - bytearray([5, 1, 1, 1, 3, 1]))) - self.assertEqual(canPhysicalLayer.receivedFrames[6], - CanFrame(ControlFrame.AMD.value, 0x539, - bytearray([5, 1, 1, 1, 3, 1]))) # new alias + self.assertFrameEqual( + canPhysicalLayer.sentFrames[0], + CanFrame(ControlFrame.AMR.value, ourAlias, + bytearray([5, 1, 1, 1, 3, 1]))) + self.assertEqual( + canPhysicalLayer.sentFrames[6], + CanFrame(ControlFrame.AMD.value, 0x539, + bytearray([5, 1, 1, 1, 3, 1]))) # new alias self.assertEqual(canLink._state, CanLink.State.Permitted) canLink.onDisconnect() @@ -306,7 +343,7 @@ def testSimpleGlobalData(self): canPhysicalLayer.fireFrameReceived(CanFrame(0x19490, 0x247)) # ^ from previously seen alias - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.sentFrames), 0) # ^ nothing back down to CAN self.assertEqual(len(messageLayer.receivedMessages), 1) # ^ one message forwarded @@ -333,7 +370,7 @@ def testVerifiedNodeInDestAliasMap(self): CanFrame(0x19170, 0x247, bytearray([8, 7, 6, 5, 4, 3]))) # ^ VerifiedNodeID from unique alias - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.sentFrames), 0) # ^ nothing back down to CAN self.assertEqual(len(messageLayer.receivedMessages), 1) # ^ one message forwarded @@ -361,7 +398,7 @@ def testNoDestInAliasMap(self): CanFrame(0x19968, 0x247, bytearray([8, 7, 6, 5, 4, 3]))) # ^ Identify Events Addressed from unique alias - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.sentFrames), 0) # ^ nothing back down to CAN self.assertEqual(len(messageLayer.receivedMessages), 1) # ^ one message forwarded @@ -681,7 +718,7 @@ def testAmdAmrSequence(self): self.assertEqual(len(canLink.aliasToNodeID), 1) self.assertEqual(len(canLink.nodeIdToAlias), 1) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.sentFrames), 0) # ^ nothing back down to CAN canPhysicalLayer.fireFrameReceived(CanFrame(0x0703, ourAlias+1)) @@ -690,7 +727,7 @@ def testAmdAmrSequence(self): self.assertEqual(len(canLink.aliasToNodeID), 0) self.assertEqual(len(canLink.nodeIdToAlias), 0) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.sentFrames), 0) # ^ nothing back down to CAN canLink.onDisconnect() @@ -800,28 +837,30 @@ def testEnum(self): if __name__ == '__main__': - # unittest.main() + unittest.main() # For debugging a test that was hanging: - testCase = TestCanLinkClass() - count = 0 - failedCount = 0 - exceptions = [] - errors = [] - for name in dir(testCase): - if name.startswith("test"): - fn = getattr(testCase, name) - try: - fn() # Look at def test_* below if tracebacks start here - count += 1 - except AssertionError as ex: - # raise ex - error = name + ": " + formatted_ex(ex) - # print(error) - failedCount += 1 - exceptions.append(ex) - errors.append(error) - # for ex in exceptions: - # print(formatted_ex(ex)) - for error in errors: - print(error) - print("{} test(s) passed.".format(count)) + # testCase = TestCanLinkClass() + # count = 0 + # failedCount = 0 + # exceptions = [] + # errors = [] + # for name in dir(testCase): + # if name.startswith("test"): + # fn = getattr(testCase, name) + # try: + # fn() # Look at def test_* below if tracebacks start here + # count += 1 + # except AssertionError as ex: + # # raise ex + # error = name + ": " + formatted_ex(ex) + # # print(error) + # failedCount += 1 + # exceptions.append(ex) + # errors.append(error) + # # for ex in exceptions: + # # print(formatted_ex(ex)) + # for error in errors: + # print(error) + # print("{} test(s) passed.".format(count)) + # if errors: + # print("{} test(s) failed.".format(len(errors))) From 1a32b3d8ec93d9add542ebe02878b3c3fdbc74b1 Mon Sep 17 00:00:00 2001 From: Poikilos <7557867+poikilos@users.noreply.github.com> Date: Thu, 22 May 2025 02:26:29 -0400 Subject: [PATCH 85/99] Move onDisconnect to PhysicalLayer since it should be managed by socket code rather than application/feature code. --- examples/example_cdi_access.py | 2 +- examples/example_datagram_transfer.py | 2 +- examples/example_frame_interface.py | 3 + examples/example_memory_length_query.py | 2 +- examples/example_memory_transfer.py | 2 +- examples/example_message_interface.py | 2 +- examples/example_node_implementation.py | 2 +- examples/example_remote_nodes.py | 2 +- openlcb/linklayer.py | 8 ++- openlcb/openlcbnetwork.py | 2 +- openlcb/physicallayer.py | 13 +++++ tests/test_canlink.py | 77 ++++++++++++++----------- tests/test_remotenodeprocessor.py | 5 +- 13 files changed, 74 insertions(+), 48 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index 98fac18..4f13a6d 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -308,7 +308,7 @@ def memoryRead(): if canLink.nodeIdToAlias != previous_nodes: previous_nodes = copy.deepcopy(canLink.nodeIdToAlias) -canLink.onDisconnect() +physicalLayer.onDisconnect() if read_failed: print("Read complete (FAILED)") diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index 3238348..bf4d589 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -134,4 +134,4 @@ def datagramWrite(): physicalLayer.receiveAll(sock, verbose=settings['trace']) physicalLayer.sendAll(sock) -canLink.onDisconnect() +physicalLayer.onDisconnect() diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index 6a4812a..0e60bce 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -61,10 +61,13 @@ def handleFrameReceived(frame): def printFrame(frame): print("RL: {}".format(frame)) +def handleDisconnect(): + print("Disconnected.") physicalLayer = CanPhysicalLayerGridConnect() physicalLayer.onFrameSent = handleFrameSent physicalLayer.onFrameReceived = handleFrameReceived +physicalLayer.onDisconnect = handleDisconnect physicalLayer.registerFrameReceivedListener(printFrame) # send an AME frame with arbitrary alias to provoke response diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index 5d41713..6efc2b3 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -159,4 +159,4 @@ def memoryRequest(): physicalLayer.sendAll(sock, verbose=True) precise_sleep(.01) -canLink.onDisconnect() +physicalLayer.onDisconnect() diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index 12031d1..6051fb0 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -154,4 +154,4 @@ def memoryRead(): physicalLayer.sendAll(sock) precise_sleep(.01) -canLink.onDisconnect() +physicalLayer.onDisconnect() diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index 86661d6..1eb917d 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -93,4 +93,4 @@ def printMessage(msg): physicalLayer.sendAll(sock, verbose=True) precise_sleep(.01) -canLink.onDisconnect() +physicalLayer.onDisconnect() diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index cf823d7..74af25c 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -168,4 +168,4 @@ def displayOtherNodeIds(message) : physicalLayer.sendAll(sock, verbose=True) precise_sleep(.01) -canLink.onDisconnect() +physicalLayer.onDisconnect() diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index 9162c61..fb83bce 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -219,4 +219,4 @@ def result(arg1, arg2=None, arg3=None, result=True) : # this ends here, which takes the local node offline -canLink.onDisconnect() +physicalLayer.onDisconnect() diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index 2871ac6..561fcb7 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -60,6 +60,7 @@ def __init__(self, physicalLayer: PhysicalLayer, localNodeID): # operator not work as expected in registerFrameReceivedListener. physicalLayer.onFrameReceived = self.handleFrameReceived physicalLayer.onFrameSent = self.handleFrameSent + physicalLayer.onDisconnect = self.handleDisconnect physicalLayer.linkLayer = self # # ^ enforce queue paradigm (See use in PhysicalLayer subclass) # physicalLayer.registerFrameReceivedListener(listener) @@ -96,12 +97,13 @@ def handleFrameSent(self, frame): self.setState(frame.afterSendState) # may change again # since setState calls pollState via _onStateChanged. - def onDisconnect(self): + def handleDisconnect(self): """Run this whenever the socket connection is lost and override _onStateChanged to handle the change. * If you override this, you *must* call - `LinkLayer.onDisconnect(self)` to trigger _onStateChanged - if the implementation utilizes getState. + `LinkLayer.handleDisconnect(self)` (such as via + physicalLayer.onDisconnect) to trigger _onStateChanged if the + implementation utilizes getState. * Override this in each subclass or state won't match! """ if type(self).__name__ != "LinkLayer": diff --git a/openlcb/openlcbnetwork.py b/openlcb/openlcbnetwork.py index e331f59..bdf9d51 100644 --- a/openlcb/openlcbnetwork.py +++ b/openlcb/openlcbnetwork.py @@ -337,7 +337,7 @@ def _listen(self): self._mode = OpenLCBNetwork.Mode.Disconnected raise # re-raise since incomplete (prevent done OK state) finally: - self._canLink.onDisconnect() + self._physicalLayer.onDisconnect() self._listenThread: threading.Thread = None self._mode = OpenLCBNetwork.Mode.Disconnected diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index 9f445fc..17cbd85 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -152,6 +152,19 @@ def onFrameReceived(self, frame): " Set this method manually to LinkLayer/subclass instance's" " handleFrameReceived method.") + def onDisconnect(self, frame): + """Stub method, patched at runtime: + LinkLayer subclass's constructor must set instance's + onDisconnect to LinkLayer subclass' handleDisconnect (The + application must pass this instance to LinkLayer subclass's + constructor so it will do that). + """ + raise NotImplementedError( + "The subclass must patch the instance:" + " PhysicalLayer instance's onDisconnect must be manually" + " set to the LinkLayer subclass instance' handleDisconnect" + " so state can be updated if necessary.") + def onFrameSent(self, frame): """Stub method, patched at runtime: LinkLayer subclass's constructor must set instance's onFrameSent diff --git a/tests/test_canlink.py b/tests/test_canlink.py index 8683fc4..c0811c3 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -92,7 +92,8 @@ def assertFrameEqual(self, frame: CanFrame, other: CanFrame): # MARK: - Alias calculations def testIncrementAlias48(self): - canLink = CanLinkLayerSimulation(PhyMockLayer(), getLocalNodeID()) + physicalLayer = PhyMockLayer() + canLink = CanLinkLayerSimulation(physicalLayer, getLocalNodeID()) # check precision of calculation self.assertEqual(canLink.incrementAlias48(0), 0x1B0C_A37A_4BA9, @@ -101,10 +102,11 @@ def testIncrementAlias48(self): # test shift and multiplication operations next = canLink.incrementAlias48(0x0000_0000_0001) self.assertEqual(next, 0x1B0C_A37A_4DAA) - canLink.onDisconnect() + physicalLayer.onDisconnect() def testIncrementAliasSequence(self): - canLink = CanLinkLayerSimulation(PhyMockLayer(), getLocalNodeID()) + physicalLayer = PhyMockLayer() + canLink = CanLinkLayerSimulation(physicalLayer, getLocalNodeID()) # sequence from TN next = canLink.incrementAlias48(0) @@ -121,10 +123,11 @@ def testIncrementAliasSequence(self): next = canLink.incrementAlias48(next) self.assertEqual(next, 0xE5_82_F9_B4_AE_4D) - canLink.onDisconnect() + physicalLayer.onDisconnect() def testCreateAlias12(self): - canLink = CanLinkLayerSimulation(PhyMockLayer(), getLocalNodeID()) + physicalLayer = PhyMockLayer() + canLink = CanLinkLayerSimulation(physicalLayer, getLocalNodeID()) # check precision of calculation self.assertEqual(canLink.createAlias12(0x001), 0x001, "0x001 input") @@ -139,7 +142,7 @@ def testCreateAlias12(self): self.assertEqual(canLink.createAlias12(0x0000), 0xAEF, "zero input check") - canLink.onDisconnect() + physicalLayer.onDisconnect() # MARK: - Test PHY Up def testLinkUpSequence(self): @@ -156,7 +159,7 @@ def testLinkUpSequence(self): self.assertEqual(canLink._state, CanLink.State.Permitted) self.assertEqual(len(messageLayer.receivedMessages), 1) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() # MARK: - Test PHY Down, Up, Error Information def testLinkDownSequence(self): @@ -170,7 +173,7 @@ def testLinkDownSequence(self): self.assertEqual(canLink._state, CanLink.State.Inhibited) self.assertEqual(len(messageLayer.receivedMessages), 1) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testEIR2NoData(self): canPhysicalLayer = CanPhysicalLayerSimulation() @@ -180,7 +183,7 @@ def testEIR2NoData(self): canPhysicalLayer.fireFrameReceived( CanFrame(ControlFrame.EIR2.value, 0)) self.assertEqual(len(canPhysicalLayer.sentFrames), 0) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() # MARK: - Test AME (Local Node) def testAMENoData(self): @@ -198,7 +201,7 @@ def testAMENoData(self): CanFrame(ControlFrame.AMD.value, ourAlias, canLink.localNodeID.toArray()) ) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testAMEnoDataInhibited(self): canPhysicalLayer = CanPhysicalLayerSimulation() @@ -207,7 +210,7 @@ def testAMEnoDataInhibited(self): canPhysicalLayer.fireFrameReceived(CanFrame(ControlFrame.AME.value, 0)) self.assertEqual(len(canPhysicalLayer.sentFrames), 0) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testAMEMatchEvent(self): canPhysicalLayer = CanPhysicalLayerSimulation() @@ -225,7 +228,7 @@ def testAMEMatchEvent(self): canPhysicalLayer.sentFrames[0], CanFrame(ControlFrame.AMD.value, ourAlias, canLink.localNodeID.toArray())) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testAMENotMatchEvent(self): canPhysicalLayer = CanPhysicalLayerSimulation() @@ -236,7 +239,7 @@ def testAMENotMatchEvent(self): frame.data = bytearray([0, 0, 0, 0, 0, 0]) canPhysicalLayer.fireFrameReceived(frame) self.assertEqual(len(canPhysicalLayer.sentFrames), 0) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() # MARK: - Test Alias Collisions (Local Node) def testCIDreceivedMatch(self): @@ -252,7 +255,7 @@ def testCIDreceivedMatch(self): self.assertEqual(len(canPhysicalLayer.sentFrames), 1) self.assertFrameEqual(canPhysicalLayer.sentFrames[0], CanFrame(ControlFrame.RID.value, ourAlias)) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testRIDreceivedMatch(self): canPhysicalLayer = CanPhysicalLayerSimulation() @@ -275,11 +278,12 @@ def testRIDreceivedMatch(self): CanFrame(ControlFrame.AMD.value, 0x539, bytearray([5, 1, 1, 1, 3, 1]))) # new alias self.assertEqual(canLink._state, CanLink.State.Permitted) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testCheckMTIMapping(self): - canLink = CanLinkLayerSimulation(PhyMockLayer(), getLocalNodeID()) + physicalLayer = PhyMockLayer() + canLink = CanLinkLayerSimulation(physicalLayer, getLocalNodeID()) self.assertEqual( canLink.canHeaderToFullFormat( CanFrame(0x19490247, bytearray())), @@ -287,11 +291,12 @@ def testCheckMTIMapping(self): ) def testControlFrameDecode(self): - canLink = CanLinkLayerSimulation(PhyMockLayer(), getLocalNodeID()) + physicalLayer = PhyMockLayer() + canLink = CanLinkLayerSimulation(physicalLayer, getLocalNodeID()) frame = CanFrame(0x1000, 0x000) # invalid control frame content self.assertEqual(canLink.decodeControlFrameFormat(frame), ControlFrame.UnknownFormat) - canLink.onDisconnect() + physicalLayer.onDisconnect() def testControlFrameIsInternal(self): self.assertFalse(ControlFrame.isInternal(ControlFrame.AMD)) @@ -352,7 +357,7 @@ def testSimpleGlobalData(self): MTI.Verify_NodeID_Number_Global) self.assertEqual(messageLayer.receivedMessages[0].source, NodeID(0x010203040506)) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testVerifiedNodeInDestAliasMap(self): # JMRI doesn't send AMD, so gets assigned 00.00.00.00.00.00 @@ -379,7 +384,7 @@ def testVerifiedNodeInDestAliasMap(self): MTI.Verified_NodeID) self.assertEqual(messageLayer.receivedMessages[0].source, NodeID(0x080706050403)) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testNoDestInAliasMap(self): '''Tests handling of a message with a destination alias not in map @@ -407,7 +412,7 @@ def testNoDestInAliasMap(self): MTI.Identify_Events_Addressed) self.assertEqual(messageLayer.receivedMessages[0].source, NodeID(0x000000000001)) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testSimpleAddressedData(self): # Test start=yes, end=yes frame canPhysicalLayer = CanPhysicalLayerSimulation() @@ -443,7 +448,7 @@ def testSimpleAddressedData(self): # Test start=yes, end=yes frame self.assertEqual(len(messageLayer.receivedMessages[1].data), 2) self.assertEqual(messageLayer.receivedMessages[1].data[0], 12) self.assertEqual(messageLayer.receivedMessages[1].data[1], 13) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testSimpleAddressedDataNoAliasYet(self): '''Test start=yes, end=yes frame with no alias match''' @@ -479,7 +484,7 @@ def testSimpleAddressedDataNoAliasYet(self): self.assertEqual(len(messageLayer.receivedMessages[1].data), 2) self.assertEqual(messageLayer.receivedMessages[1].data[0], 12) self.assertEqual(messageLayer.receivedMessages[1].data[1], 13) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testMultiFrameAddressedData(self): '''multi-frame addressed messages - SNIP reply @@ -525,7 +530,7 @@ def testMultiFrameAddressedData(self): NodeID(0x01_02_03_04_05_06)) self.assertEqual(messageLayer.receivedMessages[1].destination, NodeID(0x05_01_01_01_03_01)) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testSimpleDatagram(self): # Test start=yes, end=yes frame canPhysicalLayer = CanPhysicalLayerSimulation() @@ -563,7 +568,7 @@ def testSimpleDatagram(self): # Test start=yes, end=yes frame self.assertEqual(messageLayer.receivedMessages[1].data[1], 11) self.assertEqual(messageLayer.receivedMessages[1].data[2], 12) self.assertEqual(messageLayer.receivedMessages[1].data[3], 13) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testMultiFrameDatagram(self): canPhysicalLayer = CanPhysicalLayerSimulation() @@ -625,7 +630,7 @@ def testMultiFrameDatagram(self): self.assertEqual(messageLayer.receivedMessages[1].data[9], 31) self.assertEqual(messageLayer.receivedMessages[1].data[10], 32) self.assertEqual(messageLayer.receivedMessages[1].data[11], 33) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testZeroLengthDatagram(self): canPhysicalLayer = PhyMockLayer() @@ -639,7 +644,7 @@ def testZeroLengthDatagram(self): self.assertEqual(len(canPhysicalLayer._send_frames), 1) self.assertEqual(str(canPhysicalLayer._send_frames[0]), "CanFrame header: 0x1A000000 []") - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testOneFrameDatagram(self): canPhysicalLayer = PhyMockLayer() @@ -656,7 +661,7 @@ def testOneFrameDatagram(self): str(canPhysicalLayer._send_frames[0]), "CanFrame header: 0x1A000000 [1, 2, 3, 4, 5, 6, 7, 8]" ) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testTwoFrameDatagram(self): canPhysicalLayer = PhyMockLayer() @@ -678,7 +683,7 @@ def testTwoFrameDatagram(self): str(canPhysicalLayer._send_frames[1]), "CanFrame header: 0x1D000000 [9, 10, 11, 12, 13, 14, 15, 16]" ) - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() def testThreeFrameDatagram(self): # FIXME: Why was testThreeFrameDatagram named same? What should it be? @@ -704,7 +709,7 @@ def testThreeFrameDatagram(self): ) self.assertEqual(str(canPhysicalLayer._send_frames[2]), "CanFrame header: 0x1D000000 [17, 18, 19]") - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() # MARK: - Test Remote Node Alias Tracking def testAmdAmrSequence(self): @@ -729,11 +734,12 @@ def testAmdAmrSequence(self): self.assertEqual(len(canPhysicalLayer.sentFrames), 0) # ^ nothing back down to CAN - canLink.onDisconnect() + canPhysicalLayer.onDisconnect() # MARK: - Data size handling def testSegmentAddressedDataArray(self): - canLink = CanLinkLayerSimulation(PhyMockLayer(), getLocalNodeID()) + physicalLayer = PhyMockLayer() + canLink = CanLinkLayerSimulation(physicalLayer, getLocalNodeID()) # no data self.assertEqual( @@ -776,10 +782,11 @@ def testSegmentAddressedDataArray(self): [bytearray([0x11,0x23, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6]), # noqa:E231 bytearray([0x31,0x23, 0x7, 0x8, 0x9, 0xA, 0xB, 0xC]), # noqa:E231 bytearray([0x21, 0x23, 0xD, 0xE])]) # noqa: E231 - canLink.onDisconnect() + physicalLayer.onDisconnect() def testSegmentDatagramDataArray(self): - canLink = CanLinkLayerSimulation(PhyMockLayer(), getLocalNodeID()) + physicalLayer = PhyMockLayer() + canLink = CanLinkLayerSimulation(physicalLayer, getLocalNodeID()) # no data self.assertEqual( @@ -824,7 +831,7 @@ def testSegmentDatagramDataArray(self): [bytearray([0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8]), bytearray([0x9, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF, 0x10]), bytearray([0x11])]) # noqa: E501 - canLink.onDisconnect() + physicalLayer.onDisconnect() def testEnum(self): usedValues = set() diff --git a/tests/test_remotenodeprocessor.py b/tests/test_remotenodeprocessor.py index dfd99dd..b25452c 100644 --- a/tests/test_remotenodeprocessor.py +++ b/tests/test_remotenodeprocessor.py @@ -20,11 +20,12 @@ class TesRemoteNodeProcessorClass(unittest.TestCase): def setUp(self) : self.node21 = Node(NodeID(21)) - self.canLink = CanLink(MockPhysicalLayer(), NodeID(100)) + self.physicalLayer = MockPhysicalLayer() + self.canLink = CanLink(self.physicalLayer, NodeID(100)) self.processor = RemoteNodeProcessor(self.canLink) def tearDown(self): - self.canLink.onDisconnect() + self.physicalLayer.onDisconnect() def testInitializationComplete(self) : # not related to node From 3f40bd9efbcd3c70a3ddc5ed411ecc4d453a631e Mon Sep 17 00:00:00 2001 From: Poikilos <7557867+poikilos@users.noreply.github.com> Date: Thu, 22 May 2025 12:49:18 -0400 Subject: [PATCH 86/99] Fix: check if isCanceled reservation frame (rename isBadReservation to isCanceled so it can be abstract and have other implementations). Count sent (allows skipping sleep). Run pollState in sendAll to reduce code required to use sendAll. Reduce commented code to match socket-openlcb-application stack model. --- examples/example_cdi_access.py | 9 +++-- examples/example_datagram_transfer.py | 8 +++-- examples/example_frame_interface.py | 10 ++++-- examples/example_memory_length_query.py | 9 +++-- examples/example_memory_transfer.py | 11 ++++-- examples/example_message_interface.py | 9 +++-- examples/example_node_implementation.py | 9 +++-- examples/example_remote_nodes.py | 3 +- examples/example_string_interface.py | 6 +++- examples/example_string_serial_interface.py | 19 +++++----- examples/example_tcp_message_interface.py | 16 ++++----- openlcb/canbus/canlink.py | 4 +-- openlcb/canbus/canphysicallayergridconnect.py | 35 +++++++++++++++---- openlcb/canbus/canphysicallayersimulation.py | 14 +++++++- openlcb/linklayer.py | 8 +++++ openlcb/openlcbnetwork.py | 2 +- openlcb/physicallayer.py | 11 +++++- tests/test_canlink.py | 19 ++++++---- 18 files changed, 144 insertions(+), 58 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index 4f13a6d..14b4fd9 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -300,11 +300,14 @@ def memoryRead(): # receives the data from the requested memory space (CDI in this # case) and offset (incremental position in the file/data, # incremented by this example's memoryReadSuccess handler). - physicalLayer.receiveAll(sock) - physicalLayer.sendAll(sock) + count = 0 + count += physicalLayer.receiveAll(sock) + count += physicalLayer.sendAll(sock) if canLink.nodeIdToAlias != previous_nodes: print("nodeIdToAlias updated: {}".format(canLink.nodeIdToAlias)) - precise_sleep(.01) + if count < 1: + precise_sleep(.01) + # else skip sleep to avoid latency (port already delayed) if canLink.nodeIdToAlias != previous_nodes: previous_nodes = copy.deepcopy(canLink.nodeIdToAlias) diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index bf4d589..623b7b3 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -131,7 +131,11 @@ def datagramWrite(): # process resulting activity while True: - physicalLayer.receiveAll(sock, verbose=settings['trace']) - physicalLayer.sendAll(sock) + count = 0 + count += physicalLayer.receiveAll(sock, verbose=settings['trace']) + count += physicalLayer.sendAll(sock) + if count < 1: + precise_sleep(.01) + # else skip sleep to avoid latency (port already delayed) physicalLayer.onDisconnect() diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index 0e60bce..1bae6c0 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -65,9 +65,13 @@ def handleDisconnect(): print("Disconnected.") physicalLayer = CanPhysicalLayerGridConnect() + +# NOTE: Normally the required handlers are set by link layer +# constructor, but this example doesn't use a link layer: physicalLayer.onFrameSent = handleFrameSent physicalLayer.onFrameReceived = handleFrameReceived physicalLayer.onDisconnect = handleDisconnect + physicalLayer.registerFrameReceivedListener(printFrame) # send an AME frame with arbitrary alias to provoke response @@ -80,5 +84,7 @@ def handleDisconnect(): # display response - should be RID from nodes while True: - physicalLayer.receiveAll(sock, verbose=True) - precise_sleep(.01) + count = physicalLayer.receiveAll(sock, verbose=True) + if count < 1: + precise_sleep(.01) + # else skip sleep to avoid latency (port already delayed) diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index 6efc2b3..b5d96f0 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -155,8 +155,11 @@ def memoryRequest(): # process resulting activity while True: - physicalLayer.receiveAll(sock, verbose=settings['trace']) - physicalLayer.sendAll(sock, verbose=True) - precise_sleep(.01) + count = 0 + count += physicalLayer.receiveAll(sock, verbose=settings['trace']) + count += physicalLayer.sendAll(sock, verbose=True) + if count < 1: + precise_sleep(.01) + # else skip sleep to avoid latency (port already delayed) physicalLayer.onDisconnect() diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index 6051fb0..9225e03 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -150,8 +150,13 @@ def memoryRead(): # process resulting activity while True: - physicalLayer.receiveAll(sock, verbose=settings['trace']) - physicalLayer.sendAll(sock) - precise_sleep(.01) + count = 0 + count += physicalLayer.receiveAll(sock, verbose=settings['trace']) + count += physicalLayer.sendAll(sock) # queue via pollState & send request + # Sleep after send to allow nodes to respond (may respond on later + # iteration, but sleep minimally to avoid latency). + if count < 1: + precise_sleep(.01) + # else skip sleep to avoid latency (port already delayed) physicalLayer.onDisconnect() diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index 1eb917d..f37d10e 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -89,8 +89,11 @@ def printMessage(msg): # process resulting activity while True: - physicalLayer.receiveAll(sock, verbose=settings['trace']) - physicalLayer.sendAll(sock, verbose=True) - precise_sleep(.01) + count = 0 + count += physicalLayer.sendAll(sock, verbose=True) + count += physicalLayer.receiveAll(sock, verbose=settings['trace']) + if count < 1: + precise_sleep(.01) + # else skip sleep to avoid latency (port already delayed) physicalLayer.onDisconnect() diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index 74af25c..44c2e37 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -164,8 +164,11 @@ def displayOtherNodeIds(message) : # process resulting activity while True: - physicalLayer.receiveAll(sock, verbose=settings['trace']) - physicalLayer.sendAll(sock, verbose=True) - precise_sleep(.01) + count = 0 + count += physicalLayer.sendAll(sock, verbose=True) + count += physicalLayer.receiveAll(sock, verbose=settings['trace']) + if count < 1: + precise_sleep(.01) + # else skip sleep to avoid latency (port already delayed) physicalLayer.onDisconnect() diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index fb83bce..18683e2 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -206,7 +206,8 @@ def result(arg1, arg2=None, arg3=None, result=True) : while True : try : received = readQueue.get(True, settings['timeout']) - if settings['trace'] : print("received: ", received) + if settings['trace']: + print("received: ", received) except Empty: break diff --git a/examples/example_string_interface.py b/examples/example_string_interface.py index 681a4e8..725224a 100644 --- a/examples/example_string_interface.py +++ b/examples/example_string_interface.py @@ -47,10 +47,14 @@ # Normally the receive call and case can be replaced by # physicalLayer.receiveAll, but we have no physicalLayer in this # example. + count = 0 received = sock.receive() if received is not None: observer.push(received) if observer.hasNext(): packet_str = observer.next() print(" RR: "+packet_str.strip()) - precise_sleep(.01) + count += 1 + if count < 1: + precise_sleep(.01) + # else skip sleep to avoid latency (port already delayed) diff --git a/examples/example_string_serial_interface.py b/examples/example_string_serial_interface.py index 67dd9e1..c88eaa0 100644 --- a/examples/example_string_serial_interface.py +++ b/examples/example_string_serial_interface.py @@ -43,18 +43,19 @@ # display response - should be RID from node(s) while True: # have to kill this manually + # Normally the receive call and case can be replaced by + # physicalLayer.receiveAll, but we have no physicalLayer in this + # example. + count = 0 received = sock.receive() if received is not None: observer.push(received) if observer.hasNext(): packet_str = observer.next() print(" RR: "+packet_str.strip()) - # canLink.pollState() - - # while True: - # frame = physicalLayer.pollFrame() - # if frame is None: - # break - # sock.sendString(frame.encodeAsString()) - # physicalLayer.onFrameSent(frame) - precise_sleep(.01) + count += 1 + + # count += physicalLayer.sendAll(sock) # typical but no physicalLayer here + if count < 1: + precise_sleep(.01) + # else skip sleep to avoid latency (port already delayed) diff --git a/examples/example_tcp_message_interface.py b/examples/example_tcp_message_interface.py index 57a68cd..9ebdc39 100644 --- a/examples/example_tcp_message_interface.py +++ b/examples/example_tcp_message_interface.py @@ -88,18 +88,14 @@ def printMessage(msg): # process resulting activity while True: + count = 0 received = sock.receive() if received is not None: print(" RR: {}".format(received)) # pass to link processor tcpLinkLayer.handleFrameReceived(received) - # Normally we would do (Probably N/A here): - # canLink.pollState() - # - # while True: - # frame = physicalLayer.pollFrame() - # if frame is None: - # break - # sock.sendString(frame.encodeAsString()) - # physicalLayer.onFrameSent(frame) - precise_sleep(.01) + count += 1 + # count += physicalLayer.sendAll(sock) # typical but N/A since realtime + if count < 1: + precise_sleep(.01) + # else skip sleep to avoid latency (port already delayed) diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index b22df05..8771213 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -198,7 +198,7 @@ def getLocalAlias(self) -> int: # assert isinstance(self._state, CanLink.State) # return self._state == CanLink.State.Permitted - def isBadReservation(self, frame: CanFrame) -> bool: + def isCanceled(self, frame: CanFrame) -> bool: if frame.reservation is None: return False return frame.reservation < self._reservation @@ -212,7 +212,7 @@ def isBadReservation(self, frame: CanFrame) -> bool: # " (alias={})." # .format(emit_cast(alias))) # return alias in self.duplicateAliases - # ^ Commented since isBadReservation handles both collision and error. + # ^ Commented since isCanceled handles both collision and error. # Commented since instead, socket code should call linkLayerUp and # linkLayerDown. Constructors should construct the openlcb stack: diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index c266112..e147724 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -67,25 +67,31 @@ def encodeFrameAsData(self, frame: CanFrame) -> Union[bytearray, bytes]: # bytes/bytearray has no attribute 'format') return self.encodeFrameAsString(frame).encode("utf-8") - def receiveAll(self, device: PortInterface, verbose=False): + def receiveAll(self, device: PortInterface, verbose=False) -> int: """Receive all data on the given device. Args: device (PortInterface): Device which *must* be in non-blocking mode (otherwise necessary two-way communication such as alias reservation cannot occur). verbose (bool, optional): If True, print each full packet. + + Returns: + int: number of bytes received """ + count = 0 try: data = device.receive() # If timeout, set non-blocking if data is None: return - self.handleData(data, verbose=verbose) + _ = self.handleData(data, verbose=verbose) + count += len(data) except BlockingIOError: # raised by receive if no data (non-blocking is # what we want, so fall through). pass + return count - def sendAll(self, device: PortInterface, mode="binary", verbose=False): + def sendAll(self, device: PortInterface, mode="binary", verbose=False) -> int: """Send all queued frames using the given device. Args: @@ -114,6 +120,12 @@ def sendAll(self, device: PortInterface, mode="binary", verbose=False): while True: frame: CanFrame = self._send_frames.popleft() # ^ exits loop with IndexError when done. + # (otherwise use pollFrame() and break if None) + if self.linkLayer: + if self.linkLayer.isCanceled(frame): + if verbose: + print("- Skipped (probably dup alias CID frame).") + continue if mode == "binary": data = self.encodeFrameAsData(frame) device.send(data) @@ -123,30 +135,37 @@ def sendAll(self, device: PortInterface, mode="binary", verbose=False): self.onFrameSent(frame) # Calls setState if necessary # (if frame.afterSendState is not None). if verbose: - print("SENT: {}".format(data)) + print("- SENT: {}".format(data)) count += 1 except IndexError: # nothing more to do (queue is empty) pass return count - def handleDataString(self, string: str): + def handleDataString(self, string: str) -> int: '''Provide string from the outside link to be parsed Args: string (str): A new UTF-8 string from outside link + + Returns: + int: The number of frames completed by inboundBuffer+string. ''' # formerly pushString formerly receiveString - self.handleData(string.encode("utf-8")) + return self.handleData(string.encode("utf-8")) - def handleData(self, data: Union[bytes, bytearray], verbose=False): + def handleData(self, data: Union[bytes, bytearray], verbose=False) -> int: """Provide characters from the outside link to be parsed Args: data (Union[bytes,bytearray]): new data from outside link verbose (bool, optional): If True, print each frame detected. + + Returns: + int: The number of frames completed by inboundBuffer+data. """ + frameCount = 0 self.inboundBuffer += data lastByte = 0 # last index is at ';' if GC_END_BYTE in self.inboundBuffer: @@ -187,6 +206,7 @@ def handleData(self, data: Union[bytes, bytearray], verbose=False): # lastByte is index of ; in this message cf = CanFrame(header, outData) + frameCount += 1 self.fireFrameReceived(cf) if verbose: print("- RECV {}".format( @@ -194,3 +214,4 @@ def handleData(self, data: Union[bytes, bytearray], verbose=False): # shorten buffer by removing the processed message self.inboundBuffer = self.inboundBuffer[lastByte:] + return frameCount \ No newline at end of file diff --git a/openlcb/canbus/canphysicallayersimulation.py b/openlcb/canbus/canphysicallayersimulation.py index f5a6a9c..f9ebc4e 100644 --- a/openlcb/canbus/canphysicallayersimulation.py +++ b/openlcb/canbus/canphysicallayersimulation.py @@ -44,15 +44,27 @@ def sendFrameAfter(self, frame: CanFrame): # later!) self._send_frames.append(frame) - def sendAll(self, device, mode="binary", verbose=False): + def sendAll(self, device, mode="binary", verbose=False) -> int: + if self.linkLayer: + self.linkLayer.pollState() # Advance delayed state(s) if necessary + # (done first since may enqueue frames). + count = 0 try: while True: frame = self._send_frames.popleft() # ^ exits loop with IndexError when done. + # (otherwise use pollFrame() and break if None) + if self.linkLayer: + if self.linkLayer.isCanceled(frame): + if verbose: + print("- Skipped (probably dup alias CID frame).") + continue # data = self.encodeFrameAsData(frame) # device.send(data) # commented since simulation self.onFrameSent(frame) self.sentFrames.append(frame) + count += 1 except IndexError: # no more frames (no problem) pass + return count diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index 561fcb7..dd5b046 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -82,6 +82,14 @@ def __init__(self, physicalLayer: PhysicalLayer, localNodeID): .format(emit_cast(type(self).DisconnectedState), type(self).__name__)) + def isCanceled(self, frame) -> bool: + """Subclass should implement this + if there is a cancelling mechanism (In the case of CanLink, + cancel frames from a previous LCC alias allocation where an + alias collision reply was received). + """ + return False + def handleFrameReceived(self, frame): logger.warning( "{} abstract handleFrameReceived called (expected implementation)" diff --git a/openlcb/openlcbnetwork.py b/openlcb/openlcbnetwork.py index bdf9d51..7def88e 100644 --- a/openlcb/openlcbnetwork.py +++ b/openlcb/openlcbnetwork.py @@ -294,7 +294,7 @@ def _listen(self): break # allow receive to run! if isinstance(frame, CanFrame): # if self._canLink.isDuplicateAlias(frame.alias): - if self._canLink.isBadReservation(frame): + if self._canLink.isCanceled(frame): logger.warning( "Discarded frame from a previous" " alias reservation attempt" diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index 17cbd85..51cc50a 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -73,17 +73,26 @@ def hasFrame(self) -> bool: """Check if there is a frame queued to send.""" return bool(self._send_frames) - def sendAll(self, device: PortInterface, mode="binary", verbose=False): + def sendAll(self, device: PortInterface, mode="binary", verbose=False) -> int: """Abstract method for sending queued frames""" raise NotImplementedError( "This must be implemented in a subclass" " which implements FrameEncoder" " (any real physical layer has an encoding).") + if self.linkLayer: + self.linkLayer.pollState() # Advance delayed state(s) if necessary + # (done first since may enqueue frames). count = 0 try: while True: data = self._send_chunks.popleft() # ^ exits loop with IndexError when done. + # (otherwise use pollFrame() and break if None) + if self.linkLayer: + if self.linkLayer.isCanceled(data): + if verbose: + print("- Skipped (canceled by link layer).") + continue device.send(data) count += 1 except IndexError: diff --git a/tests/test_canlink.py b/tests/test_canlink.py index c0811c3..ec5104b 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -29,7 +29,7 @@ def sendDataAfter(self, data): self.receivedFrames.append(data) - def sendAll(self, _, mode="binary", verbose=True): + def sendAll(self, _, mode="binary", verbose=True) -> int: """Simulated sendAll The simulation has no real communication, so no device argument is necessary. See CanLink for a real implementation. @@ -39,19 +39,29 @@ def sendAll(self, _, mode="binary", verbose=True): recommended in the case of numerous sequential memory read requests such as when reading CDI/FDI). """ - if self.canLink: - self.canLink.pollState() # run first since may enqueue frame(s) + count = 0 + if self.linkLayer: + self.linkLayer.pollState() # run first since may enqueue frame(s) while True: # self.physicalLayer must be set by canLink constructor by # passing a physicalLayer to it. frame = self.physicalLayer.pollFrame() if not frame: break + # ^ If using popleft, break on IndexError (empty) instead. + if self.linkLayer: + if self.linkLayer.isCanceled(frame): + if verbose: + print("- Skipped (probably dup alias CID frame).") + continue + string = frame.encodeAsString() # device.sendString(string) # commented since simulation if verbose: print(" SENT (simulated socket) packet: "+string.strip()) self.physicalLayer.onFrameSent(frame) + count += 1 + return count class MessageMockLayer: @@ -193,7 +203,6 @@ def testAMENoData(self): canLink._state = CanLink.State.Permitted canPhysicalLayer.fireFrameReceived(CanFrame(ControlFrame.AME.value, 0)) - canLink.pollState() # add response to queue canPhysicalLayer.sendAll(None) # add response to sentFrames self.assertEqual(len(canPhysicalLayer.sentFrames), 1) self.assertFrameEqual( @@ -221,7 +230,6 @@ def testAMEMatchEvent(self): frame = CanFrame(ControlFrame.AME.value, 0) frame.data = bytearray([5, 1, 1, 1, 3, 1]) canPhysicalLayer.fireFrameReceived(frame) - canLink.pollState() # add response to queue canPhysicalLayer.sendAll(None) # add response to sentFrames self.assertEqual(len(canPhysicalLayer.sentFrames), 1) self.assertFrameEqual( @@ -250,7 +258,6 @@ def testCIDreceivedMatch(self): canPhysicalLayer.fireFrameReceived( CanFrame(7, canLink.localNodeID, ourAlias)) - canLink.pollState() # add response to queue canPhysicalLayer.sendAll(None) # add response to sentFrames self.assertEqual(len(canPhysicalLayer.sentFrames), 1) self.assertFrameEqual(canPhysicalLayer.sentFrames[0], From 8d52f27ad999a7e914fa42ddec968aa0d83c7640 Mon Sep 17 00:00:00 2001 From: Poikilos <7557867+poikilos@users.noreply.github.com> Date: Thu, 22 May 2025 21:18:39 -0400 Subject: [PATCH 87/99] Remove dead code (GridConnectObserver is replaced by verbose=True in examples where PhysicalLayer subclass is avaiable). --- examples/example_frame_interface.py | 3 --- examples/example_node_implementation.py | 3 --- examples/example_remote_nodes.py | 2 -- 3 files changed, 8 deletions(-) diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index 1bae6c0..99ba266 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -19,7 +19,6 @@ # endregion same code as other examples from openlcb import precise_sleep # noqa: E402 -from openlcb.canbus.gridconnectobserver import GridConnectObserver # noqa:E402 from openlcb.tcplink.tcpsocket import TcpSocket # noqa: E402 from openlcb.canbus.canphysicallayergridconnect import ( # noqa: E402 CanPhysicalLayerGridConnect, @@ -80,8 +79,6 @@ def handleDisconnect(): physicalLayer.sendFrameAfter(frame) physicalLayer.sendAll(sock, verbose=True) -observer = GridConnectObserver() - # display response - should be RID from nodes while True: count = physicalLayer.receiveAll(sock, verbose=True) diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index 44c2e37..e4b2fce 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -20,7 +20,6 @@ # endregion same code as other examples from openlcb import precise_sleep # noqa: E402 -from openlcb.canbus.gridconnectobserver import GridConnectObserver # noqa:E402 from openlcb.tcplink.tcpsocket import TcpSocket # noqa: E402 from openlcb.canbus.canphysicallayergridconnect import ( # noqa: E402 @@ -160,8 +159,6 @@ def displayOtherNodeIds(message) : NodeID(settings['localNodeID']), None) canLink.sendMessage(message) -observer = GridConnectObserver() - # process resulting activity while True: count = 0 diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index 18683e2..b60d305 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -14,7 +14,6 @@ from timeit import default_timer from examples_settings import Settings # do 1st to fix path if no pip install from openlcb import precise_sleep -from openlcb.canbus.gridconnectobserver import GridConnectObserver settings = Settings() if __name__ == "__main__": @@ -106,7 +105,6 @@ def printMessage(msg): readQueue = Queue() -observer = GridConnectObserver() _frameReceivedListeners = physicalLayer._frameReceivedListeners assert len(_frameReceivedListeners) == 1, \ "{} listener(s) unexpectedly".format(len(_frameReceivedListeners)) From 6e9b1da8c7abd3d7c790a3f6ee6425348fe09e68 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Fri, 23 May 2025 12:00:49 -0400 Subject: [PATCH 88/99] Fix: handle "None" return from _receive properly (and never return None for count). Reduce OpenLCBNetwork code to use high-level sendAll and receiveAll. --- openlcb/canbus/canphysicallayergridconnect.py | 2 +- openlcb/openlcbnetwork.py | 92 ++++++++----------- openlcb/portinterface.py | 2 +- tests/test_canlink.py | 5 +- 4 files changed, 42 insertions(+), 59 deletions(-) diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index e147724..4c3bc50 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -82,7 +82,7 @@ def receiveAll(self, device: PortInterface, verbose=False) -> int: try: data = device.receive() # If timeout, set non-blocking if data is None: - return + return count _ = self.handleData(data, verbose=verbose) count += len(data) except BlockingIOError: diff --git a/openlcb/openlcbnetwork.py b/openlcb/openlcbnetwork.py index 7def88e..5e18fcc 100644 --- a/openlcb/openlcbnetwork.py +++ b/openlcb/openlcbnetwork.py @@ -42,7 +42,9 @@ MemoryReadMemo, MemoryService, ) +from openlcb.physicallayer import PhysicalLayer from openlcb.platformextras import SysDirs, clean_file_name +from openlcb.portinterface import PortInterface if __name__ == "__main__": logger = getLogger(__file__) @@ -133,12 +135,12 @@ def __init__(self, *args, **kwargs): # endregion ContentHandler # region connect - self._port = None - self._physicalLayer = None - self._canLink = None - self._datagramService = None - self._memoryService = None - self._resultingCDI = None + self._port: PortInterface = None + self.physicalLayer: CanPhysicalLayerGridConnect = None + self.canLink: CanLink = None + self._datagramService: DatagramService = None + self._memoryService: MemoryService = None + self._resultingCDI: bytearray = None # endregion connect self._connectingStart: float = None @@ -174,22 +176,22 @@ def startListening(self, connected_port, "[startListening] A previous _port will be discarded.") self._port = connected_port self._fireStatus("CanPhysicalLayerGridConnect...") - self._physicalLayer = CanPhysicalLayerGridConnect() + self.physicalLayer = CanPhysicalLayerGridConnect() self._fireStatus("CanLink...") - self._canLink = CanLink(self._physicalLayer, NodeID(localNodeID)) + self.canLink = CanLink(self.physicalLayer, NodeID(localNodeID)) # ^ CanLink constructor sets _physicalLayer's onFrameReceived # and onFrameSent to handlers in _canLink. self._fireStatus("CanLink...registerMessageReceivedListener...") - self._canLink.registerMessageReceivedListener(self._handleMessage) + self.canLink.registerMessageReceivedListener(self._handleMessage) # NOTE: Incoming data (Memo) is handled by _memoryReadSuccess # and _memoryReadFail. # - These are set when constructing the MemoryReadMemo which # is provided to openlcb's requestMemoryRead method. self._fireStatus("DatagramService...") - self._datagramService = DatagramService(self._canLink) - self._canLink.registerMessageReceivedListener( + self._datagramService = DatagramService(self.canLink) + self.canLink.registerMessageReceivedListener( self._datagramService.process ) @@ -209,9 +211,9 @@ def startListening(self, connected_port, # if not None. self._fireStatus("physicalLayerUp...") - self._physicalLayer.physicalLayerUp() + self.physicalLayer.physicalLayerUp() self._fireStatus("Waiting for alias reservation...") - while self._canLink.pollState() != CanLink.State.Permitted: + while self.canLink.pollState() != CanLink.State.Permitted: precise_sleep(.02) # ^ triggers fireFrameReceived which calls CanLink's default # receiveListener by default since added on CanPhysicalLayer @@ -234,6 +236,10 @@ def _receive(self) -> bytearray: Override this if serial/other subclass not using TCP (or better yet, make all ports including TcpSocket inherit from a standard port interface) + Returns: + bytearray: Data, or None if no data (BlockingIOError is + handled by PortInterface, *not* passed up the + callstack). """ return self._port.receive() @@ -258,19 +264,22 @@ def _listen(self): try: # Receive mode (switches to write mode on BlockingIOError # which is expected and used on purpose) - # print("Waiting for _receive") - received = self._receive() # requires setblocking(False) - print("[_listen] received {} byte(s)" - .format(len(received)), - file=sys.stderr) - # print(" RR: {}".format(received.strip())) - # pass to link processor - self._physicalLayer.handleData(received) - # ^ will trigger self._printFrame if that was added - # via registerFrameReceivedListener during connect. + count = self.physicalLayer.receiveAll(self._port) + if count < 1: + # BlockingIOError would be raised by + # self._port.receive via self._receive, but + # receiveAll handles the exception (in which + # case return is 0), so switch back to send + # mode manually by raising: + raise BlockingIOError("No data yet") + + # ^ handleData will trigger self._printFrame if that + # was added via registerFrameReceivedListener + # during connect. But now you can use verbose=True + # for receiveAll instead if desired debugging. precise_sleep(.01) # let processor sleep before read - if time.perf_counter() - self._connectingStart > .2: - if self._canLink._state != CanLink.State.Permitted: + if time.perf_counter() - self._connectingStart > .21: + if self.canLink._state != CanLink.State.Permitted: delta = time.perf_counter() - self._messageStart if ((self._messageStart is None) or (delta > 1)): logger.warning( @@ -283,34 +292,7 @@ def _listen(self): except BlockingIOError: # Nothing to receive right now, so perform all sends # This *must* occur (require socket.setblocking(False)) - # sends = self._physicalLayer.popFrames() - # while sends: - while True: - # *Always* do send in the receive thread to - # avoid overlapping calls to socket - # (causes undefined behavior)! - frame = self._physicalLayer.pollFrame() - if frame is None: - break # allow receive to run! - if isinstance(frame, CanFrame): - # if self._canLink.isDuplicateAlias(frame.alias): - if self._canLink.isCanceled(frame): - logger.warning( - "Discarded frame from a previous" - " alias reservation attempt" - " (duplicate alias={})" - .format(frame.alias)) - continue - logger.debug("[_listen] _sendString...") - packet = frame.encodeAsString() - assert isinstance(packet, str) - print("Sending {}".format(packet)) - self._port.sendString(packet) - self._physicalLayer.onFrameSent(frame) - else: - raise NotImplementedError( - "Event type {} is not handled." - .format(type(frame).__name__)) + self.physicalLayer.sendAll(self._port) # so that it doesn't block (or occur during) recv # (overlapping calls would cause undefined behavior)! # delay = random.uniform(.005,.02) @@ -337,7 +319,7 @@ def _listen(self): self._mode = OpenLCBNetwork.Mode.Disconnected raise # re-raise since incomplete (prevent done OK state) finally: - self._physicalLayer.onDisconnect() + self.physicalLayer.onDisconnect() self._listenThread: threading.Thread = None self._mode = OpenLCBNetwork.Mode.Disconnected @@ -383,7 +365,7 @@ def callback(event_d): if not self._port: raise RuntimeError( "No port connection. Call startListening first.") - if not self._physicalLayer: + if not self.physicalLayer: raise RuntimeError( "No physicalLayer. Call startListening first.") self._cdi_offset = 0 diff --git a/openlcb/portinterface.py b/openlcb/portinterface.py index bd4a0ed..fc4423c 100644 --- a/openlcb/portinterface.py +++ b/openlcb/portinterface.py @@ -171,7 +171,7 @@ def close(self) -> None: # # Use receive (uses required semaphores) not _receive (not thread safe) # return data.decode("utf-8") - def sendString(self, string): + def sendString(self, string: str): """Send a single string. """ self.send(string.encode('utf-8')) diff --git a/tests/test_canlink.py b/tests/test_canlink.py index ec5104b..bdb9ead 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -1,3 +1,4 @@ +from typing import Union import unittest from openlcb import formatted_ex @@ -74,10 +75,10 @@ def receiveMessage(self, msg): class MockPort(PortInterface): - def send(self, data): + def send(self, data: Union[bytearray, bytes]): pass - def sendString(self, data): + def sendString(self, data: str): pass def receive(self): From 6bffdad650d531bb2db974438b49c4b305625316 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Fri, 23 May 2025 13:37:14 -0400 Subject: [PATCH 89/99] Rename an attribute for clarity (already renamed in Simulation class). --- tests/test_canlink.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_canlink.py b/tests/test_canlink.py index bdb9ead..9b7d5ef 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -22,13 +22,12 @@ def __init__(self): # onFrameSent will not work until this instance is passed to the # LinkLayer subclass' constructor (See onFrameSent # docstring in PhysicalLayer) - self.receivedFrames = [] + self.sentFrames = [] CanPhysicalLayer.__init__(self) def sendDataAfter(self, data): assert isinstance(data, (bytes, bytearray)) - self.receivedFrames.append(data) - + self.sentFrames.append(data) def sendAll(self, _, mode="binary", verbose=True) -> int: """Simulated sendAll From cb3810f71fd7115860046faf730c137f2ca883c3 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Fri, 23 May 2025 13:38:24 -0400 Subject: [PATCH 90/99] Fix: Call sendAll in example_remode_nodes since not using Realtime subclass. Separate RealtimePhysicalLayer from RealtimeRawPhysicalLayer for clarity. --- examples/example_message_interface.py | 2 +- examples/example_remote_nodes.py | 4 +- examples/example_tcp_message_interface.py | 10 +-- openlcb/canbus/canlink.py | 2 +- openlcb/canbus/canphysicallayergridconnect.py | 2 +- openlcb/canbus/canphysicallayersimulation.py | 6 +- openlcb/linklayer.py | 2 +- openlcb/physicallayer.py | 3 +- openlcb/rawphysicallayer.py | 35 ++++++++++ openlcb/realtimephysicallayer.py | 66 ++++++++++++++++--- openlcb/realtimerawphysicallayer.py | 33 ++++++++++ openlcb/tcplink/tcplink.py | 9 ++- python-openlcb.code-workspace | 2 + tests/test_datagramservice.py | 2 +- tests/test_localnodeprocessor.py | 2 +- tests/test_memoryservice.py | 2 +- 16 files changed, 154 insertions(+), 28 deletions(-) create mode 100644 openlcb/rawphysicallayer.py create mode 100644 openlcb/realtimerawphysicallayer.py diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index f37d10e..099cc31 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -91,7 +91,7 @@ def printMessage(msg): while True: count = 0 count += physicalLayer.sendAll(sock, verbose=True) - count += physicalLayer.receiveAll(sock, verbose=settings['trace']) + count += physicalLayer.receiveAll(sock, verbose=True) if count < 1: precise_sleep(.01) # else skip sleep to avoid latency (port already delayed) diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index b60d305..0bf7949 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -199,7 +199,7 @@ def result(arg1, arg2=None, arg3=None, result=True) : NodeID(settings['localNodeID']), None) if settings['trace'] : print("SM: {}".format(message)) canLink.sendMessage(message) - +physicalLayer.sendAll(sock) # pull the received messages while True : try : @@ -218,4 +218,6 @@ def result(arg1, arg2=None, arg3=None, result=True) : # this ends here, which takes the local node offline +# For explicitness (to make this example match use in non-linear +# application), notify openlcb of disconnect: physicalLayer.onDisconnect() diff --git a/examples/example_tcp_message_interface.py b/examples/example_tcp_message_interface.py index 9ebdc39..aa5a888 100644 --- a/examples/example_tcp_message_interface.py +++ b/examples/example_tcp_message_interface.py @@ -15,7 +15,7 @@ # region same code as other examples from examples_settings import Settings # do 1st to fix path if no pip install from openlcb import precise_sleep -from openlcb.realtimephysicallayer import RealtimePhysicalLayer +from openlcb.realtimerawphysicallayer import RealtimeRawPhysicalLayer settings = Settings() if __name__ == "__main__": @@ -55,14 +55,14 @@ # assert isinstance(data, (bytes, bytearray)) # print(" SR: {}".format(data)) # sock.send(data) -# ^ Moved to RealtimePhysicalLayer sendFrameAfter override +# ^ Moved to RealtimeRawPhysicalLayer sendFrameAfter override def printMessage(msg): print("RM: {} from {}".format(msg, msg.source)) -physicalLayer = RealtimePhysicalLayer(sock) +physicalLayer = RealtimeRawPhysicalLayer(sock) # ^ this was not in the example before # (just gave sendToSocket to TcpLink) @@ -80,8 +80,8 @@ def printMessage(msg): message = Message(MTI.Verify_NodeID_Number_Global, NodeID(settings['localNodeID']), None) print("SM: {}".format(message)) -tcpLinkLayer.sendMessage(message) - +tcpLinkLayer.sendMessage(message, verbose=True) +physicalLayer.sendAll(sock, verbose=True) # only a formality since Realtime # N/A # while not tcpLinkLayer.getState() == TcpLink.State.Permitted: # time.sleep(.02) diff --git a/openlcb/canbus/canlink.py b/openlcb/canbus/canlink.py index 8771213..a6908d7 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -726,7 +726,7 @@ def handleReceivedData(self, frame: CanFrame): msg.originalMTI = ((frame.header >> 12) & 0xFFF) self.fireMessageReceived(msg) - def sendMessage(self, msg: Message): + def sendMessage(self, msg: Message, verbose=False): # special case for datagram if msg.mti == MTI.Datagram: header = 0x10_00_00_00 diff --git a/openlcb/canbus/canphysicallayergridconnect.py b/openlcb/canbus/canphysicallayergridconnect.py index 4c3bc50..227ee4b 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -214,4 +214,4 @@ def handleData(self, data: Union[bytes, bytearray], verbose=False) -> int: # shorten buffer by removing the processed message self.inboundBuffer = self.inboundBuffer[lastByte:] - return frameCount \ No newline at end of file + return frameCount diff --git a/openlcb/canbus/canphysicallayersimulation.py b/openlcb/canbus/canphysicallayersimulation.py index f9ebc4e..e40cb20 100644 --- a/openlcb/canbus/canphysicallayersimulation.py +++ b/openlcb/canbus/canphysicallayersimulation.py @@ -2,7 +2,7 @@ Simulated CanPhysicalLayer to record frames requested to be sent. ''' -from typing import List +from typing import List, Union from openlcb.canbus.canframe import CanFrame from openlcb.canbus.canphysicallayer import CanPhysicalLayer from openlcb.frameencoder import FrameEncoder @@ -25,9 +25,11 @@ def __init__(self): def _onQueuedFrame(self, frame: CanFrame): raise AttributeError("Not implemented for simulation") - def handleData(self, data: bytearray, verbose=False): + def handleData(self, data: Union[bytes, bytearray], verbose=False) -> int: # Do not parse, since simulation. Just collect for later analysis self.received_chunks.append(data) + frameCount = 1 # assumed for simulation + return frameCount def encodeFrameAsString(self, frame: CanFrame): return "(no encoding, only simulating CanPhysicalLayer superclass)" diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index dd5b046..8102819 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -151,7 +151,7 @@ def _onStateChanged(self, oldState, newState): raise NotImplementedError( "[LinkLayer] abstract _onStateChanged not implemented") - def sendMessage(self, msg: Message): + def sendMessage(self, msg: Message, verbose=False): '''This is the basic abstract interface ''' diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index 51cc50a..2a55c14 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -73,7 +73,8 @@ def hasFrame(self) -> bool: """Check if there is a frame queued to send.""" return bool(self._send_frames) - def sendAll(self, device: PortInterface, mode="binary", verbose=False) -> int: + def sendAll(self, device: PortInterface, mode="binary", + verbose=False) -> int: """Abstract method for sending queued frames""" raise NotImplementedError( "This must be implemented in a subclass" diff --git a/openlcb/rawphysicallayer.py b/openlcb/rawphysicallayer.py new file mode 100644 index 0000000..032d2da --- /dev/null +++ b/openlcb/rawphysicallayer.py @@ -0,0 +1,35 @@ +from typing import Union +from openlcb.frameencoder import FrameEncoder +from openlcb.physicallayer import PhysicalLayer + + +class RawPhysicalLayer(PhysicalLayer, FrameEncoder): + """Implements FrameEncoder but leaves PhysicalLayer untouched + so that PhysicalLayer methods can be implemented by subclass or + sibling class used as second superclass by subclass. + - This FrameEncoder implementation doesn't actually + encode CanFrame--only converts between str & bytes! + """ + def __init__(self, *args, **kwargs): + PhysicalLayer.__init__(self, *args, **kwargs) + FrameEncoder.__init__(self, *args, **kwargs) + + def encodeFrameAsString(self, frame) -> str: + if isinstance(frame, str): + return frame + elif isinstance(frame, (bytearray, bytes)): + return frame.decode("utf-8") + raise TypeError( + "Only str, bytes, or bytearray is allowed for RawPhysicalLayer." + " For {} use/make another Encoder implementation." + .format(type(self).__name__)) + + def encodeFrameAsData(self, frame) -> Union[bytearray, bytes]: + if isinstance(frame, str): + return frame.encode("utf-8") + elif isinstance(frame, (bytearray, bytes)): + return frame + raise TypeError( + "Only str, bytes, or bytearray is allowed for RawPhysicalLayer." + " For frame type {} use/make another FrameEncoder implementation." + .format(type(self).__name__)) diff --git a/openlcb/realtimephysicallayer.py b/openlcb/realtimephysicallayer.py index 379ca23..022a17c 100644 --- a/openlcb/realtimephysicallayer.py +++ b/openlcb/realtimephysicallayer.py @@ -1,15 +1,23 @@ -from enum import Enum from logging import getLogger from typing import Union from openlcb.physicallayer import PhysicalLayer +from openlcb.portinterface import PortInterface logger = getLogger(__name__) class RealtimePhysicalLayer(PhysicalLayer): - + """A realtime physical layer is only for use when there is an + absence of a link layer (or link layer doesn't enqueue frames) *and* + the application is not multi-threaded or uses a lock and avoids + race conditions. + Otherwise, overlapping port calls (*undefined behavior* at OS level) + may occur! + TODO: Add a lock variable and do reads here so all port usage can + utilize the lock and prevent overlapping use of the port. + """ class State: Initial = 0 Disconnected = 1 @@ -18,34 +26,74 @@ class State: DisconnectedState = State.Disconnected def __init__(self, socket): + PhysicalLayer.__init__(self) # sock to distinguish from socket module or socket.socket class! self.sock = socket - def sendDataAfter(self, data: Union[bytearray, bytes]): + def sendDataAfter(self, data: Union[bytearray, bytes], verbose=True): + """Send data (immediately, since realtime subclass). + + Args: + data (Union[bytearray, bytes, CanFrame]): data to send. + verbose (bool, optional): verbose is only for Realtime + subclass (since data is sent immediately), otherwise set + verbose on sendAll. Defaults to False. + """ # if isinstance(data, list): # raise TypeError( # "Got {}({}) but expected str" # .format(type(data).__name__, data) # ) assert isinstance(data, (bytes, bytearray)) - print(" SR: {}".format(data)) + if verbose: + print(" SR: {}".format(data)) self.sock.send(data) - def sendFrameAfter(self, frame): + def sendFrameAfter(self, frame, verbose=False): + """Send frame (immediately, since realtime subclass). + + Args: + data (Union[bytearray, bytes, CanFrame]): data to send. + verbose (bool, optional): verbose is only for Realtime + subclass (since data is sent immediately), otherwise set + verbose on sendAll. Defaults to False. + """ + if hasattr(self, 'encodeFrameAsData'): + data = self.encodeFrameAsData(frame) + else: + assert isinstance(frame, (bytes, bytearray, str)), \ + "Use a FrameEncoder implementation if not using bytes/str" + if isinstance(frame, str): + data = frame.encode("utf-8") + else: + data = frame # if isinstance(data, list): # raise TypeError( # "Got {}({}) but expected str" # .format(type(data).__name__, data) # ) - print(" SR: {}".format(frame.encode())) + if verbose: + print(" SR: {}".format(frame)) # send and fireFrameReceived would usually occur after # frame from _send_frames.popleft is sent, # but we do all this here in the Realtime subclass: - self.sock.send(frame.encode()) - # TODO: finish onFrameSent - if frame.afterSendState: + self.sock.send(data) + if hasattr(frame, 'afterSendState') and frame.afterSendState: + # Use hasattr since only applicable to subclasses that use + # CanFrame. self.fireFrameReceived(frame) # also calls self.onFrameSent(frame) + def sendAll(self, device: PortInterface, mode="binary", + verbose=False) -> int: + """sendAll is only a stub in the case of realtime subclasses. + Instead of popping frames it performs a check to ensure the + queue is not used (since queue should only be used for typical + subclasses which are queued). + """ + if len(self._send_frames) > 0: + raise AssertionError("Realtime subclasses should not use a queue!") + logger.debug("sendAll ran (realtime subclass, so nothing to do)") + def registerFrameReceivedListener(self, listener): """Register a new frame received listener (optional since LinkLayer subclass constructor sets diff --git a/openlcb/realtimerawphysicallayer.py b/openlcb/realtimerawphysicallayer.py new file mode 100644 index 0000000..de1e425 --- /dev/null +++ b/openlcb/realtimerawphysicallayer.py @@ -0,0 +1,33 @@ +from typing import Union +from openlcb.rawphysicallayer import RawPhysicalLayer +from openlcb.realtimephysicallayer import RealtimePhysicalLayer + + +class RealtimeRawPhysicalLayer(RealtimePhysicalLayer, RawPhysicalLayer): + """A realtime physical layer is only for use when there is an + absence of a link layer (or link layer doesn't enqueue frames) *and* + the application is not multi-threaded or uses a lock and avoids + race conditions. + Otherwise, overlapping port calls (*undefined behavior* at OS level) + may occur! + See RealtimePhysicalLayer for more information. + """ + def sendFrameAfter(self, frame, verbose=False): + self.sendDataAfter(frame, verbose=verbose) + + def sendDataAfter(self, data: Union[bytearray, bytes], verbose=False): + # ^ data for sendDataAfter, + # For frame see sendFrameAfter. + # verbose is only for Realtime subclass (since data is sent + # immediately), otherwise set verbose on sendAll. + # if isinstance(data, list): + # raise TypeError( + # "Got {}({}) but expected str" + # .format(type(data).__name__, data) + # ) + if isinstance(data, str): + data = data.encode("utf-8") + assert isinstance(data, (bytes, bytearray)) + if verbose: + print(" SR: {}".format(data)) + self.sock.send(data) diff --git a/openlcb/tcplink/tcplink.py b/openlcb/tcplink/tcplink.py index 684bc51..9e76c23 100644 --- a/openlcb/tcplink/tcplink.py +++ b/openlcb/tcplink/tcplink.py @@ -68,14 +68,14 @@ def _onStateChanged(self, oldState, newState): print(f"[TcpLink] _onStateChanged from {oldState} to {newState}" " (nothing to do since TcpLink)") - def handleFrameReceived(self, inputData: bytearray): + def handleFrameReceived(self, inputData: Union[bytes, bytearray]): """Receives bytes from lower level and accumulates them into individual message parts. Args: inputData ([int]) : next chunk of the input stream """ - assert isinstance(inputData, bytearray) + assert isinstance(inputData, (bytes, bytearray)) self.accumulatedData.extend(inputData) # Now check it if has one or more complete message. while len(self.accumulatedData) > 0 : @@ -194,11 +194,14 @@ def linkDown(self): msg = Message(MTI.Link_Layer_Down, NodeID(0), None, bytearray()) self.fireMessageReceived(msg) - def sendMessage(self, message: Message): + def sendMessage(self, message: Message, verbose=False): """ The message level calls this with an OpenLCB message. That is then converted to a byte stream and forwarded to the TCP socket layer. + Args: + message (Message): A message. + verbose (bool, optional): Ignored (Reserved for subclass). """ mti = message.mti diff --git a/python-openlcb.code-workspace b/python-openlcb.code-workspace index c35d10d..20ef10c 100644 --- a/python-openlcb.code-workspace +++ b/python-openlcb.code-workspace @@ -81,7 +81,9 @@ "pyproject", "pyserial", "pythonopenlcb", + "rawphysicallayer", "realtimephysicallayer", + "realtimerawphysicallayer", "remotenodeprocessor", "remotenodestore", "repr", diff --git a/tests/test_datagramservice.py b/tests/test_datagramservice.py index b3a0e70..22baca4 100644 --- a/tests/test_datagramservice.py +++ b/tests/test_datagramservice.py @@ -26,7 +26,7 @@ class State: DisconnectedState = State.Disconnected - def sendMessage(self, message): + def sendMessage(self, message, verbose=False): LinkMockLayer.sentMessages.append(message) def _onStateChanged(self, oldState, newState): diff --git a/tests/test_localnodeprocessor.py b/tests/test_localnodeprocessor.py index 23f9823..454f97f 100644 --- a/tests/test_localnodeprocessor.py +++ b/tests/test_localnodeprocessor.py @@ -24,7 +24,7 @@ class State: DisconnectedState = State.Disconnected - def sendMessage(self, message): + def sendMessage(self, message, verbose=False): LinkMockLayer.sentMessages.append(message) def _onStateChanged(self, oldState, newState): diff --git a/tests/test_memoryservice.py b/tests/test_memoryservice.py index eef5d0d..19d792f 100644 --- a/tests/test_memoryservice.py +++ b/tests/test_memoryservice.py @@ -55,7 +55,7 @@ class State: sentMessages = [] - def sendMessage(self, message): + def sendMessage(self, message, verbose=False): LinkMockLayer.sentMessages.append(message) def _onStateChanged(self, oldState, newState): From 466c3698207f0c6fe84f3e9c43b2b0f3096cfa15 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Fri, 23 May 2025 14:09:30 -0400 Subject: [PATCH 91/99] Add more type hints. --- openlcb/node.py | 10 ++++++---- openlcb/nodestore.py | 7 ++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/openlcb/node.py b/openlcb/node.py index 2c5a72a..2433c44 100644 --- a/openlcb/node.py +++ b/openlcb/node.py @@ -15,6 +15,7 @@ from enum import Enum from typing import Set +from openlcb.nodeid import NodeID from openlcb.pip import PIP from openlcb.snip import SNIP from openlcb.localeventstore import LocalEventStore @@ -40,11 +41,12 @@ class Node: events (LocalEventStore): The store for local events associated with the node. """ - def __init__(self, nodeID, snip: SNIP = None, pipSet: Set[PIP] = None): - self.id = nodeID - self.snip = snip + def __init__(self, nodeID: NodeID, snip: SNIP = None, + pipSet: Set[PIP] = None): + self.id: NodeID = nodeID + self.snip: SNIP = snip if snip is None : self.snip = SNIP() - self.pipSet = pipSet + self.pipSet: Set[PIP] = pipSet if pipSet is None : self.pipSet = set([]) self.state = Node.State.Uninitialized self.events = LocalEventStore() diff --git a/openlcb/nodestore.py b/openlcb/nodestore.py index 3a9965b..f1d5cb4 100644 --- a/openlcb/nodestore.py +++ b/openlcb/nodestore.py @@ -1,4 +1,5 @@ from typing import ( + Dict, List, # in case list doesn't support `[` in this Python version Union, # in case `|` doesn't support 'type' in this Python version ) @@ -18,7 +19,7 @@ class NodeStore : ''' def __init__(self) : - self.byIdMap: dict = {} + self.byIdMap: Dict[NodeID, Node] = {} self.nodes: List[Node] = [] self.processors: List[Processor] = [] @@ -34,10 +35,10 @@ def store(self, node: Node) : self.nodes.sort(key=lambda x: x.snip.userProvidedNodeName, reverse=True) - def isPresent(self, nodeID) : + def isPresent(self, nodeID: NodeID) -> bool: return self.byIdMap.get(nodeID) is not None - def asArray(self) : + def asArray(self) -> List[Node]: return [self.byIdMap[i] for i in self.byIdMap] # Retrieve a Node's content from the store From 71544c8b107fa3a6197c809a7d801e2fc1ca2fa5 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Fri, 23 May 2025 18:37:54 -0400 Subject: [PATCH 92/99] Count the number of frames sent such as for testing issue #70 (needs confirmation) and #71. Helps validate pull request #67. --- examples/example_frame_interface.py | 4 +++- examples/example_tcp_message_interface.py | 11 ++++++++++- openlcb/linklayer.py | 5 ++++- openlcb/physicallayer.py | 3 ++- openlcb/realtimephysicallayer.py | 6 ++++-- openlcb/realtimerawphysicallayer.py | 9 +++++++-- openlcb/tcplink/tcplink.py | 2 +- tests/test_canlink.py | 6 ++++-- tests/test_canphysicallayer.py | 10 +++++++++- tests/test_canphysicallayergridconnect.py | 1 + 10 files changed, 45 insertions(+), 12 deletions(-) diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index 99ba266..453e304 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -49,7 +49,7 @@ def sendToSocket(frame: CanFrame): def handleFrameSent(frame): # No state to manage since no link layer - pass + physicalLayer._sentFramesCount += 1 def handleFrameReceived(frame): @@ -60,9 +60,11 @@ def handleFrameReceived(frame): def printFrame(frame): print("RL: {}".format(frame)) + def handleDisconnect(): print("Disconnected.") + physicalLayer = CanPhysicalLayerGridConnect() # NOTE: Normally the required handlers are set by link layer diff --git a/examples/example_tcp_message_interface.py b/examples/example_tcp_message_interface.py index aa5a888..53c6437 100644 --- a/examples/example_tcp_message_interface.py +++ b/examples/example_tcp_message_interface.py @@ -79,8 +79,17 @@ def printMessage(msg): # send an VerifyNodes message to provoke response message = Message(MTI.Verify_NodeID_Number_Global, NodeID(settings['localNodeID']), None) -print("SM: {}".format(message)) +print("Sending Message: {}...".format(message)) +previousCount = physicalLayer._sentFramesCount tcpLinkLayer.sendMessage(message, verbose=True) +thisSentCount = physicalLayer._sentFramesCount - previousCount +messageCount = 1 +assert thisSentCount == messageCount, \ + "Expected {} sent since realtime, got {}".format(messageCount, + thisSentCount) +# ^ Change the assertion if more than one message is required for some +# reason (expected one sent here instead of after sendAll *only* since +# using a Realtime subclass for physicalLayer in this example.) physicalLayer.sendAll(sock, verbose=True) # only a formality since Realtime # N/A # while not tcpLinkLayer.getState() == TcpLink.State.Permitted: diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index 8102819..933af9f 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -101,7 +101,10 @@ def pollState(self): def handleFrameSent(self, frame): """Update state based on the frame having been sent.""" - if frame.afterSendState is not None: + if self.physicalLayer: + self.physicalLayer._sentFramesCount += 1 + if (hasattr(frame, 'afterSendState') + and (frame.afterSendState is not None)): self.setState(frame.afterSendState) # may change again # since setState calls pollState via _onStateChanged. diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index 2a55c14..c2c5e53 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -56,11 +56,12 @@ class PhysicalLayer: """ def __init__(self): + self._sentFramesCount = 0 self._send_frames = deque() # self._send_chunks = deque() self.onQueuedFrame = None - def sendDataAfter(self, data: Union[bytes, bytearray]): + def sendDataAfter(self, data: Union[bytes, bytearray], verbose=False): raise NotImplementedError( "This method is only for Realtime subclass(es)" " (which should only be used when not using GridConnect" diff --git a/openlcb/realtimephysicallayer.py b/openlcb/realtimephysicallayer.py index 022a17c..91fa8de 100644 --- a/openlcb/realtimephysicallayer.py +++ b/openlcb/realtimephysicallayer.py @@ -46,8 +46,9 @@ def sendDataAfter(self, data: Union[bytearray, bytes], verbose=True): # ) assert isinstance(data, (bytes, bytearray)) if verbose: - print(" SR: {}".format(data)) + print("- SENT data (realtime): {}".format(data.strip())) self.sock.send(data) + self.onFrameSent(data) def sendFrameAfter(self, frame, verbose=False): """Send frame (immediately, since realtime subclass). @@ -73,11 +74,12 @@ def sendFrameAfter(self, frame, verbose=False): # .format(type(data).__name__, data) # ) if verbose: - print(" SR: {}".format(frame)) + print("- SENT frame (realtime): {}".format(frame)) # send and fireFrameReceived would usually occur after # frame from _send_frames.popleft is sent, # but we do all this here in the Realtime subclass: self.sock.send(data) + self.onFrameSent(data) if hasattr(frame, 'afterSendState') and frame.afterSendState: # Use hasattr since only applicable to subclasses that use # CanFrame. diff --git a/openlcb/realtimerawphysicallayer.py b/openlcb/realtimerawphysicallayer.py index de1e425..0197f8c 100644 --- a/openlcb/realtimerawphysicallayer.py +++ b/openlcb/realtimerawphysicallayer.py @@ -13,9 +13,14 @@ class RealtimeRawPhysicalLayer(RealtimePhysicalLayer, RawPhysicalLayer): See RealtimePhysicalLayer for more information. """ def sendFrameAfter(self, frame, verbose=False): - self.sendDataAfter(frame, verbose=verbose) + self._sendDataAfter(frame, verbose=verbose) + self.onFrameSent(frame) def sendDataAfter(self, data: Union[bytearray, bytes], verbose=False): + self._sendDataAfter(data, verbose=verbose) + self.onFrameSent(data) + + def _sendDataAfter(self, data: Union[bytearray, bytes], verbose=False): # ^ data for sendDataAfter, # For frame see sendFrameAfter. # verbose is only for Realtime subclass (since data is sent @@ -29,5 +34,5 @@ def sendDataAfter(self, data: Union[bytearray, bytes], verbose=False): data = data.encode("utf-8") assert isinstance(data, (bytes, bytearray)) if verbose: - print(" SR: {}".format(data)) + print("- SENT data (realtime raw): {}".format(data.strip())) self.sock.send(data) diff --git a/openlcb/tcplink/tcplink.py b/openlcb/tcplink/tcplink.py index 9e76c23..c990bab 100644 --- a/openlcb/tcplink/tcplink.py +++ b/openlcb/tcplink/tcplink.py @@ -238,6 +238,6 @@ def sendMessage(self, message: Message, verbose=False): outputBytes.extend(message.data) - self.physicalLayer.sendDataAfter(outputBytes) + self.physicalLayer.sendDataAfter(outputBytes, verbose=verbose) # ^ The physical layer should be one with "Raw" in the name # since takes bytes. See example_tcp_message_interface. diff --git a/tests/test_canlink.py b/tests/test_canlink.py index 9b7d5ef..f07426b 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -25,7 +25,8 @@ def __init__(self): self.sentFrames = [] CanPhysicalLayer.__init__(self) - def sendDataAfter(self, data): + def sendDataAfter(self, data, verbose=False): + # verbose: ignored since used in sendAll when not a Realtime subclass. assert isinstance(data, (bytes, bytearray)) self.sentFrames.append(data) @@ -58,7 +59,8 @@ def sendAll(self, _, mode="binary", verbose=True) -> int: string = frame.encodeAsString() # device.sendString(string) # commented since simulation if verbose: - print(" SENT (simulated socket) packet: "+string.strip()) + print("- SENT frame (simulated socket) packet: {}" + .format(string.strip())) self.physicalLayer.onFrameSent(frame) count += 1 return count diff --git a/tests/test_canphysicallayer.py b/tests/test_canphysicallayer.py index f8da1fa..7e140bf 100644 --- a/tests/test_canphysicallayer.py +++ b/tests/test_canphysicallayer.py @@ -9,6 +9,11 @@ class TestCanPhysicalLayerClass(unittest.TestCase): # test function marks that the listeners were fired received = False + def __init__(self, *args): + unittest.TestCase.__init__(self, *args) + self.layer = None + self._sentFramesCount = 0 + def receiveListener(self, frame: CanFrame): self.received = True @@ -16,13 +21,16 @@ def handleFrameReceived(self, frame: CanFrame): pass def handleFrameSent(self, frame: CanFrame): - pass + self._sentFramesCount += 1 + if self.layer: + self.layer._sentFramesCount += 1 def testReceipt(self): self.received = False frame = CanFrame(0x000, bytearray()) receiver = self.receiveListener layer = CanPhysicalLayer() + self.layer = layer layer.onFrameReceived = self.handleFrameReceived layer.onFrameSent = self.handleFrameSent layer.registerFrameReceivedListener(receiver) diff --git a/tests/test_canphysicallayergridconnect.py b/tests/test_canphysicallayergridconnect.py index 009ddb2..cbac5ee 100644 --- a/tests/test_canphysicallayergridconnect.py +++ b/tests/test_canphysicallayergridconnect.py @@ -30,6 +30,7 @@ def captureString(self, frame: CanFrame): def onFrameSent(self, frame: CanFrame): pass + self._sentFramesCount += 1 # NOTE: not patching this method to be canLink.handleFrameSent # since testing only physical layer not link layer. From f6c63611ff099cf3028635c55966cf2f261fc411 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Sat, 24 May 2025 09:03:18 -0400 Subject: [PATCH 93/99] Fix #71: Wait for all SNIP info to arrive; Do not use socket on two different threads! --- examples/example_remote_nodes.py | 39 ++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index 0bf7949..6a54350 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -124,7 +124,7 @@ def printMessage(msg): state = canLink.getState() if state == CanLink.State.Permitted: break - physicalLayer.receiveAll(sock, verbose=settings['trace']) + physicalLayer.receiveAll(sock, verbose=True) physicalLayer.sendAll(sock, verbose=True) @@ -140,15 +140,20 @@ def printMessage(msg): print("nodeIdToAlias: {}".format(canLink.nodeIdToAlias)) -def receiveLoop(): +def socketLoop(): """put the read on a separate thread""" while True: - physicalLayer.receiveAll(sock, verbose=settings['trace']) - precise_sleep(.01) + count = 0 + count += physicalLayer.sendAll(sock, verbose=True) + count += physicalLayer.receiveAll(sock, verbose=True) + if count < 1: + precise_sleep(.01) + # else no sleep (socket already delayed) + print("Stopped receiving.") import threading # noqa E402 -thread = threading.Thread(daemon=True, target=receiveLoop) +thread = threading.Thread(daemon=True, target=socketLoop) def result(arg1, arg2=None, arg3=None, result=True) : @@ -199,17 +204,23 @@ def result(arg1, arg2=None, arg3=None, result=True) : NodeID(settings['localNodeID']), None) if settings['trace'] : print("SM: {}".format(message)) canLink.sendMessage(message) -physicalLayer.sendAll(sock) -# pull the received messages -while True : - try : - received = readQueue.get(True, settings['timeout']) - if settings['trace']: - print("received: ", received) - except Empty: - break +# physicalLayer.sendAll(sock, verbose=True) # can't use port on 2 threads! +# (moved to socketLoop) + +# pull the received messages +# Commented since using verbose=True for receiveAll +# while True : +# # physicalLayer.sendAll(sock, verbose=True) +# try : +# received = readQueue.get(True, settings['timeout']) +# if settings['trace']: +# print("received: ", received) +# except Empty: +# break # print the resulting node store contents +print("\nWaiting for SNIP requests and responses...") +precise_sleep(2) # Wait for approximately all SNIP info to arrive. print("\nDiscovered nodes:") for node in remoteNodeStore.asArray() : From 6dce10745a297d081d21d24e9cf71044c7d08b63 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Wed, 28 May 2025 17:48:05 -0400 Subject: [PATCH 94/99] Fix docstring for example_node_implementation.py. --- examples/example_node_implementation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index e4b2fce..5aeb77d 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -1,5 +1,7 @@ ''' -Demo of using the datagram service to send and receive a datagram +Demo of creating a virtual node to represent the application +(other local nodes are possible, but at least one is necessary +for the application to announce itself and provide SNIP info). Usage: python3 example_node_implementation.py [host|host:port] From 6b3058f27b7156c2b1b07c8d378ea76c7b1f67b4 Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 29 May 2025 12:20:47 -0400 Subject: [PATCH 95/99] Add more type hints. --- openlcb/snip.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openlcb/snip.py b/openlcb/snip.py index 704dfa3..ab41c52 100644 --- a/openlcb/snip.py +++ b/openlcb/snip.py @@ -1,5 +1,6 @@ import logging +from typing import Union class SNIP: @@ -112,7 +113,7 @@ def findString(self, n: int) -> int: # fell out without finding return 0 - def getString(self, first, maxLength) -> str: + def getString(self, first: int, maxLength: int) -> str: """Get the string at index `first` ending with either a null or having maxLength, whichever comes first. @@ -131,7 +132,7 @@ def getString(self, first, maxLength) -> str: # terminate_i should point at the first zero or exclusive end return self.data[first:terminate_i].decode("utf-8") - def addData(self, in_data): + def addData(self, in_data: Union[bytearray, bytes]): '''Add additional bytes of SNIP data ''' for i in range(0, len(in_data)): From 988f906cbb7b9034f6ac603233d5d6fa5ad6682a Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 29 May 2025 12:23:34 -0400 Subject: [PATCH 96/99] Use Pythonic underscore convention for private LocalNodeProcessor methods. --- openlcb/localnodeprocessor.py | 40 ++++++++++++++--------------------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/openlcb/localnodeprocessor.py b/openlcb/localnodeprocessor.py index 7c5ed80..f0e0d9c 100644 --- a/openlcb/localnodeprocessor.py +++ b/openlcb/localnodeprocessor.py @@ -36,15 +36,15 @@ def process(self, message: Message, givenNode=None): return False # not to us # specific message handling if message.mti == MTI.Link_Layer_Up: - self.linkUpMessage(message, node) + self._linkUpMessage(message, node) elif message.mti == MTI.Link_Layer_Down: - self.linkDownMessage(message, node) + self._linkDownMessage(message, node) elif message.mti == MTI.Verify_NodeID_Number_Global: - self.verifyNodeIDNumberGlobal(message, node) + self._verifyNodeIDNumberGlobal(message, node) elif message.mti == MTI.Verify_NodeID_Number_Addressed: - self.verifyNodeIDNumberAddressed(message, node) + self._verifyNodeIDNumberAddressed(message, node) elif message.mti == MTI.Protocol_Support_Inquiry: - self.protocolSupportInquiry(message, node) + self._protocolSupportInquiry(message, node) elif message.mti in (MTI.Protocol_Support_Reply, MTI.Simple_Node_Ident_Info_Reply): # these are not relevant here @@ -59,18 +59,17 @@ def process(self, message: Message, givenNode=None): # DatagramService pass elif message.mti == MTI.Simple_Node_Ident_Info_Request: - self.simpleNodeIdentInfoRequest(message, node) + self._simpleNodeIdentInfoRequest(message, node) elif message.mti == MTI.Identify_Events_Addressed: - self.identifyEventsAddressed(message, node) + self._identifyEventsAddressed(message, node) elif message.mti in (MTI.Terminate_Due_To_Error, MTI.Optional_Interaction_Rejected): - self.errorMessageReceived(message, node) + self._errorMessageReceived(message, node) else: self._unrecognizedMTI(message, node) return False - # private method - def linkUpMessage(self, message: Message, node: Node): + def _linkUpMessage(self, message: Message, node: Node): node.state = Node.State.Initialized msgIC = Message(MTI.Initialization_Complete, node.id, None, node.id.toArray()) @@ -79,26 +78,22 @@ def linkUpMessage(self, message: Message, node: Node): # msgVN = Message( MTI.Verify_NodeID_Number_Global, node.id) # self.linkLayer.sendMessage(msgVN) - # private method - def linkDownMessage(self, message: Message, node: Node): + def _linkDownMessage(self, message: Message, node: Node): node.state = Node.State.Uninitialized - # private method - def verifyNodeIDNumberGlobal(self, message: Message, node: Node): + def _verifyNodeIDNumberGlobal(self, message: Message, node: Node): if not (len(message.data) == 0 or node.id == NodeID(message.data)): return # not to us msg = Message(MTI.Verified_NodeID, node.id, message.source, node.id.toArray()) self.linkLayer.sendMessage(msg) - # private method - def verifyNodeIDNumberAddressed(self, message: Message, node: Node): + def _verifyNodeIDNumberAddressed(self, message: Message, node: Node): msg = Message(MTI.Verified_NodeID, node.id, message.source, node.id.toArray()) self.linkLayer.sendMessage(msg) - # private method - def protocolSupportInquiry(self, message: Message, node: Node): + def _protocolSupportInquiry(self, message: Message, node: Node): pips = 0 for pip in node.pipSet: pips |= pip.value @@ -112,14 +107,12 @@ def protocolSupportInquiry(self, message: Message, node: Node): retval) self.linkLayer.sendMessage(msg) - # private method - def simpleNodeIdentInfoRequest(self, message: Message, node: Node): + def _simpleNodeIdentInfoRequest(self, message: Message, node: Node): msg = Message(MTI.Simple_Node_Ident_Info_Reply, node.id, message.source, node.snip.returnStrings()) self.linkLayer.sendMessage(msg) - # private method - def identifyEventsAddressed(self, message: Message, node: Node): + def _identifyEventsAddressed(self, message: Message, node: Node): '''EventProtocol in PIP, but no Events here to reply about; no reply necessary ''' @@ -150,7 +143,6 @@ def _unrecognizedMTI(self, message: Message, node: Node): (originalMTI & 0xFF)])) # permanent error self.linkLayer.sendMessage(msg) - # private method - def errorMessageReceived(self, message: Message, node: Node): + def _errorMessageReceived(self, message: Message, node: Node): # these are just logged until we have more complex interactions logging.info("received unexpected {}".format(message)) From f8f96a52468f45beaab349f8fcb9aa6e9b1c7e3d Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 29 May 2025 12:24:26 -0400 Subject: [PATCH 97/99] Add registration methods for SNIP and Producer/Consumer identified. Use Pythonic underscore convention for private methods. --- openlcb/remotenodeprocessor.py | 57 +++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/openlcb/remotenodeprocessor.py b/openlcb/remotenodeprocessor.py index 0e94bf8..fd1dcee 100644 --- a/openlcb/remotenodeprocessor.py +++ b/openlcb/remotenodeprocessor.py @@ -1,4 +1,5 @@ +from typing import Callable from openlcb.eventid import EventID from openlcb.linklayer import LinkLayer from openlcb.node import Node @@ -20,6 +21,20 @@ class RemoteNodeProcessor(Processor) : def __init__(self, linkLayer: LinkLayer = None) : self.linkLayer = linkLayer + self._nodeIdentifiedListeners = [] + self._producerUpdatedListeners = [] + self._consumerUpdatedListeners = [] + + def registerNodeIdentified(self, callback: Callable[[Node], None]): + self._nodeIdentifiedListeners.append(callback) + + def registerProducerUpdated(self, callback: Callable[[Node, EventID], + None]): + self._producerUpdatedListeners.append(callback) + + def registerConsumerUpdated(self, callback: Callable[[Node, EventID], + None]): + self._consumerUpdatedListeners.append(callback) def process(self, message: Message, node: Node) : """Do a fast drop of messages not to us, from us, or global @@ -44,35 +59,35 @@ def process(self, message: Message, node: Node) : # specific message handling if message.mti in (MTI.Initialization_Complete, MTI.Initialization_Complete_Simple) : # noqa: E501 - self.initializationComplete(message, node) + self._initializationComplete(message, node) return True elif message.mti == MTI.Protocol_Support_Reply : - self.protocolSupportReply(message, node) + self._protocolSupportReply(message, node) return True elif message.mti == MTI.Link_Layer_Up : - self.linkUpMessage(message, node) + self._linkUpMessage(message, node) elif message.mti == MTI.Link_Layer_Down : - self.linkDownMessage(message, node) + self._linkDownMessage(message, node) elif message.mti == MTI.Simple_Node_Ident_Info_Request : - self.simpleNodeIdentInfoRequest(message, node) + self._simpleNodeIdentInfoRequest(message, node) elif message.mti == MTI.Simple_Node_Ident_Info_Reply : - self.simpleNodeIdentInfoReply(message, node) + self._simpleNodeIdentInfoReply(message, node) return True elif message.mti in (MTI.Producer_Identified_Active, MTI.Producer_Identified_Inactive, MTI.Producer_Identified_Unknown, MTI.Producer_Consumer_Event_Report) : # noqa: E501 - self.producedEventIndicated(message, node) + self._producedEventIndicated(message, node) return True elif message.mti in (MTI.Consumer_Identified_Active, MTI.Consumer_Identified_Inactive, MTI.Consumer_Identified_Unknown) : # noqa: E501 - self.consumedEventIndicated(message, node) + self._consumedEventIndicated(message, node) return True elif message.mti == MTI.New_Node_Seen : - self.newNodeSeen(message, node) + self._newNodeSeen(message, node) return True else : # we ignore others return False return False - def initializationComplete(self, message: Message, node: Node) : + def _initializationComplete(self, message: Message, node: Node) : if self.checkSourceID(message, node) : # Send by us? node.state = Node.State.Initialized # clear out PIP, SNIP caches @@ -80,17 +95,17 @@ def initializationComplete(self, message: Message, node: Node) : node.pipSet = set(()) node.snip = SNIP() - def linkUpMessage(self, message: Message, node: Node) : + def _linkUpMessage(self, message: Message, node: Node) : # affects everybody node.state = Node.State.Uninitialized # don't clear out PIP, SNIP caches, they're probably still good - def linkDownMessage(self, message: Message, node: Node) : + def _linkDownMessage(self, message: Message, node: Node) : # affects everybody node.state = Node.State.Uninitialized # don't clear out PIP, SNIP caches, they're probably still good - def newNodeSeen(self, message: Message, node: Node) : + def _newNodeSeen(self, message: Message, node: Node) : # send pip and snip requests for info from the new node pip = Message(MTI.Protocol_Support_Inquiry, self.linkLayer.localNodeID, node.id, bytearray()) @@ -105,7 +120,7 @@ def newNodeSeen(self, message: Message, node: Node) : self.linkLayer.localNodeID, node.id, bytearray()) self.linkLayer.sendMessage(eventReq) - def protocolSupportReply(self, message: Message, node: Node) : + def _protocolSupportReply(self, message: Message, node: Node) : if self.checkSourceID(message, node) : # sent by us? part0 = ((message.data[0]) << 24) if (len(message.data) > 0) else 0 part1 = ((message.data[1]) << 16) if (len(message.data) > 1) else 0 @@ -115,28 +130,34 @@ def protocolSupportReply(self, message: Message, node: Node) : content = part0 | part1 | part2 | part3 node.pipSet = PIP.setContentsFromInt(content) - def simpleNodeIdentInfoRequest(self, message: Message, node: Node) : + def _simpleNodeIdentInfoRequest(self, message: Message, node: Node) : if self.checkDestID(message, node) : # sent by us? - overlapping SNIP activity is otherwise confusing # noqa: E501 # clear SNIP in the node to start accumulating node.snip = SNIP() - def simpleNodeIdentInfoReply(self, message: Message, node: Node) : + def _simpleNodeIdentInfoReply(self, message: Message, node: Node) : if self.checkSourceID(message, node) : # sent by this node? - overlapping SNIP activity is otherwise confusing # noqa: E501 # accumulate data in the node if len(message.data) > 2 : node.snip.addData(message.data) node.snip.updateStringsFromSnipData() + for callback in self._nodeIdentifiedListeners: + callback(node) - def producedEventIndicated(self, message: Message, node: Node) : + def _producedEventIndicated(self, message: Message, node: Node) : if self.checkSourceID(message, node) : # produced by this node? # make an event id from the data eventID = EventID(message.data) # register it node.events.produces(eventID) + for callback in self._producerUpdatedListeners: + callback(node, eventID) - def consumedEventIndicated(self, message: Message, node: Node) : + def _consumedEventIndicated(self, message: Message, node: Node) : if self.checkSourceID(message, node) : # consumed by this node? # make an event id from the data eventID = EventID(message.data) # register it node.events.consumes(eventID) + for callback in self._consumerUpdatedListeners: + callback(node, eventID) From 4317cd2f4027c099b5c6bc0a39e66aecb51ddc9c Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 29 May 2025 13:47:31 -0400 Subject: [PATCH 98/99] Use camelCase since used elsewhere. Rename GUI field fill methods to "fill*" for clarity. --- examples/examples_gui.py | 228 +++++++++++++++++---------------- examples/tkexamples/cdiform.py | 8 +- 2 files changed, 121 insertions(+), 115 deletions(-) diff --git a/examples/examples_gui.py b/examples/examples_gui.py index 6caaa49..4df911b 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -38,6 +38,8 @@ from openlcb import emit_cast, formatted_ex from openlcb.tcplink.mdnsconventions import id_from_tcp_service_name +from typing import OrderedDict as TypingOrderedDict + zeroconf_enabled = False try: from zeroconf import ServiceBrowser, ServiceListener, Zeroconf @@ -143,39 +145,39 @@ def __init__(self, parent): # have backed up & moved the bad JSON file: self.settings = Settings() self.detected_services = OrderedDict() - self.fields = OrderedDict() + self.fields: TypingOrderedDict[str, tk.Entry] = OrderedDict() self.proc = None self._gui(parent) - self.w1.after(1, self.on_form_loaded) # must go after gui + self.w1.after(1, self.onFormLoaded) # must go after gui self.example_modules = OrderedDict() self.example_buttons = OrderedDict() if zeroconf_enabled: self.zeroconf = Zeroconf() self.listener = MyListener() - self.listener.update_service = self.update_service - self.listener.remove_service = self.remove_service - self.listener.add_service = self.add_service - - def on_form_loaded(self): - self.load_settings() - self.load_examples() - count = self.show_next_error() + self.listener.update_service = self.updateService + self.listener.remove_service = self.removeService + self.listener.add_service = self.addService + + def onFormLoaded(self): + self.loadSettings() + self.loadExamples() + count = self.showNextError() if not count: - self.set_status( + self.setStatus( "Welcome!" ) # else show_next_error should have already set status label text. - def show_next_error(self): + def showNextError(self): if not self.errors: return 0 error = self.errors.popleft() if not error: return 0 - self.set_status(error) + self.setStatus(error) return 1 - def remove_examples(self): + def removeExamples(self): for module_name, button in self.example_buttons.items(): button.grid_forget() self.row -= 1 @@ -188,8 +190,8 @@ def remove_examples(self): self.example_buttons.clear() self.example_modules.clear() - def load_examples(self): - self.remove_examples() + def loadExamples(self): + self.removeExamples() self.example_row = 0 self.examples_label = ttk.Label( self.example_tab, @@ -202,7 +204,7 @@ def load_examples(self): self.run_button = ttk.Button( self.example_tab, text="Run", - command=self.run_example, + command=self.runExample, # command=lambda x=name: self.run_example(module_name=x), # x=name is necessary for early binding, otherwise all # lambdas will have the *last* value in the loop. @@ -236,7 +238,7 @@ def load_examples(self): self.example_buttons[name] = button self.example_row += 1 - def run_example(self, module_name=None): + def runExample(self, module_name=None): """Run the selected example. Args: @@ -248,26 +250,26 @@ def run_example(self, module_name=None): # for name, radiobutton in self.example_buttons.items(): index = self.example_var.get() if index is None: - self.set_status("Select an example first.") + self.setStatus("Select an example first.") return module_name = self.examples[index] - self.set_status("") + self.setStatus("") node_ids = ( self.fields['localNodeID'].get(), self.fields['farNodeID'].get(), ) for node_id in node_ids: if (":" in node_id) or ("." not in node_id): - self.set_status("Error: expected dot-separated ID") + self.setStatus("Error: expected dot-separated ID") return - self.save_settings() + self.saveSettings() module_path = self.example_modules[module_name] args = (sys.executable, module_path) - self.set_status("Running {} (see console for results)..." - "".format(module_name)) + self.setStatus("Running {} (see console for results)..." + .format(module_name)) - self.enable_buttons(False) + self.enableButtons(False) try: self.proc = subprocess.Popen( args, @@ -276,9 +278,9 @@ def run_example(self, module_name=None): # stdin=None, stdout=None, stderr=None, ) finally: - self.enable_buttons(True) + self.enableButtons(True) - def enable_buttons(self, enable): + def enableButtons(self, enable): state = tk.NORMAL if enable else tk.DISABLED if self.run_button: self.run_button.configure(state=state) @@ -287,13 +289,13 @@ def enable_buttons(self, enable): continue field.button.configure(state=state) - def load_settings(self): + def loadSettings(self): # import json # print(json.dumps(self.settings._meta, indent=1, sort_keys=True)) # print("[gui] self.settings['localNodeID']={}" # .format(self.settings['localNodeID'])) - for key, var in self.fields.items(): + for key in self.fields.keys(): if key not in self.settings: # The field must not be a setting. Don't try to load # (Avoid KeyError). @@ -302,7 +304,7 @@ def load_settings(self): # print("[gui] self.fields['localNodeID']={}" # .format(self.fields['localNodeID'].get())) - def save_settings(self): + def saveSettings(self): for key, field in self.fields.items(): if key not in self.settings: # Skip runtime GUI data fields such as @@ -348,26 +350,26 @@ def _gui(self, parent): self.parent.rowconfigure(0, weight=1) self.parent.columnconfigure(0, weight=1) self.row = 0 - self.add_field("service_name", - "TCP Service name (optional, sets host&port)", - gui_class=ttk.Combobox, tooltip="", - command=self.set_id_from_name, - command_text="Copy digits to Far Node ID") + self.addField("service_name", + "TCP Service name (optional, sets host&port)", + gui_class=ttk.Combobox, tooltip="", + command=self.setIdFromName, + 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, - command_text="Detect") - self.add_field( + self.fields["service_name"].var.trace('w', self.onServiceNameChange) + self.addField("host", "IP address/hostname", + command=self.detectHosts, + command_text="Detect") + self.addField( "port", "Port", - command=self.default_port, + command=self.fillDefaultPort, command_text="Default", ) - self.add_field( + self.addField( "localNodeID", "Local Node ID", - command=self.default_local_node_id, + command=self.fillDefaultLocalNodeId, command_text="Default", tooltip=('("05.01.01.01.03.01 for Python openlcb examples only:'), ) @@ -385,7 +387,7 @@ def _gui(self, parent): # A label is not a button, so must bind to mouse button event manually: self.local_node_url_label.bind( "", # Mouse button 1 (left click) - lambda e: self.open_url(self.unique_ranges_url) + lambda e: self.openUrl(self.unique_ranges_url) ) self.local_node_url_label.grid(row=self.row, column=self.tooltip_column, @@ -393,32 +395,32 @@ def _gui(self, parent): sticky=tk.N) self.row += 1 - self.add_field( + self.addField( "farNodeID", "Far Node ID", gui_class=ttk.Combobox, - command=self.detect_nodes, # TODO: finish detect_nodes & use + command=self.detectNodes, # TODO: finish detect_nodes & use command_text="Detect", # TODO: finish detect_nodes & use ) - self.add_field( + self.addField( "device", "Serial Device (or COM port)", gui_class=ttk.Combobox, - command=lambda: self.load_default("device"), + command=lambda: self.fillDefault("device"), command_text="Default", ) - self.add_field( + self.addField( "timeout", "Remote nodes timeout (seconds)", gui_class=ttk.Entry, ) - self.add_field( + self.addField( "trace", "Remote nodes logging", gui_class=ttk.Checkbutton, text="Trace", ) - # NOTE: load_examples (See on_form_loaded) fills Examples tab. + # NOTE: load_examples (See onFormLoaded) fills Examples tab. self.notebook = ttk.Notebook(self) self.notebook.grid(sticky=tk.NSEW, row=self.row, column=0, columnspan=self.column_count) @@ -436,20 +438,21 @@ def _gui(self, parent): self.cdi_connect_button = ttk.Button( self.cdi_tab, text="Connect", - command=self.cdi_connect_clicked, + command=self.cdiConnectClicked, ) self.cdi_connect_button.grid(row=self.cdi_row, column=0) self.cdi_refresh_button = ttk.Button( self.cdi_tab, text="Refresh", - command=self.cdi_refresh_clicked, + command=self.cdiRefreshClicked, state=tk.DISABLED, # enabled on connect success callback ) self.cdi_refresh_button.grid(row=self.cdi_row, column=1) self.cdi_row += 1 self.cdi_form = CDIForm(self.cdi_tab) # OpenLCBNetwork() subclass + # ^ CDIForm has ttk.Treeview etc. self.cdi_form.grid(row=self.cdi_row) self.example_tab = ttk.Frame(self.notebook) @@ -478,7 +481,7 @@ def _gui(self, parent): # self.rowconfigure(row, weight=1) # self.rowconfigure(self.row_count-1, weight=1) # make last row expand - def _connect_state_changed(self, event_d): + def _connectStateChanged(self, event_d): """Handle connection events. This is an example of how to handle different combinations of @@ -503,13 +506,13 @@ def _connect_state_changed(self, event_d): # logger.debug("Connect state changed: {}".format(event_d)) status = event_d.get('status') if status: - self.set_status(status) + self.setStatus(status) error = event_d.get('error') if error: if status: raise ValueError( "openlcb should set message or error, not both") - self.set_status(error) + self.setStatus(error) status = error logger.error("[_connect_state_changed] {}".format(error)) done = event_d.get('done') @@ -520,7 +523,7 @@ def _connect_state_changed(self, event_d): ready_message += " " + status if not error: self.cdi_refresh_button.configure(state=tk.NORMAL) - self.set_status(ready_message) + self.setStatus(ready_message) print(ready_message) else: # Only would be enabled if done without error before, @@ -529,9 +532,9 @@ def _connect_state_changed(self, event_d): # any read/write messages to the LCC network) in this # situation: self.cdi_refresh_button.configure(state=tk.DISABLED) - # Already called self.set_status(error) above. + # Already called self.setStatus(error) above. - def connect_state_changed(self, event_d): + def connectStateChanged(self, event_d): """Handle a dict event from a different thread by sending the event to the main (GUI) thread. @@ -556,7 +559,7 @@ def connect_state_changed(self, event_d): """ # Trigger the main thread (only the main thread can access the # GUI): - self.root.after(0, self._connect_state_changed, event_d) + self.root.after(0, self._connectStateChanged, event_d) return True # indicate that the message was handled. def _connect(self): @@ -571,29 +574,32 @@ def _connect(self): localNodeID_var = self.fields.get('localNodeID') localNodeID = localNodeID_var.get() # self.cdi_form.connect(host, port, localNodeID) - self.save_settings() + self.saveSettings() self.cdi_connect_button.configure(state=tk.DISABLED) self.cdi_refresh_button.configure(state=tk.DISABLED) msg = "connecting to {}...".format(host) - self.cdi_form.set_status(msg) - self.connect_state_changed({'status': msg}) # self._callback_msg(msg) + self.cdi_form.setStatus(msg) + self.connectStateChanged({'status': msg}) # self._callback_msg(msg) result = None try: self._tcp_socket = TcpSocket() # self._sock.settimeout(30) self._tcp_socket.connect(host, port) - self.cdi_form.setConnectHandler(self.connect_state_changed) + self.cdi_form.setConnectHandler(self.connectStateChanged) result = self.cdi_form.startListening( self._tcp_socket, localNodeID, ) self._connect_thread = None except Exception as ex: - self.set_status("Connect failed. {}".format(formatted_ex(ex))) + if self.cdi_form.getStatus() == msg: + # If error wasn't shown, clear startup message. + self.cdi_form.setStatus("") + self.setStatus("Connect failed. {}".format(formatted_ex(ex))) raise # show traceback still, in case in an IDE or Terminal. return result - def cdi_connect_clicked(self): + def cdiConnectClicked(self): self._connect_thread = threading.Thread( target=self._connect, daemon=True, # True prevents continuing when trying to exit @@ -602,15 +608,15 @@ def cdi_connect_clicked(self): # This thread may end quickly after connection since # start_receiving starts a thread. - def cdi_refresh_clicked(self): + def cdiRefreshClicked(self): self.cdi_connect_button.configure(state=tk.DISABLED) self.cdi_refresh_button.configure(state=tk.DISABLED) - farNodeID = self.get_value('farNodeID') + farNodeID = self.getValue('farNodeID') if not farNodeID: - self.set_status('Set "Far node ID" first.') + self.setStatus('Set "Far node ID" first.') return print("Querying farNodeID={}".format(repr(farNodeID))) - self.set_status("Downloading CDI...") + self.setStatus("Downloading CDI...") threading.Thread( target=self.cdi_form.downloadCDI, args=(farNodeID,), @@ -618,25 +624,25 @@ def cdi_refresh_clicked(self): daemon=True, ).start() - def get_value(self, key): + def getValue(self, key): field = self.fields.get(key) if not field: raise KeyError("Invalid form field {}".format(repr(key))) return field.get() - def set_id_from_name(self): - id = self.get_id_from_name(update_button=True) + def setIdFromName(self): + id = self.getIdFromName(update_button=True) if not id: - self.set_status( + self.setStatus( "The service name {} does not contain an LCC ID" " (Does not follow hardware convention).") return self.fields['farNodeID'].var.set(id) - self.set_status( + self.setStatus( "Far Node ID has been set to {} portion of service name." .format(repr(id))) - def get_id_from_name(self, update_button=False): + def getIdFromName(self, update_button=False): lcc_id = id_from_tcp_service_name( self.fields['service_name'].var.get()) if update_button: @@ -646,9 +652,9 @@ def get_id_from_name(self, update_button=False): self.fields["service_name"].button.configure(state=tk.NORMAL) return lcc_id - def on_service_name_change(self, index, value, op): + def onServiceNameChange(self, index, value, op): key = self.fields['service_name'].get() - _ = self.get_id_from_name(update_button=True) + _ = self.getIdFromName(update_button=True) info = self.detected_services.get(key) if not info: # The user may be typing, so don't spam screen with messages, @@ -658,11 +664,11 @@ def on_service_name_change(self, index, value, op): 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)) + self.setStatus("Hostname & Port have been set ({server}:{port})" + .format(**info)) - def add_field(self, key, caption, gui_class=ttk.Entry, command=None, - command_text=None, tooltip=None, text=None): + def addField(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 @@ -740,48 +746,48 @@ def add_field(self, key, caption, gui_class=ttk.Entry, command=None, if self.column > self.column_count: self.column_count = self.column - def open_url(self, url): + def openUrl(self, url): import webbrowser webbrowser.open_new_tab(url) - def default_local_node_id(self): - self.load_default('localNodeID') + def fillDefaultLocalNodeId(self): + self.fillDefault('localNodeID') - def default_port(self): - self.load_default('port') + def fillDefaultPort(self): + self.fillDefault('port') - def load_default(self, key): + def fillDefault(self, key): self.fields[key].set(self.settings.getDefault(key)) - def set_status(self, msg): + def setStatus(self, msg): self.statusLabel.configure(text=msg) - def set_tooltip(self, key, msg): + def setTooltip(self, key, msg): self.fields[key].tooltip.configure(text=msg) - def show_services(self): + def showServices(self): self.fields['service_name'].widget['values'] = \ list(self.detected_services.keys()) - def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: + def updateService(self, zc: Zeroconf, type_: str, name: str) -> None: if name in self.detected_services: self.detected_services[name]['type'] = type_ print(f"Service {name} updated") else: self.detected_services[name] = {'type': type_} print(f"Warning: {name} was not present yet during update.") - self.show_services() + self.showServices() - def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: + def removeService(self, zc: Zeroconf, type_: str, name: str) -> None: if name in self.detected_services: del self.detected_services[name] - self.set_status(f"{name} disconnected from the Wi-Fi/LAN") + self.setStatus(f"{name} disconnected from the Wi-Fi/LAN") print(f"Service {name} removed") else: print(f"Warning: {name} was already removed.") - self.show_services() + self.showServices() - def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: + def addService(self, zc: Zeroconf, type_: str, name: str) -> None: """ This must use name as key, since multiple services can be advertised by one server! @@ -800,40 +806,40 @@ def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: self.detected_services[name]['addresses'] = info.addresses # ^ addresses is a list of bytes objects # other info attributes: priority, weight, added, interface_index - self.set_tooltip( + self.setTooltip( 'service_name', f"Found {name} on Wi-Fi/LAN. Select an option above." ) print(f"Service {name} added, service info: {info}") else: print(f"Warning: {name} was already added.") - self.show_services() + self.showServices() - def detect_hosts(self, servicetype="_openlcb-can._tcp.local."): + def detectHosts(self, servicetype="_openlcb-can._tcp.local."): if not zeroconf_enabled: - self.set_status("The Python zeroconf package is not installed.") + self.setStatus("The Python zeroconf package is not installed.") return if not self.zeroconf: - self.set_status("Zeroconf was not initialized.") + self.setStatus("Zeroconf was not initialized.") return if not self.listener: - self.set_status("Listener was not initialized.") + self.setStatus("Listener was not initialized.") return if self.browser: - self.set_status("Already listening for {} devices." - .format(self.servicetype)) + self.setStatus("Already listening for {} devices." + .format(self.servicetype)) return self.servicetype = servicetype self.browser = ServiceBrowser(self.zeroconf, self.servicetype, self.listener) - self.set_status("Detecting hosts...") + self.setStatus("Detecting hosts...") - def detect_nodes(self): - self.set_status("Detecting nodes...") - self.set_status("Detecting nodes...not implemented here." - " See example_node_implementation.") + def detectNodes(self): + self.setStatus("Detecting nodes...") + self.setStatus("Detecting nodes...not implemented here." + " See example_node_implementation.") - def exit_clicked(self): + def exitClicked(self): self.top = self.winfo_toplevel() self.top.quit() diff --git a/examples/tkexamples/cdiform.py b/examples/tkexamples/cdiform.py index 52e1dac..ee9610f 100644 --- a/examples/tkexamples/cdiform.py +++ b/examples/tkexamples/cdiform.py @@ -91,7 +91,7 @@ def clear(self): widget = self._top_widgets.pop() widget.grid_forget() self._gui() - self.set_status("Display reset.") + self.setStatus("Display reset.") # def connect(self, new_socket, localNodeID, callback=None): # return OpenLCBNetwork.connect(self, new_socket, localNodeID, @@ -99,12 +99,12 @@ def clear(self): def downloadCDI(self, farNodeID: str, callback: Callable[[dict], None] = None): - self.set_status("Downloading CDI...") + self.setStatus("Downloading CDI...") self.ignore_non_gui_tags = deque() self._populating_stack = deque() super().downloadCDI(farNodeID, callback=callback) - def set_status(self, message: str): + def setStatus(self, message: str): self._status_var.set(message) def on_cdi_element(self, event_d: dict): @@ -141,7 +141,7 @@ def on_cdi_element(self, event_d: dict): elif done: show_status = "Done loading CDI." if show_status: - self.root.after(0, self.set_status, show_status) + self.root.after(0, self.setStatus, show_status) if done: return if event_d.get('end'): From 01a5b81ac3588304706ab2110b6bff5d3cf37a8e Mon Sep 17 00:00:00 2001 From: Jacob Gustafson <7557867+Poikilos@users.noreply.github.com> Date: Thu, 29 May 2025 15:51:09 -0400 Subject: [PATCH 99/99] Fix: Eliminate redundant onDisconnect (use physicalLayerDown to trigger state change and Message). --- examples/example_cdi_access.py | 2 +- examples/example_datagram_transfer.py | 2 +- examples/example_frame_interface.py | 5 - examples/example_memory_length_query.py | 2 +- examples/example_memory_transfer.py | 2 +- examples/example_message_interface.py | 2 +- examples/example_node_implementation.py | 2 +- examples/example_remote_nodes.py | 2 +- examples/examples_gui.py | 127 +++++++++--------------- openlcb/canbus/canphysicallayer.py | 2 - openlcb/linklayer.py | 19 ---- openlcb/openlcbnetwork.py | 81 +++++++-------- openlcb/physicallayer.py | 15 +-- tests/test_canlink.py | 56 +++++------ tests/test_remotenodeprocessor.py | 7 +- 15 files changed, 126 insertions(+), 200 deletions(-) diff --git a/examples/example_cdi_access.py b/examples/example_cdi_access.py index 14b4fd9..44145c1 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -311,7 +311,7 @@ def memoryRead(): if canLink.nodeIdToAlias != previous_nodes: previous_nodes = copy.deepcopy(canLink.nodeIdToAlias) -physicalLayer.onDisconnect() +physicalLayer.physicalLayerDown() if read_failed: print("Read complete (FAILED)") diff --git a/examples/example_datagram_transfer.py b/examples/example_datagram_transfer.py index 623b7b3..e5af2e3 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -138,4 +138,4 @@ def datagramWrite(): precise_sleep(.01) # else skip sleep to avoid latency (port already delayed) -physicalLayer.onDisconnect() +physicalLayer.physicalLayerDown() diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index 453e304..89ff1ee 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -61,17 +61,12 @@ def printFrame(frame): print("RL: {}".format(frame)) -def handleDisconnect(): - print("Disconnected.") - - physicalLayer = CanPhysicalLayerGridConnect() # NOTE: Normally the required handlers are set by link layer # constructor, but this example doesn't use a link layer: physicalLayer.onFrameSent = handleFrameSent physicalLayer.onFrameReceived = handleFrameReceived -physicalLayer.onDisconnect = handleDisconnect physicalLayer.registerFrameReceivedListener(printFrame) diff --git a/examples/example_memory_length_query.py b/examples/example_memory_length_query.py index b5d96f0..c9974de 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -162,4 +162,4 @@ def memoryRequest(): precise_sleep(.01) # else skip sleep to avoid latency (port already delayed) -physicalLayer.onDisconnect() +physicalLayer.physicalLayerDown() diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index 9225e03..6d0a3b9 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -159,4 +159,4 @@ def memoryRead(): precise_sleep(.01) # else skip sleep to avoid latency (port already delayed) -physicalLayer.onDisconnect() +physicalLayer.physicalLayerDown() diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index 099cc31..69af857 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -96,4 +96,4 @@ def printMessage(msg): precise_sleep(.01) # else skip sleep to avoid latency (port already delayed) -physicalLayer.onDisconnect() +physicalLayer.physicalLayerDown() diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index 5aeb77d..768f09f 100644 --- a/examples/example_node_implementation.py +++ b/examples/example_node_implementation.py @@ -170,4 +170,4 @@ def displayOtherNodeIds(message) : precise_sleep(.01) # else skip sleep to avoid latency (port already delayed) -physicalLayer.onDisconnect() +physicalLayer.physicalLayerDown() diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index 6a54350..21bdb5c 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -231,4 +231,4 @@ def result(arg1, arg2=None, arg3=None, result=True) : # For explicitness (to make this example match use in non-linear # application), notify openlcb of disconnect: -physicalLayer.onDisconnect() +physicalLayer.physicalLayerDown() diff --git a/examples/examples_gui.py b/examples/examples_gui.py index 4df911b..c7c7597 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -20,6 +20,9 @@ from logging import getLogger +from openlcb.message import Message +from openlcb.mti import MTI + try: import tkinter as tk except ImportError: @@ -382,7 +385,7 @@ def _gui(self, parent): # a tk.Font instance. self.local_node_url_label = ttk.Label( self, - text='See {})'.format(underlined_url), + text='See ({})'.format(underlined_url), ) # A label is not a button, so must bind to mouse button event manually: self.local_node_url_label.bind( @@ -453,6 +456,8 @@ def _gui(self, parent): self.cdi_row += 1 self.cdi_form = CDIForm(self.cdi_tab) # OpenLCBNetwork() subclass # ^ CDIForm has ttk.Treeview etc. + self.cdi_form.canLink.registerMessageReceivedListener( + self.handleMessage) self.cdi_form.grid(row=self.cdi_row) self.example_tab = ttk.Frame(self.notebook) @@ -481,86 +486,45 @@ def _gui(self, parent): # self.rowconfigure(row, weight=1) # self.rowconfigure(self.row_count-1, weight=1) # make last row expand - def _connectStateChanged(self, event_d): - """Handle connection events. + def handleMessage(self, message: Message): + """Off-thread message handler. + This is called by the OpenLCB network stack which is controlled + by the socket loop thread, so we must use self.root.after to + trigger methods which affect the GUI (such as _handleMessage). + """ + self.root.after(0, self._handleMessage(message)) - This is an example of how to handle different combinations of - errors and messages. It could be simplified slightly by having a - multi-line log panel, which would allow adding both 'error' and - 'message' on the same run on different lines (but still only - show ready_message if 'done' or not 'error'). + def _handleMessage(self, message: Message): + """Main thread Message handler. + Use self.root.after to trigger this, since code here affects the + GUI (Only main thread can access the GUI)! + """ + if message.mti == MTI.Link_Layer_Up: + self._handleConnect() + elif message.mti == MTI.Link_Layer_Down: + self._handleDisconnect() + + def _handleDisconnect(self): + """Handle Link_Layer_Up Message. + Affects GUI, so run from main thread or via self.root.after. + """ + # formerly part of _connectStateChanged + # formerly called from connectStateChanged such as on connect or + # _listen thread - This method must run on the main thread to affect the GUI, so it - is triggered indirectly (by connect_state_changed which runs on - the connect or _listen thread). + # Can't communicate with LCC network, so disable related widget(s): + self.cdi_refresh_button.configure(state=tk.DISABLED) + self.setStatus("LCC network disconnected.") - Args: - event_d (dict): Information sent by OpenLCBNetwork's - connect method during the connection steps - including alias reservation. Potential keys: - - 'error' (str): Indicates a failure - - 'status' (str): Status message - - 'done' (bool): Indicates the process is done, but - *only ready to send messages if 'error' is None*. + def _handleConnect(self): + """Handle Link_Layer_Down Message + Affects GUI, so run from main thread or via self.root.after. """ - # logger.debug("Connect state changed: {}".format(event_d)) - status = event_d.get('status') - if status: - self.setStatus(status) - error = event_d.get('error') - if error: - if status: - raise ValueError( - "openlcb should set message or error, not both") - self.setStatus(error) - status = error - logger.error("[_connect_state_changed] {}".format(error)) - done = event_d.get('done') - if done: - ready_message = 'Ready to load CDI (click "Refresh").' - # if event_d.get('command') == "connect": - if status: - ready_message += " " + status - if not error: - self.cdi_refresh_button.configure(state=tk.NORMAL) - self.setStatus(ready_message) - print(ready_message) - else: - # Only would be enabled if done without error before, - # but maybe connection went down, so disable the - # refresh button since we cannot read CDI (can't send - # any read/write messages to the LCC network) in this - # situation: - self.cdi_refresh_button.configure(state=tk.DISABLED) - # Already called self.setStatus(error) above. - - def connectStateChanged(self, event_d): - """Handle a dict event from a different thread - by sending the event to the main (GUI) thread. - - This handles changes in the network connection, whether - triggered by an LCC Message, TcpSocket or the OS's network - implementation (called by _listen directly unless triggered by - LCC Message). - - In this program, this is added to OpenLCBNetwork via - setConnectHandler. - - Therefore in this program, this is triggered during _listen in - OpenLCBNetwork: Connecting is actually done until - sendAliasAllocationSequence detects success and marks - canLink._state to CanLink.State.Permitted (which triggers - _handleMessage which calls this). - - May also be directly called by _listen directly in case - stopped listening (RuntimeError reading port, or other reason - lower in the stack than LCC). - - OpenLCBNetwork's _onConnect attribute is a method - reference to this if set via setConnectHandler. - """ - # Trigger the main thread (only the main thread can access the - # GUI): - self.root.after(0, self._connectStateChanged, event_d) - return True # indicate that the message was handled. + ready_message = 'Ready to load CDI (click "Refresh").' + # if event_d.get('command') == "connect": + self.cdi_refresh_button.configure(state=tk.NORMAL) + self.setStatus(ready_message) + print(ready_message) def _connect(self): host_var = self.fields.get('host') @@ -579,13 +543,16 @@ def _connect(self): self.cdi_refresh_button.configure(state=tk.DISABLED) msg = "connecting to {}...".format(host) self.cdi_form.setStatus(msg) - self.connectStateChanged({'status': msg}) # self._callback_msg(msg) + detectButton = self.fields['farNodeID'].button + detectButton.configure(state=tk.NORMAL) + result = None try: self._tcp_socket = TcpSocket() # self._sock.settimeout(30) self._tcp_socket.connect(host, port) - self.cdi_form.setConnectHandler(self.connectStateChanged) + # self.cdi_form.setConnectHandler(self.connectStateChanged) + # ^ See message.mti == MTI Link_Layer_Down instead. result = self.cdi_form.startListening( self._tcp_socket, localNodeID, @@ -607,6 +574,8 @@ def cdiConnectClicked(self): self._connect_thread.start() # This thread may end quickly after connection since # start_receiving starts a thread. + self.cdi_connect_button.configure(state=tk.DISABLED) + self.cdi_connect_button.configure(state=tk.DISABLED) def cdiRefreshClicked(self): self.cdi_connect_button.configure(state=tk.DISABLED) diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index 756bfa7..5edeed9 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -10,7 +10,6 @@ from openlcb.canbus.canframe import CanFrame from openlcb.canbus.controlframe import ControlFrame -from openlcb.linklayer import LinkLayer from openlcb.physicallayer import PhysicalLayer logger = getLogger(__name__) @@ -24,7 +23,6 @@ class CanPhysicalLayer(PhysicalLayer): def __init__(self,): PhysicalLayer.__init__(self) - self.linkLayer: LinkLayer = None # CanLink would be circular import self._frameReceivedListeners: list[Callable[[CanFrame], None]] = [] def sendFrameAfter(self, frame: CanFrame): diff --git a/openlcb/linklayer.py b/openlcb/linklayer.py index 933af9f..1f3d3fd 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -60,7 +60,6 @@ def __init__(self, physicalLayer: PhysicalLayer, localNodeID): # operator not work as expected in registerFrameReceivedListener. physicalLayer.onFrameReceived = self.handleFrameReceived physicalLayer.onFrameSent = self.handleFrameSent - physicalLayer.onDisconnect = self.handleDisconnect physicalLayer.linkLayer = self # # ^ enforce queue paradigm (See use in PhysicalLayer subclass) # physicalLayer.registerFrameReceivedListener(listener) @@ -108,24 +107,6 @@ def handleFrameSent(self, frame): self.setState(frame.afterSendState) # may change again # since setState calls pollState via _onStateChanged. - def handleDisconnect(self): - """Run this whenever the socket connection is lost - and override _onStateChanged to handle the change. - * If you override this, you *must* call - `LinkLayer.handleDisconnect(self)` (such as via - physicalLayer.onDisconnect) to trigger _onStateChanged if the - implementation utilizes getState. - * Override this in each subclass or state won't match! - """ - if type(self).__name__ != "LinkLayer": - # ^ Use name, since isinstance returns True for any subclass. - if isinstance(type(self).DisconnectedState, LinkLayer.State): - raise NotImplementedError( - " LinkLayer.State and LinkLayer.DisconnectedState" - " are only for testing. Redefine them in each subclass.") - - self.setState(type(self).DisconnectedState) - def getState(self): return self._state diff --git a/openlcb/openlcbnetwork.py b/openlcb/openlcbnetwork.py index 5e18fcc..f6e1e4d 100644 --- a/openlcb/openlcbnetwork.py +++ b/openlcb/openlcbnetwork.py @@ -145,6 +145,32 @@ def __init__(self, *args, **kwargs): self._connectingStart: float = None + self._fireStatus("CanPhysicalLayerGridConnect...") + self.physicalLayer = CanPhysicalLayerGridConnect() + self._fireStatus("CanLink...") + self.canLink = CanLink(self.physicalLayer, NodeID(localNodeID)) + # ^ CanLink constructor sets _physicalLayer's onFrameReceived + # and onFrameSent to handlers in _canLink. + self._fireStatus("CanLink...registerMessageReceivedListener...") + self.canLink.registerMessageReceivedListener(self._handleMessage) + # NOTE: Incoming data (Memo) is handled by _memoryReadSuccess + # and _memoryReadFail. + # - These are set when constructing the MemoryReadMemo which + # is provided to openlcb's requestMemoryRead method. + + self._fireStatus("DatagramService...") + self._datagramService = DatagramService(self.canLink) + self.canLink.registerMessageReceivedListener( + self._datagramService.process + ) + + self._datagramService.registerDatagramReceivedListener( + self._printDatagram + ) + + self._fireStatus("MemoryService...") + self._memoryService = MemoryService(self._datagramService) + def _resetTree(self): self.etree = ET.Element("root") self._openEl = self.etree @@ -167,6 +193,12 @@ def setElementHandler(self, handler: Callable): self._onElement = handler def setConnectHandler(self, handler: Callable): + """Deprecated in favor of a Message handler, + Since it is the socket loop's responsibility to call + physicalLayerUp and physicalLayerDown, and those each trigger a + Message (See Link_Layer_Up and Link_Layer_Down in _handleMessage + in examples_gui.py) + """ self._onConnect = handler def startListening(self, connected_port, @@ -175,53 +207,10 @@ def startListening(self, connected_port, logger.warning( "[startListening] A previous _port will be discarded.") self._port = connected_port - self._fireStatus("CanPhysicalLayerGridConnect...") - self.physicalLayer = CanPhysicalLayerGridConnect() - - self._fireStatus("CanLink...") - self.canLink = CanLink(self.physicalLayer, NodeID(localNodeID)) - # ^ CanLink constructor sets _physicalLayer's onFrameReceived - # and onFrameSent to handlers in _canLink. - self._fireStatus("CanLink...registerMessageReceivedListener...") - self.canLink.registerMessageReceivedListener(self._handleMessage) - # NOTE: Incoming data (Memo) is handled by _memoryReadSuccess - # and _memoryReadFail. - # - These are set when constructing the MemoryReadMemo which - # is provided to openlcb's requestMemoryRead method. - - self._fireStatus("DatagramService...") - self._datagramService = DatagramService(self.canLink) - self.canLink.registerMessageReceivedListener( - self._datagramService.process - ) - - self._datagramService.registerDatagramReceivedListener( - self._printDatagram - ) - - self._fireStatus("MemoryService...") - self._memoryService = MemoryService(self._datagramService) self._fireStatus("listen...") - self.listen() # Must listen for alias reservation responses - # (sendAliasConnectionSequence will occur for another 200ms - # once, then another 200ms on each alias collision if any) - # - must also keep doing frame = pollFrame() and sending - # if not None. - - self._fireStatus("physicalLayerUp...") - self.physicalLayer.physicalLayerUp() - self._fireStatus("Waiting for alias reservation...") - while self.canLink.pollState() != CanLink.State.Permitted: - precise_sleep(.02) - # ^ triggers fireFrameReceived which calls CanLink's default - # receiveListener by default since added on CanPhysicalLayer - # arg of linkPhysicalLayer. - # - Must happen *after* listen thread starts, since - # fireFrameReceived (ControlFrame.LinkUp) - # calls sendAliasConnectionSequence on this thread! - self._fireStatus("Alias reservation complete.") + self.listen() def listen(self): self._listenThread = threading.Thread( @@ -244,6 +233,8 @@ def _receive(self) -> bytearray: return self._port.receive() def _listen(self): + self._fireStatus("physicalLayerUp...") + self.physicalLayer.physicalLayerUp() self._connectingStart = time.perf_counter() self._messageStart = None self._mode = OpenLCBNetwork.Mode.Idle # Idle until data type is known @@ -319,7 +310,7 @@ def _listen(self): self._mode = OpenLCBNetwork.Mode.Disconnected raise # re-raise since incomplete (prevent done OK state) finally: - self.physicalLayer.onDisconnect() + self.physicalLayer.physicalLayerDown() # Link_Layer_Down, setState self._listenThread: threading.Thread = None self._mode = OpenLCBNetwork.Mode.Disconnected diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index c2c5e53..a242bb1 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -60,6 +60,8 @@ def __init__(self): self._send_frames = deque() # self._send_chunks = deque() self.onQueuedFrame = None + self.linkLayer = None # LinkLayer would be circular import + # so no type hint...o dear. def sendDataAfter(self, data: Union[bytes, bytearray], verbose=False): raise NotImplementedError( @@ -163,19 +165,6 @@ def onFrameReceived(self, frame): " Set this method manually to LinkLayer/subclass instance's" " handleFrameReceived method.") - def onDisconnect(self, frame): - """Stub method, patched at runtime: - LinkLayer subclass's constructor must set instance's - onDisconnect to LinkLayer subclass' handleDisconnect (The - application must pass this instance to LinkLayer subclass's - constructor so it will do that). - """ - raise NotImplementedError( - "The subclass must patch the instance:" - " PhysicalLayer instance's onDisconnect must be manually" - " set to the LinkLayer subclass instance' handleDisconnect" - " so state can be updated if necessary.") - def onFrameSent(self, frame): """Stub method, patched at runtime: LinkLayer subclass's constructor must set instance's onFrameSent diff --git a/tests/test_canlink.py b/tests/test_canlink.py index f07426b..253c7a4 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -114,7 +114,7 @@ def testIncrementAlias48(self): # test shift and multiplication operations next = canLink.incrementAlias48(0x0000_0000_0001) self.assertEqual(next, 0x1B0C_A37A_4DAA) - physicalLayer.onDisconnect() + physicalLayer.physicalLayerDown() def testIncrementAliasSequence(self): physicalLayer = PhyMockLayer() @@ -135,7 +135,7 @@ def testIncrementAliasSequence(self): next = canLink.incrementAlias48(next) self.assertEqual(next, 0xE5_82_F9_B4_AE_4D) - physicalLayer.onDisconnect() + physicalLayer.physicalLayerDown() def testCreateAlias12(self): physicalLayer = PhyMockLayer() @@ -154,7 +154,7 @@ def testCreateAlias12(self): self.assertEqual(canLink.createAlias12(0x0000), 0xAEF, "zero input check") - physicalLayer.onDisconnect() + physicalLayer.physicalLayerDown() # MARK: - Test PHY Up def testLinkUpSequence(self): @@ -171,7 +171,7 @@ def testLinkUpSequence(self): self.assertEqual(canLink._state, CanLink.State.Permitted) self.assertEqual(len(messageLayer.receivedMessages), 1) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() # MARK: - Test PHY Down, Up, Error Information def testLinkDownSequence(self): @@ -185,7 +185,7 @@ def testLinkDownSequence(self): self.assertEqual(canLink._state, CanLink.State.Inhibited) self.assertEqual(len(messageLayer.receivedMessages), 1) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testEIR2NoData(self): canPhysicalLayer = CanPhysicalLayerSimulation() @@ -195,7 +195,7 @@ def testEIR2NoData(self): canPhysicalLayer.fireFrameReceived( CanFrame(ControlFrame.EIR2.value, 0)) self.assertEqual(len(canPhysicalLayer.sentFrames), 0) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() # MARK: - Test AME (Local Node) def testAMENoData(self): @@ -212,7 +212,7 @@ def testAMENoData(self): CanFrame(ControlFrame.AMD.value, ourAlias, canLink.localNodeID.toArray()) ) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testAMEnoDataInhibited(self): canPhysicalLayer = CanPhysicalLayerSimulation() @@ -221,7 +221,7 @@ def testAMEnoDataInhibited(self): canPhysicalLayer.fireFrameReceived(CanFrame(ControlFrame.AME.value, 0)) self.assertEqual(len(canPhysicalLayer.sentFrames), 0) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testAMEMatchEvent(self): canPhysicalLayer = CanPhysicalLayerSimulation() @@ -238,7 +238,7 @@ def testAMEMatchEvent(self): canPhysicalLayer.sentFrames[0], CanFrame(ControlFrame.AMD.value, ourAlias, canLink.localNodeID.toArray())) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testAMENotMatchEvent(self): canPhysicalLayer = CanPhysicalLayerSimulation() @@ -249,7 +249,7 @@ def testAMENotMatchEvent(self): frame.data = bytearray([0, 0, 0, 0, 0, 0]) canPhysicalLayer.fireFrameReceived(frame) self.assertEqual(len(canPhysicalLayer.sentFrames), 0) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() # MARK: - Test Alias Collisions (Local Node) def testCIDreceivedMatch(self): @@ -264,7 +264,7 @@ def testCIDreceivedMatch(self): self.assertEqual(len(canPhysicalLayer.sentFrames), 1) self.assertFrameEqual(canPhysicalLayer.sentFrames[0], CanFrame(ControlFrame.RID.value, ourAlias)) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testRIDreceivedMatch(self): canPhysicalLayer = CanPhysicalLayerSimulation() @@ -287,7 +287,7 @@ def testRIDreceivedMatch(self): CanFrame(ControlFrame.AMD.value, 0x539, bytearray([5, 1, 1, 1, 3, 1]))) # new alias self.assertEqual(canLink._state, CanLink.State.Permitted) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testCheckMTIMapping(self): @@ -305,7 +305,7 @@ def testControlFrameDecode(self): frame = CanFrame(0x1000, 0x000) # invalid control frame content self.assertEqual(canLink.decodeControlFrameFormat(frame), ControlFrame.UnknownFormat) - physicalLayer.onDisconnect() + physicalLayer.physicalLayerDown() def testControlFrameIsInternal(self): self.assertFalse(ControlFrame.isInternal(ControlFrame.AMD)) @@ -366,7 +366,7 @@ def testSimpleGlobalData(self): MTI.Verify_NodeID_Number_Global) self.assertEqual(messageLayer.receivedMessages[0].source, NodeID(0x010203040506)) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testVerifiedNodeInDestAliasMap(self): # JMRI doesn't send AMD, so gets assigned 00.00.00.00.00.00 @@ -393,7 +393,7 @@ def testVerifiedNodeInDestAliasMap(self): MTI.Verified_NodeID) self.assertEqual(messageLayer.receivedMessages[0].source, NodeID(0x080706050403)) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testNoDestInAliasMap(self): '''Tests handling of a message with a destination alias not in map @@ -421,7 +421,7 @@ def testNoDestInAliasMap(self): MTI.Identify_Events_Addressed) self.assertEqual(messageLayer.receivedMessages[0].source, NodeID(0x000000000001)) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testSimpleAddressedData(self): # Test start=yes, end=yes frame canPhysicalLayer = CanPhysicalLayerSimulation() @@ -457,7 +457,7 @@ def testSimpleAddressedData(self): # Test start=yes, end=yes frame self.assertEqual(len(messageLayer.receivedMessages[1].data), 2) self.assertEqual(messageLayer.receivedMessages[1].data[0], 12) self.assertEqual(messageLayer.receivedMessages[1].data[1], 13) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testSimpleAddressedDataNoAliasYet(self): '''Test start=yes, end=yes frame with no alias match''' @@ -493,7 +493,7 @@ def testSimpleAddressedDataNoAliasYet(self): self.assertEqual(len(messageLayer.receivedMessages[1].data), 2) self.assertEqual(messageLayer.receivedMessages[1].data[0], 12) self.assertEqual(messageLayer.receivedMessages[1].data[1], 13) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testMultiFrameAddressedData(self): '''multi-frame addressed messages - SNIP reply @@ -539,7 +539,7 @@ def testMultiFrameAddressedData(self): NodeID(0x01_02_03_04_05_06)) self.assertEqual(messageLayer.receivedMessages[1].destination, NodeID(0x05_01_01_01_03_01)) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testSimpleDatagram(self): # Test start=yes, end=yes frame canPhysicalLayer = CanPhysicalLayerSimulation() @@ -577,7 +577,7 @@ def testSimpleDatagram(self): # Test start=yes, end=yes frame self.assertEqual(messageLayer.receivedMessages[1].data[1], 11) self.assertEqual(messageLayer.receivedMessages[1].data[2], 12) self.assertEqual(messageLayer.receivedMessages[1].data[3], 13) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testMultiFrameDatagram(self): canPhysicalLayer = CanPhysicalLayerSimulation() @@ -639,7 +639,7 @@ def testMultiFrameDatagram(self): self.assertEqual(messageLayer.receivedMessages[1].data[9], 31) self.assertEqual(messageLayer.receivedMessages[1].data[10], 32) self.assertEqual(messageLayer.receivedMessages[1].data[11], 33) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testZeroLengthDatagram(self): canPhysicalLayer = PhyMockLayer() @@ -653,7 +653,7 @@ def testZeroLengthDatagram(self): self.assertEqual(len(canPhysicalLayer._send_frames), 1) self.assertEqual(str(canPhysicalLayer._send_frames[0]), "CanFrame header: 0x1A000000 []") - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testOneFrameDatagram(self): canPhysicalLayer = PhyMockLayer() @@ -670,7 +670,7 @@ def testOneFrameDatagram(self): str(canPhysicalLayer._send_frames[0]), "CanFrame header: 0x1A000000 [1, 2, 3, 4, 5, 6, 7, 8]" ) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testTwoFrameDatagram(self): canPhysicalLayer = PhyMockLayer() @@ -692,7 +692,7 @@ def testTwoFrameDatagram(self): str(canPhysicalLayer._send_frames[1]), "CanFrame header: 0x1D000000 [9, 10, 11, 12, 13, 14, 15, 16]" ) - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() def testThreeFrameDatagram(self): # FIXME: Why was testThreeFrameDatagram named same? What should it be? @@ -718,7 +718,7 @@ def testThreeFrameDatagram(self): ) self.assertEqual(str(canPhysicalLayer._send_frames[2]), "CanFrame header: 0x1D000000 [17, 18, 19]") - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() # MARK: - Test Remote Node Alias Tracking def testAmdAmrSequence(self): @@ -743,7 +743,7 @@ def testAmdAmrSequence(self): self.assertEqual(len(canPhysicalLayer.sentFrames), 0) # ^ nothing back down to CAN - canPhysicalLayer.onDisconnect() + canPhysicalLayer.physicalLayerDown() # MARK: - Data size handling def testSegmentAddressedDataArray(self): @@ -791,7 +791,7 @@ def testSegmentAddressedDataArray(self): [bytearray([0x11,0x23, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6]), # noqa:E231 bytearray([0x31,0x23, 0x7, 0x8, 0x9, 0xA, 0xB, 0xC]), # noqa:E231 bytearray([0x21, 0x23, 0xD, 0xE])]) # noqa: E231 - physicalLayer.onDisconnect() + physicalLayer.physicalLayerDown() def testSegmentDatagramDataArray(self): physicalLayer = PhyMockLayer() @@ -840,7 +840,7 @@ def testSegmentDatagramDataArray(self): [bytearray([0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8]), bytearray([0x9, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF, 0x10]), bytearray([0x11])]) # noqa: E501 - physicalLayer.onDisconnect() + physicalLayer.physicalLayerDown() def testEnum(self): usedValues = set() diff --git a/tests/test_remotenodeprocessor.py b/tests/test_remotenodeprocessor.py index b25452c..3c6a5b7 100644 --- a/tests/test_remotenodeprocessor.py +++ b/tests/test_remotenodeprocessor.py @@ -13,7 +13,10 @@ class MockPhysicalLayer(PhysicalLayer): - pass + def physicalLayerDown(self): + # Usually this would trigger LinkLayerDown using a CanFrame, + # but limit test to RemoteNodeProcessor as much as possible + pass class TesRemoteNodeProcessorClass(unittest.TestCase): @@ -25,7 +28,7 @@ def setUp(self) : self.processor = RemoteNodeProcessor(self.canLink) def tearDown(self): - self.physicalLayer.onDisconnect() + self.physicalLayer.physicalLayerDown() def testInitializationComplete(self) : # not related to node