Move SLM control center groundwork onto dev

This commit is contained in:
serversdwn
2026-01-12 18:07:26 +00:00
parent 893cb96e8d
commit 8a5fadb5df
22 changed files with 3960 additions and 29 deletions

View File

@@ -0,0 +1,384 @@
"""
Device Controller Service
Routes device operations to the appropriate backend module:
- SLMM for sound level meters
- SFM for seismographs (future implementation)
This abstraction allows Projects system to work with any device type
without knowing the underlying communication protocol.
"""
from typing import Dict, Any, Optional, List
from backend.services.slmm_client import get_slmm_client, SLMMClientError
class DeviceControllerError(Exception):
"""Base exception for device controller errors."""
pass
class UnsupportedDeviceTypeError(DeviceControllerError):
"""Raised when device type is not supported."""
pass
class DeviceController:
"""
Unified interface for controlling all device types.
Routes commands to appropriate backend module based on device_type.
Usage:
controller = DeviceController()
await controller.start_recording("nl43-001", "sound_level_meter", config={})
await controller.stop_recording("seismo-042", "seismograph")
"""
def __init__(self):
self.slmm_client = get_slmm_client()
# ========================================================================
# Recording Control
# ========================================================================
async def start_recording(
self,
unit_id: str,
device_type: str,
config: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Start recording on a device.
Args:
unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph"
config: Device-specific recording configuration
Returns:
Response dict from device module
Raises:
UnsupportedDeviceTypeError: Device type not supported
DeviceControllerError: Operation failed
"""
if device_type == "sound_level_meter":
try:
return await self.slmm_client.start_recording(unit_id, config)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
# TODO: Implement SFM client for seismograph control
# For now, return a placeholder response
return {
"status": "not_implemented",
"message": "Seismograph recording control not yet implemented",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(
f"Device type '{device_type}' is not supported. "
f"Supported types: sound_level_meter, seismograph"
)
async def stop_recording(
self,
unit_id: str,
device_type: str,
) -> Dict[str, Any]:
"""
Stop recording on a device.
Args:
unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph"
Returns:
Response dict from device module
"""
if device_type == "sound_level_meter":
try:
return await self.slmm_client.stop_recording(unit_id)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
# TODO: Implement SFM client
return {
"status": "not_implemented",
"message": "Seismograph recording control not yet implemented",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
async def pause_recording(
self,
unit_id: str,
device_type: str,
) -> Dict[str, Any]:
"""
Pause recording on a device.
Args:
unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph"
Returns:
Response dict from device module
"""
if device_type == "sound_level_meter":
try:
return await self.slmm_client.pause_recording(unit_id)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
return {
"status": "not_implemented",
"message": "Seismograph pause not yet implemented",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
async def resume_recording(
self,
unit_id: str,
device_type: str,
) -> Dict[str, Any]:
"""
Resume paused recording on a device.
Args:
unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph"
Returns:
Response dict from device module
"""
if device_type == "sound_level_meter":
try:
return await self.slmm_client.resume_recording(unit_id)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
return {
"status": "not_implemented",
"message": "Seismograph resume not yet implemented",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
# ========================================================================
# Status & Monitoring
# ========================================================================
async def get_device_status(
self,
unit_id: str,
device_type: str,
) -> Dict[str, Any]:
"""
Get current device status.
Args:
unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph"
Returns:
Status dict from device module
"""
if device_type == "sound_level_meter":
try:
return await self.slmm_client.get_unit_status(unit_id)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
# TODO: Implement SFM status check
return {
"status": "not_implemented",
"message": "Seismograph status not yet implemented",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
async def get_live_data(
self,
unit_id: str,
device_type: str,
) -> Dict[str, Any]:
"""
Get live data from device.
Args:
unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph"
Returns:
Live data dict from device module
"""
if device_type == "sound_level_meter":
try:
return await self.slmm_client.get_live_data(unit_id)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
return {
"status": "not_implemented",
"message": "Seismograph live data not yet implemented",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
# ========================================================================
# Data Download
# ========================================================================
async def download_files(
self,
unit_id: str,
device_type: str,
destination_path: str,
files: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
Download data files from device.
Args:
unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph"
destination_path: Local path to save files
files: List of filenames, or None for all
Returns:
Download result with file list
"""
if device_type == "sound_level_meter":
try:
return await self.slmm_client.download_files(
unit_id,
destination_path,
files,
)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
# TODO: Implement SFM file download
return {
"status": "not_implemented",
"message": "Seismograph file download not yet implemented",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
# ========================================================================
# Device Configuration
# ========================================================================
async def update_device_config(
self,
unit_id: str,
device_type: str,
config: Dict[str, Any],
) -> Dict[str, Any]:
"""
Update device configuration.
Args:
unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph"
config: Configuration parameters
Returns:
Updated config from device module
"""
if device_type == "sound_level_meter":
try:
return await self.slmm_client.update_unit_config(
unit_id,
host=config.get("host"),
tcp_port=config.get("tcp_port"),
ftp_port=config.get("ftp_port"),
ftp_username=config.get("ftp_username"),
ftp_password=config.get("ftp_password"),
)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
return {
"status": "not_implemented",
"message": "Seismograph config update not yet implemented",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
# ========================================================================
# Health Check
# ========================================================================
async def check_device_connectivity(
self,
unit_id: str,
device_type: str,
) -> bool:
"""
Check if device is reachable.
Args:
unit_id: Unit identifier
device_type: "sound_level_meter" | "seismograph"
Returns:
True if device is reachable, False otherwise
"""
if device_type == "sound_level_meter":
try:
status = await self.slmm_client.get_unit_status(unit_id)
return status.get("last_seen") is not None
except:
return False
elif device_type == "seismograph":
# TODO: Implement SFM connectivity check
return False
else:
return False
# Singleton instance
_default_controller: Optional[DeviceController] = None
def get_device_controller() -> DeviceController:
"""
Get the default device controller instance.
Returns:
DeviceController instance
"""
global _default_controller
if _default_controller is None:
_default_controller = DeviceController()
return _default_controller

View File

@@ -0,0 +1,355 @@
"""
Scheduler Service
Executes scheduled actions for Projects system.
Monitors pending scheduled actions and executes them by calling device modules (SLMM/SFM).
This service runs as a background task in FastAPI, checking for pending actions
every minute and executing them when their scheduled time arrives.
"""
import asyncio
import json
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import and_
from backend.database import SessionLocal
from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project
from backend.services.device_controller import get_device_controller, DeviceControllerError
import uuid
class SchedulerService:
"""
Service for executing scheduled actions.
Usage:
scheduler = SchedulerService()
await scheduler.start() # Start background loop
scheduler.stop() # Stop background loop
"""
def __init__(self, check_interval: int = 60):
"""
Initialize scheduler.
Args:
check_interval: Seconds between checks for pending actions (default: 60)
"""
self.check_interval = check_interval
self.running = False
self.task: Optional[asyncio.Task] = None
self.device_controller = get_device_controller()
async def start(self):
"""Start the scheduler background task."""
if self.running:
print("Scheduler is already running")
return
self.running = True
self.task = asyncio.create_task(self._run_loop())
print(f"Scheduler started (checking every {self.check_interval}s)")
def stop(self):
"""Stop the scheduler background task."""
self.running = False
if self.task:
self.task.cancel()
print("Scheduler stopped")
async def _run_loop(self):
"""Main scheduler loop."""
while self.running:
try:
await self.execute_pending_actions()
except Exception as e:
print(f"Scheduler error: {e}")
# Continue running even if there's an error
await asyncio.sleep(self.check_interval)
async def execute_pending_actions(self) -> List[Dict[str, Any]]:
"""
Find and execute all pending scheduled actions that are due.
Returns:
List of execution results
"""
db = SessionLocal()
results = []
try:
# Find pending actions that are due
now = datetime.utcnow()
pending_actions = db.query(ScheduledAction).filter(
and_(
ScheduledAction.execution_status == "pending",
ScheduledAction.scheduled_time <= now,
)
).order_by(ScheduledAction.scheduled_time).all()
if not pending_actions:
return []
print(f"Found {len(pending_actions)} pending action(s) to execute")
for action in pending_actions:
result = await self._execute_action(action, db)
results.append(result)
db.commit()
except Exception as e:
print(f"Error executing pending actions: {e}")
db.rollback()
finally:
db.close()
return results
async def _execute_action(
self,
action: ScheduledAction,
db: Session,
) -> Dict[str, Any]:
"""
Execute a single scheduled action.
Args:
action: ScheduledAction to execute
db: Database session
Returns:
Execution result dict
"""
print(f"Executing action {action.id}: {action.action_type} for unit {action.unit_id}")
result = {
"action_id": action.id,
"action_type": action.action_type,
"unit_id": action.unit_id,
"scheduled_time": action.scheduled_time.isoformat(),
"success": False,
"error": None,
}
try:
# Determine which unit to use
# If unit_id is specified, use it; otherwise get from location assignment
unit_id = action.unit_id
if not unit_id:
# Get assigned unit from location
from backend.models import UnitAssignment
assignment = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == action.location_id,
UnitAssignment.status == "active",
)
).first()
if not assignment:
raise Exception(f"No active unit assigned to location {action.location_id}")
unit_id = assignment.unit_id
# Execute the action based on type
if action.action_type == "start":
response = await self._execute_start(action, unit_id, db)
elif action.action_type == "stop":
response = await self._execute_stop(action, unit_id, db)
elif action.action_type == "download":
response = await self._execute_download(action, unit_id, db)
else:
raise Exception(f"Unknown action type: {action.action_type}")
# Mark action as completed
action.execution_status = "completed"
action.executed_at = datetime.utcnow()
action.module_response = json.dumps(response)
result["success"] = True
result["response"] = response
print(f"✓ Action {action.id} completed successfully")
except Exception as e:
# Mark action as failed
action.execution_status = "failed"
action.executed_at = datetime.utcnow()
action.error_message = str(e)
result["error"] = str(e)
print(f"✗ Action {action.id} failed: {e}")
return result
async def _execute_start(
self,
action: ScheduledAction,
unit_id: str,
db: Session,
) -> Dict[str, Any]:
"""Execute a 'start' action."""
# Start recording via device controller
response = await self.device_controller.start_recording(
unit_id,
action.device_type,
config={}, # TODO: Load config from action.notes or metadata
)
# Create recording session
session = RecordingSession(
id=str(uuid.uuid4()),
project_id=action.project_id,
location_id=action.location_id,
unit_id=unit_id,
session_type="sound" if action.device_type == "sound_level_meter" else "vibration",
started_at=datetime.utcnow(),
status="recording",
session_metadata=json.dumps({"scheduled_action_id": action.id}),
)
db.add(session)
return {
"status": "started",
"session_id": session.id,
"device_response": response,
}
async def _execute_stop(
self,
action: ScheduledAction,
unit_id: str,
db: Session,
) -> Dict[str, Any]:
"""Execute a 'stop' action."""
# Stop recording via device controller
response = await self.device_controller.stop_recording(
unit_id,
action.device_type,
)
# Find and update the active recording session
active_session = db.query(RecordingSession).filter(
and_(
RecordingSession.location_id == action.location_id,
RecordingSession.unit_id == unit_id,
RecordingSession.status == "recording",
)
).first()
if active_session:
active_session.stopped_at = datetime.utcnow()
active_session.status = "completed"
active_session.duration_seconds = int(
(active_session.stopped_at - active_session.started_at).total_seconds()
)
return {
"status": "stopped",
"session_id": active_session.id if active_session else None,
"device_response": response,
}
async def _execute_download(
self,
action: ScheduledAction,
unit_id: str,
db: Session,
) -> Dict[str, Any]:
"""Execute a 'download' action."""
# Get project and location info for file path
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
project = db.query(Project).filter_by(id=action.project_id).first()
if not location or not project:
raise Exception("Project or location not found")
# Build destination path
# Example: data/Projects/{project-id}/sound/{location-name}/session-{timestamp}/
session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M")
location_type_dir = "sound" if action.device_type == "sound_level_meter" else "vibration"
destination_path = (
f"data/Projects/{project.id}/{location_type_dir}/"
f"{location.name}/session-{session_timestamp}/"
)
# Download files via device controller
response = await self.device_controller.download_files(
unit_id,
action.device_type,
destination_path,
files=None, # Download all files
)
# TODO: Create DataFile records for downloaded files
return {
"status": "downloaded",
"destination_path": destination_path,
"device_response": response,
}
# ========================================================================
# Manual Execution (for testing/debugging)
# ========================================================================
async def execute_action_by_id(self, action_id: str) -> Dict[str, Any]:
"""
Manually execute a specific action by ID.
Args:
action_id: ScheduledAction ID
Returns:
Execution result
"""
db = SessionLocal()
try:
action = db.query(ScheduledAction).filter_by(id=action_id).first()
if not action:
return {"success": False, "error": "Action not found"}
result = await self._execute_action(action, db)
db.commit()
return result
except Exception as e:
db.rollback()
return {"success": False, "error": str(e)}
finally:
db.close()
# Singleton instance
_scheduler_instance: Optional[SchedulerService] = None
def get_scheduler() -> SchedulerService:
"""
Get the scheduler singleton instance.
Returns:
SchedulerService instance
"""
global _scheduler_instance
if _scheduler_instance is None:
_scheduler_instance = SchedulerService()
return _scheduler_instance
async def start_scheduler():
"""Start the global scheduler instance."""
scheduler = get_scheduler()
await scheduler.start()
def stop_scheduler():
"""Stop the global scheduler instance."""
scheduler = get_scheduler()
scheduler.stop()

View File

@@ -0,0 +1,423 @@
"""
SLMM API Client Wrapper
Provides a clean interface for Terra-View to interact with the SLMM backend.
All SLM operations should go through this client instead of direct HTTP calls.
SLMM (Sound Level Meter Manager) is a separate service running on port 8100
that handles TCP/FTP communication with Rion NL-43/NL-53 devices.
"""
import httpx
from typing import Optional, Dict, Any, List
from datetime import datetime
import json
# SLMM backend base URLs
SLMM_BASE_URL = "http://localhost:8100"
SLMM_API_BASE = f"{SLMM_BASE_URL}/api/nl43"
class SLMMClientError(Exception):
"""Base exception for SLMM client errors."""
pass
class SLMMConnectionError(SLMMClientError):
"""Raised when cannot connect to SLMM backend."""
pass
class SLMMDeviceError(SLMMClientError):
"""Raised when device operation fails."""
pass
class SLMMClient:
"""
Client for interacting with SLMM backend.
Usage:
client = SLMMClient()
units = await client.get_all_units()
status = await client.get_unit_status("nl43-001")
await client.start_recording("nl43-001", config={...})
"""
def __init__(self, base_url: str = SLMM_BASE_URL, timeout: float = 30.0):
self.base_url = base_url
self.api_base = f"{base_url}/api/nl43"
self.timeout = timeout
async def _request(
self,
method: str,
endpoint: str,
data: Optional[Dict] = None,
params: Optional[Dict] = None,
) -> Dict[str, Any]:
"""
Make an HTTP request to SLMM backend.
Args:
method: HTTP method (GET, POST, PUT, DELETE)
endpoint: API endpoint (e.g., "/units", "/{unit_id}/status")
data: JSON body for POST/PUT requests
params: Query parameters
Returns:
Response JSON as dict
Raises:
SLMMConnectionError: Cannot reach SLMM
SLMMDeviceError: Device operation failed
"""
url = f"{self.api_base}{endpoint}"
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.request(
method=method,
url=url,
json=data,
params=params,
)
response.raise_for_status()
# Handle empty responses
if not response.content:
return {}
return response.json()
except httpx.ConnectError as e:
raise SLMMConnectionError(
f"Cannot connect to SLMM backend at {self.base_url}. "
f"Is SLMM running? Error: {str(e)}"
)
except httpx.HTTPStatusError as e:
error_detail = "Unknown error"
try:
error_data = e.response.json()
error_detail = error_data.get("detail", str(error_data))
except:
error_detail = e.response.text or str(e)
raise SLMMDeviceError(
f"SLMM operation failed: {error_detail}"
)
except Exception as e:
raise SLMMClientError(f"Unexpected error: {str(e)}")
# ========================================================================
# Unit Management
# ========================================================================
async def get_all_units(self) -> List[Dict[str, Any]]:
"""
Get all configured SLM units from SLMM.
Returns:
List of unit dicts with id, config, and status
"""
# SLMM doesn't have a /units endpoint yet, so we'll need to add this
# For now, return empty list or implement when SLMM endpoint is ready
try:
response = await self._request("GET", "/units")
return response.get("units", [])
except SLMMClientError:
# Endpoint may not exist yet
return []
async def get_unit_config(self, unit_id: str) -> Dict[str, Any]:
"""
Get unit configuration from SLMM cache.
Args:
unit_id: Unit identifier (e.g., "nl43-001")
Returns:
Config dict with host, tcp_port, ftp_port, etc.
"""
return await self._request("GET", f"/{unit_id}/config")
async def update_unit_config(
self,
unit_id: str,
host: Optional[str] = None,
tcp_port: Optional[int] = None,
ftp_port: Optional[int] = None,
ftp_username: Optional[str] = None,
ftp_password: Optional[str] = None,
) -> Dict[str, Any]:
"""
Update unit configuration in SLMM cache.
Args:
unit_id: Unit identifier
host: Device IP address
tcp_port: TCP control port (default: 2255)
ftp_port: FTP data port (default: 21)
ftp_username: FTP username
ftp_password: FTP password
Returns:
Updated config
"""
config = {}
if host is not None:
config["host"] = host
if tcp_port is not None:
config["tcp_port"] = tcp_port
if ftp_port is not None:
config["ftp_port"] = ftp_port
if ftp_username is not None:
config["ftp_username"] = ftp_username
if ftp_password is not None:
config["ftp_password"] = ftp_password
return await self._request("PUT", f"/{unit_id}/config", data=config)
# ========================================================================
# Status & Monitoring
# ========================================================================
async def get_unit_status(self, unit_id: str) -> Dict[str, Any]:
"""
Get cached status snapshot from SLMM.
Args:
unit_id: Unit identifier
Returns:
Status dict with measurement_state, lp, leq, battery, etc.
"""
return await self._request("GET", f"/{unit_id}/status")
async def get_live_data(self, unit_id: str) -> Dict[str, Any]:
"""
Request fresh data from device (DOD command).
Args:
unit_id: Unit identifier
Returns:
Live data snapshot
"""
return await self._request("GET", f"/{unit_id}/live")
# ========================================================================
# Recording Control
# ========================================================================
async def start_recording(
self,
unit_id: str,
config: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Start recording on a unit.
Args:
unit_id: Unit identifier
config: Optional recording config (interval, settings, etc.)
Returns:
Response from SLMM with success status
"""
return await self._request("POST", f"/{unit_id}/start", data=config or {})
async def stop_recording(self, unit_id: str) -> Dict[str, Any]:
"""
Stop recording on a unit.
Args:
unit_id: Unit identifier
Returns:
Response from SLMM
"""
return await self._request("POST", f"/{unit_id}/stop")
async def pause_recording(self, unit_id: str) -> Dict[str, Any]:
"""
Pause recording on a unit.
Args:
unit_id: Unit identifier
Returns:
Response from SLMM
"""
return await self._request("POST", f"/{unit_id}/pause")
async def resume_recording(self, unit_id: str) -> Dict[str, Any]:
"""
Resume paused recording on a unit.
Args:
unit_id: Unit identifier
Returns:
Response from SLMM
"""
return await self._request("POST", f"/{unit_id}/resume")
async def reset_data(self, unit_id: str) -> Dict[str, Any]:
"""
Reset measurement data on a unit.
Args:
unit_id: Unit identifier
Returns:
Response from SLMM
"""
return await self._request("POST", f"/{unit_id}/reset")
# ========================================================================
# Device Settings
# ========================================================================
async def get_frequency_weighting(self, unit_id: str) -> Dict[str, Any]:
"""
Get frequency weighting setting (A, C, or Z).
Args:
unit_id: Unit identifier
Returns:
Dict with current weighting
"""
return await self._request("GET", f"/{unit_id}/frequency-weighting")
async def set_frequency_weighting(
self,
unit_id: str,
weighting: str,
) -> Dict[str, Any]:
"""
Set frequency weighting (A, C, or Z).
Args:
unit_id: Unit identifier
weighting: "A", "C", or "Z"
Returns:
Confirmation response
"""
return await self._request(
"PUT",
f"/{unit_id}/frequency-weighting",
data={"weighting": weighting},
)
async def get_time_weighting(self, unit_id: str) -> Dict[str, Any]:
"""
Get time weighting setting (F, S, or I).
Args:
unit_id: Unit identifier
Returns:
Dict with current time weighting
"""
return await self._request("GET", f"/{unit_id}/time-weighting")
async def set_time_weighting(
self,
unit_id: str,
weighting: str,
) -> Dict[str, Any]:
"""
Set time weighting (F=Fast, S=Slow, I=Impulse).
Args:
unit_id: Unit identifier
weighting: "F", "S", or "I"
Returns:
Confirmation response
"""
return await self._request(
"PUT",
f"/{unit_id}/time-weighting",
data={"weighting": weighting},
)
async def get_all_settings(self, unit_id: str) -> Dict[str, Any]:
"""
Get all device settings.
Args:
unit_id: Unit identifier
Returns:
Dict with all settings
"""
return await self._request("GET", f"/{unit_id}/settings")
# ========================================================================
# Data Download (Future)
# ========================================================================
async def download_files(
self,
unit_id: str,
destination_path: str,
files: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
Download files from unit via FTP.
NOTE: This endpoint doesn't exist in SLMM yet. Will need to implement.
Args:
unit_id: Unit identifier
destination_path: Local path to save files
files: List of filenames to download, or None for all
Returns:
Dict with downloaded files list and metadata
"""
data = {
"destination_path": destination_path,
"files": files or "all",
}
return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
# ========================================================================
# Health Check
# ========================================================================
async def health_check(self) -> bool:
"""
Check if SLMM backend is reachable.
Returns:
True if SLMM is responding, False otherwise
"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{self.base_url}/health")
return response.status_code == 200
except:
return False
# Singleton instance for convenience
_default_client: Optional[SLMMClient] = None
def get_slmm_client() -> SLMMClient:
"""
Get the default SLMM client instance.
Returns:
SLMMClient instance
"""
global _default_client
if _default_client is None:
_default_client = SLMMClient()
return _default_client