diff --git a/agent/Meezi.PrintAgent/AgentConfig.cs b/agent/Meezi.PrintAgent/AgentConfig.cs
new file mode 100644
index 0000000..c44bfb9
--- /dev/null
+++ b/agent/Meezi.PrintAgent/AgentConfig.cs
@@ -0,0 +1,40 @@
+using System.Text.Json;
+
+namespace Meezi.PrintAgent;
+
+/// Persisted agent identity — written to %APPDATA%\MeeziPrintAgent\config.json.
+public class AgentConfig
+{
+ /// Origin of the Meezi API, e.g. https://app.meezi.ir.
+ public string? ApiBaseUrl { get; set; }
+ public string? Token { get; set; }
+ public string? CafeId { get; set; }
+ public string? AgentId { get; set; }
+ public string? Name { get; set; }
+
+ private static string Dir =>
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MeeziPrintAgent");
+ private static string FilePath => Path.Combine(Dir, "config.json");
+
+ public bool IsPaired => !string.IsNullOrWhiteSpace(Token) && !string.IsNullOrWhiteSpace(ApiBaseUrl);
+
+ public static AgentConfig Load()
+ {
+ try
+ {
+ if (File.Exists(FilePath))
+ return JsonSerializer.Deserialize(File.ReadAllText(FilePath)) ?? new AgentConfig();
+ }
+ catch
+ {
+ // corrupt/unreadable config → start fresh
+ }
+ return new AgentConfig();
+ }
+
+ public void Save()
+ {
+ Directory.CreateDirectory(Dir);
+ File.WriteAllText(FilePath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
+ }
+}
diff --git a/agent/Meezi.PrintAgent/Meezi.PrintAgent.csproj b/agent/Meezi.PrintAgent/Meezi.PrintAgent.csproj
new file mode 100644
index 0000000..610f970
--- /dev/null
+++ b/agent/Meezi.PrintAgent/Meezi.PrintAgent.csproj
@@ -0,0 +1,23 @@
+
+
+
+ Exe
+
+ net10.0-windows
+ false
+ enable
+ enable
+ Meezi.PrintAgent
+ MeeziPrintAgent
+ 0.1.0
+ true
+
+
+
+
+
+
+
+
diff --git a/agent/Meezi.PrintAgent/Pairing.cs b/agent/Meezi.PrintAgent/Pairing.cs
new file mode 100644
index 0000000..73cb85f
--- /dev/null
+++ b/agent/Meezi.PrintAgent/Pairing.cs
@@ -0,0 +1,43 @@
+using System.Net.Http.Json;
+
+namespace Meezi.PrintAgent;
+
+/// Redeems a one-time pairing code for a long-lived agent token.
+public static class Pairing
+{
+ private record ClaimReq(string code, string? name, string? machineName);
+ private record ApiEnvelope(bool success, T? data);
+ private record ClaimData(string agentId, string token, string cafeId, string agentName);
+
+ public static async Task ClaimAsync(string apiBaseUrl, string code, string name)
+ {
+ using var http = new HttpClient { BaseAddress = new Uri(apiBaseUrl), Timeout = TimeSpan.FromSeconds(20) };
+ HttpResponseMessage resp;
+ try
+ {
+ resp = await http.PostAsJsonAsync("/api/print-agent/claim",
+ new ClaimReq(code, name, Environment.MachineName));
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($" network error: {ex.Message}");
+ return null;
+ }
+
+ if (!resp.IsSuccessStatusCode)
+ return null;
+
+ var env = await resp.Content.ReadFromJsonAsync>();
+ if (env?.success != true || env.data is null)
+ return null;
+
+ return new AgentConfig
+ {
+ ApiBaseUrl = apiBaseUrl.TrimEnd('/'),
+ Token = env.data.token,
+ CafeId = env.data.cafeId,
+ AgentId = env.data.agentId,
+ Name = env.data.agentName,
+ };
+ }
+}
diff --git a/agent/Meezi.PrintAgent/PrinterDiscovery.cs b/agent/Meezi.PrintAgent/PrinterDiscovery.cs
new file mode 100644
index 0000000..1be87b1
--- /dev/null
+++ b/agent/Meezi.PrintAgent/PrinterDiscovery.cs
@@ -0,0 +1,45 @@
+using System.Management;
+using System.Runtime.Versioning;
+
+namespace Meezi.PrintAgent;
+
+/// One printer the agent can reach. SystemName is what it prints to (the
+/// Windows printer name, or "ip:port" for a raw network device).
+public record DiscoveredPrinter(string SystemName, string DisplayName, string Kind);
+
+[SupportedOSPlatform("windows")]
+public static class PrinterDiscovery
+{
+ /// Every printer installed on this PC (USB and network-with-driver alike).
+ public static List Discover()
+ {
+ var list = new List();
+ try
+ {
+ using var searcher = new ManagementObjectSearcher(
+ "SELECT Name, PortName, Network FROM Win32_Printer");
+ foreach (var o in searcher.Get())
+ {
+ using var p = (ManagementObject)o;
+ var name = p["Name"]?.ToString();
+ if (string.IsNullOrWhiteSpace(name)) continue;
+ var port = p["PortName"]?.ToString() ?? "";
+ var network = p["Network"] as bool? ?? false;
+ list.Add(new DiscoveredPrinter(name!, name!, ClassifyKind(port, network)));
+ }
+ }
+ catch
+ {
+ // WMI unavailable — report nothing rather than crash.
+ }
+ return list;
+ }
+
+ private static string ClassifyKind(string port, bool network)
+ {
+ var up = port.ToUpperInvariant();
+ if (up.StartsWith("USB") || up.StartsWith("DOT4")) return "usb";
+ if (network || up.StartsWith("IP_") || up.StartsWith("WSD") || up.Contains(':')) return "network";
+ return "other";
+ }
+}
diff --git a/agent/Meezi.PrintAgent/Program.cs b/agent/Meezi.PrintAgent/Program.cs
new file mode 100644
index 0000000..7dfa2a1
--- /dev/null
+++ b/agent/Meezi.PrintAgent/Program.cs
@@ -0,0 +1,143 @@
+using Meezi.PrintAgent;
+using Microsoft.AspNetCore.SignalR.Client;
+
+Console.OutputEncoding = System.Text.Encoding.UTF8;
+Console.WriteLine("=== Meezi Print Agent (پرینتسرور میزی) ===");
+
+var config = AgentConfig.Load();
+var wantsPair = args.Length > 0 && args[0].Equals("pair", StringComparison.OrdinalIgnoreCase);
+
+if (!config.IsPaired || wantsPair)
+{
+ var paired = await PairInteractiveAsync(config);
+ if (paired is null)
+ {
+ Console.WriteLine("Pairing cancelled or failed.");
+ return 1;
+ }
+ paired.Save();
+ config = paired;
+ Console.WriteLine($"✓ Paired as '{config.Name}'. Configuration saved.");
+}
+
+await RunAsync(config);
+return 0;
+
+static async Task PairInteractiveAsync(AgentConfig existing)
+{
+ var defaultUrl = existing.ApiBaseUrl ?? "https://app.meezi.ir";
+ Console.Write($"Meezi API URL [{defaultUrl}]: ");
+ var url = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(url)) url = defaultUrl;
+
+ Console.Write("Pairing code (from Dashboard → Settings → Printers): ");
+ var code = Console.ReadLine()?.Trim();
+ if (string.IsNullOrWhiteSpace(code)) return null;
+
+ Console.Write($"Name for this PC [{Environment.MachineName}]: ");
+ var name = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(name)) name = Environment.MachineName;
+
+ Console.WriteLine("Pairing…");
+ var cfg = await Pairing.ClaimAsync(url!, code!, name!);
+ if (cfg is null) Console.WriteLine(" Invalid/expired code, or the URL is wrong.");
+ return cfg;
+}
+
+static async Task RunAsync(AgentConfig config)
+{
+ var hubUrl = $"{config.ApiBaseUrl!.TrimEnd('/')}/hubs/print-agent" +
+ $"?access_token={Uri.EscapeDataString(config.Token!)}";
+
+ var connection = new HubConnectionBuilder()
+ .WithUrl(hubUrl)
+ .WithAutomaticReconnect(new ForeverRetry())
+ .Build();
+
+ connection.On("PrintJob", async (jobId, printerSystemName, base64) =>
+ {
+ var ok = false;
+ string? err = null;
+ try
+ {
+ var data = Convert.FromBase64String(base64);
+ await RawPrinter.PrintAsync(printerSystemName, data, CancellationToken.None);
+ ok = true;
+ Console.WriteLine($"[print] {data.Length} bytes → {printerSystemName} ✓");
+ }
+ catch (Exception ex)
+ {
+ err = ex.Message;
+ Console.WriteLine($"[print] {printerSystemName} ✗ {ex.Message}");
+ }
+ try { await connection.InvokeAsync("JobResult", jobId, ok, err); } catch { /* ack best-effort */ }
+ });
+
+ connection.Reconnected += async _ =>
+ {
+ Console.WriteLine("[hub] reconnected");
+ await SafeReportAsync(connection);
+ };
+ connection.Closed += _ =>
+ {
+ Console.WriteLine("[hub] connection closed");
+ return Task.CompletedTask;
+ };
+
+ await ConnectWithRetryAsync(connection);
+ Console.WriteLine("[hub] connected");
+ await SafeReportAsync(connection);
+
+ // Heartbeat + re-report every 2 minutes (printers added/removed get picked up).
+ _ = Task.Run(async () =>
+ {
+ using var timer = new PeriodicTimer(TimeSpan.FromMinutes(2));
+ while (await timer.WaitForNextTickAsync())
+ {
+ try
+ {
+ await connection.InvokeAsync("Heartbeat");
+ await SafeReportAsync(connection);
+ }
+ catch { /* will recover on reconnect */ }
+ }
+ });
+
+ Console.WriteLine("Agent running. Leave this window open. Press Ctrl+C to quit.");
+ await Task.Delay(Timeout.Infinite);
+}
+
+static async Task SafeReportAsync(HubConnection connection)
+{
+ try
+ {
+ var printers = PrinterDiscovery.Discover();
+ await connection.InvokeAsync("ReportPrinters", printers);
+ Console.WriteLine($"[printers] reported {printers.Count}: " +
+ string.Join(", ", printers.Select(p => $"{p.DisplayName} ({p.Kind})")));
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[printers] report failed: {ex.Message}");
+ }
+}
+
+static async Task ConnectWithRetryAsync(HubConnection connection)
+{
+ while (true)
+ {
+ try { await connection.StartAsync(); return; }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[hub] connect failed: {ex.Message}; retrying in 5s");
+ await Task.Delay(5000);
+ }
+ }
+}
+
+/// Retry reconnecting forever with capped exponential backoff.
+sealed class ForeverRetry : IRetryPolicy
+{
+ public TimeSpan? NextRetryDelay(RetryContext ctx) =>
+ TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, Math.Min(ctx.PreviousRetryCount, 5))));
+}
diff --git a/agent/Meezi.PrintAgent/RawPrinter.cs b/agent/Meezi.PrintAgent/RawPrinter.cs
new file mode 100644
index 0000000..3e44b99
--- /dev/null
+++ b/agent/Meezi.PrintAgent/RawPrinter.cs
@@ -0,0 +1,95 @@
+using System.Net.Sockets;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+
+namespace Meezi.PrintAgent;
+
+/// Writes raw ESC/POS bytes to a printer — by Windows name (winspool RAW
+/// passthrough) or to an "ip:port" endpoint (raw TCP).
+[SupportedOSPlatform("windows")]
+public static class RawPrinter
+{
+ public static async Task PrintAsync(string systemName, byte[] data, CancellationToken ct)
+ {
+ if (TryParseEndpoint(systemName, out var ip, out var port))
+ {
+ await PrintTcpAsync(ip, port, data, ct);
+ return;
+ }
+ if (!SendBytesToPrinter(systemName, data))
+ throw new Exception($"winspool write failed (last error {Marshal.GetLastWin32Error()})");
+ }
+
+ private static bool TryParseEndpoint(string s, out string ip, out int port)
+ {
+ ip = "";
+ port = 9100;
+ var idx = s.LastIndexOf(':');
+ if (idx <= 0) return false;
+ var host = s[..idx];
+ if (!host.Contains('.')) return false; // not an IPv4-ish host → treat as printer name
+ if (int.TryParse(s[(idx + 1)..], out var p)) port = p;
+ ip = host;
+ return true;
+ }
+
+ private static async Task PrintTcpAsync(string ip, int port, byte[] data, CancellationToken ct)
+ {
+ using var client = new TcpClient();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(TimeSpan.FromSeconds(8));
+ await client.ConnectAsync(ip, port, cts.Token);
+ await using var stream = client.GetStream();
+ await stream.WriteAsync(data, cts.Token);
+ await stream.FlushAsync(cts.Token);
+ }
+
+ // ── winspool raw printing ────────────────────────────────────────────────
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct DOCINFOW
+ {
+ [MarshalAs(UnmanagedType.LPWStr)] public string pDocName;
+ [MarshalAs(UnmanagedType.LPWStr)] public string? pOutputFile;
+ [MarshalAs(UnmanagedType.LPWStr)] public string pDataType;
+ }
+
+ [DllImport("winspool.drv", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool OpenPrinter(string src, out IntPtr hPrinter, IntPtr pd);
+ [DllImport("winspool.drv", SetLastError = true)]
+ private static extern bool ClosePrinter(IntPtr hPrinter);
+ [DllImport("winspool.drv", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool StartDocPrinter(IntPtr hPrinter, int level, ref DOCINFOW di);
+ [DllImport("winspool.drv", SetLastError = true)]
+ private static extern bool EndDocPrinter(IntPtr hPrinter);
+ [DllImport("winspool.drv", SetLastError = true)]
+ private static extern bool StartPagePrinter(IntPtr hPrinter);
+ [DllImport("winspool.drv", SetLastError = true)]
+ private static extern bool EndPagePrinter(IntPtr hPrinter);
+ [DllImport("winspool.drv", SetLastError = true)]
+ private static extern bool WritePrinter(IntPtr hPrinter, IntPtr pBytes, int dwCount, out int dwWritten);
+
+ private static bool SendBytesToPrinter(string printerName, byte[] bytes)
+ {
+ if (!OpenPrinter(printerName, out var hPrinter, IntPtr.Zero)) return false;
+ try
+ {
+ var di = new DOCINFOW { pDocName = "Meezi Receipt", pDataType = "RAW" };
+ if (!StartDocPrinter(hPrinter, 1, ref di)) return false;
+ try
+ {
+ if (!StartPagePrinter(hPrinter)) return false;
+ var ptr = Marshal.AllocHGlobal(bytes.Length);
+ try
+ {
+ Marshal.Copy(bytes, 0, ptr, bytes.Length);
+ if (!WritePrinter(hPrinter, ptr, bytes.Length, out _)) return false;
+ }
+ finally { Marshal.FreeHGlobal(ptr); }
+ EndPagePrinter(hPrinter);
+ }
+ finally { EndDocPrinter(hPrinter); }
+ }
+ finally { ClosePrinter(hPrinter); }
+ return true;
+ }
+}
diff --git a/agent/README.md b/agent/README.md
new file mode 100644
index 0000000..d7de083
--- /dev/null
+++ b/agent/README.md
@@ -0,0 +1,43 @@
+# Meezi Print Agent (پرینتسرور میزی)
+
+A tiny Windows background app that lets the **cloud-hosted** Meezi reach printers on
+the café's **local network** (USB or Wi-Fi/Ethernet). The cloud can't open a
+connection to a `192.168.x.x` or USB printer directly — this agent runs on the cash
+PC (which *is* on that network), connects **outward** to Meezi over SignalR, reports
+the printers it can see, and prints the jobs the cloud sends it.
+
+```
+Cloud API ──SignalR(out)──► Print Agent (cash PC) ──► USB / LAN printers
+```
+
+## How it works
+1. In the dashboard: **Settings → Printers → Add print server** → you get a pairing code.
+2. Run the agent on the cash PC, enter the code once. It saves a token to
+ `%APPDATA%\MeeziPrintAgent\config.json` and connects.
+3. It reports every printer installed on that PC. Back in the dashboard you map
+ *receipt / kitchen / bar* to a printer from the dropdown — no IP typing.
+4. When Meezi prints, the bytes (ESC/POS) are relayed to the agent, which writes them
+ raw to the chosen printer (`winspool` for installed printers, raw TCP for
+ `ip:port` devices).
+
+## Build & run (dev)
+Requires the .NET 10 SDK on Windows.
+
+```sh
+# restore via the Nexus mirror (nuget.org is blocked on this network)
+dotnet restore agent/Meezi.PrintAgent/Meezi.PrintAgent.csproj -s https://mirror.soroushasadi.com/repository/nuget-group/
+dotnet run --project agent/Meezi.PrintAgent # first run prompts to pair
+dotnet run --project agent/Meezi.PrintAgent -- pair # re-pair later
+```
+
+## Publish a single .exe for cafés
+```sh
+dotnet publish agent/Meezi.PrintAgent -c Release -r win-x64 \
+ -p:PublishSingleFile=true --self-contained true -o dist/agent
+# → dist/agent/MeeziPrintAgent.exe
+```
+
+## Notes / roadmap
+- **Not part of the API solution or CI** — it targets `net10.0-windows` and builds on its own.
+- Console MVP today. Next: system-tray UI, run-at-login (Task Scheduler / service), auto-update, and an optional LAN scan for raw `ip:9100` printers that aren't installed in Windows.
+- The token is bearer-equivalent — keep `config.json` on a trusted machine. Revoke from the dashboard if a PC is lost.