seedlink-client 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.
- seedlink_client/__init__.py +79 -0
- seedlink_client/_base.py +232 -0
- seedlink_client/_commands.py +389 -0
- seedlink_client/aio.py +618 -0
- seedlink_client/cli.py +607 -0
- seedlink_client/client.py +654 -0
- seedlink_client/mseed.py +86 -0
- seedlink_client/protocol.py +392 -0
- seedlink_client/py.typed +0 -0
- seedlink_client/state.py +92 -0
- seedlink_client/streams.py +175 -0
- seedlink_client/time_utils.py +80 -0
- seedlink_client-0.1.0.dist-info/METADATA +93 -0
- seedlink_client-0.1.0.dist-info/RECORD +17 -0
- seedlink_client-0.1.0.dist-info/WHEEL +4 -0
- seedlink_client-0.1.0.dist-info/entry_points.txt +2 -0
- seedlink_client-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SeedLink protocol client for streaming geophysical (seismic) data, with
|
|
3
|
+
transparent support for both SeedLink 3.x and 4.0.
|
|
4
|
+
|
|
5
|
+
SeedLink is a real-time streaming protocol used by seismological data
|
|
6
|
+
systems (e.g. SeisComP, ringserver) to deliver miniSEED data as it is
|
|
7
|
+
recorded. This client negotiates whichever protocol version a server
|
|
8
|
+
speaks and presents the same API either way.
|
|
9
|
+
|
|
10
|
+
Quick start::
|
|
11
|
+
|
|
12
|
+
from seedlink_client import SeedLink
|
|
13
|
+
|
|
14
|
+
with SeedLink("rtserve.earthscope.org") as sl:
|
|
15
|
+
sl.add_stream("IU_COLA", "BH?")
|
|
16
|
+
for packet in sl.collect():
|
|
17
|
+
print(packet.station_id, packet.seqnum, len(packet.payload))
|
|
18
|
+
|
|
19
|
+
Async quick start (requires Python 3.11+; only the methods that do network
|
|
20
|
+
I/O, e.g. connect/collect/info, are coroutines -- stream selection like
|
|
21
|
+
add_stream() is plain configuration and stays synchronous)::
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
from seedlink_client import AsyncSeedLink
|
|
25
|
+
|
|
26
|
+
async def main():
|
|
27
|
+
async with AsyncSeedLink("rtserve.earthscope.org") as sl:
|
|
28
|
+
sl.add_stream("IU_COLA", "BH?")
|
|
29
|
+
async for packet in sl.collect():
|
|
30
|
+
print(packet.station_id, packet.seqnum, len(packet.payload))
|
|
31
|
+
|
|
32
|
+
asyncio.run(main())
|
|
33
|
+
|
|
34
|
+
Interactive client::
|
|
35
|
+
|
|
36
|
+
seedlink-client [host:port]
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from typing import TYPE_CHECKING
|
|
40
|
+
|
|
41
|
+
from .cli import main
|
|
42
|
+
from .client import SeedLink
|
|
43
|
+
from .protocol import (
|
|
44
|
+
Protocol,
|
|
45
|
+
SeedLinkAuthError,
|
|
46
|
+
SeedLinkError,
|
|
47
|
+
SeedLinkPacket,
|
|
48
|
+
SeedLinkResponse,
|
|
49
|
+
SeedLinkTimeout,
|
|
50
|
+
)
|
|
51
|
+
from .streams import Stream
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
# Only for type checkers/IDEs; the real (lazy) import is in __getattr__
|
|
55
|
+
# below, so importing seedlink_client doesn't pull in asyncio unless
|
|
56
|
+
# AsyncSeedLink is actually used.
|
|
57
|
+
from .aio import AsyncSeedLink
|
|
58
|
+
|
|
59
|
+
__version__ = "0.1.0"
|
|
60
|
+
__all__ = [
|
|
61
|
+
"AsyncSeedLink",
|
|
62
|
+
"Protocol",
|
|
63
|
+
"SeedLink",
|
|
64
|
+
"SeedLinkAuthError",
|
|
65
|
+
"SeedLinkError",
|
|
66
|
+
"SeedLinkPacket",
|
|
67
|
+
"SeedLinkResponse",
|
|
68
|
+
"SeedLinkTimeout",
|
|
69
|
+
"Stream",
|
|
70
|
+
"main",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def __getattr__(name: str):
|
|
75
|
+
if name == "AsyncSeedLink":
|
|
76
|
+
from .aio import AsyncSeedLink
|
|
77
|
+
|
|
78
|
+
return AsyncSeedLink
|
|
79
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
seedlink_client/_base.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Shared, transport-independent state and config for SeedLink clients.
|
|
2
|
+
|
|
3
|
+
Both :class:`~seedlink_client.client.SeedLink` (sockets) and
|
|
4
|
+
:class:`~seedlink_client.aio.AsyncSeedLink` (asyncio) subclass
|
|
5
|
+
:class:`_SeedLinkBase` for everything that doesn't touch a connection:
|
|
6
|
+
host/port/TLS configuration, ``from_server_string``, ``__repr__``, stream
|
|
7
|
+
selection (:meth:`add_stream` and friends), and state file save/restore.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
from . import state, streams
|
|
15
|
+
from .protocol import Protocol, SeedLinkError, parse_hello
|
|
16
|
+
from .streams import Stream
|
|
17
|
+
from .time_utils import parse_timestring
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _SeedLinkBase:
|
|
21
|
+
"""Transport-independent config and state shared by both SeedLink clients."""
|
|
22
|
+
|
|
23
|
+
TLS_PORT = 18500
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
host: str = "localhost",
|
|
28
|
+
port: int = 18000,
|
|
29
|
+
timeout: float | None = None,
|
|
30
|
+
tls: bool | None = None,
|
|
31
|
+
tls_noverify: bool = False,
|
|
32
|
+
protocol: Protocol | None = None,
|
|
33
|
+
keepalive: float | None = None,
|
|
34
|
+
idle_timeout: float = 600.0,
|
|
35
|
+
reconnect_delay: float = 30.0,
|
|
36
|
+
dialup: bool = False,
|
|
37
|
+
batch: bool = False,
|
|
38
|
+
clientname: str | None = None,
|
|
39
|
+
clientversion: str | None = None,
|
|
40
|
+
auth: tuple[str, str] | str | None = None,
|
|
41
|
+
):
|
|
42
|
+
self._host = host
|
|
43
|
+
self._port = port
|
|
44
|
+
self._timeout = timeout
|
|
45
|
+
self._tls = tls if tls is not None else (port == self.TLS_PORT)
|
|
46
|
+
self._tls_noverify = tls_noverify
|
|
47
|
+
self._requested_protocol = protocol
|
|
48
|
+
self._keepalive = keepalive
|
|
49
|
+
self._idle_timeout = idle_timeout
|
|
50
|
+
self._reconnect_delay = reconnect_delay
|
|
51
|
+
self._dialup = dialup
|
|
52
|
+
self._want_batch = batch
|
|
53
|
+
self._batch_active = False
|
|
54
|
+
self._clientname = clientname
|
|
55
|
+
self._clientversion = clientversion
|
|
56
|
+
self._auth = auth
|
|
57
|
+
self._streams: list[Stream] = []
|
|
58
|
+
self._multistation = False
|
|
59
|
+
self._all_stations = False
|
|
60
|
+
self._start_time: datetime | None = None
|
|
61
|
+
self._end_time: datetime | None = None
|
|
62
|
+
self._streaming = False
|
|
63
|
+
|
|
64
|
+
self.protocol: Protocol | None = None # negotiated, set by connect()
|
|
65
|
+
self.server_id: str | None = None
|
|
66
|
+
self.organization: str | None = None
|
|
67
|
+
self.server_capabilities: dict[str, str | bool] = {}
|
|
68
|
+
self._server_protocol_majors: frozenset[str] = frozenset()
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_server_string(
|
|
72
|
+
cls,
|
|
73
|
+
server: str,
|
|
74
|
+
timeout: float | None = None,
|
|
75
|
+
tls: bool | None = None,
|
|
76
|
+
tls_noverify: bool = False,
|
|
77
|
+
**kwargs,
|
|
78
|
+
):
|
|
79
|
+
"""Create a client from a server string (host:port, host@port, host, or '').
|
|
80
|
+
|
|
81
|
+
``host@port`` is an unambiguous alternative to ``host:port`` for use
|
|
82
|
+
with a bare IPv6 host, which cannot otherwise be told apart from the
|
|
83
|
+
``:port`` suffix; ``[host]:port`` bracket notation works too.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def _parse_port(text: str) -> int:
|
|
87
|
+
try:
|
|
88
|
+
port = int(text)
|
|
89
|
+
except ValueError:
|
|
90
|
+
raise ValueError(f"Invalid port in server string: {server!r}") from None
|
|
91
|
+
if not 1 <= port <= 65535:
|
|
92
|
+
raise ValueError(f"Port out of range in server string: {server!r}")
|
|
93
|
+
return port
|
|
94
|
+
|
|
95
|
+
host = "localhost"
|
|
96
|
+
port = 18000
|
|
97
|
+
server = server.strip()
|
|
98
|
+
if server:
|
|
99
|
+
if server.startswith("["):
|
|
100
|
+
bracket_end = server.find("]")
|
|
101
|
+
if bracket_end < 0:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"Missing closing bracket in server string: {server!r}"
|
|
104
|
+
)
|
|
105
|
+
host = server[1:bracket_end] or "localhost"
|
|
106
|
+
remainder = server[bracket_end + 1 :]
|
|
107
|
+
if remainder.startswith(":") and remainder[1:]:
|
|
108
|
+
port = _parse_port(remainder[1:])
|
|
109
|
+
elif "@" in server:
|
|
110
|
+
# host@port: split on the last '@' only, so a host containing
|
|
111
|
+
# '@' before this point is preserved rather than mangled.
|
|
112
|
+
head, _, tail = server.rpartition("@")
|
|
113
|
+
host = head or "localhost"
|
|
114
|
+
if tail:
|
|
115
|
+
port = _parse_port(tail)
|
|
116
|
+
elif server.count(":") > 1:
|
|
117
|
+
# A bare IPv6 literal (2+ colons) can't be split from a port
|
|
118
|
+
# suffix unambiguously; require bracket or '@' notation.
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"Ambiguous server string {server!r}; use '[host]:port' or "
|
|
121
|
+
"'host@port' for a bare IPv6 address"
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
parts = server.rsplit(":", 1)
|
|
125
|
+
host = parts[0] or "localhost"
|
|
126
|
+
if len(parts) == 2 and parts[1]:
|
|
127
|
+
port = _parse_port(parts[1])
|
|
128
|
+
return cls(host, port, timeout=timeout, tls=tls, tls_noverify=tls_noverify, **kwargs)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def is_connected(self) -> bool:
|
|
132
|
+
"""Whether the transport currently holds an open connection.
|
|
133
|
+
|
|
134
|
+
Each transport overrides this: the sync client checks ``_sock``, the
|
|
135
|
+
async client checks its ``_writer``.
|
|
136
|
+
"""
|
|
137
|
+
raise NotImplementedError
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def is_streaming(self) -> bool:
|
|
141
|
+
return self._streaming
|
|
142
|
+
|
|
143
|
+
def __repr__(self) -> str:
|
|
144
|
+
state_str = "connected" if self.is_connected else "disconnected"
|
|
145
|
+
tls = ", tls" if self._tls else ""
|
|
146
|
+
proto = f", v{self.protocol.value}" if self.protocol else ""
|
|
147
|
+
return f"{type(self).__name__}({self._host!r}, {self._port}, {state_str}{tls}{proto})"
|
|
148
|
+
|
|
149
|
+
def _store_identity(self, line1: str, line2: str) -> None:
|
|
150
|
+
"""Record a HELLO reply's server ID, organization, and capabilities."""
|
|
151
|
+
server_id, organization, capabilities, protocol_majors = parse_hello(line1, line2)
|
|
152
|
+
self.server_id = server_id
|
|
153
|
+
self.organization = organization
|
|
154
|
+
self.server_capabilities = capabilities
|
|
155
|
+
self._server_protocol_majors = protocol_majors
|
|
156
|
+
|
|
157
|
+
# -- Stream selection -----------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def add_stream(
|
|
160
|
+
self,
|
|
161
|
+
station_id: str,
|
|
162
|
+
selectors: str | list[str] | None = None,
|
|
163
|
+
seqnum: int | None = None,
|
|
164
|
+
all_data: bool = False,
|
|
165
|
+
timestamp: str | None = None,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Add one station to request, enabling multi-station mode.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
station_id: 'NET_STA', wildcards allowed (guaranteed in v4 only).
|
|
171
|
+
selectors: A selector string ('B_H_? B_L_?') or list of
|
|
172
|
+
selectors; None or empty means all streams.
|
|
173
|
+
seqnum: Resume after this sequence number.
|
|
174
|
+
all_data: Request the earliest available data (v4 ``DATA ALL``).
|
|
175
|
+
timestamp: ISO time of the last packet received, if resuming.
|
|
176
|
+
"""
|
|
177
|
+
if self._all_stations:
|
|
178
|
+
raise SeedLinkError("Cannot mix add_stream() with set_all_stations()")
|
|
179
|
+
sels = selectors.split() if isinstance(selectors, str) else list(selectors or [])
|
|
180
|
+
self._streams.append(
|
|
181
|
+
Stream(station_id=station_id, selectors=sels, seqnum=seqnum,
|
|
182
|
+
all_data=all_data, timestamp=timestamp)
|
|
183
|
+
)
|
|
184
|
+
self._multistation = True
|
|
185
|
+
|
|
186
|
+
def set_all_stations(
|
|
187
|
+
self,
|
|
188
|
+
selectors: str | list[str] | None = None,
|
|
189
|
+
seqnum: int | None = None,
|
|
190
|
+
all_data: bool = False,
|
|
191
|
+
timestamp: str | None = None,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Request all stations (v3 uni-station mode; v4 ``STATION *``)."""
|
|
194
|
+
if self._streams:
|
|
195
|
+
raise SeedLinkError("Cannot mix set_all_stations() with add_stream()")
|
|
196
|
+
sels = selectors.split() if isinstance(selectors, str) else list(selectors or [])
|
|
197
|
+
self._streams = [
|
|
198
|
+
Stream(station_id="*", selectors=sels, seqnum=seqnum,
|
|
199
|
+
all_data=all_data, timestamp=timestamp)
|
|
200
|
+
]
|
|
201
|
+
self._multistation = False
|
|
202
|
+
self._all_stations = True
|
|
203
|
+
|
|
204
|
+
def add_streamlist(self, text: str, default_selectors: str | None = None) -> None:
|
|
205
|
+
"""Add streams from a stream-list string; see :func:`streams.parse_streamlist`."""
|
|
206
|
+
if self._all_stations:
|
|
207
|
+
raise SeedLinkError("Cannot mix add_streamlist() with set_all_stations()")
|
|
208
|
+
self._streams.extend(streams.parse_streamlist(text, default_selectors))
|
|
209
|
+
self._multistation = True
|
|
210
|
+
|
|
211
|
+
def add_streamlist_file(self, path: str, default_selectors: str | None = None) -> None:
|
|
212
|
+
"""Add streams from a stream-list file; see :func:`streams.read_streamlist_file`."""
|
|
213
|
+
if self._all_stations:
|
|
214
|
+
raise SeedLinkError("Cannot mix add_streamlist_file() with set_all_stations()")
|
|
215
|
+
self._streams.extend(streams.read_streamlist_file(path, default_selectors))
|
|
216
|
+
self._multistation = True
|
|
217
|
+
|
|
218
|
+
def set_timewindow(self, start: str | datetime, end: str | datetime | None = None) -> None:
|
|
219
|
+
"""Request a fixed time window instead of resuming from a sequence number."""
|
|
220
|
+
self._start_time = parse_timestring(start) if isinstance(start, str) else start
|
|
221
|
+
self._end_time = parse_timestring(end) if isinstance(end, str) else end
|
|
222
|
+
|
|
223
|
+
def save_state(self, path: str) -> None:
|
|
224
|
+
"""Write each stream's current sequence number and timestamp to a state file."""
|
|
225
|
+
state.save_state(path, self._streams)
|
|
226
|
+
|
|
227
|
+
def recover_state(self, path: str) -> None:
|
|
228
|
+
"""Load a state file and apply saved sequence numbers to matching streams."""
|
|
229
|
+
saved = state.load_state(path)
|
|
230
|
+
for stream in self._streams:
|
|
231
|
+
if stream.station_id in saved:
|
|
232
|
+
stream.seqnum, stream.timestamp = saved[stream.station_id]
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""The SeedLink command vocabulary: pure request/reply builders, no I/O.
|
|
2
|
+
|
|
3
|
+
Every SeedLink command has the same shape: send a text line, and -- for
|
|
4
|
+
all but a few fire-and-forget commands -- read back exactly one reply line
|
|
5
|
+
and parse it. A :class:`Command` captures that shape as data, the same
|
|
6
|
+
pattern used by ``datalink_client._commands``. Both transports execute a
|
|
7
|
+
``Command`` with the same dispatch: send ``cmd.text`` + ``cmd.terminator``,
|
|
8
|
+
then, if ``cmd.parse`` is not None, read one reply line and call it.
|
|
9
|
+
|
|
10
|
+
:func:`plan_handshake` and :func:`plan_negotiation` are the heart of
|
|
11
|
+
transparent v3/v4 support: each takes configuration and returns an ordered
|
|
12
|
+
``list[Command]``, so the sync and async transports share every protocol
|
|
13
|
+
decision, and the planners are directly unit-testable without a socket.
|
|
14
|
+
|
|
15
|
+
SeedLink's negotiation is more stateful than DataLink's simple 1:1
|
|
16
|
+
command/reply model -- a rejected v3 STATION means skip that station's
|
|
17
|
+
SELECT/DATA, and a rejected selector is logged but not fatal -- so
|
|
18
|
+
``Command`` carries a little routing metadata (``role``, ``stream_id``) that
|
|
19
|
+
the transport's negotiation loop uses to apply that behavior generically
|
|
20
|
+
across all three negotiation shapes (v4, v3 multi-station, v3 uni-station).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from typing import Any, Literal
|
|
29
|
+
|
|
30
|
+
from .protocol import (
|
|
31
|
+
Protocol,
|
|
32
|
+
SeedLinkAuthError,
|
|
33
|
+
SeedLinkError,
|
|
34
|
+
SeedLinkResponse,
|
|
35
|
+
generate_client_id,
|
|
36
|
+
parse_reply,
|
|
37
|
+
)
|
|
38
|
+
from .streams import Stream, v3_to_v4_selector, v4_to_v3_selector
|
|
39
|
+
from .time_utils import parse_timestring, to_comma_timestring, to_iso_timestring
|
|
40
|
+
|
|
41
|
+
Role = Literal["generic", "station", "select", "data", "end"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Command:
|
|
46
|
+
"""A SeedLink request plus how to interpret its reply.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
text: The ASCII command line to send, without CR/LF.
|
|
50
|
+
parse: Callable to turn the reply line into a result, or None
|
|
51
|
+
if no reply is expected at all (e.g. END, BYE, or a v3
|
|
52
|
+
command sent while BATCH is active).
|
|
53
|
+
terminator: Line terminator to send after ``text``. v4's
|
|
54
|
+
STATION/SELECT/DATA use a bare ``\\r``; everything else
|
|
55
|
+
uses ``\\r\\n``.
|
|
56
|
+
role: Routing hint for :meth:`negotiate` loops; ``"generic"``
|
|
57
|
+
for anything sent outside a negotiation plan.
|
|
58
|
+
stream_id: The station ID this command belongs to, for negotiation
|
|
59
|
+
commands; None otherwise.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
text: str
|
|
63
|
+
parse: Callable[[str], Any] | None = None
|
|
64
|
+
terminator: bytes = b"\r\n"
|
|
65
|
+
role: Role = "generic"
|
|
66
|
+
stream_id: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _expect_ok(line: str) -> SeedLinkResponse:
|
|
70
|
+
resp = parse_reply(line)
|
|
71
|
+
if not resp:
|
|
72
|
+
raise SeedLinkError(resp.message or "Server returned ERROR", resp.code)
|
|
73
|
+
return resp
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _expect_auth_ok(line: str) -> SeedLinkResponse:
|
|
77
|
+
resp = parse_reply(line)
|
|
78
|
+
if not resp:
|
|
79
|
+
raise SeedLinkAuthError(resp.message or "Authentication failed", resp.code)
|
|
80
|
+
return resp
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Handshake commands (HELLO, SLPROTO, CAPABILITIES, USERAGENT, AUTH, BATCH)
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def hello() -> Command:
|
|
89
|
+
"""HELLO. The two-line reply is special-cased by the transport."""
|
|
90
|
+
return Command("HELLO", parse=None)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def slproto(version: str = "4.0") -> Command:
|
|
94
|
+
return Command(f"SLPROTO {version}", parse=_expect_ok)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def capabilities(flags: str = "EXTREPLY") -> Command:
|
|
98
|
+
return Command(f"CAPABILITIES {flags}", parse=_expect_ok)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def useragent(client_name: str | None, client_version: str | None) -> Command:
|
|
102
|
+
"""USERAGENT <name>/<version> [seedlink-client/<version>].
|
|
103
|
+
|
|
104
|
+
Falls back to :func:`~seedlink_client.protocol.generate_client_id` (the
|
|
105
|
+
running program's basename) when ``client_name`` is not given, so the
|
|
106
|
+
reported identity is never just the library on its own.
|
|
107
|
+
"""
|
|
108
|
+
from . import __version__
|
|
109
|
+
|
|
110
|
+
if client_name:
|
|
111
|
+
tokens = [f"{client_name}/{client_version}" if client_version else client_name]
|
|
112
|
+
else:
|
|
113
|
+
name, version = generate_client_id()
|
|
114
|
+
tokens = [f"{name}/{version}"]
|
|
115
|
+
tokens.append(f"seedlink-client/{__version__}")
|
|
116
|
+
return Command(f"USERAGENT {' '.join(tokens)}", parse=_expect_ok)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def auth_userpass(username: str, password: str) -> Command:
|
|
120
|
+
if " " in username or " " in password:
|
|
121
|
+
raise SeedLinkError("AUTH USERPASS username/password must not contain spaces")
|
|
122
|
+
return Command(f"AUTH USERPASS {username} {password}", parse=_expect_auth_ok)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def auth_jwt(token: str) -> Command:
|
|
126
|
+
return Command(f"AUTH JWT {token}", parse=_expect_auth_ok)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def batch() -> Command:
|
|
130
|
+
"""BATCH (v3). Non-fatal if rejected -- the caller just stays unbatched."""
|
|
131
|
+
return Command("BATCH", parse=parse_reply)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cat() -> Command:
|
|
135
|
+
"""CAT: legacy freeform station listing, superseded by INFO STATIONS in
|
|
136
|
+
v4. Not used by the negotiation planners; available for direct/
|
|
137
|
+
interactive use only, where the caller reads the freeform reply itself.
|
|
138
|
+
"""
|
|
139
|
+
return Command("CAT", parse=None)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def bye() -> Command:
|
|
143
|
+
return Command("BYE", parse=None)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def info(level: str) -> Command:
|
|
147
|
+
"""INFO <level>. The reply arrives as a data-stream packet, not a
|
|
148
|
+
single reply line, so this is sent with no expected line reply."""
|
|
149
|
+
return Command(f"INFO {level}", parse=None)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Negotiation-command builders
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _split_net_sta(station_id: str) -> tuple[str, str]:
|
|
158
|
+
"""Split 'NET_STA' at the first underscore; v3 STATION wants STA then NET."""
|
|
159
|
+
net, _, sta = station_id.partition("_")
|
|
160
|
+
return net, sta
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def station_v4(station_id: str) -> Command:
|
|
164
|
+
return Command(
|
|
165
|
+
f"STATION {station_id}", parse=_expect_ok, terminator=b"\r",
|
|
166
|
+
role="station", stream_id=station_id,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def station_v3(station_id: str, expect_reply: bool) -> Command:
|
|
171
|
+
net, sta = _split_net_sta(station_id)
|
|
172
|
+
return Command(
|
|
173
|
+
f"STATION {sta} {net}", parse=parse_reply if expect_reply else None,
|
|
174
|
+
role="station", stream_id=station_id,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def select_v4(station_id: str, selector: str) -> Command:
|
|
179
|
+
return Command(
|
|
180
|
+
f"SELECT {selector}", parse=_expect_ok, terminator=b"\r",
|
|
181
|
+
role="select", stream_id=station_id,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def select_v3(station_id: str, selector: str, expect_reply: bool) -> Command:
|
|
186
|
+
return Command(
|
|
187
|
+
f"SELECT {selector}", parse=parse_reply if expect_reply else None,
|
|
188
|
+
role="select", stream_id=station_id,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def data_v4(
|
|
193
|
+
station_id: str,
|
|
194
|
+
seqnum: int | None,
|
|
195
|
+
all_data: bool,
|
|
196
|
+
start: datetime | None,
|
|
197
|
+
end: datetime | None,
|
|
198
|
+
) -> Command:
|
|
199
|
+
"""DATA [<seq>|ALL] [<start> [<end>]] (v4).
|
|
200
|
+
|
|
201
|
+
A stream requesting a time window with no prior sequence number gets
|
|
202
|
+
``DATA ALL <start>...`` -- "give me everything in this window".
|
|
203
|
+
"""
|
|
204
|
+
parts = ["DATA"]
|
|
205
|
+
if seqnum is not None:
|
|
206
|
+
parts.append(str(seqnum))
|
|
207
|
+
elif all_data or start is not None:
|
|
208
|
+
parts.append("ALL")
|
|
209
|
+
if start is not None:
|
|
210
|
+
parts.append(to_iso_timestring(start))
|
|
211
|
+
if end is not None:
|
|
212
|
+
parts.append(to_iso_timestring(end))
|
|
213
|
+
return Command(
|
|
214
|
+
" ".join(parts), parse=_expect_ok, terminator=b"\r",
|
|
215
|
+
role="data", stream_id=station_id,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def data_v3(
|
|
220
|
+
station_id: str,
|
|
221
|
+
cmd_name: str,
|
|
222
|
+
seqnum: int | None,
|
|
223
|
+
timestamp: str | None,
|
|
224
|
+
expect_reply: bool,
|
|
225
|
+
) -> Command:
|
|
226
|
+
"""DATA or FETCH [<seq> [<timestamp>]] (v3). ``cmd_name`` is 'DATA' or 'FETCH'."""
|
|
227
|
+
if seqnum is not None:
|
|
228
|
+
text = f"{cmd_name} {seqnum:X}"
|
|
229
|
+
if timestamp:
|
|
230
|
+
text += f" {timestamp}"
|
|
231
|
+
else:
|
|
232
|
+
text = cmd_name
|
|
233
|
+
return Command(
|
|
234
|
+
text, parse=parse_reply if expect_reply else None,
|
|
235
|
+
role="data", stream_id=station_id,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def time_v3(station_id: str, start: str, end: str | None, expect_reply: bool) -> Command:
|
|
240
|
+
"""TIME <start> [<end>] (v3); requesting a time window overrides resume."""
|
|
241
|
+
text = f"TIME {start}" + (f" {end}" if end else "")
|
|
242
|
+
return Command(
|
|
243
|
+
text, parse=parse_reply if expect_reply else None,
|
|
244
|
+
role="data", stream_id=station_id,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def end_v3() -> Command:
|
|
249
|
+
return Command("END", parse=None, role="end")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def end_v4(dialup: bool) -> Command:
|
|
253
|
+
return Command("ENDFETCH" if dialup else "END", parse=None, role="end")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# Planners
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def plan_handshake(
|
|
262
|
+
protocol: Protocol,
|
|
263
|
+
server_capabilities: dict[str, str | bool],
|
|
264
|
+
client_name: str | None,
|
|
265
|
+
client_version: str | None,
|
|
266
|
+
auth: tuple[str, str] | str | None,
|
|
267
|
+
want_batch: bool,
|
|
268
|
+
) -> list[Command]:
|
|
269
|
+
"""Build the post-HELLO handshake commands, in send order.
|
|
270
|
+
|
|
271
|
+
``auth`` is ``(username, password)`` for USERPASS, a bare token string
|
|
272
|
+
for JWT, or None to send no AUTH command (v4 only; v3 has no AUTH).
|
|
273
|
+
Unlike :func:`plan_negotiation`'s v4 commands, these are meant to be
|
|
274
|
+
sent one at a time with its reply read immediately -- each of
|
|
275
|
+
SLPROTO/CAPABILITIES/USERAGENT/AUTH depends on the outcome of the one
|
|
276
|
+
before it.
|
|
277
|
+
"""
|
|
278
|
+
commands: list[Command] = []
|
|
279
|
+
if protocol is Protocol.V4:
|
|
280
|
+
commands.append(slproto("4.0"))
|
|
281
|
+
commands.append(useragent(client_name, client_version))
|
|
282
|
+
if auth is not None:
|
|
283
|
+
if isinstance(auth, str):
|
|
284
|
+
commands.append(auth_jwt(auth))
|
|
285
|
+
else:
|
|
286
|
+
commands.append(auth_userpass(*auth))
|
|
287
|
+
elif server_capabilities.get("CAP"):
|
|
288
|
+
commands.append(capabilities("EXTREPLY"))
|
|
289
|
+
if protocol is Protocol.V3 and want_batch:
|
|
290
|
+
commands.append(batch())
|
|
291
|
+
return commands
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def plan_negotiation(
|
|
295
|
+
protocol: Protocol,
|
|
296
|
+
streams: list[Stream],
|
|
297
|
+
dialup: bool,
|
|
298
|
+
multistation: bool,
|
|
299
|
+
resume: bool,
|
|
300
|
+
lastpkttime: bool,
|
|
301
|
+
batch_active: bool,
|
|
302
|
+
start_time: datetime | None = None,
|
|
303
|
+
end_time: datetime | None = None,
|
|
304
|
+
) -> list[Command]:
|
|
305
|
+
"""Build the stream-selection commands that start streaming, in order.
|
|
306
|
+
|
|
307
|
+
Three shapes, chosen by ``protocol`` and ``multistation``:
|
|
308
|
+
|
|
309
|
+
- v4: per stream, STATION then one SELECT per selector then DATA;
|
|
310
|
+
finally END or ENDFETCH. STATION/SELECT/DATA errors are fatal
|
|
311
|
+
(raised immediately by the transport as it reads each reply).
|
|
312
|
+
- v3 multi-station: per stream, STATION then SELECT(s) then
|
|
313
|
+
DATA/FETCH/TIME; finally END. A rejected STATION means that
|
|
314
|
+
stream's SELECT/DATA commands are skipped (the transport's
|
|
315
|
+
negotiation loop does this via ``role``/``stream_id``); a rejected
|
|
316
|
+
SELECT or DATA/FETCH/TIME is logged but not fatal.
|
|
317
|
+
- v3 uni-station (exactly one stream): no STATION, no END -- just
|
|
318
|
+
SELECT(s) then DATA/FETCH/TIME.
|
|
319
|
+
|
|
320
|
+
A stream's ``all_data``/time window takes precedence over resuming
|
|
321
|
+
from ``seqnum`` (v4 only -- v3 has no "all data" request). A set time
|
|
322
|
+
window always overrides sequence resumption, matching libslink.
|
|
323
|
+
"""
|
|
324
|
+
start_str = to_comma_timestring(start_time) if start_time else None
|
|
325
|
+
end_str = to_comma_timestring(end_time) if end_time else None
|
|
326
|
+
cmd_name = "FETCH" if dialup else "DATA"
|
|
327
|
+
|
|
328
|
+
def _resume_seqnum(stream: Stream) -> int | None:
|
|
329
|
+
return (stream.seqnum + 1) if (resume and stream.seqnum is not None) else None
|
|
330
|
+
|
|
331
|
+
def _resume_timestamp(stream: Stream, seqnum: int | None) -> str | None:
|
|
332
|
+
if not (lastpkttime and seqnum is not None and stream.timestamp):
|
|
333
|
+
return None
|
|
334
|
+
return to_comma_timestring(parse_timestring(stream.timestamp))
|
|
335
|
+
|
|
336
|
+
def _wire_selector(selector: str) -> str:
|
|
337
|
+
"""Convert a selector to the negotiated protocol's syntax if it
|
|
338
|
+
looks like it's written in the other one; already-native selectors
|
|
339
|
+
(and ones with no equivalent) pass through unchanged."""
|
|
340
|
+
if protocol is Protocol.V4:
|
|
341
|
+
return v3_to_v4_selector(selector) or selector
|
|
342
|
+
return v4_to_v3_selector(selector) or selector
|
|
343
|
+
|
|
344
|
+
if protocol is Protocol.V4:
|
|
345
|
+
commands: list[Command] = []
|
|
346
|
+
for stream in streams:
|
|
347
|
+
commands.append(station_v4(stream.station_id))
|
|
348
|
+
for selector in stream.selectors:
|
|
349
|
+
commands.append(select_v4(stream.station_id, _wire_selector(selector)))
|
|
350
|
+
commands.append(
|
|
351
|
+
data_v4(stream.station_id, _resume_seqnum(stream), stream.all_data, start_time, end_time)
|
|
352
|
+
)
|
|
353
|
+
commands.append(end_v4(dialup))
|
|
354
|
+
return commands
|
|
355
|
+
|
|
356
|
+
if not multistation:
|
|
357
|
+
if len(streams) != 1:
|
|
358
|
+
raise SeedLinkError("v3 uni-station mode requires exactly one stream")
|
|
359
|
+
stream = streams[0]
|
|
360
|
+
commands = [
|
|
361
|
+
select_v3(stream.station_id, _wire_selector(selector), expect_reply=True)
|
|
362
|
+
for selector in stream.selectors
|
|
363
|
+
]
|
|
364
|
+
if start_str:
|
|
365
|
+
commands.append(time_v3(stream.station_id, start_str, end_str, expect_reply=False))
|
|
366
|
+
else:
|
|
367
|
+
seqnum = _resume_seqnum(stream)
|
|
368
|
+
timestamp = _resume_timestamp(stream, seqnum)
|
|
369
|
+
commands.append(
|
|
370
|
+
data_v3(stream.station_id, cmd_name, seqnum, timestamp, expect_reply=False)
|
|
371
|
+
)
|
|
372
|
+
return commands
|
|
373
|
+
|
|
374
|
+
commands = []
|
|
375
|
+
for stream in streams:
|
|
376
|
+
expect_reply = not batch_active
|
|
377
|
+
commands.append(station_v3(stream.station_id, expect_reply))
|
|
378
|
+
for selector in stream.selectors:
|
|
379
|
+
commands.append(select_v3(stream.station_id, _wire_selector(selector), expect_reply))
|
|
380
|
+
if start_str:
|
|
381
|
+
commands.append(time_v3(stream.station_id, start_str, end_str, expect_reply))
|
|
382
|
+
else:
|
|
383
|
+
seqnum = _resume_seqnum(stream)
|
|
384
|
+
timestamp = _resume_timestamp(stream, seqnum)
|
|
385
|
+
commands.append(
|
|
386
|
+
data_v3(stream.station_id, cmd_name, seqnum, timestamp, expect_reply)
|
|
387
|
+
)
|
|
388
|
+
commands.append(end_v3())
|
|
389
|
+
return commands
|