firestarter 3.2.7b1__tar.gz → 3.2.7b3__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 (212) hide show
  1. {firestarter-3.2.7b1/firestarter.egg-info → firestarter-3.2.7b3}/PKG-INFO +1 -1
  2. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/_version.py +3 -3
  3. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/channel.py +8 -15
  4. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/cli_handlers.py +45 -29
  5. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/eprom_operations.py +41 -8
  6. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/exceptions.py +3 -0
  7. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/firmware.py +85 -14
  8. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/fw_release_gate.py +80 -4
  9. {firestarter-3.2.7b1 → firestarter-3.2.7b3/firestarter.egg-info}/PKG-INFO +1 -1
  10. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter.egg-info/scm_version.json +2 -2
  11. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/__snapshots__/test_characterization.ambr +5 -8
  12. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_characterization.py +33 -16
  13. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_cli_handlers.py +8 -0
  14. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_eprom_operations.py +68 -2
  15. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_firmware_install.py +7 -5
  16. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_fw_port_targeting_and_blind_install.py +9 -2
  17. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_fw_release_gate.py +249 -1
  18. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_fw_update_dead_endpoint.py +2 -2
  19. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_fw_update_path_gate.py +8 -1
  20. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_py32_channel_gating.py +51 -0
  21. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_py32_dfu.py +7 -0
  22. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/LICENSE +0 -0
  23. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/MANIFEST.in +0 -0
  24. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/README.md +0 -0
  25. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/__init__.py +0 -0
  26. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/address_parser.py +0 -0
  27. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/avr_tool.py +0 -0
  28. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/chip_resolver.py +0 -0
  29. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/chip_test.py +0 -0
  30. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/codec.py +0 -0
  31. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/compare.py +0 -0
  32. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/config.py +0 -0
  33. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/constants.py +0 -0
  34. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/data/chip_database.json +0 -0
  35. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/data/pinouts.json +0 -0
  36. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/database.py +0 -0
  37. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/diagnostic_report.py +0 -0
  38. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/dip24_2816_erase_gate.py +0 -0
  39. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/eprom_info.py +0 -0
  40. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/erase_support.py +0 -0
  41. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/flash4_erase_gate.py +0 -0
  42. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/frame_parser.py +0 -0
  43. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/hardware.py +0 -0
  44. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/ic_layout.py +0 -0
  45. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/jp5_gate.py +0 -0
  46. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/jumper_table.py +0 -0
  47. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/lock_status.py +0 -0
  48. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/log_capture.py +0 -0
  49. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/logging_utils.py +0 -0
  50. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/main.py +0 -0
  51. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/messages.py +0 -0
  52. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/page_size_gate.py +0 -0
  53. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/protection_readability.py +0 -0
  54. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/py32_dfu.py +0 -0
  55. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/sdp_capability.py +0 -0
  56. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/sdp_honesty.py +0 -0
  57. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/serial_comm.py +0 -0
  58. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/submit.py +0 -0
  59. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/timestamps.py +0 -0
  60. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/transport_counters.py +0 -0
  61. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/utils.py +0 -0
  62. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/vpp_display.py +0 -0
  63. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter/write_blank_guard.py +0 -0
  64. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter.egg-info/SOURCES.txt +0 -0
  65. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter.egg-info/dependency_links.txt +0 -0
  66. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter.egg-info/entry_points.txt +0 -0
  67. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter.egg-info/requires.txt +0 -0
  68. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter.egg-info/scm_file_list.json +0 -0
  69. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/firestarter.egg-info/top_level.txt +0 -0
  70. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/pyproject.toml +0 -0
  71. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/setup.cfg +0 -0
  72. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/__init__.py +0 -0
  73. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/conftest.py +0 -0
  74. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fake_chip.py +0 -0
  75. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/part_number_delta.json +0 -0
  76. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/report_shapes.py +0 -0
  77. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/at28c256-full-all-ok-sdp.json +0 -0
  78. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/attr01-status-axis-transport-fault.json +0 -0
  79. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/gh20-at28c256-fail.json +0 -0
  80. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/gh23-w27e257-fail.json +0 -0
  81. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/gh28-m27c512-fail.json +0 -0
  82. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/gh47-sst27sf512-pass.json +0 -0
  83. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/m27c512-full-all-ok.json +0 -0
  84. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/m27c512-full-blank-check-bad.json +0 -0
  85. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/m27c512-full-canonical-name.json +0 -0
  86. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/m27c512-full-comma-joined-name.json +0 -0
  87. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/m27c512-full-runs-1.json +0 -0
  88. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/prune03-synthesized-fingerprint-match.json +0 -0
  89. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/sst27sf512-full-all-ok.json +0 -0
  90. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/sst27sf512-six-step-readback-gated.json +0 -0
  91. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/sst27sf512-six-step.json +0 -0
  92. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/synthetic-arm4-empty-results.json +0 -0
  93. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/synthetic-arm4-no-ok.json +0 -0
  94. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/uv-slot-write-pass.json +0 -0
  95. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/reports/w27e257-full-all-ok.json +0 -0
  96. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/shape_ids.json +0 -0
  97. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/fixtures/synthetic_nonzero_chip_id.py +0 -0
  98. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/golden/build_db_part_specific_constants.json +0 -0
  99. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/golden/chip_database_field_inventory.json +0 -0
  100. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/golden/wire_dict.json +0 -0
  101. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/plan_corpus.py +0 -0
  102. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_address_parser.py +0 -0
  103. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_b15_page_size_corroboration.py +0 -0
  104. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_blast_radius_invariance.py +0 -0
  105. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_boot_block_hint.py +0 -0
  106. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_budget_failure_render.py +0 -0
  107. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_bug_characterization.py +0 -0
  108. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_build_db_constant_census.py +0 -0
  109. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_build_db_inclusion.py +0 -0
  110. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_build_db_interpret_timing.py +0 -0
  111. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_build_db_pinout_fork.py +0 -0
  112. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_build_db_sram_non_volatile.py +0 -0
  113. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_canonical_part_number.py +0 -0
  114. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_chip_database_field_inventory.py +0 -0
  115. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_chip_name_resolution.py +0 -0
  116. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_chip_resolver.py +0 -0
  117. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_chip_test.py +0 -0
  118. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_chip_test_blank_check_order.py +0 -0
  119. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_chip_test_cycle.py +0 -0
  120. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_chip_test_sdp_leg.py +0 -0
  121. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_chip_test_timing.py +0 -0
  122. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_chip_test_uv_slot_write.py +0 -0
  123. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_click_group_gate_hook.py +0 -0
  124. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_cobs.py +0 -0
  125. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_codec.py +0 -0
  126. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_codec_format_message.py +0 -0
  127. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_compare.py +0 -0
  128. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_config.py +0 -0
  129. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_config_band_refusal.py +0 -0
  130. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_connect_cost_harness.py +0 -0
  131. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_consistency_check.py +0 -0
  132. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_coverage_floor_v18.py +0 -0
  133. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_database_conversion.py +0 -0
  134. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_datasheet_overrides.py +0 -0
  135. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_decoder.py +0 -0
  136. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_derive_plan_no_drop_sweep.py +0 -0
  137. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_derive_plan_structural_sentinel.py +0 -0
  138. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_dev_group_channel_gating.py +0 -0
  139. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_dev_test_cmd.py +0 -0
  140. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_dev_tools_channel_gate.py +0 -0
  141. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_devtest_firmware_error_propagation.py +0 -0
  142. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_dfu_opcode_anchors.py +0 -0
  143. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_diagnostic_report.py +0 -0
  144. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_dip24_2816_erase_gate.py +0 -0
  145. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_endpoint_constants.py +0 -0
  146. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_eprom_database.py +0 -0
  147. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_eprom_info.py +0 -0
  148. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_erase_blank_step_nonregression.py +0 -0
  149. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_erase_flag_invariants.py +0 -0
  150. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_error_code_seam.py +0 -0
  151. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_even_block.py +0 -0
  152. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_extra_chips_supplement.py +0 -0
  153. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_flash4_erase_gate.py +0 -0
  154. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_fw_list_failure_vs_empty.py +0 -0
  155. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_fw_version_guard.py +0 -0
  156. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_fwguard.py +0 -0
  157. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_hardware.py +0 -0
  158. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_hw_revision_gate.py +0 -0
  159. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_ic_layout.py +0 -0
  160. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_info_vpp_and_erase.py +0 -0
  161. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_jp5_gate.py +0 -0
  162. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_jumper_table.py +0 -0
  163. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_lock_status_class_partition.py +0 -0
  164. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_lock_status_cli.py +0 -0
  165. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_lock_status_wire.py +0 -0
  166. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_log_capture.py +0 -0
  167. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_logging_utils.py +0 -0
  168. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_matrix_artifact.py +0 -0
  169. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_page_size_alignment_refusal.py +0 -0
  170. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_page_size_invariants.py +0 -0
  171. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_page_size_write_refusal.py +0 -0
  172. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_parse_devtest_issue.py +0 -0
  173. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_probe_spurious_setup_ack.py +0 -0
  174. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_programming_vcc_census.py +0 -0
  175. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_protect_flags_doc_measurements.py +0 -0
  176. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_protection_resolution.py +0 -0
  177. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_protection_status_catalog.py +0 -0
  178. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_protection_table_citations.py +0 -0
  179. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_protocol_not_implemented.py +0 -0
  180. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_protocol_not_implemented_production_path.py +0 -0
  181. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_provenance.py +0 -0
  182. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_pulse_us_override.py +0 -0
  183. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_py32_flash_map_host.py +0 -0
  184. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_py32_pyusb_absent.py +0 -0
  185. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_pyusb_api_surface.py +0 -0
  186. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_sdp_capability.py +0 -0
  187. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_sdp_db_invariant.py +0 -0
  188. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_sdp_honesty.py +0 -0
  189. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_serial_characterization.py +0 -0
  190. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_serial_comm.py +0 -0
  191. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_session_lease.py +0 -0
  192. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_setup_refusals.py +0 -0
  193. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_submit.py +0 -0
  194. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_timestamps.py +0 -0
  195. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_transport_counters.py +0 -0
  196. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_utils.py +0 -0
  197. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_uv_mask.py +0 -0
  198. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_validate_family_cmd.py +0 -0
  199. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_validate_oracle.py +0 -0
  200. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_validated_parts_regression_surface.py +0 -0
  201. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_vcc_margin_rail.py +0 -0
  202. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_vpp_decode_table.py +0 -0
  203. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_vpp_rail_classification.py +0 -0
  204. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_wire_dict_equivalence.py +0 -0
  205. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_write_blank_guard.py +0 -0
  206. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_write_blank_guard_pinning.py +0 -0
  207. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_write_guard_port_pinning.py +0 -0
  208. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_write_progress.py +0 -0
  209. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_write_response_budget.py +0 -0
  210. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_write_skip_erase_0x0d.py +0 -0
  211. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_write_skip_sdp_unlock.py +0 -0
  212. {firestarter-3.2.7b1 → firestarter-3.2.7b3}/tests/test_write_verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firestarter
3
- Version: 3.2.7b1
3
+ Version: 3.2.7b3
4
4
  Summary: EPROM programmer software for the Relatively Universal ROM Programmer
5
5
  Author-email: Henrik Olsson <henols@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/henols/firestarter_app
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '3.2.7b1'
22
- __version_tuple__ = version_tuple = (3, 2, 7, 'b1')
21
+ __version__ = version = '3.2.7b3'
22
+ __version_tuple__ = version_tuple = (3, 2, 7, 'b3')
23
23
 
24
- __commit_id__ = commit_id = 'g0159d48'
24
+ __commit_id__ = commit_id = 'g85b7152'
@@ -59,23 +59,16 @@ def is_prerelease_build() -> bool:
59
59
  """True when this build came off `beta` (its own version is a pre-release).
60
60
 
61
61
  Fails **closed**: an unparseable version is treated as stable, so a gated
62
- feature stays hidden rather than leaking. This matches
63
- `_maybe_auto_route_to_pre`, which also treats `InvalidVersion` as
64
- not-a-prerelease. In practice `__version__` is a literal in the package and
65
- is always valid PEP 440; dev versions (`2.0.7_dev`) parse as pre-releases and
66
- therefore keep gated features enabled while working from a checkout.
62
+ feature stays hidden rather than leaking. `_maybe_auto_route_to_pre` uses
63
+ this function, so it treats an unparseable version the same way. In
64
+ practice `__version__` is a literal in the package and is always valid
65
+ PEP 440; dev versions (`2.0.7_dev`) parse as pre-releases and therefore
66
+ keep gated features enabled while working from a checkout.
67
67
  """
68
- try:
69
- from packaging.version import InvalidVersion, Version
68
+ import firestarter as _pkg
69
+ from firestarter.fw_release_gate import is_prerelease_version
70
70
 
71
- import firestarter as _pkg
72
-
73
- try:
74
- return bool(Version(_pkg.__version__).is_prerelease)
75
- except InvalidVersion:
76
- return False
77
- except ImportError: # pragma: no cover — packaging is a hard dependency
78
- return False
71
+ return is_prerelease_version(_pkg.__version__) is True
79
72
 
80
73
 
81
74
  def is_board_available(board: str | None) -> bool:
@@ -37,6 +37,7 @@ from firestarter.channel import (
37
37
  available_boards,
38
38
  dev_command_gate_message,
39
39
  is_dev_tools_enabled,
40
+ is_prerelease_build,
40
41
  )
41
42
  from firestarter.chip_resolver import (
42
43
  describe_ambiguity,
@@ -186,6 +187,27 @@ def _complete_eprom(
186
187
  _ALL_BOARDS: tuple[str, ...] = ("uno", "uno328pb", "leonardo", "py32f071")
187
188
  _BOARD_CHOICES: list[str] = available_boards(_ALL_BOARDS)
188
189
  _PY32_ENABLED: bool = "py32f071" in _BOARD_CHOICES
190
+ # A stable build never offers pre-release firmware: `fw --pre` is refused and
191
+ # `fw --list` shows stable releases only. fw_release_gate refuses the install.
192
+ _PRE_CHANNEL_ENABLED: bool = is_prerelease_build()
193
+
194
+
195
+ def _reject_gated_option(name: str, given: bool, enabled: bool) -> None:
196
+ """The one refusal for a CLI option that this build's channel does not offer.
197
+
198
+ It reads like Click's own unknown-option error, because on this build the
199
+ option does not exist.
200
+ """
201
+ if given and not enabled:
202
+ raise click.UsageError(f"no such option: {name}")
203
+
204
+
205
+ def _reject_pre_only_option(name: str, given: bool) -> None:
206
+ """Refuse a pre-release-only CLI option on a stable build.
207
+
208
+ Reads `_PRE_CHANNEL_ENABLED` at CALL time, like `_reject_py32_only_option`.
209
+ """
210
+ _reject_gated_option(name, given, _PRE_CHANNEL_ENABLED)
189
211
 
190
212
 
191
213
  def _reject_py32_only_option(name: str, given: bool) -> None:
@@ -204,8 +226,7 @@ def _reject_py32_only_option(name: str, given: bool) -> None:
204
226
  default -- which is what makes it monkeypatchable while the Click surface
205
227
  stays frozen at import time.
206
228
  """
207
- if given and not _PY32_ENABLED:
208
- raise click.UsageError(f"no such option: {name}")
229
+ _reject_gated_option(name, given, _PY32_ENABLED)
209
230
 
210
231
 
211
232
  def map_typed_errors(f: Callable[..., Any]) -> Callable[..., Any]:
@@ -322,29 +343,15 @@ def _maybe_auto_route_to_pre(args: object) -> None:
322
343
  or getattr(args, "stable", False)
323
344
  ):
324
345
  return
325
- try:
326
- from packaging.version import InvalidVersion, Version
327
-
328
- import firestarter as _pkg
329
-
330
- try:
331
- if Version(_pkg.__version__).is_prerelease:
332
- args.pre = True # type: ignore[attr-defined]
333
- helper_logger.info(
334
- "Beta app detected — defaulting to --pre. "
335
- "Use --firmware-version X.Y.Z to pin a stable version."
336
- )
337
- except InvalidVersion:
338
- # Deliberate fail-OPEN, and deliberately the opposite polarity to
339
- # fw_release_gate, which fails CLOSED on the same unreadable input.
340
- # The asymmetry is intended: the worst case here is that the wrong
341
- # channel is picked, which the gate then judges on its merits; the
342
- # worst case there is a board flashed with firmware this host cannot
343
- # drive, recoverable only through the bootloader. Do not "align"
344
- # these two without re-reading both worst cases.
345
- pass
346
- except ImportError:
347
- pass
346
+ # An unreadable version is not a pre-release here: the worst case is the
347
+ # wrong channel, which fw_release_gate then judges and refuses (it fails
348
+ # CLOSED on the same input). Do not "align" the two polarities.
349
+ if is_prerelease_build():
350
+ args.pre = True # type: ignore[attr-defined]
351
+ helper_logger.info(
352
+ "Beta app detected — defaulting to --pre. "
353
+ "Use --firmware-version X.Y.Z to pin a stable version."
354
+ )
348
355
 
349
356
 
350
357
  def _build_op_flags(
@@ -1764,6 +1771,7 @@ def _maybe_auto_route_to_pre_click(
1764
1771
  @click.option(
1765
1772
  "--pre",
1766
1773
  is_flag=True,
1774
+ hidden=not _PRE_CHANNEL_ENABLED,
1767
1775
  help="Fetch latest pre-release firmware (mirrors pip install --pre).",
1768
1776
  )
1769
1777
  @click.option(
@@ -1772,7 +1780,11 @@ def _maybe_auto_route_to_pre_click(
1772
1780
  type=_FirmwareVersionType(),
1773
1781
  default=None,
1774
1782
  metavar="VERSION",
1775
- help="Pin exact firmware version (e.g. 3.1.0, 3.1.0b2, 3.1.0rc1).",
1783
+ help=(
1784
+ "Pin exact firmware version (e.g. 3.1.0, 3.1.0b2, 3.1.0rc1)."
1785
+ if _PRE_CHANNEL_ENABLED
1786
+ else "Pin exact firmware version (e.g. 3.1.0)."
1787
+ ),
1776
1788
  )
1777
1789
  @click.option(
1778
1790
  "--stable",
@@ -1861,11 +1873,15 @@ def fw(
1861
1873
  ) -> None:
1862
1874
  """Firmware version.
1863
1875
 
1864
- ``--pre``, ``--firmware-version`` and ``--stable`` are mutually exclusive;
1865
- passing more than one is refused before anything is installed.
1876
+ The release channel options are mutually exclusive; passing more than one
1877
+ is refused before anything is installed.
1866
1878
  """
1867
1879
  app: AppContext = ctx.obj
1868
1880
 
1881
+ # Before the mutex check, so a stable build never names --pre in an error
1882
+ # other than this one.
1883
+ _reject_pre_only_option("--pre", pre)
1884
+
1869
1885
  # 3-way --pre / --firmware-version / --stable mutex enforced once,
1870
1886
  # post-parse, with a deterministic error message that doesn't depend on
1871
1887
  # the user's option ordering. Raises click.UsageError → exit-2, matching
@@ -1914,7 +1930,7 @@ def fw(
1914
1930
  channel_filter: Literal["all", "pre", "stable"]
1915
1931
  if pre:
1916
1932
  channel_filter = "pre"
1917
- elif stable:
1933
+ elif stable or not _PRE_CHANNEL_ENABLED:
1918
1934
  channel_filter = "stable"
1919
1935
  else:
1920
1936
  channel_filter = "all"
@@ -16,7 +16,7 @@ import statistics
16
16
  import time
17
17
  from contextlib import contextmanager
18
18
  from pathlib import Path
19
- from typing import Callable, Dict, Tuple # noqa: UP035
19
+ from typing import BinaryIO, Callable, Dict, Tuple # noqa: UP035
20
20
 
21
21
  import tqdm
22
22
  from tqdm.contrib.logging import logging_redirect_tqdm
@@ -362,6 +362,25 @@ def _blank_expected_bytes(_offset: int, length: int) -> bytes:
362
362
  return b"\xff" * length
363
363
 
364
364
 
365
+ def _open_default_read_file(eprom_name: str) -> tuple[str, BinaryIO]:
366
+ """Create the output file `read` uses when the user gives none:
367
+ `<NAME>.bin`, or `<NAME>-<n>.bin` with the lowest free `n` when that
368
+ file exists, so a second read never overwrites the first.
369
+
370
+ Mode 'xb' claims the name and creates the file in one step, so two reads
371
+ that start together never get the same name.
372
+ """
373
+ base = eprom_name.upper()
374
+ path = f"{base}.bin"
375
+ n = 1
376
+ while True:
377
+ try:
378
+ return path, open(path, "xb")
379
+ except FileExistsError:
380
+ path = f"{base}-{n}.bin"
381
+ n += 1
382
+
383
+
365
384
  def _write_blank_guard_refusal_message(
366
385
  eprom_name: str, region_start: int, result: CompareResult
367
386
  ) -> str:
@@ -1298,14 +1317,20 @@ class EpromOperator:
1298
1317
  if not cmd_data:
1299
1318
  return False
1300
1319
 
1301
- actual_output_file = output_file or f"{eprom_name.upper()}.bin"
1302
- logger.info(
1303
- f"Reading EPROM {eprom_name.upper()}, saving to {actual_output_file}"
1304
- )
1305
- start_time = time.time()
1306
-
1320
+ actual_output_file = output_file or ""
1321
+ is_ok = False
1307
1322
  try:
1308
- with open(actual_output_file, "wb") as file_handle:
1323
+ if output_file:
1324
+ file_handle = open(output_file, "wb")
1325
+ else:
1326
+ actual_output_file, file_handle = _open_default_read_file(
1327
+ eprom_name
1328
+ )
1329
+ logger.info(
1330
+ f"Reading EPROM {eprom_name.upper()}, saving to {actual_output_file}"
1331
+ )
1332
+ start_time = time.time()
1333
+ with file_handle:
1309
1334
 
1310
1335
  def _write_to_file(address, data_chunk):
1311
1336
  file_handle.seek(address)
@@ -1326,6 +1351,14 @@ class EpromOperator:
1326
1351
  except IOError as e: # noqa: UP024
1327
1352
  logger.error(f"File I/O error with {actual_output_file}: {e}")
1328
1353
  return False
1354
+ finally:
1355
+ # A failed read with a generated name leaves no file behind,
1356
+ # so the next read takes that name again.
1357
+ if not output_file and actual_output_file and not is_ok:
1358
+ try:
1359
+ os.remove(actual_output_file)
1360
+ except OSError:
1361
+ pass
1329
1362
 
1330
1363
  def consistency_check_eprom(
1331
1364
  self,
@@ -181,6 +181,9 @@ class FirmwareReleaseRefusedError(FirmwareOperationError):
181
181
  waives it; `--force` does not, because `--force` means "reinstall the same
182
182
  version" and is also a wire flag (FLAG_FORCE).
183
183
 
184
+ Also raised when this CLI is a stable release and the firmware release is a
185
+ pre-release. Nothing waives that refusal.
186
+
184
187
  A FirmwareOperationError, so cli_handlers.map_typed_errors renders it
185
188
  verbatim through the existing FirmwareOperationError arm. No new arm.
186
189
  """
@@ -39,7 +39,12 @@ from firestarter.exceptions import (
39
39
  ProgrammerNotFoundError,
40
40
  SerialError,
41
41
  )
42
- from firestarter.fw_release_gate import require_installable_release
42
+ from firestarter.fw_release_gate import (
43
+ feature_version,
44
+ is_prerelease_version,
45
+ matches_stable_cli,
46
+ require_installable_release,
47
+ )
43
48
  from firestarter.serial_comm import SerialCommunicator
44
49
  from firestarter.timestamps import iso_text_to_cet
45
50
 
@@ -331,15 +336,21 @@ class FirmwareManager:
331
336
  channel: Literal["stable", "pre", "pinned"] = "stable",
332
337
  version: str | None = None,
333
338
  board: str = "uno",
339
+ app_version: str | None = None,
334
340
  ) -> Tuple[str | None, str | None]: # noqa: UP006
335
341
  """Router: returns (resolved_version, download_url) or (None, None) on failure.
336
342
 
337
- channel='stable' → delegates to fetch_latest_release_info (back-compat shim).
343
+ channel='stable' → for a stable app_version, the highest stable release on
344
+ its feature version (a stable CLI 3.2.x runs 3.2.y only);
345
+ otherwise delegates to fetch_latest_release_info.
338
346
  channel='pre' → paginates /releases, takes the highest non-draft release by
339
347
  PEP 440, stable or pre-release; falls back to stable if none.
340
348
  channel='pinned' → fetches /releases/tags/{version} directly.
341
349
  """ # noqa: E501
342
350
  if channel == "stable":
351
+ app_feature = feature_version(app_version)
352
+ if is_prerelease_version(app_version) is False and app_feature:
353
+ return self._fetch_matching_stable_release_info(app_feature, board)
343
354
  return self.fetch_latest_release_info(board=board)
344
355
 
345
356
  elif channel == "pinned":
@@ -413,6 +424,44 @@ class FirmwareManager:
413
424
  )
414
425
  return None, None
415
426
 
427
+ def _fetch_matching_stable_release_info(
428
+ self, app_feature: tuple[int, int], board: str
429
+ ) -> Tuple[str | None, str | None]: # noqa: UP006
430
+ """The highest non-draft stable release whose feature version is app_feature."""
431
+ line = f"{app_feature[0]}.{app_feature[1]}.x"
432
+ try:
433
+ all_releases = self._fetch_all_releases()
434
+ except requests.RequestException as e:
435
+ logger.error(f"Failed to fetch firmware releases: {e}")
436
+ return None, None
437
+
438
+ candidates = []
439
+ for r in all_releases:
440
+ if r.get("draft"):
441
+ continue
442
+ try:
443
+ tag_version = Version(r.get("tag_name", ""))
444
+ except InvalidVersion:
445
+ continue
446
+ if tag_version.is_prerelease:
447
+ continue
448
+ if (tag_version.major, tag_version.minor) == app_feature:
449
+ candidates.append((tag_version, r))
450
+
451
+ if not candidates:
452
+ logger.error(f"No stable firmware release {line} was found.")
453
+ return None, None
454
+
455
+ _, picked = max(candidates, key=lambda t: t[0])
456
+ download_url = _pick_asset(picked.get("assets", []), board)
457
+ if not download_url:
458
+ logger.error(
459
+ f"Release {picked.get('tag_name')!r} has no asset "
460
+ f"{_asset_label(board)} for board {board!r}."
461
+ )
462
+ return None, None
463
+ return picked.get("tag_name"), download_url
464
+
416
465
  def list_releases(
417
466
  self,
418
467
  channel_filter: Literal["all", "pre", "stable"] = "all",
@@ -847,8 +896,17 @@ class FirmwareManager:
847
896
  self._hint_dfu_board()
848
897
  return False
849
898
 
899
+ if app_version is None:
900
+ # Dereferenced at call time, not bound at import, so a test can
901
+ # monkeypatch firestarter.__version__ without reloading this module.
902
+ import firestarter as _pkg
903
+
904
+ app_version = _pkg.__version__
850
905
  latest_version, download_url = self.fetch_release_info(
851
- channel=channel, version=pinned_version, board=board_to_use
906
+ channel=channel,
907
+ version=pinned_version,
908
+ board=board_to_use,
909
+ app_version=app_version,
852
910
  )
853
911
 
854
912
  # Guard the release tag where it is BORN, not on each branch that
@@ -866,12 +924,9 @@ class FirmwareManager:
866
924
  # onto the pre channel, which is the channel most likely to be ahead of
867
925
  # it. That raises how often this refusal fires. Do not "fix" the auto
868
926
  # route to avoid the refusal -- the refusal is the point.
869
- if app_version is None:
870
- # Dereferenced at call time, not bound at import, so a test can
871
- # monkeypatch firestarter.__version__ without reloading this module.
872
- import firestarter as _pkg
873
-
874
- app_version = _pkg.__version__
927
+ #
928
+ # The same call refuses a pre-release on a stable CLI. That covers a
929
+ # library caller that asks a stable CLI for channel="pre".
875
930
  require_installable_release(
876
931
  app_version,
877
932
  latest_version,
@@ -917,8 +972,22 @@ class FirmwareManager:
917
972
  "only guard."
918
973
  )
919
974
 
975
+ # A stable CLI runs a stable release on its own feature version only. A
976
+ # board on anything else is never up to date, even when its version is
977
+ # higher: a stable 3.2.6 CLI moves a 3.2.3b1 board to 3.2.2.
978
+ mismatched = bool(
979
+ current_version
980
+ and is_prerelease_version(app_version) is False
981
+ and not matches_stable_cli(app_version, current_version)
982
+ )
920
983
  is_up_to_date = False
921
- if current_version and latest_version:
984
+ if mismatched:
985
+ logger.warning(
986
+ f"Firmware {current_version} does not match CLI {app_version}. "
987
+ "A stable CLI uses a stable firmware release with the same "
988
+ "major.minor version."
989
+ )
990
+ elif current_version and latest_version:
922
991
  is_up_to_date = self._compare_versions(current_version, latest_version)
923
992
 
924
993
  if is_up_to_date and not force_install:
@@ -946,10 +1015,12 @@ class FirmwareManager:
946
1015
  elif (
947
1016
  not is_up_to_date and current_version and latest_version
948
1017
  ): # No flags, but update available
949
- if Confirm.ask(
950
- f"New firmware {latest_version} available for {board_to_use} (current: {current_version}). Update now?", # noqa: E501
951
- default=False,
952
- ):
1018
+ prompt = (
1019
+ f"Install firmware {latest_version} for {board_to_use} (current: {current_version})?" # noqa: E501
1020
+ if mismatched
1021
+ else f"New firmware {latest_version} available for {board_to_use} (current: {current_version}). Update now?" # noqa: E501
1022
+ )
1023
+ if Confirm.ask(prompt, default=False):
953
1024
  should_install_now = True
954
1025
  else:
955
1026
  logger.info("Firmware update cancelled by user.")
@@ -16,6 +16,16 @@ The rule is the FEATURE version, (major, minor). The patch number is ignored,
16
16
  and so is the pre-release suffix: 3.1.0b2 and 3.1.0b5 are one feature version.
17
17
  A release is refused when its feature version is ABOVE this CLI's.
18
18
 
19
+ A stable CLI also refuses every pre-release firmware. A stable CLI is a final
20
+ PEP 440 release; a pre-release firmware is a b/rc/dev tag. This rule ignores the
21
+ feature version, and nothing waives it: a stable user gets stable firmware only.
22
+ A pre-release CLI (a b/rc tag, or a dev checkout) installs either.
23
+
24
+ A stable CLI also refuses a release BELOW its feature version. A stable CLI
25
+ 3.2.x runs stable firmware 3.2.y only (`matches_stable_cli`). Nothing waives
26
+ this refusal either. `firmware.fetch_release_info` picks the highest matching
27
+ stable release for a stable CLI, so `fw --install` installs a matching release.
28
+
19
29
  The reverse pairing -- a CLI newer than the firmware -- is NOT gated here.
20
30
  `_maybe_auto_route_to_pre` in cli_handlers.py steers channel selection away from
21
31
  it. The two checks cover opposite directions and neither replaces the other.
@@ -23,7 +33,7 @@ it. The two checks cover opposite directions and neither replaces the other.
23
33
  Polarity: fail closed. A false pass flashes a board this host cannot drive, and
24
34
  the board must then be recovered through the bootloader. A false refusal only
25
35
  makes the install unavailable, and `firestarter fw --list` still enumerates
26
- every release. A version that cannot be read on either side is refused exactly
36
+ the releases. A version that cannot be read on either side is refused exactly
27
37
  like a version that is too new.
28
38
 
29
39
  One deliberate exception: a release version of None is NOT a refusal. With no
@@ -31,7 +41,8 @@ release there is nothing to flash, and `manage_firmware_update` already reports
31
41
  the failed fetch. Turning a GitHub outage into a version-mismatch refusal would
32
42
  be a lie. This is the one place the gate looks fail-open and is not.
33
43
 
34
- Escape: `--allow-newer-firmware`. Never an environment variable.
44
+ Escape for the feature-version refusal: `--allow-newer-firmware`. Never an
45
+ environment variable. The pre-release and older-line refusals have no escape.
35
46
 
36
47
  Unlike the pre-serial gates, the authoritative call runs AFTER the release
37
48
  metadata fetch, because the release tag is only known then. It still runs before
@@ -75,6 +86,23 @@ _UNREADABLE_FORMAT = (
75
86
  "--allow-newer-firmware. This pairing is not tested."
76
87
  )
77
88
 
89
+ _PRERELEASE_ON_STABLE_FORMAT = (
90
+ "Firmware {release} is a pre-release. This CLI is {app}, a stable release. "
91
+ "A stable CLI installs stable firmware only. "
92
+ "Refusing to install firmware {release}.\n"
93
+ "Run 'firestarter fw --install' to install the latest stable firmware.\n"
94
+ "To use pre-release firmware, install a pre-release CLI. "
95
+ "Run 'pip install --upgrade --pre firestarter'."
96
+ )
97
+
98
+ _OLDER_LINE_ON_STABLE_FORMAT = (
99
+ "Firmware {release} is feature version {release_feature}. This CLI is {app}, "
100
+ "a stable release. A stable CLI installs stable firmware {app_feature}.x only. "
101
+ "Refusing to install firmware {release}.\n"
102
+ "Run 'firestarter fw --install' to install the latest stable firmware "
103
+ "{app_feature}.x."
104
+ )
105
+
78
106
  _UNREADABLE_TEXT = "an unreadable version"
79
107
 
80
108
 
@@ -94,6 +122,31 @@ def feature_version(raw: str | None) -> tuple[int, int] | None:
94
122
  return (parsed.major, parsed.minor)
95
123
 
96
124
 
125
+ def is_prerelease_version(raw: str | None) -> bool | None:
126
+ """True for a PEP 440 pre-release (b, rc or dev), False for a final release.
127
+
128
+ None when the version cannot be read. The caller decides what that means;
129
+ the feature-version check already refuses an unreadable version.
130
+ """
131
+ if not raw:
132
+ return None
133
+ try:
134
+ return Version(raw).is_prerelease
135
+ except InvalidVersion:
136
+ return None
137
+
138
+
139
+ def matches_stable_cli(app_version: str | None, fw_version: str | None) -> bool:
140
+ """True when fw_version is a stable release on app_version's feature version.
141
+
142
+ The firmware a stable CLI runs. False when either version cannot be read.
143
+ """
144
+ if is_prerelease_version(fw_version) is not False:
145
+ return False
146
+ app = feature_version(app_version)
147
+ return app is not None and feature_version(fw_version) == app
148
+
149
+
97
150
  def is_refused(app_version: str | None, release_version: str | None) -> bool:
98
151
  """True when this CLI must not flash this release.
99
152
 
@@ -117,9 +170,32 @@ def require_installable_release(
117
170
  ) -> None:
118
171
  """Raise FirmwareReleaseRefusedError when this CLI must not flash this release.
119
172
 
120
- Returns None on pass. `allow_newer` waives the refusal; it defaults to False
121
- so a library caller that does not ask for the waiver does not get it.
173
+ Returns None on pass. `allow_newer` waives the feature-version refusal; it
174
+ defaults to False so a library caller that does not ask for the waiver does
175
+ not get it. Nothing waives the pre-release or older-line refusal on a
176
+ stable CLI.
177
+
178
+ An unreadable version on either side is not judged by the stable-CLI rules.
179
+ The feature-version check refuses it.
122
180
  """
181
+ if is_prerelease_version(app_version) is False:
182
+ if is_prerelease_version(release_version) is True:
183
+ raise FirmwareReleaseRefusedError(
184
+ _PRERELEASE_ON_STABLE_FORMAT.format(
185
+ app=app_version, release=release_version
186
+ )
187
+ )
188
+ app = feature_version(app_version)
189
+ release = feature_version(release_version)
190
+ if app and release and release < app:
191
+ raise FirmwareReleaseRefusedError(
192
+ _OLDER_LINE_ON_STABLE_FORMAT.format(
193
+ app=app_version,
194
+ release=release_version,
195
+ app_feature=f"{app[0]}.{app[1]}",
196
+ release_feature=f"{release[0]}.{release[1]}",
197
+ )
198
+ )
123
199
  if allow_newer or not is_refused(app_version, release_version):
124
200
  return
125
201
  app = feature_version(app_version)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firestarter
3
- Version: 3.2.7b1
3
+ Version: 3.2.7b3
4
4
  Summary: EPROM programmer software for the Relatively Universal ROM Programmer
5
5
  Author-email: Henrik Olsson <henols@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/henols/firestarter_app
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "3.2.7b1",
2
+ "tag": "3.2.7b3",
3
3
  "distance": 0,
4
- "node": "g0159d48",
4
+ "node": "g85b7152",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-10-28"
@@ -217,8 +217,8 @@
217
217
 
218
218
  Firmware version.
219
219
 
220
- ``--pre``, ``--firmware-version`` and ``--stable`` are mutually exclusive;
221
- passing more than one is refused before anything is installed.
220
+ The release channel options are mutually exclusive; passing more than one is
221
+ refused before anything is installed.
222
222
 
223
223
  Options:
224
224
  -i, --install Try to install the latest firmware.
@@ -257,15 +257,12 @@
257
257
 
258
258
  Firmware version.
259
259
 
260
- ``--pre``, ``--firmware-version`` and ``--stable`` are mutually exclusive;
261
- passing more than one is refused before anything is installed.
260
+ The release channel options are mutually exclusive; passing more than one is
261
+ refused before anything is installed.
262
262
 
263
263
  Options:
264
264
  -i, --install Try to install the latest firmware.
265
- --pre Fetch latest pre-release firmware (mirrors
266
- pip install --pre).
267
- --firmware-version VERSION Pin exact firmware version (e.g. 3.1.0,
268
- 3.1.0b2, 3.1.0rc1).
265
+ --firmware-version VERSION Pin exact firmware version (e.g. 3.1.0).
269
266
  --stable Explicitly select stable channel. With
270
267
  --list, filters to stable releases only.
271
268
  --list List available firmware releases for the