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:
+81
-23
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user