switch.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. import asyncio
  2. import os
  3. import argparse
  4. import serial_asyncio
  5. from serial.tools import list_ports
  6. import yaml
  7. from aiohttp import web
  8. from asyncio import Queue
  9. write_queue = Queue()
  10. cam_transports = {}
  11. async def writer_task():
  12. while True:
  13. cam_name, packet = await write_queue.get()
  14. transport = cam_transports.get(cam_name)
  15. if transport:
  16. try:
  17. transport.write(packet)
  18. except Exception as e:
  19. print(f"[ERROR] Write failed for {cam_name}: {e}")
  20. else:
  21. print(f"[WARN] No transport for {cam_name}")
  22. write_queue.task_done()
  23. def enqueue_write(cam_name, packet):
  24. write_queue.put_nowait((cam_name, packet))
  25. def get_config_path():
  26. parser = argparse.ArgumentParser(description="PTZ router")
  27. parser.add_argument(
  28. "-c", "--config", type=str, help="Path to config.yml file"
  29. )
  30. args = parser.parse_args()
  31. if args.config:
  32. return args.config
  33. xdg_config_home = os.environ.get(
  34. "XDG_CONFIG_HOME", os.path.expanduser("~/.config")
  35. )
  36. return os.path.join(xdg_config_home, "diy_ptz_switch", "config.yml")
  37. def load_location_roles():
  38. config_file = get_config_path()
  39. if not os.path.exists(config_file):
  40. raise FileNotFoundError(f"Config file not found at: {config_file}")
  41. with open(config_file, "r", encoding="utf-8") as f:
  42. config = yaml.safe_load(f)
  43. return config.get("location_roles", {})
  44. location_roles = load_location_roles()
  45. port_map = {}
  46. for port in list_ports.comports():
  47. if "LOCATION=" in port.hwid:
  48. loc = port.hwid.split("LOCATION=")[-1]
  49. if loc in location_roles:
  50. role = location_roles[loc]
  51. port_map[role] = port.device
  52. print("Port mapping by USB port:")
  53. for role, dev in port_map.items():
  54. print(f" {role}: {dev}")
  55. JOYSTICK_PORT = port_map.get("joystick")
  56. CAM1_PORT = port_map.get("cam1")
  57. CAM2_PORT = port_map.get("cam2")
  58. BAUDRATE = 2400
  59. current_target = "cam1" # Default
  60. current_mode = "preview" # Default
  61. class JoystickProtocol(asyncio.Protocol):
  62. def __init__(self, forward_func):
  63. self.forward = forward_func
  64. self.buffer = bytearray()
  65. def data_received(self, data):
  66. print(f"[DEBUG] Raw data received: {data.hex()}")
  67. self.buffer += data
  68. self.parse_pelco_d_packets()
  69. def parse_pelco_d_packets(self):
  70. while len(self.buffer) >= 7:
  71. if self.buffer[0] != 0xFF:
  72. self.buffer.pop(0)
  73. continue
  74. packet = self.buffer[:7]
  75. self.buffer = self.buffer[7:]
  76. address = packet[1]
  77. cmd1 = packet[2]
  78. cmd2 = packet[3]
  79. data1 = packet[4]
  80. data2 = packet[5]
  81. print(
  82. f"[Joystick] Packet to camera addr {address:02X} — "
  83. f"Cmd1: {cmd1:02X}, Cmd2: {cmd2:02X}, "
  84. f"Data1: {data1:02X}, Data2: {data2:02X}, "
  85. f"Target: {current_target}"
  86. )
  87. self.forward(packet)
  88. class DummyCamProtocol(asyncio.Protocol):
  89. def connection_made(self, transport):
  90. pass
  91. def make_preset_command(cam_address: int, cmd2: int, preset_id: int):
  92. if not (1 <= preset_id <= 0xFF):
  93. raise ValueError("Preset ID must be between 1 and 255")
  94. cmd1 = 0x00
  95. data1 = 0x00
  96. data2 = preset_id
  97. packet = bytearray([0xFF, cam_address, cmd1, cmd2, data1, data2])
  98. checksum = sum(packet[1:]) % 256
  99. packet.append(checksum)
  100. return packet
  101. def send_preset_command(cam_name, cmd2, preset_id):
  102. # cam id is hardcoded, should be the same on all cameras or else they will
  103. # not accept the preset commands
  104. packet = make_preset_command(1, cmd2, preset_id)
  105. print(
  106. f"[API] Queueing preset cmd2={cmd2:02X} preset_id={preset_id} for {cam_name}"
  107. )
  108. enqueue_write(cam_name, packet)
  109. async def handle_status(request):
  110. return web.json_response({"current_target": current_target})
  111. async def handle_set_target(request):
  112. global current_target
  113. data = await request.json()
  114. target = data.get("target")
  115. if target not in cam_transports:
  116. return web.json_response(
  117. {"error": f"Invalid target: {target}"}, status=400
  118. )
  119. current_target = target
  120. print(f"[API] Target set to: {current_target}")
  121. return web.json_response({"status": "ok", "target": current_target})
  122. async def handle_goto_preset(request):
  123. data = await request.json()
  124. preset_id = int(data.get("preset"))
  125. target = data.get("target", current_target)
  126. if target not in cam_transports:
  127. return web.json_response(
  128. {"error": f"Invalid target: {target}"}, status=400
  129. )
  130. send_preset_command(target, cmd2=0x07, preset_id=preset_id)
  131. return web.json_response(
  132. {
  133. "status": "ok",
  134. "action": "goto",
  135. "preset": preset_id,
  136. "target": target,
  137. }
  138. )
  139. async def handle_save_preset(request):
  140. data = await request.json()
  141. preset_id = int(data.get("preset"))
  142. target = data.get("target", current_target)
  143. if target == "both":
  144. for cam in ["cam1", "cam2"]:
  145. send_preset_command(cam, cmd2=0x03, preset_id=preset_id)
  146. elif target in cam_transports:
  147. send_preset_command(target, cmd2=0x03, preset_id=preset_id)
  148. else:
  149. return web.json_response(
  150. {"error": f"Invalid target: {target}"}, status=400
  151. )
  152. return web.json_response(
  153. {
  154. "status": "ok",
  155. "action": "save",
  156. "preset": preset_id,
  157. "target": target,
  158. }
  159. )
  160. async def handle_set_mode(request):
  161. global current_mode
  162. mode = request.query.get("mode")
  163. if mode not in ("preview", "program"):
  164. return web.json_response({"error": "Invalid mode"}, status=400)
  165. current_mode = mode
  166. return web.json_response({"status": "ok", "mode": current_mode})
  167. async def handle_get_mode(request):
  168. return web.json_response({"mode": current_mode})
  169. async def start_http_server():
  170. app = web.Application()
  171. app.router.add_get("/target/get", handle_status)
  172. app.router.add_post("/target/set", handle_set_target)
  173. app.router.add_post("/preset/goto", handle_goto_preset)
  174. app.router.add_post("/preset/save", handle_save_preset)
  175. app.router.add_get("/mode/get", handle_get_mode)
  176. app.router.add_post("/mode/set", handle_set_mode)
  177. runner = web.AppRunner(app)
  178. await runner.setup()
  179. site = web.TCPSite(runner, "0.0.0.0", 1423)
  180. await site.start()
  181. async def main():
  182. global cam_transports
  183. loop = asyncio.get_running_loop()
  184. def forward_packet(packet):
  185. if current_target in cam_transports:
  186. enqueue_write(current_target, packet)
  187. else:
  188. print(f"[WARN] No transport for {current_target}")
  189. # Connect to cameras
  190. cam1_transport, _ = await serial_asyncio.create_serial_connection(
  191. loop, DummyCamProtocol, CAM1_PORT, baudrate=BAUDRATE
  192. )
  193. cam2_transport, _ = await serial_asyncio.create_serial_connection(
  194. loop, DummyCamProtocol, CAM2_PORT, baudrate=BAUDRATE
  195. )
  196. cam_transports = {"cam1": cam1_transport, "cam2": cam2_transport}
  197. asyncio.create_task(writer_task())
  198. await serial_asyncio.create_serial_connection(
  199. loop,
  200. lambda: JoystickProtocol(forward_packet),
  201. JOYSTICK_PORT,
  202. baudrate=BAUDRATE,
  203. )
  204. asyncio.create_task(start_http_server())
  205. # Wait forever
  206. await asyncio.Event().wait()
  207. if __name__ == "__main__":
  208. asyncio.run(main())