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>
This commit is contained in:
Aletheia
2026-07-07 20:11:01 +02:00
parent 263e6e9b75
commit 136920b717
4 changed files with 101 additions and 31 deletions

View File

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

View File

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