Drive format
The open upload format MeshSonar uses, and the endpoints that accept it. Any server can implement it.
The upload format for MeshSonar, the open-source MeshCore wardriving app, and the matching endpoints on MeshSonar regional sites (for example https://<region>.meshsonar.com). Any server can accept it; the regional site's implementation is src/lib/drive/ (validator: validate.ts, endpoints: handlers.ts).
The same JSON is what MeshSonar's Export writes on the phone, so a saved drive file can be uploaded later unchanged.
Principles
- Never guess. A value the phone or radio couldn't measure is
null. Out-of-range values are rejected, not clamped. - "Heard nothing" is data. A
txping with"echoes": []means pinged here, no repeater answered. - Retries are harmless. Every ping carries a UUID made on the phone. Re-sending a ping the server already has is counted as a duplicate and stored once.
- Nothing secret in the app. Each driver gets their own key; the server stores only its SHA-256.
Upload body
{
"format": "meshsonar.drive.v1", // required: "meshsonar.drive.v1" or "meshsonar.drive.v1.1"
"app": { "name": "MeshSonar", "version": "0.1.0", "platform": "ios" },
"session": {
"id": "uuid", // required; made on the phone when the drive starts
"started_at": "2026-10-15T21:00:00Z", // required, ISO 8601
"ended_at": null, // null while driving; must not be before started_at
"label": "Westerville loop", // optional, ≤ 80 chars
"channel": "#wardriving", // channel the pings were sent on, ≤ 40 chars
"radio": { "freq_mhz": 910.525, "bw_khz": 62.5, "sf": 7, "cr": 5, "tx_dbm": 27.8 },
"antenna": { "type": "external", "gain_dbi": 5.8, "height_m": 1.8 },
"companion": { "public_key": "hex", "name": "KE4BFI-v4", "firmware": "1.9" }
},
"pings": [ // 0 to 5,000 per request; send more in further requests
{
"id": "uuid", // required
"ts": "2026-10-15T21:02:11.384Z", // required, ISO 8601; not more than a day in the future
"lat": 40.128091, "lon": -82.977705, // required (0,0 rejected)
"gps_accuracy_m": 4.0, "speed_mps": 13.2, // optional
"kind": "tx", // "tx" (we pinged) or "rx" (overheard); default "tx"
"noise_floor_dbm": -94, // optional
"echoes": [ // required list; [] = heard nothing; ≤ 64
{ "repeater": "df0136", "snr_db": 12.75, "rssi_dbm": -71, "hops": 1, "path": ["df0136"], "delay_ms": 840 }
],
"neighbor": null, // two-way exchange when the firmware reports one:
// { "repeater", "local_snr_db", "local_rssi_dbm", "remote_snr_db" }
"pkt_hash": "94ac75928507bfbd", // v1.1, optional: MeshCore packet hash of the sent packet
"tx_epoch_s": 1792259274, // v1.1, optional: the epoch second inside the sent packet
"tx_dbm": 22 // v1.1, optional: radio TX power for this ping
}
]
}Field rules
| Field | Rule |
|---|---|
repeater, path[], companion.public_key, neighbor.repeater | Hex key or prefix, whole bytes (even length, 2–64 chars). 0x and case are normalised. |
snr_db, local_snr_db, remote_snr_db | −40 … +40 dB |
rssi_dbm, local_rssi_dbm, noise_floor_dbm | −160 … 0 dBm |
hops | Integer 0 … 64. 1 = the repeater was heard directly; only those measure a phone↔repeater link. |
path | List of ≤ 64 hex keys, or null |
delay_ms | Integer 0 … 600,000 |
gps_accuracy_m | 0 … 10,000 |
speed_mps | 0 … 150 |
radio.freq_mhz / bw_khz / sf / cr / tx_dbm | 100–3000 / 1–1000 / integer 5–12 / integer 5–8 / −10…40 |
antenna.gain_dbi / height_m | −10…20 / 0…300 |
| Text fields | Control characters removed; trimmed; capped as noted |
pkt_hash (v1.1) | 16 hex chars (8 bytes); 0x and case normalised. MeshCore Packet::calculatePacketHash: SHA-256(payload type byte ‖ payload), first 8 bytes (TRACE also hashes path_len); route bits, transport codes and path are not hashed, so every repeat shares it. The key the observer join uses. |
tx_epoch_s (v1.1) | Integer 1,420,070,400 … 4,294,967,295: the timestamp inside the sent packet (MeshSonar keeps it strictly increasing, so it can be ts's second + 1) |
tx_dbm (v1.1) | −10 … 40; the power can change mid-drive, unlike session.radio.tx_dbm |
v1.1 adds only the three optional ping fields above. Unknown fields are ignored, so a client may send them under the meshsonar.drive.v1 id (MeshSonar 0.6.0 does, so it works with servers that predate v1.1); a malformed v1.1 value rejects that ping like any other bad field. Validated values are passed to meshsonar_insert_pings with the ping; storing them (drive_pings.pkt_hash, tx_epoch_s, ping_hashes) is the M2 observer-ingest migration's job.
A ping with any invalid echo is rejected whole. Dropping only the bad echo could turn a ping that heard something into a false "heard nothing".
Problems with the whole upload (wrong format, bad session, more than 5,000 pings) fail the request with HTTP 400. Problems with individual pings don't: good pings are stored and the bad ones are listed.
Endpoints (regional site: https://<host>/api/cmh/<name>)
| Endpoint | Auth | Request | Response |
|---|---|---|---|
driver-request | none | POST {name, callsign?, email?} | 201 {driverId, key, status, display} — the key is shown once; store it on the device |
driver-status | x-driver-key | GET | {status, display} |
drive-upload | x-driver-key (approved) | POST upload body | {sessionId, stored, duplicate, rejected, rejections[≤20]: {index, id, reason}} |
drive-repeaters | x-driver-key (approved) | GET | {repeaters: [{key, name, lat, lon}], at} — for offline repeater names |
driver-admin | x-admin-key | GET | {mode, drivers[]} |
POST {action: "approve" | "reject" | "revoke", driverId} | {driverId, status} | ||
POST {action: "mode", mode: "approval" | "open"} | {mode} | ||
drive-data | Site access code (x-access-token) | GET | {sessions[≤500], cells[], totals, cell} — community view, see below |
Driver flow
- MeshSonar calls
driver-requestwith the driver's name (and callsign/email if given) and stores the returned key. - Approval mode (current): status is
pendinguntil the admin approves it. MeshSonar pollsdriver-status. Open mode: status isapprovedimmediately. - Approved drivers upload. Revoked, banned or rejected keys get
401 {error, status}ondrive-uploadanddrive-repeaters;driver-statusis the one exception and answers200 {status, display}for them (so the app can say why). Unknown keys get401.
Status changes: pending → approved | rejected, approved → revoked, rejected | revoked → approved.
Status codes
| Code | Meaning |
|---|---|
| 400 | Upload-level problem; error says what |
| 401 | Missing or unknown driver key / admin key |
| 403 | Driver key exists but isn't approved (status included) |
| 409 | Session id belongs to another driver, or a status change that isn't allowed |
| 429 | Too many key requests waiting for approval |
Display names and privacy
The public handle (display) is the callsign, or "Driver N" without one. Real names and emails are visible to the admin only. Public views hide the first and last 500 m of each drive (the driver's trim_m) and any privacy zones the driver sets; the model still uses those points, and a driver's own export is never trimmed.
drive-data (community view)
Source: src/lib/drive/drive-read.ts (TypeScript, used by the dev mock and tests) and public.meshsonar_drive_read (drizzle/migrations/0004_drive_read.sql, used in production). The two give identical output.
sessions: newest first, at most 500 —{id, display, startedAt, endedAt, label, pings, heard, heardPct, antennaType, txDbm}.displayis the public handle, or"Anonymous"when the driver opted out.pings/heardcounttxpings.cells: ~220 m squares (0.002° lat × 0.0025° lon) —{lat, lon (centre), pings, heard, bestSnrDb, repeaters[≤5]}. Onlytxpings;bestSnrDbandrepeaters(most pings first) use direct echoes only (hops1 or null). Left out per session: pings withintrim_mof the start or end, measured along the path and also in a straight line from the first/last point (so GPS jitter while parked can't reveal a driveway), and pings inside any privacy zone.totals:{sessions, drivers, pings, heard}over every visible session.- Only approved or revoked drivers are included. Raw tracks are never returned.
Account and public endpoints
All at https://<host>/api/cmh/<name>. Errors are {error} with 400, 401, 403, 404, 409 or 429 (with Retry-After).
| Endpoint | Auth | Request and response |
|---|---|---|
site-config | none | GET → {viewerGate, driverRegistration, appLinks} |
public-coverage | none | GET [?since=ISO] → {cellDeg, cells[], totals, asOf}: measured squares, drive ends trimmed, flagged drives excluded |
public-stats | none | GET → {drivers, drives, pings, heardPct, km, repeatersHeard, lastDriveAt} |
leaderboard | none | GET ?period=30d|all → {period, rows[]}: opt-in drivers, callsign only |
me-driver-key | x-user-jwt (signed in) | POST {callsign?} → {driver, key}: a new driver key linked to your account, shown once |
me-driver-revoke | x-user-jwt (signed in) | POST {driverId} → {ok: true} |
me-drives | x-user-jwt (signed in) | GET → {drives[]}: your own drives, untrimmed |