openvibers/openvibe.link Commits

Installer, release packaging, and stdout reserved for the plugin protocol

OP OpenVibers committed 29 Sept 2026 · b8da30a051 · 4 files, +255 −6

install.sh detects OS/CPU (and a Raspberry Pi), downloads the binary and
plugin bundle and checks SHA256SUMS, creates the plugin venv, writes a
config for --robot adeept|adeept-mecanum|cozmo|none, disables the Adeept
kit's stock server (Adeept_Robot.service), creates a service account with
the hardware groups, installs the service and pairs. scripts/dist.sh builds
every binary, the plugin bundle and the checksums. Tested here end to end
with --no-service against a local mirror of dist/.

The plugin runtime now keeps a private duplicate of stdout for the protocol
and points fd 1 at stderr: Adafruit Blinka prints board warnings to stdout.

Changes

ddiff --git a/install/install.sh b/install/install.sh
nnew file mode 100755
iindex 0000000..8bb20dd
---- /dev/null
++++ b/install/install.sh
@@@ -0,0 +1,202 @@
++#!/bin/sh
++# OpenVibe Node installer.
++#
++# curl -fsSL https://openvibe.bot/install | sh -s -- <CODE> [--robot adeept|adeept-mecanum|cozmo|none]
++#
++# Detects the OS and CPU, downloads the openvibe-node binary and the plugin bundle (checking their SHA-256), creates
++# a Python virtual environment for the plugins, disables the Adeept kit's stock control server if it finds it,
++# installs the system service and pairs the device with <CODE>. Run it again to upgrade; the credential is kept.
++#
++# Options:
++# --robot KIND adeept (ordinary wheels), adeept-mecanum, cozmo (bridge), none (dry run). Default: none.
++# --no-service install and pair, but do not install the service.
++# --local DIR install from DIR (openvibe-node-<os>-<arch> and openvibe-node-plugins.tar.gz) instead of
++# downloading.
++# --version TAG release to download (default: latest).
++# Environment:
++# OPENVIBE_NODE_BASE download base URL (default: GitHub releases of OpenVibers/OpenVibe.Node).
++# OPENVIBE_SERVER OpenVibe.Bot origin (default https://openvibe.bot).
++set -eu
++
++REPO_URL="https://github.com/OpenVibers/OpenVibe.Node"
++CODE=""
++ROBOT="none"
++SERVICE=1
++LOCAL=""
++TAG="latest"
++
++usage() {
++ echo "usage: install.sh [CODE] [--robot adeept|adeept-mecanum|cozmo|none] [--no-service] [--local DIR] [--version TAG]"
++ echo " curl -fsSL https://openvibe.bot/install | sh -s -- <CODE> --robot adeept"
++}
++say() { printf '%s\n' "openvibe-node: $*"; }
++die() { printf '%s\n' "openvibe-node: error: $*" >&2; exit 1; }
++
++while [ $# -gt 0 ]; do
++ case "$1" in
++ --robot) ROBOT="${2:-}"; shift 2 ;;
++ --robot=*) ROBOT="${1#*=}"; shift ;;
++ --no-service) SERVICE=0; shift ;;
++ --local) LOCAL="${2:-}"; shift 2 ;;
++ --version) TAG="${2:-}"; shift 2 ;;
++ -h|--help) usage; exit 0 ;;
++ -*) die "unknown option $1" ;;
++ *) CODE="$1"; shift ;;
++ esac
++done
++
++case "$ROBOT" in adeept|adeept-mecanum|cozmo|none) ;; *) die "--robot must be adeept, adeept-mecanum, cozmo or none" ;; esac
++
++# Root for the service, /usr/local/bin and /etc.
++SUDO=""
++if [ "$(id -u)" -ne 0 ]; then
++ command -v sudo >/dev/null 2>&1 || die "run as root (sudo is not installed)"
++ SUDO="sudo"
++fi
++
++# ---- platform ----
++OS="$(uname -s)"
++case "$OS" in
++Linux) OS=linux; BIN_DIR=/usr/local/bin; STATE=/var/lib/openvibe-node; CONF=/etc/openvibe-node ;;
++Darwin) OS=darwin; BIN_DIR=/usr/local/bin; STATE="/Library/Application Support/OpenVibe Node"; CONF="$STATE" ;;
++*) die "unsupported OS $OS (on Windows, download openvibe-node-windows-amd64.exe from $REPO_URL/releases)" ;;
++esac
++ARCH="$(uname -m)"
++case "$ARCH" in
++x86_64|amd64) ARCH=amd64 ;;
++aarch64|arm64) ARCH=arm64 ;;
++armv7l|armv7*) ARCH=armv7 ;;
++armv6l) die "ARMv6 (Raspberry Pi Zero / 1) is not supported; use a Pi 3, 4, 5 or Zero 2 with a 64-bit OS" ;;
++*) die "unsupported CPU $ARCH" ;;
++esac
++if [ "$OS" = darwin ] && [ "$ARCH" = armv7 ]; then die "unsupported platform"; fi
++IS_PI=0
++if [ -r /proc/device-tree/model ] && grep -q "Raspberry Pi" /proc/device-tree/model 2>/dev/null; then IS_PI=1; fi
++say "platform $OS/$ARCH$( [ $IS_PI = 1 ] && printf ' (Raspberry Pi)')"
++
++# ---- download ----
++TMP="$(mktemp -d)"
++trap 'rm -rf "$TMP"' EXIT
++BIN_NAME="openvibe-node-$OS-$ARCH"
++if [ -n "$LOCAL" ]; then
++ cp "$LOCAL/$BIN_NAME" "$TMP/$BIN_NAME" || die "$LOCAL/$BIN_NAME not found"
++ cp "$LOCAL/openvibe-node-plugins.tar.gz" "$TMP/" || die "$LOCAL/openvibe-node-plugins.tar.gz not found"
++else
++ if [ "$TAG" = latest ]; then
++ BASE="${OPENVIBE_NODE_BASE:-$REPO_URL/releases/latest/download}"
++ else
++ BASE="${OPENVIBE_NODE_BASE:-$REPO_URL/releases/download/$TAG}"
++ fi
++ fetch() {
++ if command -v curl >/dev/null 2>&1; then curl -fsSL --retry 3 -o "$2" "$1"
++ elif command -v wget >/dev/null 2>&1; then wget -q -O "$2" "$1"
++ else die "curl or wget is needed"; fi
++ }
++ say "downloading $BIN_NAME"
++ fetch "$BASE/$BIN_NAME" "$TMP/$BIN_NAME" || die "download failed: $BASE/$BIN_NAME"
++ fetch "$BASE/openvibe-node-plugins.tar.gz" "$TMP/openvibe-node-plugins.tar.gz" || die "download failed: plugin bundle"
++ fetch "$BASE/SHA256SUMS" "$TMP/SHA256SUMS" || die "download failed: SHA256SUMS"
++ ( cd "$TMP"
++ grep -E " ($BIN_NAME|openvibe-node-plugins\.tar\.gz)\$" SHA256SUMS > sums || die "no checksums for $BIN_NAME"
++ if command -v sha256sum >/dev/null 2>&1; then sha256sum -c sums >/dev/null
++ else shasum -a 256 -c sums >/dev/null; fi ) || die "checksum mismatch: refusing to install"
++fi
++
++# ---- binary ----
++$SUDO install -m 0755 "$TMP/$BIN_NAME" "$BIN_DIR/openvibe-node"
++say "installed $BIN_DIR/openvibe-node ($("$BIN_DIR/openvibe-node" version))"
++
++# ---- service account (Linux): hardware groups, no login ----
++SVC_USER=""
++if [ "$OS" = linux ] && [ "$SERVICE" = 1 ]; then
++ SVC_USER=openvibe-node
++ if ! id "$SVC_USER" >/dev/null 2>&1; then
++ $SUDO useradd --system --home-dir "$STATE" --no-create-home --shell /usr/sbin/nologin "$SVC_USER" 2>/dev/null ||
++ $SUDO adduser --system --home "$STATE" --no-create-home --shell /usr/sbin/nologin "$SVC_USER"
++ fi
++ for g in gpio i2c spi video audio dialout plugdev render; do
++ if getent group "$g" >/dev/null 2>&1; then $SUDO usermod -a -G "$g" "$SVC_USER"; fi
++ done
++fi
++$SUDO mkdir -p "$CONF" "$STATE"
++$SUDO chmod 700 "$CONF" "$STATE"
++
++# ---- plugins and their Python environment ----
++command -v python3 >/dev/null 2>&1 || die "python3 is needed for the plugins (sudo apt install python3 python3-venv)"
++$SUDO rm -rf "$STATE/plugins"
++$SUDO mkdir -p "$STATE/plugins"
++$SUDO tar -xzf "$TMP/openvibe-node-plugins.tar.gz" -C "$STATE/plugins"
++if [ ! -x "$STATE/venv/bin/python" ]; then
++ say "creating the plugin environment in $STATE/venv"
++ # --system-site-packages lets the Pi use the OS's picamera2/lgpio builds.
++ $SUDO python3 -m venv --system-site-packages "$STATE/venv" || die "python3 -m venv failed (sudo apt install python3-venv)"
++fi
++PIP="$STATE/venv/bin/pip"
++$SUDO "$PIP" install --quiet --upgrade pip >/dev/null 2>&1 || true
++$SUDO "$PIP" install --quiet "$STATE/plugins/sdk" "$STATE/plugins/dryrun"
++PLUGINS='[{"name": "dryrun"}]'
++KIND=onboard
++VIDEO='"source": "auto"'
++case "$ROBOT" in
++adeept|adeept-mecanum)
++ [ $IS_PI = 1 ] || say "warning: this is not a Raspberry Pi; the Adeept plugin will report a hardware fault"
++ $SUDO "$PIP" install --quiet "$STATE/plugins/adeept_adr036[pi]"
++ WHEELS=ordinary; [ "$ROBOT" = adeept-mecanum ] && WHEELS=mecanum
++ PLUGINS="[{\"name\": \"adeept_adr036\", \"config\": {\"backend\": \"real\", \"wheels\": \"$WHEELS\"}}]"
++ ;;
++cozmo)
++ $SUDO "$PIP" install --quiet "$STATE/plugins/cozmo[robot]"
++ PLUGINS='[{"name": "cozmo"}]'
++ KIND=bridge
++ command -v espeak-ng >/dev/null 2>&1 || say "tip: install espeak-ng so Cozmo can speak (sudo apt install espeak-ng)"
++ ;;
++esac
++if [ ! -f "$CONF/config.json" ]; then
++ $SUDO sh -c "umask 077; cat > '$CONF/config.json'" <<EOF
++{
++ "server": "${OPENVIBE_SERVER:-https://openvibe.bot}",
++ "device_kind": "$KIND",
++ "plugins": $PLUGINS,
++ "video": {$VIDEO}
++}
++EOF
++ say "wrote $CONF/config.json"
++else
++ say "keeping the existing $CONF/config.json"
++fi
++
++# ---- the Adeept kit's stock server: never run it (fixed admin:123456 login on 0.0.0.0:8888, MJPEG on :5000) ----
++if command -v systemctl >/dev/null 2>&1; then
++ unit=Adeept_Robot.service
++ if systemctl is-enabled "$unit" >/dev/null 2>&1 || systemctl is-active "$unit" >/dev/null 2>&1; then
++ say "disabling the Adeept kit's stock server ($unit): it listens on 0.0.0.0:8888 with a fixed password"
++ $SUDO systemctl disable --now "$unit" || true
++ fi
++fi
++if command -v pgrep >/dev/null 2>&1 && pgrep -f "Server_(Ordinary|Mecanum)Wheels/(WebServer|APPServer|GUIServer|app)\.py" >/dev/null 2>&1; then
++ say "stopping a running Adeept stock server process"
++ $SUDO pkill -f "Server_(Ordinary|Mecanum)Wheels/(WebServer|APPServer|GUIServer|app)\.py" || true
++fi
++
++# ---- service ----
++if [ "$SERVICE" = 1 ]; then
++ if [ -n "$SVC_USER" ]; then
++ $SUDO "$BIN_DIR/openvibe-node" install --user "$SVC_USER" >/dev/null 2>&1 || $SUDO "$BIN_DIR/openvibe-node" install --user "$SVC_USER"
++ else
++ $SUDO "$BIN_DIR/openvibe-node" install >/dev/null 2>&1 || $SUDO "$BIN_DIR/openvibe-node" install
++ fi
++ say "service installed and started"
++fi
++
++# ---- pair ----
++if [ -n "$CODE" ]; then
++ if $SUDO "$BIN_DIR/openvibe-node" status --json 2>/dev/null | grep -q '"paired":true'; then
++ say "already paired; keeping the credential (to pair again: sudo openvibe-node pair --force <CODE>)"
++ else
++ $SUDO "$BIN_DIR/openvibe-node" pair "$CODE"
++ fi
++else
++ say "not paired yet: get a code on openvibe.bot and run: sudo openvibe-node pair <CODE>"
++fi
++
++say "done. Check it with: sudo openvibe-node status Stop everything with: sudo openvibe-node stop"
ddiff --git a/plugins/sdk/openvibe_plugin/__init__.py b/plugins/sdk/openvibe_plugin/__init__.py
iindex fefbf74..73d726c 100644
---- a/plugins/sdk/openvibe_plugin/__init__.py
++++ b/plugins/sdk/openvibe_plugin/__init__.py
@@@ -13,6 +13,7 @@ See docs/plugins.md for the wire format.
"""
import json
++import os
import queue
import sys
import threading
@@@ -328,10 +329,20 @@ class Runtime:
return 0
++def protocol_stdout():
++ """Reserve stdout for the protocol.
++
++ Hardware libraries print to stdout (Adafruit Blinka warns about the board there), which would corrupt the JSON
++ lines. Keep a private duplicate of file descriptor 1 for the protocol, then point descriptor 1 and sys.stdout at
++ stderr, so every other print, from Python or from C, lands in the Node's log instead.
++ """
++ sys.stdout.flush()
++ proto = os.fdopen(os.dup(1), "w", buffering=1, encoding="utf-8")
++ os.dup2(2, 1)
++ sys.stdout = sys.stderr
++ return proto
++
++
def run(plugin):
"""Entry point for a plugin's __main__."""
-- try:
-- sys.stdout.reconfigure(line_buffering=True)
-- except AttributeError:
-- pass
-- return Runtime(plugin).run()
++ return Runtime(plugin, outfile=protocol_stdout()).run()
ddiff --git a/plugins/sdk/tests/test_runtime.py b/plugins/sdk/tests/test_runtime.py
iindex afdb4c8..1b5cc72 100644
---- a/plugins/sdk/tests/test_runtime.py
++++ b/plugins/sdk/tests/test_runtime.py
@@@ -162,12 +162,13 @@ class ProcessTest(unittest.TestCase):
"""The real thing: a child process whose stdin closes must stop and exit."""
def test_child_exits_on_eof(self):
-- code = ("import sys; sys.path.insert(0, %r)\n"
++ code = ("import os, sys; sys.path.insert(0, %r)\n"
"from openvibe_plugin import Plugin, run\n"
"class P(Plugin):\n"
" driver='t'\n"
" def handle(self, cmd): pass\n"
" def stop(self): sys.stderr.write('STOPPED\\n'); sys.stderr.flush()\n"
++ " def setup(self, config): print('library noise on stdout'); os.write(1, b'C noise\\n')\n"
"sys.exit(run(P()))\n") % os.path.dirname(HERE)
proc = subprocess.Popen([sys.executable, "-c", code], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
@@@ -182,6 +183,11 @@ class ProcessTest(unittest.TestCase):
self.assertEqual(rc, 0)
self.assertIn('"op":"ack"', out)
self.assertIn("STOPPED", err)
++ # Everything on stdout is protocol; library prints went to stderr.
++ for line in out.splitlines():
++ json.loads(line)
++ self.assertIn("library noise on stdout", err)
++ self.assertIn("C noise", err)
if __name__ == "__main__":
ddiff --git a/scripts/dist.sh b/scripts/dist.sh
nnew file mode 100755
iindex 0000000..bf8f254
---- /dev/null
++++ b/scripts/dist.sh
@@@ -0,0 +1,30 @@
++#!/bin/sh
++# Build the release artifacts into dist/: one binary per platform, the plugin bundle, install.sh and SHA256SUMS.
++# scripts/dist.sh [VERSION]
++set -eu
++VERSION="${1:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}"
++cd "$(dirname "$0")/.."
++rm -rf dist
++mkdir -p dist
++for target in linux/amd64 linux/arm64 linux/arm/7 darwin/amd64 darwin/arm64 windows/amd64; do
++ os="${target%%/*}"
++ rest="${target#*/}"
++ arch="${rest%%/*}"
++ arm=""
++ name="openvibe-node-$os-$arch"
++ if [ "$arch" = arm ]; then
++ arm="${rest#*/}"
++ name="openvibe-node-$os-armv$arm"
++ fi
++ [ "$os" = windows ] && name="$name.exe"
++ CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" GOARM="$arm" \
++ go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "dist/$name" ./cmd/openvibe-node
++ echo "built dist/$name"
++done
++# The plugin bundle: the Python packages without their tests.
++tar -czf dist/openvibe-node-plugins.tar.gz -C plugins \
++ --exclude=tests --exclude=__pycache__ --exclude='*.egg-info' --exclude=.pytest_cache --exclude=build \
++ sdk dryrun adeept_adr036 cozmo
++cp install/install.sh dist/install.sh
++(cd dist && if command -v sha256sum >/dev/null 2>&1; then sha256sum -- * >SHA256SUMS; else shasum -a 256 -- * >SHA256SUMS; fi)
++echo "dist/ ready for $VERSION"