Guest User

Untitled

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