farsounder 0.1.0__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.
farsounder/__init__.py ADDED
@@ -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
+ )
@@ -0,0 +1,307 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import TypeVar
5
+
6
+ import httpx
7
+ import zmq
8
+ import zmq.asyncio
9
+ from google.protobuf.message import Message
10
+
11
+ from farsounder.proto.proto import nav_api_pb2 as nav_api
12
+ from farsounder.client.config import (
13
+ ClientConfig,
14
+ ReqRepEndpoint,
15
+ resolve_reqrep_port,
16
+ resolve_rest_port,
17
+ )
18
+ from farsounder.client.exceptions import RequestTimeoutError
19
+ from farsounder.client.history_types import HistoryData
20
+
21
+ ResponseT = TypeVar("ResponseT", bound=Message)
22
+
23
+
24
+ async def _request(
25
+ config: ClientConfig,
26
+ endpoint: ReqRepEndpoint,
27
+ request: Message,
28
+ response_cls: type[ResponseT],
29
+ ) -> ResponseT:
30
+ ctx = zmq.asyncio.Context.instance()
31
+ socket = ctx.socket(zmq.REQ)
32
+ try:
33
+ port = resolve_reqrep_port(config, endpoint)
34
+ 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:
39
+ raise RequestTimeoutError(
40
+ f"Timeout waiting for {endpoint} after {config.timeout_seconds:.1f}s"
41
+ ) from exc
42
+ finally:
43
+ socket.close(linger=0)
44
+
45
+ response = response_cls()
46
+ response.ParseFromString(data)
47
+ return response
48
+
49
+
50
+ async def get_processor_settings(
51
+ config: ClientConfig,
52
+ ) -> nav_api.GetProcessorSettingsResponse:
53
+ """Request the current processor settings.
54
+
55
+ Parameters
56
+ ----------
57
+ config
58
+ Client configuration.
59
+
60
+ Returns
61
+ -------
62
+ nav_api.GetProcessorSettingsResponse
63
+ Response containing the current processor settings.
64
+ """
65
+
66
+ request = nav_api.GetProcessorSettingsRequest()
67
+ return await _request(
68
+ config,
69
+ "GetProcessorSettings",
70
+ request,
71
+ nav_api.GetProcessorSettingsResponse,
72
+ )
73
+
74
+
75
+ async def set_field_of_view(
76
+ fov: nav_api.FieldOfView,
77
+ config: ClientConfig,
78
+ ) -> nav_api.SetFieldOfViewResponse:
79
+ """Request a change to the sonar FieldOfView.
80
+
81
+ Parameters
82
+ ----------
83
+ fov
84
+ Desired field of view value.
85
+ config
86
+ Client configuration.
87
+
88
+ Returns
89
+ -------
90
+ nav_api.SetFieldOfViewResponse
91
+ Response describing the result of the request.
92
+ """
93
+
94
+ request = nav_api.SetFieldOfViewRequest(fov=fov)
95
+ return await _request(
96
+ config,
97
+ "SetFieldOfView",
98
+ request,
99
+ nav_api.SetFieldOfViewResponse,
100
+ )
101
+
102
+
103
+ async def set_bottom_detection(
104
+ enable: bool,
105
+ config: ClientConfig,
106
+ ) -> nav_api.SetBottomDetectionResponse:
107
+ """Enable or disable bottom detection.
108
+
109
+ Parameters
110
+ ----------
111
+ enable
112
+ Whether to enable bottom detection.
113
+ config
114
+ Client configuration.
115
+
116
+ Returns
117
+ -------
118
+ nav_api.SetBottomDetectionResponse
119
+ Response describing the result of the request.
120
+ """
121
+
122
+ request = nav_api.SetBottomDetectionRequest(enable_bottom_detection=enable)
123
+ return await _request(
124
+ config,
125
+ "SetBottomDetection",
126
+ request,
127
+ nav_api.SetBottomDetectionResponse,
128
+ )
129
+
130
+
131
+ async def set_inwater_squelch(
132
+ value: float,
133
+ config: ClientConfig,
134
+ ) -> nav_api.SetInWaterSquelchResponse:
135
+ """Set the in-water squelch value.
136
+
137
+ Parameters
138
+ ----------
139
+ value
140
+ Squelch value to set.
141
+ config
142
+ Client configuration.
143
+
144
+ Returns
145
+ -------
146
+ nav_api.SetInWaterSquelchResponse
147
+ Response describing the result of the request.
148
+ """
149
+
150
+ request = nav_api.SetInWaterSquelchRequest(new_squelch_val=value)
151
+ return await _request(
152
+ config,
153
+ "SetInWaterSquelch",
154
+ request,
155
+ nav_api.SetInWaterSquelchResponse,
156
+ )
157
+
158
+
159
+ async def set_squelchless_inwater_detector(
160
+ enable: bool,
161
+ config: ClientConfig,
162
+ ) -> nav_api.SetSquelchlessInWaterDetectorResponse:
163
+ """Enable or disable the squelchless in-water detector.
164
+
165
+ Parameters
166
+ ----------
167
+ enable
168
+ Whether to enable squelchless detection.
169
+ config
170
+ Client configuration.
171
+
172
+ Returns
173
+ -------
174
+ nav_api.SetSquelchlessInWaterDetectorResponse
175
+ Response describing the result of the request.
176
+ """
177
+
178
+ request = nav_api.SetSquelchlessInWaterDetectorRequest(
179
+ enable_squelchless_detection=enable
180
+ )
181
+ return await _request(
182
+ config,
183
+ "SetSquelchlessInWaterDetector",
184
+ request,
185
+ nav_api.SetSquelchlessInWaterDetectorResponse,
186
+ )
187
+
188
+
189
+ async def get_vessel_info(
190
+ config: ClientConfig,
191
+ ) -> nav_api.GetVesselInfoResponse:
192
+ """Request the current vessel info.
193
+
194
+ Parameters
195
+ ----------
196
+ config
197
+ Client configuration.
198
+
199
+ Returns
200
+ -------
201
+ nav_api.GetVesselInfoResponse
202
+ Response containing the vessel info.
203
+ """
204
+
205
+ request = nav_api.GetVesselInfoRequest()
206
+ return await _request(
207
+ config, "GetVesselInfo", request, nav_api.GetVesselInfoResponse
208
+ )
209
+
210
+
211
+ def _build_history_params(
212
+ *,
213
+ latitude: float,
214
+ longitude: float,
215
+ radius_meters: float,
216
+ since_timestamp_utc: float | None,
217
+ tide_corrected_only: bool,
218
+ skip: int,
219
+ limit: int,
220
+ include_count: bool,
221
+ ) -> dict[str, str]:
222
+ params: dict[str, str] = {
223
+ "latitude": str(latitude),
224
+ "longitude": str(longitude),
225
+ "radius_meters": str(radius_meters),
226
+ "tide_corrected_only": str(tide_corrected_only).lower(),
227
+ "skip": str(skip),
228
+ "limit": str(limit),
229
+ "include_count": str(include_count).lower(),
230
+ }
231
+ if since_timestamp_utc is not None:
232
+ params["since_timestamp_utc"] = str(since_timestamp_utc)
233
+ return params
234
+
235
+
236
+ async def get_history_data(
237
+ config: ClientConfig,
238
+ *,
239
+ latitude: float,
240
+ longitude: float,
241
+ radius_meters: float = 500,
242
+ since_timestamp_utc: float | None = None,
243
+ tide_corrected_only: bool = False,
244
+ skip: int = 0,
245
+ limit: int = 50000,
246
+ include_count: bool = True,
247
+ ) -> HistoryData:
248
+ """Request history data from the REST API.
249
+
250
+ Parameters
251
+ ----------
252
+ config
253
+ Client configuration.
254
+ latitude
255
+ Latitude in decimal degrees.
256
+ longitude
257
+ Longitude in decimal degrees.
258
+ radius_meters
259
+ Query radius in meters.
260
+ since_timestamp_utc
261
+ Return data collected or updated since this UTC epoch time.
262
+ tide_corrected_only
263
+ Only return tide corrected seafloor detections.
264
+ skip
265
+ Number of records to skip.
266
+ limit
267
+ Maximum number of records to return.
268
+ include_count
269
+ Include the total count in the response header.
270
+
271
+ Returns
272
+ -------
273
+ HistoryData
274
+ Parsed history data response.
275
+ """
276
+
277
+ port = resolve_rest_port(config, "GetHistoryData")
278
+ params = _build_history_params(
279
+ latitude=latitude,
280
+ longitude=longitude,
281
+ radius_meters=radius_meters,
282
+ since_timestamp_utc=since_timestamp_utc,
283
+ tide_corrected_only=tide_corrected_only,
284
+ skip=skip,
285
+ limit=limit,
286
+ include_count=include_count,
287
+ )
288
+ url = f"http://{config.host}:{port}/api/history_data"
289
+ try:
290
+ async with httpx.AsyncClient(timeout=config.timeout_seconds) as client:
291
+ response = await client.get(url, params=params)
292
+ response.raise_for_status()
293
+ except httpx.ConnectError as exc:
294
+ raise httpx.ConnectError(
295
+ f"Failed to connect to {url}, is the server running?"
296
+ ) from exc
297
+
298
+ history_count = None
299
+ if include_count:
300
+ header_val = response.headers.get("X-Grids-Count")
301
+ if header_val is not None:
302
+ try:
303
+ history_count = int(header_val)
304
+ except ValueError:
305
+ history_count = None
306
+ payload = response.json()
307
+ return HistoryData.from_dict(payload, history_count=history_count)