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
5 changes: 3 additions & 2 deletions app/src/main/cpp/satellite_jni.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1076,15 +1076,16 @@ Java_com_tinkernorth_dish_hotpath_input_RumbleBridge_nativeInstall(JNIEnv* env,

JNIEXPORT jint JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_attachUsbDevice(
JNIEnv*, jobject, jint fd, jint vid, jint pid, jint interfaceNumber, jint epIn,
jint epInMaxPacket, jint epOut) {
jint epInMaxPacket, jint epOut, jint ifClass, jint ifSubclass, jint ifProtocol) {
int dupFd = dup(fd);
if (dupFd < 0) {
LOGE("attachUsbDevice: dup(%d) failed: %s", fd, strerror(errno));
return 0;
}
usbhost::AttachResult r = usbhost::attachDevice(
dupFd, (uint16_t)(vid & 0xFFFF), (uint16_t)(pid & 0xFFFF), interfaceNumber,
(uint8_t)(epIn & 0xFF), (uint16_t)(epInMaxPacket & 0xFFFF), (uint8_t)(epOut & 0xFF));
(uint8_t)(epIn & 0xFF), (uint16_t)(epInMaxPacket & 0xFFFF), (uint8_t)(epOut & 0xFF),
(uint8_t)(ifClass & 0xFF), (uint8_t)(ifSubclass & 0xFF), (uint8_t)(ifProtocol & 0xFF));
return r.ok ? (jint)r.syntheticDeviceId : 0;
}

Expand Down
20 changes: 7 additions & 13 deletions app/src/main/cpp/usb_host.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -339,21 +339,15 @@ void fetchPsCalibration(int fd, int interfaceNumber, uint8_t reportId,
} // namespace

AttachResult attachDevice(int fd, uint16_t vid, uint16_t pid, int interfaceNumber, uint8_t epIn,
uint16_t epInMaxPacket, uint8_t epOut) {
uint16_t epInMaxPacket, uint8_t epOut, uint8_t ifClass,
uint8_t ifSubclass, uint8_t ifProtocol) {
AttachResult out;

const usbparsers::KnownDevice* known = usbparsers::lookupKnown(vid, pid);
std::string modelName;
usbparsers::Parser parser = usbparsers::Parser::NONE;
usbparsers::InitKind init = usbparsers::InitKind::NONE;
if (known) {
modelName = known->name;
parser = known->parser;
init = known->init;
} else {
modelName = "USB controller";
parser = usbparsers::Parser::GENERIC_HID_GAMEPAD;
}
usbparsers::Classification classification =
usbparsers::classifyDevice(vid, pid, ifClass, ifSubclass, ifProtocol);
std::string modelName = classification.name != nullptr ? classification.name : "USB controller";
usbparsers::Parser parser = classification.parser;
usbparsers::InitKind init = classification.init;

// We expect Kotlin to have already called UsbDeviceConnection.claimInterface(force=true);
// CLAIMINTERFACE here is idempotent (returns EBUSY if already held by our process, which is
Expand Down
4 changes: 3 additions & 1 deletion app/src/main/cpp/usb_host.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ struct AttachResult {
};

AttachResult attachDevice(int fd, uint16_t vid, uint16_t pid, int interfaceNumber,
uint8_t endpointIn, uint16_t endpointInMaxPacket, uint8_t endpointOut);
uint8_t endpointIn, uint16_t endpointInMaxPacket, uint8_t endpointOut,
uint8_t interfaceClass, uint8_t interfaceSubclass,
uint8_t interfaceProtocol);

void detachDevice(int32_t syntheticDeviceId);

Expand Down
21 changes: 21 additions & 0 deletions app/src/main/cpp/usb_parsers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,27 @@ bool isVerifiedFastLane(uint16_t vid, uint16_t pid) {
return k != nullptr && k->parser != Parser::NONE;
}

constexpr uint8_t kIfClassVendor = 0xFF;
constexpr uint8_t kXInputSubclass = 0x5D;
constexpr uint8_t kXInputProtocol = 0x01;
constexpr uint8_t kGipSubclass = 0x47;
constexpr uint8_t kGipProtocol = 0xD0;

Classification classifyDevice(uint16_t vid, uint16_t pid, uint8_t ifClass, uint8_t ifSubclass,
uint8_t ifProtocol) {
const KnownDevice* known = lookupKnown(vid, pid);
if (known != nullptr) { return {known->parser, known->init, known->name}; }
// Wired XInput streams unsolicited; GIP needs the power-on packet first.
if (ifClass == kIfClassVendor && ifSubclass == kXInputSubclass &&
ifProtocol == kXInputProtocol) {
return {Parser::XINPUT_360, InitKind::NONE, nullptr};
}
if (ifClass == kIfClassVendor && ifSubclass == kGipSubclass && ifProtocol == kGipProtocol) {
return {Parser::XBOX_ONE_GIP, InitKind::XBOX_ONE_POWERON, nullptr};
}
return {Parser::GENERIC_HID_GAMEPAD, InitKind::NONE, nullptr};
}

const char* parserName(Parser p) {
switch (p) {
case Parser::XINPUT_360:
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/cpp/usb_parsers.h
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ struct KnownDevice {
InitKind init;
};

struct Classification {
Parser parser = Parser::NONE;
InitKind init = InitKind::NONE;
const char* name = nullptr;
};

// Per-device, expand-only auto-range for sticks that report raw ADC values. We don't read the
// controller's factory calibration from SPI flash, and the usable deflection varies per unit and
// per direction, so each axis tracks the largest deflection seen on each side of center
Expand Down Expand Up @@ -80,6 +86,9 @@ struct ParserState {

const KnownDevice* lookupKnown(uint16_t vid, uint16_t pid);

Classification classifyDevice(uint16_t vid, uint16_t pid, uint8_t ifClass, uint8_t ifSubclass,
uint8_t ifProtocol);

bool isVerifiedFastLane(uint16_t vid, uint16_t pid);

const char* parserName(Parser p);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ class PhysicalInputNative
endpointIn: Int,
endpointInMaxPacket: Int,
endpointOut: Int,
interfaceClass: Int,
interfaceSubclass: Int,
interfaceProtocol: Int,
): Int =
SatelliteNative.attachUsbDevice(
fd = fd,
Expand All @@ -74,6 +77,9 @@ class PhysicalInputNative
endpointIn = endpointIn,
endpointInMaxPacket = endpointInMaxPacket,
endpointOut = endpointOut,
interfaceClass = interfaceClass,
interfaceSubclass = interfaceSubclass,
interfaceProtocol = interfaceProtocol,
)

fun detachUsbDevice(syntheticDeviceId: Int) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ object SatelliteNative {
keyCode: Int,
): Boolean

@Suppress("LongParameterList")
external fun attachUsbDevice(
fd: Int,
vendorId: Int,
Expand All @@ -157,6 +158,9 @@ object SatelliteNative {
endpointIn: Int,
endpointInMaxPacket: Int,
endpointOut: Int,
interfaceClass: Int,
interfaceSubclass: Int,
interfaceProtocol: Int,
): Int

external fun detachUsbDevice(syntheticDeviceId: Int)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,9 @@ class UsbGamepadManager
endpointIn = epIn.address,
endpointInMaxPacket = epIn.maxPacketSize,
endpointOut = epOut?.address ?: 0,
interfaceClass = intf.interfaceClass,
interfaceSubclass = intf.interfaceSubclass,
interfaceProtocol = intf.interfaceProtocol,
)
if (synthetic == 0) {
runCatching {
Expand Down Expand Up @@ -488,39 +491,48 @@ class UsbGamepadManager
return product?.takeIf { it.isNotBlank() } ?: device.deviceName
}

private fun isGamepadShaped(device: UsbDevice): Boolean {
for (i in 0 until device.interfaceCount) {
val intf = device.getInterface(i)
val isHid = intf.interfaceClass == UsbConstants.USB_CLASS_HID
val isVendor = intf.interfaceClass == UsbConstants.USB_CLASS_VENDOR_SPEC
if (!isHid && !isVendor) continue
for (e in 0 until intf.endpointCount) {
val ep = intf.getEndpoint(e)
if (ep.type == UsbConstants.USB_ENDPOINT_XFER_INT && ep.direction == UsbConstants.USB_DIR_IN) {
return true
}
}
private fun isGamepadShaped(device: UsbDevice): Boolean = findInterruptInPair(device) != null

private fun gameInterfaceRank(intf: UsbInterface): Int {
val cls = intf.interfaceClass
if (cls == UsbConstants.USB_CLASS_HID) return RANK_HID
if (cls != UsbConstants.USB_CLASS_VENDOR_SPEC) return RANK_NONE
val sub = intf.interfaceSubclass
val proto = intf.interfaceProtocol
return when {
sub == XINPUT_SUBCLASS && proto == XINPUT_PROTOCOL -> RANK_XINPUT
sub == GIP_SUBCLASS && proto == GIP_PROTOCOL -> RANK_GIP
sub == XINPUT_SUBCLASS && (proto == XINPUT_AUX_PROTOCOL || proto == XINPUT_AUDIO_PROTOCOL) -> RANK_NONE
sub == XINPUT_SECURITY_SUBCLASS -> RANK_NONE
else -> RANK_VENDOR_FALLBACK
}
return false
}

private fun interruptInOutOf(intf: UsbInterface): Pair<UsbEndpoint, UsbEndpoint?>? {
var epIn: UsbEndpoint? = null
var epOut: UsbEndpoint? = null
for (e in 0 until intf.endpointCount) {
val ep = intf.getEndpoint(e)
if (ep.type != UsbConstants.USB_ENDPOINT_XFER_INT) continue
if (ep.direction == UsbConstants.USB_DIR_IN && epIn == null) epIn = ep
if (ep.direction == UsbConstants.USB_DIR_OUT && epOut == null) epOut = ep
}
return epIn?.let { it to epOut }
}

// Ranked, not first-match: a composite 360 pad's audio/security interfaces also carry an interrupt-IN.
private fun findInterruptInPair(device: UsbDevice): Triple<UsbInterface, UsbEndpoint, UsbEndpoint?>? {
var best: Triple<UsbInterface, UsbEndpoint, UsbEndpoint?>? = null
var bestRank = RANK_NONE
for (i in 0 until device.interfaceCount) {
val intf = device.getInterface(i)
val isHid = intf.interfaceClass == UsbConstants.USB_CLASS_HID
val isVendor = intf.interfaceClass == UsbConstants.USB_CLASS_VENDOR_SPEC
if (!isHid && !isVendor) continue
var epIn: UsbEndpoint? = null
var epOut: UsbEndpoint? = null
for (e in 0 until intf.endpointCount) {
val ep = intf.getEndpoint(e)
if (ep.type != UsbConstants.USB_ENDPOINT_XFER_INT) continue
if (ep.direction == UsbConstants.USB_DIR_IN && epIn == null) epIn = ep
if (ep.direction == UsbConstants.USB_DIR_OUT && epOut == null) epOut = ep
}
if (epIn != null) return Triple(intf, epIn, epOut)
val rank = gameInterfaceRank(intf)
if (rank <= bestRank) continue
val pair = interruptInOutOf(intf) ?: continue
best = Triple(intf, pair.first, pair.second)
bestRank = rank
}
return null
return best
}

private fun deviceFromIntent(intent: Intent): UsbDevice? =
Expand All @@ -537,6 +549,20 @@ class UsbGamepadManager
const val TAG = "UsbGamepadManager"
const val ACTION_USB_PERMISSION = "com.tinkernorth.dish.USB_PERMISSION"
const val TRANSITION_TIMEOUT_MS = 4000L

const val XINPUT_SUBCLASS = 0x5D
const val XINPUT_PROTOCOL = 0x01
const val XINPUT_AUX_PROTOCOL = 0x02
const val XINPUT_AUDIO_PROTOCOL = 0x03
const val XINPUT_SECURITY_SUBCLASS = 0xFD
const val GIP_SUBCLASS = 0x47
const val GIP_PROTOCOL = 0xD0

const val RANK_NONE = 0
const val RANK_VENDOR_FALLBACK = 1
const val RANK_HID = 2
const val RANK_GIP = 3
const val RANK_XINPUT = 4
}
}

Expand Down
37 changes: 37 additions & 0 deletions app/src/test/cpp/usb_parsers_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -655,3 +655,40 @@ TEST(TouchpadCapability, PlayStationParsersHaveTouchpads) {
EXPECT_FALSE(usbparsers::parserHasTouchpad(Parser::SWITCH_PRO_USB));
EXPECT_FALSE(usbparsers::parserHasTouchpad(Parser::GENERIC_HID_GAMEPAD));
}

TEST(ClassifyDevice, KnownDeviceWinsOverDescriptorTriple) {
auto c = usbparsers::classifyDevice(0x045E, 0x028E, 0x00, 0x00, 0x00);
EXPECT_EQ(c.parser, Parser::XINPUT_360);
EXPECT_NE(c.name, nullptr);
}

TEST(ClassifyDevice, WiredXInputInterfaceClassifiesWithoutTableEntry) {
auto c = usbparsers::classifyDevice(0x1234, 0x5678, 0xFF, 0x5D, 0x01);
EXPECT_EQ(c.parser, Parser::XINPUT_360);
EXPECT_EQ(c.init, InitKind::NONE);
EXPECT_EQ(c.name, nullptr);
}

TEST(ClassifyDevice, EightBitDoDongleTripleClassifiesAsXInput) {
// 0xFFFF stands in for the unlisted 2.4g dongle PID: only the descriptor can classify it.
auto c = usbparsers::classifyDevice(0x2DC8, 0xFFFF, 0xFF, 0x5D, 0x01);
EXPECT_EQ(c.parser, Parser::XINPUT_360);
EXPECT_EQ(c.init, InitKind::NONE);
}

TEST(ClassifyDevice, GipInterfaceClassifiesAsXboxOneWithPowerOn) {
auto c = usbparsers::classifyDevice(0x1234, 0x5678, 0xFF, 0x47, 0xD0);
EXPECT_EQ(c.parser, Parser::XBOX_ONE_GIP);
EXPECT_EQ(c.init, InitKind::XBOX_ONE_POWERON);
}

TEST(ClassifyDevice, HidInterfaceClassifiesAsGenericHid) {
auto c = usbparsers::classifyDevice(0x1234, 0x5678, 0x03, 0x00, 0x00);
EXPECT_EQ(c.parser, Parser::GENERIC_HID_GAMEPAD);
EXPECT_EQ(c.init, InitKind::NONE);
}

TEST(ClassifyDevice, UnknownVendorInterfaceFallsBackToGeneric) {
auto c = usbparsers::classifyDevice(0x1234, 0x5678, 0xFF, 0x99, 0x99);
EXPECT_EQ(c.parser, Parser::GENERIC_HID_GAMEPAD);
}
Loading
Loading