Skip to content

Commit d127828

Browse files
authored
Merge pull request #79 from JSydll/feature/sejo/auto-discover-oem-commands
protocol/fastboot: Automatically discover availability of oem commands
2 parents 0eee96e + 3935d30 commit d127828

3 files changed

Lines changed: 154 additions & 29 deletions

File tree

src/snagrecover/protocols/fastboot.py

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import usb
2222
import time
2323
import tempfile
24+
from typing import Optional, Union
25+
2426
from snagrecover import utils
2527
from snagflash.android_sparse_file.utils import split
2628

@@ -35,10 +37,16 @@
3537
for more information on fastboot support in U-Boot.
3638
"""
3739

40+
FASTBOOT_UNSUPPORTED_CMD_RESPONSE = b"Unsupported command"
41+
FASTBOOT_UNRECOGNIZED_CMD_RESPONSE = b"unrecognized command"
42+
43+
CHECK_OEM_RUN_CMD_SUPPORT = "oem run:version\x00"
44+
3845

3946
class FastbootError(Exception):
40-
def __init__(self, message):
47+
def __init__(self, message, data=None):
4148
self.message = message
49+
self.data = data
4250
super().__init__(self.message)
4351

4452
def __str__(self):
@@ -86,40 +94,43 @@ def __init__(self, dev: usb.core.Device, timeout: int = 10000):
8694

8795
self.max_size = MAX_LIBUSB_TRANSFER_SIZE
8896

89-
def cmd(self, packet: bytes):
90-
self.dev.write(self.ep_out, packet, timeout=self.timeout)
91-
status = ""
92-
t0 = time.time()
93-
while time.time() - t0 < 10 * self.timeout:
94-
ret = self.dev.read(self.ep_in, 256, timeout=self.timeout)
95-
status = bytes(ret[:4])
96-
if status == b"INFO":
97-
logger.debug(f"(bootloader) {bytes(ret[4:256])}")
98-
elif status == b"TEXT":
99-
logger.debug(f"(bootloader) {bytes(ret[4:256])}", end="")
100-
elif status == b"FAIL":
101-
raise FastbootError(f"Fastboot fail with message: {bytes(ret[4:256])}")
102-
elif status == b"OKAY":
103-
logger.debug("fastboot OKAY")
104-
return bytes(ret[4:])
105-
elif status == b"DATA":
106-
length = int("0x" + (bytes(ret[4:12]).decode("ascii")), base=16)
107-
logger.debug(f"fastboot DATA length: {length}")
108-
return length
109-
raise FastbootError("Timeout while completing fastboot transaction")
97+
# The support of OEM commands is depending on the configuration
98+
# u-boot was built with, so they need to be probed at runtime.
99+
# The command handler for oem run is actually ucmd in the sources,
100+
# therefore it's safe to use this as fallback.
101+
self.oem_run_basecmd = (
102+
"oem run" if self._is_cmd_supported(CHECK_OEM_RUN_CMD_SUPPORT) else "UCmd"
103+
)
110104

111-
def response(self):
105+
def _is_cmd_supported(self, cmd: str) -> bool:
106+
try:
107+
self.cmd(cmd)
108+
except FastbootError as e:
109+
if e.data and FASTBOOT_UNSUPPORTED_CMD_RESPONSE in e.data:
110+
return False
111+
return True
112+
113+
def cmd(
114+
self, packet: Optional[bytes] = None, loglevel=logging.DEBUG
115+
) -> Union[bytes, int]:
116+
if packet is not None:
117+
self.dev.write(self.ep_out, packet, timeout=self.timeout)
112118
t0 = time.time()
113119
while time.time() - t0 < 10 * self.timeout:
114120
ret = self.dev.read(self.ep_in, 256, timeout=self.timeout)
115121
status = bytes(ret[:4])
122+
data = bytes(ret[4:256])
116123
if status in [b"INFO", b"TEXT"]:
117-
logger.info(f"(bootloader) {bytes(ret[4:256])}", end="")
124+
logger.log(loglevel, f"(bootloader) {data}", end="")
118125
elif status == b"FAIL":
119-
raise FastbootError(f"Fastboot fail with message: {bytes(ret[4:256])}")
126+
raise FastbootError(f"Fastboot fail with message: {data}", data)
120127
elif status == b"OKAY":
121-
logger.info("fastboot OKAY")
122-
return bytes(ret[4:])
128+
logger.log(loglevel, "fastboot OKAY")
129+
return data
130+
elif packet is not None and status == b"DATA":
131+
length = int("0x" + (data.decode("ascii")), base=16)
132+
logger.log(loglevel, f"fastboot DATA length: {length}")
133+
return length
123134
raise FastbootError("Timeout while completing fastboot transaction")
124135

125136
def getvar(self, var: str):
@@ -133,7 +144,7 @@ def send(self, blob: bytes, padding: int = 0):
133144
self.cmd(packet)
134145
for chunk in utils.dnload_iter(blob + b"\x00" * padding, self.max_size):
135146
self.dev.write(self.ep_out, chunk, timeout=self.timeout)
136-
self.response()
147+
self.cmd(loglevel=logging.INFO)
137148

138149
def download(self, path: str, padding: int = 0):
139150
with open(path, "rb") as file:
@@ -191,7 +202,7 @@ def oem_run(self, cmd: str):
191202
"""
192203
Execute an arbitrary U-Boot command
193204
"""
194-
packet = f"oem run:{cmd}\x00"
205+
packet = f"{self.oem_run_basecmd}:{cmd}\x00"
195206
self.cmd(packet)
196207

197208
def oem_format(self):

tests.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
# Skip USB tests if no backend is available (e.g., in Windows CI env)
3535
print(f"Skipping USB tests: {e}")
3636

37+
print("Executing unit tests")
3738
unit_tests = unittest.TestLoader().discover("tests", "*.py")
3839

3940
unit_runner = unittest.TextTestRunner()

tests/protocols_fastboot.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import unittest
2+
from unittest.mock import MagicMock
3+
from enum import Enum, auto
4+
5+
from snagrecover.protocols.fastboot import Fastboot, FastbootError
6+
7+
DUMMY_CMD: str = "test_cmd"
8+
DUMMY_DATA: bytes = b"test_data"
9+
10+
11+
class ResponseType(Enum):
12+
INFO = auto()
13+
TEXT = auto()
14+
FAIL = auto()
15+
OKAY = auto()
16+
DATA = auto()
17+
TIMEOUT = auto()
18+
19+
20+
class TestFastboot(unittest.TestCase):
21+
@staticmethod
22+
def _get_usb_device_mock() -> MagicMock:
23+
# Mock endpoint with bulk IN/OUT attributes
24+
mock_ep_in = MagicMock()
25+
mock_ep_in.bmAttributes = 0x02 # ENDPOINT_TYPE_BULK
26+
mock_ep_in.bEndpointAddress = 0x81 # ENDPOINT_IN
27+
mock_ep_out = MagicMock()
28+
mock_ep_out.bmAttributes = 0x02 # ENDPOINT_TYPE_BULK
29+
mock_ep_out.bEndpointAddress = 0x01 # ENDPOINT_OUT
30+
31+
# Mock interface with endpoints
32+
mock_intf = MagicMock()
33+
mock_intf.endpoints.return_value = [mock_ep_in, mock_ep_out]
34+
35+
# Mock configuration with interfaces
36+
mock_cfg = MagicMock()
37+
mock_cfg.interfaces.return_value = [mock_intf]
38+
39+
mock_device = MagicMock()
40+
mock_device.get_active_configuration.return_value = mock_cfg
41+
42+
return mock_device
43+
44+
def _setup_fastboot(self, has_oem_run: bool = True) -> Fastboot:
45+
self.mock_device = self._get_usb_device_mock()
46+
self.mock_device.read.side_effect = [
47+
b"FAILunrecognized command\x00"
48+
if has_oem_run
49+
else b"FAILUnsupported command\x00"
50+
]
51+
self.fastboot = Fastboot(self.mock_device)
52+
self.assert_device_write("oem run:version\x00")
53+
self.mock_device.reset_mock()
54+
55+
def setUp(self) -> None:
56+
self._setup_fastboot()
57+
58+
def assert_device_write(self, expected_cmd: str) -> None:
59+
self.mock_device.write.assert_called_once_with(
60+
self.fastboot.ep_out, expected_cmd, timeout=self.fastboot.timeout
61+
)
62+
63+
def expect_device_response(
64+
self, response_type: ResponseType, data: bytes = b""
65+
) -> None:
66+
read_value = response_type.name.encode() + data
67+
if response_type == ResponseType.TIMEOUT:
68+
self.mock_device.read.side_effect = TimeoutError()
69+
return
70+
71+
self.mock_device.read.side_effect = [read_value]
72+
73+
# --- Generic cmd tests ---
74+
75+
def test_cmd_okay(self) -> None:
76+
self.expect_device_response(ResponseType.OKAY, DUMMY_DATA)
77+
result = self.fastboot.cmd(DUMMY_CMD)
78+
self.assert_device_write(DUMMY_CMD)
79+
self.assertEqual(result, DUMMY_DATA)
80+
81+
def test_cmd_fail(self) -> None:
82+
self.expect_device_response(ResponseType.FAIL, DUMMY_DATA)
83+
with self.assertRaises(FastbootError):
84+
self.fastboot.cmd(DUMMY_CMD)
85+
self.assert_device_write(DUMMY_CMD)
86+
87+
def test_cmd_info_or_text(self) -> None:
88+
for response in [ResponseType.INFO, ResponseType.TEXT]:
89+
with self.subTest(response=response):
90+
self.mock_device.reset_mock()
91+
self.mock_device.read.side_effect = [
92+
response.name.encode() + DUMMY_DATA,
93+
ResponseType.OKAY.name.encode() + b" waited for OKAY",
94+
]
95+
self.fastboot.cmd(DUMMY_CMD)
96+
self.assert_device_write(DUMMY_CMD)
97+
98+
# --- oem_run ---
99+
100+
def test_oem_run_accepted(self) -> None:
101+
subcommand = "test_oem_subcommand"
102+
self.expect_device_response(ResponseType.OKAY)
103+
self.fastboot.oem_run(subcommand)
104+
expected_cmd = f"oem run:{subcommand}\x00"
105+
self.assert_device_write(expected_cmd)
106+
107+
def test_oem_run_unavailable_fallback_to_ucmd(self) -> None:
108+
self._setup_fastboot(has_oem_run=False)
109+
110+
subcommand = "test_oem_subcommand"
111+
self.expect_device_response(ResponseType.OKAY, b" executed via UCmd")
112+
self.fastboot.oem_run(subcommand)
113+
self.assert_device_write(f"UCmd:{subcommand}\x00")

0 commit comments

Comments
 (0)