4 Commits

Author SHA1 Message Date
a9c5139d55 add mqtt control
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-01-31 16:50:34 +11:00
7cc6056e7e fix image location
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-31 14:07:58 +11:00
7516eb4444 fix build
Some checks failed
continuous-integration/drone/push Build encountered an error
2026-01-31 14:07:32 +11:00
ef2403b3cd code
Some checks failed
continuous-integration/drone Build is failing
2026-01-31 14:02:01 +11:00
13 changed files with 420 additions and 6 deletions

32
.drone.yml Normal file
View File

@@ -0,0 +1,32 @@
kind: pipeline
type: docker
name: build
steps:
- name: build-deb
image: cache.coadcorp.com/library/ubuntu:22.04
environment:
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 libmosquitto-dev dpkg-dev fakeroot ca-certificates
- bash sbin/build_deb.sh "${DRONE_TAG:-${DRONE_COMMIT_SHA:0:8}}"
- ls -la dist
- name: gitea-release
image: cache.coadcorp.com/plugins/gitea-release
depends_on:
- build-deb
settings:
api_key:
from_secret: GITEA_TOKEN
base_url: https://git.coadcorp.com
files:
- dist/*.deb
draft: false
prerelease: false
title: ${DRONE_TAG}
note: Automated release for ${DRONE_TAG}
when:
event:
- tag

View File

@@ -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:

View File

@@ -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:

View File

@@ -5,6 +5,10 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERSION="${1:-${VERSION:-0.0.0}}"
VERSION="${VERSION#v}"
# Debian versions must start with a digit; fall back to 0.0.0+<sha/tag>.
if [[ ! "$VERSION" =~ ^[0-9] ]]; then
VERSION="0.0.0+${VERSION}"
fi
ARCH="${ARCH:-$(dpkg --print-architecture)}"
PACKAGE_NAME="slide"
@@ -27,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

View File

@@ -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 &currentConfig) {
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)
{

View File

@@ -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<PathEntry> paths;
MqttConfig mqtt;
bool debugMode = false;

View File

@@ -371,4 +371,4 @@ const ImageDetails ListImageSelector::getNextImage(const ImageDisplayOptions& ba
}
}
while(true);
}
}

View File

@@ -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<void(MainWindow &w, Imag
void ImageSwitcher::setRotationTime(unsigned int timeoutMsecIn)
{
timeout = timeoutMsecIn;
timer.start(timeout);
if (!paused)
timer.start(timeout);
}
void ImageSwitcher::setImageSelector(std::unique_ptr<ImageSelector>& 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<ImageSelector>& selectorIn)
{
paused = false;
timerNoContent.stop();
setImageSelector(selectorIn);
timer.start(timeout);
scheduleImageUpdate();
}
bool ImageSwitcher::skipToNextFolder()
{
auto *listSelector = dynamic_cast<ListImageSelector*>(selector.get());
if (!listSelector)
return false;
stepOnce();
return true;
}
bool ImageSwitcher::isPaused() const
{
return paused;
}

View File

@@ -19,6 +19,12 @@ public:
void setConfigFileReloader(std::function<void(MainWindow &w, ImageSwitcher *switcher)> reloadConfigIfNeededIn);
void setRotationTime(unsigned int timeoutMsec);
void setImageSelector(std::unique_ptr<ImageSelector>& selector);
void pause();
void resume();
void stepOnce();
void restart(std::unique_ptr<ImageSelector>& 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<void(MainWindow &w, ImageSwitcher *switcher)> reloadConfigIfNeeded;
bool paused = false;
};
#endif // IMAGESWITCHER_H

View File

@@ -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<void(MainWindow &w, ImageSwitcher *switcher)> reloader = [&appConfig](MainWindow &w, ImageSwitcher *switcher) { ReloadConfigIfNeeded(appConfig, w, switcher); };
switcher.setConfigFileReloader(reloader);
std::unique_ptr<MqttController> mqttController;
if (appConfig.mqtt.enabled)
{
mqttController = std::unique_ptr<MqttController>(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<ImageSelector> newSelector = GetSelectorForApp(appConfig);
switcher.restart(newSelector);
});
mqttController->start();
}
switcher.start();
return a.exec();
}

152
src/mqttcontroller.cpp Normal file
View File

@@ -0,0 +1,152 @@
#include "mqttcontroller.h"
#include "logger.h"
#include <QMetaObject>
#include <mosquitto.h>
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<MqttController *>(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<MqttController *>(userdata);
if (!self || !message || !message->payload)
return;
QString payload = QString::fromUtf8(static_cast<const char *>(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());
}

42
src/mqttcontroller.h Normal file
View File

@@ -0,0 +1,42 @@
#ifndef MQTTCONTROLLER_H
#define MQTTCONTROLLER_H
#include <QObject>
#include <QString>
#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

View File

@@ -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