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
20 changes: 15 additions & 5 deletions cli/src/mpftp/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1547,7 +1547,8 @@ def cmd_bootloader(ns: argparse.Namespace) -> None:


def cmd_usb_restart(ns: argparse.Namespace) -> None:
"""Re-enumerate an ESP32's USB node after `bootloader` wedges it (mpftp#31).
"""Re-enumerate an ESP32's USB node after `bootloader` wedges it, or restart a
CH343 USB-serial bridge whose COM port answers "Incorrect function" (mpftp#31).

No board connection and no elevation: this drives the SYSTEM scheduled task
that does the privileged part. --status first, always -- the task on a given
Expand All @@ -1556,7 +1557,7 @@ def cmd_usb_restart(ns: argparse.Namespace) -> None:
from . import espusb

if ns.list:
out({"devices": espusb.espressif_devices()})
out({"devices": espusb.restartable_devices()})
return
if ns.status or not ns.instance:
state = espusb.task_state()
Expand Down Expand Up @@ -2549,10 +2550,19 @@ def build_parser() -> argparse.ArgumentParser:

ur = sub.add_parser(
"usb-restart",
help="Windows: re-enumerate an ESP32 USB node wedged by `bootloader` (no elevation)",
help="Windows: restart an ESP32 USB node wedged by `bootloader`, or a CH343 serial port that "
"answers 'Incorrect function' (no elevation)",
)
ur.add_argument(
"--instance",
help="Device instance id, e.g. 'USB\\VID_303A&PID_4003\\<serial>' (Espressif) or "
"'USB\\VID_1A86&PID_55D3\\<serial>' (CH343)",
)
ur.add_argument(
"--list",
action="store_true",
help="List attached VID_303A (Espressif) and VID_1A86 (CH343) devices and their instance ids",
)
ur.add_argument("--instance", help="Device instance id, e.g. 'USB\\VID_303A&PID_4003\\<serial>'")
ur.add_argument("--list", action="store_true", help="List attached VID_303A devices and their instance ids")
ur.add_argument(
"--status",
action="store_true",
Expand Down
35 changes: 21 additions & 14 deletions cli/src/mpftp/espusb.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""No-UAC recovery for an ESP32 wedged on its own USB (mpftp#31).
"""No-UAC recovery for an ESP32 wedged on its own USB, or a CH343 port that
answers "Incorrect function" (mpftp#31).

An ESP32-S3 whose only serial line is its native USB -- the LilyGO T-Embed --
cannot be reset by the host: DTR and RTS go nowhere, so `mpftp bootloader` asks
Expand Down Expand Up @@ -34,13 +35,14 @@

TASK_NAME = "mpftp-restart-esp-usb"

#: The one shape a request may take: the composite parent of an Espressif
#: (VID_303A) USB device. Anchored at both ends, so a trailing ``; calc`` or a
#: second path component cannot ride along, and the ``\\`` after the product id
#: The one shape a request may take: the parent node of an Espressif (VID_303A)
#: or WCH CH343 USB-serial bridge (VID_1A86) device. Anchored at both ends, so a
#: trailing ``; calc`` or a second path component cannot ride along, and the
#: ``\\`` after the product id
#: rejects ``&MI_00`` children -- restarting the parent brings its interfaces
#: with it. The task's PowerShell side enforces this same rule independently;
#: this copy is here so a bad id is refused before it is ever written down.
INSTANCE_ID_RE = re.compile(r"^USB\\VID_303A&PID_[0-9A-Fa-f]{4}\\[0-9A-Za-z&_.\-]+$")
INSTANCE_ID_RE = re.compile(r"^USB\\VID_(303A|1A86)&PID_[0-9A-Fa-f]{4}\\[0-9A-Za-z&_.\-]+$")

MAX_REQUEST_CHARS = 200
_TASK_WAIT_SECS = 45.0
Expand Down Expand Up @@ -108,14 +110,16 @@ def log_path() -> Path:

# ------------------------------------------------------------------ devices

def espressif_devices() -> list[dict[str, str]]:
"""Every attached VID_303A composite parent, so ids are found and not guessed.
def restartable_devices() -> list[dict[str, str]]:
"""Every attached VID_303A (Espressif) or VID_1A86 (CH343) parent node, so ids
are found and not guessed.

``&MI_`` children are left out: they are what you must *not* restart.
"""
out = _ps(
"Get-PnpDevice -PresentOnly "
"| Where-Object { $_.InstanceId -like 'USB\\VID_303A*' -and $_.InstanceId -notmatch '&MI_' } "
"| Where-Object { ($_.InstanceId -like 'USB\\VID_303A*' -or $_.InstanceId -like 'USB\\VID_1A86*') "
"-and $_.InstanceId -notmatch '&MI_' } "
"| ForEach-Object { $_.InstanceId + '|' + $_.Status + '|' + $_.FriendlyName }"
)
devices = []
Expand All @@ -135,8 +139,9 @@ def validate_instance_id(instance_id: str) -> str:
raise ValueError(f"instance id is {len(candidate)} chars, limit is {MAX_REQUEST_CHARS}")
if not INSTANCE_ID_RE.match(candidate):
raise ValueError(
f"{instance_id!r} is not an Espressif USB instance id. Expected the composite "
f"parent of a VID_303A device, e.g. 'USB\\VID_303A&PID_4003\\3485186BFCAC0000' -- "
f"{instance_id!r} is not an Espressif or CH343 USB instance id. Expected the parent "
f"node of a VID_303A (Espressif) or VID_1A86 (WCH CH343) device, e.g. "
f"'USB\\VID_303A&PID_4003\\3485186BFCAC0000' or 'USB\\VID_1A86&PID_55D3\\5ABA052144' -- "
f"not an &MI_ child, not another vendor, nothing appended. "
f"`mpftp usb-restart --list` prints the attached ones."
)
Expand Down Expand Up @@ -230,7 +235,7 @@ def _task_is_running() -> bool:


def restart_device(instance_id: str, wait: float = _TASK_WAIT_SECS) -> dict[str, Any]:
"""Restart one Espressif USB node through the task. No elevation here.
"""Restart one Espressif or CH343 USB node through the task. No elevation here.

Returns the task's exit code and the tail of its transcript. The exit codes
are the script's: 0 restarted, 2 no request, 3 request refused, 4 no such
Expand Down Expand Up @@ -286,9 +291,11 @@ def restart_device(instance_id: str, wait: float = _TASK_WAIT_SECS) -> dict[str,
1: "PowerShell itself failed before the script ran -- check the task's action.",
2: "the task found no request file. Something consumed it first, or the write did not land.",
3: "the request was refused as malformed. mpftp validates the same rule before writing, "
"so this means the file was changed between the two.",
4: "no device with that instance id is attached. It may already have re-enumerated -- "
"look for 303A:1001 on a new COM number.",
"so this means the file was changed between the two -- or, for a CH343 (VID_1A86) id, "
"that the installed script predates CH343 support and an administrator needs to run "
"tools/windows/install-restart-esp-usb-task.ps1 again.",
4: "no device with that instance id is attached. An Espressif board may already have "
"re-enumerated -- look for 303A:1001 on a new COM number.",
5: "the restart itself failed. The transcript has pnputil's own words.",
6: "the restart was accepted and the node came back not OK -- same instance id, still "
"there, still broken, and no COM port behind it. The transcript names the Problem "
Expand Down
6 changes: 4 additions & 2 deletions cli/tests/esp_usb_doubles.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ param(
[Parameter(Mandatory = $true)][string]$Script,
[Parameter(Mandatory = $true)][string]$Scenario,
[Parameter(Mandatory = $true)][string]$Calls,
[Parameter(Mandatory = $true)][string]$Log
[Parameter(Mandatory = $true)][string]$Log,
# Which device the script is asked to restart; an Espressif board by default.
[string]$InstanceId = 'USB\VID_303A&PID_4003\D0UB1E0000000000'
)

$script:DoubleCalls = $Calls
Expand Down Expand Up @@ -92,6 +94,6 @@ function pnputil.exe {
}
}

. $Script -InstanceId 'USB\VID_303A&PID_4003\D0UB1E0000000000' -LogPath $Log
. $Script -InstanceId $InstanceId -LogPath $Log
# `exit` in a dot-sourced script returns here, with its code in $LASTEXITCODE.
exit $LASTEXITCODE
91 changes: 84 additions & 7 deletions cli/tests/test_esp_usb_restart.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
one-line check ``-ne 1`` -> ``-lt 1`` 1 red, two devices in one request
link checks made to return $null 4 red, every link case
contents echoed on a link refusal 2 red, incl. the leak assertion

CH343 support (2026-10-20) was shown the same way, by running these against the
script as it was on main: 5 red, all the CH343 ones.
"""

from __future__ import annotations
Expand All @@ -37,7 +40,11 @@

TEMBED = r"USB\VID_303A&PID_4003\3485186BFCAC0000"
ABSENT = r"USB\VID_303A&PID_4003\DEADBEEFDEADBEEF"
P4_UART = r"USB\VID_1A86&PID_55D3\5ABA052144"
CH343 = r"USB\VID_1A86&PID_55D3\5ABA052144"
CH343_ABSENT = r"USB\VID_1A86&PID_55D3\DEADBEEF00"
CH343_MI_CHILD = r"USB\VID_1A86&PID_55D2&MI_00\6&1B2C3D4&0&0000"
FTDI = r"USB\VID_0403&PID_6001\A50285BI"
CP210X = r"USB\VID_10C4&PID_EA60\0001"
MI_CHILD = r"USB\VID_303A&PID_4003&MI_00\6&BFF214A&0&0000"


Expand All @@ -50,11 +57,31 @@ def test_a_composite_parent_is_accepted(self):
def test_surrounding_whitespace_is_trimmed_not_refused(self):
self.assertEqual(espusb.validate_instance_id(f" {TEMBED}\n"), TEMBED)

def test_a_ch343_parent_is_accepted(self):
self.assertEqual(espusb.validate_instance_id(CH343), CH343)

def test_another_vendor_is_refused(self):
# The bench that found mpftp#31 had a second board whose UART must not
# be bounced; a VID check is what keeps it out.
# A VID check is what keeps every other bridge out. Only Espressif and
# WCH are on the list; FTDI and Silicon Labs are not.
for other in (FTDI, CP210X):
with self.assertRaises(ValueError):
espusb.validate_instance_id(other)

def test_a_ch343_mi_child_is_refused(self):
with self.assertRaises(ValueError):
espusb.validate_instance_id(P4_UART)
espusb.validate_instance_id(CH343_MI_CHILD)

def test_a_ch343_with_a_command_or_wildcard_is_refused(self):
for bad in (CH343 + "; calc", CH343 + '" ; calc ; "', r"USB\VID_1A86&PID_55D3\*",
r"USB\VID_1A86*", CH343 + "\n" + CH343):
with self.assertRaises(ValueError):
espusb.validate_instance_id(bad)

def test_the_vid_is_not_a_prefix_match(self):
# VID_1A86 followed by more hex digits is a different vendor id.
for bad in (r"USB\VID_1A860&PID_55D3\5ABA052144", r"USB\VID_303A1&PID_4003\1"):
with self.assertRaises(ValueError):
espusb.validate_instance_id(bad)

def test_an_mi_child_is_refused(self):
with self.assertRaises(ValueError):
Expand Down Expand Up @@ -235,7 +262,22 @@ def test_an_empty_request_exits_2(self):
self.assertEqual(self._run(" \n\n"), 2)

def test_another_vendors_device_is_refused(self):
self.assertEqual(self._run(P4_UART + "\n"), 3)
self.assertEqual(self._run(FTDI + "\n"), 3)
self.assertEqual(self._run(CP210X + "\n"), 3)

def test_a_ch343_id_passes_the_allow_list(self):
# Well-formed, absent: past the allow-list (not 3) and stopped at the
# presence check (4), device untouched.
self.assertEqual(self._run(CH343_ABSENT + "\n"), 4)

def test_a_ch343_mi_child_is_refused(self):
self.assertEqual(self._run(CH343_MI_CHILD + "\n"), 3)

def test_a_ch343_with_a_trailing_command_is_refused(self):
self.assertEqual(self._run(CH343 + "; calc\n"), 3)

def test_a_ch343_and_an_espressif_in_one_request_are_refused(self):
self.assertEqual(self._run(CH343 + "\n" + TEMBED + "\n"), 3)

def test_an_mi_child_is_refused(self):
self.assertEqual(self._run(MI_CHILD + "\n"), 3)
Expand Down Expand Up @@ -387,7 +429,7 @@ def setUpClass(cls):
super().setUpClass()
shutil.copy(cls.DOUBLES, espusb._win_to_local(cls.work) / "esp_usb_doubles.ps1")

def _play(self, scenario: str) -> tuple[int, list[str]]:
def _play(self, scenario: str, instance_id: str | None = None) -> tuple[int, list[str]]:
calls = espusb._win_to_local(self.work) / "doubles.calls"
log = espusb._win_to_local(self.work) / "doubles.log"
# Delete before the run, both of them: a transcript left by the last
Expand All @@ -402,7 +444,8 @@ def _play(self, scenario: str) -> tuple[int, list[str]]:
"-Script", self.script,
"-Scenario", scenario,
"-Calls", self.work + r"\doubles.calls",
"-Log", self.work + r"\doubles.log"],
"-Log", self.work + r"\doubles.log",
*(["-InstanceId", instance_id] if instance_id else [])],
capture_output=True, text=True,
)
seen = calls.read_text(encoding="utf-8-sig").split("\n") if calls.exists() else []
Expand Down Expand Up @@ -449,6 +492,40 @@ def test_a_node_that_is_gone_afterwards_is_still_a_success(self):
self.assertIn("re-enumerated as a different device", self._doubles_log())


@unittest.skipIf(_powershell() is None, "needs Windows PowerShell (interop from WSL)")
class ACh343Restart(_PlaysScenarios, unittest.TestCase):
"""A CH343 comes back under the same id; an Espressif board may not.

Nothing about a USB-serial bridge re-enumerates into download mode, so the
script's "that id is gone, look for 303A:1001" success is the Espressif
rule only. A CH343 that is not there afterwards has no COM port behind it.
"""

def test_a_healthy_ch343_is_restarted_once(self):
code, calls = self._play("healthy", CH343)
self.assertEqual(code, 0)
self.assertEqual(calls, ["pnputil /restart-device"])

def test_a_ch343_that_comes_back_not_ok_is_not_a_success(self):
code, _ = self._play("error-after", CH343)
self.assertEqual(code, 6)

def test_a_ch343_that_is_gone_afterwards_is_not_a_success(self):
code, _ = self._play("gone-after", CH343)
self.assertEqual(code, 6)
self.assertNotIn("303A:1001", self._doubles_log())

def test_an_espressif_that_is_gone_afterwards_still_is(self):
# The control: the Espressif rule is unchanged.
code, _ = self._play("gone-after", TEMBED)
self.assertEqual(code, 0)

def test_a_disabled_ch343_is_enabled_before_it_is_restarted(self):
code, calls = self._play("disabled", CH343)
self.assertEqual(code, 0)
self.assertEqual(calls, ["pnputil /enable-device", "pnputil /restart-device"])


@unittest.skipIf(_powershell() is None, "needs Windows PowerShell (interop from WSL)")
class ItNeverLeavesANodeDisabled(_PlaysScenarios, unittest.TestCase):
"""The restart itself, run for real over pretend hardware.
Expand Down
29 changes: 26 additions & 3 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -679,10 +679,30 @@ around it:**

```bash
mpftp usb-restart --status # exits 1, and says why, if it would not work
mpftp usb-restart --list # attached VID_303A devices and their instance ids
mpftp usb-restart --list # attached VID_303A (Espressif) and VID_1A86 (CH343) devices and their instance ids
mpftp usb-restart --instance 'USB\VID_303A&PID_4003\<serial>'
mpftp usb-restart --instance 'USB\VID_1A86&PID_55D3\<serial>' # a CH343 serial bridge
```

The task covers two kinds of device, one per run and always by explicit
instance id: an Espressif board on its native USB (`VID_303A`), and a WCH
CH343 USB-serial bridge (`VID_1A86`), the UART port on the P4 and many other
boards. Nothing else is accepted.

**A CH343 port that answers "Incorrect function".** When a COM port behind a
CH343 fails every open with Windows error 1, "Incorrect function", the node
usually still reads `Status OK` and `mpftp ports` still lists it. The vendor
driver (`CH343SER`) binds straight to the `USB\VID_1A86&PID_55D3\<serial>` node
(there is no composite parent above it), so restarting that node stops and
restarts the driver instance, the same thing Device Manager's Disable and
Enable does. Find the id with `--list`, restart it, and the port should
answer again under the **same** instance id and COM number. A CH343 has no
download mode to re-enumerate into, so unlike an Espressif board a node that
is *gone* afterwards is a failure (exit 6), not a success. Whether a restart
clears every "Incorrect function" is not something this documentation can
promise: it reloads the driver, which is the usual cause, and if the port is
still refusing opens afterwards the bridge itself needs unplugging.

`--instance` exits non-zero when the restart did not leave a reachable board,
and `taskResult` says which wall it hit: 4 no such device, 5 the restart itself
failed, 6 the restart was accepted and the node came back not OK. A 6 is the
Expand All @@ -704,7 +724,9 @@ success for *starting* a task, never for what it did, so read
`mpftp usb-restart` read it for you.

**It needs one elevated install before any of that works**, and an agent must
not try to do it. Copy `tools/windows/` to a plain Windows path — an elevated
not try to do it. The install copies the script, so a machine that installed
before CH343 support needs it run again: until then a CH343 id is refused with
exit 3. Copy `tools/windows/` to a plain Windows path — an elevated
shell cannot reliably read `\\wsl.localhost\...`, and on this bench it could
not read it at all — then ask the owner to run, once, in an administrator
PowerShell:
Expand All @@ -722,7 +744,8 @@ The task runs as SYSTEM, so it must never execute anything an ordinary account
can write: the script is installed into `C:\Program Files\mpftp`, and changing
it costs another elevated install. What you *do* write unprivileged is one
instance id into `C:\ProgramData\mpftp\restart-esp-usb.target`, which the
script matches whole against an allow-list for a VID_303A composite parent,
script matches whole against an allow-list for a VID_303A (Espressif) or
VID_1A86 (CH343) parent node,
never evaluates, and deletes on read. Name the device explicitly — the old
task matched `USB\VID_303A*` and would have bounced every Espressif board on
the bench together, which on this one is a second board mid-demo.
Expand Down
20 changes: 16 additions & 4 deletions tools/windows/install-restart-esp-usb-task.ps1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Install (or repair) the no-UAC ESP32 USB recovery: run this ONCE, elevated.
# Install (or repair) the no-UAC ESP32 / CH343 USB recovery: run this ONCE, elevated.
#
# Restarting a USB device node needs elevation, and an unattended session has
# nobody to click a UAC prompt. The way round that is a scheduled task running
Expand Down Expand Up @@ -172,8 +172,8 @@ $settings.AllowDemandStart = $true

Register-ScheduledTask -TaskName $TaskName -Action $action -Principal $taskPrincipal `
-Settings $settings -Description (
'mpftp#31: restart one Espressif USB device node named by ' + $requestPath +
'. Recovers an ESP32-S3 wedged by machine.bootloader() without a UAC prompt.') -Force | Out-Null
'mpftp#31: restart one Espressif or WCH CH343 USB device node named by ' + $requestPath +
'. Recovers an ESP32-S3 wedged by machine.bootloader(), or a CH343 port answering Incorrect function, without a UAC prompt.') -Force | Out-Null
Write-Output "registered task '$TaskName' -> $target"

# Let ordinary accounts start it, which is the entire point. This is the
Expand Down Expand Up @@ -213,6 +213,17 @@ if ($LASTEXITCODE -ne 3) {
}
Write-Output "self-check a malformed instance id is refused with exit 3"

# The installed script must know the CH343 rule too: a well-formed id for a
# device that is not attached gets past the allow-list and stops at "no such
# device" (4). An older installed script would refuse it with 3.
& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $target `
-InstanceId 'USB\VID_1A86&PID_55D3\NOSUCHDEVICE0' -DryRun | Out-Null
if ($LASTEXITCODE -ne 4) {
Write-Error "self-check failed: a well-formed CH343 id for an absent device should exit 4, got $LASTEXITCODE"
exit 1
}
Write-Output "self-check a CH343 instance id passes the allow-list (exit 4, no such device)"

if (-not (Test-Path -LiteralPath $keep)) {
Write-Error "self-check failed: $keep is missing, so the request directory could be emptied and converted into a junction"
exit 1
Expand All @@ -237,7 +248,8 @@ Write-Output ''
Write-Output 'Done. Nothing else needs elevation. To prove it on a board, as an ordinary user:'
Write-Output ''
Write-Output ' mpftp usb-restart --status'
Write-Output ' mpftp usb-restart --instance "USB\VID_303A&PID_4003\<serial>"'
Write-Output ' mpftp usb-restart --instance "USB\VID_303A&PID_4003\<serial>" (Espressif)'
Write-Output ' mpftp usb-restart --instance "USB\VID_1A86&PID_55D3\<serial>" (CH343)'
Write-Output ''
Write-Output "or without mpftp: write the instance id into $requestPath, then"
Write-Output " schtasks /run /tn $TaskName"
Expand Down
Loading
Loading