diff --git a/pureboot/pureboot.py b/pureboot/pureboot.py index 465899e..54332ac 100644 --- a/pureboot/pureboot.py +++ b/pureboot/pureboot.py @@ -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