FLYsafe.live — MQTT Topic Reference

Visual reference for the MQTT broker architecture, topic namespace design, and per-category topic inventory. Read alongside the ClickUp wiki page FLYsafe_MQTT_Topic_Strategy.md.

Rev 6 — July 2026 Middleware · Flink · Web App teams v0 (legacy) → v1 (category-first) — in progress
💡
What's new in this revision. Naming clarified to match the migration everyone's actually doing: the live app's flat, unstructured topics are v0; the category-first structure this whole doc defines is v1 — the version being implemented now (§10). Also this pass: the Kafka-internal section is gone — implementation detail not needed at this stage; the severity-filtered alerts topic is simplified to one shape, .../alerts/{severity}, instead of publishing every alert twice (§9); and two appendices were added — a fuller eventType catalog for the frontend to build against (Appendix A), and a full example networkid payload (Appendix B).
1. Broker architecture

Three broker types. Each has a distinct role and a distinct set of consumers.

Shared platform broker
One — for all tenants
emqx-mqtt.stage.iart.software

Publishes surveillance feeds and weather. Topics are pushed to every tenant broker on arrival. No aircraft telemetry lives here.

Tenant broker
One per organisation
emqx-mqtt.{tenant}.stage.flysafe.live

All aircraft telemetry, external ingest, internal coordination, and the shared feeds re-published from the platform broker. The web app subscribes here only.

Kafka (internal)
Microservice bus
internal — not exposed

Raw ingest, Flink pipeline topics, commands, dead-letter queues. Never consumed by the web app or the MQTT broker directly.

Data flow — shared feeds to tenant brokers
Shared broker
surveillance + weather
airmarket broker
v1/shared/…
Web app
one broker subscription
Same shared topics also pushed to sait broker, and any future tenant broker
💡
One broker per tenant. The web app subscribes to the tenant broker only and receives everything — including the shared surveillance and weather feeds — in one connection. No multi-broker subscription complexity in the client.
2. Topic path anatomy — category-first

Agreed direction: the segment right after v1 is the message's category, not the tenant. Tenant stays implicit — it's already fixed by which broker you're connected to — and category becomes the one axis the whole namespace is organised around.

v1 / output / aircraft / SIMTL70 / telemetry
v1
Schema version. Breaking schema changes ship as v2/… in parallel; consumers migrate on their own schedule. This is the version the team is finishing now — the category reorg below happens inside v1, not as a reason to reach for v2.
output
Category — the primary axis. One of shared · input · output · internal — see §3. This replaces the tenant ID as segment 2. It's what a tree browser groups by, and what EMQX ACL rules key off: who's allowed to publish here, and whether the web app may ever subscribe.
aircraft
Scope, within the category. What the data is about. aircraft for anything keyed by a single drone; a bare feed or service name for tenant-wide data that isn't per-drone (surveillance, infrastructure, rtm-services, inventory, dock-status, or nothing at all for events/alerts, which go straight from category to message type). Not every category needs this level — some go straight from category to feed name.
SIMTL70
Serial / ID. Present only on topics scoped to one aircraft. Wildcard + subscribes to all drones: v1/output/aircraft/+/telemetry.
telemetry
Message type. Goes directly here for anything already unambiguous once you know it's aircraft-scoped: telemetry · networkid · c2links · events · proximate_aircraft · alerts. Only oi/declaration keeps an explicit context prefix — Operational Intent is a distinct domain concept, not just "another drone message," so it earns the extra segment; nothing else does.
💡
What changed, and why. Earlier drafts put the tenant in segment 2 (v1/{tenant}/aircraft/…), gave partner data its own root (v1/partner/…), and inserted a drones/ context level in front of every per-aircraft message type. Since none of this has shipped yet, we're correcting course before it does. Tenant moves out of the path — every tenant already has its own dedicated EMQX broker, so the connection itself tells you the tenant. And drones/ comes out too: once you're under aircraft/{serial}/, "this is drone data" is already established — repeating it before every message type added a segment without adding information. This is the same "kind-first" logic Tim's topic-convention proposal argues for (group by ingest/derived, not by aircraft): the tree only has one axis to spend, and ACLs / broker policy need it more than a redundant label does. See §3 for how partner data is represented without a dedicated segment.
⚠️
Not everything under output is aircraft-scoped. events and alerts/{severity} are the two exceptions to the aircraft/{serial}/… pattern above — they're tenant-wide, single topics with no serial in the path at all (v1/output/events, v1/output/alerts/{severity}), same shape as the other tenant-wide feeds (infrastructure/sites, rtm-services/platform, inventory/assets). Which drone(s) and which GUI context(s) a given event or alert belongs to is carried in the payload (droneIds[], contexts[]), not the topic. See §9 for the full rationale and schema.
Variable — changes per topic
Fixed keyword — always literal
Context-aligned — matches app nav
3. Topic categories — the four types on every tenant broker

Three categories were the starting brief — shared, input, output. Reviewing every topic already documented in this file surfaces a fourth, internal, that can't be folded into the other three without losing its ACL boundary. These four are the complete set: every topic on a tenant broker is exactly one of them, and the category alone tells you who may publish, who may consume, and whether the web app is allowed anywhere near it.

Shared
Platform-wide · broker-pushed
Surveillance traffic/sensors and weather, produced once on the shared platform broker and pushed to every tenant broker unchanged. Nothing tenant-specific about the data itself — same topic, same payload, on every tenant.
Includessurveillance/traffic/* · surveillance/sensors/* · surveillance/fused · weather/*
v1/shared/surveillance/traffic/live_mlat
Input
Producer-facing · pre-normalisation
Raw data as producers emit it, in native (non-canonical) shape. Only external producers and agents publish here; only Flink adapter jobs subscribe, transform, and republish under output. The web app never sees this category. DJI's Cloud API is the one accepted exception — it publishes at the broker root (thing/product/…) because the device can't be reconfigured to a namespaced topic; the adapter treats it as input in every other respect.
Includesgcs_telemetry (raw agent/GCS feed) · thing/product/…/osd (DJI, root exception) · anything else pre-Flink-normalisation
v1/input/aircraft/{sn}/gcs_telemetry
Output
Web-app facing · canonical
Everything Flink/FlightBinder has normalised: per-drone telemetry, networkid, c2links, OI declarations, DAA proximity — plus tenant-wide streams that aren't scoped to any one aircraft: events, alerts, and derived feeds (infrastructure, rtm-services, inventory). Only Flink jobs publish here. This and shared are the only two categories the web app subscribes to.
Includesaircraft/{sn}/telemetry · …/networkid · …/c2links · …/oi/declaration · …/proximate_aircraft — plus tenant-wide: events (incl. TISB) · alerts/{severity} · infrastructure/sites · rtm-services/platform · inventory/assets
v1/output/aircraft/{sn}/telemetry
Internal
Microservice-facing · never the web app
Coordination signals between backend services — dock↔drone pairing, asset positions, C2 station tracking. Not producer data and not consumer-facing output, so it doesn't fit cleanly in either of the other two; kept as its own category specifically so this ACL boundary doesn't have to be carved out of output by hand.
Includesdock-status/{sn} · asset-tracking/ops/{id} · asset-tracking/c2/{id}
v1/internal/dock-status/{sn}
⚠️
ACL maps directly to category. Producers/agents may publish only under v1/input/#. Only Flink may publish under v1/output/# and v1/internal/#. The web app credential may subscribe to v1/shared/# and v1/output/# only — configure EMQX to reject any web app subscription to v1/input/# or v1/internal/#.
🤝
Partnerships aren't a fifth category. The earlier draft gave cross-tenant partner data its own v1/partner/{partnerTenant}/… root. Team decision: that's not a strong-priority structural need — the JSON payloads already carry tenant identifiers, and who's partnered with whom is exactly the kind of thing that changes over time and needs a status (pending/approved/suspended), which a topic path can't express. So a partner drone's telemetry rides the same v1/output/aircraft/{sn}/telemetry topic as any other aircraft on that broker, distinguished in the payload by a sourceTenant field; which tenants are currently partnered, and in what state, is tracked in the database and exposed to the frontend over the Partnerships API rather than via broker ACL or topic namespace. See §5 for the full picture.
4. Shared platform broker — topic inventory (category: shared)

These topics originate on the shared broker and are re-published to each tenant broker under v1/shared/…

💡
Why surveillance/traffic and surveillance/sensors, not surveillance_traffic/surveillance_sensors. MQTT wildcards match whole path segments, not substrings — there's no way to subscribe to "everything surveillance" when traffic, sensors, and fused all sit at the same level under different underscore-joined names; today that means three separate subscriptions, or a broad v1/shared/+ that also pulls in weather. Nesting them under a shared surveillance/ parent — surveillance/traffic/…, surveillance/sensors/…, surveillance/fused — makes v1/shared/surveillance/# a single clean wildcard for all of it, cleanly separate from v1/shared/weather/#. This only touches the re-published, tenant-facing form; the native topic names on the shared broker itself (left column below) are owned by the upstream surveillance systems and aren't changing — the restructuring happens in the republish step, a one-line path transform. This reopens OI-01 — see §11.
Surveillance traffic — air targets
Topic (on shared broker)Re-published as (on tenant broker)RateDescription
surveillance_traffic/live_mlatv1/shared/surveillance/traffic/live_mlat1–5 HzMLAT-fused aircraft tracks. 5 Jetvision G-1090 stations — 2 clusters: CYEG area + Fort Saskatchewan, 37.2 km baseline.
surveillance_traffic/live_communityv1/shared/surveillance/traffic/live_community1–5 HzCommunity ADS-B — 989 global receivers. dump1090/readsb JSON format. Dedup by ICAO hex across feeds.
surveillance_traffic/live_regional_{region}v1/shared/surveillance/traffic/live_regional_{region}1–5 HzRegional feeds: EDM (23 receivers), CAL (55 receivers), FMM (provisioned, empty on staging).
surveillance_traffic/live_adsv1/shared/surveillance/traffic/live_ads1–5 HzAggregated ADS-B feed. Slightly different schema — no r/t fields, extended emergency string.
surveillance_traffic/live_visionv1/shared/surveillance/traffic/live_visionOn detectGACM vision sensor aircraft detections. Includes emitter_device_id. Subset of ADS-B fields.
Fused traffic — derived from the feeds above
TopicRateDescription
v1/shared/surveillance/fused1–5 HzThe Surveillance Fusion Job's deduped, merged view of every surveillance/traffic/* feed above (MLAT + community + ADS-B + vision + regional), one target list. Produced directly by Flink, not re-published from an upstream native topic — so it's already v1/shared/… shaped, no transform step. This is the tenant-wide input the DAA Proximity Job reads to build each drone's own proximate_aircraft topic (§8) — see the callout there for how a shared, non-per-aircraft feed becomes per-aircraft data.
Surveillance sensors — station health
Topic (on shared broker)Re-published as (on tenant broker)RateDescription
surveillance_sensors/live_mlatv1/shared/surveillance/sensors/live_mlat30–60 s5/5 online. G-1090 + G-1090 UAT hardware. Note: no per-device connected or uptime fields — station health inferred from envelope online_receivers.
surveillance_sensors/live_visionv1/shared/surveillance/sensors/live_vision30–60 s2/2 online. GACM-0100-000086 + 000114. Includes altitude_mm, rtt_ms, uptime per device. altitude_mm ÷ 1000 = metres.
surveillance_sensors/live_communityv1/shared/surveillance/sensors/live_community30–60 s989/989 global receivers. Unique schema: has distance_miles_edm, distance_miles_fmm, distance_miles_cal per receiver — pre-computed from all three Alberta cities.
surveillance_sensors/live_regional_{region}v1/shared/surveillance/sensors/live_regional_{region}30–60 sEDM (23 receivers), CAL (55 receivers). Single distance_miles field from regional centre.
Weather
TopicRateDescription
weather/VariesAll weather subtopics. Full subtopic structure TBD — see Open Item OI-02. Feeds Drones — Weather tab.
5. Tenant broker — RTTP telemetry topics (category: output)

Published by FlightBinder (Flink) after normalisation. All values are canonical — correct units, decoded field names, no raw integers or sentinel values.

Per-aircraft — path prefix: v1/output/aircraft/{serial}/
TopicRateApp contextKey fields
telemetry10 Hz Drones lat, lng, heading, agl/aglM, asl/aslM, atl/atlM, flightMode, armed, battery, groundSpeed, vertSpeed, hAcc, vAcc, utmState
networkid~1 Hz Drones ASTM F3411-aligned Remote ID fields, plus a clearly-separated FlySafe extension block — see the payload breakdown below this table.
c2links1 Hz Drones — C2 tab per-link: label, tech, status, latency, rssi, rfScore, isLead, uplink, downlink; mobile station: lat, lng, vehicleCallsign
oi/declarationOn change OI's oiPolygon[] (Leaflet format), conformance, status, maxAgl, start/end, subscribers[], utmProvider, declarationId
events and alerts/{severity} used to be per-aircraft topics here too — they're now tenant-wide (v1/output/events, v1/output/alerts/{severity}), grouped with the other tenant-wide feeds below. See §9 for why.
networkid payload — ASTM F3411 (Network Remote ID) alignment
⚠️
Flagged concern, addressed here. The prior networkid payload mixed genuine Remote ID broadcast fields with FlySafe-only mission-planning fields (pilot name, mission name/location, waypoints) with no marker distinguishing the two. That's a problem the moment this payload needs to satisfy a UTM/USS integration or a regulator expecting a clean F3411 message. Fix: split the payload into a standard block and a clearly-namespaced extension block, so any consumer that only wants the compliant portion can take it as-is.
FieldNotes
Standard — ASTM F3411 Network Remote ID (top-level fields)
uasId, uasIdTypeBasic ID Message. uasIdType one of Serial Number (ANSI/CTA-2063-A), CAA Registration ID, UTM/USS-assigned ID, or Specific Session ID.
uaTypeBasic ID Message. Aircraft type enum (Aeroplane, Helicopter/Multirotor, Gyroplane, Hybrid Lift, Glider, etc.).
operationalStatusLocation/Vector Message. Undeclared · Ground · Airborne · Emergency · RemoteIDSystemFailure.
latitude, longitude, geodeticAltitudeLocation/Vector Message. Position in WGS84.
height, heightTypeLocation/Vector Message. AGL or takeoff-relative — distinct from geodeticAltitude.
horizontalAccuracy, verticalAccuracy, speedAccuracyLocation/Vector Message. Standard accuracy-category enums, not raw metres.
speed, direction, verticalSpeedLocation/Vector Message. Ground speed, track (true north), vertical rate.
timestamp, timestampAccuracyLocation/Vector Message. UTC time of this state, and its accuracy.
selfIdTextSelf-ID Message (optional). Free-text operator description, e.g. flight purpose.
operatorId, operatorIdTypeOperator ID Message. An identifier, not a name — see extension block below for that.
operatorLatitude, operatorLongitude, operatorAltitudeSystem Message. Ground-control / operator position.
areaCount, areaRadius, areaCeiling, areaFloorSystem Message. Declared operating area, when applicable.
uaClassificationSystem Message, optional / region-specific (e.g. EU category + class).
FlySafe extension — nested under ext, not part of F3411
ext.pilotFullNameHuman-readable name. F3411's operatorId is an identifier, not a name — this is a FlySafe addition for the C2/Events UI.
ext.callsignInformal callsign — not an F3411 field.
ext.missionName, ext.missionLocation, ext.waypoints[]FlySafe mission-planning concept, unrelated to Remote ID broadcast content.
ext.trackOriginInternal provenance tag (which UTM/pipeline path produced this record).
Field names above describe the message types F3411's Network Remote ID defines (Basic ID, Location/Vector, Self-ID, Operator ID, System) — confirm exact wire field names against the specific F3411 edition and any regional profile (FAA vs. EASA) this tenant operates under before treating this as a wire contract. Tracked as OI-12. A full example payload is in Appendix B.
Tenant-wide — path prefix: v1/output/ (not scoped to any one aircraft)
ℹ️
An earlier draft of this table mixed these output topics in with the true v1/shared/… surveillance/weather feeds — those are covered in §4 and aren't repeated here. Everything below is Flink-produced, tenant-scoped, and lives under output: five topics that (unlike telemetry/networkid/c2links/oi above) aren't keyed by a single aircraft serial.
TopicRateApp contextKey fields
eventsOn event multi-context ts, tenantId, droneIds[], contexts[], category (Comms/Mission/DAA/Power/OI/System/TISB), severity (ok/warn/crit), eventType, message, data. Tenant-wide — one stream for the whole fleet; droneIds[] is empty for events not tied to any aircraft. See §9 for the full schema and rationale.
alerts/{severity}On alert multi-context ts, alertId, tenantId, droneIds[], contexts[], severity, category, title, message, sourceEvent, requiresAck, ackedAt, ackedBy. Tenant-wide, severity as the last path segment; droneIds: [] covers what used to be a separate broadcast topic. See §9.
infrastructure/sites30 s Infrastructure site.id, site.status, site.lat/lng, backhaul, latency, uptime, component.status, component.model
rtm-services/platform5 s RTM Services service.name, service.status, kafkaLag, flinkJobs[], cpuPct, memPct, errorRate, processingRate
inventory/assetsOn change Inventory droneId, model, serial, uasType, deploymentType, ec.adsbOut, ec.adsbIn, dockId, assignedPilot
💡
Wildcard subscriptions. Web app subscribes to all drones — own fleet and partner — with one wildcard: v1/output/aircraft/+/telemetry. Partner drones use the identical topic, distinguished only by a sourceTenant field in the payload (see below). All own-tenant output: v1/output/#. All shared feeds: v1/shared/#. There's no separate "partner" wildcard to add — supporting partnerships costs zero new subscriptions, only a payload-level check in the client. events and alerts/{severity} need no wildcard at all — v1/output/events and v1/output/alerts/+ are single, already-tenant-wide subscriptions; route each message client-side by its droneIds/contexts payload fields.
Partner drone data — same topics as own-fleet, distinguished by payload
🤝
Decision: no dedicated partner segment. An earlier draft used a separate v1/partner/{partnerTenant}/aircraft/{serial}/… root so the topic itself signalled own-fleet vs. cross-tenant. The team's call: that's not worth a dedicated path — partnership status changes over time (pending → approved → suspended → revoked), and a topic path is a poor place to represent state that changes independently of the data flowing through it.

Instead, a partner drone's telemetry, network ID, and OI declaration land on the same per-aircraft output topics as any other drone on the receiving tenant's broker — v1/output/aircraft/{serial}/telemetry, …/networkid, …/oi/declaration. The JSON payload already carries tenant identifiers (a sourceTenant / partner_tenant field), so the frontend distinguishes "is this mine or a partner's" by reading the payload, not by parsing the topic. Which tenants are currently partnered, in what state, and since when is tracked in the partnerships database and exposed to the frontend via the Partnerships API — the broker doesn't need to know about partnerships at all.
Topic (identical to own-fleet)RateApp contextWhat's added for a partner drone
v1/output/aircraft/{sn}/telemetry10 Hz Partner Drones Identical schema to own-drone telemetry, plus a sourceTenant field injected by the KafkaConnector transform. Web app checks this field to render with distinct visual treatment — grey icon, "Partner: {sourceTenant}" label.
v1/output/aircraft/{sn}/networkid~1 Hz Partner Drones Same operator context fields as own-drone networkid — pilot name, callsign, mission name, waypoints — for whichever partner-operated drone is on the map.
v1/output/aircraft/{sn}/oi/declarationOn change Partner OIs Same OI schema as own-drone declarations — polygon, conformance, max AGL, start/end, declarationId — so the partner's OI boundary renders alongside their drone position.
ℹ️
Source & lifecycle (backend, unchanged from the prior design). A partner-{B}-to-{A}-mqtt-sink KafkaConnector in the shared-kafka namespace reads {B}.telemetry.enriched and writes into this tenant's v1/output/aircraft/{serial}/… topics, routed per-serial from the Kafka message key. Partnership must be approved via the Partnerships API before messages flow — creating the connector starts streaming, deleting it stops it immediately. See Cross-Tenant Partnership Architecture for the full request → approve → suspend → resume lifecycle. What changed is only where the connector writes to (the shared output topic, not a dedicated partner root) and where partnership state lives (the database/API, not the topic tree).

ACL. v1/output/# may only be published to by this tenant's Flink jobs and approved partner KafkaConnectors — never by the web app or by producers/agents (those are confined to v1/input/#).
Partnership data in the frontend GUI

Aircraft and OI data arrive over MQTT identically for own-fleet and partner drones (above). What differs is entirely a frontend rendering and access decision, made by reading the sourceTenant payload field and cross-referencing the Partnerships API — not anything the broker enforces topic-by-topic.

GUI elementOwn fleetPartner drone
Map markerFull-colour icon by drone type/statusGrey/outline icon + "Partner: {tenant display name}" label. Display name comes from the Partnerships API, not the raw sourceTenant slug.
OI polygonSolid fill in tenant brand colour; conformance shown by fill/border colour (green conforming / red non-conforming)Dashed/hatched border, muted fill, so ownership reads at a glance without being confused for a conformance signal. Conformance still shown, but as a small badge rather than changing the base fill.
Detail panel (on click)Full detail: telemetry, C2 links, events, alerts, OI, command actions (RTL, mode change, etc.)Position, networkid context (whatever the partner shares — pilot/callsign/mission per §5's networkid split), and OI status only. No alerts panel, no command affordances — partner drones are read-only and never alert into this tenant.
FilteringStandard fleet filters (status, dock, mission)Global "Show partner drones" toggle, plus a per-partner toggle sourced from the Partnerships panel below. All client-side against sourceTenant — no extra MQTT subscription needed.
🤝
New surface: a Partnerships panel, backed by the API — not MQTT. Since partnership status/lifecycle isn't in the topic tree (§3), the frontend needs a place to show it: a panel listing each partnership from GET /api/partnerships — partner tenant name, status (pending/approved/suspended), and since-date — with the per-partner map visibility toggle living here. This is the one place partnership state surfaces in the GUI; the drone and OI data itself keeps flowing over the same MQTT topics regardless of what this panel shows or hides — the toggle only affects client-side rendering, not the subscription.
⚠️
Deliberately out of scope for partner drones. No alerts (partner drone alerts, if any, are that tenant's own concern, not surfaced here). No command/control affordances — partnership is read-only end to end. No DAA/proximate_aircraft sharing between tenants — each tenant's DAA proximity job still only reasons about its own drones against the shared surveillance feed (§8); a partner's drone is not specially injected as a DAA target.
6. Tenant broker — external ingest topics (category: input)

Third-party data arriving in native formats. Flink adapter jobs normalise and republish. The web app never subscribes to these topics.

⚠️
External ingest topics live at the broker root — no namespace prefix. DJI and weather cannot be routed to nested topics due to constraints of the external systems. They publish directly to root-level topics. Flink adapter jobs subscribe and route to the correct Kafka topics.
DJI Cloud API — root level
Topic (root)RateKafka outputNotes
thing/product/{sn}/osd1–5 Hz{tenant}.telemetry.raw.djiOSD = On-Screen Display. host key = dock/GCS. sub key = aircraft (airborne only).
thing/product/{sn}/#Various{tenant}.telemetry.raw.djiAll DJI message types via one wildcard subscription.
DJI Bridge adapter — required conversion rules
data.sn
Dock serial — look up aircraft serial via dronesense_dock_status before attributing OSD data to a drone
host key
Dock / GCS hardware telemetry — always present. Maps to dock.* and c2.* canonical fields.
sub key
Aircraft telemetry — only present when drone is airborne. Maps to position, altitude, battery, flight mode fields.
quality == 65535
DJI sentinel = "not measured". Map to null. Never pass 65535 to the web app — it is not a valid signal quality value.
departure_point 0,0
Home point not yet acquired. Map lat/lon 0,0 to null in rth.homePt.
tilt_angle.value
Radians → degrees: multiply by 57.2958. Sample: 0.5978 rad = 34.2°
link_workmode
1 = SDR primary link. 0 = 4G primary link. Map to c2.workmode string.
oiPolygon coordinates
DJI / GeoJSON convention is [longitude, latitude]. Leaflet expects [latitude, longitude]. Swap all coordinate pairs before publishing.
7. Tenant broker — internal system topics (category: internal)

Microservice coordination signals. Current topics use flat unversioned names. Migration to v1/internal/… namespace is recommended.

Internal topics — current + proposed names
Current topicProposed topicConsumerPurpose
dronesense_dock_status/{dock_sn} v1/internal/dock-status/{sn} DJI Bridge adapter Dock↔drone serial pairing registry. Required lookup before DJI OSD can be attributed to the correct aircraft. dock_sn ≠ drone_sn.
asset_tracking/ops_asset/{asset_id} v1/internal/asset-tracking/ops/{id} Asset tracker service + infra map Operational asset positions (trucks, docks, vehicles). Dock variant (subcategory=Dock) includes on-site weather: temperature, humidity, wind_speed, rainfall — feeds wx.groundSensors[].
asset_tracking/c2_monitoring/{station_id} v1/internal/asset-tracking/c2/{id} C2 Monitor service Mobile C2 station position. GRS station is vehicle-mounted on chase truck — horizontal_speed > 0 when moving. Feeds C2 tab station geolocation. Must update dynamically.
⚠️
Parallel publish during migration. Rename internal topics by publishing to both old and new paths simultaneously for a transition period. Internal consumers can cut over to the new path independently.
Key findings from payload analysis
Dock = weather station
The asset_tracking/ops_asset/{dock_id} payload with subcategory=Dock carries info.temperature, info.humidity, info.wind_speed, info.environment_temperature, info.rainfall. This can populate the Drones weather tab ground sensors for sites with a dock — no separate weather API needed.
Drone in dock state
info.drone_in_dock == 1 confirms drone is physically docked. info.drone_charge_state.capacity_percent gives battery while docked. info.sub_device.device_online_status gives aircraft agent connectivity.
RTK quality from dock
info.position_state.rtk_number (29 sats), is_fixed == 2 (RTK fixed), quality == 5 (best). This is the dock's GPS quality, not the aircraft's.
8. Proximate aircraft topic — per-drone

A dedicated per-drone topic streams aircraft within the DAA evaluation rings to both the web app and the aircraft agent. Critical path for DAA services — each consumer gets the same message, no topic duplication.

Data flow — DAA proximity job
Fused surveillance targets
v1/shared/surveillance/fused (tenant-wide, all targets)
Drone telemetry
v1/output/aircraft/{sn}/telemetry
DAA ring config
eval / SWC / NMAC radii
DAA Proximity Job
Flink — keyed by drone serial
computes distance + closure rate
assigns ring: EVAL / SWC / NMAC
proximate_aircraft
v1/output/aircraft/{serial}/proximate_aircraft
Web app
renders NMAC/SWC/Eval rings
Aircraft agent
onboard DAA logic
Wildcard sub all drones:
v1/output/aircraft/+/proximate_aircraft
💡
How the shared fused feed becomes per-aircraft data. v1/shared/surveillance/fused itself is not per-drone — it's one tenant-wide stream of every fused target (MLAT + community + ADS-B + vision + regional, deduped by the Surveillance Fusion Job), the same for every client that subscribes. It only becomes "per aircraft" here: the DAA Proximity Job joins that one shared stream against this tenant's per-drone telemetry, computes distance/bearing/closure relative to each drone individually, assigns a ring, and republishes a personalised slice to that drone's own proximate_aircraft topic. So the sharing mechanism isn't a broker feature (there's no per-aircraft filtering at the MQTT level) — it's this Flink job doing the join once, so neither the web app nor the aircraft agent has to filter the full traffic firehose themselves.
Payload fields
FieldTypeDescription
tsint msEvaluation timestamp (Unix ms)
droneIdstringDrone serial — self-describing alongside topic path
evalRadius / swcRadius / nmacRadiusfloat mRing radii in metres — tenant/drone-configurable, not hardcoded
evalCount / swcCount / nmacCountintTarget count per ring — for UI badge display
targets[].hexstringICAO hex — primary target ID
targets[].callsignstringTrimmed ATC callsign
targets[].lat / .lngfloat degTarget position
targets[].alt_baro / .alt_geomfloat ftBarometric and geometric altitude
targets[].gs / .track / .baro_ratekt / deg / ft/minSpeed, heading, vertical rate
targets[].distM / .distNmfloatDistance from drone — primary DAA field
targets[].bearingfloat degBearing from drone to target (true north)
targets[].closureRatefloat m/sClosure rate — positive = approaching
targets[].ringenumEVAL / SWC / NMAC
targets[].isMlat / .isTisb / .trust / .feedSourcemixedSource provenance and trust level
9. Events vs alerts — two separate queues

Events and alerts serve different audiences and travel different pipelines. A single event may trigger zero, one, or many alerts depending on configurable routing rules.

Events
System → system
Operational facts that happened. Consumed by the web app Events tab and Flink CEP jobs. Every state change is recorded. Retained 30 days — queryable. Not all events need a user alert. Tenant-wide topic — the payload's droneIds[] and contexts[] say which drone(s) and GUI area(s) it belongs to.
v1/output/events
Alerts
System → human
Notifications requiring human attention. Tenant-wide topic, one subscription regardless of fleet size — the web app routes each alert to the right drone panel (or a global banner) using droneIds[]/contexts[], the same way events do. No per-aircraft subscription, no separate broadcast topic.
v1/output/alerts/{severity}
Events → Alert Router → tenant-wide alert topic
Flink CEP jobs
detect state changes, fleet-wide
events
what happened · stored 30 d
droneIds[] + contexts[] in payload
→ web app Events tab · further Flink CEP
Alert Router (Flink)
event → should alert? who?
alerts/{severity}
Notification svc
💡
Why tenant-wide instead of per-aircraft. events and alerts/{severity} used to sit under aircraft/{serial}/… like telemetry. Moving them to flat topics under output lines them up with how the other non-per-drone output feeds already work (infrastructure/sites, etc.) instead of running two competing patterns for conceptually similar "things that happen" data. It also folds the previously separate shared/alerts/broadcast topic into the general model — a platform-wide alert is just an alert with droneIds: [], not a special case. And it scales flatter: one subscription regardless of fleet size, instead of a + wildcard whose match set grows with every new drone.

Trade-off, honestly. Every subscriber now receives the whole tenant's event/alert stream and filters client-side by droneIds — for very large fleets that's more bandwidth per client than a scoped per-serial subscription would be. Reasonable for fleet sizes in the tens, worth revisiting if that changes — tracked as OI-15.
💡
Do we still need severity in the path? Yes — this is the one broker-level filter left once the aircraft serial is out of the topic. A prior draft also published a separate flat .../alerts topic alongside the severity-scoped one; that's gone too (§ history), since the wildcard alerts/+ already covers "give me everything" with a single topic shape published once.
Event → alert routing rules
Event Alert? Severity Notify
DAA
utmState == EMERGENCYYESCRITICALAll users
DAA NMAC breachYESCRITICALOps team
DAA SWC breachYESWARNINGOps team
Target enters eval ringnoEvent only — no alert
C2 links
All C2 links lostYESCRITICALOps + pilot
Primary link lostYESWARNINGOps team
Link restorednoEvent only
Power
Battery < 15%YESWARNINGOps team
Battery < 5%YESCRITICALAll users
Battery at 30%noEvent only — logged
OI / Mission
OI non-conformingYESWARNINGOps team
Mission completeYESINFOOps team
Flight mode changenoEvent only — logged
TISB — folded in from the old dedicated tisb topic
CIFIB connection lostYESWARNINGOps team
CIFIB connection restorednoEvent only — logged
Alert routing rules are configurable — stored in Alert Router config, not hardcoded · Threshold values (15%, 5%, ring radii) must be tenant-configurable
Alert topic — tenant-wide, one stream per severity
✈️
Alerts are tenant-wide, not aircraft-scoped. The web app subscribes once for the whole tenant — not per monitored drone — and routes each alert to the right drone panel using the payload's droneIds[], or to a global banner when droneIds is empty (what used to be a separate broadcast topic). No per-aircraft subscription, no per-user routing, no user identity required.
TopicPurposeWho subscribes
v1/output/alerts/{severity}All alerts, tenant-wide — DAA, C2, battery, OI, mission, and platform-level (droneIds: []). One topic shape serves both "give me everything" (wildcard the severity segment) and "give me only criticals" (subscribe the exact severity).Web app (one subscription, all drones) · Selective automated consumers (exact severity, e.g. a paging integration)
Web app wildcard — all severities: v1/output/alerts/+ — single subscription, no aircraft wildcard needed since the topic was never per-aircraft. Client routes by droneIds[]/contexts[] in the payload to the correct drone panel or a global banner.
Payload schemas — events & alerts — full eventType catalog in Appendix A
Event payload · v1/output/events
FieldTypeDescription
tsint msUnix timestamp (ms) when event occurred
tenantIdstringTenant identifier
droneIds[]string[]Aircraft serial(s) this event concerns. Empty for a tenant-wide event not tied to any drone (e.g. a Flink job restart); one element in the common case; more than one for events involving multiple aircraft.
contexts[]string[]Which GUI area(s) to surface this in — one or more of drones · oi · surveillance · infrastructure · rtm-services · inventory (§2). Usually one; an OI event might carry both drones and oi so it shows in each place a user would look for it.
categoryenumComms · Mission · DAA · Power · OI · System · TISB
severityenumok · warn · crit
eventTypestringMachine-readable identifier (snake_case)
messagestringHuman-readable event description
dataobjectCategory-specific detail fields — see examples below
Alert payload · v1/output/alerts/{severity}
FieldTypeDescription
tsint msUnix timestamp (ms) when alert was issued
alertIdstringUnique alert identifier — prefixed ID or UUID
tenantIdstringTenant identifier
droneIds[]string[]Aircraft serial(s) this alert concerns. Empty array for platform-wide/broadcast alerts — replaces the old separate shared/alerts/broadcast topic; same field shape as events.
contexts[]string[]Which GUI area(s) to surface this in — same enum and multi-value rationale as the event payload above.
severityenumcritical · warning · info
categoryenumDAA · C2 · Power · OI · System
titlestringShort display title for UI notification
messagestringFull alert message for display
sourceEventstringeventType that triggered this alert
requiresAckboolWhether user acknowledgement is required
ackedAtint ms|nullAcknowledgement timestamp — null if pending
ackedBystring|nullUser ID who acknowledged — null if pending
Test & development example payloads

Ready-to-publish payloads for each event and alert category. Use these in MQTT Explorer, a test harness, or the EMQX dashboard to simulate flight conditions during development.

ℹ️
Test drone: SIMTL70 · Tenant: airmarket · Per-aircraft topic prefix: v1/output/aircraft/SIMTL70/ (telemetry, networkid, c2links, oi/declaration, proximate_aircraft) · Events/alerts: tenant-wide, no serial in the topic — v1/output/events, v1/output/alerts/{severity} — replace ts values with the current Unix epoch in milliseconds when publishing.
Event examples — topic: v1/output/events (all use droneIds: ["SIMTL70"] below)
Comms — primary link lost (warn)
{
  "ts": 1718467200000,
  "tenantId": "airmarket",
  "droneIds": ["SIMTL70"],
  "contexts": ["drones"],
  "category": "Comms",
  "severity": "warn",
  "eventType": "link_primary_lost",
  "message": "Primary C2 link lost — SDR offline",
  "data": {
    "linkLabel": "SDR",
    "tech": "SDR",
    "previousStatus": "active",
    "currentStatus": "offline",
    "backupActive": true,
    "backupTech": "4G"
  }
}
DAA — NMAC breach (crit)
{
  "ts": 1718467320000,
  "tenantId": "airmarket",
  "droneIds": ["SIMTL70"],
  "contexts": ["drones"],
  "category": "DAA",
  "severity": "crit",
  "eventType": "daa_nmac_breach",
  "message": "NMAC breach — C172 within 152m",
  "data": {
    "targetHex": "C00F12",
    "targetCallsign": "CNC172",
    "distM": 152.4,
    "closureRate": 14.7,
    "ring": "NMAC",
    "targetAltFt": 2340,
    "droneAglM": 91.5
  }
}
Power — battery < 15% (warn)
{
  "ts": 1718467440000,
  "tenantId": "airmarket",
  "droneIds": ["SIMTL70"],
  "contexts": ["drones"],
  "category": "Power",
  "severity": "warn",
  "eventType": "battery_low",
  "message": "Battery at 13% — return recommended",
  "data": {
    "batteryPct": 13,
    "threshold": 15,
    "estimatedFlightTimeSec": 210,
    "voltageV": 21.6,
    "currentA": 18.3
  }
}
OI — non-conforming (warn)
{
  "ts": 1718467560000,
  "tenantId": "airmarket",
  "droneIds": ["SIMTL70"],
  "contexts": ["drones", "oi"],
  "category": "OI",
  "severity": "warn",
  "eventType": "oi_non_conforming",
  "message": "Aircraft outside OI boundary",
  "data": {
    "declarationId": "oi-2024-06-15-001",
    "conformance": "NON_CONFORMING",
    "deviationM": 47.2,
    "maxAglM": 120,
    "currentAglM": 134.5,
    "missionName": "Pipeline Survey North"
  }
}
Mission — complete (ok)
{
  "ts": 1718468100000,
  "tenantId": "airmarket",
  "droneIds": ["SIMTL70"],
  "contexts": ["drones"],
  "category": "Mission",
  "severity": "ok",
  "eventType": "mission_complete",
  "message": "Mission completed successfully",
  "data": {
    "missionName": "Pipeline Survey North",
    "missionId": "msn-2024-06-15-003",
    "durationSec": 1740,
    "waypointsTotal": 12,
    "waypointsCompleted": 12,
    "pilotName": "Jordan Ellis"
  }
}
System — flight mode change (ok)
{
  "ts": 1718467680000,
  "tenantId": "airmarket",
  "droneIds": ["SIMTL70"],
  "contexts": ["drones"],
  "category": "System",
  "severity": "ok",
  "eventType": "flight_mode_change",
  "message": "Flight mode changed to AUTO",
  "data": {
    "previousMode": "LOITER",
    "currentMode": "AUTO",
    "armed": true,
    "flightMode": "AUTO"
  }
}
TISB — connection lost (warn)
{
  "ts": 1718467740000,
  "tenantId": "airmarket",
  "droneIds": ["SIMTL70"],
  "contexts": ["drones", "surveillance"],
  "category": "TISB",
  "severity": "warn",
  "eventType": "tisb_connection_lost",
  "message": "CIFIB connection lost — no TIS-B traffic feed",
  "data": {
    "cifibStatus": "disconnected",
    "previousStatus": "connected",
    "lastUpdate": 1718467700000
  }
}
Alert examples — topic: v1/output/alerts/{severity} — the exact severity below is the last path segment
DAA — NMAC breach (critical)
{
  "ts": 1718467320000,
  "alertId": "alrt_daa_7f3a9c",
  "tenantId": "airmarket",
  "droneIds": ["SIMTL70"],
  "contexts": ["drones"],
  "severity": "critical",
  "category": "DAA",
  "title": "NMAC Breach",
  "message": "C172 (C00F12) within 152m — immediate action required",
  "sourceEvent": "daa_nmac_breach",
  "requiresAck": true,
  "ackedAt": null,
  "ackedBy": null
}
C2 — all links lost (critical)
{
  "ts": 1718467200000,
  "alertId": "alrt_c2_2b8f1e",
  "tenantId": "airmarket",
  "droneIds": ["SIMTL70"],
  "contexts": ["drones"],
  "severity": "critical",
  "category": "C2",
  "title": "All C2 Links Lost",
  "message": "All C2 links offline — aircraft in failsafe",
  "sourceEvent": "link_all_lost",
  "requiresAck": true,
  "ackedAt": null,
  "ackedBy": null
}
Power — battery < 15% (warning)
{
  "ts": 1718467440000,
  "alertId": "alrt_pwr_9d4c5a",
  "tenantId": "airmarket",
  "droneIds": ["SIMTL70"],
  "contexts": ["drones"],
  "severity": "warning",
  "category": "Power",
  "title": "Low Battery",
  "message": "Battery at 13% — return to home recommended",
  "sourceEvent": "battery_low",
  "requiresAck": false,
  "ackedAt": null,
  "ackedBy": null
}
Platform-wide — topic: v1/output/alerts/warning (droneIds: [] — was a separate broadcast topic)
{
  "ts": 1718467800000,
  "alertId": "alrt_sys_3e2a1f",
  "tenantId": "airmarket",
  "droneIds": [],
  "contexts": ["rtm-services"],
  "severity": "warning",
  "category": "System",
  "title": "Platform Degraded",
  "message": "FlightBinder latency elevated — telemetry may be delayed",
  "sourceEvent": "flink_job_lag_high",
  "requiresAck": false,
  "ackedAt": null,
  "ackedBy": null
}
💡
Testing workflow. Publish event payloads to v1/output/events (with droneIds: ["SIMTL70"] in the body) to verify the Events tab renders. Publish alert payloads to v1/output/alerts/{severity} (e.g. .../alerts/critical) to verify the alert panel — the topic no longer carries the serial, so droneIds in the payload is what routes it to the right panel. The Alert Router normally produces alerts automatically from events — in manual testing, publish both independently. Use MQTT Explorer or: mosquitto_pub -h emqx-mqtt.airmarket.stage.flysafe.live -t "v1/output/alerts/critical" -m '{...}'
10. Migration plan — v0 (legacy) to v1 (category-first)

The current, live FLYsafe RTM app — v0 — subscribes to a set of legacy flat topics with no structure at all. Nothing has shipped yet against a namespaced design, which is exactly why we can finish v1, the version being implemented now, as category-first from the start rather than build a tenant-first shape and re-migrate later. This section defines the full porting path from v0 to v1 for frontend developers and the parallel-publish strategy that makes the migration zero-downtime.

Four structural changes, finished in v1
Change 1 — Category axis
All topics gain a structured prefix keyed by category: v1/output/aircraft/{serial}/…, v1/shared/…, v1/input/…, v1/internal/…. The tenant is not in the path — it's already fixed by which broker you connect to. Category is explicit instead, which is what ACL rules actually need to key off.
Change 2 — Context hierarchy
Flat topic names become hierarchical app-context paths. networkid/{serial} becomes v1/output/aircraft/{serial}/networkid. The context segment (drones/, oi/) maps 1-to-1 with the RTM UI navigation context — no per-serial subscription needed; one wildcard covers all drones.
Change 3 — Partnerships via payload, not path
Cross-tenant drone feeds are not a separate topic root. A partner drone publishes to the exact same v1/output/aircraft/{serial}/… topics as any other aircraft, carrying a sourceTenant payload field. Which tenants are partnered, and in what state, is tracked in the database and served over the Partnerships API.
Change 4 — Events & alerts go tenant-wide
The two exceptions to "aircraft-scoped." v1/output/events and v1/output/alerts/{severity} carry no serial at all — one flat topic per message type, with droneIds[] and contexts[] in the payload identifying who and where it's for. Folds the old separate broadcast alert topic into the same model (§9).
Current — legacy topics (v0)
FLYsafe RTM v0
Flat topic names, no tenant prefix, no partnership support. Remains active during the parallel publish window. The web app subscribes directly to these.
networkid/{serial}
flightplan/{serial}
surveillance_traffic/{feed}
TISB Stream/{serial}
thing/product/{sn}/osd ← middleware-internal
— no partnership support —
Target — category-first structure (v1)
FLYsafe RTM v1
Every topic namespaced by category — output, shared, input, internal. No separate partnership root: partner drones are just more output traffic, tagged in the payload. Developed and tested in parallel against the new paths during the transition window.
v1/output/aircraft/{sn}/networkid
v1/output/aircraft/{sn}/oi/declaration
v1/shared/surveillance/traffic/{feed}
v1/output/events (category: TISB — tenant-wide, no serial)
v1/output/aircraft/{sn}/telemetry
v1/output/aircraft/{sn}/telemetry + sourceTenant (partner)
Topic-by-topic migration map
Legacy topic (v0)Target topic (v1)PriorityNotes
Own-drone telemetry — v1/output/aircraft/{serial}/…
networkid/{serial} …/networkid P1 Primary normalised source — heading, AGL, pilot name, callsign, mission name, waypoints. Most critical topic to port. Wildcard: v1/output/aircraft/+/networkid.
flightplan/{serial} …/oi/declaration P1 OI polygon (Leaflet format), conformance state (CONFORMING / NON_CONFORMING), max AGL, start/end times, declarationId. Renamed from flightplan to reflect the operational intent concept. Wildcard: v1/output/aircraft/+/oi/declaration.
Events & alerts — v1/output/events, v1/output/alerts/{severity} — tenant-wide, no serial
TISB Stream/{serial} v1/output/events (category: TISB) P2 TIS-B CIFIB connection status no longer gets its own topic — it's folded into the generic, tenant-wide event stream as category: "TISB", identified by droneIds[] in the payload rather than a per-serial topic. See §9 for the event schema and routing rule.
— no legacy topic — v1/output/alerts/{severity} NEW The Alert Router's output, tenant-wide. There is no legacy alert topic to port from — v0 has no alerting concept. Also absorbs what would otherwise have been a separate broadcast topic (droneIds: []).
Shared feeds — v1/shared/…
surveillance_traffic/{feed}
surveillance_sensors/{feed}
v1/shared/surveillance/traffic/{feed}
v1/shared/surveillance/sensors/{feed}
P2 Payload and feed names (live_ads, live_mlat, live_community, live_regional_*) are unchanged. The republish step now also restructures the path — surveillance_traffic and surveillance_sensors become surveillance/traffic and surveillance/sensors, so v1/shared/surveillance/# is one wildcard for all of it (§4). Middleware Surveillance Fusion Job publishes to both old and new paths during the transition window.
External ingest — unchanged at broker root (middleware-internal, not subscribed by web app)
thing/product/{sn}/osd v1/output/aircraft/{sn}/telemetry
(produced by DJI Bridge adapter)
P3 DJI publishes to the root-level thing/product/ path — this does not change. The DJI Bridge adapter normalises OSD payloads into the canonical telemetry schema. The web app never subscribes to thing/product/… directly — it always consumed normalised output.
Partnership data — no dedicated topic, rides the output rows above
— no legacy topic — Same as own-fleet:
…/telemetry · …/networkid · …/oi/declaration
+ sourceTenant field
NEW A KafkaConnector writes an approved partner's enriched telemetry, networkid, and OI declarations into this tenant's normal output topics per-serial, tagged with sourceTenant. No new topic root, no new wildcard — see §5 for the full rationale and §3 for the "not a fifth category" decision. Partnership state (who, since when, what status) is DB-backed, exposed via the Partnerships API.
Net-new topics — no legacy equivalent, v1 only
TopicContextWhat it enables
v1/output/aircraft/{sn}/telemetryDronesCanonical 10 Hz drone position direct from FlightBinder — lat, lng, heading, AGL, ASL, flight mode, armed state, battery, ground speed. Primary own-fleet map feed.
v1/output/aircraft/{sn}/c2linksDrones — C2 tabPer-link C2 telemetry — SDR/4G label, latency, RSSI, RF score, primary/backup designation, mobile station lat/lng. Powers the C2 tab and chase-truck map pin.
v1/output/eventsmulti-contextTyped event stream (Comms / Mission / DAA / Power / OI / System / TISB), tenant-wide. Powers the Events tab, routed client-side by droneIds[]/contexts[]; feeds the Alert Router for downstream alerting.
v1/output/aircraft/{sn}/proximate_aircraftDrones — DAADAA proximity rings — all air traffic within EVAL / SWC / NMAC radii, with distance, bearing, and closure rate per target. Powers the DAA map overlay.
v1/output/alerts/{severity}multi-contextAlert stream, tenant-wide, severity as the last segment. Frontend subscribes once with v1/output/alerts/+ — no aircraft wildcard needed. Routes by droneIds[] to the correct drone panel, or a global banner when empty (absorbs the old broadcast topic).
🤝
Partnership needs no MQTT changes at all. Because partner drones publish onto the same v1/output/aircraft/{serial}/… topics as own-fleet drones (§5), the wildcard subscriptions the frontend already needs for its own fleet cover partner drones automatically the moment a KafkaConnector starts writing. The only frontend work is (1) reading the sourceTenant payload field to decide how to render a drone, and (2) calling the Partnerships API to know which tenants are currently partnered, for UI labelling — there's nothing to subscribe to that isn't already subscribed.
Frontend subscription checklist — v0 → v1 porting guide
Replace all subscriptions in the Remove block once v1 is validated and before v0 is retired. Add everything in the Add blocks from day one of v1 frontend development — they are live and publishable now. There's no tenant placeholder to fill in below — the tenant is fixed by which broker you connect to, not by the topic string.
↳ Remove — unsubscribe from legacy topics once v1 is in production
networkid/{serial}
Replace with v1/output/aircraft/+/networkid — single wildcard covers all drones, no per-serial subscription needed
flightplan/{serial}
Replace with v1/output/aircraft/+/oi/declaration — renamed to reflect the operational intent concept; schema extended with conformance state
surveillance_traffic/{feed}
Replace with v1/shared/surveillance/traffic/{feed} — payload and feed names unchanged; path now nests under surveillance/ (§4)
TISB Stream/{serial}
Replace with v1/output/events, filtering for category: "TISB" — no longer a dedicated topic, and no longer per-serial; the affected drone is in the payload's droneIds[]
↳ Add — own-drone subscriptions (new topics, no legacy equivalent)
v1/output/aircraft/+/telemetry
10 Hz canonical position from FlightBinder — primary map feed for own-fleet drones. Replaces DJI raw OSD (which was always middleware-internal)
v1/output/aircraft/+/c2links
Per-link C2 detail — feeds the C2 tab; provides mobile station lat/lng for the chase-truck map pin
v1/output/aircraft/+/proximate_aircraft
DAA proximity rings — renders EVAL / SWC / NMAC rings and target list on the map for each active drone
↳ Add — tenant-wide subscriptions (no wildcard, no legacy equivalent)
v1/output/events
Typed event stream, tenant-wide, now including TISB connection status as its own category — feeds Events tab; route client-side by droneIds[]/contexts[]. The Alert Router derives alert topics from these events.
v1/output/alerts/+
Alert stream, tenant-wide, severity as the last segment — one subscription, no aircraft wildcard. Route by droneIds[] to the correct drone panel, or a global banner when empty. Subscribe a narrower .../alerts/critical instead if a consumer only wants criticals.
↳ Partnership support — no new MQTT subscriptions
(reuses the wildcards above)
Partner drones arrive on the same v1/output/aircraft/+/telemetry / …/networkid / …/oi/declaration wildcards already subscribed for own-fleet — check the sourceTenant payload field to render distinctly (grey icon, "Partner: {sourceTenant}" label)
GET /api/partnerships
New — not MQTT. Call the Partnerships API to know which tenants are currently partnered and their status, for UI filtering/labelling. This replaces what a v1/partner/… topic segment would have told you
Parallel publish strategy — zero downtime migration
1
Middleware publishes to BOTH namespaces simultaneously. Each topic is published to the legacy flat path AND the new category-first v1/output/… / v1/shared/… path. No existing consumer breaks. Partner drone data begins appearing on v1/output/aircraft/{serial}/… the moment the first KafkaConnector is created for an approved partnership — it has no legacy path to maintain and needs no separate publish step.
2
v1 app is built and tested against the new namespace. Frontend subscribes to v1/output/… and v1/shared/… topics and calls the Partnerships API for partner status. Both versions run in parallel — v0 on legacy paths, v1 on the category-first structure — without interfering with each other.
3
v1 reaches feature parity — switch traffic over. When v1 passes acceptance testing, route production users to v1. v0 remains on standby as rollback for an agreed window. Partnership features are live in v1 from the first approved partnership — no additional release required.
4
Decommission legacy topic paths. After an agreed notice period, middleware stops publishing to legacy flat paths and they're retired. Category-first v1 is the only structure going forward until a genuine v2 schema change is needed.
11. Open items

Unresolved items and confirmed resolutions.

IDItem / ResolutionStatusOwner
OI-01surveillance_traffic/ structure NOT changing. Parent topic with sub-topics per service. live_ads has reduced field set vs live_community — missing r, t, mlat[], rssi. Fusion Job handles both schemas.✅ ResolvedMiddleware
OI-02Topic is weather/. Subtopic structure and payload TBD — to be provided by weather service team.⏳ PartialWeather team
OI-03dronesense_dock_status produced by FLYsafe microservices AFTER OSD messages arrive. DJI Bridge must queue OSD until pairing available — do not drop.✅ ResolvedDroneSense
OI-04Asset IDs are human-labelled in microservice config. Stable per deployment.✅ ResolvedAsset tracker
OI-05GRS = Ground Radio Station. Stable per vehicle per deployment.✅ ResolvedC2 team
OI-06Internal topic migration to v1/internal/ namespace. Parallel publish approach confirmed. Timeline TBD.⏳ In progressMiddleware
OI-07DLQ monitoring — developer monitors during development. AI agent monitoring under consideration for production.⏳ In progressMiddleware / Ops
OI-08Partnership connector source topic: prefer {partnerTenant}.telemetry.enriched over .raw — enriched is already canonical RTTP schema, web app receives correct units and decoded fields with no additional transform. Confirm Tenant B's enriched topic is published into the shared-kafka namespace before the connector is built.⏳ OpenTenant B middleware
OI-09Category set for the v1/{category}/… axis (§3): brief named three (shared, input, output); reviewing existing dock-status / asset-tracking / C2-station topics surfaced a needed fourth, internal. Team to confirm this is the complete set and that the keyword itself (vs. e.g. coordination) is final.⏳ OpenMiddleware / Web App
OI-10Tenant dropped entirely from the topic path (was segment 2 in the prior draft) on the basis that each EMQX broker is already tenant-dedicated. Confirm no consumer genuinely needs a single federated cross-tenant subscription spanning multiple brokers — if one ever does, that's a reason to revisit, not a reason to keep tenant in every topic today.⏳ OpenMiddleware
OI-11Partnership representation: v1/partner/… root replaced by a sourceTenant payload field on normal output topics, with partnership status/lifecycle moved to a database + Partnerships API. Confirm the API's shape (endpoint, auth, poll vs. push) with the web app team before it's built.⏳ OpenMiddleware / Web App
OI-12networkid payload redesigned around ASTM F3411 Network Remote ID message types, with mission/pilot fields moved under a non-standard ext block (§5). Field names above describe F3411's message types, not a confirmed wire schema — verify against the exact F3411 edition/profile in use and adjust field names before implementation.⏳ OpenMiddleware / Compliance
OI-13surveillance/fused (§4, §8) categorised as shared on the assumption the Fusion Job's merged output is identical across tenants, same as its shared inputs. Confirm whether it actually runs once centrally or per-tenant, and whether any tenant-specific filtering is ever applied — if so it belongs under output instead.⏳ OpenMiddleware
OI-14TISB CIFIB connection status folded into the events topic as category: "TISB" (§5, §9), replacing the old dedicated drones/tisb topic. Confirm no existing consumer depends on subscribing to TISB status in isolation without pulling the full event stream.⏳ OpenWeb App
OI-15events and alerts/{severity} moved from per-aircraft (aircraft/{serial}/…) to tenant-wide flat topics (§9, §10), with droneIds[]/contexts[] replacing topic-level scoping. Confirm this array-based payload shape is final (vs. a simpler nullable single droneId for the common one-drone case), and confirm the bandwidth trade-off — every client now receives the whole tenant's stream — is acceptable at expected fleet sizes.⏳ OpenMiddleware / Web App
OI-16Re-published (tenant-facing) surveillance topics restructured from surveillance_traffic/surveillance_sensors to nested surveillance/traffic/surveillance/sensors (§4), refining OI-01 — the native upstream topic naming on the shared broker is unaffected, only the republish transform. Confirm no tenant-side consumer already depends on the old flat v1/shared/surveillance_traffic/… form before cutover.⏳ OpenMiddleware
Appendix A — Anticipated event types

The eventType catalog the frontend Events tab (and the Alert Router) should build against. All of these arrive on the single tenant-wide v1/output/events topic (§9) — the frontend routes by the payload's droneIds[] and contexts[], not by which topic the event was on. §9 shows six as worked examples; this is the fuller set to design the UI around now, so a new event type doesn't require a frontend release later.

⚠️
Anticipated, not all shipped on day one. Some rows below are already emitted (matching §9's routing table); others are reasonable additions surfaced by reviewing the categories this doc already defines (e.g. a conforming-again event to pair with oi_non_conforming, or a link-restored event to pair with each link-lost). Build the Events tab to render gracefully on an eventType or category it doesn't recognise — new ones will land over time without warning.
eventTypeCategorySeverityAlert?Notes
DAA
daa_target_enters_evalDAAoknoTarget enters the outer evaluation ring. Logged for the DAA target list; no user-facing alert.
daa_swc_breachDAAwarnWARNINGTarget inside the self-separation (SWC) ring.
daa_nmac_breachDAAcritCRITICALNear mid-air collision ring breach.
daa_conflict_resolvedDAAoknoTarget has left the ring it previously breached — pairs with the breach event so the UI can clear the alert state.
utm_emergency_declaredDAAcritCRITICALutmState == EMERGENCY.
Comms (C2)
link_degradedCommswarnWARNINGRF score / latency crosses a configured threshold without the link actually dropping.
link_primary_lostCommswarnWARNINGPrimary link down, backup still active.
link_all_lostCommscritCRITICALAll C2 links down — aircraft in failsafe.
link_restoredCommsoknoA previously-lost link (primary, secondary, or all) is back.
Power
battery_level_updatePoweroknoRoutine checkpoint (e.g. 30%) — logged, not alerted.
battery_lowPowerwarnWARNINGBelow the tenant-configurable low threshold (default 15%).
battery_criticalPowercritCRITICALBelow the tenant-configurable critical threshold (default 5%).
battery_charging_startedPoweroknoDock-charging began — relevant to the Inventory/Dock UI, not the flight map.
battery_charging_completePoweroknoDock-charging finished.
OI / Mission
oi_non_conformingOIwarnWARNINGAircraft outside its declared OI boundary.
oi_conformingOIoknoBack inside the boundary — pairs with oi_non_conforming to clear the alert.
mission_startedMissionoknoMission execution began.
mission_completeMissionokINFOAll waypoints completed successfully.
mission_abortedMissionwarnWARNINGMission stopped before completion — pilot/ops abort, distinct from a normal finish.
System
flight_mode_changeSystemoknoe.g. LOITER → AUTO.
armed_state_changeSystemoknoArm/disarm transition.
remoteid_system_failureSystemcritCRITICALF3411 operationalStatus == RemoteIDSystemFailure (§5 Appendix B) — the aircraft has stopped broadcasting valid Remote ID.
agent_connectivity_lostSystemwarnWARNINGAircraft agent heartbeat lost — application-level, distinct from a C2 radio link event.
agent_connectivity_restoredSystemoknoAgent heartbeat resumed.
TISB
tisb_connection_lostTISBwarnWARNINGCIFIB connection lost — no TIS-B traffic feed.
tisb_connection_restoredTISBoknoCIFIB connection back.
Appendix B — networkid message format

One concrete example combining the ASTM F3411-aligned standard fields with the FlySafe ext block, per §5's field breakdown. Topic: v1/output/aircraft/SIMTL70/networkid.

{
  "uasId": "1596F8B0C2A1E4",
  "uasIdType": "SERIAL_NUMBER",
  "uaType": "HELICOPTER_MULTIROTOR",
  "operationalStatus": "AIRBORNE",
  "latitude": 53.5461,
  "longitude": -113.4938,
  "geodeticAltitude": 723.4,
  "height": 91.5,
  "heightType": "AGL",
  "horizontalAccuracy": "HA_10M",
  "verticalAccuracy": "VA_10M",
  "speedAccuracy": "SA_3MPS",
  "speed": 12.3,
  "direction": 274,
  "verticalSpeed": 0.4,
  "timestamp": "2026-07-23T14:02:11.500Z",
  "timestampAccuracy": "TA_0_1S",
  "selfIdText": "Pipeline inspection survey",
  "operatorId": "OP-AB-2024-00341",
  "operatorIdType": "CAA_REGISTRATION",
  "operatorLatitude": 53.5502,
  "operatorLongitude": -113.4901,
  "operatorAltitude": 668.0,
  "areaCount": 1,
  "areaRadius": 150,
  "areaCeiling": 120,
  "areaFloor": 0,
  "uaClassification": null,
  "ext": {
    "pilotFullName": "Jordan Ellis",
    "callsign": "RTM-70",
    "missionName": "Pipeline Survey North",
    "missionLocation": "Fort Saskatchewan, AB",
    "waypoints": [
      { "seq": 1, "lat": 53.5461, "lng": -113.4938, "altM": 120 },
      { "seq": 2, "lat": 53.5489, "lng": -113.4870, "altM": 120 }
    ],
    "trackOrigin": "utm-network-rid"
  }
}
Everything above ext is the ASTM F3411 Network Remote ID portion — a UTM/USS integration should need nothing past uaClassification. Everything inside ext is FlySafe-only and safe to ignore for compliance purposes. See §5 for the field-by-field breakdown and OI-12 for the confirmation this still needs against the exact F3411 edition in use.