imrishabh18/rp2040-motor-controller

This circuit module provides a microcontroller-based NEMA17 stepper motor driver with integrated USB-C Power Delivery, temperature sensing, and safety interlocks.

Version
1.0.16
License
unset
Stars
0

firmware/test_thermal.py

import unittest
from tmp102 import TMP102, CONFIG, encode_temperature, decode_temperature
from thermal_policy import ThermalPolicy


class FakeI2C:
    def __init__(self):
        self.registers = {0: b'\x19\x00', 1: b'\x60\xa0', 2: b'\x4b\x00', 3: b'\x50\x00'}
        self.failed = False

    def writeto_mem(self, address, register, data):
        if self.failed:
            raise OSError("bus failure")
        assert address == 0x48
        self.registers[register] = data

    def readfrom_mem(self, address, register, size):
        if self.failed:
            raise OSError("bus failure")
        assert address == 0x48 and size == 2
        return self.registers[register]


class ProtectionTests(unittest.TestCase):
    def test_sensor_register_byte_order_and_signed_temperature(self):
        for data, c in ((b'\x19\x00', 25), (b'\x4b\x00', 75), (b'\x3c\x00', 60), (b'\xf5\xe0', -10.125)):
            self.assertEqual(decode_temperature(data), c)
            self.assertEqual(encode_temperature(c), data)
        bus = FakeI2C()
        sensor = TMP102(bus)
        sensor.configure()
        self.assertEqual(bus.registers[1], b'\x60\xc0')
        self.assertEqual(bus.registers[2], b'\x3c\x00')
        self.assertEqual(bus.registers[3], b'\x4b\x00')
        self.assertEqual(sensor.temperature(), 25)

    def test_sensor_reset_mode_corruption_and_missing_sensor(self):
        for register, data in ((1, b'\x60\xa0'), (1, b'\x64\xc0'), (1, b'\x60\xd0'),
                               (1, b'\x61\xc0'), (2, b'\x4b\x00'), (3, b'\x50\x00')):
            bus = FakeI2C()
            sensor = TMP102(bus)
            sensor.configure()
            bus.registers[register] = data
            with self.assertRaises(OSError):
                sensor.temperature()
        bus.failed = True
        with self.assertRaises(OSError):
            sensor.configure()

    def test_status_bits_are_read_only_and_do_not_mask_configuration_errors(self):
        bus = FakeI2C()
        sensor = TMP102(bus)
        sensor.configure()
        bus.registers[1] = b'\xe0\xe0'  # OS and AL may vary
        self.assertEqual(sensor.temperature(), 25)

    def test_startup_requires_valid_sensor_and_explicit_arm(self):
        p = ThermalPolicy()
        self.assertFalse(p.arm())
        p.sample(25)
        self.assertFalse(p.enabled)
        self.assertTrue(p.arm())

    def test_warning_trip_and_strict_cooldown_with_manual_rearm(self):
        p = ThermalPolicy()
        p.sample(25)
        p.arm()
        self.assertIn("WARNING", p.sample(65))
        self.assertTrue(p.enabled)
        p.sample(75)
        self.assertFalse(p.enabled)
        p.sample(60)
        self.assertFalse(p.arm())
        p.sample(59.9375)
        self.assertFalse(p.enabled)
        self.assertTrue(p.latched)
        self.assertTrue(p.arm())

    def test_faults_stop_motor_and_never_auto_resume(self):
        for kwargs in ({'sensor_ok': False}, {'alert_ok': False}, {'driver_fault': True}):
            p = ThermalPolicy()
            p.sample(25)
            p.arm()
            p.sample(25, **kwargs)
            self.assertFalse(p.enabled)
            p.sample(25)
            self.assertFalse(p.enabled)
            self.assertTrue(p.latched)
        for temp in (None, float('nan'), -41, 126):
            p.sample(temp)
            self.assertFalse(p.arm())

    def test_bad_temperature_format_is_rejected(self):
        for data in (b'\x00', b'\x19\x01', b'\x7f\xf0'):
            with self.assertRaises(ValueError):
                decode_temperature(data)

# Exercise the actual MicroPython adapter with emulated pins/I2C; no motors run.
class AdapterTests(unittest.TestCase):
    def setUp(self):
        import sys
        import types
        import importlib
        from unittest.mock import patch
        self.bus = FakeI2C()
        self.pins = {}
        pins = self.pins

        class FakePin:
            IN, OUT, IRQ_FALLING = 0, 1, 2

            def __new__(cls, number, *args, **kwargs):
                if number in pins:
                    return pins[number]
                obj = super().__new__(cls)
                pins[number] = obj
                obj.level = 1
                obj.callback = None
                return obj

            def __init__(self, number, mode=None, value=None):
                if value is not None:
                    self.level = value

            def value(self, value=None):
                if value is not None:
                    self.level = value
                return self.level

            def irq(self, handler=None, **kwargs):
                self.callback = handler

        machine = types.SimpleNamespace(Pin=FakePin, I2C=lambda *a, **k: self.bus,
                                        disable_irq=lambda: 0, enable_irq=lambda _: None)
        clock = types.SimpleNamespace(sleep_ms=lambda _: None, ticks_ms=lambda: 1000,
                                      ticks_diff=lambda a, b: a - b)
        with patch.dict(sys.modules, {'machine': machine, 'time': clock}):
            sys.modules.pop('thermal_guard', None)
            self.module = importlib.import_module('thermal_guard')
            self.guard = self.module.ThermalGuard()

    def test_boot_and_sensor_failure_hold_enable_low(self):
        self.assertEqual(self.pins[22].level, 0)
        self.assertTrue(self.guard.arm())
        self.bus.failed = True
        self.guard.poll()
        self.assertEqual(self.pins[22].level, 0)
        self.assertFalse(self.guard.arm())

    def test_interrupt_latches_even_after_alert_has_recovered(self):
        self.guard.arm()
        self.pins[24].level = 0
        self.pins[24].callback(None)
        self.assertEqual(self.pins[22].level, 0)
        self.pins[24].level = 1
        self.guard.poll()
        self.assertFalse(self.guard.policy.enabled)
        self.assertTrue(self.guard.arm())  # explicit user action after cool check

    def test_arm_uses_fresh_temperature_and_checks_driver_fault(self):
        self.bus.registers[0] = b'\x4b\x00'
        self.assertFalse(self.guard.arm())
        self.bus.registers[0] = b'\x19\x00'
        self.pins[23].level = 0
        self.assertFalse(self.guard.arm())
        self.assertEqual(self.pins[22].level, 0)

    def test_exit_and_stop_clear_enable_and_coil_inputs(self):
        self.guard.arm()
        for n in (18, 19, 20, 21):
            self.pins[n].value(1)
        self.guard.stop('user stop')
        self.assertEqual([self.pins[n].level for n in (18, 19, 20, 21, 22)], [0] * 5)


if __name__ == '__main__':
    unittest.main()