| 1 | """Live ingestion daemon. Continuously feeds fresh plate reads into Sightline through the same |
| 2 | normalize/dedup/watchlist pipeline as everything else, so the platform shows genuine real-time |
| 3 | activity. Reads carry the current timestamp, hit random cameras nationwide, reuse existing |
| 4 | plates (so vehicle histories grow) with occasional new plates and hotlist hits. |
| 5 | |
| 6 | Run detached: python live_feed.py |
| 7 | Stops on SIGTERM/SIGINT. |
| 8 | """ |
| 9 | from __future__ import annotations |
| 10 | |
| 11 | import random |
| 12 | import signal |
| 13 | import time |
| 14 | from datetime import datetime, timezone |
| 15 | |
| 16 | from sqlalchemy import select |
| 17 | |
| 18 | from app.db.session import SessionLocal |
| 19 | from app.models import LicensePlate, Source |
| 20 | from app.services.ingest import ingest_live_reads |
| 21 | from generate_data import CAMERAS |
| 22 | |
| 23 | INTERVAL_SECONDS = 4 |
| 24 | BATCH_MIN, BATCH_MAX = 2, 7 |
| 25 | |
| 26 | _running = True |
| 27 | |
| 28 | |
| 29 | def _stop(*_): |
| 30 | global _running |
| 31 | _running = False |
| 32 | |
| 33 | |
| 34 | def main(): |
| 35 | signal.signal(signal.SIGTERM, _stop) |
| 36 | signal.signal(signal.SIGINT, _stop) |
| 37 | db = SessionLocal() |
| 38 | source = db.scalar(select(Source).order_by(Source.created_at)) |
| 39 | src_id = source.id if source else None |
| 40 | |
| 41 | pool = [(p.plate_norm, p.state) for p in |
| 42 | db.scalars(select(LicensePlate).limit(400)).all()] |
| 43 | if not pool: |
| 44 | pool = [("LIVE001", "TN")] |
| 45 | print(f"live feed started: source={source.name if source else None}, pool={len(pool)} plates, " |
| 46 | f"{len(CAMERAS)} cameras, every {INTERVAL_SECONDS}s", flush=True) |
| 47 | |
| 48 | total = 0 |
| 49 | while _running: |
| 50 | now = datetime.now(timezone.utc) |
| 51 | batch = [] |
| 52 | for _ in range(random.randint(BATCH_MIN, BATCH_MAX)): |
| 53 | cam = random.choice(CAMERAS) |
| 54 | if random.random() < 0.05: |
| 55 | plate, state = "BNDT447", "TN" |
| 56 | hotlist = "true" |
| 57 | elif random.random() < 0.15: |
| 58 | plate = "".join(random.choices("ABCDEFGHJKLMNPQRSTUVWXYZ", k=3)) + \ |
| 59 | "".join(random.choices("0123456789", k=4)) |
| 60 | state = random.choice([s for s in {c[4].split(", ")[1] for c in CAMERAS}]) |
| 61 | hotlist = "false" |
| 62 | else: |
| 63 | plate, state = random.choice(pool) |
| 64 | hotlist = "false" |
| 65 | batch.append({ |
| 66 | "plate": plate, "state": state, |
| 67 | "timestamp": now.strftime("%Y-%m-%dT%H:%M:%SZ"), |
| 68 | "camera": cam[0], "latitude": cam[2], "longitude": cam[3], |
| 69 | "confidence": random.randint(82, 99), "hotlist": hotlist, |
| 70 | }) |
| 71 | try: |
| 72 | res = ingest_live_reads(db, src_id, batch, label=f"live-{int(now.timestamp())}") |
| 73 | total += res.get("inserted", 0) |
| 74 | except Exception as e: |
| 75 | print(f"live feed error: {e}", flush=True) |
| 76 | db.rollback() |
| 77 | for _ in range(INTERVAL_SECONDS): |
| 78 | if not _running: |
| 79 | break |
| 80 | time.sleep(1) |
| 81 | db.close() |
| 82 | print(f"live feed stopped after {total} reads", flush=True) |
| 83 | |
| 84 | |
| 85 | if __name__ == "__main__": |
| 86 | main() |