one-wire: the knock's lost byte is the wiring, and the diagnosis was unreachable
Measured on an ATtiny13A with the link folded onto PB3 and the FTDI's TX reaching it through 1 k: a knock aimed at a loader already in session loses its second byte every time, 8 runs of 8, never intermittently. The first byte draws a prompt while the second is still going out and the device's push-pull ack wins the line against the resistor, so that byte is destroyed rather than delayed — which is what the README predicted and the sim bridge cannot show, since it arbitrates the line by queueing. The recovery for it existed and could not run. Two defects: OneWirePort.write read its echo with read_exact, whose contract is to raise, so the "one-wire echo missing — is the adapter's RX tied to the line?" message was unreachable on any line that simply fell quiet, and a bare "timeout: got 0 of 1 bytes" surfaced in its place. The one message the class exists to produce could never be produced. The read is speculative and is now read_available. And any raise from write aborted _handshake before the retry loop that exists to absorb exactly this, whose docstring already claimed it "converges into an already-live session" — true on a pty, impossible on real wiring. The knock is now the one write marked blind: a missing echo there is a property of the shared line, counted and reported under -v rather than raised. Every other write is ack-paced and cannot collide, so a missing echo there still means an RX that is not on the line, and still raises. Both gates green on Windows (31/31 m328p, 16/16 t13a); on hardware the reconnect now converges on the first knock, the surviving prompt being all the handshake needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -454,11 +454,26 @@ class OneWirePort:
|
||||
def __init__(self, port):
|
||||
self._port = port
|
||||
self._pending = b""
|
||||
self.lost_echoes = 0
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._port, name)
|
||||
|
||||
def write(self, data):
|
||||
def write(self, data, blind=False):
|
||||
"""Put `data` on the line and consume its echo.
|
||||
|
||||
`blind` marks the protocol's one multi-byte write with no ack between
|
||||
its bytes — the knock. Aimed at a loader already in session, its first
|
||||
byte draws a prompt while the second is still going out, and on real
|
||||
wiring the device's push-pull ack **wins the line** against the host's
|
||||
1 k series resistor: that second byte is *destroyed, not delayed*, and
|
||||
its echo never comes. Measured on an ATtiny13A at 57600 — the loader
|
||||
answers a single byte perfectly and loses the knock's second every
|
||||
time. So on a blind write a missing echo is a property of the wiring
|
||||
rather than a fault in it, and the caller's retry is what deals with
|
||||
it. Every other write is ack-paced and cannot collide, so a missing
|
||||
echo there really is an RX that is not on the line.
|
||||
"""
|
||||
data = bytes(data)
|
||||
self._port.write(data)
|
||||
# The echo arrives at line rate — 10 bits a byte — plus adapter
|
||||
@@ -466,16 +481,27 @@ class OneWirePort:
|
||||
# of the error path.
|
||||
deadline = time.monotonic() + 10 * len(data) / self._port.baud + 0.5
|
||||
remaining = data
|
||||
while remaining:
|
||||
budget = deadline - time.monotonic()
|
||||
if budget <= 0:
|
||||
raise Error(f"one-wire echo missing after {len(data) - len(remaining)} of "
|
||||
f"{len(data)} byte(s) — is the adapter's RX tied to the line?")
|
||||
byte = self._port.read_exact(1, budget)
|
||||
if byte == remaining[:1]:
|
||||
remaining = remaining[1:]
|
||||
else:
|
||||
self._pending += byte
|
||||
while remaining and time.monotonic() < deadline:
|
||||
# Speculative, so it cannot be read_exact, whose contract is to
|
||||
# raise: doing that made the diagnosis below unreachable on every
|
||||
# quiet line and surfaced a bare "timeout: got 0 of 1 bytes" in
|
||||
# its place — the one message this class exists to replace.
|
||||
for byte in self._port.read_available(0.02):
|
||||
if remaining and byte == remaining[0]:
|
||||
remaining = remaining[1:]
|
||||
else:
|
||||
self._pending += bytes((byte,))
|
||||
if not remaining:
|
||||
return
|
||||
if not blind:
|
||||
raise Error(f"one-wire echo missing after {len(data) - len(remaining)} of "
|
||||
f"{len(data)} byte(s) — is the adapter's RX tied to the line?")
|
||||
self.lost_echoes += len(remaining)
|
||||
verbose(f"one-wire: {len(remaining)} of {len(data)} knock byte(s) lost to the "
|
||||
f"device's ack; retrying")
|
||||
|
||||
def write_blind(self, data):
|
||||
self.write(data, blind=True)
|
||||
|
||||
def read_exact(self, count, timeout):
|
||||
taken, self._pending = self._pending[:count], self._pending[count:]
|
||||
@@ -658,9 +684,16 @@ class Loader:
|
||||
break
|
||||
knocks = 0
|
||||
refusal = None
|
||||
# The knock is the only write in the protocol with no ack between its
|
||||
# bytes, so on a shared line it is the only one whose echo may
|
||||
# legitimately not come back — the device's ack collides with it and
|
||||
# wins (OneWirePort.write). Losing a byte here is what the retry below
|
||||
# is for; raising instead aborted the loop before it ever ran, which on
|
||||
# real wiring made every reconnect into a live session fail.
|
||||
knock_out = getattr(self.port, "write_blind", self.port.write)
|
||||
while True:
|
||||
self.port.flush_input()
|
||||
self.port.write(knock)
|
||||
knock_out(knock)
|
||||
knocks += 1
|
||||
if PROMPT in self.port.read_available(0.4):
|
||||
# Settle: absorb a real loader's trailing bytes before asking
|
||||
|
||||
Reference in New Issue
Block a user