Skip to content

Add MQTTManager unit test harness and CI - #392

Open
jamesmulcahy wants to merge 23 commits into
NSPManager:develfrom
jamesmulcahy:feat/mqttmanager-test-harness
Open

jamesmulcahy wants to merge 23 commits into
NSPManager:develfrom
jamesmulcahy:feat/mqttmanager-test-harness

Conversation

@jamesmulcahy

@jamesmulcahy jamesmulcahy commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

This adds a harness for MQTTManager unit tests, runs them in CI, and uses it to cover the entity types, the EntityManager, OpenHAB and the config the manager sends to panels. The bugs the tests found are either fixed in their own commits or pinned as disabled tests.

Running the tests

cd docker/MQTTManager
./run_tests.sh                                   # build and run everything (in Docker)
./run_tests.sh --gtest_filter='NSPanelTest.*'    # extra arguments go to the test binary

The build runs in Docker (same python:3.12.5-bookworm base and compiler as the production image). The first run builds the Conan dependencies from source and takes about an hour; after that they are cached in the nspm-mqttmanager-conan volume and a rebuild plus run takes a few minutes. Output is also written to tests/last_run.log.

./compile_mqttmanager.sh --test still works: it now sets the new NSPM_BUILD_TESTS CMake option instead of sed-editing CMakeLists.txt.

The harness

  • NSPM_BUILD_TESTS CMake option. It turns on TEST_MODE=1 and builds tests/. The old if (TEST_MODE==1) block compared a string and was always false, so test discovery never ran.

  • tests/ builds its own nspm_mqttmanager_tests executable. test_helpers.hpp has scoped database rows (entities, scenes, panels) that remove themselves, and builders that write entity_data exactly as Django's rest.py does.

  • Send capture, TEST_MODE only. MQTT_Manager, HomeAssistantManager and OpenhabManager record what they send, so tests check the exact MQTT messages and service calls without a live connection:

    • HomeAssistantManager::test_process_event() delivers an event through the real observer routing.
    • OpenhabManager gets hooks to feed a raw websocket message through its parser and to point its REST calls at an address without opening a websocket. tests/fake_http_server.hpp answers those REST calls from 127.0.0.1, using ixwebsocket's ix::HttpServer (already used by the Nextion image server, so no new dependency).
    • MQTT_Manager and CommandManager can report how many callbacks are attached, used to check that destroyed panels clean up.

    Production builds are unchanged.

  • The existing inline tests in the Config and Light libraries are linked in whole, so they register too.

  • CI in .github/workflows/mqttmanager-tests.yml runs on PRs and pushes to main/devel that touch docker/MQTTManager/.

    • The Conan package cache is a host directory saved with actions/cache, keyed on conanfile.py and the test Dockerfile. It's saved even if the tests fail, so only the first run (or a dependency change) pays the one-hour build.
    • Results are written as JUnit XML and published to the job summary with action-junit-report. It uses annotate_only, because creating a check run needs checks: write, which PRs from forks don't get.

What's covered

Area File
Light config loading light_config_test.cpp
Home Assistant switches, buttons, scenes and scripts, thermostats and media players switch_test.cpp, button_test.cpp, scene_test.cpp, thermostat_test.cpp, media_player_test.cpp
NSPM buttons button_test.cpp
EntityManager: entities, rooms and panels entity_manager_test.cpp
OpenHAB switches, scenes and thermostats openhab_test.cpp
The config sent to panels, and panel commands nspanel_test.cpp

nspanel_test.cpp covers:

  • Config contents: defaults, panel settings, temperature calibration, screensaver fallbacks, button modes, thermostat limits, relay group bindings, and room infos with and without locking.
  • Config sending: reloads, and panels in an unknown room or that were denied (which get no config).
  • Panel states: pending, accepted and denied.
  • Commands: reboot topics, MQTT payload buttons, and detached buttons toggling an entity or activating a scene.
  • Callback cleanup: when a panel is destroyed or renamed.

Fixes

Each fix is its own commit, with tests:

  • Broken thermostat test blocks. home_assistant_thermostat.cpp and openhab_thermostat.cpp each ended with a copy of the Home Assistant light tests, which didn't compile under TEST_MODE. Removed. Production was unaffected.
  • Unused settings key removed. OPENHAB_RGB_CHANNEL_NAME had no entry in _setting_key_map, which the existing inline config test caught. The setting is a leftover that nothing reads, so it's removed from MQTTManager, the Django settings view and the React settings store.
  • Room temperature provider not initialised. Room::_room_temp_provider was never initialised, so every room without a temperature sensor logged "Got unknown temperature provider", and could detach an observer it had never attached.
  • Wrong controller check for NSPM buttons. The NSPMButton constructor checked for the Home Assistant controller (copied from HomeAssistantSwitch), so every NSPM button logged "has not been recognized as controlled by HOME_ASSISTANT" when it loaded.
  • Room temperature sensor not sent to panels (regression). d8220f6 dropped the line that sets inside_temperature_sensor_mqtt_topic. Since then, panels always show their built-in sensor on the screensaver, even when their room has a temperature sensor.
  • Crash on a non-numeric thermostat limit. The limits are free text in the web interface and were parsed with std::stoi. A value like abc threw an exception that reload_config() doesn't catch. std::stoi also silently truncated 21.5 to 21 and read 21abc as 21. The limits are now parsed whole:
    • a value that isn't a number, or is out of range, logs an error and is sent as 0;
    • a decimal is rounded to the nearest degree, with a warning.
  • NSPanel members not initialised. Humidity and pressure were sent to the web interface from uninitialised memory until the panel's first status report, and the relay state and "register as light" flags were never initialised. The relay 2 debug log also printed relay 1's setting.
  • Pending panels reported as accepted. Since Feat first page react #391, the NSPanel constructor overwrote AWAITING_ACCEPT with WAITING. A newly discovered panel showed as "waiting" with "accepted": true. Accepting a panel had only worked because of this bug: register requests from panels awaiting accept or denied are ignored, and nothing moved an accepted panel out of those states. reload_config() now moves a newly accepted panel to WAITING.
  • OpenHAB thermostat updates dropped at random. OpenhabThermostat ignores an event if the item changed less than a second ago, but the _last_*_change timestamps it compares against were never initialised. When that memory held a large value, OpenHAB updates for that item were silently ignored. CI hit this in follows_target_temperature_and_mode_from_openhab.
  • OpenHAB light group events dropped at random. OpenhabLight's _openhab_group_*_thread_running flags were never initialised. If one started out true, the thread that processes that group's brightness, colour temperature or RGB events was never started.
  • Use-after-free after deleting a panel. ~NSPanel() didn't detach its CommandManager callback, its nspanel/<mac>/log subscription, or the legacy nspanel/<name>/status and status_report subscriptions. After a panel was deleted, the next button press from any panel, or the next message on one of those topics, called into the destroyed object. These topics are now stored and detached. Renaming a panel also moves its legacy subscriptions to the new name instead of leaving the old name subscribed.

Known bugs, pinned as DISABLED_ tests

Each has a KNOWN BUG comment. Remove DISABLED_ when fixing it.

  • LightConfig.DISABLED_non_string_controller_does_not_throw: a light whose controller isn't a string throws nlohmann::json::type_error instead of falling back to Home Assistant.
  • OpenhabSceneTest.DISABLED_activating_sends_the_token: OpenhabScene::activate() builds the Authorization header from .c_str() of a temporary, so the header is a dangling pointer.
  • OpenhabThermostatTest.DISABLED_set_preset_and_swing_command_their_items and DISABLED_fetches_the_initial_preset_and_swing_over_rest: the thermostat reads openhab_preset_item / openhab_swing_item / openhab_swingh_item, but Django stores *_mode_item. Presets and swing are sent to openhab/items//command and never follow OpenHAB. Once the names are fixed, the initial preset fetch compares against the HVAC mode, and the horizontal swing fetch checks the vertical item.
  • OpenhabThermostatTest.DISABLED_publishes_the_current_temperature_from_openhab: the current temperature item is never attached, and its callback checks the target temperature item. Panels never show the thermostat's own reading.
  • OpenhabThermostatTest.DISABLED_fetches_the_initial_target_temperature_over_rest: the initial target temperature fetched over REST is rounded to a whole degree, so 21.5 shows as 22.

Notes

  • The tests share one SQLite database for the whole run. Rooms are never deleted, because each room runs a detached thread, so tests create rooms with unique names.
  • The inline OpenHAB light tests take about 11 s, because they wait out the light's 1 s state debounce several times.
  • Possible next steps: tests against a Django-migrated schema, and the websocket/STOMP command paths.

🤖 Generated with Claude Code

jamesmulcahy and others added 21 commits October 2, 2026 06:56
Wire GoogleTest into the CMake build behind an NSPM_BUILD_TESTS option and add
a tests/ directory with its own test executable. The existing `if (TEST_MODE==1)`
block never ran, so the inline tests had never been built or discovered.

- tests/: main.cpp initialises the (TEST_MODE) database; test_helpers.hpp has a
  ScopedEntity RAII helper and builds entity_data exactly as Django's rest.py
  writes it; light_config_test.cpp checks lights load placement, capabilities
  and controller from Django-shaped rows.
- The inline TEST_MODE tests in the Config and Light libraries are linked in
  whole so they register with GoogleTest (21 tests in total).
- run_tests.sh builds and runs everything in Docker, caching Conan packages and
  build output in named volumes.
- compile_mqttmanager.sh --test now sets the CMake option instead of using sed.

Fixes found by turning the tests on:
- Remove the copies of the Home Assistant light tests at the end of both
  thermostat sources; they did not compile and duplicated test names.
- Add the missing OPENHAB_RGB_CHANNEL_NAME entry to the settings key map
  (caught by MqttManagerConfigTest.verify_all_settings_exists_and_have_db_key).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs docker/MQTTManager/run_tests.sh on pull requests and pushes that touch
docker/MQTTManager. The Conan package cache is a host directory saved with
actions/cache (keyed on conanfile.py and the test Dockerfile), so only the
first run pays for building the dependencies from source.

run_tests.sh takes NSPM_TEST_CONAN_CACHE / NSPM_TEST_BUILD_DIR to override
its Docker volumes, and the container now prunes Conan's build and source
trees after installing to keep the cache small.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
run_tests.sh writes gtest JUnit XML to NSPM_TEST_RESULTS_DIR when it is set,
and CI publishes it with action-junit-report. annotate_only is used because
creating a check run needs checks: write, which pull requests from forks
don't get.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ia players and the EntityManager

In test builds, HomeAssistantManager, OpenhabManager and MQTT_Manager record what they send,
so tests can check the exact service calls and MQTT messages without a live connection.
HomeAssistantManager::test_process_event() delivers an event through the real observer routing.
All of it is behind TEST_MODE, so production builds are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Room::_room_temp_provider was never initialised, so loading a room without a temperature
sensor logged "Got unknown temperature provider ... Provider '<random number>'", and if the
garbage happened to match a controller it detached an observer that was never attached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The check was copied from HomeAssistantSwitch, so every NSPM button logged "HomeAssistantSwitch
has not been recognized as controlled by HOME_ASSISTANT" when it was loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a TEST_MODE hook that feeds a raw websocket message through
OpenhabManager's parser, and one that points the REST API at an address
without opening a websocket. Tests use a small fake HTTP server on
127.0.0.1 for the REST calls (initial item state, rule runs).

Known bugs are pinned as DISABLED_ tests:
- Thermostat reads openhab_preset_item/openhab_swing_item/
  openhab_swingh_item but Django writes *_mode_item, so presets and
  swing are sent to openhab/items//command and never followed.
- The current temperature item is never attached, and its callback
  checks the target temperature item.
- The initial target temperature fetched over REST is rounded.
- OpenhabScene::activate passes a dangling c_str() as the
  Authorization header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover the NSPanelConfig the manager publishes (defaults, panel settings,
temperature calibration, screensaver fallbacks, button modes, thermostat
limits, relay group bindings, room infos and locking, reload, unknown room,
denied panel) and the command paths (reboot topics, MQTT payload buttons,
detached buttons toggling entities and activating scenes).

Adds TEST_MODE hooks to count MQTT and CommandManager callbacks.

Pinned as DISABLED_ (KNOWN BUG):
- a panel that is neither accepted nor denied reports WAITING, not
  AWAITING_ACCEPT
- ~NSPanel leaves its CommandManager callback and log/legacy status
  subscriptions attached

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d8220f6 moved the thermostat temperature limits in NSPanel::send_config()
and dropped the line that sets inside_temperature_sensor_mqtt_topic along
with them. Since then panels always show their built-in sensor on the
screensaver, even when their room has a temperature sensor configured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The thermostat limits are free text in the web interface, and
send_config() parsed them with std::stoi. d8220f6 stopped empty values
being saved, but any other non-numeric value still threw
std::invalid_argument, which reload_config() does not catch (it only
catches std::system_error). Parse the limits safely, logging an error and
sending 0 for a bad value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- _humidity and _pressure were never initialised, so the web interface
  was sent whatever was in memory until the panel's first status report.
  Reset them with the other readings when the panel is offline.
- _relay1_state, _relay2_state and _register_relay1/2_as_light were never
  initialised. Default them to false.
- The relay 2 "Will register" debug log printed relay 1's setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since NSPManager#391 the NSPanel constructor sets WAITING whenever the panel has a
config topic, which pending panels have too, so it overwrote the
AWAITING_ACCEPT state set by reload_config(). A newly discovered panel was
reported to the web interface as "waiting" with "accepted": true. Only set
WAITING for panels that are not awaiting accept, and drop the duplicated
block that set OFFLINE just before it.

Accepting a panel only worked because of that bug: register requests from
panels in AWAITING_ACCEPT or DENIED are ignored, and nothing moved an
accepted panel out of those states. reload_config() now moves a panel
that has just been accepted to WAITING.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
~NSPanel() never detached its CommandManager callback, and
reset_mqtt_topics() missed the nspanel/<mac>/log subscription and the
legacy nspanel/<name>/status and status_report subscriptions, which were
built inline and never stored. After a panel was deleted, the next button
press from any panel, or the next message on one of those topics, called
into the destroyed NSPanel.

Keep those topics in members so they can be detached, which also moves
the legacy subscriptions over when a panel is renamed instead of leaving
the old name subscribed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
std::stoi stopped at the first character it couldn't read, so "21.5" was
silently sent as 21 and "21abc" as 21, and the "not a whole number" error
was only ever logged for values that weren't numbers at all. Parse the
whole value instead:
- not a number (including trailing text): error, send 0
- out of range: error, send 0
- a decimal: round to the nearest degree and log a warning

ScopedErrorLog can now record warnings too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OpenhabThermostat ignores an OpenHAB event for an item unless at least a
second has passed since the item last changed, but the _last_*_change
timestamps it compares against were never initialised. When the memory
held a large value, target temperature, mode, fan, preset or swing
updates from OpenHAB were silently ignored for that thermostat. CI hit
this in OpenhabThermostatTest.follows_target_temperature_and_mode_from_openhab.
Set them to 0 in the constructor, as OpenhabSwitch and OpenhabLight do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The _openhab_group_*_item_state_changed_event_thread_running flags were
never initialised. If one started out true, the thread that processes
that group's events (brightness, colour temperature or RGB) was never
started, so the light ignored those OpenHAB group events. Set them to
false in the constructor, along with the matching group event timestamps
(always written before they are read today, but uninitialised).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scene tests already check the nspanelmanager context a script gets
when activate() is called. These cover the paths that call it:
- tapping a scene or script on a panel's page (ToggleEntityFromEntitiesPage)
  sends the panel's room and the script's room, or only the panel's room
  for a global script, and still runs from an unknown panel without a
  triggering room
- the triggering room follows the panel when it moves room, and a renamed
  room's new name is sent
- a detached button runs a script with the full context, or turns on a
  scene

Adds a TEST_MODE hook to hand EntityManager a panel command without
starting EntityManager::init()'s threads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every room starts a detached thread that waits on the room's mutex and
condition variable forever. Since the tests started creating rooms, the
binary segfaults (exit code 139) after all tests have passed: exit
destroys EntityManager's static room list while those threads are still
waiting. The manager never exits this way, so leave the threads alone
and exit with std::_Exit once the results are written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI has seen the test binary segfault part-way through a run, which only
showed up as exit code 139 with the last buffered output missing. Make
stdout line-buffered, and on a fatal signal print the running test and a
demangled backtrace before re-raising. Link with -rdynamic so frames in
the executable are named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MQTT_Manager::publish() and clear_retain() wrote to _mqtt_retain_buffer,
a std::unordered_map, without holding any lock. Room status threads,
entity updates and panel reloads publish retained messages from different
threads, so concurrent inserts and erases could corrupt the map during a
rehash. The test binary crashed this way in CI: a room's status thread
published its retained state while a panel was loading and clearing its
Home Assistant discovery topics.

Give the buffer its own mutex, and replay it from a copy on reconnect,
since publish() writes to it. The reconnect also sent and erased the
buffered messages without _mqtt_client_mutex, which publish() and
clear_retain() hold when adding to that list; take it there too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
load_rooms() erased removed rooms from _rooms and sorted it without
_rooms_mutex, and update_all_rooms_status() walked it without the lock,
while get_room(), get_all_rooms() and the room command handlers lock it.
A config reload could reallocate or reorder the list under a reader.
Take the lock for the erase and the sort, and have the 'All rooms'
thread work on a copy taken under the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jamesmulcahy
jamesmulcahy force-pushed the feat/mqttmanager-test-harness branch from cf329ca to 554e30c Compare October 2, 2026 13:56
@jamesmulcahy
jamesmulcahy marked this pull request as ready for review October 2, 2026 14:04
@jamesmulcahy

Copy link
Copy Markdown
Contributor Author

Hi @tpanajott -- Here's another set of tests (and several fixes) for the MQTTManager side of things. It looks like a lot but it's 99% new test code, rather than changes to existing logic.

As issues were found, I kept the fixes (and associated tests to defend them) isolated to individual commits, to keep things clearer -- that's partially why there are so many commits. You're obviously free to squash them on merge.

@tpanajott tpanajott left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there. Sorry for the delay, it's just been a bit much in life and also these PRs are massive 😆 This looks pretty good but there are still some behaviours that I'd like to check though I'm not sure as to how to do that the best way. For example, requesting a specific brightness on the main page should behave differently depending on which lights are on, what their settings are and so on. Though, I suspect that might be better to do after this PR has landed.

steps:
- uses: actions/checkout@v4

- name: Restore Conan packages

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awesome. I'll need to look into doing this when doing the actual release builds as well.

{MQTT_MANAGER_SETTING::OPENHAB_TOKEN, {"openhab_token", ""}},
{MQTT_MANAGER_SETTING::OPENHAB_BRIGHTNESS_CHANNEL_MAX, {"openhab_brightness_channel_max", "255"}},
{MQTT_MANAGER_SETTING::OPENHAB_BRIGHTNESS_CHANNEL_MIN, {"openhab_brightness_channel_min", "0"}},
{MQTT_MANAGER_SETTING::OPENHAB_RGB_CHANNEL_NAME, {"openhab_rgb_channel_name", ""}},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably remove this and any reference to openhab_rgb_channel_name as it's a remnant of how we planned to do things in the beginning but it never worked out that way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack -- removed!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This all probably works but it's pretty hard to read. Would this not be better handed over to a library?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good call out -- this was pretty sloppy. Looks like ix::HttpServer, which is in a library we're already using, can be used for this. I've cut over to it

jamesmulcahy and others added 2 commits October 11, 2026 07:33
It was left over from an early plan for OpenHAB RGB lights and nothing reads it.
Drop it from the MQTTManager settings enum and key map, the Django settings view
and the React settings store, rather than giving it a key map entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the hand-written socket and HTTP parsing code with ixwebsocket's
HttpServer, which MQTTManager already depends on and uses for the Nextion image
server. ix::HttpServer can't bind to port 0, so the fake tries ports from 18800
until one is free.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jamesmulcahy

Copy link
Copy Markdown
Contributor Author

Hey there. Sorry for the delay, it's just been a bit much in life and also these PRs are massive 😆

No worries -- I understand there's a lot to review here. No pressure on my side.

This looks pretty good but there are still some behaviours that I'd like to check though I'm not sure as to how to do that the best way. For example, requesting a specific brightness on the main page should behave differently depending on which lights are on, what their settings are and so on. Though, I suspect that might be better to do after this PR has landed.

Agreed; as far as I'm aware, this doesn't regress anything, but fixes some targeted issues identified during tests. If we want to expand coverage into other use cases, that's probably best done as extra PRs on top. It'll be good to get future PRs defended with a baseline of tests/builds and iterate from there.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants