farsounder 0.1.0__py3-none-any.whl → 0.1.1__py3-none-any.whl

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.
@@ -1,11 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
4
3
  from typing import TypeVar
5
4
 
6
5
  import httpx
7
6
  import zmq
8
- import zmq.asyncio
9
7
  from google.protobuf.message import Message
10
8
 
11
9
  from farsounder.proto.proto import nav_api_pb2 as nav_api
@@ -21,24 +19,27 @@ from farsounder.client.history_types import HistoryData
21
19
  ResponseT = TypeVar("ResponseT", bound=Message)
22
20
 
23
21
 
24
- async def _request(
22
+ def _request(
25
23
  config: ClientConfig,
26
24
  endpoint: ReqRepEndpoint,
27
25
  request: Message,
28
26
  response_cls: type[ResponseT],
29
27
  ) -> ResponseT:
30
- ctx = zmq.asyncio.Context.instance()
28
+ ctx = zmq.Context.instance()
31
29
  socket = ctx.socket(zmq.REQ)
32
30
  try:
33
31
  port = resolve_reqrep_port(config, endpoint)
34
32
  socket.connect(f"tcp://{config.host}:{port}")
35
- await socket.send(request.SerializeToString())
36
- try:
37
- data = await asyncio.wait_for(socket.recv(), timeout=config.timeout_seconds)
38
- except asyncio.TimeoutError as exc:
33
+ socket.send(request.SerializeToString())
34
+ poller = zmq.Poller()
35
+ poller.register(socket, zmq.POLLIN)
36
+ timeout_ms = int(config.timeout_seconds * 1000)
37
+ events = dict(poller.poll(timeout=timeout_ms))
38
+ if socket not in events:
39
39
  raise RequestTimeoutError(
40
40
  f"Timeout waiting for {endpoint} after {config.timeout_seconds:.1f}s"
41
- ) from exc
41
+ )
42
+ data = socket.recv()
42
43
  finally:
43
44
  socket.close(linger=0)
44
45
 
@@ -47,7 +48,7 @@ async def _request(
47
48
  return response
48
49
 
49
50
 
50
- async def get_processor_settings(
51
+ def get_processor_settings(
51
52
  config: ClientConfig,
52
53
  ) -> nav_api.GetProcessorSettingsResponse:
53
54
  """Request the current processor settings.
@@ -64,7 +65,7 @@ async def get_processor_settings(
64
65
  """
65
66
 
66
67
  request = nav_api.GetProcessorSettingsRequest()
67
- return await _request(
68
+ return _request(
68
69
  config,
69
70
  "GetProcessorSettings",
70
71
  request,
@@ -72,7 +73,7 @@ async def get_processor_settings(
72
73
  )
73
74
 
74
75
 
75
- async def set_field_of_view(
76
+ def set_field_of_view(
76
77
  fov: nav_api.FieldOfView,
77
78
  config: ClientConfig,
78
79
  ) -> nav_api.SetFieldOfViewResponse:
@@ -92,7 +93,7 @@ async def set_field_of_view(
92
93
  """
93
94
 
94
95
  request = nav_api.SetFieldOfViewRequest(fov=fov)
95
- return await _request(
96
+ return _request(
96
97
  config,
97
98
  "SetFieldOfView",
98
99
  request,
@@ -100,7 +101,7 @@ async def set_field_of_view(
100
101
  )
101
102
 
102
103
 
103
- async def set_bottom_detection(
104
+ def set_bottom_detection(
104
105
  enable: bool,
105
106
  config: ClientConfig,
106
107
  ) -> nav_api.SetBottomDetectionResponse:
@@ -120,7 +121,7 @@ async def set_bottom_detection(
120
121
  """
121
122
 
122
123
  request = nav_api.SetBottomDetectionRequest(enable_bottom_detection=enable)
123
- return await _request(
124
+ return _request(
124
125
  config,
125
126
  "SetBottomDetection",
126
127
  request,
@@ -128,7 +129,7 @@ async def set_bottom_detection(
128
129
  )
129
130
 
130
131
 
131
- async def set_inwater_squelch(
132
+ def set_inwater_squelch(
132
133
  value: float,
133
134
  config: ClientConfig,
134
135
  ) -> nav_api.SetInWaterSquelchResponse:
@@ -148,7 +149,7 @@ async def set_inwater_squelch(
148
149
  """
149
150
 
150
151
  request = nav_api.SetInWaterSquelchRequest(new_squelch_val=value)
151
- return await _request(
152
+ return _request(
152
153
  config,
153
154
  "SetInWaterSquelch",
154
155
  request,
@@ -156,7 +157,7 @@ async def set_inwater_squelch(
156
157
  )
157
158
 
158
159
 
159
- async def set_squelchless_inwater_detector(
160
+ def set_squelchless_inwater_detector(
160
161
  enable: bool,
161
162
  config: ClientConfig,
162
163
  ) -> nav_api.SetSquelchlessInWaterDetectorResponse:
@@ -178,7 +179,7 @@ async def set_squelchless_inwater_detector(
178
179
  request = nav_api.SetSquelchlessInWaterDetectorRequest(
179
180
  enable_squelchless_detection=enable
180
181
  )
181
- return await _request(
182
+ return _request(
182
183
  config,
183
184
  "SetSquelchlessInWaterDetector",
184
185
  request,
@@ -186,7 +187,7 @@ async def set_squelchless_inwater_detector(
186
187
  )
187
188
 
188
189
 
189
- async def get_vessel_info(
190
+ def get_vessel_info(
190
191
  config: ClientConfig,
191
192
  ) -> nav_api.GetVesselInfoResponse:
192
193
  """Request the current vessel info.
@@ -203,9 +204,7 @@ async def get_vessel_info(
203
204
  """
204
205
 
205
206
  request = nav_api.GetVesselInfoRequest()
206
- return await _request(
207
- config, "GetVesselInfo", request, nav_api.GetVesselInfoResponse
208
- )
207
+ return _request(config, "GetVesselInfo", request, nav_api.GetVesselInfoResponse)
209
208
 
210
209
 
211
210
  def _build_history_params(
@@ -233,7 +232,7 @@ def _build_history_params(
233
232
  return params
234
233
 
235
234
 
236
- async def get_history_data(
235
+ def get_history_data(
237
236
  config: ClientConfig,
238
237
  *,
239
238
  latitude: float,
@@ -287,13 +286,17 @@ async def get_history_data(
287
286
  )
288
287
  url = f"http://{config.host}:{port}/api/history_data"
289
288
  try:
290
- async with httpx.AsyncClient(timeout=config.timeout_seconds) as client:
291
- response = await client.get(url, params=params)
289
+ with httpx.Client(timeout=config.timeout_seconds) as client:
290
+ response = client.get(url, params=params)
292
291
  response.raise_for_status()
293
292
  except httpx.ConnectError as exc:
294
293
  raise httpx.ConnectError(
295
294
  f"Failed to connect to {url}, is the server running?"
296
295
  ) from exc
296
+ except httpx.ConnectTimeout as exc:
297
+ raise RequestTimeoutError(
298
+ f"Timeout waiting for {url} after {config.timeout_seconds:.1f}s"
299
+ ) from exc
297
300
 
298
301
  history_count = None
299
302
  if include_count:
@@ -1,12 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
4
3
  import logging
4
+ import threading
5
5
  from collections.abc import Callable, Iterable
6
- from typing import Awaitable, Literal
6
+ from concurrent.futures import Future, ThreadPoolExecutor
7
+ from typing import Literal
7
8
 
8
9
  import zmq
9
- import zmq.asyncio
10
10
  from google.protobuf.message import Message
11
11
 
12
12
  from farsounder.proto.proto import nav_api_pb2 as nav_api
@@ -19,7 +19,7 @@ from farsounder.client.config import (
19
19
 
20
20
  logger = logging.getLogger(__name__)
21
21
 
22
- MessageCallback = Callable[[Message], Awaitable[None] | None]
22
+ MessageCallback = Callable[[Message], None]
23
23
 
24
24
  PUBSUB_MESSAGE_CLASSES: dict[PubSubMessage, type[Message]] = {
25
25
  "HydrophoneData": nav_api.HydrophoneData,
@@ -30,18 +30,20 @@ PUBSUB_MESSAGE_CLASSES: dict[PubSubMessage, type[Message]] = {
30
30
 
31
31
 
32
32
  class Subscription:
33
- """Async subscription to pub-sub messages."""
33
+ """Subscription to pub-sub messages using background threads."""
34
34
 
35
35
  def __init__(self, config: ClientConfig) -> None:
36
36
  self._config = config
37
37
  self._callbacks: dict[PubSubMessage, list[MessageCallback]] = {
38
38
  message_type: [] for message_type in PUBSUB_MESSAGE_CLASSES
39
39
  }
40
- self._ctx = zmq.asyncio.Context.instance()
41
- self._sockets: dict[PubSubMessage, zmq.asyncio.Socket] = {}
42
- self._tasks: list[asyncio.Task[None]] = []
40
+ self._threads: list[threading.Thread] = []
43
41
  self._running = False
42
+ self._stop_event = threading.Event()
44
43
  self._use_threadpool = config.callback_executor == "threadpool"
44
+ self._executor: ThreadPoolExecutor | None = (
45
+ ThreadPoolExecutor() if self._use_threadpool else None
46
+ )
45
47
 
46
48
  def on(self, message_type: PubSubMessage, callback: MessageCallback) -> None:
47
49
  """Register a callback for a pub-sub message type.
@@ -59,92 +61,107 @@ class Subscription:
59
61
  raise ValueError(f"Unknown pub-sub message type: {message_type}")
60
62
  self._callbacks[message_type].append(callback)
61
63
 
62
- async def start(self) -> None:
63
- """Start background receive tasks for subscribed message types."""
64
+ def start(self) -> None:
65
+ """Start background receive threads for subscribed message types."""
64
66
 
65
67
  if self._running:
66
68
  return
67
69
  self._running = True
70
+ self._stop_event.clear()
71
+ if self._use_threadpool and self._executor is None:
72
+ self._executor = ThreadPoolExecutor()
68
73
 
69
74
  for message_type in self._config.subscribe:
70
- port = resolve_pubsub_port(self._config, message_type)
71
- socket = self._ctx.socket(zmq.SUB)
72
- socket.setsockopt(zmq.SUBSCRIBE, b"")
73
- socket.connect(f"tcp://{self._config.host}:{port}")
74
- self._sockets[message_type] = socket
75
- self._tasks.append(
76
- asyncio.create_task(self._recv_loop(message_type, socket))
75
+ thread = threading.Thread(
76
+ target=self._recv_loop,
77
+ args=(message_type,),
78
+ name=f"farsounder-sub-{message_type}",
79
+ daemon=True,
77
80
  )
81
+ thread.start()
82
+ self._threads.append(thread)
78
83
 
79
- async def stop(self) -> None:
80
- """Stop background receive tasks and close sockets."""
84
+ def stop(self) -> None:
85
+ """Stop background receive threads."""
81
86
 
82
87
  if not self._running:
83
88
  return
84
89
  self._running = False
90
+ self._stop_event.set()
85
91
 
86
- for task in self._tasks:
87
- task.cancel()
88
- await asyncio.gather(*self._tasks, return_exceptions=True)
89
- self._tasks.clear()
92
+ for thread in self._threads:
93
+ thread.join()
94
+ self._threads.clear()
90
95
 
91
- for socket in self._sockets.values():
92
- socket.close(linger=0)
93
- self._sockets.clear()
96
+ if self._executor is not None:
97
+ self._executor.shutdown(wait=True)
98
+ self._executor = None
94
99
 
95
- async def __aenter__(self) -> "Subscription":
96
- await self.start()
100
+ def __enter__(self) -> "Subscription":
101
+ self.start()
97
102
  return self
98
103
 
99
- async def __aexit__(self, exc_type, exc, tb) -> None:
100
- await self.stop()
104
+ def __exit__(self, exc_type, exc, tb) -> None:
105
+ self.stop()
101
106
 
102
- async def _recv_loop(
103
- self, message_type: PubSubMessage, socket: zmq.asyncio.Socket
104
- ) -> None:
107
+ def _recv_loop(self, message_type: PubSubMessage) -> None:
108
+ port = resolve_pubsub_port(self._config, message_type)
109
+ socket = zmq.Context.instance().socket(zmq.SUB)
110
+ socket.setsockopt(zmq.SUBSCRIBE, b"")
111
+ socket.connect(f"tcp://{self._config.host}:{port}")
112
+ poller = zmq.Poller()
113
+ poller.register(socket, zmq.POLLIN)
105
114
  message_cls = PUBSUB_MESSAGE_CLASSES[message_type]
106
- while True:
107
- try:
108
- data = await socket.recv()
109
- except asyncio.CancelledError:
110
- break
111
- except Exception:
112
- logger.exception("Failed to receive %s", message_type)
113
- continue
114
-
115
- try:
116
- message = message_cls()
117
- message.ParseFromString(data)
118
- except Exception:
119
- logger.exception("Failed to parse %s", message_type)
120
- continue
121
-
122
- self._dispatch(message_type, message)
115
+ try:
116
+ while not self._stop_event.is_set():
117
+ try:
118
+ events = dict(poller.poll(timeout=1000))
119
+ except Exception:
120
+ if self._stop_event.is_set():
121
+ break
122
+ logger.exception("Failed to poll %s", message_type)
123
+ continue
124
+
125
+ if socket not in events:
126
+ continue
127
+
128
+ try:
129
+ data = socket.recv()
130
+ except Exception:
131
+ if self._stop_event.is_set():
132
+ break
133
+ logger.exception("Failed to receive %s", message_type)
134
+ continue
135
+
136
+ try:
137
+ message = message_cls()
138
+ message.ParseFromString(data)
139
+ except Exception:
140
+ logger.exception("Failed to parse %s", message_type)
141
+ continue
142
+
143
+ self._dispatch(message_type, message)
144
+ finally:
145
+ socket.close(linger=0)
123
146
 
124
147
  def _dispatch(self, message_type: PubSubMessage, message: Message) -> None:
125
148
  callbacks = list(self._callbacks.get(message_type, []))
126
149
  if not callbacks:
127
150
  return
128
151
 
129
- loop = asyncio.get_running_loop()
130
152
  for callback in callbacks:
131
- if self._use_threadpool and not asyncio.iscoroutinefunction(callback):
132
- future = loop.run_in_executor(None, callback, message)
153
+ if self._use_threadpool and self._executor is not None:
154
+ future = self._executor.submit(callback, message)
133
155
  future.add_done_callback(self._log_task_result)
134
156
  continue
135
157
 
136
158
  try:
137
- result = callback(message)
159
+ callback(message)
138
160
  except Exception:
139
161
  logger.exception("Callback error for %s", message_type)
140
- continue
141
-
142
- if asyncio.iscoroutine(result):
143
- task = asyncio.create_task(result)
144
- task.add_done_callback(self._log_task_result)
145
162
 
146
163
  @staticmethod
147
- def _log_task_result(task: asyncio.Future[object]) -> None:
164
+ def _log_task_result(task: Future[object]) -> None:
148
165
  try:
149
166
  task.result()
150
167
  except Exception:
@@ -1,18 +1,25 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: farsounder
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Python client to communicate with FarSounder's ARGOS sensors
5
5
  Author-email: FarSounder Software Team <sw@farsounder.com>
6
6
  Requires-Python: >=3.13
7
7
  Description-Content-Type: text/markdown
8
+ License-File: LICENSE
8
9
  Requires-Dist: httpx
9
10
  Requires-Dist: protobuf
10
11
  Requires-Dist: pyzmq
12
+ Dynamic: license-file
11
13
 
12
- # SDK Client for live FarSounder data
14
+ # Python API Client for Argos data
13
15
 
14
16
  Python client to communicate with SonaSoft via API.
15
17
 
18
+ >[!NOTE]
19
+ > This is still under active development and testing. But may serve as an example for
20
+ > integration, beyond the lower level example in [sdk repo](https://github.com/farsounder/SDK-Integration-Examples).
21
+ > For now the docs are the interfaces and the examples in examples/
22
+
16
23
  ## Usage
17
24
 
18
25
  Clone locally and install (uv add .) or install from pypi like:
@@ -25,47 +32,63 @@ uv add farsounder
25
32
  Then - for example to subscribe to `TargetData` messages:
26
33
 
27
34
  ```python
28
- import asyncio
35
+ import time
29
36
 
30
37
  from farsounder import config, requests, subscriber
31
38
  from farsounder.proto import nav_api_pb2
32
39
 
33
40
 
34
- async def main() -> None:
41
+ def main() -> None:
42
+
43
+ # Build the configuration:
44
+ # - where is the api running?
45
+ # - what messages do you care about subscribing to?
46
+ # - if ports are changed, they can overridden here
35
47
  cfg = config.build_config(
36
48
  host="127.0.0.1",
37
49
  subscribe=["TargetData"],
38
50
  )
39
51
 
40
- sub = subscriber.subscribe(config)
41
52
 
53
+ # Pub/Sub
54
+ sub = subscriber.subscribe(cfg)
55
+ # A callback to run when there are new target data messages
56
+ # - don't do a lot of processing in here it will block (thank GIL - for now...)
57
+ # - use sync mechanism if you're accessing shared data (callbacks
58
+ # run on thread pool [default] or on the receive thread for the message
59
+ # type)
42
60
  def on_targets(message: nav_api_pb2.TargetData) -> None:
43
61
  print("Got a TargetData!")
44
62
  print("Targets:", len(message.groups))
45
63
 
46
- # Pub/Sub
64
+ # register the callback(s)
47
65
  sub.on("TargetData", on_targets)
48
- await sub.start()
66
+
67
+ # start the receive loop
68
+ sub.start()
49
69
 
50
70
  # Req/rep
51
- settings = await requests.get_processor_settings(config)
71
+ # for each of the request / reply endpoints - there are specific
72
+ # get/set functions
73
+ settings = requests.get_processor_settings(cfg)
52
74
  print(settings)
53
75
 
54
- # History data
55
- history = await requests.get_history_data(
56
- config,
76
+ history = requests.get_history_data(
77
+ cfg,
57
78
  latitude=41.7223,
58
79
  longitude=-71.35,
59
80
  radius_meters=500,
60
81
  )
61
82
  print(history.gridded_bottom_detections)
62
83
 
63
- await asyncio.sleep(1.0)
64
- await sub.stop()
84
+ time.sleep(1.0)
85
+
86
+ # Stop receive loop
87
+ sub.stop()
65
88
 
66
89
 
67
90
  if __name__ == "__main__":
68
- asyncio.run(main())
91
+ main()
69
92
  ```
70
93
 
71
94
  ## Simulated backend
@@ -3,8 +3,8 @@ farsounder/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU
3
3
  farsounder/client/config.py,sha256=toTQ9hjLps4OJJYdN8WMk9O3yRTTmUQbKDLXWdZmxM0,4596
4
4
  farsounder/client/exceptions.py,sha256=CLMtiQwe7zRKL4swq5Xl8p1Xo_jutpKov3YCmDMp0PI,88
5
5
  farsounder/client/history_types.py,sha256=NkZV9wwPdUMOz_hamyUjMvx8p9BPbAE6WNKA1OZd7yQ,3358
6
- farsounder/client/requests.py,sha256=_YV0qWOymdwnN2kkKwkLJ1LTR4llXlKtcDBd97IMOOM,8115
7
- farsounder/client/subscriber.py,sha256=0WH93zKj9S_SOVnjuFIPVfgOwSXc2k8b9f45-i0ihog,6234
6
+ farsounder/client/requests.py,sha256=LhX1KYQv91k_GlHZp2T3qC5Xu7ggT32pSZHZfSo1IJQ,8218
7
+ farsounder/client/subscriber.py,sha256=O_KWCR0kqt8E1cfiKNypAGS8gC-fL68idK2Vj1ybFdE,6803
8
8
  farsounder/proto/__init__.py,sha256=srUEbuHuHHVMvPgQY9z4PmprVai78cleAYN34oiKcHI,824
9
9
  farsounder/proto/proto/__init__.py,sha256=jNeAvw99gdQ6m2XO8vtp05Sp3v-MVFzPhDxtttOBXUg,76
10
10
  farsounder/proto/proto/array_pb2.py,sha256=7q7vr6AtUIc1z31Q521XRHb5CeCuZEFvmp5E4nPLWuo,2170
@@ -19,8 +19,9 @@ farsounder/proto/proto/nmea_pb2.py,sha256=jTmHhkrP1EOJxIy9coh2WM2_Po_2BUt41ovGqV
19
19
  farsounder/proto/proto/nmea_pb2.pyi,sha256=Jz18Kr61182b1JLNLSSoajuODeIGP4vzQnjImxRw9AE,795
20
20
  farsounder/proto/proto/time_pb2.py,sha256=WEc9S8-Qk0mE3W5H76ruancCxKrA5y6sGYkM4Jjz2tU,1542
21
21
  farsounder/proto/proto/time_pb2.pyi,sha256=dVAMCH_pxbYeCKDgIBAZPYa9Rpj52hB8MXeRFWyPeD0,950
22
- farsounder-0.1.0.dist-info/METADATA,sha256=NYJux9H6WuY_ks7C5gJF0jS8lQyr2LjD2Q0Km07uKcM,2155
23
- farsounder-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
24
- farsounder-0.1.0.dist-info/entry_points.txt,sha256=JKbKYe7jA1DvRuucj2jO8mXd3Bipb2BoPvxF0clcQWg,64
25
- farsounder-0.1.0.dist-info/top_level.txt,sha256=B7BR-Oj3C_D1nEICNpZt19anMsV8Ozxt836-R4VhTcs,11
26
- farsounder-0.1.0.dist-info/RECORD,,
22
+ farsounder-0.1.1.dist-info/licenses/LICENSE,sha256=Emjj_IK4M3YYqy_ziQr96CSknkIfzQG3za-kQw9gcW0,908
23
+ farsounder-0.1.1.dist-info/METADATA,sha256=Fj731yUyJqy83iJtWRz_FxQx88tM2bWKLAREpzIRpBU,3083
24
+ farsounder-0.1.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
25
+ farsounder-0.1.1.dist-info/entry_points.txt,sha256=JKbKYe7jA1DvRuucj2jO8mXd3Bipb2BoPvxF0clcQWg,64
26
+ farsounder-0.1.1.dist-info/top_level.txt,sha256=B7BR-Oj3C_D1nEICNpZt19anMsV8Ozxt836-R4VhTcs,11
27
+ farsounder-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,15 @@
1
+ # Licensing
2
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
3
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
4
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
5
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
6
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
7
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
8
+ SOFTWARE.
9
+
10
+ The SonaSoft SDK is provided free of charge for non-commercial applications,
11
+ development partners are still required to sign a licensing agreement with
12
+ FarSounder to receive the complete SDK materials (the SonaSoft demo software)
13
+ and eventually to become an authorized third-party integrator. Please contact
14
+ us at service@farsounder.com to complete the licensing agreement or discuss use
15
+ for commercial applications.