|
| 1 | +# XRP MicroPython Library — Code Review |
| 2 | + |
| 3 | +## Bugs |
| 4 | + |
| 5 | +### 1. `return Exception` instead of `raise Exception` |
| 6 | +**File:** `XRPLib/encoded_motor.py:62` |
| 7 | + |
| 8 | +```python |
| 9 | +return Exception("Invalid motor index") # returns the exception object silently |
| 10 | +``` |
| 11 | + |
| 12 | +Should be `raise Exception(...)`. Currently an invalid index silently returns an Exception object as if it were a motor, which will cause a confusing `AttributeError` later when the caller tries to use it. |
| 13 | + |
| 14 | +--- |
| 15 | + |
| 16 | +### 2. IMU `gyro_rate()` reads wrong register bits |
| 17 | +**File:** `XRPLib/imu.py:484` |
| 18 | + |
| 19 | +```python |
| 20 | +index = list(LSM_ODR.values()).index(self.reg_ctrl1_xl_bits.ODR_G) |
| 21 | +``` |
| 22 | + |
| 23 | +This reads `ODR_G` from `reg_ctrl1_xl_bits` (the **accelerometer** control register) instead of from `reg_ctrl2_g_bits` (the **gyroscope** control register). Should be: |
| 24 | + |
| 25 | +```python |
| 26 | +index = list(LSM_ODR.values()).index(self.reg_ctrl2_g_bits.ODR_G) |
| 27 | +``` |
| 28 | + |
| 29 | +--- |
| 30 | + |
| 31 | +### 3. IMU `timer_frequency` not initialized in `__init__` |
| 32 | +**File:** `XRPLib/imu.py` |
| 33 | + |
| 34 | +`self.timer_frequency` is only set inside `gyro_rate()` (line 492), but the timer is started in `reset()` -> `_default_config()` -> `gyro_rate('208Hz')`. If `_start_timer()` is ever called before `gyro_rate()` completes (e.g. in a subclass override or future refactor), you get an `AttributeError`. It should be initialized in `__init__` or `_reset_member_variables()`. |
| 35 | + |
| 36 | +--- |
| 37 | + |
| 38 | +### 4. `Timeout` and IMU use raw `ticks_ms` arithmetic instead of `ticks_diff` |
| 39 | +**Files:** `XRPLib/timeout.py:23`, `XRPLib/imu.py:184`, `XRPLib/imu.py:514` |
| 40 | + |
| 41 | +```python |
| 42 | +# timeout.py:23 |
| 43 | +return time.ticks_ms() - self.start_time > self.timeout |
| 44 | + |
| 45 | +# imu.py:184 |
| 46 | +while time.ticks_ms() < (t0 + wait_timeout_ms): |
| 47 | + |
| 48 | +# imu.py:514 |
| 49 | +while time.ticks_ms() < start_time + calibration_time*1000: |
| 50 | +``` |
| 51 | + |
| 52 | +`ticks_ms()` wraps around (it's a 30-bit counter on MicroPython). All of these should use `time.ticks_diff(time.ticks_ms(), start_time)` to handle wraparound correctly. These will malfunction if `ticks_ms` wraps during operation (~12.4 days on RP2040). |
| 53 | + |
| 54 | +--- |
| 55 | + |
| 56 | +### 5. `_getregs` allocates on the heap inside timer callback |
| 57 | +**File:** `XRPLib/imu.py:108-111` |
| 58 | + |
| 59 | +```python |
| 60 | +def _getregs(self, reg, num_bytes): |
| 61 | + rx_buf = bytearray(num_bytes) # heap allocation every call |
| 62 | +``` |
| 63 | + |
| 64 | +`_update_imu_readings()` is called from a timer callback and calls `get_gyro_rates()` -> `_getregs()`, which allocates a new `bytearray(6)` every call (~208 times/second). In MicroPython timer callbacks, heap allocation can trigger GC and cause crashes or unpredictable timing. Should use a pre-allocated buffer. |
| 65 | + |
| 66 | +--- |
| 67 | + |
| 68 | +### 6. I2C operations in timer callback context |
| 69 | +**File:** `XRPLib/imu.py:548-553` |
| 70 | + |
| 71 | +The `_update_imu_readings` callback does I2C reads (`readfrom_mem_into`). I2C is a slow bus protocol. MicroPython's soft timers (Timer -1) do run in a thread-safe context (not hard IRQ), so this *works*, but it blocks other soft timer callbacks (like motor speed updates at 50 Hz) for the duration of the I2C transaction. Consider using `micropython.schedule()` to defer this work. |
| 72 | + |
| 73 | +--- |
| 74 | + |
| 75 | +## Concurrency / Race Conditions |
| 76 | + |
| 77 | +### 7. Shared mutable `irq_v` array without protection |
| 78 | +**File:** `XRPLib/imu.py:82` |
| 79 | + |
| 80 | +`get_acc_rates()`, `get_gyro_rates()`, and `get_acc_gyro_rates()` all write to `self.irq_v` and return it. The timer callback also calls `get_gyro_rates()` which mutates `irq_v[1]`. If user code calls `get_acc_gyro_rates()` while the timer fires, the gyro values in the returned array could be partially overwritten. The returned list is a shared reference, not a copy. |
| 81 | + |
| 82 | +--- |
| 83 | + |
| 84 | +### 8. Rangefinder `_trigger_ping` does busy-wait in timer callback |
| 85 | +**File:** `XRPLib/rangefinder.py:92-116` |
| 86 | + |
| 87 | +The timer callback calls `_trigger_ping()` which does a busy-wait `_delay_us(10)`. Timer callbacks with `Timer(-1)` are soft callbacks on RP2040, but the ~15us busy-wait still blocks other callbacks. If the platform treats virtual timers as hard IRQs, the busy loop could cause watchdog issues. |
| 88 | + |
| 89 | +--- |
| 90 | + |
| 91 | +### 9. Rangefinder floating-point division in hard IRQ |
| 92 | +**File:** `XRPLib/rangefinder.py:134` |
| 93 | + |
| 94 | +```python |
| 95 | +self.cms = (pulse_time / 2) / 29.1 |
| 96 | +``` |
| 97 | + |
| 98 | +The echo handler runs as a pin IRQ (hard IRQ). Floating-point division can allocate on the heap in MicroPython. Consider using integer arithmetic instead: |
| 99 | + |
| 100 | +```python |
| 101 | +self.cms = pulse_time * 100 // 5820 # equivalent to pulse_time / 2 / 29.1 |
| 102 | +``` |
| 103 | + |
| 104 | +--- |
| 105 | + |
| 106 | +## Design Issues |
| 107 | + |
| 108 | +### 10. `defaults.py` eagerly initializes all hardware |
| 109 | +**File:** `XRPLib/defaults.py:18-29` |
| 110 | + |
| 111 | +Importing `defaults` creates ALL hardware objects including all 4 motors, the IMU (with 1-second calibration!), the rangefinder, servos, and webserver. Most programs only need 2 motors + drivetrain. This wastes: |
| 112 | +- 1 second on IMU calibration every boot |
| 113 | +- 4 PIO state machines (only 4 available per PIO block) |
| 114 | +- 4 virtual timers for motor speed control (motors 3 & 4 usually unused) |
| 115 | +- Rangefinder timer running continuously even when not needed |
| 116 | + |
| 117 | +Consider lazy initialization or splitting into separate import targets. |
| 118 | + |
| 119 | +--- |
| 120 | + |
| 121 | +### 11. Motor PWM frequency of 50 Hz is very low |
| 122 | +**Files:** `XRPLib/motor.py:17`, `XRPLib/motor.py:63-64` |
| 123 | + |
| 124 | +50 Hz PWM means the motor duty cycle only updates every 20ms and produces audible whine. Typical DC motor PWM is 1-20 kHz. 50 Hz is the standard for servo control, not DC motors. This likely causes jerky low-speed performance and audible noise. |
| 125 | + |
| 126 | +--- |
| 127 | + |
| 128 | +### 12. Encoder `get_position_counts` reads PIO FIFO 5 times (blocking) |
| 129 | +**File:** `XRPLib/encoder.py:47-52` |
| 130 | + |
| 131 | +```python |
| 132 | +counts = self.sm.get() |
| 133 | +counts = self.sm.get() |
| 134 | +counts = self.sm.get() |
| 135 | +counts = self.sm.get() |
| 136 | +counts = self.sm.get() |
| 137 | +``` |
| 138 | + |
| 139 | +This is called from the 50 Hz motor update timer callback. Each `sm.get()` blocks until data is in the FIFO. The PIO RX FIFO is 4 deep, so 5 reads drains the buffer and gets the freshest value. However, this is a blocking operation in a timer callback, which could cause timing jitter for other callbacks. |
| 140 | + |
| 141 | +--- |
| 142 | + |
| 143 | +### 13. PID `min_output` creates oscillation around setpoint |
| 144 | +**File:** `XRPLib/pid.py:94-98` |
| 145 | + |
| 146 | +```python |
| 147 | +if output > 0: |
| 148 | + output = max(self.min_output, output) |
| 149 | +else: |
| 150 | + output = min(-self.min_output, output) |
| 151 | +``` |
| 152 | + |
| 153 | +The output can NEVER be between `-min_output` and `+min_output` (excluding zero). When the error is very small, the output jumps between `+min_output` and `-min_output`, causing visible oscillation around the setpoint. The system only stops via the `is_done()` tolerance check in the calling code (`straight()`, `turn()`), but the robot will visibly jerk back and forth before stopping. |
| 154 | + |
| 155 | +--- |
| 156 | + |
| 157 | +### 14. PID never outputs zero |
| 158 | +Related to issue #13. Since `min_output` forces the output to always be at least `+/-min_output`, the PID can never output 0. The system only stops via the `is_done()` tolerance check in calling code. If someone uses PID standalone without checking `is_done()`, the motors never stop. |
| 159 | + |
| 160 | +--- |
| 161 | + |
| 162 | +### 15. No cleanup/deinitialization for Rangefinder instances |
| 163 | +**File:** `XRPLib/rangefinder.py:89` |
| 164 | + |
| 165 | +`_instances` is a class-level list that's never cleaned up. If a Rangefinder is garbage collected, it stays in `_instances`, and the timer callback will try to call `_do_ping` on a dead object. There's no `deinit()` method to remove the instance or stop the timer. |
| 166 | + |
| 167 | +--- |
| 168 | + |
| 169 | +## Memory & Performance |
| 170 | + |
| 171 | +### 16. String concatenation in `_generateHTML` |
| 172 | +**File:** `XRPLib/webserver.py:234-258` |
| 173 | + |
| 174 | +Building HTML via repeated `string +=` creates many intermediate string objects. On a memory-constrained microcontroller, this can fragment the heap and trigger GC during request handling. Consider using a list and `''.join()`: |
| 175 | + |
| 176 | +```python |
| 177 | +parts = [_HTML1] |
| 178 | +# ... append to parts ... |
| 179 | +return ''.join(parts) |
| 180 | +``` |
| 181 | + |
| 182 | +--- |
| 183 | + |
| 184 | +### 17. `_raw_to_mg` and `_raw_to_mdps` receive sliced bytearrays |
| 185 | +**File:** `XRPLib/imu.py:137-141` |
| 186 | + |
| 187 | +```python |
| 188 | +def _raw_to_mg(self, raw): |
| 189 | + return self._int16((raw[1] << 8) | raw[0]) * LSM_MG_PER_LSB_2G * self._acc_scale_factor |
| 190 | +``` |
| 191 | + |
| 192 | +Called with `raw_bytes[0:2]` which creates a new `bytearray` slice each time. In the timer callback path, this means 3 allocations per update (~208 Hz). Pass buffer and index instead of slicing. |
| 193 | + |
| 194 | +--- |
| 195 | + |
| 196 | +## Minor Issues |
| 197 | + |
| 198 | +### 18. Typo: `ZERO_EFFORT_BREAK` should be `BRAKE` |
| 199 | +**File:** `XRPLib/encoded_motor.py:10` |
| 200 | + |
| 201 | +```python |
| 202 | +ZERO_EFFORT_BREAK = True # typo: should be ZERO_EFFORT_BRAKE |
| 203 | +``` |
| 204 | + |
| 205 | +--- |
| 206 | + |
| 207 | +### 19. `webserver.py` creates a module-level instance with side effects |
| 208 | +**File:** `XRPLib/webserver.py:261` |
| 209 | + |
| 210 | +The `webserver = Webserver()` at module level runs `gc.threshold(50000)` on import. This side effect happens even if you only import the class for type checking or introspection. |
| 211 | + |
| 212 | +--- |
| 213 | + |
| 214 | +### 20. Reflectance `MAX_ADC_VALUE` off by one |
| 215 | +**File:** `XRPLib/reflectance.py:29` |
| 216 | + |
| 217 | +```python |
| 218 | +self.MAX_ADC_VALUE: int = 65536 |
| 219 | +``` |
| 220 | + |
| 221 | +The max value from `read_u16()` is 65535, so the sensor can never return exactly 1.0 (max is `65535/65536 = 0.99998`). Should be `65535` if a true [0, 1] range is desired. |
| 222 | + |
| 223 | +--- |
| 224 | + |
| 225 | +### 21. `DualPWMMotor.set_effort` doesn't clamp input |
| 226 | +**File:** `XRPLib/motor.py:66-80` |
| 227 | + |
| 228 | +Unlike `SinglePWMMotor` which clamps effort to [0, 1], `DualPWMMotor` passes `abs(effort)` directly to `duty_u16()`. An effort > 1.0 would produce PWM values > 65535, which may wrap or error. |
| 229 | + |
| 230 | +--- |
| 231 | + |
| 232 | +### 22. IMU `is_connected()` check is ignored |
| 233 | +**File:** `XRPLib/imu.py:56-58` |
| 234 | + |
| 235 | +```python |
| 236 | +if not self.is_connected(): |
| 237 | + # TODO - do somehting intelligent here |
| 238 | + pass |
| 239 | +``` |
| 240 | + |
| 241 | +If the IMU isn't connected, the code proceeds to `reset()` which will fail with an I2C error. Should raise an exception or return a null/dummy IMU. |
| 242 | + |
| 243 | +--- |
| 244 | + |
| 245 | +## Suggestions for Improvement |
| 246 | + |
| 247 | +- **Add a `deinit()` pattern** to all hardware classes (rangefinder, motors, IMU) to cleanly stop timers and release pins. Critical for REPL-based development where students restart code frequently. |
| 248 | +- **Consider `micropython.schedule()`** for the IMU timer callback to avoid doing I2C in interrupt context. |
| 249 | +- **Pre-allocate all buffers** used in timer/IRQ callbacks (especially the 6-byte and 12-byte reads in IMU). |
| 250 | +- **Make `defaults.py` lazy** — use properties or functions so unused hardware isn't initialized. |
| 251 | +- **Increase motor PWM frequency** to at least 1 kHz for smoother motor operation. |
| 252 | +- **Add bounds checking** in `DualPWMMotor.set_effort` to match `SinglePWMMotor`. |
0 commit comments