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");
|
||||
|
||||
Reference in New Issue
Block a user