App commands and --ha-remove (iteration 2, step 6)

The platform accepts app commands (D43): flags that get a
QCoreApplication like the manifest commands and run prepare() and then
the new hook runCommand(). The app's --ha-remove clears the device's
retained topics, so HA deletes the device; uninstall.sh runs it after
stopping the app and before unregistering from SteamVR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 16:32:51 +02:00
co-authored by Claude Opus 5.5
parent 148ae663cf
commit 0bdcaddcd5
12 changed files with 219 additions and 47 deletions
+81 -23
View File
@@ -3,6 +3,8 @@
#include <QCommandLineOption>
#include <QCoreApplication>
#include <QDebug>
#include <QEventLoop>
#include <QTimer>
#include "ha/HaDevice.h"
#include "ha/HaSensor.h"
@@ -11,10 +13,13 @@
namespace {
const QString kConfigOption = QStringLiteral("config");
const QString kHaRemoveCommand = QStringLiteral("ha-remove");
// How often the dummy sensor's value is updated.
constexpr int kUptimeIntervalMs = 10000;
// How long a normal exit waits for the broker to acknowledge "offline".
constexpr int kDisconnectTimeoutMs = 2000;
// --ha-remove: how long it may take to connect, and then to publish.
constexpr int kRemoveTimeoutMs = 10000;
} // namespace
@@ -24,7 +29,11 @@ Dashboard::Dashboard(int& argc, char** argv)
{QCommandLineOption(kConfigOption,
QStringLiteral("Settings file (default: %1).")
.arg(Config::defaultPath()),
QStringLiteral("path"))}) {
QStringLiteral("path"))},
{QCommandLineOption(
kHaRemoveCommand,
QStringLiteral("Remove this device from Home Assistant (clears "
"its retained MQTT topics) and exit."))}) {
addSurface({.qmlRootType = QStringLiteral("Main")});
}
@@ -33,7 +42,7 @@ Dashboard::~Dashboard() = default;
bool Dashboard::prepare(QString* error) {
// Snapshot and benchmark only render the UI: no settings needed.
if (mode() != DashboardMode::Overlay && mode() != DashboardMode::Desktop &&
mode() != DashboardMode::DesktopNoVr)
mode() != DashboardMode::DesktopNoVr && mode() != DashboardMode::Command)
return true;
const QString path = isOptionSet(kConfigOption) ? optionValue(kConfigOption)
: Config::defaultPath();
@@ -46,30 +55,38 @@ bool Dashboard::prepare(QString* error) {
return true;
}
MosquittoClient::Settings Dashboard::mqttSettings() const {
MosquittoClient::Settings settings;
settings.broker = m_config->mqtt;
settings.clientId = QStringLiteral("frameassistant_") + m_config->deviceId;
settings.will = HaDevice::lastWill(m_config->deviceId);
return settings;
}
HaDevice::Info Dashboard::deviceInfo() const {
return {.name = m_config->deviceName,
.id = m_config->deviceId,
.discoveryPrefix = m_config->discoveryPrefix,
.softwareVersion = QCoreApplication::applicationVersion()};
}
HaSensor* Dashboard::addEntities(HaDevice& device) {
return device.addSensor({.id = QStringLiteral("uptime"),
.name = QStringLiteral("Uptime"),
.deviceClass = QStringLiteral("duration"),
.unit = QStringLiteral("s"),
.category = QStringLiteral("diagnostic")});
}
void Dashboard::loading() {
if (!m_config) return;
const Config& config = *m_config;
MosquittoClient::Settings settings;
settings.broker = config.mqtt;
settings.clientId = QStringLiteral("frameassistant_") + config.deviceId;
settings.will = HaDevice::lastWill(config.deviceId);
m_mqtt = std::make_unique<MosquittoClient>(settings);
m_mqtt = std::make_unique<MosquittoClient>(mqttSettings());
m_device = std::make_unique<HaDevice>(deviceInfo(), m_mqtt.get());
m_uptime = addEntities(*m_device);
m_device = std::make_unique<HaDevice>(
HaDevice::Info{.name = config.deviceName,
.id = config.deviceId,
.discoveryPrefix = config.discoveryPrefix,
.softwareVersion = QCoreApplication::applicationVersion()},
m_mqtt.get());
m_uptime = m_device->addSensor({.id = QStringLiteral("uptime"),
.name = QStringLiteral("Uptime"),
.deviceClass = QStringLiteral("duration"),
.unit = QStringLiteral("s"),
.category = QStringLiteral("diagnostic")});
m_status.setDevice(
config.deviceName,
QStringLiteral("%1:%2").arg(config.mqtt.host).arg(config.mqtt.port));
const MqttConfig& broker = m_config->mqtt;
m_status.setDevice(m_config->deviceName,
QStringLiteral("%1:%2").arg(broker.host).arg(broker.port));
connect(m_mqtt.get(), &MqttClient::connectionChanged, &m_status,
&ConnectionStatus::setConnection);
}
@@ -91,3 +108,44 @@ void Dashboard::started() {
});
m_mqtt->connectToBroker();
}
int Dashboard::runCommand(const QString& name) {
if (name == kHaRemoveCommand) return removeFromHa();
return DashboardApplication::runCommand(name);
}
int Dashboard::removeFromHa() {
MosquittoClient::Settings settings = mqttSettings();
// Its own client id, so it doesn't knock a running instance off the
// broker; no will, since it publishes nothing to keep.
settings.clientId += QStringLiteral("_remove");
settings.will = {};
MosquittoClient client(settings);
QEventLoop loop;
QTimer::singleShot(kRemoveTimeoutMs, &loop, &QEventLoop::quit);
connect(&client, &MqttClient::connectionChanged, &loop,
[&loop](MqttClient::State state) {
if (state == MqttClient::State::Connected) loop.quit();
});
client.connectToBroker();
loop.exec();
if (client.state() != MqttClient::State::Connected) {
qCritical().noquote() << "Cannot remove the device from Home Assistant: "
"no connection to the broker";
return 1;
}
// Created only now: a device that saw the connection come up would
// publish itself first.
HaDevice device(deviceInfo(), &client);
addEntities(device);
const bool published = device.removeFromHa();
const bool acknowledged = client.disconnectFromBroker(kRemoveTimeoutMs);
if (!published || !acknowledged) {
qCritical().noquote() << "Removing the device from Home Assistant failed";
return 1;
}
qInfo().noquote() << "Removed device" << m_config->deviceName
<< "(" + m_config->deviceId + ") from Home Assistant";
return 0;
}
+12 -2
View File
@@ -7,10 +7,10 @@
#include "ConnectionStatus.h"
#include "app/DashboardApplication.h"
#include "config/Config.h"
#include "ha/HaDevice.h"
#include "mqtt/MosquittoClient.h"
class HaDevice;
class HaSensor;
class MosquittoClient;
// The dashboard app. Its UI is the QML module set up in src/CMakeLists.txt
// (QML_URI FrameAssistant, files in src/qml/).
@@ -30,8 +30,18 @@ class Dashboard : public DashboardApplication {
void loading() override;
// Connects to the broker and starts the sensors.
void started() override;
// --ha-remove (docs/DESIGN.md, "--ha-remove").
int runCommand(const QString& name) override;
private:
// The settings for the MQTT client and the HA device.
MosquittoClient::Settings mqttSettings() const;
HaDevice::Info deviceInfo() const;
// Adds the entities to `device` (the same for the app and --ha-remove);
// returns the dummy sensor.
static HaSensor* addEntities(HaDevice& device);
int removeFromHa();
std::optional<Config> m_config; // none in snapshot/benchmark mode
// The device uses the client: declared after it, so destroyed before it.
std::unique_ptr<MosquittoClient> m_mqtt;
+33 -6
View File
@@ -115,6 +115,7 @@ std::unique_ptr<RenderBackend> startBackend(GraphicsApi api, bool apiExplicit,
struct DashboardApplication::Options {
QString error; // set: exec() reports it and fails
bool manifestCommand = false;
QString appCommand; // name of the app command given, if any
bool install = false;
bool launch = false;
QString manifest;
@@ -144,6 +145,8 @@ const char* dashboardModeName(DashboardMode mode) {
return "snapshot";
case DashboardMode::Benchmark:
return "benchmark";
case DashboardMode::Command:
return "command";
}
return "?";
}
@@ -170,7 +173,8 @@ void DashboardApplication::setDefaultQmlModule(const char* uri) {
}
DashboardApplication::DashboardApplication(
int& argc, char** argv, const QList<QCommandLineOption>& appOptions)
int& argc, char** argv, const QList<QCommandLineOption>& appOptions,
const QList<QCommandLineOption>& appCommands)
: m_options(std::make_unique<Options>()), m_qmlModule(defaultQmlModule()) {
// When launched by SteamVR, stderr is not a TTY and Qt would log to
// journald only; keep messages on stderr so they show up wherever the
@@ -178,14 +182,24 @@ DashboardApplication::DashboardApplication(
if (!qEnvironmentVariableIsSet("QT_FORCE_STDERR_LOGGING"))
qputenv("QT_FORCE_STDERR_LOGGING", "1");
// Manifest commands only talk to SteamVR: no GUI (and no display
// connection) needed, so they also work over SSH.
// Manifest commands only talk to SteamVR, app commands do their own thing:
// no GUI (and no display connection) needed, so they also work over SSH.
// Decided before parsing: the parser needs the application object.
Options& o = *m_options;
o.manifestCommand = std::any_of(argv + 1, argv + argc, [](const char* arg) {
return qstrcmp(arg, "--install-manifest") == 0 ||
qstrcmp(arg, "--uninstall-manifest") == 0;
});
if (o.manifestCommand)
const bool appCommand =
std::any_of(argv + 1, argv + argc, [&appCommands](const char* arg) {
for (const QCommandLineOption& command : appCommands) {
for (const QString& name : command.names()) {
if (QLatin1String(arg) == QStringLiteral("--") + name) return true;
}
}
return false;
});
if (o.manifestCommand || appCommand)
m_app = std::make_unique<QCoreApplication>(argc, argv);
else
m_app = std::make_unique<QGuiApplication>(argc, argv);
@@ -257,7 +271,7 @@ DashboardApplication::DashboardApplication(
benchmarkOption, apiOption, keyOption, nameOption,
fpsOption, installOption, uninstallOption, manifestOption,
launchOption, logOption});
for (const QCommandLineOption& option : appOptions) {
for (const QCommandLineOption& option : appOptions + appCommands) {
if (!parser.addOption(option))
o.error = QStringLiteral("App option --%1 clashes with another option")
.arg(option.names().constFirst());
@@ -283,6 +297,12 @@ DashboardApplication::DashboardApplication(
? resolvePath(parser.value(manifestOption))
: shareDir() + QStringLiteral("/manifest.vrmanifest");
if (o.manifestCommand) return;
for (const QCommandLineOption& command : appCommands) {
if (parser.isSet(command)) {
o.appCommand = command.names().constFirst();
return;
}
}
if (parser.isSet(apiOption)) {
if (!parseGraphicsApi(parser.value(apiOption), &o.graphicsApi))
@@ -338,6 +358,10 @@ void DashboardApplication::setQuickStyle(const QString& style) {
bool DashboardApplication::prepare(QString*) { return true; }
int DashboardApplication::runCommand(const QString& name) {
return fail(QStringLiteral("Command --%1 is not implemented").arg(name));
}
void DashboardApplication::loading() {}
void DashboardApplication::started() {}
@@ -400,7 +424,7 @@ int DashboardApplication::exec() {
QString error;
if (!validateConfig(&error)) return fail(error);
m_mode = o.mode;
m_mode = o.appCommand.isEmpty() ? o.mode : DashboardMode::Command;
m_appKey = !o.appKey.isEmpty() ? o.appKey
: !m_configAppKey.isEmpty()
? m_configAppKey
@@ -411,6 +435,7 @@ int DashboardApplication::exec() {
: QString::fromUtf8(AppConfig::kDisplayName);
QCoreApplication::setApplicationName(m_appName);
if (!prepare(&error)) return fail(error);
if (m_mode == DashboardMode::Command) return runCommand(o.appCommand);
if (!m_quickStyle.isEmpty()) QQuickStyle::setStyle(m_quickStyle);
m_engine = std::make_unique<QQmlEngine>();
@@ -432,6 +457,8 @@ int DashboardApplication::exec() {
case DashboardMode::Overlay:
result = runOverlay();
break;
case DashboardMode::Command:
break; // handled above
}
// Still within exec(): the derived object (and what it owns) outlives all
// QML. Surfaces (their QML scenes, renderers, backends) before the engine.
+18 -2
View File
@@ -20,7 +20,15 @@ class VrRuntime;
// Desktop and DesktopNoVr both show desktop windows; DesktopNoVr promises
// never to connect to SteamVR (for apps that use SteamVR data otherwise).
enum class DashboardMode { Overlay, Desktop, DesktopNoVr, Snapshot, Benchmark };
// Command: one of the app's commands runs (runCommand()); no GUI.
enum class DashboardMode {
Overlay,
Desktop,
DesktopNoVr,
Snapshot,
Benchmark,
Command
};
const char* dashboardModeName(DashboardMode mode);
// Base class of a dashboard app. It owns everything app- and surface-related:
@@ -59,8 +67,12 @@ class DashboardApplication : public QObject {
// `appOptions` are the app's own command-line options: parsed and listed
// in --help together with the built-in ones (names must not clash); read
// them with isOptionSet() / optionValue().
// `appCommands` are the app's commands (flags, e.g. --ha-remove): like the
// manifest commands they need no display (QCoreApplication), and exec()
// runs prepare() and then runCommand() instead of a mode.
DashboardApplication(int& argc, char** argv,
const QList<QCommandLineOption>& appOptions = {});
const QList<QCommandLineOption>& appOptions = {},
const QList<QCommandLineOption>& appCommands = {});
~DashboardApplication() override;
// Runs the mode selected on the command line; returns the exit code.
@@ -98,6 +110,10 @@ class DashboardApplication : public QObject {
virtual void loading();
// Everything runs; called right before the event loop starts.
virtual void started();
// Command mode: runs the app command `name` (an appCommands option, see
// the constructor) after prepare(); no QML engine, surfaces or SteamVR.
// Returns the exit code. The default fails.
virtual int runCommand(const QString& name);
// --- The running app (valid in the hooks and until exec() returns) ---
DashboardMode mode() const { return m_mode; }