mirror of
https://github.com/AletheiaVox/signal_bridge_remote.git
synced 2026-10-07 03:18:17 +08:00
fix: direct commands supersede running patterns; duration auto-stops are tracked
Two races the ported Android engine also has (flagged for backport there): - A direct output command (vibrate etc.) now cancels the pattern running on the target device. Previously the pattern loop kept overwriting the direct command's value every few hundred ms, so the command appeared to do nothing. - Duration auto-stops are now tracked per (device, channel, feature) and cancelled when a newer command, pattern, or stop takes over that channel. Previously an auto-stop from an earlier command could fire minutes later and silently zero output the user believed was running — e.g. kill an escalate hold with no ack and no log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -109,7 +109,9 @@ class DeviceController:
|
||||
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._pattern_tasks: dict[str, asyncio.Task] = {} # short_name → task
|
||||
# Pending duration auto-stops: (short_name, otype, feature_index) → task
|
||||
self._timed_stops: dict[tuple, asyncio.Task] = {}
|
||||
self._connected = False
|
||||
|
||||
async def connect(self):
|
||||
@@ -255,14 +257,26 @@ class DeviceController:
|
||||
err = self._check_feature(cd, otype, feature_index)
|
||||
if err:
|
||||
return self._ack(False, err, request_id)
|
||||
|
||||
# A direct command supersedes whatever pattern is running on this
|
||||
# device (otherwise the pattern loop keeps overwriting the new
|
||||
# value), and replaces any pending auto-stop on this channel (a
|
||||
# leftover auto-stop from an earlier command would silently kill
|
||||
# this one partway through).
|
||||
self._cancel_pattern(cd.profile.short_name)
|
||||
self._cancel_timed_stops(cd.profile.short_name, otype, feature_index)
|
||||
|
||||
try:
|
||||
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
|
||||
# Auto-stop after duration — tracked so later commands cancel it
|
||||
if duration > 0:
|
||||
asyncio.create_task(self._timed_stop(cd, otype, duration, feature_index))
|
||||
key = (cd.profile.short_name, otype, feature_index)
|
||||
self._timed_stops[key] = asyncio.create_task(
|
||||
self._timed_stop(cd, otype, duration, feature_index)
|
||||
)
|
||||
|
||||
names = ", ".join(cd.profile.short_name for cd in targets)
|
||||
return self._ack(
|
||||
@@ -299,11 +313,11 @@ class DeviceController:
|
||||
# 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).
|
||||
# pulse fight over the same actuator), plus any pending
|
||||
# duration auto-stops that would fire mid-pattern.
|
||||
task_key = cd.profile.short_name
|
||||
old_task = self._pattern_tasks.pop(task_key, None)
|
||||
if old_task:
|
||||
old_task.cancel()
|
||||
self._cancel_pattern(task_key)
|
||||
self._cancel_timed_stops(task_key)
|
||||
|
||||
if pattern == "pulse":
|
||||
task = asyncio.create_task(
|
||||
@@ -347,6 +361,9 @@ class DeviceController:
|
||||
del self._pattern_tasks[key]
|
||||
|
||||
for cd in targets:
|
||||
# A pending duration auto-stop must not fire after this stop —
|
||||
# it could zero a channel a later command has since restarted.
|
||||
self._cancel_timed_stops(cd.profile.short_name)
|
||||
try:
|
||||
await cd.buttplug_device.stop()
|
||||
except Exception:
|
||||
@@ -523,8 +540,37 @@ class DeviceController:
|
||||
)
|
||||
return None
|
||||
|
||||
def _cancel_pattern(self, short_name: str):
|
||||
"""Cancel the running pattern task on a device, if any."""
|
||||
old = self._pattern_tasks.pop(short_name, None)
|
||||
if old:
|
||||
old.cancel()
|
||||
|
||||
def _cancel_timed_stops(self, short_name: str, otype=None, feature_index: Optional[int] = None):
|
||||
"""Cancel pending duration auto-stops for a device.
|
||||
|
||||
With otype=None every channel's auto-stop is cancelled (a pattern or
|
||||
stop takes over the whole device). With an otype, only auto-stops
|
||||
that overlap that channel are cancelled — a feature_index of None on
|
||||
either side overlaps everything on the channel.
|
||||
"""
|
||||
for key in list(self._timed_stops):
|
||||
k_name, k_otype, k_feature = key
|
||||
if k_name != short_name:
|
||||
continue
|
||||
if otype is not None:
|
||||
if k_otype != otype:
|
||||
continue
|
||||
if (k_feature is not None and feature_index is not None
|
||||
and k_feature != feature_index):
|
||||
continue
|
||||
task = self._timed_stops.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
|
||||
async def _timed_stop(self, cd: ConnectedDevice, otype, duration: float, feature_index: Optional[int] = None):
|
||||
await asyncio.sleep(duration)
|
||||
self._timed_stops.pop((cd.profile.short_name, otype, feature_index), None)
|
||||
try:
|
||||
await self._write_output(cd, otype, 0, feature_index)
|
||||
except Exception:
|
||||
@@ -564,10 +610,13 @@ class DeviceController:
|
||||
await cd.buttplug_device.stop()
|
||||
except Exception:
|
||||
pass
|
||||
# Cancel all patterns
|
||||
# Cancel all patterns and pending auto-stops
|
||||
for task in self._pattern_tasks.values():
|
||||
task.cancel()
|
||||
self._pattern_tasks.clear()
|
||||
for task in self._timed_stops.values():
|
||||
task.cancel()
|
||||
self._timed_stops.clear()
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -230,6 +230,8 @@ class PatternRunner:
|
||||
def __init__(self, bp):
|
||||
self.bp = bp
|
||||
self.active_tasks = {}
|
||||
# Pending duration auto-stops: (short_name, output_type, feature_index) -> task
|
||||
self.timed_stops = {}
|
||||
|
||||
def _floor(self, raw, floor):
|
||||
if raw <= 0.01:
|
||||
@@ -255,6 +257,37 @@ class PatternRunner:
|
||||
if task:
|
||||
task.cancel()
|
||||
|
||||
def cancel_timed_stops(self, device="all", output_type=None, feature_index=None):
|
||||
"""Cancel pending duration auto-stops.
|
||||
|
||||
With output_type=None every channel's auto-stop for the device is
|
||||
cancelled (a pattern or stop takes over the whole device). With an
|
||||
output_type, only auto-stops that overlap that channel are cancelled —
|
||||
a feature_index of None on either side overlaps everything.
|
||||
"""
|
||||
for key in list(self.timed_stops):
|
||||
k_name, k_otype, k_feature = key
|
||||
if device != "all" and k_name != device:
|
||||
continue
|
||||
if output_type is not None:
|
||||
if k_otype != output_type:
|
||||
continue
|
||||
if (k_feature is not None and feature_index is not None
|
||||
and k_feature != feature_index):
|
||||
continue
|
||||
task = self.timed_stops.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
|
||||
async def _timed_stop(self, short_name, idx, output_type, duration, feature_index=None):
|
||||
await asyncio.sleep(duration)
|
||||
self.timed_stops.pop((short_name, output_type, feature_index), None)
|
||||
try:
|
||||
await self.bp.scalar_cmd(idx, 0.0, output_type, feature_index)
|
||||
log.info(f"Auto-stopped {short_name} ({output_type}) after {duration}s")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def run_command(self, cmd):
|
||||
msg_type = cmd.get("type", "")
|
||||
request_id = cmd.get("request_id", "")
|
||||
@@ -290,17 +323,25 @@ class PatternRunner:
|
||||
floor = profile.get("intensity_floor", 0.0)
|
||||
adj = self._floor(intensity, floor)
|
||||
log.info(f" {short_name}: raw={intensity} floor={floor} adjusted={adj}")
|
||||
|
||||
# A direct command supersedes whatever pattern is running on this
|
||||
# device (otherwise the pattern loop keeps overwriting the new
|
||||
# value), and replaces any pending auto-stop on this channel (a
|
||||
# leftover auto-stop from an earlier command would silently kill
|
||||
# this one partway through).
|
||||
await self.cancel_patterns(short_name)
|
||||
self.cancel_timed_stops(short_name, output_type, feature_index)
|
||||
|
||||
await self.bp.scalar_cmd(idx, adj, output_type, feature_index)
|
||||
|
||||
names = [t[0] for t in targets]
|
||||
# Auto-stop after duration — tracked so later commands cancel it
|
||||
if duration > 0:
|
||||
key = (short_name, output_type, feature_index)
|
||||
self.timed_stops[key] = asyncio.create_task(
|
||||
self._timed_stop(short_name, idx, output_type, duration, feature_index)
|
||||
)
|
||||
|
||||
if duration > 0:
|
||||
async def auto_stop():
|
||||
await asyncio.sleep(duration)
|
||||
for sn, ix in targets:
|
||||
await self.bp.stop_device(ix)
|
||||
log.info(f"Auto-stopped after {duration}s")
|
||||
asyncio.create_task(auto_stop())
|
||||
names = [t[0] for t in targets]
|
||||
|
||||
return self._ack(True, "Set " + output_type + " " + str(intensity) + " on " + ", ".join(names), request_id, names)
|
||||
|
||||
@@ -319,6 +360,9 @@ class PatternRunner:
|
||||
|
||||
for short_name, idx in targets:
|
||||
await self.cancel_patterns(short_name)
|
||||
# Pending duration auto-stops would fire mid-pattern and zero the
|
||||
# channel the pattern is driving.
|
||||
self.cancel_timed_stops(short_name)
|
||||
profile = self.bp.profiles.get(short_name, {})
|
||||
floor = profile.get("intensity_floor", 0.0)
|
||||
|
||||
@@ -346,6 +390,7 @@ class PatternRunner:
|
||||
|
||||
for short_name, idx in targets:
|
||||
await self.cancel_patterns(short_name)
|
||||
self.cancel_timed_stops(short_name)
|
||||
await self.bp.stop_device(idx)
|
||||
|
||||
if fallback:
|
||||
@@ -355,6 +400,7 @@ class PatternRunner:
|
||||
if device == "all":
|
||||
await self.bp.stop_all()
|
||||
self.active_tasks.clear()
|
||||
self.cancel_timed_stops("all")
|
||||
|
||||
names = [t[0] for t in targets]
|
||||
return self._ack(True, "Stopped " + (", ".join(names) if names else "all"), request_id, names)
|
||||
|
||||
Reference in New Issue
Block a user