diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..8705aee --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,4 @@ +{ + "permissions": { + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4948361 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main, migrate-to-avalonia] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + # The browser (WASM) head needs the wasm-tools workload to build. + - name: Install wasm-tools workload + run: dotnet workload install wasm-tools + + - name: Restore + run: dotnet restore CrossEscPos.slnx + + - name: Build + run: dotnet build CrossEscPos.slnx -c Release --no-restore + + - name: Test + run: dotnet test CrossEscPos.slnx -c Release --no-build + + - name: Pack libraries + run: dotnet pack CrossEscPos.slnx -c Release --no-build -o artifacts/packages + + - uses: actions/upload-artifact@v4 + with: + name: nupkg + path: artifacts/packages/*.nupkg + if-no-files-found: warn diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..de1ef1b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,166 @@ +name: Release + +# Builds self-contained desktop apps for Windows, Linux and macOS, packs the NuGet libraries, and +# publishes everything to a GitHub Release. Trigger by pushing a tag like `v1.2.3`, or run manually +# (workflow_dispatch) to just build artifacts. + +on: + push: + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.rid }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { rid: win-x64, os: ubuntu-latest, kind: zip } + - { rid: linux-x64, os: ubuntu-latest, kind: targz } + - { rid: osx-x64, os: macos-latest, kind: app } + - { rid: osx-arm64, os: macos-latest, kind: app } + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Determine version + id: ver + shell: bash + run: | + VER=1.0.0 + if [ "$GITHUB_REF_TYPE" = "tag" ]; then VER="${GITHUB_REF_NAME#v}"; fi + echo "version=$VER" >> "$GITHUB_OUTPUT" + + - name: Publish (self-contained) + run: > + dotnet publish src/CrossEscPos.App.Desktop/CrossEscPos.App.Desktop.csproj + -c Release + -r ${{ matrix.rid }} + --self-contained true + -p:PublishSingleFile=false + -p:Version=${{ steps.ver.outputs.version }} + -o publish/${{ matrix.rid }} + + - name: Package — zip (Windows) + if: matrix.kind == 'zip' + run: | + cd publish/${{ matrix.rid }} + zip -r ../../CrossEscPos-${{ matrix.rid }}.zip . + + - name: Package — tar.gz (Linux) + if: matrix.kind == 'targz' + run: tar -czf CrossEscPos-${{ matrix.rid }}.tar.gz -C publish/${{ matrix.rid }} . + + - name: Package — .app bundle (macOS) + if: matrix.kind == 'app' + run: | + chmod +x scripts/make-macos-app.sh + scripts/make-macos-app.sh publish/${{ matrix.rid }} dist "${{ steps.ver.outputs.version }}" + ditto -c -k --keepParent dist/CrossEscPos.app CrossEscPos-${{ matrix.rid }}.zip + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.rid }} + path: | + CrossEscPos-${{ matrix.rid }}.zip + CrossEscPos-${{ matrix.rid }}.tar.gz + if-no-files-found: ignore + + pack: + name: Pack NuGet libraries + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Determine version + id: ver + shell: bash + run: | + VER=1.0.0 + if [ "$GITHUB_REF_TYPE" = "tag" ]; then VER="${GITHUB_REF_NAME#v}"; fi + echo "version=$VER" >> "$GITHUB_OUTPUT" + + # The packable libraries don't need the browser head, so pack each individually (no wasm workload). + - name: Pack + shell: bash + run: | + for proj in Abstractions Core Rendering.Skia Rendering.ImageSharp Transports Controls; do + dotnet pack "src/CrossEscPos.$proj/CrossEscPos.$proj.csproj" \ + -c Release -p:Version=${{ steps.ver.outputs.version }} -o packages + done + + - uses: actions/upload-artifact@v4 + with: + name: nupkg + path: | + packages/*.nupkg + packages/*.snupkg + + publish-nuget: + name: Publish to NuGet.org + needs: pack + runs-on: ubuntu-latest + # Only publish for real version tags (v*), never on a manual workflow_dispatch dry run. + if: startsWith(github.ref, 'refs/tags/') + # Scopes the OIDC token to this environment; also lets you add a manual approval gate in + # Settings → Environments → release before anything reaches nuget.org. + environment: release + permissions: + id-token: write # required: mints the GitHub OIDC token for NuGet Trusted Publishing + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - uses: actions/download-artifact@v4 + with: + name: nupkg + path: packages + + # Exchanges the GitHub OIDC token for a short-lived (1 h) nuget.org API key. No stored key. + # NUGET_USER is your nuget.org profile name (NOT your email); set it as a repo/environment secret. + - name: NuGet login (OIDC → short-lived API key) + id: login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} + + # Pushing *.nupkg also pushes each matching *.snupkg symbol package automatically. + - name: Push packages + run: > + dotnet nuget push "packages/*.nupkg" + --api-key ${{ steps.login.outputs.NUGET_API_KEY }} + --source https://api.nuget.org/v3/index.json + --skip-duplicate + + release: + name: Create GitHub Release + needs: [build, pack] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: List artifacts + run: find artifacts -type f + + - uses: softprops/action-gh-release@v2 + with: + files: artifacts/**/* + generate_release_notes: true + fail_on_unmatched_files: false diff --git a/.gitignore b/.gitignore index 251f913..ce74ac9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,14 @@ riderModule.iml /_ReSharper.Caches/ /.vs /.idea/ + +# Runtime debug dumps written by ReceiptPrinter.FeedEscPos +last_escpos_receive.txt +last_ticket.bin +last_*.txt + +# macOS +.DS_Store + +# Generated: the host publishes the Avalonia WASM client here on first build. +samples/CrossEscPos.Host/wwwroot/ diff --git a/App.xaml b/App.xaml deleted file mode 100644 index ef92ca9..0000000 --- a/App.xaml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - diff --git a/App.xaml.cs b/App.xaml.cs deleted file mode 100644 index 105b9bb..0000000 --- a/App.xaml.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Windows; -using ReceiptPrinterEmulator.Emulator; -using ReceiptPrinterEmulator.Networking; - -namespace ReceiptPrinterEmulator -{ - public partial class App : Application - { - public static ReceiptPrinter? Printer = null; - public static NetServer? Server = null; - - private void App_OnStartup(object sender, StartupEventArgs e) - { - Printer = new ReceiptPrinter(PaperConfiguration.Default); - - Server = new NetServer(9100); - _ = Server.Run(); - } - - private void App_OnExit(object sender, ExitEventArgs e) - { - Server?.Stop(); - } - } -} \ No newline at end of file diff --git a/AssemblyInfo.cs b/AssemblyInfo.cs deleted file mode 100644 index 4a05c7d..0000000 --- a/AssemblyInfo.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Windows; - -[assembly: ThemeInfo( - ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located - //(used if a resource is not found in the page, - // or application resource dictionaries) - ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located - //(used if a resource is not found in the page, - // app, or any theme specific resource dictionaries) -)] \ No newline at end of file diff --git a/CrossEscPos.slnx b/CrossEscPos.slnx new file mode 100644 index 0000000..8faa7ac --- /dev/null +++ b/CrossEscPos.slnx @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..c163408 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,46 @@ + + + + + net10.0 + enable + latest + + CrossEscPos + + disable + true + + all + + false + + + + + 1.2.0 + Daniel Meza + + Gasoleo Technology + CrossEscPos + Copyright (c) 2026 Daniel Meza / Gasoleo Technology + https://github.com/danielmeza/CrossEscPosEmulator + https://github.com/danielmeza/CrossEscPosEmulator + git + escpos;receipt;printer;emulator;thermal;pos + + MIT + README.md + true + snupkg + + true + true + + true + + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..208c234 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/Emulator/Abstraction/IReceiptPrintable.cs b/Emulator/Abstraction/IReceiptPrintable.cs deleted file mode 100644 index 26bd060..0000000 --- a/Emulator/Abstraction/IReceiptPrintable.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Drawing; - -namespace ReceiptPrinterEmulator.Emulator.Abstraction; - -public interface IReceiptPrintable -{ - public void Render(Bitmap bitmap, Graphics g, int offsetX, int offsetY); - public int GetPrintHeight(); -} \ No newline at end of file diff --git a/Emulator/Printables/ReceiptTextLine.cs b/Emulator/Printables/ReceiptTextLine.cs deleted file mode 100644 index 889371b..0000000 --- a/Emulator/Printables/ReceiptTextLine.cs +++ /dev/null @@ -1,207 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using ReceiptPrinterEmulator.Emulator.Abstraction; -using ReceiptPrinterEmulator.Emulator.Enums; - -namespace ReceiptPrinterEmulator.Emulator.Printables; - -public class ReceiptTextLine : IReceiptPrintable -{ - private readonly PaperConfiguration.FontConfiguration _font; - private readonly int _printWidth; - private readonly int _charHeight; - private readonly TextJustification _justification; - private readonly bool _bold; - private readonly bool _italic; - private readonly UnderlineMode _underline; - - private int _totalWidth; - private readonly List<(string text, PrintMode mode)> _strings = new(); - - public bool IsEmpty => _strings.Count==0; - - public ReceiptTextLine(PaperConfiguration paperConfiguration, PrintMode printMode) - { - _font = paperConfiguration.GetFont(printMode.Font); - _printWidth = paperConfiguration.GetPrintWidthInPixels(); - _charHeight = _font.CharacterHeight * printMode.CharHeightScale; - _justification = printMode.Justification; - _bold = printMode.Emphasize; - _italic = printMode.Italic; - _underline = printMode.Underline; - - _totalWidth = 0; - } - - public bool TryWriteChar(char c, PrintMode mode) - { - int charWidth = (_font.CharacterWidth * mode.CharWidthScale); - if ((_totalWidth + charWidth) >= _printWidth) - return false; - - if (_strings.Count > 0 && mode.Equals(_strings[^1].mode)) - { - // Append to last run - var (text, lastMode) = _strings[^1]; - _strings[^1] = (text + c, lastMode); - } - else - { - // Start new run - _strings.Add((c.ToString(), mode.Clone())); - } - _totalWidth += charWidth; - return true; - } - - public int GetPrintHeight() - { - // Use the tallest run's height for correct line spacing - int maxCharHeight = 0; - foreach (var (_, mode) in _strings) - { - int charHeight = (_font.CharacterHeight / 2) * mode.CharHeightScale; - if (charHeight > maxCharHeight) - maxCharHeight = charHeight; - } - // Add a small extra space for visual separation (like real printers) - return maxCharHeight + (_font.CharacterHeight / 4); - } - - public void Render(Bitmap bitmap, Graphics g, int offsetX, int offsetY) - { - // 1. Measure total line width for justification - float totalWidth = 0; - var runWidths = new List(); - foreach (var (text, mode) in _strings) - { - int baseCharHeight = _font.CharacterHeight / 2; - var fontStyle = FontStyle.Regular; - if (mode.Emphasize) fontStyle |= FontStyle.Bold; - if (mode.Italic) fontStyle |= FontStyle.Italic; - using var font = new Font(_font.RenderFont, baseCharHeight, fontStyle); - SizeF baseSize = g.MeasureString(text, font, int.MaxValue, StringFormat.GenericTypographic); - float scaledWidth = baseSize.Width * mode.CharWidthScale; - runWidths.Add(scaledWidth); - totalWidth += scaledWidth; - } - - // 2. Use the justification of the first run (ESC/POS line property) - TextJustification justification = _strings.Count > 0 ? _strings[0].mode.Justification : TextJustification.Left; - int x = offsetX; - if (justification == TextJustification.Center) - x += (int)((_printWidth - totalWidth) / 2); - else if (justification == TextJustification.Right) - x += (int)(_printWidth - totalWidth); - - - // Find the tallest run in this line for baseline alignment - int maxCharHeight = 0; - float maxAscent = 0; - foreach (var (_, mode) in _strings) - { - int baseCharHeight = _font.CharacterHeight / 2; - int charHeight = baseCharHeight * mode.CharHeightScale; - using var font = new Font(_font.RenderFont, baseCharHeight, FontStyle.Regular); - var ascent = font.FontFamily.GetCellAscent(font.Style) * font.Size / font.FontFamily.GetEmHeight(font.Style) * mode.CharHeightScale; - if (charHeight > maxCharHeight) - maxCharHeight = charHeight; - if (ascent > maxAscent) - maxAscent = ascent; - } - - foreach (var (text, mode) in _strings) - { - int baseCharWidth = _font.CharacterWidth / 2; - int baseCharHeight = _font.CharacterHeight / 2; - int charHeight = baseCharHeight * mode.CharHeightScale; - - var fontStyle = FontStyle.Regular; - if (mode.Emphasize) fontStyle |= FontStyle.Bold; - if (mode.Italic) fontStyle |= FontStyle.Italic; - - using var font = new Font(_font.RenderFont, baseCharHeight, fontStyle); - - // Font metrics for baseline alignment - float ascent = font.FontFamily.GetCellAscent(font.Style) * font.Size / font.FontFamily.GetEmHeight(font.Style) * mode.CharHeightScale; - float baselineOffset = (float)(maxAscent - ascent + (maxCharHeight - charHeight)); - - // Measure the string width in base font, then scale - SizeF baseSize = g.MeasureString(text, font, int.MaxValue, StringFormat.GenericTypographic); - float scaledWidth = baseSize.Width * mode.CharWidthScale; - - var state = g.Save(); - - // Align baseline of run to line baseline - g.TranslateTransform(x, offsetY + baselineOffset); - g.ScaleTransform(mode.CharWidthScale, mode.CharHeightScale); - - g.DrawString(text, font, Brushes.Black, 0, 0, StringFormat.GenericTypographic); - - // Underline (draw in scaled context) - if (mode.Underline is UnderlineMode.OnOneDot or UnderlineMode.OnTwoDots) - { - var dotHeight = (mode.Underline is UnderlineMode.OnTwoDots ? 2 : 1); - g.DrawLine(new Pen(Color.Black, dotHeight), 0, baseCharHeight*2, baseSize.Width, baseCharHeight*2); - } - - g.Restore(state); - - x += (int)Math.Ceiling(scaledWidth); - } - } - - - /* public void Render(Bitmap bitmap, Graphics g, int offsetX, int offsetY) - { - int x = offsetX; - foreach (var (c, mode) in _strings) - { - int charWidth = (_font.CharacterWidth * mode.CharWidthScale)* c.Length; - int charHeight = _font.CharacterHeight * mode.CharHeightScale; - var fontStyle = FontStyle.Regular; - if (mode.Emphasize) fontStyle |= FontStyle.Bold; - if (mode.Italic) fontStyle |= FontStyle.Italic; - using var font = new Font(_font.RenderFont, charWidth / 1.5f, fontStyle); - - var rect = new Rectangle(x, offsetY, charWidth, charHeight); - Console.WriteLine($"Drawing char '{c}' at ({x}, {offsetY}) with size ({charWidth}, {charHeight})"); - //var state = g.Save(); - // Justification logic here if needed - // g.ScaleTransform(mode.CharWidthScale, mode.CharHeightScale); - g.DrawString(c, font, Brushes.Black, rect); - //g.Restore(state); - - if (mode.Underline is UnderlineMode.OnOneDot or UnderlineMode.OnTwoDots) - { - var dotHeight = (mode.Underline is UnderlineMode.OnTwoDots ? 2 : 1); - g.DrawLine(new Pen(Color.Black, dotHeight), rect.Left, rect.Bottom, rect.Right, rect.Bottom); - } - - x += charWidth; - } - - } */ - -} - - -/* var state = g.Save(); - - // Move to the correct position, aligning bottom of char to baseline - g.TranslateTransform(x, offsetY + (maxCharHeight - charHeight)); - - // Scale for double width/height - g.ScaleTransform(mode.CharWidthScale, mode.CharHeightScale); - - g.DrawString(c.ToString(), font, Brushes.Black, 0, 0); - - // Underline (draw in scaled context) - if (mode.Underline is UnderlineMode.OnOneDot or UnderlineMode.OnTwoDots) - { - var dotHeight = (mode.Underline is UnderlineMode.OnTwoDots ? 2 : 1); -g.DrawLine(new Pen(Color.Black, dotHeight), 0, baseCharHeight, baseCharWidth, baseCharHeight); - } - - g.Restore(state); */ diff --git a/Emulator/ReceiptPrinter.cs b/Emulator/ReceiptPrinter.cs deleted file mode 100644 index b05d661..0000000 --- a/Emulator/ReceiptPrinter.cs +++ /dev/null @@ -1,230 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.IO; -using System.Text; -using ReceiptPrinterEmulator.Emulator.Enums; -using ReceiptPrinterEmulator.EscPos; -using ReceiptPrinterEmulator.Logging; - -namespace ReceiptPrinterEmulator.Emulator; - -public class ReceiptPrinter -{ - private readonly PaperConfiguration _paperConfiguration; - private readonly EscPosInterpreter _escPosInterpreter; - - private PrintMode _printMode; - private int _lineSpacing; - private int _tabSpacing; - - public Receipt CurrentReceipt { get; private set; } - public List ReceiptStack { get; private set; } - - public event EventHandler OnActivityEvent; - - public ReceiptPrinter(PaperConfiguration paperConfiguration) - { - _paperConfiguration = paperConfiguration; - _escPosInterpreter = new(this); - - _printMode = new PrintMode(); - - ReceiptStack = new(); - - StartNewReceipt(); - - PowerCycle(); - } - - #region ESC/POS - - public void FeedEscPos(string ascii) - { - if (ascii.Length>10000) - { - File.WriteAllText("last_ticket.bin", ascii, Encoding.ASCII); - } - File.WriteAllText("last_escpos_receive.txt", ascii, Encoding.ASCII); - - try - { - Logger.Info($"Received: {ascii}"); - _escPosInterpreter.Interpret(ascii); - - } - catch (Exception ex) - { - Logger.Exception(ex, "ESC/POS Interpreter Error"); - } - - OnActivityEvent?.Invoke(this, EventArgs.Empty); - } - - #endregion - - #region Receipt meta - - public void StartNewReceipt() - { - CurrentReceipt = new(_paperConfiguration, _printMode, _lineSpacing); - ReceiptStack.Add(CurrentReceipt); - - Logger.Info($"Starting new receipt (#{ReceiptStack.Count})"); - } - - #endregion - - #region Emulated - - public void PowerCycle() - { - Initialize(); - } - - #endregion - - #region Direct API - - public void Initialize() - { - _escPosInterpreter.ClearBuffers(); - - SelectFont(PrinterFont.FontA); - SelectJustification(TextJustification.Left); - SelectCharacterSize(1, 1); - SelectEmphasizeMode(false); - SelectItalicMode(false); - SelectUnderlineMode(UnderlineMode.Off); - SetDefaultLineSpacing(); - SetDefaultTabSpacing(); - } - - public void PrintText(string text) - { - Logger.Info($"Print: {text}"); - - CurrentReceipt.PrintText(text,_printMode); - } - - public void Cut(CutFunction cutFunction = CutFunction.Cut, CutShape cutShape = CutShape.Full, int n = 0) - { - Logger.Info($"Execute cut: {cutFunction}, {cutShape}, {n}"); - - LineFeed(); - - // TODO Support alternate cut modes - - StartNewReceipt(); - } - - /// - /// Feeds one line, based on the current line spacing. - /// - /// - /// - The amount of paper fed per line is based on the value set using the line spacing command (ESC 2 or ESC 3). - /// - public void LineFeed() - { - Logger.Info($"Line feed"); - CurrentReceipt.AdvanceToNewLine(); - } - - public void SelectFont(PrinterFont printerFont) - { - Logger.Info($"Select font: {printerFont}"); - - _printMode.Font = printerFont; - CurrentReceipt.ChangeFontConfiguration(_printMode); - } - - public void SelectJustification(TextJustification justification) - { - Logger.Info($"Select justification: {justification}"); - - _printMode.Justification = justification; - CurrentReceipt.ChangeFontConfiguration(_printMode); - } - - public void SelectCharacterSize(int width, int height) - { - Logger.Info($"Set character size scale: x{width} width, x{height} height"); - - _printMode.CharWidthScale = width; - _printMode.CharHeightScale = height; - CurrentReceipt.ChangeFontConfiguration(_printMode); - } - - public void SelectEmphasizeMode(bool enable) - { - Logger.Info($"Set emphasize mode: {enable}"); - - _printMode.Emphasize = enable; - CurrentReceipt.ChangeFontConfiguration(_printMode); - } - - public void SelectItalicMode(bool enable) - { - Logger.Info($"Set italic mode: {enable}"); - - _printMode.Italic = enable; - CurrentReceipt.ChangeFontConfiguration(_printMode); - } - - public void SelectUnderlineMode(UnderlineMode mode) - { - Logger.Info($"Set underline mode: {mode}"); - - _printMode.Underline = mode; - CurrentReceipt.ChangeFontConfiguration(_printMode); - } - - public void SetLineSpacing(int value) - { - Logger.Info($"Set line spacing: {value}"); - - _lineSpacing = value; - CurrentReceipt.SetLineSpacing(_lineSpacing); - } - - public void SetTabSpacing(int value) - { - Logger.Info($"Set tab spacing: {value}"); - - _tabSpacing = value; - CurrentReceipt.SetTabSpacing(_tabSpacing); - } - - public void SetDefaultLineSpacing() => SetLineSpacing(_paperConfiguration.DefaultLineSpacing); - public void SetDefaultTabSpacing() => SetTabSpacing(_paperConfiguration.DefaultTabSpacing); - - public void PrintBitmap(Bitmap bitmap) - { - Logger.Info($"Print bitmap: {bitmap.Width}x{bitmap.Height}"); - - CurrentReceipt.PrintBitmap(bitmap); - } - - #endregion - - #region Command API - - /// - /// Prints the data in the print buffer and feeds one line, based on the current line spacing. - /// - public void PrintAndLineFeed(string printBuffer) - { - PrintText(printBuffer); - LineFeed(); - } - - public void PrintTab() - { - string tabs = ""; - - for (var i = 0; i < _tabSpacing; i++) tabs += " "; - PrintText(tabs); - } - - #endregion -} \ No newline at end of file diff --git a/EscPos/Commands/ESC/SelectFontCommand.cs b/EscPos/Commands/ESC/SelectFontCommand.cs deleted file mode 100644 index 60a071e..0000000 --- a/EscPos/Commands/ESC/SelectFontCommand.cs +++ /dev/null @@ -1,55 +0,0 @@ -using ReceiptPrinterEmulator.Emulator; -using ReceiptPrinterEmulator.Emulator.Enums; - -namespace ReceiptPrinterEmulator.EscPos.Commands.ESC; - -/// -/// Select character font -/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=27 -/// -public class SelectFontCommand : BaseCommand -{ - public override string Prefix => EscPosInterpreter.ESC + "M"; - public override bool HasArgs => true; - - private int _n; - - public override void Reset() - { - _n = 0; - } - - public override bool InterpretNextChar(char c) - { - _n = c; - return false; - } - - public override void Execute(ReceiptPrinter printer, string? args) - { - switch (_n) - { - case 0 or 48: - printer.SelectFont(PrinterFont.FontA); - break; - case 1 or 49: - printer.SelectFont(PrinterFont.FontB); - break; - case 2 or 50: - printer.SelectFont(PrinterFont.FontC); - break; - case 3 or 51: - printer.SelectFont(PrinterFont.FontD); - break; - case 4 or 52: - printer.SelectFont(PrinterFont.FontE); - break; - case 97: - printer.SelectFont(PrinterFont.SpecialFontA); - break; - case 98: - printer.SelectFont(PrinterFont.SpecialFontB); - break; - } - } -} \ No newline at end of file diff --git a/EscPos/Commands/GS/SelectCharacterSizeCommand.cs b/EscPos/Commands/GS/SelectCharacterSizeCommand.cs deleted file mode 100644 index 8e17e7f..0000000 --- a/EscPos/Commands/GS/SelectCharacterSizeCommand.cs +++ /dev/null @@ -1,60 +0,0 @@ -using ReceiptPrinterEmulator.Emulator; - -namespace ReceiptPrinterEmulator.EscPos.Commands.GS; - -/// -/// Select character size -/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=34 -/// -public class SelectCharacterSizeCommand : BaseCommand -{ - public override string Prefix => EscPosInterpreter.GS + "!"; - public override bool HasArgs => true; - - private byte _n; - - public override void Reset() - { - _n = 0; - } - - public override bool InterpretNextChar(char c) - { - _n = (byte)c; - return false; - } - - public override void Execute(ReceiptPrinter printer, string? args) - { - var widthMode = _n & 0b01110000; // bits 6,5,4 - var heightMode = _n & 0b00000111; // bits 2,1,0 - - var charWidth = widthMode switch - { - 0 => 1, - 16 => 2, - 32 => 3, - 48 => 4, - 64 => 5, - 80 => 6, - 96 => 7, - 112 => 8, - _ => 1 - }; - - var charHeight = heightMode switch - { - 0 => 1, - 1 => 2, - 2 => 3, - 3 => 4, - 4 => 5, - 5 => 6, - 6 => 7, - 7 => 8, - _ => 1 - }; - - printer.SelectCharacterSize(charWidth, charHeight); - } -} \ No newline at end of file diff --git a/EscPos/Commands/GS/SelectCutModeAndCutCommand.cs b/EscPos/Commands/GS/SelectCutModeAndCutCommand.cs deleted file mode 100644 index 7dec680..0000000 --- a/EscPos/Commands/GS/SelectCutModeAndCutCommand.cs +++ /dev/null @@ -1,90 +0,0 @@ -using ReceiptPrinterEmulator.Emulator; -using ReceiptPrinterEmulator.Emulator.Enums; - -namespace ReceiptPrinterEmulator.EscPos.Commands.GS; - -/// -/// Select cut mode and cut paper -/// https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=87 -/// -public class SelectCutModeAndCutCommand : BaseCommand -{ - public override string Prefix => EscPosInterpreter.GS + "V"; - public override bool HasArgs => true; - - private int _idx; - private int _m; - private int _n; - - public override void Reset() - { - _idx = 0; - _m = 0; - _n = 0; - } - - public override bool InterpretNextChar(char c) - { - if (_idx == 0) - { - _idx++; - _m = c; - _n = 0; - - // If "m" is greater than 49, it means cut function is B, C, or D with a second arg - return (_m > 49); - } - - if (_idx == 1) - { - _idx++; - _n = c; - } - - return false; - } - - public override void Execute(ReceiptPrinter printer, string? args) - { - var function = CutFunction.Cut; - var shape = CutShape.Full; - - switch (_n) - { - case 0 or 48: - function = CutFunction.Cut; - shape = CutShape.Full; - break; - case 1 or 49: - function = CutFunction.Cut; - shape = CutShape.Partial; - break; - case 65: - function = CutFunction.FeedAndCut; - shape = CutShape.Full; - break; - case 66: - function = CutFunction.FeedAndCut; - shape = CutShape.Partial; - break; - case 97: - function = CutFunction.SetCutPos; - shape = CutShape.Full; - break; - case 98: - function = CutFunction.SetCutPos; - shape = CutShape.Partial; - break; - case 103: - function = CutFunction.FeedAndCutAndReverse; - shape = CutShape.Full; - break; - case 104: - function = CutFunction.FeedAndCutAndReverse; - shape = CutShape.Partial; - break; - } - - printer.Cut(function, shape, _n); - } -} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..275bee2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Daniel Meza / Gasoleo Technology + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Logging/Logger.cs b/Logging/Logger.cs deleted file mode 100644 index 93f5eb8..0000000 --- a/Logging/Logger.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using System.Text; - -namespace ReceiptPrinterEmulator.Logging; - -public static class Logger -{ - public static void Info(params object[] values) - { - PrintMessage("Info", values); - } - - public static void Exception(Exception ex, string? message = null) - { - PrintMessage("Exception", new object[] { message ?? string.Empty, ex }); - } - - private static void PrintMessage(string prefix, object[] values) - { - var combinedMessage = $"[{prefix}] {FormatValues(values)}"; - Console.WriteLine(combinedMessage); - } - - private static string FormatValues(params object[] values) - { - var sb = new StringBuilder(); - - foreach (var val in values) - { - var asString = val.ToString(); - - if (String.IsNullOrWhiteSpace(asString)) - continue; - - if (sb.Length > 0) - sb.Append(' '); - sb.Append(asString); - } - - return sb.ToString(); - } -} \ No newline at end of file diff --git a/MainWindow.xaml b/MainWindow.xaml deleted file mode 100644 index 1db292d..0000000 --- a/MainWindow.xaml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs deleted file mode 100644 index 24bdc19..0000000 --- a/MainWindow.xaml.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.IO; -using System.Text; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using ReceiptPrinterEmulator.Emulator; -using ReceiptPrinterEmulator.Logging; -using ReceiptPrinterEmulator.Utils; -using Image = System.Windows.Controls.Image; - -namespace ReceiptPrinterEmulator -{ - /// - /// Interaction logic for MainWindow.xaml - /// - public partial class MainWindow : Window - { - public MainWindow() - { - InitializeComponent(); - } - - private void MainWindow_OnLoaded(object sender, RoutedEventArgs e) - { - App.Printer!.OnActivityEvent += (o, args) => - { - RefreshUI(); - WindowsUtils.FlashWindow(this); - WindowsUtils.ExclaimSoft(); - }; - - RefreshUI(); - } - - private void ResetButton_OnClick(object sender, RoutedEventArgs e) - { - Logger.Info("Resetting"); - - App.Printer!.ReceiptStack.Clear(); - App.Printer.Initialize(); - App.Printer.StartNewReceipt(); - - var toRemove = new List(); - foreach (var childControl in ReceiptImageRoot.Children) - if (childControl is Image imgControl) - toRemove.Add(imgControl); - toRemove.ForEach(img => ReceiptImageRoot.Children.Remove(img)); - - RefreshUI(); - } - - private void ShowButton_OnClick(object sender, RoutedEventArgs e) - { - var path = "test_receipt.txt"; - if (!File.Exists(path)) - { - Console.WriteLine("File not found."); - return; - } - - byte[] data = File.ReadAllBytes(path); - var sb = new StringBuilder(); - for (int i = 0; i < data.Length; i++) - { - sb.AppendFormat("{0:X2} ", data[i]); - if ((i + 1) % 16 == 0) - sb.AppendLine(); - } - Console.WriteLine(sb.ToString()); - } - - private void TestButton_OnClick(object sender, RoutedEventArgs e) - { - if (!File.Exists("test_receipt.txt")) - return; - - App.Printer?.FeedEscPos(File.ReadAllText("test_receipt.txt", Encoding.ASCII)); - } - - private void RefreshUI() - { - // Status label - Address.Text = $"{App.Server!.EndPoint}"; - Address.Foreground = new SolidColorBrush(App.Server!.IsRunning ? Colors.SpringGreen : Colors.Crimson); - - // Receipt images - foreach (var receipt in App.Printer!.ReceiptStack) - CreateOrUpdateReceiptControl(ReceiptImageRoot, receipt); - - MainScrollView.ScrollToBottom(); - } - - private void CreateOrUpdateReceiptControl(Panel parentControl, Receipt receipt) - { - if (receipt.IsEmpty) - return; - - var guidName = "R" + receipt.Guid.Replace("-", ""); - - Image? ourControl = null; - - foreach (var childControl in parentControl.Children) - { - if (childControl is Image imgControl) - { - if (imgControl.Name == guidName) - { - ourControl = imgControl; - break; - } - } - } - - if (ourControl == null) - { - ourControl = new Image(); - ourControl.Name = guidName; - ourControl.Stretch = Stretch.None; - ourControl.Margin = new Thickness(0, 0, 0, 10); - - parentControl.Children.Add(ourControl); - } - - ourControl.Source = ConvertBitmap(receipt.Render()); - } - - /// - /// Takes a bitmap and converts it to an image that can be handled by WPF ImageBrush - /// - /// A bitmap image - /// The image as a BitmapImage for WPF - public BitmapImage ConvertBitmap(Bitmap src) - { - MemoryStream ms = new MemoryStream(); - ((System.Drawing.Bitmap)src).Save(ms, System.Drawing.Imaging.ImageFormat.Bmp); - BitmapImage image = new BitmapImage(); - image.BeginInit(); - ms.Seek(0, SeekOrigin.Begin); - image.StreamSource = ms; - image.EndInit(); - return image; - } - } -} \ No newline at end of file diff --git a/Networking/NetServer.cs b/Networking/NetServer.cs deleted file mode 100644 index 2b738a4..0000000 --- a/Networking/NetServer.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Net.Sockets; -using System.Threading; -using System.Threading.Tasks; -using ReceiptPrinterEmulator.Logging; - -namespace ReceiptPrinterEmulator.Networking; - -public class NetServer -{ - public IPEndPoint EndPoint { get; private set; } - - private Socket? _tcpSocket; - private CancellationTokenSource? _cts; - - private List _clients; - - public bool IsRunning => _tcpSocket is not null && _tcpSocket.IsBound; - - public NetServer(int port) - { - EndPoint = new IPEndPoint(IPAddress.Any, port); - - _clients = new(); - } - - public async Task Run() - { - Stop(); - - Logger.Info($"Starting NetServer on TCP port {EndPoint.Port}"); - - _tcpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); - _tcpSocket.Bind(EndPoint); - _tcpSocket.Listen(8); - - Logger.Info($"Server bound to {EndPoint}, starting accept/receive loop"); - - _cts = new CancellationTokenSource(); - await AcceptLoopAsync(_cts.Token); - } - - public void Stop() - { - if (_cts is not null) - { - _cts.Cancel(); - _cts = null; - } - } - - private async Task AcceptLoopAsync(CancellationToken cancellationToken) - { - while (IsRunning && !cancellationToken.IsCancellationRequested) - { - var clientSocket = await _tcpSocket!.AcceptAsync(cancellationToken); - - if (!clientSocket.Connected) - continue; - - var client = new NetClient(this, clientSocket); - _clients.Add(client); - - Logger.Info($"Accepted new connection (RemoteEndPoint={client.RemoteEndPoint})"); - - _ = client.ReceiveLoopAsync(); - } - } -} \ No newline at end of file diff --git a/README.md b/README.md index 9c86817..d3e813d 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,66 @@ # ESC/POS Receipt Printer Emulator 🖨️ **This app emulates a networked receipt printer to test your ESC/POS commands against.** - +![Emulator](docs/Example.png) ### About -- Windows application (WPF + .NET 9) -- Binds to a TCP/IP interface and listens for ESC/POS commands +- Cross-platform application (Avalonia 12 + SkiaSharp + .NET 10), runs on Windows, macOS and Linux +- Listens for ESC/POS commands over **TCP/IP** and (optionally) a **serial port** - Logs commands and visually represents the resulting receipt(s) +- Renders 1D barcodes and 2D codes (QR, PDF417, DataMatrix, Aztec), bit images, and page mode +- Answers status queries (`DLE EOT`, `GS r`, Automatic Status Back) — simulate paper-out, cover, drawer, offline and error states from the **Printer state** panel +- Signals buzzer / cash-drawer events with a sound and on-screen toast +- Configure the TCP listen address/port and serial port live from the UI +- Export rendered tickets to PNG — all in one image, or one file per cut - It support different text formattings in the same line, although a few combinations were tested. +> **Cross-platform fork.** This project began as a cross-platform port of +> [roydejong/EscPosEmulator](https://github.com/roydejong/EscPosEmulator) (originally a Windows/WPF +> app). It has been migrated to Avalonia + SkiaSharp + .NET 10 so it runs on Windows, macOS and +> Linux, and extended with barcode/QR rendering and a serial transport. All credit for the original +> emulator goes to the upstream author. + 👷 **This is an unfinished experiment.** Use at your own risk and keep your expectations low. :) +### Download + +Pre-built, **self-contained** apps (no .NET install required) are published on the +[Releases](../../releases) page: + +| Platform | Artifact | +|----------|----------| +| Windows (x64) | `CrossEscPos-win-x64.zip` | +| Linux (x64) | `CrossEscPos-linux-x64.tar.gz` | +| macOS (Intel) | `CrossEscPos-osx-x64.zip` (`.app` bundle) | +| macOS (Apple Silicon) | `CrossEscPos-osx-arm64.zip` (`.app` bundle) | + +Releases are produced by the [`Release`](.github/workflows/release.yml) GitHub Actions workflow on +each `v*` tag. + +> **macOS first launch.** The `.app` is **ad-hoc signed but not notarized** (no paid Apple Developer +> ID). macOS quarantines anything downloaded from the internet, so on first launch you may see +> *"CrossEscPos is damaged and can't be opened"* (especially on Apple Silicon). Clear the +> quarantine flag once, then open it: +> +> ```sh +> xattr -dr com.apple.quarantine /path/to/CrossEscPos.app +> open /path/to/CrossEscPos.app +> ``` +> +> (Right-click → **Open** also works once the quarantine is cleared.) + +### Built with + +- [.NET 10](https://dotnet.microsoft.com/) · [Avalonia 12](https://avaloniaui.net/) · + [SkiaSharp](https://github.com/mono/SkiaSharp) (rendering) · + [CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet) (MVVM) +- [ZXing.Net](https://github.com/micjahn/ZXing.Net) (1D barcodes) · + [QRCoder](https://github.com/codebude/QRCoder) (QR codes) · + [Ardalis.SmartEnum](https://github.com/ardalis/SmartEnum) (class-based enums) · + [System.IO.Ports](https://www.nuget.org/packages/System.IO.Ports) (serial) +- [ESC-POS-.NET](https://github.com/lukevp/ESC-POS-.NET) (the Monitor test client) · + [LibUsbDotNet](https://github.com/LibUsbDotNet/LibUsbDotNet) (direct USB printing) + ### Supported commands ⚠️ Support is currently limited to only a subset of ESC/POS. Even the commands listed here may only be partially implemented. @@ -33,6 +83,20 @@ - Partial cut (`ESC i`) - Print and feed n lines (`ESC d`) - Print and feed paper (`ESC J`) + - Generate pulse / kick cash drawer (`ESC p m t1 t2`) + - Select character code table (`ESC t`) — PC437/850/852/858/860/863/865/866/1252 remapped to Unicode + - Beeper (`ESC ( A`) + - Bit image (`ESC *`) — 8-dot and 24-dot inline raster + - Page mode: select page / standard mode (`ESC L` / `ESC S`), print area (`ESC W`), direction (`ESC T`), absolute position (`ESC $`) + - User-defined characters (`ESC &` / `ESC %` / `ESC ?`) — parsed & stored +- Control characters: + - Buzzer / beeper (`BEL`, 0x07) + - Form feed (`FF`) — prints the page in page mode + - Cancel (`CAN`) — cancels page data +- DLE (real-time) Commands: + - Real-time status (`DLE EOT n`, n=1-4) + - Real-time request / recover (`DLE ENQ`) + - Real-time cash-drawer pulse (`DLE DC4 1 m t`) - FS Commands: - Print stored logo (`FS p n m`) - Auto cut (`FS } 0x60 n`) @@ -41,10 +105,261 @@ - Select cut mode and cut paper - Paper eject (`GS e n [m t]`) - Print raster image (`GS v 0 [m xL xH yL yH ...pixels]`) + - Print 1D barcode (`GS k`) — UPC-A/E, EAN-13/8, CODE39, CODE93, CODE128, ITF, CODABAR (both function A & B forms) + - Set barcode height / module width (`GS h` / `GS w`) + - Select HRI text position / font (`GS H` / `GS f`) + - Print 2D symbols (`GS ( k`) — QR Code (cn=49), PDF417 (cn=48), DataMatrix (cn=54), Aztec (cn=55) + - **Status / transmit-back**: paper & drawer status (`GS r`), printer ID (`GS I`), Automatic Status Back (`GS a`) + - Download bit image: define (`GS *`) and print (`GS /`) + - Set motion units (`GS P`), absolute/relative vertical position (`GS $` / `GS \`) + - Config (accepted/ignored): user setup (`GS ( E`), print control (`GS ( K`), response request (`GS ( H`) -### Example +The emulator is **bidirectional**: status commands (`DLE EOT`, `GS r`, `GS I`) and Automatic Status +Back reply to the host over the same TCP/serial connection, driven by the **Printer state** panel +(right side) where you can simulate paper-out/near-end, cover open, cash-drawer open/closed, +offline, and error conditions. Like a real device, the emulator **refuses to print** while it isn't +ready (out of paper, cover open, offline, or in an error state) and shows a notification instead. -![Emulator](docs/Example.png) +Barcodes and QR codes render inline on the receipt, with optional HRI text: + +![Barcode and QR example](docs/Example%20QR.png) + +### Not yet implemented + +A few things remain partial or unimplemented: + +- **Page-mode coordinate system** — page mode buffers output and rasterizes it on `FF`, but absolute/relative positioning (`ESC $`, `GS $`, `GS \`) and print direction (`ESC T`) are accepted as no-ops rather than fully positioned. +- **User-defined glyph substitution** — `ESC &` glyphs are parsed and stored, but the inline text renderer still draws the font glyph rather than the custom bitmap. +- **MaxiCode / GS1 DataBar / Composite** 2D symbologies (`GS ( k` cn=50/51/52). +- **Graphics commands** `GS ( L` / `GS 8 L` (NV/raster graphics store-and-print) and other `ESC *`-family densities. +- **Real-time `DLE DC4`** functions other than the cash-drawer pulse (power-off, recover-and-cancel, buzzer). +- **Katakana / CJK code pages** render as missing glyphs since the bundled Latin font has no such glyphs. + +Contributions welcome — new commands follow the simple `BaseCommand` pattern in +[`EscPos/Commands`](EscPos/Commands) and are registered in +[`EscPosInterpreter.RegisterCommands`](EscPos/EscPosInterpreter.cs). + +### Connecting + +The emulator accepts ESC/POS data over two transports. Both can be changed **live from the UI** +(left panel): pick a TCP listen address and port and Start/Stop the listener, or select a serial +port + baud and Open/Close it (⟳ refreshes the port list). The environment variables below set the +**initial** values at startup: + +| Variable | Default | Meaning | +|----------|---------|---------| +| `ESCPOS_LISTEN_ADDRESS` | `0.0.0.0` | Initial TCP bind address (`0.0.0.0` = all interfaces, `127.0.0.1` = localhost). | +| `ESCPOS_TCP_PORT` | `9100` | Initial TCP listen port. Set to `off` / `0` to start with TCP stopped. | +| `ESCPOS_SERIAL_PORT` | *(unset)* | Serial device to auto-open (e.g. `/dev/ttys004`, `COM3`). Unset = serial closed. | +| `ESCPOS_SERIAL_BAUD` | `9600` | Serial baud rate. | +| `ESCPOS_DEBUG_DUMP` | *(off)* | Set to `1` to dump every received payload to `last_*` files. | +| `ESCPOS_RENDER_BACKEND` | `skia` | Render backend: `skia` (default) or `imagesharp` (managed). The `--backend` arg overrides it. | + +Examples: + +```sh +# (run is shorthand for: dotnet run --project src/CrossEscPos.App.Desktop) +dotnet run --project src/CrossEscPos.App.Desktop # TCP only, port 9100 +ESCPOS_TCP_PORT=9200 dotnet run --project src/CrossEscPos.App.Desktop # TCP on 9200 +ESCPOS_SERIAL_PORT=/dev/ttys004 dotnet run --project src/CrossEscPos.App.Desktop # TCP 9100 + serial +ESCPOS_TCP_PORT=off ESCPOS_SERIAL_PORT=COM3 dotnet run --project src/CrossEscPos.App.Desktop # serial only +``` + +**Pick the render backend** (for A/B testing the two rendering libraries) with `--backend skia` +(default) or `--backend imagesharp` (the managed, no-native backend). The active backend is shown in +the window title bar. + +```sh +dotnet run --project src/CrossEscPos.App.Desktop -- --backend imagesharp # managed ImageSharp backend +dotnet run --project src/CrossEscPos.App.Desktop -- --backend skia # default SkiaSharp backend +``` + +The status panel shows the active TCP endpoint and serial port. + +#### Testing serial without hardware (app-to-app on one machine) + +You don't need a USB serial adapter. Create a **virtual serial bridge** — a pair of linked ports — +then point the emulator at one end and your POS application (or a shell) at the other. Bytes written +to one end appear on the other. + +**macOS / Linux** — using [`socat`](http://www.dest-unreach.org/socat/) (`brew install socat` / +`apt install socat`). A helper script is included: + +```sh +./scripts/serial-bridge.sh +# It prints a linked pair, e.g.: +# PORT A (emulator): /dev/ttys004 +# PORT B (your app): /dev/ttys005 +# Leave it running. +``` + +Then, in two more terminals: + +```sh +# Terminal 2 — run the emulator on port A +ESCPOS_SERIAL_PORT=/dev/ttys004 dotnet run --project src/CrossEscPos.App.Desktop + +# Terminal 3 — send a receipt from "another app" on port B +cat test_receipt.txt > /dev/ttys005 +# …or from your own program, just open /dev/ttys005 like a normal serial port +# (9600 8N1) and write ESC/POS bytes to it. +``` + +The receipt appears in the emulator window. Because the interpreter is stateful across reads, +fragmented serial writes (commands split across packets) are handled correctly. + +**Windows** — install [com0com](https://com0com.sourceforge.net/) and create a linked pair +(e.g. `COM3` ↔ `COM4`). Run the emulator with `ESCPOS_SERIAL_PORT=COM3` and have your application +write to `COM4`. + +### Monitor (built-in test client) + +Sending test jobs is the **monitor's** job — the emulator is the device, the monitor is the POS-side +client that drives it over the wire (just like a real application would). The Monitor is **shared** by +both heads: click **Open monitor…** to launch it — a window on desktop, an in-page overlay in the +browser. Its test jobs and status display are identical; only the transport differs: + +- **Desktop** (built on [ESC-POS-.NET](https://github.com/lukevp/ESC-POS-.NET)) — pick a transport: + - **TCP/IP** — connect to the emulator's listener (or any networked printer). + - **Serial** — pick a port + baud; pairs with the emulator's serial transport via a virtual port bridge. + - **USB** — print **directly to a real USB printer** selected from the connected-device list (by + VID:PID), via libusb. This is send-only (no status), and needs native **libusb** installed + (macOS `brew install libusb`, Debian/Ubuntu `apt install libusb-1.0-0`; bundled on Windows) and the + OS not already holding the device. +- **Browser** — sends over the **SignalR proxy** (the `CrossEscPos.Host` hub) to the in-page emulator, + for a full round-trip without any native transport. + +It then lets you exercise the target without writing any code: + +- Print a sample receipt, all 1D barcodes, or QR / PDF417 / DataMatrix / Aztec. +- Send the full feature test receipt, open the cash drawer, buzz, or cut. +- Watch the **printer status** the emulator reports back: toggle paper-out / cover / drawer / offline + in the **Printer state** panel and the monitor's status display updates live (via Automatic Status + Back), confirming the emulator's status responses are wire-correct. When the printer isn't ready, + the emulator drops the job and shows a notification, just like real hardware. + +![Monitor](docs/Monitor.png) + +Toggling the emulator's **Printer state** panel pushes status to the monitor in real time — here the +printer reports *paper low* and a *recoverable error*, so the monitor shows **Not ready**: + +![Monitor reflecting printer state](docs/Monitor%20Invalid%20State.png) + +### Exporting tickets + +Each cut (`ESC i` / `ESC m` / `GS V`) starts a new receipt — a "page". The **Export** buttons in the +left panel save the rendered tickets as PNG: + +- **Export all (single image)** — stacks every receipt into one tall PNG (a save dialog). +- **Export each cut (folder)** — writes one `receipt_NNN.png` per cut into a chosen folder. + +### Architecture & packages + +The emulator is split into layered, independently-publishable packages so the **rendering backend is +swappable** and the **core runs headless** (and in the browser). The namespace is unified under +`CrossEscPos.*` (organized by feature/directory, not by package), so types resolve across assemblies. + +| Package | Namespace(s) | Role | Depends on | +| --- | --- | --- | --- | +| `CrossEscPos.Abstractions` | `CrossEscPos`, `CrossEscPos.Graphics` | Backend-agnostic rendering + printer contracts (`IReceiptCanvas`, `IReceiptImage`, `IReceiptImageFactory`, `ITypefaceProvider`, `IImageEncoder`, `IReceiptPrintable`, `IPrinterResponder`) | — | +| `CrossEscPos.Core` | `CrossEscPos.Emulator`, `CrossEscPos.EscPos`, … | Headless ESC/POS interpreter, printer state machine, receipt document model, barcode/QR generation. ESC/POS code maps (2D families, code tables, barcode systems, status requests) are behaviour-carrying `SmartEnum`s, not magic-number switches | Abstractions, QRCoder, ZXing.Net, Ardalis.SmartEnum | +| `CrossEscPos.Rendering.Skia` | `CrossEscPos.Rendering.Skia` | The default **render backend** (SkiaSharp). Swap it for another `IReceiptImageFactory`/`ITypefaceProvider`/`IImageEncoder` | Abstractions, SkiaSharp | +| `CrossEscPos.Rendering.ImageSharp` | `CrossEscPos.Rendering.ImageSharp` | A **100% managed render backend** (ImageSharp) — no native dependency, so it runs in **Blazor WASM** without a native relink. Same output as the Skia backend | Abstractions, SixLabors.ImageSharp.Drawing | +| `CrossEscPos.Transports` | `CrossEscPos.Transports` | TCP / serial / USB transports (desktop only) | Core, System.IO.Ports, LibUsbDotNet, ESC-POS-.NET | +| `CrossEscPos.Controls` | `CrossEscPos.Controls` | Reusable Avalonia controls (`ReceiptView`, `PrinterStatePanel`) — host apps consume these. **Backend-agnostic** (no SkiaSharp dependency) | Core, Avalonia | + +`Core` carries **no UI and no graphics-backend dependency**, so the library works headless or in WASM. +The host (desktop, browser, or your own app) is the composition root: it picks a backend and injects it. + +```csharp +var imageFactory = new SkiaImageFactory(); +var typefaces = new SkiaTypefaceProvider(); +var printer = new ReceiptPrinter(PaperConfiguration.Default, imageFactory, typefaces); +printer.FeedEscPos(escPosBytes); // byte[] — ESC/POS is binary +using var image = printer.CurrentReceipt.Render(); // IReceiptImage +new SkiaImageEncoder().EncodePng(image, outputStream); +``` + +📦 **Package usage guides:** [`docs/packages/`](docs/packages/README.md) — getting started, the core +emulator, rendering & custom backends (Skia + managed ImageSharp), the Avalonia controls, and the +transports. The desktop and browser apps are one shared Avalonia app ([`src/CrossEscPos.App`](src/CrossEscPos.App)). + +📖 **Full reference & guides:** the [**project wiki**](https://github.com/danielmeza/CrossEscPosEmulator/wiki), +including a step-by-step [**Adding a render backend**](https://github.com/danielmeza/CrossEscPosEmulator/wiki/Adding-a-Render-Backend) guide. + +### Building & running + +Requires the .NET 10 SDK (and the `wasm-tools` workload for the browser head: +`dotnet workload install wasm-tools`). + +```sh +# Desktop app (Windows / macOS / Linux) +dotnet run --project src/CrossEscPos.App.Desktop + +# Headless: ESC/POS bytes -> PNG, no UI, no Avalonia +dotnet run --project samples/CrossEscPos.Headless -- test_receipt.txt out.png + +# Browser: the SAME app in the browser (Avalonia WASM head) — full parity with desktop +dotnet run --project src/CrossEscPos.App.Browser + +# Web host: serves the browser app AND the SignalR broker on one origin, giving it TCP reception +# (browsers can't open raw sockets). First run publishes the WASM app into wwwroot (cached after). +# Browse to the printed URL; the emulator opens the TCP port itself. +dotnet run --project samples/CrossEscPos.Host + +# Tests (unit + headless UI) +dotnet test CrossEscPos.slnx +``` + +**One app, two heads.** The desktop and browser apps are the **same** Avalonia application +([`src/CrossEscPos.App`](src/CrossEscPos.App)) — the same views, view models, receipts, printer-state +panel, PNG export, and the Monitor test-client. Only the platform edges differ, injected via +`IPlatformServices`: + +| | Desktop head | Browser head | +|---|---|---| +| Transports | TCP + serial | **Web Serial + WebUSB + SignalR TCP proxy** | +| Export | native save dialog | browser **download** (same Avalonia storage API) | +| Monitor | test-client **window** | in-page **overlay** (SignalR round-trip) | + +A browser can't open a raw **TCP** listen socket, so the browser head connects over **SignalR** to +[`samples/CrossEscPos.Host`](samples/CrossEscPos.Host) — one ASP.NET Core host that serves the WASM app +*and* runs the broker. The emulator asks the host to open a TCP listener on the address:port you choose; +POS software connects there and its jobs bridge to the in-page emulator, with status flowing back. The +same hub carries the browser Monitor's jobs, so it round-trips against the in-page emulator too. + +- **Windows / macOS:** no extra setup — native rendering libraries ship with the Avalonia packages. +- **Linux:** install the usual font/render native deps if they are missing, e.g. + `sudo apt install libfontconfig1 libfreetype6` (Debian/Ubuntu). + +### Testing + +Two test projects under [`tests/`](tests): + +- **`CrossEscPos.Core.Tests`** — emulation coverage. ESC/POS sequences are fed through the interpreter + and the observable results (rendered draws, printer state, host responses, events) asserted: text + and control characters, styling (emphasis/italic/size/justification), cutting and feeding, 1D/2D + barcodes and bit images, status/transmit-back (`DLE EOT`, `GS r`, `GS I`, `GS a`), cash drawer and + buzzer, real-time commands, code pages, page mode and not-ready handling — plus exact `StatusByteBuilder` + bit layouts and the `SmartEnum` code maps. Rendering goes to a synthetic backend, so the suite runs + with no SkiaSharp (proving the core is headless). +- **`CrossEscPos.Controls.Tests`** — headless Avalonia UI tests for the controls (two-way state binding, + receipt image rendering). + +```sh +dotnet test CrossEscPos.slnx +``` + +### Fonts & license + +Receipt text is rendered with **[JetBrains Mono](https://www.jetbrains.com/lp/mono/)**, embedded in the +`CrossEscPos.Rendering.Skia` package (under +[`src/CrossEscPos.Rendering.Skia/Assets/Fonts/`](src/CrossEscPos.Rendering.Skia/Assets/Fonts)) so output +is identical across platforms and works in the browser sandbox (no file IO). JetBrains Mono is licensed +under the **SIL Open Font License 1.1**; the full license text is included at +[`src/CrossEscPos.Rendering.Skia/Assets/Fonts/OFL.txt`](src/CrossEscPos.Rendering.Skia/Assets/Fonts/OFL.txt). Per the OFL, the font is redistributed here under its +original license and "JetBrains Mono" is a trademark of JetBrains s.r.o. To swap in a different +monospace font, replace the `receipt-mono*.ttf` files (and keep its license alongside). ### Emulated printer diff --git a/ReceiptPrinterEmulator.csproj b/ReceiptPrinterEmulator.csproj deleted file mode 100644 index b786aef..0000000 --- a/ReceiptPrinterEmulator.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - Exe - net9.0-windows7.0 - enable - true - - - - - - - - - Always - - - - diff --git a/ReceiptPrinterEmulator.sln b/ReceiptPrinterEmulator.sln deleted file mode 100644 index a82252f..0000000 --- a/ReceiptPrinterEmulator.sln +++ /dev/null @@ -1,16 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReceiptPrinterEmulator", "ReceiptPrinterEmulator.csproj", "{6D581EC7-33B0-45DF-B829-C3EE718B30F1}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {6D581EC7-33B0-45DF-B829-C3EE718B30F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6D581EC7-33B0-45DF-B829-C3EE718B30F1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6D581EC7-33B0-45DF-B829-C3EE718B30F1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6D581EC7-33B0-45DF-B829-C3EE718B30F1}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/Utils/WindowsUtils.cs b/Utils/WindowsUtils.cs deleted file mode 100644 index e36ef26..0000000 --- a/Utils/WindowsUtils.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using System.Media; -using System.Runtime.InteropServices; -using System.Windows; -using System.Windows.Interop; - -namespace ReceiptPrinterEmulator.Utils; - -public static class WindowsUtils -{ - [DllImport("user32")] - public static extern int FlashWindow(IntPtr hwnd, bool bInvert); - public static void FlashWindow(Window wnd) => FlashWindow(new WindowInteropHelper(wnd).Handle, true); - - public static void Exclaim() => SystemSounds.Exclamation.Play(); - public static void ExclaimSoft() => SystemSounds.Hand.Play(); -} \ No newline at end of file diff --git a/docs/Example QR.png b/docs/Example QR.png new file mode 100644 index 0000000..6ed1c06 Binary files /dev/null and b/docs/Example QR.png differ diff --git a/docs/Example.png b/docs/Example.png index 74b7596..b0fe8bb 100644 Binary files a/docs/Example.png and b/docs/Example.png differ diff --git a/docs/Monitor Invalid State.png b/docs/Monitor Invalid State.png new file mode 100644 index 0000000..555fc4f Binary files /dev/null and b/docs/Monitor Invalid State.png differ diff --git a/docs/Monitor.png b/docs/Monitor.png new file mode 100644 index 0000000..0fa381c Binary files /dev/null and b/docs/Monitor.png differ diff --git a/docs/packages/README.md b/docs/packages/README.md new file mode 100644 index 0000000..c9576e3 --- /dev/null +++ b/docs/packages/README.md @@ -0,0 +1,85 @@ +# Using the CrossEscPos packages + +CrossEscPos is split into layered, independently-usable packages so you can take only what you need — +the headless emulator, a render backend, the transports, and/or the Avalonia controls. + +| Package | What it gives you | Depends on | +| --- | --- | --- | +| **CrossEscPos.Abstractions** | Backend-agnostic contracts: `IReceiptCanvas`, `IReceiptImage`, `IReceiptImageFactory`, `ITypefaceProvider`, `IImageEncoder`, `IReceiptPrintable`, `IPrinterResponder` | — | +| **CrossEscPos.Core** | The headless ESC/POS emulator: `ReceiptPrinter`, the interpreter, the receipt document model, barcode/QR | Abstractions | +| **CrossEscPos.Rendering.Skia** | The default render backend (SkiaSharp); fonts embedded | Abstractions | +| **CrossEscPos.Rendering.ImageSharp** | A 100% managed render backend (ImageSharp) — no native dependency, so it runs in Blazor WASM with no native relink. Byte-compatible output with Skia | Abstractions | +| **CrossEscPos.Transports** | TCP / serial / USB transports that feed a printer | Core | +| **CrossEscPos.Controls** | Reusable Avalonia controls: `ReceiptView`, `PrinterStatePanel` | Core, Avalonia | + +Everything lives under the single `CrossEscPos.*` namespace root (organized by feature, e.g. +`CrossEscPos.Emulator`, `CrossEscPos.Graphics`, `CrossEscPos.Controls`), so types resolve across the +packages regardless of which one they ship in. + +## How the packages depend on each other + +```mermaid +flowchart BT + Abstractions["CrossEscPos.Abstractions"] + Core["CrossEscPos.Core"] --> Abstractions + Skia["CrossEscPos.Rendering.Skia"] --> Abstractions + ImageSharp["CrossEscPos.Rendering.ImageSharp"] --> Abstractions + Transports["CrossEscPos.Transports"] --> Core + Controls["CrossEscPos.Controls"] --> Core + Controls --> Abstractions + + %% A host (your app) composes a backend + core + controls/transports + Host(["your host app"]) -.-> Core + Host -.->|"pick one backend"| Skia + Host -.->|"…or"| ImageSharp + Host -.-> Controls + Host -.-> Transports +``` + +## The mental model + +```mermaid +flowchart TD + bytes["ESC/POS bytes"] --> printer["ReceiptPrinter
(Core · headless)"] + printer --> stack["ReceiptStack
one Receipt per cut"] + stack -->|"Render()"| image["IReceiptImage"] + image --> encoder["IImageEncoder"] + encoder --> png["PNG bytes
(or an Avalonia Bitmap in a host)"] + + backend["render backend
Rendering.Skia, Rendering.ImageSharp, or your own"] + backend -. "IReceiptImageFactory + ITypefaceProvider" .-> printer +``` + +`Core` knows **nothing** about SkiaSharp or Avalonia. You — the host — pick a render backend and inject +it. That's the whole design: the emulation is portable (headless, server, WASM); rendering is swappable. + +## Guides + +- **[Getting started](getting-started.md)** — install and render your first ticket headless. +- **[Core](core.md)** — feeding ESC/POS, receipts, printer state, status responses, events. +- **[Rendering](rendering.md)** — the Skia and ImageSharp backends, exporting PNGs, and writing your own. +- **[Controls](controls.md)** — hosting `ReceiptView` and `PrinterStatePanel` in an Avalonia app. +- **[Transports](transports.md)** — driving the emulator over TCP, serial, or USB. +- **[Blazor web app](web.md)** — render ESC/POS in the browser with the managed backend (Blazor WASM). + +> 📚 For the full reference — including a step-by-step **[Adding a render backend](https://github.com/danielmeza/CrossEscPosEmulator/wiki/Adding-a-Render-Backend)** guide — see the [project wiki](https://github.com/danielmeza/CrossEscPosEmulator/wiki). + +## Installing + +The packages are on NuGet (published by the [`Release`](../../.github/workflows/release.yml) workflow on each `v*` tag): + +```sh +dotnet add package CrossEscPos.Core +dotnet add package CrossEscPos.Rendering.Skia # native SkiaSharp backend (desktop default) +# or the managed, WASM-safe backend: +dotnet add package CrossEscPos.Rendering.ImageSharp +# …and Controls / Transports as needed +``` + +Prefer local builds? Pack them or reference the projects directly: + +```sh +dotnet pack -c Release # emits the .nupkg files under each project's bin/Release +# or +dotnet add reference ../CrossEscPosEmulator/src/CrossEscPos.Core/CrossEscPos.Core.csproj +``` diff --git a/docs/packages/controls.md b/docs/packages/controls.md new file mode 100644 index 0000000..6076e89 --- /dev/null +++ b/docs/packages/controls.md @@ -0,0 +1,105 @@ +# CrossEscPos.Controls + +Reusable Avalonia controls so a host app can show a live receipt stream and drive the simulated printer +state. The controls are **backend-agnostic** — they depend on the abstraction, not on SkiaSharp. Your +app is the composition root that picks the render backend. + +```mermaid +flowchart TD + app["your Avalonia app
(composition root)"] + app -->|injects SkiaImageFactory / TypefaceProvider / Encoder| printer["ReceiptPrinter"] + app --> view["ReceiptView"] + app --> panel["PrinterStatePanel"] + printer --> receipts["Receipt(s)"] + receipts -->|wrapped as| vm["ReceiptViewModel
(exposes an Avalonia Bitmap)"] + vm --> view + printer --> state["PrinterState"] + state --> panel +``` + +## The controls + +### `ReceiptView` + +Renders the live stream of receipts (one image per cut) with a per-page PNG export button. Bind its +`Receipts` property to an `IEnumerable` of `ReceiptViewModel`. + +```xml + + + +``` + +It also exposes `ScrollToEnd()` so the host can keep the newest receipt in view. + +### `PrinterStatePanel` + +Two-way binds the simulated `PrinterState` (online, cover, paper, drawer, error, feed button). Toggling +it drives the status commands the emulator reports. + +```xml + +``` + +## Wiring it up + +`ReceiptViewModel` wraps a `Receipt` and exposes a ready-to-bind Avalonia `Bitmap`. It reaches the +render backend only through `IImageEncoder`, so the control never sees SkiaSharp. + +```csharp +using CrossEscPos.Controls; +using CrossEscPos.Controls.Services; +using CrossEscPos.Emulator; +using CrossEscPos.Rendering.Skia; + +public partial class MainViewModel : ObservableObject +{ + private readonly ReceiptPrinter _printer; + private readonly SkiaImageEncoder _encoder = new(); + private readonly IFileDialogService _dialogs; // your Avalonia implementation + + public ObservableCollection Receipts { get; } = new(); + public PrinterState State => _printer.State; + + public MainViewModel(IFileDialogService dialogs) + { + _dialogs = dialogs; + _printer = new ReceiptPrinter(PaperConfiguration.Default, new SkiaImageFactory(), new SkiaTypefaceProvider()); + // Marshal off-thread state changes (e.g. a TCP drawer kick) onto the UI thread. + _printer.UiDispatch = a => Dispatcher.UIThread.Post(a); + } + + // Call after feeding ESC/POS to (re)build the bound view models. + private void Refresh() + { + Receipts.Clear(); + int index = 1; + foreach (var receipt in _printer.ReceiptStack) + { + if (receipt.IsEmpty) continue; + Receipts.Add(new ReceiptViewModel(receipt, _encoder, _dialogs, index++)); + } + } +} +``` + +## Service interfaces + +The controls depend on two small host-provided services (in `CrossEscPos.Controls.Services`): + +- `IFileDialogService` — `SavePngAsync(suggestedName)` and `PickFolderAsync()` for the export buttons. +- `INotificationService` — surface buzzer / cash-drawer / activity events (sound, taskbar flash, …). + +The desktop app provides Avalonia implementations; a browser/headless host can stub them (e.g. return +`null` from the dialogs to disable export). + +## Converting an image yourself + +If you render outside the view models, `AvaloniaImageExtensions` bridges an `IReceiptImage` to an +Avalonia `Bitmap` via PNG (no SkiaSharp dependency in your UI code): + +```csharp +using CrossEscPos.Controls; + +Bitmap bmp = receiptImage.ToAvaloniaBitmap(encoder); +``` diff --git a/docs/packages/core.md b/docs/packages/core.md new file mode 100644 index 0000000..390458e --- /dev/null +++ b/docs/packages/core.md @@ -0,0 +1,105 @@ +# CrossEscPos.Core + +The headless emulator: the ESC/POS interpreter, the `ReceiptPrinter` state machine, the receipt +document model, and barcode/QR generation. No SkiaSharp, no Avalonia — you supply a render backend +(an `IReceiptImageFactory` + `ITypefaceProvider`) when you construct the printer. + +```csharp +using CrossEscPos.Emulator; +using CrossEscPos.Graphics; // the abstraction types +``` + +## Constructing a printer + +```csharp +var printer = new ReceiptPrinter(PaperConfiguration.Default, imageFactory, typefaces); +``` + +`PaperConfiguration` controls the paper geometry (defaults to 80mm @ 203dpi): + +```csharp +var paper = new PaperConfiguration { PaperWidthMm = 58, PrintWidthMm = 54, DotsPerInch = 203 }; +var printer = new ReceiptPrinter(paper, imageFactory, typefaces); +``` + +## Feeding ESC/POS + +`FeedEscPos` is the single entry point. ESC/POS is binary, so the `byte[]` / `ReadOnlySpan` +overloads are the natural ones; a `string` overload (one char per byte, Latin1) is there for convenience. + +```csharp +printer.FeedEscPos(bytes); // byte[] +printer.FeedEscPos(buffer.AsSpan(0, count)); // ReadOnlySpan +printer.FeedEscPos("Hello\n"); // string convenience +``` + +Each call is parsed and executed immediately. Malformed/unsupported commands are logged and skipped — +a bad byte never throws out of `FeedEscPos`. + +## Receipts + +```csharp +printer.CurrentReceipt // the receipt currently being printed (Receipt) +printer.ReceiptStack // IReadOnlyList — one entry per cut +receipt.IsEmpty // nothing printed yet +using var image = receipt.Render(); // IReceiptImage (dispose it) +``` + +## Printer state + +`ReceiptPrinter.State` is a `PrinterState` you can both read and drive. Driving it makes the emulator +answer status commands the way a real device would (out of paper, cover open, …). It implements +`INotifyPropertyChanged` and raises `Changed` on any mutation. + +```csharp +printer.State.Online = false; // now status replies report offline +printer.State.Paper = PaperLevel.Out; // and "out of paper" +printer.State.Changed += () => { /* refresh UI */ }; +``` + +While the printer isn't ready (offline, no paper, cover open, error), print operations are dropped and +`OnPrintBlocked` fires with a reason — mirroring real hardware. + +## Status / transmit-back + +Status commands (`DLE EOT`, `GS r`, `GS I`, Automatic Status Back) reply to the host. Provide a channel +by implementing `IPrinterResponder`: + +```csharp +sealed class MyResponder : IPrinterResponder +{ + public void Send(byte[] data) { /* write back to the host */ } +} + +// Per-request reply (the responder for the bytes currently being processed): +printer.FeedEscPos(requestBytes, new MyResponder()); + +// Or register a long-lived responder to also receive Automatic Status Back broadcasts: +printer.RegisterResponder(myResponder); +``` + +The transports in `CrossEscPos.Transports` already implement `IPrinterResponder` for you. + +## Events + +```csharp +printer.OnActivityEvent += (_, _) => { }; // a payload was received & processed +printer.OnBuzzer += () => { }; // BEL / buzzer command +printer.OnCashDrawer += () => { }; // cash-drawer kick (also sets State.DrawerOpen) +printer.OnPrintBlocked += reason => { }; // print dropped because not ready +``` + +## Threading + +Events and status can fire from a transport's receive thread. If you bind `State` to UI, marshal those +mutations onto the UI thread via `UiDispatch` (it runs synchronously by default — fine for headless): + +```csharp +printer.UiDispatch = action => Dispatcher.UIThread.Post(action); // Avalonia example +``` + +## Code pages + +`ESC t n` selects a character code table; high bytes are remapped to Unicode for rendering. The legacy +code pages (437/850/852/858/866/1252, …) are provided by the .NET runtime, so this works on every +platform with no extra setup. diff --git a/docs/packages/getting-started.md b/docs/packages/getting-started.md new file mode 100644 index 0000000..b834b7a --- /dev/null +++ b/docs/packages/getting-started.md @@ -0,0 +1,64 @@ +# Getting started + +The smallest useful setup: turn a stream of ESC/POS bytes into a PNG, with no UI. + +## 1. Reference the packages + +```sh +dotnet add package CrossEscPos.Core +dotnet add package CrossEscPos.Rendering.Skia +``` + +Prefer a **fully managed** backend (no native dependency — needed for Blazor WASM without a native +relink)? Swap the render package for `CrossEscPos.Rendering.ImageSharp`; everything below is identical +apart from the `Skia*` type names becoming `ImageSharp*`. + +(See the [package index](README.md#installing) for versions and local builds.) + +## 2. Compose a printer and render + +```csharp +using CrossEscPos.Emulator; // ReceiptPrinter, PaperConfiguration +using CrossEscPos.Rendering.Skia; // SkiaImageFactory, SkiaTypefaceProvider, SkiaImageEncoder + +// The composition root: pick a render backend and inject it. +var imageFactory = new SkiaImageFactory(); +var typefaces = new SkiaTypefaceProvider(); +var encoder = new SkiaImageEncoder(); + +var printer = new ReceiptPrinter(PaperConfiguration.Default, imageFactory, typefaces); + +// ESC/POS is binary — feed the raw bytes. +byte[] escpos = File.ReadAllBytes("ticket.escpos"); +printer.FeedEscPos(escpos); + +// Each "cut" produces a Receipt; render the current one to an image and encode it. +using var image = printer.CurrentReceipt.Render(); // IReceiptImage +using var output = File.Create("ticket.png"); +encoder.EncodePng(image, output); +``` + +That's the entire headless pipeline. No Avalonia, no window, no UI thread — `CrossEscPos.Core` has no +dependency on a graphics framework; `CrossEscPos.Rendering.Skia` is the backend you plugged in. + +## 3. Multiple receipts (cuts) + +A `GS V` / `ESC i` / `ESC m` cut starts a new receipt. They accumulate in `printer.ReceiptStack`: + +```csharp +foreach (var receipt in printer.ReceiptStack) +{ + if (receipt.IsEmpty) continue; + using var img = receipt.Render(); + // …encode each, or stack them — see Rendering. +} +``` + +## Where to go next + +- [Core](core.md) — printer state, status replies, events, code pages. +- [Rendering](rendering.md) — the Skia + ImageSharp backends, exporting, stacking, and writing your own. +- [Controls](controls.md) — show receipts live in an Avalonia app. +- [Transports](transports.md) — feed the printer over TCP/serial/USB instead of a file. +- [Blazor web app](web.md) — render ESC/POS in the browser with the managed backend. +- [Adding a render backend](https://github.com/danielmeza/CrossEscPosEmulator/wiki/Adding-a-Render-Backend) (wiki) — the full step-by-step guide. diff --git a/docs/packages/rendering.md b/docs/packages/rendering.md new file mode 100644 index 0000000..af59be5 --- /dev/null +++ b/docs/packages/rendering.md @@ -0,0 +1,143 @@ +# Rendering: the backends and writing your own + +`CrossEscPos.Core` renders the receipt document model through the **abstraction** in +`CrossEscPos.Abstractions` (`CrossEscPos.Graphics`), never against a concrete graphics library. A +**render backend** implements that abstraction. Two ship in-box: + +| Backend | Package | Best for | +| --- | --- | --- | +| **SkiaSharp** (default) | `CrossEscPos.Rendering.Skia` | Desktop / server — fast native rasterization | +| **ImageSharp** (managed) | `CrossEscPos.Rendering.ImageSharp` | Blazor WASM / anywhere native deps are unwanted — no native relink, byte-compatible output | + +Both embed the same monospace font and produce the **same receipts**; a host picks one and injects it. + +```mermaid +flowchart LR + subgraph core["CrossEscPos.Core"] + receipt["Receipt / printables"] + end + subgraph abs["CrossEscPos.Abstractions"] + factory["IReceiptImageFactory"] + canvas["IReceiptCanvas"] + image["IReceiptImage"] + font["ITypefaceProvider / IReceiptFont"] + encoder["IImageEncoder"] + end + subgraph backends["render backends"] + s["Rendering.Skia
SkiaImageFactory · SkiaReceiptCanvas · …"] + i["Rendering.ImageSharp
ImageSharpImageFactory · ImageSharpReceiptCanvas · …"] + end + receipt --> factory + receipt --> canvas + receipt --> font + factory -.implemented by.-> s + factory -.implemented by.-> i + canvas -.implemented by.-> s + canvas -.implemented by.-> i +``` + +## The Skia backend (native, default) + +```csharp +using CrossEscPos.Rendering.Skia; + +var imageFactory = new SkiaImageFactory(); // IReceiptImageFactory +var typefaces = new SkiaTypefaceProvider(); // ITypefaceProvider (fonts embedded) +var encoder = new SkiaImageEncoder(); // IImageEncoder (PNG) +``` + +Backed by native `libSkiaSharp`. In Blazor WASM this needs the `wasm-tools` workload and a native +relink of the runtime (a larger `dotnet.native.wasm`). + +## The ImageSharp backend (100% managed) + +Same three types, same output — but pure managed code (SixLabors.ImageSharp), so **no native +dependency**. It loads like any other assembly and runs in **Blazor WASM with no native relink**. + +```csharp +using CrossEscPos.Rendering.ImageSharp; + +var imageFactory = new ImageSharpImageFactory(); // IReceiptImageFactory +var typefaces = new ImageSharpTypefaceProvider(); // ITypefaceProvider (fonts embedded) +var encoder = new ImageSharpImageEncoder(); // IImageEncoder (PNG) +``` + +Everything downstream (`ReceiptPrinter`, `Render()`, encoding) is identical — only the injected triple +differs. See the [Blazor web app](web.md), which lets you switch between the two engines at runtime. + +The monospace receipt font (JetBrains Mono, OFL) is embedded in each backend and loaded from memory, so +receipts render identically on every OS and inside the browser sandbox with no file IO. + +## Exporting + +```csharp +using var image = printer.CurrentReceipt.Render(); // IReceiptImage + +encoder.EncodePng(image, stream); // write to a Stream +byte[] png = encoder.EncodePng(image); // or get the bytes +``` + +Stack every receipt into one tall image (the "export all" use case): + +```csharp +using CrossEscPos.Emulator.Rendering; + +var images = printer.ReceiptStack.Where(r => !r.IsEmpty).Select(r => r.Render()).ToList(); +using var combined = ReceiptExporter.StackVertical(images, imageFactory); +encoder.EncodePng(combined, output); +foreach (var i in images) i.Dispose(); +``` + +## Writing your own backend + +Want a different backend (System.Drawing, a GPU canvas, an HTML5 ``, a null/measuring backend…)? +Implement these five interfaces and inject them — `Core` is none the wiser. + +| Interface | Responsibility | +| --- | --- | +| `IReceiptImageFactory` | create blank images, build images from raw pixels, and create a canvas over an image | +| `IReceiptCanvas` | draw text, rects, lines and images; transform stack (`Save`/`Translate`/`Scale`/`RestoreToCount`) | +| `IReceiptImage` | a rasterized image (`Width`, `Height`, `Copy`) | +| `ITypefaceProvider` / `IReceiptFont` | resolve a font by family/bold/italic/size; measure text (**advance width**) + metrics | +| `IImageEncoder` | encode an `IReceiptImage` to PNG | + +Anti-aliasing is fixed per primitive to keep output consistent: text and lines are anti-aliased, +filled rects are not (crisp barcode modules), scaled image draws are sampled. A minimal sketch: + +```csharp +using CrossEscPos.Graphics; + +public sealed class MyImageFactory : IReceiptImageFactory +{ + public IReceiptImage Create(int w, int h, ReceiptColor fill) => /* … */; + public IReceiptImage FromPixels(int w, int h, ReceiptColor[] rowMajor) => /* … */; + public IReceiptCanvas CreateCanvas(IReceiptImage image) => /* … */; +} + +// …plus MyReceiptCanvas : IReceiptCanvas, MyTypefaceProvider : ITypefaceProvider, etc. + +var printer = new ReceiptPrinter(PaperConfiguration.Default, new MyImageFactory(), new MyTypefaceProvider()); +``` + +That's the entire contract for swapping the render layer. + +### 📌 Worked example — the ImageSharp backend (PR #11) + +`CrossEscPos.Rendering.ImageSharp` is itself the reference sample for adding a backend: it mirrors the +Skia one class-for-class against the same contract. Study it end-to-end in +[**PR #11**](https://github.com/danielmeza/CrossEscPosEmulator/pull/11) (by +[@yhonc9](https://github.com/yhonc9)) and the sources under +[`src/CrossEscPos.Rendering.ImageSharp/`](../../src/CrossEscPos.Rendering.ImageSharp). + +A couple of contract subtleties that PR worked through — worth knowing if you write your own: + +- **`IReceiptFont.MeasureText` must return the *advance width*** (like `SKFont.MeasureText`), counting + side bearings **and** trailing whitespace — the layout justifies and advances runs on it. ImageSharp's + `TextMeasurer.MeasureSize` returns glyph *bounds* (wrong); `MeasureAdvance` returns the advance, but + still trims trailing whitespace, so the backend re-adds it with a sentinel. +- **`FontMetrics` uses the Skia sign convention** — `Ascent` negative, `Descent` positive. +- **The transform stack** (`Save`/`Translate`/`Scale`/`RestoreToCount`) must apply per draw op — + translate outermost, scale inner — so double-width/height text scales correctly. + +The step-by-step **[Adding a render backend](https://github.com/danielmeza/CrossEscPosEmulator/wiki/Adding-a-Render-Backend)** +guide in the wiki walks through each interface using that backend as the model. diff --git a/docs/packages/transports.md b/docs/packages/transports.md new file mode 100644 index 0000000..167a6a8 --- /dev/null +++ b/docs/packages/transports.md @@ -0,0 +1,63 @@ +# CrossEscPos.Transports + +Desktop transports that feed an ESC/POS stream into a `ReceiptPrinter` the way a real device receives +it — over **TCP/IP**, a **serial** port, or **USB**. They also implement `IPrinterResponder`, so the +printer's status replies (`DLE EOT`, `GS r`, Automatic Status Back) are sent back over the same channel. + +> Desktop only. These use raw sockets, `System.IO.Ports` and libusb, which don't exist in the browser +> sandbox — a WASM host feeds ESC/POS in-page instead and doesn't reference this package. + +```mermaid +flowchart LR + host["POS app / client"] -->|ESC/POS bytes| t["NetServer / SerialServer"] + t -->|FeedEscPos| printer["ReceiptPrinter"] + printer -.->|status bytes via IPrinterResponder| t + t -.-> host +``` + +## TCP/IP server + +Listens for connections and feeds whatever bytes arrive. This is how most modern POS software talks to +a network receipt printer (port 9100 by convention). + +```csharp +using System.Net; +using CrossEscPos.Transports; + +var tcp = new NetServer(printer); +tcp.Start(IPAddress.Any, 9100); +// … tcp.IsRunning, tcp.EndPoint … +tcp.Stop(); +``` + +## Serial server + +Reads ESC/POS from an RS-232 / virtual serial port. + +```csharp +var serial = new SerialServer(printer); +serial.Start("/dev/ttyUSB0", baudRate: 9600); // or "COM3" on Windows +// … serial.IsRunning, serial.PortName, serial.BaudRate … +serial.Stop(); +``` + +To test serial without hardware, create a virtual serial bridge (a linked pair of ports) and point the +server at one end and your client at the other — see the main README's "Testing serial without hardware". + +## USB + +`UsbPrinter` is a USB client (libusb) used to drive a real or emulated printer over a bulk endpoint. It +needs the native `libusb-1.0` at runtime (`brew install libusb`, `apt install libusb-1.0-0`; bundled on +Windows). + +## Lifetime + +Start the transports after constructing the printer, and stop them on shutdown: + +```csharp +tcp.Stop(); +serial.Stop(); +``` + +Because receive happens on a background thread, set `printer.UiDispatch` if you bind printer state to a +UI (see [Core](core.md#threading)). diff --git a/docs/packages/web.md b/docs/packages/web.md new file mode 100644 index 0000000..1688984 --- /dev/null +++ b/docs/packages/web.md @@ -0,0 +1,77 @@ +# Blazor web app — render ESC/POS in the browser + +[`samples/CrossEscPos.Web`](../../samples/CrossEscPos.Web) is a **Blazor WebAssembly** app that runs the +whole emulator in the browser: feed it ESC/POS, see the rendered receipt, and **switch the render engine +at runtime** — managed **ImageSharp** (default) or native **SkiaSharp**. It's the showcase for the +managed backend: with ImageSharp there's **no native relink** and the runtime stays the stock size. + +```mermaid +flowchart LR + input["paste text / upload file / sample"] --> host["EmulatorHost
(ReceiptPrinter + active backend)"] + host -->|"Render()"| png["PNG (data URI)"] + png --> view["<ReceiptView> (Razor)"] + host --> state["PrinterState"] --> panel["<PrinterStatePanel> (Razor)"] + selector["engine selector"] -->|"UseBackend()"| host +``` + +## Run it + +```sh +dotnet run --project samples/CrossEscPos.Web +``` + +Opens a dev server; the page auto-renders a sample ticket. Pick the engine on the left, paste ESC/POS +text or upload a `.escpos` file, and the receipt renders on the fly. The active engine is remembered +across reloads (`localStorage`). + +> **Building requires the `wasm-tools` workload** (`dotnet workload install wasm-tools`). Because the app +> includes **both** engines, SkiaSharp's native `libSkiaSharp.a` is relinked into the runtime +> (`dotnet.native.wasm` ≈ 23 MB). ImageSharp alone needs none of that — that's the whole point of the +> managed backend; the app carries Skia only so you can A/B the two in one place. + +## How it's wired + +The app is a thin shell over the same headless `Core` the desktop uses — the only browser-specific part +is the composition root. + +| Piece | Role | +| --- | --- | +| [`RenderBackend`](../../samples/CrossEscPos.Web/Rendering/RenderBackend.cs) | A small **named registry** — resolves `"imagesharp"` / `"skia"` to a factory + typefaces + encoder triple | +| [`EmulatorHost`](../../samples/CrossEscPos.Web/Services/EmulatorHost.cs) | Owns the `ReceiptPrinter` and the active backend; `Render(bytes)`, `UseBackend(id)`, exposes `Receipts` + `State` | +| [`ReceiptView.razor`](../../samples/CrossEscPos.Web/Components/ReceiptView.razor) | Shows one receipt as a PNG with a download link (the Razor analogue of the Avalonia `ReceiptView`) | +| [`PrinterStatePanel.razor`](../../samples/CrossEscPos.Web/Components/PrinterStatePanel.razor) | Two-way binds the `PrinterState` (online, cover, paper, drawer, …) | + +Switching the engine simply re-creates the printer with the other backend triple and re-renders the +current input — the core, the receipt model, and the Razor components are all backend-agnostic. + +```csharp +// EmulatorHost — the essence of the runtime switch +public void UseBackend(string id) +{ + var next = RenderBackend.ById(id); // "imagesharp" | "skia" + if (next.Id == _backend.Id) return; + _backend = next; + Rebuild(preserveState: true); // new factory/typefaces/encoder; replay last input +} +``` + +## Publish + +```sh +dotnet publish samples/CrossEscPos.Web -c Release -o publish/web +``` + +`publish/web/wwwroot/` is a static site (no server runtime) you can host anywhere — GitHub Pages, a CDN, +any static host. Serve `_framework/` as static files. + +## Just want ESC/POS → PNG, no UI? + +Reference `CrossEscPos.Core` + `CrossEscPos.Rendering.ImageSharp` directly in your own Blazor app and +call the two-line pipeline from [Getting started](getting-started.md) — the managed backend means it +"just works" in WASM with no extra toolchain. + +## The full interactive desktop app + +For the native desktop emulator (TCP/serial/USB transports, live UI) see +[`src/CrossEscPos.App.Desktop`](../../src/CrossEscPos.App.Desktop); it can also switch backends at +runtime with `--backend skia|imagesharp`. diff --git a/global.json b/global.json index f4fd385..bdfc2a0 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.0", + "version": "10.0.300", "rollForward": "latestMajor", "allowPrerelease": true } diff --git a/samples/CrossEscPos.Headless/CrossEscPos.Headless.csproj b/samples/CrossEscPos.Headless/CrossEscPos.Headless.csproj new file mode 100644 index 0000000..330150a --- /dev/null +++ b/samples/CrossEscPos.Headless/CrossEscPos.Headless.csproj @@ -0,0 +1,14 @@ + + + + Exe + crossescpos-render + + + + + + + + + diff --git a/samples/CrossEscPos.Headless/Program.cs b/samples/CrossEscPos.Headless/Program.cs new file mode 100644 index 0000000..6332360 --- /dev/null +++ b/samples/CrossEscPos.Headless/Program.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using CrossEscPos.Graphics; +using CrossEscPos.Emulator; +using CrossEscPos.Emulator.Rendering; +using CrossEscPos.Rendering.Skia; + +namespace CrossEscPos.Headless; + +/// +/// Headless ESC/POS → PNG renderer. Proves the emulator runs with no UI framework: it wires the Core +/// emulator to the SkiaSharp render backend directly — no Avalonia, no window, no UI thread. +/// +/// crossescpos-render <input.escpos|--> <output.png> +/// +/// Reads ESC/POS bytes from the given file (or stdin when the path is "-"), renders every receipt the +/// stream produced, stacks them top-to-bottom, and writes a single PNG. +/// +internal static class Program +{ + private static int Main(string[] args) + { + if (args.Length < 2) + { + Console.Error.WriteLine("usage: crossescpos-render "); + return 2; + } + + var inputPath = args[0]; + var outputPath = args[1]; + + byte[] bytes = inputPath == "-" + ? ReadAllStdin() + : File.ReadAllBytes(inputPath); + + // Compose the render backend explicitly — this is the headless composition root. + var imageFactory = new SkiaImageFactory(); + var typefaces = new SkiaTypefaceProvider(); + var encoder = new SkiaImageEncoder(); + + var printer = new ReceiptPrinter(PaperConfiguration.Default, imageFactory, typefaces); + + // ESC/POS is binary — feed the raw bytes straight in. + printer.FeedEscPos(bytes); + + // Render every non-empty receipt the stream produced and stack them. + var pages = new List(); + foreach (var receipt in printer.ReceiptStack) + { + if (receipt.IsEmpty) + continue; + pages.Add(receipt.Render()); + } + + if (pages.Count == 0) + { + Console.Error.WriteLine("No printable content was produced by the stream."); + return 1; + } + + using var combined = ReceiptExporter.StackVertical(pages, imageFactory); + using (var output = File.Create(outputPath)) + encoder.EncodePng(combined, output); + + foreach (var page in pages) + page.Dispose(); + + Console.WriteLine($"Rendered {pages.Count} receipt(s) -> {outputPath} ({combined.Width}x{combined.Height})"); + return 0; + } + + private static byte[] ReadAllStdin() + { + using var stdin = Console.OpenStandardInput(); + using var buffer = new MemoryStream(); + stdin.CopyTo(buffer); + return buffer.ToArray(); + } +} diff --git a/samples/CrossEscPos.Host/BridgeHub.cs b/samples/CrossEscPos.Host/BridgeHub.cs new file mode 100644 index 0000000..a1b1bd4 --- /dev/null +++ b/samples/CrossEscPos.Host/BridgeHub.cs @@ -0,0 +1,177 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using CrossEscPos.Bridge; +using Microsoft.AspNetCore.SignalR; + +namespace CrossEscPos.Host; + +/// +/// The broker hub — strongly typed on both sides: it implements (the methods +/// clients call) and is a of (the methods it calls back, +/// e.g. Clients.Client(id).ReceiveEscPos(data)). When an emulator attaches it opens a TCP listener +/// on the address:port it asks for; POS software that connects there — or a SignalR monitor — sends jobs +/// to that emulator and gets its status replies back. The listener is torn down when the emulator leaves. +/// +public sealed class BridgeHub : Hub, IBridgeServer +{ + internal static string? EmulatorConnection; + internal static Func? SenderReply; // routes status to the current sender + internal static IHubContext? Context_; // strongly-typed client proxy + + private static readonly ConcurrentDictionary Sessions = new(); + private static readonly ConcurrentDictionary Outbound = new(); // monitor → printer + + private sealed record TcpSession(TcpListener Listener, CancellationTokenSource Cts); + + public Task AttachEmulator(string address, int port) + { + var connection = Context.ConnectionId; + EmulatorConnection = connection; + StopSession(connection); // drop any prior listener for this connection (re-attach) + + var ip = IPAddress.TryParse(address, out var parsed) ? parsed : IPAddress.Any; + TcpListener listener; + try + { + listener = new TcpListener(ip, port); + listener.Start(); + } + catch (Exception ex) + { + throw new HubException($"Could not listen on {address}:{port} — {ex.Message}"); + } + + var cts = new CancellationTokenSource(); + Sessions[connection] = new TcpSession(listener, cts); + Console.WriteLine($"[hub] emulator attached; listening on {address}:{port}"); + _ = AcceptLoop(connection, listener, cts.Token); + return Task.CompletedTask; + } + + public async Task ConnectTcp(string host, int port) + { + var connection = Context.ConnectionId; + CloseOutbound(connection); + + var client = new TcpClient(); + try + { + await client.ConnectAsync(host, port); + } + catch (Exception ex) + { + client.Dispose(); + throw new HubException($"Could not connect to {host}:{port} — {ex.Message}"); + } + + Outbound[connection] = client; + Console.WriteLine($"[tcp-out] monitor → printer {host}:{port}"); + _ = PumpOutbound(connection, client); + } + + public Task SendToEmulator(byte[] data) + { + var connection = Context.ConnectionId; + + // A monitor that opened an outbound TCP printer writes to that socket instead of the emulator. + if (Outbound.TryGetValue(connection, out var printer)) + { + try { return printer.GetStream().WriteAsync(data).AsTask(); } + catch { return Task.CompletedTask; } + } + + SenderReply = d => Context_!.Clients.Client(connection).ReceiveStatus(d); + return ForwardToEmulator(data); + } + + public Task ReplyToSender(byte[] data) => SenderReply?.Invoke(data) ?? Task.CompletedTask; + + public override Task OnDisconnectedAsync(Exception? exception) + { + var connection = Context.ConnectionId; + StopSession(connection); + CloseOutbound(connection); + if (EmulatorConnection == connection) + EmulatorConnection = null; + return base.OnDisconnectedAsync(exception); + } + + private static void CloseOutbound(string connection) + { + if (Outbound.TryRemove(connection, out var client)) + { + try { client.Close(); client.Dispose(); } catch { /* already gone */ } + Console.WriteLine("[tcp-out] printer connection closed"); + } + } + + /// Pump a monitor's outbound TCP printer: its bytes → the monitor as status replies. + private static async Task PumpOutbound(string connection, TcpClient client) + { + try + { + var stream = client.GetStream(); + var buffer = new byte[4096]; + int read; + while ((read = await stream.ReadAsync(buffer)) > 0) + { + if (Context_ is not null) + await Context_.Clients.Client(connection).ReceiveStatus(buffer[..read]); + } + } + catch { /* printer gone */ } + CloseOutbound(connection); + } + + private static Task ForwardToEmulator(byte[] data) + => EmulatorConnection is not null && Context_ is not null + ? Context_.Clients.Client(EmulatorConnection).ReceiveEscPos(data) + : Task.CompletedTask; + + private static void StopSession(string connection) + { + if (Sessions.TryRemove(connection, out var session)) + { + try { session.Cts.Cancel(); session.Listener.Stop(); } catch { /* already gone */ } + Console.WriteLine("[hub] TCP session stopped"); + } + } + + /// Accept POS TCP clients for one emulator session until the listener is stopped. + private static async Task AcceptLoop(string emulatorConnection, TcpListener listener, CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + var client = await listener.AcceptTcpClientAsync(ct); + _ = BridgeTcpAsync(emulatorConnection, client, ct); + } + } + catch { /* listener stopped */ } + } + + /// Bridge a raw TCP POS client: its bytes → its emulator; the emulator's status → its stream. + private static async Task BridgeTcpAsync(string emulatorConnection, TcpClient client, CancellationToken ct) + { + using (client) + { + var stream = client.GetStream(); + SenderReply = d => stream.WriteAsync(d, CancellationToken.None).AsTask(); + Console.WriteLine("[tcp] POS connected — bridging to emulator"); + var buffer = new byte[4096]; + int read; + try + { + while ((read = await stream.ReadAsync(buffer, ct)) > 0) + { + if (Context_ is not null) + await Context_.Clients.Client(emulatorConnection).ReceiveEscPos(buffer[..read]); + } + } + catch { /* POS gone / cancelled */ } + Console.WriteLine("[tcp] POS disconnected"); + } + } +} diff --git a/samples/CrossEscPos.Host/CrossEscPos.Host.csproj b/samples/CrossEscPos.Host/CrossEscPos.Host.csproj new file mode 100644 index 0000000..87d8600 --- /dev/null +++ b/samples/CrossEscPos.Host/CrossEscPos.Host.csproj @@ -0,0 +1,53 @@ + + + + net10.0 + + enable + enable + + + + + + + + + + + + + + + + $(MSBuildThisFileDirectory)..\..\src\CrossEscPos.App.Browser\CrossEscPos.App.Browser.csproj + + $(MSBuildThisFileDirectory)obj\wasmclient\ + + + + + + + + + + + diff --git a/samples/CrossEscPos.Host/Program.cs b/samples/CrossEscPos.Host/Program.cs new file mode 100644 index 0000000..42846db --- /dev/null +++ b/samples/CrossEscPos.Host/Program.cs @@ -0,0 +1,26 @@ +using CrossEscPos.Bridge; +using CrossEscPos.Host; +using Microsoft.AspNetCore.SignalR; + +// CrossEscPos.Host — the single web host for the browser experience. It serves the Avalonia WASM app +// (published into wwwroot) AND hosts the SignalR broker on one origin, so the browser reaches the hub at +// /bridge with no CORS. The browser emulator asks the hub to open a TCP listener on the address:port it +// chooses (per session); POS software connects there and its jobs bridge to the in-page emulator, and the +// Monitor rides the same hub for a full round-trip. + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddSignalR(); + +var app = builder.Build(); + +// Serve the WASM app: _framework/* with the right MIME types, then the static content, with a fallback +// to index.html for the SPA. +app.UseBlazorFrameworkFiles(); +app.UseStaticFiles(); + +app.MapHub("/bridge"); +BridgeHub.Context_ = app.Services.GetRequiredService>(); + +app.MapFallbackToFile("index.html"); + +app.Run(); diff --git a/scripts/gen-test-receipt.py b/scripts/gen-test-receipt.py new file mode 100644 index 0000000..c32e7b2 --- /dev/null +++ b/scripts/gen-test-receipt.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Generate a comprehensive test_receipt.txt that exercises every supported feature: +text formatting, alignment, fonts, sizes, all 1D barcodes, all 2D symbols, a bit image, +the buzzer and the cash drawer. Run from the repo root: python3 scripts/gen-test-receipt.py""" + +out = bytearray() + + +def raw(*xs): + for x in xs: + out.append(x & 0xFF) + + +def S(s): + out.extend(s.encode("latin1")) + + +ESC, GS = 0x1B, 0x1D + +# --- helpers -------------------------------------------------------------- +def init(): raw(ESC, ord('@')) +def align(n): raw(ESC, ord('a'), n) # 0 left, 1 center, 2 right +def bold(on): raw(ESC, ord('E'), 1 if on else 0) +def italic(on): raw(ESC, 0x34 if on else 0x35) # ESC 4 / ESC 5 +def underline(n): raw(ESC, ord('-'), n) # 0 off, 1 one-dot, 2 two-dot +def font(n): raw(ESC, ord('M'), n) # 0 A, 1 B +def size(w, h): raw(GS, ord('!'), ((w - 1) << 4) | (h - 1)) +def feed(n=1): raw(ESC, ord('d'), n) +def line(s=""): S(s); raw(0x0A) +def rule(): line("-" * 42) +def cut(): raw(ESC, ord('i')) + + +def section(title): + align(1); bold(True); size(1, 2); line(title) + size(1, 1); bold(False); align(0); feed(1) + + +# --- header --------------------------------------------------------------- +init() +align(1) +size(2, 2); bold(True); line("ESC/POS") +size(1, 1); line("Emulator feature test"); bold(False) +align(0); rule() + +# --- text formatting ------------------------------------------------------ +section("TEXT STYLES") +line("Normal text") +bold(True); line("Bold text"); bold(False) +italic(True); line("Italic text"); italic(False) +underline(1); line("Underline (1 dot)"); underline(0) +underline(2); line("Underline (2 dot)"); underline(0) +font(1); line("Font B (small)"); font(0) +size(2, 1); line("Double width"); size(1, 1) +size(1, 2); line("Double height"); size(1, 1) +size(2, 2); line("Double both"); size(1, 1) +rule() + +# --- alignment ------------------------------------------------------------ +section("ALIGNMENT") +align(0); line("Left aligned") +align(1); line("Center aligned") +align(2); line("Right aligned") +align(0); rule() + +# --- 1D barcodes ---------------------------------------------------------- +section("1D BARCODES") +raw(GS, ord('h'), 70) # height 70 dots +raw(GS, ord('w'), 2) # module width 2 +raw(GS, ord('H'), 2) # HRI below +raw(GS, ord('f'), 0) # HRI font A + + +def barcode(m, data, label): + align(1); S(label); raw(0x0A) + raw(GS, ord('k'), m, len(data)); S(data); raw(0x0A) + align(0); feed(1) + + +barcode(65, "12345678901", "UPC-A") +barcode(67, "123456789012", "EAN-13") +barcode(68, "1234567", "EAN-8") +barcode(69, "CODE39", "CODE39") +barcode(72, "CODE93", "CODE93") +barcode(73, "Code128", "CODE128") +barcode(70, "12345678", "ITF") +barcode(71, "A123456A", "CODABAR") +rule() + +# --- 2D symbols ----------------------------------------------------------- +section("2D SYMBOLS") + + +def sym2d(cn, data, label, size_dots=5): + align(1); S(label); raw(0x0A) + raw(GS, ord('('), ord('k'), 3, 0, cn, 67, size_dots) # module size + if cn == 49: + raw(GS, ord('('), ord('k'), 3, 0, cn, 69, 49) # QR EC = M + ln = 3 + len(data) + raw(GS, ord('('), ord('k'), ln, 0, cn, 80, 48); S(data) # store + raw(GS, ord('('), ord('k'), 3, 0, cn, 81, 48) # print + align(0); feed(1) + + +sym2d(49, "https://example.com/qr", "QR Code", 6) +sym2d(48, "PDF417 sample data", "PDF417", 3) +sym2d(54, "DataMatrix sample", "DataMatrix") +sym2d(55, "Aztec sample", "Aztec") +rule() + +# --- bit image (ESC *) ---------------------------------------------------- +section("BIT IMAGE (ESC *)") +align(1) +width = 96 +raw(ESC, ord('*'), 33, width & 0xFF, width >> 8) # 24-dot mode +for col in range(width): + # a wavy band + top = 0xFF if (col // 6) % 2 == 0 else 0x18 + mid = 0x3C + bot = 0x18 if (col // 6) % 2 == 0 else 0xFF + raw(top, mid, bot) +raw(0x0A) +align(0); rule() + +# --- peripherals ---------------------------------------------------------- +section("PERIPHERALS") +align(1); bold(True); line(">> BEEP + OPEN DRAWER <<"); bold(False); align(0) +raw(0x07) # BEL buzzer +raw(ESC, ord('p'), 0, 25, 25) # cash drawer kick +feed(1) +line("Thank you!") +feed(2) +cut() + +with open("test_receipt.txt", "wb") as f: + f.write(out) +print(f"wrote test_receipt.txt ({len(out)} bytes)") diff --git a/scripts/make-macos-app.sh b/scripts/make-macos-app.sh new file mode 100755 index 0000000..d92b809 --- /dev/null +++ b/scripts/make-macos-app.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# +# make-macos-app.sh — wrap a `dotnet publish` output into a macOS .app bundle (with .icns icon). +# Must run on macOS (uses sips + iconutil). +# +# Usage: make-macos-app.sh [icon-png] +# +set -euo pipefail + +PUBLISH_DIR=${1:?publish dir required} +OUTPUT_DIR=${2:?output dir required} +VERSION=${3:-1.0.0} +ICON_PNG=${4:-src/CrossEscPos.App.Desktop/Assets/Icon/icon.png} +# The published executable (AssemblyName) vs the visible bundle name. +EXE_NAME=CrossEscPos.App.Desktop +APP_NAME=CrossEscPos + +APP="$OUTPUT_DIR/$APP_NAME.app" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + +# Payload +cp -R "$PUBLISH_DIR"/. "$APP/Contents/MacOS/" +chmod +x "$APP/Contents/MacOS/$EXE_NAME" + +# Icon (.icns) from the PNG. +# Prefer macOS tooling (sips + iconutil); fall back to png2icns (icnsutils) on Linux so the bundle +# can also be assembled off a Mac. If neither is available the .app still runs (default icon). +ICNS_OUT="$APP/Contents/Resources/icon.icns" +if command -v iconutil >/dev/null 2>&1 && command -v sips >/dev/null 2>&1; then + ICONSET="$(mktemp -d)/icon.iconset" + mkdir -p "$ICONSET" + for s in 16 32 128 256 512; do + sips -z "$s" "$s" "$ICON_PNG" --out "$ICONSET/icon_${s}x${s}.png" >/dev/null + d=$((s * 2)) + sips -z "$d" "$d" "$ICON_PNG" --out "$ICONSET/icon_${s}x${s}@2x.png" >/dev/null + done + iconutil -c icns "$ICONSET" -o "$ICNS_OUT" +elif command -v png2icns >/dev/null 2>&1; then + TMP="$(mktemp -d)" + if command -v convert >/dev/null 2>&1; then + for s in 16 32 48 128 256 512; do convert "$ICON_PNG" -resize "${s}x${s}" "$TMP/i_${s}.png"; done + png2icns "$ICNS_OUT" "$TMP"/i_*.png + else + png2icns "$ICNS_OUT" "$ICON_PNG" + fi +else + echo "warning: no iconutil/png2icns found — building .app without a custom icon" >&2 +fi + +# Info.plist +cat > "$APP/Contents/Info.plist" < + + + + CFBundleNameESC/POS Emulator + CFBundleDisplayNameESC/POS Receipt Printer Emulator + CFBundleIdentifiercom.github.crossescposemulator + CFBundleVersion$VERSION + CFBundleShortVersionString$VERSION + CFBundleExecutable$EXE_NAME + CFBundleIconFileicon + CFBundlePackageTypeAPPL + LSMinimumSystemVersion11.0 + NSHighResolutionCapable + LSApplicationCategoryTypepublic.app-category.developer-tools + + +PLIST + +# Ad-hoc code-sign the whole bundle (deep) so it runs on Apple Silicon. Without any signature, +# Gatekeeper reports a downloaded app as "damaged" on arm64. This is NOT Developer-ID/notarized — +# users still need to clear the download quarantine on first launch (see README). Signing must be +# the LAST step, after all files (Info.plist, icns, payload) are in place. +if command -v codesign >/dev/null 2>&1; then + echo "Ad-hoc code-signing $APP …" + codesign --remove-signature "$APP" 2>/dev/null || true + codesign --force --deep --sign - --timestamp=none "$APP" + codesign --verify --deep --strict --verbose=2 "$APP" || echo "warning: codesign verify failed" >&2 +else + echo "warning: codesign not available — bundle left unsigned (will be 'damaged' on Apple Silicon)" >&2 +fi + +echo "Built $APP" diff --git a/scripts/serial-bridge.sh b/scripts/serial-bridge.sh new file mode 100755 index 0000000..fd79f29 --- /dev/null +++ b/scripts/serial-bridge.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# +# serial-bridge.sh — create a virtual serial port pair for testing the emulator without hardware. +# +# Requires socat (macOS: `brew install socat`, Debian/Ubuntu: `sudo apt install socat`). +# +# It links two PTY devices: write ESC/POS bytes to one and they arrive on the other. Point the +# emulator at PORT A and your POS application (or `cat file > PORT_B`) at PORT B. +# +# Usage: +# ./scripts/serial-bridge.sh +# # then, in other terminals: +# ESCPOS_SERIAL_PORT= dotnet run +# cat test_receipt.txt > +# +set -euo pipefail + +if ! command -v socat >/dev/null 2>&1; then + echo "error: socat is not installed." >&2 + echo " macOS: brew install socat" >&2 + echo " Debian/Ubuntu: sudo apt install socat" >&2 + exit 1 +fi + +echo "Creating a virtual serial port pair (Ctrl-C to stop)…" +echo "Look for two 'PTY is /dev/ttysNNN' lines below:" +echo " - the FIRST is PORT A -> ESCPOS_SERIAL_PORT= dotnet run" +echo " - the SECOND is PORT B -> write your ESC/POS to it (e.g. cat test_receipt.txt > )" +echo + +exec socat -d -d pty,raw,echo=0 pty,raw,echo=0 diff --git a/src/CrossEscPos.Abstractions/CrossEscPos.Abstractions.csproj b/src/CrossEscPos.Abstractions/CrossEscPos.Abstractions.csproj new file mode 100644 index 0000000..166e998 --- /dev/null +++ b/src/CrossEscPos.Abstractions/CrossEscPos.Abstractions.csproj @@ -0,0 +1,10 @@ + + + + true + Backend-agnostic rendering and printer contracts for the CrossEscPos receipt emulator + (IReceiptCanvas, IReceiptImage, IReceiptImageFactory, ITypefaceProvider, IImageEncoder, + IReceiptPrintable, IPrinterResponder). No external dependencies — headless and WASM safe. + + + diff --git a/src/CrossEscPos.Abstractions/Graphics/FontMetrics.cs b/src/CrossEscPos.Abstractions/Graphics/FontMetrics.cs new file mode 100644 index 0000000..20460f3 --- /dev/null +++ b/src/CrossEscPos.Abstractions/Graphics/FontMetrics.cs @@ -0,0 +1,8 @@ +namespace CrossEscPos.Graphics; + +/// +/// Vertical font metrics in device pixels, following the Skia convention used by the receipt layout: +/// is negative (distance above the baseline) and is positive +/// (distance below it). +/// +public readonly record struct FontMetrics(float Ascent, float Descent); diff --git a/src/CrossEscPos.Abstractions/Graphics/IImageEncoder.cs b/src/CrossEscPos.Abstractions/Graphics/IImageEncoder.cs new file mode 100644 index 0000000..8771303 --- /dev/null +++ b/src/CrossEscPos.Abstractions/Graphics/IImageEncoder.cs @@ -0,0 +1,11 @@ +using System.IO; + +namespace CrossEscPos.Graphics; + +/// Encodes an to PNG — the portable handoff to UI hosts and export. +public interface IImageEncoder +{ + void EncodePng(IReceiptImage image, Stream destination); + + byte[] EncodePng(IReceiptImage image); +} diff --git a/src/CrossEscPos.Abstractions/Graphics/IReceiptCanvas.cs b/src/CrossEscPos.Abstractions/Graphics/IReceiptCanvas.cs new file mode 100644 index 0000000..4fc87ed --- /dev/null +++ b/src/CrossEscPos.Abstractions/Graphics/IReceiptCanvas.cs @@ -0,0 +1,50 @@ +using System; + +namespace CrossEscPos.Graphics; + +/// +/// A 2D drawing surface over an . This is the seam that lets the render +/// backend be swapped: the receipt document model (text lines, bit images, barcodes) draws against +/// this interface and never touches SkiaSharp directly. +/// +/// Anti-aliasing is fixed per primitive to match the original Skia output: text and lines are +/// anti-aliased (smooth glyphs / underlines), filled rectangles are not (crisp barcode modules), and +/// scaled image draws are sampled. +/// +/// The canvas is created by ; the creator owns and +/// disposes it. Elements handed a canvas in only draw — they +/// must not dispose it. +/// +public interface IReceiptCanvas : IDisposable +{ + /// Fills the entire surface with . + void Clear(ReceiptColor color); + + /// Draws with its baseline at (, ). + void DrawText(string text, float x, float baselineY, IReceiptFont font, ReceiptColor color); + + /// Fills (no anti-aliasing — crisp module edges). + void DrawRect(ReceiptRect rect, ReceiptColor color); + + /// Strokes a line of the given width (anti-aliased). + void DrawLine(float x0, float y0, float x1, float y1, ReceiptColor color, float strokeWidth); + + /// Draws at (, ) at native size. + void DrawImage(IReceiptImage image, float x, float y); + + /// Draws scaled to fill (sampled). + void DrawImage(IReceiptImage image, ReceiptRect dest); + + /// Saves the current transform and returns a restore token (see ). + int Save(); + + void Translate(float dx, float dy); + + void Scale(float sx, float sy); + + /// Restores the transform stack to the depth returned by an earlier . + void RestoreToCount(int count); + + /// Flushes any buffered drawing onto the backing image. + void Flush(); +} diff --git a/src/CrossEscPos.Abstractions/Graphics/IReceiptFont.cs b/src/CrossEscPos.Abstractions/Graphics/IReceiptFont.cs new file mode 100644 index 0000000..d5da049 --- /dev/null +++ b/src/CrossEscPos.Abstractions/Graphics/IReceiptFont.cs @@ -0,0 +1,18 @@ +using System; + +namespace CrossEscPos.Graphics; + +/// +/// A resolved font at a specific pixel size, used to measure and draw receipt text. Obtained from an +/// ; the caller owns it and disposes it. +/// +public interface IReceiptFont : IDisposable +{ + /// The em size in device pixels this font was created at. + float Size { get; } + + FontMetrics Metrics { get; } + + /// The advance width of in device pixels at this font's size. + float MeasureText(string text); +} diff --git a/src/CrossEscPos.Abstractions/Graphics/IReceiptImage.cs b/src/CrossEscPos.Abstractions/Graphics/IReceiptImage.cs new file mode 100644 index 0000000..72b4994 --- /dev/null +++ b/src/CrossEscPos.Abstractions/Graphics/IReceiptImage.cs @@ -0,0 +1,17 @@ +using System; + +namespace CrossEscPos.Graphics; + +/// +/// A rasterized image (a receipt page, a barcode, an uploaded bit image). Produced by an +/// and consumed by and +/// . The owner disposes it. +/// +public interface IReceiptImage : IDisposable +{ + int Width { get; } + int Height { get; } + + /// Returns an independent copy whose lifetime the caller owns. + IReceiptImage Copy(); +} diff --git a/src/CrossEscPos.Abstractions/Graphics/IReceiptImageFactory.cs b/src/CrossEscPos.Abstractions/Graphics/IReceiptImageFactory.cs new file mode 100644 index 0000000..44e59e4 --- /dev/null +++ b/src/CrossEscPos.Abstractions/Graphics/IReceiptImageFactory.cs @@ -0,0 +1,21 @@ +namespace CrossEscPos.Graphics; + +/// +/// Creates images and the canvases that draw onto them. This is the backend entry point the emulator +/// core depends on; the desktop/browser hosts (and the headless sample) supply a concrete factory +/// (e.g. the SkiaSharp one). +/// +public interface IReceiptImageFactory +{ + /// Creates a blank image of the given size filled with . + IReceiptImage Create(int width, int height, ReceiptColor fill); + + /// + /// Creates an image from row-major pixels (length must be * + /// ). Used by the raster / bit-image ESC/POS commands. + /// + IReceiptImage FromPixels(int width, int height, ReceiptColor[] rowMajorPixels); + + /// Creates a canvas that draws onto . + IReceiptCanvas CreateCanvas(IReceiptImage image); +} diff --git a/src/CrossEscPos.Abstractions/Graphics/ITypefaceProvider.cs b/src/CrossEscPos.Abstractions/Graphics/ITypefaceProvider.cs new file mode 100644 index 0000000..183c3d1 --- /dev/null +++ b/src/CrossEscPos.Abstractions/Graphics/ITypefaceProvider.cs @@ -0,0 +1,10 @@ +namespace CrossEscPos.Graphics; + +/// +/// Resolves a font family + style + size into a drawable . Backends decide +/// how families are resolved (embedded TTF, system fonts, …). Implementations should cache. +/// +public interface ITypefaceProvider +{ + IReceiptFont GetFont(string family, bool bold, bool italic, float sizePx); +} diff --git a/src/CrossEscPos.Abstractions/Graphics/ReceiptColor.cs b/src/CrossEscPos.Abstractions/Graphics/ReceiptColor.cs new file mode 100644 index 0000000..34637cf --- /dev/null +++ b/src/CrossEscPos.Abstractions/Graphics/ReceiptColor.cs @@ -0,0 +1,14 @@ +namespace CrossEscPos.Graphics; + +/// +/// A backend-agnostic 32-bit RGBA color. Receipts are monochrome, but a full color keeps the surface +/// general (and lets the raster/bit-image commands carry grayscale values straight through). +/// +public readonly record struct ReceiptColor(byte R, byte G, byte B, byte A = 255) +{ + public static readonly ReceiptColor Black = new(0, 0, 0); + public static readonly ReceiptColor White = new(255, 255, 255); + + /// An opaque gray where R=G=B=. + public static ReceiptColor Gray(byte value) => new(value, value, value); +} diff --git a/src/CrossEscPos.Abstractions/Graphics/ReceiptRect.cs b/src/CrossEscPos.Abstractions/Graphics/ReceiptRect.cs new file mode 100644 index 0000000..19d2009 --- /dev/null +++ b/src/CrossEscPos.Abstractions/Graphics/ReceiptRect.cs @@ -0,0 +1,7 @@ +namespace CrossEscPos.Graphics; + +/// An axis-aligned rectangle in device pixels (top-left origin), mirroring SKRect.Create semantics. +public readonly record struct ReceiptRect(float X, float Y, float Width, float Height) +{ + public static ReceiptRect Create(float x, float y, float width, float height) => new(x, y, width, height); +} diff --git a/src/CrossEscPos.Abstractions/IPrinterResponder.cs b/src/CrossEscPos.Abstractions/IPrinterResponder.cs new file mode 100644 index 0000000..b71dd2d --- /dev/null +++ b/src/CrossEscPos.Abstractions/IPrinterResponder.cs @@ -0,0 +1,11 @@ +namespace CrossEscPos; + +/// +/// A channel the printer can write bytes back to the host over (TCP client or serial port). +/// Implemented by the transports so status / transmit-back commands can reply to the host that +/// sent the request. +/// +public interface IPrinterResponder +{ + void Send(byte[] data); +} diff --git a/src/CrossEscPos.Abstractions/IReceiptPrintable.cs b/src/CrossEscPos.Abstractions/IReceiptPrintable.cs new file mode 100644 index 0000000..886cbc9 --- /dev/null +++ b/src/CrossEscPos.Abstractions/IReceiptPrintable.cs @@ -0,0 +1,15 @@ +using CrossEscPos.Graphics; + +namespace CrossEscPos; + +/// +/// One renderable element of a receipt (a text line, a bit image, vertical space). Draws itself onto a +/// backend-agnostic . +/// +public interface IReceiptPrintable +{ + /// Draws this line onto the receipt canvas at the given top-left offset. + void Render(IReceiptCanvas canvas, int offsetX, int offsetY); + + int GetPrintHeight(); +} diff --git a/src/CrossEscPos.App.Browser/BrowserFileDialogService.cs b/src/CrossEscPos.App.Browser/BrowserFileDialogService.cs new file mode 100644 index 0000000..f163bb5 --- /dev/null +++ b/src/CrossEscPos.App.Browser/BrowserFileDialogService.cs @@ -0,0 +1,51 @@ +using System; +using System.IO; +using System.Runtime.InteropServices.JavaScript; +using System.Threading.Tasks; +using CrossEscPos.Controls.Services; + +namespace CrossEscPos.App.Browser; + +/// +/// Browser "save PNG" = a real download. Avalonia's browser storage provider goes through the File System +/// Access API (Chromium-only, and its writable stream rejects the encoder's synchronous writes), so exports +/// silently failed. Here hands back a memory stream that, when disposed, blobs +/// its bytes and downloads them via a JS anchor — works in every browser. Folder export isn't offered. +/// +public sealed class BrowserFileDialogService : IFileDialogService +{ + public Task SavePngAsync(string suggestedName) + { + var name = suggestedName.EndsWith(".png", StringComparison.OrdinalIgnoreCase) + ? suggestedName + : suggestedName + ".png"; + return Task.FromResult(new DownloadStream(name)); + } + + // No folder picker in the browser; "export each cut to a folder" is a desktop feature. + public Task PickFolderAsync() => Task.FromResult(null); +} + +/// A that downloads its bytes as a file when disposed. +internal sealed partial class DownloadStream : MemoryStream +{ + private readonly string _name; + private bool _saved; + + public DownloadStream(string name) => _name = name; + + protected override void Dispose(bool disposing) + { + // Stream.DisposeAsync() (used by `await using`) calls Dispose(), so this covers both paths. + if (disposing && !_saved && Length > 0) + { + _saved = true; + try { DownloadFile(_name, Convert.ToBase64String(GetBuffer(), 0, (int)Length)); } + catch { /* download unavailable */ } + } + base.Dispose(disposing); + } + + [JSImport("globalThis.crossescpos.downloadFile")] + private static partial void DownloadFile(string name, string base64); +} diff --git a/src/CrossEscPos.App.Browser/BrowserNotificationService.cs b/src/CrossEscPos.App.Browser/BrowserNotificationService.cs new file mode 100644 index 0000000..6cad56d --- /dev/null +++ b/src/CrossEscPos.App.Browser/BrowserNotificationService.cs @@ -0,0 +1,14 @@ +using CrossEscPos.Controls.Services; + +namespace CrossEscPos.App.Browser; + +/// +/// Browser notifications — the on-screen toast (shown by the shared MainViewModel) is the feedback; +/// sound/flash have no clean browser equivalent here, so they are no-ops. +/// +public sealed class BrowserNotificationService : INotificationService +{ + public void NotifyActivity() { } + public void Beep() { } + public void OpenCashDrawer() { } +} diff --git a/src/CrossEscPos.App.Browser/BrowserPlatformServices.cs b/src/CrossEscPos.App.Browser/BrowserPlatformServices.cs new file mode 100644 index 0000000..8632f19 --- /dev/null +++ b/src/CrossEscPos.App.Browser/BrowserPlatformServices.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using Avalonia.Controls; +using CrossEscPos.App; +using CrossEscPos.App.Browser.Monitor; +using CrossEscPos.App.Browser.Transports; +using CrossEscPos.App.Monitor; +using CrossEscPos.App.Transports; +using CrossEscPos.Controls.Services; +using CrossEscPos.Emulator; +using CrossEscPos.Graphics; +using CrossEscPos.Transports.Browser; + +namespace CrossEscPos.App.Browser; + +/// +/// The browser platform: the Skia render backend (Avalonia's browser renderer already links it), +/// export via Avalonia's storage provider (a download), no-op notifications, the Web Serial / WebUSB / +/// SignalR transports, and the in-page Monitor. Injected into the shared , +/// which runs as a single view here. +/// +public sealed class BrowserPlatformServices : IPlatformServices +{ + private readonly RenderBackend _backend = RenderBackend.Select(Array.Empty()); // Skia + private readonly BrowserFileDialogService _dialogs = new(); + private readonly BrowserNotificationService _notifications = new(); + // One bridge instance (its [JSExport] delivery slot is static) shared by the transports + Monitor. + private readonly WasmJsTransportBridge _bridge = new(); + + public IReceiptImageFactory ImageFactory => _backend.ImageFactory; + public ITypefaceProvider Typefaces => _backend.Typefaces; + public IImageEncoder Encoder => _backend.Encoder; + public string BackendName => _backend.Name; + public IFileDialogService FileDialogs => _dialogs; + public INotificationService Notifications => _notifications; + public byte[] SampleTicket => Sample.Ticket; + + public IReadOnlyList CreateTransports(ReceiptPrinter printer) + { + var sink = new BrowserTransportSink(printer); + var serial = new WebTransport(_bridge, sink, "serial", "Web Serial"); + var usb = new WebTransport(_bridge, sink, "usb", "WebUSB"); + var signalr = new SignalRTransport(sink, "SignalR"); + + // Default the proxy to the same origin that served the app (the CrossEscPos.Host). + var origin = WasmJsTransportBridge.PageOrigin(); + var bridgeUrl = (string.IsNullOrEmpty(origin) ? "http://localhost:5000" : origin) + "/bridge"; + signalr.Url = bridgeUrl; + + var baud = new TransportField("Baud", "9600"); + var proxyUrl = new TransportField("Proxy URL", bridgeUrl); + var listenAddress = new TransportField("Listen address", "0.0.0.0"); + var listenPort = new TransportField("Listen port", "9100"); + + return new TransportEntry[] + { + new ReceiptTransportEntry(serial, "Web Serial", new[] { baud }, () => serial.Options = baud.Value), + new ReceiptTransportEntry(usb, "WebUSB"), + new ReceiptTransportEntry(signalr, "TCP proxy (SignalR)", + new[] { proxyUrl, listenAddress, listenPort }, + () => + { + signalr.Url = proxyUrl.Value; + signalr.ListenAddress = listenAddress.Value; + signalr.ListenPort = int.TryParse(listenPort.Value, out var p) ? p : 9100; + }, + autoConnect: true), // comes up on load, like the desktop TCP listener + }; + } + + public IMonitorClient CreateMonitorClient() => new WebMonitorClient(_bridge); + + public bool MonitorInWindow => false; // the browser hosts the Monitor as an in-page overlay + + public void ShowMonitorWindow(MonitorViewModel monitor) { /* unused; browser hosts in-page */ } + + public void AttachRoot(Control mainView) { /* browser downloads via JS — no top level needed */ } + + public Window CreateMainWindow(Control content) + => throw new NotSupportedException("The browser head runs as a single view."); + + public void Shutdown() { } +} diff --git a/src/CrossEscPos.App.Browser/CrossEscPos.App.Browser.csproj b/src/CrossEscPos.App.Browser/CrossEscPos.App.Browser.csproj new file mode 100644 index 0000000..8f3f496 --- /dev/null +++ b/src/CrossEscPos.App.Browser/CrossEscPos.App.Browser.csproj @@ -0,0 +1,29 @@ + + + + net10.0-browser + Exe + + true + + + + + + + + + + + + + + + + + + + + diff --git a/src/CrossEscPos.App.Browser/Monitor/WebMonitorClient.cs b/src/CrossEscPos.App.Browser/Monitor/WebMonitorClient.cs new file mode 100644 index 0000000..ae37b24 --- /dev/null +++ b/src/CrossEscPos.App.Browser/Monitor/WebMonitorClient.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using CrossEscPos.App.Browser.Transports; +using CrossEscPos.App.Monitor; +using CrossEscPos.App.Transports; +using CrossEscPos.Bridge; +using CrossEscPos.Transports.Browser; + +namespace CrossEscPos.App.Browser.Monitor; + +/// +/// The browser Monitor, with the same transports as the desktop one — pick a transport and drive a +/// printer: Network (TCP) (the host dials a real network printer on the browser's behalf through +/// the SignalR proxy), Web Serial, WebUSB, or the SignalR proxy round-trip to the +/// in-page emulator. Serial/USB reuse the shared JS bridge on their own sender channels +/// (mon-serial/mon-usb) so they don't clash with the emulator's connections. +/// +public sealed class WebMonitorClient : IMonitorClient +{ + private const string Proxy = "SignalR proxy", Tcp = "Network (TCP)", Serial = "Web Serial", Usb = "WebUSB"; + + private readonly IJsTransportBridge _bridge; + private readonly string _hubUrl; + private readonly TransportField _url; + private readonly TransportField _host = new("Host", "127.0.0.1"); + private readonly TransportField _port = new("Port", "9100"); + private readonly TransportField _baud = new("Baud", "9600"); + private readonly TransportField _usbDevice = new("Device (VID:PID)", "", new[] { "" }); + + private HubConnection? _hub; + private IBridgeServer? _server; + private string _mode = Proxy; + + public WebMonitorClient(IJsTransportBridge bridge) + { + _bridge = bridge; + _bridge.DataReceived += OnBridgeData; + + var origin = WasmJsTransportBridge.PageOrigin(); + _hubUrl = (string.IsNullOrEmpty(origin) ? "http://localhost:5000" : origin) + "/bridge"; + _url = new TransportField("Proxy URL", _hubUrl); + } + + public IReadOnlyList Modes { get; } = new[] { Proxy, Tcp, Serial, Usb }; + + public string Mode + { + get => _mode; + set + { + if (_mode == value) + return; + _mode = value; + FieldsChanged?.Invoke(); + } + } + + public IReadOnlyList Fields => _mode switch + { + Tcp => new[] { _host, _port }, + Serial => new[] { _baud }, + Usb => new[] { _usbDevice }, + _ => new[] { _url }, + }; + + public bool CanRefresh => _mode == Usb; + + public event Action? FieldsChanged; + public event Action? StatusReceived; + public event Action? Log; + + private bool IsHubMode => _mode is Proxy or Tcp; + private string KindId => _mode == Usb ? "mon-usb" : "mon-serial"; + + public async Task RefreshAsync() + { + if (_mode != Usb) + return; + var current = _usbDevice.Value; + var devices = await _bridge.ListUsbDevicesAsync(); + var options = _usbDevice.Options!; + options.Clear(); + foreach (var d in devices) + options.Add(d); + _usbDevice.Value = current is not null && devices.Contains(current) ? current : devices.FirstOrDefault() ?? ""; + if (devices.Count == 0) + Log?.Invoke("No paired USB devices — click Connect to pair one."); + } + + // GS a n — enable Automatic Status Back (any non-zero n). The printer then pushes a status block on + // every state change; without this the status panel never updates. (The desktop monitor does the same.) + private static readonly byte[] EnableAutoStatusBack = [0x1D, 0x61, 0xFF]; + + public async Task ConnectAsync() + { + string description; + if (IsHubMode) + { + await StartHubAsync(_mode == Proxy ? _url.Value : _hubUrl); + if (_mode == Tcp) + { + int port = int.TryParse(_port.Value, out var p) ? p : 9100; + await _server!.ConnectTcp(_host.Value, port); + description = $"{_host.Value}:{port}"; + } + else + { + description = _url.Value; + } + } + else + { + // Serial: pass the baud; USB: pass the selected device's index (or none → the picker). + string? options = _mode == Serial ? _baud.Value : IndexOf(_usbDevice); + var desc = await _bridge.ConnectAsync(KindId, options); + if (string.IsNullOrEmpty(desc)) + throw new InvalidOperationException("Connection cancelled."); + description = desc; + } + + try { await SendAsync(EnableAutoStatusBack); } + catch { /* status stays best-effort */ } + return description; + } + + private async Task StartHubAsync(string url) + { + var hub = new HubConnectionBuilder().WithUrl(url).WithAutomaticReconnect().Build(); + hub.On(nameof(IBridgeClient.ReceiveStatus), OnStatusBytes); + hub.Closed += _ => { Log?.Invoke("proxy connection closed"); return Task.CompletedTask; }; + await hub.StartAsync(); + _hub = hub; + _server = new BridgeServerProxy(hub); + } + + private string? IndexOf(TransportField dropdown) + { + var options = dropdown.Options; + if (options is null) + return null; + var i = options.IndexOf(dropdown.Value); + return i >= 0 ? i.ToString() : null; // null → the JS side opens the device picker + } + + public async Task SendAsync(byte[] data) + { + if (IsHubMode) + { + var server = _server ?? throw new InvalidOperationException("Not connected."); + await server.SendToEmulator(data); // routed to the emulator, or the TCP printer if one is open + } + else + { + await _bridge.WriteAsync(KindId, data); + } + } + + public void Disconnect() + { + if (IsHubMode) + { + var hub = _hub; + _hub = null; + _server = null; + if (hub is not null) + _ = hub.DisposeAsync(); + } + else + { + _ = _bridge.DisconnectAsync(KindId); + } + } + + private void OnBridgeData(string kind, byte[] data) + { + if (!IsHubMode && kind == KindId) + OnStatusBytes(data); + } + + private void OnStatusBytes(byte[] data) + { + var status = MonitorStatus.FromAutoStatusBack(data); + if (status is not null) + StatusReceived?.Invoke(status); + } +} diff --git a/src/CrossEscPos.App.Browser/Program.cs b/src/CrossEscPos.App.Browser/Program.cs new file mode 100644 index 0000000..0acbaa4 --- /dev/null +++ b/src/CrossEscPos.App.Browser/Program.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Browser; + +namespace CrossEscPos.App.Browser; + +internal sealed partial class Program +{ + private static Task Main(string[] args) + { + // Compose the browser platform (Skia backend, download export, web transports) and hand it to + // the shared app, which runs here as a single view. + CrossEscPos.App.App.Platform = new BrowserPlatformServices(); + return BuildAvaloniaApp() + .WithInterFont() + .StartBrowserAppAsync("out"); + } + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure(); +} diff --git a/src/CrossEscPos.App.Browser/Properties/launchSettings.json b/src/CrossEscPos.App.Browser/Properties/launchSettings.json new file mode 100644 index 0000000..6f02844 --- /dev/null +++ b/src/CrossEscPos.App.Browser/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "CrossEscPos.App.Browser": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:5210", + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}" + } + } +} diff --git a/src/CrossEscPos.App.Browser/Transports/BrowserTransportSink.cs b/src/CrossEscPos.App.Browser/Transports/BrowserTransportSink.cs new file mode 100644 index 0000000..e75202f --- /dev/null +++ b/src/CrossEscPos.App.Browser/Transports/BrowserTransportSink.cs @@ -0,0 +1,17 @@ +using CrossEscPos; +using CrossEscPos.Emulator; +using CrossEscPos.Transports.Browser; + +namespace CrossEscPos.App.Browser.Transports; + +/// Feeds live transport bytes into the shared printer; the MainViewModel refreshes on activity. +public sealed class BrowserTransportSink : ITransportSink +{ + private readonly ReceiptPrinter _printer; + + public BrowserTransportSink(ReceiptPrinter printer) => _printer = printer; + + public void Feed(byte[] data, IPrinterResponder responder) => _printer.FeedEscPos(data, responder); + public void Attach(IPrinterResponder responder) => _printer.RegisterResponder(responder); + public void Detach(IPrinterResponder responder) => _printer.UnregisterResponder(responder); +} diff --git a/src/CrossEscPos.App.Browser/Transports/ReceiptTransportEntry.cs b/src/CrossEscPos.App.Browser/Transports/ReceiptTransportEntry.cs new file mode 100644 index 0000000..032dd05 --- /dev/null +++ b/src/CrossEscPos.App.Browser/Transports/ReceiptTransportEntry.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CrossEscPos.App.Transports; +using CrossEscPos.Transports.Browser; + +namespace CrossEscPos.App.Browser.Transports; + +/// +/// A that wraps any browser (Web Serial, +/// WebUSB, SignalR proxy) so it renders in the shared connections view. Its s +/// are copied onto the transport via right before connecting. +/// +public sealed class ReceiptTransportEntry : TransportEntry +{ + private readonly IReceiptTransport _transport; + private readonly Action? _applyBeforeConnect; + private readonly bool _autoConnect; + private bool _supported = true; + + /// + /// Connect on startup without a user gesture. Only valid for gesture-free transports (the SignalR + /// proxy) — so the browser's TCP bridge comes up automatically, matching the desktop TCP listener. + /// + public ReceiptTransportEntry(IReceiptTransport transport, string name, + IReadOnlyList? fields = null, Action? applyBeforeConnect = null, bool autoConnect = false) : base(name) + { + _transport = transport; + _applyBeforeConnect = applyBeforeConnect; + _autoConnect = autoConnect; + if (fields is not null) + Fields = fields; + _transport.StateChanged += OnStateChanged; + _ = InitAsync(); + } + + private async Task InitAsync() + { + _supported = await _transport.IsSupportedAsync(); + OnStateChanged(); + + // Bring the proxy up on its own so POS software can reach the emulator over TCP immediately — + // the browser equivalent of the desktop's auto-started TCP listener. Fails quietly if the host + // isn't serving the hub (e.g. the app is served standalone). + if (_autoConnect && _supported && !_transport.IsConnected) + { + _applyBeforeConnect?.Invoke(); + try { await _transport.ConnectAsync(); } + catch { /* hub unreachable — the entry just shows "not connected" */ } + } + } + + private void OnStateChanged() + => Set(_transport.IsConnected, + _transport.IsConnected ? (_transport.Description ?? "connected") + : !_supported ? "unsupported" + // A description while disconnected is a connect error (e.g. "port in use"). + : _transport.Description ?? "not connected"); + + protected override Task ToggleAsync() + { + if (_transport.IsConnected) + return _transport.DisconnectAsync(); + _applyBeforeConnect?.Invoke(); + return _transport.ConnectAsync(); // opens the device picker (user gesture) + } + + public override void Shutdown() => _ = _transport.DisposeAsync(); +} diff --git a/src/CrossEscPos.App.Browser/Transports/WasmJsTransportBridge.cs b/src/CrossEscPos.App.Browser/Transports/WasmJsTransportBridge.cs new file mode 100644 index 0000000..e32ef4d --- /dev/null +++ b/src/CrossEscPos.App.Browser/Transports/WasmJsTransportBridge.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices.JavaScript; +using System.Threading.Tasks; +using CrossEscPos.Transports.Browser; + +namespace CrossEscPos.App.Browser.Transports; + +/// +/// Avalonia WASM implementation of — bridges the shared +/// transports.js over [JSImport]/[JSExport] (the raw browser interop Avalonia uses). +/// Outbound calls target the globalThis.crossescpos.* functions; inbound delivery arrives at the +/// / exports (wired to the JS callback slots in +/// main.js). Bytes cross as base64 so the interop marshaling stays trivial. +/// +public sealed partial class WasmJsTransportBridge : IJsTransportBridge +{ + private static WasmJsTransportBridge? _instance; + + public event Action? DataReceived; + public event Action? Closed; + + public WasmJsTransportBridge() => _instance = this; + + public ValueTask IsSupportedAsync(string kind) => ValueTask.FromResult(IsSupported(kind)); + + public async ValueTask ConnectAsync(string kind, string? options) + { + var description = await Connect(kind, options ?? string.Empty); + return string.IsNullOrEmpty(description) ? null : description; + } + + public ValueTask WriteAsync(string kind, byte[] data) => new(Write(kind, Convert.ToBase64String(data))); + + public ValueTask DisconnectAsync(string kind) => new(Disconnect(kind)); + + public async ValueTask> ListUsbDevicesAsync() + { + var joined = await ListUsb(); + return string.IsNullOrEmpty(joined) ? Array.Empty() : joined.Split('\n'); + } + + /// The page origin (e.g. http://localhost:5000) that served the app. + public static string PageOrigin() + { + try { return Origin(); } + catch { return string.Empty; } + } + + // JS -> .NET delivery (base64). Wired from main.js. + [JSExport] + internal static void DeliverData(string kind, string base64) + => _instance?.DataReceived?.Invoke(kind, Convert.FromBase64String(base64)); + + [JSExport] + internal static void DeliverClosed(string kind) + => _instance?.Closed?.Invoke(kind); + + // .NET -> JS (globals defined by transports.js; window === globalThis on the browser main thread). + [JSImport("globalThis.crossescpos.isSupported")] + private static partial bool IsSupported(string kind); + + [JSImport("globalThis.crossescpos.connect")] + private static partial Task Connect(string kind, string options); + + [JSImport("globalThis.crossescpos.write")] + private static partial Task Write(string kind, string base64); + + [JSImport("globalThis.crossescpos.disconnect")] + private static partial Task Disconnect(string kind); + + [JSImport("globalThis.crossescpos.origin")] + private static partial string Origin(); + + [JSImport("globalThis.crossescpos.listUsb")] + private static partial Task ListUsb(); +} diff --git a/src/CrossEscPos.App.Browser/wwwroot/app.css b/src/CrossEscPos.App.Browser/wwwroot/app.css new file mode 100644 index 0000000..2f3dc54 --- /dev/null +++ b/src/CrossEscPos.App.Browser/wwwroot/app.css @@ -0,0 +1,12 @@ +html, body { + margin: 0; + padding: 0; + overflow: hidden; + height: 100%; + background: #262626; + font-family: sans-serif; +} + +#out { + height: 100vh; +} diff --git a/src/CrossEscPos.App.Browser/wwwroot/index.html b/src/CrossEscPos.App.Browser/wwwroot/index.html new file mode 100644 index 0000000..ac6b6ec --- /dev/null +++ b/src/CrossEscPos.App.Browser/wwwroot/index.html @@ -0,0 +1,15 @@ + + + + + + CrossEscPos Emulator — WASM + + + +
+ + + + + diff --git a/src/CrossEscPos.App.Browser/wwwroot/main.js b/src/CrossEscPos.App.Browser/wwwroot/main.js new file mode 100644 index 0000000..47eded0 --- /dev/null +++ b/src/CrossEscPos.App.Browser/wwwroot/main.js @@ -0,0 +1,30 @@ +import { dotnet } from './_framework/dotnet.js' + +const is_browser = typeof window != "undefined"; +if (!is_browser) throw new Error(`Expected to be running in a browser`); + +const dotnetRuntime = await dotnet + .withDiagnosticTracing(false) + .withApplicationArgumentsFromQuery() + .create(); + +const config = dotnetRuntime.getConfig(); + +// Wire the shared transports.js delivery slots to the app's [JSExport] callbacks. transports.js loads +// as a classic script first, so globalThis.crossescpos already exists; we override the no-op slots. +try { + const exports = await dotnetRuntime.getAssemblyExports(config.mainAssemblyName); + const bridge = exports.CrossEscPos.App.Browser.Transports.WasmJsTransportBridge; + const cx = (globalThis.crossescpos = globalThis.crossescpos || {}); + const toBase64 = (u8) => { + let s = ''; + for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); + return btoa(s); + }; + cx.onData = (kind, u8) => bridge.DeliverData(kind, toBase64(u8)); + cx.onClosed = (kind) => bridge.DeliverClosed(kind); +} catch (e) { + console.warn('CrossEscPos: transport callbacks not wired', e); +} + +await dotnetRuntime.runMain(config.mainAssemblyName, [globalThis.location.href]); diff --git a/src/CrossEscPos.App.Desktop/Assets/Icon/icon-256.png b/src/CrossEscPos.App.Desktop/Assets/Icon/icon-256.png new file mode 100644 index 0000000..4e5703b Binary files /dev/null and b/src/CrossEscPos.App.Desktop/Assets/Icon/icon-256.png differ diff --git a/src/CrossEscPos.App.Desktop/Assets/Icon/icon.ico b/src/CrossEscPos.App.Desktop/Assets/Icon/icon.ico new file mode 100644 index 0000000..0ff1268 Binary files /dev/null and b/src/CrossEscPos.App.Desktop/Assets/Icon/icon.ico differ diff --git a/src/CrossEscPos.App.Desktop/Assets/Icon/icon.png b/src/CrossEscPos.App.Desktop/Assets/Icon/icon.png new file mode 100644 index 0000000..c0ad858 Binary files /dev/null and b/src/CrossEscPos.App.Desktop/Assets/Icon/icon.png differ diff --git a/src/CrossEscPos.App.Desktop/CrossEscPos.App.Desktop.csproj b/src/CrossEscPos.App.Desktop/CrossEscPos.App.Desktop.csproj new file mode 100644 index 0000000..559969b --- /dev/null +++ b/src/CrossEscPos.App.Desktop/CrossEscPos.App.Desktop.csproj @@ -0,0 +1,31 @@ + + + + WinExe + true + true + Assets/Icon/icon.ico + CrossEscPos.App.Desktop + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CrossEscPos.App.Desktop/DesktopPlatformServices.cs b/src/CrossEscPos.App.Desktop/DesktopPlatformServices.cs new file mode 100644 index 0000000..1d6237c --- /dev/null +++ b/src/CrossEscPos.App.Desktop/DesktopPlatformServices.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using Avalonia.Controls; +using CrossEscPos.App; +using CrossEscPos.App.Desktop.Monitor; +using CrossEscPos.App.Desktop.Transports; +using CrossEscPos.App.Desktop.Views; +using CrossEscPos.App.Monitor; +using CrossEscPos.App.Transports; +using CrossEscPos.Controls.Services; +using CrossEscPos.Emulator; +using CrossEscPos.Graphics; + +namespace CrossEscPos.App.Desktop; + +/// +/// The desktop platform: native file dialogs + sound/flash notifications, TCP + serial transports, and +/// the Monitor test-client window. Injected into the shared . +/// +public sealed class DesktopPlatformServices : IPlatformServices +{ + private readonly RenderBackend _backend; + private readonly FileDialogService _dialogs = new(); + private readonly NotificationService _notifications = new(); + private TcpTransportEntry? _tcp; + private MonitorWindow? _monitorWindow; + + public DesktopPlatformServices(RenderBackend backend) => _backend = backend; + + public IReceiptImageFactory ImageFactory => _backend.ImageFactory; + public ITypefaceProvider Typefaces => _backend.Typefaces; + public IImageEncoder Encoder => _backend.Encoder; + public string BackendName => _backend.Name; + public IFileDialogService FileDialogs => _dialogs; + public INotificationService Notifications => _notifications; + public byte[] SampleTicket => Sample.Ticket; + + public IReadOnlyList CreateTransports(ReceiptPrinter printer) + { + var (tcpEnabled, tcpPort) = ReadTcpSettings(); + var serialPort = Environment.GetEnvironmentVariable("ESCPOS_SERIAL_PORT"); + int serialBaud = int.TryParse(Environment.GetEnvironmentVariable("ESCPOS_SERIAL_BAUD"), out var b) ? b : 9600; + var listenAddress = Environment.GetEnvironmentVariable("ESCPOS_LISTEN_ADDRESS") ?? "0.0.0.0"; + + _tcp = new TcpTransportEntry(printer, listenAddress, tcpPort, tcpEnabled); + var serial = new SerialTransportEntry(printer, + string.IsNullOrWhiteSpace(serialPort) ? null : serialPort, serialBaud, autoStart: serialPort is not null); + return new TransportEntry[] { _tcp, serial }; + } + + public IMonitorClient CreateMonitorClient() => new DesktopMonitorClient(_tcp?.CurrentPort ?? 9100); + + public bool MonitorInWindow => true; + + public void ShowMonitorWindow(MonitorViewModel monitor) + { + if (_monitorWindow is not null) + { + _monitorWindow.Activate(); + return; + } + _monitorWindow = new MonitorWindow { DataContext = monitor }; + _monitorWindow.Closed += (_, _) => _monitorWindow = null; + _monitorWindow.Show(); + } + + public void AttachRoot(Control mainView) => _dialogs.AttachControl(mainView); + + public Window CreateMainWindow(Control content) + { + var window = new MainWindow + { + Content = content, + Title = $"ESC/POS Receipt Printer Emulator • Render: {_backend.Name}" + }; + _notifications.AttachWindow(window); + _dialogs.AttachTopLevel(window); + return window; + } + + public void Shutdown() { /* transports are stopped by MainViewModel.Shutdown */ } + + private static (bool enabled, int port) ReadTcpSettings() + { + var setting = Environment.GetEnvironmentVariable("ESCPOS_TCP_PORT"); + if (setting is "off" or "none" or "disabled" or "0") + return (false, 9100); + int port = int.TryParse(setting, out var p) && p > 0 ? p : 9100; + return (true, port); + } +} diff --git a/src/CrossEscPos.App.Desktop/Monitor/DesktopMonitorClient.cs b/src/CrossEscPos.App.Desktop/Monitor/DesktopMonitorClient.cs new file mode 100644 index 0000000..0c3d7a3 --- /dev/null +++ b/src/CrossEscPos.App.Desktop/Monitor/DesktopMonitorClient.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.IO.Ports; +using System.Linq; +using System.Threading.Tasks; +using ESCPOS_NET; +using ESCPOS_NET.Emitters; +using CrossEscPos.App.Monitor; +using CrossEscPos.App.Transports; +using CrossEscPos.Transports; + +namespace CrossEscPos.App.Desktop.Monitor; + +/// +/// Desktop : connects to the emulator/printer over TCP, serial, or USB using +/// ESC-POS-.NET's family. Enables Automatic Status Back on connect and maps the +/// library's parsed onto the shared . +/// +public sealed class DesktopMonitorClient : IMonitorClient +{ + private const string Tcp = "TCP", Serial = "Serial", Usb = "USB"; + + private readonly EPSON _e = new(); + private BasePrinter? _printer; + + // TCP fields. + private readonly TransportField _host = new("Host", "127.0.0.1"); + private readonly TransportField _port; + // Serial fields. + private readonly TransportField _serialPort = new("Port", "", new[] { "" }); + private readonly TransportField _baud = new("Baud", "9600"); + // USB fields. + private readonly TransportField _usbDevice = new("Device", "", new[] { "" }); + private readonly Dictionary _usbByDisplay = new(); + + private string _mode = Tcp; + + public DesktopMonitorClient(int defaultPort) + { + _port = new TransportField("Port", defaultPort.ToString()); + RefreshPorts(); + } + + public IReadOnlyList Modes { get; } = new[] { Tcp, Serial, Usb }; + + public string Mode + { + get => _mode; + set + { + if (_mode == value) + return; + _mode = value; + if (_mode == Usb) + RefreshUsb(); + FieldsChanged?.Invoke(); + } + } + + public IReadOnlyList Fields => _mode switch + { + Serial => new[] { _serialPort, _baud }, + Usb => new[] { _usbDevice }, + _ => new[] { _host, _port }, + }; + + public bool CanRefresh => _mode is Serial or Usb; + + public event Action? FieldsChanged; + public event Action? StatusReceived; + public event Action? Log; + + public Task RefreshAsync() + { + if (_mode == Serial) RefreshPorts(); + else if (_mode == Usb) RefreshUsb(); + return Task.CompletedTask; + } + + private void RefreshPorts() + { + var current = _serialPort.Value; + var names = Array.Empty(); + try { names = SerialPort.GetPortNames().OrderBy(n => n).ToArray(); } + catch (Exception ex) { Log?.Invoke($"could not list serial ports: {ex.Message}"); } + SetOptions(_serialPort, names, current); + } + + private void RefreshUsb() + { + var current = _usbDevice.Value; + _usbByDisplay.Clear(); + try + { + foreach (var d in UsbPrinter.ListDevices()) + _usbByDisplay[d.Display] = d; + } + catch (Exception ex) + { + if (IsLibusbMissing(ex)) AppendLibusbHelp(); + else Log?.Invoke($"USB list failed: {ex.Message}"); + } + SetOptions(_usbDevice, _usbByDisplay.Keys.ToArray(), current); + } + + private static void SetOptions(TransportField field, string[] options, string? keep) + { + var opts = field.Options!; // dropdown fields are constructed with a non-null Options collection + opts.Clear(); + foreach (var o in options) + opts.Add(o); + field.Value = keep is not null && options.Contains(keep) ? keep : options.FirstOrDefault() ?? ""; + } + + public Task ConnectAsync() + { + string target; + switch (_mode) + { + case Usb: + if (!_usbByDisplay.TryGetValue(_usbDevice.Value ?? "", out var dev)) + throw new InvalidOperationException("No USB device selected."); + _printer = new UsbPrinter(dev.Vid, dev.Pid); + target = dev.Display; + break; + + case Serial: + if (string.IsNullOrWhiteSpace(_serialPort.Value)) + throw new InvalidOperationException("No serial port selected."); + int baud = int.TryParse(_baud.Value, out var b) && b > 0 ? b : 9600; + _printer = new SerialPrinter(portName: _serialPort.Value, baudRate: baud); + target = $"serial {_serialPort.Value} @ {baud}"; + break; + + default: // TCP + _printer = new NetworkPrinter(new NetworkPrinterSettings + { + ConnectionString = $"{_host.Value}:{_port.Value}", + PrinterName = "Monitor" + }); + target = $"{_host.Value}:{_port.Value}"; + break; + } + + try + { + _printer.StatusChanged += OnStatusChanged; + // Ask the emulator to push status on every state change (panel toggles show up here). + _printer.Write(_e.EnableAutomaticStatusBack()); + } + catch (Exception ex) + { + if (_mode == Usb && IsLibusbMissing(ex)) + AppendLibusbHelp(); + Disconnect(); + throw; + } + + return Task.FromResult(target); + } + + public Task SendAsync(byte[] data) + { + var printer = _printer ?? throw new InvalidOperationException("Not connected."); + return Task.Run(() => printer.Write(data)); + } + + public void Disconnect() + { + try + { + if (_printer is not null) + { + _printer.StatusChanged -= OnStatusChanged; + _printer.Dispose(); + } + } + catch { /* ignore */ } + _printer = null; + } + + private void OnStatusChanged(object? sender, EventArgs e) + { + if (e is not PrinterStatusEventArgs s) + return; + StatusReceived?.Invoke(new MonitorStatus( + Online: s.IsPrinterOnline == true, + PaperOut: s.IsPaperOut == true, + PaperLow: s.IsPaperLow == true, + CoverOpen: s.IsCoverOpen == true, + DrawerOpen: s.IsCashDrawerOpen == true, + Error: s.IsInErrorState == true)); + } + + /// True when an exception was caused by libusb not being loadable. + private static bool IsLibusbMissing(Exception ex) + { + for (var e = ex; e is not null; e = e.InnerException) + if (e is DllNotFoundException || e.Message.Contains("libusb", StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } + + private void AppendLibusbHelp() + { + string install = OperatingSystem.IsMacOS() + ? "brew install libusb" + : OperatingSystem.IsLinux() + ? "sudo apt install libusb-1.0-0 (Debian/Ubuntu) — or your distro's libusb-1.0 package" + : "libusb ships with the app on Windows; reinstall the app if it's missing"; + Log?.Invoke("Could not find libusb (the native USB library) — USB printing is unavailable.\n" + + $" Install it: {install}\n" + + " Then click the ⟳ button to refresh the USB device list."); + } +} diff --git a/src/CrossEscPos.App.Desktop/Program.cs b/src/CrossEscPos.App.Desktop/Program.cs new file mode 100644 index 0000000..7c5d269 --- /dev/null +++ b/src/CrossEscPos.App.Desktop/Program.cs @@ -0,0 +1,34 @@ +using Avalonia; +using CrossEscPos.App; +using CrossEscPos.Logging; + +namespace CrossEscPos.App.Desktop; + +public static class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [System.STAThread] + public static void Main(string[] args) + { + // Register process-wide exception logging before anything else so startup failures and + // background-thread crashes (e.g. transport teardown) are recorded rather than lost. + Logger.InstallGlobalHandlers(); + + // Compose the desktop platform (render backend, native dialogs/notifications, TCP+serial + // transports, Monitor) and hand it to the shared app. + var backend = RenderBackend.Select(args); + Logger.Info($"Render backend: {backend.Name}"); + CrossEscPos.App.App.Platform = new DesktopPlatformServices(backend); + + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} diff --git a/src/CrossEscPos.App.Desktop/Services/NotificationService.cs b/src/CrossEscPos.App.Desktop/Services/NotificationService.cs new file mode 100644 index 0000000..412bc34 --- /dev/null +++ b/src/CrossEscPos.App.Desktop/Services/NotificationService.cs @@ -0,0 +1,123 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Threading.Tasks; +using Avalonia.Controls; +using CrossEscPos.Logging; + +namespace CrossEscPos.Controls.Services; + +/// +/// Default notification service. Plays a best-effort sound for buzzer / cash-drawer events +/// (afplay on macOS, Console.Beep on Windows, paplay/aplay/terminal-bell on Linux) and flashes the +/// taskbar on Windows. The primary, always-visible feedback is the on-screen toast shown by the view +/// model — sound here is supplementary since the machine may be muted. +/// +public class NotificationService : INotificationService +{ + private Window? _window; + + public void AttachWindow(Window window) => _window = window; + + public void NotifyActivity() => Flash(); + + public void Beep() + { + Logger.Info("Buzzer"); + PlaySound(isDrawer: false); + Flash(); + } + + public void OpenCashDrawer() + { + Logger.Info("Cash drawer kick"); + PlaySound(isDrawer: true); + Flash(); + } + + private static void PlaySound(bool isDrawer) + { + try + { + if (OperatingSystem.IsMacOS()) + { + // Built-in macOS system sounds. + var sound = isDrawer ? "/System/Library/Sounds/Funk.aiff" + : "/System/Library/Sounds/Glass.aiff"; + if (!TryStart("/usr/bin/afplay", sound)) + TryStart("/usr/bin/osascript", "-e", "beep"); + } + else if (OperatingSystem.IsWindows()) + { + // Console.Beep is synchronous — run it off the UI thread. The platform guard is + // repeated inside the lambda so the analyzer sees it at the call site (CA1416). + Task.Run(() => + { + if (!OperatingSystem.IsWindows()) return; + try + { + Console.Beep(isDrawer ? 600 : 1000, 180); + if (isDrawer) Console.Beep(900, 180); + } + catch { /* ignore */ } + }); + } + else // Linux / other Unix + { + if (!TryStart("canberra-gtk-play", "-i", isDrawer ? "message" : "bell") && + !TryStart("paplay", "/usr/share/sounds/freedesktop/stereo/complete.oga") && + !TryStart("aplay", "-q", "/usr/share/sounds/alsa/Front_Center.wav")) + { + Console.Out.Write('\a'); + Console.Out.Flush(); + } + } + } + catch (Exception ex) + { + Logger.Exception(ex, "Failed to play notification sound"); + } + } + + private static bool TryStart(string fileName, params string[] args) + { + try + { + var psi = new ProcessStartInfo + { + FileName = fileName, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + foreach (var a in args) + psi.ArgumentList.Add(a); + + return Process.Start(psi) is not null; + } + catch + { + return false; // tool not installed — caller falls back + } + } + + private void Flash() + { + if (OperatingSystem.IsWindows()) + FlashOnWindows(); + } + + [SupportedOSPlatform("windows")] + private void FlashOnWindows() + { + var handle = _window?.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; + if (handle != IntPtr.Zero) + FlashWindow(handle, true); + } + + [SupportedOSPlatform("windows")] + [DllImport("user32")] + private static extern int FlashWindow(IntPtr hwnd, bool bInvert); +} diff --git a/src/CrossEscPos.App.Desktop/Transports/SerialTransportEntry.cs b/src/CrossEscPos.App.Desktop/Transports/SerialTransportEntry.cs new file mode 100644 index 0000000..b78ad23 --- /dev/null +++ b/src/CrossEscPos.App.Desktop/Transports/SerialTransportEntry.cs @@ -0,0 +1,66 @@ +using System; +using System.IO.Ports; +using System.Linq; +using System.Threading.Tasks; +using CrossEscPos.App.Transports; +using CrossEscPos.Emulator; +using CrossEscPos.Transports; + +namespace CrossEscPos.App.Desktop.Transports; + +/// The desktop serial reader (RS-232 / USB-serial), refreshable port list. +public sealed class SerialTransportEntry : TransportEntry +{ + private readonly SerialServer _serial; + private readonly TransportField _port; + private readonly TransportField _baud; + + public SerialTransportEntry(ReceiptPrinter printer, string? port, int baud, bool autoStart) : base("Serial") + { + _serial = new SerialServer(printer); + var ports = Ports(); + _port = new TransportField("Port", port ?? (ports.Length > 0 ? ports[0] : string.Empty), ports); + _baud = new TransportField("Baud rate", baud.ToString()); + Fields = new[] { _port, _baud }; + Set(false, "Serial: closed"); + if (autoStart) + StartCore(); + } + + public override bool CanRefresh => true; + public override string ButtonText => IsActive ? "Close" : "Open"; + + protected override Task ToggleAsync() + { + if (_serial.IsRunning) { _serial.Stop(); Set(false, "Serial: closed"); } + else StartCore(); + return Task.CompletedTask; + } + + private void StartCore() + { + if (string.IsNullOrWhiteSpace(_port.Value)) { Status = "No port selected"; return; } + int baud = int.TryParse(_baud.Value, out var b) && b > 0 ? b : 9600; + _serial.Start(_port.Value, baud); + Set(true, $"{_serial.PortName} @ {_serial.BaudRate} (open)"); + } + + protected override Task RefreshAsync() + { + var options = _port.Options!; + var current = _port.Value; + options.Clear(); + foreach (var name in Ports()) + options.Add(name); + _port.Value = current is not null && options.Contains(current) ? current : options.FirstOrDefault() ?? string.Empty; + return Task.CompletedTask; + } + + public override void Shutdown() => _serial.Stop(); + + private static string[] Ports() + { + try { return SerialPort.GetPortNames().OrderBy(n => n).ToArray(); } + catch { return Array.Empty(); } + } +} diff --git a/src/CrossEscPos.App.Desktop/Transports/TcpTransportEntry.cs b/src/CrossEscPos.App.Desktop/Transports/TcpTransportEntry.cs new file mode 100644 index 0000000..2477212 --- /dev/null +++ b/src/CrossEscPos.App.Desktop/Transports/TcpTransportEntry.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using CrossEscPos.App.Transports; +using CrossEscPos.Emulator; +using CrossEscPos.Logging; +using CrossEscPos.Transports; + +namespace CrossEscPos.App.Desktop.Transports; + +/// The desktop TCP/IP listener (a network receipt printer on port 9100 by convention). +public sealed class TcpTransportEntry : TransportEntry +{ + private readonly NetServer _tcp; + private readonly TransportField _address; + private readonly TransportField _port; + + public TcpTransportEntry(ReceiptPrinter printer, string address, int port, bool autoStart) : base("TCP/IP") + { + _tcp = new NetServer(printer); + _address = new TransportField("Listen address", address, Addresses(address)); + _port = new TransportField("Port", port.ToString()); + Fields = new[] { _address, _port }; + Set(false, "TCP stopped"); + if (autoStart) + StartCore(); + } + + public override string ButtonText => IsActive ? "Stop" : "Start"; + + public int CurrentPort => int.TryParse(_port.Value, out var p) ? p : 9100; + + protected override Task ToggleAsync() + { + if (_tcp.IsRunning) { _tcp.Stop(); Set(false, "TCP stopped"); } + else StartCore(); + return Task.CompletedTask; + } + + private void StartCore() + { + if (!IPAddress.TryParse(_address.Value, out var addr)) { Status = $"Invalid address '{_address.Value}'"; return; } + if (!int.TryParse(_port.Value, out var port) || port is < 1 or > 65535) { Status = $"Invalid port '{_port.Value}'"; return; } + _tcp.Start(addr, port); + Set(true, $"Listening on {_tcp.EndPoint}"); + } + + public override void Shutdown() => _tcp.Stop(); + + private static string[] Addresses(string preferred) + { + var list = new List { "0.0.0.0", "127.0.0.1" }; + try + { + foreach (var ip in Dns.GetHostAddresses(Dns.GetHostName())) + if (ip.AddressFamily == AddressFamily.InterNetwork && !list.Contains(ip.ToString())) + list.Add(ip.ToString()); + } + catch (Exception ex) { Logger.Exception(ex, "Failed to enumerate local addresses"); } + if (!string.IsNullOrWhiteSpace(preferred) && !list.Contains(preferred)) + list.Add(preferred); + return list.ToArray(); + } +} diff --git a/src/CrossEscPos.App.Desktop/Views/MainWindow.axaml b/src/CrossEscPos.App.Desktop/Views/MainWindow.axaml new file mode 100644 index 0000000..4910f34 --- /dev/null +++ b/src/CrossEscPos.App.Desktop/Views/MainWindow.axaml @@ -0,0 +1,9 @@ + + + diff --git a/src/CrossEscPos.App.Desktop/Views/MainWindow.axaml.cs b/src/CrossEscPos.App.Desktop/Views/MainWindow.axaml.cs new file mode 100644 index 0000000..12881c2 --- /dev/null +++ b/src/CrossEscPos.App.Desktop/Views/MainWindow.axaml.cs @@ -0,0 +1,10 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace CrossEscPos.App.Desktop.Views; + +/// Thin desktop window shell; its content is the shared MainView. +public partial class MainWindow : Window +{ + public MainWindow() => AvaloniaXamlLoader.Load(this); +} diff --git a/src/CrossEscPos.App.Desktop/Views/MonitorWindow.axaml b/src/CrossEscPos.App.Desktop/Views/MonitorWindow.axaml new file mode 100644 index 0000000..3f3bf24 --- /dev/null +++ b/src/CrossEscPos.App.Desktop/Views/MonitorWindow.axaml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/src/CrossEscPos.App.Desktop/Views/MonitorWindow.axaml.cs b/src/CrossEscPos.App.Desktop/Views/MonitorWindow.axaml.cs new file mode 100644 index 0000000..b20500b --- /dev/null +++ b/src/CrossEscPos.App.Desktop/Views/MonitorWindow.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace CrossEscPos.App.Desktop.Views; + +/// Desktop window that hosts the shared . +public partial class MonitorWindow : Window +{ + public MonitorWindow() => InitializeComponent(); + + private void InitializeComponent() => AvaloniaXamlLoader.Load(this); +} diff --git a/src/CrossEscPos.App/App.axaml b/src/CrossEscPos.App/App.axaml new file mode 100644 index 0000000..5258f88 --- /dev/null +++ b/src/CrossEscPos.App/App.axaml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/src/CrossEscPos.App/App.axaml.cs b/src/CrossEscPos.App/App.axaml.cs new file mode 100644 index 0000000..9f6d69a --- /dev/null +++ b/src/CrossEscPos.App/App.axaml.cs @@ -0,0 +1,53 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using CrossEscPos.App.ViewModels; +using CrossEscPos.App.Views; +using CrossEscPos.Emulator; + +namespace CrossEscPos.App; + +/// +/// The shared Avalonia application, reused by the Desktop and Browser heads. It composes the headless +/// emulator with the head's and shows the shared +/// in whichever lifetime the head runs (a desktop Window or a browser single view). +/// +public partial class App : Application +{ + /// Set by the head's Program before the app starts. + public static IPlatformServices Platform { get; set; } = null!; + + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + var platform = Platform ?? throw new InvalidOperationException( + "App.Platform must be set by the head before startup."); + + var printer = new ReceiptPrinter(PaperConfiguration.Default, platform.ImageFactory, platform.Typefaces); + // Transports fire events from background threads (desktop) — marshal to the UI thread. + printer.UiDispatch = a => Dispatcher.UIThread.Post(a); + + var viewModel = new MainViewModel(printer, platform); + var mainView = new MainView { DataContext = viewModel }; + platform.AttachRoot(mainView); + + switch (ApplicationLifetime) + { + case IClassicDesktopStyleApplicationLifetime desktop: + var window = platform.CreateMainWindow(mainView); + desktop.MainWindow = window; + desktop.ShutdownRequested += (_, _) => { viewModel.Shutdown(); platform.Shutdown(); }; + break; + + case ISingleViewApplicationLifetime singleView: + singleView.MainView = mainView; + break; + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/src/CrossEscPos.App/CrossEscPos.App.csproj b/src/CrossEscPos.App/CrossEscPos.App.csproj new file mode 100644 index 0000000..50b59b1 --- /dev/null +++ b/src/CrossEscPos.App/CrossEscPos.App.csproj @@ -0,0 +1,41 @@ + + + + net10.0 + + false + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CrossEscPos.App/IPlatformServices.cs b/src/CrossEscPos.App/IPlatformServices.cs new file mode 100644 index 0000000..6ca4f35 --- /dev/null +++ b/src/CrossEscPos.App/IPlatformServices.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using Avalonia.Controls; +using CrossEscPos.Controls.Services; +using CrossEscPos.Emulator; +using CrossEscPos.Graphics; + +namespace CrossEscPos.App; + +/// +/// Everything the shared / needs from the host +/// platform. The Desktop and Browser heads implement this; the rest of the app (rendering, receipts, +/// printer state, export, notifications, the paste/upload input) is shared and platform-agnostic. +/// +public interface IPlatformServices +{ + // Render backend (chosen by the head — Skia on desktop, Skia in the Avalonia browser). + IReceiptImageFactory ImageFactory { get; } + ITypefaceProvider Typefaces { get; } + IImageEncoder Encoder { get; } + string BackendName { get; } + + // Shared services (Avalonia's implementations are cross-platform — export via IStorageProvider even + // triggers a browser download). + IFileDialogService FileDialogs { get; } + INotificationService Notifications { get; } + + /// The bundled sample ESC/POS ticket (text + barcodes + QR), or empty if none. + byte[] SampleTicket { get; } + + /// + /// The platform's transports, wired to — desktop yields TCP + serial; + /// the browser yields Web Serial + WebUSB + SignalR. The shared connections view renders them all. + /// + IReadOnlyList CreateTransports(ReceiptPrinter printer); + + /// + /// Creates the platform's Monitor transport (desktop: TCP/serial/USB over ESC-POS-.NET; browser: + /// SignalR to the host's proxy hub), or null if the platform has no Monitor. The shared Monitor view + /// + test-job generation are platform-agnostic. + /// + Monitor.IMonitorClient? CreateMonitorClient(); + + /// Whether the Monitor opens in its own window (desktop) or an in-page panel (browser). + bool MonitorInWindow { get; } + + /// Opens the Monitor in a platform window (desktop only; unused when it hosts in-page). + void ShowMonitorWindow(Monitor.MonitorViewModel monitor); + + /// + /// Called with the shared main view once created, so the platform can wire top-level-dependent + /// services (e.g. the export dialog resolves its TopLevel from this control). + /// + void AttachRoot(Control mainView); + + /// Wraps the shared main view in the platform's top-level window (desktop only). + Window CreateMainWindow(Control content); + + /// Called on application shutdown so the head can stop transports, etc. + void Shutdown(); +} diff --git a/src/CrossEscPos.App/Monitor/IMonitorClient.cs b/src/CrossEscPos.App/Monitor/IMonitorClient.cs new file mode 100644 index 0000000..fa19431 --- /dev/null +++ b/src/CrossEscPos.App/Monitor/IMonitorClient.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CrossEscPos.App.Transports; + +namespace CrossEscPos.App.Monitor; + +/// +/// The transport the shared uses to reach the emulator/printer. Desktop +/// implements TCP / serial / USB over ESC-POS-.NET; the browser implements a single SignalR mode that +/// rides the host's proxy hub to the in-page emulator. The view model owns the test-job generation and the +/// status/indicator UI; the client only carries bytes and surfaces the status the emulator sends back. +/// +public interface IMonitorClient +{ + /// Selectable transport modes (e.g. TCP / Serial / USB, or a single "SignalR proxy"). + IReadOnlyList Modes { get; } + + /// The current mode; setting it swaps and raises . + string Mode { get; set; } + + /// Connection fields for the current mode (host/port, serial port + baud, proxy URL, …). + IReadOnlyList Fields { get; } + + /// Raised when changes (mode switch or a device/port refresh). + event Action? FieldsChanged; + + /// Whether the current mode can re-enumerate devices (serial ports / USB). + bool CanRefresh { get; } + + /// Re-enumerate devices for the current mode. + Task RefreshAsync(); + + /// Connects using the current mode + fields; returns a human-readable target. Throws on failure. + Task ConnectAsync(); + + /// Sends an ESC/POS job to the printer. + Task SendAsync(byte[] data); + + /// Closes the connection (idempotent). + void Disconnect(); + + /// Raised when the emulator reports status back (ASB / parsed). + event Action? StatusReceived; + + /// Raised for connection/transport log lines to append to the activity log. + event Action? Log; +} diff --git a/src/CrossEscPos.App/Monitor/MonitorStatus.cs b/src/CrossEscPos.App/Monitor/MonitorStatus.cs new file mode 100644 index 0000000..7e2fb5c --- /dev/null +++ b/src/CrossEscPos.App/Monitor/MonitorStatus.cs @@ -0,0 +1,40 @@ +namespace CrossEscPos.App.Monitor; + +/// +/// A platform-neutral snapshot of the printer status the emulator reports back. The desktop client maps +/// ESC-POS-.NET's parsed status onto this; the browser client parses the raw 4-byte Automatic Status Back +/// block itself (see ), so both drive the same indicator UI. +/// +public sealed record MonitorStatus( + bool Online, + bool PaperOut, + bool PaperLow, + bool CoverOpen, + bool DrawerOpen, + bool Error) +{ + public bool Ready => Online && !PaperOut && !CoverOpen && !Error; + + /// + /// Reverses StatusByteBuilder.AutoStatusBack: interprets the emulator's 4-byte ASB block. + /// Returns null when the buffer isn't a 4-byte block (e.g. an unrelated response). + /// + public static MonitorStatus? FromAutoStatusBack(byte[] data) + { + // The emulator emits ASB as discrete 4-byte frames; if several arrive coalesced, read the last. + if (data is null || data.Length < 4 || data.Length % 4 != 0) + return null; + + int off = data.Length - 4; + byte b0 = data[off], b1 = data[off + 1], b2 = data[off + 2]; + + bool drawerOpen = (b0 & 0x04) == 0; // bit2 SET = drawer closed + bool online = (b0 & 0x08) == 0; // bit3 SET = offline + bool coverOpen = (b0 & 0x20) != 0; // bit5 = cover open + bool error = (b1 & 0x60) != 0; // bits5,6 = recoverable / unrecoverable error + bool paperOut = (b2 & 0x0C) != 0; // bits2,3 = paper end + bool paperLow = !paperOut && (b2 & 0x03) != 0; // bits0,1 = near-end + + return new MonitorStatus(online, paperOut, paperLow, coverOpen, drawerOpen, error); + } +} diff --git a/src/CrossEscPos.App/Monitor/MonitorViewModel.cs b/src/CrossEscPos.App/Monitor/MonitorViewModel.cs new file mode 100644 index 0000000..1b9ab01 --- /dev/null +++ b/src/CrossEscPos.App/Monitor/MonitorViewModel.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Reflection; +using System.Threading.Tasks; +using Avalonia.Media; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ESCPOS_NET.Emitters; +using ESCPOS_NET.Utilities; +using CrossEscPos.App.Transports; +using CrossEscPos.Emulator.Rendering; + +namespace CrossEscPos.App.Monitor; + +/// One status row: a colored dot, a label, and the current value. +public sealed class StatusIndicator +{ + public StatusIndicator(string label, string value, IBrush color) + { + Label = label; + Value = value; + Color = color; + } + + public string Label { get; } + public string Value { get; } + public IBrush Color { get; } +} + +/// +/// The shared Monitor: a POS-client that connects to the emulator (over TCP/serial/USB on desktop, or the +/// SignalR proxy in the browser), sends ESC/POS test jobs built with ESC-POS-.NET, and shows the printer +/// status the emulator reports back. Transport is delegated to an injected so +/// the same view model, test jobs, and indicator UI run identically on both heads. +/// +public partial class MonitorViewModel : ObservableObject +{ + private const byte Bel = 0x07; // bell / buzzer control code + private const int DefaultModuleSize = 5; // dots per module for 2D symbols + + private readonly EPSON _e = new(); + private readonly IMonitorClient _client; + + [ObservableProperty] private bool _isConnected; + [ObservableProperty] private string _connectButtonText = "Connect"; + [ObservableProperty] private string _statusText = "Not connected."; + [ObservableProperty] private bool _canRefresh; + + public IReadOnlyList Modes => _client.Modes; + public bool HasModes => _client.Modes.Count > 1; + + public string Mode + { + get => _client.Mode; + set + { + if (_client.Mode == value) + return; + _client.Mode = value; + OnPropertyChanged(); + } + } + + public IReadOnlyList Fields => _client.Fields; + + public ObservableCollection Log { get; } = new(); + public ObservableCollection Indicators { get; } = new(); + + // Fully qualified: ESCPOS_NET.Emitters also defines a Color type. + private static readonly IBrush Green = new SolidColorBrush(Avalonia.Media.Color.Parse("#3CE07A")); + private static readonly IBrush Red = new SolidColorBrush(Avalonia.Media.Color.Parse("#E0533C")); + private static readonly IBrush Amber = new SolidColorBrush(Avalonia.Media.Color.Parse("#E0A93C")); + private static readonly IBrush Blue = new SolidColorBrush(Avalonia.Media.Color.Parse("#3C9AE0")); + private static readonly IBrush Gray = new SolidColorBrush(Avalonia.Media.Color.Parse("#777777")); + + public MonitorViewModel(IMonitorClient client) + { + _client = client; + _client.Log += Append; + _client.StatusReceived += OnStatusReceived; + _client.FieldsChanged += OnFieldsChanged; + _canRefresh = _client.CanRefresh; + } + + private void OnFieldsChanged() => Dispatcher.UIThread.Post(() => + { + OnPropertyChanged(nameof(Fields)); + CanRefresh = _client.CanRefresh; + }); + + [RelayCommand] + private async Task Refresh() + { + try { await _client.RefreshAsync(); } + catch (Exception ex) { Append($"refresh failed: {ex.Message}"); } + OnFieldsChanged(); + } + + private void Append(string message) => Dispatcher.UIThread.Post(() => + { + Log.Insert(0, message); + if (Log.Count > 200) Log.RemoveAt(Log.Count - 1); + }); + + #region Connection + + [RelayCommand] + private async Task ToggleConnect() + { + if (IsConnected) { Disconnect(); return; } + + try + { + var target = await _client.ConnectAsync(); + IsConnected = true; + ConnectButtonText = "Disconnect"; + StatusText = "Monitoring… toggle the Printer state panel to see updates."; + Append($"Connected ({target})"); + } + catch (Exception ex) + { + Append($"connect failed: {ex.Message}"); + Disconnect(); + } + } + + private void Disconnect() + { + try { _client.Disconnect(); } + catch { /* ignore */ } + IsConnected = false; + ConnectButtonText = "Connect"; + StatusText = "Not connected."; + Indicators.Clear(); + Append("Disconnected"); + } + + private void OnStatusReceived(MonitorStatus s) => Dispatcher.UIThread.Post(() => + { + StatusText = s.Ready ? "● Ready" : "● Not ready"; + Indicators.Clear(); + Indicators.Add(new("Printer", s.Online ? "Online" : "Offline", s.Online ? Green : Red)); + Indicators.Add(new("Paper", s.PaperOut ? "Out" : s.PaperLow ? "Low" : "OK", + s.PaperOut ? Red : s.PaperLow ? Amber : Green)); + Indicators.Add(new("Cover", s.CoverOpen ? "Open" : "Closed", s.CoverOpen ? Red : Green)); + Indicators.Add(new("Cash drawer", s.DrawerOpen ? "Open" : "Closed", s.DrawerOpen ? Blue : Gray)); + Indicators.Add(new("Error", s.Error ? "Yes" : "None", s.Error ? Red : Green)); + Append("← status update"); + }); + + #endregion + + #region Send commands + + private async Task Send(string label, params byte[][] parts) + { + try + { + await _client.SendAsync(ByteSplicer.Combine(parts)); + Append($"→ {label}"); + } + catch (Exception ex) + { + Append($"send failed: {ex.Message}"); + } + } + + [RelayCommand] + private async Task SendSample() => await Send("sample receipt", + _e.CenterAlign(), + _e.SetStyles(PrintStyle.Bold | PrintStyle.DoubleWidth | PrintStyle.DoubleHeight), + _e.PrintLine("MONITOR"), + _e.SetStyles(PrintStyle.None), + _e.PrintLine("Sent via ESC-POS-.NET"), + _e.LeftAlign(), + _e.PrintLine("Item ................ 9.99"), + _e.SetStyles(PrintStyle.Bold), + _e.PrintLine("TOTAL ............... 9.99"), + _e.SetStyles(PrintStyle.None), + _e.FeedLines(1), + _e.CenterAlign(), + _e.PrintQRCode("https://example.com/monitor"), + _e.FeedLines(2), + _e.PartialCut()); + + [RelayCommand] + private async Task SendBarcodes() + { + var parts = new List + { + _e.CenterAlign(), + _e.SetBarcodeHeightInDots(80), + _e.SetBarWidth(BarWidth.Default), + _e.SetBarLabelPosition(BarLabelPrintPosition.Below) + }; + void Bc(BarcodeType t, string data, string label) + { + parts.Add(_e.PrintLine(label)); + parts.Add(_e.PrintBarcode(t, data)); + parts.Add(_e.FeedLines(1)); + } + Bc(BarcodeType.UPC_A, "12345678901", "UPC-A"); + Bc(BarcodeType.JAN13_EAN13, "123456789012", "EAN-13"); + Bc(BarcodeType.CODE39, "CODE39", "CODE39"); + Bc(BarcodeType.CODE128, "Code128", "CODE128"); + Bc(BarcodeType.ITF, "12345678", "ITF"); + parts.Add(_e.PartialCut()); + await Send("all barcodes", parts.ToArray()); + } + + [RelayCommand] + private async Task SendQr() => await Send("QR + PDF417 + DataMatrix + Aztec", + _e.CenterAlign(), + _e.PrintLine("QR"), + _e.PrintQRCode("https://example.com"), + _e.PrintLine("PDF417"), + _e.Print2DCode(TwoDimensionCodeType.PDF417, "PDF417 from monitor"), + // DataMatrix / Aztec aren't in the ESC-POS-.NET emitter, so send the raw GS ( k bytes — + // the emulator supports them (cn=54 / cn=55). + _e.PrintLine("DataMatrix"), + Raw2D(TwoDimensionCode.DataMatrix.Value, "DataMatrix from monitor", DefaultModuleSize), + _e.PrintLine("Aztec"), + Raw2D(TwoDimensionCode.Aztec.Value, "Aztec from monitor", DefaultModuleSize), + _e.FeedLines(1), + _e.PartialCut()); + + /// Builds raw GS ( k bytes for a 2D symbol the emitter doesn't expose (DataMatrix/Aztec). + private static byte[] Raw2D(int cn, string data, int moduleSize) + { + const int GS = 0x1D, ParenK0 = '(', ParenK1 = 'k'; + const int FnModuleSize = 67, FnStore = 80, FnPrint = 81, StoreM = 48; + const int HeaderLen = 3; // cn + fn + m + + var b = new List(); + void By(params int[] xs) { foreach (var x in xs) b.Add((byte)x); } + + By(GS, ParenK0, ParenK1, HeaderLen, 0, cn, FnModuleSize, moduleSize); + int len = HeaderLen + data.Length; + By(GS, ParenK0, ParenK1, len & 0xFF, len >> 8, cn, FnStore, StoreM); + b.AddRange(System.Text.Encoding.ASCII.GetBytes(data)); + By(GS, ParenK0, ParenK1, HeaderLen, 0, cn, FnPrint, StoreM); + return b.ToArray(); + } + + [RelayCommand] + private async Task OpenDrawer() => await Send("open cash drawer", _e.CashDrawerOpenPin2()); + + [RelayCommand] + private async Task Beep() => await Send("buzzer (BEL)", [Bel]); + + [RelayCommand] + private async Task Cut() => await Send("cut", _e.PartialCut()); + + [RelayCommand] + private async Task SendFullTest() + { + // Embedded in the shared app so it works on both heads (desktop and browser). + using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("test_receipt.txt"); + if (stream is null) { Append("test_receipt.txt not found"); return; } + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + await Send("full feature test receipt", ms.ToArray()); + } + + #endregion + + public void Shutdown() => Disconnect(); +} diff --git a/src/CrossEscPos.App/RenderBackend.cs b/src/CrossEscPos.App/RenderBackend.cs new file mode 100644 index 0000000..70d64a6 --- /dev/null +++ b/src/CrossEscPos.App/RenderBackend.cs @@ -0,0 +1,56 @@ +using System; +using CrossEscPos.Graphics; +using CrossEscPos.Rendering.ImageSharp; +using CrossEscPos.Rendering.Skia; + +namespace CrossEscPos.App; + +/// +/// Selects the render backend at startup so the two rendering libraries can be A/B tested visually. +/// +/// Choose with a command-line argument — --backend imagesharp (or --backend=imagesharp, +/// or -b imagesharp) — or the ESCPOS_RENDER_BACKEND environment variable. Accepted values +/// are skia (default) and imagesharp. Anything unrecognised falls back to Skia. +/// +public sealed record RenderBackend( + string Name, + IReceiptImageFactory ImageFactory, + ITypefaceProvider Typefaces, + IImageEncoder Encoder) +{ + public static RenderBackend Select(string[] args) => Resolve(args) switch + { + "imagesharp" => new RenderBackend( + "ImageSharp (managed)", + new ImageSharpImageFactory(), + new ImageSharpTypefaceProvider(), + new ImageSharpImageEncoder()), + _ => new RenderBackend( + "SkiaSharp", + new SkiaImageFactory(), + new SkiaTypefaceProvider(), + new SkiaImageEncoder()), + }; + + // CLI wins over the environment variable; both are normalised the same way. + private static string Resolve(string[] args) + { + for (int i = 0; i < args.Length; i++) + { + var a = args[i]; + if ((a.Equals("--backend", StringComparison.OrdinalIgnoreCase) || a.Equals("-b", StringComparison.OrdinalIgnoreCase)) + && i + 1 < args.Length) + return Normalize(args[i + 1]); + if (a.StartsWith("--backend=", StringComparison.OrdinalIgnoreCase)) + return Normalize(a["--backend=".Length..]); + } + + return Normalize(Environment.GetEnvironmentVariable("ESCPOS_RENDER_BACKEND")); + } + + private static string Normalize(string? value) => value?.Trim().ToLowerInvariant() switch + { + "imagesharp" or "managed" or "is" => "imagesharp", + _ => "skia", + }; +} diff --git a/src/CrossEscPos.App/Sample.cs b/src/CrossEscPos.App/Sample.cs new file mode 100644 index 0000000..6fe2a27 --- /dev/null +++ b/src/CrossEscPos.App/Sample.cs @@ -0,0 +1,20 @@ +using System; +using System.IO; + +namespace CrossEscPos.App; + +/// The bundled sample ESC/POS ticket (text + barcodes + QR), embedded in the shared app. +public static class Sample +{ + public static byte[] Ticket { get; } = Load(); + + private static byte[] Load() + { + using var stream = typeof(Sample).Assembly.GetManifestResourceStream("sample.escpos"); + if (stream is null) + return Array.Empty(); + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + return buffer.ToArray(); + } +} diff --git a/src/CrossEscPos.App/Services/FileDialogService.cs b/src/CrossEscPos.App/Services/FileDialogService.cs new file mode 100644 index 0000000..0841b65 --- /dev/null +++ b/src/CrossEscPos.App/Services/FileDialogService.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Platform.Storage; + +namespace CrossEscPos.Controls.Services; + +/// +/// backed by Avalonia's — cross-platform, +/// so on the browser head the same "save PNG" path becomes a download. The top level is resolved either +/// from an explicit window (desktop) or lazily from a control (browser single-view). +/// +public class FileDialogService : IFileDialogService +{ + private TopLevel? _topLevel; + private Control? _control; + + public void AttachTopLevel(TopLevel topLevel) => _topLevel = topLevel; + + /// Attach a control; the top level is resolved from it at call time (browser single-view). + public void AttachControl(Control control) => _control = control; + + private IStorageProvider? Provider => + (_topLevel ?? (_control is null ? null : TopLevel.GetTopLevel(_control)))?.StorageProvider; + + public async Task SavePngAsync(string suggestedName) + { + var provider = Provider; + if (provider is null) + return null; + + var file = await provider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Export receipt", + SuggestedFileName = suggestedName, + DefaultExtension = "png", + FileTypeChoices = new List + { + new("PNG image") { Patterns = new[] { "*.png" } } + } + }); + + if (file is null) + return null; + + return await file.OpenWriteAsync(); + } + + public async Task PickFolderAsync() + { + var provider = Provider; + if (provider is null) + return null; + + var folders = await provider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = "Choose export folder", + AllowMultiple = false + }); + + if (folders is null || folders.Count == 0) + return null; + + return folders[0].TryGetLocalPath(); + } +} diff --git a/src/CrossEscPos.App/Transports/TransportEntry.cs b/src/CrossEscPos.App/Transports/TransportEntry.cs new file mode 100644 index 0000000..3947a74 --- /dev/null +++ b/src/CrossEscPos.App/Transports/TransportEntry.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace CrossEscPos.App.Transports; + +/// +/// A platform-agnostic, self-describing transport shown in the shared connections view: a name, a +/// status line, some configurable s, and a toggle action. The desktop +/// (TCP, serial) and browser (Web Serial, WebUSB, WebSocket/SignalR) transports all subclass this, so +/// one shared view renders them uniformly. +/// +public abstract partial class TransportEntry : ObservableObject +{ + protected TransportEntry(string name) => Name = name; + + public string Name { get; } + + [ObservableProperty] private string _status = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ButtonText))] + private bool _isActive; + + /// Connect/Disconnect by default; Start/Stop or Open/Close for the desktop server transports. + public virtual string ButtonText => IsActive ? "Disconnect" : "Connect"; + + public IReadOnlyList Fields { get; protected set; } = Array.Empty(); + + /// True to show a refresh button (e.g. re-enumerate serial ports). + public virtual bool CanRefresh => false; + + [RelayCommand] private Task Toggle() => ToggleAsync(); + [RelayCommand] private Task Refresh() => RefreshAsync(); + + protected abstract Task ToggleAsync(); + protected virtual Task RefreshAsync() => Task.CompletedTask; + + protected void Set(bool active, string status) + { + IsActive = active; + Status = status; + } + + /// Stop/disconnect on shutdown. + public virtual void Shutdown() { } +} diff --git a/src/CrossEscPos.App/Transports/TransportField.cs b/src/CrossEscPos.App/Transports/TransportField.cs new file mode 100644 index 0000000..a5ac6c7 --- /dev/null +++ b/src/CrossEscPos.App/Transports/TransportField.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace CrossEscPos.App.Transports; + +/// +/// One configurable field of a transport (e.g. a TCP port, a serial baud rate, a WebSocket URL). The +/// shared connections view renders it as a text box, or a combo box when is set. +/// +public sealed partial class TransportField : ObservableObject +{ + public TransportField(string label, string value, IEnumerable? options = null) + { + Label = label; + _value = value; + Options = options is null ? null : new ObservableCollection(options); + } + + public string Label { get; } + + [ObservableProperty] + private string _value; + + /// Non-null for a dropdown field; mutable so it can be refreshed (e.g. serial ports). + public ObservableCollection? Options { get; } + + public bool IsDropdown => Options is not null; +} diff --git a/src/CrossEscPos.App/ViewModels/MainViewModel.cs b/src/CrossEscPos.App/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..1d9f96a --- /dev/null +++ b/src/CrossEscPos.App/ViewModels/MainViewModel.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CrossEscPos.Controls; +using CrossEscPos.Controls.Services; +using CrossEscPos.Emulator; +using CrossEscPos.Emulator.Rendering; +using CrossEscPos.Graphics; +using CrossEscPos.App.Monitor; + +namespace CrossEscPos.App.ViewModels; + +/// +/// The shared main view model, reused by both heads. It owns the platform-agnostic features — the +/// paste/upload ESC/POS input, the rendered receipts, printer-state simulation, export, and the +/// buzzer/cash-drawer toast — and hosts the platform's transport UI via . +/// +public partial class MainViewModel : ObservableObject +{ + private const string DefaultInput = + "*** CrossEscPos ***\n" + + "Type or paste ESC/POS here\n" + + "and press Render, or connect\n" + + "a device on the left.\n" + + "\n" + + "Item $ 4.50\n" + + "Tax $ 0.36\n" + + "TOTAL $ 4.86\n" + + "\n" + + "Thank you!\n"; + + private static readonly TimeSpan ToastDuration = TimeSpan.FromSeconds(3.5); + + private readonly ReceiptPrinter _printer; + private readonly IPlatformServices _platform; + private readonly IImageEncoder _encoder; + private readonly IFileDialogService _dialogs; + private readonly INotificationService _notifications; + private readonly DispatcherTimer _toastTimer; + private readonly Dictionary _receiptsById = new(); + + public ObservableCollection Receipts { get; } = new(); + public bool HasReceipts => Receipts.Count > 0; + public PrinterState State => _printer.State; + + /// The platform's transports, rendered uniformly by the shared connections view. + public IReadOnlyList Transports { get; } + + /// The shared Monitor test-client, or null if the platform has no Monitor. + public MonitorViewModel? Monitor { get; } + public bool SupportsMonitor => Monitor is not null; + public string BackendName => _platform.BackendName; + + [ObservableProperty] private string _input = DefaultInput; + [ObservableProperty] private string _toastMessage = string.Empty; + [ObservableProperty] private bool _toastVisible; + /// Browser only: shows the Monitor as an in-page overlay (desktop uses a window instead). + [ObservableProperty] private bool _isMonitorOpen; + + /// Raised on the UI thread after receipts change (the view scrolls to the newest). + public event EventHandler? ReceiptsUpdated; + + public MainViewModel(ReceiptPrinter printer, IPlatformServices platform) + { + _printer = printer; + _platform = platform; + _encoder = platform.Encoder; + _dialogs = platform.FileDialogs; + _notifications = platform.Notifications; + + _toastTimer = new DispatcherTimer { Interval = ToastDuration }; + _toastTimer.Tick += (_, _) => { _toastTimer.Stop(); ToastVisible = false; }; + + // Transport receive threads raise these — marshal to the UI thread. + _printer.OnActivityEvent += (_, _) => Dispatcher.UIThread.Post(() => { RefreshReceipts(); _notifications.NotifyActivity(); }); + _printer.OnBuzzer += () => Dispatcher.UIThread.Post(() => { _notifications.Beep(); ShowToast("🔔 Buzzer"); }); + _printer.OnCashDrawer += () => Dispatcher.UIThread.Post(() => { _notifications.OpenCashDrawer(); ShowToast("💵 Cash drawer opened"); }); + _printer.OnPrintBlocked += reason => Dispatcher.UIThread.Post(() => ShowToast($"🚫 {reason} — print dropped")); + + Transports = platform.CreateTransports(printer); + + if (platform.CreateMonitorClient() is { } monitorClient) + Monitor = new MonitorViewModel(monitorClient); + + Render(); // render the default input so the app shows something on launch + } + + [RelayCommand] + private void Render() + { + ResetPrinter(); + _printer.FeedEscPos(Encoding.Latin1.GetBytes(Input)); + RefreshReceipts(); + } + + [RelayCommand] + private void LoadSample() + { + var sample = _platform.SampleTicket; + if (sample.Length == 0) + return; + ResetPrinter(); + _printer.FeedEscPos(sample); + RefreshReceipts(); + } + + [RelayCommand] + private void Clear() + { + ResetPrinter(); + RefreshReceipts(); + } + + [RelayCommand] + private void OpenMonitor() + { + if (Monitor is null) + return; + if (_platform.MonitorInWindow) + _platform.ShowMonitorWindow(Monitor); + else + IsMonitorOpen = true; // in-page overlay (browser) + } + + [RelayCommand] + private void CloseMonitor() => IsMonitorOpen = false; + + private void ResetPrinter() + { + _printer.ReceiptStack.Clear(); + _printer.Initialize(); + _printer.StartNewReceipt(); + _receiptsById.Clear(); + Receipts.Clear(); + } + + #region Export + + private List NonEmptyReceipts() => _printer.ReceiptStack.Where(r => !r.IsEmpty).ToList(); + + [RelayCommand(CanExecute = nameof(HasReceipts))] + private async Task ExportAll() + { + var receipts = NonEmptyReceipts(); + if (receipts.Count == 0) + return; + + var bitmaps = receipts.Select(r => r.Render()).ToList(); + try + { + using var combined = ReceiptExporter.StackVertical(bitmaps, _printer.ImageFactory); + var stream = await _dialogs.SavePngAsync("receipts"); + if (stream is null) + return; + await using (stream) + _encoder.EncodePng(combined, stream); + } + finally + { + foreach (var b in bitmaps) + b.Dispose(); + } + } + + [RelayCommand(CanExecute = nameof(HasReceipts))] + private async Task ExportEach() + { + var receipts = NonEmptyReceipts(); + if (receipts.Count == 0) + return; + + var folder = await _dialogs.PickFolderAsync(); + if (folder is null) + return; + + for (int i = 0; i < receipts.Count; i++) + { + using var bmp = receipts[i].Render(); + var path = Path.Combine(folder, $"receipt_{i + 1:D3}.png"); + await using var fs = File.Create(path); + _encoder.EncodePng(bmp, fs); + } + } + + #endregion + + /// Called on shutdown — stop every transport. + public void Shutdown() + { + Monitor?.Shutdown(); + foreach (var transport in Transports) + transport.Shutdown(); + } + + private void ShowToast(string message) + { + ToastMessage = message; + ToastVisible = true; + _toastTimer.Stop(); + _toastTimer.Start(); + } + + private void RefreshReceipts() + { + foreach (var receipt in _printer.ReceiptStack) + { + if (receipt.IsEmpty) + continue; + + if (_receiptsById.TryGetValue(receipt.Guid, out var vm)) + { + vm.Refresh(); + } + else + { + vm = new ReceiptViewModel(receipt, _encoder, _dialogs, Receipts.Count + 1); + _receiptsById[receipt.Guid] = vm; + Receipts.Add(vm); + } + } + + OnPropertyChanged(nameof(HasReceipts)); + ExportAllCommand.NotifyCanExecuteChanged(); + ExportEachCommand.NotifyCanExecuteChanged(); + ReceiptsUpdated?.Invoke(this, EventArgs.Empty); + } +} diff --git a/src/CrossEscPos.App/Views/MainView.axaml b/src/CrossEscPos.App/Views/MainView.axaml new file mode 100644 index 0000000..7796fdf --- /dev/null +++ b/src/CrossEscPos.App/Views/MainView.axaml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + +