diff --git a/EpsonPrintService/ConfigurationLoader.cs b/EpsonPrintService/ConfigurationLoader.cs
new file mode 100644
index 0000000..f14356f
--- /dev/null
+++ b/EpsonPrintService/ConfigurationLoader.cs
@@ -0,0 +1,55 @@
+using Inspectron.Epson.PrintServer.ConfigurationSources;
+
+namespace EpsonPrintService;
+
+public record ConfigLoadResult(bool Success, EpsonPrintServiceConfiguration? Config, string? Error);
+
+public static class ConfigurationLoader
+{
+ public static ConfigLoadResult TryLoadFromBase64(string base64Content)
+ {
+ try
+ {
+ var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(base64Content.Trim()));
+ var parts = decoded.Split(';');
+ if (parts.Length != 3)
+ return new ConfigLoadResult(false, null, $"Invalid configuration format. Expected 'apiUrl;restaurantId;apiKey', got {parts.Length} parts.");
+
+ var config = new EpsonPrintServiceConfiguration
+ {
+ ApiUrl = parts[0],
+ RestaurantId = parts[1],
+ ApiKey = parts[2]
+ };
+
+ if (string.IsNullOrWhiteSpace(config.ApiUrl) || string.IsNullOrWhiteSpace(config.RestaurantId) || string.IsNullOrWhiteSpace(config.ApiKey))
+ return new ConfigLoadResult(false, null, "Configuration contains empty values.");
+
+ return new ConfigLoadResult(true, config, null);
+ }
+ catch (FormatException)
+ {
+ return new ConfigLoadResult(false, null, "Invalid base64 content.");
+ }
+ }
+
+ public static ConfigLoadResult TryLoadFromFile()
+ {
+ try
+ {
+ var configPath = ConfigurationPaths.GetConfigPath();
+ if (!File.Exists(configPath))
+ return new ConfigLoadResult(false, null, $"Configuration file not found: {configPath}");
+
+ var base64Line = File.ReadAllText(configPath).Trim();
+ if (string.IsNullOrWhiteSpace(base64Line))
+ return new ConfigLoadResult(false, null, "Configuration file is empty.");
+
+ return TryLoadFromBase64(base64Line);
+ }
+ catch (Exception ex)
+ {
+ return new ConfigLoadResult(false, null, $"Failed to read configuration: {ex.Message}");
+ }
+ }
+}
diff --git a/EpsonPrintService/HtmlPageBuilder.cs b/EpsonPrintService/HtmlPageBuilder.cs
new file mode 100644
index 0000000..614ad1c
--- /dev/null
+++ b/EpsonPrintService/HtmlPageBuilder.cs
@@ -0,0 +1,127 @@
+using Inspectron.Epson.PrintServer.ConfigurationSources;
+
+namespace EpsonPrintService;
+
+public static class HtmlPageBuilder
+{
+ public static string BuildConfigPage(
+ EpsonPrintServiceConfiguration? currentConfig,
+ bool printServerRunning,
+ UsbKeyResult? usbResult,
+ string? errorMessage = null)
+ {
+ var statusColor = printServerRunning ? "#2e7d32" : "#e65100";
+ var statusText = printServerRunning ? "Running" : "Not Configured";
+
+ return $@"
+
+
+
+
+Epson Print Server
+
+
+
+Epson Print Server
+
+
+
Status: {statusText}
+ {(currentConfig != null ? $@"
+
+
Restaurant ID: {Escape(currentConfig.RestaurantId)}
+
API URL: {Escape(currentConfig.ApiUrl ?? "(not set)")}
+
" : "")}
+
+
+{(errorMessage != null ? $@"{Escape(errorMessage)}
" : "")}
+
+{BuildUsbSection(usbResult)}
+
+
+
+
+";
+ }
+
+ private static string BuildUsbSection(UsbKeyResult? usbResult)
+ {
+ if (usbResult == null)
+ return "";
+
+ if (usbResult.Error != null && !usbResult.Found)
+ return $@"{Escape(usbResult.Error)}
";
+
+ if (!usbResult.Found)
+ return @"No USB drive with print_server_key.txt found. Insert a USB drive and press Refresh.
";
+
+ if (usbResult.Error != null)
+ return $@"USB key found but invalid: {Escape(usbResult.Error)}
";
+
+ return $@"
+
+
+
USB Key Found
+
Restaurant ID: {Escape(usbResult.DecodedRestaurantId ?? "?")}
+
API URL: {Escape(usbResult.DecodedApiUrl ?? "?")}
+
+
+
";
+ }
+
+ public static string BuildSuccessPage(EpsonPrintServiceConfiguration newConfig)
+ {
+ return $@"
+
+
+
+
+
+Configuration Updated
+
+
+
+Configuration Updated!
+
+
+
The configuration has been saved. The service will restart automatically.
+
+
+
Restaurant ID: {Escape(newConfig.RestaurantId)}
+
API URL: {Escape(newConfig.ApiUrl ?? "(not set)")}
+
+
This page will refresh in 5 seconds...
+
+
+";
+ }
+
+ private static string Escape(string value) =>
+ System.Net.WebUtility.HtmlEncode(value);
+}
diff --git a/EpsonPrintService/PrintServerBootstrapper.cs b/EpsonPrintService/PrintServerBootstrapper.cs
new file mode 100644
index 0000000..b6d35fb
--- /dev/null
+++ b/EpsonPrintService/PrintServerBootstrapper.cs
@@ -0,0 +1,78 @@
+using Inspectron.Epson;
+using Inspectron.Epson.PrintServer;
+using Inspectron.Epson.PrintServer.ConfigurationSources;
+using Inspectron.Epson.PrintServer.JobSources;
+using Inspectron.Epson.PrintServer.JobStatusReporters;
+using Inspectron.Epson.PrintServer.PrinterAssinment;
+using Inspectron.Epson.PrintServer.Printers;
+using Inspectron.Epson.PrintServer.Printers.Utils;
+using Inspectron.Epson.PrintServer.PrintServices;
+using Inspectron.Epson.Queue;
+using Microsoft.Extensions.Logging;
+using Ninject;
+
+namespace EpsonPrintService;
+
+public class PrintServerBootstrapper
+{
+ private readonly EpsonPrintServiceConfiguration _config;
+ private readonly CancellationTokenSource _cts;
+ private StandardKernel? _kernel;
+
+ public bool IsRunning { get; private set; }
+
+ public PrintServerBootstrapper(EpsonPrintServiceConfiguration config, CancellationTokenSource cts)
+ {
+ _config = config;
+ _cts = cts;
+ }
+
+ public async Task StartAsync()
+ {
+ _kernel = new StandardKernel();
+
+ _kernel.Bind().To().InSingletonScope();
+ _kernel.Bind().ToConstant(new HttpClient { Timeout = TimeSpan.FromSeconds(30) }).InSingletonScope();
+ _kernel.Bind().ToConstant(_config);
+ _kernel.Bind().To();
+ _kernel.Bind().To().InSingletonScope();
+ _kernel.Bind().To();
+ _kernel.Bind().ToMethod(ctx =>
+ {
+ var loggerFactory = LoggerFactory.Create(builder =>
+ {
+ builder.AddConsole();
+ builder.SetMinimumLevel(LogLevel.Trace);
+ });
+ return loggerFactory.CreateLogger("EpsonPrintService");
+ });
+ _kernel.Bind().To().InSingletonScope();
+ _kernel.Bind().To();
+ _kernel.Bind().To().InSingletonScope();
+ _kernel.Bind().To();
+ _kernel.Bind().ToSelf().InSingletonScope();
+ _kernel.Bind().To().InSingletonScope();
+ _kernel.Bind().ToSelf().InSingletonScope();
+ _kernel.Bind().ToSelf().InSingletonScope();
+
+ var printLoop = _kernel.Get();
+ var discoveryTask = _kernel.Get();
+ var heartbeatTask = _kernel.Get();
+ var jobSourceTask = _kernel.Get();
+
+ // Start background tasks
+ _ = discoveryTask.StartAsync(_cts.Token);
+ _ = heartbeatTask.StartAsync(_cts.Token);
+
+ // Delay for a moment to allow discovery to find printers
+ Console.WriteLine("Waiting for printer discovery...");
+ await Task.Delay(3000);
+
+ _ = jobSourceTask.StartAsync();
+
+ await printLoop.StartAsync();
+
+ IsRunning = true;
+ Console.WriteLine("Print server started.");
+ }
+}
diff --git a/EpsonPrintService/Program.cs b/EpsonPrintService/Program.cs
index 38a2311..13e1e6e 100644
--- a/EpsonPrintService/Program.cs
+++ b/EpsonPrintService/Program.cs
@@ -1,95 +1,22 @@
-using Inspectron.Epson;
-using Microsoft.AspNetCore.SignalR.Client;
-using Microsoft.Extensions.Logging;
-using Ninject;
-using System.Threading.Channels;
-using Inspectron.Epson.PrintServer;
-using Inspectron.Epson.PrintServer.ConfigurationSources;
-using Inspectron.Epson.PrintServer.JobSources;
-using Inspectron.Epson.PrintServer.PrinterAssinment;
-using Inspectron.Epson.PrintServer.Printers;
-using Inspectron.Epson.PrintServer.Printers.Utils;
-using Inspectron.Epson.PrintServer.PrintServices;
using EpsonPrintService;
-using Inspectron.Epson.PrintServer.JobStatusReporters;
-using Inspectron.Epson.Queue;
+using Inspectron.Epson.PrintServer.ConfigurationSources;
-StandardKernel kernel = new StandardKernel();
+// Load configuration (may be absent on first boot)
+var configResult = ConfigurationLoader.TryLoadFromFile();
+EpsonPrintServiceConfiguration? activeConfig = null;
-
-var configPath = ConfigurationPaths.GetConfigPath();
-Console.WriteLine($"Loading configuration from: {configPath}");
-
-var base64Line = File.ReadAllText(configPath).Trim();
-var decodedConfig = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(base64Line));
-var configParts = decodedConfig.Split(';');
-if (configParts.Length != 3)
- throw new InvalidOperationException($"Invalid configuration format. Expected 'apiUrl;restaurantId;apiKey', got {configParts.Length} parts.");
-
-var config = new EpsonPrintServiceConfiguration
+if (configResult.Success)
{
- ApiUrl = configParts[0],
- RestaurantId = configParts[1],
- ApiKey = configParts[2]
-};
-
-Console.WriteLine($"Restaurant: {config.RestaurantId}");
-Console.WriteLine($"API URL: {config.ApiUrl}");
-
-kernel.Bind().To().InSingletonScope();
-
-//var discovery = new PreconfiguredDiscoveryService();
-//discovery.AddPrinter("192.168.50.176", "TM-T30III", port: 9100);
-//discovery.AddPrinter("192.168.50.60", "TM-T30III", port: 9100);
-//discovery.AddPrinter("192.168.50.61", "TM-T30III", port: 9100);
-//discovery.AddPrinter("192.168.50.80", "TM-U220II", port: 9100);
-//kernel.Bind().ToConstant(discovery);
-
-kernel.Bind().ToConstant(new HttpClient { Timeout = TimeSpan.FromSeconds(30) }).InSingletonScope();
-
-
-kernel.Bind().ToConstant(config);
-//kernel.Bind().ToMethod(sp=>new HtmlPrinterFactory("TM-U220II.html", paperWidth: 258,type:0x0d, logger:sp.Kernel.Get()));
-//kernel.Bind().ToMethod(sp=>new HtmlPrinterFactory("TM-T30III.html",logger:sp.Kernel.Get()));
-kernel.Bind().To();
-
-kernel.Bind().To().InSingletonScope();
-//kernel.Bind().To().InSingletonScope();
-
-kernel.Bind().To();
-kernel.Bind().ToMethod(ctx =>
+ activeConfig = configResult.Config;
+ Console.WriteLine($"Configuration loaded.");
+ Console.WriteLine($"Restaurant: {activeConfig!.RestaurantId}");
+ Console.WriteLine($"API URL: {activeConfig.ApiUrl}");
+}
+else
{
- var loggerFactory = LoggerFactory.Create(builder =>
- {
- builder.AddConsole();
- builder.SetMinimumLevel(LogLevel.Trace);
- });
- return loggerFactory.CreateLogger("EpsonPrintService");
-});
-
-kernel.Bind().To().InSingletonScope();
-kernel.Bind().To();
-kernel.Bind().To().InSingletonScope();
-kernel.Bind().To();
-kernel.Bind().ToSelf().InSingletonScope();
-kernel.Bind().To().InSingletonScope();
-kernel.Bind().ToSelf().InSingletonScope();
-kernel.Bind().ToSelf().InSingletonScope();
-
-
-var printLoop = kernel.Get();
-var printServer = kernel.Get();
-
-
-
-var discoveryTask = kernel.Get();
-var heartbeatTask = kernel.Get();
-var jobSourceTask = kernel.Get();
-//printServer.RegisterPrinter("10.0.20.12");
-
-
-
-
+ Console.WriteLine($"No valid configuration: {configResult.Error}");
+ Console.WriteLine("Running in configuration-only mode. Navigate to http:// to configure.");
+}
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (sender, e) =>
@@ -99,18 +26,21 @@ Console.CancelKeyPress += (sender, e) =>
cts.Cancel();
};
-// Start background tasks
-_ = discoveryTask.StartAsync(cts.Token);
-_ = heartbeatTask.StartAsync(cts.Token);
+// Track print server state for the web UI
+PrintServerBootstrapper? bootstrapper = null;
+// Start web UI (always runs)
+var httpServer = new SimpleHttpServer(
+ getConfig: () => activeConfig,
+ getIsRunning: () => bootstrapper?.IsRunning ?? false);
+_ = Task.Run(() => httpServer.StartAsync(cts.Token));
-// delay for a moment to allow discovery to find printers
-Console.WriteLine("Waiting for printer discovery...");
-await Task.Delay(3000);
-
-_ = jobSourceTask.StartAsync();
-
-await printLoop.StartAsync();
+// Start print server if configured
+if (activeConfig != null)
+{
+ bootstrapper = new PrintServerBootstrapper(activeConfig, cts);
+ await bootstrapper.StartAsync();
+}
try
{
@@ -120,4 +50,3 @@ catch (TaskCanceledException)
{
// Expected when Ctrl+C is pressed
}
-
diff --git a/EpsonPrintService/SimpleHttpServer.cs b/EpsonPrintService/SimpleHttpServer.cs
new file mode 100644
index 0000000..1b761d2
--- /dev/null
+++ b/EpsonPrintService/SimpleHttpServer.cs
@@ -0,0 +1,136 @@
+using System.Net;
+using System.Text;
+using Inspectron.Epson.PrintServer.ConfigurationSources;
+
+namespace EpsonPrintService;
+
+public class SimpleHttpServer
+{
+ private readonly HttpListener _listener;
+ private readonly Func _getConfig;
+ private readonly Func _getIsRunning;
+
+ public SimpleHttpServer(Func getConfig, Func getIsRunning)
+ {
+ _listener = new HttpListener();
+ _listener.Prefixes.Add("http://+:80/");
+ _getConfig = getConfig;
+ _getIsRunning = getIsRunning;
+ }
+
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ _listener.Start();
+ Console.WriteLine("Web UI listening on http://+:80/");
+
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var context = await _listener.GetContextAsync();
+ _ = Task.Run(() => HandleRequestAsync(context), cancellationToken);
+ }
+ }
+ catch (HttpListenerException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Expected on shutdown
+ }
+ finally
+ {
+ _listener.Stop();
+ }
+ }
+
+ private async Task HandleRequestAsync(HttpListenerContext context)
+ {
+ try
+ {
+ var request = context.Request;
+ var response = context.Response;
+ var path = request.Url?.AbsolutePath ?? "/";
+ var method = request.HttpMethod;
+
+ string html;
+
+ if (method == "GET" && path == "/")
+ {
+ html = await BuildIndexPageAsync();
+ }
+ else if (method == "POST" && path == "/update-key")
+ {
+ html = await HandleUpdateKeyAsync();
+ }
+ else
+ {
+ response.StatusCode = 404;
+ html = "Not Found
";
+ }
+
+ var buffer = Encoding.UTF8.GetBytes(html);
+ response.ContentType = "text/html; charset=utf-8";
+ response.ContentLength64 = buffer.Length;
+ await response.OutputStream.WriteAsync(buffer);
+ response.Close();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"HTTP error: {ex.Message}");
+ try { context.Response.Close(); } catch { }
+ }
+ }
+
+ private async Task BuildIndexPageAsync()
+ {
+ var usbResult = await UsbKeyScanner.ScanForKeyAsync();
+ return HtmlPageBuilder.BuildConfigPage(_getConfig(), _getIsRunning(), usbResult);
+ }
+
+ private async Task HandleUpdateKeyAsync()
+ {
+ var usbResult = await UsbKeyScanner.ScanForKeyAsync();
+
+ if (!usbResult.Found || usbResult.Base64Content == null)
+ {
+ return HtmlPageBuilder.BuildConfigPage(
+ _getConfig(), _getIsRunning(), usbResult,
+ errorMessage: "No valid USB key found. Please insert a USB drive and try again.");
+ }
+
+ // Validate again
+ var validation = ConfigurationLoader.TryLoadFromBase64(usbResult.Base64Content);
+ if (!validation.Success)
+ {
+ return HtmlPageBuilder.BuildConfigPage(
+ _getConfig(), _getIsRunning(), usbResult,
+ errorMessage: $"Key validation failed: {validation.Error}");
+ }
+
+ // Write config file
+ try
+ {
+ var configPath = ConfigurationPaths.GetConfigPath();
+ var configDir = Path.GetDirectoryName(configPath);
+ if (!string.IsNullOrEmpty(configDir) && !Directory.Exists(configDir))
+ Directory.CreateDirectory(configDir);
+
+ await File.WriteAllTextAsync(configPath, usbResult.Base64Content);
+ Console.WriteLine($"Configuration updated at {configPath}");
+ }
+ catch (Exception ex)
+ {
+ return HtmlPageBuilder.BuildConfigPage(
+ _getConfig(), _getIsRunning(), usbResult,
+ errorMessage: $"Failed to write configuration: {ex.Message}");
+ }
+
+ // Schedule exit so systemd restarts us with new config
+ _ = Task.Run(async () =>
+ {
+ await Task.Delay(500);
+ Console.WriteLine("Exiting for restart with new configuration...");
+ Environment.Exit(0);
+ });
+
+ return HtmlPageBuilder.BuildSuccessPage(validation.Config!);
+ }
+}
diff --git a/EpsonPrintService/UsbKeyScanner.cs b/EpsonPrintService/UsbKeyScanner.cs
new file mode 100644
index 0000000..2e38a2f
--- /dev/null
+++ b/EpsonPrintService/UsbKeyScanner.cs
@@ -0,0 +1,137 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+
+namespace EpsonPrintService;
+
+public record UsbKeyResult(bool Found, string? Base64Content, string? DecodedApiUrl, string? DecodedRestaurantId, string? Error);
+
+public static class UsbKeyScanner
+{
+ private const string KeyFileName = "print_server_key.txt";
+
+ public static async Task ScanForKeyAsync()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ return new UsbKeyResult(false, null, null, null, "USB scanning is only supported on Linux.");
+
+ try
+ {
+ var lsblkJson = await RunCommandAsync("lsblk", "-J -o NAME,RM,MOUNTPOINT,TYPE");
+ if (lsblkJson == null)
+ return new UsbKeyResult(false, null, null, null, "Failed to run lsblk.");
+
+ var doc = JsonDocument.Parse(lsblkJson);
+ var devices = doc.RootElement.GetProperty("blockdevices");
+
+ foreach (var device in devices.EnumerateArray())
+ {
+ var result = await ScanDeviceAsync(device);
+ if (result != null)
+ return result;
+
+ // Check children (partitions)
+ if (device.TryGetProperty("children", out var children))
+ {
+ foreach (var child in children.EnumerateArray())
+ {
+ result = await ScanDeviceAsync(child);
+ if (result != null)
+ return result;
+ }
+ }
+ }
+
+ return new UsbKeyResult(false, null, null, null, null);
+ }
+ catch (Exception ex)
+ {
+ return new UsbKeyResult(false, null, null, null, $"USB scan failed: {ex.Message}");
+ }
+ }
+
+ private static async Task ScanDeviceAsync(JsonElement device)
+ {
+ var isRemovable = device.TryGetProperty("rm", out var rm) && (rm.ValueKind == JsonValueKind.True || (rm.ValueKind == JsonValueKind.Number && rm.GetInt32() == 1));
+ if (!isRemovable)
+ return null;
+
+ var type = device.TryGetProperty("type", out var t) ? t.GetString() : null;
+ if (type != "part")
+ return null;
+
+ var name = device.TryGetProperty("name", out var n) ? n.GetString() : null;
+ if (name == null)
+ return null;
+
+ var mountpoint = device.TryGetProperty("mountpoint", out var mp) && mp.ValueKind == JsonValueKind.String ? mp.GetString() : null;
+
+ if (!string.IsNullOrEmpty(mountpoint))
+ {
+ return CheckMountpoint(mountpoint);
+ }
+
+ // Try to mount and check
+ var tempMount = $"/tmp/usb_scan_{name}";
+ try
+ {
+ Directory.CreateDirectory(tempMount);
+ var mountResult = await RunCommandAsync("mount", $"-o ro /dev/{name} {tempMount}");
+ if (mountResult == null)
+ return null;
+
+ return CheckMountpoint(tempMount);
+ }
+ finally
+ {
+ try { await RunCommandAsync("umount", tempMount); } catch { }
+ try { Directory.Delete(tempMount); } catch { }
+ }
+ }
+
+ private static UsbKeyResult? CheckMountpoint(string mountpoint)
+ {
+ var keyPath = Path.Combine(mountpoint, KeyFileName);
+ if (!File.Exists(keyPath))
+ return null;
+
+ var content = File.ReadAllText(keyPath).Trim();
+ if (string.IsNullOrWhiteSpace(content))
+ return null;
+
+ var validation = ConfigurationLoader.TryLoadFromBase64(content);
+ if (!validation.Success)
+ return new UsbKeyResult(true, content, null, null, $"Key file found but invalid: {validation.Error}");
+
+ return new UsbKeyResult(true, content, validation.Config!.ApiUrl, validation.Config.RestaurantId, null);
+ }
+
+ private static async Task RunCommandAsync(string command, string arguments)
+ {
+ try
+ {
+ using var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = command,
+ Arguments = arguments,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ }
+ };
+
+ process.Start();
+ var output = await process.StandardOutput.ReadToEndAsync();
+ await process.WaitForExitAsync();
+
+ return process.ExitCode == 0 ? output : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}