From 62a5e23ad503bfe1fd892501e59f6c9d8c375c51 Mon Sep 17 00:00:00 2001 From: Chris Gerth Date: Thu, 25 Jun 2026 16:56:31 -0500 Subject: [PATCH 1/6] skeleton --- photon-sc-app/build.sh | 94 ++++++++++++++++++ photon-sc-app/control/control | 12 +++ photon-sc-app/control/postinst | 42 ++++++++ photon-sc-app/control/postrm | 17 ++++ photon-sc-app/control/prerm | 25 +++++ .../etc/systemd/system/photon-sc-app.service | 27 +++++ .../etc/systemd/system/photon-sc-app.socket | 18 ++++ .../overlay/usr/share/photon-sc-app.png | Bin 0 -> 7886 bytes 8 files changed, 235 insertions(+) create mode 100644 photon-sc-app/build.sh create mode 100644 photon-sc-app/control/control create mode 100644 photon-sc-app/control/postinst create mode 100644 photon-sc-app/control/postrm create mode 100644 photon-sc-app/control/prerm create mode 100644 photon-sc-app/overlay/etc/systemd/system/photon-sc-app.service create mode 100644 photon-sc-app/overlay/etc/systemd/system/photon-sc-app.socket create mode 100644 photon-sc-app/overlay/usr/share/photon-sc-app.png diff --git a/photon-sc-app/build.sh b/photon-sc-app/build.sh new file mode 100644 index 0000000000..dfbaa2d2ef --- /dev/null +++ b/photon-sc-app/build.sh @@ -0,0 +1,94 @@ +#!/bin/bash + +set -e + +# Extract package info from control/control file +if [ ! -f "control/control" ]; then + echo "Error: control/control not found!" + echo "Create a control/control file with package metadata" + exit 1 +fi + +# Parse package name and version from control file +PACKAGE_NAME=$(grep "^Package:" control/control | cut -d' ' -f2- | tr -d ' ') +PACKAGE_VERSION=$(grep "^Version:" control/control | cut -d' ' -f2- | tr -d ' ') + +# Validate required fields +if [ -z "$PACKAGE_NAME" ] || [ -z "$PACKAGE_VERSION" ]; then + echo "Err: Package and Version must be set in control/control" + echo "Package: my-package" + echo "Version: 1.0.0" + exit 1 +fi + +PACKAGE_DIR="${PACKAGE_NAME}_${PACKAGE_VERSION}" +BUILD_DIR="build" + +echo "Building IPK package from overlay structure..." +echo "Package: ${PACKAGE_NAME}_${PACKAGE_VERSION}.ipk" + +if [ ! -d "overlay" ]; then + echo "overlay/ directory not found" + exit 1 +fi + +if [ ! -d "control" ]; then + echo "Error: control/ directory not found!" + echo "Create control/ with control, postinst, prerm, postrm files" + exit 1 +fi + +echo "Cleaning previous build..." +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR/$PACKAGE_DIR" + +echo "Copying overlay structure..." +cp -r overlay/* "$BUILD_DIR/$PACKAGE_DIR/" + +echo "Copying CONTROL files..." +mkdir -p "$BUILD_DIR/$PACKAGE_DIR/CONTROL" +cp control/* "$BUILD_DIR/$PACKAGE_DIR/CONTROL/" + +echo "Setting file permissions..." + +# Make scripts executable +find "$BUILD_DIR/$PACKAGE_DIR" -name "*.py" -exec chmod +x {} \; + +if [ -d "$BUILD_DIR/$PACKAGE_DIR/CONTROL" ]; then + chmod +x "$BUILD_DIR/$PACKAGE_DIR/CONTROL"/* 2>/dev/null || true +fi + +find "$BUILD_DIR/$PACKAGE_DIR" -name "*.sh" -exec chmod +x {} \; + +echo "Building IPK dir structure" +cd "$BUILD_DIR" + +echo "Creating data.tar.gz" +tar --exclude='CONTROL' -czf data.tar.gz -C "$PACKAGE_DIR" . + +echo "Creating control.tar.gz" +tar -czf control.tar.gz -C "$PACKAGE_DIR/CONTROL" . + +echo "Creating IPK..." +ar r "../${PACKAGE_NAME}_${PACKAGE_VERSION}.ipk" control.tar.gz data.tar.gz + +cd .. + +echo "" +echo "IPK package created." +echo "Package: ${PACKAGE_NAME}_${PACKAGE_VERSION}.ipk" +echo "" +echo "Package structure:" +echo " CONTROL files:" +find control -type f | sort | sed 's/^/ /' +echo " Overlay files (will be installed):" +find overlay -type f | sort | sed 's/^overlay/ /' | head -15 + +if [ $(find overlay -type f | wc -l) -gt 15 ]; then + echo " ... and $(($(find overlay -type f | wc -l) - 15)) more files" +fi + +rm -rf "$BUILD_DIR" + +echo "" +echo "Build complete" \ No newline at end of file diff --git a/photon-sc-app/control/control b/photon-sc-app/control/control new file mode 100644 index 0000000000..9da746e655 --- /dev/null +++ b/photon-sc-app/control/control @@ -0,0 +1,12 @@ +Package: photon-sc-app +Version: 1.0.0 +Description: PhotonVision SystemCore App +Section: development +Priority: optional +Maintainer: PhotonVision +Architecture: all +Source: local +X-Port: 9042 +X-Has-UI: true +X-Auto-Start: false +X-Icon-Path: /usr/share/photon-sc-app.png \ No newline at end of file diff --git a/photon-sc-app/control/postinst b/photon-sc-app/control/postinst new file mode 100644 index 0000000000..9879873262 --- /dev/null +++ b/photon-sc-app/control/postinst @@ -0,0 +1,42 @@ +#!/bin/sh +PACKAGE_NAME="photon-sc-app" + +echo "Setting up socket activation for $PACKAGE_NAME" + +# Stop any existing instances (cleanup from previous versions) +systemctl stop $PACKAGE_NAME.service 2>/dev/null || true +systemctl stop $PACKAGE_NAME.socket 2>/dev/null || true +systemctl disable $PACKAGE_NAME.service 2>/dev/null || true +systemctl daemon-reload + +# Enable ONLY the socket unit +if systemctl enable $PACKAGE_NAME.socket; then + echo "Socket unit enabled successfully" +else + echo "Failed to enable socket unit" + exit 1 +fi + +# Start listening +if systemctl start $PACKAGE_NAME.socket; then + echo "Socket started successfully" +else + echo "Failed to start socket" + systemctl status $PACKAGE_NAME.socket --no-pager + exit 1 +fi + +# Verify +sleep 1 + +if systemctl is-active --quiet $PACKAGE_NAME.socket; then + echo "Socket activation configured successfully." +else + echo "Socket failed to activate" + echo "Debug info:" + systemctl status $PACKAGE_NAME.socket --no-pager || true + journalctl -u $PACKAGE_NAME.socket --no-pager -n 10 || true + exit 1 +fi + +exit 0 \ No newline at end of file diff --git a/photon-sc-app/control/postrm b/photon-sc-app/control/postrm new file mode 100644 index 0000000000..26cea64f40 --- /dev/null +++ b/photon-sc-app/control/postrm @@ -0,0 +1,17 @@ +#!/bin/sh +PACKAGE_NAME="photon-sc-app" + +echo "Cleaning up socket activation for $PACKAGE_NAME" + +systemctl daemon-reload +systemctl reset-failed $PACKAGE_NAME.socket 2>/dev/null || true +systemctl reset-failed $PACKAGE_NAME.service 2>/dev/null || true + +if systemctl list-units --all | grep -q "$PACKAGE_NAME"; then + echo "Some $PACKAGE_NAME units may still be present (this is normal until reboot)" +else + echo "All $PACKAGE_NAME units cleaned up" +fi + +echo "Socket activation cleanup completed" +exit 0 \ No newline at end of file diff --git a/photon-sc-app/control/prerm b/photon-sc-app/control/prerm new file mode 100644 index 0000000000..dcaafb37b9 --- /dev/null +++ b/photon-sc-app/control/prerm @@ -0,0 +1,25 @@ +#!/bin/sh +PACKAGE_NAME="photon-sc-app" + +echo "Stopping socket activation for $PACKAGE_NAME" + +if systemctl stop $PACKAGE_NAME.socket 2>/dev/null; then + echo "Socket stopped" +else + echo "Socket was not running" +fi + +if systemctl stop $PACKAGE_NAME.service 2>/dev/null; then + echo "Service stopped" +else + echo "Service was not running" +fi + +if systemctl disable $PACKAGE_NAME.socket 2>/dev/null; then + echo "Socket disabled" +else + echo " Socket was not enabled" +fi + +echo "Socket activation stopped and disabled" +exit 0 \ No newline at end of file diff --git a/photon-sc-app/overlay/etc/systemd/system/photon-sc-app.service b/photon-sc-app/overlay/etc/systemd/system/photon-sc-app.service new file mode 100644 index 0000000000..e24baff1d5 --- /dev/null +++ b/photon-sc-app/overlay/etc/systemd/system/photon-sc-app.service @@ -0,0 +1,27 @@ +[Unit] +Description=PhotonVision SystemCore App service +Requires=photon-sc-app.socket +After=photon-sc-app.socket +DefaultDependencies=no + +[Service] +Type=simple +User=root +Group=root +ExecStart=/usr/local/bin/photon-sc-app/photon_sc_app.py + +Restart=no + +KillMode=mixed +TimeoutStopSec=3 + + +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/log + +#don't enable this service unit +[Install] +######### \ No newline at end of file diff --git a/photon-sc-app/overlay/etc/systemd/system/photon-sc-app.socket b/photon-sc-app/overlay/etc/systemd/system/photon-sc-app.socket new file mode 100644 index 0000000000..772495ec46 --- /dev/null +++ b/photon-sc-app/overlay/etc/systemd/system/photon-sc-app.socket @@ -0,0 +1,18 @@ +[Unit] +Description=photon-sc-app Socket + +[Socket] +ListenStream=9002 +Accept=no + +SocketUser=root +SocketGroup=root +SocketMode=0660 + +NoDelay=true +KeepAlive=true + +DefaultDependencies=no + +[Install] +WantedBy=sockets.target \ No newline at end of file diff --git a/photon-sc-app/overlay/usr/share/photon-sc-app.png b/photon-sc-app/overlay/usr/share/photon-sc-app.png new file mode 100644 index 0000000000000000000000000000000000000000..1eebb28d3ba4418b8057c1ca61a7217d398144b7 GIT binary patch literal 7886 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 C(z!SQ literal 0 HcmV?d00001 From 475c3926a129a516a96aee02fb00f6a94da0f9c7 Mon Sep 17 00:00:00 2001 From: Chris Gerth Date: Fri, 26 Jun 2026 12:38:23 -0500 Subject: [PATCH 2/6] refactor(photon-sc-app): add comprehensive type hints, formatting, and documentation - Add complete type hints throughout Python backend (photon_sc_app.py) * All function signatures include parameter and return type annotations * Use Optional[], Dict[], list[] generic types for clarity * Union types for service instantiation polymorphism - Format code with black for consistency and PEP 8 compliance - Enhance docstrings across all classes and methods * Module-level overview explaining architecture and deployment modes * SocketActivatedService: systemd socket activation protocol documentation * LocalService: development server details * ServiceHandler: HTTP routing and status/tabs endpoint details * Comprehensive signal_handler documentation - Update frontend JavaScript with detailed JSDoc comments * Explain tab switching logic and event handling * Document URL parsing and error handling in fetchTabs() * Clear function-level comments with parameters and return types - Restore iframe sandbox attribute for security - Remove broken cross-origin proxy endpoint - Add CI/CD integration: build-ipk job to GitHub Actions * Automatically build .ipk artifacts on push * Upload to release assets - Add run_local.sh and run_local.bat for convenient local development - Update .gitignore for build artifacts (photon-sc-app/build/, *.ipk) All Python code now passes syntax validation and black formatting checks. --- .github/workflows/build.yml | 29 +- .gitignore | 4 + .../local/bin/photon-sc-app/photon_sc_app.py | 309 ++++++++++++++++++ .../local/bin/photon-sc-app/www/index.html | 25 ++ .../usr/local/bin/photon-sc-app/www/script.js | 113 +++++++ photon-sc-app/run_local.bat | 5 + photon-sc-app/run_local.sh | 8 + 7 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 photon-sc-app/overlay/usr/local/bin/photon-sc-app/photon_sc_app.py create mode 100644 photon-sc-app/overlay/usr/local/bin/photon-sc-app/www/index.html create mode 100644 photon-sc-app/overlay/usr/local/bin/photon-sc-app/www/script.js create mode 100644 photon-sc-app/run_local.bat create mode 100755 photon-sc-app/run_local.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b0b40a8233..86825ab7ed 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -424,6 +424,27 @@ jobs: steps: *build-package-steps + build-ipk: + needs: [validation] + if: github.event_name == 'push' + runs-on: ubuntu-24.04 + name: "Build photon-sc-app IPK" + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Make build script executable + run: chmod +x photon-sc-app/build.sh + - name: Build IPK + working-directory: photon-sc-app + run: ./build.sh + - name: Upload IPK artifact + uses: actions/upload-artifact@v7 + with: + archive: false + name: photon-sc-ipk + path: '*.ipk' + run-smoketest-native: needs: [build-package-linux, build-package-macos, build-package-windows] @@ -629,7 +650,7 @@ jobs: release: # Require smoketest-native so that if those fail, we don't release broken artifacts - needs: [build-photonlib-vendorjson, build-image, combine, build-package-linux, build-package-macos, build-package-windows, run-smoketest-native] + needs: [build-photonlib-vendorjson, build-image, combine, build-package-linux, build-package-macos, build-package-windows, run-smoketest-native, build-ipk] if: (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && github.repository == 'PhotonVision/photonvision' runs-on: ubuntu-24.04 steps: @@ -652,6 +673,11 @@ jobs: with: merge-multiple: true pattern: photonvision-*.xz + # Download IPK package + - uses: actions/download-artifact@v8 + with: + name: photon-sc-ipk + path: . - run: find # Push to dev release @@ -667,6 +693,7 @@ jobs: **/*win*.jar **/photonlib*.json **/photonlib*.zip + **/*.ipk if: github.event_name == 'push' - name: Create Vendor JSON Repo PR uses: wpilibsuite/vendor-json-repo/.github/actions/add_vendordep@HEAD diff --git a/.gitignore b/.gitignore index 032a1d7e11..1a01a011da 100644 --- a/.gitignore +++ b/.gitignore @@ -150,6 +150,10 @@ dist-ssr components.d.ts photon-server/src/main/resources/web/index.html +# photon-sc-app IPK build artifacts +photon-sc-app/build/ +photon-sc-app/*.ipk + # Playwright photon-client/test-results/ photon-client/playwright-report/ diff --git a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/photon_sc_app.py b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/photon_sc_app.py new file mode 100644 index 0000000000..6f1d22c1c4 --- /dev/null +++ b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/photon_sc_app.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +""" +Photon SC App - Tab Dashboard HTTP Server + +This service serves a dynamic tab-based dashboard UI that can display multiple +external web content in iframes. It supports two deployment modes: + +1. Systemd socket activation (production): The service is managed by systemd + and receives a pre-bound socket via FD 3. + +2. Local development mode (--local flag): Direct HTTP server on localhost. + +Key Features: +- Dynamic tab list via /tabs API endpoint +- Service status endpoint at /status for health checks +- Graceful shutdown via SIGINT/SIGTERM +""" + +import argparse +import http.server +import json +import os +import signal +import socket +import socketserver +import sys +from datetime import datetime +from typing import Any, Dict, Optional + +# Global flag to track deployment mode (True for systemd, False for local) +SOCKET_ACTIVATED: bool = False + + +class ServiceHandler(http.server.SimpleHTTPRequestHandler): + """HTTP request handler for the Photon SC App. + + Extends SimpleHTTPRequestHandler to add custom endpoints while maintaining + the ability to serve static files from the 'www' directory. + """ + + def __init__( + self, *args: Any, directory: Optional[str] = None, **kwargs: Any + ) -> None: + """Initialize handler with www directory for static file serving. + + Args: + directory: Optional directory path. Defaults to 'www' subdirectory. + """ + www_dir = directory or os.path.join(os.path.dirname(__file__), "www") + super().__init__(*args, directory=www_dir, **kwargs) + + def do_GET(self) -> None: + """Route incoming GET requests to appropriate handlers.""" + # Serve index.html for root requests + if self.path in ("/", "/index.html", ""): + self.path = "/index.html" + return super().do_GET() + + # Service status endpoint for health checks + if self.path in ("/status", "/health"): + return self.send_status() + + # Dynamic tab configuration endpoint + if self.path == "/tabs": + return self.send_tabs() + + # Default: serve static files from www directory + return super().do_GET() + + def send_status(self) -> None: + """Return service status as JSON for health monitoring.""" + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + + response: Dict[str, Any] = { + "service": os.path.basename(os.path.dirname(__file__)), + "status": "running", + "socket_activated": SOCKET_ACTIVATED, + "pid": os.getpid(), + "timestamp": datetime.now().isoformat(), + } + + self.wfile.write(json.dumps(response, indent=2).encode()) + + def send_tabs(self) -> None: + """Return list of available tabs as JSON. + + The frontend fetches this on startup and refresh to populate the tab bar. + Tab format: {"title": "Display Name", "url": "https://..."} + + TODO: Make this configurable from a file or database. + """ + tabs: list[Dict[str, str]] = [ + {"title": "Example", "url": "https://example.com"}, + {"title": "Docs", "url": "https://docs.photonvision.org"}, + ] + + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"tabs": tabs}, indent=2).encode()) + + +class SocketActivatedService: + """Service wrapper for systemd socket activation mode (production). + + In this mode, systemd creates a listening socket and passes it to the + service via file descriptor 3. This separation of concerns allows: + + - Privileged socket binding (e.g., port 80/443) without service privileges + - Socket reuse across service restarts without TIME_WAIT delays + - Systemd to manage service lifecycle and auto-restart on failure + - Better integration with systemd security features and resource limits + """ + + def __init__(self) -> None: + """Initialize the socket-activated service.""" + self.httpd: Optional[socketserver.ThreadingTCPServer] = None + + def get_systemd_socket(self) -> socket.socket: + """Retrieve and validate the socket passed by systemd. + + Systemd sets LISTEN_PID and LISTEN_FDS environment variables: + - LISTEN_PID: Our process ID (to ensure the socket is for us) + - LISTEN_FDS: Number of file descriptors (should be >= 1) + + The first socket is always FD 3 (by convention): + - FD 0 = stdin + - FD 1 = stdout + - FD 2 = stderr + - FD 3+ = sockets/files from systemd + + We validate all three conditions before returning the socket. + + Returns: + A socket object for the systemd-provided listening socket. + + Raises: + RuntimeError: If not started by systemd or validation fails. + """ + listen_pid = os.environ.get("LISTEN_PID") + listen_fds = os.environ.get("LISTEN_FDS") + + if not listen_pid or not listen_fds: + raise RuntimeError("Not started by systemd socket activation") + + if int(listen_pid) != os.getpid(): + raise RuntimeError( + f"PID mismatch: expected {listen_pid}, got {os.getpid()}" + ) + + if int(listen_fds) < 1: + raise RuntimeError(f"No sockets provided: LISTEN_FDS={listen_fds}") + + # Convert file descriptor to a Python socket object + sock = socket.fromfd(3, socket.AF_INET, socket.SOCK_STREAM) + return sock + + def start(self) -> None: + """Start the HTTP server using the systemd-provided socket. + + Key details: + - bind_and_activate=False: Socket from systemd is already bound/listening + - daemon_threads=True: Allows quick shutdown (threads don't block exit) + - poll_interval=0.5: Checks signals frequently for responsive Ctrl-C + """ + server_socket = self.get_systemd_socket() + + # Create threaded server WITHOUT calling bind() or listen() + # The socket from systemd is already bound and listening + self.httpd = socketserver.ThreadingTCPServer( + ("", 0), ServiceHandler, bind_and_activate=False + ) + self.httpd.daemon_threads = True # Allow quick shutdown + self.httpd.socket = server_socket # Replace with systemd socket + + print(f"Service started (PID: {os.getpid()})") + # Poll interval allows signal handler to interrupt serve_forever() + self.httpd.serve_forever(poll_interval=0.5) + + def stop(self) -> None: + """Cleanly shut down the server and release resources.""" + if self.httpd: + self.httpd.shutdown() + self.httpd.server_close() + + +def signal_handler(signum: int, frame: Any) -> None: + """Handle SIGINT (Ctrl-C) and SIGTERM signals for graceful shutdown. + + Prints a message and exits, allowing the main exception handler to run + cleanup. We don't directly call service.stop() here because: + - If called from a signal handler during blocking I/O, it may not work cleanly + - Using sys.exit() allows the exception handler to call stop() properly + + Args: + signum: Signal number received. + frame: Current stack frame. + """ + print("\nShutdown signal received, exiting.") + sys.exit(0) + + +class LocalService: + """Service wrapper for local development mode (--local flag). + + Runs a standard threaded HTTP server on localhost without systemd. + This is convenient for development without requiring: + - Systemd service file setup + - Privileged socket binding + - Special environment variables + + Default: localhost:8080 (customizable via --host and --port arguments) + """ + + def __init__(self, host: str = "127.0.0.1", port: int = 8080) -> None: + """Initialize the local service. + + Args: + host: Hostname to bind to. Defaults to '127.0.0.1'. + port: Port to bind to. Defaults to 8080. + """ + self.httpd: Optional[http.server.ThreadingHTTPServer] = None + self.host = host + self.port = port + + def start(self) -> None: + """Start the HTTP server on the specified host:port. + + Key details: + - ThreadingHTTPServer: Handles each request in a separate thread + - daemon_threads=True: Threads don't block shutdown + - allow_reuse_address=True: Allows quick restart without TIME_WAIT + - poll_interval=0.5: Responsive to Ctrl-C and other signals + """ + self.httpd = http.server.ThreadingHTTPServer( + (self.host, self.port), ServiceHandler + ) + self.httpd.daemon_threads = True # Allow quick shutdown + self.httpd.allow_reuse_address = True # Reuse port after restart + print(f"Local service started at http://{self.host}:{self.port}") + # Poll interval allows signal handler to interrupt serve_forever() + self.httpd.serve_forever(poll_interval=0.5) + + def stop(self) -> None: + """Cleanly shut down the server and release resources.""" + if self.httpd: + self.httpd.shutdown() + self.httpd.server_close() + + +def main() -> None: + """Entry point: parse arguments, select deployment mode, and start service. + + Deployment modes: + + 1. Systemd socket activation (default): + - Requires LISTEN_PID and LISTEN_FDS environment variables + - Best for production with systemd + + 2. Local development (--local flag): + - Direct TCP server on localhost + - Customizable host/port via --host and --port + """ + parser = argparse.ArgumentParser(description="Photon SC App service") + parser.add_argument( + "--local", + action="store_true", + help="Run in local development mode without systemd socket activation", + ) + parser.add_argument( + "--host", + default="127.0.0.1", + help="Local host address when running in local mode", + ) + parser.add_argument( + "--port", type=int, default=8080, help="Local port when running in local mode" + ) + args = parser.parse_args() + + # Select deployment mode based on --local flag + if not args.local: + # Production: systemd socket activation + if not os.environ.get("LISTEN_PID") or not os.environ.get("LISTEN_FDS"): + print( + "ERROR: Must be started by systemd socket activation or use --local " + "for development mode" + ) + sys.exit(1) + service: SocketActivatedService | LocalService = SocketActivatedService() + else: + # Development: local server + service = LocalService(host=args.host, port=args.port) + + # Register signal handlers for clean shutdown + signal.signal(signal.SIGINT, signal_handler) # Ctrl-C + signal.signal(signal.SIGTERM, signal_handler) # Termination request + + try: + service.start() + except (KeyboardInterrupt, SystemExit): + # Ensure cleanup happens on any exit (Ctrl-C, signal, etc.) + service.stop() + print("Server stopped.") + + +if __name__ == "__main__": + main() diff --git a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/www/index.html b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/www/index.html new file mode 100644 index 0000000000..12fc2eb27a --- /dev/null +++ b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/www/index.html @@ -0,0 +1,25 @@ + + + + + + Photon SC App + + + +
+
+
+ +
+ +
+
+ +
+
+
+ + + + diff --git a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/www/script.js b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/www/script.js new file mode 100644 index 0000000000..6c6a8502e4 --- /dev/null +++ b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/www/script.js @@ -0,0 +1,113 @@ +/** + * Photon SC App - Frontend Tab Dashboard + * + * This script manages the dynamic tab interface for loading external web content + * in iframes. It handles: + * - Fetching the tab list from the backend + * - Rendering tab buttons + * - Loading content in the iframe + */ + +const tabList = document.getElementById('tabList'); +const tabFrame = document.getElementById('tabFrame'); +const refreshButton = document.getElementById('refreshButton'); +let tabs = []; +let activeTabIndex = 0; + +/** + * Fetch the list of tabs from the backend. + * + * Called on page load and when user clicks the "Update Clients" button. + * The /tabs endpoint returns a JSON array of tab objects: + * [ + * {"title": "Example", "url": "https://example.com"}, + * ... + * ] + */ +async function fetchTabs() { + try { + const response = await fetch('/tabs'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const payload = await response.json(); + tabs = Array.isArray(payload.tabs) ? payload.tabs : []; + renderTabs(); + + // Automatically load the first tab if available + if (tabs.length > 0) { + setActiveTab(0); + } else { + tabList.innerHTML = '
No tabs available
'; + tabFrame.src = 'about:blank'; + } + } catch (error) { + console.error('Failed to load tabs:', error); + tabList.innerHTML = '
Unable to load tabs
'; + tabFrame.src = 'about:blank'; + } +} + +/** + * Render tab buttons based on the current tabs list. + * + * Creates a button for each tab in the tabs array and adds click handlers. + * The buttons are styled with CSS and the active one is highlighted. + */ +function renderTabs() { + tabList.innerHTML = ''; + + tabs.forEach((tab, index) => { + const button = document.createElement('button'); + button.className = 'tab-button'; + button.textContent = tab.title || `Tab ${index + 1}`; + // Click handler to switch to this tab + button.addEventListener('click', () => setActiveTab(index)); + tabList.appendChild(button); + }); + + // Update visual highlighting of the active tab + updateActiveTabStyles(); +} + +/** + * Set the active tab and load its content. + * + * This is the main function that handles tab switching: + * 1. Validates the index is in range + * 2. Gets the URL from the selected tab + * 3. Loads the URL in the iframe + * 4. Updates visual styling + * + * @param {number} index - Index of tab to activate + */ +function setActiveTab(index) { + if (index < 0 || index >= tabs.length) { + return; + } + + activeTabIndex = index; + const tab = tabs[index]; + tabFrame.src = tab.url || 'about:blank'; + + updateActiveTabStyles(); + + +/** + * Update visual styling to highlight the active tab. + * + * Adds the 'active' CSS class to the selected tab button and removes it + * from others. The CSS provides visual highlighting (different background + * color, border color, etc.). + */ +function updateActiveTabStyles() { + const buttons = tabList.querySelectorAll('.tab-button'); + buttons.forEach((button, index) => { + button.classList.toggle('active', index === activeTabIndex); + }); +} + +// Event listeners +refreshButton.addEventListener('click', fetchTabs); // "Update Clients" button +window.addEventListener('load', fetchTabs); // Load tabs on page load diff --git a/photon-sc-app/run_local.bat b/photon-sc-app/run_local.bat new file mode 100644 index 0000000000..7080590524 --- /dev/null +++ b/photon-sc-app/run_local.bat @@ -0,0 +1,5 @@ +@echo off +rem Run the Photon SC App in local development mode +set SCRIPT_DIR=%~dp0 +cd /d "%SCRIPT_DIR%" +python "%SCRIPT_DIR%overlay\usr\local\bin\photon-sc-app\photon_sc_app.py" --local %* diff --git a/photon-sc-app/run_local.sh b/photon-sc-app/run_local.sh new file mode 100755 index 0000000000..c092045b7a --- /dev/null +++ b/photon-sc-app/run_local.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +cd "$SCRIPT_DIR" + +echo "Starting Photon SC App in local development mode..." +python3 "${SCRIPT_DIR}/overlay/usr/local/bin/photon-sc-app/photon_sc_app.py" --local "$@" From 087036cf3142b49d036f15e16d3540fbed160f20 Mon Sep 17 00:00:00 2001 From: Chris Gerth Date: Fri, 26 Jun 2026 13:00:20 -0500 Subject: [PATCH 3/6] feat(photon-sc-app): add background discovery cache with configurable refresh intervals Discovery Suite Architecture: - Modular discovery strategies: mDNS, network scan (10.TE.AM.0/24), port 5800 check, NetworkTables - Background DiscoveryCache thread for low-CPU periodic updates - Independent refresh intervals for fast (mDNS, port checks) vs slow (network scan) strategies Background Discovery Features: - DiscoveryCache class manages periodic discovery in daemon thread - Fast strategies default to 10-second cycles (mDNS, port verification) - Slow strategies default to 60-second cycles (network scan, NetworkTables) - Tunable via command-line args: --discovery-fast-interval, --discovery-slow-interval - Can disable fast/slow strategies with --discovery-disable-fast/--discovery-disable-slow - Thread-safe cache using RLock, responsive to stop signal with 0.5s sleep precision - Low CPU overhead: sleeps most of the time, wakes only for periodic discovery ServiceHandler Integration: - /tabs endpoint now serves results from DiscoveryCache instead of blocking on discovery - Instant response even if discovery is running (cache coherency via lock) - Multiple requests don't trigger redundant discovery Both SocketActivatedService and LocalService now: - Create and pass DiscoveryCache to request handlers - Start discovery thread on service startup - Cleanly stop discovery thread on shutdown Additional improvements: - Enhanced styles.css with .empty-state styling for no-dashboards case - Updated logger to use string formatting instead of print() - Added team number to discovery log output - Updated docstrings to explain background caching architecture --- .../bin/photon-sc-app/discovery/__init__.py | 8 + .../bin/photon-sc-app/discovery/aggregator.py | 108 ++++++++++ .../bin/photon-sc-app/discovery/cache.py | 152 ++++++++++++++ .../photon-sc-app/discovery/mdns_discovery.py | 38 ++++ .../discovery/network_scan_discovery.py | 92 +++++++++ .../discovery/networktables_discovery.py | 84 ++++++++ .../discovery/port_check_discovery.py | 65 ++++++ .../local/bin/photon-sc-app/photon_sc_app.py | 145 +++++++++++-- .../usr/local/bin/photon-sc-app/www/script.js | 2 +- .../local/bin/photon-sc-app/www/styles.css | 195 ++++++++++++++++++ 10 files changed, 873 insertions(+), 16 deletions(-) create mode 100644 photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/__init__.py create mode 100644 photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/aggregator.py create mode 100644 photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/cache.py create mode 100644 photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/mdns_discovery.py create mode 100644 photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/network_scan_discovery.py create mode 100644 photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/networktables_discovery.py create mode 100644 photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/port_check_discovery.py create mode 100644 photon-sc-app/overlay/usr/local/bin/photon-sc-app/www/styles.css diff --git a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/__init__.py b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/__init__.py new file mode 100644 index 0000000000..8b694e71a7 --- /dev/null +++ b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/__init__.py @@ -0,0 +1,8 @@ +"""Discovery suite for finding PhotonVision dashboards on the network. + +Provides multiple strategies to locate PhotonVision instances: +- mDNS discovery via photonvision.local +- Network scanning on FRC team networks (10.TE.AM.XX) +- Port 5800 verification +- NetworkTables client discovery +""" diff --git a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/aggregator.py b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/aggregator.py new file mode 100644 index 0000000000..e723776dee --- /dev/null +++ b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/aggregator.py @@ -0,0 +1,108 @@ +"""Discovery aggregator - combines multiple strategies into a single list. + +This module orchestrates all discovery strategies: +1. mDNS (photonvision.local) +2. Network scanning (10.TE.AM.XX) +3. Port 5800 verification +4. NetworkTables discovery + +Results are merged, deduplicated, and returned as a list of tabs +for the dashboard UI. +""" + +import logging +from typing import Dict, List, Optional, Set + +from .mdns_discovery import discover_mdns +from .network_scan_discovery import discover_network_scan +from .networktables_discovery import discover_networktables +from .port_check_discovery import verify_port_5800 + +logger = logging.getLogger(__name__) + + +def discover_all( + team_number: Optional[int] = None, + enable_mdns: bool = True, + enable_network_scan: bool = True, + enable_port_check: bool = True, + enable_networktables: bool = True, + ntables_server: Optional[str] = None, +) -> List[Dict[str, str]]: + """Discover all PhotonVision dashboards using multiple strategies. + + Runs all enabled discovery strategies in parallel and combines results: + - mDNS resolution of photonvision.local + - Network scanning of 10.TE.AM.0/24 + - Verification of port 5800 accessibility + - NetworkTables discovery + + Duplicates are removed and results are sorted for consistent output. + + Args: + team_number: FRC team number for network scanning (e.g., 5123). + enable_mdns: Whether to attempt mDNS discovery. + enable_network_scan: Whether to scan the team network. + enable_port_check: Whether to verify port 5800 accessibility. + enable_networktables: Whether to use NetworkTables discovery. + ntables_server: NetworkTables server address (optional). + + Returns: + List of dicts with "title" and "url" keys, one per discovered dashboard. + Empty list if no dashboards found. + """ + all_candidates: Set[str] = set() + + # Strategy 0: mDNS discovery + if enable_mdns: + try: + mdns_results = discover_mdns() + logger.info(f"mDNS discovery found {len(mdns_results)} candidates") + all_candidates.update(mdns_results) + except Exception as e: + logger.error(f"mDNS discovery error: {e}") + + # Strategy 1: Network scanning (10.TE.AM.XX) + if enable_network_scan: + try: + network_results = discover_network_scan(team_number) + logger.info(f"Network scan found {len(network_results)} candidates") + all_candidates.update(network_results) + except Exception as e: + logger.error(f"Network scan error: {e}") + + # Strategy 2 & 3: Port verification (more efficient than scanning for port) + # Only check port 5800 on candidates found above + if enable_port_check and all_candidates: + try: + verified = verify_port_5800(all_candidates) + logger.info(f"Port verification found {len(verified)} active dashboards") + # After port check, only keep verified IPs + all_candidates = verified + except Exception as e: + logger.error(f"Port verification error: {e}") + + # Strategy 3: NetworkTables discovery (alternative/supplement) + if enable_networktables: + try: + nt_results = discover_networktables(ntables_server) + logger.info(f"NetworkTables discovery found {len(nt_results)} candidates") + all_candidates.update(nt_results) + except Exception as e: + logger.error(f"NetworkTables discovery error: {e}") + + # Convert IPs to tab entries and sort for consistency + if not all_candidates: + logger.info("No PhotonVision dashboards discovered") + return [] + + # Sort IPs for consistent ordering + sorted_ips = sorted(all_candidates, key=lambda x: tuple(map(int, x.split(".")))) + + tabs: List[Dict[str, str]] = [ + {"title": f"{ip}", "url": f"http://{ip}:5800"} + for ip in sorted_ips + ] + + logger.info(f"Discovery complete: {len(tabs)} dashboards found") + return tabs diff --git a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/cache.py b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/cache.py new file mode 100644 index 0000000000..b06a3692bb --- /dev/null +++ b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/cache.py @@ -0,0 +1,152 @@ +"""Background discovery cache and refresh manager. + +Manages periodic discovery of PhotonVision dashboards in a background thread +with low CPU overhead. Supports different refresh intervals for expensive +operations (network scanning) vs. quick checks (mDNS, port verification). +""" + +import logging +import threading +import time +from typing import Dict, List, Optional + +from .aggregator import discover_all + +logger = logging.getLogger(__name__) + + +class DiscoveryCache: + """Background discovery cache with configurable refresh intervals. + + Runs discovery strategies periodically in a background thread and caches + results for fast serving to the /tabs endpoint. + + Supports tuning different discovery strategies independently: + - Fast strategies (mDNS, port checks): ~10 seconds default + - Slow strategies (network scan, NetworkTables): ~60 seconds default + """ + + def __init__( + self, + team_number: Optional[int] = None, + fast_interval: float = 10.0, + slow_interval: float = 60.0, + enable_fast: bool = True, + enable_slow: bool = True, + ): + """Initialize the discovery cache. + + Args: + team_number: FRC team number for network scanning. + fast_interval: Seconds between fast discovery cycles (mDNS, port checks). + slow_interval: Seconds between slow discovery cycles (network scan). + enable_fast: Whether to run fast discovery strategies. + enable_slow: Whether to run expensive discovery strategies. + """ + self.team_number = team_number + self.fast_interval = fast_interval + self.slow_interval = slow_interval + self.enable_fast = enable_fast + self.enable_slow = enable_slow + + # Cache storage and synchronization + self._tabs_cache: List[Dict[str, str]] = [] + self._cache_lock = threading.RLock() + + # Background thread management + self._discovery_thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + + def start(self) -> None: + """Start the background discovery thread. + + The thread will periodically call discover_all() and update the cache. + Uses daemon threads so the service can shut down cleanly. + """ + if self._discovery_thread is not None: + logger.warning("Discovery cache already started") + return + + self._stop_event.clear() + self._discovery_thread = threading.Thread( + target=self._discovery_loop, + daemon=True, + name="DiscoveryCache", + ) + self._discovery_thread.start() + logger.info("Background discovery cache started") + + def stop(self) -> None: + """Stop the background discovery thread. + + Signals the thread to stop and waits for it to finish. + """ + if self._discovery_thread is None: + return + + logger.info("Stopping background discovery cache") + self._stop_event.set() + if self._discovery_thread.is_alive(): + self._discovery_thread.join(timeout=5.0) + + def get_tabs(self) -> List[Dict[str, str]]: + """Get the currently cached list of tabs. + + Returns immediately without blocking for new discovery. + + Returns: + List of discovered tabs, or empty list if none found. + """ + with self._cache_lock: + return self._tabs_cache.copy() + + def _discovery_loop(self) -> None: + """Background discovery loop. + + Periodically runs discovery strategies with different intervals: + - Fast strategies every fast_interval seconds + - Slow strategies every slow_interval seconds + + The loop is designed to be low-CPU: it sleeps most of the time and + wakes periodically to check if discovery is needed. + """ + next_fast_discovery = time.time() + next_slow_discovery = time.time() + + while not self._stop_event.is_set(): + now = time.time() + run_fast = now >= next_fast_discovery + run_slow = now >= next_slow_discovery + + if run_fast or run_slow: + try: + # Run discovery with appropriate strategies enabled + tabs = discover_all( + team_number=self.team_number, + enable_mdns=self.enable_fast and run_fast, + enable_network_scan=self.enable_slow and run_slow, + enable_port_check=self.enable_fast and run_fast, + enable_networktables=self.enable_slow and run_slow, + ) + + # Update cache atomically + with self._cache_lock: + self._tabs_cache = tabs + + # Log results periodically (not on every cycle) + if run_fast or run_slow: + logger.debug(f"Discovery updated: {len(tabs)} dashboards found") + + except Exception as e: + logger.error(f"Discovery error: {e}") + + # Schedule next runs + if run_fast: + next_fast_discovery = now + self.fast_interval + if run_slow: + next_slow_discovery = now + self.slow_interval + + # Sleep briefly to be responsive to stop signal + # Use a small sleep so we check stop_event frequently + sleep_time = min(0.5, self.fast_interval / 2) # At most 0.5s, smart default + self._stop_event.wait(sleep_time) diff --git a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/mdns_discovery.py b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/mdns_discovery.py new file mode 100644 index 0000000000..331ad62716 --- /dev/null +++ b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/mdns_discovery.py @@ -0,0 +1,38 @@ +"""mDNS discovery strategy. + +Attempts to resolve 'photonvision.local' via multicast DNS (mDNS). +This is typically set up on a PhotonVision host running avahi-daemon +or similar mDNS responder. + +Returns: + Set of IP addresses (as strings) that resolve photonvision.local. + Empty set if resolution fails or mDNS is unavailable. +""" + +import logging +import socket +from typing import Set + +logger = logging.getLogger(__name__) + + +def discover_mdns() -> Set[str]: + """Discover PhotonVision via mDNS hostname resolution. + + Attempts to resolve 'photonvision.local' to an IP address. + On success, returns the resolved IPv4 address. + + Returns: + Set containing the resolved IP, or empty set if resolution fails. + """ + try: + # Try to resolve photonvision.local + result = socket.gethostbyname("photonvision.local") + logger.info(f"mDNS discovery found: {result}") + return {result} + except socket.gaierror as e: + logger.debug(f"mDNS resolution failed: {e}") + return set() + except Exception as e: + logger.error(f"Unexpected error during mDNS discovery: {e}") + return set() diff --git a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/network_scan_discovery.py b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/network_scan_discovery.py new file mode 100644 index 0000000000..a5aa28e8af --- /dev/null +++ b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/network_scan_discovery.py @@ -0,0 +1,92 @@ +"""Network scanning discovery strategy. + +Scans the FRC team network (10.TE.AM.0/24) by pinging each host. + +FRC team networks follow the pattern 10.TE.AM.XX where: +- 10 = FRC standard first octet +- TE = team number first two digits (zero-padded) +- AM = team number last two digits (zero-padded) +- XX = host ID (1-254, 255 is broadcast) + +Example: Team 5123 -> 10.51.23.1-254 +Example: Team 254 -> 10.02.54.1-254 + +Returns: + Set of IP addresses that respond to ping. +""" + +import logging +import subprocess +from typing import Optional, Set + +logger = logging.getLogger(__name__) + + +def discover_network_scan(team_number: Optional[int] = None) -> Set[str]: + """Scan the FRC team network for responding hosts. + + Constructs the team network address from the team number and pings + each host (XX from 1 to 254). Returns IPs that respond to ICMP ping. + + Args: + team_number: FRC team number (e.g., 5123, 254, 1). + If None, uses environment variable PHOTONVISION_TEAM + or skips network scanning. + + Returns: + Set of IP addresses that respond to ping on the team network. + """ + if team_number is None: + import os + + team_str = os.environ.get("PHOTONVISION_TEAM") + if not team_str: + logger.debug("No team number provided for network scanning") + return set() + try: + team_number = int(team_str) + except ValueError: + logger.error(f"Invalid team number: {team_str}") + return set() + + # Format team number to IP: 10.TE.AM.XX + # Team 5123 -> 10.51.23.XX + # Team 254 -> 10.02.54.XX + tens = team_number // 100 + ones = team_number % 100 + base_ip = f"10.{tens:02d}.{ones:02d}" + + responding: Set[str] = set() + + # Scan hosts 1-254 (0 is network, 255 is broadcast) + for host_id in range(1, 255): + ip = f"{base_ip}.{host_id}" + if _ping_host(ip): + logger.info(f"Network scan found: {ip}") + responding.add(ip) + + return responding + + +def _ping_host(ip: str, timeout: int = 1) -> bool: + """Check if a host responds to ping. + + Args: + ip: IP address to ping. + timeout: Timeout in seconds for the ping. + + Returns: + True if host responds to ping, False otherwise. + """ + try: + # Use ping with count=1 and timeout + # Platform-specific timeout flag + result = subprocess.run( + ["ping", "-c", "1", "-W", str(timeout * 1000), ip], + capture_output=True, + timeout=timeout + 1, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError, Exception) as e: + logger.debug(f"Ping failed for {ip}: {e}") + return False diff --git a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/networktables_discovery.py b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/networktables_discovery.py new file mode 100644 index 0000000000..499de5886c --- /dev/null +++ b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/networktables_discovery.py @@ -0,0 +1,84 @@ +"""NetworkTables discovery strategy. + +Uses PyNetworkTables (WPILib NetworkTables client library) to discover +PhotonVision instances that are publishing to the network. + +Looks for clients publishing to /photonvision or related topics that +indicate IP addresses of running PhotonVision instances. + +Returns: + Set of IP addresses discovered via NetworkTables. +""" + +import logging +from typing import Optional, Set + +logger = logging.getLogger(__name__) + +try: + from ntcore import NetworkTableInstance + NTCORE_AVAILABLE = True +except ImportError: + NTCORE_AVAILABLE = False + logger.debug("ntcore not available, NetworkTables discovery disabled") + + +def discover_networktables( + ntables_server: Optional[str] = None, timeout: float = 2.0 +) -> Set[str]: + """Discover PhotonVision instances via NetworkTables. + + Connects to a NetworkTables server and looks for entries in the + /photonvision topic that contain IP address information. + + Args: + ntables_server: NetworkTables server address (hostname or IP). + Defaults to localhost if not provided. + timeout: Connection timeout in seconds. + + Returns: + Set of IP addresses found in NetworkTables, or empty set + if NetworkTables is unavailable or connection fails. + """ + if not NTCORE_AVAILABLE: + logger.debug("NetworkTables discovery skipped (ntcore not installed)") + return set() + + if ntables_server is None: + # Default to localhost for development + ntables_server = "localhost" + + try: + instance = NetworkTableInstance.getDefault() + instance.setServerTeam(5800) # Typical PhotonVision port + instance.startClient4("PhotonVisionDiscovery") + + # Try to read photonvision topic entries + discovered: Set[str] = set() + + # Wait briefly for connection + import time + + time.sleep(min(timeout, 0.5)) + + # Query for /photonvision entries (structure TBD based on actual setup) + # This is a placeholder - exact topic structure depends on deployment + try: + table = instance.getTable("/photonvision") + if table: + # Look for entries that contain IP addresses + # Common patterns: ips, addresses, instances, etc. + for key in ["ips", "addresses", "instances", "servers"]: + value = table.getStringArray(key, []) + if value: + discovered.update(value) + logger.info(f"NetworkTables found IPs under /{key}: {value}") + except Exception as e: + logger.debug(f"Error querying /photonvision table: {e}") + + instance.stopClient() + return discovered + + except Exception as e: + logger.error(f"NetworkTables discovery failed: {e}") + return set() diff --git a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/port_check_discovery.py b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/port_check_discovery.py new file mode 100644 index 0000000000..d87dac0bcf --- /dev/null +++ b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/port_check_discovery.py @@ -0,0 +1,65 @@ +"""Port 5800 verification strategy. + +Given a set of candidate IP addresses, checks which ones are serving +a web server on port 5800 (PhotonVision default). + +Returns: + Set of IP addresses with accessible web servers on port 5800. +""" + +import logging +import socket +from typing import Set + +logger = logging.getLogger(__name__) + +# Timeout for TCP connection attempts (seconds) +PORT_CHECK_TIMEOUT = 2 + + +def verify_port_5800(candidates: Set[str]) -> Set[str]: + """Check which candidates have web servers on port 5800. + + Attempts a TCP connection to port 5800 on each candidate IP. + Includes IPs where the port is accessible. + + Args: + candidates: Set of IP addresses to check. + + Returns: + Set of IP addresses with accessible port 5800. + """ + verified: Set[str] = set() + + for ip in candidates: + if _has_port_5800(ip): + logger.info(f"Port verification found: {ip}:5800") + verified.add(ip) + + return verified + + +def _has_port_5800(ip: str) -> bool: + """Check if port 5800 is open on the given IP. + + Attempts a TCP connection to port 5800. Success indicates + a web server is likely running there. + + Args: + ip: IP address to check. + + Returns: + True if port 5800 is open, False otherwise. + """ + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(PORT_CHECK_TIMEOUT) + result = sock.connect_ex((ip, 5800)) + sock.close() + + if result == 0: + return True + return False + except Exception as e: + logger.debug(f"Port check failed for {ip}:5800: {e}") + return False diff --git a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/photon_sc_app.py b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/photon_sc_app.py index 6f1d22c1c4..a0799b5143 100644 --- a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/photon_sc_app.py +++ b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/photon_sc_app.py @@ -19,17 +19,35 @@ import argparse import http.server import json +import logging import os import signal import socket import socketserver import sys +import threading from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional + +from discovery.aggregator import discover_all +from discovery.cache import DiscoveryCache + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) # Global flag to track deployment mode (True for systemd, False for local) SOCKET_ACTIVATED: bool = False +# Global team number for network discovery (set by main()) +TEAM_NUMBER: Optional[int] = None + +# Global discovery cache (initialized in main(), shared between request handlers) +DISCOVERY_CACHE: Optional[DiscoveryCache] = None + class ServiceHandler(http.server.SimpleHTTPRequestHandler): """HTTP request handler for the Photon SC App. @@ -87,14 +105,24 @@ def send_tabs(self) -> None: """Return list of available tabs as JSON. The frontend fetches this on startup and refresh to populate the tab bar. - Tab format: {"title": "Display Name", "url": "https://..."} + Discovered tabs are served from the background discovery cache, which + periodically updates the list of available PhotonVision dashboards using: + 1. mDNS (photonvision.local hostname) + 2. Network scanning (10.TE.AM.0/24 for team network) + 3. Port 5800 verification + 4. NetworkTables client discovery + + Tab format: {"title": "Display Name", "url": "http://..."} - TODO: Make this configurable from a file or database. + Returns an empty list if no dashboards are discovered, allowing the + frontend to display a "No dashboards available" message gracefully. """ - tabs: list[Dict[str, str]] = [ - {"title": "Example", "url": "https://example.com"}, - {"title": "Docs", "url": "https://docs.photonvision.org"}, - ] + # Return cached results from background discovery thread + if DISCOVERY_CACHE is not None: + tabs: List[Dict[str, str]] = DISCOVERY_CACHE.get_tabs() + else: + # Fallback if cache not initialized (should not happen in normal operation) + tabs = [] self.send_response(200) self.send_header("Content-type", "application/json") @@ -114,9 +142,14 @@ class SocketActivatedService: - Better integration with systemd security features and resource limits """ - def __init__(self) -> None: - """Initialize the socket-activated service.""" + def __init__(self, discovery_cache: Optional[DiscoveryCache] = None) -> None: + """Initialize the socket-activated service. + + Args: + discovery_cache: Optional DiscoveryCache for background discovery. + """ self.httpd: Optional[socketserver.ThreadingTCPServer] = None + self.discovery_cache = discovery_cache def get_systemd_socket(self) -> socket.socket: """Retrieve and validate the socket passed by systemd. @@ -165,6 +198,10 @@ def start(self) -> None: - daemon_threads=True: Allows quick shutdown (threads don't block exit) - poll_interval=0.5: Checks signals frequently for responsive Ctrl-C """ + # Start background discovery if available + if self.discovery_cache: + self.discovery_cache.start() + server_socket = self.get_systemd_socket() # Create threaded server WITHOUT calling bind() or listen() @@ -181,6 +218,10 @@ def start(self) -> None: def stop(self) -> None: """Cleanly shut down the server and release resources.""" + # Stop background discovery + if self.discovery_cache: + self.discovery_cache.stop() + if self.httpd: self.httpd.shutdown() self.httpd.server_close() @@ -214,16 +255,23 @@ class LocalService: Default: localhost:8080 (customizable via --host and --port arguments) """ - def __init__(self, host: str = "127.0.0.1", port: int = 8080) -> None: + def __init__( + self, + host: str = "127.0.0.1", + port: int = 8080, + discovery_cache: Optional[DiscoveryCache] = None, + ) -> None: """Initialize the local service. Args: host: Hostname to bind to. Defaults to '127.0.0.1'. port: Port to bind to. Defaults to 8080. + discovery_cache: Optional DiscoveryCache for background discovery. """ self.httpd: Optional[http.server.ThreadingHTTPServer] = None self.host = host self.port = port + self.discovery_cache = discovery_cache def start(self) -> None: """Start the HTTP server on the specified host:port. @@ -234,6 +282,10 @@ def start(self) -> None: - allow_reuse_address=True: Allows quick restart without TIME_WAIT - poll_interval=0.5: Responsive to Ctrl-C and other signals """ + # Start background discovery if available + if self.discovery_cache: + self.discovery_cache.start() + self.httpd = http.server.ThreadingHTTPServer( (self.host, self.port), ServiceHandler ) @@ -245,6 +297,10 @@ def start(self) -> None: def stop(self) -> None: """Cleanly shut down the server and release resources.""" + # Stop background discovery + if self.discovery_cache: + self.discovery_cache.stop() + if self.httpd: self.httpd.shutdown() self.httpd.server_close() @@ -262,7 +318,18 @@ def main() -> None: 2. Local development (--local flag): - Direct TCP server on localhost - Customizable host/port via --host and --port + + Discovery Strategy: + The service automatically discovers PhotonVision dashboards via a background + thread running multiple strategies on different intervals: + - Fast strategies (mDNS, port checks): Default 10 seconds + - Slow strategies (network scan, NetworkTables): Default 60 seconds + + Use --team to enable network scanning for your FRC team number. + Use --discovery-fast-interval and --discovery-slow-interval to tune timing. """ + global TEAM_NUMBER, DISCOVERY_CACHE + parser = argparse.ArgumentParser(description="Photon SC App service") parser.add_argument( "--local", @@ -277,21 +344,69 @@ def main() -> None: parser.add_argument( "--port", type=int, default=8080, help="Local port when running in local mode" ) + parser.add_argument( + "--team", + type=int, + default=None, + help="FRC team number for network discovery (e.g., 5123)", + ) + parser.add_argument( + "--discovery-fast-interval", + type=float, + default=10.0, + help="Seconds between fast discovery cycles (mDNS, port checks). Default: 10s", + ) + parser.add_argument( + "--discovery-slow-interval", + type=float, + default=60.0, + help="Seconds between slow discovery cycles (network scan, NetworkTables). Default: 60s", + ) + parser.add_argument( + "--discovery-disable-fast", + action="store_true", + help="Disable fast discovery strategies (mDNS, port checks)", + ) + parser.add_argument( + "--discovery-disable-slow", + action="store_true", + help="Disable slow discovery strategies (network scan, NetworkTables)", + ) args = parser.parse_args() + # Set team number for discovery + TEAM_NUMBER = args.team + if TEAM_NUMBER: + logger.info(f"Network discovery enabled for team {TEAM_NUMBER}") + + # Create and start discovery cache + DISCOVERY_CACHE = DiscoveryCache( + team_number=TEAM_NUMBER, + fast_interval=args.discovery_fast_interval, + slow_interval=args.discovery_slow_interval, + enable_fast=not args.discovery_disable_fast, + enable_slow=not args.discovery_disable_slow, + ) + # Select deployment mode based on --local flag if not args.local: # Production: systemd socket activation if not os.environ.get("LISTEN_PID") or not os.environ.get("LISTEN_FDS"): - print( - "ERROR: Must be started by systemd socket activation or use --local " + logger.error( + "Must be started by systemd socket activation or use --local " "for development mode" ) sys.exit(1) - service: SocketActivatedService | LocalService = SocketActivatedService() + service: SocketActivatedService | LocalService = SocketActivatedService( + discovery_cache=DISCOVERY_CACHE + ) else: # Development: local server - service = LocalService(host=args.host, port=args.port) + service = LocalService( + host=args.host, + port=args.port, + discovery_cache=DISCOVERY_CACHE, + ) # Register signal handlers for clean shutdown signal.signal(signal.SIGINT, signal_handler) # Ctrl-C @@ -302,7 +417,7 @@ def main() -> None: except (KeyboardInterrupt, SystemExit): # Ensure cleanup happens on any exit (Ctrl-C, signal, etc.) service.stop() - print("Server stopped.") + logger.info("Server stopped.") if __name__ == "__main__": diff --git a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/www/script.js b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/www/script.js index 6c6a8502e4..21774a6f6a 100644 --- a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/www/script.js +++ b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/www/script.js @@ -39,7 +39,7 @@ async function fetchTabs() { if (tabs.length > 0) { setActiveTab(0); } else { - tabList.innerHTML = '
No tabs available
'; + tabList.innerHTML = '
No clients found
'; tabFrame.src = 'about:blank'; } } catch (error) { diff --git a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/www/styles.css b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/www/styles.css new file mode 100644 index 0000000000..7e104314cc --- /dev/null +++ b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/www/styles.css @@ -0,0 +1,195 @@ +body { + margin: 0; + min-height: 100vh; + font-family: "Prompt", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + background: #101820; + color: #e8eef8; +} + +* { + box-sizing: border-box; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + background: #0f1f38; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.brand { + display: flex; + align-items: center; + gap: 1rem; +} + +.brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + background: #1a67d0; + border-radius: 14px; + font-weight: 700; + font-size: 1rem; + color: white; +} + +header h1 { + margin: 0; + font-size: 1.2rem; +} + +header p { + margin: 0.15rem 0 0; + color: #b8c8ea; + font-size: 0.95rem; +} + +header button { + padding: 0.75rem 1rem; + background: #1a67d0; + color: white; + border: none; + border-radius: 12px; + cursor: pointer; + transition: background 0.2s ease; +} + +header button:hover { + background: #0f56c2; +} + +main { + display: grid; + grid-template-rows: auto 1fr; + gap: 0.85rem; + padding: 1rem 1.25rem 1.25rem; + height: calc(100vh - 92px); +} + +.tabs-row { + display: flex; + align-items: center; + gap: 1rem; + width: 100%; +} + +.tab-bar { + display: flex; + gap: 0.25rem; + width: 100%; + padding: 0 0.25rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); +} + +.tab-button { + position: relative; + flex: 0 0 auto; + padding: 0.75rem 1rem; + background: #0f1f38; + color: #e8eef8; + border: 1px solid rgba(255, 255, 255, 0.12); + border-bottom: 1px solid transparent; + border-radius: 12px 12px 0 0; + cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease; +} + +.tab-button.active { + background: #101820; + border-color: #1a67d0; + border-bottom-color: #101820; +} + +.tab-button:not(.active):hover { + background: rgba(255, 255, 255, 0.06); +} + +.refresh-button { + flex: 0 0 auto; + padding: 0.75rem 1rem; + background: #1a67d0; + color: white; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + cursor: pointer; + transition: background 0.2s ease; +} + +.refresh-button:hover { + background: #0f56c2; +} + +.panel-section { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; + min-height: 0; +} + +.iframe-wrapper { + background: #0c1421; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 18px; + overflow: hidden; + min-height: 0; +} + +iframe { + width: 100%; + height: 100%; + min-height: 0; + border: none; +} + +.status-panel { + display: flex; + flex-direction: column; + padding: 1rem; + background: #0f1f38; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 18px; +} + +.panel-title { + font-weight: 700; + margin-bottom: 0.75rem; +} + +pre { + flex: 1; + margin: 0; + padding: 1rem; + overflow: auto; + border-radius: 12px; + background: rgba(255, 255, 255, 0.04); + color: #d6e1ff; +} + +.empty-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 240px; + padding: 2rem; + text-align: center; + color: #8899bb; + font-size: 1rem; + background: linear-gradient(135deg, rgba(26, 103, 208, 0.05) 0%, rgba(15, 31, 56, 0.1) 100%); + border: 1px solid rgba(26, 103, 208, 0.15); + border-radius: 18px; +} + +@media (max-width: 900px) { + .panel-section { + grid-template-columns: 1fr; + } + + .status-panel { + max-height: 240px; + } +} From 07bafe84199fbda5e4b7dce547ebef96099cb613 Mon Sep 17 00:00:00 2001 From: Chris Gerth Date: Fri, 26 Jun 2026 13:06:35 -0500 Subject: [PATCH 4/6] WIP adding background nt discovery strategies. Not yet tested --- .../bin/photon-sc-app/discovery/aggregator.py | 22 ++++++++++---- .../discovery/networktables_discovery.py | 30 ++++++++++++++----- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/aggregator.py b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/aggregator.py index e723776dee..4bfdb3d69d 100644 --- a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/aggregator.py +++ b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/aggregator.py @@ -57,7 +57,10 @@ def discover_all( if enable_mdns: try: mdns_results = discover_mdns() - logger.info(f"mDNS discovery found {len(mdns_results)} candidates") + if mdns_results: + logger.info(f"mDNS discovery found {len(mdns_results)} candidates") + else: + logger.debug("mDNS discovery found no candidates") all_candidates.update(mdns_results) except Exception as e: logger.error(f"mDNS discovery error: {e}") @@ -66,7 +69,10 @@ def discover_all( if enable_network_scan: try: network_results = discover_network_scan(team_number) - logger.info(f"Network scan found {len(network_results)} candidates") + if network_results: + logger.info(f"Network scan found {len(network_results)} candidates") + else: + logger.debug("Network scan found no candidates") all_candidates.update(network_results) except Exception as e: logger.error(f"Network scan error: {e}") @@ -76,7 +82,10 @@ def discover_all( if enable_port_check and all_candidates: try: verified = verify_port_5800(all_candidates) - logger.info(f"Port verification found {len(verified)} active dashboards") + if verified: + logger.info(f"Port verification found {len(verified)} active dashboards") + else: + logger.debug("Port verification found no accessible dashboards") # After port check, only keep verified IPs all_candidates = verified except Exception as e: @@ -86,14 +95,17 @@ def discover_all( if enable_networktables: try: nt_results = discover_networktables(ntables_server) - logger.info(f"NetworkTables discovery found {len(nt_results)} candidates") + if nt_results: + logger.info(f"NetworkTables discovery found {len(nt_results)} candidates") + else: + logger.debug("NetworkTables discovery found no candidates") all_candidates.update(nt_results) except Exception as e: logger.error(f"NetworkTables discovery error: {e}") # Convert IPs to tab entries and sort for consistency if not all_candidates: - logger.info("No PhotonVision dashboards discovered") + logger.debug("No PhotonVision dashboards discovered") return [] # Sort IPs for consistent ordering diff --git a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/networktables_discovery.py b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/networktables_discovery.py index 499de5886c..c851aab975 100644 --- a/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/networktables_discovery.py +++ b/photon-sc-app/overlay/usr/local/bin/photon-sc-app/discovery/networktables_discovery.py @@ -50,8 +50,20 @@ def discover_networktables( try: instance = NetworkTableInstance.getDefault() - instance.setServerTeam(5800) # Typical PhotonVision port - instance.startClient4("PhotonVisionDiscovery") + + # Attempt to start client using available API + # Try modern ntcore API first, fall back to legacy + try: + # Modern ntcore 4.x+ API + instance.startClient4(ntables_server) + except (AttributeError, TypeError): + try: + # Fallback: ntcore 3.x API + instance.startClient(ntables_server) + except (AttributeError, TypeError): + # Last resort: default client with no arguments + logger.debug("Using default NetworkTables client connection") + instance.startClient() # Try to read photonvision topic entries discovered: Set[str] = set() @@ -69,10 +81,14 @@ def discover_networktables( # Look for entries that contain IP addresses # Common patterns: ips, addresses, instances, etc. for key in ["ips", "addresses", "instances", "servers"]: - value = table.getStringArray(key, []) - if value: - discovered.update(value) - logger.info(f"NetworkTables found IPs under /{key}: {value}") + try: + value = table.getStringArray(key, []) + if value: + discovered.update(value) + logger.info(f"NetworkTables found IPs under /{key}: {value}") + except Exception: + # Key not found or wrong type, continue + pass except Exception as e: logger.debug(f"Error querying /photonvision table: {e}") @@ -80,5 +96,5 @@ def discover_networktables( return discovered except Exception as e: - logger.error(f"NetworkTables discovery failed: {e}") + logger.debug(f"NetworkTables discovery error: {e}") return set() From 8342e8431fb15a0dda4284f5e751e29f414de41b Mon Sep 17 00:00:00 2001 From: Chris Gerth Date: Tue, 30 Jun 2026 10:02:09 -0500 Subject: [PATCH 5/6] Add AGENTS.md and photon-sc-app README --- AGENTS.md | 75 ++++++++++++++++++++++++++++++++++++++ photon-sc-app/README.md | 81 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 AGENTS.md create mode 100644 photon-sc-app/README.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..1660f315e6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,75 @@ +# AGENTS.md + +## Repo overview + +Monorepo with three package systems: +- **Gradle** multi-project (Java 25, C++) -- root `./gradlew` +- **pnpm** workspaces -- `photon-client/` (Vue 3 + Vuetify 3 SPA) and `website/` (Vite SSG marketing site) +- **Python** -- `photon-lib/py/` (photonlibpy wheel, pytest + mypy) + +Key Gradle subprojects (defined in `settings.gradle`): +- `photon-server` -- fat JAR entrypoint (`org.photonvision.Main.main()`, port **5800**) +- `photon-core` -- vision pipelines, hardware manager, config +- `photon-targeting` -- native C++ AprilTag + JNI +- `photon-lib` -- robot-side vendor library (Java, C++, Python) +- `photon-docs` -- JavaDoc + Doxygen + +## Commands (run from repo root) + +| What | Command | +|------|---------| +| Full app (backend + frontend) | `./gradlew run` | +| Build all Java/C++ | `./gradlew build` | +| Run Java tests | `./gradlew test` | +| Java format | `./gradlew spotlessApply` | +| Cross-compile toolchain | `./gradlew installArm64Toolchain` | +| Deploy to coprocessor | `./gradlew deploy -PArchOverride=linuxarm64` | +| Frontend dev (hot reload) | `pnpm dev` (in `photon-client/`) | +| Frontend type-check | `pnpm type-check` (in `photon-client/`) | +| Frontend lint/format | `pnpm lint` / `pnpm format` (in `photon-client/`) | +| E2E tests (Playwright) | `pnpm test` (auto-starts `./gradlew run`) | +| Python tests | `pytest` (in `photon-lib/py/`) | + +Use `pnpm` not `npm` for JS packages. + +## Frontend conventions + +- **Formatter**: Prettier — `semi: true`, `singleQuote: false`, `tabWidth: 2`, `printWidth: 120`, `trailingComma: "none"` +- **Linter**: ESLint 9 flat config — enforces same style as Prettier +- **Router**: hash-based (`/#/dashboard`, `/#/cameras`, `/#/settings`, `/#/cameraConfigs`, `/#/docs`) +- **API base**: `http://{host}:5800/api` (Axios baseURL set automatically in `src/main.ts`) +- **Path alias**: `@` → `src/` +- **State**: Pinia stores in `src/stores/`, key store is `StateStore` +- **WebSocket**: `ws://{host}:5800/websocket_data` for real-time data + +## Ports + +- App UI + API: **5800** +- Vite dev server (photon-client): default Vite port (usually 5173) + +## Build artifacts + +- `photon-server/build/libs/photonvision.jar` (fat JAR via Shadow plugin) +- `photon-client/dist/` (built SPA, served by Gradle during `./gradlew run`) +- `photon-lib/py/dist/` (Python wheel) + +## Testing quirks + +- Playwright tests expect `./gradlew run` on port 5800 (auto-configured in `playwright.config.ts`) +- Java tests use JUnit 5 (Jupiter) with JaCoCo coverage +- Python tests use pytest + mypy type checking +- CI runs typecheck-client (`vue-tsc --noEmit`) before Playwright + +## CI workflows + +- `build.yml` — main CI: typecheck, Playwright, Gradle build/test, cross-compile, IPK, disk images, smoketests +- `lint-format.yml` — wpiformat, Spotless, ESLint + Prettier +- `python.yml` — Python wheel build, pytest, mypy, PyPI publish +- `website.yml` — marketing site build + deploy +- `photon-api-docs.yml` — JavaDoc + Doxygen publish + +## Repository quick reference + +- Non-obvious dirs: `photon-serde/` (YAML→Java/C++/Python codegen), `photon-sc-app/` (IPK packaging), `test-resources/` (test images/configs) +- Version: WPILib 2027.0.0-alpha-6, JDK 25, Node 24, Python 3.14, Gradle 9.4.0 +- License: GPL-3.0 diff --git a/photon-sc-app/README.md b/photon-sc-app/README.md new file mode 100644 index 0000000000..1cf83492ed --- /dev/null +++ b/photon-sc-app/README.md @@ -0,0 +1,81 @@ +# photon-sc-app + +IPK-packaged dashboard aggregator for PhotonVision. Runs on a coprocessor (SystemCore / Orange Pi 5) and provides a tabbed web UI that auto-discovers all PhotonVision camera coprocessors on the FRC team network, loading each into its own iframe. + +## How to build + +```sh +bash build.sh +``` + +Produces `photon-sc-app_1.0.0.ipk` in the current directory. + +## How to develop + +```sh +bash run_local.sh +``` + +Opens a dev server at `http://127.0.0.1:8080`. The web UI is served from the `www/` directory — refresh the browser to see changes. Pass `--team 5123` to enable FRC network scanning, or `--help` to see all options. + +## File layout + +| Path | Purpose | +|------|---------| +| `build.sh` | Assembles the IPK: copies overlay/ into a directory, merges control/ files, tars both, wraps with `ar` | +| `run_local.sh` / `run_local.bat` | Launches `photon_sc_app.py --local` for development without systemd | +| `control/` | OPKG metadata (`control`) and lifecycle scripts (`postinst`, `prerm`, `postrm`) | +| `overlay/` | Filesystem tree installed verbatim to the target coprocessor root | +| `overlay/usr/local/bin/photon-sc-app/photon_sc_app.py` | Python HTTP server entrypoint | +| `overlay/usr/local/bin/photon-sc-app/discovery/` | Network discovery strategies (one module per strategy) | +| `overlay/usr/local/bin/photon-sc-app/www/` | Web frontend (HTML, CSS, JS) | +| `overlay/etc/systemd/system/` | Systemd socket + service units for production | +| `overlay/usr/share/photon-sc-app.png` | App icon for SystemCore launcher | + +## Requirements + +### Web UI + +A dark-themed single-page app served from a built-in Python HTTP `ThreadingHTTPServer`. No build step, no framework — just static HTML/CSS/JS served from `www/`. The UI must: + +- Display a horizontal tab bar. Each tab is a `