Skip to content

Commit 7926cd5

Browse files
bradamillerclaude
andcommitted
Add test plan, unit tests, hardware tests, and code review
- TEST_PLAN.md: Two-tier testing strategy (unit + hardware integration) - CODE_REVIEW.md: Comprehensive code review with findings and recommendations - tests/unit/: 152 unit tests covering all library modules (runs with pytest on desktop) - tests/mocks/: Mock infrastructure for machine, rp2, neopixel, uctypes, etc. - tests/hardware/: On-device integration tests for motors, encoders, drivetrain, sensors, and timing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 41fad77 commit 7926cd5

54 files changed

Lines changed: 2817 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CODE_REVIEW.md

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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

Comments
 (0)