diff --git a/.github/workflows/make_ipks.yml b/.github/workflows/make_ipks.yml new file mode 100644 index 0000000000..fd8d0adb3c --- /dev/null +++ b/.github/workflows/make_ipks.yml @@ -0,0 +1,28 @@ +name: Make IPKs + +on: + push: + branches: + - "*" + tags-ignore: + - "*" + pull_request: + release: + types: [published] + +jobs: + + build-system-viewer: + name: Build IPK for SystemViewer + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Build IPK (SystemCore) + run: cd systemcore-apps/system-view && chmod +x build.sh && ./build.sh + - name: Upload development artifact (SystemCore) + uses: actions/upload-artifact@v4 + with: + name: pv-system-viewer.ipk + path: systemcore-apps/system-view/pv-system-viewer_*.ipk diff --git a/.gitignore b/.gitignore index dda0d4712f..4522bf02ea 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,5 @@ photon-server/src/main/resources/web/* node_modules dist components.d.ts + +*.ipk diff --git a/systemcore-apps/system-view/.gitignore b/systemcore-apps/system-view/.gitignore new file mode 100644 index 0000000000..83c41fb10f --- /dev/null +++ b/systemcore-apps/system-view/.gitignore @@ -0,0 +1,2 @@ +!overlay/ +!overlay/** diff --git a/systemcore-apps/system-view/build.sh b/systemcore-apps/system-view/build.sh new file mode 100755 index 0000000000..87b34a869d --- /dev/null +++ b/systemcore-apps/system-view/build.sh @@ -0,0 +1,93 @@ +#!/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 + +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" diff --git a/systemcore-apps/system-view/control/control b/systemcore-apps/system-view/control/control new file mode 100644 index 0000000000..be143b6de8 --- /dev/null +++ b/systemcore-apps/system-view/control/control @@ -0,0 +1,13 @@ +Package: pv-system-viewer +Version: 0.1.0 +Description: PhotonVision System Viewer - A simple interface to see all PhotonVision cameras. +Section: development +Priority: optional +Maintainer: PhotonVision Team +Architecture: all +Source: local +X-Port: 5804 +X-Has-UI: true +X-Auto-Start: true +X-Icon-Path: /usr/share/pv-system-viewer.png +X-Display-Name: PhotonVision System Viewer diff --git a/systemcore-apps/system-view/control/postinst b/systemcore-apps/system-view/control/postinst new file mode 100644 index 0000000000..e719783f7c --- /dev/null +++ b/systemcore-apps/system-view/control/postinst @@ -0,0 +1,29 @@ +#!/bin/sh +PACKAGE_NAME="pv-system-viewer" + +echo "Configuring service for $PACKAGE_NAME" + +systemctl daemon-reload +sleep 1 + +echo "Enabling service for auto-start on boot..." +systemctl enable $PACKAGE_NAME.service + +echo "Starting service..." +if systemctl start $PACKAGE_NAME.service; then + echo "Service started successfully" + + sleep 2 + + if systemctl is-active $PACKAGE_NAME.service >/dev/null 2>&1; then + echo "Service is now running" + else + echo "Service may have an issue" + fi +else + echo "Failed to start service automatically" +fi + +echo "Package installation complete" + +exit 0 diff --git a/systemcore-apps/system-view/control/postrm b/systemcore-apps/system-view/control/postrm new file mode 100644 index 0000000000..6fe3eb6778 --- /dev/null +++ b/systemcore-apps/system-view/control/postrm @@ -0,0 +1,15 @@ +#!/bin/sh +PACKAGE_NAME="pv-system-viewer" + +systemctl daemon-reload +systemctl reset-failed $PACKAGE_NAME.service 2>/dev/null || true + +if systemctl list-unit-files | grep -q "$PACKAGE_NAME.service"; then + echo "Service still appears in systemd unit files (normal until next boot)" +else + echo "Service removed from systemd" +fi + +echo "Service cleanup complete." + +exit 0 diff --git a/systemcore-apps/system-view/control/prerm b/systemcore-apps/system-view/control/prerm new file mode 100644 index 0000000000..861055929d --- /dev/null +++ b/systemcore-apps/system-view/control/prerm @@ -0,0 +1,27 @@ +#!/bin/sh +PACKAGE_NAME="pv-system-viewer" + +echo "Stopping $PACKAGE_NAME service..." + +if systemctl is-active $PACKAGE_NAME.service >/dev/null 2>&1; then + echo "Service is running, stopping it..." + if systemctl stop $PACKAGE_NAME.service; then + echo "Service stopped successfully" + else + echo "Service stop may have failed, continuing anyway..." + fi +else + echo "Service was not running" +fi + +# Disable the service +echo "Disabling service auto-start..." +if systemctl disable $PACKAGE_NAME.service 2>/dev/null; then + echo "Service auto-start disabled" +else + echo "Service was not enabled or disable failed" +fi + +echo "Service stopped and disabled" + +exit 0 diff --git a/systemcore-apps/system-view/overlay/etc/systemd/system/pv-system-viewer.service b/systemcore-apps/system-view/overlay/etc/systemd/system/pv-system-viewer.service new file mode 100644 index 0000000000..7cbc05b587 --- /dev/null +++ b/systemcore-apps/system-view/overlay/etc/systemd/system/pv-system-viewer.service @@ -0,0 +1,23 @@ +[Unit] +Description=PhotonVision System Viewer - A simple interface to check all connected PhotonVision Coprocessor statuses +After=network.target +Wants=network.target + +[Service] +Type=simple +User=root +Group=root +ExecStart=/usr/local/bin/pv-system-viewer/pv-system-viewer.py +Restart=always +RestartSec=5 +TimeoutStartSec=3 +TimeoutStopSec=3 + +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/log + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/404.html b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/404.html new file mode 100644 index 0000000000..e0b1f01921 --- /dev/null +++ b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/404.html @@ -0,0 +1,5 @@ + + + +

404 error

+ \ No newline at end of file diff --git a/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/pv-system-viewer.py b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/pv-system-viewer.py new file mode 100644 index 0000000000..5ab9b4d199 --- /dev/null +++ b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/pv-system-viewer.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +import http.server +import socketserver +import threading +import signal +import sys +import os +import json +from datetime import datetime + +import mimetypes +# Ensure mimetypes are set for common file types +mimetypes.add_type('application/javascript', '.js') + + +class PVSystemViewerHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + script_dir = os.path.dirname(os.path.abspath(__file__)) + www_dir = os.path.join(script_dir, 'www') # Path to the www folder + + if self.path == '/': + file_path = os.path.join(www_dir, 'systemViewer.html') + else: + # Serve the requested file + file_path = os.path.join(www_dir, self.path.lstrip('/')) + + # Check if the file exists and is within the www directory + if os.path.commonpath([www_dir, os.path.abspath(file_path)]) != www_dir or not os.path.isfile(file_path): + self.send_response(404) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(b'

404 Not Found

') + return + + # Determine the MIME type of the file + mime_type, _ = mimetypes.guess_type(file_path) + print(mime_type) + self.send_response(200) + self.send_header('Content-type', mime_type or 'application/octet-stream') + self.end_headers() + + # Serve the file content + with open(file_path, 'rb') as f: + self.wfile.write(f.read()) + + def log_message(self, format, *args): + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + print(f"[{timestamp}] {format % args}") + +class PVSystemViewerServer: + def __init__(self, port=5804): + self.port = port + self.httpd = None + self.server_thread = None + self.shutdown_event = threading.Event() + + def start(self): + try: + self.httpd = socketserver.TCPServer(("", self.port), PVSystemViewerHandler) + # Allow socket reuse to prevent "Address already in use" errors + self.httpd.allow_reuse_address = True + + self.server_thread = threading.Thread(target=self.httpd.serve_forever) + self.server_thread.daemon = True + self.server_thread.start() + print(f"PhotonVision System Viewer server started on port {self.port}") + return True + except Exception as e: + print(f"Failed to start server: {e}") + return False + + def stop(self): + print("Stopping PhotonVision System Viewer server...") + self.shutdown_event.set() + + if self.httpd: + self.httpd.shutdown() + self.httpd.server_close() + + if self.server_thread and self.server_thread.is_alive(): + self.server_thread.join(timeout=2) + + print("PhotonVision System Viewer server stopped") + +# Global server instance for signal handler +server_instance = None + +def signal_handler(signum, frame): + print(f"\nReceived signal {signum}, stopping server...") + if server_instance: + server_instance.stop() + sys.exit(0) + +def main(): + global server_instance + + # Register signal handlers + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + server_instance = PVSystemViewerServer(5804) + + if server_instance.start(): + print("PhotonVision System Viewer service is running. Managed by systemd.") + try: + server_instance.shutdown_event.wait() + except KeyboardInterrupt: + signal_handler(signal.SIGINT, None) + else: + print("Failed to start PhotonVision System Viewer service") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/favicon.svg b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/favicon.svg new file mode 100644 index 0000000000..88643de263 --- /dev/null +++ b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/favicon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/NT4_CalInf.js b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/NT4_CalInf.js new file mode 100644 index 0000000000..9602093c7f --- /dev/null +++ b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/NT4_CalInf.js @@ -0,0 +1,130 @@ +///////////////////////////////////////////////////////////////////////// +// Calibration - wrapper around NT4 to specifically extract cal information +// and allow clients to interact with one or more calibrations +// +// Mirroring (I assume) NT4 architecture, it's heavily callback driven +///////////////////////////////////////////////////////////////////////// + +import { NT4_Client } from "./nt4.js"; +import { CalObj } from "./calobj.js"; + +export class NT4_CalInf { + + /////////////////////////////////////// + // Public API + + constructor(onNewCalAdded_in, //Gets called when a new calibration is available + onCalValueUpdated_in, //Gets called when one calibration's value has changed. + onConnect_in, //Gets called once client completes initial handshake with server + onDisconnect_in) { //Gets called once client detects server has disconnected + this.onNewCalAdded = onNewCalAdded_in; + this.onCalValueUpdated = onCalValueUpdated_in; + this.onConnect = onConnect_in; + this.onDisconnect = onDisconnect_in; + + this.allCals = new Map(); + + + this.nt4Client = new NT4_Client(window.location.hostname, + this.topicAnnounceHandler.bind(this), + this.topicUnannounceHandler.bind(this), + this.valueUpdateHandler.bind(this), + this.onConnect.bind(this), + this.onDisconnect.bind(this) + ); + + this.nt4Client.subscribeAllSamples(["/Calibrations"]); + this.nt4Client.ws_connect(); + + } + + //Submit a new calibration value + setCalibrationValue(name, value){ + var valTopic = this.calNameToTopic(name, "desValue"); + this.nt4Client.addSample(valTopic, this.nt4Client.getServerTime_us(), value); + } + + /////////////////////////////////////////// + // Internal implementations + + topicAnnounceHandler(topic){ + + if(this.isCalTopic(topic, "curValue")){ + var calName = this.topicToCalName(topic); + + //we got something new related to calibrations... + + //ensure we've got an object for this cal + if(!this.allCals.has(calName)){ + var newCal = new CalObj(); + newCal.name = calName; + newCal.units = topic.properties.units; + newCal.min = topic.properties.min_cal; + newCal.max = topic.properties.max_cal; + newCal.default = topic.properties.default_val; + + if(newCal.min == null){ + newCal.min = -Infinity; + } + + if(newCal.max == null){ + newCal.max = Infinity; + } + + //Publish a desVal topic for every curVal topic + var desValTopic = this.nt4Client.publishNewTopic(this.calNameToTopic(calName, "desValue"), topic.type); + this.nt4Client.setProperties(desValTopic, false, true); + + this.allCals.set(calName, newCal); + this.onNewCalAdded(newCal); + + } + } + } + + topicUnannounceHandler(topic){ + if(this.isCalTopic(topic, "curValue")){ + var oldTopic = this.allCals.get(this.topicToCalName(topic)); + this.allCals.delete(this.topicToCalName(topic)); + //TODO call user hook + //TODO unpublish desired + } + } + + + valueUpdateHandler(topic, timestamp, value){ + if(this.isCalTopic(topic, "curValue")){ + var calName = this.topicToCalName(topic); + var updatedCal = this.allCals.get(calName); + updatedCal.value = value; + this.onCalValueUpdated(updatedCal); + } + } + + + ///////////////////////////////////////////////// + // Helper Utiltiies + + calNameToTopic(name, suffix){ + return "/Calibrations/" + name + "/" + suffix; + } + + isCalTopic(topic, suffix){ + if(suffix === undefined){ + suffix = ".*"; + } + var replace = "\/Calibrations\/[a-zA-Z0-9 \._]+\/"+suffix; + var re = new RegExp(replace,"g"); + return re.test(topic.name); + } + + topicToCalName(topic){ + var replace = "\/Calibrations\/([a-zA-Z0-9 \._]+)\/"; + var re = new RegExp(replace,"g"); + var arr = re.exec(topic.name); + return arr[1]; + } + + + +} \ No newline at end of file diff --git a/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/calobj.js b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/calobj.js new file mode 100644 index 0000000000..2d6caaa6a3 --- /dev/null +++ b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/calobj.js @@ -0,0 +1,17 @@ +export class CalObj { + name = null; + units = null; + min = null; + max = null; + default = null; + value = null; + + isFullyAnnounced() { + return this.name != null && + this.units != null && + this.min != null && + this.max != null && + this.default != null && + this.value != null; + } +} \ No newline at end of file diff --git a/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/dummy_NT4.js b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/dummy_NT4.js new file mode 100644 index 0000000000..2a15f503ea --- /dev/null +++ b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/dummy_NT4.js @@ -0,0 +1,184 @@ +export class NT4_Client { + + + constructor(serverAddr, + onTopicAnnounce_in, //Gets called when server announces enough topics to form a new signal + onTopicUnAnnounce_in, //Gets called when server unannounces any part of a signal + onNewTopicData_in, //Gets called when any new data is available + onConnect_in, //Gets called once client completes initial handshake with server + onDisconnect_in) { //Gets called once client detects server has disconnected + + this.onTopicAnnounce = onTopicAnnounce_in; + this.onTopicUnAnnounce = onTopicUnAnnounce_in; + this.onNewTopicData = onNewTopicData_in; + this.onConnect = onConnect_in; + this.onDisconnect = onDisconnect_in; + + this.subscriptions = new Set(); + + + //TEST ONLY - fake data source loop and events + setTimeout(this.testConnect.bind(this),500); + setTimeout(this.testAnnounceSignals.bind(this),750); + this.loopCount = 0; + + } + + announceTopic(name, defaultVal){ + this.testTopicsMap.set(name, defaultVal); + this.onTopicAnnounce(name, defaultVal); + } + + unAnnounceTopic(name){ + this.testTopicsMap.delete(name); + this.onTopicUnAnnounce(name); + } + + addSample(name, timestamp, value){ + this.testTopicsMap.set(name, value); + this.onNewTopicData(name, timestamp, value); + } + + subscribe(topicPattern){ + this.subscriptions.add(topicPattern); + } + + unSubscribe(topicPattern){ + this.subscriptions.delete(topicPattern); + } + + clearAllSubscriptions(){ + this.subscriptions.clear(); + } + + getMostRecentValue(name){ + if(this.testTopicsMap.has(name)){ + return this.testTopicsMap.get(name); + } else { + return null; + } + } + + //Gets a timestamp in the NT4 time scaling & domain + // I have no idea ift his will be useful going forward but.. + getServerTime_us(){ + return new Date().getTime()*1000000; + } + + + // TEST ONLY - this is a periodic loop which simulates + // a NT server with signals and data in it + testDataSourceLoop(){ + var curWallTime = window.performance.now()/1000.0; + var curTimeSec = this.loopCount * 0.020; //20ms robot code; + + + while(curTimeSec < curWallTime){ + //Calculate values for each signal + var testSlowSin1 = 50+50*Math.sin( curTimeSec * 2 * Math.PI * 0.1); + var testFastSin1 = 50+30*Math.sin( curTimeSec* 2 * Math.PI * 1.0); + var testFastSin2 = 20*Math.sin( (curTimeSec + 0.2 )* 2 * Math.PI * 1.0); + var testSquare1 = (Math.round(curTimeSec*1000) % 1000 > 500) ? 1.0 : 0.0; + var testSquare2 = (Math.round(curTimeSec*200) % 1000 > 500) ? 2.0 : 1.0; + var testAzmth = 180*Math.sin( curTimeSec * 2 * Math.PI * 0.1); + var testSpeed = Math.sin( curTimeSec * 2 * Math.PI * 0.2); + + this.testPublishNewTopicData("Signals/TestFastSin1/Value", curTimeSec, testFastSin1); + this.testPublishNewTopicData("Signals/TestFastSin2/Value", curTimeSec, testFastSin2); + this.testPublishNewTopicData("Signals/TestSlowSin/Value", curTimeSec, testSlowSin1); + this.testPublishNewTopicData("Signals/TestSquare/Value", curTimeSec, testSquare1); + this.testPublishNewTopicData("Signals/AnotherTestSquare/Value", curTimeSec, testSquare2); + this.testPublishNewTopicData("testText", curTimeSec, testSlowSin1.toPrecision(3).toString() + " PSI"); + + this.testPublishNewTopicData("modFL_azmthDes", curTimeSec, testAzmth*-1.0); + this.testPublishNewTopicData("modFL_azmthAct", curTimeSec, testAzmth); + this.testPublishNewTopicData("modFL_speedDes", curTimeSec, testSpeed*-1.0); + this.testPublishNewTopicData("modFL_speedAct", curTimeSec, testSpeed); + this.testPublishNewTopicData("modFR_azmthDes", curTimeSec, testAzmth); + this.testPublishNewTopicData("modFR_azmthAct", curTimeSec, testAzmth); + this.testPublishNewTopicData("modFR_speedDes", curTimeSec, testSpeed); + this.testPublishNewTopicData("modFR_speedAct", curTimeSec, testSpeed); + this.testPublishNewTopicData("modBL_azmthDes", curTimeSec, testAzmth*-0.2); + this.testPublishNewTopicData("modBL_azmthAct", curTimeSec, testAzmth); + this.testPublishNewTopicData("modBL_speedDes", curTimeSec, testSpeed); + this.testPublishNewTopicData("modBL_speedAct", curTimeSec, testSpeed); + this.testPublishNewTopicData("modBR_azmthDes", curTimeSec, testAzmth*0.8); + this.testPublishNewTopicData("modBR_azmthAct", curTimeSec, testAzmth); + this.testPublishNewTopicData("modBR_speedDes", curTimeSec, testSpeed); + this.testPublishNewTopicData("modBR_speedAct", curTimeSec, testSpeed); + + this.loopCount++; + curTimeSec = this.loopCount * 0.020; + } + + this.testPublishNewTopicData("Autonomous/curVal", curTimeSec, this.testTopicsMap.get("Autonomous/desVal")); + this.testPublishNewTopicData("Autonomous/curValDelay", curTimeSec, this.testTopicsMap.get("Autonomous/desValDelay")); + + + } + + testPublishNewTopicData(name, timestamp, value){ + this.testTopicsMap.set(name, value); //Update "most-recent" value + + //If subscribed, broadcast the data with timestamp + this.subscriptions.forEach(subPattern => { + if(name.includes(subPattern)){ + this.onNewTopicData(name, timestamp, value); + } + }) + } + + testAnnounceSignals(){ + this.testTopicsMap.forEach((value,name) => { + this.onTopicAnnounce(name); + }) + } + + testConnect(){ + + this.testTopicsMap = new Map(); + this.testTopicsMap.set("Signals/TestFastSin1/Value", 0); + this.testTopicsMap.set("Signals/TestFastSin1/Units", "RPM"); + this.testTopicsMap.set("Signals/TestFastSin2/Value", 0); + this.testTopicsMap.set("Signals/TestFastSin2/Units", "V"); + this.testTopicsMap.set("Signals/TestSlowSin/Value", 0); + this.testTopicsMap.set("Signals/TestSlowSin/Units", ""); + this.testTopicsMap.set("Signals/TestSquare/Value", 0); + this.testTopicsMap.set("Signals/TestSquare/Units", "A"); + this.testTopicsMap.set("Signals/AnotherTestSquare/Value", 0); + this.testTopicsMap.set("Signals/AnotherTestSquare/Units", "A"); + this.testTopicsMap.set("Calibrations/ShooterSetpoint/Value", 1000); + this.testTopicsMap.set("Calibrations/ShooterSetpoint/Units", "RPM"); + this.testTopicsMap.set("Calibrations/ShooterSetpoint/Min", 500); + this.testTopicsMap.set("Calibrations/ShooterSetpoint/Max", 2500); + this.testTopicsMap.set("Calibrations/ShooterSetpoint/Default", 1000); + this.testTopicsMap.set("testText", ""); + this.testTopicsMap.set("Autonomous/curVal", 0); + this.testTopicsMap.set("Autonomous/desVal", 0); + this.testTopicsMap.set("Autonomous/curValDelay", 0); + this.testTopicsMap.set("Autonomous/desValDelay", 0); + this.testTopicsMap.set("modFL_azmthDes",0); + this.testTopicsMap.set("modFL_azmthAct",0); + this.testTopicsMap.set("modFL_speedDes",0); + this.testTopicsMap.set("modFL_speedAct",0); + this.testTopicsMap.set("modFR_azmthDes",0); + this.testTopicsMap.set("modFR_azmthAct",0); + this.testTopicsMap.set("modFR_speedDes",0); + this.testTopicsMap.set("modFR_speedAct",0); + this.testTopicsMap.set("modBL_azmthDes",0); + this.testTopicsMap.set("modBL_azmthAct",0); + this.testTopicsMap.set("modBL_speedDes",0); + this.testTopicsMap.set("modBL_speedAct",0); + this.testTopicsMap.set("modBR_azmthDes",0); + this.testTopicsMap.set("modBR_azmthAct",0); + this.testTopicsMap.set("modBR_speedDes",0); + this.testTopicsMap.set("modBR_speedAct",0); + + setInterval(this.testDataSourceLoop.bind(this), 75); + + this.onConnect(); + } + + + +} diff --git a/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/msgpack/msgpack.js b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/msgpack/msgpack.js new file mode 100644 index 0000000000..7f0266e580 --- /dev/null +++ b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/msgpack/msgpack.js @@ -0,0 +1,564 @@ +(function () { + "use strict"; + + // Serializes a value to a MessagePack byte array. + // + // data: The value to serialize. This can be a scalar, array or object. + // options: An object that defined additional options. + // - multiple: Indicates whether multiple values in data are concatenated to multiple MessagePack arrays. + // - invalidTypeReplacement: The value that is used to replace values of unsupported types, or a function that returns such a value, given the original value as parameter. + function serialize(data, options) { + if (options && options.multiple && !Array.isArray(data)) { + throw new Error("Invalid argument type: Expected an Array to serialize multiple values."); + } + const pow32 = 0x100000000; // 2^32 + let floatBuffer, floatView; + let array = new Uint8Array(128); + let length = 0; + + var th = ""; + if(options && options.typeHint){ + th = options.typeHint; + } + + if (options && options.multiple) { + for (let i = 0; i < data.length; i++) { + append(data[i], false, th); + } + } + else { + append(data, false, th); + } + return array.subarray(0, length); + + function append(data, isReplacement, th) { + switch (typeof data) { + case "undefined": + appendNull(data); + break; + case "boolean": + appendBoolean(data); + break; + case "number": + appendNumber(data, th); + break; + case "string": + appendString(data); + break; + case "object": + if (data === null) + appendNull(data); + else if (data instanceof Date) + appendDate(data); + else if (Array.isArray(data)) + appendArray(data); + else if (data instanceof Uint8Array || data instanceof Uint8ClampedArray) + appendBinArray(data); + else if (data instanceof Int8Array || data instanceof Int16Array || data instanceof Uint16Array || + data instanceof Int32Array || data instanceof Uint32Array || + data instanceof Float32Array || data instanceof Float64Array) + appendArray(data); + else + appendObject(data); + break; + default: + if (!isReplacement && options && options.invalidTypeReplacement) { + if (typeof options.invalidTypeReplacement === "function") + append(options.invalidTypeReplacement(data), true, th); + else + append(options.invalidTypeReplacement, true, th); + } + else { + throw new Error("Invalid argument type: The type '" + (typeof data) + "' cannot be serialized."); + } + } + } + + function appendNull(data) { + appendByte(0xc0); + } + + function appendBoolean(data) { + appendByte(data ? 0xc3 : 0xc2); + } + + function appendNumber(data, th) { + var isInteger = (th === "int") || + (isFinite(data) && Math.floor(data) === data && th !== "double" && th !== "float"); + if (isInteger) { + // Integer + if (data >= 0 && data <= 0x7f) { + appendByte(data); + } + else if (data < 0 && data >= -0x20) { + appendByte(data); + } + else if (data > 0 && data <= 0xff) { // uint8 + appendBytes([0xcc, data]); + } + else if (data >= -0x80 && data <= 0x7f) { // int8 + appendBytes([0xd0, data]); + } + else if (data > 0 && data <= 0xffff) { // uint16 + appendBytes([0xcd, data >>> 8, data]); + } + else if (data >= -0x8000 && data <= 0x7fff) { // int16 + appendBytes([0xd1, data >>> 8, data]); + } + else if (data > 0 && data <= 0xffffffff) { // uint32 + appendBytes([0xce, data >>> 24, data >>> 16, data >>> 8, data]); + } + else if (data >= -0x80000000 && data <= 0x7fffffff) { // int32 + appendBytes([0xd2, data >>> 24, data >>> 16, data >>> 8, data]); + } + else if (data > 0 && data <= 0xffffffffffffffff) { // uint64 + // Split 64 bit number into two 32 bit numbers because JavaScript only regards + // 32 bits for bitwise operations. + let hi = data / pow32; + let lo = data % pow32; + appendBytes([0xd3, hi >>> 24, hi >>> 16, hi >>> 8, hi, lo >>> 24, lo >>> 16, lo >>> 8, lo]); + } + else if (data >= -0x8000000000000000 && data <= 0x7fffffffffffffff) { // int64 + appendByte(0xd3); + appendInt64(data); + } + else if (data < 0) { // below int64 + appendBytes([0xd3, 0x80, 0, 0, 0, 0, 0, 0, 0]); + } + else { // above uint64 + appendBytes([0xcf, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); + } + } + else { + // Float + if (!floatView) { + floatBuffer = new ArrayBuffer(8); + floatView = new DataView(floatBuffer); + } + floatView.setFloat64(0, data); + appendByte(0xcb); + appendBytes(new Uint8Array(floatBuffer)); + } + } + + function appendString(data) { + let bytes = encodeUtf8(data); + let length = bytes.length; + + if (length <= 0x1f) + appendByte(0xa0 + length); + else if (length <= 0xff) + appendBytes([0xd9, length]); + else if (length <= 0xffff) + appendBytes([0xda, length >>> 8, length]); + else + appendBytes([0xdb, length >>> 24, length >>> 16, length >>> 8, length]); + + appendBytes(bytes); + } + + function appendArray(data) { + let length = data.length; + + if (length <= 0xf) + appendByte(0x90 + length); + else if (length <= 0xffff) + appendBytes([0xdc, length >>> 8, length]); + else + appendBytes([0xdd, length >>> 24, length >>> 16, length >>> 8, length]); + + for (let index = 0; index < length; index++) { + append(data[index]); + } + } + + function appendBinArray(data) { + let length = data.length; + + if (length <= 0xf) + appendBytes([0xc4, length]); + else if (length <= 0xffff) + appendBytes([0xc5, length >>> 8, length]); + else + appendBytes([0xc6, length >>> 24, length >>> 16, length >>> 8, length]); + + appendBytes(data); + } + + function appendObject(data) { + let length = 0; + for (let key in data) { + if (data[key] !== undefined) { + length++; + } + } + + if (length <= 0xf) + appendByte(0x80 + length); + else if (length <= 0xffff) + appendBytes([0xde, length >>> 8, length]); + else + appendBytes([0xdf, length >>> 24, length >>> 16, length >>> 8, length]); + + for (let key in data) { + let value = data[key]; + if (value !== undefined) { + append(key); + append(value); + } + } + } + + function appendDate(data) { + let sec = data.getTime() / 1000; + if (data.getMilliseconds() === 0 && sec >= 0 && sec < 0x100000000) { // 32 bit seconds + appendBytes([0xd6, 0xff, sec >>> 24, sec >>> 16, sec >>> 8, sec]); + } + else if (sec >= 0 && sec < 0x400000000) { // 30 bit nanoseconds, 34 bit seconds + let ns = data.getMilliseconds() * 1000000; + appendBytes([0xd7, 0xff, ns >>> 22, ns >>> 14, ns >>> 6, ((ns << 2) >>> 0) | (sec / pow32), sec >>> 24, sec >>> 16, sec >>> 8, sec]); + } + else { // 32 bit nanoseconds, 64 bit seconds, negative values allowed + let ns = data.getMilliseconds() * 1000000; + appendBytes([0xc7, 12, 0xff, ns >>> 24, ns >>> 16, ns >>> 8, ns]); + appendInt64(sec); + } + } + + function appendByte(byte) { + if (array.length < length + 1) { + let newLength = array.length * 2; + while (newLength < length + 1) + newLength *= 2; + let newArray = new Uint8Array(newLength); + newArray.set(array); + array = newArray; + } + array[length] = byte; + length++; + } + + function appendBytes(bytes) { + if (array.length < length + bytes.length) { + let newLength = array.length * 2; + while (newLength < length + bytes.length) + newLength *= 2; + let newArray = new Uint8Array(newLength); + newArray.set(array); + array = newArray; + } + array.set(bytes, length); + length += bytes.length; + } + + function appendInt64(value) { + // Split 64 bit number into two 32 bit numbers because JavaScript only regards 32 bits for + // bitwise operations. + let hi, lo; + if (value >= 0) { + // Same as uint64 + hi = value / pow32; + lo = value % pow32; + } + else { + // Split absolute value to high and low, then NOT and ADD(1) to restore negativity + value++; + hi = Math.abs(value) / pow32; + lo = Math.abs(value) % pow32; + hi = ~hi; + lo = ~lo; + } + appendBytes([hi >>> 24, hi >>> 16, hi >>> 8, hi, lo >>> 24, lo >>> 16, lo >>> 8, lo]); + } + } + + // Deserializes a MessagePack byte array to a value. + // + // array: The MessagePack byte array to deserialize. This must be an Array or Uint8Array containing bytes, not a string. + // options: An object that defined additional options. + // - multiple: Indicates whether multiple concatenated MessagePack arrays are returned as an array. + function deserialize(array, options) { + const pow32 = 0x100000000; // 2^32 + let pos = 0; + if (array instanceof ArrayBuffer) { + array = new Uint8Array(array); + } + if (typeof array !== "object" || typeof array.length === "undefined") { + throw new Error("Invalid argument type: Expected a byte array (Array or Uint8Array) to deserialize."); + } + if (!array.length) { + throw new Error("Invalid argument: The byte array to deserialize is empty."); + } + if (!(array instanceof Uint8Array)) { + array = new Uint8Array(array); + } + let data; + if (options && options.multiple) { + // Read as many messages as are available + data = []; + while (pos < array.length) { + data.push(read()); + } + } + else { + // Read only one message and ignore additional data + data = read(); + } + return data; + + function read() { + const byte = array[pos++]; + if (byte >= 0x00 && byte <= 0x7f) return byte; // positive fixint + if (byte >= 0x80 && byte <= 0x8f) return readMap(byte - 0x80); // fixmap + if (byte >= 0x90 && byte <= 0x9f) return readArray(byte - 0x90); // fixarray + if (byte >= 0xa0 && byte <= 0xbf) return readStr(byte - 0xa0); // fixstr + if (byte === 0xc0) return null; // nil + if (byte === 0xc1) throw new Error("Invalid byte code 0xc1 found."); // never used + if (byte === 0xc2) return false; // false + if (byte === 0xc3) return true; // true + if (byte === 0xc4) return readBin(-1, 1); // bin 8 + if (byte === 0xc5) return readBin(-1, 2); // bin 16 + if (byte === 0xc6) return readBin(-1, 4); // bin 32 + if (byte === 0xc7) return readExt(-1, 1); // ext 8 + if (byte === 0xc8) return readExt(-1, 2); // ext 16 + if (byte === 0xc9) return readExt(-1, 4); // ext 32 + if (byte === 0xca) return readFloat(4); // float 32 + if (byte === 0xcb) return readFloat(8); // float 64 + if (byte === 0xcc) return readUInt(1); // uint 8 + if (byte === 0xcd) return readUInt(2); // uint 16 + if (byte === 0xce) return readUInt(4); // uint 32 + if (byte === 0xcf) return readUInt(8); // uint 64 + if (byte === 0xd0) return readInt(1); // int 8 + if (byte === 0xd1) return readInt(2); // int 16 + if (byte === 0xd2) return readInt(4); // int 32 + if (byte === 0xd3) return readInt(8); // int 64 + if (byte === 0xd4) return readExt(1); // fixext 1 + if (byte === 0xd5) return readExt(2); // fixext 2 + if (byte === 0xd6) return readExt(4); // fixext 4 + if (byte === 0xd7) return readExt(8); // fixext 8 + if (byte === 0xd8) return readExt(16); // fixext 16 + if (byte === 0xd9) return readStr(-1, 1); // str 8 + if (byte === 0xda) return readStr(-1, 2); // str 16 + if (byte === 0xdb) return readStr(-1, 4); // str 32 + if (byte === 0xdc) return readArray(-1, 2); // array 16 + if (byte === 0xdd) return readArray(-1, 4); // array 32 + if (byte === 0xde) return readMap(-1, 2); // map 16 + if (byte === 0xdf) return readMap(-1, 4); // map 32 + if (byte >= 0xe0 && byte <= 0xff) return byte - 256; // negative fixint + console.debug("msgpack array:", array); + throw new Error("Invalid byte value '" + byte + "' at index " + (pos - 1) + " in the MessagePack binary data (length " + array.length + "): Expecting a range of 0 to 255. This is not a byte array."); + } + + function readInt(size) { + let value = 0; + let first = true; + while (size-- > 0) { + if (first) { + let byte = array[pos++]; + value += byte & 0x7f; + if (byte & 0x80) { + value -= 0x80; // Treat most-significant bit as -2^i instead of 2^i + } + first = false; + } + else { + value *= 256; + value += array[pos++]; + } + } + return value; + } + + function readUInt(size) { + let value = 0; + while (size-- > 0) { + value *= 256; + value += array[pos++]; + } + return value; + } + + function readFloat(size) { + let view = new DataView(array.buffer, pos + array.byteOffset, size); + pos += size; + if (size === 4) + return view.getFloat32(0, false); + if (size === 8) + return view.getFloat64(0, false); + } + + function readBin(size, lengthSize) { + if (size < 0) size = readUInt(lengthSize); + let data = array.subarray(pos, pos + size); + pos += size; + return data; + } + + function readMap(size, lengthSize) { + if (size < 0) size = readUInt(lengthSize); + let data = {}; + while (size-- > 0) { + let key = read(); + data[key] = read(); + } + return data; + } + + function readArray(size, lengthSize) { + if (size < 0) size = readUInt(lengthSize); + let data = []; + while (size-- > 0) { + data.push(read()); + } + return data; + } + + function readStr(size, lengthSize) { + if (size < 0) size = readUInt(lengthSize); + let start = pos; + pos += size; + return decodeUtf8(array, start, size); + } + + function readExt(size, lengthSize) { + if (size < 0) size = readUInt(lengthSize); + let type = readUInt(1); + let data = readBin(size); + switch (type) { + case 255: + return readExtDate(data); + } + return { type: type, data: data }; + } + + function readExtDate(data) { + if (data.length === 4) { + let sec = ((data[0] << 24) >>> 0) + + ((data[1] << 16) >>> 0) + + ((data[2] << 8) >>> 0) + + data[3]; + return new Date(sec * 1000); + } + if (data.length === 8) { + let ns = ((data[0] << 22) >>> 0) + + ((data[1] << 14) >>> 0) + + ((data[2] << 6) >>> 0) + + (data[3] >>> 2); + let sec = ((data[3] & 0x3) * pow32) + + ((data[4] << 24) >>> 0) + + ((data[5] << 16) >>> 0) + + ((data[6] << 8) >>> 0) + + data[7]; + return new Date(sec * 1000 + ns / 1000000); + } + if (data.length === 12) { + let ns = ((data[0] << 24) >>> 0) + + ((data[1] << 16) >>> 0) + + ((data[2] << 8) >>> 0) + + data[3]; + pos -= 8; + let sec = readInt(8); + return new Date(sec * 1000 + ns / 1000000); + } + throw new Error("Invalid data length for a date value."); + } + } + + // Encodes a string to UTF-8 bytes. + function encodeUtf8(str) { + // Prevent excessive array allocation and slicing for all 7-bit characters + let ascii = true, length = str.length; + for (let x = 0; x < length; x++) { + if (str.charCodeAt(x) > 127) { + ascii = false; + break; + } + } + + // Based on: https://gist.github.com/pascaldekloe/62546103a1576803dade9269ccf76330 + let i = 0, bytes = new Uint8Array(str.length * (ascii ? 1 : 4)); + for (let ci = 0; ci !== length; ci++) { + let c = str.charCodeAt(ci); + if (c < 128) { + bytes[i++] = c; + continue; + } + if (c < 2048) { + bytes[i++] = c >> 6 | 192; + } + else { + if (c > 0xd7ff && c < 0xdc00) { + if (++ci >= length) + throw new Error("UTF-8 encode: incomplete surrogate pair"); + let c2 = str.charCodeAt(ci); + if (c2 < 0xdc00 || c2 > 0xdfff) + throw new Error("UTF-8 encode: second surrogate character 0x" + c2.toString(16) + " at index " + ci + " out of range"); + c = 0x10000 + ((c & 0x03ff) << 10) + (c2 & 0x03ff); + bytes[i++] = c >> 18 | 240; + bytes[i++] = c >> 12 & 63 | 128; + } + else bytes[i++] = c >> 12 | 224; + bytes[i++] = c >> 6 & 63 | 128; + } + bytes[i++] = c & 63 | 128; + } + return ascii ? bytes : bytes.subarray(0, i); + } + + // Decodes a string from UTF-8 bytes. + function decodeUtf8(bytes, start, length) { + // Based on: https://gist.github.com/pascaldekloe/62546103a1576803dade9269ccf76330 + let i = start, str = ""; + length += start; + while (i < length) { + let c = bytes[i++]; + if (c > 127) { + if (c > 191 && c < 224) { + if (i >= length) + throw new Error("UTF-8 decode: incomplete 2-byte sequence"); + c = (c & 31) << 6 | bytes[i++] & 63; + } + else if (c > 223 && c < 240) { + if (i + 1 >= length) + throw new Error("UTF-8 decode: incomplete 3-byte sequence"); + c = (c & 15) << 12 | (bytes[i++] & 63) << 6 | bytes[i++] & 63; + } + else if (c > 239 && c < 248) { + if (i + 2 >= length) + throw new Error("UTF-8 decode: incomplete 4-byte sequence"); + c = (c & 7) << 18 | (bytes[i++] & 63) << 12 | (bytes[i++] & 63) << 6 | bytes[i++] & 63; + } + else throw new Error("UTF-8 decode: unknown multibyte start 0x" + c.toString(16) + " at index " + (i - 1)); + } + if (c <= 0xffff) str += String.fromCharCode(c); + else if (c <= 0x10ffff) { + c -= 0x10000; + str += String.fromCharCode(c >> 10 | 0xd800) + str += String.fromCharCode(c & 0x3FF | 0xdc00) + } + else throw new Error("UTF-8 decode: code point 0x" + c.toString(16) + " exceeds UTF-16 reach"); + } + return str; + } + + // The exported functions + let msgpack = { + serialize: serialize, + deserialize: deserialize, + + // Compatibility with other libraries + encode: serialize, + decode: deserialize + }; + + // Environment detection + if (typeof module === "object" && module && typeof module.exports === "object") { + // Node.js + module.exports = msgpack; + } + else { + // Global object + window[window.msgpackJsName || "msgpack"] = msgpack; + } + +})(); diff --git a/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/nt4.js b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/nt4.js new file mode 100644 index 0000000000..1dc5edc402 --- /dev/null +++ b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/nt4.js @@ -0,0 +1,547 @@ +import "./msgpack/msgpack.js"; + +var typestrIdxLookup = { + NT4_TYPESTR: 0, + "double": 1, + "int": 2, + "float": 3, + "string": 4, + "json": 4, + "raw": 5, + "rpc": 5, + "msgpack": 5, + "protobuf": 5, + "boolean[]": 16, + "double[]": 17, + "int[]": 18, + "float[]": 19, + "string[]": 20 +} + +class NT4_TYPESTR { + static BOOL = "boolean"; + static FLOAT_64 = "double"; + static INT = "int"; + static FLOAT_32 = "float"; + static STR = "string"; + static JSON = "json"; + static BIN_RAW = "raw"; + static BIN_RPC = "rpc"; + static BIN_MSGPACK = "msgpack"; + static BIN_PROTOBUF = "protobuf"; + static BOOL_ARR = "boolean[]"; + static FLOAT_64_ARR = "double[]"; + static INT_ARR = "int[]"; + static FLOAT_32_ARR = "float[]"; + static STR_ARR = "string[]"; +} + +export class NT4_ValReq { + topics = new Set(); + + toGetValsObj() { + return { + "topics": Array.from(this.topics), + }; + } +} + +export class NT4_Subscription { + topics = new Set(); + options = new NT4_SubscriptionOptions(); + uid = -1; + + toSubscribeObj() { + return { + "topics": Array.from(this.topics), + "options": this.options.toObj(), + "subuid": this.uid, + }; + } + + toUnSubscribeObj() { + return { + "subuid": this.uid, + }; + } +} + +export class NT4_SubscriptionOptions { + periodicRate_s = 0.1; + all = false; + topicsonly = false; + prefix = true; //nonstandard default + + toObj() { + return { + "periodic": this.periodicRate_s, + "all": this.all, + "topicsonly": this.topicsonly, + "prefix": this.prefix, + }; + } +} + +export class NT4_Topic { + name = ""; + type = ""; + id = 0; + pubuid = 0; + properties = {}; //Properties are free-form, might have anything in them + + toPublishObj() { + return { + "name": this.name, + "type": this.type, + "pubuid": this.pubuid, + } + } + + toUnPublishObj() { + return { + "name": this.name, + "pubuid": this.pubuid, + } + } + + toPropertiesObj() { + return { + "name": this.name, + "update": this.properties, + } + } + + getTypeIdx() { + return typestrIdxLookup[this.type]; + } +} + +export class NT4_Client { + + + constructor(serverAddr, + onTopicAnnounce_in, //Gets called when server announces enough topics to form a new signal + onTopicUnAnnounce_in, //Gets called when server unannounces any part of a signal + onNewTopicData_in, //Gets called when any new data is available + onConnect_in, //Gets called once client completes initial handshake with server + onDisconnect_in) { //Gets called once client detects server has disconnected + + this.onTopicAnnounce = onTopicAnnounce_in; + this.onTopicUnAnnounce = onTopicUnAnnounce_in; + this.onNewTopicData = onNewTopicData_in; + this.onConnect = onConnect_in; + this.onDisconnect = onDisconnect_in; + + this.subscriptions = new Map(); + this.subscription_uid_counter = 0; + this.publish_uid_counter = 0; + + this.clientPublishedTopics = new Map(); + this.announcedTopics = new Map(); + + this.timeSyncBgEvent = setInterval(this.ws_sendTimestamp.bind(this), 5000); + + // WS Connection State (with defaults) + this.serverBaseAddr = serverAddr; + this.clientIdx = 0; + this.serverAddr = ""; + this.serverConnectionActive = false; + this.serverTimeOffset_us = 0; + + + } + + ////////////////////////////////////////////////////////////// + // PUBLIC API + + // Add a new subscription which requests announcment of topics + subscribeTopicNames(topicPatterns) { + var newSub = new NT4_Subscription(); + newSub.uid = this.getNewSubUID(); + newSub.options.topicsonly = true; + newSub.options.periodicRate_s = 1.0; + newSub.topics = new Set(topicPatterns); + + this.subscriptions.set(newSub.uid, newSub); + if (this.serverConnectionActive) { + this.ws_subscribe(newSub); + } + return newSub; + } + + // Add a new subscription. Returns a subscription object + subscribePeriodic(topicPatterns, period) { + var newSub = new NT4_Subscription(); + newSub.uid = this.getNewSubUID(); + newSub.options.periodicRate_s = period; + newSub.topics = new Set(topicPatterns); + + this.subscriptions.set(newSub.uid, newSub); + if (this.serverConnectionActive) { + this.ws_subscribe(newSub); + } + return newSub; + } + + // Add a new subscription. Returns a subscription object + subscribeAllSamples(topicPatterns) { + var newSub = new NT4_Subscription(); + newSub.uid = this.getNewSubUID(); + newSub.topics = new Set(topicPatterns); + newSub.options.all = true; + + this.subscriptions.set(newSub.uid, newSub); + if (this.serverConnectionActive) { + this.ws_subscribe(newSub); + } + return newSub; + } + + // Given an existing subscription, unsubscribe from it. + unSubscribe(sub) { + this.subscriptions.delete(sub.uid); + if (this.serverConnectionActive) { + this.ws_unsubscribe(sub); + } + } + + // Unsubscribe from all current subscriptions + clearAllSubscriptions() { + for (const sub of this.subscriptions.values()) { + this.unSubscribe(sub); + } + } + + // Set the properties of a particular topic + setProperties(topic, isPersistent, isRetained) { + topic.properties.persistent = isPersistent; + topic.properties.retained = isRetained; + if (this.serverConnectionActive) { + this.ws_setproperties(topic); + } + } + + // Publish a new topic from this client with the provided name and type + publishNewTopic(name, type) { + var newTopic = new NT4_Topic(); + newTopic.name = name; + newTopic.type = type; + this.publishTopic(newTopic); + return newTopic; + } + + // Publish an existing topic to the server + publishTopic(topic) { + topic.pubuid = this.getNewPubUID(); + this.clientPublishedTopics.set(topic.name, topic); + if (this.serverConnectionActive) { + this.ws_publish(topic); + } + } + + // UnPublish a previously-published topic from this client. + unPublishTopic(oldTopic) { + this.clientPublishedTopics.delete(oldTopic.name); + if (this.serverConnectionActive) { + this.ws_unpublish(oldTopic); + } + } + + // Send some new value to the server + // Timestamp is whatever the current time is. + addSample(topic, value) { + var timestamp = this.getServerTime_us(); + this.addSample(topic, timestamp, value); + } + + // Send some new timestamped value to the server + addSample(topic, timestamp, value) { + + if (typeof topic === 'string') { + var topicFound = false; + //Slow-lookup - strings are assumed to be topic names for things the server has already announced. + for (const topicIter of this.announcedTopics.values()) { + if (topicIter.name === topic) { + topic = topicIter; + topicFound = true; + break; + } + } + if (!topicFound) { + throw "Topic " + topic + " not found in announced server topics!"; + } + } + + var sourceData = [topic.pubuid, timestamp, topic.getTypeIdx(), value]; + var txData = msgpack.serialize(sourceData); + + this.ws_sendBinary(txData); + } + + ////////////////////////////////////////////////////////////// + // Server/Client Time Sync Handling + + getClientTime_us() { + return Math.round(performance.now() * 1000.0); + } + + getServerTime_us() { + return this.getClientTime_us() + this.serverTimeOffset_us; + } + + ws_sendTimestamp() { + var timeTopic = this.announcedTopics.get(-1); + if (timeTopic) { + var timeToSend = this.getClientTime_us(); + this.addSample(timeTopic, 0, timeToSend); + } + } + + ws_handleReceiveTimestamp(serverTimestamp, clientTimestamp) { + var rxTime = this.getClientTime_us(); + + //Recalculate server/client offset based on round trip time + var rtt = rxTime - clientTimestamp; + var serverTimeAtRx = serverTimestamp - rtt / 2.0; + this.serverTimeOffset_us = serverTimeAtRx - rxTime; + + } + + ////////////////////////////////////////////////////////////// + // Websocket Message Send Handlers + + ws_subscribe(sub) { + this.ws_sendJSON("subscribe", sub.toSubscribeObj()); + } + + ws_unsubscribe(sub) { + this.ws_sendJSON("unsubscribe", sub.toUnSubscribeObj()); + } + + ws_publish(topic) { + this.ws_sendJSON("publish", topic.toPublishObj()); + } + + ws_unpublish(topic) { + this.ws_sendJSON("unpublish", topic.toUnPublishObj()); + } + + ws_setproperties(topic) { + this.ws_sendJSON("setproperties", topic.toPropertiesObj()); + } + + ws_sendJSON(method, params) { //Sends a single json message + if (this.ws.readyState === WebSocket.OPEN) { + var txObj = [{ + "method": method, + "params": params + }]; + var txJSON = JSON.stringify(txObj); + + //console.log("[NT4] Client Says: " + txJSON); + + this.ws.send(txJSON); + } + } + + ws_sendBinary(data) { + if (this.ws.readyState === WebSocket.OPEN) { + this.ws.send(data); + } + } + + ////////////////////////////////////////////////////////////// + // Websocket connection Maintenance + + ws_onOpen() { + + // Add default time topic + var timeTopic = new NT4_Topic(); + timeTopic.name = "Time"; + timeTopic.id = -1; + timeTopic.pubuid = -1; + timeTopic.type = NT4_TYPESTR.INT; + this.announcedTopics.set(timeTopic.id, timeTopic); + + // Set the flag allowing general server communication + this.serverConnectionActive = true; + + //Publish any existing topics + for (const topic of this.clientPublishedTopics.values()) { + this.ws_publish(topic); + this.ws_setproperties(topic); + } + + //Subscribe to existing subscriptions + for (const sub of this.subscriptions.values()) { + this.ws_subscribe(sub); + } + + // User connection-opened hook + this.onConnect(); + } + + ws_onClose(e) { + //Clear flags to stop server communication + this.ws = null; + this.serverConnectionActive = false; + + // User connection-closed hook + this.onDisconnect(); + + //Clear out any local cache of server state + this.announcedTopics.clear(); + + console.log('[NT4] Socket is closed. Reconnect will be attempted in 0.5 second.', e.reason); + setTimeout(this.ws_connect.bind(this), 500); + + if (!e.wasClean) { + console.error('Socket encountered error!'); + } + + } + + ws_onError(e) { + console.log("[NT4] Websocket error - " + e.toString()); + this.ws.close(); + } + + ws_onMessage(e) { + if (typeof e.data === 'string') { + //console.log("[NT4] Server Says: " + e.data); + //JSON Message + var rxArray = JSON.parse(e.data); + + rxArray.forEach(function (msg) { + + //Validate proper format of message + if (typeof msg !== 'object') { + console.log("[NT4] Ignoring text message, JSON parsing did not produce an object."); + return; + } + + if (!("method" in msg) || !("params" in msg)) { + console.log("[NT4] Ignoring text message, JSON parsing did not find all required fields."); + return; + } + + var method = msg["method"]; + var params = msg["params"]; + + if (typeof method !== 'string') { + console.log("[NT4] Ignoring text message, JSON parsing found \"method\", but it wasn't a string."); + return; + } + + if (typeof params !== 'object') { + console.log("[NT4] Ignoring text message, JSON parsing found \"params\", but it wasn't an object."); + return; + } + + // Message validates reasonably, switch based on supported methods + if (method === "announce") { + + //Check to see if we already knew about this topic. If not, make a new object. + + var newTopic = null; + for (const topic of this.clientPublishedTopics.values()) { + if (params.name === topic.name) { + newTopic = topic; //Existing topic, use it. + } + } + + // Did not know about the topic. Make a new one. + if(newTopic === null){ + newTopic = new NT4_Topic(); + } + + newTopic.name = params.name; + newTopic.id = params.id; + + //Strategy - if server sends a pubid use it + // otherwise, preserve whatever we had? + //TODO - ask peter about this. It smells wrong. + if (params.pubid != null) { + newTopic.pubuid = params.pubuid; + } + + newTopic.type = params.type; + newTopic.properties = params.properties; + this.announcedTopics.set(newTopic.id, newTopic); + this.onTopicAnnounce(newTopic); + } else if (method === "unannounce") { + var removedTopic = this.announcedTopics.get(params.id); + if (!removedTopic) { + console.log("[NT4] Ignorining unannounce, topic was not previously announced."); + return; + } + this.announcedTopics.delete(removedTopic.id); + this.onTopicUnAnnounce(removedTopic); + + } else if (method === "properties") { + //TODO support property changes + } else { + console.log("[NT4] Ignoring text message - unknown method " + method); + return; + } + }, this); + + } else { + //MSGPack + var rxArray = msgpack.deserialize(e.data, { multiple: true }); + + rxArray.forEach(function (unpackedData) { //For every value update... + var topicID = unpackedData[0]; + var timestamp_us = unpackedData[1]; + var typeIdx = unpackedData[2]; + var value = unpackedData[3]; + + if (topicID >= 0) { + var topic = this.announcedTopics.get(topicID); + this.onNewTopicData(topic, timestamp_us, value); + } else if (topicID === -1) { + this.ws_handleReceiveTimestamp(timestamp_us, value); + } else { + console.log("[NT4] Ignoring binary data - invalid topic id " + topicID.toString()); + } + }, this); + + } + } + + ws_connect() { + + this.clientIdx = Math.floor(Math.random() * 99999999); //Not great, but using it for now + + var port = 5810; //fallback - unsecured + var prefix = "ws://"; + + this.serverAddr = prefix + this.serverBaseAddr + ":" + port.toString() + "/nt/" + "JSClient_" + this.clientIdx.toString(); + + this.ws = new WebSocket(this.serverAddr, "v4.1.networktables.first.wpi.edu"); + this.ws.binaryType = "arraybuffer"; + this.ws.onopen = this.ws_onOpen.bind(this); + this.ws.onmessage = this.ws_onMessage.bind(this); + this.ws.onclose = this.ws_onClose.bind(this); + this.ws.onerror = this.ws_onError.bind(this); + + console.log("[NT4] Connected with idx " + this.clientIdx.toString()); + } + + + + ////////////////////////////////////////////////////////////// + // General utilties + + getNewSubUID() { + this.subscription_uid_counter++; + return this.subscription_uid_counter + this.clientIdx; + } + + getNewPubUID() { + this.publish_uid_counter++; + return this.publish_uid_counter + this.clientIdx; + } + + +} diff --git a/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/signalDAQ_NT4.js b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/signalDAQ_NT4.js new file mode 100644 index 0000000000..d9cb6e7ba6 --- /dev/null +++ b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/interfaces/signalDAQ_NT4.js @@ -0,0 +1,120 @@ +///////////////////////////////////////////////////////////////////////// +// SignalDAQ - wrapper around NT4 to specifically extract signal information +// and allow clients to request one or more signals +// +// Mirroring (I assume) NT4 architecture, it's heavily callback driven +///////////////////////////////////////////////////////////////////////// + +import { NT4_Client } from "./nt4.js"; + +export class SignalDAQNT4 { + + + constructor(onSignalAnnounce_in, //Gets called when server announces enough topics to form a new signal + onSignalUnAnnounce_in, //Gets called when server unannounces any part of a signal + onNewSampleData_in, //Gets called when any new data is available + onConnect_in, //Gets called once client completes initial handshake with server + onDisconnect_in, //Gets called once client detects server has disconnected + statusTextCallback_in) { + this.onSignalAnnounce = onSignalAnnounce_in; + this.onSignalUnAnnounce = onSignalUnAnnounce_in; + this.onNewSampleData = onNewSampleData_in; + this.onConnect = onConnect_in; + this.onDisconnect = onDisconnect_in; + this.statusTextCallback = statusTextCallback_in; + + this.daqSignalList = new Set(); //start assuming no signals. + + this.daqRunning = false; + + this.rxCount = 0; + + this.timeOffset = 0; + + this.nt4Client = new NT4_Client(window.location.hostname, + this.topicAnnounceHandler.bind(this), + this.topicUnannounceHandler.bind(this), + this.valueUpdateHandler.bind(this), + this.localOnConnect.bind(this), + this.onDisconnect.bind(this) + ); + + this.statusTextCallback("Starting connection..."); + this.nt4Client.ws_connect(); + this.statusTextCallback("NT4 Connected."); + } + + localOnConnect() { + this.nt4Client.subscribeTopicNames(["/SmartDashboard"]); + this.onConnect(); + } + + topicAnnounceHandler( newTopic ) { + //If a signal units topic is announced, request what those units value actually is. + var sigName = newTopic.name; + var sigUnits = ""; + if(newTopic.properties.units){ + sigUnits = newTopic.properties.units; + } + this.onSignalAnnounce(sigName, sigUnits); //Announce signal when we know the value of its units + } + + topicUnannounceHandler( removedTopic ) { + this.onSignalUnAnnounce(removedTopic.name); + } + + valueUpdateHandler(topic, timestamp, value){ + // Got a new sample + var sigName = topic.name; + this.onNewSampleData(sigName, timestamp - this.timeOffset, value); + if(this.daqRunning){ + this.rxCount++; + } + this.updateStatusText(); + } + + //Request a signal get added to the DAQ + addSignal(signalNameIn){ + this.daqSignalList.add(signalNameIn); + } + + //Call to remove a signal from the DAQ + removeSignal(signalNameIn){ + this.daqSignalList.delete(signalNameIn); + } + + clearSignalList(){ + this.daqSignalList.clear(); + } + + //Request RIO start sending periodic updates with data values + startDAQ(){ + this.daqRunning = true; + this.daqSignalList.forEach(sigName => { + this.nt4Client.subscribeAllSamples([sigName]); + }); + this.rxCount = 0; + this.timeOffset = this.nt4Client.getServerTime_us(); + this.updateStatusText(); + } + + //Request RIO stop sending periodic updates + stopDAQ(){ + this.nt4Client.clearAllSubscriptions(); + this.daqRunning = false; + this.updateStatusText(); + } + + updateStatusText(){ + var text = ""; + if(this.daqRunning){ + text += "DAQ Running"; + } else { + text += "DAQ Stopped"; + } + text += " RX Count: " + this.rxCount.toString(); + this.statusTextCallback(text); + } + + +} \ No newline at end of file diff --git a/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/systemViewer.css b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/systemViewer.css new file mode 100644 index 0000000000..aa2b5c0a2e --- /dev/null +++ b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/systemViewer.css @@ -0,0 +1,99 @@ +/* Color variables for PhotonVision */ +:root { + --pv-primary: #006492; + --pv-accent: #FFD843; + --pv-accent2: #FFFFFF; + --pv-bg: oklch(27.4% .006 286.033); +} + +body { + background: var(--pv-bg); + padding: 0.25em; + margin: 0; + font-size: 16px; + font-family: "Lato", Verdana, sans-serif; + color: var(--pv-accent2); +} + +html, body { + height: 99vh; +} + + +.outlined { + border: 2px solid var(--pv-accent); + border-radius: 10px; + margin: 1px; +} + +.tile-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; + padding: 16px; +} + +.tile { + width: 300px; + height: 200px; + background: var(--pv-primary); + border: 2px solid var(--pv-accent); + border-radius: 10px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.25); +} + +.tile-content { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +.cam-name { + font-weight: bold; + background: var(--pv-primary); + color: var(--pv-accent2); + padding: 6px 8px; + text-align: center; + border-bottom: 2px solid var(--pv-primary); + font-size: 1.1em; + z-index: 1; + letter-spacing: 0.03em; +} + +.tile img { + flex: 1 1 auto; + object-fit: contain; + background: var(--pv-bg); + width: 100%; + height: 100%; + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; +} + +img:fullscreen { + width: 100vw; + height: 100vh; + object-fit: contain; + background: var(--pv-bg); + display: block; +} + +hr { + border: none; + border-top: 2px solid var(--pv-accent); + margin: 16px 0; +} + +::-webkit-scrollbar { + width: 10px; + background: var(--pv-bg); +} +::-webkit-scrollbar-thumb { + background: var(--pv-primary); + border-radius: 5px; +} \ No newline at end of file diff --git a/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/systemViewer.html b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/systemViewer.html new file mode 100644 index 0000000000..d270170462 --- /dev/null +++ b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/systemViewer.html @@ -0,0 +1,26 @@ + + + + + + + + PhotonVision System + + + + + +
+ +
+ +
+
Not yet connected...
+
+ + + + + + \ No newline at end of file diff --git a/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/systemViewer.js b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/systemViewer.js new file mode 100644 index 0000000000..b445870350 --- /dev/null +++ b/systemcore-apps/system-view/overlay/usr/local/bin/pv-system-viewer/www/systemViewer.js @@ -0,0 +1,169 @@ +import { NT4_Client } from "/interfaces/nt4.js"; + +var nt4Client = new NT4_Client(window.location.hostname, + topicAnnounceHandler, + topicUnannounceHandler, + valueUpdateHandler, + onConnect, + onDisconnect + ); + + +console.log("Starting connection..."); +nt4Client.ws_connect(); +console.log("Connection Triggered"); + +var subscription = null; + +function topicAnnounceHandler( newTopic ) { + + // If topic is a photonvision camera stream, show it in a card. + console.log("New topic announced: " + newTopic.name); + + if(isCamStreamTopic(newTopic.name)) { + console.log("Camera stream topic detected: " + newTopic.name); + + // Create or update the tile with the MJPEG stream URL + createOrUpdateTile(topicToCamName(newTopic.name), newTopic.value); + } +} + +function topicUnannounceHandler( removedTopic ) { + if(isCamStreamTopic(removedTopic.name)) { + removeTile(topicToCamName(removedTopic.name)); + } +} + +function valueUpdateHandler(topic, timestamp_us, value) { + // If topic is a photonvision camera stream, update the card with the new value. + if(isCamStreamTopic(topic.name)) { + console.log("Value update for topic: " + topic.name); + console.log("Value: ", value); + // Create or update the tile with the MJPEG stream URL + createOrUpdateTile(topicToCamName(topic.name), value); + } +} + +function topicToCamName(topicName) { + // Extract the camera name from the topic name + // Strips prefix and suffix, and replaces _ with spaces + // For example for the topic /CameraPublisher/photonvision_Port_1181_Input_MJPEG_Server/streams + // The name shall be "Port 1181 Input" + return topicName.split("/")[2].replace("photonvision_", "").replace("_MJPEG_Server", "").replace(/_/g, " "); +} + +function isCamStreamTopic(topicName) { + // Check if the topic is a photonvision camera stream + return topicName.startsWith("/CameraPublisher/") && topicName.endsWith("/streams"); +} + +function onConnect() { + + document.getElementById("status").innerHTML = "Connected to Server"; + removeAllTiles(); + subscribeToCamerServer(); +} + +function onDisconnect() { + document.getElementById("status").innerHTML = "Disconnected from Server"; + subscription = null; +} + + +function subscribeToCamerServer() { + if(subscription == null){ + subscription = nt4Client.subscribePeriodic(["/CameraPublisher/"], 0.5); + } +} + +function createOrUpdateTile(camName, mjpegUrlList) { + let tileId = camName + "-tile"; + + const grid = document.getElementById('tileGrid'); + let tile = document.getElementById(tileId); + + if (!tile) { + tile = document.createElement('div'); + tile.className = 'tile'; + tile.id = tileId; + grid.appendChild(tile); + } + + // Clear previous content + tile.innerHTML = ''; + + // Create a container for the cam name and image + const content = document.createElement('div'); + content.className = 'tile-content'; + + // Camera name element + const camNameElement = document.createElement('div'); + camNameElement.className = 'cam-name'; + camNameElement.textContent = camName; + + // Filter and clean up the MJPEG URLs + let urls = (Array.isArray(mjpegUrlList) ? mjpegUrlList : []) + .filter(entry => typeof entry === "string" && entry.startsWith("mjpg:")) + .map(entry => entry.replace("mjpg:", "")); + + // Prefer IPv4 addresses over DNS/mDNS + const ipv4Regex = /https?:\/\/(\d{1,3}\.){3}\d{1,3}/; + urls = urls.sort((a, b) => { + const aIsIp = ipv4Regex.test(a); + const bIsIp = ipv4Regex.test(b); + return (aIsIp === bIsIp) ? 0 : aIsIp ? -1 : 1; + }); + + if (urls.length === 0) { + tile.textContent = camName + ": No valid MJPEG stream found."; + return; + } + + // Create the img element + const img = document.createElement('img'); + img.style.width = '100%'; + img.style.height = '100%'; + img.alt = 'Stream'; + + let currentIdx = 0; + img.src = urls[currentIdx]; + + img.onerror = function () { + currentIdx++; + if (currentIdx < urls.length) { + img.src = urls[currentIdx]; + } else { + tile.textContent = camName + ": All MJPEG streams failed to load."; + } + }; + + img.onclick = function () { + if (img.requestFullscreen) { + img.requestFullscreen(); + } else if (img.webkitRequestFullscreen) { // Safari + img.webkitRequestFullscreen(); + } else if (img.msRequestFullscreen) { // IE11 + img.msRequestFullscreen(); + } +}; + + content.appendChild(camNameElement); + content.appendChild(img); + tile.appendChild(content); +} + + +function removeTile(camName) { + let tileId = camName + "-tile"; + const tile = document.getElementById(tileId); + if (tile) { + tile.remove(); + } +} + +function removeAllTiles() { + const grid = document.getElementById('tileGrid'); + while (grid.firstChild) { + grid.removeChild(grid.firstChild); + } +} \ No newline at end of file diff --git a/systemcore-apps/system-view/overlay/usr/share/pv-system-viewer.png b/systemcore-apps/system-view/overlay/usr/share/pv-system-viewer.png new file mode 100644 index 0000000000..b46064ca81 Binary files /dev/null and b/systemcore-apps/system-view/overlay/usr/share/pv-system-viewer.png differ