Merge branch 'feature/insin-integration'

Add Insin integration:
- Runtime telemetry via loopback (service.started, job.printed, job.failed.*, printer.discovered/online/offline)
- Package delivery: install.cfg, postinst, prem, systemd unit
- Manual publish script (scripts/publish-insin.sh)
- Config path move to /opt/epson-print-service
- Bump EpsonPrintService version to 1.0.15
This commit is contained in:
EugeneTes
2026-07-23 08:44:38 +00:00
29 changed files with 2079 additions and 11 deletions

6
.gitignore vendored
View File

@@ -360,4 +360,8 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
FodyWeavers.xsd
# Insin publish artifacts
.tools/
publish/

View File

@@ -317,9 +317,9 @@ Insin uses a flat global package namespace with the `AdminToken` auth header (NO
set -euo pipefail
: "${INSIN_URL:?}" "${INSIN_TOKEN:?}"
# 1. Fetch latest CLI (linux-arm64 shown; use win-x64 on Windows CI).
# 1. Fetch latest CLI (pick RID matching host — linux-x64 shown).
LATEST=$(curl -fsSL "$INSIN_URL/api/v1/downloads/cli" \
| python3 -c 'import json,sys; m=json.load(sys.stdin)[0]; a=next(x for x in m["artifacts"] if x["rid"]=="linux-arm64"); print(m["version"], a["filename"])')
| python3 -c 'import json,sys; m=json.load(sys.stdin)[0]; a=next(x for x in m["artifacts"] if x["rid"]=="linux-x64"); print(m["version"], a["filename"])')
VERSION=${LATEST% *}; FILENAME=${LATEST#* }
curl -fsSL "$INSIN_URL/api/v1/downloads/cli/$VERSION/$FILENAME" -o /tmp/insin.tar.gz
mkdir -p /tmp/insin && tar -xzf /tmp/insin.tar.gz -C /tmp/insin
@@ -345,7 +345,7 @@ Under the hood `publish` = `POST $INSIN_URL/api/v1/admin/packages` (multipart fo
- **First publish auto-creates** the package name. No separate registration.
- **Versions are immutable.** Re-publishing the same `(name, version)` returns **409 Conflict**. Bump the version.
- **Flat global namespace** — no scopes. Pick a distinctive name (we use `epson-print-service`).
- **CLI artifacts:** only `linux-arm64` and `win-x64` today. On `linux-x64` CI, run under qemu (`--platform linux/arm64`) — the CLI is just packer + uploader so emulation is fine.
- **CLI artifacts** ship for `linux-x64`, `linux-arm64`, `linux-arm`, and `win-x64` (as of CLI 1.3.1). Pick the RID that matches the host; no qemu required.
- **`insin pack` output is one dir UP** (`../packages/`), not `./`. Look there if the `.pkg` seems missing.
- If both `INSIN_ADMIN_TOKEN` and `INSIN_TOKEN` are set, `publish` reads `INSIN_TOKEN` first (service token wins).
- **List packages:** `curl -H "Authorization: AdminToken $INSIN_TOKEN" $INSIN_URL/api/v1/admin/packages`.

View File

@@ -4,7 +4,7 @@ namespace EpsonPrintService;
public static class ConfigurationPaths
{
private const string LinuxConfigDir = "/root/epsonprintservice";
private const string LinuxConfigDir = "/opt/epson-print-service";
private const string LocalConfigFileName = "config.txt";
public static string GetConfigPath()

View File

@@ -5,7 +5,7 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.14</Version>
<Version>1.0.15</Version>
</PropertyGroup>
<ItemGroup>

View File

@@ -8,9 +8,11 @@ using Inspectron.Epson.PrintServer.PrinterAssinment;
using Inspectron.Epson.PrintServer.Printers;
using Inspectron.Epson.PrintServer.Printers.Utils;
using Inspectron.Epson.PrintServer.PrintServices;
using Inspectron.Epson.PrintServer.Telemetry;
using Inspectron.Epson.Queue;
using Microsoft.Extensions.Logging;
using Ninject;
using System.Reflection;
namespace EpsonPrintService;
@@ -45,6 +47,10 @@ public class PrintServerBootstrapper
_kernel = new StandardKernel();
_kernel.Bind<HttpClient>().ToConstant(new HttpClient { Timeout = TimeSpan.FromSeconds(30) }).InSingletonScope();
if (_config.EmulationMode)
_kernel.Bind<IInsinTelemetry>().To<NullInsinTelemetry>().InSingletonScope();
else
_kernel.Bind<IInsinTelemetry>().To<LoopbackInsinTelemetry>().InSingletonScope();
_kernel.Bind<EpsonPrintServiceConfiguration>().ToConstant(_config);
if (_config.EmulationMode)
@@ -97,7 +103,12 @@ public class PrintServerBootstrapper
{
_kernel.Bind<PrintJobArrivedHandler>().ToConstant(handler);
}
_kernel.Bind<IDiscoveredPrintersReceiver>().To<JamesDiscoveredPrintersReceiver>().InSingletonScope();
_kernel.Bind<JamesDiscoveredPrintersReceiver>().ToSelf().InSingletonScope();
_kernel.Bind<IDiscoveredPrintersReceiver>().ToMethod(ctx =>
new InsinTelemetryDiscoveredPrintersReceiver(
ctx.Kernel.Get<JamesDiscoveredPrintersReceiver>(),
ctx.Kernel.Get<IInsinTelemetry>()))
.InSingletonScope();
_kernel.Bind<PrinterDiscoveryBackgroundTask>().ToSelf().InSingletonScope();
_kernel.Bind<HeartbeatBackgroundTask>().ToSelf().InSingletonScope();
@@ -120,5 +131,15 @@ public class PrintServerBootstrapper
IsRunning = true;
Console.WriteLine("Print server started.");
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
var telemetry = _kernel.Get<IInsinTelemetry>();
try
{
telemetry.Emit(InsinEventKinds.ServiceStarted, InsinMessageFormatter.Format(
("version", version),
("restaurantId", _config.RestaurantId)));
}
catch { /* telemetry must never fail startup */ }
}
}

View File

@@ -0,0 +1,40 @@
using System.Runtime.InteropServices;
using EpsonPrintService;
namespace Inspectron.Epson.Tests.Configuration;
public class ConfigurationPathsTests
{
[Fact]
public void GetConfigPath_uses_opt_dir_on_linux_when_env_unset_and_no_local_file()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return;
var previous = Environment.GetEnvironmentVariable("EPSON_CONFIG_PATH");
Environment.SetEnvironmentVariable("EPSON_CONFIG_PATH", null);
try
{
var path = ConfigurationPaths.GetConfigPath();
Assert.Equal("/opt/epson-print-service/config.txt", path);
}
finally
{
Environment.SetEnvironmentVariable("EPSON_CONFIG_PATH", previous);
}
}
[Fact]
public void EPSON_CONFIG_PATH_override_still_wins()
{
var previous = Environment.GetEnvironmentVariable("EPSON_CONFIG_PATH");
Environment.SetEnvironmentVariable("EPSON_CONFIG_PATH", "/tmp/override.txt");
try
{
var path = ConfigurationPaths.GetConfigPath();
Assert.Equal("/tmp/override.txt", path);
}
finally
{
Environment.SetEnvironmentVariable("EPSON_CONFIG_PATH", previous);
}
}
}

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RollForward>Major</RollForward>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Inspectron.Epson\Inspectron.Epson.csproj" />
<ProjectReference Include="..\EpsonPrintService\EpsonPrintService.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
namespace Inspectron.Epson.Tests.Sanity;
public class SanityTests
{
[Fact]
public void Truth_is_true() => Assert.True(true);
}

View File

@@ -0,0 +1,28 @@
using Inspectron.Epson.PrintServer.Telemetry;
using Inspectron.Epson.Queue;
namespace Inspectron.Epson.Tests.Telemetry;
public class InsinEventKindsTests
{
[Theory]
[InlineData(PrintErrorType.OutOfPaper, "job.failed.paper_out")]
[InlineData(PrintErrorType.Offline, "job.failed.connection")]
[InlineData(PrintErrorType.ConversionError, "job.failed.other")]
[InlineData(PrintErrorType.Other, "job.failed.other")]
[InlineData(PrintErrorType.None, "job.failed.other")]
public void MapErrorTypeToKind(PrintErrorType input, string expected)
{
Assert.Equal(expected, InsinEventKinds.MapErrorTypeToKind(input));
}
[Fact]
public void Constants_have_expected_values()
{
Assert.Equal("service.started", InsinEventKinds.ServiceStarted);
Assert.Equal("job.printed", InsinEventKinds.JobPrinted);
Assert.Equal("printer.discovered", InsinEventKinds.PrinterDiscovered);
Assert.Equal("printer.online", InsinEventKinds.PrinterOnline);
Assert.Equal("printer.offline", InsinEventKinds.PrinterOffline);
}
}

View File

@@ -0,0 +1,52 @@
using Inspectron.Epson.PrintServer.Telemetry;
namespace Inspectron.Epson.Tests.Telemetry;
public class InsinMessageFormatterTests
{
[Fact]
public void Format_single_pair_no_spaces()
{
var result = InsinMessageFormatter.Format(("printer", "192.168.1.10"));
Assert.Equal("printer=192.168.1.10", result);
}
[Fact]
public void Format_multiple_pairs_joined_by_space()
{
var result = InsinMessageFormatter.Format(
("printer", "192.168.1.10"),
("jobId", "42"));
Assert.Equal("printer=192.168.1.10 jobId=42", result);
}
[Fact]
public void Format_quotes_value_containing_space()
{
var result = InsinMessageFormatter.Format(("error", "paper end detected"));
Assert.Equal("error=\"paper end detected\"", result);
}
[Fact]
public void Format_escapes_embedded_double_quote()
{
var result = InsinMessageFormatter.Format(("error", "he said \"nope\""));
Assert.Equal("error=\"he said \\\"nope\\\"\"", result);
}
[Fact]
public void Format_treats_null_or_empty_value_as_empty_string()
{
var result = InsinMessageFormatter.Format(("model", (string?)null), ("name", ""));
Assert.Equal("model= name=", result);
}
[Fact]
public void Format_omits_pairs_with_null_or_empty_key()
{
var result = InsinMessageFormatter.Format(
("", "ignored"),
("printer", "192.168.1.10"));
Assert.Equal("printer=192.168.1.10", result);
}
}

View File

@@ -0,0 +1,104 @@
using EpsonPrintService;
using Inspectron.Epson;
using Inspectron.Epson.PrintServer.Telemetry;
namespace Inspectron.Epson.Tests.Telemetry;
public class InsinTelemetryDiscoveredPrintersReceiverTests
{
private sealed class RecordingTelemetry : IInsinTelemetry
{
public List<(string Kind, string Message)> Events { get; } = new();
public void Emit(string kind, string message) => Events.Add((kind, message));
}
private sealed class CountingInner : IDiscoveredPrintersReceiver
{
public int Calls;
public Task OnPrintersDiscoveredAsync(IReadOnlyList<DiscoveredPrinter> printers)
{
Calls++;
return Task.CompletedTask;
}
}
private static DiscoveredPrinter Printer(string ip, string model = "TM-T30III") =>
new() { IPAddress = ip, ModelName = model };
[Fact]
public async Task First_sighting_emits_discovered_and_online()
{
var telemetry = new RecordingTelemetry();
var inner = new CountingInner();
var sut = new InsinTelemetryDiscoveredPrintersReceiver(inner, telemetry);
await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") });
Assert.Equal(1, inner.Calls);
Assert.Collection(telemetry.Events,
e => Assert.Equal("printer.discovered", e.Kind),
e => Assert.Equal("printer.online", e.Kind));
Assert.Contains("printer=192.168.1.10", telemetry.Events[0].Message);
Assert.Contains("model=TM-T30III", telemetry.Events[0].Message);
}
[Fact]
public async Task Repeat_sighting_emits_nothing()
{
var telemetry = new RecordingTelemetry();
var sut = new InsinTelemetryDiscoveredPrintersReceiver(new CountingInner(), telemetry);
await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") });
telemetry.Events.Clear();
await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") });
Assert.Empty(telemetry.Events);
}
[Fact]
public async Task Disappearance_emits_offline()
{
var telemetry = new RecordingTelemetry();
var sut = new InsinTelemetryDiscoveredPrintersReceiver(new CountingInner(), telemetry);
await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") });
telemetry.Events.Clear();
await sut.OnPrintersDiscoveredAsync(Array.Empty<DiscoveredPrinter>());
Assert.Single(telemetry.Events);
Assert.Equal("printer.offline", telemetry.Events[0].Kind);
Assert.Equal("printer=192.168.1.10", telemetry.Events[0].Message);
}
[Fact]
public async Task Reappearance_emits_online_only_not_discovered()
{
var telemetry = new RecordingTelemetry();
var sut = new InsinTelemetryDiscoveredPrintersReceiver(new CountingInner(), telemetry);
await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") });
await sut.OnPrintersDiscoveredAsync(Array.Empty<DiscoveredPrinter>());
telemetry.Events.Clear();
await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") });
Assert.Single(telemetry.Events);
Assert.Equal("printer.online", telemetry.Events[0].Kind);
}
[Fact]
public async Task Telemetry_failures_do_not_break_inner()
{
var telemetry = new ThrowingTelemetry();
var inner = new CountingInner();
var sut = new InsinTelemetryDiscoveredPrintersReceiver(inner, telemetry);
await sut.OnPrintersDiscoveredAsync(new[] { Printer("192.168.1.10") });
Assert.Equal(1, inner.Calls);
}
private sealed class ThrowingTelemetry : IInsinTelemetry
{
public void Emit(string kind, string message) => throw new InvalidOperationException("boom");
}
}

View File

@@ -0,0 +1,74 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Inspectron.Epson.PrintServer.Telemetry;
using Microsoft.Extensions.Logging.Abstractions;
namespace Inspectron.Epson.Tests.Telemetry;
public class LoopbackInsinTelemetryTests
{
private const int TestPort = 47823;
[Fact]
public async Task Emit_posts_json_payload_to_loopback()
{
using var listener = new HttpListener();
listener.Prefixes.Add($"http://127.0.0.1:{TestPort}/");
listener.Start();
using var http = new HttpClient();
var telemetry = new LoopbackInsinTelemetry(http, NullLogger.Instance);
telemetry.Emit("service.started", "version=1.0.14");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
var ctxTask = listener.GetContextAsync();
var completed = await Task.WhenAny(ctxTask, Task.Delay(Timeout.Infinite, cts.Token));
Assert.Same(ctxTask, completed);
var ctx = ctxTask.Result;
Assert.Equal("POST", ctx.Request.HttpMethod);
Assert.Equal("/events", ctx.Request.Url!.AbsolutePath);
using var sr = new StreamReader(ctx.Request.InputStream, Encoding.UTF8);
var body = await sr.ReadToEndAsync();
using var doc = JsonDocument.Parse(body);
Assert.Equal("service.started", doc.RootElement.GetProperty("kind").GetString());
Assert.Equal("version=1.0.14", doc.RootElement.GetProperty("message").GetString());
var at = doc.RootElement.GetProperty("at").GetString();
Assert.False(string.IsNullOrEmpty(at));
Assert.EndsWith("Z", at);
ctx.Response.StatusCode = 200;
ctx.Response.Close();
listener.Stop();
}
[Fact]
public async Task Emit_swallows_connection_refused_and_does_not_throw()
{
using var http = new HttpClient();
var telemetry = new LoopbackInsinTelemetry(http, NullLogger.Instance);
var ex = Record.Exception(() => telemetry.Emit("service.started", "version=1.0.14"));
Assert.Null(ex);
await Task.Delay(200);
}
[Fact]
public async Task Emit_swallows_500_response_and_does_not_throw()
{
using var listener = new HttpListener();
listener.Prefixes.Add($"http://127.0.0.1:{TestPort}/");
listener.Start();
using var http = new HttpClient();
var telemetry = new LoopbackInsinTelemetry(http, NullLogger.Instance);
telemetry.Emit("service.started", "version=1.0.14");
var ctx = await listener.GetContextAsync();
ctx.Response.StatusCode = 500;
ctx.Response.Close();
await Task.Delay(200);
listener.Stop();
}
}

View File

@@ -0,0 +1,14 @@
using Inspectron.Epson.PrintServer.Telemetry;
namespace Inspectron.Epson.Tests.Telemetry;
public class NullInsinTelemetryTests
{
[Fact]
public void Emit_does_nothing_and_does_not_throw()
{
var telemetry = new NullInsinTelemetry();
var ex = Record.Exception(() => telemetry.Emit("service.started", "version=1.0.0"));
Assert.Null(ex);
}
}

View File

@@ -6,6 +6,7 @@
</Folder>
<Project Path="EpsonPrintService/EpsonPrintService.csproj" />
<Project Path="Inspectron.Epson.TemplateEngine.Tests/Inspectron.Epson.TemplateEngine.Tests.csproj" />
<Project Path="Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj" />
<Project Path="Inspectron.Epson.TemplateEngine/Inspectron.Epson.TemplateEngine.csproj" />
<Project Path="Inspectron.Epson/Inspectron.Epson.csproj" />
</Solution>

View File

@@ -0,0 +1,71 @@
using Inspectron.Epson;
using Inspectron.Epson.PrintServer.Telemetry;
namespace EpsonPrintService;
public sealed class InsinTelemetryDiscoveredPrintersReceiver : IDiscoveredPrintersReceiver
{
private readonly IDiscoveredPrintersReceiver _inner;
private readonly IInsinTelemetry _telemetry;
private readonly HashSet<string> _seenIps = new();
private readonly Dictionary<string, bool> _present = new();
private readonly object _stateLock = new();
public InsinTelemetryDiscoveredPrintersReceiver(
IDiscoveredPrintersReceiver inner,
IInsinTelemetry telemetry)
{
_inner = inner;
_telemetry = telemetry;
}
public async Task OnPrintersDiscoveredAsync(IReadOnlyList<DiscoveredPrinter> printers)
{
await _inner.OnPrintersDiscoveredAsync(printers);
List<(string kind, string message)> toEmit;
lock (_stateLock)
{
toEmit = ComputeEvents(printers);
}
foreach (var (kind, message) in toEmit)
{
try { _telemetry.Emit(kind, message); }
catch { /* swallow: telemetry must never break discovery */ }
}
}
private List<(string, string)> ComputeEvents(IReadOnlyList<DiscoveredPrinter> printers)
{
var events = new List<(string, string)>();
var currentIps = new HashSet<string>();
foreach (var p in printers)
{
if (string.IsNullOrEmpty(p.IPAddress)) continue;
currentIps.Add(p.IPAddress);
if (_seenIps.Add(p.IPAddress))
events.Add((InsinEventKinds.PrinterDiscovered,
InsinMessageFormatter.Format(("printer", p.IPAddress), ("model", p.ModelName))));
if (!_present.TryGetValue(p.IPAddress, out var wasPresent) || !wasPresent)
events.Add((InsinEventKinds.PrinterOnline,
InsinMessageFormatter.Format(("printer", p.IPAddress))));
_present[p.IPAddress] = true;
}
foreach (var kv in _present.ToList())
{
if (kv.Value && !currentIps.Contains(kv.Key))
{
events.Add((InsinEventKinds.PrinterOffline,
InsinMessageFormatter.Format(("printer", kv.Key))));
_present[kv.Key] = false;
}
}
return events;
}
}

View File

@@ -0,0 +1,6 @@
namespace Inspectron.Epson.PrintServer.Telemetry;
public interface IInsinTelemetry
{
void Emit(string kind, string message);
}

View File

@@ -0,0 +1,19 @@
using Inspectron.Epson.Queue;
namespace Inspectron.Epson.PrintServer.Telemetry;
public static class InsinEventKinds
{
public const string ServiceStarted = "service.started";
public const string JobPrinted = "job.printed";
public const string PrinterDiscovered = "printer.discovered";
public const string PrinterOnline = "printer.online";
public const string PrinterOffline = "printer.offline";
public static string MapErrorTypeToKind(PrintErrorType errorType) => errorType switch
{
PrintErrorType.OutOfPaper => "job.failed.paper_out",
PrintErrorType.Offline => "job.failed.connection",
_ => "job.failed.other",
};
}

View File

@@ -0,0 +1,26 @@
using System.Text;
namespace Inspectron.Epson.PrintServer.Telemetry;
public static class InsinMessageFormatter
{
public static string Format(params (string Key, string? Value)[] pairs)
{
var sb = new StringBuilder();
foreach (var (key, value) in pairs)
{
if (string.IsNullOrEmpty(key)) continue;
if (sb.Length > 0) sb.Append(' ');
sb.Append(key).Append('=').Append(FormatValue(value));
}
return sb.ToString();
}
private static string FormatValue(string? value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
if (value.IndexOf(' ') < 0 && value.IndexOf('"') < 0) return value;
var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\"");
return $"\"{escaped}\"";
}
}

View File

@@ -0,0 +1,56 @@
using System.Net.Http.Json;
using Microsoft.Extensions.Logging;
namespace Inspectron.Epson.PrintServer.Telemetry;
public sealed class LoopbackInsinTelemetry : IInsinTelemetry
{
private const string Endpoint = "http://127.0.0.1:47823/events";
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(2);
private static readonly long WarnIntervalTicks = TimeSpan.FromMinutes(5).Ticks / TimeSpan.TicksPerMillisecond;
private readonly HttpClient _http;
private readonly ILogger _logger;
private long _lastWarnMs = -WarnIntervalTicks;
public LoopbackInsinTelemetry(HttpClient http, ILogger logger)
{
_http = http;
_logger = logger;
}
public void Emit(string kind, string message)
{
var payload = new
{
kind,
message,
at = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
};
_ = Task.Run(() => SendAsync(payload));
}
private async Task SendAsync(object payload)
{
try
{
using var cts = new CancellationTokenSource(RequestTimeout);
using var resp = await _http.PostAsJsonAsync(Endpoint, payload, cts.Token);
if (!resp.IsSuccessStatusCode)
MaybeWarn($"insin loopback returned {(int)resp.StatusCode}");
}
catch (Exception ex)
{
MaybeWarn($"insin loopback unreachable at 127.0.0.1:47823 ({ex.GetType().Name}), dropping events");
}
}
private void MaybeWarn(string message)
{
var now = Environment.TickCount64;
var last = Interlocked.Read(ref _lastWarnMs);
if (now - last < WarnIntervalTicks) return;
if (Interlocked.CompareExchange(ref _lastWarnMs, now, last) != last) return;
_logger.LogWarning("{Message}", message);
}
}

View File

@@ -0,0 +1,6 @@
namespace Inspectron.Epson.PrintServer.Telemetry;
public sealed class NullInsinTelemetry : IInsinTelemetry
{
public void Emit(string kind, string message) { }
}

View File

@@ -8,18 +8,19 @@ public class PrintServer
private readonly IPrintService _printService;
private readonly ILogger _logger;
private readonly IJobStatusReporter _statusReporter;
private readonly Inspectron.Epson.PrintServer.Telemetry.IInsinTelemetry _telemetry;
private readonly ConcurrentDictionary<string, PrinterQueue> _printerQueues;
private readonly SemaphoreSlim _printDiscoveryLock = new(1, 1);
private int _readerCount = 0;
private readonly object _readerCountLock = new();
public PrintServer(IPrintService printService, ILogger logger, IJobStatusReporter statusReporter)
public PrintServer(IPrintService printService, ILogger logger, IJobStatusReporter statusReporter, Inspectron.Epson.PrintServer.Telemetry.IInsinTelemetry telemetry)
{
_printService = printService;
_logger = logger;
_statusReporter = statusReporter;
_telemetry = telemetry;
_printerQueues = new ConcurrentDictionary<string, PrinterQueue>();
}
/// <summary>
@@ -80,7 +81,7 @@ public class PrintServer
public void RegisterPrinter(string printerIp)
{
var queue = new PrinterQueue(printerIp, _printService, _logger, this, _statusReporter);
var queue = new PrinterQueue(printerIp, _printService, _logger, this, _statusReporter, _telemetry);
if (_printerQueues.TryAdd(printerIp, queue))
{
queue.Start();

View File

@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using Inspectron.Epson.PrintServer.Telemetry;
using Microsoft.Extensions.Logging;
namespace Inspectron.Epson.Queue;
@@ -15,11 +16,12 @@ public class PrinterQueue
private readonly ILogger _logger;
private readonly PrintServer _printServer;
private readonly IJobStatusReporter _statusReporter;
private readonly IInsinTelemetry _telemetry;
public bool IsProcessing { get; private set; }
public int QueueLength => _queue.Count + _priorityQueue.Count;
public PrinterQueue(string printerIp, IPrintService printService, ILogger logger, PrintServer printServer, IJobStatusReporter statusReporter)
public PrinterQueue(string printerIp, IPrintService printService, ILogger logger, PrintServer printServer, IJobStatusReporter statusReporter, IInsinTelemetry telemetry)
{
PrinterIp = printerIp;
_queue = new ConcurrentQueue<PrintJob>();
@@ -30,6 +32,7 @@ public class PrinterQueue
_logger = logger;
_printServer = printServer;
_statusReporter = statusReporter;
_telemetry = telemetry;
}
public void Enqueue(PrintJob job)
@@ -74,13 +77,23 @@ public class PrinterQueue
_logger.LogInformation("Processing job on printer {PrinterId}", PrinterIp);
_printServer.EnterPrintLock();
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
// Call the actual print function
var result = await _printService.PrintAsync(PrinterIp, job);
stopwatch.Stop();
if (!result.Success)
{
var failKind = InsinEventKinds.MapErrorTypeToKind(result.ErrorType);
SafeEmit(failKind, InsinMessageFormatter.Format(
("printer", PrinterIp),
("receiptType", job.Document?.ReceiptType.ToString()),
("retry", job.RetryCount.ToString()),
("durationMs", stopwatch.ElapsedMilliseconds.ToString()),
("error", result.ErrorType.ToString())));
if (result.ErrorType == PrintErrorType.ConversionError)
{
_logger.LogWarning("Job failed due to conversion error, not re-queuing");
@@ -108,12 +121,23 @@ public class PrinterQueue
{
_logger.LogInformation("Job completed successfully on printer {PrinterId}", PrinterIp);
await _statusReporter.ReportStatusAsync(job, PrintJobStatus.Completed);
SafeEmit(InsinEventKinds.JobPrinted, InsinMessageFormatter.Format(
("printer", PrinterIp),
("receiptType", job.Document?.ReceiptType.ToString()),
("durationMs", stopwatch.ElapsedMilliseconds.ToString())));
}
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "Error processing job");
await _statusReporter.ReportStatusAsync(job, PrintJobStatus.Failed);
SafeEmit(InsinEventKinds.MapErrorTypeToKind(PrintErrorType.Other), InsinMessageFormatter.Format(
("printer", PrinterIp),
("receiptType", job.Document?.ReceiptType.ToString()),
("retry", job.RetryCount.ToString()),
("durationMs", stopwatch.ElapsedMilliseconds.ToString()),
("error", ex.GetType().Name)));
}
finally
{
@@ -146,4 +170,10 @@ public class PrinterQueue
{
return new List<PrintJob>(_queue);
}
private void SafeEmit(string kind, string message)
{
try { _telemetry.Emit(kind, message); }
catch (Exception ex) { _logger.LogWarning(ex, "insin telemetry emit failed"); }
}
}

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Epson Print Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/epson-print-service
ExecStart=/opt/epson-print-service/EpsonPrintService
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target

1
deploy/insin/install.cfg Normal file
View File

@@ -0,0 +1 @@
install_path=/opt/epson-print-service

8
deploy/insin/postinst Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALL_DIR="$(pwd)"
chmod +x "$INSTALL_DIR/EpsonPrintService"
mkdir -p /var/lib/epson-print-service
install -m 644 epson.service /etc/systemd/system/epson.service
systemctl daemon-reload
systemctl enable --now epson.service

5
deploy/insin/prem Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
systemctl disable --now epson.service 2>/dev/null || true
rm -f /etc/systemd/system/epson.service
systemctl daemon-reload 2>/dev/null || true

File diff suppressed because it is too large Load Diff

54
scripts/publish-insin.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
: "${INSIN_URL:?INSIN_URL not set}"
: "${INSIN_TOKEN:?INSIN_TOKEN not set}"
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$repo_root"
version=$(dotnet msbuild EpsonPrintService/EpsonPrintService.csproj \
-getProperty:Version -nologo | tr -d '[:space:]')
echo "Publishing epson-print-service@${version}"
payload="publish/payload"
rm -rf publish && mkdir -p "$payload"
dotnet publish EpsonPrintService/EpsonPrintService.csproj \
-c Release -r linux-arm64 --self-contained true \
-p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true \
-o "$payload"
cp deploy/insin/install.cfg "$payload/"
cp deploy/insin/postinst "$payload/"
cp deploy/insin/prem "$payload/"
cp deploy/insin/epson.service "$payload/"
chmod +x "$payload/postinst" "$payload/prem"
mkdir -p .tools
case "$(uname -m)" in
x86_64) cli_rid=linux-x64 ;;
aarch64|arm64) cli_rid=linux-arm64 ;;
armv7l|armv6l) cli_rid=linux-arm ;;
*) echo "unsupported host arch: $(uname -m)" >&2; exit 1 ;;
esac
manifest=$(curl -fsSL "$INSIN_URL/api/v1/downloads/cli")
cli_version=$(printf '%s' "$manifest" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["version"])')
cli_filename=$(printf '%s' "$manifest" \
| python3 -c "import json,sys; a=next(x for x in json.load(sys.stdin)[0]['artifacts'] if x['rid']=='$cli_rid'); print(a['filename'])")
if [ ! -x ".tools/insin/insin" ] || [ "$(cat .tools/insin/.version 2>/dev/null)" != "$cli_version" ]; then
echo "Fetching insin CLI $cli_version"
curl -fsSL "$INSIN_URL/api/v1/downloads/cli/$cli_version/$cli_filename" -o /tmp/insin.tar.gz
rm -rf .tools/insin && mkdir -p .tools/insin
tar -xzf /tmp/insin.tar.gz -C .tools/insin
chmod +x .tools/insin/insin
echo "$cli_version" > .tools/insin/.version
fi
insin_bin="$repo_root/.tools/insin/insin"
( cd "$payload" && "$insin_bin" pack "epson-print-service@${version}" )
pkg="publish/packages/epson-print-service@${version}.pkg"
[ -f "$pkg" ] || { echo "expected $pkg missing"; exit 1; }
"$insin_bin" publish "$pkg"
echo "Published epson-print-service@${version}"