Get with File on P-ION: connect and transfer the way Ontrol's Niagara driver does
A real P-ION (fw 1.32) restarted on every Sox file get. Ontrol's Niagara 4 driver gets app.sab from the same device, so its settings are copied: - DASP hello asks for 256-byte packets (idealMax/absMax), so file chunks are 238 bytes - the message-length header (0x81) goes to Orion only, decided once the platform id is read - the app is saved on the device before app.sab is read, the save resent every 5 s rather than 1 s (a P-ION save takes 2-3+ s) - the file open is resent every 15 s; DASP gains a per-packet resend interval for this Tested on a P-ION at 192.168.1.252: save 3.2 s, app.sab + kits.scode + app.sax in 15.8 s, no resends, device stayed up. The unsaved-changes question became a notice, since the app is now always saved first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+22
-2
@@ -23,6 +23,7 @@ import zipfile
|
|||||||
|
|
||||||
import app_state
|
import app_state
|
||||||
import sab_app
|
import sab_app
|
||||||
|
import sox
|
||||||
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, RION_KIT
|
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, RION_KIT
|
||||||
|
|
||||||
APP_SAB = "app.sab"
|
APP_SAB = "app.sab"
|
||||||
@@ -159,6 +160,19 @@ def _source_entry(media, source_path):
|
|||||||
return f"{media.bank_folder}/{file_name}"
|
return f"{media.bank_folder}/{file_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def save_app(client):
|
||||||
|
"""Invokes App.save on the device and waits for it to finish.
|
||||||
|
|
||||||
|
Ontrol's Niagara driver does this before every app.sab get, resending the request
|
||||||
|
only every 5 s: a P-ION saving a large app takes seconds, and 1 s resends of the
|
||||||
|
request while it is busy are what knocked it over.
|
||||||
|
"""
|
||||||
|
comp = client.cache_get(0) or client.load(0)
|
||||||
|
slots = client.slot_registry.get((comp.kit_id, comp.type_id)) or []
|
||||||
|
slot = next((s for s in slots if s["name"] == "save" and s.get("is_action")), None)
|
||||||
|
client.invoke(0, slot["id"] if slot else 0, send_retry=sox.SAVE_SEND_RETRY)
|
||||||
|
|
||||||
|
|
||||||
class BackupResult:
|
class BackupResult:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.path = None
|
self.path = None
|
||||||
@@ -171,8 +185,9 @@ class BackupResult:
|
|||||||
def get_with_file(client, path, status=None):
|
def get_with_file(client, path, status=None):
|
||||||
"""Reads the app and its media off the device and writes the backup zip to `path`.
|
"""Reads the app and its media off the device and writes the backup zip to `path`.
|
||||||
|
|
||||||
Runs off the main thread; status(text, fraction) reports progress. Returns a
|
The app is saved on the device first, so app.sab holds what is running. Runs off
|
||||||
BackupResult; a failure to read app.sab or kits.scode raises.
|
the main thread; status(text, fraction) reports progress. Returns a BackupResult;
|
||||||
|
a failure to save, or to read app.sab or kits.scode, raises.
|
||||||
"""
|
"""
|
||||||
def report(text, fraction=None):
|
def report(text, fraction=None):
|
||||||
if status:
|
if status:
|
||||||
@@ -190,6 +205,11 @@ def get_with_file(client, path, status=None):
|
|||||||
result = BackupResult()
|
result = BackupResult()
|
||||||
entries = [] # (zip name, bytes or local path)
|
entries = [] # (zip name, bytes or local path)
|
||||||
|
|
||||||
|
report("Saving the app on the device ...", 0.0)
|
||||||
|
try:
|
||||||
|
save_app(client)
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Saving the app on the device: {e}") from e
|
||||||
sab = fetch(APP_SAB, 0.0, 0.15)
|
sab = fetch(APP_SAB, 0.0, 0.15)
|
||||||
entries.append(("device/" + APP_SAB, sab))
|
entries.append(("device/" + APP_SAB, sab))
|
||||||
entries.append(("device/" + KITS_SCODE, fetch(KITS_SCODE, 0.15, 0.35)))
|
entries.append(("device/" + KITS_SCODE, fetch(KITS_SCODE, 0.15, 0.35)))
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
|
|||||||
import configparser
|
import configparser
|
||||||
import os
|
import os
|
||||||
|
|
||||||
VERSION = "0.0.0.143"
|
VERSION = "0.0.0.144"
|
||||||
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
||||||
DEFAULT_GRID_SIZE = 10
|
DEFAULT_GRID_SIZE = 10
|
||||||
DEFAULT_ADD_TIMESTAMP = True
|
DEFAULT_ADD_TIMESTAMP = True
|
||||||
|
|||||||
@@ -456,7 +456,7 @@ def _to_ack_more(seq_more, max_bytes):
|
|||||||
|
|
||||||
class _Packet:
|
class _Packet:
|
||||||
__slots__ = ("seq_num", "payload", "acked", "enqueued_time",
|
__slots__ = ("seq_num", "payload", "acked", "enqueued_time",
|
||||||
"sent_time", "send_attempts", "next")
|
"sent_time", "send_attempts", "send_retry", "next")
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.seq_num = 0
|
self.seq_num = 0
|
||||||
@@ -465,6 +465,7 @@ class _Packet:
|
|||||||
self.enqueued_time = 0.0
|
self.enqueued_time = 0.0
|
||||||
self.sent_time = 0.0
|
self.sent_time = 0.0
|
||||||
self.send_attempts = 1
|
self.send_attempts = 1
|
||||||
|
self.send_retry = None # seconds; None = the window's own interval
|
||||||
self.next = None
|
self.next = None
|
||||||
|
|
||||||
|
|
||||||
@@ -506,8 +507,9 @@ class SendWindow:
|
|||||||
self._tail = None
|
self._tail = None
|
||||||
self._cond.notify_all()
|
self._cond.notify_all()
|
||||||
|
|
||||||
def send(self, payload):
|
def send(self, payload, send_retry=None):
|
||||||
p = _Packet()
|
p = _Packet()
|
||||||
|
p.send_retry = send_retry
|
||||||
p.enqueued_time = _ticks()
|
p.enqueued_time = _ticks()
|
||||||
with self._cond:
|
with self._cond:
|
||||||
p.seq_num = self._seq_num & 0xFFFF
|
p.seq_num = self._seq_num & 0xFFFF
|
||||||
@@ -618,9 +620,15 @@ class SendWindow:
|
|||||||
p = self._head
|
p = self._head
|
||||||
while p is not None:
|
while p is not None:
|
||||||
if not p.acked:
|
if not p.acked:
|
||||||
backoff_shift = min(p.send_attempts - 1, 32)
|
if p.send_retry is not None:
|
||||||
interval = min(self.send_retry * (1 << backoff_shift),
|
# A request the device is slow to take -- a P-ION saving its
|
||||||
self.max_retry_backoff)
|
# app, opening a file -- is resent at its own steady interval:
|
||||||
|
# the 1 s default resends it while the device is still busy.
|
||||||
|
interval = p.send_retry
|
||||||
|
else:
|
||||||
|
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:
|
if now - p.sent_time >= interval:
|
||||||
self._resend(p)
|
self._resend(p)
|
||||||
oldest = max(oldest, now - p.enqueued_time)
|
oldest = max(oldest, now - p.enqueued_time)
|
||||||
@@ -776,10 +784,11 @@ class DaspSession:
|
|||||||
|
|
||||||
# ── Send / Receive ──────────────────────────────────────────
|
# ── Send / Receive ──────────────────────────────────────────
|
||||||
|
|
||||||
def send(self, payload):
|
def send(self, payload, send_retry=None):
|
||||||
|
"""Queues a datagram. send_retry (seconds) overrides the resend interval."""
|
||||||
if self.is_closed:
|
if self.is_closed:
|
||||||
raise DaspException(f"Session closed: {self.close_cause}")
|
raise DaspException(f"Session closed: {self.close_cause}")
|
||||||
self.send_window.send(payload)
|
self.send_window.send(payload, send_retry)
|
||||||
|
|
||||||
def receive(self, timeout=1.0):
|
def receive(self, timeout=1.0):
|
||||||
if self.is_closed:
|
if self.is_closed:
|
||||||
|
|||||||
+3
-1
@@ -10,6 +10,7 @@ import xml.etree.ElementTree as ET
|
|||||||
|
|
||||||
import app_state
|
import app_state
|
||||||
import schema
|
import schema
|
||||||
|
import sox
|
||||||
from sox import SoxClient, SoxComponent, TREE, CONFIG, RUNTIME, LINKS, ALL
|
from sox import SoxClient, SoxComponent, TREE, CONFIG, RUNTIME, LINKS, ALL
|
||||||
|
|
||||||
|
|
||||||
@@ -43,7 +44,8 @@ class DeviceSession:
|
|||||||
|
|
||||||
def connect(self, host, port, user, password):
|
def connect(self, host, port, user, password):
|
||||||
"""DASP connect → read schema → load root. Called from background thread."""
|
"""DASP connect → read schema → load root. Called from background thread."""
|
||||||
self.sox_client = SoxClient(host, int(port), user, password)
|
self.sox_client = SoxClient(host, int(port), user, password,
|
||||||
|
ideal_max=sox.IDEAL_MAX, abs_max=sox.ABS_MAX)
|
||||||
self.sox_client.on_event = self._on_sox_event
|
self.sox_client.on_event = self._on_sox_event
|
||||||
self.sox_client.checksum_resolver = schema.resolve_truncated_checksum
|
self.sox_client.checksum_resolver = schema.resolve_truncated_checksum
|
||||||
self.sox_client.connect()
|
self.sox_client.connect()
|
||||||
|
|||||||
@@ -619,9 +619,9 @@ _APP_ACTIONS = ["save", "hibernate", "quit", "restart", "reboot"]
|
|||||||
def do_get_with_file():
|
def do_get_with_file():
|
||||||
"""App > Get with File: backs the device's app up to a zip in <Sedona home>/store/apps.
|
"""App > Get with File: backs the device's app up to a zip in <Sedona home>/store/apps.
|
||||||
|
|
||||||
The zip carries the device's app.sab, so changes not yet saved on the device are
|
The app is saved on the device before its app.sab is read, as Ontrol's Niagara
|
||||||
offered a save first. The transfer runs off the main thread behind a small progress
|
driver does, so unsaved changes are announced first and the backup can be called
|
||||||
window.
|
off. The transfer runs off the main thread behind a small progress window.
|
||||||
"""
|
"""
|
||||||
ds = app_state.device_session
|
ds = app_state.device_session
|
||||||
if not app_state.live_mode or ds is None or not ds.is_connected:
|
if not app_state.live_mode or ds is None or not ds.is_connected:
|
||||||
@@ -631,21 +631,11 @@ def do_get_with_file():
|
|||||||
messagebox.showerror("Get with File", "The Sedona home folder is not set (Preferences).")
|
messagebox.showerror("Get with File", "The Sedona home folder is not set (Preferences).")
|
||||||
return
|
return
|
||||||
|
|
||||||
if app_state.is_dirty:
|
if app_state.is_dirty and not messagebox.askokcancel(
|
||||||
answer = messagebox.askyesnocancel(
|
|
||||||
"Unsaved Changes",
|
"Unsaved Changes",
|
||||||
"The app has changes that are not saved on the device yet." + NL + NL +
|
"The app has changes that are not saved on the device yet." + NL + NL +
|
||||||
"Save the app on the device before getting it?" + NL +
|
"Get with File saves the app on the device first, so they will be saved."):
|
||||||
"(No gets the app as it was last saved.)",
|
return
|
||||||
)
|
|
||||||
if answer is None:
|
|
||||||
return
|
|
||||||
if answer and not _invoke_app_action("save"):
|
|
||||||
messagebox.showerror(
|
|
||||||
"Get with File",
|
|
||||||
"The device did not save the app, so nothing was fetched. "
|
|
||||||
"The status bar says why.")
|
|
||||||
return
|
|
||||||
|
|
||||||
path = _ask_backup_path(ds)
|
path = _ask_backup_path(ds)
|
||||||
if not path:
|
if not path:
|
||||||
@@ -683,6 +673,7 @@ def do_get_with_file():
|
|||||||
app_state.set_status(f"Get with File failed: {error}", warn=True)
|
app_state.set_status(f"Get with File failed: {error}", warn=True)
|
||||||
messagebox.showerror("Get with File", f"The backup failed:{NL}{NL}{error}")
|
messagebox.showerror("Get with File", f"The backup failed:{NL}{NL}{error}")
|
||||||
return
|
return
|
||||||
|
app_state.mark_clean() # the app was saved on the device first
|
||||||
_report_backup(result)
|
_report_backup(result)
|
||||||
|
|
||||||
def work():
|
def work():
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ BUF_ID = 7
|
|||||||
STR_ID = 8
|
STR_ID = 8
|
||||||
|
|
||||||
|
|
||||||
|
# DASP resend intervals (seconds) for requests a device can be slow to take, as Ontrol's
|
||||||
|
# Niagara driver sets them: a P-ION saving a large app takes 2-3+ s, and a file open can
|
||||||
|
# take 10+ s on some devices. Resent every second instead, the device is sent copies
|
||||||
|
# while it is still busy with the first.
|
||||||
|
SAVE_SEND_RETRY = 5.0
|
||||||
|
FILE_OPEN_SEND_RETRY = 15.0
|
||||||
|
|
||||||
|
# The DASP packet sizes Ontrol's Niagara driver asks for in its hello.
|
||||||
|
IDEAL_MAX = 256
|
||||||
|
ABS_MAX = 256
|
||||||
|
|
||||||
|
|
||||||
class SoxException(Exception):
|
class SoxException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -464,6 +476,10 @@ class SoxClient:
|
|||||||
# Same 512-byte limit as read_schema: whatever is cut off is left
|
# Same 512-byte limit as read_schema: whatever is cut off is left
|
||||||
# blank rather than failing the connect.
|
# blank rather than failing the connect.
|
||||||
platform_id = res.try_str() or ""
|
platform_id = res.try_str() or ""
|
||||||
|
# Ontrol's message-length header is for Orion only; the Niagara driver never
|
||||||
|
# sends it to a P-ION or R-ION.
|
||||||
|
if self._session is not None:
|
||||||
|
self._session.include_message_len = platform_id.startswith("ontrol-orion")
|
||||||
scode_flags = res.u1() if res.remaining > 0 else 0
|
scode_flags = res.u1() if res.remaining > 0 else 0
|
||||||
kit_versions = []
|
kit_versions = []
|
||||||
for p in parts:
|
for p in parts:
|
||||||
@@ -662,13 +678,13 @@ class SoxClient:
|
|||||||
|
|
||||||
# ── Invoke Action ───────────────────────────────────────────
|
# ── Invoke Action ───────────────────────────────────────────
|
||||||
|
|
||||||
def invoke(self, comp_id, slot_id, type_id=None, arg=None):
|
def invoke(self, comp_id, slot_id, type_id=None, arg=None, send_retry=None):
|
||||||
req = _prepare_request(ord('i'))
|
req = _prepare_request(ord('i'))
|
||||||
req.write_u2(comp_id)
|
req.write_u2(comp_id)
|
||||||
req.write_u1(slot_id)
|
req.write_u1(slot_id)
|
||||||
if arg is not None and type_id is not None:
|
if arg is not None and type_id is not None:
|
||||||
encode_value(req, type_id, arg)
|
encode_value(req, type_id, arg)
|
||||||
res = self.request(req)
|
res = self.request(req, send_retry)
|
||||||
res.check_response('I')
|
res.check_response('I')
|
||||||
|
|
||||||
# ── Add Component ───────────────────────────────────────────
|
# ── Add Component ───────────────────────────────────────────
|
||||||
@@ -934,7 +950,7 @@ class SoxClient:
|
|||||||
opened = closed = False
|
opened = closed = False
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
res = self.request(req)
|
res = self.request(req, FILE_OPEN_SEND_RETRY)
|
||||||
except SoxException as e:
|
except SoxException as e:
|
||||||
# A P-ION on firmware 1.32 never answers a file get -- it drops off
|
# A P-ION on firmware 1.32 never answers a file get -- it drops off
|
||||||
# the network for ~20 s, as a restart would -- even for a file it
|
# the network for ~20 s, as a restart would -- even for a file it
|
||||||
@@ -986,16 +1002,21 @@ class SoxClient:
|
|||||||
|
|
||||||
# ── Networking ──────────────────────────────────────────────
|
# ── Networking ──────────────────────────────────────────────
|
||||||
|
|
||||||
def request(self, req):
|
def request(self, req, send_retry=None):
|
||||||
results = self.requests([req])
|
results = self.requests([req], send_retry)
|
||||||
return results[0]
|
return results[0]
|
||||||
|
|
||||||
def requests(self, reqs):
|
def requests(self, reqs, send_retry=None):
|
||||||
|
"""Sends the batch and waits for every reply.
|
||||||
|
|
||||||
|
send_retry (seconds) sets the DASP resend interval of these datagrams, for a
|
||||||
|
request the device is known to be slow to take.
|
||||||
|
"""
|
||||||
if self.is_closed:
|
if self.is_closed:
|
||||||
raise SoxException("SoxClient closed")
|
raise SoxException("SoxClient closed")
|
||||||
|
|
||||||
if len(reqs) > 255:
|
if len(reqs) > 255:
|
||||||
return self._chunk_requests(reqs)
|
return self._chunk_requests(reqs, send_retry)
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
for i, req in enumerate(reqs):
|
for i, req in enumerate(reqs):
|
||||||
@@ -1006,7 +1027,7 @@ class SoxClient:
|
|||||||
self._req_event.clear()
|
self._req_event.clear()
|
||||||
|
|
||||||
for req in reqs:
|
for req in reqs:
|
||||||
self._session.send(req.to_bytes())
|
self._session.send(req.to_bytes(), send_retry)
|
||||||
|
|
||||||
deadline = time.monotonic() + 30.0
|
deadline = time.monotonic() + 30.0
|
||||||
while True:
|
while True:
|
||||||
@@ -1030,11 +1051,11 @@ class SoxClient:
|
|||||||
self._responses = None
|
self._responses = None
|
||||||
raise SoxException("Request timeout")
|
raise SoxException("Request timeout")
|
||||||
|
|
||||||
def _chunk_requests(self, reqs):
|
def _chunk_requests(self, reqs, send_retry=None):
|
||||||
results = []
|
results = []
|
||||||
for i in range(0, len(reqs), 255):
|
for i in range(0, len(reqs), 255):
|
||||||
chunk = reqs[i:i + 255]
|
chunk = reqs[i:i + 255]
|
||||||
results.extend(self.requests(chunk))
|
results.extend(self.requests(chunk, send_retry))
|
||||||
return results
|
return results
|
||||||
|
|
||||||
# called by receiver thread
|
# called by receiver thread
|
||||||
|
|||||||
Reference in New Issue
Block a user