● Zion Boggan
repos/Sightline/backend/app/services/flock_connector.py
zionboggan.com ↗
145 lines · python
History for this file →
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}