switch.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. import asyncio
  2. import os
  3. import argparse
  4. import socket
  5. import serial_asyncio
  6. from serial.tools import list_ports
  7. import yaml
  8. from aiohttp import web
  9. from asyncio import Queue
  10. write_queue = Queue()
  11. cam_transports = {}
  12. VISCA_PORT = 52381
  13. class ViscaOverIP:
  14. def __init__(self, ip, port):
  15. self.ip = ip
  16. self.port = port
  17. self.sequence_number = 1
  18. self.transport = None
  19. async def connect(self, loop):
  20. class ViscaProtocol(asyncio.DatagramProtocol):
  21. def connection_made(self, transport):
  22. pass
  23. self.transport, _ = await loop.create_datagram_endpoint(
  24. ViscaProtocol,
  25. 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([0x81, 0x01, 0x06, 0x01, v_pan_speed, v_tilt_speed, pan_dir, tilt_dir, 0xFF])
  66. # Zoom
  67. # 81 01 04 07 0p FF (0p: 00=Stop, 02=Tele/In, 03=Wide/Out)
  68. if cmd2 & 0x20: # Zoom In (Tele)
  69. return bytearray([0x81, 0x01, 0x04, 0x07, 0x02, 0xFF])
  70. elif cmd2 & 0x40: # Zoom Out (Wide)
  71. return bytearray([0x81, 0x01, 0x04, 0x07, 0x03, 0xFF])
  72. # Focus
  73. # 81 01 04 08 0p FF (02=Far, 03=Near)
  74. if cmd1 & 0x01: # Focus Near
  75. return bytearray([0x81, 0x01, 0x04, 0x08, 0x03, 0xFF])
  76. elif cmd1 & 0x02: # Focus Far
  77. return bytearray([0x81, 0x01, 0x04, 0x08, 0x02, 0xFF])
  78. # If it's a stop packet (cmd1=0, cmd2=0) or we don't recognize it
  79. if cmd1 == 0 and cmd2 == 0:
  80. # General stop for Pan/Tilt and Zoom
  81. # Note: VISCA Zoom stop is separate but we'll prioritize P/T stop
  82. return bytearray([0x81, 0x01, 0x06, 0x01, 0x00, 0x00, 0x03, 0x03, 0xFF])
  83. return None
  84. async def writer_task():
  85. while True:
  86. cam_name, packet = await write_queue.get()
  87. visca_obj = cam_transports.get(cam_name)
  88. if visca_obj:
  89. try:
  90. visca_obj.write(packet)
  91. except Exception as e:
  92. print(f"[ERROR] Write failed for {cam_name}: {e}")
  93. else:
  94. print(f"[WARN] No transport for {cam_name}")
  95. write_queue.task_done()
  96. def enqueue_write(cam_name, packet):
  97. write_queue.put_nowait((cam_name, packet))
  98. def get_config_path():
  99. parser = argparse.ArgumentParser(description="PTZ router")
  100. parser.add_argument(
  101. "-c", "--config", type=str, help="Path to config.yml file"
  102. )
  103. args = parser.parse_args()
  104. if args.config:
  105. return args.config
  106. xdg_config_home = os.environ.get(
  107. "XDG_CONFIG_HOME", os.path.expanduser("~/.config")
  108. )
  109. return os.path.join(xdg_config_home, "diy_ptz_switch", "config.yml")
  110. def load_config():
  111. config_file = get_config_path()
  112. if not os.path.exists(config_file):
  113. raise FileNotFoundError(f"Config file not found at: {config_file}")
  114. with open(config_file, "r", encoding="utf-8") as f:
  115. config = yaml.safe_load(f)
  116. return config
  117. config = load_config()
  118. location_roles = config.get("location_roles", {})
  119. camera_ips = config.get("cameras", {})
  120. port_map = {}
  121. for port in list_ports.comports():
  122. if "LOCATION=" in port.hwid:
  123. loc = port.hwid.split("LOCATION=")[-1]
  124. if loc in location_roles:
  125. role = location_roles[loc]
  126. port_map[role] = port.device
  127. print("Port mapping by USB port:")
  128. for role, dev in port_map.items():
  129. print(f" {role}: {dev}")
  130. JOYSTICK_PORT = port_map.get("joystick")
  131. BAUDRATE = 2400
  132. current_target = "cam1" # Default
  133. current_mode = "preview" # Default
  134. class JoystickProtocol(asyncio.Protocol):
  135. def __init__(self, forward_func):
  136. self.forward = forward_func
  137. self.buffer = bytearray()
  138. def data_received(self, data):
  139. print(f"[DEBUG] Raw data received: {data.hex()}")
  140. self.buffer += data
  141. self.parse_pelco_d_packets()
  142. def parse_pelco_d_packets(self):
  143. while len(self.buffer) >= 7:
  144. if self.buffer[0] != 0xFF:
  145. self.buffer.pop(0)
  146. continue
  147. packet = self.buffer[:7]
  148. self.buffer = self.buffer[7:]
  149. address = packet[1]
  150. cmd1 = packet[2]
  151. cmd2 = packet[3]
  152. data1 = packet[4]
  153. data2 = packet[5]
  154. print(
  155. f"[Joystick] Pelco packet to addr {address:02X} — "
  156. f"Cmd1: {cmd1:02X}, Cmd2: {cmd2:02X}, "
  157. f"Data1: {data1:02X}, Data2: {data2:02X}, "
  158. f"Target: {current_target}"
  159. )
  160. visca_packet = translate_pelco_to_visca(packet)
  161. if visca_packet:
  162. self.forward(visca_packet)
  163. def make_visca_preset_command(cmd2, preset_id):
  164. if not (0 <= preset_id <= 0xFF):
  165. raise ValueError("Preset ID must be between 0 and 255")
  166. # VISCA: 81 01 04 3F 0p pp FF
  167. # 0p: 01=Set, 02=Recall
  168. action = 0x02 if cmd2 == 0x07 else 0x01
  169. return bytearray([0x81, 0x01, 0x04, 0x3F, action, preset_id, 0xFF])
  170. def send_preset_command(cam_name, cmd2, preset_id):
  171. packet = make_visca_preset_command(cmd2, preset_id)
  172. print(
  173. f"[API] Queueing VISCA preset action={cmd2:02X} preset_id={preset_id} for {cam_name}"
  174. )
  175. enqueue_write(cam_name, packet)
  176. async def handle_status(request):
  177. return web.json_response({"current_target": current_target})
  178. async def handle_set_target(request):
  179. global current_target
  180. data = await request.json()
  181. target = data.get("target")
  182. if target not in cam_transports:
  183. return web.json_response(
  184. {"error": f"Invalid target: {target}"}, status=400
  185. )
  186. current_target = target
  187. print(f"[API] Target set to: {current_target}")
  188. return web.json_response({"status": "ok", "target": current_target})
  189. async def handle_goto_preset(request):
  190. data = await request.json()
  191. preset_id = int(data.get("preset"))
  192. target = data.get("target", current_target)
  193. if target not in cam_transports:
  194. return web.json_response(
  195. {"error": f"Invalid target: {target}"}, status=400
  196. )
  197. send_preset_command(target, cmd2=0x07, preset_id=preset_id)
  198. return web.json_response(
  199. {
  200. "status": "ok",
  201. "action": "goto",
  202. "preset": preset_id,
  203. "target": target,
  204. }
  205. )
  206. async def handle_save_preset(request):
  207. data = await request.json()
  208. preset_id = int(data.get("preset"))
  209. target = data.get("target", current_target)
  210. if target == "both":
  211. for cam in ["cam1", "cam2"]:
  212. send_preset_command(cam, cmd2=0x03, preset_id=preset_id)
  213. elif target in cam_transports:
  214. send_preset_command(target, cmd2=0x03, preset_id=preset_id)
  215. else:
  216. return web.json_response(
  217. {"error": f"Invalid target: {target}"}, status=400
  218. )
  219. return web.json_response(
  220. {
  221. "status": "ok",
  222. "action": "save",
  223. "preset": preset_id,
  224. "target": target,
  225. }
  226. )
  227. async def handle_set_mode(request):
  228. global current_mode
  229. mode = request.query.get("mode")
  230. if mode not in ("preview", "program"):
  231. return web.json_response({"error": "Invalid mode"}, status=400)
  232. current_mode = mode
  233. return web.json_response({"status": "ok", "mode": current_mode})
  234. async def handle_get_mode(request):
  235. return web.json_response({"mode": current_mode})
  236. async def start_http_server():
  237. app = web.Application()
  238. app.router.add_get("/target/get", handle_status)
  239. app.router.add_post("/target/set", handle_set_target)
  240. app.router.add_post("/preset/goto", handle_goto_preset)
  241. app.router.add_post("/preset/save", handle_save_preset)
  242. app.router.add_get("/mode/get", handle_get_mode)
  243. app.router.add_post("/mode/set", handle_set_mode)
  244. runner = web.AppRunner(app)
  245. await runner.setup()
  246. site = web.TCPSite(runner, "0.0.0.0", 1423)
  247. await site.start()
  248. async def main():
  249. global cam_transports
  250. loop = asyncio.get_running_loop()
  251. def forward_packet(packet):
  252. if current_target in cam_transports:
  253. enqueue_write(current_target, packet)
  254. else:
  255. print(f"[WARN] No transport for {current_target}")
  256. # Connect to cameras via VISCA over IP
  257. for cam_name, ip in camera_ips.items():
  258. if cam_name.startswith("cam"):
  259. print(f"[INFO] Connecting to {cam_name} at {ip}")
  260. visca_obj = ViscaOverIP(ip, VISCA_PORT)
  261. await visca_obj.connect(loop)
  262. cam_transports[cam_name] = visca_obj
  263. if not cam_transports:
  264. print("[WARN] No cameras configured")
  265. asyncio.create_task(writer_task())
  266. if JOYSTICK_PORT:
  267. await serial_asyncio.create_serial_connection(
  268. loop,
  269. lambda: JoystickProtocol(forward_packet),
  270. JOYSTICK_PORT,
  271. baudrate=BAUDRATE,
  272. )
  273. else:
  274. print("[WARN] No joystick port found")
  275. asyncio.create_task(start_http_server())
  276. # Wait forever
  277. await asyncio.Event().wait()
  278. if __name__ == "__main__":
  279. asyncio.run(main())
  280. if __name__ == "__main__":
  281. asyncio.run(main())