Compare commits
17 Commits
dc9bbbe467
...
894836f94d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
894836f94d | ||
|
|
421f3085f0 | ||
|
|
7105659ab0 | ||
|
|
732ee08561 | ||
|
|
21ecea9741 | ||
|
|
7dfce95dc1 | ||
|
|
120634e569 | ||
|
|
c6d0d58722 | ||
|
|
e70126be2b | ||
|
|
050616b595 | ||
|
|
465d9b4b4d | ||
|
|
37318f9653 | ||
|
|
2c52861d70 | ||
|
|
d01efdd8c2 | ||
|
|
f8e5515050 | ||
|
|
360320a474 | ||
|
|
042b9c9633 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -360,4 +360,8 @@ MigrationBackup/
|
|||||||
.ionide/
|
.ionide/
|
||||||
|
|
||||||
# Fody - auto-generated XML schema
|
# Fody - auto-generated XML schema
|
||||||
FodyWeavers.xsd
|
FodyWeavers.xsd
|
||||||
|
|
||||||
|
# Insin publish artifacts
|
||||||
|
.tools/
|
||||||
|
publish/
|
||||||
64
CLAUDE.md
64
CLAUDE.md
@@ -287,3 +287,67 @@ Font magnification is 1-8x for both width and height:
|
|||||||
- Check RestaurantId and ApiKey in config.txt (base64-encoded)
|
- Check RestaurantId and ApiKey in config.txt (base64-encoded)
|
||||||
- Verify network connectivity to the API URL
|
- Verify network connectivity to the API URL
|
||||||
- SignalR auto-reconnects with infinite retry; check logs for reconnection attempts
|
- SignalR auto-reconnects with infinite retry; check logs for reconnection attempts
|
||||||
|
|
||||||
|
## Insin Integration
|
||||||
|
|
||||||
|
This service is deployed as an Insin package and emits telemetry via the on-device Insin agent's **local loopback listener**. Env vars `INSIN_URL` and `INSIN_TOKEN` are provided by the deployment environment.
|
||||||
|
|
||||||
|
### Publishing events (local loopback — the path we use)
|
||||||
|
|
||||||
|
Because this service runs on Insin-managed devices where `insin monitor` (the `insin.service` systemd unit) is active, events go to the loopback ingest — **not** a remote HTTP endpoint. The on-device agent persists locally and forwards on the next device heartbeat.
|
||||||
|
|
||||||
|
- **Endpoint:** `POST http://127.0.0.1:47823/events` (loopback-only, no auth).
|
||||||
|
- **Single event payload:** `{"kind": "<subsystem>.<verb>[.<qualifier>]", "message": "<free-form>", "at": "<ISO-8601 UTC>"}`.
|
||||||
|
- **Batch payload:** `{"events": [ {...}, {...} ]}`.
|
||||||
|
- **Event `kind` conventions** for this service:
|
||||||
|
- `job.printed` — successful print, message includes printer + jobId
|
||||||
|
- `job.failed.<reason>` — e.g. `job.failed.paper_out`, `job.failed.cover_open`, `job.failed.connection`
|
||||||
|
- `printer.online` / `printer.offline` — status transitions
|
||||||
|
- `printer.discovered` — new printer found by discovery
|
||||||
|
- **Metrics** use the same shape at `POST /metrics` with `{name, value, unit}` (e.g. `printer.head.temperature`).
|
||||||
|
- Delivery: at-least-once, server ring-trims to ~1000 per device. Admin UI polls every 5s. No rate limiting or 429s; each POST commits to local SQLite before returning 200.
|
||||||
|
- **If `127.0.0.1:47823` is unreachable**, `insin monitor` isn't running — do not swallow this silently in production; log it. In dev, just no-op.
|
||||||
|
- Full reference: `docs/device-telemetry-api.md` in the Insin repo.
|
||||||
|
|
||||||
|
### Deploying / publishing this service as an Insin package
|
||||||
|
|
||||||
|
Insin uses a flat global package namespace with the `AdminToken` auth header (NOT `Bearer`). Publish flow for CI or a release script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -euo pipefail
|
||||||
|
: "${INSIN_URL:?}" "${INSIN_TOKEN:?}"
|
||||||
|
|
||||||
|
# 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-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
|
||||||
|
chmod +x /tmp/insin/insin
|
||||||
|
|
||||||
|
# 2. Pack. NOTE: pack zips CWD recursively (minus *.pkg) and writes to
|
||||||
|
# ../packages/<name>@<version>.pkg — one directory UP from CWD.
|
||||||
|
# cd into the build output first; don't run from repo root (would bundle .git/).
|
||||||
|
cd EpsonPrintService/bin/Release/net8.0/publish
|
||||||
|
/tmp/insin/insin pack epson-print-service@1.2.3
|
||||||
|
|
||||||
|
# 3. Publish. Reads INSIN_URL + INSIN_TOKEN from env; --url/--token override.
|
||||||
|
/tmp/insin/insin publish ../packages/epson-print-service@1.2.3.pkg
|
||||||
|
```
|
||||||
|
|
||||||
|
Under the hood `publish` = `POST $INSIN_URL/api/v1/admin/packages` (multipart form, field `file`) with header `Authorization: AdminToken $INSIN_TOKEN`.
|
||||||
|
|
||||||
|
### Insin rules & gotchas
|
||||||
|
|
||||||
|
- **Auth header:** `Authorization: AdminToken <token>` — NOT `Bearer`. Same header for master admin token AND service tokens.
|
||||||
|
- **Always use a service token** (minted in admin UI → Service Tokens → New token, plaintext shown once). Never ship the master `INSIN_ADMIN_TOKEN`.
|
||||||
|
- **`INSIN_URL` is a bare origin:** no trailing slash, no `/api` suffix. Just `https://insin.example.com`.
|
||||||
|
- **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** 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`.
|
||||||
|
- **Delete a version:** `DELETE /api/v1/admin/packages/{name}/{version}` (same header).
|
||||||
|
- No shared public staging — stand up a scratch instance if you need one.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace EpsonPrintService;
|
|||||||
|
|
||||||
public static class ConfigurationPaths
|
public static class ConfigurationPaths
|
||||||
{
|
{
|
||||||
private const string LinuxConfigDir = "/root/epsonprintservice";
|
private const string LinuxConfigDir = "/opt/epson-print-service";
|
||||||
private const string LocalConfigFileName = "config.txt";
|
private const string LocalConfigFileName = "config.txt";
|
||||||
|
|
||||||
public static string GetConfigPath()
|
public static string GetConfigPath()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<Version>1.0.14</Version>
|
<Version>1.0.15</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ using Inspectron.Epson.PrintServer.PrinterAssinment;
|
|||||||
using Inspectron.Epson.PrintServer.Printers;
|
using Inspectron.Epson.PrintServer.Printers;
|
||||||
using Inspectron.Epson.PrintServer.Printers.Utils;
|
using Inspectron.Epson.PrintServer.Printers.Utils;
|
||||||
using Inspectron.Epson.PrintServer.PrintServices;
|
using Inspectron.Epson.PrintServer.PrintServices;
|
||||||
|
using Inspectron.Epson.PrintServer.Telemetry;
|
||||||
using Inspectron.Epson.Queue;
|
using Inspectron.Epson.Queue;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Ninject;
|
using Ninject;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
namespace EpsonPrintService;
|
namespace EpsonPrintService;
|
||||||
|
|
||||||
@@ -45,6 +47,10 @@ public class PrintServerBootstrapper
|
|||||||
_kernel = new StandardKernel();
|
_kernel = new StandardKernel();
|
||||||
|
|
||||||
_kernel.Bind<HttpClient>().ToConstant(new HttpClient { Timeout = TimeSpan.FromSeconds(30) }).InSingletonScope();
|
_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);
|
_kernel.Bind<EpsonPrintServiceConfiguration>().ToConstant(_config);
|
||||||
|
|
||||||
if (_config.EmulationMode)
|
if (_config.EmulationMode)
|
||||||
@@ -97,7 +103,12 @@ public class PrintServerBootstrapper
|
|||||||
{
|
{
|
||||||
_kernel.Bind<PrintJobArrivedHandler>().ToConstant(handler);
|
_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<PrinterDiscoveryBackgroundTask>().ToSelf().InSingletonScope();
|
||||||
_kernel.Bind<HeartbeatBackgroundTask>().ToSelf().InSingletonScope();
|
_kernel.Bind<HeartbeatBackgroundTask>().ToSelf().InSingletonScope();
|
||||||
|
|
||||||
@@ -120,5 +131,15 @@ public class PrintServerBootstrapper
|
|||||||
|
|
||||||
IsRunning = true;
|
IsRunning = true;
|
||||||
Console.WriteLine("Print server started.");
|
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 */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Inspectron.Epson.Tests/GlobalUsings.cs
Normal file
1
Inspectron.Epson.Tests/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
global using Xunit;
|
||||||
26
Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
Normal file
26
Inspectron.Epson.Tests/Inspectron.Epson.Tests.csproj
Normal 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>
|
||||||
7
Inspectron.Epson.Tests/Sanity/SanityTests.cs
Normal file
7
Inspectron.Epson.Tests/Sanity/SanityTests.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Inspectron.Epson.Tests.Sanity;
|
||||||
|
|
||||||
|
public class SanityTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Truth_is_true() => Assert.True(true);
|
||||||
|
}
|
||||||
28
Inspectron.Epson.Tests/Telemetry/InsinEventKindsTests.cs
Normal file
28
Inspectron.Epson.Tests/Telemetry/InsinEventKindsTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Inspectron.Epson.Tests/Telemetry/NullInsinTelemetryTests.cs
Normal file
14
Inspectron.Epson.Tests/Telemetry/NullInsinTelemetryTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
</Folder>
|
</Folder>
|
||||||
<Project Path="EpsonPrintService/EpsonPrintService.csproj" />
|
<Project Path="EpsonPrintService/EpsonPrintService.csproj" />
|
||||||
<Project Path="Inspectron.Epson.TemplateEngine.Tests/Inspectron.Epson.TemplateEngine.Tests.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.TemplateEngine/Inspectron.Epson.TemplateEngine.csproj" />
|
||||||
<Project Path="Inspectron.Epson/Inspectron.Epson.csproj" />
|
<Project Path="Inspectron.Epson/Inspectron.Epson.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Inspectron.Epson.PrintServer.Telemetry;
|
||||||
|
|
||||||
|
public interface IInsinTelemetry
|
||||||
|
{
|
||||||
|
void Emit(string kind, string message);
|
||||||
|
}
|
||||||
19
Inspectron.Epson/PrintServer/Telemetry/InsinEventKinds.cs
Normal file
19
Inspectron.Epson/PrintServer/Telemetry/InsinEventKinds.cs
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Inspectron.Epson.PrintServer.Telemetry;
|
||||||
|
|
||||||
|
public sealed class NullInsinTelemetry : IInsinTelemetry
|
||||||
|
{
|
||||||
|
public void Emit(string kind, string message) { }
|
||||||
|
}
|
||||||
@@ -8,18 +8,19 @@ public class PrintServer
|
|||||||
private readonly IPrintService _printService;
|
private readonly IPrintService _printService;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private readonly IJobStatusReporter _statusReporter;
|
private readonly IJobStatusReporter _statusReporter;
|
||||||
|
private readonly Inspectron.Epson.PrintServer.Telemetry.IInsinTelemetry _telemetry;
|
||||||
private readonly ConcurrentDictionary<string, PrinterQueue> _printerQueues;
|
private readonly ConcurrentDictionary<string, PrinterQueue> _printerQueues;
|
||||||
private readonly SemaphoreSlim _printDiscoveryLock = new(1, 1);
|
private readonly SemaphoreSlim _printDiscoveryLock = new(1, 1);
|
||||||
private int _readerCount = 0;
|
private int _readerCount = 0;
|
||||||
private readonly object _readerCountLock = new();
|
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;
|
_printService = printService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_statusReporter = statusReporter;
|
_statusReporter = statusReporter;
|
||||||
|
_telemetry = telemetry;
|
||||||
_printerQueues = new ConcurrentDictionary<string, PrinterQueue>();
|
_printerQueues = new ConcurrentDictionary<string, PrinterQueue>();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -80,7 +81,7 @@ public class PrintServer
|
|||||||
|
|
||||||
public void RegisterPrinter(string printerIp)
|
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))
|
if (_printerQueues.TryAdd(printerIp, queue))
|
||||||
{
|
{
|
||||||
queue.Start();
|
queue.Start();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using Inspectron.Epson.PrintServer.Telemetry;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Inspectron.Epson.Queue;
|
namespace Inspectron.Epson.Queue;
|
||||||
@@ -15,11 +16,12 @@ public class PrinterQueue
|
|||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private readonly PrintServer _printServer;
|
private readonly PrintServer _printServer;
|
||||||
private readonly IJobStatusReporter _statusReporter;
|
private readonly IJobStatusReporter _statusReporter;
|
||||||
|
private readonly IInsinTelemetry _telemetry;
|
||||||
|
|
||||||
public bool IsProcessing { get; private set; }
|
public bool IsProcessing { get; private set; }
|
||||||
public int QueueLength => _queue.Count + _priorityQueue.Count;
|
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;
|
PrinterIp = printerIp;
|
||||||
_queue = new ConcurrentQueue<PrintJob>();
|
_queue = new ConcurrentQueue<PrintJob>();
|
||||||
@@ -30,6 +32,7 @@ public class PrinterQueue
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_printServer = printServer;
|
_printServer = printServer;
|
||||||
_statusReporter = statusReporter;
|
_statusReporter = statusReporter;
|
||||||
|
_telemetry = telemetry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Enqueue(PrintJob job)
|
public void Enqueue(PrintJob job)
|
||||||
@@ -74,13 +77,23 @@ public class PrinterQueue
|
|||||||
_logger.LogInformation("Processing job on printer {PrinterId}", PrinterIp);
|
_logger.LogInformation("Processing job on printer {PrinterId}", PrinterIp);
|
||||||
|
|
||||||
_printServer.EnterPrintLock();
|
_printServer.EnterPrintLock();
|
||||||
|
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Call the actual print function
|
// Call the actual print function
|
||||||
var result = await _printService.PrintAsync(PrinterIp, job);
|
var result = await _printService.PrintAsync(PrinterIp, job);
|
||||||
|
stopwatch.Stop();
|
||||||
|
|
||||||
if (!result.Success)
|
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)
|
if (result.ErrorType == PrintErrorType.ConversionError)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Job failed due to conversion error, not re-queuing");
|
_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);
|
_logger.LogInformation("Job completed successfully on printer {PrinterId}", PrinterIp);
|
||||||
await _statusReporter.ReportStatusAsync(job, PrintJobStatus.Completed);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
stopwatch.Stop();
|
||||||
_logger.LogError(ex, "Error processing job");
|
_logger.LogError(ex, "Error processing job");
|
||||||
await _statusReporter.ReportStatusAsync(job, PrintJobStatus.Failed);
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -146,4 +170,10 @@ public class PrinterQueue
|
|||||||
{
|
{
|
||||||
return new List<PrintJob>(_queue);
|
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"); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
14
deploy/insin/epson.service
Normal file
14
deploy/insin/epson.service
Normal 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
1
deploy/insin/install.cfg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
install_path=/opt/epson-print-service
|
||||||
8
deploy/insin/postinst
Executable file
8
deploy/insin/postinst
Executable 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
5
deploy/insin/prem
Executable 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
|
||||||
1399
docs/superpowers/plans/2026-07-23-insin-integration.md
Normal file
1399
docs/superpowers/plans/2026-07-23-insin-integration.md
Normal file
File diff suppressed because it is too large
Load Diff
287
docs/superpowers/specs/2026-07-23-insin-integration-design.md
Normal file
287
docs/superpowers/specs/2026-07-23-insin-integration-design.md
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
# Insin Integration — Design
|
||||||
|
|
||||||
|
**Status:** design (approved via brainstorming)
|
||||||
|
**Date:** 2026-07-23
|
||||||
|
**Scope:** Combined runtime telemetry emission and manual publish flow for shipping this service as an Insin package.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Two integrations with the Insin package server + on-device agent:
|
||||||
|
|
||||||
|
1. **Runtime telemetry** — emit a small set of events to Insin so operators can see live service and printer state in the Insin admin UI.
|
||||||
|
2. **Package delivery** — package the service as an Insin `.pkg` so it can be delivered, installed, and upgraded on Raspberry Pi devices by the on-device `insin monitor` agent, replacing the current hand-copy-to-Pi workflow.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Runs on Raspberry Pi (linux-arm64). Currently deployed manually via `scp` + hand-installed systemd units (`epson.service`, `print_server.service`).
|
||||||
|
- Insin is *delivery + installer + telemetry ingest*, not a process supervisor. Systemd stays on the device.
|
||||||
|
- The on-device agent (`insin monitor`, running under `insin.service`) exposes a loopback HTTP listener at `http://127.0.0.1:47823/events` — this is the only usable event-ingest path for a service that isn't a device itself.
|
||||||
|
- Environment secrets `INSIN_URL` and `INSIN_TOKEN` are provided on the machine that runs the publish script (developer machine, not the Pi).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No CI automation. Publish is a hand-run script triggered by the developer.
|
||||||
|
- No metrics (numeric samples), only events. Metrics can be added later if a specific need appears.
|
||||||
|
- No status-poll background task. Online/offline signal is driven purely by the existing discovery cycle.
|
||||||
|
- No persistence of "seen printer IPs" — restart re-fires `printer.discovered` for already-known printers. Noise is acceptable.
|
||||||
|
- No fleet or rollback automation. Reassigning group versions in the Insin admin UI is the operator's job.
|
||||||
|
|
||||||
|
## Runtime — Telemetry Emission
|
||||||
|
|
||||||
|
### Event catalog
|
||||||
|
|
||||||
|
| Kind | When | Message shape |
|
||||||
|
|---|---|---|
|
||||||
|
| `service.started` | Once, at end of `PrintServerBootstrapper.StartAsync()` | `version=<v> restaurantId=<id>` |
|
||||||
|
| `job.printed` | `PrinterQueue.ProcessQueueAsync()` on `PrintResult.Ok` | `printer=<ip> receiptType=<type> durationMs=<n>` |
|
||||||
|
| `job.failed.<reason>` | `PrinterQueue.ProcessQueueAsync()` on `PrintResult.Fail` | `printer=<ip> receiptType=<type> retry=<n> error="<short msg>"` |
|
||||||
|
| `printer.discovered` | First time an IP is seen after service start | `printer=<ip> model=<name>` |
|
||||||
|
| `printer.online` | Discovery cycle where an IP transitions from absent → present | `printer=<ip>` |
|
||||||
|
| `printer.offline` | Discovery cycle where an IP transitions from present → absent | `printer=<ip>` |
|
||||||
|
|
||||||
|
**`PrintErrorType` → suffix mapping** (in `PrinterQueue`):
|
||||||
|
|
||||||
|
- `PaperOut` → `paper_out`
|
||||||
|
- `CoverOpen` → `cover_open`
|
||||||
|
- `Connection` → `connection`
|
||||||
|
- anything else → `other`
|
||||||
|
|
||||||
|
**Message format**: flat `key=value` pairs, space-separated. Values containing spaces are double-quoted (`error="paper end detected"`). No structured JSON in the message body.
|
||||||
|
|
||||||
|
### Interface
|
||||||
|
|
||||||
|
New namespace: `Inspectron.Epson.PrintServer.Telemetry`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface IInsinTelemetry
|
||||||
|
{
|
||||||
|
void Emit(string kind, string message);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementations
|
||||||
|
|
||||||
|
- **`LoopbackInsinTelemetry`** — posts `{"kind": <k>, "message": <m>, "at": <ISO-8601 UTC>}` to `http://127.0.0.1:47823/events`. 2-second timeout, fire-and-forget on a background task. Failures are caught and logged at `LogLevel.Warning` at most once per 5-minute window with `"insin loopback unreachable at 127.0.0.1:47823, dropping events"`. Never re-throws. Uses the singleton `HttpClient` already bound in `PrintServerBootstrapper`.
|
||||||
|
- **`NullInsinTelemetry`** — no-op. Bound when `EpsonPrintServiceConfiguration.EmulationMode` is true.
|
||||||
|
|
||||||
|
### DI binding
|
||||||
|
|
||||||
|
In `PrintServerBootstrapper.StartAsync()`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (_config.EmulationMode)
|
||||||
|
_kernel.Bind<IInsinTelemetry>().To<NullInsinTelemetry>().InSingletonScope();
|
||||||
|
else
|
||||||
|
_kernel.Bind<IInsinTelemetry>().To<LoopbackInsinTelemetry>().InSingletonScope();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wiring — where events fire from
|
||||||
|
|
||||||
|
- **`service.started`** — one-line call at the end of `PrintServerBootstrapper.StartAsync()`, after `IsRunning = true`, reading `_config` for restaurant id and reflecting the running assembly's `InformationalVersion` / `Version`.
|
||||||
|
- **`job.printed` / `job.failed.<reason>`** — inside `PrinterQueue.ProcessQueueAsync()`, at the point where `PrintResult` is inspected. Pass through the `PrintJob` (for printer ip, receipt type, retry count) and the `PrintErrorType` on failure. `durationMs` is measured from just before `IPrintService.PrintAsync` to just after.
|
||||||
|
- **`printer.discovered` / `printer.online` / `printer.offline`** — new decorator `InsinTelemetryDiscoveredPrintersReceiver` that wraps `JamesDiscoveredPrintersReceiver`. Holds:
|
||||||
|
- `HashSet<string> _seenIps` — IPs seen at least once during this process's lifetime.
|
||||||
|
- `Dictionary<string, bool> _present` — last-known presence per IP, keyed by IP.
|
||||||
|
On each `Handle(...)` call:
|
||||||
|
1. Delegate to the wrapped receiver first (behavior preserved).
|
||||||
|
2. Compute the diff between the incoming set of IPs and `_present`.
|
||||||
|
3. For each new IP not in `_seenIps`: `Emit("printer.discovered", ...)`, then add to `_seenIps`.
|
||||||
|
4. For each IP transitioning absent → present: `Emit("printer.online", ...)`.
|
||||||
|
5. For each IP transitioning present → absent: `Emit("printer.offline", ...)`.
|
||||||
|
6. Update `_present`.
|
||||||
|
Bound in DI as `IDiscoveredPrintersReceiver`, taking `JamesDiscoveredPrintersReceiver` as a constructor dependency.
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
- All `Emit` call sites are wrapped in `try/catch (Exception)` so a bug in message formatting can't break the caller. Caught exceptions log at `LogLevel.Warning`.
|
||||||
|
- `LoopbackInsinTelemetry.Emit` never throws. HTTP failures are handled internally with rate-limited warn logging.
|
||||||
|
- Print flow is never blocked on telemetry — every send is fire-and-forget on `Task.Run`.
|
||||||
|
|
||||||
|
## Package Delivery
|
||||||
|
|
||||||
|
### `.pkg` layout
|
||||||
|
|
||||||
|
```
|
||||||
|
epson-print-service@<version>.pkg (zip)
|
||||||
|
├── install.cfg
|
||||||
|
├── epson-print-service # self-contained single-file linux-arm64 binary
|
||||||
|
├── epson.service # systemd unit — main service
|
||||||
|
├── print_server.service # systemd unit — companion
|
||||||
|
├── postinst
|
||||||
|
└── prem
|
||||||
|
```
|
||||||
|
|
||||||
|
### `install.cfg`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
install_path=/opt/epson-print-service
|
||||||
|
```
|
||||||
|
|
||||||
|
### `postinst` (CWD = install dir)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
INSTALL_DIR="$(pwd)"
|
||||||
|
chmod +x "$INSTALL_DIR/epson-print-service"
|
||||||
|
mkdir -p /var/lib/epson-print-service
|
||||||
|
install -m 644 epson.service /etc/systemd/system/epson.service
|
||||||
|
install -m 644 print_server.service /etc/systemd/system/print_server.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now epson.service print_server.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Idempotent: safe to re-run on upgrade over an existing install.
|
||||||
|
|
||||||
|
### `prem` (CWD = temp dir on install, install dir on remove)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
systemctl disable --now epson.service print_server.service 2>/dev/null || true
|
||||||
|
rm -f /etc/systemd/system/epson.service /etc/systemd/system/print_server.service
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
Idempotent: safe to run when the unit was never installed or has already been removed. Critical: this MUST stop the systemd unit before install-time file copy, otherwise files get overwritten under a running process.
|
||||||
|
|
||||||
|
### `epson.service`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[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/epson-print-service
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
`print_server.service` is rewritten to the same shape (companion service kept from current deploy).
|
||||||
|
|
||||||
|
### Config-path change
|
||||||
|
|
||||||
|
`ConfigurationPaths.Linux` currently returns `/root/epsonprintservice/config.txt`. This is incompatible with running from `/opt/epson-print-service/` under systemd. **Change:** `ConfigurationPaths.Linux` returns `/opt/epson-print-service/config.txt`. Users generate `config.txt` and drop it into the install directory post-install.
|
||||||
|
|
||||||
|
### Missing-config-at-first-install behavior
|
||||||
|
|
||||||
|
The service fails to start. Systemd's `Restart=on-failure RestartSec=5s` retries every 5s. When the operator drops `config.txt` into `/opt/epson-print-service/`, the next restart picks it up. No code change needed — this is already the current behavior.
|
||||||
|
|
||||||
|
## Publish Script
|
||||||
|
|
||||||
|
### Trigger
|
||||||
|
|
||||||
|
Developer runs `./scripts/publish-insin.sh` from the repo root when they choose to ship. **No CI. No Gitea workflow. No scheduled automation.**
|
||||||
|
|
||||||
|
### Version source of truth
|
||||||
|
|
||||||
|
`<Version>` in `EpsonPrintService/EpsonPrintService.csproj`. Human bumps it in the PR that ships the change (matches existing cadence: `bump EpsonPrintService version to 1.0.14`). The script reads it via `dotnet msbuild -getProperty:Version`.
|
||||||
|
|
||||||
|
Re-publishing the same version returns 409 Conflict from Insin — this is the "you forgot to bump" reminder. The script surfaces the error and exits non-zero.
|
||||||
|
|
||||||
|
### Repo additions
|
||||||
|
|
||||||
|
- `scripts/publish-insin.sh` — the script below (git-tracked, executable).
|
||||||
|
- `deploy/insin/` — `install.cfg`, `postinst`, `prem`, `epson.service`, `print_server.service` (git-tracked).
|
||||||
|
- `.gitignore` — add `.tools/` (insin CLI cache) and `publish/` (build output).
|
||||||
|
|
||||||
|
### `scripts/publish-insin.sh`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/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/"
|
||||||
|
cp deploy/insin/print_server.service "$payload/"
|
||||||
|
chmod +x "$payload/postinst" "$payload/prem"
|
||||||
|
|
||||||
|
mkdir -p .tools
|
||||||
|
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"]=="linux-arm64"); 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}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Prerequisite for the developer running publish: the machine must be able to execute the `insin` CLI (arm64 or win-x64 build available). On x86_64 Linux, that means `qemu-user-static` installed. Outside the scope of this spec.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Runtime unit tests (in `EpsonTest`)
|
||||||
|
|
||||||
|
- **`LoopbackInsinTelemetryTests`** — spin up an in-test `HttpListener` on `127.0.0.1:47823`, capture posted payloads, assert `kind`, `message`, ISO-8601 `at`. Cases: happy path, request timeout, connection refused (no listener bound), 500 response. Verify the rate-limited warn log fires once per 5-minute window and not more.
|
||||||
|
- **`InsinTelemetryDiscoveredPrintersReceiverTests`** — mock `IDiscoveredPrintersReceiver` inner, feed sequences of discovery cycles. Assertions:
|
||||||
|
- First-see: `printer.discovered` + `printer.online` in that order.
|
||||||
|
- Re-see: no events.
|
||||||
|
- Absent after present: `printer.offline`.
|
||||||
|
- Re-appearance after offline: `printer.online` only (not `discovered` again).
|
||||||
|
- Wrapped receiver's `Handle` is called every cycle regardless of telemetry.
|
||||||
|
- **`PrinterQueueTelemetryTests`** — mock `IInsinTelemetry`, run one success and one failure of each `PrintErrorType`, assert `kind` suffix mapping and message content (printer, receipt type, retry count).
|
||||||
|
|
||||||
|
### Manual acceptance test (single Pi with real hardware)
|
||||||
|
|
||||||
|
1. `./scripts/publish-insin.sh` (with bumped version).
|
||||||
|
2. Assign package to device group in Insin admin UI.
|
||||||
|
3. Wait one monitor tick (~60s).
|
||||||
|
4. `systemctl status epson.service` shows `active (running)`.
|
||||||
|
5. `service.started` and `printer.discovered` events appear in the Insin admin UI.
|
||||||
|
6. Trigger a real print → `job.printed` event.
|
||||||
|
7. Pull paper roll, trigger a print → `job.failed.paper_out`.
|
||||||
|
8. Bump version, re-publish, reassign to new version. Confirm rolling upgrade: old process stopped, new process running, no lingering unit files, no telemetry gaps beyond the restart window.
|
||||||
|
|
||||||
|
### Not tested (deferred)
|
||||||
|
|
||||||
|
- Multi-Pi fleet.
|
||||||
|
- Rollback smoke (reassigning to an older version). Same code path as upgrade — worth doing once but not blocking.
|
||||||
|
- Automated end-to-end.
|
||||||
|
|
||||||
|
## Rollout plan
|
||||||
|
|
||||||
|
1. Land runtime telemetry (interface, implementations, wiring, tests).
|
||||||
|
2. Land package assets (`deploy/insin/`, `.csproj` `<Version>` review, `ConfigurationPaths.Linux` change).
|
||||||
|
3. Land publish script.
|
||||||
|
4. First manual publish + smoke on a single Pi.
|
||||||
|
5. Assign to the rest of the fleet if smoke passes.
|
||||||
54
scripts/publish-insin.sh
Executable file
54
scripts/publish-insin.sh
Executable 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}"
|
||||||
Reference in New Issue
Block a user