A Go SDK, and a CLI built on it, for controlling SDWireC and SDWire3 devices — USB-controlled SD card multiplexers that switch a single SD card between a host computer (for flashing images) and a target device under test (for booting them), without physically re-seating the card.
The CLI is a drop-in replacement for the official python package, but includes:
- hub-power fallback for SDWire3 (which otherwise can cause failures)
- disk unmounting from the host before switching to target mode
- support for power managing the target (currently using Meross WiFi power plugs)
- one-shot command for updaing the target SD card image; the flow:
- switch off target
- flash SD image
- re-mount to target
- power on target
This was originally a fork of github.com/fcjr/sdwire.
go install github.com/jphastings/sdwire/cmd/sdwire@latestOr download a prebuilt binary from the releases page (built via GoReleaser/CI for darwin, linux, and windows, amd64 and arm64).
go get github.com/jphastings/sdwiresdwire is a drop-in replacement for the list, state, and switch
commands of the Python sdwire-cli
(see Migrating from the Python CLI
below), plus new commands for flashing, power control, and scripting.
Every command accepts -s/--serial to select which attached device to
operate on: a plain USB serial, the port-suffixed identity form
(20120501030900000.1.1.3), a USB location (1-1.1.3), or a device name
from your config file. With no -s and no default_device
configured, commands that need a single device use the sole attached
device, or error out listing every attached device's identity if more than
one is found.
List every SDWire this computer knows about: its identity, USB product
info, the reader's resolved block device path (or None if it can't
currently be found), and which side its card is switched to.
$ sdwire list
Serial Product Info Block Dev State
20120501030900000.1.1.3 [0bda::0316] /dev/disk4 Host
Devices remembered in the hub-port cache are
listed even when they aren't on the USB bus, since an SDWire3 in target
mode is powered off and cannot be enumerated at all. For those, State is
read from the hub port without powering anything on:
| State | Meaning |
|---|---|
Host |
The card is connected to this computer. |
Target |
The port is unpowered: the card is with the target board. |
Unknown |
The port is powered but nothing is enumerated on it — an empty socket, or a reader that has crashed and been dropped from the bus. Recover the latter with sdwire revive. |
The first three columns keep the Python CLI's exact widths and separators,
so anything parsing that output by position still works; --json carries
the same information plus an attached flag.
A remembered port whose status can't be read — the dock it belongs to has
moved to a different USB port, say, which changes its bus number — is
skipped, and list warns how many it skipped and which file to delete to
forget them.
Print which side the selected device's SD card is currently connected to:
Host, Target, or Unknown. When the device is attached and powered on,
this is an honest live readback via the device — see
SDWire3 state semantics below for how a
powered-off (target mode) SDWire3 is handled instead.
$ sdwire state
Serial State
20120501030900000.1.1.3 Host
Switch the selected device's SD card. dut/target connect it to the
target board; host/ts connect it to this computer. off is rejected
with an explanation: SDWireC has no third state, and for SDWire3 "powering
the port off" is literally how target mode is implemented — it does not
mean disconnected from both sides. Prints nothing and exits 0 on success.
sdwire switch host # connect the card to this computer
sdwire switch target -s bench # connect the card to the "bench" device's targetPower-cycle the selected device's upstream hub port and wait for it to come back: the software equivalent of unplugging the device and plugging it back in. Any volumes mounted from its reader are unmounted first.
sdwire revive # the default (or -s named) device
sdwire revive -s 1-1.1.3 # by location, straight from live USB topologyUse it when a reader has stopped answering and been dropped from the bus —
sdwire list shows Unknown, or nothing at all. A USB port reset does not
clear that state; only removing power does, and the port is held dark long
enough for the reader's own supply to drain rather than merely reset.
Because such a device isn't enumerated, it can't be selected by serial
alone. -s/--serial accepts a location (1-1.1.3, as shown by
sdwire list), which is resolved from the live USB topology — the hub is
still there even when the device on it isn't — so a revive works even when
the hub-port cache is empty, stale, or names one serial at several ports.
Write an image to the selected device's SD card and boot the target from
it: powers the target off (if a power plugin is
configured for the device), switches to host mode, raw-writes the image
with progress on stderr, switches back to target mode, then powers the
target back on. Raw disk writes need elevated privileges — run with
sudo on macOS/Linux, or as Administrator on Windows.
sudo sdwire flash ./ubuntu-24.04-preinstalled.img.xz -s benchflashed 1234 / 3800 MiB (32%)
If the reader stops responding part-way through the write — see the reader-wedge entry below — the flash does not fail. Its hub port is power-cycled, the reader is waited for at the same USB location, and the write resumes from the last completed chunk, up to three times:
flashed 96 / 272 MiB (35%)
warning: the reader stopped responding 96 MiB into the write; power-cycling its hub port and resuming (attempt 1 of 3)
flashed 272 / 272 MiB (100%)
The warning is deliberately loud: a flash that needed several power cycles
should never look like a clean one. Errors that a power cycle cannot fix —
a full card, an unreadable image — still fail immediately rather than
retrying. WithWriteRetries in the SDK tunes or disables the recovery.
Without a power plugin configured for the device the flash still happens,
but nothing power-cycles the target afterwards: it carries on running
whatever it was before, which is indistinguishable from a board that won't
boot the image you just wrote. flash says so on stderr when it finishes,
and prints the config snippet that would fix it. --require-power turns
that into an error before any writing starts — worth it in scripts, where
a warning nobody reads is no use.
Drive the power plugin configured for the selected
device. on/off set target power directly; cycle powers off, waits at
least min_off_seconds, then powers back on. This never touches the
SDWire's USB connection — it's the normal way to boot a target whose SD
card is already switched to it. If the device has no power plugin
configured, this prints a ready-to-copy YAML snippet for your config file
and exits 1.
Print just the selected device's resolved block device path — nothing else — for use in scripts:
sudo dd if=image.img of=$(sdwire disk -s bench) bs=4M status=progressExits non-zero if the block device can't currently be found.
list, state, and disk accept --json for machine-readable output:
$ sdwire list --json
[
{
"serial": "20120501030900000",
"identity": "20120501030900000.1.1.3",
"location": "1-1.1.3",
"product": "USB3.0-CRW",
"generation": "SDWire3",
"state": "Host",
"attached": true,
"block_dev": "/dev/disk4"
}
]
$ sdwire state --json
{ "identity": "20120501030900000.1.1.3", "state": "Host" }
$ sdwire disk --json
{ "block_dev": "/dev/disk4" }SDK warnings (for example, an SDWire3 sitting behind a hub that only
switches port power in a ganged fashion, affecting sibling ports) always
print to stderr, whether or not --debug is set. --debug adds further
diagnostics — the resolved config path, which device a selector matched,
and so on.
During a flash, --debug also prints how long each chunk's write took and
its throughput:
debug: wrote 4096 KiB at offset 84 MiB in 121ms (33.1 MiB/s)
That is the number to watch when a reader is misbehaving: a healthy one
holds a steady rate, while one whose internal write buffer is backing up
slows chunk by chunk before it stalls outright. WithWriteTiming exposes
the same data to SDK callers.
sdwire completion bash|zsh|fish|powershellSee sdwire completion --help (and each shell's subcommand --help) for
how to load the generated script.
0 on success, 1 for an operational failure (device not found, config
error, flash failure, ...), 2 for a CLI usage error (bad flags, unknown
subcommand or argument).
Path: ~/.config/sdwire/config.yaml on every OS (not
os.UserConfigDir()'s platform-specific location — macOS's ~/Library/ Application Support in particular — this project deliberately uses one
fixed XDG-style path everywhere). Override with --config <path> or the
SDWIRE_CONFIG environment variable. Individual keys are also overridable
via SDWIRE_-prefixed environment variables (e.g. SDWIRE_DEFAULT_DEVICE,
with . replaced by _ for nested keys).
default_device: bench
devices:
bench:
serial: "20120501030900000" # or the port-suffixed identity form
location: "1-1.1.3" # optional; disambiguates identical Realtek serials
power:
type: meross
ip: 192.0.2.10
key: "<meross account key>"
# channel: 0 # optional
min_off_seconds: 8default_deviceis used whenever-s/--serialisn't given.- Each entry under
devicesnames a device you can pass to-s; either key alone is enough. Given both,locationis tried first (more specific — needed because every Realtek SDWire3 reader shares the same hardcoded USB serial number) andserialis the fallback: a location names the socket the device is plugged into, so moving it to another port makes the location stale. Commands then find the device by serial and warn that thelocation:line wants updating, rather than reporting the device missing. powerconfigures a power plugin for that device — see below. A device with nopowersection works fine forlist/state/switch/flash; onlysdwire powerrequires one.min_off_seconds(default8) is the minimum dark timesdwire power cycleandsdwire flashhold target power off for.
No config file at all is a fully supported setup: with exactly one SDWire
attached, list, state, switch, flash, and disk all work without
any configuration; power will explain what to add.
The CLI ships one registered power plugin type, meross (for Meross smart
plugs — see power/meross/README.md for how to
get your Meross account key). The SDK itself (sdwire.PowerFunc) is
plugin-agnostic; this registry, and the config wiring around it, lives in
the CLI. sdwire power on|off|cycle with an unrecognized power.type
lists the registered types in its error.
sdwire state and sdwire switch match the
Python sdwire-cli v0.3.1's
output byte-for-byte, and sdwire list keeps its first three columns at
the same widths and separators — with a State column appended, and rows
for devices remembered but not currently on the bus. Scripts reading those
three columns by position work unchanged once this binary is what sdwire
on your PATH resolves to. flash, power, disk, revive, --json,
and --debug are new.
PATH precedence. If you already have the Python CLI installed (e.g. via
pip), check which one PATH finds first:
which -a sdwirego install puts this binary at $(go env GOPATH)/bin/sdwire (typically
~/go/bin/sdwire); make sure that directory precedes wherever the Python
version lives (often an Anaconda/Miniconda bin directory, or a pip
--user install path) in PATH, or pip uninstall sdwire the Python one.
For SDWire3, a device currently in target mode is, physically, powered
off — it isn't enumerable on USB at all. sdwire state never powers a
device on to answer: when there's nothing live to read, it falls back to
the on-disk hub-port cache and a direct hub port-status read, which is
enough to report Target honestly without side effects. sdwire switch dut/target does the same check before doing any work, so switching a
device that's already in target mode is also a no-op. Only commands that
need the SD card to actually move data — switch host/ts and flash —
revive a powered-off SDWire3 via the cache, since restoring power is
inherent to what those commands do.
That cache is a separate file from your config, at <user cache dir>/sdwire/hubports.json — ~/Library/Caches/sdwire/hubports.json on
macOS, ~/.cache/sdwire/hubports.json on Linux,
%LocalAppData%\sdwire\hubports.json on Windows. Any command that finds a
device live rewrites its entry, so deleting the file is safe: the worst
that happens is a device sitting in target mode can't be found again until
it's powered back on some other way.
A device moved between sockets over its life leaves one entry per socket,
and nothing removes the old ones — every SDWire3 shares one Realtek serial,
so a bare serial can name several remembered ports. Each entry records when
the device was last seen there, and a device that isn't on the bus is taken
to be at the port it was most recently seen at: state, switch dut's
already-in-target check and revive by serial answer for that one, and
switch host powers it on first. Entries written before this was recorded
have no sighting to compare, so they still report the ambiguity until the
device is next seen live.
Flashing needs raw block device access: run sdwire flash (or any
direct write to the path from sdwire disk) with sudo on macOS/Linux, or
as Administrator on Windows.
Linux udev rules. Create /etc/udev/rules.d/99-sdwire.rules:
# SDWireC (FTDI)
SUBSYSTEM=="usb", ATTR{idVendor}=="04e8", ATTR{idProduct}=="6001", MODE="0666"
# SDWire3 (Realtek reader)
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="0316", MODE="0666"
# SDWire3 switching also needs write access to its upstream hub, since the
# card is handed to the target by cutting that hub port's power (VBUS).
# Either run as root, or grant access to hub devices:
SUBSYSTEM=="usb", ATTR{bDeviceClass}=="09", MODE="0666"
Then reload rules and add your user to plugdev:
sudo udevadm control --reload-rules
sudo udevadm trigger
sudo usermod -a -G plugdev $USERWindows. SDWire3 switching needs libusb access to the upstream hub, not just the reader — install UsbDk, or bind the hub to WinUSB with Zadig.
package main
import (
"context"
"log"
"github.com/jphastings/sdwire"
"github.com/jphastings/sdwire/power/meross"
)
func main() {
powerFunc, err := meross.New("192.0.2.10", "your-meross-account-key")
if err != nil {
log.Fatal(err)
}
dev, err := sdwire.New(
sdwire.WithTargetPower(powerFunc),
sdwire.WithWarningHandler(func(msg string) { log.Println("warning:", msg) }),
)
if err != nil {
log.Fatal(err)
}
defer dev.Close()
if err := dev.FlashAndBoot(context.Background(), "./image.img",
sdwire.WithFlashProgress(func(written, total int64) {
log.Printf("flashed %d / %d bytes", written, total)
}),
); err != nil {
log.Fatal(err)
}
mode, err := dev.Mode()
if err != nil {
log.Fatal(err)
}
log.Println("now in mode:", mode)
}Other entry points: ListDevices() enumerates every attached device;
NewWithSerial/NewWithIdentity connect to a specific one (by bare
serial, or by the port-suffixed identity/location forms respectively —
identical to what the CLI's -s flag accepts); SetMode/Mode switch and
read back the card's side; PowerCycle and TargetPower drive a
configured PowerFunc directly, independent of flashing;
WithoutRevive() disables the hub-cache power-on fallback for callers that
must not risk switching a target-mode SDWire3 back to host mode;
CachedPortState reads an SDWire3's mode from the on-disk hub cache
without powering anything on or off at all, for exactly that case;
WithWriteRetries and WithWriteTiming tune a flash's recovery from a
reader that drops off the bus mid-write, and expose its per-chunk write
timings; ListDeviceStates() is the same idea for the whole inventory, returning
every attached device plus every remembered-but-absent one with the mode
each is in; and Revive power-cycles a device's hub port to recover a
reader that has been dropped from the bus, addressable by location when no
other selector can reach it.
- Linux — tested on Ubuntu, Debian
- macOS — tested on macOS 10.15+ (uses
ioreg/diskutilfor block device discovery) - Windows — tested on Windows 10+ (needs libusb access to the SDWire3's hub — see Permissions)
Device not found. Confirm it's actually attached and enumerating:
SDWireC shows up as USB 04e8:6001, SDWire3 as 0bda:0316. On Linux,
check the udev rules above; on Windows, check libusb/UsbDk binding.
SDWire3 stuck in target mode. This is expected, not broken — see
SDWire3 state semantics. sdwire switch host/ts and sdwire flash (and, in the SDK, New/NewWithSerial/
NewWithIdentity by default) revive it via the on-disk hub-port cache,
powering it back on into host mode; state, list and power never do.
sdwire revive forces the same thing explicitly, for a
device too broken to be selected the usual way.
The reader vanishes mid-flash and only comes back on a physical replug.
The SD reader inside an SDWire3 can stop answering under sustained writes.
The OS asks its hub for a port reset, the reset fails to bring the port
back up, and the device is torn off the bus — after which nothing
re-probes that port, so sdwire list reports it as Unknown (or, if the
port was powered down too, Target). On macOS the sequence appears in
log show as:
USB3.0-CRW@01113000 endpoint 0x01: status 0xe0005000 (pipe stalled): 65536 bytes transferred
USB3.0-CRW@01113000 endpoint 0x01: status 0xe00002ed (transaction error): 0 bytes transferred
IOUSBMassStorageDriver: USB device 0BDA031601113000 - will be reset!
AppleUSB20HubPort::resetAndCreateDevice: reset did not enable port
AppleUSBHostPort::terminateDevice: destroying 0x0bda/0316/0204 (USB3.0-CRW): reset API call
Endpoint 0x01 is the reader's bulk-OUT pipe, so this is the reader
crashing mid-write — not a fault in this tool, and not something a port
reset clears. Nothing in software can stop the reader stalling; what
software can do is not lose the work.
sdwire flash recovers from this automatically,
power-cycling the port and resuming where it stopped. Outside a flash, use
sdwire revive instead of reaching for the cable;
sdwire revive -s <location> works even when the device can no longer be
selected by serial.
Two things make it worse and are worth ruling out: a reader on a USB 2.0 port writes at a tenth of the speed and so spends ten times as long in the window where it can fail, and other software grabbing the card (Spotlight indexing a freshly mounted volume, or anything enumerating USB) adds load at exactly the wrong moment.
"device is attached to a root hub port" / a hubpower.ErrRootPort-shaped
error. SDWire3 switching works by cutting power to the device's
upstream hub port; a device plugged directly into a computer's built-in
(root) USB port has no controllable parent to do that through — see
the VBUS story above. Attach it behind an
external hub with independent per-port power switching instead.
Contributions are welcome — please open an issue first for anything more than a small fix.
MIT License — see LICENSE.
- fcjr/sdwire — the original Go library this project is forked from
- Badger-Embedded/sdwire-cli —
the Python CLI this project's
list/state/switchcommands are compatible with - fcjr/ykush3 — a Go library for YKUSH3 USB switches
Forked and extended by @jphastings. Originally made with ❤️ at the Recurse Center.