firestarter 3.2.0b1__tar.gz → 3.2.1__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 (220) hide show
  1. {firestarter-3.2.0b1/firestarter.egg-info → firestarter-3.2.1}/PKG-INFO +2 -1
  2. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/_version.py +3 -3
  3. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/chip_resolver.py +12 -0
  4. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/cli_handlers.py +88 -15
  5. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/codec.py +29 -7
  6. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/constants.py +12 -0
  7. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/diagnostic_report.py +4 -4
  8. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/eprom_operations.py +65 -31
  9. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/firmware.py +3 -2
  10. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/hardware.py +44 -2
  11. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/serial_comm.py +44 -11
  12. firestarter-3.2.1/firestarter/timestamps.py +83 -0
  13. {firestarter-3.2.0b1 → firestarter-3.2.1/firestarter.egg-info}/PKG-INFO +2 -1
  14. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter.egg-info/SOURCES.txt +5 -9
  15. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter.egg-info/requires.txt +1 -0
  16. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter.egg-info/scm_file_list.json +5 -9
  17. firestarter-3.2.1/firestarter.egg-info/scm_version.json +8 -0
  18. {firestarter-3.2.0b1 → firestarter-3.2.1}/pyproject.toml +1 -0
  19. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/__snapshots__/test_characterization.ambr +15 -12
  20. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/conftest.py +36 -0
  21. firestarter-3.2.0b1/tests/golden/wire_dict_baseline.json → firestarter-3.2.1/tests/golden/wire_dict.json +189 -137
  22. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_cli_handlers.py +2 -2
  23. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_codec_format_message.py +62 -0
  24. firestarter-3.2.1/tests/test_config_band_refusal.py +188 -0
  25. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_consistency_check.py +7 -1
  26. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_derive_plan_no_drop_sweep.py +14 -22
  27. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_devtest_firmware_error_propagation.py +11 -4
  28. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_eprom_operations.py +123 -0
  29. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_erase_flag_invariants.py +20 -30
  30. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_firmware_install.py +4 -2
  31. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_hardware.py +56 -2
  32. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_page_size_invariants.py +22 -44
  33. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_probe_spurious_setup_ack.py +3 -0
  34. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_protocol_not_implemented_production_path.py +4 -1
  35. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_sdp_db_invariant.py +22 -62
  36. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_session_lease.py +52 -1
  37. firestarter-3.2.1/tests/test_setup_refusals.py +229 -0
  38. firestarter-3.2.1/tests/test_timestamps.py +87 -0
  39. firestarter-3.2.1/tests/test_wire_dict_equivalence.py +177 -0
  40. firestarter-3.2.0b1/firestarter.egg-info/scm_version.json +0 -8
  41. firestarter-3.2.0b1/tests/golden/wire_dict_expected_deltas_149.json +0 -65
  42. firestarter-3.2.0b1/tests/golden/wire_dict_expected_deltas_153.json +0 -265
  43. firestarter-3.2.0b1/tests/golden/wire_dict_expected_deltas_182.json +0 -219
  44. firestarter-3.2.0b1/tests/golden/wire_dict_expected_deltas_194.json +0 -86
  45. firestarter-3.2.0b1/tests/golden/wire_dict_expected_deltas_197.json +0 -20
  46. firestarter-3.2.0b1/tests/golden/wire_dict_expected_deltas_198.json +0 -17
  47. firestarter-3.2.0b1/tests/golden/wire_dict_expected_deltas_199.json +0 -14
  48. firestarter-3.2.0b1/tests/golden/wire_dict_expected_deltas_dip24_2816.json +0 -91
  49. firestarter-3.2.0b1/tests/test_wire_dict_equivalence.py +0 -821
  50. {firestarter-3.2.0b1 → firestarter-3.2.1}/LICENSE +0 -0
  51. {firestarter-3.2.0b1 → firestarter-3.2.1}/MANIFEST.in +0 -0
  52. {firestarter-3.2.0b1 → firestarter-3.2.1}/README.md +0 -0
  53. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/__init__.py +0 -0
  54. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/address_parser.py +0 -0
  55. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/avr_tool.py +0 -0
  56. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/channel.py +0 -0
  57. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/chip_test.py +0 -0
  58. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/compare.py +0 -0
  59. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/config.py +0 -0
  60. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/data/chip_database.json +0 -0
  61. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/data/pinouts.json +0 -0
  62. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/database.py +0 -0
  63. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/dip24_2816_erase_gate.py +0 -0
  64. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/eprom_info.py +0 -0
  65. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/erase_support.py +0 -0
  66. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/exceptions.py +0 -0
  67. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/flash4_erase_gate.py +0 -0
  68. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/frame_parser.py +0 -0
  69. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/fw_release_gate.py +0 -0
  70. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/ic_layout.py +0 -0
  71. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/jp5_gate.py +0 -0
  72. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/jumper_table.py +0 -0
  73. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/lock_status.py +0 -0
  74. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/log_capture.py +0 -0
  75. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/logging_utils.py +0 -0
  76. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/main.py +0 -0
  77. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/messages.py +0 -0
  78. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/page_size_gate.py +0 -0
  79. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/protection_readability.py +0 -0
  80. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/py32_dfu.py +0 -0
  81. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/sdp_capability.py +0 -0
  82. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/sdp_honesty.py +0 -0
  83. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/submit.py +0 -0
  84. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/transport_counters.py +0 -0
  85. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/utils.py +0 -0
  86. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/vpp_display.py +0 -0
  87. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter/write_blank_guard.py +0 -0
  88. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter.egg-info/dependency_links.txt +0 -0
  89. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter.egg-info/entry_points.txt +0 -0
  90. {firestarter-3.2.0b1 → firestarter-3.2.1}/firestarter.egg-info/top_level.txt +0 -0
  91. {firestarter-3.2.0b1 → firestarter-3.2.1}/setup.cfg +0 -0
  92. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/__init__.py +0 -0
  93. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fake_chip.py +0 -0
  94. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/part_number_delta.json +0 -0
  95. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/report_shapes.py +0 -0
  96. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/at28c256-full-all-ok-sdp.json +0 -0
  97. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/attr01-status-axis-transport-fault.json +0 -0
  98. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/gh20-at28c256-fail.json +0 -0
  99. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/gh23-w27e257-fail.json +0 -0
  100. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/gh28-m27c512-fail.json +0 -0
  101. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/gh47-sst27sf512-pass.json +0 -0
  102. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/m27c512-full-all-ok.json +0 -0
  103. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/m27c512-full-blank-check-bad.json +0 -0
  104. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/m27c512-full-canonical-name.json +0 -0
  105. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/m27c512-full-comma-joined-name.json +0 -0
  106. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/m27c512-full-runs-1.json +0 -0
  107. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/prune03-synthesized-fingerprint-match.json +0 -0
  108. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/sst27sf512-full-all-ok.json +0 -0
  109. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/sst27sf512-six-step-readback-gated.json +0 -0
  110. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/sst27sf512-six-step.json +0 -0
  111. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/synthetic-arm4-empty-results.json +0 -0
  112. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/synthetic-arm4-no-ok.json +0 -0
  113. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/uv-slot-write-pass.json +0 -0
  114. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/reports/w27e257-full-all-ok.json +0 -0
  115. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/shape_ids.json +0 -0
  116. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/fixtures/synthetic_nonzero_chip_id.py +0 -0
  117. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/golden/build_db_part_specific_constants.json +0 -0
  118. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/golden/chip_database_field_inventory.json +0 -0
  119. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/plan_corpus.py +0 -0
  120. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_address_parser.py +0 -0
  121. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_b15_page_size_corroboration.py +0 -0
  122. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_blast_radius_invariance.py +0 -0
  123. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_boot_block_hint.py +0 -0
  124. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_budget_failure_render.py +0 -0
  125. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_bug_characterization.py +0 -0
  126. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_build_db_constant_census.py +0 -0
  127. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_build_db_inclusion.py +0 -0
  128. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_build_db_interpret_timing.py +0 -0
  129. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_build_db_pinout_fork.py +0 -0
  130. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_canonical_part_number.py +0 -0
  131. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_characterization.py +0 -0
  132. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_chip_database_field_inventory.py +0 -0
  133. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_chip_resolver.py +0 -0
  134. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_chip_test.py +0 -0
  135. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_chip_test_blank_check_order.py +0 -0
  136. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_chip_test_cycle.py +0 -0
  137. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_chip_test_sdp_leg.py +0 -0
  138. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_chip_test_timing.py +0 -0
  139. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_chip_test_uv_slot_write.py +0 -0
  140. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_click_group_gate_hook.py +0 -0
  141. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_cobs.py +0 -0
  142. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_codec.py +0 -0
  143. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_compare.py +0 -0
  144. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_config.py +0 -0
  145. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_connect_cost_harness.py +0 -0
  146. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_coverage_floor_v18.py +0 -0
  147. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_database_conversion.py +0 -0
  148. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_datasheet_overrides.py +0 -0
  149. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_decoder.py +0 -0
  150. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_derive_plan_structural_sentinel.py +0 -0
  151. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_dev_group_channel_gating.py +0 -0
  152. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_dev_test_cmd.py +0 -0
  153. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_dev_tools_channel_gate.py +0 -0
  154. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_dfu_opcode_anchors.py +0 -0
  155. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_diagnostic_report.py +0 -0
  156. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_dip24_2816_erase_gate.py +0 -0
  157. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_endpoint_constants.py +0 -0
  158. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_eprom_database.py +0 -0
  159. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_eprom_info.py +0 -0
  160. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_erase_blank_step_nonregression.py +0 -0
  161. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_error_code_seam.py +0 -0
  162. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_even_block.py +0 -0
  163. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_extra_chips_supplement.py +0 -0
  164. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_flash4_erase_gate.py +0 -0
  165. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_fw_list_failure_vs_empty.py +0 -0
  166. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_fw_port_targeting_and_blind_install.py +0 -0
  167. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_fw_release_gate.py +0 -0
  168. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_fw_update_dead_endpoint.py +0 -0
  169. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_fw_update_path_gate.py +0 -0
  170. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_fw_version_guard.py +0 -0
  171. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_fwguard.py +0 -0
  172. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_hw_revision_gate.py +0 -0
  173. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_ic_layout.py +0 -0
  174. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_info_vpp_and_erase.py +0 -0
  175. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_jp5_gate.py +0 -0
  176. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_jumper_table.py +0 -0
  177. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_lock_status_class_partition.py +0 -0
  178. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_lock_status_cli.py +0 -0
  179. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_lock_status_wire.py +0 -0
  180. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_log_capture.py +0 -0
  181. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_logging_utils.py +0 -0
  182. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_matrix_artifact.py +0 -0
  183. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_page_size_alignment_refusal.py +0 -0
  184. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_page_size_write_refusal.py +0 -0
  185. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_parse_devtest_issue.py +0 -0
  186. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_programming_vcc_census.py +0 -0
  187. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_protect_flags_doc_measurements.py +0 -0
  188. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_protection_resolution.py +0 -0
  189. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_protection_status_catalog.py +0 -0
  190. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_protection_table_citations.py +0 -0
  191. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_protocol_not_implemented.py +0 -0
  192. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_provenance.py +0 -0
  193. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_pulse_us_override.py +0 -0
  194. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_py32_channel_gating.py +0 -0
  195. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_py32_dfu.py +0 -0
  196. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_py32_flash_map_host.py +0 -0
  197. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_py32_pyusb_absent.py +0 -0
  198. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_pyusb_api_surface.py +0 -0
  199. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_sdp_capability.py +0 -0
  200. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_sdp_honesty.py +0 -0
  201. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_serial_characterization.py +0 -0
  202. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_serial_comm.py +0 -0
  203. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_submit.py +0 -0
  204. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_transport_counters.py +0 -0
  205. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_utils.py +0 -0
  206. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_uv_mask.py +0 -0
  207. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_validate_family_cmd.py +0 -0
  208. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_validate_oracle.py +0 -0
  209. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_validated_parts_regression_surface.py +0 -0
  210. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_vcc_margin_rail.py +0 -0
  211. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_vpp_decode_table.py +0 -0
  212. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_vpp_rail_classification.py +0 -0
  213. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_write_blank_guard.py +0 -0
  214. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_write_blank_guard_pinning.py +0 -0
  215. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_write_guard_port_pinning.py +0 -0
  216. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_write_progress.py +0 -0
  217. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_write_response_budget.py +0 -0
  218. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_write_skip_erase_0x0d.py +0 -0
  219. {firestarter-3.2.0b1 → firestarter-3.2.1}/tests/test_write_skip_sdp_unlock.py +0 -0
  220. {firestarter-3.2.0b1 → firestarter-3.2.1}/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.0b1
3
+ Version: 3.2.1
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
@@ -37,6 +37,7 @@ Requires-Dist: syrupy<7,>=5.0; extra == "test"
37
37
  Requires-Dist: ruff>=0.15.14; extra == "test"
38
38
  Requires-Dist: mypy<3,>=2.1.0; extra == "test"
39
39
  Requires-Dist: pytest-cov>=7.1.0; extra == "test"
40
+ Requires-Dist: pytest-xdist>=3.6; extra == "test"
40
41
  Requires-Dist: types-pyserial>=3.5.0.20260519; extra == "test"
41
42
  Dynamic: license-file
42
43
 
@@ -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.0b1'
22
- __version_tuple__ = version_tuple = (3, 2, 0, 'b1')
21
+ __version__ = version = '3.2.1'
22
+ __version_tuple__ = version_tuple = (3, 2, 1)
23
23
 
24
- __commit_id__ = commit_id = 'gf7d80f3'
24
+ __commit_id__ = commit_id = 'g4526a89'
@@ -12,6 +12,8 @@ from typing import Any
12
12
  from firestarter.database import EpromDatabase
13
13
  from firestarter.exceptions import ChipNotFoundError, ChipNotImplementedError
14
14
 
15
+ _NON_ZERO_WIRE_FIELDS = ("memory-size", "pin-count", "vpp_mv")
16
+
15
17
 
16
18
  def resolve_chip(name: str, db: EpromDatabase | None = None) -> dict[str, Any]:
17
19
  """Resolve a chip name to its programmer-config dict.
@@ -74,4 +76,14 @@ def resolve_chip(name: str, db: EpromDatabase | None = None) -> dict[str, Any]:
74
76
  data = db.convert_to_programmer(full) if full else None
75
77
  if not data:
76
78
  raise ChipNotFoundError(name)
79
+
80
+ # The firmware refuses a memory command whose memory-size, pin-count or
81
+ # vpp_mv is 0, with the same 'Bad JSON' answer that old firmware gives.
82
+ # No shipped row has a 0 here; a user database override can.
83
+ zero_fields = [key for key in _NON_ZERO_WIRE_FIELDS if not data.get(key)]
84
+ if zero_fields:
85
+ raise ChipNotImplementedError(
86
+ f"{name}: the chip entry gives 0 for {', '.join(zero_fields)}. "
87
+ "The programmer cannot use a value of 0 there."
88
+ )
77
89
  return data
@@ -4,7 +4,6 @@ This module is the production CLI surface; main.py re-exports `cli` as `main`
4
4
  for the console-script entry point.
5
5
  """
6
6
 
7
- import datetime
8
7
  import functools
9
8
  import hashlib
10
9
  import json
@@ -60,6 +59,10 @@ from firestarter.chip_test import (
60
59
  )
61
60
  from firestarter.config import ConfigManager, get_config_dir
62
61
  from firestarter.constants import (
62
+ CONFIG_R1_MAX,
63
+ CONFIG_R1_MIN,
64
+ CONFIG_R2_MAX,
65
+ CONFIG_R2_MIN,
63
66
  FIRESTARTER_RELEASES_URL,
64
67
  FLAG_CHIP_ENABLE,
65
68
  FLAG_OUTPUT_ENABLE,
@@ -102,6 +105,7 @@ from firestarter.protection_readability import (
102
105
  protection_gate_for_entry,
103
106
  )
104
107
  from firestarter.sdp_capability import SDP_PROTOCOL_ID, sdp_capability
108
+ from firestarter.timestamps import iso_cet
105
109
 
106
110
  logger = logging.getLogger("Firestarter")
107
111
 
@@ -564,6 +568,9 @@ def read(
564
568
  ) -> None:
565
569
  """Reads the content from an EPROM."""
566
570
  eprom_data = resolve_chip(eprom, db=app.db)
571
+ refusal = _address_size_refusal_exit_code(eprom=eprom, address=address, size=size)
572
+ if refusal is not None:
573
+ sys.exit(refusal)
567
574
  ok = app.eprom_operator.read_eprom(
568
575
  eprom,
569
576
  eprom_data,
@@ -999,6 +1006,41 @@ def write(
999
1006
  sys.exit(1)
1000
1007
 
1001
1008
 
1009
+ _SIZE_TOO_SMALL_REFUSAL = "{eprom}: refused -- the size {size!r} is less than 1 byte."
1010
+ _EMPTY_INPUT_FILE_REFUSAL = "{eprom}: refused -- the input file is empty."
1011
+ _NEGATIVE_SECTOR_ADDRESS_REFUSAL = (
1012
+ "{eprom}: refused -- the sector address {address!r} is negative."
1013
+ )
1014
+
1015
+
1016
+ def _address_size_refusal_exit_code(
1017
+ *, eprom: str, address: str | None, size: str | None
1018
+ ) -> int | None:
1019
+ """Refuse a negative start address or a size below 1 byte before the
1020
+ serial port opens.
1021
+
1022
+ The firmware refuses both at setup, with the same 'Bad JSON' answer that
1023
+ firmware too old for this CLI gives, so without this check the operator
1024
+ sees only "no programmer found". Returns 2 to refuse with, or `None`.
1025
+ A malformed string is not refused here: `_setup_operation`'s own parse
1026
+ handling still owns that error.
1027
+ """
1028
+ try:
1029
+ write_blank_guard.require_non_negative_address(eprom, address)
1030
+ except NegativeStartAddressError as e:
1031
+ click.echo(str(e))
1032
+ return 2
1033
+ if size is not None:
1034
+ try:
1035
+ parsed_size = parse_size(size)
1036
+ except ValueError:
1037
+ return None
1038
+ if parsed_size is not None and parsed_size < 1:
1039
+ click.echo(_SIZE_TOO_SMALL_REFUSAL.format(eprom=eprom.upper(), size=size))
1040
+ return 2
1041
+ return None
1042
+
1043
+
1002
1044
  def _region_refusal_exit_code(
1003
1045
  *,
1004
1046
  eprom: str,
@@ -1040,7 +1082,14 @@ def _region_refusal_exit_code(
1040
1082
  also exit 2, via the existing setup-failure path. Returning `None` here
1041
1083
  lets that path run rather than parsing the string a second time with
1042
1084
  different error handling.
1085
+
1086
+ A negative start address, a size below 1 byte and an empty input file
1087
+ are refused here too: the firmware refuses a zero-length region at
1088
+ setup.
1043
1089
  """
1090
+ refusal = _address_size_refusal_exit_code(eprom=eprom, address=address, size=size)
1091
+ if refusal is not None:
1092
+ return refusal
1044
1093
  try:
1045
1094
  start = parse_address(address) if address is not None else 0
1046
1095
  except ValueError:
@@ -1098,6 +1147,10 @@ def _region_refusal_exit_code(
1098
1147
  else:
1099
1148
  length = None
1100
1149
 
1150
+ if explicit_size is None and input_file is not None and length == 0:
1151
+ click.echo(_EMPTY_INPUT_FILE_REFUSAL.format(eprom=eprom.upper()))
1152
+ return 2
1153
+
1101
1154
  if length is not None and mem_size is not None and start + length > mem_size:
1102
1155
  click.echo(
1103
1156
  f"{eprom.upper()}: refused -- the region 0x{start:X}-"
@@ -1193,8 +1246,9 @@ def verify(
1193
1246
  Exits 0 on a match, 1 on a mismatch, 2 on a transport, hardware, setup,
1194
1247
  or region failure. The three are distinct: a transport failure is not
1195
1248
  reported as a mismatch, and a region refusal -- an explicit --size
1196
- longer than the input file, or a region running past the chip's end --
1197
- is reported before the serial port ever opens.
1249
+ longer than the input file, a region running past the chip's end, a
1250
+ negative address, or an empty region -- is reported before the serial
1251
+ port ever opens.
1198
1252
 
1199
1253
  Without --size, the compared region is the input file's own length;
1200
1254
  with it, --size wins.
@@ -1255,8 +1309,9 @@ def blank(
1255
1309
  Exits 0 when blank, 1 when at least one byte is not blank, 2 on a
1256
1310
  transport, hardware, setup, or region failure. The three are distinct:
1257
1311
  a transport failure is not reported as a not-blank verdict, and a
1258
- region refusal -- a region running past the chip's end -- is reported
1259
- before the serial port ever opens.
1312
+ region refusal -- a region running past the chip's end, a negative
1313
+ address, or a size of 0 -- is reported before the serial port ever
1314
+ opens.
1260
1315
 
1261
1316
  Without --size, the checked region is the whole chip; with it, --size
1262
1317
  wins.
@@ -1352,6 +1407,18 @@ def erase(
1352
1407
  if sector_blank_refusal is not None:
1353
1408
  sys.exit(sector_blank_refusal)
1354
1409
 
1410
+ try:
1411
+ negative_sector = (parse_address(sector_address) or 0) < 0
1412
+ except ValueError:
1413
+ negative_sector = False
1414
+ if negative_sector:
1415
+ click.echo(
1416
+ _NEGATIVE_SECTOR_ADDRESS_REFUSAL.format(
1417
+ eprom=eprom.upper(), address=sector_address
1418
+ )
1419
+ )
1420
+ sys.exit(2)
1421
+
1355
1422
  eprom_data = resolve_chip(eprom, db=app.db)
1356
1423
 
1357
1424
  if flash4_erase_gate.is_flash4(eprom_data):
@@ -1492,13 +1559,12 @@ def hw(app: AppContext) -> None:
1492
1559
 
1493
1560
 
1494
1561
  _BANDGAP_NOMINAL_MV = 1100
1495
- _DIVIDER_GAIN = 314000 / 44000
1496
1562
 
1497
1563
 
1498
1564
  @cli.command(name="cal")
1499
1565
  @click.option(
1500
1566
  "--vcc",
1501
- type=float,
1567
+ type=click.FloatRange(min=0, min_open=True),
1502
1568
  default=None,
1503
1569
  metavar="VOLTS",
1504
1570
  help="Calibrate using a multimeter reading of the 5V pin, e.g. --vcc 5.09",
@@ -1545,7 +1611,9 @@ def cal(app: AppContext, vcc: float | None, reset: bool) -> None:
1545
1611
  click.echo(
1546
1612
  "Calibration refused. The result was outside 1000-1200 mV, which "
1547
1613
  "means the reading does not match this board. Check that the "
1548
- "multimeter is on the 5V pin and that the value is in volts."
1614
+ "multimeter is on the 5V pin and that the value is in volts. "
1615
+ "A py32f071 board has no bandgap calibration and always "
1616
+ "refuses it."
1549
1617
  )
1550
1618
  sys.exit(1)
1551
1619
  after = app.hardware_manager.read_calibration()
@@ -1599,17 +1667,17 @@ def _echo_calibration_effect(bandgap_mv: int) -> None:
1599
1667
  "-r1",
1600
1668
  "--r16",
1601
1669
  "r16",
1602
- type=int,
1670
+ type=click.IntRange(CONFIG_R1_MIN, CONFIG_R1_MAX),
1603
1671
  default=None,
1604
- help="Set R16 resistance, resistor connected to VPE",
1672
+ help="Set R16 resistance in ohms, resistor connected to VPE.",
1605
1673
  )
1606
1674
  @click.option(
1607
1675
  "-r2",
1608
1676
  "--r14r15",
1609
1677
  "r14r15",
1610
- type=int,
1678
+ type=click.IntRange(CONFIG_R2_MIN, CONFIG_R2_MAX),
1611
1679
  default=None,
1612
- help="Set R14/R15 resistance, resistors connected to GND",
1680
+ help="Set R14/R15 resistance in ohms, resistors connected to GND.",
1613
1681
  )
1614
1682
  @click.pass_obj
1615
1683
  @map_typed_errors
@@ -1623,6 +1691,8 @@ def config(
1623
1691
  # --rev is an IntRange: '--rev 2.2' used to go through float() and int()
1624
1692
  # and silently select code 2 (Rev 2.0). Click now refuses it before any
1625
1693
  # serial byte is sent, and refuses a code the firmware does not define.
1694
+ # -r1 and -r2 are IntRanges for the same reason: the firmware refuses a
1695
+ # value outside its band, so the host refuses it before the port opens.
1626
1696
  ok = app.hardware_manager.set_hardware_config(
1627
1697
  rev, r16, r14r15, flags=_build_op_flags()
1628
1698
  )
@@ -1838,10 +1908,10 @@ def fw(
1838
1908
 
1839
1909
  print(_json.dumps(releases, indent=2))
1840
1910
  else:
1841
- print(f"{'Version':<12} {'Channel':<14} {'Published':<22} Asset URL")
1911
+ print(f"{'Version':<12} {'Channel':<14} {'Published':<25} Asset URL")
1842
1912
  for r in releases:
1843
1913
  print(
1844
- f"{r['version']:<12} {r['channel']:<14} {r['published']:<22} {r['asset_url']}" # noqa: E501
1914
+ f"{r['version']:<12} {r['channel']:<14} {r['published']:<25} {r['asset_url']}" # noqa: E501
1845
1915
  )
1846
1916
  if not releases:
1847
1917
  print(f"No releases found for board {board}.")
@@ -1989,6 +2059,9 @@ def dev_read(
1989
2059
  ) -> None:
1990
2060
  """Reads the content from an EPROM and prints data to console."""
1991
2061
  eprom_data = resolve_chip(eprom, db=app.db)
2062
+ refusal = _address_size_refusal_exit_code(eprom=eprom, address=address, size=size)
2063
+ if refusal is not None:
2064
+ sys.exit(refusal)
1992
2065
  ok = app.eprom_operator.dev_read_eprom(
1993
2066
  eprom,
1994
2067
  eprom_data,
@@ -2565,7 +2638,7 @@ def _write_artifact(
2565
2638
  out_path.mkdir(parents=True, exist_ok=True)
2566
2639
 
2567
2640
  artifact: Dict[str, Any] = { # noqa: UP006
2568
- "generated": datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
2641
+ "generated": iso_cet(),
2569
2642
  "harness_version": "71",
2570
2643
  "cells": cells,
2571
2644
  }
@@ -40,6 +40,12 @@ from firestarter.messages import (
40
40
 
41
41
  logger = logging.getLogger("Codec")
42
42
 
43
+ # Older, shorter forms of fixed-width acks that the host still accepts. The
44
+ # value is the legacy param-region length; the decoder reads only the leading
45
+ # catalog params that fit in it. MSG_OK_CFG gained the trailing u16 bandgap in
46
+ # 3.1.0b4, so the 3.1.0 stable firmware still sends the 9-byte form.
47
+ _LEGACY_PARAM_BYTES = {MSG_OK_CFG: 9}
48
+
43
49
  # REVISION_* byte → silkscreen-string mapping for MSG_OK_REV rendering.
44
50
  # Mirrors firmware enum at firestarter/include/rurp_shield.h. Lookup-via-dict.get()
45
51
  # so unknown bytes fall back to "Rev{n}" instead of raising.
@@ -71,9 +77,13 @@ def format_message(msg_id: int, params: List[Any], entry: MessageDef) -> str | N
71
77
  effective!=0xFF → "Rev{effective}, Override HW: Rev{physical}"
72
78
 
73
79
  P-03 MSG_OK_CFG — params[0]=r1 u32, params[1]=r2 u32, params[2]=override u8,
74
- params[3]=bandgap_mv u16 (v1.43)
75
- override==0xFF → "R1: {r1}, R2: {r2}"
80
+ params[3]=bandgap_mv u16 (absent on firmware before 3.1.0b4)
81
+ override==0xFF → "R1: {r1}, R2: {r2}, Bandgap: {n} mV"
76
82
  override!=0xFF → "R1: {r1}, R2: {r2}, Override HW: {silkscreen_str}, Bandgap: {n} mV"
83
+ The Bandgap clause is always present when the ack carries the field:
84
+ HardwareManager.read_calibration keys on it. The 9-byte form from older
85
+ firmware has no bandgap, so it renders without the Bandgap clause, and
86
+ read_calibration reports that firmware as too old.
77
87
  Override clause now routes through
78
88
  _REVISION_SILKSCREEN so the same byte that surfaces as "Rev 2.0-class"
79
89
  via MSG_OK_REV no longer surfaces as "Rev2" on the adjacent ack line.
@@ -96,8 +106,8 @@ def format_message(msg_id: int, params: List[Any], entry: MessageDef) -> str | N
96
106
  eff_str = _REVISION_SILKSCREEN.get(effective, f"Rev{effective}")
97
107
  return f"{eff_str}, Override HW: {phys_str}"
98
108
 
99
- if msg_id == MSG_OK_CFG and len(params) == 4:
100
- r1, r2, override, bandgap_mv = params[0], params[1], params[2], params[3]
109
+ if msg_id == MSG_OK_CFG and len(params) in (3, 4):
110
+ r1, r2, override = params[0], params[1], params[2]
101
111
  head = f"R1: {r1}, R2: {r2}"
102
112
  if override != 0xFF:
103
113
  # Route the override byte through
@@ -106,9 +116,12 @@ def format_message(msg_id: int, params: List[Any], entry: MessageDef) -> str | N
106
116
  # No-space "Rev{n}" fallback mirrors the MSG_OK_REV branch shape.
107
117
  override_str = _REVISION_SILKSCREEN.get(override, f"Rev{override}")
108
118
  head += f", Override HW: {override_str}"
119
+ if len(params) == 3:
120
+ # Firmware before 3.1.0b4 sends no bandgap field.
121
+ return head
109
122
  # v1.43 appended the calibrated reference. Rendered last so the
110
123
  # override clause keeps the position every prior release put it in.
111
- return f"{head}, Bandgap: {bandgap_mv} mV"
124
+ return f"{head}, Bandgap: {params[3]} mV"
112
125
 
113
126
  # Silkscreen-aware rendering for the two
114
127
  # boot-time INFO surfaces that carry the same revision byte as
@@ -230,18 +243,27 @@ def decode_id_frame(frame_len: int, body: bytes) -> LogMessage | None:
230
243
  # entries carry param_bytes == -1 in the catalog; for those we
231
244
  # cannot pre-validate, but _decode_param will surface any overrun
232
245
  # via IndexError below.
233
- if entry.param_bytes >= 0 and len(params_bytes) != entry.param_bytes:
246
+ legacy_len = _LEGACY_PARAM_BYTES.get(msg_id)
247
+ is_legacy = legacy_len is not None and len(params_bytes) == legacy_len
248
+ if (
249
+ entry.param_bytes >= 0
250
+ and len(params_bytes) != entry.param_bytes
251
+ and not is_legacy
252
+ ):
234
253
  logger.warning(
235
254
  f"Param shape mismatch for ID 0x{msg_id:02x} ({entry.name}): "
236
255
  f"expected {entry.param_bytes} bytes, got {len(params_bytes)}"
237
256
  )
238
257
  return None
239
258
 
240
- # Decode each param per the catalog grammar.
259
+ # Decode each param per the catalog grammar. A legacy short form stops
260
+ # at the end of its shorter param region.
241
261
  values: List[Any] = [] # noqa: UP006
242
262
  cursor = 0
243
263
  try:
244
264
  for ptype, _prender in entry.params:
265
+ if is_legacy and cursor >= len(params_bytes):
266
+ break
245
267
  value, cursor = _decode_param(ptype, params_bytes, cursor)
246
268
  values.append(value)
247
269
  except (IndexError, struct.error, ValueError) as exc:
@@ -215,3 +215,15 @@ REVISION_2_1 = 0x03 # via EEPROM override only — ADC cannot distinguish
215
215
  REVISION_2_2 = 0x04 # via EEPROM override only — ADC cannot distinguish
216
216
  REVISION_2_3 = 0x05 # R41=10k physical detect
217
217
  REVISION_UNKNOWN = 0xFE # ADC band-gap or pre-detect-resistor + A2 indeterminate
218
+
219
+ # Accepted bands for the stored voltage-divider resistors and the calibrated
220
+ # bandgap. Firmware sync: rurp_shield.h VALUE_R1_MIN/MAX and
221
+ # VALUE_R2_MIN/MAX (+/-25 % of 270000 and 44000 ohm), and rurp_voltage_math.h
222
+ # RURP_BANDGAP_MIN_MV/MAX_MV. The firmware refuses a config command with a
223
+ # value outside these bands; the host refuses it first.
224
+ CONFIG_R1_MIN = 202500
225
+ CONFIG_R1_MAX = 337500
226
+ CONFIG_R2_MIN = 33000
227
+ CONFIG_R2_MAX = 55000
228
+ CONFIG_BANDGAP_MIN_MV = 1000
229
+ CONFIG_BANDGAP_MAX_MV = 1200
@@ -23,7 +23,6 @@ human-input field gates it.
23
23
 
24
24
  from __future__ import annotations
25
25
 
26
- import datetime
27
26
  import hashlib
28
27
  import json
29
28
  from dataclasses import dataclass, field
@@ -45,6 +44,7 @@ from firestarter.chip_test import (
45
44
  repeat_policy_tag,
46
45
  resolve_error_name,
47
46
  )
47
+ from firestarter.timestamps import iso_cet
48
48
 
49
49
  # ---------------------------------------------------------------------------
50
50
  # Module constants -- single sources of truth
@@ -774,8 +774,8 @@ class DiagnosticReport:
774
774
  over dataclass fields, so this field's absence from that list is the
775
775
  whole exclusion mechanism."""
776
776
 
777
- def _utc_now(self) -> str:
778
- return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
777
+ def _now(self) -> str:
778
+ return iso_cet()
779
779
 
780
780
  def _auto_capture_dict(self) -> dict[str, Any]:
781
781
  ac = self.auto_capture
@@ -1065,7 +1065,7 @@ class DiagnosticReport:
1065
1065
  """
1066
1066
  return {
1067
1067
  "schema_version": SCHEMA_VERSION,
1068
- "generated": self._utc_now(),
1068
+ "generated": self._now(),
1069
1069
  "elapsed": self.elapsed,
1070
1070
  "auto_capture": self._auto_capture_dict(),
1071
1071
  "transport_health": self._transport_dict(),
@@ -15,7 +15,6 @@ import shutil
15
15
  import statistics
16
16
  import time
17
17
  from contextlib import contextmanager
18
- from datetime import datetime
19
18
  from pathlib import Path
20
19
  from typing import Callable, Dict, Tuple # noqa: UP035
21
20
 
@@ -75,6 +74,7 @@ from firestarter.serial_comm import (
75
74
  WRITE_BUDGET_MAX_S,
76
75
  SerialCommunicator,
77
76
  )
77
+ from firestarter.timestamps import iso_cet_basic
78
78
  from firestarter.utils import extract_hex_to_decimal
79
79
  from firestarter.write_blank_guard import (
80
80
  incomplete_refusal_text,
@@ -551,6 +551,12 @@ class EpromOperator:
551
551
  # `--full` run, which never sets an abort_predicate, can never be
552
552
  # mistaken for this host's own doing.
553
553
  self._read_abort_intended: bool = False
554
+ # Whether the ERROR that ended the last `_run_state_machine` call was
555
+ # this host's own deliberate read stop (see `_is_own_read_abort`).
556
+ # Decided ONCE, where the error is caught, so the log level there and
557
+ # `_drive_region_compare`'s verdict can never disagree. Reset per
558
+ # operation alongside `_read_abort_stopped_at`.
559
+ self._read_abort_accepted: bool = False
554
560
  # SESS-01 (Phase 206): whether this operator is currently holding one
555
561
  # validated link open across multiple calls instead of connecting
556
562
  # and tearing down per call. Default off -- joins the transient
@@ -716,7 +722,11 @@ class EpromOperator:
716
722
  # A leased setup whose ack was rejected (not a raised
717
723
  # error) is a failed setup, treated identically to a cold
718
724
  # connect failure: the caller's existing not-`command_dict`
719
- # guard in `_operation_context` handles it unchanged.
725
+ # guard in `_operation_context` handles it unchanged. The
726
+ # firmware may still be mid-reply, so the held link is not
727
+ # trusted again: drop it, as the SerialError arm above does,
728
+ # and the next operation cold-connects.
729
+ self._disconnect_programmer()
720
730
  return None, 0
721
731
  buffer_size = self._calculate_buffer_size()
722
732
  logger.debug(
@@ -871,6 +881,7 @@ class EpromOperator:
871
881
  self.last_firmware_error_code = None
872
882
  self.last_firmware_error_message = None
873
883
  self._read_abort_stopped_at = None
884
+ self._read_abort_accepted = False
874
885
  try:
875
886
  with logging_redirect_tqdm():
876
887
  # --- INIT Phase ---
@@ -897,7 +908,14 @@ class EpromOperator:
897
908
  logger.error(f"Communication error during {operation_name}: {e}")
898
909
  return False, str(e)
899
910
  except EpromOperationError as e:
900
- logger.error(f"Programmer error during {operation_name}: {e}")
911
+ self._read_abort_accepted = self._is_own_read_abort(e.error_code)
912
+ if self._read_abort_accepted:
913
+ # The host stopped this read on purpose; the caller reports
914
+ # the compare result. An ERROR line here would read as a
915
+ # hardware fault on every non-blank or mismatching chip.
916
+ logger.debug(f"Read stopped by the host during {operation_name}: {e}")
917
+ else:
918
+ logger.error(f"Programmer error during {operation_name}: {e}")
901
919
  # Debug session w27c512-devtest-all-bad: record the firmware's
902
920
  # own id and text before collapsing this to a bool, so the
903
921
  # diagnostic report can state WHY a step failed instead of
@@ -907,6 +925,35 @@ class EpromOperator:
907
925
  return False, str(e)
908
926
  finally:
909
927
  progress.close()
928
+ # A leased link outlives this call: never carry a read stop's
929
+ # expected-error mark into the next operation.
930
+ if self.comm is not None:
931
+ self.comm.expected_error_id = None
932
+
933
+ def _is_own_read_abort(self, error_code: int | None) -> bool:
934
+ """Whether an ERROR is the consequence of this host's own read stop.
935
+
936
+ D-08: the deliberate stop and a genuine timeout both surface as an
937
+ ERROR carrying MSG_ERR_TIMEOUT -- the two are wire-identical. Accept
938
+ the error as this host's own doing ONLY when all four hold: the
939
+ default path actually asked for a stop (`_read_abort_intended`), the
940
+ read loop actually recorded one (`_read_abort_stopped_at` is not
941
+ `None`), the firmware's own error id is exactly the timeout id (not
942
+ some other fault wearing its clothes), and the stop happened
943
+ recently enough that this error frame could plausibly be its
944
+ consequence (the bounded window). Any single condition failing
945
+ means a real fault -- a mismatching chip whose read failed for an
946
+ unrelated reason must never be reported as a mismatch, and this
947
+ host's own deliberate abort must never be reported as hardware
948
+ trouble.
949
+ """
950
+ stopped_at = self._read_abort_stopped_at
951
+ return (
952
+ self._read_abort_intended
953
+ and stopped_at is not None
954
+ and error_code == MSG_ERR_TIMEOUT
955
+ and (time.monotonic() - stopped_at) <= READ_ABORT_ACCEPTANCE_WINDOW_S
956
+ )
910
957
 
911
958
  def _execute_phase(
912
959
  self, phase_name: str, progress: ClassProgressHandler
@@ -1210,7 +1257,11 @@ class EpromOperator:
1210
1257
  if abort_predicate is not None and abort_predicate():
1211
1258
  stopped = True
1212
1259
  self._read_abort_stopped_at = time.monotonic()
1213
- logger.info(
1260
+ # The firmware's ack-wait timeout is now the expected
1261
+ # end of this read: log it at DEBUG, not ERROR.
1262
+ # `_run_state_machine` clears this on the way out.
1263
+ self.comm.expected_error_id = MSG_ERR_TIMEOUT
1264
+ logger.debug(
1214
1265
  f"Read stopped in flight at 0x{start_addr:06x} "
1215
1266
  "(abort predicate fired)."
1216
1267
  )
@@ -1332,7 +1383,7 @@ class EpromOperator:
1332
1383
  # path is to use the actual board name -- but the unit-test
1333
1384
  # surface doesn't have a serial connection, so we fall back).
1334
1385
  if output_dir is None:
1335
- timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
1386
+ timestamp = iso_cet_basic()
1336
1387
  output_dir = str(
1337
1388
  Path(DEFAULT_RUN_OUTPUT_DIR)
1338
1389
  / f"consistency-check-{eprom_name}-unknown-board-{timestamp}"
@@ -1509,7 +1560,7 @@ class EpromOperator:
1509
1560
  source_sha = hashlib.sha256(Path(source_image_path).read_bytes()).hexdigest()
1510
1561
 
1511
1562
  if output_dir is None:
1512
- timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
1563
+ timestamp = iso_cet_basic()
1513
1564
  output_dir = str(
1514
1565
  Path(DEFAULT_RUN_OUTPUT_DIR)
1515
1566
  / f"write-cycle-{eprom_name}-unknown-board-{timestamp}"
@@ -1627,7 +1678,7 @@ class EpromOperator:
1627
1678
  from firestarter.serial_comm import FaultInjectingSerialCommunicator
1628
1679
 
1629
1680
  if output_dir is None:
1630
- timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
1681
+ timestamp = iso_cet_basic()
1631
1682
  output_dir = f"fault-inject-{eprom_name}-{direction}-{timestamp}"
1632
1683
  output_path = Path(output_dir)
1633
1684
  output_path.mkdir(parents=True, exist_ok=True)
@@ -1894,7 +1945,7 @@ class EpromOperator:
1894
1945
  return False
1895
1946
 
1896
1947
  if output_dir is None:
1897
- timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
1948
+ timestamp = iso_cet_basic()
1898
1949
  output_dir = f"fault-inject-latency-{fault_form}-{timestamp}"
1899
1950
  output_path = Path(output_dir)
1900
1951
  output_path.mkdir(parents=True, exist_ok=True)
@@ -2098,7 +2149,7 @@ class EpromOperator:
2098
2149
  return False
2099
2150
 
2100
2151
  if output_dir is None:
2101
- timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
2152
+ timestamp = iso_cet_basic()
2102
2153
  output_dir = f"connect-cost-{timestamp}"
2103
2154
  output_path = Path(output_dir)
2104
2155
  output_path.mkdir(parents=True, exist_ok=True)
@@ -2803,28 +2854,11 @@ class EpromOperator:
2803
2854
 
2804
2855
  aborted = False
2805
2856
  if not is_ok:
2806
- # D-08: the deliberate stop and a genuine timeout both surface
2807
- # here as `_run_state_machine` returning `(False, ...)` with
2808
- # `last_firmware_error_code == MSG_ERR_TIMEOUT` -- the two are
2809
- # wire-identical. Accept the error as this host's own doing ONLY
2810
- # when all four hold: the default path actually asked for a stop
2811
- # (`_read_abort_intended`), the read loop actually recorded one
2812
- # (`stopped_at` is not `None`), the firmware's own error id is
2813
- # exactly the timeout id (not some other fault wearing its
2814
- # clothes), and the stop happened recently enough that this
2815
- # error frame could plausibly be its consequence (the bounded
2816
- # window). Any single condition failing means a real fault: take
2817
- # the exit-2 path exactly as before -- a mismatching chip whose
2818
- # read failed for an unrelated reason must never be reported as
2819
- # a mismatch, and this host's own deliberate abort must never be
2820
- # reported as hardware trouble.
2821
- stopped_at = self._read_abort_stopped_at
2822
- aborted = (
2823
- self._read_abort_intended
2824
- and stopped_at is not None
2825
- and self.last_firmware_error_code == MSG_ERR_TIMEOUT
2826
- and (time.monotonic() - stopped_at) <= READ_ABORT_ACCEPTANCE_WINDOW_S
2827
- )
2857
+ # D-08: `_run_state_machine` already decided, with
2858
+ # `_is_own_read_abort`, whether the error that ended the read was
2859
+ # this host's own stop. Anything else is a real fault: take the
2860
+ # exit-2 path exactly as before.
2861
+ aborted = self._read_abort_accepted
2828
2862
  if not aborted:
2829
2863
  return 2
2830
2864
 
@@ -41,6 +41,7 @@ from firestarter.exceptions import (
41
41
  )
42
42
  from firestarter.fw_release_gate import require_installable_release
43
43
  from firestarter.serial_comm import SerialCommunicator
44
+ from firestarter.timestamps import iso_text_to_cet
44
45
 
45
46
  logger = logging.getLogger("Firmware")
46
47
 
@@ -62,7 +63,7 @@ class ReleaseInfo(TypedDict):
62
63
  version: str # tag_name from GitHub API
63
64
  tag: str # raw tag_name (same as version for firmware releases)
64
65
  channel: str # "stable" or "prerelease"
65
- published: str # ISO-8601 from published_at field
66
+ published: str # published_at as ISO 8601 in CET
66
67
  asset_url: str # browser_download_url for the board-matching .hex asset
67
68
 
68
69
 
@@ -468,7 +469,7 @@ class FirmwareManager:
468
469
  version=tag,
469
470
  tag=tag,
470
471
  channel="prerelease" if is_pre else "stable",
471
- published=r.get("published_at") or "",
472
+ published=iso_text_to_cet(r.get("published_at") or ""),
472
473
  asset_url=asset_url,
473
474
  )
474
475
  )