Building a CoT Bridge: How to Get NVR, AIS and Drone Feeds onto a TAK Map Without Flooding It

October 1, 2026

Every TAK deployment we scope eventually arrives at the same question. The server is up, the field teams have ATAK on their phones, the map works — and then someone asks why the gate cameras, the boat tracker and the drone aren’t on it.

They aren’t on it because none of them speak TAK. A TAK map only understands Cursor on Target (CoT) events, and almost nothing outside the TAK ecosystem emits CoT natively. An NVR sends plate reads over its own webhook. An AIS receiver outputs NMEA sentences. A drone ground station publishes MAVLink or a vendor API. Something has to sit in the middle, translate each source into CoT, and do it in a way that keeps the map useful rather than drowning it. That something is a CoT bridge, and it is most of the actual engineering in a sensor-heavy TAK deployment.

Our earlier posts covered how TAK works, the idea of pushing Wazuh alerts onto a TAK map, and the hub-and-spoke architecture for multi-site OpenTAKServer deployments. This one goes a layer down: what a production bridge has to decide, how it should connect, and a working example you can run.

1. What a bridge actually does

A bridge has four jobs, in this order:

flowchart TD
    SRC["Source feed such as NVR webhook AIS or drone telemetry"] --> PARSE["Parse and validate the raw payload"]
    PARSE --> FILTER["Filter low confidence and out of area data"]
    FILTER --> MAP["Map to a CoT event with UID type and stale time"]
    MAP --> RATE["Rate control with dedupe and latest wins per UID"]
    RATE --> TLS["Send over mutual TLS to the TAK server"]
    TLS --> CLIENTS["ATAK WinTAK and iTAK clients"]
    RATE --> BUF["Local buffer while the server link is down"]
    BUF --> TLS

Parse the source’s own format. Filter what nobody should see — low-confidence detections, positions outside the area of interest, test traffic. Map what’s left into a CoT event, which is where most of the design decisions live. Control the rate, because sensors report far more often than a human can usefully read a map. Then send it over an authenticated connection, and buffer when that connection is down.

Most failed bridges skip the filter and rate stages. They translate faithfully, send everything, and a week later the operators turn the layer off because the map is unreadable.

2. The anatomy of a CoT event

A CoT event is a small XML document. Here is the one our example bridge produces for a single plate read:

<event version="2.0" uid="anpr-gate-north-1KB1234" type="a-u-G-E-V" how="m-p"
       time="2026-10-01T04:14:14Z" start="2026-10-01T04:14:14Z" stale="2026-10-01T04:24:14Z">
  <point lat="13.7563" lon="100.5018" hae="0" ce="15.0" le="9999999"/>
  <detail>
    <contact callsign="Plate 1KB1234"/>
    <remarks>ANPR gate-north confidence 0.93</remarks>
  </detail>
</event>
Field What it means What the bridge must decide
uid Identity of the thing being reported; persists across reports How to build a stable ID for each real-world entity
type Hierarchical class: a for a physical thing, then affiliation (f friendly, h hostile, n neutral, u unknown), then dimension (G ground, A air, S sea surface, U subsurface), then more specific function Which icon and affiliation the sensor is honestly allowed to claim
how How the report was produced — starts with m for machine-generated or h for human-entered Machine for sensor feeds, almost always
time / start / stale When the event was generated, when the state became valid, and when it should stop being treated as current How long each source’s reports stay true
point Latitude, longitude, height above the WGS84 ellipsoid (hae), and horizontal and vertical 1-sigma error in metres (ce, le) How precise the position really is
detail Open-ended extension element: contact, track, remarks and anything else clients understand What the operator needs to see when they tap the marker

The convention for "unknown" error is a very large number; 9999999 is what PyTAK itself emits when it has no real value. Using it is better than inventing precision.

3. Four decisions every bridge makes

UID: one real-world thing, one UID, forever

The UID is how TAK knows that a new report is an update to an existing marker rather than a new marker. Get it wrong and you get one of two failures: a new icon on every report (a trail of hundreds of duplicate boats), or several real-world things collapsing onto one icon.

Build UIDs from the most stable identifier the source has. For AIS that is the vessel’s MMSI. For a drone it is the airframe serial or remote ID, not the flight number. For ANPR it depends on what you want the map to show: camera + plate gives one marker per vehicle per gate; plate alone gives one marker that jumps between gates. Both are defensible — but decide deliberately, and prefix UIDs by source (ais-, anpr-, uas-) so two sources can never collide.

Plates in non-Latin scripts — Thai, Japanese, Chinese — need one more step. XML carries them fine, but UIDs are safest as plain ASCII across every client and tool. Map the plate to an agreed ASCII code or a hash for the UID, and show the real plate in the callsign, which is what operators actually read.

Type: claim only what the sensor actually knows

A plate camera knows there is a vehicle. It does not know whether that vehicle is friendly or hostile, so the honest affiliation is u — unknown — and the type is a ground vehicle (a-u-G-E-V). The same discipline applies everywhere: an AIS contact is a sea-surface track of unknown affiliation until someone with authority says otherwise; your own drone is friendly air. Bridges that mark everything hostile to make it stand out train operators to ignore red.

Stale time: how long is this report true?

Stale time is the most under-designed field in most bridges, and it is the one that most affects what operators see. When the stale time passes, clients stop treating the marker as current. Set it per source, from how often that source reports and how fast the thing moves:

Source Typical update cadence A reasonable stale time
Drone telemetry About once per second Tens of seconds — a drone that stops reporting is news
AIS vessel Seconds to minutes, depending on speed and class A few minutes, longer for anchored vessels
ANPR plate read One event per pass Minutes — it records a sighting, not a live track
Fixed sensor alarm On state change Until the alarm is cleared, refreshed by a heartbeat

A stale time that is too long leaves ghosts on the map. One that is too short makes markers flicker in and out between updates. Neither is visible in a lab with one test device, which is why it gets missed.

Position error: report the uncertainty you have

A plate read is located at the camera, not at the car, so its ce should be the camera’s coverage radius, not zero. An AIS position has its own accuracy; an estimated position from a human report has much more. Clients can use ce to show uncertainty, but only if the bridge fills it in honestly.

4. Rate control: the part that keeps the map readable

A single busy gate camera can produce hundreds of plate reads an hour, many of them the same vehicle idling in front of the lens. A drone publishes telemetry several times a second. TAK clients on a 4G link do not need either at that rate.

Three techniques, usually combined:

  • Drop at the edge. Discard detections below a confidence threshold and anything outside the area of interest before they cost bandwidth.
  • Deduplicate per UID. If the same entity was sent less than N seconds ago and hasn’t moved meaningfully, skip it.
  • Latest wins. When the outbound queue backs up, keep only the newest event per UID rather than sending a backlog of stale positions in order. PyTAK’s own default behaviour points in this direction: its outbound queue holds 100 events and drops the oldest when full.

5. Transport: mutual TLS, not plaintext, not HTTP

On OpenTAKServer the standard deployment exposes plaintext CoT streaming on port 8088, mutual-TLS CoT streaming on 8089, and automatic certificate enrollment on 8446. A production bridge should connect on 8089 with its own client certificate.

That matters for more than encryption. With mutual TLS, the server knows which bridge sent each event, you can revoke a single bridge’s certificate if its host is compromised, and you can put each bridge in a TAK group so a camera feed from one site isn’t broadcast to every team in the country. A bridge on plaintext 8088 is anonymous, and anything that can reach the port can inject markers onto your operators’ maps.

Two related details:

  • XML or protobuf. TAK clients and servers can negotiate an upgrade from the original XML encoding ("version 0") to a Protocol Buffers encoding ("version 1"), but every client must still decode XML. A bridge can send XML and let the server handle the rest; switch to protobuf only if bandwidth on the bridge’s own link is genuinely tight.
  • Buffer on disconnect. Edge sites lose their uplink. A bridge should keep a bounded local buffer — applying latest-wins per UID — and flush it on reconnect, instead of losing everything or replaying an hour of positions in order. This is the bridge-level version of the federation question we discussed in the hub-and-spoke post.

6. A working example: ANPR plate reads to CoT

Here is a complete, minimal bridge that takes plate reads from an NVR, filters and deduplicates them, and sends CoT using PyTAK, the open-source Python TAK library. We tested it against a local listener: of three sample reads — one good read, the same plate again immediately after, and a low-confidence read — exactly one CoT event went out.

"""Minimal ANPR-to-CoT bridge: NVR plate reads in, CoT events out to a TAK server."""
import asyncio
import time
import xml.etree.ElementTree as ET
from configparser import ConfigParser

import pytak

# Fixed camera positions come from the site survey, not from the NVR payload.
CAMERAS = {
    "gate-north": {"lat": 13.7563, "lon": 100.5018, "ce": 15.0},
}
MIN_INTERVAL_S = 30   # same plate at the same camera: at most one update per 30 s
STALE_S = 600         # a plate sighting stops being "current" after 10 minutes

def anpr_to_cot(read: dict) -> bytes:
    """Map one NVR plate read to a CoT event."""
    cam = CAMERAS[read["camera_id"]]
    plate = read["plate"].replace(" ", "").upper()
    event = ET.Element(
        "event",
        version="2.0",
        uid=f"anpr-{read['camera_id']}-{plate}",   # stable per plate and camera
        type="a-u-G-E-V",                          # unknown ground vehicle
        how="m-p",                                 # machine-generated, relayed by the bridge
        time=pytak.cot_time(),
        start=pytak.cot_time(),
        stale=pytak.cot_time(STALE_S),
    )
    ET.SubElement(event, "point", lat=str(cam["lat"]), lon=str(cam["lon"]),
                  hae="0", ce=str(cam["ce"]), le="9999999")
    detail = ET.SubElement(event, "detail")
    ET.SubElement(detail, "contact", callsign=f"Plate {plate}")
    ET.SubElement(detail, "remarks").text = (
        f"ANPR {read['camera_id']} confidence {read['confidence']:.2f}"
    )
    return ET.tostring(event)

class AnprSender(pytak.QueueWorker):
    """Reads plate events from a local queue, rate-limits per UID, sends CoT."""

    def __init__(self, tx_queue, config, source: asyncio.Queue):
        super().__init__(tx_queue, config)
        self.source = source
        self.last_sent: dict[str, float] = {}

    async def handle_data(self, data: bytes) -> None:
        await self.put_queue(data)              # hand the CoT event to pytak's TX worker

    async def run(self, number_of_iterations=-1):
        while True:
            read = await self.source.get()
            if read["confidence"] < 0.80:      # drop low-confidence reads at the edge
                continue
            key = f"{read['camera_id']}-{read['plate'].replace(' ', '').upper()}"
            now = time.monotonic()
            if now - self.last_sent.get(key, 0) < MIN_INTERVAL_S:
                continue                        # duplicate within the window
            self.last_sent[key] = now
            await self.handle_data(anpr_to_cot(read))

async def main(cot_url: str, reads: list[dict]):
    config = ConfigParser()
    config["bridge"] = {"COT_URL": cot_url}
    # For mutual TLS on port 8089, also set PYTAK_TLS_CLIENT_CERT,
    # PYTAK_TLS_CLIENT_KEY and PYTAK_TLS_CLIENT_CAFILE here.
    source: asyncio.Queue = asyncio.Queue()
    for r in reads:
        source.put_nowait(r)
    clitool = pytak.CLITool(config["bridge"])
    await clitool.setup()
    clitool.add_tasks({AnprSender(clitool.tx_queue, config["bridge"], source)})
    await clitool.run()

Three things worth noticing:

  • Positions come from the site survey, not the NVR. The camera’s coordinates and coverage radius live in the bridge’s configuration, because most NVRs don’t know where they are.
  • handle_data must be implemented. In PyTAK’s QueueWorker it is a placeholder; it has to put the event on the transmit queue. We found this the honest way — our first test run sent only PyTAK’s own connection ping.
  • TLS is configuration, not code. Set COT_URL to tls://your-server:8089 and point PYTAK_TLS_CLIENT_CERT, PYTAK_TLS_CLIENT_KEY and PYTAK_TLS_CLIENT_CAFILE at the bridge’s certificate, key and the server’s CA. Leave PYTAK_TLS_DONT_VERIFY at its default of 0.

A production version adds a webhook listener in place of the sample list, a persistent buffer, health metrics, and a heartbeat event so operators can see the bridge itself go stale when it stops reporting.

7. Operating a bridge

A bridge is a service that people will stop trusting the first time it lies to them, so it needs the same care as any production service:

  • Make the bridge visible. Publish a heartbeat CoT for the bridge itself. When a site’s bridge dies, operators should see its marker go stale — not silently lose a layer.
  • Log what you dropped, not just what you sent. When someone asks why a vehicle wasn’t on the map, the answer is usually a filter. You want to be able to show which one.
  • Version the mappings. Type codes, stale times and thresholds are operational decisions. Keep them in configuration under version control, so a change is reviewable and reversible.
  • One certificate per bridge. Never share client certificates across bridges or sites; revocation only works if each certificate maps to exactly one thing.

8. What Simplico builds, and what is scoped per mission

Bridge builds are a fixed-scope engagement on our TAK Integration page: a bridge for a defined set of sources, with the source code delivered and a handover included. Sources we regularly bridge include drone and UAV telemetry, CCTV/NVR and ANPR events, GPS trackers, AIS, ADS-B, perimeter sensors and SCADA alarms.

Scoped per mission: custom ATAK or WinTAK plugins (for example, video tiles or mission-specific dialogs), live video routing, protobuf transport, and the two-way link between a SOC and TAK, where cyber detections become map events and field incidents become cases. Each of these is a separate piece of work, priced once we understand the sources and the operating environment.

Out of scope: the sensors themselves. We integrate the cameras, receivers and drones you already own or procure; we don’t supply or mark up hardware.

FAQ

Can’t we just POST events to the TAK server over HTTP?

For a prototype, yes. For production, a streaming connection over mutual TLS is the better default: it identifies each bridge, supports group-based routing and revocation, and avoids the per-request overhead of HTTP for high-rate sources.

Should a bridge run at the edge site or centrally?

At the edge whenever the source is at the edge. Filtering and deduplication close to the sensor save the uplink, and the bridge can buffer through a link outage. Central bridges suit sources that are already centralised, such as an AIS aggregator feed.

Which TAK server does this work with?

The bridge speaks standard CoT over TCP or TLS, so it works with OpenTAKServer, FreeTAKServer and the official TAK Server. Ports and enrollment details differ by server; the ones in this post are OpenTAKServer’s.

Does every sensor need its own bridge?

One bridge process per source type is usually cleanest — one for ANPR, one for AIS, one for drones — because each has a different rate, stale profile and failure mode. They can share a codebase and a deployment.

What about personal data in plate reads?

A plate is personal data in most jurisdictions. Decide who is allowed to see the ANPR layer, put it in a restricted TAK group, and keep the stale time short so the map is a live picture rather than a movement history.


If you have sensors that should be on a TAK map and aren’t, our two-week readiness assessment maps each source, its rate and its connectivity before anything is built. Contact hello@simplico.net, phone (+66) 97 496 6397, WhatsApp (+66) 83 001 0222, or LINE iiitum1984.


Sources:

Ready to talk about your project?

Share goals and constraints. We'll assemble architects and engineers to move fast with you.

Get in touch