The device builds the schema and version replies in a fixed 512-byte packet and drops whatever does not fit. Read strings defensively, recover a cut-off checksum from the one local manifest matching its prefix, and fail with a clear message when the kit list cannot be recovered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1070 lines
37 KiB
Python
1070 lines
37 KiB
Python
"""
|
|
Sox (Sedona Object Exchange) protocol layer.
|
|
|
|
Ported from sedonaOntrol/src/sedona/sox/ Java sources.
|
|
Provides component tree access, property read/write,
|
|
subscribe/unsubscribe, and structural mutations over DASP.
|
|
"""
|
|
|
|
import struct
|
|
import threading
|
|
import time
|
|
|
|
from dasp import DaspSocket, DaspException
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# Constants
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
TREE = 0x01
|
|
CONFIG = 0x02
|
|
RUNTIME = 0x04
|
|
LINKS = 0x08
|
|
ALL = TREE | CONFIG | RUNTIME | LINKS
|
|
|
|
# Sedona type ids (matches Type.java constants)
|
|
BOOL_ID = 0
|
|
BYTE_ID = 1
|
|
SHORT_ID = 2
|
|
INT_ID = 3
|
|
LONG_ID = 4
|
|
FLOAT_ID = 5
|
|
DOUBLE_ID = 6
|
|
BUF_ID = 7
|
|
STR_ID = 8
|
|
|
|
|
|
class SoxException(Exception):
|
|
pass
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# SoxMsg — cursor-based binary buffer for Sox payloads
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
class SoxMsg:
|
|
"""Big-endian binary buffer with cursor, mirroring sedona.Buf."""
|
|
|
|
def __init__(self, data=None, capacity=512):
|
|
if data is not None:
|
|
self._buf = bytearray(data)
|
|
self._size = len(data)
|
|
else:
|
|
self._buf = bytearray(capacity)
|
|
self._size = 0
|
|
self._pos = 0
|
|
|
|
# ── Read ────────────────────────────────────────────────────
|
|
|
|
def u1(self):
|
|
v = self._buf[self._pos]
|
|
self._pos += 1
|
|
return v
|
|
|
|
def u2(self):
|
|
v = (self._buf[self._pos] << 8) | self._buf[self._pos + 1]
|
|
self._pos += 2
|
|
return v
|
|
|
|
def i4(self):
|
|
v, = struct.unpack_from(">i", self._buf, self._pos)
|
|
self._pos += 4
|
|
return v
|
|
|
|
def i8(self):
|
|
v, = struct.unpack_from(">q", self._buf, self._pos)
|
|
self._pos += 8
|
|
return v
|
|
|
|
def f4(self):
|
|
v, = struct.unpack_from(">f", self._buf, self._pos)
|
|
self._pos += 4
|
|
return v
|
|
|
|
def f8(self):
|
|
v, = struct.unpack_from(">d", self._buf, self._pos)
|
|
self._pos += 8
|
|
return v
|
|
|
|
def str(self):
|
|
start = self._pos
|
|
while self._buf[self._pos] != 0:
|
|
self._pos += 1
|
|
s = self._buf[start:self._pos].decode("utf-8", errors="replace")
|
|
self._pos += 1 # null terminator
|
|
return s
|
|
|
|
def try_str(self):
|
|
"""Like str(), but returns None instead of overrunning a truncated buffer."""
|
|
end = self._buf.find(0, self._pos, self._size)
|
|
if end < 0:
|
|
return None
|
|
return self.str()
|
|
|
|
def read_bytes(self, n):
|
|
data = bytes(self._buf[self._pos:self._pos + n])
|
|
self._pos += n
|
|
return data
|
|
|
|
@property
|
|
def remaining(self):
|
|
return self._size - self._pos
|
|
|
|
# ── Write ───────────────────────────────────────────────────
|
|
|
|
def _grow(self, need):
|
|
end = self._pos + need
|
|
if end > len(self._buf):
|
|
new_cap = max(end, len(self._buf) * 2)
|
|
self._buf.extend(b'\x00' * (new_cap - len(self._buf)))
|
|
if end > self._size:
|
|
self._size = end
|
|
|
|
def write_u1(self, v):
|
|
self._grow(1)
|
|
self._buf[self._pos] = v & 0xFF
|
|
self._pos += 1
|
|
|
|
def write_u2(self, v):
|
|
self._grow(2)
|
|
self._buf[self._pos] = (v >> 8) & 0xFF
|
|
self._buf[self._pos + 1] = v & 0xFF
|
|
self._pos += 2
|
|
|
|
def write_i4(self, v):
|
|
self._grow(4)
|
|
struct.pack_into(">i", self._buf, self._pos, v)
|
|
self._pos += 4
|
|
|
|
def write_i8(self, v):
|
|
self._grow(8)
|
|
struct.pack_into(">q", self._buf, self._pos, v)
|
|
self._pos += 8
|
|
|
|
def write_f4(self, v):
|
|
self._grow(4)
|
|
struct.pack_into(">f", self._buf, self._pos, v)
|
|
self._pos += 4
|
|
|
|
def write_f8(self, v):
|
|
self._grow(8)
|
|
struct.pack_into(">d", self._buf, self._pos, v)
|
|
self._pos += 8
|
|
|
|
def write_str(self, s):
|
|
encoded = s.encode("utf-8")
|
|
self._grow(len(encoded) + 1)
|
|
self._buf[self._pos:self._pos + len(encoded)] = encoded
|
|
self._pos += len(encoded)
|
|
self._buf[self._pos] = 0
|
|
self._pos += 1
|
|
|
|
def write_bytes(self, data):
|
|
self._grow(len(data))
|
|
self._buf[self._pos:self._pos + len(data)] = data
|
|
self._pos += len(data)
|
|
|
|
# ── Payload ─────────────────────────────────────────────────
|
|
|
|
def to_bytes(self):
|
|
return bytes(self._buf[:self._size])
|
|
|
|
@property
|
|
def command(self):
|
|
return self._buf[0]
|
|
|
|
@property
|
|
def reply_num(self):
|
|
return self._buf[1] & 0xFF
|
|
|
|
@reply_num.setter
|
|
def reply_num(self, v):
|
|
self._buf[1] = v & 0xFF
|
|
|
|
def check_response(self, expected_cmd):
|
|
cmd = self.u1()
|
|
_reply = self.u1()
|
|
if cmd == ord('!'):
|
|
cause = self.str()
|
|
raise SoxException(f"Request failed: {cause}")
|
|
if cmd != ord(expected_cmd):
|
|
raise SoxException(
|
|
f"Response {chr(cmd)}({cmd}) != expected {expected_cmd}({ord(expected_cmd)})")
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# Value codec — encode/decode Sedona property values
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
def decode_value(msg, type_id):
|
|
if type_id == BOOL_ID:
|
|
return msg.u1() != 0
|
|
elif type_id == BYTE_ID:
|
|
return msg.u1()
|
|
elif type_id == SHORT_ID:
|
|
return msg.u2()
|
|
elif type_id == INT_ID:
|
|
return msg.i4()
|
|
elif type_id == LONG_ID:
|
|
return msg.i8()
|
|
elif type_id == FLOAT_ID:
|
|
return msg.f4()
|
|
elif type_id == DOUBLE_ID:
|
|
return msg.f8()
|
|
elif type_id == STR_ID:
|
|
size = msg.u2()
|
|
if size == 0:
|
|
return ""
|
|
chars = msg.read_bytes(size - 1).decode("utf-8", errors="replace")
|
|
msg.u1() # null terminator
|
|
return chars
|
|
elif type_id == BUF_ID:
|
|
size = msg.u2()
|
|
return msg.read_bytes(size)
|
|
else:
|
|
raise SoxException(f"Unknown type id: {type_id}")
|
|
|
|
|
|
def encode_value(msg, type_id, value):
|
|
if type_id == BOOL_ID:
|
|
msg.write_u1(1 if value else 0)
|
|
elif type_id == BYTE_ID:
|
|
msg.write_u1(value & 0xFF)
|
|
elif type_id == SHORT_ID:
|
|
msg.write_u2(value & 0xFFFF)
|
|
elif type_id == INT_ID:
|
|
msg.write_i4(value)
|
|
elif type_id == LONG_ID:
|
|
msg.write_i8(value)
|
|
elif type_id == FLOAT_ID:
|
|
msg.write_f4(value)
|
|
elif type_id == DOUBLE_ID:
|
|
msg.write_f8(value)
|
|
elif type_id == STR_ID:
|
|
encoded = value.encode("utf-8") if isinstance(value, str) else value
|
|
msg.write_u2(len(encoded) + 1)
|
|
msg.write_bytes(encoded)
|
|
msg.write_u1(0)
|
|
elif type_id == BUF_ID:
|
|
msg.write_u2(len(value))
|
|
msg.write_bytes(value)
|
|
else:
|
|
raise SoxException(f"Unknown type id: {type_id}")
|
|
|
|
|
|
TYPE_ID_BY_NAME = {
|
|
"bool": BOOL_ID, "byte": BYTE_ID, "short": SHORT_ID,
|
|
"int": INT_ID, "long": LONG_ID, "float": FLOAT_ID,
|
|
"double": DOUBLE_ID, "buf": BUF_ID, "Buf": BUF_ID,
|
|
}
|
|
|
|
|
|
def type_id_for_slot(slot_type_name):
|
|
if slot_type_name in TYPE_ID_BY_NAME:
|
|
return TYPE_ID_BY_NAME[slot_type_name]
|
|
return STR_ID # asStr slots
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# Link
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
class Link:
|
|
__slots__ = ("from_comp_id", "from_slot_id", "to_comp_id", "to_slot_id")
|
|
|
|
def __init__(self, from_comp_id=0, from_slot_id=0, to_comp_id=0, to_slot_id=0):
|
|
self.from_comp_id = from_comp_id
|
|
self.from_slot_id = from_slot_id
|
|
self.to_comp_id = to_comp_id
|
|
self.to_slot_id = to_slot_id
|
|
|
|
def __repr__(self):
|
|
return (f"Link({self.from_comp_id}:{self.from_slot_id}"
|
|
f" -> {self.to_comp_id}:{self.to_slot_id})")
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# SoxComponent — cached remote component
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
class SoxComponent:
|
|
def __init__(self, comp_id, kit_id=-1, type_id=-1, type_name=""):
|
|
self.id = comp_id
|
|
self.kit_id = kit_id
|
|
self.type_id = type_id
|
|
self.type_name = type_name # "kit::Type" qualified name
|
|
self.name = ""
|
|
self.parent = -1
|
|
self.children = [] # list of child comp ids
|
|
self.permissions = 0
|
|
self.subscription = 0 # bitmask of subscribed categories
|
|
self.links = [] # list of Link objects
|
|
self.values = {} # slot_name -> value (config + runtime)
|
|
|
|
def __repr__(self):
|
|
return f"SoxComponent(id={self.id}, name={self.name!r}, type={self.type_name})"
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# KitPart — kit name + checksum from schema read
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
class KitPart:
|
|
__slots__ = ("name", "checksum")
|
|
|
|
def __init__(self, name, checksum):
|
|
self.name = name
|
|
self.checksum = checksum
|
|
|
|
def __repr__(self):
|
|
return f"{self.name}-{self.checksum & 0xFFFFFFFF:08x}"
|
|
|
|
|
|
class VersionInfo:
|
|
def __init__(self, platform_id, scode_flags, kit_versions, props=None):
|
|
self.platform_id = platform_id
|
|
self.scode_flags = scode_flags
|
|
self.kit_versions = kit_versions # list of (kit_name, checksum, version_str)
|
|
self.props = props or {} # extra props like "soxVer"
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# SoxClient — Sox protocol client with component cache
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
class SoxClient:
|
|
"""
|
|
Sox client with component cache, request/response correlation,
|
|
batch subscribe, and COV event dispatch.
|
|
"""
|
|
|
|
def __init__(self, host, port, user, password, **dasp_options):
|
|
self.host = host
|
|
self.port = port
|
|
self.user = user
|
|
self.password = password
|
|
self.dasp_options = dasp_options
|
|
|
|
self._dasp_socket = None
|
|
self._session = None
|
|
self._receiver = None
|
|
self._lock = threading.Lock()
|
|
|
|
# request/response correlation
|
|
self._requests = None # list[SoxMsg] current batch
|
|
self._responses = None # list[SoxMsg|None] current batch
|
|
self._req_event = threading.Event()
|
|
|
|
# cache
|
|
self.cache = [None] * 1024 # SoxComponent[] indexed by comp_id
|
|
self.all_tree_events = False
|
|
|
|
# schema
|
|
self.schema_parts = None # list[KitPart]
|
|
self.version_info = None # VersionInfo
|
|
self.schema = None # will hold schema reference from editor
|
|
|
|
# slot registry — set externally after readSchema
|
|
# maps (kit_id, type_id) -> list of slot dicts
|
|
# each slot dict: {name, id, type_name, is_config, is_runtime, is_action, is_operator, is_as_str}
|
|
self.slot_registry = {}
|
|
|
|
# fn(kit_name, prefix_bytes) -> signed i4 checksum or None; recovers a
|
|
# checksum the device's reply truncated. Set externally by device_session.
|
|
self.checksum_resolver = None
|
|
|
|
# type name map — set externally by device_session
|
|
# maps (kit_id, type_id) -> "kit::TypeName" for proper type resolution
|
|
self.type_name_map = {}
|
|
|
|
# event callback: called on main thread via queue
|
|
self.on_event = None # fn(comp_id, what_char)
|
|
|
|
# ── Connect / Disconnect ────────────────────────────────────
|
|
|
|
def connect(self):
|
|
self._dasp_socket = DaspSocket()
|
|
self._session = self._dasp_socket.connect(
|
|
self.host, self.port, self.user, self.password,
|
|
**self.dasp_options)
|
|
self._receiver = _SoxReceiver(self)
|
|
self._receiver.start()
|
|
|
|
def close(self):
|
|
if self._receiver:
|
|
self._receiver.kill()
|
|
self._receiver = None
|
|
if self._session and not self._session.is_closed:
|
|
try:
|
|
self._session.close()
|
|
except Exception:
|
|
pass
|
|
if self._dasp_socket:
|
|
try:
|
|
self._dasp_socket.close()
|
|
except Exception:
|
|
pass
|
|
self._session = None
|
|
self._dasp_socket = None
|
|
self.cache = [None] * 1024
|
|
self.all_tree_events = False
|
|
|
|
@property
|
|
def is_closed(self):
|
|
return self._session is None or self._session.is_closed
|
|
|
|
# ── Read Schema ─────────────────────────────────────────────
|
|
|
|
def read_schema(self):
|
|
if self.schema_parts is not None:
|
|
return self.schema_parts
|
|
req = _prepare_request(ord('v'))
|
|
res = self.request(req)
|
|
res.check_response('V')
|
|
num = res.u1()
|
|
parts = []
|
|
# The device builds this reply in a fixed 512-byte packet and silently
|
|
# drops whatever does not fit, so a device with many kits can cut the
|
|
# list short — sometimes mid-checksum of the last kit.
|
|
for _ in range(num):
|
|
name = res.try_str()
|
|
if name is None:
|
|
break
|
|
if res.remaining >= 4:
|
|
checksum = res.i4()
|
|
else:
|
|
prefix = res.read_bytes(res.remaining)
|
|
checksum = (self.checksum_resolver(name, prefix)
|
|
if self.checksum_resolver else None)
|
|
if checksum is None:
|
|
break
|
|
parts.append(KitPart(name, checksum))
|
|
if len(parts) < num:
|
|
raise SoxException(
|
|
f"The device lists {num} kits but its reply was cut off after "
|
|
f"{len(parts)} — the list does not fit in the device's 512-byte "
|
|
f"Sox packet. Remove unused kits from the device's scode, or "
|
|
f"shorten kit names.")
|
|
self.schema_parts = parts
|
|
return parts
|
|
|
|
def read_version(self):
|
|
if self.version_info is not None:
|
|
return self.version_info
|
|
parts = self.read_schema()
|
|
req = _prepare_request(ord('y'))
|
|
res = self.request(req)
|
|
res.check_response('Y')
|
|
# Same 512-byte limit as read_schema: whatever is cut off is left
|
|
# blank rather than failing the connect.
|
|
platform_id = res.try_str() or ""
|
|
scode_flags = res.u1() if res.remaining > 0 else 0
|
|
kit_versions = []
|
|
for p in parts:
|
|
ver = res.try_str()
|
|
kit_versions.append((p.name, p.checksum, ver or ""))
|
|
vi = VersionInfo(platform_id, scode_flags, kit_versions)
|
|
prop_num = res.u1() if res.remaining > 0 else 0
|
|
for _ in range(prop_num):
|
|
k = res.try_str()
|
|
v = res.try_str()
|
|
if k is None or v is None:
|
|
break
|
|
vi.props[k] = v
|
|
self.version_info = vi
|
|
return vi
|
|
|
|
@property
|
|
def sox_version(self):
|
|
try:
|
|
vi = self.read_version()
|
|
return vi.props.get("soxVer")
|
|
except Exception:
|
|
return None
|
|
|
|
# ── Load Component (Tree) ───────────────────────────────────
|
|
|
|
def load_app(self):
|
|
return self.load(0)
|
|
|
|
def load(self, comp_id):
|
|
if comp_id < 0:
|
|
return None
|
|
cached = self.cache_get(comp_id)
|
|
if cached is not None:
|
|
return cached
|
|
results = self.load_ids([comp_id])
|
|
return results[0] if results else None
|
|
|
|
def load_ids(self, ids, checked=True):
|
|
self.read_schema()
|
|
results = [None] * len(ids)
|
|
reqs = []
|
|
req_indices = []
|
|
|
|
for i, cid in enumerate(ids):
|
|
c = self.cache_get(cid)
|
|
if c is not None:
|
|
results[i] = c
|
|
else:
|
|
reqs.append(_make_update_req(cid, ord('t')))
|
|
req_indices.append(i)
|
|
|
|
if not reqs:
|
|
return results
|
|
|
|
responses = self.requests(reqs)
|
|
for res in responses:
|
|
res.check_response('C')
|
|
self._apply_to_cache(res)
|
|
|
|
for i, cid in enumerate(ids):
|
|
results[i] = self.cache_get(cid)
|
|
if checked and results[i] is None:
|
|
raise SoxException(f"Failed to load component {cid}")
|
|
|
|
return results
|
|
|
|
# ── Update (config / runtime / links) ───────────────────────
|
|
|
|
def update(self, comps, mask):
|
|
reqs = []
|
|
for c in comps:
|
|
if mask & TREE:
|
|
reqs.append(_make_update_req(c.id, ord('t')))
|
|
if mask & CONFIG:
|
|
reqs.append(_make_update_req(c.id, ord('c')))
|
|
if mask & RUNTIME:
|
|
reqs.append(_make_update_req(c.id, ord('r')))
|
|
if mask & LINKS:
|
|
reqs.append(_make_update_req(c.id, ord('l')))
|
|
if not reqs:
|
|
return
|
|
responses = self.requests(reqs)
|
|
for res in responses:
|
|
res.check_response('C')
|
|
self._apply_to_cache(res)
|
|
|
|
# ── Subscribe / Unsubscribe ─────────────────────────────────
|
|
|
|
def subscribe(self, comps, mask):
|
|
if self.sox_version is not None:
|
|
self._batch_subscribe(comps, mask)
|
|
else:
|
|
self._do_subscribe(comps, mask)
|
|
|
|
def _batch_subscribe(self, comps, mask):
|
|
to_sub = [c for c in comps if (c.subscription & mask) != mask]
|
|
if not to_sub:
|
|
return
|
|
req = _prepare_request(ord('s'))
|
|
req.write_u1(mask)
|
|
req.write_u1(len(to_sub))
|
|
for c in to_sub:
|
|
req.write_u2(c.id)
|
|
res = self.request(req)
|
|
res.check_response('S')
|
|
for c in to_sub:
|
|
c.subscription |= mask
|
|
|
|
def _do_subscribe(self, comps, mask):
|
|
reqs = []
|
|
for c in comps:
|
|
cur = c.subscription
|
|
need = mask & ~cur
|
|
if need & TREE:
|
|
reqs.append(_make_subscribe_req(c.id, ord('t')))
|
|
if need & CONFIG:
|
|
reqs.append(_make_subscribe_req(c.id, ord('c')))
|
|
if need & RUNTIME:
|
|
reqs.append(_make_subscribe_req(c.id, ord('r')))
|
|
if need & LINKS:
|
|
reqs.append(_make_subscribe_req(c.id, ord('l')))
|
|
if not reqs:
|
|
return
|
|
responses = self.requests(reqs)
|
|
for res in responses:
|
|
res.check_response('S')
|
|
self._apply_to_cache(res)
|
|
for c in comps:
|
|
c.subscription |= mask
|
|
|
|
def subscribe_all_tree_events(self):
|
|
if self.sox_version is not None:
|
|
req = _prepare_request(ord('s'))
|
|
req.write_u1(0xFF)
|
|
req.write_u1(0)
|
|
self.request(req)
|
|
else:
|
|
self.request(_make_subscribe_req(0, ord('a')))
|
|
self.all_tree_events = True
|
|
for c in self.cache:
|
|
if c is not None:
|
|
c.subscription |= TREE
|
|
|
|
def unsubscribe(self, comps, mask):
|
|
if self.is_closed:
|
|
return
|
|
if self.all_tree_events:
|
|
mask &= ~TREE
|
|
to_unsub = [c for c in comps if (c.subscription & mask) != 0]
|
|
if not to_unsub:
|
|
return
|
|
if self.sox_version is not None:
|
|
self._batch_unsubscribe(to_unsub, mask)
|
|
else:
|
|
self._do_unsubscribe(to_unsub, mask)
|
|
for c in comps:
|
|
c.subscription &= ~mask
|
|
|
|
def _batch_unsubscribe(self, comps, mask):
|
|
req = _prepare_request(ord('u'))
|
|
req.write_u1(mask)
|
|
req.write_u1(len(comps))
|
|
for c in comps:
|
|
req.write_u2(c.id)
|
|
self.request(req)
|
|
|
|
def _do_unsubscribe(self, comps, mask):
|
|
reqs = []
|
|
for c in comps:
|
|
reqs.append(_make_unsubscribe_req(c.id, mask))
|
|
self.requests(reqs)
|
|
|
|
# ── Read Property ───────────────────────────────────────────
|
|
|
|
def read_prop(self, comp_id, slot_id, type_id):
|
|
req = _prepare_request(ord('r'))
|
|
req.write_u2(comp_id)
|
|
req.write_u1(slot_id)
|
|
res = self.request(req)
|
|
res.check_response('R')
|
|
res.u2() # comp_id
|
|
res.u1() # slot_id
|
|
res_type_id = res.u1()
|
|
return decode_value(res, res_type_id)
|
|
|
|
# ── Write Property ──────────────────────────────────────────
|
|
|
|
def write_prop(self, comp_id, slot_id, type_id, value):
|
|
req = _prepare_request(ord('w'))
|
|
req.write_u2(comp_id)
|
|
req.write_u1(slot_id)
|
|
encode_value(req, type_id, value)
|
|
res = self.request(req)
|
|
res.check_response('W')
|
|
|
|
# ── Invoke Action ───────────────────────────────────────────
|
|
|
|
def invoke(self, comp_id, slot_id, type_id=None, arg=None):
|
|
req = _prepare_request(ord('i'))
|
|
req.write_u2(comp_id)
|
|
req.write_u1(slot_id)
|
|
if arg is not None and type_id is not None:
|
|
encode_value(req, type_id, arg)
|
|
res = self.request(req)
|
|
res.check_response('I')
|
|
|
|
# ── Add Component ───────────────────────────────────────────
|
|
|
|
def add(self, parent_id, kit_id, type_id_val, name, config_values=None):
|
|
req = _prepare_request(ord('a'))
|
|
req.write_u2(parent_id)
|
|
req.write_u1(kit_id)
|
|
req.write_u1(type_id_val)
|
|
req.write_str(name)
|
|
if config_values:
|
|
for tid, val in config_values:
|
|
encode_value(req, tid, val)
|
|
res = self.request(req)
|
|
res.check_response('A')
|
|
new_id = res.u2()
|
|
|
|
comp = SoxComponent(new_id, kit_id, type_id_val)
|
|
comp.name = name
|
|
comp.parent = parent_id
|
|
comp.children = []
|
|
comp.type_name = self._resolve_type_name(kit_id, type_id_val)
|
|
self.cache_add(comp)
|
|
|
|
parent = self.cache_get(parent_id)
|
|
if parent is not None and new_id not in parent.children:
|
|
parent.children.append(new_id)
|
|
|
|
return comp
|
|
|
|
# ── Delete Component ────────────────────────────────────────
|
|
|
|
def delete(self, comp_id):
|
|
req = _prepare_request(ord('d'))
|
|
req.write_u2(comp_id)
|
|
res = self.request(req)
|
|
res.check_response('D')
|
|
|
|
comp = self.cache_get(comp_id)
|
|
if comp is not None:
|
|
parent = self.cache_get(comp.parent)
|
|
if parent is not None and comp_id in parent.children:
|
|
parent.children.remove(comp_id)
|
|
self.cache_remove(comp)
|
|
|
|
# ── Rename Component ────────────────────────────────────────
|
|
|
|
def rename(self, comp_id, new_name):
|
|
req = _prepare_request(ord('n'))
|
|
req.write_u2(comp_id)
|
|
req.write_str(new_name)
|
|
res = self.request(req)
|
|
res.check_response('N')
|
|
comp = self.cache_get(comp_id)
|
|
if comp is not None:
|
|
comp.name = new_name
|
|
|
|
# ── Reorder Children ────────────────────────────────────────
|
|
|
|
def reorder(self, comp_id, child_ids):
|
|
req = _prepare_request(ord('o'))
|
|
req.write_u2(comp_id)
|
|
req.write_u1(len(child_ids))
|
|
for cid in child_ids:
|
|
req.write_u2(cid)
|
|
res = self.request(req)
|
|
res.check_response('O')
|
|
comp = self.cache_get(comp_id)
|
|
if comp is not None:
|
|
comp.children = list(child_ids)
|
|
|
|
# ── Link / Unlink ───────────────────────────────────────────
|
|
|
|
def link(self, from_comp_id, from_slot_id, to_comp_id, to_slot_id):
|
|
self._link_cmd(ord('a'), from_comp_id, from_slot_id, to_comp_id, to_slot_id)
|
|
|
|
def unlink(self, from_comp_id, from_slot_id, to_comp_id, to_slot_id):
|
|
self._link_cmd(ord('d'), from_comp_id, from_slot_id, to_comp_id, to_slot_id)
|
|
|
|
def _link_cmd(self, subcmd, from_comp_id, from_slot_id, to_comp_id, to_slot_id):
|
|
req = _prepare_request(ord('l'))
|
|
req.write_u1(subcmd)
|
|
req.write_u2(from_comp_id)
|
|
req.write_u1(from_slot_id)
|
|
req.write_u2(to_comp_id)
|
|
req.write_u1(to_slot_id)
|
|
res = self.request(req)
|
|
res.check_response('L')
|
|
|
|
# ── Links snapshot ──────────────────────────────────────────
|
|
|
|
def read_links(self, comp_id):
|
|
req = _prepare_request(ord('c'))
|
|
req.write_u2(comp_id)
|
|
req.write_u1(ord('l'))
|
|
res = self.request(req)
|
|
res.check_response('C')
|
|
res_comp_id = res.u2()
|
|
what = res.u1()
|
|
links = []
|
|
while True:
|
|
from_comp = res.u2()
|
|
if from_comp == 0xFFFF:
|
|
break
|
|
from_slot = res.u1()
|
|
to_comp = res.u2()
|
|
to_slot = res.u1()
|
|
links.append(Link(from_comp, from_slot, to_comp, to_slot))
|
|
return links
|
|
|
|
# ── Cache Management ────────────────────────────────────────
|
|
|
|
def cache_get(self, comp_id):
|
|
if 0 <= comp_id < len(self.cache):
|
|
return self.cache[comp_id]
|
|
return None
|
|
|
|
def cache_add(self, comp):
|
|
if comp.id >= len(self.cache):
|
|
new_size = max(len(self.cache) * 2, comp.id + 32)
|
|
self.cache.extend([None] * (new_size - len(self.cache)))
|
|
self.cache[comp.id] = comp
|
|
|
|
def cache_remove(self, comp):
|
|
for child_id in list(comp.children):
|
|
child = self.cache_get(child_id)
|
|
if child is not None:
|
|
self.cache_remove(child)
|
|
if 0 <= comp.id < len(self.cache):
|
|
self.cache[comp.id] = None
|
|
|
|
# ── Apply to Cache ──────────────────────────────────────────
|
|
|
|
def _apply_to_cache(self, msg, is_event=False):
|
|
comp_id = msg.u2()
|
|
what = msg.u1()
|
|
cached = self.cache_get(comp_id)
|
|
|
|
if cached is None:
|
|
if what == ord('t'):
|
|
cached = self._apply_tree_new(msg, comp_id)
|
|
self.cache_add(cached)
|
|
else:
|
|
return
|
|
else:
|
|
if what == ord('t'):
|
|
self._apply_tree_cached(msg, cached)
|
|
elif what in (ord('c'), ord('C')):
|
|
self._apply_props(msg, cached, 'c', what == ord('C'))
|
|
elif what in (ord('r'), ord('R')):
|
|
self._apply_props(msg, cached, 'r', what == ord('R'))
|
|
elif what == ord('l'):
|
|
self._apply_links(msg, cached)
|
|
|
|
if is_event and cached is not None:
|
|
mask = _what_to_mask(what)
|
|
if mask and not (cached.subscription & mask):
|
|
cached.subscription |= mask
|
|
if self.on_event:
|
|
self.on_event(comp_id, chr(what))
|
|
|
|
def _apply_tree_new(self, msg, comp_id):
|
|
kit_id = msg.u1()
|
|
type_id_val = msg.u1()
|
|
type_name = self._resolve_type_name(kit_id, type_id_val)
|
|
comp = SoxComponent(comp_id, kit_id, type_id_val, type_name)
|
|
self._apply_tree_body(msg, comp)
|
|
return comp
|
|
|
|
def _apply_tree_cached(self, msg, comp):
|
|
_kit_id = msg.u1()
|
|
_type_id_val = msg.u1()
|
|
self._apply_tree_body(msg, comp)
|
|
|
|
def _apply_tree_body(self, msg, comp):
|
|
comp.name = msg.str()
|
|
comp.parent = msg.u2()
|
|
comp.permissions = msg.u1()
|
|
num_children = msg.u1()
|
|
new_children = [msg.u2() for _ in range(num_children)]
|
|
|
|
old_set = set(comp.children) if comp.children else set()
|
|
new_set = set(new_children)
|
|
for removed_id in old_set - new_set:
|
|
removed = self.cache_get(removed_id)
|
|
if removed is not None:
|
|
self.cache_remove(removed)
|
|
|
|
comp.children = new_children
|
|
|
|
def _apply_props(self, msg, comp, what_char, operator_only):
|
|
slots = self._get_slots(comp.kit_id, comp.type_id)
|
|
if slots is None:
|
|
return
|
|
for slot in slots:
|
|
if not slot.get("is_prop", True):
|
|
continue
|
|
if slot.get("is_action"):
|
|
continue
|
|
if operator_only and not slot.get("is_operator"):
|
|
continue
|
|
is_config = slot.get("is_config", False)
|
|
if what_char == 'c' and not is_config:
|
|
continue
|
|
if what_char == 'r' and is_config:
|
|
continue
|
|
tid = type_id_for_slot(slot.get("type_name", "int"))
|
|
try:
|
|
val = decode_value(msg, tid)
|
|
comp.values[slot["name"]] = val
|
|
except Exception:
|
|
break
|
|
|
|
def _apply_links(self, msg, comp):
|
|
links = []
|
|
while msg.remaining >= 6:
|
|
from_comp = msg.u2()
|
|
if from_comp == 0xFFFF:
|
|
break
|
|
from_slot = msg.u1()
|
|
to_comp = msg.u2()
|
|
to_slot = msg.u1()
|
|
links.append(Link(from_comp, from_slot, to_comp, to_slot))
|
|
comp.links = links
|
|
|
|
def _resolve_type_name(self, kit_id, type_id_val):
|
|
resolved = self.type_name_map.get((kit_id, type_id_val))
|
|
if resolved:
|
|
return resolved
|
|
if self.schema_parts and 0 <= kit_id < len(self.schema_parts):
|
|
kit_name = self.schema_parts[kit_id].name
|
|
else:
|
|
kit_name = f"kit{kit_id}"
|
|
return f"{kit_name}::type{type_id_val}"
|
|
|
|
def _get_slots(self, kit_id, type_id_val):
|
|
return self.slot_registry.get((kit_id, type_id_val))
|
|
|
|
# ── Networking ──────────────────────────────────────────────
|
|
|
|
def request(self, req):
|
|
results = self.requests([req])
|
|
return results[0]
|
|
|
|
def requests(self, reqs):
|
|
if self.is_closed:
|
|
raise SoxException("SoxClient closed")
|
|
|
|
if len(reqs) > 255:
|
|
return self._chunk_requests(reqs)
|
|
|
|
with self._lock:
|
|
for i, req in enumerate(reqs):
|
|
req.reply_num = i
|
|
|
|
self._responses = [None] * len(reqs)
|
|
self._requests = reqs
|
|
self._req_event.clear()
|
|
|
|
for req in reqs:
|
|
self._session.send(req.to_bytes())
|
|
|
|
deadline = time.monotonic() + 30.0
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
break
|
|
|
|
if all(r is not None for r in self._responses):
|
|
res = list(self._responses)
|
|
self._requests = None
|
|
self._responses = None
|
|
return res
|
|
|
|
self._req_event.wait(timeout=min(remaining, 0.5))
|
|
self._req_event.clear()
|
|
|
|
if self.is_closed:
|
|
raise SoxException("Session closed while waiting for response")
|
|
|
|
self._requests = None
|
|
self._responses = None
|
|
raise SoxException("Request timeout")
|
|
|
|
def _chunk_requests(self, reqs):
|
|
results = []
|
|
for i in range(0, len(reqs), 255):
|
|
chunk = reqs[i:i + 255]
|
|
results.extend(self.requests(chunk))
|
|
return results
|
|
|
|
# called by receiver thread
|
|
def _receive_response(self, msg):
|
|
reqs = self._requests
|
|
resps = self._responses
|
|
if reqs is None or resps is None:
|
|
return
|
|
|
|
cmd = msg.command
|
|
reply_num = msg.reply_num
|
|
index = reply_num & 0xFF
|
|
|
|
if index < 0 or index >= len(reqs):
|
|
return
|
|
|
|
req_cmd = reqs[index].command
|
|
if cmd != ord('!') and cmd != (req_cmd & ~0x20):
|
|
return
|
|
|
|
resps[index] = msg
|
|
self._req_event.set()
|
|
|
|
# called by receiver thread for events
|
|
def _receive_event(self, msg):
|
|
_cmd = msg.u1()
|
|
_reply = msg.u1()
|
|
try:
|
|
self._apply_to_cache(msg, is_event=True)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# SoxReceiver — daemon thread
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
class _SoxReceiver(threading.Thread):
|
|
def __init__(self, client):
|
|
super().__init__(name="SoxReceiver", daemon=True)
|
|
self._client = client
|
|
self._alive = True
|
|
|
|
def kill(self):
|
|
self._alive = False
|
|
|
|
def run(self):
|
|
client = self._client
|
|
while self._alive and not client.is_closed:
|
|
try:
|
|
dasp_msg = client._session.receive(timeout=1.0)
|
|
if dasp_msg is None:
|
|
continue
|
|
payload = dasp_msg.payload
|
|
if not payload or len(payload) < 2:
|
|
continue
|
|
msg = SoxMsg(payload)
|
|
cmd = payload[0]
|
|
if cmd == ord('e'):
|
|
client._receive_event(msg)
|
|
elif cmd == ord('k') or cmd == ord('z'):
|
|
pass # file transfer — not needed for editor
|
|
else:
|
|
client._receive_response(msg)
|
|
except DaspException:
|
|
if self._alive:
|
|
break
|
|
except Exception:
|
|
if self._alive and not client.is_closed:
|
|
continue
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# Message factories
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
def _prepare_request(cmd, reply_num=0xFF):
|
|
msg = SoxMsg()
|
|
msg.write_u1(cmd)
|
|
msg.write_u1(reply_num)
|
|
return msg
|
|
|
|
|
|
def _make_update_req(comp_id, what):
|
|
req = _prepare_request(ord('c'))
|
|
req.write_u2(comp_id)
|
|
req.write_u1(what)
|
|
return req
|
|
|
|
|
|
def _make_subscribe_req(comp_id, what):
|
|
req = _prepare_request(ord('s'))
|
|
req.write_u2(comp_id)
|
|
req.write_u1(what)
|
|
return req
|
|
|
|
|
|
def _make_unsubscribe_req(comp_id, mask):
|
|
req = _prepare_request(ord('u'))
|
|
req.write_u2(comp_id)
|
|
req.write_u1(mask)
|
|
return req
|
|
|
|
|
|
def _what_to_mask(what):
|
|
if what == ord('t'):
|
|
return TREE
|
|
if what in (ord('c'), ord('C')):
|
|
return CONFIG
|
|
if what in (ord('r'), ord('R')):
|
|
return RUNTIME
|
|
if what == ord('l'):
|
|
return LINKS
|
|
return 0
|