From 142e83d512cd3ed31e4452ead1e2b59df47807aa Mon Sep 17 00:00:00 2001 From: Aletheia Date: Sat, 14 Mar 2026 19:40:48 +0100 Subject: [PATCH] Add files via upload --- Dockerfile | 21 + README.md | 521 +++++++++++++ docker-compose.yml | 15 + phone/devices.json | 115 +++ phone/relay_client.py | 692 ++++++++++++++++++ requirements-server.txt | 7 + server/__init__.py | 1 + server/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 165 bytes server/__pycache__/app.cpython-310.pyc | Bin 0 -> 10597 bytes server/__pycache__/auth.cpython-310.pyc | Bin 0 -> 7650 bytes server/__pycache__/config.cpython-310.pyc | Bin 0 -> 1709 bytes server/__pycache__/mcp_tools.cpython-310.pyc | Bin 0 -> 8272 bytes server/__pycache__/models.cpython-310.pyc | Bin 0 -> 4309 bytes server/__pycache__/relay_hub.cpython-310.pyc | Bin 0 -> 2149 bytes server/__pycache__/safety.cpython-310.pyc | Bin 0 -> 3748 bytes .../session_registry.cpython-310.pyc | Bin 0 -> 6810 bytes server/app.py | 494 +++++++++++++ server/auth.py | 226 ++++++ server/config.py | 46 ++ server/mcp_tools.py | 373 ++++++++++ server/models.py | 123 ++++ server/relay_hub.py | 53 ++ server/safety.py | 110 +++ server/session_registry.py | 171 +++++ termux_relay_v3.py | 509 +++++++++++++ 25 files changed, 3477 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 phone/devices.json create mode 100644 phone/relay_client.py create mode 100644 requirements-server.txt create mode 100644 server/__init__.py create mode 100644 server/__pycache__/__init__.cpython-310.pyc create mode 100644 server/__pycache__/app.cpython-310.pyc create mode 100644 server/__pycache__/auth.cpython-310.pyc create mode 100644 server/__pycache__/config.cpython-310.pyc create mode 100644 server/__pycache__/mcp_tools.cpython-310.pyc create mode 100644 server/__pycache__/models.cpython-310.pyc create mode 100644 server/__pycache__/relay_hub.cpython-310.pyc create mode 100644 server/__pycache__/safety.cpython-310.pyc create mode 100644 server/__pycache__/session_registry.cpython-310.pyc create mode 100644 server/app.py create mode 100644 server/auth.py create mode 100644 server/config.py create mode 100644 server/mcp_tools.py create mode 100644 server/models.py create mode 100644 server/relay_hub.py create mode 100644 server/safety.py create mode 100644 server/session_registry.py create mode 100644 termux_relay_v3.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1207da3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install server dependencies +COPY requirements-server.txt . +RUN pip install --no-cache-dir -r requirements-server.txt + +# Copy server code +COPY server/ ./server/ + +# Create data directory for SQLite +RUN mkdir -p /data + +ENV SB_DB_PATH=/data/signal_bridge.db +ENV SB_HOST=0.0.0.0 +ENV SB_PORT=8420 + +EXPOSE 8420 + +CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8420"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9cd99f0 --- /dev/null +++ b/README.md @@ -0,0 +1,521 @@ +# Signal Bridge Remote — Setup Guide + +A remote MCP server that lets Claude control Bluetooth intimate hardware over the internet via Buttplug.io / Intiface Central. + +## Architecture + +``` +Claude (claude.ai or Desktop) + ↓ HTTPS / MCP JSON-RPC +VPS (FastAPI + Docker + Caddy) + ↓ WebSocket (WSS) +Phone or PC (Relay Client) + ↓ WebSocket (local) +Intiface Central + ↓ Bluetooth +Devices +``` + +The relay client bridges between your VPS and Intiface Central running on whatever device is physically near your Bluetooth toys. This can be a Windows PC or an Android phone running Termux. + +## What You'll Need + +- A VPS (this guide uses a DigitalOcean droplet, $6/month) +- A domain or free DuckDNS subdomain (for HTTPS) +- Intiface Central installed on your PC or Android phone +- Bluetooth-compatible intimate hardware +- A claude.ai account or Claude Desktop app + +--- + +## Part 1: VPS Setup + +### 1.1 Create a Droplet + +Sign up at [DigitalOcean](https://www.digitalocean.com/) and create a droplet: + +- **Image**: Ubuntu 22.04 LTS +- **Plan**: Basic, $6/month (1 vCPU, 1GB RAM) is plenty +- **Region**: Choose one close to you for lower latency +- **Authentication**: SSH key (recommended) or password + +Note your droplet's IP address (e.g., `139.59.156.242`). + +### 1.2 Install Docker + +SSH into your droplet and install Docker: + +```bash +ssh root@YOUR_DROPLET_IP +apt update && apt upgrade -y +apt install -y docker.io docker-compose +systemctl enable docker && systemctl start docker +``` + +### 1.3 Deploy Signal Bridge + +On your local machine, create the project directory and prepare files. The project structure is: + +``` +signal-bridge-remote/ +├── Dockerfile +├── docker-compose.yml +├── .env +├── requirements-server.txt +├── server/ +│ ├── __init__.py +│ ├── app.py +│ ├── auth.py +│ ├── config.py +│ ├── models.py +│ ├── mcp_tools.py +│ ├── relay_hub.py +│ ├── safety.py +│ └── session_registry.py +└── phone/ + ├── relay_client.py + └── devices.json +``` + +**Dockerfile:** +```dockerfile +FROM python:3.11-slim +WORKDIR /app +COPY requirements-server.txt . +RUN pip install --no-cache-dir -r requirements-server.txt +COPY server/ ./server/ +RUN mkdir -p /data +ENV SB_DB_PATH=/data/signal_bridge.db +ENV SB_HOST=0.0.0.0 +ENV SB_PORT=8420 +EXPOSE 8420 +CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8420"] +``` + +**docker-compose.yml:** +```yaml +version: "3.8" +services: + signal-bridge: + build: . + ports: + - "8420:8420" + volumes: + - sb_data:/data + env_file: + - .env + restart: unless-stopped +volumes: + sb_data: +``` + +**requirements-server.txt:** +``` +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +websockets>=12.0 +bcrypt>=4.1.0 +PyJWT>=2.8.0 +python-dotenv>=1.0.0 +``` + +**.env:** +```env +# Generate a secret key: +# python -c "import secrets; print(secrets.token_hex(32))" +SB_SECRET_KEY=paste-your-generated-key-here +SB_HOST=0.0.0.0 +SB_PORT=8420 +SB_REGISTRATION_OPEN=true +SB_TOKEN_EXPIRY_HOURS=720 +SB_HEARTBEAT_INTERVAL=2.0 +SB_HEARTBEAT_TIMEOUT=6.0 +SB_BAN_THRESHOLD=20 +SB_BAN_DURATION_MINUTES=30 +``` + +Upload to your VPS: + +```bash +# From your local machine +tar czf signal-bridge.tar.gz signal-bridge-remote/ +scp signal-bridge.tar.gz root@YOUR_DROPLET_IP:/root/ +``` + +Build and start on the VPS: + +```bash +ssh root@YOUR_DROPLET_IP +cd /root +tar xzf signal-bridge.tar.gz +cd signal-bridge-remote +docker-compose up -d --build +``` + +Verify it's running: + +```bash +curl http://localhost:8420/health +# Should return: {"status":"ok","active_phones":0,"banned_ips":0} +``` + +### 1.4 Register a User Account + +```bash +curl -X POST http://localhost:8420/auth/register \ + -H "Content-Type: application/json" \ + -d '{"username": "yourname", "password": "your-secure-password"}' +``` + +Then log in to get your JWT token: + +```bash +curl -X POST http://localhost:8420/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "yourname", "password": "your-secure-password"}' +``` + +**Save the token from the response.** You'll need it for the relay client and Claude Desktop. After your users are set up, you can set `SB_REGISTRATION_OPEN=false` in `.env` and restart the container to lock out new registrations. + +--- + +## Part 2: Domain & HTTPS + +Claude.ai requires HTTPS for custom MCP connectors. We'll use DuckDNS (free) and Caddy (automatic Let's Encrypt certificates). + +### 2.1 Get a DuckDNS Subdomain + +1. Go to [DuckDNS](https://www.duckdns.org/) and sign in +2. Create a subdomain (e.g., `signal-bridge`) pointing to your VPS IP +3. You now have `signal-bridge.duckdns.org` + +### 2.2 Install and Configure Caddy + +On your VPS: + +```bash +apt install -y debian-keyring debian-archive-keyring apt-transport-https curl +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list +apt update +apt install caddy +``` + +Edit the Caddyfile: + +```bash +nano /etc/caddy/Caddyfile +``` + +Replace its contents with: + +``` +signal-bridge.duckdns.org { + reverse_proxy localhost:8420 +} +``` + +(Replace `signal-bridge.duckdns.org` with your actual subdomain.) + +Restart Caddy: + +```bash +systemctl restart caddy +``` + +Caddy automatically provisions an HTTPS certificate from Let's Encrypt. Verify: + +```bash +curl https://signal-bridge.duckdns.org/health +``` + +--- + +## Part 3: Connecting Claude + +### Option A: Claude Desktop (requires token) + +Edit your Claude Desktop config file: + +- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` +- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` + +Add this MCP server config: + +```json +{ + "mcpServers": { + "signal-bridge": { + "url": "https://signal-bridge.duckdns.org/mcp", + "transport": { + "type": "streamableHttp" + }, + "headers": { + "Authorization": "Bearer YOUR_JWT_TOKEN_HERE" + } + } + } +} +``` + +Restart Claude Desktop. You should see Signal Bridge in your available tools. + +### Option B: claude.ai Custom Connector (authless) + +Claude.ai supports custom MCP connectors without authentication, using a fallback mechanism: when only one phone is connected, all MCP requests are routed to that phone automatically. + +1. Go to claude.ai Settings (or click the connector icon in the chat) +2. Choose "Add custom connector" (or "Add MCP server") +3. Enter your server URL: `https://signal-bridge.duckdns.org/mcp` +4. Leave authentication as "None" +5. Save + +**Important**: The authless fallback only works when exactly one phone/relay client is connected to the server. If no phones are connected, claude.ai will show a connection error. Start your relay client first, then connect from claude.ai. + +--- + +## Part 4: Relay Client — Windows PC + +If your toys are near your Windows PC, run the relay client there. + +### 4.1 Install Intiface Central + +Download from [intiface.com](https://intiface.com/central/) and install. Launch it, go to settings, and make sure: + +- App Mode is set to **Engine** +- Server Port is **12345** +- Start the server (click the play button) + +Turn on your Bluetooth devices so Intiface can find them. + +### 4.2 Install Python Dependencies + +```powershell +pip install buttplug websockets +``` + +### 4.3 Run the Relay Client + +```powershell +cd path\to\signal-bridge-remote +python phone/relay_client.py --server wss://signal-bridge.duckdns.org/ws/phone --token "YOUR_JWT_TOKEN_HERE" +``` + +You should see: + +``` +Authenticated with server! +Sent device list: N device(s) +``` + +The relay will stay running, receiving commands from Claude and forwarding them to your devices via Intiface. + +### 4.4 Configure Device Profiles + +Edit `phone/devices.json` to match your actual hardware: + +```json +{ + "devices": { + "ferri": { + "device_id": "ferri", + "name": "Lovense Ferri", + "intensity_floor": 0.0, + "supported_outputs": ["vibrate"] + }, + "enigma": { + "device_id": "enigma", + "name": "Lovense Enigma", + "intensity_floor": 0.4, + "supported_outputs": ["vibrate", "rotate"] + }, + "gravity": { + "device_id": "gravity", + "name": "Lovense Gravity", + "intensity_floor": 0.0, + "supported_outputs": ["vibrate", "oscillate"] + } + } +} +``` + +The `intensity_floor` is important for devices that stutter or click below a certain intensity. Set it to the minimum intensity that produces smooth output (e.g., 0.4 means the device needs at least 40% to work properly). The relay automatically maps the 0-100% range to sit above this floor. + +--- + +## Part 5: Relay Client — Android Phone (Termux) + +This is useful when you want to be mobile and not tethered to a PC. The Termux relay is a lightweight version that speaks the Buttplug protocol directly without the `buttplug` Python library (which can't compile on Android). + +### 5.1 Install Intiface Central on Android + +Install from the Google Play Store or F-Droid. Open it and configure: + +- App Mode: **Engine** +- Server Port: **12345** +- Start the server + +Go to your phone's Settings > Apps > Intiface Central > Permissions and make sure **Bluetooth** and **Location** are both allowed (Android requires location permission for Bluetooth scanning). + +### 5.2 Install Termux + +Install Termux from [F-Droid](https://f-droid.org/en/packages/com.termux/) (not the Play Store version, which is outdated). + +Open Termux and run: + +```bash +pkg update && pkg upgrade +pkg install python +pip install websockets +``` + +The `pkg upgrade` step may take a while and ask you configuration questions — just press Enter to accept defaults. + +### 5.3 Get the Termux Relay Script + +You need `termux_relay.py` on your phone. The easiest way is to serve it temporarily from your VPS: + +From your **computer**: + +```bash +# Upload the file to your VPS +scp termux_relay_v3.py root@YOUR_DROPLET_IP:/tmp/termux_relay.py + +# Start a temporary file server +ssh root@YOUR_DROPLET_IP "cd /tmp && python3 -m http.server 9999 &" +``` + +In **Termux**: + +```bash +mkdir -p ~/signal-bridge && cd ~/signal-bridge +curl -o termux_relay.py http://YOUR_DROPLET_IP:9999/termux_relay.py +``` + +Back on your **computer**, kill the temp server: + +```bash +ssh root@YOUR_DROPLET_IP "pkill -f 'http.server 9999'" +``` + +### 5.4 Run the Termux Relay + +First, find your phone's local IP. In Intiface Central, it's shown as the Server Address (e.g., `ws://192.168.1.203:12345`). Alternatively, toggle on "Listen on all network interfaces" in Intiface settings and use `127.0.0.1` instead (recommended — this way the address never changes). + +```bash +cd ~/signal-bridge +python -u termux_relay.py \ + --token "YOUR_JWT_TOKEN_HERE" \ + --intiface ws://127.0.0.1:12345 +``` + +You should see: + +``` +=== Signal Bridge Termux Relay v3 === +Connecting to Intiface at ws://127.0.0.1:12345 ... +Connected to Intiface Server (protocol v3) +Scanning for devices ... +Device: Lovense Ferri -> 'ferri' (index 0) +Scan complete - 1 device(s) found +Devices ready: ['ferri'] +Connecting to server: wss://signal-bridge.duckdns.org/ws/phone +Authenticated with server! +Sent device list: 1 device(s) +``` + +The Termux relay has built-in device profiles for Lovense Ferri and Enigma. For other devices, it will auto-detect capabilities from Intiface and generate a short name from the device name. + +**Tip**: If your phone's IP changes (because of DHCP), the `--intiface ws://127.0.0.1:12345` approach avoids this problem entirely since Termux and Intiface are on the same device. + +--- + +## Available MCP Tools + +Once connected, Claude has access to these tools: + +| Tool | Description | +|------|-------------| +| `list_devices` | Show connected devices and their capabilities | +| `scan_devices` | Rescan for new or reconnected Bluetooth devices | +| `vibrate` | Send vibration (intensity 0.0–1.0, optional duration in seconds) | +| `rotate` | Rotation or sonic output (device-dependent) | +| `oscillate` | Thrusting/oscillation output | +| `pulse` | Rhythmic on/off pattern | +| `wave` | Smooth sine-wave intensity modulation | +| `escalate` | Gradual ramp from 0 to peak, with optional hold | +| `stop` | Immediately stop all output (also cancels patterns) | +| `read_battery` | Read device battery level | + +All output tools accept `device` (name or "all"), `intensity` (0.0–1.0), and `duration` (seconds, 0 = until stopped). Pattern tools also accept `output_type` to modulate rotation or oscillation instead of vibration. + +--- + +## Safety Features + +Signal Bridge has several safety mechanisms built in: + +- **Dead Man's Switch**: The server pings the relay client every 2 seconds. If 3 pings go unanswered (6 seconds), the server sends an emergency stop to all devices and disconnects the session. Your devices will never be left running if the connection drops. +- **Auto-stop on Duration**: Commands with a `duration` parameter automatically stop after the specified time. +- **Fallback Stop**: If a stop command references a device name that doesn't exist, ALL devices are stopped as a safety fallback. +- **Rate Limiting**: Prevents command flooding (120 commands/minute default). +- **IP Banning**: 20 failed auth attempts triggers a 30-minute ban. +- **User Isolation**: Each user's devices are completely isolated — no one can control another user's hardware. + +--- + +## Troubleshooting + +**"No phone connected" when Claude calls list_devices** +Your relay client isn't running or couldn't authenticate. Start the relay and check the output for "Authenticated with server!" + +**"Temporarily banned" on relay startup** +Too many rapid reconnection attempts. Restart the Docker container to clear in-memory bans: `docker restart signal-bridge_signal-bridge_1` + +**Relay connects but finds 0 devices** +Intiface Central can't see your Bluetooth devices. Make sure devices are turned on and in range. On Android, verify Location and Bluetooth permissions are granted to Intiface. + +**claude.ai stuck on "checking connection"** +The authless fallback requires at least one relay client connected. Start your relay first, then add the connector in claude.ai. + +**Heartbeat timeout / disconnects after a few commands** +Use the Termux v3 relay (`termux_relay_v3.py`), which processes commands in background tasks so heartbeat responses are never blocked. + +**Device always vibrates at maximum regardless of intensity setting** +The server sends the output type in the `action` field, not `output_type`. Make sure your relay reads `cmd.get("action", cmd.get("output_type", "vibrate"))`. + +**Docker not picking up code changes** +Docker caches build layers aggressively. Force a fresh build: +```bash +docker-compose down && docker-compose build --no-cache && docker-compose up -d +``` + +--- + +## Updating the Server + +When you change server code: + +```bash +# On your local machine +tar czf signal-bridge-v2.tar.gz signal-bridge-remote/ +scp signal-bridge-v2.tar.gz root@YOUR_DROPLET_IP:/root/ + +# On your VPS +cd /root +tar xzf signal-bridge-v2.tar.gz +cd signal-bridge-remote +docker-compose down +docker-compose build --no-cache +docker-compose up -d +``` + +User data is stored in a Docker volume (`sb_data`) and persists across rebuilds. + +--- + +## Project Background + +Signal Bridge was originally a local MCP server for Claude Desktop, connecting directly to Intiface Central on the same machine. This remote version adds a relay architecture so that Claude can control devices over the internet, whether through claude.ai, the Claude Android app, or Claude Desktop — from anywhere. + +Built with love, stubbornness, and an unreasonable number of debugging sessions. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..35bb590 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +version: "3.8" + +services: + signal-bridge: + build: . + ports: + - "8420:8420" + volumes: + - sb_data:/data + env_file: + - .env + restart: unless-stopped + +volumes: + sb_data: diff --git a/phone/devices.json b/phone/devices.json new file mode 100644 index 0000000..45b05c6 --- /dev/null +++ b/phone/devices.json @@ -0,0 +1,115 @@ +[ + { + "short_name": "ferri", + "match_strings": ["Ferri"], + "capabilities": { + "vibrate": "external clitoral vibration" + }, + "intensity_floor": 0.0, + "notes": "Small wearable. Intense even at low settings." + }, + { + "short_name": "lush", + "match_strings": ["Lush"], + "capabilities": { + "vibrate": "internal egg vibration" + }, + "intensity_floor": 0.0, + "notes": "Insertable egg. Strong deep vibration." + }, + { + "short_name": "gravity", + "match_strings": ["Gravity"], + "capabilities": { + "vibrate": "shaft vibration", + "oscillate": "thrusting motion" + }, + "intensity_floor": 0.0, + "notes": "Vibration + thrusting. Use 0.05+ intensity for slow strokes." + }, + { + "short_name": "enigma", + "match_strings": ["Enigma"], + "capabilities": { + "vibrate": "G-spot thumping stimulation", + "rotate": "clitoral sonic pulse" + }, + "intensity_floor": 0.4, + "notes": "Dual stimulation. 'rotate' = sonic pulse, NOT rotation. Needs 40%+ to feel." + }, + { + "short_name": "max", + "match_strings": ["Max"], + "capabilities": { + "vibrate": "internal vibration", + "constrict": "air pump compression" + }, + "intensity_floor": 0.0, + "notes": "Vibration + air pump constriction. Constrict controls squeeze pressure." + }, + { + "short_name": "nora", + "match_strings": ["Nora"], + "capabilities": { + "vibrate": "internal vibration", + "rotate": "internal rotation" + }, + "intensity_floor": 0.0, + "notes": "Vibration + actual physical rotation (unlike Enigma)." + }, + { + "short_name": "edge", + "match_strings": ["Edge"], + "capabilities": { + "vibrate": "dual motor vibration" + }, + "intensity_floor": 0.0, + "notes": "Prostate massager. Two independent vibration motors." + }, + { + "short_name": "hush", + "match_strings": ["Hush"], + "capabilities": { + "vibrate": "vibration" + }, + "intensity_floor": 0.0, + "notes": "Vibrating plug. Simple single-motor vibration." + }, + { + "short_name": "domi", + "match_strings": ["Domi"], + "capabilities": { + "vibrate": "powerful wand vibration" + }, + "intensity_floor": 0.0, + "notes": "Mini wand. Very powerful. Start low." + }, + { + "short_name": "osci", + "match_strings": ["Osci"], + "capabilities": { + "oscillate": "oscillating stimulation" + }, + "intensity_floor": 0.0, + "notes": "Oscillating G-spot stimulator. Uses oscillate, not vibrate." + }, + { + "short_name": "dolce", + "match_strings": ["Dolce"], + "capabilities": { + "vibrate": "dual vibration" + }, + "intensity_floor": 0.0, + "notes": "Couples' vibrator. Dual motors." + }, + { + "short_name": "flexer", + "match_strings": ["Flexer"], + "capabilities": { + "vibrate": "vibration", + "oscillate": "come-hither motion" + }, + "intensity_floor": 0.0, + "notes": "Vibration + finger-like come-hither oscillation pattern." + } +] diff --git a/phone/relay_client.py b/phone/relay_client.py new file mode 100644 index 0000000..19560c8 --- /dev/null +++ b/phone/relay_client.py @@ -0,0 +1,692 @@ +#!/usr/bin/env python3 +""" +Signal Bridge Remote — Phone Relay Client + +Runs alongside Intiface Central on the device host (phone or desktop). +Maintains two connections: + 1. Outbound WebSocket to the VPS relay server + 2. Local WebSocket to Intiface Central (Buttplug protocol) + +Receives commands from the server and executes them locally through Intiface. +Includes local dead man's switch: if the server connection drops, +all devices are immediately stopped. + +Usage: + python relay_client.py --server wss://your-server.com/ws/phone --token YOUR_JWT + +For testing (desktop with Intiface running locally): + python relay_client.py --server ws://localhost:8420/ws/phone --token YOUR_JWT +""" +from __future__ import annotations +import argparse +import asyncio +import json +import logging +import math +import os +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +# Buttplug imports — verified against buttplug-py v1.0.0 +try: + from buttplug import ( + ButtplugClient, + ButtplugDevice, + DeviceOutputCommand, + OutputType, + ) +except ImportError: + print("ERROR: buttplug package not installed. Run: pip install buttplug") + sys.exit(1) + +import websockets + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + datefmt="%H:%M:%S", +) +log = logging.getLogger("signal_bridge.phone") + + +# ════════════════════════════════════════════════════════════════════════ +# Device Profile System +# ════════════════════════════════════════════════════════════════════════ + +@dataclass +class DeviceProfile: + short_name: str + match_strings: list[str] + capabilities: dict[str, str] = field(default_factory=dict) + intensity_floor: float = 0.0 + notes: str = "" + + +@dataclass +class ConnectedDevice: + buttplug_id: int + buttplug_device: ButtplugDevice + profile: DeviceProfile + available_outputs: list[str] = field(default_factory=list) + + +def load_profiles(path: str = None) -> list[DeviceProfile]: + """Load device profiles from devices.json.""" + if path is None: + path = str(Path(__file__).parent / "devices.json") + try: + with open(path) as f: + data = json.load(f) + return [DeviceProfile(**d) for d in data] + except FileNotFoundError: + log.warning(f"No devices.json found at {path}, using empty profiles") + return [] + + +# ════════════════════════════════════════════════════════════════════════ +# Device Controller — Local Buttplug Integration +# ════════════════════════════════════════════════════════════════════════ + +# Map string output types to Buttplug OutputType enum. +# Verified against buttplug-py v1.0.0 OutputType members: +# VIBRATE, ROTATE, OSCILLATE, CONSTRICT, SPRAY, +# TEMPERATURE, LED, POSITION, POSITION_WITH_DURATION +OUTPUT_TYPE_MAP: dict[str, OutputType] = {} +for _name, _member in OutputType.__members__.items(): + OUTPUT_TYPE_MAP[_name.lower()] = _member +# Also add a friendly alias +OUTPUT_TYPE_MAP["position_with_duration"] = OutputType.POSITION_WITH_DURATION + + +class DeviceController: + """Manages local Buttplug connection and device control.""" + + def __init__(self, intiface_url: str = "ws://127.0.0.1:12345", profiles: list[DeviceProfile] = None): + self.intiface_url = intiface_url + self.profiles = profiles or [] + self.client: Optional[ButtplugClient] = None + self.devices: dict[str, ConnectedDevice] = {} # short_name → device + self._pattern_tasks: dict[str, asyncio.Task] = {} + self._connected = False + + async def connect(self): + """Connect to local Intiface Central.""" + self.client = ButtplugClient("Signal Bridge Phone") + try: + await self.client.connect(self.intiface_url) + self._connected = True + log.info(f"Connected to Intiface at {self.intiface_url}") + await self.scan() + except Exception as e: + log.error(f"Failed to connect to Intiface: {e}") + raise + + async def disconnect(self): + """Disconnect from Intiface.""" + if self.client: + try: + await self.client.disconnect() + except Exception: + pass + self._connected = False + + async def scan(self): + """Scan for devices and register them.""" + if not self.client: + return + + await self.client.start_scanning() + await asyncio.sleep(3) # give devices time to be found + try: + await self.client.stop_scanning() + except Exception: + pass + + self.devices = {} + for dev in self.client.devices.values(): + profile = self._match_profile(dev.name) + available = self._detect_outputs(dev) + cd = ConnectedDevice( + buttplug_id=dev.index, + buttplug_device=dev, + profile=profile, + available_outputs=available, + ) + self.devices[profile.short_name] = cd + log.info( + f"Found device: {profile.short_name} ({dev.name}) " + f"outputs={available}" + ) + + def _match_profile(self, device_name: str) -> DeviceProfile: + """Match a Buttplug device name to a known profile.""" + for p in self.profiles: + for match_str in p.match_strings: + if match_str.lower() in device_name.lower(): + return p + # Generic profile + short = device_name.split()[0].lower()[:12] + return DeviceProfile( + short_name=short, + match_strings=[device_name], + capabilities={"vibrate": "unknown"}, + notes=f"Auto-detected: {device_name}", + ) + + def _detect_outputs(self, dev: ButtplugDevice) -> list[str]: + """Detect which output types a device supports.""" + outputs = [] + for name, otype in OUTPUT_TYPE_MAP.items(): + try: + if dev.has_output(otype): + outputs.append(name) + except Exception: + pass + return outputs or ["vibrate"] # fallback + + def get_device_list(self) -> list[dict[str, Any]]: + """Get device info for reporting to the server.""" + result = [] + for name, cd in self.devices.items(): + result.append({ + "short_name": cd.profile.short_name, + "device_name": cd.buttplug_device.name, + "capabilities": cd.profile.capabilities, + "available_outputs": cd.available_outputs, + "intensity_floor": cd.profile.intensity_floor, + "notes": cd.profile.notes, + }) + return result + + # ── Command Execution ─────────────────────────────────────────── + + async def execute_command(self, cmd: dict) -> dict: + """Execute a command from the server. Returns ack dict.""" + cmd_type = cmd.get("type") + request_id = cmd.get("request_id") + + try: + if cmd_type == "command": + return await self._handle_output(cmd, request_id) + elif cmd_type == "pattern": + return await self._handle_pattern(cmd, request_id) + elif cmd_type == "stop": + return await self._handle_stop(cmd, request_id) + elif cmd_type == "scan": + await self.scan() + return self._ack(True, "Scan complete", request_id) + elif cmd_type == "read_sensor": + return await self._handle_sensor(cmd, request_id) + else: + return self._ack(False, f"Unknown command type: {cmd_type}", request_id) + except Exception as e: + log.error(f"Command execution error: {e}") + return self._ack(False, str(e), request_id) + + async def _handle_output(self, cmd: dict, request_id: str) -> dict: + """Handle direct output command (vibrate, rotate, etc.).""" + action = cmd.get("action", "vibrate") + device_name = cmd.get("device", "all") + intensity = cmd.get("intensity", 0.5) + duration = cmd.get("duration", 0) + + targets = self._resolve_targets(device_name) + if not targets: + return self._ack(False, f"No device found: {device_name}", request_id) + + otype = OUTPUT_TYPE_MAP.get(action) + if not otype: + return self._ack(False, f"Unsupported output type: {action}", request_id) + + for cd in targets: + adj_intensity = self._apply_floor(intensity, cd.profile.intensity_floor) + try: + await cd.buttplug_device.run_output( + DeviceOutputCommand(otype, adj_intensity) + ) + except Exception as e: + return self._ack(False, f"Device error ({cd.profile.short_name}): {e}", request_id) + + # Auto-stop after duration + if duration > 0: + asyncio.create_task(self._timed_stop(cd, otype, duration)) + + names = ", ".join(cd.profile.short_name for cd in targets) + return self._ack( + True, + f"{action} at {intensity:.0%} on {names}" + + (f" for {duration}s" if duration > 0 else ""), + request_id, + ) + + async def _handle_pattern(self, cmd: dict, request_id: str) -> dict: + """Handle pattern command (pulse, wave, escalate).""" + pattern = cmd.get("pattern") + output_type = cmd.get("output_type", "vibrate") + device_name = cmd.get("device", "all") + intensity = cmd.get("intensity", 0.6) + duration = cmd.get("duration", 10) + + targets = self._resolve_targets(device_name) + if not targets: + return self._ack(False, f"No device found: {device_name}", request_id) + + otype = OUTPUT_TYPE_MAP.get(output_type) + if not otype: + return self._ack(False, f"Unsupported output type: {output_type}", request_id) + + for cd in targets: + task_key = f"{cd.profile.short_name}:{pattern}" + # Cancel existing pattern on this device + if task_key in self._pattern_tasks: + self._pattern_tasks[task_key].cancel() + + if pattern == "pulse": + task = asyncio.create_task( + self._run_pulse(cd, otype, intensity, duration) + ) + elif pattern == "wave": + task = asyncio.create_task( + self._run_wave(cd, otype, intensity, duration) + ) + elif pattern == "escalate": + hold_seconds = cmd.get("hold_seconds", 0) + task = asyncio.create_task( + self._run_escalate(cd, otype, intensity, duration, hold_seconds) + ) + else: + return self._ack(False, f"Unknown pattern: {pattern}", request_id) + + self._pattern_tasks[task_key] = task + + names = ", ".join(cd.profile.short_name for cd in targets) + return self._ack(True, f"{pattern} ({output_type}) on {names} for {duration}s", request_id) + + async def _handle_stop(self, cmd: dict, request_id: str) -> dict: + """Stop all outputs and cancel patterns.""" + device_name = cmd.get("device", "all") + targets = self._resolve_targets(device_name) + + # If a specific device was requested but not found, stop ALL as safety fallback + # but tell Claude what happened so it can correct the device name + fallback_stop = False + if device_name != "all" and not targets: + fallback_stop = True + targets = list(self.devices.values()) + + # Cancel relevant pattern tasks + for key, task in list(self._pattern_tasks.items()): + if device_name == "all" or fallback_stop or any( + cd.profile.short_name in key for cd in targets + ): + task.cancel() + del self._pattern_tasks[key] + + for cd in targets: + try: + await cd.buttplug_device.stop() + except Exception: + pass + + if fallback_stop: + available = ", ".join(self.devices.keys()) or "none" + return self._ack( + True, + f"Device '{device_name}' not found — stopped ALL devices as safety fallback. " + f"Available devices: {available}", + request_id, + ) + + names = ", ".join(cd.profile.short_name for cd in targets) if targets else "all" + return self._ack(True, f"Stopped: {names}", request_id) + + async def _handle_sensor(self, cmd: dict, request_id: str) -> dict: + """Read sensor data from a device.""" + sensor = cmd.get("sensor", "battery") + device_name = cmd.get("device") + + targets = self._resolve_targets(device_name) + if not targets: + return self._ack(False, f"No device found: {device_name}", request_id) + + cd = targets[0] + dev = cd.buttplug_device + try: + if sensor == "battery": + if not dev.has_battery(): + return self._ack(False, f"{cd.profile.short_name} has no battery sensor", request_id) + level = await dev.battery() + return self._ack( + True, + f"{cd.profile.short_name} battery: {level:.0%}", + request_id, + data={"battery": level}, + ) + elif sensor == "rssi": + if not dev.has_rssi(): + return self._ack(False, f"{cd.profile.short_name} has no RSSI sensor", request_id) + rssi = await dev.rssi() + return self._ack( + True, + f"{cd.profile.short_name} RSSI: {rssi}", + request_id, + data={"rssi": rssi}, + ) + else: + return self._ack( + False, + f"Sensor '{sensor}' read not supported in buttplug-py v1.0. " + f"Available: battery, rssi", + request_id, + ) + except Exception as e: + return self._ack(False, f"Sensor read error: {e}", request_id) + + # ── Pattern Runners ───────────────────────────────────────────── + + async def _run_pulse(self, cd: ConnectedDevice, otype, intensity: float, duration: float): + try: + start = time.time() + floor = cd.profile.intensity_floor + adj = self._apply_floor(intensity, floor) + while time.time() - start < duration: + await cd.buttplug_device.run_output(DeviceOutputCommand(otype, adj)) + await asyncio.sleep(0.5) + await cd.buttplug_device.run_output(DeviceOutputCommand(otype, 0)) + await asyncio.sleep(0.3) + except asyncio.CancelledError: + pass + finally: + try: + await cd.buttplug_device.stop() + except Exception: + pass + + async def _run_wave(self, cd: ConnectedDevice, otype, intensity: float, duration: float): + try: + start = time.time() + floor = cd.profile.intensity_floor + while time.time() - start < duration: + elapsed = time.time() - start + # Raw sine: 0.0 to 1.0 + raw = (math.sin(elapsed * 2.0) + 1.0) / 2.0 * intensity + # Map smoothly above the floor: floor..intensity (never drops below floor) + # Only true zero if raw is actually zero (which it never quite is with sine) + if raw <= 0.01: + adj = 0 + elif floor > 0: + adj = floor + raw * (1.0 - floor) + adj = min(1.0, adj) + else: + adj = min(1.0, raw) + await cd.buttplug_device.run_output(DeviceOutputCommand(otype, adj)) + await asyncio.sleep(0.1) + except asyncio.CancelledError: + pass + finally: + try: + await cd.buttplug_device.stop() + except Exception: + pass + + async def _run_escalate(self, cd: ConnectedDevice, otype, peak: float, duration: float, hold_seconds: float = 0): + try: + steps = 20 + floor = cd.profile.intensity_floor + for i in range(steps + 1): + val = (i / steps) * peak + if val <= 0.01: + adj = 0 + elif floor > 0: + adj = floor + val * (1.0 - floor) + adj = min(1.0, adj) + else: + adj = self._apply_floor(val, floor) + await cd.buttplug_device.run_output(DeviceOutputCommand(otype, adj)) + await asyncio.sleep(duration / steps) + # At peak now. hold_seconds: 0 = hold indefinitely, >0 = hold then stop + if hold_seconds > 0: + await asyncio.sleep(hold_seconds) + await cd.buttplug_device.stop() + # else: stay at peak until explicit stop command + except asyncio.CancelledError: + try: + await cd.buttplug_device.stop() + except Exception: + pass + + # ── Helpers ────────────────────────────────────────────────────── + + async def _timed_stop(self, cd: ConnectedDevice, otype, duration: float): + await asyncio.sleep(duration) + try: + await cd.buttplug_device.run_output(DeviceOutputCommand(otype, 0)) + except Exception: + pass + + def _resolve_targets(self, device_name: str) -> list[ConnectedDevice]: + if device_name == "all": + return list(self.devices.values()) + cd = self.devices.get(device_name) + return [cd] if cd else [] + + @staticmethod + def _apply_floor(intensity: float, floor: float) -> float: + if intensity <= 0: + return 0 + if floor <= 0: + return min(1.0, intensity) + return max(floor, min(1.0, intensity)) + + def _ack(self, success: bool, message: str, request_id: str = None, data: dict = None) -> dict: + result = { + "type": "command_ack", + "success": success, + "message": message, + } + if request_id: + result["request_id"] = request_id + if data: + result["data"] = data + return result + + async def emergency_stop(self): + """Stop ALL devices immediately. Called when server connection drops.""" + log.critical("LOCAL EMERGENCY STOP — all devices halted") + for cd in self.devices.values(): + try: + await cd.buttplug_device.stop() + except Exception: + pass + # Cancel all patterns + for task in self._pattern_tasks.values(): + task.cancel() + self._pattern_tasks.clear() + + +# ════════════════════════════════════════════════════════════════════════ +# Relay Agent — Bridges server ↔ Intiface +# ════════════════════════════════════════════════════════════════════════ + +class RelayAgent: + """ + Main relay loop. Connects to both the VPS server and local Intiface, + and bridges commands between them. + """ + + def __init__( + self, + server_url: str, + token: str, + intiface_url: str = "ws://127.0.0.1:12345", + devices_json: str = None, + ): + self.server_url = server_url + self.token = token + self.intiface_url = intiface_url + self.controller = DeviceController( + intiface_url=intiface_url, + profiles=load_profiles(devices_json), + ) + self._running = False + + async def run(self): + """Main loop with auto-reconnection.""" + self._running = True + + # Connect to local Intiface first + log.info(f"Connecting to Intiface at {self.intiface_url}...") + await self.controller.connect() + log.info(f"Found {len(self.controller.devices)} device(s)") + + while self._running: + try: + await self._connect_and_relay() + except Exception as e: + log.error(f"Server connection error: {e}") + await self.controller.emergency_stop() + + if self._running: + log.info("Reconnecting to server in 5 seconds...") + await asyncio.sleep(5) + + async def _connect_and_relay(self): + """Single connection lifecycle.""" + log.info(f"Connecting to server at {self.server_url}...") + + async with websockets.connect(self.server_url) as ws: + # Authenticate + await ws.send(json.dumps({ + "type": "phone_auth", + "token": self.token, + })) + + auth_response = json.loads(await ws.recv()) + if auth_response.get("type") != "auth_ok": + log.error(f"Auth failed: {auth_response}") + return + + log.info("Authenticated with server!") + + # Send current device list immediately — no need to wait for server scan + # (we already scanned during controller.connect()) + if self.controller.devices: + device_list = self.controller.get_device_list() + await ws.send(json.dumps({ + "type": "device_list", + "devices": device_list, + })) + log.info(f"Sent device list to server: {len(device_list)} device(s)") + else: + log.warning("No devices to report — was the initial scan empty?") + + # Message loop + async for raw in ws: + try: + msg = json.loads(raw) + await self._handle_server_message(ws, msg) + except json.JSONDecodeError: + log.warning("Invalid JSON from server") + except Exception as e: + log.error(f"Error handling server message: {e}") + + # If we get here, the connection closed + log.warning("Server connection closed") + await self.controller.emergency_stop() + + async def _handle_server_message(self, ws, msg: dict): + """Handle incoming message from the server.""" + msg_type = msg.get("type") + + if msg_type == "heartbeat_ping": + # Respond immediately + await ws.send(json.dumps({ + "type": "heartbeat_pong", + "timestamp": msg.get("timestamp", time.time()), + })) + + elif msg_type in ("command", "pattern", "stop", "read_sensor", "scan"): + # Execute locally and send ack + ack = await self.controller.execute_command(msg) + await ws.send(json.dumps(ack)) + + # After a scan, also send the updated device list + if msg_type == "scan": + device_list = self.controller.get_device_list() + await ws.send(json.dumps({ + "type": "device_list", + "devices": device_list, + })) + log.info(f"Scan complete — sent device list: {len(device_list)} device(s)") + + else: + log.debug(f"Unknown server message type: {msg_type}") + + async def stop(self): + """Graceful shutdown.""" + self._running = False + await self.controller.emergency_stop() + await self.controller.disconnect() + + +# ════════════════════════════════════════════════════════════════════════ +# CLI Entry Point +# ════════════════════════════════════════════════════════════════════════ + +def main(): + parser = argparse.ArgumentParser( + description="Signal Bridge Phone Relay Client", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Connect to your VPS: + python relay_client.py --server wss://signal-bridge.example.com/ws/phone --token eyJ... + + # Local testing (server on same machine): + python relay_client.py --server ws://localhost:8420/ws/phone --token eyJ... + + # Custom Intiface port: + python relay_client.py --server wss://example.com/ws/phone --token eyJ... --intiface ws://127.0.0.1:54321 + """, + ) + parser.add_argument( + "--server", required=True, + help="WebSocket URL of the Signal Bridge server (e.g. wss://example.com/ws/phone)", + ) + parser.add_argument( + "--token", default=os.environ.get("SB_TOKEN"), + help="Your JWT auth token (get from /auth/login). " + "Can also be set via SB_TOKEN env var.", + ) + parser.add_argument( + "--intiface", default="ws://127.0.0.1:12345", + help="Local Intiface Central WebSocket URL (default: ws://127.0.0.1:12345)", + ) + parser.add_argument( + "--devices", default=None, + help="Path to devices.json (default: ./devices.json)", + ) + args = parser.parse_args() + + if not args.token: + parser.error("Token required: use --token or set SB_TOKEN environment variable") + + agent = RelayAgent( + server_url=args.server, + token=args.token, + intiface_url=args.intiface, + devices_json=args.devices, + ) + + try: + asyncio.run(agent.run()) + except KeyboardInterrupt: + log.info("Shutting down...") + asyncio.run(agent.stop()) + + +if __name__ == "__main__": + main() diff --git a/requirements-server.txt b/requirements-server.txt new file mode 100644 index 0000000..87b71b0 --- /dev/null +++ b/requirements-server.txt @@ -0,0 +1,7 @@ +# Signal Bridge Remote — Server Dependencies +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +websockets>=12.0 +bcrypt>=4.1.0 +PyJWT>=2.8.0 +python-dotenv>=1.0.0 diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..9159e24 --- /dev/null +++ b/server/__init__.py @@ -0,0 +1 @@ +# Signal Bridge Remote — Server Package diff --git a/server/__pycache__/__init__.cpython-310.pyc b/server/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b6b6fd57e988dd0264829cfa0c310f3ebe4b5c0e GIT binary patch literal 165 zcmd1j<>g`k0{clTGqr&9V-N=!FakLaKwQiMBvKfH88jLFRx%WUgb~Cq5B=iQ;^NHw zykh<0oYd5UO5OCL)Wj0q{R{Syb}H3%=Em(9H*kpl=M{nqSV~{l2o9kqO#N? i{rLFIyv&mLc)fzkTO2mI`6;D2sdgY6ikW}}3j+YlfheE= literal 0 HcmV?d00001 diff --git a/server/__pycache__/app.cpython-310.pyc b/server/__pycache__/app.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bda77dbafb41510a9508f477fcebe312f645bd02 GIT binary patch literal 10597 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 D4EL*Q literal 0 HcmV?d00001 diff --git a/server/__pycache__/auth.cpython-310.pyc b/server/__pycache__/auth.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2a1ae18564bc040e6e5e679bc2e8c494b0ed8b72 GIT binary patch literal 7650 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 XkxHd5q|T;BQzPT!#{769b>;s7wC^Z8 literal 0 HcmV?d00001 diff --git a/server/__pycache__/config.cpython-310.pyc b/server/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f95b1a682ad98c14570b13f14cdf4c62500a77d5 GIT binary patch literal 1709 zcmZuy-BQ~|6xK@qi?M?N0|^kar9Xo+!FC{JGHoYQ+o<3P{uxQMiElK*yVxqST1^RiFe7>q&AXsUQd_lhaUFkd^s;Lyd8vO;;N}F!acRXBWF6mKh&_Tc{{`=QI zuu0h&Ww;jjCvI=V9PS2wI$iNR98%6*zZYVMQS1c{p#-0>V1TKA=CZ&aP@m&7hq=zN zM??JD<$ajrhb}-&=!rA(cvwu=eLQ3V8Fiz`kE5vAtlFktGjz*-p})dBzz$}PPayQ} z)2(jbVL0RrmM*6Mg}p1y0ur&d!~5`_i8f*r*u8%yk`U;iWm@B8xhQX`X}1hZ#GXCf zE{p7~m4qT@K~ZZNrrk2?`}L+N(4%+gu~-DP)q0^fZT)4tZoGo1M}{f1C%ex?9&Cob zUpFnIV%1wsyVcg4BF5Q>iuuS^v2^>e-l$u4<;Xf1r@k%?Tz|x=Sh{VkwHl2|bI%;7 zpKO<}d}Q?N2>XYvYUOa8-YI|Z$U&4w<)!_TX}5L5uD1o+5s|W5Y1-C-p_>P-!#$xw zy314T-qCccje7IQ(oLc6lx2(=bwOuf! zA?9AyZdV|+Tqs#&A4?V$$#FOOJU1^DKnLB-v&$$CzXcw;KHr?eA`ecfZ};ghn>*Wu!bY>8Lb4xdDKf^$=k9>& zj0Nni!qy=pIlTL!6w)vRHixCqqjY$_)nnA*TV2m#r?fQid1=yalWr?9*=;Z>>a@}% z+v4zCq|O}AC9wO=sAAEv7gJTFqS*V`dxilP))k-z5T6<=*rEL>}+Oa-L|Cx%Gb_KE9Js8(VKhZfWz zIi;>F=k+I?NDrgZq`n)#;pEZh@HcR3d{iY64}n9Lk`1l+*Gk69XN}O>WSy HXc_H4g9-Ma literal 0 HcmV?d00001 diff --git a/server/__pycache__/mcp_tools.cpython-310.pyc b/server/__pycache__/mcp_tools.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cf09dddf9ab053e59780dc3c60f3e70838834039 GIT binary patch literal 8272 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 literal 0 HcmV?d00001 diff --git a/server/__pycache__/relay_hub.cpython-310.pyc b/server/__pycache__/relay_hub.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..70322ad9fcf8218950217b866e0065ce3509d3b6 GIT binary patch literal 2149 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 OU(Ku;zFD{YKl~q1CgWlN literal 0 HcmV?d00001 diff --git a/server/__pycache__/session_registry.cpython-310.pyc b/server/__pycache__/session_registry.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cf9cb0dd87434cd624b975423f3439beb3f5081d GIT binary patch literal 6810 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 literal 0 HcmV?d00001 diff --git a/server/app.py b/server/app.py new file mode 100644 index 0000000..f84990b --- /dev/null +++ b/server/app.py @@ -0,0 +1,494 @@ +""" +Signal Bridge Remote — Main Server Application + +Single FastAPI app that serves three roles: + 1. OAuth-style auth (register, login, token refresh) + 2. MCP endpoint (Streamable HTTP — tool calls from Claude) + 3. WebSocket relay hub (persistent phone connections) + +Plus rate limiting, IP banning, and the dead man's switch. +""" +from __future__ import annotations +import asyncio +import json +import logging +import os +import sys +import uuid +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from . import config +from .auth import ( + init_db, create_user, verify_user, create_token, verify_token, + extract_token, ip_tracker, rate_limiter, +) +from .mcp_tools import TOOLS, HANDLERS, current_user_id +from .relay_hub import check_ws_ip_limit, release_ws_ip_slot, get_ip_from_headers +from .session_registry import registry +from .safety import dead_man_switch + +# ── Logging ───────────────────────────────────────────────────────────── + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + datefmt="%H:%M:%S", +) +log = logging.getLogger("signal_bridge") + + +# ── Lifespan ──────────────────────────────────────────────────────────── + +@asynccontextmanager +async def lifespan(app: FastAPI): + config.validate() + init_db() + await dead_man_switch.start() + log.info(f"Signal Bridge Remote started on {config.HOST}:{config.PORT}") + log.info(f"Registration {'OPEN' if config.REGISTRATION_OPEN else 'CLOSED'}") + yield + await dead_man_switch.stop() + log.info("Signal Bridge Remote shutting down") + + +app = FastAPI( + title="Signal Bridge Remote", + version="1.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=config.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ════════════════════════════════════════════════════════════════════════ +# Auth helpers +# ════════════════════════════════════════════════════════════════════════ + +def _get_ip(request: Request) -> str: + forwarded = request.headers.get("X-Forwarded-For", "") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +async def _require_auth(request: Request) -> dict | None: + """Validate Bearer token. Returns user dict or None.""" + token = extract_token(request.headers.get("Authorization", "")) + if not token: + return None + return verify_token(token) + + +# ════════════════════════════════════════════════════════════════════════ +# Auth Endpoints +# ════════════════════════════════════════════════════════════════════════ + +@app.post("/auth/register") +async def register(request: Request): + """Register a new user account.""" + ip = _get_ip(request) + + if await ip_tracker.is_banned(ip): + return JSONResponse({"error": "Temporarily banned"}, status_code=429) + + if not await rate_limiter.check(f"auth:{ip}", config.RATE_LIMIT_AUTH): + return JSONResponse({"error": "Too many attempts"}, status_code=429) + + if not config.REGISTRATION_OPEN: + return JSONResponse({"error": "Registration is closed"}, status_code=403) + + body = await request.json() + username = body.get("username", "").strip() + password = body.get("password", "") + + try: + user = await asyncio.to_thread(create_user, username, password) + except ValueError as e: + # Don't count validation errors (short username, weak password) toward IP ban. + # Only actual auth failures (wrong credentials) should inflate the ban counter. + return JSONResponse({"error": str(e)}, status_code=400) + + token = create_token(user["user_id"], user["username"]) + await ip_tracker.clear_failures(ip) + + return {"user_id": user["user_id"], "username": user["username"], "token": token} + + +@app.post("/auth/login") +async def login(request: Request): + """Authenticate and receive a JWT.""" + ip = _get_ip(request) + + if await ip_tracker.is_banned(ip): + return JSONResponse({"error": "Temporarily banned"}, status_code=429) + + if not await rate_limiter.check(f"auth:{ip}", config.RATE_LIMIT_AUTH): + return JSONResponse({"error": "Too many attempts"}, status_code=429) + + body = await request.json() + username = body.get("username", "") + password = body.get("password", "") + + user = await asyncio.to_thread(verify_user, username, password) + if not user: + await ip_tracker.record_failure(ip) + return JSONResponse({"error": "Invalid credentials"}, status_code=401) + + token = create_token(user["user_id"], user["username"]) + await ip_tracker.clear_failures(ip) + + return {"user_id": user["user_id"], "username": user["username"], "token": token} + + +# ════════════════════════════════════════════════════════════════════════ +# MCP Endpoint — Streamable HTTP (JSON-RPC over POST + GET) +# +# Implements the MCP Streamable HTTP transport spec: +# - POST: JSON-RPC requests from client +# - GET: SSE stream for server-to-client notifications (kept open) +# - Mcp-Session-Id header for session tracking +# - Authless mode for claude.ai connector, Bearer token for Claude Desktop +# ════════════════════════════════════════════════════════════════════════ + +# In-memory MCP session tracking (maps session_id → user_id) +_mcp_sessions: dict[str, str] = {} + + +async def _resolve_mcp_user(request: Request) -> dict | None: + """ + Resolve the user for an MCP request. + Priority: Bearer token > Mcp-Session-Id lookup > sole active phone session. + """ + # 1. Try Bearer token auth (Claude Desktop) + user = await _require_auth(request) + if user: + return user + + # 2. Try Mcp-Session-Id (subsequent requests from claude.ai) + session_id = request.headers.get("mcp-session-id", "") + if session_id and session_id in _mcp_sessions: + return {"user_id": _mcp_sessions[session_id]} + + # 3. Fall back to sole active phone session (authless / claude.ai init) + fallback_user_id = await registry.get_sole_user_id() + if fallback_user_id: + log.info(f"MCP request without auth — using active session: {fallback_user_id}") + return {"user_id": fallback_user_id} + + return None + + +@app.post("/mcp") +async def mcp_endpoint(request: Request): + """ + MCP Streamable HTTP endpoint (POST). + + Accepts JSON-RPC requests, routes tool calls to the authenticated + user's phone via the session registry. + """ + ip = _get_ip(request) + + if await ip_tracker.is_banned(ip): + return JSONResponse({"error": "Temporarily banned"}, status_code=429) + + if not await rate_limiter.check(f"global:{ip}", config.RATE_LIMIT_GLOBAL): + return JSONResponse({"error": "Rate limit exceeded"}, status_code=429) + + # Parse JSON-RPC first (we need to check if it's an initialize request) + try: + body = await request.json() + except Exception: + return _jsonrpc_error(None, -32700, "Parse error: invalid JSON") + + method = body.get("method", "") + params = body.get("params", {}) + req_id = body.get("id") + + # Resolve user + user = await _resolve_mcp_user(request) + if not user: + return JSONResponse( + {"jsonrpc": "2.0", "error": {"code": -32000, "message": "No auth token and no active phone session"}}, + status_code=401, + ) + + # Rate limit per user for commands + if not await rate_limiter.check( + f"cmd:{user['user_id']}", config.RATE_LIMIT_COMMANDS + ): + return JSONResponse( + {"jsonrpc": "2.0", "error": {"code": -32000, "message": "Command rate limit exceeded"}}, + status_code=429, + ) + + # Set user context for tool handlers + current_user_id.set(user["user_id"]) + + # ── Route by method ───────────────────────────────────────────── + + if method == "initialize": + # Generate a session ID and bind it to this user + session_id = str(uuid.uuid4()) + _mcp_sessions[session_id] = user["user_id"] + log.info(f"MCP session created: {session_id[:8]}... for user {user['user_id']}") + + result = { + "protocolVersion": "2025-03-26", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "Signal Bridge Remote", "version": "1.0.0"}, + } + response = JSONResponse({"jsonrpc": "2.0", "id": req_id, "result": result}) + response.headers["Mcp-Session-Id"] = session_id + return response + + elif method == "tools/list": + return _jsonrpc_result(req_id, {"tools": TOOLS}) + + elif method == "tools/call": + tool_name = params.get("name", "") + tool_args = params.get("arguments", {}) + + handler = HANDLERS.get(tool_name) + if not handler: + return _jsonrpc_error(req_id, -32601, f"Unknown tool: {tool_name}") + + try: + result_text = await handler(**tool_args) + return _jsonrpc_result(req_id, { + "content": [{"type": "text", "text": result_text}], + }) + except Exception as e: + log.error(f"Tool {tool_name} error: {e}") + return _jsonrpc_result(req_id, { + "content": [{"type": "text", "text": f"Error: {e}"}], + "isError": True, + }) + + elif method == "ping": + return _jsonrpc_result(req_id, {}) + + elif method == "resources/list": + return _jsonrpc_result(req_id, {"resources": []}) + + elif method == "prompts/list": + return _jsonrpc_result(req_id, {"prompts": []}) + + elif method.startswith("notifications/"): + # MCP notifications (e.g. notifications/initialized) are fire-and-forget. + # Return empty success — no error, no noise. + return _jsonrpc_result(req_id, {}) + + else: + return _jsonrpc_error(req_id, -32601, f"Unknown method: {method}") + + +@app.get("/mcp") +async def mcp_sse_endpoint(request: Request): + """ + MCP Streamable HTTP endpoint (GET). + + Opens an SSE stream for server-to-client notifications. + We don't currently use server-initiated notifications, + so this just stays open to satisfy the spec. + """ + from starlette.responses import StreamingResponse + + async def event_stream(): + # Send a keep-alive comment, then hold the connection open + yield ": connected\n\n" + try: + while True: + await asyncio.sleep(30) + yield ": keepalive\n\n" + except asyncio.CancelledError: + pass + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) + + +def _jsonrpc_result(req_id, result): + return JSONResponse({"jsonrpc": "2.0", "id": req_id, "result": result}) + + +def _jsonrpc_error(req_id, code, message): + return JSONResponse( + {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}} + ) + + +# ════════════════════════════════════════════════════════════════════════ +# WebSocket Relay — Phone connections +# ════════════════════════════════════════════════════════════════════════ + +@app.websocket("/ws/phone") +async def websocket_phone(websocket: WebSocket): + """ + WebSocket endpoint for phone relay clients. + The phone connects here, authenticates with its JWT, + and maintains a persistent connection for receiving device commands. + """ + await websocket.accept() + await _handle_phone_ws(websocket) + + +async def _handle_phone_ws(ws: WebSocket): + """ + Full phone WebSocket lifecycle: auth → register → message loop → cleanup. + """ + from .models import CommandAck + + ip = get_ip_from_headers( + ws.client.host if ws.client else None, + dict(ws.headers) if ws.headers else None, + ) + + # IP-level rate limiting + rejection = await check_ws_ip_limit(ip) + if rejection: + await ws.close(4003, rejection) + return + + user_id = None + try: + # Wait for auth message + raw = await asyncio.wait_for(ws.receive_text(), timeout=10.0) + msg = json.loads(raw) + + if msg.get("type") != "phone_auth" or "token" not in msg: + await ws.close(4001, "First message must be phone_auth") + await ip_tracker.record_failure(ip) + return + + user = verify_token(msg["token"]) + if not user: + await ws.close(4001, "Invalid token") + await ip_tracker.record_failure(ip) + return + + user_id = user["user_id"] + await ip_tracker.clear_failures(ip) + await ws.send_json({ + "type": "auth_ok", + "user_id": user_id, + "message": "Connected to Signal Bridge relay", + }) + log.info(f"Phone connected: user={user['username']} ip={ip}") + + # Create a wrapper that looks like a websockets ServerConnection + wrapper = _FastAPIWSWrapper(ws) + session = await registry.register(user_id, wrapper) + + # Request device list (phone also sends proactively, but this is a backup) + log.info(f"Requesting device scan from phone: user={user_id}") + await ws.send_json({"type": "scan"}) + + # Message loop + while True: + try: + raw = await ws.receive_text() + msg = json.loads(raw) + msg_type = msg.get("type") + + if msg_type == "heartbeat_pong": + await registry.update_heartbeat(user_id) + elif msg_type == "command_ack": + ack = CommandAck( + success=msg.get("success", True), + message=msg.get("message", ""), + request_id=msg.get("request_id"), + data=msg.get("data"), + ) + if ack.request_id: + session.resolve_ack(ack.request_id, ack) + elif msg_type == "device_list": + await registry.update_devices(user_id, msg.get("devices", [])) + log.info(f"Devices updated: user={user_id}, count={len(msg.get('devices', []))}") + + except WebSocketDisconnect: + break + except json.JSONDecodeError: + continue + + except asyncio.TimeoutError: + await ws.close(4001, "Auth timeout") + except WebSocketDisconnect: + pass + except Exception as e: + log.error(f"Phone WS error: {e}") + finally: + if user_id: + await registry.unregister(user_id) + log.info(f"Phone disconnected: user={user_id}") + await release_ws_ip_slot(ip) + + +class _FastAPIWSWrapper: + """ + Minimal wrapper to make a FastAPI WebSocket look enough like a + websockets ServerConnection for the session registry and safety module. + """ + def __init__(self, ws: WebSocket): + self._ws = ws + + async def send(self, data: str): + await self._ws.send_text(data) + + async def close(self, code: int = 1000, reason: str = ""): + await self._ws.close(code, reason) + + @property + def transport(self): + return self # duck typing for _get_ip fallback + + def get_extra_info(self, key): + if key == "peername" and self._ws.client: + return (self._ws.client.host, self._ws.client.port) + return None + + +# ════════════════════════════════════════════════════════════════════════ +# Health & Status +# ════════════════════════════════════════════════════════════════════════ + +@app.get("/health") +async def health(): + return { + "status": "ok", + "active_phones": registry.active_count, + "banned_ips": ip_tracker.banned_count, + } + + +# ════════════════════════════════════════════════════════════════════════ +# Init module +# ════════════════════════════════════════════════════════════════════════ + +@app.get("/") +async def root(): + return { + "service": "Signal Bridge Remote", + "version": "1.0.0", + "endpoints": { + "auth": "/auth/register, /auth/login", + "mcp": "/mcp (POST, JSON-RPC)", + "phone_relay": "/ws/phone (WebSocket)", + "health": "/health", + }, + } diff --git a/server/auth.py b/server/auth.py new file mode 100644 index 0000000..1980943 --- /dev/null +++ b/server/auth.py @@ -0,0 +1,226 @@ +""" +Signal Bridge Remote — Authentication & Rate Limiting + +JWT-based auth with bcrypt password hashing, SQLite user store, +progressive IP banning, and per-endpoint rate limiting. +""" +from __future__ import annotations +import asyncio +import sqlite3 +import time +import uuid +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from typing import Optional + +import bcrypt +import jwt + +from . import config + + +# ════════════════════════════════════════════════════════════════════════ +# Database +# ════════════════════════════════════════════════════════════════════════ + +def init_db(): + """Create user table if it doesn't exist.""" + conn = sqlite3.connect(config.DB_PATH) + conn.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + is_active INTEGER DEFAULT 1 + ) + """) + conn.commit() + conn.close() + + +def _get_conn(): + conn = sqlite3.connect(config.DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +# ════════════════════════════════════════════════════════════════════════ +# User Management +# ════════════════════════════════════════════════════════════════════════ + +def create_user(username: str, password: str) -> dict: + """Register a new user. Returns user dict or raises ValueError.""" + if len(username) < 3 or len(username) > 32: + raise ValueError("Username must be 3-32 characters") + if len(password) < 8: + raise ValueError("Password must be at least 8 characters") + + user_id = str(uuid.uuid4()) + password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + conn = _get_conn() + try: + conn.execute( + "INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)", + (user_id, username.lower().strip(), password_hash, + datetime.now(timezone.utc).isoformat()), + ) + conn.commit() + except sqlite3.IntegrityError: + raise ValueError("Username already taken") + finally: + conn.close() + + return {"user_id": user_id, "username": username.lower().strip()} + + +def verify_user(username: str, password: str) -> Optional[dict]: + """Check credentials. Returns user dict or None.""" + conn = _get_conn() + row = conn.execute( + "SELECT * FROM users WHERE username = ? AND is_active = 1", + (username.lower().strip(),), + ).fetchone() + conn.close() + + if not row: + return None + if not bcrypt.checkpw(password.encode(), row["password_hash"].encode()): + return None + + return {"user_id": row["id"], "username": row["username"]} + + +# ════════════════════════════════════════════════════════════════════════ +# JWT Tokens +# ════════════════════════════════════════════════════════════════════════ + +def create_token(user_id: str, username: str) -> str: + """Issue a JWT token.""" + payload = { + "sub": user_id, + "username": username, + "iat": datetime.now(timezone.utc), + "exp": datetime.now(timezone.utc) + timedelta(hours=config.TOKEN_EXPIRY_HOURS), + } + return jwt.encode(payload, config.SECRET_KEY, algorithm="HS256") + + +def verify_token(token: str) -> Optional[dict]: + """Validate a JWT. Returns payload dict or None.""" + try: + payload = jwt.decode(token, config.SECRET_KEY, algorithms=["HS256"]) + return {"user_id": payload["sub"], "username": payload["username"]} + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return None + + +def extract_token(authorization: str) -> Optional[str]: + """Pull the token from an Authorization header value.""" + if not authorization: + return None + parts = authorization.split() + if len(parts) == 2 and parts[0].lower() == "bearer": + return parts[1] + return None + + +# ════════════════════════════════════════════════════════════════════════ +# IP Ban Tracker +# ════════════════════════════════════════════════════════════════════════ + +class IPBanTracker: + """ + Tracks failed auth attempts per IP and issues temporary bans + after threshold is exceeded. Designed to frustrate targeted + harassment without affecting legitimate users. + """ + + def __init__(self): + self._failures: dict[str, list[float]] = defaultdict(list) + self._bans: dict[str, float] = {} # ip → ban_expires_at timestamp + self._lock = asyncio.Lock() + + async def record_failure(self, ip: str): + """Record a failed auth attempt. May trigger a ban.""" + async with self._lock: + now = time.time() + window = now - 3600 # 1-hour sliding window + self._failures[ip] = [t for t in self._failures[ip] if t > window] + self._failures[ip].append(now) + + if len(self._failures[ip]) >= config.BAN_THRESHOLD: + self._bans[ip] = now + (config.BAN_DURATION_MINUTES * 60) + self._failures[ip] = [] + + async def is_banned(self, ip: str) -> bool: + """Check if an IP is currently banned.""" + async with self._lock: + if ip not in self._bans: + return False + if time.time() > self._bans[ip]: + del self._bans[ip] + return False + return True + + async def clear_failures(self, ip: str): + """Reset failure counter on successful auth.""" + async with self._lock: + self._failures.pop(ip, None) + + @property + def banned_count(self) -> int: + """Number of currently banned IPs.""" + now = time.time() + return sum(1 for exp in self._bans.values() if exp > now) + + +# Singleton +ip_tracker = IPBanTracker() + + +# ════════════════════════════════════════════════════════════════════════ +# Rate Limiter (simple token bucket per key) +# ════════════════════════════════════════════════════════════════════════ + +class RateLimiter: + """ + Simple in-memory rate limiter using sliding window counters. + Parse rate strings like "5/minute", "100/hour". + """ + + PERIODS = { + "second": 1, "minute": 60, "hour": 3600, "day": 86400, + } + + def __init__(self): + self._windows: dict[str, list[float]] = defaultdict(list) + self._lock = asyncio.Lock() + + @staticmethod + def _parse_rate(rate_str: str) -> tuple[int, int]: + """Parse '5/minute' → (5, 60).""" + count_str, period_str = rate_str.split("/") + return int(count_str), RateLimiter.PERIODS[period_str] + + async def check(self, key: str, rate_str: str) -> bool: + """ + Check if request is allowed. Returns True if allowed. + Automatically records the attempt if allowed. + """ + max_count, period = self._parse_rate(rate_str) + async with self._lock: + now = time.time() + window_start = now - period + self._windows[key] = [t for t in self._windows[key] if t > window_start] + + if len(self._windows[key]) >= max_count: + return False + + self._windows[key].append(now) + return True + + +# Singleton +rate_limiter = RateLimiter() diff --git a/server/config.py b/server/config.py new file mode 100644 index 0000000..1f96497 --- /dev/null +++ b/server/config.py @@ -0,0 +1,46 @@ +""" +Signal Bridge Remote — Server Configuration + +All settings are loaded from environment variables with sensible defaults. +In production, set SB_SECRET_KEY to a random 64-char string. +""" +import os +from pathlib import Path + +from dotenv import load_dotenv +load_dotenv() # Load .env file before reading any env vars + +# ── Server ────────────────────────────────────────────────────────────── +HOST = os.getenv("SB_HOST", "0.0.0.0") +PORT = int(os.getenv("SB_PORT", "8420")) +SECRET_KEY = os.getenv("SB_SECRET_KEY", "") # MUST be set in production +CORS_ORIGINS = os.getenv("SB_CORS_ORIGINS", "*").split(",") + +# ── Auth ──────────────────────────────────────────────────────────────── +TOKEN_EXPIRY_HOURS = int(os.getenv("SB_TOKEN_EXPIRY_HOURS", "168")) # 1 week +REGISTRATION_OPEN = os.getenv("SB_REGISTRATION_OPEN", "true").lower() == "true" + +# ── Rate Limiting ─────────────────────────────────────────────────────── +# Format: "count/period" — e.g. "5/minute", "100/hour" +RATE_LIMIT_AUTH = os.getenv("SB_RATE_LIMIT_AUTH", "5/minute") +RATE_LIMIT_COMMANDS = os.getenv("SB_RATE_LIMIT_COMMANDS", "120/minute") +RATE_LIMIT_GLOBAL = os.getenv("SB_RATE_LIMIT_GLOBAL", "300/minute") +MAX_WS_PER_IP = int(os.getenv("SB_MAX_WS_PER_IP", "3")) +BAN_THRESHOLD = int(os.getenv("SB_BAN_THRESHOLD", "20")) +BAN_DURATION_MINUTES = int(os.getenv("SB_BAN_DURATION_MINUTES", "30")) + +# ── Safety ────────────────────────────────────────────────────────────── +HEARTBEAT_INTERVAL_S = float(os.getenv("SB_HEARTBEAT_INTERVAL", "2.0")) +HEARTBEAT_TIMEOUT_S = float(os.getenv("SB_HEARTBEAT_TIMEOUT", "6.0")) + +# ── Database ──────────────────────────────────────────────────────────── +DB_PATH = os.getenv("SB_DB_PATH", str(Path(__file__).parent / "signal_bridge.db")) + + +def validate(): + """Check that critical config is set. Call on startup.""" + if not SECRET_KEY: + raise RuntimeError( + "SB_SECRET_KEY is not set. Generate one with: " + "python -c \"import secrets; print(secrets.token_hex(32))\"" + ) diff --git a/server/mcp_tools.py b/server/mcp_tools.py new file mode 100644 index 0000000..28bdcd1 --- /dev/null +++ b/server/mcp_tools.py @@ -0,0 +1,373 @@ +""" +Signal Bridge Remote — MCP Tool Definitions + +All tools that Claude can call to control devices. Each tool: + 1. Validates input + 2. Builds a command message + 3. Routes it through the session registry to the user's phone + 4. Returns the result to Claude + +Expanded to support ALL Buttplug output types: + vibrate, rotate, oscillate, constrict, temperature, led, position, spray + +And sensor input types: + battery, rssi, pressure, button, depth, position +""" +from __future__ import annotations +import asyncio +import contextvars +import json +from typing import Optional + +from .models import ( + OutputType, InputType, + DeviceCommand, PatternCommand, StopCommand, ScanCommand, ReadSensorCommand, + CommandAck, +) +from .session_registry import registry + +# Set by auth middleware before each MCP request +current_user_id: contextvars.ContextVar[str] = contextvars.ContextVar("current_user_id") + + +# ════════════════════════════════════════════════════════════════════════ +# Tool registry — built at import time, consumed by the MCP endpoint +# ════════════════════════════════════════════════════════════════════════ + +TOOLS: list[dict] = [] # MCP tool definitions (schema) +HANDLERS: dict[str, callable] = {} # tool_name → async handler function + + +def _register_tool(name: str, description: str, params: dict, required: list[str] = None): + """Decorator factory for registering MCP tools.""" + def decorator(fn): + schema = { + "type": "object", + "properties": params, + } + # Infer required fields: any param without a "default" key is required + if required is not None: + schema["required"] = required + else: + inferred = [k for k, v in params.items() if "default" not in v] + if inferred: + schema["required"] = inferred + TOOLS.append({ + "name": name, + "description": description, + "inputSchema": schema, + }) + HANDLERS[name] = fn + return fn + return decorator + + +# ════════════════════════════════════════════════════════════════════════ +# Helper +# ════════════════════════════════════════════════════════════════════════ + +async def _send(command: dict) -> str: + """Route a command to the current user's phone and return result text.""" + user_id = current_user_id.get() + ack = await registry.send_to_user(user_id, command) + if ack.success: + return ack.message or "OK" + else: + return f"Error: {ack.message}" + + +# ════════════════════════════════════════════════════════════════════════ +# Device Discovery +# ════════════════════════════════════════════════════════════════════════ + +@_register_tool( + "list_devices", + "List all connected devices with their capabilities, intensity floors, and notes.", + {}, +) +async def list_devices(**kwargs) -> str: + user_id = current_user_id.get() + devices = await registry.get_devices(user_id) + + # If cache is empty but phone is connected, try requesting a fresh scan + if not devices: + session = await registry.get_session(user_id) + if session: + # Phone is connected but device list is empty — request a scan + try: + scan_ack = await session.send_command({"type": "scan"}, timeout=15.0) + if scan_ack.success: + # Give a moment for the device_list message to arrive and be processed + await asyncio.sleep(0.5) + devices = await registry.get_devices(user_id) + except Exception: + pass + + if not devices: + # Check if there's even a session + session = await registry.get_session(user_id) + if not session: + return ( + "No phone connected. Start the relay client on your phone/PC " + "and connect it to the server." + ) + return ( + "Phone is connected but no devices found. Make sure Intiface Central " + "is running and devices are turned on." + ) + + lines = [] + for d in devices: + caps = ", ".join(d.get("capabilities", {}).keys()) + notes = d.get("notes", "") + floor = d.get("intensity_floor", 0) + lines.append( + f"• {d.get('short_name', '?')} — capabilities: [{caps}]" + + (f" | floor: {floor}" if floor > 0 else "") + + (f" | {notes}" if notes else "") + ) + return "\n".join(lines) + + +@_register_tool( + "scan_devices", + "Rescan for new or reconnected Bluetooth devices.", + {}, +) +async def scan_devices(**kwargs) -> str: + return await _send(ScanCommand().model_dump()) + + +# ════════════════════════════════════════════════════════════════════════ +# Output Commands — one tool per output type +# ════════════════════════════════════════════════════════════════════════ + +_OUTPUT_PARAMS = { + "device": { + "type": "string", + "description": "Device short name (e.g. 'ferri', 'lush', 'gravity') or 'all'", + "default": "all", + }, + "intensity": { + "type": "number", + "description": "Intensity from 0.0 (off) to 1.0 (maximum)", + "default": 0.5, + }, + "duration": { + "type": "number", + "description": "Duration in seconds. 0 = stay on until stop command.", + "default": 0, + }, +} + + +def _make_output_handler(output_type: OutputType): + """Factory for output command handlers.""" + async def handler( + device: str = "all", intensity: float = 0.5, duration: float = 0, **kw + ) -> str: + cmd = DeviceCommand( + action=output_type, + device=device, + intensity=max(0.0, min(1.0, intensity)), + duration=max(0.0, duration), + ) + return await _send(cmd.model_dump()) + return handler + + +# Standard outputs (available on most devices) +_register_tool( + "vibrate", + "Send vibration to a device. Most common output type.", + _OUTPUT_PARAMS, +)(_make_output_handler(OutputType.VIBRATE)) + +_register_tool( + "rotate", + "Send rotation/sonic pulse output. Device-specific — some devices use this " + "for sonic clitoral stimulation rather than physical rotation.", + _OUTPUT_PARAMS, +)(_make_output_handler(OutputType.ROTATE)) + +_register_tool( + "oscillate", + "Send oscillation/thrusting output. Device-specific — typically linear " + "thrusting motion.", + _OUTPUT_PARAMS, +)(_make_output_handler(OutputType.OSCILLATE)) + +# Extended outputs (device-specific, may not be available on all hardware) +_register_tool( + "constrict", + "Send constriction/compression output. Device-specific — available on " + "devices with squeeze or compression mechanisms.", + _OUTPUT_PARAMS, +)(_make_output_handler(OutputType.CONSTRICT)) + +_register_tool( + "temperature", + "Set temperature output. Device-specific — available on devices with " + "heating or cooling elements. Intensity maps to temperature range.", + _OUTPUT_PARAMS, +)(_make_output_handler(OutputType.TEMPERATURE)) + +_register_tool( + "led", + "Control LED light output. Device-specific — intensity controls brightness.", + _OUTPUT_PARAMS, +)(_make_output_handler(OutputType.LED)) + +_register_tool( + "position", + "Set linear position. Device-specific — intensity maps to position " + "along the device's range of motion (0.0 = retracted, 1.0 = extended).", + _OUTPUT_PARAMS, +)(_make_output_handler(OutputType.POSITION)) + +_register_tool( + "spray", + "Trigger spray/liquid output. Device-specific.", + _OUTPUT_PARAMS, +)(_make_output_handler(OutputType.SPRAY)) + + +# ════════════════════════════════════════════════════════════════════════ +# Stop +# ════════════════════════════════════════════════════════════════════════ + +@_register_tool( + "stop", + "Immediately stop all output on a device (or all devices). " + "Also cancels any running patterns.", + { + "device": { + "type": "string", + "description": "Device short name or 'all'", + "default": "all", + }, + }, +) +async def stop(device: str = "all", **kwargs) -> str: + return await _send(StopCommand(device=device).model_dump()) + + +# ════════════════════════════════════════════════════════════════════════ +# Patterns — work with ANY output type +# ════════════════════════════════════════════════════════════════════════ + +_PATTERN_PARAMS = { + "device": { + "type": "string", + "description": "Device short name or 'all'", + "default": "all", + }, + "output_type": { + "type": "string", + "description": "Which output to modulate: vibrate, rotate, oscillate, " + "constrict, temperature, led, position, spray", + "default": "vibrate", + }, + "intensity": { + "type": "number", + "description": "Peak intensity (0.0–1.0)", + "default": 0.6, + }, + "duration": { + "type": "number", + "description": "Duration in seconds", + "default": 10, + }, +} + + +def _make_pattern_handler(pattern_name: str): + async def handler( + device: str = "all", + output_type: str = "vibrate", + intensity: float = 0.6, + duration: float = 10, + hold_seconds: float = 0, + **kw, + ) -> str: + cmd = PatternCommand( + pattern=pattern_name, + output_type=OutputType(output_type), + device=device, + intensity=max(0.0, min(1.0, intensity)), + duration=max(0.0, duration), + hold_seconds=max(0.0, hold_seconds), + ) + return await _send(cmd.model_dump()) + return handler + + +_register_tool( + "pulse", + "Rhythmic on/off pattern. 0.5s on at intensity, 0.3s off, repeating. " + "Works with any output type (default: vibrate).", + _PATTERN_PARAMS, +)(_make_pattern_handler("pulse")) + +_register_tool( + "wave", + "Smooth sine-wave intensity modulation. Rises and falls continuously. " + "Works with any output type (default: vibrate).", + _PATTERN_PARAMS, +)(_make_pattern_handler("wave")) + +_register_tool( + "escalate", + "Gradual ramp from 0% to peak intensity over the duration, then hold at peak. " + "Use hold_seconds to auto-stop after holding (0 = hold indefinitely until stop command). " + "Works with any output type (default: vibrate).", + {k: v for k, v in _PATTERN_PARAMS.items() if k != "intensity"} + | { + "intensity": {"type": "number", "description": "Peak intensity to ramp up to", "default": 1.0}, + "hold_seconds": { + "type": "number", + "description": "Seconds to hold at peak after ramp completes. 0 = hold indefinitely until explicit stop.", + "default": 0, + }, + }, +)(_make_pattern_handler("escalate")) + + +# ════════════════════════════════════════════════════════════════════════ +# Sensor Inputs — read data FROM the device +# ════════════════════════════════════════════════════════════════════════ + +@_register_tool( + "read_battery", + "Read battery level from a device. Returns percentage (0-100).", + { + "device": { + "type": "string", + "description": "Device short name", + }, + }, +) +async def read_battery(device: str, **kwargs) -> str: + cmd = ReadSensorCommand(sensor=InputType.BATTERY, device=device) + return await _send(cmd.model_dump()) + + +@_register_tool( + "read_sensor", + "Read a sensor value from a device. Available sensors depend on hardware: " + "battery, rssi (signal strength), pressure, button, depth, position. " + "Not all devices support all sensors.", + { + "device": { + "type": "string", + "description": "Device short name", + }, + "sensor": { + "type": "string", + "description": "Sensor type: battery, rssi, pressure, button, depth, position", + }, + }, +) +async def read_sensor(device: str, sensor: str, **kwargs) -> str: + cmd = ReadSensorCommand(sensor=InputType(sensor), device=device) + return await _send(cmd.model_dump()) diff --git a/server/models.py b/server/models.py new file mode 100644 index 0000000..4ece80c --- /dev/null +++ b/server/models.py @@ -0,0 +1,123 @@ +""" +Signal Bridge Remote — Shared Models & Command Protocol + +Defines the JSON message format between all three tiers: + Claude ←(MCP)→ VPS Server ←(WebSocket)→ Phone +""" +from __future__ import annotations +from pydantic import BaseModel, Field +from typing import Optional, Any +from enum import Enum + + +# ── Output Types (commands TO the device) ─────────────────────────────── + +class OutputType(str, Enum): + VIBRATE = "vibrate" + ROTATE = "rotate" + OSCILLATE = "oscillate" + CONSTRICT = "constrict" # compression / squeeze + TEMPERATURE = "temperature" # heating / cooling + LED = "led" # light control + POSITION = "position" # linear positioning + SPRAY = "spray" # liquid / spray + + +# ── Input Types (readings FROM the device) ────────────────────────────── + +class InputType(str, Enum): + BATTERY = "battery" + RSSI = "rssi" # signal strength + PRESSURE = "pressure" + BUTTON = "button" + DEPTH = "depth" + POSITION = "position" + + +# ════════════════════════════════════════════════════════════════════════ +# Server → Phone messages +# ════════════════════════════════════════════════════════════════════════ + +class DeviceCommand(BaseModel): + """Direct output command to a device.""" + type: str = "command" + action: OutputType + device: str = "all" + intensity: float = Field(0.5, ge=0.0, le=1.0) + duration: float = Field(0.0, ge=0.0) # 0 = indefinite + + +class PatternCommand(BaseModel): + """Run a named pattern on a device.""" + type: str = "pattern" + pattern: str # "pulse", "wave", "escalate" + output_type: OutputType = OutputType.VIBRATE + device: str = "all" + intensity: float = Field(0.6, ge=0.0, le=1.0) + duration: float = Field(10.0, ge=0.0) + hold_seconds: float = Field(0.0, ge=0.0) # escalate only: 0 = hold at peak indefinitely + + +class StopCommand(BaseModel): + type: str = "stop" + device: str = "all" + + +class ScanCommand(BaseModel): + type: str = "scan" + + +class ReadSensorCommand(BaseModel): + type: str = "read_sensor" + sensor: InputType + device: str + + +class HeartbeatPing(BaseModel): + type: str = "heartbeat_ping" + timestamp: float + + +# ════════════════════════════════════════════════════════════════════════ +# Phone → Server messages +# ════════════════════════════════════════════════════════════════════════ + +class HeartbeatPong(BaseModel): + type: str = "heartbeat_pong" + timestamp: float + + +class DeviceListReport(BaseModel): + type: str = "device_list" + devices: list[dict[str, Any]] = [] + + +class CommandAck(BaseModel): + type: str = "command_ack" + success: bool = True + message: str = "" + request_id: Optional[str] = None + data: Optional[dict[str, Any]] = None # sensor readings, etc. + + +class PhoneAuth(BaseModel): + type: str = "phone_auth" + token: str + + +# ════════════════════════════════════════════════════════════════════════ +# MCP JSON-RPC models +# ════════════════════════════════════════════════════════════════════════ + +class MCPRequest(BaseModel): + jsonrpc: str = "2.0" + id: Optional[str | int] = None + method: str + params: Optional[dict[str, Any]] = None + + +class MCPResponse(BaseModel): + jsonrpc: str = "2.0" + id: Optional[str | int] = None + result: Optional[Any] = None + error: Optional[dict[str, Any]] = None diff --git a/server/relay_hub.py b/server/relay_hub.py new file mode 100644 index 0000000..ccfe097 --- /dev/null +++ b/server/relay_hub.py @@ -0,0 +1,53 @@ +""" +Signal Bridge Remote — WebSocket Relay Hub (utilities) + +IP tracking and rate limiting for phone WebSocket connections. +The actual WebSocket handling lives in app.py using FastAPI's native WebSocket. + +This module provides the shared state and helpers used by the app endpoint. +""" +from __future__ import annotations +import asyncio +import logging +from collections import defaultdict + +from . import config +from .auth import ip_tracker + +log = logging.getLogger("signal_bridge.relay") + +# Track WebSocket connections per IP for rate limiting +ws_count_by_ip: dict[str, int] = defaultdict(int) +ws_lock = asyncio.Lock() + + +async def check_ws_ip_limit(ip: str) -> str | None: + """ + Check if an IP is allowed to open a new WebSocket connection. + Returns an error reason string if rejected, None if allowed. + Automatically increments the counter if allowed. + """ + if await ip_tracker.is_banned(ip): + return "Temporarily banned" + + async with ws_lock: + if ws_count_by_ip[ip] >= config.MAX_WS_PER_IP: + return "Too many connections from this IP" + ws_count_by_ip[ip] += 1 + + return None + + +async def release_ws_ip_slot(ip: str): + """Decrement the connection counter for an IP when a WebSocket disconnects.""" + async with ws_lock: + ws_count_by_ip[ip] = max(0, ws_count_by_ip[ip] - 1) + + +def get_ip_from_headers(host: str | None, headers: dict | None = None) -> str: + """Extract real IP, checking X-Forwarded-For for reverse proxy setups.""" + if headers: + forwarded = headers.get("X-Forwarded-For", headers.get("x-forwarded-for", "")) + if forwarded: + return forwarded.split(",")[0].strip() + return host or "unknown" diff --git a/server/safety.py b/server/safety.py new file mode 100644 index 0000000..2cd9140 --- /dev/null +++ b/server/safety.py @@ -0,0 +1,110 @@ +""" +Signal Bridge Remote — Safety Systems + +Dead Man's Switch: Monitors phone connections via heartbeat. +If a phone stops responding, all its devices are stopped immediately. + +This is non-negotiable safety infrastructure. Hardware must NEVER +be left running unattended after a connection failure. +""" +from __future__ import annotations +import asyncio +import json +import logging +import time + +from . import config +from .session_registry import registry + +log = logging.getLogger("signal_bridge.safety") + + +class DeadManSwitch: + """ + Periodic heartbeat monitor for all active phone sessions. + + Every HEARTBEAT_INTERVAL_S seconds: + 1. Send a heartbeat_ping to each phone + 2. Check if any phones missed their last heartbeat by > HEARTBEAT_TIMEOUT_S + 3. If so, send emergency stop and disconnect + + The phone relay client responds to pings with pongs. + The session registry tracks last_heartbeat timestamps. + """ + + def __init__(self): + self._task: asyncio.Task | None = None + self._running = False + + async def start(self): + """Start the heartbeat monitor loop.""" + if self._running: + return + self._running = True + self._task = asyncio.create_task(self._monitor_loop()) + log.info( + f"Dead man's switch active: ping every {config.HEARTBEAT_INTERVAL_S}s, " + f"timeout after {config.HEARTBEAT_TIMEOUT_S}s" + ) + + async def stop(self): + """Stop the monitor.""" + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + + async def _monitor_loop(self): + while self._running: + try: + await self._check_all() + except Exception as e: + log.error(f"Heartbeat monitor error: {e}") + await asyncio.sleep(config.HEARTBEAT_INTERVAL_S) + + async def _check_all(self): + now = time.time() + sessions = await registry.get_all_sessions() + + for user_id, session in sessions.items(): + # Send ping + ping = {"type": "heartbeat_ping", "timestamp": now} + try: + await session.websocket.send(json.dumps(ping)) + except Exception: + # Can't even send — connection dead + log.warning(f"DEAD MAN'S SWITCH: Cannot reach phone for user {user_id}") + await self._emergency_stop(user_id, session) + continue + + # Check if last pong is too old + elapsed = now - session.last_heartbeat + if elapsed > config.HEARTBEAT_TIMEOUT_S: + log.warning( + f"DEAD MAN'S SWITCH: Phone heartbeat timeout for user {user_id} " + f"({elapsed:.1f}s since last pong)" + ) + await self._emergency_stop(user_id, session) + + async def _emergency_stop(self, user_id: str, session): + """Send stop-all and disconnect the session.""" + log.critical(f"EMERGENCY STOP for user {user_id} — all devices halted") + try: + stop_cmd = {"type": "stop", "device": "all", "emergency": True} + await session.websocket.send(json.dumps(stop_cmd)) + except Exception: + pass # best effort — the phone client also has its own local failsafe + + try: + await session.websocket.close(1001, "Heartbeat timeout — emergency stop") + except Exception: + pass + + await registry.unregister(user_id) + + +# Singleton +dead_man_switch = DeadManSwitch() diff --git a/server/session_registry.py b/server/session_registry.py new file mode 100644 index 0000000..91267dd --- /dev/null +++ b/server/session_registry.py @@ -0,0 +1,171 @@ +""" +Signal Bridge Remote — Session Registry + +Maps authenticated users to their active phone WebSocket connections. +Handles routing commands from MCP tool calls to the correct phone. +""" +from __future__ import annotations +import asyncio +import json +import logging +import time +import uuid +from dataclasses import dataclass, field +from typing import Optional, Any, Protocol, runtime_checkable + +from .models import CommandAck + +log = logging.getLogger("signal_bridge.sessions") + + +# ════════════════════════════════════════════════════════════════════════ +# WebSocket Protocol — works with any WebSocket implementation +# (FastAPI wrapper, websockets library, etc.) +# ════════════════════════════════════════════════════════════════════════ + +@runtime_checkable +class WebSocketLike(Protocol): + """Minimal interface for a WebSocket connection.""" + async def send(self, data: str) -> None: ... + async def close(self, code: int = 1000, reason: str = "") -> None: ... + + +@dataclass +class PhoneSession: + """An active connection from a user's phone.""" + user_id: str + websocket: WebSocketLike + connected_at: float = field(default_factory=time.time) + last_heartbeat: float = field(default_factory=time.time) + devices: list[dict[str, Any]] = field(default_factory=list) + + # Pending command acknowledgments: request_id → Future + _pending: dict[str, asyncio.Future] = field(default_factory=dict) + + async def send_command(self, command: dict, timeout: float = 10.0) -> CommandAck: + """Send a command and wait for acknowledgment.""" + request_id = str(uuid.uuid4())[:8] + command["request_id"] = request_id + + loop = asyncio.get_running_loop() + future: asyncio.Future[CommandAck] = loop.create_future() + self._pending[request_id] = future + + try: + await self.websocket.send(json.dumps(command)) + ack = await asyncio.wait_for(future, timeout=timeout) + return ack + except asyncio.TimeoutError: + return CommandAck(success=False, message="Phone did not respond in time") + finally: + self._pending.pop(request_id, None) + + def resolve_ack(self, request_id: str, ack: CommandAck): + """Called when the phone sends a command_ack.""" + future = self._pending.get(request_id) + if future and not future.done(): + future.set_result(ack) + + async def send_fire_and_forget(self, command: dict): + """Send without waiting for ack (used for heartbeats, stops).""" + try: + await self.websocket.send(json.dumps(command)) + except Exception: + pass # connection probably dead, heartbeat will catch it + + +class SessionRegistry: + """ + Central registry mapping users to their active phone sessions. + Thread-safe via asyncio locks. + """ + + def __init__(self): + self._sessions: dict[str, PhoneSession] = {} # user_id → session + self._lock = asyncio.Lock() + + async def register(self, user_id: str, websocket: WebSocketLike) -> PhoneSession: + """Register a new phone connection for a user.""" + async with self._lock: + # Close existing session if any (phone reconnected) + old = self._sessions.get(user_id) + if old: + log.info(f"Replacing existing session for user {user_id}") + try: + await old.websocket.close(1000, "Replaced by new connection") + except Exception: + pass + + session = PhoneSession(user_id=user_id, websocket=websocket) + self._sessions[user_id] = session + log.info(f"Phone connected: user={user_id}") + return session + + async def unregister(self, user_id: str): + """Remove a phone session.""" + async with self._lock: + session = self._sessions.pop(user_id, None) + if session: + log.info(f"Phone disconnected: user={user_id}") + + async def get_session(self, user_id: str) -> Optional[PhoneSession]: + """Get the active phone session for a user.""" + async with self._lock: + return self._sessions.get(user_id) + + async def send_to_user( + self, user_id: str, command: dict, wait_ack: bool = True + ) -> CommandAck: + """Route a command to a user's phone. Returns ack.""" + session = await self.get_session(user_id) + if not session: + return CommandAck( + success=False, + message="No phone connected. Open Intiface and connect to the relay.", + ) + + if wait_ack: + return await session.send_command(command) + else: + await session.send_fire_and_forget(command) + return CommandAck(success=True, message="Sent (no ack requested)") + + async def update_heartbeat(self, user_id: str): + """Mark that a heartbeat pong was received.""" + async with self._lock: + session = self._sessions.get(user_id) + if session: + session.last_heartbeat = time.time() + + async def update_devices(self, user_id: str, devices: list[dict]): + """Update the device list for a user's session.""" + async with self._lock: + session = self._sessions.get(user_id) + if session: + session.devices = devices + + async def get_devices(self, user_id: str) -> list[dict]: + """Get device list for a user.""" + session = await self.get_session(user_id) + return session.devices if session else [] + + async def get_all_sessions(self) -> dict[str, PhoneSession]: + """Get snapshot of all sessions (for heartbeat monitor).""" + async with self._lock: + return dict(self._sessions) + + async def get_sole_user_id(self) -> Optional[str]: + """If exactly one phone session is active, return its user_id. + Used for authless MCP access (e.g. claude.ai connector).""" + async with self._lock: + if len(self._sessions) == 1: + return next(iter(self._sessions)) + return None + + @property + def active_count(self) -> int: + return len(self._sessions) + + +# Singleton +registry = SessionRegistry() diff --git a/termux_relay_v3.py b/termux_relay_v3.py new file mode 100644 index 0000000..71a58ea --- /dev/null +++ b/termux_relay_v3.py @@ -0,0 +1,509 @@ +#!/usr/bin/env python3 +""" +Signal Bridge Relay Client - Termux Edition v3 +- Processes commands in background tasks so heartbeats always respond instantly +- Drains Intiface WebSocket responses to prevent buffer buildup +""" + +import argparse, asyncio, json, logging, math, os, time, sys + +try: + import websockets +except ImportError: + print("Missing dependency. Run: pip install websockets") + sys.exit(1) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", +) +log = logging.getLogger("relay") + +DEFAULT_DEVICES = { + "ferri": { + "device_id": "ferri", + "name": "Lovense Ferri", + "intensity_floor": 0.0, + "supported_outputs": ["vibrate"], + }, + "enigma": { + "device_id": "enigma", + "name": "Lovense Enigma", + "intensity_floor": 0.4, + "supported_outputs": ["vibrate", "rotate"], + }, +} + + +def load_profiles(path="devices.json"): + if os.path.exists(path): + with open(path) as f: + data = json.load(f) + log.info(f"Loaded device profiles from {path}") + return data.get("devices", data) + log.info("No devices.json found - using built-in defaults") + return DEFAULT_DEVICES + + +class ButtplugRaw: + def __init__(self, intiface_url="ws://127.0.0.1:12345"): + self.url = intiface_url + self.ws = None + self._msg_id = 0 + self.bp_devices = {} + self.name_map = {} + self.profiles = load_profiles() + self._drain_task = None + + def _next_id(self): + self._msg_id += 1 + return self._msg_id + + async def connect(self): + log.info(f"Connecting to Intiface at {self.url} ...") + self.ws = await websockets.connect(self.url) + await self._send([{ + "RequestServerInfo": { + "Id": self._next_id(), + "ClientName": "Signal Bridge Termux", + "MessageVersion": 3, + } + }]) + resp = await self._recv() + if resp and "ServerInfo" in resp[0]: + info = resp[0]["ServerInfo"] + log.info(f"Connected to {info.get('ServerName', 'Intiface')} (protocol v{info.get('MessageVersion', '?')})") + else: + log.warning(f"Unexpected handshake response: {resp}") + + async def start_drain(self): + """Background task to read and process Intiface messages (device events, Ok responses).""" + async def _drain(): + try: + while self.ws: + try: + raw = await asyncio.wait_for(self.ws.recv(), timeout=30.0) + msgs = json.loads(raw) + for msg in msgs: + self._handle_event(msg) + except asyncio.TimeoutError: + pass + except (websockets.exceptions.ConnectionClosed, asyncio.CancelledError): + pass + self._drain_task = asyncio.create_task(_drain()) + + async def stop_drain(self): + if self._drain_task: + self._drain_task.cancel() + try: + await self._drain_task + except asyncio.CancelledError: + pass + self._drain_task = None + + async def scan(self, duration=5.0): + log.info("Scanning for devices ...") + await self._send([{"StartScanning": {"Id": self._next_id()}}]) + await asyncio.sleep(duration) + await self._send([{"RequestDeviceList": {"Id": self._next_id()}}]) + await asyncio.sleep(1.0) + log.info(f"Scan complete - {len(self.bp_devices)} device(s) found") + + def _handle_event(self, msg): + if "DeviceAdded" in msg: + self._add_device(msg["DeviceAdded"]) + elif "DeviceRemoved" in msg: + idx = msg["DeviceRemoved"].get("DeviceIndex") + for name, bidx in list(self.name_map.items()): + if bidx == idx: + del self.name_map[name] + break + self.bp_devices.pop(idx, None) + log.info(f"Device removed (index {idx})") + elif "DeviceList" in msg: + for dev in msg["DeviceList"].get("Devices", []): + self._add_device(dev) + + def _add_device(self, dev_info): + idx = dev_info["DeviceIndex"] + bp_name = dev_info.get("DeviceName", f"device-{idx}") + self.bp_devices[idx] = dev_info + short_name = None + bp_lower = bp_name.lower() + for pid, profile in self.profiles.items(): + pname = profile["name"].lower() + if pname in bp_lower or pid.lower() in bp_lower: + short_name = pid + break + if not short_name: + short_name = bp_name.lower().replace(" ", "_") + self.name_map[short_name] = idx + log.info(f"Device: {bp_name} -> '{short_name}' (index {idx})") + + def get_device_list(self): + result = [] + for short_name, idx in self.name_map.items(): + bp_dev = self.bp_devices.get(idx, {}) + bp_name = bp_dev.get("DeviceName", short_name) + profile = self.profiles.get(short_name, {}) + capabilities = {} + for feature in bp_dev.get("DeviceMessages", {}).get("ScalarCmd", []): + at = feature.get("ActuatorType", "").lower() + if at in ("vibrate", "rotate", "oscillate"): + capabilities[at] = {} + if not capabilities: + for o in profile.get("supported_outputs", ["vibrate"]): + capabilities[o] = {} + result.append({ + "short_name": short_name, + "name": profile.get("name", bp_name), + "intensity_floor": profile.get("intensity_floor", 0.0), + "capabilities": capabilities, + "notes": profile.get("name", bp_name), + }) + return result + + async def scalar_cmd(self, idx, intensity, actuator_type="Vibrate"): + bp_dev = self.bp_devices.get(idx, {}) + scalars = [] + for i, feature in enumerate(bp_dev.get("DeviceMessages", {}).get("ScalarCmd", [])): + if feature.get("ActuatorType", "").lower() == actuator_type.lower(): + scalars.append({ + "Index": i, + "Scalar": max(0.0, min(1.0, intensity)), + "ActuatorType": feature["ActuatorType"], + }) + if not scalars: + scalars = [{"Index": 0, "Scalar": max(0.0, min(1.0, intensity)), "ActuatorType": actuator_type}] + await self._send([{ + "ScalarCmd": { + "Id": self._next_id(), + "DeviceIndex": idx, + "Scalars": scalars, + } + }]) + + async def stop_device(self, idx): + await self._send([{"StopDeviceCmd": {"Id": self._next_id(), "DeviceIndex": idx}}]) + + async def stop_all(self): + await self._send([{"StopAllDevices": {"Id": self._next_id()}}]) + + async def _send(self, msgs): + if self.ws: + await self.ws.send(json.dumps(msgs)) + + async def _recv(self): + if self.ws: + raw = await self.ws.recv() + return json.loads(raw) + return None + + async def close(self): + await self.stop_drain() + if self.ws: + await self.ws.close() + + +class PatternRunner: + def __init__(self, bp): + self.bp = bp + self.active_tasks = {} + + def _floor(self, raw, floor): + if raw <= 0.01: + return 0.0 + if floor > 0: + return min(1.0, floor + raw * (1.0 - floor)) + return min(1.0, raw) + + def _resolve_targets(self, device): + if device == "all": + return list(self.bp.name_map.items()) + if device in self.bp.name_map: + return [(device, self.bp.name_map[device])] + return [] + + async def cancel_patterns(self, device="all"): + if device == "all": + for task in self.active_tasks.values(): + task.cancel() + self.active_tasks.clear() + else: + task = self.active_tasks.pop(device, None) + if task: + task.cancel() + + async def run_command(self, cmd): + msg_type = cmd.get("type", "") + request_id = cmd.get("request_id", "") + if msg_type == "command": + return await self._handle_command(cmd, request_id) + elif msg_type == "pattern": + return await self._handle_pattern(cmd, request_id) + elif msg_type == "stop": + return await self._handle_stop(cmd, request_id) + elif msg_type == "scan": + return await self._handle_scan(request_id) + elif msg_type == "read_sensor": + return self._ack(False, "Sensors not supported in Termux relay", request_id) + else: + return self._ack(False, f"Unknown command type: {msg_type}", request_id) + + async def _handle_command(self, cmd, request_id): + device = cmd.get("device", "all") + intensity = cmd.get("intensity", 0.5) + output_type = cmd.get("action", cmd.get("output_type", "vibrate")) + duration = cmd.get("duration", 0) + targets = self._resolve_targets(device) + + if not targets: + available = list(self.bp.name_map.keys()) + return self._ack(False, f"Device not found. Available: {available}", request_id) + + log.info(f"Command: {output_type} intensity={intensity} duration={duration} targets={[t[0] for t in targets]}") + + for short_name, idx in targets: + profile = self.bp.profiles.get(short_name, {}) + floor = profile.get("intensity_floor", 0.0) + adj = self._floor(intensity, floor) + log.info(f" {short_name}: raw={intensity} floor={floor} adjusted={adj}") + await self.bp.scalar_cmd(idx, adj, output_type) + + names = [t[0] for t in targets] + + if duration > 0: + async def auto_stop(): + await asyncio.sleep(duration) + for sn, ix in targets: + await self.bp.stop_device(ix) + log.info(f"Auto-stopped after {duration}s") + asyncio.create_task(auto_stop()) + + return self._ack(True, "Set " + output_type + " " + str(intensity) + " on " + ", ".join(names), request_id, names) + + async def _handle_pattern(self, cmd, request_id): + pattern = cmd.get("pattern", "pulse") + device = cmd.get("device", "all") + intensity = cmd.get("intensity", 0.6) + duration = cmd.get("duration", 10.0) + output_type = cmd.get("action", cmd.get("output_type", "vibrate")) + hold = cmd.get("hold_seconds", 0.0) + targets = self._resolve_targets(device) + + if not targets: + return self._ack(False, "Device not found", request_id) + + for short_name, idx in targets: + await self.cancel_patterns(short_name) + profile = self.bp.profiles.get(short_name, {}) + floor = profile.get("intensity_floor", 0.0) + + if pattern == "pulse": + task = asyncio.create_task(self._run_pulse(idx, output_type, intensity, duration, floor)) + elif pattern == "wave": + task = asyncio.create_task(self._run_wave(idx, output_type, intensity, duration, floor)) + elif pattern == "escalate": + task = asyncio.create_task(self._run_escalate(idx, output_type, intensity, duration, hold, floor)) + else: + return self._ack(False, "Unknown pattern: " + pattern, request_id) + self.active_tasks[short_name] = task + + names = [t[0] for t in targets] + return self._ack(True, "Pattern " + pattern + " started on " + ", ".join(names), request_id, names) + + async def _handle_stop(self, cmd, request_id): + device = cmd.get("device", "all") + targets = self._resolve_targets(device) + fallback = False + + if device != "all" and not targets: + fallback = True + targets = list(self.bp.name_map.items()) + + for short_name, idx in targets: + await self.cancel_patterns(short_name) + await self.bp.stop_device(idx) + + if fallback: + available = ", ".join(self.bp.name_map.keys()) or "none" + return self._ack(True, "Unknown device - stopped ALL as safety fallback. Available: " + available, request_id, [t[0] for t in targets]) + + if device == "all": + await self.bp.stop_all() + self.active_tasks.clear() + + names = [t[0] for t in targets] + return self._ack(True, "Stopped " + (", ".join(names) if names else "all"), request_id, names) + + async def _handle_scan(self, request_id): + await self.bp.scan(duration=5.0) + return self._ack(True, "Scan complete - " + str(len(self.bp.bp_devices)) + " device(s)", request_id) + + async def _run_pulse(self, idx, output_type, intensity, duration, floor): + try: + start = time.time() + on = True + while time.time() - start < duration: + if on: + adj = self._floor(intensity, floor) + await self.bp.scalar_cmd(idx, adj, output_type) + else: + await self.bp.scalar_cmd(idx, 0.0, output_type) + on = not on + await asyncio.sleep(0.4) + except asyncio.CancelledError: + pass + finally: + try: + await self.bp.stop_device(idx) + except Exception: + pass + + async def _run_wave(self, idx, output_type, intensity, duration, floor): + try: + start = time.time() + while time.time() - start < duration: + elapsed = time.time() - start + raw = (math.sin(elapsed * 2.0) + 1.0) / 2.0 * intensity + adj = self._floor(raw, floor) + await self.bp.scalar_cmd(idx, adj, output_type) + await asyncio.sleep(0.1) + except asyncio.CancelledError: + pass + finally: + try: + await self.bp.stop_device(idx) + except Exception: + pass + + async def _run_escalate(self, idx, output_type, peak, duration, hold, floor): + try: + steps = 20 + for i in range(steps + 1): + val = (i / steps) * peak + adj = self._floor(val, floor) + await self.bp.scalar_cmd(idx, adj, output_type) + await asyncio.sleep(duration / steps) + if hold > 0: + await asyncio.sleep(hold) + await self.bp.stop_device(idx) + except asyncio.CancelledError: + try: + await self.bp.stop_device(idx) + except Exception: + pass + + def _ack(self, success, message, request_id, devices=None): + return { + "type": "command_ack", + "request_id": request_id, + "success": success, + "message": message, + "devices_affected": devices or [], + } + + +async def relay_loop(server_url, token, intiface_url): + bp = ButtplugRaw(intiface_url) + runner = PatternRunner(bp) + ws_lock = asyncio.Lock() + + while True: + try: + await bp.connect() + await bp.start_drain() + await bp.scan(duration=5.0) + + device_list = bp.get_device_list() + log.info(f"Devices ready: {[d['short_name'] for d in device_list]}") + + log.info(f"Connecting to server: {server_url}") + async with websockets.connect(server_url) as ws: + await ws.send(json.dumps({"type": "phone_auth", "token": token})) + auth_resp = json.loads(await ws.recv()) + + if auth_resp.get("type") != "auth_ok": + log.error(f"Auth failed: {auth_resp}") + await asyncio.sleep(5) + continue + + log.info("Authenticated with server!") + + if device_list: + await ws.send(json.dumps({"type": "device_list", "devices": device_list})) + log.info(f"Sent device list: {len(device_list)} device(s)") + else: + log.warning("No devices to report after scan") + + async def process_command(msg, msg_type): + """Handle a command in the background so heartbeats stay responsive.""" + try: + ack = await runner.run_command(msg) + async with ws_lock: + await ws.send(json.dumps(ack)) + log.info(f"-> Ack: {ack.get('message', '')}") + + if msg_type == "scan": + dl = bp.get_device_list() + async with ws_lock: + await ws.send(json.dumps({"type": "device_list", "devices": dl})) + log.info(f"Sent updated device list: {len(dl)} device(s)") + except Exception as e: + log.error(f"Command processing error: {e}") + + async for raw_msg in ws: + try: + msg = json.loads(raw_msg) + msg_type = msg.get("type", "") + + if msg_type in ("ping", "heartbeat_ping"): + async with ws_lock: + await ws.send(json.dumps({"type": "heartbeat_pong"})) + continue + + if msg_type in ("command", "pattern", "stop", "read_sensor", "scan"): + log.info(f"<- Server: {msg_type}") + asyncio.create_task(process_command(msg, msg_type)) + + else: + log.warning(f"Unknown message type: {msg_type}") + + except json.JSONDecodeError: + log.warning("Bad JSON from server") + + except websockets.exceptions.ConnectionClosed as e: + log.warning(f"Connection closed: {e}. Reconnecting in 5s ...") + except ConnectionRefusedError: + log.warning("Connection refused. Is Intiface running? Retrying in 5s ...") + except Exception as e: + log.error(f"Error: {e}. Retrying in 5s ...") + + try: + await bp.close() + except Exception: + pass + bp.ws = None + bp.bp_devices.clear() + bp.name_map.clear() + + await asyncio.sleep(5) + + +def main(): + parser = argparse.ArgumentParser(description="Signal Bridge Termux Relay") + parser.add_argument("--server", default="wss://signal-bridge.duckdns.org/ws/phone", help="VPS WebSocket URL") + parser.add_argument("--token", default=os.environ.get("SB_TOKEN"), help="JWT auth token (or set SB_TOKEN env var)") + parser.add_argument("--intiface", default="ws://127.0.0.1:12345", help="Intiface Central WebSocket URL") + args = parser.parse_args() + + if not args.token: + parser.error("Token required: use --token or set SB_TOKEN env var") + + log.info("=== Signal Bridge Termux Relay v3 ===") + asyncio.run(relay_loop(args.server, args.token, args.intiface)) + + +if __name__ == "__main__": + main()