Files
signal_bridge_remote/termux_relay_v3.py
Aletheia 136920b717 feat: feature_index — target individual motors on multi-actuator devices
Ported from the Android edition and completed for the Python relays
(the Android repo only implemented the phone side in Kotlin):

- models.py / mcp_tools.py: optional feature_index on every output and
  pattern tool, passed through to the phone relay.
- relay_client.py: routes targeted writes through buttplug-py's
  per-feature API (device.features[i].run_output) and validates the
  index up front so a bad one fails the ack with the valid indices
  listed, instead of dying silently inside a pattern task.
- termux_relay_v3.py: ScalarCmd entries filtered to the requested
  actuator index, same fallback semantics as the Android relay engine.

Lets Claude drive e.g. a Dolce's internal and external motors
independently (feature_index 0 / 1) instead of always both together.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:11:01 +02:00

521 lines
20 KiB
Python

#!/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", feature_index=None):
bp_dev = self.bp_devices.get(idx, {})
scalars = []
# Find matching actuators, optionally filtered by feature index
# (multi-motor devices like the Edge or Dolce)
for i, feature in enumerate(bp_dev.get("DeviceMessages", {}).get("ScalarCmd", [])):
if feature.get("ActuatorType", "").lower() == actuator_type.lower():
if feature_index is not None and i != feature_index:
continue
scalars.append({
"Index": i,
"Scalar": max(0.0, min(1.0, intensity)),
"ActuatorType": feature["ActuatorType"],
})
# Fallback: if no matching actuator found, use requested index (or 0)
if not scalars:
scalars = [{
"Index": feature_index if feature_index is not None else 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)
feature_index = cmd.get("feature_index")
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} feature_index={feature_index} 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, feature_index)
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)
feature_index = cmd.get("feature_index")
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, feature_index))
elif pattern == "wave":
task = asyncio.create_task(self._run_wave(idx, output_type, intensity, duration, floor, feature_index))
elif pattern == "escalate":
task = asyncio.create_task(self._run_escalate(idx, output_type, intensity, duration, hold, floor, feature_index))
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, feature_index=None):
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, feature_index)
else:
await self.bp.scalar_cmd(idx, 0.0, output_type, feature_index)
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, feature_index=None):
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, feature_index)
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, feature_index=None):
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, feature_index)
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()