Skip to content

Commit e4c4262

Browse files
committed
linux: add CLI support for querying status and controlling AirPods
Add command-line interface to the Linux app: - --help, --version: standard CLI options - --status, -s: show device status, battery levels - --json, -j: JSON output for scripting - --waybar, -w: Waybar custom module format - --set-noise-mode: control noise cancellation mode - --set-conversational-awareness: toggle conversational awareness - --set-adaptive-level: set adaptive noise level CLI commands communicate with running instance via IPC socket. Refactored CLI code into separate cli.cpp/cli.h for cleaner main.cpp.
1 parent f3b1db2 commit e4c4262

4 files changed

Lines changed: 463 additions & 29 deletions

File tree

linux/CMakeLists.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ project(linux VERSION 0.1 LANGUAGES CXX)
44

55
set(CMAKE_CXX_STANDARD_REQUIRED ON)
66

7+
# Pass version to C++ code
8+
add_compile_definitions(LIBREPODS_VERSION="${PROJECT_VERSION}")
9+
710
find_package(Qt6 REQUIRED COMPONENTS Quick Widgets Bluetooth DBus)
811
find_package(OpenSSL REQUIRED)
912
find_package(PkgConfig REQUIRED)
@@ -13,6 +16,8 @@ qt_standard_project_setup()
1316

1417
qt_add_executable(librepods
1518
main.cpp
19+
cli.cpp
20+
cli.h
1621
logger.h
1722
media/mediacontroller.cpp
1823
media/mediacontroller.h

linux/cli.cpp

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
#include "cli.h"
2+
3+
#include <QLocalSocket>
4+
#include <QCommandLineParser>
5+
#include <QCommandLineOption>
6+
#include <QTextStream>
7+
8+
#ifndef LIBREPODS_VERSION
9+
#define LIBREPODS_VERSION "0.1"
10+
#endif
11+
12+
namespace CLI {
13+
14+
QString noiseControlModeName(NoiseControlMode mode) {
15+
switch (mode) {
16+
case NoiseControlMode::Off: return "off";
17+
case NoiseControlMode::NoiseCancellation: return "noise-cancellation";
18+
case NoiseControlMode::Transparency: return "transparency";
19+
case NoiseControlMode::Adaptive: return "adaptive";
20+
default: return "unknown";
21+
}
22+
}
23+
24+
std::optional<NoiseControlMode> parseNoiseControlMode(const QString &name) {
25+
QString lower = name.toLower();
26+
if (lower == "off" || lower == "0") return NoiseControlMode::Off;
27+
if (lower == "noise-cancellation" || lower == "nc" || lower == "anc" || lower == "1") return NoiseControlMode::NoiseCancellation;
28+
if (lower == "transparency" || lower == "tr" || lower == "2") return NoiseControlMode::Transparency;
29+
if (lower == "adaptive" || lower == "3") return NoiseControlMode::Adaptive;
30+
return std::nullopt;
31+
}
32+
33+
bool isInstanceRunning() {
34+
QLocalSocket socket;
35+
socket.connectToServer("app_server");
36+
bool running = socket.waitForConnected(300);
37+
socket.disconnectFromServer();
38+
return running;
39+
}
40+
41+
QString sendIpcCommand(const QString &command, int timeout) {
42+
QLocalSocket socket;
43+
socket.connectToServer("app_server");
44+
45+
if (!socket.waitForConnected(500)) {
46+
return QString();
47+
}
48+
49+
socket.write(command.toUtf8());
50+
socket.flush();
51+
socket.waitForBytesWritten(500);
52+
53+
if (socket.waitForReadyRead(timeout)) {
54+
QString response = QString::fromUtf8(socket.readAll());
55+
socket.disconnectFromServer();
56+
return response;
57+
}
58+
59+
socket.disconnectFromServer();
60+
return QString();
61+
}
62+
63+
int handleCLICommands(QApplication &app) {
64+
app.setApplicationName("LibrePods");
65+
app.setApplicationVersion(LIBREPODS_VERSION);
66+
67+
QCommandLineParser parser;
68+
parser.setApplicationDescription("LibrePods - Control your AirPods on Linux");
69+
70+
// Standard options
71+
parser.addHelpOption();
72+
parser.addVersionOption();
73+
74+
// Application options
75+
QCommandLineOption debugOption(QStringList() << "debug",
76+
"Enable debug logging output");
77+
parser.addOption(debugOption);
78+
79+
QCommandLineOption hideOption(QStringList() << "hide",
80+
"Start with window hidden (tray only)");
81+
parser.addOption(hideOption);
82+
83+
// CLI query options
84+
QCommandLineOption statusOption(QStringList() << "s" << "status",
85+
"Show AirPods connection status and battery levels");
86+
parser.addOption(statusOption);
87+
88+
QCommandLineOption jsonOption(QStringList() << "j" << "json",
89+
"Output in JSON format (use with --status)");
90+
parser.addOption(jsonOption);
91+
92+
QCommandLineOption waybarOption(QStringList() << "w" << "waybar",
93+
"Output in Waybar custom module format");
94+
parser.addOption(waybarOption);
95+
96+
// CLI control options
97+
QCommandLineOption setNoiseModeOption(QStringList() << "set-noise-mode",
98+
"Set noise control mode (off, transparency, noise-cancellation/nc/anc, adaptive)",
99+
"mode");
100+
parser.addOption(setNoiseModeOption);
101+
102+
QCommandLineOption setCAOption(QStringList() << "set-conversational-awareness",
103+
"Set conversational awareness (on/off, true/false, 1/0)",
104+
"state");
105+
parser.addOption(setCAOption);
106+
107+
QCommandLineOption setAdaptiveLevelOption(QStringList() << "set-adaptive-level",
108+
"Set adaptive noise level (0-100)",
109+
"level");
110+
parser.addOption(setAdaptiveLevelOption);
111+
112+
parser.process(app);
113+
114+
bool wantsStatus = parser.isSet(statusOption);
115+
bool wantsJson = parser.isSet(jsonOption);
116+
bool wantsWaybar = parser.isSet(waybarOption);
117+
QString noiseMode = parser.value(setNoiseModeOption);
118+
QString caState = parser.value(setCAOption);
119+
QString adaptiveLevel = parser.value(setAdaptiveLevelOption);
120+
121+
// Check if this is a CLI command
122+
bool hasStatusQuery = wantsStatus || wantsWaybar;
123+
bool hasControlCommand = !noiseMode.isEmpty() || !caState.isEmpty() || !adaptiveLevel.isEmpty();
124+
bool isCLICommand = hasStatusQuery || hasControlCommand;
125+
126+
if (!isCLICommand) {
127+
// Not a CLI command, return -1 to indicate GUI should start
128+
return -1;
129+
}
130+
131+
// Handle CLI commands
132+
QTextStream out(stdout);
133+
QTextStream err(stderr);
134+
135+
if (!isInstanceRunning()) {
136+
err << "Error: LibrePods is not running. Start the application first.\n";
137+
return 1;
138+
}
139+
140+
// Handle waybar output
141+
if (wantsWaybar) {
142+
QString response = sendIpcCommand("cli:status:waybar");
143+
144+
if (response.isEmpty()) {
145+
// Output disconnected state for waybar
146+
out << R"({"text": "󰥰 --", "tooltip": "LibrePods not running", "class": "disconnected"})" << "\n";
147+
return 0;
148+
}
149+
150+
out << response;
151+
if (!response.endsWith('\n')) out << "\n";
152+
return 0;
153+
}
154+
155+
// Handle status query
156+
if (wantsStatus) {
157+
QString cmd = wantsJson ? "cli:status:json" : "cli:status:text";
158+
QString response = sendIpcCommand(cmd);
159+
160+
if (response.isEmpty()) {
161+
err << "Error: No response from LibrePods\n";
162+
return 1;
163+
}
164+
165+
out << response;
166+
if (!response.endsWith('\n')) out << "\n";
167+
return 0;
168+
}
169+
170+
// Handle set noise mode
171+
if (!noiseMode.isEmpty()) {
172+
auto mode = parseNoiseControlMode(noiseMode);
173+
if (!mode.has_value()) {
174+
err << "Error: Invalid noise mode '" << noiseMode << "'\n";
175+
err << "Valid modes: off, transparency, noise-cancellation (nc/anc), adaptive\n";
176+
return 1;
177+
}
178+
179+
QString response = sendIpcCommand("cli:set-noise-mode:" + QString::number(static_cast<int>(mode.value())));
180+
if (response.startsWith("OK")) {
181+
out << "Noise control mode set to: " << noiseControlModeName(mode.value()) << "\n";
182+
return 0;
183+
} else {
184+
err << "Error: " << (response.isEmpty() ? "No response from LibrePods" : response) << "\n";
185+
return 1;
186+
}
187+
}
188+
189+
// Handle set conversational awareness
190+
if (!caState.isEmpty()) {
191+
QString lower = caState.toLower();
192+
bool enabled;
193+
if (lower == "on" || lower == "true" || lower == "1" || lower == "yes") {
194+
enabled = true;
195+
} else if (lower == "off" || lower == "false" || lower == "0" || lower == "no") {
196+
enabled = false;
197+
} else {
198+
err << "Error: Invalid state '" << caState << "'\n";
199+
err << "Valid values: on/off, true/false, 1/0, yes/no\n";
200+
return 1;
201+
}
202+
203+
QString response = sendIpcCommand("cli:set-ca:" + QString(enabled ? "1" : "0"));
204+
if (response.startsWith("OK")) {
205+
out << "Conversational awareness set to: " << (enabled ? "on" : "off") << "\n";
206+
return 0;
207+
} else {
208+
err << "Error: " << (response.isEmpty() ? "No response from LibrePods" : response) << "\n";
209+
return 1;
210+
}
211+
}
212+
213+
// Handle set adaptive level
214+
if (!adaptiveLevel.isEmpty()) {
215+
bool ok;
216+
int level = adaptiveLevel.toInt(&ok);
217+
if (!ok || level < 0 || level > 100) {
218+
err << "Error: Invalid adaptive level '" << adaptiveLevel << "'\n";
219+
err << "Valid range: 0-100\n";
220+
return 1;
221+
}
222+
223+
QString response = sendIpcCommand("cli:set-adaptive-level:" + QString::number(level));
224+
if (response.startsWith("OK")) {
225+
out << "Adaptive noise level set to: " << level << "\n";
226+
return 0;
227+
} else {
228+
err << "Error: " << (response.isEmpty() ? "No response from LibrePods" : response) << "\n";
229+
return 1;
230+
}
231+
}
232+
233+
return 0;
234+
}
235+
236+
} // namespace CLI

linux/cli.h

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#pragma once
2+
3+
#include <QString>
4+
#include <QApplication>
5+
#include <optional>
6+
#include "enums.h"
7+
8+
using namespace AirpodsTrayApp::Enums;
9+
10+
namespace CLI {
11+
12+
// Noise control mode helpers
13+
QString noiseControlModeName(NoiseControlMode mode);
14+
std::optional<NoiseControlMode> parseNoiseControlMode(const QString &name);
15+
16+
// Check if another instance is running
17+
bool isInstanceRunning();
18+
19+
// Send IPC command to running instance and get response
20+
QString sendIpcCommand(const QString &command, int timeout = 2000);
21+
22+
// Parse CLI arguments and handle CLI commands
23+
// Returns: -1 if should continue to GUI, otherwise the exit code
24+
int handleCLICommands(QApplication &app);
25+
26+
} // namespace CLI

0 commit comments

Comments
 (0)