Compare commits

...
2 Commits
Author SHA1 Message Date
ArdaandClaude Opus 5.5 c8c68f7b31 Note why the message-length header is kept off the P-ION
Tested on a P-ION (fw 1.32) at 192.168.1.252 with everything else as
in 5acca52: with the 0x81 header forced on, the app save is answered
but the app.sab file open never is, and the device drops off the
network. Without it, the same Get with File works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:57:39 +03:00
ArdaandClaude Opus 5.5 5acca522bf DASP: drop the P-ION resend overrides, use standard 512-byte packets
The save and file-open requests used their own fixed resend intervals
(5 s / 15 s) and the hello asked for 256-byte packets. Ontrol's Sedona
Java stack does neither and works with the P-ION: the standard backoff
(1, 2, 4, 8 s) sends a busy device a few copies with the same seqNum,
which its receive window drops. Both are removed.

Also fixes a race in SendWindow.send(): the packet entered the window
before its payload and sent_time were set, so housekeeping could resend
it with an empty payload, and the device would then drop the real one
as a duplicate.

Tested on a P-ION at 192.168.1.252, App > Get with File: save 3.2 s
(2 resends), app.sab + kits.scode in 11.3 s with 500-byte chunks
(was 14.6 s at 256), device stayed up, files identical to a 256-byte
backup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:52:18 +03:00
5 changed files with 29 additions and 56 deletions
+2 -5
View File
@@ -23,7 +23,6 @@ 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"
@@ -163,14 +162,12 @@ def _source_entry(media, source_path):
def save_app(client): def save_app(client):
"""Invokes App.save on the device and waits for it to finish. """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 Ontrol's Niagara driver does this before every app.sab get.
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) comp = client.cache_get(0) or client.load(0)
slots = client.slot_registry.get((comp.kit_id, comp.type_id)) or [] 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) 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) client.invoke(0, slot["id"] if slot else 0)
class BackupResult: class BackupResult:
+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 configparser
import os import os
VERSION = "0.0.0.144" VERSION = "0.0.0.146"
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
+8 -15
View File
@@ -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", "send_retry", "next") "sent_time", "send_attempts", "next")
def __init__(self): def __init__(self):
self.seq_num = 0 self.seq_num = 0
@@ -465,7 +465,6 @@ 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
@@ -507,17 +506,17 @@ class SendWindow:
self._tail = None self._tail = None
self._cond.notify_all() self._cond.notify_all()
def send(self, payload, send_retry=None): def send(self, payload):
p = _Packet() p = _Packet()
p.send_retry = send_retry
p.enqueued_time = _ticks() p.enqueued_time = _ticks()
p.payload = payload
with self._cond: with self._cond:
p.seq_num = self._seq_num & 0xFFFF p.seq_num = self._seq_num & 0xFFFF
self._seq_num = (self._seq_num + 1) & 0xFFFF self._seq_num = (self._seq_num + 1) & 0xFFFF
# sent_time is stamped inside the lock: send_retries() must not see the
# packet in the window as never sent and resend it before its first send.
self._enqueue(p) self._enqueue(p)
p.payload = payload
p.sent_time = _ticks()
self._session._send_raw(self._to_msg(p)) self._session._send_raw(self._to_msg(p))
def _enqueue(self, p): def _enqueue(self, p):
@@ -531,6 +530,7 @@ class SendWindow:
else: else:
self._tail.next = p self._tail.next = p
self._tail = p self._tail = p
p.sent_time = _ticks()
self._size += 1 self._size += 1
self._cond.notify_all() self._cond.notify_all()
@@ -620,12 +620,6 @@ 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:
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) backoff_shift = min(p.send_attempts - 1, 32)
interval = min(self.send_retry * (1 << backoff_shift), interval = min(self.send_retry * (1 << backoff_shift),
self.max_retry_backoff) self.max_retry_backoff)
@@ -784,11 +778,10 @@ class DaspSession:
# ── Send / Receive ────────────────────────────────────────── # ── Send / Receive ──────────────────────────────────────────
def send(self, payload, send_retry=None): def send(self, payload):
"""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, send_retry) self.send_window.send(payload)
def receive(self, timeout=1.0): def receive(self, timeout=1.0):
if self.is_closed: if self.is_closed:
+1 -3
View File
@@ -10,7 +10,6 @@ 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
@@ -44,8 +43,7 @@ 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()
+14 -29
View File
@@ -35,18 +35,6 @@ 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
@@ -476,8 +464,9 @@ 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 # Ontrol's message-length header is for Orion only, as in the Niagara driver.
# sends it to a P-ION or R-ION. # A P-ION on firmware 1.32 takes it on most requests, but a file open sent with
# it goes unanswered and the device drops off the network for ~20 s.
if self._session is not None: if self._session is not None:
self._session.include_message_len = platform_id.startswith("ontrol-orion") 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
@@ -678,13 +667,13 @@ class SoxClient:
# ── Invoke Action ─────────────────────────────────────────── # ── Invoke Action ───────────────────────────────────────────
def invoke(self, comp_id, slot_id, type_id=None, arg=None, send_retry=None): def invoke(self, comp_id, slot_id, type_id=None, arg=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, send_retry) res = self.request(req)
res.check_response('I') res.check_response('I')
# ── Add Component ─────────────────────────────────────────── # ── Add Component ───────────────────────────────────────────
@@ -950,7 +939,7 @@ class SoxClient:
opened = closed = False opened = closed = False
try: try:
try: try:
res = self.request(req, FILE_OPEN_SEND_RETRY) res = self.request(req)
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
@@ -1002,21 +991,17 @@ class SoxClient:
# ── Networking ────────────────────────────────────────────── # ── Networking ──────────────────────────────────────────────
def request(self, req, send_retry=None): def request(self, req):
results = self.requests([req], send_retry) results = self.requests([req])
return results[0] return results[0]
def requests(self, reqs, send_retry=None): def requests(self, reqs):
"""Sends the batch and waits for every reply. """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, send_retry) return self._chunk_requests(reqs)
with self._lock: with self._lock:
for i, req in enumerate(reqs): for i, req in enumerate(reqs):
@@ -1027,7 +1012,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(), send_retry) self._session.send(req.to_bytes())
deadline = time.monotonic() + 30.0 deadline = time.monotonic() + 30.0
while True: while True:
@@ -1051,11 +1036,11 @@ class SoxClient:
self._responses = None self._responses = None
raise SoxException("Request timeout") raise SoxException("Request timeout")
def _chunk_requests(self, reqs, send_retry=None): def _chunk_requests(self, reqs):
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, send_retry)) results.extend(self.requests(chunk))
return results return results
# called by receiver thread # called by receiver thread