from datetime import datetime, timezone from sqlalchemy.orm import Session from backend.database import get_db_session from backend.models import Emitter, RosterUnit, IgnoredUnit def ensure_utc(dt): if dt is None: return None if dt.tzinfo is None: return dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) def format_age(last_seen): if not last_seen: return "N/A" last_seen = ensure_utc(last_seen) now = datetime.now(timezone.utc) diff = now - last_seen hours = diff.total_seconds() // 3600 mins = (diff.total_seconds() % 3600) // 60 return f"{int(hours)}h {int(mins)}m" def calculate_status(last_seen, status_ok_threshold=12, status_pending_threshold=24): """ Calculate status based on how long ago the unit was last seen. Args: last_seen: datetime of last seen (UTC) status_ok_threshold: hours before status becomes Pending (default 12) status_pending_threshold: hours before status becomes Missing (default 24) Returns: "OK", "Pending", or "Missing" """ if not last_seen: return "Missing" last_seen = ensure_utc(last_seen) now = datetime.now(timezone.utc) hours_ago = (now - last_seen).total_seconds() / 3600 if hours_ago > status_pending_threshold: return "Missing" elif hours_ago > status_ok_threshold: return "Pending" else: return "OK" def emit_status_snapshot(): """ Merge roster (what we *intend*) with emitter data (what is *actually happening*). Status is recalculated based on current time to ensure accuracy. """ db = get_db_session() try: # Get user preferences for status thresholds from backend.models import UserPreferences prefs = db.query(UserPreferences).filter_by(id=1).first() status_ok_threshold = prefs.status_ok_threshold_hours if prefs else 12 status_pending_threshold = prefs.status_pending_threshold_hours if prefs else 24 roster = {r.id: r for r in db.query(RosterUnit).all()} emitters = {e.id: e for e in db.query(Emitter).all()} ignored = {i.id for i in db.query(IgnoredUnit).all()} units = {} # --- Merge roster entries first --- for unit_id, r in roster.items(): e = emitters.get(unit_id) if r.retired: # Retired units get separated later status = "Retired" age = "N/A" last_seen = None fname = "" else: if e: last_seen = ensure_utc(e.last_seen) # RECALCULATE status based on current time, not stored value status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold) age = format_age(last_seen) fname = e.last_file else: # Rostered but no emitter data status = "Missing" last_seen = None age = "N/A" fname = "" units[unit_id] = { "id": unit_id, "status": status, "age": age, "last": last_seen.isoformat() if last_seen else None, "fname": fname, "deployed": r.deployed, "note": r.note or "", "retired": r.retired, # Device type and type-specific fields "device_type": r.device_type or "seismograph", "last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None, "next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None, "deployed_with_modem_id": r.deployed_with_modem_id, "ip_address": r.ip_address, "phone_number": r.phone_number, "hardware_model": r.hardware_model, # Location for mapping "location": r.location or "", "address": r.address or "", "coordinates": r.coordinates or "", } # --- Add unexpected emitter-only units --- for unit_id, e in emitters.items(): if unit_id not in roster: last_seen = ensure_utc(e.last_seen) # RECALCULATE status for unknown units too status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold) units[unit_id] = { "id": unit_id, "status": status, "age": format_age(last_seen), "last": last_seen.isoformat(), "fname": e.last_file, "deployed": False, # default "note": "", "retired": False, # Device type and type-specific fields (defaults for unknown units) "device_type": "seismograph", # default "last_calibrated": None, "next_calibration_due": None, "deployed_with_modem_id": None, "ip_address": None, "phone_number": None, "hardware_model": None, # Location fields "location": "", "address": "", "coordinates": "", } # --- Derive modem status from paired devices --- # Modems don't have their own check-in system, so we inherit status # from whatever device they're paired with (seismograph or SLM) for unit_id, unit_data in units.items(): if unit_data.get("device_type") == "modem" and not unit_data.get("retired"): roster_unit = roster.get(unit_id) if roster_unit and roster_unit.deployed_with_unit_id: paired_unit_id = roster_unit.deployed_with_unit_id paired_unit = units.get(paired_unit_id) if paired_unit: # Inherit status from paired device unit_data["status"] = paired_unit.get("status", "Missing") unit_data["age"] = paired_unit.get("age", "N/A") unit_data["last"] = paired_unit.get("last") unit_data["derived_from"] = paired_unit_id # Separate buckets for UI active_units = { uid: u for uid, u in units.items() if not u["retired"] and u["deployed"] and uid not in ignored } benched_units = { uid: u for uid, u in units.items() if not u["retired"] and not u["deployed"] and uid not in ignored } retired_units = { uid: u for uid, u in units.items() if u["retired"] } # Unknown units - emitters that aren't in the roster and aren't ignored unknown_units = { uid: u for uid, u in units.items() if uid not in roster and uid not in ignored } return { "timestamp": datetime.utcnow().isoformat(), "units": units, "active": active_units, "benched": benched_units, "retired": retired_units, "unknown": unknown_units, "summary": { "total": len(active_units) + len(benched_units), "active": len(active_units), "benched": len(benched_units), "retired": len(retired_units), "unknown": len(unknown_units), # Status counts only for deployed units (active_units) "ok": sum(1 for u in active_units.values() if u["status"] == "OK"), "pending": sum(1 for u in active_units.values() if u["status"] == "Pending"), "missing": sum(1 for u in active_units.values() if u["status"] == "Missing"), } } finally: db.close()