ptzsim 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ptzsim/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2026 Stephan Strittmatter
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """PTZ camera simulator speaking ONVIF, VISCA (serial, raw TCP/UDP, Sony VISCA over IP) and NDI."""
5
+
6
+ __version__ = "0.2.0"
ptzsim/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ # SPDX-FileCopyrightText: 2026 Stephan Strittmatter
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ import sys
5
+
6
+ from .cli import main
7
+
8
+ sys.exit(main())
ptzsim/cli.py ADDED
@@ -0,0 +1,221 @@
1
+ # SPDX-FileCopyrightText: 2026 Stephan Strittmatter
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Command line entry point: ``ptzsim serve`` and ``ptzsim send``."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import asyncio
10
+ import logging
11
+ import socket
12
+ import sys
13
+ from typing import List, Optional
14
+
15
+ from .display import dashboard, log_printer
16
+ from .transports import (SONY_HEADER, SONY_VISCA_COMMAND, SONY_VISCA_INQUIRY,
17
+ Simulator, serve_pty, serve_tcp, serve_udp)
18
+ from .onvif import OnvifCamera, serve_discovery, serve_onvif
19
+ from .visca import ViscaCamera, hexdump, parse_hex
20
+
21
+ DEFAULT_PORTS = {"sony": 52381, "tcp": 5678, "udp": 1259, "onvif": 8080}
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(prog="ptzsim", description="PTZ camera simulator (ONVIF, VISCA and NDI)")
26
+ sub = parser.add_subparsers(dest="command", required=True)
27
+
28
+ serve = sub.add_parser("serve", help="run a simulated camera")
29
+ serve.add_argument("--host", default="0.0.0.0", help="address to listen on (default 0.0.0.0)")
30
+ serve.add_argument("--sony", type=int, metavar="PORT", nargs="?", const=DEFAULT_PORTS["sony"],
31
+ help="Sony VISCA over IP on UDP (default port 52381)")
32
+ serve.add_argument("--tcp", type=int, metavar="PORT", nargs="?", const=DEFAULT_PORTS["tcp"],
33
+ help="raw VISCA over TCP, e.g. PTZOptics (default port 5678)")
34
+ serve.add_argument("--udp", type=int, metavar="PORT", nargs="?", const=DEFAULT_PORTS["udp"],
35
+ help="raw VISCA over UDP (default port 1259)")
36
+ serve.add_argument("--pty", nargs="?", const="", metavar="LINK",
37
+ help="emulate a serial port via a pseudo terminal; optional symlink path")
38
+ serve.add_argument("--onvif", type=int, metavar="PORT", nargs="?", const=DEFAULT_PORTS["onvif"],
39
+ help="ONVIF (SOAP over HTTP) with WS-Discovery (default port 8080)")
40
+ serve.add_argument("--onvif-user", metavar="USER", help="require authentication (see --onvif-auth) with this user")
41
+ serve.add_argument("--onvif-password", metavar="PASSWORD", default="", help="password for --onvif-user")
42
+ serve.add_argument("--ndi", action="store_true",
43
+ help="publish an NDI source with PTZ control (needs: pip install ndi-python numpy)")
44
+ serve.add_argument("--ndi-fps", type=int, default=25, metavar="FPS", help="NDI frame rate (default 25)")
45
+ serve.add_argument("--name", default="PTZ-Simulator", help="camera name (ONVIF scope/profile, NDI source name)")
46
+ serve.add_argument("--no-discovery", action="store_true", help="do not answer WS-Discovery probes")
47
+ serve.add_argument("--advertise-host", metavar="IP",
48
+ help="address announced in WS-Discovery (default: detected per request)")
49
+ serve.add_argument("--onvif-auth", choices=("both", "digest", "wsse"), default="both",
50
+ help="ONVIF authentication: HTTP digest and/or WS-UsernameToken (default both)")
51
+ serve.add_argument("--onvif-no-absolute", action="store_true",
52
+ help="simulate a camera without AbsoluteMove (presets only via SetPreset/GotoPreset)")
53
+ serve.add_argument("--rtsp-uri", metavar="URI", help="stream URI returned by ONVIF GetStreamUri")
54
+ serve.add_argument("--address", type=int, default=1, choices=range(1, 8),
55
+ help="VISCA camera address (default 1)")
56
+ serve.add_argument("--instant", action="store_true",
57
+ help="jump to target positions instead of moving at motor speed")
58
+ serve.add_argument("--no-tui", action="store_true", help="print one line per command instead of a dashboard")
59
+ serve.add_argument("--view-only", action="store_true",
60
+ help="show the dashboard without keyboard control")
61
+ serve.add_argument("-v", "--verbose", action="store_true", help="log every packet sent and received")
62
+
63
+ send = sub.add_parser("send", help="send VISCA hex packets to a camera and print the replies")
64
+ send.add_argument("packets", nargs="+", help='VISCA packets as hex, e.g. "81 01 06 04 FF"')
65
+ send.add_argument("--host", default="127.0.0.1")
66
+ send.add_argument("--port", type=int)
67
+ send.add_argument("--proto", choices=("sony", "tcp", "udp"), default="sony")
68
+ send.add_argument("--timeout", type=float, default=2.0, help="seconds to wait for replies")
69
+ return parser
70
+
71
+
72
+ async def _serve(args: argparse.Namespace) -> None:
73
+ camera = ViscaCamera(address=args.address, instant=args.instant)
74
+ sim = Simulator(camera)
75
+ endpoints: List[str] = []
76
+ discovery = None
77
+
78
+ if all(v is None for v in (args.sony, args.tcp, args.udp, args.pty, args.onvif)) and not args.ndi:
79
+ args.sony = DEFAULT_PORTS["sony"]
80
+ if args.sony is not None:
81
+ await serve_udp(sim, args.host, args.sony, sony=True)
82
+ endpoints.append(f"Sony VISCA/IP udp://{args.host}:{args.sony}")
83
+ if args.tcp is not None:
84
+ await serve_tcp(sim, args.host, args.tcp)
85
+ endpoints.append(f"VISCA tcp://{args.host}:{args.tcp}")
86
+ if args.udp is not None:
87
+ await serve_udp(sim, args.host, args.udp, sony=False)
88
+ endpoints.append(f"VISCA udp://{args.host}:{args.udp}")
89
+ if args.pty is not None:
90
+ endpoints.append(f"VISCA serial {serve_pty(sim, args.pty or None)}")
91
+ if args.onvif is not None:
92
+ onvif = OnvifCamera(camera, name=args.name, username=args.onvif_user,
93
+ password=args.onvif_password, rtsp_uri=args.rtsp_uri,
94
+ absolute=not args.onvif_no_absolute, auth=args.onvif_auth)
95
+ await serve_onvif(onvif, args.host, args.onvif)
96
+ endpoints.append(f"ONVIF http://{args.host}:{args.onvif}/onvif/device_service")
97
+ if not args.no_discovery:
98
+ discovery = await serve_discovery(onvif, args.onvif, advertise_host=args.advertise_host)
99
+ endpoints.append("WS-Discovery udp/3702")
100
+ if args.ndi:
101
+ from .ndi import start_ndi
102
+
103
+ source = start_ndi(camera, args.name, args.ndi_fps)
104
+ endpoints.append(f"NDI {source.source_name}")
105
+
106
+ title = " | ".join(endpoints)
107
+ logging.getLogger("ptzsim").info("listening: %s", title)
108
+ if args.no_tui:
109
+ view = log_printer(camera)
110
+ elif args.view_only or not _interactive_terminal():
111
+ view = dashboard(camera, title)
112
+ else:
113
+ from .tui import interactive
114
+
115
+ view = interactive(camera, title)
116
+ tasks = [asyncio.ensure_future(sim.run()), asyncio.ensure_future(view)]
117
+ try:
118
+ done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
119
+ for task in pending:
120
+ task.cancel()
121
+ for task in done:
122
+ task.result() # re-raise errors from the simulator or the view
123
+ finally:
124
+ if discovery is not None:
125
+ discovery.get_protocol().bye() # WS-Discovery Bye when leaving the network
126
+
127
+
128
+ def _interactive_terminal() -> bool:
129
+ """The keyboard UI needs curses (not on Windows) and a terminal on stdin and stdout."""
130
+ try:
131
+ import curses # noqa: F401
132
+ except ImportError:
133
+ return False
134
+ return sys.stdin.isatty() and sys.stdout.isatty()
135
+
136
+
137
+ def send_packets(packets: List[bytes], host: str, port: int, proto: str, timeout: float) -> List[bytes]:
138
+ """Send packets to a camera and return all replies received (helper for manual tests)."""
139
+ replies: List[bytes] = []
140
+ if proto == "tcp":
141
+ with socket.create_connection((host, port), timeout=timeout) as sock:
142
+ for packet in packets:
143
+ sock.sendall(packet)
144
+ replies += _collect_stream(sock, timeout)
145
+ return replies
146
+
147
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
148
+ sock.settimeout(timeout)
149
+ for seq, packet in enumerate(packets, start=1):
150
+ if proto == "sony":
151
+ kind = SONY_VISCA_INQUIRY if len(packet) > 1 and packet[1] == 0x09 else SONY_VISCA_COMMAND
152
+ packet = SONY_HEADER.pack(kind, len(packet), seq) + packet
153
+ sock.sendto(packet, (host, port))
154
+ replies += _collect_datagrams(sock, proto == "sony")
155
+ return replies
156
+
157
+
158
+ def _collect_stream(sock: socket.socket, timeout: float) -> List[bytes]:
159
+ buffer = bytearray()
160
+ replies: List[bytes] = []
161
+ while True:
162
+ try:
163
+ data = sock.recv(1024)
164
+ except socket.timeout:
165
+ break
166
+ if not data:
167
+ break
168
+ buffer.extend(data)
169
+ while 0xFF in buffer:
170
+ end = buffer.index(0xFF)
171
+ replies.append(bytes(buffer[: end + 1]))
172
+ del buffer[: end + 1]
173
+ if replies and _is_final(replies[-1]):
174
+ break
175
+ return replies
176
+
177
+
178
+ def _collect_datagrams(sock: socket.socket, sony: bool) -> List[bytes]:
179
+ replies: List[bytes] = []
180
+ while True:
181
+ try:
182
+ data, _ = sock.recvfrom(1024)
183
+ except socket.timeout:
184
+ break
185
+ if sony:
186
+ data = data[SONY_HEADER.size:]
187
+ replies.append(data)
188
+ if _is_final(data):
189
+ break
190
+ return replies
191
+
192
+
193
+ def _is_final(reply: bytes) -> bool:
194
+ """Completion, inquiry answer or error ends a request; an ACK (y0 4z) does not."""
195
+ return len(reply) >= 2 and reply[1] & 0xF0 in (0x50, 0x60) or reply[:1] == b"\x88"
196
+
197
+
198
+ def main(argv: Optional[List[str]] = None) -> int:
199
+ args = build_parser().parse_args(argv)
200
+ if args.command == "send":
201
+ port = args.port or DEFAULT_PORTS[args.proto]
202
+ packets = [parse_hex(p) for p in args.packets]
203
+ for reply in send_packets(packets, args.host, port, args.proto, args.timeout):
204
+ print(hexdump(reply))
205
+ return 0
206
+
207
+ level = logging.DEBUG if args.verbose else logging.INFO
208
+ logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(message)s",
209
+ stream=sys.stderr if args.no_tui else open("ptzsim.log", "a"))
210
+ try:
211
+ asyncio.run(_serve(args))
212
+ except KeyboardInterrupt:
213
+ pass
214
+ except (RuntimeError, OSError) as err:
215
+ print(f"ptzsim: {err}", file=sys.stderr)
216
+ return 1
217
+ return 0
218
+
219
+
220
+ if __name__ == "__main__":
221
+ sys.exit(main())
ptzsim/display.py ADDED
@@ -0,0 +1,102 @@
1
+ # SPDX-FileCopyrightText: 2026 Stephan Strittmatter
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Terminal dashboard showing the simulated camera."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import sys
10
+ from typing import List
11
+
12
+ from . import state as st
13
+ from .visca import ViscaCamera
14
+
15
+ CLEAR = "\x1b[H\x1b[2J"
16
+ HIDE_CURSOR, SHOW_CURSOR = "\x1b[?25l", "\x1b[?25h"
17
+ GREEN, RED, DIM, BOLD, RESET = "\x1b[32m", "\x1b[31m", "\x1b[2m", "\x1b[1m", "\x1b[0m"
18
+
19
+
20
+ def bar(value: float, low: float, high: float, width: int = 30, center: bool = False) -> str:
21
+ pos = round((value - low) / (high - low) * (width - 1))
22
+ cells = ["─"] * width
23
+ if center:
24
+ zero = round((0 - low) / (high - low) * (width - 1))
25
+ cells[zero] = "┼"
26
+ cells[max(0, min(width - 1, pos))] = "●"
27
+ return "[" + "".join(cells) + "]"
28
+
29
+
30
+ def render(camera: ViscaCamera, title: str, color: bool = True, log_lines: int = 10) -> str:
31
+ s = camera.state
32
+ g, r, d, b, x = (GREEN, RED, DIM, BOLD, RESET) if color else ("",) * 5
33
+ pan, tilt, zoom, focus = s.position()
34
+
35
+ def motion(velocity: float, target) -> str:
36
+ if target is not None:
37
+ return f"{g}→ {round(target[0])}{x}"
38
+ if velocity:
39
+ return f"{g}{'▶' if velocity > 0 else '◀'} {abs(velocity):.0f}/s{x}"
40
+ return f"{d}idle{x}"
41
+
42
+ lines: List[str] = [
43
+ f"{b}PTZ camera simulator{x} {d}{title}{x}",
44
+ f"Power: {g + 'ON' if s.power else r + 'STANDBY'}{x} "
45
+ f"Address: {camera.address} Commands: {camera.command_count}",
46
+ "",
47
+ f"Pan {pan:6d} {s.pan_degrees:+7.1f}° "
48
+ f"{bar(s.pan, st.PAN_MIN, st.PAN_MAX, center=True)} {motion(s.pan_velocity, s.pan_target)}",
49
+ f"Tilt {tilt:6d} {s.tilt_degrees:+7.1f}° "
50
+ f"{bar(s.tilt, st.TILT_MIN, st.TILT_MAX, center=True)} {motion(s.tilt_velocity, s.tilt_target)}",
51
+ f"Zoom {zoom:#06x} {s.zoom_ratio:7.1f}x "
52
+ f"{bar(s.zoom, st.ZOOM_MIN, st.ZOOM_MAX)} {motion(s.zoom_velocity, s.zoom_target)}",
53
+ f"Focus {focus:#06x} {'AF' if s.autofocus else 'MF':>8} "
54
+ f"{bar(s.focus, st.FOCUS_MIN, st.FOCUS_MAX)} {motion(s.focus_velocity, s.focus_target)}",
55
+ f"White balance {s.white_balance:<12} R {s.red_gain:#04x} B {s.blue_gain:#04x}",
56
+ "",
57
+ ]
58
+ if s.presets:
59
+ lines.append(f"{b}Presets{x}")
60
+ for number, p in sorted(s.presets.items()):
61
+ marker = "*" if number == s.last_preset else " "
62
+ lines.append(f" {marker}{number + 1:3d}: pan {p.pan:6d} tilt {p.tilt:6d} "
63
+ f"zoom {p.zoom:#06x} focus {p.focus:#06x}"
64
+ + (f" {p.name}" if p.name else ""))
65
+ else:
66
+ lines.append(f"{d}No presets stored{x}")
67
+ lines += ["", f"{b}Last commands{x}"]
68
+ entries = list(camera.log)[-log_lines:]
69
+ if not entries:
70
+ lines.append(f"{d}(waiting for commands){x}")
71
+ for entry in entries:
72
+ c = "" if entry.ok else r
73
+ lines.append(f" {d}{entry.source:<44}{x} {c}{entry.description}{x}")
74
+ lines += ["", f"{d}Ctrl+C to quit{x}"]
75
+ return "\n".join(lines)
76
+
77
+
78
+ async def dashboard(camera: ViscaCamera, title: str, interval: float = 0.1) -> None:
79
+ out = sys.stdout
80
+ out.write(HIDE_CURSOR)
81
+ try:
82
+ while True:
83
+ out.write(CLEAR + render(camera, title) + "\n")
84
+ out.flush()
85
+ await asyncio.sleep(interval)
86
+ finally:
87
+ out.write(SHOW_CURSOR)
88
+ out.flush()
89
+
90
+
91
+ async def log_printer(camera: ViscaCamera, interval: float = 0.1) -> None:
92
+ """Plain line output for non-interactive use (``--no-tui``)."""
93
+ seen = 0
94
+ while True:
95
+ total = camera.command_count
96
+ new = list(camera.log)[-(total - seen):] if total > seen else []
97
+ seen = total
98
+ for entry in new:
99
+ pan, tilt, zoom, focus = camera.state.position()
100
+ print(f"{entry.source:<44} {entry.description:<36} "
101
+ f"pan={pan} tilt={tilt} zoom={zoom:#06x}", flush=True)
102
+ await asyncio.sleep(interval)
ptzsim/ndi.py ADDED
@@ -0,0 +1,285 @@
1
+ # SPDX-FileCopyrightText: 2026 Stephan Strittmatter
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """NDI PTZ camera: an NDI source with PTZ support and a rendered test picture.
5
+
6
+ NDI receivers send PTZ commands as XML metadata (``<ntk_ptz_pan_tilt .../>`` and
7
+ friends) back to the sender. :class:`NdiPtzController` turns those into camera
8
+ moves; it is plain Python. :class:`NdiSource` needs the NDI runtime via the
9
+ ``ndi-python`` package (``pip install ndi-python numpy``).
10
+
11
+ Value ranges follow the NDI SDK (Processing.NDI.Recv.ex.h):
12
+ pan -1 (left) .. +1 (right) pan_speed -1 (moving right) .. +1 (moving left)
13
+ tilt -1 (bottom) .. +1 (top) tilt_speed -1 (down) .. +1 (up)
14
+ zoom 0 (zoomed in) .. 1 (out) zoom_speed -1 (outwards) .. +1 (inwards)
15
+ focus 0 (infinity) .. 1 (close) presets 0 .. 99, recall speed 0 .. 1
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+ import socket
23
+ import threading
24
+ import xml.etree.ElementTree as ET
25
+ from typing import Callable, Optional
26
+
27
+ from . import state as st
28
+ from .onvif import tilt_from_onvif
29
+ from .visca import ViscaCamera
30
+
31
+ log = logging.getLogger("ptzsim")
32
+
33
+ # Connection metadata a sender sends so receivers report PTZ support.
34
+ CAPABILITIES = ('<ndi_capabilities ntk_ptz="true" ntk_pan_tilt="true" ntk_zoom="true" '
35
+ 'ntk_focus="true" ntk_autofocus="true" ntk_white_balance="true" '
36
+ 'ntk_preset_speed="true"/>')
37
+ PRESET_RANGE = range(0, 100) # NDI SDK: "The preset number is in the range 0 to 99"
38
+
39
+
40
+ class NdiPtzController:
41
+ """Applies NDI PTZ metadata messages to the simulated camera."""
42
+
43
+ def __init__(self, camera: ViscaCamera):
44
+ self.camera = camera
45
+
46
+ @property
47
+ def state(self) -> st.CameraState:
48
+ return self.camera.state
49
+
50
+ def handle(self, xml: str) -> bool:
51
+ """Handle one metadata frame. Returns False if it was not understood."""
52
+ try:
53
+ elem = ET.fromstring(xml)
54
+ except ET.ParseError:
55
+ self.camera.record("NDI", f"invalid metadata {xml[:40]!r}", ok=False)
56
+ return False
57
+ handler = getattr(self, "_" + elem.tag, None)
58
+ if handler is None or not elem.tag.startswith("ntk_ptz"):
59
+ self.camera.record("NDI", f"unsupported metadata <{elem.tag}>", ok=False)
60
+ return False
61
+ try:
62
+ description = handler(elem.attrib)
63
+ except (KeyError, ValueError) as err:
64
+ self.camera.record("NDI", f"<{elem.tag}> bad attribute: {err}", ok=False)
65
+ return False
66
+ if self.camera.instant:
67
+ self.state.finish_moves()
68
+ self.camera.record("NDI", description)
69
+ return True
70
+
71
+ @staticmethod
72
+ def _num(attrs, name: str, low: float, high: float) -> float:
73
+ return st.clamp(float(attrs[name]), low, high)
74
+
75
+ # ------------------------------------------------------------ pan/tilt
76
+ def _ntk_ptz_pan_tilt(self, a) -> str:
77
+ pan, tilt = self._num(a, "pan", -1, 1), self._num(a, "tilt", -1, 1)
78
+ self.state.move_pan_tilt_to(pan * st.PAN_MAX, tilt_from_onvif(tilt),
79
+ st.PAN_SPEED_MAX, st.TILT_SPEED_MAX)
80
+ return f"pan/tilt to {pan:+.2f} {tilt:+.2f}"
81
+
82
+ def _ntk_ptz_pan_tilt_speed(self, a) -> str:
83
+ pan, tilt = self._num(a, "pan_speed", -1, 1), self._num(a, "tilt_speed", -1, 1)
84
+ s = self.state
85
+ s.pan_target = s.tilt_target = None
86
+ # NDI: positive pan speed moves left, i.e. towards negative pan.
87
+ s.pan_velocity = -pan * st.PAN_SPEED_MAX * st.PAN_TILT_UNITS_PER_SPEED_STEP
88
+ s.tilt_velocity = tilt * st.TILT_SPEED_MAX * st.PAN_TILT_UNITS_PER_SPEED_STEP
89
+ return "pan/tilt stop" if pan == tilt == 0 else f"pan/tilt speed {pan:+.2f} {tilt:+.2f}"
90
+
91
+ # ---------------------------------------------------------------- zoom
92
+ def _ntk_ptz_zoom(self, a) -> str:
93
+ zoom = self._num(a, "zoom", 0, 1)
94
+ self.state.zoom_to(st.ZOOM_MIN + (1 - zoom) * (st.ZOOM_MAX - st.ZOOM_MIN))
95
+ return f"zoom to {zoom:.2f}"
96
+
97
+ def _ntk_ptz_zoom_speed(self, a) -> str:
98
+ speed = self._num(a, "zoom_speed", -1, 1)
99
+ self.state.zoom_target = None
100
+ self.state.zoom_velocity = speed * 8 * st.ZOOM_UNITS_PER_SPEED_STEP
101
+ return "zoom stop" if speed == 0 else f"zoom speed {speed:+.2f}"
102
+
103
+ # --------------------------------------------------------------- focus
104
+ def _ntk_ptz_focus(self, a) -> str:
105
+ if a.get("mode") == "auto":
106
+ self.state.autofocus = True
107
+ return "auto focus"
108
+ distance = self._num(a, "distance", 0, 1)
109
+ self.state.autofocus = False
110
+ self.state.focus_to(st.FOCUS_MIN + distance * (st.FOCUS_MAX - st.FOCUS_MIN))
111
+ return f"focus to {distance:.2f}"
112
+
113
+ def _ntk_ptz_focus_speed(self, a) -> str:
114
+ speed = self._num(a, "distance", -1, 1)
115
+ self.state.autofocus = False
116
+ self.state.focus_target = None
117
+ self.state.focus_velocity = speed * 8 * st.FOCUS_UNITS_PER_SPEED_STEP
118
+ return "focus stop" if speed == 0 else f"focus speed {speed:+.2f}"
119
+
120
+ # ------------------------------------------------------------- presets
121
+ def _preset(self, a) -> int:
122
+ number = int(a["index"])
123
+ if number not in PRESET_RANGE:
124
+ raise ValueError(f"preset {number} out of the NDI range 0..99")
125
+ if number >= st.PRESET_COUNT:
126
+ raise ValueError(f"preset {number}: the camera has {st.PRESET_COUNT} presets")
127
+ return number
128
+
129
+ def _ntk_ptz_store_preset(self, a) -> str:
130
+ number = self._preset(a)
131
+ self.state.save_preset(number)
132
+ return f"store preset {number}"
133
+
134
+ def _ntk_ptz_recall_preset(self, a) -> str:
135
+ number = self._preset(a)
136
+ speed = self._num(a, "speed", 0, 1) if "speed" in a else 1.0
137
+ if not self.state.recall_preset(number, speed):
138
+ raise ValueError(f"preset {number} not set")
139
+ return f"recall preset {number} (speed {speed:.2f})"
140
+
141
+ # ------------------------------------------- accepted but not simulated
142
+ def _ignored(self, name: str, a) -> str:
143
+ detail = " ".join(f"{k}={v}" for k, v in a.items())
144
+ return f"{name} {detail} (not simulated)".replace(" ", " ")
145
+
146
+ def _ntk_ptz_white_balance(self, a) -> str:
147
+ mode = a.get("mode")
148
+ if mode in ("auto", "indoor", "outdoor"):
149
+ self.state.set_white_balance(mode)
150
+ return f"white balance {mode}"
151
+ if mode == "one_shot":
152
+ self.state.one_push_white_balance()
153
+ return "white balance one shot"
154
+ if mode == "manual":
155
+ red, blue = self._num(a, "red", 0, 1), self._num(a, "blue", 0, 1)
156
+ self.state.set_white_balance("manual")
157
+ self.state.set_gains(round(red * 256), round(blue * 256)) # NDI 0.5 is neutral (0x80)
158
+ return f"white balance manual red {red:.2f} blue {blue:.2f}"
159
+ return self._ignored("white balance", a)
160
+
161
+ def _ntk_ptz_exposure(self, a) -> str:
162
+ return self._ignored("exposure", a)
163
+
164
+ def _ntk_ptz_iris(self, a) -> str:
165
+ return self._ignored("iris", a)
166
+
167
+ def _ntk_ptz_flip(self, a) -> str:
168
+ return self._ignored("flip", a)
169
+
170
+
171
+ # ---------------------------------------------------------------- picture
172
+ class SceneRenderer:
173
+ """Draws what the camera "sees": a panorama grid that follows pan/tilt/zoom."""
174
+
175
+ BASE_FOV = 60.0 # horizontal field of view at 1x, degrees
176
+
177
+ def __init__(self, width: int = 640, height: int = 360):
178
+ import numpy as np
179
+
180
+ self.np = np
181
+ self.width, self.height = width, height
182
+ self.u = (np.arange(width, dtype=np.float32) / width - 0.5)[None, :]
183
+ self.v = (np.arange(height, dtype=np.float32) / height - 0.5)[:, None]
184
+
185
+ def render(self, s: st.CameraState):
186
+ np = self.np
187
+ fov = self.BASE_FOV / s.zoom_ratio
188
+ az = s.pan_degrees + self.u * fov
189
+ el = s.tilt_degrees - self.v * fov * self.height / self.width
190
+ checker = ((np.floor(az / 5) + np.floor(el / 5)) % 2).astype(np.float32)
191
+ sky = el > 0
192
+ frame = np.empty((self.height, self.width, 4), dtype=np.uint8)
193
+ # BGRX: sky blue-ish, ground green-ish, checker pattern every 5 degrees.
194
+ frame[..., 0] = np.where(sky, 150 + 60 * checker, 40 + 30 * checker)
195
+ frame[..., 1] = np.where(sky, 90 + 40 * checker, 110 + 50 * checker)
196
+ frame[..., 2] = np.where(sky, 40 + 20 * checker, 50 + 30 * checker)
197
+ frame[..., 3] = 255
198
+ line = fov / self.width * 1.5
199
+ # Fine 1-degree grid once zoomed in far enough to see it.
200
+ if fov / self.width < 1 / 16:
201
+ fine = (np.abs(((az + 0.5) % 1) - 0.5) < line / 2) | (np.abs(((el + 0.5) % 1) - 0.5) < line / 2)
202
+ frame[np.broadcast_to(fine, frame.shape[:2]), :3] //= 2
203
+ # Landmarks: red posts every 45 degrees of pan, white horizon.
204
+ post = np.abs(((az + 22.5) % 45) - 22.5) < line * 2
205
+ frame[np.broadcast_to(post, frame.shape[:2])] = (40, 40, 220, 255)
206
+ frame[np.broadcast_to(np.abs(el) < line, frame.shape[:2])] = (255, 255, 255, 255)
207
+ # Centre crosshair.
208
+ cx, cy = self.width // 2, self.height // 2
209
+ frame[cy, cx - 12:cx + 13] = (0, 255, 255, 255)
210
+ frame[cy - 12:cy + 13, cx] = (0, 255, 255, 255)
211
+ return frame
212
+
213
+
214
+ # ------------------------------------------------------------- NDI source
215
+ class NdiSource:
216
+ """Publishes the camera as an NDI source and executes received PTZ commands."""
217
+
218
+ def __init__(self, camera: ViscaCamera, name: str, fps: int = 25,
219
+ width: int = 640, height: int = 360):
220
+ try:
221
+ import NDIlib # noqa: F401 (ndi-python)
222
+ except ImportError as err:
223
+ raise RuntimeError("NDI needs the ndi-python package: pip install ndi-python numpy") from err
224
+ self.camera = camera
225
+ self.name = name
226
+ self.fps = fps
227
+ self.renderer = SceneRenderer(width, height)
228
+ self.controller = NdiPtzController(camera)
229
+ self._stop = threading.Event()
230
+ self._thread: Optional[threading.Thread] = None
231
+ self.source_name: Optional[str] = None
232
+
233
+ def start(self, dispatch: Callable[[Callable[[], None]], None]) -> None:
234
+ """Start the sender thread. PTZ commands are handed to ``dispatch`` so
235
+ they run on the simulator's event loop."""
236
+ import NDIlib as ndi
237
+
238
+ if not ndi.initialize():
239
+ raise RuntimeError("NDI runtime could not be initialised (CPU not supported?)")
240
+ settings = ndi.SendCreate(self.name, clock_video=True, clock_audio=False)
241
+ sender = ndi.send_create(settings)
242
+ if sender is None:
243
+ raise RuntimeError("could not create NDI sender")
244
+ caps = ndi.MetadataFrame()
245
+ caps.data = CAPABILITIES
246
+ ndi.send_add_connection_metadata(sender, caps)
247
+ try:
248
+ self.source_name = ndi.send_get_source_name(sender).ndi_name
249
+ except TypeError: # some ndi-python builds cannot convert the return value
250
+ self.source_name = f"{socket.gethostname().split('.')[0].upper()} ({self.name})"
251
+ self._thread = threading.Thread(target=self._run, args=(ndi, sender, dispatch),
252
+ name="ndi-sender", daemon=True)
253
+ self._thread.start()
254
+
255
+ def stop(self) -> None:
256
+ self._stop.set()
257
+ if self._thread is not None:
258
+ self._thread.join(timeout=2)
259
+
260
+ def _run(self, ndi, sender, dispatch) -> None:
261
+ frame = ndi.VideoFrameV2()
262
+ frame.FourCC = ndi.FOURCC_VIDEO_TYPE_BGRX
263
+ frame.frame_rate_N, frame.frame_rate_D = self.fps, 1
264
+ try:
265
+ while not self._stop.is_set():
266
+ frame.data = self.renderer.render(self.camera.state)
267
+ ndi.send_send_video_v2(sender, frame) # blocks to the frame rate
268
+ while True:
269
+ metadata = ndi.MetadataFrame()
270
+ ndi.send_capture(sender, metadata, 0)
271
+ if not metadata.data:
272
+ break
273
+ xml = metadata.data
274
+ ndi.send_free_metadata(sender, metadata)
275
+ log.debug("NDI metadata %s", xml)
276
+ dispatch(lambda xml=xml: self.controller.handle(xml))
277
+ finally:
278
+ ndi.send_destroy(sender)
279
+
280
+
281
+ def start_ndi(camera: ViscaCamera, name: str, fps: int = 25) -> NdiSource:
282
+ loop = asyncio.get_running_loop()
283
+ source = NdiSource(camera, name, fps)
284
+ source.start(loop.call_soon_threadsafe)
285
+ return source