openbricks 2.6.2__tar.gz → 2.7.0__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 (141) hide show
  1. {openbricks-2.6.2 → openbricks-2.7.0}/CHANGELOG.md +18 -0
  2. {openbricks-2.6.2/openbricks.egg-info → openbricks-2.7.0}/PKG-INFO +1 -1
  3. {openbricks-2.6.2 → openbricks-2.7.0/openbricks.egg-info}/PKG-INFO +1 -1
  4. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/__init__.py +1 -1
  5. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/_docs/offline-docs.zip +0 -0
  6. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/cli.py +8 -0
  7. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/flash.py +38 -0
  8. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/run.py +9 -10
  9. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_cli.py +2 -0
  10. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_flash.py +78 -0
  11. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_run.py +18 -16
  12. {openbricks-2.6.2 → openbricks-2.7.0}/MANIFEST.in +0 -0
  13. {openbricks-2.6.2 → openbricks-2.7.0}/README.md +0 -0
  14. {openbricks-2.6.2 → openbricks-2.7.0}/examples/color_drive.py +0 -0
  15. {openbricks-2.6.2 → openbricks-2.7.0}/examples/repeat_mission.py +0 -0
  16. {openbricks-2.6.2 → openbricks-2.7.0}/examples/scored_mission.py +0 -0
  17. {openbricks-2.6.2 → openbricks-2.7.0}/examples/unified_chassis_dims.py +0 -0
  18. {openbricks-2.6.2 → openbricks-2.7.0}/examples/wall_avoid.py +0 -0
  19. {openbricks-2.6.2 → openbricks-2.7.0}/examples/wander.py +0 -0
  20. {openbricks-2.6.2 → openbricks-2.7.0}/examples/wander_hardware_style.py +0 -0
  21. {openbricks-2.6.2 → openbricks-2.7.0}/examples/wander_with_gyro.py +0 -0
  22. {openbricks-2.6.2 → openbricks-2.7.0}/examples/wro_elementary_walkthrough.py +0 -0
  23. {openbricks-2.6.2 → openbricks-2.7.0}/native/_shared/drivebase_core.c +0 -0
  24. {openbricks-2.6.2 → openbricks-2.7.0}/native/_shared/drivebase_core.h +0 -0
  25. {openbricks-2.6.2 → openbricks-2.7.0}/native/_shared/motor_process_core.c +0 -0
  26. {openbricks-2.6.2 → openbricks-2.7.0}/native/_shared/motor_process_core.h +0 -0
  27. {openbricks-2.6.2 → openbricks-2.7.0}/native/_shared/observer_core.c +0 -0
  28. {openbricks-2.6.2 → openbricks-2.7.0}/native/_shared/observer_core.h +0 -0
  29. {openbricks-2.6.2 → openbricks-2.7.0}/native/_shared/servo_core.c +0 -0
  30. {openbricks-2.6.2 → openbricks-2.7.0}/native/_shared/servo_core.h +0 -0
  31. {openbricks-2.6.2 → openbricks-2.7.0}/native/_shared/st_move_core.c +0 -0
  32. {openbricks-2.6.2 → openbricks-2.7.0}/native/_shared/st_move_core.h +0 -0
  33. {openbricks-2.6.2 → openbricks-2.7.0}/native/_shared/trajectory_core.c +0 -0
  34. {openbricks-2.6.2 → openbricks-2.7.0}/native/_shared/trajectory_core.h +0 -0
  35. {openbricks-2.6.2 → openbricks-2.7.0}/native/openbricks_sim_native.c +0 -0
  36. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks.egg-info/SOURCES.txt +0 -0
  37. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks.egg-info/dependency_links.txt +0 -0
  38. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks.egg-info/entry_points.txt +0 -0
  39. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks.egg-info/requires.txt +0 -0
  40. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks.egg-info/top_level.txt +0 -0
  41. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/__main__.py +0 -0
  42. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/_nus.py +0 -0
  43. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/_ports.py +0 -0
  44. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/_signing.py +0 -0
  45. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/docs.py +0 -0
  46. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/log.py +0 -0
  47. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/mpycompile.py +0 -0
  48. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/pasteprobe.py +0 -0
  49. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/scan.py +0 -0
  50. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/servo_id.py +0 -0
  51. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/stop.py +0 -0
  52. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_dev/upload.py +0 -0
  53. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/__init__.py +0 -0
  54. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/__main__.py +0 -0
  55. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/chassis.py +0 -0
  56. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/cli.py +0 -0
  57. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/lego_mjcf.py +0 -0
  58. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/randomization.py +0 -0
  59. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/robot.py +0 -0
  60. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/runtime.py +0 -0
  61. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/shim.py +0 -0
  62. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/world.py +0 -0
  63. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/practice_line/world.xml +0 -0
  64. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/practice_walls/README.md +0 -0
  65. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/practice_walls/world.xml +0 -0
  66. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/practice_zones/README.md +0 -0
  67. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/practice_zones/world.xml +0 -0
  68. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/README.md +0 -0
  69. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/mat.png +0 -0
  70. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/props/amplifier.ldr +0 -0
  71. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/props/cable.ldr +0 -0
  72. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/props/clef.ldr +0 -0
  73. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/props/congas.ldr +0 -0
  74. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/props/guitar.ldr +0 -0
  75. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/props/keyboard.ldr +0 -0
  76. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/props/microphone.ldr +0 -0
  77. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/props/note_black.ldr +0 -0
  78. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/props/note_blue.ldr +0 -0
  79. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/props/note_green.ldr +0 -0
  80. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/props/note_red.ldr +0 -0
  81. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/props/note_white.ldr +0 -0
  82. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/props/note_yellow.ldr +0 -0
  83. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/props/speaker.ldr +0 -0
  84. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_elementary_robot_rockstars/world.xml +0 -0
  85. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_junior_heritage_heroes/README.md +0 -0
  86. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_junior_heritage_heroes/mat.png +0 -0
  87. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_junior_heritage_heroes/props/artefact.ldr +0 -0
  88. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_junior_heritage_heroes/props/barrier.ldr +0 -0
  89. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_junior_heritage_heroes/props/dirt.ldr +0 -0
  90. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_junior_heritage_heroes/props/parrot.ldr +0 -0
  91. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_junior_heritage_heroes/props/red_tower.ldr +0 -0
  92. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_junior_heritage_heroes/props/visitor.ldr +0 -0
  93. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_junior_heritage_heroes/props/yellow_tower.ldr +0 -0
  94. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_junior_heritage_heroes/world.xml +0 -0
  95. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_senior_mosaic_masters/README.md +0 -0
  96. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_senior_mosaic_masters/mat.png +0 -0
  97. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_senior_mosaic_masters/mosaic_frame.stl +0 -0
  98. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_senior_mosaic_masters/props/cement_bowl.ldr +0 -0
  99. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_senior_mosaic_masters/props/cement_element.ldr +0 -0
  100. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_senior_mosaic_masters/props/masonry_trowel.ldr +0 -0
  101. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_senior_mosaic_masters/props/mosaic_tile.ldr +0 -0
  102. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_senior_mosaic_masters/props/rect_trowel.ldr +0 -0
  103. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_senior_mosaic_masters/props/senior_barrier_black_red.ldr +0 -0
  104. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_senior_mosaic_masters/props/senior_barrier_red_blue.ldr +0 -0
  105. {openbricks-2.6.2 → openbricks-2.7.0}/openbricks_sim/worlds/wro_2026_senior_mosaic_masters/world.xml +0 -0
  106. {openbricks-2.6.2 → openbricks-2.7.0}/pyproject.toml +0 -0
  107. {openbricks-2.6.2 → openbricks-2.7.0}/setup.cfg +0 -0
  108. {openbricks-2.6.2 → openbricks-2.7.0}/setup.py +0 -0
  109. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_chassis.py +0 -0
  110. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_cibuildwheel_config.py +0 -0
  111. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_color_sensor.py +0 -0
  112. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_distance_sensor.py +0 -0
  113. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_docs_cli.py +0 -0
  114. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_imu.py +0 -0
  115. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_lego_mjcf.py +0 -0
  116. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_line_follow_sim.py +0 -0
  117. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_log.py +0 -0
  118. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_mpycompile.py +0 -0
  119. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_native_drivebase.py +0 -0
  120. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_native_motor_process.py +0 -0
  121. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_native_observer.py +0 -0
  122. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_native_servo.py +0 -0
  123. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_native_trajectory.py +0 -0
  124. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_no_stale_names.py +0 -0
  125. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_nus.py +0 -0
  126. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_pasteprobe.py +0 -0
  127. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_project_urls.py +0 -0
  128. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_randomization.py +0 -0
  129. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_release_tags.py +0 -0
  130. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_robot.py +0 -0
  131. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_runtime.py +0 -0
  132. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_scan.py +0 -0
  133. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_sdist_build.py +0 -0
  134. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_servo_id.py +0 -0
  135. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_shim.py +0 -0
  136. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_signing.py +0 -0
  137. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_sim_cli.py +0 -0
  138. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_stop.py +0 -0
  139. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_upload.py +0 -0
  140. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_wheel_bundles_worlds.py +0 -0
  141. {openbricks-2.6.2 → openbricks-2.7.0}/tests/test_world.py +0 -0
@@ -3,6 +3,24 @@
3
3
  Versions the unified `openbricks` PyPI package (CLI + MuJoCo sim).
4
4
  Firmware versions are tracked separately on the `v*` tag namespace.
5
5
 
6
+ ## 2.7.0 — run is upload-then-run; flash --with-qtr-init
7
+
8
+ Two CLI changes, both user-requested:
9
+
10
+ * **`run` stages to the button slot** (reversing the 1.97.0 run-slot
11
+ split, deliberately different from Pybricks): the script lands at
12
+ `/program.mpy` before executing, so a run that fails midway stays
13
+ rerunnable with the start button — no BLE round trip. The accepted
14
+ price: one-shot tools (calibrations, diagnostics) replace the
15
+ button program; re-upload your mission afterwards.
16
+ * **`flash --with-qtr-init`** stores a starter QTR calibration at
17
+ `/qtr.cal` (reference-bench sweep, default pins 1-10, the exact
18
+ `save_calibration` format) so line-follow examples work on a
19
+ fresh hub. Re-run `examples/qtr_calibrate.py` for your own mat
20
+ and lighting.
21
+
22
+ CLI-side: `pipx upgrade openbricks` (no reflash needed).
23
+
6
24
  ## 2.6.2 — done requires standstill; per-axis settle diagnostics
7
25
 
8
26
  Bench (the twitch that survived 2.6.0, now measured): the final
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbricks
3
- Version: 2.6.2
3
+ Version: 2.7.0
4
4
  Summary: openbricks — host-side CLI + MuJoCo-backed simulator for the openbricks MicroPython firmware. Flash, list, run, upload, log; ``openbricks sim`` for the physics simulator.
5
5
  Author: openbricks contributors
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbricks
3
- Version: 2.6.2
3
+ Version: 2.7.0
4
4
  Summary: openbricks — host-side CLI + MuJoCo-backed simulator for the openbricks MicroPython firmware. Flash, list, run, upload, log; ``openbricks sim`` for the physics simulator.
5
5
  Author: openbricks contributors
6
6
  License: MIT
@@ -1,4 +1,4 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  """openbricks — host-side CLI for flashing and running code on hubs."""
3
3
 
4
- __version__ = "2.6.2"
4
+ __version__ = "2.7.0"
@@ -77,6 +77,14 @@ def _build_parser():
77
77
  "--baud", default="460800",
78
78
  help="esptool flash baud rate. Default: 460800.",
79
79
  )
80
+ p_flash.add_argument(
81
+ "--with-qtr-init", action="store_true",
82
+ help="After flashing, store a starter QTR line-sensor "
83
+ "calibration at /qtr.cal (recorded on the reference "
84
+ "bench, default pins 1-10) so line-follow examples work "
85
+ "out of the box. Re-run examples/qtr_calibrate.py for a "
86
+ "calibration measured on your own mat and lighting.",
87
+ )
80
88
  p_flash.add_argument(
81
89
  "--skip-erase", action="store_true",
82
90
  help="Skip erase_flash (faster dev loop; leaves stale NVS keys).",
@@ -364,6 +364,41 @@ def _wait_for_repl(mpremote, port, timeout_s=20):
364
364
  )
365
365
 
366
366
 
367
+ # Starter QTR calibration shipped by ``--with-qtr-init`` — a real
368
+ # bench sweep (2026-08-17, QTRX-HD-15A on the WRO mat, default pins
369
+ # 1-10; the per-element spans from that run's log). Format matches
370
+ # ``QTRArray.save_calibration``: {"pins", "min", "max"}. A starter
371
+ # file gets a fresh flash line-following immediately; heights, mats
372
+ # and lighting differ, so examples/qtr_calibrate.py remains the way
373
+ # to a calibration measured on YOUR rig.
374
+ _QTR_CAL_PATH = "/qtr.cal"
375
+ _QTR_STARTER_CAL = {
376
+ "pins": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
377
+ "min": [9346, 4048, 3888, 3728, 3984, 3888, 3824, 3968, 4593, 8658],
378
+ "max": [44922, 37849, 36024, 35816, 36056, 36536, 32888, 36552,
379
+ 40057, 43834],
380
+ }
381
+
382
+
383
+ def _write_qtr_starter_cal(mpremote, port):
384
+ """Store the starter QTR calibration on the hub filesystem and
385
+ verify it parses back — a corrupt or truncated write must fail
386
+ HERE, not as a RuntimeError in the user's first line-follow."""
387
+ import json
388
+ payload = json.dumps(_QTR_STARTER_CAL)
389
+ snippet = (
390
+ "f = open(%r, 'w'); f.write(%r); f.close(); "
391
+ "import json; d = json.load(open(%r)); "
392
+ "print('qtr-cal-ok', len(d['min']), len(d['max']))"
393
+ ) % (_QTR_CAL_PATH, payload, _QTR_CAL_PATH)
394
+ rc, out, err = _mpremote_exec(mpremote, port, snippet)
395
+ if rc != 0 or "qtr-cal-ok 10 10" not in out:
396
+ _die("failed to write starter QTR calibration:\n" + (err or out))
397
+ print("starter QTR calibration written to %s (re-run "
398
+ "examples/qtr_calibrate.py for your own mat/lighting)"
399
+ % _QTR_CAL_PATH)
400
+
401
+
367
402
  def _write_hub_name(mpremote, port, name):
368
403
  # The name already passed validation in ``_validate_name``; plain
369
404
  # bytes literal is safe.
@@ -645,6 +680,9 @@ def run(args):
645
680
  _die("verification failed: wrote %r, read back %r" % (args.name, readback))
646
681
  print("hub name %r written and verified" % readback)
647
682
 
683
+ if getattr(args, "with_qtr_init", False):
684
+ _write_qtr_starter_cal(mpremote, args.port)
685
+
648
686
  flashed_version = _write_fw_marker(mpremote, args.port,
649
687
  firmware_verdict)
650
688
 
@@ -59,16 +59,15 @@ _TARGET_PATH = "/program.py"
59
59
  # removes the other — see _compose_runner(remove_stale=...).
60
60
  _MPY_TARGET_PATH = "/program.mpy"
61
61
 
62
- # One-shot runs stage to their OWN slot (CLI >= 1.97.0): ``run`` used
63
- # to share /program.mpy with ``upload``, so every run — including
64
- # calibrations and diagnostics — silently replaced the program the
65
- # BUTTON launches. Bench 2026-08-14: upload aa.py, run
62
+ # ``run`` stages to the BUTTON slot (/program.*) — an upload-then-run
63
+ # (user decision 2026-08-17, reversing the 1.97.0 run-slot split and
64
+ # deliberately different from Pybricks): a run that fails mid-way
65
+ # leaves the program on the hub, so the start button reruns it
66
+ # without another BLE round trip. The accepted price is the 1.97.0
67
+ # hazard returning: running a calibration or diagnostic script
68
+ # replaces the button program (bench 2026-08-14: upload aa.py, run
66
69
  # qtr_calibrate, press start -> the robot invisibly re-ran the
67
- # calibration and the report was "button does nothing". The button
68
- # never reads /run.*, so the two intents can no longer clobber
69
- # each other.
70
- _RUN_TARGET_PATH = "/run.py"
71
- _RUN_MPY_TARGET_PATH = "/run.mpy"
70
+ # calibration) — re-upload your mission after one-shot tools.
72
71
 
73
72
  # First firmware whose launcher runs /program.mpy (native exec_mpy).
74
73
  _MIN_MPY_FIRMWARE = (1, 92, 0)
@@ -744,7 +743,7 @@ async def _run_session(link, name, user_bytes, mpy_bytes):
744
743
  await _enter_raw_repl(blink, link)
745
744
  try:
746
745
  target, use_mpy, remove_stale = await _pick_staging(
747
- blink, link, _RUN_MPY_TARGET_PATH, _RUN_TARGET_PATH)
746
+ blink, link)
748
747
  payload = mpy_bytes if use_mpy else user_bytes
749
748
  runner = _compose_runner(target, remove_stale)
750
749
  await _stage_file(blink, link, target, payload, name)
@@ -232,6 +232,8 @@ class DispatchTests(unittest.TestCase):
232
232
  ("stop", ["stop", "-n", "X"]),
233
233
  ("log", ["log", "-n", "X"]),
234
234
  ("servo_id", ["servo-id", "-p", "/p", "3"]),
235
+ ("pasteprobe", ["paste-probe", "-n", "X"]),
236
+ ("docs", ["docs"]),
235
237
  ]
236
238
  for mod_name, argv in cases:
237
239
  seen.clear()
@@ -524,6 +524,37 @@ class RunHappyPathTests(unittest.TestCase):
524
524
  stderr=""),
525
525
  ])
526
526
 
527
+ def test_full_flow_with_qtr_init_writes_the_cal(self):
528
+ # --with-qtr-init inserts one mpremote exec between the name
529
+ # readback and the fw marker: the /qtr.cal write+verify.
530
+ snippets = []
531
+
532
+ def _fake_call(cmd):
533
+ return 0
534
+
535
+ run_responses = iter([
536
+ MagicMock(returncode=1, stdout="", stderr="no repl"),
537
+ MagicMock(returncode=0, stdout="ok\n", stderr=""),
538
+ MagicMock(returncode=0, stdout="wrote: 'RobotA'\n", stderr=""),
539
+ MagicMock(returncode=0, stdout="RobotA\n", stderr=""),
540
+ MagicMock(returncode=0, stdout="qtr-cal-ok 10 10\n", stderr=""),
541
+ MagicMock(returncode=0, stdout="9.9.9\n", stderr=""),
542
+ MagicMock(returncode=0, stdout="marker: 9.9.9:customized\n",
543
+ stderr=""),
544
+ ])
545
+
546
+ def _fake_run(cmd, capture_output=True, text=True, timeout=None):
547
+ snippets.append(cmd[-1])
548
+ return next(run_responses)
549
+
550
+ with patch("subprocess.call", side_effect=_fake_call), \
551
+ patch("subprocess.run", side_effect=_fake_run):
552
+ rc = flash.run(self._run_args(with_qtr_init=True))
553
+
554
+ self.assertEqual(rc, 0)
555
+ self.assertTrue(any("'/qtr.cal'" in sn for sn in snippets),
556
+ "qtr cal write never composed")
557
+
527
558
  def test_full_flow_success(self):
528
559
  # subprocess.call for erase_flash / write_flash / final reset.
529
560
  call_history = []
@@ -703,6 +734,53 @@ class ToolMissingTests(unittest.TestCase):
703
734
  self.assertIn("esptool not found", str(ctx.exception))
704
735
 
705
736
 
737
+ class QtrStarterCalTests(unittest.TestCase):
738
+ """``flash --with-qtr-init`` stores the reference-bench starter
739
+ calibration at /qtr.cal in exactly the format
740
+ ``QTRArray.save_calibration`` writes — pins list included, since
741
+ ``load_calibration`` refuses a file recorded for other wiring."""
742
+
743
+ def test_snippet_writes_valid_cal_json(self):
744
+ import json as _json
745
+ captured = {}
746
+
747
+ def _fake_run(cmd, capture_output=True, text=True, timeout=None):
748
+ captured["snippet"] = cmd[-1]
749
+ return MagicMock(returncode=0,
750
+ stdout="qtr-cal-ok 10 10\n", stderr="")
751
+
752
+ with patch("subprocess.run", side_effect=_fake_run):
753
+ flash._write_qtr_starter_cal("mpremote", "/dev/ttyUSB0")
754
+
755
+ snippet = captured["snippet"]
756
+ self.assertIn("'/qtr.cal'", snippet)
757
+ # The staged payload is the repr of a JSON document with the
758
+ # save_calibration schema: 10 pins, 10 mins, 10 maxes, and
759
+ # every span positive (min < max) — a starter file that fails
760
+ # load_calibration would be worse than no file.
761
+ start = snippet.index("f.write(") + len("f.write(")
762
+ payload_repr = snippet[start:snippet.index("); f.close()")]
763
+ payload = eval(payload_repr) # repr of a str literal
764
+ data = _json.loads(payload)
765
+ self.assertEqual(data["pins"], list(range(1, 11)))
766
+ self.assertEqual(len(data["min"]), 10)
767
+ self.assertEqual(len(data["max"]), 10)
768
+ for lo, hi in zip(data["min"], data["max"]):
769
+ self.assertTrue(0 < lo < hi <= 65535, (lo, hi))
770
+
771
+ def test_verification_failure_dies(self):
772
+ def _fake_run(cmd, capture_output=True, text=True, timeout=None):
773
+ return MagicMock(returncode=0, stdout="garbage\n", stderr="")
774
+
775
+ with patch("subprocess.run", side_effect=_fake_run):
776
+ try:
777
+ flash._write_qtr_starter_cal("mpremote", "/p")
778
+ except flash.FlashError as e:
779
+ self.assertIn("starter QTR calibration", str(e))
780
+ else:
781
+ self.fail("must die on unverified write")
782
+
783
+
706
784
  class NameWriteSnippetTests(unittest.TestCase):
707
785
  """The mpremote ``exec`` snippet must embed the name in a form that
708
786
  ``openbricks._read_hub_name`` will accept back (bytes via set_blob)."""
@@ -339,13 +339,13 @@ class RunFlowTests(unittest.TestCase):
339
339
 
340
340
  self.assertEqual(rc, 0)
341
341
  joined = b"".join(fake.writes)
342
- # The SOURCE crossed the link, targeting the run slot's
343
- # source path, and no sibling delete was composed. The
344
- # button's /program.* is never touched.
342
+ # The SOURCE crossed the link, targeting the BUTTON slot's
343
+ # source path (run = upload-then-run since 2.7.0, so a failed
344
+ # run stays button-rerunnable), and no sibling delete was
345
+ # composed on the source path.
345
346
  self.assertIn(b"hello from hub", joined)
346
- self.assertIn(b"'/run.py'", joined)
347
- self.assertNotIn(b"'/program.py'", joined)
348
- self.assertNotIn(b"'/program.mpy'", joined)
347
+ self.assertIn(b"'/program.py'", joined)
348
+ self.assertNotIn(b"'/run.py'", joined)
349
349
  self.assertNotIn(b"os.remove", joined)
350
350
  self.assertIn("predates precompiled", err.getvalue())
351
351
  self.assertIn("1.91.1", err.getvalue())
@@ -367,10 +367,10 @@ class RunFlowTests(unittest.TestCase):
367
367
  self.assertNotIn("===", out.getvalue())
368
368
 
369
369
  joined = b"".join(fake.writes)
370
- # Confirm the staging writes the RUN slot and calls
371
- # run_program on it — never the button's /program.*.
372
- self.assertIn(b"'/run.mpy'", joined)
373
- self.assertNotIn(b"'/program.py'", joined)
370
+ # Confirm the staging writes the BUTTON slot (run =
371
+ # upload-then-run since 2.7.0) and calls run_program on it.
372
+ self.assertIn(b"'/program.mpy'", joined)
373
+ self.assertNotIn(b"'/run.mpy'", joined)
374
374
  self.assertIn(b"launcher.run_program", joined)
375
375
  # Confirm the control-byte sequence of raw-paste mode.
376
376
  self.assertIn(b"\x03\x03", joined) # Ctrl-C interrupt
@@ -522,11 +522,13 @@ class InlineCommandTests(unittest.TestCase):
522
522
  self.assertIn(b"M\\x06", joined) # .mpy header inside the
523
523
  self.assertNotIn(b"print('hello')", joined) # staged repr
524
524
  self.assertIn(b"launcher.run_program", joined)
525
- # One-shot runs stage to their OWN slot (1.97.0) — the
526
- # BUTTON's /program.mpy must never appear in a run session.
527
- self.assertIn(b"'/run.mpy'", joined)
528
- self.assertNotIn(b"'/program.mpy'", joined)
529
- self.assertNotIn(b"os.remove('/program.py')", joined)
525
+ # run stages to the BUTTON slot (2.7.0: upload-then-run, so
526
+ # a failed run stays rerunnable by start press) — the .mpy
527
+ # staging removes a stale source sibling so the launcher's
528
+ # source-wins resolution can't run old code.
529
+ self.assertIn(b"'/program.mpy'", joined)
530
+ self.assertNotIn(b"'/run.mpy'", joined)
531
+ self.assertIn(b"os.remove('/program.py')", joined)
530
532
 
531
533
 
532
534
  def test_connect_failure_propagates_as_run_error(self):
@@ -857,7 +859,7 @@ class HostInterruptForwardingTests(unittest.TestCase):
857
859
  return None
858
860
 
859
861
  async def _stub_pick(blink, l, *slot_pair):
860
- return run_mod._RUN_TARGET_PATH, False, None
862
+ return run_mod._TARGET_PATH, False, None
861
863
 
862
864
  patches = [
863
865
  ("_enter_raw_repl", _raw_repl),
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes