MCPTT Mission Critical Push-to-Talk Service sealMCPTT.comMission Critical Push-to-Talk ServiceDeveloper documentation

EchoCAD integration: replacing the WalkieFleet client with the MCPTT event stream

Status: describes the MCPTT event stream as served by the edge from 11/03/26. Read it with events-websocket.md (the protocol: hello, subscribe, snapshot, resume, heartbeats, close codes) and management-rest.md (the configuration API). Where this document and those two differ on the protocol, those two are right; this one is about how to map what EchoCAD does today onto MCPTT.

Audience: the developers of EchoCAD's PTT location service, the service that today holds one WalkieFleet (WF) WebSocket per radio network and turns what arrives into unit records, Redis state and per-agency publishes.

1. Purpose and overview

EchoCAD needs, for every radio and phone of every agency it dispatches:

EchoCAD showsMCPTT event
Who is talking, on which channelfloor.granted, floor.released, floor.idle
Unit online / offline, and the unit listpresence.online, presence.offline (and the snapshot)
Batterypresence.battery (also battery_pct on location.position)
LTE signal and carriernetwork.signal
Wi-Fi and Bluetooth on or offpresence.radios
Selected channel, or scanningpresence.channel
Positionlocation.position
SOS / emergency raised, updated, clearedemergency.started, emergency.updated, emergency.cleared

Everything arrives on one WebSocket per agency per site. There is no login sequence, no initialisation dump and no GPS subscription to place: you authenticate with a token, subscribe to topics, receive a snapshot of current state, then live events.

Every device event carries the device's identity, so you do not need a side table to translate ids before you can use an event:

FieldPresentMeaning
device_idalwaysMCPTT device UUID. Stable for the life of the device record in the agency
imeiwhen the device has a real IMEI15 digits. Radios always have one; phones (in particular iPhones) may not
callsignwhen the device is signed in to a callsignThe talking identity, for example Medic 12
unit_numberwhen the callsign has oneInteger unit number of the callsign within the agency
device_labelwhen the device has a labelThe hardware label an admin gave the device, for example PS35 #4
emailonly for tokens with events:identity, and only when the signed-in person has an emailThe person's email address
user_id, alias_idwhen signed inThe callsign's user UUID (the two are the same value today)

These identity fields are on the payload of every event whose subject is a device: floor.granted, floor.released, presence.*, network.signal, network.tx_report, location.position, emergency.started, emergency.updated. They are not on floor.idle (its subject is a talkgroup) or on emergency.cleared (keep the emergency_id to device mapping from emergency.started). On a talkgroup another agency shares with yours, floor.granted and floor.released for that agency's talker carry only callsign (and the ids); its imei, unit_number, device_label and email belong to the other agency and are not sent to you, so such a talker will not join to your radio table.

Changes 11/03/26

If your adapter already subscribes to presence, it now also receives presence.online, presence.offline and presence.radios, live and in the snapshot. The adapter outline in section 7 handles all three; an adapter written before that should ignore types it does not know, or subscribe to presence.battery, presence.channel and presence.accessory by name. Floor events for another agency's talker on a shared talkgroup now carry only its callsign, as described above.

1.1 Join keys

Unit typeJoin to your radio record byFallback
Radioimei (your agency radio table already holds IMEIs)device_id
Phone with an IMEIimeiemail, then user_id
Phone without an IMEIemail (needs events:identity)user_id
Consolenot a unit; ignore unless you want to show dispatch talk—

WF identified one radio by two base64 ids (the subscriber id and the live session id) and you reconciled them. MCPTT has one device_id per device, a UUID in canonical lowercase form, and it does not change when the device logs in again, changes callsign or changes site. Do not try to convert WF ids into MCPTT ids; they have nothing in common. During a migration where an agency moves from WF to MCPTT, the IMEI is the only key both systems share.

2. Per-agency setup

EchoCAD serves many agencies. In MCPTT each agency is a separate tenant, and an event token belongs to exactly one tenant at exactly one site. So:

  • one event token per agency per site (two sites today: two tokens per agency);
  • optionally one management API client per agency, for the roster and for names (section 2.4).

2.1 Creating the event tokens

An admin of the agency (with write access) does this, once per site:

  1. Sign in at https://loc1.server.mcptt.com/admin/event-tokens (then the same at https://loc2.server.mcptt.com/admin/event-tokens), with the agency selected in the portal banner.
  2. Name the token, for example EchoCAD, and choose an expiry.
  3. Tick these scopes:
ScopeWhy EchoCAD needs it
events:floorWho is talking
events:presenceOnline/offline, battery, selected channel and scan, Wi-Fi/Bluetooth, accessories
events:networkLTE signal and carrier
events:location and locations:readPositions. Both are required; either alone gives no positions
events:emergencySOS and emergency state
events:identityAdds email to device events. Needed to key phones without an IMEI

Optional command scopes, only if EchoCAD will send these commands (section 3.3):

ScopeCommand
emergency:clearClear an emergency from CAD
locations:requestAsk a callsign's devices for a position now
calls:alertSend a call alert ("call me back") to a callsign
  1. Press "Create token" and copy it from the next page. It is shown once (mcptt_evt_ followed by 43 characters). The page also shows the token id (evt_ plus 16 hex digits) and the expiry.

The agency admin hands the token to EchoCAD through whatever secure channel you use for credentials today. A token is a credential for CJI (positions); treat it like the WF network password.

2.2 Expiry and renewal

  • Lifetimes are 7, 30 or 90 days, but at most 30 days for a token with locations:read. EchoCAD's token has it, so plan on renewal at least every 30 days per agency per site.
  • 60 seconds before expiry the server sends {"op":"reauth_required"}. If you hold a new token for the same agency at the same site, send {"op":"reauth","token":"<new>"} and the connection continues ({"op":"reauthed"}). Otherwise the connection closes with 4401 at expiry.
  • You can also simply reconnect with the new token and your saved cursor; the cursor stays valid because it is the same site.
  • Store the expiry with the token and alert the agency well ahead (for example 7 days). There is no API to create or renew event tokens; an agency admin does it in the portal.
  • Revoking a token in the portal closes its connections within 20 seconds (close code 4401).

2.3 Where to keep the tokens

Keep them in a registry shaped like the one you already use for radio networks, one row per agency per site:

ColumnNotes
agency id (yours)Which EchoCAD agency the events belong to
siteloc1 or loc2
stream URLwss://events.loc1.server.mcptt.com/v1/stream or wss://events.loc2.server.mcptt.com/v1/stream
MCPTT tenant idFrom the welcome frame on first connect; check it on every later connect
token (encrypted at rest), token id, expires atRead only by the PTT location service; never logged or served over HTTP
granted scopesWhat the admin ticked, so you can explain denied topics
activeYour switch to connect or not
last connected at, last errorWritten back by the service, as you do for WF today
last seqOptional: the resume cursor, so a service restart can resume instead of re-snapshotting (section 3.2)

The connection key changes from host, port, login and password to URL and token. Reconcile on reload as you do today: a changed token or URL reconnects that row only; an unchanged row keeps its socket. Note that a token reauth on a live connection does not need a reconnect.

2.4 Management API client (optional)

Agency admins can create a management API client themselves in the portal at /admin/api-clients (client id and secret, shown once). Exchange them at https://loc1.server.mcptt.com/oauth/token for a 15-minute bearer token. Scopes worth granting to EchoCAD: devices:read (devices with IMEI, label and callsign), users:read (callsigns with name and unit number), talkgroups:read (talkgroup names and numbers), and users:lock only if you want the callsign lock described in section 5.1. See management-rest.md.

You need it only for the full roster including devices that have not connected since the last edge restart (section 3.1, DATAEX 11 row), and for periodic name reconciliation. Everything else is on the stream.

2.5 Limits that matter for a multi-agency client

  • 8 open connections per token; 256 authenticated connections per site in total, across all integrators. EchoCAD uses one per agency per site; tell us before you pass about 100 agencies on one site.
  • Commands: 60 a minute per connection, 600 a minute per agency at a site.
  • A connection more than 5,000 events behind is closed (4429) with a resume cursor. Do not block the socket's reader on database or Redis writes; queue them.

3. Connection

3.1 Both sites at once

Radios and phones connect to both sites at the same time. Each site publishes what its own core sees. Connect every agency to both sites, each with its own token, its own cursor and its own state, and merge:

EventMerge rule
floor.granted, floor.releasedDe-duplicate by correlation.transmission_id plus type. The same burst can be reported by both sites
floor.idlePer talkgroup_id, keep the newest occurred_at. Ignore an idle older than the last grant you applied for that talkgroup
emergency.*De-duplicate by payload.emergency_id. An emergency.cleared with merged_into means this record was folded into merged_into: move your state to that id, do not show the emergency as cleared
presence.online, presence.offlineKeep the state per site per device. The unit is online if it is online at any site whose stream you currently hold. It is offline only when every connected site says offline
presence.battery, presence.channel, presence.radios, network.signalPer device, keep the newest occurred_at from either site
location.positionPer device, keep the newest fix_at. The same fix from both sites has the same fix_at; apply it once

Never de-duplicate by envelope id or seq across sites. Both are per site. Within one site, id is stable across replays, so use it to drop a replayed event you already applied.

If you hold only one site for an agency (the other stream is down), use that site's state alone and mark the agency's feed as degraded. Do not let absence at a site you are not connected to make units offline.

3.2 Reconnect, resume and the snapshot

  • Keep, per agency per site, the highest seq you processed. seq can exceed 2^53: keep it as a string or BigInt, not a JS Number. The example client in events-websocket.md section 9.1 shows how to read it from the raw text.
  • On reconnect send it as resume_from_seq in hello, then the same subscribe with last_value: true.
    • If the site still holds it: you get the missed events in order, then caught_up. No snapshot. Your state stays valid.
    • If not (too old, edge restarted, cursor from the other site): you get system.resync_required, then the snapshot, then caught_up. Throw away that site's state for the agency and rebuild it from the snapshot.
  • Without a cursor (first start), subscribe with last_value: true and take the snapshot as the state.
  • The snapshot is the current state, not new occurrences. Do not raise an SOS klaxon, open a usage-stats key-up or play a sound for a snapshot event unless you had no state for it (an emergency.started in the snapshot that you did not know about is a live emergency and must alert).
  • The snapshot holds the latest of: floor.granted or floor.idle per talkgroup; presence.online or presence.offline, presence.battery, presence.channel, presence.radios, network.signal, location.position per device; presence.accessory per device and accessory; and every active emergency (its emergency.started, and emergency.updated events).
  • The snapshot has no end marker. It is over at the first frame that is not snapshot (a live event, a heartbeat or caught_up). Treat "snapshot over" as the equivalent of WF END_INITIALIZE.
  • Answer every {"op":"heartbeat"} (every 20 s) with {"op":"heartbeat_ack"}. Two unanswered heartbeats close the connection (4408). WebSocket ping frames do not count.
  • Back off on reconnect (1, 2, 4 ... 30 s with jitter). On 4401 (bad, revoked or expired token), 4400 or 1003, stop reconnecting and write the error to the registry row, as you do for a WF login failure.

3.3 Commands

Commands go on the same connection as a command frame; the answer is a command_result with your command_id. Format and errors: events-websocket.md section 6.

CommandPayloadNotes
emergency.clearemergency_id, reason (at least 10 characters), false_alarm?emergency.cleared follows with cleared_by: "dispatcher"
location.requesttargets: {alias_ids: [...]} (1 to 16 callsign user ids), envelope reason requiredOne position per device, arriving as location.position with request_id. One-shot; there is no periodic mode
call_alert.sendto_alias_id, text? (1 to 64 characters)Delivery to the radio is not reported back

Send a command to the site that answers it; for location.request the result says other_site for a device that is logged in only at the other site, so send it to both sites and merge the results.

4. WF message to MCPTT mapping

WF messageWhat EchoCAD does with it todayMCPTT equivalent
SERVER_CONFIG, DEVICE_CONFIG, CONFIG_SERVER_RESPONSE_ACKHandshake, client identityNone. Open the WebSocket and send hello with the token
LOGIN, LOGIN_RESPONSECredentials check; Response != 0 is a login failurewelcome is success. Close 4401 is the failure (bad, expired or revoked token)
BEGIN_INITIALIZEClear network and online listssystem.resync_required, or the first snapshot frame after a fresh connect: clear that site's state for the agency
END_INITIALIZEReady; subscribe GPS; start heartbeatFirst non-snapshot frame after subscribed (or caught_up). Nothing to subscribe; the heartbeat is server-driven
PING / PONG (client every 5 s)KeepaliveServer heartbeat every 20 s, client answers heartbeat_ack
DATAEX DataType 100 (networks)List of networks on the serverNone. A token is one agency (tenant); welcome.tenant_id names it. The agency name is GET /orgs/{id} on the management API
DATAEX DataType 11 (subscribers: ID, Login = IMEI, Name)IMEI map, configured name (renames), unit listIdentity fields on every device event (imei, callsign, device_label, unit_number, email). The full roster, including devices never online since the edge started, is GET /devices (IMEI, label, callsign_id) and GET /users on the management API
DATAEX DataType 10 (sessions; Operation 0 full list, 1 add, 2 remove)Online list; Parameters carry battery and selected grouppresence.online / presence.offline. The snapshot holds the latest of either per device, so the snapshot is the session list; battery and channel are their own events in the same snapshot
DATAEX DataType 40 (BATTERY_LEVEL, BATTERY_CHARGING, SelectedGroupID, SelectedGroupName, scan)battery and selectedGroup emitspresence.battery (pct, charging) and presence.channel (selected_talkgroup_*, scanning, scan_talkgroups)
DEVICE_ONLINE, DEVICE_OFFLINE, DEVICE_STATEdeviceState emit, online sessions, auto-statuspresence.online / presence.offline (with reason)
PTT_CONTROL Control 4 (request), 10 (start), 5 (sending)Talking on, key-up stats, talking logfloor.granted. There is no separate request or "sending" event; floor.granted is the moment the talker has the channel
PTT_CONTROL Control 6 (stop)Talking off after 1.5 s, key-down statsfloor.released (duration_ms, end_cause), then floor.idle for the talkgroup when it frees
PTT_CONTROL SourceID, SourceName, TargetID, TargetName, CallIDWho, on what, which calldevice_id + identity fields; talkgroup_id, talkgroup_name, talkgroup_number; transmission_id
GPS_DEVICE_REQUEST (Timing 1, Period 15 s)Subscribe every device to 15 s positionsNone needed. Positions arrive when the device reports, at the cadence the agency's device policy sets. For an immediate fix, the location.request command (one-shot)
GPS_RESULT_TO_CLIENT, DATAEX DataType 50gps emitlocation.position
EMERGENCYemergency emit, SOS activation, klaxon, CAD callemergency.started
EMERGENCY_ACKemergencyAck emit, used as the clearemergency.cleared. There is no acknowledge event yet (section 5.2)
DATAEX DataType 11 Operation 3 with LockedLock or unlock a radio from CADNo equivalent on the stream (section 5.1)
10-8 API poll (LTE signal percent, operator name, by IMEI)signal_strength, signal_level, network, network_abbrnetwork.signal, pushed by the device itself (bearer, carrier, mcc_mnc, level, rsrp_dbm ...). The poll is no longer needed for MCPTT units

5. Field-level mapping per internal event

The names on the left are the internal events your current WF client emits to the rest of the service. Each section gives the MCPTT event that feeds it, the field mapping, and an example. Envelopes are trimmed to the fields that matter; the full envelope is in events-websocket.md section 3. Values are illustrative.

Common to all: deviceId becomes payload.device_id; imei / Login becomes payload.imei (absent for some phones: use email or user_id as the key then); unitName / UserName / FromName becomes payload.callsign (fall back to device_label); networkId becomes your agency id plus site, not an MCPTT field.

5.1 battery

Internal fieldMCPTT
deviceIdpayload.device_id
levelpayload.pct (0 to 100)
chargingpayload.charging (boolean)
unitNamepayload.callsign
imeipayload.imei

presence.battery is sent at most once per 5 minutes per device, sooner when charging starts or stops or the level is under 15 %. location.position also carries battery_pct when the device included it; you may use it to refresh the level between battery events.

{"type":"presence.battery","occurred_at":"2026-11-03T15:04:10.120000Z","source":"mcptt-edge@site1",
 "subject":{"device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf","user_id":"0192f3a0-0000-7000-8000-0000000000b1","alias_id":"0192f3a0-0000-7000-8000-0000000000b1"},
 "payload":{"device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf","pct":14,"charging":false,
  "imei":"356938035643809","callsign":"Medic 12","unit_number":"12","device_label":"PS35 #4","email":"jo.smith@example.gov"}}

5.2 selectedGroup

Internal fieldMCPTT
deviceId, imei, unitNameas above
groupIdpayload.selected_talkgroup_id (UUID)
groupNamepayload.selected_talkgroup_name
(new) group numberpayload.selected_talkgroup_number
scanModepayload.scanning
(new) scan listpayload.scan_talkgroups: [{talkgroup_id, name, number}]; the bare ids are also in scan_talkgroup_ids

Difference: WF signalled scan as an all-zero group id with no name, so you set the selected group to empty while scanning. An MCPTT device can be scanning and still have a selected talkgroup (the one it talks on when it keys up). Show both if you can; if your board has one column, keep today's behaviour (scanning true shows "Scan").

{"type":"presence.channel","subject":{"device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf","talkgroup_id":"00000000-0000-0000-0000-000000000101"},
 "payload":{"device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf",
  "selected_talkgroup_id":"00000000-0000-0000-0000-000000000101","selected_talkgroup_name":"Fire Dispatch","selected_talkgroup_number":"1001",
  "scanning":true,"scan_talkgroup_ids":["00000000-0000-0000-0000-000000000102"],
  "scan_talkgroups":[{"talkgroup_id":"00000000-0000-0000-0000-000000000102","name":"Fire TAC 2","number":"1002"}],
  "imei":"356938035643809","callsign":"Medic 12","unit_number":"12","device_label":"PS35 #4"}}

5.3 deviceState

Internal fieldMCPTT
DeviceIDpayload.device_id
State1 for presence.online, 0 for presence.offline (after merging both sites, section 3.1)
UserNamepayload.callsign, else payload.device_label
Loginpayload.imei
(new) kindpayload.device_kind: radio, phone or console
(new) app versionpayload.app_version (online only)
(new) sitepayload.site (online only); on offline, the envelope source names the site
(new) why offlinepayload.reason: logout, link_lost, replaced, server_stop, other

presence.offline at one site with reason: "link_lost" while the device is still online at the other site means it lost one of its two links, not that the unit is off the air. logout is the user signing out. server_stop is a site restarting; the device is normally online at the other site and comes back to this one within seconds, so do not start an out-of-service timer on it alone.

{"type":"presence.online","source":"mcptt-edge@site2","subject":{"device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf","user_id":"0192f3a0-0000-7000-8000-0000000000b1"},
 "payload":{"device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf","user_id":"0192f3a0-0000-7000-8000-0000000000b1","alias_id":"0192f3a0-0000-7000-8000-0000000000b1",
  "device_kind":"radio","app_version":"27.0.90","site":"loc2",
  "imei":"356938035643809","callsign":"Medic 12","unit_number":"12","device_label":"PS35 #4"}}
{"type":"presence.offline","source":"mcptt-edge@site2","subject":{"device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf"},
 "payload":{"device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf","reason":"link_lost",
  "imei":"356938035643809","callsign":"Medic 12","unit_number":"12","device_label":"PS35 #4"}}

5.4 pttControl

Internal fieldMCPTT
sourceIdpayload.device_id
imei, sourceNamepayload.imei, payload.callsign
sourceUserIdpayload.user_id
targetIdpayload.talkgroup_id (on floor.released: subject.talkgroup_id)
targetNamepayload.talkgroup_name
(new) target numberpayload.talkgroup_number
callIdpayload.transmission_id (one per key-up, a UUIDv7)
control 4 / 10floor.granted
control 6floor.released
(new)payload.priority, payload.emergency (an emergency call), payload.preempted_transmission_id
(new)on floor.released: duration_ms, end_cause (release or revoke)

Set talking on at floor.granted. Set it off at floor.released only if the released transmission_id is the one you set talking for; a late release of an earlier burst must not clear a newer key-up. You no longer need the 1.5 s timer to debounce WF stop/start: floor.idle tells you the talkgroup is free, and a preemption arrives as floor.released of the old burst followed by floor.granted with preempted_transmission_id. A floor.granted in the snapshot means that talker holds the channel now.

{"type":"floor.granted","correlation":{"transmission_id":"019a4c2e-1b40-7000-9000-00000000a001"},
 "subject":{"talkgroup_id":"00000000-0000-0000-0000-000000000101","user_id":"0192f3a0-0000-7000-8000-0000000000b1","device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf"},
 "payload":{"transmission_id":"019a4c2e-1b40-7000-9000-00000000a001","talkgroup_id":"00000000-0000-0000-0000-000000000101",
  "talkgroup_name":"Fire Dispatch","talkgroup_number":"1001",
  "alias_id":"0192f3a0-0000-7000-8000-0000000000b1","user_id":"0192f3a0-0000-7000-8000-0000000000b1","device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf",
  "callsign":"Medic 12","unit_number":"12","device_label":"PS35 #4","imei":"356938035643809","priority":2,"emergency":false}}
{"type":"floor.released","correlation":{"transmission_id":"019a4c2e-1b40-7000-9000-00000000a001"},
 "subject":{"talkgroup_id":"00000000-0000-0000-0000-000000000101","device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf"},
 "payload":{"transmission_id":"019a4c2e-1b40-7000-9000-00000000a001","duration_ms":4210,"end_cause":"release",
  "talkgroup_name":"Fire Dispatch","talkgroup_number":"1001","callsign":"Medic 12","imei":"356938035643809"}}
{"type":"floor.idle","subject":{"talkgroup_id":"00000000-0000-0000-0000-000000000101"},
 "payload":{"talkgroup_id":"00000000-0000-0000-0000-000000000101","talkgroup_name":"Fire Dispatch","talkgroup_number":"1001",
  "hang_until":"2026-11-03T15:05:07.623000Z"}}

5.5 gps

Internal fieldMCPTT
DeviceID, IMEI, FromNamepayload.device_id, payload.imei, payload.callsign
Latitude, Longitudepayload.lat, payload.lon (WGS 84 degrees)
Accuracypayload.accuracy_m (metres, optional)
Speedpayload.speed_mps (metres per second, optional; your m/s to mph conversion stays)
Directionpayload.heading_deg (degrees from north, optional)
(new) altitudepayload.altitude_m
fix timepayload.fix_at (also the envelope occurred_at). Use it, not the arrival time, for your fix-freshness logic
(new)payload.source: gnss, network or fused; payload.battery_pct; payload.request_id when the fix answers your location.request

A device sends positions in batches; only the newest fix of a batch is published. Your accuracy gate and minimum-move filter apply unchanged.

{"type":"location.position","occurred_at":"2026-11-03T15:06:00.000000Z",
 "subject":{"device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf","user_id":"0192f3a0-0000-7000-8000-0000000000b1"},
 "payload":{"device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf","user_id":"0192f3a0-0000-7000-8000-0000000000b1",
  "lat":32.9876543,"lon":-86.1234567,"accuracy_m":6,"speed_mps":12.5,"heading_deg":271.5,
  "fix_at":"2026-11-03T15:06:00.000000Z","source":"gnss","battery_pct":81,
  "imei":"356938035643809","callsign":"Medic 12","unit_number":"12","device_label":"PS35 #4"}}

5.6 LTE signal and carrier (today from the 10-8 poll)

Your fieldMCPTT network.signal
signal_level (1 to 5)payload.level is 0 to 4 bars; level + 1 gives your scale. Absent when the device did not report it
signal_strength (percent)No percent. Use rsrp_dbm (LTE/NR), or rssi_dbm on Wi-Fi, if you want a number
network (operator name)payload.carrier
network_abbrDerive from carrier with your existing name rules, or from payload.mcc_mnc (for example 313100 FirstNet, 311480 Verizon, 310260 T-Mobile, 310410 AT&T; keep your own table)
(new)payload.bearer: lte, nr, wifi, none and others; rsrq_db, sinr_db, band

A bearer change is sent at once; other changes at most once a minute per device.

{"type":"network.signal","payload":{"device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf","bearer":"lte","mcc_mnc":"313100","carrier":"FirstNet",
  "level":3,"rsrp_dbm":-98,"rsrq_db":-11,"sinr_db":8,"band":14,"imei":"356938035643809","callsign":"Medic 12"}}

5.7 Wi-Fi and Bluetooth (new; WF had no equivalent)

presence.radios: wifi_enabled?, bluetooth_enabled? (booleans, each absent when not reported), at, plus identity. Reported by the Android radio and phone apps from version 27.0.90. The iOS app does not report it yet, so for iPhones expect no presence.radios at all and show "unknown", not "off".

{"type":"presence.radios","payload":{"device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf","wifi_enabled":false,"bluetooth_enabled":true,
  "at":"2026-11-03T15:02:11.000000Z","imei":"356938035643809","callsign":"Medic 12"}}

5.8 emergency

Internal fieldMCPTT emergency.started
emergencyIdpayload.emergency_id (UUID)
deviceId, imeipayload.device_id, payload.imei
userIdpayload.user_id (the callsign)
userNamepayload.callsign. callsign_unverified: true means the device could not prove the callsign at that moment; show it, flagged
reasonpayload.reason: sos, man_down, lone_worker, duress, external
profileIdpayload.profile_id
(new)payload.silent (do not make the radio's emergency audible; CAD still alerts), payload.auto_call

emergency.updated carries trigger_added (another device or trigger joined the same emergency) or target_seen (an alerted device showed the alert). The emergency belongs to the callsign, not the device: two devices of one callsign in SOS are one emergency with two triggers. Your "flag every record of the callsign" logic fits.

Active emergencies are in the snapshot, so after a reconnect you learn of an emergency that started while you were away. Your SOS path must be idempotent by emergency_id: the same emergency can come from both sites and again in a snapshot.

{"type":"emergency.started","source":"mcptt-edge@site1",
 "subject":{"device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf","user_id":"0192f3a0-0000-7000-8000-0000000000b1"},
 "payload":{"emergency_id":"019a4c30-7aa1-7c00-8000-0000000e0001","alias_id":"0192f3a0-0000-7000-8000-0000000000b1","user_id":"0192f3a0-0000-7000-8000-0000000000b1",
  "device_id":"2f402ba7-25e4-ae16-3199-df5c5269d3bf","callsign":"Medic 12","callsign_unverified":false,"unit_number":"12","device_label":"PS35 #4",
  "imei":"356938035643809","reason":"sos","silent":false,"profile_id":"00000000-0000-0000-0000-0000000e0f01","auto_call":true}}

5.9 emergencyAck

Feed it from emergency.cleared:

Internal fieldMCPTT emergency.cleared
emergencyIdpayload.emergency_id
userName (who acked)payload.cleared_by: self (the user), dispatcher (including clears sent from EchoCAD), admin, others possible; payload.by_user_id when known
deviceId, userIdNot on the event. Look them up from the emergency.started you kept
(new)payload.at, payload.duration_ms, payload.false_alarm

If the payload has merged_into instead, the emergency continues under that id (the two sites had each opened a record for the same emergency). Re-key your state; do not clear.

{"type":"emergency.cleared","payload":{"emergency_id":"019a4c30-7aa1-7c00-8000-0000000e0001","cleared_by":"dispatcher",
  "at":"2026-11-03T15:12:40.000000Z","duration_ms":400120,"false_alarm":false}}
{"type":"emergency.cleared","payload":{"emergency_id":"019a4c30-7aa1-7c00-8000-0000000e0002","merged_into":"019a4c30-7aa1-7c00-8000-0000000e0001","at":"2026-11-03T15:06:02.000000Z"}}

5.10 unitList

Internal fieldMCPTT
units[].IDdevice_id
units[].Namecallsign, else device_label
units[].Loginimei
units[].Type (1 online)presence.online = 1, presence.offline = 0
sourcesession for the stream snapshot; subscriber for the management API roster
fulltrue for a complete snapshot; false for a single live event

Build a full: true session list from every presence.online / presence.offline in a site's snapshot, once the snapshot is over. Devices that have not connected to that site since its edge last started are not in it; for the complete roster use GET /devices on the management API (each item has device_id, imei, kind, label, callsign_id; resolve callsign_id with GET /users for name and unit_number) and emit that as source: "subscriber".

Renames: there is no rename event. A renamed callsign or relabelled device shows its new callsign / device_label on the next event from that device (at the latest its next presence.online, battery or position). If you need renames sooner, re-read the management API roster periodically. Your rule that the CAD's own unit name wins stays as it is.

6. Gaps and differences

6.1 No device lock command

WF let CAD lock a radio (subscriber Locked). The MCPTT stream has no lock or unlock command. What exists instead, on the management API with users:lock: PUT /users/{id}/lock locks a callsign (its devices are signed off and it cannot sign in) and DELETE /users/{id}/lock unlocks it. That locks the callsign, not the radio: a shared radio can still be used under another callsign. It is refused with 409 emergency_active while the callsign is in an emergency unless you send force with a reason. If your lock endpoint must keep working, map it to the callsign lock and say so to the dispatcher, or answer "not supported on MCPTT".

6.2 No emergency acknowledge

There is no emergency.ack command and no emergency.acked event yet. Only clear exists (emergency.clear command, emergency.cleared event). An "Acknowledge" button in EchoCAD can stay local to EchoCAD; the radio is not told that dispatch saw it. Clearing from EchoCAD needs emergency:clear and a reason of at least 10 characters, and it ends the emergency on the radio.

6.3 Positions are pushed by the device, not subscribed

WF delivered a position every 15 s per subscribed device. MCPTT has no position subscription: a device sends fixes on the schedule set by device or agency policy (it can be slower when stationary, to save battery). location.request is a one-shot "locate now", audited and rate-limited (60 commands a minute per connection, 16 callsigns each); do not loop it to imitate a 15 s subscription. If an agency needs a faster cadence, the agency changes its device policy.

6.4 Online and offline are per site

Each site reports its own link to the device. A device is normally online at both. Merge as in section 3.1. A site restart produces presence.offline with reason: "server_stop" for every device at that site, followed by presence.online as they reconnect; the other site is unaffected.

6.5 Other differences

  • No unit-list dump at connect beyond the snapshot; never-seen devices come from the management API (section 5.10).
  • No talking "request" or "sending" phases, only granted and released.
  • Positions, battery and signal are rate-limited at the source (battery 5 min, signal 1 min, positions by policy). The board may change less often than with WF.
  • iOS devices send no presence.radios, and may have no imei.
  • Renames arrive as new identity values on the next event; there is no rename event.
  • email needs events:identity, and is absent for a callsign with no person or a person with no email.
  • Names of people are not in emergency events beyond the callsign and the optional email.
  • Tokens cannot be created by API and expire within 30 days (with positions). Renewal is a portal task for each agency's admin at each site.
  • Private calls (one-to-one) are not reported as floor events; talkgroup traffic is.
  • Replay after a disconnect covers what the site holds in memory (up to 100,000 events per agency). After an edge restart there is no replay, only the snapshot. Usage statistics built from key-up/key-down can miss bursts that happened while you were disconnected from both sites.

7. Adapter outline

The aim is to swap transports with minimal change: a new module that emits the same internal events, with the same shapes, as your WF client. The rest of the service (unit records, Redis, auto-status, SOS, stats) stays. This is a sketch; error handling, persistence and logging are left out. Do not log raw frames: they carry positions and, with events:identity, email addresses (CJI and personal data).

// One instance per agency per site. A merger (below) combines the two sites of an agency.
class McpttSiteClient extends EventEmitter {
  constructor({ agencyId, site, url, token, cursor }) {
    super();
    Object.assign(this, { agencyId, site, url, token, cursor });
    this.inSnapshot = false;
    this.snapshotUnits = [];
    this.backoff = 1000;
  }

  connect() {
    this.ws = new WebSocket(this.url);
    this.ws.on('open', () => {
      let hello = JSON.stringify({ op: 'hello', token: this.token, envelope_version: '1' });
      if (this.cursor) hello = hello.slice(0, -1) + `,"resume_from_seq":${this.cursor}}`; // keep seq as text
      this.ws.send(hello);
    });
    this.ws.on('message', (data) => this.onFrame(data.toString()));
    this.ws.on('close', (code) => this.onClose(code));
    this.ws.on('error', (e) => this.emit('error', e));
  }

  onFrame(text) {
    const msg = JSON.parse(text);
    const seqText = /"seq":(\d+)/.exec(text)?.[1];
    if (msg.op !== 'snapshot' && this.inSnapshot) this.endSnapshot();
    switch (msg.op) {
      case 'welcome':
        this.backoff = 1000;
        this.emit('loginOk', { tenantId: msg.tenant_id });
        this.ws.send(JSON.stringify({ op: 'subscribe', last_value: true,
          topics: ['floor', 'presence', 'network.signal', 'location', 'emergency'] }));
        return;
      case 'snapshot':
        if (!this.inSnapshot) { this.inSnapshot = true; this.snapshotUnits = []; this.emit('resync'); }
        for (const ev of msg.events) this.onEvent(ev, true);
        return;
      case 'heartbeat': this.ws.send('{"op":"heartbeat_ack"}'); return;
      case 'reauth_required': this.emit('reauthRequired'); return;   // caller may call reauth(newToken)
      case 'overflow': this.cursor = /"resume_from_seq":(\d+)/.exec(text)[1]; return;
      case 'caught_up': case 'subscribed': case 'reauthed': case 'command_result': case 'error': return;
    }
    if (msg.envelope_version) {
      if (msg.type === 'system.resync_required') { this.emit('resync'); return; }
      this.onEvent(msg, false);
      this.cursor = seqText;                               // persist per agency per site
    }
  }

  endSnapshot() {
    this.inSnapshot = false;
    this.emit('unitList', { units: this.snapshotUnits, networkId: this.agencyId, source: 'session', full: true });
    this.emit('ready', { site: this.site });               // the END_INITIALIZE moment
  }

  onEvent(ev, fromSnapshot) {
    const p = ev.payload;
    const base = { deviceId: p.device_id, imei: p.imei ?? null, email: p.email ?? null,
                   unitName: p.callsign ?? p.device_label ?? null, networkId: this.agencyId,
                   site: this.site, at: ev.occurred_at, fromSnapshot };
    switch (ev.type) {
      case 'presence.battery':
        this.emit('battery', { ...base, level: p.pct, charging: p.charging }); break;
      case 'presence.channel':
        this.emit('selectedGroup', { ...base, groupId: p.selected_talkgroup_id ?? null,
          groupName: p.selected_talkgroup_name ?? '', scanMode: p.scanning, scanGroups: p.scan_talkgroups ?? [] }); break;
      case 'presence.online': case 'presence.offline': {
        const state = ev.type === 'presence.online' ? 1 : 0;
        if (fromSnapshot) this.snapshotUnits.push({ ID: p.device_id, Name: base.unitName, Login: base.imei, Type: state });
        this.emit('siteDeviceState', { ...base, state, reason: p.reason ?? null }); break;   // merged below
      }
      case 'presence.radios':
        this.emit('radios', { ...base, wifi: p.wifi_enabled, bluetooth: p.bluetooth_enabled }); break;
      case 'network.signal':
        this.emit('signal', { ...base, level: p.level != null ? p.level + 1 : null,
          network: p.carrier ?? null, mccMnc: p.mcc_mnc ?? null, bearer: p.bearer }); break;
      case 'location.position':
        this.emit('gps', { DeviceID: p.device_id, IMEI: base.imei, FromName: base.unitName,
          Latitude: p.lat, Longitude: p.lon, Accuracy: p.accuracy_m, Speed: p.speed_mps,
          Direction: p.heading_deg, fixAt: p.fix_at, networkId: this.agencyId }); break;
      case 'floor.granted': case 'floor.released':
        this.emit('floor', { ...base, type: ev.type, transmissionId: p.transmission_id ?? ev.correlation.transmission_id,
          talkgroupId: p.talkgroup_id ?? ev.subject.talkgroup_id, talkgroupName: p.talkgroup_name ?? null,
          userId: p.user_id ?? ev.subject.user_id }); break;
      case 'emergency.started': case 'emergency.updated': case 'emergency.cleared':
        this.emit('emergencyEvent', { ...base, type: ev.type, payload: p }); break;
    }
  }

  onClose(code) {
    if ([4401, 4400, 1003].includes(code)) {
      this.emit('loginFailed', { responseCode: code });   // write last_error; do not retry
      return;
    }
    this.emit('disconnected', { code });
    setTimeout(() => this.connect(), this.backoff + Math.random() * 1000);
    this.backoff = Math.min(this.backoff * 2, 30000);
  }
}

// One per agency. Owns the cross-site rules of section 3.1 and re-emits the
// internal events the rest of the service already consumes.
class McpttAgencyFeed extends EventEmitter {
  constructor(sites /* [McpttSiteClient, McpttSiteClient] */) {
    super();
    this.online = new Map();       // device_id -> Map(site -> 0|1)
    this.seenFloor = new Set();    // `${transmission_id}:${type}`, expire after a few minutes
    this.emergencies = new Map();  // emergency_id -> started payload
    this.latest = new Map();       // `${kind}:${device_id}` -> occurred_at / fix_at
    for (const s of sites) this.attach(s);
  }

  newer(kind, deviceId, at) {
    const k = `${kind}:${deviceId}`;
    if (this.latest.has(k) && this.latest.get(k) >= at) return false;
    this.latest.set(k, at);
    return true;
  }

  attach(s) {
    for (const name of ['ready', 'unitList', 'loginOk', 'loginFailed', 'disconnected', 'error'])
      s.on(name, (d) => this.emit(name, d));
    s.on('resync', () => { for (const m of this.online.values()) m.delete(s.site); });
    s.on('disconnected', () => { for (const m of this.online.values()) m.delete(s.site); });
    s.on('battery', (d) => this.newer('bat', d.deviceId, d.at) && this.emit('battery', d));
    s.on('selectedGroup', (d) => this.newer('chan', d.deviceId, d.at) && this.emit('selectedGroup', d));
    s.on('gps', (d) => this.newer('gps', d.DeviceID, d.fixAt) && this.emit('gps', d));
    s.on('siteDeviceState', (d) => {
      const m = this.online.get(d.deviceId) ?? new Map();
      m.set(d.site, d.state);
      this.online.set(d.deviceId, m);
      const State = [...m.values()].some((v) => v === 1) ? 1 : 0;
      this.emit('deviceState', { DeviceID: d.deviceId, NetworkID: d.networkId, State, UserName: d.unitName, Login: d.imei });
    });
    s.on('floor', (d) => {
      const key = `${d.transmissionId}:${d.type}`;
      if (this.seenFloor.has(key)) return;
      this.seenFloor.add(key);
      this.emit('pttControl', { sourceId: d.deviceId, imei: d.imei, sourceUserId: d.userId, sourceName: d.unitName,
        targetId: d.talkgroupId, targetName: d.talkgroupName, callId: d.transmissionId,
        control: d.type === 'floor.granted' ? 10 : 6, networkId: d.networkId, fromSnapshot: d.fromSnapshot });
    });
    s.on('emergencyEvent', (d) => {
      const p = d.payload;
      if (d.type === 'emergency.started') {
        if (this.emergencies.has(p.emergency_id)) return;
        this.emergencies.set(p.emergency_id, p);
        this.emit('emergency', { emergencyId: p.emergency_id, deviceId: p.device_id, imei: p.imei ?? null,
          userId: p.user_id, userName: p.callsign, reason: p.reason, profileId: p.profile_id, networkId: d.networkId });
      } else if (d.type === 'emergency.cleared') {
        const started = this.emergencies.get(p.emergency_id);
        if (!started) return;
        this.emergencies.delete(p.emergency_id);
        if (p.merged_into) { this.emergencies.set(p.merged_into, started); return; }  // re-key in your SOS state too
        this.emit('emergencyAck', { emergencyId: p.emergency_id, deviceId: started.device_id,
          userId: started.user_id, userName: p.cleared_by, networkId: d.networkId });
      }
    });
  }
}

Points the sketch leaves to you:

  • The talking timer: with control 6 coming from floor.released, you can drop the 1.5 s delay, or keep it; either works. Clear talking only for the matching callId.
  • Snapshot events are flagged fromSnapshot: skip key-up statistics and the activity log for them.
  • newer() compares RFC 3339 strings, which sort correctly because the server always writes them in UTC with microseconds.
  • signal and radios are new internal events; feed them into the fields the 10-8 poll fills today, and into new Wi-Fi/Bluetooth fields.
  • Your per-network auto-status gate ("the network's client is connected and has delivered a unit list") becomes: at least one site of the agency has finished its snapshot, and a unit counts as absent only when every connected site says it is offline.
  • Lock requests: see section 6.1.

8. Acceptance test checklist

Use a test agency with at least one Android radio, one Android phone and one iPhone, all on app version 27.0.90 or later.

Setup and connection

  • [ ] Agency admin creates a token at both sites with the scopes of section 2.1; both connect and welcome.tenant_id is the same at both.
  • [ ] subscribed.denied is empty. Remove events:identity from a test token and confirm email disappears and nothing else changes.
  • [ ] A wrong or revoked token closes with 4401; the registry row shows the error and the service does not retry in a loop.
  • [ ] Heartbeats are answered; a connection stays up for an hour with no 4408.
  • [ ] Kill the service and restart it within a few minutes with the saved cursors: caught_up arrives and no snapshot is needed. Restart with no cursor: the snapshot rebuilds the board.
  • [ ] Token reauth: with a 7-day test token near expiry (or a fresh one), reauth on a live connection keeps it open.

Units and presence

  • [ ] After the snapshot, every signed-in device of the agency is online on the board, keyed by IMEI for radios and by email for the iPhone.
  • [ ] Turn a radio off: offline at both sites, unit offline on the board. Turn it on: online.
  • [ ] Block one site from the radio (or wait for a site restart): presence.offline at that site only; the unit stays online.
  • [ ] Rename the callsign in the portal: the new name shows on the next event from the device.
  • [ ] Battery: unplug and plug the charger; charging changes within seconds. Let a test device fall under 15 %.
  • [ ] Change channel; turn scan on and off: selected channel name and scan state follow.
  • [ ] Toggle Wi-Fi and Bluetooth on the Android radio and phone: the board follows. The iPhone shows unknown.
  • [ ] LTE carrier and signal level are shown for the radio and phone; a switch to Wi-Fi shows bearer wifi.

Talk

  • [ ] Key up on a talkgroup: the unit shows talking on the right channel name and number within a second; release clears it.
  • [ ] Two quick key-ups in a row: the second is not cleared by the first one's release.
  • [ ] Key up while the EchoCAD service reconnects: after reconnect the talker shows from the snapshot (if still talking) and no duplicate key-up is counted.
  • [ ] Each burst appears once in usage statistics even though both sites report it.

Positions

  • [ ] A moving radio updates its position at the cadence of the agency's policy; fix_at drives freshness.
  • [ ] location.request for one callsign (if enabled) returns a fix with request_id.

Emergencies

  • [ ] SOS on a radio: one alert in EchoCAD (not two, although both sites report it), with callsign, IMEI and position.
  • [ ] Disconnect the service, raise SOS, reconnect: the emergency arrives in the snapshot and alerts.
  • [ ] Clear on the radio (cleared_by: "self") and, if enabled, from EchoCAD (emergency.clear, cleared_by: "dispatcher"): the flag clears on every record of the callsign.
  • [ ] Two devices of one callsign in SOS: one emergency, emergency.updated with trigger_added.

Hygiene

  • [ ] No token, raw frame, position or email appears in EchoCAD logs.
  • [ ] Two agencies on one service instance: no event of one appears on the other's channel.