diff --git a/pyproject.toml b/pyproject.toml index 85ad07f..32356d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wlsonar" -version = "0.2.0" +version = "0.3.0" description = "Python client and Range Image Protocol utilities for Water Linked Sonar 3D-15." readme = "README.md" authors = [ diff --git a/src/wlsonar/__init__.py b/src/wlsonar/__init__.py index 5551fca..f5a3bc5 100644 --- a/src/wlsonar/__init__.py +++ b/src/wlsonar/__init__.py @@ -9,6 +9,7 @@ UDP_MAX_DATAGRAM_SIZE, Sonar3D, UdpConfig, + VersionException, ) from ._udp_helper import ( DEFAULT_MCAST_GRP, @@ -25,6 +26,7 @@ "UDP_MAX_DATAGRAM_SIZE", "Sonar3D", "UdpConfig", + "VersionException", "open_sonar_udp_multicast_socket", "open_sonar_udp_unicast_socket", "range_image_to_xyz", diff --git a/src/wlsonar/_client.py b/src/wlsonar/_client.py index bcbf8e7..124f8ff 100644 --- a/src/wlsonar/_client.py +++ b/src/wlsonar/_client.py @@ -71,8 +71,8 @@ class StatusEntry: message: str # Operational is true if the system is functional, false if it is not operational: bool - # Severity is the severity of the status message: info, warning, error - severity: str + # Status is "ok", "warning", or "error" + status: str @dataclass @@ -81,10 +81,18 @@ class Status: # IntegrationAPI is the status of the UDP IntegrationAPI for external communication api: StatusEntry - # Calibration is the status of the Sonar calibration - calibration: StatusEntry # Temperature is the status of the Sonar temperature temperature: StatusEntry + # SystemsCheck is the status of internal processing of the Sonar + systems_check: StatusEntry + + +class VersionException(Exception): + def __init__(self, what: str, min_version: str, sonar_version: str) -> None: + super().__init__( + f"{what} requires Sonar 3D-15 release {min_version} or newer. " + f"Detected version: {sonar_version}" + ) class Sonar3D: @@ -97,12 +105,12 @@ class Sonar3D: def __init__(self, ip: str, port: int = 80, timeout: float = 5.0) -> None: """Initialize the Sonar3D client. - Requires minimum Sonar 3D-15 release version 1.5.1. Some API methods may require later - versions. - Gets sonar release version in order to verify connectivity and check compatibility in later requests. + Requires: + Sonar 3D-15 release 1.5.1 or higher. Some API methods require later versions. + Args: ip: IP address or hostname of the Sonar device. port: Port number of the Sonar device HTTP API. You should not have to change this from @@ -111,6 +119,7 @@ def __init__(self, ip: str, port: int = 80, timeout: float = 5.0) -> None: Raises: requests.RequestException: on HTTP or connection error. + VersionException: if sonar version is too old to support the Sonar3D client """ self.ip = ip self.base_url = f"http://{ip}:{port}" @@ -122,11 +131,9 @@ def __init__(self, ip: str, port: int = 80, timeout: float = 5.0) -> None: raise RuntimeError(f"Could not connect to Sonar 3D-15 at {ip}:{port}") from e self.sonar_version = about.version_short - if _semver_is_less_than(self.sonar_version, "1.5.1"): - raise RuntimeError( - "Sonar3D client requires Sonar 3D-15 release 1.5.1 or newer. " - f"Detected version: {self.sonar_version}" - ) + min_version = "1.5.1" + if _semver_is_less_than(self.sonar_version, min_version): + raise VersionException("Sonar3D client", min_version, self.sonar_version) ############################################################################ # HTTP helpers @@ -206,12 +213,18 @@ def about(self) -> About: def get_status(self) -> Status: """Get system status. - Returns: - Status: status information. + Requires: + Sonar 3D-15 release 1.7.0 or higher. Raises: requests.RequestException: on HTTP or connection error. + VersionException: if sonar version is too old to support this method. """ + min_version = "1.7.0" + if _semver_is_less_than(self.sonar_version, min_version): + # see release notes of sonar release 1.7.0 for context + raise VersionException("Sonar3D client .get_status", min_version, self.sonar_version) + resp = self._get_json("/api/v1/integration/status") if not isinstance(resp, dict): raise ValueError("status endpoint gave unexpected response") @@ -222,17 +235,17 @@ def get_status(self) -> Status: and isinstance(resp["api"]["id"], str) and isinstance(resp["api"]["message"], str) and isinstance(resp["api"]["operational"], bool) - and isinstance(resp["api"]["severity"], str) - and isinstance(resp["calibration"], dict) - and isinstance(resp["calibration"]["id"], str) - and isinstance(resp["calibration"]["message"], str) - and isinstance(resp["calibration"]["operational"], bool) - and isinstance(resp["calibration"]["severity"], str) + and isinstance(resp["api"]["status"], str) and isinstance(resp["temperature"], dict) and isinstance(resp["temperature"]["id"], str) and isinstance(resp["temperature"]["message"], str) and isinstance(resp["temperature"]["operational"], bool) - and isinstance(resp["temperature"]["severity"], str) + and isinstance(resp["temperature"]["status"], str) + and isinstance(resp["systems_check"], dict) + and isinstance(resp["systems_check"]["id"], str) + and isinstance(resp["systems_check"]["message"], str) + and isinstance(resp["systems_check"]["operational"], bool) + and isinstance(resp["systems_check"]["status"], str) ): raise RuntimeError("status endpoint gave unexpected response") status = Status( @@ -240,19 +253,19 @@ def get_status(self) -> Status: id=resp["api"]["id"], message=resp["api"]["message"], operational=resp["api"]["operational"], - severity=resp["api"]["severity"], - ), - calibration=StatusEntry( - id=resp["calibration"]["id"], - message=resp["calibration"]["message"], - operational=resp["calibration"]["operational"], - severity=resp["calibration"]["severity"], + status=resp["api"]["status"], ), temperature=StatusEntry( id=resp["temperature"]["id"], message=resp["temperature"]["message"], operational=resp["temperature"]["operational"], - severity=resp["temperature"]["severity"], + status=resp["temperature"]["status"], + ), + systems_check=StatusEntry( + id=resp["systems_check"]["id"], + message=resp["systems_check"]["message"], + operational=resp["systems_check"]["operational"], + status=resp["systems_check"]["status"], ), ) except KeyError as e: @@ -352,6 +365,90 @@ def set_udp_config(self, cfg: UdpConfig) -> None: """ self._post_json("/api/v1/integration/udp", cfg.to_json()) + def get_mode(self) -> Literal["low-frequency", "high-frequency"]: + """Get sonar mode. + + Requires: + Sonar 3D-15 release 1.7.0 or higher. + + Raises: + requests.RequestException: on HTTP or connection error. + VersionException: if sonar version is too old to support this method. + """ + min_version = "1.7.0" + if _semver_is_less_than(self.sonar_version, min_version): + raise VersionException("Sonar3D client .get_mode", min_version, self.sonar_version) + resp = self._get_json("/api/v1/integration/acoustics/mode") + if not isinstance(resp, str): + raise ValueError("get_mode endpoint gave unexpected response") + if resp == "low-frequency": + return "low-frequency" + if resp == "high-frequency": + return "high-frequency" + raise ValueError("get_mode endpoint gave unexpected string") + + def set_mode(self, mode: Literal["low-frequency", "high-frequency"]) -> None: + """Set sonar mode. + + Requires: + Sonar 3D-15 release 1.7.0 or higher. + + Raises: + requests.RequestException: on HTTP or connection error. + VersionException: if sonar version is too old to support this method. + """ + min_version = "1.7.0" + if _semver_is_less_than(self.sonar_version, min_version): + raise VersionException("Sonar3D client .set_mode", min_version, self.sonar_version) + if mode == "low-frequency": + self._post_json("/api/v1/integration/acoustics/mode", "low-frequency") + elif mode == "high-frequency": + self._post_json("/api/v1/integration/acoustics/mode", "high-frequency") + else: + raise ValueError(f"set_mode got invalid mode: {mode}") + + def get_salinity(self) -> Literal["salt", "fresh"]: + """Get configured salinity for automatic speed of sound calculation. + + Requires: + Sonar 3D-15 release 1.7.0 or higher. + + Raises: + requests.RequestException: on HTTP or connection error. + VersionException: if sonar version is too old to support this method. + """ + min_version = "1.7.0" + if _semver_is_less_than(self.sonar_version, min_version): + raise VersionException("Sonar3D client .get_salinity", min_version, self.sonar_version) + resp = self._get_json("/api/v1/integration/acoustics/salinity") + if not isinstance(resp, str): + raise ValueError("get_salinity endpoint gave unexpected response") + if resp == "salt": + return "salt" + if resp == "fresh": + return "fresh" + raise ValueError("get_salinity endpoint gave unexpected string") + + def set_salinity(self, mode: Literal["salt", "fresh"]) -> None: + """Set salinity for automatic speed of sound calculation. + + Requires: + Sonar 3D-15 release 1.7.0 or higher. + + Raises: + requests.RequestException: on HTTP or connection error. + VersionException: if sonar version is too old to support this method. + """ + min_version = "1.7.0" + if _semver_is_less_than(self.sonar_version, min_version): + raise VersionException("Sonar3D client .set_salinity", min_version, self.sonar_version) + if mode == "salt": + self._post_json("/api/v1/integration/acoustics/salinity", "salt") + elif mode == "fresh": + self._post_json("/api/v1/integration/acoustics/salinity", "fresh") + else: + raise ValueError(f"set_salinity got invalid mode: {mode}") + ################################################################################################ # Convenience methods ################################################################################################ diff --git a/tests/test_e2e_real_sonar.py b/tests/test_e2e_real_sonar.py index 2dc3269..e05a28a 100644 --- a/tests/test_e2e_real_sonar.py +++ b/tests/test_e2e_real_sonar.py @@ -1,6 +1,9 @@ +from typing import Literal, cast + import pytest -from wlsonar import Sonar3D, UdpConfig +from wlsonar import Sonar3D, UdpConfig, VersionException +from wlsonar._semver import _semver_is_less_than @pytest.mark.e2e @@ -18,6 +21,9 @@ def test_e2e_Sonar3D_client_against_real_sonar(request: pytest.FixtureRequest) - sonar through its HTTP API. It does not test the sonar itself. For example, it tests that we can enable acoustics, but does not test that this causes the sonar to actually start producing sonar images. + + Example use: + uv run pytest -s -m e2e --sonar-ip 10.1.2.156 """ sonar_ip = request.config.getoption("--sonar-ip") assert sonar_ip is not None and isinstance(sonar_ip, str), ( @@ -39,8 +45,16 @@ def test_e2e_Sonar3D_client_against_real_sonar(request: pytest.FixtureRequest) - about = sonar.about() print("Sonar about:", about) - status = sonar.get_status() - print("Sonar status:", status) + if _semver_is_less_than(about.version_short, "1.7.0"): + with pytest.raises(VersionException): + sonar.get_status() + print( + f"Sonar release {sonar.sonar_version} does not support .get_status. " + "Got expected VersionException." + ) + else: + status = sonar.get_status() + print("Sonar status:", status) temperature = sonar.get_temperature() print(f"Sonar temperature: {temperature:.2f} °C") @@ -157,4 +171,77 @@ def test_e2e_Sonar3D_client_against_real_sonar(request: pytest.FixtureRequest) - print("Sonar UDP config after resetting:", reset_udp_config) assert reset_udp_config == existing_udp_config, "Failed to reset UDP config" + ################################################################################################ + # mode + ################################################################################################ + + if _semver_is_less_than(sonar.sonar_version, "1.7.0"): + with pytest.raises(VersionException): + sonar.get_mode() + print( + f"Sonar release {sonar.sonar_version} does not support .get_mode. " + "Got expected VersionException." + ) + with pytest.raises(VersionException): + sonar.set_mode("low-frequency") + print( + f"Sonar release {sonar.sonar_version} does not support .set_mode. " + "Got expected VersionException." + ) + else: + had_mode = sonar.get_mode() + print(f"Sonar mode: {had_mode}") + + # try toggling mode + + # determine the mode we are not currently using + other_mode = "high-frequency" if had_mode == "low-frequency" else "low-frequency" + # cast: make type checker happy + other_mode = cast(Literal["low-frequency", "high-frequency"], other_mode) + + sonar.set_mode(other_mode) + mode_after_toggle = sonar.get_mode() + print(f"Toggled sonar mode to: {mode_after_toggle}") + assert mode_after_toggle == other_mode, "Failed to toggle mode" + sonar.set_mode(had_mode) + mode_after_toggle_back = sonar.get_mode() + print(f"Toggled sonar mode back to: {mode_after_toggle_back}") + assert mode_after_toggle_back == had_mode, "Failed to toggle mode back" + + #################################################################################################### + # salinity + #################################################################################################### + + if _semver_is_less_than(sonar.sonar_version, "1.7.0"): + with pytest.raises(VersionException): + sonar.get_salinity() + print( + f"Sonar release {sonar.sonar_version} does not support .get_salinity. " + "Got expected VersionException." + ) + with pytest.raises(VersionException): + sonar.set_salinity("salt") + print( + f"Sonar release {sonar.sonar_version} does not support .set_salinity. " + "Got expected VersionException." + ) + else: + had_salinity = sonar.get_salinity() + print(f"Sonar salinity: {had_salinity}") + + # toggle salinity type + + # determine the salinity we are not currently using + other_salinity = "salt" if had_salinity == "fresh" else "fresh" + other_salinity = cast(Literal["salt", "fresh"], other_salinity) # make type checker happy + + sonar.set_salinity(other_salinity) + salinity_after_toggle = sonar.get_salinity() + print(f"Toggled salinity to: {salinity_after_toggle}") + assert salinity_after_toggle == other_salinity, "Failed to toggle salinity" + sonar.set_salinity(had_salinity) + salinity_after_toggle_back = sonar.get_salinity() + print(f"Toggled salinity back to: {salinity_after_toggle_back}") + assert salinity_after_toggle_back == had_salinity, "Failed to toggle salinity back" + print("Sonar3D client tested against real sonar: all checks passed.") diff --git a/uv.lock b/uv.lock index fa5c1fe..2b3cea7 100644 --- a/uv.lock +++ b/uv.lock @@ -732,7 +732,7 @@ wheels = [ [[package]] name = "wlsonar" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "protobuf" },