Files
sedona_editor/dasp.py
T
ArdaandClaude Opus 4.6 643bcdc952 Add Trace DASP preference to print protocol transactions to terminal
New checkbox in Preferences (default off). When enabled, every DASP
send (>>>) and receive (<<<) prints message type, seq/ack numbers,
payload size, and remote address to stdout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-11 23:48:10 +03:00

973 lines
34 KiB
Python

"""
DASP (Datagram Authenticated Session Protocol) transport layer.
Ported from sedonaOntrol/src/sedona/dasp/ Java sources.
Provides reliable, authenticated, full-duplex UDP communication
as the transport layer beneath the Sox application protocol.
"""
import hashlib
import os
import queue
import random
import socket
import struct
import threading
import time
# ────────────────────────────────────────────────────────────────
# Constants (from DaspConst.java)
# ────────────────────────────────────────────────────────────────
VERSION_VAL = 0x0100
IDEAL_MAX_VAL = 512
ABS_MAX_VAL = 1024
RECEIVE_MAX_VAL = 31
RECEIVE_TIMEOUT_VAL = 30_000 # ms
CONNECT_TIMEOUT_VAL = 10_000 # ms
SEND_RETRY_VAL = 1000 # ms
MAX_RETRY_BACKOFF = 8000 # ms (Ontrol)
ACK_MORE_BYTE_LIMIT = 7 # (Ontrol)
DIGEST_ALGORITHM = "SHA-1" # (Ontrol: also "SHA-256")
IDEAL_MAX_DEF = 512
ABS_MAX_DEF = 512
RECEIVE_MAX_DEF = 31
RECEIVE_TIMEOUT_DEF = 30_000 # ms
# Message types
DISCOVER = 0
HELLO = 1
CHALLENGE = 2
AUTHENTICATE = 3
WELCOME = 4
KEEPALIVE = 5
DATAGRAM = 6
CLOSE = 7
# Header field value encodings (low 2 bits of field id)
NIL = 0
U2 = 1
STR = 2
BYTES = 3
# Header field ids
HDR_VERSION = 0x05
HDR_REMOTE_ID = 0x09
HDR_DIGEST_ALGORITHM = 0x0E
HDR_NONCE = 0x13
HDR_USERNAME = 0x16
HDR_DIGEST = 0x1B
HDR_IDEAL_MAX = 0x1D
HDR_ABS_MAX = 0x21
HDR_ACK = 0x25
HDR_ACK_MORE = 0x2B
HDR_RECEIVE_MAX = 0x2D
HDR_RECEIVE_TIMEOUT = 0x31
HDR_ERROR_CODE = 0x35
HDR_PLATFORM_ID = 0x3A
HDR_MESSAGE_LEN = 0x81 # (Ontrol)
# Error codes
ERR_INCOMPATIBLE_VERSION = 0xE1
ERR_BUSY = 0xE2
ERR_DIGEST_NOT_SUPPORTED = 0xE3
ERR_NOT_AUTHENTICATED = 0xE4
ERR_TIMEOUT = 0xE5
DEFAULT_PORT = 1876
_MSG_TYPE_NAMES = {
DISCOVER: "DISCOVER", HELLO: "HELLO", CHALLENGE: "CHALLENGE",
AUTHENTICATE: "AUTHENTICATE", WELCOME: "WELCOME", KEEPALIVE: "KEEPALIVE",
DATAGRAM: "DATAGRAM", CLOSE: "CLOSE",
}
def _trace(direction, msg, addr=None):
import app_state
if not app_state.TRACE_DASP:
return
name = _MSG_TYPE_NAMES.get(msg.msg_type, f"?{msg.msg_type}")
seq = f" seq={msg.seq_num}" if msg.seq_num >= 0 else ""
ack = f" ack={msg.ack}" if msg.ack >= 0 else ""
plen = len(msg.payload) if msg.payload else 0
dest = f" {addr[0]}:{addr[1]}" if addr else ""
print(f"[DASP] {direction}{dest} {name}{seq}{ack} payload={plen}")
class DaspException(Exception):
def __init__(self, msg, error_code=-1):
super().__init__(msg)
self.error_code = error_code
# ────────────────────────────────────────────────────────────────
# DaspMsg (from DaspMsg.java)
# ────────────────────────────────────────────────────────────────
class DaspMsg:
"""Binary encoder/decoder for DASP messages."""
def __init__(self):
self.session_id = -1
self.msg_type = -1
self.seq_num = -1
# headers
self.version = -1
self.remote_id = -1
self.digest_algorithm = None
self.nonce = None
self.username = None
self.digest = None
self.ideal_max = -1
self.abs_max = -1
self.ack = -1
self.ack_more = None
self.receive_max = -1
self.receive_timeout = -1 # ms internally
self.error_code = -1
self.platform_id = None
self.include_message_len = True # (Ontrol)
# payload
self.payload = b""
# ── Decode ──────────────────────────────────────────────────
@staticmethod
def decode(buf, length=None):
if length is None:
length = len(buf)
msg = DaspMsg()
msg._decode(buf, length)
return msg
def _decode(self, buf, length):
self.session_id = (buf[0] << 8) | buf[1]
self.seq_num = (buf[2] << 8) | buf[3]
self.msg_type = (buf[4] & 0xFF) >> 4
num_fields = buf[4] & 0x0F
pos = 5
for _ in range(num_fields):
fid = buf[pos] & 0xFF
pos += 1
ftype = fid & 0x03
u2_val = 0
str_val = None
bytes_val = None
if ftype == NIL:
pass
elif ftype == U2:
u2_val = (buf[pos] << 8) | buf[pos + 1]
pos += 2
elif ftype == STR:
start = pos
while buf[pos] != 0:
pos += 1
str_val = buf[start:pos].decode("utf-8")
pos += 1 # skip null terminator
elif ftype == BYTES:
blen = buf[pos] & 0xFF
pos += 1
bytes_val = bytes(buf[pos:pos + blen])
pos += blen
if fid == HDR_VERSION: self.version = u2_val
elif fid == HDR_REMOTE_ID: self.remote_id = u2_val
elif fid == HDR_DIGEST_ALGORITHM: self.digest_algorithm = str_val
elif fid == HDR_NONCE: self.nonce = bytes_val
elif fid == HDR_USERNAME: self.username = str_val
elif fid == HDR_DIGEST: self.digest = bytes_val
elif fid == HDR_IDEAL_MAX: self.ideal_max = u2_val
elif fid == HDR_ABS_MAX: self.abs_max = u2_val
elif fid == HDR_ACK: self.ack = u2_val
elif fid == HDR_ACK_MORE: self.ack_more = bytes_val
elif fid == HDR_RECEIVE_MAX: self.receive_max = u2_val
elif fid == HDR_RECEIVE_TIMEOUT: self.receive_timeout = u2_val * 1000
elif fid == HDR_ERROR_CODE: self.error_code = u2_val
elif fid == HDR_PLATFORM_ID: self.platform_id = str_val
elif fid == HDR_MESSAGE_LEN: pass # not used after decode
self.payload = bytes(buf[pos:length])
# ── Encode ──────────────────────────────────────────────────
def encode(self):
buf = bytearray(ABS_MAX_VAL)
length = self._encode_into(buf)
return bytes(buf[:length])
def _encode_into(self, buf):
struct.pack_into(">HH", buf, 0, self.session_id & 0xFFFF, self.seq_num & 0xFFFF)
pos = 5 # skip byte 4, will backpatch
has_ideal_max = self.ideal_max > -1 and self.ideal_max != IDEAL_MAX_DEF
has_abs_max = self.abs_max > -1 and self.abs_max != ABS_MAX_DEF
has_receive_max = self.receive_max > -1 and self.receive_max != RECEIVE_MAX_DEF
has_receive_timeout = self.receive_timeout > -1 and self.receive_timeout != RECEIVE_TIMEOUT_DEF
num = 0
# Ontrol: MESSAGE_LEN first (backpatched)
msg_len_pos = -1
if self.include_message_len:
num += 1
buf[pos] = HDR_MESSAGE_LEN
pos += 1
msg_len_pos = pos
pos += 2 # placeholder for length
if self.version > -1:
num += 1; buf[pos] = HDR_VERSION; pos += 1
pos = _put_u2(buf, pos, self.version)
if self.remote_id > -1:
num += 1; buf[pos] = HDR_REMOTE_ID; pos += 1
pos = _put_u2(buf, pos, self.remote_id)
if self.digest_algorithm is not None:
num += 1; buf[pos] = HDR_DIGEST_ALGORITHM; pos += 1
pos = _put_str(buf, pos, self.digest_algorithm)
if self.nonce is not None:
num += 1; buf[pos] = HDR_NONCE; pos += 1
pos = _put_bytes(buf, pos, self.nonce)
if self.username is not None:
num += 1; buf[pos] = HDR_USERNAME; pos += 1
pos = _put_str(buf, pos, self.username)
if self.digest is not None:
num += 1; buf[pos] = HDR_DIGEST; pos += 1
pos = _put_bytes(buf, pos, self.digest)
if has_ideal_max:
num += 1; buf[pos] = HDR_IDEAL_MAX; pos += 1
pos = _put_u2(buf, pos, self.ideal_max)
if has_abs_max:
num += 1; buf[pos] = HDR_ABS_MAX; pos += 1
pos = _put_u2(buf, pos, self.abs_max)
if self.ack > -1:
num += 1; buf[pos] = HDR_ACK; pos += 1
pos = _put_u2(buf, pos, self.ack)
if self.ack_more is not None:
num += 1; buf[pos] = HDR_ACK_MORE; pos += 1
pos = _put_bytes(buf, pos, self.ack_more)
if has_receive_max:
num += 1; buf[pos] = HDR_RECEIVE_MAX; pos += 1
pos = _put_u2(buf, pos, self.receive_max)
if has_receive_timeout:
num += 1; buf[pos] = HDR_RECEIVE_TIMEOUT; pos += 1
pos = _put_u2(buf, pos, int(self.receive_timeout / 1000))
if self.error_code > -1:
num += 1; buf[pos] = HDR_ERROR_CODE; pos += 1
pos = _put_u2(buf, pos, self.error_code)
if self.platform_id is not None:
num += 1; buf[pos] = HDR_PLATFORM_ID; pos += 1
pos = _put_str(buf, pos, self.platform_id)
# backpatch msg_type | num_fields
buf[4] = ((self.msg_type & 0x0F) << 4) | (num & 0x0F)
# payload
if self.payload:
buf[pos:pos + len(self.payload)] = self.payload
pos += len(self.payload)
# backpatch MESSAGE_LEN
if msg_len_pos >= 0:
_put_u2(buf, msg_len_pos, pos)
return pos
def _put_u2(buf, pos, val):
buf[pos] = (val >> 8) & 0xFF
buf[pos + 1] = val & 0xFF
return pos + 2
def _put_str(buf, pos, val):
encoded = val.encode("ascii")
buf[pos:pos + len(encoded)] = encoded
pos += len(encoded)
buf[pos] = 0
return pos + 1
def _put_bytes(buf, pos, val):
buf[pos] = len(val) & 0xFF
pos += 1
buf[pos:pos + len(val)] = val
return pos + len(val)
# ────────────────────────────────────────────────────────────────
# ReceiveWindow (from ReceiveWindow.java)
# ────────────────────────────────────────────────────────────────
class ReceiveWindow:
"""Manages incoming message sequence numbers and acknowledgements."""
MAX = RECEIVE_MAX_VAL # 31
def __init__(self, session):
self._session = session
self._lock = threading.Lock()
self._seq_num = -1
self._seq_more = -1
self._acked = False
def unacked(self):
with self._lock:
return not self._acked
def init(self, num):
with self._lock:
if self._seq_num >= 0:
raise RuntimeError("ReceiveWindow already initialized")
self._seq_num = (num - 1) & 0xFFFF
self._seq_more = 1
self._acked = False
def set_ack_headers(self, msg):
with self._lock:
payload_len = len(msg.payload)
# room for 5-byte fixed header + 3-byte ack header = 8
if payload_len + 8 <= self._session.ideal_max:
msg.ack = self._seq_num
self._acked = True
# room for ack_more: +3 bytes = 11 total overhead
if self._seq_more > 0x01 and payload_len + 11 <= self._session.ideal_max:
max_bytes = self._session.ideal_max - payload_len - 10
msg.ack_more = _to_ack_more(self._seq_more, max_bytes)
def receive(self, num):
with self._lock:
if num < 0 or num > 0xFFFF:
raise RuntimeError(f"Invalid seq num: {num}")
if self._seq_num < 0:
raise RuntimeError("ReceiveWindow not initialized")
start = self._seq_num
end = start + self.MAX
if end <= 0xFFFF:
if num <= start or num > end:
self._acked = False
return False
else:
end -= 0xFFFF
if num <= start and num > end:
self._acked = False
return False
# next in sequence — slide window
if num == (start + 1) & 0xFFFF:
while True:
self._seq_num = (self._seq_num + 1) & 0xFFFF
self._seq_more = (self._seq_more >> 1) & 0x7FFFFFFF
if (self._seq_more & 0x02) == 0:
break
self._seq_more |= 0x01
else:
# out of order
bit = 1 << ((num - start) & 0xFFFF)
if self._seq_more & bit:
return False
self._seq_more |= bit
self._acked = False
return True
def _to_ack_more(seq_more, max_bytes):
if seq_more <= 0xFF or max_bytes <= 1:
return bytes([seq_more & 0xFF])
if seq_more <= 0xFFFF or max_bytes <= 2:
return bytes([(seq_more >> 8) & 0xFF, seq_more & 0xFF])
if seq_more <= 0xFFFFFF or max_bytes <= 3:
return bytes([(seq_more >> 16) & 0xFF, (seq_more >> 8) & 0xFF, seq_more & 0xFF])
return bytes([(seq_more >> 24) & 0xFF, (seq_more >> 16) & 0xFF,
(seq_more >> 8) & 0xFF, seq_more & 0xFF])
# ────────────────────────────────────────────────────────────────
# SendWindow (from SendWindow.java)
# ────────────────────────────────────────────────────────────────
class _Packet:
__slots__ = ("seq_num", "payload", "acked", "enqueued_time",
"sent_time", "send_attempts", "next")
def __init__(self):
self.seq_num = 0
self.payload = b""
self.acked = False
self.enqueued_time = 0.0
self.sent_time = 0.0
self.send_attempts = 1
self.next = None
class SendWindow:
"""Manages the outgoing datagram queue with reliable delivery."""
FAST_RETRANSMIT_DUP_ACKS = 2
def __init__(self, session):
self._session = session
self._cond = threading.Condition()
self._head = None
self._tail = None
self._is_alive = True
self._size = 0
self._seq_num = random.randint(0, 0xFFFF)
self.send_size = 8
self.send_retry = SEND_RETRY_VAL / 1000.0 # seconds
self.max_retry_backoff = MAX_RETRY_BACKOFF / 1000.0 # seconds
self._last_checked_ack = -1
self._dup_ack_count = 0
def size(self):
with self._cond:
return self._size
def cur_seq_num(self):
return self._seq_num & 0xFFFF
def kill(self):
with self._cond:
self._is_alive = False
self._cond.notify_all()
def clear(self):
with self._cond:
self._size = 0
self._head = None
self._tail = None
self._cond.notify_all()
def send(self, payload):
p = _Packet()
p.enqueued_time = _ticks()
with self._cond:
p.seq_num = self._seq_num & 0xFFFF
self._seq_num = (self._seq_num + 1) & 0xFFFF
self._enqueue(p)
p.payload = payload
p.sent_time = _ticks()
self._session._send_raw(self._to_msg(p))
def _enqueue(self, p):
with self._cond:
while self._is_alive and (self._full() or self._exceeds_ack_more_limit(p)):
self._cond.wait()
if not self._is_alive:
return
if self._tail is None:
self._head = self._tail = p
else:
self._tail.next = p
self._tail = p
self._size += 1
self._cond.notify_all()
def _full(self):
return self._size >= self.send_size
def _exceeds_ack_more_limit(self, p):
last_ack = self._session.last_ack
if last_ack < 0:
return False
span = (p.seq_num - last_ack) & 0xFFFF
return span > self._session.ack_more_byte_limit
def _to_msg(self, packet):
msg = DaspMsg()
msg.msg_type = DATAGRAM
msg.session_id = self._session.remote_id
msg.seq_num = packet.seq_num
msg.payload = packet.payload
self._session.receive_window.set_ack_headers(msg)
return msg
def check_ack_headers(self, msg):
with self._cond:
ack_num = msg.ack
if ack_num < 0:
return
if self._head is None:
self._track_dup_acks(ack_num)
return
unack_start = (ack_num + 1) & 0xFFFF
unack_end = (unack_start + 10000) & 0xFFFF
p = self._head
while p is not None:
if not p.acked and self._is_acked(unack_start, unack_end, msg, p.seq_num):
p.acked = True
if p is self._head and p.acked:
self._head = p.next
self._size -= 1
p = p.next
if self._head is None:
self._tail = None
self._track_dup_acks(ack_num)
self._cond.notify_all()
def _track_dup_acks(self, ack_num):
if ack_num != self._last_checked_ack:
self._last_checked_ack = ack_num
self._dup_ack_count = 0
return
self._dup_ack_count += 1
if self._dup_ack_count < self.FAST_RETRANSMIT_DUP_ACKS:
return
self._dup_ack_count = 0
if self._head is not None and not self._head.acked:
self._resend(self._head)
@staticmethod
def _is_acked(unack_start, unack_end, msg, seq_num):
if unack_start <= unack_end:
if seq_num < unack_start or seq_num > unack_end:
return True
else:
if seq_num < unack_start and seq_num > unack_end:
return True
ack_more = msg.ack_more
if ack_more is not None:
diff = (seq_num - msg.ack) & 0xFFFF
index = len(ack_more) - (diff >> 3) - 1
if 0 <= index < len(ack_more):
ack_more_byte = ack_more[index] & 0xFF
mask = 1 << (diff & 0x07)
if ack_more_byte & mask:
return True
return False
def send_retries(self):
with self._cond:
now = _ticks()
oldest = 0.0
p = self._head
while p is not None:
if not p.acked:
backoff_shift = min(p.send_attempts - 1, 32)
interval = min(self.send_retry * (1 << backoff_shift),
self.max_retry_backoff)
if now - p.sent_time >= interval:
self._resend(p)
oldest = max(oldest, now - p.enqueued_time)
p = p.next
return oldest
def _resend(self, p):
p.sent_time = _ticks()
p.send_attempts += 1
self._session.num_retries += 1
self._session._send_raw(self._to_msg(p))
# ────────────────────────────────────────────────────────────────
# DaspSession (from DaspSession.java)
# ────────────────────────────────────────────────────────────────
class DaspSession:
"""
Client endpoint of a DASP session. Handles the handshake,
keepalive, reliable send/receive, and session close.
"""
def __init__(self, dasp_socket, session_id, host, port, **options):
self.socket = dasp_socket
self.id = session_id
self.host = host
self.port = port
self.remote_id = -1
self.is_closed = False
self.close_cause = "???"
self.user = ""
self.password = ""
self.ideal_max = options.get("ideal_max", IDEAL_MAX_DEF)
self.abs_max = options.get("abs_max", ABS_MAX_DEF)
self.receive_timeout = options.get("receive_timeout", RECEIVE_TIMEOUT_VAL) / 1000.0 # seconds
self.connect_timeout = options.get("connect_timeout", CONNECT_TIMEOUT_VAL) / 1000.0 # seconds
self.ack_more_byte_limit = options.get("ack_more_byte_limit", ACK_MORE_BYTE_LIMIT)
self.digest_algorithm = options.get("digest_algorithm", DIGEST_ALGORITHM)
self.include_message_len = options.get("include_message_len", True)
self.receive_window = ReceiveWindow(self)
self.send_window = SendWindow(self)
self.send_window.send_retry = options.get("send_retry", SEND_RETRY_VAL) / 1000.0
self.send_window.max_retry_backoff = options.get("max_retry_backoff", MAX_RETRY_BACKOFF) / 1000.0
self._receive_queue = queue.Queue(maxsize=2000)
self.remote_receive_max = RECEIVE_MAX_DEF
self.last_ack = -1
self.last_send = _ticks()
self.last_receive = _ticks()
self.num_sent = 0
self.num_received = 0
self.num_retries = 0
self._nonce = None
self._close_lock = threading.Lock()
self.on_close = None # callback
# ── Client handshake ────────────────────────────────────────
def connect(self):
res = self._hello()
if res.msg_type == CLOSE:
raise DaspException("Connection denied", res.error_code)
elif res.msg_type == CHALLENGE:
self.remote_id = res.remote_id
welcome = self._authenticate(res)
elif res.msg_type == WELCOME:
self.remote_id = res.remote_id
welcome = res
else:
raise DaspException(f"Unexpected response to hello: {res.msg_type}")
if welcome.msg_type == CLOSE:
raise DaspException("Connection denied", welcome.error_code)
elif welcome.msg_type == WELCOME:
self._tune(welcome)
else:
raise DaspException(f"Expected welcome, not {welcome.msg_type}")
def _hello(self):
msg = DaspMsg()
msg.msg_type = HELLO
msg.session_id = 0xFFFF
msg.seq_num = self.send_window.cur_seq_num()
msg.remote_id = self.id
msg.version = VERSION_VAL
msg.ideal_max = self.ideal_max
msg.abs_max = self.abs_max
msg.receive_max = RECEIVE_MAX_VAL
msg.receive_timeout = int(self.receive_timeout * 1000)
timeout = max(self.connect_timeout / 3, 1.0)
for _ in range(3):
self._send_raw(msg)
try:
res = self._receive_queue.get(timeout=timeout)
return res
except queue.Empty:
continue
raise DaspException("No response from hello")
def _authenticate(self, challenge):
algorithm = challenge.digest_algorithm or "SHA-1"
if algorithm not in ("SHA-1", "SHA-256"):
self.close(ERR_DIGEST_NOT_SUPPORTED, "digest not supported")
raise DaspException("Unsupported algorithm", ERR_DIGEST_NOT_SUPPORTED)
# credentials: always SHA-1 of "user:pass"
cred = hashlib.sha1(f"{self.user}:{self.password}".encode("utf-8")).digest()
# outer digest uses negotiated algorithm
algo_name = "sha1" if algorithm == "SHA-1" else "sha256"
md = hashlib.new(algo_name)
md.update(cred)
md.update(challenge.nonce)
digest_bytes = md.digest()
msg = DaspMsg()
msg.msg_type = AUTHENTICATE
msg.session_id = self.remote_id
msg.seq_num = self.send_window.cur_seq_num()
msg.username = self.user
msg.digest = digest_bytes
timeout = max(self.connect_timeout / 3, 1.0)
for _ in range(3):
self._send_raw(msg)
try:
res = self._receive_queue.get(timeout=timeout)
if res.msg_type != CHALLENGE:
return res
# drop duplicate challenge from multiple hellos
except queue.Empty:
continue
raise DaspException("No response from authenticate")
def _tune(self, msg):
remote_ideal = msg.ideal_max if msg.ideal_max > -1 else IDEAL_MAX_DEF
remote_abs = msg.abs_max if msg.abs_max > -1 else ABS_MAX_DEF
remote_recv_max = msg.receive_max if msg.receive_max > -1 else RECEIVE_MAX_DEF
remote_timeout = (msg.receive_timeout if msg.receive_timeout > -1
else RECEIVE_TIMEOUT_DEF) / 1000.0
self.ideal_max = min(remote_ideal, self.ideal_max)
self.abs_max = min(remote_abs, self.abs_max)
self.remote_receive_max = remote_recv_max
self.receive_timeout = max(remote_timeout, self.receive_timeout)
self.send_window.send_size = min(self.remote_receive_max, self.ack_more_byte_limit)
self.receive_window.init(msg.seq_num)
# ── Send / Receive ──────────────────────────────────────────
def send(self, payload):
if self.is_closed:
raise DaspException(f"Session closed: {self.close_cause}")
self.send_window.send(payload)
def receive(self, timeout=1.0):
if self.is_closed:
raise DaspException(f"Session closed: {self.close_cause}")
try:
msg = self._receive_queue.get(timeout=timeout)
except queue.Empty:
return None
if self.is_closed:
raise DaspException(f"Session closed: {self.close_cause}")
if not isinstance(msg, DaspMsg):
return msg
if msg.msg_type != DATAGRAM:
raise DaspException(f"Invalid message type received: {msg.msg_type}")
return msg
def _send_raw(self, msg):
msg.include_message_len = self.include_message_len
self.last_send = _ticks()
self.num_sent += 1
self.socket._send_msg(self, msg)
# ── Dispatch (called on receiver thread) ────────────────────
def dispatch(self, msg):
self.num_received += 1
self.last_receive = _ticks()
if msg.ack >= 0:
self.last_ack = msg.ack
self.send_window.check_ack_headers(msg)
if msg.msg_type == DATAGRAM:
if not self.receive_window.receive(msg.seq_num):
return
self._enqueue(msg)
elif msg.msg_type in (CHALLENGE, WELCOME):
self._enqueue(msg)
elif msg.msg_type == CLOSE:
self._enqueue(msg)
self._shutdown(f"remote close (error={msg.error_code})")
elif msg.msg_type == KEEPALIVE:
pass # lastReceive already updated
def _enqueue(self, msg):
try:
self._receive_queue.put_nowait(msg)
except queue.Full:
self.close(ERR_TIMEOUT, "receive queue full")
# ── Housekeeping (called every 100ms) ───────────────────────
def housekeeping(self):
if self.is_closed:
return
if _ticks() - self.last_receive > self.receive_timeout:
self.close(ERR_TIMEOUT, "receive timeout")
return
oldest = self.send_window.send_retries()
if oldest > self.receive_timeout:
self.close(ERR_TIMEOUT, "unacked send timeout")
return
if (self.receive_window.unacked() or
_ticks() - self.last_send > self.receive_timeout / 3):
self._keepalive()
def _keepalive(self):
msg = DaspMsg()
msg.msg_type = KEEPALIVE
msg.session_id = self.remote_id
msg.seq_num = 0xFFFF
self.receive_window.set_ack_headers(msg)
self._send_raw(msg)
# ── Close ───────────────────────────────────────────────────
def close(self, error_code=-1, cause="close()"):
with self._close_lock:
if self.is_closed:
return
close_msg = DaspMsg()
close_msg.msg_type = CLOSE
close_msg.session_id = self.remote_id if self.remote_id >= 0 else 0xFFFF
close_msg.seq_num = 0xFFFF
if error_code >= 0:
close_msg.error_code = error_code
self._send_raw(close_msg)
if error_code < 0:
time.sleep(0.05)
self._send_raw(close_msg)
self._shutdown(cause)
def _shutdown(self, cause):
with self._close_lock:
if self.is_closed:
return
self.is_closed = True
self.close_cause = cause
self.send_window.kill()
# wake anyone blocked on receive
try:
self._receive_queue.put_nowait(None)
except queue.Full:
pass
if self.on_close:
try:
self.on_close(self)
except Exception:
pass
self.socket._free_session(self)
# ────────────────────────────────────────────────────────────────
# DaspSocket (from DaspSocket.java + DaspSocketInterface.java)
# ────────────────────────────────────────────────────────────────
class DaspSocket:
"""
Manages a UDP socket shared by DASP sessions.
Provides receiver and housekeeping background threads.
"""
def __init__(self, port=-1):
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._sock.settimeout(1.0)
if port > 0:
self._sock.bind(("", port))
else:
self._sock.bind(("", 0))
self._sessions = {} # id -> DaspSession
self._sessions_lock = threading.Lock()
self._is_alive = True
self._rand = random.Random()
self._send_lock = threading.Lock()
self._receiver_thread = threading.Thread(
target=self._receiver_loop, name="DASP-Receiver", daemon=True)
self._housekeeping_thread = threading.Thread(
target=self._housekeeping_loop, name="DASP-Housekeeping", daemon=True)
self._receiver_thread.start()
self._housekeeping_thread.start()
@property
def port(self):
return self._sock.getsockname()[1]
@property
def is_closed(self):
return not self._is_alive
def connect(self, host, port, user, password, **options):
if not self._is_alive:
raise DaspException("Socket is closed")
session = self._alloc_session(host, port, **options)
session.user = user
session.password = password
session.connect()
return session
def close(self):
if not self._is_alive:
return
self._is_alive = False
with self._sessions_lock:
for s in list(self._sessions.values()):
try:
s.close(-1, "socket closed")
except Exception:
pass
try:
self._sock.close()
except Exception:
pass
# ── Session management ──────────────────────────────────────
def _alloc_session(self, host, port, **options):
with self._sessions_lock:
while True:
sid = self._rand.randint(0, 0xFFFE)
if sid not in self._sessions:
break
session = DaspSession(self, sid, host, port, **options)
self._sessions[sid] = session
return session
def _free_session(self, session):
with self._sessions_lock:
self._sessions.pop(session.id, None)
# ── Network I/O ─────────────────────────────────────────────
def _send_msg(self, session, msg):
_trace(">>>", msg, (session.host, session.port))
data = msg.encode()
with self._send_lock:
try:
self._sock.sendto(data, (session.host, session.port))
except OSError:
pass
def _receiver_loop(self):
buf = bytearray(ABS_MAX_VAL + 64)
while self._is_alive:
try:
nbytes, addr = self._sock.recvfrom_into(buf)
if nbytes < 5:
continue
msg = DaspMsg.decode(buf, nbytes)
self._dispatch(msg, addr)
except socket.timeout:
continue
except OSError:
if self._is_alive:
continue
break
def _dispatch(self, msg, addr):
_trace("<<<", msg, addr)
session_id = msg.session_id
with self._sessions_lock:
session = self._sessions.get(session_id)
if session is None:
return
if session.host != addr[0] or session.port != addr[1]:
return
session.dispatch(msg)
def _housekeeping_loop(self):
while self._is_alive:
time.sleep(0.1)
with self._sessions_lock:
sessions = list(self._sessions.values())
for s in sessions:
try:
s.housekeeping()
except Exception:
pass
# ────────────────────────────────────────────────────────────────
# Utility
# ────────────────────────────────────────────────────────────────
def _ticks():
"""Monotonic time in seconds (float)."""
return time.monotonic()