Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,43 @@ behaviour stays predictable across platforms:
enum), USB VID / PID, and the SDL GUID. Aimed at users reporting *"my pad
doesn't work"* — same idea as Android's SatelliteJNI `DEVCAPS` log.

## Rumble (return path)

Rumble flows the opposite direction to the input hot path: a game on the
satellite host writes to the virtual controller's vibration channel, the
satellite forwards a `MSG_RUMBLE = 0x0009` packet back over the encrypted
UDP socket, and the dish actuates the matching SDL controller.

```
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ SatelliteClient │ ───► │ WifiConnection │ ───► │ SDLGamepadBridge │
│ • receive thread │ │ • per-conn handler │ │ • applyRumble(...) │
│ • parseRumbleMsg │ │ (installed by │ │ → SDL_Game- │
│ • dispatch to │ │ AppModel via │ │ ControllerRumble│
│ handler │ │ poolChanged) │ │ → ...SetLED │
└──────────────────────┘ └──────────────────────┘ └──────────┬───────────┘
evdev EVIOCSFF
(or BT-HID rumble)
```

The wire format is documented in
[`satellite/README.md`](https://github.com/TinkerNorth/satellite#rumble-return-path).
On the dish-linux side:

* **Parser** — `SatelliteClient::parseRumbleMessage` is a pure static
decoder so unit tests can exercise byte layouts without a live socket
(see `tests/test_satellite_client_rumble.cpp`).
* **Routing** — `AppModel::installRumbleHandlers` walks the `WifiConnection`
pool on every `poolChanged` and attaches a handler that resolves
`connId → slotId → deviceId` via the `ConnectionHub` bindings, then
calls `SDLGamepadBridge::applyRumble`.
* **Actuation** — `SDL_GameControllerRumble(strong, weak, durMs)` for the
motors; `SDL_GameControllerSetLED(R, G, B)` when the satellite published
a DS4 lightbar colour. Failures are silent — many pads don't support
either operation and there's nothing actionable for the player.

## Requirements

- A reasonably current Linux distro (Ubuntu 22.04+, Fedora 38+, Arch, …)
Expand Down
33 changes: 33 additions & 0 deletions src/AppModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ AppModel::AppModel(std::unique_ptr<util::DisplaySleepInhibitor> inhibitor, QObje
&AppModel::onBridgeDevicesChanged);
QObject::connect(wifi_, &net::WifiConnectionManager::connectionEvent, this,
&AppModel::onWifiEvent);
// poolChanged fires every time a WifiConnection is created or transitions
// state — perfect place to make sure new connections have a rumble
// handler. Idempotent on already-wired connections.
QObject::connect(wifi_, &net::WifiConnectionManager::poolChanged, this,
&AppModel::installRumbleHandlers);

autoReconnectTimer_->setInterval(15'000);
QObject::connect(autoReconnectTimer_, &QTimer::timeout, this,
Expand All @@ -43,6 +48,34 @@ AppModel::AppModel(std::unique_ptr<util::DisplaySleepInhibitor> inhibitor, QObje

AppModel::~AppModel() { bridge_->stop(); }

void AppModel::installRumbleHandlers() {
for (auto* conn : wifi_->connections()) {
const QString id = conn->id();
if (rumbleWiredConnections_.contains(id)) { continue; }
rumbleWiredConnections_.insert(id);
// Capture `this` and the connection id by value. The handler runs on
// the SatelliteClient receive thread; it only reads structures
// protected by their own locks (hub bindings, bridge device map).
conn->setRumbleHandler([this, id](const net::SatelliteClient::RumbleMessage& rm) {
// Find the slot bound to this connection.
QString deviceId;
const auto bindings = hub_->bindings();
for (auto it = bindings.cbegin(); it != bindings.cend(); ++it) {
if (it.value() == id) {
deviceId = it.key();
break;
}
}
if (deviceId.isEmpty()) { return; }
// For dish-linux, slot.id == physical device.id (set in rebuild()),
// so the slot id IS the bridge's device id. Hand it straight to
// the SDL bridge.
bridge_->applyRumble(deviceId, rm.strongMagnitude, rm.weakMagnitude, rm.durationMs,
rm.hasLightbar, rm.lightbarR, rm.lightbarG, rm.lightbarB);
});
}
}

void AppModel::start() {
bridge_->start();
wifi_->autoReconnectAll();
Expand Down
10 changes: 10 additions & 0 deletions src/AppModel.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

#include <QHash>
#include <QObject>
#include <QSet>
#include <QString>
#include <QTimer>

Expand Down Expand Up @@ -85,6 +86,10 @@ class AppModel : public QObject {
void onHubChanged();
void onBridgeDevicesChanged();
void onWifiEvent(const net::ConnectionEvent& evt);
// Walk the WifiConnectionManager pool and install our rumble handler on
// any connection that doesn't already have one. Idempotent — invoked on
// every poolChanged signal so newly-created connections get wired.
void installRumbleHandlers();

std::unique_ptr<net::ConnectionStore> store_;
net::WifiConnectionManager* wifi_;
Expand All @@ -98,6 +103,11 @@ class AppModel : public QObject {
std::unique_ptr<util::DisplaySleepInhibitor> inhibitor_;
util::ScreenWakeController wake_;

// Set of connection ids we've already attached rumble handlers to, so we
// don't reinstall on every pool churn. WifiConnections live until
// application teardown so this set never gets pruned.
QSet<QString> rumbleWiredConnections_;

MainUiState state_;

// slotId -> active sender. Read on the SDL gamepad thread; written on the
Expand Down
27 changes: 27 additions & 0 deletions src/Input/SDLGamepadBridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,33 @@ void SDLGamepadBridge::runLoop() {
SDL_QuitSubSystem(SDL_INIT_GAMECONTROLLER | SDL_INIT_JOYSTICK);
}

void SDLGamepadBridge::applyRumble(const QString& deviceId, std::uint16_t strongMagnitude,
std::uint16_t weakMagnitude, std::uint16_t durationMs,
bool hasLightbar, std::uint8_t lightbarR,
std::uint8_t lightbarG, std::uint8_t lightbarB) {
SDL_GameController* gc = nullptr;
{
std::lock_guard<std::mutex> lock(mtx_);
for (const auto& [iid, did] : deviceIds_) {
if (did == deviceId) {
if (auto it = openControllers_.find(iid); it != openControllers_.end()) {
gc = it->second;
}
break;
}
}
}
if (gc == nullptr) { return; }
// SDL2's `SDL_GameControllerRumble` returns 0 on success, -1 if the device
// doesn't support rumble — silent: the caller has no recourse beyond the
// satellite-side game already running, which doesn't know either way.
SDL_GameControllerRumble(gc, strongMagnitude, weakMagnitude, durationMs);
if (hasLightbar) {
// SDL_GameControllerSetLED is a no-op on pads without a lightbar.
SDL_GameControllerSetLED(gc, lightbarR, lightbarG, lightbarB);
}
}

void SDLGamepadBridge::rebuildState(int iid) {
SDL_GameController* gc = nullptr;
std::string deviceId;
Expand Down
18 changes: 18 additions & 0 deletions src/Input/SDLGamepadBridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include <QString>

#include <atomic>
#include <cstdint>
#include <thread>
#include <unordered_map>

Expand Down Expand Up @@ -42,6 +43,23 @@ class SDLGamepadBridge : public QObject {
};
QList<Device> devices() const;

// Drive the physical controller's rumble motors. `strongMagnitude` and
// `weakMagnitude` are 16-bit magnitudes matching XInput's scale so they
// can flow through the SDL2 API verbatim. `durationMs == 0` is a "stop"
// signal — SDL itself treats 0 as "do not run", so we forward as-is.
//
// If the controller exposes a lightbar (DualShock 4 / DualSense) and the
// satellite published one, we also call SDL_GameControllerSetLED. Failures
// are silent — many pads don't support either operation and SDL just
// returns -1 in that case.
//
// Thread-safety: callable from any thread; takes the same internal mutex
// that guards the device map. Intended to be invoked from the
// SatelliteClient receive thread.
void applyRumble(const QString& deviceId, std::uint16_t strongMagnitude,
std::uint16_t weakMagnitude, std::uint16_t durationMs, bool hasLightbar,
std::uint8_t lightbarR, std::uint8_t lightbarG, std::uint8_t lightbarB);

signals:
void devicesChanged();

Expand Down
39 changes: 39 additions & 0 deletions src/Network/SatelliteClient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,46 @@ void SatelliteClient::processIncoming(const std::uint8_t* buf, std::size_t n) {
} else if (msgType == kMsgServerStatus && msgLen >= 2 && plainLen >= 6) {
vigemAvailable_.store(plain[4] == 0 ? 0 : 1, std::memory_order_relaxed);
activeControllerCount_.store(static_cast<std::int8_t>(plain[5]), std::memory_order_relaxed);
} else if (msgType == kMsgRumble) {
// The inner header bytes are at plain[0..3]; the payload starts at +4.
// parseRumbleMessage works on the payload region for parity with the
// unit-test seam, so adjust the pointer/length accordingly.
if (plainLen < 4) { return; }
const auto rm = parseRumbleMessage(plain.data() + 4,
static_cast<std::size_t>(plainLen) - 4);
if (!rm) { return; }
RumbleHandler handler;
{
std::lock_guard<std::mutex> lock(rumbleHandlerMtx_);
handler = rumbleHandler_;
}
if (handler) { handler(*rm); }
}
}

void SatelliteClient::setRumbleHandler(RumbleHandler handler) {
std::lock_guard<std::mutex> lock(rumbleHandlerMtx_);
rumbleHandler_ = std::move(handler);
}

std::optional<SatelliteClient::RumbleMessage>
SatelliteClient::parseRumbleMessage(const std::uint8_t* payload, std::size_t len) {
// Mandatory fields: ctrlIdx + strong + weak + dur + flags = 8 bytes.
if (payload == nullptr || len < 8) { return std::nullopt; }
RumbleMessage rm;
rm.controllerIndex = payload[0];
rm.strongMagnitude = util::readU16Be(payload + 1);
rm.weakMagnitude = util::readU16Be(payload + 3);
rm.durationMs = util::readU16Be(payload + 5);
const std::uint8_t flags = payload[7];
rm.hasLightbar = (flags & 0x01) != 0;
if (rm.hasLightbar) {
if (len < 11) { return std::nullopt; } // declared lightbar but truncated
rm.lightbarR = payload[8];
rm.lightbarG = payload[9];
rm.lightbarB = payload[10];
}
return rm;
}

} // namespace dish::net
43 changes: 43 additions & 0 deletions src/Network/SatelliteClient.h
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
#include <array>
#include <atomic>
#include <cstdint>
#include <functional>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <vector>
Expand All @@ -40,6 +42,7 @@ class SatelliteClient {
static constexpr std::uint16_t kMsgControllerAck = 0x0006;
static constexpr std::uint16_t kMsgServerStatus = 0x0007;
static constexpr std::uint16_t kMsgControllerType = 0x0008;
static constexpr std::uint16_t kMsgRumble = 0x0009;

static constexpr std::uint32_t kHeartbeatIntervalMs = 2000;
static constexpr int kHeartbeatMissMax = 5;
Expand Down Expand Up @@ -69,6 +72,40 @@ class SatelliteClient {
void sendControllerType(int index, int type);
void resetControllerAck() { lastControllerAck_.store(-1, std::memory_order_relaxed); }

// Decoded rumble message from the satellite. `lightbar*` are valid only
// when `hasLightbar` is true (the wire format's optional trailing 3 bytes).
struct RumbleMessage {
int controllerIndex = 0;
std::uint16_t strongMagnitude = 0;
std::uint16_t weakMagnitude = 0;
std::uint16_t durationMs = 0;
bool hasLightbar = false;
std::uint8_t lightbarR = 0;
std::uint8_t lightbarG = 0;
std::uint8_t lightbarB = 0;
};

// Install (or replace) the rumble callback. Invoked from the receive
// loop's thread for every parsed MSG_RUMBLE packet. The handler is
// expected to enqueue / forward to the actuator without blocking; we
// hold an internal lock around assignment to avoid a TOCTOU on the read
// side, but the call itself runs unlocked.
using RumbleHandler = std::function<void(const RumbleMessage&)>;
void setRumbleHandler(RumbleHandler handler);

// Pure decoder for the MSG_RUMBLE inner payload (the 4-byte header
// {type, length} has already been stripped). Returns std::nullopt on
// truncation; see ClientAdapter::sendRumble for the producer side. Kept
// public + static so it can be exercised by unit tests without a live
// socket.
//
// Wire layout:
// ctrlIdx(1) strong(2 BE) weak(2 BE) durMs(2 BE) flags(1) [R(1) G(1) B(1)]
//
// `flags` bit 0 set ⇒ trailing R/G/B bytes are present (DS4 lightbar).
static std::optional<RumbleMessage> parseRumbleMessage(const std::uint8_t* payload,
std::size_t len);

void startHeartbeat();
void stopHeartbeat();
void startReceiveLoop();
Expand Down Expand Up @@ -112,6 +149,12 @@ class SatelliteClient {
std::atomic<std::int32_t> lastControllerAck_{-1};
std::atomic<std::int8_t> vigemAvailable_{-1};
std::atomic<std::int8_t> activeControllerCount_{-1};

// Read on every parsed MSG_RUMBLE on the receive thread; written from
// the owning thread (Qt main) via setRumbleHandler. A short critical
// section (handler copy under lock) keeps the hot-path call unlocked.
std::mutex rumbleHandlerMtx_;
RumbleHandler rumbleHandler_;
};

} // namespace dish::net
8 changes: 8 additions & 0 deletions src/Network/WifiConnection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ void WifiConnection::markConnected(std::shared_ptr<SatelliteClient> client,
onDead_ = std::move(onDead);

client->resetControllerAck();
if (rumbleHandler_) { client->setRumbleHandler(rumbleHandler_); }
client->startReceiveLoop();
client->startHeartbeat();

Expand Down Expand Up @@ -180,4 +181,11 @@ void WifiConnection::sendReport(std::uint16_t buttons, std::uint8_t lt, std::uin
}
}

void WifiConnection::setRumbleHandler(RumbleHandler handler) {
rumbleHandler_ = std::move(handler);
// Apply immediately if a session is already live; otherwise markConnected
// will pick up the new handler the next time it runs.
if (auto c = clientRef_.get()) { c->setRumbleHandler(rumbleHandler_); }
}

} // namespace dish::net
12 changes: 12 additions & 0 deletions src/Network/WifiConnection.h
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ class WifiConnection : public QObject {
void sendReport(std::uint16_t buttons, std::uint8_t lt, std::uint8_t rt, std::int16_t lx,
std::int16_t ly, std::int16_t rx, std::int16_t ry);

// Install the per-connection rumble handler. The handler is invoked from
// the SatelliteClient's receive thread on every MSG_RUMBLE we decode.
// Stored on the WifiConnection (not the per-session SatelliteClient) so
// it survives reconnects: markConnected() re-installs it on the new
// client instance.
using RumbleHandler = std::function<void(const SatelliteClient::RumbleMessage&)>;
void setRumbleHandler(RumbleHandler handler);

signals:
void changed();
void errorOccurred(const QString& message);
Expand Down Expand Up @@ -102,6 +110,10 @@ class WifiConnection : public QObject {
std::function<void()> onDead_;
bool controllerAdded_ = false;
int pendingControllerType_ = 0;

// Set once during composition; re-applied to each fresh SatelliteClient
// in markConnected() so we don't lose rumble across reconnects.
RumbleHandler rumbleHandler_;
};

} // namespace dish::net
3 changes: 2 additions & 1 deletion tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ set(DISH_TEST_SOURCES
test_gamepad_input_processor.cpp
test_pairing_client_classify.cpp
test_screen_wake_controller.cpp
test_freedesktop_screensaver_inhibitor.cpp)
test_freedesktop_screensaver_inhibitor.cpp
test_satellite_client_rumble.cpp)

add_executable(DishTests ${DISH_TEST_SOURCES})
target_link_libraries(DishTests
Expand Down
Loading
Loading