Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
599370f60c | ||
|
|
a0e5ec1a31 | ||
|
|
b4a504e57d | ||
|
|
0674f32637 | ||
|
|
d32285abe8 | ||
|
|
67c403339c | ||
|
|
1fd6e72b8c | ||
|
|
643bcdc952 | ||
|
|
4431f11d83 | ||
|
|
dafebcebda |
+20
-4
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
|
||||
import configparser
|
||||
import os
|
||||
|
||||
VERSION = "0.0.0.072"
|
||||
VERSION = "0.0.0.083"
|
||||
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
||||
DEFAULT_GRID_SIZE = 10
|
||||
DEFAULT_ADD_TIMESTAMP = True
|
||||
@@ -111,6 +111,7 @@ def load_preferences():
|
||||
box_width = DEFAULT_BOX_WIDTH
|
||||
left_pane_width = DEFAULT_LEFT_PANE_WIDTH
|
||||
sedona_home = ""
|
||||
trace_dasp = False
|
||||
|
||||
if os.path.exists(PROPERTIES_FILE):
|
||||
try:
|
||||
@@ -122,6 +123,7 @@ def load_preferences():
|
||||
"Editor", "left_pane_width", fallback=DEFAULT_LEFT_PANE_WIDTH
|
||||
)
|
||||
sedona_home = config.get("Editor", "sedona.home", fallback="").strip()
|
||||
trace_dasp = config.getboolean("Editor", "trace_dasp", fallback=False)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
@@ -142,7 +144,7 @@ def load_preferences():
|
||||
left_pane_width = max(
|
||||
MIN_ALLOWED_LEFT_PANE_WIDTH, min(MAX_ALLOWED_LEFT_PANE_WIDTH, left_pane_width)
|
||||
)
|
||||
return sedona_home, grid_size, add_timestamp, box_width, left_pane_width
|
||||
return sedona_home, grid_size, add_timestamp, box_width, left_pane_width, trace_dasp
|
||||
|
||||
|
||||
def save_preferences():
|
||||
@@ -153,7 +155,8 @@ def save_preferences():
|
||||
"grid_size": str(GRID_SIZE),
|
||||
"add_timestamp": str(ADD_TIMESTAMP),
|
||||
"box_width": str(BOX_WIDTH),
|
||||
"left_pane_width": str(LEFT_PANE_WIDTH)
|
||||
"left_pane_width": str(LEFT_PANE_WIDTH),
|
||||
"trace_dasp": str(TRACE_DASP),
|
||||
}
|
||||
with open(PROPERTIES_FILE, "w") as configfile:
|
||||
config.write(configfile)
|
||||
@@ -204,4 +207,17 @@ def mark_clean():
|
||||
refresh_dirty_display()
|
||||
|
||||
|
||||
SEDONA_HOME, GRID_SIZE, ADD_TIMESTAMP, BOX_WIDTH, LEFT_PANE_WIDTH = load_preferences()
|
||||
# ── Live device connection state ────────────────────────────────
|
||||
live_mode = False
|
||||
device_session = None # device_session.DeviceSession instance
|
||||
device_host = ""
|
||||
device_port = 1876
|
||||
connection_status = "offline" # offline | connecting | connected | error
|
||||
|
||||
|
||||
def base_window_title_live():
|
||||
"""Window title for live mode."""
|
||||
return f"Sedona Live Editor [v{VERSION}] — {device_host}:{device_port}"
|
||||
|
||||
|
||||
SEDONA_HOME, GRID_SIZE, ADD_TIMESTAMP, BOX_WIDTH, LEFT_PANE_WIDTH, TRACE_DASP = load_preferences()
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Connection dialog for connecting to a live Sedona device.
|
||||
|
||||
Modal Toplevel with host, port, username, password fields.
|
||||
Connect runs in a background thread; progress and errors shown inline.
|
||||
"""
|
||||
|
||||
import configparser
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
import app_state
|
||||
|
||||
|
||||
class ConnectDialog:
|
||||
"""Modal dialog for entering device connection parameters."""
|
||||
|
||||
def __init__(self, parent, on_connect_callback):
|
||||
self._callback = on_connect_callback
|
||||
self._thread = None
|
||||
self._cancelled = False
|
||||
|
||||
self._dlg = tk.Toplevel(parent)
|
||||
self._dlg.title("Connect to Device")
|
||||
self._dlg.resizable(False, False)
|
||||
self._dlg.transient(parent)
|
||||
self._dlg.grab_set()
|
||||
|
||||
host, port, user = _load_last_connection()
|
||||
|
||||
frame = ttk.Frame(self._dlg, padding=16)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
row = 0
|
||||
ttk.Label(frame, text="Host:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
|
||||
self._host_var = tk.StringVar(value=host)
|
||||
self._host_entry = ttk.Entry(frame, textvariable=self._host_var, width=28)
|
||||
self._host_entry.grid(row=row, column=1, columnspan=2, sticky="ew", pady=4)
|
||||
|
||||
row += 1
|
||||
ttk.Label(frame, text="Port:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
|
||||
self._port_var = tk.StringVar(value=port)
|
||||
ttk.Entry(frame, textvariable=self._port_var, width=8).grid(
|
||||
row=row, column=1, sticky="w", pady=4)
|
||||
|
||||
row += 1
|
||||
ttk.Label(frame, text="Username:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
|
||||
self._user_var = tk.StringVar(value=user)
|
||||
ttk.Entry(frame, textvariable=self._user_var, width=28).grid(
|
||||
row=row, column=1, columnspan=2, sticky="ew", pady=4)
|
||||
|
||||
row += 1
|
||||
ttk.Label(frame, text="Password:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
|
||||
self._pass_var = tk.StringVar()
|
||||
ttk.Entry(frame, textvariable=self._pass_var, width=28, show="*").grid(
|
||||
row=row, column=1, columnspan=2, sticky="ew", pady=4)
|
||||
|
||||
row += 1
|
||||
self._status_label = ttk.Label(frame, text="", foreground="#666666")
|
||||
self._status_label.grid(row=row, column=0, columnspan=3, sticky="w", pady=(8, 4))
|
||||
|
||||
row += 1
|
||||
btn_frame = ttk.Frame(frame)
|
||||
btn_frame.grid(row=row, column=0, columnspan=3, sticky="e", pady=(8, 0))
|
||||
|
||||
self._btn_cancel = ttk.Button(btn_frame, text="Cancel", command=self._do_cancel)
|
||||
self._btn_cancel.pack(side=tk.RIGHT)
|
||||
|
||||
self._btn_connect = ttk.Button(btn_frame, text="Connect", command=self._do_connect)
|
||||
self._btn_connect.pack(side=tk.RIGHT, padx=(0, 8))
|
||||
|
||||
self._host_entry.focus_set()
|
||||
self._dlg.bind("<Return>", lambda e: self._do_connect())
|
||||
self._dlg.bind("<Escape>", lambda e: self._do_cancel())
|
||||
|
||||
self._dlg.update_idletasks()
|
||||
x = parent.winfo_x() + (parent.winfo_width() - self._dlg.winfo_width()) // 2
|
||||
y = parent.winfo_y() + (parent.winfo_height() - self._dlg.winfo_height()) // 2
|
||||
self._dlg.geometry(f"+{x}+{y}")
|
||||
|
||||
def _do_connect(self):
|
||||
host = self._host_var.get().strip()
|
||||
port = self._port_var.get().strip()
|
||||
user = self._user_var.get().strip()
|
||||
password = self._pass_var.get()
|
||||
|
||||
if not host:
|
||||
self._status_label.config(text="Host is required", foreground="#c00000")
|
||||
return
|
||||
|
||||
try:
|
||||
port_int = int(port)
|
||||
except ValueError:
|
||||
self._status_label.config(text="Invalid port number", foreground="#c00000")
|
||||
return
|
||||
|
||||
self._btn_connect.config(state="disabled")
|
||||
self._status_label.config(text="Connecting...", foreground="#666666")
|
||||
|
||||
self._cancelled = False
|
||||
self._thread = threading.Thread(
|
||||
target=self._connect_worker,
|
||||
args=(host, port_int, user, password),
|
||||
daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
self._poll_connect()
|
||||
|
||||
def _connect_worker(self, host, port, user, password):
|
||||
try:
|
||||
result = self._callback(host, port, user, password)
|
||||
if self._cancelled:
|
||||
return
|
||||
self._result = ("ok", result, host, port, user)
|
||||
except Exception as e:
|
||||
if self._cancelled:
|
||||
return
|
||||
self._result = ("error", str(e), host, port, user)
|
||||
|
||||
def _poll_connect(self):
|
||||
if self._cancelled:
|
||||
return
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
self._dlg.after(100, self._poll_connect)
|
||||
return
|
||||
|
||||
status, *rest = self._result
|
||||
if status == "ok":
|
||||
_, host, port, user = rest
|
||||
_save_last_connection(host, str(port), user)
|
||||
self._dlg.destroy()
|
||||
else:
|
||||
msg = rest[0]
|
||||
self._status_label.config(text=f"Error: {msg}", foreground="#c00000")
|
||||
self._btn_connect.config(state="normal")
|
||||
|
||||
def _do_cancel(self):
|
||||
self._cancelled = True
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
# Connection attempt in progress — clean up after it finishes
|
||||
import app_state as _as
|
||||
def _cleanup():
|
||||
self._thread.join(timeout=5)
|
||||
if _as.device_session is not None and _as.live_mode:
|
||||
try:
|
||||
_as.device_session.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
_as.live_mode = False
|
||||
_as.device_session = None
|
||||
_as.connection_status = "offline"
|
||||
threading.Thread(target=_cleanup, daemon=True).start()
|
||||
self._dlg.destroy()
|
||||
|
||||
|
||||
def _load_last_connection():
|
||||
"""Reads last connection params from editor.properties."""
|
||||
config = configparser.ConfigParser()
|
||||
host = "192.168.1.100"
|
||||
port = "1876"
|
||||
user = "admin"
|
||||
try:
|
||||
if app_state.PROPERTIES_FILE:
|
||||
config.read(app_state.PROPERTIES_FILE)
|
||||
host = config.get("Device", "host", fallback=host)
|
||||
port = config.get("Device", "port", fallback=port)
|
||||
user = config.get("Device", "user", fallback=user)
|
||||
except Exception:
|
||||
pass
|
||||
return host, port, user
|
||||
|
||||
|
||||
def _save_last_connection(host, port, user):
|
||||
"""Saves connection params to editor.properties."""
|
||||
config = configparser.ConfigParser()
|
||||
try:
|
||||
config.read(app_state.PROPERTIES_FILE)
|
||||
if "Device" not in config:
|
||||
config["Device"] = {}
|
||||
config["Device"]["host"] = host
|
||||
config["Device"]["port"] = port
|
||||
config["Device"]["user"] = user
|
||||
with open(app_state.PROPERTIES_FILE, "w") as f:
|
||||
config.write(f)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,972 @@
|
||||
"""
|
||||
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.RLock()
|
||||
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()
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
DeviceSession — bridges SoxClient to the editor's ET.Element-based UI.
|
||||
|
||||
Handles lazy loading, DOM synthesis from cached SoxComponents,
|
||||
COV event routing, and subscription lifecycle.
|
||||
"""
|
||||
|
||||
import queue
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import app_state
|
||||
import schema
|
||||
from sox import SoxClient, SoxComponent, TREE, CONFIG, RUNTIME, LINKS, ALL
|
||||
|
||||
|
||||
class DeviceSession:
|
||||
"""High-level session manager for a live Sedona device connection."""
|
||||
|
||||
def __init__(self):
|
||||
self.sox_client = None
|
||||
self._event_queue = queue.Queue()
|
||||
self._current_subs = set() # comp_ids we subscribed for the open wiresheet
|
||||
|
||||
# ── Connect / Disconnect ────────────────────────────────────
|
||||
|
||||
def connect(self, host, port, user, password):
|
||||
"""DASP connect → read schema → load root. Called from background thread."""
|
||||
self.sox_client = SoxClient(host, int(port), user, password)
|
||||
self.sox_client.on_event = self._on_sox_event
|
||||
self.sox_client.connect()
|
||||
|
||||
parts = self.sox_client.read_schema()
|
||||
vi = self.sox_client.read_version()
|
||||
|
||||
self._load_slot_registry(parts)
|
||||
|
||||
root_comp = self.sox_client.load(0)
|
||||
# Pre-load immediate children so the tree shows the first level
|
||||
if root_comp and root_comp.children:
|
||||
self.load_children(root_comp.id)
|
||||
return root_comp
|
||||
|
||||
def disconnect(self):
|
||||
if self.sox_client is None:
|
||||
return
|
||||
try:
|
||||
if self._current_subs:
|
||||
comps = [self.sox_client.cache_get(cid)
|
||||
for cid in self._current_subs]
|
||||
comps = [c for c in comps if c is not None]
|
||||
if comps:
|
||||
self.sox_client.unsubscribe(comps, ALL)
|
||||
except Exception:
|
||||
pass
|
||||
self._current_subs.clear()
|
||||
try:
|
||||
self.sox_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.sox_client = None
|
||||
|
||||
@property
|
||||
def is_connected(self):
|
||||
return self.sox_client is not None and not self.sox_client.is_closed
|
||||
|
||||
# ── Schema → Slot Registry ──────────────────────────────────
|
||||
|
||||
def _load_slot_registry(self, parts):
|
||||
"""Builds slot_registry from kit manifests for the device's schema."""
|
||||
registry = {}
|
||||
for kit_id, part in enumerate(parts):
|
||||
kit_name = part.name
|
||||
checksum = part.checksum
|
||||
manifest = schema.find_manifest(kit_name, checksum)
|
||||
if manifest is None:
|
||||
continue
|
||||
self._register_kit_types(registry, kit_id, kit_name, manifest)
|
||||
self.sox_client.slot_registry = registry
|
||||
|
||||
def _register_kit_types(self, registry, kit_id, kit_name, manifest):
|
||||
root = manifest
|
||||
for type_elem in root.iter("type"):
|
||||
type_name = type_elem.get("name", "")
|
||||
type_id_val = int(type_elem.get("id", "-1"))
|
||||
if type_id_val < 0:
|
||||
continue
|
||||
slots = []
|
||||
for slot_elem in type_elem.iter("slot"):
|
||||
slot_name = slot_elem.get("name", "")
|
||||
slot_id = int(slot_elem.get("id", "-1"))
|
||||
slot_type = slot_elem.get("type", "int")
|
||||
flags_str = slot_elem.get("flags", "")
|
||||
is_config = "c" in flags_str or "p" in flags_str
|
||||
is_runtime = "r" in flags_str
|
||||
is_action = "a" in flags_str
|
||||
is_operator = "o" in flags_str
|
||||
is_as_str = slot_type == "Str" or slot_elem.get("asStr") == "true"
|
||||
slots.append({
|
||||
"name": slot_name,
|
||||
"id": slot_id,
|
||||
"type_name": slot_type,
|
||||
"is_config": is_config,
|
||||
"is_runtime": is_runtime,
|
||||
"is_action": is_action,
|
||||
"is_operator": is_operator,
|
||||
"is_as_str": is_as_str,
|
||||
"is_prop": not is_action,
|
||||
})
|
||||
registry[(kit_id, type_id_val)] = slots
|
||||
full_name = f"{kit_name}::{type_name}"
|
||||
# update type_name on any cached component of this type
|
||||
for c in self.sox_client.cache:
|
||||
if c is not None and c.kit_id == kit_id and c.type_id == type_id_val:
|
||||
c.type_name = full_name
|
||||
|
||||
# ── Lazy Loading ────────────────────────────────────────────
|
||||
|
||||
def load_children(self, comp_id):
|
||||
"""Loads direct children of a component (cache-aware). Returns list of SoxComponent."""
|
||||
parent = self.sox_client.cache_get(comp_id)
|
||||
if parent is None:
|
||||
parent = self.sox_client.load(comp_id)
|
||||
if parent is None:
|
||||
return []
|
||||
uncached = [cid for cid in parent.children
|
||||
if self.sox_client.cache_get(cid) is None]
|
||||
if uncached:
|
||||
self.sox_client.load_ids(uncached, checked=False)
|
||||
return [self.sox_client.cache_get(cid)
|
||||
for cid in parent.children
|
||||
if self.sox_client.cache_get(cid) is not None]
|
||||
|
||||
# ── Wiresheet Open / Close ──────────────────────────────────
|
||||
|
||||
def open_wiresheet(self, folder_comp_id):
|
||||
"""Full wiresheet load: children, links, remote comps, config+runtime, subscribe.
|
||||
|
||||
Returns list of SoxComponent for the folder's children.
|
||||
"""
|
||||
children = self.load_children(folder_comp_id)
|
||||
if not children:
|
||||
return []
|
||||
|
||||
all_link_remote_ids = set()
|
||||
for child in children:
|
||||
links = self.sox_client.read_links(child.id)
|
||||
child.links = links
|
||||
for lnk in links:
|
||||
all_link_remote_ids.add(lnk.from_comp_id)
|
||||
all_link_remote_ids.add(lnk.to_comp_id)
|
||||
|
||||
child_ids = {c.id for c in children}
|
||||
child_ids.add(folder_comp_id)
|
||||
remote_ids = [rid for rid in all_link_remote_ids
|
||||
if rid not in child_ids
|
||||
and self.sox_client.cache_get(rid) is None]
|
||||
if remote_ids:
|
||||
self.sox_client.load_ids(remote_ids, checked=False)
|
||||
|
||||
self.sox_client.update(children, CONFIG | RUNTIME)
|
||||
|
||||
folder_comp = self.sox_client.cache_get(folder_comp_id)
|
||||
to_subscribe = [folder_comp] + children if folder_comp else children
|
||||
to_subscribe = [c for c in to_subscribe if c is not None]
|
||||
try:
|
||||
self.sox_client.subscribe(to_subscribe, ALL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._current_subs = {c.id for c in to_subscribe}
|
||||
return children
|
||||
|
||||
def close_wiresheet(self):
|
||||
"""Unsubscribe from previously subscribed components."""
|
||||
if not self._current_subs or not self.is_connected:
|
||||
self._current_subs.clear()
|
||||
return
|
||||
comps = [self.sox_client.cache_get(cid)
|
||||
for cid in self._current_subs]
|
||||
comps = [c for c in comps if c is not None]
|
||||
if comps:
|
||||
try:
|
||||
self.sox_client.unsubscribe(comps, ALL)
|
||||
except Exception:
|
||||
pass
|
||||
self._current_subs.clear()
|
||||
|
||||
# ── DOM Synthesis ───────────────────────────────────────────
|
||||
|
||||
def build_dom_tree(self, root_comp):
|
||||
"""Builds an ET.Element tree from cached SoxComponents, for the editor UI.
|
||||
|
||||
Only builds from what is currently in the cache (lazy-loaded components).
|
||||
Returns a root <sedonaApp> element.
|
||||
"""
|
||||
sax_root = ET.Element("sedonaApp")
|
||||
schema_elem = ET.SubElement(sax_root, "schema")
|
||||
if self.sox_client and self.sox_client.schema_parts:
|
||||
for part in self.sox_client.schema_parts:
|
||||
ET.SubElement(schema_elem, "kit",
|
||||
name=part.name, checksum=str(part.checksum))
|
||||
|
||||
app_elem = ET.SubElement(sax_root, "app")
|
||||
if root_comp is not None:
|
||||
self._build_comp_element(app_elem, root_comp)
|
||||
|
||||
return sax_root
|
||||
|
||||
def _build_comp_element(self, parent_elem, sox_comp):
|
||||
type_qname = sox_comp.type_name
|
||||
attribs = {
|
||||
"name": sox_comp.name,
|
||||
"id": str(sox_comp.id),
|
||||
"type": type_qname,
|
||||
}
|
||||
comp_elem = ET.SubElement(parent_elem, "comp", **attribs)
|
||||
|
||||
slots = self.sox_client.slot_registry.get(
|
||||
(sox_comp.kit_id, sox_comp.type_id))
|
||||
if slots:
|
||||
for slot in slots:
|
||||
if slot.get("is_action"):
|
||||
continue
|
||||
name = slot["name"]
|
||||
if name in sox_comp.values:
|
||||
val = sox_comp.values[name]
|
||||
ET.SubElement(comp_elem, "prop",
|
||||
name=name, val=str(val))
|
||||
|
||||
for child_id in sox_comp.children:
|
||||
child_comp = self.sox_client.cache_get(child_id)
|
||||
if child_comp is not None:
|
||||
self._build_comp_element(comp_elem, child_comp)
|
||||
else:
|
||||
# Placeholder so the tree shows a + expand arrow for uncached children
|
||||
ET.SubElement(comp_elem, "comp",
|
||||
name="...", id=str(child_id), type="")
|
||||
|
||||
return comp_elem
|
||||
|
||||
def build_links_elements(self, sax_root):
|
||||
"""Adds <link> elements under the app element from cached link data."""
|
||||
app_elem = sax_root.find("app")
|
||||
if app_elem is None:
|
||||
return
|
||||
for c in self.sox_client.cache:
|
||||
if c is None:
|
||||
continue
|
||||
for lnk in c.links:
|
||||
from_comp = self.sox_client.cache_get(lnk.from_comp_id)
|
||||
to_comp = self.sox_client.cache_get(lnk.to_comp_id)
|
||||
if from_comp is None or to_comp is None:
|
||||
continue
|
||||
|
||||
from_slot_name = self._slot_name_by_id(
|
||||
from_comp.kit_id, from_comp.type_id, lnk.from_slot_id)
|
||||
to_slot_name = self._slot_name_by_id(
|
||||
to_comp.kit_id, to_comp.type_id, lnk.to_slot_id)
|
||||
|
||||
from_path = self._comp_path(lnk.from_comp_id)
|
||||
to_path = self._comp_path(lnk.to_comp_id)
|
||||
|
||||
if from_path and to_path and from_slot_name and to_slot_name:
|
||||
ET.SubElement(app_elem, "link",
|
||||
**{"from": f"{from_path}.{from_slot_name}",
|
||||
"to": f"{to_path}.{to_slot_name}"})
|
||||
|
||||
def _slot_name_by_id(self, kit_id, type_id_val, slot_id):
|
||||
slots = self.sox_client.slot_registry.get((kit_id, type_id_val))
|
||||
if slots:
|
||||
for s in slots:
|
||||
if s["id"] == slot_id:
|
||||
return s["name"]
|
||||
return None
|
||||
|
||||
def _comp_path(self, comp_id):
|
||||
parts = []
|
||||
cid = comp_id
|
||||
while cid >= 0:
|
||||
c = self.sox_client.cache_get(cid)
|
||||
if c is None:
|
||||
break
|
||||
if c.id == 0:
|
||||
break
|
||||
parts.append(c.name)
|
||||
cid = c.parent
|
||||
if not parts:
|
||||
return None
|
||||
parts.reverse()
|
||||
return "/".join(parts)
|
||||
|
||||
# ── Update DOM from COV ─────────────────────────────────────
|
||||
|
||||
def update_element_from_cache(self, comp_id, sax_root):
|
||||
"""Updates a single comp element in the DOM from cached SoxComponent values."""
|
||||
sox_comp = self.sox_client.cache_get(comp_id)
|
||||
if sox_comp is None:
|
||||
return None
|
||||
|
||||
comp_elem = self._find_element_by_id(sax_root, comp_id)
|
||||
if comp_elem is None:
|
||||
return None
|
||||
|
||||
slots = self.sox_client.slot_registry.get(
|
||||
(sox_comp.kit_id, sox_comp.type_id))
|
||||
if slots:
|
||||
existing_props = {p.get("name"): p for p in comp_elem.findall("prop")}
|
||||
for slot in slots:
|
||||
if slot.get("is_action"):
|
||||
continue
|
||||
name = slot["name"]
|
||||
if name in sox_comp.values:
|
||||
val = str(sox_comp.values[name])
|
||||
if name in existing_props:
|
||||
existing_props[name].set("val", val)
|
||||
else:
|
||||
ET.SubElement(comp_elem, "prop", name=name, val=val)
|
||||
return comp_elem
|
||||
|
||||
@staticmethod
|
||||
def _find_element_by_id(root, comp_id):
|
||||
target = str(comp_id)
|
||||
for elem in root.iter("comp"):
|
||||
if elem.get("id") == target:
|
||||
return elem
|
||||
return None
|
||||
|
||||
# ── Event queue (thread-safe) ───────────────────────────────
|
||||
|
||||
def _on_sox_event(self, comp_id, what_char):
|
||||
self._event_queue.put((comp_id, what_char))
|
||||
|
||||
def poll_events(self):
|
||||
"""Drains the event queue. Returns list of (comp_id, what_char) tuples."""
|
||||
events = []
|
||||
try:
|
||||
while True:
|
||||
events.append(self._event_queue.get_nowait())
|
||||
except queue.Empty:
|
||||
pass
|
||||
return events
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Write-through interception layer for live device mode.
|
||||
|
||||
Checks app_state.live_mode and routes mutations through DeviceSession/SoxClient.
|
||||
In offline mode, falls through to the original DOM-only code paths.
|
||||
"""
|
||||
|
||||
import app_state
|
||||
from sox import type_id_for_slot
|
||||
|
||||
|
||||
def is_live():
|
||||
return (app_state.live_mode
|
||||
and app_state.device_session is not None
|
||||
and app_state.device_session.is_connected)
|
||||
|
||||
|
||||
# ── Property Write ──────────────────────────────────────────────
|
||||
|
||||
def live_set_prop_value(element, slot_name, value_str):
|
||||
"""In live mode, sends a Sox write to the device. Returns True on success."""
|
||||
if not is_live():
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
comp_id = _comp_id(element)
|
||||
if comp_id is None:
|
||||
return False
|
||||
|
||||
slot_info = _find_slot(ds, comp_id, slot_name)
|
||||
if slot_info is None:
|
||||
return False
|
||||
|
||||
type_id = type_id_for_slot(slot_info["type_name"])
|
||||
coerced = _coerce_value(type_id, value_str)
|
||||
|
||||
try:
|
||||
ds.sox_client.write_prop(comp_id, slot_info["id"], type_id, coerced)
|
||||
return True
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Write failed: {e}", warn=True)
|
||||
return False
|
||||
|
||||
|
||||
# ── Add Component ───────────────────────────────────────────────
|
||||
|
||||
def live_add_component(parent_element, kit_name, type_name, comp_name, config_values=None):
|
||||
"""In live mode, sends a Sox add to the device. Returns new comp_id or None."""
|
||||
if not is_live():
|
||||
return None
|
||||
|
||||
ds = app_state.device_session
|
||||
parent_id = _comp_id(parent_element)
|
||||
if parent_id is None:
|
||||
return None
|
||||
|
||||
kit_id = _kit_id(ds, kit_name)
|
||||
type_id_val = _type_id(ds, kit_id, type_name)
|
||||
if kit_id is None or type_id_val is None:
|
||||
return None
|
||||
|
||||
sox_config = []
|
||||
if config_values:
|
||||
for tid, val in config_values:
|
||||
sox_config.append((tid, val))
|
||||
|
||||
try:
|
||||
new_comp = ds.sox_client.add(parent_id, kit_id, type_id_val, comp_name, sox_config)
|
||||
return new_comp.id
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Add failed: {e}", warn=True)
|
||||
return None
|
||||
|
||||
|
||||
# ── Delete Component ────────────────────────────────────────────
|
||||
|
||||
def live_delete_component(element):
|
||||
"""In live mode, sends a Sox delete to the device. Returns True on success."""
|
||||
if not is_live():
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
comp_id = _comp_id(element)
|
||||
if comp_id is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
ds.sox_client.delete(comp_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Delete failed: {e}", warn=True)
|
||||
return False
|
||||
|
||||
|
||||
# ── Rename Component ────────────────────────────────────────────
|
||||
|
||||
def live_rename_component(element, new_name):
|
||||
"""In live mode, sends a Sox rename to the device. Returns True on success."""
|
||||
if not is_live():
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
comp_id = _comp_id(element)
|
||||
if comp_id is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
ds.sox_client.rename(comp_id, new_name)
|
||||
return True
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Rename failed: {e}", warn=True)
|
||||
return False
|
||||
|
||||
|
||||
# ── Link / Unlink ───────────────────────────────────────────────
|
||||
|
||||
def live_add_link(from_element, from_slot_name, to_element, to_slot_name):
|
||||
"""In live mode, sends a Sox link command. Returns True on success."""
|
||||
if not is_live():
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
from_id = _comp_id(from_element)
|
||||
to_id = _comp_id(to_element)
|
||||
if from_id is None or to_id is None:
|
||||
return False
|
||||
|
||||
from_slot = _find_slot(ds, from_id, from_slot_name)
|
||||
to_slot = _find_slot(ds, to_id, to_slot_name)
|
||||
if from_slot is None or to_slot is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
ds.sox_client.link(from_id, from_slot["id"], to_id, to_slot["id"])
|
||||
return True
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Link failed: {e}", warn=True)
|
||||
return False
|
||||
|
||||
|
||||
def live_delete_link(from_element, from_slot_name, to_element, to_slot_name):
|
||||
"""In live mode, sends a Sox unlink command. Returns True on success."""
|
||||
if not is_live():
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
from_id = _comp_id(from_element)
|
||||
to_id = _comp_id(to_element)
|
||||
if from_id is None or to_id is None:
|
||||
return False
|
||||
|
||||
from_slot = _find_slot(ds, from_id, from_slot_name)
|
||||
to_slot = _find_slot(ds, to_id, to_slot_name)
|
||||
if from_slot is None or to_slot is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
ds.sox_client.unlink(from_id, from_slot["id"], to_id, to_slot["id"])
|
||||
return True
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Unlink failed: {e}", warn=True)
|
||||
return False
|
||||
|
||||
|
||||
# ── Invoke Action ───────────────────────────────────────────────
|
||||
|
||||
def live_invoke(element, slot_name, arg=None):
|
||||
"""In live mode, sends a Sox invoke. Returns True on success."""
|
||||
if not is_live():
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
comp_id = _comp_id(element)
|
||||
if comp_id is None:
|
||||
return False
|
||||
|
||||
slot_info = _find_slot(ds, comp_id, slot_name)
|
||||
if slot_info is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
ds.sox_client.invoke(comp_id, slot_info["id"])
|
||||
return True
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Invoke failed: {e}", warn=True)
|
||||
return False
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _comp_id(element):
|
||||
cid = element.get("id")
|
||||
if cid is None:
|
||||
return None
|
||||
try:
|
||||
return int(cid)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _find_slot(ds, comp_id, slot_name):
|
||||
comp = ds.sox_client.cache_get(comp_id)
|
||||
if comp is None:
|
||||
return None
|
||||
slots = ds.sox_client.slot_registry.get((comp.kit_id, comp.type_id))
|
||||
if slots is None:
|
||||
return None
|
||||
for s in slots:
|
||||
if s["name"] == slot_name:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def _kit_id(ds, kit_name):
|
||||
if ds.sox_client.schema_parts:
|
||||
for i, part in enumerate(ds.sox_client.schema_parts):
|
||||
if part.name == kit_name:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def _type_id(ds, kit_id, type_name):
|
||||
if kit_id is None:
|
||||
return None
|
||||
for (kid, tid), slots in ds.sox_client.slot_registry.items():
|
||||
if kid == kit_id:
|
||||
comp = ds.sox_client.cache_get(0)
|
||||
if comp and comp.type_name.endswith(f"::{type_name}"):
|
||||
return tid
|
||||
# fallback: search all cached components for a matching type_name
|
||||
for c in ds.sox_client.cache:
|
||||
if c is not None and c.kit_id == kit_id and c.type_name.endswith(f"::{type_name}"):
|
||||
return c.type_id
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_value(type_id, value_str):
|
||||
from sox import BOOL_ID, BYTE_ID, SHORT_ID, INT_ID, LONG_ID, FLOAT_ID, DOUBLE_ID, STR_ID
|
||||
if type_id == BOOL_ID:
|
||||
return value_str.lower() in ("true", "1", "yes")
|
||||
elif type_id in (BYTE_ID, SHORT_ID, INT_ID):
|
||||
return int(value_str)
|
||||
elif type_id == LONG_ID:
|
||||
return int(value_str)
|
||||
elif type_id == FLOAT_ID:
|
||||
return float(value_str)
|
||||
elif type_id == DOUBLE_ID:
|
||||
return float(value_str)
|
||||
elif type_id == STR_ID:
|
||||
return str(value_str)
|
||||
return value_str
|
||||
@@ -5,7 +5,11 @@ import sys
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, font as tkfont, messagebox, ttk
|
||||
|
||||
import threading
|
||||
|
||||
import app_state
|
||||
import connect_dialog
|
||||
import device_session
|
||||
import link_view
|
||||
import manage_kits
|
||||
import palette
|
||||
@@ -393,6 +397,9 @@ def _clear_restoring_flag():
|
||||
|
||||
|
||||
def do_undo(event=None):
|
||||
if app_state.live_mode:
|
||||
app_state.set_status("Undo is not available in live mode", warn=True)
|
||||
return "break"
|
||||
label = undo.undo()
|
||||
if label is None:
|
||||
return "break"
|
||||
@@ -401,6 +408,9 @@ def do_undo(event=None):
|
||||
|
||||
|
||||
def do_redo(event=None):
|
||||
if app_state.live_mode:
|
||||
app_state.set_status("Redo is not available in live mode", warn=True)
|
||||
return "break"
|
||||
label = undo.redo()
|
||||
if label is None:
|
||||
return "break"
|
||||
@@ -409,10 +419,32 @@ def do_redo(event=None):
|
||||
|
||||
|
||||
def do_save(event=None):
|
||||
if app_state.live_mode:
|
||||
do_download_to_sax()
|
||||
return "break"
|
||||
sax_file.execute_direct_overwrite_save()
|
||||
return "break"
|
||||
|
||||
|
||||
def do_download_to_sax():
|
||||
"""In live mode, saves the current device DOM snapshot as a .sax file."""
|
||||
if app_state.xml_root_element is None:
|
||||
return
|
||||
path = filedialog.asksaveasfilename(
|
||||
title="Download to SAX",
|
||||
defaultextension=".sax",
|
||||
filetypes=[("Sedona SAX", "*.sax"), ("All files", "*.*")],
|
||||
initialfile=f"{app_state.device_host}.sax"
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
sax_file.write_sax_to_path(path)
|
||||
app_state.set_status(f"Downloaded to {os.path.basename(path)}")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Download Failed", str(e))
|
||||
|
||||
|
||||
NL = chr(10) # newline, for the multi-line message boxes
|
||||
|
||||
|
||||
@@ -546,7 +578,7 @@ def on_canvas_wheel_zoom(event):
|
||||
|
||||
def on_tree_select(event):
|
||||
"""Triggered when a branch node is clicked in the Navigation view."""
|
||||
global _pending_link_selection
|
||||
global _pending_link_selection, _restoring_selection
|
||||
|
||||
# Read and cleared first thing, so a link a jump asked for is only ever picked out
|
||||
# by the very selection that jump made. A selection that turns out not to paint a
|
||||
@@ -578,9 +610,36 @@ def on_tree_select(event):
|
||||
app_state.current_sheet_parent = element
|
||||
record_visit(VIEW_SHEET, element)
|
||||
|
||||
if app_state.live_mode and app_state.device_session and element is not None:
|
||||
comp_id_str = element.get("id")
|
||||
if comp_id_str is not None:
|
||||
ds = app_state.device_session
|
||||
ds.close_wiresheet()
|
||||
try:
|
||||
ds.open_wiresheet(int(comp_id_str))
|
||||
sax_root = ds.build_dom_tree(ds.sox_client.cache_get(0))
|
||||
ds.build_links_elements(sax_root)
|
||||
app_state.xml_root_element = sax_root
|
||||
sax_file.refresh_treeview_from_dom(sax_root)
|
||||
# re-find element by comp id after DOM rebuild (tree item IDs changed)
|
||||
element = None
|
||||
for tid, elem in app_state.tree_element_map.items():
|
||||
if elem.get("id") == comp_id_str:
|
||||
element = elem
|
||||
selected_id = tid
|
||||
break
|
||||
if element is not None:
|
||||
_restoring_selection = True
|
||||
tree.selection_set(selected_id)
|
||||
tree.see(selected_id)
|
||||
tree.after_idle(_clear_restoring_flag)
|
||||
app_state.current_sheet_parent = element
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if element is not None:
|
||||
components_to_render = [child for child in element if child.tag == "comp"]
|
||||
else:
|
||||
elif tree.exists(selected_id):
|
||||
top_level_ids = tree.get_children(selected_id)
|
||||
for t_id in top_level_ids:
|
||||
comp_elem = app_state.tree_element_map.get(t_id)
|
||||
@@ -1290,7 +1349,18 @@ def attach_tree_scrollbars(container, tree):
|
||||
|
||||
# Resizing, and opening or closing a branch, both change what the widest row is.
|
||||
tree.bind("<Configure>", lambda event: fit_tree_column(tree), add="+")
|
||||
tree.bind("<<TreeviewOpen>>", lambda event: tree.after_idle(fit_tree_column, tree), add="+")
|
||||
def _on_tree_open(event):
|
||||
tree.after_idle(fit_tree_column, tree)
|
||||
if app_state.live_mode:
|
||||
opened = tree.focus()
|
||||
if opened:
|
||||
elem = app_state.tree_element_map.get(opened)
|
||||
if elem is not None:
|
||||
cid = elem.get("id")
|
||||
if cid is not None:
|
||||
_live_tree_expand(int(cid), opened)
|
||||
|
||||
tree.bind("<<TreeviewOpen>>", _on_tree_open, add="+")
|
||||
tree.bind("<<TreeviewClose>>", lambda event: tree.after_idle(fit_tree_column, tree), add="+")
|
||||
|
||||
|
||||
@@ -1601,7 +1671,7 @@ def open_preferences_dialog():
|
||||
|
||||
dialog = tk.Toplevel(window)
|
||||
dialog.title("Preferences")
|
||||
dialog.geometry("470x290")
|
||||
dialog.geometry("470x330")
|
||||
dialog.resizable(False, False)
|
||||
dialog.transient(window)
|
||||
dialog.grab_set()
|
||||
@@ -1664,7 +1734,18 @@ def open_preferences_dialog():
|
||||
text="Yes",
|
||||
variable=var_timestamp
|
||||
)
|
||||
chk_timestamp.grid(row=4, column=1, sticky="w", pady=(0, 15))
|
||||
chk_timestamp.grid(row=4, column=1, sticky="w", pady=(0, 10))
|
||||
|
||||
lbl_trace = ttk.Label(frame_padded, text="Trace DASP to terminal?", anchor="w")
|
||||
lbl_trace.grid(row=5, column=0, sticky="w", pady=(0, 15), padx=(0, 10))
|
||||
|
||||
var_trace_dasp = tk.BooleanVar(value=app_state.TRACE_DASP)
|
||||
chk_trace_dasp = ttk.Checkbutton(
|
||||
frame_padded,
|
||||
text="Yes",
|
||||
variable=var_trace_dasp
|
||||
)
|
||||
chk_trace_dasp.grid(row=5, column=1, sticky="w", pady=(0, 15))
|
||||
|
||||
def apply_preferences():
|
||||
try:
|
||||
@@ -1721,6 +1802,7 @@ def open_preferences_dialog():
|
||||
app_state.LEFT_PANE_WIDTH = pane_width
|
||||
app_state.GRID_SIZE = val
|
||||
app_state.ADD_TIMESTAMP = var_timestamp.get()
|
||||
app_state.TRACE_DASP = var_trace_dasp.get()
|
||||
app_state.SEDONA_HOME = entry_home.get().strip()
|
||||
|
||||
app_state.save_preferences()
|
||||
@@ -1739,7 +1821,7 @@ def open_preferences_dialog():
|
||||
messagebox.showerror("Invalid Input", message, parent=dialog)
|
||||
|
||||
btn_save_pref = ttk.Button(frame_padded, text="Apply Changes", command=apply_preferences)
|
||||
btn_save_pref.grid(row=5, column=1, sticky="e")
|
||||
btn_save_pref.grid(row=6, column=1, sticky="e")
|
||||
|
||||
if not app_state.SEDONA_HOME:
|
||||
entry_home.focus_set()
|
||||
@@ -1838,6 +1920,165 @@ def verify_startup_configurations():
|
||||
wiresheet.render_components(app_state.active_components_list)
|
||||
|
||||
|
||||
# ── Live device connection ──────────────────────────────────────
|
||||
|
||||
_cov_poll_id = None
|
||||
|
||||
|
||||
def do_device_connect():
|
||||
"""Opens the connect dialog and initiates a device connection."""
|
||||
connect_dialog.ConnectDialog(app_state.window, _connect_to_device)
|
||||
|
||||
|
||||
def _connect_to_device(host, port, user, password):
|
||||
"""Called from the connect dialog's background thread."""
|
||||
ds = device_session.DeviceSession()
|
||||
root_comp = ds.connect(host, port, user, password)
|
||||
|
||||
app_state.device_session = ds
|
||||
app_state.device_host = host
|
||||
app_state.device_port = port
|
||||
app_state.live_mode = True
|
||||
app_state.connection_status = "connected"
|
||||
|
||||
app_state.window.after(0, lambda: _on_connected(ds, root_comp))
|
||||
return root_comp
|
||||
|
||||
|
||||
def _on_connected(ds, root_comp):
|
||||
"""Main-thread callback after successful connect."""
|
||||
app_state.window.title(app_state.base_window_title_live())
|
||||
menu_device.entryconfig("Connect...", state="disabled")
|
||||
menu_device.entryconfig("Disconnect", state="normal")
|
||||
menu_device.entryconfig("Download to SAX...", state="normal")
|
||||
app_state.set_status(f"Connected to {app_state.device_host}:{app_state.device_port}")
|
||||
|
||||
sax_root = ds.build_dom_tree(root_comp)
|
||||
app_state.xml_root_element = sax_root
|
||||
app_state.current_file_path = f"device://{app_state.device_host}"
|
||||
|
||||
_load_live_schema(ds)
|
||||
sax_file.refresh_treeview_from_dom(sax_root)
|
||||
|
||||
undo.reset()
|
||||
app_state.mark_clean()
|
||||
_start_cov_polling()
|
||||
|
||||
|
||||
def _load_live_schema(ds):
|
||||
"""Loads schema kits from the device's schema parts into the editor registry."""
|
||||
app_state.active_schema_kits.clear()
|
||||
app_state.final_components_registry.clear()
|
||||
if ds.sox_client and ds.sox_client.schema_parts:
|
||||
for part in ds.sox_client.schema_parts:
|
||||
app_state.active_schema_kits[part.name] = f"{part.checksum & 0xFFFFFFFF:08x}"
|
||||
try:
|
||||
schema.load_schema_kits_and_manifests()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def do_device_disconnect():
|
||||
"""Disconnects from the live device."""
|
||||
global _cov_poll_id
|
||||
|
||||
if _cov_poll_id is not None:
|
||||
app_state.window.after_cancel(_cov_poll_id)
|
||||
_cov_poll_id = None
|
||||
|
||||
ds = app_state.device_session
|
||||
if ds is not None:
|
||||
ds.disconnect()
|
||||
|
||||
app_state.device_session = None
|
||||
app_state.live_mode = False
|
||||
app_state.connection_status = "offline"
|
||||
app_state.device_host = ""
|
||||
app_state.device_port = 1876
|
||||
|
||||
app_state.window.title(app_state.base_window_title())
|
||||
menu_device.entryconfig("Connect...", state="normal")
|
||||
menu_device.entryconfig("Disconnect", state="disabled")
|
||||
menu_device.entryconfig("Download to SAX...", state="disabled")
|
||||
app_state.set_status("Disconnected")
|
||||
|
||||
sax_file.reset_to_startup_state()
|
||||
sax_file.refresh_treeview_from_dom(None)
|
||||
wiresheet.render_components([])
|
||||
|
||||
|
||||
def _start_cov_polling():
|
||||
"""Starts the 100ms COV event polling loop on the main thread."""
|
||||
global _cov_poll_id
|
||||
|
||||
def poll():
|
||||
global _cov_poll_id
|
||||
ds = app_state.device_session
|
||||
if ds is None or not ds.is_connected:
|
||||
_cov_poll_id = None
|
||||
if app_state.live_mode:
|
||||
app_state.connection_status = "error"
|
||||
app_state.set_status("Connection lost", warn=True)
|
||||
return
|
||||
|
||||
events = ds.poll_events()
|
||||
if events and app_state.xml_root_element is not None:
|
||||
needs_repaint = False
|
||||
for comp_id, what_char in events:
|
||||
elem = ds.update_element_from_cache(comp_id, app_state.xml_root_element)
|
||||
if elem is not None:
|
||||
needs_repaint = True
|
||||
if needs_repaint:
|
||||
wiresheet.render_current_sheet()
|
||||
if properties_panel is not None:
|
||||
try:
|
||||
properties_panel.draw()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_cov_poll_id = app_state.window.after(100, poll)
|
||||
|
||||
_cov_poll_id = app_state.window.after(100, poll)
|
||||
|
||||
|
||||
def _live_tree_expand(comp_id, tree_item_id):
|
||||
"""Called when user expands a tree node in live mode to lazy-load children."""
|
||||
ds = app_state.device_session
|
||||
if ds is None or not ds.is_connected:
|
||||
return
|
||||
|
||||
app_state.set_status("Loading children...")
|
||||
|
||||
def _bg():
|
||||
try:
|
||||
children = ds.load_children(comp_id)
|
||||
if not children:
|
||||
app_state.window.after(0, lambda: app_state.set_status("No children"))
|
||||
return
|
||||
sax_root = ds.build_dom_tree(ds.sox_client.cache_get(0))
|
||||
ds.build_links_elements(sax_root)
|
||||
|
||||
def _ui():
|
||||
app_state.xml_root_element = sax_root
|
||||
sax_file.refresh_treeview_from_dom(sax_root)
|
||||
# Re-open and select the node the user clicked
|
||||
for iid, elem in app_state.tree_element_map.items():
|
||||
if elem.get("id") == str(comp_id):
|
||||
tree.item(iid, open=True)
|
||||
tree.selection_set(iid)
|
||||
tree.see(iid)
|
||||
break
|
||||
app_state.set_status(f"Loaded {len(children)} children")
|
||||
|
||||
app_state.window.after(0, _ui)
|
||||
except Exception as exc:
|
||||
app_state.window.after(
|
||||
0, lambda: app_state.set_status(f"Load error: {exc}", warn=True)
|
||||
)
|
||||
|
||||
threading.Thread(target=_bg, daemon=True).start()
|
||||
|
||||
|
||||
# --- UI Setup ---
|
||||
window = tk.Tk()
|
||||
app_state.window = window
|
||||
@@ -1926,6 +2167,13 @@ accel_mod = "Cmd" if sys.platform == "darwin" else "Ctrl"
|
||||
# --- Native Application Menu Configurations ---
|
||||
menu_bar = tk.Menu(window)
|
||||
|
||||
menu_device = tk.Menu(menu_bar, tearoff=0)
|
||||
menu_device.add_command(label="Connect...", command=do_device_connect)
|
||||
menu_device.add_command(label="Disconnect", command=do_device_disconnect, state="disabled")
|
||||
menu_device.add_separator()
|
||||
menu_device.add_command(label="Download to SAX...", command=do_download_to_sax, state="disabled")
|
||||
menu_bar.add_cascade(label="Device", menu=menu_device)
|
||||
|
||||
menu_file = tk.Menu(menu_bar, tearoff=0)
|
||||
menu_file.add_command(label="Open", command=sax_file.open_file)
|
||||
menu_file.add_command(label="Save", command=do_save, accelerator=f"{accel_mod}+S")
|
||||
|
||||
+20
-5
@@ -32,6 +32,7 @@ import tkinter as tk
|
||||
from tkinter import font as tkfont, messagebox, ttk
|
||||
|
||||
import app_state
|
||||
import live_hooks
|
||||
import schema
|
||||
import undo
|
||||
from sax_dom import (get_component_path, read_meta_property, set_prop_value,
|
||||
@@ -1132,11 +1133,16 @@ class PropertiesPanel:
|
||||
self.status_var.set("No changes to save.")
|
||||
return
|
||||
|
||||
live_failures = []
|
||||
for element, prop_name, _before, after in changes:
|
||||
if prop_name != "meta":
|
||||
set_prop_value(element, prop_name, after)
|
||||
if live_hooks.is_live():
|
||||
if not live_hooks.live_set_prop_value(element, prop_name, after):
|
||||
live_failures.append(prop_name)
|
||||
|
||||
undo.push(undo.SlotValueCommand(list(changes)))
|
||||
if not app_state.live_mode:
|
||||
undo.push(undo.SlotValueCommand(list(changes)))
|
||||
app_state.mark_dirty()
|
||||
|
||||
self.refresh()
|
||||
@@ -1144,10 +1150,19 @@ class PropertiesPanel:
|
||||
self.on_change()
|
||||
|
||||
count = len(changes)
|
||||
self.status_var.set(
|
||||
f"{count} slot{'' if count == 1 else 's'} changed. "
|
||||
"Ctrl+S writes the file."
|
||||
)
|
||||
if live_failures:
|
||||
self.status_var.set(
|
||||
f"Written locally but failed on device: {', '.join(live_failures)}"
|
||||
)
|
||||
elif app_state.live_mode:
|
||||
self.status_var.set(
|
||||
f"{count} slot{'' if count == 1 else 's'} written to device."
|
||||
)
|
||||
else:
|
||||
self.status_var.set(
|
||||
f"{count} slot{'' if count == 1 else 's'} changed. "
|
||||
"Ctrl+S writes the file."
|
||||
)
|
||||
app_state.set_status(
|
||||
f"{self.comp.get('name', '?')}: {count} slot{'' if count == 1 else 's'} changed."
|
||||
)
|
||||
|
||||
+66
-19
@@ -6,11 +6,13 @@ from tkinter import filedialog, messagebox
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import app_state
|
||||
import live_hooks
|
||||
import palette
|
||||
import schema
|
||||
import undo
|
||||
import wiresheet
|
||||
from sax_dom import (
|
||||
find_component_by_path,
|
||||
find_parent_element,
|
||||
get_component_path,
|
||||
restore_children,
|
||||
@@ -253,6 +255,18 @@ def execute_direct_overwrite_save():
|
||||
messagebox.showerror("Save Error", f"Failed to write direct update stream:\n{str(e)}")
|
||||
|
||||
|
||||
def write_sax_to_path(path):
|
||||
"""Writes the current DOM to a .sax file at the given path."""
|
||||
if app_state.xml_root_element is None:
|
||||
raise RuntimeError("No DOM to write")
|
||||
sync_live_metadata_to_dom()
|
||||
with open(path, "wb") as f:
|
||||
f.write(b'<?xml version="1.0" encoding="UTF-8"?>\n')
|
||||
ET.ElementTree(app_state.xml_root_element).write(
|
||||
f, encoding="utf-8", xml_declaration=False
|
||||
)
|
||||
|
||||
|
||||
def save_file_as():
|
||||
"""Saves the tracking tree document hierarchy targeting an explicit location choice dialog."""
|
||||
if not app_state.current_file_path or app_state.xml_root_element is None:
|
||||
@@ -754,9 +768,16 @@ def add_component(kit_name, type_name, grid_x, grid_y):
|
||||
reset_to_startup_state()
|
||||
return None
|
||||
|
||||
comp_id = allocate_component_id()
|
||||
comp_name = unique_child_name(parent, type_name)
|
||||
|
||||
if live_hooks.is_live():
|
||||
new_id = live_hooks.live_add_component(parent, kit_name, type_name, comp_name)
|
||||
if new_id is None:
|
||||
return None
|
||||
comp_id = new_id
|
||||
else:
|
||||
comp_id = allocate_component_id()
|
||||
|
||||
element = ET.Element("comp")
|
||||
element.set("name", comp_name)
|
||||
element.set("id", str(comp_id))
|
||||
@@ -769,18 +790,19 @@ def add_component(kit_name, type_name, grid_x, grid_y):
|
||||
index = len(list(parent))
|
||||
parent.append(element)
|
||||
|
||||
undo.push(undo.AddComponentCommand(
|
||||
parent, index, element,
|
||||
label=f"add {comp_name}"
|
||||
))
|
||||
if not app_state.live_mode:
|
||||
undo.push(undo.AddComponentCommand(
|
||||
parent, index, element,
|
||||
label=f"add {comp_name}"
|
||||
))
|
||||
|
||||
app_state.mark_dirty()
|
||||
refresh_treeview_from_dom(app_state.xml_root_element)
|
||||
wiresheet.render_current_sheet()
|
||||
|
||||
# The schema is only reconciled on save, so the placement says so here rather than
|
||||
# letting the save dialog be the first the user hears of it.
|
||||
if kit_name in app_state.pending_schema_kits:
|
||||
if app_state.live_mode:
|
||||
app_state.set_status(f"{comp_name} ({kit_name}::{type_name}) added to device.")
|
||||
elif kit_name in app_state.pending_schema_kits:
|
||||
app_state.set_status(
|
||||
f"{comp_name} ({kit_name}::{type_name}) added — '{kit_name}' is not in this "
|
||||
"app's schema and will be declared when you save.",
|
||||
@@ -801,20 +823,31 @@ def remove_link(link_element):
|
||||
if links_elem is None:
|
||||
return
|
||||
|
||||
from_ref = link_element.get("from", "")
|
||||
to_ref = link_element.get("to", "")
|
||||
|
||||
if live_hooks.is_live():
|
||||
from_comp_path, from_slot_name = from_ref.rsplit(".", 1) if "." in from_ref else ("", from_ref)
|
||||
to_comp_path, to_slot_name = to_ref.rsplit(".", 1) if "." in to_ref else ("", to_ref)
|
||||
from_elem = find_component_by_path(app_state.xml_root_element, from_comp_path)
|
||||
to_elem = find_component_by_path(app_state.xml_root_element, to_comp_path)
|
||||
if from_elem is not None and to_elem is not None:
|
||||
if not live_hooks.live_delete_link(from_elem, from_slot_name, to_elem, to_slot_name):
|
||||
return
|
||||
|
||||
try:
|
||||
index = list(links_elem).index(link_element)
|
||||
links_elem.remove(link_element)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
from_ref = link_element.get("from", "")
|
||||
to_ref = link_element.get("to", "")
|
||||
from_slot = from_ref.rsplit(".", 1)[-1] if "." in from_ref else from_ref
|
||||
to_slot = to_ref.rsplit(".", 1)[-1] if "." in to_ref else to_ref
|
||||
undo.push(undo.DeleteLinkCommand(
|
||||
links_elem, index, link_element,
|
||||
label=f"delete link {from_slot}→{to_slot}"
|
||||
))
|
||||
if not app_state.live_mode:
|
||||
undo.push(undo.DeleteLinkCommand(
|
||||
links_elem, index, link_element,
|
||||
label=f"delete link {from_slot}→{to_slot}"
|
||||
))
|
||||
|
||||
app_state.selected_link_tag = None
|
||||
app_state.mark_dirty()
|
||||
@@ -830,16 +863,26 @@ def add_link(from_path_slot, to_path_slot):
|
||||
if links_elem is None:
|
||||
links_elem = ET.SubElement(app_state.xml_root_element, "links")
|
||||
|
||||
if live_hooks.is_live():
|
||||
from_comp_path, from_slot_name = from_path_slot.rsplit(".", 1) if "." in from_path_slot else ("", from_path_slot)
|
||||
to_comp_path, to_slot_name = to_path_slot.rsplit(".", 1) if "." in to_path_slot else ("", to_path_slot)
|
||||
from_elem = find_component_by_path(app_state.xml_root_element, from_comp_path)
|
||||
to_elem = find_component_by_path(app_state.xml_root_element, to_comp_path)
|
||||
if from_elem is not None and to_elem is not None:
|
||||
if not live_hooks.live_add_link(from_elem, from_slot_name, to_elem, to_slot_name):
|
||||
return
|
||||
|
||||
link_elem = ET.SubElement(links_elem, "link")
|
||||
link_elem.set("from", from_path_slot)
|
||||
link_elem.set("to", to_path_slot)
|
||||
|
||||
from_slot = from_path_slot.rsplit(".", 1)[-1] if "." in from_path_slot else from_path_slot
|
||||
to_slot = to_path_slot.rsplit(".", 1)[-1] if "." in to_path_slot else to_path_slot
|
||||
undo.push(undo.AddLinkCommand(
|
||||
links_elem, link_elem,
|
||||
label=f"add link {from_slot}→{to_slot}"
|
||||
))
|
||||
if not app_state.live_mode:
|
||||
undo.push(undo.AddLinkCommand(
|
||||
links_elem, link_elem,
|
||||
label=f"add link {from_slot}→{to_slot}"
|
||||
))
|
||||
|
||||
app_state.mark_dirty()
|
||||
wiresheet.render_current_sheet()
|
||||
@@ -861,6 +904,10 @@ def execute_cascading_deletion(comp_element):
|
||||
if not confirm:
|
||||
return False
|
||||
|
||||
if live_hooks.is_live():
|
||||
if not live_hooks.live_delete_component(comp_element):
|
||||
return False
|
||||
|
||||
comp_path = get_component_path(comp_element, app_state.xml_root_element)
|
||||
|
||||
all_elements_to_prune = [comp_element] + descendant_comps
|
||||
@@ -896,7 +943,7 @@ def execute_cascading_deletion(comp_element):
|
||||
comp_index = list(xml_parent).index(comp_element)
|
||||
xml_parent.remove(comp_element)
|
||||
|
||||
if xml_parent is not None:
|
||||
if xml_parent is not None and not app_state.live_mode:
|
||||
undo.push(undo.DeleteComponentCommand(
|
||||
xml_parent, comp_index, comp_element, link_records,
|
||||
label=f"delete {comp_name}"
|
||||
|
||||
@@ -224,6 +224,18 @@ def find_manifest_path(kit_name, checksum):
|
||||
return None
|
||||
|
||||
|
||||
def find_manifest(kit_name, checksum):
|
||||
"""Returns the parsed ET root of a kit manifest, or None."""
|
||||
checksum_hex = f"{checksum:08x}" if isinstance(checksum, int) else checksum
|
||||
path = find_manifest_path(kit_name, checksum_hex)
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
return ET.parse(path).getroot()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def load_schema_kits_and_manifests():
|
||||
"""Loads the manifests for the file's <schema> kits, plus any kit still only pending.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user