402 lines
14 KiB
Python
402 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Pure state machines for robot-side right-hand button gestures.
|
|
|
|
This module deliberately has no ROS dependencies so the hold/release safety
|
|
rules can be exercised offline before the bridge is deployed on a robot.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import Any, Mapping, Sequence
|
|
|
|
|
|
class GestureToggle:
|
|
"""Toggle one persistent gesture with a guarded long press.
|
|
|
|
A new session always starts release-locked. A continuous release clears
|
|
that lock, then a continuous press toggles the gesture once. The same
|
|
physical press cannot toggle twice; another stable release is required.
|
|
"""
|
|
|
|
def __init__(self, hold_seconds: float, release_seconds: float) -> None:
|
|
if not math.isfinite(hold_seconds) or hold_seconds <= 0.0:
|
|
raise ValueError("gesture hold time must be positive and finite")
|
|
if not math.isfinite(release_seconds) or release_seconds <= 0.0:
|
|
raise ValueError("gesture release time must be positive and finite")
|
|
self.hold_seconds = float(hold_seconds)
|
|
self.release_seconds = float(release_seconds)
|
|
self.active = False
|
|
self.hold_started_at: float | None = None
|
|
self.release_started_at: float | None = None
|
|
self.require_release = True
|
|
self.armed = False
|
|
self.input_healthy = False
|
|
self.button_pressed: bool | None = None
|
|
self._freeze_right_hand = False
|
|
self.toggle_count = 0
|
|
self.last_transition = "initialized"
|
|
|
|
def new_session(self) -> None:
|
|
"""Start release-locked and never carry a gesture across sessions."""
|
|
|
|
self.active = False
|
|
self.hold_started_at = None
|
|
self.release_started_at = None
|
|
self.require_release = True
|
|
self.armed = True
|
|
self.input_healthy = False
|
|
self.button_pressed = None
|
|
self._freeze_right_hand = True
|
|
self.last_transition = "new_session"
|
|
|
|
def disarm(self, reason: str = "disarmed") -> None:
|
|
"""Clear the logical gesture without commanding any hand movement."""
|
|
|
|
self.active = False
|
|
self.hold_started_at = None
|
|
self.release_started_at = None
|
|
self.require_release = True
|
|
self.armed = False
|
|
self.input_healthy = False
|
|
self.button_pressed = None
|
|
self._freeze_right_hand = False
|
|
self.last_transition = reason
|
|
|
|
def update(
|
|
self,
|
|
now: float,
|
|
*,
|
|
armed: bool,
|
|
input_healthy: bool,
|
|
pressed: bool | None,
|
|
) -> bool:
|
|
"""Advance the state and return ``True`` only when a toggle occurs.
|
|
|
|
``pressed=None`` represents a missing or malformed B-button sample. It
|
|
cancels a pending hold and requires a fresh stable release. A hand
|
|
feedback gap behaves the same way, but an already active gesture is
|
|
retained so it can resume through the bridge's measured-feedback slew.
|
|
"""
|
|
|
|
if not math.isfinite(now):
|
|
raise ValueError("gesture clock must be finite")
|
|
if not armed:
|
|
if self.armed or self.active:
|
|
self.disarm()
|
|
return False
|
|
if not self.armed:
|
|
self.new_session()
|
|
|
|
self.input_healthy = bool(input_healthy)
|
|
self.button_pressed = pressed
|
|
if not input_healthy or pressed is None:
|
|
self.hold_started_at = None
|
|
self.release_started_at = None
|
|
self.require_release = True
|
|
if not self.active:
|
|
self._freeze_right_hand = True
|
|
return False
|
|
|
|
if self.require_release:
|
|
self.hold_started_at = None
|
|
if pressed:
|
|
self.release_started_at = None
|
|
return False
|
|
if self.release_started_at is None:
|
|
self.release_started_at = now
|
|
return False
|
|
if now - self.release_started_at >= self.release_seconds:
|
|
self.require_release = False
|
|
self.release_started_at = None
|
|
if not self.active:
|
|
self._freeze_right_hand = False
|
|
return False
|
|
|
|
self.release_started_at = None
|
|
if not pressed:
|
|
if self.hold_started_at is not None:
|
|
# A short/interrupted press is not a toggle. Debounce the
|
|
# release before another hold can start.
|
|
self.hold_started_at = None
|
|
self.release_started_at = now
|
|
self.require_release = True
|
|
if not self.active:
|
|
self._freeze_right_hand = True
|
|
return False
|
|
|
|
if self.hold_started_at is None:
|
|
self.hold_started_at = now
|
|
if not self.active:
|
|
self._freeze_right_hand = True
|
|
return False
|
|
if now - self.hold_started_at < self.hold_seconds:
|
|
return False
|
|
|
|
self.active = not self.active
|
|
self.toggle_count += 1
|
|
self.last_transition = "activated" if self.active else "deactivated"
|
|
# Activation uses the point target. Deactivation starts slewing back
|
|
# to live hand input immediately; the release lock only prevents a
|
|
# second toggle from the same physical press.
|
|
self._freeze_right_hand = False
|
|
self.hold_started_at = None
|
|
self.release_started_at = None
|
|
self.require_release = True
|
|
return True
|
|
|
|
@property
|
|
def freeze_right_hand(self) -> bool:
|
|
"""Whether the bridge must hold its last right-hand command.
|
|
|
|
While inactive, freeze through an incomplete B hold and its required
|
|
release. A completed deactivation starts returning to live input at
|
|
once; its release lock prevents only another toggle. This keeps a
|
|
short B press from leaking through xTELE's processed command stream.
|
|
"""
|
|
|
|
return self.armed and not self.active and self._freeze_right_hand
|
|
|
|
def hold_elapsed(self, now: float) -> float:
|
|
if self.hold_started_at is None:
|
|
return 0.0
|
|
return max(0.0, now - self.hold_started_at)
|
|
|
|
def release_elapsed(self, now: float) -> float:
|
|
if self.release_started_at is None:
|
|
return 0.0
|
|
return max(0.0, now - self.release_started_at)
|
|
|
|
@property
|
|
def state(self) -> str:
|
|
if not self.armed:
|
|
return "disarmed"
|
|
if not self.input_healthy:
|
|
return "input_unhealthy"
|
|
if self.button_pressed is None:
|
|
return "invalid_button"
|
|
if self.require_release:
|
|
return "awaiting_release"
|
|
if self.hold_started_at is not None:
|
|
return "holding_stop" if self.active else "holding_start"
|
|
return "active" if self.active else "idle"
|
|
|
|
|
|
class GuardedMomentaryGesture:
|
|
"""Hold one gesture only while its button is pressed.
|
|
|
|
A newly armed teleoperation session must first observe a stable release,
|
|
preventing a button held before START from moving the hand. Missing or
|
|
malformed input fails closed and requires another stable release.
|
|
"""
|
|
|
|
def __init__(self, release_seconds: float) -> None:
|
|
if not math.isfinite(release_seconds) or release_seconds <= 0.0:
|
|
raise ValueError("gesture release time must be positive and finite")
|
|
self.release_seconds = float(release_seconds)
|
|
self.active = False
|
|
self.release_started_at: float | None = None
|
|
self.require_release = True
|
|
self.armed = False
|
|
self.input_healthy = False
|
|
self.button_pressed: bool | None = None
|
|
self._freeze_right_hand = False
|
|
self.activation_count = 0
|
|
self.last_transition = "initialized"
|
|
|
|
def new_session(self) -> None:
|
|
self.active = False
|
|
self.release_started_at = None
|
|
self.require_release = True
|
|
self.armed = True
|
|
self.input_healthy = False
|
|
self.button_pressed = None
|
|
self._freeze_right_hand = True
|
|
self.last_transition = "new_session"
|
|
|
|
def disarm(self, reason: str = "disarmed") -> None:
|
|
self.active = False
|
|
self.release_started_at = None
|
|
self.require_release = True
|
|
self.armed = False
|
|
self.input_healthy = False
|
|
self.button_pressed = None
|
|
self._freeze_right_hand = False
|
|
self.last_transition = reason
|
|
|
|
def update(
|
|
self,
|
|
now: float,
|
|
*,
|
|
armed: bool,
|
|
input_healthy: bool,
|
|
pressed: bool | None,
|
|
) -> bool:
|
|
"""Advance the gate and report only active/inactive edge changes."""
|
|
|
|
if not math.isfinite(now):
|
|
raise ValueError("gesture clock must be finite")
|
|
was_active = self.active
|
|
if not armed:
|
|
if self.armed or self.active:
|
|
self.disarm()
|
|
return was_active != self.active
|
|
if not self.armed:
|
|
self.new_session()
|
|
|
|
self.input_healthy = bool(input_healthy)
|
|
self.button_pressed = pressed
|
|
if not input_healthy or pressed is None:
|
|
self.active = False
|
|
self.release_started_at = None
|
|
self.require_release = True
|
|
self._freeze_right_hand = True
|
|
self.last_transition = "input_unhealthy"
|
|
return was_active != self.active
|
|
|
|
if self.require_release:
|
|
self.active = False
|
|
self._freeze_right_hand = True
|
|
if pressed:
|
|
self.release_started_at = None
|
|
elif self.release_started_at is None:
|
|
self.release_started_at = now
|
|
elif now - self.release_started_at >= self.release_seconds:
|
|
self.require_release = False
|
|
self.release_started_at = None
|
|
self._freeze_right_hand = False
|
|
self.last_transition = "release_ready"
|
|
return was_active != self.active
|
|
|
|
self.release_started_at = None
|
|
self._freeze_right_hand = False
|
|
self.active = bool(pressed)
|
|
if self.active != was_active:
|
|
if self.active:
|
|
self.activation_count += 1
|
|
self.last_transition = "activated"
|
|
else:
|
|
self.last_transition = "deactivated"
|
|
return was_active != self.active
|
|
|
|
@property
|
|
def freeze_right_hand(self) -> bool:
|
|
return self.armed and not self.active and self._freeze_right_hand
|
|
|
|
def release_elapsed(self, now: float) -> float:
|
|
if self.release_started_at is None:
|
|
return 0.0
|
|
return max(0.0, now - self.release_started_at)
|
|
|
|
@property
|
|
def state(self) -> str:
|
|
if not self.armed:
|
|
return "disarmed"
|
|
if not self.input_healthy:
|
|
return "input_unhealthy"
|
|
if self.button_pressed is None:
|
|
return "invalid_button"
|
|
if self.require_release:
|
|
return "awaiting_release"
|
|
return "active" if self.active else "idle"
|
|
|
|
|
|
def right_a_pressed(data: Mapping[str, Any]) -> bool | None:
|
|
"""Return TS1P right-A state, or ``None`` for a malformed sample."""
|
|
|
|
try:
|
|
buttons = data["button"]
|
|
if not isinstance(buttons, Mapping):
|
|
return None
|
|
right = buttons["right"]
|
|
if not isinstance(right, (list, tuple)) or len(right) < 1:
|
|
return None
|
|
value = right[0]
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, int) and value in (0, 1):
|
|
return bool(value)
|
|
return None
|
|
except (KeyError, TypeError):
|
|
return None
|
|
|
|
|
|
def right_b_pressed(data: Mapping[str, Any]) -> bool | None:
|
|
"""Return TS1P right-B state, or ``None`` for a malformed sample."""
|
|
|
|
try:
|
|
buttons = data["button"]
|
|
if not isinstance(buttons, Mapping):
|
|
return None
|
|
right = buttons["right"]
|
|
if not isinstance(right, (list, tuple)) or len(right) < 2:
|
|
return None
|
|
value = right[1]
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, int) and value in (0, 1):
|
|
return bool(value)
|
|
return None
|
|
except (KeyError, TypeError):
|
|
return None
|
|
|
|
|
|
def normalized_pose_to_positions(
|
|
pose: Sequence[float], minimum: int, maximum: int
|
|
) -> list[int]:
|
|
"""Convert a six-motor normalized BrainCo pose to driver positions."""
|
|
|
|
if len(pose) != 6:
|
|
raise ValueError("gesture pose must contain 6 normalized values")
|
|
values = [float(value) for value in pose]
|
|
if not all(math.isfinite(value) and 0.0 <= value <= 1.0 for value in values):
|
|
raise ValueError("gesture pose must contain finite values in [0, 1]")
|
|
if minimum < 0 or maximum <= minimum:
|
|
raise ValueError("invalid BrainCo position range")
|
|
return [
|
|
int(round(minimum + value * (maximum - minimum))) for value in values
|
|
]
|
|
|
|
|
|
def select_hand_target(
|
|
source_target: Sequence[int],
|
|
previous_command: Sequence[int] | None,
|
|
gesture_target: Sequence[int],
|
|
*,
|
|
active: bool,
|
|
freeze: bool,
|
|
) -> list[int]:
|
|
"""Apply one hand's gesture override or fail-safe input freeze."""
|
|
|
|
source = list(source_target)
|
|
gesture = list(gesture_target)
|
|
if len(source) != 6 or len(gesture) != 6:
|
|
raise ValueError("right-hand targets must each contain 6 positions")
|
|
if active:
|
|
return gesture
|
|
if freeze:
|
|
previous = [] if previous_command is None else list(previous_command)
|
|
if len(previous) != 6:
|
|
raise ValueError("cannot freeze without a complete previous command")
|
|
return previous
|
|
return source
|
|
|
|
|
|
def select_right_hand_target(
|
|
source_target: Sequence[int],
|
|
previous_command: Sequence[int] | None,
|
|
gesture_target: Sequence[int],
|
|
*,
|
|
active: bool,
|
|
freeze: bool,
|
|
) -> list[int]:
|
|
"""Backward-compatible name for the original right-only implementation."""
|
|
|
|
return select_hand_target(
|
|
source_target,
|
|
previous_command,
|
|
gesture_target,
|
|
active=active,
|
|
freeze=freeze,
|
|
)
|