openvibers/openvibe.link Commits

Node: the one command on the robot — install.sh with no code turns on the Pi's buses, finds the kit, shows a code for openvibe.bot/link and pairs once it is approved (#50)

OP OpenVibers committed 24 days ago · a9bf0a440f · 6 files, +436 −6

Owner, 2026-10-17: "I should be able to run a single command on my raspi that controls and handles the adeept 4dw (or
other) support easy and ready to go live and controllable". `curl -fsSL https://openvibe.bot/install | sh` with no
robot, no code, no Network pairing and not --worker/--computer:

- on a Raspberry Pi: I2C and SPI on (raspi-config, now and after a reboot; config.txt + dtparam without it), and the
  missing ones of i2c-tools, rpicam-apps (libcamera-apps on Bullseye), python3-venv, python3-lgpio and qrencode;
- the I2C bus read: PCA9685 at 0x5f is the Adeept ADR036 4WD (driver adeept), a rosbridge on 9090 a ROS 2 robot (ros2);
  other boards are reported (PCA9685 at 0x40, ADS7830 at 0x48, a Robot HAT at 0x14, an OLED at 0x3c); the camera named;
  --driver still wins;
- that driver installed as before, then a claim on openvibe.bot (POST /api/v1/claims, OpenVibe.Bot PR alongside): the
  code, the link and a QR code, polled until its owner approves it at openvibe.bot/link (16 minutes; denied or late adds
  nothing). The answer is checked value by value before the shell reads it;
- a model chosen there with another driver gets that driver (driver_setup is a function now, set_driver_config merges
  the plugins, kind and video into an existing config), then it pairs at once and checks the link, the driver and the
  camera, and prints the robot's panel and whether it is on the owner's Live channel.

--no-claim keeps the old unpaired install. scripts/test-install-claim.sh (in CI with shellcheck): the detection
(OPENVIBE_INSTALL_DETECT_ONLY with OPENVIBE_I2C_ADDRS/OPENVIBE_CAMERA) and the claim against a fake openvibe.bot
(OPENVIBE_INSTALL_CLAIM_ONLY): Bot and Network pairings, a denial, an answer that could break out of the shell's
quotes. Checked end to end against the real Bot. Docs: README, docs/install.md, docs/hardware-adeept.md.


Claude-Session: https://claude.ai/code/session_01UgqWFqsrjYdYbusKn5547T

Co-authored-by: Claude Opus 5.5 <[email protected]>

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 12eca0a..b603bcb 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -106,11 +106,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
-- - run: shellcheck -s sh install/install.sh scripts/dist.sh scripts/test-install-stock-server.sh scripts/test-install-pair-args.sh scripts/test-install-worker-config.sh scripts/test-install-computer-config.sh
++ - run: shellcheck -s sh install/install.sh scripts/dist.sh scripts/test-install-stock-server.sh scripts/test-install-pair-args.sh scripts/test-install-worker-config.sh scripts/test-install-computer-config.sh scripts/test-install-claim.sh
- run: sh scripts/test-install-stock-server.sh
- run: sh scripts/test-install-pair-args.sh
- run: sh scripts/test-install-worker-config.sh
- run: sh scripts/test-install-computer-config.sh
++ - run: sh scripts/test-install-claim.sh
esp32-conformance:
name: esp32 protocol conformance (desktop)
ddiff --git a/README.md b/README.md
iindex 64fdd49..37e3bc0 100644
---- a/README.md
++++ b/README.md
@@@ -4,6 +4,18 @@ One service for your computers, servers, Raspberry Pis, phones and robots: pair
like **OpenVibe.Bot** (robots: drive, cameras, sensors, with safety) and **OpenVibe.Actor** (computer control for AI
agents, at the level you set on the machine) can use the device. It connects out, needs no open ports, and keeps a local kill switch.
++On a robot's Raspberry Pi (or other Linux computer), the one command:
++
++```sh
++curl -fsSL https://openvibe.bot/install | sh
++```
++
++It turns on I2C and SPI, installs the camera tools, finds the kit (an Adeept ADR036 4WD, a ROS 2 robot), shows a code
++to approve at openvibe.bot/link, and pairs: the robot is made, drivable on its panel and, if you chose it, live on your
++OpenVibe.Live channel ([docs/install.md](docs/install.md#the-one-command-raspberry-pi-and-other-linux-robots)).
++
++With a robot already added on openvibe.bot:
++
```sh
curl -fsSL https://openvibe.bot/install | sh -s -- --robot rob_… --code ABCD-1234 --driver adeept
```
ddiff --git a/docs/hardware-adeept.md b/docs/hardware-adeept.md
iindex 034ed79..df625a5 100644
---- a/docs/hardware-adeept.md
++++ b/docs/hardware-adeept.md
@@@ -4,9 +4,19 @@ The device side is the OpenVibe Node (`openvibe-node`) with the `adeept_adr036`
installer; the panel is `openvibe.bot/panel/<rob_…>`. Nothing here runs the kit's stock control server — the Node
replaces it, and the installer disables the kit's own autostart (`docs/install.md`).
++## 0. The one command
++
++```sh
++curl -fsSL https://openvibe.bot/install | sh
++```
++
++On the Pi, with the kit's board on it: it turns on I2C and SPI, installs the camera tools and the plugin's packages,
++finds the kit (PCA9685 at `0x5f`, ADS7830 at `0x48`), shows a code for openvibe.bot/link, and pairs once you approve it
++there (pick **Adeept ADR036 mecanum** on that page for the mecanum chassis). Sections 1 and 2 are the same steps by hand.
++
## 1. On the Pi (Raspberry Pi OS Bookworm, 64-bit)
--- `sudo raspi-config` → Interface Options: enable **I2C** and **SPI**. Reboot. (Or set `dtparam=i2c_arm=on` and
++- `sudo raspi-config` → Interface Options: enable **I2C** and **SPI** (the one command does this). Reboot. (Or set `dtparam=i2c_arm=on` and
`dtparam=spi=on` in `/boot/firmware/config.txt`.)
- Confirm the kit's stock control server is not running: `systemctl is-enabled Adeept_Robot.service` must report
`disabled` (or the unit absent), and ports 8888/5000 must be free. `install/install.sh` stops and disables
ddiff --git a/docs/install.md b/docs/install.md
iindex 956437a..68efeef 100644
---- a/docs/install.md
++++ b/docs/install.md
@@@ -3,7 +3,42 @@
The Node is one program, `openvibe-node`, plus Python plugins for robots. It connects **out** to OpenVibe (no open
ports, works behind any home router), and a local kill switch stops everything it drives.
--## Quick start (Linux, Raspberry Pi, macOS)
++## The one command (Raspberry Pi and other Linux robots)
++
++On the robot's computer, with nothing set up on openvibe.bot first:
++
++```sh
++curl -fsSL https://openvibe.bot/install | sh
++```
++
++On a Raspberry Pi it turns on I2C and SPI (now and after a reboot, through `raspi-config`), installs what is missing of
++`i2c-tools`, the camera tools (`rpicam-apps`), `python3-venv`, `python3-lgpio` and `qrencode`, and looks at the I2C bus:
++a PCA9685 at `0x5f` is the Adeept ADR036 4WD (driver `adeept`), a rosbridge on port 9090 a ROS 2 robot (`ros2`). It
++installs that driver, then prints a code and a QR code:
++
++```
++ Approve this robot on openvibe.bot (signed in as you):
++ open https://openvibe.bot/link
++ code KQ7M-2XRD
++```
++
++Open the link (or scan the QR with your phone), sign in, and approve it: openvibe.bot shows what the machine found and
++lets you set its name, its model (the Adeept mecanum chassis looks the same on the bus: pick it there), who may drive,
++and whether its camera goes on your OpenVibe.Live channel. The installer then pairs, sets up the driver for the model
++you chose if it differs, and checks the link, the driver and the camera:
++
++```
++ link connected
++ driver adeept_adr036: running
++ camera live from rpicam
++openvibe-node: drive it: https://openvibe.bot/panel/rob_…
++```
++
++The code lasts 15 minutes; a denied or expired one adds nothing, and running the command again gives a new one. An
++already-paired machine keeps its pairing. `--driver` still overrides what was found, and `--no-claim` installs without
++asking for a code. To drive the robot from your Live chat, use **Use it in my OpenVibe.Live chat** on the approval page.
++
++## Quick start with a code from openvibe.bot (Linux, Raspberry Pi, macOS)
1. On openvibe.bot, add a robot. You get a code like `ABCD-1234` (valid 10 minutes) and the installer command with
the robot's id filled in.
ddiff --git a/install/install.sh b/install/install.sh
iindex 8156b23..300c25d 100755
---- a/install/install.sh
++++ b/install/install.sh
@@@ -1,6 +1,7 @@
#!/bin/sh
# OpenVibe Node installer.
#
++# curl -fsSL https://openvibe.bot/install | sh
# curl -fsSL https://openvibe.bot/install | sh -s -- --robot <rob_…> --code <CODE> [--driver adeept|adeept-mecanum|cozmo|looi|ros2|ros2-holonomic|none]
# curl -fsSL https://openvibe.bot/install | sh -s -- --network <URL> --pairing <pair_…> --code <CODE> [--driver …]
# curl -fsSL https://openvibe.bot/install | sh -s -- --network <URL> --pairing <pair_…> --code <CODE> --worker
@@@ -10,6 +11,12 @@
# installs the system service and pairs the device with <CODE>. Run it again to upgrade; the credential is kept.
# openvibe.bot shows one of these lines with the robot id (or the Network pairing) and a fresh code filled in.
#
++# With no robot and no code (the first line) it is the one command on the robot's computer: on a Raspberry Pi it turns
++# on I2C and SPI, installs the camera tools and what the drivers need, looks at the I2C bus for a kit it knows (an
++# Adeept ADR036 4WD at 0x5f) and at the camera, installs, then shows a code and a QR code. Its owner enters the code at
++# openvibe.bot/link (signed in), sees what was found and approves it; the robot is made, this machine pairs with it,
++# and the installer checks the driver and the camera are running and prints the robot's panel.
++#
# Options:
# --robot ROBOT the rob_… id of the robot to pair with (from openvibe.bot). A driver kind here (adeept, …) is the
# old meaning of --robot: still accepted, but deprecated; use --driver.
@@@ -28,6 +35,7 @@
# docs/computer.md), at LEVEL observe, ask, trusted or full. The service then runs as YOUR account
# (the one that ran sudo), never root, and its folders are your home. Linux only; not with --worker.
# --no-service install and pair, but do not install the service.
++# --no-claim with no robot and no code: install without pairing (no code is asked for on openvibe.bot).
# --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).
@@@ -57,6 +65,8 @@ LOCAL=""
TAG="latest"
WORKER=0
COMPUTER=""
++CLAIM_OK=1
++DRIVER_GIVEN=0
# The OpenVibe.Codes release whose `openvibe-codes job` the worker runs (protocol [email protected]).
CODES_VERSION="${OPENVIBE_CODES_VERSION:-v0.3.1}"
CODES_DIR=/opt/openvibe-codes
@@@ -64,8 +74,9 @@ CODES_DIR=/opt/openvibe-codes
usage() {
echo "usage: install.sh [--robot rob_… | --network URL --pairing pair_…] [--code CODE | CODE] [--name NAME]"
echo " [--driver adeept|adeept-mecanum|cozmo|looi|ros2|ros2-holonomic|none] [--worker]"
-- echo " [--computer observe|ask|trusted|full] [--no-service]"
++ echo " [--computer observe|ask|trusted|full] [--no-service] [--no-claim]"
echo " [--local DIR] [--version TAG]"
++ echo " curl -fsSL https://openvibe.bot/install | sh (finds the kit, then shows a code to approve on openvibe.bot/link)"
echo " curl -fsSL https://openvibe.bot/install | sh -s -- --robot rob_… --code ABCD-1234 --driver adeept"
echo " curl -fsSL https://openvibe.bot/install | sh -s -- --network https://openvibe.network --pairing pair_… --code ABCD-1234"
}
@@@ -83,8 +94,8 @@ while [ $# -gt 0 ]; do
case "$1" in
--robot) robot_arg "${2:-}"; shift 2 ;;
--robot=*) robot_arg "${1#*=}"; shift ;;
-- --driver) DRIVER="${2:-}"; shift 2 ;;
-- --driver=*) DRIVER="${1#*=}"; shift ;;
++ --driver) DRIVER="${2:-}"; DRIVER_GIVEN=1; shift 2 ;;
++ --driver=*) DRIVER="${1#*=}"; DRIVER_GIVEN=1; shift ;;
--code) CODE="${2:-}"; shift 2 ;;
--code=*) CODE="${1#*=}"; shift ;;
--name) NAME="${2:-}"; shift 2 ;;
@@@ -97,6 +108,7 @@ while [ $# -gt 0 ]; do
--computer) COMPUTER="${2:-}"; shift 2 ;;
--computer=*) COMPUTER="${1#*=}"; shift ;;
--no-service) SERVICE=0; shift ;;
++ --no-claim) CLAIM_OK=0; shift ;;
--local) LOCAL="${2:-}"; shift 2 ;;
--version) TAG="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
@@@ -112,6 +124,9 @@ case "$PAIRING" in ""|pair_*) ;; *) [ -z "$CODE" ] || die "--pairing must be a p
if [ -n "$PAIRING" ] && [ -z "$NETWORK" ]; then die "--pairing needs --network, the OpenVibe.Network URL"; fi
if [ -n "$NETWORK" ] && [ -z "$CODE" ]; then die "--network needs the pairing code (--code)"; fi
case "$NETWORK" in ""|https://*|http://localhost*|http://127.0.0.1*) ;; *) die "--network must be an https:// URL" ;; esac
++# No robot, no code, no Network pairing, not a worker or a computer: the one command (openvibe.bot/link, see the top).
++CLAIM=0
++if [ "$CLAIM_OK" = 1 ] && [ -z "$CODE$ROBOT_ID$NETWORK$PAIRING" ] && [ "$WORKER" = 0 ] && [ -z "$COMPUTER" ]; then CLAIM=1; fi
# Run "$@" pair … with the pairing the flags gave: through Network with --network, else Bot's legacy code.
pair_with() {
@@@ -131,6 +146,119 @@ if [ "${OPENVIBE_INSTALL_PAIR_ARGS_ONLY:-0}" = 1 ]; then
exit 0
fi
++# set_driver_config <config.json>: $PLUGINS, $KIND and $VIDEO (driver_setup's) in that config, the rest kept (a computer
++# plugin too). The one command writes the driver the bus suggested, then the one its owner chose, if that differs.
++set_driver_config() {
++ ${SUDO:-} env OV_PLUGINS="$PLUGINS" OV_KIND="$KIND" OV_VIDEO="{$VIDEO}" python3 - "$1" <<'PY'
++import json, os, sys
++path = sys.argv[1]
++try:
++ with open(path) as f:
++ conf = json.load(f)
++except (OSError, ValueError):
++ conf = {}
++keep = [p for p in conf.get('plugins') or [] if p.get('name') == 'computer']
++conf['plugins'] = json.loads(os.environ['OV_PLUGINS']) + keep
++conf['device_kind'] = os.environ['OV_KIND']
++conf['video'] = {**(conf.get('video') or {}), **json.loads(os.environ['OV_VIDEO'])}
++os.umask(0o077)
++with open(path + '.tmp', 'w') as f:
++ json.dump(conf, f, indent=2)
++ f.write('\n')
++os.replace(path + '.tmp', path)
++PY
++}
++
++# claim_robot: ask openvibe.bot for a code, show it (and a QR), and wait up to 16 minutes for its owner to approve it
++# on openvibe.bot/link. Approved, it sets ROBOT_ID, CODE (or NETWORK and PAIRING), CLAIM_DRIVER, ROBOT_NAME, PANEL_URL
++# and LIVE from the answer, each checked before the shell reads it. Denied or late, it fails.
++claim_robot() {
++ _answer="$TMP/claim.env"
++ # shellcheck disable=SC1091 # the OS's own file, read for its name only
++ OV_HW_MODEL="$({ tr -d '\0' < /proc/device-tree/model; } 2>/dev/null || true)" \
++ OV_HW_OS="$( (. /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-}") || true)" \
++ OV_HW_ARCH="$(uname -m)" OV_HW_KIT="${KIT:-}" OV_HW_KIT_NAME="${KIT_NAME:-}" OV_HW_FOUND="${FOUND:-}" OV_HW_CAMERA="${CAMERA:-}" \
++ python3 - "${OPENVIBE_SERVER:-https://openvibe.bot}" "$_answer" "${NAME:-$(hostname)}" <<'PY' || return 1
++import json, os, re, subprocess, sys, time, urllib.error, urllib.request
++server, out, host = sys.argv[1].rstrip('/'), sys.argv[2], sys.argv[3]
++hw = {k: os.environ.get('OV_HW_' + k.upper(), '').strip() for k in ('model', 'os', 'arch', 'kit', 'kit_name', 'camera')}
++hw = {k: v for k, v in hw.items() if v}
++found = [x for x in os.environ.get('OV_HW_FOUND', '').split('|') if x]
++if found:
++ hw['found'] = found
++def call(method, path, body=None, headers=None):
++ req = urllib.request.Request(server + path, method=method, data=None if body is None else json.dumps(body).encode(),
++ headers={'Content-Type': 'application/json', 'User-Agent': 'openvibe-node-installer', **(headers or {})})
++ with urllib.request.urlopen(req, timeout=20) as r:
++ return json.load(r)
++def die(msg):
++ print(f'\nopenvibe-node: error: {msg}', file=sys.stderr)
++ sys.exit(1)
++try:
++ c = call('POST', '/api/v1/claims', {'hostname': host, 'hardware': hw})
++except Exception as e: # noqa: BLE001 - say what happened and stop
++ die(f'could not ask {server} for a code: {e}')
++print(f"\n Approve this robot on openvibe.bot (signed in as you):\n open {c['verification_url']}\n code {c['user_code']}\n")
++try:
++ subprocess.run(['qrencode', '-t', 'ANSIUTF8', '-m', '2', c['verification_url_complete']], check=True, timeout=10)
++ print(' (or scan this with your phone)\n')
++except Exception: # noqa: BLE001 - no QR, the link is enough
++ print(f" or open {c['verification_url_complete']}\n")
++interval, deadline = max(2, int(c.get('interval') or 3)), time.time() + 16 * 60
++sys.stdout.write(' waiting for you to approve it')
++sys.stdout.flush()
++while time.time() < deadline:
++ time.sleep(interval)
++ try:
++ s = call('GET', '/api/v1/claims/' + c['id'], headers={'X-OV-Claim-Secret': c['secret']})
++ except urllib.error.HTTPError as e:
++ if e.code == 404:
++ die('openvibe.bot no longer knows this code: run the installer again for a new one')
++ interval = min(30, interval * 2) if e.code == 429 else interval
++ continue
++ except Exception: # noqa: BLE001 - the network blinked: keep waiting
++ continue
++ state = s.get('state')
++ if state == 'pending':
++ sys.stdout.write('.')
++ sys.stdout.flush()
++ continue
++ if state == 'denied':
++ die('the code was denied on openvibe.bot, so nothing was added')
++ if state != 'approved':
++ die(f'the code is {state}: run the installer again for a new one')
++ p = s.get('pairing') or {}
++ network = bool(p.get('pairing_id'))
++ vals = {'ROBOT_ID': '' if network else p.get('robot') or '', 'CODE': p.get('code') or '',
++ 'NETWORK': p.get('network') or '' if network else '', 'PAIRING': p.get('pairing_id') or '',
++ 'CLAIM_DRIVER': s.get('driver') or 'none', 'ROBOT_NAME': s.get('name') or '', 'PANEL_URL': s.get('panel_url') or '',
++ 'LIVE': '1' if s.get('live') else '0'}
++ shape = {'ROBOT_ID': r'(rob_[0-9A-Za-z]+)?', 'CODE': r'[0-9A-Za-z-]{4,20}', 'NETWORK': r'(https://[A-Za-z0-9.:/-]+)?',
++ 'PAIRING': r'(pair_[0-9A-Za-z]+)?', 'CLAIM_DRIVER': r'adeept|adeept-mecanum|cozmo|looi|ros2|ros2-holonomic|none',
++ 'ROBOT_NAME': r"[^'\n\\]{0,80}", 'PANEL_URL': r'((https://|http://(localhost|127\.0\.0\.1)[:/])[A-Za-z0-9.:/_-]*)?', 'LIVE': r'[01]'}
++ for k, v in vals.items():
++ if not re.fullmatch(shape[k], v):
++ die(f'openvibe.bot answered an unexpected {k}')
++ with open(out, 'w') as f:
++ f.write(''.join(f"{k}='{v}'\n" for k, v in vals.items()))
++ print(' approved')
++ sys.exit(0)
++die('nobody approved the code in 16 minutes: run the installer again for a new one')
++PY
++ # shellcheck source=/dev/null # written just above, every value checked against its shape
++ . "$_answer"
++}
++
++# Test hook: ask OPENVIBE_SERVER for a code, wait for the answer, print what pairing would use, and stop.
++if [ "${OPENVIBE_INSTALL_CLAIM_ONLY:-0}" = 1 ]; then
++ TMP="$(mktemp -d)"
++ trap 'rm -rf "$TMP"' EXIT
++ claim_robot || exit 1
++ printf 'ROBOT_ID=%s\nCODE=%s\nNETWORK=%s\nPAIRING=%s\nCLAIM_DRIVER=%s\nROBOT_NAME=%s\nPANEL_URL=%s\nLIVE=%s\n' \
++ "$ROBOT_ID" "$CODE" "$NETWORK" "$PAIRING" "$CLAIM_DRIVER" "$ROBOT_NAME" "$PANEL_URL" "$LIVE"
++ exit 0
++fi
++
# write_worker_config <config.json> <job uid> <job gid> [<node.js> <codes dir>]: turn the job worker on in that config
# (kept as it is otherwise, created when missing): jobs as the job user, public egress, caps sized for a coding task,
# and, with a Node.js and a Codes install, the `[email protected]` job runner. Keys stay empty for the owner to add.
@@@ -350,6 +478,90 @@ 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)')"
++# ---- the robot's computer: its buses, its camera tools, and what is on them (the one command, or an Adeept driver) ----
++# I2C carries the Adeept kit's motor and servo board and its battery ADC, SPI its lights: both on now and after a reboot
++# (raspi-config applies them at once). Only missing packages are installed: i2c-tools to look at the bus, the camera
++# tools the video uses, venv and lgpio for the plugins, qrencode for the code's QR.
++pi_prepare() {
++ [ "$OS" = linux ] && [ "$IS_PI" = 1 ] || return 0
++ if command -v raspi-config >/dev/null 2>&1; then
++ [ "$($SUDO raspi-config nonint get_i2c 2>/dev/null)" = 0 ] ||
++ { say "turning on I2C (the kit's motor board is on it)"; $SUDO raspi-config nonint do_i2c 0 || say "warning: raspi-config could not turn on I2C"; }
++ [ "$($SUDO raspi-config nonint get_spi 2>/dev/null)" = 0 ] ||
++ { say "turning on SPI (the kit's lights)"; $SUDO raspi-config nonint do_spi 0 || say "warning: raspi-config could not turn on SPI"; }
++ else
++ boot_config=/boot/firmware/config.txt
++ [ -f "$boot_config" ] || boot_config=/boot/config.txt
++ for p in i2c_arm spi; do
++ if [ -f "$boot_config" ] && ! grep -Eq "^dtparam=$p=on" "$boot_config"; then
++ say "turning on ${p%_arm} in $boot_config"
++ printf 'dtparam=%s=on\n' "$p" | $SUDO tee -a "$boot_config" >/dev/null
++ fi
++ if command -v dtparam >/dev/null 2>&1; then $SUDO dtparam "$p=on" 2>/dev/null || true; fi
++ done
++ fi
++ $SUDO modprobe i2c-dev 2>/dev/null || true
++ command -v apt-get >/dev/null 2>&1 || return 0
++ need=""
++ command -v i2cdetect >/dev/null 2>&1 || need="$need i2c-tools"
++ command -v rpicam-vid >/dev/null 2>&1 || command -v libcamera-vid >/dev/null 2>&1 || need="$need rpicam-apps"
++ python3 -c 'import ensurepip' >/dev/null 2>&1 || need="$need python3-venv"
++ python3 -c 'import lgpio' >/dev/null 2>&1 || need="$need python3-lgpio"
++ [ "$CLAIM" = 0 ] || command -v qrencode >/dev/null 2>&1 || need="$need qrencode"
++ [ -n "$need" ] || return 0
++ say "installing:$need"
++ $SUDO apt-get update -qq >/dev/null 2>&1 || true
++ # shellcheck disable=SC2086 # one word per package
++ if ! $SUDO apt-get install -y -qq $need >/dev/null 2>&1; then
++ need=$(printf '%s' "$need" | sed 's/rpicam-apps/libcamera-apps/') # Bullseye's name for the camera tools
++ $SUDO apt-get install -y -qq $need >/dev/null 2>&1 || say "warning: apt-get could not install$need"
++ fi
++}
++
++# What is on the I2C bus and which camera there is, and the kit that makes it: KIT (a profile id on openvibe.bot),
++# KIT_NAME and FOUND (the evidence, |-separated) for the approval page; DRIVER unless --driver chose one.
++KIT="" KIT_NAME="" FOUND="" CAMERA=""
++detect_kit() {
++ [ "$OS" = linux ] || return 0
++ addrs=""
++ if [ -n "${OPENVIBE_I2C_ADDRS+x}" ]; then
++ addrs=" $OPENVIBE_I2C_ADDRS " # test-only: the bus's addresses, as i2cdetect would print them
++ elif [ -e /dev/i2c-1 ] && command -v i2cdetect >/dev/null 2>&1; then
++ addrs=" $($SUDO i2cdetect -y 1 2>/dev/null | awk 'NR > 1 { for (i = 2; i <= NF; i++) if ($i ~ /^[0-9a-f][0-9a-f]$/) printf "%s ", $i }')"
++ fi
++ case "$addrs" in *" 5f "*) FOUND="$FOUND|PCA9685 at 0x5f" ;; esac
++ case "$addrs" in *" 40 "*) FOUND="$FOUND|PCA9685 at 0x40" ;; esac
++ case "$addrs" in *" 48 "*) FOUND="$FOUND|ADS7830 at 0x48" ;; esac
++ case "$addrs" in *" 14 "*) FOUND="$FOUND|Robot HAT at 0x14" ;; esac
++ case "$addrs" in *" 3c "*) FOUND="$FOUND|OLED at 0x3c" ;; esac
++ case "$addrs" in
++ *" 5f "*) KIT=adeept.adr036; KIT_NAME="Adeept ADR036 4WD"; [ "$DRIVER_GIVEN" = 1 ] || DRIVER=adeept ;;
++ *)
++ if { ss -ltn 2>/dev/null || netstat -ltn 2>/dev/null; } | grep -q '[:.]9090 '; then
++ KIT=ros2.diffdrive; KIT_NAME="ROS 2 robot (rosbridge)"; FOUND="$FOUND|rosbridge on port 9090"
++ [ "$DRIVER_GIVEN" = 1 ] || DRIVER=ros2
++ fi ;;
++ esac
++ for lister in ${OPENVIBE_CAMERA+none} rpicam-hello libcamera-hello; do
++ [ "$lister" != none ] || { CAMERA="$OPENVIBE_CAMERA"; break; } # test-only: the camera's name
++ command -v "$lister" >/dev/null 2>&1 || continue
++ CAMERA=$(timeout 15 "$lister" --list-cameras 2>/dev/null | sed -n 's/^ *0 : \([A-Za-z0-9_]*\).*/\1/p' | head -n 1)
++ break
++ done
++ if [ -z "$CAMERA" ] && [ -d /dev/v4l/by-id ]; then CAMERA=$(find /dev/v4l/by-id -mindepth 1 -maxdepth 1 -printf '%f\n' 2>/dev/null | head -n 1 | cut -c1-60); fi
++ FOUND="${FOUND#|}"
++ say "found: ${KIT_NAME:-no kit it knows}$( [ -n "$FOUND" ] && printf ' (%s)' "$(printf '%s' "$FOUND" | sed 's/|/, /g')"); camera: ${CAMERA:-none}"
++}
++# Test hook: only the detection (OPENVIBE_I2C_ADDRS and OPENVIBE_CAMERA stand in for the bus and the camera).
++if [ "${OPENVIBE_INSTALL_DETECT_ONLY:-0}" = 1 ]; then
++ OS=linux
++ detect_kit >/dev/null
++ printf 'KIT=%s\nDRIVER=%s\nFOUND=%s\nCAMERA=%s\n' "$KIT" "$DRIVER" "$FOUND" "$CAMERA"
++ exit 0
++fi
++if [ "$CLAIM" = 1 ] || [ "$DRIVER" = adeept ] || [ "$DRIVER" = adeept-mecanum ]; then pi_prepare; fi
++[ "$CLAIM" = 0 ] || detect_kit
++
# ---- download ----
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
@@@ -423,6 +635,9 @@ 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" "$STATE/plugins/relay"
++# driver_setup: the plugin of $DRIVER into the venv, and its PLUGINS, KIND and VIDEO for the config. Run again when the
++# owner chose another model on openvibe.bot/link than the one the bus suggested.
++driver_setup() {
PLUGINS='[{"name": "dryrun"}]'
KIND=onboard
VIDEO='"source": "auto"'
@@@ -459,6 +674,8 @@ looi)
fi
;;
esac
++}
++driver_setup
if [ -n "$COMPUTER" ]; then
# The computer plugin, and Pillow for compact screen captures when pip can get it.
$SUDO "$PIP" install --quiet "$STATE/plugins/computer[screen]" 2>/dev/null || $SUDO "$PIP" install --quiet "$STATE/plugins/computer"
@@@ -476,6 +693,8 @@ EOF
say "wrote $CONF/config.json"
else
say "keeping the existing $CONF/config.json"
++ # The one command sets up the robot it found: the driver's plugins, kind and video replace the old ones.
++ [ "$CLAIM" = 0 ] || set_driver_config "$CONF/config.json"
fi
if [ -n "$COMPUTER" ]; then
write_computer_config "$CONF/config.json" "$COMPUTER" "$COMPUTER_HOME"
@@@ -554,6 +773,22 @@ if [ "$SERVICE" = 1 ]; then
say "service installed and started"
fi
++# ---- the one command: the owner approves the code on openvibe.bot/link, then this machine pairs at once ----
++if [ "$CLAIM" = 1 ]; then
++ if $SUDO "$BIN_DIR/openvibe-node" status --json 2>/dev/null | grep -q '"paired":true'; then
++ say "already paired; keeping it (to make it a new robot: sudo openvibe-node pair --force <CODE> with a code from openvibe.bot)"
++ CLAIM=0
++ else
++ claim_robot || die "not paired: nothing was added on openvibe.bot"
++ if [ "$CLAIM_DRIVER" != "$DRIVER" ]; then
++ say "setting up the driver for the model you chose: $CLAIM_DRIVER"
++ DRIVER="$CLAIM_DRIVER"
++ driver_setup
++ set_driver_config "$CONF/config.json"
++ fi
++ fi
++fi
++
# ---- pair ----
if [ -n "$CODE" ]; then
if $SUDO "$BIN_DIR/openvibe-node" status --json 2>/dev/null | grep -q '"paired":true'; then
@@@ -565,4 +800,32 @@ else
say "not paired yet: get a code on openvibe.bot and run: sudo openvibe-node pair <CODE>"
fi
++# ---- the one command: is it driving and streaming? ----
++if [ "$CLAIM" = 1 ] && [ "$SERVICE" = 1 ]; then
++ say "checking the link, the driver and the camera (up to a minute)"
++ _status="" _i=0
++ while [ "$_i" -lt 30 ]; do
++ _status="$($SUDO "$BIN_DIR/openvibe-node" status --json 2>/dev/null || true)"
++ if printf '%s' "$_status" | grep -q '"connected":true' && printf '%s' "$_status" | grep -q '"state":"live"'; then break; fi
++ _i=$((_i + 1))
++ sleep 2
++ done
++ printf '%s' "$_status" | python3 -c '
++import json, sys
++try:
++ s = json.load(sys.stdin)
++except ValueError:
++ sys.exit("openvibe-node: the service did not answer: sudo journalctl -u openvibe-node -n 50")
++link = s.get("link") or {}
++print(" link " + ("connected" if link.get("connected") else "not connected" + (": " + link["last_error"] if link.get("last_error") else "")))
++for p in s.get("plugins") or []:
++ fault = p.get("fault") or {}
++ print(" driver %s: %s" % (p.get("name"), p.get("state") + (" (" + (fault.get("message") or fault.get("code") or "") + ")" if fault else "")))
++v = s.get("video") or {}
++print(" camera %s%s" % (v.get("state") or "off", (" (" + v["last_error"] + ")") if v.get("last_error") else (" from " + v["source"] if v.get("source") else "")))
++' || true
++ say "drive it: ${PANEL_URL:-https://openvibe.bot/robots}"
++ [ "${LIVE:-0}" = 0 ] || say "its camera is on your OpenVibe.Live channel while it streams"
++fi
++
say "done. Check it with: sudo openvibe-node status Stop everything with: sudo openvibe-node stop"
ddiff --git a/scripts/test-install-claim.sh b/scripts/test-install-claim.sh
nnew file mode 100755
iindex 0000000..1a75368
---- /dev/null
++++ b/scripts/test-install-claim.sh
@@@ -0,0 +1,109 @@
++#!/bin/sh
++# Tests the one command (install.sh with no robot and no code): the kit it reads off the I2C bus, and the claim it makes
++# on openvibe.bot (POST /api/v1/claims, then GET /api/v1/claims/:id with the secret until the owner approves it on
++# openvibe.bot/link). OPENVIBE_INSTALL_DETECT_ONLY=1 and OPENVIBE_INSTALL_CLAIM_ONLY=1 make the installer do only that and
++# exit before it downloads, installs or pairs anything; a small fake openvibe.bot answers the claim.
++set -eu
++
++here="$(cd "$(dirname "$0")" && pwd)"
++installer="$here/../install/install.sh"
++work="$(mktemp -d)"
++server_pid=""
++trap 'if [ -n "$server_pid" ]; then kill "$server_pid" 2>/dev/null || true; fi; rm -rf "$work"' EXIT
++
++fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
++
++# detect <i2c addresses> <camera>: the detection's KIT, DRIVER, FOUND and CAMERA lines.
++detect() { OPENVIBE_INSTALL_DETECT_ONLY=1 OPENVIBE_I2C_ADDRS="$1" OPENVIBE_CAMERA="$2" sh "$installer" 2>/dev/null; }
++has() { printf '%s\n' "$1" | grep -qxF "$2" || fail "detect: want '$2' in: $(printf '%s' "$1" | tr '\n' ';')"; }
++
++out="$(detect "48 5f" imx219)"
++has "$out" "KIT=adeept.adr036"
++has "$out" "DRIVER=adeept"
++has "$out" "FOUND=PCA9685 at 0x5f|ADS7830 at 0x48"
++has "$out" "CAMERA=imx219"
++out="$(detect "40 48" "")"
++has "$out" "KIT="
++has "$out" "DRIVER=none"
++has "$out" "FOUND=PCA9685 at 0x40|ADS7830 at 0x48"
++out="$(OPENVIBE_INSTALL_DETECT_ONLY=1 OPENVIBE_I2C_ADDRS="5f" OPENVIBE_CAMERA="" sh "$installer" --driver cozmo 2>/dev/null)"
++has "$out" "DRIVER=cozmo"
++
++# A fake openvibe.bot: the claim is pending on the first poll and answered on the second (with $1's answer).
++serve() {
++ python3 - "$work" "$1" <<'PY' &
++import json, sys
++from http.server import BaseHTTPRequestHandler, HTTPServer
++work, mode = sys.argv[1], sys.argv[2]
++polls = []
++answers = {
++ 'approved': {'state': 'approved', 'robot_id': 'rob_01JZTEST', 'name': 'Garage rover', 'profile_id': 'adeept.adr036.mecanum',
++ 'driver': 'adeept-mecanum', 'pairing': {'robot': 'rob_01JZTEST', 'code': 'ABCD-1234'},
++ 'panel_url': 'https://openvibe.bot/panel/rob_01JZTEST', 'live': True},
++ 'network': {'state': 'approved', 'robot_id': 'rob_01JZTEST', 'name': 'Garage rover', 'driver': 'adeept',
++ 'pairing': {'network': 'https://openvibe.network', 'pairing_id': 'pair_01JZTEST', 'code': 'WXYZ-5678'},
++ 'panel_url': 'https://openvibe.bot/panel/rob_01JZTEST', 'live': False},
++ 'denied': {'state': 'denied'},
++ 'hostile': {'state': 'approved', 'robot_id': 'rob_01JZTEST', 'name': "x'; rm -rf / #", 'driver': 'adeept',
++ 'pairing': {'robot': 'rob_01JZTEST', 'code': 'ABCD-1234'}, 'panel_url': 'https://openvibe.bot/panel/rob_01JZTEST'},
++}
++class H(BaseHTTPRequestHandler):
++ def log_message(self, *a): pass
++ def send(self, code, body):
++ data = json.dumps(body).encode()
++ self.send_response(code); self.send_header('Content-Type', 'application/json'); self.send_header('Content-Length', str(len(data))); self.end_headers(); self.wfile.write(data)
++ def do_POST(self):
++ body = self.rfile.read(int(self.headers.get('Content-Length') or 0))
++ open(f'{work}/claim-body.json', 'wb').write(body)
++ if self.path != '/api/v1/claims': return self.send(404, {})
++ self.send(201, {'id': 'clm_01JZTEST', 'user_code': 'KQ7M-2XRD', 'secret': 's3cret', 'interval': 1, 'expires_at': '2099-01-01T00:00:00Z',
++ 'verification_url': 'https://openvibe.bot/link', 'verification_url_complete': 'https://openvibe.bot/link?code=KQ7M-2XRD'})
++ def do_GET(self):
++ if self.path != '/api/v1/claims/clm_01JZTEST' or self.headers.get('X-OV-Claim-Secret') != 's3cret': return self.send(404, {})
++ polls.append(1)
++ self.send(200, {'state': 'pending'} if len(polls) == 1 else answers[mode])
++srv = HTTPServer(('127.0.0.1', 0), H)
++open(f'{work}/port', 'w').write(str(srv.server_port))
++srv.serve_forever()
++PY
++ server_pid=$!
++ i=0
++ while [ ! -s "$work/port" ]; do i=$((i + 1)); [ "$i" -lt 50 ] || fail "the fake openvibe.bot did not start"; sleep 0.1; done
++}
++stop() { kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true; server_pid=""; rm -f "$work/port"; }
++claim() { OPENVIBE_INSTALL_CLAIM_ONLY=1 OPENVIBE_SERVER="http://127.0.0.1:$(cat "$work/port")" sh "$installer" --name raspi 2>"$work/err"; }
++
++serve approved
++out="$(claim)" || fail "the approved claim failed: $(cat "$work/err")"
++stop
++has "$out" "ROBOT_ID=rob_01JZTEST"
++has "$out" "CODE=ABCD-1234"
++has "$out" "NETWORK="
++has "$out" "CLAIM_DRIVER=adeept-mecanum"
++has "$out" "ROBOT_NAME=Garage rover"
++has "$out" "PANEL_URL=https://openvibe.bot/panel/rob_01JZTEST"
++has "$out" "LIVE=1"
++printf '%s\n' "$out" | grep -q "code KQ7M-2XRD" || fail "the code is shown"
++python3 -c 'import json,sys; b=json.load(open(sys.argv[1])); assert b["hostname"] == "raspi" and b["hardware"]["arch"], b' "$work/claim-body.json" ||
++ fail "the claim names the machine"
++
++serve network
++out="$(claim)" || fail "the Network-paired claim failed: $(cat "$work/err")"
++stop
++has "$out" "ROBOT_ID="
++has "$out" "NETWORK=https://openvibe.network"
++has "$out" "PAIRING=pair_01JZTEST"
++has "$out" "CODE=WXYZ-5678"
++has "$out" "LIVE=0"
++
++serve denied
++if claim >/dev/null; then fail "a denied claim was taken"; fi
++grep -q "denied" "$work/err" || fail "a denial says so: $(cat "$work/err")"
++stop
++
++serve hostile
++if claim >/dev/null; then fail "a name that could break out of the shell's quotes was taken"; fi
++grep -q "unexpected ROBOT_NAME" "$work/err" || fail "an odd answer says which value: $(cat "$work/err")"
++stop
++
++echo "install.sh one command (detection, claim): ok"