# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview This is a C# .NET 8.0 solution for Epson thermal receipt printer integration. The solution consists of a core SDK library (Inspectron.Epson) and a production print service application. ## Solution Structure ### Projects - **Inspectron.Epson** - Core SDK library providing printer communication, status monitoring, ESC/POS commands, print queue management, and receipt conversion - **EpsonPrintService** - Production console application with SignalR-based remote job source, printer discovery, and systemd service support - **EpsonTest** - Test/example project for SDK functionality and receipt conversion ## Common Build Commands ```bash # Build entire solution dotnet build Inspectron.Epson.slnx # Build specific project dotnet build Inspectron.Epson/Inspectron.Epson.csproj dotnet build EpsonPrintService/EpsonPrintService.csproj # Run print service dotnet run --project EpsonPrintService/EpsonPrintService.csproj # Run tests/examples dotnet run --project EpsonTest/EpsonTest.csproj # Clean build artifacts dotnet clean ``` ## Architecture ### Three-Layer Architecture 1. **SDK Layer (Inspectron.Epson)** - `IEpsonPrinter` / `EpsonPrinter`: Main interface and TCP/IP implementation for printer operations (async/await) - `EpsonCommands`: Static ESC/POS command definitions - `EpsonImageConverter`: Image processing with Floyd-Steinberg dithering - `IDiscoveryService` / `DiscoveryService`: Network printer discovery (SLP, ENPC) - `PreconfiguredDiscoveryService`: Preconfigured printer list (no network scan) - `BonjourDiscovery`: mDNS-based printer discovery - `HtmlPrinter`: HTML output implementation for testing without hardware - Status classes: PrinterStatus, OfflineStatus, ErrorStatus, PaperSensorStatus, OverallStatus - Exception hierarchy: EpsonPrinterException -> EpsonConnectionException, EpsonCommandException 2. **Queue Layer (Inspectron.Epson.Queue namespace)** - `PrintServer`: Manager for per-printer queues with read/write lock coordination between printing and discovery - `PrinterQueue`: Per-printer queue with retry logic (ConcurrentQueue + priority ConcurrentStack) - `IPrintService`: Print workflow interface (PrintAsync returns `PrintResult`) - `PrintJob`: Job model with IP, Document, QueuedAt, RetryCount - `PrintResult`: Success/Failure result with `PrintErrorType` - `IJobStatusReporter`: Interface for reporting job status updates 3. **PrintServer Layer (Inspectron.Epson.PrintServer namespace)** - `PrintLoop`: Orchestrator connecting job sources to print server - **JobSources/**: `IPrintJobSource` strategy pattern (SignalRPrintJobSource, SingleJobSource) - **PrintServices/**: `IPrintService` implementation (`EpsonPrintService`), `IAssignedPrinterRepository`, `IPrinterConfigurationSource` - **ConfigurationSources/**: `EpsonPrintServiceConfiguration`, `FixedConfigurationSource` - **Printers/**: `IPrinterFactory` (creates `IEpsonPrinter`), `IWrapperPrinterFactory` / `PrinterWrapperFactory` (creates `IPrinter` adapter by model ID) - **Printers/Utils/**: `PrintCommand` model, `IReceiptConverter` / `IReceiptConverterFactory` with receipt type implementations - **WorkAreaSources/**: `IWorkAreasSource`, `JamesWorkAreaSource` - **DiscoveredPrintersReceiver/**: `IDiscoveredPrintersReceiver`, `JamesDiscoveredPrintersReceiver` 4. **Application Layer (EpsonPrintService)** - Production deployment with Ninject DI, SignalR remote jobs, printer discovery, heartbeat, systemd service ### Receipt Conversion System Receipt content is converted from JSON to `PrintCommand` lists via `IReceiptConverterFactory`: - **KitchenReceipt** - Kitchen order tickets - **FinalReceipt** - Final customer receipts - **InvoiceReceipt** - Invoice receipts - **BarReceipt** - Bar order tickets - **OrderItems** - Order item lists `PrintCommand` properties: Text, IsBig, IsTall, IsBold, IsRed, SetLineSpacing, IsCut ### Printer Model Adapters `IPrinter` implementations wrap `IEpsonPrinter` with model-specific behavior: - `TM-T30III` / `TM-T30IIITranslated` - Thermal receipt printer (384px image width) - `TM-U220II` / `TM-U220IITranslated` - Dot matrix receipt printer (narrower image width) Created via `IWrapperPrinterFactory.CreatePrinterFromId(modelIdByte, epsonPrinter)`. ### Dependency Injection (Ninject) ```csharp StandardKernel kernel = new(); kernel.Bind().To(); kernel.Bind().To().InSingletonScope(); kernel.Bind().To().InSingletonScope(); kernel.Bind().To(); kernel.Bind().To(); kernel.Bind().To().InSingletonScope(); kernel.Bind().To(); kernel.Bind().ToConstant(discovery); kernel.Bind().To().InSingletonScope(); kernel.Bind().ToSelf().InSingletonScope(); ``` ### Key Interfaces and Abstractions - `IEpsonPrinter`: Low-level printer operations (connect, status, ESC/POS commands, image printing) - `IPrinter`: Printer model adapter (InitAsync, PrintImageAsync, SetFontSizeAsync, PrintAsync, Cut) - `IPrintJobSource`: Pluggable job sources (async via Channel) - `IPrintService`: Print workflow execution (PrintAsync returns `PrintResult`) - `IPrinterFactory`: Creates `IEpsonPrinter` instances (EpsonPrinterFactory, HtmlPrinterFactory) - `IWrapperPrinterFactory`: Creates `IPrinter` adapter from model ID byte - `IPrinterConfigurationSource`: Provides printer configuration - `IReceiptConverter` / `IReceiptConverterFactory`: Converts JSON receipt content to `PrintCommand` list - `IDiscoveryService`: Printer network discovery - `IDiscoveredPrintersReceiver`: Receives and processes discovered printers - `IJobStatusReporter`: Reports job status updates ### Print Job Flow ``` PrintLoop.StartAsync() -> IPrintJobSource.GetNextJobAsync() [blocks until job available] -> PrintServer.SubmitJob(job.IP, job) -> PrinterQueue.Enqueue(job) [per-printer queue] -> ProcessQueueAsync() [background loop] -> IPrintService.PrintAsync(printerIp, job) -> IPrinterFactory.CreatePrinter() [creates IEpsonPrinter] -> Connect, get printer ID, check status -> IWrapperPrinterFactory.CreatePrinterFromId() [creates IPrinter adapter] -> Initialize printer, download & cache logo (if URL provided) -> IReceiptConverterFactory.Create(receiptType, printerId) -> Convert JSON content to PrintCommand list -> printerAdapter.PrintAsync(commands), cut paper -> Return PrintResult.Ok() or PrintResult.Fail(errorType) -> On failure: retry up to 3 times via priority queue ``` ### Configuration System Configuration is loaded from a base64-encoded `config.txt` file containing `apiUrl;restaurantId;apiKey`: ``` # config.txt contains a single base64-encoded line # Decoded format: apiUrl;restaurantId;apiKey # Example decoded: https://api.gastrojames.ch;63e37295bd6f26dbd36164c0;yourApiKeyHere ``` `ConfigurationPaths` resolves the config path: - **Windows**: local `config.txt` - **Linux**: `/root/epsonprintservice/config.txt` Printers are registered via `PreconfiguredDiscoveryService` with hardcoded IPs in `Program.cs`, or dynamically via `PrinterDiscoveryBackgroundTask`. ### PrintServer Read/Write Lock `PrintServer` uses a readers-writer lock pattern to coordinate printing with discovery: - **Print operations** acquire a read lock (`EnterPrintLock` / `ExitPrintLock`) - multiple prints run concurrently - **Discovery operations** acquire a write lock (`EnterDiscoveryLockAsync` / `ExitDiscoveryLock`) - blocks until all prints complete, prevents new prints during discovery ### Printer Communication - **Protocol**: ESC/POS over TCP/IP (port 9100 default) - **Encoding**: UTF-8 (ESC t 255) or CP852 (ESC t 18) with System.Text.Encoding.CodePages - **Status Queries**: DLE EOT real-time commands (0x10 0x04 + sub-command byte) - **Image Printing**: Two modes - Raster mode (GS ( L): 384px width for TM-T30III - Bit-image mode (ESC *): 8-dot or 24-dot with single/double density - **Error Handling**: All async operations throw EpsonConnectionException or EpsonCommandException on failure ## Adding New Printer Models 1. Get printer ID byte via `EpsonPrinter.GetPrinterIdAsync()` (GS I 1 command) 2. Create new class implementing `IPrinter` in `Inspectron.Epson/PrintServer/Printers/` 3. Implement required methods: InitAsync, PrintImageAsync, SetFontSizeAsync, PrintAsync, Cut 4. Add case to `PrinterWrapperFactory.CreatePrinterFromId()` with the printer ID byte 5. Add receipt converter support in `ReceiptConverterFactory` if needed 6. Test with actual hardware - different models have different image widths and positioning ## Adding New Receipt Types 1. Create a new folder under `Inspectron.Epson/PrintServer/Printers/Utils/` 2. Add Models.cs with the receipt data model 3. Create a converter implementing `IReceiptConverter` (returns `List`) 4. Register the new receipt type in `ReceiptConverterFactory` ## Testing Printer Connectivity ```csharp // Basic connection test await using var printer = new EpsonPrinter(logger); await printer.ConnectAsync("192.168.1.100"); var status = await printer.GetOverallStatusAsync(); Console.WriteLine($"Status: {status.StatusText}, Ready: {status.IsReady}"); // Full diagnostics var diagnostics = new EpsonDiagnostics(logger); var report = await diagnostics.RunFullDiagnosticsAsync("192.168.1.100"); Console.WriteLine(report); ``` ## Deployment ### Systemd Service (Linux) EpsonPrintService includes systemd service files (`epson.service`, `print_server.service`): ```bash # Copy files to deployment directory cp -r EpsonPrintService/bin/Debug/net8.0/* /home/pi/epson_service/ # Install and start service sudo cp epson.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable epson.service sudo systemctl start epson.service # Check status sudo systemctl status epson.service ``` Deployment scripts included: `start.sh`, `postinst`, `prem`, `uninstall.sh`. ## Important Implementation Notes ### PrinterQueue Retry Logic Failed print jobs are automatically retried up to 3 times via priority queue: - Failed jobs pushed to `ConcurrentStack` (LIFO) - Priority stack checked before main queue in `ProcessQueueAsync()` - RetryCount incremented on each failure - Jobs with RetryCount >= 3 are discarded ### Image Processing Pipeline When printing images via `EpsonPrinter.LoadImageAsync()`: 1. Load image with SixLabors.ImageSharp 2. Resize maintaining aspect ratio (max width: printer-specific) 3. Convert to grayscale 4. Apply Floyd-Steinberg dithering for 1-bit black/white conversion 5. Pack pixels to byte-aligned format 6. Generate column format for bit-image mode (ESC * 33) ### Logo Caching `EpsonPrintService` downloads logos from URLs provided in print jobs: - Logos are cached locally in a `logos/` directory under the app base directory - Filename is base64-encoded to avoid path issues - Subsequent prints skip the download if the cached file exists ### SignalR Job Source `SignalRPrintJobSource` connects to `{ApiUrl}/hubs/printer-servers`: - Uses `InfiniteRetryPolicy` with exponential backoff (capped at 30s) for automatic reconnection - Retries initial connection in a loop on failure - Joins group by RestaurantId from configuration - Message: "PrintJob" with { PrinterIp, LogoUrl, ReceiptType, Content } - Uses Channel for async producer-consumer pattern ### Font Sizing Font magnification is 1-8x for both width and height: - ESC ! command: bits 4-7 control size - Formula: `(widthMagnifier - 1) << 4 | (heightMagnifier - 1)` - Example: Size 2 = 0x11 (2x width, 2x height) ## Common Troubleshooting ### "Printer not found" or connection timeout - Verify IP address with `ping ` - Check printer is on same network/VLAN - Ensure port 9100 is not blocked by firewall - Check `PreconfiguredDiscoveryService` entries in `Program.cs` ### "Cover open" or "Paper end" errors - Check `OverallStatus.Recommendations` for specific issues - Common: Cover not fully closed, paper roll empty/misaligned ### Image not printing or garbled - Verify image width matches printer specification (384px for TM-T30III) - Check image file path or URL is accessible - Ensure printer model supports raster graphics (TM-T30III does, older models may not) ### SignalR connection failures - Check RestaurantId and ApiKey in config.txt (base64-encoded) - Verify network connectivity to the API URL - SignalR auto-reconnects with infinite retry; check logs for reconnection attempts ## Insin Integration This service is deployed as an Insin package and emits telemetry via the on-device Insin agent's **local loopback listener**. Env vars `INSIN_URL` and `INSIN_TOKEN` are provided by the deployment environment. ### Publishing events (local loopback — the path we use) Because this service runs on Insin-managed devices where `insin monitor` (the `insin.service` systemd unit) is active, events go to the loopback ingest — **not** a remote HTTP endpoint. The on-device agent persists locally and forwards on the next device heartbeat. - **Endpoint:** `POST http://127.0.0.1:47823/events` (loopback-only, no auth). - **Single event payload:** `{"kind": ".[.]", "message": "", "at": ""}`. - **Batch payload:** `{"events": [ {...}, {...} ]}`. - **Event `kind` conventions** for this service: - `job.printed` — successful print, message includes printer + jobId - `job.failed.` — e.g. `job.failed.paper_out`, `job.failed.cover_open`, `job.failed.connection` - `printer.online` / `printer.offline` — status transitions - `printer.discovered` — new printer found by discovery - **Metrics** use the same shape at `POST /metrics` with `{name, value, unit}` (e.g. `printer.head.temperature`). - Delivery: at-least-once, server ring-trims to ~1000 per device. Admin UI polls every 5s. No rate limiting or 429s; each POST commits to local SQLite before returning 200. - **If `127.0.0.1:47823` is unreachable**, `insin monitor` isn't running — do not swallow this silently in production; log it. In dev, just no-op. - Full reference: `docs/device-telemetry-api.md` in the Insin repo. ### Deploying / publishing this service as an Insin package Insin uses a flat global package namespace with the `AdminToken` auth header (NOT `Bearer`). Publish flow for CI or a release script: ```bash set -euo pipefail : "${INSIN_URL:?}" "${INSIN_TOKEN:?}" # 1. Fetch latest CLI (linux-arm64 shown; use win-x64 on Windows CI). LATEST=$(curl -fsSL "$INSIN_URL/api/v1/downloads/cli" \ | python3 -c 'import json,sys; m=json.load(sys.stdin)[0]; a=next(x for x in m["artifacts"] if x["rid"]=="linux-arm64"); print(m["version"], a["filename"])') VERSION=${LATEST% *}; FILENAME=${LATEST#* } curl -fsSL "$INSIN_URL/api/v1/downloads/cli/$VERSION/$FILENAME" -o /tmp/insin.tar.gz mkdir -p /tmp/insin && tar -xzf /tmp/insin.tar.gz -C /tmp/insin chmod +x /tmp/insin/insin # 2. Pack. NOTE: pack zips CWD recursively (minus *.pkg) and writes to # ../packages/@.pkg — one directory UP from CWD. # cd into the build output first; don't run from repo root (would bundle .git/). cd EpsonPrintService/bin/Release/net8.0/publish /tmp/insin/insin pack epson-print-service@1.2.3 # 3. Publish. Reads INSIN_URL + INSIN_TOKEN from env; --url/--token override. /tmp/insin/insin publish ../packages/epson-print-service@1.2.3.pkg ``` Under the hood `publish` = `POST $INSIN_URL/api/v1/admin/packages` (multipart form, field `file`) with header `Authorization: AdminToken $INSIN_TOKEN`. ### Insin rules & gotchas - **Auth header:** `Authorization: AdminToken ` — NOT `Bearer`. Same header for master admin token AND service tokens. - **Always use a service token** (minted in admin UI → Service Tokens → New token, plaintext shown once). Never ship the master `INSIN_ADMIN_TOKEN`. - **`INSIN_URL` is a bare origin:** no trailing slash, no `/api` suffix. Just `https://insin.example.com`. - **First publish auto-creates** the package name. No separate registration. - **Versions are immutable.** Re-publishing the same `(name, version)` returns **409 Conflict**. Bump the version. - **Flat global namespace** — no scopes. Pick a distinctive name (we use `epson-print-service`). - **CLI artifacts:** only `linux-arm64` and `win-x64` today. On `linux-x64` CI, run under qemu (`--platform linux/arm64`) — the CLI is just packer + uploader so emulation is fine. - **`insin pack` output is one dir UP** (`../packages/`), not `./`. Look there if the `.pkg` seems missing. - If both `INSIN_ADMIN_TOKEN` and `INSIN_TOKEN` are set, `publish` reads `INSIN_TOKEN` first (service token wins). - **List packages:** `curl -H "Authorization: AdminToken $INSIN_TOKEN" $INSIN_URL/api/v1/admin/packages`. - **Delete a version:** `DELETE /api/v1/admin/packages/{name}/{version}` (same header). - No shared public staging — stand up a scratch instance if you need one.