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.
@@ -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}")
@@ -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