| 1 | """Connector for a USER-OWNED Flock Safety ALPR camera (a consented device the user |
| 2 | physically owns). Flock units expose no public API, so this supports the realistic local |
| 3 | surfaces of an owned unit: an RTSP stream, a snapshot image (URL/upload), and Flock-style |
| 4 | CSV/JSON export files. Every path funnels into the same normalize/dedup/watchlist pipeline |
| 5 | (ingest_live_reads) as file imports and other live sources. |
| 6 | """ |
| 7 | from __future__ import annotations |
| 8 | |
| 9 | from datetime import datetime, timezone |
| 10 | |
| 11 | from sqlalchemy import select |
| 12 | from sqlalchemy.orm import Session |
| 13 | |
| 14 | from app.models import Camera, Location, Source |
| 15 | from app.services.alpr import recognize_bytes |
| 16 | from app.services.ingest import ingest_live_reads |
| 17 | from app.services.normalize import build_alias_map, normalize_read |
| 18 | from app.services.parsers import get_parser |
| 19 | from app.services.video_alpr import process_stream |
| 20 | |
| 21 | SOURCE_NAME = "Flock (Owned Device)" |
| 22 | SYSTEM_TYPE = "flock_owned" |
| 23 | LIVE_SOURCE = "flock_owned" |
| 24 | |
| 25 | |
| 26 | def _slug(name: str) -> str: |
| 27 | s = "".join(ch if ch.isalnum() else "-" for ch in (name or "").lower()) |
| 28 | while "--" in s: |
| 29 | s = s.replace("--", "-") |
| 30 | return s.strip("-") or "device" |
| 31 | |
| 32 | |
| 33 | def _get_or_create_source(db: Session) -> Source: |
| 34 | src = db.scalar(select(Source).where(Source.system_type == SYSTEM_TYPE)) |
| 35 | if src: |
| 36 | return src |
| 37 | src = Source(name=SOURCE_NAME, system_type=SYSTEM_TYPE, organization="default", |
| 38 | trust_level=1.0) |
| 39 | db.add(src) |
| 40 | db.flush() |
| 41 | return src |
| 42 | |
| 43 | |
| 44 | def register_flock_device(db: Session, name: str, lat: float, lon: float, |
| 45 | snapshot_url: str | None = None) -> Camera: |
| 46 | """Create (or fetch) the owned-Flock Source and a Camera for this physical unit. |
| 47 | Idempotent on (source, external_camera_id) derived from the device name.""" |
| 48 | src = _get_or_create_source(db) |
| 49 | ext_id = f"flock-owned-{_slug(name)}" |
| 50 | cam = db.scalar(select(Camera).where( |
| 51 | Camera.source_id == src.id, Camera.external_camera_id == ext_id)) |
| 52 | if cam: |
| 53 | if snapshot_url: |
| 54 | cam.image_url, cam.has_live_feed = snapshot_url, True |
| 55 | db.flush() |
| 56 | return cam |
| 57 | loc = Location(label=name, lat=lat, lon=lon) |
| 58 | db.add(loc) |
| 59 | db.flush() |
| 60 | cam = Camera(source_id=src.id, external_camera_id=ext_id, name=name, |
| 61 | operator="Flock Safety", live_source=LIVE_SOURCE, |
| 62 | has_live_feed=bool(snapshot_url), image_url=snapshot_url, |
| 63 | location_id=loc.id, |
| 64 | meta={"ownership": "user_owned", "consented": True}) |
| 65 | db.add(cam) |
| 66 | db.flush() |
| 67 | return cam |
| 68 | |
| 69 | |
| 70 | def _camera_loc(db: Session, camera: Camera) -> Location | None: |
| 71 | return db.get(Location, camera.location_id) if camera.location_id else None |
| 72 | |
| 73 | |
| 74 | def _track_reads(db: Session, camera: Camera, plates: list[dict]) -> list[dict]: |
| 75 | """Build ingest_live_reads dicts from aggregated plate tracks / detections, pinned to |
| 76 | this device's identity and coordinates (reads happen where the owned unit is mounted).""" |
| 77 | loc = _camera_loc(db, camera) |
| 78 | now = datetime.now(timezone.utc).isoformat() |
| 79 | return [{"plate": p["plate_norm"], "state": camera.state, "timestamp": now, |
| 80 | "camera": camera.external_camera_id, |
| 81 | "latitude": loc.lat if loc else None, "longitude": loc.lon if loc else None, |
| 82 | "confidence": p["ocr_confidence"]} for p in plates] |
| 83 | |
| 84 | |
| 85 | def ingest_flock_image(db: Session, camera: Camera, image_bytes: bytes, |
| 86 | created_by: str | None = None, min_conf: float = 0.4) -> dict: |
| 87 | """Snapshot path: recognize plates in one frame from the owned unit and create real reads.""" |
| 88 | dets = recognize_bytes(image_bytes, min_conf=min_conf) |
| 89 | plates = [{"plate": d.plate, "plate_norm": d.plate_norm, |
| 90 | "ocr_confidence": round(d.ocr_confidence, 3), |
| 91 | "detection_confidence": round(d.detection_confidence, 3), |
| 92 | "bbox": d.bbox} for d in dets] |
| 93 | if not plates: |
| 94 | return {"recognized": 0, "inserted": 0, "plates": []} |
| 95 | reads = _track_reads(db, camera, plates) |
| 96 | summary = ingest_live_reads(db, camera.source_id, reads, label="flock-owned-image", |
| 97 | created_by=created_by) |
| 98 | return {"recognized": len(plates), **summary, "plates": plates} |
| 99 | |
| 100 | |
| 101 | def ingest_flock_rtsp(db: Session, camera: Camera, rtsp_url: str, seconds: float = 15.0, |
| 102 | created_by: str | None = None, sample_fps: float = 2.0, |
| 103 | min_conf: float = 0.4) -> dict: |
| 104 | """RTSP path: sample the owned unit's stream for a bounded window, aggregate per-plate |
| 105 | tracks, and ingest each distinct plate as one read.""" |
| 106 | result = process_stream(rtsp_url, seconds=seconds, sample_fps=sample_fps, min_conf=min_conf) |
| 107 | if result["plates"]: |
| 108 | reads = _track_reads(db, camera, result["plates"]) |
| 109 | result["ingest"] = ingest_live_reads(db, camera.source_id, reads, |
| 110 | label="flock-owned-rtsp", created_by=created_by) |
| 111 | return result |
| 112 | |
| 113 | |
| 114 | def ingest_flock_export(db: Session, camera: Camera, file_bytes: bytes, filename: str, |
| 115 | created_by: str | None = None, |
| 116 | content_type: str | None = None) -> dict: |
| 117 | """Export path: parse a Flock-style CSV/JSON export, normalize rows, attribute them to |
| 118 | this device, and ingest. Rows keep their own timestamps; coordinates fall back to the |
| 119 | device location when the export omits them.""" |
| 120 | parsed = get_parser(filename, content_type).parse(file_bytes) |
| 121 | alias = build_alias_map(parsed.headers) |
| 122 | loc = _camera_loc(db, camera) |
| 123 | reads: list[dict] = [] |
| 124 | skipped = 0 |
| 125 | for raw in parsed.rows: |
| 126 | try: |
| 127 | n = normalize_read(raw, alias, source_tz=timezone.utc) |
| 128 | except Exception: |
| 129 | n = None |
| 130 | if n is None: |
| 131 | skipped += 1 |
| 132 | continue |
| 133 | reads.append({"plate": n.plate_norm, "state": n.state or camera.state, |
| 134 | "timestamp": n.read_ts.isoformat(), |
| 135 | "camera": camera.external_camera_id, |
| 136 | "latitude": n.lat if n.lat is not None else (loc.lat if loc else None), |
| 137 | "longitude": n.lon if n.lon is not None else (loc.lon if loc else None), |
| 138 | "confidence": n.confidence}) |
| 139 | if not reads: |
| 140 | return {"parsed_rows": len(parsed.rows), "skipped": skipped, "inserted": 0, |
| 141 | "errors": parsed.errors[:50]} |
| 142 | summary = ingest_live_reads(db, camera.source_id, reads, |
| 143 | label=f"flock-owned-export-{_slug(filename)}", |
| 144 | created_by=created_by) |
| 145 | return {"parsed_rows": len(parsed.rows), "skipped": skipped, **summary} |