diff --git a/src/ax_devil_mqtt/core/manager.py b/src/ax_devil_mqtt/core/manager.py index a34c074..616e679 100644 --- a/src/ax_devil_mqtt/core/manager.py +++ b/src/ax_devil_mqtt/core/manager.py @@ -200,7 +200,9 @@ def __init__( If create_publisher is False, provide a topic to subscribe to an existing publisher. You can also inject an existing RawMqttClient or TemporaryAnalyticsMQTTPublisher for testing. """ - topic_suffix = hashlib.sha256(analytics_data_source_key.encode()).hexdigest()[:8] + device_host = self._resolve_device_host(device_config, publisher) + hash_input = f"{analytics_data_source_key}:{device_host}" + topic_suffix = hashlib.sha256(hash_input.encode()).hexdigest()[:8] self.topic: str = topic or f"ax-devil/temp/{topic_suffix}" self._publisher: Optional[TemporaryAnalyticsMQTTPublisher] = None self._client: RawMqttClient @@ -231,6 +233,22 @@ def __init__( broker_password=broker_password, ) + @staticmethod + def _resolve_device_host( + device_config: Optional[DeviceConfig], + publisher: Optional[TemporaryAnalyticsMQTTPublisher], + ) -> str: + """Resolve device host used in topic hashing.""" + if device_config and getattr(device_config, "host", ""): + return str(device_config.host) + + if publisher and getattr(publisher, "client", None): + publisher_device_config = getattr(publisher.client, "device_config", None) + if publisher_device_config and getattr(publisher_device_config, "host", ""): + return str(publisher_device_config.host) + + return "" + def start(self) -> None: """Start listening for analytics messages.""" self._client.start() diff --git a/tests/test_message_processing.py b/tests/test_message_processing.py index 9269709..d263cc1 100644 --- a/tests/test_message_processing.py +++ b/tests/test_message_processing.py @@ -55,13 +55,20 @@ def username_pw_set(self, username, password=None): class DummyAnalyticsPublisher: - def __init__(self): + def __init__(self, host: str | None = None): self.cleaned = False + if host: + self.client = type("PublisherClient", (), {"device_config": DummyDeviceConfig(host=host)})() def cleanup(self): self.cleaned = True +class DummyDeviceConfig: + def __init__(self, host: str): + self.host = host + + def test_mqtt_client_dispatch_basic(): processed_messages = [] @@ -194,3 +201,68 @@ def stop(self_inner): assert started["value"] is True assert started["stopped"] is True assert dummy_publisher.cleaned is True + + +def test_analytics_topic_hash_changes_with_device_ip(): + client_a = AxisAnalyticsMqttClient( + broker_host="broker", + broker_port=1883, + device_config=DummyDeviceConfig(host="192.168.0.10"), + analytics_data_source_key="stream-key", + message_callback=lambda _: None, + create_publisher=False, + ) + client_b = AxisAnalyticsMqttClient( + broker_host="broker", + broker_port=1883, + device_config=DummyDeviceConfig(host="192.168.0.11"), + analytics_data_source_key="stream-key", + message_callback=lambda _: None, + create_publisher=False, + ) + + assert client_a.topic != client_b.topic + + +def test_analytics_topic_hash_is_stable_for_same_stream_and_device_ip(): + client_a = AxisAnalyticsMqttClient( + broker_host="broker", + broker_port=1883, + device_config=DummyDeviceConfig(host="192.168.0.10"), + analytics_data_source_key="stream-key", + message_callback=lambda _: None, + create_publisher=False, + ) + client_b = AxisAnalyticsMqttClient( + broker_host="broker", + broker_port=1883, + device_config=DummyDeviceConfig(host="192.168.0.10"), + analytics_data_source_key="stream-key", + message_callback=lambda _: None, + create_publisher=False, + ) + + assert client_a.topic == client_b.topic + + +def test_analytics_topic_hash_uses_publisher_device_ip_when_device_config_missing(): + client_a = AxisAnalyticsMqttClient( + broker_host="broker", + broker_port=1883, + device_config=None, + analytics_data_source_key="stream-key", + message_callback=lambda _: None, + create_publisher=False, + publisher=DummyAnalyticsPublisher(host="192.168.0.20"), + ) + client_b = AxisAnalyticsMqttClient( + broker_host="broker", + broker_port=1883, + device_config=None, + analytics_data_source_key="stream-key", + message_callback=lambda _: None, + create_publisher=False, + publisher=DummyAnalyticsPublisher(host="192.168.0.21"), + ) + + assert client_a.topic != client_b.topic