feat: terra-view scheduler implementation added. Start_cylce and stop_cycle functions added.

This commit is contained in:
serversdwn
2026-01-22 20:25:47 +00:00
parent 4868381053
commit 152377d608
2 changed files with 259 additions and 0 deletions

View File

@@ -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."""

View File

@@ -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