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:
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()
|
||||
Reference in New Issue
Block a user