diff --git a/build-pi-image.sh b/build-pi-image.sh index 6079594..078958e 100755 --- a/build-pi-image.sh +++ b/build-pi-image.sh @@ -868,6 +868,7 @@ echo "════════════════════════ echo "" echo "Useful Commands:" echo " lablink-status - Show this status" +echo " lablink-version - Show version and git commit info" echo " lablink-start - Start LabLink" echo " lablink-stop - Stop LabLink" echo " lablink-restart - Restart LabLink" @@ -998,6 +999,12 @@ lablink-status UPDATESCRIPT chmod +x /usr/local/bin/lablink-update +# Install lablink-version command +if [ -f /opt/lablink/lablink-version.sh ]; then + cp /opt/lablink/lablink-version.sh /usr/local/bin/lablink-version + chmod +x /usr/local/bin/lablink-version +fi + echo "[LabLink] First boot setup complete!" echo "[LabLink] ════════════════════════════════════════════════════════" echo "[LabLink] " @@ -1007,6 +1014,7 @@ echo "[LabLink] Access LabLink at: http://$(hostname).local" echo "[LabLink] " echo "[LabLink] Useful commands:" echo "[LabLink] lablink-status - Check LabLink status" +echo "[LabLink] lablink-version - Show version and git commit info" echo "[LabLink] lablink-logs - View logs" echo "[LabLink] lablink-update - Update to latest code" echo "[LabLink] " @@ -1101,6 +1109,7 @@ fi echo "" echo "Quick Commands:" echo " lablink-status - Show detailed status" +echo " lablink-version - Show version and git commit info" echo " lablink-logs - View logs" echo " lablink-restart - Restart services" echo " lablink-update - Update to latest code" diff --git a/client/api/client.py b/client/api/client.py index e0b1051..a321885 100644 --- a/client/api/client.py +++ b/client/api/client.py @@ -1401,6 +1401,32 @@ def run_pi_diagnostics(self) -> Dict[str, Any]: response.raise_for_status() return response.json() + def run_usb_diagnostics(self, resource_string: str) -> Dict[str, Any]: + """Run USB device diagnostics to troubleshoot connection issues. + + Analyzes why a USB device's serial number may not be readable and + provides recommendations for resolving the issue. + + Args: + resource_string: VISA resource string of the device to diagnose + + Returns: + Dictionary containing: + - resource_string: The analyzed resource string + - has_serial: Whether a serial number is present + - serial_readable: Whether the serial number can be read + - usb_info: USB vendor/product/serial information + - issues: List of detected issues + - recommendations: List of recommended fixes + """ + response = self._session.post( + f"{self.base_url}/equipment/diagnostics/usb", + json={"resource_string": resource_string}, + timeout=10 + ) + response.raise_for_status() + return response.json() + # ==================== State Management API ==================== def capture_state( diff --git a/client/ui/diagnostics_panel.py b/client/ui/diagnostics_panel.py index 2b87970..750772f 100644 --- a/client/ui/diagnostics_panel.py +++ b/client/ui/diagnostics_panel.py @@ -71,6 +71,11 @@ def _setup_ui(self): pi_diagnostics_btn.setToolTip("Run comprehensive diagnostic script on the Raspberry Pi server") button_layout.addWidget(pi_diagnostics_btn) + usb_diagnostics_btn = QPushButton("USB Device Diagnostics") + usb_diagnostics_btn.clicked.connect(self.run_usb_diagnostics) + usb_diagnostics_btn.setToolTip("Diagnose USB connection issues and unreadable serial numbers") + button_layout.addWidget(usb_diagnostics_btn) + layout.addLayout(button_layout) def set_client(self, client: LabLinkClient): @@ -339,3 +344,136 @@ def save_output(): f"Failed to run Pi diagnostics:\n\n{str(e)}\n\n" "Make sure the diagnose-pi.sh script is installed on the server." ) + + def run_usb_diagnostics(self): + """Run USB device diagnostics to troubleshoot connection issues.""" + if not self.client: + QMessageBox.warning( + self, "Not Connected", "Please connect to a server first" + ) + return + + # Ask user for resource string + from PyQt6.QtWidgets import QInputDialog, QComboBox, QDialog, QVBoxLayout, QLabel + + # Get list of discovered devices to help user choose + try: + devices_response = self.client.discover_devices() + devices = devices_response.get("devices", []) + + # Create dialog with device selection + dialog = QDialog(self) + dialog.setWindowTitle("Select Device for USB Diagnostics") + dialog.resize(600, 200) + + layout = QVBoxLayout(dialog) + + layout.addWidget(QLabel("Select a device to diagnose:")) + + device_combo = QComboBox() + for device in devices: + resource_name = device.get("resource_name", "") + if resource_name.startswith("USB"): + # Show resource name and any available device info + label = resource_name + if device.get("manufacturer"): + label += f" ({device['manufacturer']})" + device_combo.addItem(label, resource_name) + + if device_combo.count() == 0: + device_combo.addItem("No USB devices found", "") + + layout.addWidget(device_combo) + + # Add manual entry option + layout.addWidget(QLabel("\nOr enter a resource string manually:")) + from PyQt6.QtWidgets import QLineEdit + manual_input = QLineEdit() + manual_input.setPlaceholderText("e.g., USB0::11975::37376::800886011797210043::0::INSTR") + layout.addWidget(manual_input) + + # Buttons + from PyQt6.QtWidgets import QDialogButtonBox + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + button_box.accepted.connect(dialog.accept) + button_box.rejected.connect(dialog.reject) + layout.addWidget(button_box) + + if dialog.exec() == QDialog.DialogCode.Accepted: + # Use manual input if provided, otherwise use selected device + resource_string = manual_input.text().strip() + if not resource_string: + resource_string = device_combo.currentData() + + if not resource_string: + QMessageBox.warning(self, "No Device", "Please select or enter a device") + return + + # Run diagnostics + try: + diagnostics = self.client.run_usb_diagnostics(resource_string) + + # Format and display results + message = f"USB Device Diagnostics\n" + message += f"{'=' * 50}\n\n" + message += f"Resource String: {diagnostics.get('resource_string', 'N/A')}\n\n" + + usb_info = diagnostics.get('usb_info') + if usb_info: + message += f"USB Information:\n" + message += f" Vendor ID: {usb_info.get('vendor_id', 'N/A')}\n" + message += f" Product ID: {usb_info.get('product_id', 'N/A')}\n" + message += f" Serial Number: {usb_info.get('serial_number', 'N/A')}\n\n" + + message += f"Serial Readable: {'Yes' if diagnostics.get('serial_readable') else 'No'}\n\n" + + issues = diagnostics.get('issues', []) + if issues: + message += f"Issues Detected:\n" + for issue in issues: + message += f" • {issue}\n" + message += "\n" + + recommendations = diagnostics.get('recommendations', []) + if recommendations: + message += f"Recommendations:\n" + for rec in recommendations: + message += f" • {rec}\n" + + # Show results in a scrollable dialog + from PyQt6.QtWidgets import QTextEdit + result_dialog = QDialog(self) + result_dialog.setWindowTitle("USB Diagnostics Results") + result_dialog.resize(700, 500) + + result_layout = QVBoxLayout(result_dialog) + + text_edit = QTextEdit() + text_edit.setReadOnly(True) + text_edit.setPlainText(message) + text_edit.setFontFamily("Monospace") + result_layout.addWidget(text_edit) + + close_btn = QPushButton("Close") + close_btn.clicked.connect(result_dialog.accept) + result_layout.addWidget(close_btn) + + result_dialog.exec() + + except Exception as e: + logger.error(f"Error running USB diagnostics: {e}") + QMessageBox.critical( + self, + "Error", + f"Failed to run USB diagnostics:\n\n{str(e)}" + ) + + except Exception as e: + logger.error(f"Error preparing USB diagnostics: {e}") + QMessageBox.critical( + self, + "Error", + f"Failed to prepare USB diagnostics:\n\n{str(e)}" + ) diff --git a/client/ui/ssh_deploy_wizard.py b/client/ui/ssh_deploy_wizard.py index 6a04c1f..a470ce3 100644 --- a/client/ui/ssh_deploy_wizard.py +++ b/client/ui/ssh_deploy_wizard.py @@ -718,6 +718,7 @@ def _install_convenience_commands(self, ssh, server_path, username): echo "" echo "Status & Monitoring:" echo " lablink-status - Show container status" + echo " lablink-version - Show version and git commit info" echo " lablink-logs - View all logs (follow mode)" echo " lablink-logs-server - View server logs only" echo " lablink-logs-web - View web dashboard logs only" diff --git a/install-server.sh b/install-server.sh index 3551792..5a3a35e 100755 --- a/install-server.sh +++ b/install-server.sh @@ -190,6 +190,17 @@ setup_environment() { else print_step ".env file already exists" fi + + # Make version check script executable + if [ -f "lablink-version.sh" ]; then + chmod +x lablink-version.sh + + # Create symlink in /usr/local/bin for easy access + if [ -w /usr/local/bin ] || [ -n "$SUDO" ]; then + $SUDO ln -sf "$LABLINK_DIR/lablink-version.sh" /usr/local/bin/lablink-version + print_step "Installed 'lablink-version' command" + fi + fi } deploy_with_docker() { @@ -313,6 +324,9 @@ print_success() { echo " Restart: sudo systemctl restart lablink.service" fi + echo "" + echo "Utility Commands:" + echo " Check version: lablink-version" echo "" echo "For help and documentation: https://docs.lablink.io" echo "" diff --git a/lablink-version.sh b/lablink-version.sh new file mode 100644 index 0000000..827c8e7 --- /dev/null +++ b/lablink-version.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# LabLink Version Check Script +# Run this on the Raspberry Pi to check what version/commit is running + +set -e + +echo "======================================" +echo " LabLink Version Information" +echo "======================================" +echo "" + +# Get the LabLink directory - try multiple locations +LABLINK_DIR="" + +# Method 1: Check common deployment locations +for dir in "/opt/lablink" "$HOME/lablink" "$HOME/LabLink"; do + if [ -d "$dir" ] && [ -f "$dir/VERSION" ]; then + LABLINK_DIR="$dir" + break + fi +done + +# Method 2: If not found, try to find it relative to script location (for symlinked command) +if [ -z "$LABLINK_DIR" ]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if [ -f "$SCRIPT_DIR/VERSION" ]; then + LABLINK_DIR="$SCRIPT_DIR" + fi +fi + +# Method 3: Check current directory as last resort +if [ -z "$LABLINK_DIR" ] && [ -f "$(pwd)/VERSION" ]; then + LABLINK_DIR="$(pwd)" +fi + +# Exit if LabLink directory not found +if [ -z "$LABLINK_DIR" ]; then + echo "Error: LabLink directory not found" + echo "" + echo "Searched locations:" + echo " - /opt/lablink (Pi image deployment)" + echo " - $HOME/lablink (SSH deployment)" + echo " - Script directory" + echo " - Current directory" + echo "" + echo "Please ensure you are running this on a system with LabLink installed." + exit 1 +fi + +cd "$LABLINK_DIR" +echo "LabLink Directory: $LABLINK_DIR" +echo "" + +# Get version from VERSION file +if [ -f "VERSION" ]; then + VERSION=$(cat VERSION) + echo "Version: $VERSION" +else + echo "Version: Unknown (VERSION file not found)" +fi + +echo "" + +# Get git information +if [ -d ".git" ]; then + echo "Git Information:" + echo " Branch: $(git rev-parse --abbrev-ref HEAD)" + echo " Commit: $(git rev-parse --short HEAD)" + echo " Date: $(git log -1 --format=%ci)" + echo "" + echo "Latest Commit:" + echo " $(git log -1 --oneline)" + echo "" + + # Check if there are uncommitted changes + if ! git diff-index --quiet HEAD --; then + echo "⚠️ WARNING: Uncommitted changes detected!" + git status --short + else + echo "✓ Working directory is clean" + fi +else + echo "Git Information: Not available (not a git repository)" +fi + +echo "" + +# Check if server is running +echo "Server Status:" +if command -v docker &> /dev/null; then + if docker ps --format '{{.Names}}' | grep -q 'lablink-server'; then + echo " ✓ Docker container 'lablink-server' is running" + + # Try to get version from API + if command -v curl &> /dev/null; then + echo "" + echo "Running Server Version (from API):" + RESPONSE=$(curl -s http://localhost:8000/api/system/version 2>/dev/null || echo "") + if [ -n "$RESPONSE" ]; then + # Extract version and git info using basic parsing + echo "$RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$RESPONSE" + else + echo " Unable to query API (server may not be responding)" + fi + fi + else + echo " ✗ Docker container 'lablink-server' is not running" + fi +elif systemctl is-active --quiet lablink-server 2>/dev/null; then + echo " ✓ LabLink server service is running" +else + echo " ✗ LabLink server is not running" +fi + +echo "" +echo "======================================" diff --git a/server/api/diagnostics.py b/server/api/diagnostics.py index 470999b..c27cca2 100644 --- a/server/api/diagnostics.py +++ b/server/api/diagnostics.py @@ -433,7 +433,15 @@ def _run_diagnostic_on_host(script_path: str) -> dict: except docker.errors.ContainerError as e: # Container exited with non-zero code - output = e.stderr.decode('utf-8', errors='replace') if e.stderr else str(e) + # Handle both bytes and string stderr + if e.stderr: + if isinstance(e.stderr, bytes): + output = e.stderr.decode('utf-8', errors='replace') + else: + output = str(e.stderr) + else: + output = str(e) + return { "success": False, "output": output, diff --git a/server/api/equipment.py b/server/api/equipment.py index fbcc235..152f6b4 100644 --- a/server/api/equipment.py +++ b/server/api/equipment.py @@ -74,6 +74,32 @@ async def discover_devices(): raise HTTPException(status_code=500, detail=str(e)) +class USBDiagnosticsRequest(BaseModel): + """Request to run USB diagnostics.""" + + resource_string: str + + +@router.post("/diagnostics/usb") +async def run_usb_diagnostics(request: USBDiagnosticsRequest): + """ + Run USB diagnostics on a device to troubleshoot connection issues. + + Helps identify why USB serial numbers may be unreadable and provides + recommendations for resolving the issue. + """ + try: + from server.utils.usb_diagnostics import diagnose_usb_device + + diagnostics = diagnose_usb_device(request.resource_string) + logger.info(f"USB diagnostics run for {request.resource_string}") + + return diagnostics + except Exception as e: + logger.error(f"Error running USB diagnostics: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/connect", response_model=dict) async def connect_device(request: ConnectDeviceRequest): """Connect to a device.""" diff --git a/server/equipment/bk_power_supply.py b/server/equipment/bk_power_supply.py index fa55dd1..2005c2b 100644 --- a/server/equipment/bk_power_supply.py +++ b/server/equipment/bk_power_supply.py @@ -506,6 +506,28 @@ async def connect(self): self.cached_info = await self.get_info() except Exception as e: + error_msg = str(e).lower() + + # If device not found, provide diagnostic information + if "not found" in error_msg or "no device" in error_msg: + from server.utils.usb_diagnostics import log_usb_diagnostics + logger.error(f"Device not found at {self.resource_string}") + log_usb_diagnostics(self.resource_string) + logger.error( + "\n" + "=" * 70 + "\n" + "USB DEVICE CONNECTION FAILED\n" + "=" * 70 + "\n" + "If the serial number is unreadable (shows as ???), this indicates\n" + "a USB communication issue after long server uptime.\n\n" + "COMMON FIXES:\n" + " 1. Unplug and replug the USB device\n" + " 2. Restart the LabLink server\n" + " 3. Check USB cable and connection quality\n" + " 4. Update device firmware if available\n" + " 5. Run 'USB Device Diagnostics' from the client's Diagnostic tab\n" + "=" * 70 + ) + logger.error(f"Failed to connect to {self.resource_string}: {e}") self.connected = False raise diff --git a/server/system/version.py b/server/system/version.py index 568c718..ad4d632 100644 --- a/server/system/version.py +++ b/server/system/version.py @@ -1,8 +1,12 @@ """Version management for LabLink.""" +import logging import re +import subprocess from pathlib import Path -from typing import Dict +from typing import Dict, Optional + +logger = logging.getLogger(__name__) # Read version from VERSION file _version_file = Path(__file__).parent.parent.parent / "VERSION" @@ -18,30 +22,119 @@ def get_version() -> str: return __version__ +def get_git_commit_info() -> Dict[str, Optional[str]]: + """Get git commit information for the running server. + + Returns: + Dictionary with git commit details: + - commit_hash: Short commit hash (e.g., "a1b2c3d") + - commit_hash_full: Full commit hash + - branch: Current git branch + - commit_date: Commit date + - commit_message: First line of commit message + """ + git_info = { + "commit_hash": None, + "commit_hash_full": None, + "branch": None, + "commit_date": None, + "commit_message": None, + } + + try: + # Get repository root (go up from server/system/) + repo_root = Path(__file__).parent.parent.parent + + # Get short commit hash + result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + cwd=repo_root, + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0: + git_info["commit_hash"] = result.stdout.strip() + + # Get full commit hash + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo_root, + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0: + git_info["commit_hash_full"] = result.stdout.strip() + + # Get branch name + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=repo_root, + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0: + git_info["branch"] = result.stdout.strip() + + # Get commit date + result = subprocess.run( + ["git", "log", "-1", "--format=%ci"], + cwd=repo_root, + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0: + git_info["commit_date"] = result.stdout.strip() + + # Get commit message (first line) + result = subprocess.run( + ["git", "log", "-1", "--format=%s"], + cwd=repo_root, + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0: + git_info["commit_message"] = result.stdout.strip() + + except Exception as e: + logger.debug(f"Unable to get git commit info: {e}") + + return git_info + + def get_version_info() -> Dict[str, any]: """Get detailed version information. Returns: - Dictionary with version details including major, minor, patch + Dictionary with version details including major, minor, patch, and git info """ # Parse version string (format: major.minor.patch) match = re.match(r"(\d+)\.(\d+)\.(\d+)", __version__) if match: major, minor, patch = match.groups() - return { + version_info = { "version": __version__, "major": int(major), "minor": int(minor), "patch": int(patch), } + else: + # Fallback if version doesn't match expected format + version_info = { + "version": __version__, + "major": 0, + "minor": 0, + "patch": 0, + } - # Fallback if version doesn't match expected format - return { - "version": __version__, - "major": 0, - "minor": 0, - "patch": 0, - } + # Add git commit information + version_info["git"] = get_git_commit_info() + + return version_info def compare_versions(version1: str, version2: str) -> int: diff --git a/server/utils/usb_diagnostics.py b/server/utils/usb_diagnostics.py new file mode 100644 index 0000000..1b9f4fb --- /dev/null +++ b/server/utils/usb_diagnostics.py @@ -0,0 +1,105 @@ +"""USB Device Diagnostics for troubleshooting serial number reading issues.""" + +import logging +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + + +def diagnose_usb_device(resource_string: str) -> Dict[str, any]: + """ + Diagnose USB device and provide detailed information about why + serial number might not be readable. + + Args: + resource_string: VISA resource string of the device + + Returns: + Dictionary with diagnostic information + """ + diagnostics = { + "resource_string": resource_string, + "has_serial": False, + "serial_readable": False, + "usb_info": None, + "issues": [], + "recommendations": [] + } + + # Check if this is a USB device + if not resource_string.startswith("USB"): + diagnostics["issues"].append("Not a USB device") + return diagnostics + + # Parse USB resource string + # Format: USB[board]::vendor::product::serial::interface::INSTR + parts = resource_string.split("::") + if len(parts) < 5: + diagnostics["issues"].append(f"Invalid USB resource string format: {resource_string}") + return diagnostics + + vendor_id = parts[1] if len(parts) > 1 else None + product_id = parts[2] if len(parts) > 2 else None + serial = parts[3] if len(parts) > 3 else None + + diagnostics["usb_info"] = { + "vendor_id": vendor_id, + "product_id": product_id, + "serial_number": serial + } + + # Check if serial number is present and readable + if serial and serial != "???": + diagnostics["has_serial"] = True + diagnostics["serial_readable"] = True + elif serial == "???": + diagnostics["has_serial"] = False + diagnostics["serial_readable"] = False + diagnostics["issues"].append("USB serial number descriptor cannot be read") + + # Add detailed root cause analysis + diagnostics["issues"].extend([ + "Possible causes:", + "1. USB communication issue - device may need to be unplugged/replugged", + "2. Long server uptime - USB subsystem may be in stale state", + "3. Device firmware issue - serial number descriptor may be corrupt", + "4. PyUSB/libusb backend issue - driver may not support reading this descriptor", + "5. USB permissions - process may not have permission to read device descriptors" + ]) + + # Add recommendations + diagnostics["recommendations"].extend([ + "Try unplugging and replugging the USB device", + "Restart the LabLink server to refresh USB subsystem", + "Check USB cable and connection quality", + "Update device firmware if available", + "On Linux: check udev rules and user permissions for USB devices", + "On Windows: check if libusb drivers are properly installed" + ]) + else: + diagnostics["has_serial"] = False + diagnostics["serial_readable"] = False + diagnostics["issues"].append("No serial number in resource string") + + return diagnostics + + +def log_usb_diagnostics(resource_string: str) -> None: + """Log comprehensive USB diagnostics for a device.""" + diag = diagnose_usb_device(resource_string) + + logger.warning(f"USB Device Diagnostics for {resource_string}:") + logger.warning(f" Vendor ID: {diag['usb_info']['vendor_id'] if diag['usb_info'] else 'N/A'}") + logger.warning(f" Product ID: {diag['usb_info']['product_id'] if diag['usb_info'] else 'N/A'}") + logger.warning(f" Serial Number: {diag['usb_info']['serial_number'] if diag['usb_info'] else 'N/A'}") + logger.warning(f" Serial Readable: {diag['serial_readable']}") + + if diag['issues']: + logger.warning(" Issues detected:") + for issue in diag['issues']: + logger.warning(f" - {issue}") + + if diag['recommendations']: + logger.warning(" Recommendations:") + for rec in diag['recommendations']: + logger.warning(f" - {rec}")