mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-13 03:51:10 +00:00
Add Steam Controller support over Bluetooth LE (#404)
This commit is contained in:
1 parent
90690d1c5c
commit
96d7c66535
8 files changed
+872
-16
No files matched your search
@@ -30,6 +30,9 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Installs DroidDeck's own updates (Updates page) over itself, after the user allows it. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- A paired Steam Controller, read over Bluetooth LE out of lizard mode (SteamControllerBle).
|
||||
Granted at install below targetSdk 31; no scan, so no location. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
|
||||
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
|
||||
@@ -1238,6 +1238,7 @@ struct DeckHidraw {
|
||||
size_t mapping_size = 0;
|
||||
uint8_t pending_feature = 0;
|
||||
bool touch = false; // Steam's touch controller (below), not the Deck
|
||||
bool triton = false; // The paired 2025 Steam Controller, with native BLE report IDs
|
||||
};
|
||||
|
||||
// What the Deck has and an Xbox pad does not, after the ring's events: motion (PadMotion) and the
|
||||
@@ -1271,6 +1272,61 @@ static constexpr uint32_t DECK_IMU_MAGIC = 0x31554D49;
|
||||
static constexpr size_t DECK_IMU_BLOCK_SIZE = 64;
|
||||
static_assert(sizeof(DeckImu) <= DECK_IMU_BLOCK_SIZE, "the IMU block is 64 bytes in the ring file");
|
||||
|
||||
// Shared native Steam Controller reports. Field offsets match FakeInputWriter.java. Android writes
|
||||
// the original BLE input packet; this process writes Steam's HID output report for Android to send.
|
||||
// A feature read is a round trip: this process bumps get_request, Android reads the controller's
|
||||
// report characteristic and answers with get_reply set to the same number.
|
||||
struct TritonSharedData {
|
||||
uint32_t magic;
|
||||
uint32_t reserved;
|
||||
uint64_t input_seq;
|
||||
uint32_t report_id;
|
||||
uint32_t input_length;
|
||||
uint8_t input[64];
|
||||
uint64_t output_seq;
|
||||
uint32_t output_feature;
|
||||
uint32_t output_length;
|
||||
uint8_t output[64];
|
||||
uint64_t get_request;
|
||||
uint64_t get_reply;
|
||||
uint32_t get_report_id;
|
||||
int32_t get_length;
|
||||
uint8_t get[64];
|
||||
};
|
||||
static constexpr uint32_t TRITON_SHARED_MAGIC = 0x4e525454;
|
||||
static constexpr size_t TRITON_SHARED_BLOCK_SIZE = 256;
|
||||
static constexpr size_t TRITON_SHARED_OFFSET = FAKE_INPUT_RING_SIZE + DECK_IMU_BLOCK_SIZE;
|
||||
static_assert(offsetof(TritonSharedData, input_seq) == 8, "Triton input sequence offset");
|
||||
static_assert(offsetof(TritonSharedData, report_id) == 16, "Triton report ID offset");
|
||||
static_assert(offsetof(TritonSharedData, input) == 24, "Triton input offset");
|
||||
static_assert(offsetof(TritonSharedData, output_seq) == 88, "Triton output sequence offset");
|
||||
static_assert(offsetof(TritonSharedData, output_feature) == 96, "Triton output feature offset");
|
||||
static_assert(offsetof(TritonSharedData, output) == 104, "Triton output offset");
|
||||
static_assert(offsetof(TritonSharedData, get_request) == 168, "Triton feature request offset");
|
||||
static_assert(offsetof(TritonSharedData, get_reply) == 176, "Triton feature reply offset");
|
||||
static_assert(offsetof(TritonSharedData, get_report_id) == 184, "Triton feature report ID offset");
|
||||
static_assert(offsetof(TritonSharedData, get_length) == 188, "Triton feature length offset");
|
||||
static_assert(offsetof(TritonSharedData, get) == 192, "Triton feature data offset");
|
||||
static_assert(sizeof(TritonSharedData) <= TRITON_SHARED_BLOCK_SIZE, "Triton ring block size");
|
||||
|
||||
static constexpr const char *TRITON_HIDRAW_PATH = "/dev/hidraw18";
|
||||
static constexpr unsigned int TRITON_HIDRAW_MAJOR = 240;
|
||||
static constexpr unsigned int TRITON_HIDRAW_MINOR = 18;
|
||||
static constexpr int TRITON_REPORT_ID_BYTES = 1;
|
||||
static constexpr int TRITON_STATE_BYTES = 45;
|
||||
static constexpr int TRITON_REPORT_INTERVAL_US = 4000;
|
||||
// A BLE read takes a few connection intervals; past this the controller is taken to be gone.
|
||||
static constexpr int TRITON_FEATURE_TIMEOUT_MS = 500;
|
||||
static constexpr const char *TRITON_NAME = "Steam Controller";
|
||||
static constexpr const char *TRITON_SERIAL = "DROIDDECKSC01";
|
||||
static const uint8_t kTritonReportDescriptor[] = {
|
||||
0x06, 0x00, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
|
||||
0x85, 0x45, 0x09, 0x02, 0x95, 0x2d, 0x81, 0x02, 0x85, 0x47, 0x09, 0x03, 0x95, 0x2d, 0x81, 0x02,
|
||||
0x85, 0x80, 0x09, 0x04, 0x95, 0x09, 0x91, 0x02, 0x85, 0x81, 0x09, 0x05, 0x95, 0x07, 0x91, 0x02,
|
||||
0x85, 0x82, 0x09, 0x06, 0x95, 0x03, 0x91, 0x02, 0x85, 0x83, 0x09, 0x07, 0x95, 0x09, 0x91, 0x02,
|
||||
0x85, 0x84, 0x09, 0x08, 0x95, 0x08, 0x91, 0x02, 0x85, 0x85, 0x09, 0x09, 0x95, 0x03, 0x91, 0x02,
|
||||
0x85, 0x01, 0x09, 0x0a, 0x95, 0x3f, 0xb1, 0x02, 0xc0};
|
||||
|
||||
__attribute__((visibility("hidden"))) static bool read_deck_extras(const DeckHidraw &deck, DeckExtras &out) {
|
||||
if (deck.mapping_size < FAKE_INPUT_RING_SIZE + DECK_IMU_BLOCK_SIZE) return false;
|
||||
const auto *imu = reinterpret_cast<const DeckImu *>(reinterpret_cast<const uint8_t *>(deck.ring) +
|
||||
@@ -1307,6 +1363,13 @@ static bool fake_deck_enabled() {
|
||||
return enabled == 1;
|
||||
}
|
||||
|
||||
static bool fake_triton_enabled() {
|
||||
static int enabled = -1;
|
||||
if (enabled < 0)
|
||||
enabled = fake_deck_enabled() && getenv("FAKE_EVDEV_TRITON") && atoi(getenv("FAKE_EVDEV_TRITON")) ? 1 : 0;
|
||||
return enabled == 1;
|
||||
}
|
||||
|
||||
// The Deck is the client's alone: a game that found it would have the pad twice, once through it
|
||||
// and once through Steam Input's virtual pad.
|
||||
__attribute__((visibility("hidden"))) static bool process_is_steam_client() {
|
||||
@@ -1337,6 +1400,10 @@ __attribute__((visibility("hidden"))) static bool is_deck_hidraw_path(const char
|
||||
return pathname && fake_deck_enabled() && !strcmp(pathname, DECK_HIDRAW_PATH);
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static bool is_triton_hidraw_path(const char *pathname) {
|
||||
return pathname && fake_triton_enabled() && !strcmp(pathname, TRITON_HIDRAW_PATH);
|
||||
}
|
||||
|
||||
// The app's first pad is the Deck now: its evdev node is withdrawn from everyone. Further players'
|
||||
// pads are left to the client alone, which hands them to games through Steam Input.
|
||||
__attribute__((visibility("hidden"))) static bool is_withdrawn_pad_path(const char *pathname) {
|
||||
@@ -1507,6 +1574,116 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
|
||||
return pair[0];
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static bool read_triton_state(const DeckHidraw &triton, uint8_t &report_id,
|
||||
uint8_t *report, size_t &length) {
|
||||
if (triton.mapping_size < TRITON_SHARED_OFFSET + TRITON_SHARED_BLOCK_SIZE) return false;
|
||||
const auto *shared = reinterpret_cast<const TritonSharedData *>(reinterpret_cast<const uint8_t *>(triton.ring) +
|
||||
TRITON_SHARED_OFFSET);
|
||||
for (int attempt = 0; attempt < 8; attempt++) {
|
||||
uint64_t seq = __atomic_load_n(&shared->input_seq, __ATOMIC_ACQUIRE);
|
||||
if (seq & 1) continue;
|
||||
if (__atomic_load_n(&shared->magic, __ATOMIC_RELAXED) != TRITON_SHARED_MAGIC) return false;
|
||||
uint32_t id = __atomic_load_n(&shared->report_id, __ATOMIC_RELAXED);
|
||||
uint32_t size = __atomic_load_n(&shared->input_length, __ATOMIC_RELAXED);
|
||||
if (id > 0xff || size > sizeof(shared->input)) return false;
|
||||
if (size) memcpy(report, shared->input, size);
|
||||
__atomic_thread_fence(__ATOMIC_ACQUIRE);
|
||||
if (seq == __atomic_load_n(&shared->input_seq, __ATOMIC_RELAXED)) {
|
||||
report_id = size ? static_cast<uint8_t>(id) : 0x45;
|
||||
length = size ? size : TRITON_STATE_BYTES;
|
||||
if (!size) memset(report, 0, length);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static void *triton_report_thread(void *arg) {
|
||||
auto *holder = static_cast<std::shared_ptr<DeckHidraw> *>(arg);
|
||||
std::shared_ptr<DeckHidraw> self = *holder;
|
||||
delete holder;
|
||||
uint8_t report[TRITON_REPORT_ID_BYTES + 64] = {};
|
||||
uint8_t report_id = 0x45;
|
||||
size_t length = TRITON_STATE_BYTES;
|
||||
for (;;) {
|
||||
if (read_triton_state(*self, report_id, report + 1, length)) {
|
||||
report[0] = report_id;
|
||||
if (send(self->peer, report, length + 1, MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
|
||||
errno != EAGAIN && errno != EWOULDBLOCK)
|
||||
break;
|
||||
}
|
||||
struct timespec interval = {0, TRITON_REPORT_INTERVAL_US * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
}
|
||||
syscall(SYS_close, self->peer);
|
||||
munmap(self->ring, self->mapping_size);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static int open_triton_hidraw(int flags) {
|
||||
static auto my_open = reinterpret_cast<int (*)(const char *, int, ...)>(dlsym(RTLD_NEXT, "open"));
|
||||
if (!process_is_steam_client()) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
}
|
||||
std::string ring_path = get_ring_path_for_slot(0);
|
||||
int ring_fd = ring_path.empty() ? -1 : my_open(ring_path.c_str(), O_RDWR | O_CLOEXEC);
|
||||
if (ring_fd < 0) {
|
||||
Logger::log("triton: %s refused: ring %s not openable (%s)\n", TRITON_HIDRAW_PATH,
|
||||
ring_path.empty() ? "(none configured)" : ring_path.c_str(), strerror(errno));
|
||||
errno = ENODEV;
|
||||
return -1;
|
||||
}
|
||||
struct stat ring_stat;
|
||||
const size_t mapping_size = TRITON_SHARED_OFFSET + TRITON_SHARED_BLOCK_SIZE;
|
||||
if (fstat(ring_fd, &ring_stat) < 0 || static_cast<size_t>(ring_stat.st_size) < mapping_size) {
|
||||
syscall(SYS_close, ring_fd);
|
||||
errno = ENODEV;
|
||||
return -1;
|
||||
}
|
||||
void *mapping = mmap(nullptr, mapping_size, PROT_READ | PROT_WRITE, MAP_SHARED, ring_fd, 0);
|
||||
syscall(SYS_close, ring_fd);
|
||||
if (mapping == MAP_FAILED || !ring_header_is_valid(static_cast<FakeInputRingHeader *>(mapping)) ||
|
||||
reinterpret_cast<TritonSharedData *>(static_cast<uint8_t *>(mapping) + TRITON_SHARED_OFFSET)->magic != TRITON_SHARED_MAGIC) {
|
||||
if (mapping != MAP_FAILED) munmap(mapping, mapping_size);
|
||||
errno = ENODEV;
|
||||
return -1;
|
||||
}
|
||||
int pair[2];
|
||||
if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, pair) < 0) {
|
||||
munmap(mapping, mapping_size);
|
||||
return -1;
|
||||
}
|
||||
int buffer = (TRITON_STATE_BYTES + TRITON_REPORT_ID_BYTES) * 4;
|
||||
setsockopt(pair[1], SOL_SOCKET, SO_SNDBUF, &buffer, sizeof(buffer));
|
||||
if (!(flags & O_CLOEXEC)) fcntl(pair[0], F_SETFD, 0);
|
||||
if (flags & O_NONBLOCK) fcntl(pair[0], F_SETFL, fcntl(pair[0], F_GETFL) | O_NONBLOCK);
|
||||
auto triton = std::make_shared<DeckHidraw>();
|
||||
triton->peer = pair[1];
|
||||
triton->ring = static_cast<FakeInputRingHeader *>(mapping);
|
||||
triton->mapping_size = mapping_size;
|
||||
triton->triton = true;
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
|
||||
deck_map()[pair[0]] = triton;
|
||||
}
|
||||
pthread_t thread;
|
||||
auto *thread_arg = new std::shared_ptr<DeckHidraw>(triton);
|
||||
if (pthread_create(&thread, nullptr, triton_report_thread, thread_arg) != 0) {
|
||||
delete thread_arg;
|
||||
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
|
||||
deck_map().erase(pair[0]);
|
||||
syscall(SYS_close, pair[0]);
|
||||
syscall(SYS_close, pair[1]);
|
||||
munmap(mapping, mapping_size);
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
pthread_detach(thread);
|
||||
Logger::log("triton: %s opened as fd %d (28de:1303)\n", TRITON_HIDRAW_PATH, pair[0]);
|
||||
return pair[0];
|
||||
}
|
||||
|
||||
|
||||
// ---- Steam's touch controller ----
|
||||
//
|
||||
@@ -2032,6 +2209,119 @@ ioctl_deck(DeckHidraw &deck, ioctl_request_t op, void *argp) {
|
||||
}
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static void triton_queue_output(DeckHidraw &triton, const uint8_t *data,
|
||||
size_t size, bool feature) {
|
||||
if (!data || !size || size > 64 || triton.mapping_size < TRITON_SHARED_OFFSET + TRITON_SHARED_BLOCK_SIZE) return;
|
||||
auto *shared = reinterpret_cast<TritonSharedData *>(reinterpret_cast<uint8_t *>(triton.ring) + TRITON_SHARED_OFFSET);
|
||||
if (__atomic_load_n(&shared->magic, __ATOMIC_ACQUIRE) != TRITON_SHARED_MAGIC) return;
|
||||
uint64_t seq = __atomic_load_n(&shared->output_seq, __ATOMIC_RELAXED);
|
||||
if (seq & 1) seq++;
|
||||
__atomic_store_n(&shared->output_seq, seq + 1, __ATOMIC_RELEASE);
|
||||
shared->output_feature = feature ? 1 : 0;
|
||||
shared->output_length = static_cast<uint32_t>(size);
|
||||
memcpy(shared->output, data, size);
|
||||
if (size < sizeof(shared->output)) memset(shared->output + size, 0, sizeof(shared->output) - size);
|
||||
__atomic_thread_fence(__ATOMIC_RELEASE);
|
||||
__atomic_store_n(&shared->output_seq, seq + 2, __ATOMIC_RELEASE);
|
||||
static std::atomic<unsigned> counts[2][256];
|
||||
uint8_t report_id = feature ? (size > 1 ? data[1] : 0) : data[0];
|
||||
unsigned n = ++counts[feature ? 1 : 0][report_id];
|
||||
if ((n & (n - 1)) == 0)
|
||||
Logger::log("triton: %s report 0x%02x (x%u, %zu bytes)\n", feature ? "feature" : "output", report_id, n, size);
|
||||
}
|
||||
|
||||
// HIDIOCGFEATURE: the client reads the controller's info (report 1) this way, and a device that
|
||||
// answers with nothing is dropped as a zombie. The read goes to the controller itself, as SDL's
|
||||
// BLE backend does it (hid.m, get_feature_report): its reply is the report, with the report ID
|
||||
// put in front when the controller left it out. Called without controller_mutex: it waits.
|
||||
__attribute__((visibility("hidden"))) static int triton_get_feature(DeckHidraw &triton, uint8_t *report, size_t size) {
|
||||
if (!report || size < 2 || triton.mapping_size < TRITON_SHARED_OFFSET + TRITON_SHARED_BLOCK_SIZE) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
auto *shared = reinterpret_cast<TritonSharedData *>(reinterpret_cast<uint8_t *>(triton.ring) + TRITON_SHARED_OFFSET);
|
||||
static std::mutex one_at_a_time;
|
||||
std::lock_guard<std::mutex> guard(one_at_a_time);
|
||||
uint8_t report_id = report[0];
|
||||
__atomic_store_n(&shared->get_report_id, report_id, __ATOMIC_RELAXED);
|
||||
uint64_t request = __atomic_add_fetch(&shared->get_request, 1, __ATOMIC_RELEASE);
|
||||
for (int waited = 0; waited < TRITON_FEATURE_TIMEOUT_MS * 4; waited++) {
|
||||
if (__atomic_load_n(&shared->get_reply, __ATOMIC_ACQUIRE) == request) {
|
||||
int32_t length = __atomic_load_n(&shared->get_length, __ATOMIC_RELAXED);
|
||||
if (length <= 0 || length > static_cast<int32_t>(sizeof(shared->get))) break;
|
||||
size_t copied;
|
||||
memset(report, 0, size);
|
||||
if (shared->get[0] == report_id) {
|
||||
copied = std::min(size, static_cast<size_t>(length));
|
||||
memcpy(report, shared->get, copied);
|
||||
} else {
|
||||
report[0] = report_id;
|
||||
copied = 1 + std::min(size - 1, static_cast<size_t>(length));
|
||||
memcpy(report + 1, shared->get, copied - 1);
|
||||
}
|
||||
static std::atomic<unsigned> reads[256];
|
||||
unsigned n = ++reads[report_id];
|
||||
if ((n & (n - 1)) == 0)
|
||||
Logger::log("triton: feature 0x%02x read, %d bytes from the controller [%02x %02x %02x %02x] (x%u)\n", report_id,
|
||||
length, shared->get[0], shared->get[1], shared->get[2], shared->get[3], n);
|
||||
return static_cast<int>(copied);
|
||||
}
|
||||
struct timespec interval = {0, 250 * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
}
|
||||
static std::atomic<unsigned> failures;
|
||||
unsigned n = ++failures;
|
||||
if ((n & (n - 1)) == 0) Logger::log("triton: feature 0x%02x read got no answer from the controller (x%u)\n", report_id, n);
|
||||
errno = EIO;
|
||||
return -1;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static int ioctl_triton(DeckHidraw &triton, ioctl_request_t op, void *argp) {
|
||||
if (_IOC_TYPE(op) != 'H') {
|
||||
errno = ENOTTY;
|
||||
return -1;
|
||||
}
|
||||
switch (_IOC_NR(op)) {
|
||||
case 0x01:
|
||||
*static_cast<int *>(argp) = sizeof(kTritonReportDescriptor);
|
||||
return 0;
|
||||
case 0x02: {
|
||||
auto *descriptor = static_cast<uint8_t *>(argp);
|
||||
uint32_t size;
|
||||
memcpy(&size, descriptor, sizeof(size));
|
||||
memcpy(descriptor + 4, kTritonReportDescriptor, std::min<size_t>(size, sizeof(kTritonReportDescriptor)));
|
||||
return 0;
|
||||
}
|
||||
case 0x03: {
|
||||
struct {
|
||||
uint32_t bustype;
|
||||
int16_t vendor;
|
||||
int16_t product;
|
||||
} info = {BUS_BLUETOOTH, static_cast<int16_t>(0x28de), static_cast<int16_t>(0x1303)};
|
||||
memcpy(argp, &info, sizeof(info));
|
||||
return 0;
|
||||
}
|
||||
case 0x04: return copy_ioctl_string(op, argp, TRITON_NAME);
|
||||
case 0x05: return copy_ioctl_string(op, argp, "bluetooth-droiddeck-1");
|
||||
case 0x08: return copy_ioctl_string(op, argp, TRITON_SERIAL);
|
||||
case 0x06: {
|
||||
// The client's feature reports are the report ID and 64 bytes; the last is padding the BLE
|
||||
// characteristic does not take (SteamControllerBle.sendOutputReport drops it), so 64 are kept.
|
||||
size_t size = _IOC_SIZE(op);
|
||||
if (!argp || size < 2 || size > 65) {
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
triton_queue_output(triton, static_cast<const uint8_t *>(argp), std::min<size_t>(size, 64), true);
|
||||
return static_cast<int>(size);
|
||||
}
|
||||
case 0x07: return triton_get_feature(triton, static_cast<uint8_t *>(argp), _IOC_SIZE(op));
|
||||
default:
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// A device scan that fails on a phone and not on the Thor: libudev gives up its whole scan on any
|
||||
// sysfs directory it cannot read for a reason other than its absence, and a phone's SELinux policy
|
||||
// denies directories a permissive device lets through. The client's /sys failures that are not
|
||||
@@ -2201,6 +2491,7 @@ EXPORT int open(const char *pathname, int flags, ...) {
|
||||
|
||||
if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags);
|
||||
if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags);
|
||||
if (is_triton_hidraw_path(pathname)) return open_triton_hidraw(flags);
|
||||
if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags);
|
||||
if (is_withdrawn_pad_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
@@ -2276,6 +2567,7 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
|
||||
|
||||
if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags);
|
||||
if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags);
|
||||
if (is_triton_hidraw_path(pathname)) return open_triton_hidraw(flags);
|
||||
if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags);
|
||||
if (is_withdrawn_pad_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
@@ -2333,7 +2625,8 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
|
||||
|
||||
template <typename S, typename Real>
|
||||
static int fake_stat_path(const char *pathname, S *statbuf, Real real) {
|
||||
if (is_withdrawn_pad_path(pathname) || (is_deck_hidraw_path(pathname) && !process_is_steam_client())) {
|
||||
if (is_withdrawn_pad_path(pathname) ||
|
||||
((is_deck_hidraw_path(pathname) || is_triton_hidraw_path(pathname)) && !process_is_steam_client())) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
}
|
||||
@@ -2349,6 +2642,12 @@ static int fake_stat_path(const char *pathname, S *statbuf, Real real) {
|
||||
statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, DECK_HIDRAW_MINOR);
|
||||
return 0;
|
||||
}
|
||||
if (is_triton_hidraw_path(pathname)) {
|
||||
memset(statbuf, 0, sizeof(*statbuf));
|
||||
statbuf->st_mode = S_IFCHR | 0666;
|
||||
statbuf->st_rdev = makedev(TRITON_HIDRAW_MAJOR, TRITON_HIDRAW_MINOR);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char *event = nullptr;
|
||||
char *fake_path = nullptr;
|
||||
@@ -2394,7 +2693,9 @@ static int fake_stat_fd(int fd, S *buf, Real real) {
|
||||
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
|
||||
if (ret == 0 && deck_map().count(fd)) {
|
||||
buf->st_mode = (buf->st_mode & ~S_IFMT) | S_IFCHR;
|
||||
buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, deck_map()[fd]->touch ? TOUCH_HIDRAW_MINOR : DECK_HIDRAW_MINOR);
|
||||
const auto &device = *deck_map()[fd];
|
||||
unsigned int minor = device.touch ? TOUCH_HIDRAW_MINOR : (device.triton ? TRITON_HIDRAW_MINOR : DECK_HIDRAW_MINOR);
|
||||
buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, minor);
|
||||
return ret;
|
||||
}
|
||||
auto controller = controller_map().find(fd);
|
||||
@@ -2440,8 +2741,9 @@ EXPORT int access(const char *pathname, int mode) {
|
||||
static auto my_access = reinterpret_cast<decltype(&::access)>(dlsym(RTLD_NEXT, "access"));
|
||||
if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0;
|
||||
if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0;
|
||||
if (is_triton_hidraw_path(pathname) && process_is_steam_client()) return 0;
|
||||
if (is_touch_hidraw_path(pathname)) return 0;
|
||||
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) {
|
||||
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname) || is_triton_hidraw_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
}
|
||||
@@ -2477,8 +2779,9 @@ EXPORT int faccessat(int dirfd, const char *pathname, int mode, int flags) {
|
||||
static auto my_faccessat = reinterpret_cast<decltype(&::faccessat)>(dlsym(RTLD_NEXT, "faccessat"));
|
||||
if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0;
|
||||
if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0;
|
||||
if (is_triton_hidraw_path(pathname) && process_is_steam_client()) return 0;
|
||||
if (is_touch_hidraw_path(pathname)) return 0;
|
||||
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) {
|
||||
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname) || is_triton_hidraw_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
}
|
||||
@@ -2599,8 +2902,15 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
|
||||
auto maker = uinput_map().find(fd);
|
||||
if (maker != uinput_map().end()) return ioctl_uinput(*maker->second, op, argp);
|
||||
auto deck = deck_map().find(fd);
|
||||
if (deck != deck_map().end())
|
||||
return deck->second->touch ? ioctl_touch(*deck->second, op, argp) : ioctl_deck(*deck->second, op, argp);
|
||||
if (deck != deck_map().end()) {
|
||||
if (deck->second->touch) return ioctl_touch(*deck->second, op, argp);
|
||||
if (deck->second->triton) {
|
||||
std::shared_ptr<DeckHidraw> triton = deck->second;
|
||||
guard.unlock();
|
||||
return ioctl_triton(*triton, op, argp);
|
||||
}
|
||||
return ioctl_deck(*deck->second, op, argp);
|
||||
}
|
||||
auto controller = controller_map().find(fd);
|
||||
if (controller == controller_map().end()) {
|
||||
guard.unlock();
|
||||
@@ -2928,6 +3238,7 @@ EXPORT ssize_t write(int fd, const void *buf, size_t count) {
|
||||
if (made != uinput_map().end()) return write_uinput(*made->second, buf, count);
|
||||
if (deck_map().count(fd)) {
|
||||
if (deck_map()[fd]->touch) touch_output(static_cast<const uint8_t *>(buf), count);
|
||||
else if (deck_map()[fd]->triton) triton_queue_output(*deck_map()[fd], static_cast<const uint8_t *>(buf), count, false);
|
||||
return static_cast<ssize_t>(count);
|
||||
}
|
||||
auto controller = controller_map().find(fd);
|
||||
@@ -2954,9 +3265,10 @@ EXPORT ssize_t writev(int fd, const struct iovec *iov, int iovcnt) {
|
||||
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
|
||||
if (deck_map().count(fd)) {
|
||||
ssize_t total = 0;
|
||||
bool touch = deck_map()[fd]->touch;
|
||||
auto device = deck_map()[fd];
|
||||
for (int i = 0; i < iovcnt; i++) {
|
||||
if (touch) touch_output(static_cast<const uint8_t *>(iov[i].iov_base), iov[i].iov_len);
|
||||
if (device->touch) touch_output(static_cast<const uint8_t *>(iov[i].iov_base), iov[i].iov_len);
|
||||
else if (device->triton) triton_queue_output(*device, static_cast<const uint8_t *>(iov[i].iov_base), iov[i].iov_len, false);
|
||||
total += static_cast<ssize_t>(iov[i].iov_len);
|
||||
}
|
||||
return total;
|
||||
|
||||
@@ -52,6 +52,7 @@ import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.SessionClipboard
|
||||
import com.droiddeck.launcher.input.OnScreenControls
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.SteamControllerBle
|
||||
import com.droiddeck.launcher.input.PointerGestures
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplays
|
||||
@@ -147,8 +148,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
/** Motion is read while the session shows and only if the pad is a Deck controller - which
|
||||
* the service can decide after this activity has resumed (SessionState.deckPadListener). */
|
||||
private fun updatePadMotion() {
|
||||
if (resumed && !pipUi && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
|
||||
// A Steam Controller brings its own gyro (SteamControllerBle); the phone's would fight it.
|
||||
if (resumed && !pipUi && SessionState.deckPad && !SteamControllerBle.connected) padMotion?.start()
|
||||
else padMotion?.stop()
|
||||
}
|
||||
private var steamController: SteamControllerBle? = null
|
||||
private var onScreenControls: OnScreenControls? = null
|
||||
/** Steam's touch controls (SteamTouchControls), made once the session offers the device. */
|
||||
private var steamTouchControls: com.droiddeck.launcher.input.SteamTouchControls? = null
|
||||
@@ -389,6 +393,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam)
|
||||
padBridge = bridge
|
||||
SessionState.padBridge = bridge
|
||||
// A paired Steam Controller is read over Bluetooth LE, out of lizard mode, as Steam Link reads it.
|
||||
SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); updateOnScreenControls() } }
|
||||
steamController = SteamControllerBle(this).also { it.start() }
|
||||
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
|
||||
@Suppress("DEPRECATION")
|
||||
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
|
||||
@@ -2037,7 +2044,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// Auto: the touch pad when there is no controller - except on the desktop, where the
|
||||
// screen is a touchpad for the pointer and a pad over it would be in the way. A game
|
||||
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
|
||||
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
|
||||
else -> !PadBridge.anyControllerConnected() && !SteamControllerBle.connected &&
|
||||
SessionState.mode != SessionService.MODE_DESKTOP
|
||||
}
|
||||
onScreenButtonsVisible = show
|
||||
if (steam != null) {
|
||||
@@ -2286,6 +2294,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
closeSecondScreen(reset = false)
|
||||
if (::hud.isInitialized) hud.stop()
|
||||
padBridge?.stop()
|
||||
steamController?.stop()
|
||||
if (SessionState.padBridge === padBridge) SessionState.padBridge = null
|
||||
padMotion?.stop()
|
||||
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
|
||||
|
||||
@@ -94,9 +94,9 @@ public class FakeInputWriter {
|
||||
private static final int RING_SNAPSHOT_SEQ_OFFSET = 32;
|
||||
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
|
||||
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
|
||||
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
|
||||
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
|
||||
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
|
||||
// After the events - MUST match the blocks in fakeinput_steam.cpp. DeckImu carries the
|
||||
// Deck-only motion, grips and trackpads. TritonShared carries the paired Steam Controller's
|
||||
// native BLE reports and output reports, so the Steam client can see it as its own device.
|
||||
private static final int IMU_OFFSET = RING_SIZE;
|
||||
private static final int IMU_MAGIC = 0x31554D49; // IMU1
|
||||
// Written by libfakeinput, not here: the client's SETTING_IMU_MODE | 0x80000000 (readImuMode).
|
||||
@@ -107,7 +107,25 @@ public class FakeInputWriter {
|
||||
private static final int DECK_PADS_OFFSET = IMU_OFFSET + 28; // short[4]: left X, Y, right X, Y
|
||||
private static final int DECK_PRESSURE_OFFSET = IMU_OFFSET + 36; // short[2]: left, right
|
||||
private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; // int: DeckControls bits
|
||||
private static final int RING_FILE_SIZE = RING_SIZE + 64;
|
||||
private static final int TRITON_OFFSET = RING_SIZE + 64;
|
||||
private static final int TRITON_MAGIC = 0x4e525454; // TTRN
|
||||
private static final int TRITON_INPUT_SEQ_OFFSET = TRITON_OFFSET + 8;
|
||||
private static final int TRITON_REPORT_ID_OFFSET = TRITON_OFFSET + 16;
|
||||
private static final int TRITON_REPORT_LENGTH_OFFSET = TRITON_OFFSET + 20;
|
||||
private static final int TRITON_REPORT_OFFSET = TRITON_OFFSET + 24;
|
||||
private static final int TRITON_OUTPUT_SEQ_OFFSET = TRITON_OFFSET + 88;
|
||||
private static final int TRITON_OUTPUT_FEATURE_OFFSET = TRITON_OFFSET + 96;
|
||||
private static final int TRITON_OUTPUT_LENGTH_OFFSET = TRITON_OFFSET + 100;
|
||||
private static final int TRITON_OUTPUT_OFFSET = TRITON_OFFSET + 104;
|
||||
// A feature read: libfakeinput bumps the request, the reply names the request it answers.
|
||||
private static final int TRITON_GET_REQUEST_OFFSET = TRITON_OFFSET + 168;
|
||||
private static final int TRITON_GET_REPLY_OFFSET = TRITON_OFFSET + 176;
|
||||
private static final int TRITON_GET_REPORT_ID_OFFSET = TRITON_OFFSET + 184;
|
||||
private static final int TRITON_GET_LENGTH_OFFSET = TRITON_OFFSET + 188;
|
||||
private static final int TRITON_GET_OFFSET = TRITON_OFFSET + 192;
|
||||
private static final int TRITON_REPORT_MAX = 64;
|
||||
private static final int TRITON_BLOCK_SIZE = 256;
|
||||
private static final int RING_FILE_SIZE = TRITON_OFFSET + TRITON_BLOCK_SIZE;
|
||||
|
||||
private static final Object RING_LOCK = new Object();
|
||||
private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS];
|
||||
@@ -223,6 +241,15 @@ public class FakeInputWriter {
|
||||
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
|
||||
}
|
||||
data.putInt(DECK_CONTROLS_OFFSET, 0);
|
||||
data.putInt(TRITON_OFFSET, TRITON_MAGIC);
|
||||
data.putLong(TRITON_INPUT_SEQ_OFFSET, 0L);
|
||||
data.putInt(TRITON_REPORT_ID_OFFSET, 0);
|
||||
data.putInt(TRITON_REPORT_LENGTH_OFFSET, 0);
|
||||
data.putLong(TRITON_OUTPUT_SEQ_OFFSET, 0L);
|
||||
data.putInt(TRITON_OUTPUT_FEATURE_OFFSET, 0);
|
||||
data.putInt(TRITON_OUTPUT_LENGTH_OFFSET, 0);
|
||||
data.putLong(TRITON_GET_REQUEST_OFFSET, 0L);
|
||||
data.putLong(TRITON_GET_REPLY_OFFSET, 0L);
|
||||
}
|
||||
|
||||
// Lock order: RING_LOCK, then the slot. Every writer holds the slot while it touches data, and
|
||||
@@ -522,6 +549,89 @@ public class FakeInputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/** Publishes a native Triton input report (without the HID report ID byte). */
|
||||
public static void writeTritonState(int slot, int reportId, byte[] report) {
|
||||
if (report == null || report.length == 0 || report.length > TRITON_REPORT_MAX) return;
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
|
||||
long seq = ring.getLong(TRITON_INPUT_SEQ_OFFSET);
|
||||
if ((seq & 1) != 0) seq++;
|
||||
ring.putLong(TRITON_INPUT_SEQ_OFFSET, seq + 1);
|
||||
nativeStoreFence();
|
||||
ring.putInt(TRITON_REPORT_ID_OFFSET, reportId);
|
||||
ring.putInt(TRITON_REPORT_LENGTH_OFFSET, report.length);
|
||||
for (int i = 0; i < TRITON_REPORT_MAX; i++) {
|
||||
ring.put(TRITON_REPORT_OFFSET + i, i < report.length ? report[i] : 0);
|
||||
}
|
||||
nativeStoreFence();
|
||||
ring.putLong(TRITON_INPUT_SEQ_OFFSET, seq + 2);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the newest Steam-client output report after {@code lastSequence}, or null. */
|
||||
public static byte[] readTritonOutput(int slot, long lastSequence) {
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return null;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return null;
|
||||
nativeStoreFence();
|
||||
long seq = ring.getLong(TRITON_OUTPUT_SEQ_OFFSET);
|
||||
if ((seq & 1) != 0 || seq <= lastSequence) return null;
|
||||
int length = ring.getInt(TRITON_OUTPUT_LENGTH_OFFSET);
|
||||
if (length <= 0 || length > TRITON_REPORT_MAX) return null;
|
||||
byte[] output = new byte[9 + length];
|
||||
ByteBuffer.wrap(output).order(ByteOrder.LITTLE_ENDIAN).putLong(seq);
|
||||
output[8] = (byte) (ring.getInt(TRITON_OUTPUT_FEATURE_OFFSET) != 0 ? 1 : 0);
|
||||
for (int i = 0; i < length; i++) output[9 + i] = ring.get(TRITON_OUTPUT_OFFSET + i);
|
||||
nativeStoreFence();
|
||||
return seq == ring.getLong(TRITON_OUTPUT_SEQ_OFFSET) ? output : null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The Steam client's newest unanswered feature read, or 0: its request number. */
|
||||
public static long readTritonFeatureRequest(int slot) {
|
||||
RingSlot ringSlot = tritonSlot(slot);
|
||||
if (ringSlot == null) return 0L;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return 0L;
|
||||
nativeStoreFence();
|
||||
long request = ring.getLong(TRITON_GET_REQUEST_OFFSET);
|
||||
return request != ring.getLong(TRITON_GET_REPLY_OFFSET) ? request : 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/** Answers feature read {@code request} with the controller's report, or a failure when null. */
|
||||
public static void writeTritonFeatureReply(int slot, long request, byte[] report) {
|
||||
RingSlot ringSlot = tritonSlot(slot);
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
|
||||
int length = report == null ? 0 : Math.min(report.length, TRITON_REPORT_MAX);
|
||||
for (int i = 0; i < TRITON_REPORT_MAX; i++) ring.put(TRITON_GET_OFFSET + i, i < length ? report[i] : 0);
|
||||
ring.putInt(TRITON_GET_LENGTH_OFFSET, length);
|
||||
nativeStoreFence();
|
||||
ring.putLong(TRITON_GET_REPLY_OFFSET, request);
|
||||
}
|
||||
}
|
||||
|
||||
private static RingSlot tritonSlot(int slot) {
|
||||
synchronized (RING_LOCK) {
|
||||
return slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
}
|
||||
|
||||
private static short clampShort(int value) {
|
||||
if (value > Short.MAX_VALUE) {
|
||||
return Short.MAX_VALUE;
|
||||
|
||||
@@ -369,6 +369,23 @@ public final class PadBridge {
|
||||
publish();
|
||||
}
|
||||
|
||||
/**
|
||||
* A controller the app reads itself rather than through Android's input (the Steam Controller
|
||||
* over Bluetooth, SteamControllerBle): its whole state, merged into the one exported slot as a
|
||||
* physical pad's events are. Android has no device for it, so a game's rumble cannot reach it.
|
||||
* It streams its state whether or not anything moved; {@code changed} is whether this one did.
|
||||
*/
|
||||
public synchronized void applyExternal(java.util.function.Consumer<PadState> mutation, boolean changed) {
|
||||
mutation.accept(state);
|
||||
if (changed) {
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
notePlayerInput();
|
||||
statButtons++;
|
||||
scheduleStats();
|
||||
}
|
||||
publish();
|
||||
}
|
||||
|
||||
/** Touch-only Steam and QAM buttons, merged with physical input without changing its state. */
|
||||
public synchronized void setSystemButtons(boolean guidePressed, boolean qamPressed) {
|
||||
if (systemGuidePressed == guidePressed && systemQamPressed == qamPressed) return;
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.ArrayDeque
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Reads the Steam Controller (2025, Triton) through its Valve BLE service. State reports remain
|
||||
* native Triton packets and are published to the session's separate 28DE:1303 HID device. Steam's
|
||||
* output reports travel back to their matching BLE characteristics, preserving grip capacitance,
|
||||
* trackpads, sensors, and controller haptics without folding the controller into the Deck target.
|
||||
*
|
||||
* Lizard mode is refreshed while connected; when a session ends, the controller restores it itself.
|
||||
*/
|
||||
@SuppressLint("MissingPermission") // BLUETOOTH is granted at install below targetSdk 31
|
||||
class SteamControllerBle(private val context: Context) {
|
||||
private val thread = HandlerThread("steam-controller-ble").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
private val outputReports = mutableMapOf<Int, UUID>()
|
||||
/** GATT allows one operation at a time: writes carry a value, a feature read none. */
|
||||
private val queuedWrites = ArrayDeque<Pair<UUID, ByteArray?>>()
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var running = false
|
||||
private var input: UUID? = null
|
||||
private var reportId = 0
|
||||
private var lizardOffSent = false
|
||||
private var reportedNone = false
|
||||
private var discoveries = 0
|
||||
private var outputSequence = 0L
|
||||
private var outputWritePending = false
|
||||
/** The Steam client's feature read being answered, so the poll queues each one once. */
|
||||
private var featureRead = 0L
|
||||
|
||||
/** Looks for a paired Steam Controller and keeps a connection to it until [stop]. */
|
||||
fun start() = handler.post {
|
||||
if (running) return@post
|
||||
running = true
|
||||
Log.i(TAG, "looking for a paired Steam Controller")
|
||||
connect()
|
||||
}
|
||||
|
||||
/** Lets the controller go (it returns to lizard mode by itself); this object is done after. */
|
||||
fun stop() = handler.post {
|
||||
running = false
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
disconnect("session ended")
|
||||
thread.quitSafely()
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
if (!running || gatt != null) return
|
||||
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
|
||||
val bonded = runCatching { adapter?.takeIf(BluetoothAdapter::isEnabled)?.bondedDevices }
|
||||
.onFailure { Log.w(TAG, "paired devices unreadable", it) }
|
||||
.getOrNull()
|
||||
val device = bonded?.firstOrNull(::isSteamController)
|
||||
if (device == null) {
|
||||
if (!reportedNone) {
|
||||
reportedNone = true
|
||||
Log.i(TAG, "no paired Steam Controller (Bluetooth ${if (adapter?.isEnabled == true) "on" else "off"}; paired: " +
|
||||
(bonded?.joinToString { "\"${it.name}\" type ${it.type}" } ?: "unreadable") + "); looking again every ${RETRY_MS / 1000} s")
|
||||
}
|
||||
handler.postDelayed(::connect, RETRY_MS)
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "connecting to \"${device.name}\"")
|
||||
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE, BluetoothDevice.PHY_LE_1M_MASK, handler)
|
||||
}
|
||||
|
||||
private fun disconnect(why: String) {
|
||||
val g = gatt ?: return
|
||||
gatt = null
|
||||
discoveries = 0
|
||||
input = null
|
||||
lizardOffSent = false
|
||||
outputWritePending = false
|
||||
featureRead = 0L
|
||||
queuedWrites.clear()
|
||||
outputReports.clear()
|
||||
connected = false
|
||||
g.disconnect()
|
||||
g.close()
|
||||
release()
|
||||
Log.i(TAG, "disconnected: $why")
|
||||
listener?.invoke()
|
||||
}
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||
if (g !== gatt) return
|
||||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
g.discoverServices()
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
disconnect("link lost (status $status)")
|
||||
if (running) handler.postDelayed(::connect, RETRY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||
if (g !== gatt) return
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
disconnect("service discovery failed (status $status)")
|
||||
if (running) handler.postDelayed(::connect, RETRY_MS)
|
||||
return
|
||||
}
|
||||
// The Triton needs data length extensions, which Android enables for a large MTU.
|
||||
if (!g.requestMtu(517)) subscribe(g)
|
||||
}
|
||||
|
||||
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
|
||||
if (g === gatt) subscribe(g)
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
if (g !== gatt || descriptor.characteristic.uuid != input) return
|
||||
Log.i(TAG, "subscribed to report 0x%02x (status %d)".format(reportId, status))
|
||||
connected = status == BluetoothGatt.GATT_SUCCESS
|
||||
listener?.invoke()
|
||||
if (connected) {
|
||||
refreshLizardMode()
|
||||
pollOutput()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
|
||||
if (g !== gatt) return
|
||||
if (characteristic.uuid == REPORT && !lizardOffSent) {
|
||||
lizardOffSent = true
|
||||
Log.i(TAG, "lizard mode off (status $status)")
|
||||
} else if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.w(TAG, "output report write failed for ${characteristic.uuid} (status $status)")
|
||||
}
|
||||
outputWritePending = false
|
||||
writeNextOutput()
|
||||
}
|
||||
|
||||
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
|
||||
if (g === gatt) onFeatureRead(value.takeIf { status == BluetoothGatt.GATT_SUCCESS })
|
||||
}
|
||||
|
||||
@Deprecated("Below Android 13 only", ReplaceWith(""))
|
||||
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
|
||||
@Suppress("DEPRECATION")
|
||||
if (g === gatt) onFeatureRead(characteristic.value.takeIf { status == BluetoothGatt.GATT_SUCCESS })
|
||||
}
|
||||
|
||||
// Android 13 hands the value over with the call; older ones only through the characteristic.
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
|
||||
if (g === gatt && characteristic.uuid == input) onState(value)
|
||||
}
|
||||
|
||||
@Deprecated("Below Android 13 only", ReplaceWith(""))
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||
@Suppress("DEPRECATION")
|
||||
if (g === gatt && characteristic.uuid == input) onState(characteristic.value ?: return)
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribe(g: BluetoothGatt) {
|
||||
val service = g.getService(SERVICE)
|
||||
if (service == null) {
|
||||
Log.w(TAG, "no Valve service on \"${g.device.name}\" (attempt ${discoveries + 1}); services: " +
|
||||
g.services.joinToString { it.uuid.toString().take(8) })
|
||||
// Android may answer from the service list its HID host cached, which can lack the
|
||||
// vendor service: ask the controller again before giving up on it.
|
||||
if (++discoveries < 3) {
|
||||
refreshCache(g)
|
||||
handler.postDelayed({ if (g === gatt) g.discoverServices() }, 500)
|
||||
} else {
|
||||
disconnect("no Valve service")
|
||||
if (running) handler.postDelayed(::connect, RETRY_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
val chr = service.getCharacteristic(INPUT_0X47)?.also { reportId = 0x47 }
|
||||
?: service.getCharacteristic(INPUT_0X45)?.also { reportId = 0x45 }
|
||||
if (chr == null) {
|
||||
disconnect("no state report characteristic")
|
||||
return
|
||||
}
|
||||
outputReports.clear()
|
||||
service.characteristics.forEach { characteristic ->
|
||||
val uuid = characteristic.uuid.toString().lowercase()
|
||||
if (uuid.startsWith(OUTPUT_UUID_PREFIX)) {
|
||||
val characteristicId = uuid.substring(6, 8).toIntOrNull(16) ?: return@forEach
|
||||
val outputId = characteristicId - OUTPUT_UUID_OFFSET
|
||||
if (outputId in 0x80..0x85) outputReports[outputId] = characteristic.uuid
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "found Triton haptic reports: ${outputReports.keys.sorted().joinToString { "0x%02x".format(it) }}")
|
||||
input = chr.uuid
|
||||
g.setCharacteristicNotification(chr, true)
|
||||
val cccd = chr.getDescriptor(CCCD) ?: return disconnect("state report cannot notify")
|
||||
@Suppress("DEPRECATION")
|
||||
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
@Suppress("DEPRECATION")
|
||||
g.writeDescriptor(cccd)
|
||||
}
|
||||
|
||||
/** BluetoothGatt.refresh(): hidden, but used to drop stale service caches. */
|
||||
private fun refreshCache(g: BluetoothGatt) {
|
||||
runCatching { g.javaClass.getMethod("refresh").invoke(g) }
|
||||
.onFailure { Log.w(TAG, "could not refresh the service cache", it) }
|
||||
}
|
||||
|
||||
/** SDL's DisableSteamTritonLizardMode: ID_SET_SETTINGS_VALUES (0x87), setting 9 = off. */
|
||||
private fun refreshLizardMode() {
|
||||
if (!connected) return
|
||||
val msg = ByteArray(FEATURE_BYTES)
|
||||
msg[0] = 0x87.toByte(); msg[1] = 3; msg[2] = 9
|
||||
enqueueOutput(REPORT, msg)
|
||||
handler.postDelayed(::refreshLizardMode, LIZARD_REFRESH_MS)
|
||||
}
|
||||
|
||||
/** Preserve the full 0x45 or 0x47 packet so Triton-only bits reach Steam's native driver. */
|
||||
private fun onState(data: ByteArray) {
|
||||
if (data.size < STATE_BYTES) return
|
||||
FakeInputWriter.writeTritonState(SLOT, reportId, data)
|
||||
}
|
||||
|
||||
/** A neutral report prevents a link lost mid-press from leaving controls held. */
|
||||
private fun release() {
|
||||
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
|
||||
}
|
||||
|
||||
/** Drain Steam HID output reports and write each to Triton's matching BLE characteristic. */
|
||||
private fun pollOutput() {
|
||||
if (!running || !connected || gatt == null) return
|
||||
FakeInputWriter.readTritonOutput(SLOT, outputSequence)?.let { output ->
|
||||
outputSequence = ByteBuffer.wrap(output, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
|
||||
sendOutputReport(output[8] != 0.toByte(), output.copyOfRange(9, output.size))
|
||||
}
|
||||
val request = FakeInputWriter.readTritonFeatureRequest(SLOT)
|
||||
if (request != 0L && request != featureRead) {
|
||||
featureRead = request
|
||||
// Behind any queued write, so a read answers the feature report written before it.
|
||||
queuedWrites.addLast(REPORT to null)
|
||||
writeNextOutput()
|
||||
}
|
||||
handler.postDelayed(::pollOutput, OUTPUT_POLL_MS)
|
||||
}
|
||||
|
||||
/** The controller's answer to a feature read (its info, for one), or null when the read failed. */
|
||||
private fun onFeatureRead(value: ByteArray?) {
|
||||
outputWritePending = false
|
||||
if (featureRead != 0L) {
|
||||
if (value == null) Log.w(TAG, "feature read failed")
|
||||
FakeInputWriter.writeTritonFeatureReply(SLOT, featureRead, value)
|
||||
}
|
||||
writeNextOutput()
|
||||
}
|
||||
|
||||
private fun sendOutputReport(feature: Boolean, report: ByteArray) {
|
||||
if (report.size < 2) return
|
||||
val characteristic = if (feature) {
|
||||
REPORT
|
||||
} else {
|
||||
val id = report[0].toInt() and 0xff
|
||||
outputReports[id] ?: run {
|
||||
Log.w(TAG, "no BLE output characteristic for report 0x%02x".format(id))
|
||||
return
|
||||
}
|
||||
}
|
||||
// SDL's BLE driver removes the HID report ID and trailing padding byte before transmission.
|
||||
enqueueOutput(characteristic, report.copyOfRange(1, report.size - 1))
|
||||
}
|
||||
|
||||
private fun enqueueOutput(characteristic: UUID, value: ByteArray?) {
|
||||
queuedWrites.addLast(characteristic to value)
|
||||
writeNextOutput()
|
||||
}
|
||||
|
||||
private fun writeNextOutput() {
|
||||
if (outputWritePending) return
|
||||
val g = gatt ?: return
|
||||
val (uuid, value) = queuedWrites.pollFirst() ?: return
|
||||
val characteristic = g.getService(SERVICE)?.getCharacteristic(uuid)
|
||||
if (characteristic == null) {
|
||||
Log.w(TAG, "BLE output characteristic $uuid disappeared")
|
||||
return
|
||||
}
|
||||
if (value == null) {
|
||||
if (g.readCharacteristic(characteristic)) {
|
||||
outputWritePending = true
|
||||
} else {
|
||||
queuedWrites.addFirst(uuid to null)
|
||||
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.value = value
|
||||
@Suppress("DEPRECATION")
|
||||
if (!g.writeCharacteristic(characteristic)) {
|
||||
queuedWrites.addFirst(uuid to value)
|
||||
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
|
||||
} else {
|
||||
outputWritePending = true
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SteamControllerBle"
|
||||
/** The paired controller's native Triton reports live in slot 0's extended ring. */
|
||||
private const val SLOT = 0
|
||||
private const val RETRY_MS = 5_000L
|
||||
private const val LIZARD_REFRESH_MS = 3_000L
|
||||
private const val OUTPUT_POLL_MS = 4L
|
||||
private const val OUTPUT_RETRY_MS = 10L
|
||||
/** SDL's 64-byte feature report less the report ID and the trailing byte. */
|
||||
private const val FEATURE_BYTES = 62
|
||||
private const val STATE_BYTES = 45
|
||||
private const val OUTPUT_UUID_PREFIX = "100f6c"
|
||||
private const val OUTPUT_UUID_OFFSET = 0x35
|
||||
|
||||
private val SERVICE = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3")
|
||||
private val INPUT_0X45 = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3")
|
||||
private val INPUT_0X47 = UUID.fromString("100F6C7C-1735-4313-B402-38567131E5F3")
|
||||
private val REPORT = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3")
|
||||
private val CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
private fun isSteamController(device: BluetoothDevice): Boolean =
|
||||
device.type and BluetoothDevice.DEVICE_TYPE_LE != 0 &&
|
||||
(device.name ?: "").let { it.startsWith("Steam Ctrl") || it.startsWith("SteamController") }
|
||||
|
||||
/** Whether a session should expose a Triton HID node for a paired controller. */
|
||||
@JvmStatic
|
||||
fun hasPairedController(context: Context): Boolean {
|
||||
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter ?: return false
|
||||
return runCatching { adapter.isEnabled && adapter.bondedDevices.any(::isSteamController) }
|
||||
.onFailure { Log.w(TAG, "paired devices unreadable", it) }
|
||||
.getOrDefault(false)
|
||||
}
|
||||
|
||||
/** A Steam Controller is connected and feeding its native HID reports. */
|
||||
@Volatile @JvmStatic
|
||||
var connected = false
|
||||
private set
|
||||
|
||||
/** Notifies the activity when [connected] changes. */
|
||||
@Volatile @JvmStatic
|
||||
var listener: (() -> Unit)? = null
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.core.HostProcess
|
||||
import com.droiddeck.launcher.frontend.GameLaunchLink
|
||||
import com.droiddeck.launcher.input.FakeInputWriter
|
||||
import com.droiddeck.launcher.input.SteamControllerBle
|
||||
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.ProotFastPath
|
||||
@@ -744,7 +745,10 @@ class SessionService : Service() {
|
||||
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
|
||||
// Steam's touch controller rides on the Deck's sysfs and is offered only beside it.
|
||||
val wantsTouch = wantsDeck && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists()
|
||||
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch) else emptyList()
|
||||
// A paired Triton is exposed as a second native Steam Controller HID, with its original
|
||||
// report IDs and output path. The handheld's own pad remains the Deck target.
|
||||
val wantsTriton = wantsDeck && SteamControllerBle.hasPairedController(this)
|
||||
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch, wantsTriton) else emptyList()
|
||||
SessionState.deckPad = deckBinds.isNotEmpty()
|
||||
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
|
||||
logControllersAtStart()
|
||||
@@ -754,6 +758,7 @@ class SessionService : Service() {
|
||||
guest.add("FAKE_EVDEV_UINPUT=1")
|
||||
if (SessionState.deckPad) {
|
||||
guest.add("FAKE_EVDEV_DECK=1")
|
||||
if (wantsTriton) guest.add("FAKE_EVDEV_TRITON=1")
|
||||
// Steam's touch controller (SteamTouchDevice): the file the app and libfakeinput share.
|
||||
if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let {
|
||||
guest.add("FAKE_TOUCHCTL_RING=" + it.path)
|
||||
|
||||
@@ -44,6 +44,14 @@ object SteamDeckPad {
|
||||
private const val HIDRAW = "$HID/hidraw/$NODE"
|
||||
private const val SERIAL = "DROIDDECK0001"
|
||||
|
||||
// The paired Bluetooth Steam Controller is a separate Triton HID device. It must not inherit
|
||||
// the Deck's identity or report format. Must match fakeinput_steam.cpp (TRITON_HIDRAW_*).
|
||||
private const val TRITON_MINOR = 18
|
||||
private const val TRITON_NODE = "hidraw$TRITON_MINOR"
|
||||
private const val TRITON_HID = "$GUEST_DEVICES/bluetooth/0005:28DE:1303.0003"
|
||||
private const val TRITON_HIDRAW = "$TRITON_HID/hidraw/$TRITON_NODE"
|
||||
private const val TRITON_SERIAL = "DROIDDECKSC01"
|
||||
|
||||
// Steam's touch controller (SteamTouchDevice), a second device beside the Deck's: 0000:11fb,
|
||||
// "Mobile Touch Control", on a USB device of its own. Must match libfakeinput (TOUCH_*).
|
||||
private const val TOUCH_MINOR = 17
|
||||
@@ -69,11 +77,27 @@ object SteamDeckPad {
|
||||
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** Triton's native 0x45/0x47 input and 0x80-0x85 haptic output report IDs. */
|
||||
private val TRITON_REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x06, 0x00, 0xff, 0x09, 0x01, 0xa1, 0x01,
|
||||
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
|
||||
0x85, 0x45, 0x09, 0x02, 0x95, 0x2d, 0x81, 0x02,
|
||||
0x85, 0x47, 0x09, 0x03, 0x95, 0x2d, 0x81, 0x02,
|
||||
0x85, 0x80, 0x09, 0x04, 0x95, 0x09, 0x91, 0x02,
|
||||
0x85, 0x81, 0x09, 0x05, 0x95, 0x07, 0x91, 0x02,
|
||||
0x85, 0x82, 0x09, 0x06, 0x95, 0x03, 0x91, 0x02,
|
||||
0x85, 0x83, 0x09, 0x07, 0x95, 0x09, 0x91, 0x02,
|
||||
0x85, 0x84, 0x09, 0x08, 0x95, 0x08, 0x91, 0x02,
|
||||
0x85, 0x85, 0x09, 0x09, 0x95, 0x03, 0x91, 0x02,
|
||||
0x85, 0x01, 0x09, 0x0a, 0x95, 0x3f, 0xb1, 0x02,
|
||||
0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */
|
||||
fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing")
|
||||
|
||||
/** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */
|
||||
fun prepare(context: Context, sessionRoot: File, touch: Boolean = false): List<String> {
|
||||
fun prepare(context: Context, sessionRoot: File, touch: Boolean = false, triton: Boolean = false): List<String> {
|
||||
val base = File(sessionRoot, "sys/deck")
|
||||
val devices = File(base, "devices")
|
||||
val hidrawClass = File(base, "class-hidraw")
|
||||
@@ -153,6 +177,22 @@ object SteamDeckPad {
|
||||
Log.i(TAG, "deck pad: /dev/$TOUCH_NODE described as Steam's touch controller (0000:11fb)")
|
||||
}
|
||||
|
||||
if (triton) {
|
||||
write(TRITON_HID, "uevent", "DRIVER=hid-generic\nHID_ID=0005:000028DE:00001303\n" +
|
||||
"HID_NAME=Valve Software Steam Controller\nHID_PHYS=bluetooth-droiddeck-1\n" +
|
||||
"HID_UNIQ=$TRITON_SERIAL\nMODALIAS=hid:b0005g0001v000028DEp00001303\n")
|
||||
File(dir(TRITON_HID), "report_descriptor").writeBytes(TRITON_REPORT_DESCRIPTOR)
|
||||
link(File(dir(TRITON_HID), "subsystem"), "/sys/bus/hid")
|
||||
write(TRITON_HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$TRITON_MINOR\nDEVNAME=$TRITON_NODE\n")
|
||||
write(TRITON_HIDRAW, "dev", "$MAJOR:$TRITON_MINOR\n")
|
||||
link(File(dir(TRITON_HIDRAW), "subsystem"), "/sys/class/hidraw")
|
||||
link(File(dir(TRITON_HIDRAW), "device"), TRITON_HID)
|
||||
link(File(hidrawClass, TRITON_NODE), TRITON_HIDRAW)
|
||||
link(File(context.cacheDir, "drm/sys/$MAJOR:$TRITON_MINOR"), TRITON_HIDRAW)
|
||||
File(udevData, "c$MAJOR:$TRITON_MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
Log.i(TAG, "steam controller: /dev/$TRITON_NODE described as Triton (28de:1303)")
|
||||
}
|
||||
|
||||
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
|
||||
listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw")
|
||||
} catch (e: Exception) {
|
||||
|
||||
Reference in new issue
Block a user