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
8 changes: 6 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ endif()
# Dependencies
# --------------------------------------------------------------------------
find_package(Threads REQUIRED)
find_package(Qt6 6.2 REQUIRED COMPONENTS Core Gui Widgets Network)
find_package(Qt6 6.2 REQUIRED COMPONENTS Core Gui Widgets Network DBus)

find_package(PkgConfig REQUIRED)
pkg_check_modules(SODIUM REQUIRED IMPORTED_TARGET libsodium)
Expand All @@ -71,6 +71,10 @@ set(DISH_CORE_SOURCES
src/Util/Hex.h
src/Util/Hex.cpp
src/Util/Endian.h
src/Util/DisplaySleepInhibitor.h
src/Util/DisplaySleepInhibitor.cpp
src/Util/ScreenWakeController.h
src/Util/ScreenWakeController.cpp
src/Models/Models.h
src/Models/Models.cpp
src/Network/SatelliteClient.h
Expand Down Expand Up @@ -100,7 +104,7 @@ add_library(dish_core STATIC ${DISH_CORE_SOURCES})
target_include_directories(dish_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_link_libraries(dish_core
PUBLIC
Qt6::Core Qt6::Network
Qt6::Core Qt6::Network Qt6::DBus
PkgConfig::SODIUM
PkgConfig::SDL2
Threads::Threads
Expand Down
56 changes: 51 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,44 @@ SDL thread publishes lock-free.
- **`MSG_NOSIGNAL`** on every send so a server disconnect can't kill the
process.

## Cross-platform behaviour parity

The following behaviours mirror dish-android and dish-mac, so user-visible
behaviour stays predictable across platforms:

- **Display-sleep inhibitor while streaming.** A `ScreenWakeController` reads
`hub.bindings × hub.connections`, derives a streaming-slot count, and flips
the `org.freedesktop.ScreenSaver.Inhibit` D-Bus cookie on every 0↔positive
transition. The cookie is released on the last unbind / disconnect, so a
forgotten session doesn't pin the display awake forever. Works under every
modern desktop environment that implements the freedesktop ScreenSaver
portal (GNOME, KDE, Xfce, MATE, Cinnamon, Sway/swayidle, …).
- **Connection state recovery.** `PairingClient` carries a `reachable` flag
on every `PairResponse` (true iff we received a JSON body). `classify(...)`
splits the outcome into `Success | AuthRequired | Unreachable`; the manager
fans those out to either `openSession`, a PIN dialog, or an error toast.
A moved/offline server now surfaces a clear
*"Server unreachable — has it moved networks?"* message instead of trapping
the user behind an unanswerable PIN prompt. Mirrors dish-android PR #43.
- **Auto-reconnect fast path.** `WifiConnectionManager::pairAndConnect`
skips the TCP pair handshake entirely when an empty PIN comes in and a
64-char shared key is already on disk, going straight to `openSession`.
A moved server then fails fast in the HTTP layer rather than bouncing
through pair → `PairingRequired`.
- **Per-device deadzones.** `GamepadInputProcessor` carries a per-device
`Deadzones { stickFlat, triggerFlat }` table; reports are filtered
(`|v| <= flat → 0`) before they leave the processor. The default profile
(~10 % stick / ~5 % trigger) is installed by `SDLGamepadBridge` when each
controller attaches. SDL2 has no OS-level "flat" query equivalent to
Android's `InputDevice.getMotionRange(axis).getFlat()`, so the default
is the noise-floor we ship; future builds can read a per-device override
from the settings file.
- **Device-capability log on attach.** Every `SDL_CONTROLLERDEVICEADDED` logs
a one-shot `DEVCAPS` line via the `dish.input` Qt logging category,
carrying the stable id, controller name + type (SDL's `SDL_GameControllerType`
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.

## Requirements

- A reasonably current Linux distro (Ubuntu 22.04+, Fedora 38+, Arch, …)
Expand All @@ -77,10 +115,14 @@ SDL thread publishes lock-free.
```bash
sudo apt install -y \
build-essential cmake ninja-build pkg-config \
qt6-base-dev libsodium-dev libsdl2-dev \
qt6-base-dev qt6-tools-dev libsodium-dev libsdl2-dev \
clang-format clang-tidy
```

Note: `qt6-base-dev` already pulls in QtDBus on Debian/Ubuntu — required for
the `org.freedesktop.ScreenSaver.Inhibit` call that keeps the display awake
while streaming.

**Fedora (38+)**
```bash
sudo dnf install -y \
Expand Down Expand Up @@ -183,10 +225,14 @@ ctest --test-dir build-debug --output-on-failure
```

Unit tests cover the hex/byte-packing utilities, the big-endian helpers, the
XUSB input mapping (axis and trigger scaling, button bitfield, zero-on-disconnect
fan-out), the lock-free atomic counter under contention, the lenient beacon
JSON decoder, and the model codable round-trips. They run in well under a
second and do not open sockets.
XUSB input mapping (axis and trigger scaling, button bitfield, per-device
deadzone application, zero-on-disconnect fan-out), the lock-free atomic
counter under contention, the lenient beacon JSON decoder, the model codable
round-trips, the `PairingClient::classify` outcome arms (Success /
AuthRequired / Unreachable), and the `ScreenWakeController` acquire/release
lifecycle via a fake `DisplaySleepInhibitor` (so the suite never has to
talk to a session bus). They run in well under a second and do not open
sockets.

## Development

Expand Down
17 changes: 16 additions & 1 deletion src/AppModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@
namespace dish {

AppModel::AppModel(QObject* parent)
: AppModel(std::make_unique<util::FreedesktopScreenSaverInhibitor>(), parent) {}

AppModel::AppModel(std::unique_ptr<util::DisplaySleepInhibitor> inhibitor, QObject* parent)
: QObject(parent), store_(std::make_unique<net::ConnectionStore>()),
wifi_(new net::WifiConnectionManager(store_.get(), this)),
hub_(new net::ConnectionHub(wifi_, store_.get(), this)),
bridge_(new input::SDLGamepadBridge(&processor_, this)),
autoReconnectTimer_(new QTimer(this)) {
autoReconnectTimer_(new QTimer(this)), inhibitor_(std::move(inhibitor)),
wake_(inhibitor_.get()) {
QObject::connect(hub_, &net::ConnectionHub::changed, this, &AppModel::onHubChanged);
QObject::connect(bridge_, &input::SDLGamepadBridge::devicesChanged, this,
&AppModel::onBridgeDevicesChanged);
Expand Down Expand Up @@ -112,6 +116,17 @@ void AppModel::rebuild() {
routing_ = std::move(nextRouting);
}

// Drive the display-sleep inhibitor off bindings × hub.connections. The
// 0↔positive transitions inside ScreenWakeController acquire / release
// the D-Bus cookie; intermediate same-count emissions are no-ops so a
// noisy hub feed doesn't thrash the session bus.
QHash<QString, models::ConnectionLive> connectionStates;
for (const auto& summary : state_.connections) {
connectionStates.insert(summary.id, summary.live);
}
const int streamingCount = util::ScreenWakeController::streamingCount(bindings, connectionStates);
wake_.update(streamingCount);

emit stateChanged();
}

Expand Down
11 changes: 11 additions & 0 deletions src/AppModel.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
#include "Network/ConnectionHub.h"
#include "Network/ConnectionStore.h"
#include "Network/WifiConnectionManager.h"
#include "Util/DisplaySleepInhibitor.h"
#include "Util/ScreenWakeController.h"

#include <QHash>
#include <QObject>
Expand Down Expand Up @@ -43,14 +45,18 @@ struct MainUiState {
class AppModel : public QObject {
Q_OBJECT
public:
// Production constructor: builds a FreedesktopScreenSaverInhibitor under
// the hood. The unique_ptr overload below lets tests inject a fake.
explicit AppModel(QObject* parent = nullptr);
AppModel(std::unique_ptr<util::DisplaySleepInhibitor> inhibitor, QObject* parent = nullptr);
~AppModel() override;

net::ConnectionStore* store() { return store_.get(); }
net::WifiConnectionManager* wifi() { return wifi_; }
net::ConnectionHub* hub() { return hub_; }
input::GamepadInputProcessor* processor() { return &processor_; }
input::SDLGamepadBridge* bridge() { return bridge_; }
util::ScreenWakeController* wake() { return &wake_; }

// Single read-only accessor — the UI reads everything off this slice
// and re-renders on stateChanged().
Expand Down Expand Up @@ -83,6 +89,11 @@ class AppModel : public QObject {
input::GamepadInputProcessor processor_;
input::SDLGamepadBridge* bridge_;
QTimer* autoReconnectTimer_;
// Owned in unique_ptr so we can swap a FakeDisplaySleepInhibitor in
// tests. ScreenWakeController holds a raw back-pointer; lifetime is
// tied to the AppModel.
std::unique_ptr<util::DisplaySleepInhibitor> inhibitor_;
util::ScreenWakeController wake_;

MainUiState state_;

Expand Down
28 changes: 26 additions & 2 deletions src/Input/GamepadInputProcessor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,28 @@ void GamepadInputProcessor::setReportSender(ReportSender sender) {
sender_ = std::move(sender);
}

void GamepadInputProcessor::setDeadzones(const DeviceId& id, const Deadzones& dz) {
std::lock_guard<std::mutex> lock(mtx_);
deadzones_[id] = dz;
}

void GamepadInputProcessor::publish(const DeviceId& id, const DeviceState& state) {
ReportSender snapshot;
DeviceState filtered;
{
std::lock_guard<std::mutex> lock(mtx_);
states_[id] = state;
Deadzones dz{};
if (auto it = deadzones_.find(id); it != deadzones_.end()) { dz = it->second; }
filtered = applyDeadzones(state, dz);
states_[id] = filtered;
++telEvents_;
++telSends_;
++telTotalSent_;
snapshot = sender_;
}
if (snapshot) {
snapshot(id, state.wButtons, state.lt, state.rt, state.lx, state.ly, state.rx, state.ry);
snapshot(id, filtered.wButtons, filtered.lt, filtered.rt, filtered.lx, filtered.ly,
filtered.rx, filtered.ry);
}
}

Expand All @@ -47,6 +57,7 @@ void GamepadInputProcessor::zeroAndSendAll() {
void GamepadInputProcessor::remove(const DeviceId& id) {
std::lock_guard<std::mutex> lock(mtx_);
states_.erase(id);
deadzones_.erase(id);
}

GamepadInputProcessor::TelemetrySnapshot GamepadInputProcessor::drainTelemetry() {
Expand All @@ -70,4 +81,17 @@ std::uint8_t scaleTrigger(float v) {
return static_cast<std::uint8_t>(std::clamp(scaled, 0, 255));
}

GamepadInputProcessor::DeviceState applyDeadzones(const GamepadInputProcessor::DeviceState& state,
const GamepadInputProcessor::Deadzones& dz) {
auto out = state;
const auto stickFlat = static_cast<std::int32_t>(dz.stickFlat);
if (std::abs(static_cast<std::int32_t>(out.lx)) <= stickFlat) { out.lx = 0; }
if (std::abs(static_cast<std::int32_t>(out.ly)) <= stickFlat) { out.ly = 0; }
if (std::abs(static_cast<std::int32_t>(out.rx)) <= stickFlat) { out.rx = 0; }
if (std::abs(static_cast<std::int32_t>(out.ry)) <= stickFlat) { out.ry = 0; }
if (out.lt <= dz.triggerFlat) { out.lt = 0; }
if (out.rt <= dz.triggerFlat) { out.rt = 0; }
return out;
}

} // namespace dish::input
22 changes: 22 additions & 0 deletions src/Input/GamepadInputProcessor.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,28 @@ class GamepadInputProcessor {
}
};

// Per-axis deadzone thresholds. Values whose absolute magnitude is at or
// below the flat are zeroed before the report leaves the processor —
// mirrors the per-device `flat` values Android pulls out of
// `InputDevice.getMotionRange(axis).getFlat()`. SDL2 doesn't surface an
// OS-level equivalent, so SDLGamepadBridge installs a sensible default
// when each device attaches.
struct Deadzones {
std::int16_t stickFlat = 0;
std::uint8_t triggerFlat = 0;
bool operator==(const Deadzones& o) const {
return stickFlat == o.stickFlat && triggerFlat == o.triggerFlat;
}
};

struct TelemetrySnapshot {
int events = 0;
int sends = 0;
std::uint64_t totalSent = 0;
};

void setReportSender(ReportSender sender);
void setDeadzones(const DeviceId& id, const Deadzones& dz);
void publish(const DeviceId& id, const DeviceState& state);
void zeroAndSendAll();
void remove(const DeviceId& id);
Expand All @@ -73,6 +88,7 @@ class GamepadInputProcessor {
private:
std::mutex mtx_;
std::unordered_map<DeviceId, DeviceState> states_;
std::unordered_map<DeviceId, Deadzones> deadzones_;
ReportSender sender_;
int telEvents_ = 0;
int telSends_ = 0;
Expand All @@ -83,4 +99,10 @@ class GamepadInputProcessor {
std::int16_t scaleAxis(float v, float maxMagnitude);
std::uint8_t scaleTrigger(float v);

// Pure deadzone application. Sticks: `|v| <= flat → 0`. Triggers: `v <= flat
// → 0`. Buttons are passed through. Extracted as a free function so tests can
// pin the arithmetic without the processor's lock plumbing.
GamepadInputProcessor::DeviceState applyDeadzones(const GamepadInputProcessor::DeviceState& state,
const GamepadInputProcessor::Deadzones& dz);

} // namespace dish::input
37 changes: 35 additions & 2 deletions src/Input/SDLGamepadBridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

#include <SDL2/SDL.h>

#include <QLoggingCategory>
#include <QMetaObject>

#include <cstdint>
Expand All @@ -13,6 +14,15 @@ namespace dish::input {

namespace {

Q_LOGGING_CATEGORY(lcDishInput, "dish.input")

// Conservative noise-floor defaults applied to every newly-attached controller.
// ~10 % of the int16 stick range and ~5 % of the 0..255 trigger range. Mirrors
// the per-device flat values Android pulls out of
// `InputDevice.getMotionRange(axis).getFlat()`. SDL2 has no equivalent.
constexpr std::int16_t kDefaultStickFlat = 3277;
constexpr std::uint8_t kDefaultTriggerFlat = 13;

// SDL_GameController axes are int16 [-32768, 32767]; pass through directly.
std::int16_t axisValue(SDL_GameController* gc, SDL_GameControllerAxis axis) {
return SDL_GameControllerGetAxis(gc, axis);
Expand Down Expand Up @@ -71,12 +81,35 @@ void SDLGamepadBridge::runLoop() {
SDL_Joystick* js = SDL_GameControllerGetJoystick(gc);
const int iid = SDL_JoystickInstanceID(js);
const auto* name = SDL_GameControllerName(gc);
const QString deviceId = QStringLiteral("sdl:%1").arg(iid);
const QString deviceName = QString::fromUtf8(name != nullptr ? name : "Gamepad");
{
std::lock_guard<std::mutex> lock(mtx_);
openControllers_[iid] = gc;
deviceIds_[iid] = QStringLiteral("sdl:%1").arg(iid);
deviceNames_[iid] = QString::fromUtf8(name != nullptr ? name : "Gamepad");
deviceIds_[iid] = deviceId;
deviceNames_[iid] = deviceName;
}
// One-shot device-capability dump — mirrors the SatelliteJNI
// DEVCAPS log on Android (PR #44/#47). SDL reports the controller
// type it negotiated (Xbox 360 / DualSense / generic), the vendor
// / product id, and the GUID; together that pins what mapping was
// applied so users reporting "my pad doesn't work" get a usable
// diagnostic without a debugger.
const auto type = SDL_GameControllerGetType(gc);
const auto vid = SDL_GameControllerGetVendor(gc);
const auto pid = SDL_GameControllerGetProduct(gc);
char guidBuf[64] = {0};
SDL_JoystickGetGUIDString(SDL_JoystickGetGUID(js), guidBuf, sizeof(guidBuf));
qCInfo(lcDishInput) << "DEVCAPS id=" << deviceId << "name=" << deviceName
<< "type=" << static_cast<int>(type)
<< "vid=" << QString::number(vid, 16)
<< "pid=" << QString::number(pid, 16) << "guid=" << guidBuf;
// Push the default deadzone profile so the processor filters
// out controller noise from the first event. The default lives
// inside the bridge (not the processor) because the bridge is
// the only thing that knows when a device shows up.
processor_->setDeadzones(deviceId.toStdString(),
{kDefaultStickFlat, kDefaultTriggerFlat});
QMetaObject::invokeMethod(this, "devicesChanged", Qt::QueuedConnection);
rebuildState(iid);
break;
Expand Down
4 changes: 4 additions & 0 deletions src/Models/Models.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ PairResponse PairResponse::fromJson(const QJsonObject& obj) {
r.ok = obj.value("ok").toBool(false);
if (auto e = optString(obj, "error"); !e.isEmpty()) { r.error = e; }
if (auto k = optString(obj, "sharedKey"); !k.isEmpty()) { r.sharedKey = k; }
// We got far enough to parse a JSON body, so the server is reachable —
// even if ok=false. PairingClient sets reachable=false explicitly on
// every network-level error path.
r.reachable = true;
return r;
}

Expand Down
6 changes: 6 additions & 0 deletions src/Models/Models.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ struct PairResponse {
bool ok = false;
std::optional<QString> error;
std::optional<QString> sharedKey;
// True iff we received any JSON body from the server. False for synthesized
// failure responses (socket / connect / send errors). Not on the wire —
// the server never sends this field; it's set client-side by
// `PairingClient::pair` so the manager can distinguish "moved networks"
// from "needs PIN". Mirrors dish-mac PairResponse.reachable.
bool reachable = false;

static PairResponse fromJson(const QJsonObject& obj);
};
Expand Down
Loading
Loading