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 733d4099..e5cc99b5 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,6 +18,9 @@ from . import BaseStage LOGGER = logging.getLogger(__name__) +REQUIRED_VERSION = semver.Version.parse("1.0.0") +RECOMMENDED_VERSION = semver.Version.parse("1.0.4") + class SangaboardThing(BaseStage): """A Thing to manage a Sangaboard motor controller. @@ -42,18 +46,15 @@ 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: - if sb.version_tuple[0] != 1: - raise RuntimeError( - "Please update your Sangaboard Firmware. v1 is required." - ) sb.query("blocking_moves false") + self.check_firmware() self.update_position() def __exit__( @@ -89,6 +90,33 @@ class SangaboardThing(BaseStage): zip(self.axis_names, sb.position, strict=True) ) + def check_firmware(self) -> None: + """Error/warn if firmware doesn't meet requirements/recommendations. + + 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: + # 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) + + # Raise an error if version is below required + if version < REQUIRED_VERSION: + raise RuntimeError( + f"Sangaboard firmware version {version} is below the required " + f"{REQUIRED_VERSION}." + ) + + # 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, cancel: lt.deps.CancelHook, diff --git a/tests/test_sangaboard.py b/tests/test_sangaboard.py new file mode 100644 index 00000000..a75b19a7 --- /dev/null +++ b/tests/test_sangaboard.py @@ -0,0 +1,78 @@ +"""Tests for the Sangaboard thing.""" + +import logging + +import pytest + +from openflexure_microscope_server.things.stage.sangaboard import ( + SangaboardThing, + REQUIRED_VERSION, + RECOMMENDED_VERSION, +) + + +@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 = "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