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

OP OpenVibers committed 24 Sept 2026 · 7f2759dc64 · 10 files, +927 −1


Changes

ddiff --git a/README.md b/README.md
iindex 33af428..f07965e 100644
---- a/README.md
++++ b/README.md
@@@ -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/`)
ddiff --git a/hardware/README.md b/hardware/README.md
nnew file mode 100644
iindex 0000000..d1e81ad
---- /dev/null
++++ b/hardware/README.md
@@@ -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). |
ddiff --git a/hardware/controller.py b/hardware/controller.py
nnew file mode 100644
iindex 0000000..d816f4a
---- /dev/null
++++ b/hardware/controller.py
@@@ -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()
ddiff --git a/hardware/streamer.py b/hardware/streamer.py
nnew file mode 100644
iindex 0000000..4022443
---- /dev/null
++++ b/hardware/streamer.py
@@@ -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()
ddiff --git a/kiosk-companion/README.md b/kiosk-companion/README.md
nnew file mode 100644
iindex 0000000..e22e9dc
---- /dev/null
++++ b/kiosk-companion/README.md
@@@ -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.
ddiff --git a/kiosk-companion/background.js b/kiosk-companion/background.js
nnew file mode 100644
iindex 0000000..c2bbc2f
---- /dev/null
++++ b/kiosk-companion/background.js
@@@ -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: [] }));
++});
ddiff --git a/kiosk-companion/history-bridge.js b/kiosk-companion/history-bridge.js
nnew file mode 100644
iindex 0000000..6250aa0
---- /dev/null
++++ b/kiosk-companion/history-bridge.js
@@@ -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);
++ });
++ }
++});
ddiff --git a/kiosk-companion/manifest.json b/kiosk-companion/manifest.json
nnew file mode 100644
iindex 0000000..8c471c7
---- /dev/null
++++ b/kiosk-companion/manifest.json
@@@ -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"
++ }
++}
ddiff --git a/kiosk-companion/newtab.html b/kiosk-companion/newtab.html
nnew file mode 100644
iindex 0000000..8d99216
---- /dev/null
++++ b/kiosk-companion/newtab.html
@@@ -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>
ddiff --git a/kiosk-companion/newtab.js b/kiosk-companion/newtab.js
nnew file mode 100644
iindex 0000000..bcaac25
---- /dev/null
++++ b/kiosk-companion/newtab.js
@@@ -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
++ }
++})();