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)} + +
+ Refresh +
+ + +"; + } + + 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; + } + } +}