diff --git a/app_backup.py b/app_backup.py index 68061c4..4de06cd 100644 --- a/app_backup.py +++ b/app_backup.py @@ -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))) diff --git a/app_state.py b/app_state.py index 5a83730..de2a7fa 100644 --- a/app_state.py +++ b/app_state.py @@ -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 diff --git a/dasp.py b/dasp.py index c605527..7774698 100644 --- a/dasp.py +++ b/dasp.py @@ -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: diff --git a/device_session.py b/device_session.py index 6995481..d6d00f9 100644 --- a/device_session.py +++ b/device_session.py @@ -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() diff --git a/main.py b/main.py index 1e3f5d1..a19f019 100644 --- a/main.py +++ b/main.py @@ -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 /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(): diff --git a/sox.py b/sox.py index 428a085..e7970a4 100644 --- a/sox.py +++ b/sox.py @@ -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