openvibers/openvibe.live Commits

Live: chat's robot commands drive the channel's OpenVibe.Bot robot; Live's robot controls are deleted; the phone chat sheet is full width again (#119)

OP OpenVibers committed 14 Oct 2026 · 95faead4b7 · 51 files, +329 −7408

* Live: chat's robot commands drive the channel's OpenVibe.Bot robot as the viewer; Live's own robot controls, control socket and ONVIF code are deleted

Plan T15's finish line: "Live has no robot code left". Robots are OpenVibe.Bot's. A bound channel already embeds its robot's panel (`LIVE_BOT_EMBED`, on in production). This change moves chat's robot commands to Bot and deletes Live's own controls.

**Why now**
- In the nginx logs kept, no hardware client ever connected to `/ws/control` (`mode=hardware`: 0).
- Channel pages still opened `/ws/control` hundreds of times a day for a feature with nothing on the other end.
- The three control profiles that existed were converted into Bot robots on 2026-10-07 (Bot#80).

**Chat's robot commands go to Bot as the viewer**
- `POST /internal/chat-effects/hardware` (OpenVibe.Chat's `!forward`, `!backward`, `!left`, `!right`, `!headup`/`!headdown`, `!liftup`/`!liftdown`, `!say`) now forwards to the channel's bound robot (`channels.bot_robot_id`).
- The call is Bot's `POST /api/v1/robots/:id/commands` with Live's service token (new `server/bot/bot-client.js`; audience `openvibe.bot`, grant `bot.robot.control`, Network#174, deployed). `X-OV-Subject` names the viewer: `from_subject` from Chat, else their linked subject.
- Bot's roles, turn queue, per-person cooldowns and e-stop decide.
- Motions are short pulses (`ms` 600, which Bot clamps to the profile's `max_command_ms`). `say` carries the text.
- Not signed in means "Sign in to drive the robot."; no robot bound means "This channel has no robot…". Bot's refusals come back as chat sentences, for example "It is not your turn at the robot yet.". A per-viewer cooldown of 250 ms stays.
- Chat's side (send the subject, show the sentence) is OpenVibe.Chat#84, to deploy after this.

**Deleted**
- `server/controls/` (the control WebSocket server, `/api/controls` and `/api/onvif`), `server/core/onvif-client.js`, `server/integrations/cozmo-presets.js` (dead), the `/ws/control` upgrade and init, the controls DB helpers, presets and API-key helpers, and the `control` API-token scope.
- Control profiles applied on go-live (RTMP, WHIP, the OpenRestream mirror and `POST /streams`), and `control_config_id` on slots and streams.
- Frontend: `public/js/onvif-dashboard.js`, the channel's controls panel and dock, the dashboard's controls and ONVIF cards, the broadcast page's and the workspace's control-profile pickers, the controls modals in `app.js`, and about 9 KB of their CSS. The docs page's Controls and Control WS tabs become one **Robots** tab that points at Bot.
- `scripts/cozmo-bridge.py` and `control-bridge-example-cozmo.py`. The Cozmo bridge lives in OpenVibe.Node.

**What viewers and streamers see instead**
- The dashboard's **Robot & API** tab: "Your robot" (bind a Bot robot, with what chat can do) and API tokens.
- The setup checklist's "Build viewer controls" is now "Connect a robot", which opens openvibe.bot.

**N-1, for one release** (they go with the tables' contract migration)
- `GET /api/streams/:id` keeps `controls: []`.
- `/js/controls.js` is a no-op stub, so a tab still on the previous client loads its channel feature.
- The N-1 harness skips the retired `/api/controls` and `/api/onvif` calls. The old client wrapped each one to fail quietly.

**Not in this PR:** dropping `control_configs`, `control_config_buttons`, `control_whitelist`, `stream_controls`, `camera_profiles`, `camera_presets`, `api_keys` and the `control_*` and `active_control_config_id` columns. That is a contract migration after the N-1 window (on or after 2026-10-21). A held contract would hold every later migration for 7 days.

**Tests**
- `chat-context` case 12 now runs against a stub Network and a stub Bot:
  - no robot is refused, and so is not signed in;
  - the right URL, token and subject reach Bot, with the pulse shape and `say` without `ms`;
  - the cooldown, Bot's refusal as a sentence, Bot being down, and an unknown command.
- `slot-control-profiles.test.js` is deleted with its feature.
- `authorization`, `security-idor`, `actor-limits`, `drill-mode`, `floating-promise-fixes` and `pip-camera-slot` drop their controls cases. `security-ssrf` lists the new outbound client.
- The full suite passes: 168/170 test files, with the 2 usual skips.

* Live: the phone's chat sheet is the full width again (a chat width saved on a wide screen made it 260px)

The side layout's saved width (--chat-w, channel-chat-resize.js) was set by a rule outside any media query whose
:not(:has(...)) outranked the portrait sheet's and the landscape overlay's rules, so a saved width made a phone's
bottom sheet that wide with the page showing beside it (reported 2026-10-14 on /@Aimzly/156, reproduced: 260px on a
412px phone). The rule now applies only at 1181px and wider; test/chat-sheet-width.test.js keeps each saved size in
its own layout.

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex 8871023b..4c81518f 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -62,9 +62,11 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
- **DM delivery:** Server verifies `dm.isParticipant()` before delivering — always maintain this check.
- **Schema:** every table is in `migrations/`; `ensureTables()` is gone. The frozen and Chat-owned tables are not in the PostgreSQL schema at all.
++**Robots:** OpenVibe.Bot owns them (plan T15). Live has no control socket, hardware API or ONVIF code: a channel bound to a robot (`channels.bot_robot_id`, `PUT /api/streams/channel/:username/bot`) embeds the robot's panel (`server/bot/embed.js`), and chat's `!forward`/`!say` … reach Live at `POST /internal/chat-effects/hardware`, which forwards them to Bot's `POST /api/v1/robots/:id/commands` as the viewer (`server/bot/bot-client.js`, Network grant `bot.robot.control`). The old controls tables stay unread until their contract migration drops them.
++
## WebSocket Endpoints
--`/ws/broadcast`, `/ws/control`, `/ws/call`, `/ws/robotstreamer-publish` — upgraded via the handler in `server/index.js` with origin checks and IP bans. `/ws/chat` is OpenVibe.Chat's (nginx routes it to 127.0.0.1:4400); Live runs no chat WebSocket server.
++`/ws/broadcast`, `/ws/call`, `/ws/robotstreamer-publish` — upgraded via the handler in `server/index.js` with origin checks and IP bans. `/ws/chat` is OpenVibe.Chat's (nginx routes it to 127.0.0.1:4400); Live runs no chat WebSocket server.
## Testing
ddiff --git a/README.md b/README.md
iindex 9f86d481..968413e8 100644
---- a/README.md
++++ b/README.md
@@@ -107,11 +107,14 @@ Live's own principal (`live`) holds grants to call:
API writes are limited per person (`server/net/actor-limits.js`, `LIVE_LIMITS_MINUTE` / `_HOUR`).
++**Robots are OpenVibe.Bot's (plan T15).** Live has no control socket, hardware API or ONVIF code. A bound channel's chat commands (`!forward`, `!backward`, `!left`, `!right`, `!headup`/`!headdown`, `!liftup`/`!liftdown`, `!say`) reach `POST /internal/chat-effects/hardware` from OpenVibe.Chat and go to Bot as the viewer who typed them (`server/bot/bot-client.js`): Bot's roles, queue, cooldowns and e-stop decide.
++
**Bot panel embed (`LIVE_BOT_EMBED`, off by default).** A channel can show its OpenVibe.Bot robot's embeddable panel (`<LIVE_BOT_URL>/panel/<robot id>/embed`). With `LIVE_BOT_EMBED=1` the channel owner (not mods, not staff) binds a robot with `PUT /api/streams/channel/:username/bot` `{ "robot_id": "rob_…" | null }` (null or `""` unbinds; stored in `channels.bot_robot_id`; limited per person as `live.bot.bind`), and `GET /api/streams/channel/:username` carries `bot_embed: { enabled: true, robot_id, url }`. Live never calls Bot to bind: the robot owner's `embed_public` toggle in Bot is the gate, so binding someone else's robot shows nothing until its owner makes it public. With the flag off the route answers 404 and the channel JSON has no `bot_embed` key (the stored id is kept). Turn it on in this order: Bot deployed with `GET /panel/:id/embed` and its `BOT_EMBED_ORIGINS` including this site's origins → set `LIVE_BOT_EMBED=1` → bind a channel. The owner binds from the dashboard's Controls tab (a "Bot robot id" field shown only with the flag on), and a bound channel's page shows the panel in a sandboxed, lazy iframe under the player (`public/js/bot-embed.js`, its own `botEmbed` feature, loaded only for a bound channel; the frame is removed when the route ends). Unbound channels and the flag off load nothing new.
| Variable | Default | Meaning |
| --- | --- | --- |
| `LIVE_BOT_EMBED` | off | `1` turns on the channel ↔ Bot robot binding and `bot_embed` on the channel JSON (`server/bot/`) |
++| `LIVE_BOT_INTERNAL_URL` | `http://127.0.0.1:4630` | where Live sends a bound channel's chat robot commands (Bot's `POST /api/v1/robots/:id/commands`, as the viewer, Network grant `bot.robot.control`) |
| `LIVE_BOT_URL` | `https://openvibe.bot` | Bot's origin for the embed URL; a bare https origin (http://localhost* outside production), else the default with a warning |
## Acceptance
ddiff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md
iindex cb7c4d88..100b109a 100644
---- a/SECURITY_AUDIT.md
++++ b/SECURITY_AUDIT.md
@@@ -62,7 +62,7 @@ test/authorization were also run against the previous code and failed there, cas
| Medium | Voice: 1:1 call channels were listed publicly and anyone (anonymous included) could walk into them; `call-user` had no rate limit or block check; anyone could answer any invite; a kicked user could rejoin at once; one host could fill the 8-seat Public channel; TURN was emitted without credentials on a box with no secret; static TURN credentials went to every visitor. | Private calls hidden from strangers and invite-only; call-user 6/min + block check; respond requires the invite; 60 s kick cooldown; 3 sockets per address, duplicate accounts replaced; TURN only with credentials; production coturn now runs `use-auth-secret` and every ICE list (voice and SFU, `server/net/turn.js`) carries a credential that expires after an hour — the old static pair no longer authenticates. | test/turn-credentials.test.js + relay probe on the host |
| Medium | Restreams outlived their source: the RobotStreamer passthrough kept its peer open (and restarted forever, without checking the stream was still live) after the broadcaster's socket timed out, OBS stopped, or an admin force-ended the stream — the robot stayed "live" on robotstreamer.com showing black. Force-end only flipped `is_live`. The dashboard's RS Stop button never reached the server. | Every stream-end path (grace timer, RTMP unpublish, WHIP end, stale sweep, admin force-end, own DELETE) stops the RS relay, ffmpeg restreams, chat relays and AI bots; the relay also ends itself on `producer-removed` / the stream row going not-live, backs off and gives up after 12 restarts; `POST /api/robotstreamer/restream/{start,stop}` (owner-only, live stream only); SRT passphrases are stored server-side and never returned (`has_srt_passphrase`), and redacted from ffmpeg command lines in logs. | test/rs-passthrough-lifecycle.test.js, test/restream-manager-lifecycle.test.js |
| Low | Slot `control_config_id` accepted other users' profiles; restream viewer counts writable for any destination. | Ownership checks. | manual |
--| Low | ONVIF discovery (LAN scan with default-password probing) available to every user. | Admin only, bounded timeout. | manual |
++| Low | ONVIF discovery (LAN scan with default-password probing) available to every user. | Removed with Live's robot controls (2026-10-14, plan T15); robots and cameras are OpenVibe.Bot's. | manual |
| Low | RobotStreamer API calls carrying the user's RS token disabled TLS verification. | Verification on for api.robotstreamer.com (valid certificate). | manual |
| Low | PowerChat test tip broadcast a fake donation to global chat. | Channel room only. | manual |
| Stability | Timer callbacks (RTMP/WHIP heartbeats, renewal sweep) could throw → `uncaughtException` → process exit → every live stream dropped. | Contained. | manual |
ddiff --git a/control-bridge-example-cozmo.py b/control-bridge-example-cozmo.py
ddeleted file mode 100644
iindex a0f06627..00000000
---- a/control-bridge-example-cozmo.py
++++ /dev/null
@@@ -1,198 +0,0 @@
--#!/usr/bin/env python3
--"""
--OpenVibe.Live — Generic Hardware Bridge
--Auto-generated for: goosely
--Control Profile: Cozmo
--
--Connects to OpenVibe.Live and receives control commands from viewers.
--Edit the handle_command() function to control your hardware.
--
--Requirements:
-- pip install websocket-client
--
--Usage:
-- python3 generic-bridge-cozmo.py
--"""
--import json
--import time
--import threading
--
--try:
-- import websocket
--except ImportError:
-- print("Missing websocket-client: pip install websocket-client")
-- exit(1)
--
--# ── Connection Settings ──────────────────────────────────────
--WS_URL = "wss://openvibe.live/ws/control?mode=hardware&stream_key=YOUR_STREAM_KEY"
--RECONNECT_DELAY = 5
--
--# ── Profile Buttons ──────────────────────────────────────────
--# These are the buttons configured in your "Cozmo" profile.
--# Each entry has: command, label, type, key_binding
--BUTTONS = [
-- {
-- "command": "forward",
-- "label": "Forward",
-- "type": "keyboard",
-- "key_binding": "w"
-- },
-- {
-- "command": "back",
-- "label": "Back",
-- "type": "keyboard",
-- "key_binding": "s"
-- },
-- {
-- "command": "left",
-- "label": "Left",
-- "type": "keyboard",
-- "key_binding": "a"
-- },
-- {
-- "command": "right",
-- "label": "Right",
-- "type": "keyboard",
-- "key_binding": "d"
-- },
-- {
-- "command": "look_up",
-- "label": "Look Up",
-- "type": "keyboard",
-- "key_binding": "e"
-- },
-- {
-- "command": "look_down",
-- "label": "Look Down",
-- "type": "keyboard",
-- "key_binding": "q"
-- },
-- {
-- "command": "lift_up",
-- "label": "Lift Up",
-- "type": "keyboard",
-- "key_binding": "r"
-- },
-- {
-- "command": "lift_down",
-- "label": "Lift Down",
-- "type": "keyboard",
-- "key_binding": "f"
-- },
-- {
-- "command": "light",
-- "label": "Toggle Light",
-- "type": "keyboard",
-- "key_binding": "c"
-- }
-- ]
--
--# ══════════════════════════════════════════════════════════════
--# COMMAND HANDLERS — Edit these to control your hardware!
--# ══════════════════════════════════════════════════════════════
--
--def handle_command(command, from_user):
-- """Called when a viewer presses a button (one-shot)."""
-- print(f"[Command] {from_user} -> {command}")
-- # ── Add your hardware logic here ──
-- # Examples:
-- # if command == "forward":
-- # my_robot.move_forward()
-- # elif command == "fire":
-- # GPIO.output(RELAY_PIN, GPIO.HIGH)
-- # forward: Forward (keyboard)
-- # back: Back (keyboard)
-- # left: Left (keyboard)
-- # right: Right (keyboard)
-- # look_up: Look Up (keyboard)
-- # look_down: Look Down (keyboard)
-- # lift_up: Lift Up (keyboard)
-- # lift_down: Lift Down (keyboard)
-- # light: Toggle Light (keyboard)
--
--
--def handle_key_down(command, from_user):
-- """Called when a viewer holds a keyboard-type key down."""
-- print(f"[Key Down] {from_user} -> {command}")
-- # Add continuous action start here
--
--
--def handle_key_up(command, from_user):
-- """Called when a viewer releases a keyboard-type key."""
-- print(f"[Key Up] {from_user} -> {command}")
-- # Add continuous action stop here
--
--
--def handle_video_click(x, y, from_user):
-- """Called when a viewer clicks on the video (if enabled)."""
-- print(f"[Click] {from_user} -> ({x:.2f}, {y:.2f})")
-- # x and y are 0.0-1.0 coordinates on the video
--
--
--# ══════════════════════════════════════════════════════════════
--# BRIDGE (no need to edit below unless customizing)
--# ══════════════════════════════════════════════════════════════
--
--class HardwareBridge:
-- def __init__(self):
-- self.running = True
-- self.held_keys = set()
--
-- def on_message(self, ws, message):
-- try:
-- msg = json.loads(message)
-- msg_type = msg.get("type", "")
-- cmd = msg.get("command", "")
-- user = msg.get("from_user", "?")
--
-- if msg_type == "command":
-- handle_command(cmd, user)
-- elif msg_type == "key_down":
-- self.held_keys.add(cmd)
-- handle_key_down(cmd, user)
-- elif msg_type == "key_up":
-- self.held_keys.discard(cmd)
-- handle_key_up(cmd, user)
-- elif msg_type == "video_click":
-- handle_video_click(msg.get("x", 0.5), msg.get("y", 0.5), user)
-- except Exception as e:
-- print(f"[Error] {e}")
--
-- def on_open(self, ws):
-- print("[Connected] Listening for commands...")
-- print(f"[Profile] {len(BUTTONS)} buttons loaded from 'Cozmo'")
-- ws.send(json.dumps({"type": "status", "connected": True}))
--
-- def on_close(self, ws, code, reason):
-- print(f"[Disconnected] code={code}")
-- self.held_keys.clear()
--
-- def on_error(self, ws, error):
-- print(f"[WS Error] {error}")
--
-- def run(self):
-- print(f"Connecting to OpenVibe.Live as hardware bridge...")
-- print(f"Profile: Cozmo ({len(BUTTONS)} buttons)")
-- print(f"Commands: {', '.join(b['command'] for b in BUTTONS)}")
-- print()
-- while self.running:
-- try:
-- ws = websocket.WebSocketApp(WS_URL,
-- on_message=self.on_message,
-- on_open=self.on_open,
-- on_close=self.on_close,
-- on_error=self.on_error)
-- ws.run_forever(ping_interval=30)
-- except Exception as e:
-- print(f"[Error] {e}")
-- if self.running:
-- print(f"Reconnecting in {RECONNECT_DELAY}s...")
-- time.sleep(RECONNECT_DELAY)
--
--
--if __name__ == "__main__":
-- bridge = HardwareBridge()
-- try:
-- bridge.run()
-- except KeyboardInterrupt:
-- print("\nBye!")
\\ No newline at end of file
ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex 3c1c9d53..48092778 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -6,7 +6,7 @@ OpenVibe.Live is a self-hosted live streaming platform, one of the OpenVibe serv
- **Express.js** (CommonJS) — HTTP API, HTML documents, static assets
- **PostgreSQL** (`openvibe-sdk/db`, through PgBouncer; PGlite in development and tests) — users, channels, streams, tokens, AI state, request analytics (`ov_live`, since 2026-10-08)
--- **WebSocket** (`ws`) — chat, broadcast signaling, calls, hardware controls
++- **WebSocket** (`ws`) — broadcast signaling (chat and calls are OpenVibe.Chat's; robots are OpenVibe.Bot's)
- **mediasoup** — WebRTC SFU; **werift** — WHIP ingest; **Node-Media-Server** — RTMP ingest
- **FFmpeg** — recording hand-off, restreams, thumbnails, audio capture for transcription
- **OpenVibe.Network** — SSO/OAuth2 (RS256 JWTs), OpenCoins wallet, shared browser modules (`openvibe-shared`, a pinned OpenVibe.Shared release served at `/shared/*` from `node_modules`)
ddiff --git a/docs/dashboard.md b/docs/dashboard.md
iindex 93bbcb9f..e2d22f9b 100644
---- a/docs/dashboard.md
++++ b/docs/dashboard.md
@@@ -10,7 +10,7 @@ Your RTMP stream key is shown (hidden by default) for use with OBS, Streamlabs,
Create long-lived API tokens for bots and integrations. Tokens authenticate via the `Authorization: Bearer hbt_...` header for REST API and chat WebSocket connections.
--Available scopes: `chat`, `read`, `stream`, `control`.
++Available scopes: `chat`, `read`, `stream`, `vibe_coding_publish`.
See [API Tokens](api-tokens.md) for full details.
@@@ -48,9 +48,9 @@ On the **Home** tab, *Let OpenVibe's AI make Moments from my streams* (on by def
- **Coin Rewards** — Create custom rewards viewers can redeem with Nickels (TTS, sound effects, chat highlights, etc.)
- **Redemption Queue** — Fulfill or reject viewer reward redemptions.
--## Camera Controls (ONVIF PTZ)
++## Your robot
--Add ONVIF-compatible cameras for viewer-controlled pan/tilt/zoom. Supports discovery and manual configuration.
++On the **Robot & API** tab, put the id of a robot you paired on [OpenVibe.Bot](https://openvibe.bot/robots/new) (a Raspberry Pi car, a Cozmo, a phone or an ONVIF pan-tilt camera). Its live panel shows under your stream, and chat drives it with `!forward`, `!backward`, `!left`, `!right`, `!headup`, `!liftup` and `!say`, each passed to Bot as the viewer who typed it; who may drive is the robot's own setting on Bot.
## Donation Goals
ddiff --git a/public/css/critical-selectors.json b/public/css/critical-selectors.json
iindex 7b898437..b1928dca 100644
---- a/public/css/critical-selectors.json
++++ b/public/css/critical-selectors.json
@@@ -177,7 +177,6 @@
".comments-header h3",
".container",
".content-container",
-- ".controls-dock",
".cta-ambient",
".cta-btn-glow",
".cta-embers",
ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex e7211f54..a39f0064 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -2,12 +2,7 @@
download them. Loaded with the feature (public/features.json), directly after style.css in the cascade.
Split by class/id ownership with cascade-order checks; edit freely. */
/* ── from style.css ── */
--.controls-row {
-- display: flex;
-- flex-direction: row;
-- gap: 8px;
-- flex-wrap: wrap;
--}
++
#ch-pastes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr));
@@@ -572,46 +567,6 @@
border-radius: var(--radius); border: 1px solid var(--border);
}
.goal-label { font-weight: 600; margin-bottom: 6px; color: var(--accent); }
--.controls-panel {
-- margin: 0; padding: 8px 14px;
-- background: var(--bg-card);
-- border-radius: 0;
-- border-top: none;
-- border-bottom: 1px solid var(--border);
--}
--.controls-header {
-- display: flex; align-items: center; gap: 8px;
-- margin-bottom: 6px;
--}
--.controls-grid {
-- display: flex; gap: 8px; flex-wrap: wrap;
--}
--.control-btn.on-cooldown { opacity: 0.5; pointer-events: none; }
--.click-ripple-remote {
-- border-color: rgba(255,255,255,0.5);
--}
--.onvif-widget {
-- margin: 12px 0; padding: 12px;
-- background: var(--bg-secondary);
-- border: 1px solid var(--border);
-- border-radius: var(--radius);
-- transition: all 0.2s;
--}
--
--.onvif-header {
-- display: flex; justify-content: space-between; align-items: center;
-- margin-bottom: 12px;
-- padding-bottom: 8px; border-bottom: 1px solid var(--border);
--}
--
--.onvif-status {
-- font-size: 1rem; font-weight: bold;
-- animation: pulse 2s infinite;
--}
--
--.onvif-controls {
-- display: flex; flex-direction: column; gap: 10px;
--}
.dpad-container {
display: grid; grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
@@@ -717,7 +672,6 @@
.ch-offline-header { flex-wrap: wrap; gap: 12px; padding: 16px; }
.ch-avatar-lg { width: 56px; height: 56px; font-size: 1.4rem; }
.viewer-badge, .uptime-badge { font-size: 0.68rem; padding: 2px 5px; }
-- .controls-panel { padding: 8px; }
#video-element, #video-canvas { width: 100%; }
.channel-below-fold { padding: 0 8px 24px; }
.channel-below-fold .section-header { padding: 12px 0; }
@@@ -763,7 +717,6 @@
.ch-info-bar { padding: 4px 8px; gap: 6px; }
.ch-info-bar .ch-avatar { width: 28px; height: 28px; font-size: 0.8rem; }
.ch-stream-title { display: none; }
-- .controls-panel { padding: 4px 8px; margin: 8px 0; }
}
.live-tabs-scroll {
display: flex; gap: 0; overflow-x: auto; align-items: stretch;
@@@ -1280,49 +1233,6 @@
.ai-tl-subtabs { display: flex; gap: 6px; margin: 14px 0 16px; border-bottom: 1px solid var(--border, rgba(255,255,255,0.08)); }
.ai-tl-subtab { font-size: 0.9rem; font-weight: 700; color: var(--text-muted); background: none; border: none; border-bottom: 2px solid transparent; padding: 8px 14px; cursor: pointer; display: inline-flex; align-items: center; gap: 7px; margin-bottom: -1px; transition: color .15s; }
.ai-tl-pane--hidden { display: none; }
--.controls-header { justify-content: space-between; }
--.controls-status { display: inline-flex; align-items: center; gap: 6px; font-size: 0.72rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
--.controls-status-txt { color: var(--text-muted); }
--.controls-dock-btn { flex-shrink: 0; }
--.controls-dock-ph { color: var(--text-muted); font-size: 0.9rem; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 12px 2px; }
--.controls-dock-bar {
-- pointer-events: auto;
-- display: flex; align-items: center; gap: 8px;
-- max-width: 100%; width: max-content;
-- padding: 7px 9px;
-- background: color-mix(in srgb, var(--bg-card) 92%, transparent);
-- backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
-- border: 1px solid var(--border); border-radius: 14px;
-- box-shadow: 0 14px 38px rgba(0,0,0,.55);
--}
--.controls-dock-grip { flex-shrink: 0; color: var(--accent); font-size: 0.95rem; padding: 0 2px; }
--.controls-dock-body::-webkit-scrollbar { height: 5px; }
--.controls-dock-close { flex-shrink: 0; background: var(--bg-hover); border: 1px solid var(--border); color: var(--text-muted); width: 30px; height: 30px; border-radius: 9px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all .15s; }
--.controls-dock-close:hover { color: var(--text-primary); border-color: var(--accent); }
--.controls-dock .controls-grid { flex-wrap: nowrap; gap: 6px; }
--.controls-dock .control-btn { position:relative; flex-direction: row; min-width: 0; padding: 7px 11px; font-size: 0.78rem; gap: 6px; white-space: nowrap; }
--.controls-dock .control-btn i { font-size: 0.98rem; }
--@media (max-width: 560px) {
-- .controls-dock .control-btn > i { display: none; }
-- .controls-dock .control-btn { padding: 9px 12px; }
-- .controls-dock-bar { gap: 6px; padding: 6px 8px; }
--}
--@media (prefers-reduced-motion: reduce) { .controls-dock { animation: none; } }
--.ch-controls-section .controls-panel { border-top: 1px solid var(--border); }
--.controls-header-btns { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; }
--.ch-controls-toggle {
-- display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0;
-- background: color-mix(in srgb, var(--accent) 16%, transparent);
-- border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
-- color: var(--accent); cursor: pointer;
-- padding: 5px 10px; border-radius: 8px; font-size: 0.85rem; line-height: 1;
-- transition: all 0.15s;
--}
--.ch-controls-toggle:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 26%, transparent); color: var(--accent-light); }
--.ch-controls-toggle-caret { font-size: 0.64rem; }
--.ch-info-bar-right .ch-controls-toggle { order: -1; }
--.controls-dock-btn { padding: 5px 9px; flex-shrink: 0; }
--body.controls-docked .messenger-toggle { z-index: 1100; }
.ov-pip:hover { border-color: rgba(255, 255, 255, .38); }
.ov-pip.is-dragging { cursor: grabbing; box-shadow: 0 10px 30px rgba(0, 0, 0, .7); }
.ov-pip.is-dragging, .ov-pip.is-resizing { user-select: none; }
@@@ -1492,8 +1402,11 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
/* ── Resizing the chat (public/js/channel-chat-resize.js) ─────
One grip per chat: on its left edge beside the stream (width), on its top edge as a bottom sheet (height). */
--/* Not for vertical (9:16) streams: their wider chat column (clamp(340px, 35vw, 500px), above) must win. */
--#page-channel .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar { width: var(--chat-w); }
++/* The column's saved width is for the side layout only (1181px and wider). Outside a media query it outranked the
++ overlay and sheet rules below (:has() adds the specificity of its argument), so a width saved on a wide screen made
++ a phone's bottom sheet 260px wide with the page showing beside it (2026-10-14).
++ Not for vertical (9:16) streams: their wider chat column (clamp(340px, 35vw, 500px), above) must win. */
++@media (min-width: 1181px) { #page-channel .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar { width: var(--chat-w); } }
@media (max-width: 1180px) and (orientation: landscape) { #page-channel .stream-layout > .chat-sidebar { width: var(--chat-ow, min(420px, 44vw)); } }
@media (max-width: 1180px) and (orientation: portrait) { #page-channel .stream-layout > .chat-sidebar { width: 100%; height: var(--chat-sheet-h, 55dvh); max-height: 92dvh; } }
#page-channel.chat-resizing, #page-channel.chat-resizing * { user-select: none; }
ddiff --git a/public/css/style.css b/public/css/style.css
iindex fe5528ca..4bf9e7a7 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -3121,37 +3121,8 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
text-align: right; margin-top: 4px;
}
--/* Controls Panel — flat action bar directly below the video */
--.controls-header h3 {
-- font-size: 0.78rem; color: var(--text-muted);
-- margin-bottom: 0; font-weight: 600;
-- text-transform: uppercase; letter-spacing: 0.05em;
-- display: flex; align-items: center; gap: 6px;
--}
--.control-btn {
-- position: relative; /* anchor the absolutely-positioned .control-keybind badge */
-- display: flex; flex-direction: column; align-items: center; gap: 2px;
-- padding: 10px 14px; background: var(--bg-hover);
-- border: 1px solid var(--border); border-radius: var(--radius);
-- color: var(--text-primary); font-size: 0.8rem;
-- min-width: 60px; cursor: pointer;
-- transition: all 0.15s;
--}
--.control-btn:hover { border-color: var(--accent); color: var(--accent); }
--.control-btn:active { background: var(--accent); color: #000; }
--.control-btn i { font-size: 1.2rem; }
--
/* Keyboard hold button */
--.control-btn-keyboard { position: relative; }
--.control-btn-keyboard.key-held {
-- background: var(--accent); color: #000;
-- box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 60%, transparent);
--}
--.control-btn-keyboard.remote-held {
-- border-color: var(--accent);
-- box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 40%, transparent);
-- animation: control-pulse 0.8s infinite;
--}
++
@keyframes control-pulse {
0%, 100% { box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 40%, transparent); }
50% { box-shadow: 0 0 16px color-mix(in srgb, var(--accent) 70%, transparent); }
@@@ -3175,31 +3146,6 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
}
/* Let the native video controls through */
.video-overlay { z-index: 10; }
--
--.click-ripple {
-- position: absolute; width: 24px; height: 24px;
-- margin-left: -12px; margin-top: -12px;
-- border-radius: 50%;
-- border: 2px solid var(--accent);
-- animation: click-ripple-anim 0.6s ease-out forwards;
-- pointer-events: none;
--}
--@keyframes click-ripple-anim {
-- 0% { transform: scale(0.5); opacity: 1; }
-- 100% { transform: scale(3); opacity: 0; }
--}
--
--/* ONVIF Camera Controls */
--
--.onvif-header h3 {
-- font-size: 0.95rem; color: var(--accent); margin: 0;
-- display: flex; align-items: center; gap: 6px;
--}
--
--.onvif-status.connected { color: #0f0; }
--.onvif-status.unreachable { color: #f00; animation: none; }
--.onvif-status.unknown { color: #888; animation: none; }
--
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
@@@ -5003,9 +4949,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
.video-controls { padding: 4px 8px; gap: 3px; }
.vc-btn { min-width: 28px; min-height: 28px; padding: 4px 5px; font-size: 0.9rem; }
-- /* Controls panel compact */
-- .controls-header h3 { font-size: 0.9rem; }
--
/* Mobile chat is open — no scrim over video, keep it visible */
/* Offline chat inherits the .chat-sidebar mobile bottom-sheet behavior (same as live). */
@@@ -5067,10 +5010,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
.mobile-chat-toggle { width: 48px; height: 48px; font-size: 1.1rem; bottom: 16px; right: 12px; }
-- /* Control buttons compact on small phones */
-- .control-btn { min-width: 50px; padding: 8px 10px; font-size: 0.75rem; }
-- .control-btn i { font-size: 1rem; }
--
/* Context menu fits small screens */
.chat-context-menu {
min-width: 0; width: calc(100vw - 16px); max-width: 300px;
@@@ -5136,9 +5075,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
.hero h1 { font-size: 1.3rem; }
.hero p { font-size: 0.85rem; }
.ch-info-bar-top h2 { font-size: 0.82rem; }
-- .controls-header h3 { font-size: 0.82rem; }
-- .control-btn { min-width: 44px; padding: 6px 8px; font-size: 0.7rem; }
-- .control-btn i { font-size: 0.9rem; }
.btn-follow { font-size: 0.72rem; padding: 3px 8px; }
.dash-card h3 { font-size: 0.9rem; }
.modal { width: 98%; padding: 16px; border-radius: 14px; }
@@@ -10108,33 +10044,7 @@ body.theme-light .btn-outline:hover {
.ai-tl-subtab.active { color: var(--accent, #8b5cf6); border-bottom-color: var(--accent, #8b5cf6); }
/* Keep legacy .ch-panel-actions working (still used by the Controls header). */
.ch-panel-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; }
--
--/* Controls tab header (compact status + dock button) */
--.controls-dock-ph i { color: var(--accent); }
--
--/* ═══════════════ Docked controls (fixed compact bottom bar) ═══════════════ */
--.controls-dock {
-- position: fixed; left: 0; right: 0; bottom: 0; z-index: 1200;
-- padding: 0 8px calc(8px + env(safe-area-inset-bottom, 0px));
-- display: flex; justify-content: center; pointer-events: none;
-- animation: dockUp .2s ease;
--}
@keyframes dockUp { from { transform: translateY(110%); opacity: 0; } to { transform: none; opacity: 1; } }
--.controls-dock-body { display: flex; align-items: center; min-width: 0; overflow-x: auto; scrollbar-width: thin; }
--/* Compact, single-row buttons inside the dock */
--@media (max-width: 560px) {
-- /* On phones show the LABEL and hide the icon — the label tells you what the button
-- does, which matters more than the glyph (keep the keybind badge). */
--}
--
--/* ═══════════════ Controls section under the player + collapse toggle ═══════════════ */
--/* Small collapse/expand toggle in the streamer info bar (top-right) */
--/* Header holds the hide-toggle + dock button, grouped at the right. */
--/* Hide/expand toggle — visually DISTINCT from the dock button: a soft accent-tinted
-- pill (vs the dock button's plain outline), so you can tell them apart at a glance. */
--/* When it lives in the info bar (collapsed), keep it snug next to the follower count. */
--/* Dock button: plain outline icon (distinct from the tinted toggle). */
--.controls-dock-btn i { font-size: 0.9rem; }
/* Keep the DMs FAB BEHIND the docked controls bar so it doesn't cover the buttons */
/* ── Rendered markdown (GitHub-README style) ─────────────────────────────
@@@ -10287,10 +10197,6 @@ body.theme-light .btn-outline:hover {
backdrop-filter: blur(3px);
}
--@media (max-width: 640px) {
-- /* Fingers need a bigger target than a hover-revealed 18px corner. */
--}
--
/* ── AI Chat Viewers v3 panel ─────────────────────────────── */
.aiv-topline { display:flex; align-items:center; gap:16px; flex-wrap:wrap; margin-bottom:8px; }
.aiv-status { font-size:0.75rem; font-weight:700; padding:3px 10px; border-radius:999px; background:var(--bg-tertiary); color:var(--text-secondary); }
@@@ -11091,21 +10997,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.section-header h2 > i:first-child { width: 26px; height: 26px; border-radius: 9px; font-size: 0.74rem; }
}
--
--/* The philosophy + legal row sits between the hero buttons and the stat board now, so it needs
-- to read as a quiet footnote rather than a third row of buttons competing with them. */
--
--/* ── Home rails on a phone ───────────────────────────────────────────────────
-- One 300px-minimum card per row meant four VODs, four clips and four pastes each took a full
-- screen of scrolling to get past, which read as spam rather than a taste of what's there. The
-- secondary rails go two-up on a phone with a trimmed-down card: the AI summary and the relative
-- timestamp are detail for a browse page, not for a glance on the way down the front page.
--
-- Live Now keeps one card per row. It is the thing people came for and it should be big. */
--@media (max-width: 640px) {
-- /* The AI blurb is three lines of text per card; at two-up it buries the thumbnails. */
--}
--
/* ── Load more ───────────────────────────────────────────────────────────────
A Prev/Next pager with a page counter belongs on a browse page where someone is hunting for a
particular thing. On a front-page rail it is machinery nobody wants to operate. */
@@@ -11359,16 +11250,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
/* A sheen that crosses a card once on hover — transform only, clipped to the card. */
@keyframes ctaCardSheen { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
--/* Money section: a sentence and three short points, not another wall of feature cards. The lede
-- was the one centred paragraph in a section that is otherwise left-aligned. */
--
--/* Cards arrive when the section is opened. They used to play a rise animation at page load,
-- while the body was collapsed to zero height — an animation nobody could see. */
--
--@media (max-width: 640px) {
-- /* Methods and features become compact rows: icon beside the text. */
--}
--
/* ═══════════════════════════════════════════════════════════════
ddiff --git a/public/features.json b/public/features.json
iindex fcc88437..8aac6c54 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -64,15 +64,6 @@
"/js/stream-pip.js"
]
},
-- "controls": {
-- "js": [
-- "/js/controls.js"
-- ],
-- "stubs": [
-- "toggleControlsSection",
-- "toggleControlsDock"
-- ]
-- },
"context": {
"css": [
"/css/context-cards.css"
@@@ -110,7 +101,6 @@
],
"deps": [
"player",
-- "controls",
"context",
"chatExtras",
"uploads"
@@@ -247,13 +237,10 @@
"/js/settings.js",
"/js/dashboard-moderation.js",
"/js/powerchat.js",
-- "/js/onvif-dashboard.js",
"/js/dashboard.js"
],
"stubs": [
-- "regenerateDashStreamKey",
-- "doCreateConfig",
-- "doAddConfigButton"
++ "regenerateDashStreamKey"
]
},
"arena": {
ddiff --git a/public/fragments/broadcast.html b/public/fragments/broadcast.html
iindex 0e410d68..67bbf828 100644
---- a/public/fragments/broadcast.html
++++ b/public/fragments/broadcast.html
@@@ -632,18 +632,6 @@ WantedBy=multi-user.target</code>
</p>
</div>
-- <!-- Interactive Controls Status -->
-- <div class="bc-settings-group">
-- <div class="bc-settings-title"><i class="fa-solid fa-gamepad"></i> Interactive Controls</div>
-- <div id="bc-ext-controls-status" style="margin-bottom:8px"><p class="muted" style="font-size:0.85rem">Loading...</p></div>
-- <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
-- <select id="bc-ext-live-config-select" class="form-input form-input-sm" style="flex:1;min-width:120px">
-- <option value="">None (no controls)</option>
-- </select>
-- <button class="btn btn-small btn-outline" onclick="applyLiveControlProfile()"><i class="fa-solid fa-check"></i> Apply</button>
-- </div>
-- <p class="bc-call-explainer" style="margin-top:6px"><i class="fa-solid fa-circle-info"></i> Change profile without ending the stream. Viewers see the new buttons immediately.</p>
-- </div>
</div>
<!-- ═══ WebRTC Browser Broadcast (live controls) ═══ -->
@@@ -912,19 +900,6 @@ WantedBy=multi-user.target</code>
</div>
</div>
-- <!-- Interactive Controls Profile (live) -->
-- <div class="bc-settings-group" id="bc-browser-controls-group">
-- <div class="bc-settings-title"><i class="fa-solid fa-gamepad"></i> Interactive Controls</div>
-- <div id="bc-browser-controls-status" style="margin-bottom:8px"><p class="muted" style="font-size:0.85rem">Loading…</p></div>
-- <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
-- <select id="bc-live-config-select" class="form-input form-input-sm" style="flex:1;min-width:120px">
-- <option value="">None (no controls)</option>
-- </select>
-- <button class="btn btn-small btn-outline" onclick="applyLiveControlProfile()"><i class="fa-solid fa-check"></i> Apply</button>
-- </div>
-- <p class="bc-call-explainer" style="margin-top:6px"><i class="fa-solid fa-circle-info"></i> Change profile without ending the stream. Viewers see the new buttons immediately.</p>
-- </div>
--
<!-- Restreams — configured in Broadcast Settings tab -->
<div class="bc-settings-group">
<div class="bc-settings-title"><i class="fa-solid fa-tower-broadcast"></i> Restreams</div>
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex 1db57827..0cff478a 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -94,19 +94,6 @@
</div>
</div>
</div>
-- <!-- Interactive Controls — right under the player when the stream has them; collapsible via the toggle in the info bar. -->
-- <div class="ch-controls-section" id="ch-controls-section" style="display:none">
-- <div class="controls-panel" id="controls-panel">
-- <div class="controls-header">
-- <span class="controls-status" id="controls-status"></span>
-- <div class="controls-header-btns">
-- <button class="ch-controls-toggle" id="ch-controls-toggle" onclick="toggleControlsSection()" title="Hide controls" aria-label="Hide or show controls"><i class="fa-solid fa-chevron-up"></i></button>
-- <button class="btn btn-sm btn-outline controls-dock-btn" id="controls-dock-btn" onclick="toggleControlsDock()" title="Dock controls to the bottom of the screen so you can use them while watching &amp; chatting" aria-label="Dock controls to bottom"><i class="fa-solid fa-arrow-down"></i></button>
-- </div>
-- </div>
-- <div class="controls-grid" id="controls-grid"></div>
-- </div>
-- </div>
<!-- OpenVibe.Bot robot panel for a channel bound to a robot (LIVE_BOT_EMBED; filled by public/js/bot-embed.js) -->
<section id="ch-bot-section" aria-labelledby="ch-bot-heading" hidden>
<h3 id="ch-bot-heading">Robot</h3>
@@@ -461,14 +448,4 @@
</div>
</div>
-- <!-- Compact docked controls: a fixed bottom bar so viewers can drive controls
-- while still watching + chatting. Lives outside the tab panels so it persists
-- across tabs; hidden until toggled from the Controls tab. -->
-- <div class="controls-dock" id="controls-dock" style="display:none" role="region" aria-label="Docked stream controls">
-- <div class="controls-dock-bar">
-- <span class="controls-dock-grip" title="Stream controls"><i class="fa-solid fa-gamepad"></i></span>
-- <div class="controls-dock-body" id="controls-dock-body"></div>
-- <button class="controls-dock-close" onclick="toggleControlsDock()" title="Close docked controls" aria-label="Close docked controls"><i class="fa-solid fa-xmark"></i></button>
-- </div>
-- </div>
ddiff --git a/public/fragments/dashboard.html b/public/fragments/dashboard.html
iindex 28112d3d..0d89dade 100644
---- a/public/fragments/dashboard.html
++++ b/public/fragments/dashboard.html
@@@ -9,7 +9,7 @@
<button class="ch-tab" role="tab" aria-selected="false" data-dtab="content" onclick="switchDashTab('content', this)"><i class="fa-solid fa-photo-film"></i> Content</button>
<button class="ch-tab" role="tab" aria-selected="false" data-dtab="moderation" onclick="switchDashTab('moderation', this)"><i class="fa-solid fa-shield-halved"></i> Moderation</button>
<button class="ch-tab" role="tab" aria-selected="false" data-dtab="chatai" onclick="switchDashTab('chatai', this)"><i class="fa-solid fa-comments"></i> Chat</button>
-- <button class="ch-tab" role="tab" aria-selected="false" data-dtab="controls" onclick="switchDashTab('controls', this)"><i class="fa-solid fa-gamepad"></i> Controls</button>
++ <button class="ch-tab" role="tab" aria-selected="false" data-dtab="controls" onclick="switchDashTab('controls', this)"><i class="fa-solid fa-robot"></i> Robot &amp; API</button>
<button class="ch-tab" role="tab" aria-selected="false" data-dtab="money" onclick="switchDashTab('money', this)"><i class="fa-solid fa-hand-holding-dollar"></i> Monetize</button>
<button class="ch-tab" role="tab" aria-selected="false" data-dtab="points" onclick="switchDashTab('points', this)"><i class="fa-solid fa-star"></i> Points</button>
</div>
@@@ -54,116 +54,6 @@
<p class="muted" style="font-size:0.82rem;margin-bottom:0">Per-slot overlay URLs are available in each stream slot's Settings tab on the <a href="/broadcast">Broadcast page</a>.<br>Browser Source size: 800×600 is a good starting point. Background stays transparent.</p>
</div>
-- <!-- Controls Manager -->
-- <div class="dash-card" id="dash-card-controls">
-- <h3><i class="fa-solid fa-gamepad"></i> Interactive Controls</h3>
--
-- <!-- Control Config Profiles -->
-- <div style="margin-bottom:12px">
-- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
-- <label style="font-weight:600;font-size:0.9rem;color:var(--text-secondary)"><i class="fa-solid fa-sliders"></i> Control Profiles</label>
-- <div style="display:flex;gap:6px">
-- <button class="btn btn-small btn-outline" onclick="showModal('create-config')"><i class="fa-solid fa-plus"></i> New Profile</button>
-- <button class="btn btn-small btn-outline" onclick="openClonePresetModal()"><i class="fa-solid fa-clone"></i> Clone Preset</button>
-- </div>
-- <!-- Clone Preset Config Modal -->
-- <div id="clone-preset-config-modal" class="modal-overlay" style="display:none" onclick="if(event.target===this)closeModal()">
-- <div class="modal" onclick="event.stopPropagation()">
-- <button class="modal-close" onclick="closeModal()">&times;</button>
-- <div class="modal-header">
-- <h3><i class="fa-solid fa-clone"></i> Clone Preset Control Profile</h3>
-- </div>
-- <div class="modal-body">
-- <p class="muted" style="margin-bottom:10px">Choose a preset profile to clone. You can edit the name and description before creating your own copy.</p>
-- <div class="form-group">
-- <label>Preset</label>
-- <select id="clone-preset-select" class="form-input">
-- <option value="">Loading...</option>
-- </select>
-- </div>
-- <div class="form-group">
-- <label>Name</label>
-- <input type="text" id="clone-preset-name" class="form-input" maxlength="50" placeholder="Profile name">
-- </div>
-- <div class="form-group">
-- <label>Description</label>
-- <input type="text" id="clone-preset-desc" class="form-input" maxlength="200" placeholder="Profile description (optional)">
-- </div>
-- </div>
-- <div class="modal-footer">
-- <button class="btn btn-outline" onclick="closeModal()">Cancel</button>
-- <button class="btn btn-primary" onclick="doClonePresetConfig()"><i class="fa-solid fa-clone"></i> Clone</button>
-- </div>
-- </div>
-- </div>
-- </div>
-- <div id="dash-config-list"></div>
-- <div id="dash-config-editor" style="display:none">
-- <div style="display:flex;align-items:center;justify-content:space-between;margin:12px 0 8px">
-- <h4 id="dash-config-editor-title" style="margin:0;font-size:0.95rem;color:var(--accent)">Editing: Config</h4>
-- <div style="display:flex;gap:6px">
-- <button class="btn btn-small btn-outline" onclick="showModal('add-config-button')"><i class="fa-solid fa-plus"></i> Add Button</button>
-- <button class="btn btn-small btn-outline" onclick="closeConfigEditor()"><i class="fa-solid fa-times"></i> Close</button>
-- </div>
-- </div>
-- <div id="dash-config-buttons-list"></div>
-- </div>
-- </div>
--
-- <hr style="border-color:var(--border);margin:12px 0">
--
-- <!-- Control Settings -->
-- <details class="dash-control-settings" style="margin-top:12px;">
-- <summary style="cursor:pointer;font-weight:600;font-size:0.9rem;color:var(--text-secondary)"><i class="fa-solid fa-sliders"></i> Control Settings</summary>
-- <div style="margin-top:12px;display:flex;flex-direction:column;gap:10px;">
-- <div class="form-group" style="margin:0">
-- <label style="font-size:0.85rem">Control Mode</label>
-- <select id="dash-control-mode" class="form-input form-input-sm" onchange="updateControlSettings()">
-- <option value="open">Open (anyone can control)</option>
-- <option value="whitelist">Whitelist Only</option>
-- <option value="disabled">Disabled</option>
-- </select>
-- </div>
-- <label class="bc-toggle-label" style="font-size:0.85rem;gap:8px">
-- <input type="checkbox" id="dash-anon-controls" checked onchange="updateControlSettings()">
-- Allow anonymous users to use controls
-- </label>
-- <label class="bc-toggle-label" style="font-size:0.85rem;gap:8px">
-- <input type="checkbox" id="dash-video-click" onchange="updateControlSettings()">
-- Enable video click (viewers click on stream to send coordinates)
-- </label>
-- <p class="bc-field-hint" style="margin:2px 0 0">Command rate limits are per-button (set each control's cooldown in its profile). A high global burst cap prevents floods automatically.</p>
-- </div>
-- </details>
--
-- <!-- Whitelist -->
-- <details id="dash-control-whitelist-section" style="margin-top:8px;display:none">
-- <summary style="cursor:pointer;font-weight:600;font-size:0.9rem;color:var(--text-secondary)"><i class="fa-solid fa-list-check"></i> Control Whitelist</summary>
-- <div style="margin-top:8px">
-- <div id="dash-control-whitelist"></div>
-- <div style="display:flex;gap:8px;margin-top:8px">
-- <input type="text" id="dash-whitelist-username" class="form-input form-input-sm" placeholder="Username" style="flex:1">
-- <button class="btn btn-small btn-outline" onclick="addToControlWhitelist()"><i class="fa-solid fa-plus"></i> Add</button>
-- </div>
-- </div>
-- </details>
--
-- <!-- Hardware Bridge Scripts -->
-- <details style="margin-top:8px">
-- <summary style="cursor:pointer;font-weight:600;font-size:0.9rem;color:var(--text-secondary)"><i class="fa-solid fa-robot"></i> Hardware Bridge Scripts</summary>
-- <div style="margin-top:8px">
-- <p class="bc-field-hint" style="margin-bottom:8px">Download Python scripts to connect hardware (robots, GPIO, servos) to your control profiles. Each script is pre-configured with your stream key and the buttons from the selected profile.</p>
-- <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
-- <select id="dash-bridge-config-select" class="form-input form-input-sm" style="flex:1;min-width:140px">
-- <option value="">Select a control profile...</option>
-- </select>
-- <button class="btn btn-small btn-outline" onclick="downloadDashBridgeScript('generic')" title="Generic hardware bridge — prints commands"><i class="fa-solid fa-download"></i> Generic Script</button>
-- <button class="btn btn-small btn-outline" onclick="downloadDashBridgeScript('cozmo')" title="Cozmo robot bridge — requires pycozmo"><i class="fa-solid fa-robot"></i> Cozmo Script</button>
-- </div>
-- </div>
-- </details>
-- </div>
--
<!-- API Tokens (Bot / Integration) -->
<div class="dash-card" id="dash-card-tokens">
<h3><i class="fa-solid fa-key"></i> API Tokens</h3>
@@@ -197,30 +87,17 @@
<!-- OpenVibe.Bot robot shown on the channel page (LIVE_BOT_EMBED; shown by loadBotEmbedSetting when the flag is on) -->
<div class="dash-card" id="dash-card-bot-embed" style="display:none">
-- <h3><i class="fa-solid fa-robot"></i> Bot robot</h3>
++ <h3><i class="fa-solid fa-robot"></i> Your robot</h3>
++ <p class="muted" style="font-size:0.85rem">Pair a robot on <a href="https://openvibe.bot/robots/new" target="_blank" rel="noopener">OpenVibe.Bot</a> (a Raspberry Pi car, a Cozmo, a phone or a pan-tilt camera) and put its id here. Its live panel shows under your stream, and chat drives it with <code>!forward</code>, <code>!backward</code>, <code>!left</code>, <code>!right</code>, <code>!headup</code>, <code>!liftup</code> and <code>!say</code>. Who may drive is the robot's setting on OpenVibe.Bot: only you, people you invite, or anyone signed in taking turns.</p>
<div class="settings-row">
-- <label class="settings-row-label" for="dash-bot-robot-id">Bot robot id</label>
++ <label class="settings-row-label" for="dash-bot-robot-id">Robot id</label>
<div class="settings-row-content">
<input type="text" id="dash-bot-robot-id" placeholder="rob_…" autocomplete="off" spellcheck="false">
</div>
</div>
<button class="btn btn-primary btn-small" onclick="saveBotEmbedSetting()"><i class="fa-solid fa-floppy-disk"></i> Save</button>
<span id="dash-bot-robot-status" class="muted" style="font-size:0.82rem" role="status"></span>
-- </div>
--
-- <!-- ONVIF Camera Controls -->
-- <div class="dash-card" id="dash-card-cameras">
-- <h3><i class="fa-solid fa-video"></i> Camera Controls (ONVIF PTZ)</h3>
-- <p class="muted" style="font-size: 0.85rem;">Add ONVIF-compatible cameras (Hikvision, Axis, Dahua, etc.) for pan/tilt/zoom viewer control.</p>
-- <div id="dash-cameras-list"></div>
-- <div style="display: flex; gap: 8px; margin-top: 8px;">
-- <button class="btn btn-outline" onclick="showModal('discover-cameras')">
-- <i class="fa-solid fa-magnifying-glass"></i> Discover Cameras
-- </button>
-- <button class="btn btn-outline" onclick="showModal('add-camera')">
-- <i class="fa-solid fa-plus"></i> Add Camera Manually
-- </button>
-- </div>
++ <p class="muted" style="font-size:0.8rem;margin:10px 0 0">No robot yet? <a href="https://openvibe.bot/try" target="_blank" rel="noopener">Drive a simulated one</a>, or see <a href="https://openvibe.bot/models" target="_blank" rel="noopener">what it can drive</a>.</p>
</div>
<!-- Donation Goals -->
ddiff --git a/public/fragments/documentation.html b/public/fragments/documentation.html
iindex f09d1b44..ec48bfb9 100644
---- a/public/fragments/documentation.html
++++ b/public/fragments/documentation.html
@@@ -32,8 +32,7 @@
<button class="docs-tab" onclick="showDocTab('chat',this)">Chat</button>
<button class="docs-tab" onclick="showDocTab('money',this)">Money</button>
<button class="docs-tab" onclick="showDocTab('ai',this)">AI</button>
-- <button class="docs-tab" onclick="showDocTab('controls',this)">Controls</button>
-- <button class="docs-tab" onclick="showDocTab('controlws',this)">Control WS</button>
++ <button class="docs-tab" onclick="showDocTab('robots',this)">Robots</button>
<button class="docs-tab" onclick="showDocTab('audio',this)">TTS &amp; Audio</button>
<button class="docs-tab" onclick="showDocTab('integrations',this)">Integrations</button>
<button class="docs-tab" onclick="showDocTab('vods',this)">VODs</button>
@@@ -121,10 +120,8 @@
│ ├─ /api/streams — Stream CRUD + go-live │
│ ├─ /api/chat — Chat REST API │
│ ├─ /api/tts — TTS voice config │
--│ ├─ /api/controls — Interactive control profiles │
│ ├─ /ws/chat — Real-time chat │
│ ├─ /ws/broadcast — WebRTC signaling │
--│ ├─ /ws/control — Hardware command relay │
│ ├─ /whip/:id — WHIP WebRTC ingestion │
│ └─ RTMP server — rtmp://host/live │
└──────────────────────────────────────────────────────┘</code></pre>
@@@ -142,7 +139,6 @@
<tbody>
<tr><td>/ws/chat</td><td>Chat rooms (per-stream or global)</td><td>Optional (anon allowed)</td></tr>
<tr><td>/ws/broadcast</td><td>WebRTC SFU signaling</td><td>JWT required</td></tr>
--<tr><td>/ws/control</td><td>Interactive controls relay (viewer ↔ hardware)</td><td>JWT or API key</td></tr>
<tr><td>/ws/call</td><td>Group voice/video calls</td><td>JWT required</td></tr>
</tbody>
</table>
@@@ -610,334 +606,34 @@ while True:
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
--<!-- TAB: Controls -->
++<!-- TAB: Robots -->
<!-- ═══════════════════════════════════════════════════════════ -->
--<div class="doc-tab-content" data-doc-tab="controls" style="display:none">
--<h2>5. INTERACTIVE CONTROL SYSTEM — ROBOT/HARDWARE GUIDE</h2>
--<p>The control system lets streamers expose interactive buttons that viewers press to control physical hardware (robots, cameras, servo rigs, etc.). Three interaction types: single-press buttons, keyboard hold (continuous drive), and video-click (x/y coordinates on the live video feed).</p>
--
--<h3>Control Config Profiles</h3>
--<p>Streamers create reusable control configs. Each config holds buttons. When going live, a config is applied to the stream — its buttons get copied to that stream session.</p>
--<table class="doc-table">
--<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Description</th></tr></thead>
--<tbody>
--<tr><td>GET</td><td>/api/controls/configs</td><td>Yes</td><td>List your configs (with button count)</td></tr>
--<tr><td>POST</td><td>/api/controls/configs</td><td>Yes</td><td>Create config { name, description }</td></tr>
--<tr><td>GET</td><td>/api/controls/configs/:id</td><td>Yes</td><td>Config with full buttons array</td></tr>
--<tr><td>PUT</td><td>/api/controls/configs/:id</td><td>Yes</td><td>Update name / description</td></tr>
--<tr><td>DELETE</td><td>/api/controls/configs/:id</td><td>Yes</td><td>Delete config</td></tr>
--<tr><td>POST</td><td>/api/controls/configs/:id/buttons</td><td>Yes</td><td>Add button to config</td></tr>
--<tr><td>PUT</td><td>/api/controls/configs/:id/buttons/:btnId</td><td>Yes</td><td>Update button</td></tr>
--<tr><td>DELETE</td><td>/api/controls/configs/:id/buttons/:btnId</td><td>Yes</td><td>Delete button</td></tr>
--<tr><td>POST</td><td>/api/controls/configs/:id/activate</td><td>Yes</td><td>Set as channel default config</td></tr>
--<tr><td>POST</td><td>/api/controls/configs/deactivate</td><td>Yes</td><td>Clear channel default</td></tr>
--<tr><td>POST</td><td>/api/controls/configs/:id/apply/:streamId</td><td>Yes</td><td>Copy config buttons onto a live stream</td></tr>
--<tr><td>GET</td><td>/api/controls/configs/:id/bridge-script?type=generic|cozmo</td><td>Yes</td><td>Download a Python hardware bridge script pre-configured with the profile's buttons</td></tr>
--</tbody>
--</table>
--
--<h3>Button Schema</h3>
--<pre><code>{
-- "label": "Forward", // Display name shown to viewers (max 50)
-- "command": "forward", // String sent to your hardware bridge (max 100)
-- "icon": "fa-arrow-up", // FontAwesome icon class (without fa-solid prefix)
-- "control_type": "keyboard", // "button" | "keyboard" | "toggle" | "dpad"
-- "key_binding": "w", // Optional keyboard shortcut for viewers (max 20)
-- "cooldown_ms": 100, // Per-press cooldown in ms (0-30000)
-- "sort_order": 0, // Display order (ascending)
-- "btn_color": "#ffffff", // Button text color (CSS color — safe values only)
-- "btn_bg": "#1a1a2e", // Button background color
-- "btn_border_color": "#6366f1", // Button border color
-- "is_enabled": 1 // 1=shown, 0=hidden
--}
--</code></pre>
--
--<h3>Control Type Behaviour</h3>
--<table class="doc-table">
--<thead><tr><th>control_type</th><th>Viewer Interaction</th><th>Messages Sent</th></tr></thead>
--<tbody>
--<tr><td>button</td><td>Single click or key tap</td><td>command</td></tr>
--<tr><td>keyboard</td><td>Hold mouse/touch/key → continuous. Release → stop</td><td>key_down on press, key_up on release</td></tr>
--<tr><td>toggle</td><td>Click to toggle on/off</td><td>command (on) / command (off)</td></tr>
--<tr><td>dpad</td><td>D-pad style directional</td><td>command</td></tr>
--</tbody>
--</table>
--
--<h3>Channel Control Settings</h3>
--<table class="doc-table">
--<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
--<tbody>
--<tr><td>GET</td><td>/api/controls/settings/channel</td><td>Get settings</td></tr>
--<tr><td>PUT</td><td>/api/controls/settings/channel</td><td>Update settings</td></tr>
--</tbody>
--</table>
--<pre><code>{
-- "control_mode": "open", // "open" | "whitelist" | "disabled"
-- "anon_controls_enabled": true, // Allow anonymous viewers to use controls
-- "control_rate_limit_ms": 100, // Global rate limit per user (100-30000)
-- "video_click_enabled": true, // Enable video click input
-- "video_click_rate_limit_ms": 0 // Per-user minimum time between video clicks; 0 = use global rate limit
--}
--</code></pre>
--
--<h3>Per-Stream Controls (Legacy / Direct)</h3>
--<table class="doc-table">
--<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
--<tbody>
--<tr><td>GET</td><td>/api/controls/:streamId</td><td>Get all controls + settings for a stream</td></tr>
--<tr><td>POST</td><td>/api/controls/:streamId</td><td>Add a button directly to active stream</td></tr>
--<tr><td>PUT</td><td>/api/controls/:streamId/:id</td><td>Update control</td></tr>
--<tr><td>DELETE</td><td>/api/controls/:streamId/:id</td><td>Delete control</td></tr>
--<tr><td>POST</td><td>/api/controls/:streamId/presets/cozmo</td><td>Apply Cozmo robot preset buttons</td></tr>
--</tbody>
--</table>
--</div>
--
--<!-- ═══════════════════════════════════════════════════════════ -->
--<!-- TAB: Control WS -->
--<!-- ═══════════════════════════════════════════════════════════ -->
--<div class="doc-tab-content" data-doc-tab="controlws" style="display:none">
--<h2>6. CONTROL WEBSOCKET — HARDWARE BRIDGE</h2>
--<p><strong>Connecting a new robot? Use <a href="https://openvibe.bot" target="_blank" rel="noopener">OpenVibe.Bot</a> and the <a href="https://github.com/OpenVibers/OpenVibe.Node" target="_blank" rel="noopener">OpenVibe Node</a> instead.</strong> The Node pairs with a one-time code (no stream key), runs your driver as a plugin (<a href="https://openvibe.bot/docs/drivers" target="_blank" rel="noopener">write a driver</a>), and Bot gives the robot a panel built from its profile, roles, a queue, limits and a latched e-stop. Bind the robot to your channel on the dashboard and its panel shows under your stream. The socket below keeps working for existing setups and is retired once they have moved to Bot.</p>
--<p>This is how your code receives viewer commands. Connect as <code>mode=hardware</code> and listen for messages. Your bridge translates them into robot/hardware actions.</p>
--
--<h3>Connection URLs</h3>
--<pre><code># Hardware bridge (your robot/device — receives commands)
--wss://openvibe.live/ws/control?mode=hardware&stream_key=YOUR_STREAM_KEY
--
--# Viewer (browser/bot — sends commands)
--wss://openvibe.live/ws/control?mode=viewer&token=JWT&stream=STREAM_ID
--</code></pre>
--
--<h3>Hardware Receives These Messages</h3>
++<div class="doc-tab-content" data-doc-tab="robots" style="display:none">
++<h2>5. ROBOTS — OPENVIBE.BOT</h2>
++<p>Robots live on <a href="https://openvibe.bot" target="_blank" rel="noopener">OpenVibe.Bot</a>, the open control panel for robots: pairing, the drive panel, roles and turn queues, limits, the latched e-stop, video and the command audit are all there. OpenVibe.Live shows a channel's robot and lets its chat drive it; it has no control socket or hardware API of its own.</p>
++
++<h3>A robot on your channel</h3>
++<ol>
++<li>Add a robot on <a href="https://openvibe.bot/robots/new" target="_blank" rel="noopener">openvibe.bot/robots/new</a>: a Raspberry Pi car, a Cozmo through a computer beside it, a phone (scan a QR code, nothing to install) or an ONVIF camera. Its pairing page has the one command (or the QR code) that brings it online.</li>
++<li>On the robot's panel, turn on <b>Let anyone watch where it is embedded</b>, and choose who may drive: only you and people you add, people you invite, or anyone signed in taking timed turns.</li>
++<li>In your dashboard (<b>Robot &amp; API</b>), put the robot's id (<code>rob_…</code>). Its live panel shows under your stream: everyone sees its camera and readouts, and anyone allowed to drive presses <b>Drive</b>.</li>
++</ol>
++<p>The binding is <code>PUT /api/streams/channel/:username/bot</code> with <code>{ "robot_id": "rob_…" }</code> (or <code>null</code> to unbind), the channel owner only.</p>
++
++<h3>Chat commands</h3>
<table class="doc-table">
--<thead><tr><th>Type</th><th>Payload Fields</th><th>When</th></tr></thead>
++<thead><tr><th>Command</th><th>What the robot gets</th></tr></thead>
<tbody>
--<tr><td>connected</td><td>message</td><td>Immediately on connect — confirms auth</td></tr>
--<tr><td>command</td><td>command, control_id, from_user, timestamp</td><td>Viewer clicked a button (button/toggle/dpad type)</td></tr>
--<tr><td>key_down</td><td>command, control_id, from_user, timestamp</td><td>Viewer started holding a keyboard-type button</td></tr>
--<tr><td>key_up</td><td>command, control_id, from_user, timestamp</td><td>Viewer released the button</td></tr>
--<tr><td>video_click</td><td>x (0-1), y (0-1), from_user, timestamp</td><td>Viewer clicked on the video feed. 0,0 = top-left, 1,1 = bottom-right</td></tr>
++<tr><td><code>!forward</code> / <code>!backward</code></td><td>a short drive pulse forward or back</td></tr>
++<tr><td><code>!left</code> / <code>!right</code></td><td>a short turn</td></tr>
++<tr><td><code>!headup</code> / <code>!headdown</code>, <code>!liftup</code> / <code>!liftdown</code></td><td>the head or lift, on robots that have one (Cozmo)</td></tr>
++<tr><td><code>!say &lt;text&gt;</code></td><td>speech, on robots that speak</td></tr>
</tbody>
</table>
++<p>Each command goes to OpenVibe.Bot as the viewer who typed it, so the robot's rules decide: signed-in viewers only, the owner's access policy and turn queue, per-person cooldowns, and nothing while the e-stop is latched. A refusal is answered in chat ("It is not your turn at the robot yet").</p>
--<h3>Hardware Can Send</h3>
--<table class="doc-table">
--<thead><tr><th>Type</th><th>Payload</th><th>Effect</th></tr></thead>
--<tbody>
--<tr><td>status</td><td>{ any fields }</td><td>Relayed to all viewers as hardware_status message</td></tr>
--</tbody>
--</table>
--
--<h3>Viewer Sends These Messages</h3>
--<table class="doc-table">
--<thead><tr><th>Type</th><th>Payload</th><th>Description</th></tr></thead>
--<tbody>
--<tr><td>command</td><td>{ command, control_id }</td><td>Single button press</td></tr>
--<tr><td>key_down</td><td>{ command, control_id }</td><td>Start holding (keyboard type)</td></tr>
--<tr><td>key_up</td><td>{ command, control_id }</td><td>Release hold</td></tr>
--<tr><td>video_click</td><td>{ x: 0-1, y: 0-1 }</td><td>Click on video feed</td></tr>
--</tbody>
--</table>
--
--<h3>Viewer Receives These Messages</h3>
--<table class="doc-table">
--<thead><tr><th>Type</th><th>Key Fields</th><th>Description</th></tr></thead>
--<tbody>
--<tr><td>controls</td><td>controls[], settings{}</td><td>Initial buttons + channel settings on connect</td></tr>
--<tr><td>command_executed</td><td>command, by</td><td>Someone pressed a button (activity feed)</td></tr>
--<tr><td>key_held</td><td>command, by</td><td>Someone started holding a key</td></tr>
--<tr><td>key_released</td><td>command, by</td><td>Someone released a key</td></tr>
--<tr><td>video_click_activity</td><td>x, y, by</td><td>Someone clicked the video</td></tr>
--<tr><td>hardware_status</td><td>any</td><td>Status update from hardware bridge</td></tr>
--<tr><td>error</td><td>message</td><td>Denied / not live / banned</td></tr>
--<tr><td>cooldown</td><td>message</td><td>Rate limited</td></tr>
--</tbody>
--</table>
--
--<h3>Permission Model</h3>
--<table class="doc-table">
--<thead><tr><th>control_mode</th><th>Who can send commands</th></tr></thead>
--<tbody>
--<tr><td>open</td><td>Any viewer (anon allowed if anon_controls_enabled=true)</td></tr>
--<tr><td>whitelist</td><td>Stream owner + explicitly whitelisted users only</td></tr>
--<tr><td>disabled</td><td>Nobody — controls are turned off</td></tr>
--</tbody>
--</table>
--
--<h3>Hardware Bridge — Python (Full Example)</h3>
--<pre><code>#!/usr/bin/env python3
--"""
--OpenVibe.Live Hardware Bridge — generic template.
--pip install websocket-client
--"""
--import json, time, threading, websocket
--
--STREAM_KEY = 'YOUR_STREAM_KEY'
--WS_URL = f'wss://openvibe.live/ws/control?mode=hardware&stream_key={STREAM_KEY}'
--
--held_keys = set() # Track currently held keyboard commands
--
--def do_command(cmd):
-- """Map command strings to your hardware."""
-- print(f'Command: {cmd}')
-- # Example: call GPIO, serial, HTTP, etc.
-- # if cmd == 'forward': robot.drive(speed=100)
--
--def do_start_hold(cmd):
-- """Called when a keyboard-type button starts being held."""
-- held_keys.add(cmd)
-- print(f'Hold start: {cmd}')
--
--def do_stop_hold(cmd):
-- """Called when a keyboard-type button is released."""
-- held_keys.discard(cmd)
-- print(f'Hold stop: {cmd}')
-- if not held_keys:
-- print('All keys released — stop movement')
-- # robot.stop()
--
--def do_video_click(x, y):
-- """Called when a viewer clicks the video feed. x,y are 0-1 normalized."""
-- print(f'Video click at ({x:.2f}, {y:.2f})')
-- # Navigate toward click: x < 0.4 = turn left, x > 0.6 = turn right
-- # y close to 0 = far away (drive more), y close to 1 = near (drive less)
--
--def on_message(ws, raw):
-- try:
-- msg = json.loads(raw)
-- t = msg.get('type')
-- if t == 'connected':
-- print('Hardware bridge authenticated!')
-- elif t == 'command':
-- do_command(msg['command'])
-- elif t == 'key_down':
-- do_start_hold(msg['command'])
-- elif t == 'key_up':
-- do_stop_hold(msg['command'])
-- elif t == 'video_click':
-- do_video_click(msg['x'], msg['y'])
-- except Exception as e:
-- print(f'Error: {e}')
--
--def on_open(ws): print('Connected')
--def on_close(ws, *a): print('Disconnected')
--def on_error(ws, e): print(f'WS error: {e}')
--
--while True:
-- ws = websocket.WebSocketApp(WS_URL, on_message=on_message,
-- on_open=on_open, on_close=on_close, on_error=on_error)
-- ws.run_forever(ping_interval=30)
-- print('Reconnecting in 5s...')
-- time.sleep(5)
--</code></pre>
--
--<h3>Hardware Bridge — Node.js (Full Example)</h3>
--<pre><code>const WebSocket = require('ws');
--
--const STREAM_KEY = 'YOUR_STREAM_KEY';
--const WS_URL = `wss://openvibe.live/ws/control?mode=hardware&stream_key=${STREAM_KEY}`;
--const heldKeys = new Set();
--
--function connect() {
-- const ws = new WebSocket(WS_URL);
--
-- ws.on('message', (raw) => {
-- const msg = JSON.parse(raw);
-- switch (msg.type) {
-- case 'connected':
-- console.log('Hardware bridge authenticated!');
-- break;
-- case 'command':
-- console.log(`Button: ${msg.command} by ${msg.from_user}`);
-- doCommand(msg.command);
-- break;
-- case 'key_down':
-- heldKeys.add(msg.command);
-- console.log(`Hold start: ${msg.command}`);
-- startContinuous(msg.command);
-- break;
-- case 'key_up':
-- heldKeys.delete(msg.command);
-- console.log(`Hold stop: ${msg.command}`);
-- if (heldKeys.size === 0) stopAll();
-- break;
-- case 'video_click':
-- console.log(`Click at (${msg.x.toFixed(2)}, ${msg.y.toFixed(2)}) by ${msg.from_user}`);
-- navigateToClick(msg.x, msg.y);
-- break;
-- }
-- });
--
-- ws.on('close', () => setTimeout(connect, 5000));
-- ws.on('error', (e) => console.error(e));
--}
--
--function doCommand(cmd) { /* implement hardware control */ }
--function startContinuous(cmd) { /* start motors etc. */ }
--function stopAll() { /* stop all motors */ }
--function navigateToClick(x, y) { /* steer toward x,y */ }
--
--connect();
--</code></pre>
--
--<h3>API Keys (for hardware bridges without JWT)</h3>
--<table class="doc-table">
--<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
--<tbody>
--<tr><td>POST</td><td>/api/controls/api-key</td><td>Generate API key (shown once — save it)</td></tr>
--<tr><td>GET</td><td>/api/controls/api-keys</td><td>List keys (masked)</td></tr>
--<tr><td>GET</td><td>/api/controls/cozmo-script</td><td>Download pre-built Cozmo robot bridge Python script</td></tr>
--</tbody>
--</table>
--
--<h3>Per-Profile Bridge Script Generator</h3>
--<p>Download Python bridge scripts that come pre-configured with your control profile's buttons and your stream key. Available from the Dashboard (Hardware Bridge Scripts section) or the Broadcast page (Script button next to profile selector).</p>
--<table class="doc-table">
--<thead><tr><th>Type</th><th>Description</th></tr></thead>
--<tbody>
--<tr><td>generic</td><td>Prints all commands to console. Edit handle_command() / handle_key_down() / handle_key_up() to control your hardware. Works with any device — GPIO, serial, HTTP, etc.</td></tr>
--<tr><td>cozmo</td><td>Pre-mapped to pycozmo actions (drive, lift, head, animations). Includes COMMAND_MAP dict so you can remap your custom button commands to Cozmo movements.</td></tr>
--</tbody>
--</table>
--<pre><code># Download via API:
--GET /api/controls/configs/42/bridge-script?type=generic
--GET /api/controls/configs/42/bridge-script?type=cozmo
--
--# Both return a .py file download with:
--# - Your stream key (auto-connects as hardware bridge)
--# - All enabled buttons from the profile listed as BUTTONS
--# - Skeleton handler functions ready to fill in
--</code></pre>
--
--<h3>Hardware Status Indicator</h3>
--<p>When a hardware bridge connects, viewers see a live connection indicator:</p>
--<table class="doc-table">
--<thead><tr><th>Indicator</th><th>Meaning</th></tr></thead>
--<tbody>
--<tr><td>&#x26AB; (grey)</td><td>No controls configured for this stream</td></tr>
--<tr><td>&#x1F7E1; (yellow)</td><td>Controls present but no hardware bridge connected</td></tr>
--<tr><td>&#x1F7E2; (green)</td><td>Hardware bridge is online — commands will be received</td></tr>
--</tbody>
--</table>
--<p>Hardware bridges send <code>{ type: "status", connected: true }</code> on connect. The server broadcasts this to all viewers as a <code>hardware_status</code> message. Send a status with <code>connected: true</code> to turn the indicator green.</p>
--
--<h3>ONVIF Camera Control</h3>
--<p>Control PTZ cameras via ONVIF. Cameras appear as controls in the stream control panel.</p>
--<table class="doc-table">
--<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
--<tbody>
--<tr><td>POST</td><td>/api/onvif/discover</td><td>Auto-discover ONVIF cameras on local network</td></tr>
--<tr><td>POST</td><td>/api/onvif/cameras</td><td>Add camera { host, port, username, password, name }</td></tr>
--<tr><td>GET</td><td>/api/onvif/cameras</td><td>List cameras</td></tr>
--<tr><td>PUT</td><td>/api/onvif/cameras/:id</td><td>Update camera</td></tr>
--<tr><td>DELETE</td><td>/api/onvif/cameras/:id</td><td>Delete camera</td></tr>
--<tr><td>GET</td><td>/api/onvif/cameras/:id/presets</td><td>List presets</td></tr>
--<tr><td>POST</td><td>/api/onvif/cameras/:id/presets</td><td>Create preset</td></tr>
--</tbody>
--</table>
--<p>ONVIF movements (send via control WS with <code>isOnvif: true, cameraId: X, movement: Y</code>):</p>
--<p>pan_left, pan_right, tilt_up, tilt_down, zoom_in, zoom_out</p>
++<h3>Building for robots</h3>
++<p>Drivers, robot profiles, the device protocol, the REST API with the same gate, and a simulated rover to try it all are on OpenVibe.Bot: <a href="https://openvibe.bot/docs" target="_blank" rel="noopener">Build for OpenVibe.Bot</a> · <a href="https://openvibe.bot/try" target="_blank" rel="noopener">try the panel</a> · <a href="https://openvibe.bot/models" target="_blank" rel="noopener">supported models</a>.</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 18cc7b57..1747ba65 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -1663,7 +1663,6 @@ function activateChannelStream(stream) {
}
}
if (typeof initChat === 'function') initChat(stream.id, stream.user_id || _activeChannelUserId);
-- if (typeof loadStreamControls === 'function') loadStreamControls(stream.id);
if (typeof startCoinHeartbeat === 'function') startCoinHeartbeat(stream.id);
if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(stream.user_id);
startUptime(stream.started_at);
@@@ -3324,7 +3323,6 @@ async function openStream(streamId) {
if (typeof initPlayer === 'function') initPlayer(s);
if (typeof initChat === 'function') initChat(streamId, s.user_id || _activeChannelUserId);
-- if (typeof loadStreamControls === 'function') loadStreamControls(streamId);
if (typeof startCoinHeartbeat === 'function') startCoinHeartbeat(streamId);
if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(s.user_id);
loadStreamGoals(streamId);
ddiff --git a/public/js/app.js b/public/js/app.js
iindex f67499ab..c9cb60e1 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -528,10 +528,6 @@ function showModal(id) {
'buy-funds': openvibeBucksBuyModal(),
cashout: openvibeBucksCashoutModal(),
'stream-key': streamKeyModal(),
-- 'add-camera': addCameraModal(),
-- 'discover-cameras': discoverCamerasModal(),
-- 'create-config': createConfigModal(),
-- 'add-config-button': addConfigButtonModal(),
'add-goal': addGoalModal(),
'add-reward': addRewardModal(),
'redeem-reward': (data) => redeemRewardModal(data),
@@@ -912,9 +908,6 @@ function teardownRoute(nextPath) {
// Loops other modules own. Each is optional — a module that isn't loaded simply isn't stopped.
const stop = (fn) => { try { if (typeof window[fn] === 'function') window[fn](); } catch { /* */ } };
-- // Hardware controls: the socket and its reconnect loop outlived the channel, and the global
-- // key handler kept sending bound keys to the last channel's device from any page.
-- stop('destroyControlWs');
// A VOD or clip playing Media's playlist: its hls.js instance, worker and loaders end with the page.
stop('vodDropHls');
try { currentStreamId = null; } catch { /* */ }
@@@ -2209,115 +2202,6 @@ function streamKeyModal() {
<i class="fa-solid fa-rotate"></i> Regenerate
</button>`;
}
--function createConfigModal() {
-- return `
-- <h3><i class="fa-solid fa-sliders"></i> New Control Profile</h3>
-- <p class="muted" style="font-size:0.85rem;margin-bottom:12px">Create a reusable set of control buttons. You can set up different profiles for different robots, games, or setups.</p>
-- <div class="form-group">
-- <label>Profile Name</label>
-- <input type="text" id="modal-config-name" class="form-input" placeholder="e.g. Cozmo Robot, RC Car, Camera Rig" maxlength="60">
-- </div>
-- <div class="form-group">
-- <label>Description (optional)</label>
-- <input type="text" id="modal-config-desc" class="form-input" placeholder="Brief description" maxlength="200">
-- </div>
-- <button class="btn btn-primary btn-lg" onclick="doCreateConfig()" style="width:100%;margin-top:8px">
-- <i class="fa-solid fa-plus"></i> Create Profile
-- </button>`;
--}
--
--function addConfigButtonModal() {
-- return `
-- <h3><i class="fa-solid fa-plus"></i> Add Control Button</h3>
-- <div class="form-group">
-- <label>Command</label>
-- <input type="text" id="modal-cfgbtn-cmd" class="form-input" placeholder="e.g. forward" maxlength="100">
-- </div>
-- <div class="form-group">
-- <label>Label</label>
-- <input type="text" id="modal-cfgbtn-label" class="form-input" placeholder="e.g. Forward" maxlength="50">
-- </div>
-- <div class="form-group">
-- <label>Icon (FontAwesome class)</label>
-- <input type="text" id="modal-cfgbtn-icon" class="form-input" placeholder="e.g. fa-arrow-up" value="fa-gamepad">
-- </div>
-- <div class="form-group">
-- <label>Type</label>
-- <select id="modal-cfgbtn-type" class="form-input">
-- <option value="button">Button (single click)</option>
-- <option value="keyboard">Keyboard (hold to activate)</option>
-- <option value="dpad">D-Pad</option>
-- <option value="toggle">Toggle</option>
-- </select>
-- </div>
-- <div class="form-group">
-- <label>Key Binding (optional)</label>
-- <input type="text" id="modal-cfgbtn-keybind" class="form-input" placeholder="e.g. w, a, s, d" maxlength="20">
-- <span class="bc-field-hint">Keyboard shortcut for this button</span>
-- </div>
-- <div class="form-group">
-- <label>Cooldown (seconds)</label>
-- <input type="number" id="modal-cfgbtn-cooldown" class="form-input" value="0.5" min="0" max="30" step="0.1">
-- </div>
-- <details style="margin-top:8px">
-- <summary style="cursor:pointer;font-weight:600;font-size:0.85rem;color:var(--text-secondary)"><i class="fa-solid fa-palette"></i> Custom Styling</summary>
-- <div style="margin-top:8px;display:flex;flex-direction:column;gap:8px;">
-- <div class="form-group" style="margin:0">
-- <label style="font-size:0.85rem">Text Color</label>
-- <input type="text" id="modal-cfgbtn-color" class="form-input form-input-sm" placeholder="#fff or red or var(--accent)">
-- </div>
-- <div class="form-group" style="margin:0">
-- <label style="font-size:0.85rem">Background</label>
-- <input type="text" id="modal-cfgbtn-bg" class="form-input form-input-sm" placeholder="#333 or darkblue">
-- </div>
-- <div class="form-group" style="margin:0">
-- <label style="font-size:0.85rem">Border Color</label>
-- <input type="text" id="modal-cfgbtn-border" class="form-input form-input-sm" placeholder="var(--accent) or var(--accent)">
-- </div>
-- </div>
-- </details>
-- <button class="btn btn-primary btn-lg" onclick="doAddConfigButton()" style="width:100%;margin-top:12px">
-- <i class="fa-solid fa-plus"></i> Add Button
-- </button>`;
--}
--
--function addCameraModal() {
-- return `
-- <h3><i class="fa-solid fa-video"></i> Add ONVIF Camera</h3>
-- <p class="muted" style="font-size:0.85rem;">Connect to an ONVIF-compatible camera (Hikvision, Axis, Dahua, etc.)</p>
-- <div class="form-group">
-- <label>Camera Name</label>
-- <input type="text" id="modal-cam-name" class="form-input" placeholder="e.g. Front Door">
-- </div>
-- <div class="form-group">
-- <label>ONVIF URL</label>
-- <input type="text" id="modal-cam-url" class="form-input" placeholder="http://192.168.1.100:8080">
-- </div>
-- <div class="form-group">
-- <label>Username</label>
-- <input type="text" id="modal-cam-username" class="form-input" placeholder="admin">
-- </div>
-- <div class="form-group">
-- <label>Password</label>
-- <input type="password" id="modal-cam-password" class="form-input">
-- </div>
-- <button class="btn btn-primary btn-lg" onclick="doAddCamera()" style="width:100%;margin-top:8px">
-- <i class="fa-solid fa-plus"></i> Add Camera
-- </button>`;
--}
--
--function discoverCamerasModal() {
-- return `
-- <h3><i class="fa-solid fa-magnifying-glass"></i> Discover Cameras</h3>
-- <p class="muted" style="font-size:0.85rem;">Scan your network for ONVIF devices. This may take a few seconds.</p>
-- <button class="btn btn-primary btn-lg" onclick="doDiscoverCameras()" style="width:100%;margin-bottom:12px">
-- <i class="fa-solid fa-wifi"></i> Scan Network
-- </button>
-- <div id="discovery-status" style="min-height:100px;padding:8px;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg-hover)">
-- <p class="muted">Click "Scan Network" to discover devices...</p>
-- </div>`;
--}
--
// Working media state for the goal editor modal.
let _goalMediaUrl = '';
let _goalMediaType = '';
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex 0da638f7..59ca4806 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -6,7 +6,7 @@
A. Persistent stream profile (server-backed, per managed stream):
Structured columns: title, description, category, is_nsfw,
-- protocol, control_config_id, slug
++ protocol, slug
Saved via: PUT /streams/managed/:id (manual Save button)
Broadcast settings blob: bitrate, fps, resolution, browserMode,
micOnlyImage, screenShareOpts
@@@ -630,9 +630,6 @@ function _wsRenderPanel() {
</div>
-- <!-- Control profile lives under Settings; createNewStream() and the save
-- payload both read #bc-control-config, which is rendered there. -->
--
<!-- Hidden element required by createNewStream() -->
<select id="bc-managed-stream" style="display:none">
<option value="${ms.id}" selected>${esc(ms.title || '')}</option>
@@@ -730,22 +727,6 @@ function _wsRenderPanel() {
<div class="bc-ws-tab-panel" data-wstabpanel="settings">
<div class="bc-ws-profile-section">
-- <!-- Control Profile -->
-- <details class="bc-ws-slot-settings">
-- <summary><i class="fa-solid fa-gamepad"></i> Control Profile</summary>
-- <div class="bc-ws-slot-settings-inner">
-- <p class="muted" style="font-size:0.82rem;margin-bottom:10px">
-- Lets viewers send commands to hardware attached to this slot. Leave as None
-- for an ordinary stream.
-- </p>
-- <div class="form-group">
-- <select id="bc-control-config" class="form-input form-input-sm" onchange="_wsMarkDirty()">
-- <option value="">None (no controls)</option>
-- </select>
-- </div>
-- </div>
-- </details>
--
<!-- Picture-in-picture camera -->
<details class="bc-ws-slot-settings">
<summary><i class="fa-solid fa-video"></i> Picture-in-picture Camera</summary>
@@@ -1150,8 +1131,7 @@ function _wsRenderPanel() {
// Restore active tab
_wsTabSwitch(_wsState.activeTab || 'profile');
-- // Async post-render: populate control configs and check device permissions
-- _wsPopulateControlConfigs(ms.control_config_id);
++ // Async post-render: check device permissions
// Corner/size selects are derived from the stored {x,y,w} fractions.
_wsPipDefaultsRestore(ms);
if (method === 'browser') {
@@@ -1816,25 +1796,6 @@ function _wsSyncScreenShareHiddenDefaults(browserMode) {
}
}
--/* ── Control config population ───────────────────────────────── */
--
--async function _wsPopulateControlConfigs(selectedId) {
-- const select = document.getElementById('bc-control-config');
-- if (!select) return;
-- try {
-- const data = await api('/controls/configs');
-- const configs = data.configs || [];
-- select.innerHTML = '<option value="">None (no controls)</option>';
-- configs.forEach(cfg => {
-- const opt = document.createElement('option');
-- opt.value = cfg.id;
-- opt.textContent = `${cfg.name} (${cfg.button_count || 0} buttons)`;
-- if (cfg.id === selectedId) opt.selected = true;
-- select.appendChild(opt);
-- });
-- } catch { /* leave as None */ }
--}
--
/* ── Stream key tools ────────────────────────────────────────── */
function _wsToggleKeyVisibility() {
@@@ -1957,7 +1918,6 @@ async function _wsSaveAll() {
protocol: ms.protocol,
streaming_method: ms.streaming_method || 'browser',
browser_mode: ms.browser_mode || _wsState.profile.browserMode || 'camera',
-- control_config_id: parseInt(document.getElementById('bc-control-config')?.value) || null,
slug: document.getElementById('bc-slug')?.value.trim().toLowerCase() || null,
// Slot-level settings
default_vod_visibility: ms.default_vod_visibility || 'public',
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 6146ca6c..bc6d9661 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -869,7 +869,6 @@ async function loadBroadcastPage() {
loadBroadcastSettings();
loadRobotStreamerIntegration().catch(() => {});
loadRestreamDestinations().catch(() => {});
-- loadBroadcastControlConfigs().catch(() => {}); // populates configs for other contexts
// Init workspace (replaces loadBroadcastManagedStreams)
initBroadcastWorkspace().catch(err => console.warn('[Broadcast] Workspace init failed:', err));
@@@ -1026,7 +1025,6 @@ async function switchToStreamTab(streamId) {
ensureBroadcastChat(streamId);
if (!ss.robotStreamer?.active) maybeStartRsRestream(streamId);
updateRsRestreamSlotUI();
-- loadLiveControlsStatus(streamId).catch(() => {});
return;
}
@@@ -1075,9 +1073,6 @@ async function showCreateStreamPanel() {
// Restore last-used form fields from localStorage
_restoreLastBroadcastFields();
-- // Refresh control config selector so keyboard/HOLD profiles are always current
-- loadBroadcastControlConfigs().catch(() => {});
--
// Populate device selectors for the create form
await populateCreateFormDevices();
}
@@@ -1176,7 +1171,6 @@ async function resumeStreamView(stream) {
if (!existing.robotStreamer?.active) maybeStartRsRestream(stream.id);
showBroadcastCallControls(); updateBroadcastCallUI();
updateRsRestreamSlotUI();
-- loadLiveControlsStatus(stream.id).catch(() => {});
return;
}
// Not yet broadcasting — create per-stream state and start capture
@@@ -1196,7 +1190,6 @@ async function resumeStreamView(stream) {
acquireWakeLock();
showBroadcastCallControls(); updateBroadcastCallUI();
updateRsRestreamSlotUI();
-- loadLiveControlsStatus(stream.id).catch(() => {});
startRestreamStatusPolling();
} else if (stream.protocol === 'rtmp') {
const ss = createStreamState(stream);
@@@ -1438,40 +1431,6 @@ function _syncBrowserSourceUI() {
}
}
--/* ── Load Control Config Selector ─────────────────────────────── */
--async function loadBroadcastControlConfigs() {
-- const selector = document.getElementById('bc-config-selector');
-- const select = document.getElementById('bc-control-config');
-- const downloadBtn = document.getElementById('bc-download-bridge-btn');
-- if (!selector || !select) return;
--
-- try {
-- const data = await api('/controls/configs');
-- const configs = data.configs || [];
-- if (!configs.length) {
-- selector.style.display = 'none';
-- return;
-- }
-- selector.style.display = '';
--
-- // Get active config
-- const settings = await api('/controls/settings/channel').catch(() => ({}));
-- const activeId = settings.active_control_config_id;
--
-- select.innerHTML = '<option value="">None (no controls)</option>' +
-- configs.map(c => `<option value="${c.id}" ${c.id === activeId ? 'selected' : ''}>${esc(c.name)} (${c.button_count} buttons)</option>`).join('');
--
-- // Show/hide script download button based on selection
-- const updateBtn = () => {
-- if (downloadBtn) downloadBtn.style.display = select.value ? '' : 'none';
-- };
-- select.addEventListener('change', updateBtn);
-- updateBtn();
-- } catch {
-- if (selector) selector.style.display = 'none';
-- }
--}
--
/* ── Create New Stream ───────────────────────────────────────── */
async function createNewStream() {
const title = document.getElementById('bc-title')?.value.trim();
@@@ -1536,8 +1495,6 @@ async function createNewStream() {
let _diagStreamId = null; // captured in catch for diagnostic logging
try {
-- const selectedConfig = document.getElementById('bc-control-config')?.value;
-- const controlConfigId = selectedConfig ? parseInt(selectedConfig) : null;
const selectedManagedStream = document.getElementById('bc-managed-stream')?.value;
const managedStreamId = selectedManagedStream ? parseInt(selectedManagedStream) : null;
const data = await api('/streams', {
@@@ -1547,7 +1504,6 @@ async function createNewStream() {
description,
protocol: method,
is_nsfw: document.getElementById('bc-nsfw')?.checked || false,
-- control_config_id: controlConfigId,
managed_stream_id: managedStreamId,
}
});
@@@ -1589,7 +1545,6 @@ async function createNewStream() {
showBrowserBroadcast(); populateDeviceLists(); populateTTSVoices(); syncSettingsUI();
_syncScreenShareUI();
ensureBroadcastChat(streamData.id);
-- loadLiveControlsStatus(streamData.id).catch(() => {});
if (isScreenMode) {
// Screen share capture options
@@@ -1675,111 +1630,6 @@ function showBrowserBroadcast() {
let _rtmpStatusPollTimer = null;
/* ── Controls Status + Live Profile Switcher ────────────────── */
--/**
-- * Load control profile status and populate the live profile selector
-- * for all visible streaming method panels (RTMP, JSMPEG, WHIP, browser).
-- * Determines the bound profile from stream-level first, channel default second.
-- */
--async function loadLiveControlsStatus(streamId) {
-- // Status label elements (non-browser streaming panels)
-- const statusEls = ['bc-rtmp-controls-status', 'bc-jsmpeg-controls-status', 'bc-whip-controls-status', 'bc-browser-controls-status']
-- .map(id => document.getElementById(id)).filter(Boolean);
--
-- // Profile selector elements (non-browser + browser panels)
-- const selectorIds = ['bc-rtmp-live-config-select', 'bc-jsmpeg-live-config-select', 'bc-whip-live-config-select', 'bc-live-config-select'];
--
-- try {
-- const [settings, cdata, streamConfig] = await Promise.all([
-- api('/controls/settings/channel').catch(() => ({})),
-- api('/controls/configs').catch(() => ({})),
-- streamId ? api(`/controls/${streamId}/config`).catch(() => ({ control_config_id: null })) : Promise.resolve({ control_config_id: null }),
-- ]);
--
-- const configs = cdata.configs || [];
--
-- // Stream-level config takes precedence; fall back to channel default
-- let configId = streamConfig?.control_config_id || null;
-- if (!configId) {
-- configId = settings.active_control_config_id || null;
-- }
--
-- // Populate all profile selectors with available configs
-- const optionsHtml = '<option value="">None (no controls)</option>' +
-- configs.map(c => `<option value="${c.id}">${esc(c.name)} (${c.button_count} btn)</option>`).join('');
-- for (const selId of selectorIds) {
-- const sel = document.getElementById(selId);
-- if (!sel) continue;
-- sel.innerHTML = optionsHtml;
-- sel.value = configId ? String(configId) : '';
-- }
--
-- // Render status label
-- if (!configId) {
-- const html = '<p class="muted" style="font-size:0.85rem">No control profile active. Select one above and click Apply.</p>';
-- statusEls.forEach(el => el.innerHTML = html);
-- return;
-- }
-- const config = configs.find(c => c.id === configId);
-- const name = config ? esc(config.name) : `Profile #${configId}`;
-- const count = config ? config.button_count : '?';
-- const plural = count === 1 ? 'button' : 'buttons';
-- const keyboardCount = config ? (config.keyboard_count ?? '?') : '?';
-- const html = `<div style="display:flex;align-items:center;gap:8px;padding:4px 0">
-- <i class="fa-solid fa-circle" style="color:#4caf50;font-size:10px;flex-shrink:0"></i>
-- <span><strong>${name}</strong> — ${count} ${plural} active for viewers</span>
-- </div>`;
-- statusEls.forEach(el => el.innerHTML = html);
-- } catch {
-- const html = '<p class="muted" style="font-size:0.85rem">Could not load control profile status.</p>';
-- statusEls.forEach(el => el.innerHTML = html);
-- }
--}
--
--/**
-- * Apply the selected live control profile to the currently active stream.
-- * Called from "Apply" buttons in all streaming method panels.
-- * Uses whichever profile selector is currently visible/filled.
-- */
--async function applyLiveControlProfile() {
-- const streamId = broadcastState.activeStreamId;
-- if (!streamId) {
-- toast('No active stream to apply profile to', 'error');
-- return;
-- }
--
-- // Find the first populated selector that has a value
-- const selectorIds = ['bc-live-config-select', 'bc-rtmp-live-config-select', 'bc-jsmpeg-live-config-select', 'bc-whip-live-config-select'];
-- let selectedConfigId = null;
-- for (const id of selectorIds) {
-- const el = document.getElementById(id);
-- if (el && el.value !== undefined && el.offsetParent !== null) {
-- // Use the first visible selector's value
-- selectedConfigId = el.value ? parseInt(el.value) : null;
-- break;
-- }
-- }
-- // Fallback: check all selectors for any non-empty value
-- if (selectedConfigId === undefined) {
-- for (const id of selectorIds) {
-- const el = document.getElementById(id);
-- if (el && el.value) { selectedConfigId = parseInt(el.value); break; }
-- }
-- }
--
-- try {
-- await api(`/controls/${streamId}/config`, {
-- method: 'PUT',
-- body: { control_config_id: selectedConfigId !== undefined ? selectedConfigId : null },
-- });
-- toast('Control profile applied — viewers will see the new buttons immediately', 'success');
-- // Refresh status display
-- await loadLiveControlsStatus(streamId);
-- } catch (err) {
-- toast(err.message || 'Failed to apply control profile', 'error');
-- }
--}
--
--
async function showRTMPInstructions(stream) {
document.getElementById('bc-stream-manager').style.display = 'none';
document.getElementById('bc-browser-broadcast').style.display = 'none';
@@@ -1791,7 +1641,6 @@ async function showRTMPInstructions(stream) {
_startRsViewerPoll();
_startRestreamViewerPoll();
syncBroadcastLiveButtonVisibility();
-- loadLiveControlsStatus(stream.id).catch(() => {});
try {
const data = await api(`/streams/${stream.id}/endpoint`);
const ep = data.endpoint || {};
@@@ -1940,7 +1789,6 @@ async function showJSMPEGInstructions(stream) {
_startRsViewerPoll();
_startRestreamViewerPoll();
syncBroadcastLiveButtonVisibility();
-- loadLiveControlsStatus(stream.id).catch(() => {});
try {
const data = await api(`/streams/${stream.id}/endpoint`);
const ep = data.endpoint || {};
@@@ -1997,7 +1845,6 @@ async function showWHIPInstructions(stream) {
_startRsViewerPoll();
_startRestreamViewerPoll();
syncBroadcastLiveButtonVisibility();
-- loadLiveControlsStatus(stream.id).catch(() => {});
const isLocalHost = /^(localhost|127\.|\[::1\])$/.test(location.hostname);
let whipBaseUrl = null;
let openreWhip = null;
ddiff --git a/public/js/controls.js b/public/js/controls.js
iindex 7442df38..0877aae1 100644
---- a/public/js/controls.js
++++ b/public/js/controls.js
@@@ -1,835 +1,11 @@
--/* ═══════════════════════════════════════════════════════════════
-- OpenVibe.Live — Interactive Controls Client (WebSocket)
-- Supports: Keyboard hold (key_down/key_up), Video click,
-- Traditional buttons, ONVIF PTZ cameras
-- ═══════════════════════════════════════════════════════════════ */
--
--let controlWs = null;
--let controlCooldowns = {};
--let onvifCooldowns = {};
--let heldKeys = new Map(); // holdKey → { controlId, command, btnEl } — structured hold state
--let controlSettings = {}; // Channel control settings from server
--let _controlReconnectTimer = null;
--let _controlReconnectDelay = 1000;
--let _controlStreamId = null; // Stream ID for reconnect
--let _hardwareConnected = false; // Whether hardware bridge is online
--let _hardwareStatusKnown = false; // Have we received a hardware_status yet?
--let _controlsHaveContent = false; // Panel has button controls to show
--let _controlsHaveOnvif = false; // Panel has ONVIF camera controls (work without the hardware bridge)
--// currentStreamId is declared in app.js (global scope)
--
--/**
-- * Show/hide the controls panel. Viewers only see controls when the hardware
-- * bridge is online (buttons do nothing otherwise) — but ONVIF camera controls
-- * don't need the bridge, so a panel with cameras stays visible.
-- */
--let _controlsCollapsed = false;
--function _applyControlsVisibility() {
-- // Controls live in a section directly under the player (above the streamer info),
-- // shown only when the watched stream has usable controls (buttons need the hardware
-- // bridge online; ONVIF cameras don't).
-- const show = _controlsHaveContent && !(_hardwareStatusKnown && !_hardwareConnected && !_controlsHaveOnvif);
-- const section = document.getElementById('ch-controls-section');
-- const toggle = document.getElementById('ch-controls-toggle');
-- if (toggle) toggle.style.display = show ? '' : 'none';
-- if (section) section.style.display = (show && !_controlsCollapsed) ? '' : 'none';
-- if (!show) _undockControls();
-- _positionControlsToggle();
-- _syncControlsToggleUI();
--}
--// The collapse/expand toggle lives NEXT TO the dock button (in the controls header)
--// while the section is open, and only relocates into the info bar once the section is
--// hidden — so there's always a visible way to bring the controls back.
--function _positionControlsToggle() {
-- const toggle = document.getElementById('ch-controls-toggle');
-- if (!toggle) return;
-- const btns = document.querySelector('#ch-controls-section .controls-header-btns');
-- const infoRight = document.querySelector('#ch-info-bar .ch-info-bar-right');
-- if (_controlsCollapsed) {
-- if (infoRight && toggle.parentElement !== infoRight) infoRight.insertBefore(toggle, infoRight.firstChild);
-- } else {
-- if (btns && toggle.parentElement !== btns) btns.insertBefore(toggle, btns.firstChild);
-- }
--}
--function _syncControlsToggleUI() {
-- const toggle = document.getElementById('ch-controls-toggle');
-- if (!toggle) return;
-- toggle.classList.toggle('collapsed', _controlsCollapsed);
-- if (_controlsCollapsed) {
-- // In the info bar: a clear "show controls" affordance (distinct from the dock arrow).
-- toggle.innerHTML = '<i class="fa-solid fa-gamepad"></i><i class="fa-solid fa-chevron-down ch-controls-toggle-caret"></i>';
-- toggle.title = 'Show controls';
-- toggle.setAttribute('aria-label', 'Show controls');
-- } else {
-- // In the header next to the dock button: a "hide/collapse" chevron.
-- toggle.innerHTML = '<i class="fa-solid fa-chevron-up"></i>';
-- toggle.title = 'Hide controls';
-- toggle.setAttribute('aria-label', 'Hide controls');
-- }
--}
--// Collapse/expand the under-player controls section.
--function toggleControlsSection() {
-- _controlsCollapsed = !_controlsCollapsed;
-- _positionControlsToggle(); // relocate the toggle BEFORE hiding the section
-- _syncControlsToggleUI();
-- const section = document.getElementById('ch-controls-section');
-- if (section) section.style.display = _controlsCollapsed ? 'none' : '';
--}
--
--/* ── Docked controls: fixed compact bottom bar ──────────────────
-- Moves the live #controls-grid into a fixed bottom bar so viewers can drive controls
-- while still watching + chatting (great on mobile). The grid NODE is moved (not
-- re-created), so all button handlers + live re-renders keep working. */
--let _controlsDocked = false;
--function toggleControlsDock() {
-- const dock = document.getElementById('controls-dock');
-- const dockBody = document.getElementById('controls-dock-body');
-- const panel = document.getElementById('controls-panel');
-- const grid = document.getElementById('controls-grid');
-- const btn = document.getElementById('controls-dock-btn');
-- if (!dock || !dockBody || !panel || !grid) return;
-- _controlsDocked = !_controlsDocked;
-- if (_controlsDocked) {
-- dockBody.appendChild(grid);
-- dock.style.display = '';
-- document.body.classList.add('controls-docked');
-- let ph = document.getElementById('controls-dock-ph');
-- if (!ph) { ph = document.createElement('div'); ph.id = 'controls-dock-ph'; ph.className = 'controls-dock-ph'; panel.appendChild(ph); }
-- ph.innerHTML = '<i class="fa-solid fa-arrow-down"></i> Controls are docked at the bottom of your screen so you can use them while you watch. <button class="btn btn-sm btn-outline" onclick="toggleControlsDock()"><i class="fa-solid fa-arrow-up"></i> Bring back here</button>';
-- if (btn) { btn.innerHTML = '<i class="fa-solid fa-arrow-up"></i>'; btn.title = 'Undock controls (bring back under the player)'; }
-- } else {
-- panel.appendChild(grid);
-- dock.style.display = 'none';
-- document.body.classList.remove('controls-docked');
-- const ph = document.getElementById('controls-dock-ph'); if (ph) ph.remove();
-- if (btn) { btn.innerHTML = '<i class="fa-solid fa-arrow-down"></i>'; btn.title = 'Dock controls to the bottom of the screen'; }
-- }
--}
--function _undockControls() { if (_controlsDocked) toggleControlsDock(); }
--
--/**
-- * Load and display interactive controls for a stream.
-- */
--async function loadStreamControls(streamId) {
-- const panel = document.getElementById('controls-panel');
-- const grid = document.getElementById('controls-grid');
-- currentStreamId = streamId;
--
-- try {
-- const data = await api(`/controls/${streamId}`);
-- const controls = data.controls || [];
-- controlSettings = data.settings || {};
--
-- const hasControls = controls.length > 0;
-- const hasVideoClick = !!controlSettings.video_click_enabled;
--
-- if (!hasControls && !hasVideoClick) {
-- _controlsHaveContent = false;
-- _controlsHaveOnvif = false;
-- _applyControlsVisibility();
-- destroyVideoClickOverlay();
-- return;
-- }
--
-- // Separate ONVIF controls from regular controls
-- const onvifControls = controls.filter(c => c.control_type === 'onvif' && c.camera_id);
-- // Track content for hardware-aware visibility
-- _controlsHaveContent = hasControls;
-- _controlsHaveOnvif = onvifControls.length > 0;
-- // Show panel if there are button controls (gated on hardware presence)
-- _applyControlsVisibility();
-- const regularControls = controls.filter(c => c.control_type !== 'onvif' || !c.camera_id);
--
-- let html = '';
--
-- // Render regular controls (buttons + keyboard hold)
-- if (regularControls.length > 0) {
-- html += '<div class="controls-row regular-controls">';
-- html += regularControls.map(c => {
-- const isKeyboard = c.control_type === 'keyboard';
-- const btnStyle = buildBtnStyle(c);
-- const bindingBadge = c.key_binding ? `<span class="control-keybind">${esc(c.key_binding.toUpperCase())}</span>` : '';
--
-- if (isKeyboard) {
-- // Keyboard control — uses hold detection (mousedown/mouseup + keydown/keyup)
-- const cd = (c.cooldown_ms != null && Number.isFinite(Number(c.cooldown_ms))) ? Number(c.cooldown_ms) : 100;
-- return `
-- <button class="control-btn control-btn-keyboard" data-id="${c.id}" data-cmd="${esc(c.command)}" data-cooldown="${cd}"
-- data-keybind="${esc(c.key_binding || '')}"
-- onmousedown="startKeyHold(${c.id}, '${esc(c.command)}', this)"
-- onmouseup="stopKeyHold(${c.id}, '${esc(c.command)}', this)"
-- onmouseleave="stopKeyHold(${c.id}, '${esc(c.command)}', this)"
-- ontouchstart="startKeyHold(${c.id}, '${esc(c.command)}', this); event.preventDefault()"
-- ontouchend="stopKeyHold(${c.id}, '${esc(c.command)}', this)"
-- ontouchcancel="stopKeyHold(${c.id}, '${esc(c.command)}', this)"
-- onpointercancel="stopKeyHold(${c.id}, '${esc(c.command)}', this)"
-- title="${esc(c.label || c.command)} (hold)" ${btnStyle}>
-- <i class="fa-solid ${esc(c.icon || 'fa-keyboard')}"></i>
-- <span>${esc(c.label || c.command)}</span>
-- ${bindingBadge}
-- </button>
-- `;
-- } else {
-- // Regular button — single click
-- const cd = (c.cooldown_ms != null && Number.isFinite(Number(c.cooldown_ms))) ? Number(c.cooldown_ms) : 100;
-- return `
-- <button class="control-btn" data-id="${c.id}" data-cmd="${esc(c.command)}" data-cooldown="${cd}"
-- data-keybind="${esc(c.key_binding || '')}"
-- onclick="sendControl(${c.id}, '${esc(c.command)}', this, ${cd})"
-- title="${esc(c.label || c.command)}" ${btnStyle}>
-- <i class="fa-solid ${esc(c.icon || 'fa-circle')}"></i>
-- <span>${esc(c.label || c.command)}</span>
-- ${bindingBadge}
-- </button>
-- `;
-- }
-- }).join('');
-- html += '</div>';
-- }
--
-- // Render ONVIF camera controls
-- if (onvifControls.length > 0) {
-- const uniqueCameras = [...new Set(onvifControls.map(c => c.camera_id))];
-- for (const cameraId of uniqueCameras) {
-- const cameraControls = onvifControls.filter(c => c.camera_id === cameraId);
-- try {
-- const cameraData = await api(`/onvif/cameras/${cameraId}`);
-- html += renderOnvifCameraWidget(cameraData, cameraControls);
-- } catch (e) {
-- console.warn(`Failed to load camera ${cameraId}:`, e);
-- }
-- }
-- }
--
-- grid.innerHTML = html;
--
-- // Setup video click overlay if enabled
-- if (hasVideoClick) {
-- setupVideoClickOverlay();
-- } else {
-- destroyVideoClickOverlay();
-- }
--
-- // Connect control WS
-- connectControlWs(streamId);
-- } catch (e) {
-- console.error('Failed to load controls:', e);
-- _controlsHaveContent = false;
-- _applyControlsVisibility();
-- }
--}
--
--/**
-- * Build inline style string for a button (sanitized server-side)
-- */
--function buildBtnStyle(control) {
-- const parts = [];
-- if (control.btn_color) parts.push(`color:${control.btn_color}`);
-- if (control.btn_bg) parts.push(`background:${control.btn_bg}`);
-- if (control.btn_border_color) parts.push(`border-color:${control.btn_border_color}`);
-- return parts.length ? `style="${parts.join(';')}"` : '';
--}
--
--/**
-- * Render ONVIF camera control widget (D-pad + zoom + presets)
-- */
--function renderOnvifCameraWidget(camera, controls) {
-- const id = `onvif-${camera.id}`;
--
-- return `
-- <div class="onvif-widget" id="${id}">
-- <div class="onvif-header">
-- <h3><i class="fa-solid fa-video"></i> ${esc(camera.name)}</h3>
-- <span class="onvif-status ${camera.status === 'connected' ? 'connected' : camera.status === 'unreachable' ? 'unreachable' : 'unknown'}">
-- ${camera.status === 'connected' ? '🟢' : camera.status === 'unreachable' ? '🔴' : '⚪'}
-- </span>
-- </div>
--
-- <div class="onvif-controls">
-- <!-- D-Pad for Pan/Tilt -->
-- <div class="dpad-container">
-- <button class="dpad-btn dpad-up" onclick="sendOnvifCommand(${camera.id}, 'tilt_up', this, 200)" title="Tilt up">
-- <i class="fa-solid fa-arrow-up"></i>
-- </button>
-- <button class="dpad-btn dpad-left" onclick="sendOnvifCommand(${camera.id}, 'pan_left', this, 200)" title="Pan left">
-- <i class="fa-solid fa-arrow-left"></i>
-- </button>
-- <button class="dpad-btn dpad-center" onclick="sendOnvifCommand(${camera.id}, 'stop', this, 0)" title="Stop">
-- <i class="fa-solid fa-circle"></i>
-- </button>
-- <button class="dpad-btn dpad-right" onclick="sendOnvifCommand(${camera.id}, 'pan_right', this, 200)" title="Pan right">
-- <i class="fa-solid fa-arrow-right"></i>
-- </button>
-- <button class="dpad-btn dpad-down" onclick="sendOnvifCommand(${camera.id}, 'tilt_down', this, 200)" title="Tilt down">
-- <i class="fa-solid fa-arrow-down"></i>
-- </button>
-- </div>
--
-- <!-- Zoom Controls -->
-- <div class="zoom-container">
-- <button class="zoom-btn zoom-out" onclick="sendOnvifCommand(${camera.id}, 'zoom_out', this, 200)" title="Zoom out">
-- <i class="fa-solid fa-minus"></i>
-- </button>
-- <input type="range" class="zoom-slider" min="0" max="100" value="50"
-- onchange="setOnvifZoom(${camera.id}, this.value)" title="Zoom">
-- <button class="zoom-btn zoom-in" onclick="sendOnvifCommand(${camera.id}, 'zoom_in', this, 200)" title="Zoom in">
-- <i class="fa-solid fa-plus"></i>
-- </button>
-- </div>
--
-- <!-- Preset Buttons -->
-- <div class="presets-container" id="presets-${camera.id}">
-- <!-- Loaded dynamically -->
-- </div>
-- </div>
-- </div>
-- `;
--}
--
--/**
-- * Load and render camera presets
-- */
--async function loadCameraPresets(cameraId) {
-- try {
-- const data = await api(`/onvif/cameras/${cameraId}/presets`);
-- const presetsDiv = document.getElementById(`presets-${cameraId}`);
-- if (!presetsDiv) return;
--
-- if (!data.presets || data.presets.length === 0) {
-- presetsDiv.innerHTML = '<p style="text-align:center;font-size:0.8em;color:#999;">No presets saved</p>';
-- return;
-- }
--
-- presetsDiv.innerHTML = data.presets.map(preset => `
-- <button class="preset-btn" onclick="gotoOnvifPreset(${cameraId}, ${preset.id}, this)">
-- ${esc(preset.name)}
-- </button>
-- `).join('');
-- } catch (e) {
-- console.warn(`Failed to load presets for camera ${cameraId}:`, e);
-- }
--}
--
--/* ═══════════════════════════════════════════════════════════════
-- Video Click Overlay
-- ═══════════════════════════════════════════════════════════════ */
--
--function setupVideoClickOverlay() {
-- let overlay = document.getElementById('video-click-overlay');
-- if (overlay) return; // Already exists
--
-- const container = document.getElementById('video-container');
-- if (!container) return;
--
-- overlay = document.createElement('div');
-- overlay.id = 'video-click-overlay';
-- overlay.className = 'video-click-overlay';
-- overlay.title = 'Click to send coordinates';
--
-- overlay.addEventListener('click', (e) => {
-- const rect = overlay.getBoundingClientRect();
-- const x = (e.clientX - rect.left) / rect.width;
-- const y = (e.clientY - rect.top) / rect.height;
-- sendVideoClick(x, y, e);
-- });
--
-- overlay.addEventListener('touchend', (e) => {
-- if (e.changedTouches.length === 0) return;
-- const touch = e.changedTouches[0];
-- const rect = overlay.getBoundingClientRect();
-- const x = (touch.clientX - rect.left) / rect.width;
-- const y = (touch.clientY - rect.top) / rect.height;
-- sendVideoClick(x, y, e);
-- });
--
-- container.appendChild(overlay);
--}
--
--function destroyVideoClickOverlay() {
-- const overlay = document.getElementById('video-click-overlay');
-- if (overlay) overlay.remove();
--}
--
--function sendVideoClick(x, y, event) {
-- if (!controlWs || controlWs.readyState !== WebSocket.OPEN) return;
--
-- const rateLimitMs = parseInt(controlSettings.video_click_rate_limit_ms || controlSettings.control_rate_limit_ms || 100);
-- const key = 'video-click';
-- if (controlCooldowns[key]) return;
--
-- controlWs.send(JSON.stringify({
-- type: 'video_click',
-- x: Math.round(Math.max(0, Math.min(1, x)) * 10000) / 10000,
-- y: Math.round(Math.max(0, Math.min(1, y)) * 10000) / 10000,
-- streamId: currentStreamId,
-- }));
--
-- // Visual ripple at click position
-- showClickRipple(event, x, y);
--
-- // Cooldown
-- if (rateLimitMs > 0) {
-- controlCooldowns[key] = true;
-- setTimeout(() => { delete controlCooldowns[key]; }, rateLimitMs);
-- }
--}
--
--function showClickRipple(event, x, y) {
-- const overlay = document.getElementById('video-click-overlay');
-- if (!overlay) return;
--
-- const ripple = document.createElement('div');
-- ripple.className = 'click-ripple';
-- ripple.style.left = (x * 100) + '%';
-- ripple.style.top = (y * 100) + '%';
-- overlay.appendChild(ripple);
-- setTimeout(() => ripple.remove(), 600);
--}
--
--/* ═══════════════════════════════════════════════════════════════
-- Keyboard Hold Detection (key_down / key_up)
-- ═══════════════════════════════════════════════════════════════ */
--
--function _checkControlWs() {
-- if (!controlWs || controlWs.readyState !== WebSocket.OPEN) {
-- toast('Controls reconnecting… please wait', 'error');
-- return false;
-- }
-- return true;
--}
--
--function startKeyHold(controlId, command, btnEl) {
-- if (!_checkControlWs()) return;
--
-- const holdKey = `hold-${controlId}`;
-- if (heldKeys.has(holdKey)) return; // Already holding
-- heldKeys.set(holdKey, { controlId, command, btnEl });
--
-- btnEl.classList.add('key-held');
--
-- controlWs.send(JSON.stringify({
-- type: 'key_down',
-- control_id: controlId,
-- command: command,
-- streamId: currentStreamId,
-- }));
--}
--
--function stopKeyHold(controlId, command, btnEl) {
-- const holdKey = `hold-${controlId}`;
-- if (!heldKeys.has(holdKey)) return;
-- heldKeys.delete(holdKey);
--
-- btnEl.classList.remove('key-held');
--
-- if (controlWs && controlWs.readyState === WebSocket.OPEN) {
-- controlWs.send(JSON.stringify({
-- type: 'key_up',
-- control_id: controlId,
-- command: command,
-- streamId: currentStreamId,
-- }));
-- }
--}
--
--/**
-- * Force-release ALL currently held keys.
-- * Called on: WS disconnect, tab blur, visibility hidden, page unload, stream switch.
-- */
--function _forceReleaseAllHeldKeys() {
-- if (heldKeys.size === 0) return;
-- for (const [holdKey, state] of heldKeys) {
-- if (state.btnEl) state.btnEl.classList.remove('key-held');
-- // Best-effort send key_up — WS may already be closed
-- if (controlWs && controlWs.readyState === WebSocket.OPEN) {
-- try {
-- controlWs.send(JSON.stringify({
-- type: 'key_up',
-- control_id: state.controlId,
-- command: state.command,
-- streamId: currentStreamId,
-- }));
-- } catch { /* WS send failed — hardware cleanup is server-side */ }
-- }
-- }
-- heldKeys.clear();
-- document.querySelectorAll('.control-btn-keyboard.key-held').forEach(b => b.classList.remove('key-held'));
--}
--
--/**
-- * Connect to the control WebSocket with auto-reconnect.
-- */
--function connectControlWs(streamId) {
-- destroyControlWs();
-- _controlStreamId = streamId;
-- _controlReconnectDelay = 1000;
--
-- _connectControlWsInner(streamId);
--}
--
--function _connectControlWsInner(streamId) {
-- if (_controlReconnectTimer) { clearTimeout(_controlReconnectTimer); _controlReconnectTimer = null; }
--
-- const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
-- const params = new URLSearchParams();
-- params.set('mode', 'viewer');
-- params.set('stream', String(streamId));
--
-- const token = localStorage.getItem('token');
-- if (token) params.set('token', token);
--
-- const wsUrl = `${protocol}://${window.location.host}/ws/control?${params.toString()}`;
--
-- controlWs = new WebSocket(wsUrl);
--
-- controlWs.onopen = () => {
-- console.log('[Controls] WebSocket connected');
-- _controlReconnectDelay = 1000; // Reset backoff on successful connect
-- _updateControlConnectionUI(true);
-- };
--
-- controlWs.onmessage = (e) => {
-- try {
-- const msg = JSON.parse(e.data);
-- handleControlMessage(msg);
-- } catch (err) {
-- console.warn('Failed to parse control message:', err);
-- }
-- };
--
-- controlWs.onerror = (err) => {
-- console.warn('[Controls] WS error:', err);
-- };
--
-- controlWs.onclose = (e) => {
-- console.log(`[Controls] WS closed (code=${e.code})`);
-- // Force-release held keys — hardware bridge needs key_up cleanup
-- _forceReleaseAllHeldKeys();
-- controlWs = null;
-- _updateControlConnectionUI(false);
--
-- // Auto-reconnect if we still have a target stream
-- if (_controlStreamId) {
-- _controlReconnectTimer = setTimeout(() => {
-- if (_controlStreamId) {
-- console.log(`[Controls] Reconnecting (delay=${_controlReconnectDelay}ms)...`);
-- _connectControlWsInner(_controlStreamId);
-- }
-- }, _controlReconnectDelay * (0.75 + Math.random() * 0.5));
-- // Exponential backoff with jitter: ~1s → 2s → 4s → 8s → max 15s
-- _controlReconnectDelay = Math.min(_controlReconnectDelay * 2, 15000);
-- }
-- };
--}
--
--function destroyControlWs() {
-- // Stop reconnect
-- _controlStreamId = null;
-- if (_controlReconnectTimer) { clearTimeout(_controlReconnectTimer); _controlReconnectTimer = null; }
--
-- // Force-release any held keys before closing WS
-- _forceReleaseAllHeldKeys();
--
-- if (controlWs) {
-- controlWs.close();
-- controlWs = null;
-- }
-- controlCooldowns = {};
-- onvifCooldowns = {};
-- _hardwareConnected = false;
-- _updateControlConnectionUI(false);
--}
--
--/**
-- * Update the controls panel header to show connection + hardware status.
-- */
--function _updateControlConnectionUI(wsConnected) {
-- // Compact status indicator (no redundant "Controls" label — the tab is the title).
-- const el = document.getElementById('controls-status');
-- if (!el) return;
-- if (!wsConnected) {
-- el.innerHTML = '<span class="control-dot control-dot-disconnected" title="Reconnecting…">⚫</span> <span class="controls-status-txt">Reconnecting…</span>';
-- } else if (_hardwareConnected) {
-- el.innerHTML = '<span class="control-dot control-dot-online" title="Hardware connected">🟢</span> <span class="controls-status-txt">Hardware connected</span>';
-- } else {
-- el.innerHTML = '<span class="control-dot control-dot-waiting" title="Waiting for hardware bridge">🟡</span> <span class="controls-status-txt">Waiting for hardware…</span>';
-- }
--}
--
--/* ── Send regular command ─────────────────────────────────────── */
--function sendControl(controlId, command, btnEl, cooldownMs) {
-- if (!_checkControlWs()) return;
--
-- const globalRateLimitMs = parseInt(controlSettings.control_rate_limit_ms || 0) || 0;
-- // Support 0ms cooldown: only default to 100 if explicitly null/undefined/NaN
-- const rawCooldown = (cooldownMs != null && Number.isFinite(Number(cooldownMs))) ? Number(cooldownMs) : 100;
-- const effectiveCooldownMs = Math.max(rawCooldown, globalRateLimitMs);
-- const cooldownKey = `cmd-${controlId}`;
-- const globalCooldownKey = 'cmd-global';
-- if (controlCooldowns[cooldownKey] || controlCooldowns[globalCooldownKey]) return;
--
-- controlWs.send(JSON.stringify({
-- type: 'command',
-- control_id: controlId,
-- command: command,
-- streamId: currentStreamId,
-- }));
--
-- // Apply cooldown
-- btnEl.classList.add('on-cooldown');
-- controlCooldowns[cooldownKey] = true;
-- controlCooldowns[globalCooldownKey] = true;
--
-- setTimeout(() => {
-- btnEl.classList.remove('on-cooldown');
-- delete controlCooldowns[cooldownKey];
-- delete controlCooldowns[globalCooldownKey];
-- }, effectiveCooldownMs);
--}
--
--/* ── Send ONVIF command ──────────────────────────────────────── */
--function sendOnvifCommand(cameraId, movement, btnEl, cooldownMs) {
-- if (!_checkControlWs()) return;
--
-- // Check cooldown
-- const cooldownKey = `onvif-${cameraId}-${movement}`;
-- if (onvifCooldowns[cooldownKey]) return;
--
-- controlWs.send(JSON.stringify({
-- type: 'command',
-- streamId: currentStreamId,
-- isOnvif: true,
-- cameraId: cameraId,
-- movement: movement,
-- }));
--
-- // Apply cooldown
-- if (btnEl) {
-- btnEl.classList.add('on-cooldown');
-- }
-- onvifCooldowns[cooldownKey] = true;
--
-- setTimeout(() => {
-- if (btnEl) {
-- btnEl.classList.remove('on-cooldown');
-- }
-- delete onvifCooldowns[cooldownKey];
-- }, cooldownMs || 200);
--}
--
--/**
-- * Adjust zoom level (sends discrete zoom_in/zoom_out as slider moves)
-- */
--let lastZoomValue = 50;
--function setOnvifZoom(cameraId, value) {
-- const numValue = parseInt(value);
-- if (numValue > lastZoomValue) {
-- sendOnvifCommand(cameraId, 'zoom_in', null, 100);
-- } else if (numValue < lastZoomValue) {
-- sendOnvifCommand(cameraId, 'zoom_out', null, 100);
-- }
-- lastZoomValue = numValue;
--}
--
--/**
-- * Go to a saved preset position
-- */
--function gotoOnvifPreset(cameraId, presetId, btnEl) {
-- if (!_checkControlWs()) return;
--
-- const cooldownKey = `preset-${cameraId}-${presetId}`;
-- if (onvifCooldowns[cooldownKey]) return;
--
-- controlWs.send(JSON.stringify({
-- type: 'command',
-- streamId: currentStreamId,
-- isOnvif: true,
-- cameraId: cameraId,
-- movement: 'preset',
-- presetId: presetId,
-- }));
--
-- btnEl.classList.add('on-cooldown');
-- onvifCooldowns[cooldownKey] = true;
--
-- setTimeout(() => {
-- btnEl.classList.remove('on-cooldown');
-- delete onvifCooldowns[cooldownKey];
-- }, 500);
--}
--
--/* ── Handle incoming control messages ─────────────────────────── */
--function handleControlMessage(msg) {
-- switch (msg.type) {
-- case 'command_executed':
-- showControlActivity(msg.command, msg.by);
-- break;
-- case 'onvif_activity':
-- showOnvifActivity(msg.camera_name, msg.movement, msg.by);
-- break;
-- case 'hardware_status':
-- _hardwareConnected = !!msg.connected;
-- _hardwareStatusKnown = true;
-- _updateControlConnectionUI(true);
-- _applyControlsVisibility();
-- break;
-- case 'key_held':
-- highlightHeldButton(msg.command, true);
-- break;
-- case 'key_released':
-- highlightHeldButton(msg.command, false);
-- break;
-- case 'video_click_activity':
-- showRemoteClickRipple(msg.x, msg.y, msg.by);
-- break;
-- case 'error':
-- toast(msg.message || 'Control error', 'error');
-- break;
-- case 'cooldown':
-- break;
-- case 'ok':
-- break;
-- }
--}
--
--function showControlActivity(command, username) {
-- const btn = document.querySelector(`.control-btn[data-cmd="${command}"]`);
-- if (!btn) return;
--
-- btn.style.borderColor = 'var(--accent)';
-- btn.style.boxShadow = '0 0 8px rgba(139,92,246,0.4)';
--
-- setTimeout(() => {
-- btn.style.borderColor = '';
-- btn.style.boxShadow = '';
-- }, 300);
--}
--
--function highlightHeldButton(command, held) {
-- const btn = document.querySelector(`.control-btn-keyboard[data-cmd="${command}"]`);
-- if (!btn) return;
-- if (held) {
-- btn.classList.add('remote-held');
-- } else {
-- btn.classList.remove('remote-held');
-- }
--}
--
--function showRemoteClickRipple(x, y, username) {
-- const overlay = document.getElementById('video-click-overlay');
-- if (!overlay) return;
--
-- const ripple = document.createElement('div');
-- ripple.className = 'click-ripple click-ripple-remote';
-- ripple.style.left = (x * 100) + '%';
-- ripple.style.top = (y * 100) + '%';
-- overlay.appendChild(ripple);
-- setTimeout(() => ripple.remove(), 600);
--}
--
--function showOnvifActivity(cameraName, movement, username) {
-- const widget = document.querySelector('.onvif-widget');
-- if (!widget) return;
--
-- widget.style.borderColor = 'var(--accent)';
-- widget.style.boxShadow = '0 0 12px rgba(139,92,246,0.5)';
--
-- setTimeout(() => {
-- widget.style.borderColor = '';
-- widget.style.boxShadow = '';
-- }, 300);
--}
--
--/* (hardware status handled by _updateControlConnectionUI) */
--
--/* ── Keyboard controls ────────────────────────────────────────── */
--// True when a keypress must NOT be captured as a hardware control: modifier
--// combos (copy/paste/cut/select-all/devtools) and any editable focus (chat
--// input, a selected message, contenteditable). Fixes Ctrl/Cmd+C on a chat
--// message triggering a control + the "Hardware not connected" toast.
--function _controlKeyShouldIgnore(e) {
-- if (e.ctrlKey || e.metaKey || e.altKey) return true;
-- const t = e.target;
-- if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return true;
-- const a = document.activeElement;
-- if (a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA' || a.tagName === 'SELECT' || a.isContentEditable)) return true;
-- return false;
--}
--
--document.addEventListener('keydown', (e) => {
-- if (!currentStreamId) return;
-- if (_controlKeyShouldIgnore(e)) return;
-- if (e.repeat) return; // Ignore OS key repeat for hold detection
--
-- // Check keyboard-bound controls first
-- const key = e.key.toLowerCase();
-- const btn = document.querySelector(`.control-btn-keyboard[data-keybind="${key}"]`);
-- if (btn) {
-- e.preventDefault();
-- const id = parseInt(btn.dataset.id);
-- const cmd = btn.dataset.cmd;
-- startKeyHold(id, cmd, btn);
-- return;
-- }
--
-- // Also support regular button keybinds (single press)
-- const regularBtn = document.querySelector(`.control-btn:not(.control-btn-keyboard)[data-keybind="${key}"]`);
-- if (regularBtn) {
-- e.preventDefault();
-- const id = parseInt(regularBtn.dataset.id);
-- const cmd = regularBtn.dataset.cmd;
-- const cd = (regularBtn.dataset.cooldown != null && Number.isFinite(Number(regularBtn.dataset.cooldown))) ? Number(regularBtn.dataset.cooldown) : 500;
-- sendControl(id, cmd, regularBtn, cd);
-- return;
-- }
--
-- // ONVIF keyboard fallback
-- const cameraId = getCurrentONVIFCamera();
-- if (!cameraId) return;
--
-- const keyMap = {
-- 'arrowup': 'tilt_up',
-- 'arrowdown': 'tilt_down',
-- 'arrowleft': 'pan_left',
-- 'arrowright': 'pan_right',
-- '[': 'zoom_out',
-- ']': 'zoom_in',
-- ' ': 'stop',
-- };
--
-- const movement = keyMap[key];
-- if (!movement) return;
--
-- e.preventDefault();
-- sendOnvifCommand(cameraId, movement, null, 200);
--});
--
--document.addEventListener('keyup', (e) => {
-- if (!currentStreamId) return;
-- if (_controlKeyShouldIgnore(e)) return;
--
-- const key = e.key.toLowerCase();
-- const btn = document.querySelector(`.control-btn-keyboard[data-keybind="${key}"]`);
-- if (btn) {
-- e.preventDefault();
-- const id = parseInt(btn.dataset.id);
-- const cmd = btn.dataset.cmd;
-- stopKeyHold(id, cmd, btn);
-- }
--});
--
--function getCurrentONVIFCamera() {
-- const widget = document.querySelector('.onvif-widget');
-- if (!widget) return null;
-- const id = widget.id; // "onvif-{cameraId}"
-- return parseInt(id.split('-')[1]);
--}
--
--/* ═══════════════════════════════════════════════════════════════
-- Hold-key lifecycle: force-release on blur / visibility / unload
-- ═══════════════════════════════════════════════════════════════ */
--window.addEventListener('blur', () => _forceReleaseAllHeldKeys());
--document.addEventListener('visibilitychange', () => {
-- if (document.visibilityState === 'hidden') _forceReleaseAllHeldKeys();
--});
--window.addEventListener('beforeunload', () => _forceReleaseAllHeldKeys());
++'use strict';
++/* Live's robot controls are gone (plan T15: robots are OpenVibe.Bot's; a bound channel shows the robot's panel and chat
++ drives it through Bot). This file stays for one release so a tab still running the previous client, whose feature
++ loader asks for /js/controls.js, gets its functions as no-ops instead of a failed load. It goes with the controls
++ tables' contract migration. */
++(() => {
++ const noop = () => {};
++ for (const name of ['loadStreamControls', 'destroyControlWs', 'toggleControlsSection', 'toggleControlsDock']) {
++ if (typeof window[name] !== 'function') window[name] = noop;
++ }
++})();
ddiff --git a/public/js/dashboard.js b/public/js/dashboard.js
iindex 2fe65550..20c41edb 100644
---- a/public/js/dashboard.js
++++ b/public/js/dashboard.js
@@@ -14,7 +14,7 @@ const _DASH_TAB_CARDS = {
home: ['dash-card-profile', 'dash-card-ai-derivation', 'dash-card-offline'],
chatai: ['dash-aibot-card', 'dash-card-overlay', 'dash-card-chatlogs'],
moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderation
-- controls: ['dash-card-controls', 'dash-card-bot-embed', 'dash-card-cameras', 'dash-card-tokens'],
++ controls: ['dash-card-bot-embed', 'dash-card-tokens'],
money: ['dash-card-bucks', 'dash-card-goals', 'dash-powerchat-card'],
points: ['dash-card-points-config', 'dash-card-rewards', 'dash-card-redemptions'],
content: [], // content cards go into sub-grids (see _DASH_CONTENT_SUBCARDS)
@@@ -97,7 +97,7 @@ window.switchDashTab = switchDashTab;
function _call(name) { try { if (typeof window[name] === 'function') window[name](); } catch (e) { console.warn('[dash]', name, e); } }
window._dashTabLoaders.home = () => { _call('loadSettingsProfile'); _call('loadAiDerivationSetting'); _call('loadSettingsOffline'); _call('loadDashFunds'); };
window._dashTabLoaders.chatai = () => { _call('updateDashObsOverlayUrl'); _call('loadAiViewers'); };
--window._dashTabLoaders.controls = () => { _call('loadDashConfigs'); _call('loadBotEmbedSetting'); _call('loadControlSettings'); _call('loadDashboardCameras'); _call('loadDashTokens'); };
++window._dashTabLoaders.controls = () => { _call('loadBotEmbedSetting'); _call('loadDashTokens'); };
window._dashTabLoaders.money = () => { _call('loadDashFunds'); _call('loadDashGoals'); _call('loadPowerchatStatus'); };
window._dashTabLoaders.points = () => { _call('loadDashPointsConfig'); _call('loadDashRewards'); _call('loadDashRedemptions'); };
window._dashTabLoaders.content = () => { switchDashContentTab('videos', document.querySelector('#dash-content-subtabs .ch-tab[data-cdtab="videos"]')); _call('loadDashAiMomentsCount'); };
@@@ -267,376 +267,6 @@ function showEndpointInfo(stream) {
el.textContent = info;
}
--/* ── Controls Manager ─────────────────────────────────────────── */
--/* Per-stream ad-hoc controls were removed — all controls now come from a Control
-- Profile assigned to the stream/channel. loadDashControls is kept as a no-op so
-- older call sites don't break; profile changes refresh via loadDashConfigs(). */
--function loadDashControls() { /* removed: controls are profile-driven */ }
--
--async function loadControlSettings() {
-- try {
-- const data = await api('/controls/settings/channel');
-- const modeEl = document.getElementById('dash-control-mode');
-- const rateEl = document.getElementById('dash-control-rate-limit');
-- const anonEl = document.getElementById('dash-anon-controls');
-- const videoClickEl = document.getElementById('dash-video-click');
-- const whitelistSection = document.getElementById('dash-control-whitelist-section');
-- if (modeEl) modeEl.value = data.control_mode || 'open';
-- if (rateEl) rateEl.value = data.control_rate_limit_ms || 100;
-- if (anonEl) anonEl.checked = data.anon_controls_enabled !== false;
-- if (videoClickEl) videoClickEl.checked = !!data.video_click_enabled;
-- const videoClickRateEl = document.getElementById('dash-video-click-rate-limit');
-- if (videoClickRateEl) videoClickRateEl.value = data.video_click_rate_limit_ms || 0;
-- if (whitelistSection) whitelistSection.style.display = data.control_mode === 'whitelist' ? '' : 'none';
-- if (data.control_mode === 'whitelist') loadControlWhitelist();
-- } catch { /* silent — settings panel is optional */ }
--}
--
--async function updateControlSettings() {
-- try {
-- const mode = document.getElementById('dash-control-mode')?.value || 'open';
-- const rate = parseInt(document.getElementById('dash-control-rate-limit')?.value) || 100;
-- const anon = document.getElementById('dash-anon-controls')?.checked ?? true;
-- const videoClick = document.getElementById('dash-video-click')?.checked ?? false;
-- const videoClickRate = parseInt(document.getElementById('dash-video-click-rate-limit')?.value) || 0;
-- await api('/controls/settings/channel', {
-- method: 'PUT',
-- body: {
-- control_mode: mode,
-- anon_controls_enabled: anon,
-- control_rate_limit_ms: rate,
-- video_click_enabled: videoClick,
-- video_click_rate_limit_ms: videoClickRate,
-- }
-- });
-- const whitelistSection = document.getElementById('dash-control-whitelist-section');
-- if (whitelistSection) whitelistSection.style.display = mode === 'whitelist' ? '' : 'none';
-- if (mode === 'whitelist') loadControlWhitelist();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function loadControlWhitelist() {
-- const container = document.getElementById('dash-control-whitelist');
-- if (!container) return;
-- try {
-- const data = await api('/controls/whitelist');
-- const list = data.whitelist || [];
-- if (!list.length) {
-- container.innerHTML = '<p class="muted" style="font-size:0.85rem">No users whitelisted</p>';
-- return;
-- }
-- container.innerHTML = list.map(u => `
-- <div style="display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid var(--border)">
-- <span>${esc(u.display_name || u.username)}</span>
-- <span class="muted" style="flex:1;font-size:0.8rem">@${esc(u.username)}</span>
-- <button class="btn btn-small btn-danger" onclick="removeFromControlWhitelist(${u.id})"><i class="fa-solid fa-times"></i></button>
-- </div>
-- `).join('');
-- } catch { container.innerHTML = '<p class="muted">Failed to load whitelist</p>'; }
--}
--
--async function addToControlWhitelist() {
-- const input = document.getElementById('dash-whitelist-username');
-- const username = input?.value.trim();
-- if (!username) return toast('Enter a username', 'error');
-- try {
-- await api('/controls/whitelist', { method: 'POST', body: { username } });
-- toast(`${username} added to whitelist`, 'success');
-- if (input) input.value = '';
-- loadControlWhitelist();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function removeFromControlWhitelist(id) {
-- try {
-- await api(`/controls/whitelist/${id}`, { method: 'DELETE' });
-- toast('Removed from whitelist', 'success');
-- loadControlWhitelist();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--/* ── Control Config Management ────────────────────────────────── */
--
--function openClonePresetModal() {
-- const modal = document.getElementById('clone-preset-config-modal');
-- if (!modal) return;
-- modal.style.display = '';
-- // Fetch presets from backend or use static list if needed
-- fetch('/api/controls/presets', { credentials: 'same-origin' })
-- .then(r => r.json())
-- .then(data => {
-- const presets = data.presets || [];
-- const select = document.getElementById('clone-preset-select');
-- select.innerHTML = presets.map(p => `<option value="${p.id}" data-name="${esc(p.name)}" data-desc="${esc(p.description||'')}">${esc(p.name)}</option>`).join('');
-- if (presets.length) {
-- select.value = presets[0].id;
-- document.getElementById('clone-preset-name').value = presets[0].name;
-- document.getElementById('clone-preset-desc').value = presets[0].description || '';
-- }
-- });
-- // Update name/desc fields on preset change
-- document.getElementById('clone-preset-select').onchange = function() {
-- const opt = this.options[this.selectedIndex];
-- document.getElementById('clone-preset-name').value = opt.getAttribute('data-name') || '';
-- document.getElementById('clone-preset-desc').value = opt.getAttribute('data-desc') || '';
-- };
--}
--
--// closeModal() lives in app.js — every modal on the site uses it, not only the dashboard's.
--
--async function doClonePresetConfig() {
-- const select = document.getElementById('clone-preset-select');
-- const presetId = select?.value;
-- const name = document.getElementById('clone-preset-name')?.value.trim();
-- const desc = document.getElementById('clone-preset-desc')?.value.trim() || '';
-- if (!presetId || !name) return toast('Select a preset and enter a name', 'error');
-- try {
-- // Get preset buttons
-- const preset = await fetch(`/api/controls/presets/${presetId}`).then(r => r.json());
-- const buttons = preset.buttons || [];
-- // Create new config
-- const res = await api('/controls/configs', { method: 'POST', body: { name, description: desc } });
-- const newConfigId = res.id || res.config_id || res.config?.id;
-- if (!newConfigId) throw new Error('Failed to create config');
-- // Add all buttons
-- for (const btn of buttons) {
-- await api(`/controls/configs/${newConfigId}/buttons`, {
-- method: 'POST',
-- body: {
-- command: btn.command,
-- label: btn.label,
-- icon: btn.icon,
-- control_type: btn.control_type,
-- key_binding: btn.key_binding,
-- cooldown_ms: btn.cooldown_ms,
-- btn_color: btn.btn_color,
-- btn_bg: btn.btn_bg,
-- btn_border_color: btn.btn_border_color,
-- }
-- });
-- }
-- toast('Preset cloned!', 'success');
-- closeModal();
-- loadDashConfigs();
-- } catch (e) { toast(e.message || 'Failed to clone preset', 'error'); }
--}
--let editingConfigId = null;
--
--async function loadDashConfigs() {
-- const list = document.getElementById('dash-config-list');
-- if (!list) return;
-- try {
-- const data = await api('/controls/configs');
-- const configs = data.configs || [];
-- const settingsData = await api('/controls/settings/channel').catch(() => ({}));
-- const activeConfigId = settingsData.active_control_config_id;
--
-- if (!configs.length) {
-- list.innerHTML = '<p class="muted" style="font-size:0.85rem">No control profiles yet. Create one to set up reusable control buttons.</p>';
-- return;
-- }
-- list.innerHTML = configs.map(c => {
-- const isActive = c.id === activeConfigId;
-- return `
-- <div style="display:flex;align-items:center;gap:8px;padding:8px;margin-bottom:6px;background:var(--bg-hover);border-radius:var(--radius);border:1px solid ${isActive ? 'var(--accent)' : 'var(--border)'}">
-- <div style="flex:1;min-width:0">
-- <div style="font-weight:600;font-size:0.9rem">${esc(c.name)} <span class="muted" style="font-size:0.75rem">(${c.button_count} buttons)</span></div>
-- ${c.description ? `<div class="muted" style="font-size:0.8rem">${esc(c.description)}</div>` : ''}
-- </div>
-- ${isActive ? '<span style="font-size:0.75rem;padding:2px 6px;background:var(--accent);color:#000;border-radius:4px;font-weight:700">ACTIVE</span>' : ''}
-- <button class="btn btn-small ${isActive ? 'btn-outline' : 'btn-primary'}" onclick="${isActive ? 'deactivateConfig()' : `activateConfig(${c.id})`}" title="${isActive ? 'Deactivate' : 'Set as active'}">
-- <i class="fa-solid ${isActive ? 'fa-circle-xmark' : 'fa-circle-check'}"></i>
-- </button>
-- <button class="btn btn-small btn-outline" onclick="editConfig(${c.id})" title="Edit buttons">
-- <i class="fa-solid fa-pen"></i>
-- </button>
-- <button class="btn btn-small btn-danger" onclick="deleteConfig(${c.id})" title="Delete profile">
-- <i class="fa-solid fa-trash"></i>
-- </button>
-- </div>`;
-- }).join('');
--
-- // Also populate the bridge script config selector
-- const bridgeSelect = document.getElementById('dash-bridge-config-select');
-- if (bridgeSelect) {
-- bridgeSelect.innerHTML = '<option value="">Select a control profile...</option>' +
-- configs.map(c => `<option value="${c.id}">${esc(c.name)} (${c.button_count} buttons)</option>`).join('');
-- }
-- } catch { list.innerHTML = '<p class="muted">Failed to load configs</p>'; }
--}
--
--async function doCreateConfig() {
-- const name = document.getElementById('modal-config-name')?.value.trim();
-- const desc = document.getElementById('modal-config-desc')?.value.trim() || '';
-- if (!name) return toast('Profile name is required', 'error');
-- try {
-- await api('/controls/configs', { method: 'POST', body: { name, description: desc } });
-- toast('Profile created', 'success');
-- closeModal();
-- loadDashConfigs();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function activateConfig(configId) {
-- try {
-- const res = await api(`/controls/configs/${configId}/activate`, { method: 'POST' });
-- const n = res.applied_to_streams || 0;
-- toast(n > 0 ? `Profile activated & applied to ${n} live stream${n > 1 ? 's' : ''}` : 'Profile activated — will apply to your next stream', 'success');
-- loadDashConfigs();
-- loadDashControls();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function deactivateConfig() {
-- try {
-- await api('/controls/configs/deactivate', { method: 'POST' });
-- toast('Profile deactivated — controls cleared from live streams', 'success');
-- loadDashConfigs();
-- loadDashControls();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function deleteConfig(configId) {
-- if (!confirm('Delete this control profile and all its buttons?')) return;
-- try {
-- await api(`/controls/configs/${configId}`, { method: 'DELETE' });
-- toast('Profile deleted', 'success');
-- if (editingConfigId === configId) closeConfigEditor();
-- loadDashConfigs();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function editConfig(configId) {
-- editingConfigId = configId;
-- const editor = document.getElementById('dash-config-editor');
-- if (!editor) return;
-- editor.style.display = '';
--
-- try {
-- const data = await api(`/controls/configs/${configId}`);
-- const config = data.config;
-- const buttons = data.buttons || [];
--
-- document.getElementById('dash-config-editor-title').textContent = `Editing: ${config.name}`;
--
-- const list = document.getElementById('dash-config-buttons-list');
-- if (!buttons.length) {
-- list.innerHTML = '<p class="muted" style="font-size:0.85rem">No buttons yet. Click "Add Button" to start building your control layout.</p>';
-- return;
-- }
--
-- list.innerHTML = buttons.map(b => {
-- const style = [];
-- if (b.btn_color) style.push(`color:${b.btn_color}`);
-- if (b.btn_bg) style.push(`background:${b.btn_bg}`);
-- if (b.btn_border_color) style.push(`border-color:${b.btn_border_color}`);
-- const styleAttr = style.length ? ` style="${style.join(';')}"` : '';
-- return `
-- <div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)">
-- <span class="control-btn" style="min-width:auto;padding:6px 10px;pointer-events:none;${style.join(';')}">
-- <i class="fa-solid ${esc(b.icon || 'fa-gamepad')}"></i>
-- </span>
-- <div style="flex:1;min-width:0">
-- <div style="font-weight:600;font-size:0.85rem">${esc(b.label)}</div>
-- <div class="muted" style="font-size:0.75rem">${esc(b.command)} &middot; ${b.control_type} ${b.key_binding ? '&middot; [' + esc(b.key_binding.toUpperCase()) + ']' : ''} &middot; ${b.cooldown_ms}ms</div>
-- </div>
-- <button class="btn btn-small btn-danger" onclick="deleteConfigButton(${configId}, ${b.id})">
-- <i class="fa-solid fa-trash"></i>
-- </button>
-- </div>`;
-- }).join('');
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--function closeConfigEditor() {
-- editingConfigId = null;
-- const editor = document.getElementById('dash-config-editor');
-- if (editor) editor.style.display = 'none';
--}
--
--async function doAddConfigButton() {
-- if (!editingConfigId) return toast('No profile selected', 'error');
-- const command = document.getElementById('modal-cfgbtn-cmd')?.value.trim();
-- const label = document.getElementById('modal-cfgbtn-label')?.value.trim();
-- const icon = document.getElementById('modal-cfgbtn-icon')?.value.trim() || 'fa-gamepad';
-- const type = document.getElementById('modal-cfgbtn-type')?.value || 'button';
-- const keybind = document.getElementById('modal-cfgbtn-keybind')?.value.trim() || '';
-- const cooldown = parseFloat(document.getElementById('modal-cfgbtn-cooldown')?.value) || 0.5;
-- const btnColor = document.getElementById('modal-cfgbtn-color')?.value.trim() || '';
-- const btnBg = document.getElementById('modal-cfgbtn-bg')?.value.trim() || '';
-- const btnBorder = document.getElementById('modal-cfgbtn-border')?.value.trim() || '';
--
-- if (!command) return toast('Command is required', 'error');
--
-- try {
-- await api(`/controls/configs/${editingConfigId}/buttons`, {
-- method: 'POST',
-- body: {
-- command,
-- label: label || command,
-- icon,
-- control_type: type,
-- key_binding: keybind,
-- cooldown_ms: Math.round(cooldown * 1000),
-- btn_color: btnColor,
-- btn_bg: btnBg,
-- btn_border_color: btnBorder,
-- }
-- });
-- toast('Button added', 'success');
-- closeModal();
-- editConfig(editingConfigId);
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function deleteConfigButton(configId, buttonId) {
-- try {
-- await api(`/controls/configs/${configId}/buttons/${buttonId}`, { method: 'DELETE' });
-- toast('Button removed', 'success');
-- editConfig(configId);
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--/* doAddControl / deleteControl removed — controls are now defined only in Control
-- Profiles (see the profile editor: addConfigButton / deleteConfigButton). */
--
--/* ── Bridge Script Downloads ──────────────────────────────────── */
--async function downloadDashBridgeScript(type) {
-- const select = document.getElementById('dash-bridge-config-select');
-- const configId = select?.value;
-- if (!configId) return toast('Select a control profile first', 'error');
-- try {
-- const url = `/api/controls/configs/${configId}/bridge-script?type=${encodeURIComponent(type)}`;
-- const resp = await fetch(url, { credentials: 'same-origin' });
-- if (!resp.ok) throw new Error('Download failed');
-- const blob = await resp.blob();
-- const filename = resp.headers.get('content-disposition')?.match(/filename="(.+)"/)?.[1] || `${type}-bridge.py`;
-- const a = document.createElement('a');
-- a.href = URL.createObjectURL(blob);
-- a.download = filename;
-- a.click();
-- URL.revokeObjectURL(a.href);
-- } catch (e) { toast(e.message || 'Download failed', 'error'); }
--}
--
--async function downloadControlBridgeScript() {
-- const select = document.getElementById('bc-control-config');
-- const configId = select?.value;
-- if (!configId) return toast('Select a control profile first', 'error');
-- try {
-- const url = `/api/controls/configs/${configId}/bridge-script?type=generic`;
-- const resp = await fetch(url, { credentials: 'same-origin' });
-- if (!resp.ok) throw new Error('Download failed');
-- const blob = await resp.blob();
-- const filename = resp.headers.get('content-disposition')?.match(/filename="(.+)"/)?.[1] || 'generic-bridge.py';
-- const a = document.createElement('a');
-- a.href = URL.createObjectURL(blob);
-- a.download = filename;
-- a.click();
-- URL.revokeObjectURL(a.href);
-- } catch (e) { toast(e.message || 'Download failed', 'error'); }
--}
--
/* ── Goals ─────────────────────────────────────────────────────── */
async function loadDashGoals() {
if (!currentUser) return;
@@@ -1188,12 +818,6 @@ const DASH_TOKEN_SCOPE_FALLBACKS = Object.freeze([
title: 'Stream control',
description: 'Start or stop streams and update stream state or metadata.',
},
-- {
-- id: 'control',
-- label: 'control',
-- title: 'Hardware control bridge',
-- description: 'Use the hardware control bridge and related remote-control surfaces.',
-- },
{
id: 'vibe_coding_publish',
label: 'vibe_coding_publish',
@@@ -1219,9 +843,9 @@ const DASH_TOKEN_PRESET_FALLBACKS = Object.freeze([
{
id: 'stream-controller',
label: 'Stream Controller',
-- description: 'Recommended for integrations that control live state, metadata, or hardware workflows.',
++ description: 'Recommended for integrations that control live state and stream metadata.',
suggested_label: 'Stream Controller',
-- scopes: ['read', 'stream', 'control'],
++ scopes: ['read', 'stream'],
},
]);
ddiff --git a/public/js/guides-live.js b/public/js/guides-live.js
iindex d4ecbeab..b3f1a93e 100644
---- a/public/js/guides-live.js
++++ b/public/js/guides-live.js
@@@ -21,7 +21,7 @@
const CATS = [['irl', 'IRL', 'fa-person-walking'], ['outdoors', 'Outdoors', 'fa-mountain-sun'], ['travel', 'Travel', 'fa-plane'], ['building', 'Building / Craft', 'fa-hammer'], ['music', 'Music', 'fa-music'], ['gaming', 'Gaming', 'fa-gamepad'], ['robot', 'Robot', 'fa-robot'], ['desktop', 'Desktop', 'fa-desktop'], ['other', 'Other', 'fa-sparkles']];
const PLAT = { twitch: ['Twitch', 'fa-brands fa-twitch', '#a970ff'], youtube: ['YouTube', 'fa-brands fa-youtube', '#ff4b4b'], kick: ['Kick', 'fa-solid fa-bolt', '#53fc18'], custom: ['Any RTMP', 'fa-solid fa-tower-broadcast', '#e5e7eb'] };
const TASK_JOURNEY = { slot: ['golive', 'stream'], method: ['golive', 'method'], restream: ['golive', 'restream'], golive: ['golive', 'golive'], profile: ['profile'], offline: ['offline'], emote: ['emote'], sound: ['sound'], goal: ['goal'], powerchat: ['powerchat'], panels: ['panels'], share: ['share'] };
-- const TASK_ICON = { slot: 'fa-tower-broadcast', method: 'fa-sliders', restream: 'fa-satellite-dish', golive: 'fa-play', profile: 'fa-user', offline: 'fa-image', emote: 'fa-face-grin-squint-tears', sound: 'fa-volume-high', goal: 'fa-bullseye', powerchat: 'fa-hand-holding-dollar', panels: 'fa-table-columns', share: 'fa-share-nodes', mods: 'fa-user-shield', requests: 'fa-list-ol', moderation: 'fa-gavel', controls: 'fa-gamepad', aibot: 'fa-robot', paste: 'fa-code' };
++ const TASK_ICON = { slot: 'fa-tower-broadcast', method: 'fa-sliders', restream: 'fa-satellite-dish', golive: 'fa-play', profile: 'fa-user', offline: 'fa-image', emote: 'fa-face-grin-squint-tears', sound: 'fa-volume-high', goal: 'fa-bullseye', powerchat: 'fa-hand-holding-dollar', panels: 'fa-table-columns', share: 'fa-share-nodes', mods: 'fa-user-shield', requests: 'fa-list-ol', moderation: 'fa-gavel', robot: 'fa-robot', aibot: 'fa-robot', paste: 'fa-code' };
// ── Shared stream state (slot / endpoint / restreams) ─────
const D = { slots: [], slot: null, key: null, rtmpUrl: null, whipBase: null, dests: [], rs: null, oauth: {}, method: null, mode: 'camera', poll: null };
@@@ -358,7 +358,7 @@
return `<div class="ovg-hub-top"><div class="ovg-ring" style="--pct:${pct}"><span>${p.done}<small>/${p.total}</small></span></div><div><div class="ovg-hub-title">${pct === 100 ? 'Everything is set up. Go be great.' : next ? `Next up: ${esc(next.title)}` : 'Nice work'}</div><div class="ovg-hub-sub">${pct === 100 ? 'Come back any time something new ships.' : next ? esc(next.why) : ''}</div>${next ? `<button type="button" class="btn btn-primary" data-task="${esc(next.id)}" data-go="${esc(next.go || '')}"><i class="fa-solid ${TASK_ICON[next.id] || 'fa-arrow-right'}"></i> Do it now</button>` : ''}</div></div>
${Object.entries(groups).map(([g, tasks]) => `<div class="ovg-hub-group"><h4>${esc(g)}</h4><ul class="ovg-tasks">${tasks.map(t => `<li class="${t.done ? 'done' : ''} ${ctx.focus === t.id ? 'focus' : ''}"><span class="ovg-task-ico"><i class="fa-solid ${t.done ? 'fa-check' : (TASK_ICON[t.id] || 'fa-circle')}"></i></span><span class="ovg-task-body"><b>${esc(t.title)}${t.done && t.count > 1 ? ` <small>×${t.count}</small>` : ''}</b><small>${esc(t.why)}</small></span><button type="button" class="btn btn-sm ${t.done ? 'btn-outline' : 'btn-primary'}" data-task="${esc(t.id)}" data-go="${esc(t.go || '')}">${t.done ? 'Revisit' : 'Set up'}</button></li>`).join('')}</ul></div>`).join('')}`;
},
-- mount: (el) => { el.querySelectorAll('[data-task]').forEach(b => b.addEventListener('click', () => { const [j, step] = TASK_JOURNEY[b.dataset.task] || []; if (!j) { const go = b.dataset.go; if (go) { G.close(false); G.cfg.navigate(go); } return; } const needsBroadcast = j === 'golive' && !onBroadcast(); if (needsBroadcast) { G.close(false); G.cfg.navigate(`/broadcast?guide=golive${step ? ':' + step : ''}`); } else G.open(j, { step }); })); },
++ mount: (el) => { el.querySelectorAll('[data-task]').forEach(b => b.addEventListener('click', () => { const [j, step] = TASK_JOURNEY[b.dataset.task] || []; if (!j) { const go = b.dataset.go; if (go && /^https:\/\//.test(go)) { window.open(go, '_blank', 'noopener'); return; } if (go) { G.close(false); G.cfg.navigate(go); } return; } const needsBroadcast = j === 'golive' && !onBroadcast(); if (needsBroadcast) { G.close(false); G.cfg.navigate(`/broadcast?guide=golive${step ? ':' + step : ''}`); } else G.open(j, { step }); })); },
footer: () => `<span class="ovg-foot-note">Everything here is optional — do it in any order.</span><button type="button" class="btn btn-outline" data-act="next">Close</button>`,
next: 'close',
}],
ddiff --git a/public/js/onvif-dashboard.js b/public/js/onvif-dashboard.js
ddeleted file mode 100644
iindex c6c869cc..00000000
---- a/public/js/onvif-dashboard.js
++++ /dev/null
@@@ -1,376 +0,0 @@
--/**
-- * OpenVibe.Live — ONVIF Camera Dashboard Management
-- *
-- * Handles:
-- * - Discovery of ONVIF cameras on local network
-- * - Creating/updating/deleting camera profiles
-- * - Managing camera presets
-- * - Linking cameras to stream controls
-- */
--
--// currentStreamId is declared in controls.js (shared global)
--
--/**
-- * Load camera list on dashboard
-- */
--async function loadDashboardCameras() {
-- const container = document.getElementById('dash-cameras-list');
-- if (!container) return;
--
-- try {
-- const data = await api('/onvif/cameras');
-- const cameras = data.cameras || [];
--
-- if (!cameras.length) {
-- container.innerHTML = '<p class="muted" style="font-size: 0.9rem;">No cameras added yet. Discover or add one to get started.</p>';
-- return;
-- }
--
-- container.innerHTML = cameras.map(cam => `
-- <div class="dash-camera-item" style="padding:8px;border:1px solid var(--border);border-radius:var(--radius);margin-bottom:8px">
-- <div style="display:flex;justify-content:space-between;align-items:center">
-- <div>
-- <div style="font-weight:bold;color:var(--accent)">${esc(cam.name)}</div>
-- <div style="font-size:0.8rem;color:var(--text-secondary)">${esc(cam.onvif_url)}</div>
-- <div style="font-size:0.75rem;color:${cam.status === 'connected' ? '#0f0' : cam.status === 'unreachable' ? '#f00' : '#999'}">
-- ${cam.status === 'connected' ? '🟢 Connected' : cam.status === 'unreachable' ? '🔴 Unreachable' : '⚪ Unknown'}
-- </div>
-- </div>
-- <div style="display:flex;gap:4px">
-- <button class="btn btn-sm" onclick="showEditCameraModal(${cam.id})" title="Edit">
-- <i class="fa-solid fa-pencil"></i>
-- </button>
-- <button class="btn btn-sm" onclick="showCameraPresetsModal(${cam.id})" title="Manage Presets">
-- <i class="fa-solid fa-bookmark"></i>
-- </button>
-- <button class="btn btn-sm" onclick="deleteCamera(${cam.id})" title="Delete">
-- <i class="fa-solid fa-trash"></i>
-- </button>
-- </div>
-- </div>
-- </div>
-- `).join('');
-- } catch (e) {
-- container.innerHTML = `<p class="error">Failed to load cameras: ${e.message}</p>`;
-- }
--}
--
--/**
-- * Discover cameras on the network
-- */
--async function doDiscoverCameras() {
-- const overlay = document.getElementById('modal-overlay');
-- const statusDiv = document.getElementById('discovery-status') || document.createElement('div');
--
-- if (!document.getElementById('discovery-status')) {
-- statusDiv.id = 'discovery-status';
-- statusDiv.style.marginTop = '12px';
-- document.body.appendChild(statusDiv);
-- }
--
-- statusDiv.innerHTML = '<p>Scanning network for ONVIF cameras...</p>';
--
-- try {
-- const data = await api('/onvif/discover', {
-- method: 'POST',
-- body: JSON.stringify({ timeout: 5000 })
-- });
--
-- const cameras = data.discovered || [];
--
-- if (!cameras.length) {
-- statusDiv.innerHTML = '<p class="error">No cameras found. Check your network connection and firewall.</p>';
-- return;
-- }
--
-- let html = '<h4 style="margin-top:12px">Found Devices:</h4>';
-- html += cameras.map((cam, idx) => `
-- <div style="margin:8px 0;padding:8px;background:var(--bg-hover);border-radius:var(--radius)">
-- <strong>${esc(cam.ip)}</strong> — ${cam.testable ? '✓ Testable' : '? May need credentials'}
-- <button class="btn btn-sm" style="float:right" onclick="selectDiscoveredCamera('${esc(cam.url)}')">
-- <i class="fa-solid fa-plus"></i> Add
-- </button>
-- </div>
-- `).join('');
--
-- statusDiv.innerHTML = html;
-- } catch (e) {
-- statusDiv.innerHTML = `<p class="error">Discovery failed: ${e.message}</p>`;
-- }
--}
--
--function selectDiscoveredCamera(url) {
-- document.getElementById('modal-cam-url').value = url;
-- toast('URL filled. Enter credentials and click Add Camera.', 'info');
--}
--
--/**
-- * Add new camera
-- */
--async function doAddCamera() {
-- const url = document.getElementById('modal-cam-url')?.value.trim();
-- const username = document.getElementById('modal-cam-username')?.value.trim();
-- const password = document.getElementById('modal-cam-password')?.value;
-- const name = document.getElementById('modal-cam-name')?.value.trim();
--
-- if (!url || !username || !password || !name) {
-- toast('All fields required', 'error');
-- return;
-- }
--
-- try {
-- const result = await api('/onvif/cameras', {
-- method: 'POST',
-- body: JSON.stringify({
-- onvif_url: url,
-- username,
-- password,
-- name,
-- stream_id: currentStreamId,
-- })
-- });
--
-- toast(`Camera "${result.name}" added!`, 'success');
-- closeModal();
-- loadDashboardCameras();
-- } catch (e) {
-- toast(`Failed to add camera: ${e.message}`, 'error');
-- }
--}
--
--/**
-- * Edit camera
-- */
--async function doEditCamera(cameraId) {
-- const url = document.getElementById('modal-cam-url')?.value.trim();
-- const username = document.getElementById('modal-cam-username')?.value.trim();
-- const password = document.getElementById('modal-cam-password')?.value || undefined;
-- const name = document.getElementById('modal-cam-name')?.value.trim();
-- const panSpeed = parseFloat(document.getElementById('modal-cam-pan-speed')?.value) || 0.5;
-- const tiltSpeed = parseFloat(document.getElementById('modal-cam-tilt-speed')?.value) || 0.5;
-- const zoomSpeed = parseFloat(document.getElementById('modal-cam-zoom-speed')?.value) || 0.5;
--
-- const data = { name, onvif_url: url, username, pan_speed: panSpeed, tilt_speed: tiltSpeed, zoom_speed: zoomSpeed };
-- if (password) data.password = password;
--
-- try {
-- await api(`/onvif/cameras/${cameraId}`, {
-- method: 'PUT',
-- body: JSON.stringify(data)
-- });
--
-- toast('Camera updated!', 'success');
-- closeModal();
-- loadDashboardCameras();
-- } catch (e) {
-- toast(`Failed to update camera: ${e.message}`, 'error');
-- }
--}
--
--/**
-- * Delete camera
-- */
--async function deleteCamera(cameraId) {
-- if (!confirm('Delete this camera?')) return;
--
-- try {
-- await api(`/onvif/cameras/${cameraId}`, { method: 'DELETE' });
-- toast('Camera deleted', 'success');
-- loadDashboardCameras();
-- } catch (e) {
-- toast(`Failed to delete camera: ${e.message}`, 'error');
-- }
--}
--
--/**
-- * Show camera presets modal
-- */
--async function showCameraPresetsModal(cameraId) {
-- const overlay = document.getElementById('modal-overlay');
-- const content = document.getElementById('modal-content');
--
-- try {
-- const camData = await api(`/onvif/cameras/${cameraId}`);
-- const presetData = await api(`/onvif/cameras/${cameraId}/presets`);
-- const presets = presetData.presets || [];
--
-- let html = `
-- <h3>${esc(camData.name)} — Presets</h3>
-- <p class="muted" style="font-size:0.85rem;">Saved PTZ (pan-tilt-zoom) positions that viewers can jump to.</p>
-- <div id="presets-list" style="max-height:300px;overflow-y:auto;margin-bottom:12px">
-- `;
--
-- if (presets.length > 0) {
-- html += presets.map(p => `
-- <div style="padding:8px;border:1px solid var(--border);border-radius:var(--radius);margin-bottom:6px">
-- <strong>${esc(p.name)}</strong>
-- <button class="btn btn-sm" style="float:right" onclick="deletePreset(${cameraId}, ${p.id})">
-- <i class="fa-solid fa-trash"></i>
-- </button>
-- </div>
-- `).join('');
-- } else {
-- html += '<p class="muted">No presets saved yet.</p>';
-- }
--
-- html += `
-- </div>
-- <h4 style="margin-top:12px">Save Current Position as Preset</h4>
-- <div class="form-group">
-- <label>Preset Name</label>
-- <input type="text" id="preset-name" class="form-input" placeholder="e.g. Home Position">
-- </div>
-- <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px">
-- <div class="form-group">
-- <label style="font-size:0.85rem">Pan (0–1)</label>
-- <input type="range" id="preset-pan" class="form-input" min="0" max="1" step="0.01" value="0.5">
-- <small id="pan-val">0.50</small>
-- </div>
-- <div class="form-group">
-- <label style="font-size:0.85rem">Tilt (0–1)</label>
-- <input type="range" id="preset-tilt" class="form-input" min="0" max="1" step="0.01" value="0.5">
-- <small id="tilt-val">0.50</small>
-- </div>
-- <div class="form-group">
-- <label style="font-size:0.85rem">Zoom (0–1)</label>
-- <input type="range" id="preset-zoom" class="form-input" min="0" max="1" step="0.01" value="0.5">
-- <small id="zoom-val">0.50</small>
-- </div>
-- </div>
-- <button class="btn btn-primary btn-lg" onclick="doSavePreset(${cameraId})" style="width:100%;margin-top:8px">
-- <i class="fa-solid fa-bookmark"></i> Save Preset
-- </button>
-- `;
--
-- content.innerHTML = html;
-- overlay.style.display = 'flex';
--
-- // Update value displays
-- ['pan', 'tilt', 'zoom'].forEach(axis => {
-- const input = document.getElementById(`preset-${axis}`);
-- if (input) {
-- input.addEventListener('input', (e) => {
-- document.getElementById(`${axis}-val`).textContent = parseFloat(e.target.value).toFixed(2);
-- });
-- }
-- });
-- } catch (e) {
-- toast(`Failed to load presets: ${e.message}`, 'error');
-- }
--}
--
--async function doSavePreset(cameraId) {
-- const name = document.getElementById('preset-name')?.value.trim();
-- const pan = parseFloat(document.getElementById('preset-pan')?.value) || 0.5;
-- const tilt = parseFloat(document.getElementById('preset-tilt')?.value) || 0.5;
-- const zoom = parseFloat(document.getElementById('preset-zoom')?.value) || 0.5;
--
-- if (!name) {
-- toast('Preset name required', 'error');
-- return;
-- }
--
-- try {
-- await api(`/onvif/cameras/${cameraId}/presets`, {
-- method: 'POST',
-- body: JSON.stringify({ name, pan, tilt, zoom })
-- });
--
-- toast('Preset saved!', 'success');
-- showCameraPresetsModal(cameraId);
-- } catch (e) {
-- toast(`Failed to save preset: ${e.message}`, 'error');
-- }
--}
--
--async function deletePreset(cameraId, presetId) {
-- if (!confirm('Delete this preset?')) return;
--
-- try {
-- await api(`/onvif/cameras/${cameraId}/presets/${presetId}`, { method: 'DELETE' });
-- showCameraPresetsModal(cameraId);
-- } catch (e) {
-- toast(`Failed to delete preset: ${e.message}`, 'error');
-- }
--}
--
--/**
-- * Show edit camera modal
-- */
--async function showEditCameraModal(cameraId) {
-- const overlay = document.getElementById('modal-overlay');
-- const content = document.getElementById('modal-content');
--
-- try {
-- const camData = await api(`/onvif/cameras/${cameraId}`);
--
-- const html = `
-- <h3><i class="fa-solid fa-video"></i> Edit Camera</h3>
-- <div class="form-group">
-- <label>Camera Name</label>
-- <input type="text" id="modal-cam-name" class="form-input" value="${esc(camData.name)}">
-- </div>
-- <div class="form-group">
-- <label>ONVIF URL</label>
-- <input type="text" id="modal-cam-url" class="form-input" value="${esc(camData.onvif_url)}">
-- </div>
-- <div class="form-group">
-- <label>Username</label>
-- <input type="text" id="modal-cam-username" class="form-input" value="${esc(camData.username)}">
-- </div>
-- <div class="form-group">
-- <label>Password (leave blank to keep current)</label>
-- <input type="password" id="modal-cam-password" class="form-input">
-- </div>
-- <h4 style="margin-top:12px">Movement Speeds (0.0–1.0)</h4>
-- <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px">
-- <div class="form-group">
-- <label style="font-size:0.85rem">Pan Speed</label>
-- <input type="range" id="modal-cam-pan-speed" min="0" max="1" step="0.1" value="${camData.pan_speed}">
-- <small>${camData.pan_speed}</small>
-- </div>
-- <div class="form-group">
-- <label style="font-size:0.85rem">Tilt Speed</label>
-- <input type="range" id="modal-cam-tilt-speed" min="0" max="1" step="0.1" value="${camData.tilt_speed}">
-- <small>${camData.tilt_speed}</small>
-- </div>
-- <div class="form-group">
-- <label style="font-size:0.85rem">Zoom Speed</label>
-- <input type="range" id="modal-cam-zoom-speed" min="0" max="1" step="0.1" value="${camData.zoom_speed}">
-- <small>${camData.zoom_speed}</small>
-- </div>
-- </div>
-- <button class="btn btn-primary btn-lg" onclick="doEditCamera(${cameraId})" style="width:100%;margin-top:12px">
-- <i class="fa-solid fa-floppy-disk"></i> Save Changes
-- </button>
-- `;
--
-- content.innerHTML = html;
-- overlay.style.display = 'flex';
-- } catch (e) {
-- toast(`Failed to load camera: ${e.message}`, 'error');
-- }
--}
--
--// Every page's markup lives in one document, so #dash-cameras-list exists even on the home page —
--// this used to fire an ONVIF camera query for anyone landing on the front page. Load the cameras
--// when the dashboard is actually on screen, and again whenever it becomes so.
--function _onvifMaybeLoad() {
-- const page = document.getElementById('page-dashboard');
-- if (!page || !page.classList.contains('active')) return;
-- if (page.dataset.onvifLoaded) return;
-- // Mark loaded only once the list exists: the class flips before the dashboard markup is in.
-- if (!document.getElementById('dash-cameras-list')) return;
-- page.dataset.onvifLoaded = '1';
-- loadDashboardCameras();
--}
--// This file loads with the dashboard, usually long after DOMContentLoaded has fired.
--function _onvifBoot() {
-- _onvifMaybeLoad();
-- const page = document.getElementById('page-dashboard');
-- if (page && 'MutationObserver' in window && !page._onvifObserved) {
-- page._onvifObserved = true;
-- new MutationObserver(_onvifMaybeLoad).observe(page, { attributes: true, attributeFilter: ['class'] });
-- }
--}
--if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _onvifBoot); else _onvifBoot();
ddiff --git a/scripts/cozmo-bridge.py b/scripts/cozmo-bridge.py
ddeleted file mode 100644
iindex 028e9c3c..00000000
---- a/scripts/cozmo-bridge.py
++++ /dev/null
@@@ -1,785 +0,0 @@
--#!/usr/bin/env python3
--"""
--OpenVibe.Live — Cozmo Hardware Bridge
--
--Connects to the OpenVibe.Live Control WebSocket as a hardware client,
--translates incoming commands into pycozmo actions on a physical Cozmo robot.
--
--HOW BUTTON TYPES WORK
--======================
-- [HOLD / keyboard type]:
-- key_down → start driving continuously (smooth mode) OR burst once (jumpy mode)
-- key_up → stop driving
--
-- [TAP / button type]:
-- command → one-shot action (face animation, machine gun, toggle, etc.)
--
--DRIVE MODES
--===========
-- SMOOTH (default): holding a drive button drives continuously.
-- Releasing → Cozmo stops. Best for precise control.
-- JUMPY: every tap sends a short burst (JUMPY_BURST_DURATION seconds).
-- Good when smooth control is hard to achieve.
-- Send the "toggle_mode" command (or press the Toggle Mode button) to switch.
--
--Requirements:
-- pip install pycozmo websocket-client Pillow
--
--Usage:
-- python3 cozmo-bridge.py --url wss://openvibe.live/ws/control --stream-key YOUR_STREAM_KEY
--
--Environment Variables (alternative to CLI args):
-- OV_WS_URL WebSocket URL (e.g. wss://openvibe.live/ws/control)
-- OV_STREAM_KEY Stream key for authentication
--
--Commands:
-- Drive (HOLD type): forward, backward, turn_left, turn_right
-- Stop (TAP): stop, emergency_stop
-- Lift (TAP): lift_up, lift_down, say:<text>, anim:<trigger>
-- Cozmo face (TAP): otter, dual_otter, mechaMG, armcat, nflag, random_glance, toggle_eyes
-- Action (TAP): machine_gun, toggle_mode
--"""
--
--import argparse
--import json
--import os
--import random
--import sys
--import threading
--import time
--import traceback
--
--try:
-- import pycozmo
-- from pycozmo import MIN_LIFT_HEIGHT, MAX_LIFT_HEIGHT, MIN_HEAD_ANGLE, MAX_HEAD_ANGLE
-- PYCOZMO_AVAILABLE = True
--except ImportError:
-- PYCOZMO_AVAILABLE = False
-- class _MinMax:
-- def __init__(self, mm=0, radians=0): self.mm = mm; self.radians = radians
-- MIN_LIFT_HEIGHT = _MinMax(mm=32)
-- MAX_LIFT_HEIGHT = _MinMax(mm=92)
-- MIN_HEAD_ANGLE = _MinMax(radians=-0.25)
-- MAX_HEAD_ANGLE = _MinMax(radians=0.785)
--
--try:
-- import websocket
-- WEBSOCKET_AVAILABLE = True
--except ImportError:
-- WEBSOCKET_AVAILABLE = False
--
--try:
-- from PIL import Image
-- PIL_AVAILABLE = True
--except ImportError:
-- PIL_AVAILABLE = False
--
--
--# ── Tunable Settings ─────────────────────────────────────────
--DRIVE_SPEED = 100 # mm/s forward / backward
--TURN_SPEED = 80 # mm/s per-side for turns
--JUMPY_BURST_DURATION = 0.07 # sec single burst in jumpy mode
--JUMPY_PAUSE_TIME = 0.03 # sec pause after burst
--MG_PULSES = 12 # machine gun vibration pulses
--MG_INTERVAL = 0.06 # sec between pulses
--MG_VIBRATE = 18 # lift amplitude (mm)
--LIFT_STEP = 20 # mm per lift_up/lift_down command
--SMOOTH_DRIVE_WINDOW = 0.15 # sec between drive_wheels calls in smooth mode
--
--CONTINUOUS_COMMANDS = {
-- "forward": ( DRIVE_SPEED, DRIVE_SPEED),
-- "backward": (-DRIVE_SPEED, -DRIVE_SPEED),
-- "turn_left": (-TURN_SPEED, TURN_SPEED),
-- "turn_right": ( TURN_SPEED, -TURN_SPEED),
--}
--
--# Asset directories for face animations
--CUSTOM_FACES_DIR = os.path.expanduser("~/Desktop/custom_faces")
--MECHA_MG_DIR = os.path.expanduser("~/Desktop/mechaMG")
--OTTER_GIF_DIR = os.path.expanduser("~/Desktop/otterGIF")
--
--
--# ── Face Asset Loader ─────────────────────────────────────────
--def _load_face_assets():
-- assets = {"static": [], "mechaMG_frames": [], "mechaMG_delays": [], "otter": [],
-- "armcat_up": None, "armcat_down": None, "hit_left": None, "hit_right": None,
-- "jL": None, "jR": None, "nflag_frames": []}
--
-- if not PIL_AVAILABLE:
-- return assets
--
-- def _bmp(path, size=(128, 32)):
-- try:
-- return Image.open(path).resize(size, Image.NEAREST).convert("1")
-- except Exception as e:
-- print(f"[Face] {path}: {e}")
-- return None
--
-- if os.path.exists(CUSTOM_FACES_DIR):
-- for f in sorted(os.listdir(CUSTOM_FACES_DIR)):
-- if not f.lower().endswith(".bmp"):
-- continue
-- im = _bmp(os.path.join(CUSTOM_FACES_DIR, f))
-- if im is None:
-- continue
-- fl = f.lower()
-- if fl == "armcatup.bmp": assets["armcat_up"] = im
-- elif fl == "armcatdown.bmp": assets["armcat_down"] = im
-- elif fl in ("hitl.bmp", "hit1.bmp", "left.bmp"): assets["hit_left"] = im
-- elif fl in ("hitr.bmp", "hit2.bmp", "right.bmp"): assets["hit_right"] = im
-- elif fl == "jl.bmp": assets["jL"] = im
-- elif fl == "jr.bmp": assets["jR"] = im
-- elif fl == "nflag1.1.bmp":
-- for angle in range(0, 360, 45):
-- assets["nflag_frames"].append(im.rotate(angle, expand=False, fillcolor=0))
-- else:
-- assets["static"].append((im, f))
--
-- if os.path.exists(MECHA_MG_DIR):
-- for fname in sorted(f for f in os.listdir(MECHA_MG_DIR)
-- if f.lower().startswith("frame_") and f.lower().endswith(".png")):
-- try:
-- im = Image.open(os.path.join(MECHA_MG_DIR, fname)).convert("1")
-- mg = im.resize((64, 48), Image.NEAREST)
-- frame = Image.new("1", (128, 32), color=0)
-- frame.paste(mg, ((128-64)//2, (32-48)//2 + 4))
-- assets["mechaMG_frames"].append(frame)
-- delay = 0.2
-- if "_delay-" in fname.lower():
-- try: delay = float(fname.lower().split("_delay-")[1].split("s.")[0])
-- except: pass
-- assets["mechaMG_delays"].append(delay)
-- except Exception as e:
-- print(f"[Face] mechaMG {fname}: {e}")
--
-- if os.path.exists(OTTER_GIF_DIR):
-- for i in range(30):
-- path = os.path.join(OTTER_GIF_DIR, f"frame_{i:02d}_delay-0.04s.png")
-- if not os.path.exists(path):
-- break
-- try:
-- im = Image.open(path).convert("1")
-- frame = Image.new("1", (128, 32), color=0)
-- resized = im.resize((64, 64), Image.NEAREST)
-- frame.paste(resized, ((128-64)//2, max(0, (32-64)//2)))
-- assets["otter"].append(frame)
-- except Exception as e:
-- print(f"[Face] otter {i}: {e}")
--
-- print(f"[Face] static={len(assets['static'])} mechaMG={len(assets['mechaMG_frames'])} otter={len(assets['otter'])}")
-- return assets
--
--
--# ── Bridge ────────────────────────────────────────────────────
--
--class CozmoHardwareBridge:
-- """Bridges OpenVibe.Live control WebSocket ↔ physical Cozmo robot."""
--
-- def __init__(self, ws_url, stream_key):
-- self.ws_url = ws_url
-- self.stream_key = stream_key
-- self.ws = None
-- self.cli = None
-- self.running = False
-- self._reconnect_delay = 2
-- self.driving_mode = "smooth" # "smooth" | "jumpy"
-- self.held_keys = set()
-- self._drive_lock = threading.Lock()
-- self.lift_height_mm = MIN_LIFT_HEIGHT.mm
-- self.animation_mode = None
-- self._anim_frame = 0
-- self._last_frame_t = 0.0
-- self.assets = _load_face_assets()
--
-- # ── Cozmo ──────────────────────────────────────────────────
-- def connect_cozmo(self):
-- if not PYCOZMO_AVAILABLE:
-- print('[Cozmo] pycozmo not installed — running in DRY RUN mode')
-- return None
-- try:
-- cli = pycozmo.Client()
-- cli.start()
-- cli.connect()
-- cli.wait_for_robot()
-- cli.enable_procedural_face(False)
-- print('[Cozmo] Robot connected!')
-- return cli
-- except Exception as e:
-- print(f'[Cozmo] Connection failed: {e} — DRY RUN mode')
-- return None
--
-- # ── Drive helpers ──────────────────────────────────────────
-- def _drive(self, left, right):
-- if self.cli:
-- try:
-- if self.driving_mode == "smooth":
-- self.cli.drive_wheels(left, right, duration=SMOOTH_DRIVE_WINDOW * 2)
-- else:
-- self.cli.drive_wheels(left, right, duration=JUMPY_BURST_DURATION)
-- time.sleep(JUMPY_PAUSE_TIME)
-- except Exception as e:
-- print(f'[Drive] {e}')
-- else:
-- print(f'[DRY RUN] drive L={left} R={right}')
--
-- def _stop(self):
-- if self.cli:
-- try: self.cli.drive_wheels(0, 0)
-- except Exception: pass
-- else:
-- print('[DRY RUN] stop')
--
-- # ── Continuous drive loop (smooth mode) ───────────────────
-- def _continuous_drive_loop(self):
-- while self.running:
-- with self._drive_lock:
-- keys = set(self.held_keys)
-- if not keys or self.driving_mode == "jumpy":
-- time.sleep(0.05)
-- continue
-- drove = False
-- for cmd in ("forward", "backward", "turn_left", "turn_right"):
-- if cmd in keys and cmd in CONTINUOUS_COMMANDS:
-- left, right = CONTINUOUS_COMMANDS[cmd]
-- self._drive(left, right)
-- drove = True
-- break
-- time.sleep(SMOOTH_DRIVE_WINDOW if drove else 0.05)
--
-- # ── Machine gun ────────────────────────────────────────────
-- def _machine_gun(self):
-- if not self.cli:
-- print('[DRY RUN] machine gun!')
-- return
-- cur = self.lift_height_mm
-- try:
-- for _ in range(MG_PULSES):
-- self.cli.set_lift_height(min(MAX_LIFT_HEIGHT.mm, cur + MG_VIBRATE), accel=3000, duration=0.04)
-- time.sleep(MG_INTERVAL)
-- self.cli.set_lift_height(max(MIN_LIFT_HEIGHT.mm, cur - MG_VIBRATE), accel=3000, duration=0.04)
-- time.sleep(MG_INTERVAL)
-- self.cli.set_lift_height(cur, accel=800, duration=0.12)
-- except Exception as e:
-- print(f'[MG] {e}')
--
-- # ── Face display ───────────────────────────────────────────
-- def _display_face(self, im):
-- if self.cli and im is not None:
-- try: self.cli.display_image(im)
-- except Exception: pass
--
-- def _tick_animations(self):
-- a = self.assets
-- now = time.time()
-- if self.animation_mode == "mechaMG" and a["mechaMG_frames"]:
-- delays = a["mechaMG_delays"]
-- delay = delays[self._anim_frame] if self._anim_frame < len(delays) else 0.2
-- if now - self._last_frame_t > delay:
-- self._anim_frame = (self._anim_frame + 1) % len(a["mechaMG_frames"])
-- self._display_face(a["mechaMG_frames"][self._anim_frame])
-- self._last_frame_t = now
-- elif self.animation_mode == "otter" and a["otter"]:
-- if now - self._last_frame_t > 0.04:
-- self._anim_frame = (self._anim_frame + 1) % len(a["otter"])
-- self._display_face(a["otter"][self._anim_frame])
-- self._last_frame_t = now
-- elif self.animation_mode == "dual_otter" and a["otter"]:
-- if now - self._last_frame_t > 0.04:
-- self._anim_frame = (self._anim_frame + 1) % len(a["otter"])
-- combined = Image.new("1", (128, 32), color=0)
-- L = self._anim_frame % len(a["otter"])
-- R = (self._anim_frame + 3) % len(a["otter"])
-- combined.paste(a["otter"][L].crop((32, 0, 96, 32)), (0, 0))
-- combined.paste(a["otter"][R].crop((32, 0, 96, 32)), (64, 0))
-- self._display_face(combined)
-- self._last_frame_t = now
-- elif self.animation_mode == "armcat" and a["armcat_up"] and a["armcat_down"]:
-- if now - self._last_frame_t > 0.2:
-- self._anim_frame = 1 - self._anim_frame
-- self._display_face(a["armcat_up"] if self._anim_frame == 0 else a["armcat_down"])
-- self._last_frame_t = now
-- elif self.animation_mode == "hit" and a["hit_left"] and a["hit_right"]:
-- delay = random.uniform(0.2, 0.5) if random.random() < 0.05 else random.uniform(2.0, 4.0)
-- if now - self._last_frame_t > delay:
-- self._anim_frame = 1 - self._anim_frame
-- self._display_face(a["hit_left"] if self._anim_frame == 0 else a["hit_right"])
-- self._last_frame_t = now
-- elif self.animation_mode == "j" and a["jL"] and a["jR"]:
-- if now - self._last_frame_t > 1.0:
-- self._anim_frame = 1 - self._anim_frame
-- self._display_face(a["jL"] if self._anim_frame == 0 else a["jR"])
-- self._last_frame_t = now
-- elif self.animation_mode == "nflag" and a["nflag_frames"]:
-- if now - self._last_frame_t > 0.08:
-- self._anim_frame = (self._anim_frame + 1) % len(a["nflag_frames"])
-- self._display_face(a["nflag_frames"][self._anim_frame])
-- self._last_frame_t = now
--
-- # ── Command dispatcher ─────────────────────────────────────
-- def handle_command(self, cmd, user='?'):
-- """Execute a one-shot TAP command."""
-- print(f'[CMD] {user} -> {cmd}')
-- if cmd in ("forward", "backward", "turn_left", "turn_right") and cmd in CONTINUOUS_COMMANDS:
-- left, right = CONTINUOUS_COMMANDS[cmd]
-- self._drive(left, right)
-- elif cmd in ("stop", "emergency_stop"):
-- self._stop()
-- elif cmd == "machine_gun":
-- self._machine_gun()
-- elif cmd == "toggle_mode":
-- self.driving_mode = "jumpy" if self.driving_mode == "smooth" else "smooth"
-- print(f'[Mode] -> {self.driving_mode.upper()}')
-- elif cmd in ("otter", "g"):
-- self.animation_mode = "otter" if self.animation_mode != "otter" else None
-- self._anim_frame = 0
-- elif cmd in ("dual_otter", "y"):
-- self.animation_mode = "dual_otter" if self.animation_mode != "dual_otter" else None
-- self._anim_frame = 0
-- elif cmd in ("mechaMG", "mecha_mg", "p"):
-- if self.animation_mode == "mechaMG":
-- self.animation_mode = None
-- elif self.assets["mechaMG_frames"]:
-- self.animation_mode = "mechaMG"
-- self._anim_frame = 0
-- self._last_frame_t = time.time()
-- elif cmd in ("armcat", "k"):
-- self.animation_mode = "armcat" if self.animation_mode != "armcat" else None
-- self._anim_frame = 0
-- elif cmd in ("nflag", "n"):
-- self.animation_mode = "nflag" if self.animation_mode != "nflag" else None
-- self._anim_frame = 0
-- elif cmd in ("random_glance", "h"):
-- self.animation_mode = "hit" if self.animation_mode != "hit" else None
-- self._anim_frame = random.randint(0, 1)
-- elif cmd in ("toggle_eyes", "o"):
-- if self.cli:
-- try: self.cli.enable_procedural_face(True)
-- except Exception: pass
-- self.animation_mode = None
-- elif cmd == "lift_up":
-- self.lift_height_mm = min(MAX_LIFT_HEIGHT.mm, self.lift_height_mm + LIFT_STEP)
-- if self.cli:
-- try: self.cli.set_lift_height(self.lift_height_mm)
-- except Exception: pass
-- elif cmd == "lift_down":
-- self.lift_height_mm = max(MIN_LIFT_HEIGHT.mm, self.lift_height_mm - LIFT_STEP)
-- if self.cli:
-- try: self.cli.set_lift_height(self.lift_height_mm)
-- except Exception: pass
-- elif cmd.startswith('say:'):
-- text = cmd[4:].strip()[:200]
-- if text and self.cli:
-- try: self.cli.say(text)
-- except Exception: pass
-- elif cmd.startswith('anim:'):
-- trigger = cmd[5:].strip()
-- if self.cli and PYCOZMO_AVAILABLE:
-- try: self.cli.play_anim_trigger(getattr(pycozmo.anim.Triggers, trigger))
-- except AttributeError: print(f'[Cozmo] Unknown anim: {trigger}')
-- else:
-- print(f'[Unknown] {cmd} — add handling to handle_command()')
--
-- # ── WS message handler ─────────────────────────────────────
-- def on_ws_message(self, ws, raw):
-- try:
-- msg = json.loads(raw)
-- except json.JSONDecodeError:
-- return
--
-- msg_type = msg.get('type', '')
-- cmd = msg.get('command', '')
-- user = msg.get('from_user', '?')
--
-- if msg_type == 'connected':
-- print(f'[WS] Registered as hardware client ({user})')
-- self.send_status({'cozmo': 'connected' if self.cli else 'dry_run',
-- 'mode': self.driving_mode})
-- return
--
-- if msg_type == 'command':
-- # TAP — one-shot action
-- self.handle_command(cmd, user)
--
-- elif msg_type == 'key_down':
-- # HOLD pressed — begin continuous drive (smooth) or burst (jumpy)
-- print(f'[Hold] {user} -> {cmd}')
-- with self._drive_lock:
-- self.held_keys.add(cmd)
-- if self.driving_mode == "jumpy" and cmd in CONTINUOUS_COMMANDS:
-- # Jumpy mode: burst immediately on press
-- left, right = CONTINUOUS_COMMANDS[cmd]
-- self._drive(left, right)
-- elif cmd not in CONTINUOUS_COMMANDS:
-- # Non-drive keyboard button: treat as one-shot on press
-- self.handle_command(cmd, user)
-- # Smooth mode drive: handled by _continuous_drive_loop
--
-- elif msg_type == 'key_up':
-- # HOLD released — stop if no more drive keys held
-- print(f'[Release] {user} -> {cmd}')
-- with self._drive_lock:
-- self.held_keys.discard(cmd)
-- with self._drive_lock:
-- remaining = set(self.held_keys)
-- if not any(k in CONTINUOUS_COMMANDS for k in remaining):
-- self._stop()
--
-- elif msg_type == 'video_click':
-- x = float(msg.get('x', 0.5))
-- y = float(msg.get('y', 0.5))
-- print(f'[Click] {user} -> ({x:.2f}, {y:.2f})')
-- if x < 0.35:
-- self._drive(-TURN_SPEED, TURN_SPEED)
-- elif x > 0.65:
-- self._drive(TURN_SPEED, -TURN_SPEED)
-- else:
-- if self.cli:
-- try: self.cli.drive_wheels(DRIVE_SPEED, DRIVE_SPEED, duration=max(0.2, (1.0 - y) * 0.6))
-- except Exception: pass
--
-- def send_status(self, status_data):
-- if self.ws:
-- try: self.ws.send(json.dumps({'type': 'status', **status_data}))
-- except Exception: pass
--
-- def on_ws_error(self, ws, error):
-- print(f'[WS] Error: {error}')
--
-- def on_ws_close(self, ws, code, reason):
-- print(f'[WS] Disconnected (code={code}) — Cozmo will stop')
-- with self._drive_lock:
-- self.held_keys.clear()
-- self._stop()
--
-- def on_ws_open(self, ws):
-- print(f'[WS] Connected!')
--
-- def run(self):
-- if not WEBSOCKET_AVAILABLE:
-- print('[ERROR] websocket-client not installed. Run: pip install websocket-client')
-- sys.exit(1)
--
-- self.running = True
--
-- # Connect to Cozmo
-- try:
-- self.cli = self.connect_cozmo()
-- except Exception as e:
-- print(f'[Cozmo] {e} — continuing in DRY RUN mode')
-- self.cli = None
--
-- # Start continuous-drive thread
-- drive_t = threading.Thread(target=self._continuous_drive_loop, daemon=True)
-- drive_t.start()
--
-- print(f'\n{"="*60}')
-- print(f' OpenVibe.Live Cozmo Bridge')
-- print(f' Drive speed: {DRIVE_SPEED} mm/s | Mode: SMOOTH')
-- print(f' Send "toggle_mode" to switch SMOOTH ↔ JUMPY')
-- print(f'{"="*60}\n')
--
-- while self.running:
-- try:
-- url = f'{self.ws_url}?mode=hardware&stream_key={self.stream_key}'
-- self.ws = websocket.WebSocketApp(
-- url,
-- on_open=self.on_ws_open,
-- on_message=self.on_ws_message,
-- on_error=self.on_ws_error,
-- on_close=self.on_ws_close,
-- )
-- # Run WS in a thread so animation ticking runs in main thread
-- ws_thread = threading.Thread(target=lambda: self.ws.run_forever(ping_interval=30, ping_timeout=10), daemon=True)
-- ws_thread.start()
--
-- while ws_thread.is_alive() and self.running:
-- self._tick_animations()
-- time.sleep(0.04)
--
-- except Exception as e:
-- print(f'[WS] Connection failed: {e}')
--
-- if self.running:
-- print(f'[WS] Reconnecting in {self._reconnect_delay}s...')
-- time.sleep(self._reconnect_delay)
-- self._reconnect_delay = min(self._reconnect_delay * 2, 30)
--
-- def stop(self):
-- self.running = False
-- with self._drive_lock:
-- self.held_keys.clear()
-- self._stop()
-- if self.ws:
-- self.ws.close()
-- if self.cli:
-- try:
-- self.cli.disconnect()
-- self.cli.stop()
-- except Exception:
-- pass
--
--
--# ── CLI Entry Point ───────────────────────────────────────────
--
--def main():
-- parser = argparse.ArgumentParser(description='OpenVibe.Live Cozmo Hardware Bridge')
-- parser.add_argument('--url', default=os.environ.get('OV_WS_URL', 'wss://openvibe.live/ws/control'),
-- help='OpenVibe.Live control WebSocket URL')
-- parser.add_argument('--stream-key', default=os.environ.get('OV_STREAM_KEY', ''),
-- help='Stream key for authentication')
-- args = parser.parse_args()
--
-- if not args.stream_key:
-- print('[ERROR] Stream key required. Use --stream-key or set OV_STREAM_KEY env var')
-- sys.exit(1)
--
-- bridge = CozmoHardwareBridge(args.url, args.stream_key)
--
-- try:
-- bridge.run()
-- except KeyboardInterrupt:
-- print('\n[Bridge] Shutting down...')
-- bridge.stop()
--
--
--if __name__ == '__main__':
-- main()
--
--
--Connects to the OpenVibe.Live Control WebSocket as a hardware client,
--translates incoming commands into pycozmo actions on a physical Cozmo robot.
--
--Requirements:
-- pip install pycozmo websocket-client
--
--Usage:
-- python3 cozmo-bridge.py --url ws://localhost:3000/ws/control --stream-key YOUR_STREAM_KEY
--
--Environment Variables (alternative to CLI args):
-- OV_WS_URL WebSocket URL (default: ws://localhost:3000/ws/control)
-- OV_STREAM_KEY Stream key for authentication
--
--Supported commands (sent from OpenVibe.Live control panel):
-- forward, backward, turn_left, turn_right,
-- lift_up, lift_down, head_up, head_down,
-- say:<text>, anim:<trigger>
--"""
--
--import argparse
--import json
--import os
--import sys
--import threading
--import time
--import traceback
--
--try:
-- import pycozmo
--except ImportError:
-- pycozmo = None
--
--try:
-- import websocket
--except ImportError:
-- websocket = None
--
--
--# ── Command → Cozmo Action Mapping ───────────────────────────
--
--DRIVE_SPEED = 100 # mm/s
--TURN_SPEED = 80 # mm/s differential
--DRIVE_DURATION = 0.4 # seconds
--LIFT_SPEED = 3.0 # rad/s
--HEAD_SPEED = 1.0 # rad/s
--MOVE_DURATION = 0.3 # seconds for lift/head
--
--
--def handle_command(cli, command_str):
-- """Translate a OpenVibe.Live control command to a pycozmo action."""
-- cmd = command_str.strip().lower()
--
-- if cmd == 'forward':
-- cli.drive_wheels(DRIVE_SPEED, DRIVE_SPEED, duration=DRIVE_DURATION)
-- elif cmd == 'backward':
-- cli.drive_wheels(-DRIVE_SPEED, -DRIVE_SPEED, duration=DRIVE_DURATION)
-- elif cmd == 'turn_left':
-- cli.drive_wheels(-TURN_SPEED, TURN_SPEED, duration=DRIVE_DURATION)
-- elif cmd == 'turn_right':
-- cli.drive_wheels(TURN_SPEED, -TURN_SPEED, duration=DRIVE_DURATION)
-- elif cmd == 'lift_up':
-- cli.move_lift(LIFT_SPEED)
-- time.sleep(MOVE_DURATION)
-- cli.move_lift(0.0)
-- elif cmd == 'lift_down':
-- cli.move_lift(-LIFT_SPEED)
-- time.sleep(MOVE_DURATION)
-- cli.move_lift(0.0)
-- elif cmd == 'head_up':
-- cli.move_head(HEAD_SPEED)
-- time.sleep(MOVE_DURATION)
-- cli.move_head(0.0)
-- elif cmd == 'head_down':
-- cli.move_head(-HEAD_SPEED)
-- time.sleep(MOVE_DURATION)
-- cli.move_head(0.0)
-- elif cmd.startswith('say:'):
-- text = command_str[4:].strip()[:200]
-- if text:
-- cli.say(text)
-- elif cmd.startswith('anim:'):
-- trigger = command_str[5:].strip()
-- try:
-- cli.play_anim_trigger(getattr(pycozmo.anim.Triggers, trigger))
-- except AttributeError:
-- print(f'[Cozmo] Unknown animation trigger: {trigger}')
-- else:
-- print(f'[Cozmo] Unknown command: {cmd}')
-- return False
--
-- return True
--
--
--# ── WebSocket Client ──────────────────────────────────────────
--
--class CozmoHardwareBridge:
-- """Bridges OpenVibe.Live control WebSocket ↔ physical Cozmo robot."""
--
-- def __init__(self, ws_url, stream_key):
-- self.ws_url = ws_url
-- self.stream_key = stream_key
-- self.ws = None
-- self.cozmo_cli = None
-- self.running = False
-- self._reconnect_delay = 2
--
-- def connect_cozmo(self):
-- """Connect to Cozmo over BLE via pycozmo."""
-- if pycozmo is None:
-- print('[Cozmo] pycozmo not installed — running in DRY RUN mode')
-- print('[Cozmo] Install with: pip install pycozmo')
-- return None
--
-- print('[Cozmo] Connecting to Cozmo robot...')
-- cli = pycozmo.Client()
-- cli.start()
-- cli.connect()
-- cli.wait_for_robot()
-- print('[Cozmo] Robot connected')
-- return cli
--
-- def send_status(self, status_data):
-- """Send a status update back to OpenVibe.Live."""
-- if self.ws:
-- try:
-- self.ws.send(json.dumps({'type': 'status', **status_data}))
-- except Exception:
-- pass
--
-- def on_ws_message(self, ws, raw):
-- try:
-- msg = json.loads(raw)
-- except json.JSONDecodeError:
-- return
--
-- if msg.get('type') == 'connected':
-- print(f'[WS] Registered as hardware client')
-- self.send_status({'cozmo': 'connected' if self.cozmo_cli else 'dry_run'})
-- return
--
-- if msg.get('type') == 'command':
-- cmd = msg.get('command', '')
-- user = msg.get('from_user', '?')
-- print(f'[CMD] {user} → {cmd}')
--
-- if self.cozmo_cli:
-- try:
-- handle_command(self.cozmo_cli, cmd)
-- except Exception as e:
-- print(f'[Cozmo] Command error: {e}')
-- self.send_status({'error': str(e)})
-- else:
-- # Dry-run mode — just log
-- print(f'[DRY RUN] Would execute: {cmd}')
--
-- def on_ws_error(self, ws, error):
-- print(f'[WS] Error: {error}')
--
-- def on_ws_close(self, ws, code, reason):
-- print(f'[WS] Disconnected (code={code})')
--
-- def on_ws_open(self, ws):
-- print(f'[WS] Connected to {self.ws_url}')
--
-- def run(self):
-- """Main loop: connect to Cozmo, then maintain WS connection."""
-- if websocket is None:
-- print('[ERROR] websocket-client not installed')
-- print('Install with: pip install websocket-client')
-- sys.exit(1)
--
-- self.running = True
--
-- # Connect to Cozmo first
-- try:
-- self.cozmo_cli = self.connect_cozmo()
-- except Exception as e:
-- print(f'[Cozmo] Connection failed: {e} — continuing in DRY RUN mode')
-- self.cozmo_cli = None
--
-- # WebSocket reconnect loop
-- while self.running:
-- try:
-- url = f'{self.ws_url}?mode=hardware&stream_key={self.stream_key}'
-- self.ws = websocket.WebSocketApp(
-- url,
-- on_open=self.on_ws_open,
-- on_message=self.on_ws_message,
-- on_error=self.on_ws_error,
-- on_close=self.on_ws_close,
-- )
-- self.ws.run_forever(ping_interval=30, ping_timeout=10)
-- except Exception as e:
-- print(f'[WS] Connection failed: {e}')
--
-- if self.running:
-- print(f'[WS] Reconnecting in {self._reconnect_delay}s...')
-- time.sleep(self._reconnect_delay)
-- self._reconnect_delay = min(self._reconnect_delay * 2, 30)
--
-- def stop(self):
-- self.running = False
-- if self.ws:
-- self.ws.close()
-- if self.cozmo_cli:
-- try:
-- self.cozmo_cli.disconnect()
-- self.cozmo_cli.stop()
-- except Exception:
-- pass
--
--
--# ── CLI Entry Point ───────────────────────────────────────────
--
--def main():
-- parser = argparse.ArgumentParser(description='OpenVibe.Live Cozmo Hardware Bridge')
-- parser.add_argument('--url', default=os.environ.get('OV_WS_URL', 'ws://localhost:3000/ws/control'),
-- help='OpenVibe.Live control WebSocket URL')
-- parser.add_argument('--stream-key', default=os.environ.get('OV_STREAM_KEY', ''),
-- help='Stream key for authentication')
-- args = parser.parse_args()
--
-- if not args.stream_key:
-- print('[ERROR] Stream key required. Use --stream-key or set OV_STREAM_KEY')
-- sys.exit(1)
--
-- bridge = CozmoHardwareBridge(args.url, args.stream_key)
--
-- try:
-- bridge.run()
-- except KeyboardInterrupt:
-- print('\n[Bridge] Shutting down...')
-- bridge.stop()
--
--
--if __name__ == '__main__':
-- main()
ddiff --git a/server/auth/auth.js b/server/auth/auth.js
iindex 4f587463..6fce5e6e 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -214,7 +214,6 @@ const TOKEN_WRITE_SCOPES = [
['/api/chat', ['chat']], ['/api/dm', ['chat']], ['/api/emotes', ['chat']], ['/api/sounds', ['chat']], ['/api/tts', ['chat']],
['/api/streams', ['stream']], ['/api/restream', ['stream']], ['/api/robotstreamer', ['stream']], ['/api/thumbnails', ['stream']],
['/api/vods', ['stream']], ['/api/clips', ['stream']], ['/api/media', ['stream', 'chat']],
-- ['/api/controls', ['control']], ['/api/onvif', ['control']],
['/api/vibe-coding', ['vibe_coding_publish']], // not the broad `stream` scope
];
function apiTokenAllows(req, scopes) {
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex bca42897..f64b58a1 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -707,12 +707,6 @@ const TOKEN_SCOPE_DEFINITIONS = Object.freeze([
title: 'Stream control',
description: 'Start or stop streams and update stream state or metadata.',
},
-- {
-- id: 'control',
-- label: 'control',
-- title: 'Hardware control bridge',
-- description: 'Use the hardware control bridge and related remote-control surfaces.',
-- },
{
id: 'vibe_coding_publish',
label: 'vibe_coding_publish',
@@@ -739,9 +733,9 @@ const TOKEN_PRESET_DEFINITIONS = Object.freeze([
{
id: 'stream-controller',
label: 'Stream Controller',
-- description: 'Recommended for integrations that control live state, metadata, or hardware workflows.',
++ description: 'Recommended for integrations that control live state and stream metadata.',
suggested_label: 'Stream Controller',
-- scopes: ['read', 'stream', 'control'],
++ scopes: ['read', 'stream'],
},
]);
ddiff --git a/server/bot/bot-client.js b/server/bot/bot-client.js
nnew file mode 100644
iindex 00000000..2ca55292
---- /dev/null
++++ b/server/bot/bot-client.js
@@@ -0,0 +1,68 @@
++/**
++ * OpenVibe.Live → OpenVibe.Bot: a channel's chat commands (!forward, !say …, relayed by OpenVibe.Chat through
++ * POST /internal/chat-effects/hardware) drive the robot the channel is bound to, through Bot's own gate as the
++ * viewer who typed them. Live keeps no robot code and no control socket (plan T15).
++ *
++ * command(robotId, subject, { id, kind, value, ms }) → Bot's command_result ({ result, … }); a refusal throws
++ * a BotRefusal with Bot's code and reason
++ *
++ * The token is Live's client-credentials token for audience openvibe.bot (Network's grant to `live`:
++ * bot.robot.control); X-OV-Subject names the viewer, so Bot's roles, queue, cooldowns and e-stop apply to them.
++ * In a restore drill (LIVE_DRILL) nothing is sent.
++ */
++'use strict';
++
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++
++const AUDIENCE = 'openvibe.bot';
++const TIMEOUT_MS = 5000;
++
++let tokens = null;
++let lastLog = null;
++
++function drill() { try { return require('../drill').enabled; } catch { return false; } }
++const baseUrl = () => String(process.env.LIVE_BOT_INTERNAL_URL || 'http://127.0.0.1:4630').replace(/\/+$/, '');
++
++class BotRefusal extends Error {
++ constructor(code, reason, status) { super(reason || code); this.code = code; this.status = status; }
++}
++class BotUnavailable extends Error {}
++
++function note(err) {
++ const m = `OpenVibe.Bot: ${(err && err.message) || err}`;
++ if (m === lastLog) return;
++ lastLog = m;
++ console.warn(`[Bot] ${m}`);
++}
++
++function serviceTokens() {
++ if (tokens) return tokens;
++ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';
++ if (!clientSecret) return null;
++ const networkInternalUrl = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
++ tokens = createServiceTokenClient({ tokenUrl: `${networkInternalUrl}/oauth/token`, clientId: process.env.OV_OAUTH_CLIENT_ID || 'live', clientSecret, audience: AUDIENCE });
++ return tokens;
++}
++
++async function command(robotId, subject, { id, kind, value, ms }) {
++ if (drill()) throw new BotUnavailable('restore drill: nothing is sent');
++ const client = serviceTokens();
++ if (!client) throw new BotUnavailable('OV_OAUTH_CLIENT_SECRET is not set');
++ let res;
++ try {
++ res = await fetch(`${baseUrl()}/api/v1/robots/${encodeURIComponent(robotId)}/commands`, {
++ method: 'POST',
++ headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${await client.getToken()}`, 'X-OV-Subject': subject },
++ body: JSON.stringify({ id, kind, value, ms }),
++ signal: AbortSignal.timeout(TIMEOUT_MS),
++ });
++ } catch (err) { note(err); throw new BotUnavailable(err.message); }
++ const out = await res.json().catch(() => ({}));
++ if (res.status === 401) client.invalidate && client.invalidate();
++ if (res.status >= 500) { note(`answered ${res.status}`); throw new BotUnavailable(`answered ${res.status}`); }
++ if (!res.ok) throw new BotRefusal(out.code || `http_${res.status}`, out.detail || null, res.status);
++ lastLog = null;
++ return out;
++}
++
++module.exports = { command, BotRefusal, BotUnavailable, AUDIENCE };
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex e5e047a4..b5637670 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -484,47 +484,66 @@ effectsRouter.post('/media-queue', async (req, res) => {
}
});
--// Cozmo / !say: the robot's control socket is in Live, keyed by the streamer's stream key. Chat commands pass the
--// same gate as the control panel (control-server.js validateControlPermission): the channel's control mode, its
--// anonymous-control switch and its whitelist, judged on the viewer Chat names (`from_user_id`; none = anonymous),
--// plus a per-viewer cooldown so a chat flood cannot drive a robot faster than a held key.
--const HARDWARE_COMMANDS = new Set(['forward', 'backward', 'turn_left', 'turn_right', 'lift_up', 'lift_down', 'head_up', 'head_down']);
++// !forward, !say … : a channel's chat commands drive the OpenVibe.Bot robot the channel is bound to (channels.bot_robot_id),
++// through Bot's gate as the viewer who typed them (server/bot/bot-client.js): the robot's access policy, queue, turn
++// budget, cooldowns and e-stop all apply, and Bot audits every one. Live keeps no robot code and no control socket
++// (plan T15). OpenVibe.Chat names the viewer by Network subject (`from_subject`); a viewer who is not signed in cannot
++// drive a robot. Each motion is a short pulse: Bot clamps `ms` to the profile's max_command_ms and the device stops
++// by itself after it. A per-viewer cooldown keeps a chat flood from queueing faster than a held key.
++const CHAT_ROBOT_COMMANDS = {
++ forward: { kind: 'drive', value: { throttle: 0.6 } },
++ backward: { kind: 'drive', value: { throttle: -0.6 } },
++ turn_left: { kind: 'drive', value: { steer: -0.7 } },
++ turn_right: { kind: 'drive', value: { steer: 0.7 } },
++ lift_up: { kind: 'actuator', value: { name: 'lift', value: 1 } },
++ lift_down: { kind: 'actuator', value: { name: 'lift', value: -1 } },
++ head_up: { kind: 'actuator', value: { name: 'head', value: 1 } },
++ head_down: { kind: 'actuator', value: { name: 'head', value: -1 } },
++};
++const CHAT_ROBOT_MS = 600;
const HARDWARE_COOLDOWN_MS = 250;
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const hardwareLast = new Map(); // `${streamer}:${viewer}` → last command ms
--async function hardwareRefusal(owner, actorId) {
-- const channel = await db.getChannelByUserId(owner.id);
-- if (!channel) return null;
-- const mode = channel.control_mode || 'open';
-- if (mode === 'disabled') return 'controls_disabled';
-- if (!actorId && (!channel.anon_controls_enabled || mode === 'whitelist')) return 'login_required';
-- if (mode === 'whitelist' && actorId !== owner.id
-- && !await db.get('SELECT 1 FROM control_whitelist WHERE channel_id = ? AND user_id = ?', [channel.id, actorId])) return 'not_whitelisted';
-- return null;
--}
++// What a refusal from Bot means, in chat words (Bot's codes, OpenVibe.Bot docs/protocol.md §2).
++const ROBOT_REFUSALS = {
++ 'bot.not_an_operator': 'You cannot drive this robot. Its owner decides who may.',
++ 'bot.not_your_turn': 'It is not your turn at the robot yet.',
++ 'bot.turn_budget': 'Your turn at the robot is used up.',
++ 'bot.cooldown': null,
++ 'bot.estop_latched': 'The robot\'s emergency stop is on.',
++ 'bot.device_offline': 'The robot is offline.',
++ 'bot.command_not_allowed': 'This robot does not do that.',
++ 'bot.unknown_actuator': 'This robot does not do that.',
++ 'bot.robot_not_found': 'This channel\'s robot no longer exists.',
++};
effectsRouter.post('/hardware', async (req, res) => {
const command = String(req.body?.command || '');
-- if (!HARDWARE_COMMANDS.has(command) && !/^say:[\s\S]{1,200}$/.test(command)) return fail(res, 400, 'unknown command');
++ const say = /^say:([\s\S]{1,200})$/.exec(command);
++ const mapped = say ? { kind: 'say', value: { text: say[1] } } : CHAT_ROBOT_COMMANDS[command];
++ if (!mapped) return fail(res, 400, 'unknown command');
const user = await db.getUserById(int(req.body.streamer_user_id));
if (!user) return res.json({ ok: false, reason: 'no_user' });
-- const actorId = int(req.body.from_user_id) || null;
-- const refused = await hardwareRefusal(user, actorId);
-- if (refused) return res.json({ ok: false, reason: refused });
-- const who = actorId ? `u${actorId}` : `a:${String(req.body.from_anon || req.body.from_user || '').slice(0, 64)}`;
-- const key = `${user.id}:${who}`;
++ const channel = await db.getChannelByUserId(user.id);
++ if (!channel || !channel.bot_robot_id) return res.json({ ok: false, reason: 'no_robot', message: 'This channel has no robot. Its owner can bind one from OpenVibe.Bot.' });
++ let subject = SUBJECT_RE.test(String(req.body.from_subject || '')) ? String(req.body.from_subject) : null;
++ if (!subject && int(req.body.from_user_id)) subject = await require('../auth/identity-sync').subjectOf(int(req.body.from_user_id));
++ if (!subject) return res.json({ ok: false, reason: 'login_required', message: 'Sign in to drive the robot.' });
++ const key = `${user.id}:${subject}`;
const now = Date.now();
if (now - (hardwareLast.get(key) || 0) < HARDWARE_COOLDOWN_MS) return res.json({ ok: false, reason: 'cooldown' });
hardwareLast.set(key, now);
if (hardwareLast.size > 5000) hardwareLast.delete(hardwareLast.keys().next().value);
-- const controlServer = require('../controls/control-server');
-- const hwWs = controlServer.hardwareClients.get(user.stream_key);
-- if (!hwWs || hwWs.readyState !== 1) return res.json({ ok: false, reason: 'no_hardware' });
-- hwWs.send(JSON.stringify({
-- type: 'command',
-- command,
-- from_user: String(req.body.from_user || ''),
-- timestamp: new Date().toISOString(),
-- }));
-- res.json({ ok: true });
++ const bot = require('../bot/bot-client');
++ try {
++ const out = await bot.command(channel.bot_robot_id, subject, { id: `chat-${require('crypto').randomUUID()}`, ...mapped, ...(mapped.kind === 'say' ? {} : { ms: CHAT_ROBOT_MS }) });
++ return res.json({ ok: true, result: out.result || null });
++ } catch (err) {
++ if (err instanceof bot.BotRefusal) {
++ const message = Object.prototype.hasOwnProperty.call(ROBOT_REFUSALS, err.code) ? ROBOT_REFUSALS[err.code] : (err.message || 'The robot refused that.');
++ return res.json({ ok: false, reason: err.code, ...(message ? { message } : {}) });
++ }
++ return res.json({ ok: false, reason: 'robot_unavailable', message: 'The robot service did not answer. Try again in a moment.' });
++ }
});
// /paste from chat, through Live's pastes client (OpenVibe.Community).
ddiff --git a/server/controls/control-server.js b/server/controls/control-server.js
ddeleted file mode 100644
iindex 37c3bfc4..00000000
---- a/server/controls/control-server.js
++++ /dev/null
@@@ -1,572 +0,0 @@
--/**
-- * OpenVibe.Live — Control Server
-- *
-- * WebSocket server for interactive camp controls.
-- * Viewers send commands → server relays → hardware client (Raspberry Pi) OR ONVIF camera.
-- *
-- * Architecture:
-- * Browser → WebSocket → Control Server → (Hardware WS OR ONVIF) → Endpoint
-- */
--const WebSocket = require('ws');
--const db = require('../db/database');
--const { authenticateWs } = require('../auth/auth');
--const { OnvifClient } = require('../core/onvif-client');
--
--class ControlServer {
-- constructor() {
-- this.wss = null;
-- /** @type {Map<string, WebSocket>} streamKey → hardware client WebSocket */
-- this.hardwareClients = new Map();
-- /** @type {Map<WebSocket, { user: object, streamId: number, heldKeys: Set<string> }>} */
-- this.viewerClients = new Map();
-- /** @type {Map<string, number>} `${streamId}-${controlId}-${userId}` → last command timestamp */
-- this.commandCounts = new Map();
-- /** @type {Map<string, number[]>} `${streamId}-burst-${userId}` → recent command timestamps (anti-flood burst cap) */
-- this.commandBursts = new Map();
-- }
--
-- /**
-- * Initialize the control WebSocket server
-- */
-- init(server) {
-- this.wss = new WebSocket.Server({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
--
-- this.wss.on('connection', (ws, req) => {
-- this.handleConnection(ws, req);
-- });
--
-- console.log('[Control] WebSocket control server initialized');
-- return this.wss;
-- }
--
-- /**
-- * Handle WebSocket upgrade for control connections
-- */
-- handleUpgrade(req, socket, head) {
-- if (req.url.startsWith('/ws/control')) {
-- this.wss.handleUpgrade(req, socket, head, (ws) => {
-- this.wss.emit('connection', ws, req);
-- });
-- return true;
-- }
-- return false;
-- }
--
-- /**
-- * Handle a new control connection
-- */
-- handleConnection(ws, req) {
-- const urlParams = new URL(req.url, 'http://localhost').searchParams;
-- const token = urlParams.get('token');
-- const streamKey = urlParams.get('stream_key');
-- const mode = urlParams.get('mode'); // 'hardware' or 'viewer'
--
-- if (mode === 'hardware') {
-- this.handleHardwareConnection(ws, streamKey).catch((e) => console.warn('[Control] hardware connection failed:', e && e.message));
-- } else {
-- this.handleViewerConnection(ws, token, urlParams).catch((e) => console.warn('[Control] viewer connection failed:', e && e.message));
-- }
-- }
--
-- /**
-- * Handle hardware client connection (Raspberry Pi / controller)
-- */
-- async handleHardwareConnection(ws, streamKey) {
-- if (!streamKey) {
-- ws.close(4001, 'Stream key required');
-- return;
-- }
--
-- const user = await db.getUserByStreamKey(streamKey);
-- if (!user) {
-- ws.close(4002, 'Invalid stream key');
-- return;
-- }
--
-- this.hardwareClients.set(streamKey, ws);
-- console.log(`[Control] Hardware client connected: ${user.username} (${streamKey.slice(0, 8)}...)`);
--
-- ws.send(JSON.stringify({ type: 'connected', message: 'Hardware client registered' }));
--
-- // Notify all viewers watching this stream that hardware is now online
-- await this.broadcastToViewers(streamKey, {
-- type: 'hardware_status',
-- connected: true,
-- });
--
-- ws.on('message', (data) => {
-- try {
-- const msg = JSON.parse(data.toString());
-- // Hardware can send status updates back
-- if (msg.type === 'status') {
-- this.broadcastToViewers(streamKey, {
-- type: 'hardware_status',
-- ...msg,
-- }).catch(() => {});
-- }
-- } catch { /* ignore */ }
-- });
--
-- ws.on('close', () => {
-- this.hardwareClients.delete(streamKey);
-- console.log(`[Control] Hardware client disconnected: ${streamKey.slice(0, 8)}...`);
--
-- // Notify all viewers that hardware went offline
-- this.broadcastToViewers(streamKey, {
-- type: 'hardware_status',
-- connected: false,
-- }).catch(() => {});
-- });
-- }
--
-- /**
-- * Handle viewer control connection
-- */
-- async handleViewerConnection(ws, token, params) {
-- const streamId = parseInt(params.get('stream')) || null;
-- const user = await authenticateWs(token);
--
-- this.viewerClients.set(ws, { user, streamId, heldKeys: new Set() });
--
-- // Send available controls + channel control settings + hardware connection status
-- if (streamId) {
-- const controls = await db.getStreamControls(streamId);
-- const stream = await db.getStreamById(streamId);
-- let controlSettings = {};
-- let hardwareConnected = false;
-- if (stream) {
-- const streamUser = await db.getUserById(stream.user_id);
-- const channel = await db.getChannelByUserId(stream.user_id);
-- if (channel) {
-- controlSettings = {
-- control_mode: channel.control_mode || 'open',
-- anon_controls_enabled: !!channel.anon_controls_enabled,
-- control_rate_limit_ms: channel.control_rate_limit_ms || 100,
-- video_click_enabled: !!channel.video_click_enabled,
-- video_click_rate_limit_ms: channel.video_click_rate_limit_ms || 0,
-- };
-- }
-- // Check if hardware client is currently connected for this stream
-- if (streamUser) {
-- const hwWs = this.hardwareClients.get(streamUser.stream_key);
-- hardwareConnected = !!(hwWs && hwWs.readyState === WebSocket.OPEN);
-- }
-- }
-- ws.send(JSON.stringify({ type: 'controls', controls, settings: controlSettings }));
-- // Send initial hardware connection status so viewer knows immediately
-- ws.send(JSON.stringify({ type: 'hardware_status', connected: hardwareConnected }));
-- }
--
-- ws.on('message', async (data) => {
-- let msg;
-- try { msg = JSON.parse(data.toString()); } catch { return; } // malformed frame: ignore
-- try {
-- if (msg.type === 'command') {
-- // Awaited: handleCommand is async, so without this a rejection (a bad camera row,
-- // a closed socket) escaped the try as an unhandled rejection nobody could see.
-- await this.handleCommand(ws, msg);
-- } else if (msg.type === 'key_down' || msg.type === 'key_up') {
-- await this.handleKeyEvent(ws, msg);
-- } else if (msg.type === 'video_click') {
-- await this.handleVideoClick(ws, msg);
-- }
-- } catch (err) {
-- console.warn('[Control] Message handling failed:', err && err.message);
-- }
-- });
--
-- ws.on('close', async () => {
-- // Force-release any keys this viewer was holding
-- const closingClient = this.viewerClients.get(ws);
-- if (closingClient && closingClient.heldKeys.size > 0 && closingClient.streamId) {
-- const stream = await db.getStreamById(closingClient.streamId);
-- if (stream) {
-- const user = await db.getUserById(stream.user_id);
-- if (user) {
-- const hardwareWs = this.hardwareClients.get(user.stream_key);
-- if (hardwareWs && hardwareWs.readyState === WebSocket.OPEN) {
-- for (const holdKey of closingClient.heldKeys) {
-- // holdKey format: `${streamId}-key-${command}-${userId}`
-- const parts = holdKey.split('-key-');
-- if (parts.length >= 2) {
-- const command = parts[1].substring(0, parts[1].lastIndexOf('-'));
-- hardwareWs.send(JSON.stringify({
-- type: 'key_up',
-- command,
-- from_user: closingClient.user?.username || 'anonymous',
-- reason: 'viewer_disconnected',
-- timestamp: new Date().toISOString(),
-- }));
-- }
-- }
-- }
-- // Broadcast key releases to other viewers
-- for (const holdKey of closingClient.heldKeys) {
-- const parts = holdKey.split('-key-');
-- if (parts.length >= 2) {
-- const command = parts[1].substring(0, parts[1].lastIndexOf('-'));
-- this.broadcastToViewers(user.stream_key, {
-- type: 'key_released',
-- command,
-- by: closingClient.user?.username || 'anonymous',
-- }).catch(() => {});
-- }
-- }
-- }
-- }
-- closingClient.heldKeys.clear();
-- }
-- this.viewerClients.delete(ws);
-- });
-- }
--
-- /**
-- * Handle a control command from a viewer
-- */
-- async handleCommand(ws, msg) {
-- const client = this.viewerClients.get(ws);
-- if (!client || !client.streamId) return;
--
-- const { command, control_id, isOnvif, cameraId, movement } = msg;
-- if (!command && !isOnvif) return;
--
-- const ctx = await this.validateControlPermission(ws, client);
-- if (!ctx) return;
-- const { stream, user, channel } = ctx;
--
-- // ── Rate limiting ─────────────────────────────────────
-- // Real per-command throttling is the per-button cooldown_ms below. The only
-- // global limit is a fixed anti-flood safety cap (not user-configurable): a
-- // ridiculous burst ceiling that just stops a spammer from firehosing commands.
-- const now = Date.now();
-- const userId = client.user?.id || 'anon';
-- const BURST_WINDOW_MS = 1000;
-- const BURST_MAX = 20; // ≤20 commands/sec/user
-- const burstKey = `${client.streamId}-burst-${userId}`;
-- let hits = this.commandBursts.get(burstKey);
-- if (!hits) { hits = []; this.commandBursts.set(burstKey, hits); }
-- // Drop timestamps outside the window.
-- while (hits.length && now - hits[0] > BURST_WINDOW_MS) hits.shift();
-- if (hits.length >= BURST_MAX) {
-- ws.send(JSON.stringify({ type: 'cooldown', message: 'Too many commands — slow down' }));
-- return;
-- }
--
-- // The command that reaches the hardware is the stream's own configured button, never a string the
-- // viewer typed. Taking `command` from the message let any viewer send commands the streamer
-- // never configured or had disabled, skipping the button's cooldown by leaving out control_id.
-- let control = null;
-- if (!isOnvif) {
-- control = control_id
-- ? await db.get('SELECT * FROM stream_controls WHERE id = ? AND stream_id = ? AND is_enabled = 1', [control_id, client.streamId])
-- : await db.get('SELECT * FROM stream_controls WHERE stream_id = ? AND command = ? AND is_enabled = 1 LIMIT 1', [client.streamId, String(command || '')]);
-- if (!control) {
-- ws.send(JSON.stringify({ type: 'error', message: 'That control is not available on this stream' }));
-- return;
-- }
-- }
-- if (control) {
-- {
-- const buttonCooldownMs = Number.isFinite(control.cooldown_ms) ? parseInt(control.cooldown_ms, 10) : 100;
-- const key = `${client.streamId}-command-${control.id}-${userId}`;
-- const lastCmd = this.commandCounts.get(key) || 0;
-- if (now - lastCmd < buttonCooldownMs) {
-- ws.send(JSON.stringify({ type: 'cooldown', message: 'Command on cooldown' }));
-- return;
-- }
-- this.commandCounts.set(key, now);
-- }
-- }
-- hits.push(now);
--
-- // Handle ONVIF camera movement
-- if (isOnvif && cameraId && movement) {
-- try {
-- const camera = await db.getCameraProfile(cameraId);
-- // The camera has to belong to the stream being controlled (or be a global profile of
-- // the same streamer). Any camera id used to work from any stream's control socket.
-- const cameraOnThisStream = camera && (camera.stream_id === client.streamId
-- || (camera.stream_id == null && camera.user_id === stream.user_id));
-- if (!cameraOnThisStream) {
-- ws.send(JSON.stringify({ type: 'error', message: 'Camera not found' }));
-- return;
-- }
--
-- // Create ONVIF client and execute movement
-- const bcrypt = require('bcryptjs');
-- const password = camera.password_hash; // For now, hash IS the plaintext (MVP)
--
-- const onvifClient = new OnvifClient(camera.onvif_url, camera.username, password);
-- const connected = await Promise.race([
-- onvifClient.connect(),
-- new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 3000))
-- ]);
--
-- if (!connected) {
-- ws.send(JSON.stringify({ type: 'error', message: 'Camera unreachable' }));
-- return;
-- }
--
-- // Execute movement command
-- const movements = {
-- 'pan_left': [-camera.pan_speed, 0, 0],
-- 'pan_right': [camera.pan_speed, 0, 0],
-- 'tilt_up': [0, camera.tilt_speed, 0],
-- 'tilt_down': [0, -camera.tilt_speed, 0],
-- 'zoom_in': [0, 0, camera.zoom_speed],
-- 'zoom_out': [0, 0, -camera.zoom_speed],
-- };
--
-- const [panSpeed, tiltSpeed, zoomSpeed] = movements[movement] || [0, 0, 0];
--
-- if (panSpeed !== 0 || tiltSpeed !== 0 || zoomSpeed !== 0) {
-- await onvifClient.relativeMove(panSpeed, tiltSpeed, zoomSpeed, 300);
-- }
--
-- onvifClient.disconnect();
--
-- // Broadcast activity to other viewers
-- await this.broadcastToViewers(user.stream_key, {
-- type: 'onvif_activity',
-- camera_name: camera.name,
-- movement,
-- by: client.user?.username || 'anonymous',
-- });
--
-- ws.send(JSON.stringify({ type: 'ok', message: 'Movement executed' }));
--
-- } catch (err) {
-- console.error('[Control] ONVIF error:', err.message);
-- ws.send(JSON.stringify({ type: 'error', message: 'Camera command failed: ' + err.message }));
-- }
-- return;
-- }
--
-- // Handle traditional hardware commands
-- if (!stream.is_live) {
-- ws.send(JSON.stringify({ type: 'error', message: 'Stream not live' }));
-- return;
-- }
--
-- // Check if hardware client is connected
-- const hardwareWs = this.hardwareClients.get(user.stream_key);
-- if (!hardwareWs || hardwareWs.readyState !== WebSocket.OPEN) {
-- ws.send(JSON.stringify({ type: 'error', message: 'Hardware not connected' }));
-- return;
-- }
--
-- // Forward command to hardware
-- hardwareWs.send(JSON.stringify({
-- type: 'command',
-- command: control.command,
-- control_id: control.id,
-- from_user: client.user?.username || 'anonymous',
-- timestamp: new Date().toISOString(),
-- }));
--
-- // Broadcast command activity to other viewers
-- await this.broadcastToViewers(user.stream_key, {
-- type: 'command_executed',
-- command: control.command,
-- by: client.user?.username || 'anonymous',
-- });
-- }
--
-- /**
-- * Validate control permissions (shared by command, key event, and click handlers)
-- */
-- async validateControlPermission(ws, client) {
-- const stream = await db.getStreamById(client.streamId);
-- if (!stream) return null;
--
-- const user = await db.getUserById(stream.user_id);
-- if (!user) return null;
--
-- const channel = await db.getChannelByUserId(stream.user_id);
-- if (channel) {
-- const mode = channel.control_mode || 'open';
-- if (mode === 'disabled') {
-- ws.send(JSON.stringify({ type: 'error', message: 'Controls are disabled' }));
-- return null;
-- }
-- if (!channel.anon_controls_enabled && !client.user) {
-- ws.send(JSON.stringify({ type: 'error', message: 'Login required to use controls' }));
-- return null;
-- }
-- if (mode === 'whitelist' && client.user) {
-- const isOwner = client.user.id === stream.user_id;
-- const isWhitelisted = await db.get(
-- 'SELECT 1 FROM control_whitelist WHERE channel_id = ? AND user_id = ?',
-- [channel.id, client.user.id]
-- );
-- if (!isOwner && !isWhitelisted) {
-- ws.send(JSON.stringify({ type: 'error', message: 'You are not on the control whitelist' }));
-- return null;
-- }
-- } else if (mode === 'whitelist' && !client.user) {
-- ws.send(JSON.stringify({ type: 'error', message: 'Login required for whitelist mode' }));
-- return null;
-- }
-- }
--
-- return { stream, user, channel };
-- }
--
-- /**
-- * Handle key_down / key_up events (hold detection)
-- */
-- async handleKeyEvent(ws, msg) {
-- const client = this.viewerClients.get(ws);
-- if (!client || !client.streamId) return;
--
-- const { command, control_id } = msg;
-- if (!command) return;
--
-- const ctx = await this.validateControlPermission(ws, client);
-- if (!ctx) return;
--
-- // Validate control exists, is keyboard type, and is enabled
-- if (control_id) {
-- const control = await db.get('SELECT * FROM stream_controls WHERE id = ? AND stream_id = ?', [control_id, client.streamId]);
-- if (!control) {
-- ws.send(JSON.stringify({ type: 'error', message: 'Control not found' }));
-- return;
-- }
-- if (control.control_type !== 'keyboard') {
-- ws.send(JSON.stringify({ type: 'error', message: 'Control is not a keyboard type' }));
-- return;
-- }
-- if (control.is_enabled === 0) {
-- ws.send(JSON.stringify({ type: 'error', message: 'Control is disabled' }));
-- return;
-- }
-- }
--
-- // Check stream is live
-- if (!ctx.stream.is_live) {
-- ws.send(JSON.stringify({ type: 'error', message: 'Stream not live' }));
-- return;
-- }
--
-- // Check hardware connection
-- const hardwareWs = this.hardwareClients.get(ctx.user.stream_key);
-- const hwConnected = hardwareWs && hardwareWs.readyState === WebSocket.OPEN;
--
-- const holdKey = `${client.streamId}-key-${command}-${client.user?.id || 'anon'}`;
-- const heldKeys = client.heldKeys;
--
-- if (msg.type === 'key_down') {
-- if (heldKeys.has(holdKey)) return;
-- // Reject key_down if hardware is not connected
-- if (!hwConnected) {
-- ws.send(JSON.stringify({ type: 'error', message: 'Hardware not connected' }));
-- return;
-- }
-- heldKeys.add(holdKey);
-- } else {
-- // key_up: always allow through to clean up state, even if hardware disconnected
-- if (!heldKeys.has(holdKey)) return;
-- heldKeys.delete(holdKey);
-- }
--
-- // Forward to hardware (if connected)
-- if (hwConnected) {
-- hardwareWs.send(JSON.stringify({
-- type: msg.type, // key_down or key_up
-- command,
-- control_id,
-- from_user: client.user?.username || 'anonymous',
-- timestamp: new Date().toISOString(),
-- }));
-- }
--
-- // Broadcast to viewers
-- await this.broadcastToViewers(ctx.user.stream_key, {
-- type: msg.type === 'key_down' ? 'key_held' : 'key_released',
-- command,
-- by: client.user?.username || 'anonymous',
-- });
-- }
--
-- /**
-- * Handle video click (x, y normalized 0-1)
-- */
-- async handleVideoClick(ws, msg) {
-- const client = this.viewerClients.get(ws);
-- if (!client || !client.streamId) return;
--
-- const x = parseFloat(msg.x);
-- const y = parseFloat(msg.y);
-- if (isNaN(x) || isNaN(y) || x < 0 || x > 1 || y < 0 || y > 1) return;
--
-- const ctx = await this.validateControlPermission(ws, client);
-- if (!ctx) return;
--
-- // Check that video click is enabled
-- if (ctx.channel && !ctx.channel.video_click_enabled) {
-- ws.send(JSON.stringify({ type: 'error', message: 'Video click not enabled' }));
-- return;
-- }
--
-- // Fixed anti-flood floor for video clicks (not user-configurable) — matches the
-- // ≤20 commands/sec/user burst philosophy for the button controls above.
-- const userId = client.user?.id || 'anon';
-- const key = `${client.streamId}-click-${userId}`;
-- const lastCmd = this.commandCounts.get(key) || 0;
-- const CLICK_MIN_INTERVAL_MS = 50; // 20 clicks/sec ceiling
-- if (Date.now() - lastCmd < CLICK_MIN_INTERVAL_MS) {
-- ws.send(JSON.stringify({ type: 'cooldown', message: 'Click on cooldown' }));
-- return;
-- }
-- this.commandCounts.set(key, Date.now());
--
-- // Forward to hardware
-- const hardwareWs = this.hardwareClients.get(ctx.user.stream_key);
-- if (hardwareWs && hardwareWs.readyState === WebSocket.OPEN) {
-- hardwareWs.send(JSON.stringify({
-- type: 'video_click',
-- x: Math.round(x * 10000) / 10000,
-- y: Math.round(y * 10000) / 10000,
-- from_user: client.user?.username || 'anonymous',
-- timestamp: new Date().toISOString(),
-- }));
-- }
--
-- // Broadcast click activity
-- await this.broadcastToViewers(ctx.user.stream_key, {
-- type: 'video_click_activity',
-- x: Math.round(x * 100) / 100,
-- y: Math.round(y * 100) / 100,
-- by: client.user?.username || 'anonymous',
-- });
--
-- ws.send(JSON.stringify({ type: 'ok', message: 'Click sent' }));
-- }
--
-- /**
-- * Broadcast to all viewers watching a specific stream
-- */
-- async broadcastToViewers(streamKey, data) {
-- const user = await db.getUserByStreamKey(streamKey);
-- if (!user) return;
--
-- const stream = await db.getStreamByUserId(user.id);
-- if (!stream) return;
--
-- const msg = JSON.stringify(data);
-- for (const [ws, client] of this.viewerClients) {
-- if (client.streamId === stream.id && ws.readyState === WebSocket.OPEN) {
-- ws.send(msg);
-- }
-- }
-- }
--
-- close() {
-- if (this.wss) {
-- this.wss.clients.forEach(ws => ws.close());
-- this.wss.close();
-- }
-- }
--}
--
--module.exports = new ControlServer();
ddiff --git a/server/controls/onvif-routes.js b/server/controls/onvif-routes.js
ddeleted file mode 100644
iindex dd424e48..00000000
---- a/server/controls/onvif-routes.js
++++ /dev/null
@@@ -1,444 +0,0 @@
--/**
-- * OpenVibe.Live — ONVIF Camera Control Routes
-- *
-- * GET /api/onvif/discover - Discover cameras on network
-- * POST /api/onvif/cameras - Create camera profile
-- * GET /api/onvif/cameras - List user's cameras
-- * GET /api/onvif/cameras/:id - Get camera details
-- * PUT /api/onvif/cameras/:id - Update camera settings
-- * DELETE /api/onvif/cameras/:id - Delete camera
-- * GET /api/onvif/cameras/:id/presets - List camera presets
-- * POST /api/onvif/cameras/:id/presets - Create preset
-- * DELETE /api/onvif/cameras/:cameraId/presets/:presetId - Delete preset
-- */
--
--const express = require('express');
--const bcrypt = require('bcryptjs');
--const crypto = require('crypto');
--const db = require('../db/database');
--const { can } = require('../auth/permissions');
--const { requireAuth } = require('../auth/auth');
--const { OnvifDiscovery, OnvifClient } = require('../core/onvif-client');
--
--const router = express.Router();
--
--// Cache ONVIF clients: cameraId -> OnvifClient instance
--const clientCache = new Map();
--const CLIENT_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
--
--/**
-- * Helper: get or create cached ONVIF client
-- */
--function getCachedClient(cameraId) {
-- const cached = clientCache.get(cameraId);
-- if (cached && Date.now() - cached.createdAt < CLIENT_CACHE_TTL) {
-- return cached.client;
-- }
-- return null;
--}
--
--function setCachedClient(cameraId, client) {
-- clientCache.set(cameraId, {
-- client,
-- createdAt: Date.now(),
-- });
--
-- // Auto-cleanup after TTL
-- setTimeout(() => {
-- const entry = clientCache.get(cameraId);
-- if (entry && entry.createdAt === clientCache.get(cameraId)?.createdAt) {
-- clientCache.delete(cameraId);
-- }
-- }, CLIENT_CACHE_TTL);
--}
--
--/**
-- * Helper: decrypt password (uses simple XOR with env key for MVP; upgrade to proper encryption)
-- */
--function decryptPassword(hash) {
-- // For now, hash IS the plaintext password (bcrypt).
-- // In production, use a proper encryption scheme with a master key.
-- return hash;
--}
--
--/**
-- * POST /api/onvif/discover
-- * Discover ONVIF devices on local network
-- */
--// Discovery multicasts on the SERVER's network and tries admin/admin on whatever answers. A streamer's
--// cameras are on their own network, never this one, so for anyone but staff this was only a way to
--// scan the hosting provider's LAN. Admin only, with a bounded timeout.
--router.post('/discover', requireAuth, (req, res, next) => (can(req.user, 'staff.hardware.manage') ? next() : res.status(403).json({ error: 'Camera discovery scans the server network and is limited to admins. Add your camera by address instead.' })), async (req, res) => {
-- try {
-- const discovery = new OnvifDiscovery();
-- const devices = await discovery.discover(Math.min(Math.max(Number(req.body.timeout) || 3000, 500), 5000));
--
-- // Test each discovered device for connectivity
-- const tested = await Promise.all(devices.map(async (device) => {
-- try {
-- const client = new OnvifClient(device.url, 'admin', 'admin');
-- const connected = await Promise.race([
-- client.connect(),
-- new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000))
-- ]);
--
-- if (connected) {
-- const caps = await client.getCapabilities();
-- client.disconnect();
-- return { ...device, testable: true, capabilities: caps };
-- }
-- } catch (e) {
-- // Device unreachable with default creds
-- }
-- return { ...device, testable: false };
-- }));
--
-- res.json({ discovered: tested });
-- } catch (err) {
-- res.status(500).json({ error: 'Discovery failed: ' + err.message });
-- }
--});
--
--/**
-- * POST /api/onvif/cameras
-- * Create a new camera profile
-- */
--router.post('/cameras', requireAuth, async (req, res) => {
-- try {
-- const { name, onvif_url, username, password, stream_id, pan_speed, tilt_speed, zoom_speed } = req.body;
--
-- if (!name || !onvif_url || !username || !password) {
-- return res.status(400).json({ error: 'Missing required fields' });
-- }
--
-- // Validate ONVIF URL
-- let url;
-- try {
-- url = new URL(onvif_url);
-- if (!['http:', 'https:'].includes(url.protocol)) {
-- throw new Error('Invalid protocol');
-- }
-- } catch (e) {
-- return res.status(400).json({ error: 'Invalid ONVIF URL' });
-- }
--
-- // Test connection
-- const testClient = new OnvifClient(onvif_url, username, password);
-- const connected = await Promise.race([
-- testClient.connect(),
-- new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000))
-- ]);
--
-- if (!connected) {
-- return res.status(400).json({ error: 'Cannot connect to camera. Check URL and credentials.' });
-- }
--
-- const capabilities = await testClient.getCapabilities();
-- testClient.disconnect();
--
-- // Hash password
-- const passwordHash = bcrypt.hashSync(password, 10);
--
-- // Check stream ownership if stream_id provided
-- if (stream_id) {
-- const stream = await db.getStreamById(stream_id);
-- if (!stream) {
-- return res.status(404).json({ error: 'Stream not found' });
-- }
-- if (stream.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not your stream' });
-- }
-- }
--
-- const result = await db.createCameraProfile({
-- user_id: req.user.id,
-- stream_id: stream_id || null,
-- name,
-- onvif_url,
-- username,
-- password_hash: passwordHash,
-- pan_speed: pan_speed || 0.5,
-- tilt_speed: tilt_speed || 0.5,
-- zoom_speed: zoom_speed || 0.5,
-- });
--
-- res.status(201).json({
-- id: result.lastInsertRowid,
-- name,
-- onvif_url,
-- username,
-- capabilities,
-- message: 'Camera profile created successfully',
-- });
-- } catch (err) {
-- console.error('[ONVIF] Create camera error:', err);
-- res.status(500).json({ error: 'Failed to create camera: ' + err.message });
-- }
--});
--
--/**
-- * GET /api/onvif/cameras
-- * List all cameras for authenticated user
-- */
--router.get('/cameras', requireAuth, async (req, res) => {
-- try {
-- const cameras = await db.getCameraProfilesByUser(req.user.id);
--
-- // Strip password hashes for safety
-- const safe = cameras.map(cam => ({
-- id: cam.id,
-- name: cam.name,
-- onvif_url: cam.onvif_url,
-- username: cam.username,
-- pan_speed: cam.pan_speed,
-- tilt_speed: cam.tilt_speed,
-- zoom_speed: cam.zoom_speed,
-- is_active: cam.is_active,
-- last_connected: cam.last_connected,
-- created_at: cam.created_at,
-- }));
--
-- res.json({ cameras: safe });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to list cameras' });
-- }
--});
--
--/**
-- * GET /api/onvif/cameras/:id
-- * Get camera profile
-- */
--router.get('/cameras/:id', requireAuth, async (req, res) => {
-- try {
-- const camera = await db.getCameraProfile(req.params.id);
--
-- if (!camera) {
-- return res.status(404).json({ error: 'Camera not found' });
-- }
--
-- if (camera.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not your camera' });
-- }
--
-- // Test connection status
-- let status = 'unknown';
-- try {
-- const client = getCachedClient(camera.id) || new OnvifClient(camera.onvif_url, camera.username, decryptPassword(camera.password_hash));
--
-- const connected = await Promise.race([
-- client.connect(),
-- new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000))
-- ]);
--
-- status = connected ? 'connected' : 'unreachable';
--
-- if (!getCachedClient(camera.id)) {
-- setCachedClient(camera.id, client);
-- }
-- } catch (e) {
-- status = 'error';
-- }
--
-- res.json({
-- id: camera.id,
-- name: camera.name,
-- onvif_url: camera.onvif_url,
-- username: camera.username,
-- pan_speed: camera.pan_speed,
-- tilt_speed: camera.tilt_speed,
-- zoom_speed: camera.zoom_speed,
-- is_active: camera.is_active,
-- status,
-- last_connected: camera.last_connected,
-- created_at: camera.created_at,
-- });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get camera' });
-- }
--});
--
--/**
-- * PUT /api/onvif/cameras/:id
-- * Update camera profile
-- */
--router.put('/cameras/:id', requireAuth, async (req, res) => {
-- try {
-- const camera = await db.getCameraProfile(req.params.id);
--
-- if (!camera) {
-- return res.status(404).json({ error: 'Camera not found' });
-- }
--
-- if (camera.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not your camera' });
-- }
--
-- const { name, onvif_url, username, password, pan_speed, tilt_speed, zoom_speed } = req.body;
--
-- // Re-test if URL/creds changed
-- let passwordHash = camera.password_hash;
-- if (onvif_url || username || password) {
-- const testUrl = onvif_url || camera.onvif_url;
-- const testUser = username || camera.username;
-- const testPass = password || decryptPassword(camera.password_hash);
--
-- const testClient = new OnvifClient(testUrl, testUser, testPass);
-- const connected = await Promise.race([
-- testClient.connect(),
-- new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000))
-- ]);
--
-- if (!connected) {
-- return res.status(400).json({ error: 'Cannot connect with new settings' });
-- }
--
-- testClient.disconnect();
--
-- if (password) {
-- passwordHash = bcrypt.hashSync(password, 10);
-- }
-- }
--
-- await db.updateCameraProfile(camera.id, {
-- name: name || camera.name,
-- onvif_url: onvif_url || camera.onvif_url,
-- username: username || camera.username,
-- password_hash: passwordHash,
-- pan_speed: pan_speed !== undefined ? pan_speed : camera.pan_speed,
-- tilt_speed: tilt_speed !== undefined ? tilt_speed : camera.tilt_speed,
-- zoom_speed: zoom_speed !== undefined ? zoom_speed : camera.zoom_speed,
-- });
--
-- // Invalidate cache
-- clientCache.delete(camera.id);
--
-- res.json({ message: 'Camera updated successfully' });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to update camera: ' + err.message });
-- }
--});
--
--/**
-- * DELETE /api/onvif/cameras/:id
-- * Delete camera profile (cascades to presets and control assignments)
-- */
--router.delete('/cameras/:id', requireAuth, async (req, res) => {
-- try {
-- const camera = await db.getCameraProfile(req.params.id);
--
-- if (!camera) {
-- return res.status(404).json({ error: 'Camera not found' });
-- }
--
-- if (camera.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not your camera' });
-- }
--
-- await db.deleteCameraProfile(camera.id);
-- clientCache.delete(camera.id);
--
-- res.json({ message: 'Camera deleted' });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to delete camera' });
-- }
--});
--
--/**
-- * GET /api/onvif/cameras/:id/presets
-- * List presets for a camera
-- */
--router.get('/cameras/:id/presets', requireAuth, async (req, res) => {
-- try {
-- const camera = await db.getCameraProfile(req.params.id);
--
-- if (!camera) {
-- return res.status(404).json({ error: 'Camera not found' });
-- }
--
-- if (camera.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not your camera' });
-- }
--
-- const presets = await db.getCameraPresetsByCamera(camera.id);
-- res.json({ presets });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to list presets' });
-- }
--});
--
--/**
-- * POST /api/onvif/cameras/:id/presets
-- * Create a new preset (saves current PTZ position)
-- */
--router.post('/cameras/:id/presets', requireAuth, async (req, res) => {
-- try {
-- const camera = await db.getCameraProfile(req.params.id);
--
-- if (!camera) {
-- return res.status(404).json({ error: 'Camera not found' });
-- }
--
-- if (camera.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not your camera' });
-- }
--
-- const { name, pan, tilt, zoom } = req.body;
--
-- if (!name || pan === undefined || tilt === undefined || zoom === undefined) {
-- return res.status(400).json({ error: 'Name and PTZ values required' });
-- }
--
-- // Clamp values to 0.0-1.0
-- const clampedPan = Math.max(0, Math.min(1, parseFloat(pan)));
-- const clampedTilt = Math.max(0, Math.min(1, parseFloat(tilt)));
-- const clampedZoom = Math.max(0, Math.min(1, parseFloat(zoom)));
--
-- const result = await db.createCameraPreset({
-- camera_id: camera.id,
-- name,
-- pan: clampedPan,
-- tilt: clampedTilt,
-- zoom: clampedZoom,
-- });
--
-- res.status(201).json({
-- id: result.lastInsertRowid,
-- name,
-- pan: clampedPan,
-- tilt: clampedTilt,
-- zoom: clampedZoom,
-- });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to create preset: ' + err.message });
-- }
--});
--
--/**
-- * DELETE /api/onvif/cameras/:cameraId/presets/:presetId
-- * Delete a preset
-- */
--router.delete('/cameras/:cameraId/presets/:presetId', requireAuth, async (req, res) => {
-- try {
-- const camera = await db.getCameraProfile(req.params.cameraId);
--
-- if (!camera) {
-- return res.status(404).json({ error: 'Camera not found' });
-- }
--
-- if (camera.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not your camera' });
-- }
--
-- const preset = await db.getCameraPreset(req.params.presetId);
-- if (!preset || preset.camera_id !== camera.id) {
-- return res.status(404).json({ error: 'Preset not found' });
-- }
--
-- await db.deleteCameraPreset(preset.id);
-- res.json({ message: 'Preset deleted' });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to delete preset' });
-- }
--});
--
--module.exports = router;
ddiff --git a/server/controls/routes.js b/server/controls/routes.js
ddeleted file mode 100644
iindex cc962ea8..00000000
---- a/server/controls/routes.js
++++ /dev/null
@@@ -1,1522 +0,0 @@
--/**
-- * OpenVibe.Live — Controls API Routes
-- *
-- * Config Profiles:
-- * GET /api/controls/configs - List user's control configs
-- * POST /api/controls/configs - Create a control config
-- * GET /api/controls/configs/:id - Get config with buttons
-- * PUT /api/controls/configs/:id - Update config name/description
-- * DELETE /api/controls/configs/:id - Delete config
-- * POST /api/controls/configs/:id/buttons - Add button to config
-- * PUT /api/controls/configs/:id/buttons/:btnId - Update button
-- * DELETE /api/controls/configs/:id/buttons/:btnId - Delete button
-- * POST /api/controls/configs/:id/activate - Activate config on channel
-- * POST /api/controls/configs/:id/apply/:streamId - Copy config to live stream
-- * GET /api/controls/configs/:id/bridge-script - Generate per-profile Python bridge script
-- *
-- * Per-Stream Controls (legacy):
-- * GET /api/controls/:streamId - Get controls for a stream
-- * POST /api/controls/:streamId - Add a control button
-- * PUT /api/controls/:streamId/:id - Update a control
-- * DELETE /api/controls/:streamId/:id - Delete a control
-- * POST /api/controls/api-key - Generate API key
-- * GET /api/controls/api-keys - List user's API keys
-- */
--const express = require('express');
--const crypto = require('crypto');
--const bcrypt = require('bcryptjs');
--const db = require('../db/database');
--const { can } = require('../auth/permissions');
--const { requireAuth } = require('../auth/auth');
--
--const router = express.Router();
--
--// Helper: re-apply this config to live streams that are explicitly bound to it
--async function syncConfigToBoundLiveStreams(configId) {
-- try {
-- const liveStreams = await db.getLiveStreamsByControlConfigId(configId);
-- for (const stream of liveStreams) {
-- try {
-- await db.applyConfigToStream(configId, stream.id);
-- } catch (e) {
-- console.warn(`[Controls] Failed to sync config ${configId} to stream ${stream.id}:`, e.message);
-- }
-- }
-- } catch (e) {
-- console.warn(`[Controls] syncConfigToBoundLiveStreams error:`, e.message);
-- }
--}
--
--// ── CSS Value Sanitization (prevent injection) ───────────────
--const CSS_COLOR_RE = /^(#[0-9a-fA-F]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*[\d.]+\s*)?\)|[a-zA-Z]{1,30}|var\(--[a-zA-Z0-9-]+\)|)$/;
--function sanitizeCssColor(val) {
-- if (!val || typeof val !== 'string') return '';
-- const trimmed = val.trim().slice(0, 60);
-- return CSS_COLOR_RE.test(trimmed) ? trimmed : '';
--}
--
--function generateCozmoScript(user, protocol, host) {
-- return `#!/usr/bin/env python3
--"""
--OpenVibe.Live — Cozmo Hardware Bridge (v2)
--Auto-generated for: ${user.username}
--
--Connects your Cozmo robot to OpenVibe.Live so viewers can control it.
--Supports: button commands, keyboard hold (key_down/key_up), video click (x,y).
--
--Requirements:
-- pip install pycozmo websocket-client
--
--Usage:
-- python3 cozmo-bridge.py
--"""
--import json
--import time
--import threading
--
--try:
-- import websocket
--except ImportError:
-- print("Missing websocket-client: pip install websocket-client")
-- exit(1)
--
--try:
-- import pycozmo
--except ImportError:
-- pycozmo = None
-- print("[Cozmo] pycozmo not installed — running in DRY RUN mode")
--
--WS_URL = "${protocol}://${host}/ws/control?mode=hardware&stream_key=${user.stream_key}"
--RECONNECT_DELAY = 5
--DRIVE_SPEED = 100
--TURN_SPEED = 80
--
--# Commands that support continuous driving (key_down -> drive, key_up -> stop)
--CONTINUOUS_COMMANDS = {
-- 'forward': (DRIVE_SPEED, DRIVE_SPEED),
-- 'backward': (-DRIVE_SPEED, -DRIVE_SPEED),
-- 'turn_left': (-TURN_SPEED, TURN_SPEED),
-- 'turn_right': (TURN_SPEED, -TURN_SPEED),
--}
--
--def execute_command(cli, cmd):
-- """Execute a one-shot command (button press)."""
-- if cli is None:
-- print(f"[DRY RUN] {cmd}")
-- return
-- movements = {
-- 'forward': lambda: cli.drive_wheels(DRIVE_SPEED, DRIVE_SPEED, duration=0.5),
-- 'backward': lambda: cli.drive_wheels(-DRIVE_SPEED, -DRIVE_SPEED, duration=0.5),
-- 'turn_left': lambda: cli.drive_wheels(-TURN_SPEED, TURN_SPEED, duration=0.4),
-- 'turn_right': lambda: cli.drive_wheels(TURN_SPEED, -TURN_SPEED, duration=0.4),
-- 'lift_up': lambda: cli.set_lift_height(1.0),
-- 'lift_down': lambda: cli.set_lift_height(0.0),
-- 'head_up': lambda: cli.set_head_angle(pycozmo.MAX_HEAD_ANGLE if pycozmo else 0.4),
-- 'head_down': lambda: cli.set_head_angle(pycozmo.MIN_HEAD_ANGLE if pycozmo else -0.2),
-- 'stop': lambda: cli.drive_wheels(0, 0),
-- }
-- if cmd in movements:
-- try:
-- movements[cmd]()
-- print(f"[Cozmo] {cmd}")
-- except Exception as e:
-- print(f"[Cozmo] Error: {e}")
-- elif cmd.startswith('anim_'):
-- try:
-- cli.play_anim_trigger(getattr(pycozmo.anim.Triggers, cmd[5:]))
-- except AttributeError:
-- print(f"[Cozmo] Unknown anim: {cmd}")
-- else:
-- print(f"[Cozmo] Unknown: {cmd}")
--
--
--class CozmoBridge:
-- def __init__(self):
-- self.cozmo_cli = None
-- self.running = True
-- self.held_keys = set() # Currently held commands
-- self._drive_thread = None
-- self._drive_lock = threading.Lock()
--
-- def connect_cozmo(self):
-- if pycozmo is None:
-- return
-- try:
-- self.cozmo_cli = pycozmo.Client()
-- self.cozmo_cli.start()
-- self.cozmo_cli.wait_for_robot()
-- print("[Cozmo] Connected!")
-- except Exception as e:
-- print(f"[Cozmo] Failed: {e}")
--
-- def _continuous_drive_loop(self):
-- """Background loop: while keys are held, keep sending drive commands."""
-- while self.running:
-- with self._drive_lock:
-- keys = set(self.held_keys)
-- if not keys:
-- time.sleep(0.05)
-- continue
-- # Pick the most recent held key for driving
-- for cmd in keys:
-- if cmd in CONTINUOUS_COMMANDS:
-- left, right = CONTINUOUS_COMMANDS[cmd]
-- if self.cozmo_cli:
-- try:
-- self.cozmo_cli.drive_wheels(left, right, duration=0.2)
-- except Exception:
-- pass
-- else:
-- print(f"[DRY RUN] continuous {cmd}")
-- break
-- time.sleep(0.15)
--
-- def handle_key_down(self, msg):
-- cmd = msg.get('command', '')
-- user = msg.get('from_user', '?')
-- print(f"[Key Down] {user} -> {cmd}")
-- with self._drive_lock:
-- self.held_keys.add(cmd)
-- # Also handle non-continuous commands as one-shot
-- if cmd not in CONTINUOUS_COMMANDS:
-- execute_command(self.cozmo_cli, cmd)
--
-- def handle_key_up(self, msg):
-- cmd = msg.get('command', '')
-- user = msg.get('from_user', '?')
-- print(f"[Key Up] {user} -> {cmd}")
-- with self._drive_lock:
-- self.held_keys.discard(cmd)
-- # If no more keys held, stop driving
-- with self._drive_lock:
-- remaining = set(self.held_keys)
-- if not any(k in CONTINUOUS_COMMANDS for k in remaining):
-- if self.cozmo_cli:
-- try:
-- self.cozmo_cli.drive_wheels(0, 0)
-- except Exception:
-- pass
-- else:
-- print("[DRY RUN] stop")
--
-- def handle_video_click(self, msg):
-- x = msg.get('x', 0.5)
-- y = msg.get('y', 0.5)
-- user = msg.get('from_user', '?')
-- print(f"[Click] {user} -> ({x:.2f}, {y:.2f})")
-- # Simple click-to-drive: turn toward click then drive forward
-- if self.cozmo_cli is None:
-- print(f"[DRY RUN] navigate to ({x:.2f}, {y:.2f})")
-- return
-- try:
-- # x < 0.4 = turn left, x > 0.6 = turn right, else straight
-- if x < 0.4:
-- self.cozmo_cli.drive_wheels(-TURN_SPEED, TURN_SPEED, duration=0.3)
-- elif x > 0.6:
-- self.cozmo_cli.drive_wheels(TURN_SPEED, -TURN_SPEED, duration=0.3)
-- time.sleep(0.1)
-- # y < 0.4 = further away (drive more), y > 0.6 = closer (drive less)
-- drive_time = 0.3 + (1.0 - y) * 0.7
-- self.cozmo_cli.drive_wheels(DRIVE_SPEED, DRIVE_SPEED, duration=drive_time)
-- except Exception as e:
-- print(f"[Cozmo] Click navigate error: {e}")
--
-- def on_message(self, ws, message):
-- try:
-- msg = json.loads(message)
-- msg_type = msg.get('type', '')
-- if msg_type == 'command':
-- print(f"[Control] {msg.get('from_user','?')} -> {msg.get('command','')}")
-- execute_command(self.cozmo_cli, msg.get('command', ''))
-- elif msg_type == 'key_down':
-- self.handle_key_down(msg)
-- elif msg_type == 'key_up':
-- self.handle_key_up(msg)
-- elif msg_type == 'video_click':
-- self.handle_video_click(msg)
-- except Exception as e:
-- print(f"[WS] Error: {e}")
--
-- def on_open(self, ws):
-- print("[WS] Connected to OpenVibe.Live!")
-- # Tell server we're online so viewers get instant green-dot
-- ws.send(json.dumps({"type": "status", "connected": True}))
--
-- def on_close(self, ws, code, reason):
-- print(f"[WS] Disconnected ({code})")
-- # Stop driving on disconnect
-- with self._drive_lock:
-- self.held_keys.clear()
--
-- def on_error(self, ws, error):
-- print(f"[WS] Error: {error}")
--
-- def run(self):
-- self.connect_cozmo()
-- # Start continuous drive thread
-- self._drive_thread = threading.Thread(target=self._continuous_drive_loop, daemon=True)
-- self._drive_thread.start()
-- while self.running:
-- try:
-- ws = websocket.WebSocketApp(WS_URL,
-- on_message=self.on_message, on_open=self.on_open,
-- on_close=self.on_close, on_error=self.on_error)
-- ws.run_forever(ping_interval=30)
-- except Exception as e:
-- print(f"[WS] {e}")
-- if self.running:
-- print(f"Reconnecting in {RECONNECT_DELAY}s...")
-- time.sleep(RECONNECT_DELAY)
--
--if __name__ == '__main__':
-- bridge = CozmoBridge()
-- try:
-- bridge.run()
-- except KeyboardInterrupt:
-- bridge.running = False
-- if bridge.cozmo_cli:
-- bridge.cozmo_cli.stop()
-- print("Bye!")
--`;
--}
--
--function generateProfileBridgeScript({ user, config, buttons, protocol, host, type }) {
-- const enabledButtons = buttons.filter(b => b.is_enabled !== 0);
-- const commandList = enabledButtons.map(b => ({
-- command: b.command,
-- label: b.label,
-- type: b.control_type || 'button',
-- key_binding: b.key_binding || null,
-- }));
--
-- const commandsJson = JSON.stringify(commandList, null, 4).split('\n').map((l, i) => i === 0 ? l : ' ' + l).join('\n');
--
-- if (type === 'cozmo') {
-- return _generateCozmoProfileScript({ user, config, commandsJson, enabledButtons, protocol, host });
-- }
--
-- // Generic bridge script
-- return `#!/usr/bin/env python3
--"""
--OpenVibe.Live — Generic Hardware Bridge
--Auto-generated for: ${user.username}
--Control Profile: ${config.name}
--
--Connects to OpenVibe.Live and receives control commands from viewers.
--Edit the handle_command() function to control your hardware.
--
--Requirements:
-- pip install websocket-client
--
--Usage:
-- python3 generic-bridge-${config.name.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase()}.py
--"""
--import json
--import time
--import threading
--
--try:
-- import websocket
--except ImportError:
-- print("Missing websocket-client: pip install websocket-client")
-- exit(1)
--
--# ── Connection Settings ──────────────────────────────────────
--WS_URL = "${protocol}://${host}/ws/control?mode=hardware&stream_key=${user.stream_key}"
--RECONNECT_DELAY = 5
--
--# ── Profile Buttons ──────────────────────────────────────────
--# These are the buttons configured in your "${config.name}" profile.
--# Each entry has: command, label, type, key_binding
--BUTTONS = ${commandsJson}
--
--# ══════════════════════════════════════════════════════════════
--# COMMAND HANDLERS — Edit these to control your hardware!
--# ══════════════════════════════════════════════════════════════
--
--def handle_command(command, from_user):
-- """Called when a viewer presses a button (one-shot)."""
-- print(f"[Command] {from_user} -> {command}")
-- # ── Add your hardware logic here ──
-- # Examples:
-- # if command == "forward":
-- # my_robot.move_forward()
-- # elif command == "fire":
-- # GPIO.output(RELAY_PIN, GPIO.HIGH)
--${enabledButtons.map(b => ` # ${b.command}: ${b.label} (${b.control_type || 'button'})`).join('\n')}
--
--
--def handle_key_down(command, from_user):
-- """Called when a viewer holds a keyboard-type key down."""
-- print(f"[Key Down] {from_user} -> {command}")
-- # Add continuous action start here
--
--
--def handle_key_up(command, from_user):
-- """Called when a viewer releases a keyboard-type key."""
-- print(f"[Key Up] {from_user} -> {command}")
-- # Add continuous action stop here
--
--
--def handle_video_click(x, y, from_user):
-- """Called when a viewer clicks on the video (if enabled)."""
-- print(f"[Click] {from_user} -> ({x:.2f}, {y:.2f})")
-- # x and y are 0.0-1.0 coordinates on the video
--
--
--# ══════════════════════════════════════════════════════════════
--# BRIDGE (no need to edit below unless customizing)
--# ══════════════════════════════════════════════════════════════
--
--class HardwareBridge:
-- def __init__(self):
-- self.running = True
-- self.held_keys = set()
--
-- def on_message(self, ws, message):
-- try:
-- msg = json.loads(message)
-- msg_type = msg.get("type", "")
-- cmd = msg.get("command", "")
-- user = msg.get("from_user", "?")
--
-- if msg_type == "command":
-- handle_command(cmd, user)
-- elif msg_type == "key_down":
-- self.held_keys.add(cmd)
-- handle_key_down(cmd, user)
-- elif msg_type == "key_up":
-- self.held_keys.discard(cmd)
-- handle_key_up(cmd, user)
-- elif msg_type == "video_click":
-- handle_video_click(msg.get("x", 0.5), msg.get("y", 0.5), user)
-- except Exception as e:
-- print(f"[Error] {e}")
--
-- def on_open(self, ws):
-- print("[Connected] Listening for commands...")
-- print(f"[Profile] {len(BUTTONS)} buttons loaded from '${config.name}'")
-- ws.send(json.dumps({"type": "status", "connected": True}))
--
-- def on_close(self, ws, code, reason):
-- print(f"[Disconnected] code={code}")
-- self.held_keys.clear()
--
-- def on_error(self, ws, error):
-- print(f"[WS Error] {error}")
--
-- def run(self):
-- print(f"Connecting to OpenVibe.Live as hardware bridge...")
-- print(f"Profile: ${config.name} ({enabledButtons.length} buttons)")
-- print(f"Commands: {', '.join(b['command'] for b in BUTTONS)}")
-- print()
-- while self.running:
-- try:
-- ws = websocket.WebSocketApp(WS_URL,
-- on_message=self.on_message,
-- on_open=self.on_open,
-- on_close=self.on_close,
-- on_error=self.on_error)
-- ws.run_forever(ping_interval=30)
-- except Exception as e:
-- print(f"[Error] {e}")
-- if self.running:
-- print(f"Reconnecting in {RECONNECT_DELAY}s...")
-- time.sleep(RECONNECT_DELAY)
--
--
--if __name__ == "__main__":
-- bridge = HardwareBridge()
-- try:
-- bridge.run()
-- except KeyboardInterrupt:
-- print("\\nBye!")
--`;
--}
--
--function _generateCozmoProfileScript({ user, config, commandsJson, enabledButtons, protocol, host }) {
-- const keyboardButtons = enabledButtons.filter(b => b.control_type === 'keyboard');
-- const buttonButtons = enabledButtons.filter(b => b.control_type !== 'keyboard' && b.control_type !== 'onvif');
-- const slugName = config.name.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase();
--
-- return `#!/usr/bin/env python3
--"""
--OpenVibe.Live — Cozmo Hardware Bridge
--Auto-generated for: ${user.username}
--Control Profile: ${config.name}
--Generated: ${new Date().toISOString().slice(0, 10)}
--
--HOW IT WORKS
--============
--This script connects to OpenVibe.Live as a hardware bridge.
--Viewer button presses are relayed to your Cozmo robot.
--
--There are TWO button types — you can see them in your profile editor:
--
-- [HOLD] keyboard type — sends key_down when pressed, key_up on release.
-- Perfect for drive commands: hold the button → Cozmo keeps going.
-- Release → Cozmo stops. (SMOOTH mode only needs this!)
--
-- [TAP] button type — sends a single "command" on each press.
-- Ideal for face animations, one-shot actions, etc.
--
--MODES
--=====
-- SMOOTH (default) — drive commands use key_down/key_up for continuous drive.
-- Holding a button drives continuously; releasing stops.
--
-- JUMPY — every tap sends a short burst (JUMPY_BURST_DURATION seconds).
-- Useful if you want snap-stop control without holding.
-- Toggle with the "toggle_mode" command or 'X' button.
--
--BUTTONS IN THIS PROFILE (${enabledButtons.length} total)
--${keyboardButtons.length ? ' [HOLD] ' + keyboardButtons.map(b => b.command + ' (' + b.label + ')').join(', ') : ' [HOLD] (none)'}
--${buttonButtons.length ? ' [TAP] ' + buttonButtons.map(b => b.command + ' (' + b.label + ')').join(', ') : ' [TAP] (none)'}
--
--REQUIREMENTS
--============
-- pip install pycozmo websocket-client Pillow
--
--USAGE
--=====
-- python3 cozmo-bridge-${slugName}.py
--
-- Set DRIVE_SPEED, TURN_SPEED, and JUMPY_BURST_DURATION to taste.
-- Add your face/animation assets to custom_faces/ and mechaMG/ directories.
--"""
--
--import json
--import os
--import random
--import threading
--import time
--
--try:
-- import websocket
--except ImportError:
-- print("ERROR: Missing websocket-client. Run: pip install websocket-client")
-- exit(1)
--
--try:
-- import pycozmo
-- from pycozmo import MIN_LIFT_HEIGHT, MAX_LIFT_HEIGHT, MIN_HEAD_ANGLE, MAX_HEAD_ANGLE
-- PYCOZMO_AVAILABLE = True
--except ImportError:
-- PYCOZMO_AVAILABLE = False
-- print("[Cozmo] pycozmo not installed — running in DRY RUN mode")
-- # Dummy constants for dry-run
-- class _MinMax:
-- def __init__(self, mm=0, radians=0): self.mm = mm; self.radians = radians
-- MIN_LIFT_HEIGHT = _MinMax(mm=32)
-- MAX_LIFT_HEIGHT = _MinMax(mm=92)
-- MIN_HEAD_ANGLE = _MinMax(radians=-0.25)
-- MAX_HEAD_ANGLE = _MinMax(radians=0.785)
--
--try:
-- from PIL import Image
-- PIL_AVAILABLE = True
--except ImportError:
-- PIL_AVAILABLE = False
--
--# ════════════════════════════════════════════════════════════════
--# TUNABLE SETTINGS — adjust these to match your robot
--# ════════════════════════════════════════════════════════════════
--DRIVE_SPEED = 100 # mm/s forward/backward
--TURN_SPEED = 80 # mm/s per-side for turns
--JUMPY_BURST_DURATION = 0.07 # sec drive burst in jumpy mode
--JUMPY_PAUSE_TIME = 0.03 # sec pause after burst in jumpy mode
--MG_PULSES = 12 # machine gun: number of lift vibrations
--MG_INTERVAL = 0.06 # machine gun: time between pulses (sec)
--MG_VIBRATE = 18 # machine gun: lift amplitude (mm)
--RECONNECT_DELAY = 5 # sec WebSocket reconnect delay
--
--# Asset directories (place your BMP/PNG face files here)
--CUSTOM_FACES_DIR = os.path.expanduser("~/Desktop/custom_faces")
--MECHA_MG_DIR = os.path.expanduser("~/Desktop/mechaMG")
--OTTER_GIF_DIR = os.path.expanduser("~/Desktop/otterGIF")
--
--# ════════════════════════════════════════════════════════════════
--# CONNECTION
--# ════════════════════════════════════════════════════════════════
--WS_URL = "${protocol}://${host}/ws/control?mode=hardware&stream_key=${user.stream_key}"
--
--# ════════════════════════════════════════════════════════════════
--# PROFILE BUTTONS (from "${config.name}")
--# ════════════════════════════════════════════════════════════════
--BUTTONS = ${commandsJson}
--
--# Commands that drive continuously when held (keyboard-type buttons)
--# key_down → start driving, key_up → stop
--CONTINUOUS_COMMANDS = {
-- "forward": ( DRIVE_SPEED, DRIVE_SPEED),
-- "backward": (-DRIVE_SPEED, -DRIVE_SPEED),
-- "turn_left": (-TURN_SPEED, TURN_SPEED),
-- "turn_right": ( TURN_SPEED, -TURN_SPEED),
--}
--
--# ════════════════════════════════════════════════════════════════
--# FACE / ANIMATION ASSETS
--# ════════════════════════════════════════════════════════════════
--def _load_bmp(path, size=(128, 32)):
-- if not PIL_AVAILABLE:
-- return None
-- try:
-- return Image.open(path).resize(size, Image.NEAREST).convert("1")
-- except Exception as e:
-- print(f"[Face] Could not load {path}: {e}")
-- return None
--
--def _load_face_assets():
-- assets = {"static": [], "mechaMG_frames": [], "mechaMG_delays": [], "otter": [],
-- "armcat_up": None, "armcat_down": None, "hit_left": None, "hit_right": None,
-- "jL": None, "jR": None, "nflag_frames": []}
--
-- if not PIL_AVAILABLE:
-- print("[Face] Pillow not installed — face animations disabled. Run: pip install Pillow")
-- return assets
--
-- # Custom BMP faces
-- if os.path.exists(CUSTOM_FACES_DIR):
-- for f in sorted(os.listdir(CUSTOM_FACES_DIR)):
-- if not f.lower().endswith(".bmp"):
-- continue
-- path = os.path.join(CUSTOM_FACES_DIR, f)
-- im = _load_bmp(path)
-- if im is None:
-- continue
-- fl = f.lower()
-- if fl == "armcatup.bmp": assets["armcat_up"] = im
-- elif fl == "armcatdown.bmp": assets["armcat_down"] = im
-- elif fl in ("hitl.bmp", "hit1.bmp", "left.bmp"): assets["hit_left"] = im
-- elif fl in ("hitr.bmp", "hit2.bmp", "right.bmp"): assets["hit_right"] = im
-- elif fl == "jl.bmp": assets["jL"] = im
-- elif fl == "jr.bmp": assets["jR"] = im
-- elif fl == "nflag1.1.bmp":
-- for angle in range(0, 360, 45):
-- rotated = im.rotate(angle, expand=False, fillcolor=0)
-- assets["nflag_frames"].append(rotated)
-- else:
-- assets["static"].append((im, f))
--
-- # MechaMG GIF frames
-- if os.path.exists(MECHA_MG_DIR):
-- for fname in sorted(f for f in os.listdir(MECHA_MG_DIR)
-- if f.lower().startswith("frame_") and f.lower().endswith(".png")):
-- path = os.path.join(MECHA_MG_DIR, fname)
-- try:
-- im = Image.open(path).convert("1")
-- mg = im.resize((64, 48), Image.NEAREST)
-- frame = Image.new("1", (128, 32), color=0)
-- frame.paste(mg, ((128-64)//2, (32-48)//2 + 4))
-- assets["mechaMG_frames"].append(frame)
-- delay = 0.2
-- if "_delay-" in fname.lower():
-- try: delay = float(fname.lower().split("_delay-")[1].split("s.")[0])
-- except: pass
-- assets["mechaMG_delays"].append(delay)
-- except Exception as e:
-- print(f"[Face] mechaMG {fname}: {e}")
--
-- # Otter GIF frames
-- if os.path.exists(OTTER_GIF_DIR):
-- for i in range(30):
-- path = os.path.join(OTTER_GIF_DIR, f"frame_{i:02d}_delay-0.04s.png")
-- if not os.path.exists(path):
-- break
-- try:
-- im = Image.open(path).convert("1")
-- frame = Image.new("1", (128, 32), color=0)
-- resized = im.resize((64, 64), Image.NEAREST)
-- frame.paste(resized, ((128-64)//2, max(0, (32-64)//2)))
-- assets["otter"].append(frame)
-- except Exception as e:
-- print(f"[Face] otter frame {i}: {e}")
--
-- print(f"[Face] Loaded: {len(assets['static'])} static, {len(assets['mechaMG_frames'])} mechaMG, {len(assets['otter'])} otter frames")
-- return assets
--
--
--# ════════════════════════════════════════════════════════════════
--# MAIN BRIDGE CLASS
--# ════════════════════════════════════════════════════════════════
--class CozmoBridge:
-- def __init__(self):
-- self.cli = None
-- self.running = True
-- self.driving_mode = "smooth" # "smooth" or "jumpy"
-- self.held_keys = set()
-- self._drive_lock = threading.Lock()
-- self.lift_height_mm = MIN_LIFT_HEIGHT.mm
-- self.head_angle = 0.0
-- self.animation_mode = None
-- self._anim_frame = 0
-- self._last_frame_t = 0.0
-- self._last_anim_t = 0.0
-- self.assets = _load_face_assets()
--
-- # ── Cozmo Connection ───────────────────────────────────────
-- def connect_cozmo(self):
-- if not PYCOZMO_AVAILABLE:
-- print("[Cozmo] Running in DRY RUN mode (pycozmo not installed)")
-- return
-- try:
-- self.cli = pycozmo.Client()
-- self.cli.start()
-- self.cli.wait_for_robot()
-- self.cli.enable_procedural_face(False)
-- print("[Cozmo] Robot connected!")
-- except Exception as e:
-- print(f"[Cozmo] Connection failed: {e}")
-- self.cli = None
--
-- # ── Drive helpers ──────────────────────────────────────────
-- def _drive(self, left, right):
-- if self.cli:
-- try:
-- if self.driving_mode == "smooth":
-- self.cli.drive_wheels(left, right, duration=0.2)
-- else:
-- self.cli.drive_wheels(left, right, duration=JUMPY_BURST_DURATION)
-- time.sleep(JUMPY_PAUSE_TIME)
-- except Exception as e:
-- print(f"[Drive] {e}")
-- else:
-- print(f"[DRY RUN] drive L={left} R={right} mode={self.driving_mode}")
--
-- def _stop(self):
-- if self.cli:
-- try: self.cli.drive_wheels(0, 0)
-- except Exception: pass
-- else:
-- print("[DRY RUN] stop")
--
-- # ── Continuous drive loop (smooth mode) ───────────────────
-- def _continuous_drive_loop(self):
-- while self.running:
-- with self._drive_lock:
-- keys = set(self.held_keys)
-- if not keys or self.driving_mode == "jumpy":
-- time.sleep(0.05)
-- continue
-- drove = False
-- for cmd in ("forward", "backward", "turn_left", "turn_right"):
-- if cmd in keys and cmd in CONTINUOUS_COMMANDS:
-- left, right = CONTINUOUS_COMMANDS[cmd]
-- self._drive(left, right)
-- drove = True
-- break
-- if not drove:
-- time.sleep(0.05)
-- else:
-- time.sleep(0.12)
--
-- # ── Command dispatcher ─────────────────────────────────────
-- def execute_command(self, cmd, user="?"):
-- """Handle a one-shot TAP command."""
-- print(f"[CMD] {user} -> {cmd}")
--
-- if cmd in ("forward", "backward", "turn_left", "turn_right"):
-- # Burst drive (used in jumpy mode and for single-click configs)
-- if cmd in CONTINUOUS_COMMANDS:
-- left, right = CONTINUOUS_COMMANDS[cmd]
-- self._drive(left, right)
--
-- elif cmd in ("stop", "emergency_stop", "space"):
-- self._stop()
--
-- elif cmd in ("machine_gun", "mg"):
-- self._machine_gun()
--
-- elif cmd in ("toggle_mode", "x"):
-- self.driving_mode = "jumpy" if self.driving_mode == "smooth" else "smooth"
-- print(f"[Mode] -> {self.driving_mode.upper()}")
--
-- elif cmd in ("otter", "g"):
-- self.animation_mode = "otter" if self.animation_mode != "otter" else None
-- self._anim_frame = 0
--
-- elif cmd in ("dual_otter", "y"):
-- self.animation_mode = "dual_otter" if self.animation_mode != "dual_otter" else None
-- self._anim_frame = 0
--
-- elif cmd in ("mechaMG", "p", "mecha_mg"):
-- if self.animation_mode == "mechaMG":
-- self.animation_mode = None
-- elif self.assets["mechaMG_frames"]:
-- self.animation_mode = "mechaMG"
-- self._anim_frame = 0
-- self._last_frame_t = time.time()
--
-- elif cmd in ("armcat", "k"):
-- self.animation_mode = "armcat" if self.animation_mode != "armcat" else None
-- self._anim_frame = 0
--
-- elif cmd in ("j_animation", "j"):
-- self.animation_mode = "j" if self.animation_mode != "j" else None
-- self._anim_frame = 0
--
-- elif cmd in ("nflag", "n"):
-- self.animation_mode = "nflag" if self.animation_mode != "nflag" else None
-- self._anim_frame = 0
--
-- elif cmd in ("random_glance", "h"):
-- self.animation_mode = "hit" if self.animation_mode != "hit" else None
-- self._anim_frame = random.randint(0, 1)
--
-- elif cmd in ("toggle_eyes", "o"):
-- if self.cli:
-- try: self.cli.enable_procedural_face(True)
-- except Exception: pass
-- self.animation_mode = None
--
-- elif cmd in ("lift_up",):
-- self.lift_height_mm = min(MAX_LIFT_HEIGHT.mm, self.lift_height_mm + 20)
-- if self.cli:
-- try: self.cli.set_lift_height(self.lift_height_mm)
-- except Exception: pass
--
-- elif cmd in ("lift_down",):
-- self.lift_height_mm = max(MIN_LIFT_HEIGHT.mm, self.lift_height_mm - 20)
-- if self.cli:
-- try: self.cli.set_lift_height(self.lift_height_mm)
-- except Exception: pass
--
-- else:
-- print(f"[Unknown] {cmd} — add it to execute_command() to handle it")
--
-- def _machine_gun(self):
-- if not self.cli:
-- print("[DRY RUN] machine gun!")
-- return
-- cur = self.lift_height_mm
-- try:
-- for _ in range(MG_PULSES):
-- self.cli.set_lift_height(min(MAX_LIFT_HEIGHT.mm, cur + MG_VIBRATE), accel=3000, duration=0.04)
-- time.sleep(MG_INTERVAL)
-- self.cli.set_lift_height(max(MIN_LIFT_HEIGHT.mm, cur - MG_VIBRATE), accel=3000, duration=0.04)
-- time.sleep(MG_INTERVAL)
-- self.cli.set_lift_height(cur, accel=800, duration=0.12)
-- except Exception as e:
-- print(f"[MG] {e}")
--
-- def _display_face(self, im):
-- if self.cli and im is not None:
-- try: self.cli.display_image(im)
-- except Exception: pass
--
-- def _tick_animations(self):
-- """Advance face animations. Call this in the main loop."""
-- a = self.assets
-- now = time.time()
-- if self.animation_mode == "mechaMG" and a["mechaMG_frames"]:
-- delays = a["mechaMG_delays"]
-- delay = delays[self._anim_frame] if self._anim_frame < len(delays) else 0.2
-- if now - self._last_frame_t > delay:
-- self._anim_frame = (self._anim_frame + 1) % len(a["mechaMG_frames"])
-- self._display_face(a["mechaMG_frames"][self._anim_frame])
-- self._last_frame_t = now
-- elif self.animation_mode == "otter" and a["otter"]:
-- if now - self._last_frame_t > 0.04:
-- self._anim_frame = (self._anim_frame + 1) % len(a["otter"])
-- self._display_face(a["otter"][self._anim_frame])
-- self._last_frame_t = now
-- elif self.animation_mode == "dual_otter" and a["otter"]:
-- if now - self._last_frame_t > 0.04:
-- self._anim_frame = (self._anim_frame + 1) % len(a["otter"])
-- L = (self._anim_frame) % len(a["otter"])
-- R = (self._anim_frame + 3) % len(a["otter"])
-- combined = Image.new("1", (128, 32), color=0)
-- combined.paste(a["otter"][L].crop((32, 0, 96, 32)), (0, 0))
-- combined.paste(a["otter"][R].crop((32, 0, 96, 32)), (64, 0))
-- self._display_face(combined)
-- self._last_frame_t = now
-- elif self.animation_mode == "armcat" and a["armcat_up"] and a["armcat_down"]:
-- if now - self._last_frame_t > 0.2:
-- self._anim_frame = 1 - self._anim_frame
-- self._display_face(a["armcat_up"] if self._anim_frame == 0 else a["armcat_down"])
-- self._last_frame_t = now
-- elif self.animation_mode == "hit" and a["hit_left"] and a["hit_right"]:
-- delay = random.uniform(0.2, 0.5) if random.random() < 0.05 else random.uniform(2.0, 4.0)
-- if now - self._last_frame_t > delay:
-- self._anim_frame = 1 - self._anim_frame
-- self._display_face(a["hit_left"] if self._anim_frame == 0 else a["hit_right"])
-- self._last_frame_t = now
-- elif self.animation_mode == "j" and a["jL"] and a["jR"]:
-- if now - self._last_frame_t > 1.0:
-- self._anim_frame = 1 - self._anim_frame
-- self._display_face(a["jL"] if self._anim_frame == 0 else a["jR"])
-- self._last_frame_t = now
-- elif self.animation_mode == "nflag" and a["nflag_frames"]:
-- if now - self._last_frame_t > 0.08:
-- self._anim_frame = (self._anim_frame + 1) % len(a["nflag_frames"])
-- self._display_face(a["nflag_frames"][self._anim_frame])
-- self._last_frame_t = now
--
-- # ── WebSocket handlers ─────────────────────────────────────
-- def on_message(self, ws, message):
-- try:
-- msg = json.loads(message)
-- msg_type = msg.get("type", "")
-- cmd = msg.get("command", "")
-- user = msg.get("from_user", "?")
--
-- if msg_type == "command":
-- # Single TAP — one-shot action
-- self.execute_command(cmd, user)
--
-- elif msg_type == "key_down":
-- # HOLD pressed — add to held set, start continuous drive if it's a drive cmd
-- print(f"[Hold] {user} holding {cmd}")
-- with self._drive_lock:
-- self.held_keys.add(cmd)
-- # In jumpy mode, also send a burst immediately
-- if self.driving_mode == "jumpy" and cmd in CONTINUOUS_COMMANDS:
-- left, right = CONTINUOUS_COMMANDS[cmd]
-- self._drive(left, right)
-- # Non-drive keyboard commands are treated as one-shot on key_down
-- elif cmd not in CONTINUOUS_COMMANDS:
-- self.execute_command(cmd, user)
--
-- elif msg_type == "key_up":
-- # HOLD released — remove from held set, stop if no more drive keys
-- print(f"[Release] {user} released {cmd}")
-- with self._drive_lock:
-- self.held_keys.discard(cmd)
-- with self._drive_lock:
-- remaining = set(self.held_keys)
-- if not any(k in CONTINUOUS_COMMANDS for k in remaining):
-- self._stop()
--
-- elif msg_type == "video_click":
-- x = float(msg.get("x", 0.5))
-- y = float(msg.get("y", 0.5))
-- print(f"[Click] {user} -> ({x:.2f}, {y:.2f})")
-- # Click-to-steer: left/right based on x position
-- if x < 0.35:
-- self._drive(-TURN_SPEED, TURN_SPEED)
-- elif x > 0.65:
-- self._drive(TURN_SPEED, -TURN_SPEED)
-- else:
-- drive_dur = max(0.2, (1.0 - y) * 0.6)
-- if self.cli:
-- try: self.cli.drive_wheels(DRIVE_SPEED, DRIVE_SPEED, duration=drive_dur)
-- except Exception: pass
--
-- except Exception as e:
-- print(f"[WS Error] {e}")
--
-- def on_open(self, ws):
-- print("[WS] Connected to OpenVibe.Live!")
-- print(f"[Profile] ${config.name} ({enabledButtons.length} buttons) | Mode: {self.driving_mode}")
-- ws.send(json.dumps({"type": "status", "connected": True, "profile": "${config.name}"}))
--
-- def on_close(self, ws, code, reason):
-- print(f"[WS] Disconnected (code={code}) — Cozmo will stop")
-- with self._drive_lock:
-- self.held_keys.clear()
-- self._stop()
--
-- def on_error(self, ws, error):
-- print(f"[WS Error] {error}")
--
-- # ── Main loop ──────────────────────────────────────────────
-- def run(self):
-- self.connect_cozmo()
--
-- # Start continuous-drive background thread (smooth mode)
-- drive_t = threading.Thread(target=self._continuous_drive_loop, daemon=True)
-- drive_t.start()
--
-- print(f"\\n{'='*60}")
-- print(f" OpenVibe.Live Cozmo Bridge — ${config.name}")
-- print(f" Drive speed: {DRIVE_SPEED} mm/s | Mode: SMOOTH (toggle with 'toggle_mode')")
-- print(f" Commands: ${enabledButtons.map(b => b.command).join(', ')}")
-- print(f"{'='*60}\\n")
--
-- while self.running:
-- try:
-- ws_app = websocket.WebSocketApp(
-- WS_URL,
-- on_message=self.on_message,
-- on_open=self.on_open,
-- on_close=self.on_close,
-- on_error=self.on_error,
-- )
-- # Run WS in a thread so we can tick animations in main thread
-- ws_thread = threading.Thread(target=lambda: ws_app.run_forever(ping_interval=30), daemon=True)
-- ws_thread.start()
--
-- while ws_thread.is_alive() and self.running:
-- self._tick_animations()
-- time.sleep(0.04)
--
-- except Exception as e:
-- print(f"[WS] {e}")
--
-- if self.running:
-- print(f"[WS] Reconnecting in {RECONNECT_DELAY}s...")
-- time.sleep(RECONNECT_DELAY)
--
--
--if __name__ == "__main__":
-- bridge = CozmoBridge()
-- try:
-- bridge.run()
-- except KeyboardInterrupt:
-- bridge.running = False
-- bridge._stop()
-- if bridge.cli:
-- try: bridge.cli.stop()
-- except Exception: pass
-- print("\\n[Bridge] Bye!")
--`;
--}
--
--// ══════════════════════════════════════════════════════════════
--// CONTROL CONFIGS (Reusable per-channel profiles)
--// ══════════════════════════════════════════════════════════════
--
--// ── List configs ─────────────────────────────────────────────
--router.get('/configs', requireAuth, async (req, res) => {
-- try {
-- const configs = await db.getControlConfigs(req.user.id);
-- // Attach button count to each config
-- const result = (await Promise.all(configs.map(async c => ({
-- ...c,
-- button_count: (await db.getConfigButtons(c.id)).length,
-- }))));
-- res.json({ configs: result });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to list configs' });
-- }
--});
--
--// ── Create config ────────────────────────────────────────────
--router.post('/configs', requireAuth, async (req, res) => {
-- try {
-- const { name, description } = req.body;
-- if (!name || typeof name !== 'string' || !name.trim()) {
-- return res.status(400).json({ error: 'Config name is required' });
-- }
-- const cleanName = String(name).replace(/<[^>]*>/g, '').slice(0, 60);
-- const cleanDesc = description ? String(description).replace(/<[^>]*>/g, '').slice(0, 200) : '';
--
-- // Limit to 20 configs per user
-- const existing = await db.getControlConfigs(req.user.id);
-- if (existing.length >= 20) {
-- return res.status(400).json({ error: 'Maximum 20 control configs allowed' });
-- }
--
-- const result = await db.createControlConfig({ user_id: req.user.id, name: cleanName, description: cleanDesc });
-- const config = await db.getControlConfig(result.lastInsertRowid);
-- res.status(201).json({ config });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to create config' });
-- }
--});
--
--// ── Get config with buttons ──────────────────────────────────
--router.get('/configs/:id', requireAuth, async (req, res) => {
-- try {
-- const config = await db.getControlConfig(req.params.id);
-- if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
-- const buttons = await db.getConfigButtons(config.id);
-- res.json({ config, buttons });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get config' });
-- }
--});
--
--// ── Update config ────────────────────────────────────────────
--router.put('/configs/:id', requireAuth, async (req, res) => {
-- try {
-- const config = await db.getControlConfig(req.params.id);
-- if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
-- const { name, description } = req.body;
-- const updates = {};
-- if (name !== undefined) updates.name = String(name).replace(/<[^>]*>/g, '').slice(0, 60);
-- if (description !== undefined) updates.description = String(description).replace(/<[^>]*>/g, '').slice(0, 200);
-- await db.updateControlConfig(config.id, updates);
-- const updated = await db.getControlConfig(config.id);
-- res.json({ config: updated });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to update config' });
-- }
--});
--
--// ── Delete config ────────────────────────────────────────────
--router.delete('/configs/:id', requireAuth, async (req, res) => {
-- try {
-- const config = await db.getControlConfig(req.params.id);
-- if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
-- // Clear active config reference if it was active
-- const channel = await db.getChannelByUserId(req.user.id);
-- if (channel && channel.active_control_config_id === config.id) {
-- await db.updateChannel(req.user.id, { active_control_config_id: null });
-- }
-- await db.deleteControlConfig(config.id);
-- res.json({ message: 'Config deleted' });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to delete config' });
-- }
--});
--
--// ── Add button to config ─────────────────────────────────────
--router.post('/configs/:id/buttons', requireAuth, async (req, res) => {
-- try {
-- const config = await db.getControlConfig(req.params.id);
-- if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
--
-- const { label, command, icon, control_type, key_binding, cooldown_ms, btn_color, btn_bg, btn_border_color } = req.body;
-- if (!label || !command) {
-- return res.status(400).json({ error: 'Label and command required' });
-- }
--
-- // Limit to 50 buttons per config
-- const existing = await db.getConfigButtons(config.id);
-- if (existing.length >= 50) {
-- return res.status(400).json({ error: 'Maximum 50 buttons per config' });
-- }
--
-- const cleanIcon = icon || 'fa-gamepad';
-- if (!/^fa-[a-z0-9-]+$/.test(cleanIcon)) {
-- return res.status(400).json({ error: 'Invalid icon class' });
-- }
--
-- const validTypes = ['button', 'toggle', 'dpad', 'keyboard'];
-- const cleanType = validTypes.includes(control_type) ? control_type : 'button';
--
-- await db.createConfigButton({
-- config_id: config.id,
-- label: String(label).replace(/<[^>]*>/g, '').slice(0, 50),
-- command: String(command).replace(/[<>"'`\\]/g, '').slice(0, 100),
-- icon: cleanIcon,
-- control_type: cleanType,
-- key_binding: key_binding ? String(key_binding).slice(0, 20) : null,
-- cooldown_ms: Math.max(0, Math.min(30000, parseInt(cooldown_ms) || 100)),
-- sort_order: existing.length,
-- btn_color: sanitizeCssColor(btn_color),
-- btn_bg: sanitizeCssColor(btn_bg),
-- btn_border_color: sanitizeCssColor(btn_border_color),
-- });
--
-- await syncConfigToBoundLiveStreams(config.id);
-- const buttons = await db.getConfigButtons(config.id);
-- res.status(201).json({ buttons });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to add button' });
-- }
--});
--
--// ── Update button ────────────────────────────────────────────
--router.put('/configs/:id/buttons/:btnId', requireAuth, async (req, res) => {
-- try {
-- const config = await db.getControlConfig(req.params.id);
-- if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
--
-- const { label, command, icon, control_type, key_binding, cooldown_ms, sort_order, btn_color, btn_bg, btn_border_color, is_enabled } = req.body;
-- const updates = {};
--
-- if (label !== undefined) updates.label = String(label).replace(/<[^>]*>/g, '').slice(0, 50);
-- if (command !== undefined) updates.command = String(command).replace(/[<>"'`\\]/g, '').slice(0, 100);
-- if (icon !== undefined) {
-- if (!/^fa-[a-z0-9-]+$/.test(icon)) return res.status(400).json({ error: 'Invalid icon class' });
-- updates.icon = icon;
-- }
-- if (control_type !== undefined) {
-- const validTypes = ['button', 'toggle', 'dpad', 'keyboard'];
-- updates.control_type = validTypes.includes(control_type) ? control_type : 'button';
-- }
-- if (key_binding !== undefined) updates.key_binding = key_binding ? String(key_binding).slice(0, 20) : null;
-- if (cooldown_ms !== undefined) updates.cooldown_ms = Math.max(0, Math.min(30000, parseInt(cooldown_ms) || 100));
-- if (sort_order !== undefined) updates.sort_order = parseInt(sort_order) || 0;
-- if (btn_color !== undefined) updates.btn_color = sanitizeCssColor(btn_color);
-- if (btn_bg !== undefined) updates.btn_bg = sanitizeCssColor(btn_bg);
-- if (btn_border_color !== undefined) updates.btn_border_color = sanitizeCssColor(btn_border_color);
-- if (is_enabled !== undefined) updates.is_enabled = is_enabled ? 1 : 0;
--
-- // Scoped to this config: the ownership check above is on the config in the URL, so the
-- // button id must belong to it or a user could rewrite anyone's button through their own config.
-- const changed = await db.updateConfigButton(parseInt(req.params.btnId), updates, config.id);
-- if (changed && changed.changes === 0) return res.status(404).json({ error: 'Button not found' });
-- await syncConfigToBoundLiveStreams(config.id);
-- const buttons = await db.getConfigButtons(config.id);
-- res.json({ buttons });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to update button' });
-- }
--});
--
--// ── Delete button ────────────────────────────────────────────
--router.delete('/configs/:id/buttons/:btnId', requireAuth, async (req, res) => {
-- try {
-- const config = await db.getControlConfig(req.params.id);
-- if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
-- const removed = await db.deleteConfigButton(parseInt(req.params.btnId), config.id);
-- if (removed && removed.changes === 0) return res.status(404).json({ error: 'Button not found' });
-- await syncConfigToBoundLiveStreams(config.id);
-- const buttons = await db.getConfigButtons(config.id);
-- res.json({ buttons });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to delete button' });
-- }
--});
--
--// ── Activate config on channel ───────────────────────────────
--router.post('/configs/:id/activate', requireAuth, async (req, res) => {
-- try {
-- const config = await db.getControlConfig(req.params.id);
-- if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
-- await db.updateChannel(req.user.id, { active_control_config_id: config.id });
-- res.json({ message: 'Config activated', config_id: config.id });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to activate config' });
-- }
--});
--
--// ── Deactivate config (clear active) ─────────────────────────
--router.post('/configs/deactivate', requireAuth, async (req, res) => {
-- try {
-- await db.updateChannel(req.user.id, { active_control_config_id: null });
-- res.json({ message: 'Config deactivated' });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to deactivate config' });
-- }
--});
--
--// ── Apply config to live stream ──────────────────────────────
--router.post('/configs/:id/apply/:streamId', requireAuth, async (req, res) => {
-- try {
-- const config = await db.getControlConfig(req.params.id);
-- if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
-- const stream = await db.getStreamById(req.params.streamId);
-- if (!stream || (stream.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage'))) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
-- const applied = await db.applyConfigToStream(config.id, stream.id);
-- const controls = await db.getStreamControls(stream.id);
-- res.json({ applied, controls });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to apply config' });
-- }
--});
--
--// ── Generate API Key (must be before :streamId routes) ────────
--router.post('/api-key', requireAuth, async (req, res) => {
-- try {
-- const { label, permissions } = req.body;
--
-- // Generate a random API key
-- const rawKey = crypto.randomBytes(32).toString('hex');
-- const keyHash = bcrypt.hashSync(rawKey, 10);
--
-- await db.createApiKey({
-- user_id: req.user.id,
-- key_hash: keyHash,
-- label: label || 'Default',
-- permissions: permissions || ['control', 'stream'],
-- });
--
-- // Return the raw key ONCE (it's hashed in the DB)
-- res.status(201).json({
-- api_key: rawKey,
-- label: label || 'Default',
-- message: 'Save this key — it cannot be retrieved again!',
-- });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to generate API key' });
-- }
--});
--
--// ── List API Keys (must be before :streamId routes) ──────────
--router.get('/api-keys', requireAuth, async (req, res) => {
-- try {
-- const keys = await db.all(
-- 'SELECT id, label, permissions, last_used, is_active, created_at FROM api_keys WHERE user_id = ?',
-- [req.user.id]
-- );
-- res.json({ api_keys: keys });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to list API keys' });
-- }
--});
--
--// ── Control Settings (MUST be before /:streamId routes) ──────
--
--// Get control settings for the current user's channel
--router.get('/settings/channel', requireAuth, async (req, res) => {
-- try {
-- const channel = await db.getChannelByUserId(req.user.id);
-- if (!channel) return res.status(404).json({ error: 'Channel not found' });
-- res.json({
-- control_mode: channel.control_mode || 'open',
-- anon_controls_enabled: !!channel.anon_controls_enabled,
-- control_rate_limit_ms: channel.control_rate_limit_ms || 100,
-- video_click_enabled: !!channel.video_click_enabled,
-- video_click_rate_limit_ms: channel.video_click_rate_limit_ms || 0,
-- active_control_config_id: channel.active_control_config_id || null,
-- });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get control settings' });
-- }
--});
--
--router.put('/settings/channel', requireAuth, async (req, res) => {
-- try {
-- const { control_mode, anon_controls_enabled, control_rate_limit_ms, video_click_enabled, video_click_rate_limit_ms } = req.body;
-- const updates = {};
-- if (control_mode !== undefined) {
-- if (!['open', 'whitelist', 'disabled'].includes(control_mode)) {
-- return res.status(400).json({ error: 'Invalid control_mode' });
-- }
-- updates.control_mode = control_mode;
-- }
-- if (anon_controls_enabled !== undefined) {
-- updates.anon_controls_enabled = anon_controls_enabled ? 1 : 0;
-- }
-- if (video_click_enabled !== undefined) {
-- updates.video_click_enabled = video_click_enabled ? 1 : 0;
-- }
-- // control_rate_limit_ms / video_click_rate_limit_ms are no longer configurable —
-- // rate limiting is now a fixed anti-flood burst cap plus per-button cooldown_ms.
-- // The columns remain (harmless) but we no longer read or write them.
-- if (Object.keys(updates).length > 0) {
-- await db.updateChannel(req.user.id, updates);
-- }
-- const channel = await db.getChannelByUserId(req.user.id);
-- res.json({
-- control_mode: channel.control_mode || 'open',
-- anon_controls_enabled: !!channel.anon_controls_enabled,
-- control_rate_limit_ms: channel.control_rate_limit_ms || 100,
-- video_click_enabled: !!channel.video_click_enabled,
-- video_click_rate_limit_ms: channel.video_click_rate_limit_ms || 0,
-- active_control_config_id: channel.active_control_config_id || null,
-- });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to update control settings' });
-- }
--});
--
--// ── Control Whitelist ────────────────────────────────────────
--
--router.get('/whitelist', requireAuth, async (req, res) => {
-- try {
-- const channel = await db.getChannelByUserId(req.user.id);
-- if (!channel) return res.status(404).json({ error: 'Channel not found' });
-- const whitelist = await db.all(
-- `SELECT cw.id, cw.user_id, u.username, u.display_name, cw.created_at
-- FROM control_whitelist cw JOIN users u ON cw.user_id = u.id
-- WHERE cw.channel_id = ? ORDER BY cw.created_at DESC`,
-- [channel.id]
-- );
-- res.json({ whitelist });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get whitelist' });
-- }
--});
--
--router.post('/whitelist', requireAuth, async (req, res) => {
-- try {
-- const { username } = req.body;
-- if (!username) return res.status(400).json({ error: 'Username required' });
-- const channel = await db.getChannelByUserId(req.user.id);
-- if (!channel) return res.status(404).json({ error: 'Channel not found' });
-- const targetUser = await db.getUserByUsername(username);
-- if (!targetUser) return res.status(404).json({ error: 'User not found' });
-- await db.run(
-- 'INSERT INTO control_whitelist (channel_id, user_id, added_by) VALUES (?, ?, ?) ON CONFLICT DO NOTHING',
-- [channel.id, targetUser.id, req.user.id]
-- );
-- res.json({ message: `${username} added to control whitelist` });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to add to whitelist' });
-- }
--});
--
--router.delete('/whitelist/:id', requireAuth, async (req, res) => {
-- try {
-- const channel = await db.getChannelByUserId(req.user.id);
-- if (!channel) return res.status(404).json({ error: 'Channel not found' });
-- await db.run('DELETE FROM control_whitelist WHERE id = ? AND channel_id = ?',
-- [req.params.id, channel.id]);
-- res.json({ message: 'Removed from whitelist' });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to remove from whitelist' });
-- }
--});
--
--// ── Cozmo Script Generator ──────────────────────────────────
--
--router.get('/cozmo-script', requireAuth, (req, res) => {
-- try {
-- const user = req.user;
-- const host = req.get('host') || 'openvibe.live';
-- const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'wss' : 'ws';
--
-- const script = generateCozmoScript(user, protocol, host);
--
-- res.set('Content-Type', 'text/x-python');
-- res.set('Content-Disposition', `attachment; filename="cozmo-bridge-${user.username}.py"`);
-- res.send(script);
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to generate script' });
-- }
--});
--
--// ── Per-Profile Bridge Script Generator ──────────────────────
--
--router.get('/configs/:id/bridge-script', requireAuth, async (req, res) => {
-- try {
-- const config = await db.getControlConfig(req.params.id);
-- if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
--
-- const buttons = await db.getConfigButtons(config.id);
-- const user = req.user;
-- const host = req.get('host') || 'openvibe.live';
-- const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'wss' : 'ws';
-- const type = req.query.type === 'cozmo' ? 'cozmo' : 'generic';
--
-- const script = generateProfileBridgeScript({ user, config, buttons, protocol, host, type });
--
-- const slug = config.name.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase().slice(0, 30);
-- res.set('Content-Type', 'text/x-python');
-- res.set('Content-Disposition', `attachment; filename="${type}-bridge-${slug}.py"`);
-- res.send(script);
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to generate bridge script' });
-- }
--});
--
--// ── Get bound control profile for a stream ───────────────────
--router.get('/:streamId/config', requireAuth, async (req, res) => {
-- try {
-- const stream = await db.getStreamById(req.params.streamId);
-- if (!stream) return res.status(404).json({ error: 'Stream not found' });
-- if (stream.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
-- res.json({ control_config_id: stream.control_config_id || null });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get stream config' });
-- }
--});
--
--// ── Bind or rebind a stream to a control profile ──────────────
--router.put('/:streamId/config', requireAuth, async (req, res) => {
-- try {
-- const stream = await db.getStreamById(req.params.streamId);
-- if (!stream) return res.status(404).json({ error: 'Stream not found' });
-- if (stream.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
--
-- const configId = req.body.control_config_id === null ? null : parseInt(req.body.control_config_id);
-- if (configId) {
-- const config = await db.getControlConfig(configId);
-- if (!config) return res.status(404).json({ error: 'Config not found' });
-- if (config.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage')) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
-- const applied = await db.applyConfigToStream(config.id, stream.id);
-- const controls = await db.getStreamControls(stream.id);
-- return res.json({ message: 'Config applied to stream', control_config_id: config.id, applied, controls });
-- }
--
-- await db.bindStreamToControlConfig(stream.id, null);
-- const controls = await db.getStreamControls(stream.id);
-- res.json({ message: 'Stream unbound from control profile', control_config_id: null, controls });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to update stream config' });
-- }
--});
--
--// ── Get Controls for a Stream ────────────────────────────────
--router.get('/:streamId', async (req, res) => {
-- try {
-- const controls = await db.getStreamControls(req.params.streamId);
-- // Attach channel settings for the controls UI
-- const stream = await db.getStreamById(req.params.streamId);
-- let settings = {};
-- if (stream) {
-- const channel = await db.getChannelByUserId(stream.user_id);
-- if (channel) {
-- settings = {
-- video_click_enabled: !!channel.video_click_enabled,
-- control_mode: channel.control_mode || 'open',
-- anon_controls_enabled: !!channel.anon_controls_enabled,
-- control_rate_limit_ms: channel.control_rate_limit_ms || 100,
-- video_click_rate_limit_ms: channel.video_click_rate_limit_ms || 0,
-- };
-- }
-- }
-- res.json({ controls, settings });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to get controls' });
-- }
--});
--
--// ── Ad-hoc per-stream control editing removed ────────────────
--// Controls are now defined only in Control Profiles (control_configs) and
--// materialized onto a live stream by applyConfigToStream. The old direct
--// editor endpoints (POST /:streamId, PUT/DELETE /:streamId/:id) that hand-authored
--// rows in stream_controls have been removed so every control has a profile source.
--
--// ── Cozmo Presets ────────────────────────────────────────────
--const { applyCozmoPresets, removeCozmoPresets } = require('../integrations/cozmo-presets');
--
--router.post('/:streamId/presets/cozmo', requireAuth, async (req, res) => {
-- try {
-- const stream = await db.getStreamById(req.params.streamId);
-- if (!stream || (stream.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage'))) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
-- const result = await applyCozmoPresets(parseInt(req.params.streamId));
-- const controls = await db.getStreamControls(req.params.streamId);
-- res.json({ ...result, controls });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to apply Cozmo presets' });
-- }
--});
--
--router.delete('/:streamId/presets/cozmo', requireAuth, async (req, res) => {
-- try {
-- const stream = await db.getStreamById(req.params.streamId);
-- if (!stream || (stream.user_id !== req.user.id && !can(req.user, 'staff.hardware.manage'))) {
-- return res.status(403).json({ error: 'Not authorized' });
-- }
-- const removed = await removeCozmoPresets(parseInt(req.params.streamId));
-- const controls = await db.getStreamControls(req.params.streamId);
-- res.json({ removed, controls });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to remove Cozmo presets' });
-- }
--});
--
--module.exports = router;
ddiff --git a/server/core/onvif-client.js b/server/core/onvif-client.js
ddeleted file mode 100644
iindex 7d981a6a..00000000
---- a/server/core/onvif-client.js
++++ /dev/null
@@@ -1,424 +0,0 @@
--/**
-- * OpenVibe.Live — ONVIF Camera Client
-- *
-- * Wrapper around node-onvif for PTZ (pan-tilt-zoom) control.
-- * Handles discovery, authentication, and movement commands.
-- */
--
--const dgram = require('dgram');
--const { EventEmitter } = require('events');
--
--/**
-- * ONVIF Discovery via WS-Discovery
-- * Broadcasts M-SEARCH on port 3702 to discover devices
-- */
--class OnvifDiscovery extends EventEmitter {
-- constructor() {
-- super();
-- this.socket = null;
-- }
--
-- /**
-- * Discover ONVIF devices on the network
-- * @param {number} timeoutMs - How long to listen for responses (default 3000ms)
-- * @returns {Promise<Array>} Array of discovered device info
-- */
-- async discover(timeoutMs = 3000) {
-- return new Promise((resolve) => {
-- const devices = [];
-- const multicastAddr = '239.255.255.250';
-- const port = 3702;
--
-- const socket = dgram.createSocket('udp4');
--
-- socket.on('message', (msg, rinfo) => {
-- try {
-- const msgStr = msg.toString('utf8');
-- // Look for ONVIF URIs in SOAP response
-- const uriMatch = msgStr.match(/onvif:\/\/www\.onvif\.org\/type\/NetworkVideoTransmitter/);
-- const addressMatch = msgStr.match(/http:\/\/([^\/]+\/.*?)(?:\s|<)/);
--
-- if (uriMatch && addressMatch) {
-- const url = addressMatch[1] || rinfo.address;
-- if (!devices.some(d => d.url === url)) {
-- devices.push({
-- url: `http://${url}`,
-- ip: rinfo.address,
-- discovered_at: new Date().toISOString(),
-- });
-- }
-- }
-- } catch (e) {
-- // Ignore malformed responses
-- }
-- });
--
-- socket.on('error', (err) => {
-- console.warn('[ONVIF] Discovery socket error:', err.message);
-- socket.close();
-- });
--
-- // Send M-SEARCH broadcast
-- const ssdpRequest = [
-- 'M-SEARCH * HTTP/1.1',
-- 'HOST: 239.255.255.250:3702',
-- 'MAN: "ssdp:discover"',
-- 'MX: 2',
-- 'ST: urn:onvif:service:device:DeviceService',
-- '',
-- '',
-- ].join('\r\n');
--
-- try {
-- socket.bind(() => {
-- socket.addMembership(multicastAddr);
-- socket.send(ssdpRequest, 0, ssdpRequest.length, port, multicastAddr, (err) => {
-- if (err) console.warn('[ONVIF] Send error:', err.message);
-- });
-- });
-- } catch (e) {
-- console.warn('[ONVIF] Bind error:', e.message);
-- return resolve([]);
-- }
--
-- // Timeout timer
-- setTimeout(() => {
-- socket.close();
-- resolve(devices);
-- }, timeoutMs);
-- });
-- }
--}
--
--/**
-- * ONVIF Client for PTZ control
-- */
--class OnvifClient extends EventEmitter {
-- /**
-- * @param {string} url - Device URL (e.g., http://192.168.1.100:8080)
-- * @param {string} username - ONVIF username
-- * @param {string} password - ONVIF password (plaintext; should be decrypted before passing)
-- */
-- constructor(url, username, password) {
-- super();
-- this.url = url;
-- this.username = username;
-- this.password = password;
-- this.connected = false;
-- this.profileToken = null;
-- this.ptzNode = null;
-- this.moveInProgress = false;
--
-- // Try to use node-onvif if available, otherwise provide fallback
-- try {
-- this.Onvif = require('node-onvif');
-- } catch (e) {
-- console.warn('[ONVIF] node-onvif not installed, using HTTP fallback');
-- this.Onvif = null;
-- }
-- }
--
-- /**
-- * Connect and retrieve capabilities
-- */
-- async connect() {
-- if (this.connected) return true;
--
-- try {
-- if (this.Onvif) {
-- return await this._connectWithLibrary();
-- } else {
-- return await this._connectWithFallback();
-- }
-- } catch (e) {
-- this.emit('error', new Error(`Failed to connect: ${e.message}`));
-- return false;
-- }
-- }
--
-- async _connectWithLibrary() {
-- const device = new this.Onvif.OnvifDevice({
-- xaddr: this.url,
-- user: this.username,
-- pass: this.password,
-- });
--
-- try {
-- await device.init();
-- this.device = device;
-- this.profileToken = device.getCurrentProfile()['$'].token ||
-- (device.getProfileList()[0] && device.getProfileList()[0]['$'].token);
--
-- if (!this.profileToken) {
-- throw new Error('No profiles available');
-- }
--
-- this.connected = true;
-- this.emit('connected');
-- return true;
-- } catch (e) {
-- throw new Error(`Device init failed: ${e.message}`);
-- }
-- }
--
-- async _connectWithFallback() {
-- // Simple HTTP-based fallback for cameras that support basic HTTP requests
-- // This is a minimal implementation; real cameras may need SOAP WSDL calls
-- const response = await this._httpRequest('GET', `/onvif/device_service`);
-- if (response && response.statusCode === 200) {
-- this.connected = true;
-- this.emit('connected');
-- return true;
-- }
-- throw new Error('Device unreachable');
-- }
--
-- /**
-- * Relative movement (speed-based)
-- */
-- async relativeMove(panSpeed, tiltSpeed, zoomSpeed, durationMs = 1000) {
-- if (!this.connected) {
-- throw new Error('Not connected');
-- }
--
-- if (this.moveInProgress) {
-- return; // Ignore rapid consecutive commands
-- }
--
-- try {
-- if (this.Onvif && this.device) {
-- return await this._relativeMoveWithLibrary(panSpeed, tiltSpeed, zoomSpeed, durationMs);
-- } else {
-- return await this._relativeMoveWithFallback(panSpeed, tiltSpeed, zoomSpeed);
-- }
-- } catch (e) {
-- this.emit('error', new Error(`Relative move failed: ${e.message}`));
-- }
-- }
--
-- async _relativeMoveWithLibrary(panSpeed, tiltSpeed, zoomSpeed, durationMs) {
-- this.moveInProgress = true;
--
-- try {
-- // Clamp speeds to 0.0-1.0
-- const params = {
-- ProfileToken: this.profileToken,
-- Translation: {
-- PanTilt: {
-- x: Math.max(-1, Math.min(1, panSpeed)),
-- y: Math.max(-1, Math.min(1, tiltSpeed)),
-- },
-- Zoom: {
-- x: Math.max(-1, Math.min(1, zoomSpeed)),
-- },
-- },
-- };
--
-- await this.device.ptz().relativeMove(params);
--
-- // Auto-stop after duration
-- if (durationMs > 0) {
-- setTimeout(() => this.stop().catch(() => {}), durationMs);
-- }
--
-- return true;
-- } finally {
-- this.moveInProgress = false;
-- }
-- }
--
-- async _relativeMoveWithFallback(panSpeed, tiltSpeed, zoomSpeed) {
-- // HTTP fallback: attempt to call CGI-based PTZ endpoint
-- // Different cameras use different endpoints (Hikvision, Axis, Dahua)
-- const endpoints = [
-- `/API/ISAPI/PTZ/channels/1`,
-- `/cgi-bin/admin/param?action=update&PTZRelativeMove`,
-- `/axis-cgi/com/ptz.cgi`,
-- ];
--
-- for (const endpoint of endpoints) {
-- try {
-- await this._httpRequest('GET', endpoint, {
-- panSpeed,
-- tiltSpeed,
-- zoomSpeed,
-- });
-- return true;
-- } catch (e) {
-- // Try next endpoint
-- }
-- }
-- throw new Error('No working PTZ endpoint found');
-- }
--
-- /**
-- * Stop all motion
-- */
-- async stop() {
-- if (!this.connected) return false;
--
-- try {
-- if (this.Onvif && this.device) {
-- const params = { ProfileToken: this.profileToken };
-- await this.device.ptz().stop(params);
-- } else {
-- // HTTP fallback
-- await this._httpRequest('GET', `/axis-cgi/com/ptz.cgi?stop=1`);
-- }
-- this.moveInProgress = false;
-- return true;
-- } catch (e) {
-- this.emit('error', new Error(`Stop failed: ${e.message}`));
-- return false;
-- }
-- }
--
-- /**
-- * Go to preset position
-- */
-- async gotoPreset(presetToken) {
-- if (!this.connected) {
-- throw new Error('Not connected');
-- }
--
-- try {
-- if (this.Onvif && this.device) {
-- const params = {
-- ProfileToken: this.profileToken,
-- PresetToken: presetToken,
-- };
-- await this.device.ptz().gotoPreset(params);
-- } else {
-- // HTTP: preset number (1-99)
-- const presetNum = parseInt(presetToken) || 1;
-- await this._httpRequest('GET', `/axis-cgi/com/ptz.cgi?gotoserverpresetname=HomePosition&speed=50`);
-- }
-- return true;
-- } catch (e) {
-- this.emit('error', new Error(`Goto preset failed: ${e.message}`));
-- return false;
-- }
-- }
--
-- /**
-- * Save current position as preset
-- */
-- async setPreset(presetName) {
-- if (!this.connected) {
-- throw new Error('Not connected');
-- }
--
-- try {
-- if (this.Onvif && this.device) {
-- const params = {
-- ProfileToken: this.profileToken,
-- PresetName: presetName,
-- };
-- const result = await this.device.ptz().setPreset(params);
-- return result?.PresetToken;
-- } else {
-- // HTTP fallback: most cameras support basic preset via CGI
-- await this._httpRequest('GET', `/axis-cgi/com/ptz.cgi?setserverpresetname=${encodeURIComponent(presetName)}`);
-- return presetName;
-- }
-- } catch (e) {
-- this.emit('error', new Error(`Set preset failed: ${e.message}`));
-- return null;
-- }
-- }
--
-- /**
-- * Get device capabilities
-- */
-- async getCapabilities() {
-- if (!this.connected) {
-- throw new Error('Not connected');
-- }
--
-- try {
-- if (this.Onvif && this.device) {
-- const info = this.device.getInformation();
-- return {
-- manufacturer: info.Manufacturer,
-- model: info.Model,
-- firmwareVersion: info.FirmwareVersion,
-- serialNumber: info.SerialNumber,
-- supportsPTZ: true,
-- supportsPresets: true,
-- };
-- } else {
-- return {
-- manufacturer: 'Unknown',
-- supportsPTZ: this.connected,
-- supportsPresets: true,
-- };
-- }
-- } catch (e) {
-- this.emit('error', new Error(`Get capabilities failed: ${e.message}`));
-- return {};
-- }
-- }
--
-- /**
-- * Helper: HTTP request with basic auth
-- */
-- async _httpRequest(method, path, params = {}) {
-- const https = require('https');
-- const http = require('http');
-- const url = require('url');
--
-- const parsedUrl = new url.URL(this.url);
-- const client = parsedUrl.protocol === 'https:' ? https : http;
--
-- let urlPath = path;
-- if (method === 'GET' && Object.keys(params).length > 0) {
-- const query = new url.URLSearchParams(params).toString();
-- urlPath = `${path}?${query}`;
-- }
--
-- const options = {
-- hostname: parsedUrl.hostname,
-- port: parsedUrl.port,
-- path: urlPath,
-- method: method,
-- auth: `${this.username}:${this.password}`,
-- timeout: 5000,
-- };
--
-- return new Promise((resolve, reject) => {
-- const req = client.request(options, (res) => {
-- let data = '';
-- res.on('data', chunk => { data += chunk; });
-- res.on('end', () => {
-- if (res.statusCode >= 200 && res.statusCode < 300) {
-- resolve({ statusCode: res.statusCode, body: data });
-- } else {
-- reject(new Error(`HTTP ${res.statusCode}`));
-- }
-- });
-- });
--
-- req.on('error', reject);
-- req.on('timeout', () => {
-- req.destroy();
-- reject(new Error('Request timeout'));
-- });
--
-- req.end();
-- });
-- }
--
-- /**
-- * Disconnect
-- */
-- disconnect() {
-- this.connected = false;
-- this.device = null;
-- this.emit('disconnected');
-- }
--}
--
--module.exports = {
-- OnvifDiscovery,
-- OnvifClient,
--};
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 139ef0dd..3c609f2e 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1,77 +1,3 @@
--// Preset control profiles for new users
--const CONTROL_PRESETS = [
-- {
-- name: 'Robot Car (WASD)',
-- description: 'Basic robot car: forward, back, left, right, stop. Hold-to-drive (key_down/key_up) for smooth continuous movement.',
-- buttons: [
-- { label: 'Forward', command: 'forward', icon: 'fa-arrow-up', control_type: 'keyboard', key_binding: 'w', cooldown_ms: 100, sort_order: 0 },
-- { label: 'Left', command: 'turn_left', icon: 'fa-arrow-left', control_type: 'keyboard', key_binding: 'a', cooldown_ms: 100, sort_order: 1 },
-- { label: 'Stop', command: 'stop', icon: 'fa-stop', control_type: 'button', key_binding: '', cooldown_ms: 100, sort_order: 2 },
-- { label: 'Right', command: 'turn_right', icon: 'fa-arrow-right', control_type: 'keyboard', key_binding: 'd', cooldown_ms: 100, sort_order: 3 },
-- { label: 'Back', command: 'backward', icon: 'fa-arrow-down', control_type: 'keyboard', key_binding: 's', cooldown_ms: 100, sort_order: 4 },
-- ]
-- },
-- {
-- name: 'Cozmo Robot',
-- description: 'Full Cozmo robot controls: hold-to-drive (WASD), face animations, machine gun lift, mode toggle, emergency stop.',
-- buttons: [
-- // Drive — keyboard type = sends key_down/key_up for smooth continuous drive
-- { label: 'Forward', command: 'forward', icon: 'fa-arrow-up', control_type: 'keyboard', key_binding: 'w', cooldown_ms: 100, sort_order: 0 },
-- { label: 'Left', command: 'turn_left', icon: 'fa-arrow-left', control_type: 'keyboard', key_binding: 'a', cooldown_ms: 100, sort_order: 1 },
-- { label: 'Stop', command: 'stop', icon: 'fa-stop', control_type: 'button', key_binding: 'space', cooldown_ms: 100, sort_order: 2 },
-- { label: 'Right', command: 'turn_right', icon: 'fa-arrow-right', control_type: 'keyboard', key_binding: 'd', cooldown_ms: 100, sort_order: 3 },
-- { label: 'Back', command: 'backward', icon: 'fa-arrow-down', control_type: 'keyboard', key_binding: 's', cooldown_ms: 100, sort_order: 4 },
-- // Face animations — button type = single tap
-- { label: 'Machine Gun', command: 'machine_gun', icon: 'fa-burst', control_type: 'button', key_binding: 'p', cooldown_ms: 1000, sort_order: 5 },
-- { label: 'Otter', command: 'otter', icon: 'fa-otter', control_type: 'button', key_binding: 'g', cooldown_ms: 300, sort_order: 6 },
-- { label: 'Dual Otter', command: 'dual_otter', icon: 'fa-otter', control_type: 'button', key_binding: 'y', cooldown_ms: 300, sort_order: 7 },
-- { label: 'Mecha MG', command: 'mechaMG', icon: 'fa-robot', control_type: 'button', key_binding: 'm', cooldown_ms: 300, sort_order: 8 },
-- { label: 'ArmCat', command: 'armcat', icon: 'fa-cat', control_type: 'button', key_binding: 'k', cooldown_ms: 300, sort_order: 9 },
-- { label: 'NFlag', command: 'nflag', icon: 'fa-flag', control_type: 'button', key_binding: 'n', cooldown_ms: 300, sort_order: 10 },
-- { label: 'Glance', command: 'random_glance', icon: 'fa-eye', control_type: 'button', key_binding: 'h', cooldown_ms: 300, sort_order: 11 },
-- { label: 'Toggle Mode', command: 'toggle_mode', icon: 'fa-shuffle', control_type: 'button', key_binding: 'x', cooldown_ms: 500, sort_order: 12 },
-- ]
-- },
-- {
-- name: 'Camera PTZ',
-- description: 'Pan/tilt/zoom camera controls (ONVIF compatible).',
-- buttons: [
-- { label: 'Pan Left', command: 'pan_left', icon: 'fa-arrow-left', control_type: 'onvif', key_binding: 'a', cooldown_ms: 300, sort_order: 0 },
-- { label: 'Pan Right', command: 'pan_right', icon: 'fa-arrow-right', control_type: 'onvif', key_binding: 'd', cooldown_ms: 300, sort_order: 1 },
-- { label: 'Tilt Up', command: 'tilt_up', icon: 'fa-arrow-up', control_type: 'onvif', key_binding: 'w', cooldown_ms: 300, sort_order: 2 },
-- { label: 'Tilt Down', command: 'tilt_down', icon: 'fa-arrow-down', control_type: 'onvif', key_binding: 's', cooldown_ms: 300, sort_order: 3 },
-- { label: 'Zoom In', command: 'zoom_in', icon: 'fa-magnifying-glass-plus', control_type: 'onvif', key_binding: 'e', cooldown_ms: 300, sort_order: 4 },
-- { label: 'Zoom Out', command: 'zoom_out', icon: 'fa-magnifying-glass-minus', control_type: 'onvif', key_binding: 'q', cooldown_ms: 300, sort_order: 5 },
-- ]
-- },
-- {
-- name: 'Gamepad (ABXY)',
-- description: 'Gamepad-style controls: A, B, X, Y, Start, Select.',
-- buttons: [
-- { label: 'A', command: 'a', icon: 'fa-circle', control_type: 'button', key_binding: 'j', cooldown_ms: 200, sort_order: 0 },
-- { label: 'B', command: 'b', icon: 'fa-circle', control_type: 'button', key_binding: 'k', cooldown_ms: 200, sort_order: 1 },
-- { label: 'X', command: 'x', icon: 'fa-circle', control_type: 'button', key_binding: 'u', cooldown_ms: 200, sort_order: 2 },
-- { label: 'Y', command: 'y', icon: 'fa-circle', control_type: 'button', key_binding: 'i', cooldown_ms: 200, sort_order: 3 },
-- { label: 'Start', command: 'start', icon: 'fa-play', control_type: 'button', key_binding: 'enter', cooldown_ms: 500, sort_order: 4 },
-- { label: 'Select', command: 'select', icon: 'fa-stop', control_type: 'button', key_binding: 'shift', cooldown_ms: 500, sort_order: 5 },
-- ]
-- }
--];
--
--async function seedControlPresetsForUser(userId) {
-- const existing = await all('SELECT * FROM control_configs WHERE user_id = ?', [userId]);
-- if (existing.length > 0) return;
-- for (const preset of CONTROL_PRESETS) {
-- const { lastInsertRowid } = await run('INSERT INTO control_configs (user_id, name, description) VALUES (?, ?, ?) RETURNING id', [userId, preset.name, preset.description]);
-- for (const btn of preset.buttons) {
-- await run(
-- `INSERT INTO control_config_buttons (config_id, label, command, icon, control_type, key_binding, cooldown_ms, sort_order, btn_color, btn_bg, btn_border_color)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', '', '')`,
-- [lastInsertRowid, btn.label, btn.command, btn.icon, btn.control_type, btn.key_binding, btn.cooldown_ms, btn.sort_order]
-- );
-- }
-- }
--}
/**
* OpenVibe.Live — Database Connection & Helpers
* PostgreSQL through openvibe-sdk/db (ADR-035, plan T4). The schema is migrations/NNNN_*.sql, run with the owner role
@@@ -350,19 +276,6 @@ async function getLiveStreamsByUserId(userId) {
`, [userId]);
}
--async function getLiveStreamsByControlConfigId(controlConfigId) {
-- return await all(`
-- SELECT s.*, COALESCE(NULLIF(s.ai_category, ''), s.category) AS category, s.category AS chosen_category, u.username, u.display_name, u.avatar_url, u.profile_color,
-- ms.slug AS managed_stream_slug, ms.stream_key AS managed_stream_key,
-- ms.title AS managed_stream_title
-- FROM streams s
-- JOIN users u ON s.user_id = u.id
-- LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id
-- WHERE s.control_config_id = ? AND s.is_live = 1
-- ORDER BY s.started_at DESC
-- `, [controlConfigId]);
--}
--
async function getStreamsByUserId(userId, limit = 50) {
return await all(`
SELECT s.*, COALESCE(NULLIF(s.ai_category, ''), s.category) AS category, s.category AS chosen_category, u.username, u.display_name, u.avatar_url, u.profile_color,
@@@ -403,12 +316,12 @@ async function fireStreamLifecycle(kind, streamId) {
try { await getDb().tx(() => streamLifecycleHook(kind, streamId)); } catch (err) { console.warn(`[Events] stream ${kind} event for ${streamId} not queued:`, err.message); }
}
--async function createStream({ user_id, channel_id, managed_stream_id, control_config_id, title, description, category, protocol, is_nsfw, thumbnail_url }) {
++async function createStream({ user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url }) {
return await getDb().tx(async () => {
const result = await run(
-- `INSERT INTO streams (user_id, channel_id, managed_stream_id, control_config_id, title, description, category, protocol, is_nsfw, thumbnail_url, is_live, started_at)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ov_now()) RETURNING id`,
-- [user_id, channel_id || null, managed_stream_id || null, control_config_id || null, title || 'Untitled Stream', description || '', category || null, protocol || 'webrtc', is_nsfw ? 1 : 0, thumbnail_url || null]
++ `INSERT INTO streams (user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url, is_live, started_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ov_now()) RETURNING id`,
++ [user_id, channel_id || null, managed_stream_id || null, title || 'Untitled Stream', description || '', category || null, protocol || 'webrtc', is_nsfw ? 1 : 0, thumbnail_url || null]
);
await fireStreamLifecycle('started', result.lastInsertRowid);
return result;
@@@ -1040,11 +953,11 @@ async function updateViewerCount(streamId, count) {
// ── Managed Stream helpers ───────────────────────────────────
--async function createManagedStream({ user_id, channel_id, slug, title, description, category, protocol, streaming_method, stream_key, is_nsfw, control_config_id }) {
++async function createManagedStream({ user_id, channel_id, slug, title, description, category, protocol, streaming_method, stream_key, is_nsfw }) {
return await run(
-- `INSERT INTO managed_streams (user_id, channel_id, slug, title, description, category, protocol, streaming_method, stream_key, is_nsfw, control_config_id)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
-- [user_id, channel_id || null, slug || null, title || 'Untitled Stream', description || '', category || null, protocol || 'webrtc', streaming_method || null, stream_key, is_nsfw ? 1 : 0, control_config_id || null]
++ `INSERT INTO managed_streams (user_id, channel_id, slug, title, description, category, protocol, streaming_method, stream_key, is_nsfw)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
++ [user_id, channel_id || null, slug || null, title || 'Untitled Stream', description || '', category || null, protocol || 'webrtc', streaming_method || null, stream_key, is_nsfw ? 1 : 0]
);
}
@@@ -1106,7 +1019,7 @@ async function getManagedStreamByIdOrSlug(userId, idOrSlug) {
async function updateManagedStream(managedStreamId, userId, fields) {
const allowed = new Set([
'slug', 'title', 'description', 'category', 'tags', 'protocol',
-- 'is_nsfw', 'control_config_id', 'sort_order',
++ 'is_nsfw', 'sort_order',
'streaming_method', 'browser_mode',
'default_vod_visibility', 'default_clip_visibility', 'slot_vod_recording_enabled', 'slot_clip_recording_enabled',
'slot_clip_notify_enabled', 'slot_powerchat_relay', 'slot_powerchat_count_rs_views',
@@@ -1712,7 +1625,7 @@ async function updateChannel(userId, fields) {
const updates = [];
const params = [];
for (const [key, val] of Object.entries(fields)) {
-- if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'control_mode', 'anon_controls_enabled', 'control_rate_limit_ms', 'active_control_config_id', 'video_click_enabled', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language', 'ai_derivation_enabled', 'social_links', 'bot_robot_id'].includes(key)) {
++ if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language', 'ai_derivation_enabled', 'social_links', 'bot_robot_id'].includes(key)) {
updates.push(`${key} = ?`);
params.push(['tags', 'panels', 'emote_sources'].includes(key) ? (typeof val === 'string' ? val : JSON.stringify(val)) : val);
}
@@@ -2376,196 +2289,6 @@ async function setSubscriptionStatus(id, status, fields = {}) {
// The legacy local vods/clips tables are frozen and unread: test/frozen-tables.test.js fails on
// a new write or read (register C-73; the drop procedure is in docs/vods-and-clips.md).
--// ── Control helpers ──────────────────────────────────────────
--
--async function getStreamControls(streamId) {
-- return await all('SELECT * FROM stream_controls WHERE stream_id = ? ORDER BY sort_order', [streamId]);
--}
--
--async function createControl({ stream_id, label, command, icon, control_type, key_binding, cooldown_ms, sort_order, btn_color, btn_bg, btn_border_color }) {
-- return await run(
-- `INSERT INTO stream_controls (stream_id, label, command, icon, control_type, key_binding, cooldown_ms, sort_order, btn_color, btn_bg, btn_border_color)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
-- [stream_id, label, command, icon || 'fa-gamepad', control_type || 'button', key_binding || null, cooldown_ms || 100, sort_order || 0, btn_color || '', btn_bg || '', btn_border_color || '']
-- );
--}
--
--// ── Control Config helpers ──────────────────────────────────
--
--async function getControlConfigs(userId) {
-- return await all('SELECT * FROM control_configs WHERE user_id = ? ORDER BY created_at DESC', [userId]);
--}
--
--async function getControlConfig(configId) {
-- return await get('SELECT * FROM control_configs WHERE id = ?', [configId]);
--}
--
--async function createControlConfig({ user_id, name, description }) {
-- return await run(
-- 'INSERT INTO control_configs (user_id, name, description) VALUES (?, ?, ?) RETURNING id',
-- [user_id, name, description || '']
-- );
--}
--
--async function updateControlConfig(configId, fields) {
-- const updates = [];
-- const params = [];
-- for (const [key, val] of Object.entries(fields)) {
-- if (val !== undefined && ['name', 'description'].includes(key)) {
-- updates.push(`${key} = ?`);
-- params.push(val);
-- }
-- }
-- if (updates.length === 0) return;
-- updates.push('updated_at = ov_now()');
-- params.push(configId);
-- return await run(`UPDATE control_configs SET ${updates.join(', ')} WHERE id = ?`, params);
--}
--
--async function deleteControlConfig(configId) {
-- return await run('DELETE FROM control_configs WHERE id = ?', [configId]);
--}
--
--async function getConfigButtons(configId) {
-- return await all('SELECT * FROM control_config_buttons WHERE config_id = ? ORDER BY sort_order', [configId]);
--}
--
--async function createConfigButton({ config_id, label, command, icon, control_type, key_binding, cooldown_ms, sort_order, btn_color, btn_bg, btn_border_color }) {
-- return await run(
-- `INSERT INTO control_config_buttons (config_id, label, command, icon, control_type, key_binding, cooldown_ms, sort_order, btn_color, btn_bg, btn_border_color)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
-- [config_id, label, command, icon || 'fa-gamepad', control_type || 'button', key_binding || null, cooldown_ms || 100, sort_order || 0, btn_color || '', btn_bg || '', btn_border_color || '']
-- );
--}
--
--async function updateConfigButton(buttonId, fields, configId) {
-- const allowed = ['label', 'command', 'icon', 'control_type', 'key_binding', 'cooldown_ms', 'sort_order', 'btn_color', 'btn_bg', 'btn_border_color', 'is_enabled'];
-- const updates = [];
-- const params = [];
-- for (const [key, val] of Object.entries(fields)) {
-- if (val !== undefined && allowed.includes(key)) {
-- updates.push(`${key} = ?`);
-- params.push(val);
-- }
-- }
-- if (updates.length === 0) return;
-- params.push(buttonId);
-- // Callers that authorised a config pass its id, so a button id from another config matches nothing.
-- if (configId != null) { params.push(configId); return await run(`UPDATE control_config_buttons SET ${updates.join(', ')} WHERE id = ? AND config_id = ?`, params); }
-- return await run(`UPDATE control_config_buttons SET ${updates.join(', ')} WHERE id = ?`, params);
--}
--
--async function deleteConfigButton(buttonId, configId) {
-- if (configId != null) return await run('DELETE FROM control_config_buttons WHERE id = ? AND config_id = ?', [buttonId, configId]);
-- return await run('DELETE FROM control_config_buttons WHERE id = ?', [buttonId]);
--}
--
--async function bindStreamToControlConfig(streamId, controlConfigId) {
-- if (controlConfigId === null) {
-- return await run('UPDATE streams SET control_config_id = NULL WHERE id = ?', [streamId]);
-- }
-- return await run('UPDATE streams SET control_config_id = ? WHERE id = ?', [controlConfigId, streamId]);
--}
--
--async function applyConfigToStream(configId, streamId) {
-- // Delete existing non-ONVIF controls from stream
-- await run('DELETE FROM stream_controls WHERE stream_id = ? AND (control_type != ? OR control_type IS NULL)', [streamId, 'onvif']);
-- // Copy buttons from config into stream_controls
-- const buttons = await getConfigButtons(configId);
-- for (let i = 0; i < buttons.length; i++) {
-- const b = buttons[i];
-- if (!b.is_enabled) continue;
-- await createControl({
-- stream_id: streamId,
-- label: b.label,
-- command: b.command,
-- icon: b.icon,
-- control_type: b.control_type,
-- key_binding: b.key_binding,
-- cooldown_ms: b.cooldown_ms,
-- sort_order: b.sort_order || i,
-- btn_color: b.btn_color,
-- btn_bg: b.btn_bg,
-- btn_border_color: b.btn_border_color,
-- });
-- }
-- await bindStreamToControlConfig(streamId, configId);
-- return buttons.filter(b => b.is_enabled).length;
--}
--
--// ── API Key helpers ──────────────────────────────────────────
--
--async function createApiKey({ user_id, key_hash, label, permissions }) {
-- return await run(
-- `INSERT INTO api_keys (user_id, key_hash, label, permissions)
-- VALUES (?, ?, ?, ?) RETURNING id`,
-- [user_id, key_hash, label || 'Default', JSON.stringify(permissions || ['control', 'stream'])]
-- );
--}
--
--async function getApiKeyByHash(hash) {
-- return await get('SELECT * FROM api_keys WHERE key_hash = ? AND is_active = 1', [hash]);
--}
--
--// ── ONVIF Camera helpers ─────────────────────────────────────
--
--async function createCameraProfile({ user_id, stream_id, name, onvif_url, username, password_hash, pan_speed, tilt_speed, zoom_speed }) {
-- return await run(
-- `INSERT INTO camera_profiles (user_id, stream_id, name, onvif_url, username, password_hash, pan_speed, tilt_speed, zoom_speed)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
-- [user_id, stream_id || null, name, onvif_url, username, password_hash, pan_speed || 0.5, tilt_speed || 0.5, zoom_speed || 0.5]
-- );
--}
--
--async function getCameraProfile(cameraId) {
-- return await get('SELECT * FROM camera_profiles WHERE id = ?', [cameraId]);
--}
--
--async function getCameraProfilesByUser(userId) {
-- return await all('SELECT * FROM camera_profiles WHERE user_id = ? ORDER BY created_at DESC', [userId]);
--}
--
--async function getCameraProfilesByStream(streamId) {
-- return await all('SELECT * FROM camera_profiles WHERE stream_id = ? AND is_active = 1 ORDER BY name', [streamId]);
--}
--
--async function updateCameraProfile(cameraId, data) {
-- const { name, onvif_url, username, password_hash, pan_speed, tilt_speed, zoom_speed, is_active, last_connected } = data;
-- return await run(
-- `UPDATE camera_profiles SET name = ?, onvif_url = ?, username = ?, password_hash = ?,
-- pan_speed = ?, tilt_speed = ?, zoom_speed = ?, is_active = ?, last_connected = ?, updated_at = ov_now()
-- WHERE id = ?`,
-- [name, onvif_url, username, password_hash, pan_speed, tilt_speed, zoom_speed, is_active, last_connected, cameraId]
-- );
--}
--
--async function deleteCameraProfile(cameraId) {
-- // Cascade delete presets and associated controls
-- await run('DELETE FROM camera_presets WHERE camera_id = ?', [cameraId]);
-- await run('UPDATE stream_controls SET camera_id = NULL WHERE camera_id = ?', [cameraId]);
-- return await run('DELETE FROM camera_profiles WHERE id = ?', [cameraId]);
--}
--
--async function createCameraPreset({ camera_id, name, pan, tilt, zoom, preset_token }) {
-- return await run(
-- `INSERT INTO camera_presets (camera_id, name, pan, tilt, zoom, preset_token)
-- VALUES (?, ?, ?, ?, ?, ?) RETURNING id`,
-- [camera_id, name, pan, tilt, zoom, preset_token || null]
-- );
--}
--
--async function getCameraPreset(presetId) {
-- return await get('SELECT * FROM camera_presets WHERE id = ?', [presetId]);
--}
--
--async function getCameraPresetsByCamera(cameraId) {
-- return await all('SELECT * FROM camera_presets WHERE camera_id = ? ORDER BY name', [cameraId]);
--}
--
--async function deleteCameraPreset(presetId) {
-- return await run('DELETE FROM camera_presets WHERE id = ?', [presetId]);
--}
--
// ── Ban helpers ──────────────────────────────────────────────
async function isUserBanned(userId, streamId) {
@@@ -3882,7 +3605,7 @@ module.exports = {
isValidManagedStreamSlug, isManagedStreamSlugTaken,
ensureStreamerRoleOnFeed,
// Streams (sessions)
-- getLiveStreams, getRecentStreams, getStreamById, setStreamAiCategory, effectiveCategory, getStreamByUserId, getLiveStreamsByUserId, getLiveStreamsByControlConfigId, getStreamsByUserId, getStreamHistoryByManagedStream,
++ getLiveStreams, getRecentStreams, getStreamById, setStreamAiCategory, effectiveCategory, getStreamByUserId, getLiveStreamsByUserId, getStreamsByUserId, getStreamHistoryByManagedStream,
createStream, endStream, onStreamLifecycle, endOtherLiveStreamsForSlot, updateViewerCount,
addStreamMemory, getStreamMemories, getLatestStreamMemory, updateStreamAiOverview,
setVodAiOverview, setClipAiOverview, setVodTranscript, setClipTranscript, getStreamMemoriesInRange,
@@@ -3944,17 +3667,6 @@ module.exports = {
countPendingMediaRequestsForUser, getMediaRequestMaxQueuePosition,
findActiveMediaRequestByCanonicalUrl, updateMediaRequest,
renormalizePendingMediaRequestPositions,
-- // Controls
-- getStreamControls, createControl, bindStreamToControlConfig,
-- // ONVIF Cameras
-- createCameraProfile, getCameraProfile, getCameraProfilesByUser, getCameraProfilesByStream,
-- updateCameraProfile, deleteCameraProfile,
-- createCameraPreset, getCameraPreset, getCameraPresetsByCamera, deleteCameraPreset,
-- // API Keys
-- createApiKey, getApiKeyByHash,
-- // Control Configs
-- getControlConfigs, getControlConfig, createControlConfig, updateControlConfig, deleteControlConfig,
-- getConfigButtons, createConfigButton, updateConfigButton, deleteConfigButton, applyConfigToStream,
// Bans
isUserBanned, isIpBanned, getIpBan, invalidateIpBanCache, forgiveBan,
getAiChatbotConfig, upsertAiChatbotConfig,
ddiff --git a/server/index.js b/server/index.js
iindex a88c1bc2..510cc709 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -10,7 +10,7 @@
* Identity/SSO + OpenCoins wallet: OpenVibe.Network (openvibe.network)
* Chat: WebSocket with anon handling + word filter
* Currencies: Vibes (tips/cashout, local) + OpenCoins (network wallet)
-- * Controls: Interactive hardware API (Raspberry Pi)
++ * Robots: OpenVibe.Bot's (a channel bound to a robot embeds its panel; chat commands drive it through Bot)
*/
// Prevent sub-service port conflicts from crashing the main HTTP server
@@@ -100,7 +100,6 @@ const rtmpServer = require('./streaming/rtmp-server');
// Real-time
const chatDelivery = require('./chat/chat-delivery');
--const controlServer = require('./controls/control-server');
const broadcastServer = require('./streaming/broadcast-server');
const VibeCodingPublishServer = require('./vibe-coding/publish-server');
@@@ -121,8 +120,6 @@ const thumbnailRoutes = require('./media-proxy/thumbnails');
const liveThumbs = require('./media-proxy/live-thumbs');
const pasteRoutes = require('./media-proxy/pastes');
const recorder = require('./streaming/recorder');
--const controlRoutes = require('./controls/routes');
--const onvifRoutes = require('./controls/onvif-routes');
const adminRoutes = require('./admin/routes');
const { requireAuth } = require('./auth/auth');
const permissions = require('./auth/permissions');
@@@ -197,7 +194,6 @@ const stopper = gracefulStop({
try { restreamManager.stopAll(); } catch { /* */ }
try { recorder.stopAll(); } catch { /* */ }
try { chatDelivery.close(); } catch { /* */ }
-- try { controlServer.close(); } catch { /* */ }
try { broadcastServer.close(); } catch { /* */ }
try { jsmpegRelay.closeAll(); } catch { /* */ }
try { webrtcSFU.closeAll(); } catch { /* */ }
@@@ -762,8 +758,6 @@ app.use('/api/recap', require('./recap/routes')); // after-show repor
app.use('/api/social', require('./social/routes').router); // channel social links and their previews (server/social/)
app.get('/embed/x-timeline', require('./social/routes').xTimelinePage);
app.use('/api/comments', commentRoutes);
--app.use('/api/controls', controlRoutes);
--app.use('/api/onvif', onvifRoutes);
app.use('/api/admin/openre', require('./openre/routes'));
app.use('/api/admin', adminRoutes);
app.use('/api/mod', require('./admin/mod-routes'));
@@@ -860,7 +854,7 @@ app.get('/api/ready', readiness.handler);
observability.registerDomainGauges(metricsRegistry, {
liveStreams: async () => (await db.getLiveStreams()).length,
-- wsServers: { broadcast: broadcastServer, control: controlServer },
++ wsServers: { broadcast: broadcastServer },
outboxStatus: async () => await require('./events/stream-events').counts(),
});
@@@ -1146,8 +1140,6 @@ server.on('upgrade', async (req, socket, head) => {
vibeCodingPublishServer.handleUpgrade(req, socket, head);
} else if (url.startsWith('/ws/broadcast')) {
broadcastServer.handleUpgrade(req, socket, head);
-- } else if (url.startsWith('/ws/control')) {
-- controlServer.handleUpgrade(req, socket, head);
} else if (url.startsWith('/ws/robotstreamer-publish')) {
robotStreamerService.handleUpgrade(req, socket, head);
} else {
@@@ -1211,10 +1203,7 @@ async function start() {
newsService.setChatServer(chatDelivery);
newsService.start();
-- // 4. Initialize control server
-- controlServer.init(server);
--
-- // 4b. Initialize broadcast server
++ // 4. Initialize the broadcast server
broadcastServer.init(server);
// Group calls and voice channels are OpenVibe.Chat's (/ws/call is routed there by nginx).
@@@ -1339,7 +1328,6 @@ async function start() {
console.log(`[Server] HTTP server: http://${config.host}:${config.port}`);
console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/chat`);
console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/broadcast`);
-- console.log(`[Server] WebSocket: ws://${config.host}:${config.port}/ws/control`);
console.log(`[Server] Environment: ${config.nodeEnv}`);
console.log(`[Server] BASE_URL: ${config.baseUrl}`);
console.log(`[Server] WHIP_PUBLIC_URL: ${config.whip?.publicUrl}`);
ddiff --git a/server/integrations/cozmo-presets.js b/server/integrations/cozmo-presets.js
ddeleted file mode 100644
iindex 606d79b6..00000000
---- a/server/integrations/cozmo-presets.js
++++ /dev/null
@@@ -1,74 +0,0 @@
--/**
-- * OpenVibe.Live — Cozmo Control Presets
-- *
-- * Provides a factory to bulk-create Cozmo-specific stream controls.
-- * These map 1:1 to the commands understood by scripts/cozmo-bridge.py.
-- */
--'use strict';
--
--const db = require('../db/database');
--
--/** Cozmo control definitions — order matches sort_order. */
--const COZMO_PRESETS = [
-- // D-Pad movement
-- { label: 'Forward', command: 'forward', icon: 'fa-arrow-up', control_type: 'dpad', key_binding: 'w', cooldown_ms: 400 },
-- { label: 'Backward', command: 'backward', icon: 'fa-arrow-down', control_type: 'dpad', key_binding: 's', cooldown_ms: 400 },
-- { label: 'Turn Left', command: 'turn_left', icon: 'fa-arrow-left', control_type: 'dpad', key_binding: 'a', cooldown_ms: 400 },
-- { label: 'Turn Right', command: 'turn_right', icon: 'fa-arrow-right', control_type: 'dpad', key_binding: 'd', cooldown_ms: 400 },
-- // Lift
-- { label: 'Lift Up', command: 'lift_up', icon: 'fa-angles-up', control_type: 'button', key_binding: 'e', cooldown_ms: 500 },
-- { label: 'Lift Down', command: 'lift_down', icon: 'fa-angles-down', control_type: 'button', key_binding: 'q', cooldown_ms: 500 },
-- // Head
-- { label: 'Head Up', command: 'head_up', icon: 'fa-circle-chevron-up', control_type: 'button', key_binding: 'r', cooldown_ms: 500 },
-- { label: 'Head Down', command: 'head_down', icon: 'fa-circle-chevron-down',control_type: 'button', key_binding: 'f', cooldown_ms: 500 },
--];
--
--/**
-- * Apply Cozmo presets to a stream.
-- * Skips any control whose command already exists on the stream.
-- * @param {number} streamId
-- * @returns {{ added: number, skipped: number }}
-- */
--async function applyCozmoPresets(streamId) {
-- const existing = await db.getStreamControls(streamId);
-- const existingCmds = new Set(existing.map(c => c.command));
--
-- let added = 0, skipped = 0;
--
-- for (let i = 0; i < COZMO_PRESETS.length; i++) {
-- const preset = COZMO_PRESETS[i];
-- if (existingCmds.has(preset.command)) {
-- skipped++;
-- continue;
-- }
-- await db.createControl({
-- stream_id: streamId,
-- label: preset.label,
-- command: preset.command,
-- icon: preset.icon,
-- control_type: preset.control_type,
-- key_binding: preset.key_binding,
-- cooldown_ms: preset.cooldown_ms,
-- });
-- added++;
-- }
--
-- return { added, skipped };
--}
--
--/**
-- * Remove all Cozmo preset controls from a stream.
-- * @param {number} streamId
-- * @returns {number} Number of controls removed.
-- */
--async function removeCozmoPresets(streamId) {
-- const cmds = COZMO_PRESETS.map(p => p.command);
-- const placeholders = cmds.map(() => '?').join(',');
-- const result = await db.run(
-- `DELETE FROM stream_controls WHERE stream_id = ? AND command IN (${placeholders})`,
-- [streamId, ...cmds]
-- );
-- return result?.changes || 0;
--}
--
--module.exports = { COZMO_PRESETS, applyCozmoPresets, removeCozmoPresets };
ddiff --git a/server/net/actor-limits.js b/server/net/actor-limits.js
iindex 5ee184be..4d592cfb 100644
---- a/server/net/actor-limits.js
++++ b/server/net/actor-limits.js
@@@ -25,7 +25,7 @@ const num = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) &&
/** Writes never counted per actor (paths relative to /api). */
const EXEMPT = [
-- /^\/auth\//, /^\/controls(\/|$)/, /^\/onvif(\/|$)/, /^\/kiosk(\/|$)/, /^\/robotstreamer(\/|$)/,
++ /^\/auth\//, /^\/kiosk(\/|$)/, /^\/robotstreamer(\/|$)/,
/^\/vods\/stream\/[^/]+\/(chunk|finalize)$/, /^\/thumbnails\/live(\/|$)/,
/^\/streams\/[^/]+\/heartbeat$/, /^\/streams\/diag-log$/,
];
ddiff --git a/server/openre/mirror.js b/server/openre/mirror.js
iindex 184911f3..d8f627c0 100644
---- a/server/openre/mirror.js
++++ b/server/openre/mirror.js
@@@ -77,7 +77,6 @@ async function apply(event) {
user_id: user.id,
channel_id: channel && channel.id,
managed_stream_id: slot.id,
-- control_config_id: slot.control_config_id || null,
title: slot.title || `${user.display_name || user.username}'s Stream`,
description: slot.description || '',
category: slot.category || null,
@@@ -96,11 +95,6 @@ async function apply(event) {
outcome: created ? 'created' : 'attached',
after: async () => {
for (const sid of ended) { try { require('../streaming/broadcast-server').endStream(sid); } catch { /* */ } }
-- try {
-- const channel = await db.getChannelByUserId(user.id);
-- const configId = slot.control_config_id || (channel && channel.active_control_config_id);
-- if (configId) await db.applyConfigToStream(configId, streamId);
-- } catch (e) { console.warn('[OpenRestream] control config for mirrored stream failed:', e.message); }
const stream = await db.getStreamById(streamId) || { id: streamId };
try { require('../streaming/live-events').announceGoLive(stream, user); } catch { /* */ }
console.log(`[OpenRestream] session ${sessionId} mirrored into stream ${streamId} (${user.username}, slot ${slot.id})`);
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex bcc66d5d..3f9e06de 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -104,15 +104,6 @@ async function staffMayModerate(actor, ownerUserId) {
return !(target && target.is_owner);
}
--/** A control profile id a slot may point at: one of the caller's own (admins may use anyone's). */
--async function ownControlConfigId(req, raw) {
-- if (raw === null || raw === undefined || raw === '') return null;
-- const id = parseInt(raw);
-- const cfg = Number.isFinite(id) ? await db.getControlConfig(id) : null;
-- if (!cfg || (cfg.user_id !== req.user.id && !can(req.user, 'staff.streams.manage'))) return undefined;
-- return id;
--}
--
/**
@@@ -1494,7 +1485,7 @@ router.get('/setup-progress', requireAuth, async (req, res) => {
// Chat owns channel_moderators and channel_moderation_settings (roadmap T3).
const channelMod = channel.id ? await moderation.getChannelModeration(channel.id).catch(() => null) : null;
const mods = channelMod ? channelMod.moderator_ids.length : 0;
-- const controls = await safe(async () => (await db.get('SELECT COUNT(*) AS n FROM control_configs WHERE user_id = ?', [uid])).n, 0);
++ const robot = await safe(async () => !!((await db.getChannelByUserId(uid)) || {}).bot_robot_id, false);
const aibot = await safe(async () => (await db.get('SELECT COUNT(*) AS n FROM channel_ai_bots WHERE channel_user_id = ?', [uid])).n, 0);
// Pastes live in OpenVibe.Community: the person's own count, unlisted and private included.
const pastes = await require('../media-proxy/lookups').countUserPastes(user && user.id ? user : null, { hidden: 'owner' });
@@@ -1519,7 +1510,7 @@ router.get('/setup-progress', requireAuth, async (req, res) => {
{ id: 'mods', group: 'Community & money', title: 'Add a moderator', why: 'Someone you trust watching chat while you stream.', done: mods > 0, count: mods, go: '/dashboard' },
{ id: 'requests', group: 'Community & money', title: 'Open media requests', why: 'Viewers spend OpenCoins to queue clips and tracks on your stream.', done: requests, go: '/dashboard' },
{ id: 'moderation', group: 'Community & money', title: 'Set your chat rules', why: 'Slow mode, followers-only and emote-only, tuned how you want them.', done: modRules, go: '/dashboard' },
-- { id: 'controls', group: 'Build & mod', title: 'Build viewer controls', why: 'Let chat drive a robot, trigger hardware or press your buttons.', done: controls > 0, count: controls, go: '/dashboard' },
++ { id: 'robot', group: 'Build & mod', title: 'Connect a robot', why: 'Pair a robot on OpenVibe.Bot and bind it to your channel: its panel shows under your stream and chat can drive it.', done: robot, go: 'https://openvibe.bot/robots/new' },
{ id: 'aibot', group: 'Build & mod', title: 'Add an AI chat regular', why: 'Keeps an empty chat warm and answers the usual questions.', done: aibot > 0, count: aibot, go: '/dashboard' },
{ id: 'paste', group: 'Build & mod', title: 'Publish a paste', why: 'Share code, configs or notes from your stream — they get their own page.', done: pastes > 0, count: pastes, go: '/pastes' },
];
@@@ -1582,8 +1573,6 @@ router.post('/managed', requireAuth, async (req, res) => {
streaming_method: cleanText(req.body.streaming_method, { maxLength: 20 }) || null,
stream_key,
is_nsfw,
-- // Someone else's profile would copy their buttons onto this slot and keep it in sync.
-- control_config_id: await ownControlConfigId(req, req.body.control_config_id) || null,
});
const managedStream = await db.getManagedStreamById(result.lastInsertRowid);
@@@ -1632,11 +1621,6 @@ router.put('/managed/:id', requireAuth, async (req, res) => {
fields.tags = cleanTags(req.body.tags);
if (fields.tags === null) return res.status(400).json({ error: 'Invalid tags' });
}
-- if (hasOwn(req.body, 'control_config_id')) {
-- const cfgId = await ownControlConfigId(req, req.body.control_config_id);
-- if (cfgId === undefined) return res.status(403).json({ error: 'Not your control profile' });
-- fields.control_config_id = cfgId;
-- }
if (hasOwn(req.body, 'pip_source_msid')) {
// null / '' clears the overlay. Anything else must be one of this user's own
// slots and must not be this slot, which would ask the player to render a
@@@ -1839,7 +1823,9 @@ router.get('/:id', optionalAuth, async (req, res) => {
}
stream.cameras = await db.all('SELECT * FROM cameras WHERE stream_id = ?', [stream.id]);
-- stream.controls = await db.getStreamControls(stream.id);
++ // Always empty: Live's robot controls are gone (OpenVibe.Bot's now). Kept one release for N-1 tabs that read it;
++ // it goes with the controls tables' contract migration.
++ stream.controls = [];
stream.channel = publicChannel(await db.getChannelByUserId(stream.user_id)) || null;
// Picture-in-picture camera overlay: another SLOT of this owner's whose live
@@@ -1910,30 +1896,11 @@ router.post('/', requireAuth, async (req, res) => {
// Use managed stream's settings as defaults, allow per-session overrides
const streamProtocol = protocol || cleanProtocol(managedStream.protocol) || cleanProtocol(channel.protocol) || 'webrtc';
const streamCategory = category || cleanText(managedStream.category, { maxLength: MAX_CATEGORY_LENGTH }) || cleanText(channel.category, { maxLength: MAX_CATEGORY_LENGTH }) || null; // null → the AI classifies the stream from what it shows
-- const requestedControlConfigId = req.body.control_config_id !== undefined ? (req.body.control_config_id === null ? null : parseInt(req.body.control_config_id)) : undefined;
--
-- if (requestedControlConfigId !== undefined && requestedControlConfigId !== null) {
-- const config = await db.getControlConfig(requestedControlConfigId);
-- if (!config) {
-- return res.status(404).json({ error: 'Control config not found' });
-- }
-- if (config.user_id !== req.user.id) {
-- return res.status(403).json({ error: 'Not authorized for this control profile' });
-- }
-- }
--
-- // Effective config: explicit non-null override > managed stream's saved default > null
-- // Note: null sent by client means "no explicit choice" (async select not yet populated),
-- // so fall back to the managed stream's configured profile rather than stripping controls.
-- const effectiveConfigId = (requestedControlConfigId != null)
-- ? requestedControlConfigId
-- : (managedStream.control_config_id || null);
const result = await db.createStream({
user_id: req.user.id,
channel_id: channel.id,
managed_stream_id: managedStream.id,
-- control_config_id: effectiveConfigId,
title: title || cleanText(managedStream.title, { maxLength: MAX_TITLE_LENGTH }) || `${req.user.display_name}'s Stream`,
description: description ?? cleanText(managedStream.description, { maxLength: MAX_DESCRIPTION_LENGTH, allowEmpty: true }) ?? '',
category: streamCategory,
@@@ -1968,24 +1935,6 @@ router.post('/', requireAuth, async (req, res) => {
[streamId, streamProtocol]
);
-- // Apply the control config to populate stream_controls for viewers.
-- if (effectiveConfigId !== null) {
-- try {
-- const applied = await db.applyConfigToStream(effectiveConfigId, streamId);
-- console.log(`[Streams] Applied control config ${effectiveConfigId} to stream ${streamId} (${applied} buttons)`);
-- } catch (cfgErr) {
-- console.warn(`[Streams] Failed to apply control config:`, cfgErr.message);
-- }
-- } else if (channel.active_control_config_id) {
-- // No slot-level config at all: fall back to channel default
-- try {
-- const applied = await db.applyConfigToStream(channel.active_control_config_id, streamId);
-- console.log(`[Streams] Auto-applied channel default control config ${channel.active_control_config_id} to stream ${streamId} (${applied} buttons)`);
-- } catch (cfgErr) {
-- console.warn(`[Streams] Failed to auto-apply channel default control config:`, cfgErr.message);
-- }
-- }
--
const stream = await db.getStreamById(streamId);
robotStreamerService.startForStream(stream).catch((rsErr) => {
console.warn(`[RS] Failed to start integration for stream ${streamId}:`, rsErr.message);
ddiff --git a/server/streaming/rtmp-server.js b/server/streaming/rtmp-server.js
iindex bac569ec..2834fd61 100644
---- a/server/streaming/rtmp-server.js
++++ b/server/streaming/rtmp-server.js
@@@ -156,24 +156,6 @@ class RTMPServer extends EventEmitter {
streamId = result.lastInsertRowid;
}
-- // Apply the per-slot control config, mirroring WHIP: the slot's own
-- // control_config_id wins, falling back to the channel default. This
-- // fixes viewers seeing the wrong (channel-default) controls when a
-- // streamer set different controls per stream slot.
-- try {
-- const streamRow = await db.getStreamById(streamId);
-- const slot = managedStream
-- || (streamRow && streamRow.managed_stream_id ? await db.getManagedStreamById(streamRow.managed_stream_id) : null);
-- const channel = await db.getChannelByUserId(resolvedUser.id);
-- const configId = (slot && slot.control_config_id) || (channel && channel.active_control_config_id);
-- if (configId) {
-- const applied = await db.applyConfigToStream(configId, streamId);
-- console.log(`[RTMP] Applied control config ${configId} to stream ${streamId} (${applied} buttons)${slot && slot.control_config_id ? ' [per-slot]' : ' [channel default]'}`);
-- }
-- } catch (cfgErr) {
-- console.warn('[RTMP] Failed to apply control config:', cfgErr.message);
-- }
--
// Dedup: end any other stale live session on this slot (keep this one).
try {
const streamRow2 = await db.getStreamById(streamId);
ddiff --git a/server/streaming/whip-handler.js b/server/streaming/whip-handler.js
iindex 0009cd0d..16adacb3 100644
---- a/server/streaming/whip-handler.js
++++ b/server/streaming/whip-handler.js
@@@ -599,13 +599,6 @@ async function autoCreateWhipSession(managedStream, user) {
await db.run('UPDATE streams SET last_heartbeat = ov_now() WHERE id = ?', [streamId]);
await db.run(`INSERT INTO cameras (stream_id, camera_index, label, protocol) VALUES (?, 0, 'Main', 'webrtc')`, [streamId]);
-- // Apply control config
-- const channel = await db.getChannelByUserId(user.id);
-- const configId = managedStream.control_config_id || (channel && channel.active_control_config_id);
-- if (configId) {
-- try { await db.applyConfigToStream(configId, streamId); } catch {}
-- }
--
const stream = await db.getStreamById(streamId);
console.log(`[WHIP] Auto-created live session ${streamId} for WHIP slot ${managedStream.id} (user: ${user.username})`);
ddiff --git a/server/web/observability.js b/server/web/observability.js
iindex 4bf39d18..f653aee7 100644
---- a/server/web/observability.js
++++ b/server/web/observability.js
@@@ -46,7 +46,7 @@ function mountMetrics(app, { release }) {
/**
* Domain gauges, read at scrape time. A source that throws is left out of that scrape.
* liveStreams() -> number of live streams (the rows /api/streams lists)
-- * wsServers -> { broadcast, control, call } objects with a `wss` (ws.Server) once initialised (chat is OpenVibe.Chat's)
++ * wsServers -> { broadcast } objects with a `wss` (ws.Server) once initialised (chat is OpenVibe.Chat's)
* outboxStatus() -> server/events/stream-events.js counts() (a promise)
*/
function registerDomainGauges(registry, { liveStreams, wsServers = {}, outboxStatus }) {
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
iindex c6716399..ccfb5640 100644
---- a/test/actor-limits.test.js
++++ b/test/actor-limits.test.js
@@@ -52,7 +52,6 @@ const tokenFor = (id, subject) => jwt.sign({ sub: String(id), username: `u${id}`
for (let i = 0; i < 20; i++) assert.strictEqual((await call('GET', '/api/streams', ann)).status, 200, 'reads are not counted');
for (let i = 0; i < 10; i++) assert.strictEqual((await call('POST', '/api/pastes')).status, 200, 'signed-out writes keep the address limit only');
for (let i = 0; i < 10; i++) {
-- assert.strictEqual((await call('POST', '/api/controls/1/move', ann)).status, 200, 'robot controls are never counted');
assert.strictEqual((await call('POST', '/api/vods/stream/9/chunk', ann)).status, 200, 'recording chunks are never counted');
assert.strictEqual((await call('POST', '/api/streams/9/heartbeat', ann)).status, 200, 'heartbeats are never counted');
}
ddiff --git a/test/authorization.test.js b/test/authorization.test.js
iindex 44dc6655..2da4f315 100644
---- a/test/authorization.test.js
++++ b/test/authorization.test.js
@@@ -63,7 +63,6 @@ const express = require('express');
const app = express();
app.use(express.json());
app.use('/api/mod', require('../server/admin/mod-routes'));
--app.use('/api/controls', require('../server/controls/routes'));
app.use('/api/admin', require('../server/admin/routes'));
app.use('/api/media', require('../server/media/routes'));
app.use('/api/ai-viewers', require('../server/ai/viewers/routes'));
@@@ -104,18 +103,6 @@ async function check(name, fn) {
assert.ok(moderated.some((m) => m.action === 'delete-message' && m.fields.id === MSG_B), 'Chat is asked to delete it');
});
-- await check('controls: a button id from someone else\'s profile cannot be edited or deleted through yours', async () => {
-- const cfgA = (await db.run("INSERT INTO control_configs (user_id, name, description) VALUES (3, 'A', '') RETURNING id")).lastInsertRowid;
-- const cfgB = (await db.run("INSERT INTO control_configs (user_id, name, description) VALUES (4, 'B', '') RETURNING id")).lastInsertRowid;
-- const btnB = (await db.run("INSERT INTO control_config_buttons (config_id, label, command) VALUES (?, 'Fire', 'fire') RETURNING id", [cfgB])).lastInsertRowid;
-- const put = await call('PUT', `/api/controls/configs/${cfgA}/buttons/${btnB}`, 3, { command: 'self_destruct' });
-- assert.strictEqual(put.status, 404, put.text);
-- const del = await call('DELETE', `/api/controls/configs/${cfgA}/buttons/${btnB}`, 3);
-- assert.strictEqual(del.status, 404, del.text);
-- const row = await raw.prepare('SELECT command FROM control_config_buttons WHERE id = ?').get(btnB);
-- assert.strictEqual(row && row.command, 'fire');
-- });
--
await check('admin: a non-owner admin cannot demote the owner, grant admin, or ban the owner/an admin', async () => {
assert.strictEqual((await call('PUT', '/api/admin/users/1', 2, { role: 'user' })).status, 403);
assert.strictEqual((await call('PUT', '/api/admin/users/5', 2, { role: 'admin' })).status, 403);
@@@ -208,12 +195,11 @@ async function check(name, fn) {
await check('API tokens: scopes gate writes; money, staff and credential routes refuse tokens', () => {
const t = (method, url, scopes) => auth.apiTokenAllows({ method, originalUrl: url }, scopes);
assert.strictEqual(t('GET', '/api/streams', ['read']), true);
-- assert.strictEqual(t('POST', '/api/funds/cashout', ['read', 'stream', 'chat', 'control']), false);
++ assert.strictEqual(t('POST', '/api/funds/cashout', ['read', 'stream', 'chat']), false);
assert.strictEqual(t('GET', '/api/admin/users', ['read']), false);
assert.strictEqual(t('DELETE', '/api/vods/3', ['read', 'chat']), false);
assert.strictEqual(t('DELETE', '/api/vods/3', ['stream']), true);
assert.strictEqual(t('POST', '/api/chat/send', ['chat']), true);
-- assert.strictEqual(t('POST', '/api/controls/configs', ['read']), false);
assert.strictEqual(t('GET', '/api/auth/stream-key', ['read']), false);
});
ddiff --git a/test/chat-context.test.js b/test/chat-context.test.js
iindex d922e5ca..67d82e88 100644
---- a/test/chat-context.test.js
++++ b/test/chat-context.test.js
@@@ -1028,30 +1028,64 @@ const waitFor = async (pred, what, ms = 3000) => {
}
-- // 12. Robot commands from chat pass the control panel's gate: control mode, anonymous switch, whitelist, cooldown.
++ // 12. Robot commands from chat drive the channel's OpenVibe.Bot robot, through Bot's gate as the viewer who typed
++ // them (server/bot/bot-client.js): no robot → said so; not signed in → refused here; a refusal from Bot comes
++ // back in chat words. A stub stands in for Network's token endpoint and Bot's command route.
{
-- const sent = [];
-- require('../server/controls/control-server').hardwareClients.set('key-streamer', { readyState: 1, send: (m) => sent.push(JSON.parse(m)) });
-- const hw = (body) => call('POST', '/internal/chat-effects/hardware', { token: WRITE, body: { streamer_user_id: streamer, command: 'forward', ...body } });
-- const setCh = async (mode, anon) => await d.prepare('UPDATE channels SET control_mode = ?, anon_controls_enabled = ? WHERE id = ?').run(mode, anon, channel.id);
-- await setCh('open', 1);
-- assert.strictEqual((await hw({ from_anon: 'anon1' })).body.ok, true, 'open channel: anyone');
-- assert.strictEqual((await hw({ from_anon: 'anon1' })).body.reason, 'cooldown', 'one command per viewer per 250 ms');
-- assert.strictEqual((await hw({ from_anon: 'anon2' })).body.ok, true, 'the cooldown is per viewer');
-- await setCh('open', 0);
-- assert.strictEqual((await hw({ from_anon: 'anon3' })).body.reason, 'login_required');
-- assert.strictEqual((await hw({ from_user_id: viewer })).body.ok, true, 'signed-in viewers still may');
-- await setCh('whitelist', 1);
-- assert.strictEqual((await hw({ from_anon: 'anon4' })).body.reason, 'login_required', 'whitelist mode never lets anonymous drive');
-- assert.strictEqual((await hw({ from_user_id: mod })).body.reason, 'not_whitelisted');
-- await d.prepare('INSERT INTO control_whitelist (channel_id, user_id) VALUES (?, ?)').run(channel.id, mod);
-- assert.strictEqual((await hw({ from_user_id: mod })).body.ok, true, 'whitelisted');
-- assert.strictEqual((await hw({ from_user_id: streamer })).body.ok, true, 'the owner always may');
-- await setCh('disabled', 1);
-- assert.strictEqual((await hw({ from_user_id: streamer, command: 'say:hi' })).body.reason, 'controls_disabled');
-- assert.strictEqual(sent.length, 5, 'only the allowed commands reached the robot');
-- assert.ok(sent.every((m) => m.type === 'command'));
-- require('../server/controls/control-server').hardwareClients.delete('key-streamer');
++ const http = require('http');
++ const seen = [];
++ let answer = { status: 200, body: { result: 'ack' } };
++ const stub = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ res.setHeader('Content-Type', 'application/json');
++ if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'live-bot-token', token_type: 'Bearer', expires_in: 300 }));
++ seen.push({ url: req.url, auth: req.headers.authorization, subject: req.headers['x-ov-subject'], body: JSON.parse(raw || '{}') });
++ res.statusCode = answer.status;
++ return res.end(JSON.stringify(answer.body));
++ });
++ });
++ await new Promise((r) => stub.listen(0, '127.0.0.1', r));
++ const stubUrl = `http://127.0.0.1:${stub.address().port}`;
++ const ENV = ['OV_OAUTH_CLIENT_SECRET', 'OV_NETWORK_INTERNAL_URL', 'LIVE_BOT_INTERNAL_URL'];
++ const before = Object.fromEntries(ENV.map((k) => [k, process.env[k]]));
++ Object.assign(process.env, { OV_OAUTH_CLIENT_SECRET: 'test-secret', OV_NETWORK_INTERNAL_URL: stubUrl, LIVE_BOT_INTERNAL_URL: stubUrl });
++ const pause = () => new Promise((r) => setTimeout(r, 270));
++ const ROBOT = 'rob_01TESTROBOT0000000000000001';
++ try {
++ const hw = (body) => call('POST', '/internal/chat-effects/hardware', { token: WRITE, body: { streamer_user_id: streamer, command: 'forward', ...body } });
++ const none = await hw({ from_user_id: viewer });
++ assert.deepStrictEqual([none.body.ok, none.body.reason], [false, 'no_robot'], 'a channel with no robot');
++ await d.prepare('UPDATE channels SET bot_robot_id = ? WHERE id = ?').run(ROBOT, channel.id);
++ assert.strictEqual((await hw({ from_anon: 'anon1' })).body.reason, 'login_required', 'a viewer who is not signed in cannot drive');
++ const ok = await hw({ from_user_id: viewer });
++ assert.strictEqual(ok.body.ok, true, JSON.stringify(ok.body));
++ const first = seen.at(-1);
++ assert.strictEqual(first.url, `/api/v1/robots/${ROBOT}/commands`);
++ assert.strictEqual(first.auth, 'Bearer live-bot-token', 'Live\'s own service token');
++ assert.strictEqual(first.subject, 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ', 'as the viewer (their linked Network subject)');
++ assert.deepStrictEqual([first.body.kind, first.body.value, first.body.ms], ['drive', { throttle: 0.6 }, 600]);
++ assert.match(first.body.id, /^chat-/);
++ assert.strictEqual((await hw({ from_user_id: viewer })).body.reason, 'cooldown', 'one command per viewer per 250 ms');
++ await pause();
++ await hw({ from_subject: 'usr_01J9YYYYYYYYYYYYYYYYYYYYYY', command: 'say:hello robot' });
++ const said = seen.at(-1);
++ assert.deepStrictEqual([said.subject, said.body.kind, said.body.value, 'ms' in said.body], ['usr_01J9YYYYYYYYYYYYYYYYYYYYYY', 'say', { text: 'hello robot' }, false], 'Chat names the viewer by subject');
++ answer = { status: 403, body: { code: 'bot.not_your_turn', detail: 'wait for your turn' } };
++ await pause();
++ const refused = await hw({ from_user_id: viewer, command: 'turn_left' });
++ assert.deepStrictEqual([refused.body.ok, refused.body.reason, refused.body.message], [false, 'bot.not_your_turn', 'It is not your turn at the robot yet.']);
++ assert.deepStrictEqual(seen.at(-1).body.value, { steer: -0.7 });
++ answer = { status: 503, body: {} };
++ await pause();
++ assert.strictEqual((await hw({ from_user_id: viewer })).body.reason, 'robot_unavailable', 'Bot down is said so');
++ assert.strictEqual((await hw({ from_user_id: viewer, command: 'self_destruct' })).status, 400, 'an unknown command');
++ await d.prepare('UPDATE channels SET bot_robot_id = NULL WHERE id = ?').run(channel.id);
++ } finally {
++ stub.close();
++ for (const k of ENV) { if (before[k] === undefined) delete process.env[k]; else process.env[k] = before[k]; }
++ }
}
quiet('chat context (Live side of OpenVibe.Chat): all checks passed');
ddiff --git a/test/chat-sheet-width.test.js b/test/chat-sheet-width.test.js
nnew file mode 100644
iindex 00000000..22dc50f2
---- /dev/null
++++ b/test/chat-sheet-width.test.js
@@@ -0,0 +1,48 @@
++'use strict';
++// The channel chat's saved sizes (public/js/channel-chat-resize.js) belong to their own layouts: --chat-w to the side
++// column (1181px and wider), --chat-ow to the landscape overlay, --chat-sheet-h to the portrait sheet. On 2026-10-14 the
++// side width's rule sat outside any media query and outranked the sheet's (its :not(:has()) added specificity), so a
++// width saved on a wide screen made a phone's bottom sheet 260px wide with the page showing beside it.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++// Comments out first: a comment before a rule would otherwise read as part of its selector or at-rule.
++const read = (...p) => fs.readFileSync(path.join(__dirname, '..', 'public', 'css', ...p), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
++const css = read('features', 'channel.css');
++const style = read('style.css');
++
++/** The @media condition enclosing position `i` of `text` (null at top level). */
++function mediaAt(text, i) {
++ let depth = 0;
++ for (let j = i; j >= 0; j--) {
++ if (text[j] === '}') depth++;
++ else if (text[j] === '{') {
++ if (depth === 0) {
++ const head = text.slice(text.lastIndexOf('}', j) + 1, j).trim();
++ if (head.startsWith('@media')) return head;
++ } else depth--;
++ }
++ }
++ return null;
++}
++
++const uses = (text, v) => [...text.matchAll(new RegExp(`var\\(${v}[,)]`, 'g'))].map((m) => m.index);
++// The channel page's own rules (#page-channel …) are the ones that outrank the layouts' rules; style.css's base
++// `.chat-sidebar { width: var(--chat-w) }` (0,1,0) loses to both the overlay and the sheet rule, so it may stay.
++assert.match(style, /\.chat-sidebar \{\s*width: var\(--chat-w\);/, 'the base default');
++for (const [name, text] of [['channel.css', css]]) {
++ for (const i of uses(text, '--chat-w')) {
++ assert.match(String(mediaAt(text, i)), /min-width:\s*1181px/, `${name}: --chat-w is used outside the side layout (min-width: 1181px)`);
++ }
++ for (const i of uses(text, '--chat-ow')) {
++ assert.match(String(mediaAt(text, i)), /max-width:\s*1180px\)\s*and\s*\(orientation:\s*landscape/, `${name}: --chat-ow outside the landscape overlay`);
++ }
++ for (const i of uses(text, '--chat-sheet-h')) {
++ assert.match(String(mediaAt(text, i)), /max-width:\s*1180px\)\s*and\s*\(orientation:\s*portrait/, `${name}: --chat-sheet-h outside the portrait sheet`);
++ }
++}
++assert.ok(uses(css, '--chat-w').length === 1, 'the side width is set in one place');
++// The sheet itself is the full width of the screen.
++assert.match(css, /@media \(max-width: 1180px\) and \(orientation: portrait\) \{ #page-channel \.stream-layout > \.chat-sidebar \{ width: 100%;/);
++console.log('chat-sheet-width: each saved chat size applies only in its own layout');
ddiff --git a/test/drill-mode.test.js b/test/drill-mode.test.js
iindex fb8e06ee..b52dfec4 100644
---- a/test/drill-mode.test.js
++++ b/test/drill-mode.test.js
@@@ -271,7 +271,7 @@ const safe = { DATA_DIR, HOST: '127.0.0.1', PORT: '13000' };
assert.deepStrictEqual(await counts(), before);
});
await check('WebSocket upgrades are refused with 403', async () => {
-- for (const p of ['/ws/chat', '/ws/broadcast', '/ws/call', '/ws/control', '/ws/robotstreamer-publish']) {
++ for (const p of ['/ws/chat', '/ws/broadcast', '/ws/call', '/ws/robotstreamer-publish']) {
const r = await request('GET', p, { headers: { Connection: 'Upgrade', Upgrade: 'websocket', 'Sec-WebSocket-Version': '13', 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==' } });
assert.strictEqual(r.status, 403, `${p} answered ${r.status}`);
assert.ok(!r.upgraded);
ddiff --git a/test/floating-promise-fixes.test.js b/test/floating-promise-fixes.test.js
iindex b02a4fae..a7870c33 100644
---- a/test/floating-promise-fixes.test.js
++++ b/test/floating-promise-fixes.test.js
@@@ -24,7 +24,6 @@ function stub(modPath, exportsObj) {
stub(path.join(__dirname, '../server/db/database'), {});
stub(path.join(__dirname, '../server/auth/auth'), { authenticateWs: () => null, extractWsToken: () => null });
--const controlServer = require('../server/controls/control-server');
const chatRelay = require('../server/integrations/chat-relay-service');
const tick = () => new Promise((resolve) => setImmediate(resolve));
@@@ -49,39 +48,7 @@ async function capture(fn) {
}
(async () => {
-- // ── 1. control socket: a rejecting command handler ──────────────────────
-- const listeners = [];
-- const ws = {
-- readyState: 1,
-- on: (event, fn) => { if (event === 'message') listeners.push(fn); },
-- send: () => {},
-- close: () => {},
-- };
-- await controlServer.handleViewerConnection(ws, null, new URLSearchParams(''));
-- assert.strictEqual(listeners.length, 1, 'the viewer control socket registers exactly one message handler');
--
-- const realHandleCommand = controlServer.handleCommand;
-- controlServer.handleCommand = async () => { throw new Error('camera exploded'); };
--
-- let control;
-- try {
-- control = await capture(() => listeners[0](JSON.stringify({ type: 'command', control_id: 1 })));
-- } finally {
-- controlServer.handleCommand = realHandleCommand;
-- }
-- assert.deepStrictEqual(control.unhandled, [], 'an awaited command failure is not an unhandled rejection');
-- assert.ok(control.warns.some((w) => w.includes('camera exploded')),
-- `the command failure is logged, got ${JSON.stringify(control.warns)}`);
--
-- // The synchronous branches still run and a malformed frame is still ignored.
-- let keyEvents = 0;
-- controlServer.handleKeyEvent = () => { keyEvents++; };
-- listeners[0]('not json at all');
-- listeners[0](JSON.stringify({ type: 'key_down', key: 'w' }));
-- assert.strictEqual(keyEvents, 1, 'key events still reach their handler');
-- delete controlServer.handleKeyEvent;
--
-- // ── 2. chat relay: a bridge whose connect() rejects ─────────────────────
++ // ── The chat relay: a bridge whose connect() rejects ─────────────────────
const realKick = chatRelay._connectKick;
const realYouTube = chatRelay._connectYouTube;
chatRelay._connectKick = async () => { throw new Error('kick ws refused'); };
@@@ -104,5 +71,5 @@ async function capture(fn) {
assert.ok(relay.warns.some((w) => w.includes('youtube api refused')),
`the YouTube connect failure is logged, got ${JSON.stringify(relay.warns)}`);
-- console.log('floating-promise-fixes: control command + chat-relay bridge rejections are logged, not unhandled');
++ console.log('floating-promise-fixes: chat-relay bridge rejections are logged, not unhandled');
})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/n-1/service.js b/test/n-1/service.js
iindex 7197cdf1..3194c512 100644
---- a/test/n-1/service.js
++++ b/test/n-1/service.js
@@@ -27,6 +27,10 @@ const ISSUER = 'https://openvibe.network';
// call server was deleted (T3): nginx sends /api/streams/voice-channels… and /api/streams/:id/call there (Chat's
// deploy/nginx/calls/openvibe.live-calls.locations.conf).
const CHAT_PATHS = /^\/(api\/(chat|dm|tts|emotes|channels)(\/|$)|api\/sounds(\/|$)|api\/streams\/voice-channels(\/|$)|api\/streams\/[^/]+\/call$|ws\/)/;
++// Live's robot controls were deleted (plan T15; robots are OpenVibe.Bot's): an N-1 tab's /api/controls and /api/onvif calls
++// now answer 404, and every one of them was already wrapped to fail quietly (the controls panel, the dashboard's
++// controls and ONVIF cards, the broadcast profile pickers), so they are not this release's to answer either.
++const RETIRED_PATHS = /^\/api\/(controls|onvif)(\/|$)/;
function freePort() {
return new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });
@@@ -76,7 +80,7 @@ module.exports = {
{ name: 'navigator.sendBeacon', method: 'POST' },
],
strip: ['API', 'location.origin', 'window.location.origin'],
-- keep: (pathname) => !CHAT_PATHS.test(pathname),
++ keep: (pathname) => !CHAT_PATHS.test(pathname) && !RETIRED_PATHS.test(pathname),
/** Pages an open tab may hold: the shell (its scripts, styles and links) and a server-rendered channel. */
crawl: ['/', '/@n1star'],
origins: ['https://openvibe.live'],
ddiff --git a/test/pip-camera-slot.test.js b/test/pip-camera-slot.test.js
iindex fabb70e3..6bf9c7b7 100644
---- a/test/pip-camera-slot.test.js
++++ b/test/pip-camera-slot.test.js
@@@ -104,15 +104,13 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut
const streamPanel = ws.slice(prof, settings);
const settingsPanel = ws.slice(settings, endpoint);
-- assert.ok(!streamPanel.includes('id="bc-control-config"'),
-- 'the control profile select must no longer render in the Stream tab');
-- assert.ok(settingsPanel.includes('id="bc-control-config"'),
-- 'the control profile select must render in the Settings tab');
++ // Live's robot controls are gone (robots are OpenVibe.Bot's): no control profile select anywhere.
++ assert.ok(!ws.includes('id="bc-control-config"'), 'no control profile select in the workspace');
assert.ok(settingsPanel.includes('id="bc-ws-pip-source"'),
'the PiP camera picker must render in the Settings tab');
-- // Both must be their own collapsible sections, matching the other Settings groups.
-- for (const label of ['Control Profile', 'Picture-in-picture Camera']) {
++ // It is its own collapsible section, matching the other Settings groups.
++ for (const label of ['Picture-in-picture Camera']) {
// Anchor on the SUMMARY text: an HTML comment carries the same label just above
// the <details>, and matching that would look in the wrong place entirely.
const idx = settingsPanel.indexOf(`${label}</summary>`);
@@@ -121,7 +119,7 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut
assert.ok(before.includes('<details class="bc-ws-slot-settings">'),
`"${label}" must be a collapsible <details> section like the rest of Settings`);
}
-- console.log('OK J: control profile and PiP camera are collapsible sections under Settings');
++ console.log('OK J: the PiP camera is a collapsible section under Settings, and there is no control profile');
}
// ── K: PiP starting geometry round-trips through corner/size ─────────────────────────
ddiff --git a/test/security-idor.test.js b/test/security-idor.test.js
iindex c5a6d800..6efa3262 100644
---- a/test/security-idor.test.js
++++ b/test/security-idor.test.js
@@@ -88,7 +88,6 @@ app.use('/api/restream', require('../server/streaming/restream-routes'));
app.use('/api/vods', require('../server/media-proxy/vods'));
app.use('/api/clips', require('../server/media-proxy/clips'));
app.use('/api/thumbnails', require('../server/media-proxy/thumbnails'));
--app.use('/api/onvif', require('../server/controls/onvif-routes'));
app.use('/api/vibe-coding', require('../server/vibe-coding/routes'));
const server = http.createServer(app).listen(0, '127.0.0.1');
@@@ -133,8 +132,6 @@ async function check(name, fn) {
VODS[100].stream_id = streamA;
const destA = Number((await db.createRestreamDestination(ALICE, { platform: 'custom', name: 'Alice mirror', server_url: 'rtmp://ingest.example.test/live', stream_key: DEST_KEY, managed_stream_id: slotA })).id);
const tokenA = (await db.createApiToken(ALICE, 'Alice bot', ['chat', 'read'])).id;
-- const camA = Number((await raw.prepare("INSERT INTO camera_profiles (user_id, stream_id, name, onvif_url, username, password_hash) VALUES (?, ?, 'Alice cam', 'http://camera.example.test', 'admin', 'x') RETURNING id").run(ALICE, streamA)).lastInsertRowid);
-- const presetA = Number((await raw.prepare("INSERT INTO camera_presets (camera_id, name, pan, tilt, zoom) VALUES (?, 'Desk', 0.5, 0.5, 0.5) RETURNING id").run(camA)).lastInsertRowid);
// Bob has his own slot and stream, so "my slot" routes have something of his to compare against.
const chanB = await db.getChannelByUserId(BOB);
const slotB = Number((await db.createManagedStream({ user_id: BOB, channel_id: chanB.id, slug: 'main', title: 'Bob main', protocol: 'rtmp', stream_key: 'ef'.repeat(20) })).lastInsertRowid);
@@@ -243,23 +240,12 @@ async function check(name, fn) {
refused(await call('DELETE', `/api/auth/tokens/${tokenA}`, BOB), 'DELETE token');
assert.strictEqual((await row('api_tokens', tokenA)).is_active, 1);
});
-- await check('ONVIF cameras: Bob cannot read, edit or delete Alice\'s camera or its presets', async () => {
-- const before = [await row('camera_profiles', camA), await row('camera_presets', presetA)];
-- refused(await call('GET', `/api/onvif/cameras/${camA}`, BOB), 'GET camera');
-- refused(await call('GET', `/api/onvif/cameras/${camA}/presets`, BOB), 'GET presets');
-- refused(await call('PUT', `/api/onvif/cameras/${camA}`, BOB, { name: 'pwned', onvif_url: 'http://bob.example.test' }), 'PUT camera');
-- refused(await call('POST', `/api/onvif/cameras/${camA}/presets`, BOB, { name: 'x', pan: 0, tilt: 0, zoom: 0 }), 'POST preset');
-- refused(await call('DELETE', `/api/onvif/cameras/${camA}/presets/${presetA}`, BOB), 'DELETE preset');
-- refused(await call('DELETE', `/api/onvif/cameras/${camA}`, BOB), 'DELETE camera');
-- assert.deepStrictEqual([await row('camera_profiles', camA), await row('camera_presets', presetA)], before);
-- });
-- await check('control: Alice can read her camera and revoke her token', async () => {
-- assert.strictEqual((await call('GET', `/api/onvif/cameras/${camA}`, ALICE)).status, 200);
++ await check('Alice can revoke her own token', async () => {
assert.strictEqual((await call('DELETE', `/api/auth/tokens/${tokenA}`, ALICE)).status, 200);
});
await check('anonymous callers get 401 on all of them', async () => {
for (const [m, p] of [['PUT', '/api/vods/100'], ['DELETE', '/api/clips/200'], ['PUT', `/api/streams/managed/${slotA}`], ['POST', `/api/streams/managed/${slotA}/regenerate-key`],
-- ['DELETE', `/api/restream/destinations/${destA}`], ['DELETE', `/api/auth/tokens/${tokenA}`], ['DELETE', `/api/onvif/cameras/${camA}`]]) {
++ ['DELETE', `/api/restream/destinations/${destA}`], ['DELETE', `/api/auth/tokens/${tokenA}`]]) {
const r = await call(m, p, null, {}).catch((e) => ({ status: 0, text: e.message }));
assert.strictEqual(r.status, 401, `${m} ${p}: ${r.status} ${r.text}`);
}
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex d1518375..769d7d95 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -347,6 +347,7 @@ async function check(name, fn) {
'server/chat/moderation-client.js': 'OpenVibe.Chat internal read API (configured)',
'server/social/preview.js': 'OpenVibe.Network public profile API (configured OV_NETWORK_INTERNAL_URL, fixed path, username checked); every other preview goes through egress',
'server/monetization/inventory-client.js': 'OpenVibe.Inventory API for Live cosmetics (configured INVENTORY_URL)',
++ 'server/bot/bot-client.js': 'OpenVibe.Bot command API for a bound channel\'s chat robot commands (configured LIVE_BOT_INTERNAL_URL)',
'server/chat/insight-client.js': 'OpenVibe.Chat public chat-AI reads (configured OV_CHAT_INTERNAL_URL, fixed paths, ids validated)',
'server/chat/chat-client.js': 'OpenVibe.Chat typed ingress (configured OV_CHAT_INTERNAL_URL, fixed /internal/chat/* paths)',
'server/chat/chat-delivery.js': 'OpenVibe.Tools anon resolve (configured)',
ddiff --git a/test/slot-control-profiles.test.js b/test/slot-control-profiles.test.js
ddeleted file mode 100644
iindex 4118b8af..00000000
---- a/test/slot-control-profiles.test.js
++++ /dev/null
@@@ -1,204 +0,0 @@
--/**
-- * Legacy parity (roadmap D20): per-stream control profiles. Each stream slot (managed stream) keeps
-- * its own control profile: whichever way a slot goes live, its stream gets that slot's buttons (the
-- * channel default only when the slot has none), and editing one profile changes only the streams
-- * bound to it.
-- *
-- * The fix it came with: an RTMP publish attached to ANY live RTMP stream of the user that was waiting
-- * for an encoder. With two slots on RTMP, the second slot's encoder took over the first slot's stream,
-- * rebound it to the second slot's profile, fed it from two encoders, and ended it when that encoder
-- * disconnected, while the second slot never got a stream. A slot key now only picks up its own slot's
-- * waiting stream (the account key: one no other encoder feeds).
-- *
-- * The real RTMP publish handlers (server/streaming/rtmp-server.js) run against a fake node-media-server
-- * on the test database; the control routes run in-process.
-- *
-- * node test/slot-control-profiles.test.js
-- */
--'use strict';
--const assert = require('assert');
--const fs = require('fs');
--const os = require('os');
--const path = require('path');
--const http = require('http');
--
--const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-slot-controls-'));
--process.env.DATA_DIR = tmp;
--process.env.NODE_ENV = 'test';
--const quiet = console.log;
--console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
--console.warn = () => {};
--
--const root = path.join(__dirname, '..');
--const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
--const db = require('../server/db/database');
--
--const auth = require('../server/auth/auth');
--const signIn = async (req) => {
-- const id = Number(req.headers['x-test-user'] || 0);
-- const u = id ? await db.getUserById(id) : null;
-- if (u) { req.user = u; req.authSource = 'network'; }
-- return u;
--};
--auth.requireAuth = async (req, res, next) => ((await signIn(req)) ? next() : res.status(401).json({ error: 'Authentication required' }));
--auth.optionalAuth = async (req, res, next) => { await signIn(req); next(); };
--
--// ── the RTMP server, with node-media-server and its side effects stubbed ──
--const handlers = {};
--class FakeNms {
-- on(event, fn) { handlers[event] = fn; }
-- run() {}
-- stop() {}
-- getSession() { return { reject() {} }; }
--}
--require.cache[require.resolve('node-media-server')] = { id: 'nms', filename: 'nms', loaded: true, exports: FakeNms };
--require.cache[require.resolve('../server/streaming/broadcast-server')] = { id: 'bs', filename: 'bs', loaded: true, exports: { endStream() {} } };
--const recorder = require('../server/streaming/recorder');
--recorder.startRecording = () => {};
--recorder.stopRecording = () => {};
--require('../server/streaming/live-events').announceGoLive = () => {};
--const rtmp = require('../server/streaming/rtmp-server');
--rtmp.start();
--let session = 0;
--const publish = async (key) => { const id = `s${++session}`; await handlers.prePublish(id, `/live/${key}`, {}); return rtmp.activeStreams.get(key); };
--const unpublish = async (key) => { await handlers.donePublish(rtmp.activeStreams.get(key)?.sessionId, `/live/${key}`, {}); };
--
--const express = require('express');
--const app = express();
--app.use(express.json());
--app.use('/api/controls', require('../server/controls/routes'));
--const server = http.createServer(app).listen(0);
--function call(method, p, user, body) {
-- return new Promise((resolve, reject) => {
-- const data = body ? JSON.stringify(body) : null;
-- const headers = { 'content-type': 'application/json' };
-- if (data) headers['content-length'] = Buffer.byteLength(data);
-- if (user) headers['x-test-user'] = String(user);
-- const req = http.request({ port: server.address().port, path: p, method, headers }, (res) => {
-- let text = '';
-- res.on('data', (c) => { text += c; });
-- res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json }); });
-- });
-- req.on('error', reject);
-- if (data) req.write(data);
-- req.end();
-- });
--}
--
--let failures = 0;
--async function check(name, fn) {
-- try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack || e.message); }
--}
--
--let ch, ROBOT, CAMERA, DEFAULT, MALLORY, ARM, CAM, PLAIN;
--const profile = async (userId, name, btnLabels) => {
-- const id = Number((await db.createControlConfig({ user_id: userId, name })).lastInsertRowid);
-- for (let i = 0; i < btnLabels.length; i++) await db.createConfigButton({ config_id: id, label: btnLabels[i], command: btnLabels[i].toLowerCase(), sort_order: i });
-- return id;
--};
--const slot = async (slug, protocol, cfg) => Number((await db.createManagedStream({
-- user_id: 3, channel_id: ch.id, slug, title: slug, protocol, streaming_method: protocol === 'rtmp' ? 'rtmp' : 'whip',
-- stream_key: `${slug}key`.padEnd(40, '0'), control_config_id: cfg,
--})).lastInsertRowid);
--const keyOf = async (msid) => (await db.getManagedStreamById(msid)).stream_key;
--const buttons = async (streamId) => (await db.getStreamControls(streamId)).map((c) => c.label);
--
--(async () => {
-- await db.initDb();
-- const raw = db.getDb();
--
-- // ── a channel with four slots and three profiles ──
-- for (const [id, name] of [[3, 'alice'], [7, 'mallory']]) {
-- await raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, stream_key, created_at) OVERRIDING SYSTEM VALUE
-- VALUES (?, ?, ?, ?, 'x', 'streamer', ?, '2025-01-01 00:00:00')`).run(id, name, name, `${name}@x`, `personal${name}key01`);
-- }
-- ch = await db.ensureChannel(3);
-- await db.ensureChannel(7);
-- ROBOT = await profile(3, 'Robot arm', ['Left', 'Right']);
-- CAMERA = await profile(3, 'Camera', ['Pan', 'Zoom']);
-- DEFAULT = await profile(3, 'Channel default', ['Wave']);
-- MALLORY = await profile(7, 'Not yours', ['Steal']);
-- await db.updateChannel(3, { active_control_config_id: DEFAULT });
-- ARM = await slot('arm', 'rtmp', ROBOT);
-- CAM = await slot('cam', 'rtmp', CAMERA);
-- PLAIN = await slot('plain', 'rtmp', null);
--
-- if (!server.listening) await new Promise((r) => server.once('listening', r));
-- let armStream, camStream;
--
-- await check('Go Live on a slot, then its encoder: the waiting stream is used and keeps the slot\'s profile', async () => {
-- // What the Go Live page does for the "arm" slot (POST /api/streams with managed_stream_id).
-- armStream = Number((await db.createStream({ user_id: 3, channel_id: ch.id, managed_stream_id: ARM, control_config_id: ROBOT, title: 'arm', protocol: 'rtmp' })).lastInsertRowid);
-- await db.applyConfigToStream(ROBOT, armStream);
-- const info = await publish(await keyOf(ARM));
-- assert.strictEqual(info.streamId, armStream);
-- assert.deepStrictEqual(await buttons(armStream), ['Left', 'Right']);
-- });
--
-- await check('a second slot\'s encoder gets its own stream and profile, and leaves the first slot\'s alone', async () => {
-- const info = await publish(await keyOf(CAM));
-- camStream = info.streamId;
-- assert.notStrictEqual(camStream, armStream, 'not the arm slot\'s stream');
-- const row = await db.getStreamById(camStream);
-- assert.deepStrictEqual([row.managed_stream_id, row.control_config_id, !!row.is_live], [CAM, CAMERA, true]);
-- assert.deepStrictEqual(await buttons(camStream), ['Pan', 'Zoom']);
-- assert.deepStrictEqual(await buttons(armStream), ['Left', 'Right'], 'the arm slot keeps its buttons');
-- assert.strictEqual((await db.getStreamById(armStream)).control_config_id, ROBOT);
-- assert.strictEqual(rtmp.activeStreams.get(await keyOf(ARM)).streamId, armStream);
-- });
--
-- await check('a slot without a profile gets the channel default', async () => {
-- const info = await publish(await keyOf(PLAIN));
-- assert.ok(![armStream, camStream].includes(info.streamId));
-- assert.deepStrictEqual(await buttons(info.streamId), ['Wave']);
-- await unpublish(await keyOf(PLAIN));
-- });
--
-- await check('one encoder leaving ends only its own slot\'s stream', async () => {
-- await unpublish(await keyOf(CAM));
-- assert.strictEqual((await db.getStreamById(camStream)).is_live, 0);
-- assert.strictEqual((await db.getStreamById(armStream)).is_live, 1, 'the arm slot is still live');
-- assert.ok(rtmp.isReceiving(await keyOf(ARM)) && !rtmp.isReceiving(await keyOf(CAM)));
-- });
--
-- await check('each stream serves its own controls to viewers', async () => {
-- assert.deepStrictEqual((await call('GET', `/api/controls/${armStream}`)).json.controls.map((c) => c.label), ['Left', 'Right']);
-- assert.deepStrictEqual((await call('GET', `/api/controls/${camStream}`)).json.controls.map((c) => c.label), ['Pan', 'Zoom']);
-- });
--
-- await check('editing a profile changes the live streams bound to it, and only those', async () => {
-- camStream = (await publish(await keyOf(CAM))).streamId;
-- const r = await call('POST', `/api/controls/configs/${ROBOT}/buttons`, 3, { label: 'Grab', command: 'grab' });
-- assert.strictEqual(r.status, 201);
-- assert.deepStrictEqual(await buttons(armStream), ['Left', 'Right', 'Grab']);
-- assert.deepStrictEqual(await buttons(camStream), ['Pan', 'Zoom']);
-- });
--
-- await check('a stream can be rebound to another of the owner\'s profiles, never to someone else\'s', async () => {
-- const mine = await call('PUT', `/api/controls/${camStream}/config`, 3, { control_config_id: DEFAULT });
-- assert.strictEqual(mine.status, 200);
-- assert.deepStrictEqual(await buttons(camStream), ['Wave']);
-- const theirs = await call('PUT', `/api/controls/${camStream}/config`, 3, { control_config_id: MALLORY });
-- assert.strictEqual(theirs.status, 403);
-- const stranger = await call('PUT', `/api/controls/${armStream}/config`, 7, { control_config_id: MALLORY });
-- assert.strictEqual(stranger.status, 403);
-- assert.deepStrictEqual(await buttons(armStream), ['Left', 'Right', 'Grab']);
-- });
--
-- await check('every other way a slot goes live applies the slot\'s profile first, then the channel default', async () => {
-- const whip = read('server/streaming/whip-handler.js');
-- assert.match(whip, /const configId = managedStream\.control_config_id \|\| \(channel && channel\.active_control_config_id\);\s*if \(configId\) \{\s*try \{ await db\.applyConfigToStream\(configId, streamId\); \}/, 'WHIP');
-- const routes = read('server/streaming/routes.js');
-- assert.match(routes, /const effectiveConfigId = \(requestedControlConfigId != null\)\s*\? requestedControlConfigId\s*: \(managedStream\.control_config_id \|\| null\);/, 'Go Live page: a session choice, else the slot\'s');
-- assert.match(routes, /\} else if \(channel\.active_control_config_id\) \{\s*\/\/ No slot-level config at all: fall back to channel default/);
-- assert.match(routes, /control_config_id: await ownControlConfigId\(req, req\.body\.control_config_id\) \|\| null,/, 'a new slot takes only the owner\'s profile');
-- assert.match(routes, /const cfgId = await ownControlConfigId\(req, req\.body\.control_config_id\);\s*if \(cfgId === undefined\) return res\.status\(403\)/, 'and so does a slot edit');
-- const mirror = read('server/openre/mirror.js');
-- assert.match(mirror, /const configId = slot\.control_config_id \|\| \(channel && channel\.active_control_config_id\);/, 'OpenRestream mirror');
-- });
--
-- server.close();
-- if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }
-- quiet('\nAll per-slot control profile checks passed');
-- process.exit(0);
--})().catch((e) => { quiet(e); process.exit(1); });