Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- --[==[
- ================================================================================
- GeorgeBridge.lua - tiny "cockpit device action" executor for DCS AH-64D George
- ================================================================================
- What it does
- Listens on a localhost UDP port for text datagrams sent by george_hotkeys.py
- and performs the requested cockpit-device actions, i.e.
- GetDevice(87):performClickableAction(<command>, <value>)
- This is exactly what VAICOM does for George (device 87 = George AI,
- 3003 = "Up", 1.0 = press, 0.0 = release). VAICOM runs it from inside a
- patched core UI file; here it runs from the normal Export.lua mechanism, so
- NO core DCS file needs to be modified and DCS updates don't wipe it.
- Install
- 1. Copy this file to:
- %USERPROFILE%\Saved Games\DCS\Scripts\GeorgeBridge\GeorgeBridge.lua
- (use "DCS.openbeta" instead of "DCS" if that is your profile folder)
- 2. Add these lines at the END of
- %USERPROFILE%\Saved Games\DCS\Scripts\Export.lua
- (create the file if it doesn't exist):
- local lfs = require('lfs')
- local ok, err = pcall(dofile, lfs.writedir() .. [[Scripts\GeorgeBridge\GeorgeBridge.lua]])
- if not ok then log.write("GEORGE_BRIDGE", log.ERROR, tostring(err)) end
- 3. Start a mission in the AH-64D and look for "GEORGE_BRIDGE" in
- %USERPROFILE%\Saved Games\DCS\Logs\dcs.log
- Datagram format (plain text, no JSON dependency)
- <seat>|<device>,<command>,<value>,<delay_ms>;<device>,<command>,<value>,<delay_ms>;...
- seat pilot | cpg | any seat the *player* must be in (pilot = George is CPG)
- delay_ms wait AFTER this action before running the next one (same meaning as VAICOM)
- Special datagram "diag" logs a status line (socket, device 87, seat) and does nothing else.
- Safety
- * Only loopback is bound.
- * Only devices in CFG.allowed_devices may be driven (George only by default),
- so a stray packet can't flip arbitrary cockpit switches.
- ================================================================================
- ]==]
- package.path = package.path .. ";.\\LuaSocket\\?.lua"
- package.cpath = package.cpath .. ";.\\LuaSocket\\?.dll"
- do
- local LOG_NAME = "GEORGE_BRIDGE"
- local CFG = {
- host = "127.0.0.1",
- port = 42087, -- must match DEFAULT_PORT in george_hotkeys.py
- allowed_devices = { [87] = true }, -- 87 = George AI (as used by VAICOM)
- max_actions_per_message = 16,
- max_queue = 64,
- max_datagrams_per_frame = 8,
- rebind_retry_s = 5,
- verbose = true, -- log every accepted command
- }
- ----------------------------------------------------------------------------
- -- logging (never let logging break the export chain)
- ----------------------------------------------------------------------------
- local function write_log(level, msg)
- pcall(function() log.write(LOG_NAME, log[level], msg) end)
- end
- local function info(msg) write_log("INFO", msg) end
- local function warn(msg) write_log("WARNING", msg) end
- local warned = {}
- local function warn_once(key, msg)
- if not warned[key] then
- warned[key] = true
- warn(msg)
- end
- end
- local ok_socket, socket = pcall(require, "socket")
- if not ok_socket then
- write_log("ERROR", "LuaSocket not available, bridge disabled: " .. tostring(socket))
- return
- end
- ----------------------------------------------------------------------------
- -- state
- ----------------------------------------------------------------------------
- local udp = nil
- local next_bind_try = 0
- local queue = {} -- pending actions: {at=, device=, command=, value=}
- local queue_free_at = 0 -- wall-clock time the last queued action is "done"
- local function ensure_socket(now)
- if udp then return true end
- if now < next_bind_try then return false end -- only set after a FAILED attempt
- local s = socket.udp()
- if not s then
- next_bind_try = now + CFG.rebind_retry_s
- warn("socket.udp() failed")
- return false
- end
- s:settimeout(0) -- never block the sim frame
- local ok, err = s:setsockname(CFG.host, CFG.port)
- if not ok then
- next_bind_try = now + CFG.rebind_retry_s
- warn(string.format("cannot bind %s:%d (%s); retrying in %ds",
- CFG.host, CFG.port, tostring(err), CFG.rebind_retry_s))
- s:close()
- return false
- end
- next_bind_try = 0
- udp = s
- info(string.format("listening on udp://%s:%d", CFG.host, CFG.port))
- return true
- end
- local function close_socket()
- if udp then
- pcall(function() udp:close() end)
- udp = nil
- end
- queue = {}
- queue_free_at = 0
- end
- ----------------------------------------------------------------------------
- -- seat detection: SEAT param is 0 = pilot, 1 = CPG (same source VAICOM uses)
- ----------------------------------------------------------------------------
- local function read_seat()
- local ok, v = pcall(function() return get_param_handle("SEAT"):get() end)
- if ok and type(v) == "number" then return v end
- return nil
- end
- local function seat_allows(required)
- if required == "any" then return true end
- local want = (required == "pilot") and 0 or 1
- local seat = read_seat()
- if seat == nil then
- -- Can't tell. Don't silently swallow the command; warn once and go ahead.
- warn_once("seat", "could not read SEAT param in the Export environment; " ..
- "seat check skipped (commands are NOT being seat-filtered)")
- return true
- end
- return seat == want, seat
- end
- ----------------------------------------------------------------------------
- -- parsing
- ----------------------------------------------------------------------------
- local function parse(data)
- local seat, body = data:match("^%s*(%a+)%s*|(.*)$")
- if not seat then return nil, "missing '<seat>|' header" end
- seat = seat:lower()
- if seat ~= "pilot" and seat ~= "cpg" and seat ~= "any" then
- return nil, "unknown seat '" .. seat .. "'"
- end
- local actions = {}
- for dev, cmd, val, dly in body:gmatch("(%d+)%s*,%s*(%d+)%s*,%s*(%-?[%d%.]+)%s*,%s*(%d+)") do
- if #actions >= CFG.max_actions_per_message then
- return nil, "too many actions"
- end
- actions[#actions + 1] = {
- device = tonumber(dev),
- command = tonumber(cmd),
- value = tonumber(val),
- delay = tonumber(dly) / 1000,
- }
- end
- if #actions == 0 then return nil, "no actions found" end
- return seat, actions
- end
- ----------------------------------------------------------------------------
- -- queue + execution
- ----------------------------------------------------------------------------
- local function enqueue(actions, now)
- if #queue + #actions > CFG.max_queue then
- warn("queue full, dropping message")
- return
- end
- local t = math.max(now, queue_free_at) -- keep messages strictly in order
- for _, a in ipairs(actions) do
- queue[#queue + 1] = { at = t, device = a.device, command = a.command, value = a.value }
- t = t + a.delay
- end
- queue_free_at = t
- end
- local function run_action(a)
- local ok, err = pcall(function()
- local dev = GetDevice(a.device)
- local t = type(dev)
- if (t ~= "table" and t ~= "userdata") or dev.performClickableAction == nil then
- error("GetDevice(" .. a.device .. ") is not a usable device: " .. tostring(dev))
- end
- dev:performClickableAction(a.command, a.value)
- end)
- if not ok then
- warn(string.format("action dev=%s cmd=%s val=%s failed: %s",
- tostring(a.device), tostring(a.command), tostring(a.value), tostring(err)))
- end
- end
- local function diag()
- local ok, dev = pcall(function() return GetDevice(87) end)
- local t = ok and type(dev) or "error"
- local usable = (t == "table" or t == "userdata") and dev.performClickableAction ~= nil
- info(string.format("diag: socket=%s GetDevice(87)=%s performClickableAction=%s SEAT=%s queued=%d",
- udp and "bound" or "NOT bound", t, tostring(usable), tostring(read_seat()), #queue))
- end
- local function handle_datagram(data, now)
- if data:match("^%s*diag%s*$") then
- diag()
- return
- end
- local seat, actions_or_err = parse(data)
- if not seat then
- warn("rejected datagram (" .. actions_or_err .. "): " .. data:sub(1, 80))
- return
- end
- local actions = actions_or_err
- for _, a in ipairs(actions) do
- if not CFG.allowed_devices[a.device] then
- warn("rejected datagram: device " .. a.device .. " is not in allowed_devices")
- return
- end
- end
- local allowed, seat_val = seat_allows(seat)
- if not allowed then
- warn(string.format("blocked: command needs the %s seat but SEAT=%s", seat, tostring(seat_val)))
- return
- end
- enqueue(actions, now)
- if CFG.verbose then
- info(string.format("queued %d action(s) [%s seat]", #actions, seat))
- end
- end
- local function step()
- local now = socket.gettime()
- if ensure_socket(now) then
- for _ = 1, CFG.max_datagrams_per_frame do
- local data = udp:receive()
- if not data then break end
- handle_datagram(data, now)
- end
- end
- while queue[1] and queue[1].at <= now do
- run_action(table.remove(queue, 1))
- end
- end
- ----------------------------------------------------------------------------
- -- Export.lua hooks, chained so DCS-BIOS / other export scripts keep working
- ----------------------------------------------------------------------------
- local prev_start = LuaExportStart
- local prev_stop = LuaExportStop
- local prev_before = LuaExportBeforeNextFrame
- local function call_prev(key, fn)
- if fn then
- local ok, err = pcall(fn)
- if not ok then warn_once(key, "previous " .. key .. " handler failed: " .. tostring(err)) end
- end
- end
- function LuaExportStart()
- call_prev("LuaExportStart", prev_start)
- pcall(function() ensure_socket(socket.gettime()) end)
- info("GeorgeBridge loaded")
- end
- function LuaExportStop()
- call_prev("LuaExportStop", prev_stop)
- close_socket()
- info("GeorgeBridge stopped")
- end
- function LuaExportBeforeNextFrame()
- call_prev("LuaExportBeforeNextFrame", prev_before)
- local ok, err = pcall(step)
- if not ok then warn_once("step", "step failed: " .. tostring(err)) end
- end
- end
- #!/usr/bin/env python3
- """
- george_hotkeys.py - hotkey -> George AI (DCS AH-64D) without VoiceAttack/VAICOM.
- How it works (mirrors VAICOM, minus the voice recognition and core-file patching):
- hotkey --> this script --UDP--> GeorgeBridge.lua (DCS Export.lua)
- --> GetDevice(87):performClickableAction(3003, 1.0 / 0.0)
- The macro table below is the equivalent of VAICOM's CPGCommandHandler.cs.
- Device 87 is "George AI"; the button IDs are the ones VAICOM uses.
- Setup: pip install keyboard
- Run: python george_hotkeys.py (hotkey listener)
- Test: python george_hotkeys.py --diag (does the DCS side answer? see dcs.log)
- python george_hotkeys.py --send PHS_SEARCH (fire the macro without a hotkey)
- Your joystick button just needs to emit LCtrl+LShift+A (via your stick's own
- software, JoyToKey, etc.).
- """
- from __future__ import annotations
- import argparse
- import socket
- import sys
- import time
- from dataclasses import dataclass
- from enum import IntEnum
- DEFAULT_HOST = "127.0.0.1"
- DEFAULT_PORT = 42087 # must match CFG.port in GeorgeBridge.lua
- DEVICE_GEORGE = 87 # AH-64D "George AI" cockpit device (same ID VAICOM uses)
- # In-game, <0.5 s is a "short" press and >0.5 s is a "long" press on George's interface.
- # VAICOM uses 1200 ms for long presses; short presses are released immediately.
- SHORT_PRESS_MS = 50
- LONG_PRESS_MS = 1200
- class GeorgeButton(IntEnum):
- """George AI device commands, taken from VAICOM's AH64GeorgeButton enum."""
- REQUEST_CONTROL = 3001
- MENU = 3002 # show/hide interface
- UP = 3003
- DOWN = 3004
- LEFT = 3005
- RIGHT = 3006
- MULTIFUNCTION = 3008 # "center"
- STORE_TARGET = 3009
- @dataclass(frozen=True)
- class Action:
- device: int
- command: int
- value: float # 1.0 = press, 0.0 = release
- delay_ms: int = 0 # wait AFTER this action before the next one
- def press(button: GeorgeButton, hold_ms: int = SHORT_PRESS_MS) -> tuple[Action, ...]:
- """Press, hold for hold_ms, release. Use hold_ms=LONG_PRESS_MS for a long press."""
- return (
- Action(DEVICE_GEORGE, int(button), 1.0, hold_ms),
- Action(DEVICE_GEORGE, int(button), 0.0, 0),
- )
- @dataclass(frozen=True)
- class Macro:
- name: str
- seat: str # seat the PLAYER must be in: "pilot" | "cpg" | "any"
- actions: tuple[Action, ...]
- def encode(self) -> str:
- body = ";".join(f"{a.device},{a.command},{a.value:g},{a.delay_ms}" for a in self.actions)
- return f"{self.seat}|{body}"
- # --- Macro table (add more George commands here) -----------------------------
- # VAICOM: "wMsgGeorgeMacroPHSsearch" -> AddGeorgeButton(AH64GeorgeButton.Up)
- # PHS SEARCH = "Up Short": George (as CPG) drops what he's doing and searches
- # where the pilot's helmet sight (PHS) is looking. Needs the player in the PILOT seat.
- MACROS: dict[str, Macro] = {
- m.name: m
- for m in (
- Macro("PHS_SEARCH", seat="pilot", actions=press(GeorgeButton.UP)),
- )
- }
- # --- Hotkeys -----------------------------------------------------------------
- HOTKEYS: dict[str, str] = {
- "left ctrl+left shift+a": "PHS_SEARCH",
- }
- class Bridge:
- def __init__(self, host: str, port: int) -> None:
- self.addr = (host, port)
- self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- def send_raw(self, text: str) -> None:
- self.sock.sendto(text.encode("ascii"), self.addr)
- def send_macro(self, name: str) -> None:
- macro = MACROS[name]
- payload = macro.encode()
- self.send_raw(payload)
- print(f"[{time.strftime('%H:%M:%S')}] {name} -> {payload}", flush=True)
- def run_hotkeys(bridge: Bridge, suppress: bool, cooldown_ms: int) -> None:
- try:
- import keyboard # imported here so --send / --diag work without it
- except ImportError:
- sys.exit("Missing dependency: run pip install keyboard")
- last_fired: dict[str, float] = {}
- def fire(name: str) -> None:
- # Debounce key auto-repeat; a deliberate re-press after the cooldown still works.
- now = time.monotonic()
- if (now - last_fired.get(name, 0.0)) * 1000 < cooldown_ms:
- return
- last_fired[name] = now
- bridge.send_macro(name)
- for combo, name in HOTKEYS.items():
- keyboard.add_hotkey(combo, fire, args=(name,), suppress=suppress)
- print(f" {combo:<28} -> {name} ({MACROS[name].seat} seat)")
- print(f"Sending to udp://{bridge.addr[0]}:{bridge.addr[1]} | Ctrl+C to quit", flush=True)
- try:
- while True:
- time.sleep(0.5) # sleep (not keyboard.wait) so Ctrl+C works on Windows
- except KeyboardInterrupt:
- pass
- finally:
- keyboard.unhook_all_hotkeys()
- def main() -> None:
- ap = argparse.ArgumentParser(description="Hotkeys for DCS AH-64D George AI (no VAICOM).")
- ap.add_argument("--host", default=DEFAULT_HOST)
- ap.add_argument("--port", type=int, default=DEFAULT_PORT)
- ap.add_argument("--suppress", action="store_true",
- help="swallow the hotkey so DCS never sees it (default: pass through)")
- ap.add_argument("--cooldown-ms", type=int, default=250,
- help="ignore repeat triggers of the same hotkey within this time")
- ap.add_argument("--send", metavar="MACRO", choices=sorted(MACROS),
- help="send one macro now and exit (testing)")
- ap.add_argument("--diag", action="store_true",
- help="ask GeorgeBridge.lua to log a status line to dcs.log, then exit")
- args = ap.parse_args()
- bridge = Bridge(args.host, args.port)
- if args.diag:
- bridge.send_raw("diag")
- print("Sent diag. Look for 'GEORGE_BRIDGE: diag:' in Saved Games\\DCS\\Logs\\dcs.log")
- elif args.send:
- bridge.send_macro(args.send)
- else:
- print("George hotkeys active:")
- run_hotkeys(bridge, args.suppress, args.cooldown_ms)
- if __name__ == "__main__":
- main()
Advertisement
Add Comment
Please, Sign In to add comment