Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
25f1dee
Add `trossen` and `realsense` extras
DarksaCY Aug 26, 2026
3d4216f
Keep an input source with no gamepad out of the button stream
DarksaCY Aug 26, 2026
ddacc32
Add the Trossen WidowX AI arm driver
DarksaCY Aug 26, 2026
4b5af61
Saturate the grip the Trossen arm reports to the range its port carries
DarksaCY Aug 26, 2026
cd3aa37
Read the controller clock to tell a dropped link from a quiet one
DarksaCY Aug 26, 2026
7c56d36
Say what a dropped link leaves the Trossen driver in
DarksaCY Aug 26, 2026
fbabf3c
Open a new session with the Trossen arm when the link goes down
DarksaCY Aug 26, 2026
c48b48a
Take Cartesian commands on the Trossen arm
DarksaCY Aug 26, 2026
a86a36e
Measure the Cartesian step cap against the arm, not the last target
DarksaCY Aug 26, 2026
64ea660
Let the Trossen fake answer for the controller's error information
DarksaCY Aug 26, 2026
7474cdd
Interpolate a streamed Trossen setpoint across the tick it spans
DarksaCY Aug 26, 2026
42ad0bc
Stand the Trossen driver down before a joint reaches its own limit
DarksaCY Aug 26, 2026
aea24ed
Solve the Trossen arm's kinematics in the driver
DarksaCY Aug 26, 2026
3abc4b5
Keep a Trossen Cartesian target in the shape the arm is already in
DarksaCY Aug 26, 2026
0c4a207
Put the Trossen model on a lit ground plane
DarksaCY Aug 26, 2026
49b6f42
Say when a Trossen joint reads outside the range the controller reports
DarksaCY Aug 26, 2026
5d3d119
Take the arm's own following error out of the Trossen driver's loops
DarksaCY Aug 26, 2026
bf51816
Let the Trossen firmware plan the moves somebody waits on
DarksaCY Aug 26, 2026
84cbc03
Collect data on the Trossen arm from a Quest
DarksaCY Aug 26, 2026
8a244c1
Read a RealSense camera
DarksaCY Aug 27, 2026
f00db89
Say which controllers a WebXR payload can leave out
DarksaCY Aug 27, 2026
efb8fcf
Write the RealSense driver text in Simplified Technical English
DarksaCY Aug 27, 2026
6d48b69
Let a camera frame reach the headset under `spawn`
DarksaCY Aug 27, 2026
f180734
Say when teleoperation starts and when the arm holds still
DarksaCY Aug 27, 2026
5143389
Say once that the arm cannot follow the poses it is given
DarksaCY Aug 27, 2026
078efc4
Say nothing from a control system the operator interrupts
DarksaCY Aug 27, 2026
c845f15
Let a streamed target ask the arm for the error it holds itself up with
DarksaCY Aug 27, 2026
3fafb3e
Turn the end effector against an arm that sags
DarksaCY Aug 27, 2026
c541aad
Say that the Trossen operator stands in front of the arm
DarksaCY Aug 27, 2026
ae5ac37
Hold the viewer scene back until the simulator work
DarksaCY Aug 28, 2026
5de6870
Take the camera out of the Trossen station
DarksaCY Aug 28, 2026
5aedd35
Refuse a controller that cannot drive the arm
DarksaCY Aug 28, 2026
798ca04
Name the robot the same way in every driver
DarksaCY Aug 28, 2026
130f15e
Put each Trossen helper beside what uses it
DarksaCY Aug 28, 2026
7b4e67f
Put the streamed goal time back to one tick
DarksaCY Aug 28, 2026
f38a7a3
Give a Linux video frame the shape the other cameras give
DarksaCY Aug 28, 2026
13d9faf
Drop a video buffer that arrives short of a frame
DarksaCY Aug 28, 2026
1537579
Name the four cameras of the Trossen station
DarksaCY Aug 28, 2026
cc43bfa
Show the Trossen station its cameras again
DarksaCY Aug 28, 2026
2473f5a
Let a vendor that cannot load stand in for one that is absent
DarksaCY Aug 28, 2026
6a42261
Let a connection wrapper change the type it carries
DarksaCY Aug 28, 2026
5a8bafe
Keep the video library's ioctl log out of a debug run
DarksaCY Aug 28, 2026
ec97d66
Say that an interrupt tore a queue's connection
DarksaCY Aug 28, 2026
c17528b
Let the end effector turn as readily as it moves
DarksaCY Aug 28, 2026
387ef80
Say that the Trossen operator stands behind the arm
DarksaCY Aug 28, 2026
d3c60e3
Ask a streamed setpoint for a tenth of each joint's limit again
DarksaCY Aug 28, 2026
979b097
Stop a process that took an interrupt from reading its neighbours' me…
DarksaCY Aug 28, 2026
ed67010
Take the hand's own shake out of what the arm follows
DarksaCY Aug 28, 2026
9d56018
Read a Trossen arm the operator holds as a teleoperation source
DarksaCY Aug 31, 2026
05fdae3
Record an episode the leader arm drives
DarksaCY Sep 2, 2026
d7d8a63
Take the leader to the start pose with its follower
DarksaCY Sep 2, 2026
9766a67
Drop what the rig reported while it travelled
DarksaCY Sep 2, 2026
d61abb9
Ask a device for the setpoint it is streamed now
DarksaCY Sep 2, 2026
9b4a76a
Take the gripper friction off the operator's hand
DarksaCY Sep 2, 2026
d18d48d
Give the station's leader the gripper friction it was tuned to
DarksaCY Sep 2, 2026
58bf2de
Let a streamed setpoint ask for three tenths of a joint's speed
DarksaCY Sep 2, 2026
15eecb7
Let a policy drive the Trossen station
DarksaCY Sep 2, 2026
4029f8e
Serve a checkpoint trained on the station's own codec
DarksaCY Sep 2, 2026
b56d4c8
Take the station's branch to current main
DarksaCY Sep 2, 2026
fe5734f
Take the review of the leader station
DarksaCY Sep 3, 2026
e6718c0
Answer the second review round on the leader station
DarksaCY Sep 7, 2026
109d35c
Say why a camera buffer that is not a frame is counted, not raised
DarksaCY Sep 7, 2026
9f2ade0
Let the session commands act, and keep the state at the caller
DarksaCY Sep 7, 2026
adcedc2
Drop the buttons a travelling rig was pressed at, and close the leade…
DarksaCY Sep 7, 2026
3b4a3dc
Merge upstream/main into the leader station
DarksaCY Sep 7, 2026
3e50a8d
Follow main's move of the eval and roboarm keys
DarksaCY Sep 7, 2026
9bece6a
Say the interrupt test takes the one receiver it asked for
DarksaCY Sep 7, 2026
fe039d2
Take the type errors this branch removed out of the baseline
DarksaCY Sep 7, 2026
fef8bc7
Walk out the travel a Cartesian delta asks for
DarksaCY Sep 8, 2026
17719ae
Answer the review round the rebase brought
DarksaCY Sep 8, 2026
f3da6d9
Pace the fingers, clip a leader goal, and drop a pose that is not a n…
DarksaCY Sep 8, 2026
7d1d186
Let the anchor go when the arm stands down
DarksaCY Sep 8, 2026
fe66bf7
Drop the fake SDK method nothing calls
DarksaCY Sep 8, 2026
23c595f
Let the fake device record itself when it is opened
DarksaCY Sep 8, 2026
e2f2f7f
Take what the arm PR settled: the model, the home travel, and taking …
DarksaCY Sep 8, 2026
28f43f0
Name the mesh dictionary once, and pin the two models against each other
DarksaCY Sep 8, 2026
70abd19
Give the panda mesh names a type sorted can order
DarksaCY Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 1 addition & 97 deletions .basedpyright/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -1155,30 +1155,6 @@
}
],
"./positronic/data_collection.py": [
{
"code": "reportOptionalOperand",
"range": {
"startColumn": 25,
"endColumn": 48,
"lineCount": 1
}
},
{
"code": "reportOptionalMemberAccess",
"range": {
"startColumn": 89,
"endColumn": 92,
"lineCount": 1
}
},
{
"code": "reportPossiblyUnboundVariable",
"range": {
"startColumn": 64,
"endColumn": 80,
"lineCount": 1
}
},
{
"code": "reportReturnType",
"range": {
Expand Down Expand Up @@ -1211,14 +1187,6 @@
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 73,
"endColumn": 99,
"lineCount": 1
}
},
{
"code": "reportAttributeAccessIssue",
"range": {
Expand All @@ -1227,14 +1195,6 @@
"lineCount": 1
}
},
{
"code": "reportAttributeAccessIssue",
"range": {
"startColumn": 66,
"endColumn": 71,
"lineCount": 1
}
},
{
"code": "reportAssignmentType",
"range": {
Expand Down Expand Up @@ -3331,30 +3291,6 @@
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 46,
"endColumn": 50,
"lineCount": 1
}
},
{
"code": "reportAttributeAccessIssue",
"range": {
"startColumn": 43,
"endColumn": 49,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 32,
"endColumn": 38,
"lineCount": 1
}
},
{
"code": "reportReturnType",
"range": {
Expand Down Expand Up @@ -3997,22 +3933,6 @@
}
],
"./positronic/drivers/webxr.py": [
{
"code": "reportArgumentType",
"range": {
"startColumn": 12,
"endColumn": 38,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 12,
"endColumn": 30,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
Expand All @@ -4021,22 +3941,6 @@
"lineCount": 1
}
},
{
"code": "reportAttributeAccessIssue",
"range": {
"startColumn": 59,
"endColumn": 70,
"lineCount": 1
}
},
{
"code": "reportAttributeAccessIssue",
"range": {
"startColumn": 59,
"endColumn": 70,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
Expand Down Expand Up @@ -6977,4 +6881,4 @@
}
]
}
}
}
1 change: 1 addition & 0 deletions pimm/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
'boto3',
's3transfer', # per part of a multipart upload
'asyncio', # per selector event, under its debug mode
'linuxpy', # per ioctl, which is several times a frame for every camera
)


Expand Down
23 changes: 23 additions & 0 deletions pimm/tests/test_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,17 @@ def run(self, should_stop, clock):
yield Sleep(0.001)


class Interrupted(ControlSystem):
"""Ends on the interrupt an operator sends, which reaches every process of a run at once."""

def __init__(self):
self.add = ControlSystemHandler[tuple[int, int], int](self)

def run(self, should_stop, clock):
yield Sleep(0.001)
raise KeyboardInterrupt


class TestAllOf:
def test_one_answer_stands_for_many(self, bound):
caller, handler = bound
Expand Down Expand Up @@ -267,6 +278,18 @@ def test_a_handler_whose_system_ends_answers_the_calls_it_never_reached(self):
world.run([client, deaf])
assert isinstance(client.results[0], HandlerStopped)

def test_a_handler_the_operator_interrupts_answers_nothing(self):
"""An interrupt can land inside a manager call, and the connection then carries half a message.

Reading it again returns the tail of another, so an interrupted system says nothing at all.
"""
client, interrupted = Client([(1, 2)]), Interrupted()
with World(virtual_time=True) as world:
world.connect(client.add, interrupted.add)
with pytest.raises(KeyboardInterrupt):
world.run([client, interrupted])
assert client.results == []

def test_a_stopped_handler_survives_the_trip_to_another_process(self):
"""A reply crosses a pipe as pickle, and an exception is rebuilt by calling its class with its args."""
assert isinstance(pickle.loads(pickle.dumps(HandlerStopped())), HandlerStopped)
Expand Down
19 changes: 19 additions & 0 deletions pimm/tests/test_world.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import pytest

import pimm.world
from pimm.core import (
ControlSystem,
ControlSystemEmitter,
Expand Down Expand Up @@ -99,6 +100,24 @@ def read_from_buffer(self, buffer: memoryview | bytes) -> None:
self.value = struct.unpack('d', buffer[:8])[0]


def test_a_process_that_took_an_interrupt_stops_talking_to_the_manager(monkeypatch):
"""An interrupt can land inside a call to the manager, and that connection then holds half a message.

Reading it again returns what another call asked for, so a reader takes a value off a channel it never
subscribed to. A process that has taken one neither reads nor sends after it.
"""
with World() as world:
emitter, receiver = world.mp_pipes()
assert not isinstance(receiver, list) # one receiver, which is what `mp_pipes` makes by default
emitter.emit('before', ts=1)
assert receiver.read() is not None

monkeypatch.setattr(pimm.world, '_interrupted', True)

emitter.emit('after', ts=2) # dropped, not sent
assert receiver.read() is None


class TestQueueEmitter:
"""Test the QueueEmitter class."""

Expand Down
54 changes: 48 additions & 6 deletions pimm/world.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import traceback
from collections import Counter, defaultdict, deque
from collections.abc import Callable, Iterator, Mapping
from contextlib import contextmanager
from enum import IntEnum
from multiprocessing import resource_tracker
from multiprocessing.managers import ValueProxy
Expand Down Expand Up @@ -42,6 +43,7 @@
logger = logging.getLogger(__name__)

T = TypeVar('T')
U = TypeVar('U')
Req = TypeVar('Req')
Res = TypeVar('Res')

Expand Down Expand Up @@ -75,6 +77,28 @@ def emit(self, data: T, ts: int = -1):
pass


# Set in a process that has taken an interrupt. An interrupt can land inside a call to the manager, and
# that connection then holds half a message: the next call over it returns what another one asked for, so
# a reader takes a value from a channel it never subscribed to. Nothing may be sent or read after it.
_interrupted = False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset the interrupt latch for fresh worlds

Rule hidden-dependency violated:
MultiprocessEmitter and MultiprocessReceiver silently depend on no earlier World having set this process-global latch. If a caller catches a KeyboardInterrupt raised inside a transport and later creates a fresh World in the same process, _interrupted remains true, so every new multiprocess channel drops emissions and returns no data even though its connections are uncorrupted. Scope the latch to the affected world/transports, or reset it when constructing a new world.

AGENTS.md reference: AGENTS.md:L7-L8

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted, and left. This is the module-global _interrupted that #691 owns, and the same finding stands open on that PR (#691). Scoping it to a manager means threading that state into both transports and through World.connect, which is a change about World, not about ending a run on an interrupt. The process that takes SIGINT is going down; a caller that catches it and builds a fresh World is the case, and it is worth its own change.



@contextmanager
def _noting_interrupt() -> Iterator[None]:
"""Record an interrupt taken inside the block, and let it go on.

A connection is torn by an interrupt that lands in the middle of a call over it, so every process that
reaches a transport records its own -- there is nowhere else the tearing can happen, and no process has
to have had a handler installed for it.
"""
global _interrupted
try:
yield
except KeyboardInterrupt:
_interrupted = True
raise


class MultiprocessEmitter(SignalEmitter[T]):
"""Signal emitter that transparently bridges processes.

Expand Down Expand Up @@ -188,9 +212,12 @@ def _emit_shared_memory(self, data: SMCompliant, ts: int) -> bool:

return True

@_noting_interrupt()
def emit(self, data: T, ts: int = -1):
if _interrupted:
return
ts = ts if ts >= 0 else self._clock.now_ns()
mode = self._ensure_mode(data)
mode = self._ensure_mode(data) # itself a call to the manager, so it sits inside the guard

if mode is TransportMode.SHARED_MEMORY:
if not isinstance(data, SMCompliant):
Expand Down Expand Up @@ -275,6 +302,10 @@ def _read_queue(self) -> Message[T] | None:
except Empty:
message = None
else:
if not isinstance(message, Message):
# An interrupt that lands inside a manager call leaves that connection holding half a
# message, and every read after it comes back as whatever another call asked for.
raise ConnectionError(f'the queue was read after an interrupt tore its connection: {message!r}')
self._last_queue_message = Message(message.data, message.ts, True)
if self._mode is TransportMode.UNDECIDED:
self._mode = TransportMode.QUEUE
Expand Down Expand Up @@ -332,8 +363,11 @@ def _read_shared_memory(self) -> Message[T] | None:
self._up_value.value = False
return Message(data=self._out_value, ts=self._ts_value.value, updated=updated) # instead of True

@_noting_interrupt()
def read(self) -> Message[T] | None:
mode = self.transport_mode
if _interrupted:
return None
mode = self.transport_mode # itself a call to the manager, so it sits inside the guard

if mode is TransportMode.SHARED_MEMORY:
return self._read_shared_memory()
Expand Down Expand Up @@ -456,11 +490,19 @@ def __init__(self, cs: ControlSystem):
self.__name__ = f'{type(cs).__name__}.run'

def __call__(self, should_stop: SignalReceiver, clock: Clock) -> Iterator[Command]:
interrupted = False
try:
yield from self.cs.run(should_stop, clock)
except KeyboardInterrupt:
# An interrupt lands in every process at once, and it can land inside a manager call, which
# leaves that connection in the middle of a message. Reading it again returns the tail of
# somebody else's, so a call is left unanswered here rather than answered from a torn stream.
interrupted = True
raise
finally:
for handler in handlers_of(self.cs):
handler.fail_queued()
if not interrupted:
for handler in handlers_of(self.cs):
handler.fail_queued()


def _bg_wrapper(
Expand Down Expand Up @@ -645,10 +687,10 @@ def interleave(self, *loops: ControlLoop) -> Iterator[Command]:
def connect(
self,
source: ControlSystemEmitter[T] | ControlSystemCaller[Req, Res],
target: ControlSystemReceiver[T] | ControlSystemHandler[Req, Res],
target: ControlSystemReceiver[U] | ControlSystemHandler[Req, Res],
*,
emitter_wrapper: Callable[[SignalEmitter[T]], SignalEmitter[T]] = identity,
receiver_wrapper: Callable[[SignalReceiver[T]], SignalReceiver[T]] = identity,
receiver_wrapper: Callable[[SignalReceiver[T]], SignalReceiver[U]] = identity,
) -> None:
"""Declare a logical connection: an Emitter feeding a Receiver, or a Caller invoking a Handler.

Expand Down
29 changes: 29 additions & 0 deletions positronic/assets/mujoco/trossen_wxai/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
BSD 3-Clause License

Copyright (c) 2025, Trossen Robotics
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24 changes: 24 additions & 0 deletions positronic/assets/mujoco/trossen_wxai/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Trossen WidowX AI Description

Robot description (MJCF) of the Trossen WidowX AI arm, from
[trossen_arm_mujoco](https://github.com/TrossenRobotics/trossen_arm_mujoco)
(`trossen_arm_mujoco/assets/wxai/wxai_follower.xml` at revision
[`8d9389b`](https://github.com/TrossenRobotics/trossen_arm_mujoco/commit/8d9389b46ba02bdbf6d5e086e09d20717ccfed68)),
BSD 3-Clause, see [LICENSE](./LICENSE).

`meshdir` names `assets`, the directory beside this file that holds the meshes.

`ee_site` is the frame the arm controller reports its Cartesian position in: the site sits 0.156 m along
the flange's x axis, which is the `t_flange_tool` offset the `trossen_arm` SDK carries for the standard
`wxai_v0` end effector. Forward kinematics on this model and the pose the controller reports agree to
0.13 mm and 0.01 degrees, measured on firmware 1.11.1.

`wxai_follower.urdf` is the same arm as URDF, from
[trossen_arm_description](https://github.com/TrossenRobotics/trossen_arm_description)
(`urdf/generated/wxai/wxai_follower.urdf`), BSD 3-Clause, with `meshes/trossen_black.png` beside the STLs
it already shares with the MJCF. The driver solves against the MJCF and the codecs against the URDF, so
`ee_gripper_link` of the URDF and `ee_site` of the MJCF must name one place:
`test_the_trossen_urdf_and_its_mjcf_put_the_control_frame_in_the_same_place` measures that they do.

The URDF names its meshes the way its own ROS package does; the driver shortens each to the file beside
this README before it publishes the model.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading