from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from pydantic import BaseModel from datetime import datetime from typing import Optional, List from backend.database import get_db from backend.models import Emitter, WatcherAgent router = APIRouter() # Helper function to detect unit type from unit ID def detect_unit_type(unit_id: str) -> str: """ Automatically detect if a unit is Series 3 or Series 4 based on ID pattern. Series 4 (Micromate) units have IDs starting with "UM" followed by digits (e.g., UM11719) Series 3 units typically have other patterns Returns: "series4" if the unit ID matches Micromate pattern (UM#####) "series3" otherwise """ if not unit_id: return "unknown" # Series 4 (Micromate) pattern: UM followed by digits if unit_id.upper().startswith("UM") and len(unit_id) > 2: # Check if remaining characters after "UM" are digits rest = unit_id[2:] if rest.isdigit(): return "series4" # Default to series3 for other patterns return "series3" # Pydantic schemas for request/response validation class EmitterReport(BaseModel): unit: str unit_type: str timestamp: str file: str status: str class EmitterResponse(BaseModel): id: str unit_type: str last_seen: datetime last_file: str status: str notes: Optional[str] = None class Config: from_attributes = True @router.post("/emitters/report", status_code=200) def report_emitter(report: EmitterReport, db: Session = Depends(get_db)): """ Endpoint for emitters to report their status. Creates a new emitter if it doesn't exist, or updates an existing one. """ try: # Parse the timestamp timestamp = datetime.fromisoformat(report.timestamp.replace('Z', '+00:00')) except ValueError: raise HTTPException(status_code=400, detail="Invalid timestamp format") # Check if emitter already exists emitter = db.query(Emitter).filter(Emitter.id == report.unit).first() if emitter: # Update existing emitter emitter.unit_type = report.unit_type emitter.last_seen = timestamp emitter.last_file = report.file emitter.status = report.status else: # Create new emitter emitter = Emitter( id=report.unit, unit_type=report.unit_type, last_seen=timestamp, last_file=report.file, status=report.status ) db.add(emitter) db.commit() db.refresh(emitter) return { "message": "Emitter report received", "unit": emitter.id, "status": emitter.status } @router.get("/fleet/status", response_model=List[EmitterResponse]) def get_fleet_status(db: Session = Depends(get_db)): """ Returns a list of all emitters and their current status. """ emitters = db.query(Emitter).all() return emitters # ── Watcher agent upsert helper ─────────────────────────────────────────────── def _upsert_watcher_agent(db: Session, source_id: str, source_type: str, version: str, ip_address: str, log_tail: str, status: str) -> None: """Create or update the WatcherAgent row for a given source_id.""" agent = db.query(WatcherAgent).filter(WatcherAgent.id == source_id).first() if agent: agent.source_type = source_type agent.version = version agent.last_seen = datetime.utcnow() agent.status = status if ip_address: agent.ip_address = ip_address if log_tail is not None: agent.log_tail = log_tail else: agent = WatcherAgent( id=source_id, source_type=source_type, version=version, last_seen=datetime.utcnow(), status=status, ip_address=ip_address, log_tail=log_tail, ) db.add(agent) # series3v1.1 Standardized Heartbeat Schema (multi-unit) from fastapi import Request @router.post("/api/series3/heartbeat", status_code=200) async def series3_heartbeat(request: Request, db: Session = Depends(get_db)): """ Accepts a full telemetry payload from the Series3 emitter. Updates or inserts each unit into the database. """ payload = await request.json() source = payload.get("source_id") units = payload.get("units", []) version = payload.get("version") log_tail = payload.get("log_tail") # list of strings or None import json as _json log_tail_str = _json.dumps(log_tail) if log_tail is not None else None client_ip = request.client.host if request.client else None print("\n=== Series 3 Heartbeat ===") print("Source:", source) print("Units received:", len(units)) print("==========================\n") results = [] for u in units: uid = u.get("unit_id") last_event_time = u.get("last_event_time") event_meta = u.get("event_metadata", {}) age_minutes = u.get("age_minutes") try: if last_event_time: ts = datetime.fromisoformat(last_event_time.replace("Z", "+00:00")) else: ts = None except: ts = None # Pull from DB emitter = db.query(Emitter).filter(Emitter.id == uid).first() # File name (from event_metadata) last_file = event_meta.get("file_name") status = "Unknown" # Determine status based on age if age_minutes is None: status = "Missing" elif age_minutes > 24 * 60: status = "Missing" elif age_minutes > 12 * 60: status = "Pending" else: status = "OK" if emitter: # Update existing emitter.last_seen = ts emitter.last_file = last_file emitter.status = status # Update unit_type if it was incorrectly classified detected_type = detect_unit_type(uid) if emitter.unit_type != detected_type: emitter.unit_type = detected_type else: # Insert new - auto-detect unit type from ID detected_type = detect_unit_type(uid) emitter = Emitter( id=uid, unit_type=detected_type, last_seen=ts, last_file=last_file, status=status ) db.add(emitter) results.append({"unit": uid, "status": status}) if source: _upsert_watcher_agent(db, source, "series3_watcher", version, client_ip, log_tail_str, "ok") db.commit() # Check if an update has been triggered for this agent update_available = False if source: agent = db.query(WatcherAgent).filter(WatcherAgent.id == source).first() if agent and agent.update_pending: update_available = True agent.update_pending = False db.commit() return { "message": "Heartbeat processed", "source": source, "units_processed": len(results), "results": results, "update_available": update_available, } # series4 (Micromate) Standardized Heartbeat Schema @router.post("/api/series4/heartbeat", status_code=200) async def series4_heartbeat(request: Request, db: Session = Depends(get_db)): """ Accepts a full telemetry payload from the Series4 (Micromate) emitter. Updates or inserts each unit into the database. Expected payload: { "source": "series4_emitter", "generated_at": "2025-12-04T20:01:00", "units": [ { "unit_id": "UM11719", "type": "micromate", "project_hint": "Clearwater - ECMS 57940", "last_call": "2025-12-04T19:30:42", "status": "OK", "age_days": 0.04, "age_hours": 0.9, "mlg_path": "C:\\THORDATA\\..." } ] } """ payload = await request.json() # Accept source_id (new standard field) with fallback to legacy "source" key source = payload.get("source_id") or payload.get("source", "series4_emitter") units = payload.get("units", []) version = payload.get("version") log_tail = payload.get("log_tail") import json as _json log_tail_str = _json.dumps(log_tail) if log_tail is not None else None client_ip = request.client.host if request.client else None print("\n=== Series 4 Heartbeat ===") print("Source:", source) print("Units received:", len(units)) print("==========================\n") results = [] for u in units: uid = u.get("unit_id") last_call_str = u.get("last_call") status = u.get("status", "Unknown") mlg_path = u.get("mlg_path") project_hint = u.get("project_hint") # Parse last_call timestamp try: if last_call_str: ts = datetime.fromisoformat(last_call_str.replace("Z", "+00:00")) else: ts = None except: ts = None # Pull from DB emitter = db.query(Emitter).filter(Emitter.id == uid).first() if emitter: # Update existing emitter.last_seen = ts emitter.last_file = mlg_path emitter.status = status # Update unit_type if it was incorrectly classified detected_type = detect_unit_type(uid) if emitter.unit_type != detected_type: emitter.unit_type = detected_type # Optionally update notes with project hint if it exists if project_hint and not emitter.notes: emitter.notes = f"Project: {project_hint}" else: # Insert new - auto-detect unit type from ID detected_type = detect_unit_type(uid) notes = f"Project: {project_hint}" if project_hint else None emitter = Emitter( id=uid, unit_type=detected_type, last_seen=ts, last_file=mlg_path, status=status, notes=notes ) db.add(emitter) results.append({"unit": uid, "status": status}) if source: _upsert_watcher_agent(db, source, "series4_watcher", version, client_ip, log_tail_str, "ok") db.commit() # Check if an update has been triggered for this agent update_available = False if source: agent = db.query(WatcherAgent).filter(WatcherAgent.id == source).first() if agent and agent.update_pending: update_available = True agent.update_pending = False db.commit() return { "message": "Heartbeat processed", "source": source, "units_processed": len(results), "results": results, "update_available": update_available, }