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.
Files changed (53) hide show
  1. cfdp/__init__.py +106 -0
  2. cfdp/checksum.py +94 -0
  3. cfdp/constants.py +153 -0
  4. cfdp/core.py +895 -0
  5. cfdp/event.py +55 -0
  6. cfdp/filestore/__init__.py +2 -0
  7. cfdp/filestore/base.py +99 -0
  8. cfdp/filestore/native.py +162 -0
  9. cfdp/meta/__init__.py +3 -0
  10. cfdp/meta/datafield.py +11 -0
  11. cfdp/meta/filestore.py +127 -0
  12. cfdp/meta/message.py +659 -0
  13. cfdp/pdu/__init__.py +8 -0
  14. cfdp/pdu/ack.py +90 -0
  15. cfdp/pdu/eof.py +123 -0
  16. cfdp/pdu/filedata.py +90 -0
  17. cfdp/pdu/finished.py +99 -0
  18. cfdp/pdu/header.py +159 -0
  19. cfdp/pdu/keep_alive.py +108 -0
  20. cfdp/pdu/metadata.py +211 -0
  21. cfdp/pdu/nak.py +220 -0
  22. cfdp/pdu/prompt.py +75 -0
  23. cfdp/pure/__init__.py +68 -0
  24. cfdp/pure/config.py +141 -0
  25. cfdp/pure/indications.py +130 -0
  26. cfdp/pure/machines/__init__.py +25 -0
  27. cfdp/pure/machines/receiver1.py +433 -0
  28. cfdp/pure/machines/receiver2.py +893 -0
  29. cfdp/pure/machines/sender1.py +400 -0
  30. cfdp/pure/machines/sender2.py +774 -0
  31. cfdp/pure/outputs.py +203 -0
  32. cfdp/pure/query_port.py +48 -0
  33. cfdp/pure/transaction.py +43 -0
  34. cfdp/py.typed +0 -0
  35. cfdp/remote_entity_config.py +12 -0
  36. cfdp/shell/__init__.py +44 -0
  37. cfdp/shell/checksum_service.py +136 -0
  38. cfdp/shell/dispatcher.py +185 -0
  39. cfdp/shell/effect_executor.py +285 -0
  40. cfdp/shell/machine_registry.py +165 -0
  41. cfdp/shell/query_port.py +70 -0
  42. cfdp/shell/timer_service.py +252 -0
  43. cfdp/transaction_handle.py +50 -0
  44. cfdp/transport/__init__.py +0 -0
  45. cfdp/transport/base.py +12 -0
  46. cfdp/transport/spacepacket.py +47 -0
  47. cfdp/transport/udp.py +90 -0
  48. cfdp/transport/zmq.py +54 -0
  49. pycfdp-0.2.2.dist-info/METADATA +76 -0
  50. pycfdp-0.2.2.dist-info/RECORD +53 -0
  51. pycfdp-0.2.2.dist-info/WHEEL +5 -0
  52. pycfdp-0.2.2.dist-info/licenses/LICENSE.txt +19 -0
  53. pycfdp-0.2.2.dist-info/top_level.txt +1 -0
cfdp/core.py ADDED
@@ -0,0 +1,895 @@
1
+ from __future__ import annotations
2
+
3
+ import queue
4
+ import threading
5
+ import time
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from . import logger
9
+ from .constants import (
10
+ DEFAULT_ACK_TIMER_EXPIRATION_LIMIT,
11
+ DEFAULT_ACK_TIMER_LIMIT,
12
+ DEFAULT_FAULT_HANDLERS,
13
+ DEFAULT_INACTIVITY_TIMEOUT,
14
+ DEFAULT_MAX_FILE_SEGMENT_LEN,
15
+ DEFAULT_NAK_TIMER_EXPIRATION_LIMIT,
16
+ DEFAULT_NAK_TIMER_INTERVAL,
17
+ ChecksumType,
18
+ ConditionCode,
19
+ Direction,
20
+ DirectiveCode,
21
+ DirectiveSubTypeCode,
22
+ MachineState,
23
+ PduTypeCode,
24
+ TransactionStatus,
25
+ TransmissionMode,
26
+ )
27
+ from .event import Event, EventType
28
+ from .meta import process_user_message as meta_process_user_message
29
+ from .pdu import (
30
+ AckPdu,
31
+ EofPdu,
32
+ FiledataPdu,
33
+ FinishedPdu,
34
+ KeepAlivePdu,
35
+ MetadataPdu,
36
+ NakPdu,
37
+ PduHeader,
38
+ PromptPdu,
39
+ )
40
+ from .pure.transaction import TransactionId
41
+ from .shell import Dispatcher, EffectExecutor, FileTable, MachineRegistry
42
+ from .shell.checksum_service import ChecksumService
43
+ from .shell.timer_service import TimerService
44
+ from .transaction_handle import TransactionHandle
45
+
46
+ if TYPE_CHECKING:
47
+ from .filestore.base import VirtualFileStore
48
+ from .transport.base import Transport
49
+
50
+
51
+ class CfdpEntity:
52
+ """The public CFDP entity — the one object callers construct and drive.
53
+
54
+ A thin facade over the functional-core / imperative-shell stack (design
55
+ ``docs/design/machine-decoupling.md``): it holds the shell collaborators
56
+ (``Dispatcher``, ``MachineRegistry``, ``EffectExecutor``, ``TimerService``,
57
+ ``ChecksumService``, ``FileTable``) and exposes the user-facing verbs
58
+ (``put``, ``cancel``, ``suspend``, ``resume``, ``report``, ``freeze``,
59
+ ``thaw``, ``start``/``stop``). One entity serves both roles (sender and
60
+ receiver) for many concurrent transactions.
61
+
62
+ ``self.machines`` is the live ``tid -> machine`` map, keyed by transaction
63
+ id — a ``(source_entity_id, seq_number)`` tuple; the ``MachineRegistry``
64
+ shares this same dict.
65
+
66
+ Constructor knobs:
67
+
68
+ * ``entity_id`` — this entity's CFDP id (protocol-opaque).
69
+ * ``filestore`` — the :class:`~cfdp.filestore.base.VirtualFileStore` backing
70
+ file reads/writes.
71
+ * ``transport`` — the :class:`~cfdp.transport.base.Transport` used to send
72
+ and receive PDUs.
73
+ * ``*_indication_required`` — per-indication opt-in flags controlling which
74
+ optional indications fire.
75
+ * ``default_fault_handlers`` — condition-code -> action map applied when a
76
+ remote/put config does not override a handler.
77
+ * ``transaction_inactivity_limit`` — seconds of silence before the
78
+ inactivity timer faults a transaction.
79
+ * ``positive_ack_timer_interval`` / ``positive_ack_timer_expiration_limit``
80
+ — ACK-timer period (seconds) and its expiration count before faulting.
81
+ * ``nak_timer_interval`` / ``nak_timer_expiration_limit`` — NAK-timer period
82
+ (seconds) and its expiration count before faulting.
83
+ * ``maximum_file_segment_length`` — max Filedata payload bytes per PDU.
84
+ * ``inter_pdu_delay`` — seconds to pace between outbound file-data segments
85
+ (0.0 = as fast as the transport accepts).
86
+ * ``completed_transaction_ttl`` — seconds a completed machine is retained
87
+ before reaping.
88
+ * ``remote_entity_configs`` — optional ``{entity_id: RemoteEntityConfig}``
89
+ per-peer overrides.
90
+
91
+ Every knob has a spec-derived default; only ``entity_id``, ``filestore``,
92
+ and ``transport`` are required.
93
+ """
94
+
95
+ # Minimum yield inserted before re-enqueuing the file-data pump when the
96
+ # transport is not writable, so an unready link idles rather than spinning
97
+ # (design R4 / issue #20). Read in trigger_event.
98
+ _BACKPRESSURE_YIELD = 0.01
99
+
100
+ def __init__(
101
+ self,
102
+ entity_id: Any,
103
+ filestore: VirtualFileStore,
104
+ transport: Transport,
105
+ eof_sent_indication_required: bool = False,
106
+ eof_received_indication_required: bool = False,
107
+ filesegment_received_indication_required: bool = False,
108
+ transaction_finished_indication_required: bool = False,
109
+ suspended_indication_required: bool = False,
110
+ resumed_indication_required: bool = False,
111
+ default_fault_handlers: dict = DEFAULT_FAULT_HANDLERS,
112
+ transaction_inactivity_limit: float = DEFAULT_INACTIVITY_TIMEOUT,
113
+ positive_ack_timer_interval: float = DEFAULT_ACK_TIMER_LIMIT,
114
+ positive_ack_timer_expiration_limit: int = DEFAULT_ACK_TIMER_EXPIRATION_LIMIT,
115
+ nak_timer_interval: float = DEFAULT_NAK_TIMER_INTERVAL,
116
+ nak_timer_expiration_limit: int = DEFAULT_NAK_TIMER_EXPIRATION_LIMIT,
117
+ maximum_file_segment_length: int = DEFAULT_MAX_FILE_SEGMENT_LEN,
118
+ inter_pdu_delay: float = 0.0,
119
+ completed_transaction_ttl: float = 60.0,
120
+ remote_entity_configs: dict | None = None,
121
+ write_to_destination_directly: bool = False,
122
+ ) -> None:
123
+ self.entity_id = entity_id
124
+ self.filestore = filestore
125
+ self.transport = transport
126
+ self.eof_sent_indication_required = eof_sent_indication_required
127
+ self.eof_received_indication_required = eof_received_indication_required
128
+ self.filesegment_received_indication_required = (
129
+ filesegment_received_indication_required
130
+ )
131
+ self.transaction_finished_indication_required = (
132
+ transaction_finished_indication_required
133
+ )
134
+ self.suspended_indication_required = suspended_indication_required
135
+ self.resumed_indication_required = resumed_indication_required
136
+ self.default_fault_handlers = default_fault_handlers
137
+ self.transaction_inactivity_limit = transaction_inactivity_limit
138
+ self.positive_ack_timer_interval = positive_ack_timer_interval
139
+ self.positive_ack_timer_expiration_limit = positive_ack_timer_expiration_limit
140
+ self.nak_timer_interval = nak_timer_interval
141
+ self.nak_timer_expiration_limit = nak_timer_expiration_limit
142
+ self.maximum_file_segment_length = maximum_file_segment_length
143
+ self.inter_pdu_delay = inter_pdu_delay
144
+ self.completed_transaction_ttl = completed_transaction_ttl
145
+ # Receive-side option: write incoming file data straight into the
146
+ # destination file instead of buffering to a temp file and copying it at
147
+ # Finished time. Lets a remote-service filestore show download progress.
148
+ # Interpreted entirely in the shell (EffectExecutor); the pure machines
149
+ # are unaware of it. Default False preserves the temp-file behaviour.
150
+ self.write_to_destination_directly = write_to_destination_directly
151
+ self._remote_entity_configs = remote_entity_configs or {}
152
+ self._transaction_seq_counter = 0
153
+ self._seq_lock = threading.Lock()
154
+ self.machines: dict[TransactionId, Any] = {}
155
+ self._completion_times: dict[TransactionId, float] = {}
156
+
157
+ self._event_queue: queue.Queue = queue.Queue()
158
+ # Typed Any, not Thread: start() attaches a dynamic ``.kill`` flag to the
159
+ # thread object that the handler loops poll, so these are not plain
160
+ # threading.Thread instances. Retyping that shutdown mechanism is out of
161
+ # scope for the annotation pass.
162
+ self._event_thread: Any = None
163
+
164
+ self._messages_to_user_queue: queue.Queue = queue.Queue()
165
+ self._message_thread: Any = None
166
+
167
+ self._started = False
168
+ self._transaction_inter_pdu_delays: dict[TransactionId, float] = {}
169
+ self._completion_events: dict[TransactionId, threading.Event] = {}
170
+
171
+ # Live file-data pacing timers (issue #31). The E1 re-enqueue paces via a
172
+ # ``threading.Timer``; untracked, one could fire after ``stop()`` and push
173
+ # onto a drained queue / leave an unjoined thread (tripping the suite's
174
+ # active-thread-count assertions). We track them so ``stop()`` can cancel
175
+ # and join every pending one. Guarded by its own lock: timers are armed on
176
+ # the event thread but fire (and self-remove) on their own timer threads.
177
+ self._pacing_timers: set[threading.Timer] = set()
178
+ self._pacing_lock = threading.Lock()
179
+
180
+ # Functional-core / imperative-shell stack (design
181
+ # docs/design/machine-decoupling.md; issues #16 / #20 / #22). Both
182
+ # Class-1 (UNACKNOWLEDGED) and Class-2 (ACKNOWLEDGED) transfers route
183
+ # through the pure machines + shell collaborators. The registry shares
184
+ # this entity's ``machines`` dict so every lookup path sees the machines
185
+ # uniformly.
186
+ self._file_table = FileTable()
187
+ # The wall-clock timer table (design D6 / issue #17). Owns real timers
188
+ # per (tid, kind); an expiry re-enters via trigger_event onto the shared
189
+ # FIFO (design D8). The executor drives it from StartTimer/CancelTimer/
190
+ # SuspendTimer/ResumeTimer effects.
191
+ self._timer_service = TimerService(self)
192
+ # The async-checksum worker (design R1 / issue #21). A RequestChecksum
193
+ # effect computes the whole-file checksum off the event thread and
194
+ # injects E42_CHECKSUM_READY onto the shared FIFO, so a large-file
195
+ # checksum no longer stalls every other transaction on the dispatcher.
196
+ self._checksum_service = ChecksumService(self, self._file_table)
197
+ self._effect_executor = EffectExecutor(
198
+ self, self._file_table, self._timer_service, self._checksum_service
199
+ )
200
+ self._machine_registry = MachineRegistry(
201
+ self, self._file_table, machines=self.machines
202
+ )
203
+ self._dispatcher = Dispatcher(
204
+ self, self._machine_registry, self._effect_executor
205
+ )
206
+
207
+ # Callback attributes — set to a callable to receive indications
208
+ self.on_transaction_finished = None
209
+ self.on_metadata_received = None
210
+ self.on_eof_sent = None
211
+ self.on_eof_received = None
212
+ self.on_filesegment_received = None
213
+ self.on_suspended = None
214
+ self.on_resumed = None
215
+ self.on_report = None
216
+ self.on_fault = None
217
+ self.on_abandoned = None
218
+ self.on_unrecognized_message = None
219
+
220
+ def start(self) -> None:
221
+ """Begin the event thread, message thread, and transport receive loop."""
222
+ if self._started:
223
+ return
224
+ self._started = True
225
+ self._event_thread = threading.Thread(target=self._event_handler, daemon=True)
226
+ self._event_thread.kill = False
227
+ self._message_thread = threading.Thread(
228
+ target=self._message_handler, daemon=True
229
+ )
230
+ self._message_thread.kill = False
231
+ self._event_thread.start()
232
+ self._message_thread.start()
233
+ self.transport.start(self.pdu_received)
234
+
235
+ def stop(self) -> None:
236
+ """Set the kill flag, send sentinels, and join both threads."""
237
+ if not self._started:
238
+ return
239
+ self._started = False
240
+ self._event_thread.kill = True
241
+ self._message_thread.kill = True
242
+ self._event_queue.put(("stop",))
243
+ self._messages_to_user_queue.put(0)
244
+ self._event_thread.join()
245
+ self._message_thread.join()
246
+ # Cancel and join every pending file-data pacing timer (issue #31) so
247
+ # none fires after shutdown to push onto the drained queue or leave an
248
+ # unjoined thread. Done after the event thread is joined, so no new
249
+ # pacing timer can be armed here.
250
+ with self._pacing_lock:
251
+ pending = list(self._pacing_timers)
252
+ self._pacing_timers.clear()
253
+ for timer in pending:
254
+ timer.cancel()
255
+ timer.join(timeout=1)
256
+ # Cancel every outstanding wall-clock timer so no timer thread leaks
257
+ # past shutdown (design D6 / issue #17). Done after the event thread is
258
+ # joined so no start/cancel can race an in-flight timer here.
259
+ self._timer_service.stop()
260
+ # Join any in-flight checksum worker so none leaks past shutdown
261
+ # (design R1 / issue #21). After the event thread is joined no new
262
+ # RequestChecksum can be issued, so this drains the last workers.
263
+ self._checksum_service.stop()
264
+
265
+ def _increment_transaction_seq_counter(self):
266
+ with self._seq_lock:
267
+ self._transaction_seq_counter += 1
268
+ return self._transaction_seq_counter
269
+
270
+ def _invoke_callback(self, cb, *args):
271
+ if cb is None:
272
+ return
273
+ try:
274
+ cb(*args)
275
+ except Exception:
276
+ logger.exception("Unhandled exception in callback")
277
+
278
+ def trigger_event(self, event, pdu=None):
279
+ if event.type == EventType.E1_SEND_FILE_DATA:
280
+ delay = self._transaction_inter_pdu_delays.get(
281
+ event.transaction.id, self.inter_pdu_delay
282
+ )
283
+ # Backpressure-aware rescheduling (design R4 / issue #20). The pure
284
+ # file-data pump re-emits E1_SEND_FILE_DATA every step even when the
285
+ # transport could not accept a segment. Left
286
+ # unchecked, an unwritable link would spin the event thread at 100%
287
+ # CPU re-servicing E1s that send nothing. Here — the one place that
288
+ # owns the E1 re-enqueue timing — we detect an unready transport and
289
+ # apply a minimum yield so the pump idles instead of busy-spinning.
290
+ # FIFO re-enqueue already gives inter-transaction fairness (design
291
+ # D8); this only fixes the CPU spin.
292
+ if delay <= 0 and not self._transport_ready():
293
+ delay = self._BACKPRESSURE_YIELD
294
+ if delay > 0:
295
+ self._schedule_paced_enqueue(delay, [event, pdu])
296
+ return
297
+ self._event_queue.put([event, pdu])
298
+
299
+ def _schedule_paced_enqueue(self, delay, item):
300
+ """Enqueue ``item`` after ``delay`` on a tracked timer (issue #31).
301
+
302
+ The timer is registered before it starts and self-removes when it fires,
303
+ so a pending pacing timer is visible to ``stop()`` (which cancels and
304
+ joins every one) and a timer that fires normally does not accumulate.
305
+ """
306
+ holder = {}
307
+
308
+ def _fire():
309
+ with self._pacing_lock:
310
+ self._pacing_timers.discard(holder["timer"])
311
+ self._event_queue.put(item)
312
+
313
+ timer = threading.Timer(delay, _fire)
314
+ holder["timer"] = timer
315
+ with self._pacing_lock:
316
+ self._pacing_timers.add(timer)
317
+ timer.start()
318
+
319
+ def _transport_ready(self):
320
+ """Best-effort local transport writability check (never raises)."""
321
+ is_ready = getattr(self.transport, "is_ready", None)
322
+ if is_ready is None:
323
+ return True
324
+ try:
325
+ return is_ready()
326
+ except Exception:
327
+ return True
328
+
329
+ def _get_remote_config(self, destination_entity_id):
330
+ return self._remote_entity_configs.get(destination_entity_id)
331
+
332
+ def _resolve_id(self, transaction_id):
333
+ if isinstance(transaction_id, TransactionHandle):
334
+ return transaction_id.id
335
+ return transaction_id
336
+
337
+ def is_complete(self, transaction_id) -> bool:
338
+ tid = self._resolve_id(transaction_id)
339
+ evt = self._completion_events.get(tid)
340
+ if evt is None:
341
+ # No record of this transaction — treat as complete
342
+ return True
343
+ return evt.is_set()
344
+
345
+ def _reap_expired_machines(self):
346
+ now = time.monotonic()
347
+ expired = [
348
+ tid
349
+ for tid, ts in list(self._completion_times.items())
350
+ if now - ts >= self.completed_transaction_ttl
351
+ ]
352
+ for tid in expired:
353
+ self.machines.pop(tid, None)
354
+ self._completion_times.pop(tid, None)
355
+ self._transaction_inter_pdu_delays.pop(tid, None)
356
+ self._completion_events.pop(tid, None)
357
+ # Close and forget any file handle the shell held for a reaped
358
+ # transaction (design D10). No-op for a transaction that never opened
359
+ # a file.
360
+ self._file_table.cleanup(tid)
361
+ # Drop any outstanding wall-clock timers for the reaped transaction
362
+ # so no timer thread outlives it (design D6 / issue #17).
363
+ self._timer_service.cancel_all(tid)
364
+
365
+ def _event_handler(self):
366
+ thread = threading.current_thread()
367
+ while not thread.kill:
368
+ item = None
369
+ try:
370
+ item = self._event_queue.get(timeout=1)
371
+ except queue.Empty:
372
+ self._reap_expired_machines()
373
+ continue
374
+ if thread.kill:
375
+ break
376
+ try:
377
+ if isinstance(item, tuple):
378
+ self._handle_control_message(item)
379
+ else:
380
+ # Normal [event, pdu] from trigger_event() or timers
381
+ event, pdu = item
382
+ self._dispatch_event(event, pdu)
383
+ except Exception:
384
+ logger.exception("Unhandled exception in event handler")
385
+ self._reap_expired_machines()
386
+
387
+ def _dispatch_event(self, event, pdu):
388
+ machine = self.machines.get(event.transaction.id)
389
+ if not machine:
390
+ return
391
+ # The pure machine has a single input door and reads any inbound PDU off
392
+ # ``event.pdu``. The Dispatcher walks its returned output list (execute
393
+ # effects inline, re-enqueue internal events) and reports completion via
394
+ # _on_pure_machine_complete.
395
+ event.pdu = pdu
396
+ self._dispatcher.dispatch(machine, event)
397
+
398
+ def _on_pure_machine_complete(self, tid):
399
+ """Mark a pure-machine transaction complete (called by the Dispatcher)."""
400
+ if tid not in self._completion_times:
401
+ self._completion_times[tid] = time.monotonic()
402
+ # Cancel any armed timers immediately on completion rather than waiting
403
+ # for the TTL reap: an ACK/NAK/inactivity timer that fires into an
404
+ # already-completed machine would inject a stale event. ``cancel_all``
405
+ # on an already-empty tid is a safe no-op, so this never double-cancels
406
+ # harmfully with the reap path (which also calls ``cancel_all``).
407
+ self._timer_service.cancel_all(tid)
408
+ ce = self._completion_events.get(tid)
409
+ if ce is not None:
410
+ ce.set()
411
+
412
+ def _handle_control_message(self, item):
413
+ tag = item[0]
414
+
415
+ if tag == "stop":
416
+ return
417
+
418
+ elif tag == "pdu_raw":
419
+ _, data = item
420
+ try:
421
+ pdu = self._get_pdu_object(data)
422
+ except Exception:
423
+ logger.exception("Failed to decode incoming PDU")
424
+ return
425
+ mode = pdu.pdu_header.transmission_mode
426
+ direction = pdu.pdu_header.direction
427
+ transaction_id = pdu.pdu_header.transaction_id
428
+ existing = self.machines.get(transaction_id)
429
+ if existing and existing.transmission_mode == mode:
430
+ if existing.state is not MachineState.COMPLETED:
431
+ self._send_pdu_to_state_machine(existing, pdu)
432
+ else:
433
+ del self.machines[transaction_id]
434
+ self._completion_times.pop(transaction_id, None)
435
+ ce = self._completion_events.pop(transaction_id, None)
436
+ if ce is not None:
437
+ ce.set()
438
+ self._run_action_for_incoming_pdu(
439
+ mode, direction, transaction_id, pdu
440
+ )
441
+ else:
442
+ self._run_action_for_incoming_pdu(mode, direction, transaction_id, pdu)
443
+
444
+ # If handling this PDU left no live machine owning the transaction,
445
+ # nothing will ever drive it to completion. ``pdu_received`` pre-
446
+ # registers a completion event for every inbound PDU (so is_complete
447
+ # is False while it is queued), but a *stateless* response — e.g.
448
+ # Ack-ing a Finished-cancel that a peer legitimately retransmits after
449
+ # we already completed and reaped (issue #23) — would otherwise leave
450
+ # that freshly-created event unset forever, flipping is_complete back
451
+ # to False. Mark it complete: no owner means done.
452
+ if transaction_id not in self.machines:
453
+ ce = self._completion_events.get(transaction_id)
454
+ if ce is not None:
455
+ ce.set()
456
+
457
+ elif tag == "cancel_tid":
458
+ _, tid = item
459
+ machine = self.machines.get(tid)
460
+ if machine:
461
+ self._dispatch_event(
462
+ Event(machine.transaction, EventType.E33_RECEIVED_CANCEL_REQUEST),
463
+ None,
464
+ )
465
+
466
+ elif tag == "suspend_tid":
467
+ _, tid = item
468
+ machine = self.machines.get(tid)
469
+ if machine:
470
+ self._dispatch_event(
471
+ Event(machine.transaction, EventType.E31_RECEIVED_SUSPEND_REQUEST),
472
+ None,
473
+ )
474
+
475
+ elif tag == "resume_tid":
476
+ _, tid = item
477
+ machine = self.machines.get(tid)
478
+ if machine:
479
+ self._dispatch_event(
480
+ Event(machine.transaction, EventType.E32_RECEIVED_RESUME_REQUEST),
481
+ None,
482
+ )
483
+
484
+ elif tag == "report_tid":
485
+ _, tid = item
486
+ machine = self.machines.get(tid)
487
+ if machine:
488
+ self._dispatch_event(
489
+ Event(machine.transaction, EventType.E34_RECEIVED_REPORT_REQUEST),
490
+ None,
491
+ )
492
+
493
+ elif tag == "freeze_entity":
494
+ _, receiving_entity_id = item
495
+ for transaction_id, machine in list(self.machines.items()):
496
+ if transaction_id[0] == self.entity_id:
497
+ if machine.transaction.destination_entity_id == receiving_entity_id:
498
+ self._dispatch_event(
499
+ Event(machine.transaction, EventType.E40_RECEIVED_FREEZE),
500
+ None,
501
+ )
502
+
503
+ elif tag == "thaw_entity":
504
+ _, receiving_entity_id = item
505
+ for transaction_id, machine in list(self.machines.items()):
506
+ if transaction_id[0] == self.entity_id:
507
+ if machine.transaction.destination_entity_id == receiving_entity_id:
508
+ self._dispatch_event(
509
+ Event(machine.transaction, EventType.E41_RECEIVED_THAW),
510
+ None,
511
+ )
512
+
513
+ def _message_handler(self):
514
+ thread = threading.current_thread()
515
+ while not thread.kill:
516
+ try:
517
+ message = self._messages_to_user_queue.get(timeout=1)
518
+ if thread.kill:
519
+ break
520
+ logger.info("Processing user message %s" % str(message))
521
+ self.process_user_message(message)
522
+ except queue.Empty:
523
+ pass
524
+ except Exception:
525
+ logger.exception("Unhandled exception in message handler")
526
+
527
+ """ CFDP service requests """
528
+
529
+ def put(
530
+ self,
531
+ destination_id,
532
+ source_filename=None,
533
+ destination_filename=None,
534
+ segmentation_control=None, # not implemented
535
+ fault_handler_overrides=None,
536
+ flow_label=None, # not implemented
537
+ transmission_mode=TransmissionMode.ACKNOWLEDGED,
538
+ closure_requested=False, # not implemented
539
+ messages_to_user=None,
540
+ filestore_requests=None,
541
+ checksum_type=ChecksumType.MODULAR,
542
+ maximum_file_segment_length: int | None = None,
543
+ ) -> TransactionHandle:
544
+ """Initiate a file-delivery transaction.
545
+
546
+ Parameters
547
+ ----------
548
+ destination_id : int
549
+ Entity ID of the receiving entity.
550
+ source_filename : str or None
551
+ VFS path of the file to send. ``None`` sends a metadata-only PDU.
552
+ destination_filename : str or None
553
+ VFS path at the receiver where the file will be written.
554
+ transmission_mode : TransmissionMode
555
+ ``ACKNOWLEDGED`` (Class 2, default) or ``UNACKNOWLEDGED`` (Class 1).
556
+ checksum_type : ChecksumType
557
+ Checksum algorithm to use (default: MODULAR).
558
+ messages_to_user : list or None
559
+ Optional TLV messages to include in the Metadata PDU.
560
+ filestore_requests : list or None
561
+ Optional filestore-request TLVs.
562
+ maximum_file_segment_length : int or None
563
+ Override the entity-level segment length for this transaction.
564
+ """
565
+ if closure_requested:
566
+ raise NotImplementedError("closure_requested is not yet implemented")
567
+ if flow_label:
568
+ raise NotImplementedError("flow_label is not yet implemented")
569
+ if segmentation_control:
570
+ raise NotImplementedError("segmentation_control is not yet implemented")
571
+
572
+ seq_number = self._increment_transaction_seq_counter()
573
+
574
+ remote_cfg = self._get_remote_config(destination_id)
575
+ effective_delay = (
576
+ remote_cfg.inter_pdu_delay
577
+ if remote_cfg is not None and remote_cfg.inter_pdu_delay is not None
578
+ else self.inter_pdu_delay
579
+ )
580
+
581
+ # Build a pure sender via the registry (which folds config into a frozen
582
+ # snapshot and injects the query port). Both Class-1 (UNACKNOWLEDGED) and
583
+ # Class-2 (ACKNOWLEDGED) route here (issues #16 / #20).
584
+ resolved_segment_length = (
585
+ maximum_file_segment_length
586
+ if maximum_file_segment_length
587
+ else self.maximum_file_segment_length
588
+ )
589
+ machine, transaction = self._machine_registry.create_sender(
590
+ seq_number=seq_number,
591
+ destination_id=destination_id,
592
+ source_filename=source_filename,
593
+ destination_filename=destination_filename,
594
+ checksum_type=checksum_type,
595
+ transmission_mode=transmission_mode,
596
+ maximum_file_segment_length=resolved_segment_length,
597
+ messages_to_user=messages_to_user,
598
+ filestore_requests=filestore_requests,
599
+ fault_handler_overrides=fault_handler_overrides,
600
+ remote_config=remote_cfg,
601
+ )
602
+ self._completion_events[transaction.id] = threading.Event()
603
+ self._transaction_inter_pdu_delays[transaction.id] = effective_delay
604
+ self.trigger_event(Event(transaction, EventType.E0_ENTERED_STATE))
605
+ self.trigger_event(Event(transaction, EventType.E30_RECEIVED_PUT_REQUEST))
606
+ return TransactionHandle(self, transaction.id)
607
+
608
+ def cancel(self, transaction_id: Any) -> None:
609
+ self._event_queue.put(("cancel_tid", self._resolve_id(transaction_id)))
610
+
611
+ def suspend(self, transaction_id: Any) -> None:
612
+ self._event_queue.put(("suspend_tid", self._resolve_id(transaction_id)))
613
+
614
+ def resume(self, transaction_id: Any) -> None:
615
+ self._event_queue.put(("resume_tid", self._resolve_id(transaction_id)))
616
+
617
+ def report(self, transaction_id: Any) -> None:
618
+ self._event_queue.put(("report_tid", self._resolve_id(transaction_id)))
619
+
620
+ """ CFDP indication dispatch — called from machine base class """
621
+
622
+ def _as_handle(self, transaction_id):
623
+ if isinstance(transaction_id, TransactionHandle):
624
+ return transaction_id
625
+ return TransactionHandle(self, transaction_id)
626
+
627
+ def _indication_transaction(self, transaction_id):
628
+ logger.info("[%s] Transaction indication" % str(transaction_id))
629
+
630
+ def _indication_eof_sent(self, transaction_id):
631
+ logger.info("[%s] EOF sent indication" % str(transaction_id))
632
+ self._invoke_callback(self.on_eof_sent, self._as_handle(transaction_id))
633
+
634
+ def _indication_transaction_finished(
635
+ self,
636
+ transaction_id,
637
+ condition_code,
638
+ file_status,
639
+ delivery_code,
640
+ filestore_responses=None,
641
+ status_report=None,
642
+ ):
643
+ logger.info("[%s] Transaction finished indication" % str(transaction_id))
644
+ self._invoke_callback(
645
+ self.on_transaction_finished,
646
+ self._as_handle(transaction_id),
647
+ condition_code,
648
+ file_status,
649
+ delivery_code,
650
+ filestore_responses,
651
+ status_report,
652
+ )
653
+
654
+ def _indication_metadata_received(
655
+ self,
656
+ transaction_id,
657
+ source_entity_id,
658
+ file_size=None,
659
+ source_filename=None,
660
+ destination_filename=None,
661
+ messages_to_user=None,
662
+ ):
663
+ logger.info(
664
+ "[%s] Metadata received indication (%s bytes)"
665
+ % (str(transaction_id), str(file_size))
666
+ )
667
+ self._invoke_callback(
668
+ self.on_metadata_received,
669
+ self._as_handle(transaction_id),
670
+ source_entity_id,
671
+ file_size,
672
+ source_filename,
673
+ destination_filename,
674
+ messages_to_user,
675
+ )
676
+
677
+ def _indication_filesegment_received(self, transaction_id, offset, length):
678
+ logger.info(
679
+ "[%s] Filesegment received indication (offset %s, length %s)"
680
+ % (transaction_id, offset, length)
681
+ )
682
+ self._invoke_callback(
683
+ self.on_filesegment_received,
684
+ self._as_handle(transaction_id),
685
+ offset,
686
+ length,
687
+ )
688
+
689
+ def _indication_suspended(self, transaction_id, condition_code):
690
+ logger.info("[%s] Suspended indication" % str(transaction_id))
691
+ self._invoke_callback(
692
+ self.on_suspended, self._as_handle(transaction_id), condition_code
693
+ )
694
+
695
+ def _indication_resumed(self, transaction_id, progress):
696
+ logger.info("[%s] Resumed indication" % str(transaction_id))
697
+ self._invoke_callback(
698
+ self.on_resumed, self._as_handle(transaction_id), progress
699
+ )
700
+
701
+ def _indication_report(self, transaction_id, status_report):
702
+ logger.info("[%s] Report indication" % str(transaction_id))
703
+ self._invoke_callback(
704
+ self.on_report, self._as_handle(transaction_id), status_report
705
+ )
706
+
707
+ def _indication_fault(self, transaction_id, condition_code, progress):
708
+ logger.info("[%s] Fault indication" % str(transaction_id))
709
+ self._invoke_callback(
710
+ self.on_fault, self._as_handle(transaction_id), condition_code, progress
711
+ )
712
+
713
+ def _indication_abandoned(self, transaction_id, condition_code, progress):
714
+ logger.info("[%s] Abandoned indication" % str(transaction_id))
715
+ self._invoke_callback(
716
+ self.on_abandoned, self._as_handle(transaction_id), condition_code, progress
717
+ )
718
+
719
+ def _indication_eof_received(self, transaction_id):
720
+ logger.info("[%s] EOF received indication" % str(transaction_id))
721
+ self._invoke_callback(self.on_eof_received, self._as_handle(transaction_id))
722
+
723
+ """ Link state procedures """
724
+
725
+ def freeze(self, receiving_entity_id) -> None:
726
+ self._event_queue.put(("freeze_entity", receiving_entity_id))
727
+
728
+ def thaw(self, receiving_entity_id) -> None:
729
+ self._event_queue.put(("thaw_entity", receiving_entity_id))
730
+
731
+ def pdu_received(self, data) -> None:
732
+ """Queue raw PDU bytes for processing on the event thread (CCSDS 720.2-G-3 §5.4).
733
+
734
+ Pre-creates a completion event so is_complete() returns False (not True)
735
+ for transactions that are queued but not yet processed.
736
+ """
737
+ try:
738
+ pdu_header = PduHeader.decode(data)
739
+ tid = pdu_header.transaction_id
740
+ self._completion_events.setdefault(tid, threading.Event())
741
+ except Exception:
742
+ # A header we cannot even decode enough to key a completion event
743
+ # (truncated/corrupt bytes). Processing still enqueues the raw PDU
744
+ # below, where _get_pdu_object surfaces the decode failure; this
745
+ # pre-registration is best-effort, so log and carry on rather than
746
+ # dropping the PDU silently.
747
+ logger.debug("Could not pre-register completion event for incoming PDU")
748
+ self._event_queue.put(("pdu_raw", data))
749
+
750
+ def _get_pdu_object(self, raw_pdu):
751
+ "Take the raw input and return proper PDU object"
752
+
753
+ pdu_header = PduHeader.decode(raw_pdu)
754
+
755
+ # determine type of PDU
756
+ if pdu_header.pdu_type == PduTypeCode.FILE_DATA:
757
+ pdu = FiledataPdu.decode(raw_pdu)
758
+
759
+ elif pdu_header.pdu_type == PduTypeCode.FILE_DIRECTIVE:
760
+ code = raw_pdu[len(pdu_header)]
761
+
762
+ if code == DirectiveCode.EOF:
763
+ pdu = EofPdu.decode(raw_pdu)
764
+
765
+ elif code == DirectiveCode.FINISHED:
766
+ pdu = FinishedPdu.decode(raw_pdu)
767
+
768
+ elif code == DirectiveCode.ACK:
769
+ pdu = AckPdu.decode(raw_pdu)
770
+
771
+ elif code == DirectiveCode.METADATA:
772
+ pdu = MetadataPdu.decode(raw_pdu)
773
+
774
+ elif code == DirectiveCode.NAK:
775
+ pdu = NakPdu.decode(raw_pdu)
776
+
777
+ elif code == DirectiveCode.PROMPT:
778
+ pdu = PromptPdu.decode(raw_pdu)
779
+
780
+ elif code == DirectiveCode.KEEP_ALIVE:
781
+ pdu = KeepAlivePdu.decode(raw_pdu)
782
+
783
+ else:
784
+ raise ValueError(f"unknown file-directive code: {code!r}")
785
+
786
+ else:
787
+ raise ValueError(f"unknown PDU type: {pdu_header.pdu_type!r}")
788
+
789
+ return pdu
790
+
791
+ def _send_pdu_to_state_machine(self, machine, pdu):
792
+ if isinstance(pdu, MetadataPdu):
793
+ self.trigger_event(
794
+ Event(machine.transaction, EventType.E10_RECEIVED_METADATA), pdu
795
+ )
796
+
797
+ elif isinstance(pdu, FiledataPdu):
798
+ self.trigger_event(
799
+ Event(machine.transaction, EventType.E11_RECEIVED_FILEDATA), pdu
800
+ )
801
+
802
+ elif isinstance(pdu, EofPdu):
803
+ if pdu.condition_code == ConditionCode.NO_ERROR:
804
+ self.trigger_event(
805
+ Event(machine.transaction, EventType.E12_RECEIVED_EOF_NO_ERROR), pdu
806
+ )
807
+ else:
808
+ self.trigger_event(
809
+ Event(machine.transaction, EventType.E13_RECEIVED_EOF_CANCEL), pdu
810
+ )
811
+
812
+ elif isinstance(pdu, AckPdu):
813
+ if pdu.directive_code_ack == DirectiveCode.EOF:
814
+ self.trigger_event(
815
+ Event(machine.transaction, EventType.E14_RECEIVED_ACK_EOF), pdu
816
+ )
817
+ elif pdu.directive_code_ack == DirectiveCode.FINISHED:
818
+ self.trigger_event(
819
+ Event(machine.transaction, EventType.E18_RECEIVED_ACK_FINISHED), pdu
820
+ )
821
+ else:
822
+ raise NotImplementedError
823
+
824
+ elif isinstance(pdu, FinishedPdu):
825
+ if pdu.condition_code == ConditionCode.NO_ERROR:
826
+ self.trigger_event(
827
+ Event(
828
+ machine.transaction, EventType.E16_RECEIVED_FINISHED_NO_ERROR
829
+ ),
830
+ pdu,
831
+ )
832
+ else:
833
+ self.trigger_event(
834
+ Event(machine.transaction, EventType.E17_RECEIVED_FINISHED_CANCEL),
835
+ pdu,
836
+ )
837
+
838
+ elif isinstance(pdu, NakPdu):
839
+ self.trigger_event(
840
+ Event(machine.transaction, EventType.E15_RECEIVED_NAK), pdu
841
+ )
842
+
843
+ else:
844
+ raise TypeError(f"unroutable PDU type: {type(pdu).__name__}")
845
+
846
+ def _run_action_for_incoming_pdu(self, mode, direction, transaction_id, pdu):
847
+ """See CCSDS 720.2-G-3, Chapter 5.4, Table 5-5"""
848
+
849
+ if (
850
+ direction == Direction.TOWARD_SENDER
851
+ and mode == TransmissionMode.UNACKNOWLEDGED
852
+ ):
853
+ # ignore
854
+ pass
855
+
856
+ elif (
857
+ direction == Direction.TOWARD_SENDER
858
+ and mode == TransmissionMode.ACKNOWLEDGED
859
+ ):
860
+ if isinstance(pdu, FinishedPdu):
861
+ logger.debug(f"[{transaction_id}] Send Ack Finished")
862
+ ack_pdu = AckPdu(
863
+ direction=Direction.TOWARD_RECEIVER,
864
+ transmission_mode=pdu.pdu_header.transmission_mode,
865
+ source_entity_id=pdu.pdu_header.source_entity_id,
866
+ transaction_seq_number=pdu.pdu_header.transaction_seq_number,
867
+ destination_entity_id=pdu.pdu_header.destination_entity_id,
868
+ #
869
+ directive_code_ack=DirectiveCode.FINISHED,
870
+ directive_subtype_code=DirectiveSubTypeCode.ACK_FINISHED,
871
+ condition_code=ConditionCode.NO_ERROR,
872
+ transaction_status=TransactionStatus.UNDEFINED,
873
+ )
874
+ self.transport.send(ack_pdu.encode())
875
+
876
+ elif direction == Direction.TOWARD_RECEIVER:
877
+ # start a new state machine
878
+ if (
879
+ isinstance(pdu, MetadataPdu)
880
+ or isinstance(pdu, FiledataPdu)
881
+ or isinstance(pdu, EofPdu)
882
+ ):
883
+ # Build a pure receiver via the registry. It picks Receiver1
884
+ # (UNACKNOWLEDGED) or Receiver2 (ACKNOWLEDGED) off the inbound PDU
885
+ # header (issues #16 / #20).
886
+ remote_cfg = self._get_remote_config(pdu.pdu_header.source_entity_id)
887
+ machine, transaction = self._machine_registry.create_receiver(
888
+ pdu, remote_config=remote_cfg
889
+ )
890
+ self._completion_events.setdefault(transaction.id, threading.Event())
891
+ self.trigger_event(Event(transaction, EventType.E0_ENTERED_STATE))
892
+ self._send_pdu_to_state_machine(machine, pdu)
893
+
894
+ def process_user_message(self, message):
895
+ meta_process_user_message(self, message)