Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
2 changes: 2 additions & 0 deletions src/wlsonar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
UDP_MAX_DATAGRAM_SIZE,
Sonar3D,
UdpConfig,
VersionException,
)
from ._udp_helper import (
DEFAULT_MCAST_GRP,
Expand All @@ -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",
Expand Down
155 changes: 126 additions & 29 deletions src/wlsonar/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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}"
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -222,37 +235,37 @@ 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(
api=StatusEntry(
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:
Expand Down Expand Up @@ -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
################################################################################################
Expand Down
93 changes: 90 additions & 3 deletions tests/test_e2e_real_sonar.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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), (
Expand All @@ -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")
Expand Down Expand Up @@ -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.")
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.