feat: terra-view scheduler implementation added. Start_cylce and stop_cycle functions added.
This commit is contained in:
@@ -562,6 +562,104 @@ async def stop_measurement(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
return {"status": "ok", "message": "Measurement stopped"}
|
return {"status": "ok", "message": "Measurement stopped"}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CYCLE COMMANDS (for scheduled automation)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class StartCyclePayload(BaseModel):
|
||||||
|
"""Payload for start_cycle endpoint."""
|
||||||
|
sync_clock: bool = Field(True, description="Whether to sync device clock to server time")
|
||||||
|
|
||||||
|
|
||||||
|
class StopCyclePayload(BaseModel):
|
||||||
|
"""Payload for stop_cycle endpoint."""
|
||||||
|
download: bool = Field(True, description="Whether to download measurement data")
|
||||||
|
download_path: str | None = Field(None, description="Custom path for ZIP file (optional)")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{unit_id}/start-cycle")
|
||||||
|
async def start_cycle(unit_id: str, payload: StartCyclePayload = None, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Execute complete start cycle for scheduled automation:
|
||||||
|
1. Sync device clock to server time (if sync_clock=True)
|
||||||
|
2. Find next safe index (increment, check overwrite, repeat if needed)
|
||||||
|
3. Start measurement
|
||||||
|
|
||||||
|
Use this instead of /start when automating scheduled measurements.
|
||||||
|
This ensures the device is properly prepared before recording begins.
|
||||||
|
"""
|
||||||
|
cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(status_code=404, detail="NL43 config not found")
|
||||||
|
|
||||||
|
if not cfg.tcp_enabled:
|
||||||
|
raise HTTPException(status_code=403, detail="TCP communication is disabled for this device")
|
||||||
|
|
||||||
|
payload = payload or StartCyclePayload()
|
||||||
|
client = NL43Client(cfg.host, cfg.tcp_port, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password, ftp_port=cfg.ftp_port or 21)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ensure sleep mode is disabled before starting
|
||||||
|
await ensure_sleep_mode_disabled(client, unit_id)
|
||||||
|
|
||||||
|
# Execute the full start cycle
|
||||||
|
result = await client.start_cycle(sync_clock=payload.sync_clock)
|
||||||
|
|
||||||
|
# Update status in database
|
||||||
|
snap = await client.request_dod()
|
||||||
|
snap.unit_id = unit_id
|
||||||
|
persist_snapshot(snap, db)
|
||||||
|
|
||||||
|
logger.info(f"Start cycle completed for {unit_id}: index {result['old_index']} -> {result['new_index']}")
|
||||||
|
return {"status": "ok", "unit_id": unit_id, **result}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Start cycle failed for {unit_id}: {e}")
|
||||||
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{unit_id}/stop-cycle")
|
||||||
|
async def stop_cycle(unit_id: str, payload: StopCyclePayload = None, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Execute complete stop cycle for scheduled automation:
|
||||||
|
1. Stop measurement
|
||||||
|
2. Enable FTP
|
||||||
|
3. Download measurement folder (matching current index)
|
||||||
|
4. Verify download succeeded
|
||||||
|
|
||||||
|
Use this instead of /stop when automating scheduled measurements.
|
||||||
|
This ensures data is properly saved and downloaded before the next session.
|
||||||
|
"""
|
||||||
|
cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(status_code=404, detail="NL43 config not found")
|
||||||
|
|
||||||
|
if not cfg.tcp_enabled:
|
||||||
|
raise HTTPException(status_code=403, detail="TCP communication is disabled for this device")
|
||||||
|
|
||||||
|
payload = payload or StopCyclePayload()
|
||||||
|
client = NL43Client(cfg.host, cfg.tcp_port, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password, ftp_port=cfg.ftp_port or 21)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Execute the full stop cycle
|
||||||
|
result = await client.stop_cycle(
|
||||||
|
download=payload.download,
|
||||||
|
download_path=payload.download_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update status in database
|
||||||
|
snap = await client.request_dod()
|
||||||
|
snap.unit_id = unit_id
|
||||||
|
persist_snapshot(snap, db)
|
||||||
|
|
||||||
|
logger.info(f"Stop cycle completed for {unit_id}: folder={result.get('downloaded_folder')}, success={result.get('download_success')}")
|
||||||
|
return {"status": "ok", "unit_id": unit_id, **result}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Stop cycle failed for {unit_id}: {e}")
|
||||||
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{unit_id}/store")
|
@router.post("/{unit_id}/store")
|
||||||
async def manual_store(unit_id: str, db: Session = Depends(get_db)):
|
async def manual_store(unit_id: str, db: Session = Depends(get_db)):
|
||||||
"""Manually store measurement data to SD card."""
|
"""Manually store measurement data to SD card."""
|
||||||
|
|||||||
161
app/services.py
161
app/services.py
@@ -1115,3 +1115,164 @@ class NL43Client:
|
|||||||
import traceback
|
import traceback
|
||||||
logger.error(f"[FTP-FOLDER] Full traceback:\n{traceback.format_exc()}")
|
logger.error(f"[FTP-FOLDER] Full traceback:\n{traceback.format_exc()}")
|
||||||
raise ConnectionError(f"FTP folder download failed: {str(e)}")
|
raise ConnectionError(f"FTP folder download failed: {str(e)}")
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Cycle Commands (for scheduled automation)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def start_cycle(self, sync_clock: bool = True, max_index_attempts: int = 100) -> dict:
|
||||||
|
"""
|
||||||
|
Execute complete start cycle for scheduled automation:
|
||||||
|
1. Sync device clock to server time
|
||||||
|
2. Find next safe index (increment, check overwrite, repeat if needed)
|
||||||
|
3. Start measurement
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sync_clock: Whether to sync device clock to server time (default: True)
|
||||||
|
max_index_attempts: Maximum attempts to find an unused index (default: 100)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with clock_synced, old_index, new_index, attempts_made, started
|
||||||
|
"""
|
||||||
|
logger.info(f"[START-CYCLE] === Starting measurement cycle on {self.device_key} ===")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"clock_synced": False,
|
||||||
|
"server_time": None,
|
||||||
|
"old_index": None,
|
||||||
|
"new_index": None,
|
||||||
|
"attempts_made": 0,
|
||||||
|
"started": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 1: Sync clock to server time
|
||||||
|
if sync_clock:
|
||||||
|
# Use configured timezone
|
||||||
|
server_now = datetime.now(timezone.utc) + TIMEZONE_OFFSET
|
||||||
|
server_time = server_now.strftime("%Y/%m/%d %H:%M:%S")
|
||||||
|
logger.info(f"[START-CYCLE] Step 1: Syncing clock to {server_time} ({TIMEZONE_NAME})")
|
||||||
|
await self.set_clock(server_time)
|
||||||
|
result["clock_synced"] = True
|
||||||
|
result["server_time"] = server_time
|
||||||
|
logger.info(f"[START-CYCLE] Clock synced successfully")
|
||||||
|
else:
|
||||||
|
logger.info(f"[START-CYCLE] Step 1: Skipping clock sync (sync_clock=False)")
|
||||||
|
|
||||||
|
# Step 2: Find next safe index with overwrite protection
|
||||||
|
logger.info(f"[START-CYCLE] Step 2: Finding safe index with overwrite protection")
|
||||||
|
current_index_str = await self.get_index_number()
|
||||||
|
current_index = int(current_index_str)
|
||||||
|
result["old_index"] = current_index
|
||||||
|
logger.info(f"[START-CYCLE] Current index: {current_index}")
|
||||||
|
|
||||||
|
test_index = current_index + 1
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
while attempts < max_index_attempts:
|
||||||
|
test_index = test_index % 10000 # Wrap at 9999
|
||||||
|
await self.set_index_number(test_index)
|
||||||
|
attempts += 1
|
||||||
|
|
||||||
|
# Check if this index is safe (no existing data)
|
||||||
|
overwrite_status = await self.get_overwrite_status()
|
||||||
|
logger.info(f"[START-CYCLE] Index {test_index:04d}: overwrite status = {overwrite_status}")
|
||||||
|
|
||||||
|
if overwrite_status == "None":
|
||||||
|
# Safe to use this index
|
||||||
|
result["new_index"] = test_index
|
||||||
|
result["attempts_made"] = attempts
|
||||||
|
logger.info(f"[START-CYCLE] Found safe index {test_index:04d} after {attempts} attempt(s)")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Data exists, try next index
|
||||||
|
test_index += 1
|
||||||
|
|
||||||
|
if test_index == current_index:
|
||||||
|
# Wrapped around completely - all indices have data
|
||||||
|
logger.error(f"[START-CYCLE] All indices have data! Device storage is full.")
|
||||||
|
raise Exception("All indices have data. Download and clear device storage.")
|
||||||
|
|
||||||
|
if result["new_index"] is None:
|
||||||
|
logger.error(f"[START-CYCLE] Could not find empty index after {max_index_attempts} attempts")
|
||||||
|
raise Exception(f"Could not find empty index after {max_index_attempts} attempts")
|
||||||
|
|
||||||
|
# Step 3: Start measurement
|
||||||
|
logger.info(f"[START-CYCLE] Step 3: Starting measurement")
|
||||||
|
await self.start()
|
||||||
|
result["started"] = True
|
||||||
|
logger.info(f"[START-CYCLE] === Measurement started successfully ===")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def stop_cycle(self, download: bool = True, download_path: str = None) -> dict:
|
||||||
|
"""
|
||||||
|
Execute complete stop cycle for scheduled automation:
|
||||||
|
1. Stop measurement
|
||||||
|
2. Enable FTP
|
||||||
|
3. Download measurement folder (matching current index)
|
||||||
|
4. Verify download succeeded
|
||||||
|
|
||||||
|
Args:
|
||||||
|
download: Whether to download measurement data (default: True)
|
||||||
|
download_path: Custom path for ZIP file (default: data/downloads/{device_key}/Auto_XXXX.zip)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with stopped, ftp_enabled, download_attempted, download_success, etc.
|
||||||
|
"""
|
||||||
|
logger.info(f"[STOP-CYCLE] === Stopping measurement cycle on {self.device_key} ===")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"stopped": False,
|
||||||
|
"ftp_enabled": False,
|
||||||
|
"download_attempted": False,
|
||||||
|
"download_success": False,
|
||||||
|
"downloaded_folder": None,
|
||||||
|
"local_path": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 1: Stop measurement
|
||||||
|
logger.info(f"[STOP-CYCLE] Step 1: Stopping measurement")
|
||||||
|
await self.stop()
|
||||||
|
result["stopped"] = True
|
||||||
|
logger.info(f"[STOP-CYCLE] Measurement stopped")
|
||||||
|
|
||||||
|
# Step 2: Enable FTP
|
||||||
|
logger.info(f"[STOP-CYCLE] Step 2: Enabling FTP")
|
||||||
|
await self.enable_ftp()
|
||||||
|
result["ftp_enabled"] = True
|
||||||
|
logger.info(f"[STOP-CYCLE] FTP enabled")
|
||||||
|
|
||||||
|
if not download:
|
||||||
|
logger.info(f"[STOP-CYCLE] === Cycle complete (download=False) ===")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Step 3: Get current index to know which folder to download
|
||||||
|
logger.info(f"[STOP-CYCLE] Step 3: Determining folder to download")
|
||||||
|
current_index_str = await self.get_index_number()
|
||||||
|
# Pad to 4 digits for folder name
|
||||||
|
folder_name = f"Auto_{current_index_str.zfill(4)}"
|
||||||
|
remote_path = f"/NL-43/{folder_name}"
|
||||||
|
result["downloaded_folder"] = folder_name
|
||||||
|
result["download_attempted"] = True
|
||||||
|
logger.info(f"[STOP-CYCLE] Will download folder: {remote_path}")
|
||||||
|
|
||||||
|
# Step 4: Download the folder
|
||||||
|
if download_path is None:
|
||||||
|
# Default path: data/downloads/{device_key}/Auto_XXXX.zip
|
||||||
|
download_dir = f"data/downloads/{self.device_key}"
|
||||||
|
os.makedirs(download_dir, exist_ok=True)
|
||||||
|
download_path = os.path.join(download_dir, f"{folder_name}.zip")
|
||||||
|
|
||||||
|
logger.info(f"[STOP-CYCLE] Step 4: Downloading to {download_path}")
|
||||||
|
try:
|
||||||
|
await self.download_ftp_folder(remote_path, download_path)
|
||||||
|
result["download_success"] = True
|
||||||
|
result["local_path"] = download_path
|
||||||
|
logger.info(f"[STOP-CYCLE] Download successful: {download_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[STOP-CYCLE] Download failed: {e}")
|
||||||
|
# Don't raise - the stop was successful, just the download failed
|
||||||
|
result["download_error"] = str(e)
|
||||||
|
|
||||||
|
logger.info(f"[STOP-CYCLE] === Cycle complete ===")
|
||||||
|
return result
|
||||||
|
|||||||
Reference in New Issue
Block a user