====== Teensy Networking Tool ====== ===== Background ===== ==== Purpose ==== There are obviously proper tools for inspecting networking gear, but they are prohibitively expensive, especially considering our primary use casefiguring out what's on the other end of an unmarked cable without having to chase tone. Our current solution is to use Wireshark and watch for LLDP frames matching our equipment's OUI. This works great, since our gear reports hostnames, port IDs, management addresses, and even firmware version. Even better would be a pocket-sized standalone device capable of sniffing LLDP. That is exactly what we intend to build with this project. ==== LLDP ==== LLDP frames are of ethertype 0x88cc. Their payload consists of three mandatory TLVs (type-length-value structures) and a variable number of optional TLVs. Each TLV has a 7-bit type field, a 9-bit length field, and a variable-length value field. TLV types are given in the below table. For this project, we are primarily interested in TLV types 4 and 5. ^ Type ^ Name ^ Mandatory? ^ | 0 | End of LLDPDU | No | | 1 | Chassis ID | Yes | | 2 | Port ID | Yes | | 3 | Time to live | Yes | | 4 | Port description | No | | 5 | System name | No | | 6 | System description | No | | 7 | System capabilities | No | | 8 | Management address | No | | 9--126 | Reserved | --- | | 127 | Custom TLVs | No | Text-based TLVs (like the system name and port description) are **not** null-terminated. ==== QNEthernet ==== [[gh>ssilverman/QNEthernet]] is a networking library specifically designed for the Teensy 4.1. Though most of the library is geared toward building client-server applications, it does include a class for directly reading and writing Ethernet frames (appropriately called ''EthernetFrame''). This class is derived from the Arduino ''[[https://www.arduino.cc/reference/en/language/functions/communication/stream/|Stream]]'' class and functions similarly to the ''EthernetUDP'' class. Reception is invoked by calling ''parseFrame()'' to read the next frame and ''data()'' to return a pointer to the frame data. ''parseFrame()'' returns the byte length of the frame, or -1 if no frame is available. In order to access raw frames, some build macros must be set((https://github.com/ssilverman/QNEthernet?tab=readme-ov-file#configuration-macros)). These **cannot** be done using simple ''#define'' macros in the sketch. ==== Teensy 4.1 ==== The Teensy 4.1 is a highly capable microcontroller the size of a stick of gum. We selected it for this application for its integrated [[http://www.ti.com/product/DP83825I|DP83825I]] Ethernet PHY. ===== Design ===== We are currently undecided as to how or if we should implement OUI filtering. Perhaps the code could check for a config file on the microSD card in ''setup()''? How should such a file be formatted for efficient parsing? How should we store the filter list internally((https://nkaaf.github.io/Arduino-List/html/index.html))? ==== Interface ==== As currently envisioned, there is no need for the device to have any kind of user inputs (buttons, encoders, etc.)((Perhaps in the future we could add a button to save a frame to the microSD card. We could also use an encoder to scroll the display or select TLVs to view.)). Its sole UI element is a 20x4 character LCD. We utilize each row as follows: - link state/error messages - source MAC - port description (TLV type 4) - system name (TLV type 5) Because the display is so small, our main loop code won't write to it directly. Rather, we will maintain four arrays as line buffers and call a separate function to update the display every so often. This allows us to buffer lines wider than the screen and scroll them if necessary. We implement the line buffers as a single 2-dimensional array so we can update the lines in any order. As a convention, we end each line with a null byte; this keeps us from having to waste time padding lines. ==== Main loop ==== check link status if down: clear display write "no connection" to display line 1 else: write "connected" to display line 1 wait for new frame if ethertype is 0x88cc: write source MAC to display write port description to display write system name to display ==== Power ==== The power board we have selected for prototyping has a few useful pins. The LBO pin **cannot** be directly connected to the Teensy! It is pulled high to Vin(([[https://learn.adafruit.com/adafruit-powerboost-500-plus-charger/pinouts]]. It's unclear from the page whether that's always the battery voltage or if it's pulled to the USB voltage when charging. We shall assume the latter as a worst case.)), so it must be level shifted. Unfortunately, a simple voltage divider solution((https://electronics.stackexchange.com/a/231619)) will not suffice. This pin isn't pulled to ground until the battery voltage reaches 3.2 V, but the divider's output will drop below the microcontroller's high voltage threshold when the battery is just below 3.5 V((The threshold is 2.3 V for a 3.3 V supply, and $2.3/0.667\approx 3.46$.)). Given that the battery's dead voltage is 3.0 V (and its nominal voltage is 3.7 V), this is not an ideal transition point. A proper level shifter will be required. ===== Prototyping ===== Initial development began on the Teensy but shortly thereafter moved to the [[:Feather]] platform for use in the field, where the project has already more than demonstrated its worth. The core Feather boards are completely interchangeable, regardless of microcontroller; have identical pinouts and on-board Li-Po battery chargers; and are programmed in a dialect of Python (CircuitPython) by default((CircuitPython can also be installed on the Teensy and Raspberry Pi Pico.)). For UI and networking, we added the ethernet and OLED FeatherWings. Unlike the Teensy's QNEthernet library, however, the existing ethernet implementation in CircuitPython does not allow for low-level frame access. We thus needed to compose our own partial driver for the WizNet W5500 PHY chip. ''' ''' import board from digitalio import DigitalInOut, Pull, Direction import displayio from i2cdisplaybus import I2CDisplayBus import math import terminalio import time from adafruit_debouncer import Debouncer from adafruit_display_text.label import Label from adafruit_displayio_ssd1306 import SSD1306 CS_PIN = board.D10 BTNA_PIN = board.D9 BTNB_PIN = board.D6 BTNC_PIN = board.D5 OLED_ADDR = 0x3c OLED_HEIGHT = 32 OLED_WIDTH = 128 MAC_ADDR = '9876b612d4bc' # init oled buttons btnApin = DigitalInOut(BTNA_PIN) btnApin.pull = Pull.UP btnBpin = DigitalInOut(BTNB_PIN) # has built-in pull-up btnCpin = DigitalInOut(BTNC_PIN) btnCpin.pull = Pull.UP btnA = Debouncer(btnApin) btnB = Debouncer(btnBpin) btnC = Debouncer(btnCpin) # display elements mac = Label(terminalio.FONT, text='', y=4) host = Label(terminalio.FONT, text='', y=15) iface = Label(terminalio.FONT, text='', y=26) def initdisplay(): # init oled displayio.release_displays() i2c = board.I2C() displaybus = I2CDisplayBus(i2c, device_address=OLED_ADDR) display = SSD1306(displaybus, width=OLED_WIDTH, height=OLED_HEIGHT) root = displayio.Group() root.append(mac) root.append(host) root.append(iface) display.root_group = root return display # class Wiznet5500: def __init__(self, cs): self.spi = board.SPI() self.cs = DigitalInOut(cs) self.cs.direction = Direction.OUTPUT self.cs.value = True def reset(self): self.write(0b00000, 0x0000, bytearray.fromhex('80')) return self.read(0b00000, 0x0000, 1) def select(self): while not self.spi.try_lock(): pass self.cs.value = False def deselect(self): self.cs.value = True self.spi.unlock() def read(self, block, address, length): data = [] # address data.append((address & 0xff00) >> 8) data.append((address & 0x00ff)) # control byte data.append((block & 0x1f)<<3) preamble = bytearray(data) # transfer self.select() result = bytearray(length) self.spi.write(bytearray(data)) self.spi.readinto(result) self.deselect() return result def write(self, block, address, buf): # buf should already be a bytearray data = [] # address data.append((address & 0xff00) >> 8) data.append((address & 0x00ff)) # control byte data.append(((block & 0x1f) << 3) | 0x4) # transfer self.select() self.spi.write(bytearray(data)) self.spi.write(buf) self.deselect() def recv(self, length): # get Sn_RX_RD ptr = int(self.read(0b00001, 0x0028, 2).hex(), 16) # read data from RX buffer print(f'recv(): getting {length} bytes from {ptr:#x}') data = self.read(0b00011, ptr, length) # update Sn_RX_RD ptr = (ptr + length) & 0xffff out = bytearray.fromhex(f'{ptr:04x}') self.write(0b00001, 0x0028, out) return data def getFrame(self): # get Sn_RX_RSR length = int(self.read(0b00001, 0x0026, 2).hex(), 16) if length > 0: datalen = int(self.recv(2).hex(), 16)-2 # Sn_CR_RECV self.write(0b00001, 0x0001, bytearray.fromhex('40')) frame = self.recv(datalen) # Sn_CR_RECV self.write(0b00001, 0x0001, bytearray.fromhex('40')) return frame else: return None def processlldp(frame): mac.text = '{0:2x}:{1:2x}:{2:2x}:{3:2x}:{4:2x}:{5:2x}'.format( frame[6], frame[7], frame[8], frame[9], frame[10], frame[11] ) pos = 14 while pos < len(frame): t = frame[pos] >> 1 pos += 1 l = (frame[pos-1] << 9) | frame[pos] pos += 1 l &= 0x1ff print(f'{t=}, {l=}') if t == 4: iface.text = frame[pos:pos+l].decode('utf-8') elif t == 5: host.text = frame[pos:pos+l].decode('utf-8') pos += l def hexdump(ba): l = len(ba) rows = math.ceil(l / 16) r = l % 16 for i in range(rows-1): sl = ba[16*i:16*(i+1)] print(f'{sl[0]:02x} {sl[1]:02x} {sl[2]:02x} {sl[3]:02x} {sl[4]:02x} {sl[5]:02x} {sl[6]:02x} {sl[7]:02x} {sl[8]:02x} {sl[9]:02x} {sl[10]:02x} {sl[11]:02x} {sl[12]:02x} {sl[13]:02x} {sl[14]:02x} {sl[15]:02x}') lastrow = '' sl = ba[16*(rows-1):] for i in range(len(sl)): lastrow += f'{sl[i]:02x} ' print(lastrow) def checkButtons(): btnA.update() btnB.update() btnC.update() a = btnA.fell b = btnB.fell c = btnC.fell return a, b, c wiznet = Wiznet5500(CS_PIN) def main(): wiznet.reset() # set tx/rx buf sizes to 16k wiznet.write(0b00001, 0x001e, bytearray.fromhex('10')) wiznet.write(0b00001, 0x001f, bytearray.fromhex('10')) # set mac address wiznet.write(0b00000, 0x0009, bytearray.fromhex(MAC_ADDR)) # open socket 0 in MACRAW wiznet.write(0b00001, 0x0000, bytearray.fromhex('04')) wiznet.write(0b00001, 0x0001, bytearray.fromhex('01')) # verify that S0 is open as expected status = int(wiznet.read(0b00001, 0x0003, 1).hex(), 16) if status != 0x42: mac.text = f'S0 {status=:#x}' # HCF? display = initdisplay() mac.text = 'listening for LLDP...' found = False # main loop while True: a, b, c = checkButtons() if found and a: for line in display.root_group: line.x -= 5 if found and b: for line in display.root_group: line.x += 6 frame = wiznet.getFrame() if not frame: #print('no frame yet') continue found = True ethertype = frame[12:14].hex() if ethertype.lower() == '88cc': processlldp(frame) time.sleep(0.5) if __name__ == '__main__': main() {{tag>electronics networking teensy feather}}