From a9c5139d5569c3d91d721cfed21990f577f003bc Mon Sep 17 00:00:00 2001 From: Nathan Coad Date: Sat, 31 Jan 2026 16:50:34 +1100 Subject: [PATCH] add mqtt control --- .drone.yml | 2 +- Makefile | 3 +- README.md | 33 ++++++++- sbin/build_deb.sh | 2 +- src/appconfig.cpp | 38 +++++++++++ src/appconfig.h | 31 +++++++++ src/imageselector.cpp | 2 +- src/imageswitcher.cpp | 58 +++++++++++++++- src/imageswitcher.h | 7 ++ src/main.cpp | 19 ++++++ src/mqttcontroller.cpp | 152 +++++++++++++++++++++++++++++++++++++++++ src/mqttcontroller.h | 42 ++++++++++++ src/slide.pro | 3 + 13 files changed, 385 insertions(+), 7 deletions(-) create mode 100644 src/mqttcontroller.cpp create mode 100644 src/mqttcontroller.h diff --git a/.drone.yml b/.drone.yml index bad9065..56830cc 100644 --- a/.drone.yml +++ b/.drone.yml @@ -9,7 +9,7 @@ steps: DEBIAN_FRONTEND: noninteractive commands: - apt-get update - - apt-get install -y --no-install-recommends build-essential qt5-qmake qtbase5-dev qtbase5-dev-tools libexif-dev qt5-image-formats-plugins dpkg-dev fakeroot ca-certificates + - apt-get install -y --no-install-recommends build-essential qt5-qmake qtbase5-dev qtbase5-dev-tools libexif-dev qt5-image-formats-plugins libmosquitto-dev dpkg-dev fakeroot ca-certificates - bash sbin/build_deb.sh "${DRONE_TAG:-${DRONE_COMMIT_SHA:0:8}}" - ls -la dist diff --git a/Makefile b/Makefile index 462e726..e7a633d 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ all: build .PHONY: install-deps-deb install-deps-deb: - apt install qt5-qmake libexif12 qt5-default libexif-dev qt5-image-formats-plugins + apt install qt5-qmake libexif12 qt5-default libexif-dev qt5-image-formats-plugins libmosquitto-dev check-deps-deb: dpkg -l | grep qt5-qmake @@ -15,6 +15,7 @@ check-deps-deb: dpkg -l | grep libexif-dev dpkg -l | grep qt5-default dpkg -l | grep qt5-image-formats-plugins + dpkg -l | grep libmosquitto-dev .PHONY: clean clean: diff --git a/README.md b/README.md index 1b68120..ded5763 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ Supported keys and values in the JSON configuration are: * `opacity` : the same as the command line `-o` argument * `blur` : the same as the command line `-b` argument * `debug` : set to true to enable verbose output from the program +* `mqtt` : MQTT playback control (see below) * `immich` : connect to an Immich server instead of a local path (see below) * `scheduler` : this entry is an array of possible path values and associated settings. This key lets you manage display times/settings for a collection of paths. In the example above the top entry shows ONLY files from a Redit feed between 2 and 4pm, ONLY files from the `show_peak_times` folder from 8am to 10am and then 4pm to 7pm. At all other times it alternates displaying files in the `always_show_1` and `always_show_2` folder. * `exclusive` : When set to `true` only this entry will be used when it is in its valid time window. @@ -124,9 +125,36 @@ Supported keys and values in the JSON configuration are: * `path` : the path to image files * `stretch` : as above +### MQTT control + +Add an `mqtt` block to control playback remotely. Publish one of the commands below to the configured topic. + +Example: +``` +{ + "mqtt": { + "host": "mqtt.local", + "port": 1883, + "topic": "slide/control", + "clientId": "slide-frame", + "username": "slide", + "password": "secret", + "keepAlive": 30, + "qos": 0 + } +} +``` + +Commands: +* `play` / `resume` — resume slideshow +* `pause` — pause slideshow +* `next` / `next-image` — advance to next image +* `next-folder` — jump to next configured path (if multiple paths are configured) +* `restart` / `reset` — recreate the selector and restart playback + ### Immich configuration (lightweight + low power) -Immich uses an API key and a `/api` base path. This integration requests the asset search endpoint and downloads thumbnail images into a local cache before displaying them. That keeps bandwidth and power usage low while still letting `slide` do its normal scaling and transitions. +Immich uses an API key and a `/api` base path. This integration requests the asset search endpoint and downloads the configured image size into a local cache before displaying them. That keeps bandwidth and power usage low while still letting `slide` do its normal scaling and transitions. Example (single source): ``` @@ -171,7 +199,7 @@ Immich settings: * `order`: `"asc"` or `"desc"` ordering for asset search. * `pageSize`: assets fetched per page. * `maxAssets`: cap on total assets fetched (0 means no cap). -* `cachePath`: local cache directory for downloaded thumbnails. +* `cachePath`: local cache directory for downloaded images. * `cacheMaxMB`: maximum cache size in MB (0 disables cleanup). * `includeArchived`: include archived assets in search results. When `immich` is set on an entry, `path` and `imageList` are ignored. @@ -205,6 +233,7 @@ See the `Configuration File` section for details of each setting. * qt5 * qt5-image-formats-plugins * libexif +* libmosquitto-dev Ubuntu/Raspbian: diff --git a/sbin/build_deb.sh b/sbin/build_deb.sh index 97022cd..75735a3 100644 --- a/sbin/build_deb.sh +++ b/sbin/build_deb.sh @@ -31,7 +31,7 @@ Section: graphics Priority: optional Architecture: ${ARCH} Maintainer: slide build -Depends: libqt5core5a, libqt5gui5, libqt5widgets5, libqt5network5, libexif12, qt5-image-formats-plugins +Depends: libqt5core5a, libqt5gui5, libqt5widgets5, libqt5network5, libexif12, qt5-image-formats-plugins, libmosquitto1 Description: Lightweight slideshow for photo frames Simple, lightweight slideshow designed for low power devices. EOF diff --git a/src/appconfig.cpp b/src/appconfig.cpp index 9bfeb00..dfc5ce7 100644 --- a/src/appconfig.cpp +++ b/src/appconfig.cpp @@ -112,6 +112,39 @@ ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) { return config; } +MqttConfig ParseMqttConfigObject(QJsonObject mqttJson) { + MqttConfig config; + + std::string host = ParseJSONString(mqttJson, "host"); + if(!host.empty()) + config.host = host; + + std::string topic = ParseJSONString(mqttJson, "topic"); + if(!topic.empty()) + config.topic = topic; + + std::string clientId = ParseJSONString(mqttJson, "clientId"); + if(!clientId.empty()) + config.clientId = clientId; + + std::string username = ParseJSONString(mqttJson, "username"); + if(!username.empty()) + config.username = username; + + std::string password = ParseJSONString(mqttJson, "password"); + if(!password.empty()) + config.password = password; + + SetJSONInt(config.port, mqttJson, "port"); + SetJSONInt(config.keepAlive, mqttJson, "keepAlive"); + SetJSONInt(config.qos, mqttJson, "qos"); + + if(!config.host.empty() && !config.topic.empty()) + config.enabled = true; + + return config; +} + Config loadConfiguration(const std::string &configFilePath, const Config ¤tConfig) { if(configFilePath.empty()) { @@ -327,6 +360,11 @@ AppConfig loadAppConfiguration(const AppConfig &commandLineConfig) { loadedConfig.overlay = overlayString; } + if(jsonDoc.contains("mqtt") && jsonDoc["mqtt"].isObject()) + { + loadedConfig.mqtt = ParseMqttConfigObject(jsonDoc["mqtt"].toObject()); + } + loadedConfig.paths = parsePathEntry(jsonDoc, baseRecursive, baseShuffle, baseSorted); if(loadedConfig.paths.count() <= 0) { diff --git a/src/appconfig.h b/src/appconfig.h index 503b565..9b14db5 100644 --- a/src/appconfig.h +++ b/src/appconfig.h @@ -49,6 +49,36 @@ struct ImmichConfig { } }; +struct MqttConfig { + bool enabled = false; + std::string host = "localhost"; + int port = 1883; + std::string topic = "slide/control"; + std::string clientId = "slide"; + std::string username = ""; + std::string password = ""; + int keepAlive = 30; + int qos = 0; + + bool operator==(const MqttConfig &b) const + { + return enabled == b.enabled && + host == b.host && + port == b.port && + topic == b.topic && + clientId == b.clientId && + username == b.username && + password == b.password && + keepAlive == b.keepAlive && + qos == b.qos; + } + + bool operator!=(const MqttConfig &b) const + { + return !operator==(b); + } +}; + // configuration options that apply to an image/folder of images struct Config { public: @@ -106,6 +136,7 @@ struct AppConfig : public Config { std::string overlay = ""; QString overlayHexRGB = "#FFFFFF"; QVector paths; + MqttConfig mqtt; bool debugMode = false; diff --git a/src/imageselector.cpp b/src/imageselector.cpp index 13afb5f..a47329c 100644 --- a/src/imageselector.cpp +++ b/src/imageselector.cpp @@ -371,4 +371,4 @@ const ImageDetails ListImageSelector::getNextImage(const ImageDisplayOptions& ba } } while(true); -} \ No newline at end of file +} diff --git a/src/imageswitcher.cpp b/src/imageswitcher.cpp index e0b2fa0..629a08a 100644 --- a/src/imageswitcher.cpp +++ b/src/imageswitcher.cpp @@ -21,6 +21,8 @@ ImageSwitcher::ImageSwitcher(MainWindow& w, unsigned int timeoutMsec, std::uniqu void ImageSwitcher::updateImage() { + if (paused) + return; if(reloadConfigIfNeeded) { reloadConfigIfNeeded(window, this); @@ -60,10 +62,64 @@ void ImageSwitcher::setConfigFileReloader(std::function& selectorIn) { selector = std::move(selectorIn); } + +void ImageSwitcher::pause() +{ + paused = true; + timer.stop(); + timerNoContent.stop(); +} + +void ImageSwitcher::resume() +{ + if (!paused) + return; + paused = false; + timer.start(timeout); + scheduleImageUpdate(); +} + +void ImageSwitcher::stepOnce() +{ + bool wasPaused = paused; + if (wasPaused) + paused = false; + updateImage(); + if (wasPaused) + { + paused = true; + timer.stop(); + timerNoContent.stop(); + } +} + +void ImageSwitcher::restart(std::unique_ptr& selectorIn) +{ + paused = false; + timerNoContent.stop(); + setImageSelector(selectorIn); + timer.start(timeout); + scheduleImageUpdate(); +} + +bool ImageSwitcher::skipToNextFolder() +{ + auto *listSelector = dynamic_cast(selector.get()); + if (!listSelector) + return false; + stepOnce(); + return true; +} + +bool ImageSwitcher::isPaused() const +{ + return paused; +} diff --git a/src/imageswitcher.h b/src/imageswitcher.h index 4f501d7..0a1e414 100644 --- a/src/imageswitcher.h +++ b/src/imageswitcher.h @@ -19,6 +19,12 @@ public: void setConfigFileReloader(std::function reloadConfigIfNeededIn); void setRotationTime(unsigned int timeoutMsec); void setImageSelector(std::unique_ptr& selector); + void pause(); + void resume(); + void stepOnce(); + void restart(std::unique_ptr& selector); + bool skipToNextFolder(); + bool isPaused() const; public slots: void updateImage(); @@ -30,6 +36,7 @@ private: const unsigned int timeoutNoContent = 5 * 1000; // 5 sec QTimer timerNoContent; std::function reloadConfigIfNeeded; + bool paused = false; }; #endif // IMAGESWITCHER_H diff --git a/src/main.cpp b/src/main.cpp index 81b8659..5ed2118 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ #include "imageswitcher.h" #include "pathtraverser.h" #include "immichpathtraverser.h" +#include "mqttcontroller.h" #include "overlay.h" #include "appconfig.h" #include "logger.h" @@ -275,6 +276,24 @@ int main(int argc, char *argv[]) w.setImageSwitcher(&switcher); std::function reloader = [&appConfig](MainWindow &w, ImageSwitcher *switcher) { ReloadConfigIfNeeded(appConfig, w, switcher); }; switcher.setConfigFileReloader(reloader); + + std::unique_ptr mqttController; + if (appConfig.mqtt.enabled) + { + mqttController = std::unique_ptr(new MqttController(appConfig.mqtt, &a)); + QObject::connect(mqttController.get(), &MqttController::play, [&switcher]() { switcher.resume(); }); + QObject::connect(mqttController.get(), &MqttController::pause, [&switcher]() { switcher.pause(); }); + QObject::connect(mqttController.get(), &MqttController::nextImage, [&switcher]() { switcher.stepOnce(); }); + QObject::connect(mqttController.get(), &MqttController::nextFolder, [&switcher]() { + if (!switcher.skipToNextFolder()) + switcher.stepOnce(); + }); + QObject::connect(mqttController.get(), &MqttController::restart, [&appConfig, &switcher]() { + std::unique_ptr newSelector = GetSelectorForApp(appConfig); + switcher.restart(newSelector); + }); + mqttController->start(); + } switcher.start(); return a.exec(); } diff --git a/src/mqttcontroller.cpp b/src/mqttcontroller.cpp new file mode 100644 index 0000000..3266ad8 --- /dev/null +++ b/src/mqttcontroller.cpp @@ -0,0 +1,152 @@ +#include "mqttcontroller.h" +#include "logger.h" + +#include + +#include + +namespace { +bool g_mqttInitialized = false; +} + +MqttController::MqttController(const MqttConfig &configIn, QObject *parent) + : QObject(parent), + config(configIn) +{ + if (!g_mqttInitialized) + { + mosquitto_lib_init(); + g_mqttInitialized = true; + } +} + +MqttController::~MqttController() +{ + if (client) + { + mosquitto_disconnect(client); + mosquitto_loop_stop(client, true); + mosquitto_destroy(client); + client = nullptr; + } +} + +void MqttController::start() +{ + if (!config.enabled) + { + Log("MQTT disabled or missing host/topic."); + return; + } + + QString clientId = QString::fromStdString(config.clientId); + if (clientId.isEmpty()) + clientId = "slide"; + + client = mosquitto_new(clientId.toUtf8().constData(), true, this); + if (!client) + { + Log("MQTT: failed to create client."); + return; + } + + mosquitto_connect_callback_set(client, &MqttController::HandleConnect); + mosquitto_message_callback_set(client, &MqttController::HandleMessage); + + if (!config.username.empty()) + { + mosquitto_username_pw_set(client, config.username.c_str(), + config.password.empty() ? nullptr : config.password.c_str()); + } + + mosquitto_reconnect_delay_set(client, 2, 30, true); + + int rc = mosquitto_connect_async(client, config.host.c_str(), config.port, config.keepAlive); + if (rc != MOSQ_ERR_SUCCESS) + { + Log("MQTT connect failed: ", mosquitto_strerror(rc)); + return; + } + + rc = mosquitto_loop_start(client); + if (rc != MOSQ_ERR_SUCCESS) + { + Log("MQTT loop start failed: ", mosquitto_strerror(rc)); + return; + } +} + +void MqttController::HandleConnect(struct mosquitto *mosq, void *userdata, int rc) +{ + auto *self = static_cast(userdata); + if (!self || !mosq) + return; + if (rc != 0) + { + Log("MQTT connect error: ", mosquitto_strerror(rc)); + return; + } + self->connected = true; + self->subscribe(); +} + +void MqttController::subscribe() +{ + if (!client || !connected) + return; + int rc = mosquitto_subscribe(client, nullptr, config.topic.c_str(), config.qos); + if (rc != MOSQ_ERR_SUCCESS) + { + Log("MQTT subscribe failed: ", mosquitto_strerror(rc)); + } + else + { + Log("MQTT subscribed to ", config.topic); + } +} + +void MqttController::HandleMessage(struct mosquitto *mosq, void *userdata, const struct mosquitto_message *message) +{ + Q_UNUSED(mosq); + auto *self = static_cast(userdata); + if (!self || !message || !message->payload) + return; + + QString payload = QString::fromUtf8(static_cast(message->payload), message->payloadlen); + QMetaObject::invokeMethod(self, "handleCommand", Qt::QueuedConnection, Q_ARG(QString, payload)); +} + +void MqttController::handleCommand(const QString &payload) +{ + QString cmd = payload.trimmed().toLower(); + if (cmd.isEmpty()) + return; + + if (cmd == "play" || cmd == "resume") + { + emit play(); + return; + } + if (cmd == "pause") + { + emit pause(); + return; + } + if (cmd == "next" || cmd == "skip" || cmd == "next-image") + { + emit nextImage(); + return; + } + if (cmd == "next-folder" || cmd == "folder-next" || cmd == "skip-folder") + { + emit nextFolder(); + return; + } + if (cmd == "restart" || cmd == "reset") + { + emit restart(); + return; + } + + Log("MQTT unknown command: ", cmd.toStdString()); +} diff --git a/src/mqttcontroller.h b/src/mqttcontroller.h new file mode 100644 index 0000000..0d94a08 --- /dev/null +++ b/src/mqttcontroller.h @@ -0,0 +1,42 @@ +#ifndef MQTTCONTROLLER_H +#define MQTTCONTROLLER_H + +#include +#include + +#include "appconfig.h" + +struct mosquitto; +struct mosquitto_message; + +class MqttController : public QObject +{ + Q_OBJECT +public: + explicit MqttController(const MqttConfig &config, QObject *parent = nullptr); + ~MqttController(); + + void start(); + +signals: + void play(); + void pause(); + void nextImage(); + void nextFolder(); + void restart(); + +private slots: + void handleCommand(const QString &payload); + +private: + static void HandleConnect(struct mosquitto *mosq, void *userdata, int rc); + static void HandleMessage(struct mosquitto *mosq, void *userdata, const struct mosquitto_message *message); + + void subscribe(); + + MqttConfig config; + struct mosquitto *client = nullptr; + bool connected = false; +}; + +#endif // MQTTCONTROLLER_H diff --git a/src/slide.pro b/src/slide.pro index 0006704..a4ef1e0 100644 --- a/src/slide.pro +++ b/src/slide.pro @@ -37,6 +37,7 @@ SOURCES += \ pathtraverser.cpp \ immichpathtraverser.cpp \ immichclient.cpp \ + mqttcontroller.cpp \ overlay.cpp \ imageselector.cpp \ appconfig.cpp \ @@ -49,6 +50,7 @@ HEADERS += \ pathtraverser.h \ immichpathtraverser.h \ immichclient.h \ + mqttcontroller.h \ overlay.h \ imageswitcher.h \ imagestructs.h \ @@ -62,3 +64,4 @@ target.path = /usr/local/bin/ INSTALLS += target unix|win32: LIBS += -lexif +unix|win32: LIBS += -lmosquitto