""" Calibration Sync Service Pulls device-reported calibration dates from SFM event sidecars and updates RosterUnit.last_calibrated when the device has a newer record than what Terra-View has stored. Conflict rule: events-as-truth, but don't go backwards. - If the newest event's calibration_date == unit.last_calibrated → no-op. - If the last UnitHistory change for last_calibrated is newer than the newest event's timestamp → skip (a manual edit was made after this event landed; manual wins until a fresher event arrives). - Otherwise → write the event's calibration_date, recompute next_calibration_due, and log a UnitHistory row with source='sfm_event'. """ import asyncio import logging import os import threading import time from datetime import datetime, date, timedelta from typing import Optional, Dict, Any, List import httpx import schedule from sqlalchemy.orm import Session from backend.database import SessionLocal from backend.models import RosterUnit, UnitHistory, UserPreferences logger = logging.getLogger(__name__) SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200") def _get_cal_interval(db: Session) -> int: prefs = db.query(UserPreferences).first() if prefs and prefs.calibration_interval_days: return prefs.calibration_interval_days return 365 def _parse_event_ts(value: Any) -> Optional[datetime]: if not value: return None if isinstance(value, datetime): return value.replace(tzinfo=None) if value.tzinfo else value try: s = str(value).replace("Z", "") if "+" in s: s = s.split("+", 1)[0] return datetime.fromisoformat(s) except (ValueError, TypeError): logger.warning(f"Could not parse event timestamp: {value!r}") return None def _parse_cal_date(value: Any) -> Optional[date]: if not value: return None if isinstance(value, date) and not isinstance(value, datetime): return value if isinstance(value, datetime): return value.date() try: return datetime.fromisoformat(str(value)).date() except (ValueError, TypeError): try: return datetime.strptime(str(value), "%Y-%m-%d").date() except (ValueError, TypeError): logger.warning(f"Could not parse calibration_date: {value!r}") return None async def _get_latest_event(client: httpx.AsyncClient, serial: str) -> Optional[Dict[str, Any]]: try: resp = await client.get( f"{SFM_BASE_URL}/db/events", params={"serial": serial, "limit": 1}, ) resp.raise_for_status() data = resp.json() events = data.get("events", []) return events[0] if events else None except (httpx.HTTPError, ValueError) as e: logger.warning(f"Failed to fetch latest event for {serial}: {e}") return None async def _get_event_sidecar(client: httpx.AsyncClient, event_id: str) -> Optional[Dict[str, Any]]: try: resp = await client.get(f"{SFM_BASE_URL}/db/events/{event_id}/sidecar") resp.raise_for_status() return resp.json() except (httpx.HTTPError, ValueError) as e: logger.warning(f"Failed to fetch sidecar for event {event_id}: {e}") return None async def sync_unit_calibration( db: Session, unit: RosterUnit, client: httpx.AsyncClient, ) -> Dict[str, Any]: """Sync calibration for one seismograph unit. Returns a result dict.""" result: Dict[str, Any] = { "unit_id": unit.id, "action": "checked", "old": unit.last_calibrated.isoformat() if unit.last_calibrated else None, "new": None, "event_id": None, } event = await _get_latest_event(client, unit.id) if not event: result["action"] = "no_event" return result sidecar = await _get_event_sidecar(client, event["id"]) if not sidecar: result["action"] = "no_sidecar" return result device = sidecar.get("device") or {} event_cal = _parse_cal_date(device.get("calibration_date")) if not event_cal: result["action"] = "no_cal_in_sidecar" return result result["event_id"] = event["id"] result["new"] = event_cal.isoformat() if unit.last_calibrated == event_cal: result["action"] = "already_in_sync" return result event_ts = _parse_event_ts(event.get("timestamp")) last_change = ( db.query(UnitHistory) .filter( UnitHistory.unit_id == unit.id, UnitHistory.field_name == "last_calibrated", ) .order_by(UnitHistory.changed_at.desc()) .first() ) if last_change and event_ts and last_change.changed_at > event_ts: result["action"] = "skipped_manual_newer" return result old_cal = unit.last_calibrated unit.last_calibrated = event_cal unit.next_calibration_due = event_cal + timedelta(days=_get_cal_interval(db)) db.add(UnitHistory( unit_id=unit.id, change_type="calibration_status_change", field_name="last_calibrated", old_value=old_cal.strftime("%Y-%m-%d") if old_cal else None, new_value=event_cal.strftime("%Y-%m-%d"), source="sfm_event", notes=f"Synced from event {event['id']}", )) result["action"] = "updated" return result async def sync_all_calibrations(db: Optional[Session] = None) -> Dict[str, Any]: """Sync calibration for every non-retired seismograph. If `db` is provided the caller owns the session and commit. Otherwise a session is opened, committed, and closed locally — this is what the scheduled job uses. """ owns_session = db is None if owns_session: db = SessionLocal() summary: Dict[str, Any] = { "started_at": datetime.utcnow().isoformat(), "checked": 0, "updated": 0, "skipped_manual_newer": 0, "already_in_sync": 0, "no_event": 0, "no_sidecar": 0, "no_cal_in_sidecar": 0, "errors": 0, "results": [], } try: units = ( db.query(RosterUnit) .filter( RosterUnit.retired == False, RosterUnit.device_type == "seismograph", ) .all() ) async with httpx.AsyncClient(timeout=15.0) as client: for unit in units: summary["checked"] += 1 try: r = await sync_unit_calibration(db, unit, client) except Exception as e: logger.exception(f"Error syncing calibration for {unit.id}") summary["errors"] += 1 summary["results"].append({"unit_id": unit.id, "action": "error", "error": str(e)}) continue summary["results"].append(r) action = r["action"] if action in summary: summary[action] += 1 if owns_session: db.commit() finally: if owns_session: db.close() summary["finished_at"] = datetime.utcnow().isoformat() logger.info( f"Calibration sync done: checked={summary['checked']} " f"updated={summary['updated']} skipped_manual={summary['skipped_manual_newer']} " f"in_sync={summary['already_in_sync']} errors={summary['errors']}" ) return summary # --------------------------------------------------------------------------- # Background scheduler — runs once daily. Modeled on backup_scheduler. # --------------------------------------------------------------------------- class CalibrationSyncScheduler: """Runs sync_all_calibrations() once per day at a fixed local time.""" def __init__(self, run_at: str = "03:15"): self.run_at = run_at self.is_running = False self.thread: Optional[threading.Thread] = None self.last_run: Optional[Dict[str, Any]] = None def _job_wrapper(self): """Run the async sync in a fresh event loop (we're on a worker thread).""" try: self.last_run = asyncio.run(sync_all_calibrations()) except Exception as e: logger.exception(f"Calibration sync job failed: {e}") self.last_run = {"error": str(e), "finished_at": datetime.utcnow().isoformat()} def start(self): if self.is_running: return logger.info(f"Starting calibration sync scheduler (daily at {self.run_at})") schedule.every().day.at(self.run_at).do(self._job_wrapper) self.is_running = True self.thread = threading.Thread(target=self._loop, daemon=True) self.thread.start() def _loop(self): while self.is_running: schedule.run_pending() time.sleep(60) def stop(self): if not self.is_running: return logger.info("Stopping calibration sync scheduler") self.is_running = False if self.thread: self.thread.join(timeout=5) def status(self) -> Dict[str, Any]: return { "running": self.is_running, "run_at": self.run_at, "last_run": self.last_run, } _scheduler: Optional[CalibrationSyncScheduler] = None def get_calibration_sync_scheduler() -> CalibrationSyncScheduler: global _scheduler if _scheduler is None: _scheduler = CalibrationSyncScheduler() return _scheduler