fix: pattern lifecycle — indefinite durations, escalate hold contract, monotonic clocks

Ports the Android relay engine's pattern semantics (its 2026-06/2026-07
fix batches) to both Python relay clients:

- duration <= 0 on pulse/wave now runs the pattern until an explicit
  stop, matching how plain commands treat duration=0. Previously the
  loop condition was already false at start, so the pattern silently
  did nothing.
- escalate now honours its documented hold contract: hold_seconds <= 0
  suspends at peak until cancelled, > 0 holds then stops. Both paths
  run through a finally that stops the device, so an error mid-ramp can
  no longer leave hardware running at the last intensity it reached.
  (The naive finally alone would stop the device the moment the ramp
  finished — the same regression the Android app shipped and fixed.)
- Pattern timing uses time.monotonic() instead of time.time(): an NTP
  wall-clock jump could stretch, truncate, or instantly end a pattern.
- relay_client.py now keys pattern tasks by device (one pattern per
  device, as in the Android engine) instead of device:pattern, which
  let a wave and a pulse fight over the same actuator.
- Duplicate same-model devices get suffixed short names (lush, lush_2)
  instead of silently replacing each other.
- Dropped the unsolicited device_list push after auth — the server
  requests a scan on connect and the scan handler answers it; the
  double-send raced session registration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aletheia
2026-07-07 20:14:32 +02:00
parent 07e5f9c38c
commit 7c61ecb410
2 changed files with 78 additions and 38 deletions

View File

@@ -26,7 +26,7 @@ import math
import os
import sys
import time
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Optional
@@ -149,6 +149,14 @@ class DeviceController:
for dev in self.client.devices.values():
profile = self._match_profile(dev.name)
available = self._detect_outputs(dev)
# Two devices of the same model would otherwise collide on the
# short name, silently hiding one of them — suffix duplicates
# (lush, lush_2, …) so both stay addressable.
if profile.short_name in self.devices:
n = 2
while f"{profile.short_name}_{n}" in self.devices:
n += 1
profile = replace(profile, short_name=f"{profile.short_name}_{n}")
cd = ConnectedDevice(
buttplug_id=dev.index,
buttplug_device=dev,
@@ -288,10 +296,14 @@ class DeviceController:
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:
self._pattern_tasks[task_key].cancel()
# One pattern per device, matching the Android relay engine:
# starting any pattern cancels whatever pattern was running on
# this device (keying by device:pattern let e.g. a wave and a
# pulse fight over the same actuator).
task_key = cd.profile.short_name
old_task = self._pattern_tasks.pop(task_key, None)
if old_task:
old_task.cancel()
if pattern == "pulse":
task = asyncio.create_task(
@@ -326,10 +338,10 @@ class DeviceController:
fallback_stop = True
targets = list(self.devices.values())
# Cancel relevant pattern tasks
# Cancel relevant pattern tasks (keys are device short names)
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
cd.profile.short_name == key for cd in targets
):
task.cancel()
del self._pattern_tasks[key]
@@ -398,10 +410,14 @@ class DeviceController:
async def _run_pulse(self, cd: ConnectedDevice, otype, intensity: float, duration: float, feature_index: Optional[int] = None):
try:
start = time.time()
# time.monotonic, not time.time: wall clock jumps on NTP sync,
# which would stretch or truncate the pattern window.
start = time.monotonic()
floor = cd.profile.intensity_floor
adj = self._apply_floor(intensity, floor)
while time.time() - start < duration:
# duration <= 0 = run indefinitely until an explicit stop cancels
# this task, matching how plain commands treat duration=0.
while duration <= 0 or time.monotonic() - start < duration:
await self._write_output(cd, otype, adj, feature_index)
await asyncio.sleep(0.5)
await self._write_output(cd, otype, 0, feature_index)
@@ -416,10 +432,11 @@ class DeviceController:
async def _run_wave(self, cd: ConnectedDevice, otype, intensity: float, duration: float, feature_index: Optional[int] = None):
try:
start = time.time()
start = time.monotonic()
floor = cd.profile.intensity_floor
while time.time() - start < duration:
elapsed = time.time() - start
# duration <= 0 = run indefinitely until an explicit stop cancels this task.
while duration <= 0 or time.monotonic() - start < duration:
elapsed = time.monotonic() - 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)
@@ -456,12 +473,20 @@ class DeviceController:
adj = self._apply_floor(val, floor)
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
# At peak now. hold_seconds > 0 = hold at peak then stop (via the
# finally below); <= 0 = stay at peak until an explicit stop
# cancels this task. Without the indefinite wait the finally would
# stop the device the moment the ramp tops out.
if hold_seconds > 0:
await asyncio.sleep(hold_seconds)
await cd.buttplug_device.stop()
# else: stay at peak until explicit stop command
else:
await asyncio.Event().wait() # suspend until cancelled
except asyncio.CancelledError:
pass
finally:
# Same finally treatment as pulse/wave: an unexpected error
# mid-ramp must not leave the device running at the last
# intensity it reached.
try:
await cd.buttplug_device.stop()
except Exception:
@@ -609,17 +634,9 @@ class RelayAgent:
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?")
# No unsolicited device_list here: the server requests a scan
# right after phone_auth, and the scan handler below reports the
# device list in response.
# Message loop
async for raw in ws:
@@ -658,7 +675,6 @@ class RelayAgent:
"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}")

View File

@@ -138,6 +138,17 @@ class ButtplugRaw:
break
if not short_name:
short_name = bp_name.lower().replace(" ", "_")
# Two devices of the same model would otherwise collide on the short
# name, silently hiding one — suffix duplicates (lush, lush_2, …).
if short_name in self.name_map:
base = short_name
n = 2
while f"{base}_{n}" in self.name_map:
n += 1
short_name = f"{base}_{n}"
# Alias the profile so intensity floors still apply to the twin
if base in self.profiles:
self.profiles[short_name] = self.profiles[base]
self.name_map[short_name] = idx
log.info(f"Device: {bp_name} -> '{short_name}' (index {idx})")
@@ -354,9 +365,13 @@ class PatternRunner:
async def _run_pulse(self, idx, output_type, intensity, duration, floor, feature_index=None):
try:
start = time.time()
# time.monotonic, not time.time: wall clock jumps on NTP sync,
# which would stretch or truncate the pattern window.
start = time.monotonic()
on = True
while time.time() - start < duration:
# duration <= 0 = run indefinitely until an explicit stop cancels
# this task, matching how plain commands treat duration=0.
while duration <= 0 or time.monotonic() - start < duration:
if on:
adj = self._floor(intensity, floor)
await self.bp.scalar_cmd(idx, adj, output_type, feature_index)
@@ -374,9 +389,10 @@ class PatternRunner:
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
start = time.monotonic()
# duration <= 0 = run indefinitely until an explicit stop cancels this task.
while duration <= 0 or time.monotonic() - start < duration:
elapsed = time.monotonic() - 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)
@@ -397,10 +413,20 @@ class PatternRunner:
adj = self._floor(val, floor)
await self.bp.scalar_cmd(idx, adj, output_type, feature_index)
await asyncio.sleep(duration / steps)
# At peak now. hold > 0 = hold at peak then stop (via the finally
# below); <= 0 = stay at peak until an explicit stop cancels this
# task. Without the indefinite wait the finally would stop the
# device the moment the ramp tops out.
if hold > 0:
await asyncio.sleep(hold)
await self.bp.stop_device(idx)
else:
await asyncio.Event().wait() # suspend until cancelled
except asyncio.CancelledError:
pass
finally:
# Same finally treatment as _run_pulse/_run_wave: an unexpected
# error mid-ramp must not leave the device running at the last
# intensity it reached.
try:
await self.bp.stop_device(idx)
except Exception:
@@ -442,11 +468,9 @@ async def relay_loop(server_url, token, intiface_url):
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")
# No unsolicited device_list here: the server requests a scan
# right after phone_auth, and the scan handler reports the
# device list in response.
async def process_command(msg, msg_type):
"""Handle a command in the background so heartbeats stay responsive."""