From ee7c10c7ba3690cb50ba9a85bd83b08e118febbb Mon Sep 17 00:00:00 2001 From: Artur Brynka Date: Sat, 6 Jun 2026 20:37:48 +0200 Subject: [PATCH 1/9] docs: update README and add agent instructions --- AGENTS.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 27 ++++++++++++++++--- 2 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ff215e0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +pc-control is a minimalistic Windows 11 command-line tool that listens to MQTT commands to put the PC to sleep or turn off the monitor for energy saving. + +## Build Commands + +```bash +# Configure (MinGW installed via choco at default location) +cmake -B build -G "MinGW Makefiles" ^ + -DCMAKE_C_COMPILER="C:/ProgramData/mingw64/mingw64/bin/gcc.exe" ^ + -DCMAKE_MAKE_PROGRAM="C:/ProgramData/mingw64/mingw64/bin/mingw32-make.exe" + +# Build +mingw32-make -C build + +# Clean +mingw32-make -C build clean +``` + +Requires: CMake 3.16+, MinGW-w64 (via `choco install mingw`), Paho MQTT C library built and installed at `../paho.mqtt.c/install/`. + +See README.md for full setup instructions including Paho MQTT C build steps. + +## Usage + +```bash +# Console version (shows output) +pc-control.exe [--hide] [port] [hostname] + +# Hidden version (no window at all) +pc-control-hidden.exe [port] [hostname] +``` + +- `port` defaults to 1883 +- `hostname` defaults to system hostname (used in topics and client ID) + +MQTT topics (hostname-based for multi-PC support): +- `pc-control//sleep` - Put PC to sleep (command) +- `pc-control//monitor-off` - Turn off monitor (command) +- `pc-control//status` - `online`/`offline` (retained, LWT) +- `pc-control//version` - Version string (retained) + +Logs are written to `pc-control.log` in the working directory. + +## Architecture + +Two executables built from `src/main.c`: +- `pc-control.exe` - Console subsystem, shows terminal output +- `pc-control-hidden.exe` - GUI subsystem with `mainCRTStartup` entry point, no window + +Both use the same source code. The hidden version is built with `-mwindows -Wl,-e,mainCRTStartup` flags, which creates a GUI app that can still use regular `main()` function. + +Features: +- MQTT connection and message handling via Paho MQTT C +- LWT (Last Will and Testament) for offline detection +- Birth messages: publishes `online` status and version on connect +- Automatic reconnection with exponential backoff (1s to 30s) +- Hostname sanitization (lowercase, special chars handled) +- Windows API calls: `SetSuspendState()` for sleep, `SendMessage(HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER, 2)` for monitor off +- Simple file-based logging with timestamps + +## Version + +Update `VERSION` define in `src/main.c` when releasing. + +## Key Windows APIs + +- `SetSuspendState(FALSE, FALSE, FALSE)` - Sleep mode (requires linking `powrprof.lib`) +- `SendMessage` with `SC_MONITORPOWER` - Monitor power control + +## Build Notes + +The hidden version uses a technique from [Switchy](https://github.com/erryox/Switchy): +- Link with `-mwindows` (GUI subsystem) +- Set entry point to `mainCRTStartup` via `-Wl,-e,mainCRTStartup` +- This allows using regular `main()` while having no console window diff --git a/README.md b/README.md index 0342e15..61bc3e4 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Minimalistic Windows 11 command-line tool for remote PC power management via MQT ## Dependencies -- [Paho MQTT C Client](https://github.com/eclipse/paho.mqtt.c) +- [Paho MQTT C Client](https://github.com/eclipse-paho/paho.mqtt.c) - CMake 3.16+ - MinGW-w64 @@ -29,7 +29,7 @@ choco install cmake mingw -y ```bash # Clone the library (next to pc-control directory) -git clone https://github.com/eclipse/paho.mqtt.c.git ../paho.mqtt.c +git clone https://github.com/eclipse-paho/paho.mqtt.c.git ../paho.mqtt.c # Configure with MinGW (adjust paths if MinGW is installed elsewhere) cmake -B ../paho.mqtt.c/build -S ../paho.mqtt.c -G "MinGW Makefiles" ^ @@ -69,7 +69,7 @@ The build produces two standalone executables in `build/` (no DLLs required): ## Usage ```bash -pc-control.exe [port] [hostname] +pc-control.exe [--hide] [port] [hostname] ``` | Argument | Required | Default | Description | @@ -80,6 +80,8 @@ pc-control.exe [port] [hostname] | port | No | 1883 | MQTT broker port | | hostname | No | System hostname | Device name used in topics | +`--hide` hides the console window after startup. For autostart, prefer `pc-control-hidden.exe`. + Examples: ```bash # Minimal (uses default port 1883 and system hostname) @@ -165,6 +167,23 @@ Commands are logged to `pc-control.log` in the working directory with timestamps [2026-02-01 16:05:01] MONITOR_OFF command received - turning off monitor [2026-02-01 16:10:15] SLEEP command received - entering sleep mode ``` +## CI/CD & Releasing + +The project uses GitHub Actions to build and release automatically. + +**On every PR to `main`:** both executables are built and uploaded as artifacts for testing. + +**To release a new version:** + +1. Update `VERSION` in `src/main.c` +2. Work on a feature branch, open a PR, and merge to `main` +3. Tag the merge commit and push the tag: + ```bash + git tag v1.2.0 + git push origin v1.2.0 + ``` +4. GitHub Actions builds and creates a [Release](../../releases) with both `.exe` files attached + ## Authors * Original creator: [Artur Brynka](https://github.com/FireHawken) @@ -175,4 +194,4 @@ Commands are logged to `pc-control.log` in the working directory with timestamps |---------|------------|---------------------------------------------------------------------------------------------------------------| | 1.1.0 | 02.03.2026 | Add new, separate "invisible" executable and statically link paho-mqtt, so only 1 file is required for distribution | | 1.0.1 | 02.02.2026 | Add logging to file, improved Home Assistant support, status topic (`online`/`offline`) via LWT, monitor off command. | -| 1.0.0 | 01.02.2026 | Initial release | \ No newline at end of file +| 1.0.0 | 01.02.2026 | Initial release | From 11eb500feee84be05d46bc53f1a615cb9d55093c Mon Sep 17 00:00:00 2001 From: Artur Brynka Date: Sat, 6 Jun 2026 20:41:09 +0200 Subject: [PATCH 2/9] refactor: defer MQTT command execution --- REFACTORING_PLAN.md | 30 ++++++++++++++++++++++++++++++ src/main.c | 25 +++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 REFACTORING_PLAN.md diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md new file mode 100644 index 0000000..f639d98 --- /dev/null +++ b/REFACTORING_PLAN.md @@ -0,0 +1,30 @@ +# Refactoring Plan + +## Goal + +Improve stability and reliability of `pc-control` while keeping the tool small and predictable. + +## Work Items + +1. Move command execution out of the MQTT callback and into the main loop. + - The callback should only record pending work and return quickly. + - The main loop should execute Windows power actions. +2. Replace shared `volatile` state with thread-safe synchronization. + - Use Windows `Interlocked*` APIs or C11 atomics for flags touched by callbacks. +3. Validate command payloads and ignore retained command messages. + - Avoid accidental sleep/monitor-off after subscribing to retained command topics. +4. Add timeouts and error logging around Windows power APIs. + - Use `SendMessageTimeout` for monitor-off. + - Log `SetSuspendState` failures with `GetLastError`. +5. Make status publishing more deterministic. + - Wait for retained status/version delivery where it matters, especially shutdown. +6. Improve process and logging robustness. + - Add a single-instance guard per hostname. + - Move logs to a predictable user-writable location and consider rotation. +7. Tighten input validation and build checks. + - Validate broker address/port and check `snprintf` truncation. + - Enable compiler warnings in CMake/CI. + +## Current Step + +Implement item 1: move command execution from `message_arrived` to the main loop. diff --git a/src/main.c b/src/main.c index cfebfcb..be63f25 100644 --- a/src/main.c +++ b/src/main.c @@ -21,8 +21,13 @@ #define STATUS_ONLINE "online" #define STATUS_OFFLINE "offline" +#define COMMAND_NONE 0 +#define COMMAND_SLEEP 0x1 +#define COMMAND_MONITOR_OFF 0x2 + static volatile int running = 1; static volatile int connected = 0; +static volatile LONG pending_commands = COMMAND_NONE; static char topic_sleep[MAX_TOPIC_LEN]; static char topic_monitor_off[MAX_TOPIC_LEN]; static char topic_status[MAX_TOPIC_LEN]; @@ -72,15 +77,29 @@ static void do_monitor_off(void) { SendMessage(HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER, 2); } +static void queue_command(LONG command) { + InterlockedOr(&pending_commands, command); +} + +static void process_pending_commands(void) { + LONG commands = InterlockedExchange(&pending_commands, COMMAND_NONE); + + if ((commands & COMMAND_SLEEP) != 0) { + do_sleep(); + } else if ((commands & COMMAND_MONITOR_OFF) != 0) { + do_monitor_off(); + } +} + static int message_arrived(void *context, char *topic, int topic_len, MQTTClient_message *msg) { (void)context; (void)topic_len; (void)msg; if (strcmp(topic, topic_sleep) == 0) { - do_sleep(); + queue_command(COMMAND_SLEEP); } else if (strcmp(topic, topic_monitor_off) == 0) { - do_monitor_off(); + queue_command(COMMAND_MONITOR_OFF); } MQTTClient_freeMessage(&msg); @@ -314,6 +333,8 @@ int main(int argc, char *argv[]) { int reconnect_delay = RECONNECT_DELAY_BASE_MS; while (running) { + process_pending_commands(); + if (!connected) { rc = try_connect(client, &conn_opts, address); if (rc != MQTTCLIENT_SUCCESS) { From 758546026d32841c3aced049f21614cb38081b09 Mon Sep 17 00:00:00 2001 From: Artur Brynka Date: Sat, 6 Jun 2026 20:42:58 +0200 Subject: [PATCH 3/9] refactor: synchronize shared runtime flags --- REFACTORING_PLAN.md | 5 +++-- src/main.c | 50 +++++++++++++++++++++++++++++++++------------ 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index f639d98..42a61ac 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -25,6 +25,7 @@ Improve stability and reliability of `pc-control` while keeping the tool small a - Validate broker address/port and check `snprintf` truncation. - Enable compiler warnings in CMake/CI. -## Current Step +## Progress -Implement item 1: move command execution from `message_arrived` to the main loop. +- Done: item 1, command execution now happens in the main loop. +- Current: item 2, replace shared `volatile` state with thread-safe synchronization. diff --git a/src/main.c b/src/main.c index be63f25..5572ba4 100644 --- a/src/main.c +++ b/src/main.c @@ -25,8 +25,8 @@ #define COMMAND_SLEEP 0x1 #define COMMAND_MONITOR_OFF 0x2 -static volatile int running = 1; -static volatile int connected = 0; +static volatile LONG running = 1; +static volatile LONG connected = 0; static volatile LONG pending_commands = COMMAND_NONE; static char topic_sleep[MAX_TOPIC_LEN]; static char topic_monitor_off[MAX_TOPIC_LEN]; @@ -34,6 +34,30 @@ static char topic_status[MAX_TOPIC_LEN]; static char topic_version[MAX_TOPIC_LEN]; static char client_id[MAX_HOSTNAME_LEN + 32]; +static int read_flag(volatile LONG *flag) { + return InterlockedCompareExchange(flag, 0, 0) != 0; +} + +static void write_flag(volatile LONG *flag, LONG value) { + InterlockedExchange(flag, value); +} + +static int is_running(void) { + return read_flag(&running); +} + +static void request_stop(void) { + write_flag(&running, 0); +} + +static int is_connected(void) { + return read_flag(&connected); +} + +static void set_connected(LONG value) { + write_flag(&connected, value); +} + static void log_action(const char *action) { FILE *f = fopen(LOG_FILE, "a"); if (f) { @@ -109,7 +133,7 @@ static int message_arrived(void *context, char *topic, int topic_len, MQTTClient static void connection_lost(void *context, char *cause) { (void)context; - connected = 0; + set_connected(0); #ifndef HIDDEN_BUILD fprintf(stderr, "Connection lost: %s\n", cause ? cause : "unknown"); #endif @@ -120,23 +144,23 @@ static BOOL WINAPI console_handler(DWORD signal) { switch (signal) { case CTRL_C_EVENT: log_action("Received CTRL_C signal"); - running = 0; + request_stop(); return TRUE; case CTRL_BREAK_EVENT: log_action("Received CTRL_BREAK signal"); - running = 0; + request_stop(); return TRUE; case CTRL_CLOSE_EVENT: log_action("Console window closed"); - running = 0; + request_stop(); return TRUE; case CTRL_LOGOFF_EVENT: log_action("User logoff detected"); - running = 0; + request_stop(); return TRUE; case CTRL_SHUTDOWN_EVENT: log_action("System shutdown detected"); - running = 0; + request_stop(); return TRUE; } return FALSE; @@ -195,7 +219,7 @@ static int try_connect(MQTTClient client, MQTTClient_connectOptions *conn_opts, return rc; } - connected = 1; + set_connected(1); log_action("Connected to MQTT broker"); printf("Connected to %s\n", address); printf("Status: %s -> %s\n", topic_status, STATUS_ONLINE); @@ -332,16 +356,16 @@ int main(int argc, char *argv[]) { int reconnect_delay = RECONNECT_DELAY_BASE_MS; - while (running) { + while (is_running()) { process_pending_commands(); - if (!connected) { + if (!is_connected()) { rc = try_connect(client, &conn_opts, address); if (rc != MQTTCLIENT_SUCCESS) { fprintf(stderr, "Connection attempt failed (%d), retrying in %d ms...\n", rc, reconnect_delay); int slept = 0; - while (running && slept < reconnect_delay) { + while (is_running() && slept < reconnect_delay) { Sleep(100); slept += 100; } @@ -360,7 +384,7 @@ int main(int argc, char *argv[]) { log_action("Shutting down"); /* Publish offline status on graceful shutdown */ - if (connected) { + if (is_connected()) { publish_retained(client, topic_status, STATUS_OFFLINE); MQTTClient_disconnect(client, 1000); } From 873cf97644f19fe185a8103389d769c9248867b4 Mon Sep 17 00:00:00 2001 From: Artur Brynka Date: Sat, 6 Jun 2026 20:46:16 +0200 Subject: [PATCH 4/9] refactor: validate MQTT command messages --- AGENTS.md | 4 ++-- README.md | 6 +++-- REFACTORING_PLAN.md | 4 +++- src/main.c | 55 ++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 59 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ff215e0..67aeef3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,8 +39,8 @@ pc-control-hidden.exe [port] [hostname] - `hostname` defaults to system hostname (used in topics and client ID) MQTT topics (hostname-based for multi-PC support): -- `pc-control//sleep` - Put PC to sleep (command) -- `pc-control//monitor-off` - Turn off monitor (command) +- `pc-control//sleep` - Put PC to sleep (command payload: `1`, `true`, `on`, or `yes`; retained messages are ignored) +- `pc-control//monitor-off` - Turn off monitor (command payload: `1`, `true`, `on`, or `yes`; retained messages are ignored) - `pc-control//status` - `online`/`offline` (retained, LWT) - `pc-control//version` - Version string (retained) diff --git a/README.md b/README.md index 61bc3e4..1e54126 100644 --- a/README.md +++ b/README.md @@ -119,11 +119,13 @@ Topics include the hostname to support multiple PCs: | Topic | Type | Description | |-------|------|-------------| -| `pc-control//sleep` | Command | Put PC to sleep (send any message) | -| `pc-control//monitor-off` | Command | Turn off monitor (send any message) | +| `pc-control//sleep` | Command | Put PC to sleep (send `1`, `true`, `on`, or `yes`) | +| `pc-control//monitor-off` | Command | Turn off monitor (send `1`, `true`, `on`, or `yes`) | | `pc-control//status` | Status | `online` / `offline` (retained) | | `pc-control//version` | Info | Version string, e.g. `1.0.0` (retained) | +Retained messages on command topics are ignored to avoid accidental actions after reconnect. + ### Status tracking (LWT) The client publishes `online` to the status topic on connect, and the broker automatically publishes `offline` via Last Will and Testament when the client disconnects unexpectedly (crash, network failure, PC sleep). diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 42a61ac..f8ce729 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -28,4 +28,6 @@ Improve stability and reliability of `pc-control` while keeping the tool small a ## Progress - Done: item 1, command execution now happens in the main loop. -- Current: item 2, replace shared `volatile` state with thread-safe synchronization. +- Done: item 2, shared runtime flags now use Windows `Interlocked*` helpers. +- Done: item 3, command messages now require explicit payloads and retained command messages are ignored. +- Current: item 4, add timeouts and error logging around Windows power APIs. diff --git a/src/main.c b/src/main.c index 5572ba4..9eae99c 100644 --- a/src/main.c +++ b/src/main.c @@ -115,15 +115,60 @@ static void process_pending_commands(void) { } } +static int payload_token_equals(const char *payload, size_t payload_len, const char *token) { + size_t token_len = strlen(token); + if (payload_len != token_len) { + return 0; + } + + for (size_t i = 0; i < payload_len; i++) { + if (tolower((unsigned char)payload[i]) != token[i]) { + return 0; + } + } + + return 1; +} + +static int is_valid_command_payload(const MQTTClient_message *msg) { + const char *payload; + size_t start = 0; + size_t end; + + if (msg == NULL || msg->payload == NULL || msg->payloadlen <= 0) { + return 0; + } + + payload = (const char *)msg->payload; + end = (size_t)msg->payloadlen; + + while (start < end && isspace((unsigned char)payload[start])) { + start++; + } + while (end > start && isspace((unsigned char)payload[end - 1])) { + end--; + } + + return payload_token_equals(payload + start, end - start, "1") || + payload_token_equals(payload + start, end - start, "true") || + payload_token_equals(payload + start, end - start, "on") || + payload_token_equals(payload + start, end - start, "yes"); +} + +static int should_accept_command_message(const MQTTClient_message *msg) { + return msg != NULL && !msg->retained && is_valid_command_payload(msg); +} + static int message_arrived(void *context, char *topic, int topic_len, MQTTClient_message *msg) { (void)context; (void)topic_len; - (void)msg; - if (strcmp(topic, topic_sleep) == 0) { - queue_command(COMMAND_SLEEP); - } else if (strcmp(topic, topic_monitor_off) == 0) { - queue_command(COMMAND_MONITOR_OFF); + if (should_accept_command_message(msg)) { + if (strcmp(topic, topic_sleep) == 0) { + queue_command(COMMAND_SLEEP); + } else if (strcmp(topic, topic_monitor_off) == 0) { + queue_command(COMMAND_MONITOR_OFF); + } } MQTTClient_freeMessage(&msg); From 2ea725d94b055ca62206de3e29cb026077b3bb0f Mon Sep 17 00:00:00 2001 From: Artur Brynka Date: Sat, 6 Jun 2026 20:48:48 +0200 Subject: [PATCH 5/9] refactor: harden Windows power actions --- AGENTS.md | 4 ++-- REFACTORING_PLAN.md | 3 ++- src/main.c | 26 ++++++++++++++++++++++++-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 67aeef3..87ee9f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ Features: - Birth messages: publishes `online` status and version on connect - Automatic reconnection with exponential backoff (1s to 30s) - Hostname sanitization (lowercase, special chars handled) -- Windows API calls: `SetSuspendState()` for sleep, `SendMessage(HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER, 2)` for monitor off +- Windows API calls: `SetSuspendState()` for sleep, `SendMessageTimeout(HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER, 2, ...)` for monitor off - Simple file-based logging with timestamps ## Version @@ -70,7 +70,7 @@ Update `VERSION` define in `src/main.c` when releasing. ## Key Windows APIs - `SetSuspendState(FALSE, FALSE, FALSE)` - Sleep mode (requires linking `powrprof.lib`) -- `SendMessage` with `SC_MONITORPOWER` - Monitor power control +- `SendMessageTimeout` with `SC_MONITORPOWER` - Monitor power control with timeout ## Build Notes diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index f8ce729..3ebe4bd 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -30,4 +30,5 @@ Improve stability and reliability of `pc-control` while keeping the tool small a - Done: item 1, command execution now happens in the main loop. - Done: item 2, shared runtime flags now use Windows `Interlocked*` helpers. - Done: item 3, command messages now require explicit payloads and retained command messages are ignored. -- Current: item 4, add timeouts and error logging around Windows power APIs. +- Done: item 4, Windows power API calls now have timeout/failure logging. +- Current: item 5, make status publishing more deterministic. diff --git a/src/main.c b/src/main.c index 9eae99c..6e48b7d 100644 --- a/src/main.c +++ b/src/main.c @@ -15,6 +15,7 @@ #define LOG_FILE "pc-control.log" #define RECONNECT_DELAY_BASE_MS 1000 #define RECONNECT_DELAY_MAX_MS 30000 +#define MONITOR_POWER_TIMEOUT_MS 2000 #define MAX_HOSTNAME_LEN 256 #define MAX_TOPIC_LEN 512 @@ -69,6 +70,19 @@ static void log_action(const char *action) { } } +static void log_windows_error(const char *action, DWORD error_code) { + char msg[256]; + + if (error_code == ERROR_SUCCESS) { + snprintf(msg, sizeof(msg), "%s failed or timed out", action); + } else { + snprintf(msg, sizeof(msg), "%s failed with GetLastError=%lu", + action, (unsigned long)error_code); + } + + log_action(msg); +} + static void sanitize_hostname(char *dest, const char *src, size_t dest_size) { size_t j = 0; for (size_t i = 0; src[i] && j < dest_size - 1; i++) { @@ -93,12 +107,20 @@ static int get_system_hostname(char *buf, size_t buf_size) { static void do_sleep(void) { log_action("SLEEP command received - entering sleep mode"); - SetSuspendState(FALSE, FALSE, FALSE); + if (!SetSuspendState(FALSE, FALSE, FALSE)) { + log_windows_error("SetSuspendState", GetLastError()); + } } static void do_monitor_off(void) { + DWORD_PTR result = 0; + log_action("MONITOR_OFF command received - turning off monitor"); - SendMessage(HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER, 2); + SetLastError(ERROR_SUCCESS); + if (SendMessageTimeout(HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER, (LPARAM)2, + SMTO_ABORTIFHUNG, MONITOR_POWER_TIMEOUT_MS, &result) == 0) { + log_windows_error("SendMessageTimeout(SC_MONITORPOWER)", GetLastError()); + } } static void queue_command(LONG command) { From 08608e5725295eb9126f432da440dacfbb742998 Mon Sep 17 00:00:00 2001 From: Artur Brynka Date: Sat, 6 Jun 2026 21:53:16 +0200 Subject: [PATCH 6/9] ci: pin Paho MQTT C version --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 473fcc1..eb72d7c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,7 +18,7 @@ jobs: run: choco install mingw --yes --no-progress - name: Clone Paho MQTT C - run: git clone --depth 1 https://github.com/eclipse-paho/paho.mqtt.c.git paho.mqtt.c + run: git clone --branch v1.3.15 --depth 1 https://github.com/eclipse-paho/paho.mqtt.c.git paho.mqtt.c - name: Build Paho MQTT C run: | From 4354d104458b4cb481a3a366618a4ce394055bdc Mon Sep 17 00:00:00 2001 From: Artur Brynka Date: Sat, 6 Jun 2026 22:13:35 +0200 Subject: [PATCH 7/9] chore: add gitignore for build artifacts --- .gitignore | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..194eea1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Build +build/ +CMakeFiles/ +CMakeCache.txt +cmake_install.cmake +compile_commands.json +Makefile +*.exe +*.dll +*.lib +*.a +*.obj +*.o +*.pdb +*.ilk + +# Logs +*.log + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# AI generated files +.claude/ +.cursor/ From a9e479f643fdb1b196e48148f11734812c478ba5 Mon Sep 17 00:00:00 2001 From: Artur Brynka Date: Sun, 7 Jun 2026 15:21:28 +0200 Subject: [PATCH 8/9] feat: add executable version metadata --- AGENTS.md | 2 +- CMakeLists.txt | 6 +++--- README.md | 2 +- src/main.c | 10 +++++++++- src/version.h | 11 +++++++++++ src/version.rc | 27 +++++++++++++++++++++++++++ 6 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 src/version.h create mode 100644 src/version.rc diff --git a/AGENTS.md b/AGENTS.md index 87ee9f0..dc66574 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ Features: ## Version -Update `VERSION` define in `src/main.c` when releasing. +Update `VERSION` and version number components in `src/version.h` when releasing. ## Key Windows APIs diff --git a/CMakeLists.txt b/CMakeLists.txt index 9a359ee..31798a0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(pc-control C) +project(pc-control C RC) set(CMAKE_C_STANDARD 11) @@ -19,12 +19,12 @@ set(COMMON_LIBS ) # Console version (for debugging/interactive use) -add_executable(pc-control src/main.c) +add_executable(pc-control src/main.c src/version.rc) target_link_libraries(pc-control ${COMMON_LIBS}) # Hidden version (GUI subsystem, no console window at all) # Uses mainCRTStartup entry point to keep regular main() function -add_executable(pc-control-hidden src/main.c) +add_executable(pc-control-hidden src/main.c src/version.rc) target_compile_definitions(pc-control-hidden PRIVATE HIDDEN_BUILD) target_link_libraries(pc-control-hidden ${COMMON_LIBS}) if(MINGW) diff --git a/README.md b/README.md index 1e54126..efb4226 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ The project uses GitHub Actions to build and release automatically. **To release a new version:** -1. Update `VERSION` in `src/main.c` +1. Update `VERSION` in `src/version.h` 2. Work on a feature branch, open a PR, and merge to `main` 3. Tag the merge commit and push the tag: ```bash diff --git a/src/main.c b/src/main.c index 6e48b7d..96b10f1 100644 --- a/src/main.c +++ b/src/main.c @@ -7,7 +7,8 @@ #include #include -#define VERSION "1.1.0" +#include "version.h" + #define TOPIC_PREFIX "pc-control" #define DEFAULT_PORT "1883" #define CLIENT_ID_PREFIX "pc-control-" @@ -83,6 +84,12 @@ static void log_windows_error(const char *action, DWORD error_code) { log_action(msg); } +static void log_startup(void) { + char msg[128]; + snprintf(msg, sizeof(msg), "pc-control v%s started", VERSION); + log_action(msg); +} + static void sanitize_hostname(char *dest, const char *src, size_t dest_size) { size_t j = 0; for (size_t i = 0; src[i] && j < dest_size - 1; i++) { @@ -386,6 +393,7 @@ int main(int argc, char *argv[]) { printf("pc-control v%s\n", VERSION); printf("Device: %s\n", hostname); + log_startup(); MQTTClient client; MQTTClient_connectOptions conn_opts = MQTTClient_connectOptions_initializer; diff --git a/src/version.h b/src/version.h new file mode 100644 index 0000000..2cfbc70 --- /dev/null +++ b/src/version.h @@ -0,0 +1,11 @@ +#ifndef PC_CONTROL_VERSION_H +#define PC_CONTROL_VERSION_H + +#define PC_CONTROL_VERSION_MAJOR 1 +#define PC_CONTROL_VERSION_MINOR 1 +#define PC_CONTROL_VERSION_PATCH 0 + +#define VERSION "1.1.0" +#define PC_CONTROL_VERSION_COMMA PC_CONTROL_VERSION_MAJOR,PC_CONTROL_VERSION_MINOR,PC_CONTROL_VERSION_PATCH,0 + +#endif diff --git a/src/version.rc b/src/version.rc new file mode 100644 index 0000000..892a31b --- /dev/null +++ b/src/version.rc @@ -0,0 +1,27 @@ +#include +#include "version.h" + +1 VERSIONINFO +FILEVERSION PC_CONTROL_VERSION_COMMA +PRODUCTVERSION PC_CONTROL_VERSION_COMMA +FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +FILEFLAGS 0x0L +FILEOS VOS_NT_WINDOWS32 +FILETYPE VFT_APP +FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "FileDescription", "pc-control MQTT power control\0" + VALUE "FileVersion", VERSION "\0" + VALUE "ProductName", "pc-control\0" + VALUE "ProductVersion", VERSION "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x0409, 1200 + END +END From daaf89e31146372e34fd355a5302e8cb8a9ec79a Mon Sep 17 00:00:00 2001 From: Artur Brynka Date: Tue, 9 Jun 2026 14:20:09 +0200 Subject: [PATCH 9/9] fix: accept empty monitor-off payload --- README.md | 6 +- src/main.c | 154 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/version.h | 4 +- 3 files changed, 156 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index efb4226..e91846a 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Topics include the hostname to support multiple PCs: | Topic | Type | Description | |-------|------|-------------| | `pc-control//sleep` | Command | Put PC to sleep (send `1`, `true`, `on`, or `yes`) | -| `pc-control//monitor-off` | Command | Turn off monitor (send `1`, `true`, `on`, or `yes`) | +| `pc-control//monitor-off` | Command | Turn off monitor (empty payload is accepted; `1`, `true`, `on`, or `yes` are also accepted) | | `pc-control//status` | Status | `online` / `offline` (retained) | | `pc-control//version` | Info | Version string, e.g. `1.0.0` (retained) | @@ -155,6 +155,9 @@ mosquitto_pub -h 192.168.1.100 -u myuser -P mypassword -t "pc-control/desktop-pc # Turn off monitor mosquitto_pub -h 192.168.1.100 -u myuser -P mypassword -t "pc-control/desktop-pc/monitor-off" -m "1" +# Turn off monitor with an empty trigger payload +mosquitto_pub -h 192.168.1.100 -u myuser -P mypassword -t "pc-control/desktop-pc/monitor-off" -n + # Check status (subscribe) mosquitto_sub -h 192.168.1.100 -u myuser -P mypassword -t "pc-control/+/status" -v ``` @@ -194,6 +197,7 @@ The project uses GitHub Actions to build and release automatically. | Version | Date | Notes | |---------|------------|---------------------------------------------------------------------------------------------------------------| +| 1.1.1 | 09.06.2026 | Accept empty MQTT payload as a trigger for `monitor-off` and improve diagnostic logging. | | 1.1.0 | 02.03.2026 | Add new, separate "invisible" executable and statically link paho-mqtt, so only 1 file is required for distribution | | 1.0.1 | 02.02.2026 | Add logging to file, improved Home Assistant support, status topic (`online`/`offline`) via LWT, monitor off command. | | 1.0.0 | 01.02.2026 | Initial release | diff --git a/src/main.c b/src/main.c index 96b10f1..8ad0810 100644 --- a/src/main.c +++ b/src/main.c @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -71,6 +72,17 @@ static void log_action(const char *action) { } } +static void log_actionf(const char *fmt, ...) { + char msg[1024]; + va_list args; + + va_start(args, fmt); + vsnprintf(msg, sizeof(msg), fmt, args); + va_end(args); + + log_action(msg); +} + static void log_windows_error(const char *action, DWORD error_code) { char msg[256]; @@ -90,6 +102,40 @@ static void log_startup(void) { log_action(msg); } +static void get_user_object_name(HANDLE handle, char *buf, size_t buf_size) { + DWORD needed = 0; + + if (buf_size == 0) { + return; + } + + if (handle == NULL || + !GetUserObjectInformationA(handle, UOI_NAME, buf, (DWORD)buf_size, &needed)) { + snprintf(buf, buf_size, ""); + } +} + +static void log_runtime_config(const char *address, const char *hostname) { + DWORD pid = GetCurrentProcessId(); + DWORD session_id = 0; + char window_station[128]; + char desktop[128]; + + if (!ProcessIdToSessionId(pid, &session_id)) { + session_id = (DWORD)-1; + } + + get_user_object_name(GetProcessWindowStation(), window_station, sizeof(window_station)); + get_user_object_name(GetThreadDesktop(GetCurrentThreadId()), desktop, sizeof(desktop)); + + log_actionf("Process context: pid=%lu, session_id=%lu, window_station=%s, desktop=%s", + (unsigned long)pid, (unsigned long)session_id, window_station, desktop); + log_actionf("Runtime config: address=%s, hostname=%s, client_id=%s", + address, hostname, client_id); + log_actionf("Topics: sleep=%s, monitor_off=%s, status=%s, version=%s", + topic_sleep, topic_monitor_off, topic_status, topic_version); +} + static void sanitize_hostname(char *dest, const char *src, size_t dest_size) { size_t j = 0; for (size_t i = 0; src[i] && j < dest_size - 1; i++) { @@ -116,6 +162,8 @@ static void do_sleep(void) { log_action("SLEEP command received - entering sleep mode"); if (!SetSuspendState(FALSE, FALSE, FALSE)) { log_windows_error("SetSuspendState", GetLastError()); + } else { + log_action("SetSuspendState returned success"); } } @@ -127,6 +175,9 @@ static void do_monitor_off(void) { if (SendMessageTimeout(HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER, (LPARAM)2, SMTO_ABORTIFHUNG, MONITOR_POWER_TIMEOUT_MS, &result) == 0) { log_windows_error("SendMessageTimeout(SC_MONITORPOWER)", GetLastError()); + } else { + log_actionf("SendMessageTimeout(SC_MONITORPOWER) returned success, result=%lu", + (unsigned long)result); } } @@ -184,19 +235,96 @@ static int is_valid_command_payload(const MQTTClient_message *msg) { payload_token_equals(payload + start, end - start, "yes"); } -static int should_accept_command_message(const MQTTClient_message *msg) { - return msg != NULL && !msg->retained && is_valid_command_payload(msg); +static int is_empty_payload(const MQTTClient_message *msg) { + return msg == NULL || msg->payload == NULL || msg->payloadlen <= 0; +} + +static void format_payload_preview(const MQTTClient_message *msg, char *buf, size_t buf_size) { + const unsigned char *payload; + size_t len; + size_t out = 0; + + if (buf_size == 0) { + return; + } + + if (msg == NULL || msg->payload == NULL || msg->payloadlen <= 0) { + snprintf(buf, buf_size, ""); + return; + } + + payload = (const unsigned char *)msg->payload; + len = (size_t)msg->payloadlen; + + for (size_t i = 0; i < len && out + 1 < buf_size; i++) { + unsigned char c = payload[i]; + + if (c == '\r' || c == '\n' || c == '\t') { + if (out + 2 >= buf_size) { + break; + } + buf[out++] = ' '; + } else if (isprint(c)) { + buf[out++] = (char)c; + } else { + buf[out++] = '.'; + } + } + + if (len > out && out + 4 < buf_size) { + buf[out++] = '.'; + buf[out++] = '.'; + buf[out++] = '.'; + } + + buf[out] = '\0'; } static int message_arrived(void *context, char *topic, int topic_len, MQTTClient_message *msg) { (void)context; (void)topic_len; - if (should_accept_command_message(msg)) { + char payload_preview[128]; + int retained = msg != NULL ? msg->retained : 0; + int qos = msg != NULL ? msg->qos : 0; + int dup = msg != NULL ? msg->dup : 0; + int payloadlen = msg != NULL ? msg->payloadlen : 0; + + format_payload_preview(msg, payload_preview, sizeof(payload_preview)); + log_actionf("MQTT message arrived: topic=%s, payload_len=%d, qos=%d, retained=%d, dup=%d, payload=\"%s\"", + topic ? topic : "", payloadlen, qos, retained, dup, payload_preview); + + if (topic == NULL) { + log_action("MQTT message ignored: topic is null"); + } else if (msg == NULL) { + log_action("MQTT message ignored: message is null"); + } else if (msg->retained) { + log_action("MQTT command ignored: retained message"); + } else { if (strcmp(topic, topic_sleep) == 0) { + if (!is_valid_command_payload(msg)) { + log_action("MQTT sleep command ignored: invalid payload"); + MQTTClient_freeMessage(&msg); + MQTTClient_free(topic); + return 1; + } + log_action("MQTT command accepted: sleep queued"); queue_command(COMMAND_SLEEP); } else if (strcmp(topic, topic_monitor_off) == 0) { + if (is_empty_payload(msg)) { + log_action("MQTT monitor-off command accepted: empty payload trigger"); + } else if (is_valid_command_payload(msg)) { + log_action("MQTT monitor-off command accepted: valid payload trigger"); + } else { + log_action("MQTT monitor-off command ignored: invalid payload"); + MQTTClient_freeMessage(&msg); + MQTTClient_free(topic); + return 1; + } + log_action("MQTT command accepted: monitor-off queued"); queue_command(COMMAND_MONITOR_OFF); + } else { + log_actionf("MQTT command ignored: topic does not match subscribed commands (topic=%s)", topic); } } @@ -211,7 +339,7 @@ static void connection_lost(void *context, char *cause) { #ifndef HIDDEN_BUILD fprintf(stderr, "Connection lost: %s\n", cause ? cause : "unknown"); #endif - log_action("MQTT connection lost"); + log_actionf("MQTT connection lost: cause=%s", cause ? cause : "unknown"); } static BOOL WINAPI console_handler(DWORD signal) { @@ -252,32 +380,47 @@ static int publish_retained(MQTTClient client, const char *topic, const char *pa static int subscribe_topics(MQTTClient client) { int rc; if ((rc = MQTTClient_subscribe(client, topic_sleep, QOS)) != MQTTCLIENT_SUCCESS) { + log_actionf("MQTT subscribe failed: topic=%s, rc=%d", topic_sleep, rc); fprintf(stderr, "Failed to subscribe to %s: %d\n", topic_sleep, rc); return rc; } + log_actionf("MQTT subscribe ok: topic=%s, qos=%d", topic_sleep, QOS); if ((rc = MQTTClient_subscribe(client, topic_monitor_off, QOS)) != MQTTCLIENT_SUCCESS) { + log_actionf("MQTT subscribe failed: topic=%s, rc=%d", topic_monitor_off, rc); fprintf(stderr, "Failed to subscribe to %s: %d\n", topic_monitor_off, rc); return rc; } + log_actionf("MQTT subscribe ok: topic=%s, qos=%d", topic_monitor_off, QOS); return MQTTCLIENT_SUCCESS; } static int publish_birth_messages(MQTTClient client) { int rc; if ((rc = publish_retained(client, topic_status, STATUS_ONLINE)) != MQTTCLIENT_SUCCESS) { + log_actionf("MQTT publish failed: topic=%s, payload=%s, retained=1, rc=%d", + topic_status, STATUS_ONLINE, rc); fprintf(stderr, "Failed to publish status: %d\n", rc); return rc; } + log_actionf("MQTT publish ok: topic=%s, payload=%s, retained=1", + topic_status, STATUS_ONLINE); if ((rc = publish_retained(client, topic_version, VERSION)) != MQTTCLIENT_SUCCESS) { + log_actionf("MQTT publish failed: topic=%s, payload=%s, retained=1, rc=%d", + topic_version, VERSION, rc); fprintf(stderr, "Failed to publish version: %d\n", rc); return rc; } + log_actionf("MQTT publish ok: topic=%s, payload=%s, retained=1", + topic_version, VERSION); return MQTTCLIENT_SUCCESS; } static int try_connect(MQTTClient client, MQTTClient_connectOptions *conn_opts, const char *address) { + log_actionf("MQTT connect attempt: address=%s, client_id=%s, clean_session=%d, keep_alive=%d", + address, client_id, conn_opts->cleansession, conn_opts->keepAliveInterval); int rc = MQTTClient_connect(client, conn_opts); if (rc != MQTTCLIENT_SUCCESS) { + log_actionf("MQTT connect failed: rc=%d", rc); return rc; } @@ -294,7 +437,7 @@ static int try_connect(MQTTClient client, MQTTClient_connectOptions *conn_opts, } set_connected(1); - log_action("Connected to MQTT broker"); + log_actionf("Connected to MQTT broker: address=%s, client_id=%s", address, client_id); printf("Connected to %s\n", address); printf("Status: %s -> %s\n", topic_status, STATUS_ONLINE); printf("Subscribed to:\n %s\n %s\n", topic_sleep, topic_monitor_off); @@ -394,6 +537,7 @@ int main(int argc, char *argv[]) { printf("pc-control v%s\n", VERSION); printf("Device: %s\n", hostname); log_startup(); + log_runtime_config(address, hostname); MQTTClient client; MQTTClient_connectOptions conn_opts = MQTTClient_connectOptions_initializer; diff --git a/src/version.h b/src/version.h index 2cfbc70..922d523 100644 --- a/src/version.h +++ b/src/version.h @@ -3,9 +3,9 @@ #define PC_CONTROL_VERSION_MAJOR 1 #define PC_CONTROL_VERSION_MINOR 1 -#define PC_CONTROL_VERSION_PATCH 0 +#define PC_CONTROL_VERSION_PATCH 1 -#define VERSION "1.1.0" +#define VERSION "1.1.1" #define PC_CONTROL_VERSION_COMMA PC_CONTROL_VERSION_MAJOR,PC_CONTROL_VERSION_MINOR,PC_CONTROL_VERSION_PATCH,0 #endif