pycfdp 0.2.2__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.
- cfdp/__init__.py +106 -0
- cfdp/checksum.py +94 -0
- cfdp/constants.py +153 -0
- cfdp/core.py +895 -0
- cfdp/event.py +55 -0
- cfdp/filestore/__init__.py +2 -0
- cfdp/filestore/base.py +99 -0
- cfdp/filestore/native.py +162 -0
- cfdp/meta/__init__.py +3 -0
- cfdp/meta/datafield.py +11 -0
- cfdp/meta/filestore.py +127 -0
- cfdp/meta/message.py +659 -0
- cfdp/pdu/__init__.py +8 -0
- cfdp/pdu/ack.py +90 -0
- cfdp/pdu/eof.py +123 -0
- cfdp/pdu/filedata.py +90 -0
- cfdp/pdu/finished.py +99 -0
- cfdp/pdu/header.py +159 -0
- cfdp/pdu/keep_alive.py +108 -0
- cfdp/pdu/metadata.py +211 -0
- cfdp/pdu/nak.py +220 -0
- cfdp/pdu/prompt.py +75 -0
- cfdp/pure/__init__.py +68 -0
- cfdp/pure/config.py +141 -0
- cfdp/pure/indications.py +130 -0
- cfdp/pure/machines/__init__.py +25 -0
- cfdp/pure/machines/receiver1.py +433 -0
- cfdp/pure/machines/receiver2.py +893 -0
- cfdp/pure/machines/sender1.py +400 -0
- cfdp/pure/machines/sender2.py +774 -0
- cfdp/pure/outputs.py +203 -0
- cfdp/pure/query_port.py +48 -0
- cfdp/pure/transaction.py +43 -0
- cfdp/py.typed +0 -0
- cfdp/remote_entity_config.py +12 -0
- cfdp/shell/__init__.py +44 -0
- cfdp/shell/checksum_service.py +136 -0
- cfdp/shell/dispatcher.py +185 -0
- cfdp/shell/effect_executor.py +285 -0
- cfdp/shell/machine_registry.py +165 -0
- cfdp/shell/query_port.py +70 -0
- cfdp/shell/timer_service.py +252 -0
- cfdp/transaction_handle.py +50 -0
- cfdp/transport/__init__.py +0 -0
- cfdp/transport/base.py +12 -0
- cfdp/transport/spacepacket.py +47 -0
- cfdp/transport/udp.py +90 -0
- cfdp/transport/zmq.py +54 -0
- pycfdp-0.2.2.dist-info/METADATA +76 -0
- pycfdp-0.2.2.dist-info/RECORD +53 -0
- pycfdp-0.2.2.dist-info/WHEEL +5 -0
- pycfdp-0.2.2.dist-info/licenses/LICENSE.txt +19 -0
- pycfdp-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""The shell's wall-clock timer table (design D6 / issue #17).
|
|
2
|
+
|
|
3
|
+
A pure machine holds no clock and no thread; it only *emits* timer effects
|
|
4
|
+
(``StartTimer/CancelTimer/SuspendTimer/ResumeTimer``) and *receives* an expiry
|
|
5
|
+
as an injected ``EventType`` event. The :class:`TimerService` is the imperative
|
|
6
|
+
shell that owns the actual wall-clock machinery: one timer per
|
|
7
|
+
``(transaction, kind)``. On expiry it injects the corresponding event onto the
|
|
8
|
+
entity's shared FIFO for the *owning* transaction (design D8 — the same door as
|
|
9
|
+
every other event), so expiry never mutates machine state directly and never
|
|
10
|
+
runs off the event thread.
|
|
11
|
+
|
|
12
|
+
Semantics reproduced exactly from the old ``machines/timer.py`` +
|
|
13
|
+
``base.Machine`` timer helpers (design D6, Parking-lot item 2):
|
|
14
|
+
|
|
15
|
+
* **start** — (re)start: cancel any live timer for the key, then schedule anew.
|
|
16
|
+
* **cancel** — cancel and *forget*: a cancelled timer cannot be resumed.
|
|
17
|
+
* **suspend** — cancel *without* forgetting: the interval + event are remembered
|
|
18
|
+
so a later :meth:`resume` can restart the same timer (old
|
|
19
|
+
``suspend_ack_timer`` = ``suspended = True`` + ``cancel()``).
|
|
20
|
+
* **resume** — restart a suspended timer (old ``resume_ack_timer`` =
|
|
21
|
+
``suspended = False`` + ``restart()``). A no-op if nothing was suspended.
|
|
22
|
+
|
|
23
|
+
The **expiration-limit counter is NOT here** (design D6): it is protocol state
|
|
24
|
+
that lives on the machine and survives suspend. The service just fires the
|
|
25
|
+
event; the machine's handler increments its own counter and decides when the
|
|
26
|
+
limit is reached. Freeze is likewise independent of the timer table — it is a
|
|
27
|
+
machine-state flag, so nothing freeze-related lives here.
|
|
28
|
+
|
|
29
|
+
**Thread-safety.** The default scheduler is :class:`threading.Timer`, so an
|
|
30
|
+
expiry callback runs on a timer thread. The callback only calls
|
|
31
|
+
``entity.trigger_event`` (a thread-safe queue put), marshalling the expiry onto
|
|
32
|
+
the event thread rather than touching machine state. A lock guards the timer
|
|
33
|
+
table so ``start/cancel/suspend/resume`` (run on the event thread) and an
|
|
34
|
+
expiry callback (run on a timer thread) never corrupt it. :meth:`stop` cancels
|
|
35
|
+
every outstanding timer so no timer thread leaks past entity shutdown.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import threading
|
|
39
|
+
|
|
40
|
+
from ..event import Event
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _ThreadingScheduler:
|
|
44
|
+
"""The real scheduler: one-shot :class:`threading.Timer` per timer.
|
|
45
|
+
|
|
46
|
+
Injected by default. Tests pass a manual scheduler instead so an expiry can
|
|
47
|
+
be fired synchronously with no wall-clock wait (issue #17 acceptance
|
|
48
|
+
criterion 4). The scheduler seam is intentionally tiny: ``schedule`` returns
|
|
49
|
+
an opaque handle, ``cancel`` cancels it.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def schedule(self, interval, callback):
|
|
53
|
+
timer = threading.Timer(interval, callback)
|
|
54
|
+
timer.daemon = True
|
|
55
|
+
timer.start()
|
|
56
|
+
return timer
|
|
57
|
+
|
|
58
|
+
def cancel(self, handle):
|
|
59
|
+
handle.cancel()
|
|
60
|
+
# ``Timer.cancel`` only prevents the action from running; the timer
|
|
61
|
+
# thread then unblocks and exits. Join it (briefly) so a cancelled
|
|
62
|
+
# timer leaves no lingering thread — the suite has active-thread-count
|
|
63
|
+
# assertions around entity shutdown that this must honor. Never join
|
|
64
|
+
# our own thread (an expiry callback that cancels its own timer).
|
|
65
|
+
if handle is not threading.current_thread():
|
|
66
|
+
handle.join(timeout=1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _Timer:
|
|
70
|
+
"""One entry in the timer table: its schedule state and how to re-arm it.
|
|
71
|
+
|
|
72
|
+
Holds the parameters needed to restart the timer (``interval``, the
|
|
73
|
+
``event_type`` to inject) so :meth:`resume` can re-arm a suspended timer,
|
|
74
|
+
plus the live scheduler ``handle`` (``None`` when not currently running).
|
|
75
|
+
``suspended`` records cancel-without-forget so ``resume`` knows to re-arm.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
__slots__ = ("interval", "event_type", "handle", "suspended")
|
|
79
|
+
|
|
80
|
+
def __init__(self, interval, event_type):
|
|
81
|
+
self.interval = interval
|
|
82
|
+
self.event_type = event_type
|
|
83
|
+
self.handle = None
|
|
84
|
+
self.suspended = False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TimerService:
|
|
88
|
+
"""The ``(tid, kind) -> timer`` table with suspend/resume semantics (D6)."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, entity, scheduler=None):
|
|
91
|
+
self._entity = entity
|
|
92
|
+
self._scheduler = scheduler if scheduler is not None else _ThreadingScheduler()
|
|
93
|
+
# (tid, kind) -> _Timer
|
|
94
|
+
self._timers = {}
|
|
95
|
+
self._lock = threading.Lock()
|
|
96
|
+
|
|
97
|
+
# -- start / cancel ---------------------------------------------------- #
|
|
98
|
+
|
|
99
|
+
def start(self, tid, kind, interval, event_type):
|
|
100
|
+
"""(Re)start the ``kind`` timer for ``tid`` (old ``restart``).
|
|
101
|
+
|
|
102
|
+
Cancels any live timer for the key first, then schedules anew. Clears
|
|
103
|
+
any suspended flag: an explicit (re)start supersedes a prior suspend.
|
|
104
|
+
"""
|
|
105
|
+
stale_handle = None
|
|
106
|
+
with self._lock:
|
|
107
|
+
existing = self._timers.get((tid, kind))
|
|
108
|
+
if existing is not None:
|
|
109
|
+
# Collect the previous handle for cancellation *outside* the
|
|
110
|
+
# lock (never join under the lock — see :meth:`cancel`).
|
|
111
|
+
stale_handle = existing.handle
|
|
112
|
+
timer = _Timer(interval, event_type)
|
|
113
|
+
self._timers[(tid, kind)] = timer
|
|
114
|
+
self._arm(tid, kind, timer)
|
|
115
|
+
if stale_handle is not None:
|
|
116
|
+
self._scheduler.cancel(stale_handle)
|
|
117
|
+
|
|
118
|
+
def cancel(self, tid, kind):
|
|
119
|
+
"""Cancel and *forget* the ``kind`` timer for ``tid`` (old ``cancel``).
|
|
120
|
+
|
|
121
|
+
A cancelled timer is removed entirely, so a later :meth:`resume` brings
|
|
122
|
+
nothing back. This is the difference from :meth:`suspend`.
|
|
123
|
+
|
|
124
|
+
The table entry is popped *inside* the lock so a concurrently-firing
|
|
125
|
+
:meth:`_on_expiry` sees ``timer is None`` and no-ops (no stale event is
|
|
126
|
+
injected). The scheduler ``cancel`` — which may ``join`` the timer
|
|
127
|
+
thread — runs *outside* the lock, so the event thread never blocks
|
|
128
|
+
joining a timer thread that is itself waiting on this lock.
|
|
129
|
+
"""
|
|
130
|
+
handle = None
|
|
131
|
+
with self._lock:
|
|
132
|
+
timer = self._timers.pop((tid, kind), None)
|
|
133
|
+
if timer is not None:
|
|
134
|
+
handle = timer.handle
|
|
135
|
+
if handle is not None:
|
|
136
|
+
self._scheduler.cancel(handle)
|
|
137
|
+
|
|
138
|
+
# -- suspend / resume (cancel-without-forget / restart) ---------------- #
|
|
139
|
+
|
|
140
|
+
def suspend(self, tid, kind):
|
|
141
|
+
"""Suspend the ``kind`` timer for ``tid`` (cancel-without-forget).
|
|
142
|
+
|
|
143
|
+
Mirrors the old ``suspend_ack_timer``: cancel the running timer but keep
|
|
144
|
+
the entry (and its interval/event) so :meth:`resume` can restart it.
|
|
145
|
+
A no-op if there is no such timer.
|
|
146
|
+
"""
|
|
147
|
+
handle = None
|
|
148
|
+
with self._lock:
|
|
149
|
+
timer = self._timers.get((tid, kind))
|
|
150
|
+
if timer is None:
|
|
151
|
+
return
|
|
152
|
+
handle = timer.handle
|
|
153
|
+
timer.handle = None
|
|
154
|
+
timer.suspended = True
|
|
155
|
+
if handle is not None:
|
|
156
|
+
self._scheduler.cancel(handle)
|
|
157
|
+
|
|
158
|
+
def resume(self, tid, kind):
|
|
159
|
+
"""Resume a suspended ``kind`` timer for ``tid`` (old ``resume`` = restart).
|
|
160
|
+
|
|
161
|
+
Only re-arms a timer that was actually suspended; resuming a running or
|
|
162
|
+
unknown timer is a no-op (it must not double-schedule).
|
|
163
|
+
"""
|
|
164
|
+
with self._lock:
|
|
165
|
+
timer = self._timers.get((tid, kind))
|
|
166
|
+
if timer is None or not timer.suspended:
|
|
167
|
+
return
|
|
168
|
+
timer.suspended = False
|
|
169
|
+
self._arm(tid, kind, timer)
|
|
170
|
+
|
|
171
|
+
# -- cleanup ----------------------------------------------------------- #
|
|
172
|
+
|
|
173
|
+
def cancel_all(self, tid):
|
|
174
|
+
"""Cancel and forget every timer for one transaction (completion/reap)."""
|
|
175
|
+
handles = []
|
|
176
|
+
with self._lock:
|
|
177
|
+
for t, kind in list(self._timers.keys()):
|
|
178
|
+
if t != tid:
|
|
179
|
+
continue
|
|
180
|
+
timer = self._timers.pop((t, kind))
|
|
181
|
+
if timer.handle is not None:
|
|
182
|
+
handles.append(timer.handle)
|
|
183
|
+
for handle in handles:
|
|
184
|
+
self._scheduler.cancel(handle)
|
|
185
|
+
|
|
186
|
+
def stop(self):
|
|
187
|
+
"""Cancel every outstanding timer (entity shutdown). No thread leaks."""
|
|
188
|
+
handles = []
|
|
189
|
+
with self._lock:
|
|
190
|
+
for timer in self._timers.values():
|
|
191
|
+
if timer.handle is not None:
|
|
192
|
+
handles.append(timer.handle)
|
|
193
|
+
self._timers.clear()
|
|
194
|
+
for handle in handles:
|
|
195
|
+
self._scheduler.cancel(handle)
|
|
196
|
+
|
|
197
|
+
# -- internals --------------------------------------------------------- #
|
|
198
|
+
|
|
199
|
+
def _arm(self, tid, kind, timer):
|
|
200
|
+
"""Schedule ``timer`` so it injects its event on expiry. Caller holds lock."""
|
|
201
|
+
timer.handle = self._scheduler.schedule(
|
|
202
|
+
timer.interval, self._make_callback(tid, kind)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def _make_callback(self, tid, kind):
|
|
206
|
+
def _expired():
|
|
207
|
+
self._on_expiry(tid, kind)
|
|
208
|
+
|
|
209
|
+
return _expired
|
|
210
|
+
|
|
211
|
+
def _on_expiry(self, tid, kind):
|
|
212
|
+
"""Timer-thread callback: marshal the expiry onto the entity FIFO (D8).
|
|
213
|
+
|
|
214
|
+
Reads the event type under the lock (the timer may have been cancelled
|
|
215
|
+
or replaced in a race), then injects the expiry outside the lock via the
|
|
216
|
+
entity's thread-safe ``trigger_event``.
|
|
217
|
+
|
|
218
|
+
**No machine mutation here (design D6, issue #29).** The ACK/NAK
|
|
219
|
+
expiration-limit counters are protocol state on the machine. Bumping them
|
|
220
|
+
from this timer thread was a lost-update race with the event thread (an
|
|
221
|
+
expiry callback reaching into ``entity.machines[tid]`` and calling
|
|
222
|
+
``note_ack_timeout()``/``note_nak_timeout()`` while the event thread also
|
|
223
|
+
mutates the same machine). The bump now lives in
|
|
224
|
+
:meth:`cfdp.shell.dispatcher.Dispatcher.dispatch`, which runs
|
|
225
|
+
single-threaded on the event thread and bumps the counter immediately
|
|
226
|
+
before ``update_state`` reads it. E25/E26 are produced ONLY by this
|
|
227
|
+
service, so the dispatcher is the one safe place to do it. This callback
|
|
228
|
+
therefore only marshals the expiry event onto the FIFO and never touches
|
|
229
|
+
any machine state.
|
|
230
|
+
"""
|
|
231
|
+
with self._lock:
|
|
232
|
+
timer = self._timers.get((tid, kind))
|
|
233
|
+
if timer is None:
|
|
234
|
+
return
|
|
235
|
+
event_type = timer.event_type
|
|
236
|
+
transaction = _TimerTransaction(tid)
|
|
237
|
+
self._entity.trigger_event(Event(transaction, event_type))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class _TimerTransaction:
|
|
241
|
+
"""A minimal transaction identity carrier for an injected expiry event.
|
|
242
|
+
|
|
243
|
+
The dispatcher routes events by ``event.transaction.id``, so the injected
|
|
244
|
+
expiry only needs to carry the owning ``tid``. This avoids the timer holding
|
|
245
|
+
a back-reference to the (frozen) transaction or the machine — the service
|
|
246
|
+
stays decoupled from both (design D6).
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
__slots__ = ("id",)
|
|
250
|
+
|
|
251
|
+
def __init__(self, tid):
|
|
252
|
+
self.id = tid
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import weakref
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TransactionHandle:
|
|
5
|
+
"""Rich return value from CfdpEntity.put() providing per-transaction controls."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, entity, transaction_id):
|
|
8
|
+
self._entity_ref = weakref.ref(entity)
|
|
9
|
+
self._id = transaction_id
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def id(self):
|
|
13
|
+
return self._id
|
|
14
|
+
|
|
15
|
+
def _entity(self):
|
|
16
|
+
entity = self._entity_ref()
|
|
17
|
+
if entity is None or not entity._started:
|
|
18
|
+
raise RuntimeError("CfdpEntity has been stopped")
|
|
19
|
+
return entity
|
|
20
|
+
|
|
21
|
+
def cancel(self):
|
|
22
|
+
self._entity().cancel(self._id)
|
|
23
|
+
|
|
24
|
+
def suspend(self):
|
|
25
|
+
self._entity().suspend(self._id)
|
|
26
|
+
|
|
27
|
+
def resume(self):
|
|
28
|
+
self._entity().resume(self._id)
|
|
29
|
+
|
|
30
|
+
def report(self):
|
|
31
|
+
self._entity().report(self._id)
|
|
32
|
+
|
|
33
|
+
def is_complete(self):
|
|
34
|
+
entity = self._entity_ref()
|
|
35
|
+
if entity is None:
|
|
36
|
+
return True
|
|
37
|
+
return entity.is_complete(self._id)
|
|
38
|
+
|
|
39
|
+
def __iter__(self):
|
|
40
|
+
return iter(self._id)
|
|
41
|
+
|
|
42
|
+
def __eq__(self, other):
|
|
43
|
+
if isinstance(other, TransactionHandle):
|
|
44
|
+
return self._id == other._id
|
|
45
|
+
if isinstance(other, tuple):
|
|
46
|
+
return self._id == other
|
|
47
|
+
return NotImplemented
|
|
48
|
+
|
|
49
|
+
def __hash__(self):
|
|
50
|
+
return hash(self._id)
|
|
File without changes
|
cfdp/transport/base.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Transport(ABC):
|
|
5
|
+
@abstractmethod
|
|
6
|
+
def send(self, data: bytes) -> None: ...
|
|
7
|
+
@abstractmethod
|
|
8
|
+
def start(self, on_receive) -> None: ...
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def stop(self) -> None: ...
|
|
11
|
+
def is_ready(self) -> bool:
|
|
12
|
+
return True
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from spacepacket import SequenceFlags, SpacePacket
|
|
2
|
+
|
|
3
|
+
from .base import Transport
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SpacePacketTransport(Transport):
|
|
7
|
+
"""Space Packet Protocol Transport for CFDP PDUs.
|
|
8
|
+
|
|
9
|
+
Wraps another Transport that carries raw bytes (e.g. UdpTransport).
|
|
10
|
+
CfdpEntity calls start(on_receive); this in turn calls the inner
|
|
11
|
+
transport's start() so no separate bind/start step is needed for it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, apid, transport, packet_type):
|
|
15
|
+
super().__init__()
|
|
16
|
+
self.apid = apid
|
|
17
|
+
self.packet_type = packet_type
|
|
18
|
+
self._inner = transport
|
|
19
|
+
self._packet_sequence_count = 0
|
|
20
|
+
self._on_receive = None
|
|
21
|
+
|
|
22
|
+
def _get_next_packet_sequence_count(self):
|
|
23
|
+
self._packet_sequence_count += 1
|
|
24
|
+
return self._packet_sequence_count
|
|
25
|
+
|
|
26
|
+
def send(self, data: bytes) -> None:
|
|
27
|
+
space_packet = SpacePacket(
|
|
28
|
+
packet_type=self.packet_type,
|
|
29
|
+
packet_sec_hdr_flag=False,
|
|
30
|
+
apid=self.apid,
|
|
31
|
+
sequence_flags=SequenceFlags.UNSEGMENTED,
|
|
32
|
+
packet_sequence_count=self._get_next_packet_sequence_count(),
|
|
33
|
+
packet_data_field=data,
|
|
34
|
+
)
|
|
35
|
+
self._inner.send(space_packet.encode())
|
|
36
|
+
|
|
37
|
+
def start(self, on_receive) -> None:
|
|
38
|
+
self._on_receive = on_receive
|
|
39
|
+
self._inner.start(self._incoming_pdu_handler)
|
|
40
|
+
|
|
41
|
+
def stop(self) -> None:
|
|
42
|
+
self._inner.stop()
|
|
43
|
+
|
|
44
|
+
def _incoming_pdu_handler(self, raw: bytes) -> None:
|
|
45
|
+
packet = SpacePacket.decode(raw)
|
|
46
|
+
if self._on_receive:
|
|
47
|
+
self._on_receive(packet.packet_data_field)
|
cfdp/transport/udp.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
import threading
|
|
3
|
+
|
|
4
|
+
from .base import Transport
|
|
5
|
+
|
|
6
|
+
DEFAULT_MAXIMUM_PACKET_LENGTH = 4096
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UdpTransport(Transport):
|
|
10
|
+
"""UDP Transport for CFDP PDUs.
|
|
11
|
+
|
|
12
|
+
This transport is mostly used for testing and for prototypes.
|
|
13
|
+
The transport is realized by making each entity a host, which does `bind` to a port.
|
|
14
|
+
The other entity then sends directly PDUs to this other entity. Since UDP is connection-less
|
|
15
|
+
there is no need to establish a connection beforehand.
|
|
16
|
+
|
|
17
|
+
Call `bind(host, port)` to set the local address, then pass to CfdpEntity which calls
|
|
18
|
+
`start(on_receive)` to begin receiving. Call `stop()` to shut down.
|
|
19
|
+
|
|
20
|
+
Routing dictionary
|
|
21
|
+
------------------
|
|
22
|
+
The ``routing`` parameter maps destination keys to lists of ``(host, port)`` tuples.
|
|
23
|
+
|
|
24
|
+
The special key ``"*"`` causes every outgoing PDU to be sent to all listed addresses::
|
|
25
|
+
|
|
26
|
+
routing = {"*": [("127.0.0.1", 5222)]}
|
|
27
|
+
|
|
28
|
+
Tuple keys ``(host, port)`` forward incoming PDUs received *from* that address on to
|
|
29
|
+
one or more other destinations (relay / repeater mode)::
|
|
30
|
+
|
|
31
|
+
routing = {
|
|
32
|
+
("127.0.0.1", 5111): [("127.0.0.1", 5333)],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
A typical two-entity local setup (entity 1 sends to entity 2 on port 5222)::
|
|
36
|
+
|
|
37
|
+
transport = UdpTransport(routing={"*": [("127.0.0.1", 5222)]})
|
|
38
|
+
transport.bind("127.0.0.1", 5111)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, routing, maximum_packet_length=DEFAULT_MAXIMUM_PACKET_LENGTH):
|
|
42
|
+
super().__init__()
|
|
43
|
+
self.routing = routing
|
|
44
|
+
self.maximum_packet_length = maximum_packet_length
|
|
45
|
+
self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
46
|
+
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4194304)
|
|
47
|
+
self._thread = threading.Thread(target=self._incoming_pdu_handler, daemon=True)
|
|
48
|
+
self._thread.kill = False
|
|
49
|
+
self._on_receive = None
|
|
50
|
+
self._addr = None
|
|
51
|
+
self.send_count = 0
|
|
52
|
+
self.recv_count = 0
|
|
53
|
+
|
|
54
|
+
def bind(self, host, port):
|
|
55
|
+
self._addr = (host, port)
|
|
56
|
+
self._socket.bind((host, port))
|
|
57
|
+
|
|
58
|
+
def send(self, data: bytes) -> None:
|
|
59
|
+
self.send_count += 1
|
|
60
|
+
if self.routing and "*" in self.routing:
|
|
61
|
+
for address in self.routing["*"]:
|
|
62
|
+
host, port = address
|
|
63
|
+
self._socket.sendto(data, (host, int(port)))
|
|
64
|
+
|
|
65
|
+
def start(self, on_receive) -> None:
|
|
66
|
+
self._on_receive = on_receive
|
|
67
|
+
self._thread.kill = False
|
|
68
|
+
self._thread.start()
|
|
69
|
+
|
|
70
|
+
def stop(self) -> None:
|
|
71
|
+
self._thread.kill = True
|
|
72
|
+
if self._addr:
|
|
73
|
+
self._socket.sendto(b"0", self._addr) # break recvfrom loop
|
|
74
|
+
self._thread.join()
|
|
75
|
+
self._socket.close()
|
|
76
|
+
|
|
77
|
+
def _incoming_pdu_handler(self):
|
|
78
|
+
thread = threading.current_thread()
|
|
79
|
+
buffer_size = 10 * self.maximum_packet_length
|
|
80
|
+
|
|
81
|
+
while not thread.kill:
|
|
82
|
+
pdu, addr = self._socket.recvfrom(buffer_size)
|
|
83
|
+
self.recv_count += 1
|
|
84
|
+
if thread.kill:
|
|
85
|
+
break
|
|
86
|
+
if self.routing and addr in self.routing:
|
|
87
|
+
for dest in self.routing[addr]:
|
|
88
|
+
self._socket.sendto(pdu, dest)
|
|
89
|
+
if self._on_receive:
|
|
90
|
+
self._on_receive(pdu)
|
cfdp/transport/zmq.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
|
|
3
|
+
import zmq
|
|
4
|
+
|
|
5
|
+
from .base import Transport
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ZmqTransport(Transport):
|
|
9
|
+
"""ZMQ Transport for CFDP PDUs.
|
|
10
|
+
|
|
11
|
+
This transport is mostly used for testing and for prototypes.
|
|
12
|
+
A connection is established by making one entity the host, which does `bind` to a port,
|
|
13
|
+
and the other entity the client, which does `connect` to the host.
|
|
14
|
+
|
|
15
|
+
Call `bind(host, port)` or `connect(host, port)` to set up the socket, then pass to
|
|
16
|
+
CfdpEntity which calls `start(on_receive)` to begin receiving. Call `stop()` to shut down.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
super().__init__()
|
|
21
|
+
self.context = zmq.Context()
|
|
22
|
+
self.socket = self.context.socket(zmq.PAIR)
|
|
23
|
+
self._thread = threading.Thread(target=self._incoming_pdu_handler, daemon=True)
|
|
24
|
+
self._thread.kill = False
|
|
25
|
+
self._on_receive = None
|
|
26
|
+
|
|
27
|
+
def bind(self, host, port):
|
|
28
|
+
self.socket.bind(f"tcp://{host}:{port}")
|
|
29
|
+
|
|
30
|
+
def connect(self, host, port):
|
|
31
|
+
self.socket.connect(f"tcp://{host}:{port}")
|
|
32
|
+
|
|
33
|
+
def send(self, data: bytes) -> None:
|
|
34
|
+
self.socket.send(data)
|
|
35
|
+
|
|
36
|
+
def start(self, on_receive) -> None:
|
|
37
|
+
self._on_receive = on_receive
|
|
38
|
+
self._thread.kill = False
|
|
39
|
+
self._thread.start()
|
|
40
|
+
|
|
41
|
+
def stop(self) -> None:
|
|
42
|
+
self._thread.kill = True
|
|
43
|
+
self._thread.join()
|
|
44
|
+
self.context.destroy()
|
|
45
|
+
|
|
46
|
+
def _incoming_pdu_handler(self):
|
|
47
|
+
thread = threading.current_thread()
|
|
48
|
+
while not thread.kill:
|
|
49
|
+
if self.socket.poll(100):
|
|
50
|
+
if thread.kill:
|
|
51
|
+
break
|
|
52
|
+
pdu = self.socket.recv()
|
|
53
|
+
if self._on_receive:
|
|
54
|
+
self._on_receive(pdu)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pycfdp
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Python implementation of CCSDS File Delivery Protocol (Modernized Fork)
|
|
5
|
+
Author-email: Alex Pope <alexpopester@gmail.com>, "LibreCube (Original Codebase)" <info@librecube.org>
|
|
6
|
+
License: MIT License
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE.txt
|
|
10
|
+
Provides-Extra: zmq
|
|
11
|
+
Requires-Dist: pyzmq; extra == "zmq"
|
|
12
|
+
Provides-Extra: spacepacket
|
|
13
|
+
Requires-Dist: spacepacket; extra == "spacepacket"
|
|
14
|
+
Provides-Extra: all
|
|
15
|
+
Requires-Dist: pyzmq; extra == "all"
|
|
16
|
+
Requires-Dist: spacepacket; extra == "all"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Python CFDP, Version 0.2
|
|
20
|
+
|
|
21
|
+
The CCSDS File Delivery Protocol (CFDP) was developed by the
|
|
22
|
+
[Consultative Committee for Space Data Systems (CCSDS)](https://public.ccsds.org).
|
|
23
|
+
The CFDP protocol provides reliable transfer of files from one endpoint to
|
|
24
|
+
another and has been designed to work well over space links that suffer
|
|
25
|
+
from outtakes and long delays. The basic operation of CFDP is to transfer a
|
|
26
|
+
file from a sender to a receiver (both referred to as CFDP entities). The
|
|
27
|
+
sender and receiver must be configured and running at the same time to perform
|
|
28
|
+
a file transfer. It can be used to perform space to ground, ground to space,
|
|
29
|
+
space to space, and ground to ground file transfers. For example it can be used
|
|
30
|
+
to transfer science data from satellite over mission control to the science
|
|
31
|
+
center in an automated fashion with minimal to no human intervention.
|
|
32
|
+
|
|
33
|
+
This Python module is an implementation of the CCSDS File Delivery Protocol.
|
|
34
|
+
It supports all features as outlined in the latest version of the [CFDP Blue Book](docs/727x0b5.pdf). These are:
|
|
35
|
+
|
|
36
|
+
- Class 1 (unacknowledged) file transfer
|
|
37
|
+
- Class 2 (acknowledged) file transfer
|
|
38
|
+
- Filestore requests
|
|
39
|
+
- Proxy operations
|
|
40
|
+
- Directory listing request
|
|
41
|
+
- Native filestore implementation
|
|
42
|
+
- UDP as default transport layer (optional ZMQ)
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
Clone the repository and then install via pip:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
pip install cfdp
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
> Depending on the transport layer to use with CFDP, one needs to install additional dependencies.
|
|
53
|
+
|
|
54
|
+
## Documentation
|
|
55
|
+
|
|
56
|
+
Find the detailed documentation [here](docs/README.md).
|
|
57
|
+
|
|
58
|
+
## Tests
|
|
59
|
+
|
|
60
|
+
The protocol is tested for cross-support as outlined in CCSDS CFDP Yellow Book.
|
|
61
|
+
See [tests/README.md](tests/README.md) for details.
|
|
62
|
+
|
|
63
|
+
## Contribute
|
|
64
|
+
|
|
65
|
+
To learn more on how to successfully contribute please read the contributing
|
|
66
|
+
information in the [LibreCube documentation](https://librecube.gitlab.io/).
|
|
67
|
+
|
|
68
|
+
## Support
|
|
69
|
+
|
|
70
|
+
If you are having issues, please let us know. Reach us at
|
|
71
|
+
[Matrix](https://app.element.io/#/room/#librecube.org:matrix.org)
|
|
72
|
+
or via [Email](mailto:info@librecube.org).
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
The project is licensed under the MIT license. See the [LICENSE](./LICENSE.txt) file for details.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
cfdp/__init__.py,sha256=LL8SZEEFxM8VuTjiyVAwmLLv87F_kISxR5B_o5SHcWc,2544
|
|
2
|
+
cfdp/checksum.py,sha256=bdxNA5szavRewdMXV75UApySmkPHNPHieYcqqE8skfI,2822
|
|
3
|
+
cfdp/constants.py,sha256=eZi0zmPuPVbs_G-_DnP4lwCUXZXksjC3sfr-iC3WtMU,3728
|
|
4
|
+
cfdp/core.py,sha256=KiQzyXI0OOmfofkmsXAX1AkkhsIw5XV5Q_oNeNrgV6U,37885
|
|
5
|
+
cfdp/event.py,sha256=TnG1pS9Jn-MFTCJO4rw1Nzko1ES8bBYN--LoENsWw3g,2651
|
|
6
|
+
cfdp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
cfdp/remote_entity_config.py,sha256=Z6QRBZM2gGgXy1LKiMO8ULc2ElbmIwK68YkjD9psmkw,403
|
|
8
|
+
cfdp/transaction_handle.py,sha256=oP8EXwQgKQaKL00YNhfciPXtga2YD0ap_hhkylczflc,1256
|
|
9
|
+
cfdp/filestore/__init__.py,sha256=ikbR4utD7WuqxPp3FnXDyhceAxMtzTUwheKDCfvmP0c,83
|
|
10
|
+
cfdp/filestore/base.py,sha256=_lZiY1f-cwpkKEcCRCV7wVysAllSjEr4nXfCSfZlEf0,2418
|
|
11
|
+
cfdp/filestore/native.py,sha256=GSPAML-u9VT5Ijrak9G9nExSe627aFQ401OXkf-WuAo,5286
|
|
12
|
+
cfdp/meta/__init__.py,sha256=B4cG2t1B_9imk4qGSDghBCGIycC6v9xfCvYnIAS_F5c,73
|
|
13
|
+
cfdp/meta/datafield.py,sha256=D_gcefotAxyegNOtlASUM0DEPsa6p6g6Wqf5OH5F-zI,279
|
|
14
|
+
cfdp/meta/filestore.py,sha256=tS2Kz2QMhHg54f3BaNcIHTE778m56QJZiCuo1Pvk__A,5048
|
|
15
|
+
cfdp/meta/message.py,sha256=DKy6rSi8qkyELCjza50Y7BfCu0YiQSpEqujZD2lDLZw,22459
|
|
16
|
+
cfdp/pdu/__init__.py,sha256=ht0APp7MeMvOiBvANzuF6x4M91NtdOPSH2Ega3cGFs0,177
|
|
17
|
+
cfdp/pdu/ack.py,sha256=0OWzo4AwwbEVnu4p4A8kdwrN_9v7mcnZ3k-UwFRem78,3358
|
|
18
|
+
cfdp/pdu/eof.py,sha256=jNC6DxRu-IPDYzkksNniR9RFdZqv_-sLeYZ5Jyjqdmo,4710
|
|
19
|
+
cfdp/pdu/filedata.py,sha256=-Rk6MgwCypsE74fTZQoJA80dNuktLJHuny_Q9WmUEV0,3307
|
|
20
|
+
cfdp/pdu/finished.py,sha256=NuuLI24FuXao21AqgfEiQKrAAtXVfPn3anvKaCbfDEk,3603
|
|
21
|
+
cfdp/pdu/header.py,sha256=l-S2IaIrp8lNcz4bx9iIqmPwxXtJ5sCsm1C9gWeK5FM,5564
|
|
22
|
+
cfdp/pdu/keep_alive.py,sha256=Pc9_hbHe-6rzkhll6fgj-dUuUwysGrVK8nmqZrbp-Zc,3802
|
|
23
|
+
cfdp/pdu/metadata.py,sha256=6qekV8RnT-AxBeQN75HBBA5lvyiyU6nkT9TEbiQlW7c,7211
|
|
24
|
+
cfdp/pdu/nak.py,sha256=6noKpP-zBg0Nuwasnq6vTOKCAJoVZL3zhtEqLXTSr-E,8250
|
|
25
|
+
cfdp/pdu/prompt.py,sha256=d0Zn-2JSl-MA-vRj7mZmeIMOaKCCvVG5YSo1rz8dD3M,2779
|
|
26
|
+
cfdp/pure/__init__.py,sha256=uHjS3O71KTbh1pO4MtuW0sBPvC01ECVXWpOOtTROvSQ,1669
|
|
27
|
+
cfdp/pure/config.py,sha256=X4bWoyZFxJ-4k36MZMae0GygOie1DCI0gbbZyQyPsjU,5451
|
|
28
|
+
cfdp/pure/indications.py,sha256=CMqcm0Ewm9hvb6U4gjX5AZ6uKFQ8cSRMkul99zQ8VWE,4761
|
|
29
|
+
cfdp/pure/outputs.py,sha256=l42GkibAz7xfr3kC2rSZOrgk0dfaoY2X0gOaTUKrLTU,6151
|
|
30
|
+
cfdp/pure/query_port.py,sha256=W6E91sDgXbqvAwBcPrcPfhNRvkbkErD5phKeq56KvCc,1930
|
|
31
|
+
cfdp/pure/transaction.py,sha256=JJUQo6Anl_LG0-Ku6e1BY8MYgU4r-In52zZanoOCqqY,1792
|
|
32
|
+
cfdp/pure/machines/__init__.py,sha256=Kq3mhWjl-4vZMqIVYz6NoonWVZP69aizo6e7HVWzcQc,1067
|
|
33
|
+
cfdp/pure/machines/receiver1.py,sha256=JhGKlIb93Ea63IKhi-5Hy9PGh2CXF1LUV1hWKtcr_FA,17250
|
|
34
|
+
cfdp/pure/machines/receiver2.py,sha256=775L-ykqIFkyGF3a5O_48fAfD7eZNbs5eQwYddyYx5M,34892
|
|
35
|
+
cfdp/pure/machines/sender1.py,sha256=I99F3RO_xRhYzYYxUQkVzVxbySwhrMncWkimBOsYcF0,15972
|
|
36
|
+
cfdp/pure/machines/sender2.py,sha256=ewQggAzj9R0of4KvJEZAzWwzptdvtPQJWoH2xb6oYmA,30902
|
|
37
|
+
cfdp/shell/__init__.py,sha256=XF-aVp8CB5nK-wU4uMHqtn1yz86fwLQ8TZOXuQxGb3c,2129
|
|
38
|
+
cfdp/shell/checksum_service.py,sha256=qxQn5eqbdtz_Dxn_gsEJNRiTBunTvveDHWDQ5GmUZZE,6201
|
|
39
|
+
cfdp/shell/dispatcher.py,sha256=lvUck7rt33Th14xj05HksHX1QkvibOHdZ9oTE5MpqOs,8773
|
|
40
|
+
cfdp/shell/effect_executor.py,sha256=atDkNmTHrh__2Mt-dgn1IdJJtox2jmjDXHBjK5wPl5Y,12369
|
|
41
|
+
cfdp/shell/machine_registry.py,sha256=qcUzAB8yL4n1ss8C8MMm3vSuzA2wzNxfI9yN41cn_EI,6649
|
|
42
|
+
cfdp/shell/query_port.py,sha256=Yy18A7nM8p9XjMleo02yJwlc69hKTzd_j3lvwbhIB3M,2809
|
|
43
|
+
cfdp/shell/timer_service.py,sha256=We_xX1fw3DCa4zQAdClP_2DJHPQSpmijorYiJSlXxxE,10767
|
|
44
|
+
cfdp/transport/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
45
|
+
cfdp/transport/base.py,sha256=EknCGBCTSTan69Z6mStyfHt1Ss4465_bV8XcwbeNagc,294
|
|
46
|
+
cfdp/transport/spacepacket.py,sha256=yPBO5ffhRpowA9tqIGqIQN_Ukwb4VgWx7jxC-nZyWMs,1562
|
|
47
|
+
cfdp/transport/udp.py,sha256=-bAsS-uf800eyT_nVCalik8ODQ3_6C7-eHZeenGOEqA,3213
|
|
48
|
+
cfdp/transport/zmq.py,sha256=y17olLqBqgd8_6lofdrztUh3M1phRBm6WCaJp7pGiXY,1680
|
|
49
|
+
pycfdp-0.2.2.dist-info/licenses/LICENSE.txt,sha256=UIp30ue1HZit7tMmSK0SS3swJBqOcLLnLJn5LY5YdNE,1036
|
|
50
|
+
pycfdp-0.2.2.dist-info/METADATA,sha256=i_8EQ3xDEGfzrnvrOdpveToquHERG5moj6iIptGpPVU,2747
|
|
51
|
+
pycfdp-0.2.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
52
|
+
pycfdp-0.2.2.dist-info/top_level.txt,sha256=Z1jvG2hdlWsdnR6V1AAoRebRKU-TWj41bUrGXPciBuk,5
|
|
53
|
+
pycfdp-0.2.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cfdp
|