From 136920b717107cf0ac99a85a48c9d8a482b66c75 Mon Sep 17 00:00:00 2001 From: Aletheia Date: Tue, 7 Jul 2026 20:11:01 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20feature=5Findex=20=E2=80=94=20target=20?= =?UTF-8?q?individual=20motors=20on=20multi-actuator=20devices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- phone/relay_client.py | 70 +++++++++++++++++++++++++++++++++---------- server/mcp_tools.py | 21 ++++++++++++- server/models.py | 2 ++ termux_relay_v3.py | 39 +++++++++++++++--------- 4 files changed, 101 insertions(+), 31 deletions(-) diff --git a/phone/relay_client.py b/phone/relay_client.py index 19560c8..c0b9bf6 100644 --- a/phone/relay_client.py +++ b/phone/relay_client.py @@ -232,6 +232,7 @@ class DeviceController: device_name = cmd.get("device", "all") intensity = cmd.get("intensity", 0.5) duration = cmd.get("duration", 0) + feature_index = cmd.get("feature_index") targets = self._resolve_targets(device_name) if not targets: @@ -243,16 +244,17 @@ class DeviceController: for cd in targets: adj_intensity = self._apply_floor(intensity, cd.profile.intensity_floor) + err = self._check_feature(cd, otype, feature_index) + if err: + return self._ack(False, err, request_id) try: - await cd.buttplug_device.run_output( - DeviceOutputCommand(otype, adj_intensity) - ) + await self._write_output(cd, otype, adj_intensity, feature_index) 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)) + asyncio.create_task(self._timed_stop(cd, otype, duration, feature_index)) names = ", ".join(cd.profile.short_name for cd in targets) return self._ack( @@ -269,6 +271,7 @@ class DeviceController: device_name = cmd.get("device", "all") intensity = cmd.get("intensity", 0.6) duration = cmd.get("duration", 10) + feature_index = cmd.get("feature_index") targets = self._resolve_targets(device_name) if not targets: @@ -279,6 +282,12 @@ class DeviceController: return self._ack(False, f"Unsupported output type: {output_type}", request_id) for cd in targets: + # Validate up front so a bad feature_index fails the ack instead + # of dying silently inside the pattern task. + err = self._check_feature(cd, otype, feature_index) + if err: + return self._ack(False, err, request_id) + task_key = f"{cd.profile.short_name}:{pattern}" # Cancel existing pattern on this device if task_key in self._pattern_tasks: @@ -286,16 +295,16 @@ class DeviceController: if pattern == "pulse": task = asyncio.create_task( - self._run_pulse(cd, otype, intensity, duration) + self._run_pulse(cd, otype, intensity, duration, feature_index) ) elif pattern == "wave": task = asyncio.create_task( - self._run_wave(cd, otype, intensity, duration) + self._run_wave(cd, otype, intensity, duration, feature_index) ) elif pattern == "escalate": hold_seconds = cmd.get("hold_seconds", 0) task = asyncio.create_task( - self._run_escalate(cd, otype, intensity, duration, hold_seconds) + self._run_escalate(cd, otype, intensity, duration, hold_seconds, feature_index) ) else: return self._ack(False, f"Unknown pattern: {pattern}", request_id) @@ -387,15 +396,15 @@ class DeviceController: # ── Pattern Runners ───────────────────────────────────────────── - async def _run_pulse(self, cd: ConnectedDevice, otype, intensity: float, duration: float): + async def _run_pulse(self, cd: ConnectedDevice, otype, intensity: float, duration: float, feature_index: Optional[int] = None): 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 self._write_output(cd, otype, adj, feature_index) await asyncio.sleep(0.5) - await cd.buttplug_device.run_output(DeviceOutputCommand(otype, 0)) + await self._write_output(cd, otype, 0, feature_index) await asyncio.sleep(0.3) except asyncio.CancelledError: pass @@ -405,7 +414,7 @@ class DeviceController: except Exception: pass - async def _run_wave(self, cd: ConnectedDevice, otype, intensity: float, duration: float): + async def _run_wave(self, cd: ConnectedDevice, otype, intensity: float, duration: float, feature_index: Optional[int] = None): try: start = time.time() floor = cd.profile.intensity_floor @@ -422,7 +431,7 @@ class DeviceController: adj = min(1.0, adj) else: adj = min(1.0, raw) - await cd.buttplug_device.run_output(DeviceOutputCommand(otype, adj)) + await self._write_output(cd, otype, adj, feature_index) await asyncio.sleep(0.1) except asyncio.CancelledError: pass @@ -432,7 +441,7 @@ class DeviceController: except Exception: pass - async def _run_escalate(self, cd: ConnectedDevice, otype, peak: float, duration: float, hold_seconds: float = 0): + async def _run_escalate(self, cd: ConnectedDevice, otype, peak: float, duration: float, hold_seconds: float = 0, feature_index: Optional[int] = None): try: steps = 20 floor = cd.profile.intensity_floor @@ -445,7 +454,7 @@ class DeviceController: adj = min(1.0, adj) else: adj = self._apply_floor(val, floor) - await cd.buttplug_device.run_output(DeviceOutputCommand(otype, adj)) + await self._write_output(cd, otype, adj, feature_index) await asyncio.sleep(duration / steps) # At peak now. hold_seconds: 0 = hold indefinitely, >0 = hold then stop if hold_seconds > 0: @@ -460,10 +469,39 @@ class DeviceController: # ── Helpers ────────────────────────────────────────────────────── - async def _timed_stop(self, cd: ConnectedDevice, otype, duration: float): + async def _write_output(self, cd: ConnectedDevice, otype, value: float, feature_index: Optional[int] = None): + """Send an output value to a device — all features matching the output + type, or one specific feature when feature_index is given (multi-motor + devices like the Edge or Dolce).""" + if feature_index is None: + await cd.buttplug_device.run_output(DeviceOutputCommand(otype, value)) + return + err = self._check_feature(cd, otype, feature_index) + if err: + raise ValueError(err) + feature = cd.buttplug_device.features[feature_index] + await feature.run_output(DeviceOutputCommand(otype, value)) + + def _check_feature(self, cd: ConnectedDevice, otype, feature_index: Optional[int]) -> Optional[str]: + """Return an error message if feature_index can't take this output, else None.""" + if feature_index is None: + return None + feature = cd.buttplug_device.features.get(feature_index) + if feature is None or not feature.has_output(otype): + valid = [ + i for i, f in cd.buttplug_device.features.items() if f.has_output(otype) + ] + oname = getattr(otype, "value", str(otype)) + return ( + f"feature_index {feature_index} has no {oname} output on " + f"{cd.profile.short_name} (valid: {valid if valid else 'none'})" + ) + return None + + async def _timed_stop(self, cd: ConnectedDevice, otype, duration: float, feature_index: Optional[int] = None): await asyncio.sleep(duration) try: - await cd.buttplug_device.run_output(DeviceOutputCommand(otype, 0)) + await self._write_output(cd, otype, 0, feature_index) except Exception: pass diff --git a/server/mcp_tools.py b/server/mcp_tools.py index 605674f..05886a9 100644 --- a/server/mcp_tools.py +++ b/server/mcp_tools.py @@ -190,13 +190,22 @@ _OUTPUT_PARAMS = { "description": "Duration in seconds. 0 = stay on until stop command.", "default": 0, }, + "feature_index": { + "type": "integer", + "description": ( + "Target a specific actuator by index when a device has multiple " + "actuators of the same type (e.g. Dolce motor 0 = primary, " + "motor 1 = secondary). Omit to drive all matching actuators together." + ), + }, } 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 + device: str = "all", intensity: float = 0.5, duration: float = 0, + feature_index: Optional[int] = None, **kw ) -> str: clamped = max(0.0, min(1.0, float(intensity))) cmd = DeviceCommand( @@ -204,6 +213,7 @@ def _make_output_handler(output_type: OutputType): device=device, intensity=clamped, duration=max(0.0, float(duration)), + feature_index=feature_index, ) return await _send(cmd.model_dump(), intensity=clamped) return handler @@ -311,6 +321,13 @@ _PATTERN_PARAMS = { "description": "Duration in seconds", "default": 10, }, + "feature_index": { + "type": "integer", + "description": ( + "Target a specific actuator by index when a device has multiple " + "actuators of the same type. Omit to drive all matching actuators." + ), + }, } @@ -321,6 +338,7 @@ def _make_pattern_handler(pattern_name: str): intensity: float = 0.6, duration: float = 10, hold_seconds: float = 0, + feature_index: Optional[int] = None, **kw, ) -> str: clamped = max(0.0, min(1.0, float(intensity))) @@ -331,6 +349,7 @@ def _make_pattern_handler(pattern_name: str): intensity=clamped, duration=max(0.0, float(duration)), hold_seconds=max(0.0, float(hold_seconds)), + feature_index=feature_index, ) return await _send(cmd.model_dump(), intensity=clamped) return handler diff --git a/server/models.py b/server/models.py index 4ece80c..cf9c78e 100644 --- a/server/models.py +++ b/server/models.py @@ -45,6 +45,7 @@ class DeviceCommand(BaseModel): device: str = "all" intensity: float = Field(0.5, ge=0.0, le=1.0) duration: float = Field(0.0, ge=0.0) # 0 = indefinite + feature_index: Optional[int] = None # target a specific actuator by index class PatternCommand(BaseModel): @@ -56,6 +57,7 @@ class PatternCommand(BaseModel): 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 + feature_index: Optional[int] = None # target a specific actuator by index class StopCommand(BaseModel): diff --git a/termux_relay_v3.py b/termux_relay_v3.py index 71a58ea..b56d3e8 100644 --- a/termux_relay_v3.py +++ b/termux_relay_v3.py @@ -164,18 +164,27 @@ class ButtplugRaw: }) return result - async def scalar_cmd(self, idx, intensity, actuator_type="Vibrate"): + 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": 0, "Scalar": max(0.0, min(1.0, intensity)), "ActuatorType": actuator_type}] + 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(), @@ -256,20 +265,21 @@ class PatternRunner: 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} targets={[t[0] for t in targets]}") + 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) + await self.bp.scalar_cmd(idx, adj, output_type, feature_index) names = [t[0] for t in targets] @@ -290,6 +300,7 @@ class PatternRunner: 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: @@ -301,11 +312,11 @@ class PatternRunner: floor = profile.get("intensity_floor", 0.0) if pattern == "pulse": - task = asyncio.create_task(self._run_pulse(idx, output_type, intensity, duration, floor)) + 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)) + 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)) + 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 @@ -341,16 +352,16 @@ class PatternRunner: 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): + 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) + await self.bp.scalar_cmd(idx, adj, output_type, feature_index) else: - await self.bp.scalar_cmd(idx, 0.0, output_type) + await self.bp.scalar_cmd(idx, 0.0, output_type, feature_index) on = not on await asyncio.sleep(0.4) except asyncio.CancelledError: @@ -361,14 +372,14 @@ class PatternRunner: except Exception: pass - async def _run_wave(self, idx, output_type, intensity, duration, floor): + 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) + await self.bp.scalar_cmd(idx, adj, output_type, feature_index) await asyncio.sleep(0.1) except asyncio.CancelledError: pass @@ -378,13 +389,13 @@ class PatternRunner: except Exception: pass - async def _run_escalate(self, idx, output_type, peak, duration, hold, floor): + 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) + await self.bp.scalar_cmd(idx, adj, output_type, feature_index) await asyncio.sleep(duration / steps) if hold > 0: await asyncio.sleep(hold)