From 60159327fb499d671ad07147c900614737fbb608 Mon Sep 17 00:00:00 2001 From: Nathan Brooks Date: Thu, 2 Jul 2026 18:48:35 -0600 Subject: [PATCH] feat: drive CD objectives over the Foxglove web bridge (Node) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the roslibpy/rosbridge objective runner with a Node runner (cd_objective_lib.mjs) that uses foxglove-ros-adapter over the always-on web bridge on port 3201 — no --enable-rosbridge sidecar. Rewrite the three example wrappers as .mjs, add package.json + a Node<22 WebSocket polyfill, install Node and npm deps in install.sh instead of pip roslibpy, and add a CLI shim to notify_lib.py so the Node runner reuses the existing Slack/GitHub notifier. Update README and QUICK_START. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 7 + QUICK_START.md | 8 +- README.md | 13 +- bin/notify_lib.py | 40 ++- example_scripts/3-waypoint-pick-and-place.mjs | 4 + example_scripts/3-waypoint-pick-and-place.py | 10 - example_scripts/cd_objective_lib.mjs | 241 +++++++++++++++ example_scripts/cd_objective_lib.py | 275 ------------------ example_scripts/ml-segment-image.mjs | 4 + example_scripts/ml-segment-image.py | 10 - example_scripts/move-all-boxes.mjs | 4 + example_scripts/move-all-boxes.py | 10 - example_scripts/package.json | 17 ++ example_scripts/ws-polyfill.mjs | 9 + install.sh | 36 +-- 15 files changed, 355 insertions(+), 333 deletions(-) create mode 100644 .gitignore create mode 100644 example_scripts/3-waypoint-pick-and-place.mjs delete mode 100755 example_scripts/3-waypoint-pick-and-place.py create mode 100644 example_scripts/cd_objective_lib.mjs delete mode 100644 example_scripts/cd_objective_lib.py create mode 100644 example_scripts/ml-segment-image.mjs delete mode 100755 example_scripts/ml-segment-image.py create mode 100644 example_scripts/move-all-boxes.mjs delete mode 100755 example_scripts/move-all-boxes.py create mode 100644 example_scripts/package.json create mode 100644 example_scripts/ws-polyfill.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d1cbc4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Python bytecode +__pycache__/ +*.py[cod] + +# Node dependencies (installed at deploy time by install.sh) +node_modules/ +package-lock.json diff --git a/QUICK_START.md b/QUICK_START.md index 15026e7..89a98e0 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -31,12 +31,12 @@ Replace `` with the target version (e.g. `9.2.0-rc6`). Downgrades work Each example script sends one goal and exits; the Behavior Tree itself loops internally. ```bash -/usr/bin/3-waypoint-pick-and-place.py -/usr/bin/ml-segment-image.py -/usr/bin/move-all-boxes.py +/usr/bin/3-waypoint-pick-and-place.mjs +/usr/bin/ml-segment-image.mjs +/usr/bin/move-all-boxes.mjs ``` -To wrap your own objective into a forever-loop driver, copy `example_scripts/cd_objective_lib.py` and call `run_objectives_forever([...])` with the Objective names you want to chain. +To wrap your own objective into a forever-loop driver, copy `example_scripts/cd_objective_lib.mjs` and call `runObjectivesForever([...])` with the Objective names you want to chain. ## Troubleshooting diff --git a/README.md b/README.md index 72d0362..724c7f0 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,11 @@ The full setup walkthrough lives at [Set Up CI/CD](https://docs.picknik.ai/how_t - `bin/install-moveit-pro` — root-owned installer wrapper. Validates the version string against a strict regex, downloads the `.deb` to a root-owned cache, installs it, and deletes the file. - `bin/moveit-pro@.service` — systemd template unit. Runs `moveit_pro run --no-browser` as `%i`. Restarts on failure. Reads optional environment from `/etc/default/moveit-pro`. - `bin/notify-crash.py` — posts to Slack and opens/updates a GitHub issue via `ExecStopPost` when the service exits non-zero. Reads `SLACK_WEBHOOK_URL` and `MOVEIT_CD_GITHUB_TOKEN` from the environment; each notification is skipped if its variable is unset. -- `bin/notify_lib.py` — shared notification helpers (`slack_post`, `github_issue`) used by both `notify-crash.py` and `cd_objective_lib.py`. Installed to `/usr/lib/moveit-pro-scripts/`. `github_issue` deduplicates by exact title within a label: a repeated failure bumps an occurrence counter and appends a row instead of opening a new issue. +- `bin/notify_lib.py` — shared notification helpers (`slack_post`, `github_issue`) used by `notify-crash.py` (Python import) and by the CD objective runner (`cd_objective_lib.mjs`, via the module's `--title`/`--reason` CLI shim). Installed to `/usr/lib/moveit-pro-scripts/`. `github_issue` deduplicates by exact title within a label: a repeated failure bumps an occurrence counter and appends a row instead of opening a new issue. - `bin/ci-runner.sudoers.template` — sudoers drop-in. `install.sh` substitutes `__CI_USER__` with the local account and installs at `/etc/sudoers.d/-ci`. Grants NOPASSWD on the installer and the user's own systemd unit only. -- `example_scripts/cd_objective_lib.py` — helper library for sending an Objective goal via rosbridge, used by the example scripts. On objective timeout or rosbridge failure it posts to Slack, opens/updates a GitHub issue, and stops the systemd unit (via `notify_lib.py`). -- `example_scripts/3-waypoint-pick-and-place.py`, `example_scripts/ml-segment-image.py`, `example_scripts/move-all-boxes.py` — example smoke-test scripts that drive an Objective on `localhost:3201` rosbridge. +- `example_scripts/cd_objective_lib.mjs` — Node helper library for sending an Objective goal over the MoveIt Pro web bridge (`foxglove_bridge`, port `3201`) using [`foxglove-ros-adapter`](https://www.npmjs.com/package/foxglove-ros-adapter). No `--enable-rosbridge` sidecar needed. On objective timeout or bridge failure it posts to Slack, opens/updates a GitHub issue, and stops the systemd unit (via `notify_lib.py`). +- `example_scripts/package.json` / `ws-polyfill.mjs` — Node dependency manifest (installed alongside the runner and `npm install`ed by `install.sh`) and the `WebSocket` global shim for Node 18–21. +- `example_scripts/3-waypoint-pick-and-place.mjs`, `example_scripts/ml-segment-image.mjs`, `example_scripts/move-all-boxes.mjs` — example smoke-test scripts that drive an Objective over the web bridge on `localhost:3201`. ## Install @@ -28,7 +29,7 @@ sudo ./install.sh This installs: - The objective scripts to `/usr/bin/`. -- `cd_objective_lib.py` and `notify_lib.py` to `/usr/lib/moveit-pro-scripts/`. +- `cd_objective_lib.mjs` (with its Node dependencies via `npm install`) and `notify_lib.py` to `/usr/lib/moveit-pro-scripts/`. - `notify-crash.py` to `/usr/bin/`. - `install-moveit-pro` to `/usr/local/sbin/` (root-owned, `0755`). - `/var/cache/moveit-pro/` as a root-owned download cache. @@ -64,7 +65,7 @@ WORKSPACE_PIN_TO_RELEASE=false ### Optional: failure notifications (Slack + GitHub issues) -Both notifiers read their config from `/etc/default/moveit-pro` (root-owned). The systemd unit loads this file via `EnvironmentFile=`, so `notify-crash.py` and `cd_objective_lib.py` pick it up for crash and CD-failure events. Each notifier is independent: set only the variables you want. +Both notifiers read their config from `/etc/default/moveit-pro` (root-owned). The systemd unit loads this file via `EnvironmentFile=`, so `notify-crash.py` and the CD objective runner pick it up for crash and CD-failure events. Each notifier is independent: set only the variables you want. ```bash sudo install -m 0640 -o root -g root /dev/stdin /etc/default/moveit-pro <<'EOF' @@ -107,7 +108,7 @@ The CI runner SSHes into each target machine over a mesh VPN (Tailscale, WireGua 1. `sudo -n /usr/local/sbin/install-moveit-pro ` — downloads and installs the `.deb`. 2. `sudo -n /bin/systemctl restart moveit-pro@.service` — restarts the service. -3. `/usr/bin/.py` — optional smoke test of an Objective via rosbridge. +3. `/usr/bin/.mjs` — optional smoke test of an Objective over the web bridge. The sudoers drop-in grants NOPASSWD on **only** steps 1 and 2. The installer validates the version string with a strict regex and downloads to a root-owned path, so a compromised CI account cannot escalate by planting a malicious `.deb`. diff --git a/bin/notify_lib.py b/bin/notify_lib.py index c1b8d5c..07e7e1e 100644 --- a/bin/notify_lib.py +++ b/bin/notify_lib.py @@ -4,7 +4,8 @@ Two call sites fire on a QA deployment failure: * notify-crash.py -- systemd ExecStopPost on non-zero service exit. - * cd_objective_lib.py -- objective-runner timeout / rosbridge failure. + * cd_objective_lib.mjs -- objective-runner timeout / web-bridge failure + (invokes this module's --title/--reason CLI shim). Both post to Slack (SLACK_WEBHOOK_URL) and, when a token is configured, open or update a deduplicated GitHub issue on the MoveIt Pro repo. Every function @@ -324,3 +325,40 @@ def _row(n): }, ) print(f"Updated GitHub issue #{number} (occurrence #{occurrence}): {title}") + + +def _main(argv=None): + """CLI entry point so non-Python callers (e.g. the Node CD runner) can send + the same Slack + deduplicated-GitHub-issue failure notification this module + exposes to Python importers. + + Best-effort: a failure in one channel never blocks the other, and the command + always exits 0 — notifications are auxiliary to stopping the service, which + the caller handles separately. + """ + import argparse + + parser = argparse.ArgumentParser( + description="Send a CD failure notification (Slack + deduplicated GitHub issue)." + ) + parser.add_argument("--title", required=True, help="GitHub issue title.") + parser.add_argument( + "--reason", required=True, help="Failure reason (Slack message + issue body)." + ) + parser.add_argument( + "--dry-run", action="store_true", help="Log the payloads instead of posting." + ) + args = parser.parse_args(argv) + + try: + slack_post(build_payload(args.reason), dry_run=args.dry_run) + except Exception as exc: # noqa: BLE001 - notifications are best-effort + print(f"slack_post failed: {exc}", file=sys.stderr) + try: + github_issue(args.title, args.reason, dry_run=args.dry_run) + except Exception as exc: # noqa: BLE001 - notifications are best-effort + print(f"github_issue failed: {exc}", file=sys.stderr) + + +if __name__ == "__main__": + _main() diff --git a/example_scripts/3-waypoint-pick-and-place.mjs b/example_scripts/3-waypoint-pick-and-place.mjs new file mode 100644 index 0000000..6fc9bfd --- /dev/null +++ b/example_scripts/3-waypoint-pick-and-place.mjs @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runObjective } from "/usr/lib/moveit-pro-scripts/cd_objective_lib.mjs"; + +await runObjective("3 Waypoints Pick and Place"); diff --git a/example_scripts/3-waypoint-pick-and-place.py b/example_scripts/3-waypoint-pick-and-place.py deleted file mode 100755 index 4f7c60e..0000000 --- a/example_scripts/3-waypoint-pick-and-place.py +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env python3 - -import sys - -sys.path.insert(0, "/usr/lib/moveit-pro-scripts") - -from cd_objective_lib import run_objective - -if __name__ == "__main__": - run_objective("3 Waypoints Pick and Place") diff --git a/example_scripts/cd_objective_lib.mjs b/example_scripts/cd_objective_lib.mjs new file mode 100644 index 0000000..c8f8a6c --- /dev/null +++ b/example_scripts/cd_objective_lib.mjs @@ -0,0 +1,241 @@ +// Shared CD objective runner (Foxglove web bridge). +// +// Connects to the always-on MoveIt Pro web bridge (foxglove_bridge, port 3201), +// waits up to 1 hour for the /do_objective action server, sends the objective +// goal, then exits. On timeout: stops the moveit-pro service, posts a Slack +// failure notification, and opens or updates a deduplicated GitHub issue (both +// via notify_lib.py). Unlike the previous roslibpy version this needs no +// --enable-rosbridge sidecar — it speaks the Foxglove protocol to port 3201. +// +// Intended to be launched detached from CI over SSH; the calling shell can exit +// immediately and the script keeps running on the host. + +import "./ws-polyfill.mjs"; // must precede the adapter import (installs global WebSocket on Node < 22) +import { Ros, ActionClient } from "foxglove-ros-adapter"; + +import { execFileSync } from "node:child_process"; +import os from "node:os"; + +const BRIDGE_URL = process.env.MOVEIT_WEB_BRIDGE_URL ?? "ws://localhost:3201"; +const ACTION_NAME = "/do_objective"; +const ACTION_TYPE = "moveit_studio_sdk_msgs/action/DoObjectiveSequence"; + +const TOTAL_TIMEOUT_MS = 3600_000; +const POLL_INTERVAL_MS = 10_000; +const CONNECT_TIMEOUT_MS = 10_000; +const ACTION_WAIT_MS = 10_000; +const SEND_GOAL_DRAIN_MS = 5_000; + +// notify_lib.py is installed alongside this module; override for local testing. +const NOTIFY_LIB = + process.env.MOVEIT_NOTIFY_LIB ?? "/usr/lib/moveit-pro-scripts/notify_lib.py"; + +// Objective context for the GitHub issue title, set once the runner knows which +// objective(s) it is driving. null until then (e.g. the bridge never came up). +let currentObjective = null; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function failureTitle() { + const suffix = currentObjective ? ` — ${currentObjective}` : ""; + return `QA deployment failure: ${os.hostname()}${suffix}`; +} + +// Notifications are auxiliary: a missing/broken notify_lib must not stop the +// runner from stopping the service. Degrade loudly. +function notify(title, reason) { + try { + execFileSync("python3", [NOTIFY_LIB, "--title", title, "--reason", reason], { + stdio: "inherit", + }); + } catch (err) { + console.error(`notify_lib failed, notifications skipped: ${err.message}`); + } +} + +function stopService() { + const user = process.env.USER || process.env.LOGNAME || ""; + if (!user) { + console.error("Cannot determine user for systemctl stop"); + return; + } + const unit = `moveit-pro@${user}.service`; + console.log(`Stopping ${unit}`); + try { + execFileSync("sudo", ["-n", "/bin/systemctl", "stop", unit], { + stdio: "inherit", + }); + } catch (err) { + console.error(`systemctl stop failed: ${err.message}`); + } +} + +function fail(reason) { + console.error(reason); + notify(failureTitle(), reason); + stopService(); + process.exit(1); +} + +// Wait for the web bridge to accept a WebSocket connection. The adapter does not +// auto-reconnect, so retry a fresh Ros per attempt until the deadline. +async function connectWithRetry(deadline) { + let attempt = 0; + while (Date.now() < deadline) { + attempt += 1; + const ros = new Ros({ url: BRIDGE_URL }); + const connected = await new Promise((resolve) => { + let settled = false; + const finish = (ok) => { + if (!settled) { + settled = true; + resolve(ok); + } + }; + const timer = setTimeout(() => finish(false), CONNECT_TIMEOUT_MS); + ros.on("connection", () => { + clearTimeout(timer); + finish(true); + }); + ros.on("error", () => { + clearTimeout(timer); + finish(false); + }); + ros.on("close", () => { + clearTimeout(timer); + finish(false); + }); + }); + if (connected) { + console.log(`Web bridge connected at ${BRIDGE_URL} (attempt ${attempt})`); + return ros; + } + try { + ros.close(); + } catch { + // ignore + } + console.log(`Waiting for web bridge at ${BRIDGE_URL}... (attempt ${attempt})`); + await sleep(POLL_INTERVAL_MS); + } + fail("Timeout: web bridge did not connect within 1h"); +} + +// Poll until the /do_objective action server is advertised. waitForAction checks +// the hidden action services the bridge exposes; it rejects if not ready within +// its own timeout, so wrap it in the overall deadline loop. +async function waitForActionServer(ros, deadline) { + let attempt = 0; + while (Date.now() < deadline) { + attempt += 1; + try { + await ros.waitForAction(ACTION_NAME, ACTION_WAIT_MS); + console.log(`${ACTION_NAME} action server is up (attempt ${attempt})`); + return; + } catch { + console.log(`Waiting for ${ACTION_NAME} action server... (attempt ${attempt})`); + await sleep(POLL_INTERVAL_MS); + } + } + fail(`Timeout: ${ACTION_NAME} action server not ready within 1h`); +} + +// Send one objective goal and return immediately after a short drain, without +// waiting for the action to terminate. Used for fire-and-forget smoke tests +// whose Behavior Tree self-loops or runs indefinitely. +export async function runObjective(objectiveName) { + currentObjective = objectiveName; + const deadline = Date.now() + TOTAL_TIMEOUT_MS; + const ros = await connectWithRetry(deadline); + try { + await waitForActionServer(ros, deadline); + const client = new ActionClient({ + ros, + name: ACTION_NAME, + actionType: ACTION_TYPE, + }); + console.log(`Sending objective: ${objectiveName}`); + client.sendGoal( + { objective_name: objectiveName }, + () => {}, + () => {}, + (err) => console.error(`Action error for '${objectiveName}': ${err}`), + ); + await sleep(SEND_GOAL_DRAIN_MS); + console.log(`Objective '${objectiveName}' sent successfully`); + } finally { + try { + ros.close(); + } catch { + // ignore + } + } +} + +// Send one objective goal and block until the action terminates. Any terminal +// status (success, abort, cancel) counts as completion — the result payload is +// opaque to this script, and real crashes are caught by the systemd +// notify-crash hook. Only rosbridge errors or a per-objective timeout fail(). +async function sendAndWait(client, objectiveName, perObjectiveTimeoutMs) { + currentObjective = objectiveName; + console.log(`Sending objective: ${objectiveName}`); + const goalId = client.sendGoal( + { objective_name: objectiveName }, + () => {}, + () => {}, + (err) => console.error(`Action error for '${objectiveName}': ${err}`), + ); + try { + await client.waitGoal(goalId, perObjectiveTimeoutMs); + } catch { + fail( + `Timeout: objective '${objectiveName}' did not terminate within ` + + `${Math.round(perObjectiveTimeoutMs / 1000)}s`, + ); + } + console.log(`Objective '${objectiveName}' terminated`); +} + +// Send each objective in order, wait for it to terminate, pause, then repeat — +// forever. Used by customer-config CD machines whose BT XML does not self-loop. +// Stuck objectives or bridge errors call fail() (Slack + GitHub issue + stop the +// unit); healthy iterations log and continue. +export async function runObjectivesForever( + objectives, + { iterationPauseMs = 5_000, perObjectiveTimeoutMs = 3600_000 } = {}, +) { + if (!objectives || objectives.length === 0) { + fail("runObjectivesForever called with empty objectives list"); + } + + // Seed the issue-title context with the whole list; sendAndWait narrows it to + // the specific objective once sending starts. This initial value is only what + // a pre-send failure (bridge / action server never came up) reports. + currentObjective = objectives.join(", "); + + const deadline = Date.now() + TOTAL_TIMEOUT_MS; + const ros = await connectWithRetry(deadline); + try { + await waitForActionServer(ros, deadline); + const client = new ActionClient({ + ros, + name: ACTION_NAME, + actionType: ACTION_TYPE, + }); + let iteration = 0; + for (;;) { + iteration += 1; + console.log(`--- Iteration ${iteration} ---`); + for (const objectiveName of objectives) { + await sendAndWait(client, objectiveName, perObjectiveTimeoutMs); + } + await sleep(iterationPauseMs); + } + } finally { + try { + ros.close(); + } catch { + // ignore + } + } +} diff --git a/example_scripts/cd_objective_lib.py b/example_scripts/cd_objective_lib.py deleted file mode 100644 index 777fa59..0000000 --- a/example_scripts/cd_objective_lib.py +++ /dev/null @@ -1,275 +0,0 @@ -#!/usr/bin/env python3 -"""Shared CD objective runner. - -Connects to rosbridge, waits up to 1 hour for the /do_objective action server, -sends the objective goal, then exits. On timeout: stops the moveit-pro service, -posts a Slack failure notification using the same webhook as notify-crash, and -opens or updates a deduplicated GitHub issue (see notify_lib). - -Intended to be launched detached from CI over SSH; the calling shell can -exit immediately and the script will continue running on the host. -""" - -import os -import socket -import subprocess -import sys -import time -from threading import Event - -import roslibpy -from roslibpy import ActionClient - -# Shared notification helpers live alongside this module once installed. -sys.path.insert(0, "/usr/lib/moveit-pro-scripts") - -try: - from notify_lib import build_payload, github_issue, slack_post -except ImportError as exc: - # A partial install / image skew must not stop the objective runner from - # running and stopping the service — notifications are auxiliary. Degrade - # to no-ops, loudly. - print(f"notify_lib unavailable, notifications disabled: {exc}", file=sys.stderr) - - def build_payload(process_time, date=None): - return {"process_time": process_time} - - def slack_post(payload, dry_run=False): - pass - - def github_issue(title, reason, version=None, dry_run=False): - pass - - -ROSBRIDGE_HOST = "localhost" -ROSBRIDGE_PORT = 3201 - -ACTION_NAME = "/do_objective" -ACTION_TYPE = "moveit_studio_sdk_msgs/action/DoObjectiveSequence" - -TOTAL_TIMEOUT_S = 3600 -POLL_INTERVAL_S = 10 -ROSBRIDGE_CONNECT_TIMEOUT_S = 10 -ROSAPI_CALL_TIMEOUT_S = 10 -SEND_GOAL_DRAIN_S = 5 - -# Objective context for the GitHub issue title, set once the runner knows which -# objective(s) it is driving. None until then (e.g. rosbridge never came up). -_current_objective = None - - -def _failure_title() -> str: - suffix = f" — {_current_objective}" if _current_objective else "" - return f"QA deployment failure: {socket.gethostname()}{suffix}" - - -def _slack_post(message: str) -> None: - slack_post(build_payload(message)) - - -def _stop_service() -> None: - user = os.environ.get("USER") or os.environ.get("LOGNAME") or "" - if not user: - print("Cannot determine user for systemctl stop", file=sys.stderr) - return - unit = f"moveit-pro@{user}.service" - print(f"Stopping {unit}") - subprocess.run( - ["sudo", "-n", "/bin/systemctl", "stop", unit], - check=False, - ) - - -def _fail(reason: str): - print(reason, file=sys.stderr) - _slack_post(reason) - github_issue(_failure_title(), reason) - _stop_service() - sys.exit(1) - - -def _wait_for_rosbridge_port(deadline: float) -> None: - attempt = 0 - while time.monotonic() < deadline: - attempt += 1 - s = socket.socket() - s.settimeout(2) - try: - if s.connect_ex((ROSBRIDGE_HOST, ROSBRIDGE_PORT)) == 0: - print(f"Rosbridge port reachable (attempt {attempt})") - return - finally: - s.close() - print(f"Waiting for rosbridge port {ROSBRIDGE_PORT}... (attempt {attempt})") - time.sleep(POLL_INTERVAL_S) - _fail("Timeout: rosbridge port not reachable within 1h") - - -def _connect_rosbridge(client: roslibpy.Ros, deadline: float) -> None: - try: - client.run(timeout=ROSBRIDGE_CONNECT_TIMEOUT_S) - except Exception as exc: - print(f"Initial rosbridge run failed: {exc}") - while time.monotonic() < deadline: - if client.is_connected: - print("Rosbridge websocket connected") - return - time.sleep(1) - _fail("Timeout: rosbridge websocket did not connect within 1h") - - -def _get_action_servers(client: roslibpy.Ros) -> list: - """Query rosapi for the list of advertised action servers. - - /rosapi/topics filters hidden topics (anything matching `_action/*`), so - enumerating via topics misses every ROS 2 action. /rosapi/action_servers - returns the action names directly. - """ - result = {"servers": []} - done = Event() - - def _ok(resp): - # resp is roslibpy.ServiceResponse (dict-like but not a dict subclass) - # in current roslibpy; access via .get to stay compatible if that ever - # changes back to a plain dict. - result["servers"] = resp.get("action_servers", []) or [] - done.set() - - def _err(err): - print(f"rosapi action_servers call failed: {err}") - done.set() - - service = roslibpy.Service( - client, "/rosapi/action_servers", "rosapi_msgs/srv/GetActionServers" - ) - service.call(roslibpy.ServiceRequest(), callback=_ok, errback=_err) - if not done.wait(timeout=ROSAPI_CALL_TIMEOUT_S): - print("rosapi action_servers: timed out waiting for response", file=sys.stderr) - return [] - return result["servers"] - - -def _wait_for_action_server(client: roslibpy.Ros, deadline: float) -> None: - attempt = 0 - while time.monotonic() < deadline: - attempt += 1 - if ACTION_NAME in _get_action_servers(client): - print(f"{ACTION_NAME} action server is up (attempt {attempt})") - return - print(f"Waiting for {ACTION_NAME} action server... (attempt {attempt})") - time.sleep(POLL_INTERVAL_S) - _fail(f"Timeout: {ACTION_NAME} action server not ready within 1h") - - -def run_objective(objective_name: str) -> None: - global _current_objective - _current_objective = objective_name - deadline = time.monotonic() + TOTAL_TIMEOUT_S - client = roslibpy.Ros(host=ROSBRIDGE_HOST, port=ROSBRIDGE_PORT) - - try: - _wait_for_rosbridge_port(deadline) - _connect_rosbridge(client, deadline) - _wait_for_action_server(client, deadline) - - action = ActionClient(client, ACTION_NAME, ACTION_TYPE) - print(f"Sending objective: {objective_name}") - action.send_goal( - {"objective_name": objective_name}, - lambda r: None, - lambda f: None, - lambda e: None, - ) - time.sleep(SEND_GOAL_DRAIN_S) - print(f"Objective '{objective_name}' sent successfully") - finally: - if client.is_connected: - client.terminate() - - -def _send_and_wait( - action: ActionClient, - objective_name: str, - per_objective_timeout_s: float, -) -> None: - """Send one objective goal and block until the action terminates. - - Any terminal status (success, abort, cancel) counts as completion of - this iteration — we don't introspect the result payload here because - DoObjectiveSequence's result shape is opaque to this script. Real - crashes still get caught by the systemd notify-crash hook. We only - fail() on rosbridge errors or timeouts. - """ - global _current_objective - _current_objective = objective_name - done = Event() - - def _on_result(_result): - done.set() - - def _on_feedback(_feedback): - return - - def _on_error(err): - print(f"Action error for '{objective_name}': {err}", file=sys.stderr) - done.set() - - print(f"Sending objective: {objective_name}") - action.send_goal( - {"objective_name": objective_name}, - _on_result, - _on_feedback, - _on_error, - ) - - if not done.wait(timeout=per_objective_timeout_s): - _fail( - f"Timeout: objective '{objective_name}' did not terminate within " - f"{per_objective_timeout_s:.0f}s" - ) - print(f"Objective '{objective_name}' terminated") - - -def run_objectives_forever( - objectives: list, - iteration_pause_s: float = 5, - per_objective_timeout_s: float = 3600, -) -> None: - """Send each objective in `objectives` in order, wait for it to terminate, - pause, then repeat — forever. - - Used by customer-config CD machines whose BT XML does not self-loop - (Clean-Botix populate_mission_scene + test_change_tool, Auto Wash - Test Run Job). Stuck objectives or rosbridge errors call _fail() which - Slacks, files a GitHub issue, and stops the systemd unit; healthy - iterations log and continue. - """ - if not objectives: - _fail("run_objectives_forever called with empty objectives list") - - # Seed the issue-title context with the whole list. _send_and_wait narrows - # it to the specific objective once we start sending; this initial value is - # only what a pre-send failure (rosbridge / action server never came up) - # reports. - global _current_objective - _current_objective = ", ".join(objectives) - - deadline = time.monotonic() + TOTAL_TIMEOUT_S - client = roslibpy.Ros(host=ROSBRIDGE_HOST, port=ROSBRIDGE_PORT) - - try: - _wait_for_rosbridge_port(deadline) - _connect_rosbridge(client, deadline) - _wait_for_action_server(client, deadline) - - action = ActionClient(client, ACTION_NAME, ACTION_TYPE) - iteration = 0 - while True: - iteration += 1 - print(f"--- Iteration {iteration} ---") - for objective_name in objectives: - _send_and_wait(action, objective_name, per_objective_timeout_s) - time.sleep(iteration_pause_s) - finally: - if client.is_connected: - client.terminate() diff --git a/example_scripts/ml-segment-image.mjs b/example_scripts/ml-segment-image.mjs new file mode 100644 index 0000000..d670411 --- /dev/null +++ b/example_scripts/ml-segment-image.mjs @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runObjective } from "/usr/lib/moveit-pro-scripts/cd_objective_lib.mjs"; + +await runObjective("ML Segment Image Loop"); diff --git a/example_scripts/ml-segment-image.py b/example_scripts/ml-segment-image.py deleted file mode 100755 index 7b27e13..0000000 --- a/example_scripts/ml-segment-image.py +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env python3 - -import sys - -sys.path.insert(0, "/usr/lib/moveit-pro-scripts") - -from cd_objective_lib import run_objective - -if __name__ == "__main__": - run_objective("ML Segment Image Loop") diff --git a/example_scripts/move-all-boxes.mjs b/example_scripts/move-all-boxes.mjs new file mode 100644 index 0000000..1e4a458 --- /dev/null +++ b/example_scripts/move-all-boxes.mjs @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runObjective } from "/usr/lib/moveit-pro-scripts/cd_objective_lib.mjs"; + +await runObjective("Move Boxes Looping"); diff --git a/example_scripts/move-all-boxes.py b/example_scripts/move-all-boxes.py deleted file mode 100755 index f1dc66a..0000000 --- a/example_scripts/move-all-boxes.py +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env python3 - -import sys - -sys.path.insert(0, "/usr/lib/moveit-pro-scripts") - -from cd_objective_lib import run_objective - -if __name__ == "__main__": - run_objective("Move Boxes Looping") diff --git a/example_scripts/package.json b/example_scripts/package.json new file mode 100644 index 0000000..e98eb78 --- /dev/null +++ b/example_scripts/package.json @@ -0,0 +1,17 @@ +{ + "name": "moveit-pro-cd-scripts", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "CD smoke-test scripts that drive a MoveIt Pro Objective over the Foxglove web bridge.", + "engines": { + "node": ">=18" + }, + "dependencies": { + "foxglove-ros-adapter": "^0.4.0", + "@foxglove/rosmsg": "^5.0.0", + "@foxglove/rosmsg2-serialization": "^3.0.0", + "zod": "^3.22.0", + "ws": "^8.18.0" + } +} diff --git a/example_scripts/ws-polyfill.mjs b/example_scripts/ws-polyfill.mjs new file mode 100644 index 0000000..8cb4f74 --- /dev/null +++ b/example_scripts/ws-polyfill.mjs @@ -0,0 +1,9 @@ +// The Foxglove adapter reads `WebSocket` from the global scope. Node 22+ ships a +// global WebSocket; on Node 18–21 there is none, so install the `ws` class as the +// global before the adapter module evaluates. Import this module FIRST (ESM +// evaluates imports in source order), ahead of `foxglove-ros-adapter`. +import NodeWebSocket from "ws"; + +if (typeof globalThis.WebSocket === "undefined") { + globalThis.WebSocket = NodeWebSocket; +} diff --git a/install.sh b/install.sh index 9d240de..46b0a63 100755 --- a/install.sh +++ b/install.sh @@ -27,30 +27,32 @@ if [[ -n "$CONFIG_SRC" && ! -f "$CONFIG_SRC" ]]; then exit 2 fi -if ! python3 -c "import roslibpy" 2>/dev/null; then - echo "Installing roslibpy" - sudo apt-get install -y python3-pip - # PEP 668: pip >= 23 on externally-managed Pythons (Ubuntu 23.04+, - # Debian 12+) refuses system-wide installs without --break-system-packages. - # Older pip (Ubuntu 22.04 ships 22.0.2) does not recognize the flag at all. - # Gate on the EXTERNALLY-MANAGED marker — present iff the flag is needed - # and supported. - PIP_ARGS=(--ignore-installed) - if compgen -G "/usr/lib/python3*/EXTERNALLY-MANAGED" > /dev/null; then - PIP_ARGS+=(--break-system-packages) - fi - python3 -m pip install "${PIP_ARGS[@]}" roslibpy +# The CD objective runner is Node (foxglove-ros-adapter over the web bridge on +# 3201). Ensure Node.js 18+ is present; install Node 20 LTS via NodeSource if not. +NODE_MAJOR=0 +if command -v node > /dev/null 2>&1; then + NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)" +fi +if [[ "$NODE_MAJOR" -lt 18 ]]; then + echo "Installing Node.js 20 LTS (via NodeSource)" + curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - + sudo apt-get install -y nodejs fi echo "Installing shared libraries to /usr/lib/moveit-pro-scripts/" sudo install -d -m 0755 -o root -g root /usr/lib/moveit-pro-scripts -sudo install -m 644 "$SCRIPT_DIR/example_scripts/cd_objective_lib.py" /usr/lib/moveit-pro-scripts/cd_objective_lib.py +sudo install -m 644 "$SCRIPT_DIR/example_scripts/cd_objective_lib.mjs" /usr/lib/moveit-pro-scripts/cd_objective_lib.mjs +sudo install -m 644 "$SCRIPT_DIR/example_scripts/ws-polyfill.mjs" /usr/lib/moveit-pro-scripts/ws-polyfill.mjs +sudo install -m 644 "$SCRIPT_DIR/example_scripts/package.json" /usr/lib/moveit-pro-scripts/package.json sudo install -m 644 "$SCRIPT_DIR/bin/notify_lib.py" /usr/lib/moveit-pro-scripts/notify_lib.py +echo "Installing Node dependencies for the CD runner" +sudo npm install --omit=dev --prefix /usr/lib/moveit-pro-scripts + echo "Installing objective scripts to /usr/bin/" -sudo install -m 755 "$SCRIPT_DIR/example_scripts/3-waypoint-pick-and-place.py" /usr/bin/3-waypoint-pick-and-place.py -sudo install -m 755 "$SCRIPT_DIR/example_scripts/ml-segment-image.py" /usr/bin/ml-segment-image.py -sudo install -m 755 "$SCRIPT_DIR/example_scripts/move-all-boxes.py" /usr/bin/move-all-boxes.py +sudo install -m 755 "$SCRIPT_DIR/example_scripts/3-waypoint-pick-and-place.mjs" /usr/bin/3-waypoint-pick-and-place.mjs +sudo install -m 755 "$SCRIPT_DIR/example_scripts/ml-segment-image.mjs" /usr/bin/ml-segment-image.mjs +sudo install -m 755 "$SCRIPT_DIR/example_scripts/move-all-boxes.mjs" /usr/bin/move-all-boxes.mjs echo "Installing notify-crash.py to /usr/bin/" sudo install -m 755 "$SCRIPT_DIR/bin/notify-crash.py" /usr/bin/notify-crash.py