diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7ad46d4e31..a309b0a161 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build +name: "๐Ÿ› ๏ธ Build" on: [push, pull_request] diff --git a/.github/workflows/build_and_docs_to_dev.yml b/.github/workflows/build_and_docs_to_dev.yml index 3c2ea37c4e..292e81e342 100644 --- a/.github/workflows/build_and_docs_to_dev.yml +++ b/.github/workflows/build_and_docs_to_dev.yml @@ -1,8 +1,8 @@ -name: Build binaries, docs and publish to dev folder +name: ๐Ÿ› ๏ธ๐Ÿ“š Build binaries, docs and publish to dev folder on: workflow_dispatch: schedule: - - cron: '0 0 * * *' + - cron: "0 0 * * *" jobs: build: strategy: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..e63fb4ab02 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,174 @@ +name: ๐Ÿš€ CI Pipeline + +on: + push: + branches: [main, development, feature/*] + pull_request: + branches: [main, development] + +jobs: + # Job 1: Code Formatting Check + lint: + name: ๐Ÿ” Check Code Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check main format + uses: DoozyX/clang-format-lint-action@v0.6 + with: + source: "./main" + extensions: "h,ino" + clangFormatVersion: 9 + + # Job 2: Native Tests + native-tests: + name: ๐Ÿงช Run Native Tests + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install PlatformIO + run: | + python -m pip install --upgrade pip + pip install platformio + - name: Run Native Tests + run: pio test -e test_native + - name: Test Results Summary + if: always() + run: | + echo "Test execution completed" + + # Job 3: Build Matrix + build: + name: ๐Ÿ› ๏ธ Build (${{ matrix.environments }}) + runs-on: ubuntu-latest + needs: [lint, native-tests] + strategy: + fail-fast: false + matrix: + environments: + - "rfbridge" + - "rfbridge-direct" + - "esp32dev-all-test" + - "esp32dev-rf" + - "esp32dev-pilight-cc1101" + - "esp32dev-somfy-cc1101" + - "esp32dev-pilight-somfy-cc1101" + - "esp32dev-weatherstation" + - "esp32dev-gf-sun-inverter" + - "esp32dev-ir" + - "esp32dev-ble" + - "esp32dev-ble-mqtt-undecoded" + - "esp32dev-ble-aws" + - "esp32feather-ble" + - "esp32-lolin32lite-ble" + - "esp32-olimex-gtw-ble-eth" + - "esp32-olimex-gtw-ble-poe" + - "esp32-olimex-gtw-ble-poe-iso" + - "esp32-wt32-eth01-ble-eth" + - "esp32-olimex-gtw-ble-wifi" + - "esp32-m5stick-ble" + - "esp32-m5stack-ble" + - "esp32-m5tough-ble" + - "esp32-m5stick-c-ble" + - "esp32-m5stick-cp-ble" + - "esp32s3-atomS3U" + - "esp32-m5atom-matrix" + - "esp32-m5atom-lite" + - "esp32dev-rtl_433" + - "esp32dev-rtl_433-fsk" + - "esp32doitv1-aithinker-r01-sx1278" + - "heltec-rtl_433" + - "heltec-rtl_433-fsk" + - "heltec-ble" + - "lilygo-rtl_433" + - "lilygo-rtl_433-fsk" + - "lilygo-ble" + - "esp32dev-multi_receiver" + - "esp32dev-multi_receiver-pilight" + - "tinypico-ble" + - "ttgo-lora32-v1" + - "ttgo-lora32-v21" + - "ttgo-t-beam" + - "heltec-wifi-lora-32" + - "shelly-plus1" + - "nodemcuv2-all-test" + - "nodemcuv2-fastled-test" + - "nodemcuv2-2g" + - "nodemcuv2-ir" + - "nodemcuv2-serial" + - "avatto-bakeey-ir" + - "nodemcuv2-rf" + - "nodemcuv2-rf-cc1101" + - "nodemcuv2-somfy-cc1101" + - "manual-wifi-test" + - "rf-wifi-gateway" + - "nodemcuv2-rf2" + - "nodemcuv2-rf2-cc1101" + - "nodemcuv2-pilight" + - "nodemcuv2-weatherstation" + - "sonoff-basic" + - "sonoff-basic-rfr3" + - "esp32dev-ble-datatest" + - "esp32s3-dev-c1-ble" + - "esp32c3-dev-m1-ble" + - "airm2m_core_esp32c3" + - "esp32c3_lolin_mini" + - "esp32c3-m5stamp" + - "thingpulse-espgateway" + - "theengs-bridge" + - "esp32dev-ble-idf" + - "theengs-bridge-v11" + - "theengs-plug" + - "esp32dev-ble-broker" + - "esp32s3-m5stack-stamps3" + - "esp32c3u-m5stamp" + - "lilygo-t3-s3-rtl_433" + - "lilygo-t3-s3-rtl_433-fsk" + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install platformio + run: | + python -m pip install --upgrade pip + pip install platformio + pip install setuptools + - name: Extract ESP32 platform version from platformio.ini + run: | + ESP32_VERSION=$(grep 'esp32_platform\s*=' platformio.ini | cut -d'@' -f2 | tr -d '[:space:]') + echo "ESP32_PLATFORM_VERSION=${ESP32_VERSION}" >> $GITHUB_ENV + - name: Run PlatformIO + run: platformio run -e ${{ matrix.environments }} + - name: Upload Assets + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.environments }} + path: | + .pio/build/*/firmware.bin + .pio/build/*/partitions.bin + retention-days: 7 + + # Job 4: Documentation + documentation: + name: ๐Ÿ“š Build Documentation + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "14.x" + - name: Download Common Config + run: | + curl -o docs/.vuepress/public/commonConfig.js https://www.theengs.io/commonConfig.js + - name: Install build dependencies + run: npm install + - name: Build documentation + run: npm run docs:build diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 08a0eeca26..0000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Check Code Format - -on: [push, pull_request] - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Check main format - uses: DoozyX/clang-format-lint-action@v0.6 - with: - source: "./main" - extensions: "h,ino" - clangFormatVersion: 9 diff --git a/.github/workflows/manual_docs.yml b/.github/workflows/manual_docs.yml index 343a1d5b5f..01eae075a9 100644 --- a/.github/workflows/manual_docs.yml +++ b/.github/workflows/manual_docs.yml @@ -1,4 +1,4 @@ -name: Create and publish documentation +name: "๐Ÿ“š Create and publish documentation" on: workflow_dispatch: workflow_call: @@ -42,4 +42,4 @@ jobs: with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./docs/.vuepress/dist - cname: docs.openmqttgateway.com \ No newline at end of file + cname: docs.openmqttgateway.com diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31b2d31a6a..79c4aba28f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release +name: "๐Ÿš€ Release" on: release: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 28f46eb968..719e0009d0 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,4 +1,5 @@ -name: Close inactive issues or PR +name: "๐Ÿ—‘๏ธ Close inactive issues or PR" + on: schedule: - cron: "30 0 * * *" diff --git a/docs/use/gateway.md b/docs/use/gateway.md index bf30579b56..691bfcef20 100644 --- a/docs/use/gateway.md +++ b/docs/use/gateway.md @@ -39,6 +39,264 @@ Auto discovery is enabled by default on release binaries and platformio. `mosquitto_pub -t "home/OpenMQTTGateway/commands/MQTTtoSYS/config" -m '{"offline":true,"save":true}'` + +## Filter messages + +OpenMQTTGateway enables to set pass and block lists to filter outbound messages. The filter can be defined for any keys of the JSON containing a string, and also for MQTT topics. + +### Filter Constraints +- **Maximum filters**: 50 total filters combined (pass + block lists) +- **Pattern matching**: Supports simple wildcards `*` (any sequence) and `?` (single character) + +Here is an example of append a new setup of filter: + +```sh +$> mosquitto_pub -t "home/OpenMQTTGateway/commands/MQTTtoSYS/config" -m '{"filter":{"block":{"name":["TP_*"]},"pass":{"name":["ATC*"]}}}' +``` + +You don't need to specify both filters; you can use any combination you prefer. The logic behind the pass and block filters is as follows: + +A message is allowed if both conditions are met: +- The block list is disabled, or the message is not in the block list (it is not explicitly blocked). +- The pass list is disabled, empty, or the message is in the pass list (it is explicitly allowed). + + +### Wildcard Pattern Matching +The filter supports two types of wildcards: +- `*` - Matches any sequence of characters (including empty) +- `?` - Matches exactly one character + +**Examples:** +- `sensor_*` matches `sensor_123`, `sensor_abc`, `sensor_` +- `dev??e` matches `device`, `devABe` but not `dev1e` +- `*_sensor_*` matches `temp_sensor_01`, `humidity_sensor_data` + +### Topic Filtering +OpenMQTTGateway also supports filtering based on MQTT topics. This allows you to control which topics are allowed or blocked at the gateway level. + +**Topic Filter Examples:** +- `home/sensor/*` - Allows all topics under `home/sensor/` +- `home/*/temp` - Allows topics like `home/kitchen/temp`, `home/bedroom/temp` +- `home/blocked/*` - Blocks all topics under `home/blocked/` + +### Filter Limits +::: warning IMPORTANT +- Maximum of **50 total filters** (combined pass and block lists) +- If you attempt to add more than 50 filters, the gateway will log a warning and reject the additional filters +- The filter count includes both exact matches and wildcard patterns +::: + +### Filter Commands API Documentation + +#### **Overview** + +Filter commands allow you to manage and control filtering rules on the gateway. Take attention that some commands are **immediate actions**: + +#### **Command Table** + +| **Command** | **Description** | **Parameters** | **Example** | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `reset` | Resets all filters and restores the gateway to its initial state. | `cmd: "reset"` | `sh mosquitto_pub -t "home/OpenMQTTGateway/commands/MQTTtoSYS/config" -m '{"filter":{"cmd":"reset"}}'` | +| `clear` | Clears all filters but keeps the `ignore_pass` and `ignore_block` flags unchanged. | `cmd: "clear"` | `sh mosquitto_pub -t "home/OpenMQTTGateway/commands/MQTTtoSYS/config" -m '{"filter":{"cmd":"clear"}}'` | +| `persist` | Persists the current filter configuration across gateway restarts. | `cmd: "persist"` | `sh mosquitto_pub -t "home/OpenMQTTGateway/commands/MQTTtoSYS/config" -m '{"filter":{"cmd":"persist"}}'` | +| `reload` | Reloads the filter configuration from storage. | `cmd: "reload"` | `sh mosquitto_pub -t "home/OpenMQTTGateway/commands/MQTTtoSYS/config" -m '{"filter":{"cmd":"reload"}}'` | +| `purge` | Resets filters and purges any stored configuration. | `cmd: "purge"` | `sh mosquitto_pub -t "home/OpenMQTTGateway/commands/MQTTtoSYS/config" -m '{"filter":{"cmd":"purge"}}'` | +| `status` | Requests the gateway to publish its current filter settings. | `cmd: "status"` | `sh mosquitto_pub -t "home/OpenMQTTGateway/commands/MQTTtoSYS/config" -m '{"cmd":"status"}'` | +| `ignore_pass` / `ignore_block` | Temporarily disables filter enforcement without removing configured filters. Can be combined with `pass` and `block`. | `ignore_pass: true`, `ignore_block: true` | `sh mosquitto_pub -t "home/OpenMQTTGateway/commands/MQTTtoSYS/config" -m '{"filter":{"ignore_pass":true,"ignore_block":true}}'` | +| `new` | Creates a new filter configuration from scratch. | `cmd: "new"`, `block`, `pass` | `sh mosquitto_pub -t "home/OpenMQTTGateway/commands/MQTTtoSYS/config" -m '{"filter":{"cmd":"new","block":{"name":["TP_*"]},"pass":{"name":["ATC*"]}}}'` | +| `rules` | Programmatically add filter rules with full validation and error handling. | `rules: [...]` | See **Rules Array Format** section below | + +*** + +#### **Parameters** + +| **Parameter** | **Type** | **Description** | +| -------------- | --------- | ----------------------------------------------------------------------------------------- | +| `cmd` | `string` | Specifies the command to execute (`reset`, `clear`, `persist`, `reload`, `purge`, `new`). | +| `block` | `object` | Defines blocking rules (e.g., `{"name":["TP_*"]}`). | +| `pass` | `object` | Defines passing rules (e.g., `{"name":["ATC*"]}`). | +| `ignore_pass` | `boolean` | If `true`, ignores pass rules temporarily. | +| `ignore_block` | `boolean` | If `true`, ignores block rules temporarily. | +| `rules` | `array` | Array of rule objects with validation (see **Rules Array Format** below). | + +*** + +#### **Rules Array Format** + +The `rules` array allows programmatic definition of filters with built-in validation and error handling. Each rule must be a JSON object with the following structure: + +```json +{ + "target": "topic", + "action": "pass|block", + "value": "filter_value", + "key": "field_name (only for message target)" +} +``` + +**Rule Fields:** +- `target`(optional): `"topic"` for MQTT topic filtering (default is message) +- `action`(required): `"pass|block"` for the action to allow (default si denay) +- `key` : Field name when `target` is not `"topic"`, ignored for `"topic"` +- `value` (required): The filter value (supports wildcards `*` and `?`) + + +**Example with Rules Array:** + +```json +{ + "filter": { + "rules": [ + { + "target": "topic", + "action": "pass", + "value": "home/sensor/*" + }, + { + "action": "block", + "key": "id", + "value": "badDevice*" + }, + { + "target": "topic", + "action": "block", + "value": "home/blocked/*" + }, + { + "action": "pass", + "key": "name", + "value": "ATC*" + } + ] + } +} +``` + +::: info VALIDATION +The gateway validates each rule in the array individually: +- Invalid rules are logged and skipped +- Valid rules continue to be processed +- If you accidentally mix non-object elements (strings, numbers, arrays) in the rules array, they are automatically skipped +- Check the gateway logs to see details about rejected rules +::: + +*** + +#### **Usage Notes** + +* Commands like `reset`, `clear`, `purge`, `persist`, and `reload` **cannot be combined** with other filter rules in the same message. +* Use `ignore_pass` and `ignore_block` for temporary overrides without deleting existing configurations. +* The `new` command replaces the entire filter configuration with the provided rules. +* The `rules` array provides programmatic control with full validation - use this for complex filter setups. + + + +### Filter Examples +This is an advanced example that create a new configuration that allows only messages that contain the MAC address of "Tuya Smart Inc" and "Apple, Inc." and within those messages it blocks all devices with names starting with the prefix "BRO" and the attribute "something" with the exact value "strange." + +```json +{ + "filter":{ + "cmd":"new", + "pass": { + "mac": ["00:33:7A:*","00:03:93:*"] + }, + "block": { + "name": ["BRO*"], + "something": ["strange"] + }, + "ignore_pass":false, + "ignore_block":false + } +} +``` + +#### **Topic Filtering Examples** + +Example 1: Block all topics under a specific path while allowing sensor topics: + +```json +{ + "filter": { + "rules": [ + { + "target": "topic", + "action": "pass", + "value": "home/sensor/*" + }, + { + "target": "topic", + "action": "block", + "value": "home/debug/*" + } + ] + } +} +``` + +Example 2: Complex topic and message filtering with rules array: + +```json +{ + "filter": { + "rules": [ + { + "target": "topic", + "action": "pass", + "value": "home/*/temp" + }, + { + "target": "topic", + "action": "block", + "value": "home/blocked/*" + }, + { + "action": "pass", + "key": "mac", + "value": "00:33:7A:*" + }, + { + "action": "block", + "key": "name", + "value": "BRO*" + } + ] + } +} +``` + +Example 3: Using rules with message field filters: + +```json +{ + "filter": { + "rules": [ + { + "action": "pass", + "key": "id", + "value": "sensor_*" + }, + { + "action": "pass", + "key": "id", + "value": "device_*" + }, + { + "action": "block", + "key": "name", + "value": "TEST*" + } + ] + } +} +``` + +::: tip PERFORMANCE +The wildcard matching is optimized for ESP32/ESP8266 and uses minimal resources. Unlike regex patterns, wildcards `*` and `?` have negligible performance impact and are safe to use even on ESP8266. +::: + + + ## Change the WiFi credentials `mosquitto_pub -t "home/OpenMQTTGateway/commands/MQTTtoSYS/config" -m '{"wifi_ssid":"ssid", "wifi_pass":"password"}'` diff --git a/main/TheengsCommon.h b/main/TheengsCommon.h index 5b53addeee..62a98bdd85 100644 --- a/main/TheengsCommon.h +++ b/main/TheengsCommon.h @@ -1,11 +1,8 @@ #pragma once +#include "TheengsLogs.h" #include "User_config.h" - -#define ARDUINOJSON_USE_LONG_LONG 1 -#define ARDUINOJSON_ENABLE_STD_STRING 1 -#include -#include +#include "config_JSONMessages.h" #if defined(ESP32) # include diff --git a/main/TheengsLogs.h b/main/TheengsLogs.h new file mode 100644 index 0000000000..69b526cca6 --- /dev/null +++ b/main/TheengsLogs.h @@ -0,0 +1,214 @@ +/** + * @file TheengsLogs.h + * @brief Cross-platform native logging for OpenMQTTGateway + * + * Provides unified logging interface: + * - Arduino (ESP32/ESP8266): Uses ArduinoLog library + * - Native/test: Uses fprintf to stderr (compatible with GoogleTest) + * + * Features: + * - Full F() macro compatibility (flash string support) + * - Compatible with existing THEENGS_LOG_* macros across 50+ files + * - Platform-optimized implementations for each target + */ + +#pragma once + +// ============================================================================ +// PLATFORM DETECTION +// ============================================================================ + +// Detect if we're on Arduino platforms (ESP32/ESP8266/AVR) +#if defined(ARDUINO) && !defined(UNIT_TEST) && !defined(UNIT_TEST_NATIVE) +# define THEENGS_PLATFORM_ARDUINO 1 +#else +# define THEENGS_PLATFORM_ARDUINO 0 +#endif + +// Detect specific ESP platforms for optimization +#if defined(ESP32) +# define THEENGS_PLATFORM_ESP32 1 +#else +# define THEENGS_PLATFORM_ESP32 0 +#endif + +#if defined(ESP8266) +# define THEENGS_PLATFORM_ESP8266 1 +#else +# define THEENGS_PLATFORM_ESP8266 0 +#endif + +// ============================================================================ +// PLATFORM-SPECIFIC HEADERS & MACROS +// ============================================================================ + +#if THEENGS_PLATFORM_ARDUINO +# include +// ESP32 and others: Use ArduinoLog library +// Declare the global Log object (defined in main.cpp) +# include +extern Logging Log; +#endif + +// Define CR (Carriage Return) for all platforms +#ifndef CR +# define CR "\n" +#endif + +// ============================================================================ +// LOG LEVEL DEFINITIONS +// ============================================================================ + +#ifndef LOG_LEVEL_SILENT +# define LOG_LEVEL_SILENT 0 +#endif +#ifndef LOG_LEVEL_FATAL +# define LOG_LEVEL_FATAL 1 +#endif +#ifndef LOG_LEVEL_ERROR +# define LOG_LEVEL_ERROR 2 +#endif +#ifndef LOG_LEVEL_WARNING +# define LOG_LEVEL_WARNING 3 +#endif +#ifndef LOG_LEVEL_NOTICE +# define LOG_LEVEL_NOTICE 4 +#endif +#ifndef LOG_LEVEL_TRACE +# define LOG_LEVEL_TRACE 5 +#endif +#ifndef LOG_LEVEL_VERBOSE +# define LOG_LEVEL_VERBOSE 6 +#endif + +// Set default log level if not specified +#ifndef LOG_LEVEL +# define LOG_LEVEL LOG_LEVEL_NOTICE +#endif + +// ============================================================================ +// PLATFORM-SPECIFIC LOGGING IMPLEMENTATIONS +// ============================================================================ + +#if THEENGS_PLATFORM_ESP8266 +// The issue: ESP8266's F() macro (FPSTR(PSTR())) doesn't work with variadic templates +// This is a known limitation of the ESP8266 Arduino core preprocessor +// Use Serial.printf_P() directly instead of ArduinoLog for ESP8266 if needed +// Disable the use of F() on ESP8266 to avoid confusion +# define F(x) (x) +#endif + +#if THEENGS_PLATFORM_ARDUINO +// ESP32 and other Arduino platforms: Use ArduinoLog library +// ArduinoLog supports F() strings on these platforms + +# define THEENGS_LOG_IMPL_VERBOSE(fmt, ...) Log.verbose(fmt, ##__VA_ARGS__) +# define THEENGS_LOG_IMPL_TRACE(fmt, ...) Log.trace(fmt, ##__VA_ARGS__) +# define THEENGS_LOG_IMPL_NOTICE(fmt, ...) Log.notice(fmt, ##__VA_ARGS__) +# define THEENGS_LOG_IMPL_WARNING(fmt, ...) Log.warning(fmt, ##__VA_ARGS__) +# define THEENGS_LOG_IMPL_ERROR(fmt, ...) Log.error(fmt, ##__VA_ARGS__) +# define THEENGS_LOG_IMPL_FATAL(fmt, ...) Log.fatal(fmt, ##__VA_ARGS__) + +#else +// Native/test platform: Use fprintf to stderr +# ifndef F +# define F(x) (x) +# endif + +# ifdef __cplusplus +extern "C" { +# endif + +# include +# include +/** + * @brief Native platform logging implementation + * + * Thread-safe logging for native/test environments. + * Uses stderr for output with proper formatting. + * + * @param level Log level string (e.g., "ERROR", "WARNING") + * @param fmt printf-style format string + * @param ... Variable arguments matching format string + */ +static inline void native_log(const char* level, const char* fmt, ...) { + va_list args; + va_start(args, fmt); + + // Print log level prefix + fprintf(stderr, "[%s] ", level); + + // Print formatted message + vfprintf(stderr, fmt, args); + + va_end(args); + + // Flush to ensure immediate output (important for debugging) + fflush(stderr); +} + +# ifdef __cplusplus +} +# endif + +# define THEENGS_LOG_IMPL_VERBOSE(...) native_log("VERBOSE", __VA_ARGS__) +# define THEENGS_LOG_IMPL_TRACE(...) native_log("TRACE", __VA_ARGS__) +# define THEENGS_LOG_IMPL_NOTICE(...) native_log("NOTICE", __VA_ARGS__) +# define THEENGS_LOG_IMPL_WARNING(...) native_log("WARNING", __VA_ARGS__) +# define THEENGS_LOG_IMPL_ERROR(...) native_log("ERROR", __VA_ARGS__) +# define THEENGS_LOG_IMPL_FATAL(...) native_log("FATAL", __VA_ARGS__) + +#endif + +// ============================================================================ +// UNIFIED PUBLIC LOGGING MACROS +// ============================================================================ + +/** + * Logging macros with compile-time optimization: + * - When LOG_LEVEL excludes a level, macro becomes ((void)0) - zero overhead + * - Supports usage in ternary operators and expressions + * - Compatible with F() macro on all platforms + * - Format: THEENGS_LOG_(format, ...) + * + * Example usage: + * THEENGS_LOG_NOTICE(F("WiFi connected: %s" CR), WiFi.localIP().toString().c_str()); + * THEENGS_LOG_ERROR(F("Sensor read failed: %d" CR), error_code); + * THEENGS_LOG_TRACE("Debug value: %d" CR, value); // Without F() also works + */ + +#if LOG_LEVEL >= LOG_LEVEL_VERBOSE +# define THEENGS_LOG_VERBOSE(...) THEENGS_LOG_IMPL_VERBOSE(__VA_ARGS__) +#else +# define THEENGS_LOG_VERBOSE(...) ((void)0) +#endif + +#if LOG_LEVEL >= LOG_LEVEL_TRACE +# define THEENGS_LOG_TRACE(...) THEENGS_LOG_IMPL_TRACE(__VA_ARGS__) +#else +# define THEENGS_LOG_TRACE(...) ((void)0) +#endif + +#if LOG_LEVEL >= LOG_LEVEL_NOTICE +# define THEENGS_LOG_NOTICE(...) THEENGS_LOG_IMPL_NOTICE(__VA_ARGS__) +#else +# define THEENGS_LOG_NOTICE(...) ((void)0) +#endif + +#if LOG_LEVEL >= LOG_LEVEL_WARNING +# define THEENGS_LOG_WARNING(...) THEENGS_LOG_IMPL_WARNING(__VA_ARGS__) +#else +# define THEENGS_LOG_WARNING(...) ((void)0) +#endif + +#if LOG_LEVEL >= LOG_LEVEL_ERROR +# define THEENGS_LOG_ERROR(...) THEENGS_LOG_IMPL_ERROR(__VA_ARGS__) +#else +# define THEENGS_LOG_ERROR(...) ((void)0) +#endif + +#if LOG_LEVEL >= LOG_LEVEL_FATAL +# define THEENGS_LOG_FATAL(...) THEENGS_LOG_IMPL_FATAL(__VA_ARGS__) +#else +# define THEENGS_LOG_FATAL(...) ((void)0) +#endif diff --git a/main/User_config.h b/main/User_config.h index 31a8351004..30ea7c2211 100644 --- a/main/User_config.h +++ b/main/User_config.h @@ -27,6 +27,7 @@ */ #ifndef user_config_h #define user_config_h + /*-------------------VERSION----------------------*/ #ifndef OMG_VERSION # define OMG_VERSION "version_tag" @@ -149,20 +150,6 @@ # endif #endif -#ifndef JSON_MSG_BUFFER -# if defined(ESP32) -# define JSON_MSG_BUFFER 1024 // adjusted to minimum size covering largest home assistant discovery messages -# if MQTT_SECURE_DEFAULT -# define JSON_MSG_BUFFER_MAX 2048 // Json message buffer size increased to handle certificate changes through MQTT, used for the queue and the coming MQTT messages -# else -# define JSON_MSG_BUFFER_MAX 1024 // Minimum size for the cover MQTT discovery message -# endif -# elif defined(ESP8266) -# define JSON_MSG_BUFFER 512 // Json message max buffer size, don't put 768 or higher it is causing unexpected behaviour on ESP8266, certificates handling with ESP8266 is not tested -# define JSON_MSG_BUFFER_MAX 832 // Minimum size for MQTT discovery message -# endif -#endif - #ifndef mqtt_max_payload_size # define mqtt_max_payload_size JSON_MSG_BUFFER_MAX + mqtt_topic_max_size + 10 // maximum size of the MQTT payload #endif @@ -223,13 +210,13 @@ #endif #define GITHUB_OTA_SERVER_CERT_HASH "d4d211b4553af9fac371f24c2268d59d2b0fec6b9aa0fdbbde068f078d7daf86" // SHA256 fingerprint of the certificate used by the OTA server - +// clang-format off #if AWS_IOT // Enable the use of ALPN for AWS IoT Core with the port 443 # define ALPN_PROTOCOLS \ - { "x-amzn-mqtt-ca", NULL } + {"x-amzn-mqtt-ca", NULL} #endif - +// clang-format on //# define MQTT_HTTPS_FW_UPDATE //uncomment to enable updating via MQTT message. #ifdef MQTT_HTTPS_FW_UPDATE @@ -600,46 +587,6 @@ extern ss_cnt_parameters cnt_parameters_array[]; #endif #define TimeToResetAtStart 5000 // Time we allow the user at start for the reset command by button press -#include - -/*-------------------DEFINE LOG LEVEL----------------------*/ -#ifndef LOG_LEVEL -# define LOG_LEVEL LOG_LEVEL_NOTICE -#endif - -/*-------------------SIMPLIFIED LOGGING MACROS----------------------*/ -// ArduinoLog levels: SILENT=0, FATAL=1, ERROR=2, WARNING=3, NOTICE=4, TRACE=5, VERBOSE=6 -#if LOG_LEVEL >= LOG_LEVEL_VERBOSE -# define THEENGS_LOG_VERBOSE(...) Log.verbose(__VA_ARGS__) -#else -# define THEENGS_LOG_VERBOSE(...) ((void)0) -#endif -#if LOG_LEVEL >= LOG_LEVEL_TRACE -# define THEENGS_LOG_TRACE(...) Log.trace(__VA_ARGS__) -#else -# define THEENGS_LOG_TRACE(...) ((void)0) -#endif -#if LOG_LEVEL >= LOG_LEVEL_NOTICE -# define THEENGS_LOG_NOTICE(...) Log.notice(__VA_ARGS__) -#else -# define THEENGS_LOG_NOTICE(...) ((void)0) -#endif -#if LOG_LEVEL >= LOG_LEVEL_WARNING -# define THEENGS_LOG_WARNING(...) Log.warning(__VA_ARGS__) -#else -# define THEENGS_LOG_WARNING(...) ((void)0) -#endif -#if LOG_LEVEL >= LOG_LEVEL_ERROR -# define THEENGS_LOG_ERROR(...) Log.error(__VA_ARGS__) -#else -# define THEENGS_LOG_ERROR(...) ((void)0) -#endif -#if LOG_LEVEL >= LOG_LEVEL_FATAL -# define LOG_FATAL(...) Log.fatal(__VA_ARGS__) -#else -# define LOG_FATAL(...) ((void)0) -#endif - /*-------------------ESP Wifi band and tx power ---------------------*/ //Certain sensors are sensitive to Wifi which can cause interference with their normal operation //For example it can cause false triggers on a PIR HC-SR501 diff --git a/main/blufiSec.cpp b/main/blufiSec.cpp index 0ac90c1467..a38d8669cd 100644 --- a/main/blufiSec.cpp +++ b/main/blufiSec.cpp @@ -6,8 +6,7 @@ #if defined(ESP32) && defined(USE_BLUFI) -# include - +# include "TheengsLogs.h" # include "User_config.h" # include "esp_blufi_api.h" # include "esp_crc.h" diff --git a/main/certs/default_client_cert.h b/main/certs/default_client_cert.h index a6d496f36b..30cbca787d 100644 --- a/main/certs/default_client_cert.h +++ b/main/certs/default_client_cert.h @@ -1,5 +1,9 @@ #pragma once -#include + +// Make compatible with both Arduino and native testing environments +#ifndef PROGMEM +# define PROGMEM +#endif static const char* ss_client_cert PROGMEM = R"EOF(" -----BEGIN CERTIFICATE----- diff --git a/main/certs/default_client_key.h b/main/certs/default_client_key.h index f41ddd687b..bc8d8d1166 100644 --- a/main/certs/default_client_key.h +++ b/main/certs/default_client_key.h @@ -1,5 +1,8 @@ #pragma once -#include +// Make compatible with both Arduino and native testing environments +#ifndef PROGMEM +# define PROGMEM +#endif static const char* ss_client_key PROGMEM = R"EOF(" -----BEGIN RSA PRIVATE KEY----- diff --git a/main/certs/default_ota_cert.h b/main/certs/default_ota_cert.h index a901ab5a4c..416ec3240a 100644 --- a/main/certs/default_ota_cert.h +++ b/main/certs/default_ota_cert.h @@ -1,7 +1,10 @@ // The certificate must be in PEM ascii format. // The default certificate is for ota.openmqttgateway.com #pragma once -#include +// Make compatible with both Arduino and native testing environments +#ifndef PROGMEM +# define PROGMEM +#endif static const char* OTAserver_cert PROGMEM = R"EOF(" -----BEGIN CERTIFICATE----- diff --git a/main/certs/default_server_cert.h b/main/certs/default_server_cert.h index 29977ce95f..9dd6580bdd 100644 --- a/main/certs/default_server_cert.h +++ b/main/certs/default_server_cert.h @@ -1,5 +1,8 @@ #pragma once -#include +// Make compatible with both Arduino and native testing environments +#ifndef PROGMEM +# define PROGMEM +#endif static const char* ss_server_cert PROGMEM = R"EOF(" -----BEGIN CERTIFICATE----- diff --git a/main/commonRF.cpp b/main/commonRF.cpp index 4eea813d00..5c6499cdd8 100644 --- a/main/commonRF.cpp +++ b/main/commonRF.cpp @@ -32,6 +32,8 @@ # include # include "TheengsCommon.h" +# include "TheengsLogs.h" +# include "config_JSONMessages.h" # include "config_RF.h" # ifdef ZgatewayRTL_433 @@ -43,14 +45,22 @@ int currentReceiver = ACTIVE_NONE; extern void enableActiveReceiver(); extern void disableCurrentReceiver(); +# if defined(ESP32) +# include +extern NVSPreferencesStorage myStorage; +# else +# include +extern NoopStorage myStorage; +# endif + // Note: this is currently just a simple wrapper used to make everything work. // It prevents introducing external dependencies on newly added C++ structures, // and acts as a first approach to mask the concrete implementations (rf, rf2, // pilight, etc.). Later this can be extended or replaced by more complete driver // abstractions without changing the rest of the system. -class ZCommonRFWrapper : public RFReceiver { +class ZCommonRFWrapper : public RFBaseGateway { public: - ZCommonRFWrapper() : RFReceiver() {} + ZCommonRFWrapper() : RFBaseGateway() {} void enable() override { enableActiveReceiver(); } void disable() override { disableCurrentReceiver(); } @@ -58,7 +68,7 @@ class ZCommonRFWrapper : public RFReceiver { }; ZCommonRFWrapper iRFReceiver; -RFConfiguration iRFConfig(iRFReceiver); +RFConfiguration iRFConfig(iRFReceiver, myStorage); //TODO review void initCC1101() { @@ -194,7 +204,7 @@ String stateRFMeasures() { JsonObject RFdata = jsonBuffer.to(); // load the configuration - iRFConfig.toJson(RFdata); + iRFConfig.to(RFdata); // load the current state # if defined(ZradioCC1101) || defined(ZradioSX127x) diff --git a/main/config_JSONMessages.h b/main/config_JSONMessages.h new file mode 100644 index 0000000000..43f40cedc7 --- /dev/null +++ b/main/config_JSONMessages.h @@ -0,0 +1,28 @@ +#pragma once + +#define ARDUINOJSON_USE_LONG_LONG 1 +#define ARDUINOJSON_ENABLE_STD_STRING 1 + +#ifndef JSON_MSG_BUFFER +# if defined(ESP32) +# define JSON_MSG_BUFFER 1024 // adjusted to minimum size covering largest home assistant discovery messages +# if MQTT_SECURE_DEFAULT +# define JSON_MSG_BUFFER_MAX 2048 // Json message buffer size increased to handle certificate changes through MQTT, used for the queue and the coming MQTT messages +# else +# define JSON_MSG_BUFFER_MAX 1024 // Minimum size for the cover MQTT discovery message +# endif +# elif defined(ESP8266) +# define JSON_MSG_BUFFER 512 // Json message max buffer size, don't put 768 or higher it is causing unexpected behaviour on ESP8266, certificates handling with ESP8266 is not tested +# define JSON_MSG_BUFFER_MAX 832 // Minimum size for MQTT discovery message +# endif +#endif + +/* +This include must be after the above defines because the previous defines are configuring ArduinoJson and change the namespaces of object used here, +if the include is before the defines it will not work as expected, becase in link fase some objects will be missing or redefinied. +*/ +#include + +namespace OMG { +typedef JsonObject MQTTMessage; +} \ No newline at end of file diff --git a/main/core/Filter.cpp b/main/core/Filter.cpp new file mode 100644 index 0000000000..1ff5026f73 --- /dev/null +++ b/main/core/Filter.cpp @@ -0,0 +1,356 @@ +#include +#include + +#include +#include + +// Constructor +Filter::Filter(IStorage& storageRef) : AbstractStorageObject(storageRef, "Filters") { + THEENGS_LOG_VERBOSE(F("Filter: Initialized with storage reference." CR)); + pass_.reserve(10); // Pre-allocate for common case + block_.reserve(10); +} + +// Updates the object from a JSON object +void Filter::from(JsonObject& data) { + THEENGS_LOG_VERBOSE(F("Filter: Loading filters from JSON data." CR)); + + auto extractAllValue = [](JsonObject& data, RuleType rule, Filter* filter) { + const char* ruleTypeName = (rule == PASS) ? "pass" : "block"; + + for (JsonPair kv : data) { + if (!kv.value().is()) { + THEENGS_LOG_WARNING(F("Filter: Invalid structure for key '%s', expected JsonArray" CR), kv.key().c_str()); + continue; + } + + JsonArray arr = kv.value().as(); + for (JsonVariant v : arr) { + if (!v.is()) { + THEENGS_LOG_WARNING(F("Filter: Invalid value type for key '%s'" CR), kv.key().c_str()); + continue; + } + + const char* value = v.as(); + if (!filter->add(kv.key().c_str(), value, rule)) { + THEENGS_LOG_WARNING(F("Filter: Failed to add %s filter for key '%s' with value '%s' (limit reached)" CR), + ruleTypeName, kv.key().c_str(), value); + return; // Stop processing if limit reached + } + + THEENGS_LOG_VERBOSE(F("Filter: Added %s filter for key '%s' with value '%s'" CR), + ruleTypeName, kv.key().c_str(), value); + } + } + }; + + extractAllValue(data, PASS, this); + extractAllValue(data, BLOCK, this); + + THEENGS_LOG_VERBOSE(F("Filter: Finished loading filters from JSON. Total: %u/%u" CR), + getTotalFilterCount(), MAX_TOTAL_FILTERS); +} + +// Serializes the object to a JSON object +void Filter::to(JsonObject& data) { + THEENGS_LOG_VERBOSE(F("Filter: Serializing filters to JSON object." CR)); + + auto serializeAllValues = [](JsonObject& data, RuleType rule, Filter* filter) { + const auto& target = (rule == PASS) ? filter->pass_ : filter->block_; + + JsonObject nestedObj = data.createNestedObject((rule == PASS) ? "pass" : "block"); + + for (const auto& [key, patterns] : target) { + JsonArray arr = nestedObj.createNestedArray(key.c_str()); + for (const auto& ruleValue : patterns) { + arr.add(ruleValue.value.c_str()); + } + } + }; + + serializeAllValues(data, PASS, this); + serializeAllValues(data, BLOCK, this); + + THEENGS_LOG_VERBOSE(F("Filter: Finished serializing filters to JSON." CR)); +} + +// Updates the object from a JSON object +void Filter::fromRulesList(JsonObject& data) { + if (data.containsKey("rules")) { + THEENGS_LOG_VERBOSE(F("Filter: Loading rules from Message." CR)); + if (data["rules"].is()) { + JsonArray listOfRules = data["rules"].as(); + for (JsonVariant ruleVariant : listOfRules) { + // Verify that the element is a JsonObject before processing + if (!ruleVariant.is()) { + THEENGS_LOG_ERROR(F("Filter: Rule is malformed, is not a object skipping." CR)); + continue; + } + + JsonObject rule = ruleVariant.as(); + const char* target = rule["target"]; + const char* action = rule["action"]; + const char* value = rule["value"]; + const char* key = rule["key"]; + if (action && value) { + Filter::RuleType ruleType; + if (strcmp(action, "pass") == 0) { + ruleType = Filter::PASS; + } else if (strcmp(action, "block") == 0) { + ruleType = Filter::BLOCK; + } else { + THEENGS_LOG_WARNING(F("Filter: Unknown action '%s', skipping rule." CR), action); + continue; + } + + if (target && (strcmp(target, "topic") == 0)) { + if (!this->addTopicFilters(value, ruleType)) { + THEENGS_LOG_WARNING(F("Filter: Failed to add topic rule - target: '%s', action: '%s', value: '%s'" CR), + target, action, value); + } else { + THEENGS_LOG_TRACE(F("Filter: Added topic rule - target: '%s', action: '%s', value: '%s'" CR), target, action, value); + } + } else { + if (key) { + if (!this->add(key, value, ruleType)) { + THEENGS_LOG_WARNING(F("Filter: Failed to add rule - target: '%s', action: '%s', key: '%s', value: '%s'" CR), + target, action, key, value); + } else { + THEENGS_LOG_VERBOSE(F("Filter: Added rule - target: '%s', action: '%s', key: '%s', value: '%s'" CR), + target, action, key, value); + } + } else { + THEENGS_LOG_ERROR(F("Filter: Rule missing 'key' field - target: '%s', action: '%s', value: '%s'" CR), + target, action, value); + } + } + } else { + THEENGS_LOG_ERROR(F("Filter: Rule malformed missing required fields." CR)); + } + } + } else { + THEENGS_LOG_WARNING(F("Filter: 'rules' field is not a Array" CR)); + } + } +} + +// Serializes the object to a JSON object +void Filter::toRulesList(JsonObject& data) { + THEENGS_LOG_VERBOSE(F("Filter: Serializing to JSON." CR)); + JsonArray rulesArray = data.createNestedArray("rules"); + + // Serialize pass filters + for (const auto& [key, values] : pass_) { + for (const auto& ruleValue : values) { + JsonObject ruleObj = rulesArray.createNestedObject(); + ruleObj["target"] = (key == TOPIC_FILTER_KEY) ? "topic" : "value"; + ruleObj["action"] = "pass"; + ruleObj["key"] = (key == TOPIC_FILTER_KEY) ? "" : key.c_str(); + ruleObj["value"] = ruleValue.value.c_str(); + } + } + + // Serialize block filters + for (const auto& [key, values] : block_) { + for (const auto& ruleValue : values) { + JsonObject ruleObj = rulesArray.createNestedObject(); + ruleObj["target"] = (key == TOPIC_FILTER_KEY) ? "topic" : "value"; + ruleObj["action"] = "block"; + ruleObj["key"] = (key == TOPIC_FILTER_KEY) ? "" : key.c_str(); + ruleObj["value"] = ruleValue.value.c_str(); + } + } + + THEENGS_LOG_VERBOSE(F("Filter: Serialization complete." CR)); +} + +// Saves the filter configuration to a string +void Filter::to(const char* data) { + if (!data) { + THEENGS_LOG_ERROR(F("Filter: Null data pointer provided to to()" CR)); + return; + } + + StaticJsonDocument doc; + JsonObject obj = doc.to(); + this->to(obj); + serializeJson(doc, const_cast(data), JSON_MSG_BUFFER); + + THEENGS_LOG_VERBOSE(F("Filter: Serialized filters to string." CR)); +} + +// Adds a value to the pass or block with capacity checking +bool Filter::add(const char* key, const char* value, RuleType rule) { + // Input validation + if (!key || !value) { + THEENGS_LOG_ERROR(F("Filter: Null key or value provided to add()" CR)); + return false; + } + + // Check capacity BEFORE adding (fail-silent for ESP32 safety) + if (!hasCapacity()) { + THEENGS_LOG_WARNING(F("Filter: Cannot add filter - limit of %u reached" CR), MAX_TOTAL_FILTERS); + return false; + } + + const char* ruleTypeName = (rule == PASS) ? "pass" : "block"; + THEENGS_LOG_VERBOSE(F("Filter: Adding %s filter for key '%s' with value '%s'" CR), + ruleTypeName, key, value); + + // Select target map + auto& target = (rule == PASS) ? pass_ : block_; + + // Intern the key for memory efficiency + std::string internedKey(key); + + // Add the rule value (exceptions disabled on ESP8266) + target[internedKey].emplace_back(value); + + THEENGS_LOG_VERBOSE(F("Filter: Successfully added %s filter. Total: %u/%u" CR), + ruleTypeName, getTotalFilterCount(), MAX_TOTAL_FILTERS); + return true; +} + +// Removes a value from the pass or block +bool Filter::remove(const char* key, const char* value) { + if (!key || !value) { + THEENGS_LOG_ERROR(F("Filter: Null key or value provided to remove()" CR)); + return false; + } + + THEENGS_LOG_VERBOSE(F("Filter: Removing filter for key '%s' with value '%s'" CR), key, value); + + bool removed = false; + std::string keyStr(key); + + auto removeFromList = [value, &removed](std::vector& list) { + auto it = std::remove_if(list.begin(), list.end(), + [value](const RuleValue& rv) { + return rv.value == value; + }); + + if (it != list.end()) { + list.erase(it, list.end()); + removed = true; + } + }; + + // Remove from both lists + if (auto it = pass_.find(keyStr); it != pass_.end()) { + removeFromList(it->second); + if (it->second.empty()) { + pass_.erase(it); + } + } + + if (auto it = block_.find(keyStr); it != block_.end()) { + removeFromList(it->second); + if (it->second.empty()) { + block_.erase(it); + } + } + + if (removed) { + THEENGS_LOG_VERBOSE(F("Filter: Removed filter for key '%s' with value '%s'. Total: %u/%u" CR), + key, value, getTotalFilterCount(), MAX_TOTAL_FILTERS); + } else { + THEENGS_LOG_VERBOSE(F("Filter: Filter not found for key '%s' with value '%s'" CR), key, value); + } + + return removed; +} + +// Checks if a value is contained in the pass or block +bool Filter::contains(const char* key, const char* value, RuleType rule) const { + if (!key || !value) { + return false; + } + + const char* ruleTypeName = (rule == PASS) ? "pass" : "block"; + // THEENGS_LOG_VERBOSE(F("Filter: Checking %s filters for key '%s' with value '%s'" CR), ruleTypeName, key, value); + + const auto& target = (rule == PASS) ? pass_ : block_; + std::string keyStr(key); + + auto it = target.find(keyStr); + if (it == target.end()) { + // THEENGS_LOG_VERBOSE(F("Filter: Key '%s' not found in %s filters" CR), key, ruleTypeName); + return false; + } + + // Check all rule values for this key + for (const auto& ruleValue : it->second) { + if (ruleValue.matches(value)) { + // THEENGS_LOG_VERBOSE(F("Filter: Match found for key '%s' with value '%s' in %s filters" CR), key, value, ruleTypeName); + return true; + } + } + + // THEENGS_LOG_VERBOSE(F("Filter: No match found for key '%s' with value '%s' in %s filters" CR), key, value, ruleTypeName); + return false; +} + +// Checks if the pass or block is empty +bool Filter::isEmptyThe(RuleType rule) const { + const auto& target = (rule == PASS) ? pass_ : block_; + bool empty = target.empty(); + return empty; +} + +// Counts total filters in a map +size_t Filter::countFiltersIn(const std::unordered_map>& map) const { + size_t count = 0; + for (const auto& [key, values] : map) { + count += values.size(); + } + return count; +} + +// Gets total number of filters +size_t Filter::getTotalFilterCount() const { + return countFiltersIn(pass_) + countFiltersIn(block_); +} + +// Gets number of filters in specified list +size_t Filter::getFilterCount(RuleType rule) const { + const auto& target = (rule == PASS) ? pass_ : block_; + return countFiltersIn(target); +} + +// Clears all filters +void Filter::clear() { + THEENGS_LOG_VERBOSE(F("Filter: Clearing all filters" CR)); + pass_.clear(); + block_.clear(); + THEENGS_LOG_VERBOSE(F("Filter: All filters cleared" CR)); +} + +bool Filter::addTopicFilters(const char* value, RuleType rule) { + if (!value) { + THEENGS_LOG_ERROR(F("Filter: Null value provided to addTopicFilters()" CR)); + return false; + } + + THEENGS_LOG_VERBOSE(F("Filter: Adding topic filters from value '%s'" CR), value); + return add(TOPIC_FILTER_KEY, value, rule); +} + +bool Filter::removeTopicFilters(const char* value) { + if (!value) { + THEENGS_LOG_ERROR(F("Filter: Null value provided to addTopicFilters()" CR)); + return false; + } + + //THEENGS_LOG_VERBOSE(F("Filter: Adding topic filters from value '%s'" CR), value); + return remove(TOPIC_FILTER_KEY, value); +} + +bool Filter::isTopicFilterPresent(const char* value, RuleType rule) { + if (!value) { + THEENGS_LOG_ERROR(F("Filter: Null value provided to isTopicFilterPresent()" CR)); + return false; + } + + //THEENGS_LOG_VERBOSE(F("Filter: Checking topic filters for value '%s'" CR), value); + return contains(TOPIC_FILTER_KEY, value, rule); +} \ No newline at end of file diff --git a/main/core/Filter.h b/main/core/Filter.h new file mode 100644 index 0000000000..7ea0605279 --- /dev/null +++ b/main/core/Filter.h @@ -0,0 +1,256 @@ +#pragma once +#include +#include +#include +#include + +#include +#include +#include +#include + +// ESP32 memory constraints - limit total filters +static constexpr size_t MAX_TOTAL_FILTERS = 50; + +// Topic filter key constant - stored efficiently for memory-constrained systems +static constexpr const char TOPIC_FILTER_KEY[] = "_topic_"; + +/** + * @brief Wildcard pattern matching for ESP32. + * + * Supports simple wildcards: * (any sequence) and ? (single char). + * Much lighter than regex - suitable for resource-constrained ESP32. + * + * @param pattern Pattern with wildcards (* and ?) + * @param text Text to match against pattern + * @return true if text matches pattern + */ +inline bool wildcardMatch(const char* pattern, const char* text) { + if (!pattern || !text) return false; + + const char* p = pattern; + const char* t = text; + const char* starPos = nullptr; + const char* textPos = nullptr; + + while (*t) { + if (*p == '*') { + // Remember position after * for backtracking + starPos = p++; + textPos = t; + } else if (*p == '?' || *p == *t) { + // Match single char or exact match + p++; + t++; + } else if (starPos) { + // Backtrack to last * and try next position + p = starPos + 1; + t = ++textPos; + } else { + return false; + } + } + + // Skip trailing * in pattern + while (*p == '*') p++; + + return *p == '\0'; +} + +/** + * @brief Represents a filter rule value with optional wildcard pattern. + * + * Optimized for ESP32 with lightweight wildcard matching (* and ?). + * Avoids heavy regex compilation, saving memory and CPU cycles. + */ +struct RuleValue { + std::string value; + bool is_pattern; + + explicit RuleValue(const char* str) : value(str ? str : ""), is_pattern(false) { + if (value.empty()) return; + + // Check if value contains wildcard characters + is_pattern = (value.find('*') != std::string::npos || + value.find('?') != std::string::npos); + + if (is_pattern) { + THEENGS_LOG_VERBOSE(F("Filter: Wildcard pattern detected: '%s'" CR), value.c_str()); + } + } + + // Default copy/move semantics are fine for simple types + RuleValue(const RuleValue&) = default; + RuleValue& operator=(const RuleValue&) = default; + RuleValue(RuleValue&&) noexcept = default; + RuleValue& operator=(RuleValue&&) noexcept = default; + + /** + * @brief Checks if input matches this rule value. + * @param input String to match against + * @return true if matches, false otherwise + */ + bool matches(const char* input) const { + if (!input) return false; + + if (is_pattern) { + // Wildcard matching (* and ?) + return wildcardMatch(value.c_str(), input); + } else { + // Direct string comparison + return value == input; + } + } + + /** + * @brief Checks if this is a wildcard pattern. + */ + bool is_wildcard() const { return is_pattern; } +}; + +/** + * @brief Filter class for MQTT message filtering with ESP32 optimizations. + * + * Implements pass/block list filtering with: + * - Memory-safe string handling (no dangling pointers) + * - 50-filter total limit to prevent heap exhaustion + * - Lightweight wildcard matching (* and ?) instead of regex + * - RAII resource management + * - Fail-silent overflow protection + */ +class Filter : public AbstractStorageObject, public IJsonable { +public: + enum RuleType { BLOCK = 0, + PASS = 1 }; + + explicit Filter(IStorage& storageRef); + virtual ~Filter() = default; + + // Disable copy, enable move + Filter(const Filter&) = delete; + Filter& operator=(const Filter&) = delete; + Filter(Filter&&) noexcept = default; + Filter& operator=(Filter&&) noexcept = default; + + /** + * @brief Updates the object from a JSON object. + * @param data A reference to a JsonObject containing the data. + */ + void from(JsonObject& data) override; + + /** + * @brief Serializes the object to a JSON object. + * @param data A reference to a JsonObject where the object data will be serialized. + */ + void to(JsonObject& data) override; + + /** + * @brief Updates the object from a JSON object that have a "rules" array. + * @param data A reference to a JsonObject containing the data. + */ + void fromRulesList(JsonObject& data); + + /** + * @brief Serializes the object to a JSON object that have a "rules" array. + * @param data A reference to a JsonObject where the object data will be serialized. + */ + void toRulesList(JsonObject& data); + + /** + * @brief Saves the filter configuration to a string. + * @param data A pointer to a character array where the configuration will be saved. + */ + void to(const char* data); + + /** + * @brief Adds a value to the pass or block list. + * @param key The key associated with the value to be added. + * @param value The value to be added. + * @param rule PASS to add to pass list, BLOCK to add to block list + * @return true if added successfully, false if limit reached + */ + bool add(const char* key, const char* value, RuleType rule); + + /** + * @brief Removes a value from pass and block lists. + * @param key The key associated with the value to be removed. + * @param value The value to be removed. + * @return true if removed successfully, false if not found + */ + bool remove(const char* key, const char* value); + + /** + * @brief Checks if a value is contained in the specified list. + * @param key The key associated with the value to be checked. + * @param value The value to be checked. + * @param rule PASS to check in pass list, BLOCK to check in block list + * @return true if the value is found, false otherwise. + */ + bool contains(const char* key, const char* value, RuleType rule) const; + + /** + * @brief Checks if the specified list is empty. + * @param rule PASS to check pass list, BLOCK to check block list + * @return true if the list is empty, false otherwise. + */ + bool isEmptyThe(RuleType rule) const; + + /** + * @brief Gets total number of filters across both lists. + * @return Total filter count + */ + size_t getTotalFilterCount() const; + + /** + * @brief Gets number of filters in specified list. + * @param rule PASS or BLOCK + * @return Filter count for specified list + */ + size_t getFilterCount(RuleType rule) const; + + /** + * @brief Checks if more filters can be added. + * @return true if under limit, false if at capacity + */ + bool hasCapacity() const { return getTotalFilterCount() < MAX_TOTAL_FILTERS; } + + /** + * @brief Clears all filters. + */ + void clear(); + + /** + * + * @brief Adds multiple topic filters. + * + * @param value The topic Filter + * @param rule PASS to add to pass list, BLOCK to add to block list + * @return true if added successfully, false otherwise + */ + bool addTopicFilters(const char* value, RuleType rule); + + /** + * + * @brief Removes multiple topic filters. + * + * @param value The topic Filter + * @return true if removed successfully, false otherwise + */ + bool removeTopicFilters(const char* value); + + /** + * @brief Checks if a topic filter is present in the specified list. + */ + bool isTopicFilterPresent(const char* value, RuleType rule); + +private: + std::unordered_map> pass_; + std::unordered_map> block_; + + /** + * @brief Counts total filters in a map. + * @param map Map to count filters in + * @return Total number of filter values + */ + size_t countFiltersIn(const std::unordered_map>& map) const; +}; diff --git a/main/core/MessageFilter.cpp b/main/core/MessageFilter.cpp new file mode 100644 index 0000000000..93538fe878 --- /dev/null +++ b/main/core/MessageFilter.cpp @@ -0,0 +1,316 @@ +#include "MessageFilter.h" + +/** + * @brief Checks if a given MQTT value is present in the block. + * + * Iterates through all keys in the MQTTvalue JsonObject and checks each value + * against the block patterns. + * + * @param MQTTvalue The MQTT value to check against the block. + * @return true if the MQTT value is in the block and the block + * check is not ignored; false otherwise. + */ +bool MessageFilter::inBlockList(JsonObject& MQTTvalue) { + //THEENGS_LOG_VERBOSE(F("MessageFilter: Checking block list for message." CR)); + // If block is ignored, always return false (bypass check) + if (ignoreBlacklist) { + THEENGS_LOG_VERBOSE(F("MessageFilter: Block list is ignored" CR)); + return false; + } + + // Fast path: If block is empty, allow all messages (per documentation) + if (filters.isEmptyThe(Filter::BLOCK)) { + THEENGS_LOG_VERBOSE(F("MessageFilter: Block list is empty" CR)); + return false; + } + + // Iterate through all keys in the MQTT message + for (JsonPair kv : MQTTvalue) { + // Handle string values directly without unnecessary conversions + if (kv.value().is()) { + const char* key = kv.key().c_str(); + + // Null check for safety + if (key == nullptr) continue; + + const char* value = kv.value().as(); + + // Null check for safety + if (value == nullptr) continue; + + if (filters.contains(key, value, Filter::BLOCK)) { + // Check if this value matches any block + THEENGS_LOG_TRACE(F("MessageFilter: Match found in BLOCK list for key '%s' with value '%s'" CR), key, value); + return true; + } + } + } + THEENGS_LOG_TRACE(F("MessageFilter: No matches found in BLOCK list" CR)); + return false; +} + +/** + * @brief Checks if a given MQTT value is in the pass. + * + * Iterates through all keys in the MQTTvalue JsonObject and checks each value + * against the pass patterns. If the pass is + * disabled or empty, the function returns true (allows all messages). + * + * @performance O(nร—m) where n=message keys, m=filter count + * With 20 keys ร— 50 filters = 1000 ops/msg + * Early exit on first match minimizes average case + * + * @param MQTTvalue The MQTT value to check against the pass. + * @return true if the pass is disabled, empty, or the value is found in the pass. + * @return false if the value is not in the pass. + */ +bool MessageFilter::inPassList(JsonObject& MQTTvalue) { + //THEENGS_LOG_VERBOSE(F("MessageFilter: Checking pass list for message." CR)); + + // Fast path: If pass is ignored, always return true (allow all) + if (ignoreWhitelist) { + THEENGS_LOG_VERBOSE(F("MessageFilter: Pass is ignored, allowing all messages." CR)); + return true; + } + + // Fast path: If pass is empty, allow all messages (per documentation) + if (filters.isEmptyThe(Filter::PASS)) { + THEENGS_LOG_VERBOSE(F("MessageFilter: Pass is empty, allowing all messages." CR)); + return true; + } + + // Iterate through all keys in the MQTT message + // Performance: O(n) where n = number of keys in message + for (JsonPair kv : MQTTvalue) { + // Handle string values directly without unnecessary conversions + if (kv.value().is()) { + const char* key = kv.key().c_str(); + if (!key) continue; // Null check + + const char* value = kv.value().as(); + if (!value) continue; // Null check + + // Check if this value matches any pass (early exit on match) + if (filters.contains(key, value, Filter::PASS)) { + THEENGS_LOG_TRACE(F("MessageFilter: Match found in PASS list for key '%s' with value '%s', allowing message." CR), key, value); + return true; // Early exit - performance optimization + } + } + } + + // No match found - return false (reject message) + THEENGS_LOG_TRACE(F("MessageFilter: No matches found in PASS list." CR)); + return false; +} + +/** + * @brief Checks if the pass is currently ignored. + * + * @return true if pass is ignored, false otherwise. + */ +bool MessageFilter::isPassListIgnored() const { + THEENGS_LOG_VERBOSE(F("MessageFilter: Checking if pass list is ignored: %s." CR), ignoreWhitelist ? "true" : "false"); + return ignoreWhitelist; +} + +/** + * @brief Sets whether to ignore the pass. + * + * @param ignore true to ignore pass, false to enforce it. + */ +void MessageFilter::ignorePassList(bool ignore) { + THEENGS_LOG_VERBOSE(F("MessageFilter: Setting pass list ignore to %s." CR), ignore ? "true" : "false"); + ignoreWhitelist = ignore; +} + +/** + * @brief Checks if the block is currently ignored. + * + * @return true if block is ignored, false otherwise. + */ +bool MessageFilter::isBlockListIgnored() const { + //THEENGS_LOG_VERBOSE(F("MessageFilter: Checking if block list is ignored: %s." CR), ignoreBlacklist ? "true" : "false"); + return ignoreBlacklist; +} + +/** + * @brief Sets whether to ignore the block. + * + * @param ignore true to ignore block, false to enforce it. + */ +void MessageFilter::ignoreBlockList(bool ignore) { + //THEENGS_LOG_VERBOSE(F("MessageFilter: Setting block list ignore to %s." CR), ignore ? "true" : "false"); + ignoreBlacklist = ignore; +} + +void MessageFilter::handleMQTTCommand(JsonObject& command) { + THEENGS_LOG_VERBOSE(F("MessageFilter: Handling MQTT command." CR)); + + if (command.containsKey("filter")) { + JsonObject filter = command["filter"].as(); + + if (filter.containsKey("cmd")) { + const char* action = filter["cmd"].as(); + THEENGS_LOG_VERBOSE(F("MessageFilter: cmd specified: '%s'." CR), action ? action : "null"); + + if (action && strcmp(action, "reset") == 0) { + THEENGS_LOG_VERBOSE(F("MessageFilter: Reset all" CR)); + filters.clear(); + ignorePassList(true); + ignoreBlockList(true); + return; + } + + // Future actions can be handled here + if (action && strcmp(action, "clear") == 0) { + THEENGS_LOG_VERBOSE(F("MessageFilter: clear all" CR)); + filters.clear(); + return; + } + + if (action && strcmp(action, "persist") == 0) { + filters.saveOnStorage(); + return; + } + + if (action && strcmp(action, "reload") == 0) { + filters.clear(); + filters.loadFromStorage(); + return; + } + + if (action && strcmp(action, "purge") == 0) { + filters.clear(); + filters.eraseStorage(); + return; + } + + if (action && strcmp(action, "new") == 0) { + THEENGS_LOG_VERBOSE(F("MessageFilter: new filters as per action." CR)); + filters.clear(); + } + } + + if (filter.containsKey("rules")) { + THEENGS_LOG_VERBOSE(F("MessageFilter: Loading rules from Message." CR)); + filters.fromRulesList(filter); + } + + // Process pass filters + if (filter.containsKey("pass")) { + THEENGS_LOG_VERBOSE(F("MessageFilter: Processing pass filters." CR)); + JsonObject passFilter = filter["pass"].as(); + + for (JsonPair kv : passFilter) { + const char* key = kv.key().c_str(); + + if (!kv.value().is()) { + THEENGS_LOG_WARNING(F("MessageFilter: Pass filter for key '%s' is not an array" CR), key); + continue; + } + + JsonArray values = kv.value().as(); + for (const char* value : values) { + if (!filters.add(key, value, Filter::PASS)) { + THEENGS_LOG_WARNING(F("MessageFilter: Failed to add pass filter for key '%s' with value '%s'" CR), + key, value); + // Continue trying to add other filters even if one fails + } else { + THEENGS_LOG_VERBOSE(F("MessageFilter: Added pass filter for key '%s' with value '%s'" CR), key, value); + } + } + } + } + + // Process block filters + if (filter.containsKey("block")) { + THEENGS_LOG_VERBOSE(F("MessageFilter: Processing block filters." CR)); + JsonObject blockFilter = filter["block"].as(); + + for (JsonPair kv : blockFilter) { + const char* key = kv.key().c_str(); + + if (!kv.value().is()) { + THEENGS_LOG_WARNING(F("MessageFilter: Block filter for key '%s' is not an array" CR), key); + continue; + } + + JsonArray values = kv.value().as(); + for (const char* value : values) { + if (!filters.add(key, value, Filter::BLOCK)) { + THEENGS_LOG_WARNING(F("MessageFilter: Failed to add block filter for key '%s' with value '%s'" CR), + key, value); + } else { + THEENGS_LOG_VERBOSE(F("MessageFilter: Added block filter for key '%s' with value '%s'" CR), key, value); + } + } + } + } + + // Process ignore flags + if (filter.containsKey("ignore_pass")) { + THEENGS_LOG_VERBOSE(F("MessageFilter: Setting ignore_pass to %s." CR), + filter["ignore_pass"].as() ? "true" : "false"); + ignorePassList(filter["ignore_pass"].as()); + } + + if (filter.containsKey("ignore_block")) { + THEENGS_LOG_VERBOSE(F("MessageFilter: Setting ignore_block to %s." CR), + filter["ignore_block"].as() ? "true" : "false"); + ignoreBlockList(filter["ignore_block"].as()); + } + + THEENGS_LOG_VERBOSE(F("MessageFilter: Command handling complete. Total filters: %u/%u" CR), + filters.getTotalFilterCount(), MAX_TOTAL_FILTERS); + } else { + THEENGS_LOG_VERBOSE(F("MessageFilter: No filter configuration in command." CR)); + return; + } +} + +void MessageFilter::to(JsonObject& data) { + THEENGS_LOG_VERBOSE(F("MessageFilter: Serializing to JSON." CR)); + JsonObject filterObj = data.createNestedObject("filter"); + + // Serialize ignore flags + filterObj["ignore_pass"] = ignoreWhitelist; + filterObj["ignore_block"] = ignoreBlacklist; + + filters.toRulesList(filterObj); //retun as "rules" array more usable and human readable + + THEENGS_LOG_VERBOSE(F("MessageFilter: Serialization complete." CR)); +} + +bool MessageFilter::allowedTopic(const char* topic) { + if (!topic) { + THEENGS_LOG_WARNING(F("MessageFilter: allowedTopic called with null topic." CR)); + return false; + } + // Check block list first + if (!ignoreBlacklist && !filters.isEmptyThe(Filter::BLOCK)) { + if (filters.isTopicFilterPresent(topic, Filter::BLOCK)) { + THEENGS_LOG_WARNING(F("MessageFilter: Topic '%s' is blocked." CR), topic); + return false; + } + } + + // Check pass list - allow if ignored, empty, or topic matches + if (ignoreWhitelist) { + //THEENGS_LOG_TRACE(F("MessageFilter: Pass list ignored, allowing topic '%s'." CR), topic); + return true; + } + + if (filters.isEmptyThe(Filter::PASS)) { + //THEENGS_LOG_TRACE(F("MessageFilter: Pass list empty, allowing topic '%s'." CR), topic); + return true; + } + + if (filters.isTopicFilterPresent(topic, Filter::PASS)) { + //THEENGS_LOG_TRACE(F("MessageFilter: Topic '%s' found in pass list." CR), topic); + return true; + } + + // Topic not in pass list + THEENGS_LOG_WARNING(F("MessageFilter: Topic '%s' is blocked, not in pass list." CR), topic); + return false; +} \ No newline at end of file diff --git a/main/core/MessageFilter.h b/main/core/MessageFilter.h new file mode 100644 index 0000000000..a12bad7110 --- /dev/null +++ b/main/core/MessageFilter.h @@ -0,0 +1,103 @@ +#pragma once +#include +#include + +class MessageFilter : public IJsonable { +public: + MessageFilter(Filter& filterConfig) : filters(filterConfig) {} + + virtual ~MessageFilter() {} + + /** + * @brief Checks if a given MQTT value is present in the block. + * + * This function determines whether the specified MQTT message is included + * in the block defined in the If the `ignoreBlacklist` + * flag in RFConfiguration is set to true, the function will always return false, + * effectively bypassing the block check. + * + * @param MQTTvalue The MQTT value to check against the block. + * @return true if the MQTT value is in the block and the block + * check is not ignored; false otherwise. + */ + bool inBlockList(JsonObject& MQTTvalue); + + /** + * @brief Checks if a given MQTT value is in the pass. + * + * This function determines whether the specified MQTT value is present + * in the pass. If the pass is disabled (via the `ignoreWhitelist` + * flag) or is empty, the function will always return true. + * + * @param MQTTvalue The MQTT value to check against the pass. + * @return true if the pass is disabled, empty, or the value is found in the pass. + * @return false if the value is not in the pass. + */ + bool inPassList(JsonObject& MQTTvalue); + + /** + * @brief Checks whether the topic is allowed to pass based on the filters. + * + * @param topic The topic to be checked. + * @return true if the topic is allowed to pass; false otherwise. + */ + bool allowedTopic(const char* topic); + + /** + * @brief Checks whether the pass should be ignored during message filtering. + * + * @return true if the pass is set to be ignored; false otherwise. + */ + bool isPassListIgnored() const; + + /** + * @brief Sets whether the pass should be ignored during message filtering. + * + * @param ignore If true, the pass will be ignored; otherwise, it will be applied. + */ + void ignorePassList(bool ignore); + + /** + * @brief Checks whether the block should be ignored during message filtering. + * + * @return true if the block is set to be ignored; false otherwise. + */ + bool isBlockListIgnored() const; + + /** + * @brief Sets whether the block should be ignored during message filtering. + * + * @param ignore If true, the block will be ignored; otherwise, it will be applied. + */ + void ignoreBlockList(bool ignore); + + /** + * @brief Handles an MQTT command to modify the filter configuration. + * + * This function processes a JSON object representing an MQTT command + * to update the filter settings, such as adding or removing entries + * from the pass or block lists. + * + * @param command A reference to a JsonObject containing the command details. + */ + void handleMQTTCommand(JsonObject& command); + + /** + * @brief Serializes the object to a JSON object. + * + * @param data A reference to a JsonObject where the object data will be serialized. + */ + void to(JsonObject& data) override; + + /** + * @brief Updates the object from a JSON object. + * + * @param data A reference to a JsonObject containing the data. + */ + void from(JsonObject& data) { THEENGS_LOG_ERROR(F("MessageFilter::from() not available" CR)); }; + +private: + Filter& filters; + bool ignoreWhitelist = false; + bool ignoreBlacklist = false; +}; \ No newline at end of file diff --git a/main/main.cpp b/main/main.cpp index cefaf82c13..5131fde071 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -34,6 +34,7 @@ #include "LEDManager.h" #include "TheengsCommon.h" #include "TheengsUtils.h" +#include "config_JSONMessages.h" //safe include used for the best configuration of ArduinoJson GatewayState gatewayState = GatewayState::WAITING_ONBOARDING; static GatewayState previousGatewayState = gatewayState; @@ -96,17 +97,25 @@ char gateway_name[parameters_size + 1] = Gateway_Name; unsigned long lastDiscovery = 0; #if BLEDecryptor - char ble_aes[parameters_size] = BLE_AES; - StaticJsonDocument ble_aes_keys; +char ble_aes[parameters_size] = BLE_AES; +StaticJsonDocument ble_aes_keys; #endif #if !MQTT_BROKER_MODE ss_cnt_parameters cnt_parameters_array[cnt_parameters_array_size] = CNT_PARAMS_ARR; #endif +// Configuration of the Storage used on the projects + #if defined(ESP32) # include Preferences preferences; +//Wrapper of Preferences Storage +# include +NVSPreferencesStorage myStorage(&preferences); +#else +# include +NoopStorage myStorage; #endif // Modules config inclusion @@ -285,9 +294,16 @@ int failure_number_ntwk = 0; // number of failure connecting to network int failure_number_mqtt = 0; // number of failure connecting to MQTT static unsigned long last_ota_activity_millis = 0; + // Global struct to store live SYS configuration data SYSConfig_s SYSConfig; +//Loard Filter Handling +#include +#include +Filter iFilter(myStorage); +MessageFilter iMessageFilter(iFilter); + bool failSafeMode = false; bool ProcessLock = true; // Process lock when we want to use a critical function like OTA for example bool mqttSetupPending = true; @@ -622,6 +638,21 @@ bool pub(JsonObject& data) { return res; } + //------------------------ + // filtering options + //------------------------ + if (!iMessageFilter.allowedTopic(topic.c_str())) { + THEENGS_LOG_WARNING(F("Topic '%s' blocked by the filter" CR), topic.c_str()); + return res; + } + if (iMessageFilter.inBlockList(data) || !iMessageFilter.inPassList(data)) { + std::string jsonString; + serializeJson(data, jsonString); + THEENGS_LOG_WARNING(F("Message blocked by the filter: %s" CR), jsonString.c_str()); + return res; + } + //------------------------ + #if valueAsATopic # ifdef ZgatewayPilight String value = data["value"]; @@ -1334,8 +1365,18 @@ void updateAndHandleLEDsTask() { void setup() { //Launch serial for debugging purposes Serial.begin(SERIAL_BAUD); +#ifndef ESP8266 Log.begin(LOG_LEVEL, &Serial); +#endif THEENGS_LOG_NOTICE(F(CR "************* WELCOME TO OpenMQTTGateway **************" CR)); + THEENGS_LOG_NOTICE(F("Log Setup" CR)); + THEENGS_LOG_VERBOSE(F("Verbose log initialized" CR)); + THEENGS_LOG_TRACE(F("Trace log initialized" CR)); + THEENGS_LOG_NOTICE(F("Notice log initialized" CR)); + THEENGS_LOG_WARNING(F("Warning log initialized" CR)); + THEENGS_LOG_ERROR(F("Error log initialized" CR)); + THEENGS_LOG_FATAL(F("Fatal log initialized" CR)); + THEENGS_LOG_NOTICE(F("************* WELCOME TO OpenMQTTGateway **************" CR)); #if defined(TRIGGER_GPIO) && !defined(ESPWifiManualSetup) pinMode(TRIGGER_GPIO, INPUT_PULLUP); checkButton(); @@ -1346,6 +1387,8 @@ void setup() { SYSConfig_init(); SYSConfig_load(); + iFilter.loadFromStorage(); + if (SYSConfig.offline) { gatewayState = GatewayState::OFFLINE; } @@ -2183,11 +2226,11 @@ bool loadConfigFromFlash() { if (json.containsKey("ble_aes")) { strcpy(ble_aes, json["ble_aes"]); THEENGS_LOG_TRACE(F("loaded default BLE AES key %s" CR), ble_aes); - } + } if (json.containsKey("ble_aes_keys")) { ble_aes_keys = json["ble_aes_keys"]; THEENGS_LOG_TRACE(F("loaded %d custom BLE AES keys" CR), ble_aes_keys.size()); - } + } # endif result = true; } else { @@ -2884,6 +2927,10 @@ String stateMeasures() { #endif SYSdata["modules"] = modules; + //Provide filter configuration + JsonObject filterConfig = SYSdata.createNestedObject("filters"); + iMessageFilter.to(filterConfig); + SYSdata["origin"] = subjectSYStoMQTT; enqueueJsonObject(SYSdata); @@ -3642,6 +3689,10 @@ void XtoSYS(const char* topicOri, JsonObject& SYSdata) { // json object decoding } THEENGS_LOG_NOTICE(F("Discovery state: %T" CR), SYSConfig.discovery); } + if (SYSdata.containsKey("filter")) { + //JsonObject filterObj = SYSdata["filter"].as(); + iMessageFilter.handleMQTTCommand(SYSdata); + } if (SYSdata.containsKey("save") && SYSdata["save"].as()) { SYSConfig_save(); } diff --git a/main/mqttDiscovery.cpp b/main/mqttDiscovery.cpp index 6a249e9db7..a22d665332 100644 --- a/main/mqttDiscovery.cpp +++ b/main/mqttDiscovery.cpp @@ -40,6 +40,9 @@ # include # endif # include "config_mqttDiscovery.h" +# if defined(ZgatewayRF2) || defined(ZgatewayPilight) +# include "config_RF.h" +# endif extern bool ethConnected; extern JsonArray modules; @@ -599,7 +602,7 @@ void createDiscovery(const char* sensor_type, } } - if (diagnostic_entity) { // entity_category + if (diagnostic_entity) { // entity_category sensor["ent_cat"] = "diagnostic"; } @@ -997,7 +1000,6 @@ void pubMqttDiscovery() { # endif # ifdef ZgatewayRF2 -# include "config_RF.h" THEENGS_LOG_TRACE(F("gatewayRF2Discovery" CR)); const char* gatewayRF2[][13] = { {HASS_TYPE_SENSOR, "gatewayRF2", "", "", jsonAddress, "", "", "", stateClassNone, nullptr, nullptr, nullptr, nullptr}}; @@ -1052,7 +1054,6 @@ void pubMqttDiscovery() { # endif # ifdef ZgatewayPilight -# include "config_RF.h" THEENGS_LOG_TRACE(F("gatewayPilightDiscovery" CR)); const char* gatewayPilight[][13] = { {HASS_TYPE_SENSOR, "gatewayPilight", "", "", jsonMsg, "", "", "", stateClassNone, nullptr, nullptr, nullptr, nullptr}}; diff --git a/main/rf/RFReceiver.h b/main/rf/RFBaseGateway.h similarity index 56% rename from main/rf/RFReceiver.h rename to main/rf/RFBaseGateway.h index 312a29f12c..f040fb4d5f 100644 --- a/main/rf/RFReceiver.h +++ b/main/rf/RFBaseGateway.h @@ -1,15 +1,12 @@ -#ifndef RFRECEIVER_H -#define RFRECEIVER_H + #pragma once -class RFReceiver { +class RFBaseGateway { public: - virtual ~RFReceiver() = default; + virtual ~RFBaseGateway() = default; // Pure virtual methods virtual void enable() = 0; virtual void disable() = 0; virtual int getReceiverID() const = 0; }; - -#endif // RFRECEIVER_H \ No newline at end of file diff --git a/main/rf/RFConfiguration.cpp b/main/rf/RFConfiguration.cpp index 9d937d46d8..45eee118be 100644 --- a/main/rf/RFConfiguration.cpp +++ b/main/rf/RFConfiguration.cpp @@ -1,6 +1,7 @@ -#include "RFConfiguration.h" - +#include #include +#include +#include #ifdef ZgatewayRTL_433 # include @@ -8,7 +9,7 @@ extern rtl_433_ESP rtl_433; #endif // Constructor -RFConfiguration::RFConfiguration(RFReceiver& receiver) : iRFReceiver(receiver) { +RFConfiguration::RFConfiguration(RFBaseGateway& receiver, IStorage& storageRef) : AbstractStorageObject(storageRef, "RFConfig"), iRFReceiver(receiver) { reInit(); } @@ -67,65 +68,6 @@ void RFConfiguration::reInit() { newOokThreshold = 0; } -/** - * @brief Erases the RF configuration from non-volatile storage (NVS). - * - * This function removes the RF configuration stored in NVS. It checks if - * the configuration exists and, if so, removes it. If the configuration - * is not found, a notice is logged. - * - * @note This function is only available on ESP32 platforms. - */ -void RFConfiguration::eraseStorage() { -#ifdef ESP32 - // Erase config from NVS (non-volatile storage) - preferences.begin(Gateway_Short_Name, false); - if (preferences.isKey("RFConfig")) { - int result = preferences.remove("RFConfig"); - Log.notice(F("RF config erase result: %d" CR), result); - } else { - Log.notice(F("RF config not found" CR)); - } - preferences.end(); -#else - Log.warning(F("RF Config Erase not support with this board" CR)); -#endif -} - -/** - * @brief Saves the RF configuration to non-volatile storage (NVS). - * - * This function serializes the RF configuration data into a JSON object - * and saves it to NVS. The saved configuration includes frequency, active - * receiver, and other relevant parameters. - * - * @note This function is only available on ESP32 platforms. - * @note Ensure that the `JSON_MSG_BUFFER` is large enough to hold the - * serialized configuration data to avoid deserialization errors. - */ -void RFConfiguration::saveOnStorage() { -#ifdef ESP32 - StaticJsonDocument jsonBuffer; - JsonObject jo = jsonBuffer.to(); - toJson(jo); -# ifdef ZgatewayRTL_433 - // FROM ORIGINAL CONFIGURATION: - // > Don't save those for now, need to be tested - jo.remove("rssithreshold"); - jo.remove("ookthreshold"); -# endif - // Save config into NVS (non-volatile storage) - String conf = ""; - serializeJson(jsonBuffer, conf); - preferences.begin(Gateway_Short_Name, false); - int result = preferences.putString("RFConfig", conf); - preferences.end(); - Log.notice(F("RF Config_save: %s, result: %d" CR), conf.c_str(), result); -#else - Log.warning(F("RF Config_save not support with this board" CR)); -#endif -} - /** * @brief Loads the RF configuration from persistent storage and applies it. * @@ -138,32 +80,10 @@ void RFConfiguration::saveOnStorage() { * it uses the Preferences library to access stored configuration data. * For other platforms, it directly enables the active receiver. */ -void RFConfiguration::loadFromStorage() { -#ifdef ESP32 - StaticJsonDocument jsonBuffer; - preferences.begin(Gateway_Short_Name, true); - if (preferences.isKey("RFConfig")) { - auto error = deserializeJson(jsonBuffer, preferences.getString("RFConfig", "{}")); - preferences.end(); - if (error) { - Log.error(F("RF Config deserialization failed: %s, buffer capacity: %u" CR), error.c_str(), jsonBuffer.capacity()); - return; - } - if (jsonBuffer.isNull()) { - Log.warning(F("RF Config is null" CR)); - return; - } - JsonObject jo = jsonBuffer.as(); - fromJson(jo); - Log.notice(F("RF Config loaded" CR)); - } else { - preferences.end(); - Log.notice(F("RF Config not found using default" CR)); - iRFReceiver.enable(); - } -#else +bool RFConfiguration::loadFromStorage() { + bool out = AbstractStorageObject::loadFromStorage(); iRFReceiver.enable(); -#endif + return out; } /** @@ -197,17 +117,17 @@ void RFConfiguration::loadFromMessage(JsonObject& RFdata) { loadFromStorage(); } - fromJson(RFdata); + from(RFdata); iRFReceiver.disable(); iRFReceiver.enable(); if (RFdata.containsKey("erase") && RFdata["erase"].as()) { eraseStorage(); - Log.notice(F("RF Config erased" CR)); + THEENGS_LOG_NOTICE(F("RF Config erased" CR)); } else if (RFdata.containsKey("save") && RFdata["save"].as()) { saveOnStorage(); - Log.notice(F("RF Config saved" CR)); + THEENGS_LOG_NOTICE(F("RF Config saved" CR)); } } @@ -235,43 +155,57 @@ void RFConfiguration::loadFromMessage(JsonObject& RFdata) { * Logs messages to indicate the success or failure of each update operation. * If no valid keys are found in the JSON object, an error message is logged. */ -void RFConfiguration::fromJson(JsonObject& RFdata) { - bool success = false; +void RFConfiguration::from(JsonObject& RFdata) { + short success = 0; + short total = 0; + total += 1; if (RFdata.containsKey("frequency") && validFrequency(RFdata["frequency"])) { - Config_update(RFdata, "frequency", frequency); - Log.notice(F("RF Receive mhz: %F" CR), frequency); - success = true; + frequency = RFdata["frequency"].as(); + THEENGS_LOG_NOTICE(F("RF updated Receive mhz: %F" CR), frequency); + success += 1; + } else { + THEENGS_LOG_WARNING(F("RF updated Frequency Missing" CR)); } + + total += 1; if (RFdata.containsKey("active")) { - Config_update(RFdata, "active", activeReceiver); - Log.notice(F("RF receiver active: %d" CR), activeReceiver); - success = true; + activeReceiver = RFdata["active"].as(); + THEENGS_LOG_NOTICE(F("RF receiver active: %d" CR), activeReceiver); + success += 1; + } else { + THEENGS_LOG_WARNING(F("RF updated Active Missing" CR)); } #ifdef ZgatewayRTL_433 + total += 1; if (RFdata.containsKey("rssithreshold")) { - Log.notice(F("RTL_433 RSSI Threshold : %d " CR), rssiThreshold); - Config_update(RFdata, "rssithreshold", rssiThreshold); + THEENGS_LOG_NOTICE(F("RTL_433 RSSI Threshold : %d " CR), rssiThreshold); + rssiThreshold = RFdata["rssithreshold"].as(); rtl_433.setRSSIThreshold(rssiThreshold); - success = true; + success += 1; } # if defined(RF_SX1276) || defined(RF_SX1278) + total += 1; if (RFdata.containsKey("ookthreshold")) { - Config_update(RFdata, "ookthreshold", newOokThreshold); - Log.notice(F("RTL_433 ookThreshold %d" CR), newOokThreshold); + newOokThreshold = RFdata["ookthreshold"].as(); + THEENGS_LOG_NOTICE(F("RTL_433 ookThreshold %d" CR), newOokThreshold); rtl_433.setOOKThreshold(newOokThreshold); - success = true; + success += 1; } # endif + total += 1; if (RFdata.containsKey("status")) { - Log.notice(F("RF get status:" CR)); + THEENGS_LOG_NOTICE(F("RF get status:" CR)); rtl_433.getStatus(); - success = true; - } - if (!success) { - Log.error(F("MQTTtoRF Fail json" CR)); + success += 1; } + #endif + if (success == total) { + THEENGS_LOG_NOTICE(F("MQTTtoRF Update Success" CR)); + } else if (success < total) { + THEENGS_LOG_WARNING(F("MQTTtoRF Fail update" CR)); + } } /** @@ -297,16 +231,11 @@ void RFConfiguration::fromJson(JsonObject& RFdata) { * "black-list": [, ...] // Array of black-list values * } */ -void RFConfiguration::toJson(JsonObject& RFdata) { +void RFConfiguration::to(JsonObject& RFdata) { RFdata["frequency"] = frequency; RFdata["rssithreshold"] = rssiThreshold; RFdata["ookthreshold"] = newOokThreshold; RFdata["active"] = activeReceiver; - - // Add white-list vector to the JSON object - JsonArray whiteListArray = RFdata.createNestedArray("white-list"); - // Add black-list vector to the JSON object - JsonArray blackListArray = RFdata.createNestedArray("black-list"); } /** diff --git a/main/rf/RFConfiguration.h b/main/rf/RFConfiguration.h index 71b9de29c4..7639bbe176 100644 --- a/main/rf/RFConfiguration.h +++ b/main/rf/RFConfiguration.h @@ -2,13 +2,15 @@ #define RFCONFIG_H #pragma once -#include -#include +#include +#include +#include +#include -class RFConfiguration { +class RFConfiguration : public AbstractStorageObject, public IJsonable { public: // Constructor - RFConfiguration(RFReceiver& receiver); + RFConfiguration(RFBaseGateway& receiver, IStorage& storageRef); ~RFConfiguration(); // Getters and Setters @@ -32,28 +34,7 @@ class RFConfiguration { */ void reInit(); - /** - * Erases the RF configuration from non-volatile storage (NVS). - * - * @note This function is only available on ESP32 platforms. - */ - void eraseStorage(); - - /** - * Saves the RF configuration to non-volatile storage (NVS). - * - * @note This function is only available on ESP32 platforms. - */ - void saveOnStorage(); - - /** - * Loads the RF configuration from persistent storage and applies it. - * - * @note This function has specific behavior for ESP32 platforms. On ESP32, - * it uses the Preferences library to access stored configuration data. - * For other platforms, it directly enables the active receiver. - */ - void loadFromStorage(); + bool loadFromStorage() override; /** * Loads the RF configuration from a JSON object and applies it. @@ -84,14 +65,14 @@ class RFConfiguration { * - "ookthreshold": Updates the OOK threshold for RTL_433 (requires RF_SX1276 or RF_SX1278). * - "status": Retrieves the current status of the RF configuration. */ - void fromJson(JsonObject& RFdata); + void from(JsonObject& RFdata) override; /** * Serializes the RF configuration to a JSON object. * * @param RFdata A reference to a JsonObject where the RF configuration will be serialized. */ - void toJson(JsonObject& RFdata); + void to(JsonObject& RFdata) override; /** * @brief Validates if the given frequency is within the acceptable ranges for the CC1101 module. @@ -107,8 +88,8 @@ class RFConfiguration { bool validFrequency(float mhz); private: - // Reference to the RFReceiver object - RFReceiver& iRFReceiver; + // Reference to the RFBaseGateway object + RFBaseGateway& iRFReceiver; float frequency; int rssiThreshold; int newOokThreshold; diff --git a/main/storage/AbstractStorageObject.h b/main/storage/AbstractStorageObject.h new file mode 100644 index 0000000000..cf106f55c7 --- /dev/null +++ b/main/storage/AbstractStorageObject.h @@ -0,0 +1,75 @@ +#pragma once +#include +#include +#include +#include + +class AbstractStorageObject : public IJsonable { +public: + AbstractStorageObject(IStorage& storageRef, const char* rootKey) + : storage(storageRef), rootStorageKey(rootKey) {} + + virtual ~AbstractStorageObject() = default; + + virtual bool saveOnStorage() { + bool out = false; + StaticJsonDocument jsonBuffer; + JsonObject jo = jsonBuffer.to(); + this->to(jo); + char conf[JSON_MSG_BUFFER] = {0}; + const size_t written = serializeJson(jsonBuffer, conf, sizeof(conf)); + if (written >= sizeof(conf)) { + THEENGS_LOG_ERROR(F("Storage save overflow: %u >= %u" CR), written, sizeof(conf)); + return false; + } else { + storage.begin(false); + int result = storage.putString(rootStorageKey, conf); + out = (result > 0); + storage.end(); + THEENGS_LOG_NOTICE(F("Storage saved: %s, result: %d" CR), conf, result); + return out; + } + }; + + virtual bool loadFromStorage() { + StaticJsonDocument jsonBuffer; + storage.begin(true); + + if (storage.isKey(rootStorageKey)) { + auto error = deserializeJson(jsonBuffer, storage.getString(rootStorageKey, "{}")); + storage.end(); + if (error) { + THEENGS_LOG_ERROR(F("%s deserialization failed: %s, buffer capacity: %u" CR), rootStorageKey, error.c_str(), jsonBuffer.capacity()); + return false; + } + if (jsonBuffer.isNull()) { + THEENGS_LOG_WARNING(F("%s is null" CR), rootStorageKey); + return false; + } + JsonObject jo = jsonBuffer.as(); + this->from(jo); + THEENGS_LOG_NOTICE(F("%s loaded" CR), rootStorageKey); + } else { + storage.end(); + } + return true; + }; + + virtual bool eraseStorage() { + bool state = false; + storage.begin(false); + if (storage.isKey(rootStorageKey)) { + int result = storage.remove(rootStorageKey); + THEENGS_LOG_NOTICE(F("%s erase result: %d" CR), rootStorageKey, result); + state = (result == 1); + } else { + THEENGS_LOG_NOTICE(F("%s not found" CR), rootStorageKey); + } + storage.end(); + return state; + } + +private: + IStorage& storage; + const char* rootStorageKey; +}; \ No newline at end of file diff --git a/main/storage/IJsonable.h b/main/storage/IJsonable.h new file mode 100644 index 0000000000..9fa31a441a --- /dev/null +++ b/main/storage/IJsonable.h @@ -0,0 +1,19 @@ +#pragma once +#include + +class IJsonable { +public: + /** + * Updates the object from a JSON object. + * + * @param data A reference to a JsonObject containing the data. + */ + virtual void from(JsonObject& data) = 0; + + /** + * Serializes the object to a JSON object. + * + * @param data A reference to a JsonObject where the object data will be serialized. + */ + virtual void to(JsonObject& data) = 0; +}; \ No newline at end of file diff --git a/main/storage/IStorage.h b/main/storage/IStorage.h new file mode 100644 index 0000000000..4381948a5b --- /dev/null +++ b/main/storage/IStorage.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include + +/** + * @brief Interface for key-value storage abstraction (e.g. Preferences, SPIFFS, etc.) + * + * This interface allows to decouple storage logic from implementation (NVS, SPIFFS, etc.). + * + * All methods mirror the minimal subset used by RFConfiguration for preferences management. + */ +class IStorage { +public: + virtual ~IStorage() = default; + + virtual const char* getNamespace() = 0; + + /** + * @brief Open the storage namespace + * @param readOnly True for read-only, false for read-write + * @return true if opened successfully + */ + virtual bool begin(bool readOnly) = 0; + + /** + * @brief Close the storage + */ + virtual void end() = 0; + + /** + * @brief Check if a key exists + * @param key Key name + * @return true if key exists + */ + virtual bool isKey(const char* key) = 0; + + /** + * @brief Get a string value for a key + * @param key Key name + * @param defaultValue Default value if key not found + * @return String value + */ + virtual const char* getString(const char* key, const char* defaultValue = "") = 0; + + /** + * @brief Put a string value for a key + * @param key Key name + * @param value Value to store + * @return Number of bytes written + */ + virtual size_t putString(const char* key, const char* value) = 0; + + /** + * @brief Remove a key from storage + * @param key Key name + * @return 1 if removed, 0 if not found + */ + virtual int remove(const char* key) = 0; +}; \ No newline at end of file diff --git a/main/storage/NVSPreferencesStorage.cpp b/main/storage/NVSPreferencesStorage.cpp new file mode 100644 index 0000000000..58c1a94184 --- /dev/null +++ b/main/storage/NVSPreferencesStorage.cpp @@ -0,0 +1,50 @@ +#ifdef ESP32 +# include "NVSPreferencesStorage.h" + +# include + +NVSPreferencesStorage::NVSPreferencesStorage() + : preferences(new Preferences()), ownsPreferences(true) {} + +NVSPreferencesStorage::NVSPreferencesStorage(Preferences* inPreferences) + : preferences(inPreferences), ownsPreferences(false) {} + +NVSPreferencesStorage::~NVSPreferencesStorage() { + if (preferences) { + preferences->end(); + if (ownsPreferences) { + delete preferences; + } + } +} + +const char* NVSPreferencesStorage::getNamespace() { + return Gateway_Short_Name; +} + +bool NVSPreferencesStorage::begin(bool readOnly) { + return preferences->begin(getNamespace(), readOnly); +} + +void NVSPreferencesStorage::end() { + preferences->end(); +} + +bool NVSPreferencesStorage::isKey(const char* key) { + return preferences->isKey(key); +} + +const char* NVSPreferencesStorage::getString(const char* key, const char* defaultValue) { + String arduinoString = preferences->getString(key, defaultValue); + return arduinoString.c_str(); +} + +size_t NVSPreferencesStorage::putString(const char* key, const char* value) { + String arduinoString(value); + return preferences->putString(key, arduinoString); +} + +int NVSPreferencesStorage::remove(const char* key) { + return preferences->remove(key); +} +#endif \ No newline at end of file diff --git a/main/storage/NVSPreferencesStorage.h b/main/storage/NVSPreferencesStorage.h new file mode 100644 index 0000000000..7530fa638a --- /dev/null +++ b/main/storage/NVSPreferencesStorage.h @@ -0,0 +1,90 @@ +#pragma once + +#ifdef ESP32 +# include +# include + +/** + * @brief Concrete implementation of IStorage using ESP32 Preferences (NVS) + */ +/** + * @class NVSPreferencesStorage + * @brief A class that provides storage functionality using NVS (Non-Volatile Storage) preferences. + * + * This class implements the IStorage interface to provide methods for storing, retrieving, + * and managing key-value pairs in non-volatile storage. It uses the Preferences library + * for interacting with the underlying storage mechanism. + */ +class NVSPreferencesStorage : public IStorage { +public: + /** + * @brief Constructs an NVSPreferencesStorage object. + */ + NVSPreferencesStorage(); + + /** + * @brief Constructs an NVSPreferencesStorage object with external Preferences. + * @param preferences External Preferences instance to use + */ + NVSPreferencesStorage(Preferences* preferences); + + /** + * @brief Destroys the NVSPreferencesStorage object and releases any resources. + */ + ~NVSPreferencesStorage() override; + + const char* getNamespace() override; + + /** + * @brief Initializes the storage with a given namespace. + * + * @param name The namespace to use for the storage. + * @param readOnly If true, opens the storage in read-only mode. + * @return True if the storage was successfully initialized, false otherwise. + */ + bool begin(bool readOnly) override; + + /** + * @brief Ends the storage session and releases resources. + */ + void end() override; + + /** + * @brief Checks if a key exists in the storage. + * + * @param key The key to check for existence. + * @return True if the key exists, false otherwise. + */ + bool isKey(const char* key) override; + + /** + * @brief Retrieves a string value associated with a key. + * + * @param key The key to retrieve the value for. + * @param defaultValue The default value to return if the key does not exist. + * @return The string value associated with the key, or the default value if the key does not exist. + */ + const char* getString(const char* key, const char* defaultValue = "") override; + + /** + * @brief Stores a string value associated with a key. + * + * @param key The key to associate the value with. + * @param value The string value to store. + * @return The size of the value stored, or 0 if the operation failed. + */ + size_t putString(const char* key, const char* value) override; + + /** + * @brief Removes a key-value pair from the storage. + * + * @param key The key to remove. + * @return 0 if the key was successfully removed, or an error code otherwise. + */ + int remove(const char* key) override; + +private: + Preferences* preferences; ///< Pointer to Preferences object used for NVS operations + bool ownsPreferences; ///< Flag indicating if this instance owns the Preferences object +}; +#endif \ No newline at end of file diff --git a/main/storage/NoopStorage.h b/main/storage/NoopStorage.h new file mode 100644 index 0000000000..fdaaeebac1 --- /dev/null +++ b/main/storage/NoopStorage.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +/** + * @brief Minimal no-op storage implementation for non-ESP32 targets. + * + * Provides an IStorage that compiles and safely does nothing. + * Useful on ESP8266 where Preferences/NVS is unavailable. + */ +class NoopStorage : public IStorage { +public: + const char* getNamespace() override { return "noop"; } + bool begin(bool /*readOnly*/) override { return true; } + void end() override {} + bool isKey(const char* /*key*/) override { return false; } + const char* getString(const char* /*key*/, const char* defaultValue = "") override { return defaultValue; } + size_t putString(const char* /*key*/, const char* /*value*/) override { return 0; } + int remove(const char* /*key*/) override { return 0; } +}; \ No newline at end of file diff --git a/platformio.ini b/platformio.ini index 92da37f09d..55b8a42b66 100644 --- a/platformio.ini +++ b/platformio.ini @@ -14,7 +14,7 @@ include_dir = main extra_configs = environments.ini - tests/*_env.ini + test/*_env.ini *_env.ini ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -166,6 +166,8 @@ rn8209 = https://github.com/theengs/RN8209C-SDK/archive/arduino.zip [env] framework = arduino +test_framework = googletest +test_filter = embedded/** lib_deps = ${libraries.picomqtt} ${libraries.arduinojson} @@ -194,6 +196,10 @@ build_flags = '-DMQTTsetMQTT' '-DMQTT_HTTPS_FW_UPDATE' ;'-DCORE_DEBUG_LEVEL=4' +build_src_filter = + +<*> + + + + [com-esp32] ; Used by all ESP32 based builds lib_deps = @@ -211,6 +217,10 @@ build_flags = ;'-DCORE_DEBUG_LEVEL=4' '-DZwebUI="WebUI"' ; enable WebUI as a default for all ESP32 builds ( the module only enables for ESP32 based builds ) '-DARDUINO_LOOP_STACK_SIZE=9600' ; The firmware upgrade options needs a large amount of free stack, ~9600 +build_src_filter = + +<*> + + + + [com-arduino] lib_deps = @@ -219,6 +229,10 @@ lib_deps = build_flags = ${env.build_flags} '-DZmqttDiscovery="HADiscovery"' +build_src_filter = + +<*> + + + + [com-arduino-low-memory] lib_deps = @@ -227,3 +241,37 @@ lib_deps = build_flags = ${env.build_flags} '-DsimpleReceiving=false' +build_src_filter = + +<*> + + + + + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; TEST ENVIRONMENTS ; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +[env:test_native] +;; Native tests are intended for the project components that are independent of physical hardware. You can also use them in pair with Mocking frameworks. +platform = native +framework = +debug_test = native/** +test_framework = googletest +test_filter = native/** +build_flags = + ${env.build_flags} + -DUNIT_TEST + -DUNIT_TEST_NATIVE + -DJSON_MSG_BUFFER=1024 ;failback of specific buffer size for tests + -std=gnu++17 +lib_deps = + google/googletest@^1.15.2 + ${libraries.arduinojson} +test_build_src = yes +build_src_filter = + -<*> + + + + + + + + + + + + \ No newline at end of file diff --git a/test/Readme.md b/test/Readme.md new file mode 100644 index 0000000000..245778e673 --- /dev/null +++ b/test/Readme.md @@ -0,0 +1,208 @@ +# Testing OpenMQTTGateway code + +OpenMQTTGateway uses a robust testing setup designed to ensure reliability and maintainability. The testing framework is built on PlatformIO and GoogleTest, providing a structured approach to validate both native and embedded code. This guide explains how to run tests, organize them effectively, and follow best practices for high-quality development. + +## Test Directory Structure + +``` +test/ +โ”œโ”€โ”€ test_runner.cpp # Main test runner with GoogleTest +โ”œโ”€โ”€ native/ # Native tests (run on development machine) +โ”‚ โ”œโ”€โ”€ unit/ # Isolated unit tests +โ”‚ โ””โ”€โ”€ integration/ # Integration tests +โ””โ”€โ”€ embedded/ # Embedded tests (run on ESP32 hardware) + โ””โ”€โ”€ test_embedded.cpp # Hardware-in-the-loop tests +``` + +## Running Tests + +### Native Tests (Fast, No Hardware Required) +```bash +# Run all native tests +pio test -e test_native + +# Run with verbose output +pio test -e test_native -v + +# Run specific test by filter +pio test -e test_native --filter "native/unit/test_rf" +``` + +### Embedded Tests (Requires ESP32 Hardware) +```bash +# Run embedded tests on any existing ESP32 configuration +# Example: using esp32dev-all-test environment +pio test -e esp32dev-all-test + +# Or use any other ESP32 environment (esp32dev-ble, esp32dev-rf, etc.) +pio test -e esp32dev-ble + +# Run with verbose output +pio test -e esp32dev-all-test -v + +# Upload to specific port +pio test -e esp32dev-all-test --upload-port COM3 # Windows +pio test -e esp32dev-all-test --upload-port /dev/ttyUSB0 # Linux/Mac +``` + +### Run All Tests +```bash +# Run both native and embedded tests +pio test +``` + +## Test Environments + +### Native Tests: `[env:test_native]` +- **Platform**: Native (runs on development machine) +- **Framework**: GoogleTest +- **Test Filter**: `native` +- **Purpose**: Fast unit testing with mocked dependencies +- **Advantages**: + - No hardware required + - Fast execution (< 1 second) + - CI/CD friendly + - Full code coverage support + +### Embedded Tests: Reuse Existing ESP32 Configurations +- **Available Environments**: 22+ existing ESP32 configurations + - `esp32dev-all-test` - Full feature set + - `esp32dev-ble` - BLE focused + - `esp32dev-rf` - RF gateway + - `esp32dev-ir` - Infrared + - And many more in `environments.ini` +- **Platform**: ESP32 (espressif32) +- **Framework**: Arduino + GoogleTest (when test framework enabled) +- **Purpose**: Hardware-in-the-loop testing with real peripherals +- **Advantages**: + - Reuses existing hardware configurations + - Tests real hardware behavior + - Validates ESP32-specific features (NVS, WiFi, BLE, etc.) + - No duplicate environment definitions + - Tests match actual deployment configurations + + +## Visual Code Integration +Visual Studio Code integrates testing seamlessly with its PlatformIO extension. However, due to the embedded nature of the project, only native tests can be executed directly within the IDE. For a complete testing experience, including embedded tests, it is recommended to use the CLI for better control and flexibility. + +![Test_Integration](../img/test_integration.gif) + + + +## Continuous Integration + +Automated testing runs on: +- **Push**: To `main`, `development`, and `feature/*` branches +- **Pull Requests**: To `main` and `development` branches + +The GitHub Actions workflow: +1. Sets up Ubuntu environment with Python 3.11 +2. Installs PlatformIO +3. Runs native tests with `pio test -e test_native` +4. Reports test results + +::: tip +Embedded tests require physical ESP32 hardware and are not run in CI/CD. Execute them manually before releases or when validating hardware-specific functionality. +::: + +## Writing Tests + +### Naming Conventions + +- Test files: `test_[ComponentName].cpp` +- Test suites: `[ComponentName]Test` +- Test cases: descriptive names using GoogleTest conventions + +### Native Test Example (Unit Test) + +Located in `test/native/unit/test_rf/test_RFConfiguration.cpp`: + +```cpp +#include +#include +#include "../../mocks/mock_IStorage.h" +#include "../../mocks/mock_RFReceiver.h" + +class RFConfigurationTest : public ::testing::Test { +protected: + void SetUp() override { + // Setup test fixtures + } +}; + +TEST_F(RFConfigurationTest, ShouldInitializeWithDefaults) { + // Arrange + MockRFReceiver mockReceiver; + RFConfiguration config(mockReceiver); + + // Assert + ASSERT_NEAR(config.getFrequency(), RF_FREQUENCY, 0.01); + ASSERT_EQ(config.getActiveReceiver(), ACTIVE_RECEIVER); +} +``` + +### Embedded Test Example (Hardware Test) + +Located in `test/embedded/test_embedded.cpp`: + +```cpp +#include +#include +#include + +TEST(EmbeddedESP32, NVSBasicOperations) { + Preferences prefs; + + // Test with real ESP32 NVS + ASSERT_TRUE(prefs.begin("test", false)); + ASSERT_GT(prefs.putString("key1", "value1"), 0); + ASSERT_STREQ(prefs.getString("key1", "").c_str(), "value1"); + + prefs.clear(); + prefs.end(); +} +``` + +::: tip +Embedded tests run on **any ESP32 environment** defined in `environments.ini`. Simply use `pio test -e ` to execute them on your target hardware configuration. +::: + +## Test Organization Best Practices + +### Native Tests (`test/native/`) +- **Purpose**: Fast feedback, logic validation, cross-platform +- **Dependencies**: Mocked (no hardware required) +- **Execution**: Runs on development machine +- **Use Cases**: + - Algorithm validation + - Configuration parsing + - Data structure operations + - Business logic testing + +### Embedded Tests (`test/embedded/`) +- **Purpose**: Hardware validation, ESP32-specific features +- **Dependencies**: Real hardware (ESP32 required) +- **Execution**: Runs on physical ESP32 board using existing environments +- **Configuration**: Leverages 22+ existing ESP32 environments from `environments.ini` +- **Use Cases**: + - NVS storage operations + - WiFi/Bluetooth connectivity + - GPIO/peripheral interactions + - Real-time performance validation + - Validating specific hardware configurations (BLE, RF, IR, etc.) + +## Best Practices + +1. **Test Naming**: Use descriptive names that explain behavior +2. **Arrange-Act-Assert**: Structure tests clearly +3. **Mock External Dependencies**: Isolate units under test +4. **Test Both Success and Failure**: Include edge cases +5. **Use Test Helpers**: Reduce duplication with common utilities +6. **Follow PlatformIO Guidelines**: Align with framework conventions + +## References + +- [PlatformIO Unit Testing](https://docs.platformio.org/en/latest/advanced/unit-testing/) +- [GoogleTest Documentation](https://google.github.io/googletest/) +- [OpenMQTTGateway Development Guide](development.md) +- [Test Best Practices](https://docs.platformio.org/en/stable/advanced/unit-testing/structure/best-practices.html) diff --git a/test/embedded/test_NVSPreferencesStorage.cpp b/test/embedded/test_NVSPreferencesStorage.cpp new file mode 100644 index 0000000000..e1fb2ad5f7 --- /dev/null +++ b/test/embedded/test_NVSPreferencesStorage.cpp @@ -0,0 +1,68 @@ +#ifdef ESP32 + +# include +# include + +# include "../../main/storage/NVSPreferencesStorage.h" + +// Basic embedded tests for NVSPreferencesStorage +TEST(NVSPreferencesStorageTest, BasicPutGetRemove) { + NVSPreferencesStorage storage; + + // Use a test namespace to avoid colliding with real data + ASSERT_TRUE(storage.begin("unittest", false)); + + // Ensure key does not exist initially + EXPECT_FALSE(storage.isKey("ut_key")); + + // Put string and verify returned size > 0 + std::string value = "hello_nvs"; + size_t written = storage.putString("ut_key", value); + EXPECT_GT(written, 0); + + // Read back + std::string read = storage.getString("ut_key", ""); + EXPECT_EQ(read, value); + EXPECT_TRUE(storage.isKey("ut_key")); + + // Remove key and verify it's gone + storage.remove("ut_key"); + EXPECT_FALSE(storage.isKey("ut_key")); + + // Cleanup namespace + storage.end(); +} + +TEST(NVSPreferencesStorageTest, OverwriteAndPersistence) { + NVSPreferencesStorage storage; + ASSERT_TRUE(storage.begin("unittest", false)); + + // Write initial value + storage.putString("ut_key2", "v1"); + EXPECT_EQ(storage.getString("ut_key2", ""), "v1"); + + // Overwrite with new value + storage.putString("ut_key2", "v2"); + EXPECT_EQ(storage.getString("ut_key2", ""), "v2"); + + // End and reopen in read-only mode to simulate later access + storage.end(); + + NVSPreferencesStorage storage2; + ASSERT_TRUE(storage2.begin("unittest", true)); + EXPECT_EQ(storage2.getString("ut_key2", ""), "v2"); + + // Cleanup + storage2.remove("ut_key2"); + storage2.end(); +} + +#else + +# include + +TEST(NVSPreferencesStorageTest, SkipOnNonESP32) { + GTEST_SKIP(); +} + +#endif diff --git a/test/native/helpers/README.md b/test/native/helpers/README.md new file mode 100644 index 0000000000..27ae430bd8 --- /dev/null +++ b/test/native/helpers/README.md @@ -0,0 +1,33 @@ +# Test Helpers + +This directory contains utility functions and helper classes for testing OpenMQTTGateway modules. + +## Purpose + +- Common test utilities and fixtures +- Mock initialization helpers +- Test data generators +- Assertion helpers for ESP32/Arduino specific testing + +## Usage + +Include helper files in your test files: + +```cpp +#include "../helpers/test_helpers.h" +#include "../helpers/mock_helpers.h" +``` + +## File Structure + +- `test_helpers.h` - Generic test utilities +- `mock_helpers.h` - Mock object initialization helpers +- `esp32_test_helpers.h` - ESP32-specific test utilities +- `json_helpers.h` - JSON testing utilities for MQTT messages + +## Best Practices + +Follow PlatformIO test best practices and maintain helper functions that are: +- Reusable across multiple test suites +- Well-documented with clear interfaces +- Independent of specific test implementations \ No newline at end of file diff --git a/test/native/helpers/test_helpers.cpp b/test/native/helpers/test_helpers.cpp new file mode 100644 index 0000000000..538a7d431e --- /dev/null +++ b/test/native/helpers/test_helpers.cpp @@ -0,0 +1,62 @@ +#include "test_helpers.h" + +#include + +#include +#include +#include + +namespace TestHelpers { + +bool isNearlyEqual(double a, double b, double epsilon) { + return std::abs(a - b) < epsilon; +} + +std::string generateTestJson(const std::vector& keys, + const std::vector& values) { + if (keys.size() != values.size()) { + return "{}"; // Return empty JSON if sizes don't match + } + + DynamicJsonDocument doc(1024); + + for (size_t i = 0; i < keys.size(); ++i) { + doc[keys[i]] = values[i]; + } + + std::string result; + serializeJson(doc, result); + return result; +} + +std::string createMQTTTopic(const std::string& gateway_name, + const std::string& module, + const std::string& direction, + const std::string& device_id) { + std::string topic = "home/" + gateway_name + "/" + module + direction; + + if (!device_id.empty()) { + topic += "/" + device_id; + } + + return topic; +} + +bool isValidJson(const std::string& json_str) { + DynamicJsonDocument doc(1024); + DeserializationError error = deserializeJson(doc, json_str); + return error == DeserializationError::Ok; +} + +std::string bytesToHex(const uint8_t* data, size_t length) { + std::ostringstream oss; + oss << std::hex << std::setfill('0'); + + for (size_t i = 0; i < length; ++i) { + oss << std::setw(2) << static_cast(data[i]); + } + + return oss.str(); +} + +} // namespace TestHelpers \ No newline at end of file diff --git a/test/native/helpers/test_helpers.h b/test/native/helpers/test_helpers.h new file mode 100644 index 0000000000..c149b7f63f --- /dev/null +++ b/test/native/helpers/test_helpers.h @@ -0,0 +1,65 @@ +#pragma once + +#include + +#include +#include + +/** + * @file test_helpers.h + * @brief Common test utilities for OpenMQTTGateway testing + * + * This file contains helper functions and utilities that can be used + * across multiple test files to reduce code duplication and improve + * test maintainability. + */ + +namespace TestHelpers { + +/** + * @brief Compare two floating point numbers with epsilon tolerance + * @param a First number to compare + * @param b Second number to compare + * @param epsilon Tolerance for comparison (default: 1e-6) + * @return true if numbers are equal within tolerance + */ +bool isNearlyEqual(double a, double b, double epsilon = 1e-6); + +/** + * @brief Generate test JSON string with specified key-value pairs + * @param keys Vector of JSON keys + * @param values Vector of JSON values (as strings) + * @return Formatted JSON string + */ +std::string generateTestJson(const std::vector& keys, + const std::vector& values); + +/** + * @brief Create a test MQTT topic following OpenMQTTGateway conventions + * @param gateway_name Gateway identifier + * @param module Module name (e.g., "BT", "RF", "IR") + * @param direction Direction ("toMQTT" or "fromMQTT") + * @param device_id Optional device identifier + * @return Formatted MQTT topic string + */ +std::string createMQTTTopic(const std::string& gateway_name, + const std::string& module, + const std::string& direction, + const std::string& device_id = ""); + +/** + * @brief Validate JSON string format + * @param json_str JSON string to validate + * @return true if valid JSON format + */ +bool isValidJson(const std::string& json_str); + +/** + * @brief Convert byte array to hex string representation + * @param data Byte array to convert + * @param length Length of byte array + * @return Hex string representation + */ +std::string bytesToHex(const uint8_t* data, size_t length); + +} // namespace TestHelpers \ No newline at end of file diff --git a/test/native/integration/README.md b/test/native/integration/README.md new file mode 100644 index 0000000000..d9a2910970 --- /dev/null +++ b/test/native/integration/README.md @@ -0,0 +1,52 @@ +# Integration Tests + +This directory contains integration tests that verify component interactions and end-to-end functionality. + +## Purpose + +Integration tests verify: +- Module-to-module communication +- MQTT message flow and topic routing +- Configuration loading and management +- Gateway protocol integration +- Home Assistant discovery integration + +## Test Categories + +### Protocol Integration +- RF gateway message processing +- Bluetooth device discovery and data parsing +- IR signal transmission and reception +- LoRa communication handling + +### MQTT Integration +- Message publishing to correct topics +- Command processing from MQTT +- Home Assistant auto-discovery payloads +- Configuration updates via MQTT + +### Configuration Integration +- Config loading from NVS/SPIFFS +- JSON configuration parsing +- Runtime configuration updates +- Environment-based configuration + +## Running Integration Tests + +Integration tests may require: +- Mock MQTT broker setup +- Simulated hardware responses +- Configuration file fixtures + +```bash +# Run integration tests specifically +pio test -e test_native --filter "*integration*" +``` + +## Guidelines + +- Test realistic scenarios and workflows +- Use minimal mocking for true integration testing +- Include error handling and recovery scenarios +- Document test prerequisites and setup requirements +- Consider test execution time and resource usage \ No newline at end of file diff --git a/test/native/mocks/README.md b/test/native/mocks/README.md new file mode 100644 index 0000000000..4f69bcdbdd --- /dev/null +++ b/test/native/mocks/README.md @@ -0,0 +1,43 @@ +# Mock Implementations for Unit Testing + +This directory contains mock implementations of interfaces and classes used in OpenMQTTGateway for unit testing purposes. + +## Available Mocks + +### mock_IStorage.h +Mock implementation of the \IStorage\ interface using Google Mock framework. + +**Usage:** +\\\cpp +#include "../mocks/mock_IStorage.h" + +MockStorage mockStorage; +EXPECT_CALL(mockStorage, begin(_, false)) + .WillOnce(Return(true)); +EXPECT_CALL(mockStorage, putString("key", ::testing::An())) + .WillOnce(Return(100)); +\\\ + +**Features:** +- Fully mocked \IStorage\ interface +- Uses \std::string\ (not Arduino \String\) +- Compatible with Google Mock expectations + +### mock_RFBaseGateway.h +Mock implementation of the \RFReceiver\ class. + +### mock_arduino.h +Mock implementations of Arduino framework functions and classes for unit testing without hardware dependencies. + +**Includes:** +- Digital I/O functions (\pinMode\, \digitalWrite\, \digitalRead\) +- Analog I/O functions (\nalogRead\, \nalogWrite\) +- Time functions (\millis\, \micros\, \delay\) +- Serial mock +- Arduino \String\ class mock (simplified) + +## Notes + +- Mock implementations are designed to be used in unit tests only +- They should not be included in production code +- All mocks are header-only for simplicity diff --git a/test/native/mocks/mock_Filter.h b/test/native/mocks/mock_Filter.h new file mode 100644 index 0000000000..e271dae6b0 --- /dev/null +++ b/test/native/mocks/mock_Filter.h @@ -0,0 +1,14 @@ +#include // Include the RFReceiver base class +#include // Brings in gMock. + +class MockFilter : public Filter { +public: + MOCK_METHOD(void, from, (JsonObject & data), (override)); + MOCK_METHOD(void, to, (JsonObject & data), (override)); + MOCK_METHOD(void, to, (const char* data), ()); + MOCK_METHOD(void, add, (const char* value, bool inWhitelist), ()); + MOCK_METHOD(void, remove, (const char* value), ()); + MOCK_METHOD(bool, contains, (const char* key, const char* value, bool inWhitelist), ()); + MOCK_METHOD(bool, regex_match, (const char* key, const char* pattern, bool inWhitelist), ()); + MOCK_METHOD(bool, isEmpty, (bool inWhitelist), (const)); +}; \ No newline at end of file diff --git a/test/native/mocks/mock_IStorage.h b/test/native/mocks/mock_IStorage.h new file mode 100644 index 0000000000..4d39114f0d --- /dev/null +++ b/test/native/mocks/mock_IStorage.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +#include + +/** + * @brief Mock implementation of IStorage for unit testing + */ +class MockStorage : public IStorage { +public: + MOCK_METHOD(bool, begin, (bool readOnly), (override)); + MOCK_METHOD(const char*, getNamespace, (), (override)); + MOCK_METHOD(void, end, (), (override)); + MOCK_METHOD(bool, isKey, (const char* key), (override)); + MOCK_METHOD(const char*, getString, (const char* key, const char* defaultValue), (override)); + MOCK_METHOD(size_t, putString, (const char* key, const char* value), (override)); + MOCK_METHOD(int, remove, (const char* key), (override)); + + // Stored data for simulating storage + std::string storedData; +}; diff --git a/test/native/mocks/mock_RFBaseGateway.h b/test/native/mocks/mock_RFBaseGateway.h new file mode 100644 index 0000000000..b4408ef059 --- /dev/null +++ b/test/native/mocks/mock_RFBaseGateway.h @@ -0,0 +1,9 @@ +#include // Brings in gMock. +#include // Include the RFReceiver base class + +class MockRFGateway : public RFBaseGateway { +public: + MOCK_METHOD(void, enable, (), (override)); + MOCK_METHOD(void, disable, (), (override)); + MOCK_METHOD(int, getReceiverID, (), (const, override)); +}; \ No newline at end of file diff --git a/test/native/mocks/mock_arduino.h b/test/native/mocks/mock_arduino.h new file mode 100644 index 0000000000..7e74f497ea --- /dev/null +++ b/test/native/mocks/mock_arduino.h @@ -0,0 +1,89 @@ +#pragma once + +/** + * @file mock_arduino.h + * @brief Mock Arduino framework functions for unit testing + * + * This file provides mock implementations of Arduino framework functions + * to enable unit testing without actual hardware dependencies. + */ + +#ifdef UNIT_TEST + +# include +# include + +// Arduino constants +# define HIGH 1 +# define LOW 0 +# define INPUT 0 +# define OUTPUT 1 +# define INPUT_PULLUP 2 + +// Mock pin definitions +# define LED_BUILTIN 2 + +// Time functions +unsigned long millis(); +unsigned long micros(); +void delay(unsigned long ms); +void delayMicroseconds(unsigned int us); + +// Digital I/O +void pinMode(uint8_t pin, uint8_t mode); +void digitalWrite(uint8_t pin, uint8_t val); +int digitalRead(uint8_t pin); + +// Analog I/O +int analogRead(uint8_t pin); +void analogWrite(uint8_t pin, int val); + +// Serial mock +class MockSerial { +public: + void begin(unsigned long baud); + void print(const char* str); + void print(const std::string& str); + void print(int value); + void print(float value); + void println(const char* str); + void println(const std::string& str); + void println(int value); + void println(float value); + void println(); + bool available(); + char read(); +}; + +extern MockSerial Serial; + +// String class mock (simplified) +class String { +public: + String(); + String(const char* str); + String(const std::string& str); + String(int value); + String(float value); + + const char* c_str() const; + size_t length() const; + String& operator+=(const String& other); + String operator+(const String& other) const; + bool operator==(const String& other) const; + +private: + std::string data_; +}; + +// Test utilities for Arduino mocks +namespace ArduinoMock { +void reset(); +void setMillis(unsigned long value); +void setMicros(unsigned long value); +void setPinState(uint8_t pin, int value); +int getPinState(uint8_t pin); +void setAnalogValue(uint8_t pin, int value); +} // namespace ArduinoMock + +#endif // UNIT_TEST \ No newline at end of file diff --git a/test/native/unit/test_core/test_Filter.cpp b/test/native/unit/test_core/test_Filter.cpp new file mode 100644 index 0000000000..f995dd2419 --- /dev/null +++ b/test/native/unit/test_core/test_Filter.cpp @@ -0,0 +1,842 @@ +#include +#include +#include +#include + +#include "../mocks/mock_IStorage.h" + +using namespace ::testing; + +class FilterTest : public ::testing::Test { +protected: + void SetUp() override { + filterUnderTest = new Filter(mockStorage); + } + + void TearDown() override { + delete filterUnderTest; + } + + MockStorage mockStorage; + Filter* filterUnderTest; +}; + +TEST_F(FilterTest, TestFilterisEmptyOnCreation) { + EXPECT_TRUE(filterUnderTest->isEmptyThe(Filter::BLOCK)); + EXPECT_TRUE(filterUnderTest->isEmptyThe(Filter::PASS)); +} + +TEST_F(FilterTest, AddWhitelistValueMakesListNonEmpty) { + bool result = filterUnderTest->add("id", "deviceA", Filter::PASS); + + EXPECT_TRUE(result); + EXPECT_FALSE(filterUnderTest->isEmptyThe(Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "deviceA", Filter::PASS)); + EXPECT_EQ(1u, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, AddFilterReturnsTrue) { + EXPECT_TRUE(filterUnderTest->add("id", "device1", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->add("name", "sensor", Filter::BLOCK)); +} + +TEST_F(FilterTest, AddFilterReturnsFalseWhenLimitReached) { + // Add 50 filters (the limit) + for (size_t i = 0; i < MAX_TOTAL_FILTERS; ++i) { + std::string value = "device" + std::to_string(i); + EXPECT_TRUE(filterUnderTest->add("id", value.c_str(), Filter::PASS)); + } + + EXPECT_EQ(MAX_TOTAL_FILTERS, filterUnderTest->getTotalFilterCount()); + + // 51st filter should fail + EXPECT_FALSE(filterUnderTest->add("id", "overflow", Filter::PASS)); + EXPECT_EQ(MAX_TOTAL_FILTERS, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, HasCapacityReturnsFalseWhenFull) { + EXPECT_TRUE(filterUnderTest->hasCapacity()); + + for (size_t i = 0; i < MAX_TOTAL_FILTERS; ++i) { + std::string value = "device" + std::to_string(i); + filterUnderTest->add("id", value.c_str(), Filter::PASS); + } + + EXPECT_FALSE(filterUnderTest->hasCapacity()); +} + +TEST_F(FilterTest, ClearRemovesAllFilters) { + filterUnderTest->add("id", "device1", Filter::PASS); + filterUnderTest->add("name", "sensor", Filter::BLOCK); + + EXPECT_EQ(2u, filterUnderTest->getTotalFilterCount()); + + filterUnderTest->clear(); + + EXPECT_EQ(0u, filterUnderTest->getTotalFilterCount()); + EXPECT_TRUE(filterUnderTest->isEmptyThe(Filter::PASS)); + EXPECT_TRUE(filterUnderTest->isEmptyThe(Filter::BLOCK)); +} + +TEST_F(FilterTest, AddBlacklistValueIsDetectable) { + EXPECT_TRUE(filterUnderTest->add("id", "badDevice", Filter::BLOCK)); + EXPECT_FALSE(filterUnderTest->isEmptyThe(Filter::BLOCK)); + EXPECT_TRUE(filterUnderTest->contains("id", "badDevice", Filter::BLOCK)); +} + +TEST_F(FilterTest, RemoveValueClearsEntriesFromBothLists) { + filterUnderTest->add("id", "shared", Filter::PASS); + filterUnderTest->add("id", "shared", Filter::BLOCK); + filterUnderTest->add("name", "shared", Filter::BLOCK); + + EXPECT_TRUE(filterUnderTest->remove("id", "shared")); + + EXPECT_TRUE(filterUnderTest->isEmptyThe(Filter::PASS)); + EXPECT_FALSE(filterUnderTest->isEmptyThe(Filter::BLOCK)); + EXPECT_FALSE(filterUnderTest->contains("id", "shared", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("id", "shared", Filter::BLOCK)); + EXPECT_TRUE(filterUnderTest->contains("name", "shared", Filter::BLOCK)); +} + +TEST_F(FilterTest, RemoveReturnsFalseWhenNotFound) { + EXPECT_FALSE(filterUnderTest->remove("id", "nonexistent")); +} + +TEST_F(FilterTest, ContainsReturnsFalseWhenValueMissing) { + filterUnderTest->add("id", "existing", Filter::PASS); + + EXPECT_FALSE(filterUnderTest->contains("id", "missing", Filter::PASS)); +} + +TEST_F(FilterTest, WildcardMatchingWithAsterisk) { + filterUnderTest->add("id", "sensor_*", Filter::PASS); + + EXPECT_TRUE(filterUnderTest->contains("id", "sensor_123", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "sensor_abc", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "sensor_", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("id", "temp_123", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("id", "sensor", Filter::PASS)); +} + +TEST_F(FilterTest, WildcardMatchingWithQuestionMark) { + filterUnderTest->add("name", "dev??e", Filter::PASS); + + EXPECT_TRUE(filterUnderTest->contains("name", "device", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("name", "devABe", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("name", "dev1e", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("name", "device1", Filter::PASS)); +} + +TEST_F(FilterTest, WildcardMatchingComplex) { + filterUnderTest->add("id", "*_sensor_*", Filter::PASS); + + EXPECT_TRUE(filterUnderTest->contains("id", "temp_sensor_01", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "humidity_sensor_data", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("id", "sensor_01", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("id", "temp_data", Filter::PASS)); +} + +TEST_F(FilterTest, WildcardMatchingComplexInTheMiddle) { + filterUnderTest->add("id", "in_*_the_*_middle", Filter::PASS); + + EXPECT_TRUE(filterUnderTest->contains("id", "in_1_the_2_middle", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "in_One_the_Alex_middle", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("id", "in_1_the_Ale", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("id", "In__The_Ale_2", Filter::PASS)); +} + +TEST_F(FilterTest, ToSerializesPassAndBlockArrays) { + filterUnderTest->add("id", "wl", Filter::PASS); + filterUnderTest->add("name", "bl", Filter::BLOCK); + + StaticJsonDocument outDoc; + JsonObject serialized = outDoc.to(); + filterUnderTest->to(serialized); + + ASSERT_TRUE(serialized.containsKey("pass")); + ASSERT_TRUE(serialized.containsKey("block")); + + // Both pass and block filters should be serialized with their keys + ASSERT_TRUE(serialized["pass"].containsKey("id")); + ASSERT_TRUE(serialized["block"].containsKey("name")); + // Verify the arrays contain expected values + JsonArray idArray = serialized["pass"]["id"].as(); + EXPECT_EQ(1u, idArray.size()); + EXPECT_STREQ("wl", idArray[0].as()); + + JsonArray nameArray = serialized["block"]["name"].as(); + EXPECT_EQ(1u, nameArray.size()); + EXPECT_STREQ("bl", nameArray[0].as()); +} + +// ============================================================================ +// JSON Serialization & Deserialization Tests +// ============================================================================ + +TEST_F(FilterTest, ToSerializesMultipleValuesPerKey) { + filterUnderTest->add("id", "device1", Filter::PASS); + filterUnderTest->add("id", "device2", Filter::PASS); + filterUnderTest->add("id", "device3", Filter::PASS); + + StaticJsonDocument outDoc; + JsonObject serialized = outDoc.to(); + filterUnderTest->to(serialized); + + JsonObject passObj = serialized["pass"].as(); + JsonObject blockObj = serialized["block"].as(); + + ASSERT_TRUE(passObj.containsKey("id")); + JsonArray idArray = passObj["id"].as(); + EXPECT_EQ(3u, idArray.size()); +} + +TEST_F(FilterTest, ToSerializesEmptyFilterAsEmptyObject) { + StaticJsonDocument outDoc; + JsonObject serialized = outDoc.to(); + filterUnderTest->to(serialized); + JsonObject passObj = serialized["pass"].as(); + JsonObject blockObj = serialized["block"].as(); + + EXPECT_EQ(0u, passObj.size()); + EXPECT_EQ(0u, blockObj.size()); +} + +TEST_F(FilterTest, FromLoadsPassFiltersFromJson) { + StaticJsonDocument doc; + JsonObject data = doc.to(); + JsonArray idArray = data.createNestedArray("id"); + idArray.add("sensor1"); + idArray.add("sensor2"); + + filterUnderTest->from(data); + + // from() loads into both PASS and BLOCK lists + EXPECT_TRUE(filterUnderTest->contains("id", "sensor1", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "sensor2", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "sensor1", Filter::BLOCK)); + EXPECT_TRUE(filterUnderTest->contains("id", "sensor2", Filter::BLOCK)); + EXPECT_EQ(4u, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, FromLoadsMixedPassAndBlockFilters) { + StaticJsonDocument doc; + JsonObject data = doc.to(); + + JsonArray idArray = data.createNestedArray("id"); + idArray.add("allowed1"); + idArray.add("allowed2"); + + JsonArray nameArray = data.createNestedArray("name"); + nameArray.add("blocked1"); + + filterUnderTest->from(data); + + // from() loads into both lists: 3 values * 2 lists = 6 total + EXPECT_EQ(6u, filterUnderTest->getTotalFilterCount()); + EXPECT_TRUE(filterUnderTest->contains("id", "allowed1", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "allowed1", Filter::BLOCK)); + EXPECT_TRUE(filterUnderTest->contains("name", "blocked1", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("name", "blocked1", Filter::BLOCK)); +} + +TEST_F(FilterTest, FromIgnoresInvalidNonArrayValues) { + StaticJsonDocument doc; + JsonObject data = doc.to(); + data["id"] = "notAnArray"; // Should be ignored + + filterUnderTest->from(data); + + EXPECT_EQ(0u, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, FromIgnoresInvalidNonStringValuesInArray) { + StaticJsonDocument doc; + JsonObject data = doc.to(); + JsonArray idArray = data.createNestedArray("id"); + idArray.add(123); // Integer, should be ignored + idArray.add("valid"); // This should work + + filterUnderTest->from(data); + + // "valid" is loaded into both PASS and BLOCK + EXPECT_EQ(2u, filterUnderTest->getTotalFilterCount()); + EXPECT_TRUE(filterUnderTest->contains("id", "valid", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "valid", Filter::BLOCK)); +} + +TEST_F(FilterTest, FromStopsLoadingWhenLimitReached) { + // Pre-fill to near capacity + for (size_t i = 0; i < MAX_TOTAL_FILTERS - 2; ++i) { + std::string value = "device" + std::to_string(i); + filterUnderTest->add("id", value.c_str(), Filter::PASS); + } + + // Try to load 5 more filters via JSON + StaticJsonDocument doc; + JsonObject data = doc.to(); + JsonArray nameArray = data.createNestedArray("name"); + for (int i = 0; i < 5; ++i) { + nameArray.add(("filter" + std::to_string(i)).c_str()); + } + + filterUnderTest->from(data); + + // Should have only added 2 filters (to reach limit) + EXPECT_EQ(MAX_TOTAL_FILTERS, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, ToStringSerializesCorrectly) { + filterUnderTest->add("id", "device1", Filter::PASS); + filterUnderTest->add("name", "blocked", Filter::BLOCK); + + char buffer[JSON_MSG_BUFFER]; + filterUnderTest->to(buffer); + + // Parse the serialized string back + StaticJsonDocument doc; + deserializeJson(doc, buffer); + JsonObject obj = doc.as(); + + JsonObject passObj = obj["pass"].as(); + JsonObject blockObj = obj["block"].as(); + EXPECT_TRUE(passObj.containsKey("id")); + EXPECT_TRUE(blockObj.containsKey("name")); +} + +TEST_F(FilterTest, SerializeAndDeserializeWithWildcards) { + // Create a new filter and add wildcard patterns via from() + StaticJsonDocument doc; + JsonObject data = doc.to(); + JsonArray idArray = data.createNestedArray("id"); + idArray.add("sensor*"); + idArray.add("device?"); + + filterUnderTest->from(data); + + // Verify wildcards work after deserialization + EXPECT_TRUE(filterUnderTest->contains("id", "sensor123", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "sensorABC", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "deviceA", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "device1", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("id", "device12", Filter::PASS)); +} + +// ============================================================================ +// fromRulesList and toRulesList Tests +// ============================================================================ + +TEST_F(FilterTest, FromRulesListLoadsPassRuleCorrectly) { + StaticJsonDocument doc; + JsonObject data = doc.to(); + JsonArray rulesArray = data.createNestedArray("rules"); + + JsonObject rule = rulesArray.createNestedObject(); + rule["target"] = "value"; + rule["action"] = "pass"; + rule["key"] = "id"; + rule["value"] = "sensor1"; + + filterUnderTest->fromRulesList(data); + + EXPECT_TRUE(filterUnderTest->contains("id", "sensor1", Filter::PASS)); + EXPECT_EQ(1u, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, FromRulesListLoadsBlockRuleCorrectly) { + StaticJsonDocument doc; + JsonObject data = doc.to(); + JsonArray rulesArray = data.createNestedArray("rules"); + + JsonObject rule = rulesArray.createNestedObject(); + rule["target"] = "value"; + rule["action"] = "block"; + rule["key"] = "id"; + rule["value"] = "badDevice"; + + filterUnderTest->fromRulesList(data); + + EXPECT_TRUE(filterUnderTest->contains("id", "badDevice", Filter::BLOCK)); + EXPECT_EQ(1u, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, FromRulesListLoadsMultipleRulesCorrectly) { + StaticJsonDocument doc; + JsonObject data = doc.to(); + JsonArray rulesArray = data.createNestedArray("rules"); + + // Add pass rule + JsonObject passRule = rulesArray.createNestedObject(); + passRule["target"] = "value"; + passRule["action"] = "pass"; + passRule["key"] = "id"; + passRule["value"] = "allowed"; + + // Add block rule + JsonObject blockRule = rulesArray.createNestedObject(); + blockRule["target"] = "value"; + blockRule["action"] = "block"; + blockRule["key"] = "name"; + blockRule["value"] = "blocked"; + + filterUnderTest->fromRulesList(data); + + EXPECT_TRUE(filterUnderTest->contains("id", "allowed", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("name", "blocked", Filter::BLOCK)); + EXPECT_EQ(2u, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, FromRulesListLoadsTopicFilterCorrectly) { + StaticJsonDocument doc; + JsonObject data = doc.to(); + JsonArray rulesArray = data.createNestedArray("rules"); + + JsonObject rule = rulesArray.createNestedObject(); + rule["target"] = "topic"; + rule["action"] = "pass"; + rule["value"] = "home/sensors/+"; + + filterUnderTest->fromRulesList(data); + + // Topic filters are added with a special key + EXPECT_EQ(1u, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, FromRulesListIgnoresInvalidAction) { + StaticJsonDocument doc; + JsonObject data = doc.to(); + JsonArray rulesArray = data.createNestedArray("rules"); + + JsonObject rule = rulesArray.createNestedObject(); + rule["target"] = "value"; + rule["action"] = "invalid"; + rule["key"] = "id"; + rule["value"] = "test"; + + filterUnderTest->fromRulesList(data); + + // Rule should be ignored + EXPECT_EQ(0u, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, FromRulesListIgnoresMissingKey) { + StaticJsonDocument doc; + JsonObject data = doc.to(); + JsonArray rulesArray = data.createNestedArray("rules"); + + JsonObject rule = rulesArray.createNestedObject(); + rule["target"] = "value"; + rule["action"] = "pass"; + rule["value"] = "test"; + // Missing "key" field + + filterUnderTest->fromRulesList(data); + + // Rule should be ignored + EXPECT_EQ(0u, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, FromRulesListIgnoresMissingValue) { + StaticJsonDocument doc; + JsonObject data = doc.to(); + JsonArray rulesArray = data.createNestedArray("rules"); + + JsonObject rule = rulesArray.createNestedObject(); + rule["target"] = "value"; + rule["action"] = "pass"; + rule["key"] = "id"; + // Missing "value" field + + filterUnderTest->fromRulesList(data); + + // Rule should be ignored + EXPECT_EQ(0u, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, FromRulesListIgnoresNonObjectElements) { + StaticJsonDocument doc; + JsonObject data = doc.to(); + JsonArray rulesArray = data.createNestedArray("rules"); + + // Add invalid non-object element + rulesArray.add("invalid"); + + // Add valid rule after invalid + JsonObject rule = rulesArray.createNestedObject(); + rule["target"] = "value"; + rule["action"] = "pass"; + rule["key"] = "id"; + rule["value"] = "valid"; + + filterUnderTest->fromRulesList(data); + + // Only valid rule should be added + EXPECT_EQ(1u, filterUnderTest->getTotalFilterCount()); + EXPECT_TRUE(filterUnderTest->contains("id", "valid", Filter::PASS)); +} + +TEST_F(FilterTest, FromRulesListStopsWhenLimitReached) { + // Pre-fill to near capacity + for (size_t i = 0; i < MAX_TOTAL_FILTERS - 2; ++i) { + std::string value = "device" + std::to_string(i); + filterUnderTest->add("id", value.c_str(), Filter::PASS); + } + + // Try to load 5 more rules via JSON + StaticJsonDocument doc; + JsonObject data = doc.to(); + JsonArray rulesArray = data.createNestedArray("rules"); + for (int i = 0; i < 5; ++i) { + JsonObject rule = rulesArray.createNestedObject(); + rule["target"] = "value"; + rule["action"] = "pass"; + rule["key"] = "name"; + rule["value"] = ("filter" + std::to_string(i)).c_str(); + } + + filterUnderTest->fromRulesList(data); + + // Should have only added 2 filters (to reach limit) + EXPECT_EQ(MAX_TOTAL_FILTERS, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, ToRulesListSerializesPassFiltersCorrectly) { + filterUnderTest->add("id", "sensor1", Filter::PASS); + filterUnderTest->add("id", "sensor2", Filter::PASS); + + StaticJsonDocument doc; + JsonObject data = doc.to(); + filterUnderTest->toRulesList(data); + + EXPECT_TRUE(data.containsKey("rules")); + JsonArray rulesArray = data["rules"].as(); + EXPECT_EQ(2u, rulesArray.size()); + + // Check first rule + JsonObject rule1 = rulesArray[0].as(); + EXPECT_STREQ("pass", rule1["action"].as()); + EXPECT_STREQ("id", rule1["key"].as()); + EXPECT_STREQ("sensor1", rule1["value"].as()); +} + +TEST_F(FilterTest, ToRulesListSerializesBlockFiltersCorrectly) { + filterUnderTest->add("name", "blocked1", Filter::BLOCK); + filterUnderTest->add("name", "blocked2", Filter::BLOCK); + + StaticJsonDocument doc; + JsonObject data = doc.to(); + filterUnderTest->toRulesList(data); + + EXPECT_TRUE(data.containsKey("rules")); + JsonArray rulesArray = data["rules"].as(); + EXPECT_EQ(2u, rulesArray.size()); + + // Check first rule + JsonObject rule1 = rulesArray[0].as(); + EXPECT_STREQ("block", rule1["action"].as()); + EXPECT_STREQ("name", rule1["key"].as()); + EXPECT_STREQ("blocked1", rule1["value"].as()); +} + +TEST_F(FilterTest, ToRulesListSerializesMixedFiltersCorrectly) { + filterUnderTest->add("id", "allowed", Filter::PASS); + filterUnderTest->add("name", "blocked", Filter::BLOCK); + + StaticJsonDocument doc; + JsonObject data = doc.to(); + filterUnderTest->toRulesList(data); + + EXPECT_TRUE(data.containsKey("rules")); + JsonArray rulesArray = data["rules"].as(); + EXPECT_EQ(2u, rulesArray.size()); + + // Count pass and block rules + int passCount = 0, blockCount = 0; + for (JsonVariant ruleVar : rulesArray) { + JsonObject rule = ruleVar.as(); + const char* action = rule["action"]; + if (strcmp(action, "pass") == 0) { + passCount++; + } else if (strcmp(action, "block") == 0) { + blockCount++; + } + } + + EXPECT_EQ(1, passCount); + EXPECT_EQ(1, blockCount); +} + +TEST_F(FilterTest, ToRulesListEmptyFiltersList) { + StaticJsonDocument doc; + JsonObject data = doc.to(); + filterUnderTest->toRulesList(data); + + EXPECT_TRUE(data.containsKey("rules")); + JsonArray rulesArray = data["rules"].as(); + EXPECT_EQ(0u, rulesArray.size()); +} + +TEST_F(FilterTest, RoundTripFromAndToRulesList) { + // Create original filters + filterUnderTest->add("id", "sensor1", Filter::PASS); + filterUnderTest->add("id", "sensor2", Filter::PASS); + filterUnderTest->add("name", "blocked", Filter::BLOCK); + + // Serialize to rules format + StaticJsonDocument doc; + JsonObject data = doc.to(); + filterUnderTest->toRulesList(data); + + // Create new filter and deserialize + Filter* newFilter = new Filter(mockStorage); + newFilter->fromRulesList(data); + + // Verify new filter has same content + EXPECT_TRUE(newFilter->contains("id", "sensor1", Filter::PASS)); + EXPECT_TRUE(newFilter->contains("id", "sensor2", Filter::PASS)); + EXPECT_TRUE(newFilter->contains("name", "blocked", Filter::BLOCK)); + EXPECT_EQ(filterUnderTest->getTotalFilterCount(), newFilter->getTotalFilterCount()); + + delete newFilter; +} + +TEST_F(FilterTest, ToRulesListWithWildcards) { + filterUnderTest->add("id", "sensor*", Filter::PASS); + filterUnderTest->add("name", "device?", Filter::BLOCK); + + StaticJsonDocument doc; + JsonObject data = doc.to(); + filterUnderTest->toRulesList(data); + + EXPECT_TRUE(data.containsKey("rules")); + JsonArray rulesArray = data["rules"].as(); + EXPECT_EQ(2u, rulesArray.size()); + + // Verify wildcards are preserved + bool foundPass = false, foundBlock = false; + for (JsonVariant ruleVar : rulesArray) { + JsonObject rule = ruleVar.as(); + const char* value = rule["value"]; + if (strcmp(value, "sensor*") == 0) { + foundPass = true; + } else if (strcmp(value, "device?") == 0) { + foundBlock = true; + } + } + + EXPECT_TRUE(foundPass); + EXPECT_TRUE(foundBlock); +} + +// ============================================================================ +// getFilterCount Tests +// ============================================================================ + +TEST_F(FilterTest, GetFilterCountReturnsZeroForEmptyLists) { + EXPECT_EQ(0u, filterUnderTest->getFilterCount(Filter::PASS)); + EXPECT_EQ(0u, filterUnderTest->getFilterCount(Filter::BLOCK)); +} + +TEST_F(FilterTest, GetFilterCountReturnsCorrectPassCount) { + filterUnderTest->add("id", "device1", Filter::PASS); + filterUnderTest->add("id", "device2", Filter::PASS); + filterUnderTest->add("name", "sensor", Filter::PASS); + + EXPECT_EQ(3u, filterUnderTest->getFilterCount(Filter::PASS)); + EXPECT_EQ(0u, filterUnderTest->getFilterCount(Filter::BLOCK)); +} + +TEST_F(FilterTest, GetFilterCountReturnsCorrectBlockCount) { + filterUnderTest->add("id", "blocked1", Filter::BLOCK); + filterUnderTest->add("id", "blocked2", Filter::BLOCK); + + EXPECT_EQ(0u, filterUnderTest->getFilterCount(Filter::PASS)); + EXPECT_EQ(2u, filterUnderTest->getFilterCount(Filter::BLOCK)); +} + +TEST_F(FilterTest, GetFilterCountWorksIndependentlyForBothLists) { + filterUnderTest->add("id", "allowed1", Filter::PASS); + filterUnderTest->add("id", "allowed2", Filter::PASS); + filterUnderTest->add("name", "blocked1", Filter::BLOCK); + + EXPECT_EQ(2u, filterUnderTest->getFilterCount(Filter::PASS)); + EXPECT_EQ(1u, filterUnderTest->getFilterCount(Filter::BLOCK)); + EXPECT_EQ(3u, filterUnderTest->getTotalFilterCount()); +} + +// ============================================================================ +// Edge Cases and Error Handling Tests +// ============================================================================ + +TEST_F(FilterTest, AddWithNullKeyReturnsFalse) { + EXPECT_FALSE(filterUnderTest->add(nullptr, "value", Filter::PASS)); + EXPECT_EQ(0u, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, AddWithNullValueReturnsFalse) { + EXPECT_FALSE(filterUnderTest->add("key", nullptr, Filter::PASS)); + EXPECT_EQ(0u, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, RemoveWithNullKeyReturnsFalse) { + EXPECT_FALSE(filterUnderTest->remove(nullptr, "value")); +} + +TEST_F(FilterTest, RemoveWithNullValueReturnsFalse) { + EXPECT_FALSE(filterUnderTest->remove("key", nullptr)); +} + +TEST_F(FilterTest, ContainsWithNullKeyReturnsFalse) { + filterUnderTest->add("id", "device", Filter::PASS); + EXPECT_FALSE(filterUnderTest->contains(nullptr, "device", Filter::PASS)); +} + +TEST_F(FilterTest, ContainsWithNullValueReturnsFalse) { + filterUnderTest->add("id", "device", Filter::PASS); + EXPECT_FALSE(filterUnderTest->contains("id", nullptr, Filter::PASS)); +} + +TEST_F(FilterTest, AddEmptyStringValueWorks) { + EXPECT_TRUE(filterUnderTest->add("id", "", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "", Filter::PASS)); +} + +TEST_F(FilterTest, AddDuplicateValueCreatesMultipleEntries) { + EXPECT_TRUE(filterUnderTest->add("id", "device1", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->add("id", "device1", Filter::PASS)); + + EXPECT_EQ(2u, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, RemoveOnlyRemovesMatchingValue) { + filterUnderTest->add("id", "device1", Filter::PASS); + filterUnderTest->add("id", "device2", Filter::PASS); + filterUnderTest->add("name", "device1", Filter::PASS); + + EXPECT_TRUE(filterUnderTest->remove("id", "device1")); + + EXPECT_FALSE(filterUnderTest->contains("id", "device1", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "device2", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("name", "device1", Filter::PASS)); +} + +TEST_F(FilterTest, RemoveRemovesKeyWhenLastValueRemoved) { + filterUnderTest->add("id", "device1", Filter::PASS); + + EXPECT_TRUE(filterUnderTest->remove("id", "device1")); + + // Key should be completely removed from internal map + EXPECT_EQ(0u, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, ContainsChecksCorrectList) { + filterUnderTest->add("id", "device1", Filter::PASS); + filterUnderTest->add("name", "blocked", Filter::BLOCK); + + // Check pass list + EXPECT_TRUE(filterUnderTest->contains("id", "device1", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("id", "device1", Filter::BLOCK)); + + // Check block list + EXPECT_TRUE(filterUnderTest->contains("name", "blocked", Filter::BLOCK)); + EXPECT_FALSE(filterUnderTest->contains("name", "blocked", Filter::PASS)); +} + +TEST_F(FilterTest, ClearAffectsBothLists) { + filterUnderTest->add("id", "device1", Filter::PASS); + filterUnderTest->add("id", "device2", Filter::PASS); + filterUnderTest->add("name", "blocked1", Filter::BLOCK); + filterUnderTest->add("name", "blocked2", Filter::BLOCK); + + EXPECT_EQ(4u, filterUnderTest->getTotalFilterCount()); + + filterUnderTest->clear(); + + EXPECT_EQ(0u, filterUnderTest->getTotalFilterCount()); + EXPECT_EQ(0u, filterUnderTest->getFilterCount(Filter::PASS)); + EXPECT_EQ(0u, filterUnderTest->getFilterCount(Filter::BLOCK)); +} + +TEST_F(FilterTest, HasCapacityReturnsTrueWhenNotFull) { + EXPECT_TRUE(filterUnderTest->hasCapacity()); + + filterUnderTest->add("id", "device1", Filter::PASS); + EXPECT_TRUE(filterUnderTest->hasCapacity()); +} + +// ============================================================================ +// Wildcard Edge Cases +// ============================================================================ + +TEST_F(FilterTest, WildcardOnlyAsteriskMatchesEverything) { + filterUnderTest->add("id", "*", Filter::PASS); + + EXPECT_TRUE(filterUnderTest->contains("id", "anything", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "123", Filter::PASS)); +} + +TEST_F(FilterTest, WildcardMultipleAsterisks) { + filterUnderTest->add("id", "*_*_*", Filter::PASS); + + EXPECT_TRUE(filterUnderTest->contains("id", "a_b_c", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "one_two_three", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("id", "a_b", Filter::PASS)); +} + +TEST_F(FilterTest, WildcardQuestionMarkExactLength) { + filterUnderTest->add("id", "???", Filter::PASS); + + EXPECT_TRUE(filterUnderTest->contains("id", "abc", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "123", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("id", "ab", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("id", "abcd", Filter::PASS)); +} + +TEST_F(FilterTest, WildcardMixedAsteriskAndQuestionMark) { + filterUnderTest->add("id", "dev?ce_*", Filter::PASS); + + EXPECT_TRUE(filterUnderTest->contains("id", "device_123", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "devXce_abc", Filter::PASS)); + // "dev_ce_123" matches because ? can match underscore + EXPECT_TRUE(filterUnderTest->contains("id", "dev_ce_123", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("id", "device", Filter::PASS)); +} + +TEST_F(FilterTest, WildcardCaseSensitiveMatching) { + filterUnderTest->add("id", "Sensor*", Filter::PASS); + + EXPECT_TRUE(filterUnderTest->contains("id", "Sensor123", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("id", "sensor123", Filter::PASS)); +} + +TEST_F(FilterTest, WildcardEmptyPatternMatchesEmpty) { + filterUnderTest->add("id", "", Filter::PASS); + + EXPECT_TRUE(filterUnderTest->contains("id", "", Filter::PASS)); + EXPECT_FALSE(filterUnderTest->contains("id", "anything", Filter::PASS)); +} + +// ============================================================================ +// Multiple Keys Tests +// ============================================================================ + +TEST_F(FilterTest, MultipleKeysInSameList) { + filterUnderTest->add("id", "device1", Filter::PASS); + filterUnderTest->add("type", "sensor", Filter::PASS); + filterUnderTest->add("location", "room1", Filter::PASS); + + EXPECT_TRUE(filterUnderTest->contains("id", "device1", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("type", "sensor", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("location", "room1", Filter::PASS)); + EXPECT_EQ(3u, filterUnderTest->getTotalFilterCount()); +} + +TEST_F(FilterTest, SameKeyInBothLists) { + filterUnderTest->add("id", "allowed", Filter::PASS); + filterUnderTest->add("id", "blocked", Filter::BLOCK); + + EXPECT_TRUE(filterUnderTest->contains("id", "allowed", Filter::PASS)); + EXPECT_TRUE(filterUnderTest->contains("id", "blocked", Filter::BLOCK)); + EXPECT_FALSE(filterUnderTest->contains("id", "allowed", Filter::BLOCK)); + EXPECT_FALSE(filterUnderTest->contains("id", "blocked", Filter::PASS)); +} diff --git a/test/native/unit/test_core/test_MessageFilter.cpp b/test/native/unit/test_core/test_MessageFilter.cpp new file mode 100644 index 0000000000..66b1e1b752 --- /dev/null +++ b/test/native/unit/test_core/test_MessageFilter.cpp @@ -0,0 +1,1882 @@ + +#include +#include +#include + +#include "../mocks/mock_IStorage.h" + +using namespace ::testing; + +class MessageFilterTest : public Test { +protected: + void SetUp() override { + filter = new Filter(mockStorage); + underTest = new MessageFilter(*filter); + } + + void TearDown() override { + delete underTest; + delete filter; + } + + JsonObject createMessage(const char* key, const char* value) { + messageBuffer.clear(); // Reset del documento + JsonObject obj = messageBuffer.to(); + obj[key] = value; + return obj; + } + + StaticJsonDocument messageBuffer; // Variabile membro + MockStorage mockStorage; + Filter* filter = nullptr; + MessageFilter* underTest = nullptr; +}; + +TEST_F(MessageFilterTest, MessagePresentInBlackListReturnsTrue) { + filter->add("name", "blocked", Filter::BLOCK); + JsonObject payload = createMessage("name", "blocked"); + + EXPECT_TRUE(underTest->inBlockList(payload)); +} + +TEST_F(MessageFilterTest, BlacklistIgnoreFlagSkipsMatching) { + filter->add("name", "blocked", Filter::BLOCK); + underTest->ignoreBlockList(true); + JsonObject payload = createMessage("name", "blocked"); + + EXPECT_FALSE(underTest->inBlockList(payload)); +} + +TEST_F(MessageFilterTest, MessageNotInBlackListReturnsFalse) { + filter->add("name", "blocked", Filter::BLOCK); + JsonObject payload = createMessage("name", "allowed"); + + EXPECT_FALSE(underTest->inBlockList(payload)); +} + +TEST_F(MessageFilterTest, EmptyWhitelistAllowsAllMessages) { + JsonObject payload = createMessage("id", "any"); + + EXPECT_TRUE(underTest->inPassList(payload)); +} + +TEST_F(MessageFilterTest, WhitelistMatchReturnsTrue) { + filter->add("id", "sensorA", Filter::PASS); + JsonObject payload = createMessage("id", "sensorA"); + + EXPECT_TRUE(underTest->inPassList(payload)); +} + +TEST_F(MessageFilterTest, WhitelistMissReturnsFalse) { + filter->add("id", "sensorA", Filter::PASS); + JsonObject payload = createMessage("id", "sensorB"); + + EXPECT_FALSE(underTest->inPassList(payload)); +} + +TEST_F(MessageFilterTest, WhitelistIgnoreFlagAlwaysAllows) { + filter->add("id", "sensorA", Filter::PASS); + underTest->ignorePassList(true); + JsonObject payload = createMessage("id", "sensorB"); + + EXPECT_TRUE(underTest->inPassList(payload)); +} + +// ============================================================================ +// Block List Edge Cases +// ============================================================================ + +TEST_F(MessageFilterTest, EmptyBlockListAllowsAllMessages) { + JsonObject payload = createMessage("id", "any"); + + EXPECT_FALSE(underTest->inBlockList(payload)); +} + +TEST_F(MessageFilterTest, MultipleKeysInMessageFirstMatches) { + filter->add("id", "blocked", Filter::BLOCK); + + messageBuffer.clear(); + JsonObject payload = messageBuffer.to(); + payload["id"] = "blocked"; + payload["name"] = "allowed"; + + EXPECT_TRUE(underTest->inBlockList(payload)); +} + +TEST_F(MessageFilterTest, MultipleKeysInMessageSecondMatches) { + filter->add("name", "blocked", Filter::BLOCK); + + messageBuffer.clear(); + JsonObject payload = messageBuffer.to(); + payload["id"] = "allowed"; + payload["name"] = "blocked"; + + EXPECT_TRUE(underTest->inBlockList(payload)); +} + +TEST_F(MessageFilterTest, BlockListWithNonStringValuesIgnored) { + filter->add("temp", "25", Filter::BLOCK); + + messageBuffer.clear(); + JsonObject payload = messageBuffer.to(); + payload["temp"] = 25; // Integer, not string + + EXPECT_FALSE(underTest->inBlockList(payload)); +} + +// ============================================================================ +// Pass List Edge Cases +// ============================================================================ + +TEST_F(MessageFilterTest, PassListWithMultipleEntriesSameKey) { + filter->add("id", "sensorA", Filter::PASS); + filter->add("id", "sensorB", Filter::PASS); + + JsonObject payload1 = createMessage("id", "sensorA"); + JsonObject payload2 = createMessage("id", "sensorB"); + + EXPECT_TRUE(underTest->inPassList(payload1)); + EXPECT_TRUE(underTest->inPassList(payload2)); +} + +TEST_F(MessageFilterTest, PassListWithDifferentKeys) { + filter->add("id", "sensorA", Filter::PASS); + filter->add("type", "temperature", Filter::PASS); + + JsonObject payload1 = createMessage("id", "sensorA"); + JsonObject payload2 = createMessage("type", "temperature"); + + EXPECT_TRUE(underTest->inPassList(payload1)); + EXPECT_TRUE(underTest->inPassList(payload2)); +} + +TEST_F(MessageFilterTest, PassListWithNonStringValuesIgnored) { + filter->add("temp", "25", Filter::PASS); + + messageBuffer.clear(); + JsonObject payload = messageBuffer.to(); + payload["temp"] = 25; // Integer, not string + + // Since pass list exists but no match, should return false + EXPECT_FALSE(underTest->inPassList(payload)); +} + +TEST_F(MessageFilterTest, PassListMatchesFirstKeyInMultiKeyMessage) { + filter->add("id", "sensorA", Filter::PASS); + + messageBuffer.clear(); + JsonObject payload = messageBuffer.to(); + payload["id"] = "sensorA"; + payload["name"] = "unknown"; + + EXPECT_TRUE(underTest->inPassList(payload)); +} + +// ============================================================================ +// Ignore Flags Tests +// ============================================================================ + +TEST_F(MessageFilterTest, IsBlockListIgnoredReturnsFalseByDefault) { + EXPECT_FALSE(underTest->isBlockListIgnored()); +} + +TEST_F(MessageFilterTest, IsBlockListIgnoredReturnsTrueAfterSet) { + underTest->ignoreBlockList(true); + + EXPECT_TRUE(underTest->isBlockListIgnored()); +} + +TEST_F(MessageFilterTest, IsBlockListIgnoredCanBeToggled) { + underTest->ignoreBlockList(true); + EXPECT_TRUE(underTest->isBlockListIgnored()); + + underTest->ignoreBlockList(false); + EXPECT_FALSE(underTest->isBlockListIgnored()); +} + +TEST_F(MessageFilterTest, IsPassListIgnoredReturnsFalseByDefault) { + EXPECT_FALSE(underTest->isPassListIgnored()); +} + +TEST_F(MessageFilterTest, IsPassListIgnoredReturnsTrueAfterSet) { + underTest->ignorePassList(true); + + EXPECT_TRUE(underTest->isPassListIgnored()); +} + +TEST_F(MessageFilterTest, IsPassListIgnoredCanBeToggled) { + underTest->ignorePassList(true); + EXPECT_TRUE(underTest->isPassListIgnored()); + + underTest->ignorePassList(false); + EXPECT_FALSE(underTest->isPassListIgnored()); +} + +// ============================================================================ +// MQTT Command Handling Tests +// ============================================================================ + +TEST_F(MessageFilterTest, HandleMQTTCommandWithNoFilterKeyDoesNothing) { + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + command["other"] = "value"; + + underTest->handleMQTTCommand(command); + + // Verify no filters were added + JsonObject testPayload = createMessage("id", "test"); + EXPECT_TRUE(underTest->inPassList(testPayload)); // Empty pass list allows all +} + +TEST_F(MessageFilterTest, HandleMQTTCommandAddsPassFilters) { + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonObject passObj = filterObj.createNestedObject("pass"); + JsonArray idArray = passObj.createNestedArray("id"); + idArray.add("sensorA"); + idArray.add("sensorB"); + + underTest->handleMQTTCommand(command); + + StaticJsonDocument doc1; + JsonObject payload1 = doc1.to(); + payload1["id"] = "sensorA"; + + StaticJsonDocument doc2; + JsonObject payload2 = doc2.to(); + payload2["id"] = "sensorB"; + + StaticJsonDocument doc3; + JsonObject payload3 = doc3.to(); + payload3["id"] = "sensorC"; + + EXPECT_TRUE(underTest->inPassList(payload1)); + EXPECT_TRUE(underTest->inPassList(payload2)); + EXPECT_FALSE(underTest->inPassList(payload3)); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandAddsBlockFilters) { + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonObject blockObj = filterObj.createNestedObject("block"); + JsonArray nameArray = blockObj.createNestedArray("name"); + nameArray.add("blocked1"); + nameArray.add("blocked2"); + + underTest->handleMQTTCommand(command); + + StaticJsonDocument doc1; + JsonObject payload1 = doc1.to(); + payload1["name"] = "blocked1"; + + StaticJsonDocument doc2; + JsonObject payload2 = doc2.to(); + payload2["name"] = "blocked2"; + + StaticJsonDocument doc3; + JsonObject payload3 = doc3.to(); + payload3["name"] = "allowed"; + + EXPECT_TRUE(underTest->inBlockList(payload1)); + EXPECT_TRUE(underTest->inBlockList(payload2)); + EXPECT_FALSE(underTest->inBlockList(payload3)); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandSetsBothPassAndBlock) { + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + + JsonObject passObj = filterObj.createNestedObject("pass"); + JsonArray passArray = passObj.createNestedArray("id"); + passArray.add("allowed"); + + JsonObject blockObj = filterObj.createNestedObject("block"); + JsonArray blockArray = blockObj.createNestedArray("id"); + blockArray.add("blocked"); + + underTest->handleMQTTCommand(command); + + StaticJsonDocument doc1; + JsonObject allowedPayload = doc1.to(); + allowedPayload["id"] = "allowed"; + + StaticJsonDocument doc2; + JsonObject blockedPayload = doc2.to(); + blockedPayload["id"] = "blocked"; + + EXPECT_TRUE(underTest->inPassList(allowedPayload)); + EXPECT_TRUE(underTest->inBlockList(blockedPayload)); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandSetsIgnorePassFlag) { + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["ignore_pass"] = true; + + underTest->handleMQTTCommand(command); + + EXPECT_TRUE(underTest->isPassListIgnored()); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandSetsIgnoreBlockFlag) { + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["ignore_block"] = true; + + underTest->handleMQTTCommand(command); + + EXPECT_TRUE(underTest->isBlockListIgnored()); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandSetsBothIgnoreFlags) { + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["ignore_pass"] = true; + filterObj["ignore_block"] = true; + + underTest->handleMQTTCommand(command); + + EXPECT_TRUE(underTest->isPassListIgnored()); + EXPECT_TRUE(underTest->isBlockListIgnored()); +} + +// New tests for cmd handling + +TEST_F(MessageFilterTest, HandleMQTTCommandActionResetClearsFiltersAndSetsIgnoreFlags) { + // Pre-populate filters + filter->add("id", "sensorX", Filter::PASS); + filter->add("name", "blockedX", Filter::BLOCK); + + EXPECT_EQ(2u, filter->getTotalFilterCount()); + + // Build command with cmd=reset and extra pass values (should be ignored because reset returns immediately) + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["cmd"] = "reset"; + JsonObject passObj = filterObj.createNestedObject("pass"); + JsonArray idArray = passObj.createNestedArray("id"); + idArray.add("newVal"); + + underTest->handleMQTTCommand(command); + + // After reset: filters cleared and both ignore flags set, pass/block arrays NOT processed + EXPECT_EQ(0u, filter->getTotalFilterCount()); + EXPECT_TRUE(underTest->isPassListIgnored()); + EXPECT_TRUE(underTest->isBlockListIgnored()); + + // Pass ignored => always allowed + StaticJsonDocument doc1; + JsonObject payloadAllow = doc1.to(); + payloadAllow["id"] = "anything"; + EXPECT_TRUE(underTest->inPassList(payloadAllow)); + + // Block ignored => never blocks + StaticJsonDocument doc2; + JsonObject payloadBlock = doc2.to(); + payloadBlock["name"] = "blockedX"; + EXPECT_FALSE(underTest->inBlockList(payloadBlock)); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandActionNewClearsThenLoadsFilters) { + // Pre-populate filters and set ignore flags + filter->add("id", "oldPass", Filter::PASS); + filter->add("name", "oldBlock", Filter::BLOCK); + underTest->ignorePassList(true); + underTest->ignoreBlockList(true); + + EXPECT_EQ(2u, filter->getTotalFilterCount()); + + // Build command with cmd=new and pass/block arrays + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["cmd"] = "new"; + + JsonObject passObj = filterObj.createNestedObject("pass"); + JsonArray passId = passObj.createNestedArray("id"); + passId.add("allowed"); + + JsonObject blockObj = filterObj.createNestedObject("block"); + JsonArray blockId = blockObj.createNestedArray("id"); + blockId.add("blocked"); + + underTest->handleMQTTCommand(command); + + // Old filters cleared, new ones loaded + EXPECT_EQ(2u, filter->getTotalFilterCount()); + EXPECT_TRUE(filter->contains("id", "allowed", Filter::PASS)); + EXPECT_TRUE(filter->contains("id", "blocked", Filter::BLOCK)); + + // Ignore flags should remain unchanged (true) - 'new' cmd doesn't modify them + EXPECT_TRUE(underTest->isPassListIgnored()); + EXPECT_TRUE(underTest->isBlockListIgnored()); + + // Validate MessageFilter behavior + StaticJsonDocument doc1; + JsonObject allowedPayload = doc1.to(); + allowedPayload["id"] = "allowed"; + EXPECT_TRUE(underTest->inPassList(allowedPayload)); + + StaticJsonDocument doc2; + JsonObject blockedPayload = doc2.to(); + blockedPayload["id"] = "blocked"; + EXPECT_FALSE(underTest->inBlockList(blockedPayload)); // Block list ignored, so returns false +} + +TEST_F(MessageFilterTest, HandleMQTTCommandUnknownActionDoesNotClear) { + // Pre-populate filters + filter->add("id", "base", Filter::PASS); + EXPECT_EQ(1u, filter->getTotalFilterCount()); + + // Unknown cmd without pass/block -> no change + messageBuffer.clear(); + JsonObject command1 = messageBuffer.to(); + JsonObject filterObj1 = command1.createNestedObject("filter"); + filterObj1["cmd"] = "unknown"; + + underTest->handleMQTTCommand(command1); + EXPECT_EQ(1u, filter->getTotalFilterCount()); + EXPECT_TRUE(filter->contains("id", "base", Filter::PASS)); + + // Unknown cmd with pass additions -> should add on top + messageBuffer.clear(); + JsonObject command2 = messageBuffer.to(); + JsonObject filterObj2 = command2.createNestedObject("filter"); + filterObj2["cmd"] = "unknown"; + JsonObject passObj2 = filterObj2.createNestedObject("pass"); + JsonArray idArray2 = passObj2.createNestedArray("id"); + idArray2.add("extra"); + + underTest->handleMQTTCommand(command2); + EXPECT_EQ(2u, filter->getTotalFilterCount()); + EXPECT_TRUE(filter->contains("id", "base", Filter::PASS)); + EXPECT_TRUE(filter->contains("id", "extra", Filter::PASS)); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandWithNonArrayPassValueIsIgnored) { + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonObject passObj = filterObj.createNestedObject("pass"); + passObj["id"] = "notAnArray"; // Should be an array + + underTest->handleMQTTCommand(command); + + // Should still have empty pass list, allowing all + JsonObject payload = createMessage("id", "test"); + EXPECT_TRUE(underTest->inPassList(payload)); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandWithNonArrayBlockValueIsIgnored) { + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonObject blockObj = filterObj.createNestedObject("block"); + blockObj["name"] = "notAnArray"; // Should be an array + + underTest->handleMQTTCommand(command); + + // Should still have empty block list, allowing all + JsonObject payload = createMessage("name", "test"); + EXPECT_FALSE(underTest->inBlockList(payload)); +} + +// ============================================================================ +// Combined Pass and Block List Scenarios +// ============================================================================ + +TEST_F(MessageFilterTest, MessageInBothPassAndBlockLists) { + filter->add("id", "sensor1", Filter::PASS); + filter->add("id", "sensor1", Filter::BLOCK); + + JsonObject payload = createMessage("id", "sensor1"); + + // Should be in both lists + EXPECT_TRUE(underTest->inPassList(payload)); + EXPECT_TRUE(underTest->inBlockList(payload)); +} + +TEST_F(MessageFilterTest, IgnoreFlagsPriorityOverFilters) { + filter->add("id", "sensor1", Filter::PASS); + filter->add("name", "blocked", Filter::BLOCK); + + underTest->ignorePassList(true); + underTest->ignoreBlockList(true); + + JsonObject passPayload = createMessage("id", "sensor1"); + JsonObject blockPayload = createMessage("name", "blocked"); + + // With ignore flags, pass should allow all, block should block none + EXPECT_TRUE(underTest->inPassList(passPayload)); + EXPECT_FALSE(underTest->inBlockList(blockPayload)); +} + +// ============================================================================ +// Wildcard Pattern Tests (if supported by Filter) +// ============================================================================ + +TEST_F(MessageFilterTest, PassListWithWildcardPattern) { + filter->add("id", "sensor*", Filter::PASS); + + StaticJsonDocument doc1; + JsonObject payload1 = doc1.to(); + payload1["id"] = "sensorA"; + + StaticJsonDocument doc2; + JsonObject payload2 = doc2.to(); + payload2["id"] = "sensorB"; + + StaticJsonDocument doc3; + JsonObject payload3 = doc3.to(); + payload3["id"] = "device1"; + + EXPECT_TRUE(underTest->inPassList(payload1)); + EXPECT_TRUE(underTest->inPassList(payload2)); + EXPECT_FALSE(underTest->inPassList(payload3)); +} + +TEST_F(MessageFilterTest, BlockListWithWildcardPattern) { + filter->add("name", "temp_*", Filter::BLOCK); + + StaticJsonDocument doc1; + JsonObject payload1 = doc1.to(); + payload1["name"] = "temp_sensor1"; + + StaticJsonDocument doc2; + JsonObject payload2 = doc2.to(); + payload2["name"] = "temp_sensor2"; + + StaticJsonDocument doc3; + JsonObject payload3 = doc3.to(); + payload3["name"] = "humidity"; + + EXPECT_TRUE(underTest->inBlockList(payload1)); + EXPECT_TRUE(underTest->inBlockList(payload2)); + EXPECT_FALSE(underTest->inBlockList(payload3)); +} + +TEST_F(MessageFilterTest, WildcardQuestionMarkSingleChar) { + filter->add("id", "sensor?", Filter::PASS); + + StaticJsonDocument doc1; + JsonObject payload1 = doc1.to(); + payload1["id"] = "sensorA"; + + StaticJsonDocument doc2; + JsonObject payload2 = doc2.to(); + payload2["id"] = "sensor1"; + + StaticJsonDocument doc3; + JsonObject payload3 = doc3.to(); + payload3["id"] = "sensor12"; + + EXPECT_TRUE(underTest->inPassList(payload1)); + EXPECT_TRUE(underTest->inPassList(payload2)); + EXPECT_FALSE(underTest->inPassList(payload3)); +} + +// ============================================================================ +// Empty and Null Safety Tests +// ============================================================================ + +TEST_F(MessageFilterTest, EmptyMessageNotInBlockList) { + filter->add("id", "test", Filter::BLOCK); + + messageBuffer.clear(); + JsonObject emptyPayload = messageBuffer.to(); + + EXPECT_FALSE(underTest->inBlockList(emptyPayload)); +} + +TEST_F(MessageFilterTest, EmptyMessageWithEmptyPassListAllowed) { + messageBuffer.clear(); + JsonObject emptyPayload = messageBuffer.to(); + + EXPECT_TRUE(underTest->inPassList(emptyPayload)); +} + +TEST_F(MessageFilterTest, EmptyMessageWithNonEmptyPassListRejected) { + filter->add("id", "test", Filter::PASS); + + messageBuffer.clear(); + JsonObject emptyPayload = messageBuffer.to(); + + EXPECT_FALSE(underTest->inPassList(emptyPayload)); +} +// ============================================================================ +// Clear Filter Tests +// ============================================================================ + +TEST_F(MessageFilterTest, HandleMQTTCommandActionClearRemovesAllFilters) { + // Add some initial filters + filter->add("id", "sensor1", Filter::PASS); + filter->add("name", "blocked", Filter::BLOCK); + + // Verify filters are active (use distinct documents to avoid reuse) + StaticJsonDocument verifyDoc1; + JsonObject passPayload = verifyDoc1.to(); + passPayload["id"] = "sensor1"; + StaticJsonDocument verifyDoc2; + JsonObject blockPayload = verifyDoc2.to(); + blockPayload["name"] = "blocked"; + EXPECT_TRUE(underTest->inPassList(passPayload)); + EXPECT_TRUE(underTest->inBlockList(blockPayload)); + + // Clear filters via MQTT command with cmd=clear + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["cmd"] = "clear"; + + underTest->handleMQTTCommand(command); + + // Clear should remove all filters + StaticJsonDocument doc1; + JsonObject newPassPayload = doc1.to(); + newPassPayload["id"] = "sensor1"; + + StaticJsonDocument doc2; + JsonObject newBlockPayload = doc2.to(); + newBlockPayload["name"] = "blocked"; + + EXPECT_TRUE(underTest->inPassList(newPassPayload)); // Empty pass list allows all + EXPECT_FALSE(underTest->inBlockList(newBlockPayload)); // Empty block list blocks none +} + +TEST_F(MessageFilterTest, HandleMQTTCommandUnknownActionDoesNotRemoveFilters) { + filter->add("id", "sensor1", Filter::PASS); + filter->add("name", "blocked", Filter::BLOCK); + + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["cmd"] = "unknown_action"; + + underTest->handleMQTTCommand(command); + + // Verify filters still exist + StaticJsonDocument doc1; + JsonObject passPayload = doc1.to(); + passPayload["id"] = "sensor1"; + + StaticJsonDocument doc2; + JsonObject blockPayload = doc2.to(); + blockPayload["name"] = "blocked"; + + EXPECT_TRUE(underTest->inPassList(passPayload)); + EXPECT_TRUE(underTest->inBlockList(blockPayload)); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandActionClearReturnsImmediately) { + filter->add("id", "oldSensor", Filter::PASS); + + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["cmd"] = "clear"; + + JsonObject passObj = filterObj.createNestedObject("pass"); + JsonArray idArray = passObj.createNestedArray("id"); + idArray.add("newSensor"); + + underTest->handleMQTTCommand(command); + + StaticJsonDocument doc1; + JsonObject oldPayload = doc1.to(); + oldPayload["id"] = "oldSensor"; + + StaticJsonDocument doc2; + JsonObject newPayload = doc2.to(); + newPayload["id"] = "newSensor"; + + EXPECT_TRUE(underTest->inPassList(oldPayload)); // Old filter cleared, empty pass allows all + EXPECT_TRUE(underTest->inPassList(newPayload)); // New filter NOT added, empty pass allows all +} + +TEST_F(MessageFilterTest, HandleMQTTCommandActionNewWithoutFiltersJustClears) { + filter->add("id", "sensor1", Filter::PASS); + + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["cmd"] = "new"; + + underTest->handleMQTTCommand(command); + + // Verify filter cleared (cmd=new clears but continues processing) + JsonObject payload = createMessage("id", "sensor1"); + EXPECT_TRUE(underTest->inPassList(payload)); // Empty pass list allows all + EXPECT_EQ(0u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandNoActionAddsFiltersToExisting) { + filter->add("id", "sensor1", Filter::PASS); + + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonObject passObj = filterObj.createNestedObject("pass"); + JsonArray idArray = passObj.createNestedArray("id"); + idArray.add("sensor2"); + + underTest->handleMQTTCommand(command); + + // Verify both filters exist (additive when no cmd specified) + EXPECT_EQ(2u, filter->getTotalFilterCount()); + EXPECT_TRUE(filter->contains("id", "sensor1", Filter::PASS)); + EXPECT_TRUE(filter->contains("id", "sensor2", Filter::PASS)); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandActionClearDoesNotResetIgnoreFlags) { + filter->add("id", "sensor1", Filter::PASS); + underTest->ignorePassList(true); + underTest->ignoreBlockList(true); + + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["cmd"] = "clear"; + + underTest->handleMQTTCommand(command); + + // Verify cmd=clear only affects filters, not ignore flags + EXPECT_EQ(0u, filter->getTotalFilterCount()); + EXPECT_TRUE(underTest->isPassListIgnored()); + EXPECT_TRUE(underTest->isBlockListIgnored()); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandMultipleActionClearCallsAreIdempotent) { + filter->add("id", "sensor1", Filter::PASS); + filter->add("name", "blocked", Filter::BLOCK); + + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["cmd"] = "clear"; + + // Clear twice + underTest->handleMQTTCommand(command); + underTest->handleMQTTCommand(command); + + EXPECT_EQ(0u, filter->getTotalFilterCount()); + JsonObject testPayload = createMessage("id", "anything"); + EXPECT_TRUE(underTest->inPassList(testPayload)); // Empty pass list allows all +} + +TEST_F(MessageFilterTest, HandleMQTTCommandActionClearDoesNotAffectSubsequentCommands) { + messageBuffer.clear(); + JsonObject clearCommand = messageBuffer.to(); + JsonObject clearFilter = clearCommand.createNestedObject("filter"); + clearFilter["cmd"] = "clear"; + + underTest->handleMQTTCommand(clearCommand); + EXPECT_EQ(0u, filter->getTotalFilterCount()); + + // Add new filters after clear + StaticJsonDocument doc2; + JsonObject addCommand = doc2.to(); + JsonObject addFilter = addCommand.createNestedObject("filter"); + JsonObject passObj = addFilter.createNestedObject("pass"); + JsonArray idArray = passObj.createNestedArray("id"); + idArray.add("newSensor"); + + underTest->handleMQTTCommand(addCommand); + + EXPECT_EQ(1u, filter->getTotalFilterCount()); + StaticJsonDocument doc3; + JsonObject payload = doc3.to(); + payload["id"] = "newSensor"; + + EXPECT_TRUE(underTest->inPassList(payload)); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandEmptyFilterObjectDoesNothing) { + filter->add("id", "sensor1", Filter::PASS); + + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + command.createNestedObject("filter"); // Empty filter object + + underTest->handleMQTTCommand(command); + + // Verify existing filter unchanged + JsonObject payload = createMessage("id", "sensor1"); + EXPECT_TRUE(underTest->inPassList(payload)); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandWithOnlyActionClear) { + filter->add("id", "sensor1", Filter::PASS); + filter->add("id", "sensor2", Filter::PASS); + filter->add("name", "device1", Filter::BLOCK); + + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["cmd"] = "clear"; + + underTest->handleMQTTCommand(command); + + // Verify all filters cleared + EXPECT_EQ(0u, filter->getTotalFilterCount()); + + StaticJsonDocument doc1; + JsonObject payload1 = doc1.to(); + payload1["id"] = "sensor1"; + + StaticJsonDocument doc2; + JsonObject payload2 = doc2.to(); + payload2["name"] = "device1"; + + EXPECT_TRUE(underTest->inPassList(payload1)); // Empty pass allows all + EXPECT_FALSE(underTest->inBlockList(payload2)); // Empty block blocks none +} + +// ============================================================================ +// Storage cmd Tests +// ============================================================================ + +TEST_F(MessageFilterTest, HandleMQTTCommandActionPersistCallsSaveOnStorage) { + // Add filters + filter->add("id", "sensor1", Filter::PASS); + filter->add("name", "blocked", Filter::BLOCK); + + // Expect underlying storage methods to be called + EXPECT_CALL(mockStorage, begin(false)).Times(1); + EXPECT_CALL(mockStorage, putString(_, _)).Times(1).WillOnce(Return(100)); + EXPECT_CALL(mockStorage, end()).Times(1); + + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["cmd"] = "persist"; + + underTest->handleMQTTCommand(command); + + // Filters should remain unchanged + EXPECT_EQ(2u, filter->getTotalFilterCount()); + EXPECT_TRUE(filter->contains("id", "sensor1", Filter::PASS)); + EXPECT_TRUE(filter->contains("name", "blocked", Filter::BLOCK)); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandActionPersistReturnsImmediately) { + filter->add("id", "sensor1", Filter::PASS); + + EXPECT_CALL(mockStorage, begin(false)).Times(1); + EXPECT_CALL(mockStorage, putString(_, _)).Times(1).WillOnce(Return(100)); + EXPECT_CALL(mockStorage, end()).Times(1); + + // Add persist cmd with additional pass filters (should be ignored) + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["cmd"] = "persist"; + JsonObject passObj = filterObj.createNestedObject("pass"); + JsonArray idArray = passObj.createNestedArray("id"); + idArray.add("sensor2"); + + underTest->handleMQTTCommand(command); + + // Only original filter should exist (persist returns immediately) + EXPECT_EQ(1u, filter->getTotalFilterCount()); + EXPECT_TRUE(filter->contains("id", "sensor1", Filter::PASS)); + EXPECT_FALSE(filter->contains("id", "sensor2", Filter::PASS)); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandActionReloadCallsLoadFromStorage) { + // Add initial filters + filter->add("id", "initial", Filter::PASS); + EXPECT_EQ(1u, filter->getTotalFilterCount()); + + // Mock underlying storage methods to simulate loading filters + EXPECT_CALL(mockStorage, begin(true)).Times(1); + EXPECT_CALL(mockStorage, isKey(_)).Times(1).WillOnce(Return(true)); + EXPECT_CALL(mockStorage, getString(_, _)) + .Times(1) + .WillOnce(Return("{\"id\":[\"loaded\"]}")); + EXPECT_CALL(mockStorage, end()).Times(1); + + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["cmd"] = "reload"; + + underTest->handleMQTTCommand(command); + + // Original filter cleared, loaded filter should be present in BOTH lists + // Note: Filter.from() adds to both PASS and BLOCK lists + EXPECT_FALSE(filter->contains("id", "initial", Filter::PASS)); + EXPECT_TRUE(filter->contains("id", "loaded", Filter::PASS)); + EXPECT_TRUE(filter->contains("id", "loaded", Filter::BLOCK)); + EXPECT_EQ(2u, filter->getTotalFilterCount()); // One in PASS, one in BLOCK +} + +TEST_F(MessageFilterTest, HandleMQTTCommandActionReloadReturnsImmediately) { + // Mock underlying storage methods + EXPECT_CALL(mockStorage, begin(true)).Times(1); + EXPECT_CALL(mockStorage, isKey(_)).Times(1).WillOnce(Return(true)); + EXPECT_CALL(mockStorage, getString(_, _)) + .Times(1) + .WillOnce(Return("{\"id\":[\"fromStorage\"]}")); + EXPECT_CALL(mockStorage, end()).Times(1); + + // Add reload cmd with additional pass filters (should be ignored) + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["cmd"] = "reload"; + JsonObject passObj = filterObj.createNestedObject("pass"); + JsonArray idArray = passObj.createNestedArray("id"); + idArray.add("fromCommand"); + + underTest->handleMQTTCommand(command); + + // Only storage filter should exist (reload returns immediately) + // Filter.from() adds to both PASS and BLOCK lists + EXPECT_TRUE(filter->contains("id", "fromStorage", Filter::PASS)); + EXPECT_TRUE(filter->contains("id", "fromStorage", Filter::BLOCK)); + EXPECT_FALSE(filter->contains("id", "fromCommand", Filter::PASS)); + EXPECT_EQ(2u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandActionPurgeCallsEraseStorage) { + // Add filters + filter->add("id", "sensor1", Filter::PASS); + filter->add("name", "blocked", Filter::BLOCK); + EXPECT_EQ(2u, filter->getTotalFilterCount()); + + // Expect underlying storage methods to be called + EXPECT_CALL(mockStorage, begin(false)).Times(1); + EXPECT_CALL(mockStorage, isKey(_)).Times(1).WillOnce(Return(true)); + EXPECT_CALL(mockStorage, remove(_)).Times(1).WillOnce(Return(1)); + EXPECT_CALL(mockStorage, end()).Times(1); + + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["cmd"] = "purge"; + + underTest->handleMQTTCommand(command); + + // All filters should be cleared + EXPECT_EQ(0u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandActionPurgeReturnsImmediately) { + filter->add("id", "sensor1", Filter::PASS); + + EXPECT_CALL(mockStorage, begin(false)).Times(1); + EXPECT_CALL(mockStorage, isKey(_)).Times(1).WillOnce(Return(true)); + EXPECT_CALL(mockStorage, remove(_)).Times(1).WillOnce(Return(1)); + EXPECT_CALL(mockStorage, end()).Times(1); + + // Add purge cmd with additional pass filters (should be ignored) + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["cmd"] = "purge"; + JsonObject passObj = filterObj.createNestedObject("pass"); + JsonArray idArray = passObj.createNestedArray("id"); + idArray.add("sensor2"); + + underTest->handleMQTTCommand(command); + + // All filters cleared (purge returns immediately, pass filters not processed) + EXPECT_EQ(0u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandStorageActionsDoNotModifyIgnoreFlags) { + // Set ignore flags + underTest->ignorePassList(true); + underTest->ignoreBlockList(true); + + // Test persist cmd + EXPECT_CALL(mockStorage, begin(false)).Times(1); + EXPECT_CALL(mockStorage, putString(_, _)).Times(1).WillOnce(Return(100)); + EXPECT_CALL(mockStorage, end()).Times(1); + messageBuffer.clear(); + JsonObject command1 = messageBuffer.to(); + JsonObject filterObj1 = command1.createNestedObject("filter"); + filterObj1["cmd"] = "persist"; + underTest->handleMQTTCommand(command1); + EXPECT_TRUE(underTest->isPassListIgnored()); + EXPECT_TRUE(underTest->isBlockListIgnored()); + + // Test reload cmd (no key in storage) + EXPECT_CALL(mockStorage, begin(true)).Times(1); + EXPECT_CALL(mockStorage, isKey(_)).Times(1).WillOnce(Return(false)); + EXPECT_CALL(mockStorage, end()).Times(1); + messageBuffer.clear(); + JsonObject command2 = messageBuffer.to(); + JsonObject filterObj2 = command2.createNestedObject("filter"); + filterObj2["cmd"] = "reload"; + underTest->handleMQTTCommand(command2); + EXPECT_TRUE(underTest->isPassListIgnored()); + EXPECT_TRUE(underTest->isBlockListIgnored()); + + // Test purge cmd + EXPECT_CALL(mockStorage, begin(false)).Times(1); + EXPECT_CALL(mockStorage, isKey(_)).Times(1).WillOnce(Return(true)); + EXPECT_CALL(mockStorage, remove(_)).Times(1).WillOnce(Return(1)); + EXPECT_CALL(mockStorage, end()).Times(1); + messageBuffer.clear(); + JsonObject command3 = messageBuffer.to(); + JsonObject filterObj3 = command3.createNestedObject("filter"); + filterObj3["cmd"] = "purge"; + underTest->handleMQTTCommand(command3); + EXPECT_TRUE(underTest->isPassListIgnored()); + EXPECT_TRUE(underTest->isBlockListIgnored()); +} + +TEST_F(MessageFilterTest, HandleMQTTCommandActionReloadWithEmptyStorage) { + filter->add("id", "existing", Filter::PASS); + EXPECT_EQ(1u, filter->getTotalFilterCount()); + + // Mock storage returning no key + EXPECT_CALL(mockStorage, begin(true)).Times(1); + EXPECT_CALL(mockStorage, isKey(_)).Times(1).WillOnce(Return(false)); + EXPECT_CALL(mockStorage, end()).Times(1); + + messageBuffer.clear(); + JsonObject command = messageBuffer.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj["cmd"] = "reload"; + + underTest->handleMQTTCommand(command); + + // Filters cleared, nothing loaded + EXPECT_EQ(0u, filter->getTotalFilterCount()); +} +// ============================================================================ +// Serialization Tests (MessageFilter::to) +// ============================================================================ + +TEST_F(MessageFilterTest, ToSerializesIgnoreFlagsDefaultValues) { + StaticJsonDocument doc; + JsonObject data = doc.to(); + + underTest->to(data); + + ASSERT_TRUE(data.containsKey("filter")); + JsonObject filterObj = data["filter"]; + + EXPECT_FALSE(filterObj["ignore_pass"].as()); + EXPECT_FALSE(filterObj["ignore_block"].as()); +} + +TEST_F(MessageFilterTest, ToSerializesIgnoreFlagsWhenSet) { + underTest->ignorePassList(true); + underTest->ignoreBlockList(true); + + StaticJsonDocument<512> doc; + JsonObject data = doc.to(); + + underTest->to(data); + + ASSERT_TRUE(data.containsKey("filter")); + JsonObject filterObj = data["filter"]; + + EXPECT_TRUE(filterObj["ignore_pass"].as()); + EXPECT_TRUE(filterObj["ignore_block"].as()); +} + +TEST_F(MessageFilterTest, ToSerializesPassFilters) { + filter->add("id", "sensor1", Filter::PASS); + filter->add("id", "sensor2", Filter::PASS); + filter->add("name", "device1", Filter::PASS); + + StaticJsonDocument<1024> doc; + JsonObject data = doc.to(); + + underTest->to(data); + + ASSERT_TRUE(data.containsKey("filter")); + JsonObject filterObj = data["filter"]; + + ASSERT_TRUE(filterObj.containsKey("rules")); + JsonArray rules = filterObj["rules"].as(); + + // Should have 3 pass rules in the array + ASSERT_EQ(3u, rules.size()); + + // Count pass actions + int passCount = 0; + for (JsonVariant ruleVar : rules) { + JsonObject rule = ruleVar.as(); + const char* action = rule["action"]; + if (action && strcmp(action, "pass") == 0) { + passCount++; + } + } + ASSERT_EQ(3, passCount); +} + +TEST_F(MessageFilterTest, ToSerializesBlockFilters) { + filter->add("id", "blocked1", Filter::BLOCK); + filter->add("name", "blocked2", Filter::BLOCK); + + StaticJsonDocument<1024> doc; + JsonObject data = doc.to(); + + underTest->to(data); + + ASSERT_TRUE(data.containsKey("filter")); + JsonObject filterObj = data["filter"]; + ASSERT_TRUE(filterObj.containsKey("rules")); + JsonArray rules = filterObj["rules"].as(); + + // Should have 2 block rules + ASSERT_EQ(2u, rules.size()); + + // Count block actions and verify values are present + int blockCount = 0; + bool foundBlocked1 = false, foundBlocked2 = false; + for (JsonVariant ruleVar : rules) { + JsonObject rule = ruleVar.as(); + const char* action = rule["action"]; + const char* value = rule["value"]; + if (action && strcmp(action, "block") == 0) { + blockCount++; + if (value && strcmp(value, "blocked1") == 0) foundBlocked1 = true; + if (value && strcmp(value, "blocked2") == 0) foundBlocked2 = true; + } + } + ASSERT_EQ(2, blockCount); + ASSERT_TRUE(foundBlocked1); + ASSERT_TRUE(foundBlocked2); +} + +TEST_F(MessageFilterTest, ToSerializesBothPassAndBlockFilters) { + filter->add("id", "sensor1", Filter::PASS); + filter->add("name", "blocked1", Filter::BLOCK); + + StaticJsonDocument<1024> doc; + JsonObject data = doc.to(); + + underTest->to(data); + + ASSERT_TRUE(data.containsKey("filter")); + JsonObject filterObj = data["filter"]; + ASSERT_TRUE(filterObj.containsKey("rules")); + JsonArray rules = filterObj["rules"].as(); + + // Should have 2 rules (1 pass + 1 block) + ASSERT_EQ(2u, rules.size()); + + // Count pass and block actions + int passCount = 0, blockCount = 0; + for (JsonVariant ruleVar : rules) { + JsonObject rule = ruleVar.as(); + const char* action = rule["action"]; + if (action && strcmp(action, "pass") == 0) { + passCount++; + } else if (action && strcmp(action, "block") == 0) { + blockCount++; + } + } + ASSERT_EQ(1, passCount); + ASSERT_EQ(1, blockCount); +} + +TEST_F(MessageFilterTest, ToSerializesEmptyFilters) { + StaticJsonDocument<512> doc; + JsonObject data = doc.to(); + + underTest->to(data); + + ASSERT_TRUE(data.containsKey("filter")); + JsonObject filterObj = data["filter"]; + ASSERT_TRUE(filterObj.containsKey("rules")); + + // Empty filters should produce empty rules array + JsonArray rules = filterObj["rules"].as(); + EXPECT_EQ(0u, rules.size()); +} + +TEST_F(MessageFilterTest, ToCreatesNestedFilterObject) { + StaticJsonDocument<512> doc; + JsonObject data = doc.to(); + + // Add some other data to verify nesting doesn't overwrite + data["other_key"] = "other_value"; + + underTest->to(data); + + EXPECT_TRUE(data.containsKey("other_key")); + EXPECT_STREQ("other_value", data["other_key"].as()); + EXPECT_TRUE(data.containsKey("filter")); +} + +TEST_F(MessageFilterTest, ToSerializesCompleteConfiguration) { + // Set ignore flags + underTest->ignorePassList(true); + underTest->ignoreBlockList(false); + + // Add various filters + filter->add("id", "sensor1", Filter::PASS); + filter->add("id", "sensor2", Filter::PASS); + filter->add("type", "temp", Filter::PASS); + filter->add("name", "blocked", Filter::BLOCK); + + StaticJsonDocument<1024> doc; + JsonObject data = doc.to(); + + underTest->to(data); + + ASSERT_TRUE(data.containsKey("filter")); + JsonObject filterObj = data["filter"]; + + // Verify ignore flags + EXPECT_TRUE(filterObj["ignore_pass"].as()); + EXPECT_FALSE(filterObj["ignore_block"].as()); + + // Verify rules array contains both pass and block rules + ASSERT_TRUE(filterObj.containsKey("rules")); + JsonArray rules = filterObj["rules"].as(); + ASSERT_EQ(4u, rules.size()); // 3 pass + 1 block + + // Count pass and block rules + int passCount = 0, blockCount = 0; + for (JsonVariant ruleVar : rules) { + JsonObject rule = ruleVar.as(); + const char* action = rule["action"]; + if (action && strcmp(action, "pass") == 0) { + passCount++; + } else if (action && strcmp(action, "block") == 0) { + blockCount++; + } + } + EXPECT_EQ(3, passCount); + EXPECT_EQ(1, blockCount); +} + +TEST_F(MessageFilterTest, ToWithMixedIgnoreFlags) { + underTest->ignorePassList(false); + underTest->ignoreBlockList(true); + + StaticJsonDocument<512> doc; + JsonObject data = doc.to(); + + underTest->to(data); + + JsonObject filterObj = data["filter"]; + EXPECT_FALSE(filterObj["ignore_pass"].as()); + EXPECT_TRUE(filterObj["ignore_block"].as()); +} + +TEST_F(MessageFilterTest, ToMultipleCallsProduceSameResult) { + filter->add("id", "test", Filter::PASS); + underTest->ignorePassList(true); + + StaticJsonDocument<1024> doc1; + JsonObject data1 = doc1.to(); + underTest->to(data1); + + StaticJsonDocument<1024> doc2; + JsonObject data2 = doc2.to(); + underTest->to(data2); + + // Both should have same structure + EXPECT_TRUE(data1.containsKey("filter")); + EXPECT_TRUE(data2.containsKey("filter")); + EXPECT_EQ(data1["filter"]["ignore_pass"].as(), + data2["filter"]["ignore_pass"].as()); +} + +// ============================================================================ +// Rules Array Parsing Tests - Type Safety +// ============================================================================ + +TEST_F(MessageFilterTest, RulesWithValidJsonObjectElements) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + JsonObject rule1 = rulesArray.createNestedObject(); + rule1["target"] = "topic"; + rule1["action"] = "pass"; + rule1["value"] = "home/sensor/temp"; + + underTest->handleMQTTCommand(command); + + EXPECT_TRUE(filter->isTopicFilterPresent("home/sensor/temp", Filter::PASS)); +} + +TEST_F(MessageFilterTest, RulesWithMultipleValidObjects) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // First rule - topic filter + JsonObject rule1 = rulesArray.createNestedObject(); + rule1["target"] = "topic"; + rule1["action"] = "pass"; + rule1["value"] = "home/sensor/temp"; + + // Second rule - key-value filter + JsonObject rule2 = rulesArray.createNestedObject(); + rule2["target"] = "message"; + rule2["action"] = "block"; + rule2["key"] = "id"; + rule2["value"] = "badDevice"; + + underTest->handleMQTTCommand(command); + + EXPECT_TRUE(filter->isTopicFilterPresent("home/sensor/temp", Filter::PASS)); + EXPECT_TRUE(filter->contains("id", "badDevice", Filter::BLOCK)); + EXPECT_EQ(2u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesArrayWithInvalidNonObjectElements) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // Add a non-object element (string) - should be skipped + rulesArray.add("invalid_string"); + + // Add a valid object after the invalid element + JsonObject validRule = rulesArray.createNestedObject(); + validRule["target"] = "topic"; + validRule["action"] = "pass"; + validRule["value"] = "home/sensor/temp"; + + underTest->handleMQTTCommand(command); + + // Only the valid rule should be processed + EXPECT_TRUE(filter->isTopicFilterPresent("home/sensor/temp", Filter::PASS)); + EXPECT_EQ(1u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesArrayWithNumberElement) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // Add a number element (invalid) - should be skipped + rulesArray.add(42); + + // Add valid rule after + JsonObject validRule = rulesArray.createNestedObject(); + validRule["target"] = "message"; + validRule["action"] = "pass"; + validRule["key"] = "id"; + validRule["value"] = "sensor1"; + + underTest->handleMQTTCommand(command); + + // Only valid rule should be added + EXPECT_TRUE(filter->contains("id", "sensor1", Filter::PASS)); + EXPECT_EQ(1u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesArrayWithNestedArrayElement) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // Add a nested array element (invalid) - should be skipped + JsonArray invalidArray = rulesArray.createNestedArray(); + invalidArray.add("test"); + + // Add valid rule + JsonObject validRule = rulesArray.createNestedObject(); + validRule["target"] = "topic"; + validRule["action"] = "block"; + validRule["value"] = "home/blocked/topic"; + + underTest->handleMQTTCommand(command); + + // Only valid rule should be processed + EXPECT_TRUE(filter->isTopicFilterPresent("home/blocked/topic", Filter::BLOCK)); + EXPECT_EQ(1u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesArrayMixedValidAndInvalidElements) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // Valid object + JsonObject rule1 = rulesArray.createNestedObject(); + rule1["target"] = "topic"; + rule1["action"] = "pass"; + rule1["value"] = "home/sensor/temp"; + + // Invalid: string + rulesArray.add("invalid"); + + // Valid object + JsonObject rule2 = rulesArray.createNestedObject(); + rule2["target"] = "topic"; + rule2["action"] = "block"; + rule2["value"] = "home/blocked/topic"; + + // Invalid: number + rulesArray.add(99); + + // Valid object + JsonObject rule3 = rulesArray.createNestedObject(); + rule3["target"] = "message"; + rule3["action"] = "pass"; + rule3["key"] = "name"; + rule3["value"] = "sensor"; + + underTest->handleMQTTCommand(command); + + // All three valid rules should be processed + EXPECT_TRUE(filter->isTopicFilterPresent("home/sensor/temp", Filter::PASS)); + EXPECT_TRUE(filter->isTopicFilterPresent("home/blocked/topic", Filter::BLOCK)); + EXPECT_TRUE(filter->contains("name", "sensor", Filter::PASS)); + EXPECT_EQ(3u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesArrayEmptyArray) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + filterObj.createNestedArray("rules"); // Empty array + + underTest->handleMQTTCommand(command); + + // No filters should be added + EXPECT_EQ(0u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesArrayOnlyInvalidElements) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // Add only invalid elements + rulesArray.add("string1"); + rulesArray.add(123); + rulesArray.add("string2"); + + underTest->handleMQTTCommand(command); + + // No valid rules processed + EXPECT_EQ(0u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesObjectSkipsInvalidAndProcessesValidFields) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // Valid rule with all required fields + JsonObject validRule = rulesArray.createNestedObject(); + validRule["target"] = "message"; + validRule["action"] = "pass"; + validRule["key"] = "id"; + validRule["value"] = "device1"; + + // Invalid rule - missing required field "value" + JsonObject invalidRule = rulesArray.createNestedObject(); + invalidRule["target"] = "message"; + invalidRule["action"] = "pass"; + invalidRule["key"] = "id"; + // No "value" field + + underTest->handleMQTTCommand(command); + + // Only valid rule should be added + EXPECT_TRUE(filter->contains("id", "device1", Filter::PASS)); + EXPECT_EQ(1u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesTopicFilterWithValidObject) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + JsonObject topicRule = rulesArray.createNestedObject(); + topicRule["target"] = "topic"; + topicRule["action"] = "pass"; + topicRule["value"] = "home/kitchen/temp"; + + underTest->handleMQTTCommand(command); + + EXPECT_TRUE(underTest->allowedTopic("home/kitchen/temp")); + EXPECT_EQ(1u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesMessageFilterWithValidObject) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + JsonObject msgRule = rulesArray.createNestedObject(); + msgRule["target"] = "message"; + msgRule["action"] = "block"; + msgRule["key"] = "id"; + msgRule["value"] = "blocked_id"; + + JsonObject payload = createMessage("id", "blocked_id"); + underTest->handleMQTTCommand(command); + + EXPECT_TRUE(underTest->inBlockList(payload)); +} + +// ============================================================================ +// allowedTopic Tests +// ============================================================================ + +TEST_F(MessageFilterTest, AllowedTopicReturnsTrueWhenNoFilters) { + EXPECT_TRUE(underTest->allowedTopic("home/sensor/temp")); + EXPECT_TRUE(underTest->allowedTopic("any/topic")); +} + +TEST_F(MessageFilterTest, AllowedTopicReturnsFalseWhenInBlockList) { + filter->addTopicFilters("home/blocked/topic", Filter::BLOCK); + + EXPECT_FALSE(underTest->allowedTopic("home/blocked/topic")); +} + +TEST_F(MessageFilterTest, AllowedTopicReturnsTrueWhenNotInBlockList) { + filter->addTopicFilters("home/blocked/topic", Filter::BLOCK); + + EXPECT_TRUE(underTest->allowedTopic("home/sensor/temp")); +} + +TEST_F(MessageFilterTest, AllowedTopicReturnsTrueWhenInPassList) { + filter->addTopicFilters("home/sensor/temp", Filter::PASS); + + EXPECT_TRUE(underTest->allowedTopic("home/sensor/temp")); +} + +TEST_F(MessageFilterTest, AllowedTopicReturnsFalseWhenNotInPassList) { + filter->addTopicFilters("home/sensor/temp", Filter::PASS); + + EXPECT_FALSE(underTest->allowedTopic("home/sensor/humidity")); +} + +TEST_F(MessageFilterTest, AllowedTopicBlockListTakesPrecedence) { + filter->addTopicFilters("home/sensor/temp", Filter::PASS); + filter->addTopicFilters("home/sensor/temp", Filter::BLOCK); + + EXPECT_FALSE(underTest->allowedTopic("home/sensor/temp")); +} + +TEST_F(MessageFilterTest, AllowedTopicWithWildcardInPassList) { + filter->addTopicFilters("home/sensor/*", Filter::PASS); + + EXPECT_TRUE(underTest->allowedTopic("home/sensor/temp")); + EXPECT_TRUE(underTest->allowedTopic("home/sensor/humidity")); + EXPECT_FALSE(underTest->allowedTopic("home/actuator/light")); +} + +TEST_F(MessageFilterTest, AllowedTopicWithWildcardInBlockList) { + filter->addTopicFilters("home/blocked/*", Filter::BLOCK); + + EXPECT_FALSE(underTest->allowedTopic("home/blocked/topic1")); + EXPECT_FALSE(underTest->allowedTopic("home/blocked/topic2")); + EXPECT_TRUE(underTest->allowedTopic("home/sensor/temp")); +} + +TEST_F(MessageFilterTest, AllowedTopicIgnoresBlockListWhenFlagSet) { + filter->addTopicFilters("home/blocked/topic", Filter::BLOCK); + underTest->ignoreBlockList(true); + + EXPECT_TRUE(underTest->allowedTopic("home/blocked/topic")); +} + +TEST_F(MessageFilterTest, AllowedTopicIgnoresPassListWhenFlagSet) { + filter->addTopicFilters("home/sensor/temp", Filter::PASS); + underTest->ignorePassList(true); + + EXPECT_TRUE(underTest->allowedTopic("home/sensor/humidity")); + EXPECT_TRUE(underTest->allowedTopic("any/topic")); +} + +TEST_F(MessageFilterTest, AllowedTopicWithNullPointerReturnsFalse) { + // Defensive test - depends on implementation handling of null + EXPECT_FALSE(underTest->allowedTopic(nullptr)); +} + +TEST_F(MessageFilterTest, AllowedTopicWithEmptyString) { + filter->addTopicFilters("", Filter::PASS); + + EXPECT_TRUE(underTest->allowedTopic("")); + EXPECT_FALSE(underTest->allowedTopic("home/sensor/temp")); +} + +TEST_F(MessageFilterTest, AllowedTopicWithComplexWildcardPattern) { + filter->addTopicFilters("home/*/temp", Filter::PASS); + + EXPECT_TRUE(underTest->allowedTopic("home/kitchen/temp")); + EXPECT_TRUE(underTest->allowedTopic("home/bedroom/temp")); + EXPECT_FALSE(underTest->allowedTopic("home/kitchen/humidity")); +} + +TEST_F(MessageFilterTest, AllowedTopicMultipleTopicsInPassList) { + filter->addTopicFilters("home/sensor/temp", Filter::PASS); + filter->addTopicFilters("home/sensor/humidity", Filter::PASS); + filter->addTopicFilters("home/actuator/light", Filter::PASS); + + EXPECT_TRUE(underTest->allowedTopic("home/sensor/temp")); + EXPECT_TRUE(underTest->allowedTopic("home/sensor/humidity")); + EXPECT_TRUE(underTest->allowedTopic("home/actuator/light")); + EXPECT_FALSE(underTest->allowedTopic("home/sensor/pressure")); +} + +TEST_F(MessageFilterTest, AllowedTopicMultipleTopicsInBlockList) { + filter->addTopicFilters("home/blocked/topic1", Filter::BLOCK); + filter->addTopicFilters("home/blocked/topic2", Filter::BLOCK); + + EXPECT_FALSE(underTest->allowedTopic("home/blocked/topic1")); + EXPECT_FALSE(underTest->allowedTopic("home/blocked/topic2")); + EXPECT_TRUE(underTest->allowedTopic("home/sensor/temp")); +} + +TEST_F(MessageFilterTest, AllowedTopicEmptyPassListAllowsAll) { + // When pass list is empty, all topics should be allowed (except blocked ones) + EXPECT_TRUE(underTest->allowedTopic("any/topic")); + EXPECT_TRUE(underTest->allowedTopic("home/sensor/temp")); +} + +TEST_F(MessageFilterTest, AllowedTopicEmptyBlockListAllowsAll) { + // When block list is empty and pass list matches, topic is allowed + filter->addTopicFilters("home/sensor/*", Filter::PASS); + + EXPECT_TRUE(underTest->allowedTopic("home/sensor/temp")); +} + +// ============================================================================ +// Rules Array - Action Validation Tests +// ============================================================================ + +TEST_F(MessageFilterTest, RulesArrayRejectInvalidActionValue) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // Invalid action - not "pass" or "block" + JsonObject invalidRule = rulesArray.createNestedObject(); + invalidRule["target"] = "message"; + invalidRule["action"] = "allow"; // Invalid! + invalidRule["key"] = "id"; + invalidRule["value"] = "device1"; + + // Valid rule after invalid one + JsonObject validRule = rulesArray.createNestedObject(); + validRule["target"] = "message"; + validRule["action"] = "pass"; + validRule["key"] = "name"; + validRule["value"] = "sensor"; + + underTest->handleMQTTCommand(command); + + // Only valid rule should be added + EXPECT_TRUE(filter->contains("name", "sensor", Filter::PASS)); + EXPECT_FALSE(filter->contains("id", "device1", Filter::PASS)); + EXPECT_EQ(1u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesArrayOnlyAcceptsPassOrBlock) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // Try various invalid actions + const char* invalidActions[] = {"allow", "deny", "whitelist", "blacklist", "include", "exclude"}; + + for (const char* action : invalidActions) { + JsonObject rule = rulesArray.createNestedObject(); + rule["action"] = action; + rule["key"] = "id"; + rule["value"] = "test"; + } + + // Add one valid rule + JsonObject validRule = rulesArray.createNestedObject(); + validRule["action"] = "pass"; + validRule["key"] = "id"; + validRule["value"] = "valid"; + + underTest->handleMQTTCommand(command); + + // Only the valid pass rule should be added + EXPECT_TRUE(filter->contains("id", "valid", Filter::PASS)); + EXPECT_EQ(1u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesArrayTargetIsOptional) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // Rule WITHOUT target field - should default to "message" + JsonObject rule = rulesArray.createNestedObject(); + // NO target field + rule["action"] = "pass"; + rule["key"] = "id"; + rule["value"] = "device1"; + + underTest->handleMQTTCommand(command); + + // Rule should be processed as message filter (default target) + EXPECT_TRUE(filter->contains("id", "device1", Filter::PASS)); + EXPECT_EQ(1u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesArrayTopicRuleDoesNotRequireKey) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // Topic rule - no key needed + JsonObject topicRule = rulesArray.createNestedObject(); + topicRule["target"] = "topic"; + topicRule["action"] = "pass"; + topicRule["value"] = "home/sensor/*"; + + underTest->handleMQTTCommand(command); + + EXPECT_TRUE(filter->isTopicFilterPresent("home/sensor/temp", Filter::PASS)); + EXPECT_EQ(1u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesArrayMessageRuleRequiresKey) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // Message rule WITHOUT key - should fail + JsonObject messageRule = rulesArray.createNestedObject(); + messageRule["action"] = "pass"; + messageRule["value"] = "device1"; + // NO key field and NO target (defaults to message) + + underTest->handleMQTTCommand(command); + + // Rule should NOT be added + EXPECT_EQ(0u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesArrayMissingActionField) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // Rule missing action field + JsonObject rule = rulesArray.createNestedObject(); + rule["key"] = "id"; + rule["value"] = "device1"; + // NO action field + + underTest->handleMQTTCommand(command); + + // Rule should NOT be added + EXPECT_EQ(0u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesArrayMissingValueField) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // Rule missing value field + JsonObject rule = rulesArray.createNestedObject(); + rule["action"] = "pass"; + rule["key"] = "id"; + // NO value field + + underTest->handleMQTTCommand(command); + + // Rule should NOT be added + EXPECT_EQ(0u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesArrayPassAndBlockActions) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // Pass rule + JsonObject passRule = rulesArray.createNestedObject(); + passRule["action"] = "pass"; + passRule["key"] = "id"; + passRule["value"] = "allowed"; + + // Block rule + JsonObject blockRule = rulesArray.createNestedObject(); + blockRule["action"] = "block"; + blockRule["key"] = "id"; + blockRule["value"] = "blocked"; + + underTest->handleMQTTCommand(command); + + // Both should be added + EXPECT_TRUE(filter->contains("id", "allowed", Filter::PASS)); + EXPECT_TRUE(filter->contains("id", "blocked", Filter::BLOCK)); + EXPECT_EQ(2u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesArrayCaseSensitiveAction) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // Wrong case - "Pass" instead of "pass" + JsonObject rule = rulesArray.createNestedObject(); + rule["action"] = "Pass"; // Wrong case + rule["key"] = "id"; + rule["value"] = "device1"; + + underTest->handleMQTTCommand(command); + + // Rule should NOT be added (case sensitive) + EXPECT_EQ(0u, filter->getTotalFilterCount()); +} + +TEST_F(MessageFilterTest, RulesArrayMixedTargetOptionalAndRequired) { + StaticJsonDocument doc; + JsonObject command = doc.to(); + JsonObject filterObj = command.createNestedObject("filter"); + JsonArray rulesArray = filterObj.createNestedArray("rules"); + + // Topic rule (target specified, no key) + JsonObject topicRule = rulesArray.createNestedObject(); + topicRule["target"] = "topic"; + topicRule["action"] = "pass"; + topicRule["value"] = "home/sensor/*"; + + // Message rule (target omitted, defaults to message, requires key) + JsonObject messageRule = rulesArray.createNestedObject(); + messageRule["action"] = "block"; + messageRule["key"] = "name"; + messageRule["value"] = "BRO*"; + + underTest->handleMQTTCommand(command); + + // Both should be added + EXPECT_TRUE(filter->isTopicFilterPresent("home/sensor/temp", Filter::PASS)); + EXPECT_TRUE(filter->contains("name", "BRO*", Filter::BLOCK)); + EXPECT_EQ(2u, filter->getTotalFilterCount()); +} \ No newline at end of file diff --git a/test/native/unit/test_rf/test_RFConfiguration.cpp b/test/native/unit/test_rf/test_RFConfiguration.cpp new file mode 100644 index 0000000000..d8ea6a97e4 --- /dev/null +++ b/test/native/unit/test_rf/test_RFConfiguration.cpp @@ -0,0 +1,146 @@ +#include +#include +#include + +#include "../mocks/mock_IStorage.h" +#include "../mocks/mock_RFBaseGateway.h" + +using ::testing::_; +using ::testing::DoAll; +using ::testing::Return; +using ::testing::SetArgReferee; + +class RFConfigurationTest : public ::testing::Test { +protected: + void SetUp() override { + config = new RFConfiguration(mockReceiver, mockStorage); + } + void TearDown() override { + delete config; + } + MockStorage mockStorage; + MockRFGateway mockReceiver; + RFConfiguration* config; +}; + +TEST_F(RFConfigurationTest, ShouldInitializeWithDefaults) { + // Assert + ASSERT_NEAR(config->getFrequency(), RF_FREQUENCY, 0.01); + ASSERT_EQ(config->getActiveReceiver(), ACTIVE_RECEIVER); + ASSERT_EQ(config->getRssiThreshold(), 0); + ASSERT_EQ(config->getNewOokThreshold(), 0); +} + +TEST_F(RFConfigurationTest, ShouldSetAndGetFrequency) { + config->setFrequency(433.92f); + ASSERT_EQ(config->getFrequency(), 433.92f); +} + +TEST_F(RFConfigurationTest, ShouldSetAndGetRssiThreshold) { + config->setRssiThreshold(42); + ASSERT_EQ(config->getRssiThreshold(), 42); +} + +TEST_F(RFConfigurationTest, ShouldSetAndGetNewOokThreshold) { + config->setNewOokThreshold(77); + ASSERT_EQ(config->getNewOokThreshold(), 77); +} + +TEST_F(RFConfigurationTest, ShouldSetAndGetActiveReceiver) { + config->setActiveReceiver(2); + ASSERT_EQ(config->getActiveReceiver(), 2); +} + +TEST_F(RFConfigurationTest, ShouldReInitRestoreDefaults) { + config->setFrequency(400.0f); + config->setActiveReceiver(3); + config->setRssiThreshold(99); + config->setNewOokThreshold(88); + + config->reInit(); + + ASSERT_NEAR(config->getFrequency(), RF_FREQUENCY, 0.01); + ASSERT_EQ(config->getActiveReceiver(), ACTIVE_RECEIVER); + ASSERT_EQ(config->getRssiThreshold(), 0); + ASSERT_EQ(config->getNewOokThreshold(), 0); +} + +TEST_F(RFConfigurationTest, ShouldValidateFrequencyRanges) { + // Valid ranges + ASSERT_TRUE(config->validFrequency(300.0f)); + ASSERT_TRUE(config->validFrequency(348.0f)); + ASSERT_TRUE(config->validFrequency(387.0f)); + ASSERT_TRUE(config->validFrequency(464.0f)); + ASSERT_TRUE(config->validFrequency(779.0f)); + ASSERT_TRUE(config->validFrequency(928.0f)); + + // Invalid ranges + ASSERT_FALSE(config->validFrequency(299.9f)); + ASSERT_FALSE(config->validFrequency(348.1f)); + ASSERT_FALSE(config->validFrequency(386.9f)); + ASSERT_FALSE(config->validFrequency(464.1f)); + ASSERT_FALSE(config->validFrequency(778.9f)); + ASSERT_FALSE(config->validFrequency(928.1f)); +} + +TEST_F(RFConfigurationTest, ShouldNotUpdateIfKeyMissing) { + StaticJsonDocument<128> doc; + JsonObject obj = doc.to(); + float freq = 433.92f; + config->setFrequency(freq); + ASSERT_EQ(freq, 433.92f); +} + +TEST_F(RFConfigurationTest, ShouldSerializeToJson) { + config->setFrequency(433.92f); + config->setRssiThreshold(12); + config->setNewOokThreshold(34); + config->setActiveReceiver(1); + + StaticJsonDocument<256> doc; + JsonObject obj = doc.to(); + config->to(obj); + + ASSERT_EQ(obj["frequency"].as(), 433.92f); + ASSERT_EQ(obj["rssithreshold"].as(), 12); + ASSERT_EQ(obj["ookthreshold"].as(), 34); + ASSERT_EQ(obj["active"].as(), 1); +} + +TEST_F(RFConfigurationTest, ShouldLoadFromJson) { + StaticJsonDocument<256> doc; + JsonObject obj = doc.to(); + obj["frequency"] = 315.0f; + obj["active"] = 2; + + config->from(obj); + + ASSERT_EQ(config->getFrequency(), 315.0f); + ASSERT_EQ(config->getActiveReceiver(), 2); +} + +TEST_F(RFConfigurationTest, ShouldNotUpdateFrequencyIfInvalid) { + StaticJsonDocument<256> doc; + JsonObject obj = doc.to(); + obj["frequency"] = 100.0f; // Invalid + + float oldFreq = config->getFrequency(); + config->from(obj); + + ASSERT_EQ(config->getFrequency(), oldFreq); +} + +TEST_F(RFConfigurationTest, ShouldCallenableOnLoadFromStorage) { + std::string storedConfig = R"({"frequency":315.0,"active":2})"; + + EXPECT_CALL(mockStorage, begin(true)).WillOnce(Return(true)); + EXPECT_CALL(mockStorage, isKey(::testing::StrEq("RFConfig"))).WillOnce(Return(true)); + EXPECT_CALL(mockStorage, getString(::testing::StrEq("RFConfig"), ::testing::StrEq("{}"))).WillOnce(Return(storedConfig.c_str())); + EXPECT_CALL(mockStorage, end()).Times(1); + EXPECT_CALL(mockReceiver, enable()).Times(1); + + config->loadFromStorage(); + + ASSERT_EQ(config->getFrequency(), 315.0f); + ASSERT_EQ(config->getActiveReceiver(), 2); +} diff --git a/test/native/unit/test_storage/test_AbstractStorageObject.cpp b/test/native/unit/test_storage/test_AbstractStorageObject.cpp new file mode 100644 index 0000000000..5381d3fd25 --- /dev/null +++ b/test/native/unit/test_storage/test_AbstractStorageObject.cpp @@ -0,0 +1,299 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "../mocks/mock_IStorage.h" +#include "../mocks/mock_arduino.h" + +// A test class derived from AbstractStorageObject for testing purposes + +class TestStorageObject : public AbstractStorageObject { +public: + TestStorageObject(IStorage& storageRef, const char* rootKey) + : AbstractStorageObject(storageRef, rootKey), + testValue(0), + testBool(false), + testString("") {} + + // Implement IJsonable interface + void from(JsonObject& data) override { + if (data.containsKey("testValue")) { + testValue = data["testValue"].as(); + } + if (data.containsKey("testBool")) { + testBool = data["testBool"].as(); + } + if (data.containsKey("testString")) { + testString = data["testString"].as(); + } + } + + void to(JsonObject& data) override { + data["testValue"] = testValue; + data["testBool"] = testBool; + data["testString"] = testString; + } + + // Test members + int testValue; + bool testBool; + std::string testString; +}; + +using namespace testing; + +class AbstractStorageObjectTest : public Test { +protected: + void SetUp() override { + testObj = new TestStorageObject(mockStorage, "testKey"); + } + + void TearDown() override { + delete testObj; + } + + MockStorage mockStorage; + TestStorageObject* testObj; +}; + +// Test successful save operation +TEST_F(AbstractStorageObjectTest, SaveOnStorage_Success) { + testObj->testValue = 42; + testObj->testBool = true; + testObj->testString = "hello"; + + EXPECT_CALL(mockStorage, begin(false)).Times(1); + EXPECT_CALL(mockStorage, putString(StrEq("testKey"), _)).WillOnce(DoAll(SaveArg<1>(&mockStorage.storedData), Return(10))); + EXPECT_CALL(mockStorage, end()).Times(1); + + bool result = testObj->saveOnStorage(); + ASSERT_TRUE(result); + + // Verify JSON content + StaticJsonDocument doc; + deserializeJson(doc, mockStorage.storedData); + EXPECT_EQ(doc["testValue"], 42); + EXPECT_EQ(doc["testBool"], true); + EXPECT_STREQ(doc["testString"], "hello"); +} + +// Test save with empty/default values +TEST_F(AbstractStorageObjectTest, SaveOnStorage_EmptyValues) { + EXPECT_CALL(mockStorage, begin(false)).Times(1); + EXPECT_CALL(mockStorage, putString(StrEq("testKey"), _)) + .WillOnce(Return(5)); + EXPECT_CALL(mockStorage, end()).Times(1); + + bool result = testObj->saveOnStorage(); + ASSERT_TRUE(result); +} + +// Test save failure +TEST_F(AbstractStorageObjectTest, SaveOnStorage_Failure) { + testObj->testValue = 99; + + EXPECT_CALL(mockStorage, begin(false)).Times(1); + EXPECT_CALL(mockStorage, putString(StrEq("testKey"), _)) + .WillOnce(Return(0)); + EXPECT_CALL(mockStorage, end()).Times(1); + + bool result = testObj->saveOnStorage(); + ASSERT_FALSE(result); +} + +// Test successful load operation +TEST_F(AbstractStorageObjectTest, LoadFromStorage_Success) { + std::string jsonData = R"({"testValue":99,"testBool":true,"testString":"loaded"})"; + mockStorage.storedData = jsonData; + + EXPECT_CALL(mockStorage, begin(true)).Times(1); + EXPECT_CALL(mockStorage, isKey(StrEq("testKey"))) + .WillOnce(Return(true)); + EXPECT_CALL(mockStorage, getString(StrEq("testKey"), _)) + .WillOnce(Return(mockStorage.storedData.c_str())); + EXPECT_CALL(mockStorage, end()).Times(1); + + bool result = testObj->loadFromStorage(); + ASSERT_TRUE(result); + ASSERT_EQ(testObj->testValue, 99); + ASSERT_TRUE(testObj->testBool); + ASSERT_EQ(testObj->testString, "loaded"); +} + +// Test load with missing key +TEST_F(AbstractStorageObjectTest, LoadFromStorage_KeyNotFound) { + EXPECT_CALL(mockStorage, begin(true)).Times(1); + EXPECT_CALL(mockStorage, isKey(StrEq("testKey"))) + .WillOnce(Return(false)); + EXPECT_CALL(mockStorage, end()).Times(1); + + bool result = testObj->loadFromStorage(); + ASSERT_TRUE(result); +} + +// Test load with invalid JSON +TEST_F(AbstractStorageObjectTest, LoadFromStorage_InvalidJson) { + mockStorage.storedData = "{invalid json}"; + + EXPECT_CALL(mockStorage, begin(true)).Times(1); + EXPECT_CALL(mockStorage, isKey(StrEq("testKey"))) + .WillOnce(Return(true)); + EXPECT_CALL(mockStorage, getString(StrEq("testKey"), _)) + .WillOnce(Return(mockStorage.storedData.c_str())); + EXPECT_CALL(mockStorage, end()).Times(1); + + bool result = testObj->loadFromStorage(); + ASSERT_FALSE(result); +} + +// Test load with null JSON +TEST_F(AbstractStorageObjectTest, LoadFromStorage_NullJson) { + mockStorage.storedData = "null"; + + EXPECT_CALL(mockStorage, begin(true)).Times(1); + EXPECT_CALL(mockStorage, isKey(StrEq("testKey"))) + .WillOnce(Return(true)); + EXPECT_CALL(mockStorage, getString(StrEq("testKey"), _)) + .WillOnce(Return(mockStorage.storedData.c_str())); + EXPECT_CALL(mockStorage, end()).Times(1); + + bool result = testObj->loadFromStorage(); + ASSERT_FALSE(result); +} + +// Test load with empty JSON object +TEST_F(AbstractStorageObjectTest, LoadFromStorage_EmptyJson) { + mockStorage.storedData = "{}"; + + EXPECT_CALL(mockStorage, begin(true)).Times(1); + EXPECT_CALL(mockStorage, isKey(StrEq("testKey"))) + .WillOnce(Return(true)); + EXPECT_CALL(mockStorage, getString(StrEq("testKey"), _)) + .WillOnce(Return(mockStorage.storedData.c_str())); + EXPECT_CALL(mockStorage, end()).Times(1); + + bool result = testObj->loadFromStorage(); + ASSERT_TRUE(result); + // Values should be defaults + EXPECT_EQ(testObj->testValue, 0); + EXPECT_FALSE(testObj->testBool); +} + +// Test erase with existing key +TEST_F(AbstractStorageObjectTest, EraseStorage_KeyExists) { + EXPECT_CALL(mockStorage, begin(false)).Times(1); + EXPECT_CALL(mockStorage, isKey(StrEq("testKey"))) + .WillOnce(Return(true)); + EXPECT_CALL(mockStorage, remove(StrEq("testKey"))) + .WillOnce(Return(1)); + EXPECT_CALL(mockStorage, end()).Times(1); + + bool result = testObj->eraseStorage(); + ASSERT_TRUE(result); +} + +// Test erase with non-existing key +TEST_F(AbstractStorageObjectTest, EraseStorage_KeyNotFound) { + EXPECT_CALL(mockStorage, begin(false)).Times(1); + EXPECT_CALL(mockStorage, isKey(StrEq("testKey"))) + .WillOnce(Return(false)); + EXPECT_CALL(mockStorage, end()).Times(1); + + bool result = testObj->eraseStorage(); + ASSERT_FALSE(result); +} + +// Test full save-load cycle +TEST_F(AbstractStorageObjectTest, SaveLoadCycle) { + testObj->testValue = 123; + testObj->testBool = true; + testObj->testString = "cycle_test"; + + // Save + EXPECT_CALL(mockStorage, begin(false)).Times(1); + EXPECT_CALL(mockStorage, putString(StrEq("testKey"), _)) + .WillOnce(DoAll( + SaveArg<1>(&mockStorage.storedData), + Return(10))); + EXPECT_CALL(mockStorage, end()).Times(1); + + bool saveResult = testObj->saveOnStorage(); + ASSERT_TRUE(saveResult); + + // Reset values + testObj->testValue = 0; + testObj->testBool = false; + testObj->testString = ""; + + // Load + EXPECT_CALL(mockStorage, begin(true)).Times(1); + EXPECT_CALL(mockStorage, isKey(StrEq("testKey"))) + .WillOnce(Return(true)); + EXPECT_CALL(mockStorage, getString(StrEq("testKey"), _)) + .WillOnce(Return(mockStorage.storedData.c_str())); + EXPECT_CALL(mockStorage, end()).Times(1); + + bool loadResult = testObj->loadFromStorage(); + ASSERT_TRUE(loadResult); + EXPECT_EQ(testObj->testValue, 123); + EXPECT_TRUE(testObj->testBool); + EXPECT_EQ(testObj->testString, "cycle_test"); +} + +// Test memory constraints (ESP32 specific) +TEST_F(AbstractStorageObjectTest, MemoryConstraints_LargeString) { + // Test with string near ESP32 typical limits + testObj->testString = std::string(200, 'x'); + testObj->testValue = 999; + + EXPECT_CALL(mockStorage, begin(false)).Times(1); + EXPECT_CALL(mockStorage, putString(StrEq("testKey"), _)) + .WillOnce(DoAll( + SaveArg<1>(&mockStorage.storedData), + Return(10))); + EXPECT_CALL(mockStorage, end()).Times(1); + + bool result = testObj->saveOnStorage(); + ASSERT_TRUE(result); + + // Verify serialization succeeded + StaticJsonDocument<512> doc; + auto error = deserializeJson(doc, mockStorage.storedData); + EXPECT_FALSE(error); +} + +// Test special characters in JSON +TEST_F(AbstractStorageObjectTest, SpecialCharacters) { + testObj->testString = "Test\"Quote\\Backslash\nNewline"; + testObj->testValue = 42; + + EXPECT_CALL(mockStorage, begin(false)).Times(1); + EXPECT_CALL(mockStorage, putString(StrEq("testKey"), _)) + .WillOnce(DoAll( + SaveArg<1>(&mockStorage.storedData), + Return(10))); + EXPECT_CALL(mockStorage, end()).Times(1); + + bool saveResult = testObj->saveOnStorage(); + ASSERT_TRUE(saveResult); + + // Load back + EXPECT_CALL(mockStorage, begin(true)).Times(1); + EXPECT_CALL(mockStorage, isKey(StrEq("testKey"))) + .WillOnce(Return(true)); + EXPECT_CALL(mockStorage, getString(StrEq("testKey"), _)) + .WillOnce(Return(mockStorage.storedData.c_str())); + EXPECT_CALL(mockStorage, end()).Times(1); + + testObj->testString = ""; + bool loadResult = testObj->loadFromStorage(); + ASSERT_TRUE(loadResult); + EXPECT_EQ(testObj->testString, "Test\"Quote\\Backslash\nNewline"); +} diff --git a/test/test_runner.cpp b/test/test_runner.cpp new file mode 100644 index 0000000000..4b1a47c2af --- /dev/null +++ b/test/test_runner.cpp @@ -0,0 +1,53 @@ +#include +// uncomment line below if you plan to use GMock +// #include + +// TEST(...) +// TEST_F(...) + +TEST(TestRunner, CheckItWorks) { + ASSERT_TRUE(true); + ASSERT_FALSE(false); + ASSERT_EQ(1, 1); + ASSERT_NE(1, 2); + ASSERT_LT(1, 2); + ASSERT_LE(1, 1); + ASSERT_GT(2, 1); + ASSERT_GE(1, 1); +} + +#if defined(ARDUINO) +# include + +void setup() { + // should be the same value as for the `test_speed` option in "platformio.ini" + // default value is test_speed=115200 + Serial.begin(115200); + + ::testing::InitGoogleTest(); + // if you plan to use GMock, replace the line above with + // ::testing::InitGoogleMock(); +} + +void loop() { + // Run tests + if (RUN_ALL_TESTS()) + ; + + // sleep for 1 sec + delay(1000); +} + +#else +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + // if you plan to use GMock, replace the line above with + // ::testing::InitGoogleMock(&argc, argv); + + if (RUN_ALL_TESTS()) + ; + + // Always return zero-code and allow PlatformIO to parse results + return 0; +} +#endif \ No newline at end of file