// ===================================================================== // RETAIL PHARMACY PLATFORM — Counter Helper // Program.cs : Windows service host // // A deliberately thin service. It moves bytes and manages processes. // No business logic lives here and none ever should — the PHP node // decides what to print, this decides how to get it onto paper. // // Binds LOOPBACK ONLY. Every request carries a token generated at // install and shared with the local node. Without that, any web page // the chemist opens in the same browser could drive his printer and // pop his cash drawer. // ===================================================================== using System; using System.Collections.Generic; using System.IO; using System.Net; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; namespace CounterHelper; public static class Program { private const string Version = "1.0.0"; private static HelperConfig _cfg = null!; private static SpoolQueue _spool = null!; private static readonly List _telemetry = new(); public static async Task Main(string[] args) { _cfg = HelperConfig.Load(); _spool = new SpoolQueue(_cfg.DataDir, Telemeter); var listener = new HttpListener(); listener.Prefixes.Add($"http://127.0.0.1:{_cfg.Port}/"); listener.Start(); Log($"Counter Helper {Version} listening on 127.0.0.1:{_cfg.Port}"); Log($"Data directory: {_cfg.DataDir}"); while (true) { HttpListenerContext ctx; try { ctx = await listener.GetContextAsync(); } catch (HttpListenerException ex) { // The listener itself failed. Retrying immediately would // spin at 100% CPU on a shop PC; restarting it is what // actually recovers. Log("listener stopped: " + ex.Message + " — restarting"); try { listener.Stop(); listener.Start(); } catch (Exception re) { Log("listener will not restart: " + re.Message); return; } await Task.Delay(500); continue; } catch (ObjectDisposedException) { return; } catch (Exception ex) { Log("listener: " + ex.Message); await Task.Delay(200); continue; } /* OBSERVE THE HANDLER TASK. * * This was `_ = Task.Run(() => Handle(ctx))`. Handle is async, * so that discards a Task: anything it throws before its * own try/catch is reached — or inside the catch's Write — is * an unobserved exception nobody ever sees. The helper was * found alive with ONE thread, not listening, and no log line * saying why. * * A counter that silently stops answering is worse than one * that crashes: the biller keeps pressing save and the screen * keeps waiting. */ _ = Task.Run(async () => { try { await Handle(ctx); } catch (Exception ex) { Log("handler: " + ex.GetType().Name + ": " + ex.Message); try { ctx.Response.StatusCode = 500; ctx.Response.Close(); } catch { /* client already hung up; the fault is logged above */ } } }); } } // ---- routing ---------------------------------------------------- private static async Task Handle(HttpListenerContext ctx) { var req = ctx.Request; var res = ctx.Response; res.Headers["Cache-Control"] = "no-store"; try { // Loopback binding already excludes the network. The token // excludes everything else running on this machine. if (req.Headers["X-Helper-Token"] != _cfg.Token) { await Write(res, 401, new { error = "bad or missing X-Helper-Token" }); return; } string path = req.Url?.AbsolutePath.TrimEnd('/') ?? ""; string body = req.HasEntityBody ? await new StreamReader(req.InputStream, Encoding.UTF8).ReadToEndAsync() : ""; switch (path) { case "/v1/health": await Write(res, 200, Health()); break; case "/v1/printers": await Write(res, 200, new { printers = RawPrinter.List() }); break; case "/v1/print": await Write(res, 200, Print(body)); break; case "/v1/testprint": await Write(res, 200, TestPrint(body)); break; case "/v1/drawer": await Write(res, 200, Drawer(body)); break; case "/v1/spool": await Write(res, 200, new { jobs = _spool.Pending() }); break; case "/v1/update/check": await Write(res, 200, new { current = Version, available = (string?)null }); break; default: if (path.StartsWith("/v1/spool/") && path.EndsWith("/retry")) { var id = path.Split('/')[3]; await Write(res, 200, new { retried = _spool.Retry(id), job_id = id }); } else await Write(res, 404, new { error = "no such endpoint" }); break; } } catch (Exception ex) { Telemeter("ERROR", "HELPER_EXCEPTION", ex.ToString()); try { await Write(res, 500, new { error = ex.Message }); } catch { /* connection gone mid-write; Telemeter has it */ } } } // ---- handlers --------------------------------------------------- private static object Print(string body) { var job = JsonSerializer.Deserialize(body) ?? throw new ArgumentException("unreadable print request"); if (string.IsNullOrWhiteSpace(job.Printer)) job.Printer = _cfg.DefaultPrinter; bool accepted = _spool.Enqueue(job); // ACCEPT AND RETURN IMMEDIATELY. The caller is a billing screen // with a customer standing in front of it; it must not wait on a // mechanical device. A duplicate job_id returns accepted=false, // which the node treats as success — the job is already queued. return new { accepted, duplicate = !accepted, job_id = job.JobId, queued_at = DateTime.UtcNow.ToString("o") }; } private static object TestPrint(string body) { var req = JsonSerializer.Deserialize>(body) ?? new Dictionary(); string printer = req.TryGetValue("printer", out var p) ? p.GetString() ?? _cfg.DefaultPrinter : _cfg.DefaultPrinter; string device = req.TryGetValue("device_type", out var d) ? d.GetString() ?? "DOTMATRIX" : "DOTMATRIX"; int columns = req.TryGetValue("columns", out var c) ? c.GetInt32() : (device == "THERMAL" ? 48 : 80); int written = RawPrinter.Send(printer, RawPrinter.TestPattern(device, columns), "Print test"); return new { printed = true, bytes = written, printer, device_type = device, columns }; } private static object Drawer(string body) { var req = JsonSerializer.Deserialize>(body) ?? new Dictionary(); string printer = req.TryGetValue("printer", out var p) ? p.GetString() ?? _cfg.DefaultPrinter : _cfg.DefaultPrinter; RawPrinter.Send(printer, RawPrinter.DrawerKick(), "Drawer"); return new { kicked = true, printer }; } private static object Health() { var pending = _spool.Pending(); int failed = 0; foreach (var j in pending) if (j.Status == "FAILED") failed++; List printers; string? printerError = null; try { printers = RawPrinter.List(); } catch (Exception ex) { printers = new List(); // FIRST LINE ONLY. // // A failed P/Invoke returns eight lines of loader search // paths. A partner reading this on a support call learns // nothing from them, and it buries the sentence that matters. var first = ex.Message.Split('\n')[0].Trim(); printerError = first.Length > 160 ? first.Substring(0, 160) : first; } // A counter that cannot reach its printer is NOT healthy. // // This said "OK" while printer_error was set — so anything polling // health would report a shop as fine when it could not print a // single bill. Found by running the helper for the first time. bool printerOk = printerError == null && (_cfg.DefaultPrinter.Length == 0 || printers.Contains(_cfg.DefaultPrinter)); return new { status = (failed > 0 || !printerOk) ? "DEGRADED" : "OK", version = Version, counter_id = _cfg.CounterId, default_printer = _cfg.DefaultPrinter, printer_present = printers.Contains(_cfg.DefaultPrinter), printer_error = printerError, spool_pending = pending.Length, spool_failed = failed, uptime_seconds = (int)(DateTime.UtcNow - _startedAt).TotalSeconds }; } // ---- telemetry -------------------------------------------------- private static readonly DateTime _startedAt = DateTime.UtcNow; /// /// Support-cost rule S8: a bug should reach engineering once, not /// reach 500 chemists. Everything lands on disk here and is shipped /// up by the local node on its next sync — the helper deliberately /// has no outbound network path of its own. /// private static void Telemeter(string kind, string code, string? message) { var row = new { kind, code, message, counter_id = _cfg?.CounterId, version = Version, occurred_at = DateTime.UtcNow.ToString("o") }; lock (_telemetry) _telemetry.Add(row); try { var file = Path.Combine(_cfg!.DataDir, "telemetry", $"{DateTime.UtcNow:yyyy-MM-dd}.jsonl"); Directory.CreateDirectory(Path.GetDirectoryName(file)!); File.AppendAllText(file, JsonSerializer.Serialize(row) + Environment.NewLine); } catch { /* telemetry must never break the thing it is watching */ } Log($"[{kind}] {code} {message}"); } private static void Log(string msg) { Console.WriteLine($"{DateTime.Now:HH:mm:ss} {msg}"); try { var f = Path.Combine(_cfg?.DataDir ?? Path.GetTempPath(), "helper.log"); File.AppendAllText(f, $"{DateTime.Now:u} {msg}{Environment.NewLine}"); } catch { /* THE LOGGER ITSELF. A full disk or a locked file must not take * the counter down: a shop that cannot bill because it cannot * write a log line is a far worse outcome than a missing log * line. There is nowhere left to report this to. */ } } private static async Task Write(HttpListenerResponse res, int code, object payload) { res.StatusCode = code; res.ContentType = "application/json"; var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload)); res.ContentLength64 = bytes.Length; await res.OutputStream.WriteAsync(bytes); res.OutputStream.Close(); } }