17 KiB
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
# 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
-
SDK Layer (Inspectron.Epson)
IEpsonPrinter/EpsonPrinter: Main interface and TCP/IP implementation for printer operations (async/await)EpsonCommands: Static ESC/POS command definitionsEpsonImageConverter: Image processing with Floyd-Steinberg ditheringIDiscoveryService/DiscoveryService: Network printer discovery (SLP, ENPC)PreconfiguredDiscoveryService: Preconfigured printer list (no network scan)BonjourDiscovery: mDNS-based printer discoveryHtmlPrinter: HTML output implementation for testing without hardware- Status classes: PrinterStatus, OfflineStatus, ErrorStatus, PaperSensorStatus, OverallStatus
- Exception hierarchy: EpsonPrinterException -> EpsonConnectionException, EpsonCommandException
-
Queue Layer (Inspectron.Epson.Queue namespace)
PrintServer: Manager for per-printer queues with read/write lock coordination between printing and discoveryPrinterQueue: Per-printer queue with retry logic (ConcurrentQueue + priority ConcurrentStack)IPrintService: Print workflow interface (PrintAsync returnsPrintResult)PrintJob: Job model with IP, Document, QueuedAt, RetryCountPrintResult: Success/Failure result withPrintErrorTypeIJobStatusReporter: Interface for reporting job status updates
-
PrintServer Layer (Inspectron.Epson.PrintServer namespace)
PrintLoop: Orchestrator connecting job sources to print server- JobSources/:
IPrintJobSourcestrategy pattern (SignalRPrintJobSource, SingleJobSource) - PrintServices/:
IPrintServiceimplementation (EpsonPrintService),IAssignedPrinterRepository,IPrinterConfigurationSource - ConfigurationSources/:
EpsonPrintServiceConfiguration,FixedConfigurationSource - Printers/:
IPrinterFactory(createsIEpsonPrinter),IWrapperPrinterFactory/PrinterWrapperFactory(createsIPrinteradapter by model ID) - Printers/Utils/:
PrintCommandmodel,IReceiptConverter/IReceiptConverterFactorywith receipt type implementations - WorkAreaSources/:
IWorkAreasSource,JamesWorkAreaSource - DiscoveredPrintersReceiver/:
IDiscoveredPrintersReceiver,JamesDiscoveredPrintersReceiver
-
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)
StandardKernel kernel = new();
kernel.Bind<IPrinterFactory>().To<EpsonPrinterFactory>();
kernel.Bind<IWrapperPrinterFactory>().To<PrinterWrapperFactory>().InSingletonScope();
kernel.Bind<IPrintJobSource, SignalRPrintJobSource>().To<SignalRPrintJobSource>().InSingletonScope();
kernel.Bind<IPrintService>().To<EpsonPrintService>();
kernel.Bind<IPrinterConfigurationSource>().To<FixedConfigurationSource>();
kernel.Bind<IReceiptConverterFactory>().To<ReceiptConverterFactory>().InSingletonScope();
kernel.Bind<IJobStatusReporter>().To<NullJobStatusReporter>();
kernel.Bind<IDiscoveryService>().ToConstant(discovery);
kernel.Bind<IDiscoveredPrintersReceiver>().To<JamesDiscoveredPrintersReceiver>().InSingletonScope();
kernel.Bind<PrintServer>().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 returnsPrintResult)IPrinterFactory: CreatesIEpsonPrinterinstances (EpsonPrinterFactory, HtmlPrinterFactory)IWrapperPrinterFactory: CreatesIPrinteradapter from model ID byteIPrinterConfigurationSource: Provides printer configurationIReceiptConverter/IReceiptConverterFactory: Converts JSON receipt content toPrintCommandlistIDiscoveryService: Printer network discoveryIDiscoveredPrintersReceiver: Receives and processes discovered printersIJobStatusReporter: 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
- Get printer ID byte via
EpsonPrinter.GetPrinterIdAsync()(GS I 1 command) - Create new class implementing
IPrinterinInspectron.Epson/PrintServer/Printers/ - Implement required methods: InitAsync, PrintImageAsync, SetFontSizeAsync, PrintAsync, Cut
- Add case to
PrinterWrapperFactory.CreatePrinterFromId()with the printer ID byte - Add receipt converter support in
ReceiptConverterFactoryif needed - Test with actual hardware - different models have different image widths and positioning
Adding New Receipt Types
- Create a new folder under
Inspectron.Epson/PrintServer/Printers/Utils/ - Add Models.cs with the receipt data model
- Create a converter implementing
IReceiptConverter(returnsList<PrintCommand>) - Register the new receipt type in
ReceiptConverterFactory
Testing Printer Connectivity
// 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):
# 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<PrintJob>(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():
- Load image with SixLabors.ImageSharp
- Resize maintaining aspect ratio (max width: printer-specific)
- Convert to grayscale
- Apply Floyd-Steinberg dithering for 1-bit black/white conversion
- Pack pixels to byte-aligned format
- 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
InfiniteRetryPolicywith 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 <printer-ip> - Check printer is on same network/VLAN
- Ensure port 9100 is not blocked by firewall
- Check
PreconfiguredDiscoveryServiceentries inProgram.cs
"Cover open" or "Paper end" errors
- Check
OverallStatus.Recommendationsfor 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": "<subsystem>.<verb>[.<qualifier>]", "message": "<free-form>", "at": "<ISO-8601 UTC>"}. - Batch payload:
{"events": [ {...}, {...} ]}. - Event
kindconventions for this service:job.printed— successful print, message includes printer + jobIdjob.failed.<reason>— e.g.job.failed.paper_out,job.failed.cover_open,job.failed.connectionprinter.online/printer.offline— status transitionsprinter.discovered— new printer found by discovery
- Metrics use the same shape at
POST /metricswith{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:47823is unreachable,insin monitorisn't running — do not swallow this silently in production; log it. In dev, just no-op. - Full reference:
docs/device-telemetry-api.mdin 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:
set -euo pipefail
: "${INSIN_URL:?}" "${INSIN_TOKEN:?}"
# 1. Fetch latest CLI (pick RID matching host — linux-x64 shown).
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-x64"); 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/<name>@<version>.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 <token>— NOTBearer. 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_URLis a bare origin: no trailing slash, no/apisuffix. Justhttps://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 ship for
linux-x64,linux-arm64,linux-arm, andwin-x64(as of CLI 1.3.1). Pick the RID that matches the host; no qemu required. insin packoutput is one dir UP (../packages/), not./. Look there if the.pkgseems missing.- If both
INSIN_ADMIN_TOKENandINSIN_TOKENare set,publishreadsINSIN_TOKENfirst (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.