mirror of
https://github.com/AletheiaVox/signal_bridge_remote.git
synced 2026-10-07 11:28:16 +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:
@@ -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}")
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user