Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,9 @@ socat -,raw,echo=0 TCP:172.17.32.17:35240
> on the same host as defib (or close to it on a LAN) is recommended.

The `-t` flag auto-detects the post-boot mode:
- **Normal U-Boot shell** (e.g. hi3516ev300): raw terminal passthrough — type commands directly
- **Normal U-Boot shell** (e.g. hi3516ev300): a two-way serial terminal — your
keystrokes go to the board and its output comes back. Ctrl-C exits the
terminal rather than being sent on to U-Boot.
- **Download command mode** (e.g. hi3516av200): interactive `defib>` prompt that wraps commands in HiSilicon's XHEAD/XCMD protocol, enabling flash operations on devices that enter `download_process()` after serial boot

```bash
Expand Down
32 changes: 24 additions & 8 deletions src/defib/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,16 @@ def on_sigint(*_: object) -> None:
if output == "human":
console.print("[dim]--- Session closed ---[/dim]")
else:
# Normal U-Boot shell — raw terminal passthrough
# Normal U-Boot shell — a real terminal, in both directions.
#
# It used to be one direction. The loop drained the port onto the
# screen and nothing read the keyboard, so `-t` handed you a live
# U-Boot prompt that ignored everything you typed
# (OpenIPC/firmware#2381). Keys are polled between serial reads
# rather than awaited on their own task: no thread, no executor,
# and nothing that can stall the loop holding the serial link.
from defib.cli.keyboard import raw_terminal, read_available_keys

if output == "human":
console.print("[dim]--- Terminal mode (Ctrl-C to exit) ---[/dim]")

Expand All @@ -409,13 +418,20 @@ def on_sigint(*_: object) -> None:
signal.signal(signal.SIGINT, on_sigint)

try:
while not stop:
try:
data = await transport.read(256, timeout=0.1)
_sys.stdout.buffer.write(data)
_sys.stdout.buffer.flush()
except Exception:
pass
with raw_terminal():
while not stop:
typed = read_available_keys()
if typed:
try:
await transport.write(typed)
except Exception:
break
try:
data = await transport.read(256, timeout=0.1)
_sys.stdout.buffer.write(data)
_sys.stdout.buffer.flush()
except Exception:
pass
finally:
signal.signal(signal.SIGINT, signal.SIG_DFL)
if output == "human":
Expand Down
157 changes: 157 additions & 0 deletions src/defib/cli/keyboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""Keystrokes for the serial terminal.

`defib burn -t` advertised a U-Boot console and delivered a viewer: the
terminal-mode loop read the port and wrote the screen, and nothing anywhere
read the keyboard. The reporter in OpenIPC/firmware#2381 reached a live
`OpenIPC #` prompt on a camera whose flash they still had to rewrite, and it
answered none of what they typed.

Both readers here are non-blocking on purpose. They are polled from the same
loop that drains the serial port, so no thread and no executor is involved and
nothing can stall the event loop -- which during a recovery is also holding the
serial link to the board.
"""

from __future__ import annotations

import logging
import os
import sys
from contextlib import contextmanager
from typing import Any, Iterator, Protocol

logger = logging.getLogger(__name__)

# Windows reports a special key (arrows, function keys, keypad) as a marker
# byte followed by a scan code. Neither means anything to U-Boot, and passing
# them through types garbage at the prompt.
_WINDOWS_SPECIAL_PREFIXES = (b"\x00", b"\xe0")

# Most one poll may collect before handing the loop back. A person types a
# few bytes; a pipe never stops, and draining it until it pauses would keep
# the serial read and the stop flag waiting for as long as the producer runs.
_MAX_BYTES_PER_POLL = 4096


class _HasFileno(Protocol):
"""Anything a terminal can be configured on -- in practice sys.stdin."""

def fileno(self) -> int:
...


@contextmanager
def raw_terminal(stream: _HasFileno | None = None) -> Iterator[None]:
"""Send keystrokes as they are typed, not a line at a time.

Also turns off local echo: a serial console echoes back what it received,
so echoing locally as well shows every character twice.

Ctrl-C is deliberately left as the interrupt (cbreak keeps ISIG), because
that is how terminal mode has always been exited and the banner says so.
A no-op where there is no terminal to configure -- Windows, a pipe, a
captured stdin under pytest -- so callers need no platform branch.
"""
stream = stream if stream is not None else sys.stdin
if sys.platform == "win32":
yield
return
try:
import termios
import tty

fd = stream.fileno()
saved = termios.tcgetattr(fd)
except Exception:
yield
return
try:
tty.setcbreak(fd)
yield
finally:
_restore_terminal(fd, saved)


def read_available_keys() -> bytes:
"""Whatever has been typed since the last call, or b"" if nothing has.

Never waits. A caller polling this between serial reads keeps typing
responsive without giving up the loop.
"""
if sys.platform == "win32":
return _read_windows()
return _read_posix()


def _read_windows() -> bytes:
try:
import msvcrt
except ImportError: # pragma: no cover - only absent off Windows
return b""

out = bytearray()
while len(out) < _MAX_BYTES_PER_POLL and msvcrt.kbhit(): # type: ignore[attr-defined]
char = msvcrt.getch() # type: ignore[attr-defined]
if char in _WINDOWS_SPECIAL_PREFIXES:
msvcrt.getch() # type: ignore[attr-defined] # drop the scan code
continue
out += char
return bytes(out)


def _read_posix() -> bytes:
import select

try:
fd = sys.stdin.fileno()
except (AttributeError, OSError, ValueError):
return b""

out = bytearray()
while len(out) < _MAX_BYTES_PER_POLL:
try:
ready, _, _ = select.select([fd], [], [], 0)
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
except (OSError, ValueError):
break
if not ready:
break
try:
chunk = os.read(fd, min(256, _MAX_BYTES_PER_POLL - len(out)))
except OSError:
break
if not chunk: # EOF on a pipe
break
out += chunk
return bytes(out)


def _restore_terminal(fd: int, saved: Any) -> None:
"""Put the terminal back the way it was, and say so if that fails.

This is the only thing between a failed restore and a shell with no echo
and no line editing. Discarding the failure -- which is what this used to
do -- leaves the operator with a broken terminal and nothing to explain it.

TCSADRAIN waits for pending output and so can be the half that fails; it is
the right first choice because it does not truncate what the board was
printing, and TCSANOW is worth trying before giving up.

Never raises. The body may already be unwinding with the error that
actually matters, and a cleanup failure must not replace it.
"""
import termios

for when in (termios.TCSADRAIN, termios.TCSANOW):
try:
termios.tcsetattr(fd, when, saved)
return
except Exception as exc: # noqa: PERF203 - the retry is the point
logger.debug("tcsetattr(%s) failed: %s", when, exc)

# stderr rather than logging alone: this has to reach someone whose
# terminal has just stopped echoing, whatever their logging setup is.
print(
"defib could not restore your terminal settings. "
"Run `stty sane` (you may have to type it blind) to get echo back.",
file=sys.stderr,
)
7 changes: 7 additions & 0 deletions src/defib/network/ip_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,13 @@ def _windows_interfaces() -> list[str]:
logger.warning("Could not list adapters with netsh: %s", exc)
return []

# Check the exit status, as the async twin does. Without this a netsh that
# failed left us parsing empty stdout and reporting "no adapters", which
# looks like a host with no network rather than a command that did not run.
if proc.returncode != 0:
logger.warning("netsh could not list adapters: %s", proc.stderr.strip())
return []

return _parse_netsh_interfaces(proc.stdout)


Expand Down
4 changes: 2 additions & 2 deletions src/defib/profiles/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def load_profile(chip_name: str, profiles_dir: Path | None = None) -> SoCProfile
if not profile_path.exists():
raise FileNotFoundError(f"No profile found for chip: {current}")

content = profile_path.read_text().strip()
content = profile_path.read_text(encoding="utf-8").strip()

# Check if this is an alias (single token ending in .json)
tokens = content.split()
Expand Down Expand Up @@ -141,7 +141,7 @@ def list_variants(chip_name: str, profiles_dir: Path | None = None) -> list[str]
profile_path = profiles_dir / f"{current}.json"
if not profile_path.exists():
return []
content = profile_path.read_text().strip()
content = profile_path.read_text(encoding="utf-8").strip()
tokens = content.split()
if len(tokens) == 1 and tokens[0].endswith(".json"):
current = tokens[0][:-5]
Expand Down
2 changes: 1 addition & 1 deletion tests/test_agent_binary_lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def test_every_chip_the_agent_makefile_builds_is_reachable(self):
"""The map is what `defib agent` will accept; the Makefile is the truth."""
makefile = (
Path(__file__).parent.parent / "agent" / "Makefile"
).read_text()
).read_text(encoding="utf-8")
built = {
line.split("$(SOC),")[1].split(")")[0]
for line in makefile.splitlines()
Expand Down
23 changes: 14 additions & 9 deletions tests/test_ip_manager_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,21 @@ def test_windows_does_not_go_through_if_nameindex(self, monkeypatch):
@pytest.mark.skipif(
not sys.platform.startswith("win"), reason="needs a real netsh",
)
def test_real_netsh_yields_at_least_one_adapter(self):
"""The mocks above pin the parser; this pins it against the real thing.

CI runs this matrix on windows-latest, so the one claim the fix rests
on -- that netsh can be asked for names netsh will accept -- is checked
on a real Windows host rather than only against a captured table.
def test_real_netsh_runs_and_parses_cleanly(self):
"""The mocks above pin the parser; this runs it against the real thing.

What is asserted is ours: netsh is callable, its output parses, and
nothing that is not an adapter name comes back. Whether this host has
adapters to list is not. An earlier version of this test demanded at
least one and duly failed on a runner that listed none -- which said
nothing about the parser and blocked a merge for it.
"""
assert _windows_interfaces(), (
"netsh listed no adapters; the parser or the command has drifted"
)
names = _windows_interfaces()
assert isinstance(names, list)
assert all(isinstance(n, str) and n.strip() for n in names)
# A header row surviving the shape test is the failure mode that
# matters, and it would show up here whatever the host's adapters are.
assert not any("Interface Name" in n for n in names)


class TestAddressAlreadyThere:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_profiles_usb_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ def test_idblock_stays_at_its_fixed_offset(self):
def test_profile_json_is_minimal(self):
"""A USB profile carrying UART bytecode would mean someone copied the
wrong template."""
data = json.loads((PROFILES_DIR / "rv1106.json").read_text())
data = json.loads((PROFILES_DIR / "rv1106.json").read_text(encoding="utf-8"))
assert not {"DDRSTEP0", "PRESTEP0", "ADDRESS", "FILELEN"} & set(data)


Expand Down
Loading
Loading