Skip to content

Commit

Permalink
Merge pull request #8 from 1-max-1/development
Browse files Browse the repository at this point in the history
New features
  • Loading branch information
1-max-1 authored Jun 23, 2023
2 parents 929271f + 09a5851 commit c6e13f1
Show file tree
Hide file tree
Showing 15 changed files with 349 additions and 58 deletions.
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# WASM Serial Terminal
A simple offline [blazor](https://dotnet.microsoft.com/en-us/apps/aspnet/web-apps/blazor) PWA for quick analysis of data from a serial device.

![demo-preview](https://user-images.githubusercontent.com/44454544/212460426-7c269d15-301b-4411-a2ca-27f1f81baf59.png)
![demo-preview](https://user-images.githubusercontent.com/44454544/248429563-2e7cacde-e968-4b0f-bc7d-9e54ac979e6e.png)

This tool allows you to connect to a serial port and view the incoming data.
Data is displayed in both textual format (utf-8) and byte by byte format - the latter resides in a grid with each row representing one byte.
Data is displayed in both textual format and byte by byte format - the latter resides in a grid with each row representing one byte.

You can also send data to the serial device in either text or individual byte format.

The HTML is somewhat responsive, but UI for the web is definitely not my strong point so it may still be dodgy on mobile-sized screens (laptops should be fine).
However, at the time of writing this, the web serial API is not supported on mobile browsers, so this app isn't currently usable on mobile anyway.
Expand All @@ -14,14 +16,16 @@ This app uses [Radzen's blazor components](https://www.radzen.com/blazor-compone

# Code notes
This project uses [Blazor](https://dotnet.microsoft.com/en-us/apps/aspnet/web-apps/blazor), which does not support the regular .NET method of accessing serial ports through `System.IO.Ports`. Instead, we must use the [web serial API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API).
In this project, the `blazor-serial.js` file in `wwwroot` contains the JS code for connecting to and reading from serial ports using the web serial API.
In this project, the `blazor-serial.js` file in `wwwroot` contains the JS code for connecting to and communicating with serial ports using the web serial API.
The `SerialService.cs` file contains the C# service that interacts with this JS code and exposes its serial functions to the rest of the project.

In the main page (`Index.razor`) some parts of the UI have been separated into their own components and these components are located in the `Shared` folder.

The `Shared` folder also contains a component called `WebSerialSupportChecker.razor`. This component is for use in `App.razor`. It checks if the users browser supports the web serial API.
If it is not supported, then an error message is displayed. Otherwise, the component lets `App.razor` render the rest of its regular content.

The `wwroot` folder contains a list of USB device ID's in JSON format (obtained from [https://github.com/1-max-1/usb_ids_api](https://github.com/1-max-1/usb_ids_api)). The app attempts to identify the connected device by looking through this list.

# Github pages
This project is currently hosted on github pages [here](https://1-max-1.github.io/WASMSerialTerminal).
On every push to the `main` branch, a github action builds and deploys the project.
71 changes: 51 additions & 20 deletions WASMSerialTerminal/Index.razor
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,36 @@
@using WASMSerialTerminal

<div class="container-fluid">
<div class="row">
<div class="col">
<SerialSettingsPanel @bind-DataBits="@dataBits" @bind-FlowControl="@flowControl" @bind-Parity="@parity" @bind-StopBits="@stopBits" @bind-BaudRate="@baudRate" Disabled="@serialService.PortOpen" />
</div>
</div>
<SerialSettingsPanel @bind-DataBits="@dataBits" @bind-FlowControl="@flowControl" @bind-Parity="@parity" @bind-StopBits="@stopBits" @bind-BaudRate="@baudRate" @bind-TextEncoding="@textEncoding" Disabled="@(@serialService.PortOpen || isChangingConnectionState)" />

<hr class="mb-4" />

<div class="row gy-5 mb-5">
<div class="col-lg-6 d-flex flex-column align-items-start" style="min-height: 400px;">
<RadzenButton Text="Connect to port" ButtonStyle="@ButtonStyle.Success" Click="@OnConnectButtonClick" Visible="@(!serialService.PortOpen)" class="mb-1" />
<RadzenButton Text="Close port" ButtonStyle="@ButtonStyle.Danger" Click="@OnDisconnectButtonClick" Visible="@serialService.PortOpen" class="mb-1" />
<AutoScrollTextBox @ref="@autoScrollTextBox" Class="w-100 flex-grow-1" />
<div class="row gy-5">
<div class="col-lg-6 d-flex flex-column align-items-start">
<div class="mb-1">
<RadzenButton Text="Connect to port" ButtonStyle="@ButtonStyle.Success" Click="@OnConnectButtonClick" Visible="@(!serialService.PortOpen)" IsBusy="@isChangingConnectionState" BusyText="Connecting..." class="mb-1" />
<RadzenButton Text="Close port" ButtonStyle="@ButtonStyle.Danger" Click="@OnDisconnectButtonClick" Visible="@serialService.PortOpen" IsBusy="@isChangingConnectionState" BusyText="Disconnecting..." class="mb-1" />
<p class="d-inline text-nowrap ms-2">@connectionStatusText<b>@connectionStatusBoldText</b></p>
</div>
<AutoScrollTextBox Style="height: 425px; width: 100%;" @ref="@autoScrollTextBox" OnClearDataButtonClicked="@OnClearDataButtonClicked" />
</div>
<div class="col-lg-6">
<div class="mb-1">
<RadzenButton ButtonStyle="@ButtonStyle.Primary" Click="@OnRefreshGridButtonClick" Icon="autorenew" />
<p class="d-inline">Data breakdown (not realtime):</p>
<p class="d-inline text-nowrap">Data breakdown (not realtime):</p>
</div>
<RadzenDataGrid TItem="@SerialByte" Data="@serialData" Style="height: 400px;" AllowColumnReorder="true" AllowColumnResize="true"
@ref="@grid" AllowPaging="true" ShowPagingSummary="true" PageSizeOptions="@(new int[] {10, 20, 30, 40, 50})">
@ref="@grid" AllowPaging="true" ShowPagingSummary="true" PageSizeOptions="@(new int[] {10, 20, 30, 40, 50})">
<Columns>
<RadzenDataGridColumn TItem="@SerialByte" Property="DecimalValue" Title="Decimal value" />
<RadzenDataGridColumn TItem="@SerialByte" Property="HexValue" Title="Hex value" />
<RadzenDataGridColumn TItem="@SerialByte" Property="Character" Title="UTF8 character" />
<RadzenDataGridColumn TItem="@SerialByte" Property="Character" Title="ASCII character" />
</Columns>
</RadzenDataGrid>
</div>
</div>

<DataInputBox OnDataToBeSent="@OnSendDataButtonClick" Disabled="@(!serialService.PortOpen || isChangingConnectionState)" TextEncoding="@textEncoding" />
</div>

@* Must be defined even for default dialogs to show *@
Expand All @@ -55,6 +56,11 @@
private SerialPortParity parity = SerialPortParity.PARITY_NONE;
private SerialPortStopBits stopBits = SerialPortStopBits.ONE_STOP_BIT;
private uint baudRate = 9600;
private Encoding textEncoding = Encoding.UTF8;

private string connectionStatusText = "(Disconnected)";
private string connectionStatusBoldText = "";
private bool isChangingConnectionState = false;

protected override void OnInitialized() {
serialService.DataReceived += OnSerialDataReceived;
Expand All @@ -68,44 +74,69 @@
}

private async Task OnConnectButtonClick() {
isChangingConnectionState = true;

try {
bool portSelected = await serialService.OpenPortSelectionDialog(baudRate, dataBits, flowControl, parity, stopBits);
isChangingConnectionState = false;
if (portSelected) {
serialData.Clear();
await grid!.Reload();
autoScrollTextBox!.ClearText();
connectionStatusText = "Connected to: ";
connectionStatusBoldText = serialService.DeviceName == null ? "Unknown device" : serialService.DeviceName;
}
}
catch (SerialSecurityException) {
await dialogService.Alert("Permission to access serial port not granted.", "Error");
}
catch (SerialInitializationException) {
await dialogService.Alert("Failed to open serial port. Please try again.", "Error");
await dialogService.Alert("Failed to open serial port. Please try again. The serial port may already be in use by another process.", "Error");
}
}

private async Task OnDisconnectButtonClick() {
isChangingConnectionState = true;
await serialService.ClosePort();
isChangingConnectionState = false;
connectionStatusText = "(Disconnected)";
connectionStatusBoldText = "";
}

private void OnSerialError() {
connectionStatusText = "(Disconnected)";
connectionStatusBoldText = "";
StateHasChanged();
dialogService.Alert("Something went wrong with the serial port. Was your device disconnected?", "Error");
}

private void OnSerialDataReceived(byte[] data) {
autoScrollTextBox!.AppendText(Encoding.UTF8.GetString(data));
// Only re-render this control, as opposed to re-rendering the whole page.
// This is because rendering the grid is slow and will cause lots of lag if we do it every time we get new data.
autoScrollTextBox!.ReRender();

foreach (byte b in data) {
serialData.Add(new SerialByte(b));
serialData.Add(new SerialByte(b)); // For the data breakdown grid
}

// Only re-render this control, as opposed to re-rendering the whole page.
// This is because rendering the grid is slow and will cause lots of lag if we do it every time we get new data.
autoScrollTextBox!.SetTextAndRedraw(textEncoding.GetString(serialData.ConvertAll(sb => sb.DecimalValue).ToArray()));
}

private async Task OnRefreshGridButtonClick() {
await grid!.Reload();
await grid!.LastPage();
}

private async Task OnClearDataButtonClicked() {
serialData.Clear();
await grid!.Reload();
}

// Handles the button clicks when sending text or byte data
private async Task OnSendDataButtonClick(byte[] data) {
try {
await serialService.WriteData(data);
}
catch (SerialTransmissionException ex) {
await dialogService.Alert(ex.Message, "Failed to write data");
}
}
}
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,11 @@ public SerialSecurityException(string message) : base(message) { }
public class SerialInitializationException : Exception {
public SerialInitializationException(string message) : base(message) { }
}

/// <summary>
/// Exception for when an attempt to send data to a serial port fails.
/// </summary>
public class SerialTransmissionException : Exception {
public SerialTransmissionException(string? message) : base(message) { }
}
}
37 changes: 37 additions & 0 deletions WASMSerialTerminal/Models/USBDeviceStructures.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Collections.Immutable;

namespace WASMSerialTerminal {
internal class USBDevice {
public string ID { get; private set; }
public string Name { get; private set; }

public USBDevice(string id, string name) {
ID = id;
Name = name;
}
}

internal class USBVendor {
public string ID { get; private set; }
public string Name { get; private set; }
public ImmutableArray<USBDevice> Devices { get; private set; }

public USBVendor(string iD, string name, ImmutableArray<USBDevice> devices) {
ID = iD;
Name = name;
Devices = devices;
}
}

internal class USBVendorList {
public ImmutableArray<USBVendor> Vendors { get; private set; }
public string Version { get; private set; }
public string Date { get; private set; }

public USBVendorList(ImmutableArray<USBVendor> vendors, string version, string date) {
Vendors = vendors;
Version = version;
Date = date;
}
}
}
11 changes: 9 additions & 2 deletions WASMSerialTerminal/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

builder.Services.AddSingleton<ISerialService, SerialService>();
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<ISerialService, SerialService>();
builder.Services.AddScoped<Radzen.DialogService>();

await builder.Build().RunAsync();
WebAssemblyHost host = builder.Build();

// Load USB devices from JSON file
var serialService = host.Services.GetRequiredService<ISerialService>();
await serialService.InitializeDevices();

await host.RunAsync();
73 changes: 68 additions & 5 deletions WASMSerialTerminal/SerialService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.JSInterop;
using System.Net.Http.Json;

namespace WASMSerialTerminal {
internal interface ISerialService {
Expand All @@ -12,41 +13,74 @@ internal interface ISerialService {
/// <exception cref="InvalidOperationException" />
/// <exception cref="SerialSecurityException" />
/// <exception cref="SerialInitializationException" />
/// <exception cref="ArgumentException" />
public Task<bool> OpenPortSelectionDialog(uint baudRate, SerialPortDataBits dataBits, SerialPortFlowControl flowControl, SerialPortParity parity, SerialPortStopBits stopBits);

/// <summary>
/// Closes the serial port and stops receiving data. Does nothing if no ports are currently open.
/// </summary>
public Task ClosePort();

/// <summary>
/// Writes the given data to the serial port. Throws an exception if the port isn't open.
/// </summary>
/// <param name="data">The bytes to write to the serial port.</param>
/// <exception cref="InvalidOperationException" />
/// <exception cref="SerialTransmissionException" />
public Task WriteData(byte[] data);

/// <summary> Grabs the device ID's from the JSON file and stores them in memory. Call this method during app initialization. </summary>
public Task InitializeDevices();

/// <summary>
/// Event raised when the serial port receives some data.
/// </summary>
public event Action<byte[]>? DataReceived;

/// <summary>
/// Event raised when the serial port throws a fatal error. If a fatal error is encountered, the serial port is closed automatically.
/// </summary>
public event Action? SerialError;

public bool PortOpen { get; }

/// <summary>
/// Once the serial port is connected, this property will be populated with the name of the USB device. <br/>
/// If the name cannot be identified or the port is not open, this prperty will be <see langword="null"/>.
/// </summary>
public string? DeviceName { get; }
}

internal class SerialService : ISerialService {
private readonly IJSRuntime js;
private DotNetObjectReference<SerialService>? selfRef;
private readonly HttpClient httpClient;
// Maps vendor ID's to another dictionary which maps usb ID's to device names (for the vendor)
private readonly Dictionary<string, Dictionary<string, string>> vendors = new();

public bool PortOpen { get; private set; }
public bool PortOpen { get; private set; } = false;
public string? DeviceName { get; private set; } = null;

public SerialService(IJSRuntime js) {
public SerialService(IJSRuntime js, HttpClient httpClient) {
this.js = js;
this.httpClient = httpClient;
}

public async Task InitializeDevices() {
USBVendorList vendorList = (await httpClient.GetFromJsonAsync<USBVendorList>("devices.json"))!;
foreach (USBVendor vendor in vendorList.Vendors) {
vendors[vendor.ID] = new();
foreach (USBDevice device in vendor.Devices) {
vendors[vendor.ID][device.ID] = device.Name;
}
}
}

public async Task<bool> OpenPortSelectionDialog(uint baudRate, SerialPortDataBits dataBits, SerialPortFlowControl flowControl, SerialPortParity parity, SerialPortStopBits stopBits) {
if (PortOpen)
throw new InvalidOperationException("Cannot open serial port because a serial port is already open.");

// Reference to us so the JS code can call back.
selfRef = DotNetObjectReference.Create(this);
selfRef = DotNetObjectReference.Create(this); // Reference to us so the JS code can call back.
string flowControlString = flowControl == SerialPortFlowControl.FLOW_CONTROL_NONE ? "none" : "hardware";
string parityString = parity switch {
SerialPortParity.PARITY_EVEN => "even",
Expand All @@ -64,7 +98,9 @@ public async Task<bool> OpenPortSelectionDialog(uint baudRate, SerialPortDataBit
throw new InvalidOperationException("Cannot open serial port because a serial port is already open.");
case 4:
throw new SerialInitializationException("Failed to open the serial port");
default:
case 5:
throw new ArgumentException("One or more serial port parameters (baud rate, data bits, flow control, parity, stop bits or buffer size) are not valid! Please check that they conform to the specification at https://wicg.github.io/serial/.");
default: // Will be 1
PortOpen = true;
return true;
}
Expand All @@ -76,6 +112,19 @@ public async Task ClosePort() {
await js.InvokeVoidAsync("closePort");
selfRef!.Dispose();
PortOpen = false;
DeviceName = null;
}

public async Task WriteData(byte[] data) {
if (!PortOpen)
throw new InvalidOperationException("Cannot write to the serial port because no port has been opened.");

try {
await js.InvokeVoidAsync("writeData", new object[] { data });
}
catch (JSException ex) {
throw new SerialTransmissionException("Failed to write data to serial port: " + ex.Message);
}
}

public event Action<byte[]>? DataReceived;
Expand All @@ -89,7 +138,21 @@ public void OnDataReceived(byte[] data) {
public void OnSerialError() {
selfRef!.Dispose();
PortOpen = false;
DeviceName = null;
SerialError?.Invoke();
}

[JSInvokable]
public void OnDeviceInfoReceived(ushort? vendorID, ushort? deviceID) {
if (vendorID == null || deviceID == null)
return;

try {
DeviceName = vendors[vendorID.Value.ToString("X4")][deviceID.Value.ToString("X4")];
}
catch (KeyNotFoundException) {
// Failed to identify the device, leave name as null
}
}
}
}
Loading

0 comments on commit c6e13f1

Please sign in to comment.