""" SLMM Synchronization Service This service ensures Terra-View roster is the single source of truth for SLM device configuration. When SLM devices are added, edited, or deleted in Terra-View, changes are automatically synced to SLMM. """ import logging import httpx import os from typing import Optional from sqlalchemy.orm import Session from backend.models import RosterUnit logger = logging.getLogger(__name__) SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") async def sync_slm_to_slmm(unit: RosterUnit) -> bool: """ Sync a single SLM device from Terra-View roster to SLMM. Args: unit: RosterUnit with device_type="slm" Returns: True if sync successful, False otherwise """ if unit.device_type != "slm": logger.warning(f"Attempted to sync non-SLM unit {unit.id} to SLMM") return False if not unit.slm_host: logger.warning(f"SLM {unit.id} has no host configured, skipping SLMM sync") return False try: async with httpx.AsyncClient(timeout=5.0) as client: response = await client.put( f"{SLMM_BASE_URL}/api/nl43/{unit.id}/config", json={ "host": unit.slm_host, "tcp_port": unit.slm_tcp_port or 2255, "tcp_enabled": True, "ftp_enabled": True, "ftp_username": "USER", # Default NL43 credentials "ftp_password": "0000", "poll_enabled": not unit.retired, # Disable polling for retired units "poll_interval_seconds": 60, # Default interval } ) if response.status_code in [200, 201]: logger.info(f"✓ Synced SLM {unit.id} to SLMM at {unit.slm_host}:{unit.slm_tcp_port or 2255}") return True else: logger.error(f"Failed to sync SLM {unit.id} to SLMM: {response.status_code} {response.text}") return False except httpx.TimeoutException: logger.error(f"Timeout syncing SLM {unit.id} to SLMM") return False except Exception as e: logger.error(f"Error syncing SLM {unit.id} to SLMM: {e}") return False async def delete_slm_from_slmm(unit_id: str) -> bool: """ Delete a device from SLMM database. Args: unit_id: The unit ID to delete Returns: True if deletion successful or device doesn't exist, False on error """ try: async with httpx.AsyncClient(timeout=5.0) as client: response = await client.delete( f"{SLMM_BASE_URL}/api/nl43/{unit_id}/config" ) if response.status_code == 200: logger.info(f"✓ Deleted SLM {unit_id} from SLMM") return True elif response.status_code == 404: logger.info(f"SLM {unit_id} not found in SLMM (already deleted)") return True else: logger.error(f"Failed to delete SLM {unit_id} from SLMM: {response.status_code} {response.text}") return False except httpx.TimeoutException: logger.error(f"Timeout deleting SLM {unit_id} from SLMM") return False except Exception as e: logger.error(f"Error deleting SLM {unit_id} from SLMM: {e}") return False async def sync_all_slms_to_slmm(db: Session) -> dict: """ Sync all SLM devices from Terra-View roster to SLMM. This ensures SLMM database matches Terra-View roster as the source of truth. Should be called on Terra-View startup and optionally via admin endpoint. Args: db: Database session Returns: Dictionary with sync results """ logger.info("Starting full SLM sync to SLMM...") # Get all SLM units from roster slm_units = db.query(RosterUnit).filter_by(device_type="slm").all() results = { "total": len(slm_units), "synced": 0, "skipped": 0, "failed": 0 } for unit in slm_units: # Skip units without host configured if not unit.slm_host: results["skipped"] += 1 logger.debug(f"Skipped {unit.unit_type} - no host configured") continue # Sync to SLMM success = await sync_slm_to_slmm(unit) if success: results["synced"] += 1 else: results["failed"] += 1 logger.info( f"SLM sync complete: {results['synced']} synced, " f"{results['skipped']} skipped, {results['failed']} failed" ) return results async def get_slmm_devices() -> Optional[list]: """ Get list of all devices currently in SLMM database. Returns: List of device unit_ids, or None on error """ try: async with httpx.AsyncClient(timeout=5.0) as client: response = await client.get(f"{SLMM_BASE_URL}/api/nl43/_polling/status") if response.status_code == 200: data = response.json() return [device["unit_id"] for device in data["data"]["devices"]] else: logger.error(f"Failed to get SLMM devices: {response.status_code}") return None except Exception as e: logger.error(f"Error getting SLMM devices: {e}") return None async def cleanup_orphaned_slmm_devices(db: Session) -> dict: """ Remove devices from SLMM that are not in Terra-View roster. This cleans up orphaned test devices or devices that were manually added to SLMM. Args: db: Database session Returns: Dictionary with cleanup results """ logger.info("Checking for orphaned devices in SLMM...") # Get all device IDs from SLMM slmm_devices = await get_slmm_devices() if slmm_devices is None: return {"error": "Failed to get SLMM device list"} # Get all SLM unit IDs from Terra-View roster roster_units = db.query(RosterUnit.id).filter_by(device_type="slm").all() roster_unit_ids = {unit.id for unit in roster_units} # Find orphaned devices (in SLMM but not in roster) orphaned = [uid for uid in slmm_devices if uid not in roster_unit_ids] results = { "total_in_slmm": len(slmm_devices), "total_in_roster": len(roster_unit_ids), "orphaned": len(orphaned), "deleted": 0, "failed": 0, "orphaned_devices": orphaned } if not orphaned: logger.info("No orphaned devices found in SLMM") return results logger.info(f"Found {len(orphaned)} orphaned devices in SLMM: {orphaned}") # Delete orphaned devices for unit_id in orphaned: success = await delete_slm_from_slmm(unit_id) if success: results["deleted"] += 1 else: results["failed"] += 1 logger.info( f"Cleanup complete: {results['deleted']} deleted, {results['failed']} failed" ) return results