Skip to content

Commit 9bedb0b

Browse files
ner serial extra features (#279)
1 parent 8d4af8d commit 9bedb0b

5 files changed

Lines changed: 280 additions & 0 deletions

File tree

middleware/include/serial.h

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#include <stdio.h>
2+
#include <stdarg.h>
3+
#include "stm32f4xx_hal.h"
4+
5+
/*
6+
* @brief Adds a new data point to a serial monitor window.
7+
* @param title The title of the serial monitor window.
8+
* @param data_label The label for the data point.
9+
* @param format The datatype format for the data point. (e.g. "%d" "%f" "%s" etc.)
10+
* @param ... The data point to be added. This can be a number, string, etc. The datatype must match the format string.
11+
*
12+
* @note Example usage: serial_monitor("Test Panel", "Test Val", "%d", test_val);
13+
*/
14+
void serial_monitor(const char *title, const char *data_label,
15+
const char *format, ...);
16+
17+
/*
18+
* @brief Adds a new data point to a serial graph window.
19+
* @param title The title of the serial graph window.
20+
* @param data_label The label for the data point.
21+
* @param format The datatype format for the data point. (e.g. "%d" "%f" "%u" etc.). This MUST be a plotable value (i.e. a number).
22+
* @param ... The data point to be added. The datatype must match the format string.
23+
*
24+
* @note Example usage: serial_graph("Test Graph", "Test Val", "%d", test_val);
25+
*/
26+
void serial_graph(const char *title, const char *data_label, const char *format,
27+
...);

middleware/src/serial.c

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#include "serial.h"
2+
3+
// TLDR: Serial printfs a line with the format: mtr/<Title>/<Data Label>/[Data]
4+
void serial_monitor(const char *title, const char *data_label,
5+
const char *format, ...)
6+
{
7+
printf("\r\n");
8+
9+
// Print the tag first
10+
printf("mtr/%s/%s/", title, data_label);
11+
12+
// Now handle the rest of the format string
13+
va_list args;
14+
va_start(args, format);
15+
vprintf(format, args);
16+
va_end(args);
17+
printf("\r\n");
18+
}
19+
20+
// TLDR: Serial printfs a line with the format: gph/<Title>/<Data Label>/[HAL_GetTick()]/[Data]
21+
void serial_graph(const char *title, const char *data_label, const char *format,
22+
...)
23+
{
24+
printf("\r\n");
25+
26+
// Print the tag first
27+
printf("gph/%s/%s/%lu/", title, data_label, HAL_GetTick());
28+
29+
// Now handle the rest of the format string
30+
va_list args;
31+
va_start(args, format);
32+
vprintf(format, args);
33+
va_end(args);
34+
printf("\r\n");
35+
}

ner_environment/build_system/build_system.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
# custom modules for functinality that is too large to be included in this script directly
2828
from .miniterm import main as miniterm
29+
from .serial2 import main as serial2_start
2930

3031
# ==============================================================================
3132
# Typer application setup
@@ -191,6 +192,21 @@ def serial(ls: bool = typer.Option(False, "--list", help='''Specify the device t
191192
else:
192193
miniterm(device=device)
193194

195+
# ==============================================================================
196+
# Serial2 command
197+
# ==============================================================================
198+
199+
@app.command(help="Like 'ner serial', but with some extra custom features (message filtering, graphing, and monitoring).")
200+
def serial2(
201+
ls: bool = typer.Option(False, "--list", help="List available serial devices and exit."),
202+
device: str = typer.Option("", "--device", "-d", help="Specify the board to connect to."),
203+
monitor: str = typer.Option(None, "--monitor", help="Opens a monitor window of the specified title. (Note: A monitor window can be created/configured using the serial_monitor() function from serial.c)"),
204+
graph: str = typer.Option(None, "--graph", help="Opens a live graph window of the specified title. (Note: A graph window can be created/configured using the serial_graph() function from serial.c)"),
205+
filter: str = typer.Option(None, "--filter", help="Only shows specific messages. Ex. 'ner serial2 --filter EXAMPLE' will only show printfs that contain the substring 'EXAMPLE'. ")):
206+
"""Custom serial terminal."""
207+
208+
serial2_start(ls=ls, device=device, monitor=monitor, graph=graph, filter=filter)
209+
194210
# ==============================================================================
195211
# Update command
196212
# ==============================================================================
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import subprocess
2+
import platform
3+
import serial
4+
import sys
5+
from rich import print
6+
from rich.panel import Panel
7+
import serial.tools.list_ports
8+
from collections import defaultdict
9+
10+
# Returns true if the script is running in WSL. Returns false otherwise.
11+
def is_wsl():
12+
with open('/proc/version', 'r') as f:
13+
if 'microsoft' in f.read().lower():
14+
return True
15+
return False
16+
17+
# USB DEVICE STUFF
18+
def list_usb_devices():
19+
"""List available USB serial devices based on the operating system."""
20+
os_name = platform.system()
21+
devices = []
22+
23+
try:
24+
if os_name == 'Linux':
25+
result = subprocess.run(['ls /dev/tty*'], shell=True, capture_output=True, text=True)
26+
devices = [device for device in result.stdout.splitlines() if 'ttyUSB' in device or 'ttyACM' in device]
27+
devices.sort(key=lambda x: ('ttyACM' in x, x)) # Prioritize ttyUSB over ttyACM (Aka prioritize FTDI)
28+
else:
29+
print(f"[bold red]Unsupported operating system: {os_name}[/bold red]", file=sys.stderr)
30+
sys.exit(1)
31+
32+
if not devices:
33+
print(f"[bold red]No USB devices found on {os_name}.[/bold red]", file=sys.stderr)
34+
if is_wsl():
35+
print("\n[blue]If you're using WSL, you may need to attach attach the USB device in a Windows terminal.")
36+
print("1. Open a Windows terminal [bold blue]with admin privalleges[/bold blue] and enter 'usbipd list'.")
37+
print("2. Find the device you want to attach (probably named something like 'CMSIS-DAP v2 Interface' or 'USB Serial Device')")
38+
print("3. Note the device's BUSID and run [bold green]'usbipd bind --busid=<BUSID>'[/bold green]. (If you've mounted the device before, skip this step.)")
39+
print("4. Then, run [bold green]'usbipd attach --wsl=ubuntu --busid <BUSID>'.")
40+
print("5. It should work now, so try running the command again.\n")
41+
sys.exit(1)
42+
43+
except subprocess.CalledProcessError as e:
44+
print(f"[bold red]Failed to execute command to list USB devices: {e}[/bold red]", file=sys.stderr)
45+
sys.exit(1)
46+
except Exception as e:
47+
print(f"[bold red]An unexpected error occurred: {e}[/bold red]", file=sys.stderr)
48+
sys.exit(1)
49+
50+
return devices
51+
52+
# MONITOR STUFF
53+
ui_monitor = None
54+
ui_monitor_labels = {}
55+
def update_monitor(line):
56+
global ui_monitor, ui_monitor_labels
57+
"""Update monitor panel with new data."""
58+
59+
import tkinter as tk # Only import tkinter if monitor stuff is being used (dunno if this is python bad practice)
60+
61+
parts = line.strip().split("/")
62+
if len(parts) != 4: return # If there aren't exactly 4 parts, the line is malformed. So, skip it.
63+
title = parts[1]
64+
data_label = parts[2]
65+
value = parts[3]
66+
67+
if ui_monitor is None: # If the monitor window doesn't exist yet, create it
68+
ui_monitor = tk.Tk()
69+
ui_monitor.title(title)
70+
71+
if data_label not in ui_monitor_labels: # If this data label hasn't been seen yet, create it
72+
ui_monitor_labels[data_label] = tk.Label(ui_monitor, text=f"{data_label}: {value}", font=("Helvetica", 12))
73+
ui_monitor_labels[data_label].pack()
74+
else: # (Otherwise, update the existing label)
75+
ui_monitor_labels[data_label].config(text=f"{data_label}: {value}")
76+
77+
try:
78+
if ui_monitor.winfo_exists(): # Check if the monitor window still exists.
79+
ui_monitor.update() # If it does, update the monitor.
80+
except tk.TclError:
81+
ui_monitor = None # If it doesn't, someone is probably trying to exit the serial program. So, set the monitor to None.
82+
83+
# GRAPH STUFF
84+
ui_graph = None
85+
ui_graph_axes = None
86+
ui_graph_lines = {}
87+
ui_graph_series = defaultdict(lambda: ([], [])) # {DataLabel: ([ticks], [values])}
88+
89+
def update_graph(line, plt):
90+
global ui_graph, ui_graph_axes, ui_graph_lines, ui_graph_series
91+
92+
# Parse the line
93+
parts = line.strip().split("/")
94+
95+
# Check if the line is valid
96+
if len(parts) != 5: return # If there aren't exactly 5 parts, the line is malformed. So, skip it.
97+
try:
98+
tick = float(parts[3])
99+
value = float(parts[4])
100+
except ValueError: return # If the conversion to float fails, the line is malformed. So, skip it.
101+
102+
title = parts[1]
103+
data_label = parts[2]
104+
tick = float(parts[3]) # Convert to float or int depending on your data
105+
value = float(parts[4])
106+
107+
# Update series
108+
ui_graph_series[data_label][0].append(tick)
109+
ui_graph_series[data_label][1].append(value)
110+
111+
# Limit to 1000 points
112+
if len(ui_graph_series[data_label][0]) > 1000:
113+
ui_graph_series[data_label][0].pop(0)
114+
ui_graph_series[data_label][1].pop(0)
115+
116+
# Create graph if it doesn't exist
117+
if ui_graph is None:
118+
ui_graph, ui_graph_axes = plt.subplots()
119+
ui_graph_axes.set_title(title)
120+
ui_graph_axes.set_xlabel("Tick")
121+
ui_graph_axes.set_ylabel("Value")
122+
for label, (x, y) in ui_graph_series.items():
123+
ui_graph_lines[label], = ui_graph_axes.plot(x, y, label=label)
124+
ui_graph_axes.legend()
125+
plt.ion() # Interactive mode on
126+
plt.show()
127+
else:
128+
# Update existing graph
129+
for label, (x, y) in ui_graph_series.items():
130+
if label not in ui_graph_lines:
131+
ui_graph_lines[label], = ui_graph_axes.plot(x, y, label=f"{label}: {y[-1]:.2f}")
132+
ui_graph_axes.legend()
133+
else:
134+
ui_graph_lines[label].set_data(x, y) # Update data
135+
ui_graph_lines[label].set_label(f"{label}: {y[-1]:.2f}") # Update label with latest value
136+
# Rescale axes if necessary
137+
ui_graph_axes.relim()
138+
ui_graph_axes.autoscale_view()
139+
ui_graph_axes.legend()
140+
ui_graph.canvas.draw()
141+
ui_graph.canvas.flush_events()
142+
143+
144+
145+
def main(ls=False, device="", monitor=None, graph=None, filter=None, showall=False):
146+
global ui_monitor, ui_graph
147+
"""Main function for serial communication."""
148+
if device == "":
149+
devices = list_usb_devices()
150+
if ls:
151+
print("[bold blue]Devices present:[/bold blue]")
152+
for dev in devices:
153+
print(f"[green] - {dev}[/green]")
154+
print("[bold blue]The first device will be selected if --device is not specified.[/bold blue]")
155+
sys.exit(0)
156+
157+
# Default to the first device
158+
selected_device = devices[0]
159+
else:
160+
selected_device = device
161+
162+
print(f"[bold blue]Selected USB device:[/bold blue] [green]{selected_device}[/green]")
163+
164+
if(graph): # Import matplotlib if graphing is used. If matplotlib isn't installed, tell the user to run "ner update".
165+
try:
166+
import matplotlib.pyplot as plt
167+
except ImportError:
168+
print("Error: You don't have matplotlib installed. Please run 'ner update' to install it.")
169+
sys.exit(1)
170+
171+
try:
172+
with serial.Serial(selected_device, 115200, timeout=1) as ser:
173+
print(f"[bold blue]Connected to {selected_device} at 115200 baud.[/bold blue]")
174+
print(f"[bold blue]Displaying messages. Use CTRL+C to exit.[/bold blue]")
175+
if filter:
176+
print(f"[bold blue]Filtering messages containing:[/bold blue] [green]{filter}[/green]")
177+
while True:
178+
line = ser.readline().decode('utf-8', errors='ignore').strip()
179+
if not line: # Skip empty lines
180+
continue
181+
if line.startswith("mtr/") or line.startswith("gph/"): # Message has a special tag, so handle accordingly.
182+
if monitor and line.startswith(f"mtr/{monitor}/"): # Message contains monitor data, so update the monitor.
183+
update_monitor(line)
184+
elif graph and line.startswith(f"gph/{graph}/"):
185+
update_graph(line, plt)
186+
elif not filter or (filter in line): # If message isn't being filtered out, print it.
187+
print(line)
188+
except serial.SerialException as e:
189+
print(f"[bold red]Failed to open serial port {selected_device}: {e}[/bold red]", file=sys.stderr)
190+
sys.exit(1)
191+
except KeyboardInterrupt:
192+
if ui_monitor is not None:
193+
ui_monitor.destroy()
194+
if ui_graph is not None:
195+
plt.close(ui_graph)
196+
print("\n[bold blue]Exiting...[/bold blue]")
197+
198+
if __name__ == '__main__':
199+
main()

ner_environment/requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
distro
22
pre-commit
33
clang-format
4+
tkinter
45
pyserial
56
typer
7+
matplotlib
8+
rich

0 commit comments

Comments
 (0)