NOPD’s Drone Dashboard Has an API 85 Flights Are Missing

Posted by

·

In a September 1 post, NOPD announced a public drone dashboard ahead of its Drone as First Responder rollout. Superintendent Kirkpatrick framed it as balance: police smarter with technology, put guardrails in place, be transparent.

I’ve been prying these records loose one request at a time all year — 273 flights with no logged reason for 206 of them in January–April, then better paperwork over the same festivals in April–August. A live dashboard beats a four-month records request.

But a dashboard is not data. You can’t sort it, total it, or check it against anything. That’s the gap between transparency and the look of transparency — and it closes with about a hundred lines of Python.

How it works

The page is a thin frontend over a public ArcGIS Feature Server:

https://services7.arcgis.com/mnhQTdIYDA7UoY2l/arcgis/rest/services/
  bbe82a3b-5931-40c7-b848-f921b8da0373-production/FeatureServer/0/query

Your date picker becomes a SQL where clause on takeoff. No API key, no token, no login — I confirmed it with no session at all. The layer is read-only and caps responses at 2,000 records.

Each record is a polyline: the actual flight path. Fields are flight_id, takeoff, landing, external_id (item number), description, and flight_purpose. The schema also defines user_email, vehicle_serial, and dock_serial — pilot, aircraft, dock — but those return null on the public layer. Blank item numbers are empty strings (one is null), so filter on external_id = '', not IS NULL.

Which means the dashboard contains less than what I already get through records requests. The logs produced under 26-10297 name the approving supervisor. The dashboard has no supervisor field at all. To learn who authorized a flight, you still have to file.

The script

Full source is on GitHub as a gist.

import argparse, json, sys, time
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
import requests
BASE = ("https://services7.arcgis.com/mnhQTdIYDA7UoY2l/arcgis/rest/services/"
"bbe82a3b-5931-40c7-b848-f921b8da0373-production/FeatureServer/0/query")
LOCAL = ZoneInfo("America/Chicago")
PAGE = 1000
DELAY = 0.3
def exceeded(page):
"""True if the server truncated this page.
f=json puts the flag at the top level; f=geojson nests it under "properties".
"""
return bool(page.get("exceededTransferLimit")
or (page.get("properties") or {}).get("exceededTransferLimit"))
def fetch_pages(start_local, end_local, fmt="json", geometry=False, session=None):
"""Yield each raw decoded server response, one per page."""
lo = start_local.replace(tzinfo=LOCAL).astimezone(timezone.utc)
hi = end_local.replace(tzinfo=LOCAL).astimezone(timezone.utc)
where = (f"takeoff >= TIMESTAMP '{lo:%Y-%m-%d %H:%M:%S}' "
f"AND takeoff < TIMESTAMP '{hi:%Y-%m-%d %H:%M:%S}'")
s = session or requests.Session()
offset = 0
while True:
r = s.get(BASE, params={
"where": where,
"outFields": "*",
"returnGeometry": str(geometry).lower(),
"orderByFields": "takeoff ASC,ObjectId ASC",
"f": fmt,
"resultOffset": offset,
"resultRecordCount": PAGE,
}, timeout=60)
r.raise_for_status()
page = r.json()
if isinstance(page, dict) and "error" in page:
raise RuntimeError(page["error"])
yield page
feats = page.get("features", [])
if not feats or not exceeded(page):
break
offset += len(feats)
time.sleep(DELAY)
def parse_when(s):
for f in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
try:
return datetime.strptime(s, f)
except ValueError:
continue
raise argparse.ArgumentTypeError(f"bad datetime: {s!r}")
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("start", type=parse_when, help="local start, e.g. 2026-01-01")
p.add_argument("end", type=parse_when, help="local end (exclusive), e.g. 2026-10-01")
p.add_argument("-o", "--out", help="output file (default: stdout)")
p.add_argument("-g", "--geometry", action="store_true", help="include flight paths")
p.add_argument("--geojson", action="store_true", help="request f=geojson")
p.add_argument("--pages", action="store_true", help="emit raw pages verbatim")
p.add_argument("--ndjson", action="store_true", help="one raw feature per line")
p.add_argument("--indent", type=int, default=2, help="indent; 0 for compact")
args = p.parse_args()
fmt = "geojson" if args.geojson else "json"
pages = list(fetch_pages(args.start, args.end, fmt, args.geometry))
indent = args.indent or None
fh = open(args.out, "w", encoding="utf-8") if args.out else sys.stdout
try:
if args.ndjson:
for page in pages:
for feat in page.get("features", []):
fh.write(json.dumps(feat, ensure_ascii=False) + "\n")
elif args.pages:
json.dump(pages, fh, indent=indent, ensure_ascii=False)
fh.write("\n")
else:
merged = dict(pages[0]) if pages else {}
merged["features"] = [f for pg in pages for f in pg.get("features", [])]
merged.pop("exceededTransferLimit", None)
if isinstance(merged.get("properties"), dict):
props = dict(merged["properties"])
props.pop("exceededTransferLimit", None)
if props:
merged["properties"] = props
else:
merged.pop("properties")
json.dump(merged, fh, indent=indent, ensure_ascii=False)
fh.write("\n")
finally:
if args.out:
fh.close()
n = sum(len(pg.get("features", [])) for pg in pages)
print(f"{n} features across {len(pages)} page(s)", file=sys.stderr)
if __name__ == "__main__":
main()
# everything, as raw JSON
python dump.py 2026-01-01 2027-01-01 -o flights.json

# with flight paths, as GeoJSON — opens in QGIS
python dump.py 2026-01-01 2027-01-01 --geojson -g -o flights.geojson

# one flight per line, for jq
python dump.py 2026-09-01 2026-10-01 --ndjson | jq -r '.attributes.flight_purpose'

Dates are New Orleans local and converted to UTC for you; the end date is exclusive, so 2026-04-01 2026-08-09 is April 1 through August 8. Output timestamps are epoch milliseconds. Leave the sleep in — the point is to check NOPD’s work, not to knock over a public server.

What the data shows

Full pull on September 25: 377 flights, January 13 through September 16.

By stated purpose: Special Event 298 (79%), Scene Reconstruction 28, Training 17, Rescue Response 11, Suspect Search 9, Tactical Operations 9, Other 3, Missing Person Search 2.

Now look at the ratio. Four out of five flights on the department’s own dashboard are “Special Event.” That’s consistent with my April–August finding (68%), now published by NOPD itself: the fleet is not mostly responding to emergencies. It is mostly loitering over crowds.

And the item numbers are still missing. 283 of 377 flights (75%) have no incident ID, and the 94 that do map to just 32 unique incidents. Eight months of aerial surveillance, 32 police matters. Before April 1, exactly 16 of 188 flights carry an item number.

The number that doesn’t add up

April 1 to August 8: the dashboard shows 158 flights. My records request produced 158. Exact match — the system faithfully reproduces the underlying log.

January 1 to April 1: the dashboard shows 188. My records request produced 273.

Eighty-five flights are missing. The dashboard‘s earliest record is January 13, so NOPD flew in the first twelve days of January and the public dashboard doesn’t say so. That doesn’t obviously account for all of it.

There are innocent explanations — incomplete backfill, different definitions of a “flight,” migration losses. But note the direction. The period I already published is complete. The period that’s short 85 records is the one containing the flight logged only PROTEST and the 206 undocumented flights PIB declined to examine.

That’s exactly the question a dashboard should let the public ask, and exactly the one you can’t ask without an export.

One more thing the API exposes that the page doesn’t: the layer’s dataLastEditDate reads September 18, with the most recent flight on September 16. So either NOPD hasn’t flown in nine days, or the dashboard has stopped updating. A dashboard that quietly goes stale is worse than none, because it answers “is this still happening?” with a confident, wrong no.

What this changes

The dashboard beats nothing, the purpose field is now always populated, and publishing flight paths is more than most departments do.

But Chapter 43.5 hasn’t changed. Drones “shall be deployed only for specific public safety missions,” and the first required field in every post-flight report is the reason for the flight. The dashboard meets the letter of that — every flight has a reason. But “Special Event” is a category, not a mission. Without an item number, 75% of these flights can’t be tied to any specific public safety purpose, and the policy doesn’t require one. That’s the gap.

DFR is coming. Flight volume goes up an order of magnitude and drones start launching on calls automatically. The time to require an item number for every flight is before that, not after.

What you can do

Run the script. One command, no credentials. Check my numbers — I’d rather be corrected than right about the 85.

Questions for Mayor Moreno’s office and your council member:

  1. Why does the dashboard show 188 flights for January–April when NOPD’s own records, produced under request 26-10297, show 273?
  2. Why do 75% of flights on the department’s transparency dashboard have no incident number, when Chapter 43.5 limits drones to specific public safety missions?
  3. Will NOPD require an item number for every flight before DFR launches?

Council contacts: council.nola.gov

A dashboard is not accountability. It’s a data source. What you do with it is accountability.

mwollenweber Avatar

About the author

Matthew Wollenweber (@mwollenweber) is a security engineer with over 20 years experience in cybersecurity and software development. Matthew is passionate about analyzing real-world security problems as inspiration to build tools. His day job is security operations, incident response, and tool development. He is a progressive political organizer in New Orleans, a BJJ brown belt, and bulldog rescuer.

Discover more from Insomniac Technologies

Subscribe now to keep reading and get access to the full archive.

Continue reading