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.shThis 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."
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
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 andSystem.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.
| 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.
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.
PlatformSimulator.Tick()advances one platform's latitude/longitude/heading/battery by a simple dead-reckoning model (course + speed + elapsed time; seePlatformSimulator.cs) and returns an immutablePlatformTelemetrysample with a monotonically increasing sequence number.CotEncoder.Encodeturns 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).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.UdpTelemetryTransmitterfires the resulting bytes at the configured host/port. No acknowledgment, no retry — matching real UDP telemetry semantics.UdpTelemetryReceiveryields inbound datagrams as anIAsyncEnumerable<ReceivedMessage>.TelemetryListenerServicecallsCotDecoder.TryDecodeon each one. A parse failure is logged and skipped — it never throws past this point.PlatformTrackerapplies 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).TerminalRenderServicepollsPlatformTracker.GetSnapshot()on its own timer and redraws the table — decoupled from how fast telemetry actually arrives.
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.
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.
Requires the .NET 10 SDK (no other tooling, no hardware).
One command, both processes, live view:
./scripts/run-demo.shThis 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.SimulatorTo 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.
dotnet build
dotnet test61 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 |
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
- 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 multipledotnet run --project src/TacticalTelemetry.Simulatorinstances with differentPlatformId/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
NewPlatformonce telemetry resumes).
Logical next steps if this were taken further — none of these are implemented:
- Real GPS/sensor hardware input, replacing
PlatformSimulatorwith an actual NMEA/GPS or IMU data source. - ESP32 or Raspberry Pi telemetry source, publishing real sensor readings over the same
ITelemetryTransmittercontract. - 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/ITelemetryReceiverinterfaces 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.
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