From ed9076a115ec3b5b94e93bd752459fb6d1edf0ae Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 1 Dec 2025 14:21:56 +0000 Subject: [PATCH 1/5] Log an error if the sangaboard firmware is below v1.0.4 --- src/openflexure_microscope_server/things/stage/sangaboard.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 733d4099..ec59b707 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -53,6 +53,11 @@ class SangaboardThing(BaseStage): raise RuntimeError( "Please update your Sangaboard Firmware. v1 is required." ) + if sb.version_tuple[1] == 0 and sb.version_tuple[2] < 4: + LOGGER.error( + "Sangaboard firmware is not 1.0.4 or later. " + "This will affect scanning performance." + ) sb.query("blocking_moves false") self.update_position() From 70e052fa21616ffdca4a66d2402a0987ccb6bb4f Mon Sep 17 00:00:00 2001 From: jaknapper Date: Mon, 1 Dec 2025 18:08:25 +0000 Subject: [PATCH 2/5] Check the firmware and log relevant messages --- .../things/stage/sangaboard.py | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index ec59b707..acfa7bff 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -17,6 +17,8 @@ from . import BaseStage LOGGER = logging.getLogger(__name__) +REQUIRED_VERSION = (1, 0, 4) + class SangaboardThing(BaseStage): """A Thing to manage a Sangaboard motor controller. @@ -49,16 +51,8 @@ class SangaboardThing(BaseStage): self._sangaboard = sangaboard.Sangaboard(**self.sangaboard_kwargs) self._sangaboard_lock = threading.RLock() with self.sangaboard() as sb: - if sb.version_tuple[0] != 1: - raise RuntimeError( - "Please update your Sangaboard Firmware. v1 is required." - ) - if sb.version_tuple[1] == 0 and sb.version_tuple[2] < 4: - LOGGER.error( - "Sangaboard firmware is not 1.0.4 or later. " - "This will affect scanning performance." - ) sb.query("blocking_moves false") + self.check_firmware() self.update_position() def __exit__( @@ -94,6 +88,42 @@ class SangaboardThing(BaseStage): zip(self.axis_names, sb.position, strict=True) ) + def check_firmware(self) -> None: + """Check the Sangaboard firmware version and log warnings or info messages. + + Logs a warning if the numeric firmware version is below ``REQUIRED_VERSION``. + Logs an info message if the version ends with ``'-dev'``. Malformed version + strings trigger a warning. + """ + with self.sangaboard() as sb: + version = sb.firmware_version + + # Remove '-dev' for numeric comparison + is_dev = version.endswith("-dev") + base_version = version[:-4] if is_dev else version + + # Parse the numeric part + parts = base_version.split(".") + try: + major, minor, patch = (int(p) for p in parts) + except ValueError: + LOGGER.warning( + f"Unrecognized Sangaboard firmware version format: {version}" + ) + return + + current = (major, minor, patch) + + # Warn if version is below minimum + if current < REQUIRED_VERSION: + LOGGER.warning( + f"Sangaboard firmware version {version} is below the required 1.0.4." + ) + + # Log dev info + if is_dev: + LOGGER.info(f"Firmware version {version} is a development build.") + def _hardware_move_relative( self, cancel: lt.deps.CancelHook, From 797dda351415223eb64cb4c0ce84deeca6749ae6 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 2 Dec 2025 11:00:41 +0000 Subject: [PATCH 3/5] Add failing unit test for sangaboard version before v1 --- .../things/stage/sangaboard.py | 2 +- tests/test_sangaboard.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 tests/test_sangaboard.py diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index acfa7bff..84d75e92 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -44,12 +44,12 @@ class SangaboardThing(BaseStage): """ self.sangaboard_kwargs = copy(kwargs) self.sangaboard_kwargs["port"] = port + self._sangaboard_lock = threading.RLock() super().__init__(**kwargs) def __enter__(self) -> None: """Connect to the sangaboard when the Thing context manager is opened.""" self._sangaboard = sangaboard.Sangaboard(**self.sangaboard_kwargs) - self._sangaboard_lock = threading.RLock() with self.sangaboard() as sb: sb.query("blocking_moves false") self.check_firmware() diff --git a/tests/test_sangaboard.py b/tests/test_sangaboard.py new file mode 100644 index 00000000..bf1c8394 --- /dev/null +++ b/tests/test_sangaboard.py @@ -0,0 +1,20 @@ +"""Tests for the Sangaboard thing.""" + +import pytest + +from openflexure_microscope_server.things.stage.sangaboard import SangaboardThing + + +@pytest.fixture +def mock_sanga_thing(mocker): + """Return a Sangaboard thing with a MagicMock for self._sangaboard.""" + sanga_thing = SangaboardThing() + sanga_thing._sangaboard = mocker.MagicMock() + return sanga_thing + + +def test_check_old_firmware(mock_sanga_thing): + """Check firmware prior to version 1 throws a Runtime Error.""" + mock_sanga_thing._sangaboard.firmware_version.return_value = "0.1.1" + with pytest.raises(RuntimeError): + mock_sanga_thing.check_firmware() From 240b7d1ba57482ab2ebf0512598759547aa63156 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 2 Dec 2025 11:55:56 +0000 Subject: [PATCH 4/5] Use semver library for checking sangaboard firmware version. --- pyproject.toml | 1 + .../things/stage/sangaboard.py | 51 +++++++-------- tests/test_sangaboard.py | 62 ++++++++++++++++++- 3 files changed, 83 insertions(+), 31 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 706c95fe..e7daf189 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "piexif", "pydantic ~= 2.10.6", "simplejpeg >= 1.8.2", + "semver ~= 3.0", ] [project.optional-dependencies] diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 84d75e92..369d9547 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -10,6 +10,7 @@ from types import TracebackType from contextlib import contextmanager from collections.abc import Mapping +import semver import sangaboard import labthings_fastapi as lt @@ -17,7 +18,8 @@ from . import BaseStage LOGGER = logging.getLogger(__name__) -REQUIRED_VERSION = (1, 0, 4) +REQUIRED_VERSION = semver.Version.parse("1.0.0") +RECOMMENDED_VERSION = semver.Version.parse("1.0.4") class SangaboardThing(BaseStage): @@ -89,40 +91,31 @@ class SangaboardThing(BaseStage): ) def check_firmware(self) -> None: - """Check the Sangaboard firmware version and log warnings or info messages. + """Error/warn if firmware doesn't meet requirements/recommendations. - Logs a warning if the numeric firmware version is below ``REQUIRED_VERSION``. - Logs an info message if the version ends with ``'-dev'``. Malformed version - strings trigger a warning. + Raise a Runtime Error if the version is below REQUIRED_VERSION + + Log a warning if the version is below RECOMMENDED_VERSION """ with self.sangaboard() as sb: - version = sb.firmware_version + # This will raise a ValueError is if the firmware version cannot be parsed + # this error will stop the microscope booting as we cannot ensure valid + # firmware. + version = semver.Version.parse(sb.firmware_version) - # Remove '-dev' for numeric comparison - is_dev = version.endswith("-dev") - base_version = version[:-4] if is_dev else version - - # Parse the numeric part - parts = base_version.split(".") - try: - major, minor, patch = (int(p) for p in parts) - except ValueError: - LOGGER.warning( - f"Unrecognized Sangaboard firmware version format: {version}" - ) - return - - current = (major, minor, patch) - - # Warn if version is below minimum - if current < REQUIRED_VERSION: - LOGGER.warning( - f"Sangaboard firmware version {version} is below the required 1.0.4." + # Warn if version is below required + if version < REQUIRED_VERSION: + raise RuntimeError( + f"Sangaboard firmware version {version} is below the required " + f"{REQUIRED_VERSION}." ) - # Log dev info - if is_dev: - LOGGER.info(f"Firmware version {version} is a development build.") + # Warn if version is below recommended + if version < RECOMMENDED_VERSION: + LOGGER.warning( + f"Sangaboard firmware version {version} is below the recommended " + f"{RECOMMENDED_VERSION}." + ) def _hardware_move_relative( self, diff --git a/tests/test_sangaboard.py b/tests/test_sangaboard.py index bf1c8394..a75b19a7 100644 --- a/tests/test_sangaboard.py +++ b/tests/test_sangaboard.py @@ -1,8 +1,14 @@ """Tests for the Sangaboard thing.""" +import logging + import pytest -from openflexure_microscope_server.things.stage.sangaboard import SangaboardThing +from openflexure_microscope_server.things.stage.sangaboard import ( + SangaboardThing, + REQUIRED_VERSION, + RECOMMENDED_VERSION, +) @pytest.fixture @@ -15,6 +21,58 @@ def mock_sanga_thing(mocker): def test_check_old_firmware(mock_sanga_thing): """Check firmware prior to version 1 throws a Runtime Error.""" - mock_sanga_thing._sangaboard.firmware_version.return_value = "0.1.1" + mock_sanga_thing._sangaboard.firmware_version = "0.1.1" with pytest.raises(RuntimeError): mock_sanga_thing.check_firmware() + + +def test_firmware_before_recommended(mock_sanga_thing, caplog): + """Check required version warns if it is lower than the recommended version.""" + if REQUIRED_VERSION == RECOMMENDED_VERSION: + # If required is currently the same as recommended then this test isn't valid + return + mock_sanga_thing._sangaboard.firmware_version = str(REQUIRED_VERSION) + with caplog.at_level(logging.WARNING): + mock_sanga_thing.check_firmware() + assert len(caplog.records) == 1 + msg = ( + f"Sangaboard firmware version {REQUIRED_VERSION} is below the recommended " + f"{RECOMMENDED_VERSION}." + ) + assert caplog.records[0].message == msg + + +def test_dev_is_before_recommended(mock_sanga_thing, caplog): + """Check that a dev version of the recommended version warns.""" + if REQUIRED_VERSION == RECOMMENDED_VERSION: + # If required is currently the same as recommended then this test isn't valid + return + dev_version = RECOMMENDED_VERSION.replace(prerelease="dev1") + mock_sanga_thing._sangaboard.firmware_version = str(dev_version) + with caplog.at_level(logging.WARNING): + mock_sanga_thing.check_firmware() + assert len(caplog.records) == 1 + msg = ( + f"Sangaboard firmware version {dev_version} is below the recommended " + f"{RECOMMENDED_VERSION}." + ) + assert caplog.records[0].message == msg + + +def test_valid_firmware(mock_sanga_thing, caplog): + """Check valid firmware doesn't error.""" + versions = [ + RECOMMENDED_VERSION, # Recommended + RECOMMENDED_VERSION.bump_major(), # Higher versions + RECOMMENDED_VERSION.bump_minor(), + RECOMMENDED_VERSION.bump_patch(), + RECOMMENDED_VERSION.bump_patch().replace(prerelease="dev1"), + ] + # Note that dev1 will not warn in check version, but will warn in the underlying + # Sangaboard library as it is a pre-release. + + for version in versions: + mock_sanga_thing._sangaboard.firmware_version = str(version) + with caplog.at_level(logging.WARNING): + mock_sanga_thing.check_firmware() + assert len(caplog.records) == 0 From dd7c135fa8cce878fa83b10bd945ba25cfe6a269 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 2 Dec 2025 14:36:10 +0000 Subject: [PATCH 5/5] Fix comment in sangaboard firmware check --- src/openflexure_microscope_server/things/stage/sangaboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 369d9547..e5cc99b5 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -103,7 +103,7 @@ class SangaboardThing(BaseStage): # firmware. version = semver.Version.parse(sb.firmware_version) - # Warn if version is below required + # Raise an error if version is below required if version < REQUIRED_VERSION: raise RuntimeError( f"Sangaboard firmware version {version} is below the required "