From d7dac9ef84b5e3293a8555930ff37a3fc9d03c20 Mon Sep 17 00:00:00 2001 From: joseg20 Date: Mon, 3 Aug 2026 17:36:53 -0400 Subject: [PATCH 1/3] feat(reolink): detect a motorised lens instead of assuming static cameras have none cam_type describes how a camera is mounted, not what optics it carries. A "static" camera is one that does not pan or tilt, which says nothing about zoom: Reolink bullets such as the RLC-811A or the P430 sit fixed on their mast and still ship a motorised varifocal lens. Ask the camera instead of inferring. GetZoomFocus reports a zoom position only on models that can drive the lens, so the answer comes from the device and holds for any model. The probe is cached, since a lens cannot grow a motor at runtime and zoom commands are frequent. A camera that cannot be reached is treated as fixed-lens rather than raising, so a network blip degrades the feature instead of failing the call. Fixed-lens cameras keep their current behaviour: they report no zoom position, so the commands stay unsent exactly as before. PTZ cameras are unaffected. --- .../camera/adapters/reolink.py | 29 ++++++- pyro_camera_api/tests/test_reolink_lens.py | 75 +++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 pyro_camera_api/tests/test_reolink_lens.py diff --git a/pyro_camera_api/pyro_camera_api/camera/adapters/reolink.py b/pyro_camera_api/pyro_camera_api/camera/adapters/reolink.py index b5fdb303..1167ff3e 100644 --- a/pyro_camera_api/pyro_camera_api/camera/adapters/reolink.py +++ b/pyro_camera_api/pyro_camera_api/camera/adapters/reolink.py @@ -71,6 +71,29 @@ def __init__( len(self.cam_poses), len(self.cam_azimuths), ) + self._has_motorised_lens: Optional[bool] = None + + def has_motorised_lens(self) -> bool: + """Whether this camera's lens can be driven. + + ``cam_type`` describes how the camera is mounted, not what optics it + carries. A "static" camera is one that does not pan or tilt, which says + nothing about zoom: Reolink bullets such as the RLC-811A or the P430 sit + fixed on their mast and still ship a motorised varifocal lens. + + Rather than infer it, ask the camera. ``GetZoomFocus`` reports a zoom + position only on models that can move the lens, so the answer comes from + the device itself and holds for any model. The result is cached: it + cannot change while the camera is running, and this saves a request on + every zoom or focus command. + """ + if self._has_motorised_lens is None: + try: + self._has_motorised_lens = (self.get_focus_level() or {}).get("zoom") is not None + except Exception as exc: + logger.warning("[%s] could not probe lens capability: %s", self.ip_address, exc) + return False + return self._has_motorised_lens def _build_url(self, command: str) -> str: """Constructs a URL for API commands to the camera.""" @@ -232,7 +255,7 @@ def set_auto_focus(self, disable: bool): return self._handle_response(response, "Set AutoFocus settings successfully.") def start_zoom_focus(self, position: int): - if self.cam_type != "static": + if self.has_motorised_lens(): url = self._build_url("StartZoomFocus") data: Any = [ { @@ -249,7 +272,7 @@ def set_manual_focus(self, position: int): """ Set manual focus to a specific position. """ - if self.cam_type != "static": + if self.has_motorised_lens(): self.focus_position = position url = self._build_url("StartZoomFocus") data: Any = [ @@ -310,7 +333,7 @@ def capture_and_score(pos: int) -> float: image.save(f"{folder}/focus_{pos}.jpg") return score_local - if self.cam_type == "static": + if not self.has_motorised_lens(): return 720 if self.focus_position is None: diff --git a/pyro_camera_api/tests/test_reolink_lens.py b/pyro_camera_api/tests/test_reolink_lens.py new file mode 100644 index 00000000..1ed162e6 --- /dev/null +++ b/pyro_camera_api/tests/test_reolink_lens.py @@ -0,0 +1,75 @@ +# Copyright (C) 2022-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + + +from unittest.mock import patch + +from pyro_camera_api.camera.adapters.reolink import ReolinkCamera + + +def _camera(cam_type="static"): + return ReolinkCamera( + camera_id="cam", + ip_address="192.168.1.10", + username="user", + password="pwd", # ruff: ignore[hardcoded-password-func-arg] + cam_type=cam_type, + ) + + +def test_static_camera_with_a_varifocal_lens_can_zoom(): + """A bullet camera does not pan, which says nothing about its optics.""" + cam = _camera(cam_type="static") + with patch.object(ReolinkCamera, "get_focus_level", return_value={"focus": 336, "zoom": 0}): + assert cam.has_motorised_lens() is True + + +def test_fixed_lens_camera_reports_no_zoom(): + cam = _camera(cam_type="static") + with patch.object(ReolinkCamera, "get_focus_level", return_value={"focus": 336, "zoom": None}): + assert cam.has_motorised_lens() is False + + +def test_unreachable_camera_is_treated_as_fixed_lens(): + """Probing must not raise: a camera that cannot be asked keeps its commands + from being sent rather than taking the whole call down.""" + cam = _camera() + with patch.object(ReolinkCamera, "get_focus_level", side_effect=OSError("unreachable")): + assert cam.has_motorised_lens() is False + + +def test_capability_is_probed_once(): + """Zoom and focus commands are frequent; the lens cannot grow a motor.""" + cam = _camera() + with patch.object(ReolinkCamera, "get_focus_level", return_value={"focus": 1, "zoom": 4}) as probe: + cam.has_motorised_lens() + cam.has_motorised_lens() + assert probe.call_count == 1 + + +def test_zoom_command_is_sent_to_a_static_varifocal_camera(): + """The regression this change is about: the command used to be dropped for + every static camera, silently returning None with no request made.""" + cam = _camera(cam_type="static") + with ( + patch.object(ReolinkCamera, "get_focus_level", return_value={"focus": 1, "zoom": 0}), + patch("pyro_camera_api.camera.adapters.reolink.requests.post") as post, + patch.object(ReolinkCamera, "_handle_response", return_value="ok"), + ): + assert cam.start_zoom_focus(32) == "ok" + assert post.call_count == 1 + payload = post.call_args.kwargs["json"][0] + assert payload["param"]["ZoomFocus"]["pos"] == 32 + assert payload["param"]["ZoomFocus"]["op"] == "ZoomPos" + + +def test_zoom_command_is_not_sent_to_a_fixed_lens_camera(): + cam = _camera(cam_type="static") + with ( + patch.object(ReolinkCamera, "get_focus_level", return_value={"focus": 1, "zoom": None}), + patch("pyro_camera_api.camera.adapters.reolink.requests.post") as post, + ): + assert cam.start_zoom_focus(32) is None + assert post.call_count == 0 From 5192fe083accc661a26dcfa7fabe9f3eea884c96 Mon Sep 17 00:00:00 2001 From: joseg20 Date: Mon, 3 Aug 2026 17:44:52 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix(reolink):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20inconclusive=20probes=20and=20the=20autofocus=20rou?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems raised in review, both real. A failed GetZoomFocus request was being cached as "no motorised lens". One transient error would have stranded a PTZ camera without zoom or manual focus for the rest of the process, long after the camera recovered. An inconclusive probe now skips the command without caching, so the next call tries again. The autofocus route rejected static cameras before ever reaching the adapter, so the capability check could not take effect through the camera service. It now asks the camera for its lens the same way, and falls back to allowing the call on adapters that cannot answer. --- pyro_camera_api/pyro_camera_api/api/routes_focus.py | 9 ++++++--- .../pyro_camera_api/camera/adapters/reolink.py | 10 +++++++++- pyro_camera_api/tests/test_reolink_lens.py | 12 +++++++++++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/pyro_camera_api/pyro_camera_api/api/routes_focus.py b/pyro_camera_api/pyro_camera_api/api/routes_focus.py index 30dfa6d4..9cc2b715 100644 --- a/pyro_camera_api/pyro_camera_api/api/routes_focus.py +++ b/pyro_camera_api/pyro_camera_api/api/routes_focus.py @@ -103,7 +103,7 @@ def run_focus_optimization(camera_ip: str, save_images: bool = False): """ Run the autofocus search algorithm and return the optimal focus position. - This operation is supported only on PTZ cameras implementing FocusMixin. + Supported on any camera with a motorised lens, whether or not it pans. If the camera exposes PTZ presets the algorithm tries moving to the second preset before the optimization step when available. The optional `save_images` parameter allows storing captured frames generated @@ -118,8 +118,11 @@ def run_focus_optimization(camera_ip: str, save_images: bool = False): if not isinstance(cam, FocusMixin): raise HTTPException(status_code=400, detail="Camera does not support autofocus") - if getattr(cam, "cam_type", "static") == "static": - raise HTTPException(status_code=400, detail="Autofocus is not supported for static cameras") + # Mounting type says nothing about the optics: a bullet camera that does not + # pan can still carry a motorised lens. Ask the camera when it can tell us. + has_motorised_lens = getattr(cam, "has_motorised_lens", None) + if has_motorised_lens is not None and not has_motorised_lens(): + raise HTTPException(status_code=400, detail="Camera does not have a motorised lens") if isinstance(cam, PTZMixin): cam_poses = getattr(cam, "cam_poses", None) diff --git a/pyro_camera_api/pyro_camera_api/camera/adapters/reolink.py b/pyro_camera_api/pyro_camera_api/camera/adapters/reolink.py index 1167ff3e..3aef35e8 100644 --- a/pyro_camera_api/pyro_camera_api/camera/adapters/reolink.py +++ b/pyro_camera_api/pyro_camera_api/camera/adapters/reolink.py @@ -89,10 +89,18 @@ def has_motorised_lens(self) -> bool: """ if self._has_motorised_lens is None: try: - self._has_motorised_lens = (self.get_focus_level() or {}).get("zoom") is not None + lens = self.get_focus_level() except Exception as exc: logger.warning("[%s] could not probe lens capability: %s", self.ip_address, exc) return False + if lens is None: + # The camera did not answer, which says nothing about its optics. + # Caching this would strand a PTZ camera as fixed-lens for the + # rest of the process over one failed request, so try again next + # time and skip the command for now. + logger.warning("[%s] lens capability probe was inconclusive", self.ip_address) + return False + self._has_motorised_lens = lens.get("zoom") is not None return self._has_motorised_lens def _build_url(self, command: str) -> str: diff --git a/pyro_camera_api/tests/test_reolink_lens.py b/pyro_camera_api/tests/test_reolink_lens.py index 1ed162e6..e132808f 100644 --- a/pyro_camera_api/tests/test_reolink_lens.py +++ b/pyro_camera_api/tests/test_reolink_lens.py @@ -14,7 +14,7 @@ def _camera(cam_type="static"): camera_id="cam", ip_address="192.168.1.10", username="user", - password="pwd", # ruff: ignore[hardcoded-password-func-arg] + password="pwd", # noqa: S106 cam_type=cam_type, ) @@ -40,6 +40,16 @@ def test_unreachable_camera_is_treated_as_fixed_lens(): assert cam.has_motorised_lens() is False +def test_an_inconclusive_probe_is_not_cached(): + """A failed request says nothing about the optics. Caching it would strand a + PTZ camera as fixed-lens for the rest of the process over one bad answer.""" + cam = _camera(cam_type="ptz") + with patch.object(ReolinkCamera, "get_focus_level", return_value=None): + assert cam.has_motorised_lens() is False + with patch.object(ReolinkCamera, "get_focus_level", return_value={"focus": 1, "zoom": 0}): + assert cam.has_motorised_lens() is True + + def test_capability_is_probed_once(): """Zoom and focus commands are frequent; the lens cannot grow a motor.""" cam = _camera() From 2ddcaa91007efcee6ef97f91d7a07b5bff442ce7 Mon Sep 17 00:00:00 2001 From: joseg20 Date: Wed, 5 Aug 2026 13:18:39 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(reolink):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20settle=20rejections,=20scope=20out=20the=20focus=20?= =?UTF-8?q?routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three points from review. The probe conflated a camera that could not be reached with one that answered by rejecting the command: both returned None from get_focus_level() and neither was cached. A fixed-lens model that rejects GetZoomFocus outright would have paid an HTTP request and a warning on every zoom and focus call for the life of the process. The probe now reads the reply itself: a well-formed non-zero code is the camera settling the question and is cached, while transport failures and HTTP errors stay uncached and are retried. The capability guard moves from the autofocus route to the zoom route. Lifting it on /focus/focus_finder would let the search run on a static varifocal camera whose focus_position is unset, and that path opens with start_zoom_focus(0) — wiping zoom levels set by hand in the field, which is exactly the state this change exists to respect. routes_focus.py is left untouched, both for that reason and because #391 rewrites it. /control/zoom keeps the guard: hasattr only proves the adapter has the method, so without it the route answered 200 for a command the adapter dropped, which is the silent failure this PR set out to remove. --- .../pyro_camera_api/api/routes_control.py | 8 +++ .../pyro_camera_api/api/routes_focus.py | 9 +-- .../camera/adapters/reolink.py | 52 ++++++++------ pyro_camera_api/tests/test_reolink_lens.py | 67 +++++++++++++------ 4 files changed, 91 insertions(+), 45 deletions(-) diff --git a/pyro_camera_api/pyro_camera_api/api/routes_control.py b/pyro_camera_api/pyro_camera_api/api/routes_control.py index 5f2e8165..4d188225 100644 --- a/pyro_camera_api/pyro_camera_api/api/routes_control.py +++ b/pyro_camera_api/pyro_camera_api/api/routes_control.py @@ -1013,6 +1013,14 @@ def zoom_camera(camera_ip: str, level: int): if not hasattr(cam, "start_zoom_focus"): raise HTTPException(status_code=400, detail="Camera does not support zoom control") + # hasattr only proves the adapter has the method, not that this camera can + # act on it: the Reolink adapter drops the command for a fixed lens and + # returns None, so without this the route would answer 200 for a zoom that + # never happened. + has_motorised_lens = getattr(cam, "has_motorised_lens", None) + if has_motorised_lens is not None and not has_motorised_lens(): + raise HTTPException(status_code=400, detail="Camera does not have a motorised lens") + lock = _acquire_or_409(camera_ip) try: diff --git a/pyro_camera_api/pyro_camera_api/api/routes_focus.py b/pyro_camera_api/pyro_camera_api/api/routes_focus.py index 9cc2b715..30dfa6d4 100644 --- a/pyro_camera_api/pyro_camera_api/api/routes_focus.py +++ b/pyro_camera_api/pyro_camera_api/api/routes_focus.py @@ -103,7 +103,7 @@ def run_focus_optimization(camera_ip: str, save_images: bool = False): """ Run the autofocus search algorithm and return the optimal focus position. - Supported on any camera with a motorised lens, whether or not it pans. + This operation is supported only on PTZ cameras implementing FocusMixin. If the camera exposes PTZ presets the algorithm tries moving to the second preset before the optimization step when available. The optional `save_images` parameter allows storing captured frames generated @@ -118,11 +118,8 @@ def run_focus_optimization(camera_ip: str, save_images: bool = False): if not isinstance(cam, FocusMixin): raise HTTPException(status_code=400, detail="Camera does not support autofocus") - # Mounting type says nothing about the optics: a bullet camera that does not - # pan can still carry a motorised lens. Ask the camera when it can tell us. - has_motorised_lens = getattr(cam, "has_motorised_lens", None) - if has_motorised_lens is not None and not has_motorised_lens(): - raise HTTPException(status_code=400, detail="Camera does not have a motorised lens") + if getattr(cam, "cam_type", "static") == "static": + raise HTTPException(status_code=400, detail="Autofocus is not supported for static cameras") if isinstance(cam, PTZMixin): cam_poses = getattr(cam, "cam_poses", None) diff --git a/pyro_camera_api/pyro_camera_api/camera/adapters/reolink.py b/pyro_camera_api/pyro_camera_api/camera/adapters/reolink.py index 3aef35e8..94040bf2 100644 --- a/pyro_camera_api/pyro_camera_api/camera/adapters/reolink.py +++ b/pyro_camera_api/pyro_camera_api/camera/adapters/reolink.py @@ -73,6 +73,32 @@ def __init__( ) self._has_motorised_lens: Optional[bool] = None + def _probe_zoom_support(self) -> Optional[bool]: + """Ask the camera whether it reports a zoom position. + + Returns None only when the camera could not be asked, so the caller can + tell a transport failure apart from a device that answered. A camera + replying with a non-zero Reolink code has answered: it does not serve + GetZoomFocus, which settles the question. + """ + try: + response = requests.post( + self._build_url("GetZoomFocus"), + json=[{"cmd": "GetZoomFocus", "action": 0, "param": {"channel": 0}}], + verify=False, # nosec: B501 + ) + except Exception as exc: + logger.warning("[%s] lens probe could not reach the camera: %s", self.ip_address, exc) + return None + if response.status_code != 200: + logger.warning("[%s] lens probe got HTTP %s", self.ip_address, response.status_code) + return None + payload = response.json() + if payload[0].get("code") != 0: + logger.info("[%s] camera does not serve GetZoomFocus: fixed lens", self.ip_address) + return False + return payload[0]["value"]["ZoomFocus"].get("zoom", {}).get("pos") is not None + def has_motorised_lens(self) -> bool: """Whether this camera's lens can be driven. @@ -81,27 +107,15 @@ def has_motorised_lens(self) -> bool: nothing about zoom: Reolink bullets such as the RLC-811A or the P430 sit fixed on their mast and still ship a motorised varifocal lens. - Rather than infer it, ask the camera. ``GetZoomFocus`` reports a zoom - position only on models that can move the lens, so the answer comes from - the device itself and holds for any model. The result is cached: it - cannot change while the camera is running, and this saves a request on - every zoom or focus command. + The answer is cached once the camera gives one, since a lens cannot grow + a motor at runtime and zoom commands are frequent. Only an unanswered + probe is retried: caching that would strand a camera over one bad + request, and re-probing a camera that already said no would cost a + request on every command for the rest of the process. """ if self._has_motorised_lens is None: - try: - lens = self.get_focus_level() - except Exception as exc: - logger.warning("[%s] could not probe lens capability: %s", self.ip_address, exc) - return False - if lens is None: - # The camera did not answer, which says nothing about its optics. - # Caching this would strand a PTZ camera as fixed-lens for the - # rest of the process over one failed request, so try again next - # time and skip the command for now. - logger.warning("[%s] lens capability probe was inconclusive", self.ip_address) - return False - self._has_motorised_lens = lens.get("zoom") is not None - return self._has_motorised_lens + self._has_motorised_lens = self._probe_zoom_support() + return self._has_motorised_lens is True def _build_url(self, command: str) -> str: """Constructs a URL for API commands to the camera.""" diff --git a/pyro_camera_api/tests/test_reolink_lens.py b/pyro_camera_api/tests/test_reolink_lens.py index e132808f..26da6ab2 100644 --- a/pyro_camera_api/tests/test_reolink_lens.py +++ b/pyro_camera_api/tests/test_reolink_lens.py @@ -4,10 +4,21 @@ # See LICENSE or go to for full license details. -from unittest.mock import patch +from unittest.mock import MagicMock, patch from pyro_camera_api.camera.adapters.reolink import ReolinkCamera +POST = "pyro_camera_api.camera.adapters.reolink.requests.post" + + +def _reply(status=200, code=0, zoom_pos=0): + """A GetZoomFocus response as the camera would send it.""" + resp = MagicMock() + resp.status_code = status + zoom = {} if zoom_pos is None else {"pos": zoom_pos} + resp.json.return_value = [{"code": code, "value": {"ZoomFocus": {"focus": {"pos": 336}, "zoom": zoom}}}] + return resp + def _camera(cam_type="static"): return ReolinkCamera( @@ -22,41 +33,59 @@ def _camera(cam_type="static"): def test_static_camera_with_a_varifocal_lens_can_zoom(): """A bullet camera does not pan, which says nothing about its optics.""" cam = _camera(cam_type="static") - with patch.object(ReolinkCamera, "get_focus_level", return_value={"focus": 336, "zoom": 0}): + with patch(POST, return_value=_reply(zoom_pos=0)): assert cam.has_motorised_lens() is True -def test_fixed_lens_camera_reports_no_zoom(): +def test_fixed_lens_camera_reports_no_zoom_position(): + cam = _camera(cam_type="static") + with patch(POST, return_value=_reply(zoom_pos=None)): + assert cam.has_motorised_lens() is False + + +def test_a_camera_that_rejects_the_command_is_a_settled_answer(): + """A non-zero Reolink code is the camera answering that it does not serve + GetZoomFocus. Re-probing it would cost a request on every command forever.""" cam = _camera(cam_type="static") - with patch.object(ReolinkCamera, "get_focus_level", return_value={"focus": 336, "zoom": None}): + with patch(POST, return_value=_reply(code=-9)) as post: + assert cam.has_motorised_lens() is False assert cam.has_motorised_lens() is False + assert post.call_count == 1 def test_unreachable_camera_is_treated_as_fixed_lens(): """Probing must not raise: a camera that cannot be asked keeps its commands from being sent rather than taking the whole call down.""" cam = _camera() - with patch.object(ReolinkCamera, "get_focus_level", side_effect=OSError("unreachable")): + with patch(POST, side_effect=OSError("unreachable")): assert cam.has_motorised_lens() is False -def test_an_inconclusive_probe_is_not_cached(): - """A failed request says nothing about the optics. Caching it would strand a - PTZ camera as fixed-lens for the rest of the process over one bad answer.""" +def test_an_unanswered_probe_is_not_cached(): + """A transport failure says nothing about the optics. Caching it would + strand a PTZ camera as fixed-lens for the rest of the process.""" cam = _camera(cam_type="ptz") - with patch.object(ReolinkCamera, "get_focus_level", return_value=None): + with patch(POST, side_effect=OSError("unreachable")): assert cam.has_motorised_lens() is False - with patch.object(ReolinkCamera, "get_focus_level", return_value={"focus": 1, "zoom": 0}): + with patch(POST, return_value=_reply(zoom_pos=0)): + assert cam.has_motorised_lens() is True + + +def test_an_http_error_is_not_cached_either(): + cam = _camera(cam_type="ptz") + with patch(POST, return_value=_reply(status=500)): + assert cam.has_motorised_lens() is False + with patch(POST, return_value=_reply(zoom_pos=4)): assert cam.has_motorised_lens() is True def test_capability_is_probed_once(): """Zoom and focus commands are frequent; the lens cannot grow a motor.""" cam = _camera() - with patch.object(ReolinkCamera, "get_focus_level", return_value={"focus": 1, "zoom": 4}) as probe: + with patch(POST, return_value=_reply(zoom_pos=4)) as post: cam.has_motorised_lens() cam.has_motorised_lens() - assert probe.call_count == 1 + assert post.call_count == 1 def test_zoom_command_is_sent_to_a_static_varifocal_camera(): @@ -64,12 +93,12 @@ def test_zoom_command_is_sent_to_a_static_varifocal_camera(): every static camera, silently returning None with no request made.""" cam = _camera(cam_type="static") with ( - patch.object(ReolinkCamera, "get_focus_level", return_value={"focus": 1, "zoom": 0}), - patch("pyro_camera_api.camera.adapters.reolink.requests.post") as post, + patch(POST, return_value=_reply(zoom_pos=0)) as post, patch.object(ReolinkCamera, "_handle_response", return_value="ok"), ): assert cam.start_zoom_focus(32) == "ok" - assert post.call_count == 1 + # one for the probe, one for the command + assert post.call_count == 2 payload = post.call_args.kwargs["json"][0] assert payload["param"]["ZoomFocus"]["pos"] == 32 assert payload["param"]["ZoomFocus"]["op"] == "ZoomPos" @@ -77,9 +106,7 @@ def test_zoom_command_is_sent_to_a_static_varifocal_camera(): def test_zoom_command_is_not_sent_to_a_fixed_lens_camera(): cam = _camera(cam_type="static") - with ( - patch.object(ReolinkCamera, "get_focus_level", return_value={"focus": 1, "zoom": None}), - patch("pyro_camera_api.camera.adapters.reolink.requests.post") as post, - ): + with patch(POST, return_value=_reply(zoom_pos=None)) as post: assert cam.start_zoom_focus(32) is None - assert post.call_count == 0 + # only the probe + assert post.call_count == 1