diff --git a/bambulabs_api/mqtt_client.py b/bambulabs_api/mqtt_client.py index d2b06bf..ec22a64 100644 --- a/bambulabs_api/mqtt_client.py +++ b/bambulabs_api/mqtt_client.py @@ -57,6 +57,25 @@ def is_valid_gcode(line: str): return True +def _deep_merge(target: dict[str, Any], updates: dict[str, Any]) -> None: + """ + Recursively merge updates into target, in place + + Nested dictionaries are merged key by key, so an incremental report does + not drop sibling fields it did not mention. Lists and scalars are replaced. + + Args: + target (dict[str, Any]): The cached data, modified in place + updates (dict[str, Any]): The newly received data + """ + for key, value in updates.items(): + existing = target.get(key) + if isinstance(existing, dict) and isinstance(value, dict): + _deep_merge(existing, value) + else: + target[key] = value + + class PrinterMQTTClient: """ Printer class for handling MQTT communication with the printer @@ -195,10 +214,7 @@ def _on_message( self.on_message_handler(self, client, userdata, msg) def manual_update(self, doc: dict[str, Any]) -> None: - for k, v in doc.items(): - if k not in self._data: - self._data[k] = {} - self._data[k] |= v + _deep_merge(self._data, doc) logger.debug(self._data) firmware_version = self.firmware_version() diff --git a/tests/test_mqtt_client.py b/tests/test_mqtt_client.py index 295f637..db4889f 100644 --- a/tests/test_mqtt_client.py +++ b/tests/test_mqtt_client.py @@ -64,3 +64,45 @@ def test_nozzle_diameter(): def test_get_firmware(): assert mqtt.firmware_version() == "01.07.00.00" assert mqtt.printer_info.firmware_version == "01.07.00.00" + + +def test_incremental_update_preserves_sibling_fields(): + """A partial report must not wipe fields it does not mention.""" + mqtt_ = bl.PrinterMQTTClient(hostname="", access="", printer_serial="") + mqtt_.manual_update( + { + "print": { + "gcode_state": "RUNNING", + "ams": { + "ams_exist_bits": "1", + "tray_exist_bits": "f", + "ams": [{"id": "0", "tray": [{"id": "0"}]}], + }, + }, + } + ) + # An incremental report mentioning only part of the ams object + mqtt_.manual_update( + { + "print": { + "ams": { + "ams": [{"id": "0", "tray": [{"id": "0", "tray_color": "FF0000FF"}]}], + }, + }, + } + ) + + ams = mqtt_._data["print"]["ams"] # noqa: SLF001 + assert ams["ams_exist_bits"] == "1", "sibling field dropped by partial update" + assert ams["tray_exist_bits"] == "f", "sibling field dropped by partial update" + assert ams["ams"][0]["tray"][0]["tray_color"] == "FF0000FF", "update not applied" + assert mqtt_._data["print"]["gcode_state"] == "RUNNING" # noqa: SLF001 + + +def test_update_replaces_scalars_and_lists(): + """Lists and scalars are replaced, not merged.""" + mqtt_ = bl.PrinterMQTTClient(hostname="", access="", printer_serial="") + mqtt_.manual_update({"print": {"gcode_state": "RUNNING", "s_obj": [1, 2]}}) + mqtt_.manual_update({"print": {"gcode_state": "FINISH", "s_obj": [9]}}) + assert mqtt_._data["print"]["gcode_state"] == "FINISH" # noqa: SLF001 + assert mqtt_._data["print"]["s_obj"] == [9] # noqa: SLF001