farsounder 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. farsounder-0.1.0/PKG-INFO +105 -0
  2. farsounder-0.1.0/README.md +94 -0
  3. farsounder-0.1.0/pyproject.toml +33 -0
  4. farsounder-0.1.0/setup.cfg +4 -0
  5. farsounder-0.1.0/src/farsounder/__init__.py +5 -0
  6. farsounder-0.1.0/src/farsounder/client/__init__.py +0 -0
  7. farsounder-0.1.0/src/farsounder/client/config.py +157 -0
  8. farsounder-0.1.0/src/farsounder/client/exceptions.py +2 -0
  9. farsounder-0.1.0/src/farsounder/client/history_types.py +91 -0
  10. farsounder-0.1.0/src/farsounder/client/requests.py +307 -0
  11. farsounder-0.1.0/src/farsounder/client/subscriber.py +190 -0
  12. farsounder-0.1.0/src/farsounder/proto/__init__.py +28 -0
  13. farsounder-0.1.0/src/farsounder/proto/proto/__init__.py +1 -0
  14. farsounder-0.1.0/src/farsounder/proto/proto/array_pb2.py +41 -0
  15. farsounder-0.1.0/src/farsounder/proto/proto/array_pb2.pyi +52 -0
  16. farsounder-0.1.0/src/farsounder/proto/proto/grid_description_pb2.py +41 -0
  17. farsounder-0.1.0/src/farsounder/proto/proto/grid_description_pb2.pyi +26 -0
  18. farsounder-0.1.0/src/farsounder/proto/proto/nav_api_pb2.py +103 -0
  19. farsounder-0.1.0/src/farsounder/proto/proto/nav_api_pb2.pyi +263 -0
  20. farsounder-0.1.0/src/farsounder/proto/proto/nav_info_pb2.py +63 -0
  21. farsounder-0.1.0/src/farsounder/proto/proto/nav_info_pb2.pyi +129 -0
  22. farsounder-0.1.0/src/farsounder/proto/proto/nmea_pb2.py +38 -0
  23. farsounder-0.1.0/src/farsounder/proto/proto/nmea_pb2.pyi +17 -0
  24. farsounder-0.1.0/src/farsounder/proto/proto/time_pb2.py +37 -0
  25. farsounder-0.1.0/src/farsounder/proto/proto/time_pb2.pyi +23 -0
  26. farsounder-0.1.0/src/farsounder.egg-info/PKG-INFO +105 -0
  27. farsounder-0.1.0/src/farsounder.egg-info/SOURCES.txt +32 -0
  28. farsounder-0.1.0/src/farsounder.egg-info/dependency_links.txt +1 -0
  29. farsounder-0.1.0/src/farsounder.egg-info/entry_points.txt +2 -0
  30. farsounder-0.1.0/src/farsounder.egg-info/requires.txt +3 -0
  31. farsounder-0.1.0/src/farsounder.egg-info/top_level.txt +1 -0
  32. farsounder-0.1.0/tests/test_config.py +34 -0
  33. farsounder-0.1.0/tests/test_exports.py +21 -0
  34. farsounder-0.1.0/tests/test_history_data.py +59 -0
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: farsounder
3
+ Version: 0.1.0
4
+ Summary: Python client to communicate with FarSounder's ARGOS sensors
5
+ Author-email: FarSounder Software Team <sw@farsounder.com>
6
+ Requires-Python: >=3.13
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: httpx
9
+ Requires-Dist: protobuf
10
+ Requires-Dist: pyzmq
11
+
12
+ # SDK Client for live FarSounder data
13
+
14
+ Python client to communicate with SonaSoft via API.
15
+
16
+ ## Usage
17
+
18
+ Clone locally and install (uv add .) or install from pypi like:
19
+
20
+ ```
21
+ uv add farsounder
22
+ ```
23
+ (or use pip, etc)
24
+
25
+ Then - for example to subscribe to `TargetData` messages:
26
+
27
+ ```python
28
+ import asyncio
29
+
30
+ from farsounder import config, requests, subscriber
31
+ from farsounder.proto import nav_api_pb2
32
+
33
+
34
+ async def main() -> None:
35
+ cfg = config.build_config(
36
+ host="127.0.0.1",
37
+ subscribe=["TargetData"],
38
+ )
39
+
40
+ sub = subscriber.subscribe(config)
41
+
42
+ def on_targets(message: nav_api_pb2.TargetData) -> None:
43
+ print("Got a TargetData!")
44
+ print("Targets:", len(message.groups))
45
+
46
+ # Pub/Sub
47
+ sub.on("TargetData", on_targets)
48
+ await sub.start()
49
+
50
+ # Req/rep
51
+ settings = await requests.get_processor_settings(config)
52
+ print(settings)
53
+
54
+ # History data
55
+ history = await requests.get_history_data(
56
+ config,
57
+ latitude=41.7223,
58
+ longitude=-71.35,
59
+ radius_meters=500,
60
+ )
61
+ print(history.gridded_bottom_detections)
62
+
63
+ await asyncio.sleep(1.0)
64
+ await sub.stop()
65
+
66
+
67
+ if __name__ == "__main__":
68
+ asyncio.run(main())
69
+ ```
70
+
71
+ ## Simulated backend
72
+
73
+ You can run a local simulated backend that publishes dummy messages and responds
74
+ to request/reply calls:
75
+
76
+ ```
77
+ uv run examples/simulated_backend.py
78
+ ```
79
+
80
+ Note: the `examples/` directory is not installed with `uv add farsounder`.
81
+ Clone the repo to run the examples locally.
82
+
83
+ # Development
84
+
85
+ ## Re-generate Protobuf stubs
86
+
87
+ Protobuf sources live in `proto/`. Generate Python stubs with:
88
+
89
+ ```
90
+ uv run --group dev gen-protos
91
+ ```
92
+
93
+ Or:
94
+
95
+ ```
96
+ uv run tools/gen_protos.py
97
+ ```
98
+
99
+ ## Tests
100
+
101
+ Run tests with:
102
+
103
+ ```
104
+ uv run --group dev pytest
105
+ ```
@@ -0,0 +1,94 @@
1
+ # SDK Client for live FarSounder data
2
+
3
+ Python client to communicate with SonaSoft via API.
4
+
5
+ ## Usage
6
+
7
+ Clone locally and install (uv add .) or install from pypi like:
8
+
9
+ ```
10
+ uv add farsounder
11
+ ```
12
+ (or use pip, etc)
13
+
14
+ Then - for example to subscribe to `TargetData` messages:
15
+
16
+ ```python
17
+ import asyncio
18
+
19
+ from farsounder import config, requests, subscriber
20
+ from farsounder.proto import nav_api_pb2
21
+
22
+
23
+ async def main() -> None:
24
+ cfg = config.build_config(
25
+ host="127.0.0.1",
26
+ subscribe=["TargetData"],
27
+ )
28
+
29
+ sub = subscriber.subscribe(config)
30
+
31
+ def on_targets(message: nav_api_pb2.TargetData) -> None:
32
+ print("Got a TargetData!")
33
+ print("Targets:", len(message.groups))
34
+
35
+ # Pub/Sub
36
+ sub.on("TargetData", on_targets)
37
+ await sub.start()
38
+
39
+ # Req/rep
40
+ settings = await requests.get_processor_settings(config)
41
+ print(settings)
42
+
43
+ # History data
44
+ history = await requests.get_history_data(
45
+ config,
46
+ latitude=41.7223,
47
+ longitude=-71.35,
48
+ radius_meters=500,
49
+ )
50
+ print(history.gridded_bottom_detections)
51
+
52
+ await asyncio.sleep(1.0)
53
+ await sub.stop()
54
+
55
+
56
+ if __name__ == "__main__":
57
+ asyncio.run(main())
58
+ ```
59
+
60
+ ## Simulated backend
61
+
62
+ You can run a local simulated backend that publishes dummy messages and responds
63
+ to request/reply calls:
64
+
65
+ ```
66
+ uv run examples/simulated_backend.py
67
+ ```
68
+
69
+ Note: the `examples/` directory is not installed with `uv add farsounder`.
70
+ Clone the repo to run the examples locally.
71
+
72
+ # Development
73
+
74
+ ## Re-generate Protobuf stubs
75
+
76
+ Protobuf sources live in `proto/`. Generate Python stubs with:
77
+
78
+ ```
79
+ uv run --group dev gen-protos
80
+ ```
81
+
82
+ Or:
83
+
84
+ ```
85
+ uv run tools/gen_protos.py
86
+ ```
87
+
88
+ ## Tests
89
+
90
+ Run tests with:
91
+
92
+ ```
93
+ uv run --group dev pytest
94
+ ```
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "farsounder"
3
+ version = "0.1.0"
4
+ description = "Python client to communicate with FarSounder's ARGOS sensors"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "FarSounder Software Team", email = "sw@farsounder.com" }
8
+ ]
9
+ requires-python = ">=3.13"
10
+ dependencies = [
11
+ "httpx",
12
+ "protobuf",
13
+ "pyzmq",
14
+ ]
15
+
16
+ [project.scripts]
17
+ gen-protos = "farsounder.tools.gen_protos:main"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "grpcio-tools",
22
+ "pytest",
23
+ "ruff>=0.14.14",
24
+ ]
25
+
26
+ [build-system]
27
+ requires = ["setuptools>=68", "wheel"]
28
+ build-backend = "setuptools.build_meta"
29
+
30
+ [tool.ruff]
31
+ exclude = [
32
+ "src/farsounder/proto/proto/**",
33
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from farsounder.client import config as config
2
+ from farsounder.client import requests as requests
3
+ from farsounder.client import subscriber as subscriber
4
+ from farsounder.client import exceptions as exceptions
5
+ from farsounder.proto import proto as proto
File without changes
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Iterable, Literal, Mapping
5
+
6
+ PubSubMessage = Literal[
7
+ "HydrophoneData",
8
+ "TargetData",
9
+ "ProcessorSettings",
10
+ "VesselInfo",
11
+ ]
12
+
13
+ ReqRepEndpoint = Literal[
14
+ "GetProcessorSettings",
15
+ "SetFieldOfView",
16
+ "SetBottomDetection",
17
+ "SetInWaterSquelch",
18
+ "SetSquelchlessInWaterDetector",
19
+ "GetVesselInfo",
20
+ "SetDraft",
21
+ "SetKeelOffset",
22
+ ]
23
+
24
+ RestEndpoint = Literal["GetHistoryData",]
25
+
26
+ PUBSUB_DEFAULT_PORTS: dict[PubSubMessage, int] = {
27
+ "HydrophoneData": 61501,
28
+ "TargetData": 61502,
29
+ "ProcessorSettings": 61503,
30
+ "VesselInfo": 61504,
31
+ }
32
+
33
+ REQREP_DEFAULT_PORTS: dict[ReqRepEndpoint, int] = {
34
+ "GetProcessorSettings": 60501,
35
+ "SetFieldOfView": 60502,
36
+ "SetBottomDetection": 60503,
37
+ "SetInWaterSquelch": 60504,
38
+ "SetSquelchlessInWaterDetector": 60505,
39
+ "GetVesselInfo": 60506,
40
+ "SetDraft": 60507,
41
+ "SetKeelOffset": 60508,
42
+ }
43
+
44
+ REST_DEFAULT_PORTS: dict[RestEndpoint, int] = {
45
+ "GetHistoryData": 3000,
46
+ }
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ClientConfig:
51
+ """Configuration for the ZeroMQ client.
52
+
53
+ Parameters
54
+ ----------
55
+ host
56
+ Hostname or IP address for all sockets.
57
+ subscribe
58
+ Pub-sub message types to subscribe to.
59
+ pubsub_ports
60
+ Port mapping for pub-sub message types.
61
+ reqrep_ports
62
+ Port mapping for request-reply endpoints.
63
+ rest_ports
64
+ Port mapping for REST endpoints.
65
+ callback_executor
66
+ Whether to run sync callbacks in a thread pool or inline.
67
+ timeout_seconds
68
+ Request-reply timeout in seconds.
69
+ """
70
+
71
+ host: str
72
+ subscribe: tuple[PubSubMessage, ...]
73
+ pubsub_ports: dict[PubSubMessage, int]
74
+ reqrep_ports: dict[ReqRepEndpoint, int]
75
+ rest_ports: dict[RestEndpoint, int]
76
+ callback_executor: Literal["threadpool", "inline"]
77
+ timeout_seconds: float
78
+
79
+
80
+ def build_config(
81
+ host: str = "127.0.0.1",
82
+ subscribe: Iterable[PubSubMessage] | None = None,
83
+ port_overrides: Mapping[str, int] | None = None,
84
+ callback_executor: Literal["threadpool", "inline"] = "threadpool",
85
+ timeout_seconds: float = 2.0,
86
+ ) -> ClientConfig:
87
+ """Build a client configuration.
88
+
89
+ Parameters
90
+ ----------
91
+ host
92
+ Hostname or IP address for all sockets.
93
+ subscribe
94
+ Iterable of message types to subscribe to. Defaults to all pub-sub
95
+ messages.
96
+ port_overrides
97
+ Mapping of message or endpoint name to port number. Keys can be any
98
+ pub-sub message name, request-reply endpoint name, or REST endpoint
99
+ name such as GetHistoryData.
100
+ callback_executor
101
+ Whether to run sync callbacks in a thread pool or inline.
102
+ timeout_seconds
103
+ Request-reply timeout in seconds.
104
+
105
+ Returns
106
+ -------
107
+ ClientConfig
108
+ The constructed configuration.
109
+ """
110
+
111
+ pubsub_ports = dict(PUBSUB_DEFAULT_PORTS)
112
+ reqrep_ports = dict(REQREP_DEFAULT_PORTS)
113
+ rest_ports = dict(REST_DEFAULT_PORTS)
114
+
115
+ if port_overrides:
116
+ for key, port in port_overrides.items():
117
+ if key in pubsub_ports:
118
+ pubsub_ports[key] = port
119
+ elif key in reqrep_ports:
120
+ reqrep_ports[key] = port
121
+ elif key in rest_ports:
122
+ rest_ports[key] = port
123
+ else:
124
+ raise ValueError(f"Unknown port override key: {key}")
125
+
126
+ subscribe_list = tuple(subscribe) if subscribe is not None else tuple(pubsub_ports)
127
+ for message_type in subscribe_list:
128
+ if message_type not in pubsub_ports:
129
+ raise ValueError(f"Unknown pub-sub message type: {message_type}")
130
+
131
+ return ClientConfig(
132
+ host=host,
133
+ subscribe=subscribe_list,
134
+ pubsub_ports=pubsub_ports,
135
+ reqrep_ports=reqrep_ports,
136
+ rest_ports=rest_ports,
137
+ callback_executor=callback_executor,
138
+ timeout_seconds=timeout_seconds,
139
+ )
140
+
141
+
142
+ def resolve_pubsub_port(config: ClientConfig, message_type: PubSubMessage) -> int:
143
+ """Resolve the port for a pub-sub message type."""
144
+
145
+ return config.pubsub_ports[message_type]
146
+
147
+
148
+ def resolve_reqrep_port(config: ClientConfig, endpoint: ReqRepEndpoint) -> int:
149
+ """Resolve the port for a request-reply endpoint."""
150
+
151
+ return config.reqrep_ports[endpoint]
152
+
153
+
154
+ def resolve_rest_port(config: ClientConfig, endpoint: RestEndpoint) -> int:
155
+ """Resolve the port for a REST endpoint."""
156
+
157
+ return config.rest_ports[endpoint]
@@ -0,0 +1,2 @@
1
+ class RequestTimeoutError(TimeoutError):
2
+ """Request-reply operation timed out."""
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class GriddedBottomDetection:
9
+ timestamp_utc: float
10
+ latitude_degrees: float
11
+ longitude_degrees: float
12
+ grid_interval_meters: float
13
+ target_strength_db: float
14
+ is_tide_corrected: bool
15
+ uploaded_to_cloud: bool
16
+ number_of_points: int
17
+ depth_meters: float
18
+
19
+ @classmethod
20
+ def from_dict(cls, data: dict[str, Any]) -> "GriddedBottomDetection":
21
+ return cls(
22
+ timestamp_utc=float(data["timestamp_utc"]),
23
+ latitude_degrees=float(data["latitude_degrees"]),
24
+ longitude_degrees=float(data["longitude_degrees"]),
25
+ grid_interval_meters=float(data["grid_interval_meters"]),
26
+ target_strength_db=float(data["target_strength_db"]),
27
+ is_tide_corrected=bool(data["is_tide_corrected"]),
28
+ uploaded_to_cloud=bool(data["uploaded_to_cloud"]),
29
+ number_of_points=int(data["number_of_points"]),
30
+ depth_meters=float(data["depth_meters"]),
31
+ )
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class GriddedInwaterDetection:
36
+ timestamp_utc: float
37
+ latitude_degrees: float
38
+ longitude_degrees: float
39
+ grid_interval_meters: float
40
+ target_strength_db: float
41
+ is_tide_corrected: bool
42
+ uploaded_to_cloud: bool
43
+ number_of_points: int
44
+ deepest_depth_meters: float | None
45
+ shallowest_depth_meters: float | None
46
+
47
+ @classmethod
48
+ def from_dict(cls, data: dict[str, Any]) -> "GriddedInwaterDetection":
49
+ return cls(
50
+ timestamp_utc=float(data["timestamp_utc"]),
51
+ latitude_degrees=float(data["latitude_degrees"]),
52
+ longitude_degrees=float(data["longitude_degrees"]),
53
+ grid_interval_meters=float(data["grid_interval_meters"]),
54
+ target_strength_db=float(data["target_strength_db"]),
55
+ is_tide_corrected=bool(data["is_tide_corrected"]),
56
+ uploaded_to_cloud=bool(data["uploaded_to_cloud"]),
57
+ number_of_points=int(data["number_of_points"]),
58
+ deepest_depth_meters=(
59
+ float(data["deepest_depth_meters"])
60
+ if data.get("deepest_depth_meters") is not None
61
+ else None
62
+ ),
63
+ shallowest_depth_meters=(
64
+ float(data["shallowest_depth_meters"])
65
+ if data.get("shallowest_depth_meters") is not None
66
+ else None
67
+ ),
68
+ )
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class HistoryData:
73
+ gridded_bottom_detections: list[GriddedBottomDetection]
74
+ gridded_inwater_detections: list[GriddedInwaterDetection]
75
+ history_count: int | None = None
76
+
77
+ @classmethod
78
+ def from_dict(
79
+ cls, data: dict[str, Any], *, history_count: int | None = None
80
+ ) -> "HistoryData":
81
+ bottom_raw = data.get("gridded_bottom_detections", [])
82
+ inwater_raw = data.get("gridded_inwater_detections", [])
83
+ return cls(
84
+ gridded_bottom_detections=[
85
+ GriddedBottomDetection.from_dict(item) for item in bottom_raw
86
+ ],
87
+ gridded_inwater_detections=[
88
+ GriddedInwaterDetection.from_dict(item) for item in inwater_raw
89
+ ],
90
+ history_count=history_count,
91
+ )