robot-hat 3.3.2__tar.gz → 3.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. {robot_hat-3.3.2 → robot_hat-3.3.3}/CHANGELOG.md +23 -0
  2. {robot_hat-3.3.2 → robot_hat-3.3.3}/PKG-INFO +42 -7
  3. {robot_hat-3.3.2 → robot_hat-3.3.3}/README.md +41 -6
  4. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/i2c/i2c_bus.py +78 -32
  5. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/i2c/smbus_manager.py +1 -1
  6. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/motor/gpio_dc_motor.py +101 -22
  7. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/motor/phase_motor.py +37 -8
  8. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/pin.py +1 -4
  9. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/services/base_motor_service.py +61 -13
  10. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/services/motor_service.py +12 -0
  11. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/services/servo_service.py +56 -40
  12. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/servos/gpio_angular_servo.py +65 -21
  13. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/servos/servo.py +79 -43
  14. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/version.py +3 -3
  15. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat.egg-info/PKG-INFO +42 -7
  16. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat.egg-info/scm_file_list.json +1 -0
  17. robot_hat-3.3.3/robot_hat.egg-info/scm_version.json +8 -0
  18. robot_hat-3.3.2/robot_hat.egg-info/scm_version.json +0 -8
  19. {robot_hat-3.3.2 → robot_hat-3.3.3}/.github/dependabot.yml +0 -0
  20. {robot_hat-3.3.2 → robot_hat-3.3.3}/.github/workflows/ci.yml +0 -0
  21. {robot_hat-3.3.2 → robot_hat-3.3.3}/.github/workflows/release.yml +0 -0
  22. {robot_hat-3.3.2 → robot_hat-3.3.3}/.gitignore +0 -0
  23. {robot_hat-3.3.2 → robot_hat-3.3.3}/.pre-commit-config.yaml +0 -0
  24. {robot_hat-3.3.2 → robot_hat-3.3.3}/AGENTS.md +0 -0
  25. {robot_hat-3.3.2 → robot_hat-3.3.3}/LICENSE +0 -0
  26. {robot_hat-3.3.2 → robot_hat-3.3.3}/MANIFEST.in +0 -0
  27. {robot_hat-3.3.2 → robot_hat-3.3.3}/Makefile +0 -0
  28. {robot_hat-3.3.2 → robot_hat-3.3.3}/docs/as5048a.md +0 -0
  29. {robot_hat-3.3.2 → robot_hat-3.3.3}/docs/hardware_validation.md +0 -0
  30. {robot_hat-3.3.2 → robot_hat-3.3.3}/docs/lidar_2d.md +0 -0
  31. {robot_hat-3.3.2 → robot_hat-3.3.3}/docs/localization_sensors.md +0 -0
  32. {robot_hat-3.3.2 → robot_hat-3.3.3}/docs/migration_guide_v2.md +0 -0
  33. {robot_hat-3.3.2 → robot_hat-3.3.3}/docs/migration_guide_v3.md +0 -0
  34. {robot_hat-3.3.2 → robot_hat-3.3.3}/docs/pololu_miniimu9_v5.md +0 -0
  35. {robot_hat-3.3.2 → robot_hat-3.3.3}/docs/sense_hat_sensors.md +0 -0
  36. {robot_hat-3.3.2 → robot_hat-3.3.3}/hardware-validation/evidence/README.md +0 -0
  37. {robot_hat-3.3.2 → robot_hat-3.3.3}/pyproject.toml +0 -0
  38. {robot_hat-3.3.2 → robot_hat-3.3.3}/pyrightconfig.json +0 -0
  39. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/__init__.py +0 -0
  40. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/common/__init__.py +0 -0
  41. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/common/event_emitter.py +0 -0
  42. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/common/singleton_meta.py +0 -0
  43. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/__init__.py +0 -0
  44. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/angular_position.py +0 -0
  45. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/battery.py +0 -0
  46. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/bus.py +0 -0
  47. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/config/__init__.py +0 -0
  48. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/config/battery.py +0 -0
  49. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/config/hts221.py +0 -0
  50. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/config/ina219.py +0 -0
  51. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/config/ina226.py +0 -0
  52. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/config/ina260.py +0 -0
  53. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/config/lidar.py +0 -0
  54. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/config/lis3mdl.py +0 -0
  55. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/config/lps25h.py +0 -0
  56. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/config/lsm6ds33.py +0 -0
  57. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/config/lsm9ds1.py +0 -0
  58. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/config/lsm9ds1_magnetometer.py +0 -0
  59. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/config/motor.py +0 -0
  60. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/config/pwm.py +0 -0
  61. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/config/sh3001.py +0 -0
  62. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/encoder.py +0 -0
  63. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/environment.py +0 -0
  64. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/imu.py +0 -0
  65. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/lidar.py +0 -0
  66. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/magnetometer.py +0 -0
  67. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/motor.py +0 -0
  68. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/quadrature.py +0 -0
  69. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/data_types/uart.py +0 -0
  70. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/drivers/__init__.py +0 -0
  71. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/drivers/adc/INA219.py +0 -0
  72. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/drivers/adc/INA226.py +0 -0
  73. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/drivers/adc/INA260.py +0 -0
  74. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/drivers/adc/__init__.py +0 -0
  75. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/drivers/adc/sunfounder_adc.py +0 -0
  76. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/drivers/angle/__init__.py +0 -0
  77. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/drivers/angle/as5048a.py +0 -0
  78. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/drivers/angle/as5600l.py +0 -0
  79. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/drivers/gpio/__init__.py +0 -0
  80. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/drivers/gpio/gpiozero_digital_edge_input.py +0 -0
  81. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/drivers/pwm/__init__.py +0 -0
  82. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/drivers/pwm/pca9685.py +0 -0
  83. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/drivers/pwm/sunfounder_pwm.py +0 -0
  84. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/exceptions.py +0 -0
  85. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/factories/__init__.py +0 -0
  86. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/factories/battery_factory.py +0 -0
  87. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/factories/motor_factory.py +0 -0
  88. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/factories/pwm_factory.py +0 -0
  89. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/filedb.py +0 -0
  90. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/i2c/__init__.py +0 -0
  91. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/i2c/i2c_manager.py +0 -0
  92. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/i2c/retry_decorator.py +0 -0
  93. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/i2c/smbus_protocol.py +0 -0
  94. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/interfaces/__init__.py +0 -0
  95. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/interfaces/angular_position_abc.py +0 -0
  96. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/interfaces/battery_abc.py +0 -0
  97. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/interfaces/digital_edge_input_abc.py +0 -0
  98. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/interfaces/encoder_abc.py +0 -0
  99. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/interfaces/environmental_sensor_abc.py +0 -0
  100. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/interfaces/imu_abc.py +0 -0
  101. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/interfaces/lidar_2d_abc.py +0 -0
  102. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/interfaces/magnetometer_abc.py +0 -0
  103. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/interfaces/motor_abc.py +0 -0
  104. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/interfaces/pwm_driver_abc.py +0 -0
  105. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/interfaces/quadrature_counter_backend_abc.py +0 -0
  106. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/interfaces/servo_abc.py +0 -0
  107. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/interfaces/smbus_abc.py +0 -0
  108. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/interfaces/spi_abc.py +0 -0
  109. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/interfaces/uart_abc.py +0 -0
  110. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/mock/__init__.py +0 -0
  111. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/mock/angular_position.py +0 -0
  112. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/mock/encoder.py +0 -0
  113. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/mock/environmental_sensor.py +0 -0
  114. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/mock/imu.py +0 -0
  115. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/mock/lidar.py +0 -0
  116. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/mock/magnetometer.py +0 -0
  117. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/mock/quadrature_counter.py +0 -0
  118. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/mock/smbus2.py +0 -0
  119. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/mock/spi.py +0 -0
  120. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/mock/uart.py +0 -0
  121. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/mock/ultrasonic.py +0 -0
  122. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/motor/__init__.py +0 -0
  123. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/motor/i2c_dc_motor.py +0 -0
  124. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/motor/mixins/__init__.py +0 -0
  125. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/motor/mixins/motor_calibration.py +0 -0
  126. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/music.py +0 -0
  127. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/py.typed +0 -0
  128. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/__init__.py +0 -0
  129. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/angular_position/__init__.py +0 -0
  130. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/angular_position/as5048a_angular_position.py +0 -0
  131. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/angular_position/as5600l_angular_position.py +0 -0
  132. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/encoder/__init__.py +0 -0
  133. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/encoder/as5048a_encoder.py +0 -0
  134. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/encoder/as5600l_encoder.py +0 -0
  135. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/encoder/gpio_quadrature_counter.py +0 -0
  136. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/encoder/quadrature_decoder.py +0 -0
  137. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/encoder/quadrature_encoder.py +0 -0
  138. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/environmental/__init__.py +0 -0
  139. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/environmental/hts221.py +0 -0
  140. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/environmental/lps25h.py +0 -0
  141. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/imu/__init__.py +0 -0
  142. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/imu/lsm6ds33.py +0 -0
  143. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/imu/lsm9ds1.py +0 -0
  144. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/imu/sh3001.py +0 -0
  145. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/lidar/__init__.py +0 -0
  146. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/lidar/rplidar_c1.py +0 -0
  147. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/magnetometer/__init__.py +0 -0
  148. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/magnetometer/lis3mdl.py +0 -0
  149. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/magnetometer/lsm9ds1.py +0 -0
  150. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/ultrasonic/HC_SR04.py +0 -0
  151. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sensors/ultrasonic/__init__.py +0 -0
  152. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/services/__init__.py +0 -0
  153. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/services/battery/__init__.py +0 -0
  154. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/services/battery/ina219_battery.py +0 -0
  155. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/services/battery/ina226_battery.py +0 -0
  156. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/services/battery/ina260_battery.py +0 -0
  157. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/services/battery/sunfounder_battery.py +0 -0
  158. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/services/single_motor_service.py +0 -0
  159. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/servos/__init__.py +0 -0
  160. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/spi/__init__.py +0 -0
  161. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/spi/spidev_device.py +0 -0
  162. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sunfounder/__init__.py +0 -0
  163. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sunfounder/accelerometer.py +0 -0
  164. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sunfounder/address_descriptions.py +0 -0
  165. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sunfounder/grayscale.py +0 -0
  166. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sunfounder/motor.py +0 -0
  167. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sunfounder/pin_descriptions.py +0 -0
  168. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sunfounder/pwm.py +0 -0
  169. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sunfounder/robot.py +0 -0
  170. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sunfounder/sunfounder_servo.py +0 -0
  171. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/sunfounder/utils.py +0 -0
  172. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/uart/__init__.py +0 -0
  173. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/uart/serial_uart.py +0 -0
  174. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/uart/usb_uart.py +0 -0
  175. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat/utils.py +0 -0
  176. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat.egg-info/SOURCES.txt +0 -0
  177. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat.egg-info/dependency_links.txt +0 -0
  178. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat.egg-info/requires.txt +0 -0
  179. {robot_hat-3.3.2 → robot_hat-3.3.3}/robot_hat.egg-info/top_level.txt +0 -0
  180. {robot_hat-3.3.2 → robot_hat-3.3.3}/setup.cfg +0 -0
  181. {robot_hat-3.3.2 → robot_hat-3.3.3}/tools/hil_motor_check.py +0 -0
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.3.3 (2026-10-16)
4
+
5
+ ### Fixed
6
+
7
+ - Keep servo travel limits independent of pulse calibration. `Servo` maps
8
+ `real_min_angle`/`real_max_angle` to the configured pulse endpoints; narrower
9
+ `min_angle`/`max_angle` limits reduce travel without stretching the pulse range.
10
+ - Validate servo calibration ranges and reject non-finite commands. Serialize
11
+ servo commands with cleanup and reject writes after close.
12
+ - Initialize GPIO servos without pulses until commanded, and apply the service's
13
+ calibrated center consistently during initialization and calibration updates.
14
+ - Preserve servo calibration state when a hardware write fails and release
15
+ servo resources after failed service initialization.
16
+ - Hold GPIO motor enables low until direction outputs are ready, disable and
17
+ stop motors before releasing GPIO resources. Serialize movement and cleanup.
18
+ - Attempt stops for every managed motor after a command or stop failure, stop
19
+ all motors before service cleanup, and report cleanup failures to callers.
20
+ - Stop phase/enable motors before closing GPIO resources.
21
+ - Serialize shared I²C transactions and cleanup to protect SMBus address
22
+ selection, notify bus managers when context managers close.
23
+ - Keep shared GPIO factories open when deinitializing an individual pin, and
24
+ preserve active bus registrations when a different instance closes.
25
+
3
26
  ## v3.3.2 (2026-10-11)
4
27
 
5
28
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robot_hat
3
- Version: 3.3.2
3
+ Version: 3.3.3
4
4
  Summary: Extensible, typed robotics hardware library for motors, servos, sensors, 2D lidar, I2C, and UART.
5
5
  Author-email: Karim Aziiev <karim.aziiev@gmail.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -362,6 +362,22 @@ for examples and custom `MotorABC` requirements.
362
362
 
363
363
  GPIO motors are motors that are controlled entirely via direct GPIO calls; no I²C address or external PWM driver is needed. Examples include the Waveshare RPi Motor Driver Board with the MC33886 module.
364
364
 
365
+ `GPIODCMotor` holds its optional active-high enable pin low during initialization,
366
+ while stopped, and before releasing GPIO resources. It enables the bridge only
367
+ after setting the direction outputs. Each GPIO must belong to one device;
368
+ an indicator LED must not share a motor's enable or direction pin.
369
+
370
+ Motor services stop every motor before releasing resources and attempt every
371
+ stop even if one output fails. Stop and close failures are reported to the caller.
372
+ A failed movement command triggers a stop attempt for all managed motors, and
373
+ closed services reject movement commands.
374
+
375
+ GPIO cleanup releases pin ownership. Software cannot guarantee the electrical
376
+ state during boot, OS halt, power loss, or a crashed process. The motor driver
377
+ must have a hardware-defined disabled state when control inputs are undriven,
378
+ and an independent power cutoff or inhibit appropriate to the mechanism.
379
+ Validate startup, shutdown, and loss of control with wheels clear of the ground.
380
+
365
381
  ```python
366
382
  from robot_hat import GPIODCMotorConfig, MotorFactory, MotorService, setup_env_vars
367
383
 
@@ -517,6 +533,29 @@ calibrated_angle = -1 × (input_angle + (-1 × calibration_offset))
517
533
 
518
534
  The `ServoService` provides a high-level abstraction for managing servo operations. It allows easy configuration of the calibration mode, movement bounds, and custom calibration logic if needed.
519
535
 
536
+ `Servo` separates permitted travel (`min_angle`, `max_angle`) from pulse
537
+ calibration (`real_min_angle` at `min_pulse`, `real_max_angle` at `max_pulse`).
538
+ An allowed angle produces the same pulse when travel limits change. For
539
+ example, with measured calibration points of -45° at 1000 µs and +45° at
540
+ 2000 µs, limits of -10° to +10° produce approximately 1389–1611 µs.
541
+ The default calibration assumes -90° to +90°; verify it for the particular
542
+ servo. These are two calibration points, not a request to find mechanical
543
+ end stops. Pulse rounding, PWM resolution, backlash, and servo behavior limit
544
+ physical accuracy. Wheel steering angle also depends on the linkage geometry.
545
+
546
+ When using `ServoService`, set its bounds to the desired movement relative
547
+ to the calibrated center. Give its underlying `Servo` the full calibrated
548
+ angle range so that the center offset can be applied without a second travel
549
+ clamp. `GPIOAngularServo` follows gpiozero's convention: its `min_angle` and
550
+ `max_angle` are the measured pulse endpoints; use `ServoService` for narrower
551
+ travel limits. GPIO servos start with pulses disabled until the first angle
552
+ command, including the calibrated center command issued by `ServoService`.
553
+
554
+ Direct `pulse_width_time()` calls are calibration operations and bypass travel
555
+ limits. Use angle commands for normal movement. Non-finite angle, offset, and
556
+ pulse commands are rejected before hardware writes; closed servos reject
557
+ further commands.
558
+
520
559
  Here's how to use `ServoCalibrationMode` in your servo configuration:
521
560
 
522
561
  **Example 1**: Steering servo using `ServoCalibrationMode.SUM`
@@ -548,6 +587,7 @@ driver = PWMFactory.create_pwm_driver(
548
587
  bus=pwm_config.bus, # either a bus number or an smbus instance.
549
588
  config=pwm_config,
550
589
  )
590
+ driver.set_pwm_freq(pwm_config.freq)
551
591
 
552
592
  steering_servo = ServoService(
553
593
  servo=Servo(
@@ -560,8 +600,7 @@ steering_servo = ServoService(
560
600
  # The minimum and maximum pulse widths (in microseconds) corresponding to the servo's physical movement.
561
601
  min_pulse=500,
562
602
  max_pulse=2500,
563
- # The minimum and maximum physical angles (in degrees) that the servo can achieve.
564
- # These values are used to map the logical angle to the physical angle.
603
+ # Measured angles at min_pulse and max_pulse, respectively.
565
604
  real_min_angle=-90.0,
566
605
  real_max_angle=90.0,
567
606
  ),
@@ -571,8 +610,6 @@ steering_servo = ServoService(
571
610
  calibration_mode=ServoCalibrationMode.SUM,
572
611
  calibration_offset=-14.4,
573
612
  )
574
- driver.set_pwm_freq(pwm_config.freq)
575
-
576
613
  steering_servo.set_angle(-30) # Turn left.
577
614
  steering_servo.set_angle(15) # Turn slightly to the right.
578
615
  steering_servo.reset() # Reset to the center position.
@@ -839,8 +876,6 @@ class MyRobotCar:
839
876
  else:
840
877
  servo = GPIOAngularServo(
841
878
  pin=channel,
842
- min_angle=min_angle,
843
- max_angle=max_angle,
844
879
  )
845
880
  return ServoService(
846
881
  servo=servo,
@@ -319,6 +319,22 @@ for examples and custom `MotorABC` requirements.
319
319
 
320
320
  GPIO motors are motors that are controlled entirely via direct GPIO calls; no I²C address or external PWM driver is needed. Examples include the Waveshare RPi Motor Driver Board with the MC33886 module.
321
321
 
322
+ `GPIODCMotor` holds its optional active-high enable pin low during initialization,
323
+ while stopped, and before releasing GPIO resources. It enables the bridge only
324
+ after setting the direction outputs. Each GPIO must belong to one device;
325
+ an indicator LED must not share a motor's enable or direction pin.
326
+
327
+ Motor services stop every motor before releasing resources and attempt every
328
+ stop even if one output fails. Stop and close failures are reported to the caller.
329
+ A failed movement command triggers a stop attempt for all managed motors, and
330
+ closed services reject movement commands.
331
+
332
+ GPIO cleanup releases pin ownership. Software cannot guarantee the electrical
333
+ state during boot, OS halt, power loss, or a crashed process. The motor driver
334
+ must have a hardware-defined disabled state when control inputs are undriven,
335
+ and an independent power cutoff or inhibit appropriate to the mechanism.
336
+ Validate startup, shutdown, and loss of control with wheels clear of the ground.
337
+
322
338
  ```python
323
339
  from robot_hat import GPIODCMotorConfig, MotorFactory, MotorService, setup_env_vars
324
340
 
@@ -474,6 +490,29 @@ calibrated_angle = -1 × (input_angle + (-1 × calibration_offset))
474
490
 
475
491
  The `ServoService` provides a high-level abstraction for managing servo operations. It allows easy configuration of the calibration mode, movement bounds, and custom calibration logic if needed.
476
492
 
493
+ `Servo` separates permitted travel (`min_angle`, `max_angle`) from pulse
494
+ calibration (`real_min_angle` at `min_pulse`, `real_max_angle` at `max_pulse`).
495
+ An allowed angle produces the same pulse when travel limits change. For
496
+ example, with measured calibration points of -45° at 1000 µs and +45° at
497
+ 2000 µs, limits of -10° to +10° produce approximately 1389–1611 µs.
498
+ The default calibration assumes -90° to +90°; verify it for the particular
499
+ servo. These are two calibration points, not a request to find mechanical
500
+ end stops. Pulse rounding, PWM resolution, backlash, and servo behavior limit
501
+ physical accuracy. Wheel steering angle also depends on the linkage geometry.
502
+
503
+ When using `ServoService`, set its bounds to the desired movement relative
504
+ to the calibrated center. Give its underlying `Servo` the full calibrated
505
+ angle range so that the center offset can be applied without a second travel
506
+ clamp. `GPIOAngularServo` follows gpiozero's convention: its `min_angle` and
507
+ `max_angle` are the measured pulse endpoints; use `ServoService` for narrower
508
+ travel limits. GPIO servos start with pulses disabled until the first angle
509
+ command, including the calibrated center command issued by `ServoService`.
510
+
511
+ Direct `pulse_width_time()` calls are calibration operations and bypass travel
512
+ limits. Use angle commands for normal movement. Non-finite angle, offset, and
513
+ pulse commands are rejected before hardware writes; closed servos reject
514
+ further commands.
515
+
477
516
  Here's how to use `ServoCalibrationMode` in your servo configuration:
478
517
 
479
518
  **Example 1**: Steering servo using `ServoCalibrationMode.SUM`
@@ -505,6 +544,7 @@ driver = PWMFactory.create_pwm_driver(
505
544
  bus=pwm_config.bus, # either a bus number or an smbus instance.
506
545
  config=pwm_config,
507
546
  )
547
+ driver.set_pwm_freq(pwm_config.freq)
508
548
 
509
549
  steering_servo = ServoService(
510
550
  servo=Servo(
@@ -517,8 +557,7 @@ steering_servo = ServoService(
517
557
  # The minimum and maximum pulse widths (in microseconds) corresponding to the servo's physical movement.
518
558
  min_pulse=500,
519
559
  max_pulse=2500,
520
- # The minimum and maximum physical angles (in degrees) that the servo can achieve.
521
- # These values are used to map the logical angle to the physical angle.
560
+ # Measured angles at min_pulse and max_pulse, respectively.
522
561
  real_min_angle=-90.0,
523
562
  real_max_angle=90.0,
524
563
  ),
@@ -528,8 +567,6 @@ steering_servo = ServoService(
528
567
  calibration_mode=ServoCalibrationMode.SUM,
529
568
  calibration_offset=-14.4,
530
569
  )
531
- driver.set_pwm_freq(pwm_config.freq)
532
-
533
570
  steering_servo.set_angle(-30) # Turn left.
534
571
  steering_servo.set_angle(15) # Turn slightly to the right.
535
572
  steering_servo.reset() # Reset to the center position.
@@ -796,8 +833,6 @@ class MyRobotCar:
796
833
  else:
797
834
  servo = GPIOAngularServo(
798
835
  pin=channel,
799
- min_angle=min_angle,
800
- max_angle=max_angle,
801
836
  )
802
837
  return ServoService(
803
838
  servo=servo,
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  import os
3
+ from threading import RLock
3
4
  from types import TracebackType
4
5
  from typing import TYPE_CHECKING, List, Optional, Sequence, Type, Union, cast
5
6
 
@@ -43,6 +44,8 @@ class I2CBus(SMBusABC):
43
44
  SMBus = cast(Type[SMBusProtocol], RealSMBus)
44
45
 
45
46
  self._bus = bus
47
+ self._lock = RLock()
48
+ self._closed = False
46
49
  self._smbus = SMBus(bus, force)
47
50
  self.emitter = EventEmitter()
48
51
  _log.debug("SMBus initialized on bus %s with force=%s", bus, force)
@@ -55,10 +58,10 @@ class I2CBus(SMBusABC):
55
58
  bus: Bus identifier to open.
56
59
  """
57
60
  _log.debug("Opening SMBus on bus %s", bus)
58
- if hasattr(self._smbus, "open"):
61
+ with self._lock:
59
62
  self._smbus.open(bus)
60
- else:
61
- _log.warning("Underlying SMBus instance does not support 'open'.")
63
+ self._bus = bus
64
+ self._closed = False
62
65
 
63
66
  def close(self) -> None:
64
67
  """
@@ -68,17 +71,28 @@ class I2CBus(SMBusABC):
68
71
  is emitted and then removed from the emitter.
69
72
  """
70
73
  _log.debug("Closing SMBus on bus %s", self._bus)
71
- try:
72
- self._smbus.close()
73
- except TimeoutError as err:
74
- _log.error("Timeout closing SMBus '%s': %s", self._bus, err)
75
- except OSError as err:
76
- _log.error("OS Error closing SMBus '%s': %s", self._bus, err)
77
- except Exception:
78
- _log.error("Unexpected error closing SMBus '%s'", self._bus, exc_info=True)
79
- finally:
80
- self.emitter.emit("close", self)
81
- self.emitter.off("close")
74
+ with self._lock:
75
+ if self._closed:
76
+ return
77
+ self._closed = True
78
+ try:
79
+ self._smbus.close()
80
+ except TimeoutError as err:
81
+ _log.error("Timeout closing SMBus '%s': %s", self._bus, err)
82
+ except OSError as err:
83
+ _log.error("OS Error closing SMBus '%s': %s", self._bus, err)
84
+ except Exception:
85
+ _log.error(
86
+ "Unexpected error closing SMBus '%s'", self._bus, exc_info=True
87
+ )
88
+ # Manager callbacks can acquire their own lock while another caller is
89
+ # opening a bus. Do not hold the transaction lock across callbacks.
90
+ self.emitter.emit("close", self)
91
+ self.emitter.off("close")
92
+
93
+ def _ensure_open(self) -> None:
94
+ if self._closed:
95
+ raise RuntimeError("I2C bus is closed")
82
96
 
83
97
  @RETRY_DECORATOR
84
98
  def enable_pec(self, enable: bool = False) -> None:
@@ -89,7 +103,9 @@ class I2CBus(SMBusABC):
89
103
  enable: Whether to enable PEC.
90
104
  """
91
105
  _log.debug("Setting PEC to %s", enable)
92
- self._smbus.enable_pec(enable)
106
+ with self._lock:
107
+ self._ensure_open()
108
+ self._smbus.enable_pec(enable)
93
109
 
94
110
  @RETRY_DECORATOR
95
111
  def write_quick(self, i2c_addr: int, force: Optional[bool] = None) -> None:
@@ -101,7 +117,9 @@ class I2CBus(SMBusABC):
101
117
  force: Optional override for force behavior.
102
118
  """
103
119
  _log.debug("write_quick: addr=%s, force=%s", i2c_addr, force)
104
- self._smbus.write_quick(i2c_addr, force)
120
+ with self._lock:
121
+ self._ensure_open()
122
+ self._smbus.write_quick(i2c_addr, force)
105
123
 
106
124
  @RETRY_DECORATOR
107
125
  def read_byte(self, i2c_addr: int, force: Optional[bool] = None) -> int:
@@ -116,7 +134,9 @@ class I2CBus(SMBusABC):
116
134
  The byte value read from the device.
117
135
  """
118
136
  _log.debug("read_byte: addr=%s, force=%s", i2c_addr, force)
119
- result = self._smbus.read_byte(i2c_addr, force)
137
+ with self._lock:
138
+ self._ensure_open()
139
+ result = self._smbus.read_byte(i2c_addr, force)
120
140
  _log.debug("read_byte result: %s", result)
121
141
  return result
122
142
 
@@ -133,7 +153,9 @@ class I2CBus(SMBusABC):
133
153
  force: Optional override for force behavior.
134
154
  """
135
155
  _log.debug("write_byte: addr=%s, value=%s, force=%s", i2c_addr, value, force)
136
- self._smbus.write_byte(i2c_addr, value, force)
156
+ with self._lock:
157
+ self._ensure_open()
158
+ self._smbus.write_byte(i2c_addr, value, force)
137
159
 
138
160
  @RETRY_DECORATOR
139
161
  def read_byte_data(
@@ -153,7 +175,9 @@ class I2CBus(SMBusABC):
153
175
  _log.debug(
154
176
  "Read_byte_data: addr=%s, register=%s, force=%s", i2c_addr, register, force
155
177
  )
156
- result = self._smbus.read_byte_data(i2c_addr, register, force)
178
+ with self._lock:
179
+ self._ensure_open()
180
+ result = self._smbus.read_byte_data(i2c_addr, register, force)
157
181
  _log.debug("read_byte_data result: %s", result)
158
182
  return result
159
183
 
@@ -177,7 +201,9 @@ class I2CBus(SMBusABC):
177
201
  value,
178
202
  force,
179
203
  )
180
- self._smbus.write_byte_data(i2c_addr, register, value, force)
204
+ with self._lock:
205
+ self._ensure_open()
206
+ self._smbus.write_byte_data(i2c_addr, register, value, force)
181
207
 
182
208
  @RETRY_DECORATOR
183
209
  def read_word_data(
@@ -197,7 +223,9 @@ class I2CBus(SMBusABC):
197
223
  _log.debug(
198
224
  "read_word_data: addr=%s, register=%s, force=%s", i2c_addr, register, force
199
225
  )
200
- result = self._smbus.read_word_data(i2c_addr, register, force)
226
+ with self._lock:
227
+ self._ensure_open()
228
+ result = self._smbus.read_word_data(i2c_addr, register, force)
201
229
  _log.debug("read_word_data result: %s", result)
202
230
  return result
203
231
 
@@ -221,7 +249,9 @@ class I2CBus(SMBusABC):
221
249
  value,
222
250
  force,
223
251
  )
224
- self._smbus.write_word_data(i2c_addr, register, value, force)
252
+ with self._lock:
253
+ self._ensure_open()
254
+ self._smbus.write_word_data(i2c_addr, register, value, force)
225
255
 
226
256
  @RETRY_DECORATOR
227
257
  def process_call(
@@ -246,7 +276,9 @@ class I2CBus(SMBusABC):
246
276
  value,
247
277
  force,
248
278
  )
249
- result = self._smbus.process_call(i2c_addr, register, value, force)
279
+ with self._lock:
280
+ self._ensure_open()
281
+ result = self._smbus.process_call(i2c_addr, register, value, force)
250
282
  _log.debug("process_call result: %s", result)
251
283
  return result
252
284
 
@@ -268,7 +300,9 @@ class I2CBus(SMBusABC):
268
300
  _log.debug(
269
301
  "read_block_data: addr=%s, register=%s, force=%s", i2c_addr, register, force
270
302
  )
271
- result = self._smbus.read_block_data(i2c_addr, register, force)
303
+ with self._lock:
304
+ self._ensure_open()
305
+ result = self._smbus.read_block_data(i2c_addr, register, force)
272
306
  _log.debug("read_block_data result: %s", result)
273
307
  return result
274
308
 
@@ -296,7 +330,9 @@ class I2CBus(SMBusABC):
296
330
  data,
297
331
  force,
298
332
  )
299
- self._smbus.write_block_data(i2c_addr, register, data, force)
333
+ with self._lock:
334
+ self._ensure_open()
335
+ self._smbus.write_block_data(i2c_addr, register, data, force)
300
336
 
301
337
  @RETRY_DECORATOR
302
338
  def block_process_call(
@@ -325,7 +361,9 @@ class I2CBus(SMBusABC):
325
361
  data,
326
362
  force,
327
363
  )
328
- result = self._smbus.block_process_call(i2c_addr, register, data, force)
364
+ with self._lock:
365
+ self._ensure_open()
366
+ result = self._smbus.block_process_call(i2c_addr, register, data, force)
329
367
  _log.debug("block_process_call result: %s", result)
330
368
  return result
331
369
 
@@ -352,7 +390,9 @@ class I2CBus(SMBusABC):
352
390
  length,
353
391
  force,
354
392
  )
355
- result = self._smbus.read_i2c_block_data(i2c_addr, register, length, force)
393
+ with self._lock:
394
+ self._ensure_open()
395
+ result = self._smbus.read_i2c_block_data(i2c_addr, register, length, force)
356
396
  _log.debug("read_i2c_block_data result: %s", result)
357
397
  return result
358
398
 
@@ -380,7 +420,9 @@ class I2CBus(SMBusABC):
380
420
  data,
381
421
  force,
382
422
  )
383
- self._smbus.write_i2c_block_data(i2c_addr, register, data, force)
423
+ with self._lock:
424
+ self._ensure_open()
425
+ self._smbus.write_i2c_block_data(i2c_addr, register, data, force)
384
426
 
385
427
  @RETRY_DECORATOR
386
428
  def i2c_rdwr(self, *i2c_msgs: "i2c_msg") -> None:
@@ -391,11 +433,15 @@ class I2CBus(SMBusABC):
391
433
  *i2c_msgs: One or more message objects describing the transactions.
392
434
  """
393
435
  _log.debug("i2c_rdwr: messages=%s", i2c_msgs)
394
- return self._smbus.i2c_rdwr(*i2c_msgs)
436
+ with self._lock:
437
+ self._ensure_open()
438
+ return self._smbus.i2c_rdwr(*i2c_msgs)
395
439
 
396
440
  def __enter__(self) -> "I2CBus":
397
441
  _log.debug("Entering I2CBus context manager")
398
- self._smbus.__enter__()
442
+ with self._lock:
443
+ self._ensure_open()
444
+ self._smbus.__enter__()
399
445
  return self
400
446
 
401
447
  def __exit__(
@@ -405,7 +451,7 @@ class I2CBus(SMBusABC):
405
451
  exc_tb: Optional[TracebackType],
406
452
  ) -> None:
407
453
  """
408
- Exit context manager and perform cleanup by delegating to the SMBus.
454
+ Close the bus and notify its manager when leaving the context.
409
455
  """
410
456
  _log.debug("Exiting I2CBus context manager")
411
- self._smbus.__exit__(exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb)
457
+ self.close()
@@ -28,7 +28,7 @@ class SMBusManager:
28
28
  def _on_bus_close(cls, closed_bus: "I2CBus") -> None:
29
29
  normalized_bus = cls._normalize_bus(closed_bus._bus)
30
30
  with cls._lock:
31
- if normalized_bus in cls._instances:
31
+ if cls._instances.get(normalized_bus) is closed_bus:
32
32
  _log.debug("Removing closed bus %s from instances", normalized_bus)
33
33
  cls._instances.pop(normalized_bus, None)
34
34
 
@@ -2,9 +2,9 @@
2
2
  This module is intended for cases where a motor is controlled directly via
3
3
  GPIO pins without I²C.
4
4
 
5
- It typically uses one or more GPIO pins to drive the motor in forward or
6
- reverse direction, and, if available, an RPi.GPIO.PWM instance for speed
7
- control.
5
+ gpiozero controls forward and reverse direction outputs, with optional PWM
6
+ on those outputs for speed control. A separate optional active-high enable
7
+ output gates the motor driver.
8
8
 
9
9
  This class is suitable when the motor driver board (e.g., a
10
10
  Waveshare/MC33886-based module) does not require or use an external PWM driver
@@ -12,7 +12,8 @@ and is controlled entirely through direct GPIO calls.
12
12
  """
13
13
 
14
14
  import logging
15
- from typing import Optional, Union, cast
15
+ from threading import RLock
16
+ from typing import Callable, Optional, Union, cast
16
17
 
17
18
  from robot_hat.data_types.config.motor import MotorDirection
18
19
  from robot_hat.interfaces.motor_abc import MotorABC
@@ -27,8 +28,8 @@ class GPIODCMotor(MotorCalibration, MotorABC):
27
28
  This implementation controls a motor using direct GPIO control. It drives the motor by toggling
28
29
  GPIO output pins for forward and reverse directions.
29
30
 
30
- If speed control is desired and available, an RPi.GPIO.PWM object can be
31
- used in conjunction with the direction pins.
31
+ gpiozero PWMOutputDevice instances provide speed control on the direction
32
+ pins when PWM is enabled.
32
33
 
33
34
  Use Case:
34
35
  - Ideal for hardware setups (like the Waveshare MC33886 module) that do not rely on a dedicated PWM
@@ -37,7 +38,8 @@ class GPIODCMotor(MotorCalibration, MotorABC):
37
38
 
38
39
  Example wiring:
39
40
  - Two GPIO pins are used for direction control (one for forward, one for reverse).
40
- - Optionally, a third GPIO pin can be used to generate PWM (using RPi.GPIO.PWM) for variable speed.
41
+ - An optional third GPIO pin is a digital active-high enable, held low
42
+ while the motor is stopped.
41
43
  """
42
44
 
43
45
  def __init__(
@@ -57,14 +59,18 @@ class GPIODCMotor(MotorCalibration, MotorABC):
57
59
  Args:
58
60
  forward_pin: GPIO pin for forward direction.
59
61
  backward_pin: GPIO pin for reverse direction.
60
- pwm_pin: PWM (enable) pin for speed control.
61
- pwm: Whether to construct PWM Output Device instances, allowing both direction
62
- and speed control.
62
+ pwm_pin: Optional digital active-high enable pin for the motor driver.
63
+ pwm: Whether to use PWM on the direction outputs for variable speed.
63
64
  calibration_direction: Initial calibration for the motor direction (+1 or -1).
64
65
  calibration_speed_offset: Adjustment for the motor speed calibration.
65
66
  name: Optional identifier for the motor for logging and debugging.
66
67
  """
67
- from gpiozero import Motor
68
+ from gpiozero import DigitalOutputDevice, Motor
69
+
70
+ self._lock = RLock()
71
+ self._closed = False
72
+ self._motor: Optional[Motor] = None
73
+ self._enable: Optional[DigitalOutputDevice] = None
68
74
 
69
75
  super().__init__(
70
76
  calibration_direction=calibration_direction,
@@ -75,9 +81,14 @@ class GPIODCMotor(MotorCalibration, MotorABC):
75
81
  self.name = name or f"F{forward_pin}-B{backward_pin}-P{pwm_pin}"
76
82
  self._speed: float = 0
77
83
  self._applied_speed: float = 0
78
- self._motor = Motor(
79
- forward=forward_pin, backward=backward_pin, enable=pwm_pin, pwm=pwm
80
- )
84
+ try:
85
+ # Hold the bridge disabled while its direction outputs are acquired.
86
+ if pwm_pin is not None:
87
+ self._enable = DigitalOutputDevice(pwm_pin, initial_value=False)
88
+ self._motor = Motor(forward=forward_pin, backward=backward_pin, pwm=pwm)
89
+ except Exception:
90
+ self.close()
91
+ raise
81
92
  _log.debug(
82
93
  f"Initialized motor {self.name} with forward_pin={forward_pin}, backward_pin={backward_pin}, pwm_pin={pwm_pin}"
83
94
  )
@@ -115,6 +126,20 @@ class GPIODCMotor(MotorCalibration, MotorABC):
115
126
  Args:
116
127
  speed: Target speed percentage within [-max_speed, max_speed].
117
128
  """
129
+ with self._lock:
130
+ if self._closed:
131
+ raise RuntimeError("Motor is closed")
132
+ try:
133
+ self._set_speed(speed)
134
+ except Exception:
135
+ try:
136
+ self.stop()
137
+ except Exception:
138
+ _log.exception("Failed to stop motor after a command failure")
139
+ raise
140
+
141
+ def _set_speed(self, speed: float) -> None:
142
+ assert self._motor is not None
118
143
  logical_speed, applied_speed = self.apply_calibration(speed, self.max_speed)
119
144
  if applied_speed == 0:
120
145
  self.stop()
@@ -122,6 +147,9 @@ class GPIODCMotor(MotorCalibration, MotorABC):
122
147
 
123
148
  applied_sign = 1 if applied_speed > 0 else -1
124
149
 
150
+ if self._enable is not None:
151
+ self._enable.off()
152
+
125
153
  if applied_sign > 0:
126
154
  command = self._motor.forward
127
155
  log_direction = "forward"
@@ -144,6 +172,9 @@ class GPIODCMotor(MotorCalibration, MotorABC):
144
172
  command(1)
145
173
  applied_speed = applied_sign * self.max_speed
146
174
 
175
+ if self._enable is not None:
176
+ self._enable.on()
177
+
147
178
  self._speed = logical_speed
148
179
  self._applied_speed = applied_speed
149
180
 
@@ -151,24 +182,72 @@ class GPIODCMotor(MotorCalibration, MotorABC):
151
182
  """
152
183
  Stop the motor.
153
184
  """
154
- _log.debug("Motor stopped.")
155
- self._motor.stop()
156
- self._speed = 0
157
- self._applied_speed = 0
185
+ with self._lock:
186
+ if self._closed:
187
+ return
188
+ actions: list[Callable[[], None]] = []
189
+ if self._enable is not None:
190
+ actions.append(self._enable.off)
191
+ if self._motor is not None:
192
+ actions.append(self._motor.stop)
193
+ first_error: Exception | None = None
194
+ for action in actions:
195
+ try:
196
+ action()
197
+ except Exception as error:
198
+ if first_error is None:
199
+ first_error = error
200
+ if first_error is not None and self._motor is not None:
201
+ from gpiozero import OutputDevice
202
+
203
+ # A composite stop can fail before reaching its second output.
204
+ for output in (
205
+ self._motor.forward_device,
206
+ self._motor.backward_device,
207
+ ):
208
+ try:
209
+ if isinstance(output, OutputDevice):
210
+ output.off()
211
+ except Exception:
212
+ _log.exception("Failed to clear motor direction output")
213
+ if first_error is not None:
214
+ raise first_error
215
+ self._speed = 0
216
+ self._applied_speed = 0
217
+ _log.debug("Motor stopped.")
158
218
 
159
219
  def close(self) -> None:
160
220
  """
161
221
  Close the underlying resources.
162
222
  """
163
- _log.debug("Closing motor.")
164
- if self._motor and hasattr(self._motor, "close"):
165
- self._motor.close()
223
+ with self._lock:
224
+ if self._closed:
225
+ return
226
+ first_error: Exception | None = None
227
+ actions: list[Callable[[], None]] = [self.stop]
228
+ if self._motor is not None:
229
+ actions.append(self._motor.close)
230
+ if self._enable is not None:
231
+ actions.append(self._enable.close)
232
+ for action in actions:
233
+ try:
234
+ action()
235
+ except Exception as error:
236
+ if first_error is None:
237
+ first_error = error
238
+ _log.exception("Failed to stop or close motor output")
239
+ self._closed = True
240
+ if first_error is not None:
241
+ raise first_error
166
242
 
167
243
  def __del__(self) -> None:
168
244
  """
169
245
  Destructor method.
170
246
  """
171
- self.close()
247
+ try:
248
+ self.close()
249
+ except Exception:
250
+ _log.exception("Failed to close motor during destruction")
172
251
 
173
252
 
174
253
  def main() -> None: