Guest User

Untitled

a guest
Jun 11th, 2026
98
0
Never
2
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 132.09 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. """
  3. GE FlashPad Apollo -- URP/PDAP Acquisition Script
  4. Detector: GE Optima XR200/220 AMX, SuperBee V1#DR_SW_FMI_FW45_2013_CD59
  5. Protocol: URP (Unified Registration Protocol) + PDAP (Proprietary Detector Access Protocol)
  6. Detector IP: 192.168.1.30, port 8100 (detector listens for all commands here)
  7. Host listens on: 5550 (Detector_HostPort from ConnectionPoint.cfg, set via PORT_SETUP)
  8.  
  9. Reverse-engineered from libDetection.so.c (598,650 lines) and XRImDet.lnx.c
  10. See PROTOCOL_FINDINGS.md for complete protocol reference.
  11. """
  12.  
  13. import socket
  14. import struct
  15. import time
  16. import sys
  17. import os
  18. from datetime import datetime
  19.  
  20. # -- Network configuration -----------------------------------------------------
  21. # All values from IDCConfigurations/SuperBee/Application/ConnectionPoint.cfg [eth0]
  22. DETECTOR_IP        = "192.168.1.30"   # DetectorIP.Val
  23. HOST_IP            = "192.168.1.1"    # Detector_SysIP.Val
  24. BROADCAST_ADDR     = "192.168.1.255"  # BroadcastAddress.Val
  25. BROADCAST_PORT     = 8100             # BroadcastPort.Val -- detector listens for ALL commands
  26. HOST_DISC_PORT     = 4500             # SystemPort.Val    -- sent in SYSTEM_STARTUP; detector sends beacons here
  27. HOST_CMD_PORT      = 5550             # Detector_HostPort.Val -- detector sends protocol replies here after PORT_SETUP
  28. HOST_IMAGE_PORT    = 6660             # Detector_ImagePort.Val -- detector sends pixel data here
  29. BROADCAST_INTERVAL = 2.0              # BroadcastInterval.Val
  30. BROADCAST_TIMEOUT  = 50.0             # BroadcastTimeout.Val
  31. # NOTE: 48879 (0xBEEF) was invented in early sessions and is not a GE port.
  32. HOST_REPLY_PORT    = HOST_CMD_PORT    # alias -- keep for backward compat; resolves to 5550
  33.  
  34. # -- PDAP cmd_type constants ---------------------------------------------------
  35. CMD_SYSTEM_STARTUP           = 0x00000001
  36. CMD_PORT_SETUP               = 0x00000002
  37. CMD_SIGNATURE_REQUEST        = 0x00000003
  38. CMD_GENERIC_SCRIPT           = 0x00000005   # download reply also uses this type
  39. CMD_EXECUTE_SCRIPT           = 0x00000006   # host sends; detector replies with same type
  40. CMD_EXECUTE_SCRIPT_STATUS    = 0x00000007   # intermediate status during execution
  41. CMD_EXECUTION_COMPLETE       = 0x00010000   # confirmed: createExecutionScriptCompleteCmd
  42. CMD_DETECTOR_CMD_REPLY       = 0x00020000   # createDetectorCommandReply
  43. CMD_IMAGE_XFER_STATUS_QUERY  = 0x00030000   # createImageTransferStatusQueryCmd
  44. CMD_IMAGE_XFER_STATUS_REPLY  = 0x00000099
  45. # cmd_type=0x40000 observed 1s after EXECUTE_SCRIPT, payload=17 (4 bytes LE).
  46. # Not in any PDAPCommandCreator function in libDetection.so.c.
  47. # Believed to be a detector-firmware state notification: payload = state_id.
  48. # state_id=17=0x11 likely means "detector ready / waiting for X-ray exposure".
  49. CMD_DETECTOR_STATE_NOTIFY    = 0x00040000
  50. # Image retrieval commands (confirmed from libDetection.so.c):
  51. #   createImageRetrivalRequestCmd(scriptId):  cmd_type=0x41, plen=4, payload=scriptId:4LE
  52. #     Detector replies with same cmd_type=0x41 confirming "No of Images to be retrieved = N"
  53. #   createImageRetrivalCmd(hostPort, imagePort): cmd_type=0x98, plen=4
  54. #     payload=[imagePort:2LE][hostPort:2LE]  (CONCAT22 stores param_3 at low address)
  55. #     Tells detector to stream image pixel data to host:imagePort
  56. CMD_IMAGE_RETRIVAL_REQUEST   = 0x00000041   # host sends scriptId; detector confirms image count
  57. CMD_IMAGE_RETRIVAL           = 0x00000098   # host sends [imagePort:2LE][hostPort:2LE]
  58.  
  59. # -- Data UPLOAD (read FROM detector) -- non-destructive ----------------------
  60. # Confirmed from libDetection.so.c PDAPCommandCreator:
  61. #   createConfigureUploadCmd (336571): cmd_type=0x13, plen=4, payload=[uploadId:4LE]
  62. #     reply createConfigureUploadCmdReply (336600): cmd_type=0x13, plen=5,
  63. #           payload=[status:1][totalSize:4LE]   -- detector reports how many bytes it will upload
  64. #   createUploadBufferCmd (336633): cmd_type=0x14, plen=8, payload=[bufId:4LE][numBytes:4LE]
  65. #     reply createUploadBufferReply (336740): cmd_type=0x14, payload=[bufId:4LE][numBytes:4LE][data]
  66. #   createEndOfUploadCmd: empty command (len=0)
  67. # Data-category upload IDs (EthernetDetectionDevice.cpp ~0x1115 / startFWDownload dispatch):
  68. #   0x71 = HostList (HostListInfo.dyn -- the REGISTERED-HOST list / pairing state)
  69. #   0x72 = DetectorInfo (DetectorInfo.dyn -- serial/model/fw/MAC)   0x73 = cal results (likely)
  70. # This is the host's read-back path (DetectorDataUploader::uploadData). We use it to dump the
  71. # detector's stored registration state with ZERO writes, before considering any 0x71 flash write.
  72. CMD_CONFIGURE_UPLOAD         = 0x00000013
  73. CMD_UPLOAD_BUFFER            = 0x00000014
  74. UPLOAD_ID_HOSTLIST           = 0x71
  75. UPLOAD_ID_DETECTORINFO       = 0x72
  76. UPLOAD_ID_CALRESULTS         = 0x73
  77.  
  78. # Number of image-buffer frames per full image.
  79. # From SuperBee/Detector/URP_DETECTOR/DeviceConfig.cfg [Diagnostics] EthernetNoOfImages.Val = 8.
  80. # The detector splits one 2048x2048x16-bit image into this many cmd_type=0x0F IMAGE_BUFFER frames
  81. # and streams them to the image port. This is also the "8" the detector reports in the 0x41 reply
  82. # ("No of Images to be retrieved = 8") -- it is the normal/expected count, NOT an error.
  83. ETHERNET_NO_OF_IMAGES        = 8
  84.  
  85. # In the PREVIEW transfer path (acquisition transfer_mode=2) the detector advertises imageId with
  86. # the high bit set (0x8000) and reports a 4-buffer image in its 0x41 reply -- a smaller preview
  87. # image, NOT the full 8-buffer frame.  Requesting 8 buffers [0..7] for a 4-buffer preview asks for
  88. # frames that don't exist in that set.  Used to size the 0x30000 missed-buffer reply correctly.
  89. PREVIEW_NO_OF_IMAGES         = 4
  90. PREVIEW_IMAGE_FLAG           = 0x8000   # imageId high bit => preview image
  91.  
  92. # Sensor-read commands (confirmed from PDAPCommandCreator + EthernetDetector::readSensor in
  93. # libDetection.so.c -- log "Read Sensor Success, sensorId: %x, sensorData %x").
  94. #   createReadDetectorSensorCmd:          cmd_type=0x7900, plen=4, payload=[selector:4LE]  (raw)
  95. #   createConvertedReadDetectorSensorCmd: cmd_type=0x7902  (engineering units; GE.txt: UNSUPPORTED)
  96. #   createDetectorDetailedSensorCmd:      cmd_type=0x7904  (Realtek radio board / detailed)
  97. # Reply: same cmd_type, payload=[value:4LE].
  98. CMD_READ_SENSOR              = 0x00007900
  99. CMD_READ_SENSOR_CONVERTED    = 0x00007902
  100. CMD_DETAILED_SENSOR          = 0x00007904
  101. # Accelerometer / vibration-sensor axis selectors (GE.txt: 0x7900,0x42=X  0x43=Y; Z assumed 0x44).
  102. SENSOR_ACCEL_X = 0x42
  103. SENSOR_ACCEL_Y = 0x43
  104. SENSOR_ACCEL_Z = 0x44
  105. # Temperature sensor IDs (DeviceConfig DEM: SurfaceTemp_ID=336=0x150, PanelTemp_ID=352=0x160).
  106. SENSOR_TEMP_SURFACE = 0x150
  107. SENSOR_TEMP_PANEL   = 0x160
  108. # Accelerometer linear conversion (DeviceConfig DEM: Avibration, Bvibration).
  109. ACCEL_A = 0.15258
  110. ACCEL_B = -312.50
  111.  
  112. # Full sensor map recovered from the detector's own [Sensor] table (backup upload-ID 0x07,
  113. # "DetectorSerialNumber = UA45829-7", Rev 1.2). Format there: Name, Cmd, SensorId, EqNum, coeffs...
  114. # Cmd is 30978 = 0x7902 (the CONVERTED read) for ALL of them -- i.e. the detector is expected to do
  115. # the unit conversion internally; the host-side coefficients were blank for this firmware rev.
  116. # (name, sensorId). Read via cmd 0x7902; reply payload = [value:4LE].
  117. SENSOR_TABLE = [
  118.     ("Temp_Surface",        336),   # 0x150
  119.     ("Temp_Panel",          352),   # 0x160
  120.     ("DCIN_RAW",             10),
  121.     ("LCORE_UNREG",          11),
  122.     ("LPANA_UNREG",          12),
  123.     ("LNANA_UNREG",          13),
  124.     ("SCAN_VCC",             14),
  125.     ("P5V_REF",              16),
  126.     ("V_ON",                 17),
  127.     ("V_OFF",                18),
  128.     ("V_COMMON",             19),
  129.     ("PARCVA_U",             21),
  130.     ("NARCVA_U",             22),
  131.     ("P5VA_SW",              25),
  132.     ("N5VA_SW",              26),
  133.     ("PRAIL_SW",             27),
  134.     ("NRAIL_SW",             28),
  135.     ("3V3",                  29),
  136.     ("FGATE_NVC_L",          34),
  137.     ("FGATE_PVC_L",          36),
  138.     ("VCC_UNREG",            37),
  139.     ("PARCPREG",             38),
  140.     ("NARCPREG",             39),
  141.     ("PANA_UNREG",           45),
  142.     ("NANA_UNREG",           46),
  143.     ("Accelerator",          70),
  144.     ("Gravity",              78),
  145.     ("Battery_Status",      256),
  146.     ("Battery_Name_Report", 257),
  147.     ("Battery_Life",        258),
  148.     ("Battery_Capacity",    259),
  149.     ("Grid_Status",         272),
  150. ]
  151.  
  152. # -- URP flags -----------------------------------------------------------------
  153. URP_FLAG_COMMAND = 0   # host->detector
  154. URP_FLAG_ACK     = 1   # detector->host
  155.  
  156. # -- Inner command DETECTORCODE values -----------------------------------------
  157. DC_ACQUISITION   = 1
  158. DC_ROE_COMMAND   = 2
  159. DC_SEND_EVENT    = 3
  160. DC_WAIT_EVENT    = 4
  161. DC_DELAY         = 5
  162.  
  163. # -- ROE register addresses from DeviceConfig.cfg ------------------------------
  164. ROE_SCAN_SETUP_CMD   = 0x00004050   # ScanSetUpCommand
  165. ROE_SCAN_SETUP_VAL   = 0x00D20000   # ScanSetUpValue
  166. ROE_INIT_CMD2        = 0x00004002   # second ROE init step
  167. ROE_INIT_CMD3        = 0x00007900   # third ROE init step
  168. ROE_STANDBY_CMD      = 0x00004080   # standby loop
  169.  
  170.  
  171. # -- Low-level packet builders -------------------------------------------------
  172.  
  173. def make_urp_packet(pdap_data: bytes, reply_port: int = 0,
  174.                     flag: int = URP_FLAG_COMMAND) -> bytes:
  175.     """Wrap PDAP data in a URP header.
  176.  
  177.    WARNING: the parameter names are historically backwards relative to URP semantics:
  178.      flag       → URP bytes 0-3  = SeqId   (not a flag -- this is the sequence counter)
  179.      reply_port → URP bytes 4-7  = CmdFlag (not a port -- 0=data packet, 1=bare ACK)
  180.  
  181.    Correct usage for a host command:
  182.      make_urp_packet(pdap, flag=self._seq_id, reply_port=0)
  183.        flag=SeqId  (increment per command; detector drops old SeqIds)
  184.        reply_port=0 (CmdFlag=0 → data packet → detector parses PDAP body)
  185.  
  186.    NEVER pass a non-zero reply_port:
  187.      A non-zero value goes into CmdFlag → detector treats packet as bare ACK → PDAP is NOT parsed.
  188.    (Historical bug: early scripts passed reply_port=1 or reply_port=48879 causing this exact failure.)
  189.    """
  190.     return struct.pack("<II", flag, reply_port) + pdap_data
  191.  
  192.  
  193. def make_pdap(cmd_type: int, payload: bytes = b"") -> bytes:
  194.     """Build PDAP message: [cmd_type:4LE][payload_len:4LE][payload]"""
  195.     return struct.pack("<II", cmd_type, len(payload)) + payload
  196.  
  197.  
  198. def parse_pdap(data: bytes) -> tuple:
  199.     """Parse PDAP from raw bytes (after URP header stripped).
  200.    Returns (cmd_type, payload_len, payload) or raises ValueError."""
  201.     if len(data) < 8:
  202.         raise ValueError(f"PDAP too short: {len(data)} bytes")
  203.     cmd_type, payload_len = struct.unpack_from("<II", data, 0)
  204.     payload = data[8:8 + payload_len]
  205.     return cmd_type, payload_len, payload
  206.  
  207.  
  208. def parse_urp(data: bytes) -> tuple:
  209.     """Parse URP packet. Returns (seq_id, cmd_flag, pdap_data).
  210.  
  211.    URP wire format: [SeqId:4LE][CmdFlag:4LE][PDAP...]
  212.      SeqId   -- sequence counter of the sender (increments per data packet)
  213.      CmdFlag -- 0 = data packet (PDAP body follows); non-zero = bare ACK (no body)
  214.    """
  215.     if len(data) < 8:
  216.         raise ValueError(f"URP too short: {len(data)} bytes")
  217.     seq_id, cmd_flag = struct.unpack_from("<II", data, 0)
  218.     return seq_id, cmd_flag, data[8:]
  219.  
  220.  
  221. # -- PDAP command builders -----------------------------------------------------
  222.  
  223. def build_system_startup(host_ip: str = HOST_IP,
  224.                           disc_port: int = HOST_DISC_PORT) -> bytes:
  225.     """SYSTEM_STARTUP -- cmd_type=1, payload=[SystemPort:2BE][host_ip:4BE]
  226.    Tells the detector: "I am at host_ip; send your periodic discovery beacons to disc_port."
  227.    disc_port = SystemPort from ConnectionPoint.cfg = 4500.
  228.    Confirmed from libDetection.so.c: createSystemStartupCmd(PDAPCreator, SystemPort, hostIP).
  229.    Port is encoded big-endian (htons / network byte order).
  230.    Must be the FIRST packet sent; detector ACKs it with a bare URP ACK then starts beaconing.
  231.    """
  232.     ip_bytes = socket.inet_aton(host_ip)
  233.     payload  = struct.pack(">H", disc_port) + ip_bytes   # port big-endian (htons)
  234.     return make_pdap(CMD_SYSTEM_STARTUP, payload)
  235.  
  236.  
  237. def build_port_setup(host_cmd_port: int = HOST_CMD_PORT,
  238.                      host_img_port: int = HOST_IMAGE_PORT) -> bytes:
  239.     """PORT_SETUP -- cmd_type=2, payload=[hostCmdPort:2BE][imagePort:2BE]
  240.  
  241.    Tells the detector two things:
  242.      host_cmd_port:  port to send ALL subsequent protocol replies to (= Detector_HostPort = 5550)
  243.      host_img_port:  port to stream image pixel data to             (= Detector_ImagePort = 6660)
  244.  
  245.    Both ports come from ConnectionPoint.cfg [eth0].  This command is REQUIRED to get image
  246.    data flowing -- without it the detector does not configure its PurpEngine streaming engine
  247.    and _ptrImageTransferElement on the host side is never initialised.
  248.  
  249.    Byte order: both shorts are big-endian (same as the port field in SYSTEM_STARTUP).
  250.    The detector bare-ACKs PORT_SETUP; after that all replies arrive on host_cmd_port.
  251.    """
  252.     payload = struct.pack(">HH", host_cmd_port, host_img_port)
  253.     return make_pdap(CMD_PORT_SETUP, payload)
  254.  
  255.  
  256. def build_signature_request() -> bytes:
  257.     """SIGNATURE_REQUEST -- cmd_type=3, no payload"""
  258.     return make_pdap(CMD_SIGNATURE_REQUEST)
  259.  
  260.  
  261. def build_execute_script() -> bytes:
  262.     """EXECUTE_SCRIPT -- cmd_type=6, no payload"""
  263.     return make_pdap(CMD_EXECUTE_SCRIPT)
  264.  
  265.  
  266. def build_image_xfer_status_query(port1: int = HOST_CMD_PORT,
  267.                                    port2: int = HOST_IMAGE_PORT) -> bytes:
  268.     """IMAGE_XFER_STATUS_QUERY -- cmd_type=0x30000"""
  269.     payload = struct.pack(">HH", port1, port2)
  270.     return make_pdap(CMD_IMAGE_XFER_STATUS_QUERY, payload)
  271.  
  272.  
  273. def build_image_xfer_status_reply(missed_ids: list = None,
  274.                                    image_id: int = 0,
  275.                                    script_id: int = 1,
  276.                                    parallel: bool = False) -> bytes:
  277.     """IMAGE_XFER_STATUS_REPLY -- sent by host in response to detector's cmd_type=0x30000.
  278.  
  279.    The detector's 0x30000 query is its "image transfer status query" carrying (imageId, scriptId).
  280.    The host answers with the list of buffers it still needs.  libDetection.so.c chooses the reply
  281.    format based on the host's `ParallelImageTransfer` config -- BUT here WE are the host, so we
  282.    pick.  Confirmed wire formats from libDetection.so.c:
  283.  
  284.      Serial   (ParallelImageTransfer=0): PDAPCommandCreator::createImageTransferStatusQueryReply
  285.                 (3-arg, ~334817) -> cmd_type=9
  286.                 payload = [numMissed:4LE][missedId_0:4LE]...
  287.      Parallel (ParallelImageTransfer=1): createImageTransferStatusQueryReply
  288.                 (5-arg, ~334759) -> cmd_type=0x99
  289.                 payload = [imageId:2LE][scriptId:2LE][numMissed:4LE][missedId_0:4LE]...
  290.                 NOTE: the two leading shorts are imageId/scriptId echoed back from the query
  291.                 (the caller passes local_18&0xffff=imageId, local_1c&0xffff=scriptId), NOT ports.
  292.                 The previous version of this builder packed [hostPort][imgPort] here -- that was
  293.                 wrong and is fixed.
  294.  
  295.    Pass missed_ids=[] (or None) when all images were received successfully (no retransmission).
  296.    The serial 0x09 reply demonstrably did NOT start the stream in prior runs (detector just
  297.    re-polls); the 0x99 parallel form is the only status-reply path tied to the image port, so
  298.    --parallel exists to test whether the firmware gates the pixel push on receiving it.
  299.    """
  300.     ids = missed_ids if missed_ids else []
  301.     n = len(ids)
  302.     if parallel:
  303.         # cmd_type=0x99: [imageId:2LE][scriptId:2LE][numMissed:4LE][id_0:4LE]...
  304.         # IMPORTANT: do NOT use make_pdap here. The 5-arg createImageTransferStatusQueryReply
  305.         # (libDetection.so.c ~334784) writes the embedded length field as `count*4 + 0xc`, which
  306.         # is the body length (count*4 + 8) PLUS 4 -- i.e. it counts from the length field itself,
  307.         # off-by-4 vs the serial cmd9 builder. The detector firmware was built against this exact
  308.         # creator, so we reproduce the byte layout precisely instead of the "natural" len(payload).
  309.         body = struct.pack("<HHI", image_id & 0xFFFF, script_id & 0xFFFF, n)
  310.         for mid in ids:
  311.             body += struct.pack("<I", mid)
  312.         length_field = n * 4 + 0xc
  313.         return struct.pack("<II", CMD_IMAGE_XFER_STATUS_REPLY, length_field) + body
  314.     else:
  315.         # cmd_type=9: [numMissed:4LE][id_0:4LE]...
  316.         payload = struct.pack("<I", n)
  317.         for mid in ids:
  318.             payload += struct.pack("<I", mid)
  319.         return make_pdap(0x00000009, payload)
  320.  
  321.  
  322. def build_image_retrival_request(script_id: int) -> bytes:
  323.     """IMAGE_RETRIVAL_REQUEST -- cmd_type=0x41, plen=4, payload=scriptId (4 bytes LE).
  324.    Confirmed from createImageRetrivalRequestCmd in libDetection.so.c:
  325.      local_10[1] = 0x41  (cmd_type)
  326.      local_10[0] = 4     (plen)
  327.      payload = scriptId  (4 bytes, passed by caller)
  328.    Host sends this after receiving the 0x30000 image-count notification.
  329.    Detector replies with the same cmd_type=0x41 logging "No of Images to be retrieved = N".
  330.    """
  331.     return make_pdap(CMD_IMAGE_RETRIVAL_REQUEST, struct.pack("<I", script_id))
  332.  
  333.  
  334. def build_image_retrival(host_port: int = HOST_REPLY_PORT,
  335.                           image_port: int = HOST_REPLY_PORT) -> bytes:
  336.     """IMAGE_RETRIVAL -- cmd_type=0x98, plen=4, payload=[imagePort:2LE][hostPort:2LE].
  337.    Confirmed from createImageRetrivalCmd(unsigned short param_2, unsigned short param_3):
  338.      local_10 = 0x98   (cmd_type)
  339.      local_14 = 4      (plen)
  340.      CONCAT22(param_2, param_3) stored as two 2-byte vars:
  341.        local_c   (lower address, bytes 8-9 of msg)  = param_3 = image_port
  342.        uStack_a  (higher address, bytes 10-11)      = param_2 = host_port
  343.    i.e. payload wire order = [image_port:2LE][host_port:2LE]
  344.    Tells detector: stream image pixel data to host:image_port.
  345.    host_port  = port for protocol ACKs (defaults to HOST_CMD_PORT=5550)
  346.    image_port = port for actual pixel data (defaults to HOST_IMAGE_PORT=6660)
  347.    """
  348.     return make_pdap(CMD_IMAGE_RETRIVAL, struct.pack("<HH", image_port, host_port))
  349.  
  350.  
  351. def build_configure_upload(upload_id: int) -> bytes:
  352.     """CONFIGURE_UPLOAD -- cmd_type=0x13, plen=4, payload=[uploadId:4LE].
  353.    Asks the detector to stage a data category for upload (read-back). Detector replies cmd_type=0x13
  354.    with [status:1][totalSize:4LE]. Non-destructive (read path)."""
  355.     return make_pdap(CMD_CONFIGURE_UPLOAD, struct.pack("<I", upload_id))
  356.  
  357.  
  358. def build_upload_buffer(buf_id: int, num_bytes: int) -> bytes:
  359.     """UPLOAD_BUFFER -- cmd_type=0x14, plen=8, payload=[bufId:4LE][numBytes:4LE].
  360.    Requests numBytes of the staged data. Detector replies cmd_type=0x14 with
  361.    [bufId:4LE][numBytes:4LE][data]. Non-destructive (read path)."""
  362.     return make_pdap(CMD_UPLOAD_BUFFER, struct.pack("<II", buf_id, num_bytes))
  363.  
  364.  
  365. # -- Data DOWNLOAD (WRITE to detector) -- *** WRITES THE DETECTOR'S FLASH *** ---
  366. # Confirmed from libDetection.so.c PDAPCommandCreator + EthernetDetector::downloadData (192407):
  367. #   createConfigureDownloadCmd (335116): cmd 0x0E, plen=8, payload=[downloadId:4LE][totalSize:4LE]
  368. #     reply createConfigureDownloadReply (335146): cmd 0x0E, plen=1, [status:1]
  369. #   createDownloadBufferCmd (335179): cmd 0x0F, payload=[bufId:4LE][numBytes:4LE][data]
  370. #     reply createDownloadBufferReply (335211): cmd 0x0F, plen=5, [status:1][_:4]
  371. #   createFlashFirmwareCmd: cmd 0x10, EMPTY  -- *** the FLASH COMMIT / BURN ***
  372. #     reply createFlashFirmwareReply (335253): cmd 0x10, plen=1, [status:1]
  373. # downloadId 0x71 = HostList (config region @ flash 0x940000) -- NOT the firmware region.
  374. CMD_CONFIGURE_DOWNLOAD       = 0x0000000E
  375. CMD_DOWNLOAD_BUFFER          = 0x0000000F
  376. CMD_FLASH_COMMIT             = 0x00000010
  377. DOWNLOAD_ID_HOSTLIST         = 0x71
  378.  
  379.  
  380. def build_configure_download(download_id: int, total_size: int) -> bytes:
  381.     """CONFIGURE_DOWNLOAD -- cmd 0x0E, [downloadId:4LE][totalSize:4LE]."""
  382.     return make_pdap(CMD_CONFIGURE_DOWNLOAD, struct.pack("<II", download_id, total_size))
  383.  
  384.  
  385. def build_download_buffer(buf_id: int, data: bytes) -> bytes:
  386.     """DOWNLOAD_BUFFER -- cmd 0x0F, [bufId:4LE][numBytes:4LE][data]."""
  387.     return make_pdap(CMD_DOWNLOAD_BUFFER, struct.pack("<II", buf_id, len(data)) + data)
  388.  
  389.  
  390. def build_flash_commit() -> bytes:
  391.     """FLASH_COMMIT (createFlashFirmwareCmd) -- cmd 0x10, empty payload. *** BURNS FLASH ***."""
  392.     return make_pdap(CMD_FLASH_COMMIT, b"")
  393.  
  394.  
  395. def hostlist_crc(data: bytes) -> int:
  396.     """CRC used on the detector's HostList/data blobs. Reverse-engineered & verified against the
  397.    live HostList from class CRC32 in libDetection.so.c: poly=0x04C11DB7, init=0, MSB-first,
  398.    augmented-message bit algorithm (data bit shifted into the LSB). To form the 4-byte trailer for a
  399.    blob `d`: struct.pack('>I', hostlist_crc(d + b'\\x00\\x00\\x00\\x00')) -- stored BIG-ENDIAN.
  400.    Self-check: hostlist_crc(blob_including_trailer) == 0.  Verified: matches real trailer B8B86FC3."""
  401.     crc = 0
  402.     for byte in data:
  403.         for bit in (0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01):
  404.             msb = crc & 0x80000000
  405.             crc = (crc << 1) & 0xFFFFFFFF
  406.             if byte & bit:
  407.                 crc |= 1
  408.             if msb:
  409.                 crc ^= 0x04C11DB7
  410.     return crc
  411.  
  412.  
  413. def hostlist_append_crc(body: bytes) -> bytes:
  414.     """Return body + its correct 4-byte big-endian CRC trailer (for building a modified HostList)."""
  415.     return body + struct.pack(">I", hostlist_crc(body + b"\x00\x00\x00\x00"))
  416.  
  417.  
  418. # HostList structure constants (decoded from the live 504-byte read).
  419. HL_HEADER_LEN   = 0x44          # DeviceId+Key+Name+Code + counts
  420. HL_OFF_COUNT    = 0x40          # CurrentNumberOfHosts (u16 LE)
  421. HL_OFF_PRIMARY  = 0x42          # IndexToPrimaryHost (u16 LE)
  422. HL_ENTRY_LEN    = 144           # per host: HostId(16) + FieldA(64) + FieldB(64)
  423.  
  424.  
  425. def hostid_from_mac(mac: str) -> str:
  426.     """Replicate generateHostId.py: MAC -> strip ':' -> UPPER -> last4 + full (16 hex chars)."""
  427.     h = mac.replace(":", "").replace("-", "").upper()
  428.     return (h[-4:] + h)[:16]
  429.  
  430.  
  431. def build_host_entry(hostid: str, name: str, location: str) -> bytes:
  432.     """Build one 144-byte host entry: HostId(16) + FieldA(64,name) + FieldB(64,location)."""
  433.     hid = hostid.encode("ascii")[:16].ljust(16, b"\x00")
  434.     fa  = name.encode("ascii")[:64].ljust(64, b"\x00")
  435.     fb  = location.encode("ascii")[:64].ljust(64, b"\x00")
  436.     e = hid + fa + fb
  437.     assert len(e) == HL_ENTRY_LEN, len(e)
  438.     return e
  439.  
  440.  
  441. def parse_hostlist(blob: bytes):
  442.     """Return (header, [entries], count, primary) from a HostList blob (incl. trailing CRC)."""
  443.     body = blob[:-4]
  444.     count = struct.unpack_from("<H", body, HL_OFF_COUNT)[0]
  445.     primary = struct.unpack_from("<H", body, HL_OFF_PRIMARY)[0]
  446.     entries = []
  447.     for i in range(count):
  448.         off = HL_HEADER_LEN + i * HL_ENTRY_LEN
  449.         entries.append(body[off:off + HL_ENTRY_LEN])
  450.     return body[:HL_HEADER_LEN], entries, count, primary
  451.  
  452.  
  453. def build_registered_hostlist(current: bytes, hostid: str, name: str, location: str) -> bytes:
  454.     """Build a new HostList that APPENDS our host as a new entry and sets it as the primary host.
  455.    Preserves all existing entries. Recomputes the CRC. Returns the full new blob (incl. CRC)."""
  456.     header, entries, count, primary = parse_hostlist(current)
  457.     our_index = count                       # appended at the end
  458.     new_entries = entries + [build_host_entry(hostid, name, location)]
  459.     new_header = bytearray(header)
  460.     struct.pack_into("<H", new_header, HL_OFF_COUNT, count + 1)
  461.     struct.pack_into("<H", new_header, HL_OFF_PRIMARY, our_index)
  462.     body = bytes(new_header) + b"".join(new_entries)
  463.     return hostlist_append_crc(body)
  464.  
  465.  
  466. # -- Inner command (packed struct) builders ------------------------------------
  467.  
  468. def pack_roe_command(roe_cmd: int, roe_data: int,
  469.                      response_flag: int = 0,
  470.                      timer_value: int = 1000000) -> bytes:
  471.     """PackedROECommand -- 14 bytes, DETECTORCODE=2
  472.    Layout: [DC=2:1][responseFlag:1][timerValue:4LE][roeCmd:4LE][roeData:4LE]
  473.    """
  474.     return struct.pack("<BBIII", DC_ROE_COMMAND, response_flag, timer_value, roe_cmd, roe_data)
  475.  
  476.  
  477. def pack_acquisition(type_mode: int, image_id: int = 1,
  478.                      no_scrubs: int = 0, scrub_duration: int = 50000,
  479.                      max_expose_time: int = 4000000, tail_time: int = 250000,
  480.                      transfer_mode: int = 0) -> bytes:
  481.     """PackedAcquisitionScript -- 17 bytes, DETECTORCODE=1
  482.    Layout: [DC=1:1][typeMode:1][imageId:1][noScrubs:1][scrubDur:4LE]
  483.            [maxExpose:4LE][tailTime:4LE][transferMode:1]
  484.    """
  485.     return struct.pack("<BBBBIIIB", DC_ACQUISITION, type_mode, image_id, no_scrubs,
  486.                        scrub_duration, max_expose_time, tail_time, transfer_mode)
  487.  
  488.  
  489. def pack_send_host_event(event_id: int) -> bytes:
  490.     """PackedSendHostEvent -- 5 bytes, DETECTORCODE=3
  491.    Layout: [DC=3:1][eventId:4LE]
  492.    """
  493.     return struct.pack("<BI", DC_SEND_EVENT, event_id)
  494.  
  495.  
  496. def pack_wait_for_host_event(event_id: int, timeout_us: int = 0) -> bytes:
  497.     """PackedWaitForHostEvent -- 9 bytes, DETECTORCODE=4
  498.    Layout: [DC=4:1][eventId:4LE][timeout:4LE]
  499.    """
  500.     return struct.pack("<BII", DC_WAIT_EVENT, event_id, timeout_us)
  501.  
  502.  
  503. def pack_delay(delay_us: int) -> bytes:
  504.     """PackedDelay -- 5 bytes, DETECTORCODE=5
  505.    Layout: [DC=5:1][delayUs:4LE]
  506.    """
  507.     return struct.pack("<BI", DC_DELAY, delay_us)
  508.  
  509.  
  510. # -- GENERIC_SCRIPT builder ----------------------------------------------------
  511.  
  512. def build_generic_script(script_id: int, repeat_count: int, repeat_event: int,
  513.                           commands: list) -> bytes:
  514.     """Build GENERIC_SCRIPT PDAP packet (cmd_type=5).
  515.  
  516.    Wire format:
  517.      [05 00 00 00]            cmd_type=5
  518.      [payload_len:4LE]        = 8 + sum(cmd_sizes) + 2
  519.      [scriptID:2LE]           script identifier
  520.      [repeatCount:2LE]        0=once, 65535=infinite loop
  521.      [repeatEvent:4LE]        event ID to break infinite loop (or 0)
  522.      [packed_cmd_1...]        concatenated inner command structs
  523.      [00 00]                  2-byte terminator
  524.    """
  525.     cmds_bytes = b"".join(commands)
  526.     header = struct.pack("<HHI", script_id, repeat_count, repeat_event)
  527.     terminator = b"\x00\x00"
  528.     payload = header + cmds_bytes + terminator
  529.     return make_pdap(CMD_GENERIC_SCRIPT, payload)
  530.  
  531.  
  532. # -- Pre-built scripts from IDC_URP_SE_2200.xml --------------------------------
  533.  
  534. def build_script_7_roe_init(scan_cmd: int = ROE_SCAN_SETUP_CMD,
  535.                               scan_val: int = ROE_SCAN_SETUP_VAL) -> bytes:
  536.     """Script 7 -- ROE Initialization (DETECTOR_SCRIPT_ID=11 in XML).
  537.    scriptID=7, repeatCount=0, repeatEvent=0
  538.    13 commands: init + 4x zero-pulse + SendHostEvent(17)
  539.    """
  540.     cmds = [
  541.         pack_roe_command(scan_cmd, scan_val),          # $1, $2
  542.         pack_roe_command(ROE_INIT_CMD2, 2),             # 0x4002, 2
  543.         pack_roe_command(ROE_INIT_CMD3, 16,
  544.                          timer_value=0),                # 0x7900, 16 (timer=0)
  545.         pack_delay(50000),
  546.     ]
  547.     for _ in range(4):
  548.         cmds.append(pack_roe_command(0, 0))
  549.         cmds.append(pack_delay(10000))
  550.     cmds.append(pack_send_host_event(17))
  551.     return build_generic_script(7, 0, 0, cmds)
  552.  
  553.  
  554. def build_script_8_standby(roe_data: int = 0) -> bytes:
  555.     """Script 8 -- Standby loop (DETECTOR_SCRIPT_ID=22 in XML).
  556.    scriptID=8, repeatCount=65535 (infinite), repeatEvent=41
  557.    2 commands: ROECmd(0x4080, $3) + Delay(10000)
  558.    """
  559.     cmds = [
  560.         pack_roe_command(ROE_STANDBY_CMD, roe_data),
  561.         pack_delay(10000),
  562.     ]
  563.     return build_generic_script(8, 65535, 41, cmds)
  564.  
  565.  
  566. def build_script_0_std_acq(transfer_mode: int = 0) -> bytes:
  567.     """Script 0 -- Standard Acquisition (DETECTOR_SCRIPT_ID=0 in XML).
  568.    scriptID=0, repeatCount=0, repeatEvent=0
  569.    2 commands: AcqScript(typeMode=0, ...) + Delay(10000)
  570.    """
  571.     cmds = [
  572.         pack_acquisition(type_mode=0, image_id=1, no_scrubs=0,
  573.                          scrub_duration=50000, max_expose_time=4000000,
  574.                          tail_time=250000, transfer_mode=transfer_mode),
  575.         pack_delay(10000),
  576.     ]
  577.     return build_generic_script(0, 0, 0, cmds)
  578.  
  579.  
  580. def build_script_1_dark_acq(type_mode: int = 1, transfer_mode: int = 0) -> bytes:
  581.     """Script 1 -- Dark/Offset Acquisition (DETECTOR_SCRIPT_ID=1 in XML).
  582.    scriptID=1, repeatCount=0, repeatEvent=0
  583.    10 commands: 4x[ROE(0,0)+Delay(10000)] + AcqScript(typeMode=1,...) + Delay(10000)
  584.    type_mode/transfer_mode are exposed for the --sweep-acq experiment (defaults match the XML).
  585.    """
  586.     cmds = []
  587.     for _ in range(4):
  588.         cmds.append(pack_roe_command(0, 0))
  589.         cmds.append(pack_delay(10000))
  590.     cmds.append(
  591.         pack_acquisition(type_mode=type_mode, image_id=1, no_scrubs=0,
  592.                          scrub_duration=10000, max_expose_time=4000000,
  593.                          tail_time=50000, transfer_mode=transfer_mode)
  594.     )
  595.     cmds.append(pack_delay(10000))
  596.     return build_generic_script(1, 0, 0, cmds)
  597.  
  598.  
  599. # -- FlashPad session ----------------------------------------------------------
  600.  
  601. class DetectorSignature:
  602.     """Parsed SIGNATURE_REPLY payload (54 bytes, cmd_type=3).
  603.    Confirmed offsets from decompile (createSignatureRequestReply):
  604.      [0..5]   MAC (6 bytes)
  605.      [6..9]   field1 (uint32 LE)
  606.      [10..13] field2 (uint32 LE)
  607.      [14..17] field3 (uint32 LE)
  608.      [18..21] field4 (uint32 LE)
  609.      [22..33] serial string (12 bytes, space/null padded)
  610.      [34..45] model string (12 bytes)
  611.      [46..53] fw_version (8 bytes)
  612.    Confirmed from live capture: MAC=40:F4:A0:00:78:4D serial='UA45829-7' model='5340000-7'
  613.    """
  614.     def __init__(self, payload: bytes):
  615.         if len(payload) < 54:
  616.             raise ValueError(f"Signature reply too short: {len(payload)}")
  617.         self.mac    = payload[0:6]
  618.         self.field1 = struct.unpack_from("<I", payload, 6)[0]
  619.         self.field2 = struct.unpack_from("<I", payload, 10)[0]
  620.         self.field3 = struct.unpack_from("<I", payload, 14)[0]
  621.         self.field4 = struct.unpack_from("<I", payload, 18)[0]
  622.         self.serial = payload[22:34].strip(b"\x00 ").decode("ascii", errors="replace")
  623.         self.model  = payload[34:46].strip(b"\x00 ").decode("ascii", errors="replace")
  624.         self.fw_bytes = list(payload[46:54])
  625.  
  626.     def __str__(self):
  627.         mac_str = ":".join(f"{b:02X}" for b in self.mac)
  628.         fw_str  = ".".join(str(b) for b in self.fw_bytes)
  629.         return (f"DetectorSignature: MAC={mac_str} serial='{self.serial}' "
  630.                 f"model='{self.model}' fw={fw_str}")
  631.  
  632.  
  633. class FlashPadSession:
  634.     """Manages URP/PDAP session with a GE FlashPad Apollo detector.
  635.  
  636.    Port architecture (from ConnectionPoint.cfg [eth0]):
  637.      - Host commands always go TO detector at 192.168.1.30:8100
  638.      - SYSTEM_STARTUP payload tells detector to send discovery beacons to host:reply_port
  639.      - PORT_SETUP (cmd_type=2) tells detector:
  640.          hostCmdPort=5550  (Detector_HostPort)  -- all protocol replies come here
  641.          hostImgPort=6660  (Detector_ImagePort) -- pixel data streams here
  642.      - Image pixel data (cmd_type=0x0F) streams to port 6660 DURING script execution,
  643.        BEFORE EXECUTION_COMPLETE.  Image socket MUST be open before execute_script().
  644.      - After streaming, detector sends cmd_type=0x30000 with [imageId:2LE][scriptId:2LE].
  645.        Host MUST reply with cmd_type=9 listing missed frame IDs (0 = all received OK).
  646.    """
  647.  
  648.     def __init__(self,
  649.                  detector_ip: str = DETECTOR_IP,
  650.                  host_ip: str = HOST_IP,
  651.                  broadcast_addr: str = BROADCAST_ADDR,
  652.                  broadcast_port: int = BROADCAST_PORT,
  653.                  reply_port: int = HOST_CMD_PORT,   # 5550 from Detector_HostPort in ConnectionPoint.cfg
  654.                  disc_port: int = HOST_DISC_PORT,   # 4500 from SystemPort in ConnectionPoint.cfg
  655.                  timeout: float = 5.0,
  656.                  verbose: bool = True,
  657.                  parallel: bool = False):
  658.         self.detector_ip    = detector_ip
  659.         self.host_ip        = host_ip
  660.         self.broadcast_addr = broadcast_addr
  661.         self.broadcast_port = broadcast_port
  662.         self.reply_port     = reply_port   # 5550 -- detector sends protocol replies here (after PORT_SETUP)
  663.         self.disc_port      = disc_port    # 4500 -- sent in SYSTEM_STARTUP payload; detector sends beacons here
  664.         self.timeout        = timeout
  665.         self.verbose        = verbose
  666.         # parallel=True -> reply to detector's 0x30000 with the cmd_type=0x99 (ParallelImageTransfer)
  667.         # status reply instead of the serial cmd_type=9.  This is the only status-reply path in
  668.         # libDetection that is associated with the image-port transfer; testing whether the firmware
  669.         # needs it to begin pushing 0x0F pixel frames.
  670.         self.parallel       = parallel
  671.         self._img_sock      = None   # separate UDP socket on HOST_IMAGE_PORT (6660) for pixel data
  672.         self._sock          = None   # main socket bound to reply_port (5550) for all control traffic
  673.         self.signature      = None
  674.         # Transfer-state signals updated by receive_image() (read by --sweep-acq / --preview):
  675.         #   _last_xfer_imageid = imageId from the detector's last 0x30000 query (0x8000 => preview)
  676.         #   _last_xfer_count   = "No of Images to retrieve" from the last 0x41 reply (8 normal / 4 preview)
  677.         self._last_xfer_imageid = None
  678.         self._last_xfer_count   = None
  679.         # SeqId counter: host must increment per command (URP bytes 0-3).
  680.         # Detector firmware stores last-seen SeqId and silently drops any command with
  681.         # SeqId <= last_seen (treats it as an old/duplicate packet).
  682.         # initializeSequenceID() in libDetection sets starting SeqId = beacon_SeqId + 1.
  683.         # We start at 1 (SYSTEM_STARTUP uses 0 during discover(); commands start at 1).
  684.         self._seq_id        = 1
  685.  
  686.     # -- socket management -----------------------------------------------------
  687.  
  688.     def _open_sockets(self):
  689.         # Bind to reply_port (5550 = Detector_HostPort).  We also pass this same port in
  690.         # SYSTEM_STARTUP so that discovery beacons AND all subsequent protocol replies arrive
  691.         # here.  The GE config distinguishes SystemPort (4500) from Detector_HostPort (5550),
  692.         # but for a single-socket implementation we unify onto reply_port and put that in
  693.         # both places.  The detector sends to wherever we tell it; using one port is safe.
  694.         s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  695.         s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  696.         s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  697.         s.bind(("", self.reply_port))
  698.         s.settimeout(self.timeout)
  699.         self._sock = s
  700.         self._log(f"Socket bound to :{self.reply_port} (Detector_HostPort / cmd reply port)")
  701.  
  702.     def _close_sockets(self):
  703.         if self._sock:
  704.             self._sock.close()
  705.             self._sock = None
  706.         self._close_image_socket()
  707.  
  708.     def _open_image_socket(self, image_port: int = HOST_IMAGE_PORT) -> bool:
  709.         """Open a dedicated UDP socket on image_port to receive pixel data.
  710.        Must be called BEFORE execute_script() because the detector streams pixel data
  711.        DURING script execution (before EXECUTION_COMPLETE arrives).
  712.        If image_port == reply_port the existing _sock is used instead (no 2nd socket).
  713.        Returns True on success.
  714.        """
  715.         if image_port == self.reply_port:
  716.             self._log(f"Image port = reply port ({image_port}), using shared socket")
  717.             return True
  718.         if self._img_sock is not None:
  719.             return True   # already open
  720.         try:
  721.             s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  722.             s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  723.             s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 8 * 1024 * 1024)   # 8 MB kernel buf
  724.             s.bind(("", image_port))
  725.             s.settimeout(0.1)
  726.             self._img_sock = s
  727.             self._log(f"Image socket bound to :{image_port} (8 MB recv buf)")
  728.             return True
  729.         except OSError as e:
  730.             self._log(f"[WARN] Cannot bind image socket :{image_port}: {e}")
  731.             return False
  732.  
  733.     def _close_image_socket(self):
  734.         if self._img_sock is not None:
  735.             try:
  736.                 self._img_sock.close()
  737.             except OSError:
  738.                 pass
  739.             self._img_sock = None
  740.  
  741.     # -- helpers ---------------------------------------------------------------
  742.  
  743.     def _log(self, msg: str):
  744.         if self.verbose:
  745.             ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
  746.             print(f"[{ts}] {msg}", flush=True)
  747.  
  748.     def _send_cmd(self, pdap_data: bytes):
  749.         """Wrap PDAP in URP and send to detector port 8100.
  750.  
  751.        URP header: [SeqId:4LE][CmdFlag:4LE]
  752.          SeqId=self._seq_id   (incremented after each send -- detector drops old SeqIds)
  753.          CmdFlag=0            (0 = data packet with PDAP; non-zero = bare ACK, no PDAP parsed)
  754.        """
  755.         seq = self._seq_id
  756.         pkt = make_urp_packet(pdap_data, reply_port=0, flag=seq)   # flag=SeqId, reply_port=CmdFlag
  757.         self._sock.sendto(pkt, (self.detector_ip, self.broadcast_port))
  758.         self._seq_id += 1
  759.         cmd_type = struct.unpack_from("<I", pdap_data, 0)[0] if len(pdap_data) >= 4 else 0
  760.         self._log(f"TX  cmd_type=0x{cmd_type:08X} SeqId={seq} len={len(pkt)}")
  761.  
  762.     def _send_bare_ack(self, det_seq_id: int):
  763.         """Send a bare URP ACK to the detector for a received data packet.
  764.  
  765.        Protocol: when the detector sends a data packet (CmdFlag=0), the host MUST reply
  766.        with a bare ACK: [det_SeqId:4LE][1:4LE]  (CmdFlag=1, no PDAP body).
  767.        Without this ACK the detector considers the packet undelivered and may not advance
  768.        its own state machine (e.g. continue sending beacons, process commands).
  769.        Sent to (detector_ip, broadcast_port=8100).
  770.        """
  771.         ack = struct.pack("<II", det_seq_id, 1)   # SeqId=det_seq_id, CmdFlag=1
  772.         self._sock.sendto(ack, (self.detector_ip, self.broadcast_port))
  773.         self._log(f"TX  bare-ACK for det_SeqId={det_seq_id}")
  774.  
  775.     def _send_cmd_via(self, sock, pdap_data: bytes):
  776.         """Like _send_cmd, but transmit from a specific socket (e.g. the image socket on 6660).
  777.        The detector still receives on port 8100; only the SOURCE udp port differs.  Used to test
  778.        whether the detector keys the image-stream destination off the source of the retrieval
  779.        request (or needs the data path 'opened' by a packet from the host's image port).
  780.        Falls back to the control socket if `sock` is None.
  781.        """
  782.         if sock is None:
  783.             self._send_cmd(pdap_data)
  784.             return
  785.         seq = self._seq_id
  786.         pkt = make_urp_packet(pdap_data, reply_port=0, flag=seq)   # flag=SeqId, CmdFlag=0
  787.         sock.sendto(pkt, (self.detector_ip, self.broadcast_port))
  788.         self._seq_id += 1
  789.         cmd_type = struct.unpack_from("<I", pdap_data, 0)[0] if len(pdap_data) >= 4 else 0
  790.         src_port = sock.getsockname()[1]
  791.         self._log(f"TX  (from :{src_port}) cmd_type=0x{cmd_type:08X} SeqId={seq} len={len(pkt)}")
  792.  
  793.     def _recv_cmd(self, expected_cmd_type: int = None, timeout_s: float = None) -> tuple:
  794.         """Read packets from the socket until we get expected_cmd_type (or any, if None).
  795.        Filters to packets from detector_ip only.
  796.  
  797.        URP header: [SeqId:4LE][CmdFlag:4LE]
  798.          CmdFlag=0 → data packet; host MUST reply with bare ACK [SeqId:4LE][1:4LE]
  799.          CmdFlag≠0 → bare ACK from detector (acknowledging a host command); no PDAP body
  800.  
  801.        Bare ACKs from the detector are returned as (seq_id, None, None).
  802.        De-duplicates retransmissions by comparing (cmd_type, payload) tuples.
  803.        Returns (seq_id, cmd_type, payload) or raises TimeoutError.
  804.        """
  805.         if timeout_s is None:
  806.             timeout_s = self.timeout
  807.         deadline = time.time() + timeout_s
  808.         seen = set()
  809.         old_to = self._sock.gettimeout()
  810.         self._sock.settimeout(0.5)
  811.         try:
  812.             while time.time() < deadline:
  813.                 try:
  814.                     data, addr = self._sock.recvfrom(65535)
  815.                 except socket.timeout:
  816.                     continue
  817.                 if addr[0] != self.detector_ip:
  818.                     continue
  819.                 self._log(f"RX  {len(data)}b from {addr[0]}:{addr[1]}  raw={data.hex()}")
  820.                 if len(data) < 8:
  821.                     continue
  822.                 seq_id, cmd_flag, pdap = parse_urp(data)   # seq_id=field0, cmd_flag=field1
  823.                 if cmd_flag != 0:
  824.                     # Bare ACK from detector (cmd_flag ≠ 0): detector acknowledges our command.
  825.                     # No PDAP body present. NOT a data packet -- do NOT send an ACK back.
  826.                     self._log(f"    bare-ACK seq_id={seq_id} cmd_flag={cmd_flag}")
  827.                     if expected_cmd_type is None:
  828.                         return seq_id, None, None
  829.                     # bare ACK is not the expected PDAP reply -- keep waiting
  830.                     continue
  831.                 # cmd_flag=0 → data packet from detector; MUST send bare ACK
  832.                 if len(pdap) < 8:
  833.                     # cmd_flag=0 but no PDAP body (shouldn't happen -- ACK it anyway)
  834.                     self._log(f"    short data pkt seq_id={seq_id} (ACKing)")
  835.                     self._send_bare_ack(seq_id)
  836.                     continue
  837.                 cmd_type, plen, payload = parse_pdap(pdap)
  838.                 self._log(f"    PDAP cmd_type=0x{cmd_type:08X} plen={plen} seq_id={seq_id}")
  839.                 # Always ACK the detector's data packet immediately
  840.                 self._send_bare_ack(seq_id)
  841.                 # De-duplicate retransmissions (same cmd_type + payload seen before)
  842.                 dedup_key = (cmd_type, bytes(payload))
  843.                 if dedup_key in seen:
  844.                     self._log("    (retransmit duplicate -- skipping)")
  845.                     continue
  846.                 seen.add(dedup_key)
  847.                 if expected_cmd_type is None or cmd_type == expected_cmd_type:
  848.                     return seq_id, cmd_type, payload
  849.                 self._log(f"    (skipping unexpected 0x{cmd_type:08X})")
  850.         finally:
  851.             self._sock.settimeout(old_to)
  852.         exp_str = f"0x{expected_cmd_type:08X}" if expected_cmd_type is not None else "any"
  853.         raise TimeoutError(f"Did not receive cmd_type={exp_str} within {timeout_s}s")
  854.  
  855.     # -- protocol steps --------------------------------------------------------
  856.  
  857.     def discover(self, max_wait: float = None) -> bool:
  858.         """Send SYSTEM_STARTUP and wait for the detector to respond with a beacon.
  859.  
  860.        Real library flow (from libDetection.so.c analysis):
  861.          1. Host broadcasts SYSTEM_STARTUP: URP[SeqId=0, CmdFlag=0] + PDAP payload
  862.               PDAP: [cmd_type=1:4LE][plen=6:4LE][SystemPort:2BE][host_ip:4]
  863.             This tells the detector: "I am at host_ip, send periodic beacons to SystemPort."
  864.          2. Detector sends bare ACK: [SeqId=0:4LE][CmdFlag=1:4LE] (8 bytes)
  865.             NOTE: bare ACK only means the UDP packet was received.  It does NOT confirm
  866.             the PDAP was processed.  CmdFlag=0 in SYSTEM_STARTUP is required for PDAP
  867.             to be parsed; CmdFlag≠0 would make the detector treat it as a bare ACK itself
  868.             and skip PDAP parsing entirely.
  869.          3. Detector starts sending periodic beacons (UDP from detector:* → host:SystemPort).
  870.             Beacons are URP data packets (CmdFlag=0) containing a 10-byte payload:
  871.               [detector_ip:4][?:6]  (last 6 bytes may be MAC or SeqId info)
  872.             Host MUST ACK each beacon with a bare ACK [beacon_SeqId:4LE][1:4LE].
  873.          4. Host extracts beacon SeqId (= detector's outgoing counter) and sets its own
  874.             starting SeqId = beacon_SeqId + 1 (from initializeSequenceID() in library).
  875.             We already initialized self._seq_id=1 which works if the detector just booted.
  876.  
  877.        There is NO 4-byte null probe ("Phase-1/Phase-2 handshake") — that does not exist
  878.        in the decompiled library.  The probe was a false hypothesis from earlier sessions.
  879.  
  880.        URP header note (make_urp_packet parameter names are misleading):
  881.          make_urp_packet(pdap, reply_port=CmdFlag_value, flag=SeqId_value)
  882.          For SYSTEM_STARTUP: SeqId=0, CmdFlag=0 → make_urp_packet(pdap, reply_port=0, flag=0)
  883.  
  884.        Returns True once a beacon (or bare ACK) is received, False on timeout.
  885.        """
  886.         self._log(f"Discovery: sending SYSTEM_STARTUP -> {self.detector_ip}:{self.broadcast_port}")
  887.         # We tell the detector to send beacons to self.reply_port (5550) because that is the
  888.         # only socket we have open.  GE's config separates SystemPort(4500) from
  889.         # Detector_HostPort(5550) but for a single-socket implementation they unify.
  890.         startup_pdap = build_system_startup(self.host_ip, self.reply_port)
  891.         # SeqId=0, CmdFlag=0 (data packet -- detector parses PDAP)
  892.         # Note: make_urp_packet(flag=SeqId, reply_port=CmdFlag) -- names are inverted
  893.         startup_pkt  = make_urp_packet(startup_pdap, reply_port=0, flag=0)
  894.  
  895.         deadline = time.time() + (max_wait if max_wait is not None else BROADCAST_TIMEOUT)
  896.         old_to   = self._sock.gettimeout()
  897.         self._sock.settimeout(0.3)
  898.         attempt  = 0
  899.         got_ack  = False
  900.         try:
  901.             while time.time() < deadline:
  902.                 # Broadcast SYSTEM_STARTUP every BROADCAST_INTERVAL seconds
  903.                 attempt += 1
  904.                 self._sock.sendto(startup_pkt, (self.broadcast_addr, self.broadcast_port))
  905.                 self._sock.sendto(startup_pkt, (self.detector_ip,    self.broadcast_port))
  906.                 self._log(f"TX  SYSTEM_STARTUP #{attempt} host={self.host_ip} disc_port={self.disc_port}")
  907.  
  908.                 # Collect responses for up to BROADCAST_INTERVAL seconds
  909.                 interval_deadline = time.time() + BROADCAST_INTERVAL
  910.                 while time.time() < interval_deadline:
  911.                     try:
  912.                         data, addr = self._sock.recvfrom(65535)
  913.                     except socket.timeout:
  914.                         continue
  915.                     if addr[0] != self.detector_ip:
  916.                         continue
  917.                     self._log(f"RX  {len(data)}b from {addr[0]}:{addr[1]}  raw={data.hex()}")
  918.                     if len(data) < 8:
  919.                         continue
  920.                     seq_id, cmd_flag = struct.unpack_from("<II", data, 0)
  921.                     if cmd_flag != 0:
  922.                         # Bare ACK from detector -- SYSTEM_STARTUP packet arrived.
  923.                         # (ACK only means UDP arrived; PDAP parsing requires CmdFlag=0 which we set.)
  924.                         self._log(f"    bare-ACK seq_id={seq_id} -- SYSTEM_STARTUP delivered")
  925.                         got_ack = True
  926.                         # Don't return yet -- keep draining; detector may send a beacon next
  927.                         continue
  928.                     # CmdFlag=0 -- data packet from detector (periodic beacon or other PDAP)
  929.                     # ACK it immediately so detector knows we're alive
  930.                     self._send_bare_ack(seq_id)
  931.                     if len(data) >= 16:
  932.                         try:
  933.                             _, _, pdap_body = parse_urp(data)
  934.                             cmd_type, plen, payload = parse_pdap(pdap_body)
  935.                             self._log(f"    beacon PDAP cmd_type=0x{cmd_type:08X} plen={plen} "
  936.                                       f"payload={payload.hex()}")
  937.                         except (ValueError, struct.error) as e:
  938.                             self._log(f"    (parse error: {e})")
  939.                     # Synchronize SeqId: library sets host SeqId = beacon_SeqId + 1.
  940.                     # Only update if the new value would be higher (don't go backwards).
  941.                     new_seq = seq_id + 1
  942.                     if new_seq > self._seq_id:
  943.                         self._log(f"    SeqId sync: {self._seq_id} -> {new_seq} "
  944.                                   f"(detector beacon SeqId={seq_id})")
  945.                         self._seq_id = new_seq
  946.                     self._log("[OK] Discovery complete -- beacon received and ACKed")
  947.                     return True
  948.  
  949.             if got_ack:
  950.                 # Got bare ACK but no beacon yet -- consider discovery partial success.
  951.                 # SYSTEM_STARTUP was delivered; beacon may come shortly after.
  952.                 self._log("[OK] Discovery: SYSTEM_STARTUP ACKed (no beacon yet -- proceeding)")
  953.                 return True
  954.  
  955.             self._log(f"[FAIL] Discovery timeout after {attempt} attempts")
  956.             return False
  957.         finally:
  958.             self._sock.settimeout(old_to)
  959.  
  960.     def request_signature(self) -> "DetectorSignature":
  961.         """Request detector signature. Returns DetectorSignature or None."""
  962.         self._log("SIGNATURE_REQUEST")
  963.         self._send_cmd(build_signature_request())
  964.         try:
  965.             seq_id, cmd_type, payload = self._recv_cmd(CMD_SIGNATURE_REQUEST,
  966.                                                         timeout_s=self.timeout)
  967.             sig = DetectorSignature(payload)
  968.             self._log(f"[OK] {sig}")
  969.             self.signature = sig
  970.             return sig
  971.         except (TimeoutError, ValueError) as e:
  972.             self._log(f"[FAIL] Signature: {e}")
  973.             return None
  974.  
  975.     def send_port_setup(self, host_cmd_port: int = HOST_CMD_PORT,
  976.                         host_img_port: int = HOST_IMAGE_PORT) -> bool:
  977.         """Send PORT_SETUP (cmd_type=2) to configure the detector's reply ports.
  978.  
  979.        REQUIRED before image transfer: sets the host ports the detector will use.
  980.        host_cmd_port = 5550 (Detector_HostPort) -- all protocol replies come here.
  981.        host_img_port = 6660 (Detector_ImagePort) -- pixel data streams here.
  982.  
  983.        The detector acknowledges PORT_SETUP with a bare URP ACK.
  984.        Returns True on ACK, False on timeout.
  985.        """
  986.         self._log(f"PORT_SETUP: hostCmdPort={host_cmd_port} hostImgPort={host_img_port}")
  987.         sent_seq = self._seq_id
  988.         self._send_cmd(build_port_setup(host_cmd_port, host_img_port))
  989.         deadline = time.time() + self.timeout
  990.         try:
  991.             while time.time() < deadline:
  992.                 remaining = deadline - time.time()
  993.                 seq_id, cmd_type, payload = self._recv_cmd(None, timeout_s=max(remaining, 0.1))
  994.                 if cmd_type is None:
  995.                     if seq_id == sent_seq:
  996.                         self._log(f"[OK] PORT_SETUP bare-ACKed (seq_id={seq_id})")
  997.                         return True
  998.                     self._log(f"    (stale bare-ACK seq_id={seq_id}, want {sent_seq} -- skip)")
  999.                     continue
  1000.                 if cmd_type == CMD_PORT_SETUP:
  1001.                     self._log(f"[OK] PORT_SETUP reply: payload={payload.hex() if payload else 'none'}")
  1002.                     return True
  1003.                 self._log(f"    (skipping cmd_type=0x{cmd_type:08X} in send_port_setup)")
  1004.             raise TimeoutError(f"send_port_setup: no reply in {self.timeout}s")
  1005.         except TimeoutError as e:
  1006.             self._log(f"[FAIL] {e}")
  1007.             return False
  1008.  
  1009.     def reply_image_xfer_status(self, missed_ids: list = None,
  1010.                                 image_id: int = 0, script_id: int = 1) -> None:
  1011.         """Reply to detector's cmd_type=0x30000 listing any missed frames.
  1012.  
  1013.        This MUST be sent whenever the detector sends cmd_type=0x30000 (image transfer status
  1014.        query).  The detector waits for this reply before considering the transfer done.
  1015.  
  1016.        Serial   (self.parallel=False): cmd_type=9,    payload=[numMissed:4LE][id_0:4LE]...
  1017.        Parallel (self.parallel=True):  cmd_type=0x99, payload=[imageId:2LE][scriptId:2LE]
  1018.                                                               [numMissed:4LE][id_0:4LE]...
  1019.        numMissed=0 means all image frames were received OK -- no retransmission needed.
  1020.        (imageId/scriptId are echoed from the detector's query, per createImageTransferStatusQueryReply.)
  1021.        """
  1022.         ids = missed_ids if missed_ids else []
  1023.         self._send_cmd(build_image_xfer_status_reply(
  1024.             missed_ids=ids, image_id=image_id, script_id=script_id,
  1025.             parallel=self.parallel))
  1026.  
  1027.     def download_script(self, script_pdap: bytes, script_name: str = "?") -> bool:
  1028.         """Download a GENERIC_SCRIPT. Waits for bare ACK with matching SeqId, or PDAP reply.
  1029.  
  1030.        Bare ACK SeqId must match our command's SeqId (detector echoes host SeqId in ACK).
  1031.        Stale ACKs from previous commands (lower SeqId) are skipped.
  1032.        Other PDAP cmd_types (late replies to previous commands) are also skipped.
  1033.        """
  1034.         script_id = struct.unpack_from("<H", script_pdap, 8)[0] if len(script_pdap) > 10 else -1
  1035.         self._log(f"GENERIC_SCRIPT: {script_name} scriptID={script_id} len={len(script_pdap)}")
  1036.         sent_seq = self._seq_id                # capture before send (send increments it)
  1037.         self._send_cmd(script_pdap)
  1038.         deadline = time.time() + self.timeout
  1039.         try:
  1040.             while time.time() < deadline:
  1041.                 remaining = deadline - time.time()
  1042.                 seq_id, cmd_type, payload = self._recv_cmd(None, timeout_s=max(remaining, 0.1))
  1043.                 if cmd_type is None:
  1044.                     # Bare ACK -- check it matches our sent SeqId
  1045.                     if seq_id == sent_seq:
  1046.                         self._log("[OK] Script bare-ACKed")
  1047.                         return True
  1048.                     self._log(f"    (stale bare-ACK seq_id={seq_id}, want {sent_seq} -- skip)")
  1049.                     continue
  1050.                 if cmd_type == CMD_GENERIC_SCRIPT:
  1051.                     status = payload[0] if payload else 0
  1052.                     self._log(f"[OK] Script reply status={status}")
  1053.                     return status == 0
  1054.                 # Some other PDAP (e.g. beacon 0x1, late reply 0x5) -- skip and keep waiting
  1055.                 self._log(f"    (skipping stale cmd_type=0x{cmd_type:08X} in download_script)")
  1056.             raise TimeoutError(f"download_script: no reply in {self.timeout}s")
  1057.         except TimeoutError as e:
  1058.             self._log(f"[FAIL] {e}")
  1059.             return False
  1060.  
  1061.     def execute_script(self) -> bool:
  1062.         """Send EXECUTE_SCRIPT. Waits for bare ACK with matching SeqId, or EXECUTE_SCRIPT reply.
  1063.  
  1064.        Stale bare ACKs (wrong SeqId) and unrelated PDAP replies are skipped.
  1065.        """
  1066.         self._log("EXECUTE_SCRIPT")
  1067.         sent_seq = self._seq_id
  1068.         self._send_cmd(build_execute_script())
  1069.         deadline = time.time() + self.timeout
  1070.         try:
  1071.             while time.time() < deadline:
  1072.                 remaining = deadline - time.time()
  1073.                 seq_id, cmd_type, payload = self._recv_cmd(None, timeout_s=max(remaining, 0.1))
  1074.                 if cmd_type is None:
  1075.                     if seq_id == sent_seq:
  1076.                         self._log("[OK] Execute bare-ACKed")
  1077.                         return True
  1078.                     self._log(f"    (stale bare-ACK seq_id={seq_id}, want {sent_seq} -- skip)")
  1079.                     continue
  1080.                 if cmd_type == CMD_EXECUTE_SCRIPT:
  1081.                     status = payload[4] if payload and len(payload) >= 5 else 0
  1082.                     self._log(f"[OK] Execute reply status={status}")
  1083.                     return status == 0
  1084.                 # Late/unrelated PDAP (e.g. 0x00000005 reply for Script0) -- skip
  1085.                 self._log(f"    (skipping stale cmd_type=0x{cmd_type:08X} in execute_script)")
  1086.             raise TimeoutError(f"execute_script: no reply in {self.timeout}s")
  1087.         except TimeoutError as e:
  1088.             self._log(f"[FAIL] {e}")
  1089.             return False
  1090.  
  1091.     def wait_for_execution_complete(self, timeout_s: float = 30.0) -> bool:
  1092.         """Wait for EXECUTION_COMPLETE (cmd_type=0x10000).
  1093.  
  1094.        Known intermediate packets received before EXECUTION_COMPLETE:
  1095.          cmd_type=0x6  (EXECUTE_SCRIPT reply, ~0.2s after EXECUTE_SCRIPT)
  1096.          cmd_type=0x40000 (detector state notify, ~1s after; payload=state_id=17 = ready/wait)
  1097.          cmd_type=0x7  (EXECUTE_SCRIPT_STATUS, 16-byte status packet during execution)
  1098.        These are all skipped; only 0x10000 satisfies this wait.
  1099.  
  1100.        NOTE: For Script0 (standard acquisition), EXECUTION_COMPLETE will NOT arrive without
  1101.        an actual X-ray exposure. Use dark_only=True (Script1) for testing without X-ray.
  1102.        """
  1103.         self._log(f"Waiting for EXECUTION_COMPLETE (timeout={timeout_s}s)")
  1104.         deadline = time.time() + timeout_s
  1105.         try:
  1106.             while time.time() < deadline:
  1107.                 remaining = deadline - time.time()
  1108.                 seq_id, cmd_type, payload = self._recv_cmd(None, timeout_s=max(remaining, 0.1))
  1109.                 if cmd_type is None:
  1110.                     # Bare ACK -- not execution complete; keep waiting
  1111.                     continue
  1112.                 if cmd_type == CMD_EXECUTION_COMPLETE:
  1113.                     self._log(f"[OK] EXECUTION_COMPLETE payload={payload.hex() if payload else 'none'}")
  1114.                     return True
  1115.                 # Log known intermediates clearly, unknown ones as warnings
  1116.                 if cmd_type == CMD_EXECUTE_SCRIPT:
  1117.                     status_byte = payload[0] if payload else 0
  1118.                     self._log(f"    EXECUTE_SCRIPT reply status_byte=0x{status_byte:02X} "
  1119.                               f"(normal -- waiting for EXECUTION_COMPLETE)")
  1120.                 elif cmd_type == CMD_EXECUTE_SCRIPT_STATUS:
  1121.                     self._log(f"    EXECUTE_SCRIPT_STATUS (script running...)")
  1122.                 elif cmd_type == CMD_DETECTOR_STATE_NOTIFY:
  1123.                     state_id = struct.unpack_from("<I", payload, 0)[0] if len(payload) >= 4 else 0
  1124.                     self._log(f"    detector state notify: state_id=0x{state_id:02X} ({state_id})")
  1125.                 elif cmd_type == CMD_GENERIC_SCRIPT:
  1126.                     self._log(f"    GENERIC_SCRIPT reply (late, from script download phase)")
  1127.                 else:
  1128.                     self._log(f"    unknown cmd_type=0x{cmd_type:08X} payload="
  1129.                               f"{payload.hex() if payload else 'none'}")
  1130.             raise TimeoutError(f"EXECUTION_COMPLETE not received in {timeout_s}s")
  1131.         except TimeoutError:
  1132.             self._log("[FAIL] EXECUTION_COMPLETE not received")
  1133.             return False
  1134.  
  1135.     def request_image_transfer(self, script_id: int = 0, timeout_s: float = 15.0,
  1136.                                 image_port: int = HOST_IMAGE_PORT) -> int:
  1137.         """After EXECUTION_COMPLETE: send image retrieval commands and return image count.
  1138.  
  1139.        Protocol (confirmed from libDetection.so.c):
  1140.          1. Detector sends cmd_type=0x30000 (spontaneously, ~0.7s after EXECUTION_COMPLETE)
  1141.               payload = [image_count:4LE]  -- number of images ready for transfer
  1142.             Host ACKs it (already done by _recv_cmd).
  1143.          2. Host sends cmd_type=0x41 (IMAGE_RETRIVAL_REQUEST), payload=[scriptId:4LE]
  1144.             Detector replies with same cmd_type=0x41 confirming image count.
  1145.          3. Host sends cmd_type=0x98 (IMAGE_RETRIVAL), payload=[imagePort:2LE][hostPort:2LE]
  1146.             Detector then streams image pixel data to host:image_port.
  1147.  
  1148.        image_port: UDP port the detector will send pixel data to (default=HOST_IMAGE_PORT=6660).
  1149.                    Use self.reply_port (5550) to receive all data on one socket instead.
  1150.        Returns the confirmed image count (from 0x30000 or 0x41 reply), or 0 on timeout.
  1151.        """
  1152.         self._log(f"IMAGE_TRANSFER: waiting for 0x30000 status notification (timeout={timeout_s}s)")
  1153.         image_count = 0
  1154.         deadline = time.time() + timeout_s
  1155.         old_to = self._sock.gettimeout()
  1156.         self._sock.settimeout(0.5)
  1157.         try:
  1158.             # Step 1: wait for detector's spontaneous 0x30000 notification
  1159.             while time.time() < deadline:
  1160.                 remaining = deadline - time.time()
  1161.                 try:
  1162.                     seq_id, cmd_type, payload = self._recv_cmd(
  1163.                         CMD_IMAGE_XFER_STATUS_QUERY, timeout_s=max(remaining, 0.1))
  1164.                     if len(payload) >= 4:
  1165.                         # payload = [imageCount:4LE] or possibly [short1:2LE][short2:2LE]
  1166.                         image_count = struct.unpack_from("<I", payload, 0)[0]
  1167.                     self._log(f"[OK] Image transfer status notification "
  1168.                               f"(payload={payload.hex()}, raw_count_field={image_count})")
  1169.                     break
  1170.                 except TimeoutError:
  1171.                     self._log("[WARN] No 0x30000 image-count notification received -- "
  1172.                               "proceeding with script_id query anyway")
  1173.                     image_count = 1   # assume at least 1 image
  1174.                     break
  1175.  
  1176.             # Step 2: send IMAGE_RETRIVAL_REQUEST (cmd_type=0x41) with scriptId
  1177.             self._log(f"  Sending IMAGE_RETRIVAL_REQUEST (0x41) scriptId={script_id}")
  1178.             self._send_cmd(build_image_retrival_request(script_id))
  1179.             # Wait for 0x41 reply (detector confirms "No of Images to be retrieved = N")
  1180.             try:
  1181.                 seq_id, cmd_type, payload = self._recv_cmd(
  1182.                     CMD_IMAGE_RETRIVAL_REQUEST, timeout_s=5.0)
  1183.                 if len(payload) >= 4:
  1184.                     confirmed = struct.unpack_from("<I", payload, 0)[0]
  1185.                     self._log(f"[OK] IMAGE_RETRIVAL_REQUEST reply: {confirmed} images confirmed "
  1186.                               f"(full payload={payload.hex()})")
  1187.                     image_count = confirmed
  1188.                 else:
  1189.                     self._log(f"[OK] IMAGE_RETRIVAL_REQUEST reply: short payload={payload.hex()}")
  1190.             except TimeoutError:
  1191.                 self._log("[WARN] No 0x41 reply received -- proceeding with retrival command")
  1192.  
  1193.             # Step 3: send IMAGE_RETRIVAL (cmd_type=0x98) with port info.
  1194.             # Payload wire format: [imagePort:2LE][hostPort:2LE]
  1195.             # (from CONCAT22(param_2=hostPort, param_3=imagePort) → param_3 at lower address)
  1196.             # image_port: detector sends pixel data here (HOST_IMAGE_PORT=6660 by default)
  1197.             # host_port:  detector sends protocol ACKs here (HOST_CMD_PORT=5550)
  1198.             self._log(f"  Sending IMAGE_RETRIVAL (0x98) imagePort={image_port} "
  1199.                       f"hostPort={self.reply_port}")
  1200.             self._send_cmd(build_image_retrival(host_port=self.reply_port,
  1201.                                                  image_port=image_port))
  1202.             # ACK from detector (bare ACK for our 0x98 command)
  1203.             try:
  1204.                 seq_id, cmd_type, payload = self._recv_cmd(None, timeout_s=3.0)
  1205.                 if cmd_type is None:
  1206.                     self._log(f"[OK] IMAGE_RETRIVAL bare-ACKed (seq_id={seq_id})")
  1207.                 else:
  1208.                     self._log(f"[OK] IMAGE_RETRIVAL response: cmd_type=0x{cmd_type:08X} "
  1209.                               f"payload={payload.hex() if payload else 'none'}")
  1210.             except TimeoutError:
  1211.                 self._log("[WARN] No ACK for IMAGE_RETRIVAL -- detector may still send data")
  1212.  
  1213.             return image_count
  1214.         finally:
  1215.             self._sock.settimeout(old_to)
  1216.  
  1217.     def receive_image(self, output_path: str = None, timeout_s: float = 90.0,
  1218.                       script_id: int = 0,
  1219.                       image_port: int = HOST_IMAGE_PORT,
  1220.                       wait_exec_complete: bool = True) -> bytes:
  1221.         """Collect image pixel data that the detector streams DURING script execution.
  1222.  
  1223.        CRITICAL TIMING: The detector streams pixel data to image_port DURING execute_script(),
  1224.        BEFORE EXECUTION_COMPLETE arrives.  _open_image_socket() MUST be called before
  1225.        execute_script() so no pixel data is dropped.  This method uses self._img_sock
  1226.        (already open) rather than opening a new socket.
  1227.  
  1228.        Unified select loop on ctrl socket (5550) + image socket (6660):
  1229.          - ctrl: EXECUTION_COMPLETE, 0x30000 → reply cmd_type=9, other protocol
  1230.          - img:  raw pixel data from detector
  1231.          Exits after EXECUTION_COMPLETE + 0x30000 handled + SILENCE_S seconds silence on img.
  1232.  
  1233.        If self._img_sock was not pre-opened (socket opened AFTER execute_script), a warning
  1234.        is logged and a new socket is opened -- but early-arriving data will already be lost.
  1235.  
  1236.        wait_exec_complete=False: skip waiting for EXECUTION_COMPLETE (for testing/recovery).
  1237.        """
  1238.         import select as _select
  1239.  
  1240.         # Use pre-opened image socket if available.  Fall back to opening now (with warning).
  1241.         img_sock = self._img_sock
  1242.         if img_sock is None and image_port != self.reply_port:
  1243.             self._log("[WARN] Image socket not pre-opened -- opening now "
  1244.                       "(pixel data arriving DURING execute_script may already be lost!)")
  1245.             if self._open_image_socket(image_port):
  1246.                 img_sock = self._img_sock
  1247.             else:
  1248.                 self._log("[WARN] Falling back to shared ctrl socket for image data")
  1249.                 image_port = self.reply_port
  1250.  
  1251.         self._log(f"Waiting for image data on :{image_port} (timeout={timeout_s}s, "
  1252.                   f"wait_exec={wait_exec_complete})")
  1253.  
  1254.         chunks      = []
  1255.         seen_keys   = set()
  1256.         ctrl_seen   = set()
  1257.         deadline    = time.time() + timeout_s
  1258.         last_img_t  = time.time()
  1259.         SILENCE_S   = 5.0
  1260.         # State flags
  1261.         exec_done     = not wait_exec_complete  # True once EXECUTION_COMPLETE received
  1262.         xfer_replied  = False                   # True once cmd_type=9 with numMissed=0 sent
  1263.         xfer_requested= False                   # True once we've requested missed buffers
  1264.         pull_done     = False                   # True once 0x41/0x98 retrieval trigger sent
  1265.  
  1266.         socks_to_watch = [s for s in [self._sock, img_sock] if s is not None]
  1267.  
  1268.         while time.time() < deadline:
  1269.             # Once EXECUTION_COMPLETE + 0x30000 reply done, exit on image silence.
  1270.             # (Silence timer also resets on EXECUTION_COMPLETE so we give a full window.)
  1271.             if exec_done and (xfer_replied or xfer_requested):
  1272.                 if time.time() - last_img_t > SILENCE_S:
  1273.                     if chunks:
  1274.                         self._log(f"  ({SILENCE_S}s silence on image port -- receive done)")
  1275.                     else:
  1276.                         self._log(f"  ({SILENCE_S}s silence -- no image data received)")
  1277.                     break
  1278.  
  1279.             ready, _, _ = _select.select(socks_to_watch, [], [], 0.2)
  1280.             for sock in ready:
  1281.                 try:
  1282.                     data, addr = sock.recvfrom(65536)
  1283.                 except socket.timeout:
  1284.                     continue
  1285.                 if addr[0] != self.detector_ip:
  1286.                     continue
  1287.  
  1288.                 is_img = (img_sock is not None) and (sock is img_sock) and (img_sock is not self._sock)
  1289.  
  1290.                 if is_img:
  1291.                     # ---- Image socket (6660): pixel data from detector ----
  1292.                     last_img_t = time.time()
  1293.                     if len(data) < 4:
  1294.                         continue
  1295.                     dedup_key = bytes(data)
  1296.                     if dedup_key in seen_keys:
  1297.                         continue
  1298.                     seen_keys.add(dedup_key)
  1299.                     # Check if pixel data is wrapped in URP/PDAP
  1300.                     if len(data) >= 16:
  1301.                         try:
  1302.                             seq_id, cmd_flag, pdap = parse_urp(data)
  1303.                             if cmd_flag == 0:
  1304.                                 self._send_bare_ack(seq_id)
  1305.                                 cmd_type, plen, payload = parse_pdap(pdap)
  1306.                                 self._log(f"  ImgSock PDAP 0x{cmd_type:08X} "
  1307.                                           f"plen={plen} {len(payload)}b")
  1308.                                 chunks.append(payload)
  1309.                                 continue
  1310.                         except (ValueError, struct.error):
  1311.                             pass
  1312.                     # Plain raw pixel data (no URP/PDAP header)
  1313.                     chunks.append(data)
  1314.                     self._log(f"  ImgSock raw #{len(chunks)}: {len(data)}b "
  1315.                               f"from {addr[0]}:{addr[1]}")
  1316.  
  1317.                 else:
  1318.                     # ---- Control socket (5550): protocol traffic ----
  1319.                     if len(data) < 8:
  1320.                         continue
  1321.                     seq_id, cmd_flag = struct.unpack_from("<II", data, 0)
  1322.                     if cmd_flag != 0:
  1323.                         # Bare ACK from detector (not a data packet)
  1324.                         self._log(f"  ctrl bare-ACK seq_id={seq_id}")
  1325.                         continue
  1326.                     # Data packet → ACK it
  1327.                     self._send_bare_ack(seq_id)
  1328.                     if len(data) < 16:
  1329.                         continue
  1330.                     try:
  1331.                         _, _, pdap = parse_urp(data)
  1332.                         cmd_type, plen, payload = parse_pdap(pdap)
  1333.                     except (ValueError, struct.error):
  1334.                         continue
  1335.  
  1336.                     dedup_key = (seq_id, cmd_type)
  1337.                     if dedup_key in ctrl_seen:
  1338.                         continue
  1339.                     ctrl_seen.add(dedup_key)
  1340.  
  1341.                     if cmd_type == CMD_EXECUTION_COMPLETE:
  1342.                         self._log(f"[OK] EXECUTION_COMPLETE "
  1343.                                   f"payload={payload.hex() if payload else 'none'}")
  1344.                         exec_done  = True
  1345.                         last_img_t = time.time()   # reset silence timer from this moment
  1346.  
  1347.                     elif cmd_type == CMD_IMAGE_XFER_STATUS_QUERY:
  1348.                         # 0x30000: detector's "image transfer status query".  Per
  1349.                         # EthernetDetector::_sendImageTransferHostStatusReply +
  1350.                         # ImageTransferElement::getPendingImageBuffers in libDetection.so.c, the
  1351.                         # detector is ASKING which image-buffer frames the host still needs, and
  1352.                         # the host replies cmd_type=9 with the list of MISSED buffer IDs.  The
  1353.                         # detector then (re)transmits exactly those frames (cmd_type=0x0F) to the
  1354.                         # image port.  numMissed=0 means "I have everything" -> detector sends
  1355.                         # nothing.  getPendingImageBuffers computes missed = totalBuffers - received
  1356.                         # (libDetection.so.c:345272), so on the first query (received=0) the host
  1357.                         # must request ALL ETHERNET_NO_OF_IMAGES (=8) buffers.  THIS is what
  1358.                         # triggers the stream; replying 0 here is why no pixel data ever arrived.
  1359.                         #
  1360.                         # A DEADBEEF payload is the detector's uninitialized/already-freed image
  1361.                         # slot (appears once a transfer has been marked complete) -- ignore it.
  1362.                         raw32 = struct.unpack_from("<I", payload, 0)[0] if len(payload) >= 4 else 0
  1363.                         if raw32 == 0xDEADBEEF:
  1364.                             self._log(f"  ctrl 0x30000 DEADBEEF payload -- image slot "
  1365.                                       f"uninitialized/freed; ignoring (not replying)")
  1366.                             continue
  1367.                         img_id = struct.unpack_from("<H", payload, 0)[0] if len(payload) >= 2 else 0
  1368.                         scr_id = struct.unpack_from("<H", payload, 2)[0] if len(payload) >= 4 else 0
  1369.                         self._last_xfer_imageid = img_id   # behavior signal for --sweep-acq
  1370.                         # We have no per-frame index in the 0x0F frames, so approximate which
  1371.                         # buffers are still outstanding by arrival order: frames come in sequence,
  1372.                         # so received = collected chunks, still-missing = [received .. N-1].
  1373.                         received = len(chunks)
  1374.                         # Honor the buffer count the detector actually advertised instead of blindly
  1375.                         # asking for 8.  In the preview path (imageId high-bit 0x8000) it reports a
  1376.                         # 4-buffer image; requesting [0..7] asks for frames that don't exist in the
  1377.                         # preview set, which may be why it discards the request.  Prefer the count
  1378.                         # learned from the previous 0x41 reply; else infer from the preview flag.
  1379.                         preview = bool(img_id & PREVIEW_IMAGE_FLAG)
  1380.                         if self._last_xfer_count:
  1381.                             n_buf = self._last_xfer_count
  1382.                         elif preview:
  1383.                             n_buf = PREVIEW_NO_OF_IMAGES
  1384.                         else:
  1385.                             n_buf = ETHERNET_NO_OF_IMAGES
  1386.                         missed = list(range(received, n_buf))
  1387.                         reply_kind = "0x99 parallel" if self.parallel else "cmd9 serial"
  1388.                         self._log(f"  ctrl 0x30000 image_xfer_status payload={payload.hex()} "
  1389.                                   f"imageId=0x{img_id:04X}{' PREVIEW' if preview else ''} "
  1390.                                   f"scriptId={scr_id} received={received}/{n_buf} -> "
  1391.                                   f"replying {reply_kind} requesting {len(missed)} "
  1392.                                   f"missed buffers {missed}")
  1393.                         # Status reply with the missed-buffer list. Crucially this is NOT
  1394.                         # numMissed=0 -- reporting 0 tells the detector "transfer complete" and it
  1395.                         # FREES the image buffer (that is what produced DEADBEEF on the subsequent
  1396.                         # 0x98 in earlier runs). A non-zero missed list keeps the image alive.
  1397.                         # With --parallel this goes out as cmd_type=0x99 (echoing imageId/scriptId),
  1398.                         # the only status-reply form tied to the image-port transfer path.
  1399.                         self.reply_image_xfer_status(missed_ids=missed,
  1400.                                                      image_id=img_id, script_id=scr_id)
  1401.                         if not xfer_requested:
  1402.                             xfer_requested = True
  1403.                             last_img_t = time.time()  # fresh window for the stream to begin
  1404.                         if not missed:
  1405.                             xfer_replied = True
  1406.  
  1407.                         # The cmd9 status reply alone does NOT start the stream -- it only keeps
  1408.                         # the image alive and reports what we still need. The actual trigger is the
  1409.                         # IMAGE_RETRIVAL (0x98) pull, queried by IMAGE_RETRIVAL_REQUEST (0x41).
  1410.                         # Send it ONCE, AFTER the first non-zero cmd9 (so the image is still alive).
  1411.                         if missed and not pull_done:
  1412.                             pull_done = True
  1413.                             self._log(f"  Sending IMAGE_RETRIVAL_REQUEST (0x41) scriptId={scr_id}")
  1414.                             self._send_cmd(make_pdap(CMD_IMAGE_RETRIVAL_REQUEST,
  1415.                                                      struct.pack("<I", scr_id)))
  1416.                             _41_pl = None
  1417.                             _41_deadline = time.time() + 2.0
  1418.                             while time.time() < _41_deadline:
  1419.                                 try:
  1420.                                     _s, _ct, _pl = self._recv_cmd(
  1421.                                         None, timeout_s=max(0.05, _41_deadline - time.time()))
  1422.                                 except Exception:
  1423.                                     break
  1424.                                 if _ct is None:        # bare-ACK -- keep waiting for the PDAP reply
  1425.                                     continue
  1426.                                 if _ct == CMD_IMAGE_RETRIVAL_REQUEST:
  1427.                                     _41_pl = _pl
  1428.                                     break
  1429.                                 self._log(f"  0x41 wait: skipping cmd_type=0x{_ct:08X}")
  1430.                             self._log(f"  0x41 reply: payload="
  1431.                                       f"{_41_pl.hex() if _41_pl else 'none'}")
  1432.                             if _41_pl and len(_41_pl) >= 4:
  1433.                                 self._last_xfer_count = struct.unpack_from("<I", _41_pl, 0)[0]
  1434.                             # EXPERIMENT: send the 0x98 retrieval FROM the image socket (6660) so
  1435.                             # the detector sees the request originating at the host's image
  1436.                             # endpoint. First emit a tiny probe datagram from 6660 -> detector:8100
  1437.                             # to 'open' the UDP path / let the detector learn host:6660.
  1438.                             if img_sock is not None and img_sock is not self._sock:
  1439.                                 try:
  1440.                                     img_sock.sendto(b"\x00\x00\x00\x00",
  1441.                                                     (self.detector_ip, self.broadcast_port))
  1442.                                     self._log(f"  probe: 4B from :{img_sock.getsockname()[1]} "
  1443.                                               f"-> {self.detector_ip}:{self.broadcast_port}")
  1444.                                 except OSError as e:
  1445.                                     self._log(f"  probe send failed: {e}")
  1446.                             self._log(f"  Sending IMAGE_RETRIVAL (0x98) from image port "
  1447.                                       f"imgPort={image_port} hostPort={self.reply_port}")
  1448.                             self._send_cmd_via(img_sock,
  1449.                                                build_image_retrival(host_port=self.reply_port,
  1450.                                                                      image_port=image_port))
  1451.                             last_img_t = time.time()  # fresh window after issuing the trigger
  1452.  
  1453.                     elif cmd_type == CMD_EXECUTE_SCRIPT:
  1454.                         status = payload[0] if payload else 0
  1455.                         self._log(f"  ctrl EXECUTE_SCRIPT reply status=0x{status:02X}")
  1456.  
  1457.                     elif cmd_type == CMD_EXECUTE_SCRIPT_STATUS:
  1458.                         self._log(f"  ctrl EXECUTE_SCRIPT_STATUS (script running...)")
  1459.  
  1460.                     elif cmd_type == CMD_DETECTOR_STATE_NOTIFY:
  1461.                         state_id = struct.unpack_from("<I", payload, 0)[0] if len(payload) >= 4 else 0
  1462.                         self._log(f"  ctrl detector state=0x{state_id:02X} ({state_id})")
  1463.  
  1464.                     elif cmd_type == CMD_GENERIC_SCRIPT:
  1465.                         self._log(f"  ctrl GENERIC_SCRIPT reply (late, from download phase)")
  1466.  
  1467.                     elif cmd_type == CMD_IMAGE_RETRIVAL:
  1468.                         self._log(f"  ctrl 0x98 reply plen={plen} "
  1469.                                   f"payload={payload.hex()}")
  1470.                         if plen > 4:
  1471.                             chunks.append(payload)
  1472.  
  1473.                     else:
  1474.                         self._log(f"  ctrl 0x{cmd_type:08X} plen={plen} "
  1475.                                   f"payload={payload.hex()}")
  1476.  
  1477.         raw = b"".join(chunks)
  1478.         self._log(f"Image: {len(raw)} total bytes in {len(chunks)} chunks")
  1479.         if output_path and raw:
  1480.             with open(output_path, "wb") as f:
  1481.                 f.write(raw)
  1482.             self._log(f"Saved -> {output_path}")
  1483.         elif output_path and not raw:
  1484.             self._log("[WARN] No image data to save")
  1485.         return raw
  1486.  
  1487.     # -- detector data read-back (non-destructive) -----------------------------
  1488.  
  1489.     def read_detector_data(self, upload_id: int, name: str = "?",
  1490.                            chunk: int = 1024, timeout_s: float = 5.0) -> bytes:
  1491.         """Read a stored data category FROM the detector via the upload protocol (read-only).
  1492.  
  1493.        Sequence (confirmed from libDetection.so.c):
  1494.          1. host -> det  cmd 0x13 [uploadId:4LE]
  1495.          2. det  -> host cmd 0x13 [status:1][totalSize:4LE]
  1496.          3. loop: host -> det cmd 0x14 [bufId:4LE][numBytes:4LE]
  1497.                   det  -> host cmd 0x14 [bufId:4LE][numBytes:4LE][data]
  1498.          4. host -> det  EndOfUpload (empty)
  1499.        Returns the raw bytes (e.g. a HostListInfo.dyn / DetectorInfo.dyn blob), or b"" on failure.
  1500.        NO writes to the detector -- this only reads what it already has stored.
  1501.        """
  1502.         self._log(f"UPLOAD/read 0x{upload_id:02X} ({name}): CONFIGURE_UPLOAD")
  1503.         self._send_cmd(build_configure_upload(upload_id))
  1504.         total = None
  1505.         deadline = time.time() + timeout_s
  1506.         while time.time() < deadline:
  1507.             try:
  1508.                 seq, ct, pl = self._recv_cmd(None, timeout_s=max(0.1, deadline - time.time()))
  1509.             except TimeoutError:
  1510.                 break
  1511.             if ct is None:
  1512.                 continue   # bare ACK -- keep waiting for the 0x13 reply
  1513.             if ct == CMD_CONFIGURE_UPLOAD:
  1514.                 status = pl[0] if pl else 0xFF
  1515.                 total = struct.unpack_from("<I", pl, 1)[0] if len(pl) >= 5 else 0
  1516.                 self._log(f"  CONFIGURE_UPLOAD reply: status=0x{status:02X} totalSize={total} "
  1517.                           f"(payload={pl.hex()})")
  1518.                 if status != 0:
  1519.                     self._log(f"  [WARN] non-zero status -- detector refused upload of 0x{upload_id:02X}")
  1520.                     return b""
  1521.                 break
  1522.             self._log(f"  (skipping cmd_type=0x{ct:08X} waiting for 0x13 reply)")
  1523.         if total is None:
  1524.             self._log(f"  [WARN] no CONFIGURE_UPLOAD (0x13) reply -- detector may not support "
  1525.                       f"uploadId 0x{upload_id:02X}, or it is gated")
  1526.             return b""
  1527.         if total == 0:
  1528.             self._log("  totalSize=0 -- nothing stored for this category")
  1529.             return b""
  1530.  
  1531.         data = bytearray()
  1532.         buf_id = 0
  1533.         while len(data) < total:
  1534.             req = min(chunk, total - len(data))
  1535.             self._log(f"  UPLOAD_BUFFER bufId={buf_id} numBytes={req} (have {len(data)}/{total})")
  1536.             self._send_cmd(build_upload_buffer(buf_id, req))
  1537.             got = None
  1538.             d2 = time.time() + timeout_s
  1539.             while time.time() < d2:
  1540.                 try:
  1541.                     seq, ct, pl = self._recv_cmd(None, timeout_s=max(0.1, d2 - time.time()))
  1542.                 except TimeoutError:
  1543.                     break
  1544.                 if ct is None:
  1545.                     continue
  1546.                 if ct == CMD_UPLOAD_BUFFER:
  1547.                     # payload = [bufId:4LE][numBytes:4LE][data]
  1548.                     if len(pl) >= 8:
  1549.                         r_buf, r_n = struct.unpack_from("<II", pl, 0)
  1550.                         got = pl[8:8 + r_n]
  1551.                         self._log(f"    reply bufId={r_buf} numBytes={r_n} got={len(got)}b")
  1552.                     else:
  1553.                         got = b""
  1554.                     break
  1555.                 self._log(f"    (skipping cmd_type=0x{ct:08X} waiting for 0x14 reply)")
  1556.             if not got:
  1557.                 self._log("  [WARN] no/empty UPLOAD_BUFFER reply -- stopping")
  1558.                 break
  1559.             data += got
  1560.             buf_id += 1
  1561.             if len(got) < req:
  1562.                 # detector returned a short chunk; assume that's all it has
  1563.                 break
  1564.  
  1565.         # Best-effort EndOfUpload (empty command). cmd_type for end-of-upload is internal;
  1566.         # a read-only probe does not strictly need it, so we skip to avoid sending a guessed type.
  1567.         self._log(f"  [OK] read {len(data)} bytes for 0x{upload_id:02X} ({name})")
  1568.         return bytes(data)
  1569.  
  1570.     def probe_detector_data(self, skip_discovery: bool = False) -> None:
  1571.         """Connect (working handshake) then READ the detector's stored DetectorInfo + HostList +
  1572.        cal-results via the upload protocol. Pure read-back -- no writes, no flash. Used to learn the
  1573.        detector's registration/pairing state (is any host registered? what HostId does it expect?)
  1574.        before deciding on anything destructive."""
  1575.         self._open_sockets()
  1576.         try:
  1577.             if not skip_discovery and not self.discover():
  1578.                 self._log("[WARN] discovery failed")
  1579.                 return
  1580.             self.send_port_setup(host_cmd_port=self.reply_port, host_img_port=HOST_IMAGE_PORT)
  1581.             self.request_signature()
  1582.             categories = [
  1583.                 (UPLOAD_ID_DETECTORINFO, "DetectorInfo"),   # known-good: validates the read path
  1584.                 (UPLOAD_ID_HOSTLIST,     "HostList"),        # the registration / pairing state
  1585.                 (UPLOAD_ID_CALRESULTS,   "CalResults"),
  1586.             ]
  1587.             print("\n=== Detector stored-data read-back (non-destructive) ===")
  1588.             for uid, name in categories:
  1589.                 blob = self.read_detector_data(uid, name)
  1590.                 print(f"\n--- 0x{uid:02X} {name}: {len(blob)} bytes ---")
  1591.                 if blob:
  1592.                     self._hexdump(blob)
  1593.                     # If it looks like text (.dyn config), also print decoded
  1594.                     printable = sum(1 for b in blob if 9 <= b <= 13 or 32 <= b < 127)
  1595.                     if printable > 0.8 * len(blob):
  1596.                         print("  [decoded text]")
  1597.                         print("  " + blob.decode("latin1").replace("\n", "\n  "))
  1598.             print("\n=======================================================\n")
  1599.         finally:
  1600.             self._close_sockets()
  1601.  
  1602.     def _hexdump(self, data: bytes, maxlen: int = 512) -> None:
  1603.         for i in range(0, min(len(data), maxlen), 16):
  1604.             chunk = data[i:i + 16]
  1605.             hexs = " ".join(f"{b:02X}" for b in chunk)
  1606.             ascii_ = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
  1607.             print(f"  {i:04X}  {hexs:<48}  {ascii_}")
  1608.         if len(data) > maxlen:
  1609.             print(f"  ... ({len(data) - maxlen} more bytes)")
  1610.  
  1611.     def configure_upload_probe(self, upload_id: int, timeout_s: float = 2.0):
  1612.         """Send only CONFIGURE_UPLOAD (cmd 0x13) for upload_id and return (status, totalSize).
  1613.        Non-destructive: this just asks the detector "can I read category X, and how big is it".
  1614.        Returns (status, size); status==0 means readable. (None, None) if no 0x13 reply."""
  1615.         self._send_cmd(build_configure_upload(upload_id))
  1616.         deadline = time.time() + timeout_s
  1617.         while time.time() < deadline:
  1618.             try:
  1619.                 seq, ct, pl = self._recv_cmd(None, timeout_s=max(0.1, deadline - time.time()))
  1620.             except TimeoutError:
  1621.                 break
  1622.             if ct is None:
  1623.                 continue
  1624.             if ct == CMD_CONFIGURE_UPLOAD:
  1625.                 status = pl[0] if pl else 0xFF
  1626.                 size = struct.unpack_from("<I", pl, 1)[0] if len(pl) >= 5 else 0
  1627.                 return status, size
  1628.         return None, None
  1629.  
  1630.     def backup_detector_data(self, out_dir: str = ".",
  1631.                              lo: int = 0x00, hi: int = 0xFF,
  1632.                              skip_discovery: bool = False) -> None:
  1633.         """Back up EVERYTHING the detector will hand over via the upload protocol (read-only).
  1634.  
  1635.        The internal NOR flash can't be read as raw firmware, but the detector exposes a set of
  1636.        data/config/calibration FILES by upload-ID (HostList, DetectorInfo/shock, cal results, DAT,
  1637.        Map, SensorInfo, ...). We don't have a clean ID table, so we SWEEP ids [lo..hi]: for each,
  1638.        CONFIGURE_UPLOAD reports status+size; for every readable one we pull the full blob and save
  1639.        it. CONFIGURE_UPLOAD only stages a read -- nothing is written to the detector.
  1640.  
  1641.        Saves: <out_dir>/detector_backup_<timestamp>/upload_0x<id>_<size>.bin  + manifest.txt
  1642.        Do this BEFORE any registration/flash write so we have a complete copy of the current state.
  1643.        """
  1644.         ts = datetime.now().strftime("%Y%m%d_%H%M%S")
  1645.         backup_dir = os.path.join(out_dir, f"detector_backup_{ts}")
  1646.         os.makedirs(backup_dir, exist_ok=True)
  1647.         self._open_sockets()
  1648.         manifest = []
  1649.         try:
  1650.             if not skip_discovery and not self.discover():
  1651.                 self._log("[WARN] discovery failed")
  1652.                 return
  1653.             self.send_port_setup(host_cmd_port=self.reply_port, host_img_port=HOST_IMAGE_PORT)
  1654.             sig = self.request_signature()
  1655.             if sig is not None:
  1656.                 with open(os.path.join(backup_dir, "signature.txt"), "w") as fh:
  1657.                     fh.write(str(sig) + "\n")
  1658.                 manifest.append(f"signature: {sig}")
  1659.  
  1660.             print(f"\n=== Detector full backup -> {backup_dir} ===")
  1661.             print(f"Sweeping upload IDs 0x{lo:02X}..0x{hi:02X} (read-only)...")
  1662.             found = 0
  1663.             for uid in range(lo, hi + 1):
  1664.                 status, size = self.configure_upload_probe(uid)
  1665.                 if status is None:
  1666.                     continue          # no reply -- id not handled
  1667.                 if status != 0:
  1668.                     continue          # detector refused this id
  1669.                 if size == 0:
  1670.                     print(f"  0x{uid:02X}: readable but empty (size 0)")
  1671.                     manifest.append(f"0x{uid:02X}: empty")
  1672.                     continue
  1673.                 print(f"  0x{uid:02X}: readable, {size} bytes -> reading...")
  1674.                 blob = self.read_detector_data(uid, name=f"id0x{uid:02X}")
  1675.                 fn = os.path.join(backup_dir, f"upload_0x{uid:02X}_{len(blob)}.bin")
  1676.                 with open(fn, "wb") as fh:
  1677.                     fh.write(blob)
  1678.                 printable = sum(1 for b in blob if 9 <= b <= 13 or 32 <= b < 127)
  1679.                 kind = "text" if blob and printable > 0.8 * len(blob) else "binary"
  1680.                 print(f"        saved {len(blob)}/{size} bytes ({kind}) -> {os.path.basename(fn)}")
  1681.                 manifest.append(f"0x{uid:02X}: {len(blob)} bytes ({kind}) -> {os.path.basename(fn)}")
  1682.                 found += 1
  1683.             with open(os.path.join(backup_dir, "manifest.txt"), "w") as fh:
  1684.                 fh.write("\n".join(manifest) + "\n")
  1685.             print(f"\n[OK] Backup complete: {found} data blobs saved to {backup_dir}")
  1686.             print("=" * 60 + "\n")
  1687.         finally:
  1688.             self._close_sockets()
  1689.  
  1690.     # -- detector data WRITE (DESTRUCTIVE -- writes flash) ----------------------
  1691.  
  1692.     def write_detector_data(self, download_id: int, data: bytes,
  1693.                             chunk: int = 1024, timeout_s: float = 8.0) -> bool:
  1694.         """Write `data` to the detector's `download_id` category and COMMIT it to flash.
  1695.  
  1696.        Sequence (confirmed from libDetection.so.c downloadData):
  1697.          1. host -> det  cmd 0x0E [downloadId:4LE][totalSize:4LE]   ; reply 0x0E [status:1]
  1698.          2. loop bufId=0,1,..: host -> det cmd 0x0F [bufId:4LE][numBytes:4LE][data]
  1699.                                reply 0x0F [status:1][_:4]
  1700.          3. host -> det  cmd 0x10 (empty)  = FLASH COMMIT/BURN      ; reply 0x10 [status:1]
  1701.        *** STEP 3 WRITES THE DETECTOR'S FLASH AND IS IRREVERSIBLE. ***
  1702.        Returns True only if every step reported status 0.
  1703.        """
  1704.         total = len(data)
  1705.         self._log(f"WRITE: CONFIGURE_DOWNLOAD id=0x{download_id:02X} totalSize={total}")
  1706.         self._send_cmd(build_configure_download(download_id, total))
  1707.         if not self._await_status(CMD_CONFIGURE_DOWNLOAD, "CONFIGURE_DOWNLOAD", timeout_s):
  1708.             return False
  1709.         buf_id = 0
  1710.         off = 0
  1711.         while off < total:
  1712.             piece = data[off:off + chunk]
  1713.             self._log(f"  DOWNLOAD_BUFFER bufId={buf_id} numBytes={len(piece)} "
  1714.                       f"({off}/{total})")
  1715.             self._send_cmd(build_download_buffer(buf_id, piece))
  1716.             if not self._await_status(CMD_DOWNLOAD_BUFFER, f"DOWNLOAD_BUFFER[{buf_id}]", timeout_s):
  1717.                 return False
  1718.             off += len(piece)
  1719.             buf_id += 1
  1720.         self._log("  FLASH_COMMIT (cmd 0x10) -- *** writing flash ***")
  1721.         self._send_cmd(build_flash_commit())
  1722.         if not self._await_status(CMD_FLASH_COMMIT, "FLASH_COMMIT", max(timeout_s, 15.0)):
  1723.             return False
  1724.         self._log("  [OK] write+commit reported success")
  1725.         return True
  1726.  
  1727.     def _await_status(self, expect_cmd: int, label: str, timeout_s: float) -> bool:
  1728.         """Wait for `expect_cmd` reply and check its leading status byte == 0."""
  1729.         deadline = time.time() + timeout_s
  1730.         while time.time() < deadline:
  1731.             try:
  1732.                 seq, ct, pl = self._recv_cmd(None, timeout_s=max(0.1, deadline - time.time()))
  1733.             except TimeoutError:
  1734.                 break
  1735.             if ct is None:
  1736.                 continue
  1737.             if ct == expect_cmd:
  1738.                 status = pl[0] if pl else 0xFF
  1739.                 ok = (status == 0)
  1740.                 self._log(f"    {label} reply: status=0x{status:02X} "
  1741.                           f"{'OK' if ok else '*** NONZERO ***'} (payload={pl.hex() if pl else 'none'})")
  1742.                 return ok
  1743.             self._log(f"    ({label}: skipping cmd 0x{ct:08X})")
  1744.         self._log(f"    [FAIL] {label}: no reply in {timeout_s}s")
  1745.         return False
  1746.  
  1747.     def smoke_test_hostlist(self, commit: bool = False,
  1748.                             skip_discovery: bool = False) -> None:
  1749.         """SAFEST write validation: read the current HostList (0x71), then write back the IDENTICAL
  1750.        bytes and read again to confirm. Targets the HostList/config region (flash 0x940000), NOT the
  1751.        firmware. Worst case on a botched write = HostList (registration) corruption, which we have
  1752.        fully backed up. Dry-run by default; pass commit=True to actually perform the flash write.
  1753.        """
  1754.         self._open_sockets()
  1755.         try:
  1756.             if not skip_discovery and not self.discover():
  1757.                 self._log("[WARN] discovery failed"); return
  1758.             self.send_port_setup(host_cmd_port=self.reply_port, host_img_port=HOST_IMAGE_PORT)
  1759.             self.request_signature()
  1760.  
  1761.             print("\n=== HostList write SMOKE-TEST (identical bytes) ===")
  1762.             before = self.read_detector_data(DOWNLOAD_ID_HOSTLIST, "HostList(before)")
  1763.             if not before:
  1764.                 print("[ABORT] could not read current HostList -- not writing."); return
  1765.             print(f"Current HostList: {len(before)} bytes")
  1766.             self._hexdump(before, maxlen=len(before))
  1767.             # sanity: the augmented-message CRC over the WHOLE blob (incl. its trailer) must be 0.
  1768.             # (equivalently: hostlist_crc(body + 0x00000000) == trailer_BE)
  1769.             crc_whole = hostlist_crc(before)
  1770.             crc_regen = hostlist_crc(before[:-4] + b"\x00\x00\x00\x00")
  1771.             crc_trailer = struct.unpack(">I", before[-4:])[0]
  1772.             crc_ok = (crc_whole == 0) and (crc_regen == crc_trailer)
  1773.             print(f"CRC self-check: whole-blob={crc_whole:#010x} (want 0); "
  1774.                   f"regen={crc_regen:#010x} vs trailer={crc_trailer:#010x} -> "
  1775.                   f"{'OK' if crc_ok else 'MISMATCH'}")
  1776.  
  1777.             if not commit:
  1778.                 print("\n[DRY-RUN] Would now send (NOTHING sent without --commit):")
  1779.                 print(f"  1. CONFIGURE_DOWNLOAD id=0x{DOWNLOAD_ID_HOSTLIST:02X} size={len(before)}")
  1780.                 nchunks = (len(before) + 1023) // 1024
  1781.                 print(f"  2. {nchunks} x DOWNLOAD_BUFFER (the {len(before)} identical bytes above)")
  1782.                 print(f"  3. FLASH_COMMIT (cmd 0x10) -- writes flash")
  1783.                 print("  Re-run with --commit to actually perform the write.")
  1784.                 print("=" * 52 + "\n")
  1785.                 return
  1786.  
  1787.             print("\n*** --commit set: performing the flash write of IDENTICAL bytes ***")
  1788.             ok = self.write_detector_data(DOWNLOAD_ID_HOSTLIST, before)
  1789.             if not ok:
  1790.                 print("[FAIL] write/commit did not report success -- verifying state...")
  1791.             after = self.read_detector_data(DOWNLOAD_ID_HOSTLIST, "HostList(after)")
  1792.             print(f"\nRead-back after write: {len(after)} bytes")
  1793.             if after == before:
  1794.                 print("[OK] *** SMOKE-TEST PASSED: HostList identical after write -- "
  1795.                       "the write/commit path works and is safe. ***")
  1796.             else:
  1797.                 print("[WARN] HostList differs after write! before != after:")
  1798.                 self._hexdump(after, maxlen=len(after))
  1799.                 print("  (Restore from backup upload_0x71_504.bin if needed.)")
  1800.             print("=" * 52 + "\n")
  1801.         finally:
  1802.             self._close_sockets()
  1803.  
  1804.     def register_self(self, commit: bool = False, skip_discovery: bool = False,
  1805.                       host_mac: str = "00:6f:00:01:0a:3a",
  1806.                       name: str = "FLASHPAD_RE", location: str = "RE_HOST") -> None:
  1807.         """Register THIS host in the detector's HostList and set it as the primary host, so the
  1808.        detector will stream images to us. Appends our entry (HostId derived from host_mac, which MUST
  1809.        be the MAC of the eth interface that talks to the detector), preserves the existing
  1810.        registrations, sets IndexToPrimaryHost to our new index, recomputes the CRC, and writes via
  1811.        0x71 + flash commit. Dry-run by default; --commit performs the flash write.
  1812.        Fully reversible: restore the 504-byte backup (upload_0x71_504.bin) to undo.
  1813.        """
  1814.         our_mac = host_mac
  1815.         our_hostid = hostid_from_mac(our_mac)
  1816.         self._open_sockets()
  1817.         try:
  1818.             if not skip_discovery and not self.discover():
  1819.                 self._log("[WARN] discovery failed"); return
  1820.             self.send_port_setup(host_cmd_port=self.reply_port, host_img_port=HOST_IMAGE_PORT)
  1821.             self.request_signature()
  1822.  
  1823.             print("\n=== REGISTER SELF in detector HostList ===")
  1824.             cur = self.read_detector_data(DOWNLOAD_ID_HOSTLIST, "HostList(before)")
  1825.             if not cur or hostlist_crc(cur) != 0:
  1826.                 print("[ABORT] could not read a valid current HostList -- not writing."); return
  1827.             _, entries, count, primary = parse_hostlist(cur)
  1828.             print(f"Current: {count} hosts, IndexToPrimaryHost={primary} "
  1829.                   f"({'NONE' if primary == 0xFFFF else primary})")
  1830.             for i, e in enumerate(entries):
  1831.                 hid = e[:16].split(b'\x00')[0].decode('latin1')
  1832.                 nm  = e[16:80].split(b'\x00')[0].decode('latin1')
  1833.                 print(f"    host[{i}] HostId={hid:18s} name={nm!r}")
  1834.             print(f"Our MAC={our_mac or '(unknown, using fallback)'}  -> HostId={our_hostid}")
  1835.  
  1836.             new = build_registered_hostlist(cur, our_hostid, name, location)
  1837.             _, nentries, ncount, nprimary = parse_hostlist(new)
  1838.             print(f"\nNEW: {ncount} hosts, IndexToPrimaryHost={nprimary} (our appended entry), "
  1839.                   f"{len(cur)} -> {len(new)} bytes, CRC self-check="
  1840.                   f"{'OK' if hostlist_crc(new) == 0 else 'BAD'}")
  1841.             print(f"    + host[{ncount-1}] HostId={our_hostid} name={name!r} loc={location!r}  PRIMARY")
  1842.  
  1843.             if not commit:
  1844.                 print("\n[DRY-RUN] Would CONFIGURE_DOWNLOAD 0x71 / "
  1845.                       f"{len(new)} -> DOWNLOAD_BUFFER(s) -> FLASH_COMMIT.")
  1846.                 print("  New HostList bytes:")
  1847.                 self._hexdump(new, maxlen=len(new))
  1848.                 print("  Re-run with --commit to write it. Then RECONNECT and run --dark-only to test.")
  1849.                 print("=" * 44 + "\n")
  1850.                 return
  1851.  
  1852.             print("\n*** --commit: writing modified HostList (registering us as primary) ***")
  1853.             ok = self.write_detector_data(DOWNLOAD_ID_HOSTLIST, new)
  1854.             after = self.read_detector_data(DOWNLOAD_ID_HOSTLIST, "HostList(after)")
  1855.             if after == new:
  1856.                 print("[OK] *** REGISTERED: HostList written & verified. We are primary host "
  1857.                       f"index {ncount-1} (HostId {our_hostid}). ***")
  1858.                 print("  NEXT: reconnect and run  python flashpad_acquire.py --dark-only --no-standby")
  1859.                 print("  to see whether the detector now streams 0x0F image data to us.")
  1860.             else:
  1861.                 print(f"[WARN] read-back ({len(after)}B) != written ({len(new)}B). "
  1862.                       "Restore upload_0x71_504.bin if needed.")
  1863.                 self._hexdump(after, maxlen=min(len(after), 256))
  1864.             print("=" * 44 + "\n")
  1865.         finally:
  1866.             self._close_sockets()
  1867.  
  1868.     def restore_hostlist(self, path: str, commit: bool = False,
  1869.                          skip_discovery: bool = False) -> None:
  1870.         """Write a saved HostList blob (e.g. the backup upload_0x71_504.bin) back to the detector via
  1871.        0x71 + flash commit. The safety 'undo' button. Dry-run unless commit=True."""
  1872.         with open(path, "rb") as fh:
  1873.             blob = fh.read()
  1874.         print(f"\n=== RESTORE HostList from {path} ({len(blob)} bytes) ===")
  1875.         if len(blob) < HL_HEADER_LEN + 4 or hostlist_crc(blob) != 0:
  1876.             print(f"[ABORT] {path} is not a valid HostList blob (CRC self-check != 0). "
  1877.                   "Refusing to write."); return
  1878.         _, ents, cnt, pri = parse_hostlist(blob)
  1879.         print(f"  valid HostList: {cnt} hosts, primary={pri}, CRC OK")
  1880.         if not commit:
  1881.             print("  [DRY-RUN] re-run with --commit to write it back."); print("=" * 44 + "\n"); return
  1882.         self._open_sockets()
  1883.         try:
  1884.             if not skip_discovery and not self.discover():
  1885.                 self._log("[WARN] discovery failed"); return
  1886.             self.send_port_setup(host_cmd_port=self.reply_port, host_img_port=HOST_IMAGE_PORT)
  1887.             self.request_signature()
  1888.             print("*** --commit: restoring HostList ***")
  1889.             self.write_detector_data(DOWNLOAD_ID_HOSTLIST, blob)
  1890.             after = self.read_detector_data(DOWNLOAD_ID_HOSTLIST, "HostList(after)")
  1891.             print("[OK] restored & verified" if after == blob else
  1892.                   f"[WARN] read-back ({len(after)}B) != file ({len(blob)}B)")
  1893.             print("=" * 44 + "\n")
  1894.         finally:
  1895.             self._close_sockets()
  1896.  
  1897.     # -- sensor reads ----------------------------------------------------------
  1898.  
  1899.     def read_sensor(self, selector: int, cmd_type: int = CMD_READ_SENSOR,
  1900.                     timeout_s: float = 3.0):
  1901.         """Read a detector sensor.  Sends a PDAP command (0x7900 raw / 0x7902 converted /
  1902.        0x7904 detailed) with a 4-byte selector and returns the 4-byte sensor value from the
  1903.        matching reply, or None on timeout.  Independent of the (broken) image transfer path.
  1904.        Format confirmed: createReadDetectorSensorCmd + EthernetDetector::readSensor.
  1905.        """
  1906.         self._send_cmd(make_pdap(cmd_type, struct.pack("<I", selector)))
  1907.         deadline = time.time() + timeout_s
  1908.         while time.time() < deadline:
  1909.             try:
  1910.                 seq, ct, pl = self._recv_cmd(None, timeout_s=max(0.05, deadline - time.time()))
  1911.             except Exception:
  1912.                 break
  1913.             if ct is None:            # bare-ACK -- keep waiting for the PDAP reply
  1914.                 continue
  1915.             if ct == cmd_type:
  1916.                 if pl and len(pl) >= 4:
  1917.                     return struct.unpack_from("<I", pl, 0)[0]
  1918.                 return None
  1919.             self._log(f"  sensor wait: skipping cmd_type=0x{ct:08X}")
  1920.         return None
  1921.  
  1922.     def read_sensors(self, skip_discovery: bool = False, roe_init: bool = True) -> bool:
  1923.         """Connect and read detector sensors (accelerometer, temperature).  Does NOT touch the
  1924.        image transfer path, so it works even though image streaming is blocked.
  1925.        """
  1926.         self._open_sockets()
  1927.         try:
  1928.             if not skip_discovery and not self.discover():
  1929.                 self._log("[WARN] discovery failed")
  1930.                 return False
  1931.             self.send_port_setup(host_cmd_port=self.reply_port, host_img_port=HOST_IMAGE_PORT)
  1932.             self.request_signature()
  1933.             if roe_init:
  1934.                 # The accelerometer/vibration + temperature sensors hang off the ROE; Script7
  1935.                 # powers/initializes it.  Harmless (~1s) and improves the odds of valid reads.
  1936.                 self._log("Running Script7 (ROE init) before sensor reads")
  1937.                 if self.download_script(build_script_7_roe_init(), "Script7-ROEInit"):
  1938.                     if self.execute_script():
  1939.                         self.wait_for_execution_complete(timeout_s=10.0)
  1940.  
  1941.             # Read the FULL sensor table from the detector's own [Sensor] map (backup 0x07).
  1942.             # Primary read = cmd 0x7902 (CONVERTED -- detector does unit conversion, per the table).
  1943.             # We also show the 0x7900 (RAW) value alongside for comparison / when 0x7902 is unsupported.
  1944.             def s32(v):
  1945.                 return None if v is None else (v - 0x100000000 if (v & 0x80000000) else v)
  1946.  
  1947.             print("\n=== Detector sensor readings (full table from 0x07) ===")
  1948.             print(f"  {'Sensor':22s} {'id':>4s}  {'converted (0x7902)':>18s}  {'raw':>6s}  decoded")
  1949.             print("  " + "-" * 70)
  1950.             any_ok = False
  1951.             seen = {}                # (conv,raw) -> first sensor name that produced it
  1952.             for name, sid in SENSOR_TABLE:
  1953.                 conv = self.read_sensor(sid, CMD_READ_SENSOR_CONVERTED)
  1954.                 raw  = self.read_sensor(sid, CMD_READ_SENSOR)
  1955.                 if conv is None and raw is None:
  1956.                     print(f"  {name:22s} {sid:4d}  {'(no reply)':>18s}")
  1957.                     continue
  1958.                 any_ok = True
  1959.                 cs, rs = s32(conv), s32(raw)
  1960.                 # Flag stale/unsupported: ids whose (conv,raw) exactly duplicate an earlier sensor's
  1961.                 # are the detector returning leftover conversion-register contents (unimplemented id).
  1962.                 key = (conv, raw)
  1963.                 dup = seen.get(key)
  1964.                 if dup is None:
  1965.                     seen[key] = name
  1966.                 # decode
  1967.                 note = ""
  1968.                 if dup is not None:
  1969.                     note = f"** stale/unsupported (== {dup})"
  1970.                 elif rs is not None and (rs & 0xFFFF) == 0x3FF:
  1971.                     note = "** ADC railed (open/idle) -- invalid"
  1972.                 elif name.startswith(("Temp_",)):
  1973.                     note = f"~{cs/10.0:.1f} degC (railed)" if cs is not None else ""
  1974.                 elif cs is not None and abs(cs) <= 30000:
  1975.                     note = f"{cs/1000.0:+.3f} V"   # rails are in mV
  1976.                 cstr = f"0x{conv:08X}({cs})" if conv is not None else "-"
  1977.                 rstr = f"{rs}" if rs is not None else "-"
  1978.                 print(f"  {name:22s} {sid:4d}  {cstr:>18s}  {rstr:>6s}  {note}")
  1979.             print("  " + "-" * 70)
  1980.             print("  converted (0x7902) = detector engineering units: power rails in mV (shown as V),")
  1981.             print("  temps in 0.1 degC. raw (0x7900) = 12-bit ADC counts (0x3FF=1023=railed/open).")
  1982.             print("=" * 72 + "\n")
  1983.             if not any_ok:
  1984.                 self._log("[WARN] No sensor replied -- the sensor path may be gated too, or "
  1985.                           "the panel/ROE is not powered.")
  1986.             return any_ok
  1987.         finally:
  1988.             self._close_sockets()
  1989.  
  1990.     def sweep_acquisition(self, type_modes=(1, 0), transfer_modes=(0, 1, 2, 3, 4, 5),
  1991.                           watch_s: float = 12.0) -> None:
  1992.         """EXPERIMENT: run the dark acquisition across (type_mode, transfer_mode) combinations and
  1993.        report which (if any) makes the detector finally stream 0x0F pixel frames. The acquisition
  1994.        'Transfer Mode' byte is the one knob we control that's literally named for image-data transfer;
  1995.        we've only ever sent 0. Fresh connection per combo. Non-destructive (acquisitions only)."""
  1996.         print("\n=== Acquisition transfer-mode SWEEP (looking for a 0x0F push) ===")
  1997.         print("(gentle mode: bounded discovery + recovery pause; stops if detector goes unresponsive)")
  1998.         results = []
  1999.         dead_streak = 0
  2000.         for tm in type_modes:
  2001.             for xfer in transfer_modes:
  2002.                 self._open_sockets()
  2003.                 got = 0
  2004.                 self._last_xfer_imageid = None
  2005.                 self._last_xfer_count = None
  2006.                 try:
  2007.                     if not self.discover(max_wait=8.0):
  2008.                         dead_streak += 1
  2009.                         self._log(f"[WARN] discover failed (detector unresponsive, streak={dead_streak})")
  2010.                         if dead_streak >= 2:
  2011.                             print("\n[STOP] detector stopped responding -- POWER-CYCLE it and re-run. "
  2012.                                   f"(last combo attempted: type_mode={tm} transfer_mode={xfer})")
  2013.                             return
  2014.                         continue
  2015.                     dead_streak = 0
  2016.                     self.send_port_setup(host_cmd_port=self.reply_port, host_img_port=HOST_IMAGE_PORT)
  2017.                     self.request_signature()
  2018.                     if not self.download_script(build_script_7_roe_init(), "Script7"):
  2019.                         continue
  2020.                     if not self.download_script(
  2021.                             build_script_1_dark_acq(type_mode=tm, transfer_mode=xfer),
  2022.                             f"Dark(tm={tm},xfer={xfer})"):
  2023.                         continue
  2024.                     self._open_image_socket(HOST_IMAGE_PORT)
  2025.                     if not self.execute_script():
  2026.                         continue
  2027.                     print(f"  --- type_mode={tm} transfer_mode={xfer}: acquiring, watching {watch_s}s ---")
  2028.                     raw = self.receive_image(output_path=None, timeout_s=watch_s,
  2029.                                              script_id=1, image_port=HOST_IMAGE_PORT,
  2030.                                              wait_exec_complete=True)
  2031.                     got = len(raw)
  2032.                 finally:
  2033.                     self._close_sockets()
  2034.                 iid = self._last_xfer_imageid
  2035.                 cnt = self._last_xfer_count
  2036.                 beh = f"imageId={'0x%04X' % iid if iid is not None else '?'} count={cnt}"
  2037.                 tag = f"*** {got} BYTES PUSHED ***" if got else f"no 0x0F  ({beh})"
  2038.                 print(f"  RESULT type_mode={tm} transfer_mode={xfer}: {tag}")
  2039.                 results.append((tm, xfer, got, iid, cnt))
  2040.                 time.sleep(3.0)   # recovery pause: let the detector return to idle between acqs
  2041.         print("\n=== sweep summary (imageId/count = the detector's transfer-state response) ===")
  2042.         hits = [r for r in results if r[2]]
  2043.         for t, x, g, iid, cnt in results:
  2044.             beh = f"imageId={'0x%04X' % iid if iid is not None else '?'} count={cnt}"
  2045.             print(f"  type_mode={t} transfer_mode={x}: "
  2046.                   f"{'PUSH %d bytes' % g if g else 'no push'}   [{beh}]")
  2047.         if hits:
  2048.             print(f"\n[!!!] PUSH detected at: " +
  2049.                   ", ".join(f"(type={t},xfer={x})" for t, x, _, _, _ in hits))
  2050.         else:
  2051.             print("\nNo combo pushed 0x0F. But note any combo where imageId/count CHANGED "
  2052.                   "(e.g. imageId=0x8000) -- that's transfer-mode altering the state machine.")
  2053.         print("=" * 60 + "\n")
  2054.  
  2055.     def preview_acquire(self, watch_s: float = 12.0) -> bytes:
  2056.         """Single-shot dark acquisition on the PREVIEW transfer path (acquisition transfer_mode=2).
  2057.  
  2058.        In tm=2 the detector advertises imageId=0x8000 (preview flag) and a 4-buffer image in its
  2059.        0x41 reply.  The 0x30000 status reply now honors that 4-buffer count (instead of hardcoding
  2060.        8), so we ask for exactly the buffers the preview set contains, then watch port 6660 for a
  2061.        0x0F push.  ONE clean connection (unlike --sweep-acq, which cycles modes and tends to wedge
  2062.        the detector), so it's safe to run repeatedly.  Run a tshark capture on host 192.168.1.30 in
  2063.        parallel to confirm any push on the wire.  Non-destructive (acquisition only)."""
  2064.         print("\n=== PREVIEW single-shot (transfer_mode=2, 4-buffer matched reply) ===")
  2065.         self._open_sockets()
  2066.         self._last_xfer_imageid = None
  2067.         self._last_xfer_count   = None
  2068.         raw = b""
  2069.         try:
  2070.             if not self.discover(max_wait=8.0):
  2071.                 self._log("[FAIL] discovery failed -- power-cycle the detector and retry")
  2072.                 return b""
  2073.             self.send_port_setup(host_cmd_port=self.reply_port, host_img_port=HOST_IMAGE_PORT)
  2074.             self.request_signature()
  2075.             if not self.download_script(build_script_7_roe_init(), "Script7-ROEInit"):
  2076.                 return b""
  2077.             if not self.download_script(
  2078.                     build_script_1_dark_acq(type_mode=1, transfer_mode=2),
  2079.                     "Dark(tm=1,xfer=2 PREVIEW)"):
  2080.                 return b""
  2081.             self._open_image_socket(HOST_IMAGE_PORT)
  2082.             if not self.execute_script():
  2083.                 return b""
  2084.             print(f"  --- preview acquiring, watching {watch_s}s for a 0x0F push ---")
  2085.             raw = self.receive_image(output_path=None, timeout_s=watch_s,
  2086.                                      script_id=1, image_port=HOST_IMAGE_PORT,
  2087.                                      wait_exec_complete=True)
  2088.         finally:
  2089.             self._close_sockets()
  2090.         iid = self._last_xfer_imageid
  2091.         cnt = self._last_xfer_count
  2092.         if raw:
  2093.             ts = datetime.now().strftime("%Y%m%d_%H%M%S")
  2094.             out = f"flashpad_preview_{ts}.raw"
  2095.             with open(out, "wb") as f:
  2096.                 f.write(raw)
  2097.             print(f"\n[!!!] PREVIEW PUSH: {len(raw)} bytes streamed -> {out}")
  2098.         else:
  2099.             beh = f"imageId={'0x%04X' % iid if iid is not None else '?'} count={cnt}"
  2100.             print(f"\n  no 0x0F pushed  ({beh})")
  2101.         return raw
  2102.  
  2103.     # -- high-level acquisition ------------------------------------------------
  2104.  
  2105.     def run_full_acquisition(self,
  2106.                               output_dir: str = ".",
  2107.                               do_dark: bool = False,
  2108.                               dark_only: bool = False,
  2109.                               skip_discovery: bool = False,
  2110.                               skip_standby: bool = False,
  2111.                               two_exec: bool = False,
  2112.                               exec_timeout: float = 60.0,
  2113.                               image_port: int = HOST_IMAGE_PORT) -> bytes:
  2114.         """Full acquisition sequence:
  2115.          1.  Open ctrl socket on port 5550 (Detector_HostPort)
  2116.          2.  SYSTEM_STARTUP (sets our IP on detector; bare ACKs use this port)
  2117.          3.  PORT_SETUP (hostCmdPort=5550, hostImgPort=6660) -- MUST be before SIGNATURE_REQUEST
  2118.          4.  SIGNATURE_REQUEST → SIGNATURE_REPLY (54-byte detector ID)
  2119.          5.  Download Script 7 (ROE init)
  2120.          6.  Download Script 8 (standby loop) [unless skip_standby]
  2121.          7a. [dark_only=True]  Download Script 1 (dark acq, no X-ray needed)
  2122.          7b. [do_dark=True]    Download Script 1 (dark), then Script 0 (std acq)
  2123.          7c. [default]         Download Script 0 (standard acq, needs X-ray)
  2124.          8.  Open image socket on port 6660 ← BEFORE execute_script (data streams during exec)
  2125.          9.  EXECUTE_SCRIPT
  2126.          10. receive_image() unified loop (ctrl + img sockets via select):
  2127.                - ctrl: await EXECUTION_COMPLETE, handle 0x30000 → reply cmd_type=9
  2128.                - img:  collect pixel data until 5s silence after EXECUTION_COMPLETE + 0x30000
  2129.        Returns raw image bytes (empty bytes on failure).
  2130.  
  2131.        NOTE on Script8: Script8 is a standby loop with repeatCount=65535 that only terminates
  2132.        on event 41.  If Script8 blocks the acquisition (detector runs Script8 before Script0/1),
  2133.        use skip_standby=True (--no-standby) to omit Script8 and run the acquisition directly.
  2134.        NOTE on PORT_SETUP ordering: The detector stores TWO separate port values:
  2135.          - system/beacon port: updated by SYSTEM_STARTUP payload (used for bare ACKs)
  2136.          - cmd reply port:     updated by PORT_SETUP hostCmdPort (used for full command replies)
  2137.        SIGNATURE_REPLY goes to cmd reply port. PORT_SETUP MUST be sent before SIGNATURE_REQUEST,
  2138.        otherwise SIGNATURE_REPLY goes to the old cached cmd reply port (e.g. 48879 from a prior
  2139.        session) and the signature step always times out.
  2140.        NOTE on two_exec: If two_exec=True, Script7 is executed ALONE first and the host waits for
  2141.        its EXECUTION_COMPLETE before downloading and executing Script1.  Hypothesis: the GE host
  2142.        software does this because Script7 ends with SendHostEvent(17) and the host processes that
  2143.        event before queueing the acquisition script.  Running both scripts in one EXECUTE_SCRIPT
  2144.        may result in the image subsystem being uninitialized when Script1 runs.
  2145.        """
  2146.         self._open_sockets()
  2147.         try:
  2148.             if not skip_discovery:
  2149.                 if not self.discover():
  2150.                     return b""
  2151.             else:
  2152.                 self._log(f"skip_discovery=True -- assuming detector already initialized")
  2153.  
  2154.             # PORT_SETUP first: updates detector's cmd reply port to 5550 so all subsequent
  2155.             # command replies (including SIGNATURE_REPLY) arrive on our socket.
  2156.             # Must come before SIGNATURE_REQUEST.
  2157.             if not self.send_port_setup(host_cmd_port=self.reply_port, host_img_port=image_port):
  2158.                 self._log("Warning: PORT_SETUP failed, image data may not arrive")
  2159.  
  2160.             sig = self.request_signature()
  2161.             if sig is None:
  2162.                 self._log("Warning: no signature received, continuing anyway")
  2163.  
  2164.             if two_exec and dark_only:
  2165.                 # Two-EXECUTE_SCRIPT approach for dark acquisition.
  2166.                 # Script7 executed alone first; host waits for EXECUTION_COMPLETE (incl. event 17)
  2167.                 # before downloading and executing Script1. Mirrors the hypothesized GE host flow
  2168.                 # where event 17 triggers a state change before the acquisition script runs.
  2169.                 self._log("[two-exec] Phase 1: Script7 alone")
  2170.                 if not self.download_script(build_script_7_roe_init(), "Script7-ROEInit"):
  2171.                     return b""
  2172.                 if not self.execute_script():
  2173.                     return b""
  2174.                 if not self.wait_for_execution_complete(timeout_s=exec_timeout):
  2175.                     return b""
  2176.                 self._log("[two-exec] Script7 EXECUTION_COMPLETE; Phase 2: Script1")
  2177.                 if not self.download_script(build_script_1_dark_acq(), "Script1-DarkAcq"):
  2178.                     return b""
  2179.                 if not self._open_image_socket(image_port):
  2180.                     self._log("Warning: image socket failed to open -- pixel data may be lost")
  2181.                 if not self.execute_script():
  2182.                     return b""
  2183.                 ts = datetime.now().strftime("%Y%m%d_%H%M%S")
  2184.                 out_path = os.path.join(output_dir, f"flashpad_image_{ts}.raw")
  2185.                 return self.receive_image(output_path=out_path,
  2186.                                           timeout_s=exec_timeout + 30.0,
  2187.                                           script_id=1,
  2188.                                           image_port=image_port,
  2189.                                           wait_exec_complete=True)
  2190.  
  2191.             if not self.download_script(build_script_7_roe_init(), "Script7-ROEInit"):
  2192.                 return b""
  2193.             if not skip_standby:
  2194.                 if not self.download_script(build_script_8_standby(), "Script8-Standby"):
  2195.                     return b""
  2196.             else:
  2197.                 self._log("skip_standby=True -- omitting Script8 standby loop")
  2198.  
  2199.             if dark_only:
  2200.                 # Dark acquisition only -- no X-ray needed; EXECUTION_COMPLETE arrives ~seconds
  2201.                 if not self.download_script(build_script_1_dark_acq(), "Script1-DarkAcq"):
  2202.                     return b""
  2203.             elif do_dark:
  2204.                 # Download dark script first, then standard acq script
  2205.                 if not self.download_script(build_script_1_dark_acq(), "Script1-DarkAcq"):
  2206.                     return b""
  2207.                 if not self.download_script(build_script_0_std_acq(), "Script0-StdAcq"):
  2208.                     return b""
  2209.             else:
  2210.                 # Standard acquisition -- requires actual X-ray exposure to complete
  2211.                 if not self.download_script(build_script_0_std_acq(), "Script0-StdAcq"):
  2212.                     return b""
  2213.  
  2214.             # Open the image socket BEFORE execute_script.
  2215.             # CRITICAL: The detector streams pixel data to image_port DURING script
  2216.             # execution, BEFORE EXECUTION_COMPLETE.  If the socket is not open in time,
  2217.             # all early pixel packets are dropped by the OS.
  2218.             if not self._open_image_socket(image_port):
  2219.                 self._log("Warning: image socket failed to open -- pixel data may be lost")
  2220.  
  2221.             if not self.execute_script():
  2222.                 return b""
  2223.  
  2224.             # Determine scriptId for image retrieval:
  2225.             # dark_only uses Script1 (scriptID=1); all other paths use Script0 (scriptID=0)
  2226.             img_script_id = 1 if dark_only else 0
  2227.  
  2228.             ts = datetime.now().strftime("%Y%m%d_%H%M%S")
  2229.             out_path = os.path.join(output_dir, f"flashpad_image_{ts}.raw")
  2230.             # receive_image() handles EXECUTION_COMPLETE + 0x30000 → cmd_type=9 + pixel data
  2231.             # in a single select() loop.  exec_timeout + 30s extra for image streaming.
  2232.             return self.receive_image(output_path=out_path,
  2233.                                       timeout_s=exec_timeout + 30.0,
  2234.                                       script_id=img_script_id,
  2235.                                       image_port=image_port,
  2236.                                       wait_exec_complete=True)
  2237.  
  2238.         finally:
  2239.             self._close_sockets()
  2240.  
  2241.  
  2242. # -- CLI entry point -----------------------------------------------------------
  2243.  
  2244. def main():
  2245.     import argparse
  2246.     parser = argparse.ArgumentParser(
  2247.         description="GE FlashPad Apollo -- URP/PDAP acquisition tool")
  2248.     parser.add_argument("--detector-ip",   default=DETECTOR_IP,
  2249.                         help=f"Detector IP (default: {DETECTOR_IP})")
  2250.     parser.add_argument("--host-ip",       default=HOST_IP,
  2251.                         help=f"Host IP sent in SYSTEM_STARTUP (default: {HOST_IP})")
  2252.     parser.add_argument("--output-dir",    default=".",
  2253.                         help="Directory to save raw image (default: .)")
  2254.     parser.add_argument("--dark",          action="store_true",
  2255.                         help="Also run dark/offset acquisition Script 1 before Script 0")
  2256.     parser.add_argument("--dark-only",     action="store_true",
  2257.                         help="Run dark acquisition only (Script 1, no X-ray needed). "
  2258.                              "Use this to test the full protocol flow without X-ray exposure.")
  2259.     parser.add_argument("--no-standby",    action="store_true",
  2260.                         help="Omit Script8 standby loop (use if Script8 blocks acquisition)")
  2261.     parser.add_argument("--two-exec",      action="store_true",
  2262.                         help="Execute Script7 alone first, wait for EXECUTION_COMPLETE, "
  2263.                              "then execute Script1. Tests whether GE host does two separate "
  2264.                              "EXECUTE_SCRIPTs in response to Script7's SendHostEvent(17). "
  2265.                              "Only applies with --dark-only.")
  2266.     parser.add_argument("--exec-timeout",  type=float, default=60.0,
  2267.                         help="Seconds to wait for EXECUTION_COMPLETE (default: 60)")
  2268.     parser.add_argument("--timeout",       type=float, default=5.0,
  2269.                         help="Socket receive timeout in seconds (default: 5.0)")
  2270.     parser.add_argument("--dump-scripts",  action="store_true",
  2271.                         help="Dump all script wire bytes as hex and exit")
  2272.     parser.add_argument("--skip-discovery", action="store_true",
  2273.                         help="Skip discovery loop; send SYSTEM_STARTUP once then proceed")
  2274.     parser.add_argument("--sensors",       action="store_true",
  2275.                         help="Connect and read detector sensors (accelerometer, temperature) "
  2276.                              "via cmd 0x7900. Independent of the image transfer path.")
  2277.     parser.add_argument("--probe-data",    action="store_true",
  2278.                         help="NON-DESTRUCTIVE: connect, then READ the detector's stored "
  2279.                              "DetectorInfo + HostList (registration/pairing state) + cal results "
  2280.                              "via the upload protocol (cmd 0x13/0x14). No writes. Use this to see "
  2281.                              "whether any host is registered and what HostId the detector expects.")
  2282.     parser.add_argument("--backup",        nargs="?", const=".", default=None, metavar="DIR",
  2283.                         help="NON-DESTRUCTIVE full backup: sweep ALL upload IDs (0x00-0xFF) and save "
  2284.                              "every readable data/config/calibration blob to "
  2285.                              "DIR/detector_backup_<timestamp>/ (default DIR='.'). Do this FIRST, "
  2286.                              "before any registration/flash write.")
  2287.     parser.add_argument("--backup-range",  default=None, metavar="LO-HI",
  2288.                         help="With --backup, limit the ID sweep, e.g. --backup-range 0x40-0xA0 "
  2289.                              "(default 0x00-0xFF).")
  2290.     parser.add_argument("--smoke-test-write", action="store_true",
  2291.                         help="HostList write SMOKE-TEST: read the HostList (0x71) and write back the "
  2292.                              "IDENTICAL bytes to validate the write/commit path. DRY-RUN unless "
  2293.                              "--commit is also given. Targets the config region (not firmware); fully "
  2294.                              "backed up. Requires a prior --backup.")
  2295.     parser.add_argument("--commit",         action="store_true",
  2296.                         help="Arm the actual flash write for --smoke-test-write / --register-self "
  2297.                              "(otherwise dry-run). THIS WRITES THE DETECTOR'S FLASH.")
  2298.     parser.add_argument("--register-self",  action="store_true",
  2299.                         help="Register THIS host in the detector HostList as the PRIMARY host so it "
  2300.                              "will stream images to us (the suspected image-transfer gate). Appends "
  2301.                              "our entry, sets IndexToPrimaryHost, recomputes CRC, writes via 0x71. "
  2302.                              "DRY-RUN unless --commit. Reversible via backup upload_0x71_504.bin.")
  2303.     parser.add_argument("--host-mac",       default="00:6f:00:01:0a:3a", metavar="MAC",
  2304.                         help="MAC of the eth interface talking to the detector (for --register-self "
  2305.                              "HostId derivation). Default 00:6f:00:01:0a:3a.")
  2306.     parser.add_argument("--restore-hostlist", metavar="FILE", default=None,
  2307.                         help="Write a saved HostList blob (e.g. detector_backup_*/upload_0x71_504.bin) "
  2308.                              "back to the detector. The undo button. DRY-RUN unless --commit.")
  2309.     parser.add_argument("--sweep-acq",      action="store_true",
  2310.                         help="EXPERIMENT: sweep the dark acquisition across type_mode/transfer_mode "
  2311.                              "values and report which (if any) makes the detector push 0x0F pixels. "
  2312.                              "Non-destructive. Tests whether the acquisition transfer-mode is the gate.")
  2313.     parser.add_argument("--preview",       action="store_true",
  2314.                         help="EXPERIMENT: single-shot dark acquisition on the PREVIEW path "
  2315.                              "(transfer_mode=2). The detector advertises imageId=0x8000 + a 4-buffer "
  2316.                              "image; the 0x30000 reply is matched to that 4-buffer count (vs the usual "
  2317.                              "hardcoded 8) and we watch :6660 for a 0x0F push. One clean connection "
  2318.                              "(won't wedge the detector like --sweep-acq). Non-destructive. Run tshark "
  2319.                              "on host 192.168.1.30 in parallel.")
  2320.     parser.add_argument("--no-roe-init",   action="store_true",
  2321.                         help="With --sensors, skip the Script7 ROE init before reading")
  2322.     parser.add_argument("--parallel",      action="store_true",
  2323.                         help="Reply to the detector's 0x30000 status query with the cmd_type=0x99 "
  2324.                              "(ParallelImageTransfer) form -- [imageId:2][scriptId:2][numMissed:4]"
  2325.                              "[ids...] -- instead of the serial cmd_type=9. This is the only "
  2326.                              "status-reply path in libDetection tied to the image port; use it to "
  2327.                              "test whether the firmware needs it to start pushing 0x0F pixels.")
  2328.     parser.add_argument("--listen",        action="store_true",
  2329.                         help=f"Passively listen on port {HOST_REPLY_PORT} for 30s")
  2330.     parser.add_argument("--quiet",         action="store_true",
  2331.                         help="Suppress verbose output")
  2332.     args = parser.parse_args()
  2333.  
  2334.     if args.listen:
  2335.         print(f"Listening on port {HOST_REPLY_PORT} for 30 seconds...")
  2336.         s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  2337.         s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  2338.         s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  2339.         s.bind(("", HOST_REPLY_PORT))
  2340.         s.settimeout(1.0)
  2341.         deadline = time.time() + 30
  2342.         while time.time() < deadline:
  2343.             try:
  2344.                 data, addr = s.recvfrom(65535)
  2345.                 print(f"  {addr[0]}:{addr[1]} -> {len(data)}b  {data.hex()}")
  2346.             except socket.timeout:
  2347.                 pass
  2348.         s.close()
  2349.         return
  2350.  
  2351.     if args.dump_scripts:
  2352.         scripts = {
  2353.             "Script7 ROEInit": build_script_7_roe_init(),
  2354.             "Script8 Standby": build_script_8_standby(),
  2355.             "Script0 StdAcq":  build_script_0_std_acq(),
  2356.             "Script1 DarkAcq": build_script_1_dark_acq(),
  2357.         }
  2358.         for name, data in scripts.items():
  2359.             print(f"\n=== {name} ({len(data)} bytes PDAP) ===")
  2360.             urp = make_urp_packet(data, reply_port=0, flag=URP_FLAG_COMMAND)
  2361.             print(f"Full UDP payload ({len(urp)} bytes):")
  2362.             for i in range(0, len(urp), 16):
  2363.                 chunk = urp[i:i+16]
  2364.                 hex_part = " ".join(f"{b:02X}" for b in chunk)
  2365.                 asc_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
  2366.                 print(f"  {i:04X}  {hex_part:<48}  {asc_part}")
  2367.         return
  2368.  
  2369.     session = FlashPadSession(
  2370.         detector_ip=args.detector_ip,
  2371.         host_ip=args.host_ip,
  2372.         timeout=args.timeout,
  2373.         verbose=not args.quiet,
  2374.         parallel=args.parallel,
  2375.     )
  2376.  
  2377.     if args.smoke_test_write:
  2378.         session.smoke_test_hostlist(commit=args.commit,
  2379.                                     skip_discovery=args.skip_discovery)
  2380.         sys.exit(0)
  2381.  
  2382.     if args.register_self:
  2383.         session.register_self(commit=args.commit, skip_discovery=args.skip_discovery,
  2384.                               host_mac=args.host_mac)
  2385.         sys.exit(0)
  2386.  
  2387.     if args.restore_hostlist:
  2388.         session.restore_hostlist(args.restore_hostlist, commit=args.commit,
  2389.                                  skip_discovery=args.skip_discovery)
  2390.         sys.exit(0)
  2391.  
  2392.     if args.sweep_acq:
  2393.         session.sweep_acquisition()
  2394.         sys.exit(0)
  2395.  
  2396.     if args.preview:
  2397.         raw = session.preview_acquire()
  2398.         sys.exit(0 if raw else 1)
  2399.  
  2400.     if args.backup is not None:
  2401.         lo, hi = 0x00, 0xFF
  2402.         if args.backup_range:
  2403.             try:
  2404.                 a, b = args.backup_range.replace(" ", "").split("-")
  2405.                 lo, hi = int(a, 0), int(b, 0)
  2406.             except ValueError:
  2407.                 print(f"Bad --backup-range '{args.backup_range}' (use e.g. 0x40-0xA0)")
  2408.                 sys.exit(2)
  2409.         session.backup_detector_data(out_dir=args.backup, lo=lo, hi=hi,
  2410.                                      skip_discovery=args.skip_discovery)
  2411.         sys.exit(0)
  2412.  
  2413.     if args.probe_data:
  2414.         session.probe_detector_data(skip_discovery=args.skip_discovery)
  2415.         sys.exit(0)
  2416.  
  2417.     if args.sensors:
  2418.         ok = session.read_sensors(skip_discovery=args.skip_discovery,
  2419.                                   roe_init=not args.no_roe_init)
  2420.         sys.exit(0 if ok else 1)
  2421.  
  2422.     image_data = session.run_full_acquisition(
  2423.         output_dir=args.output_dir,
  2424.         do_dark=args.dark,
  2425.         dark_only=args.dark_only,
  2426.         skip_discovery=args.skip_discovery,
  2427.         skip_standby=args.no_standby,
  2428.         two_exec=args.two_exec,
  2429.         exec_timeout=args.exec_timeout,
  2430.     )
  2431.  
  2432.     if image_data:
  2433.         print(f"\nAcquisition complete: {len(image_data)} bytes received")
  2434.         sys.exit(0)
  2435.     else:
  2436.         print("\nAcquisition failed -- see log above")
  2437.         sys.exit(1)
  2438.  
  2439.  
  2440. if __name__ == "__main__":
  2441.     main()
  2442.  
Advertisement
Comments
  • User was banned
  • User was banned
Add Comment
Please, Sign In to add comment