From 96d7c66535c623fdfd4ad95c225828a947192f2f Mon Sep 17 00:00:00 2001 From: SG3xHERO Date: Sat, 10 Oct 2026 21:53:07 +0100 Subject: [PATCH] Add Steam Controller support over Bluetooth LE (#404) --- app/src/main/AndroidManifest.xml | 3 + app/src/main/cpp/fakeinput_steam.cpp | 328 +++++++++++++++- .../com/droiddeck/launcher/SessionActivity.kt | 13 +- .../launcher/input/FakeInputWriter.java | 118 +++++- .../droiddeck/launcher/input/PadBridge.java | 17 + .../launcher/input/SteamControllerBle.kt | 360 ++++++++++++++++++ .../launcher/session/SessionService.kt | 7 +- .../launcher/session/SteamDeckPad.kt | 42 +- 8 files changed, 872 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e0f103841..d833ca2ae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,6 +30,9 @@ + + diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index c315a96b6..913d11914 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -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(reinterpret_cast(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(reinterpret_cast(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(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 *>(arg); + std::shared_ptr 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(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(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(mapping)) || + reinterpret_cast(static_cast(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(); + triton->peer = pair[1]; + triton->ring = static_cast(mapping); + triton->mapping_size = mapping_size; + triton->triton = true; + { + std::lock_guard guard(controller_mutex()); + deck_map()[pair[0]] = triton; + } + pthread_t thread; + auto *thread_arg = new std::shared_ptr(triton); + if (pthread_create(&thread, nullptr, triton_report_thread, thread_arg) != 0) { + delete thread_arg; + std::lock_guard 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(reinterpret_cast(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(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 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(reinterpret_cast(triton.ring) + TRITON_SHARED_OFFSET); + static std::mutex one_at_a_time; + std::lock_guard 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(sizeof(shared->get))) break; + size_t copied; + memset(report, 0, size); + if (shared->get[0] == report_id) { + copied = std::min(size, static_cast(length)); + memcpy(report, shared->get, copied); + } else { + report[0] = report_id; + copied = 1 + std::min(size - 1, static_cast(length)); + memcpy(report + 1, shared->get, copied - 1); + } + static std::atomic 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(copied); + } + struct timespec interval = {0, 250 * 1000L}; + nanosleep(&interval, nullptr); + } + static std::atomic 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(argp) = sizeof(kTritonReportDescriptor); + return 0; + case 0x02: { + auto *descriptor = static_cast(argp); + uint32_t size; + memcpy(&size, descriptor, sizeof(size)); + memcpy(descriptor + 4, kTritonReportDescriptor, std::min(size, sizeof(kTritonReportDescriptor))); + return 0; + } + case 0x03: { + struct { + uint32_t bustype; + int16_t vendor; + int16_t product; + } info = {BUS_BLUETOOTH, static_cast(0x28de), static_cast(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(argp), std::min(size, 64), true); + return static_cast(size); + } + case 0x07: return triton_get_feature(triton, static_cast(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 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 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(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(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 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(buf), count); + else if (deck_map()[fd]->triton) triton_queue_output(*deck_map()[fd], static_cast(buf), count, false); return static_cast(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 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(iov[i].iov_base), iov[i].iov_len); + if (device->touch) touch_output(static_cast(iov[i].iov_base), iov[i].iov_len); + else if (device->triton) triton_queue_output(*device, static_cast(iov[i].iov_base), iov[i].iov_len, false); total += static_cast(iov[i].iov_len); } return total; diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 19f9f7eac..78d18e1be 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java index a5f18a062..f80c592e3 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java +++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java @@ -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; diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index 7a543a363..9eff5a89b 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -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 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; diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt new file mode 100644 index 000000000..a7bea1a1c --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt @@ -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() + /** GATT allows one operation at a time: writes carry a value, a feature read none. */ + private val queuedWrites = ArrayDeque>() + 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 + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 1d2637723..2009b3e24 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt index 928a397f3..a4b8ea2d1 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt @@ -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 { + fun prepare(context: Context, sessionRoot: File, touch: Boolean = false, triton: Boolean = false): List { 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) {