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' diff --git a/.gitignore b/.gitignore index 3f3d8ae..9f4e116 100644 --- a/.gitignore +++ b/.gitignore @@ -183,3 +183,5 @@ cython_debug/ /build /doc/_autosummary /examples/settings.json +/.venv-3.12/ +/cached-cdi.xml 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/example_cdi_access.py b/examples/example_cdi_access.py index f668854..44145c1 100644 --- a/examples/example_cdi_access.py +++ b/examples/example_cdi_access.py @@ -11,24 +11,26 @@ address and port. ''' # region same code as other examples +import copy +# 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.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 ( +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, ) @@ -42,18 +44,20 @@ # 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;" # " RL, SL are link interface; RM, SM are message interface") -def sendToSocket(string): - # print(" SR: {}".format(string.strip())) - s.send(string) +# def sendToSocket(frame: CanFrame): +# string = frame.encodeAsString() +# # print(" SR: {}".format(string.strip())) +# sock.sendString(string) +# physicalLayer.onFrameSent(frame) def printFrame(frame): @@ -80,11 +84,10 @@ def printDatagram(memo): return False -canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(sendToSocket) -canPhysicalLayerGridConnect.registerFrameReceivedListener(printFrame) +physicalLayer = CanPhysicalLayerGridConnect() +physicalLayer.registerFrameReceivedListener(printFrame) -canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(canPhysicalLayerGridConnect) +canLink = CanLink(physicalLayer, NodeID(settings['localNodeID'])) canLink.registerMessageReceivedListener(printMessage) datagramService = DatagramService(canLink) @@ -100,6 +103,9 @@ def printDatagram(memo): # callbacks to get results of memory read +complete_data = False +read_failed = False + def memoryReadSuccess(memo): """Handle a successful read @@ -113,6 +119,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: @@ -139,12 +146,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 ####################### @@ -161,54 +171,57 @@ def memoryReadFail(memo): class MyHandler(xml.sax.handler.ContentHandler): - """XML SAX callbacks in a handler object""" - def __init__(self): - self._charBuffer = bytearray() + """XML SAX callbacks in a handler object - def startElement(self, name, attrs): - """_summary_ + Attributes: + _chunks (list[str]): Collects chunks of data. + This is implementation-specific, and not + required if streaming (parser.feed). + """ - Args: - name (_type_): _description_ - attrs (_type_): _description_ - """ + def __init__(self): + self._chunks = [] + + 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): - """_summary_ - - Args: - name (_type_): _description_ - """ + def endElement(self, name: str): + """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 + def characters(self, data: str): + """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() -def processXML(content) : +def processXML(content: str) : """process the XML and invoke callbacks Args: @@ -218,6 +231,11 @@ 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: + # 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") @@ -225,8 +243,25 @@ 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() +print(" QUEUE frames : link up...") +physicalLayer.physicalLayerUp() +print(" QUEUED frames : link up...waiting...") +while canLink.pollState() != CanLink.State.Permitted: + # provides incoming data to physicalLayer & sends queued: + physicalLayer.receiveAll(sock, verbose=True) + physicalLayer.sendAll(sock) + + if canLink.getState() == CanLink.State.WaitForAliases: + # physicalLayer.receiveAll(sock, verbose=True) + physicalLayer.sendAll(sock) + # ^ 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())) + precise_sleep(.02) +print(" SENT frames : link up") def memoryRead(): @@ -236,8 +271,16 @@ def memoryRead(): to AME """ import time - time.sleep(1) - + time.sleep(.21) + # ^ 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 (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 memMemo = MemoryReadMemo(NodeID(settings['farNodeID']), 64, 0xFF, 0, memoryReadFail, memoryReadSuccess) @@ -247,10 +290,30 @@ def memoryRead(): import threading # noqa E402 thread = threading.Thread(target=memoryRead) thread.start() - +previous_nodes = copy.deepcopy(canLink.nodeIdToAlias) # process resulting activity -while True: - received = s.receive() - # print(" RR: {}".format(received.strip())) - # pass to link processor - canPhysicalLayerGridConnect.receiveString(received) +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 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). + count = 0 + count += physicalLayer.receiveAll(sock) + count += physicalLayer.sendAll(sock) + if canLink.nodeIdToAlias != previous_nodes: + print("nodeIdToAlias updated: {}".format(canLink.nodeIdToAlias)) + 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) + +physicalLayer.physicalLayerDown() + +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 50b2256..e5af2e3 100644 --- a/examples/example_datagram_transfer.py +++ b/examples/example_datagram_transfer.py @@ -11,21 +11,22 @@ ''' # 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 settings = Settings() if __name__ == "__main__": settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -import threading +import threading # noqa:E402 -from openlcb.canbus.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, ) @@ -36,35 +37,36 @@ # port = 12021 # endregion replaced by settings -localNodeID = "05.01.01.01.03.01" -farNodeID = "09.00.99.03.00.35" -s = TcpSocket() +# localNodeID = "05.01.01.01.03.01" +# farNodeID = "09.00.99.03.00.35" +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") -def sendToSocket(string): - print(" SR: "+string.strip()) - s.send(string) +# def sendToSocket(frame: CanFrame): +# string = frame.encodeAsString() +# print(" SR: "+string.strip()) +# sock.sendString(string) +# physicalLayer.onFrameSent(frame) def printFrame(frame): print(" RL: "+str(frame)) -canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(sendToSocket) -canPhysicalLayerGridConnect.registerFrameReceivedListener(printFrame) +physicalLayer = CanPhysicalLayerGridConnect() +physicalLayer.registerFrameReceivedListener(printFrame) def printMessage(message): print("RM: {} from {}".format(message, message.source)) -canLink = CanLink(NodeID(localNodeID)) -canLink.linkPhysicalLayer(canPhysicalLayerGridConnect) +canLink = CanLink(physicalLayer, NodeID(settings['localNodeID'])) canLink.registerMessageReceivedListener(printMessage) datagramService = DatagramService(canLink) @@ -95,8 +97,16 @@ def datagramReceiver(memo): ####################### # 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...") +physicalLayer.physicalLayerUp() print(" SL : link up") -canPhysicalLayerGridConnect.physicalLayerUp() + +while canLink.pollState() != CanLink.State.Permitted: + physicalLayer.receiveAll(sock, verbose=settings['trace']) + physicalLayer.sendAll(sock) + precise_sleep(.02) def datagramWrite(): @@ -109,8 +119,8 @@ def datagramWrite(): time.sleep(1) writeMemo = DatagramWriteMemo( - NodeID(farNodeID), - [0x20, 0x43, 0x00, 0x00, 0x00, 0x00, 0x14], + NodeID(settings['farNodeID']), + bytearray([0x20, 0x43, 0x00, 0x00, 0x00, 0x00, 0x14]), writeCallBackCheck ) datagramService.sendDatagram(writeMemo) @@ -121,7 +131,11 @@ def datagramWrite(): # process resulting activity while True: - received = s.receive() - print(" RR: {}".format(received.strip())) - # pass to link processor - canPhysicalLayerGridConnect.receiveString(received) + 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.physicalLayerDown() diff --git a/examples/example_frame_interface.py b/examples/example_frame_interface.py index d18c09a..89ff1ee 100644 --- a/examples/example_frame_interface.py +++ b/examples/example_frame_interface.py @@ -18,12 +18,13 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb.canbus.tcpsocket import TcpSocket -from openlcb.canbus.canphysicallayergridconnect import ( +from openlcb import precise_sleep # 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 @@ -31,34 +32,53 @@ # 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") -def sendToSocket(string): +def sendToSocket(frame: CanFrame): + string = frame.encodeAsString() print(" SR: {}".format(string.strip())) - s.send(string) + sock.sendString(string) + physicalLayer.onFrameSent(frame) + + +def handleFrameSent(frame): + # No state to manage since no link layer + physicalLayer._sentFramesCount += 1 + + +def handleFrameReceived(frame): + # No state to manage since no link layer + pass def printFrame(frame): print("RL: {}".format(frame)) -canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(sendToSocket) -canPhysicalLayerGridConnect.registerFrameReceivedListener(printFrame) +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.registerFrameReceivedListener(printFrame) # send an AME frame with arbitrary alias to provoke response frame = CanFrame(ControlFrame.AME.value, 1, bytearray()) print("SL: {}".format(frame)) -canPhysicalLayerGridConnect.sendCanFrame(frame) +physicalLayer.sendFrameAfter(frame) +physicalLayer.sendAll(sock, verbose=True) # display response - should be RID from nodes while True: - received = s.receive() - print(" RR: {}".format(received.strip())) - # pass to link processor - canPhysicalLayerGridConnect.receiveString(received) + 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 aa548ea..c9974de 100644 --- a/examples/example_memory_length_query.py +++ b/examples/example_memory_length_query.py @@ -17,20 +17,21 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb.canbus.tcpsocket import TcpSocket +from openlcb import precise_sleep # 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, ) @@ -43,33 +44,34 @@ 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") -def sendToSocket(string): - print(" SR: {}".format(string.strip())) - s.send(string) +# def sendToSocket(frame: CanFrame): +# string = frame.encodeAsString() +# print(" SR: {}".format(string.strip())) +# sock.sendString(string) +# physicalLayer.onFrameSent(frame) def printFrame(frame): print(" RL: {}".format(frame)) -canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(sendToSocket) -canPhysicalLayerGridConnect.registerFrameReceivedListener(printFrame) +physicalLayer = CanPhysicalLayerGridConnect() +physicalLayer.registerFrameReceivedListener(printFrame) def printMessage(message): print("RM: {} from {}".format(message, message.source)) -canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(canPhysicalLayerGridConnect) +canLink = CanLink(physicalLayer, NodeID(settings['localNodeID'])) canLink.registerMessageReceivedListener(printMessage) datagramService = DatagramService(canLink) @@ -108,14 +110,26 @@ def printDatagram(memo): # def memoryReadFail(memo): # print("memory read failed: {}".format(memo.data)) + def memoryLengthReply(address) : - print ("memory length reply: "+str(address)) + print("memory length reply: "+str(address)) + ####################### # 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...") +physicalLayer.physicalLayerUp() + + +while canLink.pollState() != CanLink.State.Permitted: + physicalLayer.receiveAll(sock, verbose=settings['trace']) + physicalLayer.sendAll(sock, verbose=True) + precise_sleep(.02) print(" SL : link up") -canPhysicalLayerGridConnect.physicalLayerUp() def memoryRequest(): @@ -131,7 +145,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 @@ -140,7 +155,11 @@ def memoryRequest(): # process resulting activity while True: - received = s.receive() - print(" RR: {}".format(received.strip())) - # pass to link processor - canPhysicalLayerGridConnect.receiveString(received) + 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.physicalLayerDown() diff --git a/examples/example_memory_transfer.py b/examples/example_memory_transfer.py index e18e08b..6d0a3b9 100644 --- a/examples/example_memory_transfer.py +++ b/examples/example_memory_transfer.py @@ -11,25 +11,27 @@ ''' # region same code as other examples 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.canbus.tcpsocket import TcpSocket +from openlcb import precise_sleep # 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, @@ -43,33 +45,34 @@ # 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") -def sendToSocket(string): +def sendToSocket(frame: CanFrame): + string = frame.encodeAsString() print(" SR: {}".format(string.strip())) - s.send(string) + sock.sendString(string) + physicalLayer.onFrameSent(frame) def printFrame(frame): print(" RL: {}".format(frame)) -canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(sendToSocket) -canPhysicalLayerGridConnect.registerFrameReceivedListener(printFrame) +physicalLayer = CanPhysicalLayerGridConnect() +physicalLayer.registerFrameReceivedListener(printFrame) def printMessage(message): print("RM: {} from {}".format(message, message.source)) -canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(canPhysicalLayerGridConnect) +canLink = CanLink(physicalLayer, NodeID(settings['localNodeID'])) canLink.registerMessageReceivedListener(printMessage) datagramService = DatagramService(canLink) @@ -96,6 +99,7 @@ def printDatagram(memo): # callbacks to get results of memory read + def memoryReadSuccess(memo): """Handle a successful read @@ -112,8 +116,16 @@ def memoryReadFail(memo): ####################### # 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: + physicalLayer.receiveAll(sock, verbose=settings['trace']) + physicalLayer.sendAll(sock) + precise_sleep(.02) print(" SL : link up") -canPhysicalLayerGridConnect.physicalLayerUp() def memoryRead(): @@ -138,7 +150,13 @@ def memoryRead(): # process resulting activity while True: - received = s.receive() - print(" RR: {}".format(received.strip())) - # pass to link processor - canPhysicalLayerGridConnect.receiveString(received) + 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.physicalLayerDown() diff --git a/examples/example_message_interface.py b/examples/example_message_interface.py index af0aabe..69af857 100644 --- a/examples/example_message_interface.py +++ b/examples/example_message_interface.py @@ -19,15 +19,16 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb.canbus.tcpsocket import TcpSocket +from openlcb import precise_sleep # 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 @@ -36,41 +37,50 @@ # 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") -def sendToSocket(string): - print(" SR: {}".format(string.strip())) - s.send(string) +# def sendToSocket(frame: CanFrame): +# string = frame.encodeAsString() +# print(" SR: {}".format(string.strip())) +# sock.sendString(string) +# physicalLayer.onFrameSent(frame) def printFrame(frame): print(" RL: {}".format(frame)) -canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(sendToSocket) -canPhysicalLayerGridConnect.registerFrameReceivedListener(printFrame) +physicalLayer = CanPhysicalLayerGridConnect() +physicalLayer.registerFrameReceivedListener(printFrame) def printMessage(msg): print("RM: {} from {}".format(msg, msg.source)) -canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(canPhysicalLayerGridConnect) +canLink = CanLink(physicalLayer, NodeID(settings['localNodeID'])) 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() + +print(" SL : link up...") +physicalLayer.physicalLayerUp() +print(" SL : link up...waiting...") +physicalLayer.physicalLayerUp() +while canLink.pollState() != CanLink.State.Permitted: + 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 message = Message(MTI.Verify_NodeID_Number_Global, NodeID(settings['localNodeID']), None) @@ -79,7 +89,11 @@ def printMessage(msg): # process resulting activity while True: - received = s.receive() - print(" RR: {}".format(received.strip())) - # pass to link processor - canPhysicalLayerGridConnect.receiveString(received) + count = 0 + count += physicalLayer.sendAll(sock, verbose=True) + count += physicalLayer.receiveAll(sock, verbose=True) + if count < 1: + precise_sleep(.01) + # else skip sleep to avoid latency (port already delayed) + +physicalLayer.physicalLayerDown() diff --git a/examples/example_node_implementation.py b/examples/example_node_implementation.py index 742dadc..768f09f 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] @@ -19,22 +21,23 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb.canbus.tcpsocket import TcpSocket +from openlcb import precise_sleep # 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 @@ -44,10 +47,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'))) @@ -57,25 +60,26 @@ " RL, SL are link interface; RM, SM are message interface") -def sendToSocket(string): - print(" SR: {}".format(string.strip())) - s.send(string) +# def sendToSocket(frame: CanFrame): +# string = frame.encodeAsString() +# print(" SR: {}".format(string.strip())) +# sock.sendString(string) +# physicalLayer.onFrameSent(frame) def printFrame(frame): print(" RL: {}".format(frame)) -canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(sendToSocket) -canPhysicalLayerGridConnect.registerFrameReceivedListener(printFrame) +physicalLayer = CanPhysicalLayerGridConnect() +physicalLayer.registerFrameReceivedListener(printFrame) def printMessage(message): print("RM: {} from {}".format(message, message.source)) -canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(canPhysicalLayerGridConnect) +canLink = CanLink(physicalLayer, NodeID(settings['localNodeID'])) canLink.registerMessageReceivedListener(printMessage) datagramService = DatagramService(canLink) @@ -143,9 +147,15 @@ 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() +print(" SL : link up...") +physicalLayer.physicalLayerUp() +print(" SL : link up...waiting...") +while canLink.pollState() != CanLink.State.Permitted: + 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 message = Message(MTI.Verify_NodeID_Number_Global, NodeID(settings['localNodeID']), None) @@ -153,7 +163,11 @@ def displayOtherNodeIds(message) : # process resulting activity while True: - input = s.receive() - print(" RR: "+input.strip()) - # pass to link processor - canPhysicalLayerGridConnect.receiveString(input) + 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.physicalLayerDown() diff --git a/examples/example_remote_nodes.py b/examples/example_remote_nodes.py index a1d2efc..21bdb5c 100644 --- a/examples/example_remote_nodes.py +++ b/examples/example_remote_nodes.py @@ -11,33 +11,35 @@ address and port. ''' # region same code as other examples +from timeit import default_timer from examples_settings import Settings # do 1st to fix path if no pip install +from openlcb import precise_sleep settings = Settings() if __name__ == "__main__": 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.canbus.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 @@ -48,26 +50,28 @@ # 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;" " RL, SL are link (frame) interface") -def sendToSocket(string) : - if settings['trace'] : print(" SR: "+string.strip()) - s.send(string) +# def sendToSocket(frame: CanFrame) : + # string = frame.encodeAsString() + # if settings['trace'] : print(" SR: "+string.strip()) + # sock.sendString(string) + # physicalLayer.onFrameSent(frame) def receiveFrame(frame) : if settings['trace']: print("RL: "+str(frame)) -canPhysicalLayerGridConnect = CanPhysicalLayerGridConnect(sendToSocket) -canPhysicalLayerGridConnect.registerFrameReceivedListener(receiveFrame) +physicalLayer = CanPhysicalLayerGridConnect() +physicalLayer.registerFrameReceivedListener(receiveFrame) def printMessage(msg): @@ -75,8 +79,7 @@ def printMessage(msg): readQueue.put(msg) -canLink = CanLink(NodeID(settings['localNodeID'])) -canLink.linkPhysicalLayer(canPhysicalLayerGridConnect) +canLink = CanLink(physicalLayer, NodeID(settings['localNodeID'])) canLink.registerMessageReceivedListener(printMessage) # create a node and connect it update @@ -100,24 +103,57 @@ def printMessage(msg): remoteNodeStore.processMessageFromLinkLayer ) - readQueue = Queue() +_frameReceivedListeners = physicalLayer._frameReceivedListeners +assert len(_frameReceivedListeners) == 1, \ + "{} listener(s) unexpectedly".format(len(_frameReceivedListeners)) + +# bring the CAN level up + +print("* QUEUE Message: link up...") +physicalLayer.physicalLayerUp() +print(" QUEUED Message: link up...waiting for alias reservation...") + +# 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: + # Wait for ready (See also waitForReady) + state = canLink.getState() + if state == CanLink.State.Permitted: + break + physicalLayer.receiveAll(sock, verbose=True) + physicalLayer.sendAll(sock, verbose=True) + + +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)) + +print("nodeIdToAlias: {}".format(canLink.nodeIdToAlias)) + -def receiveLoop() : +def socketLoop(): """put the read on a separate thread""" - # bring the CAN level up - if settings['trace'] : print(" SL : link up") - canPhysicalLayerGridConnect.physicalLayerUp() while True: - input = s.receive() - if settings['trace'] : print(" RR: "+input.strip()) - # pass to link processor - canPhysicalLayerGridConnect.receiveString(input) + 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) : @@ -169,15 +205,22 @@ def result(arg1, arg2=None, arg3=None, result=True) : if settings['trace'] : print("SM: {}".format(message)) canLink.sendMessage(message) -# 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() : @@ -185,3 +228,7 @@ def result(arg1, arg2=None, arg3=None, result=True) : node.snip.userProvidedNodeName) # 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.physicalLayerDown() diff --git a/examples/example_string_interface.py b/examples/example_string_interface.py index e1f7668..725224a 100644 --- a/examples/example_string_interface.py +++ b/examples/example_string_interface.py @@ -18,7 +18,9 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -from openlcb.canbus.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 @@ -26,18 +28,33 @@ # 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) -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(s.receive().strip())) + # 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()) + 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 f50a4f1..c88eaa0 100644 --- a/examples/example_string_serial_interface.py +++ b/examples/example_string_serial_interface.py @@ -18,7 +18,9 @@ settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -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 @@ -26,17 +28,34 @@ # endregion replaced by settings -s = SerialLink() -s.connect(settings['device']) +sock = SerialLink() +sock.connectLocal(settings['device']) ####################### # send a AME frame in GridConnect string format with arbitrary source alias to # elicit response AME = ":X10702001N;" -s.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(s.receive().strip())) + # 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()) + 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 8dd9905..53c6437 100644 --- a/examples/example_tcp_message_interface.py +++ b/examples/example_tcp_message_interface.py @@ -11,20 +11,28 @@ 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 openlcb import precise_sleep +from openlcb.realtimerawphysicallayer import RealtimeRawPhysicalLayer settings = Settings() if __name__ == "__main__": settings.load_cli_args(docstring=__doc__) # endregion same code as other examples -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.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__) +else: + logger = getLogger(__name__) # specify connection information # region moved to settings @@ -33,49 +41,70 @@ # 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") -def sendToSocket(data): - # if isinstance(data, list): - # raise TypeError( - # "Got {}({}) but expected str" - # .format(type(data).__name__, data) - # ) - print(" SR: {}".format(data)) - s.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, NodeID(settings['localNodeID']), None) -print("SM: {}".format(message)) -tcpLinkLayer.sendMessage(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: +# time.sleep(.02) # process resulting activity while True: - received = s.receive() - print(" RR: {}".format(received)) - # pass to link processor - tcpLinkLayer.receiveListener(received) + count = 0 + received = sock.receive() + if received is not None: + print(" RR: {}".format(received)) + # pass to link processor + tcpLinkLayer.handleFrameReceived(received) + 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/examples/examples_gui.py b/examples/examples_gui.py index e3e0bc8..c7c7597 100644 --- a/examples/examples_gui.py +++ b/examples/examples_gui.py @@ -13,15 +13,36 @@ """ import json import os +import platform import subprocess import sys -import tkinter as tk +import threading + +from logging import getLogger + +from openlcb.message import Message +from openlcb.mti import MTI + +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 collections import OrderedDict, deque + +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 -from examples_settings import Settings +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 @@ -39,6 +60,11 @@ class ServiceBrowser: """Placeholder for when zeroconf is *not* present""" pass +if __name__ == "__main__": + logger = getLogger(__file__) +else: + logger = getLogger(__name__) + class MyListener(ServiceListener): pass @@ -61,6 +87,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() @@ -102,10 +130,14 @@ 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 - self.errors = [] + self.errors = deque() + self.root = parent + self._connect_thread = None try: self.settings = Settings() except json.decoder.JSONDecodeError as ex: @@ -116,46 +148,73 @@ 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._gui(parent) + 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( - "Welcome! Select an example. Run also saves settings." + 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.pop(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 + 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() + def loadExamples(self): + self.removeExamples() + 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.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. + ) + 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: @@ -169,7 +228,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), @@ -178,21 +237,11 @@ 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): + def runExample(self, module_name=None): """Run the selected example. Args: @@ -204,38 +253,52 @@ 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.proc = subprocess.Popen( - args, - shell=True, - # close_fds=True, close file descriptors >= 3 before running - # stdin=None, stdout=None, stderr=None, - ) + self.setStatus("Running {} (see console for results)..." + .format(module_name)) - def load_settings(self): + self.enableButtons(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.enableButtons(True) + + def enableButtons(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 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). @@ -244,7 +307,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 @@ -264,7 +327,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)) @@ -290,26 +353,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:'), ) @@ -322,12 +385,12 @@ 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( "", # 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, @@ -335,31 +398,77 @@ 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 onFormLoaded) 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.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.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.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.canLink.registerMessageReceivedListener( + self.handleMessage) + self.cdi_form.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="Other 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) @@ -377,14 +486,134 @@ def gui(self, parent): # self.rowconfigure(row, weight=1) # self.rowconfigure(self.row_count-1, weight=1) # make last row expand - def set_id_from_name(self): - id = self.get_id_from_name(update_button=True) + 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)) + + 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 + + # Can't communicate with LCC network, so disable related widget(s): + self.cdi_refresh_button.configure(state=tk.DISABLED) + self.setStatus("LCC network disconnected.") + + def _handleConnect(self): + """Handle Link_Layer_Down Message + Affects GUI, so run from main thread or via self.root.after. + """ + 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') + 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_form.connect(host, port, localNodeID) + 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.setStatus(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) + # ^ See message.mti == MTI Link_Layer_Down instead. + result = self.cdi_form.startListening( + self._tcp_socket, + localNodeID, + ) + self._connect_thread = None + except Exception as 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 cdiConnectClicked(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. + 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) + self.cdi_refresh_button.configure(state=tk.DISABLED) + farNodeID = self.getValue('farNodeID') + if not farNodeID: + self.setStatus('Set "Far node ID" first.') + return + print("Querying farNodeID={}".format(repr(farNodeID))) + self.setStatus("Downloading CDI...") + threading.Thread( + target=self.cdi_form.downloadCDI, + args=(farNodeID,), + kwargs={'callback': self.cdi_form.on_cdi_element}, + daemon=True, + ).start() + + def getValue(self, key): + field = self.fields.get(key) + if not field: + raise KeyError("Invalid form field {}".format(repr(key))) + return field.get() + + def setIdFromName(self): + id = self.getIdFromName(update_button=True) if not id: + self.setStatus( + "The service name {} does not contain an LCC ID" + " (Does not follow hardware convention).") return self.fields['farNodeID'].var.set(id) + 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): - lcc_id = id_from_tcp_service_name(self.fields['service_name'].var.get()) + def getIdFromName(self, update_button=False): + lcc_id = id_from_tcp_service_name( + self.fields['service_name'].var.get()) if update_button: if not lcc_id: self.fields["service_name"].button.configure(state=tk.DISABLED) @@ -392,9 +621,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, @@ -404,11 +633,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 @@ -443,7 +672,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( @@ -486,48 +715,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! @@ -546,57 +775,86 @@ 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() 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) - window_h = round(screen_h * .75) - 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) - 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/examples/examples_settings.py b/examples/examples_settings.py index 9c1eb1c..b9ea92d 100644 --- a/examples/examples_settings.py +++ b/examples/examples_settings.py @@ -8,12 +8,22 @@ import shutil import sys +from logging import getLogger +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")): # 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/cdiform.py b/examples/tkexamples/cdiform.py new file mode 100644 index 0000000..ee9610f --- /dev/null +++ b/examples/tkexamples/cdiform.py @@ -0,0 +1,264 @@ +""" +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 +""" +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 + +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) +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.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" + " or adds it to sys.path like examples_settings does.", + file=sys.stderr) + raise # sys.exit(1) + + +class CDIForm(ttk.Frame, OpenLCBNetwork): + """A GUI frame to represent the CDI visually as a tree. + + Args: + parent (TkWidget): Typically a ttk.Frame or tk.Frame with "root" + attribute set. + """ + def __init__(self, *args, **kwargs): + OpenLCBNetwork.__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 + self._treeview = None + self._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) + self._status_label = ttk.Label(container, + textvariable=self._status_var) + 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._overview.grid(sticky=tk.NSEW, row=len(self._top_widgets)) + self._top_widgets.append(self._overview) + self._treeview = ttk.Treeview(container) + 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 = None # no parent when top of Treeview + 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.setStatus("Display reset.") + + # def connect(self, new_socket, localNodeID, callback=None): + # return OpenLCBNetwork.connect(self, new_socket, localNodeID, + # callback=callback) + + def downloadCDI(self, farNodeID: str, + callback: Callable[[dict], None] = None): + self.setStatus("Downloading CDI...") + self.ignore_non_gui_tags = deque() + self._populating_stack = deque() + super().downloadCDI(farNodeID, callback=callback) + + def setStatus(self, message: str): + self._status_var.set(message) + + def on_cdi_element(self, event_d: dict): + """Handler for incoming CDI tag + (Use this for callback in downloadCDI, which sets parser's + _onElement) + + Args: + 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. + - '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') + 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 + elif status: + show_status = status + elif done: + show_status = "Done loading CDI." + if show_status: + self.root.after(0, self.setStatus, show_status) + if done: + return + if event_d.get('end'): + 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_end(self, event_d: dict): + 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 (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 "" # "" (empty str) is magic value for top of ttk.Treeview + return self._populating_stack.pop() + + def _on_cdi_element_start(self, event_d: dict): + 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 + 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) + prev_stack_size = len(self._populating_stack) + if tagLower in ("segment", "group"): + name = "" + for child in element: + 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: + 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, + iid=self._current_iid, + text=name, + ) + self._populating_stack.append(new_branch) + # values=(), image=None + self._current_iid += 1 # TODO: associate with SubElement + 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) + self.ignore_non_gui_tags.append(tagLower) + elif tagLower in ("int", "string", "float"): + name = "" + for child in element: + if child.tag == "name": + name = child.text + break + 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) + 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/__init__.py b/openlcb/__init__.py index 2c72ccc..9760096 100644 --- a/openlcb/__init__.py +++ b/openlcb/__init__.py @@ -1,5 +1,12 @@ -from collections import OrderedDict +from enum import Enum import re +import time + +from collections import OrderedDict +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})+$") @@ -7,7 +14,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). @@ -15,19 +22,26 @@ 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 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) -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 @@ -36,7 +50,30 @@ 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: 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 + 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) + + +def formatted_ex(ex) -> str: + return "{}: {}".format(type(ex).__name__, ex) diff --git a/openlcb/canbus/canframe.py b/openlcb/canbus/canframe.py index 21908eb..1562259 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, _) -> str: + 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 @@ -18,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 @@ -36,6 +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). + 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 = [ @@ -61,10 +89,24 @@ def __str__(self): list(self.data), # cast to list to format bytearray(b'') as [] ) - def __init__(self, *args): + 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 + + def __init__(self, *args, afterSendState=None, reservation=None): + self.afterSendState = afterSendState + self.encoder = NoEncoder() + self.reservation = reservation arg1 = None arg2 = None arg3 = None + self._alias = None if len(args) > 0: arg1 = args[0] if len(args) > 1: @@ -76,9 +118,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,15 +132,17 @@ 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) + 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 | (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 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. @@ -104,6 +150,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 +163,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 +178,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: @@ -140,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 32012d0..a6908d7 100644 --- a/openlcb/canbus/canlink.py +++ b/openlcb/canbus/canlink.py @@ -19,60 +19,306 @@ ''' from enum import Enum - -import logging - +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 ( + 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__) class CanLink(LinkLayer): + """CAN link layer (manage stack's link state). + + Attributes: + 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 + (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. + 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 = STANDARD_ALIAS_RESPONSE_DELAY # See docstring. - def __init__(self, localNodeID): # a NodeID - self.localAliasSeed = localNodeID.nodeId - self.localAlias = self.createAlias12(self.localAliasSeed) + 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 + 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. + """ + Initial = 1 # 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) + + 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) + + def __init__(self, physicalLayer: PhysicalLayer, localNodeID: NodeID): + # See class docstring for args + self.physicalLayer: CanPhysicalLayer = None # set by super() below + # ^ typically CanPhysicalLayerGridConnect + LinkLayer.__init__(self, physicalLayer, localNodeID) + self._previousLocalAliasSeed = None + 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._state = CanLink.State.Initial + self._frameCount = 0 + self._aliasCollisionCount = 0 + self._errorCount = 0 + self._previousFrameCount = None self.aliasToNodeID = {} self.nodeIdToAlias = {} self.accumulator = {} + self.duplicateAliases = [] self.nextInternallyAssignedNodeID = 1 - LinkLayer.__init__(self, localNodeID) + 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 + # *only after* a successful reservation) + 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 + 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). - def linkPhysicalLayer(self, cpl): # CanPhysicalLayer - self.link = cpl - cpl.registerFrameReceivedListener(self.receiveListener) - - class State(Enum): - Initial = 1, # a special case of .Inhibited where init hasn't started - Inhibited = 2, - Permitted = 3 - - def receiveListener(self, frame): + Returns: + int: The local alias. + """ + if self._state != CanLink.State.Permitted: + raise InterruptedError( + "The alias reservation is not complete (state={})." + " 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" + " 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 + + # 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 isCanceled(self, frame: CanFrame) -> bool: + 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 isCanceled handles both collision and error. + + # 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 + # 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.handleFrameReceived) + # # ^ Commented since it makes more sense for its + # # constructor to do this, since it needs a PhysicalLayer + # # in order to do anything + + 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)) + 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) + 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). + 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. + 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 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 + else: + print("[CanLink handleFrameReceived] control_frame={}" + .format(control_frame)) + if control_frame == ControlFrame.LinkUp: self.handleReceivedLinkUp(frame) elif control_frame == ControlFrame.LinkRestarted: # noqa: E501 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)) + 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: + # 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) @@ -86,22 +332,47 @@ def receiveListener(self, frame): ControlFrame.EIR1, ControlFrame.EIR2, ControlFrame.EIR3): - pass # ignored upon receipt + 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.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)) + elif (control_frame == ControlFrame.UnknownFormat): + 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 # case occurs, some code is missing above). - logging.warning("Invalid control frame format 0x{:08X}" - "".format(control_frame)) - - def handleReceivedLinkUp(self, frame): + 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 ( + 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: CanFrame): """Link started, update state, start process to create alias. LinkUp message will be sent when alias process completes. @@ -109,12 +380,11 @@ 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 self.defineAndReserveAlias() - # notify upper layers - self.linkStateChange(self.state) + print("[CanLink] done calling defineAndReserveAlias.") - def handleReceivedLinkRestarted(self, frame): + def handleReceivedLinkRestarted(self, frame: CanFrame): """Send a LinkRestarted message upstream. Args: @@ -122,85 +392,136 @@ def handleReceivedLinkRestarted(self, frame): """ msg = Message(MTI.Link_Layer_Restarted, NodeID(0), None, bytearray()) - self.fireListeners(msg) - - def defineAndReserveAlias(self): - self.sendAliasAllocationSequence() - - # TODO: wait 200 msec before declaring ready to go (and doing - # steps following the call here) + self.fireMessageReceived(msg) + 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.physicalLayer.sendFrameAfter( + 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.nodeIdToAlias[self.localNodeID] = self.localAlias + 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, - self.localAlias)) + self.physicalLayer.sendFrameAfter( + CanFrame(ControlFrame.AME.value, self._localAlias, + afterSendState=CanLink.State.Permitted) + ) # TODO: (restart) Should this set inhibited every time? LinkUp not # 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: 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: State): + """invoked when the link layer comes up and down - # invoked when the link layer comes up and down - def linkStateChange(self, state): # state is of the State enum + 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()) - self.fireListeners(msg) - - def handleReceivedCID(self, frame): # CanFrame + else: + raise TypeError( + "The other layers don't need to know the intermediate steps.") + self.fireMessageReceived(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: + 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 + 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). + """ if self.checkAndHandleAliasCollision(frame): return # 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 alias = frame.header & 0xFFF self.aliasToNodeID[alias] = nodeID + logger.info( + "handleReceivedAMD setting nodeIdToAlias[{}]" + .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). + """ if self.checkAndHandleAliasCollision(frame): return - if self.state != CanLink.State.Permitted: + if self._state != CanLink.State.Permitted: return # check node ID matchNodeID = self.localNodeID @@ -209,11 +530,14 @@ 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) + 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). + """ if (self.checkAndHandleAliasCollision(frame)): return # Alias Map Reset - drop from maps @@ -227,9 +551,14 @@ 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. + """ if self.checkAndHandleAliasCollision(frame): return + # ^ may affect _aliasCollisionCount (not _frameCount) # get proper MTI mti = self.canHeaderToFullFormat(frame) sourceID = NodeID(0) @@ -238,52 +567,65 @@ 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) - logging.info("Verified_NodeID from unknown source alias: {}," - " continue with observed ID {}" - "".format(frame, sourceID)) + logger.info( + "Verified_NodeID frame {} from unknown source alias: {}," + " continue with observed ID {}" + .format(frame, unmapped, 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 frame {} from unknown source alias: {}," + " continue with created ID {}" + .format(frame, unmapped, 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) # 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] 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 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: @@ -291,7 +633,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) @@ -305,10 +647,10 @@ 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) + self.fireMessageReceived(msg) # remove accumulation self.accumulator[key] = None @@ -327,12 +669,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 +692,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 @@ -362,7 +710,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 @@ -376,9 +724,9 @@ 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): + def sendMessage(self, msg: Message, verbose=False): # special case for datagram if msg.mti == MTI.Datagram: header = 0x10_00_00_00 @@ -393,7 +741,7 @@ def sendMessage(self, msg): except KeyboardInterrupt: raise except: - logging.warning( + logger.warning( "Did not know source = {} on datagram send" "".format(msg.source) ) @@ -403,48 +751,55 @@ def sendMessage(self, msg): header |= ((dddAlias) & 0xFFF) << 12 except KeyboardInterrupt: raise - except: - logging.warning( - "Did not know destination = {} on datagram send" - "".format(msg.source) + except Exception as ex: + logger.error( + "Did not know destination = {} on datagram send ({})" + " self.nodeIdToAlias={}. Ensure recv loop" + " (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." + .format(msg.destination, formatted_ex(ex), + self.nodeIdToAlias) ) if len(msg.data) <= 8: # 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_000_000, dataSegments[0]) - self.link.sendCanFrame(frame) + frame = CanFrame(header | 0x0B_00_00_00, dataSegments[0]) + 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_000_000, + frame = CanFrame(header | 0x0C_00_00_00, dataSegments[index]) - self.link.sendCanFrame(frame) + self.physicalLayer.sendFrameAfter(frame) # send last one frame = CanFrame( - header | 0x0D_000_000, + 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 - 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 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(): @@ -459,17 +814,18 @@ 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: - 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 frame = CanFrame(header, msg.data) - self.link.sendCanFrame(frame) + 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. @@ -500,7 +856,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. @@ -539,39 +896,191 @@ def segmentAddressedDataArray(self, alias, data): return segments # MARK: common code - def checkAndHandleAliasCollision(self, frame): - if self.state != CanLink.State.Permitted: + def checkAndHandleAliasCollision(self, frame: CanFrame): + 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 - def processCollision(self, frame) : + def markDuplicateAlias(self, alias: int): + if not isinstance(alias, int): + raise NotImplementedError( + "Can't mark collision 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))) + self.duplicateAliases.append(alias) + + def processCollision(self, frame: CanFrame): ''' Collision! ''' - logging.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())) + 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.physicalLayer.sendFrameAfter(CanFrame( + ControlFrame.AMR.value, + 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''' - 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)) - self.link.sendCanFrame(CanFrame(ControlFrame.RID.value, - self.localAlias)) - - def incrementAlias48(self, oldAlias): + # 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) -> State: + """You must keep polling state after every time + a state change frame is sent, and after + every call to handleDataString or handleData + 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 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. OpenLCBNetwork 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: + if ((default_timer() - self._waitingForAliasStart) + > CanLink.ALIAS_RESPONSE_DELAY): + # 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) + # 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() + + # 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. + """ + 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): + """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.physicalLayer.sendFrameAfter(CanFrame(7, self.localNodeID, + self._localAlias, + reservation=self._reservation)) + self.physicalLayer.sendFrameAfter(CanFrame(6, self.localNodeID, + self._localAlias, + reservation=self._reservation)) + self.physicalLayer.sendFrameAfter(CanFrame(5, self.localNodeID, + self._localAlias, + reservation=self._reservation)) + self.physicalLayer.sendFrameAfter( + CanFrame(4, self.localNodeID, self._localAlias, + afterSendState=CanLink.State.WaitForAliases, + reservation=self._reservation) + ) + self._previousErrorCount = self._errorCount + self._previousFrameCount = self._frameCount + self._previousLocalAliasSeed = self._localAliasSeed + self.setState(CanLink.State.WaitingForSendCIDSequence) + + def _enqueueReserveID(self): + """Send Reserve ID (RID) + + 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) + # 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() + + self.physicalLayer.sendFrameAfter( + CanFrame(ControlFrame.RID.value, self._localAlias, + afterSendState=CanLink.State.NotifyAliasReservation, + reservation=self._reservation) + ) + self.setState(CanLink.State.WaitingForSendReserveID) + + def incrementAlias48(self, oldAlias: int) -> int: ''' Implements the OpenLCB preferred alias generation mechanism: a 48-bit computation @@ -579,11 +1088,11 @@ 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 - def createAlias12(self, rnd): + def createAlias12(self, rnd: int) -> int: '''Form 12 bit alias from 48-bit random number''' part1 = (rnd >> 36) & 0x0FFF @@ -599,24 +1108,27 @@ 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) -> ControlFrame: 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: - 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 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): + 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) @@ -625,8 +1137,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,10 +1148,118 @@ 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 + 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 + (overlapping read or write 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.physicalLayer.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: + # self.physicalLayer.receiveAll(device) + 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 new file mode 100644 index 0000000..7a673ca --- /dev/null +++ b/openlcb/canbus/canlinklayersimulation.py @@ -0,0 +1,10 @@ +from logging import getLogger + +from openlcb.canbus.canlink import CanLink + + +logger = getLogger(__name__) + + +class CanLinkLayerSimulation(CanLink): + pass \ No newline at end of file diff --git a/openlcb/canbus/canphysicallayer.py b/openlcb/canbus/canphysicallayer.py index d521a94..5edeed9 100644 --- a/openlcb/canbus/canphysicallayer.py +++ b/openlcb/canbus/canphysicallayer.py @@ -4,25 +4,87 @@ This is a class because it represents a single physical connection to a layout and is subclassed. ''' +from logging import getLogger +from typing import Callable +import warnings + 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 + (No encodeFrameAsString, since this binary layer may be wrapped by + the higher layer such as the text-based CanPhysicalLayerGridConnect) + """ + + def __init__(self,): + PhysicalLayer.__init__(self) + self._frameReceivedListeners: list[Callable[[CanFrame], None]] = [] - def __init__(self): - self.listeners = [] + def sendFrameAfter(self, frame: 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) + """ + assert isinstance(frame, CanFrame) + frame.encoder = self + PhysicalLayer.sendFrameAfter(self, frame) # calls onQueuedFrame if set - def sendCanFrame(self, frame): - '''basic abstract interface''' - pass + def pollFrame(self) -> CanFrame: + frame = super().pollFrame() + if frame is None: + return None + assert isinstance(frame, CanFrame) + return frame - def registerFrameReceivedListener(self, listener): - self.listeners.append(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]" + " 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.onFrameReceived set by LinkLayer/subclass" + " constructor).") + self._frameReceivedListeners.append(listener) - def fireListeners(self, frame): - for listener in self.listeners: + 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 onFrameReceived, + so registerFrameReceivedListener is now optional, and + a Message handler should usually be used instead. + """ + # (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.onFrameReceived(frame) # canLink.handleFrameReceived reference + for listener in self._frameReceivedListeners: listener(frame) def physicalLayerUp(self): @@ -30,7 +92,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 @@ -38,7 +100,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 @@ -46,4 +108,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 24cdf14..227ee4b 100644 --- a/openlcb/canbus/canphysicallayergridconnect.py +++ b/openlcb/canbus/canphysicallayergridconnect.py @@ -10,45 +10,170 @@ - :X19170365N020112FE056C; ''' + +from typing import Union 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 # ; + + +class CanPhysicalLayerGridConnect(CanPhysicalLayer, FrameEncoder): + """CAN physical layer subclass for GridConnect + + This acts as frame.encoder for canLink, and manages the packet + _send_frames 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 + 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): + # ^ A CanLink requires a physical layer to operate, + # so CanLink now requires a PhysicalLayer instance + # such as this in its constructor. + CanPhysicalLayer.__init__(self) # creates self._send_frames -class CanPhysicalLayerGridConnect(CanPhysicalLayer): + # region moved to CanLink constructor + # from canLink.linkPhysicalLayer(self) # self.setCallBack(callback): + # canLink.physicalLayer = self + # self.registerFrameReceivedListener(canLink.handleFrameReceived) + # endregion moved to CanLink constructor - def __init__(self, callback): - CanPhysicalLayer.__init__(self) - self.canSendCallback = callback self.inboundBuffer = bytearray() - def setCallBack(self, callback): - self.canSendCallback = callback + # def setCallBack(self, callback): + # assert callable(callback) + # self.canSendCallback = callback - def sendCanFrame(self, frame): - output = ":X{:08X}N".format(frame.header) + 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: - output += "{:02X}".format(byte) + output += "{:02X}".format(byte) # at least 2 chars, hex output += ";\n" - self.canSendCallback(output) + return output + + 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") + + 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 count + _ = 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 receiveString(self, string): - '''Receive a string from the outside link to be parsed + def sendAll(self, device: PortInterface, mode="binary", verbose=False) -> int: + """Send all queued frames using the given device. Args: - string (str): A UTF-8 string to parse. + 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). + 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. + """ + 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: + 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) + 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) -> 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. ''' - self.receiveChars(string.encode("utf-8")) + # formerly pushString formerly receiveString + return self.handleData(string.encode("utf-8")) + + 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. - # Provide characters from the outside link to be parsed - def receiveChars(self, data): + Returns: + int: The number of frames completed by inboundBuffer+data. + """ + frameCount = 0 self.inboundBuffer += data - lastByte = 0 - if 0x3B in self.inboundBuffer: + 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 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 @@ -61,19 +186,32 @@ def receiveChars(self, data): # offset 11 might be data, might be ; lastByte = 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: # noqa: E501 break # two characters are data byte1 = self.inboundBuffer[index+11+2*dataItem] 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 > 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 # lastByte is index of ; in this message cf = CanFrame(header, outData) - self.fireListeners(cf) + frameCount += 1 + 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[lastByte:] + return frameCount diff --git a/openlcb/canbus/canphysicallayersimulation.py b/openlcb/canbus/canphysicallayersimulation.py index 208745f..e40cb20 100644 --- a/openlcb/canbus/canphysicallayersimulation.py +++ b/openlcb/canbus/canphysicallayersimulation.py @@ -2,14 +2,71 @@ Simulated CanPhysicalLayer to record frames requested to be sent. ''' +from typing import List, Union +from openlcb.canbus.canframe import CanFrame from openlcb.canbus.canphysicallayer import CanPhysicalLayer +from openlcb.frameencoder import FrameEncoder -class CanPhysicalLayerSimulation(CanPhysicalLayer): +class CanPhysicalLayerSimulation(CanPhysicalLayer, FrameEncoder): + """Simulation CanPhysicalLayer and FrameEncoder implementation + Attributes: + received_chunks (list[bytearray]): Reserved for future use. + """ def __init__(self): - self.receivedFrames = [] + self.sentFrames: List[CanFrame] = [] + # ^ formerly receivedFrames but was appended in self.sendCanFrame! + 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: 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)" + + def encodeFrameAsData(self, frame: CanFrame): + return self.encodeFrameAsString(frame).encode("utf-8") + + def sendFrameAfter(self, frame: CanFrame): + frame.encoder = self + # 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 sendCanFrame(self, frame): - self.receivedFrames.append(frame) + 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/canbus/controlframe.py b/openlcb/canbus/controlframe.py index 846a3e0..6e81ec8 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,11 +69,53 @@ 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 + # 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 OpenLCBNetwork can manage runlevel). LinkUp = 0x20000 LinkRestarted = 0x20001 LinkCollision = 0x20002 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/openlcb/canbus/gridconnectobserver.py b/openlcb/canbus/gridconnectobserver.py new file mode 100644 index 0000000..e69df95 --- /dev/null +++ b/openlcb/canbus/gridconnectobserver.py @@ -0,0 +1,8 @@ + +from openlcb.canbus.canphysicallayergridconnect import GC_END_BYTE +from openlcb.scanner import Scanner + + +class GridConnectObserver(Scanner): + def __init__(self): + Scanner.__init__(self, delimiter=GC_END_BYTE) diff --git a/openlcb/canbus/seriallink.py b/openlcb/canbus/seriallink.py index 65c31a8..0a44318 100644 --- a/openlcb/canbus/seriallink.py +++ b/openlcb/canbus/seriallink.py @@ -4,68 +4,90 @@ ''' 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 +# 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: +class SerialLink(PortInterface): """simple serial input for string send and receive""" def __init__(self): + super(SerialLink, self).__init__() + + def _settimeout(self, seconds: float): + logger.warning("settimeout is not implemented for SerialLink") pass - def connect(self, device, baudrate=230400): + def _connect(self, _, port: str, device: serial.Serial = None, + baudrate: int = 230400): """Connect to a serial port. Args: - device (str): A string that identifies a serial port for the + _ (NoneType): Host (Unused since host is always local + machine for serial; placeholder for + compatibility with the interface). + 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, 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:]) + sent = self._device.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) + chunk = self._device.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): - self.port.close() + def _close(self): + self._device.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/datagramservice.py b/openlcb/datagramservice.py index b17e773..082d3ec 100644 --- a/openlcb/datagramservice.py +++ b/openlcb/datagramservice.py @@ -24,9 +24,16 @@ from enum import Enum import logging +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 from openlcb.mti import MTI +from openlcb.nodeid import NodeID def defaultIgnoreReply(memo): @@ -38,8 +45,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 {}" @@ -60,7 +68,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 @@ -92,18 +100,18 @@ class ProtocolID(Enum): Unrecognized = 0xFF # Not formally assigned - def __init__(self, linkLayer): - self.linkLayer = linkLayer + def __init__(self, linkLayer: LinkLayer): + self.linkLayer: LinkLayer = linkLayer self.quiesced = False self.currentOutstandingMemo = None self.pendingWriteMemos = [] - self.listeners = [] + 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 @@ -123,15 +131,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. ''' @@ -143,14 +152,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 @@ -160,11 +170,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): # 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 @@ -172,7 +183,7 @@ def fireListeners(self, dg): # internal for testing self.negativeReplyToDatagram(dg, 0x1042) # "Not implemented, datagram type unknown" - permanent error - def process(self, message): + def process(self, message: Message): '''Processor entry point. Returns: @@ -196,14 +207,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.fireListeners(memo) + 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) @@ -221,7 +232,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) @@ -239,11 +250,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 ''' @@ -258,7 +269,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 @@ -280,7 +291,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: @@ -292,7 +303,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/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/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/internalevent.py b/openlcb/internalevent.py new file mode 100644 index 0000000..ba295d7 --- /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 OpenLCBNetwork 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/linklayer.py b/openlcb/linklayer.py index 4cdf462..1f3d3fd 100644 --- a/openlcb/linklayer.py +++ b/openlcb/linklayer.py @@ -16,20 +16,133 @@ ''' +from enum import Enum +from logging import getLogger + +from openlcb import emit_cast +from openlcb.message import Message +from openlcb.physicallayer import PhysicalLayer + +logger = getLogger(__name__) + + class LinkLayer: + """Abstract Link Layer interface + + Attributes: + _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. + State (class(Enum)): values for _state. Implement all necessary + states in subclass to handle connection phases etc. + """ - def __init__(self, localNodeID): + class State(Enum): + 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) + + def __init__(self, physicalLayer: PhysicalLayer, localNodeID): + assert isinstance(physicalLayer, PhysicalLayer) # allows any subclass + # subclass should check type of localNodeID technically self.localNodeID = localNodeID + self._messageReceivedListeners = [] + self._state = None # LinkLayer.State.Undefined + # region moved from CanLink linkPhysicalLayer + self.physicalLayer = physicalLayer # formerly self.link = cpl + # if physicalLayer is not None: + # listener = self.handleFrameReceived # try to prevent + # "new bound method" Python behavior in subclass from making "is" + # 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 + # physicalLayer.onFrameReceived in fireFrameReceived in PhysicalLayer + # else: + # print("Using {} without" + # " registerFrameReceivedListener(self.handleFrameReceived)" + # " 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 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)" + .format(type(self).__name__)) - def sendMessage(self, msg): + 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 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. + + def getState(self): + return self._state + + 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 + 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( + "[LinkLayer] abstract _onStateChanged not implemented") + + def sendMessage(self, msg: Message, verbose=False): '''This is the basic abstract interface ''' def registerMessageReceivedListener(self, listener): - self.listeners.append(listener) - - listeners = [] # local list of listener callbacks + self._messageReceivedListeners.append(listener) - def fireListeners(self, msg): - for listener in self.listeners: + def fireMessageReceived(self, msg: Message): + """Fire *Message received* listeners.""" + for listener in self._messageReceivedListeners: listener(msg) diff --git a/openlcb/localnodeprocessor.py b/openlcb/localnodeprocessor.py index 58c961f..f0e0d9c 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: @@ -35,15 +36,15 @@ def process(self, 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 @@ -58,18 +59,17 @@ def process(self, 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, node): + def _linkUpMessage(self, message: Message, node: Node): node.state = Node.State.Initialized msgIC = Message(MTI.Initialization_Complete, node.id, None, node.id.toArray()) @@ -78,26 +78,22 @@ def linkUpMessage(self, message, node): # msgVN = Message( MTI.Verify_NodeID_Number_Global, node.id) # 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, node.id.toArray()) 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 @@ -111,20 +107,18 @@ def protocolSupportInquiry(self, message, node): retval) 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 ''' @@ -149,7 +143,6 @@ def _unrecognizedMTI(self, message, node): (originalMTI & 0xFF)])) # permanent error 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 487bbb9..0feab24 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. @@ -22,8 +22,15 @@ ''' import logging + +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, DatagramWriteMemo, DatagramService, ) @@ -44,11 +51,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. @@ -182,13 +189,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 +285,7 @@ def datagramReceivedListener(self, dmemo): return True - def requestMemoryWrite(self, memo): + def requestMemoryWrite(self, memo: MemoryWriteMemo): """Request memory write. Args: @@ -310,8 +319,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 @@ -327,11 +336,15 @@ 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) - 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 d6d281f..fb91f86 100644 --- a/openlcb/message.py +++ b/openlcb/message.py @@ -4,6 +4,12 @@ ''' +from openlcb import emit_cast +from openlcb.mti import MTI +from openlcb.node import Node +from openlcb.nodeid import NodeID + + class Message: """basic message, with an MTI, source, destination? and data content @@ -15,20 +21,33 @@ class Message: empty bytearray(). """ - def __init__(self, mti, source, destination, data=bytearray()): + def __init__(self, mti, source: NodeID, destination: NodeID, 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 isGlobal(self): + def assertTypes(self): + assert isinstance(self.mti, MTI) + 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) -> bool: return self.mti.value & 0x0008 == 0 - def isAddressed(self): + def isAddressed(self) -> bool: return self.mti.value & 0x0008 != 0 def __str__(self): @@ -37,12 +56,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/mti.py b/openlcb/mti.py index 2899d4c..0e3c447 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 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 @@ -57,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 dda078b..2433c44 100644 --- a/openlcb/node.py +++ b/openlcb/node.py @@ -14,6 +14,9 @@ ''' 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 @@ -38,11 +41,12 @@ class Node: events (LocalEventStore): The store for local events associated with the node. """ - def __init__(self, nodeID, snip=None, pipSet=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() @@ -50,7 +54,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): @@ -67,7 +71,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..af211ec 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''' @@ -23,10 +25,13 @@ 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 - self.nodeId = data + self.value = data elif isinstance(data, str): parts = data.split(".") result = 0 @@ -36,23 +41,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])" @@ -60,23 +65,23 @@ def __init__(self, data): else: print("invalid data type to nodeid constructor", data) - def toArray(self): + def toArray(self) -> bytearray: 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/nodestore.py b/openlcb/nodestore.py index 2c3f984..f1d5cb4 100644 --- a/openlcb/nodestore.py +++ b/openlcb/nodestore.py @@ -1,4 +1,13 @@ +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 +) + +from openlcb.message import Message +from openlcb.node import Node from openlcb.nodeid import NodeID +from openlcb.processor import Processor class NodeStore : @@ -10,13 +19,13 @@ class NodeStore : ''' def __init__(self) : - self.byIdMap = {} - self.nodes = [] - self.processors = [] + self.byIdMap: Dict[NodeID, Node] = {} + 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) @@ -26,10 +35,10 @@ def store(self, 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 @@ -37,7 +46,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 +58,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 new file mode 100644 index 0000000..f6e1e4d --- /dev/null +++ b/openlcb/openlcbnetwork.py @@ -0,0 +1,667 @@ + +""" +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) +""" +from collections import deque +from enum import Enum +import os +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 # for autocomplete only + +from openlcb import formatted_ex, precise_sleep + +from openlcb.canbus.canframe import CanFrame +from openlcb.canbus.canphysicallayergridconnect import ( + 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 ( + 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__) +else: + logger = getLogger(__name__) + + +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). + """ + # 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()} + + +# 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 + 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). + _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). + _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 + 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. + 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): + caches_dir = SysDirs.Cache + 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._stringTerminated = None # None means no read is occurring. + self._parser = xml.sax.make_parser() + self._parser.setContentHandler(self) + + self._realtime = True + + # region ContentHandler + # self._chunks = [] + self._tag_stack = [] + # endregion ContentHandler + + # region connect + 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 + + 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 + + def _fireStatus(self, status, callback=None): + """Fire status handlers with the given status.""" + if callback is None: + callback = self._onElement + if callback is None: + callback = self._onConnect + if callback: + print("CDIForm callback_msg({})".format(repr(status))) + self._onConnect({ + 'status': status, + }) + else: + logger.warning("No callback, but set status: {}".format(status)) + + 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, + localNodeID: Union[NodeID, int, str, bytearray]): + if self._port is not None: + logger.warning( + "[startListening] A previous _port will be discarded.") + self._port = connected_port + + self._fireStatus("listen...") + + self.listen() + + def listen(self): + self._listenThread = threading.Thread( + target=self._listen, + daemon=True, # True to terminate on program exit + ) + print("[listen] Starting port receive loop...") + self._listenThread.start() + + 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 + 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() + + 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 + caught_ex = None + 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 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: + # Receive mode (switches to write mode on BlockingIOError + # which is expected and used on purpose) + 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 > .21: + if self.canLink._state != CanLink.State.Permitted: + 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" + "--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)) + 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) + # ^ random delay may help if send is on another thread + # (but avoid that for stability and speed) + precise_sleep(.01) + # raise RuntimeError("We should never get here") + except RuntimeError as ex: + caught_ex = ex + # If _port is a TcpSocket: + # 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. + 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._onElement: + self._onElement(event_d) + self._mode = OpenLCBNetwork.Mode.Disconnected + raise # re-raise since incomplete (prevent done OK state) + finally: + self.physicalLayer.physicalLayerDown() # Link_Layer_Down, setState + self._listenThread: threading.Thread = None + + 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 = { + 'error': ("Listen loop stopped (caught_ex={})." + .format(formatted_ex(caught_ex))), + 'done': True, + } + 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: 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 + 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) + self._memoryService.requestMemoryRead(memMemo) + + def downloadCDI(self, farNodeID: str, callback=None): + if not farNodeID or not farNodeID.strip(): + raise ValueError("No farNodeID specified.") + self._farNodeID = farNodeID + self._stringTerminated = False + if callback is None: + def callback(event_d): + print("downloadCDI default callback: {}".format(event_d), + file=sys.stderr) + self._onElement = callback + if not self._port: + raise RuntimeError( + "No port connection. Call startListening first.") + if not self.physicalLayer: + raise RuntimeError( + "No physicalLayer. Call startListening first.") + self._cdi_offset = 0 + self._resetTree() + self._mode = OpenLCBNetwork.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: str): + # # print(" SR: {}".format(string.strip())) + # DeprecationWarning("Use a PhysicalLayer subclass' sendFrameAfter") + # self.sendFrameAfter(string) + + # def _printFrame(self, frame: CanFrame): + # # print(" RL: {}".format(frame)) + # pass + + 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 + 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("[_handleMessage] RM: {} from {}" + .format(message, message.source)) + print("[_handleMessage] message.mti={}".format(message.mti)) + if message.mti == MTI.Link_Layer_Down: + if self._onConnect: + self._onConnect({ + 'done': True, + 'error': "Disconnected", + 'message': message, + }) + self._messageStart = None # so _listen won't discard error + return True + elif message.mti == MTI.Link_Layer_Up: + if self._onConnect: + self._onConnect({ + 'done': True, # 'done' without error indicates connected. + 'message': message, + }) + return True + return False + + def _printDatagram(self, memo: DatagramReadMemo): + """A call-back for when datagrams received + + Args: + 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). + """ + # print("Datagram receive call back: {}".format(memo.data)) + return False + + 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) + + 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: MemoryReadMemo): + """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 + cdiString = None + 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._fireStatus("Done loading CDI.") + if self._onElement: + self._onElement({ + 'done': True, # 'done' and not 'error' means got all + }) + 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: 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 + # 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: MemoryReadMemo): + """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._stringTerminated = False + if self._mode == OpenLCBNetwork.Mode.CDI: + # save content + self._CDIReadPartial(memo) + else: + logger.error( + "Unknown data packet received" + " (memory read not triggered by OpenLCBNetwork)") + # update the address + memo.address = memo.address + 64 + # and read again (read next) + self._memoryService.requestMemoryRead(memo) + # The last packet is not yet reached + else: # last chunk + self._stringTerminated = True + # and we're done! + if self._mode == OpenLCBNetwork.Mode.CDI: + self._CDIReadDone(memo) + else: + logger.error( + "Unknown last data packet received" + " (memory read not triggered by OpenLCBNetwork)") + self._mode = OpenLCBNetwork.Mode.Idle # CDI no longer expected + # done reading + + def _memoryReadFail(self, memo: MemoryReadMemo): + error = "memory read failed: {}".format(memo.data) + if self._onElement: + self._onElement({ + 'error': error, + 'done': True, # stop progress in gui/other main thread + }) + else: + logger.error(error) + + 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 : + print(tab, " Attributes: ", attrs.getNames()) + # el = ET.Element(name, attrs) + attrib = attrs_to_dict(attrs) + 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._onElement: + self._onElement(event_d) + + # self._callback_msg( + # "loaded: {}{}".format(tab, ET.tostring(el, encoding="unicode"))) + self._tag_stack.append(el) + self._openEl = el + + def checkDone(self, event_d: dict): + """Notify the caller if parsing is over. + 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 + case. + + Returns: + dict: Reserved for use without events (doesn't need to be + processed if self._onElement 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._onElement: + self._onElement(event_d) + return event_d + + def endElement(self, name: str): + """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 + # print(tab, name, "content:", self._flushCharBuffer()) + print(tab, "End: ", 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'])) + 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._openEl = self._tag_stack[-1] + else: + self._openEl = 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._onElement(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: Union[bytearray, bytes, List[int]]): + # """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) diff --git a/openlcb/physicallayer.py b/openlcb/physicallayer.py index 4db1b82..a242bb1 100644 --- a/openlcb/physicallayer.py +++ b/openlcb/physicallayer.py @@ -3,15 +3,222 @@ 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 _send_frames. + +We implement logic here not only because it is convenient but also +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 +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 +from logging import getLogger +from typing import Union + +from openlcb.portinterface import PortInterface + +logger = getLogger(__name__) + 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._sentFramesCount = 0 + 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( + "This method is only for Realtime subclass(es)" + " (which should only be used when not using GridConnect" + " 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 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: + # 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 + super().pollFrame) then enforce type, only if not None, before + returning the return of this superclass method. + + Returns: + 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: + 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 + + 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) + idx = reservation + newFrames = \ + [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() + 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) + 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 + if self.onQueuedFrame: + self.onQueuedFrame(frame) + + 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). + + 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 onFrameSent must be manually set" + " to the LinkLayer subclass instance' handleFrameSent" + " so state can be updated if necessary.") + + 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 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): - 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.") diff --git a/openlcb/pip.py b/openlcb/pip.py index c51782c..3b56c95 100644 --- a/openlcb/pip.py +++ b/openlcb/pip.py @@ -7,6 +7,12 @@ ''' from enum import Enum +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): @@ -35,31 +41,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,31 +89,32 @@ 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: - 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. """ - data = 0 - if (len(raw) > 0): - data |= ((raw[0]) << 24) - if (len(raw) > 1): - data |= ((raw[1]) << 16) - if (len(raw) > 2): - data |= ((raw[2]) << 8) - if (len(raw) > 3): - data |= ((raw[3])) - return PIP.setContentsFromInt(data) + bitmask = 0 + 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/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 new file mode 100644 index 0000000..fc4423c --- /dev/null +++ b/openlcb/portinterface.py @@ -0,0 +1,178 @@ +"""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 handleData 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). + """ + + 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 + self._device = 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 InterruptedError( + "Untracked {} ended during {}" + " Check busy() first or setListeners" + " (implementation problem: See OpenLCBNetwork" + " for correct example)" + .format(caller, self._busy_message)) + self._busy_message = None + + def assertNotBusy(self, caller): + if self._busy_message: + raise InterruptedError( + "{} was called during {}." + " Check busy() first or setListeners" + " and wait for {} ready" + " (or use OpenLCBNetwork to send&receive)" + .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, 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, device=None): + """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) + device (Union[socket.socket, serial.Serial, None]): Existing + hardware abstraction: Type depends on implementation. + """ + self._setBusy("connect") + 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). + See connect for documentation, but host is None in this case. + """ + 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.") + + def send(self, data: Union[bytes, bytearray]) -> None: + """ + + Raises: + InterruptedError: (raised by assertNotBusy) if + port is in use. Use sendFrameAfter to avoid this. + + Args: + data (Union[bytes, bytearray]): _description_ + """ + 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") + try: + result = self._receive() + finally: + self._unsetBusy("receive") + 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() + + # 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: str): + """Send a single string. + """ + self.send(string.encode('utf-8')) + # Use send (uses required semaphores) not _send (not thread safe) 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 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 new file mode 100644 index 0000000..91fa8de --- /dev/null +++ b/openlcb/realtimephysicallayer.py @@ -0,0 +1,111 @@ + +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 + Permitted = 2 + + 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], 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)) + if verbose: + 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). + + 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) + # ) + if verbose: + 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. + 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 + 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).") diff --git a/openlcb/realtimerawphysicallayer.py b/openlcb/realtimerawphysicallayer.py new file mode 100644 index 0000000..0197f8c --- /dev/null +++ b/openlcb/realtimerawphysicallayer.py @@ -0,0 +1,38 @@ +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) + 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 + # 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("- SENT data (realtime raw): {}".format(data.strip())) + self.sock.send(data) diff --git a/openlcb/remotenodeprocessor.py b/openlcb/remotenodeprocessor.py index 668832a..fd1dcee 100644 --- a/openlcb/remotenodeprocessor.py +++ b/openlcb/remotenodeprocessor.py @@ -1,5 +1,7 @@ +from typing import Callable 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 +19,24 @@ 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 + self._nodeIdentifiedListeners = [] + self._producerUpdatedListeners = [] + self._consumerUpdatedListeners = [] - def process(self, message, node) : + 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 NOTE: linkLayer up/down are marked as global @@ -43,35 +59,35 @@ def process(self, message, 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, 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 +95,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 +120,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,28 +130,34 @@ 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() + for callback in self._nodeIdentifiedListeners: + callback(node) - 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) + for callback in self._producerUpdatedListeners: + callback(node, 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) # register it node.events.consumes(eventID) + for callback in self._consumerUpdatedListeners: + callback(node, eventID) diff --git a/openlcb/scanner.py b/openlcb/scanner.py new file mode 100644 index 0000000..439874c --- /dev/null +++ b/openlcb/scanner.py @@ -0,0 +1,115 @@ + +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 + (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) -> 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__)) + result = self._buffer[0] + del self._buffer[0] + return result + + def hasNextByte(self) -> bool: + return True if self._buffer else False + + def hasNext(self) -> bool: + 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)() # 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) + 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.") + # 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 + + 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/openlcb/snip.py b/openlcb/snip.py index 9a840f9..ab41c52 100644 --- a/openlcb/snip.py +++ b/openlcb/snip.py @@ -1,5 +1,6 @@ import logging +from typing import Union class SNIP: @@ -27,12 +28,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 @@ -46,12 +53,11 @@ 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): - ''' - 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 +80,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 +113,7 @@ def findString(self, n): # fell out without finding return 0 - def getString(self, first, maxLength): + 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. @@ -124,10 +132,8 @@ 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 + def addData(self, in_data: Union[bytearray, bytes]): + '''Add additional bytes of SNIP data ''' for i in range(0, len(in_data)): # protect against overlapping requests causing an overflow @@ -139,8 +145,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) @@ -151,8 +156,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) @@ -168,7 +172,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)) @@ -178,7 +182,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)) @@ -188,7 +192,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)) @@ -198,10 +202,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)) @@ -211,7 +216,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)) @@ -222,10 +227,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 15da4fd..c990bab 100644 --- a/openlcb/tcplink/tcplink.py +++ b/openlcb/tcplink/tcplink.py @@ -10,14 +10,19 @@ 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 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 class TcpLink(LinkLayer): @@ -31,31 +36,46 @@ 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, localNodeID): + def __init__(self, physicalLayer: PhysicalLayer, localNodeID: NodeID): + 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() - - 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 - - def receiveListener(self, inputData): # [] input + self.physicalLayer = physicalLayer # formerly linkCall + self.localNodeID = localNodeID # unused here + + # 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. + # """ + # 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}" + " (nothing to do since TcpLink)") + + 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, (bytes, bytearray)) self.accumulatedData.extend(inputData) # Now check it if has one or more complete message. while len(self.accumulatedData) > 0 : @@ -87,13 +107,14 @@ def receiveListener(self, inputData): # [] input self.accumulatedData = self.accumulatedData[5+length:] # and repeat - def receivedPart(self, messagePart, flags, length): - """Receives message parts from receiveListener + def receivedPart(self, messagePart: bytearray, flags: int, length: int): + """Receives message parts from handleFrameReceived 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]) @@ -128,13 +149,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 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 + messageBytes (Union[bytearray, list[int]]) : the bytes making up a single OpenLCB message, starting with the MTI """ # extract MTI @@ -150,34 +171,37 @@ 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): + 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 @@ -214,4 +238,6 @@ def sendMessage(self, message): outputBytes.extend(message.data) - self.linkCall(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/openlcb/tcplink/tcpsocket.py b/openlcb/tcplink/tcpsocket.py index 3cf9d56..37a6752 100644 --- a/openlcb/tcplink/tcpsocket.py +++ b/openlcb/tcplink/tcpsocket.py @@ -5,52 +5,104 @@ # https://docs.python.org/3/howto/sockets.html import socket +from typing import Union -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 +from openlcb.portinterface import PortInterface +from logging import getLogger + +logger = getLogger(__name__) - def settimeout(self, seconds): + +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): + super(TcpSocket, self).__init__() + + def _settimeout(self, seconds: float): """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) + self._device.settimeout(seconds) - def connect(self, host, port): - self.sock.connect((host, port)) + 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( + socket.AF_INET, + socket.SOCK_STREAM, + ) + else: + self._device = device - def send(self, data): - '''Send a single message, provided as an [int] - ''' - msg = bytes(data) + 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). + logger.warning("You must call physicalLayerUp after this") + + 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) + """ + # 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(msg[total_sent:]): - sent = self.sock.send(msg[total_sent:]) + while total_sent < len(data[total_sent:]): + sent = self._device.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. + 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. ''' - chunk = self.sock.recv(128) - if chunk == b'': + # 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: + # None is only expected allowed in non-blocking mode + return None + # ^ 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): - self.sock.close() - return + def _close(self): + self._device.close() + return None 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..20ef10c 100644 --- a/python-openlcb.code-workspace +++ b/python-openlcb.code-workspace @@ -19,50 +19,89 @@ "1Bdddsss", "1Cdddsss", "1Ddddsss", + "ABCN", "AccumKey", + "acdi", "ADCDI", + "appendleft", "autosummary", "baudrate", + "bitfield", + "bitfields", "bitmask", "bitmasks", "bobjacobsen", "canbus", "canframe", "canlink", + "canlinklayersimulation", "canphysicallayer", + "canphysicallayergridconnect", "canphysicallayersimulation", + "cdiform", "columnspan", "controlframe", "datagram", "datagrams", "datagramservice", + "deque", "distros", "dmemo", "Dmitry", "dunder", + "frameencoder", + "gaierror", "gridargs", + "gridconnectobserver", + "issuecomment", "JMRI", "linklayer", + "LOCALAPPDATA", "localeventstore", + "localnodeprocessor", "localoverrides", "MDNS", + "mdnsconventions", "memoryservice", "metas", "MSGLEN", "nodeid", "nodestore", + "offvalue", + "onvalue", "openlcb", + "openlcbnetwork", "padx", "pady", "physicallayer", + "platformextras", "Poikilos", + "popleft", + "portinterface", "pyproject", + "pyserial", + "pythonopenlcb", + "rawphysicallayer", + "realtimephysicallayer", + "realtimerawphysicallayer", + "remotenodeprocessor", + "remotenodestore", + "repr", + "runlevel", + "seriallink", "servicetype", "settingtypes", "setuptools", + "sysdirs", + "tcplink", + "tcpsocket", "textvariable", + "tkexamples", "unformatting", "usbmodem", + "WASI", + "winnative", + "xscrollcommand", "zeroconf" ] } 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 * 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..253c7a4 100644 --- a/tests/test_canlink.py +++ b/tests/test_canlink.py @@ -1,23 +1,69 @@ +from typing import Union import unittest +from openlcb import formatted_ex 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 +from openlcb.canbus.canphysicallayersimulation import ( + CanPhysicalLayerSimulation +) from openlcb.message import Message from openlcb.mti import MTI from openlcb.nodeid import NodeID from openlcb.canbus.controlframe import ControlFrame +from openlcb.portinterface import PortInterface class PhyMockLayer(CanPhysicalLayer): def __init__(self): - self.receivedFrames = [] + # onFrameSent will not work until this instance is passed to the + # LinkLayer subclass' constructor (See onFrameSent + # docstring in PhysicalLayer) + self.sentFrames = [] CanPhysicalLayer.__init__(self) - def sendCanFrame(self, frame): - self.receivedFrames.append(frame) + 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) + + 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. + + 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). + """ + 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 frame (simulated socket) packet: {}" + .format(string.strip())) + self.physicalLayer.onFrameSent(frame) + count += 1 + return count class MessageMockLayer: @@ -29,11 +75,37 @@ def receiveMessage(self, msg): self.receivedMessages.append(msg) +class MockPort(PortInterface): + def send(self, data: Union[bytearray, bytes]): + pass + + def sendString(self, data: str): + pass + + def receive(self): + return None + + +def getLocalNodeIDStr(): + return "05.01.01.01.03.01" + + +def getLocalNodeID(): + return NodeID(getLocalNodeIDStr()) + + class TestCanLinkClass(unittest.TestCase): + 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 = CanLink(NodeID("05.01.01.01.03.01")) + physicalLayer = PhyMockLayer() + canLink = CanLinkLayerSimulation(physicalLayer, getLocalNodeID()) # check precision of calculation self.assertEqual(canLink.incrementAlias48(0), 0x1B0C_A37A_4BA9, @@ -42,9 +114,11 @@ def testIncrementAlias48(self): # test shift and multiplication operations next = canLink.incrementAlias48(0x0000_0000_0001) self.assertEqual(next, 0x1B0C_A37A_4DAA) + physicalLayer.physicalLayerDown() def testIncrementAliasSequence(self): - canLink = CanLink(NodeID("05.01.01.01.03.01")) + physicalLayer = PhyMockLayer() + canLink = CanLinkLayerSimulation(physicalLayer, getLocalNodeID()) # sequence from TN next = canLink.incrementAlias48(0) @@ -61,9 +135,11 @@ def testIncrementAliasSequence(self): next = canLink.incrementAlias48(next) self.assertEqual(next, 0xE5_82_F9_B4_AE_4D) + physicalLayer.physicalLayerDown() def testCreateAlias12(self): - canLink = CanLink(NodeID("05.01.01.01.03.01")) + physicalLayer = PhyMockLayer() + canLink = CanLinkLayerSimulation(physicalLayer, getLocalNodeID()) # check precision of calculation self.assertEqual(canLink.createAlias12(0x001), 0x001, "0x001 input") @@ -78,161 +154,210 @@ def testCreateAlias12(self): self.assertEqual(canLink.createAlias12(0x0000), 0xAEF, "zero input check") + physicalLayer.physicalLayerDown() # MARK: - Test PHY Up def testLinkUpSequence(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLinkLayerSimulation( + canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() + canLink.waitForReady(self.device) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 7) - self.assertEqual(canLink.state, CanLink.State.Permitted) + self.assertEqual(len(canPhysicalLayer.sentFrames), 7) + self.assertEqual(canLink._state, CanLink.State.Permitted) self.assertEqual(len(messageLayer.receivedMessages), 1) + canPhysicalLayer.physicalLayerDown() # 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 = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) 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) + canPhysicalLayer.physicalLayerDown() - def testAEIE2noData(self): + def testEIR2NoData(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) - canLink.state = CanLink.State.Permitted + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) + canLink._state = CanLink.State.Permitted - canPhysicalLayer.fireListeners(CanFrame(ControlFrame.EIR2.value, 0)) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + canPhysicalLayer.fireFrameReceived( + CanFrame(ControlFrame.EIR2.value, 0)) + self.assertEqual(len(canPhysicalLayer.sentFrames), 0) + canPhysicalLayer.physicalLayerDown() # 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) - 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) - self.assertEqual( - canPhysicalLayer.receivedFrames[0], + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) + ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) + canLink._state = CanLink.State.Permitted + + canPhysicalLayer.fireFrameReceived(CanFrame(ControlFrame.AME.value, 0)) + 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()) ) + canPhysicalLayer.physicalLayerDown() def testAMEnoDataInhibited(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) - canLink.state = CanLink.State.Inhibited + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) + canLink._state = CanLink.State.Inhibited - canPhysicalLayer.fireListeners(CanFrame(ControlFrame.AME.value, 0)) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + canPhysicalLayer.fireFrameReceived(CanFrame(ControlFrame.AME.value, 0)) + self.assertEqual(len(canPhysicalLayer.sentFrames), 0) + canPhysicalLayer.physicalLayerDown() 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) - canLink.linkPhysicalLayer(canPhysicalLayer) - canLink.state = CanLink.State.Permitted + 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) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 1) - self.assertEqual(canPhysicalLayer.receivedFrames[0], - CanFrame(ControlFrame.AMD.value, ourAlias, - canLink.localNodeID.toArray())) + canPhysicalLayer.fireFrameReceived(frame) + 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())) + canPhysicalLayer.physicalLayerDown() - def testAMEnotMatchEvent(self): + def testAMENotMatchEvent(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) - canLink.state = CanLink.State.Permitted + 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) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + canPhysicalLayer.fireFrameReceived(frame) + self.assertEqual(len(canPhysicalLayer.sentFrames), 0) + canPhysicalLayer.physicalLayerDown() # 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) - canLink.linkPhysicalLayer(canPhysicalLayer) - 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 = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) + 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.sendAll(None) # add response to sentFrames + self.assertEqual(len(canPhysicalLayer.sentFrames), 1) + self.assertFrameEqual(canPhysicalLayer.sentFrames[0], + CanFrame(ControlFrame.RID.value, ourAlias)) + canPhysicalLayer.physicalLayerDown() 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) - canLink.linkPhysicalLayer(canPhysicalLayer) - canLink.state = CanLink.State.Permitted - - canPhysicalLayer.fireListeners(CanFrame(ControlFrame.RID.value, - ourAlias)) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 8) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) + ourAlias = canLink._localAlias # 576 with NodeID(0x05_01_01_01_03_01) + canLink._state = CanLink.State.Permitted + + canPhysicalLayer.fireFrameReceived( + CanFrame(ControlFrame.RID.value, ourAlias)) + # ^ collision + canLink.waitForReady(self.device) + 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.assertEqual(canLink.state, CanLink.State.Permitted) + 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) + canPhysicalLayer.physicalLayerDown() - def testCheckMTImapping(self): + def testCheckMTIMapping(self): - canLink = CanLink(NodeID("05.01.01.01.03.01")) + physicalLayer = PhyMockLayer() + canLink = CanLinkLayerSimulation(physicalLayer, getLocalNodeID()) self.assertEqual( - canLink.canHeaderToFullFormat(CanFrame(0x19490247, - bytearray())), + canLink.canHeaderToFullFormat( + CanFrame(0x19490247, bytearray())), MTI.Verify_NodeID_Number_Global ) def testControlFrameDecode(self): - canLink = CanLink(NodeID("05.01.01.01.03.01")) + physicalLayer = PhyMockLayer() + canLink = CanLinkLayerSimulation(physicalLayer, getLocalNodeID()) frame = CanFrame(0x1000, 0x000) # invalid control frame content self.assertEqual(canLink.decodeControlFrameFormat(frame), ControlFrame.UnknownFormat) + physicalLayer.physicalLayerDown() + + 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")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) 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) 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) + self.assertEqual(len(canPhysicalLayer.sentFrames), 0) # ^ nothing back down to CAN self.assertEqual(len(messageLayer.receivedMessages), 1) # ^ one message forwarded @@ -241,25 +366,25 @@ def testSimpleGlobalData(self): MTI.Verify_NodeID_Number_Global) self.assertEqual(messageLayer.receivedMessages[0].source, NodeID(0x010203040506)) + canPhysicalLayer.physicalLayerDown() def testVerifiedNodeInDestAliasMap(self): # JMRI doesn't send AMD, so gets assigned 00.00.00.00.00.00 # This tests that a VerifiedNode will update that. canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + 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.fireListeners(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) + self.assertEqual(len(canPhysicalLayer.sentFrames), 0) # ^ nothing back down to CAN self.assertEqual(len(messageLayer.receivedMessages), 1) # ^ one message forwarded @@ -268,6 +393,7 @@ def testVerifiedNodeInDestAliasMap(self): MTI.Verified_NodeID) self.assertEqual(messageLayer.receivedMessages[0].source, NodeID(0x080706050403)) + canPhysicalLayer.physicalLayerDown() def testNoDestInAliasMap(self): '''Tests handling of a message with a destination alias not in map @@ -275,19 +401,18 @@ def testNoDestInAliasMap(self): ''' canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLinkLayerSimulation(canPhysicalLayer, getLocalNodeID()) 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 - canPhysicalLayer.fireListeners(CanFrame(0x19968, 0x247, - bytearray([8, 7, 6, 5, 4, 3]))) + canPhysicalLayer.fireFrameReceived( + 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 @@ -296,26 +421,29 @@ def testNoDestInAliasMap(self): MTI.Identify_Events_Addressed) self.assertEqual(messageLayer.receivedMessages[0].source, NodeID(0x000000000001)) + canPhysicalLayer.physicalLayerDown() def testSimpleAddressedData(self): # Test start=yes, end=yes frame canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLinkLayerSimulation( + canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() + canLink.waitForReady(self.device) # 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) + 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 @@ -329,26 +457,28 @@ 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.physicalLayerDown() 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.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLinkLayerSimulation( + canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() + canLink.waitForReady(self.device) # 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] ) - 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 @@ -363,30 +493,32 @@ 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.physicalLayerDown() 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.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLinkLayerSimulation( + canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() + canLink.waitForReady(self.device) # 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) + 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 @@ -395,7 +527,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 @@ -407,27 +539,29 @@ def testMultiFrameAddressedData(self): NodeID(0x01_02_03_04_05_06)) self.assertEqual(messageLayer.receivedMessages[1].destination, NodeID(0x05_01_01_01_03_01)) + canPhysicalLayer.physicalLayerDown() def testSimpleDatagram(self): # Test start=yes, end=yes frame canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLinkLayerSimulation( + canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) canPhysicalLayer.physicalLayerUp() + canLink.waitForReady(self.device) # 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 @@ -443,33 +577,45 @@ 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.physicalLayerDown() - def testThreeFrameDatagrm(self): + def testMultiFrameDatagram(self): canPhysicalLayer = CanPhysicalLayerSimulation() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + canLink = CanLinkLayerSimulation( + canPhysicalLayer, getLocalNodeID()) messageLayer = MessageMockLayer() canLink.registerMessageReceivedListener(messageLayer.receiveMessage) - + print("[testMultiFrameDatagram] state={}" + .format(canLink.getState())) canPhysicalLayer.physicalLayerUp() + canLink.waitForReady(self.device) # 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() + 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.onFrameSent(frame) self.assertEqual(len(messageLayer.receivedMessages), 2) # ^ startup plus one message forwarded @@ -493,113 +639,116 @@ def testThreeFrameDatagrm(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.physicalLayerDown() def testZeroLengthDatagram(self): canPhysicalLayer = PhyMockLayer() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + 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) - 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 []") + canPhysicalLayer.physicalLayerDown() def testOneFrameDatagram(self): canPhysicalLayer = PhyMockLayer() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + 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) - 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]" ) + canPhysicalLayer.physicalLayerDown() def testTwoFrameDatagram(self): canPhysicalLayer = PhyMockLayer() - canLink = CanLink(NodeID("05.01.01.01.03.01")) - canLink.linkPhysicalLayer(canPhysicalLayer) + 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])) 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]" ) + canPhysicalLayer.physicalLayerDown() 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 = 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])) 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]") + canPhysicalLayer.physicalLayerDown() # 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) - canLink.linkPhysicalLayer(canPhysicalLayer) + 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) self.assertEqual(len(canLink.nodeIdToAlias), 1) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.sentFrames), 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) self.assertEqual(len(canLink.nodeIdToAlias), 0) - self.assertEqual(len(canPhysicalLayer.receivedFrames), 0) + self.assertEqual(len(canPhysicalLayer.sentFrames), 0) # ^ nothing back down to CAN + canPhysicalLayer.physicalLayerDown() # MARK: - Data size handling def testSegmentAddressedDataArray(self): - canLink = CanLink(NodeID("05.01.01.01.03.01")) + physicalLayer = PhyMockLayer() + canLink = CanLinkLayerSimulation(physicalLayer, getLocalNodeID()) # no data self.assertEqual( @@ -642,9 +791,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 + physicalLayer.physicalLayerDown() def testSegmentDatagramDataArray(self): - canLink = CanLink(NodeID("05.01.01.01.03.01")) + physicalLayer = PhyMockLayer() + canLink = CanLinkLayerSimulation(physicalLayer, getLocalNodeID()) # no data self.assertEqual( @@ -689,7 +840,43 @@ 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.physicalLayerDown() + + 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.value, int) if __name__ == '__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)) + # if errors: + # print("{} test(s) failed.".format(len(errors))) diff --git a/tests/test_canphysicallayer.py b/tests/test_canphysicallayer.py index 4a3a7c6..7e140bf 100644 --- a/tests/test_canphysicallayer.py +++ b/tests/test_canphysicallayer.py @@ -9,17 +9,33 @@ class TestCanPhysicalLayerClass(unittest.TestCase): # test function marks that the listeners were fired received = False - def receiveListener(self, frame): + def __init__(self, *args): + unittest.TestCase.__init__(self, *args) + self.layer = None + self._sentFramesCount = 0 + + def receiveListener(self, frame: CanFrame): self.received = True + def handleFrameReceived(self, frame: CanFrame): + pass + + def handleFrameSent(self, frame: CanFrame): + 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) - layer.fireListeners(frame) + layer.fireFrameReceived(frame) self.assertTrue(self.received) diff --git a/tests/test_canphysicallayergridconnect.py b/tests/test_canphysicallayergridconnect.py index 2f0c81a..cbac5ee 100644 --- a/tests/test_canphysicallayergridconnect.py +++ b/tests/test_canphysicallayergridconnect.py @@ -1,63 +1,112 @@ import unittest +from openlcb import emit_cast from openlcb.canbus.canphysicallayergridconnect import ( + GC_END_BYTE, CanPhysicalLayerGridConnect, ) 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) + # ^ Sets onQueuedFrame on None, so set it afterward: + self.onQueuedFrame = self.captureString + + def captureString(self, frame: CanFrame): + # formerly was in CanPhysicalLayerGridConnectTest + # 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 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. + + def onFrameReceived(self, frame: CanFrame): + pass + # NOTE: not patching + # self.onFrameReceived = canLink.handleFrameReceived + # since testing only physical layer not link layer. + + class CanPhysicalLayerGridConnectTest(unittest.TestCase): def __init__(self, *args, **kwargs): super(CanPhysicalLayerGridConnectTest, self).__init__(*args, **kwargs) - self.capturedString = "" + # self.capturedString = "" + self.physicalLayer = PhysicalLayerMock() + self.physicalLayer.capturedFrame = None self.receivedFrames = [] # PHY side - def captureString(self, string): - self.capturedString = string + # def captureString(self, string): + # self.capturedString = string # Link Layer side - def receiveListener(self, frame): + def receiveListener(self, frame: CanFrame): self.receivedFrames += [frame] def testCID4Sent(self): - gc = CanPhysicalLayerGridConnect(self.captureString) - - gc.sendCanFrame(CanFrame(4, NodeID(0x010203040506), 0xABC)) - self.assertEqual(self.capturedString, ":X14506ABCN;\n") + 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.gc.capturedString, + ":X14506ABCN;\n") def testVerifyNodeSent(self): - gc = CanPhysicalLayerGridConnect(self.captureString) - - gc.sendCanFrame(CanFrame(0x19170, 0x365, bytearray([ - 0x02, 0x01, 0x12, 0xFE, - 0x05, 0x6C]))) - self.assertEqual(self.capturedString, ":X19170365N020112FE056C;\n") + self.gc = PhysicalLayerMock() + frame = CanFrame(0x19170, 0x365, + bytearray([0x02, 0x01, 0x12, 0xFE, 0x05, 0x6C])) + frame.encoder = self.gc + assert self.gc.onQueuedFrame is not None + self.gc.sendFrameAfter(frame) + # self.assertEqual(self.capturedString, ":X19170365N020112FE056C;\n") + self.assertEqual(self.gc.capturedString, + ":X19170365N020112FE056C;\n") def testOneFrameReceivedExactlyHeaderOnly(self): - gc = CanPhysicalLayerGridConnect(self.captureString) - gc.registerFrameReceivedListener(self.receiveListener) + self.gc = PhysicalLayerMock() + 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) + self.gc.handleData(bytes) - self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) + self.assertEqual( + self.receivedFrames[0], + CanFrame(0x19490365, bytearray()) + ) def testOneFrameReceivedExactlyWithData(self): - gc = CanPhysicalLayerGridConnect(self.captureString) - gc.registerFrameReceivedListener(self.receiveListener) + self.gc = PhysicalLayerMock() + self.gc.registerFrameReceivedListener(self.receiveListener) bytes = bytearray([ 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) + self.gc.handleData(bytes) self.assertEqual( self.receivedFrames[0], @@ -66,13 +115,13 @@ def testOneFrameReceivedExactlyWithData(self): ) def testOneFrameReceivedHeaderOnlyTwice(self): - gc = CanPhysicalLayerGridConnect(self.captureString) - gc.registerFrameReceivedListener(self.receiveListener) + self.gc = PhysicalLayerMock() + 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) + self.gc.handleData(bytes+bytes) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) @@ -80,39 +129,39 @@ def testOneFrameReceivedHeaderOnlyTwice(self): CanFrame(0x19490365, bytearray())) def testOneFrameReceivedHeaderOnlyPlusPartOfAnother(self): - gc = CanPhysicalLayerGridConnect(self.captureString) - gc.registerFrameReceivedListener(self.receiveListener) + self.gc = PhysicalLayerMock() + 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) + self.gc.handleData(bytes) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) bytes = bytearray([ 0x31, 0x39, 0x34, 0x39, 0x30, 0x33, - 0x36, 0x35, 0x4e, 0x3b, 0x0a]) - gc.receiveChars(bytes) + 0x36, 0x35, 0x4e, GC_END_BYTE, 0x0a]) + self.gc.handleData(bytes) self.assertEqual(self.receivedFrames[1], CanFrame(0x19490365, bytearray())) def testOneFrameReceivedInTwoChunks(self): - gc = CanPhysicalLayerGridConnect(self.captureString) - gc.registerFrameReceivedListener(self.receiveListener) + self.gc = PhysicalLayerMock() + self.gc.registerFrameReceivedListener(self.receiveListener) bytes1 = bytearray([ 0x3a, 0x58, 0x31, 0x39, 0x31, 0x37, 0x30, 0x33, 0x36, 0x35, 0x4e, 0x30]) # :X19170365N020112FE056C; - gc.receiveChars(bytes1) + self.gc.handleData(bytes1) bytes2 = bytearray([ 0x32, 0x30, 0x31, 0x31, 0x32, 0x46, 0x45, 0x30, 0x35, 0x36, - 0x43, 0x3b]) - gc.receiveChars(bytes2) + 0x43, GC_END_BYTE]) + self.gc.handleData(bytes2) self.assertEqual( self.receivedFrames[0], @@ -121,21 +170,21 @@ def testOneFrameReceivedInTwoChunks(self): ) def testSequence(self): - gc = CanPhysicalLayerGridConnect(self.captureString) - gc.registerFrameReceivedListener(self.receiveListener) + self.gc = PhysicalLayerMock() + 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) + self.gc.handleData(bytes) self.assertEqual(len(self.receivedFrames), 1) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) self.receivedFrames = [] - gc.receiveChars(bytes) + self.gc.handleData(bytes) self.assertEqual(len(self.receivedFrames), 1) self.assertEqual(self.receivedFrames[0], CanFrame(0x19490365, bytearray())) 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_conventions.py b/tests/test_conventions.py index 6ccd0e7..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__": @@ -44,8 +47,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 +59,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_datagramservice.py b/tests/test_datagramservice.py index e0daa3e..22baca4 100644 --- a/tests/test_datagramservice.py +++ b/tests/test_datagramservice.py @@ -9,18 +9,36 @@ 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 = [] - def sendMessage(self, message): + class State: + Initial = 0 + Disconnected = 1 + Permitted = 2 + + DisconnectedState = State.Disconnected + + def sendMessage(self, message, verbose=False): 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): - self.service = DatagramService(LinkMockLayer(NodeID(12))) + self.service = DatagramService( + LinkMockLayer(MockPhysicalLayer(), NodeID(12)) + ) LinkMockLayer.sentMessages = [] self.received = False self.readMemos = [] @@ -31,13 +49,13 @@ def receiveListener(self, msg): self.readMemos.append(msg) return True - def testFireListeners(self): + def testFireDatagramReceived(self): msg = DatagramReadMemo(NodeID(12), bytearray()) receiver = self.receiveListener self.service.registerDatagramReceivedListener(receiver) - self.service.fireListeners(msg) + self.service.fireDatagramReceived(msg) self.assertTrue(self.received) @@ -167,6 +185,14 @@ 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_linklayer.py b/tests/test_linklayer.py index d2f0aea..12c70ce 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,13 +24,24 @@ 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) + layer.fireMessageReceived(msg) 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_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_localnodeprocessor.py b/tests/test_localnodeprocessor.py index 5cb0fa1..454f97f 100644 --- a/tests/test_localnodeprocessor.py +++ b/tests/test_localnodeprocessor.py @@ -5,23 +5,41 @@ 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 = [] - def sendMessage(self, message): + class State: + Initial = 0 + Disconnected = 1 + Permitted = 2 + + DisconnectedState = State.Disconnected + + def sendMessage(self, message, verbose=False): 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): 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_mdnsconventions.py b/tests/test_mdnsconventions.py index c760fbf..b3fc7c5 100644 --- a/tests/test_mdnsconventions.py +++ b/tests/test_mdnsconventions.py @@ -2,14 +2,26 @@ import sys import unittest +from logging import getLogger +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. 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 +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 72c3f67..19d792f 100644 --- a/tests/test_memoryservice.py +++ b/tests/test_memoryservice.py @@ -4,34 +4,64 @@ import sys import unittest +from logging import getLogger + +from openlcb.physicallayer import PhysicalLayer +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. TESTS_DIR = os.path.dirname(os.path.realpath(__file__)) REPO_DIR = os.path.dirname(TESTS_DIR) - sys.path.insert(0, 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 ( + 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 # 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, ) +class MockPhysicalLayer(PhysicalLayer): + pass + + class LinkMockLayer(LinkLayer): + + class State: + Initial = 0 + Disconnected = 1 + Permitted = 2 + + DisconnectedState = State.Disconnected + sentMessages = [] - def sendMessage(self, message): + def sendMessage(self, message, verbose=False): 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): @@ -45,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_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_openlcb.py b/tests/test_openlcb.py index 9bbdb5d..c803003 100644 --- a/tests/test_openlcb.py +++ b/tests/test_openlcb.py @@ -1,14 +1,31 @@ import os import sys +import time import unittest +from logging import getLogger +if __name__ == "__main__": + logger = getLogger(__file__) +else: + 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))) + +import openlcb # noqa: E402 -from openlcb import ( +# for brevity: +from openlcb import ( # noqa: E402 emit_cast, + formatted_ex, list_type_names, only_hex_pairs, ) @@ -20,12 +37,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"}), @@ -51,6 +68,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() diff --git a/tests/test_openlcbnetwork.py b/tests/test_openlcbnetwork.py new file mode 100644 index 0000000..58162c9 --- /dev/null +++ b/tests/test_openlcbnetwork.py @@ -0,0 +1,20 @@ +import unittest + +from openlcb.openlcbnetwork import OpenLCBNetwork + + +class OpenLCBNetworkTest(unittest.TestCase): + def setUp(self): + pass + + def testEnum(self): + usedValues = set() + # ensure values are unique: + for entry in OpenLCBNetwork.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_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_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_platformextras.py b/tests/test_platformextras.py new file mode 100644 index 0000000..78d99b4 --- /dev/null +++ b/tests/test_platformextras.py @@ -0,0 +1,53 @@ +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). + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_remotenodeprocessor.py b/tests/test_remotenodeprocessor.py index c17cbb1..3c6a5b7 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,23 @@ from openlcb.pip import PIP +class MockPhysicalLayer(PhysicalLayer): + 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): def setUp(self) : self.node21 = Node(NodeID(21)) - self.processor = RemoteNodeProcessor(CanLink(NodeID(100))) + self.physicalLayer = MockPhysicalLayer() + self.canLink = CanLink(self.physicalLayer, NodeID(100)) + self.processor = RemoteNodeProcessor(self.canLink) + + def tearDown(self): + self.physicalLayer.physicalLayerDown() def testInitializationComplete(self) : # not related to node @@ -156,7 +169,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 new file mode 100644 index 0000000..2fa7ade --- /dev/null +++ b/tests/test_scanner.py @@ -0,0 +1,64 @@ +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) + 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]) + 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(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.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. + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_snip.py b/tests/test_snip.py index c4bfebf..8761ff6 100644 --- a/tests/test_snip.py +++ b/tests/test_snip.py @@ -3,15 +3,26 @@ import sys import unittest +from logging import getLogger +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. 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 openlcb.snip import SNIP # noqa: E402 class TestSnipClass(unittest.TestCase): @@ -148,12 +159,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..4681471 100644 --- a/tests/test_tcplink.py +++ b/tests/test_tcplink.py @@ -1,5 +1,6 @@ import unittest +from openlcb.realtimephysicallayer import RealtimePhysicalLayer from openlcb.tcplink.tcplink import TcpLink from openlcb.message import Message @@ -7,7 +8,11 @@ from openlcb.nodeid import NodeID -class TcpMockLayer(): +# class MockPhysicalLayer(PhysicalLayer): +# pass + + +class TcpMockLayer(): # or TcpMockLayer(PortInterface): def __init__(self): self.receivedText = [] @@ -30,9 +35,8 @@ def testLinkUpSequence(self): messageLayer = MessageMockLayer() tcpLayer = TcpMockLayer() - linkLayer = TcpLink(NodeID(100)) + linkLayer = TcpLink(RealtimePhysicalLayer(tcpLayer), NodeID(100)) linkLayer.registerMessageReceivedListener(messageLayer.receiveMessage) - linkLayer.linkPhysicalLayer(tcpLayer.send) linkLayer.linkUp() @@ -43,9 +47,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 +59,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 +71,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 @@ -81,7 +82,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) @@ -92,15 +93,14 @@ 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 0x00, 0x00, 20, ]) - linkLayer.receiveListener(messageText) + linkLayer.handleFrameReceived(messageText) messageText = bytearray([ 0x00, 0x00, 0x00, 0x00, 0x01, 0x23, # source node ID @@ -108,7 +108,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) @@ -119,27 +119,26 @@ 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 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) @@ -150,9 +149,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 @@ -162,7 +160,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 @@ -172,7 +170,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) @@ -185,9 +183,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 @@ -203,7 +200,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) @@ -214,9 +211,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 @@ -230,7 +226,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, @@ -238,7 +234,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) @@ -249,9 +245,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 +275,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())