mirror of
https://github.com/AletheiaVox/signal_bridge_remote.git
synced 2026-10-07 03:18:17 +08:00
Add files via upload
This commit is contained in:
21
Dockerfile
Normal file
21
Dockerfile
Normal 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
521
README.md
Normal 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
15
docker-compose.yml
Normal 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
115
phone/devices.json
Normal 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
692
phone/relay_client.py
Normal 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
7
requirements-server.txt
Normal 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
1
server/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Signal Bridge Remote — Server Package
|
||||
BIN
server/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
server/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
server/__pycache__/app.cpython-310.pyc
Normal file
BIN
server/__pycache__/app.cpython-310.pyc
Normal file
Binary file not shown.
BIN
server/__pycache__/auth.cpython-310.pyc
Normal file
BIN
server/__pycache__/auth.cpython-310.pyc
Normal file
Binary file not shown.
BIN
server/__pycache__/config.cpython-310.pyc
Normal file
BIN
server/__pycache__/config.cpython-310.pyc
Normal file
Binary file not shown.
BIN
server/__pycache__/mcp_tools.cpython-310.pyc
Normal file
BIN
server/__pycache__/mcp_tools.cpython-310.pyc
Normal file
Binary file not shown.
BIN
server/__pycache__/models.cpython-310.pyc
Normal file
BIN
server/__pycache__/models.cpython-310.pyc
Normal file
Binary file not shown.
BIN
server/__pycache__/relay_hub.cpython-310.pyc
Normal file
BIN
server/__pycache__/relay_hub.cpython-310.pyc
Normal file
Binary file not shown.
BIN
server/__pycache__/safety.cpython-310.pyc
Normal file
BIN
server/__pycache__/safety.cpython-310.pyc
Normal file
Binary file not shown.
BIN
server/__pycache__/session_registry.cpython-310.pyc
Normal file
BIN
server/__pycache__/session_registry.cpython-310.pyc
Normal file
Binary file not shown.
494
server/app.py
Normal file
494
server/app.py
Normal 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
226
server/auth.py
Normal 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
46
server/config.py
Normal 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
373
server/mcp_tools.py
Normal 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
123
server/models.py
Normal 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
53
server/relay_hub.py
Normal 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
110
server/safety.py
Normal 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
171
server/session_registry.py
Normal 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
509
termux_relay_v3.py
Normal 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()
|
||||
Reference in New Issue
Block a user