From 746bc54d168d9d91d5b7b43ff21b90664efb98d1 Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 14:14:51 +0100 Subject: [PATCH 01/20] add package skeleton + pytest conftest --- src/__init__.py | 0 src/adapters/__init__.py | 0 tests/conftest.py | 10 ++++++++++ tests/unit/__init__.py | 0 4 files changed, 10 insertions(+) create mode 100644 src/__init__.py create mode 100644 src/adapters/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/unit/__init__.py diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/adapters/__init__.py b/src/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b735a36 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +import os +import sys + +# Add `src/` to sys.path so imports work in test files. Matches the +# runtime invocation pattern: `python3 src/shim_server.py`. +_SRC = os.path.abspath( + os.path.join(os.path.dirname(__file__), os.pardir, "src") +) +if _SRC not in sys.path: + sys.path.insert(0, _SRC) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 From 4ad7c2c3eea1ee6c365ec28a84654196c6c6a59a Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 14:19:27 +0100 Subject: [PATCH 02/20] add internal docs to .gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 9e407e4..5951c22 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,8 @@ __pycache__/ .qibullet/ *.run *.tar.gz + +# internal docs +docs/ roadmap/ + From 595986f17dcd28ac4daf4e30beec4270ccdebde8 Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 15:22:58 +0100 Subject: [PATCH 03/20] add TaskRegistry for sim-shim post dispatch --- src/post_tasks.py | 42 ++++++++++++++++++ tests/unit/test_post_tasks.py | 81 +++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/post_tasks.py create mode 100644 tests/unit/test_post_tasks.py diff --git a/src/post_tasks.py b/src/post_tasks.py new file mode 100644 index 0000000..e2e393b --- /dev/null +++ b/src/post_tasks.py @@ -0,0 +1,42 @@ +"""Task registry for the sim shim's `post` dispatch. + +NAOqi's `post` returns a task ID that callers later query via `wait` or +`getCurrentPosition`. qibullet has no async dispatch, so we run targets +synchronously and treat the resulting state as "already done". + +Monotonic int IDs (never reused) so a slow caller's stale task_id never +aliases to a new task. LRU-bounded storage (default 100k) caps memory in +long-running sim sessions. +""" + +import threading +from collections import OrderedDict + + +class TaskRegistry: + MAX_ENTRIES = 100_000 + # LRU-bounded storage is hardcoded and not tunable from in config. Not a priority until this is blocking. + + def __init__(self): + self._lock = threading.Lock() + self._next_id = 1 + self._tasks = OrderedDict() + + def submit_sync(self, callable_, args, kwargs): + """Run `callable_` synchronously, store result, return a fresh task ID.""" + with self._lock: + task_id = self._next_id + self._next_id += 1 + while len(self._tasks) >= self.MAX_ENTRIES: + self._tasks.popitem(last=False) + try: + result = callable_(*args, **kwargs) + self._tasks[task_id] = {"done": True, "result": result, "error": None} + except Exception as e: + self._tasks[task_id] = {"done": True, "result": None, "error": str(e)} + return task_id + + def wait(self, task_id, timeout_ms=None): + """Return True if the task is done. Unknown task IDs return False.""" + task = self._tasks.get(task_id) + return task is not None and task["done"] diff --git a/tests/unit/test_post_tasks.py b/tests/unit/test_post_tasks.py new file mode 100644 index 0000000..d7cc36f --- /dev/null +++ b/tests/unit/test_post_tasks.py @@ -0,0 +1,81 @@ +import threading +import pytest +from post_tasks import TaskRegistry + + +def test_submit_returns_monotonic_ids(): + reg = TaskRegistry() + ids = [reg.submit_sync(lambda: None, (), {}) for _ in range(5)] + assert ids == [1, 2, 3, 4, 5] + + +def test_submit_records_result(): + reg = TaskRegistry() + task_id = reg.submit_sync(lambda x, y: x + y, (2, 3), {}) + assert reg._tasks[task_id]["done"] is True + assert reg._tasks[task_id]["result"] == 5 + assert reg._tasks[task_id]["error"] is None + + +def test_submit_records_exception(): + reg = TaskRegistry() + def boom(): + raise ValueError("explode") + task_id = reg.submit_sync(boom, (), {}) + assert reg._tasks[task_id]["done"] is True + assert reg._tasks[task_id]["result"] is None + assert "explode" in reg._tasks[task_id]["error"] + + +def test_wait_known_id_returns_true(): + reg = TaskRegistry() + task_id = reg.submit_sync(lambda: 42, (), {}) + assert reg.wait(task_id) is True + + +def test_wait_unknown_id_returns_false(): + reg = TaskRegistry() + assert reg.wait(99999) is False + + +def test_lru_evicts_oldest_at_max_entries(): + reg = TaskRegistry() + reg.MAX_ENTRIES = 3 + ids = [reg.submit_sync(lambda: None, (), {}) for _ in range(5)] + # IDs 1 and 2 should be evicted, 3, 4, 5 retained. + assert reg.wait(ids[0]) is False # ID 1 evicted + assert reg.wait(ids[1]) is False # ID 2 evicted + assert reg.wait(ids[2]) is True # ID 3 retained + assert reg.wait(ids[3]) is True + assert reg.wait(ids[4]) is True + + +def test_ids_remain_monotonic_after_eviction(): + """Eviction must not reuse IDs.""" + reg = TaskRegistry() + reg.MAX_ENTRIES = 2 + first_ids = [reg.submit_sync(lambda: None, (), {}) for _ in range(2)] + next_id = reg.submit_sync(lambda: None, (), {}) + assert next_id == 3 + assert next_id not in first_ids + + +def test_concurrent_submits_get_distinct_ids(): + reg = TaskRegistry() + seen_ids = [] + lock = threading.Lock() + + def worker(): + for _ in range(50): + task_id = reg.submit_sync(lambda: None, (), {}) + with lock: + seen_ids.append(task_id) + + threads = [threading.Thread(target=worker) for _ in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(seen_ids) == 200 + assert len(set(seen_ids)) == 200 # all distinct From d669bdc379cac6878e59adceb70f78e1df6f8219 Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 16:23:00 +0100 Subject: [PATCH 04/20] add adapter base classes (GenericAdapter, StubAdapter, @stub) --- src/adapters/base.py | 88 ++++++++++++++++++++++++ tests/unit/test_adapters.py | 129 ++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/adapters/base.py create mode 100644 tests/unit/test_adapters.py diff --git a/src/adapters/base.py b/src/adapters/base.py new file mode 100644 index 0000000..e6b7e23 --- /dev/null +++ b/src/adapters/base.py @@ -0,0 +1,88 @@ +"""Adapter base classes and the @stub decorator. + +GenericAdapter wraps a qibullet `PepperVirtual` and dispatches NAOqi-shaped +method calls. Methods that exist on `pepper` fall through via `__getattr__` +(overrides defined on subclasses take precedence). Unknown methods raise +AttributeError, which the dispatcher surfaces as HTTP 500. + +StubAdapter is for whole-module no-ops (modules where qibullet has no +analogue at all). Every method returns the declared default (or a +per-method override), tagged with `_sim_stub = True` so the dispatcher can +set the `X-Sim-Stub` response header. + +@stub marks individual methods on modeled adapters as sim no-ops without +making the whole adapter a StubAdapter. +""" + +import functools + + +def stub(default=None): + """Mark a method as a sim stub. + + Sets `_sim_stub = True` on the wrapper so the dispatcher can flag the + response. The `default` argument is documentation only; pass it to + record the intended return value at the decoration site. + """ + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + wrapper._sim_stub = True + return wrapper + return decorator + + +class GenericAdapter: + """Wraps qibullet's PepperVirtual. Subclasses add overrides and stubs.""" + + _BLOCKED_NAMES = frozenset({"loadRobot"}) + + def __init__(self, pepper, task_registry): + self._pepper = pepper + self._tasks = task_registry + + def post(self, target_method, *args, **kwargs): + """NAOqi `post` dispatch. Runs target synchronously, returns task ID.""" + target = getattr(self, target_method) + return self._tasks.submit_sync(target, args, kwargs) + + def __getattr__(self, name): + # __getattr__ runs only when normal lookup fails, so overrides + # defined on subclasses are reached first. + if name.startswith("_"): + raise AttributeError("{!r} is private".format(name)) + if name in self._BLOCKED_NAMES: + raise AttributeError("{!r} is blocked".format(name)) + if not hasattr(self._pepper, name): + raise AttributeError("PepperVirtual has no method {!r}".format(name)) + return getattr(self._pepper, name) + + +class StubAdapter: + """For modules where every method is a no-op in sim. + + `default` is the return value for any method not in `overrides`. + `overrides` maps method name -> return value for methods that need a + specific non-default response. + """ + + def __init__(self, default=None, overrides=None, task_registry=None): + self._default = default + self._overrides = overrides or {} + self._tasks = task_registry + + def post(self, target_method, *args, **kwargs): + target = getattr(self, target_method) + return self._tasks.submit_sync(target, args, kwargs) + + def __getattr__(self, name): + if name.startswith("_"): + raise AttributeError("{!r} is private".format(name)) + return_value = self._overrides.get(name, self._default) + + def stub_method(*args, **kwargs): + return return_value + + stub_method._sim_stub = True + return stub_method diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py new file mode 100644 index 0000000..e0f0e6f --- /dev/null +++ b/tests/unit/test_adapters.py @@ -0,0 +1,129 @@ +from unittest.mock import MagicMock +import pytest +from adapters.base import GenericAdapter, StubAdapter, stub +from post_tasks import TaskRegistry + + +# --- @stub decorator --- + +def test_stub_decorator_marks_function(): + @stub() + def f(): + return 7 + assert f() == 7 + assert getattr(f, "_sim_stub", False) is True + + +def test_stub_decorator_preserves_name(): + @stub() + def my_method(): + pass + assert my_method.__name__ == "my_method" + + +# --- GenericAdapter --- + +def test_generic_falls_through_to_pepper_for_existing_method(): + pepper = MagicMock() + pepper.someMethod.return_value = "qibullet-result" + adapter = GenericAdapter(pepper, TaskRegistry()) + assert adapter.someMethod("arg") == "qibullet-result" + pepper.someMethod.assert_called_once_with("arg") + + +def test_generic_raises_for_missing_pepper_method(): + pepper = MagicMock(spec=[]) # spec=[] makes hasattr return False for everything + adapter = GenericAdapter(pepper, TaskRegistry()) + with pytest.raises(AttributeError, match="PepperVirtual has no method"): + adapter.nonExistent + + +def test_generic_blocks_underscore_prefix(): + pepper = MagicMock() + adapter = GenericAdapter(pepper, TaskRegistry()) + with pytest.raises(AttributeError, match="is private"): + adapter._secret + + +def test_generic_blocks_loadrobot(): + pepper = MagicMock() + pepper.loadRobot = MagicMock() + adapter = GenericAdapter(pepper, TaskRegistry()) + with pytest.raises(AttributeError, match="is blocked"): + adapter.loadRobot + + +def test_generic_post_runs_target_and_returns_task_id(): + pepper = MagicMock() + pepper.someMethod.return_value = "done" + registry = TaskRegistry() + adapter = GenericAdapter(pepper, registry) + task_id = adapter.post("someMethod", "arg") + assert isinstance(task_id, int) + assert registry._tasks[task_id]["result"] == "done" + pepper.someMethod.assert_called_once_with("arg") + + +def test_generic_post_propagates_unknown_target(): + pepper = MagicMock(spec=[]) + adapter = GenericAdapter(pepper, TaskRegistry()) + with pytest.raises(AttributeError): + adapter.post("nonExistent", "arg") + + +def test_generic_subclass_method_takes_precedence_over_fallthrough(): + """If a subclass defines a method, it must be called instead of falling + through to pepper. This protects against accidentally using + __getattribute__ instead of __getattr__ in the base class.""" + + class MySubclass(GenericAdapter): + def myMethod(self): + return "from-subclass" + + pepper = MagicMock() + pepper.myMethod.return_value = "from-pepper" + adapter = MySubclass(pepper, TaskRegistry()) + + assert adapter.myMethod() == "from-subclass" + pepper.myMethod.assert_not_called() + + +def test_generic_post_forwards_multiple_args_and_kwargs(): + """post must forward arbitrary positional and keyword arguments to the + target. Live callers pass complex payloads (e.g. lookAt takes a point + list, two floats, and a bool).""" + pepper = MagicMock() + adapter = GenericAdapter(pepper, TaskRegistry()) + adapter.post("multiArgMethod", 1, "two", 3.0, flag=True, mode="x") + pepper.multiArgMethod.assert_called_once_with(1, "two", 3.0, flag=True, mode="x") + + +# --- StubAdapter --- + +def test_stub_adapter_returns_default(): + adapter = StubAdapter(default=None, task_registry=TaskRegistry()) + assert adapter.anyMethod("ignored") is None + + +def test_stub_adapter_marks_returned_callable(): + adapter = StubAdapter(default=None, task_registry=TaskRegistry()) + stub_callable = adapter.anyMethod + assert getattr(stub_callable, "_sim_stub", False) is True + + +def test_stub_adapter_uses_per_method_override(): + adapter = StubAdapter( + default=None, + overrides={"getMode": "Head"}, + task_registry=TaskRegistry(), + ) + assert adapter.getMode() == "Head" + assert adapter.someOtherMethod() is None + + +def test_stub_adapter_post_runs_stub_target(): + registry = TaskRegistry() + adapter = StubAdapter(default=None, task_registry=registry) + task_id = adapter.post("anyMethod", "arg") + assert isinstance(task_id, int) + assert registry._tasks[task_id]["result"] is None From cdee92fb5e7f4f789b3babb4cbbe2cd9fbd0bdf2 Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 18:50:05 +0100 Subject: [PATCH 05/20] add ALMotionAdapter and docker-based test runner --- src/adapters/motion.py | 92 +++++++++++++++++++++++++++ tests/run-in-docker.sh | 23 +++++++ tests/unit/test_adapters.py | 122 ++++++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 src/adapters/motion.py create mode 100755 tests/run-in-docker.sh diff --git a/src/adapters/motion.py b/src/adapters/motion.py new file mode 100644 index 0000000..58f1fab --- /dev/null +++ b/src/adapters/motion.py @@ -0,0 +1,92 @@ +"""ALMotion adapter. Overrides where qibullet's API differs from NAOqi; +stubs for NAOqi methods qibullet doesn't model.""" + +from qibullet.base_controller import PepperBaseController + +from .base import GenericAdapter, stub + + +class ALMotionAdapter(GenericAdapter): + + # --- Overrides --- + + def move(self, x, y, theta): + self._pepper.move(float(x), float(y), float(theta)) + + def moveToward(self, x, y, theta): + # NAOqi: normalized velocity in [-1, 1]. Translate to qibullet's + # absolute m/s and rad/s using the base controller's max constants. + self._pepper.move( + float(x) * PepperBaseController.MAX_LINEAR_VELOCITY, + float(y) * PepperBaseController.MAX_LINEAR_VELOCITY, + float(theta) * PepperBaseController.MAX_ANGULAR_VELOCITY, + ) + + def moveTo(self, x, y, theta): + # NAOqi's moveTo is async-by-default; qibullet's is synchronous. + # Force async to match NAOqi semantics. + self._pepper.moveTo(float(x), float(y), float(theta), _async=True) + + def stopMove(self): + # qibullet's stopMove only cancels async moveTo. Compose with + # move(0,0,0) to zero the velocity that move() may have set. + self._pepper.stopMove() + self._pepper.move(0, 0, 0) + + def getRobotPosition(self, useSensors=True): + # qibullet's getPosition returns (x, y, theta). NAOqi callers + # expect a list. `useSensors` has no sim equivalent and is ignored. + return list(self._pepper.getPosition()) + + def getAngles(self, names, useSensors=True): + # qibullet returns a scalar for single-name requests, list otherwise. + # NAOqi's getAngles always returns a list. Wrap scalar. + result = self._pepper.getAnglesPosition(names) + if isinstance(result, list): + return result + return [result] + + def setAngles(self, names, angles, fraction_max_speed): + self._pepper.setAngles(names, angles, fraction_max_speed) + + def angleInterpolation(self, names, angles, durations, isAbsolute=True): + # qibullet has no time-curve interpolation. Run a single setAngles + # at moderate speed; durations and isAbsolute are ignored. + self._pepper.setAngles(names, angles, 0.5) + + def wait(self, task_id, timeout_ms=None): + return self._tasks.wait(task_id, timeout_ms) + + # --- Stubs (no qibullet equivalent) --- + + @stub() + def wakeUp(self): + return None + + @stub() + def rest(self): + return None + + @stub(default=True) + def robotIsWakeUp(self): + return True + + @stub() + def setStiffnesses(self, *args, **kwargs): + return None + + @stub() + def setBreathConfig(self, *args, **kwargs): + return None + + @stub() + def setBreathEnabled(self, *args, **kwargs): + return None + + @stub() + def setExternalCollisionProtectionEnabled(self, *args, **kwargs): + return None + + @stub(default=[]) + def getRobotConfig(self): + return [] diff --git a/tests/run-in-docker.sh b/tests/run-in-docker.sh new file mode 100755 index 0000000..17b8a32 --- /dev/null +++ b/tests/run-in-docker.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Run pytest inside the pepper-box container. +# +# Mounts the working tree (src/, tests/, py3-naoqi-bridge/) over the image's +# copies so that we test the current code. Installs pytest at startup +# because the runtime image keeps test deps out. +# +# Usage: ./tests/run-in-docker.sh [pytest-args...] +# ./tests/run-in-docker.sh tests/unit/ -v +# ./tests/run-in-docker.sh tests/unit/test_post_tasks.py::test_wait_known_id_returns_true -v + +set -e + +SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +REPO_ROOT="$(dirname "$SCRIPT_DIR")" + +exec docker run --rm \ + -v "$REPO_ROOT/src:/home/pepperdev/src" \ + -v "$REPO_ROOT/tests:/home/pepperdev/tests" \ + -v "$REPO_ROOT/py3-naoqi-bridge:/home/pepperdev/py3-naoqi-bridge" \ + --entrypoint bash \ + ghcr.io/action-prediction-lab/pepper-box:latest \ + -c 'python3 -m pip install --quiet --user pytest && python3 -m pytest "$@"' -- "$@" diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py index e0f0e6f..b34d9d3 100644 --- a/tests/unit/test_adapters.py +++ b/tests/unit/test_adapters.py @@ -127,3 +127,125 @@ def test_stub_adapter_post_runs_stub_target(): task_id = adapter.post("anyMethod", "arg") assert isinstance(task_id, int) assert registry._tasks[task_id]["result"] is None + + +# --- ALMotionAdapter --- + +from adapters.motion import ALMotionAdapter + + +def _motion_adapter(): + return ALMotionAdapter(MagicMock(), TaskRegistry()) + + +def test_motion_move_passes_floats(): + adapter = _motion_adapter() + adapter.move("1.5", "0", "0.5") + adapter._pepper.move.assert_called_once_with(1.5, 0.0, 0.5) + + +def test_motion_movetoward_scales_by_max_velocity(): + from qibullet.base_controller import PepperBaseController as PBC + adapter = _motion_adapter() + adapter.moveToward(0.5, 0, 0.5) + adapter._pepper.move.assert_called_once_with( + 0.5 * PBC.MAX_LINEAR_VELOCITY, + 0.0 * PBC.MAX_LINEAR_VELOCITY, + 0.5 * PBC.MAX_ANGULAR_VELOCITY, + ) + + +def test_motion_moveto_forces_async(): + adapter = _motion_adapter() + adapter.moveTo(1.0, 0.0, 0.0) + adapter._pepper.moveTo.assert_called_once_with(1.0, 0.0, 0.0, _async=True) + + +def test_motion_stopmove_zeros_velocity(): + adapter = _motion_adapter() + adapter.stopMove() + adapter._pepper.stopMove.assert_called_once_with() + adapter._pepper.move.assert_called_once_with(0, 0, 0) + + +def test_motion_getrobotposition_returns_list(): + adapter = _motion_adapter() + adapter._pepper.getPosition.return_value = (1.0, 2.0, 0.5) + result = adapter.getRobotPosition(True) + assert result == [1.0, 2.0, 0.5] + adapter._pepper.getPosition.assert_called_once_with() + + +def test_motion_getangles_wraps_scalar_to_list(): + adapter = _motion_adapter() + adapter._pepper.getAnglesPosition.return_value = 0.42 # scalar from qibullet + result = adapter.getAngles("HeadYaw", True) + assert result == [0.42] + + +def test_motion_getangles_preserves_list(): + adapter = _motion_adapter() + adapter._pepper.getAnglesPosition.return_value = [0.1, 0.2] + result = adapter.getAngles(["HeadYaw", "HeadPitch"], True) + assert result == [0.1, 0.2] + + +def test_motion_setangles_passes_through(): + adapter = _motion_adapter() + adapter.setAngles(["HeadYaw"], [0.3], 0.1) + adapter._pepper.setAngles.assert_called_once_with(["HeadYaw"], [0.3], 0.1) + + +def test_motion_angleinterpolation_calls_setangles(): + adapter = _motion_adapter() + adapter.angleInterpolation(["RHand"], [0.5], [2.0], True) + adapter._pepper.setAngles.assert_called_once_with(["RHand"], [0.5], 0.5) + + +def test_motion_wakeup_is_stub(): + adapter = _motion_adapter() + assert adapter.wakeUp() is None + assert getattr(type(adapter).wakeUp, "_sim_stub", False) is True + + +def test_motion_robotiswakeup_returns_true(): + adapter = _motion_adapter() + assert adapter.robotIsWakeUp() is True + + +def test_motion_setstiffnesses_is_stub_no_op(): + adapter = _motion_adapter() + assert adapter.setStiffnesses("Head", 0.5) is None + adapter._pepper.setStiffnesses.assert_not_called() + + +def test_motion_setbreathconfig_is_stub_no_op(): + adapter = _motion_adapter() + assert adapter.setBreathConfig([["Bpm", 15.0], ["Amplitude", 0.99]]) is None + adapter._pepper.setBreathConfig.assert_not_called() + + +def test_motion_setbreathenabled_is_stub_no_op(): + adapter = _motion_adapter() + assert adapter.setBreathEnabled("Legs", True) is None + adapter._pepper.setBreathEnabled.assert_not_called() + + +def test_motion_setexternalcollisionprotectionenabled_is_stub_no_op(): + adapter = _motion_adapter() + assert adapter.setExternalCollisionProtectionEnabled("RArm", False) is None + adapter._pepper.setExternalCollisionProtectionEnabled.assert_not_called() + + +def test_motion_getrobotconfig_returns_empty_list(): + adapter = _motion_adapter() + assert adapter.getRobotConfig() == [] + + +def test_motion_wait_delegates_to_registry(): + pepper = MagicMock() + registry = TaskRegistry() + adapter = ALMotionAdapter(pepper, registry) + fake_id = registry.submit_sync(lambda: None, (), {}) + assert adapter.wait(fake_id, 5000) is True + assert adapter.wait(99999, 5000) is False From 8063feb0646b8b98f3b1d287ff52a67a790d137f Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 19:08:09 +0100 Subject: [PATCH 06/20] add ALMemoryAdapter for sonar/laser key routing --- src/adapters/memory.py | 44 ++++++++++++++++++++++ tests/unit/test_adapters.py | 73 +++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 src/adapters/memory.py diff --git a/src/adapters/memory.py b/src/adapters/memory.py new file mode 100644 index 0000000..8aa970f --- /dev/null +++ b/src/adapters/memory.py @@ -0,0 +1,44 @@ +"""ALMemory adapter. Key-routed getData translates NAOqi's stringly-typed +sensor lookups to qibullet's laser API. Most ALMemory operations have no +qibullet equivalent and stub to None or the closest sensible value.""" + +from .base import GenericAdapter, stub + +_SONAR_FRONT = "Device/SubDeviceList/Platform/Front/Sonar/Sensor/Value" +_SONAR_BACK = "Device/SubDeviceList/Platform/Back/Sonar/Sensor/Value" +_LASER_FRONT = "Device/SubDeviceList/Platform/Laser/Front/Sensor/Value" +_LANDMARK = "LandmarkDetected" +_MAX_SONAR_RANGE = 3.0 # meters + + +class ALMemoryAdapter(GenericAdapter): + + def getData(self, key, *_rest): + # NAOqi's ALMemory.getData accepts an optional 2nd arg (timeout/format). + # Capture it as *_rest and ignore. + if key == _SONAR_FRONT: + return self._min_laser(self._pepper.getFrontLaserValue()) + if key == _SONAR_BACK: + left = self._pepper.getLeftLaserValue() or [] + right = self._pepper.getRightLaserValue() or [] + return self._min_laser(list(left) + list(right)) + if key == _LASER_FRONT: + return self._pepper.getFrontLaserValue() + if key == _LANDMARK: + # Empty detection + return [0, [], 0] + # Unknown key + return None + + def getListData(self, keys): + return [self.getData(k) for k in keys] + + @stub() + def insertData(self, *args, **kwargs): + return None + + @staticmethod + def _min_laser(values): + if not values: + return _MAX_SONAR_RANGE + return min(values) diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py index b34d9d3..65b3c5d 100644 --- a/tests/unit/test_adapters.py +++ b/tests/unit/test_adapters.py @@ -249,3 +249,76 @@ def test_motion_wait_delegates_to_registry(): fake_id = registry.submit_sync(lambda: None, (), {}) assert adapter.wait(fake_id, 5000) is True assert adapter.wait(99999, 5000) is False + + +# --- ALMemoryAdapter --- + +from adapters.memory import ALMemoryAdapter + + +def _memory_adapter(): + return ALMemoryAdapter(MagicMock(), TaskRegistry()) + + +def test_memory_getdata_front_sonar_returns_min_laser(): + adapter = _memory_adapter() + adapter._pepper.getFrontLaserValue.return_value = [3.0, 2.5, 4.0] + result = adapter.getData("Device/SubDeviceList/Platform/Front/Sonar/Sensor/Value") + assert result == 2.5 + + +def test_memory_getdata_back_sonar_returns_min_of_left_plus_right(): + adapter = _memory_adapter() + adapter._pepper.getLeftLaserValue.return_value = [3.0, 2.8] + adapter._pepper.getRightLaserValue.return_value = [2.4, 3.5] + result = adapter.getData("Device/SubDeviceList/Platform/Back/Sonar/Sensor/Value") + assert result == 2.4 + + +def test_memory_getdata_front_sonar_empty_returns_max_range(): + adapter = _memory_adapter() + adapter._pepper.getFrontLaserValue.return_value = [] + result = adapter.getData("Device/SubDeviceList/Platform/Front/Sonar/Sensor/Value") + assert result == 3.0 + + +def test_memory_getdata_back_sonar_empty_returns_max_range(): + adapter = _memory_adapter() + adapter._pepper.getLeftLaserValue.return_value = [] + adapter._pepper.getRightLaserValue.return_value = [] + result = adapter.getData("Device/SubDeviceList/Platform/Back/Sonar/Sensor/Value") + assert result == 3.0 + + +def test_memory_getdata_laser_front_returns_raw_array(): + adapter = _memory_adapter() + adapter._pepper.getFrontLaserValue.return_value = [1.0, 2.0, 3.0] + result = adapter.getData("Device/SubDeviceList/Platform/Laser/Front/Sensor/Value") + assert result == [1.0, 2.0, 3.0] + + +def test_memory_getdata_landmark_returns_empty_detection(): + adapter = _memory_adapter() + result = adapter.getData("LandmarkDetected", 0) + assert result == [0, [], 0] + + +def test_memory_getdata_unknown_key_returns_none(): + adapter = _memory_adapter() + assert adapter.getData("SomeRandomKey") is None + + +def test_memory_getlistdata_routes_each_key(): + adapter = _memory_adapter() + adapter._pepper.getFrontLaserValue.return_value = [2.0] + result = adapter.getListData([ + "Device/SubDeviceList/Platform/Front/Sonar/Sensor/Value", + "UnknownKey", + ]) + assert result == [2.0, None] + + +def test_memory_insertdata_is_stub(): + adapter = _memory_adapter() + assert adapter.insertData("key", "value") is None + adapter._pepper.insertData.assert_not_called() From 55b950153e5db6d6c526c6e947f93a8e1bb9a9fe Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 19:16:48 +0100 Subject: [PATCH 07/20] add ALRobotPostureAdapter --- src/adapters/posture.py | 11 +++++++++++ tests/unit/test_adapters.py | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 src/adapters/posture.py diff --git a/src/adapters/posture.py b/src/adapters/posture.py new file mode 100644 index 0000000..bd04417 --- /dev/null +++ b/src/adapters/posture.py @@ -0,0 +1,11 @@ +"""ALRobotPosture adapter. Direct passthrough to qibullet's goToPosture, +which returns False for postures qibullet doesn't model (Sit, SitRelax, +LyingBelly, LyingBack).""" + +from .base import GenericAdapter + + +class ALRobotPostureAdapter(GenericAdapter): + + def goToPosture(self, name, speed): + return self._pepper.goToPosture(name, speed) diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py index 65b3c5d..01df86f 100644 --- a/tests/unit/test_adapters.py +++ b/tests/unit/test_adapters.py @@ -322,3 +322,23 @@ def test_memory_insertdata_is_stub(): adapter = _memory_adapter() assert adapter.insertData("key", "value") is None adapter._pepper.insertData.assert_not_called() + + +# --- ALRobotPostureAdapter --- + +from adapters.posture import ALRobotPostureAdapter + + +def test_posture_gotoposture_passes_through(): + pepper = MagicMock() + pepper.goToPosture.return_value = True + adapter = ALRobotPostureAdapter(pepper, TaskRegistry()) + assert adapter.goToPosture("Stand", 0.5) is True + pepper.goToPosture.assert_called_once_with("Stand", 0.5) + + +def test_posture_gotoposture_returns_qibullet_false_for_unknown(): + pepper = MagicMock() + pepper.goToPosture.return_value = False + adapter = ALRobotPostureAdapter(pepper, TaskRegistry()) + assert adapter.goToPosture("Sit", 0.5) is False From 48c333616580e894482fc4dbd0fa7006b0dc31a7 Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 19:22:21 +0100 Subject: [PATCH 08/20] michail89 --- src/adapters/laser.py | 11 +++++++++++ tests/unit/test_adapters.py | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/adapters/laser.py diff --git a/src/adapters/laser.py b/src/adapters/laser.py new file mode 100644 index 0000000..236fc9e --- /dev/null +++ b/src/adapters/laser.py @@ -0,0 +1,11 @@ +"""ALLaser adapter. PepperBox-custom NAOqi module name; maps to qibullet's +laser debug-line visualisation.""" + +from .base import GenericAdapter + + +class ALLaserAdapter(GenericAdapter): + + def show(self, state): + self._pepper.showLaser(bool(state)) + return True diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py index 01df86f..53bde77 100644 --- a/tests/unit/test_adapters.py +++ b/tests/unit/test_adapters.py @@ -342,3 +342,21 @@ def test_posture_gotoposture_returns_qibullet_false_for_unknown(): pepper.goToPosture.return_value = False adapter = ALRobotPostureAdapter(pepper, TaskRegistry()) assert adapter.goToPosture("Sit", 0.5) is False + + +# --- ALLaserAdapter --- + +from adapters.laser import ALLaserAdapter + + +def test_laser_show_calls_pepper_showlaser(): + pepper = MagicMock() + adapter = ALLaserAdapter(pepper, TaskRegistry()) + adapter.show(True) + pepper.showLaser.assert_called_once_with(True) + + +def test_laser_show_returns_true(): + pepper = MagicMock() + adapter = ALLaserAdapter(pepper, TaskRegistry()) + assert adapter.show(True) is True From d7ca43a2d907b7b5a9e593048c05829331890a05 Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 19:37:31 +0100 Subject: [PATCH 09/20] add ALTextToSpeechAdapter for sim TTS stub --- src/adapters/tts.py | 19 +++++++++++++++++++ tests/unit/test_adapters.py | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 src/adapters/tts.py diff --git a/src/adapters/tts.py b/src/adapters/tts.py new file mode 100644 index 0000000..ccdd75a --- /dev/null +++ b/src/adapters/tts.py @@ -0,0 +1,19 @@ +"""ALTextToSpeech adapter. qibullet has no audio backend. Calls to .say +log the requested phrase to stderr so a developer watching the shim's +docker logs sees the intended utterance.""" + +import sys + +from .base import GenericAdapter, stub + + +class ALTextToSpeechAdapter(GenericAdapter): + + @stub() + def say(self, text): + sys.stderr.write("[SIM-TTS] {}\n".format(text)) + return None + + @stub(default=["English"]) + def getAvailableLanguages(self): + return ["English"] diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py index 53bde77..d265cfb 100644 --- a/tests/unit/test_adapters.py +++ b/tests/unit/test_adapters.py @@ -360,3 +360,25 @@ def test_laser_show_returns_true(): pepper = MagicMock() adapter = ALLaserAdapter(pepper, TaskRegistry()) assert adapter.show(True) is True + + +# --- ALTextToSpeechAdapter --- + +from adapters.tts import ALTextToSpeechAdapter + + +def test_tts_say_returns_none_and_prints(capsys): + adapter = ALTextToSpeechAdapter(MagicMock(), TaskRegistry()) + result = adapter.say("Hello world") + captured = capsys.readouterr() + assert result is None + assert "[SIM-TTS] Hello world" in captured.err + + +def test_tts_say_is_stub_marked(): + assert getattr(ALTextToSpeechAdapter.say, "_sim_stub", False) is True + + +def test_tts_getavailablelanguages_returns_english(): + adapter = ALTextToSpeechAdapter(MagicMock(), TaskRegistry()) + assert adapter.getAvailableLanguages() == ["English"] From a5e60638adce5c46b4a5fea2b0d95a2656716ba5 Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 19:48:35 +0100 Subject: [PATCH 10/20] add MODULE_ADAPTERS registry wiring all sim shim adapters --- src/adapters/__init__.py | 73 +++++++++++++++++++++++++++++++++++++ tests/unit/test_adapters.py | 71 ++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/src/adapters/__init__.py b/src/adapters/__init__.py index e69de29..e0d95ef 100644 --- a/src/adapters/__init__.py +++ b/src/adapters/__init__.py @@ -0,0 +1,73 @@ +"""Module adapter registry. `build_module_adapters` returns a fresh dict +of `module_name -> adapter` for the dispatcher to use. Construct one set +of adapters per shim process; they hold mutable refs to pepper and the +task registry.""" + +from .base import GenericAdapter, StubAdapter +from .motion import ALMotionAdapter +from .memory import ALMemoryAdapter +from .posture import ALRobotPostureAdapter +from .laser import ALLaserAdapter +from .tts import ALTextToSpeechAdapter + + +def build_module_adapters(pepper, task_registry): + """Construct the per-module adapter dict for one shim process.""" + tts = ALTextToSpeechAdapter(pepper, task_registry) + return { + # Modeled modules. + "ALMotion": ALMotionAdapter(pepper, task_registry), + "ALMemory": ALMemoryAdapter(pepper, task_registry), + "ALRobotPosture": ALRobotPostureAdapter(pepper, task_registry), + "ALLaser": ALLaserAdapter(pepper, task_registry), + "ALTextToSpeech": tts, + "ALAnimatedSpeech": tts, # same surface; reuse the same instance + + # Pure-stub modules (every method returns the default). + "ALAnimationPlayer": StubAdapter(default=None, task_registry=task_registry), + "ALAutonomousLife": StubAdapter( + default=None, + overrides={"getState": "disabled"}, + task_registry=task_registry, + ), + "ALBasicAwareness": StubAdapter( + default=None, + overrides={"isEnabled": False}, + task_registry=task_registry, + ), + "ALFaceDetection": StubAdapter(default=None, task_registry=task_registry), + "ALTracker": StubAdapter( + default=None, + overrides={"getMode": "Head"}, + task_registry=task_registry, + ), + "ALLandMarkDetection": StubAdapter(default=None, task_registry=task_registry), + "ALSystem": StubAdapter( + default=None, + overrides={"systemVersion": "2.5.7.1"}, + task_registry=task_registry, + ), + "ALAudioPlayer": StubAdapter( + default=None, + overrides={"getCurrentPosition": 0.0}, + task_registry=task_registry, + ), + "ALAudioDevice": StubAdapter( + default=None, + overrides={"isAudioOutActing": False}, + task_registry=task_registry, + ), + "ALBattery": StubAdapter( + default=None, + overrides={"getBatteryCharge": 100.0}, + task_registry=task_registry, + ), + "ALBodyTemperature": StubAdapter( + default=None, + overrides={"getTemperatureDiagnosis": [0, []]}, + task_registry=task_registry, + ), + } + + +__all__ = ["build_module_adapters"] diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py index d265cfb..856f591 100644 --- a/tests/unit/test_adapters.py +++ b/tests/unit/test_adapters.py @@ -382,3 +382,74 @@ def test_tts_say_is_stub_marked(): def test_tts_getavailablelanguages_returns_english(): adapter = ALTextToSpeechAdapter(MagicMock(), TaskRegistry()) assert adapter.getAvailableLanguages() == ["English"] + + +# --- MODULE_ADAPTERS registry --- + +from adapters import build_module_adapters + + +def test_registry_contains_modeled_modules(): + pepper = MagicMock() + registry = TaskRegistry() + adapters = build_module_adapters(pepper, registry) + for module in ["ALMotion", "ALMemory", "ALRobotPosture", "ALLaser", + "ALTextToSpeech", "ALAnimatedSpeech"]: + assert module in adapters, "missing adapter for {}".format(module) + + +def test_registry_contains_stub_modules(): + pepper = MagicMock() + registry = TaskRegistry() + adapters = build_module_adapters(pepper, registry) + for module in ["ALAnimationPlayer", "ALAutonomousLife", "ALBasicAwareness", + "ALFaceDetection", "ALTracker", "ALLandMarkDetection", + "ALSystem", "ALAudioPlayer", "ALAudioDevice", + "ALBattery", "ALBodyTemperature"]: + assert module in adapters, "missing stub adapter for {}".format(module) + + +def test_registry_tracker_getmode_returns_head(): + adapters = build_module_adapters(MagicMock(), TaskRegistry()) + assert adapters["ALTracker"].getMode() == "Head" + + +def test_registry_basicawareness_isenabled_returns_false(): + adapters = build_module_adapters(MagicMock(), TaskRegistry()) + assert adapters["ALBasicAwareness"].isEnabled() is False + + +def test_registry_battery_getbatterycharge_returns_100(): + adapters = build_module_adapters(MagicMock(), TaskRegistry()) + assert adapters["ALBattery"].getBatteryCharge() == 100.0 + + +def test_registry_bodytemperature_getdiagnosis(): + adapters = build_module_adapters(MagicMock(), TaskRegistry()) + assert adapters["ALBodyTemperature"].getTemperatureDiagnosis() == [0, []] + + +def test_registry_system_systemversion(): + adapters = build_module_adapters(MagicMock(), TaskRegistry()) + assert adapters["ALSystem"].systemVersion() == "2.5.7.1" + + +def test_registry_audiodevice_isaudioout_returns_false(): + adapters = build_module_adapters(MagicMock(), TaskRegistry()) + assert adapters["ALAudioDevice"].isAudioOutActing() is False + + +def test_registry_audioplayer_getcurrentposition_returns_zero(): + adapters = build_module_adapters(MagicMock(), TaskRegistry()) + assert adapters["ALAudioPlayer"].getCurrentPosition(0) == 0.0 + + +def test_registry_autonomouslife_getstate_returns_disabled(): + adapters = build_module_adapters(MagicMock(), TaskRegistry()) + assert adapters["ALAutonomousLife"].getState() == "disabled" + + +def test_registry_animatedspeech_says(): + adapters = build_module_adapters(MagicMock(), TaskRegistry()) + # ALAnimatedSpeech reuses the same adapter class as ALTextToSpeech. + assert adapters["ALAnimatedSpeech"].say("hi") is None From 66d34348784f764a2b603adb09ad0663d0ad2f62 Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 20:00:19 +0100 Subject: [PATCH 11/20] add Dispatcher core with post unwrap and stub marker --- src/dispatcher.py | 35 +++++++++++++ tests/unit/test_dispatcher.py | 94 +++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/dispatcher.py create mode 100644 tests/unit/test_dispatcher.py diff --git a/src/dispatcher.py b/src/dispatcher.py new file mode 100644 index 0000000..43e2ef8 --- /dev/null +++ b/src/dispatcher.py @@ -0,0 +1,35 @@ +"""Dispatcher: looks up the per-module adapter and forwards calls. + +Returns (result, is_stub) so the Flask layer can set the X-Sim-Stub header +without inspecting adapter internals. + +Raises AttributeError for unknown modules, unknown methods on known +modules, blocked names, and private names. The shim's route handler turns +AttributeError into HTTP 500 with the exception's message as the error body. +""" + + +class Dispatcher: + + def __init__(self, module_adapters): + self._adapters = module_adapters + + def dispatch(self, module, method, args, kwargs): + adapter = self._adapters.get(module) + if adapter is None: + raise AttributeError("No adapter for module {!r}".format(module)) + + if method == "post": + if not args: + raise AttributeError( + "post requires the target method name as first arg" + ) + target = args[0] + inner_args = args[1:] + result = adapter.post(target, *inner_args, **kwargs) + return result, False + + handler = getattr(adapter, method) + result = handler(*args, **kwargs) + is_stub = getattr(handler, "_sim_stub", False) + return result, is_stub diff --git a/tests/unit/test_dispatcher.py b/tests/unit/test_dispatcher.py new file mode 100644 index 0000000..2bc7af6 --- /dev/null +++ b/tests/unit/test_dispatcher.py @@ -0,0 +1,94 @@ +from unittest.mock import MagicMock +import pytest +from dispatcher import Dispatcher +from post_tasks import TaskRegistry +from adapters import build_module_adapters + + +def _dispatcher(): + # Create a mock with spec of known methods so hasattr works correctly. + pepper = MagicMock(spec=[ + 'move', 'moveToward', 'moveTo', 'stopMove', 'getPosition', + 'getAnglesPosition', 'setAngles', 'getRobotPosition', + 'wakeUp', 'robotIsWakeUp', 'setStiffnesses', 'setBreathConfig', + 'setBreathEnabled', 'setExternalCollisionProtectionEnabled', + 'getRobotConfig', 'goToPosture', 'getFrontLaserValue', + 'getLeftLaserValue', 'getRightLaserValue', 'insertData', + 'showLaser', + ]) + registry = TaskRegistry() + adapters = build_module_adapters(pepper, registry) + return Dispatcher(adapters), pepper + + +def test_dispatch_returns_result_and_is_stub_false_for_real_method(): + d, pepper = _dispatcher() + result, is_stub = d.dispatch("ALMotion", "move", [1.0, 0, 0], {}) + assert is_stub is False + pepper.move.assert_called_once_with(1.0, 0.0, 0.0) + + +def test_dispatch_is_stub_true_for_stub_method(): + d, _ = _dispatcher() + result, is_stub = d.dispatch("ALMotion", "wakeUp", [], {}) + assert result is None + assert is_stub is True + + +def test_dispatch_unknown_module_raises_attribute_error(): + d, _ = _dispatcher() + with pytest.raises(AttributeError, match="No adapter for module"): + d.dispatch("ALNonExistent", "doStuff", [], {}) + + +def test_dispatch_unknown_method_on_known_module_raises(): + d, _ = _dispatcher() + with pytest.raises(AttributeError, match="has no method"): + d.dispatch("ALMotion", "definitelyFake", [], {}) + + +def test_dispatch_blocks_underscore_prefix(): + d, _ = _dispatcher() + with pytest.raises(AttributeError, match="is private"): + d.dispatch("ALMotion", "_secret", [], {}) + + +def test_dispatch_blocks_loadrobot(): + d, _ = _dispatcher() + with pytest.raises(AttributeError, match="is blocked"): + d.dispatch("ALMotion", "loadRobot", [], {}) + + +def test_dispatch_post_unwraps_target_method(): + d, pepper = _dispatcher() + pepper.move.return_value = None + result, is_stub = d.dispatch("ALMotion", "post", ["move", 1.0, 0, 0], {}) + assert isinstance(result, int) + assert is_stub is False + pepper.move.assert_called_once_with(1.0, 0.0, 0.0) + + +def test_dispatch_post_propagates_unknown_target(): + d, _ = _dispatcher() + with pytest.raises(AttributeError, match="has no method"): + d.dispatch("ALMotion", "post", ["nonExistent"], {}) + + +def test_dispatch_post_with_empty_args_raises(): + d, _ = _dispatcher() + with pytest.raises(AttributeError, match="post requires the target method name"): + d.dispatch("ALMotion", "post", [], {}) + + +def test_dispatch_post_of_stub_target_still_returns_is_stub_false(): + d, _ = _dispatcher() + result, is_stub = d.dispatch("ALMotion", "post", ["wakeUp"], {}) + assert isinstance(result, int) + assert is_stub is False + + +def test_dispatch_stub_module_returns_is_stub_true(): + d, _ = _dispatcher() + result, is_stub = d.dispatch("ALAnimationPlayer", "runTag", ["wave"], {}) + assert result is None + assert is_stub is True From 9d9e55a32fe1f4f79dfddbb370cbd57dcc859c67 Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 20:23:01 +0100 Subject: [PATCH 12/20] refactor shim_server.py to create_app factory with dispatcher --- src/shim_server.py | 193 +++++++++++---------------------- tests/unit/test_shim_server.py | 118 ++++++++++++++++++++ 2 files changed, 183 insertions(+), 128 deletions(-) create mode 100644 tests/unit/test_shim_server.py diff --git a/src/shim_server.py b/src/shim_server.py index 268df7b..2ef8ba7 100644 --- a/src/shim_server.py +++ b/src/shim_server.py @@ -1,22 +1,23 @@ -from flask import Flask, request, jsonify +"""Sim shim HTTP entry point. Wire-compatible with the real-robot shim at +`py3-naoqi-bridge/shim_server.py`. + +The `create_app` factory takes a module-adapter dict so tests can supply +mocked adapters without launching qibullet. The `__main__` block wires up +the real driver, task registry, and joint publisher.""" + import os import sys import threading import struct import time import zmq -from driver import QiBulletDriver - -# --- Initialization --- -# We launch the simulation on import/startup. -# Default headless so the sim runs on X-less lab boxes; set QIBULLET_GUI=true -# to get the pybullet window when a display is available. -_gui = os.environ.get("QIBULLET_GUI", "false").lower() in ("1", "true", "yes") -driver = QiBulletDriver(gui=_gui) - +from flask import Flask, request, jsonify +from dispatcher import Dispatcher class JointPublisher(threading.Thread): - """50Hz PUB of HeadYaw/HeadPitch on :5560 topic 'joints', wire-compatible with py3-naoqi-bridge/state_service.py.""" + """50Hz PUB of HeadYaw/HeadPitch on :5560 topic 'joints', wire-compatible + with py3-naoqi-bridge/state_service.py.""" + def __init__(self, pepper, rate_hz=50.0, port=5560): super(JointPublisher, self).__init__() self.pepper = pepper @@ -34,131 +35,67 @@ def run(self): try: angles = self.pepper.getAnglesPosition(["HeadYaw", "HeadPitch"]) if angles and len(angles) == 2: - payload = struct.pack('dff', loop_start, angles[0], angles[1]) + payload = struct.pack("dff", loop_start, angles[0], angles[1]) sock.send_multipart([b"joints", payload]) except Exception as e: - # Swallow so a transient qibullet hiccup doesn't kill the thread and silently lose proprioception. sys.stderr.write("[JointPublisher] Error: {}\n".format(e)) elapsed = time.time() - loop_start if elapsed < self.dt: time.sleep(self.dt - elapsed) -joint_publisher = JointPublisher(driver.pepper) -joint_publisher.start() - -app = Flask(__name__) - -@app.route("/api/call", methods=["POST"]) -def call_naoqi_method(): - data = request.get_json() - module = data.get("module") - method = data.get("method") - args = data.get("args", []) - - print(f"Received call: {module}.{method} with args {args}") - - try: - result = None - - # --- API MAPPING --- - if module == "ALTextToSpeech" and method == "say": - driver.say(args[0]) - - elif module == "ALMotion": - if method == "move": - # args: x, y, theta - # Ensure we cast to float, as JSON might pass them as strings or ints - x = float(args[0]) - y = float(args[1]) - theta = float(args[2]) - print(f"[Shim] Executing Move: x={x}, y={y}, theta={theta}") - driver.move(x, y, theta) - elif method == "moveToward": - # Naoqi: moveToward(vx, vy, vth) -> Velocity control - x = float(args[0]) - y = float(args[1]) - theta = float(args[2]) - # print(f"[Shim] Executing moveToward: x={x}, y={y}, theta={theta}") - driver.move(x, y, theta) - - elif method == "stopMove": - print("[Shim] Executing stopMove") - driver.stop_motion() - - elif method == "getRobotPosition": - # args: useSensors (bool) - ignored in shim, we give ground truth - # Returns [x, y, theta] - result = driver.get_position() - - elif method == "moveTo": - # Fallback to move(0,0,0) or warn - print("WARNING: moveTo called (blocking), treating as move(0,0,0) or ignored for async shim") - - elif module == "ALRobotPosture": - if method == "goToPosture": - # args: posture_name, speed - p_name = args[0] - speed = 0.5 - if len(args) > 1: - speed = float(args[1]) - driver.set_posture(p_name, speed) - result = True - - elif module == "ALAnimationPlayer": - # Stub for animations - print(f"[Shim] ALAnimationPlayer.{method} called (Not Implemented in qibullet). Ignored.") - result = None - - elif module == "ALLaser": - if method == "show": - # Custom method to toggle debug lines - # args: [True/False] - state = True - if len(args) > 0: - state = bool(args[0]) - driver.show_lasers(state) - result = True - - elif module == "ALAutonomousLife": - # Stub for life - print(f"[Shim] ALAutonomousLife.{method} called (Not Implemented). Ignored.") - result = "disabled" - - elif module == "ALBasicAwareness": - print(f"[Shim] ALBasicAwareness.{method} called. Ignored.") - result = None - - elif module == "ALMemory": - if method == "getData": - key = args[0] - # print(f"[Shim] ALMemory.getData: {key}") - - if key == "Device/SubDeviceList/Platform/Front/Sonar/Sensor/Value": - result = driver.get_front_sonar() - elif key == "Device/SubDeviceList/Platform/Back/Sonar/Sensor/Value": - result = driver.get_back_sonar() - elif key == "Device/SubDeviceList/Platform/Laser/Front/Sensor/Value": - # Non-standard key, but exposing the raw array - result = driver.get_front_laser() - else: - # Generic fallback - result = None - - # TODO: Add more modules (ALVideoDevice, ALMemory, etc.) - - return jsonify({"result": result}) - - except Exception as e: - sys.stderr.write(f"Error handling request: {e}\n") - return jsonify({"error": str(e)}), 500 +# --- Flask app factory --- + +def create_app(module_adapters): + """Build the Flask app around a pre-built module-adapter dict.""" + app = Flask(__name__) + dispatcher = Dispatcher(module_adapters) + + @app.route("/api/call", methods=["POST"]) + def call_naoqi_method(): + data = request.get_json(silent=True) or {} + module = data.get("module") + method = data.get("method") + if not module or not method: + return jsonify({"error": "Invalid request: missing 'module' or 'method'"}), 400 + + args = data.get("args", []) + kwargs = data.get("kwargs", {}) + + try: + result, is_stub = dispatcher.dispatch(module, method, args, kwargs) + except AttributeError as e: + sys.stderr.write("Error calling {}.{}: {}\n".format(module, method, e)) + return jsonify({"error": str(e)}), 500 + except Exception as e: + sys.stderr.write("Adapter raised on {}.{}: {}\n".format(module, method, e)) + return jsonify({"error": str(e)}), 500 + + response = jsonify({"result": result}) + if is_stub: + response.headers["X-Sim-Stub"] = "1" + sys.stderr.write("[SIM-STUB] {}.{}(args={!r}) -> {!r}\n".format( + module, method, args, result + )) + return response + + return app + + +# --- main: spin up qibullet, build adapters, start app --- if __name__ == "__main__": - # Start Flask - # Note: PyBullet GUI needs the main thread usually? - # qibullet spawns its own thread for physics. - # Flask can run on main thread IF qibullet is happy backgrounded. - # Actually qibullet examples usually run script on main thread. - + from driver import QiBulletDriver + from post_tasks import TaskRegistry + from adapters import build_module_adapters + + _gui = os.environ.get("QIBULLET_GUI", "false").lower() in ("1", "true", "yes") + driver = QiBulletDriver(gui=_gui) + task_registry = TaskRegistry() + module_adapters = build_module_adapters(driver.pepper, task_registry) + + JointPublisher(driver.pepper).start() + + app = create_app(module_adapters) print("Starting Flask Shim Server on port 5000...") - app.run(host="0.0.0.0", port=5000, debug=False, use_reloader=False) + app.run(host="0.0.0.0", port=5000, debug=False, use_reloader=False, threaded=True) diff --git a/tests/unit/test_shim_server.py b/tests/unit/test_shim_server.py new file mode 100644 index 0000000..be2e84d --- /dev/null +++ b/tests/unit/test_shim_server.py @@ -0,0 +1,118 @@ +from unittest.mock import MagicMock +import pytest +from shim_server import create_app +from post_tasks import TaskRegistry +from adapters import build_module_adapters + + +def _make_pepper(): + """Return a MagicMock that mimics PepperVirtual's attribute surface.""" + pepper = MagicMock(spec=[]) + pepper.move = MagicMock(return_value=None) + pepper.getPosition = MagicMock(return_value=(0.0, 0.0, 0.0)) + pepper.getAnglesPosition = MagicMock(return_value=[0.0, 0.0]) + pepper.goToPosture = MagicMock(return_value=True) + pepper.showLaser = MagicMock(return_value=None) + pepper.getFrontLaserValue = MagicMock(return_value=[3.0]) + pepper.getLeftLaserValue = MagicMock(return_value=[3.0]) + pepper.getRightLaserValue = MagicMock(return_value=[3.0]) + return pepper + + +def _client(): + pepper = _make_pepper() + registry = TaskRegistry() + adapters = build_module_adapters(pepper, registry) + app = create_app(adapters) + return app.test_client(), pepper + + +def test_post_call_returns_200_for_real_method(): + client, pepper = _client() + resp = client.post("/api/call", json={ + "module": "ALMotion", "method": "move", + "args": [1.0, 0, 0], "kwargs": {}, + }) + assert resp.status_code == 200 + assert resp.get_json() == {"result": None} + assert resp.headers.get("X-Sim-Stub") is None + pepper.move.assert_called_once_with(1.0, 0.0, 0.0) + + +def test_post_call_sets_xsimstub_header_for_stub(): + client, _ = _client() + resp = client.post("/api/call", json={ + "module": "ALMotion", "method": "wakeUp", + "args": [], "kwargs": {}, + }) + assert resp.status_code == 200 + assert resp.get_json() == {"result": None} + assert resp.headers.get("X-Sim-Stub") == "1" + + +def test_unknown_module_returns_500(): + client, _ = _client() + resp = client.post("/api/call", json={ + "module": "ALNonExistent", "method": "doStuff", + "args": [], "kwargs": {}, + }) + assert resp.status_code == 500 + assert "No adapter for module" in resp.get_json()["error"] + + +def test_unknown_method_returns_500(): + client, _ = _client() + resp = client.post("/api/call", json={ + "module": "ALMotion", "method": "definitelyFake", + "args": [], "kwargs": {}, + }) + assert resp.status_code == 500 + assert "has no method" in resp.get_json()["error"] + + +def test_missing_module_key_returns_400(): + client, _ = _client() + resp = client.post("/api/call", json={"method": "move", "args": []}) + assert resp.status_code == 400 + assert "module" in resp.get_json()["error"] + + +def test_missing_method_key_returns_400(): + client, _ = _client() + resp = client.post("/api/call", json={"module": "ALMotion", "args": []}) + assert resp.status_code == 400 + assert "method" in resp.get_json()["error"] + + +def test_adapter_exception_returns_500(): + client, pepper = _client() + pepper.move.side_effect = RuntimeError("pybullet exploded") + resp = client.post("/api/call", json={ + "module": "ALMotion", "method": "move", "args": [0, 0, 0], + }) + assert resp.status_code == 500 + assert "pybullet exploded" in resp.get_json()["error"] + + +def test_post_dispatch_returns_task_id(): + client, _ = _client() + resp = client.post("/api/call", json={ + "module": "ALMotion", "method": "post", + "args": ["wakeUp"], "kwargs": {}, + }) + assert resp.status_code == 200 + body = resp.get_json() + assert isinstance(body["result"], int) + assert body["result"] >= 1 + + +def test_call_with_omitted_kwargs_uses_empty_dict_default(): + """Verifies the route's data.get('kwargs', {}) default. Callers that + don't send a kwargs key (e.g. curl users) still work.""" + client, pepper = _client() + resp = client.post("/api/call", json={ + "module": "ALMotion", "method": "move", + "args": [1.0, 0, 0], + }) + assert resp.status_code == 200 + pepper.move.assert_called_once_with(1.0, 0.0, 0.0) From 7defff36d563cbb6f77fe2f43257d0a94be79a09 Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 20:29:52 +0100 Subject: [PATCH 13/20] refactor QiBulletDriver to sim lifecycle only --- src/driver.py | 72 +++++++-------------------------------------------- 1 file changed, 9 insertions(+), 63 deletions(-) diff --git a/src/driver.py b/src/driver.py index 1923246..589859c 100644 --- a/src/driver.py +++ b/src/driver.py @@ -1,78 +1,24 @@ +"""This is the qibullet sim lifecycle owner. + +Holds the simulation and the Pepper instance the adapters dispatch against. +Adapters call into `self.pepper` directly.""" + from qibullet import SimulationManager -from qibullet import PepperVirtual -import time + class QiBulletDriver: + def __init__(self, gui=True): self.simulation_manager = SimulationManager() - # Launch simulation self.client = self.simulation_manager.launchSimulation(gui=gui) - # Spawn Pepper print("Spawning Pepper...") self.pepper = self.simulation_manager.spawnPepper( - self.client, - spawn_ground_plane=True + self.client, spawn_ground_plane=True ) print("Pepper spawned.") - - # Subscribe to Lasers to simulate Sonar self.pepper.subscribeLaser() print("Lasers subscribed (simulating Sonar).") - def start_motion(self, x, y, theta): - # Explicit naming to clear up confusion - self.pepper.move(x, y, theta) - - def move(self, x, y, theta): - self.pepper.move(x, y, theta) - - def stop_motion(self): - self.pepper.move(0.0, 0.0, 0.0) - - def set_posture(self, posture_name, speed=0.5): - # qibullet supports: Stand, StandInit, StandZero, Crouch, Sit, SitRelax - # Naoqi might send others, but we map strictly or pass through - print(f"[QiBullet] Setting Posture: {posture_name}") - self.pepper.goToPosture(posture_name, speed) - - def get_front_sonar(self): - # Proxy: Use minimum distance from Front Laser - # Returns float in meters - lasers = self.pepper.getFrontLaserValue() - if not lasers: - return 3.0 # Max range if empty - return min(lasers) - - def get_back_sonar(self): - # Proxy: Use minimum distance from Left/Right lasers (which cover rear quadrants) - left = self.pepper.getLeftLaserValue() - right = self.pepper.getRightLaserValue() - combined = left + right - if not combined: - return 3.0 - return min(combined) - - def get_front_laser(self): - # Returns the full list of float values (rays) - return self.pepper.getFrontLaserValue() - - def show_lasers(self, state=True): - # Toggles the red/green debug lines in the PyBullet GUI - self.pepper.showLaser(state) - - def get_position(self): - # Returns [x, y, theta] in World Frame - # qibullet getPosition() returns x, y, theta - return self.pepper.getPosition() - - def say(self, text): - print(f"[QiBullet] ROBOT SAYS: {text}") - - def step(self): - # PyBullet steps automatically in GUI mode usually, - # but we can force step if needed or just keep thread alive. - time.sleep(0.1) - return True - def stop(self): + """Tear down the simulation.""" self.simulation_manager.stopSimulation(self.client) From c9880f0bce487079fbdc36e3393fb9af7b3a5f91 Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 20:56:15 +0100 Subject: [PATCH 14/20] add SimStubWarning opt in clien warning in NaoqiClient --- py3-naoqi-bridge/naoqi_proxy.py | 55 +++++++++--- .../tests/test_sim_stub_warning.py | 85 +++++++++++++++++++ 2 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 py3-naoqi-bridge/tests/test_sim_stub_warning.py diff --git a/py3-naoqi-bridge/naoqi_proxy.py b/py3-naoqi-bridge/naoqi_proxy.py index f3e7c5f..ad4ccd2 100644 --- a/py3-naoqi-bridge/naoqi_proxy.py +++ b/py3-naoqi-bridge/naoqi_proxy.py @@ -1,9 +1,19 @@ import json +import os import urllib.request +import urllib.error +import warnings + class NaoqiProxyError(Exception): - """Custom exception for errors from the NAOqi shim server.""" - pass + """Raised when the shim returns an error response.""" + + +class SimStubWarning(UserWarning): + """Triggered when the sim shim returns a stub (no-op) response and the + client has opted into stub-aware warnings via `warn_on_stubs=True` or + the `NAOQI_SIM_WARN_STUBS=1` env var.""" + class NaoqiModule: def __init__(self, client, module_name): @@ -21,8 +31,13 @@ def method_caller(*args, **kwargs): return method_caller class NaoqiClient: - def __init__(self, host="localhost", port=5000): + def __init__(self, host="localhost", port=5000, warn_on_stubs=None): self._shim_url = f"http://{host}:{port}/api/call" + if warn_on_stubs is None: + warn_on_stubs = os.environ.get("NAOQI_SIM_WARN_STUBS", "").lower() in ( + "1", "true", "yes" + ) + self._warn_on_stubs = warn_on_stubs def __getattr__(self, module_name): return NaoqiModule(self, module_name) @@ -34,32 +49,46 @@ def _call_shim(self, module, method, args, kwargs): "args": list(args), "kwargs": kwargs, } - req = urllib.request.Request( self._shim_url, - data=json.dumps(payload).encode('utf-8'), - headers={'Content-Type': 'application/json'} + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, ) try: with urllib.request.urlopen(req) as response: - response_data = json.loads(response.read().decode('utf-8')) + response_data = json.loads(response.read().decode("utf-8")) + if self._warn_on_stubs and self._is_sim_stub(response): + warnings.warn( + "{}.{} is a sim stub (no qibullet equivalent); " + "returning {!r}".format(module, method, response_data.get("result")), + SimStubWarning, + stacklevel=4, + ) if response.status == 200: return True, response_data.get("result"), None - else: - error_message = response_data.get("error", "Unknown error from shim server") - return False, None, error_message + return False, None, response_data.get("error", "Unknown error") except urllib.error.HTTPError as e: error_message = f"HTTP Error {e.code}: {e.reason}" try: - error_data = json.loads(e.read().decode('utf-8')) + error_data = json.loads(e.read().decode("utf-8")) error_message += f" - {error_data.get('error', 'No error details')}" except json.JSONDecodeError: - pass # Not a JSON error response + pass return False, None, error_message except urllib.error.URLError as e: return False, None, f"URL Error: {e.reason}" except json.JSONDecodeError: return False, None, "Failed to decode JSON response from shim server." + except SimStubWarning: + # Let SimStubWarning propagate even if it's been turned into an errorbby the warnings filter. + raise except Exception as e: - return False, None, f"An unexpected error occurred: {e}" \ No newline at end of file + return False, None, f"An unexpected error occurred: {e}" + + @staticmethod + def _is_sim_stub(response): + try: + return response.headers.get("X-Sim-Stub") == "1" + except Exception: + return False diff --git a/py3-naoqi-bridge/tests/test_sim_stub_warning.py b/py3-naoqi-bridge/tests/test_sim_stub_warning.py new file mode 100644 index 0000000..ace70bc --- /dev/null +++ b/py3-naoqi-bridge/tests/test_sim_stub_warning.py @@ -0,0 +1,85 @@ +import json +import os +import warnings +from contextlib import contextmanager +from unittest.mock import patch +import pytest + +# Allow the test to import naoqi_proxy directly. +import sys +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from naoqi_proxy import NaoqiClient, SimStubWarning + + +@contextmanager +def _fake_urlopen(body, headers=None, status=200): + headers = headers or {} + + class FakeResponse: + def __init__(self): + self.status = status + self.headers = headers + + def read(self): + return json.dumps(body).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + with patch("urllib.request.urlopen", return_value=FakeResponse()) as mock: + yield mock + + +def test_default_no_warning_on_stub_response(): + with _fake_urlopen({"result": None}, headers={"X-Sim-Stub": "1"}): + client = NaoqiClient() # default warn_on_stubs=False + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + client.ALMotion.wakeUp() + sim_warnings = [w for w in recorded if issubclass(w.category, SimStubWarning)] + assert sim_warnings == [] + + +def test_opt_in_warning_on_stub_response(): + with _fake_urlopen({"result": None}, headers={"X-Sim-Stub": "1"}): + client = NaoqiClient(warn_on_stubs=True) + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + client.ALMotion.wakeUp() + sim_warnings = [w for w in recorded if issubclass(w.category, SimStubWarning)] + assert len(sim_warnings) == 1 + assert "ALMotion.wakeUp" in str(sim_warnings[0].message) + + +def test_no_warning_when_header_absent(): + with _fake_urlopen({"result": None}, headers={}): + client = NaoqiClient(warn_on_stubs=True) + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + client.ALMotion.move(0, 0, 0) + sim_warnings = [w for w in recorded if issubclass(w.category, SimStubWarning)] + assert sim_warnings == [] + + +def test_env_var_enables_warning(monkeypatch): + monkeypatch.setenv("NAOQI_SIM_WARN_STUBS", "1") + with _fake_urlopen({"result": None}, headers={"X-Sim-Stub": "1"}): + client = NaoqiClient() # no explicit flag; env var should win + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + client.ALTracker.lookAt([0, 0, 0]) + sim_warnings = [w for w in recorded if issubclass(w.category, SimStubWarning)] + assert len(sim_warnings) == 1 + + +def test_error_filter_raises_on_stub(): + with _fake_urlopen({"result": None}, headers={"X-Sim-Stub": "1"}): + client = NaoqiClient(warn_on_stubs=True) + with warnings.catch_warnings(): + warnings.simplefilter("error", SimStubWarning) + with pytest.raises(SimStubWarning): + client.ALMotion.wakeUp() From fa00f3f44f1daa19246125f7f2b13499ac0d34ca Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 21:14:03 +0100 Subject: [PATCH 15/20] add QIBULLET_GUI as env var to run.sh --- run.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/run.sh b/run.sh index d5bda59..6a869c4 100755 --- a/run.sh +++ b/run.sh @@ -33,6 +33,7 @@ docker run -it --rm \ -e DISPLAY=$DISPLAY \ -e NAOQI_IP=${NAOQI_IP:-"127.0.0.1"} \ -e NAOQI_PORT=${NAOQI_PORT:-9559} \ + -e QIBULLET_GUI=${QIBULLET_GUI:-false} \ -v /tmp/.X11-unix:/tmp/.X11-unix \ -v $(pwd)/py3-naoqi-bridge:/home/pepperdev/py3-naoqi-bridge \ -v $(pwd)/src:/home/pepperdev/src \ From ac16642f78dc50cdc7a5dc76af01410c2a88dd26 Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 22:54:32 +0100 Subject: [PATCH 16/20] finalise fix: dispatcher error wording, stacklevel fix, GUI env var, doc refresh --- README.md | 23 +++++++++++++++---- py3-naoqi-bridge/naoqi_proxy.py | 2 +- .../tests/test_sim_stub_warning.py | 12 ++++++++++ src/dispatcher.py | 14 +++++++++-- tests/unit/test_dispatcher.py | 6 ++--- tests/unit/test_shim_server.py | 4 +++- 6 files changed, 49 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9b6dde9..8454350 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,14 @@ export PEPPERBOX_ACCEPT_SOFTBANK_LICENSE=1 The relevant licence files ship with the qiBullet package; see the project at `softbankrobotics-research/qibullet`. +To show the PyBullet GUI window on launch: + +```bash +QIBULLET_GUI=true ./run.sh +``` + +By default PepperBox ships with the simulator set to headless. + ## Build ```bash @@ -82,13 +90,18 @@ PepperBox/ ├── entrypoint.sh # mode dispatch + pre-flight checks ├── run.sh # standalone host launcher ├── setup.sh # one-shot pynaoqi installer -├── src/ -│ ├── shim_server.py # qiBullet (sim) shim +├── src/ # qiBullet (sim) backend +│ ├── shim_server.py # Flask route + create_app factory +│ ├── dispatcher.py # per-module dispatch returning (result, is_stub) +│ ├── driver.py # QiBulletDriver sim lifecycle +│ ├── post_tasks.py # TaskRegistry for NAOqi post() async dispatch +│ ├── adapters/ # per-module NAOqi adapters (motion, memory, ...) │ └── setup_wizard.py # qiBullet asset installer -└── py3-naoqi-bridge/ - ├── shim_server.py # pynaoqi (physical) shim +└── py3-naoqi-bridge/ # pynaoqi (physical) backend + shared Python 3 client + ├── shim_server.py # pynaoqi (physical) shim + ├── naoqi_proxy.py # NaoqiClient — used against both shims ├── video_streamer.py - └── ... # NAOqi-side services + └── ... # NAOqi-side services and clients ``` ## Licence diff --git a/py3-naoqi-bridge/naoqi_proxy.py b/py3-naoqi-bridge/naoqi_proxy.py index ad4ccd2..24638a4 100644 --- a/py3-naoqi-bridge/naoqi_proxy.py +++ b/py3-naoqi-bridge/naoqi_proxy.py @@ -63,7 +63,7 @@ def _call_shim(self, module, method, args, kwargs): "{}.{} is a sim stub (no qibullet equivalent); " "returning {!r}".format(module, method, response_data.get("result")), SimStubWarning, - stacklevel=4, + stacklevel=3, ) if response.status == 200: return True, response_data.get("result"), None diff --git a/py3-naoqi-bridge/tests/test_sim_stub_warning.py b/py3-naoqi-bridge/tests/test_sim_stub_warning.py index ace70bc..44f10d5 100644 --- a/py3-naoqi-bridge/tests/test_sim_stub_warning.py +++ b/py3-naoqi-bridge/tests/test_sim_stub_warning.py @@ -83,3 +83,15 @@ def test_error_filter_raises_on_stub(): warnings.simplefilter("error", SimStubWarning) with pytest.raises(SimStubWarning): client.ALMotion.wakeUp() + +def test_warning_points_at_caller_source_line(): + """Asserts the warning's reported filename is the caller's file, not naoqi_proxy.py.""" + with _fake_urlopen({"result": None}, headers={"X-Sim-Stub": "1"}): + client = NaoqiClient(warn_on_stubs=True) + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + client.ALMotion.wakeUp() + sim_warnings = [w for w in recorded if issubclass(w.category, SimStubWarning)] + assert len(sim_warnings) == 1 + assert "test_sim_stub_warning.py" in sim_warnings[0].filename + assert "naoqi_proxy.py" not in sim_warnings[0].filename diff --git a/src/dispatcher.py b/src/dispatcher.py index 43e2ef8..486278d 100644 --- a/src/dispatcher.py +++ b/src/dispatcher.py @@ -26,10 +26,20 @@ def dispatch(self, module, method, args, kwargs): ) target = args[0] inner_args = args[1:] - result = adapter.post(target, *inner_args, **kwargs) + try: + result = adapter.post(target, *inner_args, **kwargs) + except AttributeError as e: + raise AttributeError( + "Module {!r}: {}".format(module, e) + ) return result, False - handler = getattr(adapter, method) + try: + handler = getattr(adapter, method) + except AttributeError as e: + raise AttributeError( + "Module {!r}: {}".format(module, e) + ) result = handler(*args, **kwargs) is_stub = getattr(handler, "_sim_stub", False) return result, is_stub diff --git a/tests/unit/test_dispatcher.py b/tests/unit/test_dispatcher.py index 2bc7af6..0d546b1 100644 --- a/tests/unit/test_dispatcher.py +++ b/tests/unit/test_dispatcher.py @@ -43,19 +43,19 @@ def test_dispatch_unknown_module_raises_attribute_error(): def test_dispatch_unknown_method_on_known_module_raises(): d, _ = _dispatcher() - with pytest.raises(AttributeError, match="has no method"): + with pytest.raises(AttributeError, match="Module 'ALMotion'.*has no method"): d.dispatch("ALMotion", "definitelyFake", [], {}) def test_dispatch_blocks_underscore_prefix(): d, _ = _dispatcher() - with pytest.raises(AttributeError, match="is private"): + with pytest.raises(AttributeError, match="Module 'ALMotion'.*is private"): d.dispatch("ALMotion", "_secret", [], {}) def test_dispatch_blocks_loadrobot(): d, _ = _dispatcher() - with pytest.raises(AttributeError, match="is blocked"): + with pytest.raises(AttributeError, match="Module 'ALMotion'.*is blocked"): d.dispatch("ALMotion", "loadRobot", [], {}) diff --git a/tests/unit/test_shim_server.py b/tests/unit/test_shim_server.py index be2e84d..56ab80d 100644 --- a/tests/unit/test_shim_server.py +++ b/tests/unit/test_shim_server.py @@ -67,7 +67,9 @@ def test_unknown_method_returns_500(): "args": [], "kwargs": {}, }) assert resp.status_code == 500 - assert "has no method" in resp.get_json()["error"] + error = resp.get_json()["error"] + assert "Module 'ALMotion'" in error + assert "has no method" in error def test_missing_module_key_returns_400(): From 0fc758b005a9af088c0e1c64f543c0bb436594a2 Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 23:36:17 +0100 Subject: [PATCH 17/20] remove superseded test --- py3-naoqi-bridge/tests/test_naoqi_proxy.py | 97 ---------------------- 1 file changed, 97 deletions(-) delete mode 100644 py3-naoqi-bridge/tests/test_naoqi_proxy.py diff --git a/py3-naoqi-bridge/tests/test_naoqi_proxy.py b/py3-naoqi-bridge/tests/test_naoqi_proxy.py deleted file mode 100644 index 57aae81..0000000 --- a/py3-naoqi-bridge/tests/test_naoqi_proxy.py +++ /dev/null @@ -1,97 +0,0 @@ -import unittest -import os -import time -from ..naoqi_proxy import NaoqiClient, NaoqiProxyError - -# Assuming the shim server is running at localhost:5000 -# And the robot.env is configured correctly for the shim server - -class TestNaoqiProxy(unittest.TestCase): - - @classmethod - def setUpClass(cls): - # Initialize NaoqiClient once for all tests - cls.client = NaoqiClient() - - def test_01_text_to_speech_say(self): - """Test ALTextToSpeech.say method.""" - print("\n--- Test: ALTextToSpeech.say ---") - try: - # This will send a command to the robot, no direct return value to assert - self.client.ALTextToSpeech.say("Hello from unittest") - print("ALTextToSpeech.say successful.") - self.assertTrue(True) # If no exception, consider it a success - except NaoqiProxyError as e: - self.fail(f"ALTextToSpeech.say failed with NaoqiProxyError: {e}") - except Exception as e: - self.fail(f"ALTextToSpeech.say failed with unexpected error: {e}") - - def test_02_motion_get_robot_config(self): - """Test ALMotion.getRobotConfig method and its return value.""" - print("\n--- Test: ALMotion.getRobotConfig ---") - try: - config = self.client.ALMotion.getRobotConfig() - print(f"ALMotion.getRobotConfig returned: {config}") - self.assertIsInstance(config, list) - # Further assertions could be made if the expected config structure is known - except NaoqiProxyError as e: - self.fail(f"ALMotion.getRobotConfig failed with NaoqiProxyError: {e}") - except Exception as e: - self.fail(f"ALMotion.getRobotConfig failed with unexpected error: {e}") - - def test_03_error_for_non_existent_method(self): - """Test that calling a non-existent method raises NaoqiProxyError.""" - print("\n--- Test: Non-existent method ---") - with self.assertRaises(NaoqiProxyError) as cm: - self.client.ALTextToSpeech.nonExistentMethod("test") - print(f"Caught expected error: {cm.exception}") - self.assertIn("Can't find method: nonExistentMethod", str(cm.exception)) - - def test_04_error_for_non_existent_module(self): - """Test that calling a method on a non-existent module raises NaoqiProxyError.""" - print("\n--- Test: Non-existent module ---") - with self.assertRaises(NaoqiProxyError) as cm: - self.client.ALNonExistentModule.someMethod("test") - print(f"Caught expected error: {cm.exception}") - self.assertIn("Can't find service: ALNonExistentModule", str(cm.exception)) - - def test_05_argument_passing(self): - """Test passing various argument types to a method.""" - print("\n--- Test: Argument Passing ---") - try: - # Assuming ALMemory.insertData can take various types - self.client.ALMemory.insertData("testKeyString", "stringValue") - self.client.ALMemory.insertData("testKeyInt", 123) - self.client.ALMemory.insertData("testKeyFloat", 123.45) - self.client.ALMemory.insertData("testKeyBool", True) - self.client.ALMemory.insertData("testKeyList", [1, "two", False]) - self.client.ALMemory.insertData("testKeyDict", {"a": 1, "b": "two"}) - print("ALMemory.insertData with various argument types successful.") - self.assertTrue(True) - except NaoqiProxyError as e: - self.fail(f"Argument passing test failed with NaoqiProxyError: {e}") - except Exception as e: - self.fail(f"Argument passing test failed with unexpected error: {e}") - - # def test_06_get_data_from_memory(self): - # """Test retrieving data from ALMemory.""" - # print("\n--- Test: Get Data from Memory ---") - # try: - # # Retrieve data inserted in the previous test - # string_val = self.client.ALMemory.getData("testKeyString") - # self.assertEqual(string_val, "stringValue") - - # int_val = self.client.ALMemory.getData("testKeyInt") - # self.assertEqual(int_val, 123) - - # list_val = self.client.ALMemory.getData("testKeyList") - # self.assertEqual(list_val, [1, "two", False]) - - # print("ALMemory.getData successful.") - # except NaoqiProxyError as e: - # self.fail(f"ALMemory.getData test failed with NaoqiProxyError: {e}") - # except Exception as e: - # self.fail(f"ALMemory.getData test failed with unexpected error: {e}") - -if __name__ == '__main__': - unittest.main() From b333d1a498ee326b9d543a53166915fef9a64a01 Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 23:46:13 +0100 Subject: [PATCH 18/20] update shim README --- py3-naoqi-bridge/README.md | 88 +++++++++++--------------------------- 1 file changed, 24 insertions(+), 64 deletions(-) diff --git a/py3-naoqi-bridge/README.md b/py3-naoqi-bridge/README.md index 354d969..ced4734 100644 --- a/py3-naoqi-bridge/README.md +++ b/py3-naoqi-bridge/README.md @@ -1,6 +1,6 @@ # Python 3 to NAOqi Bridge -This directory contains the Python 2.7 "shim" server and the Python 3 "proxy" client, which together form a bridge to allow Python 3 applications to communicate with the `naoqi` robot SDK. +This directory contains the Python 2.7 "shim" server and the Python 3 "proxy" client. When put together they form a bridge to allow Python 3 applications to communicate with the `naoqi` robot SDK. ## Purpose @@ -9,96 +9,56 @@ The `pynaoqi` SDK is only compatible with Python 2.7. The Python 2.7 shim server ## Components * **`shim_server.py`**: The Python 2.7 Flask server that exposes the NAOqi API via HTTP. -* **`naoqi_proxy.py`**: The Python 3 client library that provides a convenient interface to interact with the shim server. +* **`naoqi_proxy.py`**: The Python 3 client library that provides an interface to interact with the shim server. Exposes the optional `warn_on_stubs` flag and `SimStubWarning` class for sim-mode introspection. * **`examples/`**: Directory containing example scripts demonstrating the usage of the `NaoqiClient` (e.g., `basic_usage_example.py`, `helloworld_in_python3.py`). -* **`test_naoqi_proxy.py`**: A comprehensive test suite for the `naoqi_proxy` client. +* **`tests/`**: Unit and integration tests. `test_audio_publisher.py` covers the ZMQ audio publisher; `test_motion.py` is a scripted integration test against a running shim; `test_sim_stub_warning.py` covers the client-side opt-in warning behavior with mocked HTTP responses. -## How to Run the Bridge +## Using the NaoqiClient -To use the bridge, both the Python 2.7 shim server and the NAOqi instance (simulated or physical robot) must be running and accessible. +The shim server is launched by the project-level `./run.sh` — see the top-level README for setup and configuration (`NAOQI_IP`, `NAOQI_PORT`, `robot.env`, etc.). Once the shim is running on port 5000, Python 3 code uses the `NaoqiClient` to interface. -### 1. Configure the NAOqi Connection +### Initialisation -The shim server connects to the NAOqi instance using the IP address and port specified in the `robot.env` file. Create this file in the `py3-naoqi-bridge` directory if it doesn't exist, and add the `NAOQI_IP` and `NAOQI_PORT`: - -``` -NAOQI_IP=127.0.0.1 -NAOQI_PORT=37647 -``` - -Replace `127.0.0.1` and `41703` with the actual IP address and port of your NAOqi instance. - -### 2. Run the Python 2.7 Shim Server - -The server must be run from within the PepperBox Docker container. Launch it from the terminal using Python 2.7: +```python +from naoqi_proxy import NaoqiClient, NaoqiProxyError -```bash -python /home/pepperdev/apps/py3-naoqi-bridge/shim_server.py +client = NaoqiClient(host="localhost", port=5000) ``` -This server will listen for incoming requests on `http://0.0.0.0:5000`. - -### 3. Use the Python 3 Proxy Client - -Once the shim server is running, you can use the `NaoqiClient` in your Python 3 applications. The client defaults to connecting to `http://localhost:5000`. - -#### Initialiation +Optional sim-mode introspection: ```python -from naoqi_proxy import NaoqiClient, NaoqiProxyError - -client = NaoqiClient(host="localhost", port=5000) # Host and port of the shim server +client = NaoqiClient(warn_on_stubs=True) # produce SimStubWarning on stub responses +# or set NAOQI_SIM_WARN_STUBS=1 in the environment ``` -#### Calling NAOqi Methods +### Calling NAOqi Methods -Access NAOqi modules as attributes of the `client` object, and then call their methods. Arguments are passed directly. +NAOqi modules are accessed as attributes of the client; methods are called directly with positional and keyword arguments. ```python try: - # Example: Make the robot say something client.ALTextToSpeech.say("Hello from Python 3 proxy") - - # Example: Get robot configuration robot_config = client.ALMotion.getRobotConfig() - print(f"Robot configuration: {robot_config}") - - # Example: Insert data into ALMemory client.ALMemory.insertData("myKey", "myValue") - except NaoqiProxyError as e: print(f"NAOqi Proxy Error: {e}") -except Exception as e: - print(f"An unexpected error occurred: {e}") ``` -#### Error Handling - -Errors originating from the NAOqi instance or communication issues will be raised as `NaoqiProxyError` exceptions on the Python 3 client side. It is recommended to wrap your NAOqi calls in `try...except NaoqiProxyError` blocks. +Errors from the shim or the underlying NAOqi instance surface as `NaoqiProxyError`. Wrap calls in `try...except NaoqiProxyError` blocks. ## Testing the Bridge -To verify the functionality of the bridge, you can run the provided test suite: - -1. Ensure the Docker container is running. -2. Ensure the Python 2.7 shim server (`shim_server.py`) is running inside the container. -3. Execute the tests using Python 3: +Unit tests for the client library and the audio publisher run inside the pepper-box container via the project-wide wrapper: - ```bash - python3 /home/pepperdev/apps/py3-naoqi-bridge/test_naoqi_proxy.py - ``` - -### Tested Functionality - -The `test_naoqi_proxy.py` suite validates the following aspects of the bridge: - -* **Basic Communication**: Successful invocation of `ALTextToSpeech.say` and `ALMotion.getRobotConfig`. -* **Error Propagation**: Correct handling and raising of `NaoqiProxyError` for non-existent NAOqi modules or methods. -* **Argument Handling**: Successful passing of various data types (strings, numbers, booleans, lists, dictionaries) to `ALMemory.insertData`. +```bash +./tests/run-in-docker.sh py3-naoqi-bridge/tests/test_sim_stub_warning.py -v +./tests/run-in-docker.sh py3-naoqi-bridge/tests/test_audio_publisher.py -v +``` -### Known Limitations +The scripted integration test `tests/test_motion.py` requires a running shim and a real or simulated robot. -* **ALMemory String Retrieval in Simulator**: When using a simulated NAOqi instance, retrieving string values that were part of a list inserted into `ALMemory` (e.g., `client.ALMemory.insertData("myList", [1, "two", False])` and then `client.ALMemory.getData("myList")`) may result in the string values being returned as `None`. This appears to be a limitation of the simulated NAOqi environment rather than a bug in the bridge implementation. The `test_06_get_data_from_memory` test case in `test_naoqi_proxy.py` is currently commented out to reflect this. +For the wider sim-side test suite (adapters, dispatcher, Flask routes) see the top-level `tests/unit/` directory and the project root README. ## API Reference (Shim Server) @@ -116,9 +76,9 @@ The request body must be a JSON object with the following fields: * `args` (array, optional): A list of positional arguments to pass to the method. Defaults to `[]`. * `kwargs` (object, optional): A dictionary of keyword arguments to pass to the method. Defaults to `{}`. -### Example Usage (cURL) +### Example Usage (curl) -Here is an example of using `curl` to make the robot say "Hello" via the shim server directly. +Here is an example of using `curl` to make the robot say "Hello, world!" via the shim server directly. ```bash curl -X POST \ From 9bc386462256b93ee92775f26e71dded67351774 Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Mon, 18 May 2026 23:49:15 +0100 Subject: [PATCH 19/20] add shim README reference to root README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8454350..f5b27c6 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A single image runs both modes; the `NAOQI_IP` environment variable selects whet - **Physical robot** — `NAOQI_IP=` runs the Python 2 `pynaoqi` bridge. - **Simulation** — `NAOQI_IP=127.0.0.1` (or unset) runs the Python 3 `qiBullet` simulator. -In both cases a Flask **shim server** on port 5000 exposes a unified HTTP API to your Python 3 client code. The client is agnostic to whether you connect to a physical or simulated robot. +In both cases a Flask **shim server** on port 5000 exposes a unified HTTP API to your Python 3 client code (see [`py3-naoqi-bridge/README.md`](py3-naoqi-bridge/README.md) for the wire contract and `NaoqiClient` reference). The client is agnostic to whether you connect to a physical or simulated robot. ## What's in the image From c61021dab5309f739789a448bf0c92fbc3f9a0ce Mon Sep 17 00:00:00 2001 From: jwgcurrie Date: Tue, 19 May 2026 00:01:02 +0100 Subject: [PATCH 20/20] update REAMDE --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f5b27c6..38c512b 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ A containerised backend for developing applications with Aldebaran/SoftBank's Pepper robots. -A single image runs both modes; the `NAOQI_IP` environment variable selects whether you connect with a: +Switch between modes by setting `NAOQI_IP` and `NAOQI_PORT` in your environment before launching `./run.sh`: -- **Physical robot** — `NAOQI_IP=` runs the Python 2 `pynaoqi` bridge. -- **Simulation** — `NAOQI_IP=127.0.0.1` (or unset) runs the Python 3 `qiBullet` simulator. +- **Robot** `NAOQI_IP=` and `NAOQI_PORT=9559` runs the Python 2 `pynaoqi` bridge. +- **Simulation** `NAOQI_IP=127.0.0.1` (or unset) runs the Python 3 `qiBullet` simulator. -In both cases a Flask **shim server** on port 5000 exposes a unified HTTP API to your Python 3 client code (see [`py3-naoqi-bridge/README.md`](py3-naoqi-bridge/README.md) for the wire contract and `NaoqiClient` reference). The client is agnostic to whether you connect to a physical or simulated robot. +The client is agnostic to whether you connect to a physical or simulated robot. In both cases a Flask **shim server** on port 5000 exposes a unified HTTP API to your Python 3 client code (see [`py3-naoqi-bridge/README.md`](py3-naoqi-bridge/README.md) for the wire contract and `NaoqiClient` reference). ## What's in the image