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:
@@ -112,19 +112,21 @@ into Qt events.
|
|||||||
- `src/app/DashboardApplication` is the **base class of every app** (in `vrdashboard_core`):
|
- `src/app/DashboardApplication` is the **base class of every app** (in `vrdashboard_core`):
|
||||||
CLI, logging, SteamVR connection, backend selection, surfaces, controller navigation, and the
|
CLI, logging, SteamVR connection, backend selection, surfaces, controller navigation, and the
|
||||||
modes (overlay, `--desktop`, `--desktop-novr`, `--snapshot`, `--benchmark`, manifest
|
modes (overlay, `--desktop`, `--desktop-novr`, `--snapshot`, `--benchmark`, manifest
|
||||||
install).
|
install, app commands).
|
||||||
- **Inheritance for the app, components for the surfaces** (the user's decision, after
|
- **Inheritance for the app, components for the surfaces** (the user's decision, after
|
||||||
trying composition): the app derives from `DashboardApplication`. In its constructor it
|
trying composition): the app derives from `DashboardApplication`. In its constructor it
|
||||||
configures (`addSurface`, optional `setAppKey`/`setDisplayName`) and passes its own
|
configures (`addSurface`, optional `setAppKey`/`setDisplayName`) and passes its own
|
||||||
command-line options (`QCommandLineOption` list) to the base constructor. It overrides
|
command-line options and its commands (two `QCommandLineOption` lists, D38, D43) to the
|
||||||
the hooks `prepare(QString*)` (first; mode and app key known, no engine/surfaces/SteamVR
|
base constructor. It overrides the hooks `prepare(QString*)` (first; mode and app key
|
||||||
yet; returning false ends `exec()` with exit code 1), `loading()` (backends + shared
|
known, no engine/surfaces/SteamVR yet; returning false ends `exec()` with exit code 1),
|
||||||
engine exist, QML not loaded) and `started()` (everything runs). In the hooks, protected
|
`loading()` (backends + shared engine exist, QML not loaded), `started()` (everything
|
||||||
|
runs) and `runCommand(name)` (mode `Command`, after `prepare()`, nothing else exists). In the hooks, protected
|
||||||
accessors give the running state: `mode()`, `appKey()`, `appName()`, `isOptionSet()`,
|
accessors give the running state: `mode()`, `appKey()`, `appName()`, `isOptionSet()`,
|
||||||
`optionValue()`, `engine()`, `surfaces()`, `surface()` (primary), `surface(id)`,
|
`optionValue()`, `engine()`, `surfaces()`, `surface()` (primary), `surface(id)`,
|
||||||
`vrRuntime()`, `navigation()`. The app is `src/Dashboard.h/.cpp`.
|
`vrRuntime()`, `navigation()`. The app is `src/Dashboard.h/.cpp`.
|
||||||
- The base constructor creates the `QGuiApplication` (`QCoreApplication` for manifest
|
- The base constructor creates the `QGuiApplication` (`QCoreApplication` for manifest and
|
||||||
commands), so derived members can be QObjects with timers.
|
app commands: decided from the raw `argv` before parsing), so derived members can be
|
||||||
|
QObjects with timers.
|
||||||
- All QML and surfaces are torn down **at the end of `exec()`**, while the derived object
|
- All QML and surfaces are torn down **at the end of `exec()`**, while the derived object
|
||||||
and its members (e.g. QML singletons) still exist.
|
and its members (e.g. QML singletons) still exist.
|
||||||
- `src/app/Surface` is **one view**: its QML root (from the app's module), renderer, render
|
- `src/app/Surface` is **one view**: its QML root (from the app's module), renderer, render
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ How the pieces fit together: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|||||||
| `--max-fps <fps>` | Frame-rate cap (default 60); rendering only happens on change |
|
| `--max-fps <fps>` | Frame-rate cap (default 60); rendering only happens on change |
|
||||||
| `--install-manifest [--launch]` | Register with SteamVR (auto-start) and optionally start it now; works over SSH |
|
| `--install-manifest [--launch]` | Register with SteamVR (auto-start) and optionally start it now; works over SSH |
|
||||||
| `--uninstall-manifest` | Unregister |
|
| `--uninstall-manifest` | Unregister |
|
||||||
|
| `--ha-remove` | Remove this device from Home Assistant (clears its retained MQTT topics; needs the settings and the broker) and exit; `uninstall.sh` calls it. Works over SSH |
|
||||||
| `--manifest <path>` | Manifest for the two options above (default: the app's own) |
|
| `--manifest <path>` | Manifest for the two options above (default: the app's own) |
|
||||||
| `--log-file <path>` | Also log to a file (relative paths: relative to the install folder) |
|
| `--log-file <path>` | Also log to a file (relative paths: relative to the install folder) |
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ Your app derives from it:
|
|||||||
`setDisplayName()`, `setQuickStyle()`, `setQmlModule()`). The app's own command-line
|
`setDisplayName()`, `setQuickStyle()`, `setQmlModule()`). The app's own command-line
|
||||||
options go to the base constructor as a list of `QCommandLineOption`s; they are parsed and
|
options go to the base constructor as a list of `QCommandLineOption`s; they are parsed and
|
||||||
listed in `--help` with the built-in ones, and read with `isOptionSet()`/`optionValue()`.
|
listed in `--help` with the built-in ones, and read with `isOptionSet()`/`optionValue()`.
|
||||||
|
Its commands (flags such as `--ha-remove`) go there as a second list: like the manifest
|
||||||
|
commands they get a `QCoreApplication` (no display needed, works over SSH), and `exec()`
|
||||||
|
runs `prepare()` and then `runCommand(name)` in mode `Command` instead of a mode.
|
||||||
- **Hooks:**
|
- **Hooks:**
|
||||||
- `prepare(QString* error)`: first, once mode and app key are known, before the QML
|
- `prepare(QString* error)`: first, once mode and app key are known, before the QML
|
||||||
engine, the surfaces and SteamVR exist (not for manifest commands). Returning `false`
|
engine, the surfaces and SteamVR exist (not for manifest commands). Returning `false`
|
||||||
@@ -48,10 +51,13 @@ Your app derives from it:
|
|||||||
- `loading()`: the shared QML engine, the surfaces and their render backends exist; QML
|
- `loading()`: the shared QML engine, the surfaces and their render backends exist; QML
|
||||||
isn't loaded yet. Set up what the QML needs here.
|
isn't loaded yet. Set up what the QML needs here.
|
||||||
- `started()`: everything runs. Connect to surfaces and input here.
|
- `started()`: everything runs. Connect to surfaces and input here.
|
||||||
|
- `runCommand(name)`: one of the app's commands, after `prepare()`; no QML engine,
|
||||||
|
surfaces or SteamVR. Its result is the exit code.
|
||||||
- **Accessors:** `mode()`, `appKey()`, `appName()`, `isOptionSet()`, `optionValue()`,
|
- **Accessors:** `mode()`, `appKey()`, `appName()`, `isOptionSet()`, `optionValue()`,
|
||||||
`engine()`, `surfaces()`, `surface()` (the first), `surface(id)`, `vrRuntime()`,
|
`engine()`, `surfaces()`, `surface()` (the first), `surface(id)`, `vrRuntime()`,
|
||||||
`navigation()`.
|
`navigation()`.
|
||||||
- **Lifetime:** the base constructor creates the `QGuiApplication`, so your members can be
|
- **Lifetime:** the base constructor creates the `QGuiApplication` (`QCoreApplication` for
|
||||||
|
commands), so your members can be
|
||||||
QObjects with timers. All QML and surfaces are torn down at the end of `exec()`, while your
|
QObjects with timers. All QML and surfaces are torn down at the end of `exec()`, while your
|
||||||
object and its members still exist.
|
object and its members still exist.
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -385,6 +385,8 @@ a `QGuiApplication` (no display needed, works over SSH), and `exec()` calls
|
|||||||
- `exec()`: after the manifest check, an app command runs `prepare()` and then a new hook
|
- `exec()`: after the manifest check, an app command runs `prepare()` and then a new hook
|
||||||
`virtual int runCommand(const QString& name)`, whose result is the exit code. No engine,
|
`virtual int runCommand(const QString& name)`, whose result is the exit code. No engine,
|
||||||
surfaces or SteamVR.
|
surfaces or SteamVR.
|
||||||
|
- The mode is `DashboardMode::Command` (so `prepare()` can tell); the name of the first app
|
||||||
|
command given goes to `runCommand()`. Tested in `tst_surfaces` (`surfaces_command`).
|
||||||
- Moving the manifest commands onto this mechanism is possible later, not part of iteration 2.
|
- Moving the manifest commands onto this mechanism is possible later, not part of iteration 2.
|
||||||
|
|
||||||
### `--ha-remove`
|
### `--ha-remove`
|
||||||
@@ -396,7 +398,11 @@ delete).
|
|||||||
- An app command (D43): loads the settings, connects, publishes **empty retained payloads**
|
- An app command (D43): loads the settings, connects, publishes **empty retained payloads**
|
||||||
to discovery (HA then deletes the device and its entities), device availability, and each
|
to discovery (HA then deletes the device and its entities), device availability, and each
|
||||||
entity's state and availability topics; disconnects.
|
entity's state and availability topics; disconnects.
|
||||||
- Exit code 0 on success; ≠ 0 with a warning if the broker can't be reached within 10 s.
|
- Its client uses the client id `frameassistant_<device id>_remove` (doesn't knock a running
|
||||||
|
instance off the broker) and no will. The `HaDevice` is created only after the connection
|
||||||
|
is up: a device that sees the connection come up publishes itself first.
|
||||||
|
- Exit code 0 on success; 1 with an error if the broker can't be reached within 10 s, or the
|
||||||
|
removal isn't acknowledged within 10 s.
|
||||||
- `uninstall.sh`: `pkill` → `sleep 1` → `--ha-remove` (a failure doesn't stop the uninstall) →
|
- `uninstall.sh`: `pkill` → `sleep 1` → `--ha-remove` (a failure doesn't stop the uninstall) →
|
||||||
`--uninstall-manifest`.
|
`--uninstall-manifest`.
|
||||||
- Also usable by hand, e.g. after a device name change without `ha_unique_id`:
|
- Also usable by hand, e.g. after a device name change without `ha_unique_id`:
|
||||||
|
|||||||
+8
-1
@@ -25,7 +25,8 @@ CLAUDE.md ("Working rules").
|
|||||||
- **Implementation** follows the order in DESIGN.md (steps 1–7). Steps 1 (libmosquitto in
|
- **Implementation** follows the order in DESIGN.md (steps 1–7). Steps 1 (libmosquitto in
|
||||||
CMake and the containers), 2 (`MqttClient`, `MosquittoClient`, `tst_mqtt`) 3
|
CMake and the containers), 2 (`MqttClient`, `MosquittoClient`, `tst_mqtt`) 3
|
||||||
(`HaEntity`, `HaSensor`, `HaDevice`, unit tests) and 4 (app wiring, `--desktop-novr`,
|
(`HaEntity`, `HaSensor`, `HaDevice`, unit tests) and 4 (app wiring, `--desktop-novr`,
|
||||||
UI-1) and 5 (TLS) done; next: step 6 (app commands, `--ha-remove`, `uninstall.sh`).
|
UI-1) 5 (TLS) and 6 (app commands, `--ha-remove`, `uninstall.sh`) done; next: step 7
|
||||||
|
(packaging check, docs).
|
||||||
|
|
||||||
## Next steps
|
## Next steps
|
||||||
|
|
||||||
@@ -178,6 +179,12 @@ Known limitation: the bundled-Qt package does not support WebEngine in this iter
|
|||||||
validation messages.
|
validation messages.
|
||||||
- Still open: which CA store the Frame's OpenSSL uses by default (for `ca_file` empty);
|
- Still open: which CA store the Frame's OpenSSL uses by default (for `ca_file` empty);
|
||||||
check on the device.
|
check on the device.
|
||||||
|
- Step 6: app commands in the platform (D43: second option list, `QCoreApplication`, mode
|
||||||
|
`Command`, hook `runCommand()`; `tst_surfaces` `surfaces_command`); `--ha-remove` in the
|
||||||
|
app; `uninstall.sh` stops the app, runs `--ha-remove` (failure doesn't stop it) and
|
||||||
|
unregisters. Checked end to end against a local mosquitto, without a display: retained
|
||||||
|
discovery, availability, state and entity availability before, none after (exit 0);
|
||||||
|
broker down → exit 1 after 10 s; missing settings → exit 1.
|
||||||
|
|
||||||
### 2026-10-05: requirements approved
|
### 2026-10-05: requirements approved
|
||||||
|
|
||||||
|
|||||||
@@ -64,9 +64,14 @@ EOF
|
|||||||
|
|
||||||
cat > "$out/uninstall.sh" <<EOF
|
cat > "$out/uninstall.sh" <<EOF
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Unregisters $name from SteamVR and stops it. The folder can be deleted afterwards.
|
# Stops $name, removes its device from Home Assistant (clears its retained MQTT
|
||||||
|
# topics) and unregisters it from SteamVR. The folder can be deleted afterwards.
|
||||||
cd "\$(dirname "\$0")" || exit 1
|
cd "\$(dirname "\$0")" || exit 1
|
||||||
pkill -f "/bin/$exe( |\\$)" 2>/dev/null
|
mkdir -p logs
|
||||||
|
# Stopped first, so it can't publish itself again; the broker sends its last will at once.
|
||||||
|
pkill -f "/bin/$exe( |\\$)" 2>/dev/null && sleep 1
|
||||||
|
./bin/$exe --ha-remove --log-file logs/install.log ||
|
||||||
|
echo "Could not remove the device from Home Assistant (see logs/install.log); unregistering anyway." >&2
|
||||||
exec ./bin/$exe --uninstall-manifest --log-file logs/install.log
|
exec ./bin/$exe --uninstall-manifest --log-file logs/install.log
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$out/install.sh" "$out/uninstall.sh"
|
chmod +x "$out/install.sh" "$out/uninstall.sh"
|
||||||
@@ -92,7 +97,8 @@ chmod +x "$out/install.sh" "$out/uninstall.sh"
|
|||||||
echo "Install / update: ./install.sh (SteamVR must be running)"
|
echo "Install / update: ./install.sh (SteamVR must be running)"
|
||||||
echo " SteamVR remembers this folder's location: after moving it,"
|
echo " SteamVR remembers this folder's location: after moving it,"
|
||||||
echo " run ./install.sh again."
|
echo " run ./install.sh again."
|
||||||
echo "Uninstall: ./uninstall.sh"
|
echo "Uninstall: ./uninstall.sh (also removes the device from Home Assistant;"
|
||||||
|
echo " needs the settings file and the MQTT broker)"
|
||||||
echo "Logs: logs/$exe.log (the app), logs/install.log (install.sh);"
|
echo "Logs: logs/$exe.log (the app), logs/install.log (install.sh);"
|
||||||
echo " run ./bin/$exe --help for options"
|
echo " run ./bin/$exe --help for options"
|
||||||
echo
|
echo
|
||||||
|
|||||||
+81
-23
@@ -3,6 +3,8 @@
|
|||||||
#include <QCommandLineOption>
|
#include <QCommandLineOption>
|
||||||
#include <QCoreApplication>
|
#include <QCoreApplication>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
|
#include <QEventLoop>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
#include "ha/HaDevice.h"
|
#include "ha/HaDevice.h"
|
||||||
#include "ha/HaSensor.h"
|
#include "ha/HaSensor.h"
|
||||||
@@ -11,10 +13,13 @@
|
|||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
const QString kConfigOption = QStringLiteral("config");
|
const QString kConfigOption = QStringLiteral("config");
|
||||||
|
const QString kHaRemoveCommand = QStringLiteral("ha-remove");
|
||||||
// How often the dummy sensor's value is updated.
|
// How often the dummy sensor's value is updated.
|
||||||
constexpr int kUptimeIntervalMs = 10000;
|
constexpr int kUptimeIntervalMs = 10000;
|
||||||
// How long a normal exit waits for the broker to acknowledge "offline".
|
// How long a normal exit waits for the broker to acknowledge "offline".
|
||||||
constexpr int kDisconnectTimeoutMs = 2000;
|
constexpr int kDisconnectTimeoutMs = 2000;
|
||||||
|
// --ha-remove: how long it may take to connect, and then to publish.
|
||||||
|
constexpr int kRemoveTimeoutMs = 10000;
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
@@ -24,7 +29,11 @@ Dashboard::Dashboard(int& argc, char** argv)
|
|||||||
{QCommandLineOption(kConfigOption,
|
{QCommandLineOption(kConfigOption,
|
||||||
QStringLiteral("Settings file (default: %1).")
|
QStringLiteral("Settings file (default: %1).")
|
||||||
.arg(Config::defaultPath()),
|
.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")});
|
addSurface({.qmlRootType = QStringLiteral("Main")});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +42,7 @@ Dashboard::~Dashboard() = default;
|
|||||||
bool Dashboard::prepare(QString* error) {
|
bool Dashboard::prepare(QString* error) {
|
||||||
// Snapshot and benchmark only render the UI: no settings needed.
|
// Snapshot and benchmark only render the UI: no settings needed.
|
||||||
if (mode() != DashboardMode::Overlay && mode() != DashboardMode::Desktop &&
|
if (mode() != DashboardMode::Overlay && mode() != DashboardMode::Desktop &&
|
||||||
mode() != DashboardMode::DesktopNoVr)
|
mode() != DashboardMode::DesktopNoVr && mode() != DashboardMode::Command)
|
||||||
return true;
|
return true;
|
||||||
const QString path = isOptionSet(kConfigOption) ? optionValue(kConfigOption)
|
const QString path = isOptionSet(kConfigOption) ? optionValue(kConfigOption)
|
||||||
: Config::defaultPath();
|
: Config::defaultPath();
|
||||||
@@ -46,30 +55,38 @@ bool Dashboard::prepare(QString* error) {
|
|||||||
return true;
|
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() {
|
void Dashboard::loading() {
|
||||||
if (!m_config) return;
|
if (!m_config) return;
|
||||||
const Config& config = *m_config;
|
m_mqtt = std::make_unique<MosquittoClient>(mqttSettings());
|
||||||
MosquittoClient::Settings settings;
|
m_device = std::make_unique<HaDevice>(deviceInfo(), m_mqtt.get());
|
||||||
settings.broker = config.mqtt;
|
m_uptime = addEntities(*m_device);
|
||||||
settings.clientId = QStringLiteral("frameassistant_") + config.deviceId;
|
|
||||||
settings.will = HaDevice::lastWill(config.deviceId);
|
|
||||||
m_mqtt = std::make_unique<MosquittoClient>(settings);
|
|
||||||
|
|
||||||
m_device = std::make_unique<HaDevice>(
|
const MqttConfig& broker = m_config->mqtt;
|
||||||
HaDevice::Info{.name = config.deviceName,
|
m_status.setDevice(m_config->deviceName,
|
||||||
.id = config.deviceId,
|
QStringLiteral("%1:%2").arg(broker.host).arg(broker.port));
|
||||||
.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));
|
|
||||||
connect(m_mqtt.get(), &MqttClient::connectionChanged, &m_status,
|
connect(m_mqtt.get(), &MqttClient::connectionChanged, &m_status,
|
||||||
&ConnectionStatus::setConnection);
|
&ConnectionStatus::setConnection);
|
||||||
}
|
}
|
||||||
@@ -91,3 +108,44 @@ void Dashboard::started() {
|
|||||||
});
|
});
|
||||||
m_mqtt->connectToBroker();
|
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 "ConnectionStatus.h"
|
||||||
#include "app/DashboardApplication.h"
|
#include "app/DashboardApplication.h"
|
||||||
#include "config/Config.h"
|
#include "config/Config.h"
|
||||||
|
#include "ha/HaDevice.h"
|
||||||
|
#include "mqtt/MosquittoClient.h"
|
||||||
|
|
||||||
class HaDevice;
|
|
||||||
class HaSensor;
|
class HaSensor;
|
||||||
class MosquittoClient;
|
|
||||||
|
|
||||||
// The dashboard app. Its UI is the QML module set up in src/CMakeLists.txt
|
// The dashboard app. Its UI is the QML module set up in src/CMakeLists.txt
|
||||||
// (QML_URI FrameAssistant, files in src/qml/).
|
// (QML_URI FrameAssistant, files in src/qml/).
|
||||||
@@ -30,8 +30,18 @@ class Dashboard : public DashboardApplication {
|
|||||||
void loading() override;
|
void loading() override;
|
||||||
// Connects to the broker and starts the sensors.
|
// Connects to the broker and starts the sensors.
|
||||||
void started() override;
|
void started() override;
|
||||||
|
// --ha-remove (docs/DESIGN.md, "--ha-remove").
|
||||||
|
int runCommand(const QString& name) override;
|
||||||
|
|
||||||
private:
|
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
|
std::optional<Config> m_config; // none in snapshot/benchmark mode
|
||||||
// The device uses the client: declared after it, so destroyed before it.
|
// The device uses the client: declared after it, so destroyed before it.
|
||||||
std::unique_ptr<MosquittoClient> m_mqtt;
|
std::unique_ptr<MosquittoClient> m_mqtt;
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ std::unique_ptr<RenderBackend> startBackend(GraphicsApi api, bool apiExplicit,
|
|||||||
struct DashboardApplication::Options {
|
struct DashboardApplication::Options {
|
||||||
QString error; // set: exec() reports it and fails
|
QString error; // set: exec() reports it and fails
|
||||||
bool manifestCommand = false;
|
bool manifestCommand = false;
|
||||||
|
QString appCommand; // name of the app command given, if any
|
||||||
bool install = false;
|
bool install = false;
|
||||||
bool launch = false;
|
bool launch = false;
|
||||||
QString manifest;
|
QString manifest;
|
||||||
@@ -144,6 +145,8 @@ const char* dashboardModeName(DashboardMode mode) {
|
|||||||
return "snapshot";
|
return "snapshot";
|
||||||
case DashboardMode::Benchmark:
|
case DashboardMode::Benchmark:
|
||||||
return "benchmark";
|
return "benchmark";
|
||||||
|
case DashboardMode::Command:
|
||||||
|
return "command";
|
||||||
}
|
}
|
||||||
return "?";
|
return "?";
|
||||||
}
|
}
|
||||||
@@ -170,7 +173,8 @@ void DashboardApplication::setDefaultQmlModule(const char* uri) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DashboardApplication::DashboardApplication(
|
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()) {
|
: m_options(std::make_unique<Options>()), m_qmlModule(defaultQmlModule()) {
|
||||||
// When launched by SteamVR, stderr is not a TTY and Qt would log to
|
// 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
|
// journald only; keep messages on stderr so they show up wherever the
|
||||||
@@ -178,14 +182,24 @@ DashboardApplication::DashboardApplication(
|
|||||||
if (!qEnvironmentVariableIsSet("QT_FORCE_STDERR_LOGGING"))
|
if (!qEnvironmentVariableIsSet("QT_FORCE_STDERR_LOGGING"))
|
||||||
qputenv("QT_FORCE_STDERR_LOGGING", "1");
|
qputenv("QT_FORCE_STDERR_LOGGING", "1");
|
||||||
|
|
||||||
// Manifest commands only talk to SteamVR: no GUI (and no display
|
// Manifest commands only talk to SteamVR, app commands do their own thing:
|
||||||
// connection) needed, so they also work over SSH.
|
// 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;
|
Options& o = *m_options;
|
||||||
o.manifestCommand = std::any_of(argv + 1, argv + argc, [](const char* arg) {
|
o.manifestCommand = std::any_of(argv + 1, argv + argc, [](const char* arg) {
|
||||||
return qstrcmp(arg, "--install-manifest") == 0 ||
|
return qstrcmp(arg, "--install-manifest") == 0 ||
|
||||||
qstrcmp(arg, "--uninstall-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);
|
m_app = std::make_unique<QCoreApplication>(argc, argv);
|
||||||
else
|
else
|
||||||
m_app = std::make_unique<QGuiApplication>(argc, argv);
|
m_app = std::make_unique<QGuiApplication>(argc, argv);
|
||||||
@@ -257,7 +271,7 @@ DashboardApplication::DashboardApplication(
|
|||||||
benchmarkOption, apiOption, keyOption, nameOption,
|
benchmarkOption, apiOption, keyOption, nameOption,
|
||||||
fpsOption, installOption, uninstallOption, manifestOption,
|
fpsOption, installOption, uninstallOption, manifestOption,
|
||||||
launchOption, logOption});
|
launchOption, logOption});
|
||||||
for (const QCommandLineOption& option : appOptions) {
|
for (const QCommandLineOption& option : appOptions + appCommands) {
|
||||||
if (!parser.addOption(option))
|
if (!parser.addOption(option))
|
||||||
o.error = QStringLiteral("App option --%1 clashes with another option")
|
o.error = QStringLiteral("App option --%1 clashes with another option")
|
||||||
.arg(option.names().constFirst());
|
.arg(option.names().constFirst());
|
||||||
@@ -283,6 +297,12 @@ DashboardApplication::DashboardApplication(
|
|||||||
? resolvePath(parser.value(manifestOption))
|
? resolvePath(parser.value(manifestOption))
|
||||||
: shareDir() + QStringLiteral("/manifest.vrmanifest");
|
: shareDir() + QStringLiteral("/manifest.vrmanifest");
|
||||||
if (o.manifestCommand) return;
|
if (o.manifestCommand) return;
|
||||||
|
for (const QCommandLineOption& command : appCommands) {
|
||||||
|
if (parser.isSet(command)) {
|
||||||
|
o.appCommand = command.names().constFirst();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (parser.isSet(apiOption)) {
|
if (parser.isSet(apiOption)) {
|
||||||
if (!parseGraphicsApi(parser.value(apiOption), &o.graphicsApi))
|
if (!parseGraphicsApi(parser.value(apiOption), &o.graphicsApi))
|
||||||
@@ -338,6 +358,10 @@ void DashboardApplication::setQuickStyle(const QString& style) {
|
|||||||
|
|
||||||
bool DashboardApplication::prepare(QString*) { return true; }
|
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::loading() {}
|
||||||
|
|
||||||
void DashboardApplication::started() {}
|
void DashboardApplication::started() {}
|
||||||
@@ -400,7 +424,7 @@ int DashboardApplication::exec() {
|
|||||||
QString error;
|
QString error;
|
||||||
if (!validateConfig(&error)) return fail(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_appKey = !o.appKey.isEmpty() ? o.appKey
|
||||||
: !m_configAppKey.isEmpty()
|
: !m_configAppKey.isEmpty()
|
||||||
? m_configAppKey
|
? m_configAppKey
|
||||||
@@ -411,6 +435,7 @@ int DashboardApplication::exec() {
|
|||||||
: QString::fromUtf8(AppConfig::kDisplayName);
|
: QString::fromUtf8(AppConfig::kDisplayName);
|
||||||
QCoreApplication::setApplicationName(m_appName);
|
QCoreApplication::setApplicationName(m_appName);
|
||||||
if (!prepare(&error)) return fail(error);
|
if (!prepare(&error)) return fail(error);
|
||||||
|
if (m_mode == DashboardMode::Command) return runCommand(o.appCommand);
|
||||||
if (!m_quickStyle.isEmpty()) QQuickStyle::setStyle(m_quickStyle);
|
if (!m_quickStyle.isEmpty()) QQuickStyle::setStyle(m_quickStyle);
|
||||||
|
|
||||||
m_engine = std::make_unique<QQmlEngine>();
|
m_engine = std::make_unique<QQmlEngine>();
|
||||||
@@ -432,6 +457,8 @@ int DashboardApplication::exec() {
|
|||||||
case DashboardMode::Overlay:
|
case DashboardMode::Overlay:
|
||||||
result = runOverlay();
|
result = runOverlay();
|
||||||
break;
|
break;
|
||||||
|
case DashboardMode::Command:
|
||||||
|
break; // handled above
|
||||||
}
|
}
|
||||||
// Still within exec(): the derived object (and what it owns) outlives all
|
// Still within exec(): the derived object (and what it owns) outlives all
|
||||||
// QML. Surfaces (their QML scenes, renderers, backends) before the engine.
|
// 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
|
// Desktop and DesktopNoVr both show desktop windows; DesktopNoVr promises
|
||||||
// never to connect to SteamVR (for apps that use SteamVR data otherwise).
|
// 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);
|
const char* dashboardModeName(DashboardMode mode);
|
||||||
|
|
||||||
// Base class of a dashboard app. It owns everything app- and surface-related:
|
// 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
|
// `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
|
// in --help together with the built-in ones (names must not clash); read
|
||||||
// them with isOptionSet() / optionValue().
|
// 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,
|
DashboardApplication(int& argc, char** argv,
|
||||||
const QList<QCommandLineOption>& appOptions = {});
|
const QList<QCommandLineOption>& appOptions = {},
|
||||||
|
const QList<QCommandLineOption>& appCommands = {});
|
||||||
~DashboardApplication() override;
|
~DashboardApplication() override;
|
||||||
|
|
||||||
// Runs the mode selected on the command line; returns the exit code.
|
// Runs the mode selected on the command line; returns the exit code.
|
||||||
@@ -98,6 +110,10 @@ class DashboardApplication : public QObject {
|
|||||||
virtual void loading();
|
virtual void loading();
|
||||||
// Everything runs; called right before the event loop starts.
|
// Everything runs; called right before the event loop starts.
|
||||||
virtual void started();
|
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) ---
|
// --- The running app (valid in the hooks and until exec() returns) ---
|
||||||
DashboardMode mode() const { return m_mode; }
|
DashboardMode mode() const { return m_mode; }
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ add_test(NAME surfaces_cli_key COMMAND tst_surfaces --graphics-api opengl)
|
|||||||
set_tests_properties(surfaces_cli_key PROPERTIES ENVIRONMENT "VRDASH_TEST_CLI_KEY=1")
|
set_tests_properties(surfaces_cli_key PROPERTIES ENVIRONMENT "VRDASH_TEST_CLI_KEY=1")
|
||||||
add_test(NAME surfaces_prepare_fail COMMAND tst_surfaces --graphics-api opengl)
|
add_test(NAME surfaces_prepare_fail COMMAND tst_surfaces --graphics-api opengl)
|
||||||
set_tests_properties(surfaces_prepare_fail PROPERTIES ENVIRONMENT "VRDASH_TEST_PREPARE_FAIL=1")
|
set_tests_properties(surfaces_prepare_fail PROPERTIES ENVIRONMENT "VRDASH_TEST_PREPARE_FAIL=1")
|
||||||
|
add_test(NAME surfaces_command COMMAND tst_surfaces)
|
||||||
|
set_tests_properties(surfaces_command PROPERTIES ENVIRONMENT "VRDASH_TEST_COMMAND=1")
|
||||||
add_test(NAME surfaces_desktop_novr COMMAND tst_surfaces)
|
add_test(NAME surfaces_desktop_novr COMMAND tst_surfaces)
|
||||||
set_tests_properties(surfaces_desktop_novr PROPERTIES
|
set_tests_properties(surfaces_desktop_novr PROPERTIES
|
||||||
ENVIRONMENT "VRDASH_TEST_DESKTOP_NOVR=1;QT_QPA_PLATFORM=offscreen")
|
ENVIRONMENT "VRDASH_TEST_DESKTOP_NOVR=1;QT_QPA_PLATFORM=offscreen")
|
||||||
|
|||||||
+32
-1
@@ -6,10 +6,14 @@
|
|||||||
// without calling the other hooks.
|
// without calling the other hooks.
|
||||||
// VRDASH_TEST_DESKTOP_NOVR=1: --desktop-novr instead of --snapshot; prepare()
|
// VRDASH_TEST_DESKTOP_NOVR=1: --desktop-novr instead of --snapshot; prepare()
|
||||||
// checks the mode and then fails (no window, no event loop).
|
// checks the mode and then fails (no window, no event loop).
|
||||||
|
// VRDASH_TEST_COMMAND=1: the app command --test-command instead of
|
||||||
|
// --snapshot: prepare(), then runCommand() without GUI, engine or surfaces;
|
||||||
|
// its result is exec()'s.
|
||||||
// Exit codes: 0 pass, 1 fail, 77 skipped (no GPU context).
|
// Exit codes: 0 pass, 1 fail, 77 skipped (no GPU context).
|
||||||
|
|
||||||
#include <QCommandLineOption>
|
#include <QCommandLineOption>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
|
#include <QGuiApplication>
|
||||||
#include <QImage>
|
#include <QImage>
|
||||||
#include <QTemporaryDir>
|
#include <QTemporaryDir>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
@@ -34,7 +38,9 @@ class TwoSurfaceApp : public DashboardApplication {
|
|||||||
QStringLiteral("An app option."),
|
QStringLiteral("An app option."),
|
||||||
QStringLiteral("value")),
|
QStringLiteral("value")),
|
||||||
QCommandLineOption(QStringLiteral("test-flag"),
|
QCommandLineOption(QStringLiteral("test-flag"),
|
||||||
QStringLiteral("An app flag, never set."))}) {
|
QStringLiteral("An app flag, never set."))},
|
||||||
|
{QCommandLineOption(QStringLiteral("test-command"),
|
||||||
|
QStringLiteral("An app command."))}) {
|
||||||
setQmlModule(QStringLiteral("SurfacesTest"));
|
setQmlModule(QStringLiteral("SurfacesTest"));
|
||||||
setDisplayName(QStringLiteral("Test App"));
|
setDisplayName(QStringLiteral("Test App"));
|
||||||
setAppKey(QStringLiteral("config.key"));
|
setAppKey(QStringLiteral("config.key"));
|
||||||
@@ -53,6 +59,7 @@ class TwoSurfaceApp : public DashboardApplication {
|
|||||||
int prepareCalls = 0;
|
int prepareCalls = 0;
|
||||||
int loadingCalls = 0;
|
int loadingCalls = 0;
|
||||||
int startedCalls = 0;
|
int startedCalls = 0;
|
||||||
|
QStringList commands;
|
||||||
bool ok = true;
|
bool ok = true;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
@@ -94,6 +101,15 @@ class TwoSurfaceApp : public DashboardApplication {
|
|||||||
"texture size from config");
|
"texture size from config");
|
||||||
ok &= check(vrRuntime() == nullptr, "no VR outside overlay mode");
|
ok &= check(vrRuntime() == nullptr, "no VR outside overlay mode");
|
||||||
}
|
}
|
||||||
|
int runCommand(const QString& name) override {
|
||||||
|
commands.append(name);
|
||||||
|
ok &= check(mode() == DashboardMode::Command, "mode in runCommand()");
|
||||||
|
ok &= check(engine() == nullptr && surfaces().isEmpty(),
|
||||||
|
"no engine and no surfaces for a command");
|
||||||
|
ok &= check(!qobject_cast<QGuiApplication*>(QCoreApplication::instance()),
|
||||||
|
"no GUI application for a command");
|
||||||
|
return 7;
|
||||||
|
}
|
||||||
void started() override {
|
void started() override {
|
||||||
++startedCalls;
|
++startedCalls;
|
||||||
const QList<Surface*> all = surfaces();
|
const QList<Surface*> all = surfaces();
|
||||||
@@ -113,11 +129,14 @@ int main(int argc, char* argv[]) {
|
|||||||
const bool cliKey = qEnvironmentVariableIntValue("VRDASH_TEST_CLI_KEY");
|
const bool cliKey = qEnvironmentVariableIntValue("VRDASH_TEST_CLI_KEY");
|
||||||
const bool desktopNoVr =
|
const bool desktopNoVr =
|
||||||
qEnvironmentVariableIntValue("VRDASH_TEST_DESKTOP_NOVR");
|
qEnvironmentVariableIntValue("VRDASH_TEST_DESKTOP_NOVR");
|
||||||
|
const bool command = qEnvironmentVariableIntValue("VRDASH_TEST_COMMAND");
|
||||||
const bool failPrepare =
|
const bool failPrepare =
|
||||||
qEnvironmentVariableIntValue("VRDASH_TEST_PREPARE_FAIL") || desktopNoVr;
|
qEnvironmentVariableIntValue("VRDASH_TEST_PREPARE_FAIL") || desktopNoVr;
|
||||||
std::vector<std::string> args = {argv[0], "--test-value", "42"};
|
std::vector<std::string> args = {argv[0], "--test-value", "42"};
|
||||||
if (desktopNoVr) {
|
if (desktopNoVr) {
|
||||||
args.emplace_back("--desktop-novr");
|
args.emplace_back("--desktop-novr");
|
||||||
|
} else if (command) {
|
||||||
|
args.emplace_back("--test-command");
|
||||||
} else {
|
} else {
|
||||||
args.emplace_back("--snapshot");
|
args.emplace_back("--snapshot");
|
||||||
args.emplace_back(snapshot);
|
args.emplace_back(snapshot);
|
||||||
@@ -136,7 +155,19 @@ int main(int argc, char* argv[]) {
|
|||||||
cliKey ? QStringLiteral("cli.key") : QStringLiteral("config.key");
|
cliKey ? QStringLiteral("cli.key") : QStringLiteral("config.key");
|
||||||
app.failPrepare = failPrepare;
|
app.failPrepare = failPrepare;
|
||||||
if (desktopNoVr) app.expectedMode = DashboardMode::DesktopNoVr;
|
if (desktopNoVr) app.expectedMode = DashboardMode::DesktopNoVr;
|
||||||
|
if (command) app.expectedMode = DashboardMode::Command;
|
||||||
const int result = app.exec();
|
const int result = app.exec();
|
||||||
|
if (command) {
|
||||||
|
bool ok = app.ok;
|
||||||
|
ok &= check(result == 7, "exec() returns runCommand()'s result");
|
||||||
|
ok &= check(
|
||||||
|
app.prepareCalls == 1 && app.loadingCalls == 0 && app.startedCalls == 0,
|
||||||
|
"prepare(), but no other hooks for a command");
|
||||||
|
ok &= check(app.commands == QStringList{QStringLiteral("test-command")},
|
||||||
|
"runCommand() once with the command's name");
|
||||||
|
fprintf(stderr, ok ? "PASS\n" : "FAILED\n");
|
||||||
|
return ok ? 0 : 1;
|
||||||
|
}
|
||||||
if (failPrepare) {
|
if (failPrepare) {
|
||||||
bool ok = app.ok;
|
bool ok = app.ok;
|
||||||
ok &= check(result == 1, "exec() returns 1 when prepare() fails");
|
ok &= check(result == 1, "exec() returns 1 when prepare() fails");
|
||||||
|
|||||||
Reference in New Issue
Block a user