firestarter 3.2.7b2__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.7b2/firestarter.egg-info → firestarter-3.2.7b3}/PKG-INFO +1 -1
  2. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/_version.py +3 -3
  3. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/channel.py +8 -15
  4. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/cli_handlers.py +9 -23
  5. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/eprom_operations.py +33 -14
  6. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/firmware.py +82 -14
  7. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/fw_release_gate.py +45 -11
  8. {firestarter-3.2.7b2 → firestarter-3.2.7b3/firestarter.egg-info}/PKG-INFO +1 -1
  9. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter.egg-info/scm_version.json +2 -2
  10. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_eprom_operations.py +62 -9
  11. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_firmware_install.py +7 -5
  12. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_fw_port_targeting_and_blind_install.py +2 -2
  13. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_fw_release_gate.py +139 -1
  14. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_fw_update_dead_endpoint.py +2 -2
  15. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_fw_update_path_gate.py +1 -1
  16. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/LICENSE +0 -0
  17. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/MANIFEST.in +0 -0
  18. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/README.md +0 -0
  19. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/__init__.py +0 -0
  20. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/address_parser.py +0 -0
  21. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/avr_tool.py +0 -0
  22. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/chip_resolver.py +0 -0
  23. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/chip_test.py +0 -0
  24. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/codec.py +0 -0
  25. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/compare.py +0 -0
  26. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/config.py +0 -0
  27. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/constants.py +0 -0
  28. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/data/chip_database.json +0 -0
  29. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/data/pinouts.json +0 -0
  30. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/database.py +0 -0
  31. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/diagnostic_report.py +0 -0
  32. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/dip24_2816_erase_gate.py +0 -0
  33. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/eprom_info.py +0 -0
  34. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/erase_support.py +0 -0
  35. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/exceptions.py +0 -0
  36. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/flash4_erase_gate.py +0 -0
  37. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/frame_parser.py +0 -0
  38. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/hardware.py +0 -0
  39. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/ic_layout.py +0 -0
  40. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/jp5_gate.py +0 -0
  41. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/jumper_table.py +0 -0
  42. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/lock_status.py +0 -0
  43. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/log_capture.py +0 -0
  44. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/logging_utils.py +0 -0
  45. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/main.py +0 -0
  46. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/messages.py +0 -0
  47. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/page_size_gate.py +0 -0
  48. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/protection_readability.py +0 -0
  49. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/py32_dfu.py +0 -0
  50. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/sdp_capability.py +0 -0
  51. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/sdp_honesty.py +0 -0
  52. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/serial_comm.py +0 -0
  53. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/submit.py +0 -0
  54. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/timestamps.py +0 -0
  55. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/transport_counters.py +0 -0
  56. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/utils.py +0 -0
  57. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/vpp_display.py +0 -0
  58. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter/write_blank_guard.py +0 -0
  59. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter.egg-info/SOURCES.txt +0 -0
  60. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter.egg-info/dependency_links.txt +0 -0
  61. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter.egg-info/entry_points.txt +0 -0
  62. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter.egg-info/requires.txt +0 -0
  63. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter.egg-info/scm_file_list.json +0 -0
  64. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/firestarter.egg-info/top_level.txt +0 -0
  65. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/pyproject.toml +0 -0
  66. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/setup.cfg +0 -0
  67. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/__init__.py +0 -0
  68. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/__snapshots__/test_characterization.ambr +0 -0
  69. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/conftest.py +0 -0
  70. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fake_chip.py +0 -0
  71. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/part_number_delta.json +0 -0
  72. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/report_shapes.py +0 -0
  73. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/at28c256-full-all-ok-sdp.json +0 -0
  74. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/attr01-status-axis-transport-fault.json +0 -0
  75. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/gh20-at28c256-fail.json +0 -0
  76. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/gh23-w27e257-fail.json +0 -0
  77. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/gh28-m27c512-fail.json +0 -0
  78. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/gh47-sst27sf512-pass.json +0 -0
  79. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/m27c512-full-all-ok.json +0 -0
  80. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/m27c512-full-blank-check-bad.json +0 -0
  81. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/m27c512-full-canonical-name.json +0 -0
  82. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/m27c512-full-comma-joined-name.json +0 -0
  83. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/m27c512-full-runs-1.json +0 -0
  84. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/prune03-synthesized-fingerprint-match.json +0 -0
  85. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/sst27sf512-full-all-ok.json +0 -0
  86. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/sst27sf512-six-step-readback-gated.json +0 -0
  87. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/sst27sf512-six-step.json +0 -0
  88. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/synthetic-arm4-empty-results.json +0 -0
  89. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/synthetic-arm4-no-ok.json +0 -0
  90. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/uv-slot-write-pass.json +0 -0
  91. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/reports/w27e257-full-all-ok.json +0 -0
  92. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/shape_ids.json +0 -0
  93. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/fixtures/synthetic_nonzero_chip_id.py +0 -0
  94. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/golden/build_db_part_specific_constants.json +0 -0
  95. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/golden/chip_database_field_inventory.json +0 -0
  96. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/golden/wire_dict.json +0 -0
  97. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/plan_corpus.py +0 -0
  98. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_address_parser.py +0 -0
  99. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_b15_page_size_corroboration.py +0 -0
  100. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_blast_radius_invariance.py +0 -0
  101. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_boot_block_hint.py +0 -0
  102. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_budget_failure_render.py +0 -0
  103. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_bug_characterization.py +0 -0
  104. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_build_db_constant_census.py +0 -0
  105. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_build_db_inclusion.py +0 -0
  106. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_build_db_interpret_timing.py +0 -0
  107. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_build_db_pinout_fork.py +0 -0
  108. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_build_db_sram_non_volatile.py +0 -0
  109. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_canonical_part_number.py +0 -0
  110. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_characterization.py +0 -0
  111. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_chip_database_field_inventory.py +0 -0
  112. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_chip_name_resolution.py +0 -0
  113. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_chip_resolver.py +0 -0
  114. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_chip_test.py +0 -0
  115. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_chip_test_blank_check_order.py +0 -0
  116. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_chip_test_cycle.py +0 -0
  117. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_chip_test_sdp_leg.py +0 -0
  118. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_chip_test_timing.py +0 -0
  119. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_chip_test_uv_slot_write.py +0 -0
  120. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_cli_handlers.py +0 -0
  121. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_click_group_gate_hook.py +0 -0
  122. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_cobs.py +0 -0
  123. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_codec.py +0 -0
  124. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_codec_format_message.py +0 -0
  125. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_compare.py +0 -0
  126. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_config.py +0 -0
  127. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_config_band_refusal.py +0 -0
  128. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_connect_cost_harness.py +0 -0
  129. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_consistency_check.py +0 -0
  130. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_coverage_floor_v18.py +0 -0
  131. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_database_conversion.py +0 -0
  132. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_datasheet_overrides.py +0 -0
  133. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_decoder.py +0 -0
  134. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_derive_plan_no_drop_sweep.py +0 -0
  135. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_derive_plan_structural_sentinel.py +0 -0
  136. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_dev_group_channel_gating.py +0 -0
  137. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_dev_test_cmd.py +0 -0
  138. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_dev_tools_channel_gate.py +0 -0
  139. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_devtest_firmware_error_propagation.py +0 -0
  140. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_dfu_opcode_anchors.py +0 -0
  141. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_diagnostic_report.py +0 -0
  142. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_dip24_2816_erase_gate.py +0 -0
  143. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_endpoint_constants.py +0 -0
  144. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_eprom_database.py +0 -0
  145. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_eprom_info.py +0 -0
  146. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_erase_blank_step_nonregression.py +0 -0
  147. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_erase_flag_invariants.py +0 -0
  148. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_error_code_seam.py +0 -0
  149. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_even_block.py +0 -0
  150. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_extra_chips_supplement.py +0 -0
  151. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_flash4_erase_gate.py +0 -0
  152. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_fw_list_failure_vs_empty.py +0 -0
  153. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_fw_version_guard.py +0 -0
  154. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_fwguard.py +0 -0
  155. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_hardware.py +0 -0
  156. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_hw_revision_gate.py +0 -0
  157. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_ic_layout.py +0 -0
  158. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_info_vpp_and_erase.py +0 -0
  159. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_jp5_gate.py +0 -0
  160. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_jumper_table.py +0 -0
  161. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_lock_status_class_partition.py +0 -0
  162. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_lock_status_cli.py +0 -0
  163. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_lock_status_wire.py +0 -0
  164. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_log_capture.py +0 -0
  165. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_logging_utils.py +0 -0
  166. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_matrix_artifact.py +0 -0
  167. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_page_size_alignment_refusal.py +0 -0
  168. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_page_size_invariants.py +0 -0
  169. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_page_size_write_refusal.py +0 -0
  170. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_parse_devtest_issue.py +0 -0
  171. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_probe_spurious_setup_ack.py +0 -0
  172. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_programming_vcc_census.py +0 -0
  173. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_protect_flags_doc_measurements.py +0 -0
  174. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_protection_resolution.py +0 -0
  175. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_protection_status_catalog.py +0 -0
  176. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_protection_table_citations.py +0 -0
  177. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_protocol_not_implemented.py +0 -0
  178. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_protocol_not_implemented_production_path.py +0 -0
  179. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_provenance.py +0 -0
  180. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_pulse_us_override.py +0 -0
  181. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_py32_channel_gating.py +0 -0
  182. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_py32_dfu.py +0 -0
  183. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_py32_flash_map_host.py +0 -0
  184. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_py32_pyusb_absent.py +0 -0
  185. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_pyusb_api_surface.py +0 -0
  186. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_sdp_capability.py +0 -0
  187. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_sdp_db_invariant.py +0 -0
  188. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_sdp_honesty.py +0 -0
  189. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_serial_characterization.py +0 -0
  190. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_serial_comm.py +0 -0
  191. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_session_lease.py +0 -0
  192. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_setup_refusals.py +0 -0
  193. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_submit.py +0 -0
  194. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_timestamps.py +0 -0
  195. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_transport_counters.py +0 -0
  196. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_utils.py +0 -0
  197. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_uv_mask.py +0 -0
  198. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_validate_family_cmd.py +0 -0
  199. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_validate_oracle.py +0 -0
  200. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_validated_parts_regression_surface.py +0 -0
  201. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_vcc_margin_rail.py +0 -0
  202. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_vpp_decode_table.py +0 -0
  203. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_vpp_rail_classification.py +0 -0
  204. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_wire_dict_equivalence.py +0 -0
  205. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_write_blank_guard.py +0 -0
  206. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_write_blank_guard_pinning.py +0 -0
  207. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_write_guard_port_pinning.py +0 -0
  208. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_write_progress.py +0 -0
  209. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_write_response_budget.py +0 -0
  210. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_write_skip_erase_0x0d.py +0 -0
  211. {firestarter-3.2.7b2 → firestarter-3.2.7b3}/tests/test_write_skip_sdp_unlock.py +0 -0
  212. {firestarter-3.2.7b2 → 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.7b2
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.7b2'
22
- __version_tuple__ = version_tuple = (3, 2, 7, 'b2')
21
+ __version__ = version = '3.2.7b3'
22
+ __version_tuple__ = version_tuple = (3, 2, 7, 'b3')
23
23
 
24
- __commit_id__ = commit_id = 'g30e9def'
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:
@@ -343,29 +343,15 @@ def _maybe_auto_route_to_pre(args: object) -> None:
343
343
  or getattr(args, "stable", False)
344
344
  ):
345
345
  return
346
- try:
347
- from packaging.version import InvalidVersion, Version
348
-
349
- import firestarter as _pkg
350
-
351
- try:
352
- if Version(_pkg.__version__).is_prerelease:
353
- args.pre = True # type: ignore[attr-defined]
354
- helper_logger.info(
355
- "Beta app detected — defaulting to --pre. "
356
- "Use --firmware-version X.Y.Z to pin a stable version."
357
- )
358
- except InvalidVersion:
359
- # Deliberate fail-OPEN, and deliberately the opposite polarity to
360
- # fw_release_gate, which fails CLOSED on the same unreadable input.
361
- # The asymmetry is intended: the worst case here is that the wrong
362
- # channel is picked, which the gate then judges on its merits; the
363
- # worst case there is a board flashed with firmware this host cannot
364
- # drive, recoverable only through the bootloader. Do not "align"
365
- # these two without re-reading both worst cases.
366
- pass
367
- except ImportError:
368
- 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
+ )
369
355
 
370
356
 
371
357
  def _build_op_flags(
@@ -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,18 +362,23 @@ def _blank_expected_bytes(_offset: int, length: int) -> bytes:
362
362
  return b"\xff" * length
363
363
 
364
364
 
365
- def _default_read_path(eprom_name: str) -> str:
366
- """The output file name `read` uses when the user gives none:
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
367
  `<NAME>.bin`, or `<NAME>-<n>.bin` with the lowest free `n` when that
368
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.
369
372
  """
370
373
  base = eprom_name.upper()
371
374
  path = f"{base}.bin"
372
375
  n = 1
373
- while os.path.exists(path):
374
- path = f"{base}-{n}.bin"
375
- n += 1
376
- return path
376
+ while True:
377
+ try:
378
+ return path, open(path, "xb")
379
+ except FileExistsError:
380
+ path = f"{base}-{n}.bin"
381
+ n += 1
377
382
 
378
383
 
379
384
  def _write_blank_guard_refusal_message(
@@ -1312,14 +1317,20 @@ class EpromOperator:
1312
1317
  if not cmd_data:
1313
1318
  return False
1314
1319
 
1315
- actual_output_file = output_file or _default_read_path(eprom_name)
1316
- logger.info(
1317
- f"Reading EPROM {eprom_name.upper()}, saving to {actual_output_file}"
1318
- )
1319
- start_time = time.time()
1320
-
1320
+ actual_output_file = output_file or ""
1321
+ is_ok = False
1321
1322
  try:
1322
- 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:
1323
1334
 
1324
1335
  def _write_to_file(address, data_chunk):
1325
1336
  file_handle.seek(address)
@@ -1340,6 +1351,14 @@ class EpromOperator:
1340
1351
  except IOError as e: # noqa: UP024
1341
1352
  logger.error(f"File I/O error with {actual_output_file}: {e}")
1342
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
1343
1362
 
1344
1363
  def consistency_check_eprom(
1345
1364
  self,
@@ -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
@@ -869,12 +927,6 @@ class FirmwareManager:
869
927
  #
870
928
  # The same call refuses a pre-release on a stable CLI. That covers a
871
929
  # library caller that asks a stable CLI for channel="pre".
872
- if app_version is None:
873
- # Dereferenced at call time, not bound at import, so a test can
874
- # monkeypatch firestarter.__version__ without reloading this module.
875
- import firestarter as _pkg
876
-
877
- app_version = _pkg.__version__
878
930
  require_installable_release(
879
931
  app_version,
880
932
  latest_version,
@@ -920,8 +972,22 @@ class FirmwareManager:
920
972
  "only guard."
921
973
  )
922
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
+ )
923
983
  is_up_to_date = False
924
- 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:
925
991
  is_up_to_date = self._compare_versions(current_version, latest_version)
926
992
 
927
993
  if is_up_to_date and not force_install:
@@ -949,10 +1015,12 @@ class FirmwareManager:
949
1015
  elif (
950
1016
  not is_up_to_date and current_version and latest_version
951
1017
  ): # No flags, but update available
952
- if Confirm.ask(
953
- f"New firmware {latest_version} available for {board_to_use} (current: {current_version}). Update now?", # noqa: E501
954
- default=False,
955
- ):
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):
956
1024
  should_install_now = True
957
1025
  else:
958
1026
  logger.info("Firmware update cancelled by user.")
@@ -21,6 +21,11 @@ PEP 440 release; a pre-release firmware is a b/rc/dev tag. This rule ignores the
21
21
  feature version, and nothing waives it: a stable user gets stable firmware only.
22
22
  A pre-release CLI (a b/rc tag, or a dev checkout) installs either.
23
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
+
24
29
  The reverse pairing -- a CLI newer than the firmware -- is NOT gated here.
25
30
  `_maybe_auto_route_to_pre` in cli_handlers.py steers channel selection away from
26
31
  it. The two checks cover opposite directions and neither replaces the other.
@@ -37,7 +42,7 @@ the failed fetch. Turning a GitHub outage into a version-mismatch refusal would
37
42
  be a lie. This is the one place the gate looks fail-open and is not.
38
43
 
39
44
  Escape for the feature-version refusal: `--allow-newer-firmware`. Never an
40
- environment variable. The pre-release refusal has no escape.
45
+ environment variable. The pre-release and older-line refusals have no escape.
41
46
 
42
47
  Unlike the pre-serial gates, the authoritative call runs AFTER the release
43
48
  metadata fetch, because the release tag is only known then. It still runs before
@@ -90,6 +95,14 @@ _PRERELEASE_ON_STABLE_FORMAT = (
90
95
  "Run 'pip install --upgrade --pre firestarter'."
91
96
  )
92
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
+
93
106
  _UNREADABLE_TEXT = "an unreadable version"
94
107
 
95
108
 
@@ -123,6 +136,17 @@ def is_prerelease_version(raw: str | None) -> bool | None:
123
136
  return None
124
137
 
125
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
+
126
150
  def is_refused(app_version: str | None, release_version: str | None) -> bool:
127
151
  """True when this CLI must not flash this release.
128
152
 
@@ -148,20 +172,30 @@ def require_installable_release(
148
172
 
149
173
  Returns None on pass. `allow_newer` waives the feature-version refusal; it
150
174
  defaults to False so a library caller that does not ask for the waiver does
151
- not get it. Nothing waives the pre-release refusal on a stable CLI.
175
+ not get it. Nothing waives the pre-release or older-line refusal on a
176
+ stable CLI.
152
177
 
153
- An unreadable version on either side is not judged by the pre-release rule.
178
+ An unreadable version on either side is not judged by the stable-CLI rules.
154
179
  The feature-version check refuses it.
155
180
  """
156
- if (
157
- is_prerelease_version(app_version) is False
158
- and is_prerelease_version(release_version) is True
159
- ):
160
- raise FirmwareReleaseRefusedError(
161
- _PRERELEASE_ON_STABLE_FORMAT.format(
162
- app=app_version, release=release_version
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
+ )
163
198
  )
164
- )
165
199
  if allow_newer or not is_refused(app_version, release_version):
166
200
  return
167
201
  app = feature_version(app_version)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firestarter
3
- Version: 3.2.7b2
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.7b2",
2
+ "tag": "3.2.7b3",
3
3
  "distance": 0,
4
- "node": "g30e9def",
4
+ "node": "g85b7152",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-10-28"
@@ -17,14 +17,17 @@ Phase 44 Plan 03 additions (read_timing block):
17
17
  test_consistency_check_default_params_absent_from_command — no extra keys when 0
18
18
  """
19
19
 
20
+ import contextlib
20
21
  import logging
21
22
  import tempfile
22
23
  from pathlib import Path
23
- from unittest.mock import patch
24
+ from unittest.mock import MagicMock, patch
25
+
26
+ import pytest
24
27
 
25
28
  from firestarter.compare import CompareAccumulator
26
29
  from firestarter.config import ConfigManager
27
- from firestarter.eprom_operations import EpromOperator, _default_read_path
30
+ from firestarter.eprom_operations import EpromOperator, _open_default_read_file
28
31
  from firestarter.messages import (
29
32
  MSG_DATA_CHUNK,
30
33
  MSG_END_DONE,
@@ -2731,14 +2734,64 @@ def test_seen_message_ids_records_decoded_ids_and_stays_bounded(
2731
2734
  assert all(isinstance(i, int) for i in comm.seen_message_ids)
2732
2735
 
2733
2736
 
2734
- def test_default_read_path_adds_lowest_free_suffix(tmp_path, monkeypatch):
2737
+ def test_default_read_file_takes_lowest_free_suffix(tmp_path, monkeypatch):
2735
2738
  """`read` with no file name never overwrites an earlier read: the
2736
- default `<NAME>.bin` gets `-1`, `-2`, ... when that name is taken."""
2739
+ default `<NAME>.bin` gets `-1`, `-2`, ... when that name is taken.
2740
+ Opening the file claims the name, so the next call skips it."""
2737
2741
  monkeypatch.chdir(tmp_path)
2738
- assert _default_read_path("w27c512") == "W27C512.bin"
2742
+ names = []
2743
+ for _ in range(3):
2744
+ path, fh = _open_default_read_file("w27c512")
2745
+ fh.close()
2746
+ names.append(path)
2747
+ assert names == ["W27C512.bin", "W27C512-1.bin", "W27C512-2.bin"]
2748
+
2749
+
2750
+ def _operator_reading(monkeypatch, outcome):
2751
+ """An EpromOperator whose read writes 4 bytes, then returns or raises `outcome`."""
2752
+ operator = EpromOperator(MagicMock())
2753
+
2754
+ @contextlib.contextmanager
2755
+ def _context(*_args, **_kwargs):
2756
+ yield {"address": 0, "memory-size": 4}, 512, "Read"
2757
+
2758
+ def _state_machine(_op_name, process_data_chunk_callback, **_kwargs):
2759
+ process_data_chunk_callback(0, b"\x01\x02\x03\x04")
2760
+ if isinstance(outcome, BaseException):
2761
+ raise outcome
2762
+ return outcome, None
2763
+
2764
+ monkeypatch.setattr(operator, "_operation_context", _context)
2765
+ monkeypatch.setattr(operator, "_run_state_machine", _state_machine)
2766
+ return operator
2739
2767
 
2740
- (tmp_path / "W27C512.bin").write_bytes(b"")
2741
- assert _default_read_path("w27c512") == "W27C512-1.bin"
2742
2768
 
2743
- (tmp_path / "W27C512-1.bin").write_bytes(b"")
2744
- assert _default_read_path("w27c512") == "W27C512-2.bin"
2769
+ def test_read_with_default_name_keeps_the_file(tmp_path, monkeypatch):
2770
+ monkeypatch.chdir(tmp_path)
2771
+ assert _operator_reading(monkeypatch, True).read_eprom("w27c512", {})
2772
+ assert (tmp_path / "W27C512.bin").read_bytes() == b"\x01\x02\x03\x04"
2773
+
2774
+
2775
+ @pytest.mark.parametrize("outcome", [False, KeyboardInterrupt()])
2776
+ def test_failed_read_with_default_name_leaves_no_file(tmp_path, monkeypatch, outcome):
2777
+ """A failed or interrupted read frees its name, so the next read takes it."""
2778
+ monkeypatch.chdir(tmp_path)
2779
+ (tmp_path / "W27C512.bin").write_bytes(b"earlier read")
2780
+ operator = _operator_reading(monkeypatch, outcome)
2781
+ if isinstance(outcome, BaseException):
2782
+ with pytest.raises(KeyboardInterrupt):
2783
+ operator.read_eprom("w27c512", {})
2784
+ else:
2785
+ assert operator.read_eprom("w27c512", {}) is False
2786
+ assert sorted(p.name for p in tmp_path.iterdir()) == ["W27C512.bin"]
2787
+ assert (tmp_path / "W27C512.bin").read_bytes() == b"earlier read"
2788
+
2789
+
2790
+ def test_failed_read_with_named_file_keeps_it(tmp_path, monkeypatch):
2791
+ """A file the user named is not removed: it is theirs."""
2792
+ out = tmp_path / "mine.bin"
2793
+ assert (
2794
+ _operator_reading(monkeypatch, False).read_eprom("w27c512", {}, str(out))
2795
+ is False
2796
+ )
2797
+ assert out.exists()
@@ -1425,12 +1425,12 @@ class TestManageFirmwareUpdate:
1425
1425
  monkeypatch.setattr(
1426
1426
  fm,
1427
1427
  "fetch_release_info",
1428
- lambda channel="stable", version=None, board="uno": (
1428
+ lambda channel="stable", version=None, board="uno", **_kw: (
1429
1429
  "3.1.0",
1430
1430
  "https://example.com/firestarter_uno.hex",
1431
1431
  ),
1432
1432
  )
1433
- result = fm.manage_firmware_update(install_flag=True)
1433
+ result = fm.manage_firmware_update(install_flag=True, app_version="3.1.0")
1434
1434
  assert result is True
1435
1435
 
1436
1436
  def test_manage_no_current_no_install_intent_returns_false(self, monkeypatch):
@@ -1442,13 +1442,15 @@ class TestManageFirmwareUpdate:
1442
1442
  monkeypatch.setattr(
1443
1443
  fm,
1444
1444
  "fetch_release_info",
1445
- lambda channel="stable", version=None, board="uno": (
1445
+ lambda channel="stable", version=None, board="uno", **_kw: (
1446
1446
  "3.1.0",
1447
1447
  "https://example.com/firestarter_uno.hex",
1448
1448
  ),
1449
1449
  )
1450
1450
  # install_flag=False + no FLAG_FORCE in flags=0 → returns False
1451
- result = fm.manage_firmware_update(install_flag=False, flags=0)
1451
+ result = fm.manage_firmware_update(
1452
+ install_flag=False, flags=0, app_version="3.1.0"
1453
+ )
1452
1454
  assert result is False
1453
1455
 
1454
1456
 
@@ -1474,7 +1476,7 @@ class TestNewerFirmwareRefusal:
1474
1476
  monkeypatch.setattr(
1475
1477
  fm,
1476
1478
  "fetch_release_info",
1477
- lambda channel="stable", version=None, board="uno": (
1479
+ lambda channel="stable", version=None, board="uno", **_kw: (
1478
1480
  latest,
1479
1481
  "https://example.com/firestarter_uno.hex" if latest else None,
1480
1482
  ),
@@ -220,7 +220,7 @@ def _manager_that_cannot_identify(monkeypatch):
220
220
  monkeypatch.setattr(
221
221
  fm,
222
222
  "fetch_release_info",
223
- lambda channel="stable", version=None, board="uno": (
223
+ lambda channel="stable", version=None, board="uno", **_kw: (
224
224
  "3.0.0b19",
225
225
  f"https://example.invalid/{board}.hex",
226
226
  ),
@@ -318,7 +318,7 @@ class TestBlindInstallRequiresExplicitBoard:
318
318
  monkeypatch.setattr(
319
319
  fm,
320
320
  "fetch_release_info",
321
- lambda channel="stable", version=None, board="uno": (
321
+ lambda channel="stable", version=None, board="uno", **_kw: (
322
322
  "3.0.0b19",
323
323
  f"https://example.invalid/{board}.hex",
324
324
  ),