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 sab_app
import sox
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, RION_KIT
APP_SAB = "app.sab"
@@ -163,14 +162,12 @@ def _source_entry(media, source_path):
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.
Ontrol's Niagara driver does this before every app.sab get.
"""
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)
client.invoke(0, slot["id"] if slot else 0)
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 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")
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
+11 -18
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", "send_retry", "next")
"sent_time", "send_attempts", "next")
def __init__(self):
self.seq_num = 0
@@ -465,7 +465,6 @@ 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
@@ -507,17 +506,17 @@ class SendWindow:
self._tail = None
self._cond.notify_all()
def send(self, payload, send_retry=None):
def send(self, payload):
p = _Packet()
p.send_retry = send_retry
p.enqueued_time = _ticks()
p.payload = payload
with self._cond:
p.seq_num = self._seq_num & 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)
p.payload = payload
p.sent_time = _ticks()
self._session._send_raw(self._to_msg(p))
def _enqueue(self, p):
@@ -531,6 +530,7 @@ class SendWindow:
else:
self._tail.next = p
self._tail = p
p.sent_time = _ticks()
self._size += 1
self._cond.notify_all()
@@ -620,15 +620,9 @@ class SendWindow:
p = self._head
while p is not None:
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)
interval = min(self.send_retry * (1 << backoff_shift),
self.max_retry_backoff)
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)
@@ -784,11 +778,10 @@ class DaspSession:
# ── Send / Receive ──────────────────────────────────────────
def send(self, payload, send_retry=None):
"""Queues a datagram. send_retry (seconds) overrides the resend interval."""
def send(self, payload):
if self.is_closed:
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):
if self.is_closed:
+1 -3
View File
@@ -10,7 +10,6 @@ import xml.etree.ElementTree as ET
import app_state
import schema
import sox
from sox import SoxClient, SoxComponent, TREE, CONFIG, RUNTIME, LINKS, ALL
@@ -44,8 +43,7 @@ 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,
ideal_max=sox.IDEAL_MAX, abs_max=sox.ABS_MAX)
self.sox_client = SoxClient(host, int(port), user, password)
self.sox_client.on_event = self._on_sox_event
self.sox_client.checksum_resolver = schema.resolve_truncated_checksum
self.sox_client.connect()
+14 -29
View File
@@ -35,18 +35,6 @@ 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
@@ -476,8 +464,9 @@ 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.
# Ontrol's message-length header is for Orion only, as in the Niagara driver.
# 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:
self._session.include_message_len = platform_id.startswith("ontrol-orion")
scode_flags = res.u1() if res.remaining > 0 else 0
@@ -678,13 +667,13 @@ class SoxClient:
# ── 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.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, send_retry)
res = self.request(req)
res.check_response('I')
# ── Add Component ───────────────────────────────────────────
@@ -950,7 +939,7 @@ class SoxClient:
opened = closed = False
try:
try:
res = self.request(req, FILE_OPEN_SEND_RETRY)
res = self.request(req)
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
@@ -1002,21 +991,17 @@ class SoxClient:
# ── Networking ──────────────────────────────────────────────
def request(self, req, send_retry=None):
results = self.requests([req], send_retry)
def request(self, req):
results = self.requests([req])
return results[0]
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.
"""
def requests(self, reqs):
"""Sends the batch and waits for every reply."""
if self.is_closed:
raise SoxException("SoxClient closed")
if len(reqs) > 255:
return self._chunk_requests(reqs, send_retry)
return self._chunk_requests(reqs)
with self._lock:
for i, req in enumerate(reqs):
@@ -1027,7 +1012,7 @@ class SoxClient:
self._req_event.clear()
for req in reqs:
self._session.send(req.to_bytes(), send_retry)
self._session.send(req.to_bytes())
deadline = time.monotonic() + 30.0
while True:
@@ -1051,11 +1036,11 @@ class SoxClient:
self._responses = None
raise SoxException("Request timeout")
def _chunk_requests(self, reqs, send_retry=None):
def _chunk_requests(self, reqs):
results = []
for i in range(0, len(reqs), 255):
chunk = reqs[i:i + 255]
results.extend(self.requests(chunk, send_retry))
results.extend(self.requests(chunk))
return results
# called by receiver thread