openvibers/openvibe.extensions Commits
Kiosk companion and hardware companions move here (ADR-023, W18): kiosk-companion/ (from OpenVibe.Live browser-extension/openvibe-kiosk-companion/) and hardware/ (streamer and controller scripts for Raspberry Pi and other boards, from OpenVibe.Live hardware/) at Live 9c5aa64, unchanged; Live keeps redirect READMEs
Changes
@@@ -19,7 +19,8 @@ per-install permissions. ADR-023 gives clients their own repository.| Surface | Directory | State ||---|---|---|| OpenVibe Coupons Helper (Chromium and Firefox, Manifest V3) | `browser/` | alpha, described below |--| Kiosk companion (Firefox) and the hardware/Raspberry Pi companions | not yet here | Planned by ADR-023: move them from OpenVibe.Live's `browser-extension/` and `hardware/`, leaving a redirect README in Live. That move isn't part of this change. |++| Kiosk companion (Firefox, new-tab and history bridge for the /kiosk page) | `kiosk-companion/` | Moved from OpenVibe.Live `browser-extension/openvibe-kiosk-companion/` (ADR-023, Live 9c5aa64); Live keeps a redirect README |++| Hardware / Raspberry Pi companions (streamer and controller scripts) | `hardware/` | Moved from OpenVibe.Live `hardware/` (ADR-023, Live 9c5aa64); Live keeps a redirect README || Desktop client | — | Planned by ADR-023 for the long term. Nothing exists. |## The OpenVibe Coupons Helper (`browser/`)nnew file mode 100644@@@ -0,0 +1,273 @@++# OpenVibe.Live — Hardware Integration++++Scripts for streaming from and controlling hardware devices (Raspberry Pi, single-board computers, etc.) with OpenVibe.Live.++++## Requirements++++- **Python 3.7+**++- **FFmpeg** — `sudo apt install ffmpeg`++- **websocket-client** — `pip3 install websocket-client`++- For TTS: **espeak** — `sudo apt install espeak`++- For GPIO: **gpiozero** — `pip3 install gpiozero` (Raspberry Pi only)++- For PulseAudio/PipeWire audio: **pulseaudio-utils** — `sudo apt install pulseaudio-utils`++++## Quick Start (Bash Script)++++The easiest way to stream. Supports camera, screen capture, OBS virtual camera, and audio-only modes.++++```bash++# Basic camera stream++./scripts/start-stream.sh YOUR_STREAM_KEY localhost 9710++++# Screen capture++MODE=screen ./scripts/start-stream.sh YOUR_KEY localhost 9710++++# OBS Virtual Camera → JSMPEG++MODE=obs ./scripts/start-stream.sh YOUR_KEY localhost 9710++++# Audio only (no video)++MODE=audioonly ./scripts/start-stream.sh YOUR_KEY localhost 9711++++# HD 720p with PulseAudio++RESOLUTION=1280x720 BITRATE=1200k AUDIO=pulse FPS=30 ./scripts/start-stream.sh YOUR_KEY localhost 9710++++# Disable audio++NOAUDIO=1 ./scripts/start-stream.sh YOUR_KEY localhost 9710++++# Custom camera device++VIDEO_DEV=/dev/video2 ./scripts/start-stream.sh YOUR_KEY localhost 9710++```++++### Environment Variables++++| Variable | Default | Description |++|----------|---------|-------------|++| `MODE` | camera | `camera`, `screen`, `obs`, or `audioonly` |++| `RESOLUTION` | 640x480 | Video resolution |++| `FPS` | 24 | Frames per second |++| `BITRATE` | 800k | Video bitrate |++| `AUDIO_BITRATE` | 128k | Audio bitrate |++| `AUDIO` | alsa | `alsa` or `pulse` |++| `NOAUDIO` | 0 | Set to `1` to disable audio |++| `VIDEO_DEV` | auto | Camera device path (e.g. `/dev/video0`) |++| `SCREEN_RES` | 1920x1080 | Screen capture resolution (screen mode only) |++| `DISPLAY_ID` | :0.0 | X11 display (screen mode only) |++++## Finding Your Devices++++### Camera / Video++++```bash++# List all video devices++v4l2-ctl --list-devices++++# List supported formats and resolutions++v4l2-ctl -d /dev/video0 --list-formats-ext++++# Test camera with a snapshot++ffmpeg -f v4l2 -i /dev/video0 -frames 1 test.jpg++++# macOS: list devices++ffmpeg -f avfoundation -list_devices true -i ""++```++++### Audio / Microphone++++```bash++# List ALSA audio devices++arecord -l++++# List PulseAudio / PipeWire sources++pactl list short sources++++# Test microphone (record 5 seconds, play back)++arecord -d 5 -f cd test.wav && aplay test.wav++++# PipeWire users — check audio sources++pw-cli list-objects | grep -A2 "node.name"++```++++### OBS Virtual Camera++++```bash++# 1. Start OBS Studio++# 2. Go to Tools → Start Virtual Camera++# 3. Check which device it created:++v4l2-ctl --list-devices++# Usually appears as /dev/video2 or /dev/video4++++# 4. Stream it (with PulseAudio for desktop audio):++ffmpeg -f v4l2 -i /dev/video2 -f pulse -i default \++ -f mpegts -codec:v mpeg1video -s 640x480 -b:v 500k -bf 0 \++ -codec:a mp2 -b:a 128k -ar 44100 -ac 1 -muxdelay 0.001 \++ http://SERVER:PORT/KEY/640/480/++```++++### Raspberry Pi Camera (libcamera)++++```bash++# Test if camera works++libcamera-hello -t 5000++++# Stream via pipe to FFmpeg++libcamera-vid -t 0 --inline --codec mjpeg --width 640 --height 480 -o - | \++ ffmpeg -i pipe:0 -f mpegts -codec:v mpeg1video -s 640x480 -b:v 350k -bf 0 \++ -muxdelay 0.001 http://SERVER:PORT/KEY/640/480/++++# With audio++libcamera-vid -t 0 --inline --codec mjpeg --width 640 --height 480 -o - | \++ ffmpeg -i pipe:0 -f alsa -i default \++ -f mpegts -codec:v mpeg1video -s 640x480 -b:v 350k -bf 0 \++ -codec:a mp2 -b:a 128k -ar 44100 -ac 1 -muxdelay 0.001 \++ http://SERVER:PORT/KEY/640/480/++```++++## FFmpeg Command Reference++++### JSMPEG Constraints++++JSMPEG uses canvas-based MPEG1 decoding. You **must** use:++- Video codec: `mpeg1video` (not h264/h265)++- Audio codec: `mp2` (not aac/opus)++- Container: `mpegts`++- No B-frames: `-bf 0`++++### Common FFmpeg Flags++++| Flag | Description | Example |++|------|-------------|---------|++| `-f v4l2` | Linux camera input | `-f v4l2 -i /dev/video0` |++| `-f avfoundation` | macOS camera input | `-f avfoundation -i "0:0"` |++| `-f x11grab` | Screen capture | `-f x11grab -s 1920x1080 -i :0.0` |++| `-f alsa` | ALSA audio input | `-f alsa -i default` |++| `-f pulse` | PulseAudio input | `-f pulse -i default` |++| `-s WxH` | Output resolution | `-s 640x480` |++| `-b:v` | Video bitrate | `-b:v 800k` |++| `-b:a` | Audio bitrate | `-b:a 128k` |++| `-r` | Frame rate | `-r 30` |++| `-ar` | Audio sample rate | `-ar 44100` |++| `-ac` | Audio channels | `-ac 1` (mono), `-ac 2` (stereo) |++| `-bf 0` | Disable B-frames | Required for JSMPEG |++| `-q:v` | Quality (2=best, 31=worst) | `-q:v 5` |++| `-muxdelay` | Mux delay (lower = less latency) | `-muxdelay 0.001` |++| `-framerate` | Input capture FPS | `-framerate 30` |++| `-video_size` | Input capture resolution | `-video_size 1280x720` |++++### Recommended Presets++++| Device | Resolution | Video Bitrate | Audio | Notes |++|--------|-----------|---------------|-------|-------|++| Pi Zero | 320x240 | 200k | 64k mono | Use `-r 15` for stability |++| Pi 3/4 | 640x480 | 350–500k | 128k mono | Good balance |++| Desktop | 1280x720 | 800k–1.5M | 128k stereo | Smooth 30fps |++| High Quality | 1920x1080 | 2–4M | 192k stereo | Requires good upload |++++## Streamer Script (Python)++++Captures video (USB camera / Pi Camera) and audio (ALSA mic), then pushes to OpenVibe.Live via JSMPEG or RTMP.++++```bash++# JSMPEG streaming (recommended for low-latency)++python3 streamer.py --key YOUR_STREAM_KEY --server openvibe.live++++# RTMP streaming (for OBS compatibility)++python3 streamer.py --key YOUR_STREAM_KEY --server openvibe.live --protocol rtmp++++# Custom settings++python3 streamer.py --key YOUR_KEY --server openvibe.live \++ --resolution 1280x720 --fps 30 --bitrate 1500 \++ --video-device /dev/video0 --audio-device default++```++++### Options++++| Flag | Default | Description |++|------|---------|-------------|++| `--key` | (required) | Stream key from dashboard |++| `--server` | localhost | OpenVibe.Live server |++| `--protocol` | jsmpeg | `jsmpeg` or `rtmp` |++| `--resolution` | 640x480 | Video resolution |++| `--fps` | 24 | Frames per second |++| `--bitrate` | 800 | Video bitrate (kbps) |++| `--no-audio` | false | Disable audio capture |++| `--dry-run` | false | Print command without running |++++## Controller Script++++Connects to OpenVibe.Live control WebSocket and executes viewer commands on the hardware (motors, servos, LEDs, TTS).++++```bash++# Basic (simulation mode — prints commands)++python3 controller.py --key YOUR_STREAM_KEY --server openvibe.live++++# With GPIO enabled (Raspberry Pi)++python3 controller.py --key YOUR_KEY --server openvibe.live \++ --enable-gpio --config gpio-config.json++```++++### GPIO Config Example++++Create `gpio-config.json`:++++```json++{++ "gpio": {++ "motor_left": { "forward": 17, "backward": 18 },++ "motor_right": { "forward": 22, "backward": 23 },++ "servos": { "tilt": 12, "pan": 13 },++ "leds": { "main": 25, "status": 24 }++ }++}++```++++### Supported Commands++++| Command | Action | Hardware |++|---------|--------|----------|++| `forward` | Move forward 0.5s | Motors |++| `backward` | Move backward 0.5s | Motors |++| `left` | Turn left 0.3s | Motors |++| `right` | Turn right 0.3s | Motors |++| `stop` | Stop all motors | Motors |++| `tts` | Text-to-speech | espeak |++| `led_on` | Turn LED on | GPIO |++| `led_off` | Turn LED off | GPIO |++| `horn` | Play horn sound | Speaker |++| `servo_up` | Tilt servo up | Servo |++| `servo_down` | Tilt servo down | Servo |++| `servo_center` | Center servo | Servo |++++## Raspberry Pi Setup++++```bash++# Install system dependencies++sudo apt update++sudo apt install ffmpeg espeak python3-pip pulseaudio-utils++++# Install Python packages++pip3 install websocket-client gpiozero++++# Enable camera++sudo raspi-config # Interface Options → Camera → Enable++++# Test camera++ffmpeg -f v4l2 -i /dev/video0 -frames 1 test.jpg++++# Run both scripts++python3 streamer.py --key YOUR_KEY --server openvibe.live &++python3 controller.py --key YOUR_KEY --server openvibe.live --enable-gpio++```++++## Troubleshooting++++| Problem | Solution |++|---------|----------|++| No video device | Run `ls /dev/video*`. If empty, check camera connection. Try `sudo modprobe uvcvideo`. |++| No audio device | Run `arecord -l`. If empty, plug in a USB mic or check `pulseaudio --check`. |++| Device busy | Close other camera apps (OBS, Cheese, etc.) and retry. |++| Connection refused | Check server is running. Check firewall: `sudo ufw allow 9710`. |++| High latency | Lower resolution, reduce bitrate, ensure `-muxdelay 0.001`. |++| Choppy video | Upload bandwidth too low for bitrate. Reduce `-b:v` or resolution. |++| Permission denied | `sudo usermod -aG video $USER` then log out/in. |++| macOS camera | Use `-f avfoundation -i "0:0"` instead of `-f v4l2 -i /dev/video0`. |++| PipeWire no sound | Use `-f pulse -i default` (PipeWire is PulseAudio-compatible). |nnew file mode 100644@@@ -0,0 +1,278 @@++#!/usr/bin/env python3++"""++OpenVibe.Live — Hardware Controller Script++Connects to OpenVibe.Live control WebSocket and executes commands++on Raspberry Pi GPIO, servos, motors, TTS, etc.++++Usage:++ python3 controller.py --key YOUR_STREAM_KEY --server openvibe.live++++Requirements:++ - websocket-client: pip3 install websocket-client++ - For TTS: espeak or pico2wave (apt install espeak)++ - For GPIO: RPi.GPIO or gpiozero (pip3 install gpiozero)++"""++++import argparse++import json++import subprocess++import sys++import signal++import time++import threading++++try:++ import websocket++except ImportError:++ print("[ERROR] websocket-client not installed. Run: pip3 install websocket-client")++ sys.exit(1)++++# Try to import GPIO (Raspberry Pi only)++GPIO_AVAILABLE = False++try:++ from gpiozero import Motor, Servo, LED++ GPIO_AVAILABLE = True++except ImportError:++ pass++++DEFAULT_SERVER = "localhost"++DEFAULT_PORT = 3000++++# ═══════════════════════════════════════════════════════════════++# Command Handlers++# ═══════════════════════════════════════════════════════════════++++class HardwareController:++ """Handles incoming commands and maps them to hardware actions."""++++ def __init__(self, config=None):++ self.config = config or {}++ self.motors = {}++ self.servos = {}++ self.leds = {}++++ if GPIO_AVAILABLE and self.config.get("enable_gpio"):++ self._init_gpio()++++ def _init_gpio(self):++ """Initialize GPIO devices based on config."""++ gpio = self.config.get("gpio", {})++++ # Motors (L298N or similar H-bridge)++ if "motor_left" in gpio:++ pins = gpio["motor_left"]++ self.motors["left"] = Motor(pins["forward"], pins["backward"])++ if "motor_right" in gpio:++ pins = gpio["motor_right"]++ self.motors["right"] = Motor(pins["forward"], pins["backward"])++++ # Servos++ for name, pin in gpio.get("servos", {}).items():++ self.servos[name] = Servo(pin)++++ # LEDs++ for name, pin in gpio.get("leds", {}).items():++ self.leds[name] = LED(pin)++++ print(f"[GPIO] Motors: {list(self.motors.keys())}, Servos: {list(self.servos.keys())}, LEDs: {list(self.leds.keys())}")++++ def handle_command(self, command, data=None):++ """Route a command to the appropriate handler."""++ handlers = {++ "forward": self.cmd_forward,++ "backward": self.cmd_backward,++ "left": self.cmd_left,++ "right": self.cmd_right,++ "stop": self.cmd_stop,++ "tts": self.cmd_tts,++ "led_on": self.cmd_led_on,++ "led_off": self.cmd_led_off,++ "horn": self.cmd_horn,++ "servo_up": self.cmd_servo_up,++ "servo_down": self.cmd_servo_down,++ "servo_center": self.cmd_servo_center,++ }++++ handler = handlers.get(command)++ if handler:++ print(f"[CMD] {command} {data or ''}")++ handler(data)++ else:++ print(f"[CMD] Unknown command: {command}")++++ # Movement commands++ def cmd_forward(self, data=None):++ duration = (data or {}).get("duration", 0.5)++ if self.motors:++ for m in self.motors.values():++ m.forward()++ threading.Timer(duration, self.cmd_stop).start()++ else:++ print(" → [SIM] Moving forward")++++ def cmd_backward(self, data=None):++ duration = (data or {}).get("duration", 0.5)++ if self.motors:++ for m in self.motors.values():++ m.backward()++ threading.Timer(duration, self.cmd_stop).start()++ else:++ print(" → [SIM] Moving backward")++++ def cmd_left(self, data=None):++ duration = (data or {}).get("duration", 0.3)++ if "left" in self.motors and "right" in self.motors:++ self.motors["left"].backward()++ self.motors["right"].forward()++ threading.Timer(duration, self.cmd_stop).start()++ else:++ print(" → [SIM] Turning left")++++ def cmd_right(self, data=None):++ duration = (data or {}).get("duration", 0.3)++ if "left" in self.motors and "right" in self.motors:++ self.motors["left"].forward()++ self.motors["right"].backward()++ threading.Timer(duration, self.cmd_stop).start()++ else:++ print(" → [SIM] Turning right")++++ def cmd_stop(self, data=None):++ for m in self.motors.values():++ m.stop()++ print(" → [SIM] Stopped" if not self.motors else " → Motors stopped")++++ # TTS++ def cmd_tts(self, data=None):++ text = (data or {}).get("text", "hello from OpenVibe.Live")++ try:++ subprocess.Popen(["espeak", "-s", "150", text],++ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)++ print(f" → TTS: {text}")++ except FileNotFoundError:++ print(f" → [SIM] TTS (espeak not installed): {text}")++++ # LED++ def cmd_led_on(self, data=None):++ name = (data or {}).get("name", "main")++ if name in self.leds:++ self.leds[name].on()++ print(f" → LED {name} ON")++++ def cmd_led_off(self, data=None):++ name = (data or {}).get("name", "main")++ if name in self.leds:++ self.leds[name].off()++ print(f" → LED {name} OFF")++++ # Horn/buzzer++ def cmd_horn(self, data=None):++ duration = (data or {}).get("duration", 0.5)++ try:++ subprocess.Popen(["aplay", "-d", str(duration), "/usr/share/sounds/alsa/Front_Center.wav"],++ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)++ except FileNotFoundError:++ print(" → [SIM] Horn!")++++ # Servo++ def cmd_servo_up(self, data=None):++ name = (data or {}).get("name", "tilt")++ if name in self.servos:++ self.servos[name].value = min(1, self.servos[name].value + 0.2)++ print(f" → Servo {name} UP")++++ def cmd_servo_down(self, data=None):++ name = (data or {}).get("name", "tilt")++ if name in self.servos:++ self.servos[name].value = max(-1, self.servos[name].value - 0.2)++ print(f" → Servo {name} DOWN")++++ def cmd_servo_center(self, data=None):++ name = (data or {}).get("name", "tilt")++ if name in self.servos:++ self.servos[name].mid()++ print(f" → Servo {name} CENTER")++++++# ═══════════════════════════════════════════════════════════════++# WebSocket Client++# ═══════════════════════════════════════════════════════════════++++def connect(args, controller):++ """Connect to OpenVibe.Live control WebSocket."""++ protocol = "wss" if args.ssl else "ws"++ url = f"{protocol}://{args.server}:{args.port}/ws/control"++ reconnect_delay = 5++++ def on_open(ws):++ print(f"[WS] Connected to {url}")++ ws.send(json.dumps({++ "type": "join",++ "role": "hardware",++ "streamKey": args.key,++ }))++++ def on_message(ws, message):++ try:++ msg = json.loads(message)++ if msg.get("type") == "command":++ controller.handle_command(msg["command"], msg.get("data"))++ elif msg.get("type") == "error":++ print(f"[WS ERROR] {msg.get('message')}")++ elif msg.get("type") == "ping":++ ws.send(json.dumps({"type": "pong"}))++ except json.JSONDecodeError:++ print(f"[WS] Invalid message: {message}")++++ def on_error(ws, error):++ print(f"[WS ERROR] {error}")++++ def on_close(ws, code, reason):++ print(f"[WS] Disconnected (code={code}, reason={reason})")++ if not args.no_reconnect:++ print(f"[WS] Reconnecting in {reconnect_delay}s...")++ time.sleep(reconnect_delay)++ connect(args, controller)++++ ws = websocket.WebSocketApp(url,++ on_open=on_open,++ on_message=on_message,++ on_error=on_error,++ on_close=on_close)++++ ws.run_forever()++++def main():++ parser = argparse.ArgumentParser(description="OpenVibe.Live — Hardware Controller")++ parser.add_argument("--key", required=True, help="Your OpenVibe.Live stream key")++ parser.add_argument("--server", default=DEFAULT_SERVER, help=f"Server hostname (default: {DEFAULT_SERVER})")++ parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Server port (default: {DEFAULT_PORT})")++ parser.add_argument("--ssl", action="store_true", help="Use WSS")++ parser.add_argument("--no-reconnect", action="store_true", help="Disable auto-reconnect")++ parser.add_argument("--enable-gpio", action="store_true", help="Enable GPIO (Raspberry Pi only)")++ parser.add_argument("--config", help="Path to GPIO config JSON file")++ args = parser.parse_args()++++ config = {"enable_gpio": args.enable_gpio}++ if args.config:++ try:++ with open(args.config) as f:++ config = json.load(f)++ config["enable_gpio"] = args.enable_gpio++ except (FileNotFoundError, json.JSONDecodeError) as e:++ print(f"[WARN] Failed to load config: {e}")++++ controller = HardwareController(config)++++ print(f"\n🏕️ OpenVibe.Live — Hardware Controller")++ print(f" Server: {args.server}:{args.port}")++ print(f" GPIO : {'enabled' if args.enable_gpio and GPIO_AVAILABLE else 'simulation mode'}")++ print(f" Press Ctrl+C to stop.\n")++++ signal.signal(signal.SIGINT, lambda s, f: sys.exit(0))++ signal.signal(signal.SIGTERM, lambda s, f: sys.exit(0))++++ connect(args, controller)++++if __name__ == "__main__":++ main()nnew file mode 100644@@@ -0,0 +1,177 @@++#!/usr/bin/env python3++"""++OpenVibe.Live — FFmpeg Streamer Script++Captures video/audio from Raspberry Pi camera + mic and streams++to OpenVibe.Live via JSMPEG (HTTP POST of MPEG-TS) or RTMP.++++Usage:++ python3 streamer.py --key YOUR_STREAM_KEY --server openvibe.live++ python3 streamer.py --key YOUR_STREAM_KEY --protocol rtmp --server openvibe.live++++Requirements:++ - FFmpeg installed (apt install ffmpeg)++ - Camera: USB cam or Pi Camera (v4l2)++ - Microphone: USB mic or built-in (ALSA)++"""++++import argparse++import subprocess++import sys++import signal++import os++++DEFAULT_SERVER = "localhost"++DEFAULT_VIDEO_PORT = 9710++DEFAULT_RTMP_PORT = 1935++++def get_video_device():++ """Find available video device."""++ for dev in ["/dev/video0", "/dev/video1"]:++ if os.path.exists(dev):++ return dev++ return None++++def get_audio_device():++ """Find available ALSA audio device."""++ try:++ result = subprocess.run(["arecord", "-l"], capture_output=True, text=True)++ if "card" in result.stdout.lower():++ return "default"++ except FileNotFoundError:++ pass++ return None++++def build_jsmpeg_command(args):++ """Build FFmpeg command for JSMPEG streaming (MPEG-TS over HTTP POST)."""++ video_dev = args.video_device or get_video_device()++ audio_dev = args.audio_device or get_audio_device()++++ if not video_dev:++ print("[ERROR] No video device found. Specify with --video-device")++ sys.exit(1)++++ cmd = ["ffmpeg"]++++ # Input: video++ cmd += ["-f", "v4l2", "-framerate", str(args.fps), "-video_size", args.resolution, "-i", video_dev]++++ # Input: audio (if available)++ if audio_dev and not args.no_audio:++ cmd += ["-f", "alsa", "-i", audio_dev]++++ # Video encoding: MPEG1 for JSMPEG++ cmd += [++ "-c:v", "mpeg1video",++ "-b:v", f"{args.bitrate}k",++ "-r", str(args.fps),++ "-bf", "0",++ ]++++ # Audio encoding++ if audio_dev and not args.no_audio:++ cmd += ["-c:a", "mp2", "-ar", "44100", "-ac", "1", "-b:a", "64k"]++ else:++ cmd += ["-an"]++++ # Output: HTTP POST to JSMPEG relay++ url = f"http://{args.server}:{args.video_port}/{args.key}"++ cmd += ["-f", "mpegts", url]++++ return cmd++++def build_rtmp_command(args):++ """Build FFmpeg command for RTMP streaming."""++ video_dev = args.video_device or get_video_device()++ audio_dev = args.audio_device or get_audio_device()++++ if not video_dev:++ print("[ERROR] No video device found. Specify with --video-device")++ sys.exit(1)++++ cmd = ["ffmpeg"]++++ # Input: video++ cmd += ["-f", "v4l2", "-framerate", str(args.fps), "-video_size", args.resolution, "-i", video_dev]++++ # Input: audio++ if audio_dev and not args.no_audio:++ cmd += ["-f", "alsa", "-i", audio_dev]++++ # Video encoding: H.264++ cmd += [++ "-c:v", "libx264",++ "-preset", "ultrafast",++ "-tune", "zerolatency",++ "-b:v", f"{args.bitrate}k",++ "-maxrate", f"{args.bitrate}k",++ "-bufsize", f"{args.bitrate * 2}k",++ "-g", str(args.fps * 2),++ ]++++ # Audio encoding: AAC++ if audio_dev and not args.no_audio:++ cmd += ["-c:a", "aac", "-ar", "44100", "-ac", "1", "-b:a", "96k"]++ else:++ cmd += ["-an"]++++ # Output: RTMP++ url = f"rtmp://{args.server}:{args.rtmp_port}/live/{args.key}"++ cmd += ["-f", "flv", url]++++ return cmd++++def main():++ parser = argparse.ArgumentParser(description="OpenVibe.Live — Stream from Raspberry Pi / Linux")++ parser.add_argument("--key", required=True, help="Your OpenVibe.Live stream key")++ parser.add_argument("--server", default=DEFAULT_SERVER, help=f"Server hostname (default: {DEFAULT_SERVER})")++ parser.add_argument("--protocol", choices=["jsmpeg", "rtmp"], default="jsmpeg", help="Streaming protocol")++ parser.add_argument("--video-device", help="Video device path (e.g. /dev/video0)")++ parser.add_argument("--audio-device", help="ALSA audio device (e.g. default, hw:1,0)")++ parser.add_argument("--resolution", default="640x480", help="Video resolution (default: 640x480)")++ parser.add_argument("--fps", type=int, default=24, help="Frames per second (default: 24)")++ parser.add_argument("--bitrate", type=int, default=800, help="Video bitrate in kbps (default: 800)")++ parser.add_argument("--video-port", type=int, default=DEFAULT_VIDEO_PORT, help=f"JSMPEG video port (default: {DEFAULT_VIDEO_PORT})")++ parser.add_argument("--rtmp-port", type=int, default=DEFAULT_RTMP_PORT, help=f"RTMP port (default: {DEFAULT_RTMP_PORT})")++ parser.add_argument("--no-audio", action="store_true", help="Disable audio")++ parser.add_argument("--dry-run", action="store_true", help="Print FFmpeg command without running")++ args = parser.parse_args()++++ if args.protocol == "jsmpeg":++ cmd = build_jsmpeg_command(args)++ else:++ cmd = build_rtmp_command(args)++++ print(f"\n🏕️ OpenVibe.Live — {args.protocol.upper()} Streaming")++ print(f" Server : {args.server}")++ print(f" Video : {args.resolution} @ {args.fps}fps, {args.bitrate}kbps")++ print(f" Audio : {'disabled' if args.no_audio else 'enabled'}")++ print(f" Command: {' '.join(cmd)}\n")++++ if args.dry_run:++ print("[DRY RUN] Command not executed.")++ return++++ # Run FFmpeg++ proc = None++ def signal_handler(sig, frame):++ print("\n[INFO] Stopping stream...")++ if proc:++ proc.terminate()++ sys.exit(0)++++ signal.signal(signal.SIGINT, signal_handler)++ signal.signal(signal.SIGTERM, signal_handler)++++ print("[INFO] Starting stream... Press Ctrl+C to stop.")++ try:++ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)++ _, stderr = proc.communicate()++ if proc.returncode != 0:++ print(f"[ERROR] FFmpeg exited with code {proc.returncode}")++ print(stderr.decode()[-500:] if stderr else "")++ except FileNotFoundError:++ print("[ERROR] FFmpeg not found. Install with: sudo apt install ffmpeg")++ sys.exit(1)++++if __name__ == "__main__":++ main()nnew file mode 100644@@@ -0,0 +1,56 @@++# OpenVibe Kiosk Companion (Firefox)++++A tiny Firefox extension that gives the OpenVibe.Live **`/kiosk#input`** address bar++access to your Firefox **history** so it can autocomplete like a real browser bar.++It can also set the kiosk as your **new-tab page**.++++## What it does++++- Injects a content script only on `openvibe.live/kiosk` that bridges the page to a++ background script which queries `browser.history` and returns your top matches++ (ranked by visit count + recency). History never leaves your browser.++- Overrides the new-tab page to open `https://openvibe.live/kiosk#input` **with the++ kiosk's omnibar focused instead of the browser address bar**. It does this by opening++ the kiosk in a fresh tab and closing the temporary new-tab page (Firefox focuses the web++ page on a real tab navigation, but keeps focus on the URL bar if you merely update the++ new-tab tab). (Don't want the new-tab override? Delete the `chrome_url_overrides` block++ from `manifest.json`.)++++The kiosk page detects the companion automatically — no configuration. Without the++extension the omnibar still works, it just falls back to its own on-page history.++++## Permissions++++- `history` — to read your browsing history for autocomplete.++- Runs its content script only on `*://openvibe.live/kiosk*`.++++## Install — quick test (temporary, gone on restart)++++1. Open `about:debugging#/runtime/this-firefox`.++2. **Load Temporary Add-on…** → pick this folder's `manifest.json`.++3. Open a new tab (or visit `openvibe.live/kiosk#input`) and start typing —++ history suggestions appear.++++## Install — permanent++++Firefox only runs signed extensions permanently. Sign it (free) with Mozilla:++++```bash++npm install --global web-ext++cd browser-extension/openvibe-kiosk-companion++# Get API credentials at https://addons.mozilla.org/developers/addon/api/key/++web-ext sign --api-key=YOUR_JWT_ISSUER --api-secret=YOUR_JWT_SECRET --channel=unlisted++```++++This produces a signed `.xpi` in `web-ext-artifacts/`. Install it via++`about:addons` → gear icon → **Install Add-on From File…**.++++(Alternatively, Firefox Developer Edition / ESR / Nightly can run unsigned++extensions by setting `xpinstall.signatures.required = false` in `about:config`.)++++## Files++++- `manifest.json` — MV3 manifest (Firefox).++- `background.js` — queries `browser.history`, ranks results.++- `history-bridge.js` — content script that relays page ⇄ background.++- `newtab.html` / `newtab.js` — new-tab override → kiosk.nnew file mode 100644@@@ -0,0 +1,33 @@++/**++ * Background script — the only context allowed to touch browser.history.++ * The content script (history-bridge.js) relays the kiosk page's queries here,++ * we search history, and return ranked matches. Nothing leaves the browser.++ */++const api = (typeof browser !== "undefined") ? browser : chrome;++++api.runtime.onMessage.addListener((msg) => {++ if (!msg || msg.type !== "openvibe-history-query") return;++ const q = String(msg.q || "").trim();++ if (!q) return Promise.resolve({ items: [] });++++ return api.history.search({++ text: q,++ maxResults: 30,++ startTime: 0 // search all of history++ }).then((results) => {++ const seen = new Set();++ const items = (results || [])++ .filter((r) => r.url && /^https?:/i.test(r.url))++ .map((r) => ({++ url: r.url,++ title: r.title || "",++ visitCount: r.visitCount || 0,++ lastVisit: r.lastVisitTime || 0++ }))++ // Rank by how often + how recently you've visited.++ .sort((a, b) => (b.visitCount - a.visitCount) || (b.lastVisit - a.lastVisit))++ .filter((r) => (seen.has(r.url) ? false : (seen.add(r.url), true)))++ .slice(0, 8);++ return { items };++ }).catch(() => ({ items: [] }));++});nnew file mode 100644@@@ -0,0 +1,38 @@++/**++ * Content script injected into openvibe.live/kiosk. It is the bridge between++ * the page (which can't read history) and the background script (which can):++ *++ * page --postMessage(history-query)--> this --runtime.sendMessage--> background++ * page <--postMessage(history-result)-- this <--response-------------- background++ *++ * Only same-origin page messages are honoured, and replies are posted back with++ * the page's own origin as targetOrigin, so history never leaks to other sites.++ */++const api = (typeof browser !== "undefined") ? browser : chrome;++const ORIGIN = window.location.origin;++++function announce() {++ window.postMessage({ source: "openvibe-kiosk-ext", type: "ready" }, ORIGIN);++}++// Announce both immediately and once the page's listener is surely attached.++announce();++window.addEventListener("DOMContentLoaded", announce);++++window.addEventListener("message", (event) => {++ if (event.source !== window) return;++ const d = event.data;++ if (!d || d.source !== "openvibe-kiosk") return;++++ if (d.type === "ping") { announce(); return; }++++ if (d.type === "history-query") {++ const { q, id } = d;++ api.runtime.sendMessage({ type: "openvibe-history-query", q })++ .then((resp) => {++ window.postMessage({ source: "openvibe-kiosk-ext", type: "history-result", id, q, items: (resp && resp.items) || [] }, ORIGIN);++ })++ .catch(() => {++ window.postMessage({ source: "openvibe-kiosk-ext", type: "history-result", id, q, items: [] }, ORIGIN);++ });++ }++});nnew file mode 100644@@@ -0,0 +1,26 @@++{++ "manifest_version": 3,++ "name": "OpenVibe Kiosk Companion",++ "version": "1.1.0",++ "description": "Lets the OpenVibe.Live /kiosk address bar autocomplete from your Firefox history, and (optionally) sets the kiosk as your new-tab page.",++ "browser_specific_settings": {++ "gecko": {++ "id": "[email protected]",++ "strict_min_version": "115.0"++ }++ },++ "permissions": ["history"],++ "background": {++ "scripts": ["background.js"]++ },++ "content_scripts": [++ {++ "matches": ["*://openvibe.live/kiosk*", "*://www.openvibe.live/kiosk*"],++ "js": ["history-bridge.js"],++ "run_at": "document_start"++ }++ ],++ "chrome_url_overrides": {++ "newtab": "newtab.html"++ }++}nnew file mode 100644@@@ -0,0 +1,11 @@++<!doctype html>++<html>++<head>++<meta charset="utf-8">++<title>New Tab</title>++<style>html,body{margin:0;height:100%;background:#08090c}</style>++</head>++<body>++<script src="newtab.js"></script>++</body>++</html>nnew file mode 100644@@@ -0,0 +1,33 @@++// Open the kiosk with the PAGE focused instead of the address bar.++//++// The trick (same one the "Custom New Tab" extensions use): simply navigating this++// temporary new-tab tab to a URL leaves focus on the address bar. But opening the URL++// in a FRESH tab and then closing this temporary tab makes Firefox focus the web page —++// so the kiosk's omnibar input gets the caret. Falls back to a plain redirect if the++// tabs API isn't available.++const api = (typeof browser !== "undefined") ? browser : chrome;++const TARGET = "https://openvibe.live/kiosk#input";++++(async () => {++ try {++ const tab = await api.tabs.getCurrent();++ if (!tab || tab.id == null) { location.replace(TARGET); return; }++++ const created = await api.tabs.create({++ url: TARGET,++ index: tab.index,++ windowId: tab.windowId,++ active: true++ });++++ // Keep the replacement in the same tab group, if any (best-effort).++ if (api.tabs.group && tab.groupId != null && tab.groupId > -1) {++ try { await api.tabs.group({ groupId: tab.groupId, tabIds: created.id }); } catch (e) { /* ignore */ }++ }++++ await api.tabs.remove(tab.id);++ try { api.history.deleteUrl({ url: location.href }); } catch (e) { /* ignore */ }++ } catch (e) {++ location.replace(TARGET); // fallback: at least land on the kiosk++ }++})();
@@ -19,7 +19,8 @@ per-install permissions. ADR-023 gives clients their own repository.
@@ -19,7 +19,8 @@ per-install permissions. ADR-023 gives clients their own repository.
| Surface | Directory | State |
| Surface | Directory | State |
|---|---|---|
|---|---|---|
| OpenVibe Coupons Helper (Chromium and Firefox, Manifest V3) | `browser/` | alpha, described below |
| OpenVibe Coupons Helper (Chromium and Firefox, Manifest V3) | `browser/` | alpha, described below |
-| Kiosk companion (Firefox) and the hardware/Raspberry Pi companions | not yet here | Planned by ADR-023: move them from OpenVibe.Live's `browser-extension/` and `hardware/`, leaving a redirect README in Live. That move isn't part of this change. |
+| Kiosk companion (Firefox, new-tab and history bridge for the /kiosk page) | `kiosk-companion/` | Moved from OpenVibe.Live `browser-extension/openvibe-kiosk-companion/` (ADR-023, Live 9c5aa64); Live keeps a redirect README |
+| Hardware / Raspberry Pi companions (streamer and controller scripts) | `hardware/` | Moved from OpenVibe.Live `hardware/` (ADR-023, Live 9c5aa64); Live keeps a redirect README |
| Desktop client | — | Planned by ADR-023 for the long term. Nothing exists. |
| Desktop client | — | Planned by ADR-023 for the long term. Nothing exists. |
## The OpenVibe Coupons Helper (`browser/`)
## The OpenVibe Coupons Helper (`browser/`)
new file mode 100644
new file mode 100644
@@ -0,0 +1,273 @@
@@ -0,0 +1,273 @@
+# OpenVibe.Live — Hardware Integration
+
+Scripts for streaming from and controlling hardware devices (Raspberry Pi, single-board computers, etc.) with OpenVibe.Live.
+
+## Requirements
+
+- **Python 3.7+**
+- **FFmpeg** — `sudo apt install ffmpeg`
+- **websocket-client** — `pip3 install websocket-client`
+- For TTS: **espeak** — `sudo apt install espeak`
+- For GPIO: **gpiozero** — `pip3 install gpiozero` (Raspberry Pi only)
+- For PulseAudio/PipeWire audio: **pulseaudio-utils** — `sudo apt install pulseaudio-utils`
+
+## Quick Start (Bash Script)
+
+The easiest way to stream. Supports camera, screen capture, OBS virtual camera, and audio-only modes.
+
+```bash
+# Basic camera stream
+./scripts/start-stream.sh YOUR_STREAM_KEY localhost 9710
+
+# Screen capture
+MODE=screen ./scripts/start-stream.sh YOUR_KEY localhost 9710
+
+# OBS Virtual Camera → JSMPEG
+MODE=obs ./scripts/start-stream.sh YOUR_KEY localhost 9710
+
+# Audio only (no video)
+MODE=audioonly ./scripts/start-stream.sh YOUR_KEY localhost 9711
+
+# HD 720p with PulseAudio
+RESOLUTION=1280x720 BITRATE=1200k AUDIO=pulse FPS=30 ./scripts/start-stream.sh YOUR_KEY localhost 9710
+
+# Disable audio
+NOAUDIO=1 ./scripts/start-stream.sh YOUR_KEY localhost 9710
+
+# Custom camera device
+VIDEO_DEV=/dev/video2 ./scripts/start-stream.sh YOUR_KEY localhost 9710
+```
+
+### Environment Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `MODE` | camera | `camera`, `screen`, `obs`, or `audioonly` |
+| `RESOLUTION` | 640x480 | Video resolution |
+| `FPS` | 24 | Frames per second |
+| `BITRATE` | 800k | Video bitrate |
+| `AUDIO_BITRATE` | 128k | Audio bitrate |
+| `AUDIO` | alsa | `alsa` or `pulse` |
+| `NOAUDIO` | 0 | Set to `1` to disable audio |
+| `VIDEO_DEV` | auto | Camera device path (e.g. `/dev/video0`) |
+| `SCREEN_RES` | 1920x1080 | Screen capture resolution (screen mode only) |
+| `DISPLAY_ID` | :0.0 | X11 display (screen mode only) |
+
+## Finding Your Devices
+
+### Camera / Video
+
+```bash
+# List all video devices
+v4l2-ctl --list-devices
+
+# List supported formats and resolutions
+v4l2-ctl -d /dev/video0 --list-formats-ext
+
+# Test camera with a snapshot
+ffmpeg -f v4l2 -i /dev/video0 -frames 1 test.jpg
+
+# macOS: list devices
+ffmpeg -f avfoundation -list_devices true -i ""
+```
+
+### Audio / Microphone
+
+```bash
+# List ALSA audio devices
+arecord -l
+
+# List PulseAudio / PipeWire sources
+pactl list short sources
+
+# Test microphone (record 5 seconds, play back)
+arecord -d 5 -f cd test.wav && aplay test.wav
+
+# PipeWire users — check audio sources
+pw-cli list-objects | grep -A2 "node.name"
+```
+
+### OBS Virtual Camera
+
+```bash
+# 1. Start OBS Studio
+# 2. Go to Tools → Start Virtual Camera
+# 3. Check which device it created:
+v4l2-ctl --list-devices
+# Usually appears as /dev/video2 or /dev/video4
+
+# 4. Stream it (with PulseAudio for desktop audio):
+ffmpeg -f v4l2 -i /dev/video2 -f pulse -i default \
+ -f mpegts -codec:v mpeg1video -s 640x480 -b:v 500k -bf 0 \
+ -codec:a mp2 -b:a 128k -ar 44100 -ac 1 -muxdelay 0.001 \
+ http://SERVER:PORT/KEY/640/480/
+```
+
+### Raspberry Pi Camera (libcamera)
+
+```bash
+# Test if camera works
+libcamera-hello -t 5000
+
+# Stream via pipe to FFmpeg
+libcamera-vid -t 0 --inline --codec mjpeg --width 640 --height 480 -o - | \
+ ffmpeg -i pipe:0 -f mpegts -codec:v mpeg1video -s 640x480 -b:v 350k -bf 0 \
+ -muxdelay 0.001 http://SERVER:PORT/KEY/640/480/
+
+# With audio
+libcamera-vid -t 0 --inline --codec mjpeg --width 640 --height 480 -o - | \
+ ffmpeg -i pipe:0 -f alsa -i default \
+ -f mpegts -codec:v mpeg1video -s 640x480 -b:v 350k -bf 0 \
+ -codec:a mp2 -b:a 128k -ar 44100 -ac 1 -muxdelay 0.001 \
+ http://SERVER:PORT/KEY/640/480/
+```
+
+## FFmpeg Command Reference
+
+### JSMPEG Constraints
+
+JSMPEG uses canvas-based MPEG1 decoding. You **must** use:
+- Video codec: `mpeg1video` (not h264/h265)
+- Audio codec: `mp2` (not aac/opus)
+- Container: `mpegts`
+- No B-frames: `-bf 0`
+
+### Common FFmpeg Flags
+
+| Flag | Description | Example |
+|------|-------------|---------|
+| `-f v4l2` | Linux camera input | `-f v4l2 -i /dev/video0` |
+| `-f avfoundation` | macOS camera input | `-f avfoundation -i "0:0"` |
+| `-f x11grab` | Screen capture | `-f x11grab -s 1920x1080 -i :0.0` |
+| `-f alsa` | ALSA audio input | `-f alsa -i default` |
+| `-f pulse` | PulseAudio input | `-f pulse -i default` |
+| `-s WxH` | Output resolution | `-s 640x480` |
+| `-b:v` | Video bitrate | `-b:v 800k` |
+| `-b:a` | Audio bitrate | `-b:a 128k` |
+| `-r` | Frame rate | `-r 30` |
+| `-ar` | Audio sample rate | `-ar 44100` |
+| `-ac` | Audio channels | `-ac 1` (mono), `-ac 2` (stereo) |
+| `-bf 0` | Disable B-frames | Required for JSMPEG |
+| `-q:v` | Quality (2=best, 31=worst) | `-q:v 5` |
+| `-muxdelay` | Mux delay (lower = less latency) | `-muxdelay 0.001` |
+| `-framerate` | Input capture FPS | `-framerate 30` |
+| `-video_size` | Input capture resolution | `-video_size 1280x720` |
+
+### Recommended Presets
+
+| Device | Resolution | Video Bitrate | Audio | Notes |
+|--------|-----------|---------------|-------|-------|
+| Pi Zero | 320x240 | 200k | 64k mono | Use `-r 15` for stability |
+| Pi 3/4 | 640x480 | 350–500k | 128k mono | Good balance |
+| Desktop | 1280x720 | 800k–1.5M | 128k stereo | Smooth 30fps |
+| High Quality | 1920x1080 | 2–4M | 192k stereo | Requires good upload |
+
+## Streamer Script (Python)
+
+Captures video (USB camera / Pi Camera) and audio (ALSA mic), then pushes to OpenVibe.Live via JSMPEG or RTMP.
+
+```bash
+# JSMPEG streaming (recommended for low-latency)
+python3 streamer.py --key YOUR_STREAM_KEY --server openvibe.live
+
+# RTMP streaming (for OBS compatibility)
+python3 streamer.py --key YOUR_STREAM_KEY --server openvibe.live --protocol rtmp
+
+# Custom settings
+python3 streamer.py --key YOUR_KEY --server openvibe.live \
+ --resolution 1280x720 --fps 30 --bitrate 1500 \
+ --video-device /dev/video0 --audio-device default
+```
+
+### Options
+
+| Flag | Default | Description |
+|------|---------|-------------|
+| `--key` | (required) | Stream key from dashboard |
+| `--server` | localhost | OpenVibe.Live server |
+| `--protocol` | jsmpeg | `jsmpeg` or `rtmp` |
+| `--resolution` | 640x480 | Video resolution |
+| `--fps` | 24 | Frames per second |
+| `--bitrate` | 800 | Video bitrate (kbps) |
+| `--no-audio` | false | Disable audio capture |
+| `--dry-run` | false | Print command without running |
+
+## Controller Script
+
+Connects to OpenVibe.Live control WebSocket and executes viewer commands on the hardware (motors, servos, LEDs, TTS).
+
+```bash
+# Basic (simulation mode — prints commands)
+python3 controller.py --key YOUR_STREAM_KEY --server openvibe.live
+
+# With GPIO enabled (Raspberry Pi)
+python3 controller.py --key YOUR_KEY --server openvibe.live \
+ --enable-gpio --config gpio-config.json
+```
+
+### GPIO Config Example
+
+Create `gpio-config.json`:
+
+```json
+{
+ "gpio": {
+ "motor_left": { "forward": 17, "backward": 18 },
+ "motor_right": { "forward": 22, "backward": 23 },
+ "servos": { "tilt": 12, "pan": 13 },
+ "leds": { "main": 25, "status": 24 }
+ }
+}
+```
+
+### Supported Commands
+
+| Command | Action | Hardware |
+|---------|--------|----------|
+| `forward` | Move forward 0.5s | Motors |
+| `backward` | Move backward 0.5s | Motors |
+| `left` | Turn left 0.3s | Motors |
+| `right` | Turn right 0.3s | Motors |
+| `stop` | Stop all motors | Motors |
+| `tts` | Text-to-speech | espeak |
+| `led_on` | Turn LED on | GPIO |
+| `led_off` | Turn LED off | GPIO |
+| `horn` | Play horn sound | Speaker |
+| `servo_up` | Tilt servo up | Servo |
+| `servo_down` | Tilt servo down | Servo |
+| `servo_center` | Center servo | Servo |
+
+## Raspberry Pi Setup
+
+```bash
+# Install system dependencies
+sudo apt update
+sudo apt install ffmpeg espeak python3-pip pulseaudio-utils
+
+# Install Python packages
+pip3 install websocket-client gpiozero
+
+# Enable camera
+sudo raspi-config # Interface Options → Camera → Enable
+
+# Test camera
+ffmpeg -f v4l2 -i /dev/video0 -frames 1 test.jpg
+
+# Run both scripts
+python3 streamer.py --key YOUR_KEY --server openvibe.live &
+python3 controller.py --key YOUR_KEY --server openvibe.live --enable-gpio
+```
+
+## Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| No video device | Run `ls /dev/video*`. If empty, check camera connection. Try `sudo modprobe uvcvideo`. |
+| No audio device | Run `arecord -l`. If empty, plug in a USB mic or check `pulseaudio --check`. |
+| Device busy | Close other camera apps (OBS, Cheese, etc.) and retry. |
+| Connection refused | Check server is running. Check firewall: `sudo ufw allow 9710`. |
+| High latency | Lower resolution, reduce bitrate, ensure `-muxdelay 0.001`. |
+| Choppy video | Upload bandwidth too low for bitrate. Reduce `-b:v` or resolution. |
+| Permission denied | `sudo usermod -aG video $USER` then log out/in. |
+| macOS camera | Use `-f avfoundation -i "0:0"` instead of `-f v4l2 -i /dev/video0`. |
+| PipeWire no sound | Use `-f pulse -i default` (PipeWire is PulseAudio-compatible). |
new file mode 100644
new file mode 100644
@@ -0,0 +1,278 @@
@@ -0,0 +1,278 @@
+#!/usr/bin/env python3
+"""
+OpenVibe.Live — Hardware Controller Script
+Connects to OpenVibe.Live control WebSocket and executes commands
+on Raspberry Pi GPIO, servos, motors, TTS, etc.
+
+Usage:
+ python3 controller.py --key YOUR_STREAM_KEY --server openvibe.live
+
+Requirements:
+ - websocket-client: pip3 install websocket-client
+ - For TTS: espeak or pico2wave (apt install espeak)
+ - For GPIO: RPi.GPIO or gpiozero (pip3 install gpiozero)
+"""
+
+import argparse
+import json
+import subprocess
+import sys
+import signal
+import time
+import threading
+
+try:
+ import websocket
+except ImportError:
+ print("[ERROR] websocket-client not installed. Run: pip3 install websocket-client")
+ sys.exit(1)
+
+# Try to import GPIO (Raspberry Pi only)
+GPIO_AVAILABLE = False
+try:
+ from gpiozero import Motor, Servo, LED
+ GPIO_AVAILABLE = True
+except ImportError:
+ pass
+
+DEFAULT_SERVER = "localhost"
+DEFAULT_PORT = 3000
+
+# ═══════════════════════════════════════════════════════════════
+# Command Handlers
+# ═══════════════════════════════════════════════════════════════
+
+class HardwareController:
+ """Handles incoming commands and maps them to hardware actions."""
+
+ def __init__(self, config=None):
+ self.config = config or {}
+ self.motors = {}
+ self.servos = {}
+ self.leds = {}
+
+ if GPIO_AVAILABLE and self.config.get("enable_gpio"):
+ self._init_gpio()
+
+ def _init_gpio(self):
+ """Initialize GPIO devices based on config."""
+ gpio = self.config.get("gpio", {})
+
+ # Motors (L298N or similar H-bridge)
+ if "motor_left" in gpio:
+ pins = gpio["motor_left"]
+ self.motors["left"] = Motor(pins["forward"], pins["backward"])
+ if "motor_right" in gpio:
+ pins = gpio["motor_right"]
+ self.motors["right"] = Motor(pins["forward"], pins["backward"])
+
+ # Servos
+ for name, pin in gpio.get("servos", {}).items():
+ self.servos[name] = Servo(pin)
+
+ # LEDs
+ for name, pin in gpio.get("leds", {}).items():
+ self.leds[name] = LED(pin)
+
+ print(f"[GPIO] Motors: {list(self.motors.keys())}, Servos: {list(self.servos.keys())}, LEDs: {list(self.leds.keys())}")
+
+ def handle_command(self, command, data=None):
+ """Route a command to the appropriate handler."""
+ handlers = {
+ "forward": self.cmd_forward,
+ "backward": self.cmd_backward,
+ "left": self.cmd_left,
+ "right": self.cmd_right,
+ "stop": self.cmd_stop,
+ "tts": self.cmd_tts,
+ "led_on": self.cmd_led_on,
+ "led_off": self.cmd_led_off,
+ "horn": self.cmd_horn,
+ "servo_up": self.cmd_servo_up,
+ "servo_down": self.cmd_servo_down,
+ "servo_center": self.cmd_servo_center,
+ }
+
+ handler = handlers.get(command)
+ if handler:
+ print(f"[CMD] {command} {data or ''}")
+ handler(data)
+ else:
+ print(f"[CMD] Unknown command: {command}")
+
+ # Movement commands
+ def cmd_forward(self, data=None):
+ duration = (data or {}).get("duration", 0.5)
+ if self.motors:
+ for m in self.motors.values():
+ m.forward()
+ threading.Timer(duration, self.cmd_stop).start()
+ else:
+ print(" → [SIM] Moving forward")
+
+ def cmd_backward(self, data=None):
+ duration = (data or {}).get("duration", 0.5)
+ if self.motors:
+ for m in self.motors.values():
+ m.backward()
+ threading.Timer(duration, self.cmd_stop).start()
+ else:
+ print(" → [SIM] Moving backward")
+
+ def cmd_left(self, data=None):
+ duration = (data or {}).get("duration", 0.3)
+ if "left" in self.motors and "right" in self.motors:
+ self.motors["left"].backward()
+ self.motors["right"].forward()
+ threading.Timer(duration, self.cmd_stop).start()
+ else:
+ print(" → [SIM] Turning left")
+
+ def cmd_right(self, data=None):
+ duration = (data or {}).get("duration", 0.3)
+ if "left" in self.motors and "right" in self.motors:
+ self.motors["left"].forward()
+ self.motors["right"].backward()
+ threading.Timer(duration, self.cmd_stop).start()
+ else:
+ print(" → [SIM] Turning right")
+
+ def cmd_stop(self, data=None):
+ for m in self.motors.values():
+ m.stop()
+ print(" → [SIM] Stopped" if not self.motors else " → Motors stopped")
+
+ # TTS
+ def cmd_tts(self, data=None):
+ text = (data or {}).get("text", "hello from OpenVibe.Live")
+ try:
+ subprocess.Popen(["espeak", "-s", "150", text],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ print(f" → TTS: {text}")
+ except FileNotFoundError:
+ print(f" → [SIM] TTS (espeak not installed): {text}")
+
+ # LED
+ def cmd_led_on(self, data=None):
+ name = (data or {}).get("name", "main")
+ if name in self.leds:
+ self.leds[name].on()
+ print(f" → LED {name} ON")
+
+ def cmd_led_off(self, data=None):
+ name = (data or {}).get("name", "main")
+ if name in self.leds:
+ self.leds[name].off()
+ print(f" → LED {name} OFF")
+
+ # Horn/buzzer
+ def cmd_horn(self, data=None):
+ duration = (data or {}).get("duration", 0.5)
+ try:
+ subprocess.Popen(["aplay", "-d", str(duration), "/usr/share/sounds/alsa/Front_Center.wav"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ except FileNotFoundError:
+ print(" → [SIM] Horn!")
+
+ # Servo
+ def cmd_servo_up(self, data=None):
+ name = (data or {}).get("name", "tilt")
+ if name in self.servos:
+ self.servos[name].value = min(1, self.servos[name].value + 0.2)
+ print(f" → Servo {name} UP")
+
+ def cmd_servo_down(self, data=None):
+ name = (data or {}).get("name", "tilt")
+ if name in self.servos:
+ self.servos[name].value = max(-1, self.servos[name].value - 0.2)
+ print(f" → Servo {name} DOWN")
+
+ def cmd_servo_center(self, data=None):
+ name = (data or {}).get("name", "tilt")
+ if name in self.servos:
+ self.servos[name].mid()
+ print(f" → Servo {name} CENTER")
+
+
+# ═══════════════════════════════════════════════════════════════
+# WebSocket Client
+# ═══════════════════════════════════════════════════════════════
+
+def connect(args, controller):
+ """Connect to OpenVibe.Live control WebSocket."""
+ protocol = "wss" if args.ssl else "ws"
+ url = f"{protocol}://{args.server}:{args.port}/ws/control"
+ reconnect_delay = 5
+
+ def on_open(ws):
+ print(f"[WS] Connected to {url}")
+ ws.send(json.dumps({
+ "type": "join",
+ "role": "hardware",
+ "streamKey": args.key,
+ }))
+
+ def on_message(ws, message):
+ try:
+ msg = json.loads(message)
+ if msg.get("type") == "command":
+ controller.handle_command(msg["command"], msg.get("data"))
+ elif msg.get("type") == "error":
+ print(f"[WS ERROR] {msg.get('message')}")
+ elif msg.get("type") == "ping":
+ ws.send(json.dumps({"type": "pong"}))
+ except json.JSONDecodeError:
+ print(f"[WS] Invalid message: {message}")
+
+ def on_error(ws, error):
+ print(f"[WS ERROR] {error}")
+
+ def on_close(ws, code, reason):
+ print(f"[WS] Disconnected (code={code}, reason={reason})")
+ if not args.no_reconnect:
+ print(f"[WS] Reconnecting in {reconnect_delay}s...")
+ time.sleep(reconnect_delay)
+ connect(args, controller)
+
+ ws = websocket.WebSocketApp(url,
+ on_open=on_open,
+ on_message=on_message,
+ on_error=on_error,
+ on_close=on_close)
+
+ ws.run_forever()
+
+def main():
+ parser = argparse.ArgumentParser(description="OpenVibe.Live — Hardware Controller")
+ parser.add_argument("--key", required=True, help="Your OpenVibe.Live stream key")
+ parser.add_argument("--server", default=DEFAULT_SERVER, help=f"Server hostname (default: {DEFAULT_SERVER})")
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Server port (default: {DEFAULT_PORT})")
+ parser.add_argument("--ssl", action="store_true", help="Use WSS")
+ parser.add_argument("--no-reconnect", action="store_true", help="Disable auto-reconnect")
+ parser.add_argument("--enable-gpio", action="store_true", help="Enable GPIO (Raspberry Pi only)")
+ parser.add_argument("--config", help="Path to GPIO config JSON file")
+ args = parser.parse_args()
+
+ config = {"enable_gpio": args.enable_gpio}
+ if args.config:
+ try:
+ with open(args.config) as f:
+ config = json.load(f)
+ config["enable_gpio"] = args.enable_gpio
+ except (FileNotFoundError, json.JSONDecodeError) as e:
+ print(f"[WARN] Failed to load config: {e}")
+
+ controller = HardwareController(config)
+
+ print(f"\n🏕️ OpenVibe.Live — Hardware Controller")
+ print(f" Server: {args.server}:{args.port}")
+ print(f" GPIO : {'enabled' if args.enable_gpio and GPIO_AVAILABLE else 'simulation mode'}")
+ print(f" Press Ctrl+C to stop.\n")
+
+ signal.signal(signal.SIGINT, lambda s, f: sys.exit(0))
+ signal.signal(signal.SIGTERM, lambda s, f: sys.exit(0))
+
+ connect(args, controller)
+
+if __name__ == "__main__":
+ main()
new file mode 100644
new file mode 100644
@@ -0,0 +1,177 @@
@@ -0,0 +1,177 @@
+#!/usr/bin/env python3
+"""
+OpenVibe.Live — FFmpeg Streamer Script
+Captures video/audio from Raspberry Pi camera + mic and streams
+to OpenVibe.Live via JSMPEG (HTTP POST of MPEG-TS) or RTMP.
+
+Usage:
+ python3 streamer.py --key YOUR_STREAM_KEY --server openvibe.live
+ python3 streamer.py --key YOUR_STREAM_KEY --protocol rtmp --server openvibe.live
+
+Requirements:
+ - FFmpeg installed (apt install ffmpeg)
+ - Camera: USB cam or Pi Camera (v4l2)
+ - Microphone: USB mic or built-in (ALSA)
+"""
+
+import argparse
+import subprocess
+import sys
+import signal
+import os
+
+DEFAULT_SERVER = "localhost"
+DEFAULT_VIDEO_PORT = 9710
+DEFAULT_RTMP_PORT = 1935
+
+def get_video_device():
+ """Find available video device."""
+ for dev in ["/dev/video0", "/dev/video1"]:
+ if os.path.exists(dev):
+ return dev
+ return None
+
+def get_audio_device():
+ """Find available ALSA audio device."""
+ try:
+ result = subprocess.run(["arecord", "-l"], capture_output=True, text=True)
+ if "card" in result.stdout.lower():
+ return "default"
+ except FileNotFoundError:
+ pass
+ return None
+
+def build_jsmpeg_command(args):
+ """Build FFmpeg command for JSMPEG streaming (MPEG-TS over HTTP POST)."""
+ video_dev = args.video_device or get_video_device()
+ audio_dev = args.audio_device or get_audio_device()
+
+ if not video_dev:
+ print("[ERROR] No video device found. Specify with --video-device")
+ sys.exit(1)
+
+ cmd = ["ffmpeg"]
+
+ # Input: video
+ cmd += ["-f", "v4l2", "-framerate", str(args.fps), "-video_size", args.resolution, "-i", video_dev]
+
+ # Input: audio (if available)
+ if audio_dev and not args.no_audio:
+ cmd += ["-f", "alsa", "-i", audio_dev]
+
+ # Video encoding: MPEG1 for JSMPEG
+ cmd += [
+ "-c:v", "mpeg1video",
+ "-b:v", f"{args.bitrate}k",
+ "-r", str(args.fps),
+ "-bf", "0",
+ ]
+
+ # Audio encoding
+ if audio_dev and not args.no_audio:
+ cmd += ["-c:a", "mp2", "-ar", "44100", "-ac", "1", "-b:a", "64k"]
+ else:
+ cmd += ["-an"]
+
+ # Output: HTTP POST to JSMPEG relay
+ url = f"http://{args.server}:{args.video_port}/{args.key}"
+ cmd += ["-f", "mpegts", url]
+
+ return cmd
+
+def build_rtmp_command(args):
+ """Build FFmpeg command for RTMP streaming."""
+ video_dev = args.video_device or get_video_device()
+ audio_dev = args.audio_device or get_audio_device()
+
+ if not video_dev:
+ print("[ERROR] No video device found. Specify with --video-device")
+ sys.exit(1)
+
+ cmd = ["ffmpeg"]
+
+ # Input: video
+ cmd += ["-f", "v4l2", "-framerate", str(args.fps), "-video_size", args.resolution, "-i", video_dev]
+
+ # Input: audio
+ if audio_dev and not args.no_audio:
+ cmd += ["-f", "alsa", "-i", audio_dev]
+
+ # Video encoding: H.264
+ cmd += [
+ "-c:v", "libx264",
+ "-preset", "ultrafast",
+ "-tune", "zerolatency",
+ "-b:v", f"{args.bitrate}k",
+ "-maxrate", f"{args.bitrate}k",
+ "-bufsize", f"{args.bitrate * 2}k",
+ "-g", str(args.fps * 2),
+ ]
+
+ # Audio encoding: AAC
+ if audio_dev and not args.no_audio:
+ cmd += ["-c:a", "aac", "-ar", "44100", "-ac", "1", "-b:a", "96k"]
+ else:
+ cmd += ["-an"]
+
+ # Output: RTMP
+ url = f"rtmp://{args.server}:{args.rtmp_port}/live/{args.key}"
+ cmd += ["-f", "flv", url]
+
+ return cmd
+
+def main():
+ parser = argparse.ArgumentParser(description="OpenVibe.Live — Stream from Raspberry Pi / Linux")
+ parser.add_argument("--key", required=True, help="Your OpenVibe.Live stream key")
+ parser.add_argument("--server", default=DEFAULT_SERVER, help=f"Server hostname (default: {DEFAULT_SERVER})")
+ parser.add_argument("--protocol", choices=["jsmpeg", "rtmp"], default="jsmpeg", help="Streaming protocol")
+ parser.add_argument("--video-device", help="Video device path (e.g. /dev/video0)")
+ parser.add_argument("--audio-device", help="ALSA audio device (e.g. default, hw:1,0)")
+ parser.add_argument("--resolution", default="640x480", help="Video resolution (default: 640x480)")
+ parser.add_argument("--fps", type=int, default=24, help="Frames per second (default: 24)")
+ parser.add_argument("--bitrate", type=int, default=800, help="Video bitrate in kbps (default: 800)")
+ parser.add_argument("--video-port", type=int, default=DEFAULT_VIDEO_PORT, help=f"JSMPEG video port (default: {DEFAULT_VIDEO_PORT})")
+ parser.add_argument("--rtmp-port", type=int, default=DEFAULT_RTMP_PORT, help=f"RTMP port (default: {DEFAULT_RTMP_PORT})")
+ parser.add_argument("--no-audio", action="store_true", help="Disable audio")
+ parser.add_argument("--dry-run", action="store_true", help="Print FFmpeg command without running")
+ args = parser.parse_args()
+
+ if args.protocol == "jsmpeg":
+ cmd = build_jsmpeg_command(args)
+ else:
+ cmd = build_rtmp_command(args)
+
+ print(f"\n🏕️ OpenVibe.Live — {args.protocol.upper()} Streaming")
+ print(f" Server : {args.server}")
+ print(f" Video : {args.resolution} @ {args.fps}fps, {args.bitrate}kbps")
+ print(f" Audio : {'disabled' if args.no_audio else 'enabled'}")
+ print(f" Command: {' '.join(cmd)}\n")
+
+ if args.dry_run:
+ print("[DRY RUN] Command not executed.")
+ return
+
+ # Run FFmpeg
+ proc = None
+ def signal_handler(sig, frame):
+ print("\n[INFO] Stopping stream...")
+ if proc:
+ proc.terminate()
+ sys.exit(0)
+
+ signal.signal(signal.SIGINT, signal_handler)
+ signal.signal(signal.SIGTERM, signal_handler)
+
+ print("[INFO] Starting stream... Press Ctrl+C to stop.")
+ try:
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ _, stderr = proc.communicate()
+ if proc.returncode != 0:
+ print(f"[ERROR] FFmpeg exited with code {proc.returncode}")
+ print(stderr.decode()[-500:] if stderr else "")
+ except FileNotFoundError:
+ print("[ERROR] FFmpeg not found. Install with: sudo apt install ffmpeg")
+ sys.exit(1)
+
+if __name__ == "__main__":
+ main()
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+# OpenVibe Kiosk Companion (Firefox)
+
+A tiny Firefox extension that gives the OpenVibe.Live **`/kiosk#input`** address bar
+access to your Firefox **history** so it can autocomplete like a real browser bar.
+It can also set the kiosk as your **new-tab page**.
+
+## What it does
+
+- Injects a content script only on `openvibe.live/kiosk` that bridges the page to a
+ background script which queries `browser.history` and returns your top matches
+ (ranked by visit count + recency). History never leaves your browser.
+- Overrides the new-tab page to open `https://openvibe.live/kiosk#input` **with the
+ kiosk's omnibar focused instead of the browser address bar**. It does this by opening
+ the kiosk in a fresh tab and closing the temporary new-tab page (Firefox focuses the web
+ page on a real tab navigation, but keeps focus on the URL bar if you merely update the
+ new-tab tab). (Don't want the new-tab override? Delete the `chrome_url_overrides` block
+ from `manifest.json`.)
+
+The kiosk page detects the companion automatically — no configuration. Without the
+extension the omnibar still works, it just falls back to its own on-page history.
+
+## Permissions
+
+- `history` — to read your browsing history for autocomplete.
+- Runs its content script only on `*://openvibe.live/kiosk*`.
+
+## Install — quick test (temporary, gone on restart)
+
+1. Open `about:debugging#/runtime/this-firefox`.
+2. **Load Temporary Add-on…** → pick this folder's `manifest.json`.
+3. Open a new tab (or visit `openvibe.live/kiosk#input`) and start typing —
+ history suggestions appear.
+
+## Install — permanent
+
+Firefox only runs signed extensions permanently. Sign it (free) with Mozilla:
+
+```bash
+npm install --global web-ext
+cd browser-extension/openvibe-kiosk-companion
+# Get API credentials at https://addons.mozilla.org/developers/addon/api/key/
+web-ext sign --api-key=YOUR_JWT_ISSUER --api-secret=YOUR_JWT_SECRET --channel=unlisted
+```
+
+This produces a signed `.xpi` in `web-ext-artifacts/`. Install it via
+`about:addons` → gear icon → **Install Add-on From File…**.
+
+(Alternatively, Firefox Developer Edition / ESR / Nightly can run unsigned
+extensions by setting `xpinstall.signatures.required = false` in `about:config`.)
+
+## Files
+
+- `manifest.json` — MV3 manifest (Firefox).
+- `background.js` — queries `browser.history`, ranks results.
+- `history-bridge.js` — content script that relays page ⇄ background.
+- `newtab.html` / `newtab.js` — new-tab override → kiosk.
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+/**
+ * Background script — the only context allowed to touch browser.history.
+ * The content script (history-bridge.js) relays the kiosk page's queries here,
+ * we search history, and return ranked matches. Nothing leaves the browser.
+ */
+const api = (typeof browser !== "undefined") ? browser : chrome;
+
+api.runtime.onMessage.addListener((msg) => {
+ if (!msg || msg.type !== "openvibe-history-query") return;
+ const q = String(msg.q || "").trim();
+ if (!q) return Promise.resolve({ items: [] });
+
+ return api.history.search({
+ text: q,
+ maxResults: 30,
+ startTime: 0 // search all of history
+ }).then((results) => {
+ const seen = new Set();
+ const items = (results || [])
+ .filter((r) => r.url && /^https?:/i.test(r.url))
+ .map((r) => ({
+ url: r.url,
+ title: r.title || "",
+ visitCount: r.visitCount || 0,
+ lastVisit: r.lastVisitTime || 0
+ }))
+ // Rank by how often + how recently you've visited.
+ .sort((a, b) => (b.visitCount - a.visitCount) || (b.lastVisit - a.lastVisit))
+ .filter((r) => (seen.has(r.url) ? false : (seen.add(r.url), true)))
+ .slice(0, 8);
+ return { items };
+ }).catch(() => ({ items: [] }));
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+/**
+ * Content script injected into openvibe.live/kiosk. It is the bridge between
+ * the page (which can't read history) and the background script (which can):
+ *
+ * page --postMessage(history-query)--> this --runtime.sendMessage--> background
+ * page <--postMessage(history-result)-- this <--response-------------- background
+ *
+ * Only same-origin page messages are honoured, and replies are posted back with
+ * the page's own origin as targetOrigin, so history never leaks to other sites.
+ */
+const api = (typeof browser !== "undefined") ? browser : chrome;
+const ORIGIN = window.location.origin;
+
+function announce() {
+ window.postMessage({ source: "openvibe-kiosk-ext", type: "ready" }, ORIGIN);
+}
+// Announce both immediately and once the page's listener is surely attached.
+announce();
+window.addEventListener("DOMContentLoaded", announce);
+
+window.addEventListener("message", (event) => {
+ if (event.source !== window) return;
+ const d = event.data;
+ if (!d || d.source !== "openvibe-kiosk") return;
+
+ if (d.type === "ping") { announce(); return; }
+
+ if (d.type === "history-query") {
+ const { q, id } = d;
+ api.runtime.sendMessage({ type: "openvibe-history-query", q })
+ .then((resp) => {
+ window.postMessage({ source: "openvibe-kiosk-ext", type: "history-result", id, q, items: (resp && resp.items) || [] }, ORIGIN);
+ })
+ .catch(() => {
+ window.postMessage({ source: "openvibe-kiosk-ext", type: "history-result", id, q, items: [] }, ORIGIN);
+ });
+ }
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+{
+ "manifest_version": 3,
+ "name": "OpenVibe Kiosk Companion",
+ "version": "1.1.0",
+ "description": "Lets the OpenVibe.Live /kiosk address bar autocomplete from your Firefox history, and (optionally) sets the kiosk as your new-tab page.",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "[email protected]",
+ "strict_min_version": "115.0"
+ }
+ },
+ "permissions": ["history"],
+ "background": {
+ "scripts": ["background.js"]
+ },
+ "content_scripts": [
+ {
+ "matches": ["*://openvibe.live/kiosk*", "*://www.openvibe.live/kiosk*"],
+ "js": ["history-bridge.js"],
+ "run_at": "document_start"
+ }
+ ],
+ "chrome_url_overrides": {
+ "newtab": "newtab.html"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+<!doctype html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>New Tab</title>
+<style>html,body{margin:0;height:100%;background:#08090c}</style>
+</head>
+<body>
+<script src="newtab.js"></script>
+</body>
+</html>
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+// Open the kiosk with the PAGE focused instead of the address bar.
+//
+// The trick (same one the "Custom New Tab" extensions use): simply navigating this
+// temporary new-tab tab to a URL leaves focus on the address bar. But opening the URL
+// in a FRESH tab and then closing this temporary tab makes Firefox focus the web page —
+// so the kiosk's omnibar input gets the caret. Falls back to a plain redirect if the
+// tabs API isn't available.
+const api = (typeof browser !== "undefined") ? browser : chrome;
+const TARGET = "https://openvibe.live/kiosk#input";
+
+(async () => {
+ try {
+ const tab = await api.tabs.getCurrent();
+ if (!tab || tab.id == null) { location.replace(TARGET); return; }
+
+ const created = await api.tabs.create({
+ url: TARGET,
+ index: tab.index,
+ windowId: tab.windowId,
+ active: true
+ });
+
+ // Keep the replacement in the same tab group, if any (best-effort).
+ if (api.tabs.group && tab.groupId != null && tab.groupId > -1) {
+ try { await api.tabs.group({ groupId: tab.groupId, tabIds: created.id }); } catch (e) { /* ignore */ }
+ }
+
+ await api.tabs.remove(tab.id);
+ try { api.history.deleteUrl({ url: location.href }); } catch (e) { /* ignore */ }
+ } catch (e) {
+ location.replace(TARGET); // fallback: at least land on the kiosk
+ }
+})();