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:
2026-10-05 08:15:12 +03:00
co-authored by Claude Opus 5.5
parent 6b23bafffc
commit 2880c2a6f2
6 changed files with 80 additions and 37 deletions
+22 -2
View File
@@ -23,6 +23,7 @@ import zipfile
import app_state
import sab_app
import sox
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, RION_KIT
APP_SAB = "app.sab"
@@ -159,6 +160,19 @@ def _source_entry(media, source_path):
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:
def __init__(self):
self.path = None
@@ -171,8 +185,9 @@ class BackupResult:
def get_with_file(client, path, status=None):
"""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
BackupResult; a failure to read app.sab or kits.scode raises.
The app is saved on the device first, so app.sab holds what is running. Runs off
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):
if status:
@@ -190,6 +205,11 @@ def get_with_file(client, path, status=None):
result = BackupResult()
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)
entries.append(("device/" + APP_SAB, sab))
entries.append(("device/" + KITS_SCODE, fetch(KITS_SCODE, 0.15, 0.35)))
+1 -1
View File
@@ -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.143"
VERSION = "0.0.0.144"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
+16 -7
View File
@@ -456,7 +456,7 @@ def _to_ack_more(seq_more, max_bytes):
class _Packet:
__slots__ = ("seq_num", "payload", "acked", "enqueued_time",
"sent_time", "send_attempts", "next")
"sent_time", "send_attempts", "send_retry", "next")
def __init__(self):
self.seq_num = 0
@@ -465,6 +465,7 @@ class _Packet:
self.enqueued_time = 0.0
self.sent_time = 0.0
self.send_attempts = 1
self.send_retry = None # seconds; None = the window's own interval
self.next = None
@@ -506,8 +507,9 @@ class SendWindow:
self._tail = None
self._cond.notify_all()
def send(self, payload):
def send(self, payload, send_retry=None):
p = _Packet()
p.send_retry = send_retry
p.enqueued_time = _ticks()
with self._cond:
p.seq_num = self._seq_num & 0xFFFF
@@ -618,9 +620,15 @@ class SendWindow:
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 p.send_retry is not None:
# A request the device is slow to take -- a P-ION saving its
# 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:
self._resend(p)
oldest = max(oldest, now - p.enqueued_time)
@@ -776,10 +784,11 @@ class DaspSession:
# ── 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:
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):
if self.is_closed:
+3 -1
View File
@@ -10,6 +10,7 @@ import xml.etree.ElementTree as ET
import app_state
import schema
import sox
from sox import SoxClient, SoxComponent, TREE, CONFIG, RUNTIME, LINKS, ALL
@@ -43,7 +44,8 @@ class DeviceSession:
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 = 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.checksum_resolver = schema.resolve_truncated_checksum
self.sox_client.connect()
+7 -16
View File
@@ -619,9 +619,9 @@ _APP_ACTIONS = ["save", "hibernate", "quit", "restart", "reboot"]
def do_get_with_file():
"""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
offered a save first. The transfer runs off the main thread behind a small progress
window.
The app is saved on the device before its app.sab is read, as Ontrol's Niagara
driver does, so unsaved changes are announced first and the backup can be called
off. The transfer runs off the main thread behind a small progress window.
"""
ds = app_state.device_session
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).")
return
if app_state.is_dirty:
answer = messagebox.askyesnocancel(
if app_state.is_dirty and not messagebox.askokcancel(
"Unsaved Changes",
"The app has changes that are not saved on the device yet." + NL + NL +
"Save the app on the device before getting it?" + NL +
"(No gets the app as it was last saved.)",
)
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
"Get with File saves the app on the device first, so they will be saved."):
return
path = _ask_backup_path(ds)
if not path:
@@ -683,6 +673,7 @@ def do_get_with_file():
app_state.set_status(f"Get with File failed: {error}", warn=True)
messagebox.showerror("Get with File", f"The backup failed:{NL}{NL}{error}")
return
app_state.mark_clean() # the app was saved on the device first
_report_backup(result)
def work():
+31 -10
View File
@@ -35,6 +35,18 @@ BUF_ID = 7
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):
pass
@@ -464,6 +476,10 @@ class SoxClient:
# Same 512-byte limit as read_schema: whatever is cut off is left
# blank rather than failing the connect.
platform_id = res.try_str() or ""
# 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
kit_versions = []
for p in parts:
@@ -662,13 +678,13 @@ class SoxClient:
# ── 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.write_u2(comp_id)
req.write_u1(slot_id)
if arg is not None and type_id is not None:
encode_value(req, type_id, arg)
res = self.request(req)
res = self.request(req, send_retry)
res.check_response('I')
# ── Add Component ───────────────────────────────────────────
@@ -934,7 +950,7 @@ class SoxClient:
opened = closed = False
try:
try:
res = self.request(req)
res = self.request(req, FILE_OPEN_SEND_RETRY)
except SoxException as e:
# 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
@@ -986,16 +1002,21 @@ class SoxClient:
# ── Networking ──────────────────────────────────────────────
def request(self, req):
results = self.requests([req])
def request(self, req, send_retry=None):
results = self.requests([req], send_retry)
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:
raise SoxException("SoxClient closed")
if len(reqs) > 255:
return self._chunk_requests(reqs)
return self._chunk_requests(reqs, send_retry)
with self._lock:
for i, req in enumerate(reqs):
@@ -1006,7 +1027,7 @@ class SoxClient:
self._req_event.clear()
for req in reqs:
self._session.send(req.to_bytes())
self._session.send(req.to_bytes(), send_retry)
deadline = time.monotonic() + 30.0
while True:
@@ -1030,11 +1051,11 @@ class SoxClient:
self._responses = None
raise SoxException("Request timeout")
def _chunk_requests(self, reqs):
def _chunk_requests(self, reqs, send_retry=None):
results = []
for i in range(0, len(reqs), 255):
chunk = reqs[i:i + 255]
results.extend(self.requests(chunk))
results.extend(self.requests(chunk, send_retry))
return results
# called by receiver thread