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 tx ping 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

FieldRule
repeater, path[], companion.public_key, neighbor.repeaterHex 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
hopsInteger 0 … 64. 1 = the repeater was heard directly; only those measure a phone↔repeater link.
pathList of ≤ 64 hex keys, or null
delay_msInteger 0 … 600,000
gps_accuracy_m0 … 10,000
speed_mps0 … 150
radio.freq_mhz / bw_khz / sf / cr / tx_dbm100–3000 / 1–1000 / integer 5–12 / integer 5–8 / −10…40
antenna.gain_dbi / height_m−10…20 / 0…300
Text fieldsControl 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>)

EndpointAuthRequestResponse
driver-requestnonePOST {name, callsign?, email?}201 {driverId, key, status, display} — the key is shown once; store it on the device
driver-statusx-driver-keyGET{status, display}
drive-uploadx-driver-key (approved)POST upload body{sessionId, stored, duplicate, rejected, rejections[≤20]: {index, id, reason}}
drive-repeatersx-driver-key (approved)GET{repeaters: [{key, name, lat, lon}], at} — for offline repeater names
driver-adminx-admin-keyGET{mode, drivers[]}
POST {action: "approve" | "reject" | "revoke", driverId}{driverId, status}
POST {action: "mode", mode: "approval" | "open"}{mode}
drive-dataSite access code (x-access-token)GET{sessions[≤500], cells[], totals, cell} — community view, see below

Driver flow

  1. MeshSonar calls driver-request with the driver's name (and callsign/email if given) and stores the returned key.
  2. Approval mode (current): status is pending until the admin approves it. MeshSonar polls driver-status. Open mode: status is approved immediately.
  3. Approved drivers upload. Revoked, banned or rejected keys get 401 {error, status} on drive-upload and drive-repeaters; driver-status is the one exception and answers 200 {status, display} for them (so the app can say why). Unknown keys get 401.

Status changes: pending → approved | rejected, approved → revoked, rejected | revoked → approved.

Status codes

CodeMeaning
400Upload-level problem; error says what
401Missing or unknown driver key / admin key
403Driver key exists but isn't approved (status included)
409Session id belongs to another driver, or a status change that isn't allowed
429Too 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}. display is the public handle, or "Anonymous" when the driver opted out. pings/heard count tx pings.
  • cells: ~220 m squares (0.002° lat × 0.0025° lon) — {lat, lon (centre), pings, heard, bestSnrDb, repeaters[≤5]}. Only tx pings; bestSnrDb and repeaters (most pings first) use direct echoes only (hops 1 or null). Left out per session: pings within trim_m of 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).

EndpointAuthRequest and response
site-confignoneGET → {viewerGate, driverRegistration, appLinks}
public-coveragenoneGET [?since=ISO] → {cellDeg, cells[], totals, asOf}: measured squares, drive ends trimmed, flagged drives excluded
public-statsnoneGET → {drivers, drives, pings, heardPct, km, repeatersHeard, lastDriveAt}
leaderboardnoneGET ?period=30d|all → {period, rows[]}: opt-in drivers, callsign only
me-driver-keyx-user-jwt (signed in)POST {callsign?} → {driver, key}: a new driver key linked to your account, shown once
me-driver-revokex-user-jwt (signed in)POST {driverId} → {ok: true}
me-drivesx-user-jwt (signed in)GET → {drives[]}: your own drives, untrimmed