Henara.Imaging.Client 1.8.1
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);
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 BundleEventService, and start it once the application is up:
events.BundleReady += async (_, s) => Use(await bundles.FetchBundleListAsync(s.BundleId));
await events.StartAsync();
Nothing is kept on the client. The gateway's status is the record. In return,
BundleReadyfires once per bundle per process and again after a restart, for every finished bundle the gateway still holds, so handlers must be idempotent. SetHenaraBundles:ClientReferencePrefixto hear only about your own bundles.The results are the same
PageResultthatReadAsyncreturns: 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
FindBundleAsyncfinds 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.Tagand filter withListBundlesAsync(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.
Readymeans every page has an outcome, not that every page was read. CheckIsFullySucceeded.Fetching before
Readyis refused unless you passallowPartial: true.DeleteBundleAsync(id)deletes a finished bundle and everything it holds on the gateway. A running one needsforce: true. Pages already forwarded are still read and billed at the OCR service, which can't cancel jobs. Deleting a bundle that's already gone returnsfalse. Needs gateway 1.7.0.Page ids (gateway 1.8.0): the receipt lists every page with the id the gateway gave it.
BundleEventService.PageFinishednames each page as it finishes, andFetchPageAsync(bundleId, pageId, includeOriginal: true)fetches it at once, with the image exactly as uploaded.BundleFetchOptionsdoes the same for several pages or the whole bundle.Restart failed pages (gateway 1.8.0) with
RestartPagesAsync(bundleId). Pages that failed at the OCR service are read again under the same page id; pages that failed before reaching it are listed as not restarted, with the reason.PageResult.OcrJobIdnames the OCR job, for support.ListBundlesAsynclists every bundle the gateway holds, from all applications. Filter by reference prefix to see one application's. For live display updates, useBundleEventService.BundleChangedand reload onConnected. Seesamples/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.gitkeepis committed. CI clones it empty and never notices. PraxxMerepeats the exact package id inpackageSourceMapping. NuGet matches the longest pattern and then considers only the sources declaring that pattern. WithContractsLocalclaimingHenara.Imaging.ContractsandPraxxMeclaiming onlyHenara.*, 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.
.NET 10.0
- Google.Protobuf (>= 3.35.1)
- Grpc.Net.ClientFactory (>= 2.71.0)
- Henara.Imaging.Contracts (>= 1.8.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
- Microsoft.Extensions.Options.DataAnnotations (>= 8.0.0)
.NET 8.0
- Google.Protobuf (>= 3.35.1)
- Grpc.Net.ClientFactory (>= 2.71.0)
- Henara.Imaging.Contracts (>= 1.8.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
- Microsoft.Extensions.Options.DataAnnotations (>= 8.0.0)
.NET 9.0
- Google.Protobuf (>= 3.35.1)
- Grpc.Net.ClientFactory (>= 2.71.0)
- Henara.Imaging.Contracts (>= 1.8.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
- Microsoft.Extensions.Options.DataAnnotations (>= 8.0.0)