diff --git a/phone/relay_client.py b/phone/relay_client.py index 8c7c352..72a57b6 100644 --- a/phone/relay_client.py +++ b/phone/relay_client.py @@ -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() # ════════════════════════════════════════════════════════════════════════ diff --git a/termux_relay_v3.py b/termux_relay_v3.py index 3e54e60..91f7571 100644 --- a/termux_relay_v3.py +++ b/termux_relay_v3.py @@ -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)