TacticalAPI

The TacticalAPI plugin connects Bowire to Rheinmetall's situational-awareness gRPC interface: bundled-schema discovery (no Server Reflection on the target needed), typed unary invoke across Situation, OwnPose and BlueForceTracking, a server-streaming pump per service, URL-scheme normalisation (tacticalapi@host:port, grpc(s)://, bare host:port), and mTLS via the shared __bowireMtls__ marker alongside the legacy _bowire:client-cert-pfx keys.

The TacticalAPI plugin connects Bowire to Rheinmetall's TacticalAPI — a gRPC interface for situational-awareness systems. The plugin ships the upstream service schema bundled with the package, so Bowire can render the service tree against any TacticalAPI server even when the server does not expose gRPC Server Reflection.

Package: Kuestenlogik.Bowire.Protocol.TacticalApi (sibling repo, not bundled with the CLI)

Install

dotnet add package Kuestenlogik.Bowire.Protocol.TacticalApi

Bowire discovers the plugin automatically via assembly scanning — no extra registration code.

Use

bowire --url tacticalapi@my-situation-server:50051

Open the workbench, pick the TacticalAPI tab, and all three upstream services appear in the sidebar:

Service Methods What it carries
Situation SubscribeSituationObjectEvents, GetSituationObjects, AddOrUpdateSituationObjects, DeleteSituationObjects Situational-awareness objects a system reports about — symbols, action tasks, organisation units.
OwnPose SubscribePositionChangedEvents, GetPosition, UpdatePosition The reporting platform's own position, and a way to set it.
BlueForceTracking SubscribeBlueForceEvents, GetBlueForces, AddOrUpdateBlueForces Friendly participants reporting themselves, on a 30 s keep-alive: a blue force that stops being re-sent is deleted implicitly.

Click Execute on any unary method and the call goes out to the server; pick a streaming method and the workbench's Wireshark-style frame pane starts filling with events as they're emitted.

URL forms

The plugin normalises the URL Bowire passes through:

You type What the plugin connects to
tacticalapi@host:4267 https://host:4267 (default — TacticalAPI in the field is mTLS)
grpc://host:50051 http://host:50051 (plaintext gRPC)
grpcs://host:50051 https://host:50051
host:4267 https://host:4267

Settings

The plugin declares four settings (Settings → TacticalAPI). The workbench delivers them in the metadata bag, and the plugin strips them before the gRPC Metadata is built, so none of them reaches the server:

Setting Default What it does
invocationDeadlineSeconds 0 (off) gRPC deadline for unary calls. Bounds a cold connect against a server that hangs. Not applied to subscriptions — a deadline on a stream would end every subscription on the clock.
streamIdleSeconds 0 (off) Ends a subscription after this many seconds without a frame. The pump closes with one last frame carrying "status": "tacticalapi:stream-idle" and the reason, so the frame pane shows why it stopped rather than looking like the server hung up.
allowSelfSignedCerts false Skip server-certificate validation. Staging and self-signed only. Unlike the core's own relaxation this is not limited to loopback — a TacticalAPI staging server is never localhost — so every unary result it applied to carries _tacticalapi:warning saying so.
useGrpcWeb false Speak gRPC-Web over HTTP/1.1 instead of native gRPC over HTTP/2 — see Two ports.

Anything else in the metadata bag travels as a gRPC request header, except the transport keys below.

What comes back

Every unary result carries its own metadata next to the response body:

Key When Meaning
_tacticalapi:objectCount the response has a repeated field How many objects it holds — the summed lengths of situation_objects, blue_forces and the like. A live TacNet answers GetSituationObjects with thousands in one JSON document; the count is there before the workbench tries to render it.
_tacticalapi:responseBytes always on a gRPC-level success Size of the response on the wire.
_tacticalapi:errorMessage the server refused The server's own wording — see A refused write is not a transport error.
_tacticalapi:warning certificate validation was off Which host was dialled with any certificate accepted.
_trailer:<name> the call failed at the gRPC level The gRPC trailers, namespaced the way the core gRPC plugin does it.

Streamed frames carry no metadata of their own; a frame that is not data — a refusal, the idle timeout, an unloadable transport — says so in its JSON with a status field. The plugin also implements the core's IBowireStreamingWithWireBytes, so the recorder keeps every server frame's wire bytes and bowire mock can serve a recorded subscription 1:1 — the bytes cannot be rebuilt from JSON, because Google.Protobuf's C# library has no DynamicMessage.

mTLS / TLS

TacticalAPI servers in production almost always run behind mTLS. The plugin takes its TLS configuration from the metadata bag and never forwards any of it — the keys are filtered out before the gRPC Metadata is built. Three paths:

Path Purpose
__bowireMtls__ (shared marker) The preferred path, and the one the workbench's mTLS auth profile writes: client certificate and private key as PEM, the key's passphrase when it is encrypted, an optional CA certificate the server's chain is pinned to, and allowSelfSigned. Material that does not load — a wrong passphrase, a truncated paste — comes back as status bad-transport-config with the loader's reason, before any dial. It is the same marker the core REST / gRPC / Kafka / AMQP plugins read (Kuestenlogik.Bowire.Auth.MtlsConfig), so an mTLS profile set up once in the workbench works on TacticalAPI the same way it works everywhere else. When it is present, the legacy PFX keys below are ignored so two client certificates never load at once.
allowSelfSignedCerts=true (setting) Accept any server certificate. Same opt-in semantics as the core gRPC plugin's --allow-self-signed-certs; the marker's own allowSelfSigned covers the same need.
_bowire:tls-skip-validation=true, _bowire:client-cert-pfx=<path>, _bowire:client-cert-password=<pw> (legacy) The keys this plugin first shipped with: accept any server certificate; a PFX with client certificate and private key; its password (omit for an unprotected PFX). Still honoured so recorded sessions and saved profiles from before the shared marker keep working. From the CLI: --metadata _bowire:client-cert-pfx=....

Writing to TacticalAPI

Every write carries the same envelope, and the contract marks all three fields Required: on every update and delete message — UpdateSymbol, UpdateActionTask, DeleteSituationObject and the rest. Bowire surfaces them as required in the invoke form (since #68), but the conventions behind them are not guessable:

Field What to send
identity The object's own id. A fresh UUID when creating a symbol; the existing one when updating it. string_identity is the shorter form for low-bandwidth links; int32_identity / int64_identity are reserved for internal use and must not be used to create objects.
reporter Who generated the change. Use the value Rheinmetall gave you — TacticalAPI if you have none.
reporting_time UTC time of the change. Only the most up-to-date information is considered, so use the current time for a real change — but keep the previous timestamp when nothing changed, or you will re-date an unchanged object.

Two more rules that cost data rather than time when missed:

  • Updates are sparse. An Update… message changes only the properties you send. Omit a property entirely to leave it untouched; send the property with no content to clear the value. Sending a complete object is a legitimate request that overwrites everything the operator never meant to touch.
  • A position needs its own time and quality. Point.location_time is the time of the fix, not of the report, and GeoPoint.measurement_code says how it was obtained — MEASUREMENT_CODE_GPS for a tracked position, MEASUREMENT_CODE_ESTIMATE for a derived one (enums go under their full proto names in the JSON the invoke pane sends). Upstream's reference client sets both on every position it sends, and OwnPose.UpdatePosition additionally names its source_identifier (the system reporting the fix).

A refused write is not a transport error. TacticalAPI answers a rejected operation with an ordinary gRPC OK whose ResponseHeader carries success = false and an error_message. Bowire reads that header and reports the call as tacticalapi:refused with the server's wording in the response metadata (#66) — for unary calls and for every streamed frame, which is what upstream's own client does. A server that fills no header at all is treated as success, so older or partial implementations keep working.

Two ports: native gRPC and gRPC-Web

TacticalAPI servers commonly expose two ports: a native HTTP/2 gRPC endpoint (typically :4267) and a gRPC-Web endpoint over HTTP/1.1 (typically :4268). Rheinmetall's own reference client defaults to the gRPC-Web one, because it is what survives a proxy or load balancer that will not carry h2c.

This plugin speaks both (#67). Native gRPC is the default; switch the wire with either of:

  • the plugin setting useGrpcWeb (Settings → TacticalAPI), or
  • the shared __bowireGrpcTransport=web marker in the call metadata — the same marker the core appends for the core gRPC plugin's grpcweb@ hint, so a caller that already knows that vocabulary needs no second one.

Where both speak, the call metadata wins: it is the narrower statement. The same precedence holds for the plugin's other three settings — allowSelfSignedCerts, invocationDeadlineSeconds and streamIdleSeconds — each of which is a workspace default for the metadata key of the same name.

Discovery works over either transport regardless, because the proto schema is bundled rather than fetched at connect time. Server-streaming works over gRPC-Web too; what gRPC-Web cannot carry is client-streaming and duplex, and the TacticalAPI surface has neither.

Licensing — please read

The plugin code, generated bindings package, and documentation are Apache-2.0. The upstream TacticalAPI .proto files at https://github.com/Rheinmetall/tacticalapi are EPL-2.0 OR BSD-3-Clause (Rheinmetall Electronics GmbH). Vendoring those files into an Apache-2.0 repository would be redistribution under a different license, which EPL-2.0 does not permit. To honour both:

  • The .proto files are downloaded at build time from the upstream repository (pinned commit 58661c9c5de7db1b944a37f6ed05fe16c603cd0e) into obj/tacticalapi-protos/ — gitignored, never committed.
  • Grpc.Tools compiles them into the assembly; only the generated C# bindings ship in the NuGet package.
  • The .proto source itself never enters the plugin's source tree or its published package.

The pin will move to a released tag once Rheinmetall cuts one. A scheduled GitHub Action in the sibling repo (.github/workflows/check-upstream-protos.yml) runs weekly, compares the pinned commit against Rheinmetall/tacticalapi@main, and opens a tracking issue when upstream drifts — so the bump cadence stays visible without manual polling.

Build requirements

Building the plugin requires outbound internet access to raw.githubusercontent.com so the proto-fetch target can reach the upstream repo. GitHub Actions runners have this by default; air-gapped CI does not.

Air-gapped builds

Pre-populate the proto cache before invoking dotnet build:

<repo-root>/artifacts/obj/Kuestenlogik.Bowire.Protocol.TacticalApi/<Configuration>/tacticalapi-protos/rheinmetall/tactical_api/v0/

Drop the ten upstream .proto files (same filenames as on Rheinmetall/tacticalapi) into that directory and the build's DownloadFile target short-circuits because the files already exist. Consumers of the published NuGet package don't need network access — only contributors and CI building from source do.

Try it — upstream test client as data populator

Rheinmetall ships an official C# test client alongside the proto set. It's a small CLI that exercises every operation on all three services — --observesituation (server-streaming), --printsituation (unary polling), --sendsymbol (create), --changesymbolname (update), --deletesymbol (delete), plus --setownposition / --printownposition / --observeownposition on OwnPose and --updateblueforce / --printblueforces / --observeblueforces on BlueForceTracking. For Bowire users the test client is the fastest way to populate a server with realistic data so the workbench has something interesting to render.

End-to-end demo, against either a live TacticalAPI server or the upstream TacNet instance:

# 1. Build the upstream test client (one-time)
git clone https://github.com/Rheinmetall/tacticalapi
cd tacticalapi/testclient/csharp
dotnet build TacticalApi.TestClient.csproj

# 2. Place a handful of symbols at WGS84 coordinates near Hamburg
#    (the test client takes lat / lon as positional args)
dotnet TacticalApi.TestClient.dll --sendsymbol 53.5 9.9
dotnet TacticalApi.TestClient.dll --sendsymbol 53.55 10.0
dotnet TacticalApi.TestClient.dll --sendsymbol 53.6 10.05

# 3. Add the optional map-widget extension so SituationObjectLocation
#    pins land on a MapLibre canvas next to the streaming-frames pane.
#    (Skip this step if you only want the raw JSON view — Bowire still
#    auto-detects the lat/lon fields, it just falls back to a
#    "Install Kuestenlogik.Bowire.Map" placeholder card instead of a map.)
dotnet add package Kuestenlogik.Bowire.Map

# 4. Point Bowire at the same server — native HTTP/2 transport
bowire --url grpc@https://localhost:4267
#    …or gRPC-Web over HTTP/1.1 if the server exposes :4268 too
bowire --url grpcweb@https://localhost:4268

# 5. In the workbench, pick Situation → SubscribeSituationObjectEvents.
#    Click Execute. With the Frame-Semantics Framework live in
#    Bowire 1.3.0+, the workbench auto-detects the lat/lon fields on
#    every SituationObjectLocation and mounts a Map tab next to the
#    streaming-frames pane — every symbol the test client created
#    appears as a pin, every new --sendsymbol from a parallel
#    terminal lights up live.

Why this demo carries weight: no Bowire-side configuration was involved. No bowire.schema-hints.json, no IBowireSchemaHints implementation, no manual right-click on a field. The TacticalAPI plugin ships transport-only; the framework recognises coordinate.latitude / coordinate.longitude from the field names + WGS84 ranges and routes the data into the map widget on its own. The pgAdmin pattern: shape-of-data drives viewer choice, not protocol-author opt-in. The map widget itself rides its own NuGet package (Kuestenlogik.Bowire.Map) so Bowire core stays ~870 KB lighter for users who never need geographic rendering — same plugin model as the protocol packages.

A companion walkthrough in the mock-server docs uses the same test client to validate bowire mock — a useful inverse comparison if you ever want to verify Bowire reproduces a real server's behaviour faithfully.

Roadmap

  • v1.0.0 (shipped 2026-05-26) — bundled-schema discovery, typed unary CRUD (GetSituationObjects / AddOrUpdateSituationObjects / DeleteSituationObjects), the server-streaming pump for SubscribeSituationObjectEvents, URL-scheme normalisation (tacticalapi@, grpc(s)://, bare host:port), mTLS via the shared __bowireMtls__ marker (legacy _bowire:client-cert-pfx keys still honoured for back-compat), IBowireMockEmitter for recording replay, in-process Kestrel-hosted integration suite. The current stable line.
  • post-1.0 (shipped) — the upstream pin moved to 58661c9 ("Add position and blue force tracking"), which brought the OwnPose and BlueForceTracking services. Both are discovered, invoked and replayed on the path Situation already used; the plugin now iterates a list of service-bearing .proto files instead of naming one, so the next upstream service is a one-line change. Nothing in the existing surface broke — the only edit upstream made to a file the plugin already compiled was two blank lines removed from situation_object_updates.proto.
  • v1.0.17 (2026-09-14) — the four contract fixes: the ResponseHeader read on every reply and frame, tacticalapi:refused (#66); gRPC-Web on the useGrpcWeb setting and the shared transport marker, allowSelfSignedCerts finally consumed (#67); the contract's Required: fields and property notes reaching the invoke form (#68); the sample writing as well as reading, with the request bodies in its README (#69). streamIdleSeconds doing what it says, and invocationDeadlineSeconds unary-only. The upstream pin as a git ref (TacticalApiProtoRef), tag-ready.
  • v1.0.18 (2026-09-15) — the __bowireMtls__ marker read in full (passphrase, CA pinning, bad-transport-config for material that does not load); the mock emitter seeing refusals and replaying over the recorded transport; the sample refusing an incomplete blue force, honouring expiry_time, cloning every frame — and tested by its own project.
  • v1.0.19 (2026-09-16) — IBowireStreamingWithWireBytes (recorded subscriptions replay 1:1), _tacticalapi:objectCount / _tacticalapi:responseBytes / _tacticalapi:warning on every unary result, TacticalApiMockEmitter.Completion; the sample's overlay of seven 2525D control measures, one per SymbolLocation case, as test data for the multipoint renderer.
  • v1.0.20 (2026-09-19) — the four declared settings reach the metadata bag that reads them; the sample's tracks carry specific symbols and plausible positions, a second phase line in the 2525C string form; the released assembly carries the released version.
  • v1.1.0 (2026-09-19) — the sample writes every one of the eleven object types, not only symbols: SituationObjectMapper walks the contract's update/served mirror once by reflection, an identity keeps its type for life, request bodies for a route, an action task and an overlay in the sample README — and every README body is sent by a test. A note on the version: by SemVer, 1.0.17 (gRPC-Web, a new status), 1.0.18 (new public API) and 1.0.19 (a new interface, three metadata keys — and the Bowire floor moving from 2.2.1 to 2.7.0, which the wire-bytes interface and the marker's certificate loader require) were minor releases tagged as patches; the cascade's auto-tag bumps patch and the manual tags followed it. Published packages are immutable, so those tags stand; this release takes the minor the line was owed. Hosts on Bowire below 2.7.0 stay on 1.0.16.
  • post-1.0 (both stages shipped) — MIL-STD-2525 / APP-6 symbol renderer, in two stages. The schema's SymbolIdentifier field is wired through; the rendering lives on the map widget's side (Kuestenlogik.Bowire.Map), not in this plugin. Stage 1 landed in Bowire 2.8 (c2df2605, 2026-09-14): every pin whose entity carries a SIDC is drawn by milsymbol (MIT, vendored next to MapLibre) — both the 15-letter 2525C and the 20-digit 2525D form — with the four affiliation shapes as the fallback while the library loads or for a code it cannot frame. This sample's thirteen tracks are the test data — each with a function id the library has an icon for, a patrol craft or a tank rather than a bare frame; a function id it has no icon for still gets its frame. Stage 2 landed in Bowire 2.8 too (2026-09-16): the multipoint tactical graphics milsymbol does not draw — lines, areas, arrows, corridors, fans, ellipses — are drawn by mil-sym-ts (Apache-2.0, MIL-STD-2525D/E, APP-6D), vendored in the browser rather than ported to .NET: its RenderSymbol2D takes the viewport and redraws arrowheads on every zoom, so a server endpoint would have been called per graphic per map move, and its label placement measures text through a canvas a port would have had to replace with a native font stack. The widget reads a SymbolLocation's line / polygon / multipoint / corridor points, an ellipse's three points and a fan's ranges and azimuths back into one geometry each, and the 2525D code from NumericIdentifier's two ten-digit halves. A 2525C-coded graphic is translated to its 2525D entity through a crosswalk built from Esri's joint-military-symbology-xml legacy table before it is drawn. This sample's overlay of eight control measures — one per location case, plus a phase line in the 2525C string form — is the test data; routeLocation and sketchLocation have no 2525 graphic and are not drawn. The parallel item on the AMQP plugin's side — Service-Bus / Artemis-flavoured AMQP 1.0 discovery — landed there on 2026-09-17, by the same reasoning: a wire with no discovery of its own, two brokers that each answer a question about themselves, and a fallback for everything that does not.

Sample

A canonical mini-server lives at Bowire.Protocol.TacticalApi/samples/Kuestenlogik.Bowire.Protocol.TacticalApi.Sample — RadarSweep grown into a combined sample that serves all three services and mounts the embedded workbench beside them. dotnet run, then open http://localhost:5191/bowire for the workbench (HTTP/1.1); the gRPC services listen on http://localhost:5192 (cleartext HTTP/2) and are already seeded into the Sources rail, so an external Bowire connects with bowire --url tacticalapi@http://localhost:5192. The workbench port also serves the same three services as gRPC-Web, so the plugin's useGrpcWeb setting has something to dial (tacticalapi@http://localhost:5191). Thirteen Situation tracks in five groups and an overlay of eight control measures exercise the read side, and three writes are worth driving by hand, each with a request body in the sample's README that carries the envelope upstream's reference client sets: Situation.AddOrUpdateSituationObjects places a symbol, changes one property of it sparsely, and — with reporter left out — is refused the way a real server refuses; OwnPose.UpdatePosition moves the own pose, the blue force flagged own_blue_force and the UAV mounted on it in one call; a blue force added via AddOrUpdateBlueForces and then left alone comes back once with is_deleted after 30 s. The ports are split because ALPN lives in the TLS handshake, so one cleartext socket cannot serve both HTTP/1.1 and h2c. For a full Harbor Control Center scene, see the harbor-demo sibling Bowire.Samples/harbor-demo/src/Kuestenlogik.Bowire.Samples.TacticalApi.

Acknowledgements

The TacticalAPI specification, including every .proto file this plugin compiles against, is the work of Rheinmetall Electronics GmbH. Used in accordance with the upstream EPL-2.0 / BSD-3-Clause licensing.