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 os
import sys import sys
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field, replace
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -149,6 +149,14 @@ class DeviceController:
for dev in self.client.devices.values(): for dev in self.client.devices.values():
profile = self._match_profile(dev.name) profile = self._match_profile(dev.name)
available = self._detect_outputs(dev) 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( cd = ConnectedDevice(
buttplug_id=dev.index, buttplug_id=dev.index,
buttplug_device=dev, buttplug_device=dev,
@@ -288,10 +296,14 @@ class DeviceController:
if err: if err:
return self._ack(False, err, request_id) return self._ack(False, err, request_id)
task_key = f"{cd.profile.short_name}:{pattern}" # One pattern per device, matching the Android relay engine:
# Cancel existing pattern on this device # starting any pattern cancels whatever pattern was running on
if task_key in self._pattern_tasks: # this device (keying by device:pattern let e.g. a wave and a
self._pattern_tasks[task_key].cancel() # 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": if pattern == "pulse":
task = asyncio.create_task( task = asyncio.create_task(
@@ -326,10 +338,10 @@ class DeviceController:
fallback_stop = True fallback_stop = True
targets = list(self.devices.values()) 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()): for key, task in list(self._pattern_tasks.items()):
if device_name == "all" or fallback_stop or any( 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() task.cancel()
del self._pattern_tasks[key] 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): async def _run_pulse(self, cd: ConnectedDevice, otype, intensity: float, duration: float, feature_index: Optional[int] = None):
try: 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 floor = cd.profile.intensity_floor
adj = self._apply_floor(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 self._write_output(cd, otype, adj, feature_index)
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
await self._write_output(cd, otype, 0, feature_index) 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): async def _run_wave(self, cd: ConnectedDevice, otype, intensity: float, duration: float, feature_index: Optional[int] = None):
try: try:
start = time.time() start = time.monotonic()
floor = cd.profile.intensity_floor floor = cd.profile.intensity_floor
while time.time() - start < duration: # duration <= 0 = run indefinitely until an explicit stop cancels this task.
elapsed = time.time() - start while duration <= 0 or time.monotonic() - start < duration:
elapsed = time.monotonic() - start
# Raw sine: 0.0 to 1.0 # Raw sine: 0.0 to 1.0
raw = (math.sin(elapsed * 2.0) + 1.0) / 2.0 * intensity raw = (math.sin(elapsed * 2.0) + 1.0) / 2.0 * intensity
# Map smoothly above the floor: floor..intensity (never drops below floor) # Map smoothly above the floor: floor..intensity (never drops below floor)
@@ -456,12 +473,20 @@ class DeviceController:
adj = self._apply_floor(val, floor) adj = self._apply_floor(val, floor)
await self._write_output(cd, otype, adj, feature_index) await self._write_output(cd, otype, adj, feature_index)
await asyncio.sleep(duration / steps) 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: if hold_seconds > 0:
await asyncio.sleep(hold_seconds) await asyncio.sleep(hold_seconds)
await cd.buttplug_device.stop() else:
# else: stay at peak until explicit stop command await asyncio.Event().wait() # suspend until cancelled
except asyncio.CancelledError: 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: try:
await cd.buttplug_device.stop() await cd.buttplug_device.stop()
except Exception: except Exception:
@@ -609,17 +634,9 @@ class RelayAgent:
log.info("Authenticated with server!") log.info("Authenticated with server!")
# Send current device list immediately — no need to wait for server scan # No unsolicited device_list here: the server requests a scan
# (we already scanned during controller.connect()) # right after phone_auth, and the scan handler below reports the
if self.controller.devices: # device list in response.
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 # Message loop
async for raw in ws: async for raw in ws:
@@ -658,7 +675,6 @@ class RelayAgent:
"type": "device_list", "type": "device_list",
"devices": device_list, "devices": device_list,
})) }))
log.info(f"Scan complete — sent device list: {len(device_list)} device(s)")
else: else:
log.debug(f"Unknown server message type: {msg_type}") log.debug(f"Unknown server message type: {msg_type}")

View File

@@ -138,6 +138,17 @@ class ButtplugRaw:
break break
if not short_name: if not short_name:
short_name = bp_name.lower().replace(" ", "_") 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 self.name_map[short_name] = idx
log.info(f"Device: {bp_name} -> '{short_name}' (index {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): async def _run_pulse(self, idx, output_type, intensity, duration, floor, feature_index=None):
try: 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 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: if on:
adj = self._floor(intensity, floor) adj = self._floor(intensity, floor)
await self.bp.scalar_cmd(idx, adj, output_type, feature_index) 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): async def _run_wave(self, idx, output_type, intensity, duration, floor, feature_index=None):
try: try:
start = time.time() start = time.monotonic()
while time.time() - start < duration: # duration <= 0 = run indefinitely until an explicit stop cancels this task.
elapsed = time.time() - start while duration <= 0 or time.monotonic() - start < duration:
elapsed = time.monotonic() - start
raw = (math.sin(elapsed * 2.0) + 1.0) / 2.0 * intensity raw = (math.sin(elapsed * 2.0) + 1.0) / 2.0 * intensity
adj = self._floor(raw, floor) adj = self._floor(raw, floor)
await self.bp.scalar_cmd(idx, adj, output_type, feature_index) await self.bp.scalar_cmd(idx, adj, output_type, feature_index)
@@ -397,10 +413,20 @@ class PatternRunner:
adj = self._floor(val, floor) adj = self._floor(val, floor)
await self.bp.scalar_cmd(idx, adj, output_type, feature_index) await self.bp.scalar_cmd(idx, adj, output_type, feature_index)
await asyncio.sleep(duration / steps) 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: if hold > 0:
await asyncio.sleep(hold) await asyncio.sleep(hold)
await self.bp.stop_device(idx) else:
await asyncio.Event().wait() # suspend until cancelled
except asyncio.CancelledError: 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: try:
await self.bp.stop_device(idx) await self.bp.stop_device(idx)
except Exception: except Exception:
@@ -442,11 +468,9 @@ async def relay_loop(server_url, token, intiface_url):
log.info("Authenticated with server!") log.info("Authenticated with server!")
if device_list: # No unsolicited device_list here: the server requests a scan
await ws.send(json.dumps({"type": "device_list", "devices": device_list})) # right after phone_auth, and the scan handler reports the
log.info(f"Sent device list: {len(device_list)} device(s)") # device list in response.
else:
log.warning("No devices to report after scan")
async def process_command(msg, msg_type): async def process_command(msg, msg_type):
"""Handle a command in the background so heartbeats stay responsive.""" """Handle a command in the background so heartbeats stay responsive."""