====== 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}}