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 shows | MCPTT event |
|---|---|
| Who is talking, on which channel | floor.granted, floor.released, floor.idle |
| Unit online / offline, and the unit list | presence.online, presence.offline (and the snapshot) |
| Battery | presence.battery (also battery_pct on location.position) |
| LTE signal and carrier | network.signal |
| Wi-Fi and Bluetooth on or off | presence.radios |
| Selected channel, or scanning | presence.channel |
| Position | location.position |
| SOS / emergency raised, updated, cleared | emergency.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:
| Field | Present | Meaning |
|---|---|---|
device_id | always | MCPTT device UUID. Stable for the life of the device record in the agency |
imei | when the device has a real IMEI | 15 digits. Radios always have one; phones (in particular iPhones) may not |
callsign | when the device is signed in to a callsign | The talking identity, for example Medic 12 |
unit_number | when the callsign has one | Integer unit number of the callsign within the agency |
device_label | when the device has a label | The hardware label an admin gave the device, for example PS35 #4 |
email | only for tokens with events:identity, and only when the signed-in person has an email | The person's email address |
user_id, alias_id | when signed in | The 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 type | Join to your radio record by | Fallback |
|---|---|---|
| Radio | imei (your agency radio table already holds IMEIs) | device_id |
| Phone with an IMEI | imei | email, then user_id |
| Phone without an IMEI | email (needs events:identity) | user_id |
| Console | not 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:
- Sign in at
https://loc1.server.mcptt.com/admin/event-tokens(then the same athttps://loc2.server.mcptt.com/admin/event-tokens), with the agency selected in the portal banner. - Name the token, for example
EchoCAD, and choose an expiry. - Tick these scopes:
| Scope | Why EchoCAD needs it |
|---|---|
events:floor | Who is talking |
events:presence | Online/offline, battery, selected channel and scan, Wi-Fi/Bluetooth, accessories |
events:network | LTE signal and carrier |
events:location and locations:read | Positions. Both are required; either alone gives no positions |
events:emergency | SOS and emergency state |
events:identity | Adds email to device events. Needed to key phones without an IMEI |
Optional command scopes, only if EchoCAD will send these commands (section 3.3):
| Scope | Command |
|---|---|
emergency:clear | Clear an emergency from CAD |
locations:request | Ask a callsign's devices for a position now |
calls:alert | Send a call alert ("call me back") to a callsign |
- 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:
| Column | Notes |
|---|---|
| agency id (yours) | Which EchoCAD agency the events belong to |
| site | loc1 or loc2 |
| stream URL | wss://events.loc1.server.mcptt.com/v1/stream or wss://events.loc2.server.mcptt.com/v1/stream |
| MCPTT tenant id | From the welcome frame on first connect; check it on every later connect |
| token (encrypted at rest), token id, expires at | Read only by the PTT location service; never logged or served over HTTP |
| granted scopes | What the admin ticked, so you can explain denied topics |
| active | Your switch to connect or not |
| last connected at, last error | Written back by the service, as you do for WF today |
| last seq | Optional: 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:
| Event | Merge rule |
|---|---|
floor.granted, floor.released | De-duplicate by correlation.transmission_id plus type. The same burst can be reported by both sites |
floor.idle | Per 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.offline | Keep 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.signal | Per device, keep the newest occurred_at from either site |
location.position | Per 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
seqyou processed.seqcan exceed 2^53: keep it as a string or BigInt, not a JSNumber. The example client inevents-websocket.mdsection 9.1 shows how to read it from the raw text. - On reconnect send it as
resume_from_seqinhello, then the samesubscribewithlast_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, thencaught_up. Throw away that site's state for the agency and rebuild it from the snapshot.
- If the site still holds it: you get the missed events in order, then
- Without a cursor (first start), subscribe with
last_value: trueand 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.startedin the snapshot that you did not know about is a live emergency and must alert). - The snapshot holds the latest of:
floor.grantedorfloor.idleper talkgroup;presence.onlineorpresence.offline,presence.battery,presence.channel,presence.radios,network.signal,location.positionper device;presence.accessoryper device and accessory; and every active emergency (itsemergency.started, andemergency.updatedevents). - The snapshot has no end marker. It is over at the first frame that is not
snapshot(a live event, aheartbeatorcaught_up). Treat "snapshot over" as the equivalent of WFEND_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.
| Command | Payload | Notes |
|---|---|---|
emergency.clear | emergency_id, reason (at least 10 characters), false_alarm? | emergency.cleared follows with cleared_by: "dispatcher" |
location.request | targets: {alias_ids: [...]} (1 to 16 callsign user ids), envelope reason required | One position per device, arriving as location.position with request_id. One-shot; there is no periodic mode |
call_alert.send | to_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 message | What EchoCAD does with it today | MCPTT equivalent |
|---|---|---|
SERVER_CONFIG, DEVICE_CONFIG, CONFIG_SERVER_RESPONSE_ACK | Handshake, client identity | None. Open the WebSocket and send hello with the token |
LOGIN, LOGIN_RESPONSE | Credentials check; Response != 0 is a login failure | welcome is success. Close 4401 is the failure (bad, expired or revoked token) |
BEGIN_INITIALIZE | Clear network and online lists | system.resync_required, or the first snapshot frame after a fresh connect: clear that site's state for the agency |
END_INITIALIZE | Ready; subscribe GPS; start heartbeat | First non-snapshot frame after subscribed (or caught_up). Nothing to subscribe; the heartbeat is server-driven |
PING / PONG (client every 5 s) | Keepalive | Server heartbeat every 20 s, client answers heartbeat_ack |
DATAEX DataType 100 (networks) | List of networks on the server | None. 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 list | Identity 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 group | presence.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 emits | presence.battery (pct, charging) and presence.channel (selected_talkgroup_*, scanning, scan_talkgroups) |
DEVICE_ONLINE, DEVICE_OFFLINE, DEVICE_STATE | deviceState emit, online sessions, auto-status | presence.online / presence.offline (with reason) |
PTT_CONTROL Control 4 (request), 10 (start), 5 (sending) | Talking on, key-up stats, talking log | floor.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 stats | floor.released (duration_ms, end_cause), then floor.idle for the talkgroup when it frees |
PTT_CONTROL SourceID, SourceName, TargetID, TargetName, CallID | Who, on what, which call | device_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 positions | None 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 50 | gps emit | location.position |
EMERGENCY | emergency emit, SOS activation, klaxon, CAD call | emergency.started |
EMERGENCY_ACK | emergencyAck emit, used as the clear | emergency.cleared. There is no acknowledge event yet (section 5.2) |
DATAEX DataType 11 Operation 3 with Locked | Lock or unlock a radio from CAD | No equivalent on the stream (section 5.1) |
| 10-8 API poll (LTE signal percent, operator name, by IMEI) | signal_strength, signal_level, network, network_abbr | network.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 field | MCPTT |
|---|---|
deviceId | payload.device_id |
level | payload.pct (0 to 100) |
charging | payload.charging (boolean) |
unitName | payload.callsign |
imei | payload.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 field | MCPTT |
|---|---|
deviceId, imei, unitName | as above |
groupId | payload.selected_talkgroup_id (UUID) |
groupName | payload.selected_talkgroup_name |
| (new) group number | payload.selected_talkgroup_number |
scanMode | payload.scanning |
| (new) scan list | payload.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 field | MCPTT |
|---|---|
DeviceID | payload.device_id |
State | 1 for presence.online, 0 for presence.offline (after merging both sites, section 3.1) |
UserName | payload.callsign, else payload.device_label |
Login | payload.imei |
| (new) kind | payload.device_kind: radio, phone or console |
| (new) app version | payload.app_version (online only) |
| (new) site | payload.site (online only); on offline, the envelope source names the site |
| (new) why offline | payload.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 field | MCPTT |
|---|---|
sourceId | payload.device_id |
imei, sourceName | payload.imei, payload.callsign |
sourceUserId | payload.user_id |
targetId | payload.talkgroup_id (on floor.released: subject.talkgroup_id) |
targetName | payload.talkgroup_name |
| (new) target number | payload.talkgroup_number |
callId | payload.transmission_id (one per key-up, a UUIDv7) |
control 4 / 10 | floor.granted |
control 6 | floor.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 field | MCPTT |
|---|---|
DeviceID, IMEI, FromName | payload.device_id, payload.imei, payload.callsign |
Latitude, Longitude | payload.lat, payload.lon (WGS 84 degrees) |
Accuracy | payload.accuracy_m (metres, optional) |
Speed | payload.speed_mps (metres per second, optional; your m/s to mph conversion stays) |
Direction | payload.heading_deg (degrees from north, optional) |
| (new) altitude | payload.altitude_m |
| fix time | payload.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 field | MCPTT 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_abbr | Derive 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 field | MCPTT emergency.started |
|---|---|
emergencyId | payload.emergency_id (UUID) |
deviceId, imei | payload.device_id, payload.imei |
userId | payload.user_id (the callsign) |
userName | payload.callsign. callsign_unverified: true means the device could not prove the callsign at that moment; show it, flagged |
reason | payload.reason: sos, man_down, lone_worker, duress, external |
profileId | payload.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 field | MCPTT emergency.cleared |
|---|---|
emergencyId | payload.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, userId | Not 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 field | MCPTT |
|---|---|
units[].ID | device_id |
units[].Name | callsign, else device_label |
units[].Login | imei |
units[].Type (1 online) | presence.online = 1, presence.offline = 0 |
source | session for the stream snapshot; subscriber for the management API roster |
full | true 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 noimei. - Renames arrive as new identity values on the next event; there is no rename event.
emailneedsevents: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
control6 coming fromfloor.released, you can drop the 1.5 s delay, or keep it; either works. Clear talking only for the matchingcallId. - 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.signalandradiosare 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_idis the same at both. - [ ]
subscribed.deniedis empty. Removeevents:identityfrom a test token and confirmemaildisappears 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_uparrives 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),
reauthon 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.offlineat 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;
chargingchanges 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_atdrives freshness. - [ ]
location.requestfor one callsign (if enabled) returns a fix withrequest_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.updatedwithtrigger_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.
