Henara.Imaging.Client 1.4.0

Henara.Imaging.Client

Client for the Henara imaging pipeline. Send scanned pages, get back one result per page: which Muster it was and the OCR reading, or why it was declined.

using var gateway = new HenaraImagingClient(new HenaraImagingOptions
{
    Address = "http://10.110.0.178:5080",
});

await foreach (var page in gateway.ReadStreamAsync(pages))
{
    if (page.IsRead) Save(page.PageClass!, page.ResultJson!);
    else if (page.IsDeclined) Review(page.FileName, page.Declined!.Status);
}

No protobuf types, no gRPC types, no channel management. The generated client is internal to the package.

Targets net8.0, net9.0 and net10.0. Deliberately not net10-only — the gateway itself is pinned there by a dependency, and that pin should not reach callers.


Install

dotnet add package Henara.Imaging.Client

Set it up

Either construct one directly and keep it:

using var gateway = new HenaraImagingClient(new HenaraImagingOptions
{
    Address = "http://10.110.0.178:5080",
});

Or register it for DI. Three overloads, same result — a singleton client:

// 1. from configuration, binding the "HenaraImaging" section
builder.Services.AddHenaraImaging(builder.Configuration);

// 2. configured in code
builder.Services.AddHenaraImaging(o => o.Address = "http://10.110.0.178:5080");

// 3. from an options object you already have
builder.Services.AddHenaraImaging(options);
// appsettings.json  -- for overload 1
"HenaraImaging": {
  "Address": "http://10.110.0.178:5080"
}

They differ in when a missing address is caught. Overloads 1 and 2 can only resolve theirs once the provider is built, so they validate at start-up. Overload 3 already has the finished object, so it throws right there at the registration call — where the stack trace points at the line that is actually wrong.

Overload 3 also uses the instance as-is rather than copying it, so it carries every property without this library needing to know about each one. The trade is that you keep a live reference: mutating the object after registering it still affects the client until it is first resolved. Hand over one you are done with.

Address resolution

An explicit address always wins. Otherwise HENARA_IMAGING_ADDRESS is used.

Address HENARA_IMAGING_ADDRESS result
set unset the option
unset set the variable
set set the option
unset unset throws, naming both

So the same build can be pointed at a different gateway per environment with no config file, which is what containers and CI usually want — while an explicit value stays authoritative.

// takes HENARA_IMAGING_ADDRESS
using var gateway = new HenaraImagingClient(new HenaraImagingOptions());

// a different variable
new HenaraImagingOptions { AddressVariable = "MY_GATEWAY" };

// no fallback: require an explicit address
new HenaraImagingOptions { AddressVariable = null };

HenaraImagingClient.Address reports what was actually resolved — worth logging at start-up. Under DI the check runs at boot (ValidateOnStart), so a missing address fails there rather than on the first scan.

Registered as a singleton, because the client owns one HTTP/2 connection and is built to be reused. A client per request leaks handles and pays connection setup every time. It is thread-safe.

Plain http:// is normal: the gateway speaks h2c behind a VPN. https:// works unchanged.

Send pages

method
ReadStreamAsync(pages) the default. Results as each page finishes
ReadAsync(pages) waits for all of it; results in input order
ReadOneAsync(page) one page
ReadFileAsync(path) one page straight off disk
ListTemplatesAsync() what this gateway can recognise
IsReadyAsync() is it up and its classifier ready
var pages = new List<DocumentPage>
{
    await DocumentPage.FromFileAsync(@"C:\scans\page1.jpg"),
    new DocumentPage("page2.png", bytes),
};

Prefer streaming

A page costs 13–24 seconds, nearly all of it OCR. Measured on a three-page batch:

ReadStreamAsync   first result after 13.5 s,  whole batch 24.0 s
ReadAsync         nothing until 26.2 s

For twenty pages that is the difference between working on page one after a few seconds and staring at nothing for five minutes.

Streamed results are not ordered — that is the point of them. Correlate on SourceIndex, which is the index of the page in the list you passed. ReadAsync does return input order.

Read the result

PageResult.Status is Read, Declined, Failed or Skipped. At most one of ResultJson, Declined and Error is set to match — a skipped page has none of them.

switch (page.Status)
{
    case PageStatus.Read:
        // page.PageClass, page.TemplateId, page.Side, page.Target
        Save(page.ResultJson!);
        break;

    case PageStatus.Declined:
        // Not recognised, and NEVER sent to OCR.
        Queue(page.FileName, page.Declined!.Status, page.Declined.Reasons);
        break;

    case PageStatus.Failed:
        // Recognised, but the OCR call did not return a reading.
        Retry(page.FileName, page.Error!.Code, page.Error.Target);
        break;

    case PageStatus.Skipped:
        // Recognised, and YOU asked for no reading of this class.
        HandleYourself(page.FileName, page.PageClass!);
        break;
}

PageClass is the routing answer:

class form
13 Heilmittelverordnung 13
56 pre-printed Muster 56
56_1e Muster 56.1/E + 56.2/E
reha_teilnahme Teilnahmebestätigung Rehabilitationssport
ft_teilnahme Teilnahmebestätigung Funktionstraining

A declined page is never a misroute. The recognizer declines rather than guess, which is the property the whole design rests on — so a declined page is a page for a human, not a failure to retry.

Side is Front, Back or Unknown. Unknown is a real answer, not a missing one: a declined page has no side, and defaulting it to Front would be a guess that reaches the reader.

Ask for more than fields

await gateway.ReadAsync(pages, new ReadOptions
{
    IncludeFieldImages = true,   // stamps and signatures, as cropped images
    IncludePdf = true,
    Autocrop = true,             // default; turn off for already-cropped images
});

IncludeFieldImages is the expensive one. Each attendance-sheet signature comes back as a ~42 KB base64 crop, so a filled sheet with 30 rows is roughly 900 KB instead of 2 KB. Off by default.

Drop the signature images

A Teilnahmebestätigung reading carries every row's signature as a base64 PNG whether you asked for it or not. If you only need to know whether a row was signed, say so:

await gateway.ReadAsync(pages, new ReadOptions
{
    StripSignatureImages = true,   // rows report found/not found, no image
});

Roughly 900 KB down to roughly 2 KB on a filled 30-row sheet.

This flag removes; every other one adds. The rest of ReadOptions is off-by-default and turns something on. This one is also off by default, which here means the images are still returned — setting it is what drops them. It is named for what it does rather than inverted into an IncludeSignatureImages, because that name defaulting to false would silently strip the images from every caller that never set it.

It is independent of IncludeFieldImages, which covers the Muster forms' declared stamp and signature regions and never reaches the attendance reader.

A stripped row still says whether it was signed. The reading reports signature.isFound either way; dropping the image does not turn a signed row into an unsigned one. What it does mean is that signature.content is gone, so code that infers presence from the base64 will report every row as unsigned — read isFound.

Needs a gateway on contract 1.4.0 or later. An older one drops the field silently, because proto3 discards unknown fields, and returns the images anyway. ImagingCatalog.IsGatewayCurrent is how you find out.

1.3.0 does not count, even if it carries the field. A second 1.3.0 was published that includes it, so one number covers two different contracts. Not even that one honours the flag — honouring it also needs Henara.Scan.Client 0.13.0 downstream, which no 1.3.0 gateway has. It accepts the field and throws it away. The floor is 1.4.0, not "whatever has the field".

Ask for less: classify without reading

For forms you already handle yourself, name the class and the gateway will identify the page and stop there:

await gateway.ReadAsync(pages, new ReadOptions
{
    SkipReadForClasses = ["56"],   // recognise Muster 56, do not read it
});

Those pages come back as PageStatus.Skipped with ResultJson null — not empty. The page is still fully identified: PageClass, TemplateId, Side and the Target that would have been called are all populated, so you can route it onward yourself without a second pass.

Classes are matched exactly. "56" does not cover "56_1e" — list both if you mean both. They share a reader (ReadMuster56) but they are different forms, and a list that quietly widened itself would stop returning readings for a form you never named. A class nothing emits is ignored rather than rejected, so a caller built against an older registry keeps working.

It is also much cheaper. Skipping is classification only — about 2 s/page against ~15 s for a read — so listing every class is a fast way to sort a mixed bundle before deciding what to read:

var sort = new ReadOptions
{
    SkipReadForClasses = ["13", "56", "56_1e", "reha_teilnahme", "ft_teilnahme"],
};

Declines are unaffected: a page the recognizer will not name is still Declined, whatever is in the list.

Classify only

When you want nothing read at all, ClassifyAsync says so directly instead of you naming every class:

var sorted = await gateway.ClassifyAsync(pages);

Same outcome as listing every class, but it does not depend on you knowing the registry — a class added to the gateway later is still not read, where a stale list would send it for a full OCR. Measured at ~2 s/page warm (the first call after the classifier starts is slower while it builds its registry).

Relay: read a page you have already identified

var result = await gateway.RelayAsync(page, "56", SheetSide.Front);

Skips classification and dispatches exactly the class you named. Nothing is inferred:

situation result
class no reader handles Failed, NO_READER_FOR_CLASS
13, 56, 56_1e with no side Failed, SIDE_REQUIRED
reha_teilnahme, ft_teilnahme side ignored, no need to pass one

The side is required for the per-side classes rather than defaulted, because a back page read by the front reader comes back as a confident, plausible, wrong reading. TemplateId is null on a relayed page — no template was matched.

It saves the ~2 s classification pass on a call whose OCR half measured between 11 s and 49 s for the same page, so treat relay as "do not classify what I already know", not as a speed-up.

Checking what you are talking to

ListTemplatesAsync reports what the gateway is, not just what its classifier is serving:

var catalog = await gateway.ListTemplatesAsync();

if (!catalog.IsGatewayCurrent)
    log.Warning("gateway contract {Gw} is older than this client's {Mine}",
        catalog.GatewayContractVersion, ImagingCatalog.ClientContractVersion);

GatewayContractVersion is the Henara.Imaging.Contracts version the gateway was compiled against, and it is the number that matters: proto3 drops unknown fields without an error, so an older gateway does not reject a newer request field — it simply never sees it. Both version fields are empty against a gateway older than 1.3.0, which is itself the answer.

That is not theoretical. A 1.2.0 client met a 1.1.0 gateway, classify_only was discarded on the wire, and every page went through a full OCR read instead: ten times the cost, no error, and readings nobody asked for. ClassifyAsync now also checks behaviourally — if a page comes back with a reading it throws, because that can only mean the flag was ignored.

Calling from synchronous code

Both have blocking wrappers for call sites that cannot await:

var sorted = gateway.ClassifySynchronous(pages);
var result = gateway.RelaySynchronous(page, "56", SheetSide.Front);

Prefer the async versions wherever you can await — these occupy a thread for the whole call. They dispatch to the thread pool before blocking, so they do not deadlock under a single-threaded synchronization context, which is the usual way sync-over-async fails.

Bundles: any number of pages, collected on one id

Everything above keeps the connection open for the whole read. Past about twenty pages, upload a bundle instead. The gateway returns one id as soon as it has the pages, then classifies them, reads them and keeps the results. You collect them once the bundle is ready.

builder.Services.AddHenaraBundles(builder.Configuration);   // "HenaraBundles": { "ConnectionString": "Data Source=henara-bundles.db" }
var receipt = await bundles.SubmitBundleAsync(pages, read, clientReference: $"case-{n}", tag: "ward-3");

await bundles.WaitForBundleAsync(receipt.BundleId,
    new Progress<BundleStatus>(s => Console.WriteLine(s)));     // "fully forwarded, 3/6 done"

var results = await bundles.FetchBundleListAsync(receipt.BundleId);   // PageResult, upload order

Or attach to the hosted BundleEventService:

events.BundleReady += async (_, s) => Use(await bundles.FetchBundleListAsync(s.BundleId));
  • The results are the same PageResult that ReadAsync returns: one per page, in upload order, declined and failed pages included.
  • Use a client reference. Submitting the same reference again returns the existing bundle without uploading anything, and FindBundleAsync finds the bundle again if its id was lost.
  • Tags are free text for grouping, such as a batch or a department. They're optional, not unique, at most 256 characters and fixed at upload. Read them from BundleStatus.Tag and filter with ListBundlesAsync(tag: ...). They need gateway 1.6.0; an older gateway drops them silently.
  • At most 1000 pages per bundle. Bundles are kept 48 h after they finish.
  • Ready means every page has an outcome, not that every page was read. Check IsFullySucceeded.
  • Fetching before Ready is refused unless you pass allowPartial: true.
  • ListBundlesAsync lists every bundle the gateway holds, from all applications. Filter by reference prefix to see one application's. For live display updates, use BundleEventService.AnyBundleChanged and reload on Connected. See samples/BundleDashboard.

Statuses, limits, restart behaviour and the gateway's SCAN_CLIENT_ID requirement are in docs/BUNDLES.md.

Timeouts

The per-call budget scales with the batch, because a constant one either strangles a large batch or lets a hung single page sit for minutes:

BaseTimeoutSeconds (30) + TimeoutSecondsPerPage (60) × pages

Both are on HenaraImagingOptions. MaxReceiveMessageBytes defaults to 64 MB, matching the gateway; the gRPC default of 4 MB is far too small for these responses and fails mid-batch rather than up front.

Failure modes

what you see what it means
IsReadyAsync() false gateway down, or its classifier still building its registry (~8 s after start)
Status == Declined not recognised. Do not retry; route to a human
Status == Skipped recognised, but its class is in your SkipReadForClasses. Not an error
Status == Failed, code Unavailable the OCR service could not be reached
Status == Failed, code NO_READER_FOR_CLASS recognised as something no OCR call handles
RpcException from the call itself the gateway could not reach its classifier at all — nothing could be decided about any page, so the whole call fails rather than returning invented per-page errors

Layout

henara-imaging-client/
  src/Henara.Imaging.Client/
    HenaraImagingClient.cs        the client
    HenaraImagingOptions.cs       address, limits, timeouts
    DocumentPage.cs               input page + ReadOptions
    PageResult.cs                 result types
    ServiceCollectionExtensions.cs  AddHenaraImaging

gateway.proto is not copied here, and this project no longer compiles it. The contract lives in henara-imaging-contracts and arrives as the Henara.Imaging.Contracts package, which the gateway references too — so the two sides of the connection cannot drift, and this repository builds from a standalone clone.

Consumers never deal with protobuf: everything on this package's public surface is a plain record. They do pick up Henara.Imaging.Contracts transitively, alongside Google.Protobuf and Grpc.Net.ClientFactory, which is ordinary for a gRPC client package.

Build and pack

dotnet build src/Henara.Imaging.Client
dotnet pack  src/Henara.Imaging.Client -c Release -o ./nupkg

Produces .nupkg and .snupkg with lib/net8.0, lib/net9.0, lib/net10.0 and XML docs for each.

To publish:

dotnet nuget push nupkg/Henara.Imaging.Client.1.1.0.nupkg \
  --source nuget.praxxme --api-key <key>

Version is set in the csproj.

Testing an unpublished contract

Henara.Imaging.Contracts comes from the private feed, and nuget.config pins it there — so by default a contract change has to be published before this package can build against it. That is right for CI and wrong for the ten minutes in which you are changing both.

local-packages/ is a local NuGet source for exactly that window. Pack the contract straight into it:

cd ../henara-imaging-contracts
dotnet pack src/Henara.Imaging.Contracts/Henara.Imaging.Contracts.csproj \
  -c Release -o ../henara-imaging-client/local-packages

cd ../henara-imaging-client
dotnet build src/Henara.Imaging.Client -c Release

The local copy wins while it is there. Delete it and the next restore goes back to the feed — worth doing before you trust a green build, since a version that only exists on your disk will fail in CI.

Two details that are easy to get wrong if you change this setup:

  • The directory must exist even when empty. A local source that is missing is NU1301, a hard restore error, which is why .gitkeep is committed. CI clones it empty and never notices.
  • PraxxMe repeats the exact package id in packageSourceMapping. NuGet matches the longest pattern and then considers only the sources declaring that pattern. With ContractsLocal claiming Henara.Imaging.Contracts and PraxxMe claiming only Henara.*, the specific pattern would win outright and CI would stop finding the contract on the feed.

.nupkg is gitignored, so nothing you drop in local-packages/ is committed.

Verified

Built as a package, then consumed from a net8.0 project referencing the .nupkg — not the source — against the live gateway:

IsReadyAsync       -> True
ListTemplatesAsync -> 8 templates, contract 1.0, digest 88bc0597ffebd2f5
                      6 validated, 8 active

ReadStreamAsync    first result after 13.5 s, whole batch 24.0 s
  [0] 2241845_….Jpeg: 13 (Front) via ReadMuster13, 1233 chars
  [1] 2257197_….Jpeg: reha_teilnahme (Front) via ReadAttendanceSheet, 731 chars
  [2] junk.bin: declined (INVALID_IMAGE)

ReadAsync          2 read, 1 declined, 0 failed, in 26.2 s
  order preserved on ReadAsync:      True
  declined page carries no reading:  True

Address resolution, all nine cases:

1. options only, no env var                    -> http://10.110.0.178:5080
2. env var only, Address unset                 -> http://10.110.0.178:5080
3. BOTH set: options must win                  -> (options won)
4. neither set                                 -> InvalidOperationException, names both
5. fallback disabled (AddressVariable = null)  -> InvalidOperationException
6. custom variable name                        -> http://10.110.0.178:5080
7. DI, empty config, env var supplies it       -> http://10.110.0.178:5080
8. DI, config wins over env var                -> (config won)
9. DI, neither: fails validation               -> OptionsValidationException at boot

No packages depend on Henara.Imaging.Client.

Version Downloads Last updated
2.1.0 1 11/06/2026
2.0.0 2 11/05/2026
1.8.1 1 10/19/2026
1.8.0 1 10/19/2026
1.7.0 2 10/14/2026
1.6.0 1 10/14/2026
1.5.0 2 10/14/2026
1.4.0 1 10/14/2026
1.3.0 0 10/14/2026
1.2.0 13 10/05/2026
1.0.2 1 10/05/2026
1.0.1 0 10/01/2026