Teensy Networking Tool

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 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.

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 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 set1). These cannot be done using simple #define macros in the sketch.

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 DP83825I Ethernet PHY.

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 internally2)?

As currently envisioned, there is no need for the device to have any kind of user inputs (buttons, encoders, etc.)3). Its sole UI element is a 20×4 character LCD. We utilize each row as follows:

  1. link state/error messages
  2. source MAC
  3. port description (TLV type 4)
  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.

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

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 Vin4), so it must be level shifted. Unfortunately, a simple voltage divider solution5) 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 V6). 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.

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 default7). 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()


3)
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.
4)
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.
6)
The threshold is 2.3 V for a 3.3 V supply, and $2.3/0.667\approx 3.46$.
7)
CircuitPython can also be installed on the Teensy and Raspberry Pi Pico.
  • rnd/projects/tnt.txt
  • Last modified: 2024-06-02 00:56
  • by asdf