Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Tactical Telemetry

A distributed tactical telemetry system, built entirely in C# / .NET, that simulates an unmanned platform streaming real-time position and health telemetry — encoded as Cursor on Target (CoT)-style XML over UDP — to a ground-control application with a live terminal display.

It runs entirely on a laptop. No hardware, no cloud services, no paid infrastructure.

git clone <this-repo> && cd tactical-telemetry
./scripts/run-demo.sh

Why this exists

This is a portfolio project built to demonstrate hands-on experience with the kind of problems that show up in tactical, unmanned, and real-time-systems work: asynchronous network I/O, message serialization, distributed state tracking, and graceful degradation under unreliable transport — without needing an actual drone, GPS receiver, or TAK server to prove it.

It is deliberately scoped to be small enough to read end-to-end in one sitting, and every architectural decision below is one I can defend in an interview — see docs/interview-guide.md for the deeper "why."

Architecture

Three independent processes, one shared library:

flowchart LR
    subgraph Simulator["TacticalTelemetry.Simulator"]
        PS["PlatformSimulator<br/>(kinematics + battery model)"]
        RI["ReliabilityInjector<br/>(drop / delay / corrupt / outage)"]
        PS --> RI
    end

    subgraph Core["TacticalTelemetry.Core (shared library)"]
        ENC["CotEncoder / CotDecoder<br/>(CoT-style XML)"]
        TR["ITelemetryTransmitter / ITelemetryReceiver<br/>(UDP today)"]
    end

    subgraph GroundStation["TacticalTelemetry.GroundStation"]
        LST["TelemetryListenerService<br/>(decode, never crash)"]
        TRK["PlatformTracker<br/>(latest state, gaps, staleness)"]
        UI["TerminalRenderService<br/>(Spectre.Console live table)"]
        LST --> TRK --> UI
    end

    RI -- "CoT XML bytes" --> ENC
    ENC --> TR
    TR -- "UDP datagram, loopback or LAN" --> LST

    style Core fill:#2d3748,color:#fff,stroke:#4a5568
Loading
  • TacticalTelemetry.Core — the only project the other two depend on. Domain model (PlatformTelemetry), CoT-style XML encode/decode, and the transport abstraction (ITelemetryTransmitter / ITelemetryReceiver) plus a UDP implementation. Zero framework dependencies — just the BCL and System.Xml.Linq — so it's trivially unit-testable.
  • TacticalTelemetry.Simulator — a console host that advances one simulated platform's kinematic and battery state every tick, encodes it as CoT, optionally runs it through a configurable reliability injector, and sends it over UDP.
  • TacticalTelemetry.GroundStation — a console host that listens for UDP datagrams, decodes them defensively, tracks per-platform state (including sequence gaps and staleness), and renders a live-updating terminal table.

Technologies used

Concern Choice
Language / runtime C# 13, .NET 10
Networking Raw UDP sockets (System.Net.Sockets)
Message format Cursor on Target (CoT)-style XML (System.Xml.Linq)
Process hosting Microsoft.Extensions.Hosting generic host, BackgroundService, Options pattern
Structured logging Serilog (console sink for the simulator, rolling file sink for the ground station)
Terminal UI Spectre.Console (live table)
Testing xUnit — unit tests plus real-socket integration tests over loopback

No web framework, no database, no message broker, no containers — none of them would add real signal here, and the project brief explicitly asks against complexity for its own sake.

Design decisions

Why UDP, not TCP. Telemetry is a stream of independent, superseding samples — the ground station only ever cares about the latest position, not a perfectly ordered backlog of old ones. UDP's fire-and-forget semantics match that: no head-of-line blocking, no connection state to manage across a flaky link, and it's the same tradeoff real CoT/TAK traffic makes (CoT is UDP/multicast-first). The ITelemetryTransmitter / ITelemetryReceiver interfaces don't mention UDP anywhere, specifically so a TCP or other transport could be added later without touching the encoder, the simulator's tick loop, or the ground station's tracking logic.

Why the domain model is a separate zero-dependency project. TacticalTelemetry.Core doesn't reference Microsoft.Extensions.* or Serilog at all. That's what makes CotEncoderTests and CotDecoderTests pure, fast, no-DI-container-needed unit tests — the kind you can read in five seconds and trust completely.

Why a coarse lock instead of ConcurrentDictionary in PlatformTracker. Update throughput here is inherently low (telemetry arrives at ~1–2 Hz per platform, from at most a handful of platforms). A single lock around a plain Dictionary makes "detect gap → discard out-of-order → update" one atomic step instead of a lock-free compare-and-swap retry loop. Simplicity and obvious correctness win when there's no real contention to optimize away.

Why Serilog sinks are configured in code, not in appsettings.json. They originally weren't — see Reliability & failure scenarios below for what that cost me and why I changed it.

Why the ground station logs to a file, not the console. The live table owns the terminal via Spectre.Console's Live display; a console log sink would fight it for the same screen real estate. Structured logs go to logs/ground-station-.log instead.

Why Spectre.Console. A "polished terminal UI" was in scope; hand-rolling ANSI cursor-positioning and box-drawing was not a good use of the time budget for the signal it demonstrates. Spectre.Console is a well-regarded, single-purpose dependency for exactly this.

How telemetry flows through the system

  1. PlatformSimulator.Tick() advances one platform's latitude/longitude/heading/battery by a simple dead-reckoning model (course + speed + elapsed time; see PlatformSimulator.cs) and returns an immutable PlatformTelemetry sample with a monotonically increasing sequence number.
  2. CotEncoder.Encode turns that into a CoT-style <event> XML element — <point> for position, <detail><track> for heading/speed, <detail><status> for battery, and a <tacticalTelemetry> extension element for operational status and the sequence number (see CoT message structure below).
  3. ReliabilityInjector.Plan() decides, per sample, whether to send it as-is, drop it, truncate it into invalid XML, delay it, or suppress it as part of a simulated outage — all configurable, all off by default.
  4. UdpTelemetryTransmitter fires the resulting bytes at the configured host/port. No acknowledgment, no retry — matching real UDP telemetry semantics.
  5. UdpTelemetryReceiver yields inbound datagrams as an IAsyncEnumerable<ReceivedMessage>.
  6. TelemetryListenerService calls CotDecoder.TryDecode on each one. A parse failure is logged and skipped — it never throws past this point.
  7. PlatformTracker applies successful decodes: first message for a platform → NewPlatform; sequence number exactly one higher → Applied; a jump → AppliedWithGap (with the gap size, implying earlier packet loss); same-or-lower → DiscardedOutOfOrder (stale/duplicate/reordered delivery, latest state left untouched).
  8. TerminalRenderService polls PlatformTracker.GetSnapshot() on its own timer and redraws the table — decoupled from how fast telemetry actually arrives.

CoT message structure

A real, functioning example — this is what's on the wire (pretty-printed here; the actual payload is single-line UTF-8):

<event version="2.0" uid="UAV-01" type="a-f-A-M-F-Q"
       time="2026-08-15T12:02:22.956Z" start="2026-08-15T12:02:22.956Z"
       stale="2026-08-15T12:02:37.956Z" how="m-g">
  <point lat="38.889640" lon="-77.035120" hae="500.0" ce="9999999.0" le="9999999.0" />
  <detail>
    <contact callsign="UAV-01" />
    <track course="44.0" speed="22.00" />
    <status battery="99.9" />
    <tacticalTelemetry status="Nominal" seq="1" />
  </detail>
</event>

event/point/detail/contact/track follow the real MITRE CoT schema, and <status battery=".."> mirrors how ATAK reports device battery as a detail extension in practice. <tacticalTelemetry> is this project's own extension, not part of the CoT standard — stock CoT has no field for a numeric health status or a sequence counter, and both are necessary here to demonstrate gap/reorder detection. This project does not claim TAK Server or ATAK/WinTAK interoperability; see Limitations.

Reliability & failure scenarios

The simulator's Reliability options (see appsettings.Demo.json) independently control:

Scenario What happens What the ground station shows
Packet loss Sample is never sent Gaps counter increases when the next sample's sequence number reveals the hole
Malformed payload Payload truncated mid-XML before sending Logged and discarded by CotDecoder; tracker state (and the table) is untouched
Delayed delivery Send is delayed by a random duration up to a configured max Sample still applies once it arrives; Age briefly spikes
Transmission outage All sends suppressed for a configured window Platform's Link flips to STALE once StaleThreshold elapses with no new message
Duplicate / reordered delivery (Always possible over UDP, not just simulated) DiscardedOutOfOrder — logged, latest state left alone

Run ./scripts/run-demo.sh (loads appsettings.Demo.json, which turns all of the above on at a fixed random seed) to see this happen live.

A real bug this design caught, worth mentioning here because it's the most honest evidence of how this system was actually built and debugged: Serilog was originally configured entirely from appsettings.json's Serilog section (Serilog.Settings.Configuration, which resolves sinks like WriteTo.Console via reflection over already-loaded assemblies). In Release builds this silently produced a logger with zero working sinks — no exception, no SelfLog warning, every log call a no-op — because the sink assembly wasn't guaranteed to be loaded into the process by the time the config was read. I found it by bisecting with temporary Console.Error.WriteLine breadcrumbs and Serilog.Debugging.SelfLog, confirmed the theory by hard-coding one sink in code, and fixed it by moving sink/level configuration into code in both Program.cs files (see the comments there). It's a good example of exactly the kind of "everything compiles, nothing's logging, and there's no exception to catch" bug that only shows up by actually running the thing — see docs/interview-guide.md for the full writeup.

How to run it

Requires the .NET 10 SDK (no other tooling, no hardware).

One command, both processes, live view:

./scripts/run-demo.sh

This builds in Release, starts the simulator in the background with reliability faults enabled (DOTNET_ENVIRONMENT=Demo), and runs the ground station in the foreground so you see its live table. Ctrl+C stops both.

Or run each side yourself, in two terminals, for the plain (no injected faults) profile:

# Terminal 1
dotnet run --project src/TacticalTelemetry.GroundStation

# Terminal 2
dotnet run --project src/TacticalTelemetry.Simulator

To try reliability faults with your own two-terminal setup: DOTNET_ENVIRONMENT=Demo dotnet run --project src/TacticalTelemetry.Simulator.

Configuration lives in each project's appsettings.json (platform start position, speed, battery drain rate, target host/port, reliability probabilities) — see SimulatorOptions.cs and GroundStationOptions.cs for every knob.

How to run the tests

dotnet build
dotnet test

61 tests across four projects, all real (no mocking framework — the integration tests use actual loopback UDP sockets):

Project Focus Count
TacticalTelemetry.Core.Tests CoT encode/decode round-tripping, malformed-input handling, culture-invariance 17
TacticalTelemetry.Simulator.Tests Kinematics/battery model, reliability injector probabilities, Options binding & validation 25
TacticalTelemetry.GroundStation.Tests Sequence-gap/out-of-order/staleness detection in PlatformTracker 10
TacticalTelemetry.IntegrationTests Real UDP send/receive, full listener→tracker pipeline, graceful cancellation 9

Representative example output

Ground station live table (./scripts/run-demo.sh, mid-run):

                                                              Tactical Telemetry — Ground Station
┌───────────────┬────────────┬───────────────┬────────────────┬────────────┬───────┬──────────────┬──────────┬─────────────┬────────┬─────────┬────────┬───────┐
│ Platform      │ Domain     │           Lat │            Lon │     Alt(m) │   Hdg │     Spd(m/s) │    Batt% │ Status      │    Age │ Link    │   Msgs │  Gaps │
├───────────────┼────────────┼───────────────┼────────────────┼────────────┼───────┼──────────────┼──────────┼─────────────┼────────┼─────────┼────────┼───────┤
│ UAV-01        │ Air        │      38.90105 │      -77.02071 │        500 │    46 │         22.0 │       96 │ Nominal     │   0.0s │ FRESH   │     82 │     0 │
└───────────────┴────────────┴───────────────┴────────────────┴────────────┴───────┴──────────────┴──────────┴─────────────┴────────┴─────────┴────────┴───────┘
                                                                         Ctrl+C to exit

Simulator structured log (console):

[12:02:21.903 INF] Simulator starting: platform=UAV-01 domain=Air target=127.0.0.1:6969 interval=00:00:01 reliabilityEnabled=False
[12:02:22.956 INF] seq=1 lat=38.88964 lon=-77.03512 hdg=44 spd=22.0 batt=99.9% status=Nominal
[12:02:23.930 INF] seq=2 lat=38.88978 lon=-77.03495 hdg=45 spd=22.0 batt=99.9% status=Nominal
[12:02:24.933 INF] seq=3 lat=38.88993 lon=-77.03477 hdg=44 spd=22.0 batt=99.8% status=Nominal

Ground station structured log (logs/ground-station-*.log):

[2026-08-15 12:02:32.062 INF] Telemetry listener starting
[2026-08-15 12:02:32.070 INF] Terminal renderer starting: refresh="00:00:00.5000000" staleThreshold="00:00:05"
[2026-08-15 12:02:35.056 INF] Telemetry listener stopped

Limitations

  • Single platform per simulator process. The architecture (per-platform PlatformId, a tracker keyed by ID) supports many concurrent platforms; the simulator just doesn't spawn more than one today. Running multiple dotnet run --project src/TacticalTelemetry.Simulator instances with different PlatformId/ports would work as-is.
  • No authentication, encryption, or replay protection. Plain UDP, plaintext XML. Fine for a local/LAN demo; not fit for anything resembling a real operational network.
  • No TAK Server, WinTAK, or ATAK interoperability. The CoT structure is real and MITRE-schema-shaped, but the <tacticalTelemetry> extension is this project's own and untested against any actual TAK ecosystem product.
  • Dead-reckoning motion model, not physically accurate. Position advances via a flat-Earth equirectangular approximation — fine at demo distances/durations, not geodesically accurate over long runs.
  • No persistence. Ground station state lives in memory; a restart loses all tracked platforms (they'll simply reappear as NewPlatform once telemetry resumes).

Future extensions

Logical next steps if this were taken further — none of these are implemented:

  • Real GPS/sensor hardware input, replacing PlatformSimulator with an actual NMEA/GPS or IMU data source.
  • ESP32 or Raspberry Pi telemetry source, publishing real sensor readings over the same ITelemetryTransmitter contract.
  • TAK Server integration, so ground-station data participates in a real CoT mesh.
  • WinTAK/ATAK interoperability, validating the CoT payload against actual TAK client parsers rather than only this project's own decoder.
  • Additional transports (TCP, QUIC, or CoT-over-multicast) implementing the existing ITelemetryTransmitter/ITelemetryReceiver interfaces without touching the simulator or ground station internals.
  • Multi-platform simulation in a single simulator process.
  • A web-based dashboard, once/if the terminal UI stops being sufficient — explicitly deferred per this project's own scope.

Repository layout

src/
  TacticalTelemetry.Core/          shared library — model, CoT codec, UDP transport
  TacticalTelemetry.Simulator/     console host — platform simulation + reliability injection
  TacticalTelemetry.GroundStation/ console host — listener, tracker, live terminal UI
tests/
  TacticalTelemetry.Core.Tests/
  TacticalTelemetry.Simulator.Tests/
  TacticalTelemetry.GroundStation.Tests/
  TacticalTelemetry.IntegrationTests/
scripts/run-demo.sh
docs/interview-guide.md
CLAUDE.md

About

C#/.NET distributed telemetry system simulating real-time unmanned vehicle communications using CoT and UDP.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages