Parcourir la source

feat: add unittests for http, mqtt, measure_ctrl

add playgrounds
Silas Gruen il y a 8 mois
Parent
commit
d5dfc70ada

+ 208 - 0
playgrounds/estimation_playground.py

@@ -0,0 +1,208 @@
+import numpy as np
+import matplotlib.pyplot as plt
+
+# -------------------------------
+# Define the battery model functions
+# -------------------------------
+
+def ocv_model(soc, temp):
+    """
+    Open-circuit voltage model as a function of SOC and temperature.
+    For demonstration, we use a simple linear relationship.
+    In a real application, this should be replaced with an empirically derived model.
+    
+    Parameters:
+      soc: state-of-charge (0 to 1)
+      temp: temperature (°C) [not used in this simple model]
+    Returns:
+      OCV (V)
+    """
+    # Example: voltage ranges from 3.0V to 4.2V
+    return 3.0 + 1.2 * soc
+
+def battery_model(x, current, dt):
+    """
+    State propagation model.
+    State vector x = [SOC, capacity]
+      - SOC: state-of-charge (0 to 1)
+      - capacity: battery capacity in Ah
+    The SOC decreases with discharge current.
+    
+    Parameters:
+      x: state vector [soc, capacity]
+      current: battery current (A) (positive for discharge)
+      dt: time interval (seconds)
+    Returns:
+      Updated state vector.
+    """
+    soc, capacity = x
+    # Note: capacity is in Ah and dt is in seconds, so convert capacity to Coulombs (Ah * 3600)
+    soc_new = soc - (current * dt) / (capacity * 3600)
+    # For simplicity, assume capacity changes very slowly; we let it remain nearly constant.
+    return np.array([soc_new, capacity])
+
+def jacobian_f(x, current, dt):
+    """
+    Computes the Jacobian of the battery_model function with respect to state x.
+    """
+    soc, capacity = x
+    # f1 = soc - (current*dt)/(capacity*3600)
+    df1_dsoc = 1.0
+    df1_dcap = (current * dt) / (capacity**2 * 3600)
+    # f2 = capacity (assumed constant), so its derivatives are:
+    df2_dsoc = 0.0
+    df2_dcap = 1.0
+    return np.array([[df1_dsoc, df1_dcap],
+                     [df2_dsoc, df2_dcap]])
+
+def measurement_model(x, current, temp, R=0.05):
+    """
+    Measurement model: the terminal voltage is given by the open-circuit voltage
+    minus the voltage drop across the internal resistance.
+    
+    Parameters:
+      x: state vector [soc, capacity]
+      current: battery current (A)
+      temp: temperature (°C)
+      R: internal resistance (Ohm)
+    Returns:
+      Terminal voltage (V)
+    """
+    soc, _ = x
+    return ocv_model(soc, temp) - current * R
+
+def jacobian_h(x, current, temp, R=0.05):
+    """
+    Computes the Jacobian of the measurement_model with respect to the state x.
+    Here we assume the OCV model derivative with respect to SOC is constant (1.2 V per unit SOC).
+    """
+    # dV/dsoc: derivative of ocv_model with respect to soc (in our simple model, it's constant)
+    dV_dsoc = 1.2
+    dV_dcap = 0.0  # Voltage is not directly affected by capacity in this simple model.
+    return np.array([dV_dsoc, dV_dcap]).reshape(1, -1)
+
+# -------------------------------
+# Extended Kalman Filter functions
+# ------------------------------- 
+
+def ekf_predict(x, P, current, dt, Q):
+    """
+    EKF prediction step.
+    
+    Parameters:
+      x: current state estimate
+      P: current state covariance matrix
+      current: current battery current (A)
+      dt: time step (s)
+      Q: process noise covariance matrix
+    Returns:
+      x_pred: predicted state
+      P_pred: predicted covariance
+    """
+    x_pred = battery_model(x, current, dt)
+    F = jacobian_f(x, current, dt)
+    P_pred = F @ P @ F.T + Q
+    return x_pred, P_pred
+
+def ekf_update(x_pred, P_pred, z, current, temp, R_internal=0.05, R_measure=0.01):
+    """
+    EKF update step.
+    
+    Parameters:
+      x_pred: predicted state
+      P_pred: predicted covariance matrix
+      z: measured voltage (V)
+      current: battery current (A)
+      temp: temperature (°C)
+      R_internal: internal resistance used in the measurement model
+      R_measure: measurement noise variance
+    Returns:
+      x_updated: updated state estimate
+      P_updated: updated covariance matrix
+    """
+    H = jacobian_h(x_pred, current, temp, R_internal)
+    z_pred = measurement_model(x_pred, current, temp, R_internal)
+    y = z - z_pred  # innovation or measurement residual
+    S = H @ P_pred @ H.T + R_measure  # innovation covariance
+    K = P_pred @ H.T @ np.linalg.inv(S)  # Kalman gain
+    x_updated = x_pred + (K * y).flatten()  # update state (flatten since K*y is a vector)
+    P_updated = (np.eye(len(x_pred)) - K @ H) @ P_pred
+    return x_updated, P_updated
+
+# -------------------------------
+# Simulation / Filtering Example
+# -------------------------------
+
+# Simulation parameters  
+dt = 0.25  # time step in seconds
+num_steps = 100  # number of simulation steps
+
+# Initialize state:
+# Assume starting at 100% SOC and a capacity of 3 Ah (e.g., 3000 mAh)
+x_est = np.array([1.0, 3.0])
+# Initial state covariance
+P_est = np.diag([0.01, 0.01])
+# Process noise covariance matrix
+Q = np.diag([1e-5, 1e-6])
+# Measurement noise variance (for voltage measurement)
+R_measure = 0.01
+# Internal resistance (Ohm)
+R_internal = 0.05
+
+# Generate dummy measurement data
+# For example, assume a constant discharge current of 0.5 A and constant temperature (25°C)
+current_array = np.full(num_steps, 0.5)
+temp_array = np.full(num_steps, 25)
+voltage_meas_array = np.zeros(num_steps)
+voltage_true_array = np.zeros(num_steps)
+
+# For simulation purposes, we generate a "true" state trajectory.
+x_true = np.array([1.0, 3.0])  # initial true state
+
+# Lists to store estimates for plotting
+soc_estimates = []
+cap_estimates = []
+
+for k in range(num_steps):
+    # --- Simulation: generate true state and corresponding measurement ---
+    x_true = battery_model(x_true, current_array[k], dt)
+    voltage_true = measurement_model(x_true, current_array[k], temp_array[k], R_internal)
+    # Simulate measurement noise
+    noise = np.random.normal(0, np.sqrt(R_measure))
+    voltage_meas = voltage_true + noise
+    voltage_true_array[k] = voltage_true 
+    voltage_meas_array[k] = voltage_meas
+
+    # --- EKF Predict Step ---
+    x_pred, P_pred = ekf_predict(x_est, P_est, current_array[k], dt, Q)
+
+    # --- EKF Update Step ---
+    x_est, P_est = ekf_update(x_pred, P_pred, voltage_meas, current_array[k], temp_array[k],
+                              R_internal, R_measure)
+
+    soc_estimates.append(x_est[0])
+    cap_estimates.append(x_est[1])
+    
+    # Optional: print the step results
+    print(f"Step {k:03d}: Measured Voltage = {voltage_meas:.3f} V, Estimated SOC = {x_est[0]:.4f}, Capacity = {x_est[1]:.4f} Ah")
+
+# -------------------------------
+# Plotting results for visualization
+# -------------------------------
+plt.figure(figsize=(12, 5))
+plt.subplot(1, 2, 1)
+plt.plot(soc_estimates, label='Estimated SOC')
+plt.xlabel('Time Step')
+plt.ylabel('State of Charge')
+plt.title('SOC Estimation')
+plt.legend()
+
+plt.subplot(1, 2, 2)
+plt.plot(cap_estimates, label='Estimated Capacity (Ah)')
+plt.xlabel('Time Step')
+plt.ylabel('Capacity (Ah)')
+plt.title('Capacity (SOH) Estimation')
+plt.legend()
+
+plt.tight_layout()
+plt.show()

+ 47 - 0
playgrounds/i2c_playground.py

@@ -0,0 +1,47 @@
+import smbus2
+
+def main():
+    # Initialize configuration
+    config = {
+        'i2c': {
+            'bus': 1,
+            'debug': False
+        }
+    }
+    
+    # Create I2C service instance
+    i2c_service = I2CService(config)
+    
+    # Test parameters
+    i2c_address = 0x48
+    num_slots = 1
+    
+    try:
+        # Request status list from the device
+        status_list = i2c_service.request_status_list(i2c_address, num_slots)
+        print(f"Status list received: {status_list}")
+        
+    except Exception as e:
+        print(f"Error occurred: {str(e)}")
+
+
+class I2CService:
+    status_register = 0x01
+    cell_data_register = 0x02
+    battery_limit_register = 0x03
+
+    def __init__(self, config: dict):
+        self.config = config
+        self.bus = None
+        bus_number = config.get('i2c', {}).get('bus', 1)
+        self.bus = smbus2.SMBus(bus_number)
+
+    def request_status_list(self, i2c_adress: int, num_slots: int) -> bool:
+        """Request the status of a all slots."""
+        print(f"Requesting status list (i2c_adress: {i2c_adress}, register: {self.status_register})")
+        status_list = self.bus.read_i2c_block_data(i2c_adress, self.status_register, 1)
+        print(f"Received status list: {status_list} (i2c_adress: {i2c_adress})")
+        return True
+
+if __name__ == "__main__":
+    main()

+ 6 - 0
pytest.ini

@@ -0,0 +1,6 @@
+[pytest]
+asyncio_mode = auto
+log_cli = true
+log_cli_level = ERROR
+log_cli_format = %(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)
+log_cli_date_format = %Y-%m-%d %H:%M:%S

+ 4 - 2
src/controllers/measurement_controller.py

@@ -46,12 +46,14 @@ class MeasurementController:
                     try:
                         # Read slot status via I2C
                         new_status_list = self.i2c_service.request_status_list(device.i2c_address, len(device.slots))
-                        if len(new_status_list) != len(device.slots):
-                            raise IndexError(f"Invalid status list length: {len(device.status_list)} != {len(device.slots)}")
                     except Exception as e:
                         logger.error(f"Error during polling device: {device.id}:\n{str(e)}")
                         continue
                     
+                    if len(new_status_list) != len(device.slots):
+                        logger.error(f"Invalid status list length: {len(new_status_list)} != {len(device.slots)}")
+                        continue
+                    
                     # Change the (change of) status for each slot and act accordingly
                     for idx, status in enumerate(new_status_list):
                         try:

+ 2 - 1
src/services/i2c_service.py

@@ -32,7 +32,8 @@ class I2CService:
         """Request the cell values of a specific slot."""
         if self.debug:
             return MeasureValues(4.2, 3.6, 1.5)
-        measure_values:MeasureValues = self.bus.read_i2c_block_data(i2c_adress, self.cell_data_register, 3, slot_id) # TODO [SG]: How do i specify the slot?
+        response = self.bus.read_i2c_block_data(i2c_adress, self.cell_data_register, 3, slot_id) # TODO [SG]: How do i specify the slot?
+        measure_values = MeasureValues(*response)
         logger.debug(f"Received measure values: {measure_values} (i2c_adress: {i2c_adress}, slot_id: {slot_id})")
         return measure_values
     

+ 101 - 0
tests/test_http_service.py

@@ -0,0 +1,101 @@
+import pytest
+from unittest.mock import patch, MagicMock
+from src.services.http_service import HTTPService, DEBUG_DATA
+
+@pytest.fixture
+def http_service():
+    config = {
+        'http': {
+            'server_url': 'http://api.example.com',
+            'endpoint': 'cells',
+            'username': 'testuser',
+            'password': 'testpass',
+            'debug': False
+        }
+    }
+    return HTTPService(config)
+
+@pytest.fixture
+def mock_response():
+    mock = MagicMock()
+    mock.status_code = 200
+    mock.json.return_value = {
+        "id": 1234,
+        "cell_type": {
+            "id": 16,
+            "name": "INR18650-35E",
+            "manufacturer": "Samsung",
+            "capacity": 3450,
+            "nominal_voltage": 3.7,
+            "max_voltage": 4.2,
+            "min_voltage": 2.5
+        }
+    }
+    return mock
+
+def test_successful_fetch(http_service, mock_response):
+    # Arrange
+    with patch('requests.get', return_value=mock_response) as mock_get:
+        # Act
+        result = http_service.fetch_cell_info(1234)
+
+        # Assert
+        mock_get.assert_called_once_with(
+            'http://api.example.com/cells/1234/',
+            auth=('testuser', 'testpass'),
+            headers={"Accept": "application/json"}
+        )
+        assert result['id'] == 1234
+        assert result['cell_type']['name'] == 'INR18650-35E'
+
+def test_failed_fetch(http_service):
+    # Arrange
+    mock_error_response = MagicMock()
+    mock_error_response.status_code = 404
+
+    with patch('requests.get', return_value=mock_error_response) as mock_get:
+        # Act & Assert
+        with pytest.raises(ConnectionError) as exc_info:
+            http_service.fetch_cell_info(9999)
+        
+        assert "could not be retreived: 404" in str(exc_info.value)
+
+def test_debug_mode():
+    # Arrange
+    config = {
+        'http': {
+            'server_url': 'http://api.example.com',
+            'debug': True
+        }
+    }
+    service = HTTPService(config)
+
+    # Act
+    result = service.fetch_cell_info(1234)
+
+    # Assert
+    assert result == DEBUG_DATA
+    assert result['cell_type']['name'] == 'INR18650-35E'
+    assert result['cell_type']['capacity'] == 3450
+
+def test_network_error(http_service):
+    # Arrange
+    with patch('requests.get', side_effect=ConnectionError('Network error')):
+        # Act & Assert
+        with pytest.raises(ConnectionError) as exc_info:
+            http_service.fetch_cell_info(1234)
+        
+        assert "Network error" in str(exc_info.value)
+
+def test_malformed_json(http_service):
+    # Arrange
+    mock_bad_response = MagicMock()
+    mock_bad_response.status_code = 200
+    mock_bad_response.json.side_effect = ValueError('Invalid JSON')
+
+    with patch('requests.get', return_value=mock_bad_response):
+        # Act & Assert
+        with pytest.raises(ValueError) as exc_info:
+            http_service.fetch_cell_info(1234)
+        
+        assert "Invalid JSON" in str(exc_info.value)

+ 7 - 7
tests/test_i2c_service.py

@@ -1,6 +1,6 @@
 import pytest
 from unittest.mock import MagicMock, patch
-from src.services.i2c_service import I2CService
+from src.services.i2c_service import I2CService, smbus2
 from src.models.device import DeviceStatus
 from src.models.cell import CellLimits, MeasureValues
 
@@ -10,7 +10,7 @@ def mock_smbus():
         yield mock
 
 @pytest.fixture
-def i2c_service(mock_smbus):
+def i2c_service(mock_smbus: smbus2.SMBus):
     config = {
         'i2c': {
             'bus': 1,
@@ -19,7 +19,7 @@ def i2c_service(mock_smbus):
     }
     return I2CService(config)
 
-def test_request_status_list(i2c_service, mock_smbus):
+def test_request_status_list(i2c_service: I2CService, mock_smbus: smbus2.SMBus):
     # Arrange
     mock_instance = mock_smbus.return_value
     mock_instance.read_i2c_block_data.return_value = [0, 1, 2]  # Mix of different states
@@ -32,11 +32,11 @@ def test_request_status_list(i2c_service, mock_smbus):
     # Assert
     assert len(result) == 3
     assert result[0] == DeviceStatus.EMPTY
-    assert result[1] == DeviceStatus.CHARGING
-    assert result[2] == DeviceStatus.READY
+    assert result[1] == DeviceStatus.INSERTED
+    assert result[2] == DeviceStatus.MEASURING
     mock_instance.read_i2c_block_data.assert_called_once_with(i2c_address, i2c_service.status_register, num_slots)
 
-def test_request_measure_values(i2c_service, mock_smbus):
+def test_request_measure_values(i2c_service: I2CService, mock_smbus: smbus2.SMBus):
     # Arrange
     mock_instance = mock_smbus.return_value
     mock_instance.read_i2c_block_data.return_value = [42, 36, 15]  # voltage values * 10
@@ -50,7 +50,7 @@ def test_request_measure_values(i2c_service, mock_smbus):
     assert isinstance(result, MeasureValues)
     mock_instance.read_i2c_block_data.assert_called_once_with(i2c_address, i2c_service.cell_data_register, 3, slot_id)
 
-def test_send_cell_limits(i2c_service, mock_smbus):
+def test_send_cell_limits(i2c_service: I2CService, mock_smbus: smbus2.SMBus):
     # Arrange
     mock_instance = mock_smbus.return_value
     i2c_address = 0x48

+ 166 - 0
tests/test_measurement_controller.py

@@ -0,0 +1,166 @@
+import pytest
+from unittest.mock import MagicMock, patch, ANY
+import asyncio
+from src.controllers.measurement_controller import MeasurementController
+from src.models.device import DeviceStatus
+from src.models.cell import Cell, CellLimits, MeasureValues
+from src.services.mqtt_service import InsertedCell
+import logging
+
+@pytest.fixture
+def mock_services():
+    i2c_service = MagicMock()
+    http_service = MagicMock()
+    mqtt_service = MagicMock()
+    return i2c_service, http_service, mqtt_service
+
+@pytest.fixture
+def config():
+    return {
+        'devices': [
+            {
+                'id': 1,
+                'i2c_address': 0x48,
+                'num_slots': 2
+            }
+        ],
+        'mqtt': {
+            'subscribe_prefix': 'test/'
+        },
+        'i2c': {
+            'polling_interval_ms': 100
+        },
+        'measurement': {
+            'min_voltage': 2.5,
+            'max_voltage': 4.2,
+            'c_rate': 0.5
+        }
+    }
+
+@pytest.fixture
+def controller(mock_services, config):
+    i2c_service, http_service, mqtt_service = mock_services
+    return MeasurementController(config, i2c_service, http_service, mqtt_service)
+
+@pytest.mark.asyncio
+async def test_device_polling(controller, mock_services):
+    i2c_service, _, _ = mock_services
+    
+    # Setup mock responses
+    i2c_service.request_status_list.return_value = [DeviceStatus.EMPTY, DeviceStatus.EMPTY]
+    
+    # Start polling
+    await controller.start_polling()
+    await asyncio.sleep(0.2)  # Allow some polling cycles
+    await controller.stop_polling()
+    
+    # Verify I2C service was called
+    assert i2c_service.request_status_list.called
+    assert i2c_service.request_status_list.call_count >= 1
+
+@pytest.mark.asyncio
+async def test_cell_insertion_flow(controller, mock_services):
+    i2c_service, http_service, mqtt_service = mock_services
+    
+    # Setup mock responses
+    http_service.fetch_cell_info.return_value = {
+        'cell_type': {
+            'min_voltage': 2.5,
+            'max_voltage': 4.2,
+            'capacity': 3000,
+            'name': 'Test Cell'
+        }
+    }
+    
+    # Simulate cell insertion
+    insertion_info = InsertedCell(device_id=1, slot_id=0, cell_id=123)
+    controller._update_inserted_cell(insertion_info)
+    
+    # Verify HTTP service was called to fetch cell info
+    http_service.fetch_cell_info.assert_called_once_with(123)
+    
+    # Verify cell was inserted with correct parameters
+    device = controller.devices[1]
+    cell = device.slots[0].get_cell()
+    assert cell is not None
+    assert cell.id == 123
+    assert cell.nom_capacity == 3000
+
+@pytest.mark.asyncio
+async def test_measurement_cycle(controller, mock_services):
+    i2c_service, _, mqtt_service = mock_services
+    
+    capacity = 3000
+    # Setup initial state
+    device = controller.devices[1]
+    cell = Cell(123, CellLimits(2.5, 4.2, 1.5), capacity)
+    device.slots[0].insert_cell(cell)
+    
+    # Setup mock responses for state transitions
+    i2c_service.request_status_list.side_effect = [
+        [DeviceStatus.INSERTED, DeviceStatus.EMPTY],   # Initial state
+        [DeviceStatus.MEASURING, DeviceStatus.EMPTY],  # Measuring
+        [DeviceStatus.MEASURING, DeviceStatus.EMPTY],  # Measuring
+        [DeviceStatus.DONE, DeviceStatus.EMPTY],        # Measurement complete
+    ]
+    
+    i2c_service.request_measure_values.return_value = MeasureValues(4.2, 3.6, 1.5)
+    
+    # Start polling
+    await controller.start_polling()
+    await asyncio.sleep(1)  # Allow state transitions
+    await controller.stop_polling()
+    
+    # Verify measurement cycle
+    assert i2c_service.send_cell_limits.called
+    assert i2c_service.request_measure_values.called
+    assert mqtt_service.cell_finished.called
+    
+    # Verify health calculation is called
+    device_id, slot_id, cell_id, health, status = mqtt_service.cell_finished.call_args[0]
+    
+    # Verify health calculation and other parameters
+    assert device_id == 1
+    assert slot_id == 0
+    assert cell_id == 123
+    assert health != -1.0  # Verify that health was actually calculated
+    assert status == DeviceStatus.DONE
+
+@pytest.mark.asyncio
+async def test_error_handling(controller, mock_services):
+    i2c_service, _, mqtt_service = mock_services
+    
+    # Setup initial state
+    device = controller.devices[1]
+    cell = Cell(123, CellLimits(2.5, 4.2, 1.5), 3000)
+    device.slots[0].insert_cell(cell)
+    
+    # Simulate error condition
+    i2c_service.request_status_list.return_value = [DeviceStatus.ERROR, DeviceStatus.ERROR]
+    
+    # Start polling
+    await controller.start_polling()
+    await asyncio.sleep(0.5)
+    await controller.stop_polling()
+    
+    # Verify error handling
+    mqtt_service.cell_finished.assert_called_with(1, 0, 123, 0.0, DeviceStatus.ERROR)
+    assert device.slots[0].get_cell() is None  # Cell should be removed after error
+
+@pytest.mark.asyncio
+async def test_invalid_status_list(controller, mock_services, caplog):
+    i2c_service, _, _ = mock_services
+    caplog.set_level(logging.ERROR)
+    
+    # Setup mock to return invalid status list
+    i2c_service.request_status_list.return_value = [DeviceStatus.EMPTY]  # Too short
+    
+    # Start polling
+    await controller.start_polling()
+    await asyncio.sleep(0.2)  # Allow some polling cycles
+    await controller.stop_polling()
+    
+    # Verify error was logged
+    assert "Invalid status list length" in caplog.text
+
+# Add pytest.ini file to handle asyncio

+ 143 - 0
tests/test_mqtt_service.py

@@ -0,0 +1,143 @@
+import pytest
+from unittest.mock import MagicMock, patch, call
+import json
+from src.services.mqtt_service import MQTTService, InsertedCell
+from src.models.device import DeviceStatus
+
+@pytest.fixture
+def mock_mqtt_client():
+    with patch('paho.mqtt.client.Client') as mock:
+        client_instance = MagicMock()
+        mock.return_value = client_instance
+        yield client_instance
+
+@pytest.fixture
+def mqtt_service(mock_mqtt_client):
+    config = {
+        'mqtt': {
+            'broker_address': 'localhost',
+            'port': 1883,
+            'keepalive': 60,
+            'username': 'test',
+            'password': 'test',
+            'debug': False
+        }
+    }
+    service = MQTTService(config)
+    return service
+
+def test_init_and_connect(mock_mqtt_client):
+    # Arrange
+    config = {
+        'mqtt': {
+            'broker_address': 'test.mosquitto.org',
+            'port': 1883,
+            'keepalive': 60,
+            'username': 'test',
+            'password': 'test',
+            'debug': False
+        }
+    }
+
+    # Act
+    service = MQTTService(config)
+
+    # Assert
+    mock_mqtt_client.username_pw_set.assert_called_once_with('test', 'test')
+    mock_mqtt_client.connect.assert_called_once_with('test.mosquitto.org', 1883, 60)
+    mock_mqtt_client.loop_start.assert_called_once()
+
+def test_register_device(mqtt_service):
+    # Arrange
+    device_id = 1
+    num_slots = 4
+    callback = lambda x: x
+
+    # Act
+    mqtt_service.register_device(device_id, num_slots, callback)
+
+    # Assert
+    assert mqtt_service.devices[device_id] == num_slots
+    assert len(mqtt_service.insertion_callbacks[device_id]) == num_slots
+    for slot in range(num_slots):
+        assert mqtt_service.insertion_callbacks[device_id][slot] == callback
+
+def test_on_message_callback(mqtt_service):
+    # Arrange
+    device_id = 1
+    slot_id = 0
+    cell_id = 123
+    callback_mock = MagicMock()
+    mqtt_service.register_device(device_id, 1, callback_mock)
+    
+    msg = MagicMock()
+    msg.topic = f"cells_inserted/device_{device_id}"
+    msg.payload = json.dumps({
+        "slot_id": slot_id,
+        "cell_id": cell_id
+    }).encode()
+
+    # Act
+    mqtt_service.on_message(None, None, msg)
+
+    # Assert
+    callback_mock.assert_called_once()
+    args = callback_mock.call_args[0][0]
+    assert isinstance(args, InsertedCell)
+    assert args.device_id == device_id
+    assert args.slot_id == slot_id
+    assert args.cell_id == cell_id
+
+def test_cell_finished_publishing(mqtt_service, mock_mqtt_client):
+    # Arrange
+    device_id = "device1"
+    slot_id = 0
+    cell_id = 123
+    capacity = 2500.0
+    status = DeviceStatus.DONE
+    
+    mqtt_service.register_device(device_id, 1)
+
+    # Act
+    mqtt_service.cell_finished(device_id, slot_id, cell_id, capacity, status)
+
+    # Assert
+    expected_payload = {
+        "device_id": device_id,
+        "slot_id": slot_id,
+        "cell_id": cell_id,
+        "capacity": capacity,
+        "status": status.name
+    }
+    mock_mqtt_client.publish.assert_called_once_with(
+        f"measurement_done/{device_id}",
+        json.dumps(expected_payload)
+    )
+
+def test_cleanup(mqtt_service, mock_mqtt_client):
+    # Act
+    mqtt_service.cleanup()
+
+    # Assert
+    mock_mqtt_client.loop_stop.assert_called_once()
+    mock_mqtt_client.disconnect.assert_called_once()
+
+def test_debug_mode(mock_mqtt_client):
+    # Arrange
+    config = {
+        'mqtt': {
+            'broker_address': 'localhost',
+            'port': 1883,
+            'keepalive': 60,
+            'username': 'test',
+            'password': 'test',
+            'debug': True
+        }
+    }
+
+    # Act
+    service = MQTTService(config)
+
+    # Assert
+    mock_mqtt_client.connect.assert_not_called()
+    mock_mqtt_client.loop_start.assert_not_called()