mirror of
https://github.com/AletheiaVox/signal_bridge_remote.git
synced 2026-10-07 03:18:17 +08:00
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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user