Add files via upload

This commit is contained in:
Aletheia
2026-03-14 19:40:48 +01:00
committed by GitHub
commit 142e83d512
25 changed files with 3477 additions and 0 deletions

21
Dockerfile Normal file
View File

@@ -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"]

521
README.md Normal file
View File

@@ -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.

15
docker-compose.yml Normal file
View File

@@ -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:

115
phone/devices.json Normal file
View File

@@ -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."
}
]

692
phone/relay_client.py Normal file
View File

@@ -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()

7
requirements-server.txt Normal file
View File

@@ -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

1
server/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Signal Bridge Remote — Server Package

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

494
server/app.py Normal file
View File

@@ -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",
},
}

226
server/auth.py Normal file
View File

@@ -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()

46
server/config.py Normal file
View File

@@ -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))\""
)

373
server/mcp_tools.py Normal file
View File

@@ -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())

123
server/models.py Normal file
View File

@@ -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

53
server/relay_hub.py Normal file
View File

@@ -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"

110
server/safety.py Normal file
View File

@@ -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()

171
server/session_registry.py Normal file
View File

@@ -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()

509
termux_relay_v3.py Normal file
View File

@@ -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()