diff --git a/app/routers.py b/app/routers.py index abbb1cf..2e8220f 100644 --- a/app/routers.py +++ b/app/routers.py @@ -440,7 +440,7 @@ async def get_clock(unit_id: str, db: Session = Depends(get_db)): class ClockPayload(BaseModel): - datetime: str # Format: YYYY/MM/DD,HH:MM:SS + datetime: str # Format: YYYY/MM/DD,HH:MM:SS or YYYY/MM/DD HH:MM:SS (both accepted) @router.put("/{unit_id}/clock") @@ -1033,3 +1033,173 @@ async def get_all_settings(unit_id: str, db: Session = Depends(get_db)): except Exception as e: logger.error(f"Failed to get all settings for {unit_id}: {e}") raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{unit_id}/diagnostics") +async def run_diagnostics(unit_id: str, db: Session = Depends(get_db)): + """Run comprehensive diagnostics on device connection and capabilities. + + Tests: + - Configuration exists + - TCP connection reachable + - Device responds to commands + - FTP status (if enabled) + """ + import asyncio + + diagnostics = { + "unit_id": unit_id, + "timestamp": datetime.now().isoformat(), + "tests": {} + } + + # Test 1: Configuration exists + cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first() + if not cfg: + diagnostics["tests"]["config_exists"] = { + "status": "fail", + "message": "Unit configuration not found in database" + } + diagnostics["overall_status"] = "fail" + return diagnostics + + diagnostics["tests"]["config_exists"] = { + "status": "pass", + "message": f"Configuration found: {cfg.host}:{cfg.tcp_port}" + } + + # Test 2: TCP enabled + if not cfg.tcp_enabled: + diagnostics["tests"]["tcp_enabled"] = { + "status": "fail", + "message": "TCP communication is disabled in configuration" + } + diagnostics["overall_status"] = "fail" + return diagnostics + + diagnostics["tests"]["tcp_enabled"] = { + "status": "pass", + "message": "TCP communication enabled" + } + + # Test 3: Modem/Router reachable (check port 443 HTTPS) + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(cfg.host, 443), timeout=3.0 + ) + writer.close() + await writer.wait_closed() + diagnostics["tests"]["modem_reachable"] = { + "status": "pass", + "message": f"Modem/router reachable at {cfg.host}" + } + except asyncio.TimeoutError: + diagnostics["tests"]["modem_reachable"] = { + "status": "fail", + "message": f"Modem/router timeout at {cfg.host} (network issue)" + } + diagnostics["overall_status"] = "fail" + return diagnostics + except ConnectionRefusedError: + # Connection refused means host is up but port 443 closed - that's ok + diagnostics["tests"]["modem_reachable"] = { + "status": "pass", + "message": f"Modem/router reachable at {cfg.host} (HTTPS closed)" + } + except Exception as e: + diagnostics["tests"]["modem_reachable"] = { + "status": "fail", + "message": f"Cannot reach modem/router at {cfg.host}: {str(e)}" + } + diagnostics["overall_status"] = "fail" + return diagnostics + + # Test 4: TCP connection reachable (device port) + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(cfg.host, cfg.tcp_port), timeout=3.0 + ) + writer.close() + await writer.wait_closed() + diagnostics["tests"]["tcp_connection"] = { + "status": "pass", + "message": f"TCP connection successful to {cfg.host}:{cfg.tcp_port}" + } + except asyncio.TimeoutError: + diagnostics["tests"]["tcp_connection"] = { + "status": "fail", + "message": f"Connection timeout to {cfg.host}:{cfg.tcp_port}" + } + diagnostics["overall_status"] = "fail" + return diagnostics + except ConnectionRefusedError: + diagnostics["tests"]["tcp_connection"] = { + "status": "fail", + "message": f"Connection refused by {cfg.host}:{cfg.tcp_port}" + } + diagnostics["overall_status"] = "fail" + return diagnostics + except Exception as e: + diagnostics["tests"]["tcp_connection"] = { + "status": "fail", + "message": f"Connection error: {str(e)}" + } + diagnostics["overall_status"] = "fail" + return diagnostics + + # Wait a bit after connection test to let device settle + await asyncio.sleep(1.5) + + # Test 5: Device responds to commands + # Use longer timeout to account for rate limiting (device requires ≥1s between commands) + client = NL43Client(cfg.host, cfg.tcp_port, timeout=10.0, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password) + try: + battery = await client.get_battery_level() + diagnostics["tests"]["command_response"] = { + "status": "pass", + "message": f"Device responds to commands (Battery: {battery})" + } + except ConnectionError as e: + diagnostics["tests"]["command_response"] = { + "status": "fail", + "message": f"Device not responding to commands: {str(e)}" + } + diagnostics["overall_status"] = "degraded" + return diagnostics + except ValueError as e: + diagnostics["tests"]["command_response"] = { + "status": "fail", + "message": f"Invalid response from device: {str(e)}" + } + diagnostics["overall_status"] = "degraded" + return diagnostics + except Exception as e: + diagnostics["tests"]["command_response"] = { + "status": "fail", + "message": f"Command error: {str(e)}" + } + diagnostics["overall_status"] = "degraded" + return diagnostics + + # Test 6: FTP status (if FTP is enabled in config) + if cfg.ftp_enabled: + try: + ftp_status = await client.get_ftp_status() + diagnostics["tests"]["ftp_status"] = { + "status": "pass" if ftp_status == "On" else "warning", + "message": f"FTP server status: {ftp_status}" + } + except Exception as e: + diagnostics["tests"]["ftp_status"] = { + "status": "warning", + "message": f"Could not query FTP status: {str(e)}" + } + else: + diagnostics["tests"]["ftp_status"] = { + "status": "skip", + "message": "FTP not enabled in configuration" + } + + # All tests passed + diagnostics["overall_status"] = "pass" + return diagnostics diff --git a/app/services.py b/app/services.py index 636ce3d..24f99c5 100644 --- a/app/services.py +++ b/app/services.py @@ -279,10 +279,13 @@ class NL43Client: """Set the device clock time. Args: - datetime_str: Time in format YYYY/MM/DD,HH:MM:SS + datetime_str: Time in format YYYY/MM/DD,HH:MM:SS or YYYY/MM/DD HH:MM:SS """ - await self._send_command(f"Clock,{datetime_str}\r\n") - logger.info(f"Clock set on {self.device_key} to {datetime_str}") + # Device expects format: Clock,YYYY/MM/DD HH:MM:SS (space between date and time) + # Replace comma with space if present to normalize format + normalized = datetime_str.replace(',', ' ', 1) + await self._send_command(f"Clock,{normalized}\r\n") + logger.info(f"Clock set on {self.device_key} to {normalized}") async def get_frequency_weighting(self, channel: str = "Main") -> str: """Get frequency weighting (A, C, Z, etc.). diff --git a/templates/index.html b/templates/index.html index 0b94b2b..e5a8b86 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,16 +7,38 @@

SLMM NL43 Standalone

Configure a unit (host/port), then use controls to Start/Stop and fetch live status.

+
+ 🔍 Connection Diagnostics + + +
+
+
Unit Config @@ -25,7 +47,26 @@ - + +
+ + +
+ + + +
@@ -113,14 +154,108 @@ logEl.scrollTop = logEl.scrollHeight; } + function toggleFtpCredentials() { + const ftpEnabled = document.getElementById('ftpEnabled').checked; + const ftpCredentials = document.getElementById('ftpCredentials'); + ftpCredentials.style.display = ftpEnabled ? 'block' : 'none'; + } + + // Add event listener for FTP checkbox + document.addEventListener('DOMContentLoaded', function() { + document.getElementById('ftpEnabled').addEventListener('change', toggleFtpCredentials); + }); + + async function runDiagnostics() { + const unitId = document.getElementById('unitId').value; + const resultsEl = document.getElementById('diagnosticsResults'); + + resultsEl.innerHTML = '

Running diagnostics...

'; + log('Running diagnostics...'); + + try { + const res = await fetch(`/api/nl43/${unitId}/diagnostics`); + const data = await res.json(); + + if (!res.ok) { + resultsEl.innerHTML = `

Diagnostics failed: ${data.detail || 'Unknown error'}

`; + log(`Diagnostics failed: ${res.status}`); + return; + } + + // Build results HTML + let html = ''; + + // Overall status summary + const statusText = { + 'pass': '✓ All systems operational', + 'fail': '✗ Connection failed', + 'degraded': '⚠ Partial connectivity' + }; + + html += `
`; + html += statusText[data.overall_status] || data.overall_status; + html += `
`; + + // Individual test results + const testNames = { + 'config_exists': '📋 Configuration', + 'tcp_enabled': '🔌 TCP Enabled', + 'modem_reachable': '📡 Modem/Router Reachable', + 'tcp_connection': '🌐 Device Port Connection', + 'command_response': '💬 Device Response', + 'ftp_status': '📁 FTP Status' + }; + + for (const [testKey, testResult] of Object.entries(data.tests)) { + const testName = testNames[testKey] || testKey; + html += `
`; + html += `${testResult.status}`; + html += `${testName}: ${testResult.message}`; + html += `
`; + } + + html += `

Last run: ${new Date(data.timestamp).toLocaleString()}

`; + + resultsEl.innerHTML = html; + log(`Diagnostics complete: ${data.overall_status}`); + + } catch (err) { + resultsEl.innerHTML = `

Error running diagnostics: ${err.message}

`; + log(`Diagnostics error: ${err.message}`); + } + } + + function clearDiagnostics() { + document.getElementById('diagnosticsResults').innerHTML = ''; + log('Diagnostics cleared'); + } + async function saveConfig() { const unitId = document.getElementById('unitId').value; const host = document.getElementById('host').value; const port = parseInt(document.getElementById('port').value, 10); + const tcpEnabled = document.getElementById('tcpEnabled').checked; + const ftpEnabled = document.getElementById('ftpEnabled').checked; + const ftpUsername = document.getElementById('ftpUsername').value; + const ftpPassword = document.getElementById('ftpPassword').value; + + const config = { + host, + tcp_port: port, + tcp_enabled: tcpEnabled, + ftp_enabled: ftpEnabled + }; + + // Only include FTP credentials if FTP is enabled + if (ftpEnabled) { + config.ftp_username = ftpUsername; + config.ftp_password = ftpPassword; + } + const res = await fetch(`/api/nl43/${unitId}/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ host, tcp_port: port }) + body: JSON.stringify(config) }); const data = await res.json(); log(`Saved config: ${JSON.stringify(data)}`); @@ -137,6 +272,12 @@ const data = response.data; document.getElementById('host').value = data.host; document.getElementById('port').value = data.tcp_port; + document.getElementById('tcpEnabled').checked = data.tcp_enabled || false; + document.getElementById('ftpEnabled').checked = data.ftp_enabled || false; + + // Show/hide FTP credentials based on FTP enabled status + toggleFtpCredentials(); + log(`Loaded config: ${JSON.stringify(data)}`); }