Added SinaglR

This commit is contained in:
Mike Phares 2023-06-08 13:36:25 -07:00
parent 2c38ecb399
commit 9452454b8a
33 changed files with 3540 additions and 33 deletions

6
.gitignore vendored
View File

@ -341,3 +341,9 @@ ASALocalRun/
# Libman.json
/wwwroot/lib/*
SignalRChat/*
Server/wwwroot/js/chat.js
Server/Hubs/ChatHub.cs
Server/Pages/Chat.cshtml
Server/Pages/Chat.cshtml.cs

View File

@ -19,9 +19,12 @@ completedColumns:
- [run-secrets-task](tasks/run-secrets-task.md)
- [install-vscode-extensions](tasks/install-vscode-extensions.md)
- [run-test-ports](tasks/run-test-ports.md)
- [signalr](tasks/signalr.md)
- [publish](tasks/publish.md)
- [create-as-service](tasks/create-as-service.md)
- [setup-nginx](tasks/setup-nginx.md)
- [self-signed-certificate](tasks/self-signed-certificate.md)
- [roll-out](tasks/roll-out.md)
## Todo

16
.kanbn/tasks/roll-out.md Normal file
View File

@ -0,0 +1,16 @@
---
---
# roll-out
- New page in the OI Viewer
- Set ubuntu frame to new OI Viewer page
- Does it refesh by it self?
- EAF needs what for the batch field
- SP1 needs to get the barcode from one of the two servers dependent on tool queued by EDA
## Sub-tasks
- [ ] phares3757
- [ ] unity4
- [ ] unity5

View File

@ -0,0 +1,20 @@
---
---
# self-signed-certificate
```bash
sudo -i
nano /etc/hosts/
apt-get install -y ca-certificates
openssl s_client -showcerts -connect DESKTOP-H6JG91B:443 </dev/null 2>/dev/null|openssl x509 -outform PEM >DESKTOP-H6JG91B.crt
cp /home/unity5/Barcode-Host/DESKTOP-H6JG91B.crt /usr/local/share/ca-certificates/DESKTOP-H6JG91B.crt
update-ca-certificates
exit
```
## Sub-tasks
- [ ] phares3757
- [ ] unity4
- [x] unity5

View File

@ -91,6 +91,11 @@ server {
listen [::]:443 default_server ssl http2;
location / {
proxy_pass http://localhost:5003;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
```

21
.kanbn/tasks/signalr.md Normal file
View File

@ -0,0 +1,21 @@
---
---
# signalr
- [Framework-4.5.1](https://learn.microsoft.com/en-us/aspnet/signalr/overview/getting-started/tutorial-getting-started-with-signalr?source=recommendations)
- [OnNotificationReceivedAsync](https://learn.microsoft.com/en-us/training/modules/aspnet-core-signalr/3-how-signalr-works)
```bash
# https://learn.microsoft.com/en-us/aspnet/core/tutorials/signalr?view=aspnetcore-7.0&tabs=visual-studio-code
dotnet tool uninstall -g Microsoft.Web.LibraryManager.Cli
dotnet tool install -g Microsoft.Web.LibraryManager.Cli
libman install @microsoft/signalr@latest -p unpkg -d wwwroot/js/signalr --files dist/browser/signalr.js --files dist/browser/signalr.js
# http://localhost:5003/notification
```
## Sub-tasks
- [ ] phares3757
- [ ] unity4
- [ ] unity5

2
.vscode/launch.json vendored
View File

@ -17,7 +17,7 @@
// Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)",
"pattern": "\\bNow listening on:\\s+(http?://\\S+)",
"uriFormat": "%s/swagger/index.html"
},
"env": {

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
@ -31,7 +31,6 @@
<PackageReference Include="Serilog.AspNetCore" Version="7.0.0" />
<PackageReference Include="Serilog" Version="2.12.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="ShellProgressBar" Version="5.2.0" />
<PackageReference Include="System.IO.Ports" Version="7.0.0" />
<PackageReference Include="System.Text.Json" Version="7.0.2" />
</ItemGroup>

View File

@ -1,9 +1,10 @@
using Barcode.Host.Server.Hubs;
using Barcode.Host.Server.Models;
using Barcode.Host.Shared.DataModels;
using Barcode.Host.Shared.KeyboardMouse;
using Barcode.Host.Shared.Models;
using Barcode.Host.Shared.Models.Stateless;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.SignalR;
using Serilog.Context;
namespace Barcode.Host.Server.HostedService;
@ -19,15 +20,18 @@ public class TimedHostedService : IHostedService, IAggregateInputReader, IDispos
private readonly ILastScanService _LastScanService;
private readonly ILogger<TimedHostedService> _Logger;
private readonly ILinuxGroupManager _LinuxGroupManager;
private readonly IHubContext<NotificationHub> _HubContext;
private readonly Dictionary<string, InputReader> _Readers;
private readonly Dictionary<EventCode, char> _CharToEventCodes;
private readonly List<(string MethodName, Timer Timer)> _Timers;
public TimedHostedService(ILogger<TimedHostedService> logger, AppSettings appSettings, ILinuxGroupManager linuxGroupManager, ILastScanService lastScanService, ISerialService serialService)
public TimedHostedService(ILogger<TimedHostedService> logger, AppSettings appSettings, ILinuxGroupManager linuxGroupManager, ILastScanService lastScanService, ISerialService serialService, IHubContext<NotificationHub> hubContext)
{
_Timers = new();
_Readers = new();
_Logger = logger;
_ExecutionCount = 0;
_HubContext = hubContext;
_CharToEventCodes = new();
_AppSettings = appSettings;
_SerialService = serialService;
@ -35,7 +39,11 @@ public class TimedHostedService : IHostedService, IAggregateInputReader, IDispos
_LinuxGroupManager = linuxGroupManager;
Timer writeTimer = new(Write, null, Timeout.Infinite, Timeout.Infinite);
Timer scanForNewInputsTimer = new(ScanForNewInputs, null, Timeout.Infinite, Timeout.Infinite);
_Timers = new List<(string, Timer)>() { (nameof(Write), writeTimer), (nameof(ScanForNewInputs), scanForNewInputsTimer) };
if (!string.IsNullOrEmpty(_AppSettings.SerialPortName))
_Timers.Add((nameof(Write), writeTimer));
#if Linux
_Timers.Add((nameof(ScanForNewInputs), scanForNewInputsTimer));
#endif
}
public Task StartAsync(CancellationToken stoppingToken)
@ -44,7 +52,9 @@ public class TimedHostedService : IHostedService, IAggregateInputReader, IDispos
using (LogContext.PushProperty("MethodName", methodName))
{
_Logger.LogInformation($"Timed Hosted Service: {_AppSettings.GitCommitSeven}:{Environment.ProcessId} running.");
if (!string.IsNullOrEmpty(_AppSettings.SerialPortName))
_SerialService.Open();
#if Linux
if (!_LinuxGroupManager.IsInInputGroup().WaitAsync(stoppingToken).Result)
{
if (string.IsNullOrEmpty(_AppSettings.RootPassword))
@ -52,6 +62,7 @@ public class TimedHostedService : IHostedService, IAggregateInputReader, IDispos
_ = _LinuxGroupManager.AddUserToInputGroup(_AppSettings.RootPassword);
_ = _LinuxGroupManager.RebootSystem(_AppSettings.RootPassword);
}
#endif
List<(EventCode, char)> collection = _LastScanService.IncludeEventCodes();
foreach ((EventCode eventCode, char @char) in collection)
_CharToEventCodes.Add(eventCode, @char);
@ -101,7 +112,19 @@ public class TimedHostedService : IHostedService, IAggregateInputReader, IDispos
if (e.TimeSpan.TotalMilliseconds > _AppSettings.ClearLastScanServiceAfter)
_LastScanService.Clear();
if (e.KeyState == KeyState.KeyUp && _CharToEventCodes.TryGetValue(e.EventCode, out char @char))
{
_LastScanService.Add(e.EventCode, @char);
int count = _LastScanService.GetCount();
if (count > _AppSettings.NotifyMinimum)
{
Result<string> result = _LastScanService.GetScan();
if (!string.IsNullOrEmpty(result.Results))
{
Notification notification = new(e, result.Results);
_ = _HubContext.Clients.All.SendAsync(nameof(NotificationHub.NotifyAll), notification);
}
}
}
}
private Timer? GetTimer(string methodName)
@ -156,6 +179,8 @@ public class TimedHostedService : IHostedService, IAggregateInputReader, IDispos
}
private void Write()
{
if (!string.IsNullOrEmpty(_AppSettings.SerialPortName))
{
int count = _LastScanService.GetCount();
if (count > 0)
@ -165,6 +190,7 @@ public class TimedHostedService : IHostedService, IAggregateInputReader, IDispos
_SerialService.SerialPortWrite(count, result.Results);
}
}
}
private void Write(object? sender)
{

View File

@ -0,0 +1,12 @@
using Barcode.Host.Shared.Models;
using Microsoft.AspNetCore.SignalR;
namespace Barcode.Host.Server.Hubs;
public class NotificationHub : Hub
{
public async Task NotifyAll(Notification notification) =>
await Clients.All.SendAsync(nameof(NotifyAll), notification);
}

View File

@ -12,10 +12,12 @@ public record AppSettings(string BuildNumber,
string LinuxDevicePath,
bool IsDevelopment,
bool IsStaging,
int NotifyMinimum,
string MockRoot,
string MonAResource,
string MonASite,
string RootPassword,
string SerialPortName,
string URLs,
string WorkingDirectoryName,
int WriteToSerialEvery)

View File

@ -1,4 +1,3 @@
using Microsoft.Extensions.Configuration;
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
@ -19,10 +18,12 @@ public class AppSettings
[Display(Name = "Linux Device Path"), Required] public string LinuxDevicePath { get; set; }
[Display(Name = "Is Development"), Required] public bool? IsDevelopment { get; set; }
[Display(Name = "Is Staging"), Required] public bool? IsStaging { get; set; }
[Display(Name = "Notify Minimum"), Required] public int? NotifyMinimum { get; set; }
[Display(Name = "Mock Root"), Required] public string MockRoot { get; set; }
[Display(Name = "MonA Resource"), Required] public string MonAResource { get; set; }
[Display(Name = "MonA Site"), Required] public string MonASite { get; set; }
[Display(Name = "RootPassword"), Required] public string RootPassword { get; set; }
[Display(Name = "Serial Port Name"), Required] public string SerialPortName { get; set; }
[Display(Name = "URLs"), Required] public string URLs { get; set; }
[Display(Name = "Working Directory Name"), Required] public string WorkingDirectoryName { get; set; }
[Display(Name = "WriteToSerialEvery"), Required] public int? WriteToSerialEvery { get; set; }
@ -60,6 +61,8 @@ public class AppSettings
throw new NullReferenceException(nameof(IsDevelopment));
if (appSettings.IsStaging is null)
throw new NullReferenceException(nameof(IsStaging));
if (appSettings.NotifyMinimum is null)
throw new NullReferenceException(nameof(NotifyMinimum));
if (appSettings.MockRoot is null)
throw new NullReferenceException(nameof(MockRoot));
if (appSettings.MonAResource is null)
@ -68,6 +71,8 @@ public class AppSettings
throw new NullReferenceException(nameof(MonASite));
if (appSettings.RootPassword is null)
throw new NullReferenceException(nameof(RootPassword));
if (appSettings.SerialPortName is null)
throw new NullReferenceException(nameof(SerialPortName));
if (appSettings.URLs is null)
throw new NullReferenceException(nameof(URLs));
if (appSettings.WorkingDirectoryName is null)
@ -85,10 +90,12 @@ public class AppSettings
appSettings.LinuxDevicePath,
appSettings.IsDevelopment.Value,
appSettings.IsStaging.Value,
appSettings.NotifyMinimum.Value,
appSettings.MockRoot,
appSettings.MonAResource,
appSettings.MonASite,
appSettings.RootPassword,
appSettings.SerialPortName,
appSettings.URLs,
appSettings.WorkingDirectoryName,
appSettings.WriteToSerialEvery.Value);

26
Server/Pages/Error.cshtml Normal file
View File

@ -0,0 +1,26 @@
@page
@model ErrorModel
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>

View File

@ -0,0 +1,23 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Barcode.Host.Server.Pages;
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[IgnoreAntiforgeryToken]
public class ErrorModel : PageModel
{
public string? RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
private readonly ILogger<ErrorModel> _Logger;
public ErrorModel(ILogger<ErrorModel> logger) =>
_Logger = logger;
public void OnGet() =>
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}

View File

@ -0,0 +1,10 @@
@page
<div class="container">
<div class="row p-1">
<div class="col-6">
<h1 id="data" style="text-align:center"></h1>
</div>
</div>
</div>
<script src="~/js/signalr/dist/browser/signalr.js"></script>
<script src="~/js/notification.js"></script>

View File

@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Barcode.Host.Server.Pages;
public class NotificationModel : PageModel
{
private readonly ILogger<NotificationModel> _Logger;
public NotificationModel(ILogger<NotificationModel> logger) =>
_Logger = logger;
public void OnGet()
{
}
}

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Barcode Host</title>
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body>
<div class="container">
<main role="main" class="pb-3">
@RenderBody()
</main>
</div>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@ -0,0 +1,48 @@
/* Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification
for details on configuring this project to bundle and minify static web assets. */
a.navbar-brand {
white-space: normal;
text-align: center;
word-break: break-all;
}
a {
color: #0077cc;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.border-top {
border-top: 1px solid #e5e5e5;
}
.border-bottom {
border-bottom: 1px solid #e5e5e5;
}
.box-shadow {
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
}
button.accept-policy {
font-size: 1rem;
line-height: inherit;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
white-space: nowrap;
line-height: 60px;
}

View File

@ -0,0 +1,2 @@
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>

View File

@ -0,0 +1,3 @@
@using Barcode.Host.Server
@namespace Barcode.Host.Server.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View File

@ -1,12 +1,9 @@
using Barcode.Host.Server.HostedService;
using Barcode.Host.Server.Hubs;
using Barcode.Host.Server.Models;
using Barcode.Host.Server.Services;
using Barcode.Host.Shared.Models;
using Barcode.Host.Shared.Models.Stateless;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
using System.Reflection;
@ -48,7 +45,7 @@ public class Program
_ = ConfigurationLoggerConfigurationExtensions.Configuration(loggerConfiguration.ReadFrom, webApplicationBuilder.Configuration);
_ = SerilogHostBuilderExtensions.UseSerilog(webApplicationBuilder.Host);
Log.Logger = loggerConfiguration.CreateLogger();
ILogger log = Log.ForContext<Program>();
Serilog.ILogger log = Log.ForContext<Program>();
try
{
if (appSettings.IsStaging && appSettings.IsDevelopment)
@ -57,6 +54,8 @@ public class Program
throw new NotSupportedException("Please check appsettings file(s)!");
if (appSettings.IsDevelopment != webApplicationBuilder.Environment.IsDevelopment())
throw new NotSupportedException("Please check appsettings file(s)!");
_ = webApplicationBuilder.Services.AddRazorPages();
_ = webApplicationBuilder.Services.AddSignalR();
_ = webApplicationBuilder.Services.AddControllersWithViews();
_ = webApplicationBuilder.Services.AddSingleton(_ => appSettings);
_ = webApplicationBuilder.Services.AddSingleton<ISerialService, SerialService>();
@ -76,11 +75,13 @@ public class Program
_ = webApplication.UseExceptionHandler("/Error");
_ = webApplication.UseHsts();
}
_ = webApplication.UseCors(corsPolicyBuilder => corsPolicyBuilder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod());
_ = webApplication.Lifetime.ApplicationStopped.Register(Log.CloseAndFlush);
_ = webApplication.UseFileServer(enableDirectoryBrowsing: true);
_ = webApplication.UseStaticFiles();
_ = webApplication.UseRouting();
_ = webApplication.UseAuthorization();
_ = webApplication.MapControllers();
_ = webApplication.MapRazorPages();
_ = webApplication.MapHub<NotificationHub>($"/{nameof(NotificationHub)}");
log.Information("Starting Web Application");
webApplication.Run();
return 0;

View File

@ -4,7 +4,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5126",
"applicationUrl": "http://localhost:5003",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@ -13,7 +13,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7130;http://localhost:5126",
"applicationUrl": "https://localhost:5004;http://localhost:5003",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@ -22,7 +22,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7130;http://localhost:5126",
"applicationUrl": "https://localhost:5004;http://localhost:5003",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Production"
}

View File

@ -9,20 +9,23 @@ public class SerialService : ISerialService
private string _LastRaw;
private readonly AppSettings _AppSettings;
private readonly System.IO.Ports.SerialPort _SerialPort;
private readonly System.IO.Ports.SerialPort? _SerialPort;
public SerialService(AppSettings appSettings)
{
_LastRaw = string.Empty;
_AppSettings = appSettings;
_SerialPort = new("/dev/ttyUSB0", 9600) { ReadTimeout = 2 };
if (string.IsNullOrEmpty(appSettings.SerialPortName))
_SerialPort = null;
else
_SerialPort = new(appSettings.SerialPortName, 9600) { ReadTimeout = 2 };
}
void ISerialService.Open() =>
_SerialPort.Open();
_SerialPort?.Open();
void ISerialService.Close() =>
_SerialPort.Close();
_SerialPort?.Close();
void ISerialService.SerialPortWrite(int count, string raw)
{
@ -36,7 +39,7 @@ public class SerialService : ISerialService
else
message = $" {raw}";
byte[] bytes = Encoding.ASCII.GetBytes(message);
_SerialPort.Write(bytes, 0, bytes.Length);
_SerialPort?.Write(bytes, 0, bytes.Length);
_LastRaw = raw;
}
}

View File

@ -18,9 +18,11 @@
},
"IsDevelopment": false,
"IsStaging": false,
"NotifyMinimum": 3,
"MockRoot": "",
"MonAResource": "OI_Metrology_Viewer_EC",
"MonASite": "auc",
"SerialPortName": "/dev/ttyUSB0",
"Serilog": {
"Using": [
"Serilog.Sinks.Console",

View File

@ -0,0 +1,22 @@
html {
font-size: 14px;
}
@media (min-width: 768px) {
html {
font-size: 16px;
}
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
html {
position: relative;
min-height: 100%;
}
body {
margin-bottom: 60px;
}

BIN
Server/wwwroot/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -0,0 +1,13 @@
"use strict";
var connection = new signalR.HubConnectionBuilder().withUrl("/NotificationHub").build();
connection.on("NotifyAll", function (data) {
var message = `${data.keyPressEvent.dateTime} - [${data.lastScanServiceResultValue}]`;
document.getElementById("data").innerText = message;
});
connection.start().then(function () {
}).catch(function (err) {
return console.error(err.toString());
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@


View File

@ -115,9 +115,10 @@ public class InputReader : IDisposable
{
short code = GetCode();
int value = GetValue();
DateTime dateTime = DateTime.Now;
KeyState keyState = (KeyState)value;
EventCode eventCode = (EventCode)code;
KeyPressEvent keyPressEvent = new(eventCode, keyState, new TimeSpan(DateTime.Now.Ticks - _Ticks));
KeyPressEvent keyPressEvent = new(dateTime, eventCode, keyState, new TimeSpan(dateTime.Ticks - _Ticks));
OnKeyPress?.Invoke(keyPressEvent);
}

View File

@ -2,13 +2,14 @@ namespace Barcode.Host.Shared.KeyboardMouse;
public readonly struct KeyPressEvent
{
public DateTime DateTime { get; init; }
public EventCode EventCode { get; init; }
public KeyState KeyState { get; init; }
public TimeSpan TimeSpan { get; init; }
public KeyPressEvent(EventCode eventCode, KeyState keyState, TimeSpan timeSpan)
public KeyPressEvent(DateTime dateTime, EventCode eventCode, KeyState keyState, TimeSpan timeSpan)
{
DateTime = dateTime;
EventCode = eventCode;
KeyState = keyState;
TimeSpan = timeSpan;

View File

@ -0,0 +1,5 @@
using Barcode.Host.Shared.KeyboardMouse;
namespace Barcode.Host.Shared.Models;
public record Notification(KeyPressEvent KeyPressEvent, string LastScanServiceResultValue);