switch.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. import asyncio
  2. import os
  3. import argparse
  4. from asyncio import Queue
  5. import yaml
  6. from aiohttp import web
  7. import serial_asyncio
  8. from serial.tools import list_ports
  9. import evdev
  10. from evdev import ecodes
  11. write_queue = Queue()
  12. cam_transports = {}
  13. VISCA_PORT = 52381
  14. class ViscaOverIP:
  15. def __init__(self, ip, port):
  16. self.ip = ip
  17. self.port = port
  18. self.sequence_number = 1
  19. self.transport = None
  20. async def connect(self, loop):
  21. class ViscaProtocol(asyncio.DatagramProtocol):
  22. def connection_made(self, transport):
  23. pass
  24. self.transport, _ = await loop.create_datagram_endpoint(
  25. ViscaProtocol, remote_addr=(self.ip, self.port)
  26. )
  27. def write(self, visca_payload):
  28. if not self.transport:
  29. print(f"[ERROR] No transport for {self.ip}")
  30. return
  31. header = bytearray([0x01, 0x00]) # Payload type: VISCA command
  32. header += len(visca_payload).to_bytes(2, "big")
  33. header += self.sequence_number.to_bytes(4, "big")
  34. packet = header + visca_payload
  35. self.transport.sendto(packet)
  36. self.sequence_number = (self.sequence_number + 1) & 0xFFFFFFFF
  37. def translate_pelco_to_visca(packet):
  38. """
  39. Translates a 7-byte Pelco-D packet to a VISCA command.
  40. """
  41. if len(packet) < 7:
  42. return None
  43. cmd1 = packet[2]
  44. cmd2 = packet[3]
  45. pan_speed = packet[4]
  46. tilt_speed = packet[5]
  47. # Map speeds (Pelco 00-3F to VISCA 01-18/17)
  48. v_pan_speed = max(1, min(0x18, int(pan_speed * 0x18 / 0x3F)))
  49. v_tilt_speed = max(1, min(0x17, int(tilt_speed * 0x17 / 0x3F)))
  50. # Pan/Tilt Drive
  51. # 81 01 06 01 VV WW 0x 0y FF
  52. # x: 01=left, 02=right, 03=stop
  53. # y: 01=up, 02=down, 03=stop
  54. pan_dir = 0x03
  55. if cmd2 & 0x02: # Right
  56. pan_dir = 0x02
  57. elif cmd2 & 0x04: # Left
  58. pan_dir = 0x01
  59. tilt_dir = 0x03
  60. if cmd2 & 0x08: # Up
  61. tilt_dir = 0x01
  62. elif cmd2 & 0x10: # Down
  63. tilt_dir = 0x02
  64. if pan_dir != 0x03 or tilt_dir != 0x03:
  65. return bytearray(
  66. [
  67. 0x81,
  68. 0x01,
  69. 0x06,
  70. 0x01,
  71. v_pan_speed,
  72. v_tilt_speed,
  73. pan_dir,
  74. tilt_dir,
  75. 0xFF,
  76. ]
  77. )
  78. # Zoom
  79. # 81 01 04 07 0p FF (0p: 00=Stop, 02=Tele/In, 03=Wide/Out)
  80. if cmd2 & 0x20: # Zoom In (Tele)
  81. return bytearray([0x81, 0x01, 0x04, 0x07, 0x02, 0xFF])
  82. if cmd2 & 0x40: # Zoom Out (Wide)
  83. return bytearray([0x81, 0x01, 0x04, 0x07, 0x03, 0xFF])
  84. # Focus
  85. # 81 01 04 08 0p FF (02=Far, 03=Near)
  86. if cmd1 & 0x01: # Focus Near
  87. return bytearray([0x81, 0x01, 0x04, 0x08, 0x03, 0xFF])
  88. if cmd1 & 0x02: # Focus Far
  89. return bytearray([0x81, 0x01, 0x04, 0x08, 0x02, 0xFF])
  90. # If it's a stop packet (cmd1=0, cmd2=0) or we don't recognize it
  91. if cmd1 == 0 and cmd2 == 0:
  92. # General stop for Pan/Tilt and Zoom
  93. # Note: VISCA Zoom stop is separate but we'll prioritize P/T stop
  94. return bytearray([0x81, 0x01, 0x06, 0x01, 0x00, 0x00, 0x03, 0x03, 0xFF])
  95. return None
  96. async def writer_task():
  97. while True:
  98. cam_name, packet = await write_queue.get()
  99. visca_obj = cam_transports.get(cam_name)
  100. if visca_obj:
  101. try:
  102. visca_obj.write(packet)
  103. except Exception as e:
  104. print(f"[ERROR] Write failed for {cam_name}: {e}")
  105. else:
  106. print(f"[WARN] No transport for {cam_name}")
  107. write_queue.task_done()
  108. def enqueue_write(cam_name, packet):
  109. write_queue.put_nowait((cam_name, packet))
  110. def get_config_path():
  111. parser = argparse.ArgumentParser(description="PTZ router")
  112. parser.add_argument(
  113. "-c", "--config", type=str, help="Path to config.yml file"
  114. )
  115. args = parser.parse_args()
  116. if args.config:
  117. return args.config
  118. xdg_config_home = os.environ.get(
  119. "XDG_CONFIG_HOME", os.path.expanduser("~/.config")
  120. )
  121. return os.path.join(xdg_config_home, "diy_ptz_switch", "config.yml")
  122. def load_config():
  123. config_file = get_config_path()
  124. if not os.path.exists(config_file):
  125. raise FileNotFoundError(f"Config file not found at: {config_file}")
  126. with open(config_file, "r", encoding="utf-8") as f:
  127. config = yaml.safe_load(f)
  128. return config
  129. config = load_config()
  130. location_roles = config.get("location_roles", {})
  131. camera_ips = config.get("cameras", {})
  132. joystick_type = config.get("joystick_type", "pelco_serial")
  133. port_map = {}
  134. for port in list_ports.comports():
  135. if "LOCATION=" in port.hwid:
  136. loc = port.hwid.split("LOCATION=")[-1]
  137. if loc in location_roles:
  138. role = location_roles[loc]
  139. port_map[role] = port.device
  140. print("Port mapping by USB port:")
  141. for role, dev in port_map.items():
  142. print(f" {role}: {dev}")
  143. JOYSTICK_PORT = port_map.get("joystick")
  144. BAUDRATE = 2400
  145. CURRENT_TARGET = "cam1" # Default
  146. CURRENT_MODE = "preview" # Default
  147. async def evdev_joystick_task(forward_func):
  148. devices = [evdev.InputDevice(path) for path in evdev.list_devices()]
  149. target_device = None
  150. for device in devices:
  151. if "shenzhenxiaolong" in device.name.lower():
  152. target_device = device
  153. break
  154. if not target_device:
  155. print("[WARN] Sony/Anxinshi USB joystick not found.")
  156. return
  157. print(f"[INFO] Using USB joystick: {target_device.name}")
  158. x_val = 512
  159. y_val = 512
  160. z_val = 512
  161. deadzone = 50
  162. last_pt_packet = None
  163. last_z_packet = None
  164. try:
  165. async for event in target_device.async_read_loop():
  166. if event.type == ecodes.EV_ABS:
  167. if event.code == ecodes.ABS_X:
  168. x_val = event.value
  169. elif event.code == ecodes.ABS_Y:
  170. y_val = event.value
  171. elif event.code == ecodes.ABS_Z:
  172. z_val = event.value
  173. elif event.type == ecodes.EV_SYN:
  174. pan_dir = 0x03
  175. pan_speed = 0x00
  176. if x_val < 512 - deadzone:
  177. pan_dir = 0x01 # Left
  178. pan_speed = int((512 - x_val) / 512.0 * 0x18)
  179. elif x_val > 512 + deadzone:
  180. pan_dir = 0x02 # Right
  181. pan_speed = int((x_val - 512) / 511.0 * 0x18)
  182. pan_speed = (
  183. max(1, min(0x18, pan_speed)) if pan_dir != 0x03 else 0x00
  184. )
  185. tilt_dir = 0x03
  186. tilt_speed = 0x00
  187. if y_val < 512 - deadzone:
  188. tilt_dir = 0x01 # Up
  189. tilt_speed = int((512 - y_val) / 512.0 * 0x17)
  190. elif y_val > 512 + deadzone:
  191. tilt_dir = 0x02 # Down
  192. tilt_speed = int((y_val - 512) / 511.0 * 0x17)
  193. tilt_speed = (
  194. max(1, min(0x17, tilt_speed)) if tilt_dir != 0x03 else 0x00
  195. )
  196. pt_packet = bytearray(
  197. [
  198. 0x81,
  199. 0x01,
  200. 0x06,
  201. 0x01,
  202. pan_speed,
  203. tilt_speed,
  204. pan_dir,
  205. tilt_dir,
  206. 0xFF,
  207. ]
  208. )
  209. if pt_packet != last_pt_packet:
  210. forward_func(pt_packet)
  211. last_pt_packet = pt_packet
  212. zoom_cmd = 0x00
  213. if z_val < 512 - deadzone:
  214. z_speed = int((512 - z_val) / 512.0 * 7)
  215. z_speed = max(0, min(7, z_speed))
  216. zoom_cmd = 0x30 | z_speed
  217. elif z_val > 512 + deadzone:
  218. z_speed = int((z_val - 512) / 511.0 * 7)
  219. z_speed = max(0, min(7, z_speed))
  220. zoom_cmd = 0x20 | z_speed
  221. z_packet = bytearray([0x81, 0x01, 0x04, 0x07, zoom_cmd, 0xFF])
  222. if z_packet != last_z_packet:
  223. forward_func(z_packet)
  224. last_z_packet = z_packet
  225. except Exception as e:
  226. print(f"[ERROR] USB Joystick loop error: {e}")
  227. class JoystickProtocol(asyncio.Protocol):
  228. def __init__(self, forward_func):
  229. self.forward = forward_func
  230. self.buffer = bytearray()
  231. def data_received(self, data):
  232. print(f"[DEBUG] Raw data received: {data.hex()}")
  233. self.buffer += data
  234. self.parse_pelco_d_packets()
  235. def parse_pelco_d_packets(self):
  236. while len(self.buffer) >= 7:
  237. if self.buffer[0] != 0xFF:
  238. self.buffer.pop(0)
  239. continue
  240. packet = self.buffer[:7]
  241. self.buffer = self.buffer[7:]
  242. address = packet[1]
  243. cmd1 = packet[2]
  244. cmd2 = packet[3]
  245. data1 = packet[4]
  246. data2 = packet[5]
  247. print(
  248. f"[Joystick] Pelco packet to addr {address:02X} — "
  249. f"Cmd1: {cmd1:02X}, Cmd2: {cmd2:02X}, "
  250. f"Data1: {data1:02X}, Data2: {data2:02X}, "
  251. f"Target: {CURRENT_TARGET}"
  252. )
  253. visca_packet = translate_pelco_to_visca(packet)
  254. if visca_packet:
  255. self.forward(visca_packet)
  256. def make_visca_preset_command(cmd2, preset_id):
  257. if not 0 <= preset_id <= 0xFF:
  258. raise ValueError("Preset ID must be between 0 and 255")
  259. # VISCA: 81 01 04 3F 0p pp FF
  260. # 0p: 01=Set, 02=Recall
  261. action = 0x02 if cmd2 == 0x07 else 0x01
  262. return bytearray([0x81, 0x01, 0x04, 0x3F, action, preset_id, 0xFF])
  263. def send_preset_command(cam_name, cmd2, preset_id):
  264. packet = make_visca_preset_command(cmd2, preset_id)
  265. print(
  266. f"[API] Queueing VISCA preset action={cmd2:02X} preset_id={preset_id} for {cam_name}"
  267. )
  268. enqueue_write(cam_name, packet)
  269. async def handle_status(request):
  270. return web.json_response({"current_target": CURRENT_TARGET})
  271. async def handle_set_target(request):
  272. global CURRENT_TARGET
  273. target = None
  274. if request.can_read_body:
  275. try:
  276. data = await request.json()
  277. target = data.get("target")
  278. except Exception:
  279. pass
  280. if not target:
  281. target = request.query.get("target")
  282. if target not in cam_transports:
  283. return web.json_response(
  284. {"error": f"Invalid target: {target}"}, status=400
  285. )
  286. CURRENT_TARGET = target
  287. print(f"[API] Target set to: {CURRENT_TARGET}")
  288. return web.json_response({"status": "ok", "target": CURRENT_TARGET})
  289. async def handle_goto_preset(request):
  290. preset_id = None
  291. target = None
  292. if request.can_read_body:
  293. try:
  294. data = await request.json()
  295. preset_id = data.get("preset")
  296. target = data.get("target")
  297. except Exception:
  298. pass
  299. if preset_id is None:
  300. preset_id = request.query.get("preset")
  301. if target is None:
  302. target = request.query.get("target", CURRENT_TARGET)
  303. if preset_id is None:
  304. return web.json_response({"error": "Missing preset"}, status=400)
  305. try:
  306. preset_id = int(preset_id)
  307. except ValueError:
  308. return web.json_response({"error": "Invalid preset ID"}, status=400)
  309. if target not in cam_transports:
  310. return web.json_response(
  311. {"error": f"Invalid target: {target}"}, status=400
  312. )
  313. send_preset_command(target, cmd2=0x07, preset_id=preset_id)
  314. return web.json_response(
  315. {
  316. "status": "ok",
  317. "action": "goto",
  318. "preset": preset_id,
  319. "target": target,
  320. }
  321. )
  322. async def handle_save_preset(request):
  323. preset_id = None
  324. target = None
  325. if request.can_read_body:
  326. try:
  327. data = await request.json()
  328. preset_id = data.get("preset")
  329. target = data.get("target")
  330. except Exception:
  331. pass
  332. if preset_id is None:
  333. preset_id = request.query.get("preset")
  334. if target is None:
  335. target = request.query.get("target", CURRENT_TARGET)
  336. if preset_id is None:
  337. return web.json_response({"error": "Missing preset"}, status=400)
  338. try:
  339. preset_id = int(preset_id)
  340. except ValueError:
  341. return web.json_response({"error": "Invalid preset ID"}, status=400)
  342. if target == "both":
  343. for cam in ["cam1", "cam2"]:
  344. send_preset_command(cam, cmd2=0x03, preset_id=preset_id)
  345. elif target in cam_transports:
  346. send_preset_command(target, cmd2=0x03, preset_id=preset_id)
  347. else:
  348. return web.json_response(
  349. {"error": f"Invalid target: {target}"}, status=400
  350. )
  351. return web.json_response(
  352. {
  353. "status": "ok",
  354. "action": "save",
  355. "preset": preset_id,
  356. "target": target,
  357. }
  358. )
  359. async def handle_set_mode(request):
  360. global CURRENT_MODE
  361. mode = request.query.get("mode")
  362. if mode not in ("preview", "program"):
  363. return web.json_response({"error": "Invalid mode"}, status=400)
  364. CURRENT_MODE = mode
  365. return web.json_response({"status": "ok", "mode": CURRENT_MODE})
  366. async def handle_get_mode(request):
  367. return web.json_response({"mode": CURRENT_MODE})
  368. async def start_http_server():
  369. app = web.Application()
  370. app.router.add_get("/target/get", handle_status)
  371. app.router.add_post("/target/set", handle_set_target)
  372. app.router.add_post("/preset/goto", handle_goto_preset)
  373. app.router.add_post("/preset/save", handle_save_preset)
  374. app.router.add_get("/mode/get", handle_get_mode)
  375. app.router.add_post("/mode/set", handle_set_mode)
  376. runner = web.AppRunner(app)
  377. await runner.setup()
  378. site = web.TCPSite(runner, "0.0.0.0", 1423)
  379. await site.start()
  380. async def main():
  381. global cam_transports
  382. loop = asyncio.get_running_loop()
  383. def forward_packet(packet):
  384. if CURRENT_TARGET in cam_transports:
  385. enqueue_write(CURRENT_TARGET, packet)
  386. else:
  387. print(f"[WARN] No transport for {CURRENT_TARGET}")
  388. # Connect to cameras via VISCA over IP
  389. for cam_name, ip in camera_ips.items():
  390. if cam_name.startswith("cam"):
  391. print(f"[INFO] Connecting to {cam_name} at {ip}")
  392. visca_obj = ViscaOverIP(ip, VISCA_PORT)
  393. await visca_obj.connect(loop)
  394. cam_transports[cam_name] = visca_obj
  395. if not cam_transports:
  396. print("[WARN] No cameras configured")
  397. asyncio.create_task(writer_task())
  398. if joystick_type == "usb_joystick":
  399. print("[INFO] Starting USB joystick task")
  400. asyncio.create_task(evdev_joystick_task(forward_packet))
  401. else:
  402. if JOYSTICK_PORT:
  403. await serial_asyncio.create_serial_connection(
  404. loop,
  405. lambda: JoystickProtocol(forward_packet),
  406. JOYSTICK_PORT,
  407. baudrate=BAUDRATE,
  408. )
  409. else:
  410. print("[WARN] No joystick port found for serial connection")
  411. asyncio.create_task(start_http_server())
  412. # Wait forever
  413. await asyncio.Event().wait()
  414. if __name__ == "__main__":
  415. asyncio.run(main())