feat(pos): bridge the card terminal through the print agent + LAN auto-detect
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m29s
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m29s
The card-terminal integration only ever worked when the API could reach the terminal's IP directly — impossible for the cloud deployment, where the terminal sits on the café LAN (the same wall the Print Agent already climbs for printers). And the terminal IP had to be typed by hand. Both fixed by reusing the agent. Cloud→LAN relay: - PrintAgentRegistry.SendPaymentAsync sends a PaymentRequest to the café's online agent and awaits its ack (PaymentResult on the hub); 95s window for the customer. - PosDeviceService now prefers an online agent (branch-matched, else any café agent) to relay POST /pay over the LAN, and falls back to the direct HTTP call only when no agent is connected (on-prem). Agent errors map back to POS_DEVICE_*. - Agent (Program.cs + PosTerminal.cs) handles PaymentRequest → POSTs the amount to the terminal's local http://ip:port/pay and reports approval/decline/timeout. Auto-detect: - Registry.ScanAsync + hub ReportScan; POST /print-agents/scan asks online agents to scan their /24 for given ports and merges the hosts found. - Agent NetworkScanner scans the LAN (:9100 printers, :8088 terminals) with a short per-host TCP probe. - Dashboard: a "تشخیص خودکار" (auto-detect) button on the POS-device, receipt and kitchen IP fields scans via the agent and fills the IP:port from a found host. Backend + agent build clean; dashboard tsc clean. NOTE: the agent app is not in CI — it must be rebuilt and redeployed on the café PC to gain these handlers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace Meezi.PrintAgent;
|
||||
|
||||
/// <summary>A host on the café LAN answering on a probed port. Property names match
|
||||
/// the cloud's <c>DiscoveredDevice</c> record so SignalR maps them across.</summary>
|
||||
public record ScannedDevice(string Ip, int Port, string Kind);
|
||||
|
||||
/// <summary>
|
||||
/// Scans the agent PC's local /24 subnet(s) for hosts answering on the given TCP
|
||||
/// ports — used to auto-find network printers (:9100) and card terminals (:8088)
|
||||
/// so the café owner doesn't have to type IP addresses.
|
||||
/// </summary>
|
||||
public static class NetworkScanner
|
||||
{
|
||||
private const int MaxConcurrency = 128;
|
||||
private const int ConnectTimeoutMs = 300;
|
||||
|
||||
public static async Task<List<ScannedDevice>> ScanAsync(string portsCsv, CancellationToken ct)
|
||||
{
|
||||
var ports = portsCsv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(p => int.TryParse(p, out var n) ? n : 0)
|
||||
.Where(n => n is > 0 and <= 65535)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (ports.Count == 0) ports = [9100, 8088];
|
||||
|
||||
var results = new ConcurrentBag<ScannedDevice>();
|
||||
using var gate = new SemaphoreSlim(MaxConcurrency);
|
||||
var tasks = new List<Task>();
|
||||
|
||||
foreach (var prefix in LocalSubnets())
|
||||
{
|
||||
for (var host = 1; host <= 254; host++)
|
||||
{
|
||||
var ip = $"{prefix}.{host}";
|
||||
foreach (var port in ports)
|
||||
{
|
||||
await gate.WaitAsync(ct);
|
||||
tasks.Add(Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await CanConnectAsync(ip, port))
|
||||
results.Add(new ScannedDevice(ip, port, Classify(port)));
|
||||
}
|
||||
finally { gate.Release(); }
|
||||
}, ct));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
return results
|
||||
.DistinctBy(d => $"{d.Ip}:{d.Port}")
|
||||
.OrderBy(d => d.Ip)
|
||||
.ThenBy(d => d.Port)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Distinct /24 prefixes of this PC's up, non-loopback IPv4 interfaces.</summary>
|
||||
private static IEnumerable<string> LocalSubnets()
|
||||
{
|
||||
var seen = new HashSet<string>();
|
||||
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
||||
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
|
||||
{
|
||||
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
|
||||
if (IPAddress.IsLoopback(ua.Address)) continue;
|
||||
var b = ua.Address.GetAddressBytes();
|
||||
var prefix = $"{b[0]}.{b[1]}.{b[2]}";
|
||||
if (seen.Add(prefix)) yield return prefix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> CanConnectAsync(string ip, int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var connect = client.ConnectAsync(ip, port);
|
||||
var done = await Task.WhenAny(connect, Task.Delay(ConnectTimeoutMs));
|
||||
if (done != connect) return false;
|
||||
await connect; // observe exceptions
|
||||
return client.Connected;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Classify(int port) => port switch
|
||||
{
|
||||
9100 => "network-printer",
|
||||
8088 => "pos-terminal",
|
||||
_ => "other",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Meezi.PrintAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Relays a card-terminal payment on the café LAN. The cloud can't reach the
|
||||
/// terminal's private IP, so it hands the agent the amount and the terminal's
|
||||
/// ip:port; the agent POSTs to the terminal's local HTTP <c>/pay</c> endpoint and
|
||||
/// reports back whether it was approved.
|
||||
/// </summary>
|
||||
public static class PosTerminal
|
||||
{
|
||||
// A card payment blocks on the customer inserting/approving — allow plenty.
|
||||
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(90) };
|
||||
|
||||
public static async Task<(bool Ok, string? Error)> SendPaymentAsync(
|
||||
string ip, int port, long amount, string orderId, CancellationToken ct)
|
||||
{
|
||||
var url = $"http://{ip}:{port}/pay";
|
||||
try
|
||||
{
|
||||
using var resp = await Http.PostAsJsonAsync(url, new { amount, orderId }, ct);
|
||||
if (resp.IsSuccessStatusCode) return (true, null);
|
||||
return (false, $"POS_DEVICE_REJECTED:HTTP {(int)resp.StatusCode}");
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return (false, "POS_DEVICE_TIMEOUT");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"POS_DEVICE_CONNECTION_FAILED:{ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,27 @@ static async Task RunAsync(AgentConfig config)
|
||||
try { await connection.InvokeAsync("JobResult", jobId, ok, err); } catch { /* ack best-effort */ }
|
||||
});
|
||||
|
||||
// Cloud → agent: relay a card-terminal payment to the terminal on the LAN.
|
||||
connection.On<string, string, int, long, string>("PaymentRequest", async (requestId, ip, port, amount, orderId) =>
|
||||
{
|
||||
var (ok, err) = await PosTerminal.SendPaymentAsync(ip, port, amount, orderId, CancellationToken.None);
|
||||
Console.WriteLine(ok
|
||||
? $"[pay] {amount} → {ip}:{port} ✓"
|
||||
: $"[pay] {ip}:{port} ✗ {err}");
|
||||
try { await connection.InvokeAsync("PaymentResult", requestId, ok, err); } catch { /* ack best-effort */ }
|
||||
});
|
||||
|
||||
// Cloud → agent: scan the LAN for hosts on the given ports (printers :9100, terminals :8088).
|
||||
connection.On<string, string>("ScanNetwork", async (requestId, ports) =>
|
||||
{
|
||||
List<ScannedDevice> found;
|
||||
try { found = await NetworkScanner.ScanAsync(ports, CancellationToken.None); }
|
||||
catch (Exception ex) { Console.WriteLine($"[scan] failed: {ex.Message}"); found = []; }
|
||||
Console.WriteLine($"[scan] ports={ports} → {found.Count} host(s): " +
|
||||
string.Join(", ", found.Select(d => $"{d.Ip}:{d.Port}")));
|
||||
try { await connection.InvokeAsync("ReportScan", requestId, found); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
connection.Reconnected += async _ =>
|
||||
{
|
||||
Console.WriteLine("[hub] reconnected");
|
||||
|
||||
@@ -117,6 +117,36 @@ public class PrintAgentsController : CafeApiControllerBase
|
||||
new ApiError(result.ErrorCode ?? "PRINT_FAILED", result.ErrorDetail ?? "Test print failed.")));
|
||||
}
|
||||
|
||||
/// <summary>Ask the café's online agents to scan their LAN for devices (network
|
||||
/// printers on :9100, card terminals on :8088) so the owner can pick instead of
|
||||
/// typing an IP. Merges results across agents.</summary>
|
||||
[HttpPost("scan")]
|
||||
public async Task<IActionResult> Scan(
|
||||
string cafeId,
|
||||
[FromBody] ScanRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||
|
||||
var online = _registry.OnlineAgentIdsForCafe(cafeId);
|
||||
if (online.Count == 0)
|
||||
return BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError("AGENT_OFFLINE", "No print agent is online to scan the network.")));
|
||||
|
||||
var ports = string.IsNullOrWhiteSpace(request.Ports) ? "9100,8088" : request.Ports!.Trim();
|
||||
var merged = new Dictionary<string, ScannedDeviceDto>();
|
||||
foreach (var agentId in online)
|
||||
{
|
||||
foreach (var d in await _registry.ScanAsync(agentId, ports, ct))
|
||||
merged[$"{d.Ip}:{d.Port}"] = new ScannedDeviceDto(d.Ip, d.Port, d.Kind);
|
||||
}
|
||||
|
||||
var dtos = merged.Values.OrderBy(d => d.Ip).ThenBy(d => d.Port).ToList();
|
||||
return Ok(new ApiResponse<IReadOnlyList<ScannedDeviceDto>>(true, dtos));
|
||||
}
|
||||
|
||||
private async Task<string> GenerateUniqueCodeAsync(CancellationToken ct)
|
||||
{
|
||||
for (var attempt = 0; attempt < 8; attempt++)
|
||||
|
||||
@@ -99,6 +99,14 @@ public class PrintAgentHub : Hub
|
||||
public void JobResult(string jobId, bool success, string? error) =>
|
||||
_registry.CompleteJob(jobId, success, error);
|
||||
|
||||
/// <summary>Agent → cloud: result of a relayed card-terminal payment.</summary>
|
||||
public void PaymentResult(string requestId, bool success, string? error) =>
|
||||
_registry.CompleteJob(requestId, success, error);
|
||||
|
||||
/// <summary>Agent → cloud: hosts found by a LAN scan (network printers, card terminals).</summary>
|
||||
public void ReportScan(string requestId, IReadOnlyList<DiscoveredDevice> devices) =>
|
||||
_registry.CompleteScan(requestId, devices ?? []);
|
||||
|
||||
/// <summary>Agent → cloud: keep-alive so the dashboard can show an accurate "last seen".</summary>
|
||||
public async Task Heartbeat()
|
||||
{
|
||||
|
||||
@@ -24,3 +24,8 @@ public record PairingCodeResponse(string AgentId, string Code, DateTime ExpiresA
|
||||
public record ClaimAgentRequest(string Code, string? Name, string? MachineName);
|
||||
|
||||
public record ClaimAgentResponse(string AgentId, string Token, string CafeId, string AgentName);
|
||||
|
||||
/// <summary>Ask online agents to scan the LAN for the given comma-separated TCP ports.</summary>
|
||||
public record ScanRequest(string? Ports);
|
||||
|
||||
public record ScannedDeviceDto(string Ip, int Port, string Kind);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Meezi.API.Models.Printing;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -29,15 +30,18 @@ public class PosDeviceService : IPosDeviceService
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IPrintAgentRegistry _agents;
|
||||
private readonly ILogger<PosDeviceService> _logger;
|
||||
|
||||
public PosDeviceService(
|
||||
AppDbContext db,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IPrintAgentRegistry agents,
|
||||
ILogger<PosDeviceService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_agents = agents;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -71,14 +75,31 @@ public class PosDeviceService : IPosDeviceService
|
||||
if (order is null)
|
||||
return PosDeviceResult.Fail("ORDER_NOT_FOUND");
|
||||
|
||||
var amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero);
|
||||
var ip = branch.PosDeviceIp!.Trim();
|
||||
|
||||
// Prefer relaying through a local print agent on the café LAN — the cloud
|
||||
// can't reach the terminal's private IP directly (same reason the agent
|
||||
// exists for printers). Fall back to a direct call only on-prem / when no
|
||||
// agent is connected.
|
||||
var agentId = await ResolveOnlineAgentAsync(cafeId, branchId, ct);
|
||||
if (agentId is not null)
|
||||
{
|
||||
var outcome = await _agents.SendPaymentAsync(agentId, ip, port, amount, request.OrderId, ct);
|
||||
if (outcome.Success)
|
||||
return PosDeviceResult.Ok();
|
||||
_logger.LogWarning("Agent-relayed POS payment failed ({Agent}): {Error}", agentId, outcome.Error);
|
||||
return PosDeviceResult.Fail(MapAgentError(outcome.Error), outcome.Error);
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero),
|
||||
amount,
|
||||
orderId = request.OrderId,
|
||||
branchId,
|
||||
};
|
||||
|
||||
var url = $"http://{branch.PosDeviceIp!.Trim()}:{port}/pay";
|
||||
var url = $"http://{ip}:{port}/pay";
|
||||
|
||||
try
|
||||
{
|
||||
@@ -117,4 +138,31 @@ public class PosDeviceService : IPosDeviceService
|
||||
return PosDeviceResult.Fail("POS_DEVICE_CONNECTION_FAILED", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The online agent best placed to reach this branch's terminal — one
|
||||
/// bound to the branch if present, else any online agent of the café.</summary>
|
||||
private async Task<string?> ResolveOnlineAgentAsync(string cafeId, string branchId, CancellationToken ct)
|
||||
{
|
||||
var online = _agents.OnlineAgentIdsForCafe(cafeId);
|
||||
if (online.Count == 0) return null;
|
||||
|
||||
var agents = await _db.PrintAgents
|
||||
.AsNoTracking()
|
||||
.Where(a => a.CafeId == cafeId && !a.Revoked && a.DeletedAt == null)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return agents.FirstOrDefault(a => a.BranchId == branchId && online.Contains(a.Id))?.Id
|
||||
?? agents.FirstOrDefault(a => online.Contains(a.Id))?.Id;
|
||||
}
|
||||
|
||||
/// <summary>Normalize an agent-relay error string back to a POS_DEVICE_* code.</summary>
|
||||
private static string MapAgentError(string? error) => error switch
|
||||
{
|
||||
null or "" => "POS_DEVICE_FAILED",
|
||||
var e when e.Contains("TIMEOUT", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_TIMEOUT",
|
||||
var e when e.StartsWith("POS_DEVICE_", StringComparison.Ordinal) => e.Split(':')[0],
|
||||
var e when e.Contains("REJECT", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_REJECTED",
|
||||
var e when e.Contains("OFFLINE", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_CONNECTION_FAILED",
|
||||
_ => "POS_DEVICE_CONNECTION_FAILED",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ namespace Meezi.API.Services.Printing;
|
||||
public record PrintJobRequest(string PrinterSystemName, byte[] Payload);
|
||||
public record PrintJobOutcome(bool Success, string? Error);
|
||||
|
||||
/// <summary>A host the agent found on the café LAN responding on a probed port
|
||||
/// (a network printer on :9100, a card terminal on :8088, …).</summary>
|
||||
public record DiscoveredDevice(string Ip, int Port, string Kind);
|
||||
|
||||
/// <summary>
|
||||
/// Tracks which print agents are currently connected (by SignalR connection) and
|
||||
/// dispatches print jobs to them, awaiting the agent's acknowledgement. In-memory:
|
||||
@@ -19,8 +23,19 @@ public interface IPrintAgentRegistry
|
||||
(string AgentId, string CafeId)? Resolve(string connectionId);
|
||||
bool IsOnline(string agentId);
|
||||
IReadOnlySet<string> OnlineAgentIds();
|
||||
/// <summary>Online agents belonging to a café — used to pick a LAN bridge for a
|
||||
/// card-terminal payment or a network scan.</summary>
|
||||
IReadOnlySet<string> OnlineAgentIdsForCafe(string cafeId);
|
||||
Task<PrintJobOutcome> SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default);
|
||||
void CompleteJob(string jobId, bool success, string? error);
|
||||
/// <summary>Relay a card-terminal payment through the agent on the café LAN; it
|
||||
/// POSTs the amount to the terminal at ip:port and acks the approval result.</summary>
|
||||
Task<PrintJobOutcome> SendPaymentAsync(
|
||||
string agentId, string ip, int port, long amount, string orderId, CancellationToken ct = default);
|
||||
/// <summary>Ask the agent to scan its LAN for hosts answering on the given ports.</summary>
|
||||
Task<IReadOnlyList<DiscoveredDevice>> ScanAsync(
|
||||
string agentId, string ports, CancellationToken ct = default);
|
||||
void CompleteScan(string requestId, IReadOnlyList<DiscoveredDevice> devices);
|
||||
}
|
||||
|
||||
public class PrintAgentRegistry : IPrintAgentRegistry
|
||||
@@ -29,6 +44,7 @@ public class PrintAgentRegistry : IPrintAgentRegistry
|
||||
private readonly ConcurrentDictionary<string, (string AgentId, string CafeId)> _byConnection = new();
|
||||
private readonly ConcurrentDictionary<string, string> _agentConnection = new(); // agentId -> connectionId
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<PrintJobOutcome>> _pending = new();
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<IReadOnlyList<DiscoveredDevice>>> _pendingScans = new();
|
||||
|
||||
public PrintAgentRegistry(IHubContext<PrintAgentHub> hub) => _hub = hub;
|
||||
|
||||
@@ -54,6 +70,12 @@ public class PrintAgentRegistry : IPrintAgentRegistry
|
||||
|
||||
public IReadOnlySet<string> OnlineAgentIds() => _agentConnection.Keys.ToHashSet();
|
||||
|
||||
public IReadOnlySet<string> OnlineAgentIdsForCafe(string cafeId) =>
|
||||
_byConnection.Values
|
||||
.Where(v => v.CafeId == cafeId)
|
||||
.Select(v => v.AgentId)
|
||||
.ToHashSet();
|
||||
|
||||
public async Task<PrintJobOutcome> SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default)
|
||||
{
|
||||
if (!_agentConnection.TryGetValue(agentId, out var connectionId))
|
||||
@@ -87,4 +109,69 @@ public class PrintAgentRegistry : IPrintAgentRegistry
|
||||
if (_pending.TryGetValue(jobId, out var tcs))
|
||||
tcs.TrySetResult(new PrintJobOutcome(success, error));
|
||||
}
|
||||
|
||||
public async Task<PrintJobOutcome> SendPaymentAsync(
|
||||
string agentId, string ip, int port, long amount, string orderId, CancellationToken ct = default)
|
||||
{
|
||||
if (!_agentConnection.TryGetValue(agentId, out var connectionId))
|
||||
return new PrintJobOutcome(false, "AGENT_OFFLINE");
|
||||
|
||||
var requestId = Guid.NewGuid().ToString("N");
|
||||
var tcs = new TaskCompletionSource<PrintJobOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[requestId] = tcs;
|
||||
try
|
||||
{
|
||||
await _hub.Clients.Client(connectionId).SendAsync(
|
||||
"PaymentRequest", requestId, ip, port, amount, orderId, ct);
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
// Card payment waits on the customer at the terminal — give it the same
|
||||
// headroom the direct path uses.
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(95));
|
||||
using var reg = timeout.Token.Register(() => tcs.TrySetResult(new PrintJobOutcome(false, "POS_DEVICE_TIMEOUT")));
|
||||
return await tcs.Task;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrintJobOutcome(false, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pending.TryRemove(requestId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DiscoveredDevice>> ScanAsync(
|
||||
string agentId, string ports, CancellationToken ct = default)
|
||||
{
|
||||
if (!_agentConnection.TryGetValue(agentId, out var connectionId))
|
||||
return [];
|
||||
|
||||
var requestId = Guid.NewGuid().ToString("N");
|
||||
var tcs = new TaskCompletionSource<IReadOnlyList<DiscoveredDevice>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pendingScans[requestId] = tcs;
|
||||
try
|
||||
{
|
||||
await _hub.Clients.Client(connectionId).SendAsync("ScanNetwork", requestId, ports, ct);
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
using var reg = timeout.Token.Register(() => tcs.TrySetResult([]));
|
||||
return await tcs.Task;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingScans.TryRemove(requestId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void CompleteScan(string requestId, IReadOnlyList<DiscoveredDevice> devices)
|
||||
{
|
||||
if (_pendingScans.TryGetValue(requestId, out var tcs))
|
||||
tcs.TrySetResult(devices);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +333,11 @@
|
||||
"posDeviceSection": "جهاز نقطة البيع (بطاقة)",
|
||||
"posDeviceHint": "عند الدفع بالبطاقة، يُرسل المبلغ عبر HTTP (POST /pay) إلى الجهاز على الشبكة المحلية.",
|
||||
"posDeviceIp": "عنوان IP لجهاز نقطة البيع",
|
||||
"detect": "كشف تلقائي",
|
||||
"detecting": "جارٍ فحص الشبكة…",
|
||||
"detectNone": "لم يُعثر على أجهزة في الشبكة",
|
||||
"detectOffline": "يجب أن يكون خادم الطباعة متصلاً للكشف التلقائي",
|
||||
"detectHint": "يفحص خادم الطباعة شبكتك المحلية للعثور على الجهاز.",
|
||||
"testSent": "تم إرسال الاختبار إلى الطابعة.",
|
||||
"sent": "تم الإرسال إلى الطابعة.",
|
||||
"noStationItems": "لا توجد أصناف لهذه المحطة في هذا الطلب.",
|
||||
|
||||
@@ -352,6 +352,11 @@
|
||||
"posDeviceSection": "Card POS terminal",
|
||||
"posDeviceHint": "On card payment, the amount is sent via HTTP (POST /pay) to the device on your LAN.",
|
||||
"posDeviceIp": "POS device IP address",
|
||||
"detect": "Auto-detect",
|
||||
"detecting": "Scanning the network…",
|
||||
"detectNone": "No devices found on the network",
|
||||
"detectOffline": "A print server must be online to auto-detect",
|
||||
"detectHint": "The print server scans your LAN to find the device.",
|
||||
"testSent": "Test sent to the printer.",
|
||||
"sent": "Sent to the printer.",
|
||||
"noStationItems": "This order has no items for that station.",
|
||||
|
||||
@@ -352,6 +352,11 @@
|
||||
"posDeviceSection": "دستگاه پوز (کارتخوان)",
|
||||
"posDeviceHint": "هنگام پرداخت کارتی، مبلغ به آدرس HTTP دستگاه ارسال میشود (POST /pay).",
|
||||
"posDeviceIp": "آدرس IP دستگاه پوز",
|
||||
"detect": "تشخیص خودکار",
|
||||
"detecting": "در حال جستجوی شبکه…",
|
||||
"detectNone": "دستگاهی در شبکه پیدا نشد",
|
||||
"detectOffline": "برای تشخیص خودکار باید پرینتسرور روشن و متصل باشد",
|
||||
"detectHint": "پرینتسرور شبکه محلی را برای یافتن دستگاه اسکن میکند.",
|
||||
"testSent": "تست به پرینتر ارسال شد.",
|
||||
"sent": "به پرینتر ارسال شد.",
|
||||
"noStationItems": "این سفارش آیتمی برای این ایستگاه ندارد.",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Server, Wifi, WifiOff, Trash2, Plus, Loader2 } from "lucide-react";
|
||||
import { Server, Wifi, WifiOff, Trash2, Plus, Loader2, Radar } from "lucide-react";
|
||||
import { apiGet, apiPatch } from "@/lib/api/client";
|
||||
import {
|
||||
listPrintAgents,
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
revokePrintAgent,
|
||||
testPrintDevice,
|
||||
deviceOptions,
|
||||
scanNetwork,
|
||||
type PairingCode,
|
||||
type ScannedDevice,
|
||||
} from "@/lib/api/print-agents";
|
||||
import { ApiClientError } from "@/lib/api/client";
|
||||
import { printErrorMessage } from "@/lib/api/print";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -324,6 +327,14 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
<DetectButton
|
||||
cafeId={cafeId}
|
||||
ports="9100"
|
||||
onPick={(ip, port) => {
|
||||
setReceiptIp(ip);
|
||||
setReceiptPort(String(port));
|
||||
}}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("port")} htmlFor="receipt-port">
|
||||
<Input
|
||||
@@ -343,6 +354,14 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
<DetectButton
|
||||
cafeId={cafeId}
|
||||
ports="9100"
|
||||
onPick={(ip, port) => {
|
||||
setKitchenIp(ip);
|
||||
setKitchenPort(String(port));
|
||||
}}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("port")} htmlFor="kitchen-port">
|
||||
<Input
|
||||
@@ -421,6 +440,14 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
<DetectButton
|
||||
cafeId={cafeId}
|
||||
ports={posDevicePort.trim() || "8088"}
|
||||
onPick={(ip, port) => {
|
||||
setPosDeviceIp(ip);
|
||||
setPosDevicePort(String(port));
|
||||
}}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("port")} htmlFor="pos-device-port">
|
||||
<Input
|
||||
@@ -447,3 +474,75 @@ export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinte
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Auto-detect" affordance for an IP field: asks the online print agent to scan
|
||||
* the café LAN for the given ports and lets the owner pick a found host (which
|
||||
* fills the IP + port) instead of typing an address by hand.
|
||||
*/
|
||||
function DetectButton({
|
||||
cafeId,
|
||||
ports,
|
||||
onPick,
|
||||
}: {
|
||||
cafeId: string;
|
||||
ports: string;
|
||||
onPick: (ip: string, port: number) => void;
|
||||
}) {
|
||||
const t = useTranslations("print");
|
||||
const [results, setResults] = useState<ScannedDevice[] | null>(null);
|
||||
|
||||
const scan = useMutation({
|
||||
mutationFn: () => scanNetwork(cafeId, ports),
|
||||
onSuccess: (devices) => setResults(devices),
|
||||
onError: (e) =>
|
||||
notify.error(
|
||||
e instanceof ApiClientError && e.code === "AGENT_OFFLINE"
|
||||
? t("detectOffline")
|
||||
: t("detectNone"),
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setResults(null);
|
||||
scan.mutate();
|
||||
}}
|
||||
disabled={scan.isPending}
|
||||
title={t("detectHint")}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-input px-2.5 py-1 text-xs font-medium text-muted-foreground hover:bg-accent disabled:opacity-60"
|
||||
>
|
||||
{scan.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Radar className="size-3.5" />
|
||||
)}
|
||||
{scan.isPending ? t("detecting") : t("detect")}
|
||||
</button>
|
||||
{results &&
|
||||
(results.length === 0 ? (
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">{t("detectNone")}</p>
|
||||
) : (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{results.map((d) => (
|
||||
<button
|
||||
key={`${d.ip}:${d.port}`}
|
||||
type="button"
|
||||
dir="ltr"
|
||||
onClick={() => {
|
||||
onPick(d.ip, d.port);
|
||||
setResults(null);
|
||||
}}
|
||||
className="block w-full rounded-md border border-border/70 bg-background px-2.5 py-1 text-start font-mono text-xs hover:border-primary hover:bg-primary/5"
|
||||
>
|
||||
{d.ip}:{d.port}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,22 @@ export function testPrintDevice(cafeId: string, deviceId: string): Promise<unkno
|
||||
return apiPost(`/api/cafes/${cafeId}/print-agents/devices/${deviceId}/test`, {});
|
||||
}
|
||||
|
||||
/** A host found on the café LAN by an online agent's network scan. */
|
||||
export interface ScannedDevice {
|
||||
ip: string;
|
||||
port: number;
|
||||
kind: string; // "network-printer" | "pos-terminal" | "other"
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the café's online print agent(s) to scan the LAN for devices on the given
|
||||
* comma-separated ports (e.g. "9100" for network printers, "8088" for terminals).
|
||||
* Throws AGENT_OFFLINE if no agent is connected to do the scan.
|
||||
*/
|
||||
export function scanNetwork(cafeId: string, ports: string): Promise<ScannedDevice[]> {
|
||||
return apiPost<ScannedDevice[]>(`/api/cafes/${cafeId}/print-agents/scan`, { ports });
|
||||
}
|
||||
|
||||
/** Every device across all agents, for a printer-picker dropdown. */
|
||||
export function deviceOptions(agents: PrintAgent[]): DeviceOption[] {
|
||||
return agents.flatMap((a) =>
|
||||
|
||||
Reference in New Issue
Block a user