diff --git a/src/esp_sensors/config.py b/src/esp_sensors/config.py index 7404e8d..3d2105d 100644 --- a/src/esp_sensors/config.py +++ b/src/esp_sensors/config.py @@ -302,6 +302,10 @@ def check_and_update_config_from_mqtt( """ Check for configuration updates from MQTT and update local configuration if needed. + This function uses a two-step process: + 1. Check the version topic to see if a new version is available + 2. If a new version is detected, fetch the full configuration from the data topic + Args: mqtt_client: MQTT client instance mqtt_config: MQTT configuration dictionary @@ -315,28 +319,22 @@ def check_and_update_config_from_mqtt( return current_config try: - # Get the configuration topic - topic_config = mqtt_config.get("topic_config") - if not topic_config: - print("No configuration topic specified") + # Get the version and data topics + topic_config_version = mqtt_config.get("topic_config_version") + topic_config_data = mqtt_config.get("topic_config_data") + + if not topic_config_version or not topic_config_data: + print("Configuration version or data topic not specified") return current_config - # Subscribe to the configuration topic - print(f"Subscribing to configuration topic: {topic_config}") + # This function is now implemented in the mqtt.py module + # We'll import and use that implementation + from .mqtt import check_config_update - # This is a simplified implementation - in a real implementation, we would - # set up a callback to handle the message and wait for it to be received - # For now, we'll just return the current configuration + # Use the implementation from mqtt.py to check for updates + updated_config = check_config_update(mqtt_client, mqtt_config, current_config) - # In a real implementation, we would: - # 1. Subscribe to the topic - # 2. Wait for a message (with timeout) - # 3. Parse the message as JSON - # 4. Check if the version is newer than the current version - # 5. If it is, update the local configuration and save it - - print("MQTT configuration update check not implemented yet") - return current_config + return updated_config except Exception as e: print(f"Error checking for configuration updates: {e}") return current_config diff --git a/src/esp_sensors/mqtt.py b/src/esp_sensors/mqtt.py index 4901037..69bf418 100644 --- a/src/esp_sensors/mqtt.py +++ b/src/esp_sensors/mqtt.py @@ -354,7 +354,7 @@ def subscribe_to_config( client: ESP32MQTTClient | MQTTClient | None, mqtt_config: dict ) -> bool: """ - Subscribe to the configuration topic. + Subscribe to the configuration version topic. Args: client: ESP32MQTTClient or MQTTClient instance @@ -368,18 +368,18 @@ def subscribe_to_config( return False try: - topic_config = mqtt_config.get("topic_config") - if not topic_config: - print("No configuration topic specified") + topic_config_version = mqtt_config.get("topic_config_version") + if not topic_config_version: + print("No configuration version topic specified") return False - print(f"Subscribing to configuration topic: {topic_config}") + print(f"Subscribing to configuration version topic: {topic_config_version}") # Both client types have compatible subscribe methods - client.subscribe(topic_config.encode()) + client.subscribe(topic_config_version.encode()) return True except Exception as e: - print(f"Failed to subscribe to configuration topic: {e}") + print(f"Failed to subscribe to configuration version topic: {e}") return False @@ -465,6 +465,9 @@ def check_config_update( """ Check for configuration updates from MQTT. + First checks the version topic to see if a new version is available. + If a new version is detected, fetches the full configuration from the data topic. + Args: client: ESP32MQTTClient or MQTTClient instance mqtt_config: MQTT configuration dictionary @@ -477,19 +480,49 @@ def check_config_update( return current_config try: - # Variable to store the received configuration + # Get the version and data topics + topic_config_version = mqtt_config.get("topic_config_version") + topic_config_data = mqtt_config.get("topic_config_data") + + if not topic_config_version or not topic_config_data: + print("Configuration version or data topic not specified") + return current_config + + # Variable to store the received version and configuration + received_version = None received_config = None + wait_time = mqtt_config.get("config_wait_time", 1.0) - if isinstance(client, ESP32MQTTClient): - print("Using ESP32MQTTClient to check for configuration updates") + # Step 1: Check the version topic for updates + print( + f"Reading from version topic: {topic_config_version} with wait time: {wait_time}s" + ) + version_msg = client.read_topic(topic_config_version, wait_time) - topic_config = mqtt_config.get("topic_config") - wait_time = mqtt_config.get("config_wait_time", 5.0) + if version_msg: + try: + msg_str = ( + version_msg.decode("utf-8") + if isinstance(version_msg, bytes) + else version_msg + ) + received_version = int(msg_str.strip()) + print(f"Received version: {received_version}") + except Exception as e: + print(f"Error parsing version message: {e}") + + # Step 2: If we received a version and it's newer, fetch the full config from the data topic + current_version = current_config.get("version", 0) + + if received_version is not None and received_version > current_version: + print( + f"Found newer version ({received_version} > {current_version}), fetching full configuration" + ) print( - f"Using ESP32MQTTClient to read from config topic with wait time: {wait_time}s" + f"Reading from data topic: {topic_config_data} with wait time: {wait_time}s" ) - config_msg = client.read_topic(topic_config, wait_time) + config_msg = client.read_topic(topic_config_data, wait_time) if config_msg: try: @@ -501,60 +534,16 @@ def check_config_update( received_config = json.loads(msg_str) except Exception as e: print(f"Error parsing configuration message: {e}") - else: - # Define callback function to handle incoming messages - def config_callback(topic, msg): - nonlocal received_config - try: - # Verify that the topic matches our expected topic - expected_topic = mqtt_config.get("topic_config") - topic_str = ( - topic.decode("utf-8") if isinstance(topic, bytes) else topic - ) - if topic_str != expected_topic: - print( - f"Ignoring message from topic {topic_str} - not matching our config topic {expected_topic}" - ) - return - - # Parse the message as JSON - msg_str = msg.decode("utf-8") if isinstance(msg, bytes) else msg - config_data = json.loads(msg_str) - print( - f"Received configuration from MQTT: version {config_data.get('version', 0)}" - ) - received_config = config_data - except Exception as e: - print(f"Error parsing configuration message: {e}") - - # Set the callback - client.set_callback(config_callback) - - # Subscribe to the configuration topic - if not subscribe_to_config(client, mqtt_config): - print("Failed to subscribe to configuration topic") - return current_config - - # Check for retained messages (will be processed by the callback) - print("Checking for retained configuration messages...") - client.check_msg() - - # For basic MQTTClient, use the original approach - print("Waiting for configuration updates...") - # Wait a short time for any retained messages to be processed - time.sleep(0.5) - client.check_msg() - print("done waiting for configuration updates") - - # If we received a configuration and its version is newer, return it - if received_config and received_config.get("version", 0) > current_config.get( - "version", 0 - ): - print( - f"Found newer configuration (version {received_config.get('version')})" - ) - return received_config + # If we received a configuration and its version matches what we expected, return it + if ( + received_config + and received_config.get("version", 0) == received_version + ): + print(f"Successfully fetched configuration version {received_version}") + return received_config + else: + print("Failed to fetch the full configuration or version mismatch") return current_config except Exception as e: diff --git a/tests/test_config_update.py b/tests/test_config_update.py new file mode 100644 index 0000000..f911373 --- /dev/null +++ b/tests/test_config_update.py @@ -0,0 +1,166 @@ +""" +Tests for the configuration update functionality. +""" + +import json +from unittest.mock import patch, MagicMock + +import pytest + +from src.esp_sensors.mqtt import check_config_update, ESP32MQTTClient + + +@pytest.fixture +def mqtt_config(): + """Fixture providing a sample MQTT configuration with version and data topics.""" + return { + "enabled": True, + "broker": "test.mosquitto.org", + "port": 1883, + "client_id": "test_client", + "username": "test_user", + "password": "test_pass", + "load_config_from_mqtt": True, + "topic_config_version": "test/config/version", + "topic_config_data": "test/config/data", + "topic_data_prefix": "test/sensors", + "publish_interval": 30, + "ssl": False, + "keepalive": 60, + "use_esp32_client": True, + } + + +@pytest.fixture +def current_config(): + """Fixture providing a sample current configuration.""" + return { + "device_id": "test_device", + "device_name": "Test Device", + "version": 5, + "sensors": { + "dht22": { + "id": "test-dht22", + "name": "Test Sensor", + "pin": 16, + "interval": 60, + } + }, + } + + +@pytest.fixture +def new_config(): + """Fixture providing a sample new configuration.""" + return { + "device_id": "test_device", + "device_name": "Test Device Updated", + "version": 6, + "sensors": { + "dht22": { + "id": "test-dht22", + "name": "Test Sensor Updated", + "pin": 16, + "interval": 30, + } + }, + } + + +def test_check_config_update_no_client(mqtt_config, current_config): + """Test that check_config_update returns the current config when client is None.""" + result = check_config_update(None, mqtt_config, current_config) + assert result == current_config + + +def test_check_config_update_disabled(mqtt_config, current_config): + """Test that check_config_update returns the current config when load_config_from_mqtt is False.""" + mqtt_config["load_config_from_mqtt"] = False + mock_client = MagicMock() + result = check_config_update(mock_client, mqtt_config, current_config) + assert result == current_config + + +def test_check_config_update_esp32_newer_version( + mqtt_config, current_config, new_config +): + """Test that check_config_update returns the new config when a newer version is available (ESP32MQTTClient).""" + # Create a mock ESP32MQTTClient + mock_client = MagicMock(spec=ESP32MQTTClient) + + # Configure the mock to return a version and then the new config + mock_client.read_topic.side_effect = [ + str(new_config["version"]), # First call returns the version + json.dumps(new_config), # Second call returns the config data + ] + + # Call the function + result = check_config_update(mock_client, mqtt_config, current_config) + + # Verify the result + assert result == new_config + + # Verify read_topic was called for both topics + assert mock_client.read_topic.call_count == 2 + mock_client.read_topic.assert_any_call(mqtt_config["topic_config_version"], 5.0) + mock_client.read_topic.assert_any_call(mqtt_config["topic_config_data"], 5.0) + + +def test_check_config_update_esp32_same_version(mqtt_config, current_config): + """Test that check_config_update returns the current config when the version is the same (ESP32MQTTClient).""" + # Create a mock ESP32MQTTClient + mock_client = MagicMock(spec=ESP32MQTTClient) + + # Configure the mock to return the same version + mock_client.read_topic.return_value = str(current_config["version"]) + + # Call the function + result = check_config_update(mock_client, mqtt_config, current_config) + + # Verify the result + assert result == current_config + + # Verify read_topic was called only for the version topic + mock_client.read_topic.assert_called_once_with( + mqtt_config["topic_config_version"], 5.0 + ) + + +def test_check_config_update_esp32_older_version(mqtt_config, current_config): + """Test that check_config_update returns the current config when the version is older (ESP32MQTTClient).""" + # Create a mock ESP32MQTTClient + mock_client = MagicMock(spec=ESP32MQTTClient) + + # Configure the mock to return an older version + mock_client.read_topic.return_value = str(current_config["version"] - 1) + + # Call the function + result = check_config_update(mock_client, mqtt_config, current_config) + + # Verify the result + assert result == current_config + + # Verify read_topic was called only for the version topic + mock_client.read_topic.assert_called_once_with( + mqtt_config["topic_config_version"], 5.0 + ) + + +def test_check_config_update_esp32_no_version(mqtt_config, current_config): + """Test that check_config_update returns the current config when no version is available (ESP32MQTTClient).""" + # Create a mock ESP32MQTTClient + mock_client = MagicMock(spec=ESP32MQTTClient) + + # Configure the mock to return None for the version + mock_client.read_topic.return_value = None + + # Call the function + result = check_config_update(mock_client, mqtt_config, current_config) + + # Verify the result + assert result == current_config + + # Verify read_topic was called only for the version topic + mock_client.read_topic.assert_called_once_with( + mqtt_config["topic_config_version"], 5.0 + )