Guest User

Untitled

a guest
Nov 2nd, 2026
3
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 53.06 KB | None | 0 0
  1. -- GeorgeBridge.lua -- keyboard/UDP control of the AH-64D George AI (device 87).
  2. --
  3. -- Runs in the DCS Export.lua environment. Listens on 127.0.0.1:7788 for
  4. -- single-line text datagrams and drives George's radial menu by issuing
  5. -- performClickableAction() on device 87.
  6. --
  7. -- The current menu mode is never assumed: it is read back from the live George
  8. -- overlay via list_indication(), so a command works no matter which wheel mode
  9. -- or submenu was last used. The read works with the wheel hidden because
  10. -- PrestonAI_page_common.lua carries an off-screen mirror element,
  11. -- mode_text_bridge, that does not inherit show_compass visibility. No command
  12. -- path presses show/hide, so the wheel stays down as it does under VAICOM.
  13. --
  14. -- Protocol (UDP, one command per datagram, reply sent to the sender):
  15. -- cmd <name> [count] execute a named command, optionally repeating its
  16. -- press `count` times (for the DEFN cycles)
  17. -- mode <NAME> switch the wheel to that mode and stop
  18. -- press <button> [short|long] raw button, e.g. "press down long"
  19. -- list [filter] reply with matching command names
  20. -- state reply with wheel / CMWS / flight state
  21. -- probe dump every list_indication() page to the log file
  22. -- eval <lua expr> evaluate an expression in the export environment
  23. -- cancel abort the running task
  24.  
  25. local GEORGE_DEVICE = 87
  26. local CMWS_DEVICE = 80 -- devices["CMWS"]; driven directly, bypassing George
  27.  
  28. local BTN = {
  29. control = 3001, -- Request control
  30. menu = 3002, -- Show/hide overlay (long press = open DEFN submenu)
  31. up = 3003,
  32. down = 3004,
  33. left = 3005, -- short press = cycle wheel mode
  34. right = 3006,
  35. mf = 3008, -- Multifunction / centre
  36. store = 3009,
  37. }
  38.  
  39. -- CMWS_commands (command_defs.lua, base 3000).
  40. local CMWS = {
  41. pwr = 3001, -- PWR switch OFF/ON, arg 610
  42. arm_safe = 3005, -- flare squibs ARM/SAFE, arg 614
  43. byp_auto = 3007, -- operation BYPASS/AUTO, arg 616
  44. }
  45.  
  46. local HOST, PORT = "127.0.0.1", 7788
  47.  
  48. -- Timing. DCS treats >0.5 s as a long press (PrestonAI.lua: long_press_time).
  49. local SHORT_HOLD = 70
  50. local LONG_HOLD = 900
  51. local POST_PRESS = 170 -- settle time after releasing a button
  52. local MODE_SETTLE = 330 -- extra settle after a mode change before re-reading
  53. local MAX_MODE_STEPS = 8 -- bound on the mode-seek loop
  54.  
  55. -- Cockpit argument ids (AH-64D clickabledata.lua).
  56. local ARG_APU = 406 -- APU, 0 off / 1 on
  57. local ARG_CMWS_PWR = 610 -- CMWS PWR switch, OFF/ON/TEST
  58. local ARG_CMWS_ARM_SAFE = 614 -- CMWS flare squibs, ARM/SAFE
  59. local ARG_CMWS_BYP_AUTO = 616 -- CMWS operation, BYPASS/AUTO
  60.  
  61. local AIRBORNE_AGL = 1.5 -- metres; above this we treat the aircraft as flying
  62.  
  63. -- Wheel mode labels. Keys are the normalised text of the overlay's mode_text
  64. -- element; values are our canonical name. Extend after running "probe".
  65. local MODE_ALIAS = {
  66. GND = "GND", GROUND = "GND",
  67. HOV = "HOV", HOVER = "HOV", HB = "HOV", HBOBUP = "HOV", BOBUP = "HOV",
  68. FLT = "FLT", FLIGHT = "FLT", FLGT = "FLT",
  69. CMBT = "CMBT", COMBAT = "CMBT", CBT = "CMBT", COMB = "CMBT",
  70. DEFN = "DEFN", DEF = "DEFN", DEFENSE = "DEFN", DEFENCE = "DEFN",
  71. }
  72.  
  73. -- ---------------------------------------------------------------- logging
  74.  
  75. local lfs_ok, lfs = pcall(require, "lfs")
  76. local LOG_PATH = lfs_ok and (lfs.writedir() .. "Logs\\GeorgeBridge.log") or nil
  77. local logfile = LOG_PATH and io.open(LOG_PATH, "w") or nil
  78.  
  79. local function Log(fmt, ...)
  80. local msg = select("#", ...) > 0 and string.format(fmt, ...) or tostring(fmt)
  81. if logfile then
  82. logfile:write(os.date("%H:%M:%S "), msg, "\n")
  83. logfile:flush()
  84. end
  85. if log and log.write then log.write("GEORGE_BRIDGE", log.INFO, msg) end
  86. end
  87.  
  88. -- ---------------------------------------------------------------- socket
  89.  
  90. local socket_ok, socket = pcall(require, "socket")
  91. local sock, reply_to = nil, nil
  92.  
  93. local function OpenSocket()
  94. if not socket_ok then
  95. Log("luasocket unavailable; bridge disabled")
  96. return
  97. end
  98. local s = socket.udp()
  99. s:setoption("reuseaddr", true)
  100. local ok, err = s:setsockname(HOST, PORT)
  101. if not ok then
  102. Log("cannot bind %s:%d (%s)", HOST, PORT, tostring(err))
  103. return
  104. end
  105. s:settimeout(0)
  106. sock = s
  107. Log("listening on %s:%d", HOST, PORT)
  108. end
  109.  
  110. local function Reply(fmt, ...)
  111. local msg = select("#", ...) > 0 and string.format(fmt, ...) or tostring(fmt)
  112. Log("-> %s", msg)
  113. if sock and reply_to then
  114. pcall(function() sock:sendto(msg, reply_to.ip, reply_to.port) end)
  115. end
  116. end
  117.  
  118. -- ---------------------------------------------------------------- reading
  119.  
  120. -- list_indication() returns dashed blocks of "name\ntext". Parse to a table.
  121. local function ParseIndication(str)
  122. if type(str) ~= "string" or #str == 0 then return nil end
  123. local t, name, value = {}, nil, nil
  124. local function flush()
  125. if name then t[name] = value or "" end
  126. name, value = nil, nil
  127. end
  128. for line in ("\n" .. str):gmatch("\n([^\n]*)") do
  129. if line:match("^%-%-%-%-") then
  130. flush()
  131. elseif name == nil then
  132. if line ~= "" then name = line end
  133. else
  134. value = value and (value .. "\n" .. line) or line
  135. end
  136. end
  137. flush()
  138. if next(t) == nil then return nil end
  139. return t
  140. end
  141.  
  142. local indicator_index = nil
  143.  
  144. local function ReadOverlay()
  145. if not list_indication then return nil end
  146. if indicator_index then
  147. local ok, raw = pcall(list_indication, indicator_index)
  148. if not ok then return nil end
  149. local t = ParseIndication(raw)
  150. if t and (t.mode_text_bridge ~= nil or t.mode_text ~= nil) then return t end
  151. return nil
  152. end
  153. for i = 0, 40 do
  154. local ok, raw = pcall(list_indication, i)
  155. if ok and type(raw) == "string"
  156. and (raw:find("mode_text_bridge", 1, true) or raw:find("mode_text", 1, true)) then
  157. indicator_index = i
  158. Log("George overlay found at list_indication(%d)", i)
  159. return ParseIndication(raw)
  160. end
  161. end
  162. return nil
  163. end
  164.  
  165. local function NormaliseMode(text)
  166. if type(text) ~= "string" then return nil end
  167. local key = text:upper():gsub("[^A-Z0-9]", "")
  168. if key == "" then return nil end
  169. return MODE_ALIAS[key] or key
  170. end
  171.  
  172. -- mode_text_bridge is the off-screen mirror; it reports whether or not the
  173. -- wheel is drawn. mode_text is the unpatched fallback, readable only while the
  174. -- wheel is visible.
  175. local function CurrentMode()
  176. local overlay = ReadOverlay()
  177. if not overlay then return nil, false end
  178. local raw = overlay.mode_text_bridge
  179. local mirrored = raw ~= nil
  180. if raw == nil or raw == "" then raw = overlay.mode_text end
  181. return NormaliseMode(raw), mirrored
  182. end
  183.  
  184. -- mode_text (unmirrored) only appears while the wheel is drawn, so its
  185. -- presence is an exact visibility test.
  186. local function WheelDrawn()
  187. local overlay = ReadOverlay()
  188. return overlay ~= nil and overlay.mode_text ~= nil
  189. end
  190.  
  191. local function Arg(id)
  192. if not GetDevice then return nil end
  193. local ok, dev = pcall(GetDevice, 0)
  194. if not ok or not dev then return nil end
  195. local okv, v = pcall(function() return dev:get_argument_value(id) end)
  196. if okv then return v end
  197. return nil
  198. end
  199.  
  200. local function AGL()
  201. if not LoGetAltitudeAboveGroundLevel then return nil end
  202. local ok, v = pcall(LoGetAltitudeAboveGroundLevel)
  203. if ok and type(v) == "number" then return v end
  204. return nil
  205. end
  206.  
  207. local function Airborne()
  208. local agl = AGL()
  209. if agl == nil then return nil end
  210. return agl > AIRBORNE_AGL
  211. end
  212.  
  213. local function IsApache()
  214. if not LoGetSelfData then return true end
  215. local ok, self_data = pcall(LoGetSelfData)
  216. if not ok or not self_data or not self_data.Name then return true end
  217. return self_data.Name:find("AH%-64D") ~= nil
  218. end
  219.  
  220. local function Now()
  221. if LoGetModelTime then
  222. local ok, t = pcall(LoGetModelTime)
  223. if ok and type(t) == "number" then return t end
  224. end
  225. return os.clock()
  226. end
  227.  
  228. -- ---------------------------------------------------------------- commands
  229. --
  230. -- Transcribed from VAICOM-Community
  231. -- VAICOM/Extensions/AICPG/CPGCommandHandler.cs. Two groups:
  232. --
  233. -- PILOT group -- what you command from the CP/G seat, where George flies.
  234. -- Each entry names the wheel mode it lives in; the bridge
  235. -- cycles to that mode first, reading back as it goes.
  236. -- CPG group -- what you command from the PILOT seat, where George runs
  237. -- the TADS and weapons. These have no mode gating at all,
  238. -- so they are plain presses.
  239. --
  240. -- press: "short", "long" (held 900 ms, past DCS's 0.5 s threshold), "hold"
  241. -- (long plus hold_ms, VAICOM's AddGeorgeLongHoldButton), or "action" (a single
  242. -- value 1.0 with no release, VAICOM's AddGeorgeAction).
  243.  
  244. local function M(desc, mode, button, press, opts)
  245. local c = opts or {}
  246. c.desc, c.mode, c.button, c.press = desc, mode, button, press
  247. return c
  248. end
  249.  
  250. local function R(desc, button, press, opts)
  251. local c = opts or {}
  252. c.desc, c.button, c.press = desc, button, press
  253. return c
  254. end
  255.  
  256. local function D(desc, button, press, opts)
  257. local c = opts or {}
  258. c.desc, c.button, c.press, c.defn = desc, button, press, true
  259. return c
  260. end
  261.  
  262. local function PANEL(desc, command, value, arg)
  263. return { desc = desc,
  264. set = { device = CMWS_DEVICE, command = command, value = value, arg = arg } }
  265. end
  266.  
  267. local function SEQ(desc, steps) return { desc = desc, sequence = steps } end
  268.  
  269. local COMMANDS = {
  270.  
  271. -- ============================================================ raw wheel
  272. -- Either seat. No mode handling, exactly as pressing the hat yourself.
  273. show_hide = R("Show/hide the George overlay", BTN.menu, "short"),
  274. up = R("Wheel up", BTN.up, "short"),
  275. down = R("Wheel down", BTN.down, "short"),
  276. left = R("Wheel left (cycles mode)", BTN.left, "short"),
  277. right = R("Wheel right", BTN.right, "short"),
  278. center = R("Wheel select (centre)", BTN.mf, "short"),
  279. up_long = R("Wheel up, held", BTN.up, "long"),
  280. down_long = R("Wheel down, held", BTN.down, "long"),
  281. left_long = R("Wheel left, held", BTN.left, "long"),
  282. right_long = R("Wheel right, held", BTN.right, "long"),
  283. center_long = R("Wheel select, held", BTN.mf, "long"),
  284. control_request = R("Request control", BTN.control, "action"),
  285. store_target = R("Store target", BTN.store, "action"),
  286.  
  287. -- ========================================================= menu modes
  288. mode_ground = { desc = "Select GND mode", seek = "GND" },
  289. mode_hover = { desc = "Select H-B mode", seek = "HOV" },
  290. mode_flight = { desc = "Select FLT mode", seek = "FLT" },
  291. mode_combat = { desc = "Select CMBT mode", seek = "CMBT" },
  292. mode_next = R("Step to the next wheel mode", BTN.left, "short"),
  293. mode_defense = { desc = "Open the DEFN submenu", defn_open = true },
  294.  
  295. -- ===================================================== PILOT: GND menu
  296. apu_start = M("Start the APU", "GND", BTN.up, "short",
  297. { skip_if = { arg = ARG_APU, equals = 1 }, verify = { arg = ARG_APU, want = 1 } }),
  298. apu_stop = M("Stop the APU", "GND", BTN.up, "short",
  299. { skip_if = { arg = ARG_APU, equals = 0 }, verify = { arg = ARG_APU, want = 0 } }),
  300. apu_only = M("APU only", "GND", BTN.down, "short"),
  301. shutdown_engines = M("Shut down the engines", "GND", BTN.down, "short"),
  302. start_engines_idle = M("Start engines to idle", "GND", BTN.right, "short"),
  303. start_engines_fly = M("Start engines to fly", "GND", BTN.up, "short"),
  304. startup_full = M("Full start-up", "GND", BTN.up, "long"),
  305. shutdown_full = M("Full shutdown", "GND", BTN.down, "long"),
  306. -- GND long-Right toggles CMWS power. The silent cmws_off / cmws_on below
  307. -- do the same thing directly and absolutely; these ask George.
  308. cmws_power_toggle_george = M("Toggle CMWS power via George", "GND", BTN.right, "long",
  309. { verify = { arg = ARG_CMWS_PWR } }),
  310. takeoff = M("Take off", "GND", BTN.mf, "short",
  311. { modes = { "GND", "HOV", "DEFN" }, require_ground = true }),
  312.  
  313. -- ===================================================== PILOT: FLT menu
  314. speed_up = M("Speed up", "FLT", BTN.up, "short"),
  315. slow_down = M("Slow down", "FLT", BTN.down, "short"),
  316. follow_waypoints = M("Follow waypoints", "FLT", BTN.right, "short"),
  317. set_airspeed_ref = M("Set airspeed reference", "FLT", BTN.mf, "short"),
  318. set_groundspeed_ref = M("Set groundspeed reference", "FLT", BTN.mf, "short"),
  319. increase_altitude = M("Increase altitude", "FLT", BTN.up, "hold", { hold_ms = 1000 }),
  320. decrease_altitude = M("Decrease altitude", "FLT", BTN.down, "hold", { hold_ms = 1000 }),
  321. come_left = M("Come left", "FLT", BTN.left, "hold", { hold_ms = 1000 }),
  322. come_right = M("Come right", "FLT", BTN.right, "hold", { hold_ms = 1000 }),
  323. set_radar_altitude = M("Set radar altitude hold", "FLT", BTN.mf, "long"),
  324. set_barometric_altitude = M("Set barometric altitude hold", "FLT", BTN.mf, "long"),
  325.  
  326. -- ==================================================== PILOT: CMBT menu
  327. align_to_tads = M("Align to TADS", "CMBT", BTN.up, "short"),
  328. align_to_nts = M("Align to NTS", "CMBT", BTN.up, "short"),
  329. hold_position = M("Hold position", "CMBT", BTN.down, "short"),
  330. return_to_battle_position = M("Return to battle position", "CMBT", BTN.down, "short"),
  331. turn_to_ghs = M("Turn to GHS", "CMBT", BTN.right, "short"),
  332. orbit_overhead = M("Orbit overhead", "CMBT", BTN.up, "long"),
  333. break180 = M("Break 180", "CMBT", BTN.down, "long"),
  334. break_left = M("Break left", "CMBT", BTN.left, "long"),
  335. break_right = M("Break right", "CMBT", BTN.right, "long"),
  336. -- Add/delete share one long MF press; VAICOM presses it twice to replace an
  337. -- existing battle position, which needs state we do not read. One press.
  338. battle_position_toggle = M("Add or delete a battle position", "CMBT", BTN.mf, "long"),
  339. mask_position = M("Mask position", "CMBT", BTN.mf, "short",
  340. { modes = { "CMBT", "HOV", "DEFN" }, require_airborne = true }),
  341.  
  342. -- ===================================================== PILOT: H-B menu
  343. hover_up_10ft = M("Come up 10 feet", "HOV", BTN.up, "short"),
  344. hover_down_10ft = M("Come down 10 feet", "HOV", BTN.down, "short"),
  345. hover_forward = M("Move forwards", "HOV", BTN.up, "hold", { hold_ms = 1000 }),
  346. hover_back = M("Move back", "HOV", BTN.down, "hold", { hold_ms = 1000 }),
  347. hover_left = M("Move left", "HOV", BTN.left, "hold", { hold_ms = 1000 }),
  348. hover_right = M("Move right", "HOV", BTN.right, "hold", { hold_ms = 1000 }),
  349.  
  350. -- ==================================================== PILOT: DEFN menu
  351. -- Toggles, so each reads its cockpit argument first and presses nothing if
  352. -- the switch is already where it should be.
  353. cmws_safe_george = D("CMWS safe, via George", BTN.up, "short",
  354. { arg = ARG_CMWS_ARM_SAFE, want = 0 }),
  355. cmws_arm_george = D("CMWS arm, via George", BTN.up, "short",
  356. { arg = ARG_CMWS_ARM_SAFE, want = 1 }),
  357. cmws_bypass_george = D("CMWS bypass, via George", BTN.down, "short",
  358. { arg = ARG_CMWS_BYP_AUTO, want = 0 }),
  359. cmws_auto_george = D("CMWS auto, via George", BTN.down, "short",
  360. { arg = ARG_CMWS_BYP_AUTO, want = 1 }),
  361. threat_warnings_toggle = D("Toggle threat warnings", BTN.down, "long"),
  362. -- The remaining DEFN items are cycles with no cockpit argument behind them,
  363. -- so there is nothing to read back and absolute selection is not possible
  364. -- yet. Each press advances one step; pass a count, e.g. "cmd evade_next 3".
  365. roe_next = D("Next rules of engagement (hold / return / free)", BTN.up, "long"),
  366. evade_next = D("Next evade mode (off / level / vertical / mask)", BTN.left, "long"),
  367. dispense_next = D("Next dispense mode (none / chaff / flares / both)", BTN.right, "long"),
  368. extlights_next = D("Next exterior light setting", BTN.right, "short"),
  369.  
  370. -- ============================================== CMWS panel, direct
  371. -- Not George at all: absolute switch positions on device 80. Idempotent,
  372. -- silent, seat-independent, confirmed from the cockpit argument.
  373. cmws_safe = PANEL("CMWS flare squibs SAFE", CMWS.arm_safe, 0.0, ARG_CMWS_ARM_SAFE),
  374. cmws_arm = PANEL("CMWS flare squibs ARM", CMWS.arm_safe, 1.0, ARG_CMWS_ARM_SAFE),
  375. cmws_off = PANEL("CMWS power OFF", CMWS.pwr, 0.0, ARG_CMWS_PWR),
  376. cmws_on = PANEL("CMWS power ON", CMWS.pwr, 1.0, ARG_CMWS_PWR),
  377. cmws_bypass = PANEL("CMWS BYPASS", CMWS.byp_auto, 0.0, ARG_CMWS_BYP_AUTO),
  378. cmws_auto = PANEL("CMWS AUTO", CMWS.byp_auto, 1.0, ARG_CMWS_BYP_AUTO),
  379.  
  380. -- ================================================== CPG: George as CP/G
  381. -- From the PILOT seat. No mode gating; the CP/G wheel has none.
  382. cpg_startup = R("George start-up", BTN.mf, "long"),
  383. cpg_shutdown = R("George shutdown", BTN.mf, "long"),
  384. cpg_area_search = R("Area search", BTN.mf, "long"),
  385. cpg_point_search = R("Point search", BTN.mf, "short"),
  386. cpg_phs_search = R("PHS search", BTN.up, "short"),
  387. cpg_tads_los = R("TADS line of sight", BTN.down, "long"),
  388. cpg_area_select = R("Area select", BTN.left, "long"),
  389. cpg_point_select = R("Point select", BTN.right, "long"),
  390. cpg_tads_sensor = R("Cycle TADS sensor", BTN.mf, "long"),
  391. cpg_tads_fov = R("Cycle TADS field of view", BTN.mf, "short"),
  392. cpg_tads_zoom_in = R("TADS zoom in", BTN.up, "long"),
  393. cpg_tads_zoom_out = R("TADS zoom out", BTN.down, "long"),
  394. cpg_adjust_aim = R("Adjust aim", BTN.mf, "long"),
  395. cpg_next_target = R("Next target", BTN.down, "short"),
  396. cpg_previous_target = R("Previous target", BTN.up, "short"),
  397. cpg_next_item = R("Next list item", BTN.down, "short"),
  398. cpg_previous_item = R("Previous list item", BTN.up, "short"),
  399. cpg_list_item_select = R("Select list item", BTN.right, "short"),
  400. cpg_exit_list = R("Exit list", BTN.left, "short"),
  401. cpg_target_list_zoom_in = R("Target list zoom in", BTN.up, "long"),
  402. cpg_target_list_zoom_out = R("Target list zoom out", BTN.down, "long"),
  403. cpg_target_list_filter = R("Target list filter", BTN.left, "long"),
  404. cpg_point_list_filter_mode = R("Point list filter mode", BTN.left, "long"),
  405. cpg_point_list_filter_threat = R("Point list filter threat", BTN.right, "long"),
  406. cpg_select_target = R("Select target", BTN.mf, "short"),
  407. cpg_track_target = R("Track target", BTN.right, "short"),
  408. cpg_last_found_target = R("Last found target", BTN.down, "long"),
  409. cpg_last_stored_target = R("Last stored target", BTN.mf, "short"),
  410. cpg_lase_target = R("Lase target", BTN.right, "short"),
  411. cpg_laser_on = R("Laser on", BTN.right, "short"),
  412. cpg_laser_off = R("Laser off", BTN.right, "short"),
  413. cpg_cleared_fire = R("Cleared to fire", BTN.mf, "short"),
  414. cpg_weapons_free = R("Weapons free", BTN.up, "long"),
  415. cpg_hold_fire = R("Hold fire", BTN.up, "long"),
  416. cpg_next_weapon = R("Next weapon", BTN.left, "short"),
  417. cpg_next_missile = R("Next missile type", BTN.left, "long"),
  418. cpg_next_rocket = R("Next rocket type", BTN.left, "long"),
  419. cpg_missile_traj = R("Missile trajectory", BTN.right, "long"),
  420. cpg_burst_limit = R("Gun burst limit", BTN.right, "short"),
  421. cpg_rocket_quantity = R("Rocket quantity", BTN.right, "short"),
  422. cpg_lobl = R("LOBL", BTN.right, "short"),
  423. cpg_loal = R("LOAL", BTN.right, "short"),
  424.  
  425. -- CP/G macros, button-for-button from CPGCommandHandler.cs. Each step is
  426. -- {button, press, post-delay ms}; the trailing menu press hides the list.
  427. cpg_macro_next_search = SEQ("Next area search", {
  428. { BTN.left, "long", 120 }, { BTN.down, "short", 80 },
  429. { BTN.right, "short", 80 }, { BTN.menu, "short", 0 } }),
  430. cpg_macro_previous_search = SEQ("Previous area search", {
  431. { BTN.left, "long", 200 }, { BTN.up, "short", 150 },
  432. { BTN.right, "short", 80 }, { BTN.menu, "short", 0 } }),
  433. cpg_macro_next_point = SEQ("Next point search", {
  434. { BTN.right, "long", 200 }, { BTN.down, "short", 150 },
  435. { BTN.right, "short", 80 }, { BTN.menu, "short", 0 } }),
  436. cpg_macro_previous_point = SEQ("Previous point search", {
  437. { BTN.right, "long", 200 }, { BTN.up, "short", 150 },
  438. { BTN.right, "short", 80 }, { BTN.menu, "short", 0 } }),
  439. cpg_macro_track_engage = SEQ("Track and engage current target", {
  440. { BTN.right, "short", 100 }, { BTN.mf, "short", 100 } }),
  441. cpg_macro_add_two_targets = SEQ("Add and track the top 2 targets", {
  442. { BTN.mf, "short", 100 }, { BTN.down, "short", 100 },
  443. { BTN.mf, "short", 100 }, { BTN.right, "short", 0 } }),
  444. cpg_macro_add_three_targets = SEQ("Add and track the top 3 targets", {
  445. { BTN.mf, "short", 100 }, { BTN.down, "short", 100 },
  446. { BTN.mf, "short", 100 }, { BTN.down, "short", 100 },
  447. { BTN.mf, "short", 100 }, { BTN.right, "short", 0 } }),
  448. cpg_macro_add_four_targets = SEQ("Add and track the top 4 targets", {
  449. { BTN.mf, "short", 100 }, { BTN.down, "short", 100 },
  450. { BTN.mf, "short", 100 }, { BTN.down, "short", 100 },
  451. { BTN.mf, "short", 100 }, { BTN.down, "short", 100 },
  452. { BTN.mf, "short", 100 }, { BTN.right, "short", 0 } }),
  453. }
  454.  
  455. local BUTTON_BY_NAME = {
  456. control = BTN.control, menu = BTN.menu, up = BTN.up, down = BTN.down,
  457. left = BTN.left, right = BTN.right, mf = BTN.mf, centre = BTN.mf,
  458. center = BTN.mf, store = BTN.store,
  459. }
  460.  
  461. -- ---------------------------------------------------------------- task runner
  462.  
  463. -- Commands run as a coroutine so they can read state between button presses.
  464. -- Yielding a number parks the task for that many milliseconds.
  465. local task, wake_at, task_name = nil, 0, nil
  466.  
  467. local function Wait(ms) coroutine.yield(ms) end
  468.  
  469. local function RawPress(device, command, value)
  470. local ok, dev = pcall(GetDevice, device)
  471. if not ok or not dev or not dev.performClickableAction then
  472. return false, string.format("device %d unavailable", device)
  473. end
  474. local done, err = pcall(function() dev:performClickableAction(command, value) end)
  475. if not done then return false, tostring(err) end
  476. return true
  477. end
  478.  
  479. local function Press(button, kind, hold_ms)
  480. if kind == "action" then
  481. local ok, err = RawPress(GEORGE_DEVICE, button, 1.0)
  482. if not ok then error("action failed: " .. tostring(err), 0) end
  483. Wait(POST_PRESS)
  484. return
  485. end
  486. local hold = SHORT_HOLD
  487. if kind == "long" then hold = LONG_HOLD
  488. elseif kind == "hold" then hold = LONG_HOLD + (hold_ms or 1000) end
  489.  
  490. local ok, err = RawPress(GEORGE_DEVICE, button, 1.0)
  491. if not ok then error("press failed: " .. tostring(err), 0) end
  492. Wait(hold)
  493. RawPress(GEORGE_DEVICE, button, 0.0)
  494. Wait(POST_PRESS)
  495. end
  496.  
  497. -- Cycle the wheel with short Left presses until the mode reports the target.
  498. -- Reading back after every press means no assumption about mode ordering, about
  499. -- which modes the current flight state offers, or about whether the DEFN
  500. -- submenu was left open. Nothing here presses the menu button, so the wheel is
  501. -- never raised: George processes hat commands whether or not it is drawn.
  502. local function SeekMode(target)
  503. for _ = 1, MAX_MODE_STEPS do
  504. local mode = CurrentMode()
  505. if mode == target then return true, mode end
  506. Press(BTN.left, "short")
  507. Wait(MODE_SETTLE)
  508. end
  509. return false, CurrentMode()
  510. end
  511.  
  512. local function InList(value, list)
  513. if not list then return false end
  514. for _, v in ipairs(list) do if v == value then return true end end
  515. return false
  516. end
  517.  
  518. -- Absolute panel switch, straight to the owning device. No George, no menu.
  519. local function RunSetCommand(name, set)
  520. local before = Arg(set.arg)
  521. local ok, err = RawPress(set.device, set.command, set.value)
  522. if not ok then
  523. Reply("error %s: %s", name, tostring(err))
  524. return
  525. end
  526. Wait(400)
  527. local after = Arg(set.arg)
  528. Reply("ok %s device=%d command=%d value=%s arg%d %s->%s",
  529. name, set.device, set.command, tostring(set.value), set.arg,
  530. tostring(before), tostring(after))
  531. end
  532.  
  533. -- DEFN submenu: long-press the menu button to open it, press the item, short
  534. -- press to close. The menu button is also show/hide, so opening DEFN may leave
  535. -- the wheel drawn; we note whether it was drawn beforehand and put that back.
  536. local function RunDefnCommand(name, spec, count)
  537. local before = spec.arg and Arg(spec.arg) or nil
  538. if before ~= nil and spec.want ~= nil and before == spec.want then
  539. Reply("ok %s: already arg%d=%s, George not asked", name, spec.arg, tostring(before))
  540. return
  541. end
  542.  
  543. local drawn_before = WheelDrawn()
  544.  
  545. Press(BTN.menu, "long")
  546. Wait(MODE_SETTLE)
  547. local mode = CurrentMode()
  548. if mode ~= "DEFN" then
  549. Log("warning: %s expected DEFN after long menu press, mode=%s", name, tostring(mode))
  550. end
  551.  
  552. for _ = 1, count do
  553. Press(spec.button, spec.press, spec.hold_ms)
  554. Wait(120)
  555. end
  556. Wait(MODE_SETTLE)
  557.  
  558. Press(BTN.menu, "short")
  559. Wait(MODE_SETTLE)
  560.  
  561. -- Restore the overlay to the visibility we found it in.
  562. local restored = true
  563. if WheelDrawn() ~= drawn_before then
  564. Press(BTN.menu, "short")
  565. Wait(MODE_SETTLE)
  566. restored = (WheelDrawn() == drawn_before)
  567. end
  568.  
  569. Wait(400)
  570. if spec.arg then
  571. Reply("ok %s via DEFN x%d arg%d %s->%s mode=%s wheel_drawn=%s%s",
  572. name, count, spec.arg, tostring(before), tostring(Arg(spec.arg)),
  573. tostring(CurrentMode()), tostring(WheelDrawn()),
  574. restored and "" or " (could not restore wheel visibility)")
  575. else
  576. Reply("ok %s via DEFN x%d mode=%s wheel_drawn=%s%s",
  577. name, count, tostring(CurrentMode()), tostring(WheelDrawn()),
  578. restored and "" or " (could not restore wheel visibility)")
  579. end
  580. end
  581.  
  582. local function RunSequence(name, steps)
  583. for _, step in ipairs(steps) do
  584. Press(step[1], step[2])
  585. if step[3] and step[3] > 0 then Wait(step[3]) end
  586. end
  587. Reply("ok %s (%d steps)", name, #steps)
  588. end
  589.  
  590. local function RunNamedCommand(name, spec, count)
  591. if not IsApache() then
  592. Reply("error %s: not flying an AH-64D", name)
  593. return
  594. end
  595.  
  596. if spec.set then return RunSetCommand(name, spec.set) end
  597. if spec.sequence then return RunSequence(name, spec.sequence) end
  598.  
  599. if spec.skip_if then
  600. local v = Arg(spec.skip_if.arg)
  601. if v ~= nil and v == spec.skip_if.equals then
  602. Reply("ok %s: already arg%d=%s, nothing sent", name, spec.skip_if.arg, tostring(v))
  603. return
  604. end
  605. end
  606.  
  607. if spec.require_airborne or spec.require_ground then
  608. local flying = Airborne()
  609. if flying == nil then
  610. Log("warning: %s could not read AGL, gate skipped", name)
  611. elseif spec.require_airborne and not flying then
  612. Reply("error %s: needs to be airborne (agl=%.1f m)", name, AGL() or 0)
  613. return
  614. elseif spec.require_ground and flying then
  615. Reply("error %s: needs to be on the ground (agl=%.1f m)", name, AGL() or 0)
  616. return
  617. end
  618. end
  619.  
  620. if spec.defn_open then
  621. Press(BTN.menu, "long")
  622. Wait(MODE_SETTLE)
  623. Reply("ok mode_defense mode=%s wheel_drawn=%s", tostring(CurrentMode()), tostring(WheelDrawn()))
  624. return
  625. end
  626.  
  627. if spec.defn then return RunDefnCommand(name, spec, count) end
  628.  
  629. local mode, mirrored = CurrentMode()
  630. if spec.seek or spec.mode then
  631. if mode == nil then
  632. Reply("error %s: menu mode unreadable -- %s", name,
  633. mirrored and "mode_text_bridge is empty"
  634. or "mode_text_bridge missing, is PrestonAI_page_common.lua patched?")
  635. return
  636. end
  637. end
  638.  
  639. if spec.seek then
  640. local reached, now_mode = SeekMode(spec.seek)
  641. if reached then Reply("ok %s mode=%s", name, spec.seek)
  642. else Reply("error %s: mode %s not reachable from %s (now %s)",
  643. name, spec.seek, mode, tostring(now_mode)) end
  644. return
  645. end
  646.  
  647. if spec.mode and not InList(mode, spec.modes) then
  648. local start_mode = mode
  649. local reached, now_mode = SeekMode(spec.mode)
  650. if not reached then
  651. -- Put the wheel back where we found it rather than leaving it
  652. -- parked somewhere the pilot did not ask for.
  653. SeekMode(start_mode)
  654. Reply("error %s: mode %s not reachable from %s (now %s) -- not offered in this flight state",
  655. name, spec.mode, start_mode, tostring(now_mode))
  656. return
  657. end
  658. end
  659.  
  660. for _ = 1, count do
  661. Press(spec.button, spec.press, spec.hold_ms)
  662. if count > 1 then Wait(120) end
  663. end
  664.  
  665. if spec.verify then
  666. Wait(500)
  667. Reply("ok %s mode=%s arg%d=%s%s", name, tostring(CurrentMode()),
  668. spec.verify.arg, tostring(Arg(spec.verify.arg)),
  669. mirrored and "" or " (wheel-visible read only)")
  670. else
  671. Reply("ok %s%s%s", name,
  672. spec.mode and (" mode=" .. spec.mode) or "",
  673. mirrored and "" or " (wheel-visible read only)")
  674. end
  675. end
  676.  
  677. local function StartTask(name, fn)
  678. if task then
  679. Reply("busy %s", tostring(task_name))
  680. return
  681. end
  682. task_name = name
  683. task = coroutine.create(fn)
  684. wake_at = 0
  685. end
  686.  
  687. local function PumpTask()
  688. if not task then return end
  689. if Now() < wake_at then return end
  690. local ok, result = coroutine.resume(task)
  691. if not ok then
  692. Reply("error %s: %s", tostring(task_name), tostring(result))
  693. task, task_name = nil, nil
  694. return
  695. end
  696. if coroutine.status(task) == "dead" then
  697. task, task_name = nil, nil
  698. return
  699. end
  700. wake_at = Now() + (tonumber(result) or 0) / 1000
  701. end
  702.  
  703. -- ---------------------------------------------------------------- handlers
  704.  
  705. local function DoProbe()
  706. if not list_indication then
  707. Reply("error probe: list_indication unavailable")
  708. return
  709. end
  710. Log("---- probe: list_indication(0..40) ----")
  711. local found = {}
  712. for i = 0, 40 do
  713. local ok, raw = pcall(list_indication, i)
  714. if ok and type(raw) == "string" and #raw > 0 then
  715. found[#found + 1] = i
  716. Log("=== list_indication(%d) ===\n%s", i, raw)
  717. end
  718. end
  719. Log("---- args: apu406=%s cmws610=%s cmws614=%s cmws616=%s agl=%s ----",
  720. tostring(Arg(ARG_APU)), tostring(Arg(ARG_CMWS_PWR)),
  721. tostring(Arg(ARG_CMWS_ARM_SAFE)), tostring(Arg(ARG_CMWS_BYP_AUTO)), tostring(AGL()))
  722. Reply("ok probe: non-empty pages [%s] -> %s", table.concat(found, ","), tostring(LOG_PATH))
  723. end
  724.  
  725. local function DoState()
  726. local mode, mirrored = CurrentMode()
  727. Reply('state indicator=%s mode=%s mirror=%s wheel_drawn=%s agl=%s apu=%s cmws_pwr=%s cmws_arm=%s cmws_auto=%s busy=%s',
  728. tostring(indicator_index), tostring(mode), tostring(mirrored),
  729. tostring(WheelDrawn()), tostring(AGL()), tostring(Arg(ARG_APU)),
  730. tostring(Arg(ARG_CMWS_PWR)), tostring(Arg(ARG_CMWS_ARM_SAFE)),
  731. tostring(Arg(ARG_CMWS_BYP_AUTO)), tostring(task_name))
  732. end
  733.  
  734. local function DoList(filter)
  735. local names = {}
  736. for name in pairs(COMMANDS) do
  737. if filter == "" or name:find(filter, 1, true) then names[#names + 1] = name end
  738. end
  739. table.sort(names)
  740. Reply("ok %d command(s): %s", #names, table.concat(names, " "))
  741. end
  742.  
  743. local function HandleLine(line)
  744. line = tostring(line or ""):gsub("^%s+", ""):gsub("%s+$", "")
  745. if line == "" then return end
  746. Log("<- %s", line)
  747.  
  748. local verb, rest = line:match("^(%S+)%s*(.*)$")
  749. verb = verb:lower()
  750.  
  751. if verb == "state" then
  752. DoState()
  753. elseif verb == "probe" then
  754. DoProbe()
  755. elseif verb == "list" then
  756. DoList(rest:lower())
  757. elseif verb == "cancel" then
  758. task, task_name = nil, nil
  759. Reply("ok cancel")
  760. elseif verb == "cmd" then
  761. local name, count_str = rest:lower():match("^(%S+)%s*(%S*)$")
  762. local spec = name and COMMANDS[name]
  763. if not spec then
  764. Reply("error unknown command %q -- try \"list\"", tostring(name))
  765. return
  766. end
  767. local count = tonumber(count_str) or 1
  768. if count < 1 or count > 12 then count = 1 end
  769. StartTask(name, function() RunNamedCommand(name, spec, count) end)
  770. elseif verb == "mode" then
  771. local target = NormaliseMode(rest)
  772. if not target then
  773. Reply("error mode: no target given")
  774. return
  775. end
  776. StartTask("mode " .. target, function()
  777. local reached, mode = SeekMode(target)
  778. if reached then Reply("ok mode %s", target)
  779. else Reply("error mode %s not reachable (now %s)", target, tostring(mode)) end
  780. end)
  781. elseif verb == "eval" then
  782. local fn, err = loadstring("return " .. rest)
  783. if not fn then
  784. Reply("error eval: %s", tostring(err))
  785. return
  786. end
  787. local ok, v = pcall(fn)
  788. Reply(ok and ("ok " .. tostring(v)) or ("error eval: " .. tostring(v)))
  789. elseif verb == "press" then
  790. local btn_name, kind = rest:match("^(%S+)%s*(%S*)$")
  791. local button = btn_name and BUTTON_BY_NAME[btn_name:lower()]
  792. if not button then
  793. Reply("error press: unknown button %q", tostring(btn_name))
  794. return
  795. end
  796. kind = (kind or ""):lower() == "long" and "long" or "short"
  797. StartTask("press " .. btn_name, function()
  798. Press(button, kind)
  799. Reply("ok press %s %s", btn_name, kind)
  800. end)
  801. else
  802. Reply("error unknown verb %q", verb)
  803. end
  804. end
  805.  
  806. local function PumpSocket()
  807. if not sock then return end
  808. for _ = 1, 8 do
  809. local data, ip, port = sock:receivefrom()
  810. if not data then return end
  811. reply_to = { ip = ip, port = port }
  812. local ok, err = pcall(HandleLine, data)
  813. if not ok then Log("handler error: %s", tostring(err)) end
  814. end
  815. end
  816.  
  817. -- ---------------------------------------------------------------- export hooks
  818.  
  819. -- Chain onto whatever Export.lua already registered (WinWing, Tacview, ...).
  820. local prev_start = LuaExportStart
  821. local prev_frame = LuaExportAfterNextFrame
  822. local prev_stop = LuaExportStop
  823.  
  824. LuaExportStart = function()
  825. OpenSocket()
  826. if prev_start then prev_start() end
  827. end
  828.  
  829. LuaExportAfterNextFrame = function()
  830. PumpSocket()
  831. PumpTask()
  832. if prev_frame then prev_frame() end
  833. end
  834.  
  835. LuaExportStop = function()
  836. if sock then pcall(function() sock:close() end) end
  837. sock = nil
  838. if logfile then logfile:close() logfile = nil end
  839. if prev_stop then prev_stop() end
  840. end
  841.  
  842. Log("GeorgeBridge loaded")
  843.  
  844.  
  845.  
  846. #!/usr/bin/env python3
  847. """Keyboard control of the AH-64D George AI, via GeorgeBridge.lua in DCS.
  848.  
  849. The bridge does all the state handling: it reads George's live menu mode out of
  850. the cockpit overlay and cycles the wheel until the command's mode is actually
  851. selected. This program only maps hotkeys to command names and prints what the
  852. bridge reports back.
  853.  
  854. python georgectl.py run start the hotkey listener
  855. python georgectl.py state print George's current menu mode
  856. python georgectl.py probe dump list_indication pages to the DCS log
  857. python georgectl.py cmd break180 fire one command
  858. python georgectl.py send "press left short"
  859. """
  860.  
  861. from __future__ import annotations
  862.  
  863. import argparse
  864. import socket
  865. import sys
  866. import time
  867.  
  868. BRIDGE_ADDR = ("127.0.0.1", 7788)
  869. REPLY_TIMEOUT = 8.0
  870.  
  871. # Hotkey -> bridge command, `keyboard` syntax.
  872. #
  873. # Everything George can be asked to do is listed below, commented out.
  874. # To enable one, uncomment the line and replace the "?" with the key you
  875. # want. A hotkey still containing "?" is skipped with a warning, so a
  876. # half-finished line cannot stop the listener from starting.
  877. #
  878. # Commands prefixed cpg_ are for the PILOT seat, where George runs the
  879. # TADS and weapons. Everything else is for the CP/G seat, where he flies.
  880. HOTKEYS = {
  881. # ---- raw wheel -----------------------------------------------------
  882. # "ctrl+alt+?": "cmd show_hide", # Show/hide the George overlay
  883. # "ctrl+alt+?": "cmd up", # Wheel up
  884. # "ctrl+alt+?": "cmd down", # Wheel down
  885. # "ctrl+alt+?": "cmd left", # Wheel left (cycles mode)
  886. # "ctrl+alt+?": "cmd right", # Wheel right
  887. # "ctrl+alt+?": "cmd center", # Wheel select (centre)
  888. # "ctrl+alt+?": "cmd up_long", # Wheel up, held
  889. # "ctrl+alt+?": "cmd down_long", # Wheel down, held
  890. # "ctrl+alt+?": "cmd left_long", # Wheel left, held
  891. # "ctrl+alt+?": "cmd right_long", # Wheel right, held
  892. # "ctrl+alt+?": "cmd center_long", # Wheel select, held
  893. # "ctrl+alt+?": "cmd control_request", # Request control
  894. # "ctrl+alt+?": "cmd store_target", # Store target
  895.  
  896. # ---- menu modes ----------------------------------------------------
  897. # "ctrl+alt+?": "cmd mode_ground", # Select GND mode
  898. # "ctrl+alt+?": "cmd mode_hover", # Select H-B mode
  899. # "ctrl+alt+?": "cmd mode_flight", # Select FLT mode
  900. # "ctrl+alt+?": "cmd mode_combat", # Select CMBT mode
  901. # "ctrl+alt+?": "cmd mode_next", # Step to the next wheel mode
  902. # "ctrl+alt+?": "cmd mode_defense", # Open the DEFN submenu
  903.  
  904. # ---- PILOT: GND menu -----------------------------------------------
  905. # "ctrl+alt+?": "cmd apu_start", # Start the APU
  906. # "ctrl+alt+?": "cmd apu_stop", # Stop the APU
  907. # "ctrl+alt+?": "cmd apu_only", # APU only
  908. # "ctrl+alt+?": "cmd shutdown_engines", # Shut down the engines
  909. # "ctrl+alt+?": "cmd start_engines_idle", # Start engines to idle
  910. # "ctrl+alt+?": "cmd start_engines_fly", # Start engines to fly
  911. # "ctrl+alt+?": "cmd startup_full", # Full start-up
  912. # "ctrl+alt+?": "cmd shutdown_full", # Full shutdown
  913. # "ctrl+alt+?": "cmd cmws_power_toggle_george", # Toggle CMWS power via George
  914. # "ctrl+alt+?": "cmd takeoff", # Take off
  915.  
  916. # ---- PILOT: FLT menu -----------------------------------------------
  917. # "ctrl+alt+?": "cmd speed_up", # Speed up
  918. # "ctrl+alt+?": "cmd slow_down", # Slow down
  919. # "ctrl+alt+?": "cmd follow_waypoints", # Follow waypoints
  920. # "ctrl+alt+?": "cmd set_airspeed_ref", # Set airspeed reference
  921. # "ctrl+alt+?": "cmd set_groundspeed_ref", # Set groundspeed reference
  922. # "ctrl+alt+?": "cmd increase_altitude", # Increase altitude
  923. # "ctrl+alt+?": "cmd decrease_altitude", # Decrease altitude
  924. # "ctrl+alt+?": "cmd come_left", # Come left
  925. # "ctrl+alt+?": "cmd come_right", # Come right
  926. # "ctrl+alt+?": "cmd set_radar_altitude", # Set radar altitude hold
  927. # "ctrl+alt+?": "cmd set_barometric_altitude", # Set barometric altitude hold
  928.  
  929. # ---- PILOT: CMBT menu ----------------------------------------------
  930. # "ctrl+alt+?": "cmd align_to_tads", # Align to TADS
  931. # "ctrl+alt+?": "cmd align_to_nts", # Align to NTS
  932. # "ctrl+alt+?": "cmd hold_position", # Hold position
  933. # "ctrl+alt+?": "cmd return_to_battle_position", # Return to battle position
  934. # "ctrl+alt+?": "cmd turn_to_ghs", # Turn to GHS
  935. # "ctrl+alt+?": "cmd orbit_overhead", # Orbit overhead
  936. "ctrl+alt+b": "cmd break180", # Break 180
  937. "ctrl+alt+left": "cmd break_left", # Break left
  938. "ctrl+alt+right": "cmd break_right", # Break right
  939. # "ctrl+alt+?": "cmd battle_position_toggle", # Add or delete a battle position
  940. # "ctrl+alt+?": "cmd mask_position", # Mask position
  941.  
  942. # ---- PILOT: H-B menu -----------------------------------------------
  943. # "ctrl+alt+?": "cmd hover_up_10ft", # Come up 10 feet
  944. # "ctrl+alt+?": "cmd hover_down_10ft", # Come down 10 feet
  945. # "ctrl+alt+?": "cmd hover_forward", # Move forwards
  946. # "ctrl+alt+?": "cmd hover_back", # Move back
  947. # "ctrl+alt+?": "cmd hover_left", # Move left
  948. # "ctrl+alt+?": "cmd hover_right", # Move right
  949.  
  950. # ---- PILOT: DEFN menu ----------------------------------------------
  951. # "ctrl+alt+?": "cmd cmws_safe_george", # CMWS safe, via George
  952. # "ctrl+alt+?": "cmd cmws_arm_george", # CMWS arm, via George
  953. # "ctrl+alt+?": "cmd cmws_bypass_george", # CMWS bypass, via George
  954. # "ctrl+alt+?": "cmd cmws_auto_george", # CMWS auto, via George
  955. # "ctrl+alt+?": "cmd threat_warnings_toggle", # Toggle threat warnings
  956. # "ctrl+alt+?": "cmd roe_next", # Next rules of engagement (hold / return / free)
  957. # "ctrl+alt+?": "cmd evade_next", # Next evade mode (off / level / vertical / mask)
  958. # "ctrl+alt+?": "cmd dispense_next", # Next dispense mode (none / chaff / flares / both)
  959. # "ctrl+alt+?": "cmd extlights_next", # Next exterior light setting
  960.  
  961. # ---- CMWS panel, direct --------------------------------------------
  962. "ctrl+alt+c": "cmd cmws_safe", # CMWS flare squibs SAFE
  963. "ctrl+alt+shift+c": "cmd cmws_arm", # CMWS flare squibs ARM
  964. "ctrl+alt+o": "cmd cmws_off", # CMWS power OFF
  965. "ctrl+alt+shift+o": "cmd cmws_on", # CMWS power ON
  966. # "ctrl+alt+?": "cmd cmws_bypass", # CMWS BYPASS
  967. # "ctrl+alt+?": "cmd cmws_auto", # CMWS AUTO
  968.  
  969. # ---- CPG: George as CP/G -------------------------------------------
  970. # "ctrl+alt+?": "cmd cpg_startup", # George start-up
  971. # "ctrl+alt+?": "cmd cpg_shutdown", # George shutdown
  972. # "ctrl+alt+?": "cmd cpg_area_search", # Area search
  973. # "ctrl+alt+?": "cmd cpg_point_search", # Point search
  974. # "ctrl+alt+?": "cmd cpg_phs_search", # PHS search
  975. # "ctrl+alt+?": "cmd cpg_tads_los", # TADS line of sight
  976. # "ctrl+alt+?": "cmd cpg_area_select", # Area select
  977. # "ctrl+alt+?": "cmd cpg_point_select", # Point select
  978. # "ctrl+alt+?": "cmd cpg_tads_sensor", # Cycle TADS sensor
  979. # "ctrl+alt+?": "cmd cpg_tads_fov", # Cycle TADS field of view
  980. # "ctrl+alt+?": "cmd cpg_tads_zoom_in", # TADS zoom in
  981. # "ctrl+alt+?": "cmd cpg_tads_zoom_out", # TADS zoom out
  982. # "ctrl+alt+?": "cmd cpg_adjust_aim", # Adjust aim
  983. # "ctrl+alt+?": "cmd cpg_next_target", # Next target
  984. # "ctrl+alt+?": "cmd cpg_previous_target", # Previous target
  985. # "ctrl+alt+?": "cmd cpg_next_item", # Next list item
  986. # "ctrl+alt+?": "cmd cpg_previous_item", # Previous list item
  987. # "ctrl+alt+?": "cmd cpg_list_item_select", # Select list item
  988. # "ctrl+alt+?": "cmd cpg_exit_list", # Exit list
  989. # "ctrl+alt+?": "cmd cpg_target_list_zoom_in", # Target list zoom in
  990. # "ctrl+alt+?": "cmd cpg_target_list_zoom_out", # Target list zoom out
  991. # "ctrl+alt+?": "cmd cpg_target_list_filter", # Target list filter
  992. # "ctrl+alt+?": "cmd cpg_point_list_filter_mode", # Point list filter mode
  993. # "ctrl+alt+?": "cmd cpg_point_list_filter_threat", # Point list filter threat
  994. # "ctrl+alt+?": "cmd cpg_select_target", # Select target
  995. # "ctrl+alt+?": "cmd cpg_track_target", # Track target
  996. # "ctrl+alt+?": "cmd cpg_last_found_target", # Last found target
  997. # "ctrl+alt+?": "cmd cpg_last_stored_target", # Last stored target
  998. # "ctrl+alt+?": "cmd cpg_lase_target", # Lase target
  999. # "ctrl+alt+?": "cmd cpg_laser_on", # Laser on
  1000. # "ctrl+alt+?": "cmd cpg_laser_off", # Laser off
  1001. # "ctrl+alt+?": "cmd cpg_cleared_fire", # Cleared to fire
  1002. # "ctrl+alt+?": "cmd cpg_weapons_free", # Weapons free
  1003. # "ctrl+alt+?": "cmd cpg_hold_fire", # Hold fire
  1004. # "ctrl+alt+?": "cmd cpg_next_weapon", # Next weapon
  1005. # "ctrl+alt+?": "cmd cpg_next_missile", # Next missile type
  1006. # "ctrl+alt+?": "cmd cpg_next_rocket", # Next rocket type
  1007. # "ctrl+alt+?": "cmd cpg_missile_traj", # Missile trajectory
  1008. # "ctrl+alt+?": "cmd cpg_burst_limit", # Gun burst limit
  1009. # "ctrl+alt+?": "cmd cpg_rocket_quantity", # Rocket quantity
  1010. # "ctrl+alt+?": "cmd cpg_lobl", # LOBL
  1011. # "ctrl+alt+?": "cmd cpg_loal", # LOAL
  1012. # "ctrl+alt+?": "cmd cpg_macro_next_search", # Next area search
  1013. # "ctrl+alt+?": "cmd cpg_macro_previous_search", # Previous area search
  1014. # "ctrl+alt+?": "cmd cpg_macro_next_point", # Next point search
  1015. # "ctrl+alt+?": "cmd cpg_macro_previous_point", # Previous point search
  1016. # "ctrl+alt+?": "cmd cpg_macro_track_engage", # Track and engage current target
  1017. # "ctrl+alt+?": "cmd cpg_macro_add_two_targets", # Add and track the top 2 targets
  1018. # "ctrl+alt+?": "cmd cpg_macro_add_three_targets", # Add and track the top 3 targets
  1019. # "ctrl+alt+?": "cmd cpg_macro_add_four_targets", # Add and track the top 4 targets
  1020.  
  1021. # ---- bridge queries ------------------------------------------------
  1022. "ctrl+alt+d": "state", # print mode / CMWS / flight state
  1023. }
  1024.  
  1025.  
  1026. class Bridge:
  1027. def __init__(self, addr=BRIDGE_ADDR, timeout=REPLY_TIMEOUT):
  1028. self.addr = addr
  1029. self.timeout = timeout
  1030. self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  1031. self.sock.bind(("127.0.0.1", 0))
  1032. self.sock.settimeout(timeout)
  1033.  
  1034. def send(self, line: str) -> str | None:
  1035. """Send one command and wait for the bridge's reply."""
  1036. self.sock.sendto(line.encode("utf-8"), self.addr)
  1037. deadline = time.monotonic() + self.timeout
  1038. while time.monotonic() < deadline:
  1039. try:
  1040. data, _ = self.sock.recvfrom(4096)
  1041. except socket.timeout:
  1042. break
  1043. except ConnectionResetError:
  1044. # Nothing bound on 42087: DCS is not running, or Export.lua
  1045. # did not load the bridge.
  1046. return None
  1047. return data.decode("utf-8", "replace").strip()
  1048. return None
  1049.  
  1050. def close(self):
  1051. self.sock.close()
  1052.  
  1053.  
  1054. def report(line: str, reply: str | None) -> bool:
  1055. if reply is None:
  1056. print(f"{line} -> no reply (is DCS running with the bridge loaded?)")
  1057. return False
  1058. print(f"{line} -> {reply}")
  1059. return not reply.startswith("error")
  1060.  
  1061.  
  1062. def run_hotkeys(bridge: Bridge) -> int:
  1063. try:
  1064. import keyboard
  1065. except ImportError:
  1066. print("The 'keyboard' package is required for hotkeys: pip install keyboard")
  1067. return 1
  1068.  
  1069. def make_handler(command: str):
  1070. def handler():
  1071. report(command, bridge.send(command))
  1072. return handler
  1073.  
  1074. # A line that was uncommented but not given a key would otherwise take the
  1075. # whole listener down, so skip those and say which.
  1076. unfinished = [c for c in HOTKEYS if "?" in c]
  1077. for combo in unfinished:
  1078. print(f" SKIPPED {combo!r} -> {HOTKEYS[combo]}: replace the '?' with a real key")
  1079.  
  1080. for combo, command in HOTKEYS.items():
  1081. if combo in unfinished:
  1082. continue
  1083. try:
  1084. keyboard.add_hotkey(combo, make_handler(command), suppress=False)
  1085. except Exception as exc:
  1086. print(f" FAILED {combo:<20} {command} ({exc})")
  1087. continue
  1088. print(f" {combo:<20} {command}")
  1089.  
  1090. print("\nListening. Ctrl+C to stop.")
  1091. reply = bridge.send("state")
  1092. report("state", reply)
  1093. try:
  1094. keyboard.wait()
  1095. except KeyboardInterrupt:
  1096. pass
  1097. return 0
  1098.  
  1099.  
  1100. def main(argv=None) -> int:
  1101. parser = argparse.ArgumentParser(description=__doc__,
  1102. formatter_class=argparse.RawDescriptionHelpFormatter)
  1103. sub = parser.add_subparsers(dest="action", required=True)
  1104. sub.add_parser("run", help="start the hotkey listener")
  1105. sub.add_parser("state", help="print George's current menu mode")
  1106. sub.add_parser("probe", help="dump list_indication pages to the DCS log")
  1107. p_list = sub.add_parser("list", help="list the bridge's command names")
  1108. p_list.add_argument("filter", nargs="?", default="")
  1109. p_eval = sub.add_parser("eval", help="evaluate a lua expression in the export env")
  1110. p_eval.add_argument("expr")
  1111. p_cmd = sub.add_parser("cmd", help="fire one named George command")
  1112. p_cmd.add_argument("name")
  1113. p_send = sub.add_parser("send", help="send a raw bridge line")
  1114. p_send.add_argument("line")
  1115.  
  1116. args = parser.parse_args(argv)
  1117. bridge = Bridge()
  1118. try:
  1119. if args.action == "run":
  1120. return run_hotkeys(bridge)
  1121. if args.action == "cmd":
  1122. line = f"cmd {args.name}"
  1123. elif args.action == "send":
  1124. line = args.line
  1125. elif args.action == "eval":
  1126. line = f"eval {args.expr}"
  1127. elif args.action == "list":
  1128. line = f"list {args.filter}".strip()
  1129. else:
  1130. line = args.action
  1131. return 0 if report(line, bridge.send(line)) else 1
  1132. finally:
  1133. bridge.close()
  1134.  
  1135.  
  1136. if __name__ == "__main__":
  1137. sys.exit(main())
  1138.  
  1139.  
  1140.  
  1141.  
Add Comment
Please, Sign In to add comment